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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
it("keeps the retained viewport mounted while inactive and reconciles it once on return", () => {
|
||||
const observed = new Map<Element, ReturnType<typeof vi.fn>>();
|
||||
it("keeps the retained viewport mounted while inactive and reconciles it once on return", async () => {
|
||||
interface ResizeObservation {
|
||||
callback: ResizeObserverCallback;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
observer: ResizeObserver;
|
||||
}
|
||||
const observed = new Map<Element, ResizeObservation>();
|
||||
Object.defineProperty(globalThis, "ResizeObserver", {
|
||||
configurable: true,
|
||||
value: class ResizeObserver {
|
||||
value: class TestResizeObserver {
|
||||
readonly disconnect = vi.fn();
|
||||
|
||||
constructor(private readonly callback: ResizeObserverCallback) {}
|
||||
|
||||
observe(target: Element) {
|
||||
observed.set(target, this.disconnect);
|
||||
observed.set(target, {
|
||||
callback: this.callback,
|
||||
disconnect: this.disconnect,
|
||||
observer: this as unknown as ResizeObserver,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
const notifyResize = (...targets: Element[]) => {
|
||||
const observation = targets[0] ? observed.get(targets[0]) : undefined;
|
||||
if (!observation) {
|
||||
throw new Error("Expected observed resize target");
|
||||
}
|
||||
observation.callback(
|
||||
targets.map(
|
||||
(target) =>
|
||||
({
|
||||
target,
|
||||
contentRect: target.getBoundingClientRect(),
|
||||
}) as ResizeObserverEntry,
|
||||
),
|
||||
observation.observer,
|
||||
);
|
||||
};
|
||||
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : 0;
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
@@ -1239,6 +1266,7 @@ describe("createWebStreamStrategy", () => {
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const onReadingPositionChange = vi.fn();
|
||||
const renderInput: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
@@ -1257,6 +1285,7 @@ describe("createWebStreamStrategy", () => {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onReadingPositionChange,
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
@@ -1278,16 +1307,50 @@ describe("createWebStreamStrategy", () => {
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
const contentNode = scrollContainer.firstElementChild;
|
||||
if (!(contentNode instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat content node");
|
||||
}
|
||||
const firstRow = contentNode.querySelector('[data-history-row-id="message-1"]');
|
||||
const secondRow = contentNode.querySelector('[data-history-row-id="message-2"]');
|
||||
if (!(firstRow instanceof HTMLElement) || !(secondRow instanceof HTMLElement)) {
|
||||
throw new Error("Expected retained history rows");
|
||||
}
|
||||
let rowsShifted = false;
|
||||
scrollContainer.getBoundingClientRect = vi.fn(() => ({ top: 0 }) as DOMRect);
|
||||
firstRow.getBoundingClientRect = vi.fn(() => ({ bottom: rowsShifted ? 4 : 120 }) as DOMRect);
|
||||
secondRow.getBoundingClientRect = vi.fn(() => ({ bottom: rowsShifted ? 120 : 160 }) as DOMRect);
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
const viewportObserverDisconnect = observed.get(scrollContainer);
|
||||
expect(viewportObserverDisconnect).toBeDefined();
|
||||
const viewportObservation = observed.get(scrollContainer);
|
||||
expect(viewportObservation).toBeDefined();
|
||||
act(() => notifyResize(scrollContainer, contentNode));
|
||||
expect(onReadingPositionChange).toHaveBeenLastCalledWith("message-1");
|
||||
|
||||
act(() => root?.render(renderWithActivity(false)));
|
||||
expect(container.querySelector('[data-testid="agent-chat-scroll"]')).toBe(scrollContainer);
|
||||
expect(viewportObserverDisconnect).toHaveBeenCalledOnce();
|
||||
expect(viewportObservation?.disconnect).toHaveBeenCalledOnce();
|
||||
scrollTo.mockClear();
|
||||
rowsShifted = true;
|
||||
act(() => root?.render(renderWithActivity(true)));
|
||||
act(() => notifyResize(scrollContainer, contentNode));
|
||||
expect(onReadingPositionChange).toHaveBeenLastCalledWith("message-2");
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
act(() => root?.render(renderWithActivity(false)));
|
||||
scrollTo.mockClear();
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 });
|
||||
act(() => root?.render(renderWithActivity(true)));
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
act(() => notifyResize(scrollContainer, contentNode));
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 2200, behavior: "auto" });
|
||||
|
||||
act(() => root?.render(renderWithActivity(false)));
|
||||
scrollTo.mockClear();
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
|
||||
@@ -202,6 +202,65 @@ function syncNearBottom(
|
||||
return nextValue;
|
||||
}
|
||||
|
||||
interface ActiveFollowOutputLayout {
|
||||
scrollContainer: HTMLElement | null;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
activationKey: string;
|
||||
isActivationReady: boolean;
|
||||
renderLiveAuxiliary: StreamRenderInput["renderers"]["renderLiveAuxiliary"];
|
||||
historyMounted: StreamRenderInput["segments"]["historyMounted"];
|
||||
historyVirtualized: StreamRenderInput["segments"]["historyVirtualized"];
|
||||
liveHead: StreamRenderInput["segments"]["liveHead"];
|
||||
virtualTotalSize: number;
|
||||
}
|
||||
|
||||
function activeFollowOutputLayoutsEqual(
|
||||
previous: ActiveFollowOutputLayout,
|
||||
next: ActiveFollowOutputLayout,
|
||||
): boolean {
|
||||
return (
|
||||
previous.scrollContainer === next.scrollContainer &&
|
||||
previous.viewportWidth === next.viewportWidth &&
|
||||
previous.viewportHeight === next.viewportHeight &&
|
||||
previous.activationKey === next.activationKey &&
|
||||
previous.isActivationReady === next.isActivationReady &&
|
||||
previous.renderLiveAuxiliary === next.renderLiveAuxiliary &&
|
||||
previous.historyMounted === next.historyMounted &&
|
||||
previous.historyVirtualized === next.historyVirtualized &&
|
||||
previous.liveHead === next.liveHead &&
|
||||
previous.virtualTotalSize === next.virtualTotalSize
|
||||
);
|
||||
}
|
||||
|
||||
interface ObservedViewportGeometry {
|
||||
clientWidth: number;
|
||||
clientHeight: number;
|
||||
scrollWidth: number;
|
||||
scrollHeight: number;
|
||||
}
|
||||
|
||||
function getObservedViewportGeometry(scrollContainer: HTMLElement): ObservedViewportGeometry {
|
||||
return {
|
||||
clientWidth: scrollContainer.clientWidth,
|
||||
clientHeight: scrollContainer.clientHeight,
|
||||
scrollWidth: scrollContainer.scrollWidth,
|
||||
scrollHeight: scrollContainer.scrollHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function observedViewportGeometriesEqual(
|
||||
previous: ObservedViewportGeometry,
|
||||
next: ObservedViewportGeometry,
|
||||
): boolean {
|
||||
return (
|
||||
previous.clientWidth === next.clientWidth &&
|
||||
previous.clientHeight === next.clientHeight &&
|
||||
previous.scrollWidth === next.scrollWidth &&
|
||||
previous.scrollHeight === next.scrollHeight
|
||||
);
|
||||
}
|
||||
|
||||
function getScrollContainerDistanceFromBottom(
|
||||
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
|
||||
): number {
|
||||
@@ -275,6 +334,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const historyStartPrependAnchorRef = useRef<HistoryStartPrependAnchor | null>(null);
|
||||
const historyStartPrependAnchorActiveRef = useRef(false);
|
||||
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
|
||||
const lastActiveFollowOutputLayoutRef = useRef<ActiveFollowOutputLayout | null>(null);
|
||||
const lastObservedViewportGeometryRef = useRef<ObservedViewportGeometry | null>(null);
|
||||
const wasFollowOutputLayoutActiveRef = useRef(false);
|
||||
const resumedUnchangedLayoutRef = useRef(false);
|
||||
const pendingResumeGeometryCheckRef = useRef(false);
|
||||
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
|
||||
const {
|
||||
renderHistoryVirtualizedRow,
|
||||
@@ -753,14 +817,39 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
// Following output is a layout invariant: rows, footer, and bottom offset must
|
||||
// reach the browser in the same paint.
|
||||
useLayoutEffect(() => {
|
||||
if (!isActive || !followOutputRef.current) {
|
||||
const layout: ActiveFollowOutputLayout = {
|
||||
scrollContainer: scrollContainerRef.current,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
activationKey,
|
||||
isActivationReady,
|
||||
renderLiveAuxiliary,
|
||||
historyMounted: segments.historyMounted,
|
||||
historyVirtualized: segments.historyVirtualized,
|
||||
liveHead: segments.liveHead,
|
||||
virtualTotalSize,
|
||||
};
|
||||
const resumedUnchangedLayout = Boolean(
|
||||
isActive &&
|
||||
!wasFollowOutputLayoutActiveRef.current &&
|
||||
lastActiveFollowOutputLayoutRef.current &&
|
||||
activeFollowOutputLayoutsEqual(lastActiveFollowOutputLayoutRef.current, layout),
|
||||
);
|
||||
resumedUnchangedLayoutRef.current = resumedUnchangedLayout;
|
||||
pendingResumeGeometryCheckRef.current = resumedUnchangedLayout;
|
||||
wasFollowOutputLayoutActiveRef.current = isActive;
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
lastActiveFollowOutputLayoutRef.current = layout;
|
||||
if (!followOutputRef.current || resumedUnchangedLayout) return;
|
||||
cancelPendingStickToBottom();
|
||||
scrollMessagesToBottom("auto");
|
||||
}, [
|
||||
activationKey,
|
||||
cancelPendingStickToBottom,
|
||||
isActive,
|
||||
isActivationReady,
|
||||
renderLiveAuxiliary,
|
||||
scrollMessagesToBottom,
|
||||
segments.historyMounted,
|
||||
@@ -773,7 +862,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
updateScrollMetrics();
|
||||
const resumedUnchangedLayout = resumedUnchangedLayoutRef.current;
|
||||
resumedUnchangedLayoutRef.current = false;
|
||||
if (!resumedUnchangedLayout) {
|
||||
updateScrollMetrics();
|
||||
}
|
||||
evaluateHistoryStart();
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartPrependSettle();
|
||||
@@ -802,9 +895,23 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return;
|
||||
}
|
||||
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
if (!pendingResumeGeometryCheckRef.current) {
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
}
|
||||
const observer = new ResizeObserver(() => {
|
||||
const nextGeometry = getObservedViewportGeometry(scrollContainer);
|
||||
if (pendingResumeGeometryCheckRef.current) {
|
||||
pendingResumeGeometryCheckRef.current = false;
|
||||
reportReadingPosition();
|
||||
const previousGeometry = lastObservedViewportGeometryRef.current;
|
||||
lastObservedViewportGeometryRef.current = nextGeometry;
|
||||
if (previousGeometry && observedViewportGeometriesEqual(previousGeometry, nextGeometry)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
lastObservedViewportGeometryRef.current = nextGeometry;
|
||||
}
|
||||
if (historyStartPrependAnchorActiveRef.current) {
|
||||
applyHistoryStartPrependAnchor();
|
||||
}
|
||||
@@ -829,6 +936,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
applyHistoryStartPrependAnchor,
|
||||
evaluateHistoryStart,
|
||||
isActive,
|
||||
reportReadingPosition,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
scheduleStickToBottom,
|
||||
updateScrollMetrics,
|
||||
|
||||
@@ -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<ActiveWorkspaceSelection[]>(() =>
|
||||
activeSelection ? [activeSelection] : [],
|
||||
const [retainedSelections, setRetainedSelections] = useState<RetainedWorkspaceSelection[]>(() =>
|
||||
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 (
|
||||
<View style={styles.deck}>
|
||||
{renderedEntries.map(({ selection, active }) => {
|
||||
return (
|
||||
<WorkspaceDeckEntry
|
||||
key={getWorkspaceSelectionKey(selection)}
|
||||
selection={selection}
|
||||
active={active}
|
||||
recoveryRequested={recoveryRequested}
|
||||
recoveryAgentId={recoveryAgentId}
|
||||
onUnmountInactive={unmountWorkspaceSelection}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<RenderProfile id="WorkspaceDeck">
|
||||
<View style={styles.deck}>
|
||||
{renderedEntries.map(({ selection, active }) => {
|
||||
return (
|
||||
<WorkspaceDeckEntry
|
||||
key={getWorkspaceSelectionKey(selection)}
|
||||
selection={selection}
|
||||
active={active}
|
||||
recoveryRequested={recoveryRequested}
|
||||
recoveryAgentId={recoveryAgentId}
|
||||
onUnmountInactive={unmountWorkspaceSelection}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</RenderProfile>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AgentControlCommandCenterSource>(
|
||||
() => ({
|
||||
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 = (
|
||||
<AgentControlCommandCenterRegistration
|
||||
sourceId={`agent:${serverId}:${agentId}`}
|
||||
enabled={Boolean(client)}
|
||||
controls={commandCenterControls}
|
||||
/>
|
||||
);
|
||||
|
||||
const handleModelSelectorOpen = useCallback(() => {
|
||||
refetchSnapshotIfStale(agentProvider);
|
||||
@@ -1692,30 +1733,33 @@ export const AgentControls = memo(function AgentControls({
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlledAgentControls
|
||||
provider={agent.provider}
|
||||
modelSelectorProviders={agentModelSelectorProviders}
|
||||
modelOptions={modelOptions}
|
||||
selectedModelId={modelSelection.activeModelId ?? undefined}
|
||||
onSelectModel={handleSelectModel}
|
||||
agentProfiles={agentProfiles}
|
||||
onApplyAgentProfile={agentProfiles?.applyProfile}
|
||||
onEditAgentProfiles={handleEditAgentProfiles}
|
||||
thinkingOptions={thinkingOptions.length > 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}
|
||||
<ControlledAgentControls
|
||||
provider={agent.provider}
|
||||
modelSelectorProviders={agentModelSelectorProviders}
|
||||
modelOptions={modelOptions}
|
||||
selectedModelId={modelSelection.activeModelId ?? undefined}
|
||||
onSelectModel={handleSelectModel}
|
||||
agentProfiles={agentProfiles}
|
||||
onApplyAgentProfile={agentProfiles?.applyProfile}
|
||||
onEditAgentProfiles={handleEditAgentProfiles}
|
||||
thinkingOptions={thinkingOptions.length > 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />;
|
||||
@@ -310,7 +309,6 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement | null {
|
||||
<AgentControls
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onDropdownClose={focusInput}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
@@ -561,6 +559,68 @@ function dispatchComposerKeyboardAction(args: DispatchComposerKeyboardActionArgs
|
||||
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(
|
||||
actionId: string,
|
||||
): 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
|
||||
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 (
|
||||
<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}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
@@ -2182,7 +2214,7 @@ export function Composer({
|
||||
{sendErrorNode}
|
||||
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<AutocompletePopover
|
||||
<ComposerAutocomplete
|
||||
visible={autocompleteVisible}
|
||||
anchorRef={messageInputContainerRef}
|
||||
options={autocomplete.options}
|
||||
@@ -2220,7 +2252,6 @@ export function Composer({
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
@@ -2267,7 +2298,7 @@ export function Composer({
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</ComposerKeyboardScopeProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ComposerTextInputHandle | null>;
|
||||
}) {
|
||||
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 (
|
||||
<Text style={styles.focusHintText} pointerEvents="none">
|
||||
{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<MessageInputRef, MessageInputProps>(
|
||||
autoFocus,
|
||||
autoFocusKey,
|
||||
disabled,
|
||||
isPaneFocused,
|
||||
leftContent,
|
||||
beforeVoiceContent,
|
||||
rightContent,
|
||||
@@ -1237,8 +1249,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
};
|
||||
}, [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<MessageInputRef, MessageInputProps>(
|
||||
|
||||
return (
|
||||
<View ref={rootRef} style={styles.container} testID="message-input-root">
|
||||
<MessageInputAutoFocus
|
||||
enabled={autoFocus}
|
||||
autoFocusKey={autoFocusKey}
|
||||
textInputRef={textInputRef}
|
||||
/>
|
||||
{/* Regular input */}
|
||||
<View
|
||||
ref={inputWrapperRef}
|
||||
@@ -1759,13 +1774,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
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()) : "",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import {
|
||||
getNextWorkspaceDeckExpirationDelay,
|
||||
orderWorkspaceSelectionsForStableRender,
|
||||
pruneMountedWorkspaceSelections,
|
||||
reconcileRetainedWorkspaceSelections,
|
||||
resolveWorkspaceDeckEntries,
|
||||
shouldKeepWorkspaceDeckEntryMounted,
|
||||
WORKSPACE_DECK_INACTIVE_TTL_MS,
|
||||
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
} from "@/screens/workspace/workspace-deck-retention";
|
||||
|
||||
function workspace(workspaceId: string, serverId = "server"): ActiveWorkspaceSelection {
|
||||
@@ -15,65 +18,115 @@ function mountedWorkspaceIds(selections: ActiveWorkspaceSelection[]): string[] {
|
||||
return selections.map((selection) => selection.workspaceId);
|
||||
}
|
||||
|
||||
describe("pruneMountedWorkspaceSelections", () => {
|
||||
it("retains the deck while an app-wide route temporarily clears the active workspace", () => {
|
||||
const mountedSelections = [workspace("A"), workspace("B")];
|
||||
function retained(workspaceId: string, inactiveSince: number | null) {
|
||||
return { selection: workspace(workspaceId), inactiveSince };
|
||||
}
|
||||
|
||||
function retainedWorkspaceIds(
|
||||
entries: ReturnType<typeof reconcileRetainedWorkspaceSelections>,
|
||||
): string[] {
|
||||
return entries.map((entry) => entry.selection.workspaceId);
|
||||
}
|
||||
|
||||
describe("reconcileRetainedWorkspaceSelections", () => {
|
||||
it("retains inactive workspaces for ten minutes across app-wide routes", () => {
|
||||
const retainedEntries = [retained("A", 1_000), retained("B", 2_000)];
|
||||
|
||||
expect(
|
||||
pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedSelections,
|
||||
reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: retainedEntries,
|
||||
activeSelection: null,
|
||||
now: 2_000 + WORKSPACE_DECK_INACTIVE_TTL_MS - 1,
|
||||
}),
|
||||
).toBe(mountedSelections);
|
||||
).toEqual([retained("B", 2_000)]);
|
||||
});
|
||||
|
||||
it("keeps the active workspace and the two most recent inactive workspaces", () => {
|
||||
const mountedAfterA = pruneMountedWorkspaceSelections({
|
||||
currentSelections: [],
|
||||
activeSelection: workspace("A"),
|
||||
it("keeps the active workspace and nine most recently activated inactive workspaces", () => {
|
||||
let retainedEntries = Array.from(
|
||||
{ length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES },
|
||||
(_, index) => retained(String(index + 1), index + 1),
|
||||
).toReversed();
|
||||
|
||||
retainedEntries = reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: retainedEntries,
|
||||
activeSelection: workspace("11"),
|
||||
now: 11,
|
||||
});
|
||||
const mountedAfterB = pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedAfterA,
|
||||
|
||||
expect(retainedWorkspaceIds(retainedEntries)).toEqual([
|
||||
"11",
|
||||
"10",
|
||||
"9",
|
||||
"8",
|
||||
"7",
|
||||
"6",
|
||||
"5",
|
||||
"4",
|
||||
"3",
|
||||
"2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("starts the TTL when the active workspace becomes inactive", () => {
|
||||
const retainedEntries = reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: [retained("A", null), retained("B", 2_000)],
|
||||
activeSelection: workspace("B"),
|
||||
});
|
||||
const mountedAfterC = pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedAfterB,
|
||||
activeSelection: workspace("C"),
|
||||
});
|
||||
const mountedAfterD = pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedAfterC,
|
||||
activeSelection: workspace("D"),
|
||||
now: 3_000,
|
||||
});
|
||||
|
||||
expect(mountedWorkspaceIds(mountedAfterD)).toEqual(["D", "C", "B"]);
|
||||
expect(retainedEntries).toEqual([retained("B", null), retained("A", 3_000)]);
|
||||
});
|
||||
|
||||
it("retains the active workspace", () => {
|
||||
const mountedSelections = pruneMountedWorkspaceSelections({
|
||||
currentSelections: [workspace("A")],
|
||||
it("never expires the active workspace", () => {
|
||||
const retainedEntries = reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: [retained("A", 0)],
|
||||
activeSelection: workspace("A"),
|
||||
now: WORKSPACE_DECK_INACTIVE_TTL_MS,
|
||||
});
|
||||
|
||||
expect(mountedWorkspaceIds(mountedSelections)).toEqual(["A"]);
|
||||
expect(retainedEntries).toEqual([retained("A", null)]);
|
||||
});
|
||||
|
||||
it("expires an inactive workspace at the TTL boundary", () => {
|
||||
const retainedEntries = reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: [retained("B", null), retained("A", 0)],
|
||||
activeSelection: workspace("B"),
|
||||
now: WORKSPACE_DECK_INACTIVE_TTL_MS,
|
||||
});
|
||||
|
||||
expect(retainedEntries).toEqual([retained("B", null)]);
|
||||
});
|
||||
|
||||
it("deduplicates retained workspace selections", () => {
|
||||
const mountedSelections = pruneMountedWorkspaceSelections({
|
||||
currentSelections: [workspace("B"), workspace("A"), workspace("B")],
|
||||
const retainedEntries = reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: [retained("B", 2), retained("A", 1), retained("B", 0)],
|
||||
activeSelection: workspace("A"),
|
||||
now: 3,
|
||||
});
|
||||
|
||||
expect(mountedWorkspaceIds(mountedSelections)).toEqual(["A", "B"]);
|
||||
expect(retainedWorkspaceIds(retainedEntries)).toEqual(["A", "B"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNextWorkspaceDeckExpirationDelay", () => {
|
||||
it("returns the remaining lifetime of the oldest inactive workspace", () => {
|
||||
expect(
|
||||
getNextWorkspaceDeckExpirationDelay({
|
||||
entries: [retained("C", null), retained("B", 2_000), retained("A", 1_000)],
|
||||
activeSelection: workspace("C"),
|
||||
now: 4_000,
|
||||
}),
|
||||
).toBe(WORKSPACE_DECK_INACTIVE_TTL_MS - 3_000);
|
||||
});
|
||||
|
||||
it("always allows at least the active workspace", () => {
|
||||
const mountedSelections = pruneMountedWorkspaceSelections({
|
||||
currentSelections: [workspace("A"), workspace("B")],
|
||||
activeSelection: workspace("C"),
|
||||
maxMountedWorkspaces: 0,
|
||||
});
|
||||
|
||||
expect(mountedWorkspaceIds(mountedSelections)).toEqual(["C"]);
|
||||
it("does not schedule expiration for the active workspace", () => {
|
||||
expect(
|
||||
getNextWorkspaceDeckExpirationDelay({
|
||||
entries: [retained("A", null)],
|
||||
activeSelection: workspace("A"),
|
||||
now: 4_000,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
export const WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES = 3;
|
||||
export const WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES = 10;
|
||||
export const WORKSPACE_DECK_INACTIVE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
interface PruneMountedWorkspaceSelectionsInput {
|
||||
currentSelections: ActiveWorkspaceSelection[];
|
||||
export interface RetainedWorkspaceSelection {
|
||||
selection: ActiveWorkspaceSelection;
|
||||
inactiveSince: number | null;
|
||||
}
|
||||
|
||||
interface ReconcileRetainedWorkspaceSelectionsInput {
|
||||
currentEntries: RetainedWorkspaceSelection[];
|
||||
activeSelection: ActiveWorkspaceSelection | null;
|
||||
now: number;
|
||||
maxMountedWorkspaces?: number;
|
||||
inactiveTtlMs?: number;
|
||||
}
|
||||
|
||||
interface GetNextWorkspaceDeckExpirationDelayInput {
|
||||
entries: RetainedWorkspaceSelection[];
|
||||
activeSelection: ActiveWorkspaceSelection | null;
|
||||
now: number;
|
||||
inactiveTtlMs?: number;
|
||||
}
|
||||
|
||||
interface WorkspaceDeckEntryMountInput {
|
||||
@@ -30,53 +45,92 @@ export function areWorkspaceSelectionsEqual(
|
||||
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
|
||||
}
|
||||
|
||||
export function areWorkspaceSelectionListsEqual(
|
||||
left: ActiveWorkspaceSelection[],
|
||||
right: ActiveWorkspaceSelection[],
|
||||
export function areRetainedWorkspaceSelectionListsEqual(
|
||||
left: RetainedWorkspaceSelection[],
|
||||
right: RetainedWorkspaceSelection[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
return left.every((selection, index) =>
|
||||
areWorkspaceSelectionsEqual(selection, right[index] ?? null),
|
||||
);
|
||||
return left.every((entry, index) => {
|
||||
const otherEntry = right[index];
|
||||
return (
|
||||
otherEntry !== undefined &&
|
||||
entry.inactiveSince === otherEntry.inactiveSince &&
|
||||
areWorkspaceSelectionsEqual(entry.selection, otherEntry.selection)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function pruneMountedWorkspaceSelections({
|
||||
currentSelections,
|
||||
export function reconcileRetainedWorkspaceSelections({
|
||||
currentEntries,
|
||||
activeSelection,
|
||||
now,
|
||||
maxMountedWorkspaces = WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
}: PruneMountedWorkspaceSelectionsInput): ActiveWorkspaceSelection[] {
|
||||
if (!activeSelection) {
|
||||
return currentSelections;
|
||||
}
|
||||
|
||||
const maxSelections = Math.max(1, maxMountedWorkspaces);
|
||||
const nextSelections: ActiveWorkspaceSelection[] = [];
|
||||
inactiveTtlMs = WORKSPACE_DECK_INACTIVE_TTL_MS,
|
||||
}: ReconcileRetainedWorkspaceSelectionsInput): RetainedWorkspaceSelection[] {
|
||||
const maxSelections = activeSelection
|
||||
? Math.max(1, maxMountedWorkspaces)
|
||||
: Math.max(0, maxMountedWorkspaces);
|
||||
const nextEntries: RetainedWorkspaceSelection[] = [];
|
||||
const seenSelectionKeys = new Set<string>();
|
||||
|
||||
function appendSelection(selection: ActiveWorkspaceSelection): void {
|
||||
if (nextSelections.length >= maxSelections) {
|
||||
function appendEntry(entry: RetainedWorkspaceSelection): void {
|
||||
if (nextEntries.length >= maxSelections) {
|
||||
return;
|
||||
}
|
||||
const selectionKey = getWorkspaceSelectionKey(selection);
|
||||
const selectionKey = getWorkspaceSelectionKey(entry.selection);
|
||||
if (seenSelectionKeys.has(selectionKey)) {
|
||||
return;
|
||||
}
|
||||
seenSelectionKeys.add(selectionKey);
|
||||
nextSelections.push(selection);
|
||||
nextEntries.push(entry);
|
||||
}
|
||||
|
||||
appendSelection(activeSelection);
|
||||
if (activeSelection) {
|
||||
appendEntry({ selection: activeSelection, inactiveSince: null });
|
||||
}
|
||||
|
||||
for (const selection of currentSelections) {
|
||||
if (areWorkspaceSelectionsEqual(selection, activeSelection)) {
|
||||
for (const entry of currentEntries) {
|
||||
if (areWorkspaceSelectionsEqual(entry.selection, activeSelection)) {
|
||||
continue;
|
||||
}
|
||||
appendSelection(selection);
|
||||
const inactiveSince = entry.inactiveSince ?? now;
|
||||
if (now - inactiveSince >= inactiveTtlMs) {
|
||||
continue;
|
||||
}
|
||||
appendEntry({ selection: entry.selection, inactiveSince });
|
||||
}
|
||||
|
||||
return nextSelections;
|
||||
if (areRetainedWorkspaceSelectionListsEqual(currentEntries, nextEntries)) {
|
||||
return currentEntries;
|
||||
}
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
export function getNextWorkspaceDeckExpirationDelay({
|
||||
entries,
|
||||
activeSelection,
|
||||
now,
|
||||
inactiveTtlMs = WORKSPACE_DECK_INACTIVE_TTL_MS,
|
||||
}: GetNextWorkspaceDeckExpirationDelayInput): number | null {
|
||||
let nextExpirationDelay: number | null = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
areWorkspaceSelectionsEqual(entry.selection, activeSelection) ||
|
||||
entry.inactiveSince === null
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const expirationDelay = Math.max(0, entry.inactiveSince + inactiveTtlMs - now);
|
||||
nextExpirationDelay =
|
||||
nextExpirationDelay === null
|
||||
? expirationDelay
|
||||
: Math.min(nextExpirationDelay, expirationDelay);
|
||||
}
|
||||
|
||||
return nextExpirationDelay;
|
||||
}
|
||||
|
||||
export function orderWorkspaceSelectionsForStableRender(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -182,6 +182,38 @@ export function selectWorkspaceStructureProjects(
|
||||
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(
|
||||
state: SessionsSnapshot,
|
||||
serverId: string | null,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user