import React from 'react'; import type { Part } from '@opencode-ai/sdk/v2'; import { cn } from '@/lib/utils'; import type { ContentChangeReason } from '@/hooks/useChatAutoFollow'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Icon } from "@/components/icon/Icon"; import type { IconName } from "@/components/icon/icons"; import { useUIStore } from '@/stores/useUIStore'; import { useDurationTickerNow } from './useDurationTicker'; import { MarkdownRenderer } from '../../MarkdownRenderer'; import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } }; export type ReasoningVariant = 'thinking' | 'justification'; const variantConfig: Record< ReasoningVariant, { label: string; Icon: IconName } > = { thinking: { label: 'Thinking', Icon: 'brain-ai-3' }, justification: { label: 'Justification', Icon: 'chat-ai-3' }, }; 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(); }; const formatDuration = (start: number, end?: number, now: number = Date.now()): string => { const duration = end ? end - start : now - start; const seconds = duration / 1000; const displaySeconds = seconds < 0.05 && end !== undefined ? 0.1 : seconds; return `${displaySeconds.toFixed(1)}s`; }; const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => { const now = useDurationTickerNow(active, 250); return <>{formatDuration(start, end, now)}; }; type ReasoningTimelineBlockProps = { text: string; variant: ReasoningVariant; onContentChange?: (reason?: ContentChangeReason) => void; blockId: string; time?: { start?: number; end?: number }; showDuration?: boolean; isStreaming?: boolean; actions?: React.ReactNode; alwaysShowActions?: boolean; }; export const ReasoningTimelineBlock: React.FC = ({ text, variant, onContentChange, blockId, time, showDuration = true, isStreaming = false, actions, alwaysShowActions = false, }) => { const [isExpanded, setIsExpanded] = React.useState(false); const summary = React.useMemo(() => getReasoningSummary(text), [text]); const { label, Icon: iconName } = variantConfig[variant]; const timeStart = typeof time?.start === 'number' && Number.isFinite(time.start) ? time.start : undefined; const timeEnd = typeof time?.end === 'number' && Number.isFinite(time.end) ? time.end : undefined; React.useEffect(() => { if (text.trim().length === 0) { return; } onContentChange?.('structural'); }, [onContentChange, text]); if (!text || text.trim().length === 0) { return null; } return (
setIsExpanded((prev) => !prev)} >
{isExpanded ? : }
{label}
{(summary || (showDuration && typeof timeStart === 'number')) ? (
{summary ? {summary} : null} {showDuration && typeof timeStart === 'number' ? ( ) : null}
) : null}
{isExpanded && (
{actions ? (
{actions}
) : null}
)}
); }; type ReasoningPartProps = { part: Part; onContentChange?: (reason?: ContentChangeReason) => void; messageId: string; alwaysShowActions?: boolean; }; const ReasoningPart = React.memo(({ part, onContentChange, messageId, alwaysShowActions = false, }: ReasoningPartProps) => { const chatRenderMode = useUIStore((state) => state.chatRenderMode); const partWithText = part as PartWithText; const rawText = partWithText.text || partWithText.content || ''; const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]); const time = partWithText.time; const isStreaming = chatRenderMode === 'live' && typeof time?.end !== 'number'; const throttledText = useStreamingTextThrottle({ text: textContent, isStreaming, identityKey: `${messageId}:${part.id ?? 'reasoning'}`, }); // Show reasoning even if time.end isn't set yet (during streaming) // Only hide if there's no text content if (!throttledText || throttledText.trim().length === 0) { return null; } return ( ); }); // eslint-disable-next-line react-refresh/only-export-components export const formatReasoningText = (text: string): string => cleanReasoningText(text); export default ReasoningPart;