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