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

+191
View File
@@ -0,0 +1,191 @@
import { useEffect, useState } from "react";
import { api, get, post } from "../../api";
import { confirmDialog, toast } from "../../dialogs";
import { useT } from "../../i18n";
import { Icon } from "../Icon";
import { Spinner } from "../Activity";
import { useStore } from "../../store";
interface Status {
installed: boolean;
logged_in?: boolean;
method?: string;
email?: string;
org?: string;
expires?: string;
login_in_progress?: boolean;
error?: string;
version?: string | null;
latest?: string | null;
update_available?: boolean;
output?: string;
}
/**
* Claude Code: sign in from the browser. The CLI prints a sign-in URL; the Anthropic page shows
* a code after login that is pasted here and handed back to the CLI on the server.
*/
export function ClaudeCode() {
const t = useT();
const [status, setStatus] = useState<Status>();
const [url, setUrl] = useState<string>();
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [console_, setConsole] = useState(false);
const isAdmin = useStore((s) => s.me?.role === "admin");
const [updating, setUpdating] = useState(false);
const [checking, setChecking] = useState(false);
const [updateLog, setUpdateLog] = useState<string>();
const check = async () => {
setChecking(true);
try {
setStatus(await get<Status>("/claude/status?refresh=true"));
} catch (e: any) {
toast(e.message);
} finally {
setChecking(false);
}
};
const update = async () => {
setUpdating(true);
setUpdateLog(undefined);
try {
const r = await post<Status>("/claude/update", {});
setStatus(r);
setUpdateLog(r.output);
toast(t("claude.updated", { version: r.version ?? "" }), "success");
} catch (e: any) {
setUpdateLog(e.message);
toast(t("claude.updateFailed"));
} finally {
setUpdating(false);
}
};
const load = () => get<Status>("/claude/status").then(setStatus, (e) => setStatus({ installed: false, error: e.message }));
useEffect(() => void load(), []);
const start = async () => {
setBusy(true);
try {
const r = await post<{ url: string }>("/claude/login", { console: console_ });
setUrl(r.url);
setCode("");
} catch (e: any) {
toast(e.message);
} finally {
setBusy(false);
}
};
const submit = async () => {
setBusy(true);
try {
setStatus(await post<Status>("/claude/login/code", { code }));
setUrl(undefined);
setCode("");
toast(t("claude.signedIn"), "success");
} catch (e: any) {
toast(e.message);
} finally {
setBusy(false);
}
};
const cancel = async () => {
await api("/claude/login", { method: "DELETE" }).catch(() => undefined);
setUrl(undefined);
setCode("");
};
const logout = async () => {
if (!(await confirmDialog({ message: t("claude.logoutConfirm"), danger: true, confirmLabel: t("claude.logout") }))) return;
try {
setStatus(await post<Status>("/claude/logout", {}));
} catch (e: any) {
toast(e.message);
}
};
return (
<div className="panel">
<h3>
<Icon name="robot" /> {t("claude.title")}
<span className="spacer" />
{status?.logged_in && (
<button className="small ghost danger" onClick={logout}>
{t("claude.logout")}
</button>
)}
</h3>
<div className="hint" style={{ marginBottom: 10 }}>{t("claude.intro")}</div>
{!status && <Spinner />}
{status && !status.installed && <div className="notice err">{status.error ?? t("claude.notInstalled")}</div>}
{status?.installed && (
<div className="row" style={{ gap: 8, flexWrap: "wrap", marginBottom: 10 }}>
<span className="chip mono">{t("claude.version", { version: status.version ?? "?" })}</span>
{status.update_available ? (
<>
<span className="chip warn">{t("claude.updateAvailable", { version: status.latest ?? "" })}</span>
{isAdmin ? (
<button className="small primary" disabled={updating} onClick={update}>
{updating ? <Spinner small /> : <Icon name="download" />} {updating ? t("claude.updating") : t("claude.update")}
</button>
) : (
<span className="hint">{t("claude.updateAdmin")}</span>
)}
</>
) : (
status.latest && <span className="chip">{t("claude.upToDate")}</span>
)}
<button className="small ghost" disabled={checking || updating} onClick={check} title={t("claude.checkNow")} aria-label={t("claude.checkNow")}>
{checking ? <Spinner small /> : <Icon name="refresh" />}
</button>
</div>
)}
{updateLog && (
<details style={{ marginBottom: 10 }}>
<summary className="hint" style={{ cursor: "pointer" }}>{t("claude.updateLog")}</summary>
<pre className="mono" style={{ whiteSpace: "pre-wrap", maxHeight: 200, overflow: "auto" }}>{updateLog}</pre>
</details>
)}
{status?.installed && status.logged_in && (
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
<span className="chip">
<Icon name="check" className="tiny" /> {t("claude.signedInAs", { who: status.email || status.org || status.method || "" })}
</span>
{status.method && <span className="chip">{status.method}</span>}
{status.expires && <span className="hint">{t("claude.expires", { date: new Date(status.expires).toLocaleDateString() })}</span>}
</div>
)}
{status?.installed && !status.logged_in && !url && (
<>
<label className="row" style={{ marginBottom: 8 }}>
<input type="checkbox" checked={console_} onChange={(e) => setConsole(e.target.checked)} /> {t("claude.console")}
</label>
<button className="primary" disabled={busy} onClick={start}>
{busy ? <Spinner small /> : <Icon name="lock" />} {t("claude.signIn")}
</button>
</>
)}
{url && (
<ol className="cli-steps">
<li>
<a className="button" href={url} target="_blank" rel="noreferrer">
<Icon name="external" /> {t("claude.openLogin")}
</a>
<div className="hint">{t("claude.step1")}</div>
</li>
<li>
{t("claude.step2")}
<div className="row" style={{ marginTop: 6 }}>
<input style={{ flex: 1 }} value={code} placeholder={t("claude.codePlaceholder")} autoComplete="off" autoCapitalize="off" spellCheck={false} onChange={(e) => setCode(e.target.value)} onKeyDown={(e) => e.key === "Enter" && code.trim() && void submit()} />
<button className="primary" disabled={busy || !code.trim()} onClick={submit}>
{busy ? <Spinner small /> : <Icon name="check" />} {t("claude.confirm")}
</button>
<button onClick={cancel}>{t("common.cancel")}</button>
</div>
</li>
</ol>
)}
</div>
);
}