Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions packages/app/src/pages/layout-new.tsx
Original file line number Diff line number Diff line change
@@ -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 })

Expand Down Expand Up @@ -51,3 +64,57 @@ export default function NewLayout(props: ParentProps) {
</div>
)
}

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)
})
}
16 changes: 16 additions & 0 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import {
import {
collectNewSessionDeepLinks,
collectOpenProjectDeepLinks,
collectOpenSessionDeepLinks,
deepLinkEvent,
drainPendingDeepLinks,
} from "./layout/deep-links"
Expand Down Expand Up @@ -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(() => {
Expand Down
10 changes: 10 additions & 0 deletions packages/app/src/pages/layout/deep-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
19 changes: 19 additions & 0 deletions packages/app/src/pages/layout/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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__: {
Expand Down
16 changes: 16 additions & 0 deletions packages/app/src/utils/markdown-sanitize.test.ts
Original file line number Diff line number Diff line change
@@ -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('<a href="opencode://open-session/ses_123">Open session</a>')
expect(html).toContain('href="opencode://open-session/ses_123"')
})

test("keeps other OpenCode links and removes unsafe protocols", () => {
expect(sanitizeMarkdown('<a href="opencode://unknown?directory=%2Ftmp%2Ftest">Unknown</a>')).toContain(
'href="opencode://unknown?directory=%2Ftmp%2Ftest"',
)
expect(sanitizeMarkdown('<a href="javascript:alert(1)">Script</a>')).toBe("<a>Script</a>")
})
})
11 changes: 11 additions & 0 deletions packages/session-ui/src/components/markdown-cache.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading