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