feat(app): open referenced session IDs from markdown (#52231)

Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-09-30 15:39:30 +08:00
committed by GitHub
parent 01efd22b2a
commit ffa4c4c730
7 changed files with 216 additions and 4 deletions
@@ -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$/)
})
@@ -10,6 +10,8 @@ import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client" import { useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs" import { useTabs } from "@/shell/tabs/tabs"
import { readLocalImage } from "@/runtime/server/image" import { readLocalImage } from "@/runtime/server/image"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
export function SessionUIProvider( export function SessionUIProvider(
props: ParentProps<{ props: ParentProps<{
@@ -22,6 +24,7 @@ export function SessionUIProvider(
const data = useData() const data = useData()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const tabs = useTabs() const tabs = useTabs()
const language = useLanguage()
const directory = () => props.directory const directory = () => props.directory
const readImage = createMemo<ReadMarkdownImage>(() => { const readImage = createMemo<ReadMarkdownImage>(() => {
const dir = directory() const dir = directory()
@@ -39,6 +42,17 @@ export function SessionUIProvider(
await data.session.sync(sessionID).catch(() => undefined) await data.session.sync(sessionID).catch(() => undefined)
navigate(href(sessionID)) 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 providers = useProviders(directory)
const sessionUIData = createMemo(() => ({ const sessionUIData = createMemo(() => ({
provider: providers.ready() provider: providers.ready()
@@ -66,7 +80,7 @@ export function SessionUIProvider(
onNavigateToSession={navigateToSession} onNavigateToSession={navigateToSession}
onSessionHref={href} onSessionHref={href}
> >
<MarkdownProvider readImage={readImage()}> <MarkdownProvider readImage={readImage()} openSession={openReferencedSession}>
<LocalProvider>{props.children}</LocalProvider> <LocalProvider>{props.children}</LocalProvider>
</MarkdownProvider> </MarkdownProvider>
</DataProvider> </DataProvider>
@@ -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 = `<p>Open ${first}, then <code>${second}</code>. See <a href="/docs">${first}</a>.</p><pre><code>${first}</code></pre>`
markSessionLinks(root)
const links = root.querySelectorAll<HTMLButtonElement>("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(`<code>${second}</code>`)
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 = `<p>ses_123 and x${first} and ${first}more and <code>${first}extra</code> and <a href="https://example.com/${first}">${first}</a></p>`
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<br>${first}<br>\`\`\``
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 = `<p>${first}</p>`
markSessionLinks(root)
root.querySelector("button")?.click()
root.innerHTML = `<p><code>${second}</code></p>`
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])
})
@@ -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 = /(?<![A-Za-z0-9_])ses_[A-Za-z0-9]{26}(?![A-Za-z0-9_])/g
const exactSessionID = /^ses_[A-Za-z0-9]{26}$/
export function markSessionLinks(root: HTMLElement) {
if (!root.textContent?.includes("ses_")) return
// The worker's escaped-raw fallback has no markdown elements; linking its text
// would turn IDs inside still-unparsed fenced code into buttons.
if (!root.querySelector("p, li, blockquote, td, th, h1, h2, h3, h4, h5, h6")) return
root.querySelectorAll("code").forEach((code) => {
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)
}
@@ -154,6 +154,26 @@
cursor: pointer; 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) { &[data-local-links] :not(pre) > code[data-inline-code-kind="path"]:not(a > code) {
cursor: pointer; cursor: pointer;
} }
@@ -40,6 +40,7 @@ import { createMarkdownRenderer } from "./markdown-solid"
import { useMarkdown, type OpenMarkdownLocalFile, type ReadMarkdownImage } from "../context/markdown" import { useMarkdown, type OpenMarkdownLocalFile, type ReadMarkdownImage } from "../context/markdown"
import { createMarkdownImages } from "./markdown-image" import { createMarkdownImages } from "./markdown-image"
import { createImagePreview } from "./image-preview" import { createImagePreview } from "./image-preview"
import { markSessionLinks, setupSessionLinks } from "./markdown-session-links"
type RenderedBlock = type RenderedBlock =
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> }) | (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
@@ -599,6 +600,7 @@ export function Markdown(
let copyCleanup: (() => void) | undefined let copyCleanup: (() => void) | undefined
let linkCleanup: (() => void) | undefined let linkCleanup: (() => void) | undefined
let faviconCleanup: (() => void) | undefined let faviconCleanup: (() => void) | undefined
let sessionLinkCleanup: (() => void) | undefined
let readImage: ReadMarkdownImage | undefined let readImage: ReadMarkdownImage | undefined
let images: ReturnType<typeof createMarkdownImages> | undefined let images: ReturnType<typeof createMarkdownImages> | undefined
@@ -634,7 +636,7 @@ export function Markdown(
}) })
activeCodeKeys.clear() activeCodeKeys.clear()
nextCodeKeys.forEach((key) => activeCodeKeys.add(key)) 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) { while (container.children.length > content.length) {
const child = container.lastElementChild const child = container.lastElementChild
if (!child) break if (!child) break
@@ -656,6 +658,7 @@ export function Markdown(
copied: i18n.t("ui.message.copied"), copied: i18n.t("ui.message.copied"),
})) }))
if (!linkCleanup) linkCleanup = setupLocalLinks(container, () => markdown?.openLocalFile) if (!linkCleanup) linkCleanup = setupLocalLinks(container, () => markdown?.openLocalFile)
if (!sessionLinkCleanup) sessionLinkCleanup = setupSessionLinks(container, () => markdown?.openSession)
if (!faviconCleanup) faviconCleanup = setupExternalLinkFavicons(container) if (!faviconCleanup) faviconCleanup = setupExternalLinkFavicons(container)
container.toggleAttribute("data-local-links", !!markdown?.openLocalFile) container.toggleAttribute("data-local-links", !!markdown?.openLocalFile)
if (result?.ready && result.text === local.text) container.dataset.markdownReady = "" if (result?.ready && result.text === local.text) container.dataset.markdownReady = ""
@@ -666,6 +669,7 @@ export function Markdown(
images?.dispose() images?.dispose()
if (copyCleanup) copyCleanup() if (copyCleanup) copyCleanup()
if (linkCleanup) linkCleanup() if (linkCleanup) linkCleanup()
if (sessionLinkCleanup) sessionLinkCleanup()
if (faviconCleanup) faviconCleanup() if (faviconCleanup) faviconCleanup()
const container = root() const container = root()
if (container) disposeRenderedMarkdown(container) if (container) disposeRenderedMarkdown(container)
@@ -723,7 +727,13 @@ function disposeCode(key: string) {
disposeStreamingCode(key) 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] const current = container.children[index]
if (block.mode === "code") { if (block.mode === "code") {
updateCodeBlock(container, current, block, labels) updateCodeBlock(container, current, block, labels)
@@ -747,6 +757,7 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl
source.innerHTML = block.html source.innerHTML = block.html
markInlineCode(source) markInlineCode(source)
markCodeLinks(source) markCodeLinks(source)
if (sessionLinks) markSessionLinks(source)
markExternalLinkFavicons(source) markExternalLinkFavicons(source)
if (rendered) { if (rendered) {
+10 -1
View File
@@ -7,11 +7,17 @@ export type OpenMarkdownLocalFile = (path: string) => void
const context = createContext<{ const context = createContext<{
readonly readImage?: ReadMarkdownImage readonly readImage?: ReadMarkdownImage
readonly openLocalFile?: OpenMarkdownLocalFile readonly openLocalFile?: OpenMarkdownLocalFile
readonly openSession?: (sessionID: string) => void
}>() }>()
export function MarkdownProvider( export function MarkdownProvider(
props: ParentProps<{ readImage?: ReadMarkdownImage; openLocalFile?: OpenMarkdownLocalFile }>, props: ParentProps<{
readImage?: ReadMarkdownImage
openLocalFile?: OpenMarkdownLocalFile
openSession?: (id: string) => void
}>,
) { ) {
const parent = useMarkdown()
return ( return (
<context.Provider <context.Provider
value={{ value={{
@@ -21,6 +27,9 @@ export function MarkdownProvider(
get openLocalFile() { get openLocalFile() {
return props.openLocalFile return props.openLocalFile
}, },
get openSession() {
return props.openSession ?? parent?.openSession
},
}} }}
> >
{props.children} {props.children}