import { takePrefill } from "../share"; import { Icon } from "./Icon"; import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"; import { del, get, patch, post } from "../api"; import { useFetch, useModels } from "../hooks"; import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n"; import { navigate } from "../router"; import { dismissError, getState, loadAround, loadOlder, loadSession, loadSessions, useStore } from "../store"; import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types"; import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity"; import { useReasoningMode, useTodosCollapsed } from "../prefs"; import { Composer } from "./Composer"; import { ShareDialog } from "./ShareDialog"; import { RenameDialog } from "./RenameDialog"; import { InstructionsDialog } from "./InstructionsDialog"; import { RegenerateButton } from "./Regenerate"; import { WorktreeChip } from "./WorktreeChip"; import { QueueList } from "./QueueList"; import { BudgetChip } from "./Budget"; import { PermissionCard, QuestionCard } from "./Interaction"; import { ImageRefs, Markdown, projectFileUrl } from "./Markdown"; import { UsageOverlay } from "./UsageOverlay"; import { RewindDialog } from "./RewindDialog"; import { ShellsChip } from "./ShellsPanel"; import { TodoList, ToolCard } from "./ToolCard"; import { confirmDialog, promptDialog, toast } from "../dialogs"; import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs"; import { MediaGallery } from "./MediaGallery"; type ToolResult = Extract; type ToolCall = Extract; const EMPTY: Message[] = []; interface SessionStats { total: Aggregate; by_model: (Aggregate & { model: string })[]; currency: string; } function Reasoning({ text, live }: { text: string; live: boolean }) { const t = useT(); const mode = useReasoningMode(); const [toggled, setToggled] = useState(null); const bodyRef = useRef(null); const open = toggled ?? (mode === "expanded" || (mode === "live" && live)); // Follow the stream while the model is thinking. useLayoutEffect(() => { if (live && bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; }, [text, live]); if (mode === "hidden" || !text.trim()) return null; const words = text.trim().split(/\s+/).length; return (
{open && (
{text}
)}
); } function AssistantParts({ parts, results, running, live, baseDir }: { parts: Part[]; results: Map; running: boolean; live: boolean; baseDir: string }) { return ( <> {parts.map((p, i) => { switch (p.type) { case "text": return (
{!live && }
); case "reasoning": return ; case "tool_call": return ; default: return null; } })} ); } const BG_RE = /^\n?([\s\S]*?)\n?<\/background-task-result>$/; /** Result of a background subagent, delivered as a message. */ function BackgroundResult({ text }: { text: string }) { const t = useT(); const m = text.match(BG_RE); if (!m) return null; const [, agent, session, description, body] = m; return (
{agent} {t("chat.backgroundDone")}: {description} e.stopPropagation()}> {t("tool.openSubagent")}
); } const REFINE_RE = /^\n?([\s\S]*?)\n?<\/refine-feedback>/; /** Enforced reviews: a completed todo passed, or a reminder about open todos. */ const NOTE_RE = /^<(refine-approved|todo-reminder)(?: reviewer="([^"]*)")?(?: open="\d+")?>\n?([\s\S]*?)\n?<\/(?:refine-approved|todo-reminder)>/; /** Messages from the harness shown as cards instead of user bubbles. */ const isHarnessNote = (text: string) => BG_RE.test(text) || REFINE_RE.test(text) || NOTE_RE.test(text); /** A todo passed its review, or the agent was sent back to its open todos. */ function HarnessNote({ text }: { text: string }) { const t = useT(); const m = text.match(NOTE_RE); if (!m) return null; const [, kind, reviewer, body] = m; const items = body.split("\n").map((l) => l.replace(/^- /, "").trim()).filter(Boolean); const approved = kind === "refine-approved"; return (
{t(approved ? "chat.reviewPassed" : "chat.todoReminder", { n: items.length })} {items.join(" · ")} {reviewer && {reviewer}}
); } function HarnessMessage({ text }: { text: string }) { if (REFINE_RE.test(text)) return ; if (NOTE_RE.test(text)) return ; return ; } /** Findings of the review loop, sent back to the agent. */ function RefineFeedback({ text }: { text: string }) { const t = useT(); const m = text.match(REFINE_RE); if (!m) return null; const [, round, reviewer, body] = m; return (
{t("chat.reviewRound", { round })} {reviewer}
); } /** Search the whole chat on the server; a hit loads its part of the chat. */ function ChatSearch({ sessionId, onPick, onClose }: { sessionId: string; onPick: (messageId: string) => void; onClose: () => void }) { const t = useT(); const [q, setQ] = useState(""); const [hits, setHits] = useState<{ id: string; role: string; created_at: number; snippet: string }[]>(); useEffect(() => { const query = q.trim(); if (query.length < 2) return setHits(undefined); let alive = true; const h = setTimeout(() => { get(`/sessions/${sessionId}/search?q=${encodeURIComponent(query)}`) .then((r) => alive && setHits(r ?? [])) .catch(() => alive && setHits([])); }, 250); return () => { alive = false; clearTimeout(h); }; }, [q, sessionId]); return (
setQ(e.target.value)} onKeyDown={(e) => e.key === "Escape" && onClose()} aria-label={t("chat.search")} />
{hits && (
{hits.length ? t("chat.searchHits", { n: hits.length }) : t("chat.searchNone")}
{hits.map((h) => ( ))}
)}
); } /** A part of the chat: a user message, a summary, or the work of the agent after it. */ type Block = { kind: "user" | "summary" | "work"; messages: Message[] }; const hasUserText = (m: Message) => m.role === "user" && m.parts.some((p) => (p.type === "text" && !isHarnessNote(p.text)) || p.type === "image"); /** Group the messages into user messages and the agent's work between them. */ function blocksOf(messages: Message[]): Block[] { const out: Block[] = []; for (const m of messages) { const kind = m.parts.some((p) => p.type === "summary") ? "summary" : hasUserText(m) ? "user" : "work"; const last = out[out.length - 1]; if (kind === "work" && last?.kind === "work") last.messages.push(m); else out.push({ kind, messages: [m] }); } return out; } /** * The messages of a chat. Memoised: typing, streaming and status changes of the * chat view do not touch it – only new messages, tool results or the busy state. */ const MessageList = memo(function MessageList({ messages, results, busy, canEdit, canRegenerate, sessionId, model, baseDir, onRewind, highlight, currency, }: { messages: Message[]; currency: string; results: Map; busy: boolean; canEdit: boolean; canRegenerate: boolean; sessionId: string; model: string; baseDir: string; onRewind: (m: Message) => void; highlight?: string; }) { const blocks = useMemo(() => blocksOf(messages), [messages]); const lastWork = blocks.map((b) => b.kind).lastIndexOf("work"); const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")?.id; return ( <> {blocks.map((b, i) => { // Stable while older messages are loaded above (they may join the first block): // the last message; the block still growing at the end keeps its first one. const key = b.kind === "work" && i !== lastWork ? b.messages[b.messages.length - 1].id : b.messages[0].id; if (b.kind !== "work") return ; const items = b.messages.map((m) => ( )); // Finished turns with tool steps fold into one line (the answer stays visible). const steps = b.messages.flatMap((m) => m.parts.filter((p) => p.type === "tool_call")) as ToolCall[]; const usage = !(busy && i === lastWork) && ; if (i === lastWork || steps.length < 2) return (
{items} {usage}
); return (
m.id === highlight)} baseDir={baseDir}> {items} {usage}
); })} ); }); /** Tokens (in/out) and cost of one answer: all requests of the turn together. */ function TurnUsage({ messages, currency }: { messages: Message[]; currency: string }) { const t = useT(); let inp = 0; let out = 0; let cost = 0; let requests = 0; for (const m of messages) { const u = m.role === "assistant" ? m.usage : undefined; if (!u) continue; requests++; inp += u.input_tokens + u.cache_read_tokens + u.cache_write_tokens; out += u.output_tokens; cost += u.cost ?? 0; } if (!requests) return null; return (
{fmtCompact(inp)} {fmtCompact(out)} · {fmtCost(cost, currency)}
); } /** A finished turn: "42 steps · 3 files" and the answer; the steps on click. */ function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messages: Message[]; steps: ToolCall[]; open: boolean; baseDir: string; children: ReactNode }) { const t = useT(); const [open, setOpen] = useState(false); const files = new Set(steps.filter((c) => ["write", "edit"].includes(c.name) && typeof c.input?.path === "string").map((c) => c.input.path as string)); // The answer: text of the last assistant message of the turn. const answer = [...messages].reverse().find((m) => m.role === "assistant" && m.parts.some((p) => p.type === "text" && p.text.trim())); const answerText = answer ? (answer.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n") : ""; if (open || forced) { return (
{children}
); } return (
{answerText && ( <> )}
); } /** An assistant message, or a user message that only carries tool results / harness notes. */ const AssistantOrNote = memo(function AssistantOrNote({ m, results, busy, baseDir, highlight, regenerate, }: { m: Message; results: Map; busy: boolean; baseDir: string; highlight?: string; regenerate?: { sessionId: string; model: string }; }) { const cls = `msg ${highlight === m.id ? "highlight" : ""}`; if (m.role === "assistant") { return (
{regenerate && }
); } const notes = (m.parts.filter((p) => p.type === "text") as { text: string }[]).filter((p) => isHarnessNote(p.text)); if (!notes.length) return null; return (
{notes.map((p, i) => ( ))}
); }); /** A user message or a summary of the history before it. */ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight }: { m: Message; canEdit: boolean; onRewind: (m: Message) => void; highlight?: string }) { const t = useT(); const [summaryOpen, setSummaryOpen] = useState(false); const summary = m.parts.find((p) => p.type === "summary"); if (summary && summary.type === "summary") { return (
setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}> {summary.auto ? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" }) : summary.tokens ? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) }) : t("chat.compacted")} {summaryOpen && }
); } const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[]; const background = allTexts.filter((p) => isHarnessNote(p.text)); const texts = allTexts.filter((p) => !isHarnessNote(p.text)); const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[]; return (
{background.map((p, i) => ( ))}
{canEdit && (
)} p.text).join("\n\n")} /> {imgs.length > 0 && (
{imgs.map((p, i) => ( ))}
)}
); }); /** * Older messages: loaded automatically when scrolled to the top; the history before a * summary only on request. Keeps the scroll position while the list grows upwards. */ function OlderLoader({ sessionId, afterSummary, scrollRef }: { sessionId: string; afterSummary?: boolean; scrollRef: RefObject }) { const t = useT(); const [loading, setLoading] = useState(false); const sentinel = useRef(null); const load = useCallback(async () => { const el = scrollRef.current; if (loading || !el) return; setLoading(true); const before = { height: el.scrollHeight, top: el.scrollTop }; try { // Scrolling stops at a summary; its button loads the history before it. await loadOlder(sessionId, !afterSummary); requestAnimationFrame(() => { el.scrollTop = before.top + (el.scrollHeight - before.height); }); } finally { setLoading(false); } }, [loading, sessionId, scrollRef, afterSummary]); useEffect(() => { const node = sentinel.current; if (!node || afterSummary || typeof IntersectionObserver === "undefined") return; const io = new IntersectionObserver((e) => e[0]?.isIntersecting && void load(), { root: scrollRef.current, rootMargin: "300px 0px 0px 0px" }); io.observe(node); return () => io.disconnect(); }, [afterSummary, load, scrollRef]); return (
{loading ? ( ) : ( )}
); } export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string; onMenu: () => void; onClosePane?: () => void }) { const t = useT(); const session = useStore((s) => s.sessionById[sessionId]); const messages = useStore((s) => s.messages[sessionId] ?? EMPTY); const win = useStore((s) => s.windows[sessionId]); // Message to show (search hit, media): scrolled into view and marked. const [highlight, setHighlight] = useState(); const [searching, setSearching] = useState(false); const [gallery, setGallery] = useState(false); const draft = useStore((s) => s.drafts[sessionId]); const busy = useStore((s) => !!s.busy[sessionId]); const errors = useStore((s) => s.errors[sessionId]); const allPermissions = useStore((s) => s.permissions); const allQuestions = useStore((s) => s.questions); const waiting = useStore((s) => s.waiting[sessionId]); const compacting = useStore((s) => s.compacting[sessionId]); const readOnly = useStore((s) => !!s.readOnly[sessionId]); const isAdmin = useStore((s) => s.me?.role === "admin"); const tabs = useChatTabs(sessionId, session?.cwd ?? ""); const [sharing, setSharing] = useState(false); // The Telegram switch in the ⋯ menu only matters with a paired bot. const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false; const [editingInstructions, setEditingInstructions] = useState(false); const models = useModels(); const [loadError, setLoadError] = useState(); const [text, setText] = useState(() => takePrefill(sessionId) ?? ""); const [stats, setStats] = useState(); const [showStats, setShowStats] = useState(false); const [editingTitle, setEditingTitle] = useState(false); const scrollRef = useRef(null); // Follow the newest message until the user scrolls up; the ↓ button resumes. const stick = useRef(true); const [following, setFollowing] = useState(true); const lastScroll = useRef({ top: 0, height: 0 }); const follow = (on: boolean) => { stick.current = on; setFollowing(on); }; const jumpToLatest = () => { follow(true); const el = scrollRef.current; if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); }; // Every chat starts following. useEffect(() => follow(true), [sessionId]); useEffect(() => { loadSession(sessionId).catch((e) => setLoadError(e.message)); }, [sessionId]); const permissions = allPermissions.filter((p) => p.session_id === sessionId || p.root_session_id === sessionId); const questions = allQuestions.filter((q) => q.session_id === sessionId || q.root_session_id === sessionId); // Session cost/tokens; refreshed when messages arrive. useEffect(() => { get(`/sessions/${sessionId}/stats`).then(setStats).catch(() => undefined); }, [sessionId, messages.length]); const results = useMemo(() => { const m = new Map(); for (const msg of messages) for (const p of msg.parts) if (p.type === "tool_result") m.set(p.call_id, p); return m; }, [messages]); // Keep scrolled to the bottom while the user hasn't scrolled up (only when the // content changes – reading the height forces a layout). const permCount = permissions.length + questions.length; useLayoutEffect(() => { const el = scrollRef.current; if (el && stick.current) el.scrollTop = el.scrollHeight; }, [messages, draft, busy, waiting, compacting, permCount, errors]); // Stable callbacks for the (memoised) message list. const rewindRef = useRef<(m: Message) => void>(() => undefined); const onRewind = useCallback((m: Message) => rewindRef.current(m), []); const showLatest = useCallback(async () => { setHighlight(undefined); await loadSession(sessionId); follow(true); }, [sessionId]); /** Load the part of the chat around a message and scroll to it. */ const jumpTo = useCallback( async (messageId: string) => { follow(false); if (!getState().messages[sessionId]?.some((m) => m.id === messageId)) await loadAround(sessionId, messageId); setHighlight(messageId); }, [sessionId], ); useEffect(() => { if (!highlight) return; const h = setTimeout(() => document.querySelector(`[data-mid="${highlight}"]`)?.scrollIntoView({ block: "center" }), 50); return () => clearTimeout(h); }, [highlight, messages]); // … also when the pane gets smaller (on-screen keyboard opens) without a render. const hasSession = !!session; useEffect(() => { const el = scrollRef.current; if (!el || typeof ResizeObserver === "undefined") return; const ro = new ResizeObserver(() => { if (stick.current) el.scrollTop = el.scrollHeight; }); ro.observe(el); return () => ro.disconnect(); }, [hasSession, sessionId]); if (loadError) return
{loadError}
; if (!session) return
{t("common.loading")}
; const update = async (p: Partial) => { try { await patch(`/sessions/${sessionId}`, p); } catch (e: any) { toast(e.message); } }; const forkSession = async () => { try { const fork = await post(`/sessions/${sessionId}/fork`, {}); await loadSessions(); toast(t("chat.forked")); navigate(`/s/${fork.id}`); } catch (e: any) { toast(e.message); } }; const send = async (raw: string, images: ImageData[], effort?: string) => { const txt = raw.trim(); if (txt === "/new") { setText(""); return navigate("/new"); } try { await post(`/sessions/${sessionId}/prompt`, { text: txt, images, effort: effort || undefined }); setText(""); follow(true); } catch (e: any) { toast(e.message); } }; const [rewinding, setRewinding] = useState(); const rewind = (m: Message) => setRewinding(m); rewindRef.current = rewind; const rewindDone = async () => { const m = rewinding!; setRewinding(undefined); await loadSession(sessionId); setText(m.parts.map((p) => (p.type === "text" ? p.text : "")).join("")); }; const remove = async () => { if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return; await del(`/sessions/${sessionId}`); navigate(session.parent_id ? `/s/${session.parent_id}` : "/new"); }; // Context usage of the last request relative to the model's window. const [prov, ...rest] = session.model.split("/"); const modelInfo = models.find((p) => p.provider === prov)?.models.find((m) => m.id === rest.join("/")); const u = session.last_usage; const ctxTokens = u ? u.input_tokens + u.cache_read_tokens + u.cache_write_tokens + u.output_tokens : 0; const ctxShare = modelInfo?.context && ctxTokens ? ctxTokens / modelInfo.context : undefined; const todos = Array.isArray(session.todos) ? session.todos : []; const openTodos = todos.filter((x) => x.status !== "completed"); const lastIsAssistant = messages.length > 0 && messages[messages.length - 1].role === "assistant"; const lastMsg = messages[messages.length - 1]; const pendingCall = lastIsAssistant ? (lastMsg.parts.filter((p) => p.type === "tool_call" && !results.has(p.id)).pop() as Extract | undefined) : undefined; const pendingTool = busy && pendingCall ? { name: pendingCall.name, input: pendingCall.input, since: lastMsg.created_at } : undefined; return ( <>

!readOnly && setEditingTitle(true)} title={readOnly ? undefined : t("chat.renameHint")}> {session.parent_id && ( )} {session.title || t("session.untitled")} {!readOnly && ( )}

