mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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.
This commit is contained in:
@@ -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<HTMLDivElement | null>(null)
|
||||
const { prose, tools } = useMemo(() => splitNativeChatBlocks(message.blocks), [message.blocks])
|
||||
@@ -163,7 +169,13 @@ function MessageRow({
|
||||
{markdown ? (
|
||||
<>
|
||||
<ImageAttachmentRefs blocks={prose} />
|
||||
<CommentMarkdown content={markdown} variant="document" className="text-sm" />
|
||||
<CommentMarkdown
|
||||
content={markdown}
|
||||
variant="document"
|
||||
className="text-sm"
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ImageAttachmentRefs blocks={prose} />
|
||||
@@ -196,7 +208,13 @@ function MessageRow({
|
||||
) : null}
|
||||
<ImageAttachmentRefs blocks={prose} />
|
||||
{markdown ? (
|
||||
<CommentMarkdown content={markdown} variant="document" className="text-sm" />
|
||||
<CommentMarkdown
|
||||
content={markdown}
|
||||
variant="document"
|
||||
className="text-sm"
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
/>
|
||||
) : null}
|
||||
{tools.length > 0 ? <NativeChatToolRun blocks={tools} expandSignal={expandSignal} /> : null}
|
||||
</div>
|
||||
@@ -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<HTMLDivElement | null>(null)
|
||||
const [stuckToBottom, setStuckToBottom] = useState(true)
|
||||
@@ -346,6 +368,8 @@ export function NativeChatMessageList({
|
||||
message={message}
|
||||
expandSignal={expandSignal}
|
||||
onScrollMessageToTop={scrollMessageToTop}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
/>
|
||||
))}
|
||||
{showTypingIndicator ? <TypingIndicatorRow /> : null}
|
||||
|
||||
@@ -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<NativeChatContextMenuActions, 'onPaste'> = {
|
||||
onSplitRight: () => {},
|
||||
@@ -164,6 +170,9 @@ function NativeChatResolvedView({
|
||||
const [workingInterrupted, setWorkingInterrupted] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const composerRef = useRef<NativeChatComposerHandle>(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<CommentMarkdownLinkClickHandler>(
|
||||
(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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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> = {}): 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> = {}): 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()
|
||||
})
|
||||
})
|
||||
@@ -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<Worktree, 'id' | 'path'> | 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)
|
||||
}
|
||||
@@ -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<HTMLElement>) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CommentMarkdown
|
||||
variant="document"
|
||||
content="[docs](docs/guide.md)"
|
||||
onLinkClick={onLinkClick}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const anchor = container.querySelector<HTMLAnchorElement>('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(
|
||||
<CommentMarkdown variant="document" content="[source](file:///repo/worktree/src/main.ts)" />
|
||||
)
|
||||
})
|
||||
|
||||
const anchor = container.querySelector<HTMLAnchorElement>('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(
|
||||
<CommentMarkdown
|
||||
variant="document"
|
||||
content='<a href="file:///repo/worktree/src/main.ts">source</a>'
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const anchor = container.querySelector<HTMLAnchorElement>('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(
|
||||
<CommentMarkdown
|
||||
variant="document"
|
||||
content="[source](file:///repo/worktree/src/main.ts)"
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const anchor = container.querySelector<HTMLAnchorElement>(
|
||||
'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<HTMLElement>) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CommentMarkdown
|
||||
variant="document"
|
||||
content=""
|
||||
onLinkClick={onLinkClick}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const image = container.querySelector<HTMLImageElement>('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)
|
||||
})
|
||||
})
|
||||
@@ -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<React.ComponentProps<typeof Markdown>['rehypePlugins']>
|
||||
type UrlTransform = NonNullable<React.ComponentProps<typeof Markdown>['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 (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="break-all text-primary underline underline-offset-2 hover:text-primary/80"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
src={href}
|
||||
controls
|
||||
preload="metadata"
|
||||
playsInline
|
||||
className="my-3 max-h-[28rem] max-w-full rounded-md bg-black/80 outline outline-1 outline-black/10 dark:outline-white/10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onError={() => setFailed(true)}
|
||||
>
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
</video>
|
||||
)
|
||||
}
|
||||
|
||||
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 <p> wrappers to avoid double margins in the tight card layout.
|
||||
p: ({ children }) => <span className="comment-md-p">{children}</span>,
|
||||
// Open links externally — sidebar is not a navigation context.
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline underline-offset-2 text-foreground/80 hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// Why: react-markdown calls the `code` component for both inline `code`
|
||||
// and the <code> inside fenced blocks (<pre><code>…</code></pre>). 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 <pre>. 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 }) => (
|
||||
<code className="rounded bg-accent px-1 py-px text-[10px] font-mono [overflow-wrap:anywhere]">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
// Compact pre blocks — no syntax highlighting needed for short comments.
|
||||
pre: ({ children }) => (
|
||||
<pre className="my-1 max-h-32 max-w-full overflow-x-auto rounded bg-accent p-1.5 text-[10px] font-mono">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
// Compact lists
|
||||
ul: ({ children }) => <ul className="my-0.5 ml-3 list-disc space-y-0">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="my-0.5 ml-3 list-decimal space-y-0">{children}</ol>,
|
||||
// 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 }) => (
|
||||
<li className="leading-normal [&>input]:pointer-events-none">{children}</li>
|
||||
),
|
||||
// Headings render as bold text at the same size — no visual hierarchy needed
|
||||
// in a tiny sidebar card.
|
||||
h1: ({ children }) => <span className="font-bold">{children}</span>,
|
||||
h2: ({ children }) => <span className="font-bold">{children}</span>,
|
||||
h3: ({ children }) => <span className="font-semibold">{children}</span>,
|
||||
h4: ({ children }) => <span className="font-semibold">{children}</span>,
|
||||
h5: ({ children }) => <span className="font-semibold">{children}</span>,
|
||||
h6: ({ children }) => <span className="font-semibold">{children}</span>,
|
||||
// Horizontal rules as a subtle divider
|
||||
hr: () => <hr className="my-1 border-border/50" />,
|
||||
// Compact blockquotes
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="my-0.5 border-l-2 border-border/60 pl-2 text-muted-foreground/80">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
// 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 ? <span>{alt}</span> : null
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={src}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline underline-offset-2 text-foreground/80 hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{alt || src}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const image = (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? ''}
|
||||
className="my-1 max-h-32 max-w-full rounded-sm object-contain outline outline-1 outline-border/70"
|
||||
/>
|
||||
)
|
||||
return src ? (
|
||||
<a href={src} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>
|
||||
{image}
|
||||
</a>
|
||||
) : (
|
||||
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 }) => (
|
||||
<div className="my-1 max-w-full overflow-x-auto">
|
||||
<table className="text-[10px] border-collapse [&_td]:border [&_td]:border-border/40 [&_td]:px-1 [&_td]:py-0.5 [&_th]:border [&_th]:border-border/40 [&_th]:px-1 [&_th]:py-0.5 [&_th]:font-semibold [&_th]:text-left">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const documentComponents: Components = {
|
||||
p: ({ children }) => <p className="my-2 first:mt-0 last:mb-0">{children}</p>,
|
||||
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.
|
||||
<GitHubUserAttachmentVideo href={href}>{children}</GitHubUserAttachmentVideo>
|
||||
) : (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="break-all text-primary underline underline-offset-2 hover:text-primary/80"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
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]'
|
||||
)
|
||||
) : (
|
||||
<code className="rounded bg-accent px-1.5 py-0.5 font-mono text-[0.92em] [overflow-wrap:anywhere]">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
// Mermaid fences render a <div>, which is invalid inside <pre>, so unwrap them.
|
||||
pre: ({ children }) =>
|
||||
isMermaidPre(children) ? (
|
||||
<>{children}</>
|
||||
) : (
|
||||
<pre className="my-3 max-h-80 max-w-full overflow-x-auto rounded-md bg-accent p-3 font-mono text-[12px]">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
ul: ({ children }) => <ul className="my-2 ml-5 list-disc space-y-1">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="my-2 ml-5 list-decimal space-y-1">{children}</ol>,
|
||||
li: ({ children }) => (
|
||||
<li className="leading-relaxed [&>input]:pointer-events-none">{children}</li>
|
||||
),
|
||||
h1: ({ children }) => (
|
||||
<h1 className="mb-2 mt-4 text-[18px] font-semibold leading-tight first:mt-0">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="mb-2 mt-4 text-[16px] font-semibold leading-tight first:mt-0">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="mb-2 mt-3 text-[15px] font-semibold leading-tight first:mt-0">{children}</h3>
|
||||
),
|
||||
h4: ({ children }) => <h4 className="mb-1 mt-3 font-semibold first:mt-0">{children}</h4>,
|
||||
h5: ({ children }) => <h5 className="mb-1 mt-3 font-semibold first:mt-0">{children}</h5>,
|
||||
h6: ({ children }) => <h6 className="mb-1 mt-3 font-semibold first:mt-0">{children}</h6>,
|
||||
hr: () => <hr className="my-4 border-border/60" />,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="my-3 border-l-2 border-border/70 pl-3 text-muted-foreground">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
img: ({ alt, src }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? ''}
|
||||
className="my-3 max-h-96 max-w-full rounded-md object-contain outline outline-1 outline-black/10 dark:outline-white/10"
|
||||
/>
|
||||
),
|
||||
// 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 }) => (
|
||||
<div className="my-3 max-w-full overflow-x-auto rounded-md border border-border/60">
|
||||
<table className="min-w-full border-collapse text-[13px] [&_td]:border [&_td]:border-border/50 [&_td]:px-2 [&_td]:py-1.5 [&_th]:border [&_th]:border-border/50 [&_th]:bg-muted/60 [&_th]:px-2 [&_th]:py-1.5 [&_th]:text-left [&_th]:font-semibold">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
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<HTMLDivElement, CommentMarkdownProps>(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}
|
||||
</Markdown>
|
||||
|
||||
@@ -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<HTMLElement>,
|
||||
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<HTMLAnchorElement>,
|
||||
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<HTMLImageElement>,
|
||||
src: string | undefined,
|
||||
onLinkClick: CommentMarkdownLinkClickHandler | undefined
|
||||
): void {
|
||||
if (!onLinkClick) {
|
||||
return
|
||||
}
|
||||
event.stopPropagation()
|
||||
onLinkClick(event, src)
|
||||
}
|
||||
|
||||
export function createCompactCommentMarkdownComponents(
|
||||
onLinkClick?: CommentMarkdownLinkClickHandler
|
||||
): Components {
|
||||
return {
|
||||
// Strip <p> wrappers to avoid double margins in the tight card layout.
|
||||
p: ({ children }) => <span className="comment-md-p">{children}</span>,
|
||||
// Open links externally — sidebar is not a navigation context.
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href || undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline underline-offset-2 text-foreground/80 hover:text-foreground"
|
||||
onClick={(e) => handleMarkdownAnchorClick(e, href, onLinkClick)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// Why: react-markdown calls the `code` component for both inline `code`
|
||||
// and the <code> inside fenced blocks (<pre><code>…</code></pre>). 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 <pre>. 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 }) => (
|
||||
<code className="rounded bg-accent px-1 py-px text-[10px] font-mono [overflow-wrap:anywhere]">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
// Compact pre blocks — no syntax highlighting needed for short comments.
|
||||
pre: ({ children }) => (
|
||||
<pre className="my-1 max-h-32 max-w-full overflow-x-auto rounded bg-accent p-1.5 text-[10px] font-mono">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
// Compact lists
|
||||
ul: ({ children }) => <ul className="my-0.5 ml-3 list-disc space-y-0">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="my-0.5 ml-3 list-decimal space-y-0">{children}</ol>,
|
||||
// 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 }) => (
|
||||
<li className="leading-normal [&>input]:pointer-events-none">{children}</li>
|
||||
),
|
||||
// Headings render as bold text at the same size — no visual hierarchy needed
|
||||
// in a tiny sidebar card.
|
||||
h1: ({ children }) => <span className="font-bold">{children}</span>,
|
||||
h2: ({ children }) => <span className="font-bold">{children}</span>,
|
||||
h3: ({ children }) => <span className="font-semibold">{children}</span>,
|
||||
h4: ({ children }) => <span className="font-semibold">{children}</span>,
|
||||
h5: ({ children }) => <span className="font-semibold">{children}</span>,
|
||||
h6: ({ children }) => <span className="font-semibold">{children}</span>,
|
||||
// Horizontal rules as a subtle divider
|
||||
hr: () => <hr className="my-1 border-border/50" />,
|
||||
// Compact blockquotes
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="my-0.5 border-l-2 border-border/60 pl-2 text-muted-foreground/80">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
// 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 ? <span>{alt}</span> : null
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={src || undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline underline-offset-2 text-foreground/80 hover:text-foreground"
|
||||
onClick={(e) => handleMarkdownAnchorClick(e, src, onLinkClick)}
|
||||
>
|
||||
{alt || src}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const image = (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? ''}
|
||||
className="my-1 max-h-32 max-w-full rounded-sm object-contain outline outline-1 outline-border/70"
|
||||
/>
|
||||
)
|
||||
return src ? (
|
||||
<a
|
||||
href={src || undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => handleMarkdownAnchorClick(e, src, onLinkClick)}
|
||||
>
|
||||
{image}
|
||||
</a>
|
||||
) : (
|
||||
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 }) => (
|
||||
<div className="my-1 max-w-full overflow-x-auto">
|
||||
<table className="text-[10px] border-collapse [&_td]:border [&_td]:border-border/40 [&_td]:px-1 [&_td]:py-0.5 [&_th]:border [&_th]:border-border/40 [&_th]:px-1 [&_th]:py-0.5 [&_th]:font-semibold [&_th]:text-left">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function createDocumentCommentMarkdownComponents(
|
||||
onLinkClick?: CommentMarkdownLinkClickHandler
|
||||
): Components {
|
||||
return {
|
||||
p: ({ children }) => <p className="my-2 first:mt-0 last:mb-0">{children}</p>,
|
||||
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.
|
||||
<GitHubUserAttachmentVideo href={href}>{children}</GitHubUserAttachmentVideo>
|
||||
) : (
|
||||
<a
|
||||
href={href || undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="break-all text-primary underline underline-offset-2 hover:text-primary/80"
|
||||
onClick={(e) => handleMarkdownAnchorClick(e, href, onLinkClick)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
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]'
|
||||
)
|
||||
) : (
|
||||
<code className="rounded bg-accent px-1.5 py-0.5 font-mono text-[0.92em] [overflow-wrap:anywhere]">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
// Mermaid fences render a <div>, which is invalid inside <pre>, so unwrap them.
|
||||
pre: ({ children }) =>
|
||||
isMermaidPre(children) ? (
|
||||
<>{children}</>
|
||||
) : (
|
||||
<pre className="my-3 max-h-80 max-w-full overflow-x-auto rounded-md bg-accent p-3 font-mono text-[12px]">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
ul: ({ children }) => <ul className="my-2 ml-5 list-disc space-y-1">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="my-2 ml-5 list-decimal space-y-1">{children}</ol>,
|
||||
li: ({ children }) => (
|
||||
<li className="leading-relaxed [&>input]:pointer-events-none">{children}</li>
|
||||
),
|
||||
h1: ({ children }) => (
|
||||
<h1 className="mb-2 mt-4 text-[18px] font-semibold leading-tight first:mt-0">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="mb-2 mt-4 text-[16px] font-semibold leading-tight first:mt-0">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="mb-2 mt-3 text-[15px] font-semibold leading-tight first:mt-0">{children}</h3>
|
||||
),
|
||||
h4: ({ children }) => <h4 className="mb-1 mt-3 font-semibold first:mt-0">{children}</h4>,
|
||||
h5: ({ children }) => <h5 className="mb-1 mt-3 font-semibold first:mt-0">{children}</h5>,
|
||||
h6: ({ children }) => <h6 className="mb-1 mt-3 font-semibold first:mt-0">{children}</h6>,
|
||||
hr: () => <hr className="my-4 border-border/60" />,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="my-3 border-l-2 border-border/70 pl-3 text-muted-foreground">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
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 (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? ''}
|
||||
className={imageClassName}
|
||||
onClick={(e) => 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 }) => (
|
||||
<div className="my-3 max-w-full overflow-x-auto rounded-md border border-border/60">
|
||||
<table className="min-w-full border-collapse text-[13px] [&_td]:border [&_td]:border-border/50 [&_td]:px-2 [&_td]:py-1.5 [&_th]:border [&_th]:border-border/50 [&_th]:bg-muted/60 [&_th]:px-2 [&_th]:py-1.5 [&_th]:text-left [&_th]:font-semibold">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const compactCommentMarkdownComponents: Components = createCompactCommentMarkdownComponents()
|
||||
export const documentCommentMarkdownComponents: Components =
|
||||
createDocumentCommentMarkdownComponents()
|
||||
@@ -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 (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="break-all text-primary underline underline-offset-2 hover:text-primary/80"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
src={href}
|
||||
controls
|
||||
preload="metadata"
|
||||
playsInline
|
||||
className="my-3 max-h-[28rem] max-w-full rounded-md bg-black/80 outline outline-1 outline-black/10 dark:outline-white/10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onError={() => setFailed(true)}
|
||||
>
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
</video>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user