Files
codecraft/web/src/components/ChatView.tsx
T
Richard ZinkandClaude Sonnet 5.5 62fe5bf467 Claude Code engine, image viewer, colour tags, checks per turn and more
- Claude Code as an engine: chats on a "claude-code/<alias>" model run through
  the claude CLI (stream-json); tool approvals go through our permission
  system. Sign in from the web UI (URL + pasted code), CLI version and update
  from Settings; the Docker image contains the CLI.
- Small models (8k context) for side tasks: input is cut to the model's
  context window; one-line chat status, "What does this do?" on approvals,
  "Plan as automation" (job draft from a chat).
- Mobile: top bar no longer widens the page, image viewer (zoom, swipe, mark
  up, use in message), jump to previous own message, message menu, time under
  every message.
- Approvals: push actions (allow/reject), "always allow in this project"
  (never for dangerous commands) with revoke.
- Per turn: changed files with diffs and rollback; project check command with
  a green/red badge and "let the agent fix it".
- Colour tags for chats in the colours of the active theme.
- Project defaults for new chats, project page (timeline), offline queue.

Tested in a throwaway container with a mock model (8k small model): 26 API
checks passed (permissions, diffs, checks, defaults, job draft, side-task
budget, Claude Code status/login URL, unsigned-in chat).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 22:30:12 +01:00

