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:
1 parent
81ee084ff2
commit
d152ad836d
26 files changed
+836
-52
No files matched your search
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user