mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
fix(desktop): show generated files once per turn
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user