diff --git a/desktop/src/features/agents/ui/AgentEditMergedDialog.tsx b/desktop/src/features/agents/ui/AgentEditMergedDialog.tsx index c59fc46f65..d30a379035 100644 --- a/desktop/src/features/agents/ui/AgentEditMergedDialog.tsx +++ b/desktop/src/features/agents/ui/AgentEditMergedDialog.tsx @@ -675,6 +675,9 @@ export function AgentEditMergedDialog({ data-testid="edit-agent-dialog" footerClassName="border-t-0 pt-0" headerClassName="pb-2" + // Deep-link focus targets are owned by the rAF effects above; suppress + // Radix's name-input open-autofocus so it can't steal the requested field. + onOpenAutoFocus={initialFocus ? (e) => e.preventDefault() : undefined} title={dialogTitle} footer={
diff --git a/desktop/src/features/agents/ui/agentEditMergedDeepLinkFocus.test.mjs b/desktop/src/features/agents/ui/agentEditMergedDeepLinkFocus.test.mjs new file mode 100644 index 0000000000..a931d0ff16 --- /dev/null +++ b/desktop/src/features/agents/ui/agentEditMergedDeepLinkFocus.test.mjs @@ -0,0 +1,361 @@ +/** + * Deep-link focus coordination test (scenario-8 regression). + * + * When the merged edit dialog opens from a config-nudge deep link with an + * `initialFocus` target, the requested field must own initial focus. The dialog + * mounts Radix's `Dialog`, whose default open-autofocus lands on the first + * focusable control (the avatar/name inputs). In the browser that autofocus + * runs AFTER the dialog's own rAF-scheduled targeted focus and steals focus + * back, so the deep link lands on the wrong field. The fix passes + * `onOpenAutoFocus={e => e.preventDefault()}` to the dialog content whenever an + * `initialFocus` is supplied, so Radix never moves focus and the targeted + * effect owns it. + * + * jsdom orders rAF-vs-focus-scope the opposite way from the browser (the + * component's rAF focus runs last and wins regardless of the fix), so an + * end-state activeElement assertion cannot see the seam. This harness ISOLATES + * Radix's default autofocus: it neutralizes `focus()` on the deep-link target + * elements so the ONLY thing that can move focus off is Radix's default + * open-autofocus. With the fix, that autofocus is prevented and focus stays on + * ; remove the `onOpenAutoFocus` prevention and Radix moves focus onto + * the first control — turning the first test red. The second test is the + * control: with no deep-link target the prevention is inert and Radix's default + * autofocus must still fire, guarding against an unconditional suppression. + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +/** QueryClients created per test, cleared in afterEach so no gc timer lingers. */ +const liveClients = []; +/** + * Radix's dismissable-layer / focus-scope schedule global timers that outlive + * the render; jsdom's timers delegate to the global, so track every timer we + * hand out and clear the survivors in teardown or the process hangs after pass. + */ +const pendingTimers = new Set(); +const nativeSetTimeout = globalThis.setTimeout; +const nativeSetInterval = globalThis.setInterval; +const nativeClearTimeout = globalThis.clearTimeout; +const nativeClearInterval = globalThis.clearInterval; + +before(() => { + globalThis.setTimeout = (fn, ms, ...args) => { + const id = nativeSetTimeout( + (...a) => { + pendingTimers.delete(id); + return fn(...a); + }, + ms, + ...args, + ); + pendingTimers.add(id); + return id; + }; + globalThis.setInterval = (fn, ms, ...args) => { + const id = nativeSetInterval(fn, ms, ...args); + pendingTimers.add(id); + return id; + }; + globalThis.clearTimeout = (id) => { + pendingTimers.delete(id); + return nativeClearTimeout(id); + }; + globalThis.clearInterval = (id) => { + pendingTimers.delete(id); + return nativeClearInterval(id); + }; + + for (const key of Object.getOwnPropertyNames(dom.window)) { + if (key in globalThis) continue; + try { + globalThis[key] = dom.window[key]; + } catch { + /* getter-only global — skip */ + } + } + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + Node: dom.window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + }); + for (const key of [ + "Event", + "CustomEvent", + "MouseEvent", + "KeyboardEvent", + "FocusEvent", + "PointerEvent", + "InputEvent", + "UIEvent", + ]) { + if (dom.window[key]) globalThis[key] = dom.window[key]; + } + dom.window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + globalThis.ResizeObserver = dom.window.ResizeObserver; + // Real rAF (macrotask-backed): the instance runtime hook schedules work via + // requestAnimationFrame during mount, so a no-op rAF crashes the render. A + // setTimeout-backed rAF lets the mount complete AND lets the dialog's own + // targeted-focus effect run — which is why we neutralize focus() on the + // targets below rather than the rAF itself. + dom.window.requestAnimationFrame = (cb) => + globalThis.setTimeout(() => cb(Date.now()), 0); + dom.window.cancelAnimationFrame = (id) => globalThis.clearTimeout(id); + globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; + globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame; + // Neutralize focus() on the deep-link targets so the dialog's own targeted + // focus cannot move focus. The ONLY remaining focus mover is Radix's default + // open-autofocus — exactly the behavior onOpenAutoFocus controls. + const realFocus = dom.window.HTMLElement.prototype.focus; + dom.window.HTMLElement.prototype.focus = function focus(...args) { + if ( + this.id === "edit-agent-model" || + this.id === "edit-agent-llm-provider" + ) { + return; + } + return realFocus.apply(this, args); + }; + dom.window.HTMLElement.prototype.scrollIntoView = () => {}; + dom.window.HTMLElement.prototype.hasPointerCapture = () => false; + dom.window.HTMLElement.prototype.releasePointerCapture = () => {}; + dom.window.HTMLElement.prototype.setPointerCapture = () => {}; + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); + for (const qc of liveClients.splice(0)) { + qc.clear(); + qc.unmount(); + } + for (const id of pendingTimers) nativeClearTimeout(id); + pendingTimers.clear(); +}); + +after(() => { + globalThis.setTimeout = nativeSetTimeout; + globalThis.setInterval = nativeSetInterval; + globalThis.clearTimeout = nativeClearTimeout; + globalThis.clearInterval = nativeClearInterval; + dom.window.close(); +}); + +const GOOSE_RUNTIME = { + id: "goose", + label: "Goose", + avatarUrl: "", + availability: "not_installed", + command: "goose-cmd", + binaryPath: "goose-cmd", + defaultArgs: [], + mcpCommand: null, + modelEnvVar: null, + providerEnvVar: null, + thinkingEnvVar: null, + maxTokensEnvVar: null, + contextLimitEnvVar: null, + maxRoundsEnvVar: null, + installHint: "", + installInstructionsUrl: "", + canAutoInstall: false, + requiresExternalCli: false, + underlyingCliPath: null, + nodeRequired: false, + authStatus: { status: "not_applicable" }, + loginHint: null, + source: "builtin", + maxParallelism: 4, +}; + +/** An unlinked (instance-only) ManagedAgent — Gurney's failing tuple. */ +function INSTANCE(overrides = {}) { + return { + pubkey: "pk-deeplink", + name: "Solo", + personaId: null, + runtime: "goose", + teamId: null, + relayUrl: "wss://relay.test", + acpCommand: "", + agentCommand: "goose-cmd", + agentCommandOverride: "goose-cmd", + agentArgs: [], + mcpCommand: "", + turnTimeoutSeconds: 0, + idleTimeoutSeconds: null, + maxTurnDurationSeconds: null, + parallelism: 1, + systemPrompt: "You are helpful.", + avatarUrl: null, + model: null, + modelSource: "instance", + provider: null, + personaOutOfDate: false, + personaOrphaned: false, + needsRestart: false, + restartDiff: [], + envVars: {}, + status: "stopped", + pid: null, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-01T00:00:00Z", + lastStartedAt: null, + lastStoppedAt: null, + lastExitCode: null, + lastError: null, + lastErrorCode: null, + logPath: "", + startOnAppLaunch: false, + autoRestartOnConfigChange: false, + backend: "local", + backendAgentId: null, + respondTo: "anyone", + respondToAllowlist: [], + ...overrides, + }; +} + +async function makeSeededClient() { + const { QueryClient } = await import("@tanstack/react-query"); + const qc = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Number.POSITIVE_INFINITY }, + }, + }); + qc.setQueryData(["acp-runtimes"], [GOOSE_RUNTIME]); + qc.setQueryData(["personas"], []); + qc.setQueryData(["managed-agents"], [INSTANCE()]); + qc.setQueryData(["teams"], []); + qc.setQueryData(["baked-build-env-keys"], []); + qc.setQueryData(["agent-access-owner-only"], false); + qc.setQueryData(["globalAgentConfig"], { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }); + qc.setQueryData(["runtime-file-config", "goose"], { + provider: null, + model: null, + satisfiedEnvKeys: [], + }); + liveClients.push(qc); + return qc; +} + +function installInvoke() { + dom.window.__TAURI_INTERNALS__ = { + invoke: async (cmd) => { + if (cmd === "list_personas") return []; + if (cmd === "list_managed_agents") return [INSTANCE()]; + if (cmd === "get_runtime_file_config") { + return { provider: null, model: null, satisfiedEnvKeys: [] }; + } + return null; + }, + transformCallback: (cb) => cb, + unregisterCallback() {}, + convertFileSrc: (p) => p, + }; +} + +/** + * Render the dialog and flush mount effects inside a single act() — this runs + * Radix's open-autofocus (a mount effect) and reads activeElement immediately + * after, BEFORE advancing the macrotask queue. Two reasons this must be + * synchronous, not settle()d: (1) it captures Radix's autofocus decision in + * isolation, before the (harness-neutralized) targeted-focus rAF; (2) when the + * fix is removed, Radix's focus scope re-traps focus on every subsequent + * macrotask tick, so a settle() spins forever — a synchronous read observes the + * initial decision and lets teardown complete cleanly. + */ +async function renderAndReadActiveElement(qc, initialFocus) { + const { createElement, act } = await import("react"); + const { render } = await import("@testing-library/react"); + const { QueryClientProvider } = await import("@tanstack/react-query"); + const { ThemeProvider } = await import("@/shared/theme/ThemeProvider"); + const { AgentEditMergedDialog } = await import("./AgentEditMergedDialog.tsx"); + + let view; + await act(async () => { + view = render( + createElement( + QueryClientProvider, + { client: qc }, + createElement( + ThemeProvider, + { defaultTheme: "buzz" }, + createElement(AgentEditMergedDialog, { + open: true, + onOpenChange: () => {}, + ctx: { kind: "instance-only", instance: INSTANCE() }, + initialFocus, + }), + ), + ), + ); + }); + const active = dom.window.document.activeElement; + // Tear the dialog down before returning. When the fix is removed, Radix's + // focus scope re-traps focus on every macrotask tick while the dialog is + // mounted, which would spin the event loop and stall the process. Unmounting + // here removes those listeners so the assertion below fails fast and cleanly. + await act(async () => { + view.unmount(); + }); + return active; +} + +test("test_deep_link_suppresses_default_open_autofocus", async () => { + const qc = await makeSeededClient(); + installInvoke(); + const active = await renderAndReadActiveElement(qc, { + type: "normalized_field", + field: "model", + }); + + // The dialog's own targeted focus is neutralized in this harness, so the only + // thing that can move focus off is Radix's default open-autofocus. The + // fix prevents it when a deep-link target is requested, so focus stays on + // . Remove the onOpenAutoFocus prevention and Radix moves focus onto + // the first focusable control, failing this assertion. + assert.equal( + active, + dom.window.document.body, + "with a deep-link target, default open-autofocus must be suppressed so it can't steal focus from the targeted field", + ); +}); + +test("test_default_open_autofocus_active_without_deep_link_target", async () => { + // Control: with no initialFocus, the prevention is inert and Radix's default + // open-autofocus must still fire — moving focus off onto the first + // control. Guards against an unconditional suppression that would regress the + // ordinary open-and-type flow. + const qc = await makeSeededClient(); + installInvoke(); + const active = await renderAndReadActiveElement(qc, undefined); + + assert.notEqual( + active, + dom.window.document.body, + "without a deep-link target, default open-autofocus must move focus off ", + ); +});