From 3560f0e7cf1d92595369b112eec3abde15c8129c Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Fri, 24 Jul 2026 16:35:46 -0300 Subject: [PATCH 1/4] feat(app): add desktop session deep links --- packages/app/src/app.tsx | 68 +++++++++++++++++++ packages/app/src/pages/layout.tsx | 16 +++++ packages/app/src/pages/layout/deep-links.ts | 10 +++ packages/app/src/pages/layout/helpers.test.ts | 19 ++++++ .../app/src/utils/markdown-sanitize.test.ts | 16 +++++ .../src/components/markdown-cache.tsx | 11 +++ 6 files changed, 140 insertions(+) create mode 100644 packages/app/src/utils/markdown-sanitize.test.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index f47c432e4206..51ebfa56582a 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -34,6 +34,7 @@ import { type JSX, lazy, onCleanup, + onMount, type ParentProps, Show, } from "solid-js" @@ -65,10 +66,18 @@ import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" import { createSessionLineage } from "@/pages/session/session-lineage" +import { showToast } from "@/utils/toast" import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" import { NewHome } from "@/pages/home" import { LegacyHome } from "@/pages/home/legacy-home" +import { + collectNewSessionDeepLinks, + collectOpenProjectDeepLinks, + collectOpenSessionDeepLinks, + deepLinkEvent, + drainPendingDeepLinks, +} from "@/pages/layout/deep-links" const NewSession = lazy(() => import("@/pages/new-session")) @@ -311,12 +320,71 @@ function SharedProviders(props: ParentProps) { + {props.children} ) } +function NewLayoutDeepLinks() { + const settings = useSettings() + const server = useServer() + const global = useGlobal() + const tabs = useTabs() + const language = useLanguage() + + const handle = (urls: string[]) => { + if (!settings.general.newLayoutDesigns()) return + 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.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) + }) + + return null +} + function DesktopCommands() { const command = useCommand() const language = useLanguage() diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 59474423184a..a408e8fc30ae 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.directory, false) + navigateWithSidebarReset(`/${base64Encode(session.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 From 64d0a102efccd7427f2e4a76d98f29e074b8e51e Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Fri, 24 Jul 2026 16:37:02 -0300 Subject: [PATCH 2/4] fix(app): resolve deep link session directory --- packages/app/src/app.tsx | 2 +- packages/app/src/pages/layout.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 51ebfa56582a..bea7e730e910 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -361,7 +361,7 @@ function NewLayoutDeepLinks() { void context.sdk.api.session .get({ sessionID }) .then((session) => { - open(session.directory) + open(session.location.directory) tabs.select(tabs.addSessionTab({ server: key, sessionId: session.id })) }) .catch(() => diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index a408e8fc30ae..c7cb9d639c3d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1282,8 +1282,8 @@ export default function LegacyLayout(props: ParentProps) { void serverSDK() .api.session.get({ sessionID }) .then((session) => { - void openProject(session.directory, false) - navigateWithSidebarReset(`/${base64Encode(session.directory)}/session/${session.id}`) + void openProject(session.location.directory, false) + navigateWithSidebarReset(`/${base64Encode(session.location.directory)}/session/${session.id}`) }) .catch(() => showToast({ From 5369052e401b7d3d85bf36ee8f440c7bdaf791d8 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Fri, 24 Jul 2026 17:01:59 -0300 Subject: [PATCH 3/4] refactor(app): scope deep links to new layout --- packages/app/src/app.tsx | 69 -------------------------- packages/app/src/pages/layout-new.tsx | 71 ++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 71 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index bea7e730e910..fbcc7cb59473 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -34,12 +34,10 @@ import { type JSX, lazy, onCleanup, - onMount, type ParentProps, Show, } from "solid-js" import { Dynamic } from "solid-js/web" -import { makeEventListener } from "@solid-primitives/event-listener" import { CommandProvider, useCommand, type CommandOption } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" @@ -66,18 +64,10 @@ import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" import { createSessionLineage } from "@/pages/session/session-lineage" -import { showToast } from "@/utils/toast" import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" import { NewHome } from "@/pages/home" import { LegacyHome } from "@/pages/home/legacy-home" -import { - collectNewSessionDeepLinks, - collectOpenProjectDeepLinks, - collectOpenSessionDeepLinks, - deepLinkEvent, - drainPendingDeepLinks, -} from "@/pages/layout/deep-links" const NewSession = lazy(() => import("@/pages/new-session")) @@ -320,71 +310,12 @@ function SharedProviders(props: ParentProps) { - {props.children} ) } -function NewLayoutDeepLinks() { - const settings = useSettings() - const server = useServer() - const global = useGlobal() - const tabs = useTabs() - const language = useLanguage() - - const handle = (urls: string[]) => { - if (!settings.general.newLayoutDesigns()) return - 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) - }) - - return null -} - function DesktopCommands() { const command = useCommand() const language = useLanguage() 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) + }) +} From 734da337ef485b46a712980fe94ef05d5d43b2dc Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Fri, 24 Jul 2026 17:04:20 -0300 Subject: [PATCH 4/4] chore(app): limit deep link diff --- packages/app/src/app.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index fbcc7cb59473..f47c432e4206 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -38,6 +38,7 @@ import { Show, } from "solid-js" import { Dynamic } from "solid-js/web" +import { makeEventListener } from "@solid-primitives/event-listener" import { CommandProvider, useCommand, type CommandOption } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file"