Advertisement

← vibesdk

src/routes/chat/components/messages.tsx

28.8 KB Download file
import { AIAvatar } from '../../../components/icons/logos';
import { cn } from '@cloudflare/kumo';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeExternalLinks from 'rehype-external-links';
import { NO_IMAGE_MARKDOWN_COMPONENTS } from './markdown-components';
import { LoaderCircle, Check, AlertTriangle, ChevronDown, ChevronRight, MessageSquare, Brain, Sparkles, HelpCircle, RotateCcw } from 'lucide-react';
import type { ToolEvent, MessagePart } from '../utils/message-helpers';
import { parseClarifyingQuestions } from '../utils/message-helpers';
import {
	getGroupStatus,
	getToolGroupSummary,
	getToolSummary,
} from '../utils/tool-display';
import { useRollback } from '../contexts/rollback-context';
import type { ConversationMessage } from '@/api-types';
import { useState, useEffect, useRef } from 'react';
import { DebugSessionBubble } from './debug-session-bubble';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';

/**
 * Strip internal system tags that should not be displayed to users
 */
function sanitizeMessageForDisplay(message: string): string {
	// Remove <system_context>...</system_context> tags and their content
	return message.replace(/<system_context>[\s\S]*?<\/system_context>\n/gi, '').trim();
}

export function UserMessage({ message }: { message: string }) {
	const sanitizedMessage = sanitizeMessageForDisplay(message);

	return (
		<div className="flex flex-col gap-2 min-w-0">
			<div className="flex items-center gap-2 mb-3">
				<div className="size-5 flex items-center justify-center rounded-full bg-brand text-white shrink-0">
					<span className="text-[10px]">U</span>
				</div>
				<div className="font-medium text-text-50">You</div>
			</div>
			<Markdown className="text-text-primary/80">{sanitizedMessage}</Markdown>
		</div>
	);
}

type ContentItem =
	| { type: 'text'; content: string; key: string }
	| { type: 'tool'; event: ToolEvent; key: string };

function JsonRenderer({ data }: { data: unknown }) {
	if (typeof data !== 'object' || data === null) {
		return <span className="text-text-primary whitespace-pre-wrap">{String(data)}</span>;
	}

	return (
		<div className="flex flex-col gap-1">
			{Object.entries(data).map(([key, value]) => (
				<div key={key} className="flex gap-2">
					<span className="text-kumo-brand font-medium flex-shrink-0">{key}:</span>
					{typeof value === 'object' && value !== null ? (
						<div className="flex-1">
							<JsonRenderer data={value} />
						</div>
					) : (
						<span className="text-text-primary flex-1 whitespace-pre-wrap break-words">
							{String(value)}
						</span>
					)}
				</div>
			))}
		</div>
	);
}

function extractTextContent(content: unknown): string {
	if (typeof content === 'string') return content;
	if (Array.isArray(content)) {
		return content
			.map(item => item.type === 'text' ? item.text : '')
			.join('');
	}
	return '';
}

function convertToToolEvent(msg: ConversationMessage, idx: number): ToolEvent | null {
	if (msg.role !== 'tool' || !('name' in msg) || !msg.name) return null;

	return {
		name: msg.name,
		status: 'success',
		timestamp: Date.now() + idx,
		result: extractTextContent(msg.content),
	};
}

export function MessageContentRenderer({
	content,
	toolEvents = [],
	richToolPreview = false,
}: {
	content: string;
	toolEvents?: ToolEvent[];
	richToolPreview?: boolean;
}) {
	const inlineToolEvents = toolEvents.filter(ev => ev.contentLength !== undefined)
		.sort((a, b) => (a.contentLength ?? 0) - (b.contentLength ?? 0));

	const orderedContent = buildOrderedContent(content, inlineToolEvents);

	if (orderedContent.length === 0) return null;

	return (
		<div className="flex flex-col gap-2">
			{orderedContent.map((item) => (
				item.type === 'text' ? (
					<Markdown key={item.key} className="a-tag">
						{item.content}
					</Markdown>
				) : (
					<div key={item.key} className="my-1">
						<ToolStatusIndicator event={item.event} richToolPreview={richToolPreview} />
					</div>
				)
			))}
		</div>
	);
}

/** Does the tool event have inspectable input (`args`)? */
function hasToolInput(event: ToolEvent): boolean {
	return !!event.args && Object.keys(event.args).length > 0;
}

