Split view, personal modes, budget warnings, server move script

- Up to three chats side by side (#/s/a+b+c), "Open beside" in the sidebar menu
- File manager tabs of a chat stored per user on the server (/sessions/{id}/tabs)
- Personal modes for every user (Settings → My modes, /account/modes)
- Budget meters (total, provider, images, opencode Go windows) with push warnings
  for admins at the warning share and 100 %, shown in the status overlay
- scripts/move-server.sh: move to another Docker host with bind mounts (Unraid);
  scripts/smoke-test.sh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 12:06:20 +01:00
1 parent 81ee084ff2
commit d152ad836d
26 files changed
+836 -52

No files matched your search

+23 -22
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
import { del, get, post } from "../api";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { del, get, post, put } from "../api";
import { confirmDialog, toast } from "../dialogs";
import { useT } from "../i18n";
import { Icon } from "./Icon";
@@ -11,31 +11,24 @@ const Files = lazy(() => import("./Files").then((m) => ({ default: m.Files })));
const POLL_MS = 15_000;
const tabKey = (sessionId: string) => `codecraft.chatTab.${sessionId}`;
const filesKey = (sessionId: string) => `codecraft.chatFiles.${sessionId}`;
/** A file manager tab: lives in this browser (it has no server state). */
/** A file manager tab (stored per user and chat on the server). */
interface FileTab {
id: string;
dir: string;
}
const loadFileTabs = (sessionId: string): FileTab[] => {
try {
return JSON.parse(localStorage.getItem(filesKey(sessionId)) ?? "[]");
} catch {
return [];
}
};
/**
* Tabs of a chat: the chat itself, its terminals and file managers. Terminals live on
* the server until closed, so they show up again after a reload or on another device
* (the list is refreshed periodically); file manager tabs are kept per browser.
* Tabs of a chat: the chat itself, its terminals and file managers. Both are kept on the
* server, so they show up again after a reload or on another device (the list is
* refreshed periodically); which tab is active is per browser tab.
*/
export function useChatTabs(sessionId: string, cwd: string) {
const t = useT();
const [terms, setTerms] = useState<TermInfo[]>([]);
const [files, setFilesState] = useState<FileTab[]>(() => loadFileTabs(sessionId));
const [files, setFilesState] = useState<FileTab[]>([]);
// Local file tab changes not yet saved: polling must not undo them.
const pending = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [tab, setTabState] = useState<string>(() => sessionStorage.getItem(tabKey(sessionId)) ?? "chat");
const setTab = useCallback(
@@ -46,10 +39,14 @@ export function useChatTabs(sessionId: string, cwd: string) {
[sessionId],
);
const setFiles = useCallback(
(update: (f: FileTab[]) => FileTab[]) =>
(update: (f: FileTab[]) => FileTab[], delay = 0) =>
setFilesState((cur) => {
const next = update(cur);
localStorage.setItem(filesKey(sessionId), JSON.stringify(next));
clearTimeout(pending.current);
pending.current = setTimeout(() => {
pending.current = undefined;
void put(`/sessions/${sessionId}/tabs`, next).catch((e) => toast(e));
}, delay);
return next;
}),
[sessionId],
@@ -57,15 +54,18 @@ export function useChatTabs(sessionId: string, cwd: string) {
const reload = useCallback(
() =>
get<TermInfo[]>(`/terminals?session=${encodeURIComponent(sessionId)}`)
.then(setTerms)
get<{ terminals: TermInfo[]; files: FileTab[] }>(`/sessions/${sessionId}/tabs`)
.then((r) => {
setTerms(r.terminals);
if (!pending.current) setFilesState(r.files);
})
.catch(() => undefined),
[sessionId],
);
useEffect(() => {
setTabState(sessionStorage.getItem(tabKey(sessionId)) ?? "chat");
setFilesState(loadFileTabs(sessionId));
setFilesState([]);
setTerms([]);
void reload();
const id = setInterval(reload, POLL_MS);
@@ -110,7 +110,8 @@ export function useChatTabs(sessionId: string, cwd: string) {
if (tab === id) setTab("chat");
};
const fileDirChanged = useCallback((id: string, dir: string) => setFiles((f) => f.map((x) => (x.id === id ? { ...x, dir } : x))), [setFiles]);
// Browsing saves the folder after a short pause, not on every click.
const fileDirChanged = useCallback((id: string, dir: string) => setFiles((f) => f.map((x) => (x.id === id ? { ...x, dir } : x)), 1000), [setFiles]);
return { terms, files, tab: current, activeTerm, setTab, openTerminal, closeTerminal, openFiles, closeFiles, fileDirChanged };
}