import { useEffect, useMemo, useRef, useState } from "react"; import type { ChatMessage, ToolState } from "./derive"; const PREVIEW_LEN: number = 120; const COPY_FEEDBACK_MS: number = 1200; const PIN_THRESHOLD_PX: number = 80; const FAB_THRESHOLD_PX: number = 400; const FENCE: string = "```"; const ENTER_KEY: string = "Enter"; const ESCAPE_KEY: string = "Escape"; const INLINE_RE: RegExp = /(`[^`\n]+`)|(\[[^\]\n]+\]\([^)\s]+\))|(\*\*[^*\n]+\*\*)|(\*[^*\n]+\*)/g; const SAFE_URL_RE: RegExp = /^https?:\/\//i; // ---------- safe mini-markdown (no deps, no innerHTML) ---------- function highlight(text: string, query: string): React.ReactNode { if (query.length === 0 || text.length === 0) return text; const hay: string = text.toLowerCase(); const needle: string = query.toLowerCase(); if (!hay.includes(needle)) return text; const parts: React.ReactNode[] = []; let from = 0; let at: number = hay.indexOf(needle); let n = 0; while (at !== -1) { if (at > from) parts.push(text.slice(from, at)); parts.push( {text.slice(at, at + needle.length)} , ); n += 1; from = at + needle.length; at = hay.indexOf(needle, from); } if (from < text.length) parts.push(text.slice(from)); return parts; } function tokenNode(tok: string, query: string, key: string): React.ReactNode { if (tok.startsWith("`")) return {tok.slice(1, -1)}; if (tok.startsWith("**")) return {highlight(tok.slice(2, -2), query)}; if (tok.startsWith("*")) return {highlight(tok.slice(1, -1), query)}; // link token; INLINE_RE guarantees the [label](url) form const closeAt: number = tok.indexOf("]"); const label: string = tok.slice(1, closeAt); const url: string = tok.slice(closeAt + 2, -1); if (SAFE_URL_RE.test(url)) return ( {label} ); // non-http scheme (javascript:, data:, …): the anchor is dropped, the // raw token stays visible as plain text return {highlight(tok, query)}; } function inlineNodes( text: string, query: string, keyBase: string, ): React.ReactNode[] { const out: React.ReactNode[] = []; let from = 0; let n = 0; for (const m of text.matchAll(INLINE_RE)) { const tok: string = m[0]; const at: number = m.index ?? 0; if (at > from) out.push(highlight(text.slice(from, at), query)); out.push(tokenNode(tok, query, `${keyBase}-${n}`)); n += 1; from = at + tok.length; } if (from < text.length) out.push(highlight(text.slice(from), query)); return out; } /** ```fences``` →
, `code`, **bold**, *italic*,
 *  [t](http…) links (http/https only). Plain segments keep the bubble's
 *  pre-wrap. Unterminated fences (streaming) render the tail as code. */
