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
+25
View File
@@ -217,6 +217,31 @@ PASEO_PROFILE_IDLE_WAIT_MS=3000 # idle baseline before switching
PASEO_PROFILE_DUMP_COMMITS=1 # include per-commit profiler samples 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 ### Desktop macOS compositor watchdog
macOS display sleep can leave Chromium's GPU-process display link — the vsync macOS display sleep can leave Chromium's GPU-process display link — the vsync
@@ -27,6 +27,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { getServerId } from "../support/helpers/server-id"; import { getServerId } from "../support/helpers/server-id";
import { buildHostWorkspaceRoute } from "@/utils/host-routes"; 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 { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { selectModel } from "../support/helpers/app"; import { selectModel } from "../support/helpers/app";
@@ -354,7 +355,7 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
model: "ten-second-stream", model: "ten-second-stream",
}); });
const evictionAgents = await Promise.all( const evictionAgents = await Promise.all(
Array.from({ length: 3 }, (_unused, index) => Array.from({ length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES }, (_unused, index) =>
seedMockAgentWorkspace({ seedMockAgentWorkspace({
repoPrefix: `submission-workspace-eviction-${testInfo.workerIndex}-${index}-`, repoPrefix: `submission-workspace-eviction-${testInfo.workerIndex}-${index}-`,
title: `Workspace eviction ${index + 1}`, title: `Workspace eviction ${index + 1}`,
@@ -379,7 +380,9 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
await expectComposerVisible(page); await expectComposerVisible(page);
} }
await expect(targetDeckEntry).toHaveCount(0); 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); gate.setAgentStreamSuppressed(false);
await target.client.waitForFinish(target.agentId, 30_000); await target.client.waitForFinish(target.agentId, 30_000);
+1
View File
@@ -27,6 +27,7 @@
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"build": "npm run build:web", "build": "npm run build:web",
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform 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", "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", "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", "build:terminal-webview": "node ./scripts/build-terminal-webview-html.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();
@@ -1220,18 +1220,45 @@ describe("createWebStreamStrategy", () => {
expect(scrollTo).not.toHaveBeenCalled(); expect(scrollTo).not.toHaveBeenCalled();
}); });
it("keeps the retained viewport mounted while inactive and reconciles it once on return", () => { it("keeps the retained viewport mounted while inactive and reconciles it once on return", async () => {
const observed = new Map<Element, ReturnType<typeof vi.fn>>(); interface ResizeObservation {
callback: ResizeObserverCallback;
disconnect: ReturnType<typeof vi.fn>;
observer: ResizeObserver;
}
const observed = new Map<Element, ResizeObservation>();
Object.defineProperty(globalThis, "ResizeObserver", { Object.defineProperty(globalThis, "ResizeObserver", {
configurable: true, configurable: true,
value: class ResizeObserver { value: class TestResizeObserver {
readonly disconnect = vi.fn(); readonly disconnect = vi.fn();
constructor(private readonly callback: ResizeObserverCallback) {}
observe(target: Element) { 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 scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
const top = typeof options === "object" ? (options.top ?? 0) : 0; const top = typeof options === "object" ? (options.top ?? 0) : 0;
Object.defineProperty(this, "scrollTop", { configurable: true, value: top }); Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
@@ -1239,6 +1266,7 @@ describe("createWebStreamStrategy", () => {
HTMLElement.prototype.scrollTo = scrollTo; HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>(); const viewportRef = React.createRef<StreamViewportHandle>();
const onReadingPositionChange = vi.fn();
const renderInput: StreamRenderInput = { const renderInput: StreamRenderInput = {
agentId: "agent", agentId: "agent",
segments: { segments: {
@@ -1257,6 +1285,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null, routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true, isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(), onNearBottomChange: vi.fn(),
onReadingPositionChange,
onNearHistoryStart: vi.fn().mockReturnValue(true), onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false, isLoadingOlderHistory: false,
hasOlderHistory: false, hasOlderHistory: false,
@@ -1278,16 +1307,50 @@ describe("createWebStreamStrategy", () => {
if (!(scrollContainer instanceof HTMLElement)) { if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container"); 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, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 }); Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
act(() => scrollContainer.dispatchEvent(new Event("scroll"))); act(() => scrollContainer.dispatchEvent(new Event("scroll")));
const viewportObserverDisconnect = observed.get(scrollContainer); const viewportObservation = observed.get(scrollContainer);
expect(viewportObserverDisconnect).toBeDefined(); expect(viewportObservation).toBeDefined();
act(() => notifyResize(scrollContainer, contentNode));
expect(onReadingPositionChange).toHaveBeenLastCalledWith("message-1");
act(() => root?.render(renderWithActivity(false))); act(() => root?.render(renderWithActivity(false)));
expect(container.querySelector('[data-testid="agent-chat-scroll"]')).toBe(scrollContainer); 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(); scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 }); Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
+112 -4
View File
@@ -202,6 +202,65 @@ function syncNearBottom(
return nextValue; 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( function getScrollContainerDistanceFromBottom(
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">, scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
): number { ): number {
@@ -275,6 +334,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const historyStartPrependAnchorRef = useRef<HistoryStartPrependAnchor | null>(null); const historyStartPrependAnchorRef = useRef<HistoryStartPrependAnchor | null>(null);
const historyStartPrependAnchorActiveRef = useRef(false); const historyStartPrependAnchorActiveRef = useRef(false);
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null); 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 shouldUseVirtualizer = segments.historyVirtualized.length > 0;
const { const {
renderHistoryVirtualizedRow, renderHistoryVirtualizedRow,
@@ -753,14 +817,39 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
// Following output is a layout invariant: rows, footer, and bottom offset must // Following output is a layout invariant: rows, footer, and bottom offset must
// reach the browser in the same paint. // reach the browser in the same paint.
useLayoutEffect(() => { 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; return;
} }
lastActiveFollowOutputLayoutRef.current = layout;
if (!followOutputRef.current || resumedUnchangedLayout) return;
cancelPendingStickToBottom(); cancelPendingStickToBottom();
scrollMessagesToBottom("auto"); scrollMessagesToBottom("auto");
}, [ }, [
activationKey,
cancelPendingStickToBottom, cancelPendingStickToBottom,
isActive, isActive,
isActivationReady,
renderLiveAuxiliary, renderLiveAuxiliary,
scrollMessagesToBottom, scrollMessagesToBottom,
segments.historyMounted, segments.historyMounted,
@@ -773,7 +862,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (!isActive) { if (!isActive) {
return; return;
} }
updateScrollMetrics(); const resumedUnchangedLayout = resumedUnchangedLayoutRef.current;
resumedUnchangedLayoutRef.current = false;
if (!resumedUnchangedLayout) {
updateScrollMetrics();
}
evaluateHistoryStart(); evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") { if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartPrependSettle(); scheduleHistoryStartPrependSettle();
@@ -802,9 +895,23 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return; return;
} }
updateScrollMetrics(); if (!pendingResumeGeometryCheckRef.current) {
evaluateHistoryStart(); updateScrollMetrics();
evaluateHistoryStart();
}
const observer = new ResizeObserver(() => { 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) { if (historyStartPrependAnchorActiveRef.current) {
applyHistoryStartPrependAnchor(); applyHistoryStartPrependAnchor();
} }
@@ -829,6 +936,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
applyHistoryStartPrependAnchor, applyHistoryStartPrependAnchor,
evaluateHistoryStart, evaluateHistoryStart,
isActive, isActive,
reportReadingPosition,
scheduleHistoryStartPrependSettle, scheduleHistoryStartPrependSettle,
scheduleStickToBottom, scheduleStickToBottom,
updateScrollMetrics, updateScrollMetrics,
@@ -13,14 +13,15 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store"; import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import { import {
areWorkspaceSelectionListsEqual, areRetainedWorkspaceSelectionListsEqual,
areWorkspaceSelectionsEqual, areWorkspaceSelectionsEqual,
getNextWorkspaceDeckExpirationDelay,
getWorkspaceSelectionKey, getWorkspaceSelectionKey,
orderWorkspaceSelectionsForStableRender, orderWorkspaceSelectionsForStableRender,
pruneMountedWorkspaceSelections, reconcileRetainedWorkspaceSelections,
type RetainedWorkspaceSelection,
resolveWorkspaceDeckEntries, resolveWorkspaceDeckEntries,
shouldKeepWorkspaceDeckEntryMounted, shouldKeepWorkspaceDeckEntryMounted,
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
} from "@/screens/workspace/workspace-deck-retention"; } from "@/screens/workspace/workspace-deck-retention";
import { import {
decodeWorkspaceIdFromPathSegment, decodeWorkspaceIdFromPathSegment,
@@ -33,6 +34,7 @@ import {
} from "@/utils/host-route-browser"; } from "@/utils/host-route-browser";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { RenderProfile } from "@/utils/render-profiler";
function getParamValue(value: string | string[] | undefined): string { function getParamValue(value: string | string[] | undefined): string {
if (typeof value === "string") { if (typeof value === "string") {
@@ -199,29 +201,31 @@ function WorkspaceDeck({
recoveryAgentId: string | null; recoveryAgentId: string | null;
}) { }) {
const activeSelection = useActiveWorkspaceSelection(); const activeSelection = useActiveWorkspaceSelection();
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() => const [retainedSelections, setRetainedSelections] = useState<RetainedWorkspaceSelection[]>(() =>
activeSelection ? [activeSelection] : [], activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [],
); );
const unmountWorkspaceSelection = useCallback((selection: ActiveWorkspaceSelection) => { const unmountWorkspaceSelection = useCallback((selection: ActiveWorkspaceSelection) => {
setMountedSelections((current) => setRetainedSelections((current) =>
current.filter( current.filter((entry) => !areWorkspaceSelectionsEqual(entry.selection, selection)),
(mountedSelection) => !areWorkspaceSelectionsEqual(mountedSelection, selection),
),
); );
}, []); }, []);
const nextMountedSelections = useMemo( const reconciliationNow = Date.now();
const nextRetainedSelections = useMemo(
() => () =>
pruneMountedWorkspaceSelections({ reconcileRetainedWorkspaceSelections({
currentSelections: mountedSelections, currentEntries: retainedSelections,
activeSelection, activeSelection,
maxMountedWorkspaces: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES, now: reconciliationNow,
}), }),
[activeSelection, mountedSelections], [activeSelection, reconciliationNow, retainedSelections],
); );
const renderedSelections = useMemo( const renderedSelections = useMemo(
() => orderWorkspaceSelectionsForStableRender(nextMountedSelections), () =>
[nextMountedSelections], orderWorkspaceSelectionsForStableRender(
nextRetainedSelections.map((entry) => entry.selection),
),
[nextRetainedSelections],
); );
const renderedEntries = useMemo( const renderedEntries = useMemo(
() => resolveWorkspaceDeckEntries({ selections: renderedSelections, activeSelection }), () => resolveWorkspaceDeckEntries({ selections: renderedSelections, activeSelection }),
@@ -229,26 +233,49 @@ function WorkspaceDeck({
); );
useLayoutEffect(() => { useLayoutEffect(() => {
if (!areWorkspaceSelectionListsEqual(mountedSelections, nextMountedSelections)) { if (!areRetainedWorkspaceSelectionListsEqual(retainedSelections, nextRetainedSelections)) {
setMountedSelections(nextMountedSelections); 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 ( return (
<View style={styles.deck}> <RenderProfile id="WorkspaceDeck">
{renderedEntries.map(({ selection, active }) => { <View style={styles.deck}>
return ( {renderedEntries.map(({ selection, active }) => {
<WorkspaceDeckEntry return (
key={getWorkspaceSelectionKey(selection)} <WorkspaceDeckEntry
selection={selection} key={getWorkspaceSelectionKey(selection)}
active={active} selection={selection}
recoveryRequested={recoveryRequested} active={active}
recoveryAgentId={recoveryAgentId} recoveryRequested={recoveryRequested}
onUnmountInactive={unmountWorkspaceSelection} recoveryAgentId={recoveryAgentId}
/> onUnmountInactive={unmountWorkspaceSelection}
); />
})} );
</View> })}
</View>
</RenderProfile>
); );
} }
@@ -64,7 +64,11 @@ import { readMeasuredWidth } from "@/hooks/use-container-width";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; 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 { isNative } from "@/constants/platform";
import { import {
resolveComposerControlDensity, resolveComposerControlDensity,
@@ -154,11 +158,28 @@ export interface DraftAgentControlsProps {
interface AgentControlsProps { interface AgentControlsProps {
agentId: string; agentId: string;
serverId: string; serverId: string;
isPaneFocused: boolean;
onDropdownClose?: () => void; onDropdownClose?: () => void;
isCompactLayout?: boolean; 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( function findOptionLabel(
options: AgentControlOption[] | undefined, options: AgentControlOption[] | undefined,
selectedId: string | undefined, selectedId: string | undefined,
@@ -1477,7 +1498,6 @@ function ThinkingComboboxOption({
export const AgentControls = memo(function AgentControls({ export const AgentControls = memo(function AgentControls({
agentId, agentId,
serverId, serverId,
isPaneFocused,
onDropdownClose, onDropdownClose,
isCompactLayout, isCompactLayout,
}: AgentControlsProps) { }: AgentControlsProps) {
@@ -1649,10 +1669,8 @@ export const AgentControls = memo(function AgentControls({
[agentId, agentProvider, client, toast, updatePreferences], [agentId, agentProvider, client, toast, updatePreferences],
); );
useAgentControlCommandCenterActions({ const commandCenterControls = useMemo<AgentControlCommandCenterSource>(
sourceId: `agent:${serverId}:${agentId}`, () => ({
enabled: isPaneFocused && Boolean(client),
controls: {
serverId, serverId,
ownerKey: agentId, ownerKey: agentId,
provider: agentProvider, provider: agentProvider,
@@ -1673,8 +1691,31 @@ export const AgentControls = memo(function AgentControls({
list: agent?.features, list: agent?.features,
set: handleSetFeature, set: handleSetFeature,
}, },
}, }),
}); [
activeModelId,
agent?.features,
agentId,
agentModelSelectorProviders,
agentProvider,
commandCenterModes,
handleSelectCommandCenterModel,
handleSelectThinkingOption,
handleSetFeature,
modeProviderDefinitions,
modelSelection.selectedThinkingId,
modelSelection.thinkingOptions,
serverId,
],
);
const commandCenterRegistration = (
<AgentControlCommandCenterRegistration
sourceId={`agent:${serverId}:${agentId}`}
enabled={Boolean(client)}
controls={commandCenterControls}
/>
);
const handleModelSelectorOpen = useCallback(() => { const handleModelSelectorOpen = useCallback(() => {
refetchSnapshotIfStale(agentProvider); refetchSnapshotIfStale(agentProvider);
@@ -1692,30 +1733,33 @@ export const AgentControls = memo(function AgentControls({
} }
return ( return (
<ControlledAgentControls <>
provider={agent.provider} {commandCenterRegistration}
modelSelectorProviders={agentModelSelectorProviders} <ControlledAgentControls
modelOptions={modelOptions} provider={agent.provider}
selectedModelId={modelSelection.activeModelId ?? undefined} modelSelectorProviders={agentModelSelectorProviders}
onSelectModel={handleSelectModel} modelOptions={modelOptions}
agentProfiles={agentProfiles} selectedModelId={modelSelection.activeModelId ?? undefined}
onApplyAgentProfile={agentProfiles?.applyProfile} onSelectModel={handleSelectModel}
onEditAgentProfiles={handleEditAgentProfiles} agentProfiles={agentProfiles}
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined} onApplyAgentProfile={agentProfiles?.applyProfile}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined} onEditAgentProfiles={handleEditAgentProfiles}
onSelectThinkingOption={handleSelectThinkingOption} thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
features={agent.features} selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSetFeature={handleSetFeature} onSelectThinkingOption={handleSelectThinkingOption}
isModelLoading={snapshotIsLoading || selectedProviderIsLoading} features={agent.features}
onModelSelectorOpen={handleModelSelectorOpen} onSetFeature={handleSetFeature}
onRetryModelProvider={handleRetryModelProvider} isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
isRetryingModelProvider={snapshotIsRefreshing} onModelSelectorOpen={handleModelSelectorOpen}
onDropdownClose={onDropdownClose} onRetryModelProvider={handleRetryModelProvider}
disabled={!client} isRetryingModelProvider={snapshotIsRefreshing}
modeControl={modeControl} onDropdownClose={onDropdownClose}
modelSelectorServerId={serverId} disabled={!client}
isCompactLayout={isCompactLayout} modeControl={modeControl}
/> modelSelectorServerId={serverId}
isCompactLayout={isCompactLayout}
/>
</>
); );
}); });
+93 -62
View File
@@ -96,7 +96,7 @@ import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispat
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { submitAgentInput } from "@/composer/submit"; import { submitAgentInput } from "@/composer/submit";
import { createMessageSubmissionWriter } from "@/composer/submission/writer"; 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 { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform"; import { isWeb, isNative } from "@/constants/platform";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
@@ -296,12 +296,11 @@ interface RenderLeftContentArgs {
serverId: string; serverId: string;
focusInput: () => void; focusInput: () => void;
isCompactLayout: boolean; isCompactLayout: boolean;
isPaneFocused: boolean;
showAgentControls: boolean; showAgentControls: boolean;
} }
function renderLeftContent(args: RenderLeftContentArgs): ReactElement | null { 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 (!args.showAgentControls) return null;
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) { if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
return <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />; return <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />;
@@ -310,7 +309,6 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement | null {
<AgentControls <AgentControls
agentId={agentId} agentId={agentId}
serverId={serverId} serverId={serverId}
isPaneFocused={isPaneFocused}
onDropdownClose={focusInput} onDropdownClose={focusInput}
isCompactLayout={isCompactLayout} isCompactLayout={isCompactLayout}
/> />
@@ -561,6 +559,68 @@ function dispatchComposerKeyboardAction(args: DispatchComposerKeyboardActionArgs
return true; return true;
} }
function ComposerKeyboardRegistration({
messageInputRef,
isAgentRunning,
isCancellingAgent,
isConnected,
handleCancelAgent,
focusMessageInputForKeyboardAction,
isMessageInputFocused,
handlerId,
}: Omit<DispatchComposerKeyboardActionArgs, "action" | "isPaneFocused"> & {
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<typeof AutocompletePopover>) {
const { isActiveComposer } = useComposerKeyboardScope();
return <AutocompletePopover {...props} visible={isActiveComposer && props.visible} />;
}
function resolveMessageInputPassthroughAction( function resolveMessageInputPassthroughAction(
actionId: string, actionId: string,
): MessageInputKeyboardActionKind | null { ): MessageInputKeyboardActionKind | null {
@@ -1052,12 +1112,23 @@ function ComposerVoiceModeButton({
); );
} }
export function Composer({ isPaneFocused, ...props }: ComposerProps) {
return (
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
<ComposerContent {...props} />
</ComposerKeyboardScopeProvider>
);
}
type ComposerContentProps = Omit<ComposerProps, "isPaneFocused">;
const ComposerContent = memo(ComposerContentImpl);
// oxlint-disable-next-line complexity // oxlint-disable-next-line complexity
export function Composer({ function ComposerContentImpl({
agentId, agentId,
serverId, serverId,
workspaceId, workspaceId,
isPaneFocused,
onSubmitMessage, onSubmitMessage,
onClientSlashCommand, onClientSlashCommand,
hasExternalContent = false, hasExternalContent = false,
@@ -1096,7 +1167,7 @@ export function Composer({
readOnly = false, readOnly = false,
submitLabel, submitLabel,
placeholder, placeholder,
}: ComposerProps) { }: ComposerContentProps) {
const mode = resolveComposerInputMode(inputMode); const mode = resolveComposerInputMode(inputMode);
const { t } = useTranslation(); const { t } = useTranslation();
const buttonIconSize = resolveComposerButtonIconSize(); const buttonIconSize = resolveComposerButtonIconSize();
@@ -1682,46 +1753,6 @@ export function Composer({
focusMessageInputWithPlatformStrategy(messageInputRef); 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({ const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
mode: "translate", mode: "translate",
enabled: !externalKeyboardShift, enabled: !externalKeyboardShift,
@@ -2032,18 +2063,9 @@ export function Composer({
serverId, serverId,
focusInput, focusInput,
isCompactLayout, isCompactLayout,
isPaneFocused,
showAgentControls: mode.showAgentControls, showAgentControls: mode.showAgentControls,
}), }),
[ [agentControls, agentId, focusInput, isCompactLayout, mode.showAgentControls, serverId],
agentControls,
agentId,
focusInput,
isCompactLayout,
isPaneFocused,
mode.showAgentControls,
serverId,
],
); );
const handleAttachButtonRef = useCallback((node: View | null) => { const handleAttachButtonRef = useCallback((node: View | null) => {
@@ -2169,10 +2191,20 @@ export function Composer({
const githubEmptyText = githubSearchResultsQuery.isFetching const githubEmptyText = githubSearchResultsQuery.isFetching
? t("composer.github.searching") ? t("composer.github.searching")
: t("composer.github.noResults"); : t("composer.github.noResults");
const autocompleteVisible = autocomplete.isVisible && isPaneFocused && mode.showAutocomplete; const autocompleteVisible = autocomplete.isVisible && mode.showAutocomplete;
return ( return (
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}> <>
<ComposerKeyboardRegistration
handlerId={keyboardHandlerIdRef.current}
messageInputRef={messageInputRef}
isAgentRunning={isAgentRunning}
isCancellingAgent={isCancellingAgent}
isConnected={isConnected}
handleCancelAgent={handleCancelAgent}
focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction}
isMessageInputFocused={isMessageInputFocused}
/>
<Animated.View style={composerContainerStyle}> <Animated.View style={composerContainerStyle}>
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} /> <AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
{/* Input area */} {/* Input area */}
@@ -2182,7 +2214,7 @@ export function Composer({
{sendErrorNode} {sendErrorNode}
<View ref={messageInputContainerRef} style={styles.messageInputContainer}> <View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<AutocompletePopover <ComposerAutocomplete
visible={autocompleteVisible} visible={autocompleteVisible}
anchorRef={messageInputContainerRef} anchorRef={messageInputContainerRef}
options={autocomplete.options} options={autocomplete.options}
@@ -2220,7 +2252,6 @@ export function Composer({
autoFocus={messageInputAutoFocus} autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`} autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
disabled={isSubmitLoading} disabled={isSubmitLoading}
isPaneFocused={isPaneFocused}
leftContent={leftContent} leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent} beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent} rightContent={rightContent}
@@ -2267,7 +2298,7 @@ export function Composer({
</View> </View>
</View> </View>
</Animated.View> </Animated.View>
</ComposerKeyboardScopeProvider> </>
); );
} }
+25 -10
View File
@@ -54,6 +54,7 @@ import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { useComposerHeightMirror } from "./height-mirror"; import { useComposerHeightMirror } from "./height-mirror";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import type { NativePastedFile } from "@/composer/native-pasted-image"; import type { NativePastedFile } from "@/composer/native-pasted-image";
@@ -116,8 +117,6 @@ export interface MessageInputProps {
autoFocus?: boolean; autoFocus?: boolean;
autoFocusKey?: string; autoFocusKey?: string;
disabled?: boolean; 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) */ /** Content to render on the left side of the composer toolbar (e.g., AgentControls) */
leftContent?: React.ReactNode; leftContent?: React.ReactNode;
/** Content to render on the right side before the voice button (e.g., context window meter) */ /** 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; const active = typeof document !== "undefined" ? document.activeElement : null;
return Boolean(element) && active === element; return Boolean(element) && active === element;
}, },
deferInitialAttempt: true,
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoFocus, autoFocusKey]); }, [autoFocus, autoFocusKey]);
} }
function MessageInputAutoFocus({
enabled,
autoFocusKey,
textInputRef,
}: {
enabled: boolean;
autoFocusKey: string | undefined;
textInputRef: React.MutableRefObject<ComposerTextInputHandle | null>;
}) {
const { isActiveComposer } = useComposerKeyboardScope();
useAutoFocusOnWebEffect(textInputRef, enabled && isActiveComposer, autoFocusKey);
return null;
}
function MessageInputOverlay({ function MessageInputOverlay({
showDictationOverlay, showDictationOverlay,
showRealtimeOverlay, showRealtimeOverlay,
@@ -580,7 +594,8 @@ function FocusHint({
focusInputKeys: ShortcutChord | null | undefined; focusInputKeys: ShortcutChord | null | undefined;
label: string; label: string;
}) { }) {
if (!visible || !focusInputKeys || !label.trim()) return null; const { isActiveComposer } = useComposerKeyboardScope();
if (!isActiveComposer || !visible || !focusInputKeys || !label.trim()) return null;
return ( return (
<Text style={styles.focusHintText} pointerEvents="none"> <Text style={styles.focusHintText} pointerEvents="none">
{label} {label}
@@ -1030,7 +1045,6 @@ interface ResolvedMessageInputProps {
autoFocus: boolean; autoFocus: boolean;
autoFocusKey: string | undefined; autoFocusKey: string | undefined;
disabled: boolean; disabled: boolean;
isPaneFocused: boolean;
leftContent: React.ReactNode; leftContent: React.ReactNode;
beforeVoiceContent: React.ReactNode; beforeVoiceContent: React.ReactNode;
rightContent: React.ReactNode; rightContent: React.ReactNode;
@@ -1078,7 +1092,6 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
autoFocus: props.autoFocus ?? false, autoFocus: props.autoFocus ?? false,
autoFocusKey: props.autoFocusKey, autoFocusKey: props.autoFocusKey,
disabled: props.disabled ?? false, disabled: props.disabled ?? false,
isPaneFocused: props.isPaneFocused ?? true,
leftContent: props.leftContent, leftContent: props.leftContent,
beforeVoiceContent: props.beforeVoiceContent, beforeVoiceContent: props.beforeVoiceContent,
rightContent: props.rightContent, rightContent: props.rightContent,
@@ -1134,7 +1147,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
autoFocus, autoFocus,
autoFocusKey, autoFocusKey,
disabled, disabled,
isPaneFocused,
leftContent, leftContent,
beforeVoiceContent, beforeVoiceContent,
rightContent, rightContent,
@@ -1237,8 +1249,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}; };
}, [onFocusChange]); }, [onFocusChange]);
useAutoFocusOnWebEffect(textInputRef, autoFocus, autoFocusKey);
const handleDictationTranscript = useCallback( const handleDictationTranscript = useCallback(
(text: string, _meta: { requestId: string }) => { (text: string, _meta: { requestId: string }) => {
const autoSend = sendAfterTranscriptRef.current; const autoSend = sendAfterTranscriptRef.current;
@@ -1738,6 +1748,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
return ( return (
<View ref={rootRef} style={styles.container} testID="message-input-root"> <View ref={rootRef} style={styles.container} testID="message-input-root">
<MessageInputAutoFocus
enabled={autoFocus}
autoFocusKey={autoFocusKey}
textInputRef={textInputRef}
/>
{/* Regular input */} {/* Regular input */}
<View <View
ref={inputWrapperRef} ref={inputWrapperRef}
@@ -1759,13 +1774,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onBlur={handleInputBlur} onBlur={handleInputBlur}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true} scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true}
autoFocus={isWeb && autoFocus} autoFocus={false}
onContentSizeChange={handleContentSizeChange} onContentSizeChange={handleContentSizeChange}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined} onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages} onPasteImages={onPasteImages}
onPasteError={handlePasteError} onPasteError={handlePasteError}
focusHintVisible={isWeb && isPaneFocused && !isInputFocused && !value} focusHintVisible={isWeb && !isInputFocused && !value}
focusInputKeys={focusInputKeys} focusInputKeys={focusInputKeys}
focusHintLabel={t("composer.input.focusHint", { focusHintLabel={t("composer.input.focusHint", {
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "", shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
@@ -44,6 +44,7 @@ describe("navigateToHostWorkspaceRoute", () => {
it("pops to the mounted host route and targets the requested workspace", () => { it("pops to the mounted host route and targets the requested workspace", () => {
const { navigationRef, dispatch } = createNavigationRef({ const { navigationRef, dispatch } = createNavigationRef({
key: "root-stack", key: "root-stack",
index: 1,
routeNames: ["index", "settings/[section]", "h/[serverId]"], routeNames: ["index", "settings/[section]", "h/[serverId]"],
routes: [ 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", () => { it("preserves a workspace open intent in the POP_TO target", () => {
const { navigationRef, dispatch } = createNavigationRef({ const { navigationRef, dispatch } = createNavigationRef({
key: "root-stack", 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); registerWorkspaceRouteNavigationRef(navigationRef);
@@ -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") { if (!state || typeof state !== "object") {
return null; return null;
} }
const candidate = state as { const candidate = state as {
index?: unknown;
key?: unknown; key?: unknown;
routes?: unknown; routes?: unknown;
}; };
@@ -52,19 +61,34 @@ function findStackKeyWithMountedRouteName(state: unknown, routeName: string): st
!!route && typeof route === "object" && (route as { name?: unknown }).name === routeName, !!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) { for (const route of candidate.routes) {
if (!route || typeof route !== "object") { if (!route || typeof route !== "object") {
continue; continue;
} }
const childKey = findStackKeyWithMountedRouteName( const childStack = findStackWithMountedRouteName(
(route as { state?: unknown }).state, (route as { state?: unknown }).state,
routeName, routeName,
); );
if (childKey) { if (childStack) {
return childKey; return childStack;
} }
} }
@@ -79,15 +103,15 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
} }
const rootState = navigation.getRootState(); const rootState = navigation.getRootState();
const target = findStackKeyWithMountedRouteName(rootState, ROOT_HOST_ROUTE_NAME); const hostStack = findStackWithMountedRouteName(rootState, ROOT_HOST_ROUTE_NAME);
if (!target) { if (!hostStack || hostStack.focusedRouteName === ROOT_HOST_ROUTE_NAME) {
return false; return false;
} }
const open = getHostWorkspaceOpenParamFromPathname(route); const open = getHostWorkspaceOpenParamFromPathname(route);
const action: NavigationAction = { const action: NavigationAction = {
type: "POP_TO", type: "POP_TO",
target, target: hostStack.key,
payload: { payload: {
name: ROOT_HOST_ROUTE_NAME, name: ROOT_HOST_ROUTE_NAME,
params: { params: {
+1 -1
View File
@@ -1648,7 +1648,7 @@ function ActiveAgentComposer({
onChangeAttachments={agentInputDraft.setAttachments} onChangeAttachments={agentInputDraft.setAttachments}
cwd={cwd} cwd={cwd}
clearDraft={agentInputDraft.clear} clearDraft={agentInputDraft.clear}
autoFocus={isPaneFocused} autoFocus
autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)} autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)}
isSubmitLoading={isSubmitLoading} isSubmitLoading={isSubmitLoading}
onAttentionInputFocus={onAttentionInputFocus} onAttentionInputFocus={onAttentionInputFocus}
@@ -1,10 +1,13 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { import {
getNextWorkspaceDeckExpirationDelay,
orderWorkspaceSelectionsForStableRender, orderWorkspaceSelectionsForStableRender,
pruneMountedWorkspaceSelections, reconcileRetainedWorkspaceSelections,
resolveWorkspaceDeckEntries, resolveWorkspaceDeckEntries,
shouldKeepWorkspaceDeckEntryMounted, shouldKeepWorkspaceDeckEntryMounted,
WORKSPACE_DECK_INACTIVE_TTL_MS,
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
} from "@/screens/workspace/workspace-deck-retention"; } from "@/screens/workspace/workspace-deck-retention";
function workspace(workspaceId: string, serverId = "server"): ActiveWorkspaceSelection { function workspace(workspaceId: string, serverId = "server"): ActiveWorkspaceSelection {
@@ -15,65 +18,115 @@ function mountedWorkspaceIds(selections: ActiveWorkspaceSelection[]): string[] {
return selections.map((selection) => selection.workspaceId); return selections.map((selection) => selection.workspaceId);
} }
describe("pruneMountedWorkspaceSelections", () => { function retained(workspaceId: string, inactiveSince: number | null) {
it("retains the deck while an app-wide route temporarily clears the active workspace", () => { return { selection: workspace(workspaceId), inactiveSince };
const mountedSelections = [workspace("A"), workspace("B")]; }
function retainedWorkspaceIds(
entries: ReturnType<typeof reconcileRetainedWorkspaceSelections>,
): string[] {
return entries.map((entry) => entry.selection.workspaceId);
}
describe("reconcileRetainedWorkspaceSelections", () => {
it("retains inactive workspaces for ten minutes across app-wide routes", () => {
const retainedEntries = [retained("A", 1_000), retained("B", 2_000)];
expect( expect(
pruneMountedWorkspaceSelections({ reconcileRetainedWorkspaceSelections({
currentSelections: mountedSelections, currentEntries: retainedEntries,
activeSelection: null, 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", () => { it("keeps the active workspace and nine most recently activated inactive workspaces", () => {
const mountedAfterA = pruneMountedWorkspaceSelections({ let retainedEntries = Array.from(
currentSelections: [], { length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES },
activeSelection: workspace("A"), (_, 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"), activeSelection: workspace("B"),
}); now: 3_000,
const mountedAfterC = pruneMountedWorkspaceSelections({
currentSelections: mountedAfterB,
activeSelection: workspace("C"),
});
const mountedAfterD = pruneMountedWorkspaceSelections({
currentSelections: mountedAfterC,
activeSelection: workspace("D"),
}); });
expect(mountedWorkspaceIds(mountedAfterD)).toEqual(["D", "C", "B"]); expect(retainedEntries).toEqual([retained("B", null), retained("A", 3_000)]);
}); });
it("retains the active workspace", () => { it("never expires the active workspace", () => {
const mountedSelections = pruneMountedWorkspaceSelections({ const retainedEntries = reconcileRetainedWorkspaceSelections({
currentSelections: [workspace("A")], currentEntries: [retained("A", 0)],
activeSelection: workspace("A"), 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", () => { it("deduplicates retained workspace selections", () => {
const mountedSelections = pruneMountedWorkspaceSelections({ const retainedEntries = reconcileRetainedWorkspaceSelections({
currentSelections: [workspace("B"), workspace("A"), workspace("B")], currentEntries: [retained("B", 2), retained("A", 1), retained("B", 0)],
activeSelection: workspace("A"), 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", () => { it("does not schedule expiration for the active workspace", () => {
const mountedSelections = pruneMountedWorkspaceSelections({ expect(
currentSelections: [workspace("A"), workspace("B")], getNextWorkspaceDeckExpirationDelay({
activeSelection: workspace("C"), entries: [retained("A", null)],
maxMountedWorkspaces: 0, activeSelection: workspace("A"),
}); now: 4_000,
}),
expect(mountedWorkspaceIds(mountedSelections)).toEqual(["C"]); ).toBeNull();
}); });
}); });
@@ -1,11 +1,26 @@
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; 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 { export interface RetainedWorkspaceSelection {
currentSelections: ActiveWorkspaceSelection[]; selection: ActiveWorkspaceSelection;
inactiveSince: number | null;
}
interface ReconcileRetainedWorkspaceSelectionsInput {
currentEntries: RetainedWorkspaceSelection[];
activeSelection: ActiveWorkspaceSelection | null; activeSelection: ActiveWorkspaceSelection | null;
now: number;
maxMountedWorkspaces?: number; maxMountedWorkspaces?: number;
inactiveTtlMs?: number;
}
interface GetNextWorkspaceDeckExpirationDelayInput {
entries: RetainedWorkspaceSelection[];
activeSelection: ActiveWorkspaceSelection | null;
now: number;
inactiveTtlMs?: number;
} }
interface WorkspaceDeckEntryMountInput { interface WorkspaceDeckEntryMountInput {
@@ -30,53 +45,92 @@ export function areWorkspaceSelectionsEqual(
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId; return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
} }
export function areWorkspaceSelectionListsEqual( export function areRetainedWorkspaceSelectionListsEqual(
left: ActiveWorkspaceSelection[], left: RetainedWorkspaceSelection[],
right: ActiveWorkspaceSelection[], right: RetainedWorkspaceSelection[],
): boolean { ): boolean {
if (left.length !== right.length) { if (left.length !== right.length) {
return false; return false;
} }
return left.every((selection, index) => return left.every((entry, index) => {
areWorkspaceSelectionsEqual(selection, right[index] ?? null), const otherEntry = right[index];
); return (
otherEntry !== undefined &&
entry.inactiveSince === otherEntry.inactiveSince &&
areWorkspaceSelectionsEqual(entry.selection, otherEntry.selection)
);
});
} }
export function pruneMountedWorkspaceSelections({ export function reconcileRetainedWorkspaceSelections({
currentSelections, currentEntries,
activeSelection, activeSelection,
now,
maxMountedWorkspaces = WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES, maxMountedWorkspaces = WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
}: PruneMountedWorkspaceSelectionsInput): ActiveWorkspaceSelection[] { inactiveTtlMs = WORKSPACE_DECK_INACTIVE_TTL_MS,
if (!activeSelection) { }: ReconcileRetainedWorkspaceSelectionsInput): RetainedWorkspaceSelection[] {
return currentSelections; const maxSelections = activeSelection
} ? Math.max(1, maxMountedWorkspaces)
: Math.max(0, maxMountedWorkspaces);
const maxSelections = Math.max(1, maxMountedWorkspaces); const nextEntries: RetainedWorkspaceSelection[] = [];
const nextSelections: ActiveWorkspaceSelection[] = [];
const seenSelectionKeys = new Set<string>(); const seenSelectionKeys = new Set<string>();
function appendSelection(selection: ActiveWorkspaceSelection): void { function appendEntry(entry: RetainedWorkspaceSelection): void {
if (nextSelections.length >= maxSelections) { if (nextEntries.length >= maxSelections) {
return; return;
} }
const selectionKey = getWorkspaceSelectionKey(selection); const selectionKey = getWorkspaceSelectionKey(entry.selection);
if (seenSelectionKeys.has(selectionKey)) { if (seenSelectionKeys.has(selectionKey)) {
return; return;
} }
seenSelectionKeys.add(selectionKey); seenSelectionKeys.add(selectionKey);
nextSelections.push(selection); nextEntries.push(entry);
} }
appendSelection(activeSelection); if (activeSelection) {
appendEntry({ selection: activeSelection, inactiveSince: null });
}
for (const selection of currentSelections) { for (const entry of currentEntries) {
if (areWorkspaceSelectionsEqual(selection, activeSelection)) { if (areWorkspaceSelectionsEqual(entry.selection, activeSelection)) {
continue; 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( export function orderWorkspaceSelectionsForStableRender(
@@ -3,6 +3,7 @@ import { useStoreWithEqualityFn } from "zustand/traditional";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { import {
composeWorkspaceStructure, composeWorkspaceStructure,
createWorkspaceStructureProjectsSelector,
selectHasHydratedWorkspaces, selectHasHydratedWorkspaces,
selectHydratedWorkspaceServerIds, selectHydratedWorkspaceServerIds,
selectWorkspaceDirectoryServerIds, selectWorkspaceDirectoryServerIds,
@@ -16,7 +17,6 @@ import {
selectWorkspaceKeys, selectWorkspaceKeys,
selectWorkspaceOrderByScope, selectWorkspaceOrderByScope,
selectWorkspaceStatusesForBadges, selectWorkspaceStatusesForBadges,
selectWorkspaceStructureProjects,
workspaceEqualityFns, workspaceEqualityFns,
type WorkspaceStructure, type WorkspaceStructure,
} from "./selectors"; } from "./selectors";
@@ -100,9 +100,13 @@ export function useWorkspaceDirectory(
} }
export function useWorkspaceStructure(serverIds: string[]): WorkspaceStructure { export function useWorkspaceStructure(serverIds: string[]): WorkspaceStructure {
const selectProjects = useMemo(
() => createWorkspaceStructureProjectsSelector(serverIds),
[serverIds],
);
const projects = useStoreWithEqualityFn( const projects = useStoreWithEqualityFn(
useSessionStore, useSessionStore,
(state) => selectWorkspaceStructureProjects(state, serverIds), selectProjects,
workspaceEqualityFns.deep, workspaceEqualityFns.deep,
); );
const projectOrder = useStoreWithEqualityFn( const projectOrder = useStoreWithEqualityFn(
@@ -4,6 +4,7 @@ import { createProjectViewKey } from "@/projects/workspace-structure";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { import {
composeWorkspaceStructure, composeWorkspaceStructure,
createWorkspaceStructureProjectsSelector,
selectHasWorkspaces, selectHasWorkspaces,
selectHydratedWorkspaceServerIds, selectHydratedWorkspaceServerIds,
selectWorkspaceDirectoryServerIds, selectWorkspaceDirectoryServerIds,
@@ -338,6 +339,27 @@ describe("selectWorkspaceFields", () => {
}); });
describe("workspace structure composition", () => { 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( function snapshotStructure(
serverId: string, serverId: string,
sidebar: SidebarOrderSnapshot, sidebar: SidebarOrderSnapshot,
@@ -182,6 +182,38 @@ export function selectWorkspaceStructureProjects(
return buildWorkspaceStructureProjects({ sessions }); return buildWorkspaceStructureProjects({ sessions });
} }
export function createWorkspaceStructureProjectsSelector(
serverIds: readonly string[],
): (state: SessionsSnapshot) => WorkspaceStructureProject[] {
let previousInputs: Array<{
workspaces: Map<string, WorkspaceDescriptor> | undefined;
projects: Map<string, ProjectDescriptor> | 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( export function selectProject(
state: SessionsSnapshot, state: SessionsSnapshot,
serverId: string | null, serverId: string | null,
+30
View File
@@ -52,6 +52,36 @@ describe("focusWithRetries", () => {
expect(frameQueue).toHaveLength(0); 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", () => { it("keeps retrying on later animation frames until focus succeeds", () => {
let focused = false; let focused = false;
let attempts = 0; let attempts = 0;
+7 -1
View File
@@ -1,6 +1,7 @@
interface FocusWithRetriesOptions { interface FocusWithRetriesOptions {
focus: () => void; focus: () => void;
isFocused: () => boolean; isFocused: () => boolean;
deferInitialAttempt?: boolean;
timeoutMs?: number; timeoutMs?: number;
onSuccess?: () => void; onSuccess?: () => void;
onTimeout?: () => void; onTimeout?: () => void;
@@ -9,6 +10,7 @@ interface FocusWithRetriesOptions {
export function focusWithRetries({ export function focusWithRetries({
focus, focus,
isFocused, isFocused,
deferInitialAttempt = false,
timeoutMs = 1500, timeoutMs = 1500,
onSuccess, onSuccess,
onTimeout, onTimeout,
@@ -40,7 +42,11 @@ export function focusWithRetries({
}); });
}; };
tick(); if (deferInitialAttempt) {
requestAnimationFrame(tick);
} else {
tick();
}
return () => { return () => {
cancelled = true; cancelled = true;