diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index dff1e68a..7b4cb617 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -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(`/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(buildWorkspacePath(sessionId, 'file', workspacePath), { signal }) }, diff --git a/desktop/src/components/chat/AssistantMessage.filepaths.test.tsx b/desktop/src/components/chat/AssistantMessage.filepaths.test.tsx index 85a51557..8486f3ba 100644 --- a/desktop/src/components/chat/AssistantMessage.filepaths.test.tsx +++ b/desktop/src/components/chat/AssistantMessage.filepaths.test.tsx @@ -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() - 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() - 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() - 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() - 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() + const { container } = render() expect(container.querySelectorAll('video')).toHaveLength(1) expect(container.querySelector('video')).toHaveAttribute('src', 'http://127.0.0.1:4321/local-file/other/promo/out/movie.mp4') - fireEvent.click(screen.getByText('track.wav').closest('button')!) + // 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() + render() 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() + render() 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() - fireEvent.click(screen.getByRole('link', { name: 'track.wav' })) + render() + 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() + + 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() + + // 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() + 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() + + 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() + + // 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() + + 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() + await first.findByRole('link', { name: 'src/app.ts:7' }) + first.unmount() + + render() + + 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() + + 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() + + expect(screen.getByRole('link', { name: '~/notes/plan.md' })).toBeInTheDocument() + expect(statWorkspaceFiles).not.toHaveBeenCalled() + }) + }) + it('does not linkify a bare path mid-stream', () => { render() 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() - 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() - 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() - 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' })) diff --git a/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx b/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx index 3149508a..7cc8aa4f 100644 --- a/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx +++ b/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx @@ -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() diff --git a/desktop/src/components/chat/AssistantMessage.tsx b/desktop/src/components/chat/AssistantMessage.tsx index e4ad6a12..5d456802 100644 --- a/desktop/src/components/chat/AssistantMessage.tsx +++ b/desktop/src/components/chat/AssistantMessage.tsx @@ -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 && ( ({ 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() 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() 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', () => { diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 5755bcdc..14292696 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -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() + 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() + 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({ diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 66c0e435..1fc36c9d 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -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 { - const filesByTurnId = new Map() +): Map { + const filesByTurnId = new Map() 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() + const filesByRenderIndex = new Map() 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 @@ -4006,6 +4024,7 @@ export const MessageBlock = memo(function MessageBlock({ branchAction={branchAction} sessionId={sessionId ?? undefined} turnChangedFiles={turnChangedFiles} + turnOutputEvidence={turnOutputEvidence} isTurnOutputOwner={isTurnOutputOwner} turnCompletion={turnCompletion} /> diff --git a/desktop/src/components/markdown/MarkdownRenderer.tsx b/desktop/src/components/markdown/MarkdownRenderer.tsx index f3c78ba6..fe3d7172 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.tsx @@ -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 `${this.parser.parseInline(token.tokens)}` + return `${this.parser.parseInline(token.tokens)}` } 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) => { const target = event.target as HTMLElement | null const button = target?.closest('[data-copy-code]') @@ -720,7 +754,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian return ( ) @@ -728,7 +762,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian return (
- {parts.map((part, i) => + {visibleParts.map((part, i) => part.type === 'html' ? ( ) : shouldRenderAsMermaid(part.block) ? ( diff --git a/desktop/src/hooks/useFileLinkVerification.ts b/desktop/src/hooks/useFileLinkVerification.ts new file mode 100644 index 00000000..a92e14df --- /dev/null +++ b/desktop/src/hooks/useFileLinkVerification.ts @@ -0,0 +1,39 @@ +import { useEffect, useMemo, useState } from 'react' +import type { FileLinkVerifier } from '@/lib/markdownAutolink' + +const PATH_SEPARATOR = '\u0000' +const NONE_MISSING: ReadonlySet = 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 | 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 } | 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 +} diff --git a/desktop/src/hooks/useTurnWrittenTargets.test.tsx b/desktop/src/hooks/useTurnWrittenTargets.test.tsx new file mode 100644 index 00000000..6dd2b0c6 --- /dev/null +++ b/desktop/src/hooks/useTurnWrittenTargets.test.tsx @@ -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 + +function onDisk(files: Record) { + 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]) + }) +}) diff --git a/desktop/src/hooks/useTurnWrittenTargets.ts b/desktop/src/hooks/useTurnWrittenTargets.ts new file mode 100644 index 00000000..085c8317 --- /dev/null +++ b/desktop/src/hooks/useTurnWrittenTargets.ts @@ -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 } + +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(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]) +} diff --git a/desktop/src/lib/assistantOutputTargets.test.ts b/desktop/src/lib/assistantOutputTargets.test.ts index 07e850f9..174a2b6f 100644 --- a/desktop/src/lib/assistantOutputTargets.test.ts +++ b/desktop/src/lib/assistantOutputTargets.test.ts @@ -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], + ]) + }) +}) diff --git a/desktop/src/lib/assistantOutputTargets.ts b/desktop/src/lib/assistantOutputTargets.ts index 9f44d816..3ddff5f1 100644 --- a/desktop/src/lib/assistantOutputTargets.ts +++ b/desktop/src/lib/assistantOutputTargets.ts @@ -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 } 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, ): 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 diff --git a/desktop/src/lib/markdownAutolink.ts b/desktop/src/lib/markdownAutolink.ts index 6daeb4c3..a4ef7be2 100644 --- a/desktop/src/lib/markdownAutolink.ts +++ b/desktop/src/lib/markdownAutolink.ts @@ -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 | undefined + /** Paths known missing. A path that could not be checked is not missing. */ + findMissing(paths: string[]): Promise> +} + +const GUESSED_FILE_LINK_SELECTOR = `a.${FILE_LINK_CLASS}:not([${AUTHORED_FILE_LINK_ATTRIBUTE}])` + +export function guessedFileLinkPaths(container: ParentNode): string[] { + return [...container.querySelectorAll(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(GUESSED_FILE_LINK_SELECTOR).forEach((anchor) => { + if (!keep(anchor.dataset.filePath ?? '')) anchor.replaceWith(...anchor.childNodes) + }) +} + /** * Rebuild the `path:line` reference an anchor stands for. * diff --git a/desktop/src/lib/openPreviewLink.test.ts b/desktop/src/lib/openPreviewLink.test.ts index 1f10c245..17a9965f 100644 --- a/desktop/src/lib/openPreviewLink.test.ts +++ b/desktop/src/lib/openPreviewLink.test.ts @@ -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() }) }) diff --git a/desktop/src/lib/openPreviewLink.ts b/desktop/src/lib/openPreviewLink.ts index bf4f5bc1..201a5793 100644 --- a/desktop/src/lib/openPreviewLink.ts +++ b/desktop/src/lib/openPreviewLink.ts @@ -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, diff --git a/desktop/src/lib/workspaceFileStats.test.ts b/desktop/src/lib/workspaceFileStats.test.ts new file mode 100644 index 00000000..7e638b87 --- /dev/null +++ b/desktop/src/lib/workspaceFileStats.test.ts @@ -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 + +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) + }) +}) diff --git a/desktop/src/lib/workspaceFileStats.ts b/desktop/src/lib/workspaceFileStats.ts new file mode 100644 index 00000000..8875436d --- /dev/null +++ b/desktop/src/lib/workspaceFileStats.ts @@ -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 +} + +const cache = new Map() +const queued = new Map 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 | undefined { + const known = new Map() + 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> { + 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) => + 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 { + const verifier = createWorkspaceFileLinkVerifier(sessionId, (target) => target) + const known = verifier.peekMissing([path]) + return known ? known.has(path) : verifier.findMissing([path]).then((missing) => missing.has(path)) +} diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 6dbc42a5..b675f919 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -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, }, ]) }) diff --git a/src/server/__tests__/workspace-service.test.ts b/src/server/__tests__/workspace-service.test.ts index 6d6670d7..81b56129 100644 --- a/src/server/__tests__/workspace-service.test.ts +++ b/src/server/__tests__/workspace-service.test.ts @@ -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-') diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index 2eda133c..e1a3d10d 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -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 diff --git a/src/server/services/sessionRewindService.ts b/src/server/services/sessionRewindService.ts index 6890e9ac..3980f597 100644 --- a/src/server/services/sessionRewindService.ts +++ b/src/server/services/sessionRewindService.ts @@ -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 diff --git a/src/server/services/workspaceService.ts b/src/server/services/workspaceService.ts index 81efbc20..590a67b7 100644 --- a/src/server/services/workspaceService.ts +++ b/src/server/services/workspaceService.ts @@ -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 { + // 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 => { + 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 { + 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 { 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 { const absolutePath = path.resolve(workDir, requestedPath || '.') if (!this.isWithinRoot(absolutePath, workDir)) { // Files this session changed outside its workdir (the user pointed the