From b9ff7e6d37a87f77fc752a877cf0907ccf1e03bf Mon Sep 17 00:00:00 2001 From: Alex Yaroshuk Date: Mon, 26 Jan 2026 22:24:31 +0800 Subject: [PATCH 1/2] fix: reintroduce external link handling to open links in system browser --- packages/desktop/src/index.tsx | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index fe9e3f92e24..b0ac8330738 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -328,18 +328,23 @@ render(() => { const [serverPassword, setServerPassword] = createSignal(null) const platform = createPlatform(() => serverPassword()) - function handleClick(e: MouseEvent) { - const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null - if (link?.href) { - e.preventDefault() - platform.openLink(link.href) + onMount(() => { + // Handle external links - open in system browser instead of webview + const handleClick = (e: MouseEvent) => { + const target = e.target as HTMLElement + const link = target.closest("a") as HTMLAnchorElement | null + + if (link?.href && !link.href.startsWith("javascript:") && !link.href.startsWith("#")) { + e.preventDefault() + e.stopPropagation() + e.stopImmediatePropagation() + void shellOpen(link.href).catch(() => undefined) + } } - } - onMount(() => { - document.addEventListener("click", handleClick) + document.addEventListener("click", handleClick, true) onCleanup(() => { - document.removeEventListener("click", handleClick) + document.removeEventListener("click", handleClick, true) }) }) From a8839946e697352665dece27adb771fafdd86419 Mon Sep 17 00:00:00 2001 From: Alex Yaroshuk Date: Tue, 27 Jan 2026 07:33:21 +0800 Subject: [PATCH 2/2] add external link interception ,fix origin comparison, add test --- packages/app/e2e/external-links.spec.ts | 67 +++++++++++++++++++++++++ packages/app/src/entry.tsx | 18 +++++++ packages/desktop/src/index.tsx | 21 +++++--- 3 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 packages/app/e2e/external-links.spec.ts diff --git a/packages/app/e2e/external-links.spec.ts b/packages/app/e2e/external-links.spec.ts new file mode 100644 index 00000000000..1f85e6eafcc --- /dev/null +++ b/packages/app/e2e/external-links.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from "./fixtures" + +test("external links are intercepted and don't navigate the app", async ({ page, context, slug, gotoSession }) => { + await gotoSession() + + await page.evaluate(() => { + const link = document.createElement("a") + link.href = "https://opencode.ai" + link.id = "test-external-link" + link.textContent = "External" + link.style.display = "block" + document.body.appendChild(link) + }) + + const currentUrl = page.url() + const popupPromise = context.waitForEvent("page") + await page.click("#test-external-link") + + const popup = await Promise.race([ + popupPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error("Popup did not open")), 2000)), + ]).catch(() => null) + + await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) + expect(page.url()).toBe(currentUrl) + + if (popup) { + expect(popup.url()).toContain("opencode.ai") + } +}) + +test("internal links navigate within the app", async ({ page, slug, gotoSession }) => { + await gotoSession() + + await page.evaluate((slug) => { + const link = document.createElement("a") + link.href = `/${slug}` + link.id = "test-internal-link" + link.textContent = "Internal" + link.style.display = "block" + document.body.appendChild(link) + }, slug) + + await page.click("#test-internal-link") + + await expect(page).toHaveURL(new RegExp(`/${slug}($|/)`)) +}) + +test("localhost links are treated as internal", async ({ page, gotoSession }) => { + await gotoSession() + const initialUrl = page.url() + + await page.evaluate(() => { + const link = document.createElement("a") + link.href = `http://localhost:${window.location.port}/` + link.id = "test-localhost-link" + link.textContent = "Localhost" + link.style.display = "block" + document.body.appendChild(link) + }) + + await page.click("#test-localhost-link") + await page.waitForTimeout(100) + + const newUrl = page.url() + expect(new URL(initialUrl).hostname).toBe(new URL(newUrl).hostname) +}) diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index df8547636b4..0c48cd423a9 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -64,6 +64,24 @@ const platform: Platform = { }, } +document.addEventListener( + "click", + (e) => { + const anchor = (e.target as HTMLElement).closest("a") + if (!anchor) return + + const href = anchor.getAttribute("href") + if (!href) return + + if (href.startsWith("http://") || href.startsWith("https://")) { + e.preventDefault() + e.stopPropagation() + platform.openLink(href) + } + }, + true +) + render( () => ( diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index b0ac8330738..f04edd92e0c 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -334,15 +334,24 @@ render(() => { const target = e.target as HTMLElement const link = target.closest("a") as HTMLAnchorElement | null - if (link?.href && !link.href.startsWith("javascript:") && !link.href.startsWith("#")) { - e.preventDefault() - e.stopPropagation() - e.stopImmediatePropagation() - void shellOpen(link.href).catch(() => undefined) + if (!link?.href) return + + try { + const linkUrl = new URL(link.href, window.location.href) + const isExternal = linkUrl.origin !== window.location.origin + + if (isExternal) { + e.preventDefault() + e.stopPropagation() + e.stopImmediatePropagation() + void shellOpen(link.href).catch(() => undefined) + } + } catch { + // Invalid URL, let it through } } - document.addEventListener("click", handleClick, true) + document.addEventListener("click", handleClick) onCleanup(() => { document.removeEventListener("click", handleClick, true) })