Files
codecraft/web/src/outbox.ts
T
Richard ZinkandClaude Sonnet 5.5 62fe5bf467 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>
2026-11-14 22:30:12 +01:00

86 lines
2.4 KiB
TypeScript

import { useSyncExternalStore } from "react";
import { ApiError, post } from "./api";
/** Messages written without a connection: kept on this device and sent when it is back. */
export interface OutboxItem {
id: string;
sessionId: string;
text: string;
effort?: string;
createdAt: number;
}
const KEY = "codecraft.outbox";
const listeners = new Set<() => void>();
let cache: OutboxItem[] | undefined;
function load(): OutboxItem[] {
if (cache) return cache;
try {
const v = JSON.parse(localStorage.getItem(KEY) ?? "[]");
cache = Array.isArray(v) ? v : [];
} catch {
cache = [];
}
return cache!;
}
function save(items: OutboxItem[]) {
cache = items;
try {
localStorage.setItem(KEY, JSON.stringify(items));
} catch {
// Storage blocked: the list still works until the page is closed.
}
listeners.forEach((l) => l());
}
export function addToOutbox(sessionId: string, text: string, effort?: string) {
save([...load(), { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, sessionId, text, effort, createdAt: Date.now() }]);
}
export function removeFromOutbox(id: string) {
save(load().filter((i) => i.id !== id));
}
export function useOutbox(sessionId: string): OutboxItem[] {
const all = useSyncExternalStore(
(l) => {
listeners.add(l);
return () => listeners.delete(l);
},
() => load(),
);
return all.filter((i) => i.sessionId === sessionId);
}
/** True for failures of the connection itself (not answers of the server). */
export function isOffline(e: unknown) {
return !(e instanceof ApiError) && (!navigator.onLine || e instanceof TypeError);
}
let flushing = false;
/** Send the waiting messages of a chat in order; stops at the first connection failure. Returns how many were sent. */
export async function flushOutbox(sessionId: string): Promise<number> {
if (flushing) return 0;
flushing = true;
let sent = 0;
try {
for (const item of load().filter((i) => i.sessionId === sessionId)) {
try {
await post(`/sessions/${item.sessionId}/prompt`, { text: item.text, effort: item.effort });
removeFromOutbox(item.id);
sent++;
} catch (e) {
if (isOffline(e)) break;
// The server refused it (deleted chat, read-only …): it would never go through.
removeFromOutbox(item.id);
}
}
} finally {
flushing = false;
}
return sent;
}