From d4435bd0166a29721c5616f46107d540a6a54328 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 16 Aug 2026 12:40:28 +0200 Subject: [PATCH] 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 --- docs/development.md | 25 + .../browser/agent-message-submission.spec.ts | 7 +- packages/app/package.json | 1 + .../scripts/profile-workspace-switching.mjs | 501 ++++++++++++++++++ .../src/agent-stream/strategy-web.test.tsx | 77 ++- .../app/src/agent-stream/strategy-web.tsx | 116 +++- .../workspace/[workspaceId]/index.tsx | 93 ++-- .../app/src/composer/agent-controls/index.tsx | 110 ++-- packages/app/src/composer/index.tsx | 155 +++--- packages/app/src/composer/input/input.tsx | 35 +- .../workspace-route-navigation.test.ts | 28 +- .../navigation/workspace-route-navigation.ts | 40 +- packages/app/src/panels/agent-panel.tsx | 2 +- .../workspace-deck-retention.test.ts | 127 +++-- .../workspace/workspace-deck-retention.ts | 108 +++- .../src/stores/session-store-hooks/index.ts | 8 +- .../session-store-hooks/selectors.test.ts | 22 + .../stores/session-store-hooks/selectors.ts | 32 ++ packages/app/src/utils/web-focus.test.ts | 30 ++ packages/app/src/utils/web-focus.ts | 8 +- 20 files changed, 1297 insertions(+), 228 deletions(-) create mode 100644 packages/app/scripts/profile-workspace-switching.mjs diff --git a/docs/development.md b/docs/development.md index 05d61b8c2..4981a6bf5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -217,6 +217,31 @@ PASEO_PROFILE_IDLE_WAIT_MS=3000 # idle baseline before switching PASEO_PROFILE_DUMP_COMMITS=1 # include per-commit profiler samples ``` +For warm workspace switching, point the benchmark at an app backed by seeded +daemon state: + +```bash +PASEO_PROFILE_APP_URL=http://localhost:19010 \ + npm run profile:workspace-switching --workspace=@getpaseo/app +``` + +The benchmark first warms `Cmd+1` through `Cmd+7`, then records a rapid seven-workspace +burst. It separately warms the three-entry workspace deck LRU and records `Cmd+1` through +`Cmd+3` without waits between keys. Both scenarios report keydown-to-activation latency and +React commits on the same browser clock. Set `PASEO_PROFILE_WORKSPACE_DIGITS`, +`PASEO_PROFILE_WORKSPACE_LRU_SIZE`, or `PASEO_PROFILE_WARM_QUIET_MS` to change the shape. Set +`PASEO_PROFILE_DUMP_COMMITS=1` to include the nested component breakdown for every commit. +Set `PASEO_PROFILE_RETAINED_REPEATS=5` to repeat the retained-LRU burst for a less noisy sample. +Set `PASEO_PROFILE_CPU_PATH=/tmp/workspace-switch.cpuprofile` to run a separate retained-LRU +capture after the latency scenarios and write a raw CDP CPU profile without contaminating their +timings. +Set `PASEO_PROFILE_TRACE_PATH=/tmp/workspace-switch.trace.json` to run another separate retained-LRU +capture and write a Chromium Performance trace with User Timing marks and screenshots. Open the +trace in the Chrome DevTools Performance panel. Set `PASEO_PROFILE_TRACE_INVALIDATIONS=1` only for a +focused invalidation capture; React Native's generated stacks make that mode highly intrusive. +Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in +the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis. + ### Desktop macOS compositor watchdog macOS display sleep can leave Chromium's GPU-process display link — the vsync diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index ed2bc794a..ebdd78358 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -27,6 +27,7 @@ import { seedWorkspace } from "../support/helpers/seed-client"; import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; import { getServerId } from "../support/helpers/server-id"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention"; import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace"; import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { selectModel } from "../support/helpers/app"; @@ -354,7 +355,7 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction( model: "ten-second-stream", }); const evictionAgents = await Promise.all( - Array.from({ length: 3 }, (_unused, index) => + Array.from({ length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES }, (_unused, index) => seedMockAgentWorkspace({ repoPrefix: `submission-workspace-eviction-${testInfo.workerIndex}-${index}-`, title: `Workspace eviction ${index + 1}`, @@ -379,7 +380,9 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction( await expectComposerVisible(page); } await expect(targetDeckEntry).toHaveCount(0); - await subscriptions.waitForSubscribedAgents([evictionAgents[2]!.agentId]); + await subscriptions.waitForSubscribedAgents([ + evictionAgents[WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES - 1]!.agentId, + ]); gate.setAgentStreamSuppressed(false); await target.client.waitForFinish(target.agentId, 30_000); diff --git a/packages/app/package.json b/packages/app/package.json index e92d61d14..e2bd2a821 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -27,6 +27,7 @@ "test:e2e:ui": "playwright test --ui", "build": "npm run build:web", "build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web", + "profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs", "profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs", "deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main", "build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs", diff --git a/packages/app/scripts/profile-workspace-switching.mjs b/packages/app/scripts/profile-workspace-switching.mjs new file mode 100644 index 000000000..f17a930c9 --- /dev/null +++ b/packages/app/scripts/profile-workspace-switching.mjs @@ -0,0 +1,501 @@ +import { chromium } from "playwright"; +import { writeFile } from "node:fs/promises"; + +const baseUrl = process.env.PASEO_PROFILE_APP_URL ?? "http://localhost:19010"; +const digits = parseDigits(process.env.PASEO_PROFILE_WORKSPACE_DIGITS ?? "1234567"); +const lruSize = Number(process.env.PASEO_PROFILE_WORKSPACE_LRU_SIZE ?? 3); +const retainedRepeatCount = Number(process.env.PASEO_PROFILE_RETAINED_REPEATS ?? 1); +const warmQuietMs = Number(process.env.PASEO_PROFILE_WARM_QUIET_MS ?? 300); +const finalWaitMs = Number(process.env.PASEO_PROFILE_FINAL_WAIT_MS ?? 1_000); +const headless = process.env.PASEO_PROFILE_HEADLESS !== "0"; +const dumpCommits = process.env.PASEO_PROFILE_DUMP_COMMITS === "1"; +const cpuProfilePath = process.env.PASEO_PROFILE_CPU_PATH; +const tracePath = process.env.PASEO_PROFILE_TRACE_PATH; +const traceInvalidations = process.env.PASEO_PROFILE_TRACE_INVALIDATIONS === "1"; +const traceFocus = process.env.PASEO_PROFILE_TRACE_FOCUS === "1"; + +function parseDigits(value) { + const parsed = [...value].filter((digit) => /^[1-9]$/.test(digit)); + if (parsed.length === 0 || new Set(parsed).size !== parsed.length) { + throw new Error("PASEO_PROFILE_WORKSPACE_DIGITS must contain unique digits from 1 through 9"); + } + return parsed; +} + +function round(value) { + return Math.round(value * 100) / 100; +} + +async function installBenchmarkShortcutOverride(page) { + await page.addInitScript( + ({ profileFocus }) => { + localStorage.setItem( + "@paseo:keyboard-shortcut-overrides", + JSON.stringify({ "workspace-navigate-index-alt-digit-web": "Cmd+Digit" }), + ); + + const preserveRenderProfile = (original) => { + return function (state, unused, url) { + if (url === undefined || url === null) { + return Reflect.apply(original, this, [state, unused, url]); + } + const nextUrl = new URL(String(url), location.href); + nextUrl.searchParams.set("renderProfile", "1"); + return Reflect.apply(original, this, [ + state, + unused, + `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`, + ]); + }; + }; + history.pushState = preserveRenderProfile(history.pushState); + history.replaceState = preserveRenderProfile(history.replaceState); + + if (profileFocus) { + const originalFocus = HTMLElement.prototype.focus; + HTMLElement.prototype.focus = function (...args) { + const start = performance.now(); + const activeBefore = document.activeElement; + const stack = new Error().stack; + const ancestorTestIds = []; + let workspaceElement = null; + for (let element = this.parentElement; element; element = element.parentElement) { + const testId = element.getAttribute("data-testid"); + if (testId) ancestorTestIds.push(testId); + if (testId?.startsWith("workspace-deck-entry-")) { + workspaceElement = element; + } + } + const workspaceDisplayBefore = workspaceElement + ? getComputedStyle(workspaceElement).display + : null; + const result = Reflect.apply(originalFocus, this, args); + globalThis.__PASEO_FOCUS_PROFILE__ ??= []; + globalThis.__PASEO_FOCUS_PROFILE__.push({ + time: start, + duration: performance.now() - start, + tagName: this.tagName, + testId: this.getAttribute("data-testid"), + ancestorTestIds, + workspaceDisplayBefore, + workspaceDisplayAfter: workspaceElement + ? getComputedStyle(workspaceElement).display + : null, + pathname: location.pathname, + deckDisplays: [ + ...document.querySelectorAll('[data-testid^="workspace-deck-entry-"]'), + ].map((element) => ({ + testId: element.getAttribute("data-testid"), + display: getComputedStyle(element).display, + })), + placeholder: this.getAttribute("placeholder"), + ariaLabel: this.getAttribute("aria-label"), + wasFocused: activeBefore === this, + becameFocused: document.activeElement === this, + stack, + }); + return result; + }; + } + }, + { profileFocus: traceFocus }, + ); +} + +async function waitForProfilerIdle(page) { + let previousCount = -1; + let unchangedSince = Date.now(); + const deadline = Date.now() + 15_000; + + while (Date.now() < deadline) { + const count = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE__?.length ?? 0); + if (count !== previousCount) { + previousCount = count; + unchangedSince = Date.now(); + } else if (Date.now() - unchangedSince >= warmQuietMs) { + return; + } + await page.waitForTimeout(50); + } + throw new Error("React profiler did not become idle during workspace warm-up"); +} + +async function pressWorkspaceShortcut(page, digit, previousDeckEntry) { + await page.keyboard.down("Meta"); + await page.keyboard.press(digit); + await page.keyboard.up("Meta"); + await page.waitForFunction( + (previous) => { + const active = [...document.querySelectorAll('[data-testid^="workspace-deck-entry-"]')].find( + (entry) => getComputedStyle(entry).display !== "none", + ); + const activeId = active?.getAttribute("data-testid"); + return Boolean(activeId && activeId !== previous); + }, + previousDeckEntry, + { timeout: 15_000 }, + ); + await waitForProfilerIdle(page); + return page.evaluate( + () => + [...document.querySelectorAll('[data-testid^="workspace-deck-entry-"]')] + .find((entry) => getComputedStyle(entry).display !== "none") + ?.getAttribute("data-testid") ?? null, + ); +} + +async function warmWorkspaces(page, warmDigits, targets) { + let activeEntry = await page.evaluate( + () => + [...document.querySelectorAll('[data-testid^="workspace-deck-entry-"]')] + .find((entry) => getComputedStyle(entry).display !== "none") + ?.getAttribute("data-testid") ?? null, + ); + for (const digit of warmDigits) { + activeEntry = await pressWorkspaceShortcut(page, digit, activeEntry); + if (!activeEntry) { + throw new Error(`Cmd+${digit} did not activate a workspace`); + } + const previousTarget = targets.get(digit); + if (previousTarget && previousTarget !== activeEntry) { + throw new Error(`Cmd+${digit} changed targets during the benchmark`); + } + targets.set(digit, activeEntry); + } +} + +async function beginBrowserMeasurements(page, scenarioName) { + await page.evaluate((name) => { + globalThis.__PASEO_RESET_RENDER_PROFILE__?.(); + globalThis.__PASEO_WORKSPACE_SWITCH_BENCHMARK_CLEANUP__?.(); + globalThis.__PASEO_FOCUS_PROFILE__ = []; + + const getDeckEntries = () => + [...document.querySelectorAll('[data-testid^="workspace-deck-entry-"]')] + .map((entry) => entry.getAttribute("data-testid")) + .filter(Boolean); + const getActiveEntry = () => + [...document.querySelectorAll('[data-testid^="workspace-deck-entry-"]')] + .find((entry) => getComputedStyle(entry).display !== "none") + ?.getAttribute("data-testid") ?? null; + const measurements = { + keydowns: [], + activations: [], + initialActiveEntry: getActiveEntry(), + initialDeckEntries: getDeckEntries(), + }; + let lastActiveEntry = measurements.initialActiveEntry; + let activationSequence = 0; + let keydownSequence = 0; + + const markTrace = (label) => { + performance.mark(label); + console.timeStamp(label); + }; + markTrace(`paseo:${name}:capture-start`); + + const recordActivation = (records) => { + const activeEntry = records + .map((record) => record.target.getAttribute?.("data-testid")) + .find( + (testId) => testId?.startsWith("workspace-deck-entry-") && testId !== lastActiveEntry, + ); + if (!activeEntry) return; + lastActiveEntry = activeEntry; + markTrace(`paseo:${name}:activation:${activationSequence}:${activeEntry}`); + activationSequence += 1; + measurements.activations.push({ activeEntry, time: performance.now() }); + }; + const recordKeydown = (digit) => { + markTrace(`paseo:${name}:keydown:${keydownSequence}:Cmd+${digit}`); + keydownSequence += 1; + measurements.keydowns.push({ + digit, + time: performance.now(), + activeEntry: lastActiveEntry, + deckEntries: getDeckEntries(), + }); + }; + const observer = new MutationObserver(recordActivation); + observer.observe(document.body, { + subtree: true, + attributes: true, + attributeFilter: ["class"], + }); + globalThis.__PASEO_WORKSPACE_SWITCH_BENCHMARK__ = measurements; + globalThis.__PASEO_RECORD_WORKSPACE_SWITCH_KEYDOWN__ = recordKeydown; + globalThis.__PASEO_WORKSPACE_SWITCH_BENCHMARK_CLEANUP__ = () => { + observer.disconnect(); + }; + }, scenarioName); +} + +function groupCommits(samples) { + const commits = new Map(); + for (const sample of samples) { + const commit = commits.get(sample.commitTime) ?? { + commitTime: sample.commitTime, + rootActualDuration: null, + rootPhase: null, + components: [], + }; + if (sample.id === "WorkspaceDeck") { + commit.rootActualDuration = sample.actualDuration; + commit.rootPhase = sample.phase; + } + commit.components.push({ + id: sample.id, + phase: sample.phase, + actualDuration: round(sample.actualDuration), + baseDuration: round(sample.baseDuration), + }); + commits.set(sample.commitTime, commit); + } + return [...commits.values()] + .sort((left, right) => left.commitTime - right.commitTime) + .map((commit) => { + commit.commitTime = round(commit.commitTime); + commit.rootActualDuration = + commit.rootActualDuration === null ? null : round(commit.rootActualDuration); + commit.components.sort((left, right) => right.actualDuration - left.actualDuration); + return commit; + }); +} + +function buildScenarioReport(name, scenarioDigits, targets, measurements, samples) { + const allCommits = groupCommits(samples); + const firstKeydownTime = measurements.keydowns[0]?.time ?? null; + const lastActivationTime = Math.max( + firstKeydownTime ?? 0, + ...measurements.activations.map((activation) => activation.time), + ); + const captureWindowEnd = lastActivationTime + warmQuietMs; + const commits = allCommits.filter( + (commit) => + firstKeydownTime !== null && + commit.commitTime >= firstKeydownTime && + commit.commitTime <= captureWindowEnd, + ); + const switches = measurements.keydowns.map((keydown, index) => { + const target = targets.get(keydown.digit) ?? null; + const activation = measurements.activations.find( + (candidate) => candidate.activeEntry === target && candidate.time >= keydown.time, + ); + const nextKeydownTime = measurements.keydowns[index + 1]?.time; + const attributionEnd = nextKeydownTime ?? (activation?.time ?? keydown.time) + warmQuietMs; + const attributedCommits = commits.filter( + (commit) => commit.commitTime >= keydown.time && commit.commitTime < attributionEnd, + ); + return { + shortcut: `Cmd+${keydown.digit}`, + target, + retainedAtKeydown: target ? keydown.deckEntries.includes(target) : false, + keydownTime: round(keydown.time), + activationTime: activation ? round(activation.time) : null, + activationLatency: activation ? round(activation.time - keydown.time) : null, + commits: attributedCommits.length, + workspaceMounts: attributedCommits.reduce( + (total, commit) => + total + + commit.components.filter( + (component) => component.id === "WorkspaceScreenContent" && component.phase === "mount", + ).length, + 0, + ), + rootActualDuration: round( + attributedCommits.reduce((total, commit) => total + (commit.rootActualDuration ?? 0), 0), + ), + }; + }); + + return { + name, + shortcuts: scenarioDigits.map((digit) => `Cmd+${digit}`), + initialDeckEntries: measurements.initialDeckEntries, + burstDuration: firstKeydownTime === null ? null : round(lastActivationTime - firstKeydownTime), + commitCount: commits.length, + rootActualDuration: round( + commits.reduce((total, commit) => total + (commit.rootActualDuration ?? 0), 0), + ), + switches, + commits: commits.map((commit) => ({ + commitTime: commit.commitTime, + sinceBurstStart: + firstKeydownTime === null ? null : round(commit.commitTime - firstKeydownTime), + rootPhase: commit.rootPhase, + rootActualDuration: commit.rootActualDuration, + components: dumpCommits ? commit.components : undefined, + })), + }; +} + +async function runScenario(page, name, scenarioDigits, targets) { + await beginBrowserMeasurements(page, name); + await page.keyboard.down("Meta"); + for (const digit of scenarioDigits) { + await page.evaluate((nextDigit) => { + globalThis.__PASEO_RECORD_WORKSPACE_SWITCH_KEYDOWN__?.(nextDigit); + }, digit); + await page.keyboard.press(digit); + } + await page.keyboard.up("Meta"); + + const finalTarget = targets.get(scenarioDigits.at(-1)); + await page.waitForFunction( + (expected) => + [...document.querySelectorAll('[data-testid^="workspace-deck-entry-"]')] + .find((entry) => getComputedStyle(entry).display !== "none") + ?.getAttribute("data-testid") === expected, + finalTarget, + { timeout: 15_000 }, + ); + await page.waitForTimeout(finalWaitMs); + await waitForProfilerIdle(page); + await page.evaluate((scenarioName) => { + const label = `paseo:${scenarioName}:capture-end`; + performance.mark(label); + console.timeStamp(label); + }, name); + + const result = await page.evaluate(() => ({ + measurements: globalThis.__PASEO_WORKSPACE_SWITCH_BENCHMARK__, + samples: globalThis.__PASEO_RENDER_PROFILE__ ?? [], + reasons: globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}, + focusCalls: globalThis.__PASEO_FOCUS_PROFILE__ ?? [], + })); + await page.evaluate(() => globalThis.__PASEO_WORKSPACE_SWITCH_BENCHMARK_CLEANUP__?.()); + return { + ...buildScenarioReport(name, scenarioDigits, targets, result.measurements, result.samples), + reasons: result.reasons, + focusCalls: result.focusCalls, + }; +} + +async function captureCpuProfile(page, run) { + const session = await page.context().newCDPSession(page); + await session.send("Profiler.enable"); + await session.send("Profiler.setSamplingInterval", { interval: 100 }); + await session.send("Profiler.start"); + try { + await run(); + } finally { + const { profile } = await session.send("Profiler.stop"); + await session.send("Profiler.disable"); + await writeFile(cpuProfilePath, JSON.stringify(profile)); + } +} + +async function readCdpStream(session, handle) { + const chunks = []; + while (true) { + const result = await session.send("IO.read", { handle }); + chunks.push( + result.base64Encoded ? Buffer.from(result.data, "base64") : Buffer.from(result.data), + ); + if (result.eof) break; + } + await session.send("IO.close", { handle }); + return Buffer.concat(chunks); +} + +async function captureDevToolsTrace(page, run) { + const session = await page.context().newCDPSession(page); + const tracingComplete = new Promise((resolve) => { + session.once("Tracing.tracingComplete", resolve); + }); + const categories = [ + "-*", + "toplevel", + "devtools.timeline", + "blink.user_timing", + "latencyInfo", + "disabled-by-default-devtools.timeline", + "disabled-by-default-devtools.timeline.frame", + "disabled-by-default-devtools.screenshot", + "disabled-by-default-v8.cpu_profiler", + "disabled-by-default-v8.cpu_profiler.hires", + ]; + if (traceInvalidations) { + categories.push("disabled-by-default-devtools.timeline.invalidationTracking"); + } + await session.send("Tracing.start", { + categories: categories.join(","), + options: "sampling-frequency=10000", + transferMode: "ReturnAsStream", + }); + let result; + try { + result = await run(); + } finally { + await session.send("Tracing.end"); + const completed = await tracingComplete; + const trace = await readCdpStream(session, completed.stream); + await writeFile(tracePath, trace); + } + return result; +} + +async function main() { + const browser = await chromium.launch({ headless }); + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + const targets = new Map(); + + try { + await installBenchmarkShortcutOverride(page); + await page.goto(`${baseUrl}/?renderProfile=1`, { waitUntil: "domcontentloaded" }); + await page.locator('[data-testid^="sidebar-workspace-row-"]').first().waitFor({ + timeout: 60_000, + }); + + await warmWorkspaces(page, digits, targets); + const fullBurst = await runScenario(page, "seven-workspace-burst", digits, targets); + + const retainedDigits = digits.slice(0, Math.min(lruSize, digits.length)); + await warmWorkspaces(page, retainedDigits, targets); + const retainedBurstDigits = Array.from( + { length: retainedRepeatCount }, + () => retainedDigits, + ).flat(); + const retainedLru = await runScenario(page, "retained-lru", retainedBurstDigits, targets); + + if (cpuProfilePath) { + await warmWorkspaces(page, retainedDigits, targets); + await captureCpuProfile(page, () => + runScenario(page, "retained-lru-cpu", retainedBurstDigits, targets), + ); + } + + let traceReport = null; + if (tracePath) { + await warmWorkspaces(page, retainedDigits, targets); + traceReport = await captureDevToolsTrace(page, () => + runScenario(page, "retained-lru-trace", retainedBurstDigits, targets), + ); + } + + console.log( + JSON.stringify( + { + appUrl: baseUrl, + warmQuietMs, + lruSize, + retainedRepeatCount, + dumpCommits, + cpuProfilePath: cpuProfilePath ?? null, + tracePath: tracePath ?? null, + traceInvalidations, + traceFocus, + traceReport, + targets: Object.fromEntries(targets), + scenarios: [retainedLru, fullBurst], + }, + null, + 2, + ), + ); + } finally { + await browser.close(); + } +} + +await main(); diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 98be7dd91..a124233b7 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -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>(); + it("keeps the retained viewport mounted while inactive and reconciles it once on return", async () => { + interface ResizeObservation { + callback: ResizeObserverCallback; + disconnect: ReturnType; + observer: ResizeObserver; + } + const observed = new Map(); 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(); + 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 }); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index db244719b..fb805bb01 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -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, ): number { @@ -275,6 +334,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const historyStartPrependAnchorRef = useRef(null); const historyStartPrependAnchorActiveRef = useRef(false); const historyStartSettleSchedulerRef = useRef(null); + const lastActiveFollowOutputLayoutRef = useRef(null); + const lastObservedViewportGeometryRef = useRef(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, diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx index 26609caa1..791e66626 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -13,14 +13,15 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store"; import { - areWorkspaceSelectionListsEqual, + areRetainedWorkspaceSelectionListsEqual, areWorkspaceSelectionsEqual, + getNextWorkspaceDeckExpirationDelay, getWorkspaceSelectionKey, orderWorkspaceSelectionsForStableRender, - pruneMountedWorkspaceSelections, + reconcileRetainedWorkspaceSelections, + type RetainedWorkspaceSelection, resolveWorkspaceDeckEntries, shouldKeepWorkspaceDeckEntryMounted, - WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES, } from "@/screens/workspace/workspace-deck-retention"; import { decodeWorkspaceIdFromPathSegment, @@ -33,6 +34,7 @@ import { } from "@/utils/host-route-browser"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { isWeb } from "@/constants/platform"; +import { RenderProfile } from "@/utils/render-profiler"; function getParamValue(value: string | string[] | undefined): string { if (typeof value === "string") { @@ -199,29 +201,31 @@ function WorkspaceDeck({ recoveryAgentId: string | null; }) { const activeSelection = useActiveWorkspaceSelection(); - const [mountedSelections, setMountedSelections] = useState(() => - activeSelection ? [activeSelection] : [], + const [retainedSelections, setRetainedSelections] = useState(() => + activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [], ); const unmountWorkspaceSelection = useCallback((selection: ActiveWorkspaceSelection) => { - setMountedSelections((current) => - current.filter( - (mountedSelection) => !areWorkspaceSelectionsEqual(mountedSelection, selection), - ), + setRetainedSelections((current) => + current.filter((entry) => !areWorkspaceSelectionsEqual(entry.selection, selection)), ); }, []); - const nextMountedSelections = useMemo( + const reconciliationNow = Date.now(); + const nextRetainedSelections = useMemo( () => - pruneMountedWorkspaceSelections({ - currentSelections: mountedSelections, + reconcileRetainedWorkspaceSelections({ + currentEntries: retainedSelections, activeSelection, - maxMountedWorkspaces: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES, + now: reconciliationNow, }), - [activeSelection, mountedSelections], + [activeSelection, reconciliationNow, retainedSelections], ); const renderedSelections = useMemo( - () => orderWorkspaceSelectionsForStableRender(nextMountedSelections), - [nextMountedSelections], + () => + orderWorkspaceSelectionsForStableRender( + nextRetainedSelections.map((entry) => entry.selection), + ), + [nextRetainedSelections], ); const renderedEntries = useMemo( () => resolveWorkspaceDeckEntries({ selections: renderedSelections, activeSelection }), @@ -229,26 +233,49 @@ function WorkspaceDeck({ ); useLayoutEffect(() => { - if (!areWorkspaceSelectionListsEqual(mountedSelections, nextMountedSelections)) { - setMountedSelections(nextMountedSelections); + if (!areRetainedWorkspaceSelectionListsEqual(retainedSelections, nextRetainedSelections)) { + setRetainedSelections(nextRetainedSelections); } - }, [mountedSelections, nextMountedSelections]); + }, [nextRetainedSelections, retainedSelections]); + + useEffect(() => { + const expirationDelay = getNextWorkspaceDeckExpirationDelay({ + entries: nextRetainedSelections, + activeSelection, + now: reconciliationNow, + }); + if (expirationDelay === null) { + return; + } + const timeout = setTimeout(() => { + setRetainedSelections((current) => + reconcileRetainedWorkspaceSelections({ + currentEntries: current, + activeSelection, + now: Date.now(), + }), + ); + }, expirationDelay + 1); + return () => clearTimeout(timeout); + }, [activeSelection, nextRetainedSelections, reconciliationNow]); return ( - - {renderedEntries.map(({ selection, active }) => { - return ( - - ); - })} - + + + {renderedEntries.map(({ selection, active }) => { + return ( + + ); + })} + + ); } diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index f611387e2..267e07f1f 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -64,7 +64,11 @@ import { readMeasuredWidth } from "@/hooks/use-container-width"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; -import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration"; +import { + useAgentControlCommandCenterActions, + type AgentControlCommandCenterSource, +} from "@/command-center/agent-control-registration"; +import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { isNative } from "@/constants/platform"; import { resolveComposerControlDensity, @@ -154,11 +158,28 @@ export interface DraftAgentControlsProps { interface AgentControlsProps { agentId: string; serverId: string; - isPaneFocused: boolean; onDropdownClose?: () => void; isCompactLayout?: boolean; } +function AgentControlCommandCenterRegistration({ + sourceId, + enabled, + controls, +}: { + sourceId: string; + enabled: boolean; + controls: AgentControlCommandCenterSource; +}) { + const { isActiveComposer } = useComposerKeyboardScope(); + useAgentControlCommandCenterActions({ + sourceId, + enabled: enabled && isActiveComposer, + controls, + }); + return null; +} + function findOptionLabel( options: AgentControlOption[] | undefined, selectedId: string | undefined, @@ -1477,7 +1498,6 @@ function ThinkingComboboxOption({ export const AgentControls = memo(function AgentControls({ agentId, serverId, - isPaneFocused, onDropdownClose, isCompactLayout, }: AgentControlsProps) { @@ -1649,10 +1669,8 @@ export const AgentControls = memo(function AgentControls({ [agentId, agentProvider, client, toast, updatePreferences], ); - useAgentControlCommandCenterActions({ - sourceId: `agent:${serverId}:${agentId}`, - enabled: isPaneFocused && Boolean(client), - controls: { + const commandCenterControls = useMemo( + () => ({ serverId, ownerKey: agentId, provider: agentProvider, @@ -1673,8 +1691,31 @@ export const AgentControls = memo(function AgentControls({ list: agent?.features, set: handleSetFeature, }, - }, - }); + }), + [ + activeModelId, + agent?.features, + agentId, + agentModelSelectorProviders, + agentProvider, + commandCenterModes, + handleSelectCommandCenterModel, + handleSelectThinkingOption, + handleSetFeature, + modeProviderDefinitions, + modelSelection.selectedThinkingId, + modelSelection.thinkingOptions, + serverId, + ], + ); + + const commandCenterRegistration = ( + + ); const handleModelSelectorOpen = useCallback(() => { refetchSnapshotIfStale(agentProvider); @@ -1692,30 +1733,33 @@ export const AgentControls = memo(function AgentControls({ } return ( - 1 ? thinkingOptions : undefined} - selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined} - onSelectThinkingOption={handleSelectThinkingOption} - features={agent.features} - onSetFeature={handleSetFeature} - isModelLoading={snapshotIsLoading || selectedProviderIsLoading} - onModelSelectorOpen={handleModelSelectorOpen} - onRetryModelProvider={handleRetryModelProvider} - isRetryingModelProvider={snapshotIsRefreshing} - onDropdownClose={onDropdownClose} - disabled={!client} - modeControl={modeControl} - modelSelectorServerId={serverId} - isCompactLayout={isCompactLayout} - /> + <> + {commandCenterRegistration} + 1 ? thinkingOptions : undefined} + selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined} + onSelectThinkingOption={handleSelectThinkingOption} + features={agent.features} + onSetFeature={handleSetFeature} + isModelLoading={snapshotIsLoading || selectedProviderIsLoading} + onModelSelectorOpen={handleModelSelectorOpen} + onRetryModelProvider={handleRetryModelProvider} + isRetryingModelProvider={snapshotIsRefreshing} + onDropdownClose={onDropdownClose} + disabled={!client} + modeControl={modeControl} + modelSelectorServerId={serverId} + isCompactLayout={isCompactLayout} + /> + ); }); diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 3315411a3..92bfa0905 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -96,7 +96,7 @@ import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispat import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { submitAgentInput } from "@/composer/submit"; import { createMessageSubmissionWriter } from "@/composer/submission/writer"; -import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope"; +import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { useAppSettings } from "@/hooks/use-settings"; import { isWeb, isNative } from "@/constants/platform"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; @@ -296,12 +296,11 @@ interface RenderLeftContentArgs { serverId: string; focusInput: () => void; isCompactLayout: boolean; - isPaneFocused: boolean; showAgentControls: boolean; } function renderLeftContent(args: RenderLeftContentArgs): ReactElement | null { - const { agentControls, agentId, serverId, focusInput, isCompactLayout, isPaneFocused } = args; + const { agentControls, agentId, serverId, focusInput, isCompactLayout } = args; if (!args.showAgentControls) return null; if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) { return ; @@ -310,7 +309,6 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement | null { @@ -561,6 +559,68 @@ function dispatchComposerKeyboardAction(args: DispatchComposerKeyboardActionArgs return true; } +function ComposerKeyboardRegistration({ + messageInputRef, + isAgentRunning, + isCancellingAgent, + isConnected, + handleCancelAgent, + focusMessageInputForKeyboardAction, + isMessageInputFocused, + handlerId, +}: Omit & { + isMessageInputFocused: boolean; + handlerId: string; +}) { + const { isActiveComposer } = useComposerKeyboardScope(); + const handleKeyboardAction = useCallback( + (action: KeyboardActionDefinition): boolean => + dispatchComposerKeyboardAction({ + action, + isPaneFocused: isActiveComposer, + messageInputRef, + isAgentRunning, + isCancellingAgent, + isConnected, + handleCancelAgent, + focusMessageInputForKeyboardAction, + }), + [ + focusMessageInputForKeyboardAction, + handleCancelAgent, + isActiveComposer, + isAgentRunning, + isCancellingAgent, + isConnected, + messageInputRef, + ], + ); + + useKeyboardActionHandler({ + handlerId, + actions: [ + "agent.interrupt", + "message-input.focus", + "message-input.send", + "message-input.dictation-toggle", + "message-input.dictation-cancel", + "message-input.dictation-confirm", + "message-input.voice-toggle", + "message-input.voice-mute-toggle", + ], + enabled: isActiveComposer, + priority: resolveKeyboardPriority(isMessageInputFocused), + isActive: () => isActiveComposer, + handle: handleKeyboardAction, + }); + return null; +} + +function ComposerAutocomplete(props: React.ComponentProps) { + const { isActiveComposer } = useComposerKeyboardScope(); + return ; +} + function resolveMessageInputPassthroughAction( actionId: string, ): MessageInputKeyboardActionKind | null { @@ -1052,12 +1112,23 @@ function ComposerVoiceModeButton({ ); } +export function Composer({ isPaneFocused, ...props }: ComposerProps) { + return ( + + + + ); +} + +type ComposerContentProps = Omit; + +const ComposerContent = memo(ComposerContentImpl); + // oxlint-disable-next-line complexity -export function Composer({ +function ComposerContentImpl({ agentId, serverId, workspaceId, - isPaneFocused, onSubmitMessage, onClientSlashCommand, hasExternalContent = false, @@ -1096,7 +1167,7 @@ export function Composer({ readOnly = false, submitLabel, placeholder, -}: ComposerProps) { +}: ComposerContentProps) { const mode = resolveComposerInputMode(inputMode); const { t } = useTranslation(); const buttonIconSize = resolveComposerButtonIconSize(); @@ -1682,46 +1753,6 @@ export function Composer({ focusMessageInputWithPlatformStrategy(messageInputRef); }, []); - const handleKeyboardAction = useCallback( - (action: KeyboardActionDefinition): boolean => - dispatchComposerKeyboardAction({ - action, - isPaneFocused, - messageInputRef, - isAgentRunning, - isCancellingAgent, - isConnected, - handleCancelAgent, - focusMessageInputForKeyboardAction, - }), - [ - focusMessageInputForKeyboardAction, - handleCancelAgent, - isAgentRunning, - isCancellingAgent, - isConnected, - isPaneFocused, - ], - ); - - useKeyboardActionHandler({ - handlerId: keyboardHandlerIdRef.current, - actions: [ - "agent.interrupt", - "message-input.focus", - "message-input.send", - "message-input.dictation-toggle", - "message-input.dictation-cancel", - "message-input.dictation-confirm", - "message-input.voice-toggle", - "message-input.voice-mute-toggle", - ], - enabled: isPaneFocused, - priority: resolveKeyboardPriority(isMessageInputFocused), - isActive: () => isPaneFocused, - handle: handleKeyboardAction, - }); - const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate", enabled: !externalKeyboardShift, @@ -2032,18 +2063,9 @@ export function Composer({ serverId, focusInput, isCompactLayout, - isPaneFocused, showAgentControls: mode.showAgentControls, }), - [ - agentControls, - agentId, - focusInput, - isCompactLayout, - isPaneFocused, - mode.showAgentControls, - serverId, - ], + [agentControls, agentId, focusInput, isCompactLayout, mode.showAgentControls, serverId], ); const handleAttachButtonRef = useCallback((node: View | null) => { @@ -2169,10 +2191,20 @@ export function Composer({ const githubEmptyText = githubSearchResultsQuery.isFetching ? t("composer.github.searching") : t("composer.github.noResults"); - const autocompleteVisible = autocomplete.isVisible && isPaneFocused && mode.showAutocomplete; + const autocompleteVisible = autocomplete.isVisible && mode.showAutocomplete; return ( - + <> + {/* Input area */} @@ -2182,7 +2214,7 @@ export function Composer({ {sendErrorNode} - - + ); } diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index d81e3c3f4..d4cd4c26d 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -54,6 +54,7 @@ import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { isWeb } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { useComposerHeightMirror } from "./height-mirror"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import type { NativePastedFile } from "@/composer/native-pasted-image"; @@ -116,8 +117,6 @@ export interface MessageInputProps { autoFocus?: boolean; autoFocusKey?: string; disabled?: boolean; - /** True when this composer's pane is focused. Used to gate global hotkeys and stop dictation when hidden. */ - isPaneFocused?: boolean; /** Content to render on the left side of the composer toolbar (e.g., AgentControls) */ leftContent?: React.ReactNode; /** Content to render on the right side before the voice button (e.g., context window meter) */ @@ -496,11 +495,26 @@ function useAutoFocusOnWebEffect( const active = typeof document !== "undefined" ? document.activeElement : null; return Boolean(element) && active === element; }, + deferInitialAttempt: true, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoFocus, autoFocusKey]); } +function MessageInputAutoFocus({ + enabled, + autoFocusKey, + textInputRef, +}: { + enabled: boolean; + autoFocusKey: string | undefined; + textInputRef: React.MutableRefObject; +}) { + const { isActiveComposer } = useComposerKeyboardScope(); + useAutoFocusOnWebEffect(textInputRef, enabled && isActiveComposer, autoFocusKey); + return null; +} + function MessageInputOverlay({ showDictationOverlay, showRealtimeOverlay, @@ -580,7 +594,8 @@ function FocusHint({ focusInputKeys: ShortcutChord | null | undefined; label: string; }) { - if (!visible || !focusInputKeys || !label.trim()) return null; + const { isActiveComposer } = useComposerKeyboardScope(); + if (!isActiveComposer || !visible || !focusInputKeys || !label.trim()) return null; return ( {label} @@ -1030,7 +1045,6 @@ interface ResolvedMessageInputProps { autoFocus: boolean; autoFocusKey: string | undefined; disabled: boolean; - isPaneFocused: boolean; leftContent: React.ReactNode; beforeVoiceContent: React.ReactNode; rightContent: React.ReactNode; @@ -1078,7 +1092,6 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu autoFocus: props.autoFocus ?? false, autoFocusKey: props.autoFocusKey, disabled: props.disabled ?? false, - isPaneFocused: props.isPaneFocused ?? true, leftContent: props.leftContent, beforeVoiceContent: props.beforeVoiceContent, rightContent: props.rightContent, @@ -1134,7 +1147,6 @@ export const MessageInput = forwardRef( autoFocus, autoFocusKey, disabled, - isPaneFocused, leftContent, beforeVoiceContent, rightContent, @@ -1237,8 +1249,6 @@ export const MessageInput = forwardRef( }; }, [onFocusChange]); - useAutoFocusOnWebEffect(textInputRef, autoFocus, autoFocusKey); - const handleDictationTranscript = useCallback( (text: string, _meta: { requestId: string }) => { const autoSend = sendAfterTranscriptRef.current; @@ -1738,6 +1748,11 @@ export const MessageInput = forwardRef( return ( + {/* Regular input */} ( onBlur={handleInputBlur} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true} - autoFocus={isWeb && autoFocus} + autoFocus={false} onContentSizeChange={handleContentSizeChange} onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined} onSelectionChange={handleSelectionChange} onPasteImages={onPasteImages} onPasteError={handlePasteError} - focusHintVisible={isWeb && isPaneFocused && !isInputFocused && !value} + focusHintVisible={isWeb && !isInputFocused && !value} focusInputKeys={focusInputKeys} focusHintLabel={t("composer.input.focusHint", { shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "", diff --git a/packages/app/src/navigation/workspace-route-navigation.test.ts b/packages/app/src/navigation/workspace-route-navigation.test.ts index 68d68da9c..704345f9a 100644 --- a/packages/app/src/navigation/workspace-route-navigation.test.ts +++ b/packages/app/src/navigation/workspace-route-navigation.test.ts @@ -44,6 +44,7 @@ describe("navigateToHostWorkspaceRoute", () => { it("pops to the mounted host route and targets the requested workspace", () => { const { navigationRef, dispatch } = createNavigationRef({ key: "root-stack", + index: 1, routeNames: ["index", "settings/[section]", "h/[serverId]"], routes: [ { @@ -78,10 +79,35 @@ describe("navigateToHostWorkspaceRoute", () => { }); }); + it("uses route navigation when the mounted host route is already focused", () => { + const { navigationRef, dispatch } = createNavigationRef({ + key: "root-stack", + index: 0, + routes: [ + { + key: "host-server-1", + name: "h/[serverId]", + params: { serverId: "server-1" }, + }, + ], + }); + registerWorkspaceRouteNavigationRef(navigationRef); + const dismissTo = vi.fn(); + + navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-b", { dismissTo }); + + expect(dispatch).not.toHaveBeenCalled(); + expect(dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-b"); + }); + it("preserves a workspace open intent in the POP_TO target", () => { const { navigationRef, dispatch } = createNavigationRef({ key: "root-stack", - routes: [{ key: "host-server-1", name: "h/[serverId]" }], + index: 1, + routes: [ + { key: "host-server-1", name: "h/[serverId]" }, + { key: "new", name: "new" }, + ], }); registerWorkspaceRouteNavigationRef(navigationRef); diff --git a/packages/app/src/navigation/workspace-route-navigation.ts b/packages/app/src/navigation/workspace-route-navigation.ts index 6a60e9cd7..79fc1d8c2 100644 --- a/packages/app/src/navigation/workspace-route-navigation.ts +++ b/packages/app/src/navigation/workspace-route-navigation.ts @@ -31,12 +31,21 @@ export function registerWorkspaceRouteNavigationRef( }; } -function findStackKeyWithMountedRouteName(state: unknown, routeName: string): string | null { +interface MountedRouteStack { + key: string; + focusedRouteName: string | null; +} + +function findStackWithMountedRouteName( + state: unknown, + routeName: string, +): MountedRouteStack | null { if (!state || typeof state !== "object") { return null; } const candidate = state as { + index?: unknown; key?: unknown; routes?: unknown; }; @@ -52,19 +61,34 @@ function findStackKeyWithMountedRouteName(state: unknown, routeName: string): st !!route && typeof route === "object" && (route as { name?: unknown }).name === routeName, ) ) { - return candidate.key; + const focusedIndex = + typeof candidate.index === "number" && + Number.isInteger(candidate.index) && + candidate.index >= 0 && + candidate.index < candidate.routes.length + ? candidate.index + : candidate.routes.length - 1; + const focusedRoute = candidate.routes[focusedIndex]; + const focusedRouteName = + focusedRoute && typeof focusedRoute === "object" + ? (focusedRoute as { name?: unknown }).name + : null; + return { + key: candidate.key, + focusedRouteName: typeof focusedRouteName === "string" ? focusedRouteName : null, + }; } for (const route of candidate.routes) { if (!route || typeof route !== "object") { continue; } - const childKey = findStackKeyWithMountedRouteName( + const childStack = findStackWithMountedRouteName( (route as { state?: unknown }).state, routeName, ); - if (childKey) { - return childKey; + if (childStack) { + return childStack; } } @@ -79,15 +103,15 @@ function dispatchHostWorkspacePopTo(route: string): boolean { } const rootState = navigation.getRootState(); - const target = findStackKeyWithMountedRouteName(rootState, ROOT_HOST_ROUTE_NAME); - if (!target) { + const hostStack = findStackWithMountedRouteName(rootState, ROOT_HOST_ROUTE_NAME); + if (!hostStack || hostStack.focusedRouteName === ROOT_HOST_ROUTE_NAME) { return false; } const open = getHostWorkspaceOpenParamFromPathname(route); const action: NavigationAction = { type: "POP_TO", - target, + target: hostStack.key, payload: { name: ROOT_HOST_ROUTE_NAME, params: { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index e2cea4e57..fe5ae2b82 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1648,7 +1648,7 @@ function ActiveAgentComposer({ onChangeAttachments={agentInputDraft.setAttachments} cwd={cwd} clearDraft={agentInputDraft.clear} - autoFocus={isPaneFocused} + autoFocus autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)} isSubmitLoading={isSubmitLoading} onAttentionInputFocus={onAttentionInputFocus} diff --git a/packages/app/src/screens/workspace/workspace-deck-retention.test.ts b/packages/app/src/screens/workspace/workspace-deck-retention.test.ts index 0dfffe785..e942760d0 100644 --- a/packages/app/src/screens/workspace/workspace-deck-retention.test.ts +++ b/packages/app/src/screens/workspace/workspace-deck-retention.test.ts @@ -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, +): 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(); }); }); diff --git a/packages/app/src/screens/workspace/workspace-deck-retention.ts b/packages/app/src/screens/workspace/workspace-deck-retention.ts index 62d4ab478..fee4f8bcd 100644 --- a/packages/app/src/screens/workspace/workspace-deck-retention.ts +++ b/packages/app/src/screens/workspace/workspace-deck-retention.ts @@ -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(); - 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( diff --git a/packages/app/src/stores/session-store-hooks/index.ts b/packages/app/src/stores/session-store-hooks/index.ts index 7d01fc9a9..89978811e 100644 --- a/packages/app/src/stores/session-store-hooks/index.ts +++ b/packages/app/src/stores/session-store-hooks/index.ts @@ -3,6 +3,7 @@ import { useStoreWithEqualityFn } from "zustand/traditional"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { composeWorkspaceStructure, + createWorkspaceStructureProjectsSelector, selectHasHydratedWorkspaces, selectHydratedWorkspaceServerIds, selectWorkspaceDirectoryServerIds, @@ -16,7 +17,6 @@ import { selectWorkspaceKeys, selectWorkspaceOrderByScope, selectWorkspaceStatusesForBadges, - selectWorkspaceStructureProjects, workspaceEqualityFns, type WorkspaceStructure, } from "./selectors"; @@ -100,9 +100,13 @@ export function useWorkspaceDirectory( } export function useWorkspaceStructure(serverIds: string[]): WorkspaceStructure { + const selectProjects = useMemo( + () => createWorkspaceStructureProjectsSelector(serverIds), + [serverIds], + ); const projects = useStoreWithEqualityFn( useSessionStore, - (state) => selectWorkspaceStructureProjects(state, serverIds), + selectProjects, workspaceEqualityFns.deep, ); const projectOrder = useStoreWithEqualityFn( diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts index 2c0ec9b46..a95d2e9fc 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.test.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -4,6 +4,7 @@ import { createProjectViewKey } from "@/projects/workspace-structure"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { composeWorkspaceStructure, + createWorkspaceStructureProjectsSelector, selectHasWorkspaces, selectHydratedWorkspaceServerIds, selectWorkspaceDirectoryServerIds, @@ -338,6 +339,27 @@ describe("selectWorkspaceFields", () => { }); describe("workspace structure composition", () => { + it("reuses structure when unrelated session state changes", () => { + const workspace = createWorkspace({ id: "workspace-a" }); + const workspaces = new Map([[workspace.id, workspace]]); + const projects = new Map([ + [workspace.projectId, projectDescriptorFromTestWorkspace(workspace)], + ]); + const selectProjects = createWorkspaceStructureProjectsSelector([SERVER_ID]); + const before = selectProjects({ sessions: { [SERVER_ID]: { workspaces, projects } } }); + const after = selectProjects({ + sessions: { + [SERVER_ID]: { + workspaces, + projects, + hasHydratedWorkspaces: true, + }, + }, + }); + + expect(after).toBe(before); + }); + function snapshotStructure( serverId: string, sidebar: SidebarOrderSnapshot, diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts index 409a66055..019b9d781 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -182,6 +182,38 @@ export function selectWorkspaceStructureProjects( return buildWorkspaceStructureProjects({ sessions }); } +export function createWorkspaceStructureProjectsSelector( + serverIds: readonly string[], +): (state: SessionsSnapshot) => WorkspaceStructureProject[] { + let previousInputs: Array<{ + workspaces: Map | undefined; + projects: Map | undefined; + }> | null = null; + let previousProjects: WorkspaceStructureProject[] | null = null; + + return (state) => { + const inputs = serverIds.map((serverId) => ({ + workspaces: state.sessions[serverId]?.workspaces, + projects: state.sessions[serverId]?.projects, + })); + const priorInputs = previousInputs; + const unchanged = + priorInputs !== null && + inputs.every( + (input, index) => + input.workspaces === priorInputs[index]?.workspaces && + input.projects === priorInputs[index]?.projects, + ); + if (unchanged && previousProjects) { + return previousProjects; + } + + previousInputs = inputs; + previousProjects = selectWorkspaceStructureProjects(state, serverIds); + return previousProjects; + }; +} + export function selectProject( state: SessionsSnapshot, serverId: string | null, diff --git a/packages/app/src/utils/web-focus.test.ts b/packages/app/src/utils/web-focus.test.ts index f513deaa1..98969cca9 100644 --- a/packages/app/src/utils/web-focus.test.ts +++ b/packages/app/src/utils/web-focus.test.ts @@ -52,6 +52,36 @@ describe("focusWithRetries", () => { expect(frameQueue).toHaveLength(0); }); + it("can defer the first focus attempt until the next animation frame", () => { + const focus = vi.fn(); + + focusWithRetries({ + focus, + isFocused: () => true, + deferInitialAttempt: true, + }); + + expect(focus).not.toHaveBeenCalled(); + + flushAnimationFrames(1); + + expect(focus).toHaveBeenCalledTimes(1); + }); + + it("cancels a deferred focus before its first attempt", () => { + const focus = vi.fn(); + const cancel = focusWithRetries({ + focus, + isFocused: () => false, + deferInitialAttempt: true, + }); + + cancel(); + flushAnimationFrames(1); + + expect(focus).not.toHaveBeenCalled(); + }); + it("keeps retrying on later animation frames until focus succeeds", () => { let focused = false; let attempts = 0; diff --git a/packages/app/src/utils/web-focus.ts b/packages/app/src/utils/web-focus.ts index 02350fdcd..4a75ab5ce 100644 --- a/packages/app/src/utils/web-focus.ts +++ b/packages/app/src/utils/web-focus.ts @@ -1,6 +1,7 @@ interface FocusWithRetriesOptions { focus: () => void; isFocused: () => boolean; + deferInitialAttempt?: boolean; timeoutMs?: number; onSuccess?: () => void; onTimeout?: () => void; @@ -9,6 +10,7 @@ interface FocusWithRetriesOptions { export function focusWithRetries({ focus, isFocused, + deferInitialAttempt = false, timeoutMs = 1500, onSuccess, onTimeout, @@ -40,7 +42,11 @@ export function focusWithRetries({ }); }; - tick(); + if (deferInitialAttempt) { + requestAnimationFrame(tick); + } else { + tick(); + } return () => { cancelled = true;