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:
1 parent
084452abf6
commit
62fe5bf467
42 files changed
+3734
-55
No files matched your search
+166
-23
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user