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>
This commit is contained in:
Richard ZinkandClaude Sonnet 5.5 committed 2026-11-14 22:30:12 +01:00
1 parent 084452abf6
commit 62fe5bf467
42 files changed
+3734 -55

No files matched your search

+166 -23
View File
@@ -2,11 +2,11 @@ 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 { del, get, patch, post, put } from "../api";
import { useFetch, useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
import { fmtCompact, fmtCost, fmtMsgTime, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { dismissError, getState, loadAround, loadOlder, loadSession, loadSessions, useStore } from "../store";
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";
@@ -26,6 +26,13 @@ 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" }>;
@@ -283,11 +290,28 @@ const MessageList = memo(function MessageList({
// 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 (
@@ -296,6 +320,8 @@ const MessageList = memo(function MessageList({
{items}
</WorkBlock>
{usage}
{checkBadge}
{changes}
</div>
);
})}
@@ -355,14 +381,29 @@ function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messa
</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,
@@ -383,7 +424,9 @@ const AssistantOrNote = memo(function AssistantOrNote({
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>
);
@@ -427,15 +470,10 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
{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">
{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")} />
<MsgTime ms={m.created_at} />
{imgs.length > 0 && (
<div className="user-files">
{imgs.map((p, i) => (
@@ -516,6 +554,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
// 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) ?? "");
@@ -537,11 +577,25 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
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);
@@ -633,7 +687,12 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
setText("");
follow(true);
} catch (e: any) {
toast(e.message);
// 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);
}
};
@@ -648,6 +707,46 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
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}`);
@@ -670,8 +769,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
const pendingTool = busy && pendingCall ? { name: pendingCall.name, input: pendingCall.input, since: lastMsg.created_at } : undefined;
return (
<>
<div className="topbar">
<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>
@@ -718,7 +817,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
{!readOnly && (
<>
{!session.parent_id && (
<button className="small ghost" onClick={() => setSharing(true)} title={t("share.title")}>
<button className="small ghost hide-mobile" onClick={() => setSharing(true)} title={t("share.title")}>
<Icon name="share" />
</button>
)}
@@ -732,11 +831,11 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<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")}>
<button className="small ghost hide-mobile" 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")}>
<button className="small ghost hide-mobile" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
<Icon name="fork" />
</button>
)}
@@ -745,6 +844,28 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<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) => {
@@ -755,6 +876,22 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
>
<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) => {
@@ -804,10 +941,10 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
)}
</div>
</details>
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
<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" onClick={remove} title={t("chat.delete")}>
<button className="small ghost danger hide-mobile" onClick={remove} title={t("chat.delete")}>
<Icon name="trash" />
</button>
</>
@@ -831,7 +968,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
{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"}>
<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
@@ -846,6 +983,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
// … 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);
@@ -925,18 +1063,23 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
</div>
)}
</div>
{!following && (
{(!following || scrolled) && (
<div className="jump-latest">
<button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
<Icon name="arrow-down" />
<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>
@@ -952,7 +1095,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
/>
)}
</div>
</>
</ChatCtx.Provider>
);
}