From e9967ddaf87da78dd2bc2b2cef89ef1080466cfb Mon Sep 17 00:00:00 2001 From: Sybil Agent Date: Wed, 4 Nov 2026 12:35:25 -0800 Subject: [PATCH] fix(web): preserve search focus behavior --- web/src/App.tsx | 6 +- web/tests/search-focus.browser.mjs | 169 +++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 web/tests/search-focus.browser.mjs diff --git a/web/src/App.tsx b/web/src/App.tsx index 598cf36..fa77b56 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1619,6 +1619,7 @@ export default function App() { useEffect(() => { if (isActiveSelectionSending) return; + if (isSearchMode) return; const hasWorkspaceSelection = Boolean(selectedItem) || draftKind !== null; if (!hasWorkspaceSelection) return; const mobileLayout = isMobileLayout(); @@ -1628,7 +1629,7 @@ export default function App() { } suppressNextMobileComposerFocusRef.current = false; focusComposer(); - }, [draftKind, isActiveSelectionSending, selectedKey]); + }, [draftKind, isActiveSelectionSending, isSearchMode, selectedKey]); useEffect(() => { return () => { @@ -1927,6 +1928,7 @@ export default function App() { setIsChatSettingsOpen(false); setChatSettingsError(null); setIsMobileSidebarOpen(false); + focusComposer(); }; const selectAdjacentSidebarItem = (direction: -1 | 1) => { @@ -3448,7 +3450,7 @@ export default function App() { } } finally { const shouldSuppressMobileChatFocus = sentTarget?.kind === "chat" && isMobileLayout(); - if ((!sentTarget || isCurrentSelection(sentTarget)) && !shouldSuppressMobileChatFocus) { + if ((!sentTarget || isCurrentSelection(sentTarget)) && !sentAsSearch && !shouldSuppressMobileChatFocus) { focusComposer(); } } diff --git a/web/tests/search-focus.browser.mjs b/web/tests/search-focus.browser.mjs new file mode 100644 index 0000000..d3ade7d --- /dev/null +++ b/web/tests/search-focus.browser.mjs @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { mkdtemp, rm } from "node:fs/promises"; + +// Browser regression test against the real app with a deterministic local API fixture. +// npm run build && npm run preview -- --host 127.0.0.1 --port 5198 +// WEB_TEST_URL=http://127.0.0.1:5198/ node tests/search-focus.browser.mjs +const targetUrl = process.env.WEB_TEST_URL || "http://127.0.0.1:5198/"; +assert.ok(new URL(targetUrl).hostname === "127.0.0.1" || new URL(targetUrl).hostname === "localhost"); +const browserBinary = process.env.CHROME_EXECUTABLE || ["/usr/bin/chromium", "/usr/bin/google-chrome", "/usr/bin/chromium-browser", + `${homedir()}/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome`].find(existsSync) || "chromium"; +const profile = await mkdtemp(`${tmpdir()}/sybil-search-focus-browser-`); +const browser = spawn(browserBinary, ["--headless", "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", + "--remote-debugging-port=0", "--remote-allow-origins=*", `--user-data-dir=${profile}`, "--no-first-run"]); +let diagnostics = ""; +browser.stderr.setEncoding("utf8").on("data", chunk => { diagnostics += chunk; }); +let socket; +let commandId = 0; +const pending = new Map(); +const calls = []; +const date = "2026-11-04T12:00:00.000Z"; +let searchNumber = 0; +let streamWasRequested = false; +let latestSearch = null; +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +async function waitForDebugPort() { + const deadline = Date.now() + 12000; + while (Date.now() < deadline) { + const match = diagnostics.match(/DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//); + if (match) return Number(match[1]); + await delay(50); + } + throw new Error(`Chrome did not start: ${diagnostics.slice(-1000)}`); +} +async function connect() { + const port = await waitForDebugPort(); + const response = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" }); + const tab = await response.json(); + socket = new WebSocket(tab.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + }); + socket.addEventListener("message", event => { + const message = JSON.parse(event.data); + if (message.method === "Fetch.requestPaused") void handlePausedRequest(message.params); + if (message.id) { + const request = pending.get(message.id); + if (request) { + pending.delete(message.id); + message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result); + } + } + }); + await call("Page.enable"); + await call("Runtime.enable"); + await call("Fetch.enable", { patterns: [{ urlPattern: "*" }] }); + await call("Page.addScriptToEvaluateOnNewDocument", { source: 'localStorage.setItem("sybil_admin_token", "search-focus-fixture-token");' }); + await call("Page.navigate", { url: targetUrl }); +} +function call(method, params = {}) { + return new Promise((resolve, reject) => { + const id = ++commandId; + pending.set(id, { resolve, reject }); + socket.send(JSON.stringify({ id, method, params })); + }); +} +async function evaluate(expression) { + const response = await call("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }); + assert.equal(response.exceptionDetails, undefined, response.exceptionDetails?.text); + return response.result?.value; +} +async function waitFor(expression, message) { + const deadline = Date.now() + 12000; + while (Date.now() < deadline) { + if (await evaluate(expression)) return; + await delay(40); + } + const body = await evaluate('document.body?.innerText.slice(0, 1200) || "no body"').catch(() => "evaluation failed"); + throw new Error(`Timed out waiting for ${message}; body=${JSON.stringify(body)}; calls=${JSON.stringify(calls)}; chrome=${diagnostics.slice(-500)}`); +} +async function fulfill(id, value, contentType = "application/json") { + const body = Buffer.from(typeof value === "string" ? value : JSON.stringify(value)).toString("base64"); + await call("Fetch.fulfillRequest", { requestId: id, responseCode: 200, + responseHeaders: [{ name: "Content-Type", value: contentType }], body }); +} +async function handlePausedRequest(request) { + try { + const url = new URL(request.request.url); + if (url.origin !== new URL(targetUrl).origin || !url.pathname.startsWith("/api/")) { + await call("Fetch.continueRequest", { requestId: request.requestId }); + return; + } + const path = url.pathname; + const method = request.request.method; + calls.push({ path, method }); + if (path.endsWith("/auth/session")) return fulfill(request.requestId, { mode: "token", authenticated: true }); + if (path.endsWith("/workspace-items")) return fulfill(request.requestId, { items: latestSearch ? [{ type: "search", ...latestSearch }] : [] }); + if (path.endsWith("/models")) return fulfill(request.requestId, { providers: {} }); + if (path.endsWith("/chat-tools") || path.endsWith("/agent/tools")) return fulfill(request.requestId, { tools: [] }); + if (path.endsWith("/active-runs")) return fulfill(request.requestId, { chats: [], searches: [] }); + if (path.endsWith("/agent")) return fulfill(request.requestId, { id: "fixture", title: "Sybil Agent", activity: { state: "idle", isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] }, protocol: "pi-room.v1", websocket: "/v1/agent/ws" }); + if (path.endsWith("/agent/messages")) return fulfill(request.requestId, { conversationId: "fixture", messages: [], cursor: "0", beforeCursor: null, hasMore: false }); + if (path.endsWith("/push/config")) return fulfill(request.requestId, { version: 1, enabled: false, webPushAvailable: false, apnsAvailable: false, sourceId: "fixture", quarantined: false, policy: { presenceHeartbeatMs: 15000, presenceLeaseMs: 60000 } }); + if (path.includes("/presence/")) return fulfill(request.requestId, { expiresAt: date }); + if (path.endsWith("/searches") && method === "POST") { + const number = ++searchNumber; + const body = JSON.parse(request.request.postData || "{}"); + const search = { id: `search-${number}`, title: body.query, query: body.query, createdAt: date, updatedAt: date, starred: false, starredAt: null, requestId: null, latencyMs: null, error: null, answerText: null, answerRequestId: null, answerCitations: null, answerError: null, results: [] }; + latestSearch = search; + return fulfill(request.requestId, { search, reused: false, cacheHit: false }); + } + if (path.includes("/searches/") && method === "GET") return fulfill(request.requestId, { search: latestSearch }); + if (path.includes("/searches/") && path.endsWith("/run/stream")) { + streamWasRequested = true; + const body = JSON.parse(request.request.postData || "{}"); + const result = { id: `result-${searchNumber}`, createdAt: date, rank: 1, title: `Fixture result for ${body.query}`, url: "https://fixture.test/result", publishedDate: null, author: null, text: "Synthetic result; no provider called.", highlights: null, highlightScores: null, score: 1, favicon: null, image: null }; + const search = { id: `search-${searchNumber}`, title: body.query, query: body.query, createdAt: date, updatedAt: date, starred: false, starredAt: null, requestId: "fixture-request", latencyMs: 1, error: null, answerText: null, answerRequestId: null, answerCitations: null, answerError: null, results: [result] }; + latestSearch = search; + await delay(900); + const events = `event: search_results\ndata: ${JSON.stringify({ requestId: "fixture-request", results: [result] })}\n\nevent: done\ndata: ${JSON.stringify({ search })}\n\n`; + return fulfill(request.requestId, events, "text/event-stream"); + } + return fulfill(request.requestId, {}); + } catch (error) { + console.error("API fixture failed", error); + await call("Fetch.failRequest", { requestId: request.requestId, errorReason: "Failed" }).catch(() => undefined); + } +} + +try { + await connect(); + await waitFor('document.querySelector("#composer-input") && document.querySelector("#composer-input").isConnected', "app composer"); + await evaluate('document.querySelector("button").blur()'); + await evaluate('Array.from(document.querySelectorAll("button")).find(button => button.textContent.includes("New search")).click()'); + await waitFor('document.activeElement?.id === "composer-input"', "New Search focus"); + assert.equal(await evaluate('document.activeElement?.id'), "composer-input", "New Search focuses the search input"); + + await evaluate('Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value").set.call(document.querySelector("#composer-input"), "first fixture query"); document.querySelector("#composer-input").dispatchEvent(new Event("input", { bubbles: true }))'); + await evaluate('Array.from(document.querySelectorAll("button")).find(button => button.getAttribute("aria-label") === "Search").click()'); + await waitFor('document.querySelector("#composer-input")?.disabled', "running fixture search"); + const streamDeadline = Date.now() + 8000; + while (!streamWasRequested && Date.now() < streamDeadline) await delay(25); + assert.equal(streamWasRequested, true, "the synthetic stream is pending before focus is moved"); + assert.notEqual(await evaluate('document.activeElement?.id'), "composer-input", "starting search does not return focus to the input"); + await evaluate('Array.from(document.querySelectorAll("button")).find(button => button.textContent.includes("New chat")).focus()'); + assert.equal(await evaluate('document.activeElement?.textContent.includes("New chat")'), true, "search execution does not steal focus from another control"); + await waitFor('document.body.innerText.includes("Fixture result for first fixture query")', "fixture result"); + assert.equal(await evaluate('document.activeElement?.textContent.includes("New chat")'), true, "search completion preserves the user's focused control"); + assert.ok(streamWasRequested, "fixture search stream was requested"); + assert.equal(searchNumber, 1); + + await evaluate('Array.from(document.querySelectorAll("button")).find(button => button.textContent.includes("New search")).click()'); + await waitFor('document.activeElement?.id === "composer-input"', "repeated New Search focus"); + assert.equal(await evaluate('document.activeElement?.id'), "composer-input", "repeated New Search focuses the input"); + assert.equal(calls.filter(call => call.path.endsWith("/run/stream")).length, 1, "exactly one synthetic search ran"); + console.log(JSON.stringify({ focusOnNewSearch: true, executionPreservedFocus: true, pendingFocusPreservedAtCompletion: true, + repeatedNewSearchFocus: true, normalSearchResultRendered: "Fixture result for first fixture query", fixtureOnly: true, apiRequests: calls.length })); +} finally { + if (socket) socket.close(); + if (browser.exitCode === null) { + browser.kill("SIGTERM"); + await Promise.race([new Promise(resolve => browser.once("close", resolve)), delay(2000)]); + } + await rm(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}