fix(web): preserve search focus behavior
This commit is contained in:
+4
-2
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user