From 2039c90c06f8431cc32dca1adc716eada02af057 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:02:11 +1000 Subject: [PATCH] fix(desktop): open external links in system browser (#39820) --- bun.lock | 28 +++++------- .../components/dialog-connect-provider.tsx | 22 +++++---- .../src/components/dialog-custom-provider.tsx | 6 +-- .../src/components/dialog-usage-exceeded.tsx | 2 +- packages/app/src/components/external-link.tsx | 21 +++++++++ packages/app/src/components/link.tsx | 26 ----------- .../app/src/components/settings-general.tsx | 4 +- .../src/components/settings-v2/general.tsx | 6 +-- packages/app/src/components/terminal.tsx | 6 ++- .../app/src/components/windows-app-menu.tsx | 2 +- packages/app/src/context/notification.tsx | 13 ++++-- packages/app/src/context/platform.tsx | 20 +++------ packages/app/src/entry.tsx | 25 ++++------- packages/app/src/index.ts | 1 - packages/app/src/pages/directory-layout.tsx | 1 + packages/app/src/pages/error.tsx | 2 +- .../pages/home/home-projects-controller.tsx | 2 +- packages/app/src/pages/layout-new.tsx | 4 -- packages/app/src/pages/layout.tsx | 8 ++-- .../session/timeline/message-timeline.tsx | 2 +- .../app/src/utils/notification-click.test.ts | 27 ----------- packages/app/src/utils/notification-click.ts | 13 ------ packages/desktop/package.json | 3 +- .../desktop/src/main/external-url.test.ts | 29 ++++++++++++ packages/desktop/src/main/external-url.ts | 20 +++++++++ packages/desktop/src/main/index.ts | 2 - packages/desktop/src/main/ipc.ts | 28 +++++++----- packages/desktop/src/main/markdown.ts | 16 ------- packages/desktop/src/main/menu.ts | 5 ++- packages/desktop/src/main/windows.ts | 38 +++++++++++++++- packages/desktop/src/preload/index.ts | 6 +-- packages/desktop/src/preload/types.ts | 6 +-- packages/desktop/src/renderer/index.tsx | 45 +++++-------------- packages/session-ui/package.json | 1 - .../src/components/message-part.tsx | 44 +++++++----------- .../src/components/tool-error-card.tsx | 7 ++- packages/session-ui/src/context/data.tsx | 4 ++ .../.storybook/mocks/app/context/platform.ts | 5 +-- 38 files changed, 242 insertions(+), 258 deletions(-) create mode 100644 packages/app/src/components/external-link.tsx delete mode 100644 packages/app/src/components/link.tsx delete mode 100644 packages/app/src/utils/notification-click.test.ts delete mode 100644 packages/app/src/utils/notification-click.ts create mode 100644 packages/desktop/src/main/external-url.test.ts create mode 100644 packages/desktop/src/main/external-url.ts delete mode 100644 packages/desktop/src/main/markdown.ts diff --git a/bun.lock b/bun.lock index 160958f8da2..e84726d6100 100644 --- a/bun.lock +++ b/bun.lock @@ -390,7 +390,6 @@ "electron-store": "11.0.2", "electron-updater": "6.8.9", "electron-window-state": "^5.0.3", - "marked": "^15", }, "devDependencies": { "@actions/artifact": "4.0.0", @@ -811,7 +810,6 @@ "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "@solidjs/meta": "catalog:", - "@solidjs/router": "catalog:", "diff": "catalog:", "dompurify": "3.3.1", "fuzzysort": "catalog:", @@ -1062,28 +1060,28 @@ }, }, "trustedDependencies": [ - "esbuild", "tree-sitter-powershell", - "protobufjs", - "electron", "web-tree-sitter", "tree-sitter-bash", + "esbuild", + "electron", + "protobufjs", ], "patchedDependencies": { + "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", - "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", - "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "pacote@21.5.0": "patches/pacote@21.5.0.patch", + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", - "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", - "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", - "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", - "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", - "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "pacote@21.5.0": "patches/pacote@21.5.0.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", + "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", }, "overrides": { "@opentui/core": "catalog:", @@ -6045,8 +6043,6 @@ "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], - "@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], - "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], "@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 8291bfc9474..0267b4b4efc 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -29,7 +29,7 @@ import { } from "solid-js" import { createStore, produce } from "solid-js/store" import { useParams } from "@solidjs/router" -import { Link } from "@/components/link" +import { ExternalLink } from "@/components/external-link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" @@ -836,12 +836,12 @@ function ProviderConnection(props: {
{language.t("provider.connect.opencodeZen.line2")}
{language.t("provider.connect.opencodeZen.visit.prefix")} - {language.t("provider.connect.opencodeZen.visit.link")} - + {language.t("provider.connect.opencodeZen.visit.suffix")}
@@ -886,9 +886,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.opencodeZen.line2")}
{language.t("provider.connect.opencodeZen.visit.prefix")} - + {language.t("provider.connect.opencodeZen.visit.link")} - + {language.t("provider.connect.opencodeZen.visit.suffix")}
@@ -967,9 +967,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.oauth.code.visit.prefix")} - + {language.t("provider.connect.oauth.code.visit.link")} - + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
@@ -1006,7 +1006,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.oauth.code.visit.prefix")} - {language.t("provider.connect.oauth.code.visit.link")} + + {language.t("provider.connect.oauth.code.visit.link")} + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
@@ -1077,7 +1079,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.oauth.auto.visit.prefix")} - {language.t("provider.connect.oauth.auto.visit.link")} + + {language.t("provider.connect.oauth.auto.visit.link")} + {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}

{language.t("provider.custom.description.prefix")} - + {language.t("provider.custom.description.link")} - + {language.t("provider.custom.description.suffix")}

diff --git a/packages/app/src/components/dialog-usage-exceeded.tsx b/packages/app/src/components/dialog-usage-exceeded.tsx index e428d4c2bbb..bf5da751e22 100644 --- a/packages/app/src/components/dialog-usage-exceeded.tsx +++ b/packages/app/src/components/dialog-usage-exceeded.tsx @@ -17,7 +17,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) { const platform = usePlatform() const runAction = () => { - if (props.link) platform.openLink(props.link) + if (props.link) platform.openExternal(props.link) props.onClose?.() dialog.close() } diff --git a/packages/app/src/components/external-link.tsx b/packages/app/src/components/external-link.tsx new file mode 100644 index 00000000000..133e752eab3 --- /dev/null +++ b/packages/app/src/components/external-link.tsx @@ -0,0 +1,21 @@ +import { ComponentProps, splitProps } from "solid-js" + +export interface ExternalLinkProps extends Omit, "href"> { + href: string +} + +export function ExternalLink(props: ExternalLinkProps) { + const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"]) + + return ( + + {local.children} + + ) +} diff --git a/packages/app/src/components/link.tsx b/packages/app/src/components/link.tsx deleted file mode 100644 index 85f7efc539e..00000000000 --- a/packages/app/src/components/link.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { ComponentProps, splitProps } from "solid-js" -import { usePlatform } from "@/context/platform" - -export interface LinkProps extends Omit, "href"> { - href: string -} - -export function Link(props: LinkProps) { - const platform = usePlatform() - const [local, rest] = splitProps(props, ["href", "children", "class"]) - - return ( - { - if (!local.href) return - event.preventDefault() - platform.openLink(local.href) - }} - {...rest} - > - {local.children} - - ) -} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index a20e66de3ee..8adaa1f0fdf 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -29,7 +29,7 @@ import { } from "@/context/settings" import { decode64 } from "@/utils/base64" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" -import { Link } from "./link" +import { ExternalLink } from "./external-link" import { SettingsList } from "./settings-list" let demoSoundState = { @@ -482,7 +482,7 @@ export const SettingsGeneral: Component = () => { description={ <> {language.t("settings.general.row.theme.description")}{" "} - {language.t("common.learnMore")} + {language.t("common.learnMore")} } > diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index e50bd316c9a..b41da107ed6 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useUpdaterAction } from "../updater-action" import { useSettings } from "@/context/settings" -import { Link } from "../link" +import { ExternalLink } from "../external-link" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" @@ -150,9 +150,9 @@ const AppearanceSection: Component<{ controller: AppearanceSettingsController }> description={ <> {language.t("settings.general.row.theme.description")}{" "} - + {language.t("common.learnMore")} - + } > diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 65035034760..7d25e7c6490 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -382,7 +382,11 @@ export const Terminal = (props: TerminalProps) => { event.preventDefault() event.stopImmediatePropagation() - platform.openLink(text) + if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) { + platform.openLocalFile(text) + return + } + platform.openExternal(text) } onMount(() => { diff --git a/packages/app/src/components/windows-app-menu.tsx b/packages/app/src/components/windows-app-menu.tsx index 017ac4cf8de..e41c2a584a7 100644 --- a/packages/app/src/components/windows-app-menu.tsx +++ b/packages/app/src/components/windows-app-menu.tsx @@ -43,7 +43,7 @@ export function WindowsAppMenu(props: { runAction(entry.action) return } - if (entry.href) props.platform.openLink(entry.href) + if (entry.href) props.platform.openExternal(entry.href) } return ( diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 9bfa13d854d..fb562a0bf5c 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -1,6 +1,6 @@ import { createStore, reconcile } from "solid-js/store" import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" -import { useParams, useSearchParams } from "@solidjs/router" +import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { createSimpleContext } from "@opencode-ai/ui/context" import type { ServerSDK } from "./server-sdk" import type { ServerSync } from "./server-sync" @@ -118,6 +118,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const global = useGlobal() const server = useServer() const tabs = useTabs() + const navigate = useNavigate() const platform = usePlatform() const settings = useSettings() const language = useLanguage() @@ -153,6 +154,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi platform, settings, language, + navigate, }), }), owner ?? undefined, @@ -217,6 +219,7 @@ function createServerNotificationState(input: { platform: ReturnType settings: ReturnType language: ReturnType + navigate: (href: string) => void }) { const serverSDK = () => input.sdk const serverSync = () => input.sync @@ -353,7 +356,11 @@ function createServerNotificationState(input: { const href = `/${base64Encode(directory)}/session/${sessionID}` if (settings.notifications.agent()) { - void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href) + void platform.notify( + language.t("notification.session.responseReady.title"), + session.title ?? sessionID, + () => input.navigate(href), + ) } }) } @@ -386,7 +393,7 @@ function createServerNotificationState(input: { (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription")) const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` if (settings.notifications.errors()) { - void platform.notify(language.t("notification.session.error.title"), description, href) + void platform.notify(language.t("notification.session.error.title"), description, () => input.navigate(href)) } }) } diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 4c374961a3f..08686361c54 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -31,26 +31,23 @@ type PlatformBase = { /** App version */ version?: string - /** Open a URL in the default browser */ - openLink(url: string): void + /** Open a web or mail URL in the default system application */ + openExternal(url: string): void /** Open a local path in a local app (desktop only) */ openPath?(path: string, app?: string): Promise + /** Open a local file URL in its default app (desktop only) */ + openLocalFile?(url: string): void + /** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */ revealPath?(path: string): Promise /** Restart the app */ restart(): Promise - /** Navigate back in history */ - back(): void - - /** Navigate forward in history */ - forward(): void - - /** Send a system notification (optional deep link) */ - notify(title: string, description?: string, href?: string): Promise + /** Send a system notification */ + notify(title: string, description?: string, onClick?: () => void): Promise /** Open a native attachment picker and read selected files sequentially (desktop only) */ openAttachmentPickerDialog?( @@ -91,9 +88,6 @@ type PlatformBase = { /** Set the preferred display backend (desktop only) */ setDisplayBackend?(backend: DisplayBackend): Promise - /** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */ - parseMarkdown?(markdown: string): Promise - /** Webview zoom level (desktop only) */ webviewZoom?: Accessor diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index c41c1bb4b63..6823d1c6d72 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -6,7 +6,6 @@ import { AppBaseProviders, AppInterface } from "@/app" import { type Platform, PlatformProvider } from "@/context/platform" import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" -import { handleNotificationClick } from "@/utils/notification-click" import { authFromToken } from "@/utils/server" import pkg from "../package.json" import { ServerConnection } from "./context/server" @@ -54,7 +53,7 @@ const setStorage = (key: string, value: string | null) => { const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY) const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url) -const notify: Platform["notify"] = async (title, description, href) => { +const notify: Platform["notify"] = async (title, description, onClick) => { if (!("Notification" in window)) return const permission = @@ -73,21 +72,17 @@ const notify: Platform["notify"] = async (title, description, href) => { }) notification.onclick = () => { - handleNotificationClick(href) + window.focus() + onClick?.() notification.close() } } -const openLink: Platform["openLink"] = (url) => { - window.open(url, "_blank") -} - -const back: Platform["back"] = () => { - window.history.back() -} - -const forward: Platform["forward"] = () => { - window.history.forward() +const openExternal: Platform["openExternal"] = (value) => { + if (!URL.canParse(value)) return + const url = new URL(value) + if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "mailto:") return + window.open(url.href, "_blank", "noopener,noreferrer") } const restart: Platform["restart"] = async () => { @@ -122,9 +117,7 @@ const clearAuthToken = () => { const platform: Platform = { platform: "web", version: pkg.version, - openLink, - back, - forward, + openExternal, restart, notify, getDefaultServer: async () => { diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index d18e05fc9c2..2267ebf0816 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -27,4 +27,3 @@ export { type WslServersState, } from "./wsl/types" export { ServerConnection } from "./context/server" -export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 77c120e57b6..a0b35763965 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -63,6 +63,7 @@ export function DirectoryDataProvider( navigate(href(sessionID))} onSessionHref={href} > diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index 86a997b39d7..2fc60954129 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -354,7 +354,7 @@ export const ErrorPage: Component = (props) => {