Chat tabs, chat mode, own modes, compaction notice, CLI binaries again

- Terminals and file managers as tabs of a chat; chat terminals keep running on
  the server (no idle timeout) and re-attach from any device
- "Just chat" in New session and a built-in chat mode with a lean prompt and only
  web tools; custom modes with kind (coding/conversation), read-only and tool picker
- Mode selector as a dropdown
- The chat shows running compactions (automatic: tokens and share of the limit)
  and labels summaries
- Docker image builds and serves the terminal client binaries again
- Fix: atomic session updates; background title/usage writes no longer undo each
  other (auto compaction triggered one turn late)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 11:24:58 +01:00
1 parent 242b80605c
commit c850586a81
29 files changed
+831 -187

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
import { del, get, post } 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}`;
const filesKey = (sessionId: string) => `codecraft.chatFiles.${sessionId}`;
/** A file manager tab: lives in this browser (it has no server state). */
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.
*/
export function useChatTabs(sessionId: string, cwd: string) {
const t = useT();
const [terms, setTerms] = useState<TermInfo[]>([]);
const [files, setFilesState] = useState<FileTab[]>(() => loadFileTabs(sessionId));
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[]) =>
setFilesState((cur) => {
const next = update(cur);
localStorage.setItem(filesKey(sessionId), JSON.stringify(next));
return next;
}),
[sessionId],
);
const reload = useCallback(
() =>
get<TermInfo[]>(`/terminals?session=${encodeURIComponent(sessionId)}`)
.then(setTerms)
.catch(() => undefined),
[sessionId],
);
useEffect(() => {
setTabState(sessionStorage.getItem(tabKey(sessionId)) ?? "chat");
setFilesState(loadFileTabs(sessionId));
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");
};
const fileDirChanged = useCallback((id: string, dir: string) => setFiles((f) => f.map((x) => (x.id === id ? { ...x, dir } : x))), [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>
);
}