- 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>
237 lines
10 KiB
TypeScript
237 lines
10 KiB
TypeScript
import { Icon } from "./Icon";
|
|
import { useState } from "react";
|
|
import { post } from "../api";
|
|
import { getLang, useT } from "../i18n";
|
|
import { navigate } from "../router";
|
|
import { useStore } from "../store";
|
|
import type { PermissionRequest, QuestionRequest } from "../types";
|
|
import { Diff, HunkDiff, splitHunks } from "./Diff";
|
|
|
|
function SubagentHint({ sessionId, currentId }: { sessionId: string; currentId: string }) {
|
|
const t = useT();
|
|
const title = useStore((s) => s.sessionById[sessionId]?.title);
|
|
if (sessionId === currentId) return null;
|
|
return (
|
|
<div className="from">
|
|
{t("interaction.fromSubagent")}{" "}
|
|
<a href={`#/s/${sessionId}`} onClick={(e) => (e.preventDefault(), navigate(`/s/${sessionId}`))}>
|
|
{title || sessionId.slice(-6)}
|
|
</a>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PermissionCard({ req, currentId }: { req: PermissionRequest; currentId: string }) {
|
|
const t = useT();
|
|
const [feedback, setFeedback] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
// File edits with several hunks can be accepted piece by piece.
|
|
const hunkCount = req.diff && (req.tool === "edit" || req.tool === "write") ? splitHunks(req.diff).hunks.length : 0;
|
|
const [selected, setSelected] = useState<boolean[]>(() => Array(hunkCount).fill(true));
|
|
const chosen = selected.filter(Boolean).length;
|
|
const partial = hunkCount > 1 && chosen < hunkCount;
|
|
|
|
const [explaining, setExplaining] = useState(false);
|
|
const [explanation, setExplanation] = useState<string>();
|
|
const [explainError, setExplainError] = useState<string>();
|
|
/** The small model explains in plain words what the action does (approving on a phone without reading code). */
|
|
const explain = async () => {
|
|
setExplaining(true);
|
|
setExplainError(undefined);
|
|
try {
|
|
const r = await post<{ text: string }>(`/permissions/${req.id}/explain`, { lang: getLang() });
|
|
setExplanation(r.text);
|
|
} catch (e: any) {
|
|
setExplainError(e.message);
|
|
} finally {
|
|
setExplaining(false);
|
|
}
|
|
};
|
|
|
|
const reply = async (r: "once" | "always" | "project" | "reject") => {
|
|
setBusy(true);
|
|
try {
|
|
const hunks = r === "once" && partial ? selected.flatMap((on, i) => (on ? [i] : [])) : undefined;
|
|
await post(`/permissions/${req.id}`, { reply: r, message: r === "reject" || partial ? feedback || undefined : undefined, hunks });
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const command: string | undefined = req.tool === "bash" ? req.input?.command : undefined;
|
|
const alwaysLabel =
|
|
req.tool === "bash"
|
|
? t("permission.alwaysCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") })
|
|
: t("permission.alwaysTool", { tool: req.tool });
|
|
|
|
// Dangerous commands are only ever allowed for the session (the server decides; this hides the button).
|
|
const dangerous = !!command && /\b(rm\s+-[a-zA-Z]*[rRf]|sudo|su|git\s+(push|reset|clean|rebase|checkout|restore)|docker|docker-compose|systemctl|chmod\s+-R|chown\s+-R|dd|mkfs|shutdown|reboot|kill|pkill|killall|crontab)\b|\|\s*(ba)?sh\b|publish/.test(command);
|
|
const projectLabel = req.tool === "bash" ? t("permission.projectCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") }) : t("permission.projectTool", { tool: req.tool });
|
|
|
|
return (
|
|
<div className="card" role="alertdialog" aria-label={t("permission.title")}>
|
|
<h3>
|
|
<Icon name="lock" /> {t("permission.title")} <span className="chip mono">{req.tool}</span>
|
|
</h3>
|
|
<SubagentHint sessionId={req.session_id} currentId={currentId} />
|
|
<div style={{ margin: "6px 0" }}>{req.title}</div>
|
|
{command && <pre className="mono" style={{ whiteSpace: "pre-wrap", margin: "6px 0" }}>$ {command}</pre>}
|
|
{req.diff && hunkCount > 1 ? (
|
|
<>
|
|
<div className="hint">{t("permission.hunksHint", { n: hunkCount })}</div>
|
|
<HunkDiff diff={req.diff} selected={selected} onToggle={(i) => setSelected((s) => s.map((v, j) => (j === i ? !v : v)))} />
|
|
</>
|
|
) : req.diff ? (
|
|
<Diff diff={req.diff} />
|
|
) : (
|
|
!command && (
|
|
<details>
|
|
<summary style={{ cursor: "pointer", color: "var(--muted)" }}>{t("tool.input")}</summary>
|
|
<pre className="mono" style={{ whiteSpace: "pre-wrap" }}>
|
|
{JSON.stringify(req.input, null, 2)}
|
|
</pre>
|
|
</details>
|
|
)
|
|
)}
|
|
{explanation ? (
|
|
<div className="explain" role="note">
|
|
<Icon name="sparkle" className="tiny" /> {explanation}
|
|
</div>
|
|
) : (
|
|
<button className="small ghost explain-btn" disabled={explaining} onClick={explain}>
|
|
<Icon name="sparkle" /> {explaining ? t("permission.explaining") : t("permission.explain")}
|
|
</button>
|
|
)}
|
|
{explainError && <div className="hint">{explainError}</div>}
|
|
<input
|
|
style={{ width: "100%", marginTop: 8 }}
|
|
placeholder={t("permission.feedback")}
|
|
value={feedback}
|
|
onChange={(e) => setFeedback(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && reply("reject")}
|
|
/>
|
|
<div className="actions">
|
|
<button className="primary" disabled={busy || (hunkCount > 1 && chosen === 0)} onClick={() => reply("once")} autoFocus>
|
|
{partial ? t("permission.applySelected", { k: chosen, n: hunkCount }) : t("permission.allowOnce")}
|
|
</button>
|
|
<button disabled={busy || partial} onClick={() => reply("always")}>
|
|
{alwaysLabel}
|
|
</button>
|
|
{!dangerous && (
|
|
<button disabled={busy || partial} onClick={() => reply("project")} title={t("permission.projectHint")}>
|
|
{projectLabel}
|
|
</button>
|
|
)}
|
|
<button className="danger" disabled={busy} onClick={() => reply("reject")}>
|
|
{feedback ? t("permission.rejectWithFeedback") : t("permission.reject")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const OTHER = "__other__";
|
|
|
|
export function QuestionCard({ req, currentId }: { req: QuestionRequest; currentId: string }) {
|
|
const t = useT();
|
|
const [selected, setSelected] = useState<Record<number, string[]>>({});
|
|
const [other, setOther] = useState<Record<number, string>>({});
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const toggle = (qi: number, label: string, multi: boolean) => {
|
|
setSelected((s) => {
|
|
const cur = s[qi] ?? [];
|
|
if (!multi) return { ...s, [qi]: [label] };
|
|
return { ...s, [qi]: cur.includes(label) ? cur.filter((x) => x !== label) : [...cur, label] };
|
|
});
|
|
};
|
|
|
|
const answerFor = (qi: number): string | string[] | undefined => {
|
|
const q = req.questions[qi];
|
|
const sel = (selected[qi] ?? []).map((l) => (l === OTHER ? other[qi]?.trim() ?? "" : l)).filter(Boolean);
|
|
if (q.options.length === 0) return other[qi]?.trim() || undefined;
|
|
if (sel.length === 0) return undefined;
|
|
return q.multi_select ? sel : sel[0];
|
|
};
|
|
|
|
const complete = req.questions.every((_, i) => answerFor(i) !== undefined);
|
|
|
|
const submit = async (dismiss = false) => {
|
|
setBusy(true);
|
|
try {
|
|
const answers = dismiss ? null : Object.fromEntries(req.questions.map((q, i) => [q.question, answerFor(i)]));
|
|
await post(`/questions/${req.id}`, { answers });
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const source = req.source.startsWith("mcp:") ? t("question.fromMcp", { server: req.source.slice(4) }) : t("question.fromAgent");
|
|
|
|
return (
|
|
<div className="card" role="dialog" aria-label={t("question.title")}>
|
|
<h3><Icon name="question" /> {t("question.title")}</h3>
|
|
<div className="from">{source}</div>
|
|
<SubagentHint sessionId={req.session_id} currentId={currentId} />
|
|
{req.questions.map((q, qi) => (
|
|
<div key={qi} style={{ marginTop: 10 }}>
|
|
{q.header && <span className="q-header">{q.header}</span>}
|
|
<div style={{ whiteSpace: "pre-wrap", color: "var(--fg0)" }}>{q.question}</div>
|
|
{q.options.length > 0 ? (
|
|
<div className="q-options" role={q.multi_select ? "group" : "radiogroup"}>
|
|
{[...q.options, { label: OTHER, description: "" }].map((o) => {
|
|
const isSel = (selected[qi] ?? []).includes(o.label);
|
|
return (
|
|
<div
|
|
key={o.label}
|
|
className={`q-option ${isSel ? "selected" : ""}`}
|
|
role={q.multi_select ? "checkbox" : "radio"}
|
|
aria-checked={isSel}
|
|
tabIndex={0}
|
|
onClick={() => toggle(qi, o.label, !!q.multi_select)}
|
|
onKeyDown={(e) => (e.key === " " || e.key === "Enter") && toggle(qi, o.label, !!q.multi_select)}
|
|
>
|
|
<Icon name={q.multi_select ? (isSel ? "box-on" : "box-off") : isSel ? "radio-on" : "radio-off"} />
|
|
<div style={{ flex: 1 }}>
|
|
{o.label === OTHER ? (
|
|
<input
|
|
style={{ width: "100%" }}
|
|
placeholder={t("question.other")}
|
|
value={other[qi] ?? ""}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onFocus={() => !isSel && toggle(qi, OTHER, !!q.multi_select)}
|
|
onChange={(e) => setOther((s) => ({ ...s, [qi]: e.target.value }))}
|
|
/>
|
|
) : (
|
|
<>
|
|
<div>{o.label}</div>
|
|
{o.description && <div className="desc">{o.description}</div>}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<textarea
|
|
style={{ width: "100%", marginTop: 6 }}
|
|
placeholder={t("question.answer")}
|
|
value={other[qi] ?? ""}
|
|
onChange={(e) => setOther((s) => ({ ...s, [qi]: e.target.value }))}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
<div className="actions">
|
|
<button className="primary" disabled={busy || !complete} onClick={() => submit()}>
|
|
{t("question.submit")}
|
|
</button>
|
|
<button disabled={busy} onClick={() => submit(true)}>
|
|
{t("question.dismiss")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|