mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 19:43:35 +00:00
fix(desktop): open external links in system browser (#39820)
This commit is contained in:
parent
da59457ca4
commit
2039c90c06
38 changed files with 242 additions and 258 deletions
28
bun.lock
28
bun.lock
|
|
@ -390,7 +390,6 @@
|
|||
"electron-store": "11.0.2",
|
||||
"electron-updater": "6.8.9",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"marked": "^15",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "4.0.0",
|
||||
|
|
@ -811,7 +810,6 @@
|
|||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"dompurify": "3.3.1",
|
||||
"fuzzysort": "catalog:",
|
||||
|
|
@ -1062,28 +1060,28 @@
|
|||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"tree-sitter-powershell",
|
||||
"protobufjs",
|
||||
"electron",
|
||||
"web-tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"esbuild",
|
||||
"electron",
|
||||
"protobufjs",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
|
|
@ -6045,8 +6043,6 @@
|
|||
|
||||
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
|
||||
|
||||
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
|
||||
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||
|
||||
"@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
21
packages/app/src/components/external-link.tsx
Normal file
21
packages/app/src/components/external-link.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -27,4 +27,3 @@ export {
|
|||
type WslServersState,
|
||||
} from "./wsl/types"
|
||||
export { ServerConnection } from "./context/server"
|
||||
export { handleNotificationClick } from "./utils/notification-click"
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export function DirectoryDataProvider(
|
|||
<DataProvider
|
||||
data={sync().data}
|
||||
directory={directory}
|
||||
sessionID={params.id}
|
||||
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
|
||||
onSessionHref={href}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
29
packages/desktop/src/main/external-url.test.ts
Normal file
29
packages/desktop/src/main/external-url.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
20
packages/desktop/src/main/external-url.ts
Normal file
20
packages/desktop/src/main/external-url.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue