diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
index 04682874db0c..6ef4ec766ff5 100644
--- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
+++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
@@ -1,3 +1,4 @@
+import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";
import type { MarkdownNode } from "react-native-nitro-markdown/headless";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
import { imageMimeType } from "@t3tools/shared/image";
@@ -423,7 +424,7 @@ function appendChildren(
return runs;
}
-function nodeTextContent(node: MarkdownNode): string {
+export function nodeTextContent(node: MarkdownNode): string {
if (node.content !== undefined) {
return node.content;
}
@@ -480,7 +481,11 @@ function appendNode(
}
const presentation = resolveMarkdownLinkPresentation(node.href ?? "");
if (presentation.kind === "file") {
- return appendRun(runs, presentation.label, {
+ const descriptive = !isMarkdownFileLinkLabel(nodeTextContent(node), presentation.href);
+ if (descriptive) {
+ appendChildren(runs, node, { ...context, href: presentation.href });
+ }
+ return appendRun(runs, descriptive ? ` (${presentation.label})` : presentation.label, {
...context,
href: presentation.href,
fileIcon: presentation.icon,
diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
index 83b0bef9c022..50e0e77f9598 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -1,3 +1,5 @@
+import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";
+import { nodeTextContent } from "@t3tools/mobile-markdown-text/markdown";
import {
WorktreeWorkingHeader,
WorktreeSetupCard,
@@ -1082,21 +1084,26 @@ function useMarkdownStyles(
preserveSoftBreaks: boolean,
highlightCode: boolean,
): CustomRenderers => ({
- link: ({ children, href = "" }) => {
+ link: ({ node, children, href = "" }) => {
const presentation = resolveMarkdownLinkPresentation(href);
if (presentation.kind === "file") {
+ const descriptive = !isMarkdownFileLinkLabel(nodeTextContent(node), href);
return (
- onLinkPress(href)}
- style={{ color: inlineTextColor }}
- >
-
- {presentation.label}
-
+
+ onLinkPress(href)}
+ style={{ color: inlineTextColor }}
+ >
+ {descriptive ? <>{children} (> : null}
+
+ {presentation.label}
+ {descriptive ? ")" : null}
+
+
);
}
if (presentation.kind === "external") {
diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts
index d934a306aa01..0004e37074fb 100644
--- a/apps/mobile/src/lib/nativeMarkdownText.test.ts
+++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts
@@ -149,14 +149,39 @@ describe("nativeMarkdownTextRuns", () => {
externalHost: "example.com",
},
{ text: " " },
+ { text: "ignored label", href: "file:///repo/README.md#L12" },
{
- text: "README.md:12",
+ text: " (README.md:12)",
href: "file:///repo/README.md#L12",
fileIcon: "markdown",
},
]);
});
+ it("preserves descriptive file-link formatting and keeps filename links compact", () => {
+ const href = "/repo/src/example.ts:12";
+ expect(
+ nativeMarkdownTextRuns({
+ type: "paragraph",
+ children: [
+ {
+ type: "link",
+ href,
+ children: [
+ { type: "bold", children: [{ type: "text", content: "validates" }] },
+ { type: "text", content: " the input" },
+ ],
+ },
+ { type: "link", href, children: [{ type: "code_inline", content: "src/example.ts:12" }] },
+ ],
+ }),
+ ).toEqual([
+ { text: "validates", bold: true, href },
+ { text: " the input", href },
+ { text: " (example.ts:12)example.ts:12", href, fileIcon: "typescript" },
+ ]);
+ });
+
it("keeps hard breaks and collapses soft breaks", () => {
const node: MarkdownNode = {
type: "paragraph",
diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx
index cb25e6938fa7..8b25c2373a32 100644
--- a/apps/web/src/components/ChatMarkdown.test.tsx
+++ b/apps/web/src/components/ChatMarkdown.test.tsx
@@ -73,6 +73,90 @@ function codeButton(renderer: ReactTestRenderer, label: string) {
return button.props as ComponentProps;
}
+describe("ChatMarkdown file-link labels", () => {
+ it.each([
+ [String.raw`read \] here`, "read ] here"],
+ [String.raw`read \[ here`, "read [ here"],
+ [String.raw`read \\\] here`, String.raw`read \] here`],
+ ])("round-trips copied file-link label %s", async (sourceLabel, label) => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ let renderer: ReactTestRenderer | undefined;
+ try {
+ await act(async () => {
+ renderer = create(
+ ,
+ );
+ });
+ const authoredLabel = () =>
+ renderer!.root
+ .findAll((node) => typeof node.type === "string" && node.children.includes(label))
+ .at(0);
+ expect(authoredLabel()).toBeDefined();
+ const copied = authoredLabel()!.props["data-markdown-copy"];
+ await act(async () => {
+ renderer!.update();
+ });
+ expect(authoredLabel()).toBeDefined();
+ expect(authoredLabel()!.props["data-markdown-copy"]).toBe(copied);
+ } finally {
+ await act(async () => renderer?.unmount());
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it("copies the filename for a whitespace-only link label", async () => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ let renderer: ReactTestRenderer | undefined;
+ try {
+ await act(async () => {
+ renderer = create();
+ });
+ expect(renderer!.root.findByType("button").props["data-markdown-copy"]).toBe(
+ "[example.ts](/repo/example.ts:12)",
+ );
+ } finally {
+ await act(async () => renderer?.unmount());
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it("retains descriptive prose, emphasis, destinations, and copy text", async () => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ let renderer: ReactTestRenderer | undefined;
+ try {
+ await act(async () => {
+ renderer = create(
+ ,
+ );
+ });
+ expect(renderer!.root.findByType("strong").children).toEqual(["validates"]);
+ expect(
+ renderer!.root
+ .findAllByType("button")
+ .some((button) => button.findAllByType("strong").length > 0),
+ ).toBe(true);
+ const label = renderer!.root
+ .findAll((node) => typeof node.type === "string" && node.children.includes(" the input"))
+ .at(0);
+ expect(label).toBeDefined();
+ expect(label!.props["data-markdown-copy"]).toBe(
+ "[validates the input](/repo/src/example.ts:12)",
+ );
+ expect(
+ renderer!.root
+ .findAllByType("span")
+ .some((node) => node.children.includes("example.ts ยท L12")),
+ ).toBe(true);
+ } finally {
+ await act(async () => renderer?.unmount());
+ vi.unstubAllGlobals();
+ }
+ });
+});
+
describe("ChatMarkdown context references", () => {
it("renders text and image references through the chip renderer, with readable fallback", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index 4a1f616aed3b..313464beb510 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -50,7 +50,10 @@ import {
classifyMarkdownImageSource,
markdownImageSourceFragment,
} from "@t3tools/client-runtime/markdown-images";
-import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links";
+import {
+ inlineCodeFilePathCandidate,
+ isMarkdownFileLinkLabel,
+} from "@t3tools/client-runtime/markdown-links";
import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";
import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
import * as Cause from "effect/Cause";
@@ -1185,6 +1188,7 @@ function UncachedShikiCodeBlock({
}
interface MarkdownFileLinkProps {
+ children?: ReactNode;
href: string;
targetPath: string;
iconPath: string;
@@ -1926,6 +1930,7 @@ function MarkdownExternalLinkContent({
}
const MarkdownFileLink = memo(function MarkdownFileLink({
+ children,
href,
targetPath,
iconPath,
@@ -2207,45 +2212,62 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
canOpenInPanel,
});
+ const chip = ;
+ const triggerClassName = cn(MARKDOWN_FILE_LINK_CLASS_NAME, !hasPrimaryAction && "select-text");
+ const triggerProps = hasPrimaryAction
+ ? {
+ "data-markdown-copy": copyMarkdown,
+ onClick: (event: ReactMouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) {
+ handleOpenInEditor();
+ return;
+ }
+ if (useBrowserPrimaryAction) {
+ handleOpenInBrowser();
+ return;
+ }
+ handleOpenInFilePreview();
+ },
+ onContextMenu: handleContextMenu,
+ }
+ : {
+ "aria-label": children ? undefined : `File options for ${label}`,
+ "aria-haspopup": "menu" as const,
+ "data-markdown-copy": copyMarkdown,
+ onClick: handleContextMenu,
+ onContextMenu: handleContextMenu,
+ };
+ // A descriptive label stays link text; only the destination keeps the chip look.
+ const descriptiveContent = children ? (
+ <>
+ {children} ({chip})
+ >
+ ) : null;
+
return (
}
- className={MARKDOWN_FILE_LINK_CLASS_NAME}
- data-markdown-copy={copyMarkdown}
- onClick={(event) => {
- event.preventDefault();
- event.stopPropagation();
- if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) {
- handleOpenInEditor();
- return;
- }
- if (useBrowserPrimaryAction) {
- handleOpenInBrowser();
- return;
- }
- handleOpenInFilePreview();
- }}
- onContextMenu={handleContextMenu}
- >
-
-
+ descriptiveContent ? (
+ hasPrimaryAction ? (
+
+ {descriptiveContent}
+
+ ) : (
+
+ )
) : (
}
- aria-label={`File options for ${label}`}
- aria-haspopup="menu"
- className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")}
- data-markdown-copy={copyMarkdown}
- onClick={handleContextMenu}
- onContextMenu={handleContextMenu}
+ render={hasPrimaryAction ? : }
+ className={triggerClassName}
+ {...triggerProps}
>
-
+ {chip}
)
}
@@ -2266,6 +2288,7 @@ function areMarkdownFileLinkPropsEqual(
next: Readonly,
): boolean {
return (
+ previous.children === next.children &&
previous.href === next.href &&
previous.targetPath === next.targetPath &&
previous.iconPath === next.iconPath &&
@@ -2611,7 +2634,12 @@ function useChatMarkdownState({
[cwd, findWorkspaceBasenameMatch, revealFileInFileManager],
);
const fileLinkChip = useCallback(
- (fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string) => {
+ (
+ fileLinkMeta: MarkdownFileLinkMeta,
+ copyMarkdown: string,
+ mediaSource?: string,
+ children?: ReactNode,
+ ) => {
const parentSuffix = fileLinkParentSuffixByPath.get(
fileLinkMeta.filePath.replaceAll("\\", "/"),
);
@@ -2668,7 +2696,9 @@ function useChatMarkdownState({
? () => openMarkdownFileInPreview(fileLinkMeta.filePath)
: undefined
}
- />
+ >
+ {children}
+
);
},
[
@@ -3101,10 +3131,17 @@ const CHAT_MARKDOWN_COMPONENTS = {
);
}
+ const label = nodeToPlainText(children);
+ const escapedLabel = (label.trim() ? label : fileLinkMeta.basename)
+ .replaceAll("\\", "\\\\")
+ .replaceAll("[", "\\[")
+ .replaceAll("]", "\\]");
+ const copyMarkdown = `[${escapedLabel}](${normalizedHref})`;
return fileLinkChip(
fileLinkMeta,
- `[${fileLinkMeta.basename}](${normalizedHref})`,
+ copyMarkdown,
normalizedHref,
+ isMarkdownFileLinkLabel(label, normalizedHref) ? undefined : children,
);
},
code: function MarkdownCode({ node, children, className, ...props }) {
diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts
index deea6960478e..f5d13673ed44 100644
--- a/packages/client-runtime/src/markdownLinks.test.ts
+++ b/packages/client-runtime/src/markdownLinks.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
fileBasename,
+ isMarkdownFileLinkLabel,
inlineCodeFilePathCandidate,
parseFileUrlHref,
parseMarkdownFileLink,
@@ -182,3 +183,37 @@ describe("workspaceRelativeFilePath", () => {
expect(workspaceRelativeFilePath(path, workspaceRoot)).toBe(relativePath);
});
});
+
+describe("isMarkdownFileLinkLabel", () => {
+ it.each([
+ "example.ts",
+ "example.ts:12",
+ "src/example.ts",
+ "./src/example.ts:12",
+ "/repo/src/example.ts",
+ "file:///repo/src/example.ts#L12",
+ "",
+ ])("keeps destination label %s compact", (label) =>
+ expect(isMarkdownFileLinkLabel(label, "/repo/src/example.ts:12")).toBe(true),
+ );
+ it.each(["validates the input", "the example.ts", "other.ts", "src/other.ts"])(
+ "preserves authored label %s",
+ (label) => expect(isMarkdownFileLinkLabel(label, "/repo/src/example.ts:12")).toBe(false),
+ );
+ it.each(["favicons", "favicons/", "/tmp/favicons/"])(
+ "keeps directory label %s compact",
+ (label) => {
+ expect(isMarkdownFileLinkLabel(label, "/tmp/favicons/")).toBe(true);
+ },
+ );
+ it("recognizes Windows paths and encoded destinations", () => {
+ expect(isMarkdownFileLinkLabel("src\\example.ts:12", "C:\\repo\\src\\example.ts:12")).toBe(
+ true,
+ );
+ expect(isMarkdownFileLinkLabel("my file.ts", "/repo/my%20file.ts#L12")).toBe(true);
+ });
+ it("matches Windows paths regardless of case", () => {
+ expect(isMarkdownFileLinkLabel("SRC\\EXAMPLE.TS", "C:\\repo\\src\\example.ts")).toBe(true);
+ expect(isMarkdownFileLinkLabel("SRC/EXAMPLE.TS", "/repo/src/example.ts")).toBe(false);
+ });
+});
diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts
index 0a32f5061083..fd3c1556dc3a 100644
--- a/packages/client-runtime/src/markdownLinks.ts
+++ b/packages/client-runtime/src/markdownLinks.ts
@@ -344,3 +344,20 @@ export function workspaceRelativeFilePath(
if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null;
return normalizedPath.slice(normalizedRoot.length + 1);
}
+
+export function isMarkdownFileLinkLabel(label: string, href: string): boolean {
+ if (!label.trim()) return true;
+ const target = parseMarkdownFileLink(href);
+ if (!target) return false;
+ // Windows paths compare case-insensitively, matching workspaceRelativeFilePath.
+ const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(target.path));
+ const normalize = (value: string) => (caseInsensitive ? value.toLowerCase() : value);
+ const path = normalize(
+ (parseMarkdownFileLink(label)?.path ?? splitFilePathPosition(label.trim()).path)
+ .replaceAll("\\", "/")
+ .replace(/^\.\//, "")
+ .replace(/([^/:])\/+$/, "$1"),
+ );
+ const targetPath = normalize(target.path.replaceAll("\\", "/").replace(/([^/:])\/+$/, "$1"));
+ return path === targetPath || targetPath.endsWith(`/${path}`);
+}