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 (
)
}
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)}
)
}