{session.cwd}
{ctxShare !== undefined && ( 0.7 ? "warn" : ""}`} title={t("chat.contextHint", { tokens: fmtNumber(ctxTokens) })}> {t("chat.context")} {fmtK(ctxTokens)}/{fmtK(modelInfo!.context!)} · {fmtPercent(ctxShare)} )} {stats && ( )} {isAdmin && } {session.permission_mode === "bypass" && {t("permissionMode.bypass")}} {!!session.instructions && ( )} {readOnly && {t("chat.readOnly")}} {!readOnly && ( <> {!session.parent_id && ( )} {!session.parent_id && ( )}
{ e.preventDefault(); (e.currentTarget.closest("details") as HTMLDetailsElement).open = false; setEditingInstructions(true); }} > {t("instructions.title")} { e.preventDefault(); (e.currentTarget.closest("details") as HTMLDetailsElement).open = false; setGallery(true); }} > {t("media.title")} {telegramPaired && !session.parent_id && ( { e.preventDefault(); void update({ telegram_quiet: !session.telegram_quiet }); }} > {t("chat.telegram")} )} {t("chat.exportMd")} {t("chat.exportJson")} {!session.parent_id && !session.worktree && ( { e.preventDefault(); (e.currentTarget.closest("details") as HTMLDetailsElement).open = false; try { await post(`/sessions/${sessionId}/worktree`, { action: "create" }); toast(t("worktree.created"), "success"); } catch (err: any) { toast(err.message); } }} > {t("worktree.create")} )}
)} {onClosePane && ( )}
{gallery && ( setGallery(false)} onJump={(id) => { setGallery(false); void jumpTo(id); }} /> )} {searching && void jumpTo(id)} onClose={() => setSearching(false)} />} ); } /** Attached-file lines ("- /path (12.3 KB)") of a user message become file chips. */ const ATTACHED_RE = /^- (\/\S+) \(([\d.]+ (?:B|KB|MB))\)$/; function UserText({ text }: { text: string }) { const lines = text.split("\n"); const files: { path: string; size: string }[] = []; const rest: string[] = []; for (const l of lines) { const m = l.match(ATTACHED_RE); if (m) files.push({ path: m[1], size: m[2] }); else rest.push(l); } // Drop the heading line ("Attached files …:") right before the list. while (files.length && rest.length && (rest[rest.length - 1].trim() === "" || rest[rest.length - 1].trim().endsWith(":"))) { const last = rest.pop()!; if (last.trim().endsWith(":")) break; } const body = rest.join("\n").trim(); return ( <> {body && } {files.length > 0 && (
{files.map((f) => ( ))}
)} ); } /** Token counts like "221k" / "1.2M" (same in every language). */ function fmtK(n: number) { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1).replace(/\.0$/, "")}M`; if (n >= 1000) return `${Math.round(n / 1000)}k`; return String(n); } /** Craft workflow progress: Study → (Specify) → Build → Check → Ready. */ function PhaseBar({ phase, deep }: { phase?: string; deep: boolean }) { const t = useT(); const phases = deep ? ["study", "specify", "build", "check", "ready"] : ["study", "build", "check", "ready"]; const cur = phase ? phases.indexOf(phase) : -1; return (
{phases.map((p, i) => { const state = i < cur || (p === "ready" && cur === i) ? "done" : i === cur ? "current" : ""; return ( {i > 0 && } {state === "done" && ( )} {t(`phase.${p}` as Key)} ); })}
); } /** Tasks of the model above the composer; folds to one line (current task, progress). */ function TodoPanel({ todos }: { todos: Todo[] }) { const t = useT(); const [collapsed, setCollapsed] = useTodosCollapsed(); const done = todos.filter((x) => x.status === "completed").length; const current = todos.find((x) => x.status === "in_progress"); return (
{!collapsed && }
); }