import React from 'react'; import type { ComponentType } from 'react'; import type { Part } from '@opencode-ai/sdk/v2'; import { RiArrowDownSLine, RiArrowRightSLine, RiBrainAi3Line, RiChatAi3Line } from '@remixicon/react'; import { cn } from '@/lib/utils'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; type PartWithText = Part & { text?: string; content?: string }; export type ReasoningVariant = 'thinking' | 'justification'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type IconComponent = ComponentType; const variantConfig: Record< ReasoningVariant, { label: string; Icon: IconComponent } > = { thinking: { label: 'Thinking', Icon: RiBrainAi3Line }, justification: { label: 'Justification', Icon: RiChatAi3Line }, }; const cleanReasoningText = (text: string): string => { if (typeof text !== 'string' || text.trim().length === 0) { return ''; } return text .split('\n') .map((line: string) => line.replace(/^>\s?/, '').trimEnd()) .filter((line: string) => line.trim().length > 0) .join('\n') .trim(); }; const getReasoningSummary = (text: string): string => { if (!text) { return ''; } const trimmed = text.trim(); const newlineIndex = trimmed.indexOf('\n'); const periodIndex = trimmed.indexOf('.'); const cutoffCandidates = [ newlineIndex >= 0 ? newlineIndex : Infinity, periodIndex >= 0 ? periodIndex : Infinity, ]; const cutoff = Math.min(...cutoffCandidates); if (!Number.isFinite(cutoff)) { return trimmed; } return trimmed.substring(0, cutoff).trim(); }; type ReasoningTimelineBlockProps = { text: string; variant: ReasoningVariant; onContentChange?: (reason?: ContentChangeReason) => void; blockId: string; }; export const ReasoningTimelineBlock: React.FC = ({ text, variant, onContentChange, blockId, }) => { const [isExpanded, setIsExpanded] = React.useState(false); const summary = React.useMemo(() => getReasoningSummary(text), [text]); const { label, Icon } = variantConfig[variant]; React.useEffect(() => { if (text.trim().length === 0) { return; } onContentChange?.('structural'); }, [onContentChange, isExpanded, text]); if (!text || text.trim().length === 0) { return null; } return (
setIsExpanded((prev) => !prev)} >
{isExpanded ? : }
{label}
{summary && (
{summary}
)}
{isExpanded && (
{text}
)}
); }; type ReasoningPartProps = { part: Part; onContentChange?: (reason?: ContentChangeReason) => void; messageId: string; }; const ReasoningPart: React.FC = ({ part, onContentChange, messageId, }) => { const partWithText = part as PartWithText; const rawText = partWithText.text || partWithText.content || ''; const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]); const timeInfo = 'time' in part ? (part.time as { start: number; end?: number }) : null; if (!timeInfo?.end) { return null; } return ( ); }; // eslint-disable-next-line react-refresh/only-export-components export const formatReasoningText = (text: string): string => cleanReasoningText(text); export default ReasoningPart;