mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-12 01:04:26 +03:00
fix(app): resolve inline file links before cold transcript reveal (#57801)
This commit is contained in:
committed by
GitHub
Unverified
parent
916b9e8c7f
commit
60e105161c
@@ -99,6 +99,76 @@ test("paints cold and warm session tabs at the latest message", async ({ page })
|
||||
expect(await warm.stop()).toBe(0)
|
||||
})
|
||||
|
||||
test("reloads a transcript only after inline file links have their final styling", async ({ page }) => {
|
||||
const sessionID = "ses_cold_inline_paths"
|
||||
await mockWorkspace(page, {
|
||||
name: "ColdInlinePaths",
|
||||
sessions: [{ id: sessionID, title: "Cold inline paths" }],
|
||||
findFiles: ({ query }) => (query === "src/file.ts" ? ["src/file.ts"] : []),
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
{ id: "user_cold_paths", type: "user", text: "Review these files", time: { created: 1 } },
|
||||
{
|
||||
id: "answer_cold_paths",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "test", providerID: "opencode" },
|
||||
time: { created: 2, completed: 3 },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "| Area | Files |\n| --- | --- |\n| App | `src/file.ts`, `src/file.ts` |\n| Missing | `missing/file.ts` |",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
await page.goto(sessionHref(sessionID))
|
||||
const answer = page.locator('[data-timeline-part-id="answer_cold_paths:text:0"]')
|
||||
const content = page.locator("[data-timeline-virtual-content]")
|
||||
await expect(answer.locator('code[data-inline-code-kind="path"]')).toHaveCount(2)
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
const held = await holdRoute(page, /\/api\/fs\/find\?/)
|
||||
|
||||
try {
|
||||
await page.reload({ waitUntil: "domcontentloaded" })
|
||||
await held.arrived
|
||||
await expect(answer.getByRole("table", { includeHidden: true })).toBeAttached()
|
||||
await expect(content).toHaveCSS("visibility", "hidden")
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.fill("The composer still works")
|
||||
await expect(editor).toHaveText("The composer still works")
|
||||
|
||||
const reveal = await content.evaluateHandle((root) => {
|
||||
type PathReveal = { kinds?: (string | null)[] }
|
||||
|
||||
const state: PathReveal = {}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!root.checkVisibility({ checkVisibilityCSS: true })) return
|
||||
state.kinds = [...root.querySelectorAll("code")].map((code) => code.getAttribute("data-inline-code-kind"))
|
||||
observer.disconnect()
|
||||
})
|
||||
|
||||
observer.observe(root, { attributes: true, attributeFilter: ["style"] })
|
||||
|
||||
return state
|
||||
})
|
||||
|
||||
held.release()
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
expect(await reveal.evaluate((state) => state.kinds)).toEqual(["path", "path", null])
|
||||
await reveal.dispose()
|
||||
await expect(answer.locator("code")).toHaveText(["src/file.ts", "src/file.ts", "missing/file.ts"])
|
||||
await expect(answer.locator('code[data-inline-code-kind="path"]')).toHaveText(["src/file.ts", "src/file.ts"])
|
||||
await expect(answer.getByText("missing/file.ts", { exact: true })).not.toHaveAttribute("data-inline-code-kind")
|
||||
} finally {
|
||||
held.release()
|
||||
}
|
||||
})
|
||||
|
||||
test("restores verified inline paths before painting after a workspace tab switch", async ({ page }) => {
|
||||
const directory = "C:/OpenCode/InlinePaths"
|
||||
const other = "C:/OpenCode/OtherInlinePaths"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { render } from "solid-js/web"
|
||||
import { DialogProvider } from "@opencode/ui/context/dialog"
|
||||
import { Markdown } from "../src/components/markdown"
|
||||
import { preloadMarkdown } from "../src/components/markdown-cache"
|
||||
import { MarkdownProvider } from "../src/context/markdown"
|
||||
import { MarkdownProvider, type MarkdownLocalFileExists } from "../src/context/markdown"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { readLocalImage } from "../../app/src/runtime/server/image"
|
||||
|
||||
@@ -29,6 +29,7 @@ export async function mountMarkdown(options: {
|
||||
cached?: boolean
|
||||
images?: boolean
|
||||
synchronousFiles?: boolean
|
||||
localFileExists?: MarkdownLocalFileExists
|
||||
}) {
|
||||
if (options.cached) await preloadMarkdown(options.text, "markdown-test")
|
||||
const host = document.createElement("div")
|
||||
@@ -59,7 +60,8 @@ export async function mountMarkdown(options: {
|
||||
options.images ? readLocalImage(api, "C:/project", path, signal) : Promise.resolve(undefined)
|
||||
}
|
||||
localFileExists={(path) =>
|
||||
options.synchronousFiles ? existingFiles.has(path) : Promise.resolve(existingFiles.has(path))
|
||||
options.localFileExists?.(path) ??
|
||||
(options.synchronousFiles ? existingFiles.has(path) : Promise.resolve(existingFiles.has(path)))
|
||||
}
|
||||
>
|
||||
<Show when={visible()}>
|
||||
|
||||
@@ -151,6 +151,102 @@ for (const cached of [false, true]) {
|
||||
})
|
||||
}
|
||||
|
||||
story("does not report completed Markdown ready until all inline path checks settle", async ({ page }) => {
|
||||
const checks = await page.evaluateHandle(async (fixture) => {
|
||||
const { mountMarkdown } = await import(fixture)
|
||||
const existing = Promise.withResolvers<boolean>()
|
||||
const missing = Promise.withResolvers<boolean>()
|
||||
const requested: string[] = []
|
||||
await mountMarkdown({
|
||||
text: "`src/file.ts` and `src/file.ts` and `missing/file.ts`",
|
||||
localFileExists: (path: string) => {
|
||||
requested.push(path)
|
||||
|
||||
return path === "src/file.ts" ? existing.promise : missing.promise
|
||||
},
|
||||
})
|
||||
|
||||
return { requested, existing: () => existing.resolve(true), missing: () => missing.reject(new Error("Offline")) }
|
||||
}, fixture)
|
||||
|
||||
const markdown = page.getByTestId("markdown-fixture").locator('[data-component="markdown"]')
|
||||
await expect(markdown.locator("code")).toHaveCount(3)
|
||||
expect(await checks.evaluate((state) => state.requested)).toEqual(["src/file.ts", "missing/file.ts"])
|
||||
await expect(markdown).not.toHaveAttribute("data-markdown-ready")
|
||||
await checks.evaluate((state) => state.existing())
|
||||
await expect(markdown.locator('code[data-inline-code-kind="path"]')).toHaveCount(2)
|
||||
await expect(markdown).not.toHaveAttribute("data-markdown-ready")
|
||||
await checks.evaluate((state) => state.missing())
|
||||
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(markdown.getByText("missing/file.ts", { exact: true })).not.toHaveAttribute("data-inline-code-kind")
|
||||
await checks.dispose()
|
||||
})
|
||||
|
||||
story("ignores stale inline path results when completed Markdown is replaced", async ({ page }) => {
|
||||
const checks = await page.evaluateHandle(async (fixture) => {
|
||||
const { mountMarkdown } = await import(fixture)
|
||||
const first = Promise.withResolvers<boolean>()
|
||||
const next = Promise.withResolvers<boolean>()
|
||||
const requested: string[] = []
|
||||
await mountMarkdown({
|
||||
text: "Before `src/file.ts`",
|
||||
cached: true,
|
||||
localFileExists: (path: string) => {
|
||||
requested.push(path)
|
||||
|
||||
return requested.length === 1 ? first.promise : next.promise
|
||||
},
|
||||
})
|
||||
|
||||
return { requested, first: () => first.resolve(true), next: () => next.resolve(false) }
|
||||
}, fixture)
|
||||
|
||||
const harness = page.getByTestId("markdown-fixture")
|
||||
const markdown = harness.locator('[data-component="markdown"]')
|
||||
await expect(markdown.locator("p")).toHaveText("Before src/file.ts")
|
||||
await expect(markdown).not.toHaveAttribute("data-markdown-ready")
|
||||
await harness.getByLabel("Markdown text").fill("Replacement `src/file.ts`")
|
||||
await expect(markdown.locator("p")).toHaveText("Replacement src/file.ts")
|
||||
expect(await checks.evaluate((state) => state.requested)).toEqual(["src/file.ts", "src/file.ts"])
|
||||
await checks.evaluate((state) => state.first())
|
||||
await expect(markdown).not.toHaveAttribute("data-markdown-ready")
|
||||
await checks.evaluate((state) => state.next())
|
||||
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(markdown.locator('code[data-inline-code-kind="path"]')).toHaveCount(0)
|
||||
await checks.dispose()
|
||||
})
|
||||
|
||||
story("keeps streaming Markdown ready while a completed prefix waits for a path check", async ({ page }) => {
|
||||
const checks = await page.evaluateHandle(async (fixture) => {
|
||||
const { mountMarkdown } = await import(fixture)
|
||||
const existing = Promise.withResolvers<boolean>()
|
||||
const requested: string[] = []
|
||||
await mountMarkdown({
|
||||
text: "Completed `src/file.ts`.\n\nStreaming **answer**",
|
||||
streaming: true,
|
||||
localFileExists: (path: string) => {
|
||||
requested.push(path)
|
||||
|
||||
return existing.promise
|
||||
},
|
||||
})
|
||||
|
||||
return { requested, resolve: () => existing.resolve(true) }
|
||||
}, fixture)
|
||||
|
||||
const harness = page.getByTestId("markdown-fixture")
|
||||
const markdown = harness.locator('[data-component="markdown"]')
|
||||
await expect(markdown.locator("strong")).toHaveText("answer")
|
||||
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
||||
expect(await checks.evaluate((state) => state.requested)).toEqual(["src/file.ts"])
|
||||
await harness.getByLabel("Streaming").uncheck()
|
||||
await expect(markdown).not.toHaveAttribute("data-markdown-ready")
|
||||
await checks.evaluate((state) => state.resolve())
|
||||
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(markdown.locator('code[data-inline-code-kind="path"]')).toHaveText("src/file.ts")
|
||||
await checks.dispose()
|
||||
})
|
||||
|
||||
story("mounts cached completed Markdown with sanitized HTML and decorations", async ({ page }) => {
|
||||
await page.evaluate(
|
||||
async ({ fixture, text }) => {
|
||||
|
||||
@@ -74,6 +74,10 @@ const renderedMarkdown = new WeakMap<
|
||||
{ renderer: ReturnType<typeof createMarkdownRenderer>; raw: string }
|
||||
>()
|
||||
|
||||
type InlineCodeCheck = { pending?: Promise<void> }
|
||||
|
||||
const inlineCodeChecks = new WeakMap<HTMLDivElement, InlineCodeCheck>()
|
||||
|
||||
function escape(text: string) {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
@@ -222,6 +226,7 @@ function disposeRenderedMarkdown(root: Element) {
|
||||
blocks.forEach((block) => {
|
||||
renderedMarkdown.get(block)?.renderer.dispose()
|
||||
renderedMarkdown.delete(block)
|
||||
inlineCodeChecks.delete(block)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -395,9 +400,13 @@ function markInlineCode(
|
||||
source: HTMLDivElement,
|
||||
target: HTMLDivElement,
|
||||
live: boolean,
|
||||
signal: AbortSignal,
|
||||
localFileExists?: MarkdownLocalFileExists,
|
||||
) {
|
||||
const check: InlineCodeCheck = {}
|
||||
inlineCodeChecks.set(target, check)
|
||||
const checked = new Map<string, boolean | Promise<boolean>>()
|
||||
const pending: Promise<void>[] = []
|
||||
|
||||
for (const code of source.querySelectorAll<HTMLElement>(":not(pre) > code")) {
|
||||
delete code.dataset.inlineCodeKind
|
||||
@@ -427,15 +436,25 @@ function markInlineCode(
|
||||
continue
|
||||
}
|
||||
|
||||
void exists
|
||||
.then((exists) => {
|
||||
if (!exists) return
|
||||
target.querySelectorAll<HTMLElement>(":not(pre) > code").forEach((node) => {
|
||||
if (!node.closest("a") && (node.textContent ?? "").trim() === text) node.dataset.inlineCodeKind = "path"
|
||||
pending.push(
|
||||
exists
|
||||
.then((exists) => {
|
||||
if (!exists || signal.aborted || inlineCodeChecks.get(target) !== check) return
|
||||
target.querySelectorAll<HTMLElement>(":not(pre) > code").forEach((node) => {
|
||||
if (!node.closest("a") && (node.textContent ?? "").trim() === text) node.dataset.inlineCodeKind = "path"
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.catch(() => undefined),
|
||||
)
|
||||
}
|
||||
|
||||
if (pending.length === 0) return
|
||||
|
||||
check.pending = Promise.all(pending).then(() => {
|
||||
if (inlineCodeChecks.get(target) === check) inlineCodeChecks.delete(target)
|
||||
})
|
||||
|
||||
return check.pending
|
||||
}
|
||||
|
||||
function localLinkTarget(target: EventTarget | null) {
|
||||
@@ -815,10 +834,17 @@ export function Markdown(
|
||||
const sessionLinks = markdown?.sessionTitle
|
||||
const localFileExists = markdown?.localFileExists
|
||||
|
||||
content.forEach((block, index) => {
|
||||
if (renderedBlocks[index] === block && container.children[index] instanceof HTMLDivElement) return
|
||||
updateBlock(container, index, block, labels, sessionLinks, localFileExists)
|
||||
const pending = content.flatMap((block, index) => {
|
||||
const current = container.children[index]
|
||||
|
||||
const check =
|
||||
renderedBlocks[index] === block && current instanceof HTMLDivElement
|
||||
? inlineCodeChecks.get(current)?.pending
|
||||
: updateBlock(container, index, block, labels, sessionLinks, lifetime.signal, localFileExists)
|
||||
|
||||
return check ? [check] : []
|
||||
})
|
||||
|
||||
renderedBlocks = content
|
||||
|
||||
if (sessionLinks) markSessionLinks(container, sessionLinks)
|
||||
@@ -854,7 +880,20 @@ export function Markdown(
|
||||
if (!faviconCleanup) faviconCleanup = setupExternalLinkFavicons(container)
|
||||
container.toggleAttribute("data-local-links", !!markdown?.openLocalFile)
|
||||
|
||||
if (result?.ready && result.text === local.text) container.dataset.markdownReady = ""
|
||||
if (!result?.ready || result.text !== local.text) return
|
||||
|
||||
if (local.streaming || pending.length === 0) {
|
||||
container.dataset.markdownReady = ""
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Cold timelines already wait for Markdown readiness before measuring and revealing their content. Include
|
||||
// path classification so the first visible frame uses the final blue-link styling and padding.
|
||||
void Promise.all(pending).then(() => {
|
||||
if (lifetime.signal.aborted || renderedBlocks !== content || result.text !== local.text) return
|
||||
container.dataset.markdownReady = ""
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -943,6 +982,7 @@ function updateBlock(
|
||||
block: RenderedBlock,
|
||||
labels: CopyLabels,
|
||||
sessionLinks: ((id: string) => string | undefined) | undefined,
|
||||
signal: AbortSignal,
|
||||
localFileExists?: MarkdownLocalFileExists,
|
||||
) {
|
||||
const current = container.children[index]
|
||||
@@ -962,10 +1002,11 @@ function updateBlock(
|
||||
// A block that finishes streaming keeps its DOM and the user's selection; only its file paths get checked now.
|
||||
if (existing.dataset.markdownMode === "live" && block.mode !== "live") {
|
||||
existing.dataset.markdownMode = block.mode
|
||||
markInlineCode(existing, existing, false, localFileExists)
|
||||
|
||||
return markInlineCode(existing, existing, false, signal, localFileExists)
|
||||
}
|
||||
|
||||
return
|
||||
return inlineCodeChecks.get(existing)?.pending
|
||||
}
|
||||
|
||||
const next = existing ?? document.createElement("div")
|
||||
@@ -980,7 +1021,7 @@ function updateBlock(
|
||||
|
||||
if (source === next) disposeCopyButtons(next)
|
||||
source.innerHTML = block.html
|
||||
markInlineCode(source, next, block.mode === "live", localFileExists)
|
||||
const pending = markInlineCode(source, next, block.mode === "live", signal, localFileExists)
|
||||
markCodeLinks(source)
|
||||
wrapTables(source)
|
||||
|
||||
@@ -991,7 +1032,7 @@ function updateBlock(
|
||||
rendered.renderer.update(source.innerHTML, block.mode === "live", rendered.raw !== block.raw)
|
||||
rendered.raw = block.raw
|
||||
|
||||
return
|
||||
return pending
|
||||
}
|
||||
|
||||
if (block.mode === "live") {
|
||||
@@ -1020,17 +1061,19 @@ function updateBlock(
|
||||
})
|
||||
}
|
||||
|
||||
if (existing) return
|
||||
if (existing) return pending
|
||||
|
||||
if (!current) {
|
||||
container.appendChild(next)
|
||||
|
||||
return
|
||||
return pending
|
||||
}
|
||||
|
||||
disposeCopyButtons(current)
|
||||
disposeRenderedMarkdown(current)
|
||||
current.replaceWith(next)
|
||||
|
||||
return pending
|
||||
}
|
||||
|
||||
function updateCodeBlock(
|
||||
|
||||
Reference in New Issue
Block a user