From be678d2bf37c070b26b8f8861a2af7967c610d07 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:26:31 +0100 Subject: [PATCH 1/3] Improve markdown file link UX --- .../src/components/ChatMarkdown.browser.tsx | 64 +++- apps/web/src/components/ChatMarkdown.tsx | 300 +++++++++++++++++- .../components/chat/MessagesTimeline.test.tsx | 48 +++ .../src/components/chat/MessagesTimeline.tsx | 39 ++- apps/web/src/filePathDisplay.test.ts | 41 +++ apps/web/src/filePathDisplay.ts | 57 ++++ apps/web/src/index.css | 59 ++++ apps/web/src/markdown-links.test.ts | 29 +- apps/web/src/markdown-links.ts | 40 ++- apps/web/src/terminal-links.ts | 2 +- 10 files changed, 644 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/filePathDisplay.test.ts create mode 100644 apps/web/src/filePathDisplay.ts diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index 48f345bd2f9..a397d52a37f 100644 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -63,7 +63,7 @@ describe("ChatMarkdown", () => { ); try { - const link = page.getByRole("link", { name: "PermissionRule.ts:1" }); + const link = page.getByRole("link", { name: "PermissionRule.ts · L1" }); await expect.element(link).toBeInTheDocument(); await expect.element(link).toHaveAttribute("href", `${filePath}#L1`); @@ -76,4 +76,66 @@ describe("ChatMarkdown", () => { 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 screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "PermissionRule.ts · L1:C7" }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute("href", `${filePath}#L1C7`); + + await link.click(); + + await vi.waitFor(() => { + expect(openInPreferredEditorMock).toHaveBeenCalledWith( + expect.anything(), + `${filePath}:1:7`, + ); + }); + } finally { + await screen.unmount(); + } + }); + + 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 screen = await render( + , + ); + + try { + await expect + .element(page.getByRole("link", { name: "MessagesTimeline.tsx · components/chat" })) + .toBeInTheDocument(); + await expect + .element(page.getByRole("link", { name: "MessagesTimeline.tsx · src/components" })) + .toBeInTheDocument(); + } finally { + await screen.unmount(); + } + }); + + it("keeps normal web links unchanged", async () => { + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "OpenAI" }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute("href", "https://openai.com/docs"); + await expect.element(link).toHaveAttribute("target", "_blank"); + } finally { + await screen.unmount(); + } + }); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 366f9231579..266eb613e5f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -3,6 +3,7 @@ import { CheckIcon, CopyIcon } from "lucide-react"; import React, { Children, Suspense, + type MouseEvent as ReactMouseEvent, isValidElement, use, useCallback, @@ -17,13 +18,17 @@ import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; +import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { toastManager } from "./ui/toast"; import { openInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; -import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "../markdown-links"; +import { resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref } from "../markdown-links"; import { readLocalApi } from "../localApi"; +import { cn } from "../lib/utils"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -236,34 +241,289 @@ function SuspenseShikiCodeBlock({ ); } +interface MarkdownFileLinkProps { + href: string; + targetPath: string; + displayPath: string; + filePath: string; + label: string; + theme: "light" | "dark"; + className?: string | undefined; +} + +const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; +const MARKDOWN_FILE_LINK_CLASS_NAME = + "chat-markdown-file-link relative top-px 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"; + +function pathParentSegments(path: string): string[] { + const normalized = path.replaceAll("\\", "/"); + const segments = normalized.split("/").filter((segment) => segment.length > 0); + return segments.slice(0, -1); +} + +function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray): Map { + const groups = new Map>(); + for (const filePath of filePaths) { + const pathSegments = filePath + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment.length > 0); + const basename = pathSegments[pathSegments.length - 1]; + if (!basename) continue; + const group = groups.get(basename) ?? new Set(); + group.add(filePath); + groups.set(basename, group); + } + + const suffixByPath = new Map(); + for (const group of groups.values()) { + const uniquePaths = [...group]; + if (uniquePaths.length < 2) continue; + + const parentSegmentsByPath = new Map( + uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]), + ); + const minUniqueDepthByPath = new Map(); + + for (const filePath of uniquePaths) { + const segments = parentSegmentsByPath.get(filePath) ?? []; + let resolvedDepth = segments.length; + for (let depth = 1; depth <= segments.length; depth += 1) { + const candidate = segments.slice(-depth).join("/"); + const collision = uniquePaths.some((otherPath) => { + if (otherPath === filePath) return false; + const otherSegments = parentSegmentsByPath.get(otherPath) ?? []; + return otherSegments.slice(-depth).join("/") === candidate; + }); + if (!collision) { + resolvedDepth = depth; + break; + } + } + minUniqueDepthByPath.set(filePath, resolvedDepth); + } + + for (const filePath of uniquePaths) { + const segments = parentSegmentsByPath.get(filePath) ?? []; + if (segments.length === 0) continue; + const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1; + const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2)); + suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/")); + } + } + + 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 normalizeMarkdownLinkHrefKey(href: string): string { + return rewriteMarkdownFileUriHref(href.trim()) ?? href.trim(); +} + +const MarkdownFileLink = memo(function MarkdownFileLink({ + href, + targetPath, + displayPath, + filePath, + label, + theme, + className, +}: MarkdownFileLinkProps) { + const handleOpen = useCallback(() => { + const api = readLocalApi(); + if (!api) { + toastManager.add({ + type: "error", + title: "Open in editor is unavailable", + }); + return; + } + + void openInPreferredEditor(api, targetPath).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to open file", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + }, [targetPath]); + + const handleCopy = useCallback((value: string, title: string) => { + if (typeof window === "undefined" || !navigator.clipboard?.writeText) { + toastManager.add({ + type: "error", + title: `Failed to copy ${title.toLowerCase()}`, + description: "Clipboard API unavailable.", + }); + return; + } + + void navigator.clipboard.writeText(value).then( + () => { + toastManager.add({ + type: "success", + title: `${title} copied`, + description: value, + }); + }, + (error) => { + toastManager.add({ + type: "error", + title: `Failed to copy ${title.toLowerCase()}`, + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + ); + }, []); + + const handleContextMenu = useCallback( + async (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const api = readLocalApi(); + if (!api) return; + + const clicked = await api.contextMenu.show( + [ + { id: "open", label: "Open in editor" }, + { id: "copy-relative", label: "Copy relative path" }, + { id: "copy-full", label: "Copy full path" }, + ] as const, + { x: event.clientX, y: event.clientY }, + ); + + if (clicked === "open") { + handleOpen(); + return; + } + if (clicked === "copy-relative") { + handleCopy(displayPath, "Relative path"); + return; + } + if (clicked === "copy-full") { + handleCopy(targetPath, "Full path"); + } + }, + [displayPath, handleCopy, handleOpen, targetPath], + ); + + return ( + + { + event.preventDefault(); + event.stopPropagation(); + handleOpen(); + }} + onContextMenu={handleContextMenu} + > + + {label} + + } + /> + +
+ {displayPath} +
+
+
+ ); +}, areMarkdownFileLinkPropsEqual); + +function areMarkdownFileLinkPropsEqual( + previous: Readonly, + next: Readonly, +): boolean { + return ( + previous.href === next.href && + previous.targetPath === next.targetPath && + previous.displayPath === next.displayPath && + previous.filePath === next.filePath && + previous.label === next.label && + previous.theme === next.theme && + previous.className === next.className + ); +} + function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); + const markdownFileLinkMetaByHref = useMemo(() => { + const metaByHref = new Map< + string, + NonNullable> + >(); + 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 markdownComponents = useMemo( () => ({ a({ node: _node, href, ...props }) { - const targetPath = resolveMarkdownFileLinkTarget(href, cwd); - if (!targetPath) { + const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; + const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; + if (!fileLinkMeta) { return ; } + const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); + const labelParts = [fileLinkMeta.basename]; + if (typeof parentSuffix === "string" && parentSuffix.length > 0) { + labelParts.push(parentSuffix); + } + if (fileLinkMeta.line) { + labelParts.push( + `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, + ); + } + return ( - { - event.preventDefault(); - event.stopPropagation(); - const api = readLocalApi(); - if (api) { - void openInPreferredEditor(api, targetPath); - } else { - console.warn("Native API not found. Unable to open file in editor."); - } - }} + ); }, @@ -289,7 +549,13 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { ); }, }), - [cwd, diffThemeName, isStreaming], + [ + diffThemeName, + fileLinkParentSuffixByPath, + isStreaming, + markdownFileLinkMetaByHref, + resolvedTheme, + ], ); return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index c4bca4b4f01..57736cd1604 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -148,4 +148,52 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Context compacted"); expect(markup).toContain("Work log"); }); + + it("formats changed file paths from the workspace root", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + {}} + changedFilesExpandedByTurnId={{}} + onSetChangedFilesExpanded={() => {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot="C:/Users/mike/dev-stuff/t3code" + />, + ); + + expect(markup).toContain("t3code/apps/web/src/session-logic.ts"); + expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f08d544cc10..881b917a2c8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -63,6 +63,7 @@ import { formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; +import { formatWorkspaceRelativePath } from "../../filePathDisplay"; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; @@ -351,7 +352,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )}
{visibleEntries.map((workEntry) => ( - + ))}
@@ -811,15 +816,17 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { function workEntryPreview( workEntry: Pick, + workspaceRoot: string | undefined, ) { if (workEntry.command) return workEntry.command; if (workEntry.detail) return workEntry.detail; if ((workEntry.changedFiles?.length ?? 0) === 0) return null; const [firstPath] = workEntry.changedFiles ?? []; if (!firstPath) return null; + const displayPath = formatWorkspaceRelativePath(firstPath, workspaceRoot); return workEntry.changedFiles!.length === 1 - ? firstPath - : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; + ? displayPath + : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; } function workEntryRawCommand( @@ -874,12 +881,13 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; + workspaceRoot: string | undefined; }) { - const { workEntry } = props; + const { workEntry, workspaceRoot } = props; const iconConfig = workToneIcon(workEntry.tone); const EntryIcon = workEntryIcon(workEntry); const heading = toolWorkEntryHeading(workEntry); - const preview = workEntryPreview(workEntry); + const preview = workEntryPreview(workEntry, workspaceRoot); const rawCommand = workEntryRawCommand(workEntry); const displayText = preview ? `${heading} - ${preview}` : heading; const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; @@ -938,15 +946,18 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { {hasChangedFiles && !previewIsChangedFiles && (
- {workEntry.changedFiles?.slice(0, 4).map((filePath) => ( - - {filePath} - - ))} + {workEntry.changedFiles?.slice(0, 4).map((filePath) => { + const displayPath = formatWorkspaceRelativePath(filePath, workspaceRoot); + return ( + + {displayPath} + + ); + })} {(workEntry.changedFiles?.length ?? 0) > 4 && ( +{(workEntry.changedFiles?.length ?? 0) - 4} diff --git a/apps/web/src/filePathDisplay.test.ts b/apps/web/src/filePathDisplay.test.ts new file mode 100644 index 00000000000..c196b5677b5 --- /dev/null +++ b/apps/web/src/filePathDisplay.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { formatWorkspaceRelativePath } from "./filePathDisplay"; + +describe("formatWorkspaceRelativePath", () => { + it("formats absolute workspace paths from the workspace root", () => { + expect( + formatWorkspaceRelativePath( + "C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts:501", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toBe("t3code/apps/web/src/session-logic.ts:501"); + }); + + it("prefixes relative paths with the workspace root label", () => { + expect( + formatWorkspaceRelativePath( + "apps/web/src/session-logic.ts:501", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toBe("t3code/apps/web/src/session-logic.ts:501"); + }); + + it("keeps paths already rooted at the workspace label stable", () => { + expect( + formatWorkspaceRelativePath( + "t3code/apps/web/src/session-logic.ts:501", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toBe("t3code/apps/web/src/session-logic.ts:501"); + }); + + it("preserves columns when present", () => { + expect( + formatWorkspaceRelativePath( + "/C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts:501:9", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toBe("t3code/apps/web/src/session-logic.ts:501:9"); + }); +}); diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts new file mode 100644 index 00000000000..5a6e2a02e10 --- /dev/null +++ b/apps/web/src/filePathDisplay.ts @@ -0,0 +1,57 @@ +import { splitPathAndPosition } from "./terminal-links"; + +function normalizePathSeparators(path: string): string { + return path.replaceAll("\\", "/"); +} + +function canonicalizeWindowsDrivePath(path: string): string { + return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path; +} + +function trimTrailingPathSeparators(path: string): string { + return path.replace(/[\\/]+$/, ""); +} + +function basenameOfPath(path: string): string { + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; +} + +function stripRelativePrefixes(path: string): string { + return path.replace(/^\.\/+/, "").replace(/^\/+/, ""); +} + +export function formatWorkspaceRelativePath( + pathWithPosition: string, + workspaceRoot: string | undefined, +): string { + const { path, line, column } = splitPathAndPosition(pathWithPosition); + const normalizedPath = canonicalizeWindowsDrivePath(normalizePathSeparators(path)); + + let displayPath = normalizedPath; + if (workspaceRoot) { + const normalizedWorkspaceRoot = canonicalizeWindowsDrivePath( + normalizePathSeparators(trimTrailingPathSeparators(workspaceRoot)), + ); + const workspaceLabel = basenameOfPath(normalizedWorkspaceRoot); + const pathForCompare = normalizedPath.toLowerCase(); + const workspaceForCompare = normalizedWorkspaceRoot.toLowerCase(); + const workspaceWithSeparator = `${workspaceForCompare}/`; + const workspaceLabelWithSeparator = `${workspaceLabel.toLowerCase()}/`; + + if (pathForCompare === workspaceForCompare) { + displayPath = workspaceLabel; + } else if (pathForCompare.startsWith(workspaceWithSeparator)) { + const relativeSuffix = normalizedPath.slice(normalizedWorkspaceRoot.length + 1); + displayPath = `${workspaceLabel}/${relativeSuffix}`; + } else if (!normalizedPath.startsWith("/")) { + const relativePath = stripRelativePrefixes(normalizedPath); + displayPath = pathForCompare.startsWith(workspaceLabelWithSeparator) + ? normalizedPath + : `${workspaceLabel}/${relativePath}`; + } + } + + if (!line) return displayPath; + return `${displayPath}:${line}${column ? `:${column}` : ""}`; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 71602ee05e5..9567567e93a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -322,6 +322,47 @@ label:has(> select#reasoning-effort) select { font-size: 0.75rem; } +.chat-markdown-file-link { + display: inline-flex; + align-items: center; + gap: 0.28rem; + border: 1px solid color-mix(in srgb, var(--border) 92%, transparent); + border-radius: 0.375rem; + background: color-mix(in srgb, var(--muted) 88%, var(--background)); + padding: 0.08rem 0.34rem; + color: var(--foreground); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.75rem; + line-height: 1.15; + vertical-align: text-bottom; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease, + opacity 120ms ease; +} + +.chat-markdown-file-link:hover { + opacity: 1; + color: var(--foreground); + border-color: color-mix(in srgb, var(--border) 65%, var(--foreground)); + background: color-mix(in srgb, var(--muted) 72%, var(--background)); +} + +.chat-markdown-file-link:focus-visible { + outline: none; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--ring) 70%, transparent); +} + +.chat-markdown-file-link-icon { + opacity: 0.72; +} + +.chat-markdown-file-link-label { + color: color-mix(in srgb, var(--foreground) 88%, transparent); + line-height: 1.1; +} + .chat-markdown pre { max-width: 100%; overflow-x: auto; @@ -338,6 +379,24 @@ label:has(> select#reasoning-effort) select { font-size: 0.75rem; } +.markdown-file-link-tooltip-scroll { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; +} + +.markdown-file-link-tooltip-scroll::-webkit-scrollbar { + height: 6px; +} + +.markdown-file-link-tooltip-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.markdown-file-link-tooltip-scroll::-webkit-scrollbar-thumb { + border-radius: 999px; + background: color-mix(in srgb, var(--border) 78%, transparent); +} + .chat-markdown .chat-markdown-codeblock { position: relative; margin: 0.65rem 0; diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index d3ca8bc99a1..a49512d8ece 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "./markdown-links"; +import { + resolveMarkdownFileLinkMeta, + resolveMarkdownFileLinkTarget, + rewriteMarkdownFileUriHref, +} from "./markdown-links"; describe("rewriteMarkdownFileUriHref", () => { it("rewrites file uri hrefs into direct path hrefs", () => { @@ -57,6 +61,29 @@ describe("resolveMarkdownFileLinkTarget", () => { ); }); + it("formats tooltip display paths relative to the cwd when possible", () => { + expect( + resolveMarkdownFileLinkMeta( + "file:///C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts#L501", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toMatchObject({ + displayPath: "t3code/apps/web/src/session-logic.ts:501", + }); + }); + + it("formats tooltip display paths relative to the cwd for slash-prefixed windows paths", () => { + expect( + resolveMarkdownFileLinkMeta( + "/C:/Users/mike/dev-stuff/t3code/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toMatchObject({ + displayPath: + "t3code/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx", + }); + }); + it("does not treat app routes as file links", () => { expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull(); }); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index b5dcab01006..003fb0409d2 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -1,4 +1,5 @@ -import { resolvePathLinkTarget } from "./terminal-links"; +import { formatWorkspaceRelativePath } from "./filePathDisplay"; +import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links"; const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; @@ -21,6 +22,15 @@ const POSIX_FILE_ROOT_PREFIXES = [ "/root/", ] as const; +export interface MarkdownFileLinkMeta { + filePath: string; + targetPath: string; + displayPath: string; + basename: string; + line?: number; + column?: number; +} + function safeDecode(value: string): string { try { return decodeURIComponent(value); @@ -143,3 +153,31 @@ export function resolveMarkdownFileLinkTarget( if (!cwd) return null; return resolvePathLinkTarget(pathWithPosition, cwd); } + +function basenameOfPath(path: string): string { + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; +} + +export function resolveMarkdownFileLinkMeta( + href: string | undefined, + cwd?: string, +): MarkdownFileLinkMeta | null { + const targetPath = resolveMarkdownFileLinkTarget(href, cwd); + if (!targetPath) return null; + + const { path, line, column } = splitPathAndPosition(targetPath); + const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN; + const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN; + const lineNumber = Number.isFinite(parsedLine) ? parsedLine : undefined; + const columnNumber = Number.isFinite(parsedColumn) ? parsedColumn : undefined; + + return { + filePath: path, + targetPath, + displayPath: formatWorkspaceRelativePath(targetPath, cwd), + basename: basenameOfPath(path), + ...(lineNumber !== undefined ? { line: lineNumber } : {}), + ...(columnNumber !== undefined ? { column: columnNumber } : {}), + }; +} diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 0d40a90e415..a4eeda4279c 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -137,7 +137,7 @@ function inferHomeFromCwd(cwd: string): string | undefined { return undefined; } -function splitPathAndPosition(value: string): { +export function splitPathAndPosition(value: string): { path: string; line: string | undefined; column: string | undefined; From a8ef7a09b912b651fe2d43eba8012ebe97bcb8ca Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:31:26 +0100 Subject: [PATCH 2/3] Stabilize timeline test timeout --- apps/web/src/components/chat/MessagesTimeline.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 57736cd1604..5cc76239de4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -100,7 +100,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Terminal 1 lines 1-5"); expect(markup).toContain("lucide-terminal"); expect(markup).toContain("yoo what's "); - }, 10_000); + }, 20_000); it("renders context compaction entries in the normal work log", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); From 4fac05f231637035fe935b509f149039fc8da187 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:25:34 +0100 Subject: [PATCH 3/3] Tweak markdown file chip alignment --- apps/web/src/components/ChatMarkdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 266eb613e5f..ba1c944cc87 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -253,7 +253,7 @@ interface MarkdownFileLinkProps { const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; const MARKDOWN_FILE_LINK_CLASS_NAME = - "chat-markdown-file-link relative top-px max-w-full no-underline"; + "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";