feat(workspace): no double resume, server-picked tabs, terminal a11y

- A resume of a session a live pane already holds is refused (409
  ESESSIONLIVE with error.run_id) and the client opens that pane, pasting
  any typed message there; catalog tools refuse resumeSessionId
  (EUNSUPPORTED) instead of silently starting fresh.
- POST /api/lanes/:id/start takes slot: "auto": the server picks the free
  tab under the lane lock (components/lanes/startSession.ts on the client,
  kept out of WorkspaceHome so the sidebar does not pull react-markdown
  into the main bundle).
- Unsaved whole-file edits are guarded on every exit (Back, another file,
  tab switch, close, lane/session change, beforeunload).
- Closing an AI tab asks first; hiding a pane has its own icon. Tabs and
  pane headers show the session name, a state dot, rename by double-click.
- Terminal: Ctrl+M toggles Tab-moves-focus (no keyboard trap), Cmd+F /
  Ctrl+Shift+F find bar, clickable links (noopener), a write-only OSC 52
  clipboard, a Reconnecting overlay, debounced resizes, optional screen
  reader mode. Alt+1-4 / Alt+\ / Alt+W focus, split and hide panes.
- Changes panel: draggable splitter, wrapping git bar, history paging,
  disarming discard/undo/abort, inline diff comments pasted only into a
  visible pane; drag MIME guard on sidebar rows; IME-safe Enter; Show
  more in the sidebar and Recents; idle dots readable with sr-only text.
