mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
fix(desktop): show files as outputs and links only on evidence (#1444)
A reply that summarised commits quoted `开题报告2.docx` and `D:/资料/测试文档1.docx` from their messages. The turn only ran `git log`, yet three Word cards appeared under it and every name was a link, all opening "file not found". Being mentioned is not being produced, and a guessed path is not a file. Output cards now need the turn to have produced the file. A changed file in the turn checkpoint still counts. A turn whose checkpoint lists no unverified write source (only read-only shell, no MCP writes) has no unseen output, so other mentions are dropped without touching the disk. Otherwise a mention is shown only once the disk reports it exists and was modified since the turn began, compared on the server's clock via a new checkpoint `startedAt`. Cards wait while the checkpoint loads. File links guessed from code spans and prose render as plain code until the workspace confirms the file exists, and stay plain when it does not. Links the reply wrote in Markdown are kept as written. Clicking a file that is gone shows the existing "could not open" toast instead of a tab. Both checks share one stat channel: GET /api/sessions/:id/workspace/stat (at most 20 paths, same boundary as the file route, the workspace root resolved once per request). The client batches every message that mounts in a tick into one request, dedupes in-flight paths and caches answers for 10 s, so a remounted message renders its final state at once and a link just verified opens without another round trip.
This commit is contained in:
@@ -355,6 +355,12 @@ export type WorkspaceTreeEntry = {
|
||||
isDirectory: boolean
|
||||
}
|
||||
|
||||
export type WorkspaceFileStat = {
|
||||
path: string
|
||||
state: 'file' | 'missing' | 'unavailable'
|
||||
mtimeMs?: number
|
||||
}
|
||||
|
||||
export type WorkspaceTreeResult = {
|
||||
state: 'ok' | 'missing' | 'error'
|
||||
path: string
|
||||
@@ -383,6 +389,8 @@ export type SessionTurnCheckpoint = {
|
||||
workDir?: string
|
||||
restoreAvailable?: boolean
|
||||
unverifiedChangeSources?: string[]
|
||||
/** When the turn's prompt was recorded, epoch ms on the server's clock. */
|
||||
startedAt?: number
|
||||
}
|
||||
|
||||
export type SessionTurnCheckpointsResponse = {
|
||||
@@ -620,6 +628,12 @@ export const sessionsApi = {
|
||||
return api.get<WorkspaceSearchResult>(`/api/sessions/${sessionId}/workspace/search?${params}`, { signal })
|
||||
},
|
||||
|
||||
statWorkspaceFiles(sessionId: string, workspacePaths: string[], signal?: AbortSignal) {
|
||||
const query = new URLSearchParams()
|
||||
for (const workspacePath of workspacePaths) query.append('path', workspacePath)
|
||||
return api.get<{ files: WorkspaceFileStat[] }>(`/api/sessions/${sessionId}/workspace/stat?${query}`, { signal })
|
||||
},
|
||||
|
||||
getWorkspaceFile(sessionId: string, workspacePath: string, signal?: AbortSignal) {
|
||||
return api.get<WorkspaceReadFileResult>(buildWorkspacePath(sessionId, 'file', workspacePath), { signal })
|
||||
},
|
||||
|
||||
@@ -50,8 +50,19 @@ vi.mock('../../stores/workspaceContentStore', () => {
|
||||
|
||||
const getWorkspaceFile = vi.hoisted(() => vi.fn().mockResolvedValue({ state: 'ok', content: 'file body' }))
|
||||
const getWorkspaceTree = vi.hoisted(() => vi.fn().mockResolvedValue({ state: 'missing', path: '', entries: [] }))
|
||||
// The turn ran a shell command, so a named file may be its output.
|
||||
const shellTurn = { unlistedWrites: true }
|
||||
// Unless a test says otherwise, every file a card names was just written.
|
||||
const writtenNow = async (_sessionId: string, paths: string[]) => ({
|
||||
files: paths.map((path) => ({ path, state: 'file' as const, mtimeMs: Date.now() })),
|
||||
})
|
||||
const nothingOnDisk = async (_sessionId: string, paths: string[]) => ({
|
||||
files: paths.map((path) => ({ path, state: 'missing' as const })),
|
||||
})
|
||||
const statWorkspaceFiles = vi.hoisted(() => vi.fn())
|
||||
statWorkspaceFiles.mockImplementation(writtenNow)
|
||||
vi.mock('../../api/sessions', () => ({
|
||||
sessionsApi: { getWorkspaceFile, getWorkspaceTree },
|
||||
sessionsApi: { getWorkspaceFile, getWorkspaceTree, statWorkspaceFiles },
|
||||
}))
|
||||
|
||||
const copyTextToClipboard = vi.hoisted(() => vi.fn().mockResolvedValue(true))
|
||||
@@ -77,6 +88,7 @@ vi.mock('../../stores/settingsStore', () => ({
|
||||
|
||||
import { AssistantMessage } from './AssistantMessage'
|
||||
import { resetDiskListingCacheForTests } from '../../hooks/useDiskConfirmedTargets'
|
||||
import { resetWorkspaceFileStatsForTests } from '../../lib/workspaceFileStats'
|
||||
|
||||
afterEach(() => {
|
||||
openPath.mockClear()
|
||||
@@ -88,32 +100,36 @@ afterEach(() => {
|
||||
copyTextToClipboard.mockReset().mockResolvedValue(true)
|
||||
getWorkspaceFile.mockReset().mockResolvedValue({ state: 'ok', content: 'file body' })
|
||||
getWorkspaceTree.mockReset().mockResolvedValue({ state: 'missing', path: '', entries: [] })
|
||||
statWorkspaceFiles.mockReset().mockImplementation(writtenNow)
|
||||
resetDiskListingCacheForTests()
|
||||
resetWorkspaceFileStatsForTests()
|
||||
})
|
||||
|
||||
describe('AssistantMessage file references', () => {
|
||||
it('opens the code view at the referenced line', () => {
|
||||
it('opens the code view at the referenced line', async () => {
|
||||
// #1146, and the contract src/constants/prompts.ts already asks the model for.
|
||||
render(<AssistantMessage sessionId="s1" content={'越界在 desktop/src/lib/foo.ts:42'} isStreaming={false} />)
|
||||
fireEvent.click(screen.getByRole('link', { name: 'desktop/src/lib/foo.ts:42' }))
|
||||
fireEvent.click(await screen.findByRole('link', { name: 'desktop/src/lib/foo.ts:42' }))
|
||||
expect(openPreviewFn).toHaveBeenCalledWith('s1', 'desktop/src/lib/foo.ts', { line: 42 })
|
||||
})
|
||||
|
||||
it('opens an inline-code reference through the same route', () => {
|
||||
it('opens an inline-code reference through the same route', async () => {
|
||||
render(<AssistantMessage sessionId="s1" content={'改 `src/app.ts:7`'} isStreaming={false} />)
|
||||
fireEvent.click(screen.getByRole('link', { name: 'src/app.ts:7' }))
|
||||
fireEvent.click(await screen.findByRole('link', { name: 'src/app.ts:7' }))
|
||||
expect(openPreviewFn).toHaveBeenCalledWith('s1', 'src/app.ts', { line: 7 })
|
||||
})
|
||||
|
||||
it('opens a source reference under the explicitly declared project root', () => {
|
||||
it('opens a source reference under the explicitly declared project root', async () => {
|
||||
render(<AssistantMessage sessionId="s1" content={'项目根目录是 `/other/promo/`:\n- `src/lib/shots.ts:7`'} />)
|
||||
fireEvent.click(screen.getByRole('link', { name: 'src/lib/shots.ts:7' }))
|
||||
fireEvent.click(await screen.findByRole('link', { name: 'src/lib/shots.ts:7' }))
|
||||
// The disk was asked about the file the click opens, not the text as written.
|
||||
expect(statWorkspaceFiles).toHaveBeenCalledWith('s1', expect.arrayContaining(['/other/promo/src/lib/shots.ts']))
|
||||
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' }))
|
||||
fireEvent.contextMenu(await screen.findByRole('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' }))
|
||||
@@ -128,13 +144,14 @@ describe('AssistantMessage file references', () => {
|
||||
'- `public/audio/track.wav` — 合成音轨',
|
||||
'- `README.md` — 说明',
|
||||
].join('\n\n')
|
||||
const { container } = render(<AssistantMessage sessionId="s1" content={content} turnChangedFiles={turnChangedFiles} />)
|
||||
const { container } = render(<AssistantMessage sessionId="s1" content={content} turnChangedFiles={turnChangedFiles} turnOutputEvidence={shellTurn} />)
|
||||
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')!)
|
||||
// Nothing in the checkpoint wrote it, so the card waits for the disk.
|
||||
fireEvent.click((await screen.findByText('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' }))
|
||||
fireEvent.click(await screen.findByRole('link', { name: 'public/audio/track.wav' }))
|
||||
await waitFor(() => expect(openPath).toHaveBeenCalledWith('/other/promo/public/audio/track.wav'))
|
||||
})
|
||||
|
||||
@@ -144,7 +161,7 @@ describe('AssistantMessage file references', () => {
|
||||
path: '',
|
||||
entries: [{ name: '报告v2.docx', path: '报告v2.docx', isDirectory: false }],
|
||||
})
|
||||
render(<AssistantMessage sessionId="s1" content={'已生成报告v2.docx'} turnChangedFiles={[]} />)
|
||||
render(<AssistantMessage sessionId="s1" content={'已生成报告v2.docx'} turnChangedFiles={[]} turnOutputEvidence={shellTurn} />)
|
||||
|
||||
const card = await screen.findByText('报告v2.docx', { selector: 'span' })
|
||||
fireEvent.click(card.closest('button')!)
|
||||
@@ -158,7 +175,7 @@ describe('AssistantMessage file references', () => {
|
||||
path: '',
|
||||
entries: [{ name: '开题报告.docx', path: '开题报告.docx', isDirectory: false }],
|
||||
})
|
||||
render(<AssistantMessage sessionId="s1" content={'已生成 开题报告.docx。只支持后缀为.docx的文件'} turnChangedFiles={[]} />)
|
||||
render(<AssistantMessage sessionId="s1" content={'已生成 开题报告.docx。只支持后缀为.docx的文件'} turnChangedFiles={[]} turnOutputEvidence={shellTurn} />)
|
||||
|
||||
expect(await screen.findByText('开题报告.docx', { selector: 'span' })).toBeInTheDocument()
|
||||
expect(screen.queryByText(/后缀为\.docx/, { selector: 'span' })).not.toBeInTheDocument()
|
||||
@@ -166,14 +183,137 @@ describe('AssistantMessage file references', () => {
|
||||
})
|
||||
|
||||
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' }))
|
||||
render(<AssistantMessage sessionId="s1" content={'`track.wav`\n\n项目根目录是 `/other/promo/`'} turnOutputEvidence={shellTurn} />)
|
||||
fireEvent.click(await screen.findByRole('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')!)
|
||||
fireEvent.click((await screen.findByText('track.wav', { selector: 'span' })).closest('button')!)
|
||||
await waitFor(() => expect(openPath).toHaveBeenLastCalledWith('/other/promo/track.wav'))
|
||||
})
|
||||
|
||||
describe('a reply that only quotes file names', () => {
|
||||
// Summarising commits, the reply quoted the names from their messages. The
|
||||
// turn ran only `git log`, yet three Word cards appeared and opened "file not
|
||||
// found" — read by the user as the agent having made Word documents.
|
||||
const content = '正文和行内代码里的 `开题报告2.docx`、`D:/资料/测试文档1.docx` 被截成 1.docx 的问题'
|
||||
|
||||
it('shows neither a card nor a link when the turn wrote nothing and the files are nowhere', async () => {
|
||||
statWorkspaceFiles.mockImplementation(nothingOnDisk)
|
||||
render(<AssistantMessage
|
||||
sessionId="s1"
|
||||
content={content}
|
||||
turnChangedFiles={[]}
|
||||
turnOutputEvidence={{ unlistedWrites: false, startedAt: Date.now() }}
|
||||
/>)
|
||||
|
||||
await waitFor(() => expect(statWorkspaceFiles).toHaveBeenCalled())
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
// The names stay readable as the code they were written as.
|
||||
expect(screen.getByText('开题报告2.docx', { selector: 'code' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: '开题报告2.docx' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'D:/资料/测试文档1.docx' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: '1.docx' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('开题报告2.docx', { selector: 'span' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('测试文档1.docx', { selector: 'span' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('1.docx', { selector: 'span' })).not.toBeInTheDocument()
|
||||
// Only the prose links asked; the card decision needed no disk at all.
|
||||
expect(statWorkspaceFiles.mock.calls.flatMap(([, paths]) => paths).sort())
|
||||
.toEqual(['1.docx', 'D:/资料/测试文档1.docx', '开题报告2.docx'])
|
||||
})
|
||||
|
||||
it('waits for the checkpoint instead of showing a card it may then take back', async () => {
|
||||
const { rerender } = render(<AssistantMessage sessionId="s1" content={'报告已生成:`out/report.docx`'} />)
|
||||
|
||||
// The prose link may confirm its file meanwhile; the card still waits.
|
||||
expect(await screen.findByRole('link', { name: 'out/report.docx' })).toBeInTheDocument()
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(screen.queryByText('report.docx', { selector: 'span' })).not.toBeInTheDocument()
|
||||
|
||||
rerender(<AssistantMessage
|
||||
sessionId="s1"
|
||||
content={'报告已生成:`out/report.docx`'}
|
||||
turnChangedFiles={[]}
|
||||
turnOutputEvidence={{ unlistedWrites: true, startedAt: Date.now() - 1_000 }}
|
||||
/>)
|
||||
expect(await screen.findByText('report.docx', { selector: 'span' })).toBeInTheDocument()
|
||||
expect(statWorkspaceFiles).toHaveBeenCalledWith('s1', ['out/report.docx'])
|
||||
})
|
||||
|
||||
it('shows no card for files that predate a turn that also ran a shell command', async () => {
|
||||
const startedAt = Date.now()
|
||||
statWorkspaceFiles.mockImplementation(async (_sessionId: string, paths: string[]) => ({
|
||||
files: paths.map((path) => path === '开题报告2.docx'
|
||||
? { path, state: 'file' as const, mtimeMs: startedAt - 86_400_000 }
|
||||
: { path, state: 'missing' as const }),
|
||||
}))
|
||||
render(<AssistantMessage
|
||||
sessionId="s1"
|
||||
content={content}
|
||||
turnChangedFiles={[]}
|
||||
turnOutputEvidence={{ unlistedWrites: true, startedAt }}
|
||||
/>)
|
||||
|
||||
await waitFor(() => expect(statWorkspaceFiles).toHaveBeenCalled())
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(screen.queryByText('开题报告2.docx', { selector: 'span' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('1.docx', { selector: 'span' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a reference guessed from code or prose', () => {
|
||||
it('links only once the file is known to exist', async () => {
|
||||
statWorkspaceFiles.mockImplementation(async (_sessionId: string, paths: string[]) => ({
|
||||
files: paths.map((path) => path === 'src/app.ts'
|
||||
? { path, state: 'file' as const, mtimeMs: 0 }
|
||||
: { path, state: 'missing' as const }),
|
||||
}))
|
||||
render(<AssistantMessage sessionId="s1" content={'改了 `src/app.ts:7`,`docs/gone.md` 已删除'} />)
|
||||
|
||||
// Plain code until the disk answers, so nothing looks openable on a guess.
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('src/app.ts:7', { selector: 'code' })).toBeInTheDocument()
|
||||
|
||||
expect(await screen.findByRole('link', { name: 'src/app.ts:7' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'docs/gone.md' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('docs/gone.md', { selector: 'code' })).toBeInTheDocument()
|
||||
expect(statWorkspaceFiles).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps a link the reply wrote in Markdown, whether or not the file is there', async () => {
|
||||
statWorkspaceFiles.mockImplementation(nothingOnDisk)
|
||||
render(<AssistantMessage sessionId="s1" content={'见 [说明](docs/gone.md)'} />)
|
||||
|
||||
expect(screen.getByRole('link', { name: '说明' })).toBeInTheDocument()
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(screen.getByRole('link', { name: '说明' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('answers a remounted message from memory, without a plain-code flash', async () => {
|
||||
const first = render(<AssistantMessage sessionId="s1" content={'改了 `src/app.ts:7`'} />)
|
||||
await first.findByRole('link', { name: 'src/app.ts:7' })
|
||||
first.unmount()
|
||||
|
||||
render(<AssistantMessage sessionId="s1" content={'改了 `src/app.ts:7`'} />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'src/app.ts:7' })).toBeInTheDocument()
|
||||
expect(statWorkspaceFiles).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the links when the disk cannot be asked', async () => {
|
||||
statWorkspaceFiles.mockRejectedValue(new Error('offline'))
|
||||
render(<AssistantMessage sessionId="s1" content={'改了 `src/app.ts:7`'} />)
|
||||
|
||||
expect(await screen.findByRole('link', { name: 'src/app.ts:7' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not ask the workspace about a home-relative path it would misread', () => {
|
||||
render(<AssistantMessage sessionId="s1" content={'笔记在 `~/notes/plan.md`'} />)
|
||||
|
||||
expect(screen.getByRole('link', { name: '~/notes/plan.md' })).toBeInTheDocument()
|
||||
expect(statWorkspaceFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
@@ -181,7 +321,7 @@ describe('AssistantMessage file references', () => {
|
||||
|
||||
it('offers the open-with menu on right-click, including the copy entries', async () => {
|
||||
render(<AssistantMessage sessionId="s1" content={'见 src/app.ts:42'} isStreaming={false} />)
|
||||
fireEvent.contextMenu(screen.getByRole('link', { name: 'src/app.ts:42' }))
|
||||
fireEvent.contextMenu(await screen.findByRole('link', { name: 'src/app.ts:42' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument())
|
||||
const labels = screen.getAllByRole('menuitem').map((el) => el.textContent)
|
||||
@@ -193,7 +333,7 @@ describe('AssistantMessage file references', () => {
|
||||
|
||||
it('copies the absolute path, resolved against the session workdir', async () => {
|
||||
render(<AssistantMessage sessionId="s1" content={'见 src/app.ts:42'} isStreaming={false} />)
|
||||
fireEvent.contextMenu(screen.getByRole('link', { name: 'src/app.ts:42' }))
|
||||
fireEvent.contextMenu(await screen.findByRole('link', { name: 'src/app.ts:42' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'openWith.copyPath' }))
|
||||
@@ -202,7 +342,7 @@ describe('AssistantMessage file references', () => {
|
||||
|
||||
it('copies file contents by reading the path without its line suffix', async () => {
|
||||
render(<AssistantMessage sessionId="s1" content={'见 src/app.ts:42'} isStreaming={false} />)
|
||||
fireEvent.contextMenu(screen.getByRole('link', { name: 'src/app.ts:42' }))
|
||||
fireEvent.contextMenu(await screen.findByRole('link', { name: 'src/app.ts:42' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'openWith.copyFileContent' }))
|
||||
|
||||
@@ -144,6 +144,8 @@ describe('AssistantMessage output-target cards', () => {
|
||||
sessionId="s1"
|
||||
content={'见 [说明文档](docs/readme.md)'}
|
||||
isStreaming={false}
|
||||
turnChangedFiles={['docs/readme.md']}
|
||||
turnOutputEvidence={{ unlistedWrites: false }}
|
||||
/>,
|
||||
)
|
||||
// Link text appears in both the bubble anchor and the card title; the badge is unique.
|
||||
@@ -254,6 +256,8 @@ describe('AssistantMessage output-target cards', () => {
|
||||
'页面 [首页](out/index.html)',
|
||||
].join('\n')}
|
||||
isStreaming={false}
|
||||
turnChangedFiles={['docs/readme.md', 'out/index.html']}
|
||||
turnOutputEvidence={{ unlistedWrites: false }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('assistantOutputs.kind.localhost')).toBeInTheDocument()
|
||||
|
||||
@@ -13,7 +13,7 @@ import { InlineImageGallery } from './InlineImageGallery'
|
||||
import { InlineVideoGallery } from './InlineVideoGallery'
|
||||
import { AssistantOutputTargetCard } from './AssistantOutputTargetCard'
|
||||
import { openPreviewLink } from '../../lib/openPreviewLink'
|
||||
import { extractAssistantOutputTargets } from '../../lib/assistantOutputTargets'
|
||||
import { extractAssistantOutputTargets, type TurnOutputEvidence } from '../../lib/assistantOutputTargets'
|
||||
import { resolveAssistantFileHref } from '@/lib/assistantFileContext'
|
||||
import { createAssistantMarkdownImageResolver, localPathFromMarkdownImageUrl } from '../../lib/markdownImages'
|
||||
import type { MarkdownImageClick } from '../markdown/MarkdownRenderer'
|
||||
@@ -22,6 +22,8 @@ import { isManagedGeneratedImagePath } from '../../lib/attachmentImages'
|
||||
import { useWorkspaceContentStore } from '../../stores/workspaceContentStore'
|
||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||
import { useDiskConfirmedTargets } from '../../hooks/useDiskConfirmedTargets'
|
||||
import { useTurnWrittenTargets } from '../../hooks/useTurnWrittenTargets'
|
||||
import { createWorkspaceFileLinkVerifier } from '../../lib/workspaceFileStats'
|
||||
|
||||
type Props = {
|
||||
content: string
|
||||
@@ -31,6 +33,11 @@ type Props = {
|
||||
/** This turn's real changed files (absolute), used to anchor output chips onto
|
||||
* files that were actually written instead of guessing from the prose. */
|
||||
turnChangedFiles?: string[]
|
||||
/**
|
||||
* What the turn's checkpoint says it could have written. Absent while it is
|
||||
* still loading, so no card is shown on a guess that the checkpoint then drops.
|
||||
*/
|
||||
turnOutputEvidence?: TurnOutputEvidence
|
||||
/** Only one assistant message per turn owns fallback cards for unmentioned changed files. */
|
||||
isTurnOutputOwner?: boolean
|
||||
/** Set only on the last reply of a finished turn: when it ended and how long it took. */
|
||||
@@ -45,6 +52,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
branchAction,
|
||||
sessionId,
|
||||
turnChangedFiles,
|
||||
turnOutputEvidence,
|
||||
isTurnOutputOwner = true,
|
||||
turnCompletion,
|
||||
}: Props) {
|
||||
@@ -64,6 +72,16 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
[content, sessionId],
|
||||
)
|
||||
|
||||
// A reference guessed from a code span or prose links only once its file is
|
||||
// known to exist, judged as the click would resolve it — so a name quoted from
|
||||
// a commit message never looks openable.
|
||||
const fileLinkVerifier = useMemo(
|
||||
() => isStreaming || !sessionId
|
||||
? undefined
|
||||
: createWorkspaceFileLinkVerifier(sessionId, (path) => resolveAssistantFileHref(path, content)),
|
||||
[content, isStreaming, sessionId],
|
||||
)
|
||||
|
||||
// Right-clicking a reference in the prose opens the same menu the output cards
|
||||
// and the file tree use, so "open in VS Code" / "reveal in Finder" / "copy
|
||||
// path" are reachable from the place the model actually names the file.
|
||||
@@ -102,13 +120,18 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
includeChangedFileFallback: isTurnOutputOwner,
|
||||
// Confirmed against the disk by useDiskConfirmedTargets before showing.
|
||||
includeUnconfirmedNames: true,
|
||||
// A card says the turn produced the file; until the checkpoint says
|
||||
// what could have written one, nothing unproven is.
|
||||
outputEvidence: { unlistedWrites: turnOutputEvidence?.unlistedWrites ?? false },
|
||||
}).filter(
|
||||
(target) => target.kind !== 'image' && target.kind !== 'video',
|
||||
),
|
||||
[content, isStreaming, isTurnOutputOwner, sessionId, workDir, turnChangedFiles],
|
||||
[content, isStreaming, isTurnOutputOwner, sessionId, workDir, turnChangedFiles, turnOutputEvidence?.unlistedWrites],
|
||||
)
|
||||
// A bare name the text could not bound is settled against the workspace listing.
|
||||
const outputTargets = useDiskConfirmedTargets(sessionId, extractedTargets)
|
||||
// A bare name the text could not bound is settled against the workspace listing,
|
||||
// then a file no changed file accounts for must show it was written this turn.
|
||||
const settledTargets = useDiskConfirmedTargets(sessionId, extractedTargets)
|
||||
const outputTargets = useTurnWrittenTargets(sessionId, settledTargets, turnOutputEvidence?.startedAt)
|
||||
const resolveAssistantImageSrc = useMemo(
|
||||
() => {
|
||||
if (isStreaming || !sessionId) return undefined
|
||||
@@ -177,6 +200,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
onLinkClick={sessionId ? handleLinkClick : undefined}
|
||||
resolveImageSrc={resolveAssistantImageSrc}
|
||||
onImageClick={resolveAssistantImageSrc ? handleImageClick : undefined}
|
||||
fileLinkVerifier={fileLinkVerifier}
|
||||
/>
|
||||
{!isStreaming && (
|
||||
<InlineImageGallery
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { openBrowser } = vi.hoisted(() => ({ openBrowser: vi.fn() }))
|
||||
@@ -102,12 +102,13 @@ describe('AssistantOutputTargetCard', () => {
|
||||
expect(screen.getByText('outputs/brief.docx')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('routes Open to workspace preview for a markdown target', () => {
|
||||
it('routes Open to workspace preview for a markdown target', async () => {
|
||||
render(<AssistantOutputTargetCard target={markdownTarget} sessionId="s1" />)
|
||||
fireEvent.click(screen.getByLabelText('assistantOutputs.open'))
|
||||
// The trailing args are openPreview's optional `origin` and `reveal` (#1146);
|
||||
// a card has no line number to reveal, hence undefined.
|
||||
expect(openPreviewFn).toHaveBeenCalledWith('s1', 'docs/readme.md', {})
|
||||
// a card has no line number to reveal, hence undefined. The workspace is
|
||||
// asked whether the file is there first, so the tab opens a tick later.
|
||||
await waitFor(() => expect(openPreviewFn).toHaveBeenCalledWith('s1', 'docs/readme.md', {}))
|
||||
})
|
||||
|
||||
it('routes Open to the in-app browser for a localhost target', () => {
|
||||
@@ -118,10 +119,10 @@ describe('AssistantOutputTargetCard', () => {
|
||||
|
||||
// The trailing icon button is a discoverability affordance, not the hit area:
|
||||
// clicking the file name / path anywhere on the row must open the target.
|
||||
it('opens the workspace preview when the row body is clicked', () => {
|
||||
it('opens the workspace preview when the row body is clicked', async () => {
|
||||
render(<AssistantOutputTargetCard target={markdownTarget} sessionId="s1" />)
|
||||
fireEvent.click(screen.getByText('readme.md'))
|
||||
expect(openPreviewFn).toHaveBeenCalledWith('s1', 'docs/readme.md', {})
|
||||
await waitFor(() => expect(openPreviewFn).toHaveBeenCalledWith('s1', 'docs/readme.md', {}))
|
||||
})
|
||||
|
||||
it('opens the in-app browser when a localhost row body is clicked', () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { ApiError } from '../../api/client'
|
||||
import { subagentsApi, type SubagentRunResponse } from '../../api/subagents'
|
||||
import { teamsApi } from '../../api/teams'
|
||||
import { resetAgentRunActivityCache } from './useAgentRunActivity'
|
||||
import { resetWorkspaceFileStatsForTests } from '../../lib/workspaceFileStats'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
import { useWorkspaceStore } from '../../stores/workspaceStore'
|
||||
@@ -8043,6 +8044,77 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(within(turnCard).getByText('ink-survey-philosophy.md')).toBeTruthy()
|
||||
})
|
||||
|
||||
describe('output cards need the turn to have produced the file', () => {
|
||||
function mockCheckpoint(unverifiedChangeSources: string[], startedAt: number) {
|
||||
return vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||||
checkpoints: [{
|
||||
target: { targetUserMessageId: 'transcript-user-1', userMessageIndex: 0, userMessageCount: 1 },
|
||||
workDir: '/private/tmp',
|
||||
code: { available: true, filesChanged: [], insertions: 0, deletions: 0 },
|
||||
unverifiedChangeSources,
|
||||
startedAt,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
function replyWith(text: string) {
|
||||
const store = useChatStore.getState()
|
||||
act(() => {
|
||||
store.sendMessage(ACTIVE_TAB, 'Summarise the last commits')
|
||||
store.handleServerMessage(ACTIVE_TAB, { type: 'content_start', blockType: 'text' })
|
||||
store.handleServerMessage(ACTIVE_TAB, { type: 'content_delta', text })
|
||||
store.handleServerMessage(ACTIVE_TAB, { type: 'status', state: 'idle' })
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetWorkspaceFileStatsForTests()
|
||||
})
|
||||
|
||||
it('shows none for file names a read-only turn only quoted', async () => {
|
||||
// The turn ran `git log`; the reply quoted names from commit messages.
|
||||
const getTurnCheckpoints = mockCheckpoint([], Date.now())
|
||||
const statWorkspaceFiles = vi.spyOn(sessionsApi, 'statWorkspaceFiles').mockImplementation(async (_sessionId, paths) => ({
|
||||
files: paths.map((path) => ({ path, state: 'missing' as const })),
|
||||
}))
|
||||
|
||||
render(<MessageList sessionId={ACTIVE_TAB} />)
|
||||
replyWith('QUOTED_NAMES 修复了 `开题报告2.docx`、`D:/资料/测试文档1.docx` 被截断的问题')
|
||||
|
||||
await waitFor(() => expect(getTurnCheckpoints).toHaveBeenCalled())
|
||||
await waitFor(() => expect(statWorkspaceFiles).toHaveBeenCalled())
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
})
|
||||
const reply = screen.getByText('QUOTED_NAMES', { exact: false }).closest('[data-chat-render-item-key]')
|
||||
expect(reply).not.toBeNull()
|
||||
expect(within(reply as HTMLElement).queryByRole('button', { name: 'Open' })).toBeNull()
|
||||
// Nothing there to open, so nothing looks openable either.
|
||||
expect(within(reply as HTMLElement).queryByRole('link')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows only what a shell command wrote after the turn began', async () => {
|
||||
const startedAt = Date.parse('2026-10-04T08:00:00Z')
|
||||
mockCheckpoint(['Bash'], startedAt)
|
||||
const statWorkspaceFiles = vi.spyOn(sessionsApi, 'statWorkspaceFiles').mockImplementation(async (_sessionId, paths) => ({
|
||||
files: paths.map((path) => ({
|
||||
path,
|
||||
state: 'file' as const,
|
||||
mtimeMs: path === 'out/report.docx' ? startedAt + 5_000 : startedAt - 86_400_000,
|
||||
})),
|
||||
}))
|
||||
|
||||
render(<MessageList sessionId={ACTIVE_TAB} />)
|
||||
replyWith('CONVERTED 已把 `in/source.docx` 转成 `out/report.docx`')
|
||||
|
||||
await waitFor(() => expect(statWorkspaceFiles).toHaveBeenCalled())
|
||||
const reply = screen.getByText('CONVERTED', { exact: false }).closest('[data-chat-render-item-key]') as HTMLElement
|
||||
await waitFor(() => expect(within(reply).getAllByRole('button', { name: 'Open' })).toHaveLength(1))
|
||||
expect(within(reply).getByText('report.docx', { selector: 'span' })).toBeTruthy()
|
||||
expect(within(reply).queryByText('source.docx', { selector: 'span' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps one output card per turn when separate turns generate the same path', async () => {
|
||||
const generatedPath = '/private/tmp/repeated-report.md'
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||||
|
||||
@@ -54,6 +54,7 @@ import type { TeamDetail, TeamWorkbenchSnapshot } from '../../types/team'
|
||||
import { formatTokenCount } from '../../lib/formatTokenCount'
|
||||
import { formatDurationMs, hasRunningBackgroundTasks as hasAnyRunningBackgroundTasks } from '../../lib/backgroundTasks'
|
||||
import { buildTurnCompletionByMessageId, type TurnCompletion } from '../../lib/turnCompletion'
|
||||
import type { TurnOutputEvidence } from '../../lib/assistantOutputTargets'
|
||||
import { isTouchH5Document } from '../../lib/touchH5'
|
||||
import { findChatRenderTarget } from '../../lib/trajectory/chatTarget'
|
||||
import { useTrajectoryViewStore } from '../../stores/trajectoryViewStore'
|
||||
@@ -1307,23 +1308,34 @@ function buildTurnCardInsertionMap(
|
||||
return cardsByRenderIndex
|
||||
}
|
||||
|
||||
type TurnFileEvidence = TurnOutputEvidence & { changedFiles: string[] }
|
||||
|
||||
const UNKNOWN_TURN_OUTPUT_EVIDENCE: TurnOutputEvidence = { unlistedWrites: true }
|
||||
|
||||
/**
|
||||
* Map each render item to the REAL changed files of the turn it belongs to, so an
|
||||
* assistant message can anchor its output chips on files that were actually
|
||||
* written this turn instead of guessing paths from the prose. Items are attributed
|
||||
* to the most recent preceding non-pending user message (the turn boundary).
|
||||
* Map each render item to what its turn's checkpoint knows: the REAL changed
|
||||
* files, so an assistant message can anchor its output chips on files that were
|
||||
* actually written this turn instead of guessing paths from the prose, and
|
||||
* whether anything else could have written one. Items are attributed to the
|
||||
* most recent preceding non-pending user message (the turn boundary).
|
||||
*/
|
||||
function buildChangedFilesByRenderIndex(
|
||||
renderItems: RenderItem[],
|
||||
turnChangeCards: TurnChangeCardModel[],
|
||||
): Map<number, string[]> {
|
||||
const filesByTurnId = new Map<string, string[]>()
|
||||
): Map<number, TurnFileEvidence> {
|
||||
const filesByTurnId = new Map<string, TurnFileEvidence>()
|
||||
for (const card of turnChangeCards) {
|
||||
filesByTurnId.set(card.target.messageId, card.checkpoint.code.filesChanged)
|
||||
const { code, unverifiedChangeSources, startedAt } = card.checkpoint
|
||||
filesByTurnId.set(card.target.messageId, {
|
||||
changedFiles: code.filesChanged,
|
||||
// An older server does not say; assume the worst.
|
||||
unlistedWrites: unverifiedChangeSources === undefined || unverifiedChangeSources.length > 0,
|
||||
...(typeof startedAt === 'number' ? { startedAt } : {}),
|
||||
})
|
||||
}
|
||||
if (filesByTurnId.size === 0) return new Map()
|
||||
|
||||
const filesByRenderIndex = new Map<number, string[]>()
|
||||
const filesByRenderIndex = new Map<number, TurnFileEvidence>()
|
||||
let activeTurnId: string | null = null
|
||||
renderItems.forEach((item, index) => {
|
||||
if (item.kind === 'message' && item.message.type === 'user_text' && !item.message.pending) {
|
||||
@@ -2880,6 +2892,9 @@ export function MessageList({
|
||||
() => buildChangedFilesByRenderIndex(renderItems, turnChangeCards),
|
||||
[renderItems, turnChangeCards],
|
||||
)
|
||||
// No checkpoint is coming for this view; the disk alone has to vouch for outputs.
|
||||
// While one is still loading, nothing unproven is shown instead.
|
||||
const turnEvidenceUnavailable = isDirectAgentSession || turnChangeLoadError !== null || workspaceChangesFallback !== null
|
||||
const turnOutputOwnerIndexes = useMemo(
|
||||
() => buildTurnOutputOwnerIndexes(renderItems, turnChangeCards),
|
||||
[renderItems, turnChangeCards],
|
||||
@@ -3679,7 +3694,8 @@ export function MessageList({
|
||||
}
|
||||
branchAction={branchActionByMessageId.get(item.message.id)}
|
||||
editAction={editActionByMessageId.get(item.message.id)}
|
||||
turnChangedFiles={changedFilesByRenderIndex.get(index)}
|
||||
turnChangedFiles={changedFilesByRenderIndex.get(index)?.changedFiles}
|
||||
turnOutputEvidence={changedFilesByRenderIndex.get(index) ?? (turnEvidenceUnavailable ? UNKNOWN_TURN_OUTPUT_EVIDENCE : undefined)}
|
||||
isTurnOutputOwner={turnOutputOwnerIndexes.has(index)}
|
||||
turnCompletion={turnCompletionByMessageId.get(item.message.id)}
|
||||
supersededAskUserQuestionIds={supersededAskUserQuestionIds}
|
||||
@@ -3930,6 +3946,7 @@ export const MessageBlock = memo(function MessageBlock({
|
||||
branchAction,
|
||||
editAction,
|
||||
turnChangedFiles,
|
||||
turnOutputEvidence,
|
||||
isTurnOutputOwner,
|
||||
turnCompletion,
|
||||
supersededAskUserQuestionIds,
|
||||
@@ -3948,6 +3965,7 @@ export const MessageBlock = memo(function MessageBlock({
|
||||
}
|
||||
editAction?: UserMessageEditAction
|
||||
turnChangedFiles?: string[]
|
||||
turnOutputEvidence?: TurnOutputEvidence
|
||||
isTurnOutputOwner?: boolean
|
||||
turnCompletion?: TurnCompletion
|
||||
supersededAskUserQuestionIds?: ReadonlySet<string>
|
||||
@@ -4006,6 +4024,7 @@ export const MessageBlock = memo(function MessageBlock({
|
||||
branchAction={branchAction}
|
||||
sessionId={sessionId ?? undefined}
|
||||
turnChangedFiles={turnChangedFiles}
|
||||
turnOutputEvidence={turnOutputEvidence}
|
||||
isTurnOutputOwner={isTurnOutputOwner}
|
||||
turnCompletion={turnCompletion}
|
||||
/>
|
||||
|
||||
@@ -7,14 +7,19 @@ import { marked, type Tokens } from 'marked'
|
||||
// The `md-code-link` variant below is spelled out literally so Tailwind can see
|
||||
// it; MarkdownRenderer.test.tsx asserts it against CODE_LINK_CLASS.
|
||||
import {
|
||||
AUTHORED_FILE_LINK_ATTRIBUTE,
|
||||
cjkAwareAutolink,
|
||||
fileRefFromElement,
|
||||
fileLinkAttributes,
|
||||
FILE_LINK_CLASS,
|
||||
guessedFileLinkPaths,
|
||||
linkifyFilePaths,
|
||||
renderCodespan,
|
||||
unwrapFileLinks,
|
||||
unwrapGuessedFileLinks,
|
||||
type FileLinkVerifier,
|
||||
} from '@/lib/markdownAutolink'
|
||||
import { useFileLinkVerification } from '@/hooks/useFileLinkVerification'
|
||||
import { classifyPreviewLink } from '@/lib/previewLinkRouter'
|
||||
import { isSafeMarkdownImageSource, normalizeMarkdownImageDestination } from '@/lib/markdownImages'
|
||||
import { CodeViewer } from '../chat/CodeViewer'
|
||||
@@ -44,6 +49,13 @@ type Props = {
|
||||
* so a viewer can show them — and the place of the one clicked.
|
||||
*/
|
||||
onImageClick?: (click: MarkdownImageClick) => void
|
||||
/**
|
||||
* Check the file links guessed from code spans and prose before they look like
|
||||
* links. Until it answers they render as the plain code or text they came from,
|
||||
* and the ones it finds missing stay that way. Links the author wrote in
|
||||
* Markdown are taken at their word. Without a verifier every guess links.
|
||||
*/
|
||||
fileLinkVerifier?: FileLinkVerifier
|
||||
}
|
||||
|
||||
export type MarkdownImageClick = {
|
||||
@@ -63,7 +75,7 @@ type MathBlock = {
|
||||
displayMode: boolean
|
||||
}
|
||||
|
||||
type HtmlPart = { type: 'html'; content: string }
|
||||
type HtmlPart = { type: 'html'; content: string; guessedFilePaths: string[] }
|
||||
type CodePart = { type: 'code'; block: CodeBlock }
|
||||
type MarkdownPart = HtmlPart | CodePart
|
||||
|
||||
@@ -147,7 +159,7 @@ renderer.link = function (token: Tokens.Link) {
|
||||
// validated local destinations as data, just like automatic file references.
|
||||
const target = classifyPreviewLink(token.href)
|
||||
if (target.path) {
|
||||
return `<a class="${FILE_LINK_CLASS}" ${fileLinkAttributes({ raw: token.href, path: target.path, line: target.line, column: target.column })}>${this.parser.parseInline(token.tokens)}</a>`
|
||||
return `<a class="${FILE_LINK_CLASS}" ${AUTHORED_FILE_LINK_ATTRIBUTE} ${fileLinkAttributes({ raw: token.href, path: target.path, line: target.line, column: target.column })}>${this.parser.parseInline(token.tokens)}</a>`
|
||||
}
|
||||
return renderDefaultLink.call(this, token)
|
||||
}
|
||||
@@ -359,7 +371,7 @@ function enhanceMarkdownHtml(
|
||||
mathBlocks: MathBlock[],
|
||||
references: ReferenceLinking,
|
||||
resolveImageSrc?: (src: string) => string | null,
|
||||
): string {
|
||||
): HtmlPart {
|
||||
const cleanHtml = DOMPurify.sanitize(html, MARKDOWN_SANITIZE_CONFIG)
|
||||
|
||||
const wantsFilePathLinks = references.bare && REFERENCE_HINT_RE.test(cleanHtml)
|
||||
@@ -371,11 +383,11 @@ function enhanceMarkdownHtml(
|
||||
|| wantsFileLinkStripping
|
||||
|| /<(?:a|table|img|source)\b/i.test(cleanHtml)
|
||||
if (!needsDomEnhancement) {
|
||||
return cleanHtml
|
||||
return { type: 'html', content: cleanHtml, guessedFilePaths: [] }
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return cleanHtml
|
||||
return { type: 'html', content: cleanHtml, guessedFilePaths: [] }
|
||||
}
|
||||
|
||||
// A detached div is not inert: assigning innerHTML can start image requests
|
||||
@@ -440,7 +452,16 @@ function enhanceMarkdownHtml(
|
||||
if (wantsFilePathLinks) linkifyFilePaths(container as unknown as HTMLElement)
|
||||
else if (wantsFileLinkStripping) unwrapFileLinks(container as unknown as HTMLElement)
|
||||
|
||||
return template.innerHTML
|
||||
return { type: 'html', content: template.innerHTML, guessedFilePaths: guessedFileLinkPaths(container) }
|
||||
}
|
||||
|
||||
/** Unwrap the guessed file links `keep` turns down, on markup already sanitized. */
|
||||
function filterGuessedFileLinks(part: HtmlPart, keep: (path: string) => boolean): HtmlPart {
|
||||
if (part.guessedFilePaths.every(keep) || typeof document === 'undefined') return part
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = part.content
|
||||
unwrapGuessedFileLinks(template.content, keep)
|
||||
return { ...part, content: template.innerHTML }
|
||||
}
|
||||
|
||||
function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[]; mathBlocks: MathBlock[] } {
|
||||
@@ -627,7 +648,7 @@ function reportImageClick(
|
||||
})
|
||||
}
|
||||
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, variant = 'default', className, cache = true, streaming = false, onLinkClick, resolveImageSrc, onImageClick }: Props) {
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, variant = 'default', className, cache = true, streaming = false, onLinkClick, resolveImageSrc, onImageClick, fileLinkVerifier }: Props) {
|
||||
const { html, codeBlocks, mathBlocks } = useMemo(
|
||||
() => cache ? getCachedMarkdownParse(content, streaming) : parseMarkdown(content),
|
||||
[cache, content, streaming],
|
||||
@@ -657,7 +678,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian
|
||||
}
|
||||
|
||||
if (codeBlocks.length === 0) {
|
||||
return [{ type: 'html' as const, content: enhanceMarkdownHtml(html, mathBlocks, references, resolveImageSrc) }]
|
||||
return [enhanceMarkdownHtml(html, mathBlocks, references, resolveImageSrc)]
|
||||
}
|
||||
|
||||
const result: MarkdownPart[] = []
|
||||
@@ -670,19 +691,32 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian
|
||||
|
||||
const before = remaining.slice(0, idx)
|
||||
if (before) {
|
||||
result.push({ type: 'html', content: enhanceMarkdownHtml(before, mathBlocks, references, resolveImageSrc) })
|
||||
result.push(enhanceMarkdownHtml(before, mathBlocks, references, resolveImageSrc))
|
||||
}
|
||||
result.push({ type: 'code', block })
|
||||
remaining = remaining.slice(idx + marker.length)
|
||||
}
|
||||
|
||||
if (remaining) {
|
||||
result.push({ type: 'html', content: enhanceMarkdownHtml(remaining, mathBlocks, references, resolveImageSrc) })
|
||||
result.push(enhanceMarkdownHtml(remaining, mathBlocks, references, resolveImageSrc))
|
||||
}
|
||||
|
||||
return result
|
||||
}, [html, codeBlocks, mathBlocks, streaming, onLinkClick, resolveImageSrc])
|
||||
|
||||
// A guessed link is a promise the file is there. Ask once the markup is final;
|
||||
// the guesses look like plain code until then, and stay so if the file is not.
|
||||
const guessedFilePaths = useMemo(
|
||||
() => parts.flatMap((part) => part.type === 'html' ? part.guessedFilePaths : []),
|
||||
[parts],
|
||||
)
|
||||
const missingFilePaths = useFileLinkVerification(fileLinkVerifier, guessedFilePaths)
|
||||
const visibleParts = useMemo(() => {
|
||||
if (!fileLinkVerifier || guessedFilePaths.length === 0) return parts
|
||||
const keep = (path: string) => missingFilePaths !== null && !missingFilePaths.has(path)
|
||||
return parts.map((part) => part.type === 'html' ? filterGuessedFileLinks(part, keep) : part)
|
||||
}, [fileLinkVerifier, guessedFilePaths.length, missingFilePaths, parts])
|
||||
|
||||
const handleClick = useCallback(async (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
const target = event.target as HTMLElement | null
|
||||
const button = target?.closest<HTMLButtonElement>('[data-copy-code]')
|
||||
@@ -720,7 +754,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian
|
||||
return (
|
||||
<MarkdownHtml
|
||||
className={proseClasses}
|
||||
html={parts[0]?.type === 'html' ? parts[0].content : ''}
|
||||
html={visibleParts[0]?.type === 'html' ? visibleParts[0].content : ''}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
)
|
||||
@@ -728,7 +762,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian
|
||||
|
||||
return (
|
||||
<div className={proseClasses} onClick={handleClick}>
|
||||
{parts.map((part, i) =>
|
||||
{visibleParts.map((part, i) =>
|
||||
part.type === 'html' ? (
|
||||
<MarkdownHtml key={i} html={part.content} />
|
||||
) : shouldRenderAsMermaid(part.block) ? (
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { FileLinkVerifier } from '@/lib/markdownAutolink'
|
||||
|
||||
const PATH_SEPARATOR = '\u0000'
|
||||
const NONE_MISSING: ReadonlySet<string> = new Set()
|
||||
|
||||
/**
|
||||
* Which of `paths` are missing, or `null` while that is not known yet.
|
||||
*
|
||||
* Without a verifier, or with nothing to ask about, nothing is missing.
|
||||
*/
|
||||
export function useFileLinkVerification(
|
||||
verifier: FileLinkVerifier | undefined,
|
||||
paths: string[],
|
||||
): ReadonlySet<string> | null {
|
||||
const unique = useMemo(() => [...new Set(paths)].sort(), [paths])
|
||||
const key = unique.join(PATH_SEPARATOR)
|
||||
const known = useMemo(
|
||||
() => verifier && unique.length > 0 ? verifier.peekMissing(unique) : NONE_MISSING,
|
||||
[unique, verifier],
|
||||
)
|
||||
const [fetched, setFetched] = useState<{ verifier: FileLinkVerifier; key: string; missing: ReadonlySet<string> } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!verifier || known) return
|
||||
let current = true
|
||||
void verifier.findMissing(unique).then((missing) => {
|
||||
if (current) setFetched({ verifier, key, missing })
|
||||
})
|
||||
return () => {
|
||||
current = false
|
||||
}
|
||||
// `key` stands for `unique`; `known` only short-cuts the request.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [verifier, key])
|
||||
|
||||
if (known) return known
|
||||
return fetched && fetched.verifier === verifier && fetched.key === key ? fetched.missing : null
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { sessionsApi, type WorkspaceFileStat } from '@/api/sessions'
|
||||
import { extractAssistantOutputTargets } from '@/lib/assistantOutputTargets'
|
||||
import { resetWorkspaceFileStatsForTests } from '@/lib/workspaceFileStats'
|
||||
import { useTurnWrittenTargets } from './useTurnWrittenTargets'
|
||||
|
||||
const TURN_STARTED = Date.parse('2026-10-04T08:00:00Z')
|
||||
|
||||
let statWorkspaceFiles: MockInstance<typeof sessionsApi.statWorkspaceFiles>
|
||||
|
||||
function onDisk(files: Record<string, number>) {
|
||||
statWorkspaceFiles.mockImplementation(async (_sessionId, paths) => ({
|
||||
files: paths.map((path): WorkspaceFileStat => path in files
|
||||
? { path, state: 'file', mtimeMs: files[path] }
|
||||
: { path, state: 'missing' }),
|
||||
}))
|
||||
}
|
||||
|
||||
// The turn wrote plan.md through an editing tool and ran a shell command too.
|
||||
// Only what the reply names is under test, not the unmentioned-file fallback.
|
||||
const outputs = (content: string) => extractAssistantOutputTargets(content, {
|
||||
workDir: '/w',
|
||||
changedFiles: ['/w/plan.md'],
|
||||
includeChangedFileFallback: false,
|
||||
outputEvidence: { unlistedWrites: true },
|
||||
})
|
||||
const hrefs = (targets: { href: string }[]) => targets.map((target) => target.href)
|
||||
|
||||
beforeEach(() => {
|
||||
resetWorkspaceFileStatsForTests()
|
||||
statWorkspaceFiles = vi.spyOn(sessionsApi, 'statWorkspaceFiles')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
statWorkspaceFiles.mockRestore()
|
||||
})
|
||||
|
||||
describe('useTurnWrittenTargets', () => {
|
||||
it('shows a shell-written file once the disk shows the turn wrote it', async () => {
|
||||
onDisk({ 'out/report.docx': TURN_STARTED + 30_000 })
|
||||
const targets = outputs('计划见 plan.md,报告已生成:out/report.docx')
|
||||
|
||||
const { result } = renderHook(() => useTurnWrittenTargets('s1', targets, TURN_STARTED))
|
||||
|
||||
// The checkpoint already vouches for plan.md; the report waits for the disk.
|
||||
expect(hrefs(result.current)).toEqual(['plan.md'])
|
||||
await waitFor(() => expect(hrefs(result.current)).toEqual(['plan.md', 'out/report.docx']))
|
||||
expect(result.current[1]?.awaitsTurnWrite).toBeUndefined()
|
||||
expect(statWorkspaceFiles).toHaveBeenCalledWith('s1', ['out/report.docx'])
|
||||
})
|
||||
|
||||
it('hides a file that is not there', async () => {
|
||||
onDisk({})
|
||||
const { result } = renderHook(() => useTurnWrittenTargets('s1', outputs('报告已生成:out/report.docx'), TURN_STARTED))
|
||||
|
||||
await waitFor(() => expect(statWorkspaceFiles).toHaveBeenCalled())
|
||||
await Promise.resolve()
|
||||
expect(result.current).toEqual([])
|
||||
})
|
||||
|
||||
it('hides a file that existed before the turn, such as the input it converted', async () => {
|
||||
onDisk({
|
||||
'in/需求.docx': TURN_STARTED - 86_400_000,
|
||||
'out/需求.pdf': TURN_STARTED + 5_000,
|
||||
})
|
||||
const targets = outputs('已把 `in/需求.docx` 转成 `out/需求.pdf`')
|
||||
expect(hrefs(targets)).toEqual(['in/需求.docx', 'out/需求.pdf'])
|
||||
|
||||
const { result } = renderHook(() => useTurnWrittenTargets('s1', targets, TURN_STARTED))
|
||||
|
||||
await waitFor(() => expect(hrefs(result.current)).toEqual(['out/需求.pdf']))
|
||||
})
|
||||
|
||||
it('allows for a file system that rounds the mtime down', async () => {
|
||||
onDisk({ 'out/report.docx': TURN_STARTED - 1_000 })
|
||||
const { result } = renderHook(() => useTurnWrittenTargets('s1', outputs('已生成 out/report.docx'), TURN_STARTED))
|
||||
|
||||
await waitFor(() => expect(hrefs(result.current)).toEqual(['out/report.docx']))
|
||||
})
|
||||
|
||||
it('settles for existence when the turn start is unknown', async () => {
|
||||
onDisk({ 'out/report.docx': 0 })
|
||||
const { result } = renderHook(() => useTurnWrittenTargets('s1', outputs('已生成 out/report.docx'), undefined))
|
||||
|
||||
await waitFor(() => expect(hrefs(result.current)).toEqual(['out/report.docx']))
|
||||
})
|
||||
|
||||
it('shows nothing it could not check', async () => {
|
||||
statWorkspaceFiles.mockRejectedValue(new Error('403'))
|
||||
const failed = renderHook(() => useTurnWrittenTargets('s1', outputs('已生成 out/a.docx'), TURN_STARTED))
|
||||
await waitFor(() => expect(statWorkspaceFiles).toHaveBeenCalled())
|
||||
await Promise.resolve()
|
||||
expect(failed.result.current).toEqual([])
|
||||
|
||||
statWorkspaceFiles.mockResolvedValue({ files: [{ path: 'D:/资料/b.docx', state: 'unavailable' }] })
|
||||
const outside = renderHook(() => useTurnWrittenTargets('s2', outputs('已生成 `D:/资料/b.docx`'), TURN_STARTED))
|
||||
await waitFor(() => expect(statWorkspaceFiles).toHaveBeenCalledWith('s2', ['D:/资料/b.docx']))
|
||||
await Promise.resolve()
|
||||
expect(outside.result.current).toEqual([])
|
||||
})
|
||||
|
||||
it('asks the disk nothing when the checkpoint already accounts for every file', () => {
|
||||
const targets = outputs('计划见 plan.md,预览 http://localhost:5173/')
|
||||
|
||||
const { result } = renderHook(() => useTurnWrittenTargets('s1', targets, TURN_STARTED))
|
||||
|
||||
expect(hrefs(result.current)).toEqual(['plan.md', 'http://localhost:5173/'])
|
||||
expect(statWorkspaceFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stats a path once for every message that names it, in batches the route accepts', async () => {
|
||||
const names = Array.from({ length: 25 }, (_, index) => `out/r${index}.docx`)
|
||||
onDisk(Object.fromEntries(names.map((name) => [name, TURN_STARTED + 1_000])))
|
||||
const many = extractAssistantOutputTargets(names.join('\n'), {
|
||||
workDir: '/w', changedFiles: [], outputEvidence: { unlistedWrites: true }, limit: 25,
|
||||
})
|
||||
|
||||
const first = renderHook(() => useTurnWrittenTargets('s1', many, TURN_STARTED))
|
||||
const second = renderHook(() => useTurnWrittenTargets('s1', many.slice(0, 1), TURN_STARTED))
|
||||
|
||||
await waitFor(() => expect(first.result.current).toHaveLength(25))
|
||||
await waitFor(() => expect(second.result.current).toHaveLength(1))
|
||||
expect(statWorkspaceFiles).toHaveBeenCalledTimes(2)
|
||||
expect(statWorkspaceFiles.mock.calls.map(([, paths]) => paths.length)).toEqual([20, 5])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { WorkspaceFileStat } from '@/api/sessions'
|
||||
import type { AssistantOutputTarget } from '@/lib/assistantOutputTargets'
|
||||
import { peekWorkspaceFileStats, statWorkspacePaths } from '@/lib/workspaceFileStats'
|
||||
|
||||
const PATH_SEPARATOR = '\u0000'
|
||||
// File systems round mtimes (FAT to 2 s); a write in the turn's first moment
|
||||
// must not read as older than the prompt that asked for it.
|
||||
const MTIME_TOLERANCE_MS = 2_000
|
||||
|
||||
type Stats = { key: string; byPath: Map<string, WorkspaceFileStat | null> }
|
||||
|
||||
function targetPath(target: AssistantOutputTarget): string {
|
||||
return target.normalizedPath ?? target.href
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep an output only once the disk shows this turn wrote it.
|
||||
*
|
||||
* The checkpoint lists what editing tools wrote; a shell command's output is
|
||||
* invisible to it, so such a file is named by the reply and marked
|
||||
* {@link AssistantOutputTarget.awaitsTurnWrite}. It is an output when it exists
|
||||
* and was modified since the turn began (`startedAt`, server clock, compared
|
||||
* with server mtimes). Without a start time, existing is all that can be asked.
|
||||
* Until the answer arrives, or when it cannot be had, the file is not shown: a
|
||||
* card claims the turn produced it, and a guess that turns out wrong opens
|
||||
* "file not found".
|
||||
*/
|
||||
export function useTurnWrittenTargets(
|
||||
sessionId: string | undefined,
|
||||
targets: AssistantOutputTarget[],
|
||||
startedAt: number | undefined,
|
||||
): AssistantOutputTarget[] {
|
||||
const paths = useMemo(() => [...new Set(
|
||||
targets.filter((target) => target.awaitsTurnWrite).map(targetPath),
|
||||
)].sort(), [targets])
|
||||
const key = sessionId && paths.length > 0 ? `${sessionId}${PATH_SEPARATOR}${paths.join(PATH_SEPARATOR)}` : ''
|
||||
const [fetched, setFetched] = useState<Stats | null>(null)
|
||||
// A remount answers from what is already known, so the card does not blink.
|
||||
const known = useMemo(() => {
|
||||
const byPath = key && sessionId ? peekWorkspaceFileStats(sessionId, paths) : undefined
|
||||
return byPath ? { key, byPath } : null
|
||||
}, [key, paths, sessionId])
|
||||
const stats = fetched?.key === key ? fetched : known
|
||||
|
||||
useEffect(() => {
|
||||
if (!key || !sessionId || known) return
|
||||
let current = true
|
||||
void statWorkspacePaths(sessionId, paths).then((byPath) => {
|
||||
if (current) setFetched({ key, byPath })
|
||||
})
|
||||
return () => {
|
||||
current = false
|
||||
}
|
||||
// `key` encodes the session and every path; `known` only short-cuts it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key])
|
||||
|
||||
return useMemo(() => targets.flatMap((target): AssistantOutputTarget[] => {
|
||||
if (!target.awaitsTurnWrite) return [target]
|
||||
const stat = stats?.byPath.get(targetPath(target))
|
||||
if (stat?.state !== 'file') return []
|
||||
if (startedAt !== undefined && (stat.mtimeMs === undefined || stat.mtimeMs < startedAt - MTIME_TOLERANCE_MS)) {
|
||||
return []
|
||||
}
|
||||
const { awaitsTurnWrite: _awaitsTurnWrite, ...written } = target
|
||||
return [written]
|
||||
}), [startedAt, stats, targets])
|
||||
}
|
||||
@@ -672,3 +672,69 @@ describe('canonical output deduplication', () => {
|
||||
expect(targets[0]?.href).toBe('/work/report.pdf')
|
||||
})
|
||||
})
|
||||
|
||||
describe('outputs a turn produced', () => {
|
||||
// A reply summarising commits quoted the file names from their messages. The
|
||||
// turn only ran `git log`, yet each name became a Word card that opened
|
||||
// "file not found": being mentioned is not being produced.
|
||||
const quoted = [
|
||||
'正文和行内代码里的 `开题报告2.docx`、`D:/资料/测试文档1.docx` 被截成 1.docx 的问题',
|
||||
'预览见 http://localhost:5173/',
|
||||
].join('\n')
|
||||
|
||||
it('drops every quoted file when the turn could not have written one unseen', () => {
|
||||
const asMentions = extractAssistantOutputTargets(quoted, { workDir: '/w', changedFiles: [] })
|
||||
expect(asMentions.map((target) => target.title))
|
||||
.toEqual(['开题报告2.docx', '测试文档1.docx', '1.docx', 'http://localhost:5173/'])
|
||||
|
||||
const asOutputs = extractAssistantOutputTargets(quoted, {
|
||||
workDir: '/w',
|
||||
changedFiles: [],
|
||||
outputEvidence: { unlistedWrites: false },
|
||||
})
|
||||
expect(asOutputs.map((target) => target.href)).toEqual(['http://localhost:5173/'])
|
||||
})
|
||||
|
||||
it('keeps what the turn wrote, by relative or absolute name, with nothing left to prove', () => {
|
||||
const targets = extractAssistantOutputTargets(
|
||||
'报告在 out/report.docx,副本在 `/Users/me/backup/report.docx`',
|
||||
{
|
||||
workDir: '/w',
|
||||
changedFiles: ['/w/out/report.docx', '/Users/me/backup/report.docx', '/w/out/summary.pdf'],
|
||||
outputEvidence: { unlistedWrites: false },
|
||||
},
|
||||
)
|
||||
|
||||
expect(targets.map((target) => [target.href, target.awaitsTurnWrite]))
|
||||
.toEqual([
|
||||
['out/report.docx', undefined],
|
||||
['/Users/me/backup/report.docx', undefined],
|
||||
['out/summary.pdf', undefined],
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a file a shell command may have written for the disk to prove', () => {
|
||||
const targets = extractAssistantOutputTargets(
|
||||
'计划见 plan.md,报告已生成:out/report.docx',
|
||||
{ workDir: '/w', changedFiles: ['/w/plan.md'], outputEvidence: { unlistedWrites: true } },
|
||||
)
|
||||
|
||||
expect(targets.map((target) => [target.normalizedPath, target.awaitsTurnWrite]))
|
||||
.toEqual([['plan.md', undefined], ['out/report.docx', true]])
|
||||
})
|
||||
|
||||
it('asks the disk about every file when no checkpoint is known', () => {
|
||||
const targets = extractAssistantOutputTargets(quoted, {
|
||||
workDir: '/w',
|
||||
outputEvidence: { unlistedWrites: true },
|
||||
})
|
||||
|
||||
expect(targets.map((target) => [target.title, target.awaitsTurnWrite]))
|
||||
.toEqual([
|
||||
['开题报告2.docx', true],
|
||||
['测试文档1.docx', true],
|
||||
['1.docx', true],
|
||||
['http://localhost:5173/', undefined],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,6 +40,22 @@ export type AssistantOutputTarget = {
|
||||
nameCandidates?: string[]
|
||||
/** Not to be shown until a changed file or the disk confirms one of its names. */
|
||||
awaitsConfirmation?: boolean
|
||||
/**
|
||||
* Named, but no changed file shows the turn wrote it. Not to be shown as an
|
||||
* output until the disk does — see {@link useTurnWrittenTargets}.
|
||||
*/
|
||||
awaitsTurnWrite?: boolean
|
||||
}
|
||||
|
||||
/** What a turn's checkpoint says about the files it could have produced. */
|
||||
export type TurnOutputEvidence = {
|
||||
/**
|
||||
* The turn ran a tool whose writes the changed files may not list (a writing
|
||||
* shell command, an MCP tool). False means the changed files are the whole story.
|
||||
*/
|
||||
unlistedWrites: boolean
|
||||
/** When the turn's prompt was recorded, epoch ms on the server's clock. */
|
||||
startedAt?: number
|
||||
}
|
||||
|
||||
export type ExtractAssistantOutputTargetOptions = {
|
||||
@@ -69,6 +85,15 @@ export type ExtractAssistantOutputTargetOptions = {
|
||||
* them against the disk before showing them should ask. Defaults to false.
|
||||
*/
|
||||
includeUnconfirmedNames?: boolean
|
||||
/**
|
||||
* Return only what the turn produced, for a caller presenting results as its
|
||||
* outputs. A mentioned file no changed file accounts for is dropped when the
|
||||
* turn could not have written it unseen, and otherwise marked
|
||||
* {@link AssistantOutputTarget.awaitsTurnWrite} for the disk to prove. A file
|
||||
* the reply merely talks about (a name quoted from a commit message) is not an
|
||||
* output. Omitted → every mention counts, as the inline galleries want.
|
||||
*/
|
||||
outputEvidence?: Pick<TurnOutputEvidence, 'unlistedWrites'>
|
||||
}
|
||||
|
||||
type FileTargetMatch = {
|
||||
@@ -341,9 +366,18 @@ export function extractAssistantOutputTargets(
|
||||
workDir,
|
||||
limit,
|
||||
options.includeChangedFileFallback !== false,
|
||||
options.outputEvidence,
|
||||
)
|
||||
}
|
||||
|
||||
// Without a checkpoint nothing vouches for any file; each one must be proven.
|
||||
const evidence = options.outputEvidence
|
||||
if (evidence) {
|
||||
return results.flatMap((target) => target.kind === 'localhost-url'
|
||||
? [target]
|
||||
: evidence.unlistedWrites ? [{ ...target, awaitsTurnWrite: true }] : [])
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -404,6 +438,7 @@ function reconcileTargetsWithChangedFiles(
|
||||
workDir: string | null,
|
||||
limit: number,
|
||||
includeChangedFileFallback: boolean,
|
||||
outputEvidence?: Pick<TurnOutputEvidence, 'unlistedWrites'>,
|
||||
): AssistantOutputTarget[] {
|
||||
if (limit <= 0) return []
|
||||
|
||||
@@ -419,12 +454,13 @@ function reconcileTargetsWithChangedFiles(
|
||||
|
||||
const mentioned = target.normalizedPath ?? target.href
|
||||
// An authored absolute path (including an explicit prose root) is identity,
|
||||
// not a basename hint. A checkpoint cannot disprove a shell-created output.
|
||||
// not a basename hint: it matches only the very same changed file. A
|
||||
// checkpoint cannot disprove a shell-created output.
|
||||
const explicitPath = isAbsoluteFilePath(target.href)
|
||||
// The longer reading of an ambiguous name wins when the turn really wrote it
|
||||
// (`报告v2.docx`, not the `v2.docx` the prose scan settled on).
|
||||
const match = explicitPath
|
||||
? null
|
||||
? findSameChangedFile(resolveFilePath(target.href), changedFiles)
|
||||
: [...(target.nameCandidates ?? []), mentioned]
|
||||
.sort((left, right) => getBasename(right).length - getBasename(left).length)
|
||||
.map((name) => matchChangedFile(name, changedFiles))
|
||||
@@ -436,6 +472,11 @@ function reconcileTargetsWithChangedFiles(
|
||||
&& target.kind !== 'video' && !/\.(?:mp3|wav|m4a|flac|aac|ogg|opus)$/i.test(mentioned)) {
|
||||
continue
|
||||
}
|
||||
// That exemption exists for writes the checkpoint cannot see. A turn that ran
|
||||
// nothing able to make one has no unseen output; the name is only quoted.
|
||||
if (!match && outputEvidence && !outputEvidence.unlistedWrites) {
|
||||
continue
|
||||
}
|
||||
|
||||
const corrected = explicitPath
|
||||
? resolveFilePath(target.href)
|
||||
@@ -460,6 +501,7 @@ function reconcileTargetsWithChangedFiles(
|
||||
// A changed-file match is settled; only an unmatched guess stays open.
|
||||
...(!match && nameCandidates ? { nameCandidates } : {}),
|
||||
...(!match && awaitsConfirmation ? { awaitsConfirmation } : {}),
|
||||
...(!match && outputEvidence ? { awaitsTurnWrite: true } : {}),
|
||||
})
|
||||
if (out.length >= limit) break
|
||||
}
|
||||
@@ -494,6 +536,11 @@ function reconcileTargetsWithChangedFiles(
|
||||
return out
|
||||
}
|
||||
|
||||
function findSameChangedFile(absolutePath: string, changedFiles: string[]): string | null {
|
||||
const identity = absolutePath.toLowerCase()
|
||||
return changedFiles.find((file) => resolveFilePath(file).toLowerCase() === identity) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a mentioned (often relative or bare) path against the turn's absolute
|
||||
* changed-file list. Prefer an unambiguous relative-suffix match, then fall back
|
||||
|
||||
@@ -71,6 +71,36 @@ export function unwrapFileLinks(container: HTMLElement): void {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a file link the author wrote as a Markdown link (`[app.ts](src/app.ts)`).
|
||||
* Every other file link is a guess — a code span or a stretch of prose that looks
|
||||
* like a path — and a guess is only worth a link once the file is known to exist.
|
||||
*/
|
||||
export const AUTHORED_FILE_LINK_ATTRIBUTE = 'data-file-authored'
|
||||
|
||||
/** Answers which guessed file links point at nothing. */
|
||||
export type FileLinkVerifier = {
|
||||
/** Paths known missing, when every path is already known; otherwise undefined. */
|
||||
peekMissing(paths: string[]): ReadonlySet<string> | undefined
|
||||
/** Paths known missing. A path that could not be checked is not missing. */
|
||||
findMissing(paths: string[]): Promise<ReadonlySet<string>>
|
||||
}
|
||||
|
||||
const GUESSED_FILE_LINK_SELECTOR = `a.${FILE_LINK_CLASS}:not([${AUTHORED_FILE_LINK_ATTRIBUTE}])`
|
||||
|
||||
export function guessedFileLinkPaths(container: ParentNode): string[] {
|
||||
return [...container.querySelectorAll<HTMLAnchorElement>(GUESSED_FILE_LINK_SELECTOR)]
|
||||
.map((anchor) => anchor.dataset.filePath ?? '')
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/** Undo the guessed file links `keep` turns down, leaving their code or text behind. */
|
||||
export function unwrapGuessedFileLinks(container: ParentNode, keep: (path: string) => boolean): void {
|
||||
container.querySelectorAll<HTMLAnchorElement>(GUESSED_FILE_LINK_SELECTOR).forEach((anchor) => {
|
||||
if (!keep(anchor.dataset.filePath ?? '')) anchor.replaceWith(...anchor.childNodes)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the `path:line` reference an anchor stands for.
|
||||
*
|
||||
|
||||
@@ -5,9 +5,19 @@ const openPath = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
// The server's verdict on a document the string check cannot place. Refusing
|
||||
// (the 403 a path outside the workspace gets) is the default.
|
||||
const getWorkspaceFile = vi.hoisted(() => vi.fn())
|
||||
// Whether the file is there at all. Every file is, unless a test says otherwise.
|
||||
const statWorkspaceFiles = vi.hoisted(() => vi.fn())
|
||||
const everyFileThere = async (_sessionId: string, paths: string[]) => ({
|
||||
files: paths.map((path) => ({ path, state: 'file' as const, mtimeMs: 0 })),
|
||||
})
|
||||
|
||||
vi.mock('../api/sessions', () => ({
|
||||
sessionsApi: { getWorkspaceFile },
|
||||
sessionsApi: { getWorkspaceFile, statWorkspaceFiles },
|
||||
}))
|
||||
|
||||
const addToast = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../stores/uiStore', () => ({
|
||||
useUIStore: { getState: () => ({ addToast }) },
|
||||
}))
|
||||
|
||||
vi.mock('./desktopHost', () => ({
|
||||
@@ -43,35 +53,39 @@ vi.mock('../stores/workspaceContentStore', () => ({
|
||||
|
||||
import { openPreviewLink } from './openPreviewLink'
|
||||
import { workspaceOpen } from './workspace/openTarget'
|
||||
import { resetWorkspaceFileStatsForTests, statWorkspacePaths } from './workspaceFileStats'
|
||||
|
||||
beforeEach(() => {
|
||||
resetWorkspaceFileStatsForTests()
|
||||
getWorkspaceFile.mockReset().mockRejectedValue(new Error('403 Path is outside workspace'))
|
||||
statWorkspaceFiles.mockReset().mockImplementation(everyFileThere)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
openPath.mockReset().mockResolvedValue(undefined)
|
||||
vi.mocked(workspaceOpen.file).mockClear()
|
||||
addToast.mockClear()
|
||||
})
|
||||
|
||||
describe('openPreviewLink for a document the workspace can draw', () => {
|
||||
it('previews a workspace-relative document, which the server resolves against the workdir', () => {
|
||||
it('previews a workspace-relative document, which the server resolves against the workdir', async () => {
|
||||
expect(openPreviewLink('out/thesis.pdf', 's1')).toBe(true)
|
||||
|
||||
expect(workspaceOpen.file).toHaveBeenCalledWith('s1', 'out/thesis.pdf', {})
|
||||
await waitFor(() => expect(workspaceOpen.file).toHaveBeenCalledWith('s1', 'out/thesis.pdf', {}))
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('previews a Word document the same way as a PDF', () => {
|
||||
it('previews a Word document the same way as a PDF', async () => {
|
||||
expect(openPreviewLink('out/thesis.docx', 's1')).toBe(true)
|
||||
|
||||
expect(workspaceOpen.file).toHaveBeenCalledWith('s1', 'out/thesis.docx', {})
|
||||
await waitFor(() => expect(workspaceOpen.file).toHaveBeenCalledWith('s1', 'out/thesis.docx', {}))
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['out/budget.xlsx', 'out/legacy.xls'])('previews an Excel workbook (%s) the same way', (path) => {
|
||||
it.each(['out/budget.xlsx', 'out/legacy.xls'])('previews an Excel workbook (%s) the same way', async (path) => {
|
||||
expect(openPreviewLink(path, 's1')).toBe(true)
|
||||
|
||||
expect(workspaceOpen.file).toHaveBeenCalledWith('s1', path, {})
|
||||
await waitFor(() => expect(workspaceOpen.file).toHaveBeenCalledWith('s1', path, {}))
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -82,10 +96,10 @@ describe('openPreviewLink for a document the workspace can draw', () => {
|
||||
expect(workspaceOpen.file).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('previews an absolute path that sits inside the session directory', () => {
|
||||
it('previews an absolute path that sits inside the session directory', async () => {
|
||||
openPreviewLink('/work/out/thesis.pdf', 's1')
|
||||
|
||||
expect(workspaceOpen.file).toHaveBeenCalledWith('s1', '/work/out/thesis.pdf', {})
|
||||
await waitFor(() => expect(workspaceOpen.file).toHaveBeenCalledWith('s1', '/work/out/thesis.pdf', {}))
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -111,10 +125,10 @@ describe('openPreviewLink for a document the workspace can draw', () => {
|
||||
expect(workspaceOpen.file).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still previews a relative document that only steps out of a folder and back in', () => {
|
||||
it('still previews a relative document that only steps out of a folder and back in', async () => {
|
||||
openPreviewLink('out/../out/thesis.pdf', 's1')
|
||||
|
||||
expect(workspaceOpen.file).toHaveBeenCalledWith('s1', 'out/../out/thesis.pdf', {})
|
||||
await waitFor(() => expect(workspaceOpen.file).toHaveBeenCalledWith('s1', 'out/../out/thesis.pdf', {}))
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -150,22 +164,22 @@ describe('openPreviewLink for a document the workspace can draw', () => {
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not ask the server when the path is plainly inside the workdir', () => {
|
||||
it('does not ask the server when the path is plainly inside the workdir', async () => {
|
||||
openPreviewLink('/work/out/thesis.pdf', 's1')
|
||||
|
||||
expect(getWorkspaceFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still previews a relative document while the workdir is unknown', () => {
|
||||
it('still previews a relative document while the workdir is unknown', async () => {
|
||||
openPreviewLink('out/thesis.pdf', 's2')
|
||||
|
||||
expect(workspaceOpen.file).toHaveBeenCalledWith('s2', 'out/thesis.pdf', {})
|
||||
await waitFor(() => expect(workspaceOpen.file).toHaveBeenCalledWith('s2', 'out/thesis.pdf', {}))
|
||||
})
|
||||
|
||||
it('applies only to documents: source files outside the workdir open in the code view as before', () => {
|
||||
it('applies only to documents: source files outside the workdir open in the code view as before', async () => {
|
||||
openPreviewLink('/Users/x/notes/app.ts', 's1')
|
||||
|
||||
expect(workspaceOpen.file).toHaveBeenCalledWith('s1', '/Users/x/notes/app.ts', {})
|
||||
await waitFor(() => expect(workspaceOpen.file).toHaveBeenCalledWith('s1', '/Users/x/notes/app.ts', {}))
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -184,10 +198,44 @@ describe('openPreviewLink', () => {
|
||||
await waitFor(() => expect(openPath).toHaveBeenCalledWith('/work/outputs/brief.pptx'))
|
||||
})
|
||||
|
||||
it('opens a CJK-named markdown in the workspace instead of ignoring the click', () => {
|
||||
it('opens a CJK-named markdown in the workspace instead of ignoring the click', async () => {
|
||||
// The output card for `README-拍摄大纲.md` rendered but its click returned
|
||||
// false: the path parser was ASCII-only and the router answered `ignored`.
|
||||
expect(openPreviewLink('README-拍摄大纲.md', 's1')).toBe(true)
|
||||
expect(workspaceOpen.file).toHaveBeenCalledWith('s1', 'README-拍摄大纲.md', {})
|
||||
await waitFor(() => expect(workspaceOpen.file).toHaveBeenCalledWith('s1', 'README-拍摄大纲.md', {}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('openPreviewLink for a file that is not there', () => {
|
||||
it('opens no tab and says it could not open the file', async () => {
|
||||
statWorkspaceFiles.mockImplementation(async (_sessionId: string, paths: string[]) => ({
|
||||
files: paths.map((path) => ({ path, state: 'missing' as const })),
|
||||
}))
|
||||
|
||||
expect(openPreviewLink('docs/开题报告2.docx', 's1')).toBe(true)
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith({ type: 'error', message: expect.stringContaining('开题报告2.docx') }))
|
||||
expect(workspaceOpen.file).not.toHaveBeenCalled()
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens at once a file the reply already found on disk', async () => {
|
||||
await statWorkspacePaths('s1', ['src/app.ts'])
|
||||
|
||||
openPreviewLink('src/app.ts:7', 's1')
|
||||
|
||||
expect(workspaceOpen.file).toHaveBeenCalledWith('s1', 'src/app.ts', { line: 7 })
|
||||
expect(statWorkspaceFiles).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still opens a file the workspace may not judge, as before', async () => {
|
||||
statWorkspaceFiles.mockImplementation(async (_sessionId: string, paths: string[]) => ({
|
||||
files: paths.map((path) => ({ path, state: 'unavailable' as const })),
|
||||
}))
|
||||
|
||||
openPreviewLink('/Users/x/notes/app.ts', 's1')
|
||||
|
||||
await waitFor(() => expect(workspaceOpen.file).toHaveBeenCalledWith('s1', '/Users/x/notes/app.ts', {}))
|
||||
expect(addToast).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useWorkspaceContentStore } from '../stores/workspaceContentStore'
|
||||
import { sessionsApi } from '../api/sessions'
|
||||
import { workspaceOpen } from './workspace/openTarget'
|
||||
import { openLocalFileWithSystem, reportOpenFailure, resolveAbsoluteOpenPath } from './systemFileOpen'
|
||||
import { isWorkspaceFileMissing } from './workspaceFileStats'
|
||||
|
||||
/**
|
||||
* Cheap, synchronous guess at whether the workspace preview can reach this document.
|
||||
@@ -72,13 +73,28 @@ export function openPreviewLink(href: string, sessionId: string): boolean {
|
||||
...(reveal ? { line: reveal.line, ...(reveal.column ? { column: reveal.column } : {}) } : {}),
|
||||
})
|
||||
}
|
||||
if (!isWorkspaceDocumentFile(path) || documentReachableInWorkspace(path, currentWorkDir())) {
|
||||
openInWorkspace()
|
||||
const route = () => {
|
||||
if (!isWorkspaceDocumentFile(path) || documentReachableInWorkspace(path, currentWorkDir())) {
|
||||
openInWorkspace()
|
||||
return
|
||||
}
|
||||
void documentReachableOnServer(id, path).then((reachable) => {
|
||||
if (reachable) openInWorkspace()
|
||||
else openSystemFile(path)
|
||||
})
|
||||
}
|
||||
// A file that is not there gets no tab announcing so; the click says it
|
||||
// could not open the file, where the user is looking. A link the reply
|
||||
// just verified answers from memory, so the tab still opens at once.
|
||||
const missing = isWorkspaceFileMissing(id, path)
|
||||
if (typeof missing === 'boolean') {
|
||||
if (missing) reportOpenFailure(path)
|
||||
else route()
|
||||
return
|
||||
}
|
||||
void documentReachableOnServer(id, path).then((reachable) => {
|
||||
if (reachable) openInWorkspace()
|
||||
else openSystemFile(path)
|
||||
void missing.then((isMissing) => {
|
||||
if (isMissing) reportOpenFailure(path)
|
||||
else route()
|
||||
})
|
||||
},
|
||||
openSystemFile,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { sessionsApi } from '../api/sessions'
|
||||
import {
|
||||
createWorkspaceFileLinkVerifier,
|
||||
peekWorkspaceFileStats,
|
||||
resetWorkspaceFileStatsForTests,
|
||||
statWorkspacePaths,
|
||||
} from './workspaceFileStats'
|
||||
|
||||
let statWorkspaceFiles: MockInstance<typeof sessionsApi.statWorkspaceFiles>
|
||||
|
||||
beforeEach(() => {
|
||||
resetWorkspaceFileStatsForTests()
|
||||
statWorkspaceFiles = vi.spyOn(sessionsApi, 'statWorkspaceFiles').mockImplementation(async (_sessionId, paths) => ({
|
||||
files: paths.map((path) => path.includes('gone/')
|
||||
? { path, state: 'missing' as const }
|
||||
: { path, state: 'file' as const, mtimeMs: 1 }),
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
statWorkspaceFiles.mockRestore()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('statWorkspacePaths', () => {
|
||||
it('sends one request for every caller asking in the same tick', async () => {
|
||||
// A long history mounts many messages at once; each asks for its own paths.
|
||||
const [first, second] = await Promise.all([
|
||||
statWorkspacePaths('s1', ['a.ts', 'b.ts']),
|
||||
statWorkspacePaths('s1', ['b.ts', 'gone/c.ts']),
|
||||
])
|
||||
|
||||
expect(statWorkspaceFiles).toHaveBeenCalledTimes(1)
|
||||
expect(statWorkspaceFiles).toHaveBeenCalledWith('s1', ['a.ts', 'b.ts', 'gone/c.ts'])
|
||||
expect(first.get('b.ts')?.state).toBe('file')
|
||||
expect(second.get('gone/c.ts')?.state).toBe('missing')
|
||||
})
|
||||
|
||||
it('keeps sessions apart and splits a batch to the route limit', async () => {
|
||||
const many = Array.from({ length: 45 }, (_, index) => `f${index}.ts`)
|
||||
await Promise.all([statWorkspacePaths('s1', many), statWorkspacePaths('s2', ['a.ts'])])
|
||||
|
||||
expect(statWorkspaceFiles.mock.calls.map(([sessionId, paths]) => [sessionId, paths.length]))
|
||||
.toEqual([['s1', 20], ['s1', 20], ['s1', 5], ['s2', 1]])
|
||||
})
|
||||
|
||||
it('answers from memory until the entry goes stale', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
await statWorkspacePaths('s1', ['a.ts'])
|
||||
expect(peekWorkspaceFileStats('s1', ['a.ts'])?.get('a.ts')?.state).toBe('file')
|
||||
await statWorkspacePaths('s1', ['a.ts'])
|
||||
expect(statWorkspaceFiles).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.setSystemTime(Date.now() + 11_000)
|
||||
expect(peekWorkspaceFileStats('s1', ['a.ts'])).toBeUndefined()
|
||||
await statWorkspacePaths('s1', ['a.ts'])
|
||||
expect(statWorkspaceFiles).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('peeks nothing while any path is still unanswered', () => {
|
||||
void statWorkspacePaths('s1', ['a.ts'])
|
||||
expect(peekWorkspaceFileStats('s1', ['a.ts'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports a failed request as unknown, not as missing', async () => {
|
||||
statWorkspaceFiles.mockRejectedValue(new Error('offline'))
|
||||
const stats = await statWorkspacePaths('s1', ['a.ts'])
|
||||
expect(stats.get('a.ts')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWorkspaceFileLinkVerifier', () => {
|
||||
it('asks about the file a click would open and reports the written path', async () => {
|
||||
const verifier = createWorkspaceFileLinkVerifier('s1', (path) => `/root/${path}`)
|
||||
|
||||
const missing = await verifier.findMissing(['src/a.ts', 'gone/b.md'])
|
||||
|
||||
expect(statWorkspaceFiles).toHaveBeenCalledWith('s1', ['/root/src/a.ts', '/root/gone/b.md'])
|
||||
expect([...missing]).toEqual(['gone/b.md'])
|
||||
expect([...verifier.peekMissing(['gone/b.md'])!]).toEqual(['gone/b.md'])
|
||||
})
|
||||
|
||||
it('never calls a file missing that it could not judge', async () => {
|
||||
statWorkspaceFiles.mockImplementation(async (_sessionId, paths) => ({
|
||||
files: paths.map((path) => ({ path, state: 'unavailable' as const })),
|
||||
}))
|
||||
const verifier = createWorkspaceFileLinkVerifier('s1', (path) => path)
|
||||
|
||||
expect([...await verifier.findMissing(['/elsewhere/a.pdf'])]).toEqual([])
|
||||
// `~` is the opener's home, which the workspace route cannot see.
|
||||
expect([...verifier.peekMissing(['~/notes.md'])!]).toEqual([])
|
||||
expect(statWorkspaceFiles).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import { sessionsApi, type WorkspaceFileStat } from '../api/sessions'
|
||||
import type { FileLinkVerifier } from './markdownAutolink'
|
||||
|
||||
const KEY_SEPARATOR = '\u0000'
|
||||
// Existence rarely flips under a rendered reply; a remount while scrolling
|
||||
// should answer from memory instead of asking again.
|
||||
const STAT_TTL_MS = 10_000
|
||||
// The server's stat route takes at most this many paths per request.
|
||||
const STAT_BATCH_SIZE = 20
|
||||
const MAX_CACHED_PATHS = 2_000
|
||||
|
||||
type CacheEntry = {
|
||||
at: number
|
||||
/** Settled answer, readable without waiting. `null` = the request failed. */
|
||||
value?: WorkspaceFileStat | null
|
||||
promise: Promise<WorkspaceFileStat | null>
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>()
|
||||
const queued = new Map<string, Map<string, (stat: WorkspaceFileStat | null) => void>>()
|
||||
let flushScheduled = false
|
||||
|
||||
export function resetWorkspaceFileStatsForTests() {
|
||||
cache.clear()
|
||||
queued.clear()
|
||||
flushScheduled = false
|
||||
}
|
||||
|
||||
function cacheKey(sessionId: string, path: string) {
|
||||
return `${sessionId}${KEY_SEPARATOR}${path}`
|
||||
}
|
||||
|
||||
function freshEntry(sessionId: string, path: string): CacheEntry | undefined {
|
||||
const entry = cache.get(cacheKey(sessionId, path))
|
||||
return entry && Date.now() - entry.at < STAT_TTL_MS ? entry : undefined
|
||||
}
|
||||
|
||||
function flush() {
|
||||
flushScheduled = false
|
||||
const batches = [...queued]
|
||||
queued.clear()
|
||||
for (const [sessionId, resolvers] of batches) {
|
||||
const paths = [...resolvers.keys()]
|
||||
for (let start = 0; start < paths.length; start += STAT_BATCH_SIZE) {
|
||||
const batch = paths.slice(start, start + STAT_BATCH_SIZE)
|
||||
// Started inside the chain, so even a call that throws on the spot settles
|
||||
// every waiting path instead of leaving it pending forever.
|
||||
void Promise.resolve()
|
||||
.then(() => sessionsApi.statWorkspaceFiles(sessionId, batch))
|
||||
.then(({ files }) => new Map(files.map((file) => [file.path, file])))
|
||||
.catch(() => null)
|
||||
.then((byPath) => {
|
||||
for (const path of batch) resolvers.get(path)!(byPath?.get(path) ?? null)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function request(sessionId: string, path: string): CacheEntry {
|
||||
let resolve!: (stat: WorkspaceFileStat | null) => void
|
||||
const entry: CacheEntry = {
|
||||
at: Date.now(),
|
||||
promise: new Promise((settle) => {
|
||||
resolve = settle
|
||||
}),
|
||||
}
|
||||
void entry.promise.then((stat) => {
|
||||
entry.value = stat
|
||||
})
|
||||
cache.set(cacheKey(sessionId, path), entry)
|
||||
while (cache.size > MAX_CACHED_PATHS) cache.delete(cache.keys().next().value!)
|
||||
|
||||
let resolvers = queued.get(sessionId)
|
||||
if (!resolvers) queued.set(sessionId, resolvers = new Map())
|
||||
resolvers.set(path, resolve)
|
||||
if (!flushScheduled) {
|
||||
flushScheduled = true
|
||||
// Every message that mounts in one render asks in the same tick; one
|
||||
// microtask later they share a request instead of each sending their own.
|
||||
queueMicrotask(flush)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* The stats already known for these paths, or `undefined` when any is not.
|
||||
* Lets a remounted message render its final state on the first paint.
|
||||
*/
|
||||
export function peekWorkspaceFileStats(
|
||||
sessionId: string,
|
||||
paths: string[],
|
||||
): Map<string, WorkspaceFileStat | null> | undefined {
|
||||
const known = new Map<string, WorkspaceFileStat | null>()
|
||||
for (const path of paths) {
|
||||
const entry = freshEntry(sessionId, path)
|
||||
if (!entry || entry.value === undefined) return undefined
|
||||
known.set(path, entry.value)
|
||||
}
|
||||
return known
|
||||
}
|
||||
|
||||
/**
|
||||
* Stat workspace paths through one shared, batched, briefly cached channel.
|
||||
* A path maps to `null` when its request failed — not known, as opposed to
|
||||
* known missing.
|
||||
*/
|
||||
export function statWorkspacePaths(
|
||||
sessionId: string,
|
||||
paths: string[],
|
||||
): Promise<Map<string, WorkspaceFileStat | null>> {
|
||||
const unique = [...new Set(paths)]
|
||||
return Promise.all(unique.map(async (path) => {
|
||||
const entry = freshEntry(sessionId, path) ?? request(sessionId, path)
|
||||
return [path, await entry.promise] as const
|
||||
})).then((entries) => new Map(entries))
|
||||
}
|
||||
|
||||
// `~` means the user's home on the machine that opens the file; the workspace
|
||||
// route would read it as a folder named `~` and call every such file missing.
|
||||
const HOME_RELATIVE_RE = /^~(?:[\\/]|$)/
|
||||
|
||||
/**
|
||||
* Judge a reply's file links against the session workspace.
|
||||
*
|
||||
* `resolve` maps a link's path to what a click on it would open, so the answer is
|
||||
* about the same file. Only `missing` counts as missing: a path the workspace
|
||||
* route may not look at (`unavailable` — outside the workspace) or could not be
|
||||
* asked about keeps its link, and the click decides.
|
||||
*/
|
||||
export function createWorkspaceFileLinkVerifier(
|
||||
sessionId: string,
|
||||
resolve: (path: string) => string,
|
||||
): FileLinkVerifier {
|
||||
const targets = (paths: string[]) => paths.flatMap((path) => {
|
||||
const target = resolve(path)
|
||||
return HOME_RELATIVE_RE.test(target) ? [] : [[path, target] as const]
|
||||
})
|
||||
const missingFrom = (pairs: (readonly [string, string])[], stats: Map<string, WorkspaceFileStat | null>) =>
|
||||
new Set(pairs.filter(([, target]) => stats.get(target)?.state === 'missing').map(([path]) => path))
|
||||
|
||||
return {
|
||||
peekMissing(paths) {
|
||||
const pairs = targets(paths)
|
||||
const stats = peekWorkspaceFileStats(sessionId, pairs.map(([, target]) => target))
|
||||
return stats ? missingFrom(pairs, stats) : undefined
|
||||
},
|
||||
async findMissing(paths) {
|
||||
const pairs = targets(paths)
|
||||
return missingFrom(pairs, await statWorkspacePaths(sessionId, pairs.map(([, target]) => target)))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the workspace says this file is not there — at once when it already
|
||||
* answered, otherwise once it does. A path it cannot judge is never missing.
|
||||
*/
|
||||
export function isWorkspaceFileMissing(sessionId: string, path: string): boolean | Promise<boolean> {
|
||||
const verifier = createWorkspaceFileLinkVerifier(sessionId, (target) => target)
|
||||
const known = verifier.peekMissing([path])
|
||||
return known ? known.has(path) : verifier.findMissing([path]).then((missing) => missing.has(path))
|
||||
}
|
||||
@@ -5691,6 +5691,22 @@ describe('Sessions API', () => {
|
||||
content: 'export const answer = 2\n',
|
||||
})
|
||||
|
||||
const statQuery = new URLSearchParams()
|
||||
for (const statPath of ['src/app.ts', 'out/missing.docx', '../escape.docx']) statQuery.append('path', statPath)
|
||||
const statRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/stat?${statQuery}`)
|
||||
expect(statRes.status).toBe(200)
|
||||
expect(await statRes.json()).toEqual({
|
||||
files: [
|
||||
{ path: 'src/app.ts', state: 'file', mtimeMs: expect.any(Number) },
|
||||
{ path: 'out/missing.docx', state: 'missing' },
|
||||
{ path: '../escape.docx', state: 'unavailable' },
|
||||
],
|
||||
})
|
||||
expect((await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/stat`)).status).toBe(400)
|
||||
const tooMany = new URLSearchParams()
|
||||
for (let index = 0; index < 21; index += 1) tooMany.append('path', `out/${index}.docx`)
|
||||
expect((await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/stat?${tooMany}`)).status).toBe(400)
|
||||
|
||||
const imageRes = await fetch(
|
||||
`${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('assets/pixel.png')}`,
|
||||
)
|
||||
@@ -6873,6 +6889,8 @@ describe('Sessions API', () => {
|
||||
}
|
||||
|
||||
expect(body.checkpoints).toHaveLength(3)
|
||||
// When each prompt was recorded: the client asks the disk for files written since.
|
||||
const PROMPT_RECORDED_AT = Date.parse('2026-01-01T00:01:00.000Z')
|
||||
expect(body.checkpoints).toEqual([
|
||||
{
|
||||
target: {
|
||||
@@ -6890,6 +6908,7 @@ describe('Sessions API', () => {
|
||||
workDir: fixture.workDir,
|
||||
restoreAvailable: true,
|
||||
unverifiedChangeSources: [],
|
||||
startedAt: PROMPT_RECORDED_AT,
|
||||
},
|
||||
{
|
||||
target: {
|
||||
@@ -6907,6 +6926,7 @@ describe('Sessions API', () => {
|
||||
workDir: fixture.workDir,
|
||||
restoreAvailable: true,
|
||||
unverifiedChangeSources: [],
|
||||
startedAt: PROMPT_RECORDED_AT,
|
||||
},
|
||||
{
|
||||
target: {
|
||||
@@ -6924,6 +6944,7 @@ describe('Sessions API', () => {
|
||||
workDir: fixture.workDir,
|
||||
restoreAvailable: true,
|
||||
unverifiedChangeSources: [],
|
||||
startedAt: PROMPT_RECORDED_AT,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -145,6 +145,59 @@ describe('WorkspaceService outside-workspace preview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspaceService.statFiles', () => {
|
||||
beforeEach(() => {
|
||||
clearFilesystemAccessRootsForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearFilesystemAccessRootsForTests()
|
||||
})
|
||||
|
||||
it('reports each path as a written file, missing, or out of bounds, in request order', async () => {
|
||||
const workDir = await makeTempDir('workspace-service-stat-')
|
||||
const outsideDir = await makeTempDir('workspace-service-stat-outside-')
|
||||
await fs.mkdir(path.join(workDir, 'out'))
|
||||
await fs.writeFile(path.join(workDir, 'out', '报告.docx'), 'docx')
|
||||
const writtenAt = new Date('2026-10-04T08:00:05Z')
|
||||
await fs.utimes(path.join(workDir, 'out', '报告.docx'), writtenAt, writtenAt)
|
||||
await fs.writeFile(path.join(outsideDir, 'secret.docx'), 'nope')
|
||||
const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? workDir : null)
|
||||
|
||||
const stats = await service.statFiles('session-1', [
|
||||
'out/报告.docx',
|
||||
'out/missing.docx',
|
||||
'out',
|
||||
path.join(outsideDir, 'secret.docx'),
|
||||
'../escape.docx',
|
||||
])
|
||||
|
||||
expect(stats).toEqual([
|
||||
{ path: 'out/报告.docx', state: 'file', mtimeMs: writtenAt.getTime() },
|
||||
{ path: 'out/missing.docx', state: 'missing' },
|
||||
// A directory is not an output file.
|
||||
{ path: 'out', state: 'missing' },
|
||||
// The file route would refuse these too, so their existence is not told.
|
||||
{ path: path.join(outsideDir, 'secret.docx'), state: 'unavailable' },
|
||||
{ path: '../escape.docx', state: 'unavailable' },
|
||||
])
|
||||
})
|
||||
|
||||
it('stats a file outside the workdir once it is a registered access root', async () => {
|
||||
const workDir = await makeTempDir('workspace-service-stat-')
|
||||
const outsideDir = await makeTempDir('workspace-service-stat-outside-')
|
||||
const outsideFile = path.join(outsideDir, 'report.pdf')
|
||||
await fs.writeFile(outsideFile, 'pdf')
|
||||
registerFilesystemAccessRoot(outsideDir)
|
||||
const service = new WorkspaceService(async () => workDir)
|
||||
|
||||
const [stat] = await service.statFiles('session-1', [outsideFile])
|
||||
|
||||
expect(stat).toMatchObject({ path: outsideFile, state: 'file' })
|
||||
expect(typeof stat?.mtimeMs).toBe('number')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspaceService', () => {
|
||||
it('surfaces bounded transcript failures instead of reporting a clean workspace', async () => {
|
||||
const workDir = await makeTempDir('workspace-history-budget-')
|
||||
|
||||
@@ -682,6 +682,11 @@ async function handleSessionWorkspaceRoute(
|
||||
))
|
||||
case 'search':
|
||||
return handleWorkspaceSearchRoute(workDir, url)
|
||||
case 'stat':
|
||||
return await runWorkspaceRequest(() => workspaceService.statFiles(
|
||||
sessionId,
|
||||
requireWorkspaceStatPaths(url),
|
||||
).then((files) => ({ files })))
|
||||
case 'file':
|
||||
return await runWorkspaceRequest(() => workspaceService.readFile(
|
||||
sessionId,
|
||||
@@ -987,6 +992,19 @@ function requireWorkspacePath(url: URL, route: 'file' | 'diff' | 'raw'): string
|
||||
return filePath
|
||||
}
|
||||
|
||||
const MAX_WORKSPACE_STAT_PATHS = 20
|
||||
|
||||
function requireWorkspaceStatPaths(url: URL): string[] {
|
||||
const paths = [...new Set(url.searchParams.getAll('path').filter(Boolean))]
|
||||
if (paths.length === 0) {
|
||||
throw ApiError.badRequest('path query parameter is required for workspace stat')
|
||||
}
|
||||
if (paths.length > MAX_WORKSPACE_STAT_PATHS) {
|
||||
throw ApiError.badRequest(`workspace stat accepts at most ${MAX_WORKSPACE_STAT_PATHS} paths`)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a workspace service failure into the API error a client can act on.
|
||||
* Shared by the JSON routes and the byte-streaming `raw` route so a path outside
|
||||
|
||||
@@ -130,6 +130,13 @@ export type SessionRewindExecuteResult = SessionRewindPreview & {
|
||||
export type SessionTurnCheckpointPreview = SessionRewindPreview & {
|
||||
workDir: string
|
||||
restoreAvailable: boolean
|
||||
/**
|
||||
* When the turn's prompt was recorded, in epoch ms on the server's clock.
|
||||
* A file the checkpoint cannot see (a shell command wrote it) counts as this
|
||||
* turn's output only if its mtime is not older; comparing two server times
|
||||
* keeps a remote client's clock out of the decision.
|
||||
*/
|
||||
startedAt?: number
|
||||
}
|
||||
|
||||
export type SessionTurnCheckpointDiffResult = {
|
||||
@@ -2220,7 +2227,8 @@ export async function listSessionTurnCheckpoints(
|
||||
frozen,
|
||||
)
|
||||
|
||||
checkpoints.push(checkpoint)
|
||||
const startedAt = Date.parse(turn.userMessage.timestamp)
|
||||
checkpoints.push(Number.isFinite(startedAt) ? { ...checkpoint, startedAt } : checkpoint)
|
||||
}
|
||||
|
||||
return checkpoints
|
||||
|
||||
@@ -130,6 +130,13 @@ export type WorkspaceTreeEntry = {
|
||||
isDirectory: boolean
|
||||
}
|
||||
|
||||
export type WorkspaceFileStat = {
|
||||
/** The path exactly as requested. */
|
||||
path: string
|
||||
state: 'file' | 'missing' | 'unavailable'
|
||||
mtimeMs?: number
|
||||
}
|
||||
|
||||
export type WorkspaceTreeResult = {
|
||||
state: 'ok' | 'missing' | 'error'
|
||||
path: string
|
||||
@@ -177,6 +184,12 @@ type GitRepoInfo =
|
||||
message: string
|
||||
}
|
||||
|
||||
type ResolvedWorkspaceRoot = {
|
||||
workDir: string
|
||||
workspaceRoot: string
|
||||
canonicalWorkspaceRoot: string
|
||||
}
|
||||
|
||||
type WorkspacePathResolution = {
|
||||
requestedPath: string
|
||||
relativePath: string
|
||||
@@ -653,6 +666,35 @@ export class WorkspaceService {
|
||||
return stop
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether each path is a file, and when it was last written. Paths resolve
|
||||
* under the same boundary as {@link readFile}, so this tells a caller nothing
|
||||
* the file route would not; a path that boundary refuses is `unavailable`.
|
||||
*/
|
||||
async statFiles(
|
||||
sessionId: string,
|
||||
filePaths: string[],
|
||||
): Promise<WorkspaceFileStat[]> {
|
||||
// One reply's links arrive together; look the workspace up once for all of them.
|
||||
let root: ResolvedWorkspaceRoot
|
||||
try {
|
||||
root = await this.resolveWorkspaceRoot(sessionId)
|
||||
} catch {
|
||||
return filePaths.map((filePath) => ({ path: filePath, state: 'unavailable' }))
|
||||
}
|
||||
return Promise.all(filePaths.map(async (filePath): Promise<WorkspaceFileStat> => {
|
||||
try {
|
||||
const resolvedPath = await this.resolveWorkspacePathIn(root, filePath)
|
||||
const stat = await this.safeStat(resolvedPath.absolutePath)
|
||||
if (stat.kind === 'error') return { path: filePath, state: 'unavailable' }
|
||||
if (stat.kind === 'missing' || !stat.stat.isFile()) return { path: filePath, state: 'missing' }
|
||||
return { path: filePath, state: 'file', mtimeMs: stat.stat.mtimeMs }
|
||||
} catch {
|
||||
return { path: filePath, state: 'unavailable' }
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async readTree(
|
||||
sessionId: string,
|
||||
treePath = '',
|
||||
@@ -1254,6 +1296,11 @@ export class WorkspaceService {
|
||||
sessionId: string,
|
||||
requestedPath: string,
|
||||
): Promise<WorkspacePathResolution> {
|
||||
return this.resolveWorkspacePathIn(await this.resolveWorkspaceRoot(sessionId), requestedPath)
|
||||
}
|
||||
|
||||
/** The session's workdir and its canonical root — the same for every path a request names. */
|
||||
private async resolveWorkspaceRoot(sessionId: string): Promise<ResolvedWorkspaceRoot> {
|
||||
const workDir = await this.requireWorkDir(sessionId)
|
||||
const workspaceRoot = await this.getWorkspaceRoot(workDir)
|
||||
if (workspaceRoot.kind === 'missing') {
|
||||
@@ -1262,7 +1309,13 @@ export class WorkspaceService {
|
||||
if (workspaceRoot.kind === 'error') {
|
||||
throw new Error(workspaceRoot.message)
|
||||
}
|
||||
return { workDir, ...workspaceRoot }
|
||||
}
|
||||
|
||||
private async resolveWorkspacePathIn(
|
||||
{ workDir, ...workspaceRoot }: ResolvedWorkspaceRoot,
|
||||
requestedPath: string,
|
||||
): Promise<WorkspacePathResolution> {
|
||||
const absolutePath = path.resolve(workDir, requestedPath || '.')
|
||||
if (!this.isWithinRoot(absolutePath, workDir)) {
|
||||
// Files this session changed outside its workdir (the user pointed the
|
||||
|
||||
Reference in New Issue
Block a user