/** Does the tool event have inspectable output (`result`)? */
function hasToolOutput(event: ToolEvent): boolean {
	return event.status === 'success' && !!event.result;
}

/**
 * Short, hover-only preview of a tool event's Input and Output, used
 * for think-agent tools where rich previews are enabled. Truncates long
 * values so the popover stays compact — full content is reachable via
 * click-to-expand below the trigger.
 */
function ToolEventHoverPreview({ event }: { event: ToolEvent }) {
	const inputPreview = event.args ? truncateInline(safeStringifyArgs(event.args)) : null;
	const outputPreview = event.result ? truncateInline(event.result) : null;
	return (
		<div className="flex flex-col gap-2 text-xs">
			<div className="font-medium text-text-secondary">{getToolSummary(event)}</div>
			{inputPreview && (
				<div className="flex flex-col gap-1">
					<span className="text-kumo-brand font-medium">Input</span>
					<span className="text-text-primary whitespace-pre-wrap break-words font-mono">{inputPreview}</span>
				</div>
			)}
			{outputPreview && (
				<div className="flex flex-col gap-1">
					<span className="text-kumo-brand font-medium">Output</span>
					<span className="text-text-primary whitespace-pre-wrap break-words font-mono">{outputPreview}</span>
				</div>
			)}
			{(inputPreview || outputPreview) && (
				<div className="text-text-tertiary italic">Click to expand</div>
			)}
		</div>
	);
}

/**
 * Full expanded panel rendered when the user clicks the chevron on a
 * rich-preview tool widget. Reuses `JsonRenderer` for input and the
 * existing `ToolResultRenderer` for output so both follow the codebase
 * conventions for structured data.
 */
function ToolEventExpandedPanel({ event }: { event: ToolEvent }) {
	const showInput = hasToolInput(event);
	const showOutput = hasToolOutput(event);
	if (!showInput && !showOutput) return null;
	return (
		<div className="flex flex-col gap-3">
			{showInput && (
				<div className="flex flex-col gap-1">
					<div className="text-[10px] uppercase tracking-wide text-text-tertiary">Input</div>
					<JsonRenderer data={event.args} />
				</div>
			)}
			{showOutput && (
				<div className="flex flex-col gap-1">
					<div className="text-[10px] uppercase tracking-wide text-text-tertiary">Output</div>
					<ToolResultRenderer result={event.result!} toolName={event.name} event={event} />
				</div>
			)}
		</div>
	);
}

/**
 * Best-effort JSON serialize for hover previews. Falls back to an
 * empty object on failure so the preview just renders nothing for
 * that section instead of crashing.
 */
function safeStringifyArgs(args: Record<string, unknown>): string {
	try {
		return JSON.stringify(args, null, 2);
	} catch {
		return '';
	}
}

/** Clamp long strings so hover previews stay compact. */
function truncateInline(s: string, limit = 220): string {
	if (s.length <= limit) return s;
	return `${s.slice(0, limit)}…`;
}

function DeepDebugTranscript({ transcript }: { transcript: ConversationMessage[] }) {
	// Build map of tool results by tool_call_id for matching
	const toolResultsMap = new Map<string, ToolEvent>();
	transcript.forEach((msg, idx) => {
		if (msg.role === 'tool' && 'tool_call_id' in msg) {
			const toolCallId = (msg as { tool_call_id?: string }).tool_call_id;
			if (toolCallId && typeof toolCallId === 'string') {
				const toolEvent = convertToToolEvent(msg, idx);
				if (toolEvent) toolResultsMap.set(toolCallId, toolEvent);
			}
		}
	});

	return (
		<div className="flex flex-col gap-3 p-3 rounded-md bg-surface-tertiary/50 border-l-2 border-brand/30">
			<div className="flex items-center gap-2 text-xs font-medium text-kumo-brand">
				<MessageSquare className="size-3" />
				<span>Deep Debugger Transcript</span>
			</div>
			{transcript.map((msg, idx) => {
				if (msg.role === 'tool') return null; // Tool results rendered with assistant messages

				const text = extractTextContent(msg.content);
				if (!text) return null;

				if (msg.role === 'assistant') {
					// Match tool_calls with their results
					const toolEvents: ToolEvent[] = msg.tool_calls?.map(tc => {
						const funcName = 'function' in tc ? tc.function.name : 'unknown_tool';
						const matchedResult = toolResultsMap.get(tc.id);
						return matchedResult || {
							name: funcName,
							status: 'start' as const,
							timestamp: Date.now() + idx,
							contentLength: 0,
						};
					}) || [];

					return (
						<div key={`${msg.conversationId}-${idx}`} className="text-xs">
							<MessageContentRenderer content={text} toolEvents={toolEvents} />
						</div>
					);
				}

				return null;
			})}
		</div>
	);
}

