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>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i
|
||||
import { navigate } from "../router";
|
||||
import { dismissError, loadSession, loadSessions, useStore } from "../store";
|
||||
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
|
||||
import { ActivityLine, Dots, ThinkIcon, WorkBar } from "./Activity";
|
||||
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
||||
import { useReasoningMode } from "../prefs";
|
||||
import { Composer } from "./Composer";
|
||||
import { ShareDialog } from "./ShareDialog";
|
||||
@@ -24,6 +24,7 @@ import { RewindDialog } from "./RewindDialog";
|
||||
import { ShellsChip } from "./ShellsPanel";
|
||||
import { TodoList, ToolCard } from "./ToolCard";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
|
||||
|
||||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||
|
||||
@@ -145,8 +146,10 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
const allPermissions = useStore((s) => s.permissions);
|
||||
const allQuestions = useStore((s) => s.questions);
|
||||
const waiting = useStore((s) => s.waiting[sessionId]);
|
||||
const compacting = useStore((s) => s.compacting[sessionId]);
|
||||
const readOnly = useStore((s) => !!s.readOnly[sessionId]);
|
||||
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||||
const tabs = useChatTabs(sessionId, session?.cwd ?? "");
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const models = useModels();
|
||||
const [loadError, setLoadError] = useState<string>();
|
||||
@@ -314,14 +317,14 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
)}
|
||||
<button
|
||||
className="small ghost hide-mobile"
|
||||
onClick={() => {
|
||||
sessionStorage.setItem("codecraft.termCwd", session.cwd);
|
||||
navigate("/terminal/new");
|
||||
}}
|
||||
onClick={() => void tabs.openTerminal()}
|
||||
title={t("terminal.here")}
|
||||
>
|
||||
<Icon name="terminal" />
|
||||
</button>
|
||||
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}>
|
||||
<Icon name="folder" />
|
||||
</button>
|
||||
{!session.parent_id && (
|
||||
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
|
||||
<Icon name="fork" />
|
||||
@@ -366,6 +369,9 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ChatTabBar state={tabs} />
|
||||
<ChatTabPanes state={tabs} />
|
||||
<div className="chat-pane" hidden={tabs.tab !== "chat"}>
|
||||
{session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
|
||||
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
|
||||
<div
|
||||
@@ -402,7 +408,13 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
if (summary && summary.type === "summary") {
|
||||
return (
|
||||
<details key={m.id} className="summary-divider">
|
||||
<summary>{t("chat.compacted")}</summary>
|
||||
<summary>
|
||||
{summary.auto
|
||||
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
||||
: summary.tokens
|
||||
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
|
||||
: t("chat.compacted")}
|
||||
</summary>
|
||||
<Markdown text={summary.text} />
|
||||
</details>
|
||||
);
|
||||
@@ -452,6 +464,19 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
||||
</div>
|
||||
)}
|
||||
{compacting && (
|
||||
<div className={`compacting-card ${compacting.auto ? "auto" : ""}`} role="status">
|
||||
<Spinner small />
|
||||
<div>
|
||||
<b>{t(compacting.auto ? "chat.compactingAuto" : "chat.compactingManual")}</b>
|
||||
{compacting.auto && compacting.tokens && compacting.limit && (
|
||||
<div className="hint">
|
||||
{t("chat.compactingDetail", { tokens: fmtCompact(compacting.tokens), limit: fmtCompact(compacting.limit), pct: fmtPercent(compacting.tokens / compacting.limit) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(busy || waiting) && (
|
||||
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
|
||||
)}
|
||||
@@ -514,6 +539,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
onUpdate={update}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -288,20 +288,20 @@ export function Composer({
|
||||
/>
|
||||
<div className="composer-bar">
|
||||
<div className="composer-options">
|
||||
<div className="mode-tabs" role="tablist" aria-label={t("composer.mode")}>
|
||||
<select
|
||||
className="mode-select"
|
||||
value={session.agent}
|
||||
onChange={(e) => onUpdate({ agent: e.target.value })}
|
||||
aria-label={t("composer.mode")}
|
||||
title={agents.find((a) => a.name === session.agent)?.description ?? t("composer.mode")}
|
||||
>
|
||||
{!agents.some((a) => a.name === session.agent) && <option value={session.agent}>{session.agent}</option>}
|
||||
{agents.map((a) => (
|
||||
<button
|
||||
key={a.name}
|
||||
role="tab"
|
||||
aria-selected={session.agent === a.name}
|
||||
className={session.agent === a.name ? "active" : ""}
|
||||
title={a.description}
|
||||
onClick={() => onUpdate({ agent: a.name })}
|
||||
>
|
||||
<option key={a.name} value={a.name} title={a.description}>
|
||||
{a.name}
|
||||
</button>
|
||||
</option>
|
||||
))}
|
||||
</div>
|
||||
</select>
|
||||
{session.agent === "craft" && (
|
||||
<div className="mode-tabs" role="tablist" aria-label={t("craft.mode")} title={t("craft.modeHint")}>
|
||||
<button className={session.clean_room !== false ? "active" : ""} onClick={() => onUpdate({ clean_room: true })}>
|
||||
|
||||
@@ -56,10 +56,16 @@ function icon(e: Entry) {
|
||||
return <Icon name="file" className="ficon" />;
|
||||
}
|
||||
|
||||
export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
/** Embedded in a chat tab: own start folder, no page header, folder kept by the caller. */
|
||||
export interface FilesEmbed {
|
||||
startDir: string;
|
||||
onDirChange?: (dir: string) => void;
|
||||
}
|
||||
|
||||
export function Files({ onMenu, embed }: { onMenu?: () => void; embed?: FilesEmbed }) {
|
||||
const t = useT();
|
||||
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
|
||||
const [dir, setDir] = useState<string>(() => localStorage.getItem("codecraft.filesDir") ?? "");
|
||||
const [dir, setDir] = useState<string>(() => embed?.startDir ?? localStorage.getItem("codecraft.filesDir") ?? "");
|
||||
const [listing, setListing] = useState<Listing>();
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
@@ -79,7 +85,8 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
const l = await get<Listing>(`/files/list?path=${encodeURIComponent(path)}&hidden=${hidden}`);
|
||||
setListing(l);
|
||||
setDir(l.path);
|
||||
localStorage.setItem("codecraft.filesDir", l.path);
|
||||
if (embed) embed.onDirChange?.(l.path);
|
||||
else localStorage.setItem("codecraft.filesDir", l.path);
|
||||
setError(undefined);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
@@ -143,7 +150,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
|
||||
// "Edit file" links from other pages (e.g. automations) open the file directly.
|
||||
useEffect(() => {
|
||||
const target = sessionStorage.getItem("codecraft.openFile");
|
||||
const target = embed ? null : sessionStorage.getItem("codecraft.openFile");
|
||||
if (!target) return;
|
||||
sessionStorage.removeItem("codecraft.openFile");
|
||||
setDir(target.slice(0, target.lastIndexOf("/")) || "/");
|
||||
@@ -432,12 +439,14 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<div className={embed ? "files-embed-bar" : "topbar"}>
|
||||
{!embed && (
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
)}
|
||||
<div className="grow">
|
||||
<h1>{t("nav.files")}</h1>
|
||||
{!embed && <h1>{t("nav.files")}</h1>}
|
||||
<div className="path">{listing?.path}</div>
|
||||
</div>
|
||||
{listing?.repo && (
|
||||
|
||||
@@ -103,6 +103,7 @@ const ICONS: Record<string, ReactNode> = {
|
||||
),
|
||||
upload: svg(<path d="M12 16V4M7 9l5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />),
|
||||
clip: svg(<path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />),
|
||||
chat: svg(<path d="M4 5h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H9l-4 3v-3H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm4 6h.01M12 11h.01M16 11h.01" />),
|
||||
start: svg(<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z" />),
|
||||
};
|
||||
|
||||
@@ -185,7 +186,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
const [creating, setCreating] = useState<string>();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const agents = useAgents(undefined).filter((a) => a.mode === "primary");
|
||||
const custom = agents.filter((a) => !["build", "plan", "craft"].includes(a.name));
|
||||
const custom = agents.filter((a) => !["build", "plan", "craft", "chat"].includes(a.name));
|
||||
|
||||
useEffect(() => {
|
||||
if (!model && config?.default_model) setModel(config.default_model);
|
||||
@@ -244,7 +245,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
const canStart =
|
||||
!creating &&
|
||||
(text.trim() || (needsUrl && url.trim()) || (isRecreate && originals.length > 0)) &&
|
||||
(folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim())));
|
||||
(task === "chat" ? !!rootPath : folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim())));
|
||||
|
||||
/** The first message(s) for the agent. */
|
||||
const prompts = (uploaded: string[]): string[] => {
|
||||
@@ -275,7 +276,10 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
try {
|
||||
// 1. Folder
|
||||
let cwd = picked;
|
||||
if (folderMode === "git") {
|
||||
if (task === "chat") {
|
||||
// A plain conversation needs no project: it runs in the user's own folder.
|
||||
cwd = rootPath;
|
||||
} else if (folderMode === "git") {
|
||||
setCreating(t("wizard.cloning"));
|
||||
const target = `${rootPath}/${folderName.trim().replace(/[\\/]/g, "-")}`;
|
||||
await post("/git", { action: "clone", path: target, url: repoUrl.trim() });
|
||||
@@ -295,7 +299,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
}
|
||||
if (!cwd) throw new Error("could not create the folder");
|
||||
}
|
||||
localStorage.setItem(LAST_DIR, cwd);
|
||||
if (task !== "chat") localStorage.setItem(LAST_DIR, cwd);
|
||||
// 2. Files: originals of old software into original/, everything else into .codecraft/uploads/
|
||||
const uploaded: string[] = [];
|
||||
const mkdirs = async (dir: string) => {
|
||||
@@ -327,7 +331,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
agent: task === "cad" ? "build" : task,
|
||||
clean_room: isCraft ? !deep : undefined,
|
||||
});
|
||||
if (isolated && folderMode !== "new") {
|
||||
if (isolated && folderMode !== "new" && task !== "chat") {
|
||||
setCreating(t("worktree.creating"));
|
||||
await post(`/sessions/${s.id}/worktree`, { action: "create" });
|
||||
}
|
||||
@@ -359,6 +363,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
<div className="panel wiz-panel">
|
||||
<Step n={n++} title={t("wizard.whatToDo")}>
|
||||
<div className="wiz-cards">
|
||||
<Card icon="chat" title={t("wizard.task.chat")} desc={t("wizard.task.chatDesc")} active={task === "chat"} onClick={() => setTask("chat")} />
|
||||
<Card icon="craft" title={t("wizard.task.craft")} desc={t("wizard.task.craftDesc")} active={task === "craft"} onClick={() => setTask("craft")} />
|
||||
<Card icon="cad" title={t("wizard.task.cad")} desc={t("wizard.task.cadDesc")} active={task === "cad"} onClick={() => setTask("cad")} />
|
||||
<Card icon="build" title={t("wizard.task.build")} desc={t("wizard.task.buildDesc")} active={task === "build"} onClick={() => setTask("build")} />
|
||||
@@ -454,7 +459,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
>
|
||||
<textarea
|
||||
value={text}
|
||||
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : task === "cad" ? "wizard.placeholder.cad" : "wizard.placeholder.task")}
|
||||
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : task === "cad" ? "wizard.placeholder.cad" : task === "chat" ? "wizard.placeholder.chat" : "wizard.placeholder.task")}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
if (e.clipboardData.files.length) {
|
||||
@@ -512,57 +517,61 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
|
||||
<div className="wiz-foot">
|
||||
<div className="wiz-foot-row">
|
||||
<label>{t("wizard.builtBy")}</label>
|
||||
<label>{task === "chat" ? t("composer.model") : t("wizard.builtBy")}</label>
|
||||
<ModelSelect value={model} onChange={setModel} />
|
||||
</div>
|
||||
<div className="wiz-folder-modes" role="radiogroup" aria-label={t("wizard.whereLabel")}>
|
||||
{(["new", "existing", "git"] as const).map((m) => (
|
||||
<button key={m} type="button" role="radio" aria-checked={folderMode === m} className={`wiz-folder-mode ${folderMode === m ? "active" : ""}`} onClick={() => setFolderMode(m)}>
|
||||
{ICONS[m === "git" ? "git" : "folder"]}
|
||||
{t(m === "new" ? "wizard.whereNew" : m === "existing" ? "wizard.whereExisting" : "wizard.whereGit")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{folderMode === "git" && (
|
||||
<input className="wiz-url" value={repoUrl} placeholder="https://git.example.com/user/repo.git" onChange={(e) => setRepoUrl(e.target.value)} />
|
||||
)}
|
||||
{folderMode !== "existing" && (
|
||||
<div className="wiz-folder">
|
||||
{ICONS.folder}
|
||||
<span className="wiz-folder-text">
|
||||
{t(folderMode === "git" ? "wizard.cloneInto" : "wizard.newFolderIn")}{" "}
|
||||
{roots.length > 1 ? (
|
||||
<select value={rootPath} onChange={(e) => setRootPath(e.target.value)}>
|
||||
{roots.map((r) => (
|
||||
<option key={r.path} value={r.path}>
|
||||
{r.name === "/" ? "workspace" : r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<code>{!roots[0] || roots[0].name === "/" ? "workspace" : roots[0].name}</code>
|
||||
)}
|
||||
<span>/</span>
|
||||
<input
|
||||
className="wiz-folder-name"
|
||||
value={folderName}
|
||||
onChange={(e) => {
|
||||
setFolderName(e.target.value);
|
||||
setNameTouched(true);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{folderMode === "existing" && <FolderPicker path={picked} onPick={setPicked} />}
|
||||
{folderMode !== "new" && (
|
||||
<label className="wiz-check-row">
|
||||
<input type="checkbox" checked={isolated} onChange={(e) => setIsolated(e.target.checked)} />
|
||||
<span>
|
||||
<b>{t("worktree.isolate")}</b>
|
||||
<span className="hint"> – {t("worktree.isolateHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
{task !== "chat" && (
|
||||
<>
|
||||
<div className="wiz-folder-modes" role="radiogroup" aria-label={t("wizard.whereLabel")}>
|
||||
{(["new", "existing", "git"] as const).map((m) => (
|
||||
<button key={m} type="button" role="radio" aria-checked={folderMode === m} className={`wiz-folder-mode ${folderMode === m ? "active" : ""}`} onClick={() => setFolderMode(m)}>
|
||||
{ICONS[m === "git" ? "git" : "folder"]}
|
||||
{t(m === "new" ? "wizard.whereNew" : m === "existing" ? "wizard.whereExisting" : "wizard.whereGit")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{folderMode === "git" && (
|
||||
<input className="wiz-url" value={repoUrl} placeholder="https://git.example.com/user/repo.git" onChange={(e) => setRepoUrl(e.target.value)} />
|
||||
)}
|
||||
{folderMode !== "existing" && (
|
||||
<div className="wiz-folder">
|
||||
{ICONS.folder}
|
||||
<span className="wiz-folder-text">
|
||||
{t(folderMode === "git" ? "wizard.cloneInto" : "wizard.newFolderIn")}{" "}
|
||||
{roots.length > 1 ? (
|
||||
<select value={rootPath} onChange={(e) => setRootPath(e.target.value)}>
|
||||
{roots.map((r) => (
|
||||
<option key={r.path} value={r.path}>
|
||||
{r.name === "/" ? "workspace" : r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<code>{!roots[0] || roots[0].name === "/" ? "workspace" : roots[0].name}</code>
|
||||
)}
|
||||
<span>/</span>
|
||||
<input
|
||||
className="wiz-folder-name"
|
||||
value={folderName}
|
||||
onChange={(e) => {
|
||||
setFolderName(e.target.value);
|
||||
setNameTouched(true);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{folderMode === "existing" && <FolderPicker path={picked} onPick={setPicked} />}
|
||||
{folderMode !== "new" && (
|
||||
<label className="wiz-check-row">
|
||||
<input type="checkbox" checked={isolated} onChange={(e) => setIsolated(e.target.checked)} />
|
||||
<span>
|
||||
<b>{t("worktree.isolate")}</b>
|
||||
<span className="hint"> – {t("worktree.isolateHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
<div className="wiz-start-row">
|
||||
|
||||
@@ -10,9 +10,10 @@ import { onThemeChange } from "../theme";
|
||||
import { toast } from "../dialogs";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface TermInfo {
|
||||
export interface TermInfo {
|
||||
id: string;
|
||||
cwd: string;
|
||||
session_id?: string | null;
|
||||
created_at: number;
|
||||
alive: boolean;
|
||||
attached: boolean;
|
||||
@@ -132,7 +133,7 @@ export function TerminalPage({ id, onMenu }: { id?: string; onMenu: () => void }
|
||||
);
|
||||
}
|
||||
|
||||
function TermView({ info }: { info: TermInfo }) {
|
||||
export function TermView({ info }: { info: TermInfo }) {
|
||||
const t = useT();
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
@@ -9,25 +9,60 @@ import { confirmDialog, promptDialog, toast } from "../../dialogs";
|
||||
|
||||
type Patch = { path: string[]; value: unknown };
|
||||
|
||||
interface ToolInfo {
|
||||
name: string;
|
||||
read_only: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Tool checkboxes: nothing checked = all tools. */
|
||||
function ToolPicker({ tools, value, onChange, readOnly }: { tools: ToolInfo[]; value: string[]; onChange: (v: string[]) => void; readOnly: boolean }) {
|
||||
const t = useT();
|
||||
const toggle = (n: string) => onChange(value.includes(n) ? value.filter((x) => x !== n) : [...value, n]);
|
||||
// Names configured by hand (e.g. MCP patterns) stay selected even if not listed.
|
||||
const extra = value.filter((v) => !tools.some((x) => x.name === v));
|
||||
return (
|
||||
<div className="tool-picker">
|
||||
<div className="hint">{value.length ? t("agents.toolsSome", { n: value.length }) : t("agents.toolsAll")}</div>
|
||||
<div className="tool-grid">
|
||||
{tools.map((x) => (
|
||||
<label key={x.name} className={`tool-item ${readOnly && !x.read_only ? "disabled" : ""}`} title={x.description}>
|
||||
<input type="checkbox" checked={value.includes(x.name)} disabled={readOnly && !x.read_only} onChange={() => toggle(x.name)} />
|
||||
<span className="mono">{x.name}</span>
|
||||
</label>
|
||||
))}
|
||||
{extra.map((n) => (
|
||||
<label key={n} className="tool-item">
|
||||
<input type="checkbox" checked onChange={() => toggle(n)} />
|
||||
<span className="mono">{n}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const WRITER_PROMPT = `You are a technical writer. Produce clear, well-structured text (Markdown) for
|
||||
documentation, summaries, step-by-step guides and translations.
|
||||
Return only the finished text.`;
|
||||
|
||||
function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: boolean, text: string) => void }) {
|
||||
function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: ToolInfo[]; onSaved: (ok: boolean, text: string) => void }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(!!raw && !raw.prompt && !raw.description && raw.mode === "primary");
|
||||
const [form, setForm] = useState({
|
||||
model: a.model ?? "",
|
||||
max_parallel: a.max_parallel ? String(a.max_parallel) : "",
|
||||
description: raw?.description ?? "",
|
||||
tools: (raw?.tools ?? []).join(", "),
|
||||
tools: (raw?.tools ?? []) as string[],
|
||||
read_only: !!(raw?.read_only ?? a.read_only),
|
||||
style: (raw?.style ?? a.style ?? "coding") as "coding" | "chat",
|
||||
prompt: raw?.prompt ?? "",
|
||||
mode: raw?.mode ?? a.mode,
|
||||
refine_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0),
|
||||
refine_model: raw?.refine?.model ?? "",
|
||||
refine_criteria: raw?.refine?.criteria ?? "",
|
||||
});
|
||||
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "general", "explore"].includes(a.name));
|
||||
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
|
||||
|
||||
const save = async () => {
|
||||
const base = ["agents", a.name];
|
||||
@@ -45,7 +80,9 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
|
||||
if (!builtin) {
|
||||
patches.push({ path: [...base, "mode"], value: form.mode });
|
||||
patches.push({ path: [...base, "tools"], value: form.tools.trim() ? form.tools.split(/[\s,]+/).filter(Boolean) : null });
|
||||
patches.push({ path: [...base, "tools"], value: form.tools.length ? form.tools : null });
|
||||
patches.push({ path: [...base, "read_only"], value: form.read_only || null });
|
||||
patches.push({ path: [...base, "style"], value: form.style === "chat" ? "chat" : null });
|
||||
patches.push({ path: [...base, "prompt"], value: form.prompt || null });
|
||||
}
|
||||
try {
|
||||
@@ -133,8 +170,23 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
<option value="subagent">{t("agents.modeSubagent")}</option>
|
||||
<option value="primary">{t("agents.modePrimary")}</option>
|
||||
</select>
|
||||
{form.mode === "primary" && (
|
||||
<>
|
||||
<label>{t("agents.style")}</label>
|
||||
<select value={form.style} onChange={(e) => setForm({ ...form, style: e.target.value as "coding" | "chat" })}>
|
||||
<option value="coding">{t("agents.styleCoding")}</option>
|
||||
<option value="chat">{t("agents.styleChat")}</option>
|
||||
</select>
|
||||
<div className="hint">{t("agents.styleHint")}</div>
|
||||
</>
|
||||
)}
|
||||
<label>{t("agents.readOnlyLabel")}</label>
|
||||
<label className="row" style={{ gap: 6, alignItems: "center" }}>
|
||||
<input type="checkbox" checked={form.read_only} onChange={(e) => setForm({ ...form, read_only: e.target.checked })} />
|
||||
<span className="hint">{t("agents.readOnlyHint")}</span>
|
||||
</label>
|
||||
<label>{t("agents.tools")}</label>
|
||||
<input value={form.tools} placeholder={t("agents.toolsAll")} onChange={(e) => setForm({ ...form, tools: e.target.value })} className="mono" />
|
||||
<ToolPicker tools={tools} value={form.tools} onChange={(v) => setForm({ ...form, tools: v })} readOnly={form.read_only} />
|
||||
<label>{t("agents.prompt")}</label>
|
||||
<textarea rows={6} value={form.prompt} onChange={(e) => setForm({ ...form, prompt: e.target.value })} />
|
||||
</>
|
||||
@@ -160,6 +212,7 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<string, any>; reloadConfig: () => void }) {
|
||||
const t = useT();
|
||||
const { data: agents, reload } = useFetch<AgentInfo[]>("/agents");
|
||||
const tools = useFetch<ToolInfo[]>("/agents/tools").data ?? [];
|
||||
const cmds = useFetch<{ commands: any[]; skills: any[] }>("/commands").data;
|
||||
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
||||
const [name, setName] = useState("");
|
||||
@@ -172,13 +225,15 @@ export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<st
|
||||
}
|
||||
};
|
||||
|
||||
const add = async (template: "writer" | "blank") => {
|
||||
const add = async (template: "writer" | "blank" | "mode") => {
|
||||
const n = name.trim() || (template === "writer" ? "writer" : "");
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
|
||||
const value =
|
||||
template === "writer"
|
||||
? { mode: "subagent", description: "Writes documentation, summaries, guides and translations.", tools: ["read", "glob", "grep"], max_parallel: 1, prompt: WRITER_PROMPT }
|
||||
: { mode: "subagent", description: "", prompt: "" };
|
||||
: template === "mode"
|
||||
? { mode: "primary" }
|
||||
: { mode: "subagent", description: "", prompt: "" };
|
||||
try {
|
||||
await patch("/config", { patches: [{ path: ["agents", n], value }] });
|
||||
setName("");
|
||||
@@ -194,13 +249,16 @@ export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<st
|
||||
<div className="notice">{t("agents.intro")}</div>
|
||||
{notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>}
|
||||
<h3 style={{ fontSize: 14 }}>{t("agents.primaryTitle")}</h3>
|
||||
{agents?.filter((a) => a.mode === "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} onSaved={saved} />)}
|
||||
{agents?.filter((a) => a.mode === "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} tools={tools} onSaved={saved} />)}
|
||||
<h3 style={{ fontSize: 14 }}>{t("agents.subagentTitle")}</h3>
|
||||
{agents?.filter((a) => a.mode !== "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} onSaved={saved} />)}
|
||||
{agents?.filter((a) => a.mode !== "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} tools={tools} onSaved={saved} />)}
|
||||
<div className="panel">
|
||||
<h3>{t("agents.add")}</h3>
|
||||
<div className="row">
|
||||
<input placeholder={t("settings.name")} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<button className="primary" onClick={() => add("mode")} disabled={!name.trim()}>
|
||||
+ {t("agents.addMode")}
|
||||
</button>
|
||||
<button onClick={() => add("writer")}>+ {t("agents.addWriter")}</button>
|
||||
<button onClick={() => add("blank")} disabled={!name.trim()}>
|
||||
+ {t("agents.addBlank")}
|
||||
|
||||
+22
-1
@@ -86,6 +86,8 @@ export const de: Messages = {
|
||||
"files.renameTo": "Neuer Name:",
|
||||
"files.copyTo": "Name der Kopie:",
|
||||
"files.chatHere": "Chat hier öffnen",
|
||||
"files.newTab": "Dateimanager öffnen",
|
||||
"files.closeTab": "Dateimanager schließen",
|
||||
"files.rename": "Umbenennen",
|
||||
"files.duplicate": "Duplizieren",
|
||||
"files.download": "Herunterladen",
|
||||
@@ -236,6 +238,9 @@ export const de: Messages = {
|
||||
"wizard.title": "Was möchtest du machen?",
|
||||
"wizard.subtitle": "Aufgabe wählen, beschreiben, was du willst, dann auf Start drücken.",
|
||||
"wizard.whatToDo": "Was möchtest du machen?",
|
||||
"wizard.task.chat": "Einfach chatten",
|
||||
"wizard.task.chatDesc": "Fragen, besprechen, nachschlagen – ohne Projekt und Dateien",
|
||||
"wizard.placeholder.chat": "Worüber möchtest du sprechen?",
|
||||
"wizard.task.craft": "Etwas craften",
|
||||
"wizard.task.craftDesc": "Website, App, Spiel oder altes Programm nachbauen – oder etwas Neues bauen",
|
||||
"wizard.task.build": "Am Code arbeiten",
|
||||
@@ -303,6 +308,9 @@ export const de: Messages = {
|
||||
"rename.generate": "Generieren",
|
||||
"nav.terminal": "Terminal",
|
||||
"terminal.new": "Neues Terminal",
|
||||
"terminal.chatTab": "Chat",
|
||||
"terminal.tab": "Terminal {n}",
|
||||
"terminal.closeConfirm": "{name} schließen? Die Shell und alles, was darin läuft, wird beendet.",
|
||||
"terminal.close": "Terminal schließen",
|
||||
"terminal.here": "Terminal in diesem Ordner öffnen",
|
||||
"terminal.connecting": "Verbinde…",
|
||||
@@ -557,6 +565,11 @@ export const de: Messages = {
|
||||
"chat.exportMd": "Markdown (.md)",
|
||||
"chat.exportJson": "JSON (.json)",
|
||||
"chat.empty": "Schreib eine Nachricht, um zu starten.",
|
||||
"chat.compactedAuto": "Kontext fast voll ({tokens} Tokens) – Unterhaltung automatisch komprimiert · Zusammenfassung",
|
||||
"chat.compactedManual": "Unterhaltung bei {tokens} Tokens komprimiert · Zusammenfassung",
|
||||
"chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert",
|
||||
"chat.compactingManual": "Unterhaltung wird komprimiert",
|
||||
"chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter",
|
||||
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
|
||||
"chat.context": "Kontext",
|
||||
"chat.contextHint": "{tokens} Tokens in der letzten Anfrage",
|
||||
@@ -843,8 +856,16 @@ export const de: Messages = {
|
||||
"agents.description": "Beschreibung",
|
||||
"agents.descriptionHint": "Anhand dieser Beschreibung entscheidet der Hauptagent, wann er delegiert.",
|
||||
"agents.mode": "Art",
|
||||
"agents.addMode": "Neuer Modus",
|
||||
"agents.style": "Art",
|
||||
"agents.styleCoding": "Programmieren – Projekt-Kontext und Coding-Ablauf",
|
||||
"agents.styleChat": "Unterhaltung – nur dieser Prompt, kein Projekt",
|
||||
"agents.styleHint": "Unterhaltungs-Modi bekommen nur deinen Prompt plus Datum und persönliches Gedächtnis, wie der eingebaute Chat-Modus.",
|
||||
"agents.readOnlyLabel": "Nur lesen",
|
||||
"agents.readOnlyHint": "Nur Werkzeuge, die nichts verändern (wie der Plan-Modus)",
|
||||
"agents.toolsSome": "{n} Werkzeuge ausgewählt – nur diese sind verfügbar",
|
||||
"agents.tools": "Tools",
|
||||
"agents.toolsAll": "alle Tools (z. B. read, glob, grep)",
|
||||
"agents.toolsAll": "Nichts ausgewählt = alle Werkzeuge",
|
||||
"agents.prompt": "Anweisungen",
|
||||
"agents.reset": "Zurücksetzen",
|
||||
"agents.resetConfirm": "„{name}“ auf die eingebauten Standardwerte zurücksetzen?",
|
||||
|
||||
+22
-1
@@ -85,6 +85,8 @@ export const en = {
|
||||
"files.renameTo": "New name:",
|
||||
"files.copyTo": "Name of the copy:",
|
||||
"files.chatHere": "Open chat here",
|
||||
"files.newTab": "Open file manager",
|
||||
"files.closeTab": "Close file manager",
|
||||
"files.rename": "Rename",
|
||||
"files.duplicate": "Duplicate",
|
||||
"files.download": "Download",
|
||||
@@ -235,6 +237,9 @@ export const en = {
|
||||
"wizard.title": "What do you want to do?",
|
||||
"wizard.subtitle": "Pick a task, say what you want, then press Start.",
|
||||
"wizard.whatToDo": "What do you want to do?",
|
||||
"wizard.task.chat": "Just chat",
|
||||
"wizard.task.chatDesc": "Ask, discuss, look things up – no project, no files",
|
||||
"wizard.placeholder.chat": "What would you like to talk about?",
|
||||
"wizard.task.craft": "Craft something",
|
||||
"wizard.task.craftDesc": "Recreate a website, app, game or old program – or build something new",
|
||||
"wizard.task.build": "Work on code",
|
||||
@@ -302,6 +307,9 @@ export const en = {
|
||||
"rename.generate": "Generate",
|
||||
"nav.terminal": "Terminal",
|
||||
"terminal.new": "New terminal",
|
||||
"terminal.chatTab": "Chat",
|
||||
"terminal.tab": "Terminal {n}",
|
||||
"terminal.closeConfirm": "Close {name}? The shell and everything running in it are stopped.",
|
||||
"terminal.close": "Close terminal",
|
||||
"terminal.here": "Open a terminal in this folder",
|
||||
"terminal.connecting": "Connecting…",
|
||||
@@ -556,6 +564,11 @@ export const en = {
|
||||
"chat.exportMd": "Markdown (.md)",
|
||||
"chat.exportJson": "JSON (.json)",
|
||||
"chat.empty": "Send a message to start.",
|
||||
"chat.compactedAuto": "Context nearly full ({tokens} tokens) – conversation compacted automatically · summary",
|
||||
"chat.compactedManual": "Conversation compacted at {tokens} tokens · summary",
|
||||
"chat.compactingAuto": "Context nearly full – compacting the conversation automatically",
|
||||
"chat.compactingManual": "Compacting the conversation",
|
||||
"chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards",
|
||||
"chat.compacted": "Conversation compacted – summary",
|
||||
"chat.context": "Context",
|
||||
"chat.contextHint": "{tokens} tokens in the last request",
|
||||
@@ -842,8 +855,16 @@ export const en = {
|
||||
"agents.description": "Description",
|
||||
"agents.descriptionHint": "The main agent decides by this description when to delegate.",
|
||||
"agents.mode": "Type",
|
||||
"agents.addMode": "New mode",
|
||||
"agents.style": "Kind",
|
||||
"agents.styleCoding": "Coding – project context and coding workflow",
|
||||
"agents.styleChat": "Conversation – only this prompt, no project",
|
||||
"agents.styleHint": "Conversation modes get just your prompt plus date and personal memory, like the built-in chat mode.",
|
||||
"agents.readOnlyLabel": "Read-only",
|
||||
"agents.readOnlyHint": "Only tools that change nothing (like plan mode)",
|
||||
"agents.toolsSome": "{n} tools selected – only these are available",
|
||||
"agents.tools": "Tools",
|
||||
"agents.toolsAll": "all tools (e.g. read, glob, grep)",
|
||||
"agents.toolsAll": "Nothing selected = all tools",
|
||||
"agents.prompt": "Instructions",
|
||||
"agents.reset": "Reset",
|
||||
"agents.resetConfirm": "Reset \"{name}\" to the built-in defaults?",
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface State {
|
||||
queue: Record<string, QueueItem[]>;
|
||||
/** Why a session waits: "model:<spec>" or "subagent:<name>". */
|
||||
waiting: Record<string, string | undefined>;
|
||||
/** Compaction running in a session (auto = context nearly full). */
|
||||
compacting: Record<string, { auto: boolean; tokens: number | null; limit: number | null; since: number } | undefined>;
|
||||
}
|
||||
|
||||
let state: State = {
|
||||
@@ -67,6 +69,7 @@ let state: State = {
|
||||
queue: {},
|
||||
steps: {},
|
||||
waiting: {},
|
||||
compacting: {},
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
@@ -188,6 +191,14 @@ export function applyEvent(ev: ServerEvent) {
|
||||
case "waiting":
|
||||
setState((s) => ({ waiting: { ...s.waiting, [ev.session_id]: ev.reason ?? undefined } }));
|
||||
break;
|
||||
case "compaction":
|
||||
setState((s) => ({
|
||||
compacting: {
|
||||
...s.compacting,
|
||||
[ev.session_id]: ev.state === "started" ? { auto: ev.auto, tokens: ev.tokens, limit: ev.limit, since: Date.now() } : undefined,
|
||||
},
|
||||
}));
|
||||
break;
|
||||
case "mcp_status":
|
||||
setState(() => ({ mcp: ev.servers }));
|
||||
break;
|
||||
|
||||
@@ -3603,3 +3603,125 @@ button.danger-solid {
|
||||
.plan-pct {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* --- chat tabs (chat + terminals) ---------------------------------------------- */
|
||||
.chat-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 10px 0;
|
||||
border-bottom: 1px solid var(--bg3);
|
||||
overflow-x: auto;
|
||||
flex: none;
|
||||
}
|
||||
.chat-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: none;
|
||||
color: var(--fg2);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
span.chat-tab {
|
||||
padding: 0 4px 0 0;
|
||||
}
|
||||
span.chat-tab > button {
|
||||
padding: 6px 4px 6px 12px;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.chat-tab .close {
|
||||
padding: 2px 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.chat-tab .close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.chat-tab:hover {
|
||||
background: var(--bg1);
|
||||
color: var(--fg0);
|
||||
}
|
||||
.chat-tab.active {
|
||||
color: var(--fg0);
|
||||
border-bottom-color: var(--accent);
|
||||
background: var(--bg1);
|
||||
}
|
||||
.chat-tab.dead {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.chat-tab-add {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.chat-pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chat-pane[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.files-embed-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--bg3);
|
||||
flex: none;
|
||||
}
|
||||
.files-embed-bar .path {
|
||||
font-size: 12px;
|
||||
color: var(--fg3);
|
||||
font-family: var(--mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-picker .hint {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
gap: 4px 12px;
|
||||
}
|
||||
.tool-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tool-item.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
.mode-select {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.compacting-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
padding: 10px 14px;
|
||||
border: 1px dashed var(--bg3);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg1);
|
||||
font-size: 13px;
|
||||
}
|
||||
.compacting-card.auto {
|
||||
border-color: color-mix(in srgb, var(--accent) 60%, var(--bg3));
|
||||
}
|
||||
+4
-1
@@ -19,7 +19,7 @@ export type Part =
|
||||
is_error: boolean;
|
||||
meta?: any;
|
||||
}
|
||||
| { type: "summary"; text: string };
|
||||
| { type: "summary"; text: string; auto?: boolean; tokens?: number };
|
||||
|
||||
export interface Usage {
|
||||
input_tokens: number;
|
||||
@@ -118,6 +118,7 @@ export type ServerEvent =
|
||||
| { type: "waiting"; session_id: string; reason: string | null }
|
||||
| { type: "queue_updated"; session_id: string; items: QueueItem[] }
|
||||
| { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean }
|
||||
| { type: "compaction"; session_id: string; state: "started" | "done" | "failed"; auto: boolean; tokens: number | null; limit: number | null }
|
||||
| { type: "mcp_status"; servers: Record<string, McpServerStatus> }
|
||||
| { type: "resync" };
|
||||
|
||||
@@ -150,6 +151,8 @@ export interface AgentInfo {
|
||||
description: string;
|
||||
mode: "primary" | "subagent";
|
||||
read_only: boolean;
|
||||
/** "coding" or "chat" (plain conversation prompt). */
|
||||
style?: "coding" | "chat";
|
||||
model?: string | null;
|
||||
tools?: string[] | null;
|
||||
source: "builtin" | "config" | "file";
|
||||
|
||||
+16
-2
@@ -4,8 +4,22 @@
|
||||
*/
|
||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
unreleased: {
|
||||
en: ["File manager: “Open chat here” in a folder's menu"],
|
||||
de: ["Dateimanager: „Chat hier öffnen“ im Ordner-Menü"],
|
||||
en: [
|
||||
"Terminals and file managers as tabs in a chat – terminals keep running, also for another device",
|
||||
"New session → “Just chat” for plain conversation",
|
||||
"Own modes: Settings → Agents & commands → New mode",
|
||||
"The chat shows when the conversation is compacted (automatically or on request)",
|
||||
"File manager: “Open chat here” in a folder's menu",
|
||||
"Terminal client downloadable from the server again",
|
||||
],
|
||||
de: [
|
||||
"Terminals und Dateimanager als Tabs im Chat – Terminals laufen weiter, auch für ein anderes Gerät",
|
||||
"Neue Sitzung → „Einfach chatten“ für reine Unterhaltung",
|
||||
"Eigene Modi: Einstellungen → Agents & Befehle → Neuer Modus",
|
||||
"Der Chat zeigt an, wenn die Unterhaltung komprimiert wird (automatisch oder auf Wunsch)",
|
||||
"Dateimanager: „Chat hier öffnen“ im Ordner-Menü",
|
||||
"Terminal-Client wieder direkt vom Server herunterladbar",
|
||||
],
|
||||
},
|
||||
"202611-00002": {
|
||||
en: [
|
||||
|
||||
Reference in new issue
Block a user