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

+160
View File
@@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from "react";
import { useT } from "../i18n";
import { toast } from "../dialogs";
import { Icon } from "./Icon";
type Tool = "pen" | "arrow" | "rect";
interface Shape {
tool: Tool;
color: string;
/** pen: all points; arrow/rect: start and end. */
pts: { x: number; y: number }[];
}
const COLORS = ["#ff3b30", "#ffcc00", "#34c759", "#0a84ff", "#ffffff", "#000000"];
const MAX_SIDE = 2000;
function paint(ctx: CanvasRenderingContext2D, shapes: Shape[], w: number) {
const lw = Math.max(3, Math.round(w / 280));
for (const s of shapes) {
ctx.strokeStyle = s.color;
ctx.fillStyle = s.color;
ctx.lineWidth = lw;
ctx.lineCap = "round";
ctx.lineJoin = "round";
const a = s.pts[0];
const b = s.pts[s.pts.length - 1];
ctx.beginPath();
if (s.tool === "pen") {
ctx.moveTo(a.x, a.y);
for (const p of s.pts.slice(1)) ctx.lineTo(p.x, p.y);
if (s.pts.length === 1) ctx.lineTo(a.x + 0.1, a.y);
ctx.stroke();
} else if (s.tool === "rect") {
ctx.strokeRect(a.x, a.y, b.x - a.x, b.y - a.y);
} else {
// Arrow: line with a filled head at the end.
const ang = Math.atan2(b.y - a.y, b.x - a.x);
const head = lw * 5;
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x - Math.cos(ang) * head * 0.6, b.y - Math.sin(ang) * head * 0.6);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(b.x, b.y);
ctx.lineTo(b.x - head * Math.cos(ang - 0.45), b.y - head * Math.sin(ang - 0.45));
ctx.lineTo(b.x - head * Math.cos(ang + 0.45), b.y - head * Math.sin(ang + 0.45));
ctx.closePath();
ctx.fill();
}
}
}
/** Draw on an image (pen, arrow, rectangle) and hand the result back as a PNG file. */
export function ImageAnnotator({ src, name, onDone, onCancel }: { src: string; name: string; onDone: (file: File) => void; onCancel: () => void }) {
const t = useT();
const canvas = useRef<HTMLCanvasElement>(null);
const bitmap = useRef<HTMLImageElement | undefined>(undefined);
const [shapes, setShapes] = useState<Shape[]>([]);
const [tool, setTool] = useState<Tool>("pen");
const [color, setColor] = useState(COLORS[0]);
const [ready, setReady] = useState(false);
const drawing = useRef<Shape | null>(null);
const redraw = (extra?: Shape | null) => {
const c = canvas.current;
const img = bitmap.current;
if (!c || !img) return;
const ctx = c.getContext("2d")!;
ctx.clearRect(0, 0, c.width, c.height);
ctx.drawImage(img, 0, 0, c.width, c.height);
paint(ctx, extra ? [...shapes, extra] : shapes, c.width);
};
useEffect(() => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const c = canvas.current;
if (!c) return;
const scale = Math.min(1, MAX_SIDE / Math.max(img.naturalWidth || 1, img.naturalHeight || 1));
c.width = Math.max(1, Math.round((img.naturalWidth || 300) * scale));
c.height = Math.max(1, Math.round((img.naturalHeight || 200) * scale));
bitmap.current = img;
setReady(true);
};
img.onerror = () => {
toast(t("viewer.annotateFailed"));
onCancel();
};
img.src = src;
}, [src]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => redraw(), [shapes, ready]); // eslint-disable-line react-hooks/exhaustive-deps
const point = (e: React.PointerEvent) => {
const c = canvas.current!;
const r = c.getBoundingClientRect();
return { x: ((e.clientX - r.left) / r.width) * c.width, y: ((e.clientY - r.top) / r.height) * c.height };
};
const finish = () => {
const c = canvas.current;
if (!c) return;
c.toBlob((blob) => {
if (!blob) return toast(t("viewer.annotateFailed"));
const base = name.replace(/\.[a-z0-9]{1,5}$/i, "") || "image";
onDone(new File([blob], `${base}-marked.png`, { type: "image/png" }));
}, "image/png");
};
return (
<div className="annotator" onClick={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}>
<canvas
ref={canvas}
style={{ visibility: ready ? "visible" : "hidden" }}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
drawing.current = { tool, color, pts: [point(e)] };
}}
onPointerMove={(e) => {
const d = drawing.current;
if (!d) return;
const p = point(e);
d.pts = d.tool === "pen" ? [...d.pts, p] : [d.pts[0], p];
redraw(d);
}}
onPointerUp={() => {
const d = drawing.current;
drawing.current = null;
if (d) setShapes((s) => [...s, d]);
}}
onPointerCancel={() => {
drawing.current = null;
redraw();
}}
/>
<div className="annotator-tools" role="toolbar" aria-label={t("viewer.annotate")}>
{(["pen", "arrow", "rect"] as Tool[]).map((k) => (
<button key={k} className={`small ${tool === k ? "primary" : "ghost"}`} onClick={() => setTool(k)} aria-pressed={tool === k} title={t(`viewer.tool.${k}` as const)}>
<Icon name={k === "pen" ? "edit" : k === "arrow" ? "arrow-right" : "box-off"} />
</button>
))}
<span className="annotator-colors">
{COLORS.map((c) => (
<button key={c} className={`swatch ${c === color ? "on" : ""}`} style={{ background: c }} onClick={() => setColor(c)} aria-label={c} aria-pressed={c === color} />
))}
</span>
<button className="small ghost" onClick={() => setShapes((s) => s.slice(0, -1))} disabled={!shapes.length} title={t("viewer.undo")} aria-label={t("viewer.undo")}>
<Icon name="undo" />
</button>
<span className="grow" />
<button className="small" onClick={onCancel}>
{t("common.cancel")}
</button>
<button className="small primary" onClick={finish} disabled={!ready}>
<Icon name="clip" /> {t("viewer.attach")}
</button>
</div>
</div>
);
}