fix(desktop): resolve output files against declared project roots

Keep explicit file identities across output cards and prose links, preserve
shell outputs without checkpoint evidence, and retain a file card when
video preview fails. Add cross-project path and opening regressions.
This commit is contained in:
程序员阿江(Relakkes)
2026-09-27 23:22:40 +08:00
parent e47e234987
commit fadaa775ac
8 changed files with 234 additions and 72 deletions
@@ -58,6 +58,11 @@ vi.mock('../../lib/clipboard', () => ({ copyTextToClipboard }))
vi.mock('@tauri-apps/plugin-shell', () => ({ open: vi.fn().mockResolvedValue(undefined) }))
const openPath = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
vi.mock('../../lib/desktopHost', () => ({
getDesktopHost: () => ({ shell: { openPath } }),
}))
vi.mock('../../i18n', () => ({
useTranslation: () => (k: string, v?: Record<string, string>) => (v?.target ? `${k}:${v.target}` : k),
}))
@@ -72,6 +77,7 @@ vi.mock('../../stores/settingsStore', () => ({
import { AssistantMessage } from './AssistantMessage'
afterEach(() => {
openPath.mockClear()
openBrowser.mockReset()
ensureTargets.mockReset().mockResolvedValue(undefined)
getTargetsForPath.mockReset().mockResolvedValue(openTargets)
@@ -95,6 +101,48 @@ describe('AssistantMessage file references', () => {
expect(openPreviewFn).toHaveBeenCalledWith('s1', 'src/app.ts', { line: 7 })
})
it('opens a source reference under the explicitly declared project root', () => {
render(<AssistantMessage sessionId="s1" content={'项目根目录是 `/other/promo/`:\n- `src/lib/shots.ts:7`'} />)
fireEvent.click(screen.getByRole('link', { name: 'src/lib/shots.ts:7' }))
expect(openPreviewFn).toHaveBeenCalledWith('s1', '/other/promo/src/lib/shots.ts', { line: 7 })
})
it('uses the declared root for prose context menus and copy path', async () => {
render(<AssistantMessage sessionId="s1" content={'项目根目录是 `/other/promo/`:\n- `public/audio/track.wav`'} />)
fireEvent.contextMenu(screen.getByRole('link', { name: 'public/audio/track.wav' }))
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument())
expect(getTargetsForPath).toHaveBeenCalledWith('/other/promo/public/audio/track.wav')
fireEvent.click(screen.getByRole('menuitem', { name: 'openWith.copyPath' }))
expect(copyTextToClipboard).toHaveBeenCalledWith('/other/promo/public/audio/track.wav')
})
it.each([undefined, [], ['/work/README.md']])('opens the screenshot audio from both card and prose with checkpoint %j', async (turnChangedFiles) => {
const content = [
'`/other/promo/out/movie.mp4`',
'项目根目录是 `/other/promo/`:',
'- `out/movie.mp4` — 成片',
'- `public/audio/track.wav` — 合成音轨',
'- `README.md` — 说明',
].join('\n\n')
const { container } = render(<AssistantMessage sessionId="s1" content={content} turnChangedFiles={turnChangedFiles} />)
expect(container.querySelectorAll('video')).toHaveLength(1)
expect(container.querySelector('video')).toHaveAttribute('src', 'http://127.0.0.1:4321/local-file/other/promo/out/movie.mp4')
fireEvent.click(screen.getByText('track.wav').closest('button')!)
await waitFor(() => expect(openPath).toHaveBeenCalledWith('/other/promo/public/audio/track.wav'))
openPath.mockClear()
fireEvent.click(screen.getByRole('link', { name: 'public/audio/track.wav' }))
await waitFor(() => expect(openPath).toHaveBeenCalledWith('/other/promo/public/audio/track.wav'))
})
it('keeps prose and card destinations equal when the project root is stated later', async () => {
render(<AssistantMessage sessionId="s1" content={'`track.wav`\n\n项目根目录是 `/other/promo/`'} />)
fireEvent.click(screen.getByRole('link', { name: 'track.wav' }))
await waitFor(() => expect(openPath).toHaveBeenLastCalledWith('/other/promo/track.wav'))
openPath.mockClear()
fireEvent.click(screen.getByText('track.wav', { selector: 'span' }).closest('button')!)
await waitFor(() => expect(openPath).toHaveBeenLastCalledWith('/other/promo/track.wav'))
})
it('does not linkify a bare path mid-stream', () => {
render(<AssistantMessage sessionId="s1" content={'越界在 desktop/src/lib/foo.ts:42'} isStreaming />)
expect(screen.queryByRole('link', { name: 'desktop/src/lib/foo.ts:42' })).toBeNull()
@@ -13,6 +13,7 @@ import { InlineVideoGallery } from './InlineVideoGallery'
import { AssistantOutputTargetCard } from './AssistantOutputTargetCard'
import { openPreviewLink } from '../../lib/openPreviewLink'
import { extractAssistantOutputTargets } from '../../lib/assistantOutputTargets'
import { resolveAssistantFileHref } from '@/lib/assistantFileContext'
import { createAssistantMarkdownImageResolver } from '../../lib/markdownImages'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
import { isManagedGeneratedImagePath } from '../../lib/attachmentImages'
@@ -52,11 +53,11 @@ export const AssistantMessage = memo(function AssistantMessage({
const handleLinkClick = useCallback(
(href: string, event: ReactMouseEvent<HTMLDivElement>): boolean => {
if (!sessionId) return false
const handled = openPreviewLink(href, sessionId)
const handled = openPreviewLink(resolveAssistantFileHref(href, content), sessionId)
if (handled) event.preventDefault()
return handled
},
[sessionId],
[content, sessionId],
)
// Right-clicking a reference in the prose opens the same menu the output cards
@@ -73,7 +74,7 @@ export const AssistantMessage = memo(function AssistantMessage({
event.preventDefault()
const anchor = link!.getBoundingClientRect()
void (async () => {
const items = await buildOpenWithMenuItemsForHref(href, {
const items = await buildOpenWithMenuItemsForHref(resolveAssistantFileHref(href, content), {
sessionId,
workDir,
// Cast t: useTranslation takes TranslationKey, the builder takes string.
@@ -83,7 +84,7 @@ export const AssistantMessage = memo(function AssistantMessage({
if (items.length > 0) setOpenWith({ items, anchor })
})()
},
[sessionId, t, workDir],
[content, sessionId, t, workDir],
)
const outputTargets = useMemo(
@@ -1,5 +1,5 @@
import '@testing-library/jest-dom'
import { render } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
// getServerBaseUrl backs the relative-path src (/preview-fs/<sessionId>/...).
@@ -7,6 +7,10 @@ vi.mock('../../lib/desktopRuntime', () => ({
getServerBaseUrl: () => 'http://127.0.0.1:4321',
}))
const openPreviewLink = vi.hoisted(() => vi.fn())
vi.mock('../../lib/openPreviewLink', () => ({ openPreviewLink }))
vi.mock('../../i18n', () => ({ useTranslation: () => (key: string) => key }))
import { InlineVideoGallery } from './InlineVideoGallery'
function videoSrcs(container: HTMLElement): string[] {
@@ -79,10 +83,29 @@ describe('InlineVideoGallery', () => {
)
expect(videoSrcs(container)).toEqual([
'http://127.0.0.1:4321/preview-fs/s1/out/demo.mp4',
'http://127.0.0.1:4321/local-file/w/out/demo.mp4',
])
})
it.each([undefined, []])('renders an external shell video without checkpoint evidence (%j)', (changedFiles) => {
const { container } = render(
<InlineVideoGallery text={'Saved to `/outside/render.mp4`'} sessionId="s1" workDir="/w" changedFiles={changedFiles} />,
)
expect(videoSrcs(container)).toEqual(['http://127.0.0.1:4321/local-file/outside/render.mp4'])
})
it.each(['outputs/render.mp4', '/outside/render.mp4'])('keeps a failed preview available as an actionable file card (%s)', (path) => {
const { container } = render(
<InlineVideoGallery text={`Saved to ${path}`} sessionId="s1" workDir="/w" />,
)
fireEvent.error(container.querySelector('video')!)
expect(container.querySelector('video')).toBeNull()
expect(screen.getByText('render.mp4')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: 'assistantOutputs.openAria' }))
expect(openPreviewLink).toHaveBeenCalledWith(path, 's1')
expect(screen.getByRole('button', { name: 'openWith.title' })).toBeVisible()
})
it('uses preload="metadata" and never autoplays', () => {
const { container } = render(
<InlineVideoGallery text={'clip at outputs/demo.mp4'} sessionId="s1" workDir="/w" />,
@@ -1,4 +1,5 @@
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { AssistantOutputTargetCard } from '@/components/chat/AssistantOutputTargetCard'
import { extractAssistantOutputTargets } from '../../lib/assistantOutputTargets'
import { isAbsoluteLocalPath, localFileUrl, previewFsUrl } from '../../lib/handlePreviewLink'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
@@ -32,6 +33,7 @@ function isMentionedAbsoluteVideo(text: string, filePath: string): boolean {
type GalleryVideo = {
src: string
name: string
href: string
}
type Props = {
@@ -87,7 +89,7 @@ export function InlineVideoGallery({ text, sessionId, workDir, changedFiles }: P
const src = localFileUrl(base, filePath)
if (seenSrc.has(src)) continue
seenSrc.add(src)
result.push({ src, name: filePath.split(/[\\/]/).pop() ?? '' })
result.push({ src, name: filePath.split(/[\\/]/).pop() ?? '', href: filePath })
}
for (const target of targets) {
@@ -99,7 +101,7 @@ export function InlineVideoGallery({ text, sessionId, workDir, changedFiles }: P
continue
}
seenSrc.add(src)
result.push({ src, name: relPath.split('/').pop() ?? '' })
result.push({ src, name: relPath.split('/').pop() ?? '', href: relPath })
}
return result
@@ -110,28 +112,48 @@ export function InlineVideoGallery({ text, sessionId, workDir, changedFiles }: P
return (
<div className="mt-3 space-y-2">
{videos.map((video) => (
<div
key={video.src}
className="overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] shadow-sm"
>
<video
src={video.src}
controls
preload="metadata"
playsInline
className="w-full rounded-t-xl bg-black"
style={{ maxHeight: 420 }}
onError={(e) => {
// Hide the whole container when the video can't be loaded.
(e.target as HTMLVideoElement).closest('div')!.style.display = 'none'
}}
/>
<div className="flex items-center gap-1.5 px-2.5 py-1.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[12px]">movie</span>
<span className="truncate">{video.name}</span>
</div>
</div>
<VideoPreview key={video.src} video={video} sessionId={sessionId!} workDir={workDir ?? undefined} />
))}
</div>
)
}
function VideoPreview({ video, sessionId, workDir }: { video: GalleryVideo; sessionId: string; workDir?: string }) {
const [failed, setFailed] = useState(false)
if (failed) {
return (
<AssistantOutputTargetCard
target={{
id: `file:${video.href}`,
kind: 'file',
title: video.name,
href: video.href,
normalizedPath: video.href,
confidence: 'high',
source: 'plain-path',
}}
sessionId={sessionId}
workDir={workDir}
/>
)
}
return (
<div className="overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] shadow-sm">
<video
src={video.src}
controls
preload="metadata"
playsInline
className="w-full rounded-t-xl bg-black"
style={{ maxHeight: 420 }}
onError={() => setFailed(true)}
/>
<div className="flex items-center gap-1.5 px-2.5 py-1.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[12px]">movie</span>
<span className="truncate">{video.name}</span>
</div>
</div>
)
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { resolveAssistantFileHref } from './assistantFileContext'
describe('resolveAssistantFileHref', () => {
it('resolves only explicit directory declarations including Windows and spaces', () => {
expect(resolveAssistantFileHref('public/audio/track.wav', '项目根目录是 `/external/my project/`:')).toBe('/external/my project/public/audio/track.wav')
expect(resolveAssistantFileHref('out/movie.mp4', 'Project root: `D:\\work\\demo\\`')).toBe('D:/work/demo/out/movie.mp4')
expect(resolveAssistantFileHref('report.pdf', '参考 `/external/docs/`')).toBe('report.pdf')
})
it('ignores declarations inside fences and preserves absolute paths and remote URLs', () => {
expect(resolveAssistantFileHref('report.pdf', '```txt\n项目根目录是 `/example/`\n```')).toBe('report.pdf')
expect(resolveAssistantFileHref('/other/report.pdf', '输出目录:`/output/`')).toBe('/other/report.pdf')
expect(resolveAssistantFileHref('https://example.com/report.pdf', '输出目录:`/output/`')).toBe('https://example.com/report.pdf')
})
it('declines multiple roots and applies a single declaration to the whole reply', () => {
const content = '项目根目录是 `/one/`\nreport.pdf\n项目根目录是 `/two/`\nreport.pdf'
expect(resolveAssistantFileHref('report.pdf', content)).toBe('report.pdf')
expect(resolveAssistantFileHref('report.pdf', 'report.pdf\n项目根目录是 `/one/`')).toBe('/one/report.pdf')
})
})
+22
View File
@@ -0,0 +1,22 @@
import { parseFilePathRef } from './filePathBoundary'
/** Only an explicit project/output directory declaration establishes a prose base. */
function declaredDirectories(content: string): string[] {
const withoutFences = content.replace(/```[^]*?```|~~~[^]*?~~~/g, (block) => ' '.repeat(block.length))
const pattern = /(?:项目根目录|项目目录|输出目录|project\s+root(?:\s+directory)?|output\s+directory)\s*(?:是|为|在|is|at|[::])\s*[::]?\s*(?:`([^`\n]+)`|((?:\/|[A-Za-z]:[\\/])[^\s,。;:]+))/giu
return [...withoutFences.matchAll(pattern)].flatMap((match) => {
const path = (match[1] ?? match[2] ?? '').trim().replaceAll('\\', '/')
return /^(?:\/|[A-Za-z]:\/)/.test(path) && !/[\r\n]/.test(path)
? [path.replace(/\/+$/, '')]
: []
})
}
/** A single declared root applies to the whole reply, including prose and cards. */
export function resolveAssistantFileHref(href: string, content: string): string {
if (/^(?:[a-z][a-z0-9+.-]*:|\/|\\\\|~[\\/])/i.test(href) || !parseFilePathRef(href)) return href
const directories = declaredDirectories(content)
if (directories.length === 0) return href
if (new Set(directories).size !== 1) return href
return `${directories[0]}/${href.replace(/^\.\//, '')}`
}
+39 -5
View File
@@ -81,10 +81,10 @@ describe('extractAssistantOutputTargets', () => {
expect(targets.some((target) => target.normalizedPath === 'src/main.ts')).toBe(false)
})
it('rejects a video path outside the active workspace (sandbox)', () => {
it('preserves explicit video identity outside the workspace for the backend route', () => {
const targets = extractAssistantOutputTargets('[bad](/etc/x.mp4)', { workDir })
expect(targets).toEqual([])
expect(targets).toMatchObject([{ href: '/etc/x.mp4', normalizedPath: '/etc/x.mp4' }])
})
it('normalizes markdown destinations with angle brackets, spaces, and line suffixes', () => {
@@ -197,14 +197,15 @@ describe('extractAssistantOutputTargets', () => {
expect(targets).toHaveLength(1)
})
it('rejects paths outside the active workspace', () => {
it('preserves paths outside the active workspace', () => {
const targets = extractAssistantOutputTargets(
'[secret](/Users/nanmi/private/secret.html) [ok](/Users/nanmi/project/demo/public/index.html)',
{ workDir },
)
expect(targets).toHaveLength(1)
expect(targets[0]).toMatchObject({
expect(targets).toHaveLength(2)
expect(targets[0]?.normalizedPath).toBe('/Users/nanmi/private/secret.html')
expect(targets[1]).toMatchObject({
kind: 'local-html',
normalizedPath: 'public/index.html',
})
@@ -528,3 +529,36 @@ describe('extractAssistantOutputTargets with changedFiles reconciliation', () =>
expect(targets).toHaveLength(0)
})
})
describe('explicit output path identity', () => {
const root = '/Users/nanmi/workspace/myself_code/cchaha-promo'
const content = `\`${root}/out/cc-haha-promo.mp4\`
顺带,项目根目录是 \`${root}/\`:
- \`out/cc-haha-promo.mp4\` — 成片
- \`public/audio/track.wav\` — 合成音轨
- \`src/lib/shots.ts\` — 分镜
- \`README.md\` — 说明`
it.each([undefined, [], ['/session/README.md']])('preserves screenshot deliverables with checkpoint %j', (changedFiles) => {
const targets = extractAssistantOutputTargets(content, { workDir: '/session', changedFiles })
expect(targets.map((target) => target.href)).toEqual([
`${root}/out/cc-haha-promo.mp4`, `${root}/public/audio/track.wav`, `${root}/README.md`,
...(changedFiles?.length ? ['README.md'] : []),
])
})
it('does not replace an explicit absolute file with another same-named checkpoint file', () => {
const targets = extractAssistantOutputTargets('报告 `/external/report.pdf`', {
workDir: '/session', changedFiles: ['/session/report.pdf'], includeChangedFileFallback: false,
})
expect(targets.map((target) => target.href)).toEqual(['/external/report.pdf'])
})
})
describe('canonical output deduplication', () => {
it.each([undefined, [], ['/work/report.pdf']])('deduplicates absolute and relative identity with checkpoint %j', (changedFiles) => {
const targets = extractAssistantOutputTargets('`/work/report.pdf` and `report.pdf`', { workDir: '/work', changedFiles })
expect(targets).toHaveLength(1)
expect(targets[0]?.href).toBe('/work/report.pdf')
})
})
+27 -37
View File
@@ -1,3 +1,4 @@
import { resolveAssistantFileHref } from './assistantFileContext'
import { trimTrailingPunctuation } from './urlBoundary'
import { isLinkableFilePath, splitTextByFilePaths } from './filePathBoundary'
import { isGeneratedArtifactFile, isOutputResourceFile, isShellProducedDeliverable } from './fileCapabilities'
@@ -39,7 +40,7 @@ export type ExtractAssistantOutputTargetOptions = {
* path. A mentioned *document deliverable* survives an unmatched lookup, because
* the checkpoint cannot see files a shell command wrote.
* Localhost URLs are unaffected. Omitted → fall back to text-only behavior;
* an empty array confirms the turn changed no files, so file targets are dropped.
* an empty array provides no evidence for source-like mentions, but cannot disprove shell outputs.
*/
changedFiles?: string[]
/**
@@ -117,7 +118,7 @@ export function extractAssistantOutputTargets(
for (const match of markdownLinks) {
const authoredTitle = match.title
const href = match.href
const href = resolveAssistantFileHref(match.href, content)
const localhostTarget = toLocalhostTarget(href)
const fileTarget = toWorkspaceFileTarget(href, workDir)
@@ -222,7 +223,7 @@ export function extractAssistantOutputTargets(
continue
}
const href = segment.ref.path
const href = resolveAssistantFileHref(segment.ref.path, content)
const fileTarget = toWorkspaceFileTarget(href, workDir)
if (!fileTarget) {
@@ -320,9 +321,9 @@ function correctedChangedFilePath(changedFile: string, workDir: string | null):
/**
* Re-anchor file chips onto the turn's real changed files. A mentioned file that
* matches a changed file (by exact relative-path suffix, else by basename) is
* rewritten to that real path; a mentioned source file with no match is dropped
* so we never render a chip that opens "file does not exist". A document
* deliverable is the documented exception — see the comment at the drop.
* rewritten to that real path. Uncorroborated source-like relative mentions are
* omitted; explicit absolute identities and document/media outputs are retained
* because shell writes are invisible to the checkpoint.
* Localhost URLs pass through untouched.
*/
function reconcileTargetsWithChangedFiles(
@@ -345,30 +346,25 @@ function reconcileTargetsWithChangedFiles(
}
const mentioned = target.normalizedPath ?? target.href
const match = matchChangedFile(mentioned, changedFiles)
// A deliverable produced by a shell command is never in `changedFiles` — the
// checkpoint only records the file-editing tools. When the turn *also* edited
// a tracked file, that list is non-empty and every unmatched mention was being
// dropped, so `Write plan.md` plus `python make_report.py` lost the report.
//
// Two things keep this from resurrecting the mentions reconciliation exists to
// drop. It needs a non-empty list, which is this function's documented "the
// turn changed nothing" signal; and it needs a format nothing reads as source,
// so "I'm about to look at notes.md" stays a mention rather than becoming a
// deliverable.
//
// The trade: an unmatched path cannot be corrected, so such a card may point
// at a file that is not there. Bounded to a closed set of document formats.
// Source files keep the old behavior, which is what this was written for.
if (!match && !(changedFiles.length > 0 && isShellProducedDeliverable(mentioned))) {
// An authored absolute path (including an explicit prose root) is identity,
// not a basename hint. A checkpoint cannot disprove a shell-created output.
const explicitPath = isAbsoluteFilePath(target.href)
const match = explicitPath ? null : matchChangedFile(mentioned, changedFiles)
// Checkpoints record editing tools, not arbitrary shell output. Explicit
// identities and document/media deliverables survive absent evidence; bare
// source-like mentions still need corroboration to avoid resource-strip noise.
if (!match && !explicitPath && !isShellProducedDeliverable(mentioned)
&& target.kind !== 'video' && !/\.(?:mp3|wav|m4a|flac|aac|ogg|opus)$/i.test(mentioned)) {
continue
}
const corrected = match
? correctedChangedFilePath(match, workDir)
: correctedChangedFilePath(anchorToChangedDirectory(mentioned, changedFiles), workDir)
const corrected = explicitPath
? resolveFilePath(target.href)
: match
? correctedChangedFilePath(match, workDir)
: correctedChangedFilePath(anchorToChangedDirectory(mentioned, changedFiles), workDir)
const key = `${target.kind}:${corrected}`
const key = `${target.kind}:${resolveFilePath(corrected, workDir ?? '/')}`
if (seen.has(key)) {
continue
}
@@ -376,6 +372,7 @@ function reconcileTargetsWithChangedFiles(
out.push({
...target,
id: createId(target.kind, corrected),
href: corrected,
normalizedPath: corrected,
subtitle: corrected,
@@ -394,7 +391,7 @@ function reconcileTargetsWithChangedFiles(
const corrected = correctedChangedFilePath(changedFile, workDir)
const kind = classifyFileTarget(corrected) ?? 'file'
const key = `${kind}:${corrected}`
const key = `${kind}:${resolveFilePath(corrected, workDir ?? '/')}`
if (seen.has(key)) continue
seen.add(key)
@@ -459,19 +456,12 @@ function toWorkspaceFileTarget(candidate: string, workDir: string | null): FileT
}
if (isAbsoluteFilePath(candidate)) {
if (!workDir) {
return null
}
const absoluteCandidate = resolveFilePath(candidate)
if (!isWithinWorkDir(absoluteCandidate, workDir)) {
return null
}
return {
kind,
normalizedPath: relativeFilePath(workDir, absoluteCandidate),
normalizedPath: workDir && isWithinWorkDir(absoluteCandidate, workDir)
? relativeFilePath(workDir, absoluteCandidate)
: absoluteCandidate,
}
}