function ClarifyingQuestionsResult({ event }: { event: ToolEvent }) {
	const questions = parseClarifyingQuestions(event);
	if (questions.length === 0) return <JsonRenderer data={event.args ?? event.result} />;

	return (
		<div className="flex flex-col gap-3">
			{questions.map((q, i) => (
				<div key={i} className="flex flex-col gap-1.5">
					<div className="flex items-center gap-1.5 text-text-primary font-medium">
						<HelpCircle className="size-3.5 text-kumo-brand" />
						<span>{q.question}</span>
					</div>
					{q.options && q.options.length > 0 && (
						<div className="text-xs text-text-secondary pl-5">
							Options: {q.options.join(', ')}
						</div>
					)}
				</div>
			))}
		</div>
	);
}

function ToolResultRenderer({ result, toolName, event }: { result: string; toolName: string; event?: ToolEvent }) {
	try {
		const parsed = JSON.parse(result);

		// Special handling for deep_debug transcript
		if (toolName === 'deep_debug' && Array.isArray(parsed.transcript)) {
			return <DeepDebugTranscript transcript={parsed.transcript} />;
		}

		// Special handling for ask_questions clarifying questions
		if (toolName === 'ask_questions' && event) {
			return <ClarifyingQuestionsResult event={event} />;
		}

		return <JsonRenderer data={parsed} />;
	} catch {
		return <div className="whitespace-pre-wrap break-words">{result}</div>;
	}
}

export function ToolStatusIndicator({ event, richToolPreview = false }: { event: ToolEvent; richToolPreview?: boolean }) {
	const [isExpanded, setIsExpanded] = useState(false);
	const hasResult = event.status === 'success' && !!event.result;
	const isDeepDebug = event.name === 'deep_debug';

	// Rich preview (think only): allow expanding when *either* args
	// or result is present, and show a hover-card peek of both.
	// Default (phasic/agentic): click-to-expand requires a result; no
	// hover-card. This preserves today's exact UX outside think.
	const showInputSection = richToolPreview && hasToolInput(event);
	const canExpand = richToolPreview ? (showInputSection || hasResult) : hasResult;

	const StatusIcon = event.status === 'start' ? LoaderCircle :
	                   event.status === 'success' ? Check :
	                   AlertTriangle;

	const iconClass = event.status === 'start' ? 'size-3 animate-spin' : 'size-3';
	const summary = getToolSummary(event);

	const button = (
		<button
			onClick={() => canExpand && setIsExpanded(!isExpanded)}
			className={cn(
				'flex items-center gap-1.5 text-xs',
				isDeepDebug ? 'text-kumo-brand font-medium' : 'text-text-tertiary',
				canExpand && 'cursor-pointer hover:text-text-secondary transition-colors'
			)}
			disabled={!canExpand}
		>
			<StatusIcon className={iconClass} />
			<span className="tracking-tight">
				{summary}
			</span>
			{canExpand && (
				isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />
			)}
		</button>
	);

	const trigger = richToolPreview && canExpand ? (
		<HoverCard openDelay={150} closeDelay={75}>
			<HoverCardTrigger asChild>{button}</HoverCardTrigger>
			<HoverCardContent align="start" className="w-80">
				<ToolEventHoverPreview event={event} />
			</HoverCardContent>
		</HoverCard>
	) : button;

	return (
		<div className="flex flex-col gap-2">
			{trigger}

			{isExpanded && canExpand && (
				<div className={cn(
					'p-3 rounded-md text-xs font-mono border overflow-auto',
					isDeepDebug
						? 'bg-surface-tertiary/30 border-brand/20 max-h-[600px]'
						: 'bg-surface-secondary max-h-96'
				)}>
					{richToolPreview ? (
						<ToolEventExpandedPanel event={event} />
					) : (
						event.result && <ToolResultRenderer result={event.result} toolName={event.name} event={event} />
					)}
				</div>
			)}
		</div>
	);
}

