Skip to content
Open
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
94 changes: 86 additions & 8 deletions apps/web/src/components/ChatMarkdown.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ describe("ChatMarkdown", () => {
});

it("rewrites file uri hrefs into direct paths before rendering", async () => {
const filePath =
"/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts";
const filePath = "/Users/lol/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts";
const screen = await render(
<ChatMarkdown text={`[PermissionRule.ts](file://${filePath})`} cwd="/repo/project" />,
);
Expand All @@ -56,8 +55,7 @@ describe("ChatMarkdown", () => {
});

it("keeps line anchors working after rewriting file uri hrefs", async () => {
const filePath =
"/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts";
const filePath = "/Users/lol/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts";
const screen = await render(
<ChatMarkdown text={`[PermissionRule.ts:1](file://${filePath}#L1)`} cwd="/repo/project" />,
);
Expand All @@ -77,9 +75,89 @@ describe("ChatMarkdown", () => {
}
});

it("opens markdown file links containing parentheses and square brackets", async () => {
const filePath = "/Users/lol/p/t3code/apps/web/src/routes/(chat)/[threadId].tsx";
const screen = await render(<ChatMarkdown text={`[route](${filePath})`} cwd="/repo/project" />);

try {
const link = page.getByRole("link", { name: "[threadId].tsx" });
await expect.element(link).toBeInTheDocument();

await link.click();

await vi.waitFor(() => {
expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), filePath);
});
} finally {
await screen.unmount();
}
});

it("opens encoded markdown file links containing parentheses and square brackets", async () => {
const targetPath = "/repo/project/apps/web/src/routes/(chat)/[threadId].tsx";
const screen = await render(
<ChatMarkdown
text="[route](apps/web/src/routes/%28chat%29/%5BthreadId%5D.tsx)"
cwd="/repo/project"
/>,
);

try {
const link = page.getByRole("link", { name: "[threadId].tsx" });
await expect.element(link).toBeInTheDocument();

await link.click();

await vi.waitFor(() => {
expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), targetPath);
});
} finally {
await screen.unmount();
}
});

it("opens angle-bracketed markdown file links", async () => {
const filePath = "/Users/lol/p/t3code/apps/web/src/routes/(chat)/My Route [thread].tsx";
const screen = await render(
<ChatMarkdown text={`[route](<${filePath}>)`} cwd="/repo/project" />,
);

try {
const link = page.getByRole("link", { name: "My Route [thread].tsx" });
await expect.element(link).toBeInTheDocument();

await link.click();

await vi.waitFor(() => {
expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), filePath);
});
} finally {
await screen.unmount();
}
});

it("opens Windows drive markdown file links", async () => {
const targetPath = "D:/Users/lol/t3code/apps/web/src/routes/(chat)/[threadId].tsx:42";
const screen = await render(
<ChatMarkdown text={`[route](${targetPath})`} cwd="/repo/project" />,
);

try {
const link = page.getByRole("link", { name: "[threadId].tsx · L42" });
await expect.element(link).toBeInTheDocument();

await link.click();

await vi.waitFor(() => {
expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), targetPath);
});
} finally {
await screen.unmount();
}
});