- Adds @xterm/addon-clipboard, @xterm/addon-search, @xterm/addon-web-links.
This commit is contained in:
2026-10-05 13:22:12 +07:00
parent 6715fbd971
commit efa417488a
43 changed files with 2252 additions and 507 deletions
+286 -84
View File
@@ -10,6 +10,14 @@
* 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.
* Tabs and pane headers name each live session (`runName`) and show its state;
* a tab click replaces the history entry (Back leaves the session view, not
* the last tab). Keyboard, outside a terminal: Alt+1–4 focus a pane, Alt+\\
* splits the focused one right with a new terminal, Alt+W hides it. The
* Changes panel resizes by its button's presets or by dragging (or arrow keys
* on) its splitter; an unsaved edit in it is asked about before the panel
* closes, switches tab, or moves to another workspace or session (it stays on
* the old one until saved or discarded). Errors are toasts.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -57,15 +65,30 @@ import {
} from "lucide-react";
import { api } from "../lib/api";
import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api";
import type { Lane, LaneFeature, ProofFeature } from "../lib/types";
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { toast } from "../lib/toast";
import { confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame";
import { useLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import { ConsoleTabs, newTabBody, newTabTools } from "../components/run/ConsoleTabs";
import { type LayoutUpdate, PaneGrid, SplitMenus, usePaneLayout } from "../components/run/PaneGrid";
import {
ConsoleTabs,
newTabBody,
newTabTools,
runName,
runState,
} from "../components/run/ConsoleTabs";
import {
Divider,
type LayoutUpdate,
PaneGrid,
SplitMenus,
usePaneLayout,
} from "../components/run/PaneGrid";
import {
type Edge,
type PaneLayout,
MAX_PANES,
closeInLayout,
leaves,
moveInLayout,
@@ -74,7 +97,8 @@ import {
pruneLayout,
} from "../lib/paneLayout";
import { useAiTools } from "../hooks/useAiTools";
import { freeSlot } from "../components/lanes/WorkspaceHome";
import { freeSlot, startInFreeTab } from "../components/lanes/startSession";
import { sessionWorktreeName } from "../components/lanes/WorkspaceSessionList";
import { LIVE_RUNS_EVENT } from "../components/WorkspaceTree";
import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, { since } from "../components/lanes/LaneCard";
@@ -105,8 +129,10 @@ type PanelTab = (typeof PANEL_TABS)[number]["id"];
* 30/70 → 50/50 → full → closed, rather than a width button beside a
* collapse button that both live in the panel's tightest corner. */
const CHANGES_SIZES = ["compact", "half", "full"] as const;
type ChangesSize = (typeof CHANGES_SIZES)[number];
const CHANGES_WIDTH: Record<ChangesSize, string> = {
type ChangesPreset = (typeof CHANGES_SIZES)[number];
/** A preset, or a width (panel fraction 0.15–0.85) the splitter was dragged to. */
type ChangesSize = ChangesPreset | number;
const CHANGES_WIDTH: Record<ChangesPreset, string> = {
compact: "w-[30%]",
half: "w-1/2",
// The console beside it is HIDDEN, not unmounted: a live run's terminal
@@ -115,11 +141,20 @@ const CHANGES_WIDTH: Record<ChangesSize, string> = {
};
/** The icon the one resize control shows AT each size: what the next click
* gives you — an even split, the full width, then closed. */
const CHANGES_NEXT_ICON: Record<ChangesSize, typeof Columns2> = {
const CHANGES_NEXT_ICON: Record<ChangesPreset, typeof Columns2> = {
compact: Columns2,
half: Maximize2,
full: PanelRightClose,
};
/** The panel's share of the row, for the splitter. */
const changesRatio = (s: ChangesSize) =>
typeof s === "number" ? s : s === "compact" ? 0.3 : s === "half" ? 0.5 : 1;
/** The preset a size counts as for the button: under ~45% is narrow. */
const changesStep = (s: ChangesSize): ChangesPreset =>
s === "full" ? "full" : changesRatio(s) < 0.45 ? "compact" : "half";
const clampChanges = (r: number) => Math.min(0.85, Math.max(0.15, r));
/** The panel's own minimum (`min-w-[18rem]`), in px. */
const CHANGES_MIN_PX = 288;
// ── Page ──────────────────────────────────────────────────────────────
@@ -161,8 +196,12 @@ function ConsoleArea({
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 run (`?run=`); `split` splits, `focusOnly` only follows the focus. */
onOpenRun: (runId: string, opts?: { split?: boolean; focusOnly?: boolean }) => void;
/** Opens a run (`?run=`); `split` splits, `focusOnly` only follows the focus,
* `replace` adds no history entry (a tab click). */
onOpenRun: (
runId: string,
opts?: { split?: boolean; focusOnly?: boolean; replace?: boolean }
) => void;
/** A tab was opened or closed: re-read the run list now. */
onRunsChanged: () => void;
}) {
@@ -195,8 +234,26 @@ function ConsoleArea({
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));
// The sessions the tabs hold — their names and states — re-read when a tab
// opens or learns its session, when the lane's status moves, or on a rename.
const [tabSessions, setTabSessions] = useState<Map<string, Session>>(new Map());
const [sessionsVersion, setSessionsVersion] = useState(0);
const tabSessionIds = tabs.map((r) => r.sessionId ?? "").join(",");
useEffect(() => {
if (selectedLaneId === null || !tabSessionIds.replace(/,/g, "")) return;
let live = true;
api.sessions
.list({ lane: selectedLaneId, limit: 50 })
.then((r) => live && setTabSessions(new Map(r.sessions.map((x) => [x.id, x]))))
.catch(() => undefined);
return () => {
live = false;
};
}, [selectedLaneId, tabSessionIds, lane?.status, lane?.needs_action, sessionsVersion]);
const sessionOf = (r: RunHandle) => (r.sessionId ? tabSessions.get(r.sessionId) : undefined);
const label = (r: RunHandle) =>
runName(r, sessionOf(r), aiTools.label(r.tool), t("tabs.terminal"));
const stateOf = (r: RunHandle) => (r.tool === "shell" ? null : runState(sessionOf(r)));
const runOf = (slot: number | null) => tabs.find((r) => (r.slot ?? 0) === slot);
const runKey = (r: RunHandle) => `${r.id}@${r.startedAt}`;
@@ -293,17 +350,16 @@ function ConsoleArea({
const newSplit = async (target: number, edge: Edge, tool?: string) => {
if (!lane || free === null) return;
const laneId = lane.id;
const slot = free;
setSplitError(null);
try {
const body = tool ? newTabBody(tool, lane, aiTools) : { shell: true };
const r = await api.lanes.action(laneId, "start", { slot, ...body });
// The server picks the slot: the list `free` came from may be stale.
const slot = slotOf(await startInFreeTab(laneId, body));
onRunsChanged();
if (!r.run_id || laneRef.current !== laneId) return;
if (laneRef.current !== laneId) return;
pending.current.set(slot, activeRuns);
change((l) => moveInLayout(l.root ? l : view, slot, target, edge));
} catch (err) {
setSplitError(err instanceof Error ? err.message : String(err));
toast.error(err);
}
};
@@ -316,8 +372,43 @@ function ConsoleArea({
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort();
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 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);
const areaRef = useRef<HTMLDivElement>(null);
onKeys.current = (e) => {
if (!attachRequest || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
// Not under a dialog, and not while the console is hidden (the Changes
// panel at full width, a replay, the home) — CSS-hidden, so by class.
if (document.querySelector('[role="dialog"]')) return;
if (!areaRef.current || areaRef.current.closest(".hidden")) return;
const el = e.target as HTMLElement | null;
if (el?.closest?.(".xterm")) return;
if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) return;
const digit = /^Digit([1-4])$/.exec(e.code);
if (digit) {
const s = shown[Number(digit[1]) - 1];
if (s === undefined) return;
e.preventDefault();
change((l) => (l.root && l.focus !== s ? { ...l, focus: s } : l));
} else if (e.code === "Backslash" && focusSlot !== null) {
e.preventDefault();
if (free !== null && shown.length < MAX_PANES) void newSplit(focusSlot, "right");
} else if (e.code === "KeyW" && focusSlot !== null && shown.length > 1) {
e.preventDefault();
change((l) => closeInLayout(l, focusSlot));
}
};
useEffect(() => {
const on = (e: KeyboardEvent) => onKeys.current(e);
document.addEventListener("keydown", on);
return () => document.removeEventListener("keydown", on);
}, []);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div ref={areaRef} className="flex min-h-0 flex-1 flex-col">
{lane && (
<ConsoleTabs
lane={lane}
@@ -326,8 +417,12 @@ function ConsoleArea({
shownRunIds={shown.map((s) => runOf(s)?.id).filter((id): id is string => !!id)}
onOpen={(runId, split) => {
onRunsChanged();
onOpenRun(runId, { split });
// A tab click is not a new history entry: Back leaves the
// session view instead of walking back through every tab.
onOpenRun(runId, { split, replace: true });
}}
sessions={tabSessions}
onRenamed={() => setSessionsVersion((v) => v + 1)}
onClosed={(runId) => ended(slotOf(runId))}
onHome={onNewSession}
// While one pane is shown, its split menus (a split pane has its own).
@@ -351,11 +446,6 @@ function ConsoleArea({
}
/>
)}
{splitError && (
<p role="alert" className="px-3 pt-2 text-xs text-status-danger">
{splitError}
</p>
)}
<PaneGrid
layout={view}
mounted={mounted}
@@ -366,6 +456,7 @@ function ConsoleArea({
tools={splitTools}
toolLabel={aiTools.label}
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
stateOf={stateOf}
renderPane={(slot) => (
<LaneConsolePane
lanes={lanes}
@@ -433,7 +524,10 @@ export function Workspace() {
(prev) => {
const next = new URLSearchParams(prev);
// A run belongs to its lane: another lane opens on its home.
if (next.get("lane") !== String(id)) next.delete("run");
if (next.get("lane") !== String(id)) {
next.delete("run");
next.delete("session");
}
if (id === null) next.delete("lane");
else next.set("lane", String(id));
return next;
@@ -454,7 +548,10 @@ export function Workspace() {
}
if (paramId === null) setSelectedLaneId(selectedLaneId);
}, [lanesLoaded, selectedLaneId, paramId, setSelectedLaneId]);
const [laneActionError, setLaneActionError] = useState<string | null>(null);
// A lane action's failure, as a toast (it stays until dismissed).
const setLaneActionError = (message: string | null) => {
if (message) toast.error(tLanes("actionError", { message }));
};
const [destructive, setDestructive] = useState<{
lane: Lane;
action: "reset" | "remove";
@@ -516,32 +613,119 @@ export function Workspace() {
/* private mode / disabled storage: the panel just forgets between visits */
}
}, [changesOpen]);
// A preset name, or the fraction a splitter drag left (stored as a number).
const [changesSize, setChangesSize] = useState<ChangesSize>(() => {
try {
const saved = window.localStorage.getItem(CHANGES_SIZE_KEY) as ChangesSize | null;
return saved && CHANGES_SIZES.includes(saved) ? saved : "compact";
const saved = window.localStorage.getItem(CHANGES_SIZE_KEY);
if (saved && (CHANGES_SIZES as readonly string[]).includes(saved))
return saved as ChangesPreset;
const n = Number(saved);
return saved && Number.isFinite(n) ? clampChanges(n) : "compact";
} catch {
return "compact";
}
});
useEffect(() => {
try {
window.localStorage.setItem(CHANGES_SIZE_KEY, changesSize);
window.localStorage.setItem(CHANGES_SIZE_KEY, String(changesSize));
} catch {
/* same as above: a forgotten width, not an error */
}
}, [changesSize]);
const changesFull = changesSize === "full";
const changesPreset = changesStep(changesSize);
const nextChangesSize =
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesSize) + 1) % CHANGES_SIZES.length]!;
const changesNextLabel =
changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1]
? tLanes("changes.collapse")
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesPreset) + 1) % CHANGES_SIZES.length]!;
const changesNextLabel = changesFull
? tLanes("changes.collapse")
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
const changesRowRef = useRef<HTMLDivElement>(null);
// The row's width: the panel never gets narrower than its 18rem minimum,
// so the splitter is drawn — and dragged — no further than that edge.
const [rowWidth, setRowWidth] = useState(0);
useEffect(() => {
const el = changesRowRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => setRowWidth(el.clientWidth));
ro.observe(el);
setRowWidth(el.clientWidth);
return () => ro.disconnect();
}, []);
const minChanges = rowWidth ? Math.min(0.85, CHANGES_MIN_PX / rowWidth) : 0.15;
// The session the opened run holds, when that run works in a worktree of
// its own (`claude --worktree`): the Changes panel shows that tree.
// its own (`claude --worktree`): the Changes panel shows that tree. A
// replayed session (`?session=`) shows its own worktree the same way.
const openedRun = runParam ? activeRuns?.items.find((r) => r.id === runParam) : undefined;
const gitSession = openedRun?.worktree && openedRun.sessionId ? openedRun.sessionId : undefined;
// Its worktree is checked once (`GET …/git?session=`): an ended session's
// worktree Claude already removed answers ENOWORKTREE, and the panel then
// shows the workspace's own folder instead of an error.
const [replayed, setReplayed] = useState<{ id: string; worktree: boolean } | null>(null);
const replayLane = lanes.find((l) => l.id === selectedLaneId);
useEffect(() => {
setReplayed(null);
if (!sessionParam || !replayLane) return;
let live = true;
void (async () => {
try {
const { session } = await api.sessions.get(sessionParam, { events: false });
if (!sessionWorktreeName(replayLane, session)) {
if (live) setReplayed({ id: sessionParam, worktree: false });
return;
}
const ok = await api.lanes
.git({ lane: replayLane.id, session: sessionParam })
.then(() => true)
.catch((err: { code?: string }) => err?.code !== "ENOWORKTREE");
if (live) setReplayed({ id: sessionParam, worktree: ok });
} catch {
/* no row: the workspace's folder */
}
})();
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- the lane by id
}, [sessionParam, replayLane?.id]);
const gitSession =
openedRun?.worktree && openedRun.sessionId
? openedRun.sessionId
: sessionParam && replayed?.id === sessionParam && replayed.worktree
? sessionParam
: undefined;
// An unsaved whole-file edit in the Changes panel. While it is open the
// panel stays on the tree it was opened in — moving it (another workspace,
// session or worktree) would remount it and drop the edit — until the user
// says to discard it, or saves.
const [changesDirty, setChangesDirty] = useState(false);
const wantPanel =
selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession };
const wantPanelKey = wantPanel ? `${wantPanel.laneId}:${wantPanel.session ?? ""}` : "";
const [panelAt, setPanelAt] = useState(wantPanel);
const panelKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
const asking = useRef(false);
useEffect(() => {
if (wantPanelKey === panelKey) return;
if (!changesDirty) {
setPanelAt(wantPanel);
return;
}
if (asking.current) return;
asking.current = true;
void confirmDiscard().then((ok) => {
asking.current = false;
if (!ok) return;
setChangesDirty(false);
setPanelAt(wantPanel);
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
}, [wantPanelKey, panelKey, changesDirty]);
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
const afterDrop = (fn: () => void) => {
if (!changesDirty) return fn();
void confirmDiscard().then((ok) => ok && fn());
};
// 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
@@ -697,8 +881,7 @@ export function Workspace() {
// editor: no page padding, no cards, dividers between its regions.
const sessionView = !!currentLane && !home;
const viewportLocked =
sessionView ||
(!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full")));
sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
setLaneActionError(null);
@@ -807,7 +990,7 @@ export function Workspace() {
<WorkspaceHome
lane={currentLane}
onRenamed={() => void refreshLanes()}
onMenuAction={(action) => {
onMenuAction={(action, runId) => {
if (action === "new-here") {
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
setAddLaneOpen(true);
@@ -816,17 +999,17 @@ export function Workspace() {
} else if (action === "started") {
setSearchParams({
lane: String(currentLane.id),
run: `agenthub-lane-${currentLane.id}`,
run: runId ?? `agenthub-lane-${currentLane.id}`,
});
}
}}
/>
)}
<div className={`flex min-h-0 flex-1${home ? " hidden" : ""}`}>
<div ref={changesRowRef} className={`relative 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${
currentLane && changesOpen && changesSize === "full" ? " hidden" : ""
currentLane && changesOpen && changesFull ? " hidden" : ""
}`}
>
{/* No header row: the console's tab bar is the header. Only a
@@ -840,7 +1023,7 @@ export function Workspace() {
<select
data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")}
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
className="select !w-auto !rounded !py-0.5 !pl-2 !pr-6 !text-xs text-fg-secondary"
value={currentLane.pipeline}
disabled={!!viewedFeature}
title={
@@ -873,7 +1056,7 @@ export function Workspace() {
{features.length > 0 && (
<select
data-testid="feature-picker"
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
className="select !w-auto !rounded !py-0.5 !pl-2 !pr-6 !text-xs"
value={viewedFeatureSlug ?? ""}
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
>
@@ -966,8 +1149,8 @@ export function Workspace() {
lane={currentLane}
sessionId={sessionParam}
liveRuns={activeRuns?.items ?? []}
onResumed={(runId) =>
setSearchParams({ lane: String(currentLane.id), run: runId })
onResumed={(runId, laneId) =>
setSearchParams({ lane: String(laneId ?? currentLane.id), run: runId })
}
/>
</div>
@@ -992,8 +1175,9 @@ export function Workspace() {
setSearchParams(
{ lane: String(currentLane.id), run: runId },
{
// Following the focus is not a new history entry.
replace: opts?.focusOnly,
// Following the focus, or a tab click, is not a new
// history entry.
replace: opts?.focusOnly || opts?.replace,
state: opts?.split
? { split: true }
: opts?.focusOnly
@@ -1008,14 +1192,29 @@ export function Workspace() {
</div>
</section>
{currentLane && changesOpen && !changesFull && (
// Drag (or arrow keys) to any width; the button keeps its presets.
<Divider
gridRef={changesRowRef}
dir="row"
at={1 - Math.max(changesRatio(changesSize), minChanges)}
box={{ x: 0, y: 0, w: 1, h: 1 }}
label={tLanes("changes.splitter")}
onRatio={(r) => setChangesSize(clampChanges(Math.max(1 - r, minChanges)))}
/>
)}
{currentLane &&
(changesOpen ? (
<aside
id="lane-changes-panel"
data-testid="lane-changes-card"
className={`flex shrink-0 flex-col bg-surface-1 ${CHANGES_WIDTH[changesSize]}${
changesSize === "full" ? "" : " border-l border-border"
}`}
aria-label={tLanes("changes.title")}
style={
typeof changesSize === "number" ? { width: `${changesSize * 100}%` } : undefined
}
className={`flex shrink-0 flex-col bg-surface-1 ${
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
>
{/* Activity bar: Files · Changes · Sessions, then the resize
control — the same height as the console's tab bar, so the
@@ -1030,7 +1229,12 @@ export function Workspace() {
type="button"
data-testid={`panel-tab-${id}`}
aria-pressed={panelTab === id}
onClick={() => setPanelTab(id)}
// Narrow, a tab switch leaves the open file (and its
// edit); at full width the editor stays put.
onClick={() =>
panelTab !== id &&
(changesFull ? setPanelTab(id) : afterDrop(() => setPanelTab(id)))
}
title={tLanes(`panel.${id}`)}
aria-label={tLanes(`panel.${id}`)}
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
@@ -1062,10 +1266,13 @@ export function Workspace() {
data-testid="lane-changes-resize"
onClick={() => {
// Past the widest step the next state is "gone", which
// is what the rail button reopens at `compact`.
if (changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1]) {
setChangesOpen(false);
setChangesSize("compact");
// is what the rail button reopens at `compact` — and
// what drops an unsaved edit, so that asks first.
if (changesFull) {
afterDrop(() => {
setChangesOpen(false);
setChangesSize("compact");
});
} else {
setChangesSize(nextChangesSize);
}
@@ -1082,31 +1289,35 @@ export function Workspace() {
{/* Icon-only, and it draws the width the click LANDS on,
not the current one — the label the tooltip carries. */}
{(() => {
const Next = CHANGES_NEXT_ICON[changesSize];
const Next = CHANGES_NEXT_ICON[changesPreset];
return <Next className="h-4 w-4" />;
})()}
</button>
</div>
<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);
}}
/>
{panelAt && (
<LaneChangesPanel
// A new tree is a new panel: no read for the previous one
// can land in it. `panelAt` holds still while an edit is
// unsaved (see above).
key={panelKey}
laneId={panelAt.laneId}
session={panelAt.session}
tab={panelTab}
full={changesFull}
onDirtyChange={setChangesDirty}
// A sibling worktree forks from the lane's SOURCE repo when
// 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>
) : (
@@ -1167,15 +1378,6 @@ export function Workspace() {
}}
/>
)}
{laneActionError && (
<p
role="alert"
className="rounded border border-status-danger bg-status-danger/40 px-3 py-2 text-sm text-status-danger"
>
{tLanes("actionError", { message: laneActionError })}
</p>
)}
</div>
);
}
+33 -6
View File
@@ -55,6 +55,8 @@ let nextRunId = 1;
// tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
}));
vi.mock("../../components/lanes/startSession", () => ({
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
freeSlot: (laneId: number, runs: Array<{ laneId: number; slot?: number; status: string }>) => {
@@ -63,6 +65,25 @@ vi.mock("../../components/lanes/WorkspaceHome", () => ({
);
return [0, 1, 2, 3].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.
startInFreeTab: async (laneId: number, body: Record<string, unknown>) => {
const { api } = await import("../../lib/api");
const { items } = await api.run.list();
const busy = new Set(
(items as Array<{ laneId: number; slot?: number; status: string }>)
.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;
await api.lanes.action(laneId, "start", { ...body, slot: "auto" });
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
},
}));
// Closing an AI tab asks first (the themed confirm needs App's host): yes.
vi.mock("../../lib/confirm", () => ({
confirmDialog: vi.fn(async () => true),
confirmDiscard: vi.fn(async () => true),
}));
// An ended session's replay (its own suite): a stub that names its session.
vi.mock("../../components/lanes/SessionReplay", () => ({
@@ -243,6 +264,9 @@ vi.mock("../../lib/api", async (importOriginal) => {
},
sessions: {
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
get: vi.fn().mockImplementation(async (id: string) => ({
session: { id, cwd: "/workspace/a", tool: "claude" },
})),
transcript: vi.fn().mockResolvedValue({ messages: [] }),
},
},
@@ -594,7 +618,7 @@ describe("console tabs", () => {
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 });
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
});
it("disables + once all four slots are taken", async () => {
@@ -782,12 +806,15 @@ describe("split panes", () => {
await right(screen.getByTestId("pane-header-1"));
await user.click(screen.getByTestId("pane-menu-split-bottom"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 3, shell: true });
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
// The new terminal (the slot the server picked, 3) opened under pane 1.
await settle();
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("Close pane"));
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Hide pane"));
await settle();
expect(shownPanes()).toEqual(["pane-0"]);
expect(shownPanes()).toEqual(["pane-0", "pane-3"]);
expect(api.run.kill).not.toHaveBeenCalled();
await right(screen.getByTestId("console-tab-2"));
@@ -804,7 +831,7 @@ describe("split panes", () => {
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 });
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
});
it("a split menu starts an AI tool, bare, in the first free slot", async () => {
@@ -815,7 +842,7 @@ describe("split panes", () => {
const user = userEvent.setup();
await user.click(screen.getByTestId("pane-split-right-1"));
await user.click(screen.getByTestId("pane-split-new-right-1-claude"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 2, prompt: "" });
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", prompt: "" });
});
it("with one pane, the split menus sit beside Tools and split it", async () => {