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:
Mohamed Boudra
2026-08-16 12:40:28 +02:00
committed by GitHub
parent d60d92e909
commit d4435bd016
20 changed files with 1297 additions and 228 deletions
@@ -1220,18 +1220,45 @@ describe("createWebStreamStrategy", () => {
expect(scrollTo).not.toHaveBeenCalled();
});
it("keeps the retained viewport mounted while inactive and reconciles it once on return", () => {
const observed = new Map<Element, ReturnType<typeof vi.fn>>();
it("keeps the retained viewport mounted while inactive and reconciles it once on return", async () => {
interface ResizeObservation {
callback: ResizeObserverCallback;
disconnect: ReturnType<typeof vi.fn>;
observer: ResizeObserver;
}
const observed = new Map<Element, ResizeObservation>();
Object.defineProperty(globalThis, "ResizeObserver", {
configurable: true,
value: class ResizeObserver {
value: class TestResizeObserver {
readonly disconnect = vi.fn();
constructor(private readonly callback: ResizeObserverCallback) {}
observe(target: Element) {
observed.set(target, this.disconnect);
observed.set(target, {
callback: this.callback,
disconnect: this.disconnect,
observer: this as unknown as ResizeObserver,
});
}
},
});
const notifyResize = (...targets: Element[]) => {
const observation = targets[0] ? observed.get(targets[0]) : undefined;
if (!observation) {
throw new Error("Expected observed resize target");
}
observation.callback(
targets.map(
(target) =>
({
target,
contentRect: target.getBoundingClientRect(),
}) as ResizeObserverEntry,
),
observation.observer,
);
};
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
const top = typeof options === "object" ? (options.top ?? 0) : 0;
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
@@ -1239,6 +1266,7 @@ describe("createWebStreamStrategy", () => {
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const onReadingPositionChange = vi.fn();
const renderInput: StreamRenderInput = {
agentId: "agent",
segments: {
@@ -1257,6 +1285,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onReadingPositionChange,
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
@@ -1278,16 +1307,50 @@ describe("createWebStreamStrategy", () => {
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
const contentNode = scrollContainer.firstElementChild;
if (!(contentNode instanceof HTMLElement)) {
throw new Error("Expected agent chat content node");
}
const firstRow = contentNode.querySelector('[data-history-row-id="message-1"]');
const secondRow = contentNode.querySelector('[data-history-row-id="message-2"]');
if (!(firstRow instanceof HTMLElement) || !(secondRow instanceof HTMLElement)) {
throw new Error("Expected retained history rows");
}
let rowsShifted = false;
scrollContainer.getBoundingClientRect = vi.fn(() => ({ top: 0 }) as DOMRect);
firstRow.getBoundingClientRect = vi.fn(() => ({ bottom: rowsShifted ? 4 : 120 }) as DOMRect);
secondRow.getBoundingClientRect = vi.fn(() => ({ bottom: rowsShifted ? 120 : 160 }) as DOMRect);
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
const viewportObserverDisconnect = observed.get(scrollContainer);
expect(viewportObserverDisconnect).toBeDefined();
const viewportObservation = observed.get(scrollContainer);
expect(viewportObservation).toBeDefined();
act(() => notifyResize(scrollContainer, contentNode));
expect(onReadingPositionChange).toHaveBeenLastCalledWith("message-1");
act(() => root?.render(renderWithActivity(false)));
expect(container.querySelector('[data-testid="agent-chat-scroll"]')).toBe(scrollContainer);
expect(viewportObserverDisconnect).toHaveBeenCalledOnce();
expect(viewportObservation?.disconnect).toHaveBeenCalledOnce();
scrollTo.mockClear();
rowsShifted = true;
act(() => root?.render(renderWithActivity(true)));
act(() => notifyResize(scrollContainer, contentNode));
expect(onReadingPositionChange).toHaveBeenLastCalledWith("message-2");
expect(scrollTo).not.toHaveBeenCalled();
act(() => root?.render(renderWithActivity(false)));
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 });
act(() => root?.render(renderWithActivity(true)));
expect(scrollTo).not.toHaveBeenCalled();
act(() => notifyResize(scrollContainer, contentNode));
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
expect(scrollTo).toHaveBeenCalledWith({ top: 2200, behavior: "auto" });
act(() => root?.render(renderWithActivity(false)));
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
+112 -4
View File
@@ -202,6 +202,65 @@ function syncNearBottom(
return nextValue;
}
interface ActiveFollowOutputLayout {
scrollContainer: HTMLElement | null;
viewportWidth: number;
viewportHeight: number;
activationKey: string;
isActivationReady: boolean;
renderLiveAuxiliary: StreamRenderInput["renderers"]["renderLiveAuxiliary"];
historyMounted: StreamRenderInput["segments"]["historyMounted"];
historyVirtualized: StreamRenderInput["segments"]["historyVirtualized"];
liveHead: StreamRenderInput["segments"]["liveHead"];
virtualTotalSize: number;
}
function activeFollowOutputLayoutsEqual(
previous: ActiveFollowOutputLayout,
next: ActiveFollowOutputLayout,
): boolean {
return (
previous.scrollContainer === next.scrollContainer &&
previous.viewportWidth === next.viewportWidth &&
previous.viewportHeight === next.viewportHeight &&
previous.activationKey === next.activationKey &&
previous.isActivationReady === next.isActivationReady &&
previous.renderLiveAuxiliary === next.renderLiveAuxiliary &&
previous.historyMounted === next.historyMounted &&
previous.historyVirtualized === next.historyVirtualized &&
previous.liveHead === next.liveHead &&
previous.virtualTotalSize === next.virtualTotalSize
);
}
interface ObservedViewportGeometry {
clientWidth: number;
clientHeight: number;
scrollWidth: number;
scrollHeight: number;
}
function getObservedViewportGeometry(scrollContainer: HTMLElement): ObservedViewportGeometry {
return {
clientWidth: scrollContainer.clientWidth,
clientHeight: scrollContainer.clientHeight,
scrollWidth: scrollContainer.scrollWidth,
scrollHeight: scrollContainer.scrollHeight,
};
}
function observedViewportGeometriesEqual(
previous: ObservedViewportGeometry,
next: ObservedViewportGeometry,
): boolean {
return (
previous.clientWidth === next.clientWidth &&
previous.clientHeight === next.clientHeight &&
previous.scrollWidth === next.scrollWidth &&
previous.scrollHeight === next.scrollHeight
);
}
function getScrollContainerDistanceFromBottom(
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
): number {
@@ -275,6 +334,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const historyStartPrependAnchorRef = useRef<HistoryStartPrependAnchor | null>(null);
const historyStartPrependAnchorActiveRef = useRef(false);
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
const lastActiveFollowOutputLayoutRef = useRef<ActiveFollowOutputLayout | null>(null);
const lastObservedViewportGeometryRef = useRef<ObservedViewportGeometry | null>(null);
const wasFollowOutputLayoutActiveRef = useRef(false);
const resumedUnchangedLayoutRef = useRef(false);
const pendingResumeGeometryCheckRef = useRef(false);
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
const {
renderHistoryVirtualizedRow,
@@ -753,14 +817,39 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
// Following output is a layout invariant: rows, footer, and bottom offset must
// reach the browser in the same paint.
useLayoutEffect(() => {
if (!isActive || !followOutputRef.current) {
const layout: ActiveFollowOutputLayout = {
scrollContainer: scrollContainerRef.current,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
activationKey,
isActivationReady,
renderLiveAuxiliary,
historyMounted: segments.historyMounted,
historyVirtualized: segments.historyVirtualized,
liveHead: segments.liveHead,
virtualTotalSize,
};
const resumedUnchangedLayout = Boolean(
isActive &&
!wasFollowOutputLayoutActiveRef.current &&
lastActiveFollowOutputLayoutRef.current &&
activeFollowOutputLayoutsEqual(lastActiveFollowOutputLayoutRef.current, layout),
);
resumedUnchangedLayoutRef.current = resumedUnchangedLayout;
pendingResumeGeometryCheckRef.current = resumedUnchangedLayout;
wasFollowOutputLayoutActiveRef.current = isActive;
if (!isActive) {
return;
}
lastActiveFollowOutputLayoutRef.current = layout;
if (!followOutputRef.current || resumedUnchangedLayout) return;
cancelPendingStickToBottom();
scrollMessagesToBottom("auto");
}, [
activationKey,
cancelPendingStickToBottom,
isActive,
isActivationReady,
renderLiveAuxiliary,
scrollMessagesToBottom,
segments.historyMounted,
@@ -773,7 +862,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (!isActive) {
return;
}
updateScrollMetrics();
const resumedUnchangedLayout = resumedUnchangedLayoutRef.current;
resumedUnchangedLayoutRef.current = false;
if (!resumedUnchangedLayout) {
updateScrollMetrics();
}
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartPrependSettle();
@@ -802,9 +895,23 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return;
}
updateScrollMetrics();
evaluateHistoryStart();
if (!pendingResumeGeometryCheckRef.current) {
updateScrollMetrics();
evaluateHistoryStart();
}
const observer = new ResizeObserver(() => {
const nextGeometry = getObservedViewportGeometry(scrollContainer);
if (pendingResumeGeometryCheckRef.current) {
pendingResumeGeometryCheckRef.current = false;
reportReadingPosition();
const previousGeometry = lastObservedViewportGeometryRef.current;
lastObservedViewportGeometryRef.current = nextGeometry;
if (previousGeometry && observedViewportGeometriesEqual(previousGeometry, nextGeometry)) {
return;
}
} else {
lastObservedViewportGeometryRef.current = nextGeometry;
}
if (historyStartPrependAnchorActiveRef.current) {
applyHistoryStartPrependAnchor();
}
@@ -829,6 +936,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
applyHistoryStartPrependAnchor,
evaluateHistoryStart,
isActive,
reportReadingPosition,
scheduleHistoryStartPrependSettle,
scheduleStickToBottom,
updateScrollMetrics,