Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8529d49
fix(web,mobile): render Codex file citations in markdown
Yash-Singh1 Aug 28, 2026
ac1fa0a
fix(clients): harden Codex file citations
Yash-Singh1 Aug 28, 2026
71a7a79
fix(web): preserve directive text layout
Yash-Singh1 Aug 28, 2026
089edc8
fix(web): isolate Codex citation parsing
Yash-Singh1 Aug 28, 2026
e08a6d6
fix(web): preserve editable markdown offsets
Yash-Singh1 Aug 28, 2026
c5ce1fd
chore(web): remove stale directive parser pieces
Yash-Singh1 Aug 28, 2026
92b91bc
fix(web,mobile): render Codex artifact templates in chat
Yash-Singh1 Aug 29, 2026
1ce7d48
chore: remove generated test document
Yash-Singh1 Aug 29, 2026
5b03916
Merge remote-tracking branch 'origin/main' into t3code/codex-citation…
Yash-Singh1 Aug 29, 2026
a65eede
fix(clients): avoid stale artifact template prompts
Yash-Singh1 Aug 29, 2026
37449a9
fix(web): isolate artifact template card blocks
Yash-Singh1 Aug 29, 2026
66c83ce
fix(web): preserve artifact card copy text
Yash-Singh1 Aug 29, 2026
ed399ba
fix(web): separate copied artifact cards
Yash-Singh1 Aug 29, 2026
090d68f
fix(web): parse Codex directives in remark
Yash-Singh1 Aug 29, 2026
51cad4d
Merge remote-tracking branch 'origin/main' into t3code/codex-citation…
Yash-Singh1 Aug 29, 2026
0d5051a
fix(web): transform recovered Codex directives
Yash-Singh1 Aug 29, 2026
4651874
fix(web): disambiguate Codex citation labels
Yash-Singh1 Aug 29, 2026
7b97b89
Merge remote-tracking branch 'origin/main' into t3code/codex-citation…
Yash-Singh1 Aug 29, 2026
007af60
fix(clients): recover indented Codex citations
Yash-Singh1 Aug 29, 2026
4522041
fix(clients): align artifact template actions
Yash-Singh1 Aug 29, 2026
68f8573
Merge remote-tracking branch 'origin/main' into t3code/codex-citation…
Yash-Singh1 Aug 29, 2026
c499630
fix(mobile): copy artifact template card text
Yash-Singh1 Aug 29, 2026
dc8a05e
refactor(clients): share Codex directive parser
Yash-Singh1 Aug 29, 2026
6a922d1
Merge remote-tracking branch 'origin/main' into t3code/codex-citation…
Yash-Singh1 Aug 29, 2026
7a8a3cc
Merge remote-tracking branch 'origin/main' into t3code/codex-citation…
Yash-Singh1 Aug 29, 2026
e5f62a9
fix(clients): keep Codex directive copy consistent
Yash-Singh1 Aug 29, 2026
0fd0984
fix(web): preserve recovered Codex directives
Yash-Singh1 Aug 29, 2026
197bd2c
fix(web): address directive interaction reviews
Yash-Singh1 Aug 29, 2026
1fdb112
Merge remote-tracking branch 'origin/main' into t3code/codex-citation…
Yash-Singh1 Aug 29, 2026
85e5a0d
Merge remote-tracking branch 'origin/main' into t3code/codex-citation…
Yash-Singh1 Aug 29, 2026
a8a0b1c
Merge remote-tracking branch 'origin/main' into t3code/codex-citation…
Yash-Singh1 Aug 30, 2026
2039443
Merge remote-tracking branch 'origin/main' into t3code/codex-citation…
Yash-Singh1 Aug 30, 2026
314bc9c
Merge remote-tracking branch 'origin/main' into t3code/codex-citation…
Yash-Singh1 Aug 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@ import type {

const EMPTY_SKILLS: ReadonlyArray<SelectableMarkdownSkill> = [];

function applyBeforeParsePlugins(
markdown: string,
plugins: SelectableMarkdownTextProps["plugins"],
): string {
if (!plugins || plugins.length === 0) return markdown;

let transformed = markdown;
const sortedPlugins = [...plugins].sort(
(left, right) => (right.priority ?? 0) - (left.priority ?? 0),
);
for (const plugin of sortedPlugins) {
if (!plugin.beforeParse) continue;
try {
transformed = plugin.beforeParse(transformed);
} catch {
// Match Nitro Markdown's plugin fallback: keep the last valid source.
}
}
return transformed;
}

export type {
MarkdownCodeHighlighter,
MarkdownHighlightedToken,
Expand All @@ -33,6 +54,7 @@ export function hasNativeSelectableMarkdownText(): boolean {

export function SelectableMarkdownText({
markdown,
plugins,
skills = EMPTY_SKILLS,
textStyle,
highlightCode,
Expand All @@ -43,7 +65,7 @@ export function SelectableMarkdownText({
marginBottom = 0,
}: SelectableMarkdownTextProps) {
const chunks = useMemo(() => {
const parsedDocument = parseMarkdownWithOptions(markdown, {
const parsedDocument = parseMarkdownWithOptions(applyBeforeParsePlugins(markdown, plugins), {
gfm: true,
html: true,
math: false,
Expand All @@ -59,7 +81,7 @@ export function SelectableMarkdownText({
}
: chunk,
);
}, [markdown, preserveSoftBreaks, skills]);
}, [markdown, plugins, preserveSoftBreaks, skills]);

return (
<MarkdownImageRendererContext.Provider value={renderImage ?? null}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { MarkdownPlugin } from "react-native-nitro-markdown";

export interface NativeMarkdownTextStyle {
readonly color: string;
readonly strongColor: string;
Expand Down Expand Up @@ -54,6 +56,7 @@ export interface SelectableMarkdownTextProps {
readonly markdown: string;
readonly textStyle: NativeMarkdownTextStyle;
readonly highlightCode: MarkdownCodeHighlighter;
readonly plugins?: ReadonlyArray<MarkdownPlugin>;
readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
readonly preserveSoftBreaks?: boolean;
readonly onLinkPress?: (href: string) => void;
Expand Down
22 changes: 16 additions & 6 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
import { useAssetUrl, useAssetUrlState } from "../../state/assets";
import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";
import {
CODEX_FILE_CITATION_MARKDOWN_PLUGINS,
replaceCodexFileCitationsWithMarkdownLinks,
} from "../../lib/codexFileCitationMarkdown";

const WIDE_MARKDOWN_BLOCK_OPTIONS = {
includeOrderedLists: Platform.OS === "android",
Expand Down Expand Up @@ -1026,6 +1030,10 @@ function renderFeedEntry(
if (entry.type === "message") {
const { message } = entry;
const isUser = message.role === "user";
const renderedText = message.text;
const copiedText = isUser
? renderedText
: replaceCodexFileCitationsWithMarkdownLinks(renderedText);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Comment thread
Yash-Singh1 marked this conversation as resolved.
Outdated
const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
const attachments = (message.attachments ?? []).filter(
Expand All @@ -1037,7 +1045,7 @@ function renderFeedEntry(
// children during the unclamped pass and never moves them once the width
// is clamped, so the paragraphs around the block end up drawn on top of
// each other. Pinning the width removes that pass.
const hasWideBlock = hasWideMarkdownBlock(message.text, WIDE_MARKDOWN_BLOCK_OPTIONS);
const hasWideBlock = hasWideMarkdownBlock(renderedText, WIDE_MARKDOWN_BLOCK_OPTIONS);
const assistantTurnStillInProgress =
message.role === "assistant" &&
props.unsettledTurnId !== null &&
Expand Down Expand Up @@ -1109,7 +1117,7 @@ function renderFeedEntry(

// Skip empty assistant messages (no text, no attachments) — they would
// render as an orphaned timestamp and break adjacent activity-group merging.
if (message.text.trim().length === 0 && attachments.length === 0) {
if (renderedText.trim().length === 0 && attachments.length === 0) {
return null;
}

Expand All @@ -1119,10 +1127,11 @@ function renderFeedEntry(
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")}
{...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}
>
{message.text.trim().length > 0 ? (
{renderedText.trim().length > 0 ? (
hasNativeSelectableMarkdownText() ? (
<SelectableMarkdownText
markdown={message.text}
markdown={renderedText}
plugins={CODEX_FILE_CITATION_MARKDOWN_PLUGINS}
skills={props.skills}
textStyle={styles.nativeTextStyle}
onLinkPress={props.onMarkdownLinkPress}
Expand All @@ -1131,11 +1140,12 @@ function renderFeedEntry(
) : (
<Markdown
options={{ gfm: true }}
plugins={CODEX_FILE_CITATION_MARKDOWN_PLUGINS}
renderers={styles.renderers}
styles={styles.styles}
theme={styles.theme}
>
{message.text}
{renderedText}
</Markdown>
)
) : null}
Expand All @@ -1154,7 +1164,7 @@ function renderFeedEntry(
<View className="mt-1 flex-row items-center gap-1">
<CopyTextButton
accessibilityLabel="Copy message"
text={message.text}
text={copiedText}
tintColor={iconSubtleColor}
buttonSize={28}
iconSize={13}
Expand Down
19 changes: 19 additions & 0 deletions apps/mobile/src/lib/codexFileCitationMarkdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vite-plus/test";

import { CODEX_FILE_CITATION_MARKDOWN_PLUGINS } from "./codexFileCitationMarkdown";

describe("Codex file citation Nitro Markdown plugin", () => {
it("uses the parser-backed citation transformer before Nitro parses", () => {
const transform = CODEX_FILE_CITATION_MARKDOWN_PLUGINS[0]?.beforeParse;
const directive = ':codex-file-citation{path="src/main.ts" line_range_start="5"}';

expect(transform?.(`Source: ${directive}`)).toBe("Source: [main.ts](<src/main.ts#L5>)");
});

it("escapes filename syntax and preserves literal URL delimiters", () => {
const transform = CODEX_FILE_CITATION_MARKDOWN_PLUGINS[0]?.beforeParse;
const directive = ':codex-file-citation{path="reports/*draft* #1?.txt" line_range_start="5"}';

expect(transform?.(directive)).toBe("[\\*draft\\* #1?.txt](<reports/*draft* %231%3F.txt#L5>)");
});
});
13 changes: 13 additions & 0 deletions apps/mobile/src/lib/codexFileCitationMarkdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { replaceCodexFileCitationsWithMarkdownLinks } from "@t3tools/client-runtime/codex-file-citation-markdown";
import type { MarkdownPlugin } from "react-native-nitro-markdown";

const CODEX_FILE_CITATION_MARKDOWN_PLUGIN: MarkdownPlugin = {
name: "codex-file-citations",
beforeParse: replaceCodexFileCitationsWithMarkdownLinks,
};

export const CODEX_FILE_CITATION_MARKDOWN_PLUGINS: MarkdownPlugin[] = [
CODEX_FILE_CITATION_MARKDOWN_PLUGIN,
];

export { replaceCodexFileCitationsWithMarkdownLinks };
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-directive": "^4.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.11"
Expand Down
161 changes: 161 additions & 0 deletions apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,167 @@ describe("ChatMarkdown file option chips", () => {
expect(html).toContain('aria-haspopup="menu"');
expect(html).toContain("select-text");
});

it.each([true, false])(
"renders Codex file citations as file chips with parseRawHtml=%s",
(parseRawHtml) => {
const html = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
text={
'Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx" purpose="output"}.'
}
lineBreaks={!parseRawHtml}
parseRawHtml={parseRawHtml}
/>,
);

expect(html).not.toContain("codex-file-citation");
expect(html).toContain("chat-markdown-file-link");
expect(html).toContain(
'data-markdown-copy="[report.xlsx](/tmp/project/outputs/report.xlsx)"',
);
expect(html).toContain("report.xlsx");
},
);

it("leaves an unfinished streaming citation visible until it is complete", () => {
const html = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
text={'Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx"'}
isStreaming
/>,
);

expect(html).toContain(":codex-file-citation");
expect(html).not.toContain("chat-markdown-file-link");
});

it("preserves Codex file citation examples inside code", () => {
const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}';
const html = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
text={`Example: \`${directive}\`\n\n\`\`\`text\n${directive}\n\`\`\``}
/>,
);

expect(html.match(/:codex-file-citation/g)).toHaveLength(2);
expect(html).not.toContain("chat-markdown-file-link");
});

it("preserves escaped Codex file citations as literal text", () => {
const html = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
text={'Example: \\:codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'}
/>,
);

expect(html).toContain(":codex-file-citation");
expect(html).not.toContain("chat-markdown-file-link");
});

it.each([
"Meeting at 10:30",
"Open src/main.ts:42",
"Use :hover and :tada:",
"Error:ENOENT",
"See ::note",
"See :::note\ncontent\n:::",
])("preserves non-Codex directive-like text: %s", (text) => {
const html = renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={text} />);

expect(html).toContain(text);
});

it.each([
"[Error:ENOENT explained](https://example.com)",
"[Error:ENOENT explained][docs]\n\n[docs]: https://example.com",
])("preserves directive-like text inside links: %s", (text) => {
const html = renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={text} />);
const renderedText = html.replace(/<[^>]+>/g, "");

expect(renderedText).toContain("Error:ENOENT explained");
});

it("does not create a nested link for citations inside link text", () => {
const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}';
const html = renderToStaticMarkup(
<ChatMarkdown cwd="/tmp/project" text={`[See ${directive}](https://example.com)`} />,
);
const renderedText = html.replace(/<[^>]+>/g, "");

expect(renderedText).toContain("codex-file-citation");
expect(html).not.toContain("chat-markdown-file-link");
});

it("keeps flow directive-like text in paragraphs", () => {
const html = renderToStaticMarkup(
<ChatMarkdown cwd="/tmp/project" text={"before\n\n::note\n\n:::note\ncontent\n:::"} />,
);

expect(html).toContain("<p>::note</p>");
expect(html).toContain("<p>:::note\ncontent\n:::</p>");
});

it("applies line-break rendering to flow directive-like text", () => {
const html = renderToStaticMarkup(
<ChatMarkdown cwd="/tmp/project" text={":::note\ncontent\n:::"} lineBreaks />,
);

expect(html).toContain("<p>:::note<br/>\ncontent<br/>\n:::</p>");
});

it("keeps inline Markdown in over-indented list recovery", () => {
const html = renderToStaticMarkup(
<ChatMarkdown cwd="/tmp/project" text={"- Updated `src/main.ts`"} />,
);

expect(html).not.toContain("<pre>");
expect(html).toContain("Updated ");
expect(html).toContain("chat-markdown-file-link");
});

it("keeps Markdown semantics inside unknown container-like text", () => {
const html = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
text={":::note\n**important** [docs](https://example.com) `inline`\n:::"}
/>,
);

expect(html).toContain("<strong>important</strong>");
expect(html).toContain(">inline</code>");
expect(html).toContain('href="https://example.com"');
});

it("renders citations after an unclosed container-like marker", () => {
const html = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
text={':::note\nCreated :codex-file-citation{path="/tmp/project/outputs/report.xlsx"}.'}
/>,
);

expect(html).toContain(":::note");
expect(html).toContain("chat-markdown-file-link");
expect(html).toContain("report.xlsx");
});

it("keeps task-list offsets on the original editable file source", () => {
const text =
':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}\n\n- [ ] Review output';
const markerOffset = text.indexOf("[ ]");
const html = renderToStaticMarkup(
<ChatMarkdown cwd="/tmp/project" text={text} onTaskListChange={() => undefined} />,
);

expect(html).toContain("codex-file-citation");
expect(html).not.toContain("chat-markdown-file-link");
expect(html).toContain(`data-task-marker-offset="${markerOffset}"`);
});
});

describe("shouldUseMarkdownFileBrowserPrimaryAction", () => {
Expand Down
Loading
Loading