1+ import { HighlightedCode } from "@components/HighlightedCode" ;
12import { Tooltip } from "@components/ui/Tooltip" ;
3+ import { usePendingScrollStore } from "@features/code-editor/stores/pendingScrollStore" ;
24import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer" ;
5+ import { usePanelLayoutStore } from "@features/panels" ;
6+ import { useCwd } from "@features/sidebar/hooks/useCwd" ;
7+ import { useTaskStore } from "@features/tasks/stores/taskStore" ;
8+ import type { FileItem } from "@hooks/useRepoFiles" ;
9+ import { useRepoFiles } from "@hooks/useRepoFiles" ;
310import { Check , Copy } from "@phosphor-icons/react" ;
4- import { Box , IconButton } from "@radix-ui/themes" ;
5- import { memo , useCallback , useState } from "react" ;
11+ import { Box , Code , IconButton } from "@radix-ui/themes" ;
12+ import { memo , useCallback , useMemo , useState } from "react" ;
13+ import type { Components } from "react-markdown" ;
14+
15+ const FILE_WITH_DIR_RE =
16+ / ^ (?: \/ | \. \. ? \/ | [ a - z A - Z ] : \\ ) ? (?: [ \w . @ - ] + \/ ) + [ \w . @ - ] + \. \w + (?: : \d + (?: - \d + ) ? ) ? $ / ;
17+ const BARE_FILE_RE = / ^ [ \w . @ - ] + \. \w + (?: : \d + (?: - \d + ) ? ) ? $ / ;
18+
19+ function hasDirectoryPath ( text : string ) : boolean {
20+ return FILE_WITH_DIR_RE . test ( text ) ;
21+ }
22+
23+ function looksLikeBareFilename ( text : string ) : boolean {
24+ return BARE_FILE_RE . test ( text ) ;
25+ }
26+
27+ function parseFilePath ( text : string ) : { filePath : string ; lineSuffix : string } {
28+ const match = text . match ( / ^ ( .+ ?) (?: : ( \d + (?: - \d + ) ? ) ) ? $ / ) ;
29+ if ( ! match ) return { filePath : text , lineSuffix : "" } ;
30+ return { filePath : match [ 1 ] , lineSuffix : match [ 2 ] ?? "" } ;
31+ }
32+
33+ function resolveFilename ( filename : string , files : FileItem [ ] ) : FileItem | null {
34+ const matches = files . filter ( ( f ) => f . name === filename ) ;
35+ if ( matches . length === 1 ) return matches [ 0 ] ;
36+ return null ;
37+ }
38+
39+ function InlineFileLink ( {
40+ text,
41+ resolvedPath,
42+ } : {
43+ text : string ;
44+ resolvedPath ?: string ;
45+ } ) {
46+ const { filePath : rawPath , lineSuffix } = parseFilePath ( text ) ;
47+ const filePath = resolvedPath ?? rawPath ;
48+ const filename = rawPath . split ( "/" ) . pop ( ) ?? rawPath ;
49+ const taskId = useTaskStore ( ( s ) => s . selectedTaskId ) ;
50+ const repoPath = useCwd ( taskId ?? "" ) ;
51+ const openFileInSplit = usePanelLayoutStore ( ( s ) => s . openFileInSplit ) ;
52+ const requestScroll = usePendingScrollStore ( ( s ) => s . requestScroll ) ;
53+
54+ const handleClick = useCallback ( ( ) => {
55+ if ( ! taskId ) return ;
56+ const relativePath =
57+ repoPath && filePath . startsWith ( `${ repoPath } /` )
58+ ? filePath . slice ( repoPath . length + 1 )
59+ : filePath ;
60+ const absolutePath = repoPath
61+ ? `${ repoPath } /${ relativePath } `
62+ : relativePath ;
63+ if ( lineSuffix ) {
64+ const line = Number . parseInt ( lineSuffix . split ( "-" ) [ 0 ] , 10 ) ;
65+ if ( line > 0 ) requestScroll ( absolutePath , line ) ;
66+ }
67+ openFileInSplit ( taskId , relativePath , true ) ;
68+ } , [ taskId , filePath , lineSuffix , repoPath , openFileInSplit , requestScroll ] ) ;
69+
70+ const tooltipText = resolvedPath ?? text ;
71+
72+ return (
73+ < Tooltip content = { tooltipText } >
74+ < button
75+ type = "button"
76+ onClick = { taskId ? handleClick : undefined }
77+ disabled = { ! taskId }
78+ className = {
79+ taskId ? "cursor-pointer underline-offset-2 hover:underline" : ""
80+ }
81+ style = { {
82+ all : "unset" ,
83+ color : "var(--accent-11)" ,
84+ font : "inherit" ,
85+ display : "inline" ,
86+ } }
87+ >
88+ { filename }
89+ { lineSuffix ? `:${ lineSuffix } ` : "" }
90+ </ button >
91+ </ Tooltip >
92+ ) ;
93+ }
94+
95+ function BareFileLink ( { text } : { text : string } ) {
96+ const { filePath : bareFilename } = parseFilePath ( text ) ;
97+ const taskId = useTaskStore ( ( s ) => s . selectedTaskId ) ;
98+ const repoPath = useCwd ( taskId ?? "" ) ;
99+ const { files } = useRepoFiles ( repoPath ?? undefined ) ;
100+ const resolved = useMemo (
101+ ( ) => resolveFilename ( bareFilename , files ) ,
102+ [ bareFilename , files ] ,
103+ ) ;
104+
105+ if ( ! resolved ) {
106+ return (
107+ < Code size = "1" variant = "ghost" style = { { color : "var(--accent-11)" } } >
108+ { text }
109+ </ Code >
110+ ) ;
111+ }
112+ return < InlineFileLink text = { text } resolvedPath = { resolved . path } /> ;
113+ }
114+
115+ const agentComponents : Partial < Components > = {
116+ code : ( { children, className } ) => {
117+ const langMatch = className ?. match ( / l a n g u a g e - ( \w + ) / ) ;
118+ if ( langMatch ) {
119+ return (
120+ < HighlightedCode
121+ code = { String ( children ) . replace ( / \n $ / , "" ) }
122+ language = { langMatch [ 1 ] }
123+ />
124+ ) ;
125+ }
126+
127+ const text = String ( children ) . replace ( / \n $ / , "" ) ;
128+ if ( hasDirectoryPath ( text ) ) {
129+ return < InlineFileLink text = { text } /> ;
130+ }
131+
132+ if ( looksLikeBareFilename ( text ) ) {
133+ return < BareFileLink text = { text } /> ;
134+ }
135+
136+ return (
137+ < Code size = "1" variant = "ghost" style = { { color : "var(--accent-11)" } } >
138+ { children }
139+ </ Code >
140+ ) ;
141+ } ,
142+ } ;
6143
7144interface AgentMessageProps {
8145 content : string ;
@@ -21,7 +158,10 @@ export const AgentMessage = memo(function AgentMessage({
21158
22159 return (
23160 < Box className = "group/msg relative py-1 pl-3 [&>*:last-child]:mb-0" >
24- < MarkdownRenderer content = { content } />
161+ < MarkdownRenderer
162+ content = { content }
163+ componentsOverride = { agentComponents }
164+ />
25165 < Box className = "absolute top-1 right-1 opacity-0 transition-opacity group-hover/msg:opacity-100" >
26166 < Tooltip content = { copied ? "Copied!" : "Copy message" } >
27167 < IconButton
0 commit comments