fix(desktop): show generated files once per turn

This commit is contained in:
程序员阿江(Relakkes)
2026-08-23 02:18:50 +08:00
parent 4575887630
commit ba8a5cb832
5 changed files with 251 additions and 7 deletions
@@ -24,13 +24,23 @@ 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[]
/** 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. */
turnCompletion?: TurnCompletion
}
const MAX_CARDS = 3
export const AssistantMessage = memo(function AssistantMessage({ content, isStreaming, branchAction, sessionId, turnChangedFiles, turnCompletion }: Props) {
export const AssistantMessage = memo(function AssistantMessage({
content,
isStreaming,
branchAction,
sessionId,
turnChangedFiles,
isTurnOutputOwner = true,
turnCompletion,
}: Props) {
const t = useTranslation()
const workDir = useWorkspacePanelStore((s) => (sessionId ? s.statusBySession[sessionId]?.workDir : undefined))
@@ -78,10 +88,14 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
isStreaming || !sessionId
? []
: // Image/video targets render inline (InlineImageGallery/InlineVideoGallery); never also as a card.
extractAssistantOutputTargets(content, { workDir, changedFiles: turnChangedFiles }).filter(
extractAssistantOutputTargets(content, {
workDir,
changedFiles: turnChangedFiles,
includeChangedFileFallback: isTurnOutputOwner,
}).filter(
(target) => target.kind !== 'image' && target.kind !== 'video',
),
[content, isStreaming, sessionId, workDir, turnChangedFiles],
[content, isStreaming, isTurnOutputOwner, sessionId, workDir, turnChangedFiles],
)
if (!content.trim()) return null
@@ -6871,6 +6871,161 @@ describe('MessageList nested tool calls', () => {
})
})
it('assigns changed-file output fallback to only the final assistant text in a turn', async () => {
const generatedPath = '/private/tmp/ink-survey-philosophy.md'
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
checkpoints: [
{
target: {
targetUserMessageId: 'transcript-user-1',
userMessageIndex: 0,
userMessageCount: 1,
},
workDir: '/private/tmp',
code: {
available: true,
filesChanged: [generatedPath],
insertions: 15,
deletions: 0,
},
},
],
})
render(<MessageList sessionId={ACTIVE_TAB} />)
const store = useChatStore.getState()
act(() => {
store.sendMessage(ACTIVE_TAB, 'Create one Markdown document')
store.handleServerMessage(ACTIVE_TAB, {
type: 'content_start',
blockType: 'tool_use',
toolName: 'Skill',
toolUseId: 'skill-1',
})
store.handleServerMessage(ACTIVE_TAB, {
type: 'tool_use_complete',
toolName: 'Skill',
toolUseId: 'skill-1',
input: { skill: 'imagegen' },
})
store.handleServerMessage(ACTIVE_TAB, {
type: 'tool_result',
toolUseId: 'skill-1',
content: 'Launching skill: imagegen',
isError: false,
})
store.handleServerMessage(ACTIVE_TAB, { type: 'content_start', blockType: 'text' })
store.handleServerMessage(ACTIVE_TAB, { type: 'content_delta', text: 'SKILL_PROGRESS' })
store.handleServerMessage(ACTIVE_TAB, {
type: 'content_start',
blockType: 'tool_use',
toolName: 'ToolSearch',
toolUseId: 'search-1',
})
store.handleServerMessage(ACTIVE_TAB, {
type: 'tool_use_complete',
toolName: 'ToolSearch',
toolUseId: 'search-1',
input: { query: 'select:ImageGen' },
})
store.handleServerMessage(ACTIVE_TAB, {
type: 'tool_result',
toolUseId: 'search-1',
content: 'ImageGen',
isError: false,
})
store.handleServerMessage(ACTIVE_TAB, { type: 'content_start', blockType: 'text' })
store.handleServerMessage(ACTIVE_TAB, { type: 'content_delta', text: 'TOOLSEARCH_PROGRESS' })
store.handleServerMessage(ACTIVE_TAB, {
type: 'content_start',
blockType: 'tool_use',
toolName: 'Write',
toolUseId: 'write-1',
})
store.handleServerMessage(ACTIVE_TAB, {
type: 'tool_use_complete',
toolName: 'Write',
toolUseId: 'write-1',
input: { file_path: generatedPath, content: '# 墨痕测绘' },
})
store.handleServerMessage(ACTIVE_TAB, {
type: 'tool_result',
toolUseId: 'write-1',
content: `File created successfully at: ${generatedPath}`,
isError: false,
})
store.handleServerMessage(ACTIVE_TAB, { type: 'content_start', blockType: 'text' })
store.handleServerMessage(ACTIVE_TAB, {
type: 'content_delta',
text: `FINAL_DELIVERY\n\n\`${generatedPath}\``,
})
store.handleServerMessage(ACTIVE_TAB, { type: 'status', state: 'idle' })
})
const turnCard = await screen.findByLabelText('Turn changed files')
await waitFor(() => {
expect(screen.getAllByRole('button', { name: 'Open' })).toHaveLength(1)
})
const firstProgressItem = screen.getByText('SKILL_PROGRESS').closest('[data-chat-render-item-key]')
const secondProgressItem = screen.getByText('TOOLSEARCH_PROGRESS').closest('[data-chat-render-item-key]')
const finalItem = screen.getByText('FINAL_DELIVERY').closest('[data-chat-render-item-key]')
expect(firstProgressItem).not.toBeNull()
expect(secondProgressItem).not.toBeNull()
expect(finalItem).not.toBeNull()
expect(within(firstProgressItem as HTMLElement).queryByRole('button', { name: 'Open' })).toBeNull()
expect(within(secondProgressItem as HTMLElement).queryByRole('button', { name: 'Open' })).toBeNull()
expect(within(finalItem as HTMLElement).getByRole('button', { name: 'Open' })).toBeTruthy()
expect(within(turnCard).getByText('ink-survey-philosophy.md')).toBeTruthy()
})
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({
checkpoints: [0, 1].map((userMessageIndex) => ({
target: {
targetUserMessageId: `transcript-user-${userMessageIndex + 1}`,
userMessageIndex,
userMessageCount: 2,
},
workDir: '/private/tmp',
code: {
available: true,
filesChanged: [generatedPath],
insertions: 1,
deletions: 0,
},
})),
})
render(<MessageList sessionId={ACTIVE_TAB} />)
const store = useChatStore.getState()
act(() => {
store.sendMessage(ACTIVE_TAB, 'Generate the first report')
store.handleServerMessage(ACTIVE_TAB, { type: 'content_start', blockType: 'text' })
store.handleServerMessage(ACTIVE_TAB, {
type: 'content_delta',
text: 'FIRST_REPORT',
})
store.handleServerMessage(ACTIVE_TAB, { type: 'status', state: 'idle' })
store.sendMessage(ACTIVE_TAB, 'Update the same report again')
store.handleServerMessage(ACTIVE_TAB, { type: 'content_start', blockType: 'text' })
store.handleServerMessage(ACTIVE_TAB, {
type: 'content_delta',
text: 'SECOND_REPORT',
})
store.handleServerMessage(ACTIVE_TAB, { type: 'status', state: 'idle' })
})
expect(await screen.findAllByLabelText('Turn changed files')).toHaveLength(2)
await waitFor(() => {
expect(screen.getAllByRole('button', { name: 'Open' })).toHaveLength(2)
})
})
it('keeps an inline absolute image when the turn checkpoint recorded no tracked changes', async () => {
// Regression: Bash-written files (e.g. a PIL render at /tmp/result.png) are
// invisible to the checkpoint, so filesChanged=[] must NOT hide the image.
+47 -3
View File
@@ -1306,6 +1306,41 @@ function buildChangedFilesByRenderIndex(
return filesByRenderIndex
}
/**
* Pick one assistant message per checkpointed turn to own generated-artifact
* fallback cards. Every assistant message still receives changedFiles for path
* reconciliation; only the last visible reply may append files absent from its
* prose, otherwise each progress update repeats the same turn-wide artifacts.
*/
function buildTurnOutputOwnerIndexes(
renderItems: RenderItem[],
turnChangeCards: TurnChangeCardModel[],
): Set<number> {
const checkpointTurnIds = new Set(
turnChangeCards.map((card) => card.target.messageId),
)
if (checkpointTurnIds.size === 0) return new Set()
const lastAssistantIndexByTurnId = new Map<string, number>()
let activeTurnId: string | null = null
renderItems.forEach((item, index) => {
if (item.kind === 'message' && item.message.type === 'user_text' && !item.message.pending) {
activeTurnId = item.message.id
return
}
if (
activeTurnId &&
checkpointTurnIds.has(activeTurnId) &&
item.kind === 'message' &&
item.message.type === 'assistant_text'
) {
lastAssistantIndexByTurnId.set(activeTurnId, index)
}
})
return new Set(lastAssistantIndexByTurnId.values())
}
/**
* Where a render item sits inside its turn, which is what decides its spacing.
*
@@ -1320,10 +1355,11 @@ export type TurnRailPosition = 'none' | 'start' | 'middle' | 'end' | 'solo'
/**
* Rail position for every render item, indexed alongside `renderItems`.
*
* Deliberately breaks on EVERY `user_text`, unlike the three turn walks above
* Deliberately breaks on EVERY `user_text`, unlike the turn-attribution walks above
* (`buildTurnCardInsertionMap`, `buildChangedFilesByRenderIndex`,
* `getBranchableMessageTargets`) which skip `pending` ones. Those answer "which
* turn owns this checkpoint", and a member session's pending echo must not steal
* `buildTurnOutputOwnerIndexes`, `getBranchableMessageTargets`) which skip
* `pending` ones. Those answer "which turn owns this checkpoint", and a member
* session's pending echo must not steal
* ownership. This answers "where does the line stop", and a pending message still
* renders as a visible right-aligned bubble (see the `user_text` case in
* `MessageBlock`) — a bubble mid-column is a break whatever it means for
@@ -2796,6 +2832,10 @@ export function MessageList({
() => buildChangedFilesByRenderIndex(renderItems, turnChangeCards),
[renderItems, turnChangeCards],
)
const turnOutputOwnerIndexes = useMemo(
() => buildTurnOutputOwnerIndexes(renderItems, turnChangeCards),
[renderItems, turnChangeCards],
)
const hasTrailingStreamingItem = streamingText.trim().length > 0
const turnRailPositions = useMemo(
() => buildTurnRailPositions(renderItems, { hasTrailingStreamingItem }),
@@ -3459,6 +3499,7 @@ export function MessageList({
}
branchAction={branchActionByMessageId.get(item.message.id)}
turnChangedFiles={changedFilesByRenderIndex.get(index)}
isTurnOutputOwner={turnOutputOwnerIndexes.has(index)}
turnCompletion={turnCompletionByMessageId.get(item.message.id)}
/>
)}
@@ -3628,6 +3669,7 @@ export const MessageBlock = memo(function MessageBlock({
toolResult,
branchAction,
turnChangedFiles,
isTurnOutputOwner,
turnCompletion,
}: {
sessionId?: string | null
@@ -3642,6 +3684,7 @@ export const MessageBlock = memo(function MessageBlock({
onBranch: () => void
}
turnChangedFiles?: string[]
isTurnOutputOwner?: boolean
turnCompletion?: TurnCompletion
}) {
const t = useTranslation()
@@ -3693,6 +3736,7 @@ export const MessageBlock = memo(function MessageBlock({
branchAction={branchAction}
sessionId={sessionId ?? undefined}
turnChangedFiles={turnChangedFiles}
isTurnOutputOwner={isTurnOutputOwner}
turnCompletion={turnCompletion}
/>
</SelectableChatMessage>
@@ -330,6 +330,23 @@ describe('extractAssistantOutputTargets with changedFiles reconciliation', () =>
expect(targets.some((target) => target.normalizedPath === 'src/main.ts')).toBe(false)
})
it('reconciles mentioned paths without sweeping unmentioned artifacts for a non-owner reply', () => {
const targets = extractAssistantOutputTargets('正在处理 `index.html`。', {
workDir: '/work',
changedFiles: ['/work/app/index.html', '/work/reports/brief.docx'],
includeChangedFileFallback: false,
})
expect(targets).toMatchObject([
{
kind: 'local-html',
href: 'app/index.html',
normalizedPath: 'app/index.html',
},
])
expect(targets.some((target) => target.normalizedPath === 'reports/brief.docx')).toBe(false)
})
it('corrects a bare mention to the real changed path in a subfolder', () => {
// The reported bug: the model writes /private/tmp/todo-app/index.html but the
// prose only says `index.html`, so the chip used to point at the (missing)
+15 -1
View File
@@ -42,6 +42,11 @@ export type ExtractAssistantOutputTargetOptions = {
* an empty array confirms the turn changed no files, so file targets are dropped.
*/
changedFiles?: string[]
/**
* Whether generated artifacts from changedFiles may be appended when the prose
* did not mention them. Reconciliation still runs when false. Defaults to true.
*/
includeChangedFileFallback?: boolean
}
type FileTargetMatch = {
@@ -257,7 +262,13 @@ export function extractAssistantOutputTargets(
}
if (options.changedFiles !== undefined) {
return reconcileTargetsWithChangedFiles(results, options.changedFiles, workDir, limit)
return reconcileTargetsWithChangedFiles(
results,
options.changedFiles,
workDir,
limit,
options.includeChangedFileFallback !== false,
)
}
return results
@@ -311,6 +322,7 @@ function reconcileTargetsWithChangedFiles(
changedFiles: string[],
workDir: string | null,
limit: number,
includeChangedFileFallback: boolean,
): AssistantOutputTarget[] {
if (limit <= 0) return []
@@ -363,6 +375,8 @@ function reconcileTargetsWithChangedFiles(
if (out.length >= limit) break
}
if (!includeChangedFileFallback) return out
// A generated document is still a deliverable when the assistant forgets to
// repeat its path in the final prose. The checkpoint is upstream identity:
// unlike text extraction, it tells us which path this turn actually wrote.