diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx
index c6fed8f6d5a0..d4e1af88c4cf 100644
--- a/packages/app/src/pages/layout-new.tsx
+++ b/packages/app/src/pages/layout-new.tsx
@@ -1,16 +1,29 @@
-import { createEffect, Suspense, type ParentProps } from "solid-js"
+import { createEffect, onMount, Suspense, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
+import { makeEventListener } from "@solid-primitives/event-listener"
import { DebugBar } from "@/components/debug-bar"
import { TabsInfoPopup } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
+import { useGlobal } from "@/context/global"
+import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
+import { ServerConnection, useServer } from "@/context/server"
+import { useTabs } from "@/context/tabs"
import { setNavigate } from "@/utils/notification-click"
-import { setV2Toast, ToastRegion } from "@/utils/toast"
+import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
+import {
+ collectNewSessionDeepLinks,
+ collectOpenProjectDeepLinks,
+ collectOpenSessionDeepLinks,
+ deepLinkEvent,
+ drainPendingDeepLinks,
+} from "./layout/deep-links"
export default function NewLayout(props: ParentProps) {
const platform = usePlatform()
const navigate = useNavigate()
+ useDeepLinks()
setNavigate(navigate)
const [state, setState] = createStore({ debugTools: true })
@@ -51,3 +64,57 @@ export default function NewLayout(props: ParentProps) {
)
}
+
+function useDeepLinks() {
+ const server = useServer()
+ const global = useGlobal()
+ const tabs = useTabs()
+ const language = useLanguage()
+
+ const handle = (urls: string[]) => {
+ if (!server.isLocal()) return
+ const current = server.current
+ if (!current) return
+ const key = ServerConnection.key(current)
+ const context = global.ensureServerCtx(current)
+ const projects = context.projects
+ const open = (directory: string) => {
+ projects.open(directory)
+ projects.touch(directory)
+ }
+
+ for (const directory of collectOpenProjectDeepLinks(urls)) {
+ open(directory)
+ void tabs.newDraft({ server: key, directory })
+ }
+
+ for (const link of collectNewSessionDeepLinks(urls)) {
+ open(link.directory)
+ void tabs.newDraft({ server: key, directory: link.directory }, link.prompt)
+ }
+
+ for (const sessionID of collectOpenSessionDeepLinks(urls)) {
+ void context.sdk.api.session
+ .get({ sessionID })
+ .then((session) => {
+ open(session.location.directory)
+ tabs.select(tabs.addSessionTab({ server: key, sessionId: session.id }))
+ })
+ .catch(() =>
+ showToast({
+ title: language.t("session.error.notFound"),
+ description: language.t("session.error.notFound.description"),
+ }),
+ )
+ }
+ }
+
+ onMount(() => {
+ const listener = (event: Event) => {
+ const urls = (event as CustomEvent<{ urls: string[] }>).detail?.urls ?? []
+ if (urls.length) handle(urls)
+ }
+ handle(drainPendingDeepLinks(window))
+ makeEventListener(window, deepLinkEvent, listener as EventListener)
+ })
+}
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 59474423184a..c7cb9d639c3d 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -72,6 +72,7 @@ import {
import {
collectNewSessionDeepLinks,
collectOpenProjectDeepLinks,
+ collectOpenSessionDeepLinks,
deepLinkEvent,
drainPendingDeepLinks,
} from "./layout/deep-links"
@@ -1276,6 +1277,21 @@ export default function LegacyLayout(props: ParentProps) {
const href = link.prompt ? `/${slug}/session?prompt=${encodeURIComponent(link.prompt)}` : `/${slug}/session`
navigateWithSidebarReset(href)
}
+
+ for (const sessionID of collectOpenSessionDeepLinks(urls)) {
+ void serverSDK()
+ .api.session.get({ sessionID })
+ .then((session) => {
+ void openProject(session.location.directory, false)
+ navigateWithSidebarReset(`/${base64Encode(session.location.directory)}/session/${session.id}`)
+ })
+ .catch(() =>
+ showToast({
+ title: language.t("session.error.notFound"),
+ description: language.t("session.error.notFound.description"),
+ }),
+ )
+ }
}
onMount(() => {
diff --git a/packages/app/src/pages/layout/deep-links.ts b/packages/app/src/pages/layout/deep-links.ts
index 5dca421f7498..dbb866a2f1a3 100644
--- a/packages/app/src/pages/layout/deep-links.ts
+++ b/packages/app/src/pages/layout/deep-links.ts
@@ -30,12 +30,22 @@ export const parseNewSessionDeepLink = (input: string) => {
return { directory, prompt }
}
+export const parseOpenSessionDeepLink = (input: string) => {
+ const url = parseUrl(input)
+ if (!url) return
+ if (url.hostname !== "open-session") return
+ return url.pathname.split("/").filter(Boolean)[0]
+}
+
export const collectOpenProjectDeepLinks = (urls: string[]) =>
urls.map(parseDeepLink).filter((directory): directory is string => !!directory)
export const collectNewSessionDeepLinks = (urls: string[]) =>
urls.map(parseNewSessionDeepLink).filter((link): link is { directory: string; prompt?: string } => !!link)
+export const collectOpenSessionDeepLinks = (urls: string[]) =>
+ urls.map(parseOpenSessionDeepLink).filter((sessionID): sessionID is string => !!sessionID)
+
type OpenCodeWindow = Window & {
__OPENCODE__?: {
deepLinks?: string[]
diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts
index df87ddfecd2a..0f2b872c5577 100644
--- a/packages/app/src/pages/layout/helpers.test.ts
+++ b/packages/app/src/pages/layout/helpers.test.ts
@@ -2,9 +2,11 @@ import { describe, expect, test } from "bun:test"
import {
collectNewSessionDeepLinks,
collectOpenProjectDeepLinks,
+ collectOpenSessionDeepLinks,
drainPendingDeepLinks,
parseDeepLink,
parseNewSessionDeepLink,
+ parseOpenSessionDeepLink,
} from "./deep-links"
import { type Session } from "@opencode-ai/sdk/v2/client"
import {
@@ -98,6 +100,23 @@ describe("layout deep links", () => {
expect(result).toEqual([{ directory: "/a" }, { directory: "/c", prompt: "ship it" }])
})
+ test("parses open-session deep links", () => {
+ expect(parseOpenSessionDeepLink("opencode://open-session/ses_123")).toBe("ses_123")
+ })
+
+ test("ignores open-session deep links without a session", () => {
+ expect(parseOpenSessionDeepLink("opencode://open-session")).toBeUndefined()
+ })
+
+ test("collects only valid open-session deep links", () => {
+ const result = collectOpenSessionDeepLinks([
+ "opencode://open-session/ses_1",
+ "opencode://open-project?directory=/b",
+ "opencode://open-session/ses_2",
+ ])
+ expect(result).toEqual(["ses_1", "ses_2"])
+ })
+
test("drains global deep links once", () => {
const target = {
__OPENCODE__: {
diff --git a/packages/app/src/utils/markdown-sanitize.test.ts b/packages/app/src/utils/markdown-sanitize.test.ts
new file mode 100644
index 000000000000..b1ad3d20232b
--- /dev/null
+++ b/packages/app/src/utils/markdown-sanitize.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, test } from "bun:test"
+import { sanitizeMarkdown } from "@opencode-ai/session-ui/markdown-cache"
+
+describe("markdown link sanitization", () => {
+ test("keeps supported OpenCode deep links", () => {
+ const html = sanitizeMarkdown('Open session')
+ expect(html).toContain('href="opencode://open-session/ses_123"')
+ })
+
+ test("keeps other OpenCode links and removes unsafe protocols", () => {
+ expect(sanitizeMarkdown('Unknown')).toContain(
+ 'href="opencode://unknown?directory=%2Ftmp%2Ftest"',
+ )
+ expect(sanitizeMarkdown('Script')).toBe("Script")
+ })
+})
diff --git a/packages/session-ui/src/components/markdown-cache.tsx b/packages/session-ui/src/components/markdown-cache.tsx
index 3f8f0259f32c..abfa4ae9bd59 100644
--- a/packages/session-ui/src/components/markdown-cache.tsx
+++ b/packages/session-ui/src/components/markdown-cache.tsx
@@ -20,6 +20,17 @@ const config = {
}
if (typeof window !== "undefined" && DOMPurify.isSupported) {
+ DOMPurify.addHook("uponSanitizeAttribute", (node: Element, data) => {
+ if (!(node instanceof HTMLAnchorElement)) return
+ if (data.attrName !== "href") return
+ try {
+ const url = new URL(data.attrValue)
+ if (url.protocol === "opencode:") data.forceKeepAttr = true
+ } catch {
+ return
+ }
+ })
+
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
if (!(node instanceof HTMLAnchorElement)) return
if (node.target !== "_blank") return