mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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(/^\.\//, '')}`
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user