瀏覽代碼

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

Luke Parker 2 周之前
父節點
當前提交
2039c90c06
共有 38 個文件被更改,包括 242 次插入258 次删除
  1. 12 16
      bun.lock
  2. 13 9
      packages/app/src/components/dialog-connect-provider.tsx
  3. 3 3
      packages/app/src/components/dialog-custom-provider.tsx
  4. 1 1
      packages/app/src/components/dialog-usage-exceeded.tsx
  5. 21 0
      packages/app/src/components/external-link.tsx
  6. 0 26
      packages/app/src/components/link.tsx
  7. 2 2
      packages/app/src/components/settings-general.tsx
  8. 3 3
      packages/app/src/components/settings-v2/general.tsx
  9. 5 1
      packages/app/src/components/terminal.tsx
  10. 1 1
      packages/app/src/components/windows-app-menu.tsx
  11. 10 3
      packages/app/src/context/notification.tsx
  12. 7 13
      packages/app/src/context/platform.tsx
  13. 9 16
      packages/app/src/entry.tsx
  14. 0 1
      packages/app/src/index.ts
  15. 1 0
      packages/app/src/pages/directory-layout.tsx
  16. 1 1
      packages/app/src/pages/error.tsx
  17. 1 1
      packages/app/src/pages/home/home-projects-controller.tsx
  18. 0 4
      packages/app/src/pages/layout-new.tsx
  19. 3 5
      packages/app/src/pages/layout.tsx
  20. 1 1
      packages/app/src/pages/session/timeline/message-timeline.tsx
  21. 0 27
      packages/app/src/utils/notification-click.test.ts
  22. 0 13
      packages/app/src/utils/notification-click.ts
  23. 1 2
      packages/desktop/package.json
  24. 29 0
      packages/desktop/src/main/external-url.test.ts
  25. 20 0
      packages/desktop/src/main/external-url.ts
  26. 0 2
      packages/desktop/src/main/index.ts
  27. 16 12
      packages/desktop/src/main/ipc.ts
  28. 0 16
      packages/desktop/src/main/markdown.ts
  29. 3 2
      packages/desktop/src/main/menu.ts
  30. 37 1
      packages/desktop/src/main/windows.ts
  31. 2 4
      packages/desktop/src/preload/index.ts
  32. 2 4
      packages/desktop/src/preload/types.ts
  33. 10 35
      packages/desktop/src/renderer/index.tsx
  34. 0 1
      packages/session-ui/package.json
  35. 17 27
      packages/session-ui/src/components/message-part.tsx
  36. 6 1
      packages/session-ui/src/components/tool-error-card.tsx
  37. 4 0
      packages/session-ui/src/context/data.tsx
  38. 1 4
      packages/storybook/.storybook/mocks/app/context/platform.ts

+ 12 - 16
bun.lock

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

+ 13 - 9
packages/app/src/components/dialog-connect-provider.tsx

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

+ 3 - 3
packages/app/src/components/dialog-custom-provider.tsx

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

+ 1 - 1
packages/app/src/components/dialog-usage-exceeded.tsx

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

+ 21 - 0
packages/app/src/components/external-link.tsx

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

+ 0 - 26
packages/app/src/components/link.tsx

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

+ 2 - 2
packages/app/src/components/settings-general.tsx

@@ -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>
             </>
             </>
           }
           }
         >
         >

+ 3 - 3
packages/app/src/components/settings-v2/general.tsx

@@ -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>
             </>
             </>
           }
           }
         >
         >

+ 5 - 1
packages/app/src/components/terminal.tsx

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

+ 1 - 1
packages/app/src/components/windows-app-menu.tsx

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

+ 10 - 3
packages/app/src/context/notification.tsx

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

+ 7 - 13
packages/app/src/context/platform.tsx

@@ -31,26 +31,23 @@ type PlatformBase = {
   /** App version */
   /** App version */
   version?: string
   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) */
   /** 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 */
-  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) */
   /** 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>
 
 

+ 9 - 16
packages/app/src/entry.tsx

@@ -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) => {
-  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 () => {
 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,
-  back,
-  forward,
+  openExternal,
   restart,
   restart,
   notify,
   notify,
   getDefaultServer: async () => {
   getDefaultServer: async () => {

+ 0 - 1
packages/app/src/index.ts

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

+ 1 - 0
packages/app/src/pages/directory-layout.tsx

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

+ 1 - 1
packages/app/src/pages/error.tsx

@@ -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" />

+ 1 - 1
packages/app/src/pages/home/home-projects-controller.tsx

@@ -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"),
     },
     },
   }
   }
 }
 }

+ 0 - 4
packages/app/src/pages/layout-new.tsx

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

+ 3 - 5
packages/app/src/pages/layout.tsx

@@ -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 />
       }
       }

+ 1 - 1
packages/app/src/pages/session/timeline/message-timeline.tsx

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

+ 0 - 27
packages/app/src/utils/notification-click.test.ts

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

+ 0 - 13
packages/app/src/utils/notification-click.ts

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

+ 1 - 2
packages/desktop/package.json

@@ -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",
-    "marked": "^15"
+    "electron-window-state": "^5.0.3"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@actions/artifact": "4.0.0",
     "@actions/artifact": "4.0.0",

+ 29 - 0
packages/desktop/src/main/external-url.test.ts

@@ -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 - 0
packages/desktop/src/main/external-url.ts

@@ -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
+  }
+}

+ 0 - 2
packages/desktop/src/main/index.ts

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

+ 16 - 12
packages/desktop/src/main/ipc.ts

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

+ 0 - 16
packages/desktop/src/main/markdown.ts

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

+ 3 - 2
packages/desktop/src/main/menu.ts

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

+ 37 - 1
packages/desktop/src/main/windows.ts

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

+ 2 - 4
packages/desktop/src/preload/index.ts

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

+ 2 - 4
packages/desktop/src/preload/types.ts

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

+ 10 - 35
packages/desktop/src/renderer/index.tsx

@@ -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) {
-      window.api.openLink(url)
+    openExternal(url: string) {
+      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}>

+ 0 - 1
packages/session-ui/package.json

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

+ 17 - 27
packages/session-ui/src/components/message-part.tsx

@@ -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) {
-  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(
 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
+  if (!parentID) return undefined
   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)
-        return
-      }
-      const value = href()
-      if (value) window.location.assign(value)
+      data.navigateToSession?.(id)
     }
     }
 
 
     const navigate = (event: MouseEvent) => {
     const navigate = (event: MouseEvent) => {

+ 6 - 1
packages/session-ui/src/components/tool-error-card.tsx

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

+ 4 - 0
packages/session-ui/src/context/data.tsx

@@ -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,
     }
     }

+ 1 - 4
packages/storybook/.storybook/mocks/app/context/platform.ts

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