Repository navigation
feat(web): render Codex file-citation chips #6103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
7847f53
0584217
7a9fe99
1453d9a
880703d
7afa39b
5099280
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -73,6 +73,11 @@ import { | |
| serializeTableElementToMarkdown, | ||
| } from "../markdown-clipboard"; | ||
| import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; | ||
| import { | ||
| codexFileCitationHref, | ||
| extractCodexFileCitationPaths, | ||
| remarkCodexFileCitations, | ||
| } from "../markdown-codex-file-citations"; | ||
| import { | ||
| normalizeMarkdownLinkDestination, | ||
| resolveInlineCodeFileLinkMeta, | ||
|
|
@@ -1343,12 +1348,25 @@ function ChatMarkdown({ | |
| serverConfig?.availableEditors ?? [], | ||
| ); | ||
| const diffThemeName = resolveDiffThemeName(resolvedTheme); | ||
| const markdownRemarkPlugins = useMemo<NonNullable<ReactMarkdownOptions["remarkPlugins"]>>( | ||
| () => [ | ||
| ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), | ||
| [remarkCodexFileCitations, { cwd }], | ||
| ], | ||
|
Comment on lines
+1351
to
+1358
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This installs the citation transformation only in the web AGENTS.md reference: AGENTS.md:L67-L71 Useful? React with 👍 / 👎. |
||
| [cwd, lineBreaks], | ||
| ); | ||
| const markdownFileLinkMetaByHref = useMemo(() => { | ||
| const metaByHref = new Map< | ||
| string, | ||
| NonNullable<ReturnType<typeof resolveMarkdownFileLinkMeta>> | ||
| >(); | ||
| for (const href of extractMarkdownLinkHrefs(text)) { | ||
| // Codex citations are rewritten into links on the mdast, so they are not in the text as | ||
| // Markdown link syntax — their hrefs are read from the directives themselves. | ||
| const hrefs = [ | ||
| ...extractMarkdownLinkHrefs(text), | ||
| ...extractCodexFileCitationPaths(text).map(codexFileCitationHref), | ||
| ]; | ||
| for (const href of hrefs) { | ||
| const normalizedHref = normalizeMarkdownLinkHrefKey(href); | ||
| if (metaByHref.has(normalizedHref)) continue; | ||
| const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); | ||
|
|
@@ -1704,9 +1722,7 @@ function ChatMarkdown({ | |
| onCopy={handleCopy} | ||
| > | ||
| <ReactMarkdown | ||
| remarkPlugins={ | ||
| lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS | ||
| } | ||
| remarkPlugins={markdownRemarkPlugins} | ||
| rehypePlugins={CHAT_MARKDOWN_REHYPE_PLUGINS} | ||
| components={markdownComponents} | ||
| urlTransform={markdownUrlTransform} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import { renderToStaticMarkup } from "react-dom/server"; | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
|
|
||
| import ChatMarkdown from "./components/ChatMarkdown"; | ||
| import { extractCodexFileCitationPaths } from "./markdown-codex-file-citations"; | ||
|
|
||
| const WORKING_DIRECTORY = "/Users/example/project"; | ||
|
|
||
| function renderMessage(text: string, cwd = WORKING_DIRECTORY): string { | ||
| return renderToStaticMarkup(<ChatMarkdown cwd={cwd} text={text} />); | ||
| } | ||
|
|
||
| describe("Codex file citations", () => { | ||
| it("renders an artifact citation as the local file chip", () => { | ||
| const html = renderMessage( | ||
| 'Created :codex-file-citation{path="/Users/example/project/output/report.docx" purpose="output"}.', | ||
| ); | ||
|
|
||
| expect(html).toContain("chat-markdown-file-link"); | ||
| expect(html).toContain('href="/Users/example/project/output/report.docx"'); | ||
| expect(html).toContain(">report.docx</span>"); | ||
| expect(html).not.toContain("codex-file-citation"); | ||
| }); | ||
|
|
||
| it("resolves a relative path against the working directory, in either attribute order", () => { | ||
| const html = renderMessage(':codex-file-citation{purpose="source" path="output/report.docx"}'); | ||
|
|
||
| expect(html).toContain("chat-markdown-file-link"); | ||
| expect(html).toContain('href="/Users/example/project/output/report.docx"'); | ||
| }); | ||
|
|
||
| it("keeps spaces, parentheses, and escaped quotes in the path", () => { | ||
| const html = renderMessage( | ||
| 'See :codex-file-citation{path="/Users/example/project/out/final \\"v2\\" (a b).docx" purpose="output"}.', | ||
| ); | ||
|
|
||
| expect(html).toContain('href="/Users/example/project/out/final "v2" (a b).docx"'); | ||
| expect(html).toContain(">final "v2" (a b).docx</span>"); | ||
| }); | ||
|
|
||
| it("links an artifact written outside the workspace", () => { | ||
| const html = renderMessage(':codex-file-citation{path="/tmp/export.zip" purpose="output"}'); | ||
|
|
||
| expect(html).toContain("chat-markdown-file-link"); | ||
| expect(html).toContain('href="/tmp/export.zip"'); | ||
| }); | ||
|
|
||
| it("links a Windows path, whose drive letter would otherwise read as a URL scheme", () => { | ||
| const html = renderMessage( | ||
| ':codex-file-citation{path="C:/Users/me/report.docx" purpose="output"}', | ||
| "C:/Users/me", | ||
| ); | ||
|
|
||
| expect(html).toContain("chat-markdown-file-link"); | ||
| expect(html).toContain('href="C:/Users/me/report.docx"'); | ||
| }); | ||
|
|
||
| it("renders citations in chat-style messages, where single newlines are hard breaks", () => { | ||
| const html = renderToStaticMarkup( | ||
| <ChatMarkdown | ||
| cwd={WORKING_DIRECTORY} | ||
| lineBreaks | ||
| text={'Done:\n:codex-file-citation{path="/tmp/export.zip" purpose="output"}'} | ||
| />, | ||
| ); | ||
|
|
||
| expect(html).toContain("chat-markdown-file-link"); | ||
| expect(html).toContain('href="/tmp/export.zip"'); | ||
| }); | ||
|
|
||
| it("leaves a half-streamed directive as the text it is", () => { | ||
| const html = renderMessage('Creating :codex-file-citation{path="/tmp/unfinished'); | ||
|
|
||
| expect(html).not.toContain("chat-markdown-file-link"); | ||
| expect(html).toContain(":codex-file-citation{path="/tmp/unfinished"); | ||
| }); | ||
|
|
||
| it("leaves directives inside code spans and code fences alone", () => { | ||
| const html = renderMessage( | ||
| '`:codex-file-citation{path="/tmp/inline.txt" purpose="output"}`\n\n' + | ||
| '```text\n:codex-file-citation{path="/tmp/fenced.txt" purpose="output"}\n```', | ||
| ); | ||
|
|
||
| expect(html).not.toContain("chat-markdown-file-link"); | ||
| expect(html).toContain(":codex-file-citation{path="/tmp/inline.txt""); | ||
| expect(html).toContain(":codex-file-citation{path="/tmp/fenced.txt""); | ||
| }); | ||
|
|
||
| it("leaves a directive whose path is not a local file alone", () => { | ||
| const html = renderMessage(':codex-file-citation{path="https://example.com/report.pdf"}'); | ||
|
|
||
| expect(html).not.toContain("chat-markdown-file-link"); | ||
| expect(html).toContain(":codex-file-citation{path="); | ||
| }); | ||
|
|
||
| it("leaves a directive inside a link label alone, rather than nesting an anchor", () => { | ||
| const html = renderMessage( | ||
| '[see :codex-file-citation{path="/tmp/export.zip" purpose="output"}](https://example.com)', | ||
| ); | ||
|
|
||
| expect(html).not.toContain("chat-markdown-file-link"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("extractCodexFileCitationPaths", () => { | ||
| it("reads every cited path, unescaped, and ignores directives without one", () => { | ||
| expect( | ||
| extractCodexFileCitationPaths( | ||
| 'One :codex-file-citation{path="src/a.ts" purpose="output"}, ' + | ||
| 'two :codex-file-citation{purpose="source" path="src/b \\"quoted\\".ts"}, ' + | ||
| 'three :codex-file-citation{purpose="output"}, ' + | ||
| 'four :codex-file-citation{path="src/d.ts', | ||
| ), | ||
| ).toEqual(["src/a.ts", 'src/b "quoted".ts']); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| /** | ||
| * Codex's artifact skills cite the files they produce with a directive of their own rather than a | ||
| * Markdown link: | ||
| * | ||
| * Created :codex-file-citation{path="/Users/me/project/output/report.docx" purpose="output"}. | ||
| * | ||
| * Nothing in the pipeline parses it, so the whole directive renders as literal text. This rewrites | ||
| * each one into an ordinary link node, which lands the citation on the file chip the renderer | ||
| * already gives Markdown file links — same path resolution, same open-in-editor and preview | ||
| * behavior, no second way to open a file. | ||
| * | ||
| * A directive is only rewritten when its path resolves to a file: an unresolvable path is not | ||
| * something the chip could open, and half-streamed or malformed markup should keep reading as the | ||
| * text it is. | ||
| */ | ||
|
|
||
| import { resolveMarkdownFileLinkMeta } from "./markdown-links"; | ||
|
|
||
| const CODEX_FILE_CITATION_PATTERN = /:codex-file-citation\{([^{}\r\n]*)\}/g; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a cited filename or directory contains Useful? React with 👍 / 👎. |
||
| // The path ends at the quote closing its attribute — the one followed by the next attribute or by | ||
| // the end of the directive. Matching lazily up to that lookahead keeps `report.docx" purpose="out` | ||
| // out of the path while still allowing a quote inside the path itself. | ||
| const CITATION_PATH_ATTRIBUTE_PATTERN = /(?:^|\s)path="(.*?)"(?=\s*$|\s+[A-Za-z_][\w-]*=)/; | ||
| // Markdown consumes a backslash before ASCII punctuation before this plugin sees the text, so a | ||
| // directive that escaped its quotes arrives unescaped. Stripping the escapes here too keeps the | ||
| // raw-source scan below reading the same path the rewritten nodes point at. | ||
| const MARKDOWN_ESCAPE_PATTERN = /\\([!-/:-@[-`{-~])/g; | ||
| // A citation path is a filesystem path, not a URL. Percent-encoding the characters that URL | ||
| // parsing would otherwise claim — a Windows drive colon read as a scheme, `#` and `?` read as a | ||
| // fragment and a query — keeps the path intact through react-markdown's URL transform and the | ||
| // sanitizer, and `resolveMarkdownFileLinkMeta` decodes it back on the other side. | ||
| const URL_SYNTAX_CHARACTER_PATTERN = /[:#?]/g; | ||
|
|
||
| interface MarkdownAstNode { | ||
| type?: string; | ||
| value?: unknown; | ||
| url?: string; | ||
| children?: MarkdownAstNode[]; | ||
| } | ||
|
|
||
| interface RemarkCodexFileCitationsOptions { | ||
| readonly cwd?: string | undefined; | ||
| } | ||
|
|
||
| function readCitationPath(attributes: string): string | null { | ||
|
macroscopeapp[bot] marked this conversation as resolved.
macroscopeapp[bot] marked this conversation as resolved.
|
||
| const path = CITATION_PATH_ATTRIBUTE_PATTERN.exec(attributes)?.[1] | ||
| ?.replace(MARKDOWN_ESCAPE_PATTERN, "$1") | ||
| .trim(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Codex emits a native Windows path containing consecutive backslashes or a separator before punctuation, this replacement treats that separator as a Markdown escape. For example, Useful? React with 👍 / 👎. |
||
| return path ? path : null; | ||
| } | ||
|
|
||
| export function codexFileCitationHref(path: string): string { | ||
| return encodeURI(path).replace(URL_SYNTAX_CHARACTER_PATTERN, encodeURIComponent); | ||
|
macroscopeapp[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| /** The cited paths, read straight from the message text, for the file-link metadata lookup. */ | ||
| export function extractCodexFileCitationPaths(text: string): string[] { | ||
| const paths: string[] = []; | ||
| for (const match of text.matchAll(CODEX_FILE_CITATION_PATTERN)) { | ||
| const path = readCitationPath(match[1] ?? ""); | ||
| if (path) paths.push(path); | ||
| } | ||
| return paths; | ||
| } | ||
|
|
||
| function rewriteCitations(value: string, cwd: string | undefined): MarkdownAstNode[] | null { | ||
| const nodes: MarkdownAstNode[] = []; | ||
| let cursor = 0; | ||
| for (const match of value.matchAll(CODEX_FILE_CITATION_PATTERN)) { | ||
| const path = readCitationPath(match[1] ?? ""); | ||
| if (!path) continue; | ||
| const href = codexFileCitationHref(path); | ||
| const fileLinkMeta = resolveMarkdownFileLinkMeta(href, cwd); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a filename contains a CommonMark entity sequence such as Useful? React with 👍 / 👎. |
||
| if (!fileLinkMeta) continue; | ||
|
Comment on lines
+135
to
+136
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a directive names a valid path that the generic Markdown-link heuristic does not recognize—such as Useful? React with 👍 / 👎. |
||
|
|
||
| if (match.index > cursor) { | ||
| nodes.push({ type: "text", value: value.slice(cursor, match.index) }); | ||
| } | ||
| nodes.push({ | ||
| type: "link", | ||
| url: href, | ||
| children: [{ type: "text", value: fileLinkMeta.basename }], | ||
| }); | ||
| cursor = match.index + match[0].length; | ||
| } | ||
|
|
||
| if (nodes.length === 0) return null; | ||
| if (cursor < value.length) nodes.push({ type: "text", value: value.slice(cursor) }); | ||
| return nodes; | ||
| } | ||
|
|
||
| export function remarkCodexFileCitations(options: RemarkCodexFileCitationsOptions = {}) { | ||
| return (tree: MarkdownAstNode) => { | ||
| const visit = (node: MarkdownAstNode, insideLink: boolean) => { | ||
| if (!node.children) return; | ||
|
|
||
| // Code spans and fences are `inlineCode` and `code` nodes rather than text, so they are | ||
| // skipped for free. A citation inside a link label is skipped deliberately: linkifying it | ||
| // would nest an anchor inside the link's anchor. | ||
| const childInsideLink = insideLink || node.type === "link" || node.type === "linkReference"; | ||
| node.children = node.children.flatMap((child) => { | ||
| if (child.type === "text" && typeof child.value === "string" && !childInsideLink) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a legal filename contains balanced Markdown delimiters, such as Useful? React with 👍 / 👎. |
||
| return rewriteCitations(child.value, options.cwd) ?? child; | ||
| } | ||
| visit(child, childInsideLink); | ||
| return child; | ||
| }); | ||
| }; | ||
|
|
||
| visit(tree, false); | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When assistant output contains an accidentally over-indented bullet such as
- :codex-file-citation{path="/tmp/export.zip"}, CommonMark initially represents the body as acodenode, so this first plugin deliberately skips it. The laterremarkNormalizeListItemIndentationplugin in the spread reparses that node into ordinary text, but the citation pass has already finished, leaving the raw directive visible instead of a file chip. Ensure normalization-produced text also passes through citation rewriting, or protect directives before parsing.Useful? React with 👍 / 👎.