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
|
* 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
|
* 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.
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -365,8 +368,14 @@ export function PaneGrid({
|
|||||||
onNewSplit,
|
onNewSplit,
|
||||||
stateOf,
|
stateOf,
|
||||||
onStop,
|
onStop,
|
||||||
|
keyOf = (slot) => slot,
|
||||||
|
kept = [],
|
||||||
}: {
|
}: {
|
||||||
layout: PaneLayout;
|
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). */
|
/** Stops the session in `slot` (the header's X). */
|
||||||
onStop?: (slot: number) => void;
|
onStop?: (slot: number) => void;
|
||||||
/** A live AI run's state for its header dot (none for a shell). */
|
/** 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));
|
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 r = rectOf.get(slot);
|
||||||
const run = runOf(slot);
|
const run = runOf(slot);
|
||||||
const focused = layout.focus === slot;
|
const focused = layout.focus === slot;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={slot}
|
key={key}
|
||||||
data-testid={`pane-${slot}`}
|
data-testid={`pane-${slot}`}
|
||||||
data-focused={r && focused ? "true" : undefined}
|
data-focused={r && focused ? "true" : undefined}
|
||||||
// A menu opened in this pane stays inside it (`menuRoom`).
|
// 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";
|
const CONTEXT_OPEN_KEY = "agenthub.workspace.contextOpen";
|
||||||
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
|
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
|
||||||
const SELECTED_KEY = "agenthub.workspace.selected";
|
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
|
/** 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
|
* 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
|
* (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
|
* 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
|
* 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({
|
function ConsoleArea({
|
||||||
lanes,
|
lanes,
|
||||||
selectedLaneId,
|
selectedLaneId,
|
||||||
@@ -481,6 +487,51 @@ function ConsoleArea({
|
|||||||
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort((a, b) => a - b);
|
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort((a, b) => a - b);
|
||||||
const focusSlot = view.focus;
|
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
|
// 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, 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
|
// 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)}
|
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
|
||||||
stateOf={stateOf}
|
stateOf={stateOf}
|
||||||
onStop={(slot) => void stopPane(slot)}
|
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) => (
|
renderPane={(slot) => (
|
||||||
<LaneConsolePane
|
<LaneConsolePane
|
||||||
lanes={lanes}
|
lanes={lanes}
|
||||||
|
|||||||
@@ -675,6 +675,56 @@ describe("console tabs", () => {
|
|||||||
expect(screen.getByTestId("console-tab-new")).toBeDisabled();
|
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 () => {
|
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");
|
window.localStorage.setItem("agenthub.workspace.allTabs", "1");
|
||||||
withRuns([live(0), { ...live(0), id: "agenthub-lane-2-1", laneId: 2, slot: 1 }]);
|
withRuns([live(0), { ...live(0), id: "agenthub-lane-2-1", laneId: 2, slot: 1 }]);
|
||||||
|
|||||||
Reference in New Issue
Block a user