Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { View } from "react-native";
import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";
import { repairUnclosedAngleLinkDestinations } from "@t3tools/client-runtime/markdown-links";

import {
nativeMarkdownChunkSpacing,
Expand Down Expand Up @@ -52,9 +53,12 @@ export function SelectableMarkdownText({
marginBottom = 0,
}: SelectableMarkdownTextProps) {
const chunks = useMemo(() => {
// Same unclosed-angle repair as web ChatMarkdown: the native parser
// also leaves `[label](<path)` as raw text without the closing `>`.
const repaired = repairUnclosedAngleLinkDestinations(markdown);
const parsedDocument = nativeMarkdownWithAuthoredWindowsPaths(
parseMarkdownWithOptions(markdown, { gfm: true, html: true, math: false }),
markdown,
parseMarkdownWithOptions(repaired, { gfm: true, html: true, math: false }),
repaired,
);
const document = preserveSoftBreaks
? nativeMarkdownWithPreservedSoftBreaks(parsedDocument)
Expand Down
62 changes: 62 additions & 0 deletions apps/mobile/modules/t3-markdown-text/src/markdownLinks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { repairUnclosedAngleLinkDestinations } from "@t3tools/client-runtime/markdown-links";
import type { MarkdownNode } from "react-native-nitro-markdown/headless";
import { describe, expect, it } from "vite-plus/test";

import { resolveMarkdownLinkPresentation } from "./markdownLinks";
import { nativeMarkdownTextRuns } from "./nativeMarkdownText";

/**
* md4c turns well-formed `[file](<local/path/file.md>)` into a link node
* whose href is the destination between the angle brackets. The native
* parser never builds a node for the unclosed shape, so this mirrors what
* it yields once the source is repaired.
*/
function parsedLinkNodeFor(markdown: string): MarkdownNode {
const repaired = repairUnclosedAngleLinkDestinations(markdown);
const href = repaired.slice(repaired.indexOf("(<") + 2, repaired.lastIndexOf(">)"));
const label = repaired.slice(repaired.indexOf("[") + 1, repaired.indexOf("]"));
const link = { type: "link", href, children: [{ type: "text", content: label }] };
return { type: "document", children: [{ type: "paragraph", children: [link] }] } as MarkdownNode;
}

describe("unclosed angle-bracket file links", () => {
it.each([
["[file](<local/path/file.md)", "[file](<local/path/file.md>)"],
[
"[Receipts](</Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts)",
"[Receipts](</Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts>)",
],
["[Open](<C:/Users/shawn/project/src/main.ts)", "[Open](<C:/Users/shawn/project/src/main.ts>)"],
["- [file](<local/path/file.md)", "- [file](<local/path/file.md>)"],
])("repairs %s before parsing", (source, expected) => {
expect(repairUnclosedAngleLinkDestinations(source)).toBe(expected);
});

it.each([
"[file](<local/path/file.md>)",
"[site](<https://example.com/docs)",
"~~~\n[file](<local/path/file.md)\n~~~",
"```\n[file](<local/path/file.md)\n```",
])("leaves %s alone", (source) => {
expect(repairUnclosedAngleLinkDestinations(source)).toBe(source);
});

it("renders the repaired destination as a file chip run", () => {
expect(resolveMarkdownLinkPresentation("local/path/file.md")).toMatchObject({
kind: "file",
label: "file.md",
});
expect(nativeMarkdownTextRuns(parsedLinkNodeFor("[file](<local/path/file.md)"))).toMatchObject([
{ text: "file.md", href: "local/path/file.md", fileIcon: "markdown" },
]);
});

it("leaves a non-file destination without a chip", () => {
expect(repairUnclosedAngleLinkDestinations("[site](<https://example.com/docs)")).toBe(
"[site](<https://example.com/docs)",
);
expect(resolveMarkdownLinkPresentation("<https://example.com/docs")).toMatchObject({
kind: "link",
});
});
});
134 changes: 134 additions & 0 deletions apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -947,3 +947,137 @@ describe("ChatMarkdown Windows file links", () => {
expect(html).not.toContain("chat-markdown-file-link");
});
});

describe("ChatMarkdown unclosed angle-bracket file links", () => {
it.each([true, false])(
"renders the reporter's missing closer as a file chip with parseRawHtml=%s",
(parseRawHtml) => {
const html = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
text="[file](<local/path/file.md)"
lineBreaks={!parseRawHtml}
parseRawHtml={parseRawHtml}
/>,
);

expect(html).toContain("chat-markdown-file-link");
expect(html).toContain("file.md");
expect(html).not.toContain("&lt;local/path/file.md");
},
);

it.each([true, false])(
"renders unclosed destinations with spaces and Windows paths as file chips with parseRawHtml=%s",
(parseRawHtml) => {
const spaced = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
text="[Receipts](</Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts)"
lineBreaks={!parseRawHtml}
parseRawHtml={parseRawHtml}
/>,
);
const windows = renderToStaticMarkup(
<ChatMarkdown
cwd="C:/Users/shawn/project"
text="[Open](<C:/Users/shawn/project/src/main.ts)"
lineBreaks={!parseRawHtml}
parseRawHtml={parseRawHtml}
/>,
);
const listed = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
text="- [file](<local/path/file.md)"
lineBreaks={!parseRawHtml}
parseRawHtml={parseRawHtml}
/>,
);

for (const html of [spaced, windows, listed]) {
expect(html).toContain("chat-markdown-file-link");
}
expect(spaced).toContain("Bike Receipts");
expect(windows).toContain("main.ts");
expect(listed).toContain("<li>");
},
);

it.each([true, false])(
"leaves other unclosed destinations as text with parseRawHtml=%s",
(parseRawHtml) => {
for (const text of [
"[site](<https://example.com/docs)",
"[note](<hello world)",
"[settings](</chat/settings)",
]) {
const html = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
text={text}
lineBreaks={!parseRawHtml}
parseRawHtml={parseRawHtml}
/>,
);

expect(html).not.toContain("chat-markdown-file-link");
}
},
);

