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