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 ? :