import { memo } from 'react' import { IconChevronRight, IconFile } from '@tabler/icons-react' import type { Part, Turn } from '@/lib/types' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/client/components/ui/collapsible' import { MarkdownContent } from '@/client/features/chat/MarkdownContent' import { ToolCallGroup } from '@/client/features/chat/tool-group/ToolCallGroup' import { formatDuration } from '@/client/features/chat/tool-group/format' import { useWorkspaceLayoutCtx } from '@/client/features/workspace/WorkspaceLayoutContext' import { cn } from '@/client/lib/cn' export function ThinkingIndicator() { return (
{[0, 1, 2].map(i => ( ))}
) } // A part either folds into a tool-group "run" (reasoning + tool calls — including // subagents and skills, which render as their own timeline rows) or stands alone // (text, files, sources, data). function isRunPart(part: Part): boolean { if (part.type === 'reasoning') return true if (part.type === 'tool-call') return true return false } type Segment = { kind: 'run'; parts: Part[] } | { kind: 'single'; part: Part } // Split a turn's parts into runs (consecutive run-parts) and singles, preserving // order. Each run becomes one ; singles render individually. function buildSegments(parts: Part[]): Segment[] { const segments: Segment[] = [] for (const part of parts) { if (isRunPart(part)) { const last = segments[segments.length - 1] if (last && last.kind === 'run') last.parts.push(part) else segments.push({ kind: 'run', parts: [part] }) } else { segments.push({ kind: 'single', part }) } } return segments } type TurnPartsProps = { parts: Part[]; cwd: string | null; processing?: boolean } // Shared body renderer for a sequence of parts — used by both a finalized turn // and the live streaming preview turn (see client/lib/preview-turn.ts), so a // streamed message and its finalized form render identically. Consecutive // reasoning + tool-call parts fold into one run; text/files/etc. // stand alone. `processing` flows into the LAST run so a trailing reasoning there // reads as a live, expanded "Thinking" row. export function TurnParts({ parts, cwd, processing = false }: TurnPartsProps) { const segments = buildSegments(parts) return (
{segments.map((seg, i) => { const spacing = i === 0 ? '' : 'mt-3' const isLast = i === segments.length - 1 return (
{seg.kind === 'run' ? ( ) : ( )}
) })}
) } type CompletedAssistantParts = { work: Part[]; response: Part[] } function hasVisibleResponse(parts: Part[]): boolean { return parts.some(part => part.type !== 'text' || part.text.trim().length > 0) } export function splitCompletedAssistantParts(parts: Part[]): CompletedAssistantParts | null { let lastWorkIndex = -1 for (let i = parts.length - 1; i >= 0; i--) { if (isRunPart(parts[i])) { lastWorkIndex = i break } } if (lastWorkIndex < 0) return null const response = parts.slice(lastWorkIndex + 1) if (!hasVisibleResponse(response)) return null return { work: parts.slice(0, lastWorkIndex + 1), response } } type AgentWorkDisclosureProps = { parts: Part[] cwd: string | null durationMs?: number defaultOpen?: boolean } export function AgentWorkDisclosure({ parts, cwd, durationMs, defaultOpen }: AgentWorkDisclosureProps) { const label = durationMs === undefined ? 'Worked' : `Worked for ${formatDuration(durationMs)}` return ( {label} ) } type AssistantTurnPartsProps = { parts: Part[] cwd: string | null processing?: boolean durationMs?: number } export function AssistantTurnParts({ parts, cwd, processing = false, durationMs }: AssistantTurnPartsProps) { const completed = processing ? null : splitCompletedAssistantParts(parts) return (
{processing ? ( ) : completed ? (
) : ( )}
) } type TurnViewProps = { turn: Turn; processing?: boolean } // Memoized: the message list maps over grouped turns (stable identities — see // `groupTurns` in ChatPanel), so parent re-renders only update rows whose `turn` // or `processing` actually changed. export const TurnView = memo(function TurnView({ turn, processing = false }: TurnViewProps) { const cwd = useWorkspaceLayoutCtx().cwd if (turn.role === 'user' && turn.origin.kind === 'user-input') { // Plain user input — right-aligned. Attachments (images/files) stack above // the text bubble. const fileParts = turn.parts.filter( (p): p is Extract => p.type === 'file' ) const text = turn.parts .filter(p => p.type === 'text') .map(p => (p.type === 'text' ? p.text : '')) .join('\n') if (!text && fileParts.length === 0) return null return (
{fileParts.map((p, i) => ( ))} {text && (

{text}

)}
) } return ( ) }) type PartRendererProps = { part: Part } // Renders the standalone parts. Reasoning + tool calls never reach here — they're // folded into a run by buildSegments. function PartRenderer({ part }: PartRendererProps) { switch (part.type) { case 'text': return case 'tool-call': // Tool calls normally fold into a run; this is a defensive fallback for a // lone tool-call segment, rendered as a one-row group. return case 'file': return case 'source-url': return case 'source-document': return case 'data': return case 'reasoning': return null } } type FilePartProps = { mediaType: string; url: string; filename?: string } function FilePart({ mediaType, url, filename }: FilePartProps) { // Images (data/object/remote URLs) render as a thumbnail; everything else is a // labelled chip. A file with no usable url (e.g. a non-image attachment whose // bytes live only server-side) still shows its name. if (mediaType.startsWith('image/') && url) { return ( {filename ) } return (
{filename ?? mediaType}
) } type SourceLinkProps = { url: string; title?: string } function SourceLink({ url, title }: SourceLinkProps) { return ( {title ?? url} ) } type DataPartProps = { name: string; data: unknown } function DataPart({ name, data }: DataPartProps) { return (
data:{name}
        {JSON.stringify(data, null, 2)}
      
) }