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`).