authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-04 09:00:49-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 09:00:49-03:00
log1d013c7ca1edc843bd3b5e23fd6573aeeaf9b11d
treef11ffc4be0a4f74d81fd22f4baef7b3a60b17832
parentd3bec62ec167e783412effbc929da77167f08c4d
signature Signed by PGP key B5690EEEBB952194

fix(markdown): preserve descriptive file-link labels (#15509)


6 files changed, 278 insertions(+), 34 deletions(-)

apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+72-6
......@@ -1,4 +1,5 @@
11import type { MarkdownNode } from "react-native-nitro-markdown/headless";
2import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";
23import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
34import { imageMimeType } from "@t3tools/shared/image";
45import { isWindowsAbsolutePath } from "@t3tools/shared/path";
......@@ -109,7 +110,8 @@ export function nativeMarkdownContextCopyRanges(
109110 }>,
110111) {
111112 let offset = 0;
112 return runs.flatMap(({ run, text, inlineImageLength }) => {
113 const ranges: Array<{ start: number; end: number; text: string }> = [];
114 for (const [index, { run, text, inlineImageLength }] of runs.entries()) {
113115 const start = offset;
114116 offset += text.length + inlineImageLength;
115117 const reference = parseComposerContextHref(run.href ?? "");
......@@ -117,11 +119,25 @@ export function nativeMarkdownContextCopyRanges(
117119 ? formatComposerContextReference({ ...reference, label: run.text })
118120 : run.skillName
119121 ? `$${run.skillName}`
120 : run.fileIcon && run.href
121 ? (run.sourceText ?? `[${run.text}](<${run.href}>)`)
122 : null;
123 return source === null ? [] : [{ start, end: offset, text: source }];
124 });
122 : run.sourceText !== undefined
123 ? run.sourceText
124 : run.fileIcon && run.href
125 ? `[${run.text}](<${run.href}>)`
126 : null;
127 if (source === null) continue;
128 const previous = ranges.at(-1);
129 if (
130 run.sourceText !== undefined &&
131 !runs[index - 1]?.run.fileIcon &&
132 previous?.end === start &&
133 previous.text === source
134 ) {
135 previous.end = offset;
136 } else {
137 ranges.push({ start, end: offset, text: source });
138 }
139 }
140 return ranges;
125141}
126142
127143import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types";
......@@ -181,6 +197,7 @@ interface RunContext {
181197 readonly href?: string;
182198 readonly externalHost?: string;
183199 readonly fileIcon?: MarkdownFileIcon;
200 readonly sourceText?: string;
184201 readonly role?: NativeMarkdownTextRun["role"];
185202 readonly headingLevel?: number;
186203 readonly depth?: number;
......@@ -268,6 +285,7 @@ function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun)
268285 left.href === right.href &&
269286 left.externalHost === right.externalHost &&
270287 left.fileIcon === right.fileIcon &&
288 left.sourceText === right.sourceText &&
271289 left.skillName === right.skillName &&
272290 left.skillLabel === right.skillLabel &&
273291 left.role === right.role &&
......@@ -298,6 +316,7 @@ function appendRun(
298316 ...(context.href ? { href: context.href } : {}),
299317 ...(context.externalHost ? { externalHost: context.externalHost } : {}),
300318 ...(context.fileIcon ? { fileIcon: context.fileIcon } : {}),
319 ...(context.sourceText !== undefined ? { sourceText: context.sourceText } : {}),
301320 ...(context.role ? { role: context.role } : {}),
302321 ...(context.headingLevel ? { headingLevel: context.headingLevel } : {}),
303322 ...(context.depth ? { depth: context.depth } : {}),
......@@ -432,6 +451,36 @@ function nodeTextContent(node: MarkdownNode): string {
432451 return (node.children ?? []).map(nodeTextContent).join("");
433452}
434453
454function fileLinkLabelMarkdown(node: MarkdownNode): string {
455 const children = (node.children ?? []).map(fileLinkLabelMarkdown).join("");
456 switch (node.type) {
457 case "bold":
458 return `**${children}**`;
459 case "italic":
460 return `*${children}*`;
461 case "strikethrough":
462 return `~~${children}~~`;
463 case "code_inline": {
464 const content = nodeTextContent(node);
465 const fence = "`".repeat(
466 Math.max(0, ...(content.match(/`+/g) ?? []).map((run) => run.length)) + 1,
467 );
468 const padding = /^`|`$|^ .* $/.test(content) && content.trim().length > 0 ? " " : "";
469 return `${fence}${padding}${content}${padding}${fence}`;
470 }
471 case "soft_break":
472 return "\n";
473 case "line_break":
474 return " \n";
475 case "text":
476 return textNodeContent(nodeTextContent(node)).replace(/[\\[\]*_`~]/g, "\\$&");
477 case "image":
478 return `![${(node.alt ?? "").replace(/[\\[\]]/g, "\\$&")}](<${node.href ?? ""}>)`;
479 default:
480 return node.content ?? children;
481 }
482}
483
435484function appendNode(
436485 runs: NativeMarkdownTextRun[],
437486 node: MarkdownNode,
......@@ -482,6 +531,23 @@ function appendNode(
482531 }
483532 const presentation = resolveMarkdownLinkPresentation(node.href ?? "");
484533 if (presentation.kind === "file") {
534 const label = textNodeContent(nodeTextContent(node));
535 if (!isMarkdownFileLinkLabel(label, presentation.href)) {
536 const fileRuns: NativeMarkdownTextRun[] = [];
537 const fileContext = {
538 ...context,
539 href: presentation.href,
540 sourceText: `[${fileLinkLabelMarkdown(node)}](<${presentation.href}>)`,
541 };
542 appendChildren(fileRuns, node, fileContext);
543 appendRun(fileRuns, " ", fileContext);
544 appendRun(fileRuns, presentation.label, {
545 ...fileContext,
546 fileIcon: presentation.icon,
547 });
548 runs.push(...fileRuns);
549 return runs;
550 }
485551 return appendRun(runs, presentation.label, {
486552 ...context,
487553 href: presentation.href,
apps/mobile/src/features/threads/ThreadFeed.tsx+39-23
......@@ -36,6 +36,8 @@ import {
3636 type CodexArtifactTemplate,
3737} from "@t3tools/client-runtime/codex-artifact-templates";
3838import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
39import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";
40import { getTextContent, type MarkdownNode } from "react-native-nitro-markdown/headless";
3941import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
4042import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
4143import {
......@@ -721,7 +723,7 @@ interface MarkdownStyleSet {
721723}
722724
723725const failedMarkdownFaviconHosts = new Set<string>();
724const MarkdownLinkLabelContext = createContext(false);
726const MarkdownLinkLabelContext = createContext<"file" | "other" | null>(null);
725727const markdownLinkStyles = StyleSheet.create({
726728 inlineIcon: {
727729 width: 14,
......@@ -813,6 +815,22 @@ function MarkdownInlineCode(props: {
813815 );
814816}
815817
818function MarkdownImage(props: {
819 readonly node: MarkdownNode;
820 readonly renderImage: MarkdownImageRenderer;
821}) {
822 const insideLink = useContext(MarkdownLinkLabelContext);
823 if (insideLink === "file")
824 return <NativeText>{props.node.alt ?? props.node.title ?? ""}</NativeText>;
825 return props.node.href
826 ? props.renderImage({
827 href: props.node.href,
828 alt: props.node.alt ?? null,
829 title: props.node.title ?? null,
830 })
831 : null;
832}
833
816834const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record<
817835 CodexArtifactTemplate["artifactKind"],
818836 AppSymbolName
......@@ -1183,26 +1201,31 @@ function useMarkdownStyles(
11831201 preserveSoftBreaks: boolean,
11841202 highlightCode: boolean,
11851203 ): CustomRenderers => ({
1186 link: ({ children, href = "" }) => {
1204 link: ({ children, node, href = "" }) => {
11871205 const presentation = resolveMarkdownLinkPresentation(href);
11881206 if (presentation.kind === "file") {
11891207 return (
1190 <NativeText
1191 className="font-t3-bold"
1192 onPress={() => onLinkPress(href)}
1193 style={{ color: inlineTextColor }}
1194 >
1195 <Image
1196 source={markdownFileIconSource(presentation.icon)}
1197 style={markdownLinkStyles.inlineIcon}
1198 />
1199 {presentation.label}
1200 </NativeText>
1208 <MarkdownLinkLabelContext.Provider value="file">
1209 <NativeText onPress={() => onLinkPress(href)} style={{ color: inlineTextColor }}>
1210 {!isMarkdownFileLinkLabel(getTextContent(node), href) && <>{children} </>}
1211 <NativeText
1212 className="font-t3-bold"
1213 onPress={() => onLinkPress(href)}
1214 style={{ color: inlineTextColor }}
1215 >
1216 <Image
1217 source={markdownFileIconSource(presentation.icon)}
1218 style={markdownLinkStyles.inlineIcon}
1219 />
1220 {presentation.label}
1221 </NativeText>
1222 </NativeText>
1223 </MarkdownLinkLabelContext.Provider>
12011224 );
12021225 }
12031226 if (presentation.kind === "external") {
12041227 return (
1205 <MarkdownLinkLabelContext.Provider value>
1228 <MarkdownLinkLabelContext.Provider value="other">
12061229 <MarkdownExternalLink
12071230 href={presentation.href}
12081231 host={presentation.host}
......@@ -1216,7 +1239,7 @@ function useMarkdownStyles(
12161239 }
12171240 const linkHref = presentation.href;
12181241 return (
1219 <MarkdownLinkLabelContext.Provider value>
1242 <MarkdownLinkLabelContext.Provider value="other">
12201243 <NativeText
12211244 className="underline"
12221245 onPress={
......@@ -1265,14 +1288,7 @@ function useMarkdownStyles(
12651288 })}
12661289 </View>
12671290 ),
1268 image: ({ node }) =>
1269 node.href
1270 ? (renderImage({
1271 href: node.href,
1272 alt: node.alt ?? null,
1273 title: node.title ?? null,
1274 }) ?? undefined)
1275 : undefined,
1291 image: ({ node }) => <MarkdownImage node={node} renderImage={renderImage} />,
12761292 code_inline: ({ content }) => (
12771293 <MarkdownInlineCode
12781294 content={content ?? ""}
apps/mobile/src/lib/nativeMarkdownText.test.ts+103-1
......@@ -138,7 +138,7 @@ describe("nativeMarkdownTextRuns", () => {
138138 {
139139 type: "link",
140140 href: "file:///repo/README.md#L12",
141 children: [{ type: "text", content: "ignored label" }],
141 children: [{ type: "text", content: "validates the input" }],
142142 },
143143 ],
144144 };
......@@ -150,14 +150,116 @@ describe("nativeMarkdownTextRuns", () => {
150150 externalHost: "example.com",
151151 },
152152 { text: " " },
153 {
154 text: "validates the input ",
155 href: "file:///repo/README.md#L12",
156 sourceText: "[validates the input](<file:///repo/README.md#L12>)",
157 },
153158 {
154159 text: "README.md:12",
155160 href: "file:///repo/README.md#L12",
156161 fileIcon: "markdown",
162 sourceText: "[validates the input](<file:///repo/README.md#L12>)",
163 },
164 ]);
165 });
166
167 it.each([true, false])("copies descriptive file links with collapsed chips=%s", (collapsed) => {
168 const link: MarkdownNode = {
169 type: "link",
170 href: "/repo/src/example.ts:12",
171 children: [
172 { type: "text", content: "validates " },
173 { type: "bold", children: [{ type: "text", content: "the input" }] },
174 ],
175 };
176 const runs = nativeMarkdownTextRuns({ type: "paragraph", children: [link, link] });
177 expect(runs.map((run) => run.text).join("")).toBe(
178 "validates the input example.ts:12validates the input example.ts:12",
179 );
180 expect(runs.filter((run) => run.bold).map((run) => run.text)).toEqual([
181 "the input",
182 "the input",
183 ]);
184 const ranges = nativeMarkdownContextCopyRanges(
185 runs.map((run) => ({
186 run,
187 text: collapsed && run.fileIcon ? "" : run.text,
188 inlineImageLength: run.fileIcon ? 1 : 0,
189 })),
190 );
191 const length = "validates the input ".length + (collapsed ? 1 : "example.ts:12".length + 1);
192 expect(ranges).toEqual([
193 { start: 0, end: length, text: "[validates **the input**](</repo/src/example.ts:12>)" },
194 {
195 start: length,
196 end: length * 2,
197 text: "[validates **the input**](</repo/src/example.ts:12>)",
157198 },
158199 ]);
159200 });
160201
202 it("keeps filename-labelled links as chips", () => {
203 expect(
204 nativeMarkdownTextRuns({
205 type: "paragraph",
206 children: [
207 {
208 type: "link",
209 href: "/repo/src/example.ts:12",
210 children: [{ type: "code_inline", content: "src/example.ts:12" }],
211 },
212 ],
213 }),
214 ).toEqual([{ text: "example.ts:12", href: "/repo/src/example.ts:12", fileIcon: "typescript" }]);
215 });
216
217 it.each([
218 [{ type: "italic", children: [{ type: "text", content: "details" }] }, "*details*"],
219 [{ type: "strikethrough", children: [{ type: "text", content: "details" }] }, "~~details~~"],
220 [{ type: "code_inline", content: "`details`" }, "`` `details` ``"],
221 [{ type: "code_inline", content: " details " }, "` details `"],
222 [{ type: "text", content: "[details] *literal*" }, "\\[details\\] \\*literal\\*"],
223 [{ type: "soft_break" }, "\n"],
224 [{ type: "line_break" }, " \n"],
225 [
226 { type: "image", alt: "details", href: "https://example.com/icon.png" },
227 "![details](<https://example.com/icon.png>)",
228 ],
229 ] satisfies ReadonlyArray<readonly [MarkdownNode, string]>)(
230 "keeps descriptive label markup when copying %j",
231 (child, source) => {
232 const runs = nativeMarkdownTextRuns({
233 type: "paragraph",
234 children: [
235 {
236 type: "link",
237 href: "/repo/a.ts",
238 children: [{ type: "text", content: "see " }, child],
239 },
240 ],
241 });
242 expect(
243 nativeMarkdownContextCopyRanges(
244 runs.map((run) => ({
245 run,
246 text: run.fileIcon ? "" : run.text,
247 inlineImageLength: run.fileIcon ? 1 : 0,
248 })),
249 ),
250 ).toEqual([
251 {
252 start: 0,
253 end:
254 runs
255 .filter((run) => !run.fileIcon)
256 .reduce((length, run) => length + run.text.length, 0) + 1,
257 text: `[see ${source}](</repo/a.ts>)`,
258 },
259 ]);
260 },
261 );
262
161263 it("keeps hard breaks and collapses soft breaks", () => {
162264 const node: MarkdownNode = {
163265 type: "paragraph",
apps/web/src/components/ChatMarkdown.tsx+17-4
......@@ -161,6 +161,7 @@ import {
161161 shouldOpenMarkdownFileLinkInEditor,
162162 type MarkdownFileLinkMeta,
163163} from "../markdown-links";
164import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";
164165import { readLocalApi } from "../localApi";
165166import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls";
166167import { cn } from "../lib/utils";
......@@ -3022,6 +3023,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
30223023 updateThreadPullRequestLink,
30233024 fileLinkChip,
30243025 renderContextReference,
3026 text,
30253027 } = use(ChatMarkdownRendererContext);
30263028 const citation = href ? parseAssistantCitationHref(href) : null;
30273029 if (citation) return <AssistantCitationChip citation={citation} />;
......@@ -3233,10 +3235,21 @@ const CHAT_MARKDOWN_COMPONENTS = {
32333235 );
32343236 }
32353237
3236 return fileLinkChip(
3237 fileLinkMeta,
3238 `[${fileLinkMeta.basename}](${normalizedHref})`,
3239 normalizedHref,
3238 const label = nodeToPlainText(children);
3239 const start = node?.position?.start.offset;
3240 const end = node?.position?.end.offset;
3241 const source = start !== undefined && end !== undefined ? text.slice(start, end) : "";
3242 const copyMarkdown =
3243 source.startsWith("[") && source.includes("](")
3244 ? source
3245 : `[${(label || fileLinkMeta.basename).replace(/[\\[\]]/g, "\\$&")}](${normalizedHref})`;
3246 const chip = fileLinkChip(fileLinkMeta, copyMarkdown, normalizedHref);
3247 return isMarkdownFileLinkLabel(label, normalizedHref) ? (
3248 chip
3249 ) : (
3250 <span data-markdown-copy={copyMarkdown}>
3251 {children} {chip}
3252 </span>
32403253 );
32413254 },
32423255 code: function MarkdownCode({ node, children, className, ...props }) {
packages/client-runtime/src/markdownLinks.test.ts+24
......@@ -3,12 +3,36 @@ import { describe, expect, it } from "vite-plus/test";
33import {
44 fileBasename,
55 inlineCodeFilePathCandidate,
6 isMarkdownFileLinkLabel,
67 parseFileUrlHref,
78 parseMarkdownFileLink,
89 splitFilePathPosition,
910 workspaceRelativeFilePath,
1011} from "./markdownLinks.ts";
1112
13describe("isMarkdownFileLinkLabel", () => {
14 it.each([
15 ["validates the input", "/repo/src/example.ts:12", false],
16 ["read src/example.ts", "/repo/src/example.ts:12", false],
17 ["example.ts?why this matters", "/repo/src/example.ts", false],
18 ["example.ts", "/repo/src/example.ts:12", true],
19 ["example.ts:12", "/repo/src/example.ts:12", true],
20 ["example.ts:99", "/repo/src/example.ts:12", false],
21 ["example.ts:12:2", "/repo/src/example.ts:12:2", true],
22 ["example.ts:12:3", "/repo/src/example.ts:12:2", false],
23 ["example.ts:12", "/repo/src/example.ts", false],
24 ["src/example.ts:12", "/repo/src/example.ts:12", true],
25 ["./src/example.ts", "/repo/src/example.ts", true],
26 ["/repo/src/example.ts", "/repo/src/example.ts", true],
27 ["src/", "/home/me/project/src/", true],
28 ["EXAMPLE.TS", "C:/repo/src/example.ts:12", true],
29 ["file name.ts", "file:///repo/file%20name.ts", true],
30 ["", "/repo/src/example.ts", true],
31 ])("classifies %s for %s", (label, href, expected) => {
32 expect(isMarkdownFileLinkLabel(label, href)).toBe(expected);
33 });
34});
35
1236describe("inlineCodeFilePathCandidate", () => {
1337 it.each([
1438 ["src\\main.ts", "src/main.ts"],
packages/client-runtime/src/markdownLinks.ts+23
......@@ -265,6 +265,29 @@ export function formatFilePathPosition(position: FilePathPosition): string {
265265 return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`;
266266}
267267
268/** Keeps filename and destination-path labels compact without discarding prose. */
269export function isMarkdownFileLinkLabel(label: string, href: string): boolean {
270 const destination = parseMarkdownFileLink(href);
271 if (!destination) return false;
272 const normalize = (path: string) =>
273 path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
274 const labelPosition = splitFilePathPosition(label.trim());
275 if (
276 (labelPosition.line !== undefined && labelPosition.line !== destination.line) ||
277 (labelPosition.column !== undefined && labelPosition.column !== destination.column)
278 ) {
279 return false;
280 }
281 let labelPath = normalize(labelPosition.path);
282 let destinationPath = normalize(destination.path);
283 if (labelPath.length === 0) return true;
284 if (isWindowsAbsolutePath(destination.path)) {
285 labelPath = labelPath.toLowerCase();
286 destinationPath = destinationPath.toLowerCase();
287 }
288 return destinationPath === labelPath || destinationPath.endsWith(`/${labelPath}`);
289}
290
268291export function isRelativeFilePath(path: string): boolean {
269292 return (
270293 RELATIVE_PATH_PREFIX_PATTERN.test(path) ||