function buildOrderedContent(message: string, inlineToolEvents: ToolEvent[]): ContentItem[] {
	if (!inlineToolEvents.length) {
		return message ? [{ type: 'text', content: message, key: 'content-0' }] : [];
	}

	const items: ContentItem[] = [];
	let lastPos = 0;

	for (const event of inlineToolEvents) {
		const pos = event.contentLength ?? 0;

		// Add text before this event
		if (pos > lastPos && message.slice(lastPos, pos)) {
			items.push({ type: 'text', content: message.slice(lastPos, pos), key: `text-${lastPos}` });
		}

		// Add event
		items.push({ type: 'tool', event, key: `tool-${event.timestamp}` });
		lastPos = pos;
	}

	// Add remaining text
	if (lastPos < message.length && message.slice(lastPos)) {
		items.push({ type: 'text', content: message.slice(lastPos), key: `text-${lastPos}` });
	}

	return items;
}

/** Trace parts (reasoning + tool) are the collapsible, out-of-band steps. */
type TracePart = Extract<MessagePart, { type: 'reasoning' } | { type: 'tool' }>;

function isTracePart(part: MessagePart): part is TracePart {
	return part.type === 'reasoning' || part.type === 'tool';
}

type RenderUnit =
	| { kind: 'text'; text: string; key: string }
	| { kind: 'trace'; items: TracePart[]; key: string };

/**
 * Walk an assistant turn's ordered parts and group contiguous reasoning/tool
 * steps into a single trace unit, flushing on each text block. This mirrors the
 * emission order exactly, so live and reloaded renders are identical.
 */
function bucketParts(parts: MessagePart[]): RenderUnit[] {
	const units: RenderUnit[] = [];
	let buffer: TracePart[] = [];
	let bufferStart = 0;

	const flush = () => {
		if (buffer.length === 0) return;
		units.push({ kind: 'trace', items: buffer, key: `trace-${bufferStart}` });
		buffer = [];
	};

	parts.forEach((part, i) => {
		if (isTracePart(part)) {
			if (buffer.length === 0) bufferStart = i;
			buffer.push(part);
			return;
		}
		const text = sanitizeMessageForDisplay(part.text);
		if (!text) return;
		flush();
		units.push({ kind: 'text', text, key: `text-${i}` });
	});
	flush();
	return units;
}

/** Tiny trailing status — spinner while live, nothing on success, soft mark on error. */
function ToolStatusMark({ status }: { status: ToolEvent['status'] }) {
	if (status === 'start') {
		return <LoaderCircle className="size-3 shrink-0 text-kumo-subtle animate-spin" />;
	}
	if (status === 'error') {
		return <span className="size-1.5 shrink-0 rounded-full bg-red-500/80" aria-hidden />;
	}
	return null;
}

/** Collapsible reasoning ("thinking") block. Opens while streaming. */
function ReasoningBlock({ part, streaming }: { part: Extract<MessagePart, { type: 'reasoning' }>; streaming: boolean }) {
	const active = streaming && !part.done;
	const [open, setOpen] = useState(active);
	const text = part.text.trim();
	if (!text) return null;

	return (
		<div className="rounded-lg border bg-surface-secondary/40">
			<button
				type="button"
				onClick={() => setOpen(o => !o)}
				className="flex w-full items-center gap-2 px-3 py-2 text-left"
			>
				{active
					? <LoaderCircle className="size-3.5 shrink-0 text-kumo-brand animate-spin" />
					: <Brain className="size-3.5 shrink-0 text-text-tertiary" />}
				<span className={cn('flex-1 text-xs font-medium text-text-secondary', active && 'animate-pulse')}>
					{active ? 'Thinking' : 'Thoughts'}
				</span>
				<ChevronDown className={cn('size-3.5 shrink-0 text-text-tertiary transition-transform', open && 'rotate-180')} />
			</button>
			{open && (
				<div className="px-3 pb-3 max-h-96 overflow-y-auto text-xs text-text-tertiary">
					<Markdown className="a-tag">{text}</Markdown>
				</div>
			)}
		</div>
	);
}

/**
 * Extract a git commit sha from a successful `commit`/`deploy_space` tool
 * result, so the tool card can offer a rollback-to-this-commit control.
 */
