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:
@@ -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`).
|
||||
|
||||
@@ -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 }]);
|
||||
|
||||
Reference in New Issue
Block a user