mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +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 { 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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user