- 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>
181 lines
7.1 KiB
TypeScript
181 lines
7.1 KiB
TypeScript
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";
|
|
import type { TermInfo } from "./Terminal";
|
|
|
|
// xterm and the editor are large: load them only when such a tab is shown.
|
|
const TermView = lazy(() => import("./Terminal").then((m) => ({ default: m.TermView })));
|
|
const Files = lazy(() => import("./Files").then((m) => ({ default: m.Files })));
|
|
|
|
const POLL_MS = 15_000;
|
|
const tabKey = (sessionId: string) => `codecraft.chatTab.${sessionId}`;
|
|
|
|
/** A file manager tab (stored per user and chat on the server). */
|
|
interface FileTab {
|
|
id: string;
|
|
dir: string;
|
|
}
|
|
|
|
/**
|
|
* 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[]>([]);
|
|
// 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(
|
|
(v: string) => {
|
|
setTabState(v);
|
|
sessionStorage.setItem(tabKey(sessionId), v);
|
|
},
|
|
[sessionId],
|
|
);
|
|
const setFiles = useCallback(
|
|
(update: (f: FileTab[]) => FileTab[], delay = 0) =>
|
|
setFilesState((cur) => {
|
|
const next = update(cur);
|
|
clearTimeout(pending.current);
|
|
pending.current = setTimeout(() => {
|
|
pending.current = undefined;
|
|
void put(`/sessions/${sessionId}/tabs`, next).catch((e) => toast(e));
|
|
}, delay);
|
|
return next;
|
|
}),
|
|
[sessionId],
|
|
);
|
|
|
|
const reload = useCallback(
|
|
() =>
|
|
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([]);
|
|
setTerms([]);
|
|
void reload();
|
|
const id = setInterval(reload, POLL_MS);
|
|
const onFocus = () => void reload();
|
|
window.addEventListener("focus", onFocus);
|
|
return () => {
|
|
clearInterval(id);
|
|
window.removeEventListener("focus", onFocus);
|
|
};
|
|
}, [sessionId, reload]);
|
|
|
|
// A tab closed elsewhere (or a stale stored tab) falls back to the chat.
|
|
const activeTerm = terms.find((x) => x.id === tab);
|
|
const activeFiles = files.find((f) => f.id === tab);
|
|
const current = activeTerm || activeFiles ? tab : "chat";
|
|
|
|
const openTerminal = async () => {
|
|
try {
|
|
const info = await post<TermInfo>("/terminals", { session_id: sessionId, cols: 100, rows: 30 });
|
|
await reload();
|
|
setTab(info.id);
|
|
} catch (e) {
|
|
toast(e);
|
|
}
|
|
};
|
|
|
|
const closeTerminal = async (x: TermInfo, n: number) => {
|
|
if (x.alive && !(await confirmDialog({ message: t("terminal.closeConfirm", { name: t("terminal.tab", { n }) }), danger: true, confirmLabel: t("terminal.close") }))) return;
|
|
await del(`/terminals/${x.id}`).catch(() => undefined);
|
|
if (tab === x.id) setTab("chat");
|
|
await reload();
|
|
};
|
|
|
|
const openFiles = () => {
|
|
const id = `files:${Date.now().toString(36)}`;
|
|
setFiles((f) => [...f, { id, dir: cwd }]);
|
|
setTab(id);
|
|
};
|
|
|
|
const closeFiles = (id: string) => {
|
|
setFiles((f) => f.filter((x) => x.id !== id));
|
|
if (tab === id) setTab("chat");
|
|
};
|
|
|
|
// 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 };
|
|
}
|
|
|
|
type Tabs = ReturnType<typeof useChatTabs>;
|
|
|
|
const basename = (p: string) => p.split("/").filter(Boolean).pop() ?? "/";
|
|
|
|
/** Tab strip above the chat: the chat, one tab per terminal and file manager, and "+". */
|
|
export function ChatTabBar({ state }: { state: Tabs }) {
|
|
const t = useT();
|
|
const { terms, files, tab, setTab, openTerminal, closeTerminal, openFiles, closeFiles } = state;
|
|
if (terms.length === 0 && files.length === 0) return null;
|
|
return (
|
|
<div className="chat-tabs" role="tablist">
|
|
<button role="tab" aria-selected={tab === "chat"} className={`chat-tab ${tab === "chat" ? "active" : ""}`} onClick={() => setTab("chat")}>
|
|
<Icon name="sparkle" /> {t("terminal.chatTab")}
|
|
</button>
|
|
{terms.map((x, i) => (
|
|
<span key={x.id} className={`chat-tab ${tab === x.id ? "active" : ""} ${x.alive ? "" : "dead"}`}>
|
|
<button role="tab" aria-selected={tab === x.id} className="ghost" onClick={() => setTab(x.id)} title={x.cwd}>
|
|
<Icon name="terminal" /> {t("terminal.tab", { n: i + 1 })}
|
|
</button>
|
|
<button className="ghost close" onClick={() => void closeTerminal(x, i + 1)} aria-label={t("terminal.close")} title={t("terminal.close")}>
|
|
<Icon name="close" size={12} />
|
|
</button>
|
|
</span>
|
|
))}
|
|
{files.map((f) => (
|
|
<span key={f.id} className={`chat-tab ${tab === f.id ? "active" : ""}`}>
|
|
<button role="tab" aria-selected={tab === f.id} className="ghost" onClick={() => setTab(f.id)} title={f.dir}>
|
|
<Icon name="folder" /> {basename(f.dir)}
|
|
</button>
|
|
<button className="ghost close" onClick={() => closeFiles(f.id)} aria-label={t("files.closeTab")} title={t("files.closeTab")}>
|
|
<Icon name="close" size={12} />
|
|
</button>
|
|
</span>
|
|
))}
|
|
<button className="ghost chat-tab-add" onClick={() => void openTerminal()} title={t("terminal.new")} aria-label={t("terminal.new")}>
|
|
<Icon name="plus" /> <Icon name="terminal" />
|
|
</button>
|
|
<button className="ghost chat-tab-add" onClick={openFiles} title={t("files.newTab")} aria-label={t("files.newTab")}>
|
|
<Icon name="plus" /> <Icon name="folder" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Tab contents besides the chat. The active terminal is mounted (it re-attaches and
|
|
* replays its output); file managers stay mounted while hidden, so unsaved edits survive.
|
|
*/
|
|
export function ChatTabPanes({ state }: { state: Tabs }) {
|
|
const t = useT();
|
|
const { tab, activeTerm, files, fileDirChanged } = state;
|
|
return (
|
|
<Suspense fallback={<div className="empty">{t("common.loading")}</div>}>
|
|
{activeTerm && <TermView key={activeTerm.id} info={activeTerm} />}
|
|
{files.map((f) => (
|
|
<div key={f.id} className="chat-pane chat-files" hidden={tab !== f.id}>
|
|
<Files embed={{ startDir: f.dir, onDirChange: (d) => fileDirChanged(f.id, d) }} />
|
|
</div>
|
|
))}
|
|
</Suspense>
|
|
);
|
|
}
|