Files
codecraft/web/src/components/ChatView.tsx
T
Richard ZinkandClaude Opus 5.5 1e8418336d Value opencode Go plan requests; per-duration cache prices; usage under answers
- opencode Go reports plan requests as cost 0 with the usage in allowance;
  ModelConfig::cost values them at the model's list prices (charged amounts
  and free requests stay as reported). Rows booked at 0 are valued once on
  start (idempotent).
- opencode's price list now has per-duration cache prices for some models
  ({"30m": ..., "12h": ...}): the shortest duration is used, also in tiers.
  Saving the provider's models failed with "untagged enum ModelEntry".
- Each answer shows tokens sent/received and the cost of the turn's requests
  (usage.cost filled when messages are saved and loaded).
- The chat title keeps a minimum width in a crowded top bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 07:58:23 +01:00

1047 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Part, { type: "tool_result" }>;
type ToolCall = Extract<Part, { type: "tool_call" }>;
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<boolean | null>(null);
const bodyRef = useRef<HTMLDivElement>(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 (
<div className={`reasoning ${live ? "live" : ""}`}>
<button className="reasoning-head" onClick={() => setToggled(!open)} aria-expanded={open}>
{live ? <Dots /> : <ThinkIcon />}
<span>{live ? t("chat.reasoningLive") : t("chat.reasoningDone", { n: words })}</span>
<Icon name={open ? "chevron-down" : "chevron-right"} />
</button>
{open && (
<div ref={bodyRef} className={`body ${live ? "streaming-text" : ""}`}>
{text}
</div>
)}
</div>
);
}
function AssistantParts({ parts, results, running, live, baseDir }: { parts: Part[]; results: Map<string, ToolResult>; running: boolean; live: boolean; baseDir: string }) {
return (
<>
{parts.map((p, i) => {
switch (p.type) {
case "text":
return (
<div key={i} className={live && i === parts.length - 1 ? "streaming" : undefined}>
<Markdown text={p.text} baseDir={baseDir} />
{!live && <ImageRefs text={p.text} baseDir={baseDir} />}
</div>
);
case "reasoning":
return <Reasoning key={i} text={p.text} live={live && i === parts.length - 1} />;
case "tool_call":
return <ToolCard key={p.id} call={p} result={results.get(p.id)} running={running} baseDir={baseDir} />;
default:
return null;
}
})}
</>
);
}
const BG_RE = /^<background-task-result agent="([^"]*)" session="([^"]*)" description="([^"]*)">\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 (
<details className="tool" style={{ borderColor: "var(--aqua)" }}>
<summary>
<span className="tool-name">{agent}</span>
<span className="tool-title">
{t("chat.backgroundDone")}: {description}
</span>
<a href={`#/s/${session}`} onClick={(e) => e.stopPropagation()}>
{t("tool.openSubagent")}
</a>
</summary>
<div className="tool-body">
<Markdown text={body} />
</div>
</details>
);
}
const REFINE_RE = /^<refine-feedback round="([^"]*)" reviewer="([^"]*)"(?: todos="\d+")?>\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 (
<div className={`harness-note ${approved ? "ok" : ""}`}>
<Icon name={approved ? "check" : "refresh"} />
<span className="grow">
<b>{t(approved ? "chat.reviewPassed" : "chat.todoReminder", { n: items.length })}</b> {items.join(" · ")}
</span>
{reviewer && <span className="chip">{reviewer}</span>}
</div>
);
}
function HarnessMessage({ text }: { text: string }) {
if (REFINE_RE.test(text)) return <RefineFeedback text={text} />;
if (NOTE_RE.test(text)) return <HarnessNote text={text} />;
return <BackgroundResult text={text} />;
}
/** 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 (
<details className="tool refine-card" open>
<summary>
<Icon name="shield" />
<span className="tool-title">{t("chat.reviewRound", { round })}</span>
<span className="chip">{reviewer}</span>
</summary>
<div className="tool-body">
<Markdown text={body} />
</div>
</details>
);
}
/** 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<typeof hits>(`/sessions/${sessionId}/search?q=${encodeURIComponent(query)}`)
.then((r) => alive && setHits(r ?? []))
.catch(() => alive && setHits([]));
}, 250);
return () => {
alive = false;
clearTimeout(h);
};
}, [q, sessionId]);
return (
<div className="chat-search" role="search">
<div className="row">
<Icon name="search" />
<input autoFocus value={q} placeholder={t("chat.searchPlaceholder")} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Escape" && onClose()} aria-label={t("chat.search")} />
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
<Icon name="close" />
</button>
</div>
{hits && (
<div className="chat-search-hits">
<div className="hint">{hits.length ? t("chat.searchHits", { n: hits.length }) : t("chat.searchNone")}</div>
{hits.map((h) => (
<button key={h.id} onClick={() => onPick(h.id)}>
<span className="who">
{t(h.role === "assistant" ? "chat.agent" : "chat.you")} · {new Date(h.created_at).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
</span>
<span className="snippet">{h.snippet}</span>
</button>
))}
</div>
)}
</div>
);
}
/** 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<string, ToolResult>;
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 <MessageItem key={key} m={b.messages[0]} canEdit={canEdit} onRewind={onRewind} highlight={highlight} />;
const items = b.messages.map((m) => (
<AssistantOrNote
key={m.id}
m={m}
results={results}
busy={busy}
baseDir={baseDir}
highlight={highlight}
regenerate={canRegenerate && m.id === lastAssistant ? { sessionId, model } : undefined}
/>
));
// 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) && <TurnUsage messages={b.messages} currency={currency} />;
if (i === lastWork || steps.length < 2)
return (
<div key={key}>
{items}
{usage}
</div>
);
return (
<div key={key}>
<WorkBlock messages={b.messages} steps={steps} open={!!highlight && b.messages.some((m) => m.id === highlight)} baseDir={baseDir}>
{items}
</WorkBlock>
{usage}
</div>
);
})}
</>
);
});
/** 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 (
<div className="turn-usage" title={t("chat.turnUsageHint", { n: requests })}>
{fmtCompact(inp)}
<Icon name="arrow-up" className="tiny" /> {fmtCompact(out)}
<Icon name="arrow-down" className="tiny" /> · {fmtCost(cost, currency)}
</div>
);
}
/** 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 (
<div className="work-block open">
<button className="work-head" onClick={() => setOpen(false)} aria-expanded>
<Icon name="chevron-down" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
</button>
{children}
</div>
);
}
return (
<div className="msg work-block" data-mid={answer?.id}>
<button className="work-head" onClick={() => setOpen(true)} aria-expanded={false}>
<Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
</button>
{answerText && (
<>
<Markdown text={answerText} baseDir={baseDir} />
<ImageRefs text={answerText} baseDir={baseDir} />
</>
)}
</div>
);
}
/** 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<string, ToolResult>;
busy: boolean;
baseDir: string;
highlight?: string;
regenerate?: { sessionId: string; model: string };
}) {
const cls = `msg ${highlight === m.id ? "highlight" : ""}`;
if (m.role === "assistant") {
return (
<div className={cls} data-mid={m.id}>
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
{regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />}
</div>
);
}
const notes = (m.parts.filter((p) => p.type === "text") as { text: string }[]).filter((p) => isHarnessNote(p.text));
if (!notes.length) return null;
return (
<div className={cls} data-mid={m.id}>
{notes.map((p, i) => (
<HarnessMessage key={i} text={p.text} />
))}
</div>
);
});
/** 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 (
<details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}>
<summary>
{summary.auto
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
: summary.tokens
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
: t("chat.compacted")}
</summary>
{summaryOpen && <Markdown text={summary.text} />}
</details>
);
}
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 (
<div className={`msg ${highlight === m.id ? "highlight" : ""}`} data-mid={m.id}>
{background.map((p, i) => (
<HarnessMessage key={i} text={p.text} />
))}
<div className="msg-user">
{canEdit && (
<div className="msg-actions">
<button className="small ghost" onClick={() => onRewind(m)} title={t("chat.rewind")}>
<Icon name="edit" />
</button>
</div>
)}
<UserText text={texts.map((p) => p.text).join("\n\n")} />
{imgs.length > 0 && (
<div className="user-files">
{imgs.map((p, i) => (
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
</FileFigure>
))}
</div>
)}
</div>
</div>
);
});
/**
* 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<HTMLDivElement | null> }) {
const t = useT();
const [loading, setLoading] = useState(false);
const sentinel = useRef<HTMLDivElement>(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 (
<div ref={sentinel} className="older-loader">
{loading ? (
<Spinner small />
) : (
<button className="small ghost" onClick={() => void load()}>
<Icon name="arrow-up" /> {t(afterSummary ? "chat.showBeforeSummary" : "chat.loadOlder")}
</button>
)}
</div>
);
}
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<string>();
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<string>();
const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
const [stats, setStats] = useState<SessionStats>();
const [showStats, setShowStats] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const scrollRef = useRef<HTMLDivElement>(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<SessionStats>(`/sessions/${sessionId}/stats`).then(setStats).catch(() => undefined);
}, [sessionId, messages.length]);
const results = useMemo(() => {
const m = new Map<string, ToolResult>();
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 <div className="empty">{loadError}</div>;
if (!session) return <div className="empty">{t("common.loading")}</div>;
const update = async (p: Partial<Session>) => {
try {
await patch(`/sessions/${sessionId}`, p);
} catch (e: any) {
toast(e.message);
}
};
const forkSession = async () => {
try {
const fork = await post<Session>(`/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<Message>();
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<Part, { type: "tool_call" }> | undefined)
: undefined;
const pendingTool = busy && pendingCall ? { name: pendingCall.name, input: pendingCall.input, since: lastMsg.created_at } : undefined;
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
<div className="grow">
<h1 onDoubleClick={() => !readOnly && setEditingTitle(true)} title={readOnly ? undefined : t("chat.renameHint")}>
{session.parent_id && (
<a href={`#/s/${session.parent_id}`} style={{ marginRight: 8 }}>
<Icon name="undo" />
</a>
)}
{session.title || t("session.untitled")}
{!readOnly && (
<button className="ghost small title-edit" onClick={() => setEditingTitle(true)} title={t("rename.title")} aria-label={t("rename.title")}>
<Icon name="edit" />
</button>
)}
</h1>
<div className="path">{session.cwd}</div>
</div>
{ctxShare !== undefined && (
<span className={`chip hide-mobile ${ctxShare > 0.7 ? "warn" : ""}`} title={t("chat.contextHint", { tokens: fmtNumber(ctxTokens) })}>
{t("chat.context")} {fmtK(ctxTokens)}/{fmtK(modelInfo!.context!)} · {fmtPercent(ctxShare)}
</span>
)}
{stats && (
<button className="chip" style={{ border: "none" }} onClick={() => setShowStats((x) => !x)} title={t("chat.statsHint")}>
<span className="hide-mobile">
{fmtCompact(stats.total.input_tokens + stats.total.cache_read_tokens + stats.total.cache_write_tokens)}<Icon name="arrow-up" className="tiny" />{" "}
{fmtCompact(stats.total.output_tokens)}<Icon name="arrow-down" className="tiny" /> ·{" "}
</span>
{fmtCost(stats.total.cost, stats.currency)}
</button>
)}
<WorktreeChip session={session} busy={busy} />
<ShellsChip sessionId={sessionId} busy={busy} />
{isAdmin && <BudgetChip />}
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
{!!session.instructions && (
<button className="chip" style={{ border: "none" }} onClick={() => !readOnly && setEditingInstructions(true)} title={session.instructions}>
<Icon name="edit" /> {t("instructions.chip")}
</button>
)}
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
{!readOnly && (
<>
{!session.parent_id && (
<button className="small ghost" onClick={() => setSharing(true)} title={t("share.title")}>
<Icon name="share" />
</button>
)}
<button
className="small ghost hide-mobile"
onClick={() => void tabs.openTerminal()}
title={t("terminal.here")}
>
<Icon name="terminal" />
</button>
<button className={`small ghost ${searching ? "active" : ""}`} onClick={() => setSearching(!searching)} title={t("chat.search")} aria-label={t("chat.search")}>
<Icon name="search" />
</button>
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}>
<Icon name="folder" />
</button>
{!session.parent_id && (
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
<Icon name="fork" />
</button>
)}
<details className="menu-pop">
<summary className="small ghost" title={t("chat.more")} aria-label={t("chat.more")}>
<Icon name="more" />
</summary>
<div className="menu-pop-body">
<a
href="#"
onClick={(e) => {
e.preventDefault();
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
setEditingInstructions(true);
}}
>
<Icon name="edit" /> {t("instructions.title")}
</a>
<a
href="#"
onClick={(e) => {
e.preventDefault();
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
setGallery(true);
}}
>
<Icon name="image" /> {t("media.title")}
</a>
{telegramPaired && !session.parent_id && (
<a
href="#"
role="menuitemcheckbox"
aria-checked={!session.telegram_quiet}
title={t("chat.telegramHint")}
onClick={(e) => {
e.preventDefault();
void update({ telegram_quiet: !session.telegram_quiet });
}}
>
<Icon name={session.telegram_quiet ? "box-off" : "box-on"} /> {t("chat.telegram")}
</a>
)}
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
<Icon name="download" /> {t("chat.exportMd")}
</a>
<a href={`/api/sessions/${sessionId}/export?format=json`} download>
<Icon name="download" /> {t("chat.exportJson")}
</a>
{!session.parent_id && !session.worktree && (
<a
href="#"
onClick={async (e) => {
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);
}
}}
>
<Icon name="fork" /> {t("worktree.create")}
</a>
)}
</div>
</details>
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
<Icon name="compact" />
</button>
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
<Icon name="trash" />
</button>
</>
)}
{onClosePane && (
<button className="small ghost" onClick={onClosePane} title={t("chat.closePane")} aria-label={t("chat.closePane")}>
<Icon name="close" />
</button>
)}
</div>
{gallery && (
<MediaGallery
sessionId={sessionId}
onClose={() => setGallery(false)}
onJump={(id) => {
setGallery(false);
void jumpTo(id);
}}
/>
)}
{searching && <ChatSearch sessionId={sessionId} onPick={(id) => void jumpTo(id)} onClose={() => setSearching(false)} />}
<ChatTabBar state={tabs} />
<ChatTabPanes state={tabs} />
<div className="chat-pane" hidden={tabs.tab !== "chat"}>
{session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
<div
className="chat-scroll"
ref={scrollRef}
onScroll={(e) => {
const el = e.currentTarget;
const last = lastScroll.current;
const fromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
// Scrolling up (not content shrinking) stops following …
if (el.scrollTop < last.top - 2 && el.scrollHeight >= last.height && stick.current) follow(false);
// … scrolling back to the very end resumes it.
else if (fromBottom < 4 && !stick.current && el.scrollTop > last.top) follow(true);
lastScroll.current = { top: el.scrollTop, height: el.scrollHeight };
}}
onWheel={(e) => {
if (e.deltaY < 0 && stick.current) follow(false);
}}
>
<div className="chat">
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
{win?.older && <OlderLoader sessionId={sessionId} afterSummary={messages[0]?.parts.some((p) => p.type === "summary")} scrollRef={scrollRef} />}
<MessageList
messages={messages}
results={results}
busy={busy}
canEdit={!busy && !readOnly}
canRegenerate={!busy && !readOnly && !session.parent_id && !win?.newer}
sessionId={sessionId}
model={session.model}
baseDir={session.cwd}
onRewind={onRewind}
highlight={highlight}
currency={stats?.currency ?? "USD"}
/>
{win?.newer && (
<button className="back-to-latest" onClick={() => void showLatest()}>
<Icon name="arrow-down" /> {t("chat.backToLatest")}
</button>
)}
{draft && (
<div className="msg">
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
</div>
)}
{compacting && (
<div className={`compacting-card ${compacting.auto ? "auto" : ""}`} role="status">
<Spinner small />
<div>
<b>{t(compacting.auto ? "chat.compactingAuto" : "chat.compactingManual")}</b>
{compacting.auto && compacting.tokens && compacting.limit && (
<div className="hint">
{t("chat.compactingDetail", { tokens: fmtCompact(compacting.tokens), limit: fmtCompact(compacting.limit), pct: fmtPercent(compacting.tokens / compacting.limit) })}
</div>
)}
</div>
</div>
)}
{(busy || waiting) && (
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
)}
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
<TodoPanel todos={todos} />
)}
{permissions.map((p) => (
<PermissionCard key={p.id} req={p} currentId={sessionId} />
))}
{questions.map((q) => (
<QuestionCard key={q.id} req={q} currentId={sessionId} />
))}
{errors?.map((e) => (
<div key={e.id} className="error-box" role="alert">
<span>{e.message}</span>
<button className="small ghost" onClick={() => dismissError(sessionId, e.id)} aria-label={t("common.close")}>
<Icon name="close" />
</button>
</div>
))}
{session.agent === "plan" && !busy && lastIsAssistant && (
<div className="row" style={{ justifyContent: "center", margin: "12px 0" }}>
<button
className="primary"
onClick={async () => {
await update({ agent: "build" });
await send(t("plan.implementPrompt"), []);
}}
>
{t("plan.implement")}
</button>
</div>
)}
</div>
{!following && (
<div className="jump-latest">
<button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
<Icon name="arrow-down" />
</button>
</div>
)}
</div>
{editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />}
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
{readOnly ? (
<div className="readonly-bar">{t("chat.readOnlyHint")}</div>
) : (
<Composer
session={session}
busy={busy}
text={text}
setText={setText}
onSend={send}
onAbort={() => post(`/sessions/${sessionId}/abort`)}
onUpdate={update}
/>
)}
</div>
</>
);
}
/** 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 && <Markdown text={body} breaks />}
{files.length > 0 && (
<div className="user-files">
{files.map((f) => (
<FileChip key={f.path} url={projectFileUrl(f.path)} name={f.path} sizeText={f.size} />
))}
</div>
)}
</>
);
}
/** 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 (
<div className="phase-bar" aria-label="Craft progress">
{phases.map((p, i) => {
const state = i < cur || (p === "ready" && cur === i) ? "done" : i === cur ? "current" : "";
return (
<span key={p} style={{ display: "contents" }}>
{i > 0 && <span className={`sep ${i <= cur ? "done" : ""}`} />}
<span className={`phase ${state}`}>
<span className="dot">
{state === "done" && (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m5 12 4.5 4.5L19 7" />
</svg>
)}
</span>
{t(`phase.${p}` as Key)}
</span>
</span>
);
})}
</div>
);
}
/** 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 (
<div className={`todos ${collapsed ? "collapsed" : ""}`}>
<button className="todos-head" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed}>
<Icon name={collapsed ? "chevron-right" : "chevron-down"} />
<span>{t("tool.todos")}</span>
<span className="todos-count">
{done}/{todos.length}
</span>
{collapsed && current && <span className="todos-current">{current.content}</span>}
</button>
{!collapsed && <TodoList todos={todos} />}
</div>
);
}