it("shows column information inline when present", async () => {
const filePath =
"/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts";
const filePath = "/Users/lol/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts";
const screen = await render(
<ChatMarkdown text={`[PermissionRule.ts](file://${filePath}#L1C7)`} cwd="/repo/project" />,
);
Expand All @@ -103,8 +181,8 @@ describe("ChatMarkdown", () => {
});

it("disambiguates duplicate file basenames inline", async () => {
const firstPath = "/Users/yashsingh/p/t3code/apps/web/src/components/chat/MessagesTimeline.tsx";
const secondPath = "/Users/yashsingh/p/t3code/apps/web/src/components/MessagesTimeline.tsx";
const firstPath = "/Users/lol/p/t3code/apps/web/src/components/chat/MessagesTimeline.tsx";
const secondPath = "/Users/lol/p/t3code/apps/web/src/components/MessagesTimeline.tsx";
const screen = await render(
<ChatMarkdown
text={`See [MessagesTimeline.tsx](file://${firstPath}) and [MessagesTimeline.tsx](file://${secondPath}).`}
Expand Down
114 changes: 65 additions & 49 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ import { fnv1a32 } from "../lib/diffRendering";
import { LRUCache } from "../lib/lruCache";
import { useTheme } from "../hooks/useTheme";
import {
normalizeMarkdownLinkDestination,
resolveMarkdownFileLinkMeta,
resolveMarkdownFileLinkTarget,
rewriteMarkdownFileUriHref,
} from "../markdown-links";
import { readLocalApi } from "../localApi";
Expand Down Expand Up @@ -286,11 +286,20 @@ interface MarkdownFileLinkProps {
className?: string | undefined;
}

const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
const MARKDOWN_FILE_LINK_CLASS_NAME =
"chat-markdown-file-link relative top-[2px] max-w-full no-underline";
const MARKDOWN_FILE_LINK_ICON_CLASS_NAME = "chat-markdown-file-link-icon size-3.5 shrink-0";
const MARKDOWN_FILE_LINK_LABEL_CLASS_NAME = "chat-markdown-file-link-label truncate";
const FILE_LINK_PARENT_SUFFIX_DATA_ATTR = "data-file-link-parent-suffix";

interface MarkdownAstNode {
type?: string;
url?: string;
children?: MarkdownAstNode[];
data?: {
hProperties?: Record<string, unknown>;
};
}

function pathParentSegments(path: string): string[] {
const normalized = path.replaceAll("\\", "/");
Expand Down Expand Up @@ -352,19 +361,42 @@ function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray<string>): Map<
return suffixByPath;
}

function extractMarkdownLinkHrefs(text: string): string[] {
const hrefs: string[] = [];
for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) {
const href = match[1]?.trim();
if (!href) continue;
hrefs.push(href);
}
return hrefs;
}
function createFileLinkDisambiguationPlugin(cwd: string | undefined) {
return () => (tree: MarkdownAstNode) => {
const fileLinks: Array<{
node: MarkdownAstNode;
meta: NonNullable<ReturnType<typeof resolveMarkdownFileLinkMeta>>;
}> = [];

const visit = (node: MarkdownAstNode) => {
if (node.type === "link" && typeof node.url === "string") {
const meta = resolveMarkdownFileLinkMeta(node.url, cwd);
if (meta) {
fileLinks.push({ node, meta });
}
}
for (const child of node.children ?? []) {
visit(child);
}
};

function normalizeMarkdownLinkHrefKey(href: string): string {
const normalizedHref = normalizeMarkdownLinkDestination(href);
return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;
visit(tree);

const suffixByPath = buildFileLinkParentSuffixByPath(
fileLinks.map((fileLink) => fileLink.meta.filePath),
);
for (const { node, meta } of fileLinks) {
const parentSuffix = suffixByPath.get(meta.filePath);
if (!parentSuffix) continue;
node.data = {
...node.data,
hProperties: {
...node.data?.hProperties,
[FILE_LINK_PARENT_SUFFIX_DATA_ATTR]: parentSuffix,
},
};
}
};
}

const MarkdownFileLink = memo(function MarkdownFileLink({
Expand Down Expand Up @@ -520,28 +552,16 @@ function ChatMarkdown({
}: ChatMarkdownProps) {
const { resolvedTheme } = useTheme();
const diffThemeName = resolveDiffThemeName(resolvedTheme);
const markdownFileLinkMetaByHref = useMemo(() => {
const metaByHref = new Map<
string,
NonNullable<ReturnType<typeof resolveMarkdownFileLinkMeta>>
>();
for (const href of extractMarkdownLinkHrefs(text)) {
const normalizedHref = normalizeMarkdownLinkHrefKey(href);
if (metaByHref.has(normalizedHref)) continue;
const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd);
if (meta) {
metaByHref.set(normalizedHref, meta);
}
}
return metaByHref;
}, [cwd, text]);
const fileLinkParentSuffixByPath = useMemo(() => {
const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath);
return buildFileLinkParentSuffixByPath(filePaths);
}, [markdownFileLinkMetaByHref]);
const markdownUrlTransform = useCallback((href: string) => {
return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);
}, []);
const remarkPlugins = useMemo(() => [remarkGfm, createFileLinkDisambiguationPlugin(cwd)], [cwd]);
const markdownUrlTransform = useCallback(
(href: string) => {
return (
rewriteMarkdownFileUriHref(href) ??
(resolveMarkdownFileLinkTarget(href, cwd) ? href : defaultUrlTransform(href))
);
},
[cwd],
);
const markdownComponents = useMemo<Components>(
() => ({
p({ node: _node, children, ...props }) {
Expand All @@ -551,15 +571,18 @@ function ChatMarkdown({
return <li {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</li>;
},
a({ node: _node, href, ...props }) {
const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;
const fileLinkMeta = href ? resolveMarkdownFileLinkMeta(href, cwd) : null;
if (!fileLinkMeta) {
return <a {...props} href={href} target="_blank" rel="noopener noreferrer" />;
}

const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath);
const anchorProps = props as typeof props & Record<string, unknown>;
const parentSuffix =
typeof anchorProps[FILE_LINK_PARENT_SUFFIX_DATA_ATTR] === "string"
? anchorProps[FILE_LINK_PARENT_SUFFIX_DATA_ATTR]
: undefined;
const labelParts = [fileLinkMeta.basename];
if (typeof parentSuffix === "string" && parentSuffix.length > 0) {
if (parentSuffix) {
labelParts.push(parentSuffix);
}
if (fileLinkMeta.line) {
Expand Down Expand Up @@ -602,20 +625,13 @@ function ChatMarkdown({
);
},
}),
[
diffThemeName,
fileLinkParentSuffixByPath,
isStreaming,
markdownFileLinkMetaByHref,
resolvedTheme,
skills,
],
[diffThemeName, cwd, isStreaming, resolvedTheme, skills],
);

return (
<div className="chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
remarkPlugins={remarkPlugins}
components={markdownComponents}
urlTransform={markdownUrlTransform}
>
Expand Down
37 changes: 23 additions & 14 deletions apps/web/src/markdown-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import {
rewriteMarkdownFileUriHref,
} from "./markdown-links";

describe("normalizeMarkdownLinkDestination", () => {
it("unescapes markdown punctuation when normalizing destinations", () => {
expect(
resolveMarkdownFileLinkTarget("apps/web/src/routes/\\(chat\\)/\\[id\\].tsx", "/repo"),
).toBe("/repo/apps/web/src/routes/(chat)/[id].tsx");
});
});

describe("rewriteMarkdownFileUriHref", () => {
it("rewrites file uri hrefs into direct path hrefs", () => {
expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe(
Expand All @@ -26,12 +34,6 @@ describe("rewriteMarkdownFileUriHref", () => {
),
).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69");
});

it("unwraps angle-bracketed file uri hrefs", () => {
expect(
rewriteMarkdownFileUriHref(" <file:///D:/Programme/t3code/apps/web/src/markdown-links.ts> "),
).toBe("D:/Programme/t3code/apps/web/src/markdown-links.ts");
});
});

describe("resolveMarkdownFileLinkTarget", () => {
Expand Down Expand Up @@ -59,6 +61,21 @@ describe("resolveMarkdownFileLinkTarget", () => {
);
});

it("resolves relative paths with route group and dynamic segment characters", () => {
expect(
resolveMarkdownFileLinkTarget("apps/web/src/routes/(chat)/[threadId].tsx", "/repo/project"),
).toBe("/repo/project/apps/web/src/routes/(chat)/[threadId].tsx");
});

it("resolves encoded route group and dynamic segment characters", () => {
expect(
resolveMarkdownFileLinkTarget(
"apps/web/src/routes/%28chat%29/%5BthreadId%5D.tsx",
"/repo/project",
),
).toBe("/repo/project/apps/web/src/routes/(chat)/[threadId].tsx");
});

it("maps #L line anchors to editor line suffixes", () => {
expect(resolveMarkdownFileLinkTarget("/Users/julius/project/src/main.ts#L42C7")).toBe(
"/Users/julius/project/src/main.ts:42:7",
Expand Down Expand Up @@ -106,14 +123,6 @@ describe("resolveMarkdownFileLinkTarget", () => {
).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx:69");
});

it("resolves angle-bracketed windows drive paths", () => {
expect(
resolveMarkdownFileLinkTarget(
"</D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1>",
),
).toBe("D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1");
});

it("does not treat app routes as file links", () => {
expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull();
});
Expand Down
Loading
Loading