authorgravatar for 145564979+justsomelegs@users.noreply.github.comlegs <145564979+justsomelegs@users.noreply.github.com> 2026-04-15 22:20:28+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-15 14:20:28-07:00
log68061af0420ba6acd9c1a4c2f77eeddfff0e8bd2
treea884d236fa31de1d9505554ed42b4c1fbc806caf
parent28cb9db20bc78fdb5a767ff9661e500076938fbe
signature Signed by PGP key B5690EEEBB952194

Improve markdown file link UX (#1956)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

10 files changed, 786 insertions(+), 35 deletions(-)

apps/web/src/components/ChatMarkdown.browser.tsx+63-1
...@@ -63,7 +63,7 @@ describe("ChatMarkdown", () => {...@@ -63,7 +63,7 @@ describe("ChatMarkdown", () => {
63 );63 );
6464
65 try {65 try {
66 const link = page.getByRole("link", { name: "PermissionRule.ts:1" });66 const link = page.getByRole("link", { name: "PermissionRule.ts · L1" });
67 await expect.element(link).toBeInTheDocument();67 await expect.element(link).toBeInTheDocument();
68 await expect.element(link).toHaveAttribute("href", `${filePath}#L1`);68 await expect.element(link).toHaveAttribute("href", `${filePath}#L1`);
6969
...@@ -76,4 +76,66 @@ describe("ChatMarkdown", () => {...@@ -76,4 +76,66 @@ describe("ChatMarkdown", () => {
76 await screen.unmount();76 await screen.unmount();
77 }77 }
78 });78 });
79
80 it("shows column information inline when present", async () => {
81 const filePath =
82 "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts";
83 const screen = await render(
84 <ChatMarkdown text={`[PermissionRule.ts](file://${filePath}#L1C7)`} cwd="/repo/project" />,
85 );
86
87 try {
88 const link = page.getByRole("link", { name: "PermissionRule.ts · L1:C7" });
89 await expect.element(link).toBeInTheDocument();
90 await expect.element(link).toHaveAttribute("href", `${filePath}#L1C7`);
91
92 await link.click();
93
94 await vi.waitFor(() => {
95 expect(openInPreferredEditorMock).toHaveBeenCalledWith(
96 expect.anything(),
97 `${filePath}:1:7`,
98 );
99 });
100 } finally {
101 await screen.unmount();
102 }
103 });
104
105 it("disambiguates duplicate file basenames inline", async () => {
106 const firstPath = "/Users/yashsingh/p/t3code/apps/web/src/components/chat/MessagesTimeline.tsx";
107 const secondPath = "/Users/yashsingh/p/t3code/apps/web/src/components/MessagesTimeline.tsx";
108 const screen = await render(
109 <ChatMarkdown
110 text={`See [MessagesTimeline.tsx](file://${firstPath}) and [MessagesTimeline.tsx](file://${secondPath}).`}
111 cwd="/repo/project"
112 />,
113 );
114
115 try {
116 await expect
117 .element(page.getByRole("link", { name: "MessagesTimeline.tsx · components/chat" }))
118 .toBeInTheDocument();
119 await expect
120 .element(page.getByRole("link", { name: "MessagesTimeline.tsx · src/components" }))
121 .toBeInTheDocument();
122 } finally {
123 await screen.unmount();
124 }
125 });
126
127 it("keeps normal web links unchanged", async () => {
128 const screen = await render(
129 <ChatMarkdown text="[OpenAI](https://openai.com/docs)" cwd="/repo/project" />,
130 );
131
132 try {
133 const link = page.getByRole("link", { name: "OpenAI" });
134 await expect.element(link).toBeInTheDocument();
135 await expect.element(link).toHaveAttribute("href", "https://openai.com/docs");
136 await expect.element(link).toHaveAttribute("target", "_blank");
137 } finally {
138 await screen.unmount();
139 }
140 });
79});141});
apps/web/src/components/ChatMarkdown.tsx+283-17
...@@ -3,6 +3,7 @@ import { CheckIcon, CopyIcon } from "lucide-react";...@@ -3,6 +3,7 @@ import { CheckIcon, CopyIcon } from "lucide-react";
3import React, {3import React, {
4 Children,4 Children,
5 Suspense,5 Suspense,
6 type MouseEvent as ReactMouseEvent,
6 isValidElement,7 isValidElement,
7 use,8 use,
8 useCallback,9 useCallback,
...@@ -17,13 +18,17 @@ import type { Components } from "react-markdown";...@@ -17,13 +18,17 @@ import type { Components } from "react-markdown";
17import ReactMarkdown from "react-markdown";18import ReactMarkdown from "react-markdown";
18import { defaultUrlTransform } from "react-markdown";19import { defaultUrlTransform } from "react-markdown";
19import remarkGfm from "remark-gfm";20import remarkGfm from "remark-gfm";
21import { VscodeEntryIcon } from "./chat/VscodeEntryIcon";
22import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
23import { toastManager } from "./ui/toast";
20import { openInPreferredEditor } from "../editorPreferences";24import { openInPreferredEditor } from "../editorPreferences";
21import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";25import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
22import { fnv1a32 } from "../lib/diffRendering";26import { fnv1a32 } from "../lib/diffRendering";
23import { LRUCache } from "../lib/lruCache";27import { LRUCache } from "../lib/lruCache";
24import { useTheme } from "../hooks/useTheme";28import { useTheme } from "../hooks/useTheme";
25import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "../markdown-links";29import { resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref } from "../markdown-links";
26import { readLocalApi } from "../localApi";30import { readLocalApi } from "../localApi";
31import { cn } from "../lib/utils";
2732
28class CodeHighlightErrorBoundary extends React.Component<33class CodeHighlightErrorBoundary extends React.Component<
29 { fallback: ReactNode; children: ReactNode },34 { fallback: ReactNode; children: ReactNode },
...@@ -236,34 +241,289 @@ function SuspenseShikiCodeBlock({...@@ -236,34 +241,289 @@ function SuspenseShikiCodeBlock({
236 );241 );
237}242}
238243
244interface MarkdownFileLinkProps {
245 href: string;
246 targetPath: string;
247 displayPath: string;
248 filePath: string;
249 label: string;
250 theme: "light" | "dark";
251 className?: string | undefined;
252}
253
254const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
255const MARKDOWN_FILE_LINK_CLASS_NAME =
256 "chat-markdown-file-link relative top-[2px] max-w-full no-underline";
257const MARKDOWN_FILE_LINK_ICON_CLASS_NAME = "chat-markdown-file-link-icon size-3.5 shrink-0";
258const MARKDOWN_FILE_LINK_LABEL_CLASS_NAME = "chat-markdown-file-link-label truncate";
259
260function pathParentSegments(path: string): string[] {
261 const normalized = path.replaceAll("\\", "/");
262 const segments = normalized.split("/").filter((segment) => segment.length > 0);
263 return segments.slice(0, -1);
264}
265
266function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray<string>): Map<string, string> {
267 const groups = new Map<string, Set<string>>();
268 for (const filePath of filePaths) {
269 const pathSegments = filePath
270 .replaceAll("\\", "/")
271 .split("/")
272 .filter((segment) => segment.length > 0);
273 const basename = pathSegments[pathSegments.length - 1];
274 if (!basename) continue;
275 const group = groups.get(basename) ?? new Set<string>();
276 group.add(filePath);
277 groups.set(basename, group);
278 }
279
280 const suffixByPath = new Map<string, string>();
281 for (const group of groups.values()) {
282 const uniquePaths = [...group];
283 if (uniquePaths.length < 2) continue;
284
285 const parentSegmentsByPath = new Map(
286 uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]),
287 );
288 const minUniqueDepthByPath = new Map<string, number>();
289
290 for (const filePath of uniquePaths) {
291 const segments = parentSegmentsByPath.get(filePath) ?? [];
292 let resolvedDepth = segments.length;
293 for (let depth = 1; depth <= segments.length; depth += 1) {
294 const candidate = segments.slice(-depth).join("/");
295 const collision = uniquePaths.some((otherPath) => {
296 if (otherPath === filePath) return false;
297 const otherSegments = parentSegmentsByPath.get(otherPath) ?? [];
298 return otherSegments.slice(-depth).join("/") === candidate;
299 });
300 if (!collision) {
301 resolvedDepth = depth;
302 break;
303 }
304 }
305 minUniqueDepthByPath.set(filePath, resolvedDepth);
306 }
307
308 for (const filePath of uniquePaths) {
309 const segments = parentSegmentsByPath.get(filePath) ?? [];
310 if (segments.length === 0) continue;
311 const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1;
312 const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2));
313 suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/"));
314 }
315 }
316
317 return suffixByPath;
318}
319
320function extractMarkdownLinkHrefs(text: string): string[] {
321 const hrefs: string[] = [];
322 for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) {
323 const href = match[1]?.trim();
324 if (!href) continue;
325 hrefs.push(href);
326 }
327 return hrefs;
328}
329
330function normalizeMarkdownLinkHrefKey(href: string): string {
331 return rewriteMarkdownFileUriHref(href.trim()) ?? href.trim();
332}
333
334const MarkdownFileLink = memo(function MarkdownFileLink({
335 href,
336 targetPath,
337 displayPath,
338 filePath,
339 label,
340 theme,
341 className,
342}: MarkdownFileLinkProps) {
343 const handleOpen = useCallback(() => {
344 const api = readLocalApi();
345 if (!api) {
346 toastManager.add({
347 type: "error",
348 title: "Open in editor is unavailable",
349 });
350 return;
351 }
352
353 void openInPreferredEditor(api, targetPath).catch((error) => {
354 toastManager.add({
355 type: "error",
356 title: "Unable to open file",
357 description: error instanceof Error ? error.message : "An error occurred.",
358 });
359 });
360 }, [targetPath]);
361
362 const handleCopy = useCallback((value: string, title: string) => {
363 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
364 toastManager.add({
365 type: "error",
366 title: `Failed to copy ${title.toLowerCase()}`,
367 description: "Clipboard API unavailable.",
368 });
369 return;
370 }
371
372 void navigator.clipboard.writeText(value).then(
373 () => {
374 toastManager.add({
375 type: "success",
376 title: `${title} copied`,
377 description: value,
378 });
379 },
380 (error) => {
381 toastManager.add({
382 type: "error",
383 title: `Failed to copy ${title.toLowerCase()}`,
384 description: error instanceof Error ? error.message : "An error occurred.",
385 });
386 },
387 );
388 }, []);
389
390 const handleContextMenu = useCallback(
391 async (event: ReactMouseEvent<HTMLAnchorElement>) => {
392 event.preventDefault();
393 event.stopPropagation();
394
395 const api = readLocalApi();
396 if (!api) return;
397
398 const clicked = await api.contextMenu.show(
399 [
400 { id: "open", label: "Open in editor" },
401 { id: "copy-relative", label: "Copy relative path" },
402 { id: "copy-full", label: "Copy full path" },
403 ] as const,
404 { x: event.clientX, y: event.clientY },
405 );
406
407 if (clicked === "open") {
408 handleOpen();
409 return;
410 }
411 if (clicked === "copy-relative") {
412 handleCopy(displayPath, "Relative path");
413 return;
414 }
415 if (clicked === "copy-full") {
416 handleCopy(targetPath, "Full path");
417 }
418 },
419 [displayPath, handleCopy, handleOpen, targetPath],
420 );
421
422 return (
423 <Tooltip>
424 <TooltipTrigger
425 render={
426 <a
427 href={href}
428 className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, className)}
429 onClick={(event) => {
430 event.preventDefault();
431 event.stopPropagation();
432 handleOpen();
433 }}
434 onContextMenu={handleContextMenu}
435 >
436 <VscodeEntryIcon
437 pathValue={filePath}
438 kind="file"
439 theme={theme}
440 className={cn(MARKDOWN_FILE_LINK_ICON_CLASS_NAME, "text-current")}
441 />
442 <span className={MARKDOWN_FILE_LINK_LABEL_CLASS_NAME}>{label}</span>
443 </a>
444 }
445 />
446 <TooltipPopup
447 side="top"
448 className="max-w-[min(40rem,calc(100vw-2rem))] font-mono text-[11px] leading-tight"
449 >
450 <div className="markdown-file-link-tooltip-scroll overflow-x-auto whitespace-nowrap">
451 {displayPath}
452 </div>
453 </TooltipPopup>
454 </Tooltip>
455 );
456}, areMarkdownFileLinkPropsEqual);
457
458function areMarkdownFileLinkPropsEqual(
459 previous: Readonly<MarkdownFileLinkProps>,
460 next: Readonly<MarkdownFileLinkProps>,
461): boolean {
462 return (
463 previous.href === next.href &&
464 previous.targetPath === next.targetPath &&
465 previous.displayPath === next.displayPath &&
466 previous.filePath === next.filePath &&
467 previous.label === next.label &&
468 previous.theme === next.theme &&
469 previous.className === next.className
470 );
471}
472
239function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {473function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
240 const { resolvedTheme } = useTheme();474 const { resolvedTheme } = useTheme();
241 const diffThemeName = resolveDiffThemeName(resolvedTheme);475 const diffThemeName = resolveDiffThemeName(resolvedTheme);
476 const markdownFileLinkMetaByHref = useMemo(() => {
477 const metaByHref = new Map<
478 string,
479 NonNullable<ReturnType<typeof resolveMarkdownFileLinkMeta>>
480 >();
481 for (const href of extractMarkdownLinkHrefs(text)) {
482 const normalizedHref = normalizeMarkdownLinkHrefKey(href);
483 if (metaByHref.has(normalizedHref)) continue;
484 const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd);
485 if (meta) {
486 metaByHref.set(normalizedHref, meta);
487 }
488 }
489 return metaByHref;
490 }, [cwd, text]);
491 const fileLinkParentSuffixByPath = useMemo(() => {
492 const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath);
493 return buildFileLinkParentSuffixByPath(filePaths);
494 }, [markdownFileLinkMetaByHref]);
242 const markdownUrlTransform = useCallback((href: string) => {495 const markdownUrlTransform = useCallback((href: string) => {
243 return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);496 return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);
244 }, []);497 }, []);
245 const markdownComponents = useMemo<Components>(498 const markdownComponents = useMemo<Components>(
246 () => ({499 () => ({
247 a({ node: _node, href, ...props }) {500 a({ node: _node, href, ...props }) {
248 const targetPath = resolveMarkdownFileLinkTarget(href, cwd);501 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
249 if (!targetPath) {502 const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;
503 if (!fileLinkMeta) {
250 return <a {...props} href={href} target="_blank" rel="noopener noreferrer" />;504 return <a {...props} href={href} target="_blank" rel="noopener noreferrer" />;
251 }505 }
252506
507 const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath);
508 const labelParts = [fileLinkMeta.basename];
509 if (typeof parentSuffix === "string" && parentSuffix.length > 0) {
510 labelParts.push(parentSuffix);
511 }
512 if (fileLinkMeta.line) {
513 labelParts.push(
514 `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`,
515 );
516 }
517
253 return (518 return (
254 <a519 <MarkdownFileLink
255 {...props}520 href={href ?? fileLinkMeta.targetPath}
256 href={href}521 targetPath={fileLinkMeta.targetPath}
257 onClick={(event) => {522 displayPath={fileLinkMeta.displayPath}
258 event.preventDefault();523 filePath={fileLinkMeta.filePath}
259 event.stopPropagation();524 label={labelParts.join(" · ")}
260 const api = readLocalApi();525 theme={resolvedTheme}
261 if (api) {526 className={props.className}
262 void openInPreferredEditor(api, targetPath);
263 } else {
264 console.warn("Native API not found. Unable to open file in editor.");
265 }
266 }}
267 />527 />
268 );528 );
269 },529 },
...@@ -289,7 +549,13 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {...@@ -289,7 +549,13 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
289 );549 );
290 },550 },
291 }),551 }),
292 [cwd, diffThemeName, isStreaming],552 [
553 diffThemeName,
554 fileLinkParentSuffixByPath,
555 isStreaming,
556 markdownFileLinkMetaByHref,
557 resolvedTheme,
558 ],
293 );559 );
294560
295 return (561 return (
apps/web/src/components/chat/MessagesTimeline.test.tsx created+189
...@@ -0,0 +1,189 @@
1import { EnvironmentId, MessageId } from "@t3tools/contracts";
2import { createRef } from "react";
3import { renderToStaticMarkup } from "react-dom/server";
4import { beforeAll, describe, expect, it, vi } from "vitest";
5import type { LegendListRef } from "@legendapp/list/react";
6
7vi.mock("@legendapp/list/react", async () => {
8 const React = await import("react");
9
10 const LegendList = React.forwardRef(function MockLegendList(
11 props: {
12 data: Array<{ id: string }>;
13 keyExtractor: (item: { id: string }) => string;
14 renderItem: (args: { item: { id: string } }) => React.ReactNode;
15 ListHeaderComponent?: React.ReactNode;
16 ListFooterComponent?: React.ReactNode;
17 },
18 _ref: React.ForwardedRef<LegendListRef>,
19 ) {
20 return (
21 <div data-testid="legend-list">
22 {props.ListHeaderComponent}
23 {props.data.map((item) => (
24 <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div>
25 ))}
26 {props.ListFooterComponent}
27 </div>
28 );
29 });
30
31 return { LegendList };
32});
33
34function matchMedia() {
35 return {
36 matches: false,
37 addEventListener: () => {},
38 removeEventListener: () => {},
39 };
40}
41
42beforeAll(() => {
43 const classList = {
44 add: () => {},
45 remove: () => {},
46 toggle: () => {},
47 contains: () => false,
48 };
49
50 vi.stubGlobal("localStorage", {
51 getItem: () => null,
52 setItem: () => {},
53 removeItem: () => {},
54 clear: () => {},
55 });
56 vi.stubGlobal("window", {
57 matchMedia,
58 addEventListener: () => {},
59 removeEventListener: () => {},
60 requestAnimationFrame: (callback: FrameRequestCallback) => {
61 callback(0);
62 return 0;
63 },
64 cancelAnimationFrame: () => {},
65 desktopBridge: undefined,
66 });
67 vi.stubGlobal("document", {
68 documentElement: {
69 classList,
70 offsetHeight: 0,
71 },
72 });
73});
74
75const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
76
77function buildProps() {
78 return {
79 isWorking: false,
80 activeTurnInProgress: false,
81 activeTurnId: null,
82 activeTurnStartedAt: null,
83 listRef: createRef<LegendListRef | null>(),
84 completionDividerBeforeEntryId: null,
85 completionSummary: null,
86 turnDiffSummaryByAssistantMessageId: new Map(),
87 routeThreadKey: "environment-local:thread-1",
88 onOpenTurnDiff: () => {},
89 revertTurnCountByUserMessageId: new Map(),
90 onRevertUserMessage: () => {},
91 isRevertingCheckpoint: false,
92 onImageExpand: () => {},
93 activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID,
94 markdownCwd: undefined,
95 resolvedTheme: "light" as const,
96 timestampFormat: "locale" as const,
97 workspaceRoot: undefined,
98 onIsAtEndChange: () => {},
99 };
100}
101
102describe("MessagesTimeline", () => {
103 it("renders inline terminal labels with the composer chip UI", async () => {
104 const { MessagesTimeline } = await import("./MessagesTimeline");
105 const markup = renderToStaticMarkup(
106 <MessagesTimeline
107 {...buildProps()}
108 timelineEntries={[
109 {
110 id: "entry-1",
111 kind: "message",
112 createdAt: "2026-03-17T19:12:28.000Z",
113 message: {
114 id: MessageId.make("message-2"),
115 role: "user",
116 text: [
117 "yoo what's @terminal-1:1-5 mean",
118 "",
119 "<terminal_context>",
120 "- Terminal 1 lines 1-5:",
121 " 1 | julius@mac effect-http-ws-cli % bun i",
122 " 2 | bun install v1.3.9 (cf6cdbbb)",
123 "</terminal_context>",
124 ].join("\n"),
125 createdAt: "2026-03-17T19:12:28.000Z",
126 streaming: false,
127 },
128 },
129 ]}
130 />,
131 );
132
133 expect(markup).toContain("Terminal 1 lines 1-5");
134 expect(markup).toContain("lucide-terminal");
135 expect(markup).toContain("yoo what&#x27;s ");
136 }, 20_000);
137
138 it("renders context compaction entries in the normal work log", async () => {
139 const { MessagesTimeline } = await import("./MessagesTimeline");
140 const markup = renderToStaticMarkup(
141 <MessagesTimeline
142 {...buildProps()}
143 timelineEntries={[
144 {
145 id: "entry-1",
146 kind: "work",
147 createdAt: "2026-03-17T19:12:28.000Z",
148 entry: {
149 id: "work-1",
150 createdAt: "2026-03-17T19:12:28.000Z",
151 label: "Context compacted",
152 tone: "info",
153 },
154 },
155 ]}
156 />,
157 );
158
159 expect(markup).toContain("Context compacted");
160 expect(markup).toContain("Work log");
161 });
162
163 it("formats changed file paths from the workspace root", async () => {
164 const { MessagesTimeline } = await import("./MessagesTimeline");
165 const markup = renderToStaticMarkup(
166 <MessagesTimeline
167 {...buildProps()}
168 timelineEntries={[
169 {
170 id: "entry-1",
171 kind: "work",
172 createdAt: "2026-03-17T19:12:28.000Z",
173 entry: {
174 id: "work-1",
175 createdAt: "2026-03-17T19:12:28.000Z",
176 label: "Updated files",
177 tone: "tool",
178 changedFiles: ["C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"],
179 },
180 },
181 ]}
182 workspaceRoot="C:/Users/mike/dev-stuff/t3code"
183 />,
184 );
185
186 expect(markup).toContain("t3code/apps/web/src/session-logic.ts");
187 expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts");
188 });
189});
apps/web/src/components/chat/MessagesTimeline.tsx+26-14
...@@ -60,6 +60,7 @@ import {...@@ -60,6 +60,7 @@ import {
60 formatInlineTerminalContextLabel,60 formatInlineTerminalContextLabel,
61 textContainsInlineTerminalContextLabels,61 textContainsInlineTerminalContextLabels,
62} from "./userMessageTerminalContexts";62} from "./userMessageTerminalContexts";
63import { formatWorkspaceRelativePath } from "../../filePathDisplay";
6364
64// ---------------------------------------------------------------------------65// ---------------------------------------------------------------------------
65// Context — shared state consumed by every row component via useContext.66// Context — shared state consumed by every row component via useContext.
...@@ -528,6 +529,7 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -528,6 +529,7 @@ const WorkGroupSection = memo(function WorkGroupSection({
528}: {529}: {
529 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];530 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
530}) {531}) {
532 const { workspaceRoot } = use(TimelineRowCtx);
531 const [isExpanded, setIsExpanded] = useState(false);533 const [isExpanded, setIsExpanded] = useState(false);
532 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;534 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
533 const visibleEntries =535 const visibleEntries =
...@@ -559,7 +561,11 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -559,7 +561,11 @@ const WorkGroupSection = memo(function WorkGroupSection({
559 )}561 )}
560 <div className="space-y-0.5">562 <div className="space-y-0.5">
561 {visibleEntries.map((workEntry) => (563 {visibleEntries.map((workEntry) => (
562 <SimpleWorkEntryRow key={`work-row:${workEntry.id}`} workEntry={workEntry} />564 <SimpleWorkEntryRow
565 key={`work-row:${workEntry.id}`}
566 workEntry={workEntry}
567 workspaceRoot={workspaceRoot}
568 />
563 ))}569 ))}
564 </div>570 </div>
565 </div>571 </div>
...@@ -861,15 +867,17 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {...@@ -861,15 +867,17 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
861867
862function workEntryPreview(868function workEntryPreview(
863 workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">,869 workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">,
870 workspaceRoot: string | undefined,
864) {871) {
865 if (workEntry.command) return workEntry.command;872 if (workEntry.command) return workEntry.command;
866 if (workEntry.detail) return workEntry.detail;873 if (workEntry.detail) return workEntry.detail;
867 if ((workEntry.changedFiles?.length ?? 0) === 0) return null;874 if ((workEntry.changedFiles?.length ?? 0) === 0) return null;
868 const [firstPath] = workEntry.changedFiles ?? [];875 const [firstPath] = workEntry.changedFiles ?? [];
869 if (!firstPath) return null;876 if (!firstPath) return null;
877 const displayPath = formatWorkspaceRelativePath(firstPath, workspaceRoot);
870 return workEntry.changedFiles!.length === 1878 return workEntry.changedFiles!.length === 1
871 ? firstPath879 ? displayPath
872 : `${firstPath} +${workEntry.changedFiles!.length - 1} more`;880 : `${displayPath} +${workEntry.changedFiles!.length - 1} more`;
873}881}
874882
875function workEntryRawCommand(883function workEntryRawCommand(
...@@ -924,12 +932,13 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string {...@@ -924,12 +932,13 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string {
924932
925const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {933const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
926 workEntry: TimelineWorkEntry;934 workEntry: TimelineWorkEntry;
935 workspaceRoot: string | undefined;
927}) {936}) {
928 const { workEntry } = props;937 const { workEntry, workspaceRoot } = props;
929 const iconConfig = workToneIcon(workEntry.tone);938 const iconConfig = workToneIcon(workEntry.tone);
930 const EntryIcon = workEntryIcon(workEntry);939 const EntryIcon = workEntryIcon(workEntry);
931 const heading = toolWorkEntryHeading(workEntry);940 const heading = toolWorkEntryHeading(workEntry);
932 const preview = workEntryPreview(workEntry);941 const preview = workEntryPreview(workEntry, workspaceRoot);
933 const rawCommand = workEntryRawCommand(workEntry);942 const rawCommand = workEntryRawCommand(workEntry);
934 const displayText = preview ? `${heading} - ${preview}` : heading;943 const displayText = preview ? `${heading} - ${preview}` : heading;
935 const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0;944 const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0;
...@@ -988,15 +997,18 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {...@@ -988,15 +997,18 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
988 </div>997 </div>
989 {hasChangedFiles && !previewIsChangedFiles && (998 {hasChangedFiles && !previewIsChangedFiles && (
990 <div className="mt-1 flex flex-wrap gap-1 pl-6">999 <div className="mt-1 flex flex-wrap gap-1 pl-6">
991 {workEntry.changedFiles?.slice(0, 4).map((filePath) => (1000 {workEntry.changedFiles?.slice(0, 4).map((filePath) => {
992 <span1001 const displayPath = formatWorkspaceRelativePath(filePath, workspaceRoot);
993 key={`${workEntry.id}:${filePath}`}1002 return (
994 className="rounded-md border border-border/55 bg-background/75 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground/75"1003 <span
995 title={filePath}1004 key={`${workEntry.id}:${filePath}`}
996 >1005 className="rounded-md border border-border/55 bg-background/75 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground/75"
997 {filePath}1006 title={displayPath}
998 </span>1007 >
999 ))}1008 {displayPath}
1009 </span>
1010 );
1011 })}
1000 {(workEntry.changedFiles?.length ?? 0) > 4 && (1012 {(workEntry.changedFiles?.length ?? 0) > 4 && (
1001 <span className="px-1 text-[10px] text-muted-foreground/55">1013 <span className="px-1 text-[10px] text-muted-foreground/55">
1002 +{(workEntry.changedFiles?.length ?? 0) - 4}1014 +{(workEntry.changedFiles?.length ?? 0) - 4}
apps/web/src/filePathDisplay.test.ts created+41
...@@ -0,0 +1,41 @@
1import { describe, expect, it } from "vitest";
2
3import { formatWorkspaceRelativePath } from "./filePathDisplay";
4
5describe("formatWorkspaceRelativePath", () => {
6 it("formats absolute workspace paths from the workspace root", () => {
7 expect(
8 formatWorkspaceRelativePath(
9 "C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts:501",
10 "C:/Users/mike/dev-stuff/t3code",
11 ),
12 ).toBe("t3code/apps/web/src/session-logic.ts:501");
13 });
14
15 it("prefixes relative paths with the workspace root label", () => {
16 expect(
17 formatWorkspaceRelativePath(
18 "apps/web/src/session-logic.ts:501",
19 "C:/Users/mike/dev-stuff/t3code",
20 ),
21 ).toBe("t3code/apps/web/src/session-logic.ts:501");
22 });
23
24 it("keeps paths already rooted at the workspace label stable", () => {
25 expect(
26 formatWorkspaceRelativePath(
27 "t3code/apps/web/src/session-logic.ts:501",
28 "C:/Users/mike/dev-stuff/t3code",
29 ),
30 ).toBe("t3code/apps/web/src/session-logic.ts:501");
31 });
32
33 it("preserves columns when present", () => {
34 expect(
35 formatWorkspaceRelativePath(
36 "/C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts:501:9",
37 "C:/Users/mike/dev-stuff/t3code",
38 ),
39 ).toBe("t3code/apps/web/src/session-logic.ts:501:9");
40 });
41});
apps/web/src/filePathDisplay.ts created+57
...@@ -0,0 +1,57 @@
1import { splitPathAndPosition } from "./terminal-links";
2
3function normalizePathSeparators(path: string): string {
4 return path.replaceAll("\\", "/");
5}
6
7function canonicalizeWindowsDrivePath(path: string): string {
8 return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path;
9}
10
11function trimTrailingPathSeparators(path: string): string {
12 return path.replace(/[\\/]+$/, "");
13}
14
15function basenameOfPath(path: string): string {
16 const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
17 return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;
18}
19
20function stripRelativePrefixes(path: string): string {
21 return path.replace(/^\.\/+/, "").replace(/^\/+/, "");
22}
23
24export function formatWorkspaceRelativePath(
25 pathWithPosition: string,
26 workspaceRoot: string | undefined,
27): string {
28 const { path, line, column } = splitPathAndPosition(pathWithPosition);
29 const normalizedPath = canonicalizeWindowsDrivePath(normalizePathSeparators(path));
30
31 let displayPath = normalizedPath;
32 if (workspaceRoot) {
33 const normalizedWorkspaceRoot = canonicalizeWindowsDrivePath(
34 normalizePathSeparators(trimTrailingPathSeparators(workspaceRoot)),
35 );
36 const workspaceLabel = basenameOfPath(normalizedWorkspaceRoot);
37 const pathForCompare = normalizedPath.toLowerCase();
38 const workspaceForCompare = normalizedWorkspaceRoot.toLowerCase();
39 const workspaceWithSeparator = `${workspaceForCompare}/`;
40 const workspaceLabelWithSeparator = `${workspaceLabel.toLowerCase()}/`;
41
42 if (pathForCompare === workspaceForCompare) {
43 displayPath = workspaceLabel;
44 } else if (pathForCompare.startsWith(workspaceWithSeparator)) {
45 const relativeSuffix = normalizedPath.slice(normalizedWorkspaceRoot.length + 1);
46 displayPath = `${workspaceLabel}/${relativeSuffix}`;
47 } else if (!normalizedPath.startsWith("/")) {
48 const relativePath = stripRelativePrefixes(normalizedPath);
49 displayPath = pathForCompare.startsWith(workspaceLabelWithSeparator)
50 ? normalizedPath
51 : `${workspaceLabel}/${relativePath}`;
52 }
53 }
54
55 if (!line) return displayPath;
56 return `${displayPath}:${line}${column ? `:${column}` : ""}`;
57}
apps/web/src/index.css+59
...@@ -324,6 +324,47 @@ label:has(> select#reasoning-effort) select {...@@ -324,6 +324,47 @@ label:has(> select#reasoning-effort) select {
324 font-size: 0.75rem;324 font-size: 0.75rem;
325}325}
326326
327.chat-markdown-file-link {
328 display: inline-flex;
329 align-items: center;
330 gap: 0.28rem;
331 border: 1px solid color-mix(in srgb, var(--border) 92%, transparent);
332 border-radius: 0.375rem;
333 background: color-mix(in srgb, var(--muted) 88%, var(--background));
334 padding: 0.08rem 0.34rem;
335 color: var(--foreground);
336 font-family: var(--font-mono, ui-monospace, monospace);
337 font-size: 0.75rem;
338 line-height: 1.15;
339 vertical-align: text-bottom;
340 transition:
341 background-color 120ms ease,
342 border-color 120ms ease,
343 color 120ms ease,
344 opacity 120ms ease;
345}
346
347.chat-markdown-file-link:hover {
348 opacity: 1;
349 color: var(--foreground);
350 border-color: color-mix(in srgb, var(--border) 65%, var(--foreground));
351 background: color-mix(in srgb, var(--muted) 72%, var(--background));
352}
353
354.chat-markdown-file-link:focus-visible {
355 outline: none;
356 box-shadow: 0 0 0 1px color-mix(in srgb, var(--ring) 70%, transparent);
357}
358
359.chat-markdown-file-link-icon {
360 opacity: 0.72;
361}
362
363.chat-markdown-file-link-label {
364 color: color-mix(in srgb, var(--foreground) 88%, transparent);
365 line-height: 1.1;
366}
367
327.chat-markdown pre {368.chat-markdown pre {
328 max-width: 100%;369 max-width: 100%;
329 overflow-x: auto;370 overflow-x: auto;
...@@ -340,6 +381,24 @@ label:has(> select#reasoning-effort) select {...@@ -340,6 +381,24 @@ label:has(> select#reasoning-effort) select {
340 font-size: 0.75rem;381 font-size: 0.75rem;
341}382}
342383
384.markdown-file-link-tooltip-scroll {
385 scrollbar-width: thin;
386 scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent;
387}
388
389.markdown-file-link-tooltip-scroll::-webkit-scrollbar {
390 height: 6px;
391}
392
393.markdown-file-link-tooltip-scroll::-webkit-scrollbar-track {
394 background: transparent;
395}
396
397.markdown-file-link-tooltip-scroll::-webkit-scrollbar-thumb {
398 border-radius: 999px;
399 background: color-mix(in srgb, var(--border) 78%, transparent);
400}
401
343.chat-markdown .chat-markdown-codeblock {402.chat-markdown .chat-markdown-codeblock {
344 --chat-markdown-codeblock-copy-button-space: 1.5rem;403 --chat-markdown-codeblock-copy-button-space: 1.5rem;
345 position: relative;404 position: relative;
apps/web/src/markdown-links.test.ts+28-1
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "./markdown-links";3import {
4 resolveMarkdownFileLinkMeta,
5 resolveMarkdownFileLinkTarget,
6 rewriteMarkdownFileUriHref,
7} from "./markdown-links";
48
5describe("rewriteMarkdownFileUriHref", () => {9describe("rewriteMarkdownFileUriHref", () => {
6 it("rewrites file uri hrefs into direct path hrefs", () => {10 it("rewrites file uri hrefs into direct path hrefs", () => {
...@@ -57,6 +61,29 @@ describe("resolveMarkdownFileLinkTarget", () => {...@@ -57,6 +61,29 @@ describe("resolveMarkdownFileLinkTarget", () => {
57 );61 );
58 });62 });
5963
64 it("formats tooltip display paths relative to the cwd when possible", () => {
65 expect(
66 resolveMarkdownFileLinkMeta(
67 "file:///C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts#L501",
68 "C:/Users/mike/dev-stuff/t3code",
69 ),
70 ).toMatchObject({
71 displayPath: "t3code/apps/web/src/session-logic.ts:501",
72 });
73 });
74
75 it("formats tooltip display paths relative to the cwd for slash-prefixed windows paths", () => {
76 expect(
77 resolveMarkdownFileLinkMeta(
78 "/C:/Users/mike/dev-stuff/t3code/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx",
79 "C:/Users/mike/dev-stuff/t3code",
80 ),
81 ).toMatchObject({
82 displayPath:
83 "t3code/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx",
84 });
85 });
86
60 it("does not treat app routes as file links", () => {87 it("does not treat app routes as file links", () => {
61 expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull();88 expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull();
62 });89 });
apps/web/src/markdown-links.ts+39-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { resolvePathLinkTarget } from "./terminal-links";1import { formatWorkspaceRelativePath } from "./filePathDisplay";
2import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links";
23
3const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;4const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
4const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;5const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
...@@ -21,6 +22,15 @@ const POSIX_FILE_ROOT_PREFIXES = [...@@ -21,6 +22,15 @@ const POSIX_FILE_ROOT_PREFIXES = [
21 "/root/",22 "/root/",
22] as const;23] as const;
2324
25export interface MarkdownFileLinkMeta {
26 filePath: string;
27 targetPath: string;
28 displayPath: string;
29 basename: string;
30 line?: number;
31 column?: number;
32}
33
24function safeDecode(value: string): string {34function safeDecode(value: string): string {
25 try {35 try {
26 return decodeURIComponent(value);36 return decodeURIComponent(value);
...@@ -143,3 +153,31 @@ export function resolveMarkdownFileLinkTarget(...@@ -143,3 +153,31 @@ export function resolveMarkdownFileLinkTarget(
143 if (!cwd) return null;153 if (!cwd) return null;
144 return resolvePathLinkTarget(pathWithPosition, cwd);154 return resolvePathLinkTarget(pathWithPosition, cwd);
145}155}
156
157function basenameOfPath(path: string): string {
158 const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
159 return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;
160}
161
162export function resolveMarkdownFileLinkMeta(
163 href: string | undefined,
164 cwd?: string,
165): MarkdownFileLinkMeta | null {
166 const targetPath = resolveMarkdownFileLinkTarget(href, cwd);
167 if (!targetPath) return null;
168
169 const { path, line, column } = splitPathAndPosition(targetPath);
170 const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN;
171 const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN;
172 const lineNumber = Number.isFinite(parsedLine) ? parsedLine : undefined;
173 const columnNumber = Number.isFinite(parsedColumn) ? parsedColumn : undefined;
174
175 return {
176 filePath: path,
177 targetPath,
178 displayPath: formatWorkspaceRelativePath(targetPath, cwd),
179 basename: basenameOfPath(path),
180 ...(lineNumber !== undefined ? { line: lineNumber } : {}),
181 ...(columnNumber !== undefined ? { column: columnNumber } : {}),
182 };
183}
apps/web/src/terminal-links.ts+1-1
...@@ -137,7 +137,7 @@ function inferHomeFromCwd(cwd: string): string | undefined {...@@ -137,7 +137,7 @@ function inferHomeFromCwd(cwd: string): string | undefined {
137 return undefined;137 return undefined;
138}138}
139139
140function splitPathAndPosition(value: string): {140export function splitPathAndPosition(value: string): {
141 path: string;141 path: string;
142 line: string | undefined;142 line: string | undefined;
143 column: string | undefined;143 column: string | undefined;