@@ -106,6 +106,79 @@ afterEach(() => {
106106} )
107107
108108describe ( 'AssistantMessage file references' , ( ) => {
109+ it . each ( [
110+ [ '[open](/work/My%20Notes.md)' , '/work/My Notes.md' ] ,
111+ [ '[open](/work/literal%2520name.md)' , '/work/literal%20name.md' ] ,
112+ [ '输出目录:`/other/QA%20root`\n[open](My%20Notes.md)' , '/other/QA%20root/My Notes.md' ] ,
113+ ] ) ( 'opens the reconciled card and prose at the same parsed file for %s' , async ( content , path ) => {
114+ // The server confirms only the actual disk path, so an encoded URI cannot
115+ // accidentally get a card just because a permissive mock says it exists.
116+ statWorkspaceFiles . mockImplementation ( async ( _sessionId : string , paths : string [ ] ) => ( {
117+ files : paths . map ( ( candidate ) => ( {
118+ path : candidate , state : candidate === path ? 'file' : 'missing' , mtimeMs : Date . now ( ) ,
119+ } ) ) ,
120+ } ) )
121+ const view = render ( < AssistantMessage
122+ sessionId = "s1" content = { content } turnChangedFiles = { [ ] } turnOutputEvidence = { shellTurn }
123+ /> )
124+ fireEvent . click ( await screen . findByRole ( 'button' , { name : 'assistantOutputs.openAria' } ) )
125+ await waitFor ( ( ) => expect ( openPreviewFn ) . toHaveBeenCalledWith ( 's1' , path , { } ) )
126+ openPreviewFn . mockClear ( )
127+ fireEvent . click ( screen . getByRole ( 'link' , { name : 'open' } ) )
128+ await waitFor ( ( ) => expect ( openPreviewFn ) . toHaveBeenCalledWith ( 's1' , path , { } ) )
129+ view . unmount ( )
130+ resetWorkspaceFileStatsForTests ( )
131+ resetDiskListingCacheForTests ( )
132+ openPreviewFn . mockClear ( )
133+
134+ // The exact changed-file match must keep the card when no unseen writes
135+ // can explain an unmatched output.
136+ render ( < AssistantMessage
137+ sessionId = "s1" content = { content } turnChangedFiles = { [ path ] } turnOutputEvidence = { { unlistedWrites : false } }
138+ /> )
139+ fireEvent . click ( await screen . findByRole ( 'button' , { name : 'assistantOutputs.openAria' } ) )
140+ await waitFor ( ( ) => expect ( openPreviewFn ) . toHaveBeenCalledWith ( 's1' , path , { } ) )
141+ } )
142+
143+ // Native QA: the renderer had decoded the Markdown destination into data-file-path,
144+ // then the click router decoded that filesystem path a second time.
145+ it . each ( [
146+ [ '[open](docs/literal%2520name.md)' , 'docs/literal%20name.md' , { } ] ,
147+ [ '[open](/work/literal%2525name.md:7:3)' , '/work/literal%25name.md' , { line : 7 , column : 3 } ] ,
148+ [ '[open](C:/QA/literal%2523name.md#L9)' , 'C:/QA/literal%23name.md' , { line : 9 } ] ,
149+ [ '[open](file:///work/literal%252Fname.md)' , '/work/literal%2Fname.md' , { } ] ,
150+ [ '[open](docs/My%20Notes.md:3)' , 'docs/My Notes.md' , { line : 3 } ] ,
151+ [ '[open](file:///work/My%20Notes.md)' , '/work/My Notes.md' , { } ] ,
152+ [ '[open](file:///C:/QA/My%20Notes.md#L9)' , 'C:/QA/My Notes.md' , { line : 9 } ] ,
153+ [ '[open](docs/name%23hash.md#L2)' , 'docs/name#hash.md' , { line : 2 } ] ,
154+ [ '[open](docs/100%.md)' , 'docs/100%.md' , { } ] ,
155+ ] ) ( 'opens the exact disk path from rendered Markdown %s' , async ( content , path , reveal ) => {
156+ render ( < AssistantMessage sessionId = "s1" content = { content } /> )
157+ fireEvent . click ( screen . getByRole ( 'link' , { name : 'open' } ) )
158+ await waitFor ( ( ) => expect ( openPreviewFn ) . toHaveBeenCalledWith ( 's1' , path , reveal ) )
159+ expect ( statWorkspaceFiles ) . toHaveBeenCalledWith ( 's1' , expect . arrayContaining ( [ path ] ) )
160+ } )
161+
162+ it . each ( [ '%20' , '%25' , '%23' , '%2F' ] ) (
163+ 'opens literal %s names in bare and inline-code references under the declared root' , async ( percent ) => {
164+ const path = `docs/literal${ percent } name.md`
165+ const content = `输出目录:\`/work/QA%20root\`\n\n${ path } :7\n\n\`${ path } :9:3\``
166+ render ( < AssistantMessage sessionId = "s1" content = { content } /> )
167+ fireEvent . click ( await screen . findByRole ( 'link' , { name : `${ path } :7` } ) )
168+ expect ( openPreviewFn ) . toHaveBeenCalledWith ( 's1' , `/work/QA%20root/${ path } ` , { line : 7 } )
169+ fireEvent . click ( await screen . findByRole ( 'link' , { name : `${ path } :9:3` } ) )
170+ expect ( openPreviewFn ) . toHaveBeenCalledWith ( 's1' , `/work/QA%20root/${ path } ` , { line : 9 , column : 3 } )
171+ } ,
172+ )
173+
174+ it ( 'uses the same literal percent filename in a Markdown context menu' , async ( ) => {
175+ render ( < AssistantMessage sessionId = "s1" content = { '[open](docs/literal%2520name.md:7)' } /> )
176+ fireEvent . contextMenu ( screen . getByRole ( 'link' , { name : 'open' } ) )
177+ await waitFor ( ( ) => expect ( getTargetsForPath ) . toHaveBeenCalledWith ( '/work/docs/literal%20name.md' ) )
178+ fireEvent . click ( await screen . findByRole ( 'menuitem' , { name : 'openWith.copyPath' } ) )
179+ expect ( copyTextToClipboard ) . toHaveBeenCalledWith ( '/work/docs/literal%20name.md' )
180+ } )
181+
109182 it ( 'opens the code view at the referenced line' , async ( ) => {
110183 // #1146, and the contract src/constants/prompts.ts already asks the model for.
111184 render ( < AssistantMessage sessionId = "s1" content = { '越界在 desktop/src/lib/foo.ts:42' } isStreaming = { false } /> )
0 commit comments