| author | |
| committer | |
| log | 801ca76ed6a5b9d5d6a40f6c1ba231d9ffb3757e |
| tree | 6dc730cdabd906afd7bf7b383f57942d06a82278 |
| parent | ec20db4a5a27de45f69f64ca05d17ec1bd96644d |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; | ... | @@ -10,7 +10,6 @@ import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 10 | import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; | 10 | import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; |
| 11 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | 11 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 12 | import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; | 12 | import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; |
| 13 | import { repairMarkdownFileLinks } from "@t3tools/client-runtime/repair-markdown-file-links"; | ||
| 14 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; | 13 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; |
| 15 | import { | 14 | import { |
| 16 | type OrchestrationMessageContext, | 15 | type OrchestrationMessageContext, |
| ... | @@ -908,17 +907,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { | ... | @@ -908,17 +907,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { |
| 908 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined; | 907 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined; |
| 909 | }) { | 908 | }) { |
| 910 | const segments = useMemo( | 909 | const segments = useMemo( |
| 911 | () => | 910 | () => splitCodexArtifactTemplateMarkdown(props.markdown), |
| 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 | ), | ||
| 922 | [props.markdown], | 911 | [props.markdown], |
| 923 | ); | 912 | ); |
| 924 | 913 | ||
| ... | @@ -934,7 +923,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { | ... | @@ -934,7 +923,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { |
| 934 | } | 923 | } |
| 935 | if (segment.markdown.trim().length === 0) return null; | 924 | if (segment.markdown.trim().length === 0) return null; |
| 936 | 925 | ||
| 937 | const markdown = segment.markdown; | 926 | const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown); |
| 938 | return hasNativeSelectableMarkdownText() ? ( | 927 | return hasNativeSelectableMarkdownText() ? ( |
| 939 | <SelectableMarkdownText | 928 | <SelectableMarkdownText |
| 940 | key={`markdown:${segment.sourceOffset}`} | 929 | 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 | ... | @@ -35,7 +35,6 @@ import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/en |
| 35 | import { useAtomValue } from "@effect/atom-react"; | 35 | import { useAtomValue } from "@effect/atom-react"; |
| 36 | import { environmentThreadDetails } from "../../state/threads"; | 36 | import { environmentThreadDetails } from "../../state/threads"; |
| 37 | import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; | 37 | import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; |
| 38 | import { repairMarkdownFileLinks } from "@t3tools/client-runtime/repair-markdown-file-links"; | ||
| 39 | import { Link } from "@tanstack/react-router"; | 38 | import { Link } from "@tanstack/react-router"; |
| 40 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; | 39 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; |
| 41 | import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution"; | 40 | import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution"; |
| ... | @@ -2495,7 +2494,6 @@ function AttemptFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "at | ... | @@ -2495,7 +2494,6 @@ function AttemptFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "at |
| 2495 | function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { | 2494 | function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { |
| 2496 | const ctx = use(TimelineRowCtx); | 2495 | const ctx = use(TimelineRowCtx); |
| 2497 | const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); | 2496 | const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); |
| 2498 | const renderedText = useMemo(() => repairMarkdownFileLinks(messageText), [messageText]); | ||
| 2499 | 2497 | ||
| 2500 | return ( | 2498 | return ( |
| 2501 | <> | 2499 | <> |
| ... | @@ -2509,7 +2507,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess | ... | @@ -2509,7 +2507,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess |
| 2509 | listRef={ctx.listRef} | 2507 | listRef={ctx.listRef} |
| 2510 | > | 2508 | > |
| 2511 | <ChatMarkdown | 2509 | <ChatMarkdown |
| 2512 | text={renderedText} | 2510 | text={messageText} |
| 2513 | cwd={ctx.markdownCwd} | 2511 | cwd={ctx.markdownCwd} |
| 2514 | threadRef={ctx.threadRef ?? undefined} | 2512 | threadRef={ctx.threadRef ?? undefined} |
| 2515 | isStreaming={Boolean(row.message.streaming)} | 2513 | isStreaming={Boolean(row.message.streaming)} |
packages/client-runtime/package.json-4| ... | @@ -55,10 +55,6 @@ | ... | @@ -55,10 +55,6 @@ |
| 55 | "types": "./src/markdownLinks.ts", | 55 | "types": "./src/markdownLinks.ts", |
| 56 | "default": "./src/markdownLinks.ts" | 56 | "default": "./src/markdownLinks.ts" |
| 57 | }, | 57 | }, |
| 58 | "./repair-markdown-file-links": { | ||
| 59 | "types": "./src/repairMarkdownFileLinks.ts", | ||
| 60 | "default": "./src/repairMarkdownFileLinks.ts" | ||
| 61 | }, | ||
| 62 | "./media-reference": { | 58 | "./media-reference": { |
| 63 | "types": "./src/mediaReference.ts", | 59 | "types": "./src/mediaReference.ts", |
| 64 | "default": "./src/mediaReference.ts" | 60 | "default": "./src/mediaReference.ts" |
packages/client-runtime/src/markdownLinks.test.ts-112| ... | @@ -1,6 +1,4 @@ | ... | @@ -1,6 +1,4 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import remarkParse from "remark-parse"; | ||
| 3 | import { unified } from "unified"; | ||
| 4 | 2 | ||
| 5 | import { | 3 | import { |
| 6 | fileBasename, | 4 | fileBasename, |
| ... | @@ -11,116 +9,6 @@ import { | ... | @@ -11,116 +9,6 @@ import { |
| 11 | splitFilePathPosition, | 9 | splitFilePathPosition, |
| 12 | workspaceRelativeFilePath, | 10 | workspaceRelativeFilePath, |
| 13 | } from "./markdownLinks.ts"; | 11 | } from "./markdownLinks.ts"; |
| 14 | import { repairMarkdownFileLinks } from "./repairMarkdownFileLinks.ts"; | ||
| 15 | import { | ||
| 16 | renderCodexDirectivesForCopy, | ||
| 17 | renderCodexFileCitationsAsMarkdown, | ||
| 18 | splitCodexArtifactTemplateMarkdown, | ||
| 19 | } from "./codexMarkdownDirectives.ts"; | ||
| 20 | |||
| 21 | describe("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 | "", | ||
| 57 | "\\\\", | ||
| 58 | "\\\\\\\\", | ||
| 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("\\")).toBe("\\"); | ||
| 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 | }); | ||
| 124 | 12 | ||
| 125 | describe("isMarkdownFileLinkLabel", () => { | 13 | describe("isMarkdownFileLinkLabel", () => { |
| 126 | it.each([ | 14 | it.each([ |
packages/client-runtime/src/repairMarkdownFileLinks.ts deleted-84| ... | @@ -1,84 +0,0 @@ | ||
| 1 | import remarkParse from "remark-parse"; | ||
| 2 | import { unified } from "unified"; | ||
| 3 | |||
| 4 | import { remarkCodexDirectives } from "./codexMarkdownDirectives.ts"; | ||
| 5 | import { parseMarkdownFileLink } from "./markdownLinks.ts"; | ||
| 6 | |||
| 7 | const parser = unified().use(remarkParse).use(remarkCodexDirectives).freeze(); | ||
| 8 | const CANDIDATE = /\[[^[\]\r\n]*\]\([ \t]*<([^<>()\r\n]+)\)/g; | ||
| 9 | const 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 | |||
| 23 | function 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. */ | ||
| 30 | export 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 | } | ||