From 7a4ec5c6716f7eafdc25c61f791106ac3a5e82ab Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 1 Jul 2026 03:03:18 -0700 Subject: [PATCH] Open file links from native chat messages (#6998) * Resolve and open file links from native chat messages - Add `onLinkClick` and `allowFileUriLinks` props to `CommentMarkdown` to support intercepting links and enabling file URI navigation. - Resolve relative paths, line/column suffixes, and file URIs from native chat messages against the active worktree context. - Handle clicked links in `NativeChatView` by opening the target file in the editor (or system default via Shift+click). - Refactor markdown renderers and parse utilities into standalone, reusable modules to share between terminal and chat components. * Fix file link resolution and click event propagation Ensure file links open correctly by falling back to repo-scoped worktrees if a known worktree lacks a path. Also, stop propagation on markdown anchor clicks to prevent triggering outer card click handlers. --- .../native-chat/NativeChatMessageList.tsx | 34 +- .../components/native-chat/NativeChatView.tsx | 29 ++ .../native-chat/native-chat-file-link.test.ts | 146 +++++++++ .../native-chat/native-chat-file-link.ts | 190 +++++++++++ .../CommentMarkdown.link-click.test.tsx | 149 +++++++++ .../components/sidebar/CommentMarkdown.tsx | 310 +++--------------- .../comment-markdown-element-renderers.tsx | 264 +++++++++++++++ ...mment-markdown-github-attachment-video.tsx | 69 ++++ .../src/lib/explicit-file-link-target.ts | 91 +++++ src/renderer/src/lib/terminal-links.ts | 60 +--- 10 files changed, 1015 insertions(+), 327 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-file-link.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-file-link.ts create mode 100644 src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx create mode 100644 src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx create mode 100644 src/renderer/src/components/sidebar/comment-markdown-github-attachment-video.tsx create mode 100644 src/renderer/src/lib/explicit-file-link-target.ts diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index edd8df21dc6..b447a4287c2 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { ArrowDown, ArrowUp, Image as ImageIcon } from 'lucide-react' -import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import CommentMarkdown, { + type CommentMarkdownLinkClickHandler +} from '@/components/sidebar/CommentMarkdown' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { basename } from '@/lib/path' @@ -123,12 +125,16 @@ function TypingIndicatorRow(): React.JSX.Element { function MessageRow({ message, expandSignal, - onScrollMessageToTop + onScrollMessageToTop, + onLinkClick, + allowFileUriLinks = false }: { message: NativeChatMessage expandSignal: boolean /** Align this message's top to the top of the scroll viewport. */ onScrollMessageToTop: (el: HTMLElement) => void + onLinkClick?: CommentMarkdownLinkClickHandler + allowFileUriLinks?: boolean }): React.JSX.Element | null { const rowRef = useRef(null) const { prose, tools } = useMemo(() => splitNativeChatBlocks(message.blocks), [message.blocks]) @@ -163,7 +169,13 @@ function MessageRow({ {markdown ? ( <> - + ) : ( @@ -196,7 +208,13 @@ function MessageRow({ ) : null} {markdown ? ( - + ) : null} {tools.length > 0 ? : null} @@ -207,7 +225,9 @@ export function NativeChatMessageList({ session, isWorking, expandSignal, - fontScale + fontScale, + onLinkClick, + allowFileUriLinks = false }: { session: NativeChatLiveSession isWorking: boolean @@ -215,6 +235,8 @@ export function NativeChatMessageList({ expandSignal: boolean /** Chat-only text multiplier (1 = default), driven by the zoom shortcuts. */ fontScale: number + onLinkClick?: CommentMarkdownLinkClickHandler + allowFileUriLinks?: boolean }): React.JSX.Element { const scrollRef = useRef(null) const [stuckToBottom, setStuckToBottom] = useState(true) @@ -346,6 +368,8 @@ export function NativeChatMessageList({ message={message} expandSignal={expandSignal} onScrollMessageToTop={scrollMessageToTop} + onLinkClick={onLinkClick} + allowFileUriLinks={allowFileUriLinks} /> ))} {showTypingIndicator ? : null} diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 0bb188831bb..ba8f877c43a 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -43,6 +43,12 @@ import { } from './native-chat-typing-redirect' import { useNativeChatContextMenu } from './use-native-chat-context-menu' import type { NativeChatContextMenuActions } from './use-native-chat-context-menu' +import { + resolveNativeChatFileLink, + resolveNativeChatFileLinkContext +} from './native-chat-file-link' +import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing' +import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' const emptyNativeChatContextMenuActions: Omit = { onSplitRight: () => {}, @@ -164,6 +170,9 @@ function NativeChatResolvedView({ const [workingInterrupted, setWorkingInterrupted] = useState(false) const rootRef = useRef(null) const composerRef = useRef(null) + const fileLinkContext = useAppStore( + useShallow((s) => resolveNativeChatFileLinkContext(s, terminalTabId)) + ) // Delegate to the composer so a pane-level Cmd/Ctrl+V (or context-menu / // app-menu paste) attaches a clipboard image when present, falling back to // text — matching the textarea's own paste behavior and the hosted TUI. @@ -338,6 +347,24 @@ function NativeChatResolvedView({ setWorkingInterrupted(true) interactiveSend.cancel() }, [interactiveSend]) + const openNativeChatFileLink = useCallback( + (event, href) => { + const target = resolveNativeChatFileLink(href, fileLinkContext) + if (!target || !fileLinkContext) { + return + } + event.preventDefault() + event.stopPropagation() + openDetectedFilePath(target.absolutePath, target.line, target.column, { + worktreeId: fileLinkContext.worktreeId, + worktreePath: fileLinkContext.worktreePath, + runtimeEnvironmentId: fileLinkContext.runtimeEnvironmentId, + openWithSystemDefault: event.shiftKey + }) + }, + [fileLinkContext] + ) + const nativeChatFileLinkClick = fileLinkContext ? openNativeChatFileLink : undefined // Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so // the chord is inert on the loading/empty/error states and elsewhere. @@ -393,6 +420,8 @@ function NativeChatResolvedView({ isWorking={isWorking} expandSignal={false} fontScale={fontScale.scale} + onLinkClick={nativeChatFileLinkClick} + allowFileUriLinks={fileLinkContext !== null} /> )} diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.test.ts b/src/renderer/src/components/native-chat/native-chat-file-link.test.ts new file mode 100644 index 00000000000..1022d8e8441 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-file-link.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalTab } from '../../../../shared/types' +import type { AppState } from '@/store/types' +import { + resolveNativeChatFileLink, + resolveNativeChatFileLinkContext, + type NativeChatFileLinkContext +} from './native-chat-file-link' + +function terminalTab(overrides: Partial = {}): TerminalTab { + return { + id: 'tab-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...overrides + } +} + +function state(overrides: Partial = {}): AppState { + return { + folderWorkspaces: [], + getKnownWorktreeById: (worktreeId: string) => + worktreeId === 'wt-1' ? ({ id: 'wt-1', path: '/repo/worktree' } as never) : undefined, + projectGroups: [], + repos: [], + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { + 'wt-1': [terminalTab()] + }, + worktreesByRepo: { + repo: [{ id: 'wt-1', repoId: 'repo', path: '/repo/worktree' } as never] + }, + ...overrides + } as AppState +} + +const context: NativeChatFileLinkContext = { + worktreeId: 'wt-1', + worktreePath: '/repo/worktree', + runtimeEnvironmentId: null +} + +describe('resolveNativeChatFileLinkContext', () => { + it('returns the owner worktree path and runtime for a native chat terminal tab', () => { + expect( + resolveNativeChatFileLinkContext( + state({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'] + }), + 'tab-1' + ) + ).toEqual({ + worktreeId: 'wt-1', + worktreePath: '/repo/worktree', + runtimeEnvironmentId: 'env-1' + }) + }) + + it('returns null when the terminal tab has no worktree owner', () => { + expect(resolveNativeChatFileLinkContext(state({ tabsByWorktree: {} }), 'tab-1')).toBeNull() + }) + + it('falls back to repo-scoped worktrees when a known worktree has no path', () => { + expect( + resolveNativeChatFileLinkContext( + state({ + getKnownWorktreeById: () => ({ id: 'wt-1' }) as never, + worktreesByRepo: { + repo: [{ id: 'wt-1', repoId: 'repo', path: '/repo/fallback' } as never] + } + }), + 'tab-1' + ) + ).toEqual({ + worktreeId: 'wt-1', + worktreePath: '/repo/fallback', + runtimeEnvironmentId: null + }) + }) +}) + +describe('resolveNativeChatFileLink', () => { + it('resolves repo-relative file links against the chat worktree', () => { + expect(resolveNativeChatFileLink('docs/guide.md', context)).toEqual({ + absolutePath: '/repo/worktree/docs/guide.md', + line: null, + column: null + }) + }) + + it('resolves explicit hrefs for non-markdown file types', () => { + expect(resolveNativeChatFileLink('src/App.tsx#L42', context)).toEqual({ + absolutePath: '/repo/worktree/src/App.tsx', + line: 42, + column: null + }) + expect(resolveNativeChatFileLink('package.json', context)).toEqual({ + absolutePath: '/repo/worktree/package.json', + line: null, + column: null + }) + expect(resolveNativeChatFileLink('assets/logo.png?raw=true', context)).toEqual({ + absolutePath: '/repo/worktree/assets/logo.png', + line: null, + column: null + }) + expect(resolveNativeChatFileLink('CODEOWNERS', context)).toEqual({ + absolutePath: '/repo/worktree/CODEOWNERS', + line: null, + column: null + }) + }) + + it('preserves terminal-style line and column suffixes', () => { + expect(resolveNativeChatFileLink('/repo/worktree/src/main.ts:12:4', context)).toEqual({ + absolutePath: '/repo/worktree/src/main.ts', + line: 12, + column: 4 + }) + }) + + it('resolves encoded file URIs', () => { + expect(resolveNativeChatFileLink('file:///repo/worktree/My%20File.md#L7', context)).toEqual({ + absolutePath: '/repo/worktree/My File.md', + line: 7, + column: null + }) + }) + + it('decodes escaped reserved characters in repo-relative hrefs', () => { + expect(resolveNativeChatFileLink('docs/Setup%20%231.md#L3', context)).toEqual({ + absolutePath: '/repo/worktree/docs/Setup #1.md', + line: 3, + column: null + }) + }) + + it('ignores http links so normal markdown navigation can handle them', () => { + expect(resolveNativeChatFileLink('https://example.com/docs/guide.md', context)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.ts b/src/renderer/src/components/native-chat/native-chat-file-link.ts new file mode 100644 index 00000000000..511cbe7f924 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-file-link.ts @@ -0,0 +1,190 @@ +import { fileUriToFilesystemPath } from '../../../../shared/file-uri-path' +import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' +import type { Worktree } from '../../../../shared/types' +import { + parseExplicitFileLinkTarget, + resolveExplicitFileLinkTarget +} from '@/lib/explicit-file-link-target' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import type { AppState } from '@/store/types' + +export type NativeChatFileLinkContext = { + worktreeId: string + worktreePath: string + runtimeEnvironmentId: string | null +} + +export type NativeChatResolvedFileLink = { + absolutePath: string + line: number | null + column: number | null +} + +type NativeChatFileLinkState = Pick< + AppState, + | 'folderWorkspaces' + | 'getKnownWorktreeById' + | 'projectGroups' + | 'repos' + | 'settings' + | 'tabsByWorktree' + | 'worktreesByRepo' +> + +function findTerminalTabWorktreeId( + tabsByWorktree: NativeChatFileLinkState['tabsByWorktree'], + terminalTabId: string +): string | null { + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + // Why: tabsByWorktree stores TerminalTab records; unified tabs carry + // entityId, but the terminal owner lookup must use the backing tab id. + if (tabs.some((tab) => tab.id === terminalTabId)) { + return worktreeId + } + } + return null +} + +function findWorktreeFallback( + worktreesByRepo: NativeChatFileLinkState['worktreesByRepo'], + worktreeId: string +): Pick | null { + for (const worktrees of Object.values(worktreesByRepo)) { + const worktree = worktrees.find((entry) => entry.id === worktreeId) + if (worktree) { + return worktree + } + } + return null +} + +export function resolveNativeChatFileLinkContext( + state: NativeChatFileLinkState, + terminalTabId: string +): NativeChatFileLinkContext | null { + const worktreeId = findTerminalTabWorktreeId(state.tabsByWorktree, terminalTabId) + if (!worktreeId) { + return null + } + + const knownWorktree = state.getKnownWorktreeById(worktreeId) + const worktree = knownWorktree?.path + ? knownWorktree + : findWorktreeFallback(state.worktreesByRepo, worktreeId) + if (!worktree?.path) { + return null + } + + return { + worktreeId, + worktreePath: worktree.path, + runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId) + } +} + +function maybeDecodeHrefPath(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +function stripQueryAndHash(value: string): { pathText: string; line: number | null } { + const hashIndex = value.indexOf('#') + const queryIndex = value.indexOf('?') + const suffixIndex = + hashIndex === -1 ? queryIndex : queryIndex === -1 ? hashIndex : Math.min(hashIndex, queryIndex) + const pathText = suffixIndex === -1 ? value : value.slice(0, suffixIndex) + const hash = + hashIndex === -1 + ? '' + : value.slice(hashIndex + 1, queryIndex > hashIndex ? queryIndex : undefined) + const line = parseLineFragment(hash) + return { pathText, line } +} + +function parseLineFragment(hash: string): number | null { + if (!hash) { + return null + } + let decoded = hash + try { + decoded = decodeURIComponent(hash) + } catch { + decoded = hash + } + const match = /^(?:L|line-?)([1-9]\d*)\b/i.exec(decoded) + return match ? Number.parseInt(match[1], 10) : null +} + +function hasNonFileUriProtocol(value: string): boolean { + if (isWindowsAbsolutePathLike(value)) { + return false + } + const match = /^[A-Za-z][A-Za-z0-9+.-]*:/.exec(value) + return Boolean(match && match[0].toLowerCase() !== 'file:') +} + +function resolvePathText( + pathText: string, + fallbackLine: number | null, + context: NativeChatFileLinkContext +): NativeChatResolvedFileLink | null { + const parsed = parseExplicitFileLinkTarget(pathText, { allowRelativeDirectoryPath: true }) + if (!parsed) { + return null + } + // Native chat hrefs are explicit agent-authored links, so avoid the terminal + // detector's conservative extension/filename filters. + const resolved = resolveExplicitFileLinkTarget(parsed, context.worktreePath) + if (!resolved) { + return null + } + return { + absolutePath: resolved.absolutePath, + line: resolved.line ?? fallbackLine, + column: resolved.column + } +} + +function resolveFileUriLink( + href: string, + context: NativeChatFileLinkContext +): NativeChatResolvedFileLink | null { + let url: URL + try { + url = new URL(href) + } catch { + return null + } + if (url.protocol !== 'file:') { + return null + } + const filePath = fileUriToFilesystemPath(url) + if (!filePath) { + return null + } + return resolvePathText(filePath, parseLineFragment(url.hash.replace(/^#/, '')), context) +} + +export function resolveNativeChatFileLink( + href: string | undefined, + context: NativeChatFileLinkContext | null +): NativeChatResolvedFileLink | null { + const rawHref = href?.trim() + if (!rawHref || rawHref.startsWith('#') || !context) { + return null + } + + if (rawHref.toLowerCase().startsWith('file:')) { + return resolveFileUriLink(rawHref, context) + } + if (hasNonFileUriProtocol(rawHref)) { + return null + } + + const { pathText, line } = stripQueryAndHash(rawHref) + const decodedPathText = maybeDecodeHrefPath(pathText) + return resolvePathText(decodedPathText, line, context) +} diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx new file mode 100644 index 00000000000..575d4343c3b --- /dev/null +++ b/src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx @@ -0,0 +1,149 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import CommentMarkdown from './CommentMarkdown' + +describe('CommentMarkdown link click handler', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('lets callers intercept rendered document links', () => { + const onLinkClick = vi.fn((event: React.MouseEvent) => { + event.preventDefault() + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + const anchor = container.querySelector('a[href="docs/guide.md"]') + expect(anchor).not.toBeNull() + const event = new window.MouseEvent('click', { bubbles: true, cancelable: true }) + + act(() => { + anchor?.dispatchEvent(event) + }) + + expect(onLinkClick).toHaveBeenCalledWith(expect.any(Object), 'docs/guide.md') + expect(event.defaultPrevented).toBe(true) + }) + + it('sanitizes file URI links unless the caller opts in', () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + const anchor = container.querySelector('a') + expect(anchor).not.toBeNull() + expect(anchor?.getAttribute('href')).toBeNull() + }) + + it('sanitizes raw HTML file URI links unless the caller opts in', () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + const anchor = container.querySelector('a') + expect(anchor).not.toBeNull() + expect(anchor?.getAttribute('href')).toBeNull() + }) + + it('lets opted-in callers intercept rendered file URI links', () => { + const onLinkClick = vi.fn() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + const anchor = container.querySelector( + 'a[href="file:///repo/worktree/src/main.ts"]' + ) + expect(anchor).not.toBeNull() + const event = new window.MouseEvent('click', { bubbles: true, cancelable: true }) + + act(() => { + anchor?.dispatchEvent(event) + }) + + expect(onLinkClick).toHaveBeenCalledWith( + expect.any(Object), + 'file:///repo/worktree/src/main.ts' + ) + expect(event.defaultPrevented).toBe(true) + }) + + it('lets callers intercept rendered document images', () => { + const onLinkClick = vi.fn((event: React.MouseEvent) => { + event.preventDefault() + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + const image = container.querySelector('img[alt="diagram"]') + expect(image?.getAttribute('src')).toBe('assets/diagram.png') + const event = new window.MouseEvent('click', { bubbles: true, cancelable: true }) + + act(() => { + image?.dispatchEvent(event) + }) + + expect(onLinkClick).toHaveBeenCalledWith(expect.any(Object), 'assets/diagram.png') + expect(event.defaultPrevented).toBe(true) + }) +}) diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx index 9b69e93d82c..4f28bac6b29 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx @@ -4,9 +4,17 @@ import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import rehypeRaw from 'rehype-raw' import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' -import type { Components } from 'react-markdown' import { cn } from '@/lib/utils' -import { isMermaidFence, isMermaidPre, renderMermaidFence } from './comment-mermaid-fence' +import { + compactCommentMarkdownComponents, + createCompactCommentMarkdownComponents, + createDocumentCommentMarkdownComponents, + documentCommentMarkdownComponents, + isTrustedCompactImageSrc, + type CommentMarkdownLinkClickHandler +} from './comment-markdown-element-renderers' + +export type { CommentMarkdownLinkClickHandler } from './comment-markdown-element-renderers' type MarkdownPlugins = NonNullable['rehypePlugins']> type UrlTransform = NonNullable['urlTransform']> @@ -34,77 +42,6 @@ type MarkdownNode = { children?: MarkdownNode[] } -function isTrustedCompactImageSrc(src: string | undefined): src is string { - if (!src) { - return false - } - const normalized = src.trim().toLowerCase() - return ( - normalized.startsWith('blob:') || /^data:image\/(?:png|jpe?g|gif|webp);base64,/.test(normalized) - ) -} - -function isGitHubUserAttachmentUrl(href: string | undefined): href is string { - if (!href) { - return false - } - try { - const url = new URL(href) - return ( - url.protocol === 'https:' && - url.hostname === 'github.com' && - url.pathname.startsWith('/user-attachments/assets/') - ) - } catch { - return false - } -} - -function isBareAutolink(children: React.ReactNode, href: string): boolean { - const text = React.Children.toArray(children).join('').trim() - return text === href -} - -function GitHubUserAttachmentVideo({ - href, - children -}: { - href: string - children: React.ReactNode -}): React.ReactElement { - const [failed, setFailed] = React.useState(false) - - if (failed) { - return ( - e.stopPropagation()} - > - {children} - - ) - } - - return ( - - ) -} - const commentMarkdownUrlTransform: UrlTransform = (value, key, node) => { if (key === 'src' && node?.tagName === 'img' && isTrustedCompactImageSrc(value)) { return value @@ -112,198 +49,11 @@ const commentMarkdownUrlTransform: UrlTransform = (value, key, node) => { return defaultUrlTransform(value) } -// Why: sidebar comments are rendered at 11px in a narrow card, so we strip -// block-level wrappers that add unwanted margins and only keep inline -// formatting (bold, italic, code, links) plus compact lists and line breaks. -// Using react-markdown (already a project dependency) lets AI agents write -// markdown via `orca worktree set --comment` and have it render nicely. - -const compactComponents: Components = { - // Strip

wrappers to avoid double margins in the tight card layout. - p: ({ children }) => {children}, - // Open links externally — sidebar is not a navigation context. - a: ({ href, children }) => ( - e.stopPropagation()} - > - {children} - - ), - // Why: react-markdown calls the `code` component for both inline `code` - // and the inside fenced blocks (

). We - // always apply inline-code styling here; the wrapper div uses a CSS - // descendant selector ([&_pre_code]) at higher specificity to strip - // the pill background/padding when code is inside a
. This is
-  // more reliable than checking `className` — which is only set when
-  // the fenced block specifies a language (```js), not for bare ```.
-  // Why: compact comment previews live in dense cards; keep diagram fences as
-  // bounded source blocks so async SVG renders do not reshape sidebar lists.
-  code: ({ children }) => (
-    
-      {children}
-    
-  ),
-  // Compact pre blocks — no syntax highlighting needed for short comments.
-  pre: ({ children }) => (
-    
-      {children}
-    
- ), - // Compact lists - ul: ({ children }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, - // Why: GFM task list checkboxes are non-functional in a read-only comment - // card (clicking them would just open the edit modal via the parent's - // onClick). Rendering them disabled avoids a misleading interactive - // affordance. - li: ({ children }) => ( -
  • {children}
  • - ), - // Headings render as bold text at the same size — no visual hierarchy needed - // in a tiny sidebar card. - h1: ({ children }) => {children}, - h2: ({ children }) => {children}, - h3: ({ children }) => {children}, - h4: ({ children }) => {children}, - h5: ({ children }) => {children}, - h6: ({ children }) => {children}, - // Horizontal rules as a subtle divider - hr: () =>
    , - // Compact blockquotes - blockquote: ({ children }) => ( -
    - {children} -
    - ), - // Why: agent replies and workspace notes often carry screenshot markdown - // like "Image #1"; compact cards inline app-managed thumbnails without - // auto-fetching arbitrary remote image URLs. - img: ({ alt, src }) => { - if (!isTrustedCompactImageSrc(src)) { - if (!src) { - return alt ? {alt} : null - } - return ( - e.stopPropagation()} - > - {alt || src} - - ) - } - - const image = ( - {alt - ) - return src ? ( - e.stopPropagation()}> - {image} - - ) : ( - image - ) - }, - // Why: GFM tables in a ~200px sidebar would overflow badly. Wrapping in an - // overflow container keeps the card layout stable while still letting the - // user scroll to see the full table. - table: ({ children }) => ( -
    - - {children} -
    -
    - ) -} - -const documentComponents: Components = { - p: ({ children }) =>

    {children}

    , - a: ({ href, children }) => - isGitHubUserAttachmentUrl(href) && isBareAutolink(children, href) ? ( - // Why: GitHub's API returns uploaded videos as bare attachment links; - // GitHub.com upgrades them to media embeds in its own renderer. - {children} - ) : ( - e.stopPropagation()} - > - {children} - - ), - code: ({ className, children }) => - isMermaidFence(className) ? ( - renderMermaidFence( - children, - 'my-3 min-w-0 max-w-full overflow-x-auto rounded-md border border-border/60 p-3 [&_.mermaid-block]:min-w-0 [&_.mermaid-block_pre]:my-0 [&_.mermaid-block_pre]:max-h-80 [&_.mermaid-block_pre]:max-w-full [&_.mermaid-block_pre]:overflow-x-auto [&_.mermaid-block_pre]:rounded-md [&_.mermaid-block_pre]:bg-accent [&_.mermaid-block_pre]:p-3 [&_.mermaid-block_pre]:font-mono [&_.mermaid-block_pre]:text-[12px]' - ) - ) : ( - - {children} - - ), - // Mermaid fences render a
    , which is invalid inside
    , so unwrap them.
    -  pre: ({ children }) =>
    -    isMermaidPre(children) ? (
    -      <>{children}
    -    ) : (
    -      
    -        {children}
    -      
    - ), - ul: ({ children }) =>
      {children}
    , - ol: ({ children }) =>
      {children}
    , - li: ({ children }) => ( -
  • {children}
  • - ), - h1: ({ children }) => ( -

    {children}

    - ), - h2: ({ children }) => ( -

    {children}

    - ), - h3: ({ children }) => ( -

    {children}

    - ), - h4: ({ children }) =>

    {children}

    , - h5: ({ children }) =>
    {children}
    , - h6: ({ children }) =>
    {children}
    , - hr: () =>
    , - blockquote: ({ children }) => ( -
    - {children} -
    - ), - img: ({ alt, src }) => ( - {alt - ), - // Why: GitHub issue/PR bodies commonly contain GFM tables. The dashboard - // dialog is wide enough to show them, but still needs overflow containment. - table: ({ children }) => ( -
    - - {children} -
    -
    - ) +const commentMarkdownFileUriUrlTransform: UrlTransform = (value, key, node) => { + if (key === 'href' && node?.tagName === 'a' && value.trim().toLowerCase().startsWith('file:')) { + return value + } + return commentMarkdownUrlTransform(value, key, node) } // Why: standard CommonMark collapses single newlines into spaces. The old @@ -418,6 +168,9 @@ const commentMarkdownSanitizeSchema = { }, protocols: { ...defaultSchema.protocols, + // Why: native chat opts into file URI links after sanitize; the URL + // transform below still strips them for all other markdown surfaces. + href: [...(defaultSchema.protocols?.href ?? []), 'file'], src: [...(defaultSchema.protocols?.src ?? []), 'data', 'blob'] } } @@ -430,6 +183,8 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & { content: string variant?: 'compact' | 'document' githubRepo?: GitHubRepoReference | null + onLinkClick?: CommentMarkdownLinkClickHandler + allowFileUriLinks?: boolean } // Why forwardRef + rest props: Radix's HoverCardTrigger asChild merges a ref @@ -437,10 +192,27 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & { // the child. Without forwarding both, the hover card cannot open or position. const CommentMarkdown = React.memo( React.forwardRef(function CommentMarkdown( - { content, className, variant = 'compact', githubRepo, ...rest }, + { + content, + className, + variant = 'compact', + githubRepo, + onLinkClick, + allowFileUriLinks = false, + ...rest + }, ref ) { - const components = variant === 'document' ? documentComponents : compactComponents + const components = React.useMemo(() => { + if (!onLinkClick) { + return variant === 'document' + ? documentCommentMarkdownComponents + : compactCommentMarkdownComponents + } + return variant === 'document' + ? createDocumentCommentMarkdownComponents(onLinkClick) + : createCompactCommentMarkdownComponents(onLinkClick) + }, [variant, onLinkClick]) const activeRemarkPlugins = React.useMemo( () => (githubRepo ? [...remarkPlugins, remarkGitHubReferences(githubRepo)] : remarkPlugins), [githubRepo] @@ -463,7 +235,9 @@ const CommentMarkdown = React.memo( remarkPlugins={activeRemarkPlugins} rehypePlugins={rehypePlugins} components={components} - urlTransform={commentMarkdownUrlTransform} + urlTransform={ + allowFileUriLinks ? commentMarkdownFileUriUrlTransform : commentMarkdownUrlTransform + } > {content} diff --git a/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx b/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx new file mode 100644 index 00000000000..5f2e0b972bf --- /dev/null +++ b/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx @@ -0,0 +1,264 @@ +import React from 'react' +import type { Components } from 'react-markdown' +import { isMermaidFence, isMermaidPre, renderMermaidFence } from './comment-mermaid-fence' +import { + GitHubUserAttachmentVideo, + isGitHubUserAttachmentVideoLink +} from './comment-markdown-github-attachment-video' + +export type CommentMarkdownLinkClickHandler = ( + event: React.MouseEvent, + href: string | undefined +) => void + +export function isTrustedCompactImageSrc(src: string | undefined): src is string { + if (!src) { + return false + } + const normalized = src.trim().toLowerCase() + return ( + normalized.startsWith('blob:') || /^data:image\/(?:png|jpe?g|gif|webp);base64,/.test(normalized) + ) +} + +function handleMarkdownAnchorClick( + event: React.MouseEvent, + href: string | undefined, + onLinkClick: CommentMarkdownLinkClickHandler | undefined +): void { + // Why: link clicks should not also trigger an outer row/card click handler; + // images only claim the click when an image handler is wired below. + event.stopPropagation() + if (href?.trim().toLowerCase().startsWith('file:')) { + event.preventDefault() + } + onLinkClick?.(event, href) +} + +function handleMarkdownImageClick( + event: React.MouseEvent, + src: string | undefined, + onLinkClick: CommentMarkdownLinkClickHandler | undefined +): void { + if (!onLinkClick) { + return + } + event.stopPropagation() + onLinkClick(event, src) +} + +export function createCompactCommentMarkdownComponents( + onLinkClick?: CommentMarkdownLinkClickHandler +): Components { + return { + // Strip

    wrappers to avoid double margins in the tight card layout. + p: ({ children }) => {children}, + // Open links externally — sidebar is not a navigation context. + a: ({ href, children }) => ( + handleMarkdownAnchorClick(e, href, onLinkClick)} + > + {children} + + ), + // Why: react-markdown calls the `code` component for both inline `code` + // and the inside fenced blocks (

    ). We + // always apply inline-code styling here; the wrapper div uses a CSS + // descendant selector ([&_pre_code]) at higher specificity to strip + // the pill background/padding when code is inside a
    . This is
    +    // more reliable than checking `className` — which is only set when
    +    // the fenced block specifies a language (```js), not for bare ```.
    +    // Why: compact comment previews live in dense cards; keep diagram fences as
    +    // bounded source blocks so async SVG renders do not reshape sidebar lists.
    +    code: ({ children }) => (
    +      
    +        {children}
    +      
    +    ),
    +    // Compact pre blocks — no syntax highlighting needed for short comments.
    +    pre: ({ children }) => (
    +      
    +        {children}
    +      
    + ), + // Compact lists + ul: ({ children }) =>
      {children}
    , + ol: ({ children }) =>
      {children}
    , + // Why: GFM task list checkboxes are non-functional in a read-only comment + // card (clicking them would just open the edit modal via the parent's + // onClick). Rendering them disabled avoids a misleading interactive + // affordance. + li: ({ children }) => ( +
  • {children}
  • + ), + // Headings render as bold text at the same size — no visual hierarchy needed + // in a tiny sidebar card. + h1: ({ children }) => {children}, + h2: ({ children }) => {children}, + h3: ({ children }) => {children}, + h4: ({ children }) => {children}, + h5: ({ children }) => {children}, + h6: ({ children }) => {children}, + // Horizontal rules as a subtle divider + hr: () =>
    , + // Compact blockquotes + blockquote: ({ children }) => ( +
    + {children} +
    + ), + // Why: agent replies and workspace notes often carry screenshot markdown + // like "Image #1"; compact cards inline app-managed thumbnails without + // auto-fetching arbitrary remote image URLs. + img: ({ alt, src }) => { + if (!isTrustedCompactImageSrc(src)) { + if (!src) { + return alt ? {alt} : null + } + return ( + handleMarkdownAnchorClick(e, src, onLinkClick)} + > + {alt || src} + + ) + } + + const image = ( + {alt + ) + return src ? ( + handleMarkdownAnchorClick(e, src, onLinkClick)} + > + {image} + + ) : ( + image + ) + }, + // Why: GFM tables in a ~200px sidebar would overflow badly. Wrapping in an + // overflow container keeps the card layout stable while still letting the + // user scroll to see the full table. + table: ({ children }) => ( +
    + + {children} +
    +
    + ) + } +} + +export function createDocumentCommentMarkdownComponents( + onLinkClick?: CommentMarkdownLinkClickHandler +): Components { + return { + p: ({ children }) =>

    {children}

    , + a: ({ href, children }) => + isGitHubUserAttachmentVideoLink(href, children) ? ( + // Why: GitHub's API returns uploaded videos as bare attachment links; + // GitHub.com upgrades them to media embeds in its own renderer. + {children} + ) : ( + handleMarkdownAnchorClick(e, href, onLinkClick)} + > + {children} + + ), + code: ({ className, children }) => + isMermaidFence(className) ? ( + renderMermaidFence( + children, + 'my-3 min-w-0 max-w-full overflow-x-auto rounded-md border border-border/60 p-3 [&_.mermaid-block]:min-w-0 [&_.mermaid-block_pre]:my-0 [&_.mermaid-block_pre]:max-h-80 [&_.mermaid-block_pre]:max-w-full [&_.mermaid-block_pre]:overflow-x-auto [&_.mermaid-block_pre]:rounded-md [&_.mermaid-block_pre]:bg-accent [&_.mermaid-block_pre]:p-3 [&_.mermaid-block_pre]:font-mono [&_.mermaid-block_pre]:text-[12px]' + ) + ) : ( + + {children} + + ), + // Mermaid fences render a
    , which is invalid inside
    , so unwrap them.
    +    pre: ({ children }) =>
    +      isMermaidPre(children) ? (
    +        <>{children}
    +      ) : (
    +        
    +          {children}
    +        
    + ), + ul: ({ children }) =>
      {children}
    , + ol: ({ children }) =>
      {children}
    , + li: ({ children }) => ( +
  • {children}
  • + ), + h1: ({ children }) => ( +

    {children}

    + ), + h2: ({ children }) => ( +

    {children}

    + ), + h3: ({ children }) => ( +

    {children}

    + ), + h4: ({ children }) =>

    {children}

    , + h5: ({ children }) =>
    {children}
    , + h6: ({ children }) =>
    {children}
    , + hr: () =>
    , + blockquote: ({ children }) => ( +
    + {children} +
    + ), + img: ({ alt, src }) => { + const imageClassName = [ + 'my-3 max-h-96 max-w-full rounded-md object-contain', + 'outline outline-1 outline-black/10 dark:outline-white/10', + onLinkClick ? 'cursor-pointer' : '' + ] + .filter(Boolean) + .join(' ') + + return ( + {alt handleMarkdownImageClick(e, src, onLinkClick)} + /> + ) + }, + // Why: GitHub issue/PR bodies commonly contain GFM tables. The dashboard + // dialog is wide enough to show them, but still needs overflow containment. + table: ({ children }) => ( +
    + + {children} +
    +
    + ) + } +} + +export const compactCommentMarkdownComponents: Components = createCompactCommentMarkdownComponents() +export const documentCommentMarkdownComponents: Components = + createDocumentCommentMarkdownComponents() diff --git a/src/renderer/src/components/sidebar/comment-markdown-github-attachment-video.tsx b/src/renderer/src/components/sidebar/comment-markdown-github-attachment-video.tsx new file mode 100644 index 00000000000..4484933894e --- /dev/null +++ b/src/renderer/src/components/sidebar/comment-markdown-github-attachment-video.tsx @@ -0,0 +1,69 @@ +import React from 'react' + +function isGitHubUserAttachmentUrl(href: string | undefined): href is string { + if (!href) { + return false + } + try { + const url = new URL(href) + return ( + url.protocol === 'https:' && + url.hostname === 'github.com' && + url.pathname.startsWith('/user-attachments/assets/') + ) + } catch { + return false + } +} + +function isBareAutolink(children: React.ReactNode, href: string): boolean { + const text = React.Children.toArray(children).join('').trim() + return text === href +} + +export function isGitHubUserAttachmentVideoLink( + href: string | undefined, + children: React.ReactNode +): href is string { + return isGitHubUserAttachmentUrl(href) && isBareAutolink(children, href) +} + +export function GitHubUserAttachmentVideo({ + href, + children +}: { + href: string + children: React.ReactNode +}): React.ReactElement { + const [failed, setFailed] = React.useState(false) + + if (failed) { + return ( + e.stopPropagation()} + > + {children} + + ) + } + + return ( + + ) +} diff --git a/src/renderer/src/lib/explicit-file-link-target.ts b/src/renderer/src/lib/explicit-file-link-target.ts new file mode 100644 index 00000000000..912119a9f70 --- /dev/null +++ b/src/renderer/src/lib/explicit-file-link-target.ts @@ -0,0 +1,91 @@ +import { + joinAbsolutePath, + normalizeAbsolutePath, + resolveTildePath +} from './terminal-path-normalization' + +export type ParsedExplicitFileLinkTarget = { + pathText: string + line: number | null + column: number | null +} + +export type ResolvedExplicitFileLinkTarget = Pick< + ParsedExplicitFileLinkTarget, + 'line' | 'column' +> & { + absolutePath: string +} + +type ParseExplicitFileLinkTargetOptions = { + allowRelativeDirectoryPath?: boolean +} + +function canKeepTrailingSeparator(pathText: string): boolean { + // Why: bare roots ("/", "~/", "C:/") are ambiguous link targets, while + // absolute/tilde paths with a real segment are unambiguous directories. + if (/^[\\/]+$/.test(pathText) || /^~[\\/]$/.test(pathText) || /^[A-Za-z]:[\\/]$/.test(pathText)) { + return false + } + return /^(?:~[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(pathText) +} + +export function parseExplicitFileLinkTarget( + value: string, + options: ParseExplicitFileLinkTargetOptions = {} +): ParsedExplicitFileLinkTarget | null { + const match = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value) + if (!match) { + return null + } + const pathText = match[1] + const hasLineOrColumn = Boolean(match[2] || match[3]) + if (!pathText) { + return null + } + if (/^[\\/]\s/.test(pathText)) { + return null + } + if (/[\\/]$/.test(pathText)) { + const canKeepRelativeDirectory = options.allowRelativeDirectoryPath === true && !hasLineOrColumn + if (hasLineOrColumn || (!canKeepRelativeDirectory && !canKeepTrailingSeparator(pathText))) { + return null + } + } + + const line = match[2] ? Number.parseInt(match[2], 10) : null + const column = match[3] ? Number.parseInt(match[3], 10) : null + if ((line !== null && line < 1) || (column !== null && column < 1)) { + return null + } + + return { pathText, line, column } +} + +export function resolveExplicitFileLinkTargetPath( + pathText: string, + cwd: string, + homePath?: string | null +): string | null { + if (/^~[\\/]/.test(pathText)) { + return resolveTildePath(pathText, cwd, homePath) + } + return normalizeAbsolutePath(pathText)?.normalized ?? joinAbsolutePath(cwd, pathText) +} + +export function resolveExplicitFileLinkTarget( + parsed: ParsedExplicitFileLinkTarget, + cwd: string, + homePath?: string | null +): ResolvedExplicitFileLinkTarget | null { + const absolutePath = resolveExplicitFileLinkTargetPath(parsed.pathText, cwd, homePath) + if (!absolutePath) { + return null + } + + return { + absolutePath, + line: parsed.line, + column: parsed.column + } +} diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts index f8ab8cfb49d..a882e71099e 100644 --- a/src/renderer/src/lib/terminal-links.ts +++ b/src/renderer/src/lib/terminal-links.ts @@ -1,9 +1,9 @@ /* eslint-disable max-lines -- Why: terminal link parsing depends on ordered passes sharing range state. */ +import { normalizeAbsolutePath } from './terminal-path-normalization' import { - joinAbsolutePath, - normalizeAbsolutePath, - resolveTildePath -} from './terminal-path-normalization' + parseExplicitFileLinkTarget, + resolveExplicitFileLinkTarget +} from './explicit-file-link-target' export type ParsedTerminalFileLink = { pathText: string @@ -88,43 +88,6 @@ function trimBoundaryPunctuation( } } -function parsePathWithOptionalLineColumn(value: string): { - pathText: string - line: number | null - column: number | null -} | null { - const match = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value) - if (!match) { - return null - } - const pathText = match[1] - const hasLineOrColumn = Boolean(match[2] || match[3]) - if (!pathText) { - return null - } - if (/^[\\/]\s/.test(pathText)) { - return null - } - if (/[\\/]$/.test(pathText) && (hasLineOrColumn || !canKeepTrailingSeparator(pathText))) { - return null - } - - const line = match[2] ? Number.parseInt(match[2], 10) : null - const column = match[3] ? Number.parseInt(match[3], 10) : null - if ((line !== null && line < 1) || (column !== null && column < 1)) { - return null - } - - return { pathText, line, column } -} - -function canKeepTrailingSeparator(pathText: string): boolean { - if (/^[\\/]+$/.test(pathText) || /^~[\\/]$/.test(pathText) || /^[A-Za-z]:[\\/]$/.test(pathText)) { - return false - } - return /^(?:~[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(pathText) -} - // Project files that look like filenames despite having no extension. The // word detector otherwise requires a `.` in the token to keep noise down — // without this list, `ls` output containing `Makefile` or `LICENSE` would @@ -333,7 +296,7 @@ function buildLineEndingSpacedPathPrefixRanges(range: DetectedRange): DetectedRa } function toParsedLink(range: DetectedRange): ParsedTerminalFileLink | null { - const parsed = parsePathWithOptionalLineColumn(range.text) + const parsed = parseExplicitFileLinkTarget(range.text) if (!parsed) { return null } @@ -492,18 +455,7 @@ export function resolveTerminalFileLink( cwd: string, homePath?: string | null ): ResolvedTerminalFileLink | null { - const absolutePath = /^~[\\/]/.test(parsed.pathText) - ? resolveTildePath(parsed.pathText, cwd, homePath) - : (normalizeAbsolutePath(parsed.pathText)?.normalized ?? joinAbsolutePath(cwd, parsed.pathText)) - if (!absolutePath) { - return null - } - - return { - absolutePath, - line: parsed.line, - column: parsed.column - } + return resolveExplicitFileLinkTarget(parsed, cwd, homePath) } export function resolveTerminalFileLinkText(