From 5ba54493e6971415a732d9401ebbf9f1e70c3d04 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:47:06 -0500 Subject: [PATCH 1/7] fix(desktop): an agent typing into the browser no longer highlights the whole page When an agent typed with "replace what is there", the app clicked the target and then sent the browser's "select all" to whatever had focus. If the click had not put focus in a text field (a button, a label, a layer over the field, a date picker), that selected every word on the page, typed nothing, and told the agent it had worked. Typing now asks the page what would take the text first (textReceiverScript.ts): a text input, a textarea, rich text, an editor that draws its own text, or nothing. With nothing, no key is sent and the call fails saying nothing was typed. Clearing happens inside the page, in the same step that checks the field still has focus, and only on that field. It is judged by the focused element, so a cursor left in a field typed in earlier does not pass for focus, and it is refused once the user has taken the tab over. --- apps/desktop/src/preview/PreviewAutomation.ts | 159 +++++++++++++----- .../src/preview/textReceiverScript.test.ts | 15 ++ .../desktop/src/preview/textReceiverScript.ts | 114 +++++++++++++ apps/server/src/mcp/browserTools.ts | 2 +- 4 files changed, 248 insertions(+), 42 deletions(-) create mode 100644 apps/desktop/src/preview/textReceiverScript.test.ts create mode 100644 apps/desktop/src/preview/textReceiverScript.ts diff --git a/apps/desktop/src/preview/PreviewAutomation.ts b/apps/desktop/src/preview/PreviewAutomation.ts index efaf18643..e08dd03a9 100644 --- a/apps/desktop/src/preview/PreviewAutomation.ts +++ b/apps/desktop/src/preview/PreviewAutomation.ts @@ -104,6 +104,11 @@ import { } from "./drawOverlayScript.ts"; import * as PreviewScopes from "./PreviewScopes.ts"; import { buildRevealElementScript } from "./revealElementScript.ts"; +import { + buildTextReceiverScript, + isTextReceiver, + type TextReceiver, +} from "./textReceiverScript.ts"; import { buildPickOverlayScript, PICK_OVERLAY_BINDING, @@ -162,6 +167,18 @@ const SCREEN_LOCKED = */ const PAGE_FOCUS_WAIT_MS = 250; const PAGE_FOCUS_POLL = "4 millis"; +/** + * How long typing waits for the click before it to put focus in a field. A + * page may focus its field a moment after the click (a search button that + * opens the box it then focuses). Together with the wait above this stays well + * inside the time the page holds the keyboard (PREVIEW_AGENT_KEYS_DEADLINE_MS). + */ +const TEXT_FIELD_WAIT_MS = 400; +const TEXT_FIELD_POLL = "20 millis"; +/** How long a page has to select a field's contents before typing gives up. */ +const TEXT_FIELD_CLEAR_MS = 200; +const NO_TEXT_FIELD = + "the click did not put the cursor in a field that takes text, so nothing was typed; aim at the text field itself, or press keys one at a time for a control like a date picker"; /** * An agent's script may wait on purpose (a promise that settles after an * animation, a fetch), so it gets nearly all of the broker's deadline. @@ -897,6 +914,15 @@ export const make = Effect.gen(function* PreviewAutomationMake() { method: string, params: Record, ) => Effect.Effect, + page: { + readonly contents: WebContents; + /** + * The same check `send` makes, for a step that is not a CDP command: + * a reason not to touch the page now, or null. Ask in the same + * synchronous step as the act. + */ + readonly refusal: () => string | null; + }, ) => Effect.Effect, ) => Effect.suspend(() => { @@ -924,30 +950,27 @@ export const make = Effect.gen(function* PreviewAutomationMake() { yield* Effect.sleep(PAGE_FOCUS_POLL); } let sent = 0; + const refusal = () => { + const tab = attached.get(webContentsId); + const reason = + controlEpoch !== undefined && tab !== undefined && tab.controlEpoch !== controlEpoch + ? "the user took control of this tab" + : contents.focusedFrame === null + ? "the page did not have keyboard focus" + : Date.now() - startedAt > PREVIEW_AGENT_KEYS_DEADLINE_MS + ? "the page took too long to take the keys" + : null; + if (reason === null) { + sent += 1; + return null; + } + return sent === 0 + ? `${reason}, so nothing was typed; try again` + : `${reason}, so only part of the input reached it; check the field before trying again`; + }; const send = (method: string, params: Record) => - sendCommand(contents, method, params, { - refusal: () => { - const tab = attached.get(webContentsId); - const refusal = - controlEpoch !== undefined && - tab !== undefined && - tab.controlEpoch !== controlEpoch - ? "the user took control of this tab" - : contents.focusedFrame === null - ? "the page did not have keyboard focus" - : Date.now() - startedAt > PREVIEW_AGENT_KEYS_DEADLINE_MS - ? "the page took too long to take the keys" - : null; - if (refusal === null) { - sent += 1; - return null; - } - return sent === 0 - ? `${refusal}, so nothing was typed; try again` - : `${refusal}, so only part of the input reached it; check the field before trying again`; - }, - }); - return yield* keys(send); + sendCommand(contents, method, params, { refusal }); + return yield* keys(send, { contents, refusal }); }), ), ); @@ -956,6 +979,57 @@ export const make = Effect.gen(function* PreviewAutomationMake() { const failCommand = (contents: WebContents, method: string, cause: string) => Effect.fail(new PreviewCommandError({ webContentsId: contents.id, method, cause })); + /** One run of the text-receiver script in the frame that holds the keyboard. */ + const askTextReceiver = (contents: WebContents, script: string) => + Effect.tryPromise(async (): Promise => + contents.focusedFrame?.executeJavaScript(script), + ).pipe( + Effect.map((answer): TextReceiver => (isTextReceiver(answer) ? answer : "none")), + // A page that cannot be asked has nothing to type into. + Effect.orElseSucceed((): TextReceiver => "none"), + ); + + /** + * What would take text typed now (`textReceiverScript.ts`), waiting a + * moment for the page to focus a field. Only looks; changes nothing. + */ + const findTextReceiver = (contents: WebContents) => + Effect.gen(function* () { + const script = buildTextReceiverScript(); + for (;;) { + const receiver = yield* askTextReceiver(contents, script); + if (receiver !== "none") return receiver; + yield* Effect.sleep(TEXT_FIELD_POLL); + } + }).pipe( + Effect.timeoutOrElse({ + duration: TEXT_FIELD_WAIT_MS, + orElse: () => Effect.succeed("none"), + }), + ); + + /** + * Selects a field's contents so the text typed next replaces them, in the + * same step in the page that checks the field still has focus, and says + * what it found there. The page is told when to give up, so one that + * answers late changes nothing. + */ + const clearTextReceiver = (contents: WebContents, refusal: () => string | null) => + Effect.suspend(() => { + const reason = refusal(); + return reason === null + ? askTextReceiver( + contents, + buildTextReceiverScript({ clearBefore: Date.now() + TEXT_FIELD_CLEAR_MS }), + ).pipe( + Effect.timeoutOrElse({ + duration: TEXT_FIELD_CLEAR_MS, + orElse: () => Effect.succeed("none"), + }), + ) + : failCommand(contents, "Input.insertText", reason); + }); + const targetDescription = (target: PreviewAutomationTarget): string => "ref" in target ? `ref ${target.ref}` @@ -2843,28 +2917,31 @@ export const make = Effect.gen(function* PreviewAutomationMake() { // Into whatever the page has focused: the renderer clicks the field first, // as its own step, so the page needs the keyboard only for the keys. type: Effect.fn("PreviewAutomation.type")(function* (input: DesktopPreviewTypeInput) { - yield* agentKeys(input.webContentsId, input.controlEpoch, (send) => + yield* agentKeys(input.webContentsId, input.controlEpoch, (send, { contents, refusal }) => Effect.gen(function* () { - if (input.clear === true) { - // `commands` invokes the editing command directly, which is what a - // modifier chord ultimately triggers. Dispatching Meta+A as a raw key - // does not: the browser resolves shortcuts to editing commands above - // the layer CDP injects at, so the keypress arrives and selects - // nothing, and the insert below lands wherever the caret happened to - // be -- mid-word, since a click centres it. - yield* send("Input.dispatchKeyEvent", { - type: "keyDown", - key: "a", - code: "KeyA", - windowsVirtualKeyCode: 65, - commands: ["selectAll"], - }); - yield* send("Input.dispatchKeyEvent", { - type: "keyUp", + // The click may have landed on something that takes no text: a + // button, a label, a layer over the field. Then nothing is sent, + // and the agent hears that nothing was typed. Sending "select all" + // regardless is what used to highlight the whole page. + let receiver = yield* findTextReceiver(contents); + if (receiver === "field" && input.clear === true) { + receiver = yield* clearTextReceiver(contents, refusal); + } + if (receiver === "none") { + return yield* failCommand(contents, "Input.insertText", NO_TEXT_FIELD); + } + if (receiver === "editContext" && input.clear === true) { + // The editor draws its own text and selects it on its own + // shortcut, so it gets the chord a person would press. No `text`: + // the chord must not also type an "a". + const chord = { key: "a", code: "KeyA", windowsVirtualKeyCode: 65, - }); + modifiers: toCdpModifierBitmask([process.platform === "darwin" ? "Meta" : "Control"]), + }; + yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...chord }); + yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...chord }); } yield* send("Input.insertText", { text: input.text }); if (input.submit === true) { diff --git a/apps/desktop/src/preview/textReceiverScript.test.ts b/apps/desktop/src/preview/textReceiverScript.test.ts new file mode 100644 index 000000000..5920a954c --- /dev/null +++ b/apps/desktop/src/preview/textReceiverScript.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildTextReceiverScript } from "./textReceiverScript.ts"; + +// The script is a string that only ever runs inside the guest page, where a +// syntax error is invisible: typing would fail on every page with nothing to +// say why. Parsing it here is what makes that loud. +describe("buildTextReceiverScript", () => { + it("parses as JavaScript with and without clearing", () => { + expect(() => new Function(`return ${buildTextReceiverScript()}`)).not.toThrow(); + expect( + () => new Function(`return ${buildTextReceiverScript({ clearBefore: 1 })}`), + ).not.toThrow(); + }); +}); diff --git a/apps/desktop/src/preview/textReceiverScript.ts b/apps/desktop/src/preview/textReceiverScript.ts new file mode 100644 index 000000000..c313f6e71 --- /dev/null +++ b/apps/desktop/src/preview/textReceiverScript.ts @@ -0,0 +1,114 @@ +/** + * What in the page would take text an agent types now, asked in the frame + * that holds the keyboard. + * + * Typing clicks its target first and then sends text to whatever the page has + * focused. When the click lands on something that takes no text (a button, a + * label, a layer over the field), inserted text goes nowhere, and a "select + * all" sent to clear the field selects the whole page instead: the user sees + * every word on the page highlighted and the agent hears that it typed. So the + * receiver is found first, and clearing happens here, in the same step that + * checks it, and only inside it. + * + * - `field`: a text input, a textarea, rich text (contenteditable), or + * anything else the page's own editor says takes text where its cursor is, + * which covers a field inside a component whose insides cannot be read (a + * closed shadow root). + * - `editContext`: an editor that draws its own text (EditContext, as newer + * code editors do). It takes inserted text and selects on its own shortcut, + * so it is not cleared here. + * - `none`: nothing that takes text. Date and time pickers are here too: they + * look like inputs and ignore inserted text. + * + * Judged by the focused element, and by a cursor that is in or on it: a + * cursor left behind in a field the user typed in earlier would otherwise + * pass for focus. Known gap: an EditContext editor inside a closed shadow + * root reads as `none`. The script runs among the page's own scripts, so a + * page that lies to it only misleads typing on itself. + */ +export type TextReceiver = "field" | "editContext" | "none"; + +/** Input types that take typed text. The rest are pickers, toggles and buttons. */ +const TEXT_INPUT_TYPES = ["text", "search", "url", "tel", "email", "password", "number"]; + +export function isTextReceiver(value: unknown): value is TextReceiver { + return value === "field" || value === "editContext" || value === "none"; +} + +/** + * The script that names the receiver. With `clearBefore` (a time in epoch + * milliseconds) it also selects a `field`'s contents, so the text typed next + * replaces them, unless it only gets to run after that time: a page too busy + * to answer must not change its selection once the caller has given up. + */ +export function buildTextReceiverScript(options?: { readonly clearBefore: number }): string { + return ` +(() => { + const CLEAR_BEFORE = ${JSON.stringify(options?.clearBefore ?? null)}; + const TEXT_INPUT_TYPES = ${JSON.stringify(TEXT_INPUT_TYPES)}; + const clears = CLEAR_BEFORE !== null; + if (clears && Date.now() > CLEAR_BEFORE) { + return "none"; + } + + let focused = document.activeElement; + while (focused && focused.shadowRoot && focused.shadowRoot.activeElement) { + focused = focused.shadowRoot.activeElement; + } + if (!focused) { + return "none"; + } + if (focused.editContext) { + return "editContext"; + } + + const tag = focused.localName; + if (tag === "input" || tag === "textarea") { + if (focused.disabled || focused.readOnly) { + return "none"; + } + if (tag === "input" && !TEXT_INPUT_TYPES.includes(focused.type)) { + return "none"; + } + if (clears) { + focused.select(); + } + return "field"; + } + + const selection = getSelection(); + const anchor = selection ? selection.anchorNode : null; + const cursorInside = anchor !== null && (anchor === focused || focused.contains(anchor)); + if (!cursorInside) { + if (focused.isContentEditable) { + // Focused by script with the cursor left elsewhere: bring it in, or + // "select all" below would act on wherever it was. + if (clears) { + selection.selectAllChildren(focused); + selection.collapseToEnd(); + } + } else { + // A field that cannot be read shows its cursor from outside as pointing + // at the element that holds it. + const pointedAt = + anchor !== null && anchor.nodeType === Node.ELEMENT_NODE + ? anchor.childNodes[selection.anchorOffset] + : null; + const cursorOnIt = pointedAt != null && (pointedAt === focused || pointedAt.contains(focused)); + if (!cursorOnIt) { + return "none"; + } + } + } + if (!focused.isContentEditable && !document.queryCommandEnabled("insertText")) { + return "none"; + } + if (clears) { + // The browser's own "select all": with the cursor in a field it takes + // that field's contents, and editors that watch for it hear about it. + document.execCommand("selectAll"); + } + return "field"; +})() +`; +} diff --git a/apps/server/src/mcp/browserTools.ts b/apps/server/src/mcp/browserTools.ts index 174c3add7..be598651a 100644 --- a/apps/server/src/mcp/browserTools.ts +++ b/apps/server/src/mcp/browserTools.ts @@ -158,7 +158,7 @@ export const BrowserDragTool = changesThePage( export const BrowserTypeTool = changesThePage( Tool.make("browser_type", { description: - "Type into a field. Set `clear` to replace what is already there rather than appending, and `submit` to press Enter afterwards, which is how most single-field forms are sent.", + "Type into a field. Set `clear` to replace what is already there rather than appending, and `submit` to press Enter afterwards, which is how most single-field forms are sent. Name the text field itself: the field is clicked first, and when that click lands on something that takes no text (a button, a label, a date picker), nothing is typed and the call fails.", parameters: PreviewAutomationTypeInputSchema, success: PreviewAutomationActionStatusSchema, failure: PreviewAutomationErrorSchema, From 10c24e44ff0d44b42ab759a3d42bf08b5ae91b5e Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:47:06 -0500 Subject: [PATCH 2/7] feat(web): the browser's address bar searches, with a search engine you choose The address bar said "Search or enter URL" but only loaded addresses, so typed words went nowhere or to a made-up host. Words are now searched: an entry is an address when it has a scheme, a path, a port, a dotted host, a full IP address or is localhost, and a search otherwise. A leading "?" forces a search. Agent navigation and link opening are unchanged. Settings > General > Browser search engine picks Google (the default), DuckDuckGo, Bing, Brave Search or Kagi, kept on this computer with the browser. --- .../settings/DesktopClientSettings.test.ts | 1 + apps/server/src/guide/topics.ts | 4 +- .../browser/AgentActivityLine.browser.tsx | 33 +++++++++- .../src/components/browser/BrowserPanel.tsx | 16 +++-- .../src/components/browser/previewUrl.test.ts | 65 ++++++++++++++++++- apps/web/src/components/browser/previewUrl.ts | 51 +++++++++++++++ .../components/settings/SettingsPanels.tsx | 56 ++++++++++++++++ .../settings/settingsRowRegistry.tsx | 2 + apps/web/src/localApi.test.ts | 2 + docs/design/agent-settings-changes.md | 1 + packages/contracts/src/settings.ts | 18 +++++ packages/shared/package.json | 4 ++ packages/shared/src/appPlaces.ts | 19 ++++++ packages/shared/src/browserSearch.ts | 29 +++++++++ 14 files changed, 292 insertions(+), 9 deletions(-) create mode 100644 packages/shared/src/browserSearch.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index db82857a4..f3cba36e0 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -19,6 +19,7 @@ const clientSettings: ClientSettings = { agentBrowserProjectSitePolicy: {}, agentBrowserTrustedCertificates: {}, browserNewTabSize: null, + browserSearchEngine: "google", browserSignIns: "shared", autoOpenAgentsPanel: false, chatChangedFilesDefaultExpanded: false, diff --git a/apps/server/src/guide/topics.ts b/apps/server/src/guide/topics.ts index ae1dc06cf..106f68201 100644 --- a/apps/server/src/guide/topics.ts +++ b/apps/server/src/guide/topics.ts @@ -275,7 +275,9 @@ When a Claude or Codex usage limit stops a thread, the notice above the composer {place:general.browserSignIns} is Shared by default: every project on a computer uses one browser, so you sign in to a site once and every project's agents can use that sign-in. Local sites are shared too, so two projects on localhost:3000 see each other's cookies and storage. Per project gives each project its own. Each project still has its own allowed sites. The browser can't use Chrome's saved passwords or your Chrome sign-ins. -Show device toolbar, in the browser's options menu (⋮), sets the page's size: Responsive fills the panel, and a Phone, Tablet, Laptop or Desktop size shrinks to fit it. New tabs open at {place:general.browserPageSize}, which the size menu can also set.`, +Show device toolbar, in the browser's options menu (⋮), sets the page's size: Responsive fills the panel, and a Phone, Tablet, Laptop or Desktop size shrinks to fit it. New tabs open at {place:general.browserPageSize}, which the size menu can also set. + +Words typed in the browser's address bar are searched for, with the search engine set in {place:general.browserSearchEngine}. An address loads as it always did.`, }, { id: "file-viewer", diff --git a/apps/web/src/components/browser/AgentActivityLine.browser.tsx b/apps/web/src/components/browser/AgentActivityLine.browser.tsx index 148efa9c9..77cc5ad68 100644 --- a/apps/web/src/components/browser/AgentActivityLine.browser.tsx +++ b/apps/web/src/components/browser/AgentActivityLine.browser.tsx @@ -1,7 +1,7 @@ import { scopedThreadKey, scopeThreadRef } from "@threadlines/client-runtime"; import { EnvironmentId, ProjectId, ThreadId } from "@threadlines/contracts"; -import { page } from "vite-plus/test/browser"; -import { describe, expect, it } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; +import { describe, expect, it, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; import { makeBrowserTab, useBrowserPanelStore } from "../../browserPanelStore"; @@ -154,6 +154,35 @@ describe("BrowserPanel untrusted local certificate", () => { }); }); +describe("BrowserPanel address bar", () => { + it("searches for typed words with the chosen search engine, and still loads an address", async () => { + seedBrowserPanel({ width: null, height: null }); + updateSettings({ browserSearchEngine: "duckduckgo" }); + const screen = await render( + {}} />, + ); + const tabUrl = () => + useBrowserPanelStore.getState().browserStateByThreadKey[THREAD_KEY]?.tabs[0]?.url; + + try { + const address = page.getByTestId("browser-panel-address"); + await address.fill("how to center a div"); + await userEvent.keyboard("{Enter}"); + await vi.waitFor(() => + expect(tabUrl()).toBe("https://duckduckgo.com/?q=how+to+center+a+div"), + ); + + await address.fill("localhost:5173"); + await userEvent.keyboard("{Enter}"); + await vi.waitFor(() => expect(tabUrl()).toBe("http://localhost:5173/")); + } finally { + screen.unmount(); + resetBrowserPanel(); + updateSettings({ browserSearchEngine: "google" }); + } + }); +}); + describe("BrowserPanel device toolbar", () => { it("keeps a responsive page fluid when the toolbar opens", async () => { seedBrowserPanel({ width: null, height: null }); diff --git a/apps/web/src/components/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx index 7e8bdb70a..f8b1fb234 100644 --- a/apps/web/src/components/browser/BrowserPanel.tsx +++ b/apps/web/src/components/browser/BrowserPanel.tsx @@ -61,7 +61,8 @@ import { type BrowserAppearance, type BrowserTab, } from "../../browserPanelStore"; -import { updateSettings, useSettings } from "../../hooks/useSettings"; +import { browserSearchUrl } from "@threadlines/shared/browserSearch"; +import { getClientSettings, updateSettings, useSettings } from "../../hooks/useSettings"; import { BROWSER_PAGE_SIZE_PRESETS, RESPONSIVE_PAGE_SIZE_ID, @@ -116,7 +117,7 @@ import { answerBrowserApproval, type BrowserApprovalDecision } from "./browserAp import { noteBrowserUserIntent } from "./browserFocusGuard"; import { completeUrl } from "./urlCompletion"; import { type AgentActivity } from "./previewAutomationHost"; -import { hostOf, normalizePreviewUrl } from "./previewUrl"; +import { hostOf, resolveAddressEntry } from "./previewUrl"; /** Cmd (Ctrl off a Mac) with the key and nothing else. */ function isPlainModShortcut(event: KeyboardEvent): boolean { @@ -391,11 +392,16 @@ export function BrowserPanel({ const submitAddress = useCallback( (event: React.FormEvent) => { event.preventDefault(); - const normalized = normalizePreviewUrl(addressDraft); - if (normalized === null || activeTabId === "") { + const entry = resolveAddressEntry(addressDraft); + if (entry === null || activeTabId === "") { return; } - openAddress(activeTabId, normalized); + openAddress( + activeTabId, + entry.kind === "url" + ? entry.url + : browserSearchUrl(getClientSettings().browserSearchEngine, entry.query), + ); }, [activeTabId, addressDraft, openAddress], ); diff --git a/apps/web/src/components/browser/previewUrl.test.ts b/apps/web/src/components/browser/previewUrl.test.ts index 8e36cee98..982b7ae0b 100644 --- a/apps/web/src/components/browser/previewUrl.test.ts +++ b/apps/web/src/components/browser/previewUrl.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { normalizePreviewUrl } from "./previewUrl"; +import { browserSearchUrl } from "@threadlines/shared/browserSearch"; + +import { normalizePreviewUrl, resolveAddressEntry } from "./previewUrl"; describe("normalizePreviewUrl", () => { it("assumes http for a bare dev-server address", () => { @@ -31,3 +33,64 @@ describe("normalizePreviewUrl", () => { expect(normalizePreviewUrl("file:///etc/passwd")).toBeNull(); }); }); + +describe("resolveAddressEntry", () => { + const url = (value: string) => ({ kind: "url", url: value }); + const search = (query: string) => ({ kind: "search", query }); + + it("loads what names a place: a host, a port, a path, an IP address or localhost", () => { + expect(resolveAddressEntry("localhost")).toEqual(url("http://localhost/")); + expect(resolveAddressEntry("localhost:5173/app")).toEqual(url("http://localhost:5173/app")); + expect(resolveAddressEntry("devbox:3000")).toEqual(url("http://devbox:3000/")); + expect(resolveAddressEntry("intranet/")).toEqual(url("http://intranet/")); + expect(resolveAddressEntry("192.168.1.20")).toEqual(url("http://192.168.1.20/")); + expect(resolveAddressEntry("threadlines.dev")).toEqual(url("https://threadlines.dev/")); + expect(resolveAddressEntry("https://example.com/a b")).toEqual( + url("https://example.com/a%20b"), + ); + expect(resolveAddressEntry("[::1]")).toEqual(url("http://[::1]/")); + expect(resolveAddressEntry("例え。テスト")).toEqual(url("https://xn--r8jz45g.xn--zckzah/")); + }); + + it("keeps an address with a path or a port an address, whatever else it holds", () => { + expect(resolveAddressEntry("localhost:5173/a b")).toEqual(url("http://localhost:5173/a%20b")); + expect(resolveAddressEntry("example.com/a b")).toEqual(url("https://example.com/a%20b")); + expect(resolveAddressEntry("user@devbox/path")).toEqual(url("http://user@devbox/path")); + expect(resolveAddressEntry("127.1:3000/")).toEqual(url("http://127.0.0.1:3000/")); + expect(resolveAddressEntry("127.0.0.1.")).toEqual(url("http://127.0.0.1/")); + expect(resolveAddressEntry("//example.com/app")).toEqual(url("https://example.com/app")); + expect(resolveAddressEntry("devbox\\logs")).toEqual(url("http://devbox/logs")); + }); + + it("searches for everything else", () => { + expect(resolveAddressEntry("how to center a div")).toEqual(search("how to center a div")); + expect(resolveAddressEntry("react")).toEqual(search("react")); + expect(resolveAddressEntry("what is node.js")).toEqual(search("what is node.js")); + expect(resolveAddressEntry("3.14")).toEqual(search("3.14")); + expect(resolveAddressEntry("someone@example.com")).toEqual(search("someone@example.com")); + expect(resolveAddressEntry("c++")).toEqual(search("c++")); + expect(resolveAddressEntry("why?")).toEqual(search("why?")); + expect(resolveAddressEntry("devbox?next=/logs")).toEqual(search("devbox?next=/logs")); + expect(resolveAddressEntry("threadlines.dev pricing")).toEqual( + search("threadlines.dev pricing"), + ); + expect(resolveAddressEntry("javascript:alert(1)")).toEqual(search("javascript:alert(1)")); + }); + + it("sends a search to the chosen engine with the words encoded", () => { + expect(browserSearchUrl("google", "tom & jerry?")).toBe( + "https://www.google.com/search?q=tom+%26+jerry%3F", + ); + expect(browserSearchUrl("duckduckgo", "c++")).toBe("https://duckduckgo.com/?q=c%2B%2B"); + }); + + it("lets a leading ? force a search", () => { + expect(resolveAddressEntry("?threadlines.dev")).toEqual(search("threadlines.dev")); + expect(resolveAddressEntry("?")).toBeNull(); + }); + + it("does nothing for an empty entry or a scheme the browser does not load", () => { + expect(resolveAddressEntry(" ")).toBeNull(); + expect(resolveAddressEntry("file:///etc/passwd")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/browser/previewUrl.ts b/apps/web/src/components/browser/previewUrl.ts index e12491d1d..54062d275 100644 --- a/apps/web/src/components/browser/previewUrl.ts +++ b/apps/web/src/components/browser/previewUrl.ts @@ -67,3 +67,54 @@ export function normalizePreviewUrl(input: string): string | null { return null; } } + +/** What an address bar entry asks for: a page to load, or words to search for. */ +export type AddressEntry = + | { readonly kind: "url"; readonly url: string } + | { readonly kind: "search"; readonly query: string }; + +/** + * Reads what was typed in the address bar the way a browser does: an address + * loads, anything else is a search. Null when there is nothing to do. + * + * Only the part before any path decides. With a path or a port it is an + * address, spaces after it and all (`localhost:5173/a b`, `intranet/`). A bare + * name is an address when it is `localhost`, has a dot, or is a full IP + * address; a lone word, a number like 3.14 and an email address are searches. + * A leading `?` forces a search. An entry with a scheme we do not load + * (`file:`) does nothing, as before, rather than searching for it. + */ +export function resolveAddressEntry(input: string): AddressEntry | null { + const typed = input.trim(); + if (typed === "") return null; + if (typed.startsWith("?")) { + const query = typed.slice(1).trim(); + return query === "" ? null : { kind: "search", query }; + } + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(typed)) { + const url = normalizePreviewUrl(typed); + return url === null ? null : { kind: "url", url }; + } + + const search: AddressEntry = { kind: "search", query: typed }; + // As the address is read below: leading slashes are dropped, and a + // backslash separates a path like a slash does. + const bare = typed.replace(/^\/+/, ""); + const authority = bare.split(/[/\\?#]/, 1)[0] ?? ""; + if (authority === "" || /\s/.test(authority)) return search; + const url = normalizePreviewUrl(typed); + if (url === null) return search; + const address: AddressEntry = { kind: "url", url }; + const hasPath = /[/\\]/.test(bare.split(/[?#]/, 1)[0] ?? ""); + if (hasPath || /:\d+$/.test(authority)) return address; + + // A bare name from here on. + if (authority.includes("@")) return search; + if (/^[\d.]+$/.test(authority)) { + // 3.14 parses as an IP address; only four numbers are meant as one. + return /^\d{1,3}(\.\d{1,3}){3}\.?$/.test(authority) ? address : search; + } + // The parsed host, so a name written with other scripts' dots still counts. + const host = new URL(url).hostname; + return host === "localhost" || host.includes(".") || host.startsWith("[") ? address : search; +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ef76fbf39..5c993b45f 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -14,6 +14,7 @@ import { ACP_REGISTRY_DRIVER_KIND, AUTO_ARCHIVE_INACTIVE_THREADS_DAY_OPTIONS, type AutoArchiveInactiveThreadsDays, + BrowserSearchEngine, defaultInstanceIdForDriver, type DesktopUpdateChannel, ProviderDriverKind, @@ -23,6 +24,7 @@ import { type UsageWindowDays, } from "@threadlines/contracts"; import { scopeThreadRef } from "@threadlines/client-runtime"; +import { BROWSER_SEARCH_ENGINE_LABELS } from "@threadlines/shared/browserSearch"; import { themeGlassLevel } from "@threadlines/shared/themeGlassLevel"; import { agentInvitesChoice, agentThreadsMode } from "@threadlines/shared/serverSettings"; import { @@ -499,6 +501,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.newThreadRoomAgents.length > 0 ? ["New thread room"] : []), ...(settings.openLinksIn !== DEFAULT_UNIFIED_SETTINGS.openLinksIn ? ["Open links in"] : []), ...(settings.browserNewTabSize !== null ? ["Browser page size"] : []), + ...(settings.browserSearchEngine !== DEFAULT_UNIFIED_SETTINGS.browserSearchEngine + ? ["Browser search engine"] + : []), ...(settings.terminalFontSize !== DEFAULT_UNIFIED_SETTINGS.terminalFontSize ? ["Terminal text size"] : []), @@ -570,6 +575,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.addProjectBaseDirectory, settings.openLinksIn, settings.browserNewTabSize, + settings.browserSearchEngine, settings.terminalFontSize, settings.terminalScreenReaderMode, settings.agentBrowserSitePolicy, @@ -629,6 +635,7 @@ export function useSettingsRestore(onRestored?: () => void) { newThreadRoomAgents: DEFAULT_UNIFIED_SETTINGS.newThreadRoomAgents, openLinksIn: DEFAULT_UNIFIED_SETTINGS.openLinksIn, browserNewTabSize: DEFAULT_UNIFIED_SETTINGS.browserNewTabSize, + browserSearchEngine: DEFAULT_UNIFIED_SETTINGS.browserSearchEngine, terminalFontSize: DEFAULT_UNIFIED_SETTINGS.terminalFontSize, terminalScreenReaderMode: DEFAULT_UNIFIED_SETTINGS.terminalScreenReaderMode, agentBrowserSitePolicy: DEFAULT_UNIFIED_SETTINGS.agentBrowserSitePolicy, @@ -842,6 +849,52 @@ export function OpenLinksRow() { ); } +/** + * Where the built-in browser's address bar searches when what was typed is + * not an address. Kept on this computer with the browser. + */ +export function BrowserSearchEngineRow() { + const searchEngine = useSettings((current) => current.browserSearchEngine); + const { updateSettings } = useUpdateSettings(); + return ( + + updateSettings({ + browserSearchEngine: DEFAULT_UNIFIED_SETTINGS.browserSearchEngine, + }) + } + /> + ) : null + } + control={ + + } + /> + ); +} + /** * The size new tabs in the built-in browser open at. Kept on this computer * with the browser; the browser's size menu sets it too, including to a size @@ -1341,6 +1394,9 @@ export function GeneralSettingsPanel({ surface = "full" }: { surface?: "full" | + + + diff --git a/apps/web/src/components/settings/settingsRowRegistry.tsx b/apps/web/src/components/settings/settingsRowRegistry.tsx index 20c7bf389..5fbaf0fa7 100644 --- a/apps/web/src/components/settings/settingsRowRegistry.tsx +++ b/apps/web/src/components/settings/settingsRowRegistry.tsx @@ -20,6 +20,7 @@ import { AgentsPanelRow, AssistantStreamingRow, BrowserPageSizeRow, + BrowserSearchEngineRow, ChangedFilesInChatRow, ClearBrowserDataRow, DiagnosticsRow, @@ -111,6 +112,7 @@ export const SETTINGS_ROWS = { "general.addProjectStartsIn": live(AddProjectStartsInRow), "general.openLinksIn": live(OpenLinksRow), "general.browserPageSize": live(BrowserPageSizeRow), + "general.browserSearchEngine": live(BrowserSearchEngineRow), "general.sitesAgentsCanVisit": live(AgentBrowserSitesRow), "general.browserSignIns": live(BrowserSignInsRow), "general.clearBrowserData": live(ClearBrowserDataRow), diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 1122f2bb9..93f3f8ad8 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -492,6 +492,7 @@ describe("wsApi", () => { agentBrowserProjectSitePolicy: {}, agentBrowserTrustedCertificates: {}, browserNewTabSize: null, + browserSearchEngine: "google" as const, browserSignIns: "shared" as const, autoOpenAgentsPanel: false, chatChangedFilesDefaultExpanded: false, @@ -897,6 +898,7 @@ describe("wsApi", () => { agentBrowserProjectSitePolicy: {}, agentBrowserTrustedCertificates: {}, browserNewTabSize: null, + browserSearchEngine: "google" as const, browserSignIns: "shared" as const, autoOpenAgentsPanel: false, chatChangedFilesDefaultExpanded: false, diff --git a/docs/design/agent-settings-changes.md b/docs/design/agent-settings-changes.md index 540ca1df1..ea82326e9 100644 --- a/docs/design/agent-settings-changes.md +++ b/docs/design/agent-settings-changes.md @@ -149,6 +149,7 @@ The first list: | Alerts: Needs you, Finished, Failed, Quiet | device | switch | alert store | | Open links in | device | choice: Built-in browser, External browser | `openLinksIn` | | Browser page size | device | choice: Responsive, Phone, Tablet, Laptop, Desktop | `browserNewTabSize` | +| Browser search engine | device | choice: Google, DuckDuckGo, Bing, Brave, Kagi | `browserSearchEngine` | The wrap-up switches only change how each app files its own thread lists (Wrapped is reversible filing, not archiving). diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b4c6486ac..dd6fc2d09 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -112,6 +112,20 @@ export const LinkOpenTarget = Schema.Literals(["browser-panel", "external"]); export type LinkOpenTarget = typeof LinkOpenTarget.Type; export const DEFAULT_LINK_OPEN_TARGET: LinkOpenTarget = "browser-panel"; +/** + * Where the browser panel's address bar sends words that are not an address. + * Kept on this computer, like the browser itself. + */ +export const BrowserSearchEngine = Schema.Literals([ + "google", + "duckduckgo", + "bing", + "brave", + "kagi", +]); +export type BrowserSearchEngine = typeof BrowserSearchEngine.Type; +export const DEFAULT_BROWSER_SEARCH_ENGINE: BrowserSearchEngine = "google"; + /** A certificate trusted for a project's pages; see `isBrowserCertificateTrusted`. */ export const BrowserTrustedCertificate = Schema.Struct({ host: TrimmedNonEmptyString, @@ -165,6 +179,9 @@ export const ClientSettingsSchema = Schema.Struct({ browserNewTabSize: Schema.NullOr(BrowserPageSize).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + browserSearchEngine: BrowserSearchEngine.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_SEARCH_ENGINE)), + ), // Whether the browser panel's projects share one profile (and so their // sign-ins) or each keep their own. Kept on this computer, like the browser. browserSignIns: BrowserSignIns.pipe( @@ -1292,6 +1309,7 @@ export const ClientSettingsPatch = Schema.Struct({ Schema.Record(ProjectId, Schema.Array(BrowserTrustedCertificate)), ), browserNewTabSize: Schema.optionalKey(Schema.NullOr(BrowserPageSize)), + browserSearchEngine: Schema.optionalKey(BrowserSearchEngine), browserSignIns: Schema.optionalKey(BrowserSignIns), autoOpenAgentsPanel: Schema.optionalKey(Schema.Boolean), chatChangedFilesDefaultExpanded: Schema.optionalKey(Schema.Boolean), diff --git a/packages/shared/package.json b/packages/shared/package.json index e92fbfbdb..1f6d535b4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -427,6 +427,10 @@ "types": "./src/browserPageSizes.ts", "import": "./src/browserPageSizes.ts" }, + "./browserSearch": { + "types": "./src/browserSearch.ts", + "import": "./src/browserSearch.ts" + }, "./settingsEdits": { "types": "./src/settingsEdits.ts", "import": "./src/settingsEdits.ts" diff --git a/packages/shared/src/appPlaces.ts b/packages/shared/src/appPlaces.ts index 566503dde..1f3a36fcf 100644 --- a/packages/shared/src/appPlaces.ts +++ b/packages/shared/src/appPlaces.ts @@ -19,6 +19,7 @@ import type { ClientSettings, ClientSettingsPatch, ComputerLinkAccess, + BrowserSearchEngine, LinkOpenTarget, ModelSelection, NewWorktreeBaseFrom, @@ -37,6 +38,7 @@ import { RESPONSIVE_PAGE_SIZE_ID, RESPONSIVE_PAGE_SIZE_LABEL, } from "./browserPageSizes.ts"; +import { BROWSER_SEARCH_ENGINE_LABELS } from "./browserSearch.ts"; import { agentInvitesChoice, agentThreadsMode } from "./serverSettings.ts"; import { themeGlassLevel } from "./themes/glassLevel.ts"; @@ -1216,6 +1218,23 @@ const ROWS = [ }, }, }, + { + id: "general.browserSearchEngine", + kind: "row", + parent: "general.thisComputer", + title: "Browser search engine", + about: "Where the browser's address bar searches when you type words instead of an address.", + keywords: ["browser", "search", "address bar", "google", "duckduckgo", "bing", "brave", "kagi"], + scope: "device", + appliesWhen: "desktop", + edit: { + lands: "device", + control: { kind: "choice", options: BROWSER_SEARCH_ENGINE_LABELS }, + leaves: ["browserSearchEngine"], + read: (settings) => settings.browserSearchEngine, + patch: (value) => ({ browserSearchEngine: value as BrowserSearchEngine }), + }, + }, { id: "general.sitesAgentsCanVisit", kind: "row", diff --git a/packages/shared/src/browserSearch.ts b/packages/shared/src/browserSearch.ts new file mode 100644 index 000000000..3b5b40e97 --- /dev/null +++ b/packages/shared/src/browserSearch.ts @@ -0,0 +1,29 @@ +/** + * The search engines the browser panel's address bar can use: the Settings + * row's choices, and where a search for some words goes. + */ +import type { BrowserSearchEngine } from "@threadlines/contracts"; + +/** "Browser search engine", as its menu words each choice. */ +export const BROWSER_SEARCH_ENGINE_LABELS: Readonly> = { + google: "Google", + duckduckgo: "DuckDuckGo", + bing: "Bing", + brave: "Brave Search", + kagi: "Kagi", +}; + +const SEARCH_PAGES: Readonly> = { + google: "https://www.google.com/search", + duckduckgo: "https://duckduckgo.com/", + bing: "https://www.bing.com/search", + brave: "https://search.brave.com/search", + kagi: "https://kagi.com/search", +}; + +/** The results page for `query` on a search engine. */ +export function browserSearchUrl(engine: BrowserSearchEngine, query: string): string { + const url = new URL(SEARCH_PAGES[engine]); + url.searchParams.set("q", query); + return url.toString(); +} From c80b803fa5b39bc86294ed46a76c5c5f7aee9402 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:50:25 -0500 Subject: [PATCH 3/7] test(web): the address bar search test no longer depends on other tests' browsing history --- .../src/components/browser/AgentActivityLine.browser.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/browser/AgentActivityLine.browser.tsx b/apps/web/src/components/browser/AgentActivityLine.browser.tsx index 77cc5ad68..ce7df1ae0 100644 --- a/apps/web/src/components/browser/AgentActivityLine.browser.tsx +++ b/apps/web/src/components/browser/AgentActivityLine.browser.tsx @@ -172,9 +172,11 @@ describe("BrowserPanel address bar", () => { expect(tabUrl()).toBe("https://duckduckgo.com/?q=how+to+center+a+div"), ); - await address.fill("localhost:5173"); + // An address no other test visits: the bar completes an address from + // the pages this browser has been to, and that history is shared. + await address.fill("address-bar.example/docs"); await userEvent.keyboard("{Enter}"); - await vi.waitFor(() => expect(tabUrl()).toBe("http://localhost:5173/")); + await vi.waitFor(() => expect(tabUrl()).toBe("https://address-bar.example/docs")); } finally { screen.unmount(); resetBrowserPanel(); From 6fed152422aca5af75b734e3ef75a8a87b807a21 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:02:11 -0500 Subject: [PATCH 4/7] feat(web): search engines show their marks in the Settings choice Each choice under Settings > General > Browser search engine now leads with the engine's mark: Google's four-color G, and the Simple Icons glyphs (CC0) for DuckDuckGo, Bing, Brave and Kagi in each brand's color. The marks live with the label component, not in the app-wide icon file, so they load with Settings and add nothing to startup. --- .../components/browser/SearchEngineLabel.tsx | 73 +++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 8 +- 2 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/browser/SearchEngineLabel.tsx diff --git a/apps/web/src/components/browser/SearchEngineLabel.tsx b/apps/web/src/components/browser/SearchEngineLabel.tsx new file mode 100644 index 000000000..1bdea6fd1 --- /dev/null +++ b/apps/web/src/components/browser/SearchEngineLabel.tsx @@ -0,0 +1,73 @@ +import type { BrowserSearchEngine } from "@threadlines/contracts"; +import { BROWSER_SEARCH_ENGINE_LABELS } from "@threadlines/shared/browserSearch"; + +import type { Icon } from "../Icons"; + +/** + * Search engine marks. Google's is its four-color G; the rest are the Simple + * Icons glyphs (CC0) in each brand's own color. Kept here, not with the + * app-wide icons, so they load with Settings and not at startup. + */ +const GoogleIcon: Icon = (props) => ( + + + + + + +); + +const DuckDuckGoIcon: Icon = (props) => ( + + + +); + +const BingIcon: Icon = (props) => ( + + + +); + +const BraveIcon: Icon = (props) => ( + + + +); + +const KagiIcon: Icon = (props) => ( + + + +); + +const SEARCH_ENGINE_ICONS: Readonly> = { + google: GoogleIcon, + duckduckgo: DuckDuckGoIcon, + bing: BingIcon, + brave: BraveIcon, + kagi: KagiIcon, +}; + +/** A search engine as a menu shows it: its mark, then its name. */ +export function SearchEngineLabel({ engine }: { engine: BrowserSearchEngine }) { + const EngineIcon = SEARCH_ENGINE_ICONS[engine]; + return ( + + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5c993b45f..91c7b20e4 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -24,7 +24,6 @@ import { type UsageWindowDays, } from "@threadlines/contracts"; import { scopeThreadRef } from "@threadlines/client-runtime"; -import { BROWSER_SEARCH_ENGINE_LABELS } from "@threadlines/shared/browserSearch"; import { themeGlassLevel } from "@threadlines/shared/themeGlassLevel"; import { agentInvitesChoice, agentThreadsMode } from "@threadlines/shared/serverSettings"; import { @@ -121,6 +120,7 @@ import { viewportForPageSizeChoice, } from "../browser/pageSizes"; import { PageSizeLabel } from "../browser/PageSizeLabel"; +import { SearchEngineLabel } from "../browser/SearchEngineLabel"; import { SettingsSurfaceContext, ShownPlace, @@ -880,12 +880,14 @@ export function BrowserSearchEngineRow() { }} > - {BROWSER_SEARCH_ENGINE_LABELS[searchEngine]} + + + {BrowserSearchEngine.literals.map((engine) => ( - {BROWSER_SEARCH_ENGINE_LABELS[engine]} + ))} From 99f0b92d84b167de295f8ebfea5944cfb7b80901 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:32:16 -0500 Subject: [PATCH 5/7] perf(web): the address bar's search rules and URLs load with the browser panel, not at startup The first cut put resolveAddressEntry in previewUrl.ts and the search URLs beside the engine names, and both modules load at startup: link opening needs previewUrl, and the Settings catalog needs the names. Only the browser panel reads the address bar, so the rules move to addressEntry.ts and the names move to the catalog with the other choice labels, leaving browserSearch.ts with just the URLs. --- .../src/components/browser/BrowserPanel.tsx | 3 +- .../components/browser/SearchEngineLabel.tsx | 2 +- .../components/browser/addressEntry.test.ts | 65 +++++++++++++++++++ .../src/components/browser/addressEntry.ts | 57 ++++++++++++++++ .../src/components/browser/previewUrl.test.ts | 65 +------------------ apps/web/src/components/browser/previewUrl.ts | 51 --------------- packages/shared/src/appPlaces.ts | 12 +++- packages/shared/src/browserSearch.ts | 14 +--- 8 files changed, 139 insertions(+), 130 deletions(-) create mode 100644 apps/web/src/components/browser/addressEntry.test.ts create mode 100644 apps/web/src/components/browser/addressEntry.ts diff --git a/apps/web/src/components/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx index f8b1fb234..7155d0f4b 100644 --- a/apps/web/src/components/browser/BrowserPanel.tsx +++ b/apps/web/src/components/browser/BrowserPanel.tsx @@ -117,7 +117,8 @@ import { answerBrowserApproval, type BrowserApprovalDecision } from "./browserAp import { noteBrowserUserIntent } from "./browserFocusGuard"; import { completeUrl } from "./urlCompletion"; import { type AgentActivity } from "./previewAutomationHost"; -import { hostOf, resolveAddressEntry } from "./previewUrl"; +import { resolveAddressEntry } from "./addressEntry"; +import { hostOf } from "./previewUrl"; /** Cmd (Ctrl off a Mac) with the key and nothing else. */ function isPlainModShortcut(event: KeyboardEvent): boolean { diff --git a/apps/web/src/components/browser/SearchEngineLabel.tsx b/apps/web/src/components/browser/SearchEngineLabel.tsx index 1bdea6fd1..b45153982 100644 --- a/apps/web/src/components/browser/SearchEngineLabel.tsx +++ b/apps/web/src/components/browser/SearchEngineLabel.tsx @@ -1,5 +1,5 @@ import type { BrowserSearchEngine } from "@threadlines/contracts"; -import { BROWSER_SEARCH_ENGINE_LABELS } from "@threadlines/shared/browserSearch"; +import { BROWSER_SEARCH_ENGINE_LABELS } from "@threadlines/shared/appPlaces"; import type { Icon } from "../Icons"; diff --git a/apps/web/src/components/browser/addressEntry.test.ts b/apps/web/src/components/browser/addressEntry.test.ts new file mode 100644 index 000000000..490504fe9 --- /dev/null +++ b/apps/web/src/components/browser/addressEntry.test.ts @@ -0,0 +1,65 @@ +import { browserSearchUrl } from "@threadlines/shared/browserSearch"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAddressEntry } from "./addressEntry"; + +describe("resolveAddressEntry", () => { + const url = (value: string) => ({ kind: "url", url: value }); + const search = (query: string) => ({ kind: "search", query }); + + it("loads what names a place: a host, a port, a path, an IP address or localhost", () => { + expect(resolveAddressEntry("localhost")).toEqual(url("http://localhost/")); + expect(resolveAddressEntry("localhost:5173/app")).toEqual(url("http://localhost:5173/app")); + expect(resolveAddressEntry("devbox:3000")).toEqual(url("http://devbox:3000/")); + expect(resolveAddressEntry("intranet/")).toEqual(url("http://intranet/")); + expect(resolveAddressEntry("192.168.1.20")).toEqual(url("http://192.168.1.20/")); + expect(resolveAddressEntry("threadlines.dev")).toEqual(url("https://threadlines.dev/")); + expect(resolveAddressEntry("https://example.com/a b")).toEqual( + url("https://example.com/a%20b"), + ); + expect(resolveAddressEntry("[::1]")).toEqual(url("http://[::1]/")); + expect(resolveAddressEntry("例え。テスト")).toEqual(url("https://xn--r8jz45g.xn--zckzah/")); + }); + + it("keeps an address with a path or a port an address, whatever else it holds", () => { + expect(resolveAddressEntry("localhost:5173/a b")).toEqual(url("http://localhost:5173/a%20b")); + expect(resolveAddressEntry("example.com/a b")).toEqual(url("https://example.com/a%20b")); + expect(resolveAddressEntry("user@devbox/path")).toEqual(url("http://user@devbox/path")); + expect(resolveAddressEntry("127.1:3000/")).toEqual(url("http://127.0.0.1:3000/")); + expect(resolveAddressEntry("127.0.0.1.")).toEqual(url("http://127.0.0.1/")); + expect(resolveAddressEntry("//example.com/app")).toEqual(url("https://example.com/app")); + expect(resolveAddressEntry("devbox\\logs")).toEqual(url("http://devbox/logs")); + }); + + it("searches for everything else", () => { + expect(resolveAddressEntry("how to center a div")).toEqual(search("how to center a div")); + expect(resolveAddressEntry("react")).toEqual(search("react")); + expect(resolveAddressEntry("what is node.js")).toEqual(search("what is node.js")); + expect(resolveAddressEntry("3.14")).toEqual(search("3.14")); + expect(resolveAddressEntry("someone@example.com")).toEqual(search("someone@example.com")); + expect(resolveAddressEntry("c++")).toEqual(search("c++")); + expect(resolveAddressEntry("why?")).toEqual(search("why?")); + expect(resolveAddressEntry("devbox?next=/logs")).toEqual(search("devbox?next=/logs")); + expect(resolveAddressEntry("threadlines.dev pricing")).toEqual( + search("threadlines.dev pricing"), + ); + expect(resolveAddressEntry("javascript:alert(1)")).toEqual(search("javascript:alert(1)")); + }); + + it("sends a search to the chosen engine with the words encoded", () => { + expect(browserSearchUrl("google", "tom & jerry?")).toBe( + "https://www.google.com/search?q=tom+%26+jerry%3F", + ); + expect(browserSearchUrl("duckduckgo", "c++")).toBe("https://duckduckgo.com/?q=c%2B%2B"); + }); + + it("lets a leading ? force a search", () => { + expect(resolveAddressEntry("?threadlines.dev")).toEqual(search("threadlines.dev")); + expect(resolveAddressEntry("?")).toBeNull(); + }); + + it("does nothing for an empty entry or a scheme the browser does not load", () => { + expect(resolveAddressEntry(" ")).toBeNull(); + expect(resolveAddressEntry("file:///etc/passwd")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/browser/addressEntry.ts b/apps/web/src/components/browser/addressEntry.ts new file mode 100644 index 000000000..1b660a1ec --- /dev/null +++ b/apps/web/src/components/browser/addressEntry.ts @@ -0,0 +1,57 @@ +/** + * What was typed in the browser panel's address bar: an address to load, or + * words to search for. Apart from `previewUrl.ts`, which the app needs at + * startup to open links; only the panel itself reads the address bar. + */ +import { normalizePreviewUrl } from "./previewUrl"; + +/** What an address bar entry asks for: a page to load, or words to search for. */ +export type AddressEntry = + | { readonly kind: "url"; readonly url: string } + | { readonly kind: "search"; readonly query: string }; + +/** + * Reads what was typed in the address bar the way a browser does: an address + * loads, anything else is a search. Null when there is nothing to do. + * + * Only the part before any path decides. With a path or a port it is an + * address, spaces after it and all (`localhost:5173/a b`, `intranet/`). A bare + * name is an address when it is `localhost`, has a dot, or is a full IP + * address; a lone word, a number like 3.14 and an email address are searches. + * A leading `?` forces a search. An entry with a scheme we do not load + * (`file:`) does nothing, as before, rather than searching for it. + */ +export function resolveAddressEntry(input: string): AddressEntry | null { + const typed = input.trim(); + if (typed === "") return null; + if (typed.startsWith("?")) { + const query = typed.slice(1).trim(); + return query === "" ? null : { kind: "search", query }; + } + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(typed)) { + const url = normalizePreviewUrl(typed); + return url === null ? null : { kind: "url", url }; + } + + const search: AddressEntry = { kind: "search", query: typed }; + // As the address is read below: leading slashes are dropped, and a + // backslash separates a path like a slash does. + const bare = typed.replace(/^\/+/, ""); + const authority = bare.split(/[/\\?#]/, 1)[0] ?? ""; + if (authority === "" || /\s/.test(authority)) return search; + const url = normalizePreviewUrl(typed); + if (url === null) return search; + const address: AddressEntry = { kind: "url", url }; + const hasPath = /[/\\]/.test(bare.split(/[?#]/, 1)[0] ?? ""); + if (hasPath || /:\d+$/.test(authority)) return address; + + // A bare name from here on. + if (authority.includes("@")) return search; + if (/^[\d.]+$/.test(authority)) { + // 3.14 parses as an IP address; only four numbers are meant as one. + return /^\d{1,3}(\.\d{1,3}){3}\.?$/.test(authority) ? address : search; + } + // The parsed host, so a name written with other scripts' dots still counts. + const host = new URL(url).hostname; + return host === "localhost" || host.includes(".") || host.startsWith("[") ? address : search; +} diff --git a/apps/web/src/components/browser/previewUrl.test.ts b/apps/web/src/components/browser/previewUrl.test.ts index 982b7ae0b..8e36cee98 100644 --- a/apps/web/src/components/browser/previewUrl.test.ts +++ b/apps/web/src/components/browser/previewUrl.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { browserSearchUrl } from "@threadlines/shared/browserSearch"; - -import { normalizePreviewUrl, resolveAddressEntry } from "./previewUrl"; +import { normalizePreviewUrl } from "./previewUrl"; describe("normalizePreviewUrl", () => { it("assumes http for a bare dev-server address", () => { @@ -33,64 +31,3 @@ describe("normalizePreviewUrl", () => { expect(normalizePreviewUrl("file:///etc/passwd")).toBeNull(); }); }); - -describe("resolveAddressEntry", () => { - const url = (value: string) => ({ kind: "url", url: value }); - const search = (query: string) => ({ kind: "search", query }); - - it("loads what names a place: a host, a port, a path, an IP address or localhost", () => { - expect(resolveAddressEntry("localhost")).toEqual(url("http://localhost/")); - expect(resolveAddressEntry("localhost:5173/app")).toEqual(url("http://localhost:5173/app")); - expect(resolveAddressEntry("devbox:3000")).toEqual(url("http://devbox:3000/")); - expect(resolveAddressEntry("intranet/")).toEqual(url("http://intranet/")); - expect(resolveAddressEntry("192.168.1.20")).toEqual(url("http://192.168.1.20/")); - expect(resolveAddressEntry("threadlines.dev")).toEqual(url("https://threadlines.dev/")); - expect(resolveAddressEntry("https://example.com/a b")).toEqual( - url("https://example.com/a%20b"), - ); - expect(resolveAddressEntry("[::1]")).toEqual(url("http://[::1]/")); - expect(resolveAddressEntry("例え。テスト")).toEqual(url("https://xn--r8jz45g.xn--zckzah/")); - }); - - it("keeps an address with a path or a port an address, whatever else it holds", () => { - expect(resolveAddressEntry("localhost:5173/a b")).toEqual(url("http://localhost:5173/a%20b")); - expect(resolveAddressEntry("example.com/a b")).toEqual(url("https://example.com/a%20b")); - expect(resolveAddressEntry("user@devbox/path")).toEqual(url("http://user@devbox/path")); - expect(resolveAddressEntry("127.1:3000/")).toEqual(url("http://127.0.0.1:3000/")); - expect(resolveAddressEntry("127.0.0.1.")).toEqual(url("http://127.0.0.1/")); - expect(resolveAddressEntry("//example.com/app")).toEqual(url("https://example.com/app")); - expect(resolveAddressEntry("devbox\\logs")).toEqual(url("http://devbox/logs")); - }); - - it("searches for everything else", () => { - expect(resolveAddressEntry("how to center a div")).toEqual(search("how to center a div")); - expect(resolveAddressEntry("react")).toEqual(search("react")); - expect(resolveAddressEntry("what is node.js")).toEqual(search("what is node.js")); - expect(resolveAddressEntry("3.14")).toEqual(search("3.14")); - expect(resolveAddressEntry("someone@example.com")).toEqual(search("someone@example.com")); - expect(resolveAddressEntry("c++")).toEqual(search("c++")); - expect(resolveAddressEntry("why?")).toEqual(search("why?")); - expect(resolveAddressEntry("devbox?next=/logs")).toEqual(search("devbox?next=/logs")); - expect(resolveAddressEntry("threadlines.dev pricing")).toEqual( - search("threadlines.dev pricing"), - ); - expect(resolveAddressEntry("javascript:alert(1)")).toEqual(search("javascript:alert(1)")); - }); - - it("sends a search to the chosen engine with the words encoded", () => { - expect(browserSearchUrl("google", "tom & jerry?")).toBe( - "https://www.google.com/search?q=tom+%26+jerry%3F", - ); - expect(browserSearchUrl("duckduckgo", "c++")).toBe("https://duckduckgo.com/?q=c%2B%2B"); - }); - - it("lets a leading ? force a search", () => { - expect(resolveAddressEntry("?threadlines.dev")).toEqual(search("threadlines.dev")); - expect(resolveAddressEntry("?")).toBeNull(); - }); - - it("does nothing for an empty entry or a scheme the browser does not load", () => { - expect(resolveAddressEntry(" ")).toBeNull(); - expect(resolveAddressEntry("file:///etc/passwd")).toBeNull(); - }); -}); diff --git a/apps/web/src/components/browser/previewUrl.ts b/apps/web/src/components/browser/previewUrl.ts index 54062d275..e12491d1d 100644 --- a/apps/web/src/components/browser/previewUrl.ts +++ b/apps/web/src/components/browser/previewUrl.ts @@ -67,54 +67,3 @@ export function normalizePreviewUrl(input: string): string | null { return null; } } - -/** What an address bar entry asks for: a page to load, or words to search for. */ -export type AddressEntry = - | { readonly kind: "url"; readonly url: string } - | { readonly kind: "search"; readonly query: string }; - -/** - * Reads what was typed in the address bar the way a browser does: an address - * loads, anything else is a search. Null when there is nothing to do. - * - * Only the part before any path decides. With a path or a port it is an - * address, spaces after it and all (`localhost:5173/a b`, `intranet/`). A bare - * name is an address when it is `localhost`, has a dot, or is a full IP - * address; a lone word, a number like 3.14 and an email address are searches. - * A leading `?` forces a search. An entry with a scheme we do not load - * (`file:`) does nothing, as before, rather than searching for it. - */ -export function resolveAddressEntry(input: string): AddressEntry | null { - const typed = input.trim(); - if (typed === "") return null; - if (typed.startsWith("?")) { - const query = typed.slice(1).trim(); - return query === "" ? null : { kind: "search", query }; - } - if (/^[a-z][a-z0-9+.-]*:\/\//i.test(typed)) { - const url = normalizePreviewUrl(typed); - return url === null ? null : { kind: "url", url }; - } - - const search: AddressEntry = { kind: "search", query: typed }; - // As the address is read below: leading slashes are dropped, and a - // backslash separates a path like a slash does. - const bare = typed.replace(/^\/+/, ""); - const authority = bare.split(/[/\\?#]/, 1)[0] ?? ""; - if (authority === "" || /\s/.test(authority)) return search; - const url = normalizePreviewUrl(typed); - if (url === null) return search; - const address: AddressEntry = { kind: "url", url }; - const hasPath = /[/\\]/.test(bare.split(/[?#]/, 1)[0] ?? ""); - if (hasPath || /:\d+$/.test(authority)) return address; - - // A bare name from here on. - if (authority.includes("@")) return search; - if (/^[\d.]+$/.test(authority)) { - // 3.14 parses as an IP address; only four numbers are meant as one. - return /^\d{1,3}(\.\d{1,3}){3}\.?$/.test(authority) ? address : search; - } - // The parsed host, so a name written with other scripts' dots still counts. - const host = new URL(url).hostname; - return host === "localhost" || host.includes(".") || host.startsWith("[") ? address : search; -} diff --git a/packages/shared/src/appPlaces.ts b/packages/shared/src/appPlaces.ts index 1f3a36fcf..b59acae5d 100644 --- a/packages/shared/src/appPlaces.ts +++ b/packages/shared/src/appPlaces.ts @@ -38,7 +38,6 @@ import { RESPONSIVE_PAGE_SIZE_ID, RESPONSIVE_PAGE_SIZE_LABEL, } from "./browserPageSizes.ts"; -import { BROWSER_SEARCH_ENGINE_LABELS } from "./browserSearch.ts"; import { agentInvitesChoice, agentThreadsMode } from "./serverSettings.ts"; import { themeGlassLevel } from "./themes/glassLevel.ts"; @@ -330,6 +329,15 @@ export const OPEN_LINKS_IN_LABELS: Readonly> = { external: "External browser", }; +/** "Browser search engine", as its menu words each choice. */ +export const BROWSER_SEARCH_ENGINE_LABELS: Readonly> = { + google: "Google", + duckduckgo: "DuckDuckGo", + bing: "Bing", + brave: "Brave Search", + kagi: "Kagi", +}; + /** "Browser page size": Responsive and the named sizes, as its menu words them. */ const BROWSER_PAGE_SIZE_LABELS: Readonly> = { [RESPONSIVE_PAGE_SIZE_ID]: RESPONSIVE_PAGE_SIZE_LABEL, @@ -1224,7 +1232,7 @@ const ROWS = [ parent: "general.thisComputer", title: "Browser search engine", about: "Where the browser's address bar searches when you type words instead of an address.", - keywords: ["browser", "search", "address bar", "google", "duckduckgo", "bing", "brave", "kagi"], + keywords: ["address bar", "google", "duckduckgo", "bing", "brave", "kagi"], scope: "device", appliesWhen: "desktop", edit: { diff --git a/packages/shared/src/browserSearch.ts b/packages/shared/src/browserSearch.ts index 3b5b40e97..93cd30cf5 100644 --- a/packages/shared/src/browserSearch.ts +++ b/packages/shared/src/browserSearch.ts @@ -1,18 +1,10 @@ /** - * The search engines the browser panel's address bar can use: the Settings - * row's choices, and where a search for some words goes. + * Where the browser panel's address bar sends a search. The engines' names + * are with the Settings choices (BROWSER_SEARCH_ENGINE_LABELS in appPlaces.ts); + * this is apart from them so only the browser panel loads it. */ import type { BrowserSearchEngine } from "@threadlines/contracts"; -/** "Browser search engine", as its menu words each choice. */ -export const BROWSER_SEARCH_ENGINE_LABELS: Readonly> = { - google: "Google", - duckduckgo: "DuckDuckGo", - bing: "Bing", - brave: "Brave Search", - kagi: "Kagi", -}; - const SEARCH_PAGES: Readonly> = { google: "https://www.google.com/search", duckduckgo: "https://duckduckgo.com/", From d8c8f81649cf37b8730f7813ed6e7a1179447fad Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:37:15 -0500 Subject: [PATCH 6/7] chore(web): raise the startup size budget to 915 KB for the browser search engine setting --- scripts/check-web-bundle-size.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/check-web-bundle-size.ts b/scripts/check-web-bundle-size.ts index 85e90a3d4..8b210d7b0 100644 --- a/scripts/check-web-bundle-size.ts +++ b/scripts/check-web-bundle-size.ts @@ -48,7 +48,13 @@ const DEFAULT_ENTRY_GZIP_BUDGET_BYTES = 255_000; // everything else (color math, art, library, picker) behind lazy() // boundaries. CI's build reads about 1.6 KB above a local one (905,480 B in // CI for a commit measured at 903,835 B here); main measured ~890 KB here. -const DEFAULT_STARTUP_GZIP_BUDGET_BYTES = 912_000; +// Raised to 915 KB (2026-11-12, Will's OK): main sat 49 B under the budget +// (911,951 B in CI), so the browser's search engine setting (#907), about +// 200 B of Settings catalog and schema once its address rules and search URLs +// were moved behind the browser panel, read 912,165 B. Worth trimming next: +// the browser panel's host and automation load at startup on the web and +// phones too, which have no browser panel. +const DEFAULT_STARTUP_GZIP_BUDGET_BYTES = 915_000; const entryBudgetBytes = Number( process.env.THREADLINES_WEB_ENTRY_GZIP_BUDGET_BYTES ?? DEFAULT_ENTRY_GZIP_BUDGET_BYTES, From 55de6436e9e4bef4322750eafa1127e072722777 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:37:32 -0500 Subject: [PATCH 7/7] chore(web): date the startup budget note correctly --- scripts/check-web-bundle-size.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-web-bundle-size.ts b/scripts/check-web-bundle-size.ts index 8b210d7b0..5431208db 100644 --- a/scripts/check-web-bundle-size.ts +++ b/scripts/check-web-bundle-size.ts @@ -48,7 +48,7 @@ const DEFAULT_ENTRY_GZIP_BUDGET_BYTES = 255_000; // everything else (color math, art, library, picker) behind lazy() // boundaries. CI's build reads about 1.6 KB above a local one (905,480 B in // CI for a commit measured at 903,835 B here); main measured ~890 KB here. -// Raised to 915 KB (2026-11-12, Will's OK): main sat 49 B under the budget +// Raised to 915 KB (2026-11-13, Will's OK): main sat 49 B under the budget // (911,951 B in CI), so the browser's search engine setting (#907), about // 200 B of Settings catalog and schema once its address rules and search URLs // were moved behind the browser panel, read 912,165 B. Worth trimming next: