fix(desktop): open external links in system browser (#39820)

This commit is contained in:
Luke Parker 2026-07-31 18:02:11 +10:00 committed by GitHub
parent da59457ca4
commit 2039c90c06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 242 additions and 258 deletions

View file

@ -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=="],

View file

@ -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: {
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div>
{language.t("provider.connect.opencodeZen.visit.prefix")}
<Link
<ExternalLink
href="https://opencode.ai/zen"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.opencodeZen.visit.link")}
</Link>
</ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
@ -886,9 +886,9 @@ function ProviderConnection(props: {
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.visit.prefix")}
<Link href="https://opencode.ai/zen" tabIndex={-1}>
<ExternalLink href="https://opencode.ai/zen" tabIndex={-1}>
{language.t("provider.connect.opencodeZen.visit.link")}
</Link>
</ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
@ -967,9 +967,9 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url} class="text-v2-text-text-base">
<ExternalLink href={store.authorization!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")}
</Link>
</ExternalLink>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
@ -1006,7 +1006,9 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
<ExternalLink href={store.authorization!.url}>
{language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
@ -1077,7 +1079,9 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
<ExternalLink href={store.authorization!.url}>
{language.t("provider.connect.oauth.auto.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField

View file

@ -8,7 +8,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast"
import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
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"
@ -185,9 +185,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base">
{language.t("provider.custom.description.prefix")}
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
<ExternalLink href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
{language.t("provider.custom.description.link")}
</Link>
</ExternalLink>
{language.t("provider.custom.description.suffix")}
</p>

View file

@ -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()
}

View file

@ -0,0 +1,21 @@
import { ComponentProps, splitProps } from "solid-js"
export interface ExternalLinkProps extends Omit<ComponentProps<"a">, "href"> {
href: string
}
export function ExternalLink(props: ExternalLinkProps) {
const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"])
return (
<a
href={local.href}
class={`text-text-strong underline ${local.class ?? ""}`}
target={local.target ?? "_blank"}
rel={local.rel ?? "noopener noreferrer"}
{...rest}
>
{local.children}
</a>
)
}

View file

@ -1,26 +0,0 @@
import { ComponentProps, splitProps } from "solid-js"
import { usePlatform } from "@/context/platform"
export interface LinkProps extends Omit<ComponentProps<"a">, "href"> {
href: string
}
export function Link(props: LinkProps) {
const platform = usePlatform()
const [local, rest] = splitProps(props, ["href", "children", "class"])
return (
<a
href={local.href}
class={`text-text-strong underline ${local.class ?? ""}`}
onClick={(event) => {
if (!local.href) return
event.preventDefault()
platform.openLink(local.href)
}}
{...rest}
>
{local.children}
</a>
)
}

View file

@ -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")}{" "}
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
<ExternalLink href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</ExternalLink>
</>
}
>

View file

@ -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")}{" "}
<Link class="settings-v2-link" href="https://opencode.ai/docs/themes/">
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
{language.t("common.learnMore")}
</Link>
</ExternalLink>
</>
}
>

View file

@ -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(() => {

View file

@ -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 (

View file

@ -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<typeof usePlatform>
settings: ReturnType<typeof useSettings>
language: ReturnType<typeof useLanguage>
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))
}
})
}

View file

@ -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<void>
/** 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<boolean>
/** Restart the app */
restart(): Promise<void>
/** 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<void>
/** Send a system notification */
notify(title: string, description?: string, onClick?: () => void): Promise<void>
/** 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<void>
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
parseMarkdown?(markdown: string): Promise<string>
/** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number>

View file

@ -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 () => {

View file

@ -27,4 +27,3 @@ export {
type WslServersState,
} from "./wsl/types"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"

View file

@ -63,6 +63,7 @@ export function DirectoryDataProvider(
<DataProvider
data={sync().data}
directory={directory}
sessionID={params.id}
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
onSessionHref={href}
>

View file

@ -354,7 +354,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
<button
type="button"
class="flex items-center text-text-interactive-base gap-1"
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
onClick={() => platform.openExternal("https://opencode.ai/desktop-feedback")}
>
<div>{language.t("error.page.report.discord")}</div>
<Icon name="discord" class="text-text-interactive-base" />

View file

@ -120,7 +120,7 @@ export function createHomeProjectsController(home: HomeController) {
},
utility: {
settings: openSettings,
help: () => platform.openLink("https://opencode.ai/desktop-feedback"),
help: () => platform.openExternal("https://opencode.ai/desktop-feedback"),
},
}
}

View file

@ -1,17 +1,13 @@
import { createEffect, Suspense, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar"
import { TabsInfoPopup } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast"
export default function NewLayout(props: ParentProps) {
const platform = usePlatform()
const navigate = useNavigate()
setNavigate(navigate)
const [state, setState] = createStore({ debugTools: true })
createEffect(() => setV2Toast(true))

View file

@ -44,7 +44,6 @@ import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import { playSoundById } from "@/utils/sound"
import { createAim } from "@/utils/aim"
import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
@ -117,7 +116,6 @@ export default function LegacyLayout(props: ParentProps) {
const notification = useNotification()
const permission = usePermission()
const navigate = useNavigate()
setNavigate(navigate)
const providers = useProviders(() => undefined)
const dialog = useDialog()
const command = useCommand()
@ -448,13 +446,13 @@ export default function LegacyLayout(props: ParentProps) {
void playSoundById(settings.sounds.permissions())
}
if (settings.notifications.permissions()) {
void platform.notify(title, description, href)
void platform.notify(title, description, () => navigate(href))
}
}
if (e.details.type === "question.asked") {
if (settings.notifications.agent()) {
void platform.notify(title, description, href)
void platform.notify(title, description, () => navigate(href))
}
}
@ -2251,7 +2249,7 @@ export default function LegacyLayout(props: ParentProps) {
settingsKeybind={() => command.keybind("settings.open")}
onOpenSettings={openSettings}
helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
onOpenHelp={() => platform.openExternal("https://opencode.ai/desktop-feedback")}
renderPanel={() =>
mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged />
}

View file

@ -646,7 +646,7 @@ export function MessageTimeline(props: {
const viewShare = () => {
const url = shareUrl()
if (!url) return
platform.openLink(url)
platform.openExternal(url)
}
const errorMessage = (err: unknown) => {

View file

@ -1,27 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { handleNotificationClick, setNavigate } from "./notification-click"
describe("notification click", () => {
afterEach(() => {
setNavigate(undefined as any)
})
test("navigates via registered navigate function", () => {
const calls: string[] = []
setNavigate((href) => calls.push(href))
handleNotificationClick("/abc/session/123")
expect(calls).toEqual(["/abc/session/123"])
})
test("does not navigate when href is missing", () => {
const calls: string[] = []
setNavigate((href) => calls.push(href))
handleNotificationClick(undefined)
expect(calls).toEqual([])
})
test("falls back to location.assign without registered navigate", () => {
handleNotificationClick("/abc/session/123")
// falls back to window.location.assign — no error thrown
})
})

View file

@ -1,13 +0,0 @@
let nav: ((href: string) => void) | undefined
export const setNavigate = (fn: (href: string) => void) => {
nav = fn
}
export const handleNotificationClick = (href?: string) => {
window.focus()
if (!href) return
if (nav) return nav(href)
console.warn("notification-click: navigate function not set, falling back to window.location.assign")
window.location.assign(href)
}

View file

@ -30,8 +30,7 @@
"electron-log": "^5",
"electron-store": "11.0.2",
"electron-updater": "6.8.9",
"electron-window-state": "^5.0.3",
"marked": "^15"
"electron-window-state": "^5.0.3"
},
"devDependencies": {
"@actions/artifact": "4.0.0",

View file

@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test"
import { resolve } from "node:path"
import { pathToFileURL } from "node:url"
import { resolveExternalURL, resolveLocalFilePath } from "./external-url"
describe("external URLs", () => {
test("opens web URLs externally", () => {
expect(resolveExternalURL("https://example.com/a?b=c")).toBe("https://example.com/a?b=c")
expect(resolveExternalURL("http://example.com")).toBe("http://example.com/")
})
test("opens mail links externally", () => {
expect(resolveExternalURL("mailto:hello@opencode.ai")).toBe("mailto:hello@opencode.ai")
})
test("rejects file URLs and unsupported protocols", () => {
expect(resolveExternalURL("file:///tmp/index.html")).toBeUndefined()
expect(resolveExternalURL("javascript:alert(1)")).toBeUndefined()
expect(resolveExternalURL("data:text/html,hello")).toBeUndefined()
expect(resolveExternalURL("not a url")).toBeUndefined()
})
test("resolves only local file URLs", () => {
const path = resolve("example.html")
expect(resolveLocalFilePath(pathToFileURL(path).href)).toBe(path)
expect(resolveLocalFilePath("file://example.com/share/index.html")).toBeUndefined()
expect(resolveLocalFilePath("https://example.com/index.html")).toBeUndefined()
})
})

View file

@ -0,0 +1,20 @@
import { fileURLToPath } from "node:url"
export function resolveExternalURL(value: string) {
if (!URL.canParse(value)) return undefined
const url = new URL(value)
if (url.protocol === "http:" || url.protocol === "https:" || url.protocol === "mailto:")
return url.href
return undefined
}
export function resolveLocalFilePath(value: string) {
if (!URL.canParse(value)) return undefined
const url = new URL(value)
if (url.protocol !== "file:" || url.hostname) return undefined
try {
return fileURLToPath(url)
} catch {
return undefined
}
}

View file

@ -17,7 +17,6 @@ import { CHANNEL } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
import { forwardInitializationFailure } from "./initialization"
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import {
finishFirstLaunchOnboarding,
@ -292,7 +291,6 @@ const main = Effect.gen(function* () {
isOldLayoutEligible,
getDisplayBackend: async () => null,
setDisplayBackend: async () => undefined,
parseMarkdown: async (markdown) => parseMarkdown(markdown),
checkAppExists: (appName) => checkAppExists(appName),
resolveAppPath: async (appName) => resolveAppPath(appName),
updater,

View file

@ -1,7 +1,7 @@
import { execFile } from "node:child_process"
import { stat } from "node:fs/promises"
import { basename } from "node:path"
import { app, BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
@ -10,7 +10,15 @@ import { runDesktopMenuAction } from "./desktop-menu-actions"
import { setForceFocus } from "./debug"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { getStore, removeStoreFileIfEmpty } from "./store"
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
import {
getPinchZoomEnabled,
getWindowID,
openExternalURL,
openLocalFileURL,
setPinchZoomEnabled,
setTitlebar,
updateTitlebar,
} from "./windows"
import type { UpdaterController } from "./updater-controller"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
@ -33,7 +41,6 @@ type Deps = {
isOldLayoutEligible: () => Promise<boolean> | boolean
getDisplayBackend: () => Promise<string | null>
setDisplayBackend: (backend: string | null) => Promise<void> | void
parseMarkdown: (markdown: string) => Promise<string> | string
checkAppExists: (appName: string) => Promise<boolean> | boolean
resolveAppPath: (appName: string) => Promise<string | null>
updater: UpdaterController
@ -63,7 +70,6 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
deps.setDisplayBackend(backend),
)
ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
ipcMain.handle("updater-subscribe", (event) => {
@ -177,8 +183,12 @@ export function registerIpcHandlers(deps: Deps) {
},
)
ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => {
void shell.openExternal(url)
ipcMain.on("open-external", (_event: IpcMainEvent, url: string) => {
openExternalURL(url)
})
ipcMain.on("open-local-file", (_event: IpcMainEvent, url: string) => {
openLocalFileURL(url)
})
ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => {
@ -208,12 +218,6 @@ export function registerIpcHandlers(deps: Deps) {
return { buffer, width: size.width, height: size.height }
})
ipcMain.on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => {
new Notification({ title, body }).show()
})
ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
ipcMain.handle("get-window-id", (event: IpcMainInvokeEvent) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) throw new Error("Window not found")

View file

@ -1,16 +0,0 @@
import { marked, type Tokens } from "marked"
const renderer = new marked.Renderer()
renderer.link = ({ href, title, text }: Tokens.Link) => {
const titleAttr = title ? ` title="${title}"` : ""
return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`
}
export function parseMarkdown(input: string) {
return marked(input, {
renderer,
breaks: false,
gfm: true,
})
}

View file

@ -1,4 +1,4 @@
import { BrowserWindow, Menu, shell } from "electron"
import { BrowserWindow, Menu } from "electron"
import type { MenuItemConstructorOptions } from "electron"
import {
DESKTOP_MENU,
@ -9,6 +9,7 @@ import {
import { UPDATER_ENABLED } from "./constants"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { openExternalURL } from "./windows"
type Deps = {
trigger: (id: string) => void
@ -56,7 +57,7 @@ function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOpt
}
if (entry.href) {
const href = entry.href
item.click = () => shell.openExternal(href)
item.click = () => openExternalURL(href)
}
return item

View file

@ -4,7 +4,7 @@ import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
import oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json"
import { randomUUID } from "node:crypto"
import { rmSync } from "node:fs"
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron"
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol, shell } from "electron"
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import type { TitlebarTheme } from "../preload/types"
@ -14,6 +14,7 @@ import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys"
import { createUnresponsiveSampler } from "./unresponsive"
import { createWindowRegistry } from "./window-registry"
import { safeWindowURL } from "./window-state"
import { resolveExternalURL, resolveLocalFilePath } from "./external-url"
const root = dirname(fileURLToPath(import.meta.url))
const rendererRoot = join(root, "../renderer")
@ -204,6 +205,7 @@ export function createMainWindow(id: string = randomUUID()) {
allowRendererPermissions(win)
wireWindowRecovery(win, id)
wireNavigationPolicy(win)
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
const { requestHeaders } = details
@ -230,6 +232,40 @@ export function createMainWindow(id: string = randomUUID()) {
return win
}
export function openExternalURL(value: string) {
const url = resolveExternalURL(value)
if (!url) {
writeLog("window", "blocked external target", { url: value }, "warn")
return
}
void shell.openExternal(url)
}
export function openLocalFileURL(value: string) {
const path = resolveLocalFilePath(value)
if (!path) {
writeLog("window", "blocked local file target", { url: value }, "warn")
return
}
void shell.openPath(path).then((error) => {
if (error) writeLog("window", "failed to open local file", { path, error }, "error")
})
}
function wireNavigationPolicy(win: BrowserWindow) {
win.webContents.setWindowOpenHandler(({ url }) => {
if (!isRendererUrl(url)) openExternalURL(url)
return { action: "deny" }
})
// Renderer reloads (window.location.reload) navigate to the app's own URL
// and must stay in-window; everything else leaves through the OS.
win.webContents.on("will-navigate", (event, url) => {
if (isRendererUrl(url)) return
event.preventDefault()
openExternalURL(url)
})
}
function registerWindow(win: BrowserWindow, id: string) {
windowIDs.set(win, id)
registry.register(id, win)

View file

@ -65,7 +65,6 @@ const api: ElectronAPI = {
isOldLayoutEligible: () => ipcRenderer.invoke("is-old-layout-eligible"),
getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),
checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName),
resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName),
storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key),
@ -75,7 +74,6 @@ const api: ElectronAPI = {
storeKeys: (name) => ipcRenderer.invoke("store-keys", name),
storeLength: (name) => ipcRenderer.invoke("store-length", name),
getWindowCount: () => ipcRenderer.invoke("get-window-count"),
getWindowID: () => ipcRenderer.invoke("get-window-id"),
onMenuCommand: (cb) => {
const handler = (_: unknown, id: string) => cb(id)
@ -94,11 +92,11 @@ const api: ElectronAPI = {
releasePickedFiles: (token) => ipcRenderer.invoke("release-picked-files", token),
getPathForFile: (file) => webUtils.getPathForFile(file),
saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
openLink: (url) => ipcRenderer.send("open-link", url),
openExternal: (url) => ipcRenderer.send("open-external", url),
openLocalFile: (url) => ipcRenderer.send("open-local-file", url),
openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
revealPath: (path) => ipcRenderer.invoke("reveal-path", path),
readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"),
showNotification: (title, body) => ipcRenderer.send("show-notification", title, body),
getWindowFocused: () => ipcRenderer.invoke("get-window-focused"),
getWindowFullscreen: () => ipcRenderer.invoke("get-window-fullscreen"),
onWindowFullscreenChanged: (cb) => {

View file

@ -55,7 +55,6 @@ export type ElectronAPI = {
isOldLayoutEligible: () => Promise<boolean>
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
parseMarkdownCommand: (markdown: string) => Promise<string>
checkAppExists: (appName: string) => Promise<boolean>
resolveAppPath: (appName: string) => Promise<string | null>
storeGet: (name: string, key: string) => Promise<string | null>
@ -65,7 +64,6 @@ export type ElectronAPI = {
storeKeys: (name: string) => Promise<string[]>
storeLength: (name: string) => Promise<number>
getWindowCount: () => Promise<number>
getWindowID: () => Promise<string>
onMenuCommand: (cb: (id: string) => void) => () => void
onDeepLink: (cb: (urls: string[]) => void) => () => void
@ -85,11 +83,11 @@ export type ElectronAPI = {
releasePickedFiles: (token: string) => Promise<void>
getPathForFile: (file: File) => string
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
openLink: (url: string) => void
openExternal: (url: string) => void
openLocalFile: (url: string) => void
openPath: (path: string, app?: string) => Promise<void>
revealPath: (path: string) => Promise<boolean>
readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null>
showNotification: (title: string, body?: string) => void
getWindowFocused: () => Promise<boolean>
getWindowFullscreen: () => Promise<boolean>
onWindowFullscreenChanged: (cb: (fullscreen: boolean) => void) => () => void

View file

@ -4,7 +4,6 @@ import {
ACCEPTED_FILE_EXTENSIONS,
AppBaseProviders,
AppInterface,
handleNotificationClick,
loadLocaleDict,
normalizeLocale,
type Locale,
@ -18,11 +17,11 @@ import type { UpdaterState } from "@opencode-ai/app/updater"
import * as Sentry from "@sentry/solid"
import type { AsyncStorage } from "@solid-primitives/storage"
import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, onCleanup, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
import { initializationData, initializationReady } from "./initialization"
import { initializationData } from "./initialization"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
import { windowFullscreen } from "./window-fullscreen"
@ -209,8 +208,11 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
})
},
openLink(url: string) {
window.api.openLink(url)
openExternal(url: string) {
window.api.openExternal(url)
},
openLocalFile(url: string) {
window.api.openLocalFile(url)
},
async openPath(path: string, app?: string) {
if (os === "windows") {
@ -223,14 +225,6 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
return window.api.revealPath(path)
},
back() {
window.history.back()
},
forward() {
window.history.forward()
},
storage,
updater: {
@ -250,7 +244,7 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
window.api.relaunch()
},
notify: async (title, description, href) => {
notify: async (title, description, onClick) => {
const focused = await window.api.getWindowFocused().catch(() => document.hasFocus())
if (focused) return
@ -261,7 +255,7 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
notification.onclick = () => {
void window.api.showWindow()
void window.api.setWindowFocus()
handleNotificationClick(href)
onClick?.()
notification.close()
}
},
@ -291,8 +285,6 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
await window.api.setDisplayBackend(backend)
},
parseMarkdown: (markdown: string) => window.api.parseMarkdownCommand(markdown),
webviewZoom,
windowFullscreen,
@ -346,8 +338,6 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
return next satisfies Locale
}
const [windowCount] = createResource(() => window.api.getWindowCount())
// Fetch sidecar credentials (available immediately, before health check)
const [sidecar] = createResource(() => window.api.awaitInitialization())
@ -358,14 +348,6 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
)
const onboarding = Promise.withResolvers<void>()
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)
}
}
function Inner() {
const cmd = useCommand()
menuTrigger = (id) => cmd.trigger(id)
@ -388,7 +370,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
const wslServers = useWslServers()
const ready = createMemo(
() =>
!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading && !wslServers.isLoading,
!defaultServer.loading && !sidecar.loading && !locale.loading && !wslServers.isLoading,
)
const servers = createMemo(() => {
const data = initializationData(sidecar)
@ -435,13 +417,6 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
)
}
onMount(() => {
document.addEventListener("click", handleClick)
onCleanup(() => {
document.removeEventListener("click", handleClick)
})
})
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale={locale.latest}>

View file

@ -51,7 +51,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:",

View file

@ -62,7 +62,6 @@ import { AnimatedCountList } from "./tool-count-summary"
import { ToolStatusTitle } from "./tool-status-title"
import { patchFiles } from "./apply-patch-file"
import { animate } from "motion"
import { useLocation } from "@solidjs/router"
import { attached, inline, kind, typeLabel } from "./message-file"
import { readPartText } from "./message-part-text"
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
@ -583,29 +582,18 @@ function urls(text: string | undefined) {
})
}
function sessionLink(id: string | undefined, path: string, href?: (id: string) => string | undefined) {
if (!id) return
const direct = href?.(id)
if (direct) return direct
const idx = path.indexOf("/session")
if (idx === -1) return
return `${path.slice(0, idx)}/session/${id}`
}
function currentSession(path: string) {
return path.match(/\/session\/([^/?#]+)/)?.[1]
function sessionLink(id: string | undefined, href?: (id: string) => string | undefined) {
if (!id) return undefined
return href?.(id)
}
function taskSession(
input: Record<string, any>,
path: string,
parentID: string | undefined,
sessions: Session[] | undefined,
agents?: readonly { name: string; color?: string }[],
) {
const parentID = currentSession(path)
if (!parentID) return
if (!parentID) return undefined
const description = typeof input.description === "string" ? input.description : ""
const agent = taskAgent(input.subagent_type, agents).name
return (sessions ?? [])
@ -1575,7 +1563,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
})
const taskHref = createMemo(() => {
if (part().tool !== "task") return
return sessionLink(taskId(), useLocation().pathname, data.sessionHref)
return sessionLink(taskId(), data.sessionHref)
})
const taskSubtitle = createMemo(() => {
if (part().tool !== "task") return undefined
@ -1614,6 +1602,14 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
subtitle={taskSubtitle()}
href={taskHref()}
onSubtitleClick={(event) => {
if (!data.navigateToSession) return
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
const id = taskId()
if (!id) return
event.preventDefault()
data.navigateToSession(id)
}}
/>
)
}}
@ -1994,11 +1990,10 @@ ToolRegistry.register({
render(props) {
const data = useData()
const i18n = useI18n()
const location = useLocation()
const childSessionId = createMemo(() => {
const value = props.metadata.sessionId
if (typeof value === "string" && value) return value
return taskSession(props.input, location.pathname, data.store.session, data.store.agent)
return taskSession(props.input, data.sessionID, data.store.session, data.store.agent)
})
const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent))
const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default"))
@ -2015,18 +2010,13 @@ ToolRegistry.register({
})
const running = createMemo(() => props.status === "pending" || props.status === "running")
const href = createMemo(() => sessionLink(childSessionId(), location.pathname, data.sessionHref))
const href = createMemo(() => sessionLink(childSessionId(), data.sessionHref))
const clickable = createMemo(() => !!(childSessionId() && (data.navigateToSession || href())))
const open = () => {
const id = childSessionId()
if (!id) return
if (data.navigateToSession) {
data.navigateToSession(id)
return
}
const value = href()
if (value) window.location.assign(value)
data.navigateToSession?.(id)
}
const navigate = (event: MouseEvent) => {

View file

@ -16,6 +16,7 @@ export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "c
onOpenChange?: (open: boolean) => void
subtitle?: string
href?: string
onSubtitleClick?: (event: MouseEvent) => void
}
export function ToolErrorCard(props: ToolErrorCardProps) {
@ -35,6 +36,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
"onOpenChange",
"subtitle",
"href",
"onSubtitleClick",
])
const setOpen = (value: boolean) => {
if (props.open === undefined) setState("open", value)
@ -113,7 +115,10 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
data-slot="basic-tool-tool-subtitle"
class="clickable subagent-link"
href={split.href!}
onClick={(e) => e.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
split.onSubtitleClick?.(event)
}}
>
{subtitle()}
</a>

View file

@ -47,6 +47,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
init: (props: {
data: Data
directory: string
sessionID?: string
onNavigateToSession?: NavigateToSessionFn
onSessionHref?: SessionHrefFn
}) => {
@ -57,6 +58,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
get directory() {
return props.directory
},
get sessionID() {
return props.sessionID
},
navigateToSession: props.onNavigateToSession,
sessionHref: props.onSessionHref,
}

View file

@ -2,13 +2,10 @@ import type { Platform } from "../../../../../app/src/context/platform"
const value: Platform = {
platform: "web",
openLink() {},
openExternal() {},
restart: async () => {},
back() {},
forward() {},
notify: async () => {},
fetch: globalThis.fetch.bind(globalThis),
parseMarkdown: async (markdown: string) => markdown,
}
export function usePlatform() {