Skip to content
24 changes: 20 additions & 4 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Comment on lines +1356 to +1357

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Process citations recovered from over-indented list items

When assistant output contains an accidentally over-indented bullet such as - :codex-file-citation{path="/tmp/export.zip"}, CommonMark initially represents the body as a code node, so this first plugin deliberately skips it. The later remarkNormalizeListItemIndentation plugin 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 👍 / 👎.

[remarkCodexFileCitations, { cwd }],
],
Comment on lines +1351 to +1358

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Normalize citation directives in the mobile thread feed

This installs the citation transformation only in the web ChatMarkdown pipeline. The mobile assistant feed still passes message.text unchanged to SelectableMarkdownText or Markdown in apps/mobile/src/features/threads/ThreadFeed.tsx:966-982, so mobile users see the raw :codex-file-citation{...} directive and cannot open the generated artifact even though mobile already supports Markdown file links. Apply equivalent normalization in the shared/mobile rendering path.

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);
Expand Down Expand Up @@ -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}
Expand Down
116 changes: 116 additions & 0 deletions apps/web/src/markdown-codex-file-citations.test.tsx
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 &quot;v2&quot; (a b).docx"');
expect(html).toContain(">final &quot;v2&quot; (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=&quot;/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=&quot;/tmp/inline.txt&quot;");
expect(html).toContain(":codex-file-citation{path=&quot;/tmp/fenced.txt&quot;");
});

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']);
});
});
112 changes: 112 additions & 0 deletions apps/web/src/markdown-codex-file-citations.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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow braces inside quoted citation paths

When a cited filename or directory contains { or }—both legal path characters, for example /tmp/build/{draft}/report.pdf—this outer pattern rejects the entire directive before the quoted path attribute is parsed. Consequently both extraction and AST rewriting skip it, leaving raw citation markup instead of an openable chip. The directive scanner should distinguish braces inside quoted attribute values from the closing directive brace.

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 {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const path = CITATION_PATH_ATTRIBUTE_PATTERN.exec(attributes)?.[1]
?.replace(MARKDOWN_ESCAPE_PATTERN, "$1")
.trim();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve native Windows separators in citation paths

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, \\server\share\report.docx is reduced to a single leading slash and no longer resolves as UNC, while C:\repo\.env becomes C:\repo.env and opens the wrong file. Parse or protect the directive before CommonMark escape processing instead of stripping every backslash before ASCII punctuation.

Useful? React with 👍 / 👎.

return path ? path : null;
}

export function codexFileCitationHref(path: string): string {
return encodeURI(path).replace(URL_SYNTAX_CHARACTER_PATTERN, encodeURIComponent);
Comment thread
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve entity-like substrings in cited filenames

When a filename contains a CommonMark entity sequence such as /tmp/report&amp;notes.pdf, the Markdown parser decodes the text node to /tmp/report&notes.pdf before this rewrite, while extractCodexFileCitationPaths scans the raw message and stores metadata for the original path. The generated href therefore misses the metadata map and targets a different filename, rendering as an ordinary link instead of an openable file chip. Citation directives need to be parsed from their raw source or otherwise protected from entity decoding.

Useful? React with 👍 / 👎.

if (!fileLinkMeta) continue;
Comment on lines +135 to +136

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve explicit citations without Markdown path heuristics

When a directive names a valid path that the generic Markdown-link heuristic does not recognize—such as path="Makefile", path="/data/output", or the Windows-relative path="output\\report.docx"—resolveMarkdownFileLinkMeta returns null, so the directive remains raw text instead of becoming a chip. These paths are already explicitly identified as filesystem paths by the citation syntax; resolve them directly against cwd (while separately rejecting URLs) rather than requiring the narrower link-shape patterns in markdown-links.ts.

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Protect citation directives before Markdown tokenization

When a legal filename contains balanced Markdown delimiters, such as /tmp/*draft*.pdf or paired backticks, remark parses the path into separate text, emphasis, or inline-code nodes before this visitor runs. Since rewriteCitations is invoked on each text child independently and requires the complete directive, the citation is never converted into a file chip and instead renders as fragmented raw markup. Extract or protect directives before Markdown parsing so delimiter characters inside quoted paths remain part of the citation.

Useful? React with 👍 / 👎.

return rewriteCitations(child.value, options.cwd) ?? child;
}
visit(child, childInsideLink);
return child;
});
};

visit(tree, false);
};
}
Loading