diff --git a/desktop/src/components/chat/AssistantMessage.filepaths.test.tsx b/desktop/src/components/chat/AssistantMessage.filepaths.test.tsx index 58cb473f..54cd45ee 100644 --- a/desktop/src/components/chat/AssistantMessage.filepaths.test.tsx +++ b/desktop/src/components/chat/AssistantMessage.filepaths.test.tsx @@ -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) => (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() + 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() + 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() + 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() + 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() expect(screen.queryByRole('link', { name: 'desktop/src/lib/foo.ts:42' })).toBeNull() diff --git a/desktop/src/components/chat/AssistantMessage.tsx b/desktop/src/components/chat/AssistantMessage.tsx index dbf44d1e..cc62ac10 100644 --- a/desktop/src/components/chat/AssistantMessage.tsx +++ b/desktop/src/components/chat/AssistantMessage.tsx @@ -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): 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( diff --git a/desktop/src/components/chat/InlineVideoGallery.test.tsx b/desktop/src/components/chat/InlineVideoGallery.test.tsx index bb7d02c2..55484749 100644 --- a/desktop/src/components/chat/InlineVideoGallery.test.tsx +++ b/desktop/src/components/chat/InlineVideoGallery.test.tsx @@ -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//...). @@ -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( + , + ) + 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( + , + ) + 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( , diff --git a/desktop/src/components/chat/InlineVideoGallery.tsx b/desktop/src/components/chat/InlineVideoGallery.tsx index 39a3f68f..90e3b67e 100644 --- a/desktop/src/components/chat/InlineVideoGallery.tsx +++ b/desktop/src/components/chat/InlineVideoGallery.tsx @@ -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 (
{videos.map((video) => ( -
-
+ ))}
) } + +function VideoPreview({ video, sessionId, workDir }: { video: GalleryVideo; sessionId: string; workDir?: string }) { + const [failed, setFailed] = useState(false) + + if (failed) { + return ( + + ) + } + + return ( +
+
+ ) +} diff --git a/desktop/src/lib/assistantFileContext.test.ts b/desktop/src/lib/assistantFileContext.test.ts new file mode 100644 index 00000000..31b97f96 --- /dev/null +++ b/desktop/src/lib/assistantFileContext.test.ts @@ -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') + }) +}) diff --git a/desktop/src/lib/assistantFileContext.ts b/desktop/src/lib/assistantFileContext.ts new file mode 100644 index 00000000..c103094b --- /dev/null +++ b/desktop/src/lib/assistantFileContext.ts @@ -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(/^\.\//, '')}` +} diff --git a/desktop/src/lib/assistantOutputTargets.test.ts b/desktop/src/lib/assistantOutputTargets.test.ts index 99cf5ba2..6e392d1e 100644 --- a/desktop/src/lib/assistantOutputTargets.test.ts +++ b/desktop/src/lib/assistantOutputTargets.test.ts @@ -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') + }) +}) diff --git a/desktop/src/lib/assistantOutputTargets.ts b/desktop/src/lib/assistantOutputTargets.ts index d4652d4a..d61473f2 100644 --- a/desktop/src/lib/assistantOutputTargets.ts +++ b/desktop/src/lib/assistantOutputTargets.ts @@ -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, } }