From ffa4c4c730bde7b0b0baed1df1ae5828bf0766fc Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:39:30 +0800 Subject: [PATCH] feat(app): open referenced session IDs from markdown (#52231) Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com> --- .../e2e/regression/session-id-links.spec.ts | 43 +++++++++++++ .../src/shell/routes/session-ui-provider.tsx | 16 ++++- .../components/markdown-session-links.test.ts | 54 ++++++++++++++++ .../src/components/markdown-session-links.ts | 61 +++++++++++++++++++ .../session-ui/src/components/markdown.css | 20 ++++++ .../session-ui/src/components/markdown.tsx | 15 ++++- packages/session-ui/src/context/markdown.tsx | 11 +++- 7 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 packages/app/e2e/regression/session-id-links.spec.ts create mode 100644 packages/session-ui/src/components/markdown-session-links.test.ts create mode 100644 packages/session-ui/src/components/markdown-session-links.ts diff --git a/packages/app/e2e/regression/session-id-links.spec.ts b/packages/app/e2e/regression/session-id-links.spec.ts new file mode 100644 index 00000000000..80b456416e2 --- /dev/null +++ b/packages/app/e2e/regression/session-id-links.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test" +import { base64Encode } from "@opencode/util/encode" +import { + assistantMessage, + session, + setupTimeline, + textPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +const target = "ses_0123456789abcdefghijklmnop" +const missing = "ses_abcdefghijklmnopqrstuvwxyz" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test("opens a verified session from agent prose or inline code with the keyboard", async ({ page }) => { + await setupTimeline(page, { + sessions: [session(), session({ id: target, title: "Linked session" })], + messages: [ + userMessage(), + assistantMessage([textPart("prt_session_links", `Visit ${target} or \`${target}\` to see the result.`)]), + ], + }) + const markdown = page.locator('[data-component="markdown"]').filter({ hasText: `Visit ${target}` }) + await expect(markdown).toHaveAttribute("data-markdown-ready", "") + await expect(markdown.getByRole("button", { name: target })).toHaveCount(2) + await markdown + .getByRole("button", { name: target }) + .filter({ has: page.locator("code") }) + .press("Enter") + await expect(page).toHaveURL(`/server/${base64Encode(server)}/session/${target}`) + await expect(page.locator(`[data-titlebar-tab-link][href$="/session/${target}"]`)).toContainText("Linked session") +}) + +test("does not navigate to an ID that is absent from the current server", async ({ page }) => { + await setupTimeline(page, { + messages: [userMessage(), assistantMessage([textPart("prt_session_missing", `See ${missing}.`)])], + }) + const markdown = page.locator('[data-component="markdown"]').filter({ hasText: `See ${missing}.` }) + await expect(markdown).toHaveAttribute("data-markdown-ready", "") + await markdown.getByRole("button", { name: missing }).click() + await expect(page.getByText("This session cannot be found")).toBeVisible() + await expect(page).toHaveURL(/\/session\/ses_timeline_stability$/) +}) diff --git a/packages/app/src/shell/routes/session-ui-provider.tsx b/packages/app/src/shell/routes/session-ui-provider.tsx index bb45b941416..d853e519f5a 100644 --- a/packages/app/src/shell/routes/session-ui-provider.tsx +++ b/packages/app/src/shell/routes/session-ui-provider.tsx @@ -10,6 +10,8 @@ import { useData } from "@/runtime/server/current" import { useServerSDK } from "@/runtime/server/client" import { useTabs } from "@/shell/tabs/tabs" import { readLocalImage } from "@/runtime/server/image" +import { useLanguage } from "@/runtime/i18n/language" +import { showToast } from "@/shell/notifications/toast" export function SessionUIProvider( props: ParentProps<{ @@ -22,6 +24,7 @@ export function SessionUIProvider( const data = useData() const serverSDK = useServerSDK() const tabs = useTabs() + const language = useLanguage() const directory = () => props.directory const readImage = createMemo(() => { const dir = directory() @@ -39,6 +42,17 @@ export function SessionUIProvider( await data.session.sync(sessionID).catch(() => undefined) navigate(href(sessionID)) } + const openReferencedSession = async (sessionID: string) => { + // The transcript may mention a session from another server (or one that was deleted). + // Resolve it on this server before touching tabs or the current route. + const session = await serverSDK.api.session.get({ sessionID }).catch(() => undefined) + if (!session || session.time.archived) { + showToast({ title: language.t("session.error.notFound") }) + return + } + data.session.remember(session) + tabs.select(tabs.addSessionTab({ server: props.server, sessionId: session.id })) + } const providers = useProviders(directory) const sessionUIData = createMemo(() => ({ provider: providers.ready() @@ -66,7 +80,7 @@ export function SessionUIProvider( onNavigateToSession={navigateToSession} onSessionHref={href} > - + {props.children} diff --git a/packages/session-ui/src/components/markdown-session-links.test.ts b/packages/session-ui/src/components/markdown-session-links.test.ts new file mode 100644 index 00000000000..362fe918bbc --- /dev/null +++ b/packages/session-ui/src/components/markdown-session-links.test.ts @@ -0,0 +1,54 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator" +import { afterAll, beforeAll, expect, test } from "bun:test" +import { markSessionLinks, setupSessionLinks } from "./markdown-session-links" + +const first = "ses_0123456789abcdefghijklmnop" +const second = "ses_abcdefghijklmnopqrstuvwxyz" + +beforeAll(() => GlobalRegistrator.register()) +afterAll(() => GlobalRegistrator.unregister()) + +test("links complete IDs in prose and inline code without changing fenced code or existing links", () => { + const root = document.createElement("div") + root.innerHTML = `

Open ${first}, then ${second}. See ${first}.

${first}
` + markSessionLinks(root) + + const links = root.querySelectorAll("button[data-session-id]") + expect([...links].map((button) => button.dataset.sessionId)).toEqual([first, second]) + expect(links[0]?.textContent).toBe(first) + expect(links[1]?.innerHTML).toBe(`${second}`) + expect(root.querySelector("pre button")).toBeNull() + expect(root.querySelector("a button")).toBeNull() + expect(root.textContent).toBe(`Open ${first}, then ${second}. See ${first}.${first}`) +}) + +test("ignores incomplete IDs, identifiers, code snippets and IDs in URLs", () => { + const root = document.createElement("div") + root.innerHTML = `

ses_123 and x${first} and ${first}more and ${first}extra and ${first}

` + markSessionLinks(root) + expect(root.querySelector("button")).toBeNull() +}) + +test("does not link an unparsed fenced block while the markdown worker is pending", () => { + const root = document.createElement("div") + root.innerHTML = `\`\`\`text
${first}
\`\`\`` + markSessionLinks(root) + expect(root.querySelector("button")).toBeNull() +}) + +test("delegates keyboard-activated clicks to the latest rendered ID", () => { + const root = document.createElement("div") + const opened: string[] = [] + const dispose = setupSessionLinks(root, () => (id) => opened.push(id)) + root.innerHTML = `

${first}

` + markSessionLinks(root) + root.querySelector("button")?.click() + root.innerHTML = `

${second}

` + markSessionLinks(root) + root.querySelector("button")?.click() + expect(opened).toEqual([first, second]) + expect(root.querySelector("button")?.getAttribute("type")).toBe("button") + dispose() + root.querySelector("button")?.click() + expect(opened).toEqual([first, second]) +}) diff --git a/packages/session-ui/src/components/markdown-session-links.ts b/packages/session-ui/src/components/markdown-session-links.ts new file mode 100644 index 00000000000..44cc7a943c1 --- /dev/null +++ b/packages/session-ui/src/components/markdown-session-links.ts @@ -0,0 +1,61 @@ +// Generated session IDs have a 26-character alphanumeric suffix. Require a complete token +// so a partially streamed ID, an identifier suffix, or a path does not become a link. +const sessionID = /(? { + if (code.closest("pre, a, button") || !exactSessionID.test(code.textContent ?? "")) return + const link = sessionButton(code.textContent!) + code.replaceWith(link) + link.replaceChildren(code) + }) + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) + const nodes: Text[] = [] + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.parentElement?.closest("a, button, code, pre")) continue + if (node.data.includes("ses_")) nodes.push(node) + } + nodes.forEach((node) => { + const matches = [...node.data.matchAll(sessionID)] + if (!matches.length) return + const fragment = document.createDocumentFragment() + let start = 0 + matches.forEach((match) => { + fragment.append(node.data.slice(start, match.index), sessionButton(match[0])) + start = match.index + match[0].length + }) + fragment.append(node.data.slice(start)) + node.replaceWith(fragment) + }) +} + +function sessionButton(id: string) { + const button = document.createElement("button") + button.type = "button" + button.className = "markdown-session-link" + button.dataset.sessionId = id + button.textContent = id + return button +} + +export function setupSessionLinks(root: HTMLElement, open: () => ((id: string) => void) | undefined) { + const click = (event: MouseEvent) => { + if (event.defaultPrevented || !(event.target instanceof Element)) return + const button = event.target.closest("button[data-session-id]") + if (!(button instanceof HTMLButtonElement) || !root.contains(button)) return + const handler = open() + if (!handler) return + event.stopPropagation() + handler(button.dataset.sessionId!) + } + root.addEventListener("click", click) + return () => root.removeEventListener("click", click) +} diff --git a/packages/session-ui/src/components/markdown.css b/packages/session-ui/src/components/markdown.css index a8b3250c149..cc4d1157865 100644 --- a/packages/session-ui/src/components/markdown.css +++ b/packages/session-ui/src/components/markdown.css @@ -154,6 +154,26 @@ cursor: pointer; } + .markdown-session-link { + display: inline; + padding: 0; + border: 0; + background: none; + color: var(--text-interactive-base); + font: inherit; + cursor: pointer; + } + + .markdown-session-link:hover { + text-decoration: underline; + text-underline-offset: 2px; + } + + .markdown-session-link:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } + &[data-local-links] :not(pre) > code[data-inline-code-kind="path"]:not(a > code) { cursor: pointer; } diff --git a/packages/session-ui/src/components/markdown.tsx b/packages/session-ui/src/components/markdown.tsx index a1ab7fbe1ea..f5d30a1a37a 100644 --- a/packages/session-ui/src/components/markdown.tsx +++ b/packages/session-ui/src/components/markdown.tsx @@ -40,6 +40,7 @@ import { createMarkdownRenderer } from "./markdown-solid" import { useMarkdown, type OpenMarkdownLocalFile, type ReadMarkdownImage } from "../context/markdown" import { createMarkdownImages } from "./markdown-image" import { createImagePreview } from "./image-preview" +import { markSessionLinks, setupSessionLinks } from "./markdown-session-links" type RenderedBlock = | (MarkdownCacheEntry & { key: string; mode: Exclude }) @@ -599,6 +600,7 @@ export function Markdown( let copyCleanup: (() => void) | undefined let linkCleanup: (() => void) | undefined let faviconCleanup: (() => void) | undefined + let sessionLinkCleanup: (() => void) | undefined let readImage: ReadMarkdownImage | undefined let images: ReturnType | undefined @@ -634,7 +636,7 @@ export function Markdown( }) activeCodeKeys.clear() nextCodeKeys.forEach((key) => activeCodeKeys.add(key)) - content.forEach((block, index) => updateBlock(container, index, block, labels)) + content.forEach((block, index) => updateBlock(container, index, block, labels, !!markdown?.openSession)) while (container.children.length > content.length) { const child = container.lastElementChild if (!child) break @@ -656,6 +658,7 @@ export function Markdown( copied: i18n.t("ui.message.copied"), })) if (!linkCleanup) linkCleanup = setupLocalLinks(container, () => markdown?.openLocalFile) + if (!sessionLinkCleanup) sessionLinkCleanup = setupSessionLinks(container, () => markdown?.openSession) if (!faviconCleanup) faviconCleanup = setupExternalLinkFavicons(container) container.toggleAttribute("data-local-links", !!markdown?.openLocalFile) if (result?.ready && result.text === local.text) container.dataset.markdownReady = "" @@ -666,6 +669,7 @@ export function Markdown( images?.dispose() if (copyCleanup) copyCleanup() if (linkCleanup) linkCleanup() + if (sessionLinkCleanup) sessionLinkCleanup() if (faviconCleanup) faviconCleanup() const container = root() if (container) disposeRenderedMarkdown(container) @@ -723,7 +727,13 @@ function disposeCode(key: string) { disposeStreamingCode(key) } -function updateBlock(container: HTMLDivElement, index: number, block: RenderedBlock, labels: CopyLabels) { +function updateBlock( + container: HTMLDivElement, + index: number, + block: RenderedBlock, + labels: CopyLabels, + sessionLinks: boolean, +) { const current = container.children[index] if (block.mode === "code") { updateCodeBlock(container, current, block, labels) @@ -747,6 +757,7 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl source.innerHTML = block.html markInlineCode(source) markCodeLinks(source) + if (sessionLinks) markSessionLinks(source) markExternalLinkFavicons(source) if (rendered) { diff --git a/packages/session-ui/src/context/markdown.tsx b/packages/session-ui/src/context/markdown.tsx index b18addc4be4..dd92a318cc4 100644 --- a/packages/session-ui/src/context/markdown.tsx +++ b/packages/session-ui/src/context/markdown.tsx @@ -7,11 +7,17 @@ export type OpenMarkdownLocalFile = (path: string) => void const context = createContext<{ readonly readImage?: ReadMarkdownImage readonly openLocalFile?: OpenMarkdownLocalFile + readonly openSession?: (sessionID: string) => void }>() export function MarkdownProvider( - props: ParentProps<{ readImage?: ReadMarkdownImage; openLocalFile?: OpenMarkdownLocalFile }>, + props: ParentProps<{ + readImage?: ReadMarkdownImage + openLocalFile?: OpenMarkdownLocalFile + openSession?: (id: string) => void + }>, ) { + const parent = useMarkdown() return ( {props.children}