feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note. REPL `help lanes|workspace|workspaces` resolves through the aliases. - A workspace holds up to ten sessions (slots 0-9); every console tab is a group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout); parseGroups keeps the stored active tab when an earlier one is dropped. - Browse button beside path fields: POST /api/lanes/pick-path opens the OS picker on the server's desktop — loopback only and no forwarding header (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is killed when the request closes, a folder field falls back to the in-app browser on any failure. - Changes rail counts chip, image viewer (/git/raw), status bar session counts and per-day cost (`date=` on /api/pricing/cost). - /agenthub-open and /agenthub-doctor run from the INSTALLED build (supersededBy), doctor offers fixes behind two confirmations; plugin-open treats a locked bootstrap as non-fatal. - New-terminal shell waits for the first attach (issue #55). - repo-onboarding / debug-live-issue skills removed. - OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history, /api/workspaces mount.
This commit is contained in:
@@ -2870,6 +2870,14 @@ export function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Monitor's Refresh sits LEFT of the tabs: on their right it pushed
|
||||
Analytics/Monitor/Health aside every time Monitor opened.
|
||||
Analytics and Health carry their own Refresh; one per view. */}
|
||||
{monitorOn && (
|
||||
<button onClick={load} className="btn-ghost flex-shrink-0">
|
||||
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
|
||||
</button>
|
||||
)}
|
||||
{/* Tabs */}
|
||||
<div
|
||||
role="tablist"
|
||||
@@ -2892,12 +2900,6 @@ export function Dashboard() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Analytics and Health carry their own Refresh; one per view. */}
|
||||
{monitorOn && (
|
||||
<button onClick={load} className="btn-ghost flex-shrink-0">
|
||||
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* 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 pipeline map (when a template is installed) and run console (a
|
||||
* tab per live tmux slot, 0–3, with a `+` menu — `ConsoleTabs.tsx`), and the
|
||||
* tab per group of up to four split panes over the live tmux slots 0–9, 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
|
||||
@@ -84,18 +85,23 @@ import {
|
||||
type LayoutUpdate,
|
||||
PaneGrid,
|
||||
SplitMenus,
|
||||
usePaneLayout,
|
||||
usePaneGroups,
|
||||
} from "../components/run/PaneGrid";
|
||||
import {
|
||||
type Edge,
|
||||
type PaneGroups,
|
||||
type PaneLayout,
|
||||
MAX_PANES,
|
||||
activeLayout,
|
||||
closeInLayout,
|
||||
groupOf,
|
||||
leaves,
|
||||
moveInLayout,
|
||||
openInLayout,
|
||||
openInGroups,
|
||||
pane,
|
||||
pruneLayout,
|
||||
pruneGroups,
|
||||
removeSlot,
|
||||
updateActive,
|
||||
} from "../lib/paneLayout";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { freeSlot, startInFreeTab } from "../components/lanes/startSession";
|
||||
@@ -105,6 +111,7 @@ import PipelineMap from "../components/lanes/PipelineMap";
|
||||
import LaneCard, { since } from "../components/lanes/LaneCard";
|
||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
|
||||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||||
import { SessionReplay } from "../components/lanes/SessionReplay";
|
||||
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
@@ -161,20 +168,20 @@ const CHANGES_MIN_PX = 288;
|
||||
|
||||
/** 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]))?$/);
|
||||
const m = runId?.match(/^agenthub-lane-\d+(?:-([1-9]))?$/);
|
||||
return m ? Number(m[1] || 0) : 0;
|
||||
}
|
||||
|
||||
/** The session view's console: a tab bar over the workspace's split panes
|
||||
* (`PaneGrid`; layout model in `lib/paneLayout.ts`), one `LaneConsolePane`
|
||||
* per slot 0–3, each its own tmux session on the selected lane. The focused
|
||||
* pane is the URL's `?run=`: a plain open (a tab, a sidebar row) shows its run
|
||||
* in the focused pane — or focuses the pane already showing it — and a split
|
||||
* open (Ctrl/Cmd+click) opens it to the right of the focused one. Every tab
|
||||
* with a live run stays MOUNTED, hidden with CSS while no pane shows it, so
|
||||
* neither switching tabs nor reshaping the panes ever drops a terminal. A
|
||||
* pane whose run ends closes; the last one leaves for another tab, else the
|
||||
* home. */
|
||||
/** The session view's console: a tab bar whose every tab is a group of up to
|
||||
* four split panes (`PaneGrid`; model in `lib/paneLayout.ts`), one
|
||||
* `LaneConsolePane` per slot 0–9, each its own tmux session on the selected
|
||||
* lane. The focused pane of the tab on screen is the URL's `?run=`: a plain
|
||||
* open (a tab, a sidebar row) shows the tab holding that run — a session in
|
||||
* no tab gets one of its own — and a split open (Ctrl/Cmd+click) brings it
|
||||
* into the tab on screen, to the right of the focused pane. Every live run
|
||||
* stays MOUNTED, hidden with CSS while its tab is not shown, so neither
|
||||
* switching tabs nor reshaping the panes ever drops a terminal. A pane whose
|
||||
* run ends closes; the last tab gone leaves for the home. */
|
||||
function ConsoleArea({
|
||||
lanes,
|
||||
selectedLaneId,
|
||||
@@ -216,11 +223,12 @@ function ConsoleArea({
|
||||
.sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0)),
|
||||
[activeRuns, selectedLaneId]
|
||||
);
|
||||
const [layout, setLayout] = usePaneLayout(selectedLaneId);
|
||||
// The latest layout, updated the moment a change is made: two changes in
|
||||
// one commit (an open request and a prune) must build on each other.
|
||||
const layoutRef = useRef(layout);
|
||||
layoutRef.current = layout;
|
||||
const [groups, setGroups] = usePaneGroups(selectedLaneId);
|
||||
// The latest tabs, updated the moment a change is made: two changes in one
|
||||
// commit (an open request and a prune) must build on each other.
|
||||
const groupsRef = useRef(groups);
|
||||
groupsRef.current = groups;
|
||||
const layout = activeLayout(groups);
|
||||
// Slots opened here whose run the polled list has not caught up with yet,
|
||||
// each with the list it was opened against: pruning spares one until a
|
||||
// NEWER list still lacks it (a run that died before it was ever listed).
|
||||
@@ -263,16 +271,16 @@ function ConsoleArea({
|
||||
// is no effect watching the focus — an open request and that effect would
|
||||
// keep overriding each other. Session view only: the home and a replay
|
||||
// keep their URL.
|
||||
const followFocus = (next: PaneLayout) => {
|
||||
const followFocus = (next: PaneGroups) => {
|
||||
if (!attachRequest) return;
|
||||
const run = runOf(next.focus);
|
||||
const run = runOf(activeLayout(next).focus);
|
||||
if (run && run.id !== attachRequest.runId) onOpenRun(run.id, { focusOnly: true });
|
||||
};
|
||||
const change: LayoutUpdate = (fn) => {
|
||||
const next = fn(layoutRef.current);
|
||||
if (next === layoutRef.current) return;
|
||||
layoutRef.current = next;
|
||||
setLayout(() => next);
|
||||
const changeGroups = (fn: (g: PaneGroups) => PaneGroups) => {
|
||||
const next = fn(groupsRef.current);
|
||||
if (next === groupsRef.current) return;
|
||||
groupsRef.current = next;
|
||||
setGroups(() => next);
|
||||
followFocus(next);
|
||||
};
|
||||
|
||||
@@ -286,19 +294,23 @@ function ConsoleArea({
|
||||
// lane arrives on a cold load; only this lane's own tabs.
|
||||
useEffect(() => {
|
||||
if (!attachRequest || selectedLaneId === null) return;
|
||||
if (!new RegExp(`^agenthub-lane-${selectedLaneId}(-[1-3])?$`).test(attachRequest.runId)) return;
|
||||
if (!new RegExp(`^agenthub-lane-${selectedLaneId}(-[1-9])?$`).test(attachRequest.runId)) return;
|
||||
const slot = slotOf(attachRequest.runId);
|
||||
// The URL catching up with a focus change: by now that pane may be closed
|
||||
// or swapped, and re-opening it would undo what the user just did. Back/
|
||||
// Forward onto such an entry puts the real focus back in the URL instead.
|
||||
if (attachRequest.focusOnly) {
|
||||
if (leaves(layoutRef.current.root).includes(slot))
|
||||
change((l) => (l.focus === slot ? l : { ...l, focus: slot }));
|
||||
else followFocus(layoutRef.current);
|
||||
const g = groupsRef.current;
|
||||
const at = groupOf(g, slot);
|
||||
if (at >= 0)
|
||||
changeGroups(() =>
|
||||
at === g.active && g.groups[at]?.focus === slot ? g : openInGroups(g, slot, false)
|
||||
);
|
||||
else followFocus(g);
|
||||
return;
|
||||
}
|
||||
if (!tabs.some((r) => (r.slot ?? 0) === slot)) pending.current.set(slot, activeRuns);
|
||||
change((l) => openInLayout(l, slot, attachRequest.split ? "split" : "replace"));
|
||||
changeGroups((g) => openInGroups(g, slot, attachRequest.split === true));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [attachRequest?.nonce, selectedLaneId]);
|
||||
|
||||
@@ -318,12 +330,14 @@ function ConsoleArea({
|
||||
}
|
||||
const first = listed.current !== selectedLaneId;
|
||||
listed.current = selectedLaneId;
|
||||
const tabbed = groupsRef.current.groups.flatMap((l) => leaves(l.root));
|
||||
const keep = first
|
||||
? new Set(pending.current.keys())
|
||||
: new Set(leaves(layoutRef.current.root).filter((s) => !expired.has(s)));
|
||||
const before = layoutRef.current;
|
||||
change((l) => pruneLayout(l, live, keep));
|
||||
if (layoutRef.current === before && before.focus !== null && arrived.includes(before.focus))
|
||||
: new Set(tabbed.filter((s) => !expired.has(s)));
|
||||
const before = groupsRef.current;
|
||||
changeGroups((g) => pruneGroups(g, live, keep));
|
||||
const focus = activeLayout(before).focus;
|
||||
if (groupsRef.current === before && focus !== null && arrived.includes(focus))
|
||||
followFocus(before);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeRuns, tabs, selectedLaneId]);
|
||||
@@ -335,9 +349,9 @@ function ConsoleArea({
|
||||
onRunsChanged();
|
||||
const dead = runOf(slot);
|
||||
if (dead) exited.current.add(runKey(dead));
|
||||
if (!leaves(layoutRef.current.root).includes(slot)) return;
|
||||
change((l) => closeInLayout(l, slot));
|
||||
if (layoutRef.current.root || !attachRequest) return;
|
||||
if (groupOf(groupsRef.current, slot) < 0) return;
|
||||
changeGroups((g) => removeSlot(g, slot));
|
||||
if (groupsRef.current.groups.length || !attachRequest) return;
|
||||
const other = tabs.find((r) => (r.slot ?? 0) !== slot && !exited.current.has(runKey(r)));
|
||||
if (other) onOpenRun(other.id);
|
||||
else onNewSession?.();
|
||||
@@ -358,7 +372,7 @@ function ConsoleArea({
|
||||
onRunsChanged();
|
||||
if (laneRef.current !== laneId) return;
|
||||
pending.current.set(slot, activeRuns);
|
||||
change((l) => moveInLayout(l.root ? l : view, slot, target, edge));
|
||||
change((l) => moveInLayout(l, slot, target, edge));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
@@ -369,12 +383,17 @@ function ConsoleArea({
|
||||
// all): one pane, so the console always has its single render site.
|
||||
const fallback = requested[0] ?? 0;
|
||||
const view: PaneLayout = layout.root ? layout : { root: pane(fallback), focus: fallback };
|
||||
const viewGroups: PaneGroups = groups.groups.length ? groups : { groups: [view], active: 0 };
|
||||
// A change to the tab on screen (`PaneGrid`, the split menus). Tabs never
|
||||
// saved are the one-pane `view`: change that.
|
||||
const change: LayoutUpdate = (fn) =>
|
||||
changeGroups((g) => updateActive(g.groups.length ? g : viewGroups, fn));
|
||||
const shown = leaves(view.root);
|
||||
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort();
|
||||
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort((a, b) => a - b);
|
||||
const focusSlot = view.focus;
|
||||
|
||||
// Alt+1–4 focus a pane, Alt+\ splits the focused one right with a new
|
||||
// terminal, Alt+W hides it. Session view only, and never from inside a
|
||||
// terminal, Alt+W takes it out into a tab of its own. Session view only, and never from inside a
|
||||
// terminal or a field, whose keys are their own (Ctrl+M lets Tab out of a
|
||||
// terminal first). Read through a ref: one listener, always current.
|
||||
const onKeys = useRef<(e: KeyboardEvent) => void>(() => undefined);
|
||||
@@ -414,8 +433,8 @@ function ConsoleArea({
|
||||
<ConsoleTabs
|
||||
lane={lane}
|
||||
tabs={tabs}
|
||||
activeRunId={runOf(focusSlot)?.id ?? attachRequest?.runId ?? null}
|
||||
shownRunIds={shown.map((s) => runOf(s)?.id).filter((id): id is string => !!id)}
|
||||
groups={viewGroups}
|
||||
onGroupsChange={changeGroups}
|
||||
onOpen={(runId, split) => {
|
||||
onRunsChanged();
|
||||
// A tab click is not a new history entry: Back leaves the
|
||||
@@ -435,8 +454,7 @@ function ConsoleArea({
|
||||
shown={shown}
|
||||
runs={tabs}
|
||||
label={label}
|
||||
// A layout never saved is the one-pane `view`: split that.
|
||||
onChange={(fn) => change((l) => fn(l.root ? l : view))}
|
||||
onChange={change}
|
||||
canStart={free !== null}
|
||||
tools={splitTools}
|
||||
toolLabel={aiTools.label}
|
||||
@@ -963,7 +981,7 @@ export function Workspace() {
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{tLanes("empty.body")}{" "}
|
||||
<code className="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary">
|
||||
agenthub lanes add --cwd $(pwd)
|
||||
agenthub workspace add --cwd $(pwd)
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
@@ -992,17 +1010,12 @@ export function Workspace() {
|
||||
<WorkspaceHome
|
||||
lane={currentLane}
|
||||
onRenamed={() => void refreshLanes()}
|
||||
onMenuAction={(action, runId) => {
|
||||
onMenuAction={(action) => {
|
||||
if (action === "new-here") {
|
||||
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
||||
setAddLaneOpen(true);
|
||||
} else if (action === "reset" || action === "remove") {
|
||||
setDestructive({ lane: currentLane, action });
|
||||
} else if (action === "started") {
|
||||
setSearchParams({
|
||||
lane: String(currentLane.id),
|
||||
run: runId ?? `agenthub-lane-${currentLane.id}`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -1323,21 +1336,27 @@ export function Workspace() {
|
||||
</div>
|
||||
</aside>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-expand"
|
||||
onClick={() => setChangesOpen(true)}
|
||||
aria-expanded={false}
|
||||
aria-controls="lane-changes-panel"
|
||||
aria-label={tLanes("changes.expand")}
|
||||
title={tLanes("changes.expand")}
|
||||
className="flex w-9 shrink-0 flex-col items-center gap-2 border-l border-border bg-surface-1 py-3 text-fg-muted transition-colors hover:text-fg-primary"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
<span className="text-[10px] uppercase tracking-wide [writing-mode:vertical-rl]">
|
||||
{tLanes("changes.title")}
|
||||
</span>
|
||||
</button>
|
||||
// The rail also carries the uncommitted counts, vertical, with
|
||||
// the per-file list on hover — a sibling of the expand button,
|
||||
// since a button may not hold another.
|
||||
<div className="flex w-9 shrink-0 flex-col items-center gap-3 border-l border-border bg-surface-1 py-3">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-expand"
|
||||
onClick={() => setChangesOpen(true)}
|
||||
aria-expanded={false}
|
||||
aria-controls="lane-changes-panel"
|
||||
aria-label={tLanes("changes.expand")}
|
||||
title={tLanes("changes.expand")}
|
||||
className="flex flex-col items-center gap-2 text-fg-muted transition-colors hover:text-fg-primary"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
<span className="text-[10px] uppercase tracking-wide [writing-mode:vertical-rl]">
|
||||
{tLanes("changes.title")}
|
||||
</span>
|
||||
</button>
|
||||
{panelAt && <LaneGitRailCounts laneId={panelAt.laneId} session={panelAt.session} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, screen, waitFor, within } from "@testing-library/react";
|
||||
import { render, act, cleanup, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
@@ -56,6 +56,8 @@ let nextRunId = 1;
|
||||
vi.mock("../../components/lanes/WorkspaceHome", () => ({
|
||||
WorkspaceHome: () => <div data-testid="workspace-home" />,
|
||||
}));
|
||||
// Hoisted with the mock: a workspace holds ten sessions (MAX_PANE_SLOT 9).
|
||||
const SLOTS = vi.hoisted(() => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
vi.mock("../../components/lanes/startSession", () => ({
|
||||
paneRunId: (id: number, slot: number) =>
|
||||
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
|
||||
@@ -63,7 +65,7 @@ vi.mock("../../components/lanes/startSession", () => ({
|
||||
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;
|
||||
return SLOTS.find((s) => !busy.has(s)) ?? null;
|
||||
},
|
||||
// The server picks the slot (`slot: "auto"`); stand in for it with the first
|
||||
// one the mocked run list leaves free.
|
||||
@@ -75,7 +77,7 @@ vi.mock("../../components/lanes/startSession", () => ({
|
||||
.filter((r) => r.laneId === laneId && r.status === "running")
|
||||
.map((r) => r.slot ?? 0)
|
||||
);
|
||||
const slot = [0, 1, 2, 3].find((x) => !busy.has(x)) ?? 0;
|
||||
const slot = SLOTS.find((x) => !busy.has(x)) ?? 0;
|
||||
await api.lanes.action(laneId, "start", { ...body, slot: "auto" });
|
||||
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
|
||||
},
|
||||
@@ -621,9 +623,13 @@ describe("console tabs", () => {
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
|
||||
});
|
||||
|
||||
it("disables + once all four slots are taken", async () => {
|
||||
it("keeps + enabled past four sessions and disables it once all ten 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")).toBeEnabled();
|
||||
cleanup();
|
||||
withRuns(SLOTS.map((s) => live(s)));
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
expect(screen.getByTestId("console-tab-new")).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -685,7 +691,7 @@ describe("split panes", () => {
|
||||
const stored = () =>
|
||||
JSON.parse(window.localStorage.getItem("agenthub.workspace.paneLayout") ?? "{}")["1"];
|
||||
|
||||
it("Ctrl+click on a tab opens it in a split beside the focused pane, and remembers it", async () => {
|
||||
it("Ctrl+click on a tab brings it into the tab on screen as a split, one combo tab", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
@@ -696,29 +702,41 @@ describe("split panes", () => {
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
|
||||
expect(screen.getByTestId("pane-1")).toHaveAttribute("data-focused", "true");
|
||||
expect(screen.getByTestId("pane-header-0")).toBeInTheDocument();
|
||||
// The other pane's tab is marked as shown; the focused one is the active tab.
|
||||
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByTestId("console-tab-0").parentElement).toHaveAttribute("data-shown", "true");
|
||||
expect(stored().root).toMatchObject({ type: "split", dir: "row" });
|
||||
// The two tabs became ONE tab holding both panes.
|
||||
expect(screen.queryByTestId("console-tab-1")).toBeNull();
|
||||
expect(screen.getByTestId("console-tab-0-1")).toHaveAttribute("aria-selected", "true");
|
||||
expect(stored().groups).toHaveLength(1);
|
||||
expect(stored().groups[0].root).toMatchObject({ type: "split", dir: "row" });
|
||||
});
|
||||
|
||||
it("a plain click focuses a tab another pane shows, and replaces the focused pane otherwise", async () => {
|
||||
it("a plain click shows another tab, and a combo tab comes back as it was", async () => {
|
||||
withRuns([live(0), live(1), live(2)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-0"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1");
|
||||
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-2"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-1", "pane-2"]);
|
||||
expect(screen.getByTestId("pane-2")).toHaveAttribute("data-focused", "true");
|
||||
expect(shownPanes()).toEqual(["pane-2"]);
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-2");
|
||||
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-0-1"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
|
||||
});
|
||||
|
||||
it("closing a pane keeps its session as a tab; closing a tab closes its pane", async () => {
|
||||
it("a tab holds at most four panes; a fifth split opens a tab of its own", async () => {
|
||||
withRuns([live(0), live(1), live(2), live(3), live(4)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
for (const s of [1, 2, 3]) await ctrlClick(screen.getByTestId(`console-tab-${s}`));
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-2", "pane-3"]);
|
||||
await ctrlClick(screen.getByTestId("console-tab-4"));
|
||||
expect(shownPanes()).toEqual(["pane-4"]);
|
||||
expect(screen.getByTestId("console-tab-0-1-2-3")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("console-tab-4")).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("closing a pane takes it out into a tab of its own; closing a tab stops every session in it", async () => {
|
||||
withRuns([live(0), live(1), live(2)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
@@ -731,12 +749,13 @@ describe("split panes", () => {
|
||||
expect(api.run.kill).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("console-tab-1")).toBeInTheDocument();
|
||||
|
||||
withRuns([live(0), live(1)]);
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-close-2"));
|
||||
withRuns([live(1)]);
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-close-0-2"));
|
||||
await settle();
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1");
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1");
|
||||
expect(shownPanes()).toEqual(["pane-1"]);
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
|
||||
});
|
||||
|
||||
it("drops a remembered pane whose session is gone", async () => {
|
||||
@@ -812,7 +831,7 @@ describe("split panes", () => {
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-3"]);
|
||||
|
||||
await right(screen.getByTestId("pane-header-1"));
|
||||
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Hide pane"));
|
||||
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Move to own tab"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-3"]);
|
||||
expect(api.run.kill).not.toHaveBeenCalled();
|
||||
@@ -896,7 +915,7 @@ describe("split panes", () => {
|
||||
await act(async () => screen.getByTestId("exit-agenthub-lane-1-1").click());
|
||||
await settle();
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("");
|
||||
expect(stored().root).toEqual({ type: "pane", slot: 0 });
|
||||
expect(stored().groups[0].root).toEqual({ type: "pane", slot: 0 });
|
||||
// The last one too: still the home.
|
||||
await act(async () => screen.getByTestId("exit-agenthub-lane-1").click());
|
||||
await settle();
|
||||
@@ -908,7 +927,7 @@ describe("split panes", () => {
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
withRuns([]);
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-0"));
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-0-1"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
});
|
||||
@@ -937,8 +956,8 @@ describe("split panes", () => {
|
||||
expect(term("agenthub-lane-1")).toHaveAttribute("data-autofocus", "false");
|
||||
});
|
||||
|
||||
it("offers no new terminal in a split once every slot is busy", async () => {
|
||||
withRuns([live(0), live(1), live(2), live(3)]);
|
||||
it("offers no new terminal in a split once all ten slots are busy", async () => {
|
||||
withRuns(SLOTS.map((s) => live(s)));
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
await userEvent.setup().click(screen.getByTestId("pane-split-right-1"));
|
||||
|
||||
@@ -2618,6 +2618,32 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
type="text"
|
||||
value="/home/test/.claude"
|
||||
/>
|
||||
<button
|
||||
aria-label="Browse for a folder"
|
||||
class="flex shrink-0 items-center gap-1 self-stretch rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
|
||||
title="Browse for a folder"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-folder-open 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="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
class="contents"
|
||||
/>
|
||||
<button
|
||||
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled=""
|
||||
@@ -5452,7 +5478,7 @@ exports[`screen snapshots > Run 1`] = `
|
||||
<code
|
||||
class="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary"
|
||||
>
|
||||
agenthub lanes add --cwd $(pwd)
|
||||
agenthub workspace add --cwd $(pwd)
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
@@ -6955,7 +6981,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v2.0.1 (rebuild client to match)
|
||||
UI build v2.1.0 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@@ -9935,7 +9961,7 @@ exports[`screen snapshots > Workflows 1`] = `
|
||||
No workspace has recorded a stage yet. Create one with
|
||||
|
||||
<code>
|
||||
agenthub lanes add --cwd $(pwd)
|
||||
agenthub workspace add --cwd $(pwd)
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user