Files
openchamber/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx
T
Bohdan Triapitsyn f2507d58cf feat: migrate to OpenCode SDK v2
Update all import statements to use @opencode-ai/sdk/v2
Modify Vite configurations to alias new SDK path
Update API client method signatures for SDK v2 compatibility
2026-01-03 13:39:59 +02:00

182 lines
6.0 KiB
TypeScript

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<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();
};
type ReasoningTimelineBlockProps = {
text: string;
variant: ReasoningVariant;
onContentChange?: (reason?: ContentChangeReason) => void;
blockId: string;
};
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
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 (
<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>
{summary && (
<div className="flex-1 min-w-0 typography-meta text-muted-foreground/70">
<span className="truncate block">{summary}</span>
</div>
)}
</div>
{isExpanded && (
<div
className={cn(
'relative pr-2 pb-2 pt-2 pl-[1.4375rem]',
'before:absolute before:left-[0.4375rem] before:w-px before:bg-border/80 before:content-[""]',
'before:top-[-0.25rem] before:bottom-0'
)}
>
<ScrollableOverlay
as="blockquote"
outerClassName="max-h-80"
className="whitespace-pre-wrap break-words typography-meta italic text-muted-foreground/70 p-0"
>
{text}
</ScrollableOverlay>
</div>
)}
</div>
);
};
type ReasoningPartProps = {
part: Part;
onContentChange?: (reason?: ContentChangeReason) => void;
messageId: string;
};
const ReasoningPart: React.FC<ReasoningPartProps> = ({
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 (
<ReasoningTimelineBlock
text={textContent}
variant="thinking"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-reasoning`}
/>
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const formatReasoningText = (text: string): string => cleanReasoningText(text);
export default ReasoningPart;