perf(workspace): switching workspaces keeps their terminals mounted

Panes were keyed by slot alone, so slot 0 of the next workspace reused the
previous one's pane: every switch disposed its terminals and attached new
ones (a WebSocket each, the whole screen replayed). The live panes of the
last 4 workspaces left now stay mounted as hidden siblings in the same
grid, keyed by paneKey (lane x 10 + slot), so going back is instant.

Measured on 3 workspaces x 2-3 shell panes, switching back to one already
opened: script 210 -> 52 ms, main thread 370 -> 120 ms, long tasks
120 -> 0 ms, terminal re-attaches 2-3 -> 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 15:03:27 +07:00
parent b08482c6aa
commit d72a255294
3 changed files with 128 additions and 3 deletions
+55 -1
View File
@@ -148,6 +148,9 @@ const CHANGES_DRAGGED_KEY = "agenthub.workspace.changesDragged";
const CONTEXT_OPEN_KEY = "agenthub.workspace.contextOpen";
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
const SELECTED_KEY = "agenthub.workspace.selected";
/** Workspaces left most recently whose live panes stay mounted (hidden), so
* switching back to one shows its terminals as they were, with no re-attach. */
const KEEP_LANES = 4;
/** The Changes panel's two preset widths. Its header has two controls, like a
* window's: Full screen (Restore once at full screen, back to the last
@@ -198,7 +201,10 @@ function slotOf(runId: string | null | undefined): number {
* (Settings → Appearance) the tabs hold EVERY workspace's live runs, stored
* once (`all`): a leaf is a `paneKey` (lane × 10 + slot), each pane is bound
* to its own run's lane, and focusing one selects its workspace (the URL's
* `?lane=` follows `?run=`). */
* `?lane=` follows `?run=`). Without it, the live panes of the last
* `KEEP_LANES` workspaces left stay mounted too — hidden siblings in the same
* grid, each keyed by its `paneKey` — so a workspace switch neither disposes
* nor re-attaches a terminal. */
function ConsoleArea({
lanes,
selectedLaneId,
@@ -481,6 +487,51 @@ function ConsoleArea({
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort((a, b) => a - b);
const focusSlot = view.focus;
// The workspaces just left, newest first — updated DURING the render that
// switches, because the switch's own commit would otherwise unmount the
// panes before an effect could keep them. Idempotent on a repeat render.
const leftLanes = useRef<number[]>([]);
const lastLane = useRef(selectedLaneId);
if (lastLane.current !== selectedLaneId) {
const prev = lastLane.current;
if (prev !== null)
leftLanes.current = [prev, ...leftLanes.current.filter((id) => id !== prev)].slice(
0,
KEEP_LANES
);
lastLane.current = selectedLaneId;
}
const kept = allTabs
? []
: (activeRuns?.items ?? [])
.filter(
(r) =>
r.laneId !== null &&
r.laneId !== selectedLaneId &&
r.status === "running" &&
leftLanes.current.includes(r.laneId)
)
.map((r) => {
const id = r.laneId as number;
const slot = r.slot ?? 0;
return {
key: paneKey(id, slot),
node: (
<LaneConsolePane
lanes={lanes}
laneId={id}
slot={slot}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
onNewSession={onNewSession}
onRunExit={onRunsChanged}
autoFocus={false}
attachRequest={null}
/>
),
};
});
// Alt+1–4 focus a pane, Alt+\ splits the focused one right with a new
// 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
@@ -578,6 +629,9 @@ function ConsoleArea({
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
stateOf={stateOf}
onStop={(slot) => void stopPane(slot)}
// Keyed by workspace too, so a kept pane coming back is the same one.
keyOf={allTabs ? undefined : (slot) => paneKey(selectedLaneId ?? 0, slot)}
kept={kept}
renderPane={(slot) => (
<LaneConsolePane
lanes={lanes}