feat(workspace): session view console as tabs, with New terminal
The 1/2/4 split view becomes a tab bar (ConsoleTabs.tsx): one tab per live tmux slot (0-3) of the workspace, the active one being ?run=, every live tab's pane kept mounted and hidden with CSS. The + menu opens a new tab in the first free slot: New terminal (a plain shell - shell: true on POST /api/lanes/:id/start, spawnShell, tool 'shell', no run_id/status change), any installed enabled AI tool started bare, or Settings -> Agents. A tab's x stops its session. splitViewStorage, &pane= and the sidebar's Ctrl/Cmd+click are gone.
This commit is contained in:
@@ -238,11 +238,6 @@ describe("WorkspaceTree", () => {
|
||||
await waitFor(() => expect(live).toHaveAttribute("data-live", "true"));
|
||||
await user.click(live);
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-2");
|
||||
// Ctrl+click asks for a second pane.
|
||||
fireEvent.click(live, { ctrlKey: true });
|
||||
expect(screen.getByTestId("where").textContent).toBe(
|
||||
"/workspace?lane=1&run=agenthub-lane-1-2&pane=1"
|
||||
);
|
||||
await user.click(screen.getByTestId("workspace-session-s-old"));
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&session=s-old");
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
* @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
|
||||
* Claude Desktop project page: the name, a prompt box that starts a NEW
|
||||
* session in the first free pane slot (0–3, `POST /api/lanes/:id/start`) and
|
||||
* then opens it (`&run=<runId>`) — the ONE place a session is started. Inside
|
||||
* then opens it (`&run=<runId>`) — the one place a session starts WITH a
|
||||
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
|
||||
* `ConsoleTabs.tsx`). Inside
|
||||
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
|
||||
* by default) and one Model chip (`ModelMenu`: the tool's own list or the
|
||||
* router's models, plus Effort); under it the quieter choices — AI Router
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
|
||||
* which deletes nothing). A session with a LIVE pane (a `GET /api/run` handle
|
||||
* carrying its session id) opens that tmux pane on the Workspace page
|
||||
* (`?lane=<id>&run=<runId>`, Ctrl/Cmd+click `&pane=1` to open it in a second
|
||||
* split pane) — attaching, never starting or resuming anything;
|
||||
* (`?lane=<id>&run=<runId>`, its tab in the session view) — attaching, never
|
||||
* starting or resuming anything;
|
||||
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
||||
* `SessionReplay`): its conversation with a prompt box, resumed only when a
|
||||
* message is SENT, because a resume re-reads the whole transcript into context.
|
||||
@@ -314,13 +314,10 @@ export function WorkspaceSessionList({
|
||||
type="button"
|
||||
data-testid={`workspace-session-${s.id}`}
|
||||
data-live={run ? "true" : undefined}
|
||||
onClick={(e) =>
|
||||
onClick={() =>
|
||||
navigate(
|
||||
run
|
||||
? // Ctrl/Cmd+click opens it beside the current pane.
|
||||
`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}${
|
||||
e.ctrlKey || e.metaKey ? "&pane=1" : ""
|
||||
}`
|
||||
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
|
||||
: `/workspace?lane=${lane.id}&session=${encodeURIComponent(s.id)}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @file ConsoleTabs.tsx
|
||||
* @description The Workspace session view's tab bar: one tab per live tmux
|
||||
* slot of the workspace (0–3, `GET /api/run`), the active one being the URL's
|
||||
* `?run=`. A tab's X stops its session (`DELETE /api/run/:id`). The `+` menu
|
||||
* opens a new tab in the first free slot — a plain shell
|
||||
* (`POST /api/lanes/:id/start {shell: true}`) or an installed, enabled AI
|
||||
* tool started bare on its default model, no prompt (a prompt, router or
|
||||
* worktree is still the workspace home's prompt box) — plus a link to
|
||||
* Settings → Agents. Four tabs is the server's limit (`MAX_PANE_SLOT`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Plus, Settings2, SquareTerminal, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { freeSlot } from "../lanes/WorkspaceHome";
|
||||
|
||||
export function ConsoleTabs({
|
||||
lane,
|
||||
tabs,
|
||||
activeRunId,
|
||||
onOpen,
|
||||
onClosed,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** The workspace's live runs, in slot order. */
|
||||
tabs: RunHandle[];
|
||||
activeRunId: string | null;
|
||||
onOpen: (runId: string) => void;
|
||||
/** A tab's session was stopped. */
|
||||
onClosed: (runId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const aiTools = useAiTools();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setMenuOpen(false);
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
const slot = freeSlot(lane.id, tabs);
|
||||
const label = (r: RunHandle) => (r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool));
|
||||
// The workspace's own tool whatever its state, then every installed, enabled one.
|
||||
const toolIds = [
|
||||
...new Set([
|
||||
lane.tool,
|
||||
...aiTools.tools.filter((x) => x.installed && x.enabled).map((x) => x.id),
|
||||
]),
|
||||
];
|
||||
|
||||
const open = async (body: Record<string, unknown>) => {
|
||||
setMenuOpen(false);
|
||||
if (slot === null) return;
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.lanes.action(lane.id, "start", { slot, ...body });
|
||||
if (r.run_id) onOpen(r.run_id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const close = async (runId: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await api.run.kill(runId);
|
||||
onClosed(runId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const item =
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border">
|
||||
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-end gap-0.5">
|
||||
{tabs.map((r) => {
|
||||
const active = r.id === activeRunId;
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
className={`group flex min-w-0 max-w-[14rem] items-center rounded-t-md border border-b-0 ${
|
||||
active
|
||||
? "border-border bg-surface-2 text-fg-primary"
|
||||
: "border-transparent text-fg-muted hover:bg-surface-2/60 hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
data-testid={`console-tab-${r.slot ?? 0}`}
|
||||
onClick={() => onOpen(r.id)}
|
||||
title={[r.model, r.promptPreview].filter(Boolean).join(" · ") || undefined}
|
||||
className="flex min-w-0 items-center gap-1.5 py-1.5 pl-2.5 pr-1 text-xs"
|
||||
>
|
||||
{r.tool === "shell" ? (
|
||||
<SquareTerminal className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<AiToolIcon tool={{ id: r.tool, label: label(r) }} className="h-3.5 w-3.5 !p-0" />
|
||||
)}
|
||||
<span className="truncate">{label(r)}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`console-tab-close-${r.slot ?? 0}`}
|
||||
onClick={() => void close(r.id)}
|
||||
aria-label={t("tabs.close")}
|
||||
title={t("tabs.close")}
|
||||
className="mr-1 shrink-0 rounded p-0.5 opacity-60 hover:bg-surface-4 hover:text-status-danger hover:opacity-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div ref={menuRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="console-tab-new"
|
||||
disabled={slot === null}
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
aria-label={t("tabs.new")}
|
||||
title={slot === null ? t("tabs.full") : t("tabs.new")}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
data-testid="console-tab-menu"
|
||||
className="absolute left-0 top-full z-50 mt-1 w-56 rounded-lg border border-border bg-surface-2 p-1 shadow-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={item}
|
||||
onClick={() => void open({ shell: true })}
|
||||
>
|
||||
<SquareTerminal className="h-4 w-4" />
|
||||
{t("tabs.newTerminal")}
|
||||
</button>
|
||||
<div className="my-1 border-t border-border" />
|
||||
{toolIds.map((id) => {
|
||||
const model = aiTools.byId(id)?.defaultModel;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid={`console-tab-new-${id}`}
|
||||
className={item}
|
||||
onClick={() =>
|
||||
void open({
|
||||
prompt: "",
|
||||
// Only a different tool is named, as the home does: the
|
||||
// workspace's own keeps starting even once disabled.
|
||||
...(id !== lane.tool ? { tool: id } : {}),
|
||||
...(model ? { model } : {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
<AiToolIcon tool={{ id, label: aiTools.label(id) }} className="h-4 w-4 !p-0" />
|
||||
{aiTools.label(id)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="my-1 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={item}
|
||||
onClick={() => navigate("/settings/agents")}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
{t("tabs.agentSettings")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<span role="alert" className="ml-2 min-w-0 truncate text-xs text-status-danger">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,11 @@
|
||||
* @file LaneConsolePane.tsx
|
||||
* @description One lane's run console: a TerminalView on the pane's tmux
|
||||
* session, or — with no run — an empty pane whose button goes back to the
|
||||
* workspace home, the ONE place a new session is started (its prompt box,
|
||||
* `WorkspaceHome.tsx`; there is no run form in a pane any more). Moved out of
|
||||
* Workspace.tsx so the Workspace page can render 1, 2, or 4 of these side by
|
||||
* side (split terminal view). Owns its own handle/busy/runHistory state —
|
||||
* workspace home, where a session starts with a prompt (its prompt box,
|
||||
* `WorkspaceHome.tsx`; there is no run form in a pane — the tab bar's `+`,
|
||||
* `ConsoleTabs.tsx`, starts a bare CLI or a shell). Moved out of
|
||||
* Workspace.tsx so the Workspace page can keep one per TAB (slot 0–3) mounted,
|
||||
* the inactive ones hidden with CSS. Owns its own handle/busy/runHistory state —
|
||||
* nothing is shared between panes. `lanes`, `binaryStatus`, `activeRuns`, and
|
||||
* `externalSessions` are supplied as props because they are global, not
|
||||
* lane-specific, and fetching them per pane would mean N redundant identical
|
||||
@@ -16,8 +17,8 @@
|
||||
* and its live tmux session — instead of leaving the previous lane's terminal
|
||||
* on screen under a new lane's header.
|
||||
*
|
||||
* Each pane is one tmux session on its lane — split panes all show the same
|
||||
* lane, pane `slot` N owning `agenthub-lane-<id>[-N]` — so starting or resuming
|
||||
* Each pane is one tmux session on its lane — every tab shows the same
|
||||
* lane, tab `slot` N owning `agenthub-lane-<id>[-N]` — so starting or resuming
|
||||
* into a pane already holding a live run answers 409 ERUNLIVE rather than overwriting a
|
||||
* prompt in flight — `onResumeFromHistory` catches
|
||||
* that and attaches to the still-live session instead of failing outright
|
||||
@@ -65,7 +66,7 @@ export interface LaneConsolePaneProps {
|
||||
externalSessions?: Session[];
|
||||
wsConnected: boolean;
|
||||
onHasActiveRunChange?: (active: boolean) => void;
|
||||
/** Which split pane this is. Every pane shows the SAME lane, each in its own
|
||||
/** Which tab this is. Every tab shows the SAME lane, each in its own
|
||||
* tmux session: slot 0 is the lane's own, 1–3 are `agenthub-lane-<id>-<slot>`. */
|
||||
slot?: number;
|
||||
/** Runs the OTHER panes are showing — left out of this pane's Active runs so
|
||||
@@ -78,8 +79,8 @@ export interface LaneConsolePaneProps {
|
||||
attachRequest?: { runId: string; nonce: string } | null;
|
||||
/** Opens the workspace home, where a new session is started. */
|
||||
onNewSession?: () => void;
|
||||
/** The pane's run exiting goes back to the home too (a single pane). */
|
||||
homeOnExit?: boolean;
|
||||
/** The pane's run exited (the tab bar opens another tab, or the home). */
|
||||
onRunExit?: () => void;
|
||||
}
|
||||
|
||||
/** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */
|
||||
@@ -103,7 +104,7 @@ export function LaneConsolePane({
|
||||
onHandleChange,
|
||||
attachRequest,
|
||||
onNewSession,
|
||||
homeOnExit = false,
|
||||
onRunExit,
|
||||
}: LaneConsolePaneProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const { t: tLanes } = useTranslation("lanes");
|
||||
@@ -490,12 +491,12 @@ export function LaneConsolePane({
|
||||
runId={handle!.id}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
||||
// a single pane goes straight back to the home's prompt box rather
|
||||
// than leaving a dead terminal the user has to click out of.
|
||||
// the tab bar moves on (another tab, or the home's prompt box)
|
||||
// rather than leaving a dead terminal the user has to click out of.
|
||||
onExit={() => {
|
||||
newRun();
|
||||
refreshList();
|
||||
if (homeOnExit) onNewSession?.();
|
||||
onRunExit?.();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -134,7 +134,7 @@ describe("LaneConsolePane", () => {
|
||||
{...baseProps()}
|
||||
activeRuns={{ items: [live] }}
|
||||
onNewSession={onNewSession}
|
||||
homeOnExit
|
||||
onRunExit={onNewSession}
|
||||
/>
|
||||
);
|
||||
await screen.findByTestId("terminal-view");
|
||||
|
||||
@@ -227,7 +227,6 @@
|
||||
"runtime.stop": "■ down",
|
||||
"runtime.toggleTitle": "Boot or stop this workspace's own application stack (separate from its Claude run)",
|
||||
"splitView.emptyPane": "No workspace selected for this pane.",
|
||||
"splitView.paneCount": "{{count}} pane",
|
||||
"splitView.paneLaneLabel": "Pane workspace selector",
|
||||
"splitView.pickLane": "Pick a workspace",
|
||||
"status.failed": "failed",
|
||||
@@ -313,8 +312,6 @@
|
||||
"menu.copyPath": "Copy path",
|
||||
"menu.reset": "Reset worktree…",
|
||||
"menu.remove": "Delete workspace…",
|
||||
"pane.slot": "slot {{n}}",
|
||||
"pane.close": "Stop this pane's session",
|
||||
"panel.files": "Files",
|
||||
"panel.changes": "Changes",
|
||||
"panel.analyst": "Analyst",
|
||||
@@ -392,5 +389,12 @@
|
||||
"home.missing": "missing",
|
||||
"home.unsupported": "{{tool}} cannot take instructions, context files or extra folders: they are kept, but its sessions start without them.",
|
||||
"home.back": "Back to the workspace home",
|
||||
"home.close": "Close"
|
||||
"home.close": "Close",
|
||||
"tabs.label": "Session tabs",
|
||||
"tabs.new": "New tab",
|
||||
"tabs.newTerminal": "New terminal",
|
||||
"tabs.terminal": "Terminal",
|
||||
"tabs.close": "Close tab (stops its session)",
|
||||
"tabs.full": "All 4 tabs are in use — close one first",
|
||||
"tabs.agentSettings": "Agent settings…"
|
||||
}
|
||||
|
||||
@@ -227,7 +227,6 @@
|
||||
"runtime.stop": "■ dừng",
|
||||
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của workspace này (khác với phiên Claude của nó)",
|
||||
"splitView.emptyPane": "Chưa chọn workspace cho ô này.",
|
||||
"splitView.paneCount": "{{count}} ô",
|
||||
"splitView.paneLaneLabel": "Bộ chọn workspace cho ô",
|
||||
"splitView.pickLane": "Chọn workspace",
|
||||
"status.failed": "thất bại",
|
||||
@@ -313,8 +312,6 @@
|
||||
"menu.copyPath": "Sao chép đường dẫn",
|
||||
"menu.reset": "Đặt lại worktree…",
|
||||
"menu.remove": "Xóa workspace…",
|
||||
"pane.slot": "ô {{n}}",
|
||||
"pane.close": "Dừng phiên của ô này",
|
||||
"panel.files": "Tệp",
|
||||
"panel.changes": "Thay đổi",
|
||||
"panel.analyst": "Analyst",
|
||||
@@ -392,5 +389,12 @@
|
||||
"home.missing": "không còn",
|
||||
"home.unsupported": "{{tool}} không nhận được hướng dẫn, file ngữ cảnh hay thư mục bổ sung: chúng vẫn được giữ, nhưng session của nó khởi động mà không có chúng.",
|
||||
"home.back": "Quay lại trang workspace",
|
||||
"home.close": "Đóng"
|
||||
"home.close": "Đóng",
|
||||
"tabs.label": "Các tab phiên",
|
||||
"tabs.new": "Tab mới",
|
||||
"tabs.newTerminal": "Terminal mới",
|
||||
"tabs.terminal": "Terminal",
|
||||
"tabs.close": "Đóng tab (dừng phiên của nó)",
|
||||
"tabs.full": "Đã dùng hết 4 tab — hãy đóng bớt một tab",
|
||||
"tabs.agentSettings": "Cài đặt agent…"
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* @file splitViewStorage.test.ts
|
||||
* @description Tests for the splitViewStorage module.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
readSplitViewState,
|
||||
writeSplitViewState,
|
||||
defaultSplitViewState,
|
||||
} from "../splitViewStorage";
|
||||
|
||||
describe("splitViewStorage", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("returns the default state when nothing is stored", () => {
|
||||
expect(readSplitViewState()).toEqual(defaultSplitViewState());
|
||||
});
|
||||
|
||||
it("defaults to a single unselected pane", () => {
|
||||
expect(defaultSplitViewState()).toEqual({ layout: 1, paneLaneIds: [null] });
|
||||
});
|
||||
|
||||
it("round-trips a written state", () => {
|
||||
writeSplitViewState({ layout: 4, paneLaneIds: [1, 2, null, null] });
|
||||
expect(readSplitViewState()).toEqual({ layout: 4, paneLaneIds: [1, 2, null, null] });
|
||||
});
|
||||
|
||||
it("falls back to the default when stored JSON is malformed", () => {
|
||||
localStorage.setItem("agenthub.workspace.splitView", "{not json");
|
||||
expect(readSplitViewState()).toEqual(defaultSplitViewState());
|
||||
});
|
||||
|
||||
it("falls back to the default when the stored layout is not 1, 2, or 4", () => {
|
||||
localStorage.setItem(
|
||||
"agenthub.workspace.splitView",
|
||||
JSON.stringify({ layout: 3, paneLaneIds: [] })
|
||||
);
|
||||
expect(readSplitViewState()).toEqual(defaultSplitViewState());
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
/**
|
||||
* @file laneOrderStorage.ts
|
||||
* @description Persists the Workspace lane strip's user-chosen card order to
|
||||
* localStorage, the same read/validate/fallback convention as
|
||||
* splitViewStorage.ts.
|
||||
* localStorage, the same read/validate/fallback convention as useTheme.ts.
|
||||
*
|
||||
* The order lives in the browser rather than the lanes table on purpose: it is
|
||||
* a per-viewer preference, like the split-pane layout beside it, and nothing
|
||||
* a per-viewer preference, and nothing
|
||||
* server-side (the CLI's `agenthub lanes`, the pipeline-stats picker) has an
|
||||
* opinion about it. Ceiling: the order does not follow the user to another
|
||||
* browser or to the CLI — move it to a `lanes.sort_order` column if it ever
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* @file splitViewStorage.ts
|
||||
* @description Persists the Workspace page's split-terminal layout (1/2/4
|
||||
* panes) and each pane's chosen lane id to localStorage, so the layout
|
||||
* survives a page reload. Follows the same read/write-with-fallback
|
||||
* convention as useTheme.ts.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export type SplitLayout = 1 | 2 | 4;
|
||||
|
||||
export interface SplitViewState {
|
||||
layout: SplitLayout;
|
||||
paneLaneIds: (number | null)[];
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "agenthub.workspace.splitView";
|
||||
|
||||
export function defaultSplitViewState(): SplitViewState {
|
||||
return { layout: 1, paneLaneIds: [null] };
|
||||
}
|
||||
|
||||
function isValidLayout(value: unknown): value is SplitLayout {
|
||||
return value === 1 || value === 2 || value === 4;
|
||||
}
|
||||
|
||||
function isValidState(value: unknown): value is SplitViewState {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
isValidLayout(v.layout) &&
|
||||
Array.isArray(v.paneLaneIds) &&
|
||||
v.paneLaneIds.every((id) => id === null || typeof id === "number")
|
||||
);
|
||||
}
|
||||
|
||||
export function readSplitViewState(): SplitViewState {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return defaultSplitViewState();
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isValidState(parsed) ? parsed : defaultSplitViewState();
|
||||
} catch {
|
||||
return defaultSplitViewState();
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSplitViewState(state: SplitViewState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
/* ignore quota / disabled storage */
|
||||
}
|
||||
}
|
||||
+67
-216
@@ -4,8 +4,9 @@
|
||||
* (CLI spawns: Claude Code or Codex, determined per lane). The workspace shown
|
||||
* is picked in the sidebar's project tree and named by `?lane=<id>` (mirrored to
|
||||
* `agenthub.workspace.selected`, so a bare /workspace reopens the last one).
|
||||
* Shows the selected lane's one-row header, pipeline map and run console (1, 2
|
||||
* or 4 panes, each split pane with its own header), and the right panel —
|
||||
* Shows the selected lane's one-row header, pipeline map and run console (a
|
||||
* tab per live tmux slot, 0–3, with a `+` menu — `ConsoleTabs.tsx`), and the
|
||||
* right panel —
|
||||
* Files · Changes · Sessions. Runs are tied to lanes: starting a run
|
||||
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
|
||||
* isn't owned by any lane, calls POST /api/lanes/ensure first. Polls GET
|
||||
@@ -43,15 +44,13 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Square,
|
||||
Columns2,
|
||||
Grid2x2,
|
||||
Layers,
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
@@ -59,7 +58,6 @@ import {
|
||||
FolderTree,
|
||||
GitCompare,
|
||||
ChartColumn,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||
@@ -67,9 +65,8 @@ import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
import { ConsoleTabs } from "../components/run/ConsoleTabs";
|
||||
import PipelineMap from "../components/lanes/PipelineMap";
|
||||
import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard";
|
||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||
@@ -77,8 +74,6 @@ import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||||
import { SessionReplay } from "../components/lanes/SessionReplay";
|
||||
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
|
||||
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
||||
|
||||
/** localStorage key for the Changes panel's open/closed state. */
|
||||
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
||||
@@ -119,104 +114,19 @@ const CHANGES_NEXT_ICON: Record<ChangesSize, typeof Columns2> = {
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** One pane, two side by side, four in a grid — the conventional icons for
|
||||
* each, so the toggle reads at a glance without spending three words on it. */
|
||||
const LAYOUT_CHOICES = [
|
||||
{ n: 1 as const, Icon: Square },
|
||||
{ n: 2 as const, Icon: Columns2 },
|
||||
{ n: 4 as const, Icon: Grid2x2 },
|
||||
];
|
||||
|
||||
function SplitLayoutToggle({
|
||||
splitView,
|
||||
setLayout,
|
||||
}: {
|
||||
splitView: SplitViewState;
|
||||
setLayout: (layout: SplitLayout) => void;
|
||||
}) {
|
||||
const { t: tLanes } = useTranslation("lanes");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{LAYOUT_CHOICES.map(({ n, Icon }) => {
|
||||
// The count still reaches assistive tech and the tooltip — only the
|
||||
// rendered word is gone, and the icon says the same thing in the
|
||||
// shape every other app uses for it.
|
||||
const label = tLanes("splitView.paneCount", { count: n });
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
aria-pressed={splitView.layout === n}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={() => setLayout(n)}
|
||||
className={`rounded border p-1.5 ${
|
||||
splitView.layout === n
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-border text-fg-secondary hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A split pane's one-line header: the run's AI tool, which lane, which slot, the run's model,
|
||||
* and — for slots 1–3 — a button that kills that pane's own tmux session
|
||||
* (`DELETE /api/run/<runId>`). Slot 0 is the lane's run and has no close. */
|
||||
function PaneHeader({
|
||||
lane,
|
||||
slot,
|
||||
runId,
|
||||
model,
|
||||
tool,
|
||||
}: {
|
||||
lane: Lane | null;
|
||||
slot: number;
|
||||
runId: string | null;
|
||||
model: string | null;
|
||||
/** The tool the pane's run is on — a session's own, not the workspace's. */
|
||||
tool: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const tools = useAiTools();
|
||||
return (
|
||||
<div
|
||||
data-testid={`pane-header-${slot}`}
|
||||
className="flex h-6 shrink-0 items-center gap-1.5 rounded border border-border bg-surface-2 px-2 text-[11px] text-fg-muted"
|
||||
>
|
||||
{tool && (
|
||||
<AiToolIcon tool={{ id: tool, label: tools.label(tool) }} className="h-3.5 w-3.5 !p-0" />
|
||||
)}
|
||||
<span className="min-w-0 truncate font-medium text-fg-secondary">
|
||||
{lane ? lane.title || lane.cwd.split("/").pop() : ""}
|
||||
</span>
|
||||
<span className="shrink-0">{t("pane.slot", { n: slot })}</span>
|
||||
{model && <span className="min-w-0 truncate">{model}</span>}
|
||||
{slot > 0 && runId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void api.run.kill(runId).catch(() => undefined)}
|
||||
aria-label={t("pane.close")}
|
||||
title={t("pane.close")}
|
||||
className="ml-auto shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-status-danger"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
/** The pane slot a run id names (`agenthub-lane-<id>[-<slot>]`). */
|
||||
function slotOf(runId: string | null | undefined): number {
|
||||
const m = runId?.match(/^agenthub-lane-\d+(?:-([1-3]))?$/);
|
||||
return m ? Number(m[1] || 0) : 0;
|
||||
}
|
||||
|
||||
/** The session view's console: a tab bar over one `LaneConsolePane` per slot
|
||||
* (0–3, each its own tmux session on the selected lane). The active tab is
|
||||
* the URL's `?run=`; every other tab with a live run stays MOUNTED, hidden
|
||||
* with CSS, so switching tabs never drops a terminal. */
|
||||
function ConsoleArea({
|
||||
lanes,
|
||||
selectedLaneId,
|
||||
splitView,
|
||||
setLayout,
|
||||
binaryStatus,
|
||||
activeRuns,
|
||||
externalSessions,
|
||||
@@ -224,14 +134,13 @@ function ConsoleArea({
|
||||
onHasActiveRunChange,
|
||||
onLaneCreated,
|
||||
onLaneIdChange,
|
||||
showToggle = true,
|
||||
attachRequest,
|
||||
onNewSession,
|
||||
onOpenRun,
|
||||
onRunsChanged,
|
||||
}: {
|
||||
lanes: Lane[];
|
||||
selectedLaneId: number | null;
|
||||
splitView: SplitViewState;
|
||||
setLayout: (layout: SplitLayout) => void;
|
||||
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
|
||||
activeRuns: RunListResponse | null;
|
||||
externalSessions: Session[];
|
||||
@@ -239,44 +148,57 @@ function ConsoleArea({
|
||||
onHasActiveRunChange: (val: boolean) => void;
|
||||
onLaneCreated: (lane: Lane) => void;
|
||||
onLaneIdChange: (id: number | null) => void;
|
||||
showToggle?: boolean;
|
||||
/** A live run to attach a pane to, from the sidebar (`?run=`, `&pane=`). */
|
||||
attachRequest: { runId: string; nonce: string; pane: number } | null;
|
||||
/** The opened run (`?run=`): its tab is the active one. */
|
||||
attachRequest: { runId: string; nonce: string } | null;
|
||||
/** Back to the workspace home, where a new session is started. */
|
||||
onNewSession?: () => void;
|
||||
/** Opens a tab's run (`?run=`). */
|
||||
onOpenRun: (runId: string) => void;
|
||||
/** A tab was opened or closed: re-read the run list now. */
|
||||
onRunsChanged: () => void;
|
||||
}) {
|
||||
// Every split pane shows the SELECTED lane, each in its own tmux session
|
||||
// (slot = pane index). Tracking what each pane shows lets the others leave
|
||||
// that run out of their Active runs, so no terminal is attached twice.
|
||||
const [paneRunIds, setPaneRunIds] = useState<(string | null)[]>([]);
|
||||
const onPaneHandle = useCallback((i: number, id: string | null) => {
|
||||
setPaneRunIds((prev) => {
|
||||
if (prev[i] === id) return prev;
|
||||
const next = [...prev];
|
||||
next[i] = id;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const paneHandlers = useMemo(
|
||||
() => [0, 1, 2, 3].map((i) => (id: string | null) => onPaneHandle(i, id)),
|
||||
[onPaneHandle]
|
||||
);
|
||||
const hiddenFor = useMemo(
|
||||
const lane = lanes.find((l) => l.id === selectedLaneId) ?? null;
|
||||
const tabs = useMemo(
|
||||
() =>
|
||||
[0, 1, 2, 3].map((i) =>
|
||||
paneRunIds.filter((id, j): id is string => j !== i && j < splitView.layout && !!id)
|
||||
),
|
||||
[paneRunIds, splitView.layout]
|
||||
(activeRuns?.items ?? [])
|
||||
.filter((r) => r.laneId === selectedLaneId && r.status === "running")
|
||||
.sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0)),
|
||||
[activeRuns, selectedLaneId]
|
||||
);
|
||||
const activeSlot = slotOf(attachRequest?.runId);
|
||||
const slots = [...new Set([activeSlot, ...tabs.map((r) => r.slot ?? 0)])].sort();
|
||||
// Off the active tab: the next one still open, else the home.
|
||||
const leave = (slot: number) => {
|
||||
const next = tabs.find((r) => (r.slot ?? 0) !== slot);
|
||||
if (next) onOpenRun(next.id);
|
||||
else onNewSession?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showToggle && <SplitLayoutToggle splitView={splitView} setLayout={setLayout} />}
|
||||
{splitView.layout === 1 ? (
|
||||
{lane && (
|
||||
<ConsoleTabs
|
||||
lane={lane}
|
||||
tabs={tabs}
|
||||
activeRunId={attachRequest?.runId ?? null}
|
||||
onOpen={(runId) => {
|
||||
onRunsChanged();
|
||||
onOpenRun(runId);
|
||||
}}
|
||||
onClosed={(runId) => {
|
||||
onRunsChanged();
|
||||
if (slotOf(runId) === activeSlot) leave(activeSlot);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{slots.map((slot) => (
|
||||
<div key={slot} className={slot === activeSlot ? "contents" : "hidden"}>
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
onHandleChange={paneHandlers[0]}
|
||||
slot={slot}
|
||||
// The other tabs' runs: never attached twice.
|
||||
hiddenRunIds={tabs.filter((r) => (r.slot ?? 0) !== slot).map((r) => r.id)}
|
||||
showLaneSelector={false}
|
||||
onLaneIdChange={onLaneIdChange}
|
||||
onLaneCreated={onLaneCreated}
|
||||
@@ -284,46 +206,15 @@ function ConsoleArea({
|
||||
activeRuns={activeRuns}
|
||||
externalSessions={externalSessions}
|
||||
wsConnected={wsConnected}
|
||||
onHasActiveRunChange={onHasActiveRunChange}
|
||||
onHasActiveRunChange={slot === activeSlot ? onHasActiveRunChange : undefined}
|
||||
onNewSession={onNewSession}
|
||||
homeOnExit
|
||||
attachRequest={attachRequest?.pane === 0 ? attachRequest : null}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`grid flex-1 min-h-0 gap-3 ${
|
||||
splitView.layout === 2 ? "grid-cols-2" : "grid-cols-2 grid-rows-2"
|
||||
}`}
|
||||
>
|
||||
{Array.from({ length: splitView.layout }, (_, i) => (
|
||||
<div key={i} className="flex min-h-0 min-w-0 flex-col gap-1">
|
||||
<PaneHeader
|
||||
lane={lanes.find((l) => l.id === selectedLaneId) ?? null}
|
||||
slot={i}
|
||||
runId={paneRunIds[i] ?? null}
|
||||
model={activeRuns?.items.find((r) => r.id === paneRunIds[i])?.model ?? null}
|
||||
tool={activeRuns?.items.find((r) => r.id === paneRunIds[i])?.tool ?? null}
|
||||
/>
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
slot={i}
|
||||
hiddenRunIds={hiddenFor[i]}
|
||||
onHandleChange={paneHandlers[i]}
|
||||
showLaneSelector={false}
|
||||
onLaneIdChange={onLaneIdChange}
|
||||
onLaneCreated={onLaneCreated}
|
||||
binaryStatus={binaryStatus}
|
||||
activeRuns={activeRuns}
|
||||
externalSessions={externalSessions}
|
||||
wsConnected={wsConnected}
|
||||
onNewSession={onNewSession}
|
||||
attachRequest={attachRequest?.pane === i ? attachRequest : null}
|
||||
onRunExit={() => slot === activeSlot && leave(slot)}
|
||||
attachRequest={
|
||||
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -346,12 +237,9 @@ export function Workspace() {
|
||||
// conversation, with a prompt box whose message resumes it (SessionReplay).
|
||||
const sessionParam = runParam ? null : searchParams.get("session");
|
||||
const navigate = useNavigate();
|
||||
// `&pane=<n>` — which split pane attaches it (Ctrl/Cmd+click in the sidebar
|
||||
// asks for pane 1); pane 0 otherwise.
|
||||
const paneParam = Math.min(Math.max(Number(searchParams.get("pane")) || 0, 0), 3);
|
||||
const attachRequest = useMemo(
|
||||
() => (runParam ? { runId: runParam, nonce: location.key, pane: paneParam } : null),
|
||||
[runParam, location.key, paneParam]
|
||||
() => (runParam ? { runId: runParam, nonce: location.key } : null),
|
||||
[runParam, location.key]
|
||||
);
|
||||
const paramId = laneParam === null ? null : Number(laneParam);
|
||||
const fallbackId = (() => {
|
||||
@@ -427,8 +315,6 @@ export function Workspace() {
|
||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
|
||||
|
||||
// Split view state
|
||||
const [splitView, setSplitView] = useState<SplitViewState>(() => readSplitViewState());
|
||||
const [panelTab, setPanelTab] = useState<PanelTab>(() => {
|
||||
try {
|
||||
const saved = window.localStorage.getItem(PANEL_TAB_KEY) as PanelTab | null;
|
||||
@@ -483,30 +369,11 @@ export function Workspace() {
|
||||
? tLanes("changes.collapse")
|
||||
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
|
||||
|
||||
const setLayout = useCallback((layout: SplitLayout) => {
|
||||
setSplitView((prev) => {
|
||||
const paneLaneIds = Array.from({ length: layout }, (_, i) => prev.paneLaneIds[i] ?? null);
|
||||
const next = { layout, paneLaneIds };
|
||||
writeSplitViewState(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// The session the opened run holds, when that run works in a worktree of
|
||||
// its own (`claude --worktree`): the Changes panel shows that tree.
|
||||
const openedRun = runParam ? activeRuns?.items.find((r) => r.id === runParam) : undefined;
|
||||
const gitSession = openedRun?.worktree && openedRun.sessionId ? openedRun.sessionId : undefined;
|
||||
|
||||
// A pane the layout does not show yet (`&pane=1` on one pane) widens the
|
||||
// split so the run lands where it was asked for — once per request, so the
|
||||
// user can still go back to one pane afterwards.
|
||||
const layoutRef = useRef(splitView.layout);
|
||||
layoutRef.current = splitView.layout;
|
||||
useEffect(() => {
|
||||
const pane = attachRequest?.pane ?? 0;
|
||||
if (pane >= layoutRef.current) setLayout(pane < 2 ? 2 : 4);
|
||||
}, [attachRequest, setLayout]);
|
||||
|
||||
// Probe the CLI of every tool a lane uses — fresh on the server, so a CLI
|
||||
// installed since the page loaded clears its missing-binary banner. Claude is
|
||||
// always probed: the console's no-lane state runs it.
|
||||
@@ -536,20 +403,6 @@ export function Workspace() {
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
// Clean up stale pane lane IDs when lanes load
|
||||
useEffect(() => {
|
||||
if (!lanes.length) return;
|
||||
setSplitView((prev) => {
|
||||
const paneLaneIds = prev.paneLaneIds.map((id) =>
|
||||
id !== null && lanes.some((l) => l.id === id) ? id : null
|
||||
);
|
||||
if (paneLaneIds.every((id, i) => id === prev.paneLaneIds[i])) return prev;
|
||||
const next = { ...prev, paneLaneIds };
|
||||
writeSplitViewState(next);
|
||||
return next;
|
||||
});
|
||||
}, [lanes]);
|
||||
|
||||
// Both setters go through `keepIfSame`: these fire every 5 s below, and a
|
||||
// fresh-but-identical payload would otherwise re-render the lane strip, the
|
||||
// lane card and the whole console area on every tick for nothing.
|
||||
@@ -896,7 +749,6 @@ export function Workspace() {
|
||||
{/* Per-lane actions (mcp sync, sync-base check)
|
||||
belong beside the lane they act on, not in a page header. */}
|
||||
<LaneToolsMenu lane={currentLane} />
|
||||
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -986,16 +838,12 @@ export function Workspace() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* `showToggle`: the lane header carries its own split toggle; with
|
||||
no lane there is no header, so the console shows one instead.
|
||||
A replay only HIDES it: the console has one render site and a
|
||||
{/* A replay only HIDES the console: it has one render site and a
|
||||
live terminal must never unmount. */}
|
||||
<div className={replaying ? "hidden" : "contents"}>
|
||||
<ConsoleArea
|
||||
lanes={lanes}
|
||||
selectedLaneId={selectedLaneId}
|
||||
splitView={splitView}
|
||||
setLayout={setLayout}
|
||||
binaryStatus={binaryStatus}
|
||||
activeRuns={activeRuns}
|
||||
externalSessions={externalSessions}
|
||||
@@ -1003,13 +851,16 @@ export function Workspace() {
|
||||
onHasActiveRunChange={setPaneHasActiveRun}
|
||||
onLaneCreated={() => void refreshLanes()}
|
||||
onLaneIdChange={setSelectedLaneId}
|
||||
showToggle={!currentLane}
|
||||
attachRequest={attachRequest}
|
||||
onNewSession={
|
||||
currentLane
|
||||
? () => setSearchParams({ lane: String(currentLane.id) })
|
||||
: undefined
|
||||
}
|
||||
onOpenRun={(runId) =>
|
||||
currentLane && setSearchParams({ lane: String(currentLane.id), run: runId })
|
||||
}
|
||||
onRunsChanged={refreshList}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,6 +59,7 @@ vi.mock("../../components/lanes/WorkspaceHome", () => ({
|
||||
WorkspaceHome: () => <div data-testid="workspace-home" />,
|
||||
paneRunId: (id: number, slot: number) =>
|
||||
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
|
||||
freeSlot: () => 0,
|
||||
}));
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file Workspace.test.tsx
|
||||
* @description Workspace page integration tests covering the lane-based run flow.
|
||||
* Verifies: lanes API contract, the home vs session view, split panes, and
|
||||
* Verifies: lanes API contract, the home vs session view, the console tabs, and
|
||||
* that a pane has no run form of its own (a new session starts from the home).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -56,6 +56,12 @@ vi.mock("../../components/lanes/WorkspaceHome", () => ({
|
||||
WorkspaceHome: () => <div data-testid="workspace-home" />,
|
||||
paneRunId: (id: number, slot: number) =>
|
||||
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
|
||||
freeSlot: (laneId: number, runs: Array<{ laneId: number; slot?: number; status: string }>) => {
|
||||
const busy = new Set(
|
||||
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
|
||||
);
|
||||
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
|
||||
},
|
||||
}));
|
||||
// An ended session's replay (its own suite): a stub that names its session.
|
||||
vi.mock("../../components/lanes/SessionReplay", () => ({
|
||||
@@ -536,50 +542,66 @@ describe("Workspace — proof gallery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("split terminal view", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
describe("console tabs", () => {
|
||||
const live = (slot: number, tool = "claude") => ({
|
||||
id: slot ? `agenthub-lane-1-${slot}` : "agenthub-lane-1",
|
||||
laneId: 1,
|
||||
slot,
|
||||
status: "running",
|
||||
tool,
|
||||
model: null,
|
||||
promptPreview: null,
|
||||
});
|
||||
|
||||
it("defaults to a single pane with no layout toggle pressed state implying 2 or 4", async () => {
|
||||
await renderWorkspace();
|
||||
expect(screen.getAllByTestId("console-body")).toHaveLength(1);
|
||||
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
|
||||
const original = vi.mocked(api.run.list).getMockImplementation();
|
||||
afterEach(() => {
|
||||
vi.mocked(api.run.list).mockImplementation(original!);
|
||||
});
|
||||
|
||||
it("switching to 2-pane layout renders two panes bound to the selected lane", async () => {
|
||||
await renderWorkspace();
|
||||
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
|
||||
await settle();
|
||||
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
|
||||
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("switching to 4-pane layout renders four panes", async () => {
|
||||
await renderWorkspace();
|
||||
fireEvent.click(screen.getByRole("button", { name: /4.*pane/i }));
|
||||
await settle();
|
||||
expect(screen.getAllByTestId("console-body")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("persists the layout to localStorage across remounts", async () => {
|
||||
const { unmount } = await renderWorkspace();
|
||||
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
|
||||
await settle();
|
||||
unmount();
|
||||
|
||||
await renderWorkspace();
|
||||
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("ignores a persisted per-pane lane id: every pane follows the selected lane", async () => {
|
||||
localStorage.setItem(
|
||||
"agenthub.workspace.splitView",
|
||||
JSON.stringify({ layout: 2, paneLaneIds: [9999, null] })
|
||||
const withRuns = (items: ReturnType<typeof live>[]) =>
|
||||
vi.mocked(api.run.list).mockResolvedValue({ runs: [], items } as never);
|
||||
const open = async (url: string) => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[url]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
};
|
||||
|
||||
it("shows a tab per live session and keeps every tab's pane mounted, showing only the active one", async () => {
|
||||
withRuns([live(0), live(2, "shell")]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1-2");
|
||||
expect(screen.getByTestId("console-tab-0")).toHaveAttribute("aria-selected", "false");
|
||||
expect(screen.getByTestId("console-tab-2")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByTestId("console-tab-2")).toHaveTextContent("Terminal");
|
||||
const bodies = screen.getAllByTestId("console-body");
|
||||
expect(bodies).toHaveLength(2);
|
||||
expect(bodies.filter((b) => b.closest(".hidden"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("opens a new terminal in the first free slot from the + menu", async () => {
|
||||
withRuns([live(0)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("console-tab-new"));
|
||||
await user.click(within(screen.getByTestId("console-tab-menu")).getByText("New terminal"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 1, shell: true });
|
||||
});
|
||||
|
||||
it("disables + once all four slots are taken", async () => {
|
||||
withRuns([live(0), live(1), live(2), live(3)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
expect(screen.getByTestId("console-tab-new")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("closing the active tab stops its session and opens the next one", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-close-0"));
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true")
|
||||
);
|
||||
await renderWorkspace();
|
||||
expect(screen.queryAllByTestId("pane-empty")).toHaveLength(0);
|
||||
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -638,9 +660,10 @@ describe("Workspace — home and session view", () => {
|
||||
});
|
||||
|
||||
it("has no run form in a pane: an empty pane's New session opens the home", async () => {
|
||||
// The run opens in pane 1, so pane 0 is left with nothing to show.
|
||||
// Nothing live in slot 0: its pane is empty.
|
||||
vi.mocked(api.run.get).mockRejectedValueOnce(new Error("gone"));
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1-1&pane=1"]}>
|
||||
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -679,17 +702,4 @@ describe("Workspace — home and session view", () => {
|
||||
vi.mocked(api.run.list).mockImplementation(original!);
|
||||
localStorage.removeItem("agenthub.workspace.changesOpen");
|
||||
});
|
||||
|
||||
it("widens one pane to two for &pane=1, so the run opens beside the first", async () => {
|
||||
localStorage.removeItem("agenthub.workspace.splitView");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1&pane=1"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.workspace.splitView")!).layout).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5410,102 +5410,8 @@ exports[`screen snapshots > Run 1`] = `
|
||||
class="contents"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1.5"
|
||||
class="contents"
|
||||
>
|
||||
<button
|
||||
aria-label="1 pane"
|
||||
aria-pressed="true"
|
||||
class="rounded border p-1.5 border-accent bg-accent/15 text-accent"
|
||||
title="1 pane"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-square h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="2 pane"
|
||||
aria-pressed="false"
|
||||
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
|
||||
title="2 pane"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-columns2 h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
<path
|
||||
d="M12 3v18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="4 pane"
|
||||
aria-pressed="false"
|
||||
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
|
||||
title="4 pane"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-grid2x2 h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 3v18"
|
||||
/>
|
||||
<path
|
||||
d="M3 12h18"
|
||||
/>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-5"
|
||||
data-testid="console-body"
|
||||
@@ -5608,6 +5514,7 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -279,7 +279,8 @@ When the session's tool is Codex, three fields control Codex-specific behavior:
|
||||
- **`router: "<name>"`** — the AI Router profile THIS session runs on (omitted = native, or for a resume the router its session last ran on); see § AI Router profiles. `400 EBADROUTER` for an unknown name, `400 EUNSUPPORTED` on a tool other than Claude/Codex.
|
||||
- **`worktree: true`** (Claude workspaces, no `resumeSessionId`) — a NEW session in a git worktree of its own, Claude Code's way: the pane runs `claude --worktree=ah-<id>-<base36 time>` (the `=` form, since `-w [name]` would take the prompt as the name; the name is made by the server), so Claude itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. `/.claude/worktrees/` is added to the repo root's `info/exclude`; the run is recorded with `worktree: 1` (and the run handle carries `worktree: true`). `400 EUNSUPPORTED` on any other tool. A `resumeSessionId` (or fork) of a session that ran in such a worktree starts the pane IN that worktree; one whose worktree is gone is `400 ENOWORKTREE`.
|
||||
|
||||
- **`slot`** — which Workspace split pane's session to start, `0`–`3` (default `0`, else `400 EBADSLOT`). Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
|
||||
- **`slot`** — which Workspace tab's session to start, `0`–`3` (default `0`, else `400 EBADSLOT`). Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
|
||||
- **`shell`** — `true` opens a plain shell in that slot instead of an AI tool (the Workspace's **New terminal** tab): tmux's default shell in the workspace's folder, recorded as tool `shell`. Every other field is ignored, nothing comes from the request but the slot, and the workspace's `run_id`/`status` stay as they are (no agent is at work). A slot with a live session answers `409 ERUNLIVE`.
|
||||
|
||||
Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every run the dashboard starts is a tmux session, so this is a missing dependency rather than a bad request — the message carries the install command, and the same code comes back from `POST /api/run`. Nothing else in the dashboard is affected.
|
||||
|
||||
|
||||
+6
-6
@@ -391,20 +391,20 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
||||
|
||||
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
|
||||
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar.
|
||||
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. Ctrl/Cmd+click adds `&pane=1`: the session view attaches pane 1 instead, widening a one-pane layout to two once for that request. Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
|
||||
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
|
||||
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
||||
- **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 (a pane has no run form) — 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 pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, 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.
|
||||
- **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 pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, 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 today's console, split panes and the Files / Changes panel, with pane 0 attached to that run; the header's back arrow returns to the home. 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 Files / Changes panel, with that run's tab active; the header's back arrow returns to the home. 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 lanes 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 onto another, 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 lanes` 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.
|
||||
- **Renaming a workspace** — double-click its sidebar row (or Rename in the row menu) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the folder name back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation.
|
||||
- **Workspace-detail header row** — one row, and every per-workspace control lives on it: liveness + status, the workspace's name (read-only here — renaming is on the sidebar row; no "Workspace 23 ·" prefix — the id is on the row), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (time-on-stage and both pickers only while a pipeline template is installed; there are no stage chips here — the declared stage and the `auto: <stage>` detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the **Tools** menu (mcp sync / sync-base check / create-update readme, hidden when the workspace declares no runtime profile) and the split-view toggle. The detail block under it (the workspace card, the PipelineMap, the proof gallery) is always shown — there is no info toggle any more — and Delete lives in the sidebar row menu. Tools sits here rather than in Settings because every one of its actions runs against one workspace's `cwd` — in Settings it would need a workspace picker just to say which. **Create/update readme file** (Claude workspaces only — Codex has no slash commands) starts the workspace's terminal run with `/sg-all:sg-md-writing` as its first message, so the workspace opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-workspace modal: creating a workspace and writing its docs are separate decisions, and the second is worth re-running long after the first.
|
||||
- **Workspace-detail header row** — one row, and every per-workspace control lives on it: liveness + status, the workspace's name (read-only here — renaming is on the sidebar row; no "Workspace 23 ·" prefix — the id is on the row), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (time-on-stage and both pickers only while a pipeline template is installed; there are no stage chips here — the declared stage and the `auto: <stage>` detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the **Tools** menu (mcp sync / sync-base check / create-update readme, hidden when the workspace declares no runtime profile). The detail block under it (the workspace card, the PipelineMap, the proof gallery) is always shown — there is no info toggle any more — and Delete lives in the sidebar row menu. Tools sits here rather than in Settings because every one of its actions runs against one workspace's `cwd` — in Settings it would need a workspace picker just to say which. **Create/update readme file** (Claude workspaces only — Codex has no slash commands) starts the workspace's terminal run with `/sg-all:sg-md-writing` as its first message, so the workspace opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-workspace modal: creating a workspace and writing its docs are separate decisions, and the second is worth re-running long after the first.
|
||||
- **Detail panel** — the selected workspace's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment Hidden while no pipeline template is installed (see "Pipeline stages and the five node states").
|
||||
- **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 new session starts only from the workspace home's prompt box. **New run** under the terminal goes back to the home, and so does a single pane whose process ends (a double Ctrl+C out of `claude`, `exit`, a crash); a split pane with nothing to show offers **New session**, which opens the home too.
|
||||
- **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. **New run** under the terminal goes back to the home; when the active tab's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) the next open tab is shown, or the home when none is left; a tab with nothing to show offers **New session**, which opens the home too.
|
||||
- **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.
|
||||
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). EVERY pane is bound to the sidebar's selection — there is no per-pane workspace picker — and each pane owns its own tmux session on that workspace: pane 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), panes 1–3 are `agenthub-lane-<id>-<slot>` (`POST /api/lanes/:id/start` with `slot`). In split view each pane carries a one-line header — the icon of the AI tool its run is on, workspace name, `slot N`, the run's model — and panes 1–3 a close button that kills only that pane's own session (`DELETE /api/run/agenthub-lane-<id>-<slot>`); slot 0 has none. A pane's **Active runs** leaves out the runs the other panes are showing, so one terminal is never attached twice side by side. Only the layout persists to `localStorage` (`agenthub.workspace.splitView`); a stored per-pane workspace id from an older build is ignored.
|
||||
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`), one tab per live tmux session of the workspace — at most four, its slots 0–3: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–3 are `agenthub-lane-<id>-<slot>`. A tab shows its AI tool's icon and name (or **Terminal**); the active tab is the URL's `?run=`, so the Changes panel and Analyst follow it. Every tab with a live run stays mounted and the inactive ones are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops its session (`DELETE /api/run/<runId>`) and moves to the next tab, or the home. The **+** menu opens a new tab in the first free slot: **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 four tabs open **+** is disabled. EVERY tab is bound to the sidebar's selection, and a tab's **Active runs** leaves out the other tabs' runs, so one terminal is never attached twice. The 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone.
|
||||
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
|
||||
|
||||
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
|
||||
|
||||
@@ -401,6 +401,42 @@ describe("lane start input", () => {
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// A Terminal tab: the user's own shell in the workspace folder, no AI tool —
|
||||
// so it is no agent at work and leaves the lane's run_id/status alone.
|
||||
it("opens a plain shell for `shell: true`, without marking the lane running", async () => {
|
||||
const tmux = require("../lib/tmux");
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-shell-"));
|
||||
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
|
||||
let created = null;
|
||||
try {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session" && !created) throw new Error("no such session");
|
||||
if (args[0] === "new-session") created = args;
|
||||
return "";
|
||||
});
|
||||
const r = await request("POST", `/api/lanes/${id}/start`, {
|
||||
slot: 1,
|
||||
shell: true,
|
||||
prompt: "ignored",
|
||||
tool: "codex",
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.run_id, `agenthub-lane-${id}-1`);
|
||||
assert.equal(created[created.indexOf("-s") + 1], `agenthub-lane-${id}-1`);
|
||||
assert.equal(created[created.indexOf("-c") + 1], cwd);
|
||||
// Nothing after `--`: tmux runs the user's own default shell.
|
||||
assert.equal(created[created.length - 1], "--");
|
||||
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
|
||||
assert.equal(lane.run_id, null);
|
||||
assert.notEqual(lane.status, "running");
|
||||
const run = (await request("GET", `/api/run/agenthub-lane-${id}-1`)).body;
|
||||
assert.equal(run.tool, "shell");
|
||||
} finally {
|
||||
tmux.__reset();
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook → lane binding", () => {
|
||||
|
||||
+48
-3
@@ -22,9 +22,10 @@
|
||||
* Spawns `claude`, `codex` or `agy` (Antigravity) in the tmux pane based on
|
||||
* the tool parameter.
|
||||
*
|
||||
* A lane can hold up to four sessions, one per Workspace split pane: pane 0 is
|
||||
* `agenthub-lane-<laneId>` (the lane's own `run_id`), panes 1–3 are
|
||||
* `agenthub-lane-<laneId>-<slot>`.
|
||||
* A lane can hold up to four sessions, one per Workspace tab: slot 0 is
|
||||
* `agenthub-lane-<laneId>` (the lane's own `run_id`), slots 1–3 are
|
||||
* `agenthub-lane-<laneId>-<slot>`. A slot holds an AI tool (`spawnRun`) or a
|
||||
* plain shell (`spawnShell`, a Terminal tab).
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -498,6 +499,49 @@ function spawnRun(args) {
|
||||
return getRun(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A Workspace "Terminal" tab: the user's own default shell (tmux's, run with
|
||||
* nothing after `--`) in a lane's pane slot. No AI tool, no prompt, nothing
|
||||
* from the request but the slot — the cwd is the lane's. Recorded as tool
|
||||
* `shell` so the tab can say what it is. The caller has already made sure the
|
||||
* slot holds no live session.
|
||||
*/
|
||||
function spawnShell({ laneId, slot = 0, cwd }) {
|
||||
if (!Number.isInteger(laneId)) throw makeErr("EBADLANE", "laneId must be an integer");
|
||||
if (!Number.isInteger(slot) || slot < 0 || slot > MAX_PANE_SLOT) {
|
||||
throw makeErr("EBADSLOT", `slot must be an integer 0–${MAX_PANE_SLOT}`);
|
||||
}
|
||||
if (!tmux.isTmuxAvailable()) throw makeErr("ETMUXMISSING", "tmux is not installed");
|
||||
const id = runIdForLane(laneId, slot);
|
||||
const env = {
|
||||
AGENTHUB_LANE_ID: String(laneId),
|
||||
AGENTHUB_RUN_ID: id,
|
||||
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
||||
...desktopEnv(),
|
||||
};
|
||||
tmux.newSession({ name: id, cwd, argv: [], env });
|
||||
dashboardRuns?.recordRun({
|
||||
id,
|
||||
sessionId: null,
|
||||
mode: "interactive",
|
||||
cwd,
|
||||
tool: "shell",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
worktree: false,
|
||||
router: null,
|
||||
prompt: "",
|
||||
status: "running",
|
||||
startedAt: Date.now(),
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
laneId,
|
||||
});
|
||||
return getRun(id);
|
||||
}
|
||||
|
||||
// Only a dashboard lane pane is a run. Without this, `DELETE /api/run/main`
|
||||
// would kill the user's own tmux session named `main`.
|
||||
function killRun(id) {
|
||||
@@ -544,6 +588,7 @@ function listRuns() {
|
||||
|
||||
module.exports = {
|
||||
spawnRun,
|
||||
spawnShell,
|
||||
killRun,
|
||||
getRun,
|
||||
listRuns,
|
||||
|
||||
@@ -1785,6 +1785,31 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
error: { code: "EBADSLOT", message: `slot must be an integer 0–${runs.MAX_PANE_SLOT}` },
|
||||
});
|
||||
}
|
||||
// A Terminal tab: a plain shell in the slot. No agent is at work in
|
||||
// it, so the lane's run_id/status stay as they are.
|
||||
if (body.shell === true) {
|
||||
const shellOutcome = await withLaneLock(lane.id, async () => {
|
||||
const current = lanesLib.getLane(lane.id);
|
||||
if (!current) return { missing: true };
|
||||
const id = runs.runIdForLane(current.id, slot);
|
||||
if (runs.getRun(id)?.status === "running") return { conflict: true };
|
||||
startedRunId = runs.spawnShell({ laneId: current.id, slot, cwd: current.cwd }).id;
|
||||
return {};
|
||||
});
|
||||
if (shellOutcome.missing) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
}
|
||||
if (shellOutcome.conflict) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({
|
||||
error: { code: "ERUNLIVE", message: "that tab already has a live session" },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
// A session that ran in a worktree of its own resumes (or forks) THERE:
|
||||
// Claude finds a session by the folder it ran in. Resolved from the
|
||||
// session row, before the lock; anything else starts as it always did.
|
||||
|
||||
Reference in New Issue
Block a user