1190 lines
51 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, put } from "../api";
import { useFetch, useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtMsgTime, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { dismissError, getState, loadAround, loadChecks, 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 { ColorSwatches } from "./ColorSwatches";
import { ProjectPermissions } from "./ProjectPermissions";
import { OutboxBar } from "./OutboxBar";
import { addToOutbox, isOffline } from "../outbox";
import { TurnCheck } from "./TurnCheck";
import { TurnChanges } from "./TurnChanges";
import { ChatCtx, MessageMenu } from "./MessageMenu";
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} />;
// Files the agent wrote or edited in this turn: diffs and "roll back" (not while it is still working).
const changed = new Set(steps.filter((c) => (c.name === "write" || c.name === "edit") && typeof c.input?.path === "string").map((c) => c.input.path as string));
const lastAnswer = [...b.messages].reverse().find((m) => m.role === "assistant");
const checkBadge = lastAnswer && !(busy && i === lastWork) && <TurnCheck sessionId={sessionId} messageId={lastAnswer.id} canSend={canEdit} />;
const prev = blocks[i - 1];
const changes = changed.size > 0 && !(busy && i === lastWork) && (
<TurnChanges
sessionId={sessionId}
fromId={b.messages[0].id}
untilId={blocks[i + 1]?.messages[0].id}
fileCount={changed.size}
userMessage={canEdit && prev?.kind === "user" ? prev.messages[0] : undefined}
onRewind={onRewind}
/>
);
if (i === lastWork || steps.length < 2)
return (
<div key={key}>
{items}
{usage}
{checkBadge}
{changes}
</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}
{checkBadge}
{changes}
</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 && (
<>
{answer && <MessageMenu messageId={answer.id} text={answerText} forkable={!answer.parts.some((p) => p.type === "tool_call")} />}
<Markdown text={answerText} baseDir={baseDir} />
<ImageRefs text={answerText} baseDir={baseDir} />
{answer && <MsgTime ms={answer.created_at} />}
</>
)}
</div>
);
}
/** Text parts of a message. */
const textOfMessage = (m: Message) => (m.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n");
/** Small date/time line under a message. */
function MsgTime({ ms }: { ms: number }) {
if (!ms) return null;
return (
<time className="msg-time" dateTime={new Date(ms).toISOString()} title={new Date(ms).toLocaleString()}>
{fmtMsgTime(ms)}
</time>
);
}
/** 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}>
<MessageMenu messageId={m.id} text={textOfMessage(m)} forkable={!m.parts.some((p) => p.type === "tool_call")} />
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
<MsgTime ms={m.created_at} />
{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} />
))}
<MessageMenu messageId={m.id} text={texts.map((p) => p.text).join("\n\n")} forkable onRewind={canEdit ? () => onRewind(m) : undefined} />
<div className="msg-user">
<UserText text={texts.map((p) => p.text).join("\n\n")} />
<MsgTime ms={m.created_at} />
{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 [showApprovals, setShowApprovals] = useState(false);
const [planning, setPlanning] = 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" });
};
// Scrolled away from the top: the jump buttons show up.
const [scrolled, setScrolled] = useState(false);
/** Scroll to the closest own message above the visible area (long chats on a phone). */
const jumpPrevOwn = () => {
const el = scrollRef.current;
if (!el) return;
const top = el.getBoundingClientRect().top;
const above = [...el.querySelectorAll<HTMLElement>(".msg-user")].filter((n) => n.getBoundingClientRect().top < top - 12);
const target = above[above.length - 1];
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
else el.scrollTo({ top: 0, behavior: "smooth" });
};
// Every chat starts following.
useEffect(() => follow(true), [sessionId]);
useEffect(() => {
loadSession(sessionId).catch((e) => setLoadError(e.message));
void loadChecks(sessionId);
}, [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) {
// No connection: keep the text on this device and send it when the server is reachable again.
if (isOffline(e) && txt && images.length === 0) {
addToOutbox(sessionId, txt, effort || undefined);
setText("");
toast(t("outbox.saved"));
} else 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(""));
};
/** New chats in this project start like this one (model, mode, colour, instructions, folder). */
const saveDefaults = async (remove: boolean) => {
try {
if (remove) await del(`/sessions/${sessionId}/project-defaults`);
else await put(`/sessions/${sessionId}/project-defaults`, {});
toast(t(remove ? "projectDefaults.removed" : "projectDefaults.saved"), "success");
} catch (e: any) {
toast(e.message);
}
};
/** The project's check command (tests, build …), run after every turn that changed files. */
const editCheck = async () => {
try {
const cur = await get<{ command: string; enabled: boolean }>(`/sessions/${sessionId}/check`);
const cmd = await promptDialog({ title: t("check.title"), message: t("check.hint", { project: session.cwd }), defaultValue: cur.command, placeholder: "cargo test", confirmLabel: t("common.save") });
if (cmd === null) return;
await put(`/sessions/${sessionId}/check`, { command: cmd.trim(), enabled: cmd.trim() !== "" });
toast(cmd.trim() ? t("check.saved") : t("check.removed"), "success");
} catch (e: any) {
toast(e.message);
}
};
/** Draft an automation from this chat (small model), create it switched off and open its editor. */
const planJob = async () => {
if (planning) return;
setPlanning(true);
toast(t("job.drafting"));
try {
const d = await post<{ name: string; prompt: string; cwd: string; agent: string }>(`/sessions/${sessionId}/job-draft`, {});
const job = await post<{ id: string }>("/jobs/create", { cwd: d.cwd, name: d.name, prompt: d.prompt, agent: d.agent === "plan" ? undefined : d.agent });
navigate(`/jobs/${job.id}`);
} catch (e: any) {
toast(e.message);
} finally {
setPlanning(false);
}
};
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 (
<ChatCtx.Provider value={{ sessionId, canFork: !session.parent_id && !readOnly && !busy, canReply: !readOnly }}>
<div className="topbar" data-color={session.color}>
<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 hide-mobile" 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 hide-mobile" onClick={tabs.openFiles} title={t("files.newTab")}>
<Icon name="folder" />
</button>
{!session.parent_id && (
<button className="small ghost hide-mobile" 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">
{/* The buttons that do not fit into the narrow bar. */}
{!session.parent_id && (
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, setSharing(true))}>
<Icon name="share" /> {t("share.title")}
</a>
)}
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, tabs.openFiles())}>
<Icon name="folder" /> {t("files.newTab")}
</a>
{!session.parent_id && !busy && (
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, forkSession())}>
<Icon name="fork" /> {t("chat.fork")}
</a>
)}
{!busy && (
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, post(`/sessions/${sessionId}/compact`).catch((err) => toast(err.message)))}>
<Icon name="compact" /> {t("command.compact")}
</a>
)}
<a href="#" className="mobile-only danger" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, remove())}>
<Icon name="trash" /> {t("chat.delete")}
</a>
<a
href="#"
onClick={(e) => {
e.preventDefault();
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
setEditingInstructions(true);
}}
>
<Icon name="edit" /> {t("instructions.title")}
</a>
<ColorSwatches value={session.color} onChange={(color) => void update({ color })} />
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void planJob())} title={t("job.planHint")}>
<Icon name="clock" /> {t("job.plan")}
</a>
<a href="#" title={t("projectDefaults.hint")} onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void saveDefaults(false))}>
<Icon name="template" /> {t("projectDefaults.save")}
</a>
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void saveDefaults(true))}>
<Icon name="undo" /> {t("projectDefaults.remove")}
</a>
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void editCheck())}>
<Icon name="check" /> {t("check.menu")}
</a>
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), setShowApprovals(true))}>
<Icon name="lock" /> {t("permProject.menu")}
</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 hide-mobile" 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 hide-mobile" 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" data-color={session.color} data-session-id={sessionId} 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 };
setScrolled(el.scrollTop > 240);
}}
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 || scrolled) && (
<div className="jump-latest">
<button onClick={jumpPrevOwn} title={t("chat.jumpPrevOwn")} aria-label={t("chat.jumpPrevOwn")}>
<Icon name="arrow-up" />
</button>
{!following && <button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
<Icon name="arrow-down" />
</button>}
</div>
)}
</div>
{showApprovals && <ProjectPermissions sessionId={sessionId} cwd={session.cwd} onClose={() => setShowApprovals(false)} />}
{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 && <OutboxBar sessionId={sessionId} />}
{!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>
</ChatCtx.Provider>
);
}
/** 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>
);
}