authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 04:47:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 04:47:28-07:00
log0796638512d4c610469905adc597cc33c307fc32
treeeeb0e19a37b3493062b1be82989a86b4b533ba74
parent7c43155883dcfe67340611ec68719aaeb3a5f8ff
signature Signed by PGP key B5690EEEBB952194

fix(chat): Codex follow-up suggestions show their label instead of raw markup (#19401)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

6 files changed, 122 insertions(+), 18 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+2-2
......@@ -55,7 +55,7 @@ import {
5555} from "@t3tools/client-runtime/markdown-images";
5656import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation";
5757import {
58 renderCodexFileCitationsAsMarkdown,
58 renderCodexInlineDirectivesAsMarkdown,
5959 splitCodexArtifactTemplateMarkdown,
6060} from "@t3tools/shared/codexMarkdownDirectives";
6161import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
......@@ -837,7 +837,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
837837 }
838838 if (segment.markdown.trim().length === 0) return null;
839839
840 const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown);
840 const markdown = renderCodexInlineDirectivesAsMarkdown(segment.markdown);
841841 return (
842842 <SelectableMarkdownText
843843 key={`markdown:${segment.sourceOffset}`}
apps/web/src/components/ChatMarkdown.test.tsx+30
......@@ -791,6 +791,36 @@ describe("ChatMarkdown file option chips", () => {
791791 });
792792});
793793
794describe("ChatMarkdown Codex follow-up suggestions", () => {
795 it.each([true, false])("renders the label with parseRawHtml=%s", (parseRawHtml) => {
796 const html = renderToStaticMarkup(
797 <ChatMarkdown
798 cwd="/tmp/project"
799 text={
800 'Done.\n\n- :codex-followup[Prepare print version]{prompt="Prepare the document for printing."}'
801 }
802 lineBreaks={!parseRawHtml}
803 parseRawHtml={parseRawHtml}
804 />,
805 );
806
807 expect(html).toContain("Prepare print version");
808 expect(html).not.toContain("codex-followup");
809 expect(html).not.toContain("Prepare the document for printing.");
810 });
811
812 it("keeps the label of a malformed follow-up created by over-indented list recovery", () => {
813 const html = renderToStaticMarkup(
814 <ChatMarkdown
815 cwd="/tmp/project"
816 text="- Next :codex-followup[Prepare print version]"
817 />,
818 );
819
820 expect(html).toContain("<li>Next :codex-followup[Prepare print version]</li>");
821 });
822});
823
794824const ARTIFACT_TEMPLATE_DIRECTIVE =
795825 '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}';
796826
apps/web/src/components/ChatMarkdown.tsx+2-2
......@@ -110,7 +110,7 @@ import { MarkdownThreadLink } from "./chat/MarkdownThreadLink";
110110import { isWindowsAbsolutePath } from "@t3tools/shared/path";
111111import {
112112 artifactTemplateFromHastProperties,
113 renderCodexFileCitationsAsMarkdown,
113 renderCodexInlineDirectivesAsMarkdown,
114114} from "@t3tools/shared/codexMarkdownDirectives";
115115import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
116116import {
......@@ -2453,7 +2453,7 @@ function useChatMarkdownState({
24532453 string,
24542454 NonNullable<ReturnType<typeof resolveMarkdownFileLinkMeta>>
24552455 >();
2456 for (const href of extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(text))) {
2456 for (const href of extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(text))) {
24572457 if (parseComposerContextHref(href)) continue;
24582458 const normalizedHref = normalizeMarkdownLinkHrefKey(href);
24592459 if (metaByHref.has(normalizedHref)) continue;
packages/shared/src/codexMarkdownDirectives.test.ts+40-6
......@@ -5,7 +5,7 @@ import { unified } from "unified";
55import {
66 remarkCodexDirectives,
77 renderCodexDirectivesForCopy,
8 renderCodexFileCitationsAsMarkdown,
8 renderCodexInlineDirectivesAsMarkdown,
99 splitCodexArtifactTemplateMarkdown,
1010} from "./codexMarkdownDirectives.ts";
1111import { parseMarkdownFileLink } from "./markdownLinks.ts";
......@@ -26,6 +26,8 @@ interface TestNode {
2626}
2727
2828const FILE_CITATION = ':codex-file-citation{path="outputs/report.xlsx" purpose="output"}';
29const FOLLOW_UP =
30 ':codex-followup[Prepare print version]{prompt="Prepare the document for printing."}';
2931const ARTIFACT_TEMPLATE =
3032 '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}';
3133
......@@ -70,6 +72,15 @@ describe("remarkCodexDirectives", () => {
7072 });
7173 });
7274
75 it("renders a follow-up suggestion as its label", () => {
76 const followUp = parse(`Next: ${FOLLOW_UP}`).children?.[0]?.children?.[1];
77
78 expect(followUp).toMatchObject({
79 data: { hName: "span" },
80 children: [{ type: "text", value: "Prepare print version" }],
81 });
82 });
83
7384 it.each([
7485 "Meeting at 10:30",
7586 "Open src/main.ts:42",
......@@ -78,6 +89,7 @@ describe("remarkCodexDirectives", () => {
7889 ":::note\ncontent\n:::",
7990 ':codex-file-citation-extra{path="outputs/report.xlsx"}',
8091 "::artifact-template-extra",
92 ':codex-followup-extra[Label]{prompt="Prompt"}',
8193 ])("does not change unrelated colon syntax: %s", (markdown) => {
8294 expect(parse(markdown)).toEqual(parseOrdinaryMarkdown(markdown));
8395 });
......@@ -85,6 +97,9 @@ describe("remarkCodexDirectives", () => {
8597 it.each([
8698 ':codex-file-citation{purpose="output"}',
8799 '::artifact-template{skill_name="artifact-template-hello-world"}',
100 ":codex-followup[Prepare print version]",
101 ':codex-followup[ ]{prompt="Prepare the document for printing."}',
102 ':codex-followup[Prepare print version]{prompt=" "}',
88103 ])("keeps malformed supported directives literal: %s", (markdown) => {
89104 expect(parse(markdown)).toEqual(parseOrdinaryMarkdown(markdown));
90105 });
......@@ -92,7 +107,7 @@ describe("remarkCodexDirectives", () => {
92107
93108describe.each([
94109 { name: "renderCodexDirectivesForCopy", render: renderCodexDirectivesForCopy },
95 { name: "renderCodexFileCitationsAsMarkdown", render: renderCodexFileCitationsAsMarkdown },
110 { name: "renderCodexInlineDirectivesAsMarkdown", render: renderCodexInlineDirectivesAsMarkdown },
96111])("$name file citation round trips", ({ render }) => {
97112 it.each([
98113 "C:\\Users\\test\\[draft]\\report.md",
......@@ -110,18 +125,33 @@ describe.each([
110125
111126describe("native Markdown adapters", () => {
112127 it("uses the same parser to render file citations as portable links", () => {
113 expect(renderCodexFileCitationsAsMarkdown(`Created ${FILE_CITATION}.`)).toBe(
128 expect(renderCodexInlineDirectivesAsMarkdown(`Created ${FILE_CITATION}.`)).toBe(
114129 "Created [report.xlsx](<outputs/report.xlsx>).",
115130 );
116131 });
117132
133 it("renders follow-up suggestions as their label Markdown", () => {
134 expect(
135 renderCodexInlineDirectivesAsMarkdown(
136 `- ${FOLLOW_UP}\n- :codex-followup[Review **document**]{prompt="Review it."}`,
137 ),
138 ).toBe("- Prepare print version\n- Review **document**");
139 });
140
141 it.each([`\\${FOLLOW_UP}`, `\`${FOLLOW_UP}\``])(
142 "does not render excluded follow-up syntax: %s",
143 (markdown) => {
144 expect(renderCodexInlineDirectivesAsMarkdown(markdown)).toBe(markdown);
145 },
146 );
147
118148 it.each([
119149 `\\${FILE_CITATION}`,
120150 `\`${FILE_CITATION}\``,
121151 `\`\`\`text\n${FILE_CITATION}\n\`\`\``,
122152 `[See ${FILE_CITATION}](https://example.com)`,
123153 ])("does not render excluded citation syntax: %s", (markdown) => {
124 expect(renderCodexFileCitationsAsMarkdown(markdown)).toBe(markdown);
154 expect(renderCodexInlineDirectivesAsMarkdown(markdown)).toBe(markdown);
125155 });
126156
127157 it("splits artifact cards from surrounding native Markdown", () => {
......@@ -159,8 +189,12 @@ describe("native Markdown adapters", () => {
159189
160190describe("directive copy adapter", () => {
161191 it("copies the Markdown representations shown by citation chips and template cards", () => {
162 expect(renderCodexDirectivesForCopy(`Created ${FILE_CITATION}.\n\n${ARTIFACT_TEMPLATE}`)).toBe(
163 "Created [report.xlsx](<outputs/report.xlsx>).\n\nHello World (Document template)",
192 expect(
193 renderCodexDirectivesForCopy(
194 `Created ${FILE_CITATION}.\n\n${ARTIFACT_TEMPLATE}\n\n- ${FOLLOW_UP}`,
195 ),
196 ).toBe(
197 "Created [report.xlsx](<outputs/report.xlsx>).\n\nHello World (Document template)\n\n- Prepare print version",
164198 );
165199 });
166200
packages/shared/src/codexMarkdownDirectives.ts+46-6
......@@ -20,6 +20,7 @@ const COLON = 58;
2020const DASH = 45;
2121const UNDERSCORE = 95;
2222const CODEX_FILE_CITATION_NAME = "codex-file-citation";
23const CODEX_FOLLOW_UP_NAME = "codex-followup";
2324const CODEX_ARTIFACT_TEMPLATE_NAME = "artifact-template";
2425
2526export const CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES = [
......@@ -46,6 +47,7 @@ interface MarkdownAstNode {
4647 data?: {
4748 codexArtifactTemplate?: CodexArtifactTemplate;
4849 codexFileCitationMarkdown?: string;
50 codexFollowUp?: true;
4951 hName?: string;
5052 hProperties?: Record<string, unknown>;
5153 };
......@@ -132,7 +134,10 @@ function codexDirectiveSyntax(): Extension {
132134
133135 return {
134136 text: {
135 [COLON]: restrictedDirective(textDirective, 1, CODEX_FILE_CITATION_NAME),
137 [COLON]: [
138 restrictedDirective(textDirective, 1, CODEX_FILE_CITATION_NAME),
139 restrictedDirective(textDirective, 1, CODEX_FOLLOW_UP_NAME),
140 ],
136141 },
137142 flow: {
138143 [COLON]: restrictedDirective(leafDirective, 2, CODEX_ARTIFACT_TEMPLATE_NAME),
......@@ -149,15 +154,20 @@ function sourceForNode(node: MarkdownAstNode, source: string): string {
149154 return start === undefined || end === undefined ? "" : source.slice(start, end);
150155}
151156
157function textContent(node: MarkdownAstNode): string {
158 return node.value ?? (node.children ?? []).map(textContent).join("");
159}
160
152161function sourceForDirective(node: MarkdownAstNode, source: string, marker: ":" | "::"): string {
153162 const prefix = `${marker}${node.name ?? ""}`;
154163 const slicedSource = sourceForNode(node, source);
155164 if (slicedSource.startsWith(prefix)) return slicedSource;
156165
166 const label = node.children?.length ? `[${textContent(node)}]` : "";
157167 const attributes = Object.entries(node.attributes ?? {}).map(([name, value]) =>
158168 value === null ? name : `${name}=${JSON.stringify(value)}`,
159169 );
160 return `${prefix}${attributes.length === 0 ? "" : `{${attributes.join(" ")}}`}`;
170 return `${prefix}${label}${attributes.length === 0 ? "" : `{${attributes.join(" ")}}`}`;
161171}
162172
163173function restoreTextDirective(node: MarkdownAstNode, source: string): void {
......@@ -199,6 +209,18 @@ function renderFileCitation(node: MarkdownAstNode, source: string, insideLink: b
199209 delete node.value;
200210}
201211
212/** Shows only the label. T3 has no suggestion UI, so the hidden prompt is not shown. */
213function renderFollowUp(node: MarkdownAstNode, source: string): void {
214 if (!node.attributes?.prompt?.trim() || !textContent(node).trim()) {
215 restoreTextDirective(node, source);
216 return;
217 }
218
219 node.data = { codexFollowUp: true, hName: "span" };
220 delete node.name;
221 delete node.attributes;
222}
223
202224function renderArtifactTemplate(node: MarkdownAstNode, source: string): void {
203225 const template = resolveCodexArtifactTemplate(node.attributes);
204226 if (!template) {
......@@ -231,6 +253,9 @@ function transformCodexDirectives(node: MarkdownAstNode, source: string, insideL
231253 renderFileCitation(node, source, insideLink);
232254 return;
233255 }
256 if (node.type === "textDirective" && node.name === CODEX_FOLLOW_UP_NAME) {
257 renderFollowUp(node, source);
258 }
234259 if (node.type === "leafDirective" && node.name === CODEX_ARTIFACT_TEMPLATE_NAME) {
235260 renderArtifactTemplate(node, source);
236261 return;
......@@ -242,7 +267,7 @@ function transformCodexDirectives(node: MarkdownAstNode, source: string, insideL
242267 }
243268}
244269
245/** Adds grammar only for the two directives emitted by Codex, then renders them as mdast. */
270/** Adds grammar only for the directives emitted by Codex, then renders them as mdast. */
246271function attachCodexDirectives(this: Processor) {
247272 const data = this.data();
248273 const micromarkExtensions = data.micromarkExtensions ?? (data.micromarkExtensions = []);
......@@ -270,6 +295,7 @@ interface DirectiveMatch {
270295 readonly end: number;
271296 readonly markdown?: string;
272297 readonly template?: CodexArtifactTemplate;
298 readonly labelRange?: readonly [start: number, end: number];
273299}
274300
275301function collectDirectiveMatches(node: MarkdownAstNode, matches: DirectiveMatch[]): void {
......@@ -284,6 +310,12 @@ function collectDirectiveMatches(node: MarkdownAstNode, matches: DirectiveMatch[
284310 matches.push({ start, end, template: node.data.codexArtifactTemplate });
285311 return;
286312 }
313 const labelStart = node.children?.[0]?.position?.start.offset;
314 const labelEnd = node.children?.at(-1)?.position?.end.offset;
315 if (node.data?.codexFollowUp && labelStart !== undefined && labelEnd !== undefined) {
316 matches.push({ start, end, labelRange: [labelStart, labelEnd] });
317 return;
318 }
287319 }
288320 for (const child of node.children ?? []) collectDirectiveMatches(child, matches);
289321}
......@@ -296,7 +328,9 @@ function renderDirectiveMatches(
296328 collectDirectiveMatches(parseCodexMarkdown(markdown), matches);
297329 let rendered = markdown;
298330 for (const match of matches.sort((left, right) => right.start - left.start)) {
299 const replacement = replacementFor(match);
331 const replacement = match.labelRange
332 ? markdown.slice(...match.labelRange)
333 : replacementFor(match);
300334 if (replacement !== undefined) {
301335 rendered = rendered.slice(0, match.start) + replacement + rendered.slice(match.end);
302336 }
......@@ -305,8 +339,13 @@ function renderDirectiveMatches(
305339}
306340
307341/** Native Markdown renderers use this adapter because they cannot consume a Remark tree. */
308export function renderCodexFileCitationsAsMarkdown(markdown: string): string {
309 if (!markdown.includes(`:${CODEX_FILE_CITATION_NAME}`)) return markdown;
342export function renderCodexInlineDirectivesAsMarkdown(markdown: string): string {
343 if (
344 !markdown.includes(`:${CODEX_FILE_CITATION_NAME}`) &&
345 !markdown.includes(`:${CODEX_FOLLOW_UP_NAME}`)
346 ) {
347 return markdown;
348 }
310349
311350 return renderDirectiveMatches(markdown, (match) => match.markdown);
312351}
......@@ -315,6 +354,7 @@ export function renderCodexFileCitationsAsMarkdown(markdown: string): string {
315354export function renderCodexDirectivesForCopy(markdown: string): string {
316355 if (
317356 !markdown.includes(`:${CODEX_FILE_CITATION_NAME}`) &&
357 !markdown.includes(`:${CODEX_FOLLOW_UP_NAME}`) &&
318358 !markdown.includes(`::${CODEX_ARTIFACT_TEMPLATE_NAME}`)
319359 ) {
320360 return markdown;
packages/shared/src/threadFindText.ts+2-2
......@@ -5,7 +5,7 @@ import {
55} from "./inlineSkills.ts";
66import {
77 artifactTemplateFromHastProperties,
8 renderCodexFileCitationsAsMarkdown,
8 renderCodexInlineDirectivesAsMarkdown,
99} from "./codexMarkdownDirectives.ts";
1010import { codexArtifactTemplatePresentationLabel } from "./codexArtifactTemplates.ts";
1111import {
......@@ -117,7 +117,7 @@ function markdownThreadFindText(
117117 : assistantProcessor;
118118 const tree = processor.runSync(processor.parse(markdown));
119119 const paths = [
120 ...extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(markdown)),
120 ...extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(markdown)),
121121 ...extractInlineCodeSpans(markdown).flatMap((span) => inlineCodeFilePathCandidate(span) ?? []),
122122 ].flatMap((href) => {
123123 const target = resolveMarkdownFileLinkTarget(href, cwd);