| author | |
| committer | |
| log | 68061af0420ba6acd9c1a4c2f77eeddfff0e8bd2 |
| tree | a884d236fa31de1d9505554ed42b4c1fbc806caf |
| parent | 28cb9db20bc78fdb5a767ff9661e500076938fbe |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 64 | 64 | ||
| 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`); |
| 69 | 69 | ||
| ... | @@ -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"; |
| 3 | import React, { | 3 | import 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"; |
| 17 | import ReactMarkdown from "react-markdown"; | 18 | import ReactMarkdown from "react-markdown"; |
| 18 | import { defaultUrlTransform } from "react-markdown"; | 19 | import { defaultUrlTransform } from "react-markdown"; |
| 19 | import remarkGfm from "remark-gfm"; | 20 | import remarkGfm from "remark-gfm"; |
| 21 | import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; | ||
| 22 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | ||
| 23 | import { toastManager } from "./ui/toast"; | ||
| 20 | import { openInPreferredEditor } from "../editorPreferences"; | 24 | import { openInPreferredEditor } from "../editorPreferences"; |
| 21 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; | 25 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 22 | import { fnv1a32 } from "../lib/diffRendering"; | 26 | import { fnv1a32 } from "../lib/diffRendering"; |
| 23 | import { LRUCache } from "../lib/lruCache"; | 27 | import { LRUCache } from "../lib/lruCache"; |
| 24 | import { useTheme } from "../hooks/useTheme"; | 28 | import { useTheme } from "../hooks/useTheme"; |
| 25 | import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "../markdown-links"; | 29 | import { resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref } from "../markdown-links"; |
| 26 | import { readLocalApi } from "../localApi"; | 30 | import { readLocalApi } from "../localApi"; |
| 31 | import { cn } from "../lib/utils"; | ||
| 27 | 32 | ||
| 28 | class CodeHighlightErrorBoundary extends React.Component< | 33 | class 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 | } |
| 238 | 243 | ||
| 244 | interface 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 | |||
| 254 | const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; | ||
| 255 | const MARKDOWN_FILE_LINK_CLASS_NAME = | ||
| 256 | "chat-markdown-file-link relative top-[2px] max-w-full no-underline"; | ||
| 257 | const MARKDOWN_FILE_LINK_ICON_CLASS_NAME = "chat-markdown-file-link-icon size-3.5 shrink-0"; | ||
| 258 | const MARKDOWN_FILE_LINK_LABEL_CLASS_NAME = "chat-markdown-file-link-label truncate"; | ||
| 259 | |||
| 260 | function 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 | |||
| 266 | function 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 | |||
| 320 | function 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 | |||
| 330 | function normalizeMarkdownLinkHrefKey(href: string): string { | ||
| 331 | return rewriteMarkdownFileUriHref(href.trim()) ?? href.trim(); | ||
| 332 | } | ||
| 333 | |||
| 334 | const 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 | |||
| 458 | function 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 | |||
| 239 | function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { | 473 | function 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 | } |
| 252 | 506 | ||
| 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 | <a | 519 | <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 | ); |
| 294 | 560 | ||
| 295 | return ( | 561 | return ( |
apps/web/src/components/chat/MessagesTimeline.test.tsx created+189| ... | @@ -0,0 +1,189 @@ | ||
| 1 | import { EnvironmentId, MessageId } from "@t3tools/contracts"; | ||
| 2 | import { createRef } from "react"; | ||
| 3 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 4 | import { beforeAll, describe, expect, it, vi } from "vitest"; | ||
| 5 | import type { LegendListRef } from "@legendapp/list/react"; | ||
| 6 | |||
| 7 | vi.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 | |||
| 34 | function matchMedia() { | ||
| 35 | return { | ||
| 36 | matches: false, | ||
| 37 | addEventListener: () => {}, | ||
| 38 | removeEventListener: () => {}, | ||
| 39 | }; | ||
| 40 | } | ||
| 41 | |||
| 42 | beforeAll(() => { | ||
| 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 | |||
| 75 | const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); | ||
| 76 | |||
| 77 | function 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 | |||
| 102 | describe("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"; |
| 63 | import { formatWorkspaceRelativePath } from "../../filePathDisplay"; | ||
| 63 | 64 | ||
| 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 { |
| 861 | 867 | ||
| 862 | function workEntryPreview( | 868 | function 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 === 1 | 878 | return workEntry.changedFiles!.length === 1 |
| 871 | ? firstPath | 879 | ? displayPath |
| 872 | : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; | 880 | : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; |
| 873 | } | 881 | } |
| 874 | 882 | ||
| 875 | function workEntryRawCommand( | 883 | function workEntryRawCommand( |
| ... | @@ -924,12 +932,13 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { | ... | @@ -924,12 +932,13 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { |
| 924 | 932 | ||
| 925 | const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { | 933 | const 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 | <span | 1001 | 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 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { formatWorkspaceRelativePath } from "./filePathDisplay"; | ||
| 4 | |||
| 5 | describe("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 @@ | ||
| 1 | import { splitPathAndPosition } from "./terminal-links"; | ||
| 2 | |||
| 3 | function normalizePathSeparators(path: string): string { | ||
| 4 | return path.replaceAll("\\", "/"); | ||
| 5 | } | ||
| 6 | |||
| 7 | function canonicalizeWindowsDrivePath(path: string): string { | ||
| 8 | return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path; | ||
| 9 | } | ||
| 10 | |||
| 11 | function trimTrailingPathSeparators(path: string): string { | ||
| 12 | return path.replace(/[\\/]+$/, ""); | ||
| 13 | } | ||
| 14 | |||
| 15 | function basenameOfPath(path: string): string { | ||
| 16 | const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); | ||
| 17 | return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; | ||
| 18 | } | ||
| 19 | |||
| 20 | function stripRelativePrefixes(path: string): string { | ||
| 21 | return path.replace(/^\.\/+/, "").replace(/^\/+/, ""); | ||
| 22 | } | ||
| 23 | |||
| 24 | export 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 | } |
| 326 | 326 | ||
| 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 | } |
| 342 | 383 | ||
| 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 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "./markdown-links"; | 3 | import { |
| 4 | resolveMarkdownFileLinkMeta, | ||
| 5 | resolveMarkdownFileLinkTarget, | ||
| 6 | rewriteMarkdownFileUriHref, | ||
| 7 | } from "./markdown-links"; | ||
| 4 | 8 | ||
| 5 | describe("rewriteMarkdownFileUriHref", () => { | 9 | describe("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 | }); |
| 59 | 63 | ||
| 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 @@ |
| 1 | import { resolvePathLinkTarget } from "./terminal-links"; | 1 | import { formatWorkspaceRelativePath } from "./filePathDisplay"; |
| 2 | import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links"; | ||
| 2 | 3 | ||
| 3 | const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; | 4 | const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; |
| 4 | const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; | 5 | const 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; |
| 23 | 24 | ||
| 25 | export interface MarkdownFileLinkMeta { | ||
| 26 | filePath: string; | ||
| 27 | targetPath: string; | ||
| 28 | displayPath: string; | ||
| 29 | basename: string; | ||
| 30 | line?: number; | ||
| 31 | column?: number; | ||
| 32 | } | ||
| 33 | |||
| 24 | function safeDecode(value: string): string { | 34 | function 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 | |||
| 157 | function basenameOfPath(path: string): string { | ||
| 158 | const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); | ||
| 159 | return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; | ||
| 160 | } | ||
| 161 | |||
| 162 | export 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 | } |
| 139 | 139 | ||
| 140 | function splitPathAndPosition(value: string): { | 140 | export 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; |