export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
	const out: React.ReactNode[] = [];
	let plain: string[] = [];
	let code: string[] | null = null;
	let n = 0;
	const flushPlain = (): void => {
		if (plain.length > 0) {
			out.push(...inlineNodes(plain.join("\n"), query, `p-${n}`));
			n += 1;
			plain = [];
		}
	};
	for (const line of text.split("\n")) {
		if (line.trimStart().startsWith(FENCE)) {
			if (code === null) {
				flushPlain();
				code = [];
			} else {
				out.push(
					
						{code.join("\n")}
					
, ); n += 1; code = null; } } else if (code === null) { plain.push(line); } else { code.push(line); } } if (code === null) { flushPlain(); } else { out.push(
				{code.join("\n")}
			
, ); } return out; } function formatTs(ts: number): string { return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }); } function searchHaystack(m: ChatMessage): string { const names: string[] = m.toolCalls.map((c) => c.name); return `${m.text} ${m.thinking ?? ""} ${names.join(" ")}`.toLowerCase(); } function oneLine(text: string): string { const flat = text.replace(/\s+/g, " ").trim(); return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}…` : flat; } /** true when the standalone result bubble is skipped because its ToolCard * (which carries the output preview) is already rendered */ function resultSkipped(m: ChatMessage, tools: Map): boolean { return ( m.role === "toolResult" && m.toolCallId !== null && tools.has(m.toolCallId) ); } function CopyButton({ text }: { text: string }): React.ReactNode { const [copied, setCopied] = useState(false); return ( ); } // ---------- pretty tool args ---------- // Keys shown first when present (most readable "what is this tool doing" // signal); the rest follow in their original order. const ARG_PRIORITY: string[] = [ "command", "path", "file_path", "pattern", "url", "query", "content", "prompt", "task", "description", ]; // Above this many lines per side, LCS is skipped and the whole old block is // rendered removed + the whole new block added. const DIFF_MAX_LINES: number = 400; export interface ArgEntry { k: string | null; v: string; } /** Parse a tool's args (JSON object, JSON string, or raw text) into labeled * display entries. A lone string entry renders without a label. */ export function toolArgsEntries(argsText: string): ArgEntry[] { let parsed: unknown; try { parsed = JSON.parse(argsText); } catch { return [{ k: null, v: argsText }]; } if (typeof parsed === "string") return [{ k: null, v: parsed }]; if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return [{ k: null, v: argsText }]; const obj = parsed as Record; const keys = Object.keys(obj); const ordered = [ ...ARG_PRIORITY.filter((k) => k in obj), ...keys.filter((k) => !ARG_PRIORITY.includes(k)), ]; const entries: ArgEntry[] = []; for (const k of ordered) { const raw = obj[k]; const v = typeof raw === "string" ? raw : (JSON.stringify(raw, null, 2) ?? String(raw)); entries.push({ k, v }); } if (entries.length === 1 && entries[0] !== undefined) return [{ k: null, v: entries[0].v }]; return entries; } /** One-line "what is this tool doing" for the collapsed summary line: * the highest-priority arg's value (bash → command, read/write/edit → path). */ export function toolSummaryText(argsText: string): string { const first = toolArgsEntries(argsText)[0]; return first === undefined ? "" : first.v; } // ---------- edit/write diff view ---------- export interface DiffLine { kind: "ctx" | "add" | "del"; text: string; } /** Line-level LCS diff between two text blocks. */ export function lineDiff(a: string, b: string): DiffLine[] { const left: string[] = a.split("\n"); const right: string[] = b.split("\n"); if (left.length > DIFF_MAX_LINES || right.length > DIFF_MAX_LINES) return [ ...left.map((text): DiffLine => ({ kind: "del", text })), ...right.map((text): DiffLine => ({ kind: "add", text })), ]; const n: number = left.length; const m: number = right.length; const dp: number[][] = Array.from({ length: n + 1 }, () => Array.from({ length: m + 1 }, (): number => 0), ); for (let i = n - 1; i >= 0; i--) { const row: number[] | undefined = dp[i]; if (row === undefined) continue; for (let j = m - 1; j >= 0; j--) { const a: string | undefined = left[i]; const b: string | undefined = right[j]; row[j] = a !== undefined && a === b ? (dp[i + 1]?.[j + 1] ?? 0) + 1 : Math.max(dp[i + 1]?.[j] ?? 0, dp[i]?.[j + 1] ?? 0); } } const out: DiffLine[] = []; let i: number = 0; let j: number = 0; while (i < n && j < m) { const a: string | undefined = left[i]; const b: string | undefined = right[j]; if (a !== undefined && a === b) { out.push({ kind: "ctx", text: a }); i += 1; j += 1; } else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) { if (a !== undefined) out.push({ kind: "del", text: a }); i += 1; } else { if (b !== undefined) out.push({ kind: "add", text: b }); j += 1; } } for (; i < n; i++) { const a: string | undefined = left[i]; if (a !== undefined) out.push({ kind: "del", text: a }); } for (; j < m; j++) { const b: string | undefined = right[j]; if (b !== undefined) out.push({ kind: "add", text: b }); } return out; } interface TextEdit { oldText: string; newText: string; } function parseArgsObj(argsText: string): Record | null { try { const p: unknown = JSON.parse(argsText); return typeof p === "object" && p !== null && !Array.isArray(p) ? (p as Record) : null; } catch { return null; } } /** Diff blocks for edit/write tool args; null when the tool is neither or * the args do not carry the expected fields. */ export function toolDiffBlocks( name: string, argsText: string, ): DiffLine[][] | null { const obj: Record | null = parseArgsObj(argsText); if (obj === null) return null; if (name === "write" && typeof obj.content === "string") return [ obj.content .split("\n") .slice(0, DIFF_MAX_LINES) .map((text): DiffLine => ({ kind: "add", text })), ]; if (name !== "edit") return null; const edits: TextEdit[] = []; if (Array.isArray(obj.edits)) { for (const e of obj.edits) { if ( typeof e === "object" && e !== null && typeof (e as Record).oldText === "string" && typeof (e as Record).newText === "string" ) edits.push(e as TextEdit); } } else if ( typeof obj.oldText === "string" && typeof obj.newText === "string" ) { edits.push({ oldText: obj.oldText, newText: obj.newText }); } return edits.length === 0 ? null : edits.map((e): DiffLine[] => lineDiff(e.oldText, e.newText)); } const DIFF_MARK: Record = { add: "+", del: "-", ctx: " ", }; function DiffBlock({ lines }: { lines: DiffLine[] }): React.ReactNode { return (
			{lines.map((l, i) => (
				
					{DIFF_MARK[l.kind] + l.text}
					{"\n"}
				
			))}
		
); } // Well-known tools get a compact glyph + their primary arg instead of // the raw tool name (bash → "$ ls -la", read → "📄 src/main.ts", …). const TOOL_ICON: Record = { bash: "$", read: "📄", edit: "✎", write: "📝", }; function ToolCard({ tool }: { tool: ToolState }) { const status: string = tool.running ? "⋯" : tool.isError ? "✗" : "✓"; const statusClass: string = tool.isError ? "tool-status-err" : tool.running ? "tool-status-run" : "tool-status-ok"; const summary: string = oneLine(toolSummaryText(tool.args)); const icon: string | undefined = TOOL_ICON[tool.name]; const label: string = icon === undefined ? tool.name : `${icon} ${summary}`.trim(); const rest: string = icon === undefined ? summary : ""; const diffBlocks: DiffLine[][] | null = toolDiffBlocks(tool.name, tool.args); return (
{label} {rest.length > 0 && {rest}} {status}
{diffBlocks === null ? (
{toolArgsEntries(tool.args).map((e, i) => (
{e.k !== null && {e.k}}
{e.v}
))}
) : (
{diffBlocks.map((b, i) => ( ))}
)}
output
						{tool.preview}
					
); } function Thinking({ text }: { text: string }) { return (
thinking
{text}
); } export function Bubble({ msg, tools, query = "", showTs = false, }: { msg: ChatMessage; tools: Map; /** live search query: matches in plain text get */ query?: string; showTs?: boolean; }) { if (msg.notice === true) { return (
⚠ {msg.text}
); } const rowClass = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "system"; const msgTools: ToolState[] = []; if (msg.role === "assistant") { for (const c of msg.toolCalls) { const t = tools.get(c.id); if (t !== undefined) msgTools.push(t); } } // the ToolCard above already carries this result (output preview): skip // the duplicate standalone "result" bubble when the tool state exists if (resultSkipped(msg, tools)) return null; const copyable: boolean = msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0); const tsTitle: string = msg.ts > 0 ? new Date(msg.ts).toISOString() : ""; return (
0 ? tsTitle : undefined} > {copyable && }
{msg.thinking !== null && msg.thinking.length > 0 && ( )} {msgTools.map((t) => ( ))} {msg.role === "toolResult" ? (
result {oneLine(msg.text)}
{msg.text}
) : ( msg.text.length > 0 &&
{renderMarkdown(msg.text, query)}
)} {msg.streaming && }
{showTs && msg.ts > 0 && (
{formatTs(msg.ts)}
)}
); } export function TypingIndicator() { return (
); } interface Props { messages: ChatMessage[]; tools: Map; busy: boolean; /** messages queued client-side while the agent runs; sent on settle */ queued: string[]; /** drop a queued message before it is sent (the ✕ on a queued bubble) */ unqueue: (index: number) => void; /** an older page exists beyond the loaded window (B1) */ hasOlder: boolean; loadingOlder: boolean; onLoadOlder: () => void; /** search overlay open (the 🔍 button or the / hotkey in ChatView) */ searchOpen: boolean; onSearchClose: () => void; /** per-bubble timestamps (⋯ menu in ChatView) */ showTs: boolean; } export default function ChatStream({ messages, tools, busy, queued, unqueue, hasOlder, loadingOlder, onLoadOlder, searchOpen, onSearchClose, showTs, }: Props) { const scrollRef = useRef(null); const pinnedRef = useRef(true); const rowRefs = useRef>(new Map()); const [query, setQuery] = useState(""); const [matchIdx, setMatchIdx] = useState(0); const [showFab, setShowFab] = useState(false); const awayFromBottom = (): number => { const el = scrollRef.current; if (el === null) return 0; return el.scrollHeight - el.scrollTop - el.clientHeight; }; const onScroll = (): void => { if (scrollRef.current === null) return; const away: number = awayFromBottom(); pinnedRef.current = away < PIN_THRESHOLD_PX; setShowFab(away > FAB_THRESHOLD_PX); }; useEffect(() => { const el = scrollRef.current; if (el === null) return; if (pinnedRef.current) el.scrollTop = el.scrollHeight; setShowFab(awayFromBottom() > FAB_THRESHOLD_PX); }, [messages, busy]); useEffect(() => { pinnedRef.current = true; const el = scrollRef.current; if (el !== null) el.scrollTop = el.scrollHeight; }, []); // overlay closed → query and cursor reset (ChatStream is keyed per session, // but the overlay must also forget a stale query when merely closed) useEffect(() => { if (!searchOpen) { setQuery(""); setMatchIdx(0); } }, [searchOpen]); const matches: string[] = useMemo(() => { const q: string = query.trim().toLowerCase(); if (q.length === 0) return []; return messages .filter( (m) => !resultSkipped(m, tools) && searchHaystack(m).includes(q), ) .map((m) => m.key); }, [messages, tools, query]); // live events can shrink the match set under the cursor useEffect(() => { setMatchIdx((i) => matches.length === 0 ? 0 : Math.min(i, matches.length - 1), ); }, [matches.length]); const cycleMatch = (dir: 1 | -1): void => { if (matches.length === 0) return; const len: number = matches.length; const next: number = (((matchIdx + dir) % len) + len) % len; setMatchIdx(next); const key: string | undefined = matches[next]; if (key !== undefined) rowRefs.current.get(key)?.scrollIntoView({ block: "center" }); }; const jumpToBottom = (): void => { const el = scrollRef.current; if (el === null) return; pinnedRef.current = true; el.scrollTop = el.scrollHeight; setShowFab(false); }; const currentKey: string | undefined = query.length > 0 && matches.length > 0 ? matches[matchIdx] : undefined; const last: ChatMessage | undefined = messages[messages.length - 1]; const streamingOpen: boolean = last !== undefined && last.streaming; const showTyping: boolean = busy && !streamingOpen; return (
{hasOlder && (
)} {messages.map((m) => (
{ if (el === null) rowRefs.current.delete(m.key); else rowRefs.current.set(m.key, el); }} >
))} {queued.map((text, i) => (
{text}
))} {showTyping && }
{searchOpen && (
{ setQuery(e.target.value); setMatchIdx(0); }} onKeyDown={(e) => { if (e.key === ESCAPE_KEY) onSearchClose(); else if (e.key === ENTER_KEY) { e.preventDefault(); cycleMatch(e.shiftKey ? -1 : 1); } }} /> {query.length > 0 && ( {matches.length === 0 ? 0 : matchIdx + 1}/{matches.length} )}
)} {showFab && ( )}
); }