feat(workspace): VS Code-style split panes, edge-to-edge session view
The console splits into up to four panes: a binary split tree of tmux slots (client/src/lib/paneLayout.ts) drawn by PaneGrid.tsx, every pane an absolutely placed sibling keyed by slot so splitting, swapping, resizing or closing never remounts a live terminal. Ctrl/Cmd+click on a tab, a + item or a sidebar session row opens it beside the focused pane (router state, never the URL); a pane header drags onto another pane's edge to split there or onto its middle to swap, picks another live session, splits right/down (a not-shown session or a new terminal), or closes the pane only. Dividers drag or take arrow keys. ?run= is the focused pane: every layout change goes through change(), which pushes the URL with focusOnly instead of an effect watching the focus (that ping-ponged with open requests). The layout is kept per workspace in localStorage. Only the focused pane's terminal takes the keyboard when it appears, and the keyboard follows a focus change. A pane closes when its terminal reports the exit; the run list prunes only a remembered layout on a workspace's first read and a just-opened slot that never got listed, so one empty read cannot wipe a split. A run ending on the home or a replay closes its pane without leaving the page. The session view now fills the page edge to edge: no page padding, the tab bar and the right panel's activity bar one h-10 strip, and the tab bar, pane headers and right panel on the sidebar/status bar colour (surface-1) around terminals on the page colour.
This commit is contained in:
+279
-85
@@ -41,7 +41,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -56,12 +56,25 @@ import {
|
||||
ChartColumn,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||
import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api";
|
||||
import type { Lane, LaneFeature, ProofFeature } from "../lib/types";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
import { ConsoleTabs } from "../components/run/ConsoleTabs";
|
||||
import { type LayoutUpdate, PaneGrid, usePaneLayout } from "../components/run/PaneGrid";
|
||||
import {
|
||||
type Edge,
|
||||
type PaneLayout,
|
||||
closeInLayout,
|
||||
leaves,
|
||||
moveInLayout,
|
||||
openInLayout,
|
||||
pane,
|
||||
pruneLayout,
|
||||
} from "../lib/paneLayout";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { freeSlot } from "../components/lanes/WorkspaceHome";
|
||||
import PipelineMap from "../components/lanes/PipelineMap";
|
||||
import LaneCard, { since, LaneToolsMenu } from "../components/lanes/LaneCard";
|
||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||
@@ -115,10 +128,16 @@ function slotOf(runId: string | null | undefined): number {
|
||||
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. */
|
||||
/** 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. */
|
||||
function ConsoleArea({
|
||||
lanes,
|
||||
selectedLaneId,
|
||||
@@ -135,15 +154,19 @@ function ConsoleArea({
|
||||
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
|
||||
activeRuns: RunListResponse | null;
|
||||
onHasActiveRunChange: (val: boolean) => void;
|
||||
/** The opened run (`?run=`): its tab is the active one. */
|
||||
attachRequest: { runId: string; nonce: string } | null;
|
||||
/** The opened run (`?run=`): its pane is the focused one. `split` opens it
|
||||
* beside the focused pane instead of in it; `focusOnly` (the URL following a
|
||||
* focus change made here) never opens anything. */
|
||||
attachRequest: { runId: string; nonce: string; split?: boolean; focusOnly?: boolean } | null;
|
||||
/** Back to the workspace home, where a new session is started. */
|
||||
onNewSession?: () => void;
|
||||
/** Opens a tab's run (`?run=`). */
|
||||
onOpenRun: (runId: string) => void;
|
||||
/** Opens a run (`?run=`); `split` splits, `focusOnly` only follows the focus. */
|
||||
onOpenRun: (runId: string, opts?: { split?: boolean; focusOnly?: boolean }) => void;
|
||||
/** A tab was opened or closed: re-read the run list now. */
|
||||
onRunsChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const aiTools = useAiTools();
|
||||
const lane = lanes.find((l) => l.id === selectedLaneId) ?? null;
|
||||
const tabs = useMemo(
|
||||
() =>
|
||||
@@ -152,53 +175,191 @@ function ConsoleArea({
|
||||
.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);
|
||||
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;
|
||||
// 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).
|
||||
const pending = useRef(new Map<number, RunListResponse | null>());
|
||||
// The workspace whose first run list was read. That first read prunes, so a
|
||||
// remembered layout loses its dead panes on arrival; after it a shown pane
|
||||
// closes on its terminal's exit, not on the polled list — one bad read (the
|
||||
// server answers an empty list on any tmux error) must not wipe a split.
|
||||
const listed = useRef<number | null>(null);
|
||||
// Runs seen ending here (`id@startedAt`): a list not yet refreshed must not
|
||||
// send the last pane to one of them.
|
||||
const exited = useRef(new Set<string>());
|
||||
const laneRef = useRef(selectedLaneId);
|
||||
laneRef.current = selectedLaneId;
|
||||
const [splitError, setSplitError] = useState<string | null>(null);
|
||||
const label = (r: RunHandle) => (r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool));
|
||||
const runOf = (slot: number | null) => tabs.find((r) => (r.slot ?? 0) === slot);
|
||||
const runKey = (r: RunHandle) => `${r.id}@${r.startedAt}`;
|
||||
|
||||
// The URL's `?run=` names the focused pane, so the right panel and the
|
||||
// sidebar read it. A change made HERE that moves the focus pushes it; there
|
||||
// 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) => {
|
||||
if (!attachRequest) return;
|
||||
const run = runOf(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);
|
||||
followFocus(next);
|
||||
};
|
||||
|
||||
// Another workspace: nothing pending or exited carries over.
|
||||
useEffect(() => {
|
||||
pending.current.clear();
|
||||
exited.current.clear();
|
||||
}, [selectedLaneId]);
|
||||
|
||||
// An open request (a new nonce): focus, replace or split. Re-run when the
|
||||
// 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;
|
||||
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);
|
||||
return;
|
||||
}
|
||||
if (!tabs.some((r) => (r.slot ?? 0) === slot)) pending.current.set(slot, activeRuns);
|
||||
change((l) => openInLayout(l, slot, attachRequest.split ? "split" : "replace"));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [attachRequest?.nonce, selectedLaneId]);
|
||||
|
||||
// The run list caught up: on a workspace's first read every pane whose run
|
||||
// is gone closes; after that only a pane opened here whose run never got
|
||||
// listed (a newer list still lacks it) does. A pane opened here whose run
|
||||
// just got listed takes the URL if it holds the focus.
|
||||
useEffect(() => {
|
||||
if (!activeRuns || selectedLaneId === null) return;
|
||||
const live = new Set(tabs.map((r) => r.slot ?? 0));
|
||||
const arrived: number[] = [];
|
||||
const expired = new Set<number>();
|
||||
for (const [s, list] of pending.current) {
|
||||
if (live.has(s)) arrived.push(s);
|
||||
else if (list !== activeRuns) expired.add(s);
|
||||
if (live.has(s) || list !== activeRuns) pending.current.delete(s);
|
||||
}
|
||||
const first = listed.current !== selectedLaneId;
|
||||
listed.current = selectedLaneId;
|
||||
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))
|
||||
followFocus(before);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeRuns, tabs, selectedLaneId]);
|
||||
|
||||
// A pane's run ended (it exited, or its tab was closed): its pane closes,
|
||||
// and in the session view the last one leaves for another tab, else the
|
||||
// home — the home and a replay only lose the pane, never their URL.
|
||||
const ended = (slot: number) => {
|
||||
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;
|
||||
const other = tabs.find((r) => (r.slot ?? 0) !== slot && !exited.current.has(runKey(r)));
|
||||
if (other) onOpenRun(other.id);
|
||||
else onNewSession?.();
|
||||
};
|
||||
|
||||
// A split menu's "New terminal": a shell in the first free slot, placed on
|
||||
// that side of the pane — unless the workspace changed while it started.
|
||||
const free = lane ? freeSlot(lane.id, tabs) : null;
|
||||
const newSplit = async (target: number, edge: Edge) => {
|
||||
if (!lane || free === null) return;
|
||||
const laneId = lane.id;
|
||||
const slot = free;
|
||||
setSplitError(null);
|
||||
try {
|
||||
const r = await api.lanes.action(laneId, "start", { slot, shell: true });
|
||||
onRunsChanged();
|
||||
if (!r.run_id || laneRef.current !== laneId) return;
|
||||
pending.current.set(slot, activeRuns);
|
||||
change((l) => moveInLayout(l, slot, target, edge));
|
||||
} catch (err) {
|
||||
setSplitError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const requested = attachRequest ? [slotOf(attachRequest.runId)] : [];
|
||||
// No layout yet (a first visit, a request not yet applied, or no run at
|
||||
// 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 shown = leaves(view.root);
|
||||
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort();
|
||||
const focusSlot = view.focus;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{lane && (
|
||||
<ConsoleTabs
|
||||
lane={lane}
|
||||
tabs={tabs}
|
||||
activeRunId={attachRequest?.runId ?? null}
|
||||
onOpen={(runId) => {
|
||||
activeRunId={runOf(focusSlot)?.id ?? attachRequest?.runId ?? null}
|
||||
shownRunIds={shown.map((s) => runOf(s)?.id).filter((id): id is string => !!id)}
|
||||
onOpen={(runId, split) => {
|
||||
onRunsChanged();
|
||||
onOpenRun(runId);
|
||||
}}
|
||||
onClosed={(runId) => {
|
||||
onRunsChanged();
|
||||
if (slotOf(runId) === activeSlot) leave(activeSlot);
|
||||
onOpenRun(runId, { split });
|
||||
}}
|
||||
onClosed={(runId) => ended(slotOf(runId))}
|
||||
// Per-workspace actions (mcp sync, sync-base check, readme).
|
||||
actions={<LaneToolsMenu lane={lane} />}
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col pt-2">
|
||||
{slots.map((slot) => (
|
||||
<div key={slot} className={slot === activeSlot ? "contents" : "hidden"}>
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
slot={slot}
|
||||
binaryStatus={binaryStatus}
|
||||
activeRuns={activeRuns}
|
||||
onHasActiveRunChange={slot === activeSlot ? onHasActiveRunChange : undefined}
|
||||
onNewSession={onNewSession}
|
||||
onRunExit={() => slot === activeSlot && leave(slot)}
|
||||
attachRequest={
|
||||
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{splitError && (
|
||||
<p role="alert" className="px-3 pt-2 text-xs text-status-danger">
|
||||
{splitError}
|
||||
</p>
|
||||
)}
|
||||
<PaneGrid
|
||||
layout={view}
|
||||
mounted={mounted}
|
||||
runs={tabs}
|
||||
label={label}
|
||||
onChange={change}
|
||||
canStart={free !== null}
|
||||
onNewSplit={(target, edge) => void newSplit(target, edge)}
|
||||
renderPane={(slot) => (
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
slot={slot}
|
||||
binaryStatus={binaryStatus}
|
||||
activeRuns={activeRuns}
|
||||
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
|
||||
onNewSession={onNewSession}
|
||||
onRunExit={() => ended(slot)}
|
||||
// Only the focused pane takes the keyboard when it appears.
|
||||
autoFocus={slot === focusSlot}
|
||||
attachRequest={
|
||||
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -220,9 +381,14 @@ export function Workspace() {
|
||||
// conversation, with a prompt box whose message resumes it (SessionReplay).
|
||||
const sessionParam = runParam ? null : searchParams.get("session");
|
||||
const navigate = useNavigate();
|
||||
// A Ctrl/Cmd+click (tab or sidebar) opens the run beside the focused pane;
|
||||
// it rides on the navigation's state, never in the URL.
|
||||
const navState = location.state as { split?: unknown; focusOnly?: unknown } | null;
|
||||
const splitOpen = navState?.split === true;
|
||||
const focusOnly = navState?.focusOnly === true;
|
||||
const attachRequest = useMemo(
|
||||
() => (runParam ? { runId: runParam, nonce: location.key } : null),
|
||||
[runParam, location.key]
|
||||
() => (runParam ? { runId: runParam, nonce: location.key, split: splitOpen, focusOnly } : null),
|
||||
[runParam, location.key, splitOpen, focusOnly]
|
||||
);
|
||||
const paramId = laneParam === null ? null : Number(laneParam);
|
||||
const fallbackId = (() => {
|
||||
@@ -496,8 +662,12 @@ export function Workspace() {
|
||||
// The session the Analyst button opens (`/sessions/:id`): the replayed one,
|
||||
// or the one the opened pane holds.
|
||||
const analystSessionId = sessionParam ?? openedRun?.sessionId ?? null;
|
||||
// The session view (console or replay) fills the page edge to edge, like an
|
||||
// editor: no page padding, no cards, dividers between its regions.
|
||||
const sessionView = !!currentLane && !home;
|
||||
const viewportLocked =
|
||||
!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full"));
|
||||
sessionView ||
|
||||
(!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full")));
|
||||
|
||||
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
|
||||
setLaneActionError(null);
|
||||
@@ -545,9 +715,12 @@ export function Workspace() {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
viewportLocked
|
||||
? "h-[calc(100vh-4rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
|
||||
: "space-y-5"
|
||||
sessionView
|
||||
? // Cancels Layout's page padding; the 1.5rem left is the status bar's.
|
||||
"-m-5 flex h-[calc(100vh-1.5rem)] flex-col lg:-m-6"
|
||||
: viewportLocked
|
||||
? "h-[calc(100vh-4rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
|
||||
: "space-y-5"
|
||||
}
|
||||
>
|
||||
{/* Workspaces are picked in the sidebar's project tree; this page shows
|
||||
@@ -618,7 +791,7 @@ export function Workspace() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={`flex min-h-0 flex-1 gap-4${home ? " hidden" : ""}`}>
|
||||
<div className={`flex min-h-0 flex-1${home ? " hidden" : ""}`}>
|
||||
<section
|
||||
data-testid="lane-detail"
|
||||
className={`flex min-h-0 min-w-0 flex-1 flex-col${
|
||||
@@ -685,7 +858,7 @@ export function Workspace() {
|
||||
</div>
|
||||
)}
|
||||
{currentLane && (
|
||||
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||
<div className="max-h-[45vh] shrink-0 overflow-y-auto px-3">
|
||||
{/* No margin when the card rendered nothing (see LaneCard). */}
|
||||
<div className="mb-3 has-[>div:empty]:mb-0">
|
||||
<LaneCard
|
||||
@@ -755,16 +928,18 @@ export function Workspace() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{replaying && (
|
||||
<SessionReplay
|
||||
lane={currentLane}
|
||||
sessionId={sessionParam}
|
||||
liveRuns={activeRuns?.items ?? []}
|
||||
onResumed={(runId) =>
|
||||
setSearchParams({ lane: String(currentLane.id), run: runId })
|
||||
}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-4 pb-3 pt-3">
|
||||
<SessionReplay
|
||||
lane={currentLane}
|
||||
sessionId={sessionParam}
|
||||
liveRuns={activeRuns?.items ?? []}
|
||||
onResumed={(runId) =>
|
||||
setSearchParams({ lane: String(currentLane.id), run: runId })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* A replay only HIDES the console: it has one render site and a
|
||||
live terminal must never unmount. */}
|
||||
@@ -781,8 +956,20 @@ export function Workspace() {
|
||||
? () => setSearchParams({ lane: String(currentLane.id) })
|
||||
: undefined
|
||||
}
|
||||
onOpenRun={(runId) =>
|
||||
currentLane && setSearchParams({ lane: String(currentLane.id), run: runId })
|
||||
onOpenRun={(runId, opts) =>
|
||||
currentLane &&
|
||||
setSearchParams(
|
||||
{ lane: String(currentLane.id), run: runId },
|
||||
{
|
||||
// Following the focus is not a new history entry.
|
||||
replace: opts?.focusOnly,
|
||||
state: opts?.split
|
||||
? { split: true }
|
||||
: opts?.focusOnly
|
||||
? { focusOnly: true }
|
||||
: undefined,
|
||||
}
|
||||
)
|
||||
}
|
||||
onRunsChanged={refreshList}
|
||||
/>
|
||||
@@ -795,12 +982,17 @@ export function Workspace() {
|
||||
<aside
|
||||
id="lane-changes-panel"
|
||||
data-testid="lane-changes-card"
|
||||
className={`flex shrink-0 flex-col ${CHANGES_WIDTH[changesSize]}${
|
||||
changesSize === "full" ? "" : " border-l border-border pl-4"
|
||||
className={`flex shrink-0 flex-col bg-surface-1 ${CHANGES_WIDTH[changesSize]}${
|
||||
changesSize === "full" ? "" : " border-l border-border"
|
||||
}`}
|
||||
>
|
||||
{/* Activity bar: Files · Changes · Sessions, then the resize control. */}
|
||||
<div className="mb-2 flex items-center gap-1" role="toolbar">
|
||||
{/* Activity bar: Files · Changes · Sessions, then the resize
|
||||
control — the same height as the console's tab bar, so the
|
||||
two read as one header strip. */}
|
||||
<div
|
||||
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
|
||||
role="toolbar"
|
||||
>
|
||||
{PANEL_TABS.map(({ id, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
@@ -864,25 +1056,27 @@ export function Workspace() {
|
||||
})()}
|
||||
</button>
|
||||
</div>
|
||||
<LaneChangesPanel
|
||||
// A new tree is a new panel: no read for the previous one
|
||||
// can land in it.
|
||||
key={`${currentLane.id}:${gitSession ?? ""}`}
|
||||
laneId={currentLane.id}
|
||||
session={gitSession}
|
||||
tab={panelTab}
|
||||
full={changesSize === "full"}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
onNewLane={(branch) => {
|
||||
setAddLanePrefill({
|
||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||
branch,
|
||||
});
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||||
<LaneChangesPanel
|
||||
// A new tree is a new panel: no read for the previous one
|
||||
// can land in it.
|
||||
key={`${currentLane.id}:${gitSession ?? ""}`}
|
||||
laneId={currentLane.id}
|
||||
session={gitSession}
|
||||
tab={panelTab}
|
||||
full={changesSize === "full"}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
onNewLane={(branch) => {
|
||||
setAddLanePrefill({
|
||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||
branch,
|
||||
});
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
) : (
|
||||
<button
|
||||
@@ -893,7 +1087,7 @@ export function Workspace() {
|
||||
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 py-3 text-fg-muted transition-colors hover:text-fg-primary"
|
||||
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]">
|
||||
|
||||
@@ -259,8 +259,23 @@ vi.mock("../../lib/eventBus", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../components/run/TerminalView", () => ({
|
||||
TerminalView: ({ runId }: { runId: string }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId} />
|
||||
// `exit-<runId>` stands in for the server's exit frame.
|
||||
TerminalView: ({
|
||||
runId,
|
||||
onExit,
|
||||
autoFocus,
|
||||
}: {
|
||||
runId: string;
|
||||
onExit?: () => void;
|
||||
autoFocus?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="terminal-view"
|
||||
data-run-id={runId}
|
||||
data-autofocus={autoFocus === false ? "false" : "true"}
|
||||
>
|
||||
<button data-testid={`exit-${runId}`} onClick={() => onExit?.()} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -598,6 +613,261 @@ describe("console tabs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("split panes", () => {
|
||||
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,
|
||||
});
|
||||
const original = vi.mocked(api.run.list).getMockImplementation();
|
||||
afterEach(() => {
|
||||
vi.mocked(api.run.list).mockImplementation(original!);
|
||||
});
|
||||
const withRuns = (items: ReturnType<typeof live>[]) =>
|
||||
vi.mocked(api.run.list).mockResolvedValue({ runs: [], items } as never);
|
||||
/** The URL's `?run=` — the focused pane's run. */
|
||||
function RunParam() {
|
||||
const [params] = useSearchParams();
|
||||
return <span data-testid="run-param">{params.get("run")}</span>;
|
||||
}
|
||||
const open = async (entry: string | { pathname: string; search: string; state?: unknown }) => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<RunParam />
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
};
|
||||
/** The slots a pane is drawn for (the rest are mounted and hidden). */
|
||||
const shownPanes = () =>
|
||||
screen
|
||||
.getAllByTestId(/^pane-\d$/)
|
||||
.filter((p) => !p.classList.contains("hidden"))
|
||||
.map((p) => p.dataset.testid);
|
||||
const ctrlClick = async (el: HTMLElement) => {
|
||||
const user = userEvent.setup();
|
||||
await user.keyboard("{Control>}");
|
||||
await user.click(el);
|
||||
await user.keyboard("{/Control}");
|
||||
await settle();
|
||||
};
|
||||
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 () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
expect(screen.queryByTestId("pane-header-0")).toBeNull();
|
||||
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
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" });
|
||||
});
|
||||
|
||||
it("a plain click focuses a tab another pane shows, and replaces the focused pane otherwise", 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");
|
||||
});
|
||||
|
||||
it("closing a pane keeps its session as a tab; closing a tab closes its pane", 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 ctrlClick(screen.getByTestId("console-tab-2"));
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-2"]);
|
||||
|
||||
await userEvent.setup().click(screen.getByTestId("pane-close-1"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-2"]);
|
||||
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"));
|
||||
await settle();
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1");
|
||||
});
|
||||
|
||||
it("drops a remembered pane whose session is gone", async () => {
|
||||
window.localStorage.setItem(
|
||||
"agenthub.workspace.paneLayout",
|
||||
JSON.stringify({
|
||||
1: {
|
||||
root: {
|
||||
type: "split",
|
||||
dir: "row",
|
||||
ratio: 0.5,
|
||||
a: { type: "pane", slot: 0 },
|
||||
b: { type: "pane", slot: 3 },
|
||||
},
|
||||
focus: 3,
|
||||
},
|
||||
})
|
||||
);
|
||||
withRuns([live(0)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
});
|
||||
|
||||
it("a sidebar Ctrl/Cmd+click (router state) splits; a plain one goes into the focused pane", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
window.localStorage.setItem(
|
||||
"agenthub.workspace.paneLayout",
|
||||
JSON.stringify({ 1: { root: { type: "pane", slot: 0 }, focus: 0 } })
|
||||
);
|
||||
await open({
|
||||
pathname: "/workspace",
|
||||
search: "?lane=1&run=agenthub-lane-1-1",
|
||||
state: { split: true },
|
||||
});
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
});
|
||||
|
||||
it("a pane's picker swaps two shown panes", async () => {
|
||||
withRuns([live(0), live(1, "shell")]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
expect(screen.getByTestId("pane-0").style.left).toBe("0%");
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("pane-pick-0"));
|
||||
await user.click(screen.getByRole("menuitemradio", { name: /Terminal/ }));
|
||||
await settle();
|
||||
expect(screen.getByTestId("pane-1").style.left).toBe("0%");
|
||||
expect(screen.getByTestId("pane-0").style.left).toBe("50%");
|
||||
});
|
||||
|
||||
it("a split menu starts a new terminal in the first free slot", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("pane-split-bottom-1"));
|
||||
await user.click(screen.getByTestId("pane-split-new-bottom-1"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 2, shell: true });
|
||||
});
|
||||
|
||||
it("a pane whose run exits closes, and the URL follows the pane that takes its place", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
|
||||
withRuns([live(0)]);
|
||||
await act(async () => screen.getByTestId("exit-agenthub-lane-1-1").click());
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1");
|
||||
});
|
||||
|
||||
it("a run ending while the home is shown closes its pane but never leaves the home", async () => {
|
||||
window.localStorage.setItem(
|
||||
"agenthub.workspace.paneLayout",
|
||||
JSON.stringify({
|
||||
1: {
|
||||
root: {
|
||||
type: "split",
|
||||
dir: "row",
|
||||
ratio: 0.5,
|
||||
a: { type: "pane", slot: 0 },
|
||||
b: { type: "pane", slot: 1 },
|
||||
},
|
||||
focus: 1,
|
||||
},
|
||||
})
|
||||
);
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1");
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("");
|
||||
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 });
|
||||
// The last one too: still the home.
|
||||
await act(async () => screen.getByTestId("exit-agenthub-lane-1").click());
|
||||
await settle();
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("");
|
||||
});
|
||||
|
||||
it("an empty run-list read after the first does not wipe a split", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
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 settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
});
|
||||
|
||||
it("only the focused pane's terminal takes the keyboard when it appears", async () => {
|
||||
window.localStorage.setItem(
|
||||
"agenthub.workspace.paneLayout",
|
||||
JSON.stringify({
|
||||
1: {
|
||||
root: {
|
||||
type: "split",
|
||||
dir: "row",
|
||||
ratio: 0.5,
|
||||
a: { type: "pane", slot: 0 },
|
||||
b: { type: "pane", slot: 1 },
|
||||
},
|
||||
focus: 1,
|
||||
},
|
||||
})
|
||||
);
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1-1");
|
||||
const term = (id: string) =>
|
||||
screen.getAllByTestId("terminal-view").find((t) => t.dataset.runId === id);
|
||||
expect(term("agenthub-lane-1-1")).toHaveAttribute("data-autofocus", "true");
|
||||
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)]);
|
||||
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"));
|
||||
expect(screen.getByTestId("pane-split-new-right-1")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("a divider resizes by keyboard", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
const sep = screen.getByRole("separator");
|
||||
sep.focus();
|
||||
await userEvent.setup().keyboard("{ArrowRight}");
|
||||
await settle();
|
||||
expect(screen.getByTestId("pane-0").style.width).toBe("55.00000000000001%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace — home and session view", () => {
|
||||
it("opens a workspace on its home, hiding (not unmounting) the session view", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
@@ -5397,14 +5397,14 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 gap-4"
|
||||
class="flex min-h-0 flex-1"
|
||||
>
|
||||
<section
|
||||
class="flex min-h-0 min-w-0 flex-1 flex-col"
|
||||
data-testid="lane-detail"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-2"
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div
|
||||
class="contents"
|
||||
@@ -5413,22 +5413,30 @@ exports[`screen snapshots > Run 1`] = `
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col pt-2"
|
||||
class="relative min-h-0 flex-1"
|
||||
data-testid="pane-grid"
|
||||
>
|
||||
<div
|
||||
class="contents"
|
||||
class="absolute flex flex-col"
|
||||
data-focused="true"
|
||||
data-testid="pane-0"
|
||||
style="left: 0%; top: 0%; width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-3"
|
||||
data-testid="console-body"
|
||||
class="flex min-h-0 flex-1 flex-col pb-1 pl-3 pr-1 pt-1.5"
|
||||
>
|
||||
<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"
|
||||
class="flex min-h-0 flex-1 flex-col gap-3"
|
||||
data-testid="console-body"
|
||||
>
|
||||
<p>
|
||||
No session in this pane. Start one from the workspace's prompt box.
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user