From 1c7ba54788740106439dbf4ad16fb410dab65606 Mon Sep 17 00:00:00 2001 From: Judah Fuller Date: Tue, 4 Aug 2026 19:49:26 +0100 Subject: [PATCH 1/8] feat(web): open chat links matching site patterns in the integrated browser Adds an "Integrated browser links" setting (Electron only) where users list host(+path-prefix) patterns like *.github.com or docs.example.com/api. Left-clicking a matching chat link opens it in the integrated browser panel instead of the system browser; modifier- and middle-clicks still open externally. Co-Authored-By: Claude Fable 5 --- .../integratedBrowserLinkPatterns.test.ts | 114 ++++++++++++++++ .../browser/integratedBrowserLinkPatterns.ts | 124 ++++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 41 ++++-- .../IntegratedBrowserLinksSetting.tsx | 102 ++++++++++++++ .../components/settings/SettingsPanels.tsx | 24 ++++ .../src/components/settings/settingsSearch.ts | 5 + packages/contracts/src/settings.test.ts | 18 +++ packages/contracts/src/settings.ts | 8 ++ 8 files changed, 427 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/browser/integratedBrowserLinkPatterns.test.ts create mode 100644 apps/web/src/browser/integratedBrowserLinkPatterns.ts create mode 100644 apps/web/src/components/settings/IntegratedBrowserLinksSetting.tsx diff --git a/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts b/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts new file mode 100644 index 000000000000..86700c180837 --- /dev/null +++ b/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + normalizeIntegratedBrowserUrlPattern, + parseIntegratedBrowserUrlPattern, + urlMatchesIntegratedBrowserPatterns, +} from "./integratedBrowserLinkPatterns"; + +describe("parseIntegratedBrowserUrlPattern", () => { + it("parses host-only patterns, lowercasing and stripping www", () => { + expect(parseIntegratedBrowserUrlPattern(" GitHub.com ")).toEqual({ + host: "github.com", + pathPrefix: null, + }); + expect(parseIntegratedBrowserUrlPattern("www.github.com")).toEqual({ + host: "github.com", + pathPrefix: null, + }); + }); + + it("parses path prefixes, dropping trailing slashes", () => { + expect(parseIntegratedBrowserUrlPattern("docs.example.com/api/")).toEqual({ + host: "docs.example.com", + pathPrefix: "/api", + }); + expect(parseIntegratedBrowserUrlPattern("example.com/")).toEqual({ + host: "example.com", + pathPrefix: null, + }); + }); + + it("rejects schemes, ports, whitespace, malformed hosts, and non-leading wildcards", () => { + for (const raw of [ + "", + " ", + "https://github.com", + "github.com:8080", + "git hub.com", + "/just/a/path", + "héllo.com", + "gist*.github.com", + "*.gist*.github.com", + "*", + ]) { + expect(parseIntegratedBrowserUrlPattern(raw)).toBeNull(); + } + }); +}); + +describe("normalizeIntegratedBrowserUrlPattern", () => { + it("wildcards bare domains and keeps more specific patterns as entered", () => { + expect(normalizeIntegratedBrowserUrlPattern("github.com")).toBe("*.github.com"); + expect(normalizeIntegratedBrowserUrlPattern("GitHub.com/T3")).toBe("*.github.com/T3"); + expect(normalizeIntegratedBrowserUrlPattern("docs.example.com/api/")).toBe( + "docs.example.com/api", + ); + expect(normalizeIntegratedBrowserUrlPattern("*.vercel.app")).toBe("*.vercel.app"); + expect(normalizeIntegratedBrowserUrlPattern("localhost")).toBe("localhost"); + expect(normalizeIntegratedBrowserUrlPattern("https://github.com")).toBeNull(); + }); +}); + +describe("urlMatchesIntegratedBrowserPatterns", () => { + it("matches exact hosts case-insensitively, ignoring ports and www", () => { + expect(urlMatchesIntegratedBrowserPatterns("https://GitHub.com/t3", ["github.com"])).toBe(true); + expect(urlMatchesIntegratedBrowserPatterns("https://www.github.com", ["github.com"])).toBe( + true, + ); + expect(urlMatchesIntegratedBrowserPatterns("https://github.com", ["www.github.com"])).toBe( + true, + ); + expect(urlMatchesIntegratedBrowserPatterns("http://localhost:5173/x", ["localhost"])).toBe( + true, + ); + }); + + it("matches the apex and any subdomain depth for leading wildcards", () => { + const patterns = ["*.github.com"]; + expect(urlMatchesIntegratedBrowserPatterns("https://github.com", patterns)).toBe(true); + expect(urlMatchesIntegratedBrowserPatterns("https://gist.github.com", patterns)).toBe(true); + expect(urlMatchesIntegratedBrowserPatterns("https://a.b.github.com", patterns)).toBe(true); + expect(urlMatchesIntegratedBrowserPatterns("https://notgithub.com", patterns)).toBe(false); + }); + + it("matches plain hosts exactly, without subdomains", () => { + expect( + urlMatchesIntegratedBrowserPatterns("https://docs.example.com", ["docs.example.com"]), + ).toBe(true); + expect( + urlMatchesIntegratedBrowserPatterns("https://v2.docs.example.com", ["docs.example.com"]), + ).toBe(false); + }); + + it("matches path prefixes on segment boundaries only", () => { + const patterns = ["docs.example.com/api"]; + expect(urlMatchesIntegratedBrowserPatterns("https://docs.example.com/api", patterns)).toBe( + true, + ); + expect(urlMatchesIntegratedBrowserPatterns("https://docs.example.com/api/v2", patterns)).toBe( + true, + ); + expect(urlMatchesIntegratedBrowserPatterns("https://docs.example.com/api-keys", patterns)).toBe( + false, + ); + }); + + it("never matches non-http schemes, unparseable hrefs, or invalid patterns", () => { + expect(urlMatchesIntegratedBrowserPatterns("mailto:hi@github.com", ["github.com"])).toBe(false); + expect(urlMatchesIntegratedBrowserPatterns("not a url", ["github.com"])).toBe(false); + expect( + urlMatchesIntegratedBrowserPatterns("https://github.com", ["https://github.com", " "]), + ).toBe(false); + }); +}); diff --git a/apps/web/src/browser/integratedBrowserLinkPatterns.ts b/apps/web/src/browser/integratedBrowserLinkPatterns.ts new file mode 100644 index 000000000000..befead9633ad --- /dev/null +++ b/apps/web/src/browser/integratedBrowserLinkPatterns.ts @@ -0,0 +1,124 @@ +// Matching for the "always open in integrated browser" URL patterns stored in +// `ClientSettings.integratedBrowserUrlPatterns`. Patterns are host names with +// an optional path prefix — `*.github.com`, `docs.example.com/api`. A leading +// `*.` matches the apex domain and any subdomain; a plain host matches only +// itself. Bare domains normalize to `*.domain.com` on entry. No schemes, no +// ports. + +export interface IntegratedBrowserUrlPattern { + /** Lowercased host pattern with any leading `www.` stripped. */ + readonly host: string; + /** Normalized path prefix (leading `/`, no trailing `/`), or null for host-only patterns. */ + readonly pathPrefix: string | null; +} + +const HOST_PATTERN_CHARS = /^[a-z0-9*.-]+$/u; + +function stripWww(host: string): string { + return host.startsWith("www.") ? host.slice(4) : host; +} + +/** + * Parses a raw user-entered pattern. Returns null when the pattern is invalid + * (schemes, ports, whitespace, empty host, or illegal host characters). + */ +export function parseIntegratedBrowserUrlPattern(raw: string): IntegratedBrowserUrlPattern | null { + const trimmed = raw.trim(); + if (trimmed.length === 0 || trimmed.includes(":") || /\s/u.test(trimmed)) { + return null; + } + + const slashIndex = trimmed.indexOf("/"); + const rawHost = slashIndex === -1 ? trimmed : trimmed.slice(0, slashIndex); + const rawPath = slashIndex === -1 ? null : trimmed.slice(slashIndex); + + const host = stripWww(rawHost.toLowerCase()); + if (host.length === 0 || !HOST_PATTERN_CHARS.test(host)) { + return null; + } + // `*` is only meaningful as a leading `*.` wildcard; reject it anywhere else + // so a pattern that would silently never match is flagged at entry. + if (host.includes("*") && (!host.startsWith("*.") || host.slice(2).includes("*"))) { + return null; + } + + if (rawPath === null || rawPath === "/") { + return { host, pathPrefix: null }; + } + return { + host, + pathPrefix: rawPath.endsWith("/") ? rawPath.slice(0, -1) : rawPath, + }; +} + +/** + * Canonical string form for storage and display. A bare domain with no + * subdomain specified becomes `*.domain.com`; anything more specific (a + * subdomain, a wildcard, a single-label host) is kept as entered. Returns + * null for invalid patterns. + */ +export function normalizeIntegratedBrowserUrlPattern(raw: string): string | null { + const pattern = parseIntegratedBrowserUrlPattern(raw); + if (pattern === null) { + return null; + } + const host = + !pattern.host.includes("*") && pattern.host.split(".").length === 2 + ? `*.${pattern.host}` + : pattern.host; + return `${host}${pattern.pathPrefix ?? ""}`; +} + +/** + * A leading `*.` matches the apex domain and any subdomain at any depth: + * `*.github.com` matches `github.com`, `gist.github.com`, `a.b.github.com`. + * A plain host matches only itself. + */ +function hostMatchesPattern(host: string, hostPattern: string): boolean { + if (hostPattern.startsWith("*.")) { + const apex = hostPattern.slice(2); + return host === apex || host.endsWith(`.${apex}`); + } + return host === hostPattern; +} + +function pathMatchesPrefix(pathname: string, prefix: string): boolean { + if (!pathname.startsWith(prefix)) { + return false; + } + const rest = pathname.slice(prefix.length); + return rest.length === 0 || rest.startsWith("/"); +} + +/** + * Returns true when `href` is an http(s) URL whose host (and path, if the + * pattern has one) matches any of the raw patterns. Invalid patterns and + * unparseable hrefs never match. + */ +export function urlMatchesIntegratedBrowserPatterns( + href: string, + patterns: readonly string[], +): boolean { + if (patterns.length === 0) { + return false; + } + + let url: URL; + try { + url = new URL(href); + } catch { + return false; + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + return false; + } + + const host = stripWww(url.hostname.toLowerCase()); + return patterns.some((raw) => { + const pattern = parseIntegratedBrowserUrlPattern(raw); + if (pattern === null || !hostMatchesPattern(host, pattern.host)) { + return false; + } + return pattern.pathPrefix === null || pathMatchesPrefix(url.pathname, pattern.pathPrefix); + }); +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 1335e6bb05b2..ad273a1978c3 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -84,6 +84,7 @@ import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; +import { urlMatchesIntegratedBrowserPatterns } from "../browser/integratedBrowserLinkPatterns"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { isBrowserPreviewFile, @@ -1459,6 +1460,15 @@ function ChatMarkdown({ const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); + const openInPreviewReportingFailure = async (target: string) => { + const result = await openExternalLinkInPreview(target); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportMarkdownActionFailure( + { operation: "open-link-in-preview", target }, + result.cause, + ); + } + }; const link = ( { if (!canOpenInPreview || !href || !faviconHost) return; @@ -1481,15 +1512,7 @@ function ChatMarkdown({ href, position: { x: event.clientX, y: event.clientY }, showContextMenu: (items, position) => api.contextMenu.show(items, position), - openInPreview: async (target) => { - const result = await openExternalLinkInPreview(target); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - reportMarkdownActionFailure( - { operation: "open-link-in-preview", target }, - result.cause, - ); - } - }, + openInPreview: openInPreviewReportingFailure, openExternal: (target) => api.shell.openExternal(target), copyLink: (target) => writeTextToClipboard(target, "link"), reportFailure: (operation, cause) => { diff --git a/apps/web/src/components/settings/IntegratedBrowserLinksSetting.tsx b/apps/web/src/components/settings/IntegratedBrowserLinksSetting.tsx new file mode 100644 index 000000000000..ad8f390e772f --- /dev/null +++ b/apps/web/src/components/settings/IntegratedBrowserLinksSetting.tsx @@ -0,0 +1,102 @@ +import { PlusIcon, XIcon } from "lucide-react"; +import { useState } from "react"; + +import { normalizeIntegratedBrowserUrlPattern } from "../../browser/integratedBrowserLinkPatterns"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; + +interface IntegratedBrowserLinksSettingProps { + readonly patterns: readonly string[]; + readonly onChange: (next: readonly string[]) => void; +} + +/** + * Add/remove editor for the URL patterns that make chat links open in the + * integrated browser panel. Entries are stored and shown in canonical form + * via `normalizeIntegratedBrowserUrlPattern` (bare domains → `*.domain.com`). + */ +export function IntegratedBrowserLinksSetting({ + patterns, + onChange, +}: IntegratedBrowserLinksSettingProps) { + const [input, setInput] = useState(""); + const [error, setError] = useState(null); + + const handleAdd = () => { + const trimmed = input.trim(); + if (trimmed.length === 0) { + setError("Enter a URL pattern."); + return; + } + const normalized = normalizeIntegratedBrowserUrlPattern(trimmed); + if (normalized === null) { + setError( + "Use a host with an optional path, like github.com or docs.example.com/api — no scheme or port.", + ); + return; + } + if (patterns.includes(normalized)) { + setError("That pattern is already in the list."); + return; + } + onChange([...patterns, normalized]); + setInput(""); + setError(null); + }; + + const handleRemove = (pattern: string) => { + onChange(patterns.filter((entry) => entry !== pattern)); + setError(null); + }; + + return ( +
+ {patterns.length > 0 ? ( +
+ {patterns.map((pattern) => ( +
+ {pattern} + +
+ ))} +
+ ) : null} + +
+ { + setInput(event.target.value); + if (error) setError(null); + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + handleAdd(); + }} + placeholder="github.com or *.vercel.app" + spellCheck={false} + aria-label="Integrated browser URL pattern" + /> + +
+ + {error ?

{error}

: null} +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9a5fe3195685..882d08b87c80 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -140,6 +140,7 @@ import { type ProviderUpdateCandidate, } from "../ProviderUpdateLaunchNotification.logic"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; +import { IntegratedBrowserLinksSetting } from "./IntegratedBrowserLinksSetting"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { backgroundActivitySharedPolicySettings, @@ -650,12 +651,14 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), + ...(settings.integratedBrowserUrlPatterns.length > 0 ? ["Integrated browser links"] : []), ...(isTextGenerationModelDirty ? ["Text generation model"] : []), ], [ isTextGenerationModelDirty, isBackgroundActivityDirty, settings.autoOpenPlanSidebar, + settings.integratedBrowserUrlPatterns, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -713,6 +716,7 @@ export function useSettingsRestore(onRestored?: () => void) { addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, + integratedBrowserUrlPatterns: DEFAULT_UNIFIED_SETTINGS.integratedBrowserUrlPatterns, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, @@ -2027,6 +2031,26 @@ export function GeneralSettingsPanel() { } /> + {isElectron ? ( + 0 ? ( + updateSettings({ integratedBrowserUrlPatterns: [] })} + /> + ) : null + } + > + updateSettings({ integratedBrowserUrlPatterns: next })} + /> + + ) : null} + { }); }); +describe("ClientSettings integrated browser url patterns", () => { + it("defaults to an empty list for legacy configs", () => { + expect(decodeClientSettings({}).integratedBrowserUrlPatterns).toEqual([]); + }); + + it("accepts and trims pattern updates", () => { + expect( + decodeClientSettingsPatch({ + integratedBrowserUrlPatterns: ["github.com", " *.vercel.app "], + }).integratedBrowserUrlPatterns, + ).toEqual(["github.com", "*.vercel.app"]); + }); + + it("rejects blank patterns", () => { + expect(() => decodeClientSettings({ integratedBrowserUrlPatterns: [" "] })).toThrow(); + }); +}); + describe("ClientSettings environment identification", () => { it("defaults to artwork and accepts each presentation mode", () => { expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cbb547b95fb8..dda5b18cc15f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -143,6 +143,13 @@ export const ClientSettingsSchema = Schema.Struct({ // Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes); // disabling restores the platform's heavier default. No effect off macOS. fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + // Host(+path-prefix) patterns for chat links that should open in the + // integrated browser panel instead of the system browser. Stored raw; + // parsing/validation lives in the web app so an invalid persisted entry + // can never break settings decoding. + integratedBrowserUrlPatterns: Schema.Array(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal · gpt-5") without @@ -762,6 +769,7 @@ export const ClientSettingsPatch = Schema.Struct({ fontFamilySans: Schema.optionalKey(FontFamilyPreference), fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference), fontSmoothing: Schema.optionalKey(Schema.Boolean), + integratedBrowserUrlPatterns: Schema.optionalKey(Schema.Array(TrimmedNonEmptyString)), favorites: Schema.optionalKey( Schema.Array( Schema.Struct({ From 650b50fc69d2a4a7713030d0a6bccc7dfc1a1595 Mon Sep 17 00:00:00 2001 From: Judah Fuller Date: Tue, 4 Aug 2026 19:55:31 +0100 Subject: [PATCH 2/8] fix(web): reject query/fragment link patterns, allow path colons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patterns match against URL.pathname, so a `?` or `#` could never match — reject them at entry. Dropping the blanket `:` check lets legitimate path colons through; schemes and ports are still caught by the host character check. Also correct two stale doc comments flagged in review. Co-Authored-By: Claude Fable 5 --- .../src/browser/integratedBrowserLinkPatterns.test.ts | 6 ++++++ apps/web/src/browser/integratedBrowserLinkPatterns.ts | 11 +++++++---- .../settings/IntegratedBrowserLinksSetting.tsx | 5 +++-- packages/contracts/src/settings.ts | 6 +++--- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts b/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts index 86700c180837..95aeec5f775b 100644 --- a/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts +++ b/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts @@ -27,6 +27,10 @@ describe("parseIntegratedBrowserUrlPattern", () => { host: "example.com", pathPrefix: null, }); + expect(parseIntegratedBrowserUrlPattern("example.com/docs/v1:api")).toEqual({ + host: "example.com", + pathPrefix: "/docs/v1:api", + }); }); it("rejects schemes, ports, whitespace, malformed hosts, and non-leading wildcards", () => { @@ -38,6 +42,8 @@ describe("parseIntegratedBrowserUrlPattern", () => { "git hub.com", "/just/a/path", "héllo.com", + "example.com/docs?view=full", + "example.com/docs#anchor", "gist*.github.com", "*.gist*.github.com", "*", diff --git a/apps/web/src/browser/integratedBrowserLinkPatterns.ts b/apps/web/src/browser/integratedBrowserLinkPatterns.ts index befead9633ad..899d9b1b0f0c 100644 --- a/apps/web/src/browser/integratedBrowserLinkPatterns.ts +++ b/apps/web/src/browser/integratedBrowserLinkPatterns.ts @@ -2,8 +2,8 @@ // `ClientSettings.integratedBrowserUrlPatterns`. Patterns are host names with // an optional path prefix — `*.github.com`, `docs.example.com/api`. A leading // `*.` matches the apex domain and any subdomain; a plain host matches only -// itself. Bare domains normalize to `*.domain.com` on entry. No schemes, no -// ports. +// itself. Bare domains normalize to `*.domain.com` on entry. No schemes, +// ports, queries, or fragments. export interface IntegratedBrowserUrlPattern { /** Lowercased host pattern with any leading `www.` stripped. */ @@ -20,11 +20,14 @@ function stripWww(host: string): string { /** * Parses a raw user-entered pattern. Returns null when the pattern is invalid - * (schemes, ports, whitespace, empty host, or illegal host characters). + * (schemes, ports, whitespace, queries, fragments, empty host, or illegal + * host characters). Matching compares `URL.pathname`, so `?`/`#` in a + * pattern could never match and are rejected up front. Schemes and ports are + * caught by the host character check — both leave a `:` in the host segment. */ export function parseIntegratedBrowserUrlPattern(raw: string): IntegratedBrowserUrlPattern | null { const trimmed = raw.trim(); - if (trimmed.length === 0 || trimmed.includes(":") || /\s/u.test(trimmed)) { + if (trimmed.length === 0 || /[\s?#]/u.test(trimmed)) { return null; } diff --git a/apps/web/src/components/settings/IntegratedBrowserLinksSetting.tsx b/apps/web/src/components/settings/IntegratedBrowserLinksSetting.tsx index ad8f390e772f..2c0e2e8d3269 100644 --- a/apps/web/src/components/settings/IntegratedBrowserLinksSetting.tsx +++ b/apps/web/src/components/settings/IntegratedBrowserLinksSetting.tsx @@ -12,8 +12,9 @@ interface IntegratedBrowserLinksSettingProps { /** * Add/remove editor for the URL patterns that make chat links open in the - * integrated browser panel. Entries are stored and shown in canonical form - * via `normalizeIntegratedBrowserUrlPattern` (bare domains → `*.domain.com`). + * integrated browser panel. New entries are canonicalized on add via + * `normalizeIntegratedBrowserUrlPattern` (bare domains → `*.domain.com`); + * persisted entries are shown as stored. */ export function IntegratedBrowserLinksSetting({ patterns, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index dda5b18cc15f..13de68832804 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -144,9 +144,9 @@ export const ClientSettingsSchema = Schema.Struct({ // disabling restores the platform's heavier default. No effect off macOS. fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Host(+path-prefix) patterns for chat links that should open in the - // integrated browser panel instead of the system browser. Stored raw; - // parsing/validation lives in the web app so an invalid persisted entry - // can never break settings decoding. + // integrated browser panel instead of the system browser. The schema only + // requires non-empty trimmed strings; pattern syntax is parsed in the web + // app, and an entry it can't parse simply never matches. integratedBrowserUrlPatterns: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), From 673295e01dad2e5102a4685be442dcff0d3dd156 Mon Sep 17 00:00:00 2001 From: Judah Fuller Date: Tue, 4 Aug 2026 19:59:19 +0100 Subject: [PATCH 3/8] fix(web): reject bare `*.` link patterns An empty apex passed the wildcard guard and would match only hosts with a trailing dot. Require a non-empty apex after `*.`. Co-Authored-By: Claude Fable 5 --- .../browser/integratedBrowserLinkPatterns.test.ts | 1 + .../web/src/browser/integratedBrowserLinkPatterns.ts | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts b/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts index 95aeec5f775b..40b01220efcf 100644 --- a/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts +++ b/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts @@ -47,6 +47,7 @@ describe("parseIntegratedBrowserUrlPattern", () => { "gist*.github.com", "*.gist*.github.com", "*", + "*.", ]) { expect(parseIntegratedBrowserUrlPattern(raw)).toBeNull(); } diff --git a/apps/web/src/browser/integratedBrowserLinkPatterns.ts b/apps/web/src/browser/integratedBrowserLinkPatterns.ts index 899d9b1b0f0c..85b3cf094266 100644 --- a/apps/web/src/browser/integratedBrowserLinkPatterns.ts +++ b/apps/web/src/browser/integratedBrowserLinkPatterns.ts @@ -39,10 +39,14 @@ export function parseIntegratedBrowserUrlPattern(raw: string): IntegratedBrowser if (host.length === 0 || !HOST_PATTERN_CHARS.test(host)) { return null; } - // `*` is only meaningful as a leading `*.` wildcard; reject it anywhere else - // so a pattern that would silently never match is flagged at entry. - if (host.includes("*") && (!host.startsWith("*.") || host.slice(2).includes("*"))) { - return null; + // `*` is only meaningful as a leading `*.` wildcard with a non-empty apex; + // reject it anywhere else so a pattern that would silently never match is + // flagged at entry. + if (host.includes("*")) { + const apex = host.startsWith("*.") ? host.slice(2) : ""; + if (apex.length === 0 || apex.includes("*")) { + return null; + } } if (rawPath === null || rawPath === "/") { From 3e0179dff60a528ea060095344745e09426804bc Mon Sep 17 00:00:00 2001 From: Judah Fuller Date: Tue, 4 Aug 2026 20:06:41 +0100 Subject: [PATCH 4/8] fix(web): fall back to the system browser when a preview open fails Left-clicks on matching links prevent default navigation, so a failed integrated-browser open previously left the click dead with only a console error. Mirror the terminal link behavior: fall back to shell.openExternal (or window.open outside Electron). The context-menu "open in preview" action keeps its existing behavior since the user asked for preview explicitly. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/ChatMarkdown.tsx | 35 ++++++++++++++++++------ 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ad273a1978c3..866a4abb6070 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1460,14 +1460,16 @@ function ChatMarkdown({ const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); - const openInPreviewReportingFailure = async (target: string) => { + const openInPreviewReportingFailure = async (target: string): Promise => { const result = await openExternalLinkInPreview(target); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - reportMarkdownActionFailure( - { operation: "open-link-in-preview", target }, - result.cause, - ); + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) { + return true; } + reportMarkdownActionFailure( + { operation: "open-link-in-preview", target }, + result.cause, + ); + return false; }; const link = (
{ + // Default navigation was prevented, so a failed preview + // open must fall back to the system browser. + if (opened) return; + const api = readLocalApi(); + if (api) { + void api.shell.openExternal(href).catch((cause: unknown) => { + reportMarkdownActionFailure( + { operation: "open-link-external", target: href }, + cause, + ); + }); + } else { + window.open(href, "_blank", "noopener,noreferrer"); + } + }); }} onContextMenu={(event) => { if (!canOpenInPreview || !href || !faviconHost) return; @@ -1512,7 +1529,9 @@ function ChatMarkdown({ href, position: { x: event.clientX, y: event.clientY }, showContextMenu: (items, position) => api.contextMenu.show(items, position), - openInPreview: openInPreviewReportingFailure, + openInPreview: async (target) => { + await openInPreviewReportingFailure(target); + }, openExternal: (target) => api.shell.openExternal(target), copyLink: (target) => writeTextToClipboard(target, "link"), reportFailure: (operation, cause) => { From 3ed66fbb2a5d0e4ba3d111fe1e07c656d9ee40d2 Mon Sep 17 00:00:00 2001 From: Judah Fuller Date: Thu, 6 Aug 2026 11:35:24 +0100 Subject: [PATCH 5/8] fix(web): hide desktop-only search entry on web, canonicalize pattern paths Percent-encode pattern path prefixes via URL so non-ASCII paths match the encoded URL.pathname they are compared against, and filter the desktop-only integrated-browser-links entry out of settings search when not in Electron. Co-Authored-By: Claude Fable 5 --- .../integratedBrowserLinkPatterns.test.ts | 17 +++++++++++++++++ .../browser/integratedBrowserLinkPatterns.ts | 13 ++++++++++++- .../src/components/settings/settingsSearch.ts | 12 +++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts b/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts index 40b01220efcf..091cd3550d56 100644 --- a/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts +++ b/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts @@ -33,6 +33,13 @@ describe("parseIntegratedBrowserUrlPattern", () => { }); }); + it("canonicalizes path prefixes to the URL.pathname representation", () => { + expect(parseIntegratedBrowserUrlPattern("example.com/über")).toEqual({ + host: "example.com", + pathPrefix: "/%C3%BCber", + }); + }); + it("rejects schemes, ports, whitespace, malformed hosts, and non-leading wildcards", () => { for (const raw of [ "", @@ -111,6 +118,16 @@ describe("urlMatchesIntegratedBrowserPatterns", () => { ); }); + it("matches non-ASCII path prefixes against percent-encoded pathnames", () => { + const patterns = ["example.com/über"]; + expect(urlMatchesIntegratedBrowserPatterns("https://example.com/über/docs", patterns)).toBe( + true, + ); + expect(urlMatchesIntegratedBrowserPatterns("https://example.com/%C3%BCber", patterns)).toBe( + true, + ); + }); + it("never matches non-http schemes, unparseable hrefs, or invalid patterns", () => { expect(urlMatchesIntegratedBrowserPatterns("mailto:hi@github.com", ["github.com"])).toBe(false); expect(urlMatchesIntegratedBrowserPatterns("not a url", ["github.com"])).toBe(false); diff --git a/apps/web/src/browser/integratedBrowserLinkPatterns.ts b/apps/web/src/browser/integratedBrowserLinkPatterns.ts index 85b3cf094266..da137bb93a0e 100644 --- a/apps/web/src/browser/integratedBrowserLinkPatterns.ts +++ b/apps/web/src/browser/integratedBrowserLinkPatterns.ts @@ -52,9 +52,20 @@ export function parseIntegratedBrowserUrlPattern(raw: string): IntegratedBrowser if (rawPath === null || rawPath === "/") { return { host, pathPrefix: null }; } + // Canonicalize through URL so the prefix uses the same representation as + // the `URL.pathname` it is compared against (e.g. `/über` → `/%C3%BCber`). + let pathname: string; + try { + pathname = new URL(`https://h${rawPath}`).pathname; + } catch { + return null; + } + if (pathname === "/") { + return { host, pathPrefix: null }; + } return { host, - pathPrefix: rawPath.endsWith("/") ? rawPath.slice(0, -1) : rawPath, + pathPrefix: pathname.endsWith("/") ? pathname.slice(0, -1) : pathname, }; } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index e3663859ed7f..c795521940fc 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1,3 +1,5 @@ +import { isElectron } from "../../env"; + export type SettingsPath = | "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/settings/general" | "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/settings/appearance" @@ -13,6 +15,8 @@ export interface SettingsSearchItem { readonly title: string; readonly to: SettingsPath; readonly targetId?: string; + /** Rendered only in the desktop app; hidden from search elsewhere. */ + readonly electronOnly?: boolean; } /** @@ -135,6 +139,7 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "integrated-browser-links", title: "Integrated browser links", to: "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/settings/general", + electronOnly: true, }, { id: "archive-confirmation", @@ -222,9 +227,14 @@ function normalizeSearchText(value: string): string { .trim(); } +const AVAILABLE_SETTINGS_SEARCH_ITEMS: ReadonlyArray = + SETTINGS_SEARCH_ITEMS.filter( + (item: SettingsSearchItem) => isElectron || item.electronOnly !== true, + ); + export function searchSettings( query: string, - items: ReadonlyArray = SETTINGS_SEARCH_ITEMS, + items: ReadonlyArray = AVAILABLE_SETTINGS_SEARCH_ITEMS, ): ReadonlyArray { const normalizedQuery = normalizeSearchText(query); if (normalizedQuery.length === 0) return []; From 96e69448f98bda2c2db2f12922a8a9a520d75439 Mon Sep 17 00:00:00 2001 From: Judah Fuller Date: Thu, 6 Aug 2026 11:39:26 +0100 Subject: [PATCH 6/8] fix(web): validate wildcard placement before www-stripping, normalize escape casing Reject `www.*.example.com` instead of silently widening it to `*.example.com`, and uppercase percent-escape hex on both the pattern prefix and the compared pathname so equivalent escapes match. Co-Authored-By: Claude Fable 5 --- .../integratedBrowserLinkPatterns.test.ts | 7 +++++ .../browser/integratedBrowserLinkPatterns.ts | 31 ++++++++++++++----- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts b/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts index 091cd3550d56..eb7de76e60aa 100644 --- a/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts +++ b/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts @@ -53,6 +53,7 @@ describe("parseIntegratedBrowserUrlPattern", () => { "example.com/docs#anchor", "gist*.github.com", "*.gist*.github.com", + "www.*.example.com", "*", "*.", ]) { @@ -126,6 +127,12 @@ describe("urlMatchesIntegratedBrowserPatterns", () => { expect(urlMatchesIntegratedBrowserPatterns("https://example.com/%C3%BCber", patterns)).toBe( true, ); + expect(urlMatchesIntegratedBrowserPatterns("https://example.com/%c3%bcber", patterns)).toBe( + true, + ); + expect( + urlMatchesIntegratedBrowserPatterns("https://example.com/über", ["example.com/%c3%bcber"]), + ).toBe(true); }); it("never matches non-http schemes, unparseable hrefs, or invalid patterns", () => { diff --git a/apps/web/src/browser/integratedBrowserLinkPatterns.ts b/apps/web/src/browser/integratedBrowserLinkPatterns.ts index da137bb93a0e..fe90e1c7980e 100644 --- a/apps/web/src/browser/integratedBrowserLinkPatterns.ts +++ b/apps/web/src/browser/integratedBrowserLinkPatterns.ts @@ -18,6 +18,15 @@ function stripWww(host: string): string { return host.startsWith("www.") ? host.slice(4) : host; } +/** + * Uppercases percent-escape hex digits so equivalent escapes compare equal + * (`URL.pathname` preserves the casing it was given, so `/%c3%bcber` and + * `/%C3%BCber` would otherwise never prefix-match). + */ +function normalizePercentEscapes(pathname: string): string { + return pathname.replace(/%[0-9a-f]{2}/giu, (escape) => escape.toUpperCase()); +} + /** * Parses a raw user-entered pattern. Returns null when the pattern is invalid * (schemes, ports, whitespace, queries, fragments, empty host, or illegal @@ -35,19 +44,24 @@ export function parseIntegratedBrowserUrlPattern(raw: string): IntegratedBrowser const rawHost = slashIndex === -1 ? trimmed : trimmed.slice(0, slashIndex); const rawPath = slashIndex === -1 ? null : trimmed.slice(slashIndex); - const host = stripWww(rawHost.toLowerCase()); - if (host.length === 0 || !HOST_PATTERN_CHARS.test(host)) { + const lowerHost = rawHost.toLowerCase(); + if (!HOST_PATTERN_CHARS.test(lowerHost)) { return null; } // `*` is only meaningful as a leading `*.` wildcard with a non-empty apex; // reject it anywhere else so a pattern that would silently never match is - // flagged at entry. - if (host.includes("*")) { - const apex = host.startsWith("*.") ? host.slice(2) : ""; + // flagged at entry. Validated before `www.` stripping — stripping first + // would silently widen `www.*.example.com` into a valid `*.example.com`. + if (lowerHost.includes("*")) { + const apex = lowerHost.startsWith("*.") ? lowerHost.slice(2) : ""; if (apex.length === 0 || apex.includes("*")) { return null; } } + const host = stripWww(lowerHost); + if (host.length === 0) { + return null; + } if (rawPath === null || rawPath === "/") { return { host, pathPrefix: null }; @@ -56,7 +70,7 @@ export function parseIntegratedBrowserUrlPattern(raw: string): IntegratedBrowser // the `URL.pathname` it is compared against (e.g. `/über` → `/%C3%BCber`). let pathname: string; try { - pathname = new URL(`https://h${rawPath}`).pathname; + pathname = normalizePercentEscapes(new URL(`https://h${rawPath}`).pathname); } catch { return null; } @@ -137,6 +151,9 @@ export function urlMatchesIntegratedBrowserPatterns( if (pattern === null || !hostMatchesPattern(host, pattern.host)) { return false; } - return pattern.pathPrefix === null || pathMatchesPrefix(url.pathname, pattern.pathPrefix); + return ( + pattern.pathPrefix === null || + pathMatchesPrefix(normalizePercentEscapes(url.pathname), pattern.pathPrefix) + ); }); } From bc509e57a98c44521682acf356eff4d502cd9c99 Mon Sep 17 00:00:00 2001 From: Judah Fuller Date: Thu, 6 Aug 2026 11:42:45 +0100 Subject: [PATCH 7/8] fix(web): reject hosts with empty or hyphen-edged labels Patterns like `example..com` or `-example.com` passed the character check but could never match a real hostname, leaving them silently dead. Co-Authored-By: Claude Fable 5 --- apps/web/src/browser/integratedBrowserLinkPatterns.test.ts | 5 +++++ apps/web/src/browser/integratedBrowserLinkPatterns.ts | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts b/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts index eb7de76e60aa..93706b1990f7 100644 --- a/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts +++ b/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts @@ -54,6 +54,11 @@ describe("parseIntegratedBrowserUrlPattern", () => { "gist*.github.com", "*.gist*.github.com", "www.*.example.com", + "example..com", + ".example.com", + "example.com.", + "-example.com", + "example-.com", "*", "*.", ]) { diff --git a/apps/web/src/browser/integratedBrowserLinkPatterns.ts b/apps/web/src/browser/integratedBrowserLinkPatterns.ts index fe90e1c7980e..42ab26f72540 100644 --- a/apps/web/src/browser/integratedBrowserLinkPatterns.ts +++ b/apps/web/src/browser/integratedBrowserLinkPatterns.ts @@ -62,6 +62,13 @@ export function parseIntegratedBrowserUrlPattern(raw: string): IntegratedBrowser if (host.length === 0) { return null; } + // Reject structurally invalid hosts (`example..com`, `.example.com`, + // `-example.com`, `example.com.`) — they pass the character check but can + // never match a real link hostname, so the pattern would be silently dead. + const labels = (host.startsWith("*.") ? host.slice(2) : host).split("."); + if (labels.some((label) => label.length === 0 || label.startsWith("-") || label.endsWith("-"))) { + return null; + } if (rawPath === null || rawPath === "/") { return { host, pathPrefix: null }; From 80af8e43cf3558be7ea670acbd90c06012e1d40e Mon Sep 17 00:00:00 2001 From: Judah Fuller Date: Thu, 6 Aug 2026 14:54:35 +0100 Subject: [PATCH 8/8] fix(web): match wildcard apexes that name www against the raw hostname `*.www.example.com` failed to match `www.example.com` itself because the link hostname was www-stripped before comparison; now both the raw and stripped forms are checked. Co-Authored-By: Claude Fable 5 --- .../browser/integratedBrowserLinkPatterns.test.ts | 7 +++++++ .../web/src/browser/integratedBrowserLinkPatterns.ts | 12 ++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts b/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts index 93706b1990f7..774abe8ce7cd 100644 --- a/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts +++ b/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts @@ -102,6 +102,13 @@ describe("urlMatchesIntegratedBrowserPatterns", () => { expect(urlMatchesIntegratedBrowserPatterns("https://notgithub.com", patterns)).toBe(false); }); + it("matches wildcard apexes that name www against the unstripped hostname", () => { + const patterns = ["*.www.example.com"]; + expect(urlMatchesIntegratedBrowserPatterns("https://www.example.com", patterns)).toBe(true); + expect(urlMatchesIntegratedBrowserPatterns("https://a.www.example.com", patterns)).toBe(true); + expect(urlMatchesIntegratedBrowserPatterns("https://example.com", patterns)).toBe(false); + }); + it("matches plain hosts exactly, without subdomains", () => { expect( urlMatchesIntegratedBrowserPatterns("https://docs.example.com", ["docs.example.com"]), diff --git a/apps/web/src/browser/integratedBrowserLinkPatterns.ts b/apps/web/src/browser/integratedBrowserLinkPatterns.ts index 42ab26f72540..88481f43a480 100644 --- a/apps/web/src/browser/integratedBrowserLinkPatterns.ts +++ b/apps/web/src/browser/integratedBrowserLinkPatterns.ts @@ -152,10 +152,18 @@ export function urlMatchesIntegratedBrowserPatterns( return false; } - const host = stripWww(url.hostname.toLowerCase()); + // Match both the raw and www-stripped hostname: stripping lets `example.com` + // patterns match `www.example.com` links, while the raw form keeps patterns + // that name `www` explicitly (e.g. a `*.www.example.com` apex) working. + const rawHostname = url.hostname.toLowerCase(); + const strippedHost = stripWww(rawHostname); return patterns.some((raw) => { const pattern = parseIntegratedBrowserUrlPattern(raw); - if (pattern === null || !hostMatchesPattern(host, pattern.host)) { + if ( + pattern === null || + (!hostMatchesPattern(strippedHost, pattern.host) && + !hostMatchesPattern(rawHostname, pattern.host)) + ) { return false; } return (