From d72a2552942b0145d5277e0d7b8835ddc760f35b Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 13 Oct 2026 15:03:27 +0700 Subject: [PATCH] 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 --- client/src/components/run/PaneGrid.tsx | 25 ++++++++- client/src/pages/Workspace.tsx | 56 ++++++++++++++++++- client/src/pages/__tests__/Workspace.test.tsx | 50 +++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) diff --git a/client/src/components/run/PaneGrid.tsx b/client/src/components/run/PaneGrid.tsx index ca819d12..e73518c3 100644 --- a/client/src/components/run/PaneGrid.tsx +++ b/client/src/components/run/PaneGrid.tsx @@ -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ĩ */ @@ -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 ( +
+ {false} +
{node}
+
+ ); + } const r = rectOf.get(slot); const run = runOf(slot); const focused = layout.focus === slot; return (