function getRollbackCommitHash(event: ToolEvent): string | null {
	if (event.status !== 'success') return null;
	if (event.name !== 'commit' && event.name !== 'deploy_space') return null;
	if (!event.result) return null;
	try {
		const parsed = JSON.parse(event.result) as Record<string, unknown>;
		const hash = parsed.commit_hash;
		return typeof hash === 'string' && hash.length > 0 ? hash : null;
	} catch {
		return null;
	}
}

/** Inline "Rollback" control shown on commit/deploy tool cards. */
function RollbackButton({ commitHash }: { commitHash: string }) {
	const onRollback = useRollback();
	const [confirming, setConfirming] = useState(false);
	if (!onRollback) return null;

	if (confirming) {
		return (
			<span className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
				<span className="text-[10px] text-text-tertiary">Roll back?</span>
				<button
					type="button"
					onClick={() => { onRollback(commitHash); setConfirming(false); }}
					className="rounded px-1.5 py-0.5 text-[10px] font-medium text-red-500 hover:bg-red-500/10"
				>
					Confirm
				</button>
				<button
					type="button"
					onClick={() => setConfirming(false)}
					className="rounded px-1.5 py-0.5 text-[10px] font-medium text-text-tertiary hover:bg-surface-tertiary/40"
				>
					Cancel
				</button>
			</span>
		);
	}

	return (
		<button
			type="button"
			onClick={e => { e.stopPropagation(); setConfirming(true); }}
			title={`Roll back to ${commitHash.slice(0, 8)}`}
			className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium text-text-tertiary hover:bg-surface-tertiary/40 hover:text-text-secondary"
		>
			<RotateCcw className="size-3 shrink-0" />
			Rollback
		</button>
	);
}

function toolEventCanExpand(event: ToolEvent): boolean {
	const hasInput = !!event.args && Object.keys(event.args).length > 0;
	const hasOutput = event.status !== 'start' && !!event.result;
	return hasInput || hasOutput;
}

/** Expanded Input/Output body — quiet indent, no heavy panel chrome. */
function ToolEventDetails({ event }: { event: ToolEvent }) {
	const hasInput = !!event.args && Object.keys(event.args).length > 0;
	const hasOutput = event.status !== 'start' && !!event.result;
	if (!hasInput && !hasOutput) return null;
	return (
		<div className="mt-1.5 mb-1 ml-0.5 border-l border-kumo-line/60 pl-3 flex flex-col gap-2.5 text-[11px] font-mono max-h-72 overflow-auto text-kumo-subtle">
			{hasInput && (
				<div className="flex flex-col gap-0.5 min-w-0">
					<div className="text-[10px] uppercase tracking-wider text-kumo-subtle/70">Input</div>
					<div className="text-kumo-subtle">
						<JsonRenderer data={event.args} />
					</div>
				</div>
			)}
			{hasOutput && (
				<div className="flex flex-col gap-0.5 min-w-0">
					<div className="text-[10px] uppercase tracking-wider text-kumo-subtle/70">Output</div>
					<div className="text-kumo-subtle">
						<ToolResultRenderer result={event.result!} toolName={event.name} event={event} />
					</div>
				</div>
			)}
		</div>
	);
}

/**
 * Single tool row: plain-English label, no box. Click expands details.
 * Nested rows (inside a group) are slightly indented.
 */
function ToolCard({ event, nested = false }: { event: ToolEvent; nested?: boolean }) {
	const [open, setOpen] = useState(false);
	const isRunning = event.status === 'start';
	const canExpand = !isRunning && toolEventCanExpand(event);
	const rollbackHash = getRollbackCommitHash(event);
	const summary = getToolSummary(event);

	return (
		<div className={cn(nested && 'pl-2')}>
			<div className="group/tool flex items-center gap-1.5 min-h-7">
				<button
					type="button"
					onClick={() => canExpand && setOpen((o) => !o)}
					disabled={!canExpand}
					className={cn(
						'flex flex-1 min-w-0 items-center gap-1.5 text-left rounded-sm py-1',
						canExpand && 'cursor-pointer hover:text-kumo-default',
						!canExpand && 'cursor-default',
					)}
				>
					<span
						className={cn(
							'min-w-0 truncate text-[13px] leading-snug text-kumo-subtle',
							isRunning && 'animate-pulse',
							event.status === 'error' && 'text-red-500/90',
						)}
					>
						{summary}
					</span>
					{canExpand && (
						<ChevronRight
							className={cn(
								'size-3 shrink-0 text-kumo-subtle/50 transition-transform duration-150',
								open && 'rotate-90',
								'opacity-0 group-hover/tool:opacity-100',
								open && 'opacity-100',
							)}
						/>
					)}
					<ToolStatusMark status={event.status} />
				</button>
				{rollbackHash && <RollbackButton commitHash={rollbackHash} />}
			</div>
			{open && canExpand && <ToolEventDetails event={event} />}
		</div>
	);
}

