perf(app): keep warm workspace switching responsive (#3447)
* perf(app): reduce warm workspace switch work Retained workspaces avoided mounts but pane activity still rerendered the full composer, message input, and agent-control presentation. Keep the activity contract intact while moving focus-sensitive behavior behind small context consumers, and add a Playwright benchmark that measures rapid retained workspace switching on one browser clock. * perf(app): accelerate retained workspace switching Avoid repeated retained-stream layout reconciliation and unrelated workspace structure recomputation. Use the lightweight route transition while the host is already focused, while preserving targeted root POP_TO for returns from app-wide routes where it prevents duplicate hidden decks. * perf(app): defer composer focus during workspace switches * perf(app): retain recently used workspaces longer Keep up to ten workspace screens warm so normal multi-workspace navigation does not repeatedly rebuild recently used timelines. Inactive screens expire after ten minutes, while the active screen remains protected and capacity eviction stays least-recently-used. * fix(app): reconcile retained timeline geometry * fix(app): refresh retained reading position
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import {
|
||||
getNextWorkspaceDeckExpirationDelay,
|
||||
orderWorkspaceSelectionsForStableRender,
|
||||
pruneMountedWorkspaceSelections,
|
||||
reconcileRetainedWorkspaceSelections,
|
||||
resolveWorkspaceDeckEntries,
|
||||
shouldKeepWorkspaceDeckEntryMounted,
|
||||
WORKSPACE_DECK_INACTIVE_TTL_MS,
|
||||
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
} from "@/screens/workspace/workspace-deck-retention";
|
||||
|
||||
function workspace(workspaceId: string, serverId = "server"): ActiveWorkspaceSelection {
|
||||
@@ -15,65 +18,115 @@ function mountedWorkspaceIds(selections: ActiveWorkspaceSelection[]): string[] {
|
||||
return selections.map((selection) => selection.workspaceId);
|
||||
}
|
||||
|
||||
describe("pruneMountedWorkspaceSelections", () => {
|
||||
it("retains the deck while an app-wide route temporarily clears the active workspace", () => {
|
||||
const mountedSelections = [workspace("A"), workspace("B")];
|
||||
function retained(workspaceId: string, inactiveSince: number | null) {
|
||||
return { selection: workspace(workspaceId), inactiveSince };
|
||||
}
|
||||
|
||||
function retainedWorkspaceIds(
|
||||
entries: ReturnType<typeof reconcileRetainedWorkspaceSelections>,
|
||||
): string[] {
|
||||
return entries.map((entry) => entry.selection.workspaceId);
|
||||
}
|
||||
|
||||
describe("reconcileRetainedWorkspaceSelections", () => {
|
||||
it("retains inactive workspaces for ten minutes across app-wide routes", () => {
|
||||
const retainedEntries = [retained("A", 1_000), retained("B", 2_000)];
|
||||
|
||||
expect(
|
||||
pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedSelections,
|
||||
reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: retainedEntries,
|
||||
activeSelection: null,
|
||||
now: 2_000 + WORKSPACE_DECK_INACTIVE_TTL_MS - 1,
|
||||
}),
|
||||
).toBe(mountedSelections);
|
||||
).toEqual([retained("B", 2_000)]);
|
||||
});
|
||||
|
||||
it("keeps the active workspace and the two most recent inactive workspaces", () => {
|
||||
const mountedAfterA = pruneMountedWorkspaceSelections({
|
||||
currentSelections: [],
|
||||
activeSelection: workspace("A"),
|
||||
it("keeps the active workspace and nine most recently activated inactive workspaces", () => {
|
||||
let retainedEntries = Array.from(
|
||||
{ length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES },
|
||||
(_, index) => retained(String(index + 1), index + 1),
|
||||
).toReversed();
|
||||
|
||||
retainedEntries = reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: retainedEntries,
|
||||
activeSelection: workspace("11"),
|
||||
now: 11,
|
||||
});
|
||||
const mountedAfterB = pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedAfterA,
|
||||
|
||||
expect(retainedWorkspaceIds(retainedEntries)).toEqual([
|
||||
"11",
|
||||
"10",
|
||||
"9",
|
||||
"8",
|
||||
"7",
|
||||
"6",
|
||||
"5",
|
||||
"4",
|
||||
"3",
|
||||
"2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("starts the TTL when the active workspace becomes inactive", () => {
|
||||
const retainedEntries = reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: [retained("A", null), retained("B", 2_000)],
|
||||
activeSelection: workspace("B"),
|
||||
});
|
||||
const mountedAfterC = pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedAfterB,
|
||||
activeSelection: workspace("C"),
|
||||
});
|
||||
const mountedAfterD = pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedAfterC,
|
||||
activeSelection: workspace("D"),
|
||||
now: 3_000,
|
||||
});
|
||||
|
||||
expect(mountedWorkspaceIds(mountedAfterD)).toEqual(["D", "C", "B"]);
|
||||
expect(retainedEntries).toEqual([retained("B", null), retained("A", 3_000)]);
|
||||
});
|
||||
|
||||
it("retains the active workspace", () => {
|
||||
const mountedSelections = pruneMountedWorkspaceSelections({
|
||||
currentSelections: [workspace("A")],
|
||||
it("never expires the active workspace", () => {
|
||||
const retainedEntries = reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: [retained("A", 0)],
|
||||
activeSelection: workspace("A"),
|
||||
now: WORKSPACE_DECK_INACTIVE_TTL_MS,
|
||||
});
|
||||
|
||||
expect(mountedWorkspaceIds(mountedSelections)).toEqual(["A"]);
|
||||
expect(retainedEntries).toEqual([retained("A", null)]);
|
||||
});
|
||||
|
||||
it("expires an inactive workspace at the TTL boundary", () => {
|
||||
const retainedEntries = reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: [retained("B", null), retained("A", 0)],
|
||||
activeSelection: workspace("B"),
|
||||
now: WORKSPACE_DECK_INACTIVE_TTL_MS,
|
||||
});
|
||||
|
||||
expect(retainedEntries).toEqual([retained("B", null)]);
|
||||
});
|
||||
|
||||
it("deduplicates retained workspace selections", () => {
|
||||
const mountedSelections = pruneMountedWorkspaceSelections({
|
||||
currentSelections: [workspace("B"), workspace("A"), workspace("B")],
|
||||
const retainedEntries = reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: [retained("B", 2), retained("A", 1), retained("B", 0)],
|
||||
activeSelection: workspace("A"),
|
||||
now: 3,
|
||||
});
|
||||
|
||||
expect(mountedWorkspaceIds(mountedSelections)).toEqual(["A", "B"]);
|
||||
expect(retainedWorkspaceIds(retainedEntries)).toEqual(["A", "B"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNextWorkspaceDeckExpirationDelay", () => {
|
||||
it("returns the remaining lifetime of the oldest inactive workspace", () => {
|
||||
expect(
|
||||
getNextWorkspaceDeckExpirationDelay({
|
||||
entries: [retained("C", null), retained("B", 2_000), retained("A", 1_000)],
|
||||
activeSelection: workspace("C"),
|
||||
now: 4_000,
|
||||
}),
|
||||
).toBe(WORKSPACE_DECK_INACTIVE_TTL_MS - 3_000);
|
||||
});
|
||||
|
||||
it("always allows at least the active workspace", () => {
|
||||
const mountedSelections = pruneMountedWorkspaceSelections({
|
||||
currentSelections: [workspace("A"), workspace("B")],
|
||||
activeSelection: workspace("C"),
|
||||
maxMountedWorkspaces: 0,
|
||||
});
|
||||
|
||||
expect(mountedWorkspaceIds(mountedSelections)).toEqual(["C"]);
|
||||
it("does not schedule expiration for the active workspace", () => {
|
||||
expect(
|
||||
getNextWorkspaceDeckExpirationDelay({
|
||||
entries: [retained("A", null)],
|
||||
activeSelection: workspace("A"),
|
||||
now: 4_000,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
export const WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES = 3;
|
||||
export const WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES = 10;
|
||||
export const WORKSPACE_DECK_INACTIVE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
interface PruneMountedWorkspaceSelectionsInput {
|
||||
currentSelections: ActiveWorkspaceSelection[];
|
||||
export interface RetainedWorkspaceSelection {
|
||||
selection: ActiveWorkspaceSelection;
|
||||
inactiveSince: number | null;
|
||||
}
|
||||
|
||||
interface ReconcileRetainedWorkspaceSelectionsInput {
|
||||
currentEntries: RetainedWorkspaceSelection[];
|
||||
activeSelection: ActiveWorkspaceSelection | null;
|
||||
now: number;
|
||||
maxMountedWorkspaces?: number;
|
||||
inactiveTtlMs?: number;
|
||||
}
|
||||
|
||||
interface GetNextWorkspaceDeckExpirationDelayInput {
|
||||
entries: RetainedWorkspaceSelection[];
|
||||
activeSelection: ActiveWorkspaceSelection | null;
|
||||
now: number;
|
||||
inactiveTtlMs?: number;
|
||||
}
|
||||
|
||||
interface WorkspaceDeckEntryMountInput {
|
||||
@@ -30,53 +45,92 @@ export function areWorkspaceSelectionsEqual(
|
||||
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
|
||||
}
|
||||
|
||||
export function areWorkspaceSelectionListsEqual(
|
||||
left: ActiveWorkspaceSelection[],
|
||||
right: ActiveWorkspaceSelection[],
|
||||
export function areRetainedWorkspaceSelectionListsEqual(
|
||||
left: RetainedWorkspaceSelection[],
|
||||
right: RetainedWorkspaceSelection[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
return left.every((selection, index) =>
|
||||
areWorkspaceSelectionsEqual(selection, right[index] ?? null),
|
||||
);
|
||||
return left.every((entry, index) => {
|
||||
const otherEntry = right[index];
|
||||
return (
|
||||
otherEntry !== undefined &&
|
||||
entry.inactiveSince === otherEntry.inactiveSince &&
|
||||
areWorkspaceSelectionsEqual(entry.selection, otherEntry.selection)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function pruneMountedWorkspaceSelections({
|
||||
currentSelections,
|
||||
export function reconcileRetainedWorkspaceSelections({
|
||||
currentEntries,
|
||||
activeSelection,
|
||||
now,
|
||||
maxMountedWorkspaces = WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
}: PruneMountedWorkspaceSelectionsInput): ActiveWorkspaceSelection[] {
|
||||
if (!activeSelection) {
|
||||
return currentSelections;
|
||||
}
|
||||
|
||||
const maxSelections = Math.max(1, maxMountedWorkspaces);
|
||||
const nextSelections: ActiveWorkspaceSelection[] = [];
|
||||
inactiveTtlMs = WORKSPACE_DECK_INACTIVE_TTL_MS,
|
||||
}: ReconcileRetainedWorkspaceSelectionsInput): RetainedWorkspaceSelection[] {
|
||||
const maxSelections = activeSelection
|
||||
? Math.max(1, maxMountedWorkspaces)
|
||||
: Math.max(0, maxMountedWorkspaces);
|
||||
const nextEntries: RetainedWorkspaceSelection[] = [];
|
||||
const seenSelectionKeys = new Set<string>();
|
||||
|
||||
function appendSelection(selection: ActiveWorkspaceSelection): void {
|
||||
if (nextSelections.length >= maxSelections) {
|
||||
function appendEntry(entry: RetainedWorkspaceSelection): void {
|
||||
if (nextEntries.length >= maxSelections) {
|
||||
return;
|
||||
}
|
||||
const selectionKey = getWorkspaceSelectionKey(selection);
|
||||
const selectionKey = getWorkspaceSelectionKey(entry.selection);
|
||||
if (seenSelectionKeys.has(selectionKey)) {
|
||||
return;
|
||||
}
|
||||
seenSelectionKeys.add(selectionKey);
|
||||
nextSelections.push(selection);
|
||||
nextEntries.push(entry);
|
||||
}
|
||||
|
||||
appendSelection(activeSelection);
|
||||
if (activeSelection) {
|
||||
appendEntry({ selection: activeSelection, inactiveSince: null });
|
||||
}
|
||||
|
||||
for (const selection of currentSelections) {
|
||||
if (areWorkspaceSelectionsEqual(selection, activeSelection)) {
|
||||
for (const entry of currentEntries) {
|
||||
if (areWorkspaceSelectionsEqual(entry.selection, activeSelection)) {
|
||||
continue;
|
||||
}
|
||||
appendSelection(selection);
|
||||
const inactiveSince = entry.inactiveSince ?? now;
|
||||
if (now - inactiveSince >= inactiveTtlMs) {
|
||||
continue;
|
||||
}
|
||||
appendEntry({ selection: entry.selection, inactiveSince });
|
||||
}
|
||||
|
||||
return nextSelections;
|
||||
if (areRetainedWorkspaceSelectionListsEqual(currentEntries, nextEntries)) {
|
||||
return currentEntries;
|
||||
}
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
export function getNextWorkspaceDeckExpirationDelay({
|
||||
entries,
|
||||
activeSelection,
|
||||
now,
|
||||
inactiveTtlMs = WORKSPACE_DECK_INACTIVE_TTL_MS,
|
||||
}: GetNextWorkspaceDeckExpirationDelayInput): number | null {
|
||||
let nextExpirationDelay: number | null = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
areWorkspaceSelectionsEqual(entry.selection, activeSelection) ||
|
||||
entry.inactiveSince === null
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const expirationDelay = Math.max(0, entry.inactiveSince + inactiveTtlMs - now);
|
||||
nextExpirationDelay =
|
||||
nextExpirationDelay === null
|
||||
? expirationDelay
|
||||
: Math.min(nextExpirationDelay, expirationDelay);
|
||||
}
|
||||
|
||||
return nextExpirationDelay;
|
||||
}
|
||||
|
||||
export function orderWorkspaceSelectionsForStableRender(
|
||||
|
||||
Reference in New Issue
Block a user