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 */
|
||||
}
|
||||
}
|
||||
+79
-228
@@ -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,91 +148,73 @@ 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 ? (
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
onHandleChange={paneHandlers[0]}
|
||||
showLaneSelector={false}
|
||||
onLaneIdChange={onLaneIdChange}
|
||||
onLaneCreated={onLaneCreated}
|
||||
binaryStatus={binaryStatus}
|
||||
activeRuns={activeRuns}
|
||||
externalSessions={externalSessions}
|
||||
wsConnected={wsConnected}
|
||||
onHasActiveRunChange={onHasActiveRunChange}
|
||||
onNewSession={onNewSession}
|
||||
homeOnExit
|
||||
attachRequest={attachRequest?.pane === 0 ? attachRequest : null}
|
||||
{lane && (
|
||||
<ConsoleTabs
|
||||
lane={lane}
|
||||
tabs={tabs}
|
||||
activeRunId={attachRequest?.runId ?? null}
|
||||
onOpen={(runId) => {
|
||||
onRunsChanged();
|
||||
onOpenRun(runId);
|
||||
}}
|
||||
onClosed={(runId) => {
|
||||
onRunsChanged();
|
||||
if (slotOf(runId) === activeSlot) leave(activeSlot);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{slots.map((slot) => (
|
||||
<div key={slot} className={slot === activeSlot ? "contents" : "hidden"}>
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
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}
|
||||
binaryStatus={binaryStatus}
|
||||
activeRuns={activeRuns}
|
||||
externalSessions={externalSessions}
|
||||
wsConnected={wsConnected}
|
||||
onHasActiveRunChange={slot === activeSlot ? onHasActiveRunChange : undefined}
|
||||
onNewSession={onNewSession}
|
||||
onRunExit={() => slot === activeSlot && leave(slot)}
|
||||
attachRequest={
|
||||
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
|
||||
}
|
||||
/>
|
||||
</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,200 +5410,107 @@ 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"
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-5"
|
||||
data-testid="console-body"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<header
|
||||
class="flex items-center gap-2.5"
|
||||
>
|
||||
<div
|
||||
class="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-play w-3.5 h-3.5 text-accent"
|
||||
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"
|
||||
>
|
||||
<polygon
|
||||
points="6 3 20 12 6 21 6 3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-w-0 flex-1 items-center gap-2"
|
||||
<header
|
||||
class="flex items-center gap-2.5"
|
||||
>
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2"
|
||||
class="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<h1
|
||||
class="text-base font-semibold text-fg-primary"
|
||||
<svg
|
||||
class="lucide lucide-play w-3.5 h-3.5 text-accent"
|
||||
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"
|
||||
>
|
||||
Run Claude
|
||||
</h1>
|
||||
<span
|
||||
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
|
||||
>
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
|
||||
<polygon
|
||||
points="6 3 20 12 6 21 6 3"
|
||||
/>
|
||||
Live
|
||||
</span>
|
||||
</svg>
|
||||
</div>
|
||||
<p
|
||||
class="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block"
|
||||
<div
|
||||
class="flex min-w-0 flex-1 items-center gap-2"
|
||||
>
|
||||
Streamed output, multi-turn, same hooks and analytics as your terminal.
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2"
|
||||
>
|
||||
<h1
|
||||
class="text-base font-semibold text-fg-primary"
|
||||
>
|
||||
Run Claude
|
||||
</h1>
|
||||
<span
|
||||
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
|
||||
>
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
|
||||
/>
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block"
|
||||
>
|
||||
Streamed output, multi-turn, same hooks and analytics as your terminal.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
|
||||
disabled=""
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-list-ordered w-3.5 h-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="M10 12h11"
|
||||
/>
|
||||
<path
|
||||
d="M10 18h11"
|
||||
/>
|
||||
<path
|
||||
d="M10 6h11"
|
||||
/>
|
||||
<path
|
||||
d="M4 10h2"
|
||||
/>
|
||||
<path
|
||||
d="M4 6h1v4"
|
||||
/>
|
||||
<path
|
||||
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
|
||||
/>
|
||||
</svg>
|
||||
Active runs
|
||||
</button>
|
||||
</header>
|
||||
<div
|
||||
class="flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
|
||||
data-testid="pane-no-run"
|
||||
>
|
||||
<p>
|
||||
No session in this pane. Start one from the workspace's prompt box.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
|
||||
disabled=""
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-list-ordered w-3.5 h-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="M10 12h11"
|
||||
/>
|
||||
<path
|
||||
d="M10 18h11"
|
||||
/>
|
||||
<path
|
||||
d="M10 6h11"
|
||||
/>
|
||||
<path
|
||||
d="M4 10h2"
|
||||
/>
|
||||
<path
|
||||
d="M4 6h1v4"
|
||||
/>
|
||||
<path
|
||||
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
|
||||
/>
|
||||
</svg>
|
||||
Active runs
|
||||
</button>
|
||||
</header>
|
||||
<div
|
||||
class="flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
|
||||
data-testid="pane-no-run"
|
||||
>
|
||||
<p>
|
||||
No session in this pane. Start one from the workspace's prompt box.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user