feat: AI commit messages, change tree, router behavesAs, sidebar times (v2.2.0)

- Changes panel: Files and Changes merged into one panel; the overview's
  groups drawn as a folder tree (compacted chains, file counts, fold,
  stage/unstage a whole folder)
- AI-suggested commit message for what is staged
  (POST /api/lanes/:id/git/commit-message, claude -p --safe-mode, haiku)
- AI Router: optional "Behaves as" Claude model id, written as
  modelPicker options into the router's --settings file (issue #56)
- Sidebar: session rows show last activity, live rows their start time;
  a closed Terminal row can be removed (DELETE /api/run/history/:id)
- Every pane's CLI starts only once a client is attached; claude panes
  no longer forced inline; tmux scrollbar one column wide
- Update notifier: "Check for updates" reopens a closed notice
This commit is contained in:
2026-10-06 12:36:06 +07:00
parent 6c605849d8
commit 0468afb99c
41 changed files with 1201 additions and 249 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+9 -4
View File
@@ -29,8 +29,9 @@
* ## Dismissal persistence
* Dismissals are keyed in `localStorage` by the release tag when there is one,
* else the upstream `remote_sha`, so a newer release re-opens the prompt while
* a dismissed one stays dismissed. Settings can reset dismissal via the
* `dashboard:reset-update-dismissal` window event.
* a dismissed one stays dismissed. The connection modal's "Check for updates"
* fires `dashboard:reset-update-dismissal`, which clears both the skip and the
* page-load close; `z-[110]` keeps this modal above that `z-[100]` one.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -184,7 +185,11 @@ export function UpdateNotifier() {
}, [syncFromPayload]);
useEffect(() => {
const handler = () => setDismissedSha(null);
// An explicit "Check for updates" undoes both kinds of close.
const handler = () => {
setDismissedSha(null);
setClosedKey(null);
};
window.addEventListener("dashboard:reset-update-dismissal", handler);
return () => window.removeEventListener("dashboard:reset-update-dismissal", handler);
}, []);
@@ -313,7 +318,7 @@ export function UpdateNotifier() {
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
role="dialog"
aria-modal="true"
aria-labelledby="update-notifier-title"
@@ -238,6 +238,16 @@ describe("UpdateNotifier — closing vs skipping", () => {
expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBe("bbb");
});
it("Check for updates re-opens a modal closed with Later", async () => {
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:later")));
await waitFor(() =>
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
);
window.dispatchEvent(new Event("dashboard:reset-update-dismissal"));
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
});
it("ignores Escape while an update is applying", async () => {
applyMock.mockReturnValue(new Promise(() => {}));
render(<UpdateNotifier />);
@@ -82,6 +82,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
cwds: vi.fn(async () => ({ items: [] })),
kill: vi.fn(async () => ({ ok: true })),
history: vi.fn(async () => ({ items: [] })),
forgetHistory: vi.fn(async () => ({ removed: "" })),
},
aiTools: {
list: vi.fn(async () => ({ tools: [], default: "claude" })),
@@ -417,6 +418,30 @@ describe("WorkspaceTree", () => {
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-3");
});
it("removes a closed terminal from its hover X and its right-click menu", async () => {
const shell = (id: string) => ({
id,
tool: "shell",
status: "exited",
started_at: "2026-10-06T08:00:00Z",
ended_at: "2026-10-06T08:05:00Z",
isLive: false,
});
vi.mocked(api.run.history).mockImplementation(
async () => ({ items: [shell("t-a"), shell("t-b")] }) as never
);
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
await user.click(await screen.findByTestId("workspace-terminal-forget-t-a"));
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-a");
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-a")).toBeNull());
fireEvent.contextMenu(screen.getByTestId("workspace-terminal-closed-t-b"));
await user.click(screen.getByText("Remove from list"));
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-b");
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-b")).toBeNull());
});
it("selects by URL, and a click selects and expands", async () => {
const user = userEvent.setup();
renderTree("/workspace?lane=2");
+218 -47
View File
@@ -2,16 +2,16 @@
* @file The Changes panel: the lane's code beside its console, in the five
* shapes a review actually needs —
*
* overview — every changed file with its +/- counts and its stage/discard
* actions; click one to open it
* overview — every changed file, in a folder tree (JetBrains' commit view),
* with its +/- counts and its stage/discard actions; click one
* to open it
* review — every file's full diff, stacked, for reading a whole change-set
* file — one file, as a diff or as its whole content (issue #35); a
* whole markdown file can also be read rendered (Preview), and an
* image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree (the
* Workspace right panel's Files tab; the other four are its
* Changes tab)
* mode switcher's Files button; at full width the sidebar)
*
* and three ranges: `work` (uncommitted, what the agent has touched right
* now), `branch` (`base...HEAD`, what this lane adds on top of its base) and
@@ -65,6 +65,7 @@ import {
Plus,
RefreshCw,
Rows3,
Sparkles,
Trash2,
UnfoldVertical,
} from "lucide-react";
@@ -81,6 +82,7 @@ import { LaneConflictView } from "./LaneConflictView";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
import type {
LaneChangeFile,
LaneChangeRange,
@@ -421,8 +423,13 @@ function ChangeRow({
onArm,
onResolve,
onMenu,
name,
depth = 0,
}: {
f: LaneChangeFile;
/** In the folder tree: the file's own name, and how deep it sits. */
name?: string;
depth?: number;
range: LaneChangeRange;
onResolve: Resolve;
onMenu: (e: React.MouseEvent, f: LaneChangeFile) => void;
@@ -435,7 +442,11 @@ function ChangeRow({
}) {
const { t } = useTranslation(["lanes"]);
return (
<li className="group flex items-baseline gap-1" onContextMenu={(e) => onMenu(e, f)}>
<li
className="group flex items-baseline gap-1"
style={depth ? { paddingLeft: `${depth * 0.9}rem` } : undefined}
onContextMenu={(e) => onMenu(e, f)}
>
<button
type="button"
onClick={() => onOpen(f.path)}
@@ -453,7 +464,7 @@ function ChangeRow({
className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary"
title={f.path}
>
{f.path}
{name ?? f.path}
</span>
{f.conflicted && (
<span className="shrink-0 text-[10px] text-status-danger">{t("changes.conflicted")}</span>
@@ -470,6 +481,80 @@ function ChangeRow({
);
}
/** One folder of the overview's change tree: its sub-folders (each a
* collapsible row with its file count), then its files. */
function ChangeTreeNodes({
dir,
depth,
group,
collapsed,
onToggle,
renderFile,
folderAction,
}: {
dir: ChangeTreeDir<LaneChangeFile>;
depth: number;
/** The overview group, so the same folder folds apart in each. */
group: string;
collapsed: Set<string>;
onToggle: (key: string) => void;
renderFile: (f: LaneChangeFile, name: string, depth: number) => React.ReactNode;
/** The folder row's own action, e.g. stage every file in it. */
folderAction?: (files: LaneChangeFile[]) => React.ReactNode;
}) {
const { t } = useTranslation(["lanes"]);
return (
<>
{dir.dirs.map((d) => {
const key = `${group}:${d.path}`;
const open = !collapsed.has(key);
return (
<li key={d.path}>
<div
className="group flex items-center gap-1"
style={{ paddingLeft: `${depth * 0.9}rem` }}
>
<button
type="button"
aria-expanded={open}
onClick={() => onToggle(key)}
title={d.path}
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{open ? (
<ChevronDown className="h-3 w-3 shrink-0 text-fg-muted" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-fg-muted" />
)}
<Folder className="h-3 w-3 shrink-0 text-accent" />
<span className="min-w-0 truncate">{d.name}</span>
<span className="shrink-0 font-sans text-[10px] text-fg-muted">
{t("changes.fileCount", { count: d.count })}
</span>
</button>
{folderAction?.(treeFiles(d))}
</div>
{open && (
<ul className="space-y-0.5">
<ChangeTreeNodes
dir={d}
depth={depth + 1}
group={group}
collapsed={collapsed}
onToggle={onToggle}
renderFile={renderFile}
folderAction={folderAction}
/>
</ul>
)}
</li>
);
})}
{dir.files.map(({ name, file }) => renderFile(file, name, depth))}
</>
);
}
/** History's page size, and the most the server reads (`listCommits`). */
const HISTORY_PAGE = 50;
const HISTORY_MAX = 200;
@@ -479,7 +564,6 @@ export function LaneChangesPanel({
session,
onNewLane,
full = false,
tab = "changes",
onDirtyChange,
}: {
laneId: number;
@@ -489,10 +573,6 @@ export function LaneChangesPanel({
* instead of the workspace's folder. The Workspace keys the panel on it,
* so a different session is a fresh panel. */
session?: string;
/** The Workspace right panel's activity-bar tab: `files` is the browse mode
* on its own (no git header, no mode switcher), `changes` the other four.
* At full width both show the explorer sidebar beside the changes. */
tab?: "files" | "changes";
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
onNewLane?: (branch?: string) => void;
/** The panel has the whole width: the file tree becomes a sidebar and a
@@ -603,6 +683,15 @@ export function LaneChangesPanel({
// a file already read is in the way of the next one — so the set is what is
// COLLAPSED, and a file arriving later is open by default.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
// The overview tree's folded groups and folders (`<group>:<folder path>`);
// everything starts open.
const [treeCollapsed, setTreeCollapsed] = useState<Set<string>>(new Set());
const toggleTreeDir = (key: string) =>
setTreeCollapsed((prev) => {
const next = new Set(prev);
if (!next.delete(key)) next.add(key);
return next;
});
// Where opening a file came FROM: its mode and how far the list was scrolled.
// Reviewing a change-set is a walk down a long list, and landing back at the
// top of it after every file is the whole cost of opening one.
@@ -610,6 +699,7 @@ export function LaneChangesPanel({
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
const pendingScroll = useRef<number | null>(null);
const [message, setMessage] = useState("");
const [suggesting, setSuggesting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Every diff read gets a ticket; only the newest may land. A lane switch (or
@@ -687,8 +777,8 @@ export function LaneChangesPanel({
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
}, [mode, laneId, reread, historyLimit]);
// Re-run when a lane switch empties the cache too: the Files tab stays in
// `browse` across the switch, so the mode alone never changes.
// Re-run when a lane switch empties the cache too: the full-width sidebar
// stays across the switch, so the mode alone never changes.
const treeRootLoaded = "" in treeChildren;
useEffect(() => {
const wanted = full || (mode === "browse" && !query.trim());
@@ -814,17 +904,16 @@ export function LaneChangesPanel({
returnTo.current = null;
pendingScroll.current = null;
}, [laneId]);
// After the lane reset above: each tab opens on its own mode.
// After the lane reset above: a workspace opens on its changes.
// Leaving the open file this way (the Workspace asked first when it was
// edited) drops it whole: a draft left behind would keep `dirty` true.
useEffect(() => {
if (full) return;
setMode(tab === "files" ? "browse" : "overview");
setMode("overview");
setOpenFile(null);
setWhole(false);
setDraft(null);
}, [tab, laneId, full]);
const filesTab = tab === "files" && !full;
}, [laneId, full]);
// Restoring has to wait for the list to be TALL enough again: leaving a file
// re-fetches the mode it came from, so the first render back is an empty
@@ -1167,6 +1256,21 @@ export function LaneChangesPanel({
}
};
// ~10 s: the server runs `claude -p` on the staged diff. Its own flag, not
// `busy`, so staging stays usable while it thinks.
const suggestMessage = async () => {
const lane = laneId;
setSuggesting(true);
try {
const { message: suggested } = await api.lanes.suggestCommitMessage(target);
if (laneNow.current === lane) setMessage(suggested);
} catch (err) {
if (laneNow.current === lane) toast.error(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setSuggesting(false);
}
};
const rereadAll = () => {
refreshGit();
// The tree cache is dropped whole: after a switch any folder may differ.
@@ -1219,7 +1323,7 @@ export function LaneChangesPanel({
};
// The Files mode's body. At full width it lives in the sidebar instead, so
// the Files tab is gone there and the main area never shows it twice.
// the Files button is gone there and the main area never shows it twice.
const browsePane = (
<div data-testid="lane-changes-browse">
{/* Pinned: the finder is what you reach for after scrolling a
@@ -1303,15 +1407,13 @@ export function LaneChangesPanel({
</aside>
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{!filesTab && (
<LaneGitHeader
laneId={laneId}
session={session}
git={git}
onChanged={rereadAll}
onNewLane={onNewLane}
/>
)}
<LaneGitHeader
laneId={laneId}
session={session}
git={git}
onChanged={rereadAll}
onNewLane={onNewLane}
/>
{/* ONE wrapping row: where you are (mode) then how you are looking at it
(range, layout, context, whole file), refresh last. Each group is
its own inline box, so a narrow panel wraps BETWEEN groups — never
@@ -1327,9 +1429,14 @@ export function LaneChangesPanel({
<ChevronLeft className="h-3 w-3" />
{t("changes.back")}
</button>
) : filesTab ? null : (
) : (
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
{(["overview", "review", "history"] as const).map((m) => (
{/* Files is the whole repo's tree; at full width it is the
sidebar already, so no button. */}
{(full
? (["overview", "review", "history"] as const)
: (["overview", "browse", "review", "history"] as const)
).map((m) => (
<button
key={m}
type="button"
@@ -1595,7 +1702,22 @@ export function LaneChangesPanel({
/>
<button
type="button"
disabled={busy || !message.trim()}
data-testid="lane-changes-suggest-message"
disabled={suggesting}
onClick={() => void suggestMessage()}
title={t("changes.suggestMessage")}
aria-label={t("changes.suggestMessage")}
className="shrink-0 flex items-center rounded border border-border-light px-1 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
{suggesting ? (
<RefreshCw className="h-3 w-3 animate-spin" aria-hidden />
) : (
<Sparkles className="h-3 w-3" aria-hidden />
)}
</button>
<button
type="button"
disabled={busy || suggesting || !message.trim()}
onClick={() => void commitStaged()}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
@@ -1668,29 +1790,78 @@ export function LaneChangesPanel({
data-testid={g.key === "conflicts" ? "lane-changes-conflicts" : undefined}
>
{g.key !== "all" && (
<div className="flex items-center gap-1.5 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-fg-secondary">
<button
type="button"
aria-expanded={!treeCollapsed.has(`${g.key}:`)}
onClick={() => toggleTreeDir(`${g.key}:`)}
className="flex w-full items-center gap-1.5 px-1 py-0.5 text-left text-[10px] font-semibold uppercase tracking-wide text-fg-secondary hover:text-fg-primary"
>
{treeCollapsed.has(`${g.key}:`) ? (
<ChevronRight className="h-3 w-3 shrink-0" />
) : (
<ChevronDown className="h-3 w-3 shrink-0" />
)}
{t(`changes.${g.key}Group`)}
<span className="rounded-full bg-surface-4 px-1.5 font-mono text-fg-primary">
{g.rows.length}
</span>
</div>
</button>
)}
<ul data-group={g.key} className="space-y-0.5">
{g.rows.map((f) => (
<ChangeRow
key={f.path}
f={f}
range={range}
busy={busy}
armed={confirmDiscard === f.path}
onOpen={show}
onAct={(...a) => void act(...a)}
onArm={setConfirmDiscard}
onResolve={resolve}
onMenu={fileCtx.open}
{!treeCollapsed.has(`${g.key}:`) && (
<ul data-group={g.key} className="space-y-0.5">
{/* Folders like JetBrains' commit view: a long
change-set reads by area, not as one flat list. */}
<ChangeTreeNodes
dir={changeTree(g.rows)}
depth={g.key === "all" ? 0 : 1}
group={g.key}
collapsed={treeCollapsed}
onToggle={toggleTreeDir}
renderFile={(f, name, depth) => (
<ChangeRow
key={f.path}
f={f}
name={name}
depth={depth}
range={range}
busy={busy}
armed={confirmDiscard === f.path}
onOpen={show}
onAct={(...a) => void act(...a)}
onArm={setConfirmDiscard}
onResolve={resolve}
onMenu={fileCtx.open}
/>
)}
folderAction={
g.key === "staged" || g.key === "unstaged"
? (rows) => (
<IconButton
label={t(
g.key === "staged"
? "changes.unstageFolder"
: "changes.stageFolder"
)}
disabled={busy}
onClick={() =>
void act(
g.key === "staged" ? "unstage" : "stage",
rows.map((f) => f.path)
)
}
>
{g.key === "staged" ? (
<Minus className="h-3 w-3" />
) : (
<Plus className="h-3 w-3" />
)}
</IconButton>
)
: undefined
}
/>
))}
</ul>
</ul>
)}
</div>
)
)}
@@ -14,7 +14,8 @@
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
* which never has a session) as **Terminal** — and under the sessions the last
* `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose
* click opens a NEW shell tab (a shell cannot be resumed); Ctrl/Cmd+click opens
* click opens a NEW shell tab (a shell cannot be resumed) and whose hover X or
* right-click menu forgets the row (`DELETE /api/run/history/:id`); Ctrl/Cmd+click opens
* it in a split beside the focused pane) — attaching, never starting or
* resuming anything;
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
@@ -31,7 +32,7 @@
import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
import { Archive, GitBranch, SquareTerminal, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { api, type RunHandle, type RunHistoryItem } from "../../lib/api";
@@ -42,7 +43,7 @@ import { timeAgo } from "../../lib/format";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { useAiTools } from "../../hooks/useAiTools";
import { useContextMenu } from "../ContextMenu";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { SessionMenu } from "./SessionMenu";
export const SESSION_LIMIT = 6;
@@ -297,6 +298,15 @@ export function WorkspaceSessionList({
toast.error(err);
}
};
const closedCtx = useContextMenu<RunHistoryItem>();
const forgetShell = async (r: RunHistoryItem) => {
try {
await api.run.forgetHistory(r.id);
setClosedShells((all) => all.filter((x) => x.id !== r.id));
} catch (err) {
toast.error(err);
}
};
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
const sessions = own?.sessions ?? (own === null ? null : []);
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
@@ -458,19 +468,19 @@ export function WorkspaceSessionList({
>
<span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
<span className="sr-only">{t("tree.state.live")}</span>
<span className={`truncate ${r.tool === "shell" ? "" : "italic"}`}>
{r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}
</span>
<span className="ml-auto shrink-0 text-[11px] text-fg-muted">
{r.startedAt ? timeAgo(r.startedAt) : null}
</span>
{r.tool === "shell" ? (
<>
<span className="truncate">{t("tabs.terminal")}</span>
<SquareTerminal aria-hidden className="ml-auto h-3.5 w-3.5 shrink-0 opacity-70" />
</>
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
) : (
<>
<span className="truncate italic">{t("tree.newSession")}</span>
<AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/>
</>
<AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }}
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/>
)}
</button>
</li>
@@ -554,9 +564,12 @@ export function WorkspaceSessionList({
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
<span className="ml-auto shrink-0 text-[11px] text-fg-muted">
{timeAgo(s.last_activity ?? s.started_at)}
</span>
<AiToolIcon
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70 group-hover:invisible"
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70 group-hover:invisible"
/>
</button>
<button
@@ -575,11 +588,12 @@ export function WorkspaceSessionList({
);
})}
{closed.map((r) => (
<li key={r.id}>
<li key={r.id} className="group relative">
<button
type="button"
data-testid={`workspace-terminal-closed-${r.id}`}
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
onContextMenu={(e) => closedCtx.open(e, r)}
title={t("tree.closedTerminal")}
className="flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
@@ -592,7 +606,20 @@ export function WorkspaceSessionList({
<span className="ml-auto shrink-0 text-[11px]">
{timeAgo(r.ended_at ?? r.started_at)}
</span>
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
<SquareTerminal
aria-hidden
className="h-3.5 w-3.5 shrink-0 opacity-70 group-hover:invisible"
/>
</button>
<button
type="button"
data-testid={`workspace-terminal-forget-${r.id}`}
onClick={() => void forgetShell(r)}
title={t("tree.forgetTerminal")}
aria-label={t("tree.forgetTerminal")}
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
>
<X className="h-3.5 w-3.5" />
</button>
</li>
))}
@@ -619,6 +646,42 @@ export function WorkspaceSessionList({
onClose={ctx.close}
/>
)}
{closedCtx.menu && (
<ContextMenu
x={closedCtx.menu.x}
y={closedCtx.menu.y}
onClose={closedCtx.close}
testId={`terminal-menu-${closedCtx.menu.item.id}`}
>
<MenuItem
onSelect={() => {
closedCtx.close();
void reopenShell(false);
}}
>
{t("tree.reopenTerminal")}
</MenuItem>
<MenuItem
onSelect={() => {
closedCtx.close();
void reopenShell(true);
}}
>
{t("sessionMenu.openSplit")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
onSelect={() => {
const item = closedCtx.menu!.item;
closedCtx.close();
void forgetShell(item);
}}
>
{t("tree.forgetTerminal")}
</MenuItem>
</ContextMenu>
)}
{sessionCtx.menu && (
<SessionMenu
x={sessionCtx.menu.x}
@@ -13,7 +13,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { LaneChangesPanel } from "../LaneChangesPanel";
import { confirmDiscard } from "../../../lib/confirm";
import { api } from "../../../lib/api";
@@ -32,6 +31,7 @@ vi.mock("../../../lib/api", () => ({
gitBranches: vi.fn(),
changes: vi.fn(),
gitAction: vi.fn(),
suggestCommitMessage: vi.fn(),
gitLog: vi.fn(),
gitFile: vi.fn(),
gitRawUrl: (id: number, path: string, rev: string) =>
@@ -92,7 +92,7 @@ function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}):
/** The overview row's discard button for one path. */
function discardOf(path: string) {
return within(screen.getByText(path).closest("li")!).getByRole("button", {
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
name: "Discard this file's changes",
});
}
@@ -140,8 +140,8 @@ beforeEach(() => {
});
});
/** The Workspace right panel's Files/Changes activity-bar buttons, around the
* panel — the tab is the Workspace's state, passed in as `tab`. */
/** The panel as the Workspace renders it: Files and Changes are its own mode
* buttons now, no activity-bar tab around it. */
function Tabbed({
laneId,
onDirtyChange,
@@ -149,18 +149,7 @@ function Tabbed({
laneId: number;
onDirtyChange?: (dirty: boolean) => void;
}) {
const [tab, setTab] = useState<"files" | "changes">("changes");
return (
<>
<button type="button" onClick={() => setTab("files")}>
Files
</button>
<button type="button" onClick={() => setTab("changes")}>
Changes
</button>
<LaneChangesPanel laneId={laneId} tab={tab} onDirtyChange={onDirtyChange} />
</>
);
return <LaneChangesPanel laneId={laneId} onDirtyChange={onDirtyChange} />;
}
describe("LaneChangesPanel", () => {
@@ -169,7 +158,7 @@ describe("LaneChangesPanel", () => {
expect(await screen.findByTestId("lane-changes-overview")).toBeInTheDocument();
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work" });
expect(screen.getByText("server/lib/git-ops.js")).toBeInTheDocument();
expect(screen.getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
expect(screen.getByText("+12")).toBeInTheDocument();
expect(screen.queryByText(/new line/)).toBeNull();
});
@@ -194,7 +183,7 @@ describe("LaneChangesPanel", () => {
],
})
);
await user.click(screen.getByText("server/lib/git-ops.js"));
await user.click(screen.getByTitle("server/lib/git-ops.js"));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
@@ -258,6 +247,26 @@ describe("LaneChangesPanel", () => {
});
});
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
// `server/lib` holds nothing but `lib`: one compacted row.
const folder = within(list).getByTitle("server/lib");
expect(folder).toHaveTextContent("1 file");
expect(within(list).getByTitle("server/lib/git-ops.js")).toHaveTextContent("git-ops.js");
await user.click(folder);
expect(within(list).queryByTitle("server/lib/git-ops.js")).toBeNull();
await user.click(
within(folder.parentElement!).getByRole("button", {
name: "Stage every file in this folder",
})
);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
});
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
vi.mocked(api.lanes.git).mockResolvedValue({
available: true,
@@ -341,16 +350,26 @@ describe("LaneChangesPanel", () => {
});
});
it("fills the commit box with the AI-suggested message", async () => {
vi.mocked(api.lanes.suggestCommitMessage).mockResolvedValue({ message: "docs: update API" });
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByTestId("lane-changes-suggest-message"));
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7);
expect(await screen.findByDisplayValue("docs: update API")).toBeInTheDocument();
});
it("lists staged files apart from the changes the next commit leaves behind", async () => {
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
expect(within(staged).getByText("docs/API.md")).toBeInTheDocument();
expect(within(staged).getByTitle("docs/API.md")).toBeInTheDocument();
expect(within(staged).queryByText("notes.txt")).toBeNull();
expect(within(unstaged).getByText("server/lib/git-ops.js")).toBeInTheDocument();
expect(within(unstaged).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
expect(within(unstaged).queryByText("docs/API.md")).toBeNull();
expect(within(unstaged).queryByTitle("docs/API.md")).toBeNull();
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
});
@@ -468,7 +487,7 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByText("server/lib/git-ops.js"));
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
@@ -486,7 +505,7 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByText("server/lib/git-ops.js"));
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
const view = await screen.findByTestId("lane-file-view");
const spy = vi.spyOn(window, "getSelection").mockReturnValue({
toString: () => "copy",
@@ -517,7 +536,7 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const row = within(list).getByText("server/lib/git-ops.js");
const row = within(list).getByTitle("server/lib/git-ops.js");
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
await right();
@@ -577,9 +596,9 @@ describe("LaneChangesPanel", () => {
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
// A plain refresh (the poll's twin), not a git action — an action already disarms.
await user.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(screen.queryByText("server/lib/git-ops.js")).toBeNull());
await waitFor(() => expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull());
await user.click(screen.getByRole("button", { name: "Refresh" }));
await screen.findByText("server/lib/git-ops.js");
await screen.findByTitle("server/lib/git-ops.js");
expect(
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
).toBeNull();
@@ -612,7 +631,7 @@ describe("LaneChangesPanel", () => {
resolveOld(changes());
await new Promise((r) => setTimeout(r, 20));
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
});
it("opens a commit from history as a commit-range review", async () => {
@@ -784,33 +803,6 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
// The Workspace asked before switching tab; the edit must then be GONE, or
// `dirty` stays true and keeps the panel pinned and the unload prompt on.
it("drops an edited file when the tab switches away from it", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 1;"],
sha: "b".repeat(40),
});
const dirty = vi.fn();
const user = userEvent.setup();
render(<Tabbed laneId={7} onDirtyChange={dirty} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await user.click(await screen.findByTestId("lane-file-view"));
await user.type(await screen.findByTestId("lane-file-editor"), "x");
expect(dirty).toHaveBeenLastCalledWith(true);
await user.click(screen.getByRole("button", { name: "Changes" }));
await waitFor(() => expect(dirty).toHaveBeenLastCalledWith(false));
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
@@ -892,7 +884,7 @@ describe("LaneChangesPanel", () => {
],
})
);
await user.click(screen.getByText("server/lib/git-ops.js"));
await user.click(screen.getByTitle("server/lib/git-ops.js"));
expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
await user.click(screen.getByRole("button", { name: "Side-by-side" }));
@@ -903,7 +895,7 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByText("server/lib/git-ops.js"));
await user.click(screen.getByTitle("server/lib/git-ops.js"));
await screen.findByRole("button", { name: "Show more surrounding lines" });
// Collapse is dead at the tightest step, and live once expanded — an
@@ -933,7 +925,7 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByText("server/lib/git-ops.js"));
await user.click(screen.getByTitle("server/lib/git-ops.js"));
await screen.findByText(/server\/lib\/git-ops\.js/);
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
@@ -995,11 +987,10 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
});
it("leaves Files out of the mode row: it is the right panel's own tab", async () => {
it("puts Files in the mode row beside Changes: one panel, no separate tab", async () => {
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
expect(screen.queryByRole("button", { name: "Files" })).toBeNull();
for (const name of ["Overview", "Review", "History"]) {
for (const name of ["Changes", "Files", "Review", "History"]) {
expect(screen.getByRole("button", { name })).toBeInTheDocument();
}
});
@@ -1351,9 +1342,9 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit");
const before = screen.getByText("server/lib/git-ops.js");
const before = screen.getByTitle("server/lib/git-ops.js");
await user.type(screen.getByLabelText("Commit message"), "x");
expect(screen.getByText("server/lib/git-ops.js")).toBe(before);
expect(screen.getByTitle("server/lib/git-ops.js")).toBe(before);
});
it("expands all folders with ONE request, not one per folder", async () => {
@@ -1418,7 +1409,7 @@ describe("LaneChangesPanel", () => {
lane7Reads
);
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
});
it("does not paint the previous lane's tree when an expand-all read outlives a lane switch", async () => {
+8 -2
View File
@@ -59,6 +59,7 @@
"changes.collapseFile": "Collapse {{path}}",
"changes.commitPlaceholder": "Commit message",
"changes.commitStaged": "Commit {{count}} staged",
"changes.suggestMessage": "Suggest a commit message with AI (Claude, staged changes)",
"changes.conflicted": "conflict",
"changes.discard": "Discard this file's changes",
"changes.discardConfirm": "Click again to discard — this cannot be undone",
@@ -82,7 +83,7 @@
"changes.loading": "Reading the working copy…",
"changes.mode.browse": "Files",
"changes.mode.history": "History",
"changes.mode.overview": "Overview",
"changes.mode.overview": "Changes",
"changes.mode.review": "Review",
"changes.noCommits": "No commits yet.",
"changes.noHunks": "No textual change.",
@@ -98,6 +99,10 @@
"changes.size.compact": "Narrow",
"changes.size.half": "Medium",
"changes.size.full": "Full screen",
"changes.fileCount_one": "{{count}} file",
"changes.fileCount_other": "{{count}} files",
"changes.stageFolder": "Stage every file in this folder",
"changes.unstageFolder": "Unstage every file in this folder",
"changes.stageAll": "Stage all ({{count}})",
"changes.unstageAll": "Unstage all ({{count}})",
"changes.dropEdits": "Discard your unsaved edits to this file?",
@@ -299,7 +304,6 @@
"menu.copyPath": "Copy path",
"menu.reset": "Reset worktree…",
"menu.remove": "Delete workspace…",
"panel.files": "Files",
"panel.changes": "Changes",
"panel.analyst": "Analyst",
"replay.placeholder": "Message to continue this session…",
@@ -323,6 +327,8 @@
"tree.newSession": "New session",
"tree.noSessions": "No sessions yet",
"tree.closedTerminal": "Closed terminal — click opens a new one in this workspace's folder (Ctrl/Cmd+click: in a split)",
"tree.reopenTerminal": "Open a new terminal",
"tree.forgetTerminal": "Remove from list",
"tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
"tree.renameSession": "Rename session",
+2
View File
@@ -587,6 +587,8 @@
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
"fieldModel": "Model",
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.",
"fieldBehavesAs": "Behaves as (Claude Code)",
"fieldBehavesAsHint": "Optional. The Claude model id Claude Code should treat this router's model names as (effort defaults, context window), e.g. claude-opus-5-5. Fill it in when Claude Code says a model isn't described by its model catalog, and name the model the router really sends to. While set, it replaces your own modelPicker setting in these sessions.",
"perTier": "Per-tier overrides",
"perTierHint": "Leave a field blank to use the model above.",
"tier": {
+8 -2
View File
@@ -59,6 +59,7 @@
"changes.collapseFile": "Thu gọn {{path}}",
"changes.commitPlaceholder": "Nội dung commit",
"changes.commitStaged": "Commit {{count}} file đã stage",
"changes.suggestMessage": "Gợi ý nội dung commit bằng AI (Claude, các thay đổi đã stage)",
"changes.conflicted": "xung đột",
"changes.discard": "Bỏ thay đổi của file này",
"changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác",
@@ -82,7 +83,7 @@
"changes.loading": "Đang đọc working copy…",
"changes.mode.browse": "Tệp",
"changes.mode.history": "Lịch sử",
"changes.mode.overview": "Tổng quan",
"changes.mode.overview": "Thay đổi",
"changes.mode.review": "Xem toàn bộ",
"changes.noCommits": "Chưa có commit nào.",
"changes.noHunks": "Không có thay đổi nội dung.",
@@ -98,6 +99,10 @@
"changes.size.compact": "Hẹp",
"changes.size.half": "Vừa",
"changes.size.full": "Toàn màn hình",
"changes.fileCount_one": "{{count}} tệp",
"changes.fileCount_other": "{{count}} tệp",
"changes.stageFolder": "Stage mọi tệp trong thư mục này",
"changes.unstageFolder": "Bỏ stage mọi tệp trong thư mục này",
"changes.stageAll": "Stage tất cả ({{count}})",
"changes.unstageAll": "Bỏ stage tất cả ({{count}})",
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
@@ -299,7 +304,6 @@
"menu.copyPath": "Sao chép đường dẫn",
"menu.reset": "Đặt lại worktree…",
"menu.remove": "Xóa workspace…",
"panel.files": "Tệp",
"panel.changes": "Thay đổi",
"panel.analyst": "Analyst",
"replay.placeholder": "Nhắn để tiếp tục session này…",
@@ -323,6 +327,8 @@
"tree.newSession": "Session mới",
"tree.noSessions": "Chưa có session",
"tree.closedTerminal": "Terminal đã đóng — bấm để mở terminal mới ở thư mục của workspace (Ctrl/Cmd+click: mở ở split)",
"tree.reopenTerminal": "Mở terminal mới",
"tree.forgetTerminal": "Xóa khỏi danh sách",
"tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
"tree.renameSession": "Đổi tên session",
+2
View File
@@ -587,6 +587,8 @@
"fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.",
"fieldModel": "Model",
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều router chỉ cần một tên tượng trưng.",
"fieldBehavesAs": "Hoạt động như (Claude Code)",
"fieldBehavesAsHint": "Không bắt buộc. Model id của Claude mà Claude Code dùng để hiểu các tên model của router này (mặc định effort, context window), ví dụ claude-opus-5-5. Điền khi Claude Code báo một model không có trong model catalog của nó, và chọn đúng model router thật sự chuyển tới. Khi đã đặt, nó thay thiết lập modelPicker của bạn trong các phiên này.",
"perTier": "Ghi đè theo từng bậc",
"perTierHint": "Để trống một ô là dùng model ở trên.",
"tier": {
@@ -0,0 +1,42 @@
/**
* @file Tests for the Changes panel's folder tree: grouping by folder,
* compacting single-child chains, counts, ordering and the per-folder file list.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { changeTree, treeFiles } from "../changeTree";
const rows = [
"app2/Services/TwitterService.php",
"app2/Services/Redis/SlowAlertRedisService.php",
"app2/Enums/Redis/RedisSlowAlertKey.php",
"config/monitoring.php",
"config/app.php",
"README.md",
].map((path) => ({ path }));
describe("changeTree", () => {
const root = changeTree(rows);
it("puts root files at the top level and counts every file", () => {
expect(root.count).toBe(6);
expect(root.files.map((f) => f.name)).toEqual(["README.md"]);
expect(root.dirs.map((d) => d.name)).toEqual(["app2", "config"]);
});
it("compacts a folder chain with nothing else in it", () => {
const app2 = root.dirs[0]!;
expect(app2.count).toBe(3);
expect(app2.dirs.map((d) => [d.name, d.path])).toEqual([
["Enums/Redis", "app2/Enums/Redis"],
["Services", "app2/Services"],
]);
// Services holds a file, so it is not folded into Redis.
expect(app2.dirs[1]!.dirs[0]!.name).toBe("Redis");
});
it("sorts files by name and lists a folder's files", () => {
expect(root.dirs[1]!.files.map((f) => f.name)).toEqual(["app.php", "monitoring.php"]);
expect(treeFiles(root.dirs[0]!).map((f) => f.path)).toHaveLength(3);
});
});
+13
View File
@@ -2186,6 +2186,9 @@ export const api = {
if (opts.limit) qs.set("limit", String(opts.limit));
return request<{ items: RunHistoryItem[] }>(`/run/history?${qs.toString()}`);
},
/** DELETE /api/run/history/:id - forget a closed Terminal tab (shell rows only). */
forgetHistory: (id: string) =>
request<{ removed: string }>(`/run/history/${encodeURIComponent(id)}`, { method: "DELETE" }),
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
binary: (tool: LaneTool = "claude") =>
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
@@ -2757,6 +2760,12 @@ export const api = {
current: string | null;
worktrees: LaneWorktree[];
}>(gitUrl(id, "/branches")),
/**
* POST /api/lanes/:id/git/commit-message — a message for what is staged,
* written by the user's `claude` CLI (no tools, nothing persisted).
*/
suggestCommitMessage: (id: GitTarget) =>
request<{ message: string }>(gitUrl(id, "/commit-message"), { method: "POST" }),
/**
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
* @param id The lane id.
@@ -3286,6 +3295,8 @@ export interface AiRouter {
hasKey: boolean;
model: string;
models: AiRouterModels;
/** Claude model id the router's own model names behave as; "" when unset. */
behavesAs?: string;
savedAt?: string;
}
@@ -3318,6 +3329,8 @@ export interface AiRouterInput {
model: string;
/** Any tier left out is filled from `model`. */
models?: Partial<AiRouterModels>;
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
behavesAs?: string;
}
/** One markdown-based config artifact (skill/agent/command/output-style),
+51
View File
@@ -0,0 +1,51 @@
/**
* @file Folds the Changes panel's flat list of changed files into a folder
* tree, JetBrains' commit-view shape: folders first, then files, each sorted
* by name, and a chain of folders that hold nothing but one sub-folder drawn
* as ONE row (`src/lib`), so a deep path does not cost a row per level.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export interface ChangeTreeDir<T> {
/** The row's label — one segment, or several joined by `/` when compacted. */
name: string;
/** The folder's repo-relative path ("" for the root). */
path: string;
dirs: ChangeTreeDir<T>[];
files: { name: string; file: T }[];
/** Every file anywhere below. */
count: number;
}
export function changeTree<T extends { path: string }>(rows: T[]): ChangeTreeDir<T> {
const root: ChangeTreeDir<T> = { name: "", path: "", dirs: [], files: [], count: 0 };
for (const file of rows) {
const parts = file.path.split("/");
let dir = root;
dir.count++;
for (const seg of parts.slice(0, -1)) {
const path = dir.path ? `${dir.path}/${seg}` : seg;
let next = dir.dirs.find((d) => d.path === path);
if (!next) dir.dirs.push((next = { name: seg, path, dirs: [], files: [], count: 0 }));
next.count++;
dir = next;
}
dir.files.push({ name: parts[parts.length - 1] ?? file.path, file });
}
const finish = (d: ChangeTreeDir<T>): ChangeTreeDir<T> => {
// Never the root: its children are the group's top level.
let only: ChangeTreeDir<T> | undefined;
while (d.path && d.files.length === 0 && d.dirs.length === 1 && (only = d.dirs[0])) {
d = { ...only, name: `${d.name}/${only.name}` };
}
d.dirs = d.dirs.map(finish).sort((a, b) => a.name.localeCompare(b.name));
d.files.sort((a, b) => a.name.localeCompare(b.name));
return d;
};
return finish(root);
}
/** Every file under a folder, for its stage/unstage-all button. */
export function treeFiles<T>(d: ChangeTreeDir<T>): T[] {
return [...d.files.map((f) => f.file), ...d.dirs.flatMap(treeFiles)];
}
+14 -1
View File
@@ -39,8 +39,9 @@ import { useDialog } from "../hooks/useDialog";
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
type FormState = Omit<AiRouterInput, "apiKey" | "models"> & {
type FormState = Omit<AiRouterInput, "apiKey" | "models" | "behavesAs"> & {
apiKey: string;
behavesAs: string;
models: Partial<AiRouterModels>;
};
@@ -50,6 +51,7 @@ const emptyForm = (): FormState => ({
apiKey: "",
model: "",
models: {},
behavesAs: "",
});
export function AiRouterConfig() {
@@ -121,6 +123,7 @@ export function AiRouterConfig() {
apiKey: "",
model: item.model,
models: { ...item.models },
behavesAs: item.behavesAs ?? "",
});
setFormError(null);
setOpen(true);
@@ -175,6 +178,7 @@ export function AiRouterConfig() {
baseUrl: form.baseUrl.trim(),
model: form.model.trim(),
models,
behavesAs: form.behavesAs.trim(),
};
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
@@ -370,6 +374,15 @@ export function AiRouterConfig() {
mono
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
/>
<Field
id="router-behaves-as"
label={t("aiRouter.fieldBehavesAs")}
hint={t("aiRouter.fieldBehavesAsHint")}
value={form.behavesAs}
mono
placeholder="claude-opus-5-5"
onChange={(v) => setForm((f) => ({ ...f, behavesAs: v }))}
/>
<details className="rounded-lg border border-border bg-surface-2 p-2">
<summary className="cursor-pointer text-xs text-fg-secondary">
+7 -52
View File
@@ -61,8 +61,6 @@ import {
PanelLeftClose,
PanelRightClose,
Maximize2,
FolderTree,
GitCompare,
ChartColumn,
} from "lucide-react";
import { api } from "../lib/api";
@@ -120,17 +118,9 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
/** localStorage key for how wide the Changes panel is. */
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
/** localStorage key for the right panel's activity-bar tab. */
const PANEL_TAB_KEY = "agenthub.workspace.panelTab";
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
const SELECTED_KEY = "agenthub.workspace.selected";
const PANEL_TABS = [
{ id: "files", Icon: FolderTree },
{ id: "changes", Icon: GitCompare },
] as const;
type PanelTab = (typeof PANEL_TABS)[number]["id"];
/** The three widths the Changes panel cycles through, then closed. Reading a
* diff and watching a console want opposite amounts of room, and which one is
* "work" changes several times an hour — hence ONE control that steps
@@ -600,21 +590,6 @@ export function Workspace() {
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
const [panelTab, setPanelTab] = useState<PanelTab>(() => {
try {
const saved = window.localStorage.getItem(PANEL_TAB_KEY) as PanelTab | null;
return saved && PANEL_TABS.some((x) => x.id === saved) ? saved : "changes";
} catch {
return "changes";
}
});
useEffect(() => {
try {
window.localStorage.setItem(PANEL_TAB_KEY, panelTab);
} catch {
/* the panel just forgets its tab between visits */
}
}, [panelTab]);
// Whether the Changes panel is open. A viewer preference, so it lives in
// localStorage and never reaches the server — same rule as the lane strip's
// order (client/src/lib/laneOrderStorage.ts).
@@ -1231,36 +1206,17 @@ export function Workspace() {
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
>
{/* Activity bar: Files · Changes · Sessions, then the resize
control — the same height as the console's tab bar, so the
two read as one header strip. */}
{/* Header: the title, Analyst, then the resize control — the
same height as the console's tab bar, so the two read as
one header strip. Files and Changes are ONE panel now; its
own mode switcher moves between them. */}
<div
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
role="toolbar"
>
{PANEL_TABS.map(({ id, Icon }) => (
<button
key={id}
type="button"
data-testid={`panel-tab-${id}`}
aria-pressed={panelTab === id}
// Narrow, a tab switch leaves the open file (and its
// edit); at full width the editor stays put.
onClick={() =>
panelTab !== id &&
(changesFull ? setPanelTab(id) : afterDrop(() => setPanelTab(id)))
}
title={tLanes(`panel.${id}`)}
aria-label={tLanes(`panel.${id}`)}
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
panelTab === id
? "bg-accent/15 text-accent"
: "text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
}`}
>
<Icon className="h-4 w-4" />
</button>
))}
<span className="px-1 text-xs font-medium text-fg-secondary">
{tLanes("panel.changes")}
</span>
{/* The opened session's analysis page — a page of its own. */}
<button
type="button"
@@ -1318,7 +1274,6 @@ export function Workspace() {
key={panelKey}
laneId={panelAt.laneId}
session={panelAt.session}
tab={panelTab}
full={changesFull}
onDirtyChange={setChangesDirty}
// A sibling worktree forks from the lane's SOURCE repo when
@@ -104,11 +104,30 @@ describe("AiRouterConfig", () => {
// Every tier equals the headline model, so none is sent: the server
// fills them, which is what makes one field enough.
models: {},
behavesAs: "",
apiKey: "sk-typed",
})
);
});
it("sends the typed behavesAs, which maps the router's model names in Claude Code (#56)", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "agent");
await user.type(screen.getByLabelText("aiRouter.fieldBehavesAs"), "claude-opus-5-5");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() =>
expect(api.aiRouters.create).toHaveBeenCalledWith(
expect.objectContaining({ model: "agent", behavesAs: "claude-opus-5-5" })
)
);
});
it("sends only the tiers that differ from the headline model", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
@@ -6981,7 +6981,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v2.1.0 (rebuild client to match)
UI build v2.2.0 (rebuild client to match)
</p>
</div>
<div
+21 -3
View File
@@ -614,7 +614,7 @@ answer `{available:false}` with HTTP **200** for a cwd git cannot read.
ignore (`ls-files --cached --others --exclude-standard`), directories folded
and listed first. A changed file carries its porcelain `status` (` M`, `??`,
`A `, ` D`…), a folder with anything changed under it `changed: true`. `400 EBADPATH` for a path that escapes the workspace.
- **`/git/tree-all`** → `{available, levels: {"<dir>": [{name, path, dir}]}, truncated}` — every level of the same tree (entries decorated the same way) from ONE `git ls-files`, each level shaped and capped like `/git/tree`'s; the Files tab's **Expand all folders** is this one request instead of one per folder.
- **`/git/tree-all`** → `{available, levels: {"<dir>": [{name, path, dir}]}, truncated}` — every level of the same tree (entries decorated the same way) from ONE `git ls-files`, each level shaped and capped like `/git/tree`'s; the Changes panel's Files mode's **Expand all folders** is this one request instead of one per folder.
#### Read a workspace's own branches
@@ -655,6 +655,22 @@ reason is gone) is cleared first. A live pane still holding the session is
`409 ELIVE`; no `confirm` is `400 ECONFIRM`; a session with no worktree here
`400 ENOWORKTREE`. Answers `{removed: true, branch, branchKept}`.
#### Suggest a commit message
```http
POST /api/lanes/:id/git/commit-message
```
The Changes panel's ✨ button beside the commit box. Runs the user's own
`claude` CLI — `claude -p --safe-mode --no-session-persistence --tools "" --model haiku`
— on the **staged** diff of the workspace's cwd (or `?session=` worktree), sent on
stdin together with the last ten commit subjects so the message follows the
repo's style. Only reads: no tool, hook or plugin runs, no transcript is saved,
and a `settings.local.json` is never part of the diff. Same-origin, because
each call spends the user's Claude quota. Answers `{"message": "..."}`;
`400 ENOSTAGED` with nothing staged, `400 ENOTOOL` without `claude` on PATH,
`502 EAI` when the CLI fails, answers empty or runs past 120 s.
#### Act on a workspace's working copy
```http
@@ -2369,7 +2385,7 @@ Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source:
```
GET /api/ai-routers Every saved profile, the built-in `native` one first
POST /api/ai-routers Create — Body: { label, baseUrl, apiKey?, model, models? }
POST /api/ai-routers Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs? }
POST /api/ai-routers/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
PATCH /api/ai-routers/:name Update the same fields, all optional
DELETE /api/ai-routers/:name Delete one saved profile
@@ -2379,6 +2395,8 @@ A **router profile** is an endpoint that stands in front of Claude Code or Codex
`models` carries the five per-tier names (`opus`, `sonnet`, `haiku`, `smallFast`, `subagent`). Any tier omitted is filled from `model`, so a router that answers to one symbolic name needs one field, not six.
`behavesAs` (optional, `""` clears it, omitted on `PATCH` keeps it) is a Claude model id (`claude-…`, else `400 EBADMODEL`) that Claude Code should treat the router's own model names as. Without it, a name Claude Code's model catalog does not know — a 9Router combo such as `agent` — makes every session print "isn't described by this version's model catalog" and compact at an assumed 200k window (issue #56). The user picks it, never AgentHub: it also sets the context window Claude Code compacts at, and only the user knows which model the router really sends a name to.
**No response here ever contains key material.** A saved profile reports `hasKey: true|false` and there is deliberately no `GET /:name` — a route returning one profile in full would be the single way an API key could leave the server. Profiles live one JSON file each under `<CLAUDE_HOME>/agenthub-routers/` (dir `0700`, files `0600`). An omitted `apiKey` on `PATCH` keeps the stored one; the edit form cannot display a key, so it has none to send back.
**`POST /api/ai-routers/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model? It sends one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (a 15 s timeout), which is the wire shape a claude workspace's eight env keys point at — a Codex-only router is not covered. A router that answers is a `200` here whatever it replies: `{ ok, status, message }` carries the router's own words (truncated to 300 characters), and `status` is `null` when nothing answered at all. Only bad input is a 4xx.
@@ -2389,7 +2407,7 @@ The stored key is only ever sent to the endpoint it was stored against. A blank
**Starting a session on a router.** `POST /api/lanes/:id/start` and `POST /api/run` take `router: "<name>"` (omitted, `""` or `"native"` = the vendor's own API). Only the name comes from the request; the key and endpoint come from the store. An unknown name is `400 EBADROUTER`, a router on a tool other than Claude/Codex `400 EUNSUPPORTED`. A resume (`resumeSessionId`, `resume`) that names no router goes back to the one its session last ran on (`dashboard_runs.router`), or native once that profile is deleted. Each spawn (re)writes the profile's files:
- **Claude** gets `--settings=<data dir>/router-settings/<name>.json` (dir `0700`, file `0600`): an `env` block with the eight keys `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL` (an empty one left out). The command-line layer outranks the project-local and user settings, so two sessions of one workspace can run on two routers, and only the path is in argv. Native adds nothing, so a router the user set in their own `~/.claude/settings.json` still applies to a native session.
- **Claude** gets `--settings=<data dir>/router-settings/<name>.json` (dir `0700`, file `0600`): an `env` block with the eight keys `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL` (an empty one left out), plus — only when the profile sets `behavesAs` — `modelPicker: { options: [{ model, behavesAs }] }` with one row per distinct model name that is not a bare `claude-*` id. Claude Code reads `modelPicker` from the policy, flag and user layers and takes the FIRST it finds, so while set it replaces a `modelPicker` in the user's own `~/.claude/settings.json` for that session (verified on 2.1.290). The command-line layer outranks the project-local and user settings, so two sessions of one workspace can run on two routers, and only the path is in argv. Native adds nothing, so a router the user set in their own `~/.claude/settings.json` still applies to a native session.
- **Codex** gets `-p <name>` and `$CODEX_HOME/<name>.config.toml`, the layer that flag loads on top of the base config (never touched). The layer sets `wire_api = "responses"` (codex-cli 0.149 refuses `"chat"`); `env_key` is written only when the profile has a key, and the key reaches the pane as `AGENTHUB_ROUTER_KEY` through tmux's own `-e`, never as typed text.
`PATCH` rewrites the profile's files that exist (a live Claude session reads its file again; the next Codex session gets the new layer) and `DELETE` removes both. Workspaces no longer carry a router: `POST /api/lanes/:id/router/apply` is gone, and at every start the server takes the eight keys an older build wrote into a workspace's `.claude/settings.local.json` back out and clears `lanes.router_profile` (a file that is not valid JSON is left alone and retried next start).
+13 -10
View File
@@ -396,7 +396,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show <session> here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Router, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Files / Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub workspace add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
- **Reordering** — drag a row and drop it anywhere in the list (a line shows where it lands, by the pointer's position — on another row, on a session list, above the first or below the last), or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub workspaces` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END; a stored id whose workspace is gone is skipped.
@@ -406,7 +406,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when a pane's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) its pane closes; the last one shows the next open tab, or the home when none is left. A pane with nothing to show offers **New session**, which opens the home too.
Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab).
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=2`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane is started with `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1` (`spawnRun` in `server/lib/pty-run.js`) and renders inline even when `~/.claude/settings.json` says `"tui": "fullscreen"`. That setting still applies to `claude` in your own terminals; a pane started before this change keeps fullscreen until it is restarted.
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=1`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+ — one column, since tmux 3.7c reserves `width` columns but paints only the first and a wider bar left a column of stale cells that read as a second scrollbar). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane under `"tui": "fullscreen"` (`~/.claude/settings.json`) scrolls inside Claude's own UI instead. AgentHub no longer forces it inline (`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`): inline, every time the pane narrowed tmux re-wrapped Claude's full-width rules and pushed lines into history that its redraw could not clear, duplicating the banner and transcript. A pane started before this change keeps inline mode until it is restarted. Every pane's CLI (and a Terminal tab's shell) also starts only once a client is attached — 2 s at most — so nothing is drawn at tmux's 80×24 birth size and re-wrapped on the attach (`WAIT_FOR_CLIENT` in `server/lib/pty-run.js`, issue #55).
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, split to the right (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen, to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, and **×** takes the pane out of this tab into a tab of its own (its session keeps running). A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
@@ -610,13 +610,11 @@ which one is "work" changes several times an hour, so the width is a step, not
a setting — and one button beats a width button wedged beside a collapse button
in the panel's tightest corner. It is icon-only, and the icon it draws is the
width the next click LANDS on (even split → wider → closed), with the wording
left to its tooltip. Beside it, along the panel's top edge, an **activity bar**
of two icon buttons picks what the panel shows (`agenthub.workspace.panelTab`):
**Files** (the browse mode below, on its own — no git header, no mode
switcher) and **Changes** (the git header and the other four modes). The
workspace's sessions live in the sidebar and on its home only — the panel's
former Sessions tab was a third copy of the same list. At Full screen the Files and Changes tabs show the same thing: the
explorer sidebar beside the changes. It
left to its tooltip. Files and Changes are ONE panel: its mode switcher reads
**Changes** (the overview, where it opens) · **Files** · **Review** · **History**
(the former activity-bar tabs and `agenthub.workspace.panelTab` are gone). The
workspace's sessions live in the sidebar and on its home only. At Full screen
Files is the explorer sidebar beside the changes, so its button is dropped. It
reads the workspace's git surface in five modes:
- **Files** — quick-open by path (`/git/ls`) or the repo's tracked file tree
@@ -719,7 +717,12 @@ refuses it without `confirm: true` and accepts only paths `git status` is
already reporting as changed. In the `work` range the overview splits like VS
Code's Source Control: **Staged Changes** (what the next commit takes, each row
offering Unstage) above **Changes** (unstaged and untracked, each offering
Stage); a partly staged file appears in both. In the tree, a changed file's
Stage); a partly staged file appears in both. Each group is a folder tree like
JetBrains' commit view (`client/src/lib/changeTree.ts`): folders first, a
chain of folders holding only one sub-folder compacted into one row
(`src/lib`), each folder row showing its file count, folding on a click and —
in Staged Changes / Changes — carrying one button that unstages / stages every
file under it; the group headers fold too. The fold state is not stored. In the tree, a changed file's
whole row takes its status tint, not only its letter.
Review, single-file, history and browse do **not** poll: re-fetching under the
+83 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.1.0
version: 2.2.0
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -10630,6 +10630,50 @@ paths:
error:
code: EBADORIGIN
message: cross-origin requests are not allowed
/api/run/history/{id}:
delete:
tags:
- Run
summary: Forget a closed Terminal tab
description: Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session — so it leaves the sidebar's recently-closed list. Rows of any other tool are never deleted (404). The loopback same-origin guard applies.
operationId: runHistoryForget
parameters:
- name: id
in: path
required: true
schema:
type: string
description: The history row's id (RunHistoryItem.id).
example: agenthub-lane-5-2@2026-10-06T08:00:00.000Z
responses:
'200':
description: Removed
content:
application/json:
example:
removed: agenthub-lane-5-2
'403':
description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level loopback same-origin guard on every /api/run route: a browser request carrying an Origin/Referer whose host is not localhost / 127.0.0.1 / ::1 / 0.0.0.0 is refused. Requests without an Origin header (e.g. curl) are allowed.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
error:
code: EBADORIGIN
message: cross-origin requests are not allowed
'404':
description: No closed Terminal tab with that id (ENOTFOUND)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: That terminal is still open (ERUNLIVE)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/run/cwds:
get:
tags:
@@ -11594,6 +11638,44 @@ paths:
description: '{available, branches, current, worktrees}.'
'404':
description: Lane not found.
/api/lanes/{id}/git/commit-message:
post:
tags:
- Lanes
summary: Suggest a commit message for the staged changes
description: Runs the user's `claude` CLI (`-p --safe-mode --no-session-persistence --tools "" --model haiku`) on the STAGED diff of the lane's cwd (or `?session=` worktree), sent on stdin with the last ten commit subjects; a `settings.local.json` is never included. Reads only — nothing is staged or committed, no tool runs and no session is saved. Same-origin, since each call spends the user's Claude quota. 400 ENOSTAGED when nothing is staged, 400 ENOTOOL when `claude` is not on PATH, 502 EAI when the CLI fails, answers empty or runs past 120 s.
operationId: suggestLaneCommitMessage
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: session
in: query
required: false
schema:
type: string
responses:
'200':
description: The suggested message
content:
application/json:
schema:
type: object
properties:
message:
type: string
required:
- message
'400':
description: ENOSTAGED / ENOTOOL / ENOWORKTREE
'403':
description: Cross-origin request refused
'404':
description: No such workspace
'502':
description: EAI — the claude CLI failed
/api/lanes/{id}/git/{action}:
post:
tags:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.1.0",
"version": "2.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.1.0",
"version": "2.2.0",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.1.0",
"version": "2.2.0",
"bin": {
"agenthub": "bin/agenthub.js"
},
+15
View File
@@ -164,6 +164,21 @@ describe("ai-routers — update", () => {
it("reports a name that does not exist", () => {
assert.throws(() => routers.updateRouter("ghost", { label: "x" }), { code: "ENOTFOUND" });
});
it("sets, keeps and clears behavesAs, and refuses an id that is not a Claude model's", () => {
const created = routers.createRouter(nineRouter({ behavesAs: " claude-opus-5-5 " }));
assert.equal(created.behavesAs, "claude-opus-5-5");
assert.equal(
routers.updateRouter(created.name, { label: "renamed" }).behavesAs,
"claude-opus-5-5"
);
assert.equal(routers.updateRouter(created.name, { behavesAs: "" }).behavesAs, "");
for (const bad of ["agent", "claude opus", "-claude-x", 5]) {
assert.throws(() => routers.updateRouter(created.name, { behavesAs: bad }), {
code: "EBADMODEL",
});
}
});
});
describe("ai-routers — remove", () => {
+91
View File
@@ -0,0 +1,91 @@
/**
* @file Tests for server/lib/commit-message.js against a real git repo and a
* fake `claude` script: only staged, in-lane, non-secret changes reach the
* model; its argv keeps tools, hooks and persistence off; its answer is
* cleaned; and every failure is a coded error.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { suggestCommitMessage, buildInput } = require("../lib/commit-message");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-commitmsg-"));
const REPO = path.join(ROOT, "repo");
const SUB = path.join(REPO, "sub");
const ARGS = path.join(ROOT, "args.txt");
const STDIN = path.join(ROOT, "stdin.txt");
const g = (...args) => {
const env = { ...process.env };
delete env.GIT_DIR;
delete env.GIT_INDEX_FILE;
execFileSync("git", ["-c", "user.email=t@h", "-c", "user.name=t", ...args], { cwd: REPO, env });
};
const write = (file, body) => {
fs.mkdirSync(path.dirname(path.join(REPO, file)), { recursive: true });
fs.writeFileSync(path.join(REPO, file), body);
};
/** A fake claude: records argv and stdin, prints `reply`, exits `code`. */
function fakeClaude(reply, code = 0) {
const bin = path.join(ROOT, `claude-${Math.random().toString(36).slice(2)}`);
fs.writeFileSync(
bin,
`#!/bin/sh\nprintf '%s\\n' "$@" > '${ARGS}'\ncat > '${STDIN}'\nprintf '${reply}'\nexit ${code}\n`,
{ mode: 0o755 }
);
return bin;
}
before(() => {
fs.mkdirSync(REPO);
g("init", "-q", "-b", "main");
write("a.txt", "one\n");
g("add", "-A");
g("commit", "-q", "-m", "feat: seed");
});
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
describe("commit-message", () => {
it("refuses with ENOSTAGED when nothing is staged", async () => {
write("a.txt", "one\nunstaged\n");
await assert.rejects(buildInput(REPO), { code: "ENOSTAGED" });
});
it("sends only staged, non-secret changes and keeps tools/hooks/persistence off", async () => {
write("b.txt", "staged line\n");
write(".claude/settings.local.json", '{"env":{"KEY":"sk-secret"}}\n');
g("add", "-f", "b.txt", ".claude/settings.local.json");
const msg = await suggestCommitMessage(REPO, { bin: fakeClaude("```\\nfeat: add b\\n```") });
assert.equal(msg, "feat: add b");
const stdin = fs.readFileSync(STDIN, "utf8");
assert.match(stdin, /feat: seed/);
assert.match(stdin, /staged line/);
assert.doesNotMatch(stdin, /unstaged|sk-secret|settings\.local/);
const argv = fs.readFileSync(ARGS, "utf8").split("\n");
for (const flag of ["-p", "--safe-mode", "--no-session-persistence", "--tools"]) {
assert.ok(argv.includes(flag), flag);
}
assert.equal(argv[argv.indexOf("--tools") + 1], "");
});
it("scopes a subdirectory lane to its own staged files", async () => {
write("sub/c.txt", "inside\n");
g("add", "sub/c.txt");
const input = await buildInput(SUB);
assert.match(input, /inside/);
assert.doesNotMatch(input, /staged line/);
});
it("reports a failing or silent CLI as EAI", async () => {
await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("boom", 1) }), {
code: "EAI",
});
await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("") }), { code: "EAI" });
});
});
+9
View File
@@ -52,6 +52,15 @@ describe("dashboard-runs", () => {
assert.equal(runs.listRuns({ laneId: 8 }).length, 2);
});
it("forgets a closed Terminal tab and nothing of another tool", () => {
start("agenthub-lane-9", { laneId: 9, tool: "claude" });
start("agenthub-lane-9-1", { laneId: 9, tool: "shell" });
assert.equal(runs.forgetShellRun("agenthub-lane-9"), false);
assert.ok(runs.getRun("agenthub-lane-9"));
assert.equal(runs.forgetShellRun("agenthub-lane-9-1"), true);
assert.equal(runs.getRun("agenthub-lane-9-1"), null);
});
it("flags as abandoned only the running rows whose pane is gone", () => {
start("agenthub-lane-5", { laneId: 5 });
start("agenthub-lane-6", { laneId: 6 });
+2 -1
View File
@@ -185,7 +185,8 @@ describe("lane-sources instructions and launch", () => {
let argv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 1);
// The CLI rides behind `sh -c <wait for a client> sh` (pty-run.js).
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 5);
return "";
});
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
+5
View File
@@ -1105,6 +1105,11 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
const bogus = await request("POST", `/api/lanes/${laneId}/git/rebase`, {});
assert.equal(bogus.status, 400);
assert.equal(bogus.body.error.code, "EBADACTION");
// Its own route, not an action: never reaches claude with nothing staged.
const suggest = await request("POST", `/api/lanes/${laneId}/git/commit-message`, {});
assert.equal(suggest.status, 400);
assert.equal(suggest.body.error.code, "ENOSTAGED");
});
it("400s a publish to a url that is not https, ssh or scp-like", async () => {
+45 -15
View File
@@ -75,11 +75,9 @@ describe("pty-run", () => {
const { spawn } = require("node:child_process");
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "fake-tmux-"));
const flag = path.join(bin, "attached");
fs.writeFileSync(
path.join(bin, "tmux"),
`#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`,
{ mode: 0o755 }
);
fs.writeFileSync(path.join(bin, "tmux"), `#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`, {
mode: 0o755,
});
try {
const t0 = Date.now();
const child = spawn("sh", ["-c", argv[2]], {
@@ -492,11 +490,43 @@ describe("pty-run", () => {
return calls.find((c) => c[0] === "new-session");
}
/** The pane's own command line — everything tmux passes after the `--`. */
/** The CLI's own command line: what tmux passes after the `--`, less the
* `sh -c '<wait for a client>; exec "$@"' sh` wrapper spawnRun puts first. */
function paneArgv(newSessionCall) {
return newSessionCall.slice(newSessionCall.indexOf("--") + 1);
const argv = newSessionCall.slice(newSessionCall.indexOf("--") + 1);
assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
assert.match(argv[2], /session_attached.*exec "\$@"$/);
return argv.slice(4);
}
it("spawnRun starts the CLI only once the pane has its real size, argv untouched", () => {
// A claude drawn at tmux's birth size duplicated its banner on the first
// resize; the argv rides as positional parameters, never script text.
const call = captureNewSession(() =>
pty.spawnRun({ laneId: 14, cwd: "/tmp/repo", initialPrompt: "a'; rm -rf / #" })
);
const argv = paneArgv(call);
assert.ok(argv.includes("a'; rm -rf / #"));
const { execFileSync } = require("node:child_process");
const out = execFileSync(
"sh",
[
...call.slice(call.indexOf("--") + 2, call.indexOf("--") + 4),
"sh",
"printf",
"%s|",
"x y",
"$HOME",
],
{
env: { ...process.env, PATH: "/nonexistent-tmux-dir:" + process.env.PATH, TMUX_PANE: "" },
encoding: "utf8",
timeout: 5000,
}
);
assert.equal(out, "x y|$HOME|");
});
it("spawnRun builds a codex argv for a codex lane", () => {
const call = captureNewSession(() =>
pty.spawnRun({
@@ -583,15 +613,11 @@ describe("pty-run", () => {
assert.ok(!envFlags(claude).some((a) => a.startsWith("CODEX_HOME=")));
});
it("keeps a claude pane off the alternate screen, so tmux scrolls (and draws a scrollbar for) it", () => {
it("leaves a claude pane's screen mode to the user's own tui setting", () => {
// Forcing it inline duplicated the transcript on every pane narrowing.
const envFlags = (call) => call.filter((a, n) => call[n - 1] === "-e");
const claude = captureNewSession(() => pty.spawnRun({ laneId: 42, cwd: "/tmp/repo" }));
assert.ok(envFlags(claude).includes("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1"));
tmux.__reset();
const codex = captureNewSession(() =>
pty.spawnRun({ laneId: 43, cwd: "/tmp/repo", tool: "codex" })
);
assert.ok(!envFlags(codex).some((a) => a.startsWith("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=")));
assert.ok(!envFlags(claude).some((a) => a.startsWith("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=")));
});
describe("catalog tools", () => {
@@ -719,7 +745,11 @@ describe("pty-run", () => {
assert.equal(envOf(ns, "GOOSE_MODE"), "auto");
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
live = true;
t.mock.timers.tick(3000);
// delayMs (3000) counts from when the CLI starts: after up to 2 s of
// waiting for a client to attach.
t.mock.timers.tick(4999);
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
t.mock.timers.tick(1);
assert.deepEqual(
calls.find((c) => c[0] === "set-buffer"),
["set-buffer", "-b", "agenthub-paste-agenthub-lane-16", "--", "fix it"]
+27
View File
@@ -119,6 +119,33 @@ describe("routerForSpawn — Claude", () => {
it("adds nothing for native", () => {
assert.deepEqual(spawnFor("claude", "native"), { argv: [], env: {} });
});
it("writes no modelPicker without behavesAs, so the user's own one still applies", () => {
const { name } = saveNineRouter();
spawnFor("claude", name);
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8"));
assert.equal("modelPicker" in written, false);
});
it("maps every distinct router model name with behavesAs (issue #56)", () => {
const { name } = routers.createRouter({
label: "9Router combo",
baseUrl: "http://localhost:20128/v1",
model: "agent",
models: { haiku: "agent-fast", subagent: "claude-haiku-4-5-20251001" },
behavesAs: "claude-opus-5-5",
});
spawnFor("claude", name);
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8"));
// A bare claude-* id is one Claude Code knows; mapping it would override
// its real handling.
assert.deepEqual(written.modelPicker, {
options: [
{ model: "agent", behavesAs: "claude-opus-5-5" },
{ model: "agent-fast", behavesAs: "claude-opus-5-5" },
],
});
});
});
describe("routerForSpawn — Codex", () => {
+1 -1
View File
@@ -59,7 +59,7 @@ describe("tmux wrapper", () => {
"-t",
"=agenthub-lane-1:",
"pane-scrollbars-style",
"fg=brightwhite,bg=black,width=2,pad=0",
"fg=brightwhite,bg=black,width=1,pad=0",
]);
});
+21 -1
View File
@@ -189,6 +189,21 @@ function normalizeModels(model, models) {
return out;
}
/**
* A known Claude model id that Claude Code should treat the router's own model
* names as (its `modelPicker` `behavesAs`, issue #56). Optional; `""` clears it.
* The user picks it, never AgentHub: it also sets the context window Claude
* Code compacts at, and a router may route a name to a non-Claude model.
*/
function normalizeBehavesAs(value) {
const clean = typeof value === "string" ? value.trim() : null;
if (clean === "") return "";
if (!clean || !/^claude-[a-z0-9][a-z0-9.-]{0,99}$/.test(clean)) {
throw err("EBADMODEL", "behavesAs must be a Claude model id, e.g. claude-opus-5-5");
}
return clean;
}
function assertNotBuiltin(name) {
if (name === BUILTIN_NAME) {
throw err("EBUILTIN", `'${BUILTIN_NAME}' is the built-in profile and cannot be changed`);
@@ -250,7 +265,7 @@ function getRouter(name) {
/**
* @param {{label:string, baseUrl:string, apiKey?:string, model:string,
* models?:Record<string,string>}} input
* models?:Record<string,string>, behavesAs?:string}} input
* @returns the browser-safe view of what was saved
*/
function createRouter(input = {}) {
@@ -265,6 +280,7 @@ function createRouter(input = {}) {
apiKey: typeof input.apiKey === "string" ? input.apiKey.trim() : "",
model,
models: normalizeModels(model, input.models),
behavesAs: input.behavesAs === undefined ? "" : normalizeBehavesAs(input.behavesAs),
savedAt: new Date().toISOString(),
};
@@ -297,6 +313,10 @@ function updateRouter(name, changes = {}) {
changes.model === undefined && changes.models === undefined
? stored.models
: normalizeModels(model, changes.models ?? stored.models),
behavesAs:
changes.behavesAs === undefined
? (stored.behavesAs ?? "")
: normalizeBehavesAs(changes.behavesAs),
savedAt: new Date().toISOString(),
};
+121
View File
@@ -0,0 +1,121 @@
/**
* @file commit-message.js
* @description Suggests a commit message for a lane's STAGED changes with the
* user's own `claude` CLI: `claude -p` on the staged diff, sent on stdin, with
* no tools, no hooks/plugins/CLAUDE.md (`--safe-mode`) and no transcript on
* disk (`--no-session-persistence`), so the call neither acts on the repo nor
* shows up as a session in the dashboard. Read-only on git; the diff is scoped
* to the lane's cwd and never carries a secret file (`isSecretOnDisk`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const { spawn } = require("node:child_process");
const worktree = require("./worktree");
const aiTools = require("./ai-tools");
const { isSecretOnDisk } = require("./git-diff");
const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts });
/** Diff characters sent to the model; the rest is cut and marked. */
const MAX_DIFF_CHARS = 60_000;
/** A `claude -p` that has not answered by then is killed. */
const TIMEOUT_MS = 120_000;
const PROMPT =
"Write a git commit message for the staged diff given on stdin. " +
"Use the conventional-commits form `type(scope): subject` (imperative, subject at most 72 characters), " +
"matching the style of the recent subjects listed there. Add a short body after a blank line only " +
"when the change needs explaining. Output ONLY the commit message — no code fences, no commentary.";
function bad(code, message) {
return Object.assign(new Error(message), { code });
}
/** The text the model reads: recent subjects, the staged file list, the diff. */
async function buildInput(dir) {
const names = (await git(dir, ["diff", "--cached", "--name-only", "--relative", "-z"])).stdout
.split("\0")
.filter(Boolean);
const paths = [];
for (const p of names) if (!(await isSecretOnDisk(dir, p))) paths.push(p);
if (!paths.length) throw bad("ENOSTAGED", "nothing is staged in this workspace");
let diff;
try {
diff = (
await git(
dir,
["diff", "--cached", "--no-color", "--no-ext-diff", "--relative", "--", ...paths],
{ maxBuffer: MAX_DIFF_CHARS * 4 + 64 * 1024 }
)
).stdout;
} catch (err) {
// An oversized diff: git's partial output is still the useful head.
if (err.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || !err.git?.stdout) throw err;
diff = String(err.git.stdout);
}
if (diff.length > MAX_DIFF_CHARS) diff = `${diff.slice(0, MAX_DIFF_CHARS)}\n[diff truncated]\n`;
const log = await git(dir, ["log", "-10", "--format=%s"])
.then((r) => r.stdout.trim())
.catch(() => ""); // no commit yet
return [
log ? `Recent commit subjects:\n${log}` : "",
`Staged files:\n${paths.join("\n")}`,
`Staged diff:\n${diff}`,
]
.filter(Boolean)
.join("\n\n");
}
/** Run `bin` with `args`, `input` on stdin; resolve its stdout. */
function run(bin, args, input) {
return new Promise((resolve, reject) => {
const child = spawn(bin, args, { cwd: os.tmpdir(), stdio: ["pipe", "pipe", "pipe"] });
let out = "";
let errOut = "";
const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (errOut += d));
child.on("error", (err) => {
clearTimeout(timer);
reject(bad("EAI", err.message));
});
child.on("close", (code, signal) => {
clearTimeout(timer);
if (code === 0) return resolve(out);
const why = signal ? "timed out" : (errOut || out).trim().slice(-500) || `exit ${code}`;
reject(bad("EAI", `claude failed: ${why}`));
});
child.stdin.on("error", () => {}); // a CLI that exits early closes stdin
child.stdin.end(input);
});
}
/**
* A suggested message for what `dir` has staged.
* @param {string} dir the lane's (or session worktree's) cwd
* @param {{bin?: string}} [opts] `bin` overrides the detected `claude` (tests)
* @returns {Promise<string>}
*/
async function suggestCommitMessage(dir, { bin } = {}) {
const input = await buildInput(dir);
const claude = bin || aiTools.detect("claude", { fresh: true }).path;
if (!claude) throw bad("ENOTOOL", "the claude CLI was not found on PATH");
const out = await run(
claude,
["-p", "--safe-mode", "--no-session-persistence", "--tools", "", "--model", "haiku", PROMPT],
input
);
const message = out
.trim()
.replace(/^```[a-z]*\n?|\n?```$/g, "")
.trim();
if (!message) throw bad("EAI", "claude returned an empty message");
return message;
}
module.exports = { suggestCommitMessage, buildInput };
+9
View File
@@ -165,6 +165,14 @@ function listRuns({ limit = 50, laneId = null, tool = null } = {}) {
}
}
const forgetShellStmt = db.prepare(`DELETE FROM dashboard_runs WHERE id = ? AND tool = 'shell'`);
/** Drop a closed Terminal tab's row — a shell has no session, so the row is
* only the sidebar's "recently closed" entry. Never touches another tool's. */
function forgetShellRun(id) {
return forgetShellStmt.run(id).changes > 0;
}
function getRun(id) {
try {
return getStmt.get({ id }) || null;
@@ -281,6 +289,7 @@ module.exports = {
patchRun,
listRuns,
getRun,
forgetShellRun,
reconcileOrphans,
routerForSession,
laneForSession,
+37 -19
View File
@@ -60,6 +60,23 @@ const ALLOWED_PERMISSION_MODES = new Set([
]);
// Pane commands that mean "idle shell prompt, safe to type a command into".
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
/**
* `sh` that waits until a client is attached to the pane — 2 s at most
* ({@link CLIENT_WAIT_MS}) — before what follows it runs. A session is born
* 80×24 and takes the browser's size only on attach (and `enableMouse`'s
* scrollbar narrows it before that). Anything drawn before then is wrong:
* a shell's prompt (issue #55), and an inline `claude`'s banner — tmux
* re-wraps the 80-column rules into two lines each on the resize, pushing the
* banner's first lines into history, and claude's redraw clears only the
* visible screen, so they stay above it as a duplicate. Not "the first
* SIGWINCH": the scrollbar fires one before any client is there. A tmux that
* cannot answer ends the wait at once, i.e. the old behaviour.
*/
const WAIT_FOR_CLIENT =
'i=0; while [ "$(tmux display -p -t "$TMUX_PANE" \'#{session_attached}\' 2>/dev/null)" = 0 ]' +
" && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done;";
const CLIENT_WAIT_MS = 2000;
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
const CODEX_APPROVALS = new Set(["on-request", "never"]);
// Codex has no `--effort` flag; reasoning effort is a config override.
@@ -452,11 +469,10 @@ function spawnRun(args) {
// journals and the router layer `-p <name>` loads live there — and the
// tmux server's own env may predate, or never have had, CODEX_HOME.
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
// Claude Code inline even under `"tui": "fullscreen"`: on the alternate
// screen tmux keeps no history and draws no pane scrollbar, so the
// dashboard terminal had nothing to drag. The user's own terminals keep
// their setting — this reaches only panes AgentHub starts.
...(tool === "claude" ? { CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: "1" } : {}),
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
// pushes lines into history its redraw cannot clear). The user's own
// `"tui"` setting decides; under fullscreen the pane scrolls in Claude.
...routerSpawn.env,
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
};
@@ -472,12 +488,18 @@ function spawnRun(args) {
} catch {
/* pane gone mid-paste — nothing to deliver to */
}
}, entry.prompt.delayMs);
}, entry.prompt.delayMs + CLIENT_WAIT_MS); // the CLI may start up to that late
if (timer.unref) timer.unref();
};
if (!tmux.hasSession(id)) {
tmux.newSession({ name: id, cwd, argv, env });
// argv rides as sh's positional parameters, never inside the script text.
tmux.newSession({
name: id,
cwd,
argv: ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$@"`, "sh", ...argv],
env,
});
record();
schedulePaste();
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
@@ -530,18 +552,14 @@ function spawnShell({ laneId, slot = 0, cwd }) {
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
...desktopEnv(),
};
// The session is born 80×24 and only takes the browser's size when the pane
// attaches. A shell started at once printed its prompt at 80 columns (a long
// cwd wrapped), and readline's redraw after the resize left the cursor on top
// of the prompt (issue #55). So wait until a client is attached — 2 s at
// most — then exec the login shell tmux would have run (tmux sets SHELL in the
// pane). Not "the first SIGWINCH": the scrollbar `enableMouse` turns on
// resizes the pane before any client is there. A tmux that cannot answer
// ends the wait at once, i.e. the old behaviour.
const waitForSize =
"i=0; while [ \"$(tmux display -p -t \"$TMUX_PANE\" '#{session_attached}' 2>/dev/null)\" = 0 ]" +
' && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done; exec "$SHELL" -l';
tmux.newSession({ name: id, cwd, argv: ["sh", "-c", waitForSize], env });
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
// the login shell tmux would have run (tmux sets SHELL in the pane).
tmux.newSession({
name: id,
cwd,
argv: ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$SHELL" -l`],
env,
});
dashboardRuns?.recordRun({
id,
sessionId: null,
+20 -2
View File
@@ -8,7 +8,8 @@
* theirs, not a choice made here:
*
* - **Claude Code** gets `--settings=<file>`: a settings file holding the
* profile's eight `env` keys, `<data dir>/router-settings/<name>.json`
* profile's eight `env` keys (plus `modelPicker` rows when the profile
* sets `behavesAs`), `<data dir>/router-settings/<name>.json`
* (0600). The command-line layer outranks the project-local and user
* settings (verified on 2.1.286), so two sessions of one workspace can run
* on two routers, and the key stays out of argv — only the path is there.
@@ -73,12 +74,29 @@ function writeSecret(file, body) {
fs.chmodSync(file, 0o600); // an existing file keeps its mode on write
}
/**
* `modelPicker` rows telling Claude Code which known model each of the router's
* own names behaves as — without one, a name like 9Router's `agent` draws an
* "isn't described by this version's model catalog" warning and a 200k
* compact window (issue #56). Only when the profile sets `behavesAs`: this
* layer's `modelPicker` replaces the user's own for the session. A bare
* `claude-*` id is skipped, since Claude Code already knows it.
*/
function modelPicker(profile) {
if (!profile.behavesAs) return {};
const names = new Set([profile.model, ...Object.values(profile.models || {})]);
const options = [...names]
.filter((m) => m && !m.startsWith("claude-"))
.map((model) => ({ model, behavesAs: profile.behavesAs }));
return options.length ? { modelPicker: { options } } : {};
}
function writeClaudeSettings(profile) {
const file = claudeSettingsPath(profile.name);
// Empty values are left out: a blank key in the command-line layer would
// blank the user's own setting, not leave it alone.
const env = Object.fromEntries(Object.entries(envForProfile(profile)).filter(([, v]) => v));
writeSecret(file, `${JSON.stringify({ env }, null, 2)}\n`);
writeSecret(file, `${JSON.stringify({ env, ...modelPicker(profile) }, null, 2)}\n`);
return file;
}
+4 -2
View File
@@ -99,13 +99,15 @@ function enableMouse(name) {
execImpl(["set-option", "-t", pane(name), "pane-scrollbars", "on"]);
// tmux's default (`fg=white,bg=black,width=1`) is a one-column track in
// ANSI black — the terminal theme's border colour, almost the background —
// so it reads as no scrollbar at all. Two columns, bright slider.
// so it reads as no scrollbar at all. Bright slider. ONE column: tmux 3.7c
// reserves `width` columns but paints only the first, so a second one is
// never cleared and shows stale cells — a ghost second scrollbar.
execImpl([
"set-option",
"-t",
pane(name),
"pane-scrollbars-style",
"fg=brightwhite,bg=black,width=2,pad=0",
"fg=brightwhite,bg=black,width=1,pad=0",
]);
} catch {
/* tmux < 3.7 */
+31
View File
@@ -674,6 +674,37 @@ const paths = {
},
},
},
"/api/lanes/{id}/git/commit-message": {
post: {
tags: ["Lanes"],
summary: "Suggest a commit message for the staged changes",
description:
"Runs the user's `claude` CLI (`-p --safe-mode --no-session-persistence --tools \"\" --model haiku`) on the STAGED diff of the lane's cwd (or `?session=` worktree), sent on stdin with the last ten commit subjects; a `settings.local.json` is never included. Reads only — nothing is staged or committed, no tool runs and no session is saved. Same-origin, since each call spends the user's Claude quota. 400 ENOSTAGED when nothing is staged, 400 ENOTOOL when `claude` is not on PATH, 502 EAI when the CLI fails, answers empty or runs past 120 s.",
operationId: "suggestLaneCommitMessage",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{ name: "session", in: "query", required: false, schema: { type: "string" } },
],
responses: {
200: {
description: "The suggested message",
content: {
"application/json": {
schema: {
type: "object",
properties: { message: { type: "string" } },
required: ["message"],
},
},
},
},
400: { description: "ENOSTAGED / ENOTOOL / ENOWORKTREE" },
403: { description: "Cross-origin request refused" },
404: { description: "No such workspace" },
502: { description: "EAI — the claude CLI failed" },
},
},
},
"/api/lanes/{id}/git/{action}": {
post: {
tags: ["Lanes"],
+39
View File
@@ -857,6 +857,45 @@ const paths = {
},
},
"/api/run/history/{id}": {
delete: {
tags: ["Run"],
summary: "Forget a closed Terminal tab",
description:
"Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session — so it leaves the sidebar's recently-closed list. Rows of any other tool are never deleted (404). The loopback same-origin guard applies.",
operationId: "runHistoryForget",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "The history row's id (RunHistoryItem.id).",
example: "agenthub-lane-5-2@2026-10-06T08:00:00.000Z",
},
],
responses: {
200: {
description: "Removed",
content: { "application/json": { example: { removed: "agenthub-lane-5-2" } } },
},
403: ebadOrigin403,
404: {
description: "No closed Terminal tab with that id (ENOTFOUND)",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
409: {
description: "That terminal is still open (ERUNLIVE)",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
},
},
},
"/api/run/cwds": {
get: {
tags: ["Run"],
+22
View File
@@ -15,6 +15,7 @@ const path = require("node:path");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
const { sessionWorktree, removeSessionWorktree } = require("../lib/session-worktree");
const { suggestCommitMessage } = require("../lib/commit-message");
const aiTools = require("../lib/ai-tools");
const {
listPipelines,
@@ -1061,6 +1062,27 @@ router.get("/:id/git/branches", async (req, res) => {
* rule about what each action may do lives in server/lib/git-ops.js, not here.
* `git pull` is --ff-only and `switch` refuses a dirty tree, by design there.
*/
/**
* A commit message suggested by the user's `claude` CLI for what is STAGED
* (server/lib/commit-message.js). Not a git write — it only reads the index —
* but same-origin, because each call spends the user's Claude quota.
* Registered before `/:id/git/:action`, which would answer EBADACTION.
*/
router.post("/:id/git/commit-message", sameOriginGuard, async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
res.json({ message: await suggestCommitMessage(t.cwd) });
} catch (err) {
const status = ["ENOSTAGED", "ENOTOOL"].includes(err.code)
? 400
: err.code === "EAI"
? 502
: 500;
res.status(status).json({ error: { code: gitErrorCode(err), message: err.message } });
}
});
router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
+17
View File
@@ -158,6 +158,23 @@ router.get("/history", (req, res) => {
});
});
/**
* Forget one closed Terminal tab (`tool = 'shell'`) from the history — the
* sidebar's remove. A live pane is refused; another tool's row is a 404.
*/
router.delete("/history/:id", (req, res) => {
if (runs.listRuns().some((h) => h.id === req.params.id)) {
return res.status(409).json({ error: { code: "ERUNLIVE", message: "terminal is still open" } });
}
const dr = require("../lib/dashboard-runs");
if (!dr.forgetShellRun(req.params.id)) {
return res
.status(404)
.json({ error: { code: "ENOTFOUND", message: "no closed terminal with that id" } });
}
res.json({ removed: req.params.id });
});
/**
* Suggest plausible working directories. Pulls from:
* - "dashboard": the dashboard server's cwd