/**
 * Consecutive same-name tools collapse to one line; expand reveals
 * individual rows (each still expandable for Input/Output).
 */
function ToolGroupCard({ events }: { events: ToolEvent[] }) {
	const [open, setOpen] = useState(false);
	const status = getGroupStatus(events);
	const summary = getToolGroupSummary(events);
	const isRunning = status === 'start';

	if (events.length === 1) {
		return <ToolCard event={events[0]} />;
	}

	return (
		<div>
			<button
				type="button"
				onClick={() => !isRunning && setOpen((o) => !o)}
				disabled={isRunning}
				className={cn(
					'group/tool flex w-full items-center gap-1.5 min-h-7 text-left rounded-sm py-1',
					isRunning ? 'cursor-default' : 'cursor-pointer hover:text-kumo-default',
				)}
			>
				<span
					className={cn(
						'min-w-0 truncate text-[13px] leading-snug text-kumo-subtle',
						isRunning && 'animate-pulse',
						status === 'error' && 'text-red-500/90',
					)}
				>
					{summary}
				</span>
				<span className="text-[11px] tabular-nums text-kumo-subtle/50 shrink-0">
					{events.length}
				</span>
				{!isRunning && (
					<ChevronRight
						className={cn(
							'size-3 shrink-0 text-kumo-subtle/50 transition-transform duration-150',
							open && 'rotate-90',
							'opacity-60 group-hover/tool:opacity-100',
						)}
					/>
				)}
				<ToolStatusMark status={status} />
			</button>
			{open && !isRunning && (
				<div className="mt-1 ml-0.5 border-l border-kumo-line/50 pl-2.5 flex flex-col gap-1">
					{events.map((event, i) => (
						<ToolCard key={event.id ?? `g-${event.name}-${i}`} event={event} nested />
					))}
				</div>
			)}
		</div>
	);
}

type TraceRenderItem =
	| { kind: 'reasoning'; part: Extract<MessagePart, { type: 'reasoning' }>; key: string; streaming: boolean }
	| { kind: 'tools'; events: ToolEvent[]; key: string };

/**
 * Collapse contiguous same-name tools, but only once finished.
 * In-flight tools stay as individual rows ("Writing foo…") so the
 * group count doesn't flicker as each write starts. Completed runs
 * ahead of a still-running tool collapse progressively
 * ("Wrote 3 files" + "Writing bar…").
 */
function collapseTraceItems(items: TracePart[], streaming: boolean): TraceRenderItem[] {
	const out: TraceRenderItem[] = [];
	let i = 0;
	while (i < items.length) {
		const item = items[i];
		if (item.type === 'reasoning') {
			out.push({
				kind: 'reasoning',
				part: item,
				key: `r-${i}`,
				streaming: streaming && i === items.length - 1,
			});
			i += 1;
			continue;
		}
		const name = item.event.name;
		const events: ToolEvent[] = [item.event];
		const start = i;
		i += 1;
		while (i < items.length) {
			const next = items[i];
			if (next.type !== 'tool' || next.event.name !== name) break;
			events.push(next.event);
			i += 1;
		}

		// Split the same-name run: finished stretches may group; running stays solo.
		let j = 0;
		while (j < events.length) {
			const abs = start + j;
			if (events[j].status === 'start') {
				out.push({
					kind: 'tools',
					events: [events[j]],
					key: events[j].id ?? `tools-${abs}-${name}`,
				});
				j += 1;
				continue;
			}
			const done: ToolEvent[] = [];
			const doneStart = abs;
			while (j < events.length && events[j].status !== 'start') {
				done.push(events[j]);
				j += 1;
			}
			out.push({
				kind: 'tools',
				events: done,
				key: done[0]?.id ?? `tools-${doneStart}-${name}`,
			});
		}
	}
	return out;
}

