authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-04 22:23:29-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 22:23:29-03:00
log801ca76ed6a5b9d5d6a40f6c1ba231d9ffb3757e
tree6dc730cdabd906afd7bf7b383f57942d06a82278
parentec20db4a5a27de45f69f64ca05d17ec1bd96644d
signature Signed by PGP key B5690EEEBB952194

revert(chat): remove automatic file-link repair (#15824)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

5 files changed, 3 insertions(+), 216 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+2-13
......@@ -10,7 +10,6 @@ import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
1010import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";
1111import { scopeThreadRef } from "@t3tools/client-runtime/environment";
1212import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
13import { repairMarkdownFileLinks } from "@t3tools/client-runtime/repair-markdown-file-links";
1413import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
1514import {
1615 type OrchestrationMessageContext,
......@@ -908,17 +907,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
908907 readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined;
909908}) {
910909 const segments = useMemo(
911 () =>
912 splitCodexArtifactTemplateMarkdown(props.markdown).map((segment) =>
913 segment.kind === "markdown"
914 ? {
915 ...segment,
916 markdown: renderCodexFileCitationsAsMarkdown(
917 repairMarkdownFileLinks(segment.markdown),
918 ),
919 }
920 : segment,
921 ),
910 () => splitCodexArtifactTemplateMarkdown(props.markdown),
922911 [props.markdown],
923912 );
924913
......@@ -934,7 +923,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
934923 }
935924 if (segment.markdown.trim().length === 0) return null;
936925
937 const markdown = segment.markdown;
926 const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown);
938927 return hasNativeSelectableMarkdownText() ? (
939928 <SelectableMarkdownText
940929 key={`markdown:${segment.sourceOffset}`}
apps/web/src/components/chat/MessagesTimeline.tsx+1-3
......@@ -35,7 +35,6 @@ import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/en
3535import { useAtomValue } from "@effect/atom-react";
3636import { environmentThreadDetails } from "../../state/threads";
3737import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
38import { repairMarkdownFileLinks } from "@t3tools/client-runtime/repair-markdown-file-links";
3938import { Link } from "@tanstack/react-router";
4039import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
4140import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution";
......@@ -2495,7 +2494,6 @@ function AttemptFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "at
24952494function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
24962495 const ctx = use(TimelineRowCtx);
24972496 const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
2498 const renderedText = useMemo(() => repairMarkdownFileLinks(messageText), [messageText]);
24992497
25002498 return (
25012499 <>
......@@ -2509,7 +2507,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
25092507 listRef={ctx.listRef}
25102508 >
25112509 <ChatMarkdown
2512 text={renderedText}
2510 text={messageText}
25132511 cwd={ctx.markdownCwd}
25142512 threadRef={ctx.threadRef ?? undefined}
25152513 isStreaming={Boolean(row.message.streaming)}
packages/client-runtime/package.json-4
......@@ -55,10 +55,6 @@
5555 "types": "./src/markdownLinks.ts",
5656 "default": "./src/markdownLinks.ts"
5757 },
58 "./repair-markdown-file-links": {
59 "types": "./src/repairMarkdownFileLinks.ts",
60 "default": "./src/repairMarkdownFileLinks.ts"
61 },
6258 "./media-reference": {
6359 "types": "./src/mediaReference.ts",
6460 "default": "./src/mediaReference.ts"
packages/client-runtime/src/markdownLinks.test.ts-112
......@@ -1,6 +1,4 @@
11import { describe, expect, it } from "vite-plus/test";
2import remarkParse from "remark-parse";
3import { unified } from "unified";
42
53import {
64 fileBasename,
......@@ -11,116 +9,6 @@ import {
119 splitFilePathPosition,
1210 workspaceRelativeFilePath,
1311} from "./markdownLinks.ts";
14import { repairMarkdownFileLinks } from "./repairMarkdownFileLinks.ts";
15import {
16 renderCodexDirectivesForCopy,
17 renderCodexFileCitationsAsMarkdown,
18 splitCodexArtifactTemplateMarkdown,
19} from "./codexMarkdownDirectives.ts";
20
21describe("repairMarkdownFileLinks", () => {
22 it.each([
23 "CONTRIBUTING.md",
24 "local/path/file.md",
25 "docs/My Folder/file.md",
26 "./scripts/deploy",
27 "~/notes/today.md",
28 "/home/me/My Folder/file.md",
29 "/tmp/reports/",
30 "C:\\Users\\me\\My Folder\\file.md",
31 "file:///home/me/file.md",
32 "src/main.ts:12:5",
33 ])("repairs a complete local destination: %s", (path) => {
34 const source = `- [file](<${path})`;
35 const rendered = repairMarkdownFileLinks(source);
36 expect(rendered).toBe(`- [file](<${path}>)`);
37 expect(unified().use(remarkParse).parse(rendered).children[0]).toMatchObject({
38 type: "list",
39 children: [{ children: [{ children: [{ type: "link" }] }] }],
40 });
41 expect(repairMarkdownFileLinks(rendered)).toBe(rendered);
42 });
43
44 it.each([
45 "ordinary prose",
46 "[file](<CONTRIBUTING.md>)",
47 "[site](<https://example.com/docs)",
48 "[route](</chat/settings)",
49 "[prose](<not a file)",
50 "[file](<src/main.ts",
51 "[file](<src/main.ts>",
52 "[file](<./file(one).md)",
53 "[file](<./file.md))",
54 "[file](<./file)name.md)",
55 "[file](<./file\\).md)",
56 "![image](<./file.png)",
57 "\\\\![image](<./file.png)",
58 "\\\\\\\\![image](<./file.png)",
59 "\\[file](<./file.md)",
60 "\\\\\\[file](<./file.md)",
61 "`[file](<./file.md)`",
62 "``[file](<./file.md)``",
63 "```md\n[file](<./file.md)\n```",
64 "~~~md\n[file](<./file.md)\n~~~",
65 " [file](<./file.md)",
66 "<div>\n[file](<./file.md)\n</div>",
67 "HTML <span>[file](<./file.md)</span>",
68 '<span title="[file](<./file.md)">text</span>',
69 "[outer [file](<./file.md)](https://example.com)",
70 "[outer [file](<./file.md)][ref]\n\n[ref]: https://example.com",
71 '[ref]: ./file.md "[file](<./other.md)"',
72 ':codex-file-citation{path="./[file](<./other.md)" purpose="output"}',
73 ':codex-file-citation{path="./[file](<./other.md)',
74 '::artifact-template{skill_name="artifact-template-example" skill_directory="/tmp/skills/example" display_name="[file](<./file.md)" artifact_kind="document"}',
75 '::artifact-template{display_name="[file](<./file.md)',
76 ])("preserves protected or uncertain syntax: %s", (source) => {
77 expect(repairMarkdownFileLinks(source)).toBe(source);
78 });
79
80 it("uses CommonMark backslash parity for link openers", () => {
81 expect(repairMarkdownFileLinks("\\\\[file](<./file.md)")).toBe("\\\\[file](<./file.md>)");
82 expect(repairMarkdownFileLinks("\\\\\\\\[file](<./file.md)")).toBe(
83 "\\\\\\\\[file](<./file.md>)",
84 );
85 expect(repairMarkdownFileLinks("\\![file](<./file.md)")).toBe("\\![file](<./file.md>)");
86 });
87
88 it("repairs ordinary segments after splitting mobile artifacts and preserves copy source", () => {
89 const link = "[file](<./file.md)";
90 const template =
91 '::artifact-template{skill_name="artifact-template-example" skill_directory="/tmp/skills/example" display_name="[file](<./file.md)" artifact_kind="document"}';
92 const citation = ':codex-file-citation{path="./report.md"}';
93 const source = `${link}\n\n${template}\n\n${citation}`;
94 const segments = splitCodexArtifactTemplateMarkdown(source);
95 const rendered = segments.map((segment) =>
96 segment.kind === "markdown"
97 ? {
98 ...segment,
99 markdown: renderCodexFileCitationsAsMarkdown(repairMarkdownFileLinks(segment.markdown)),
100 }
101 : segment,
102 );
103 expect(rendered[0]).toEqual({
104 kind: "markdown",
105 markdown: `${link.slice(0, -1)}>)\n\n`,
106 sourceOffset: 0,
107 });
108 expect(rendered[1]).toEqual(segments[1]);
109 expect(rendered[1]).toMatchObject({
110 kind: "artifact-template",
111 sourceOffset: link.length + 2,
112 template: { displayName: link, skillName: "artifact-template-example" },
113 });
114 expect(rendered[2]).toEqual({
115 kind: "markdown",
116 sourceOffset: source.indexOf("\n\n" + citation),
117 markdown: "\n\n[report.md](<./report.md>)",
118 });
119 expect(renderCodexDirectivesForCopy(source)).toBe(
120 `${link}\n\n${link} (Document template)\n\n[report.md](<./report.md>)`,
121 );
122 });
123});
12412
12513describe("isMarkdownFileLinkLabel", () => {
12614 it.each([
packages/client-runtime/src/repairMarkdownFileLinks.ts deleted-84
......@@ -1,84 +0,0 @@
1import remarkParse from "remark-parse";
2import { unified } from "unified";
3
4import { remarkCodexDirectives } from "./codexMarkdownDirectives.ts";
5import { parseMarkdownFileLink } from "./markdownLinks.ts";
6
7const parser = unified().use(remarkParse).use(remarkCodexDirectives).freeze();
8const CANDIDATE = /\[[^[\]\r\n]*\]\([ \t]*<([^<>()\r\n]+)\)/g;
9const PROTECTED = new Set([
10 "code",
11 "inlineCode",
12 "html",
13 "link",
14 "image",
15 "linkReference",
16 "imageReference",
17 "definition",
18 "textDirective",
19 "leafDirective",
20 "containerDirective",
21]);
22
23function isEscaped(source: string, offset: number): boolean {
24 let backslashes = 0;
25 while (offset > 0 && source[--offset] === "\\") backslashes += 1;
26 return backslashes % 2 === 1;
27}
28
29/** Closes only complete, unambiguous local-file destinations for assistant rendering. */
30export function repairMarkdownFileLinks(markdown: string): string {
31 if (!/\]\([ \t]*</.test(markdown)) return markdown;
32 const candidates = Array.from(markdown.matchAll(CANDIDATE)).filter((match) => {
33 const start = match.index;
34 const close = start + match[0].length - 1;
35 return (
36 !isEscaped(markdown, start) &&
37 !(markdown[start - 1] === "!" && !isEscaped(markdown, start - 1)) &&
38 !isEscaped(markdown, close) &&
39 (close + 1 === markdown.length || /[\s.,;:!?}\]*_~]/.test(markdown[close + 1] ?? "")) &&
40 parseMarkdownFileLink(match[1] ?? "") !== null
41 );
42 });
43 if (candidates.length === 0) return markdown;
44
45 const protectedRanges: Array<{ start: number; end: number }> = [];
46 interface Node {
47 type: string;
48 position?:
49 | { start: { offset?: number | undefined }; end: { offset?: number | undefined } }
50 | undefined;
51 children?: Node[] | undefined;
52 }
53 const visit = (node: Node, paragraph?: Node) => {
54 const containingParagraph = node.type === "paragraph" ? node : paragraph;
55 const sourceStart = node.position?.start.offset;
56 const sourceEnd = node.position?.end.offset;
57 const unfinishedDirective =
58 (node.type === "textDirective" && markdown[sourceEnd ?? -1] === "{") ||
59 (node.type === "text" &&
60 markdown.slice(sourceStart, sourceEnd).includes("::artifact-template{"));
61 if (PROTECTED.has(node.type) || unfinishedDirective) {
62 // Inline HTML and unfinished directive attributes can span separate text nodes.
63 const protectedNode =
64 node.type === "html" || unfinishedDirective ? (containingParagraph ?? node) : node;
65 const start = protectedNode.position?.start.offset;
66 const end = protectedNode.position?.end.offset;
67 if (start !== undefined && end !== undefined) protectedRanges.push({ start, end });
68 return;
69 }
70 node.children?.forEach((child) => visit(child, containingParagraph));
71 };
72 visit(parser.parse(markdown));
73
74 let rendered = "";
75 let cursor = 0;
76 for (const match of candidates) {
77 const start = match.index;
78 const end = start + match[0].length;
79 if (protectedRanges.some((range) => start < range.end && end > range.start)) continue;
80 rendered += markdown.slice(cursor, end - 1) + ">";
81 cursor = end - 1;
82 }
83 return rendered + markdown.slice(cursor);
84}