mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 02:24:10 +08:00
feat(app): open referenced session IDs from markdown (#52231)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
01efd22b2a
commit
ffa4c4c730
@@ -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 { 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<ReadMarkdownImage>(() => {
|
||||
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}
|
||||
>
|
||||
<MarkdownProvider readImage={readImage()}>
|
||||
<MarkdownProvider readImage={readImage()} openSession={openReferencedSession}>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</MarkdownProvider>
|
||||
</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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -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<Block["mode"], "code"> })
|
||||
@@ -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<typeof createMarkdownImages> | 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) {
|
||||
|
||||
@@ -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 (
|
||||
<context.Provider
|
||||
value={{
|
||||
@@ -21,6 +27,9 @@ export function MarkdownProvider(
|
||||
get openLocalFile() {
|
||||
return props.openLocalFile
|
||||
},
|
||||
get openSession() {
|
||||
return props.openSession ?? parent?.openSession
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
Reference in New Issue
Block a user