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:
1 parent
242b80605c
commit
c850586a81
29 files changed
+831
-187
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user