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
+23 -2
View File
@@ -19,6 +19,9 @@
* the close as one menu. Dividers resize by pointer or by arrow keys; a drag
* moves only the line and commits the ratio on release, so the layout is
* saved and the terminals refit once, not on every pointer move.
* `kept` panes (another workspace's live sessions) are hidden children of
* the SAME list, keyed by `keyOf`, so a pane moves between shown and kept
* without remounting — a workspace switch never re-attaches its terminals.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -365,8 +368,14 @@ export function PaneGrid({
onNewSplit,
stateOf,
onStop,
keyOf = (slot) => slot,
kept = [],
}: {
layout: PaneLayout;
/** A pane's React key: unique across `mounted` and `kept` (a `paneKey`). */
keyOf?: (slot: number) => number;
/** Panes kept mounted, always hidden — never in the layout. */
kept?: { key: number; node: ReactNode }[];
/** Stops the session in `slot` (the header's X). */
onStop?: (slot: number) => void;
/** A live AI run's state for its header dot (none for a shell). */
@@ -490,13 +499,25 @@ export function PaneGrid({
onChange((l) => applyDrop(l, src, z.slot, z.zone));
}}
>
{mounted.map((slot) => {
{[
...mounted.map((slot) => ({ key: keyOf(slot), slot, node: null as ReactNode })),
...kept.map((k) => ({ key: k.key, slot: null, node: k.node })),
].map(({ key, slot, node }) => {
if (slot === null) {
// The same element shape as a hidden pane below, so React keeps it.
return (
<div key={key} data-testid={`pane-kept-${key}`} className="hidden">
{false}
<div className="flex min-h-0 flex-1 flex-col">{node}</div>
</div>
);
}
const r = rectOf.get(slot);
const run = runOf(slot);
const focused = layout.focus === slot;
return (
<div
key={slot}
key={key}
data-testid={`pane-${slot}`}
data-focused={r && focused ? "true" : undefined}
// A menu opened in this pane stays inside it (`menuRoom`).
+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}
@@ -675,6 +675,56 @@ describe("console tabs", () => {
expect(screen.getByTestId("console-tab-new")).toBeDisabled();
});
// Switching workspaces used to hand slot 0 of the new workspace the SAME
// pane as the old one's slot 0: its terminal was disposed and a new one
// attached, replaying the whole screen — the lag of every switch.
it("keeps a workspace's terminals mounted (hidden) while another workspace is shown", async () => {
withRuns([live(0), { ...live(0), id: "agenthub-lane-2", laneId: 2, slot: 0 }]);
function Go() {
const [, setParams] = useSearchParams();
return (
<>
{[1, 2].map((id) => (
<button
key={id}
data-testid={`go-${id}`}
onClick={() => setParams({ lane: String(id), run: `agenthub-lane-${id}` })}
/>
))}
</>
);
}
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Go />
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
const term = (id: string) =>
screen.getAllByTestId("terminal-view").find((el) => el.dataset.runId === id);
const first = term("agenthub-lane-1");
expect(first).toBeDefined();
const user = userEvent.setup();
await user.click(screen.getByTestId("go-2"));
await settle();
const second = term("agenthub-lane-2");
expect(second?.closest(".hidden")).toBeNull();
// Lane 1's terminal is the same element, only hidden.
expect(term("agenthub-lane-1")).toBe(first);
expect(first!.closest(".hidden")).not.toBeNull();
await user.click(screen.getByTestId("go-1"));
await settle();
expect(term("agenthub-lane-1")).toBe(first);
expect(first!.closest(".hidden")).toBeNull();
expect(term("agenthub-lane-2")).toBe(second);
// Only the shown workspace's tabs are in the tab bar.
expect(
screen.getAllByRole("tab").filter((t) => t.dataset.testid?.startsWith("console-tab-"))
).toHaveLength(1);
});
it("with tabs from every workspace on, shows another workspace's session and selects it on click", async () => {
window.localStorage.setItem("agenthub.workspace.allTabs", "1");
withRuns([live(0), { ...live(0), id: "agenthub-lane-2-1", laneId: 2, slot: 1 }]);