diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 9d824d006c6b..0f55d36c0110 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -10,7 +10,6 @@ import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; -import { repairMarkdownFileLinks } from "@t3tools/client-runtime/repair-markdown-file-links"; import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; import { type OrchestrationMessageContext, @@ -908,17 +907,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly skills?: ReadonlyArray | undefined; }) { const segments = useMemo( - () => - splitCodexArtifactTemplateMarkdown(props.markdown).map((segment) => - segment.kind === "markdown" - ? { - ...segment, - markdown: renderCodexFileCitationsAsMarkdown( - repairMarkdownFileLinks(segment.markdown), - ), - } - : segment, - ), + () => splitCodexArtifactTemplateMarkdown(props.markdown), [props.markdown], ); @@ -934,7 +923,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { } if (segment.markdown.trim().length === 0) return null; - const markdown = segment.markdown; + const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown); return hasNativeSelectableMarkdownText() ? ( }) { const ctx = use(TimelineRowCtx); const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); - const renderedText = useMemo(() => repairMarkdownFileLinks(messageText), [messageText]); return ( <> @@ -2509,7 +2507,7 @@ function AssistantTimelineRow({ row }: { row: Extract { - it.each([ - "CONTRIBUTING.md", - "local/path/file.md", - "docs/My Folder/file.md", - "./scripts/deploy", - "~/notes/today.md", - "/home/me/My Folder/file.md", - "/tmp/reports/", - "C:\\Users\\me\\My Folder\\file.md", - "file:///home/me/file.md", - "src/main.ts:12:5", - ])("repairs a complete local destination: %s", (path) => { - const source = `- [file](<${path})`; - const rendered = repairMarkdownFileLinks(source); - expect(rendered).toBe(`- [file](<${path}>)`); - expect(unified().use(remarkParse).parse(rendered).children[0]).toMatchObject({ - type: "list", - children: [{ children: [{ children: [{ type: "link" }] }] }], - }); - expect(repairMarkdownFileLinks(rendered)).toBe(rendered); - }); - - it.each([ - "ordinary prose", - "[file]()", - "[site](", - "[file](<./file(one).md)", - "[file](<./file.md))", - "[file](<./file)name.md)", - "[file](<./file\\).md)", - "![image](<./file.png)", - "\\\\![image](<./file.png)", - "\\\\\\\\![image](<./file.png)", - "\\[file](<./file.md)", - "\\\\\\[file](<./file.md)", - "`[file](<./file.md)`", - "``[file](<./file.md)``", - "```md\n[file](<./file.md)\n```", - "~~~md\n[file](<./file.md)\n~~~", - " [file](<./file.md)", - "
\n[file](<./file.md)\n
", - "HTML [file](<./file.md)", - 'text', - "[outer [file](<./file.md)](https://example.com)", - "[outer [file](<./file.md)][ref]\n\n[ref]: https://example.com", - '[ref]: ./file.md "[file](<./other.md)"', - ':codex-file-citation{path="./[file](<./other.md)" purpose="output"}', - ':codex-file-citation{path="./[file](<./other.md)', - '::artifact-template{skill_name="artifact-template-example" skill_directory="/tmp/skills/example" display_name="[file](<./file.md)" artifact_kind="document"}', - '::artifact-template{display_name="[file](<./file.md)', - ])("preserves protected or uncertain syntax: %s", (source) => { - expect(repairMarkdownFileLinks(source)).toBe(source); - }); - - it("uses CommonMark backslash parity for link openers", () => { - expect(repairMarkdownFileLinks("\\\\[file](<./file.md)")).toBe("\\\\[file](<./file.md>)"); - expect(repairMarkdownFileLinks("\\\\\\\\[file](<./file.md)")).toBe( - "\\\\\\\\[file](<./file.md>)", - ); - expect(repairMarkdownFileLinks("\\![file](<./file.md)")).toBe("\\![file](<./file.md>)"); - }); - - it("repairs ordinary segments after splitting mobile artifacts and preserves copy source", () => { - const link = "[file](<./file.md)"; - const template = - '::artifact-template{skill_name="artifact-template-example" skill_directory="/tmp/skills/example" display_name="[file](<./file.md)" artifact_kind="document"}'; - const citation = ':codex-file-citation{path="./report.md"}'; - const source = `${link}\n\n${template}\n\n${citation}`; - const segments = splitCodexArtifactTemplateMarkdown(source); - const rendered = segments.map((segment) => - segment.kind === "markdown" - ? { - ...segment, - markdown: renderCodexFileCitationsAsMarkdown(repairMarkdownFileLinks(segment.markdown)), - } - : segment, - ); - expect(rendered[0]).toEqual({ - kind: "markdown", - markdown: `${link.slice(0, -1)}>)\n\n`, - sourceOffset: 0, - }); - expect(rendered[1]).toEqual(segments[1]); - expect(rendered[1]).toMatchObject({ - kind: "artifact-template", - sourceOffset: link.length + 2, - template: { displayName: link, skillName: "artifact-template-example" }, - }); - expect(rendered[2]).toEqual({ - kind: "markdown", - sourceOffset: source.indexOf("\n\n" + citation), - markdown: "\n\n[report.md](<./report.md>)", - }); - expect(renderCodexDirectivesForCopy(source)).toBe( - `${link}\n\n${link} (Document template)\n\n[report.md](<./report.md>)`, - ); - }); -}); describe("isMarkdownFileLinkLabel", () => { it.each([ diff --git a/packages/client-runtime/src/repairMarkdownFileLinks.ts b/packages/client-runtime/src/repairMarkdownFileLinks.ts deleted file mode 100644 index 9c863ee26f9f..000000000000 --- a/packages/client-runtime/src/repairMarkdownFileLinks.ts +++ /dev/null @@ -1,84 +0,0 @@ -import remarkParse from "remark-parse"; -import { unified } from "unified"; - -import { remarkCodexDirectives } from "./codexMarkdownDirectives.ts"; -import { parseMarkdownFileLink } from "./markdownLinks.ts"; - -const parser = unified().use(remarkParse).use(remarkCodexDirectives).freeze(); -const CANDIDATE = /\[[^[\]\r\n]*\]\([ \t]*<([^<>()\r\n]+)\)/g; -const PROTECTED = new Set([ - "code", - "inlineCode", - "html", - "link", - "image", - "linkReference", - "imageReference", - "definition", - "textDirective", - "leafDirective", - "containerDirective", -]); - -function isEscaped(source: string, offset: number): boolean { - let backslashes = 0; - while (offset > 0 && source[--offset] === "\\") backslashes += 1; - return backslashes % 2 === 1; -} - -/** Closes only complete, unambiguous local-file destinations for assistant rendering. */ -export function repairMarkdownFileLinks(markdown: string): string { - if (!/\]\([ \t]* { - const start = match.index; - const close = start + match[0].length - 1; - return ( - !isEscaped(markdown, start) && - !(markdown[start - 1] === "!" && !isEscaped(markdown, start - 1)) && - !isEscaped(markdown, close) && - (close + 1 === markdown.length || /[\s.,;:!?}\]*_~]/.test(markdown[close + 1] ?? "")) && - parseMarkdownFileLink(match[1] ?? "") !== null - ); - }); - if (candidates.length === 0) return markdown; - - const protectedRanges: Array<{ start: number; end: number }> = []; - interface Node { - type: string; - position?: - | { start: { offset?: number | undefined }; end: { offset?: number | undefined } } - | undefined; - children?: Node[] | undefined; - } - const visit = (node: Node, paragraph?: Node) => { - const containingParagraph = node.type === "paragraph" ? node : paragraph; - const sourceStart = node.position?.start.offset; - const sourceEnd = node.position?.end.offset; - const unfinishedDirective = - (node.type === "textDirective" && markdown[sourceEnd ?? -1] === "{") || - (node.type === "text" && - markdown.slice(sourceStart, sourceEnd).includes("::artifact-template{")); - if (PROTECTED.has(node.type) || unfinishedDirective) { - // Inline HTML and unfinished directive attributes can span separate text nodes. - const protectedNode = - node.type === "html" || unfinishedDirective ? (containingParagraph ?? node) : node; - const start = protectedNode.position?.start.offset; - const end = protectedNode.position?.end.offset; - if (start !== undefined && end !== undefined) protectedRanges.push({ start, end }); - return; - } - node.children?.forEach((child) => visit(child, containingParagraph)); - }; - visit(parser.parse(markdown)); - - let rendered = ""; - let cursor = 0; - for (const match of candidates) { - const start = match.index; - const end = start + match[0].length; - if (protectedRanges.some((range) => start < range.end && end > range.start)) continue; - rendered += markdown.slice(cursor, end - 1) + ">"; - cursor = end - 1; - } - return rendered + markdown.slice(cursor); -}