import { takePrefill } from "../share"; import { Icon } from "./Icon"; import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { del, get, patch, post } from "../api"; import { useModels } from "../hooks"; import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n"; import { navigate } from "../router"; import { dismissError, loadSession, loadSessions, useStore } from "../store"; import type { Aggregate, ImageData, Message, Part, Session } from "../types"; import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity"; import { useReasoningMode } from "../prefs"; import { Composer } from "./Composer"; import { ShareDialog } from "./ShareDialog"; import { RenameDialog } from "./RenameDialog"; 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"; type ToolResult = 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>/; /** 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}
); } 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 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); 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. useLayoutEffect(() => { const el = scrollRef.current; if (el && stick.current) el.scrollTop = el.scrollHeight; }); 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 lastAssistantIdx = messages.reduce((acc, m, i) => (m.role === "assistant" ? i : acc), -1); const [rewinding, setRewinding] = useState(); const rewind = (m: Message) => setRewinding(m); 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")}} {readOnly && {t("chat.readOnly")}} {!readOnly && ( <> {!session.parent_id && ( )} {!session.parent_id && ( )}
)} {onClosePane && ( )}
); } /** 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; } return ( <> {rest.join("\n").trimEnd()} {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)} ); })}
); }