it("leaves the unclosed shape inside code spans literal", () => {
const html = renderToStaticMarkup(
<ChatMarkdown cwd="/tmp/project" text="`[file](</tmp/project/src/main.ts)`" />,
);

expect(html).not.toContain("chat-markdown-file-link");
expect(html).toContain("&lt;/tmp/project/src/main.ts");
});

it("keeps task toggles on the original offsets when a repair precedes them", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer: ReactTestRenderer | undefined;
let editedText: string | undefined;
const original = "[a](<src/a.ts)\n- [ ] Task";
const message = (text: string) => (
<ChatMarkdown
cwd="/tmp/project"
text={text}
onTaskListChange={({ markerOffset, checked }) => {
editedText = setMarkdownTaskChecked(text, markerOffset, checked);
renderer!.update(message(editedText));
}}
/>
);

try {
await act(async () => {
renderer = create(message(original));
});
const mounted = renderer!;
const input = mounted.root.findAllByType("input")[0]!;
const listItem = mounted.root.findAllByType("li")[0]!;
// The `[` sits at offset 17 in the original text; the inserted `>`
// shifts the repaired offset to 18.
expect(listItem.props["data-task-marker-offset"]).toBe(17);
const { onChange } = input.props as ComponentProps<"input">;
if (!onChange) throw new Error("Task checkbox has no edit handler");
await act(async () => {
onChange({
currentTarget: {
checked: true,
closest: () => ({
dataset: { taskMarkerOffset: String(listItem.props["data-task-marker-offset"]) },
}),
},
} as unknown as Parameters<typeof onChange>[0]);
});

expect(editedText).toBe("[a](<src/a.ts)\n- [x] Task");
} finally {
await act(async () => renderer?.unmount());
vi.unstubAllGlobals();
}
});
});
37 changes: 30 additions & 7 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ import {
classifyMarkdownImageSource,
markdownImageSourceFragment,
} from "@t3tools/client-runtime/markdown-images";
import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links";
import {
inlineCodeFilePathCandidate,
mapRepairedOffsetToOriginal,
repairUnclosedAngleLinkDestinationsDetailed,
} 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";
Expand Down Expand Up @@ -278,6 +282,7 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: {

const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
const EMPTY_REMARK_PLUGINS: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = [];
const EMPTY_REPAIR_OFFSETS: readonly number[] = [];

const ARTIFACT_TEMPLATE_ICON_BY_KIND = {
document: FileTextIcon,
Expand Down Expand Up @@ -2322,6 +2327,7 @@ function areMarkdownFileLinkPropsEqual(

function useChatMarkdownState({
text,
repairOffsets = EMPTY_REPAIR_OFFSETS,
cwd,
threadRef,
pullRequestPanelRef,
Expand All @@ -2336,7 +2342,7 @@ function useChatMarkdownState({
renderContextReference,
headingLevelOffset = 0,
githubMedia = false,
}: ChatMarkdownProps) {
}: ChatMarkdownProps & { readonly repairOffsets?: readonly number[] }) {
const { resolvedTheme } = useTheme();
const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null);
const markdownRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -2746,6 +2752,7 @@ function useChatMarkdownState({
linkedThreadPullRequestFor,
resolveThreadPullRequest,
resolvedTheme,
repairOffsets,
serverConfig,
skills,
text,
Expand Down Expand Up @@ -2777,6 +2784,7 @@ function useChatMarkdownState({
linkedThreadPullRequestFor,
resolveThreadPullRequest,
resolvedTheme,
repairOffsets,
serverConfig,
skills,
text,
Expand Down Expand Up @@ -2867,12 +2875,16 @@ const CHAT_MARKDOWN_COMPONENTS = {
);
},
li: function MarkdownListItem({ node, children, ...props }) {
const { text, skills } = use(ChatMarkdownRendererContext);
const { text, skills, repairOffsets } = use(ChatMarkdownRendererContext);
const listItemStart = node?.position?.start.offset;
const markerOffset =
typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null;
// Repairs insert `>`, so a marker after one sits further along in the
// rendered text than in the source the toggle edits. Map it back.
const originalMarkerOffset =
markerOffset === null ? null : mapRepairedOffsetToOriginal(repairOffsets, markerOffset);
return (
<li {...props} data-task-marker-offset={markerOffset ?? undefined}>
<li {...props} data-task-marker-offset={originalMarkerOffset ?? undefined}>
{renderSkillInlineMarkdownChildren(children, skills)}
</li>
);
Expand Down Expand Up @@ -3357,18 +3369,29 @@ function ChatMarkdown({
extraRemarkPlugins = EMPTY_REMARK_PLUGINS,
...props
}: ChatMarkdownProps) {
// Codex can emit angle-bracket destinations without the closing `>`,
// which CommonMark leaves as raw text. Repairing up front keeps the
// parsed tree, the link pre-scan, and source offsets on the same string.
// The insertion offsets map repaired positions (task markers) back to the
// source the toggle edits.
const repairedMarkdown = useMemo(() => repairUnclosedAngleLinkDestinationsDetailed(text), [text]);
const repairedText = repairedMarkdown.text;
const {
componentState,
handleCopy,
markdownRef,
markdownUrlTransform,
localMediaPreview,
setLocalMediaPreview,
} = useChatMarkdownState({ text, ...props });
} = useChatMarkdownState({
text: repairedText,
repairOffsets: repairedMarkdown.insertedOffsets,
...props,
});
const incrementalParsing =
props.isStreaming === true &&
extraRemarkPlugins.length === 0 &&
/(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text);
/(?:^|\n) {0,3}(?:`{3}|~{3})/.test(repairedText);
const remarkPlugins = useMemo(
() => [
...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),
Expand Down Expand Up @@ -3400,7 +3423,7 @@ function ChatMarkdown({
components={CHAT_MARKDOWN_COMPONENTS}
urlTransform={markdownUrlTransform}
>
{text}
{repairedText}
</ReactMarkdown>
</ChatMarkdownRendererContext>
{localMediaPreview ? (
Expand Down
Loading
Loading