2025-12-07 19:32:53 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
import type { ComponentType } from 'react';
|
2026-01-03 13:39:59 +02:00
|
|
|
import type { Part } from '@opencode-ai/sdk/v2';
|
2025-12-07 19:32:53 +02:00
|
|
|
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';
|
2026-03-01 01:21:01 +02:00
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
2026-03-20 01:01:03 +02:00
|
|
|
import { useDurationTickerNow } from './useDurationTicker';
|
2026-03-23 23:51:55 +02:00
|
|
|
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-02-28 15:16:31 -03:00
|
|
|
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
export type ReasoningVariant = 'thinking' | 'justification';
|
|
|
|
|
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
type IconComponent = ComponentType<any>;
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-28 15:16:31 -03:00
|
|
|
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 }) => {
|
2026-03-20 01:01:03 +02:00
|
|
|
const now = useDurationTickerNow(active, 250);
|
2026-02-28 15:16:31 -03:00
|
|
|
|
|
|
|
|
return <>{formatDuration(start, end, now)}</>;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
type ReasoningTimelineBlockProps = {
|
|
|
|
|
text: string;
|
|
|
|
|
variant: ReasoningVariant;
|
|
|
|
|
onContentChange?: (reason?: ContentChangeReason) => void;
|
|
|
|
|
blockId: string;
|
2026-02-28 15:16:31 -03:00
|
|
|
time?: { start?: number; end?: number };
|
2026-03-12 23:45:45 +02:00
|
|
|
showDuration?: boolean;
|
2026-03-23 23:51:55 +02:00
|
|
|
isStreaming?: boolean;
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|
|
|
|
text,
|
|
|
|
|
variant,
|
|
|
|
|
onContentChange,
|
|
|
|
|
blockId,
|
2026-02-28 15:16:31 -03:00
|
|
|
time,
|
2026-03-12 23:45:45 +02:00
|
|
|
showDuration = true,
|
2026-03-23 23:51:55 +02:00
|
|
|
isStreaming = false,
|
2025-12-07 19:32:53 +02:00
|
|
|
}) => {
|
|
|
|
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
|
|
|
|
|
|
|
|
|
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
|
|
|
|
|
const { label, Icon } = variantConfig[variant];
|
2026-02-28 15:16:31 -03:00
|
|
|
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;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (text.trim().length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
onContentChange?.('structural');
|
|
|
|
|
}, [onContentChange, isExpanded, text]);
|
|
|
|
|
|
|
|
|
|
if (!text || text.trim().length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="my-1" data-reasoning-block-id={blockId}>
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'group/tool flex items-center gap-2 pr-2 pl-px py-1.5 rounded-xl cursor-pointer'
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => setIsExpanded((prev) => !prev)}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
|
|
|
<div className="relative h-3.5 w-3.5 flex-shrink-0">
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'absolute inset-0 transition-opacity',
|
|
|
|
|
isExpanded && 'opacity-0',
|
|
|
|
|
!isExpanded && 'group-hover/tool:opacity-0'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<Icon className="h-3.5 w-3.5" />
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'absolute inset-0 transition-opacity flex items-center justify-center',
|
|
|
|
|
isExpanded && 'opacity-100',
|
|
|
|
|
!isExpanded && 'opacity-0 group-hover/tool:opacity-100'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{isExpanded ? <RiArrowDownSLine className="h-3.5 w-3.5" /> : <RiArrowRightSLine className="h-3.5 w-3.5" />}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="typography-meta font-medium">{label}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
{(summary || (showDuration && typeof timeStart === 'number')) ? (
|
2026-02-28 15:16:31 -03:00
|
|
|
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta text-muted-foreground/70">
|
2026-03-23 23:51:55 +02:00
|
|
|
{summary ? <span className="flex-1 min-w-0 truncate">{summary}</span> : null}
|
2026-03-12 23:45:45 +02:00
|
|
|
{showDuration && typeof timeStart === 'number' ? (
|
2026-03-01 01:21:01 +02:00
|
|
|
<span className="relative flex-shrink-0 tabular-nums text-right">
|
2026-03-12 23:45:45 +02:00
|
|
|
<span className="text-muted-foreground/80 transition-opacity duration-150">
|
2026-03-01 01:21:01 +02:00
|
|
|
<LiveDuration
|
|
|
|
|
start={timeStart}
|
|
|
|
|
end={timeEnd}
|
|
|
|
|
active={typeof timeEnd !== 'number'}
|
|
|
|
|
/>
|
|
|
|
|
</span>
|
2026-02-28 15:16:31 -03:00
|
|
|
</span>
|
|
|
|
|
) : null}
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
2026-02-28 15:16:31 -03:00
|
|
|
) : null}
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{isExpanded && (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-03-12 23:45:45 +02:00
|
|
|
'relative pr-2 pb-2 pt-2 pl-4'
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<ScrollableOverlay
|
2026-03-23 23:51:55 +02:00
|
|
|
as="div"
|
2025-12-07 19:32:53 +02:00
|
|
|
outerClassName="max-h-80"
|
2026-03-23 23:51:55 +02:00
|
|
|
className="p-0"
|
2025-12-07 19:32:53 +02:00
|
|
|
>
|
2026-03-23 23:51:55 +02:00
|
|
|
<MarkdownRenderer
|
|
|
|
|
content={text}
|
|
|
|
|
messageId={blockId}
|
|
|
|
|
isAnimated={false}
|
|
|
|
|
isStreaming={isStreaming}
|
|
|
|
|
variant="reasoning"
|
|
|
|
|
/>
|
2025-12-07 19:32:53 +02:00
|
|
|
</ScrollableOverlay>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type ReasoningPartProps = {
|
|
|
|
|
part: Part;
|
|
|
|
|
onContentChange?: (reason?: ContentChangeReason) => void;
|
|
|
|
|
messageId: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ReasoningPart: React.FC<ReasoningPartProps> = ({
|
|
|
|
|
part,
|
|
|
|
|
onContentChange,
|
|
|
|
|
messageId,
|
|
|
|
|
}) => {
|
2026-03-12 23:45:45 +02:00
|
|
|
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
2025-12-07 19:32:53 +02:00
|
|
|
const partWithText = part as PartWithText;
|
|
|
|
|
const rawText = partWithText.text || partWithText.content || '';
|
|
|
|
|
const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]);
|
2026-02-28 15:16:31 -03:00
|
|
|
const time = partWithText.time;
|
2026-03-23 23:51:55 +02:00
|
|
|
const isStreaming = chatRenderMode === 'live' && typeof time?.end !== 'number';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-02-06 02:30:00 -08:00
|
|
|
// Show reasoning even if time.end isn't set yet (during streaming)
|
|
|
|
|
// Only hide if there's no text content
|
|
|
|
|
if (!textContent || textContent.trim().length === 0) {
|
2025-12-07 19:32:53 +02:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ReasoningTimelineBlock
|
|
|
|
|
text={textContent}
|
|
|
|
|
variant="thinking"
|
|
|
|
|
onContentChange={onContentChange}
|
|
|
|
|
blockId={part.id || `${messageId}-reasoning`}
|
2026-02-28 15:16:31 -03:00
|
|
|
time={time}
|
2026-03-12 23:45:45 +02:00
|
|
|
showDuration={chatRenderMode !== 'sorted'}
|
2026-03-23 23:51:55 +02:00
|
|
|
isStreaming={isStreaming}
|
2025-12-07 19:32:53 +02:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// eslint-disable-next-line react-refresh/only-export-components
|
|
|
|
|
export const formatReasoningText = (text: string): string => cleanReasoningText(text);
|
|
|
|
|
|
|
|
|
|
export default ReasoningPart;
|