/** Render a contiguous run of reasoning/tool steps as a vertical trace. */
function TraceGroup({ items, streaming }: { items: TracePart[]; streaming: boolean }) {
	const collapsed = collapseTraceItems(items, streaming);
	return (
		<div className="flex flex-col gap-1.5">
			{collapsed.map((item) =>
				item.kind === 'reasoning'
					? <div key={item.key} className="my-1"><ReasoningBlock part={item.part} streaming={item.streaming} /></div>
					: <ToolGroupCard key={item.key} events={item.events} />,
			)}
		</div>
	);
}

/** Render an assistant turn's ordered parts (text + reasoning/tool traces). */
function AssistantParts({ parts, streaming }: { parts: MessagePart[]; streaming: boolean }) {
	const units = bucketParts(parts);
	if (units.length === 0) return null;
	return (
		<div className="flex flex-col gap-2">
			{units.map(unit =>
				unit.kind === 'text'
					? <Markdown key={unit.key} className="a-tag">{unit.text}</Markdown>
					: <TraceGroup key={unit.key} items={unit.items} streaming={streaming} />,
			)}
		</div>
	);
}

export function AIMessage({
	message = '',
	parts,
	isThinking,
	toolEvents = [],
}: {
	message?: string;
	parts?: MessagePart[];
	isThinking?: boolean;
	toolEvents?: ToolEvent[];
	/** @deprecated Input/Output is now shown for all behaviors; kept for call-site compat. */
	richToolPreview?: boolean;
}) {
	const sanitizedMessage = sanitizeMessageForDisplay(message);

	// Ordered parts are authoritative; fall back to a single text part for the
	// few literal placeholder messages that are constructed without parts.
	const resolvedParts: MessagePart[] = parts && parts.length > 0
		? parts
		: (sanitizedMessage ? [{ type: 'text', text: sanitizedMessage }] : []);

	// Check if this is a debug session (active or just completed in this session)
	const debugEvent = toolEvents.find(ev => ev.name === 'deep_debug');
	const isActiveDebug = debugEvent?.status === 'start';
	const isCompletedDebug = debugEvent?.status === 'success' || debugEvent?.status === 'error';
	const hasToolCalls = toolEvents.some(ev => ev.name !== 'deep_debug');
	const isLiveDebugSession = debugEvent && (isActiveDebug || (isCompletedDebug && hasToolCalls));

	// Calculate elapsed time for active debug sessions
	const [elapsedSeconds, setElapsedSeconds] = useState(0);
	const startTimeRef = useRef<number | null>(null);

	useEffect(() => {
		if (!isActiveDebug) {
			startTimeRef.current = null;
			setElapsedSeconds(0);
			return;
		}
		if (!startTimeRef.current) {
			startTimeRef.current = Date.now();
		}
		const interval = setInterval(() => {
			if (startTimeRef.current) {
				setElapsedSeconds(Math.floor((Date.now() - startTimeRef.current) / 1000));
			}
		}, 1000);
		return () => clearInterval(interval);
	}, [isActiveDebug]);

	if (isLiveDebugSession) {
		const toolCallCount = toolEvents.filter(e => e.name !== 'deep_debug').length;
		return (
			<DebugSessionBubble
				message={{
					conversationId: 'debug-session',
					role: 'assistant' as const,
					content: sanitizedMessage,
					ui: { toolEvents },
				}}
				isActive={isActiveDebug}
				elapsedSeconds={elapsedSeconds}
				toolCallCount={toolCallCount}
			/>
		);
	}

	if (resolvedParts.length === 0) {
		return null;
	}

	return (
		<div className="flex flex-col gap-2 min-w-0">
			<div className="font-mono font-medium text-text-50 flex items-center gap-2 mb-3">
				<AIAvatar className="size-5 text-orange-500 shrink-0" />
				Orange
				{isThinking && <Sparkles className="size-3 text-orange-400 animate-pulse" />}
			</div>
			<div className={cn(isThinking && 'animate-pulse')}>
				<AssistantParts parts={resolvedParts} streaming={!!isThinking} />
			</div>
		</div>
	);
}

interface MarkdownProps extends React.ComponentProps<'article'> {
	children: string;
}

export function Markdown({ children, className, ...props }: MarkdownProps) {
	return (
		<article
			className={cn('prose prose-sm prose-teal', className)}
			{...props}
		>
			<ReactMarkdown
				remarkPlugins={[remarkGfm]}
				rehypePlugins={[[rehypeExternalLinks, { target: '_blank' }]]}
				components={NO_IMAGE_MARKDOWN_COMPONENTS}
			>
				{children}
			</ReactMarkdown>
		</article>
	);
}