- 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>
192 lines
7.0 KiB
TypeScript
192 lines
7.0 KiB
TypeScript
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>
|
|
);
|
|
}
|