- 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>
86 lines
2.4 KiB
TypeScript
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;
|
|
}
|