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
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-store": "11.0.2",
"electron-updater": "6.8.9", "electron-updater": "6.8.9",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"marked": "^15",
}, },
"devDependencies": { "devDependencies": {
"@actions/artifact": "4.0.0", "@actions/artifact": "4.0.0",
@ -811,7 +810,6 @@
"@solid-primitives/media": "2.3.3", "@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"diff": "catalog:", "diff": "catalog:",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
@ -1062,28 +1060,28 @@
}, },
}, },
"trustedDependencies": [ "trustedDependencies": [
"esbuild",
"tree-sitter-powershell", "tree-sitter-powershell",
"protobufjs",
"electron",
"web-tree-sitter", "web-tree-sitter",
"tree-sitter-bash", "tree-sitter-bash",
"esbuild",
"electron",
"protobufjs",
], ],
"patchedDependencies": { "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", "@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", "pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.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", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.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", "@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", "@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": { "overrides": {
"@opentui/core": "catalog:", "@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/@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/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=="], "@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" } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { Link } from "@/components/link" import { ExternalLink } from "@/components/external-link"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" 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.line2")}</div>
<div> <div>
{language.t("provider.connect.opencodeZen.visit.prefix")} {language.t("provider.connect.opencodeZen.visit.prefix")}
<Link <ExternalLink
href="https://opencode.ai/zen" 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" 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")} {language.t("provider.connect.opencodeZen.visit.link")}
</Link> </ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")} {language.t("provider.connect.opencodeZen.visit.suffix")}
</div> </div>
</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.line2")}</div>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.visit.prefix")} {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")} {language.t("provider.connect.opencodeZen.visit.link")}
</Link> </ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")} {language.t("provider.connect.opencodeZen.visit.suffix")}
</div> </div>
</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 class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div> <div>
{language.t("provider.connect.oauth.code.visit.prefix")} {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")} {language.t("provider.connect.oauth.code.visit.link")}
</Link> </ExternalLink>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div> </div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch"> <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="flex flex-col gap-6">
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")} {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 })} {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div> </div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> <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="flex flex-col gap-6">
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")} {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 })} {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div> </div>
<TextField <TextField

View file

@ -8,7 +8,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { batch, For } from "solid-js" import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store" 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 { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" 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"> <form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base"> <p class="text-14-regular text-text-base">
{language.t("provider.custom.description.prefix")} {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")} {language.t("provider.custom.description.link")}
</Link> </ExternalLink>
{language.t("provider.custom.description.suffix")} {language.t("provider.custom.description.suffix")}
</p> </p>

View file

@ -17,7 +17,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
const platform = usePlatform() const platform = usePlatform()
const runAction = () => { const runAction = () => {
if (props.link) platform.openLink(props.link) if (props.link) platform.openExternal(props.link)
props.onClose?.() props.onClose?.()
dialog.close() 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" } from "@/context/settings"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link" import { ExternalLink } from "./external-link"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
let demoSoundState = { let demoSoundState = {
@ -482,7 +482,7 @@ export const SettingsGeneral: Component = () => {
description={ description={
<> <>
{language.t("settings.general.row.theme.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 { usePlatform } from "@/context/platform"
import { useUpdaterAction } from "../updater-action" import { useUpdaterAction } from "../updater-action"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { Link } from "../link" import { ExternalLink } from "../external-link"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row" import { SettingsRowV2 } from "./parts/row"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
@ -150,9 +150,9 @@ const AppearanceSection: Component<{ controller: AppearanceSettingsController }>
description={ description={
<> <>
{language.t("settings.general.row.theme.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")} {language.t("common.learnMore")}
</Link> </ExternalLink>
</> </>
} }
> >

View file

@ -382,7 +382,11 @@ export const Terminal = (props: TerminalProps) => {
event.preventDefault() event.preventDefault()
event.stopImmediatePropagation() event.stopImmediatePropagation()
platform.openLink(text) if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) {
platform.openLocalFile(text)
return
}
platform.openExternal(text)
} }
onMount(() => { onMount(() => {

View file

@ -43,7 +43,7 @@ export function WindowsAppMenu(props: {
runAction(entry.action) runAction(entry.action)
return return
} }
if (entry.href) props.platform.openLink(entry.href) if (entry.href) props.platform.openExternal(entry.href)
} }
return ( return (

View file

@ -1,6 +1,6 @@
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" 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 { createSimpleContext } from "@opencode-ai/ui/context"
import type { ServerSDK } from "./server-sdk" import type { ServerSDK } from "./server-sdk"
import type { ServerSync } from "./server-sync" import type { ServerSync } from "./server-sync"
@ -118,6 +118,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const global = useGlobal() const global = useGlobal()
const server = useServer() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
const navigate = useNavigate()
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings() const settings = useSettings()
const language = useLanguage() const language = useLanguage()
@ -153,6 +154,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
platform, platform,
settings, settings,
language, language,
navigate,
}), }),
}), }),
owner ?? undefined, owner ?? undefined,
@ -217,6 +219,7 @@ function createServerNotificationState(input: {
platform: ReturnType<typeof usePlatform> platform: ReturnType<typeof usePlatform>
settings: ReturnType<typeof useSettings> settings: ReturnType<typeof useSettings>
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
navigate: (href: string) => void
}) { }) {
const serverSDK = () => input.sdk const serverSDK = () => input.sdk
const serverSync = () => input.sync const serverSync = () => input.sync
@ -353,7 +356,11 @@ function createServerNotificationState(input: {
const href = `/${base64Encode(directory)}/session/${sessionID}` const href = `/${base64Encode(directory)}/session/${sessionID}`
if (settings.notifications.agent()) { 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")) (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
if (settings.notifications.errors()) { 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 */ /** App version */
version?: string version?: string
/** Open a URL in the default browser */ /** Open a web or mail URL in the default system application */
openLink(url: string): void openExternal(url: string): void
/** Open a local path in a local app (desktop only) */ /** Open a local path in a local app (desktop only) */
openPath?(path: string, app?: string): Promise<void> 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) */ /** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
revealPath?(path: string): Promise<boolean> revealPath?(path: string): Promise<boolean>
/** Restart the app */ /** Restart the app */
restart(): Promise<void> restart(): Promise<void>
/** Navigate back in history */ /** Send a system notification */
back(): void notify(title: string, description?: string, onClick?: () => void): Promise<void>
/** Navigate forward in history */
forward(): void
/** Send a system notification (optional deep link) */
notify(title: string, description?: string, href?: string): Promise<void>
/** Open a native attachment picker and read selected files sequentially (desktop only) */ /** Open a native attachment picker and read selected files sequentially (desktop only) */
openAttachmentPickerDialog?( openAttachmentPickerDialog?(
@ -91,9 +88,6 @@ type PlatformBase = {
/** Set the preferred display backend (desktop only) */ /** Set the preferred display backend (desktop only) */
setDisplayBackend?(backend: DisplayBackend): Promise<void> 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) */ /** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number> webviewZoom?: Accessor<number>

View file

@ -6,7 +6,6 @@ import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform" import { type Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh" import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click"
import { authFromToken } from "@/utils/server" import { authFromToken } from "@/utils/server"
import pkg from "../package.json" import pkg from "../package.json"
import { ServerConnection } from "./context/server" import { ServerConnection } from "./context/server"
@ -54,7 +53,7 @@ const setStorage = (key: string, value: string | null) => {
const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY) const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY)
const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url) 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 if (!("Notification" in window)) return
const permission = const permission =
@ -73,21 +72,17 @@ const notify: Platform["notify"] = async (title, description, href) => {
}) })
notification.onclick = () => { notification.onclick = () => {
handleNotificationClick(href) window.focus()
onClick?.()
notification.close() notification.close()
} }
} }
const openLink: Platform["openLink"] = (url) => { const openExternal: Platform["openExternal"] = (value) => {
window.open(url, "_blank") if (!URL.canParse(value)) return
} const url = new URL(value)
if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "mailto:") return
const back: Platform["back"] = () => { window.open(url.href, "_blank", "noopener,noreferrer")
window.history.back()
}
const forward: Platform["forward"] = () => {
window.history.forward()
} }
const restart: Platform["restart"] = async () => { const restart: Platform["restart"] = async () => {
@ -122,9 +117,7 @@ const clearAuthToken = () => {
const platform: Platform = { const platform: Platform = {
platform: "web", platform: "web",
version: pkg.version, version: pkg.version,
openLink, openExternal,
back,
forward,
restart, restart,
notify, notify,
getDefaultServer: async () => { getDefaultServer: async () => {

View file

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

View file

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

View file

@ -354,7 +354,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
<button <button
type="button" type="button"
class="flex items-center text-text-interactive-base gap-1" 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> <div>{language.t("error.page.report.discord")}</div>
<Icon name="discord" class="text-text-interactive-base" /> <Icon name="discord" class="text-text-interactive-base" />

View file

@ -120,7 +120,7 @@ export function createHomeProjectsController(home: HomeController) {
}, },
utility: { utility: {
settings: openSettings, 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 { createEffect, Suspense, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar" import { DebugBar } from "@/components/debug-bar"
import { TabsInfoPopup } from "@/components/help-button" import { TabsInfoPopup } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast" import { setV2Toast, ToastRegion } from "@/utils/toast"
export default function NewLayout(props: ParentProps) { export default function NewLayout(props: ParentProps) {
const platform = usePlatform() const platform = usePlatform()
const navigate = useNavigate()
setNavigate(navigate)
const [state, setState] = createStore({ debugTools: true }) const [state, setState] = createStore({ debugTools: true })
createEffect(() => setV2Toast(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 { retry } from "@opencode-ai/core/util/retry"
import { playSoundById } from "@/utils/sound" import { playSoundById } from "@/utils/sound"
import { createAim } from "@/utils/aim" import { createAim } from "@/utils/aim"
import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff" import { setSessionHandoff } from "@/pages/session/handoff"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
@ -117,7 +116,6 @@ export default function LegacyLayout(props: ParentProps) {
const notification = useNotification() const notification = useNotification()
const permission = usePermission() const permission = usePermission()
const navigate = useNavigate() const navigate = useNavigate()
setNavigate(navigate)
const providers = useProviders(() => undefined) const providers = useProviders(() => undefined)
const dialog = useDialog() const dialog = useDialog()
const command = useCommand() const command = useCommand()
@ -448,13 +446,13 @@ export default function LegacyLayout(props: ParentProps) {
void playSoundById(settings.sounds.permissions()) void playSoundById(settings.sounds.permissions())
} }
if (settings.notifications.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 (e.details.type === "question.asked") {
if (settings.notifications.agent()) { 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")} settingsKeybind={() => command.keybind("settings.open")}
onOpenSettings={openSettings} onOpenSettings={openSettings}
helpLabel={() => language.t("sidebar.help")} helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} onOpenHelp={() => platform.openExternal("https://opencode.ai/desktop-feedback")}
renderPanel={() => renderPanel={() =>
mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged /> mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged />
} }

View file

@ -646,7 +646,7 @@ export function MessageTimeline(props: {
const viewShare = () => { const viewShare = () => {
const url = shareUrl() const url = shareUrl()
if (!url) return if (!url) return
platform.openLink(url) platform.openExternal(url)
} }
const errorMessage = (err: unknown) => { 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-log": "^5",
"electron-store": "11.0.2", "electron-store": "11.0.2",
"electron-updater": "6.8.9", "electron-updater": "6.8.9",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3"
"marked": "^15"
}, },
"devDependencies": { "devDependencies": {
"@actions/artifact": "4.0.0", "@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 { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
import { forwardInitializationFailure } from "./initialization" import { forwardInitializationFailure } from "./initialization"
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging" import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu" import { createMenu } from "./menu"
import { import {
finishFirstLaunchOnboarding, finishFirstLaunchOnboarding,
@ -292,7 +291,6 @@ const main = Effect.gen(function* () {
isOldLayoutEligible, isOldLayoutEligible,
getDisplayBackend: async () => null, getDisplayBackend: async () => null,
setDisplayBackend: async () => undefined, setDisplayBackend: async () => undefined,
parseMarkdown: async (markdown) => parseMarkdown(markdown),
checkAppExists: (appName) => checkAppExists(appName), checkAppExists: (appName) => checkAppExists(appName),
resolveAppPath: async (appName) => resolveAppPath(appName), resolveAppPath: async (appName) => resolveAppPath(appName),
updater, updater,

View file

@ -1,7 +1,7 @@
import { execFile } from "node:child_process" import { execFile } from "node:child_process"
import { stat } from "node:fs/promises" import { stat } from "node:fs/promises"
import { basename } from "node:path" 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 { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
@ -10,7 +10,15 @@ import { runDesktopMenuAction } from "./desktop-menu-actions"
import { setForceFocus } from "./debug" import { setForceFocus } from "./debug"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker" import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { getStore, removeStoreFileIfEmpty } from "./store" 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 type { UpdaterController } from "./updater-controller"
import { createUpdaterSubscriptions } from "./updater-subscriptions" import { createUpdaterSubscriptions } from "./updater-subscriptions"
@ -33,7 +41,6 @@ type Deps = {
isOldLayoutEligible: () => Promise<boolean> | boolean isOldLayoutEligible: () => Promise<boolean> | boolean
getDisplayBackend: () => Promise<string | null> getDisplayBackend: () => Promise<string | null>
setDisplayBackend: (backend: string | null) => Promise<void> | void setDisplayBackend: (backend: string | null) => Promise<void> | void
parseMarkdown: (markdown: string) => Promise<string> | string
checkAppExists: (appName: string) => Promise<boolean> | boolean checkAppExists: (appName: string) => Promise<boolean> | boolean
resolveAppPath: (appName: string) => Promise<string | null> resolveAppPath: (appName: string) => Promise<string | null>
updater: UpdaterController updater: UpdaterController
@ -63,7 +70,6 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) => ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
deps.setDisplayBackend(backend), 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("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName)) ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
ipcMain.handle("updater-subscribe", (event) => { ipcMain.handle("updater-subscribe", (event) => {
@ -177,8 +183,12 @@ export function registerIpcHandlers(deps: Deps) {
}, },
) )
ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => { ipcMain.on("open-external", (_event: IpcMainEvent, url: string) => {
void shell.openExternal(url) openExternalURL(url)
})
ipcMain.on("open-local-file", (_event: IpcMainEvent, url: string) => {
openLocalFileURL(url)
}) })
ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => { 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 } 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) => { ipcMain.handle("get-window-id", (event: IpcMainInvokeEvent) => {
const win = BrowserWindow.fromWebContents(event.sender) const win = BrowserWindow.fromWebContents(event.sender)
if (!win) throw new Error("Window not found") 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 type { MenuItemConstructorOptions } from "electron"
import { import {
DESKTOP_MENU, DESKTOP_MENU,
@ -9,6 +9,7 @@ import {
import { UPDATER_ENABLED } from "./constants" import { UPDATER_ENABLED } from "./constants"
import { runDesktopMenuAction } from "./desktop-menu-actions" import { runDesktopMenuAction } from "./desktop-menu-actions"
import { openExternalURL } from "./windows"
type Deps = { type Deps = {
trigger: (id: string) => void trigger: (id: string) => void
@ -56,7 +57,7 @@ function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOpt
} }
if (entry.href) { if (entry.href) {
const href = entry.href const href = entry.href
item.click = () => shell.openExternal(href) item.click = () => openExternalURL(href)
} }
return item 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 oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json"
import { randomUUID } from "node:crypto" import { randomUUID } from "node:crypto"
import { rmSync } from "node:fs" 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 { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url" import { fileURLToPath, pathToFileURL } from "node:url"
import type { TitlebarTheme } from "../preload/types" 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 { createUnresponsiveSampler } from "./unresponsive"
import { createWindowRegistry } from "./window-registry" import { createWindowRegistry } from "./window-registry"
import { safeWindowURL } from "./window-state" import { safeWindowURL } from "./window-state"
import { resolveExternalURL, resolveLocalFilePath } from "./external-url"
const root = dirname(fileURLToPath(import.meta.url)) const root = dirname(fileURLToPath(import.meta.url))
const rendererRoot = join(root, "../renderer") const rendererRoot = join(root, "../renderer")
@ -204,6 +205,7 @@ export function createMainWindow(id: string = randomUUID()) {
allowRendererPermissions(win) allowRendererPermissions(win)
wireWindowRecovery(win, id) wireWindowRecovery(win, id)
wireNavigationPolicy(win)
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => { win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
const { requestHeaders } = details const { requestHeaders } = details
@ -230,6 +232,40 @@ export function createMainWindow(id: string = randomUUID()) {
return win 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) { function registerWindow(win: BrowserWindow, id: string) {
windowIDs.set(win, id) windowIDs.set(win, id)
registry.register(id, win) registry.register(id, win)

View file

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

View file

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

View file

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

View file

@ -51,7 +51,6 @@
"@solid-primitives/media": "2.3.3", "@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"diff": "catalog:", "diff": "catalog:",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",

View file

@ -62,7 +62,6 @@ import { AnimatedCountList } from "./tool-count-summary"
import { ToolStatusTitle } from "./tool-status-title" import { ToolStatusTitle } from "./tool-status-title"
import { patchFiles } from "./apply-patch-file" import { patchFiles } from "./apply-patch-file"
import { animate } from "motion" import { animate } from "motion"
import { useLocation } from "@solidjs/router"
import { attached, inline, kind, typeLabel } from "./message-file" import { attached, inline, kind, typeLabel } from "./message-file"
import { readPartText } from "./message-part-text" import { readPartText } from "./message-part-text"
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2" 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) { function sessionLink(id: string | undefined, href?: (id: string) => string | undefined) {
if (!id) return if (!id) return undefined
return href?.(id)
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 taskSession( function taskSession(
input: Record<string, any>, input: Record<string, any>,
path: string, parentID: string | undefined,
sessions: Session[] | undefined, sessions: Session[] | undefined,
agents?: readonly { name: string; color?: string }[], agents?: readonly { name: string; color?: string }[],
) { ) {
const parentID = currentSession(path) if (!parentID) return undefined
if (!parentID) return
const description = typeof input.description === "string" ? input.description : "" const description = typeof input.description === "string" ? input.description : ""
const agent = taskAgent(input.subagent_type, agents).name const agent = taskAgent(input.subagent_type, agents).name
return (sessions ?? []) return (sessions ?? [])
@ -1575,7 +1563,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
}) })
const taskHref = createMemo(() => { const taskHref = createMemo(() => {
if (part().tool !== "task") return if (part().tool !== "task") return
return sessionLink(taskId(), useLocation().pathname, data.sessionHref) return sessionLink(taskId(), data.sessionHref)
}) })
const taskSubtitle = createMemo(() => { const taskSubtitle = createMemo(() => {
if (part().tool !== "task") return undefined if (part().tool !== "task") return undefined
@ -1614,6 +1602,14 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined} onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
subtitle={taskSubtitle()} subtitle={taskSubtitle()}
href={taskHref()} 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) { render(props) {
const data = useData() const data = useData()
const i18n = useI18n() const i18n = useI18n()
const location = useLocation()
const childSessionId = createMemo(() => { const childSessionId = createMemo(() => {
const value = props.metadata.sessionId const value = props.metadata.sessionId
if (typeof value === "string" && value) return value 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 agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent))
const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default")) 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 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 clickable = createMemo(() => !!(childSessionId() && (data.navigateToSession || href())))
const open = () => { const open = () => {
const id = childSessionId() const id = childSessionId()
if (!id) return if (!id) return
if (data.navigateToSession) { data.navigateToSession?.(id)
data.navigateToSession(id)
return
}
const value = href()
if (value) window.location.assign(value)
} }
const navigate = (event: MouseEvent) => { const navigate = (event: MouseEvent) => {

View file

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

View file

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

View file

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