diff --git a/mobile/src/i18n/en-runtime-required.json b/mobile/src/i18n/en-runtime-required.json index 20e1dd8d677..e2d21aa3e7e 100644 --- a/mobile/src/i18n/en-runtime-required.json +++ b/mobile/src/i18n/en-runtime-required.json @@ -29,9 +29,9 @@ "workspaceHostAmbiguous": "Its workspace spans more than one host, so it has no single owner." }, "runFailures": { + "agentInterrupted": "The agent was interrupted before it finished this run.", "agentNeverStarted": "Automation agent never started after the prompt was submitted.", "agentNoCompletion": "Automation agent did not report completion.", - "agentInterrupted": "The agent was interrupted before it finished this run.", "automationChanged": "The automation changed before this run could launch.", "destinationChanged": "The automation destination changed before this run could launch.", "hooksReviewBlocked": "The agent is waiting for someone to review new or changed hooks (\"Hooks need review\"). Open this run and trust them, or remove them. Scheduled runs keep going and succeed once nothing needs review.", @@ -152,6 +152,12 @@ "needsYou_one": "{{count}} needs you", "needsYou_other": "{{count}} need you" }, + "native-chat": { + "turnDiff": { + "changedFiles_one": "{{count}} changed file", + "changedFiles_other": "{{count}} changed files" + } + }, "worktreeAgentList": { "hideChildAgents_one": "Hide {{count}} child agent", "hideChildAgents_other": "Hide {{count}} child agents", diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index e45b68c5237..fac3665fc28 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -1377,6 +1377,14 @@ "noMatches": "No files match", "current": "current", "viewedFile": "{{path}} viewed" + }, + "native-chat": { + "turnDiff": { + "changedFiles_one": "{{count}} changed file", + "changedFiles_other": "{{count}} changed files", + "partial": "Partial diff", + "showMoreFiles": "Show {{count}} more" + } } }, "pullRequest": { diff --git a/mobile/src/session/native-chat/MobileNativeChatDiffCard.test.tsx b/mobile/src/session/native-chat/MobileNativeChatDiffCard.test.tsx new file mode 100644 index 00000000000..42da6626f1e --- /dev/null +++ b/mobile/src/session/native-chat/MobileNativeChatDiffCard.test.tsx @@ -0,0 +1,36 @@ +import './transcript/mobile-agent-message-renderer.test-fixture' +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, expect, it } from 'vitest' +import { findAllByHostType } from '../../test-support/react-test-renderer-host-nodes' +import { Transcript, textIn } from './transcript/mobile-native-chat-turn-diff.test-fixture' + +let renderer: ReactTestRenderer | null = null +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null +}) + +it('shows finished-turn files outside folded work and opens a patch with one tap', () => { + act(() => { + renderer = create(createElement(Transcript, { working: true })) + }) + expect(textIn(renderer!.root)).toContain('greeting.ts') + act(() => { + renderer!.update(createElement(Transcript)) + }) + expect(textIn(renderer!.root)).toContain('greeting.ts') + expect(textIn(renderer!.root)).not.toContain('diagnostic-output') + expect(textIn(renderer!.root)).not.toContain('helloNew()') + const file = findAllByHostType(renderer!.root, 'Pressable').find( + (node) => node.props.accessibilityLabel === '/repo/greeting.ts' + )! + act(() => file.props.onPress()) + expect(textIn(renderer!.root)).toContain('helloNew()') + expect(textIn(renderer!.root)).not.toContain('statusNew()') + act(() => renderer!.unmount()) + act(() => { + renderer = create(createElement(Transcript)) + }) + expect(textIn(renderer!.root)).toContain('status.ts') +}) diff --git a/mobile/src/session/native-chat/MobileNativeChatDiffCard.tsx b/mobile/src/session/native-chat/MobileNativeChatDiffCard.tsx index e2f9390b418..1781e8092b3 100644 --- a/mobile/src/session/native-chat/MobileNativeChatDiffCard.tsx +++ b/mobile/src/session/native-chat/MobileNativeChatDiffCard.tsx @@ -130,19 +130,21 @@ export function MobileNativeChatDiffCard({ fontScale, disclosureKey, rootIdentity, + initialOpen = false, onOpenFile }: { file: NativeChatEditFile fontScale: number disclosureKey?: string rootIdentity?: string + initialOpen?: boolean onOpenFile?: (path: string) => void }): React.JSX.Element { useMobileColorScheme() const t = useMobileTranslation() const copy = nativeChatDiffCardCopy(t) const verbs = nativeChatToolRowVerbCopy(t) - const [expanded, setExpanded] = useMobileNativeChatDisclosure(disclosureKey, false) + const [expanded, setExpanded] = useMobileNativeChatDisclosure(disclosureKey, initialOpen) const hasBody = file.lines.length > 0 const copyText = useMemo(() => nativeChatEditFilePatchText(file.lines), [file.lines]) const type = toolType(fontScale) diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatRowItemSource.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatRowItemSource.tsx index 5163ace466c..d1ac4b7d0bf 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatRowItemSource.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatRowItemSource.tsx @@ -1,3 +1,8 @@ +import { + sameNativeChatTurnDiff, + type NativeChatTurnDiff +} from '../../../../../src/shared/native-chat/tools/native-chat-turn-diffs' +import { MobileNativeChatTurnDiffRollup } from './MobileNativeChatTurnDiffRollup' import { memo, useContext, useMemo, type ComponentProps } from 'react' import { MobileNativeChatItemSourceContext, @@ -23,13 +28,23 @@ type Recovery = MobileNativeChatVisualSource & { } export type MobileNativeChatRowContentProps = ComponentProps & { scope: string + turnDiff?: NativeChatTurnDiff source: Recovery | null } +const TurnDiffOutcome = memo( + MobileNativeChatTurnDiffRollup, + (previous, next) => + sameNativeChatTurnDiff(previous.diff, next.diff) && + previous.fontScale === next.fontScale && + previous.onOpenFile === next.onOpenFile +) + // React's mounted content props are the sole reuse baseline. export const MobileNativeChatRowContent = memo(function MobileNativeChatRowContent({ source, scope: _scope, + turnDiff, ...props }: MobileNativeChatRowContentProps) { const value = useMemo(() => { @@ -50,6 +65,13 @@ export const MobileNativeChatRowContent = memo(function MobileNativeChatRowConte return ( + {turnDiff ? ( + + ) : null} ) }, equalMobileNativeChatRowContent) diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatTurnDiffRollup.test.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatTurnDiffRollup.test.tsx new file mode 100644 index 00000000000..2bbb2378601 --- /dev/null +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatTurnDiffRollup.test.tsx @@ -0,0 +1,69 @@ +import './mobile-agent-message-renderer.test-fixture' +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, expect, it, vi } from 'vitest' +import { messages, Transcript } from './mobile-native-chat-turn-diff.test-fixture' +import { MobileNativeChatTurnDiffRollup } from './MobileNativeChatTurnDiffRollup' +import type * as TurnDiffRollupModule from './MobileNativeChatTurnDiffRollup' + +vi.mock('./MobileNativeChatTurnDiffRollup', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, MobileNativeChatTurnDiffRollup: vi.fn(actual.MobileNativeChatTurnDiffRollup) } +}) + +let renderer: ReactTestRenderer | null = null +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null +}) + +it.each([10, 100])( + 'does not repaint unrelated outcome rows while another turn streams (%i turns)', + (count) => { + const rows = Array.from({ length: count }, (_, index) => + messages.map((message) => ({ + ...message, + id: `${index}:${message.id}` + })) + ).flat() + act(() => { + renderer = create(createElement(Transcript, { rows })) + }) + const outcome = vi.mocked(MobileNativeChatTurnDiffRollup) + const before = outcome.mock.calls.length + const last = rows.at(-1)! + const updated = [ + ...rows.slice(0, -1), + { ...last, blocks: [{ type: 'text' as const, text: 'Updated again.' }] } + ] + act(() => { + renderer!.update(createElement(Transcript, { rows: updated })) + }) + expect(outcome.mock.calls.length - before).toBe(0) + const changed = updated.map((message) => + message.id === `${count - 1}:edit` + ? { + ...message, + blocks: message.blocks.map((block) => + block.type === 'tool-call' && block.name === 'Edit' + ? { + ...block, + input: { + file_path: '/repo/greeting.ts', + old_string: 'helloOld()', + new_string: 'helloNewest()' + } + } + : block + ) + } + : message + ) + act(() => { + renderer!.update(createElement(Transcript, { rows: changed })) + }) + expect(outcome.mock.calls.length - before).toBe(1) + const latest = outcome.mock.calls.at(-1)![0].diff + expect(latest.files.map((file) => file.path)).toContain('/repo/greeting.ts') + } +) diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatTurnDiffRollup.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatTurnDiffRollup.tsx new file mode 100644 index 00000000000..319cb83d08e --- /dev/null +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatTurnDiffRollup.tsx @@ -0,0 +1,155 @@ +import { Pressable, Text, View } from 'react-native' +import { ChevronRight } from 'lucide-react-native' +import { + NATIVE_CHAT_TURN_DIFF_FILE_LIMIT, + nativeChatTurnDiffEditFile, + type NativeChatTurnDiff, + type NativeChatTurnDiffFile +} from '../../../../../src/shared/native-chat/tools/native-chat-turn-diffs' +import { MobileNativeChatDiffCard } from '../MobileNativeChatDiffCard' +import { useMobileNativeChatDisclosure } from './mobile-native-chat-disclosure-store' +import { useMobileTranslation } from '../../../i18n/use-mobile-translation' +import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme' +import { colors, pressedStyle, radii, spacing } from '../../../theme/mobile-theme' +import { themedStyleSheet } from '../../../theme/themed-styles' +import { toolType } from './mobile-native-chat-tool-styles' +import { nativeChatEditFileBaseName } from '../../../../../src/shared/native-chat/tools/native-chat-edit-model' + +function FileRow({ + file, + fontScale, + onOpenFile +}: { + file: NativeChatTurnDiffFile + fontScale: number + onOpenFile?: (path: string) => void +}): React.JSX.Element { + const [open, setOpen] = useMobileNativeChatDisclosure( + `turn-diff:${file.edits[0]?.target.messageId}:${file.path}`, + false + ) + const type = toolType(fontScale) + const name = nativeChatEditFileBaseName(file.path) + return ( + + [styles.file, pressed && pressedStyle]} + accessibilityRole="button" + accessibilityLabel={file.path} + accessibilityState={{ expanded: open }} + onPress={() => setOpen(!open)} + > + + + {name} + + + {file.path.slice(0, -name.length)} + + +{file.added} + -{file.removed} + + {open + ? file.edits.map((edit) => { + const patch = nativeChatTurnDiffEditFile(edit) + const key = `turn-diff-card:${edit.target.messageId}:${edit.target.editKey}:${edit.fileIndex}` + return patch ? ( + + ) : null + }) + : null} + + ) +} + +export function MobileNativeChatTurnDiffRollup({ + diff, + fontScale, + onOpenFile +}: { + diff: NativeChatTurnDiff + fontScale: number + onOpenFile?: (path: string) => void +}): React.JSX.Element { + useMobileColorScheme() + const t = useMobileTranslation() + const [showAll, setShowAll] = useMobileNativeChatDisclosure( + `turn-diff-files:${diff.files[0]?.edits[0]?.target.messageId}`, + false + ) + const files = showAll ? diff.files : diff.files.slice(0, NATIVE_CHAT_TURN_DIFF_FILE_LIMIT) + const remaining = diff.files.length - files.length + const type = toolType(fontScale) + return ( + + + + {t('components.native-chat.turnDiff.changedFiles', '{{count}} changed files', { + count: diff.files.length, + defaultValue_one: '{{count}} changed file' + })} + + +{diff.added} + -{diff.removed} + {diff.truncated ? ( + + {t('components.native-chat.turnDiff.partial', 'Partial diff')} + + ) : null} + + {files.map((file) => ( + + ))} + {remaining > 0 ? ( + [styles.file, pressed && pressedStyle]} + onPress={() => setShowAll(true)} + > + + {t('components.native-chat.turnDiff.showMoreFiles', 'Show {{count}} more', { + count: remaining + })} + + + ) : null} + + ) +} + +const styles = themedStyleSheet(() => ({ + card: { + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.surface, + padding: spacing.sm, + marginBottom: spacing.md, + marginHorizontal: spacing.lg, + gap: spacing.xs + }, + header: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: spacing.sm }, + file: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.sm + }, + path: { maxWidth: '65%', flexShrink: 1, color: colors.textPrimary }, + directory: { flex: 1, color: colors.textMuted }, + label: { color: colors.textMuted }, + added: { color: colors.gitDecorationAdded }, + removed: { color: colors.gitDecorationDeleted }, + open: { transform: [{ rotate: '90deg' }] } +})) diff --git a/mobile/src/session/native-chat/transcript/mobile-native-chat-row-projection.ts b/mobile/src/session/native-chat/transcript/mobile-native-chat-row-projection.ts index 527dad51662..e986312a6a2 100644 --- a/mobile/src/session/native-chat/transcript/mobile-native-chat-row-projection.ts +++ b/mobile/src/session/native-chat/transcript/mobile-native-chat-row-projection.ts @@ -1,3 +1,4 @@ +import { sameNativeChatTurnDiff } from '../../../../../src/shared/native-chat/tools/native-chat-turn-diffs' import type { NativeChatBlock, NativeChatMessage @@ -49,6 +50,9 @@ export function equalMobileNativeChatRowContent( if (Object.is(x, y)) { return true } + if (key === 'turnDiff') { + return sameNativeChatTurnDiff(a.turnDiff, b.turnDiff) + } if (key === 'message') { return sameMessage(a.message, b.message) } diff --git a/mobile/src/session/native-chat/transcript/mobile-native-chat-turn-diff.test-fixture.tsx b/mobile/src/session/native-chat/transcript/mobile-native-chat-turn-diff.test-fixture.tsx new file mode 100644 index 00000000000..31a7a6e29d1 --- /dev/null +++ b/mobile/src/session/native-chat/transcript/mobile-native-chat-turn-diff.test-fixture.tsx @@ -0,0 +1,90 @@ +import './mobile-agent-message-renderer.test-fixture' +import type { NativeChatMessage } from '../../../../../src/shared/native-chat/native-chat-types' +import type { ReactTestInstance } from 'react-test-renderer' +import { vi } from 'vitest' +import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' +import { MobileNativeChatRowContent } from './MobileNativeChatRowItemSource' +import { findAllByHostType } from '../../../test-support/react-test-renderer-host-nodes' + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: async () => null, setItem: async () => {}, removeItem: async () => {} } +})) + +export const messages: NativeChatMessage[] = [ + { + id: 'u', + role: 'user', + source: 'transcript', + timestamp: 1, + blocks: [{ type: 'text', text: 'Fix the greeting' }] + }, + { + id: 'edit', + role: 'assistant', + source: 'transcript', + timestamp: 2, + blocks: [ + { + type: 'tool-call', + name: 'Bash', + state: 'completed', + input: { command: 'inspect-diagnostic' } + }, + { type: 'tool-result', output: 'diagnostic-output' }, + { + type: 'tool-call', + name: 'Edit', + state: 'completed', + input: { + file_path: '/repo/greeting.ts', + old_string: 'helloOld()', + new_string: 'helloNew()' + } + }, + { type: 'tool-result', output: 'The file /repo/greeting.ts has been updated successfully.' }, + { type: 'tool-call', name: 'Diff', input: { path: 'status.ts' } }, + { type: 'tool-result', output: '@@ -1 +1 @@\n-statusOld()\n+statusNew()' } + ] + }, + { + id: 'answer', + role: 'assistant', + source: 'transcript', + timestamp: 3, + blocks: [{ type: 'text', text: 'Updated the greeting.' }] + } +] +const settled = new Map([['u', { startedAt: 1, workedSeconds: 2 }]]) +export function Transcript({ + rows = messages, + working = false +}: { + rows?: NativeChatMessage[] + working?: boolean +}) { + const turns = useMobileNativeChatTurnDisclosure({ + messages: rows, + enabled: true, + isWorking: working, + settledTurns: working ? undefined : settled, + scopeKey: 'test' + }) + return ( + <> + {turns.listMessages.map((message, index) => ( + + ))} + + ) +} +export function textIn(node: ReactTestInstance): string[] { + return findAllByHostType(node, 'Text').map((text) => String(text.children.join(''))) +} diff --git a/mobile/src/session/native-chat/transcript/use-mobile-native-chat-row-props.ts b/mobile/src/session/native-chat/transcript/use-mobile-native-chat-row-props.ts index 272a1ca6484..94bc4914153 100644 --- a/mobile/src/session/native-chat/transcript/use-mobile-native-chat-row-props.ts +++ b/mobile/src/session/native-chat/transcript/use-mobile-native-chat-row-props.ts @@ -5,7 +5,32 @@ import type { import type { NativeChatTaskListPredecessors } from '../../../../../src/shared/native-chat/input/native-chat-task-list-history' import type { NativeChatResolvedPrompt } from '../../../../../src/shared/native-chat/native-chat-resolution-receipt' import { isActionableStructuredAgentSessionPrompt } from '../../../../../src/shared/agent-session/view/structured-agent-session-live-turn' -import type { MobileNativeChatTurnRow } from './use-mobile-native-chat-turn-disclosure' +import type { NativeChatTurnStatus } from '../../../../../src/shared/native-chat/transcript/native-chat-turn-status' +import type { NativeChatTurnDiff } from '../../../../../src/shared/native-chat/tools/native-chat-turn-diffs' + +export type MobileNativeChatTurnRow = { + turnDiff?: NativeChatTurnDiff + turnStatus: NativeChatTurnStatus | null + turnStatusAbove?: boolean + turnExpanded: boolean + turnKey?: string + activeTurnIsWorking: boolean + trailingRun: boolean + workRun?: readonly NativeChatMessage[] + joinsWorkRun?: boolean + foldedAway?: boolean + openReasoning?: ReadonlySet + mayStillGrow: boolean + reasoningIsLive: boolean + reasoningExpanded: boolean + onToggleReasoning: (key: string) => void + // Pass roster disclosure only to roster rows so toggles cannot rerender every transcript row. + subagentGroupsOpen?: ReadonlySet + onToggleSubagentGroup: (groupId: string) => void + // Hold expanded user messages above recycled rows so scrolling away cannot collapse them. + userMessageOpen: boolean + onToggleUserMessage: (messageId: string) => void +} /** The message and its derived render inputs share one reuse boundary. */ export type MobileNativeChatRowProps = MobileNativeChatTurnRow & { diff --git a/mobile/src/session/native-chat/transcript/use-mobile-native-chat-turn-diffs.ts b/mobile/src/session/native-chat/transcript/use-mobile-native-chat-turn-diffs.ts new file mode 100644 index 00000000000..f52b4652068 --- /dev/null +++ b/mobile/src/session/native-chat/transcript/use-mobile-native-chat-turn-diffs.ts @@ -0,0 +1,24 @@ +import { useMemo } from 'react' +import type { NativeChatMessage } from '../../../../../src/shared/native-chat/native-chat-types' +import { nativeChatTurnMembership } from '../../../../../src/shared/native-chat/transcript/native-chat-turn-membership' +import { nativeChatTurnDiffs } from '../../../../../src/shared/native-chat/tools/native-chat-turn-diffs' + +export function useMobileNativeChatTurnDiffs( + rows: readonly NativeChatMessage[], + structured: boolean, + turnKeys: readonly (string | undefined)[], + partialTurnKey: string | undefined +) { + return useMemo(() => { + const keys = structured ? turnKeys : nativeChatTurnMembership(rows, null).turnKeys + const diffs = nativeChatTurnDiffs(rows, keys, undefined, partialTurnKey) + const lastRows = new Map() + rows.forEach((row, index) => { + const key = keys[index] + if (key !== undefined) { + lastRows.set(key, row.id) + } + }) + return new Map(Array.from(lastRows, ([key, id]) => [id, diffs.get(key)])) + }, [structured, partialTurnKey, rows, turnKeys]) +} diff --git a/mobile/src/session/native-chat/transcript/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/native-chat/transcript/use-mobile-native-chat-turn-disclosure.ts index 332746f5852..7d4ec5be362 100644 --- a/mobile/src/session/native-chat/transcript/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/native-chat/transcript/use-mobile-native-chat-turn-disclosure.ts @@ -1,3 +1,5 @@ +import { useMobileNativeChatTurnDiffs } from './use-mobile-native-chat-turn-diffs' +import type { MobileNativeChatTurnRow } from './use-mobile-native-chat-row-props' import { useCallback, useMemo, useState } from 'react' import { isSubagentGroupBlock, @@ -32,29 +34,6 @@ const EMPTY_TURN_IDS: ReadonlySet = new Set() const NO_TURN_KEYS: readonly undefined[] = [] const MAX_EXPANDED_TURNS = 128 -export type MobileNativeChatTurnRow = { - turnStatus: NativeChatTurnStatus | null - turnStatusAbove?: boolean - turnExpanded: boolean - turnKey?: string - activeTurnIsWorking: boolean - trailingRun: boolean - workRun?: readonly NativeChatMessage[] - joinsWorkRun?: boolean - foldedAway?: boolean - openReasoning?: ReadonlySet - mayStillGrow: boolean - reasoningIsLive: boolean - reasoningExpanded: boolean - onToggleReasoning: (key: string) => void - // Pass roster disclosure only to roster rows so toggles cannot rerender every transcript row. - subagentGroupsOpen?: ReadonlySet - onToggleSubagentGroup: (groupId: string) => void - // Hold expanded user messages above recycled rows so scrolling away cannot collapse them. - userMessageOpen: boolean - onToggleUserMessage: (messageId: string) => void -} - export type MobileNativeChatLiveLine = NativeChatLiveLine & { reasoningExpanded: boolean } function useScopedOpenKeys(scopeKey: string): [ReadonlySet, (key: string) => void] { @@ -120,13 +99,14 @@ export function useMobileNativeChatTurnDisclosure({ liveLine: MobileNativeChatLiveLine | null onToggleReasoning: (key: string) => void } { - const { rows, turnKeys, liveTurnKey, unresolvedRowIds } = useMemo(() => { + const { rows, turnKeys, liveTurnKey, unresolvedRowIds, partialTurnKey } = useMemo(() => { if (!enabled) { return { rows: messages, turnKeys: NO_TURN_KEYS, liveTurnKey: undefined, - unresolvedRowIds: undefined + unresolvedRowIds: undefined, + partialTurnKey: undefined } } const membership = nativeChatTurnMembership(messages, turnJournal) @@ -134,9 +114,11 @@ export function useMobileNativeChatTurnDisclosure({ rows: nativeChatRowsInDrawOrder(messages, membership.drawOrder), turnKeys: nativeChatRowsInDrawOrder(membership.turnKeys, membership.drawOrder), liveTurnKey: membership.liveTurnKey, + partialTurnKey: membership.partialTurnKey, unresolvedRowIds: membership.unresolvedRowIds } }, [enabled, messages, turnJournal]) + const summaries = useMobileNativeChatTurnDiffs(rows, enabled, turnKeys, partialTurnKey) const waiting = useMemo(() => { const ids = enabled ? nativeChatMessagesWaitingBehindLiveTurn( @@ -236,6 +218,7 @@ export function useMobileNativeChatTurnDisclosure({ : (completedByTurn[turnKey] ?? null) : null return { + turnDiff: summaries.get(message.id), turnStatus, ...(bar?.above === true && turnStatus !== null ? { turnStatusAbove: true } : {}), turnExpanded: turnKey ? expandedTurnIds.has(turnKey) : false, @@ -269,6 +252,7 @@ export function useMobileNativeChatTurnDisclosure({ }, [ turnKeys, + summaries, waiting, bars, enabled, diff --git a/src/main/codex/history/journal-views/codex-history-projection.ts b/src/main/codex/history/journal-views/codex-history-projection.ts index eadc7c0070a..cff77eb28cf 100644 --- a/src/main/codex/history/journal-views/codex-history-projection.ts +++ b/src/main/codex/history/journal-views/codex-history-projection.ts @@ -2,6 +2,8 @@ import type { AgentJournalItemBody, AgentJournalRenderItem } from '../../../../shared/agent-session/journal/agent-session-journal-types' +import { readRecord } from '../../app-server/codex-item-field-readers' +import { codexItemRunState } from '../../structured/items/codex-item-run-state' import type { CodexThreadItem } from '../../structured/items/codex-thread-item-identity' export function preserveSavedHistoryPayload( @@ -10,8 +12,35 @@ export function preserveSavedHistoryPayload( saved: AgentJournalRenderItem | null ): AgentJournalItemBody { const old = saved?.body - if (old?.kind === 'diff' && item.type === 'fileChange' && item.changes === undefined) { - translated = old + if (item.type === 'fileChange' && item.changes === undefined) { + const fields = readRecord(old?.kind === 'tool-call' ? old.input : null) + const patch = + old?.kind === 'diff' + ? old + : old?.kind === 'tool-call' && + old.name === 'Diff' && + typeof fields.path === 'string' && + Object.keys(fields).length === 1 && + old.output + ? { path: fields.path, patch: old.output } + : null + if (patch && old) { + // Retained bytes never override authoritative application status. + const state = codexItemRunState(item) + translated = + item.status == null + ? old + : state === 'completed' + ? { kind: 'diff', path: patch.path, patch: patch.patch } + : { + kind: 'tool-call', + name: 'Diff', + callId: old?.kind === 'tool-call' ? old.callId : item.id, + input: { path: patch.path }, + output: patch.patch, + state + } + } } if ( old?.kind === 'message' && diff --git a/src/main/codex/structured/items/codex-file-change-outcome.test.ts b/src/main/codex/structured/items/codex-file-change-outcome.test.ts new file mode 100644 index 00000000000..b68fede4f4a --- /dev/null +++ b/src/main/codex/structured/items/codex-file-change-outcome.test.ts @@ -0,0 +1,177 @@ +import { afterEach, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../../shared/agent-session/journal/agent-session-journal-item-key' +import { isAdmissibleAgentJournalRenderItem } from '../../../../shared/agent-session/journal/agent-session-journal-schemas' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session/journal/agent-session-journal-types' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} from '../../../../shared/agent-session/structured-agent-session-reducer' +import { projectStructuredItemsToNativeChat } from '../../../../shared/agent-session/view/structured-agent-session-projection' +import { + nativeChatTurnDiffEditFile, + nativeChatTurnDiffs +} from '../../../../shared/native-chat/tools/native-chat-turn-diffs' +import type { StructuredAgentSessionEventSink } from '../../../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { agentJournalItemForWire } from '../../../native-chat/agent-session-wire/agent-session-item-wire-form' +import { + createCodexHistoryTranslator, + createCodexJournalTranslator +} from '../journal/codex-structured-journal-translation' +import type { CodexStructuredSessionEvent } from '../session-lifecycle/codex-structured-session-state' + +const disposers: (() => void)[] = [] +afterEach(() => disposers.splice(0).forEach((dispose) => dispose())) +const patch = '@@ -1 +1 @@\n-old\n+new' +// Protocol shape also exercised by codex-structured-reasoning-lifecycle.test.ts; no real CLI run is claimed. +const change = (status?: string) => ({ + type: 'fileChange', + id: 'patch', + ...(status ? { status } : {}), + changes: [{ path: 'src/app.ts', kind: { type: 'update' }, diff: patch }] +}) + +function fixture() { + let sequence = 0 + let state: StructuredAgentSessionState = { ...EMPTY_STRUCTURED_AGENT_SESSION, epoch: 'epoch' } + const apply = (items: AgentJournalRenderItem[]) => { + state = reduceStructuredAgentSession(state, { + type: 'event', + event: { + type: 'batch', + sessionId: 'session', + batch: { + cursor: { epoch: 'epoch', sequence: ++sequence }, + items, + removedItemIds: [], + submissions: [] + } + } + }) + } + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => { + const itemId = agentJournalItemKey(identity) + const previous = state.items.find((item) => item.itemId === itemId) + apply([ + agentJournalItemForWire({ + itemId, + body, + revision: (previous?.revision ?? 0) + 1, + sequence: previous?.sequence ?? sequence + 1, + observedAt: options.observedAt ?? 1 + }).item + ]) + }, + appendTombstone: () => {}, + publish: () => {} + } + const live = createCodexJournalTranslator({ sink, schedule: () => () => {} }) + const history = createCodexHistoryTranslator({ sink }) + disposers.push(live.dispose, history.dispose) + const send = (method: string, params: unknown) => + live.handle({ + type: 'notification', + sessionId: 'session', + threadId: 'thread', + method, + params + } satisfies CodexStructuredSessionEvent) + const outcome = () => { + const messages = projectStructuredItemsToNativeChat(state.items) + return nativeChatTurnDiffs( + messages, + messages.map(() => 'turn') + ).get('turn') + } + return { + live, + history, + send, + outcome, + replay: () => { + const items = state.items.map((item) => { + const restored: unknown = JSON.parse(JSON.stringify(item)) + if (!isAdmissibleAgentJournalRenderItem(restored)) { + throw new Error('Journal item failed replay validation') + } + return restored + }) + state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'history-page', + page: { + sessionId: 'session', + epoch: 'epoch', + direction: 'tail', + items, + submissions: [], + removedItemIds: [], + hasOlder: false, + hasNewer: false, + liveCursor: { epoch: 'epoch', sequence }, + window: { + oldest: null, + newest: null, + nextCursor: { epoch: 'epoch', sequence: 0 } + } + } + }) + } + } +} + +it('shows one landed patch only after completion, including stream updates and replay revisions', () => { + const f = fixture() + f.send('item/started', { item: change('inProgress') }) + expect(f.outcome()).toBeUndefined() + f.send('item/fileChange/outputDelta', { + itemId: 'patch', + delta: '@@ -1 +1 @@\n-stdout\n+not-a-patch' + }) + f.send('item/fileChange/patchUpdated', { itemId: 'patch', changes: change().changes }) + f.live.flush() + expect(f.outcome()).toBeUndefined() + f.send('item/completed', { item: change('completed') }) + f.live.flush() + expect(f.outcome()?.files.map((file) => file.path)).toEqual(['src/app.ts']) + expect(f.outcome()).toMatchObject({ added: 1, removed: 1 }) + expect(f.outcome()?.files[0]?.edits).toHaveLength(1) + expect(nativeChatTurnDiffEditFile(f.outcome()!.files[0]!.edits[0]!)).toMatchObject({ + path: 'src/app.ts', + added: 1, + removed: 1 + }) + f.replay() + f.send('item/completed', { item: change('completed') }) + expect(f.outcome()?.files[0]?.edits).toHaveLength(1) + expect(f.outcome()).toMatchObject({ added: 1, removed: 1 }) +}) + +it.each(['failed', 'declined', 'inProgress', undefined])( + 'never promotes %s changes via a completion wrapper or history import', + (status) => { + const f = fixture() + f.send('item/started', { item: change('inProgress') }) + f.send('item/completed', { item: change(status) }) + expect(f.outcome()).toBeUndefined() + f.history.restoreThread('thread', { + turns: [{ id: 'turn', status: 'completed', items: [change(status)] }] + }) + expect(f.outcome()).toBeUndefined() + f.replay() + expect(f.outcome()).toBeUndefined() + } +) + +it('keeps an orphan patch update unconfirmed and settles buffered stdout without a landed file', () => { + const f = fixture() + f.send('item/fileChange/patchUpdated', { itemId: 'patch', changes: change().changes }) + f.live.flush() + expect(f.outcome()).toBeUndefined() + f.send('item/started', { item: change('inProgress') }) + f.send('item/fileChange/outputDelta', { itemId: 'patch', delta: patch }) + f.live.handle({ type: 'ended', sessionId: 'session', reason: 'app-server exited' }) + expect(f.outcome()).toBeUndefined() + f.replay() + expect(f.outcome()).toBeUndefined() +}) diff --git a/src/main/codex/structured/items/codex-structured-item-translation.ts b/src/main/codex/structured/items/codex-structured-item-translation.ts index aa2f545955d..3e997e3890a 100644 --- a/src/main/codex/structured/items/codex-structured-item-translation.ts +++ b/src/main/codex/structured/items/codex-structured-item-translation.ts @@ -92,6 +92,20 @@ function commandItem(item: CodexThreadItem): CodexJournalItem { } } +function fileChangeToolCall(item: CodexThreadItem, output?: string): CodexJournalItem { + return { + body: { + kind: 'tool-call', + name: 'apply_patch', + callId: item.id, + input: { changes: item.changes ?? null }, + state: codexItemRunState(item), + ...(output === undefined ? {} : { output: journalPayload(output) }) + }, + handled: true + } +} + function fileChangeItem(item: CodexThreadItem): CodexJournalItem { const changes = Array.isArray(item.changes) ? item.changes.flatMap((change) => { @@ -101,17 +115,8 @@ function fileChangeItem(item: CodexThreadItem): CodexJournalItem { return path && diff ? [{ path, diff }] : [] }) : [] - if (changes.length === 0) { - return { - body: { - kind: 'tool-call', - name: 'apply_patch', - callId: item.id, - input: { changes: item.changes ?? null }, - state: codexItemRunState(item) - }, - handled: true - } + if (changes.length === 0 || codexItemRunState(item) !== 'completed') { + return fileChangeToolCall(item) } const patch = changes.map((change) => change.diff).join('\n') return { @@ -267,13 +272,7 @@ export function codexStreamingJournalItem(item: CodexThreadItem, text: string): return commandItem({ ...item, aggregatedOutput: text }) } if (item.type === 'fileChange') { - const path = Array.isArray(item.changes) - ? readString(readRecord(item.changes[0]), 'path') - : null - return { - body: { kind: 'diff', path: path ?? 'pending patch', patch: journalPayload(text) }, - handled: true - } + return fileChangeToolCall(item, text) } if (item.type === 'plan') { return { diff --git a/src/main/codex/structured/threads/codex-thread-catch-up-file-change.test.ts b/src/main/codex/structured/threads/codex-thread-catch-up-file-change.test.ts new file mode 100644 index 00000000000..7ea2480cc60 --- /dev/null +++ b/src/main/codex/structured/threads/codex-thread-catch-up-file-change.test.ts @@ -0,0 +1,89 @@ +import { expect, it } from 'vitest' +import { projectStructuredItemsToNativeChat } from '../../../../shared/agent-session/view/structured-agent-session-projection' +import { + nativeChatTurnDiffEditFile, + nativeChatTurnDiffs +} from '../../../../shared/native-chat/tools/native-chat-turn-diffs' +import { chat, turn } from './codex-thread-catch-up-contract-test-fixture' + +const patch = '@@ -1 +1 @@\n-old\n+new' + +async function savedChange() { + const c = await chat({ now: 1_000 }) + const source = (status?: string, changes?: unknown[]) => ({ + ...turn(1), + items: [ + { + type: 'fileChange', + id: 'patch', + ...(status === undefined ? {} : { status }), + ...(changes === undefined ? {} : { changes }) + } + ] + }) + await c.open([ + source('completed', [{ path: 'src/app.ts', kind: { type: 'update' }, diff: patch }]) + ]) + const original = c.journal.snapshot().items.find((item) => item.body.kind === 'diff')! + expect(original.body.kind).toBe('diff') + const current = () => c.journal.snapshot().items.find((item) => item.itemId === original.itemId)! + const outcome = () => { + const messages = projectStructuredItemsToNativeChat(c.journal.snapshot().items) + return nativeChatTurnDiffs( + messages, + messages.map(() => 'turn') + ).get('turn') + } + return { c, source, original, current, outcome } +} + +it('uses declined history status without losing the patch or resurrecting it on sparse enrichment', async () => { + const f = await savedChange() + await f.c.open([], { returnedTurns: [f.source('declined')], paginate: false }) + expect(f.current().body).toMatchObject({ + kind: 'tool-call', + name: 'Diff', + input: { path: 'src/app.ts' }, + state: 'failed' + }) + expect(f.outcome()).toBeUndefined() + const failed = f.current().body + expect(failed.kind === 'tool-call' && failed.output).toEqual( + f.original.body.kind === 'diff' && f.original.body.patch + ) + await f.c.open([], { returnedTurns: [f.source()], paginate: false }) + expect(f.current().body).toEqual(failed) + expect(f.outcome()).toBeUndefined() +}) + +it.each(['completed', undefined])( + 'retains the saved successful patch for sparse %s history', + async (status) => { + const f = await savedChange() + await f.c.open([], { returnedTurns: [f.source(status)], paginate: false }) + expect(f.current().body).toEqual(f.original.body) + expect(f.outcome()).toMatchObject({ added: 1, removed: 1 }) + expect(f.outcome()?.files.map((file) => file.path)).toEqual(['src/app.ts']) + expect(f.outcome()?.files[0]?.edits).toHaveLength(1) + } +) + +it('retains a sparse pending patch until a new import confirms its application', async () => { + const f = await savedChange() + await f.c.open([], { returnedTurns: [f.source('inProgress')], paginate: false }) + expect(f.current().body).toMatchObject({ kind: 'tool-call', name: 'Diff', state: 'running' }) + expect(f.outcome()).toBeUndefined() + await f.c.open([], { returnedTurns: [f.source('completed')], paginate: false }) + expect(f.current().body).toEqual(f.original.body) + const outcome = f.outcome()! + expect(outcome).toMatchObject({ added: 1, removed: 1 }) + expect(outcome.files.map((file) => file.path)).toEqual(['src/app.ts']) + expect(outcome.files[0]?.edits).toHaveLength(1) + expect(nativeChatTurnDiffEditFile(outcome.files[0]!.edits[0]!)).toMatchObject({ + path: 'src/app.ts', + lines: [ + { kind: 'del', text: 'old' }, + { kind: 'add', text: 'new' } + ] + }) +}) diff --git a/src/main/codex/structured/turns/codex-structured-reasoning-lifecycle.test.ts b/src/main/codex/structured/turns/codex-structured-reasoning-lifecycle.test.ts index d3899992140..74c6c839e58 100644 --- a/src/main/codex/structured/turns/codex-structured-reasoning-lifecycle.test.ts +++ b/src/main/codex/structured/turns/codex-structured-reasoning-lifecycle.test.ts @@ -246,9 +246,10 @@ describe('a Codex reasoning row', () => { for (let index = 2; index <= MAX_CODEX_ACTIVE_ITEMS + 1; index += 1) { items.handle({ threadId: THREAD_ID, method: 'item/started', params: reasoning(`r-${index}`) }) } - expect(rows.get('orca:codex-item%3Athread-abc%3Apatch-1')).toEqual({ - kind: 'status', - text: 'File changes were interrupted before completion.' + expect(rows.get('orca:codex-item%3Athread-abc%3Apatch-1')).toMatchObject({ + kind: 'tool-call', + name: 'apply_patch', + state: 'failed' }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx index 73ba4774545..e1e8e3f5af3 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx @@ -563,8 +563,6 @@ import type { NativeChatLiveSession } from './use-native-chat-live-session' } { - const scrollTo = vi.fn() - function item( itemId: string, body: AgentJournalItemBody, @@ -759,25 +757,13 @@ import type { NativeChatLiveSession } from './use-native-chat-live-session' expect(screen.getAllByText('Awaiting user input:')).toHaveLength(1) }) - it('reveals and scrolls to a folded diff card from a collapsed completed turn', () => { - vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) + it('opens a recorded patch while completed work stays folded', () => { render(view([user, prose, diff()])) - expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull() - const header = screen.getByRole('button', { name: /1 changed file/ }) - expect(header).toHaveAttribute('aria-expanded', 'false') - fireEvent.click(header) + expect(screen.queryByText('after')).toBeNull() fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) - expect(screen.getByText('Edited')).toBeInTheDocument() expect(screen.getByText('after')).toBeInTheDocument() expect(screen.getByText('before')).toBeInTheDocument() - expect(scrollTo).toHaveBeenCalled() - fireEvent.click(screen.getByRole('button', { name: /Edited 1 file/ })) - expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull() - fireEvent.click(header) - expect(header).toHaveAttribute('aria-expanded', 'false') - fireEvent.click(header) - fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) - expect(scrollTo.mock.calls.length).toBeGreaterThanOrEqual(2) + expect(screen.getByRole('button', { name: /Edited 1 file/, expanded: false })).toBeVisible() }) it('updates journal revisions and replaces flat approval text with a passive receipt', () => { @@ -817,7 +803,7 @@ import type { NativeChatLiveSession } from './use-native-chat-live-session' } } rerender(view([user, prose, diff('@@ -0,0 +1,2 @@\n+first\n+second'), resolved])) - expect(screen.getByRole('button', { name: /1 changed file \+2/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /src\/a.ts \+2/ })).toBeInTheDocument() expect(screen.getByText('Run tests?')).toBeInTheDocument() expect(screen.getByText('Allow once')).toBeInTheDocument() expect(screen.getByText('Answered on another device')).toBeInTheDocument() @@ -825,7 +811,7 @@ import type { NativeChatLiveSession } from './use-native-chat-live-session' expect(screen.queryByText('resolved')).toBeNull() }) - it('keeps rollups turn-local and leaves legacy message lists unchanged', () => { + it('keeps changed files turn-local in journal and legacy transcripts', () => { const secondUser = item( 'user-two', { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Again' }] }, @@ -834,9 +820,9 @@ import type { NativeChatLiveSession } from './use-native-chat-live-session' const secondDiff = { ...diff(), itemId: 'second-diff', sequence: 6, observedAt: 6000 } const items = [user, prose, diff(), secondUser, secondDiff] const { rerender } = render(view(items)) - expect(screen.getAllByRole('button', { name: /1 changed file/ })).toHaveLength(2) + expect(screen.getAllByRole('button', { name: /src\/a.ts/ })).toHaveLength(2) rerender(view(items, false)) - expect(screen.queryByRole('button', { name: /changed file/ })).toBeNull() + expect(screen.getAllByRole('button', { name: /src\/a.ts/ })).toHaveLength(2) }) }) } diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx index 75ed3edae91..1b5404cc44f 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx @@ -26,12 +26,7 @@ afterEach(() => { expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() }) -import type { - AgentJournalItemBody, - AgentJournalRenderItem -} from '../../../../shared/agent-session/journal/agent-session-journal-types' import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types' -import { projectStructuredItemsToNativeChat } from '../../../../shared/agent-session/view/structured-agent-session-projection' import { NativeChatMessageList } from './NativeChatMessageList' import { transcriptRowOffset, transcriptWindowTop } from './native-chat-window-test-layout' import type { NativeChatRailOutlineEntry } from './native-chat-message-rail-items' @@ -414,110 +409,4 @@ describe('jumping from the rail while following the end', () => { expect(screen.queryByText('prompt-5')).toBeNull() expect(distanceFromBottom()).toBe(0) }) - - describe('revealing a diff while a rail jump pages', () => { - function journalItem(itemId: string, body: AgentJournalItemBody, sequence: number) { - return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1 } - } - function prompt(itemId: string, text: string, sequence: number) { - return journalItem( - itemId, - { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }, - sequence - ) - } - - const patch = '@@ -1 +1 @@\n-before\n+after' - const OLDER = prompt('older', 'Oldest prompt', 1) - const LOADED: AgentJournalRenderItem[] = [ - prompt('user', 'Edit it', 2), - journalItem( - 'diff', - { - kind: 'diff', - path: 'src/a.ts', - patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length } - }, - 3 - ), - ...Array.from({ length: 200 }, (_, index) => - journalItem( - `tail-${index}`, - { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: `marker-${index}` }] - }, - index + 4 - ) - ) - ] - - function DiffTranscript({ holdPage }: { holdPage: () => Promise }): React.JSX.Element { - const [loadedOlder, setLoadedOlder] = useState(false) - const items = useMemo(() => (loadedOlder ? [OLDER, ...LOADED] : LOADED), [loadedOlder]) - const loadEarlier = useCallback(async (): Promise => { - await holdPage() - setLoadedOlder(true) - return 'applied' - }, [holdPage]) - return ( - - ) - } - - function expectDiffInView(): void { - expect( - screen.getByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/, expanded: true }) - ).toBeInTheDocument() - const row = screen.getByText('Edited').closest('[data-index]') - if (!row) { - throw new Error('revealed diff has no mounted row') - } - const top = layout.aboveTranscriptPx + row.offsetTop - scroller().scrollTop - expect(row.offsetHeight).toBeGreaterThan(0) - expect(top).toBeGreaterThanOrEqual(-2) - expect(top).toBeLessThan(scroller().clientHeight - 2) - } - - it('keeps the diff in view when its page lands', async () => { - const pages = holdFirstPage() - render() - await settle(10) - expect(distanceFromBottom()).toBe(0) - expect(screen.queryByText('Edited')).toBeNull() - - await pickUnloadedWhilePaging('Oldest prompt') - expect(pages.asked()).toBe(1) - expect(screen.getByRole('button', { name: 'Oldest prompt' })).toHaveAttribute( - 'aria-busy', - 'true' - ) - - fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) - fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) - expect(screen.getByRole('button', { name: 'Oldest prompt' })).not.toHaveAttribute('aria-busy') - await settle(40) - expectDiffInView() - - pages.release() - await settle(60) - - expect(pages.asked()).toBe(1) - expect(screen.getByRole('button', { name: 'Oldest prompt' })).not.toHaveAttribute('aria-busy') - expectDiffInView() - }) - }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.render-cost.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.render-cost.test.tsx index d41efc9956b..46ceceb6da9 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.render-cost.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.render-cost.test.tsx @@ -211,14 +211,11 @@ describe('native chat transcript re-render cost during a streaming turn', () => ) } expect(patchCalls).toEqual({ summary: 1, detailed: 0 }) - fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) - expect(patchCalls.detailed).toBe(0) - expect(screen.queryByText('Totals from recorded edits in this turn.')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Show diff' })) + fireEvent.click(screen.getByRole('button', { name: /^src\/a.ts/ })) expect(patchCalls).toEqual({ summary: 1, detailed: 1 }) expect(screen.getByText('new')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'Hide diff' })) - fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) + fireEvent.click(screen.getByRole('button', { name: /^src\/a.ts/ })) + fireEvent.click(screen.getByRole('button', { name: /^src\/a.ts/ })) expect(patchCalls).toEqual({ summary: 1, detailed: 1 }) expect(screen.getByText('new')).toBeInTheDocument() }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx index d3df422b684..2c078d7e13b 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx @@ -54,7 +54,6 @@ import { list, marker, scrollTranscript, - session as materialRailSession, stubLayout as materialRailStubLayout, windowState } from './NativeChatMessageList.windowing-test-support' @@ -247,17 +246,16 @@ import { expect(screen.getAllByText(/^lane [abc]$/)).toHaveLength(3) }) - it("still counts the subagent's edit in the turn, and reveals it inside its section", () => { + it("shows the subagent's patch without opening its diagnostic section", () => { vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(() => {}) renderList() expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull() - fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) expect(screen.getByText('Edited')).toBeInTheDocument() - expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /explore the lane/, expanded: true })).toBeVisible() + expect(screen.queryByText('The PR is CLEAN.')).toBeNull() + expect(screen.getByRole('button', { name: /Ran 1 subagent/, expanded: false })).toBeVisible() }) // Journal turn records own subagent edits, even when a newer send is queued or the opener is unloaded. @@ -304,10 +302,10 @@ import { expandSignal={false} /> ) - const rollups = screen.getAllByRole('button', { name: /changed file/ }) - expect(rollups.map((rollup) => rollup.textContent)).toEqual([ - expect.stringMatching(/1 changed file/), - expect.stringMatching(/1 changed file/) + const rollups = screen.getAllByRole('button', { name: /src\/[ab].ts/ }) + expect(rollups.map((rollup) => rollup.getAttribute('aria-label'))).toEqual([ + expect.stringMatching(/src\/a.ts/), + expect.stringMatching(/src\/b.ts/) ]) const inOrder = [ screen.getByText('Edited a.'), @@ -327,11 +325,10 @@ import { fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) - fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) expect(screen.getByText('Edited')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /Ran 1 subagent/, expanded: true })).toBeVisible() + expect(screen.getByRole('button', { name: /Ran 1 subagent/, expanded: false })).toBeVisible() }) it('keeps a roster list open or closed past its row unmounting, and hides its sections while closed', () => { @@ -425,122 +422,8 @@ import { } { - const session = materialRailSession const stubLayout = materialRailStubLayout - describe('revealing a diff from a turn rollup', () => { - afterEach(cleanup) - let restoreLayout = (): void => {} - beforeEach(() => { - restoreLayout = stubLayout({ scrollGeometry: true, offsetChain: true }) - vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( - this: HTMLElement - ) { - const row = this.closest('[data-index]') - const scroller = this.closest('[data-native-chat-scroll]') - return new DOMRect( - 0, - row ? row.offsetTop - (scroller?.scrollTop ?? 0) : 0, - 100, - this.offsetHeight - ) - }) - }) - afterEach(() => { - restoreLayout() - vi.restoreAllMocks() - }) - - function journalItem(itemId: string, body: AgentJournalItemBody, sequence: number) { - return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1 } - } - - const patch = '@@ -1 +1 @@\n-before\n+after' - const items: AgentJournalRenderItem[] = [ - journalItem( - 'user', - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Edit it' }] }, - 1 - ), - journalItem( - 'diff', - { - kind: 'diff', - path: 'src/a.ts', - patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length } - }, - 2 - ), - ...Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) => - journalItem( - `tail-${index}`, - { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: `marker-${index}` }] - }, - index + 3 - ) - ) - ] - - it('lets a rail jump supersede a previously revealed diff', () => { - const withPrompts = [ - ...items.slice(0, 2), - journalItem( - 'user-2', - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Second prompt' }] }, - 3 - ), - journalItem( - 'user-3', - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Third prompt' }] }, - 4 - ), - ...items.slice(2) - ].map((item, index) => ({ ...item, sequence: index + 1 })) - const scrollTo = vi.fn() - vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) - const transcript = (): React.JSX.Element => ( - - ) - const { container, rerender } = render(transcript()) - const selectedTop = - screen.getByText('Second prompt').closest('[data-index]')?.offsetTop ?? 0 - expect(selectedTop).toBeGreaterThan(0) - fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) - fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) - scrollTranscript(container, 6000) - expect(screen.getByText('Edited')).toBeInTheDocument() - scrollTo.mockClear() - fireEvent.click(screen.getByRole('button', { name: 'Second prompt' })) - expect(scrollTo.mock.calls).toEqual([ - [{ top: 6000, behavior: 'auto' }], - [{ top: selectedTop, behavior: 'auto' }] - ]) - expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull() - scrollTo.mockClear() - rerender(transcript()) - rerender(transcript()) - expect(scrollTo).not.toHaveBeenCalled() - - scrollTranscript(container, 0) - scrollTo.mockClear() - fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) - fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) - const diffTop = - screen.getByText('Edited').closest('[data-index]')?.offsetTop ?? 0 - expect(Number.isFinite(diffTop)).toBe(true) - expect(scrollTo).toHaveBeenCalledWith({ top: diffTop, behavior: 'auto' }) - expect(scrollTo.mock.calls.some(([options]) => options?.behavior === 'smooth')).toBe(false) - }) - }) - // Rail jumps borrow the diff-reveal pin; release it after scrolling so later renders cannot repeat or outrank navigation. describe('jumping to a message from the rail', () => { afterEach(cleanup) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index cbdecc71887..538cf3ce38c 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -61,7 +61,10 @@ import type { import type { AgentSessionLatestTurn } from '../../../../shared/agent-session/agent-session-wire' import { isStructuredAgentSessionThinking } from '../../../../shared/agent-session/view/structured-agent-session-live-turn' import type { NativeChatSettledTurns } from '../../../../shared/native-chat/transcript/native-chat-turn-status' -import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-turn-diffs' +import type { + NativeChatDiffReveal, + NativeChatDiffTarget +} from '../../../../shared/native-chat/tools/native-chat-turn-diffs' import { useNativeChatTurnDiffs } from './use-native-chat-turn-diffs' import { transcriptTailRow } from './native-chat-transcript-tail-row' import type { NativeChatAwaitingInput } from '../../../../shared/native-chat/native-chat-terminal-turn' @@ -171,11 +174,7 @@ export function NativeChatMessageList({ latestTurn ) const { messages: rows, turnKeys, liveTurnKey } = turnRows - const turnDiffs = useNativeChatTurnDiffs( - journalItems ? turnRows : null, - subagentRowsInOrder, - subagentSections.pathOf - ) + const turnDiffs = useNativeChatTurnDiffs(turnRows, subagentRowsInOrder, subagentSections.pathOf) // Only real tail reasoning counts as thinking; an in-flight request with no output does not. const thinking = useMemo( () => isStructuredAgentSessionThinking({ items: journalItems ?? [], latestTurn }), diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx index 2690313db2b..0684b703f12 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx @@ -221,7 +221,7 @@ describe('revealing a diff from a turn rollup', () => { ) ] - it('mounts the row a reveal names even when the window has left it behind', () => { + it('opens an old edit inline at the visible turn outcome without mounting its source row', () => { const scrollTo = vi.fn() vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) const { container } = render( @@ -237,14 +237,13 @@ describe('revealing a diff from a turn rollup', () => { expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull() const mountedBefore = windowState(container).indexes.length - fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) scrollTo.mockClear() fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) expect(screen.getByText('Edited')).toBeInTheDocument() expect(screen.getByText('after')).toBeInTheDocument() - expect(scrollTo).toHaveBeenCalled() - // Pinned, not paged to: the window is still a window. + expect(scrollTo).not.toHaveBeenCalled() + // The outcome reuses the bounded transcript window. expect(windowState(container).indexes.length).toBeLessThanOrEqual(mountedBefore + 2) }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index d8a2e156b15..8c4485b9350 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -15,7 +15,7 @@ import { nativeChatNoticeBlock } from '../../../../shared/native-chat/native-cha import { nativeChatStoppedBeforeStartLabel } from './native-chat-stopped-before-start-row' import { nativeChatBlocksInOwnWords } from '../../../../shared/native-chat/native-chat-stopped-before-start' import { ProviderFrameRow } from './NativeChatTranscriptChrome' -import type { NativeChatDiffReveal } from './native-chat-turn-diffs' +import type { NativeChatDiffReveal } from '../../../../shared/native-chat/tools/native-chat-turn-diffs' import type { NativeChatSubagentDisclosure, NativeChatSubagentRosterState diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index c7d4e254c51..44777958ce7 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -14,7 +14,7 @@ import { type NativeChatToolCallBlock } from '../../../../shared/native-chat/native-chat-types' import { isRenderableSubagentGroup } from '../../../../shared/native-chat/transcript/native-chat-subagent-summary' -import type { NativeChatDiffReveal } from './native-chat-turn-diffs' +import type { NativeChatDiffReveal } from '../../../../shared/native-chat/tools/native-chat-turn-diffs' import { nativeChatToolRunSentence } from './native-chat-tool-run-label' import { NATIVE_CHAT_TOOL_ACTIVITY_COPY } from '../../../../shared/native-chat/tools/native-chat-tool-activity' import { nativeChatToolRunHeader } from '../../../../shared/native-chat/tools/native-chat-tool-run-header' diff --git a/src/renderer/src/components/native-chat/NativeChatToolRunMemberList.tsx b/src/renderer/src/components/native-chat/NativeChatToolRunMemberList.tsx index cca8735a00f..3824dee4d32 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRunMemberList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRunMemberList.tsx @@ -11,7 +11,7 @@ import { NativeChatTaskList } from './NativeChatTaskList' import { NativeChatToolLine } from './NativeChatToolLine' import { buildEditCards } from '../../../../shared/native-chat/tools/native-chat-edit-cards' import { buildNativeChatTaskListRows } from '../../../../shared/native-chat/input/native-chat-task-list-history' -import type { NativeChatDiffReveal } from './native-chat-turn-diffs' +import type { NativeChatDiffReveal } from '../../../../shared/native-chat/tools/native-chat-turn-diffs' import type { NativeChatToolRunAsides } from './NativeChatToolRun' /** Mount only while expanded so collapsed runs avoid member pairing and diff work. */ diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx index 1ddb540660c..b153115ca26 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx @@ -11,7 +11,10 @@ import { NativeChatSubagentEntries } from './NativeChatSubagentRun' import type { NativeChatSubagentDisclosure } from './native-chat-subagent-sections' import type { NativeChatTaskListPredecessors } from '../../../../shared/native-chat/input/native-chat-task-list-history' import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots' -import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-turn-diffs' +import type { + NativeChatDiffReveal, + NativeChatDiffTarget +} from '../../../../shared/native-chat/tools/native-chat-turn-diffs' import { NativeChatRewindContext } from './native-chat-rewind-context' import { nativeChatRowOffersRewind } from './native-chat-rewind-eligibility' import { isActionableStructuredAgentSessionPrompt } from '../../../../shared/agent-session/view/structured-agent-session-live-turn' @@ -139,7 +142,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ )} {slot.statusAbove ? null : statusRow} {turnDiff ? ( - + ) : null} ) diff --git a/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.fixture.tsx b/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.fixture.tsx new file mode 100644 index 00000000000..3128e4786c9 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.fixture.tsx @@ -0,0 +1,104 @@ +import { defineFixture } from '../../component-fixtures/component-fixture' +import { NativeChatTurnDiffRollup } from './NativeChatTurnDiffRollup' +import { NativeChatMessageList } from './NativeChatMessageList' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { nativeChatTurnDiffs } from '../../../../shared/native-chat/tools/native-chat-turn-diffs' +import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types' + +const messages: NativeChatMessage[] = [ + { + id: 'user', + role: 'user', + source: 'transcript', + timestamp: 0, + blocks: [{ type: 'text', text: 'Update the greeting and mark the feature ready.' }] + }, + { + id: 'edits', + role: 'assistant', + source: 'transcript', + timestamp: 1, + blocks: [ + { + type: 'tool-call', + name: 'Bash', + state: 'completed', + input: { command: 'inspect sources' } + }, + { type: 'tool-result', output: 'Source files inspected.' }, + { + type: 'tool-call', + name: 'Edit', + state: 'completed', + input: { + file_path: '/Users/alice/orca/workspaces/orca/feature-x/src/components/greeting.ts', + old_string: 'return "Hello"', + new_string: 'return `Hello, ${name}`' + } + }, + { type: 'tool-result', output: 'The file was updated successfully.' }, + { type: 'tool-call', name: 'Diff', input: { path: 'src/components/status.ts' } }, + { + type: 'tool-result', + output: '@@ -1 +1 @@\n-export const ready = false\n+export const ready = true' + } + ] + }, + { + id: 'answer', + role: 'assistant', + source: 'transcript', + timestamp: 2, + blocks: [ + { type: 'text', text: 'The greeting now includes the name, and the feature is ready.' } + ] + } +] +const diff = nativeChatTurnDiffs( + messages, + messages.map(() => 'turn') +).get('turn')! +export const settled = defineFixture({ + width: 560, + render: () => +}) +export const opened = defineFixture({ + width: 560, + render: () => , + interact: (frame) => { + frame.querySelector('button')?.click() + } +}) + +const session: NativeChatLiveSession = { + messages, + agent: 'claude', + sessionId: 'fixture', + status: 'ready', + readPhase: 'ready', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: async () => 'applied' +} +function Transcript({ working = false }: { working?: boolean }) { + return ( +
+ +
+ ) +} +export const completedTranscript = defineFixture({ width: 560, render: () => }) +export const completedTranscriptOpened = defineFixture({ + width: 560, + render: () => , + interact: (frame) => { + frame.querySelector('button[title$="greeting.ts"]')?.click() + } +}) +export const liveTranscript = defineFixture({ width: 560, render: () => }) diff --git a/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.test.tsx b/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.test.tsx new file mode 100644 index 00000000000..a4b1e1071c8 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types' +import { nativeChatTurnDiffs } from '../../../../shared/native-chat/tools/native-chat-turn-diffs' +import { TooltipProvider } from '../ui/tooltip' +import { NativeChatTurnDiffRollup } from './NativeChatTurnDiffRollup' +import { NativeChatMessageList } from './NativeChatMessageList' +import { session, stubLayout, stubResizeObserver } from './native-chat-windowing-test-harness' + +const messages: NativeChatMessage[] = [ + { + id: 'u', + role: 'user', + source: 'transcript', + timestamp: 1, + blocks: [{ type: 'text', text: 'Fix the greeting' }] + }, + { + id: 'work', + role: 'assistant', + source: 'transcript', + timestamp: 2, + blocks: [ + { + type: 'tool-call', + name: 'Bash', + input: { command: 'inspect-private-diagnostic' }, + state: 'completed' + }, + { type: 'tool-result', output: 'diagnostic-output' }, + { + type: 'tool-call', + name: 'Edit', + input: { + file_path: '/repo/greeting.ts', + old_string: 'helloOld()', + new_string: 'helloNew()' + }, + state: 'completed' + }, + { type: 'tool-result', output: 'The file /repo/greeting.ts has been updated successfully.' }, + { type: 'tool-call', name: 'Diff', input: { path: 'status.ts' } }, + { type: 'tool-result', output: '@@ -1 +1 @@\n-statusOld()\n+statusNew()' } + ] + }, + { + id: 'answer', + role: 'assistant', + source: 'transcript', + timestamp: 3, + blocks: [{ type: 'text', text: 'Updated the greeting.' }] + } +] +let restoreLayout = (): void => {} +let restoreResizeObserver = (): void => {} +beforeEach(() => { + restoreLayout = stubLayout({ scrollGeometry: true, offsetChain: true }) + restoreResizeObserver = stubResizeObserver() +}) +afterEach(() => { + cleanup() + restoreResizeObserver() + restoreLayout() +}) + +it('keeps landed files visible after work folds, opens a patch in one click and survives history remount', () => { + const onLinkClick = vi.fn() + const view = (working: boolean) => ( + + + + ) + const { rerender, unmount } = render(view(true)) + expect(screen.getByRole('button', { name: /\/repo\/greeting.ts/ })).toBeVisible() + rerender(view(false)) + expect(screen.getByRole('button', { name: /\/repo\/greeting.ts/ })).toBeVisible() + expect(screen.queryByText('diagnostic-output')).toBeNull() + expect(screen.queryByText('helloNew()')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /\/repo\/greeting.ts/ })) + expect(screen.getByText('helloOld()')).toBeVisible() + expect(screen.getByText('helloNew()')).toBeVisible() + fireEvent.click(screen.getByRole('link', { name: '/repo/greeting.ts' })) + expect(onLinkClick).toHaveBeenCalledTimes(1) + expect(screen.getByText('helloNew()')).toBeVisible() + expect(screen.queryByText('statusNew()')).toBeNull() + unmount() + render(view(false)) + expect(screen.getByRole('button', { name: /status.ts/ })).toBeVisible() + expect(screen.queryByText('diagnostic-output')).toBeNull() +}) + +it('mounts a bounded file prefix and no patch bodies until a file is selected', () => { + const many: NativeChatMessage[] = Array.from({ length: 200 }, (_, index) => ({ + id: `e${index}`, + role: 'assistant', + source: 'transcript', + timestamp: index, + blocks: [ + { type: 'tool-call', name: 'Diff', input: { path: `file-${index}.ts` } }, + { type: 'tool-result', output: `@@ -1 +1 @@\n-old-${index}\n+new-${index}` } + ] + })) + const diff = nativeChatTurnDiffs( + many, + many.map(() => 'u') + ).get('u')! + const { container } = render() + expect(screen.getAllByRole('button', { name: /file-\d+.ts/ })).toHaveLength(20) + expect(container.querySelectorAll('[data-native-chat-code-content]')).toHaveLength(0) + fireEvent.click(screen.getByRole('button', { name: /file-0.ts/ })) + expect(screen.getByText('new-0')).toBeVisible() + expect(screen.queryByText('new-1')).toBeNull() + expect(container.querySelectorAll('[data-native-chat-code-content]')).toHaveLength(1) + fireEvent.click(screen.getByRole('button', { name: /Show 180 more/ })) + expect(screen.getByRole('button', { name: /file-199.ts/ })).toBeVisible() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.tsx b/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.tsx index d740b3245bc..5bef643bfbc 100644 --- a/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.tsx @@ -1,20 +1,26 @@ import { ChevronRight } from 'lucide-react' -import { useId } from 'react' import { Button } from '@/components/ui/button' -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { translate } from '@/i18n/i18n' import { DiffLineCounts } from '../right-sidebar/source-control/listing/diff-line-counts' import { NativeChatDiffCard } from './NativeChatDiffCard' import { NativeChatExpandable } from './NativeChatExpandable' import { useNativeChatDisclosure } from './native-chat-disclosure-store' +import { nativeChatEditFileBaseName } from '../../../../shared/native-chat/tools/native-chat-edit-model' +import type { CommentMarkdownLinkClickHandler } from '../sidebar/CommentMarkdown' import { nativeChatTurnDiffEditFile, - type NativeChatDiffTarget, + NATIVE_CHAT_TURN_DIFF_FILE_LIMIT, type NativeChatTurnDiff, type NativeChatTurnDiffFile -} from './native-chat-turn-diffs' +} from '../../../../shared/native-chat/tools/native-chat-turn-diffs' -function TurnDiffFilePatches({ file }: { file: NativeChatTurnDiffFile }): React.JSX.Element { +function TurnDiffFilePatches({ + file, + onLinkClick +}: { + file: NativeChatTurnDiffFile + onLinkClick?: CommentMarkdownLinkClickHandler +}): React.JSX.Element { return (
{file.edits.map((edit) => { @@ -23,6 +29,7 @@ function TurnDiffFilePatches({ file }: { file: NativeChatTurnDiffFile }): React. @@ -34,46 +41,34 @@ function TurnDiffFilePatches({ file }: { file: NativeChatTurnDiffFile }): React. function TurnDiffFileRow({ file, - onReveal + onLinkClick }: { file: NativeChatTurnDiffFile - onReveal: (target: NativeChatDiffTarget) => void + onLinkClick?: CommentMarkdownLinkClickHandler }): React.JSX.Element { - const pathId = useId() + const name = nativeChatEditFileBaseName(file.path) const disclosureKey = `turn-diff:${file.edits[0]?.target.messageId}:${file.path}` const { open, setOpen } = useNativeChatDisclosure(disclosureKey, false) return (
-
- - -
+ - +
) @@ -81,36 +76,43 @@ function TurnDiffFileRow({ export function NativeChatTurnDiffRollup({ diff, - onReveal + onLinkClick }: { diff: NativeChatTurnDiff - onReveal: (target: NativeChatDiffTarget) => void + onLinkClick?: CommentMarkdownLinkClickHandler }): React.JSX.Element { + const { open: showAll, setOpen: setShowAll } = useNativeChatDisclosure( + `turn-diff-files:${diff.files[0]?.edits[0]?.target.messageId}`, + false + ) + const files = showAll ? diff.files : diff.files.slice(0, NATIVE_CHAT_TURN_DIFF_FILE_LIMIT) + const remaining = diff.files.length - files.length return ( - - - - - - {diff.files.map((file) => ( - +
+
+ + {translate('components.native-chat.turnDiff.changedFiles', '{{count}} changed files', { + count: diff.files.length, + defaultValue_one: '{{count}} changed file' + })} + + + {diff.truncated ? ( + {translate('components.native-chat.turnDiff.partial', 'Partial diff')} + ) : null} +
+
+ {files.map((file) => ( + ))} - - + {remaining > 0 ? ( + + ) : null} +
+
) } diff --git a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts index 3347295c8f6..3f0b7b16bc6 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types' import type { NativeChatResolvedPrompt } from '../../../../shared/native-chat/native-chat-resolution-receipt' -import type { NativeChatTurnDiff } from './native-chat-turn-diffs' +import type { NativeChatTurnDiff } from '../../../../shared/native-chat/tools/native-chat-turn-diffs' import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' import { buildNativeChatRailItems, diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index 50c18987be9..321f1acaa43 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -25,7 +25,7 @@ import { nativeChatSubagentSections } from './native-chat-subagent-sections' import { projectNativeChatTaskListFrames } from '../../../../shared/native-chat/input/native-chat-task-list-frames' import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows' import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' -import type { NativeChatTurnDiff } from './native-chat-turn-diffs' +import type { NativeChatTurnDiff } from '../../../../shared/native-chat/tools/native-chat-turn-diffs' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' function row(sequence: number, body: AgentJournalItemBody, itemId = `item-${sequence}`) { diff --git a/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts index 68be4da465a..a05fecd7960 100644 --- a/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts +++ b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts @@ -1,5 +1,6 @@ // Keep estimates arithmetic over cached metrics; per-measurement block scans would traverse the whole transcript. +import { NATIVE_CHAT_TURN_DIFF_FILE_LIMIT } from '../../../../shared/native-chat/tools/native-chat-turn-diffs' import { agentMessageIsCompact } from '../../../../shared/agent-session/agent-message-presentation' import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types' import { deriveNativeChatRowContent } from '../../../../shared/native-chat/transcript/native-chat-row-content' @@ -24,6 +25,7 @@ export type NativeChatRowChromeMetrics = { hasReceipt: boolean hasStatus: boolean hasTurnDiff: boolean + turnDiffFileCount?: number /** Folded prose/tools paint only a roster and must not reserve their expanded height. */ folded?: boolean /** Inside a subagent's section, where assistant prose keeps its controls in flow. */ @@ -176,7 +178,15 @@ export function estimateNativeChatRowHeight( partCount += 1 } if (chrome.hasTurnDiff) { - height += TURN_DIFF_PX * scale + height += + TURN_DIFF_PX * + scale * + (1 + + Math.min(chrome.turnDiffFileCount ?? 0, NATIVE_CHAT_TURN_DIFF_FILE_LIMIT) + + (chrome.turnDiffFileCount !== undefined && + chrome.turnDiffFileCount > NATIVE_CHAT_TURN_DIFF_FILE_LIMIT + ? 1 + : 0)) partCount += 1 } height += Math.max(0, partCount - 1) * NATIVE_CHAT_ROW_GAP_PX diff --git a/src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts b/src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts index cf5c1a8e5ec..fb2d014b6da 100644 --- a/src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts @@ -25,7 +25,7 @@ import { type NativeChatMessageSlot, type NativeChatTranscriptSlot } from './native-chat-transcript-slots' -import { nativeChatTurnDiffs } from './native-chat-turn-diffs' +import { nativeChatTurnDiffs } from '../../../../shared/native-chat/tools/native-chat-turn-diffs' let sequence = 0 diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 6b195398d07..0b5addc1cb3 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -12,7 +12,7 @@ import { } from '../../../../shared/native-chat/transcript/native-chat-turn-status' import { selectStructuredAgentSettledTurns } from '../../../../shared/agent-session/view/structured-agent-session-turn-timing' import type { NativeChatResolvedPrompt } from '../../../../shared/native-chat/native-chat-resolution-receipt' -import type { NativeChatTurnDiff } from './native-chat-turn-diffs' +import type { NativeChatTurnDiff } from '../../../../shared/native-chat/tools/native-chat-turn-diffs' import { buildNativeChatTranscriptSlots, nativeChatSlotIndexOf, diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index 20dbbd7e625..0d56f84e6af 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -20,7 +20,7 @@ import { nativeChatRowContentMetrics } from './native-chat-row-height-estimate' import type { NativeChatResolvedPrompt } from '../../../../shared/native-chat/native-chat-resolution-receipt' -import type { NativeChatTurnDiff } from './native-chat-turn-diffs' +import type { NativeChatTurnDiff } from '../../../../shared/native-chat/tools/native-chat-turn-diffs' import { NO_NATIVE_CHAT_SUBAGENT_CHOICES, NO_NATIVE_CHAT_SUBAGENT_SECTIONS, @@ -205,6 +205,7 @@ export function buildNativeChatTranscriptSlots( hasReceipt: receipt !== undefined, hasStatus: status !== undefined, hasTurnDiff: turnDiff !== undefined, + turnDiffFileCount: turnDiff?.files.length, folded }, typography diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-work-runs.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-work-runs.test.ts index 0ef562f24b2..50c23206091 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-work-runs.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-work-runs.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types' import type { NativeChatResolvedPrompt } from '../../../../shared/native-chat/native-chat-resolution-receipt' -import type { NativeChatTurnDiff } from './native-chat-turn-diffs' +import type { NativeChatTurnDiff } from '../../../../shared/native-chat/tools/native-chat-turn-diffs' import type { NativeChatTurnStatus } from '../../../../shared/native-chat/transcript/native-chat-turn-status' import { buildNativeChatTranscriptSlots, diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-work-runs.ts b/src/renderer/src/components/native-chat/native-chat-transcript-work-runs.ts index c7735d07149..412f5305237 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-work-runs.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-work-runs.ts @@ -58,6 +58,7 @@ function workRunSlot( hasReceipt: false, hasStatus: head.status !== undefined, hasTurnDiff: turnDiff !== undefined, + turnDiffFileCount: turnDiff?.files.length, inSubagentSection: head.depth > 0 }, typography diff --git a/src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts b/src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts index b6d443549e6..ee1a76a3cc2 100644 --- a/src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts @@ -6,10 +6,13 @@ import type { } from '../../../../shared/native-chat/native-chat-types' import { foldToolMessages } from '../../../../shared/native-chat/tools/native-chat-tool-fold' import { - buildDiffSummaries, + buildEditSummaries, buildEditCards } from '../../../../shared/native-chat/tools/native-chat-edit-cards' -import { nativeChatTurnDiffEditFile, nativeChatTurnDiffs } from './native-chat-turn-diffs' +import { + nativeChatTurnDiffEditFile, + nativeChatTurnDiffs +} from '../../../../shared/native-chat/tools/native-chat-turn-diffs' function diff(id: string, path: string, patch = '@@ -1 +1 @@\n-old\n+new'): NativeChatMessage { return { @@ -172,18 +175,10 @@ describe('turn diff rollups', () => { ).toBe(0) }) - it('leaves non-journal Diff envelopes to the deferred tool card', () => { - for (const input of [{ path: 'x', patch: '@@\n+override' }, { file_path: 'x' }, null]) { - const message = diff('generic', 'x') - message.blocks[0] = { type: 'tool-call', name: 'Diff', input } - expect(buildDiffSummaries(message.blocks).size).toBe(0) - } - }) - it('caches counts and deferred card models separately and refreshes new results', () => { const message = diff('a', 'a.ts') - const summary = [...buildDiffSummaries(message.blocks).values()][0]!.files - expect([...buildDiffSummaries([...message.blocks]).values()][0]!.files).toBe(summary) + const summary = [...buildEditSummaries(message.blocks).values()][0]!.files + expect([...buildEditSummaries([...message.blocks]).values()][0]!.files).toBe(summary) expect(summary[0]).not.toHaveProperty('lines') const first = [...buildEditCards(message.blocks).editCards.values()][0]!.files expect([...buildEditCards([...message.blocks]).editCards.values()][0]!.files).toBe(first) @@ -194,7 +189,7 @@ describe('turn diff rollups', () => { const updated = [...buildEditCards(message.blocks).editCards.values()][0]!.files expect(updated).not.toBe(first) expect(updated[0]?.added).toBe(2) - const updatedSummary = [...buildDiffSummaries(message.blocks).values()][0]!.files + const updatedSummary = [...buildEditSummaries(message.blocks).values()][0]!.files expect(updatedSummary).not.toBe(summary) expect(updatedSummary[0]?.added).toBe(2) }) @@ -209,10 +204,41 @@ describe('turn diff rollups', () => { '@@ -1 +1 @@\n-old\n+new\n… (9999 bytes)' ])('keeps lightweight counts identical to the expanded card (case %#)', (patch) => { const message = diff('parity', 'a.ts', patch) - const summary = [...buildDiffSummaries(message.blocks).values()][0]!.files + const summary = [...buildEditSummaries(message.blocks).values()][0]!.files const detailed = [...buildEditCards(message.blocks).editCards.values()][0]!.files expect(summary).toEqual( detailed.map(({ lines: _lines, lineNumbersKnown: _known, ...file }) => file) ) }) }) + +it('summarizes landed Claude and Codex edits, including a completed call without a result', () => { + const message: NativeChatMessage = { + id: 'tools', + role: 'assistant', + timestamp: 1, + source: 'transcript', + blocks: [ + { + type: 'tool-call', + name: 'Edit', + state: 'completed', + input: { file_path: '/repo/a.ts', old_string: 'before', new_string: 'after' } + }, + { type: 'tool-result', output: 'The file /repo/a.ts has been updated successfully.' }, + { + type: 'tool-call', + name: 'apply_patch', + state: 'completed', + input: { patch: '*** Begin Patch\n*** Add File: b.ts\n+added\n*** End Patch' } + } + ] + } + const turn = nativeChatTurnDiffs([message], ['u']).get('u')! + expect(turn.files.map((file) => [file.path, file.added, file.removed])).toEqual([ + ['/repo/a.ts', 1, 1], + ['b.ts', 1, 0] + ]) + expect(turn.files[1]!.edits.map(nativeChatTurnDiffEditFile)[0]?.lines[0]?.text).toBe('added') + expect(nativeChatTurnDiffs([message], ['u'], undefined, 'u').get('u')?.truncated).toBe(true) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts index 7ec237cbacf..7a41e843487 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts @@ -5,7 +5,7 @@ import * as rowContent from '../../../../shared/native-chat/transcript/native-ch import { afterEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types' import type { NativeChatResolvedPrompt } from '../../../../shared/native-chat/native-chat-resolution-receipt' -import type { NativeChatTurnDiff } from './native-chat-turn-diffs' +import type { NativeChatTurnDiff } from '../../../../shared/native-chat/tools/native-chat-turn-diffs' import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' import { useNativeChatMessageRail } from './use-native-chat-message-rail' diff --git a/src/renderer/src/components/native-chat/use-native-chat-turn-diffs.ts b/src/renderer/src/components/native-chat/use-native-chat-turn-diffs.ts index 7ea1da1b584..38d96da835b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-turn-diffs.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-turn-diffs.ts @@ -1,7 +1,10 @@ import { useMemo } from 'react' import type { NativeChatSubagentRow } from '../../../../shared/native-chat/transcript/native-chat-transcript-projection' import { nativeChatRowsInTranscriptOrder } from './native-chat-subagent-sections' -import { nativeChatTurnDiffs, type NativeChatTurnDiff } from './native-chat-turn-diffs' +import { + nativeChatTurnDiffs, + type NativeChatTurnDiff +} from '../../../../shared/native-chat/tools/native-chat-turn-diffs' import type { NativeChatTurnRows } from './use-native-chat-turn-membership' const NO_TURN_DIFFS: ReadonlyMap = new Map() diff --git a/src/renderer/src/components/native-chat/use-native-chat-work-run.tsx b/src/renderer/src/components/native-chat/use-native-chat-work-run.tsx index e054aff5d25..dba90564889 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-work-run.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-work-run.tsx @@ -11,7 +11,7 @@ import { } from '../../../../shared/native-chat/native-chat-work-run' import { NativeChatReasoningRow } from './NativeChatReasoningRow' import type { NativeChatToolRunAsides } from './NativeChatToolRun' -import type { NativeChatDiffReveal } from './native-chat-turn-diffs' +import type { NativeChatDiffReveal } from '../../../../shared/native-chat/tools/native-chat-turn-diffs' export type NativeChatWorkRun = { /** Every member's tool blocks, in order: the run's one block list. */ diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 58a4ed9602b..f359908cdfd 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -19336,8 +19336,6 @@ }, "turnDiff": { "partial": "Teilweiser Diff", - "showDiff": "Diff anzeigen", - "hideDiff": "Diff ausblenden", "changedFiles_one": "{{count}} geänderte Datei", "changedFiles_other": "{{count}} geänderte Dateien" }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3963285e764..6b2fbb28403 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19729,10 +19729,9 @@ }, "turnDiff": { "partial": "Partial diff", - "showDiff": "Show diff", - "hideDiff": "Hide diff", "changedFiles_one": "{{count}} changed file", - "changedFiles_other": "{{count}} changed files" + "changedFiles_other": "{{count}} changed files", + "showMoreFiles": "Show {{count}} more" }, "receipt": { "unavailable": "Selected answer unavailable", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 148255290aa..342bcdecc59 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -19584,8 +19584,6 @@ }, "turnDiff": { "partial": "Diff parcial", - "showDiff": "Mostrar diferencias", - "hideDiff": "Ocultar diferencias", "changedFiles_one": "{{count}} archivo modificado", "changedFiles_other": "{{count}} archivos modificados", "changedFiles_many": "{{count}} archivos modificados" diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index ae717b48b09..bd99b64ea83 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -19635,8 +19635,6 @@ }, "turnDiff": { "partial": "Différence partielle", - "showDiff": "Afficher les différences", - "hideDiff": "Masquer les différences", "changedFiles_one": "{{count}} fichier modifié", "changedFiles_other": "{{count}} fichiers modifiés", "changedFiles_many": "{{count}} fichiers modifiés" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c5b9c6d7556..dc7d6ed9f76 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18950,8 +18950,6 @@ }, "turnDiff": { "partial": "部分的な差分", - "showDiff": "差分を表示", - "hideDiff": "差分を非表示", "changedFiles_other": "変更されたファイル {{count}} 個" }, "receipt": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 6e2fe0bc79e..48b3bc56df7 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18950,8 +18950,6 @@ }, "turnDiff": { "partial": "부분적인 차이점", - "showDiff": "차이 보기", - "hideDiff": "차이 숨기기", "changedFiles_other": "변경된 파일 {{count}}개" }, "receipt": { diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index a052090a445..e2b99c5c69e 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -20050,8 +20050,6 @@ }, "turnDiff": { "partial": "Diff parcial", - "showDiff": "Mostrar diferenças", - "hideDiff": "Ocultar diferenças", "changedFiles_one": "{{count}} arquivo alterado", "changedFiles_other": "{{count}} arquivos alterados", "changedFiles_many": "{{count}} arquivos alterados" diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 024153dccbf..2a484aa1d95 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -19911,8 +19911,6 @@ }, "turnDiff": { "partial": "Показана часть изменений", - "showDiff": "Показать изменения", - "hideDiff": "Скрыть изменения", "changedFiles_one": "Изменён {{count}} файл", "changedFiles_few": "Изменено {{count}} файла", "changedFiles_many": "Изменено {{count}} файлов", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index e691d6afa7f..c667740e84f 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -19323,8 +19323,6 @@ }, "turnDiff": { "partial": "Kısmi diff", - "showDiff": "Diff'i göster", - "hideDiff": "Diff'i gizle", "changedFiles_other": "Değiştirilen {{count}} dosya", "changedFiles_one": "Değiştirilen {{count}} dosya" }, diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index d3402e5ce1c..4cc801007ea 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -19078,8 +19078,6 @@ }, "turnDiff": { "partial": "部分差異", - "showDiff": "顯示差異", - "hideDiff": "隱藏差異", "changedFiles_other": "{{count}} 個已變更的檔案" }, "receipt": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4fab58c41c8..831d7f76dbd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18895,8 +18895,6 @@ }, "turnDiff": { "partial": "部分差异", - "showDiff": "显示差异", - "hideDiff": "隐藏差异", "changedFiles_other": "{{count}} 个已更改的文件" }, "receipt": { diff --git a/src/shared/native-chat/tools/native-chat-edit-cards.ts b/src/shared/native-chat/tools/native-chat-edit-cards.ts index 5fda6c50952..bf66bbc426f 100644 --- a/src/shared/native-chat/tools/native-chat-edit-cards.ts +++ b/src/shared/native-chat/tools/native-chat-edit-cards.ts @@ -96,45 +96,54 @@ const diffSummaries = new WeakMap< >() // Only the journal's path-only Diff envelope has counts that can be read without tool normalization. -export function buildDiffSummaries(blocks: NativeChatBlock[]): Map< +export function buildEditSummaries(blocks: NativeChatBlock[]): Map< NativeChatToolCallBlock, { files: NativeChatEditFileSummary[] key: string - result: NativeChatToolResultBlock + result: NativeChatToolResultBlock | undefined } > { const summaries = new Map< NativeChatToolCallBlock, - { files: NativeChatEditFileSummary[]; key: string; result: NativeChatToolResultBlock } + { + files: NativeChatEditFileSummary[] + key: string + result: NativeChatToolResultBlock | undefined + } >() for (const [index, pair] of pairToolBlocks(blocks).entries()) { const { call, result } = pair - if ( - !call || - call.name !== 'Diff' || - call.state === 'running' || - call.state === 'failed' || - result?.isError || - result?.editPatch || - !result?.output - ) { + if (!call || !isEditToolName(call.name)) { continue } const input = call.input - if ( - !input || - typeof input !== 'object' || - !('path' in input) || - typeof input.path !== 'string' || - Object.keys(input).some((key) => key !== 'path') - ) { + const journalDiff = + call.name === 'Diff' && + call.state !== 'running' && + call.state !== 'failed' && + result && + !result.isError && + !result.editPatch && + result.output && + input && + typeof input === 'object' && + 'path' in input && + typeof input.path === 'string' && + Object.keys(input).every((key) => key === 'path') + ? { output: result.output, path: input.path } + : undefined + if (!journalDiff) { + const files = normalizedToolEditFiles(call, result) + if (files?.length) { + summaries.set(call, { files, key: `${call.name}:${index}`, result }) + } continue } const cached = diffSummaries.get(call) - let files = cached?.result === result ? cached.files : undefined + let files = cached && cached.result === result ? cached.files : undefined if (files === undefined) { - files = editFilesFromPatchText(result.output, input.path, true) + files = editFilesFromPatchText(journalDiff.output, journalDiff.path, true) diffSummaries.set(call, { result, files }) } if (files?.length) { diff --git a/src/shared/native-chat/tools/native-chat-edit-summary.performance.test.ts b/src/shared/native-chat/tools/native-chat-edit-summary.performance.test.ts new file mode 100644 index 00000000000..5729758d191 --- /dev/null +++ b/src/shared/native-chat/tools/native-chat-edit-summary.performance.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../native-chat-types' +import { nativeChatTurnDiffs, sameNativeChatTurnDiff } from './native-chat-turn-diffs' + +// Count source normalization separately from rendered patches: unopened summaries still consume input. +describe('landed edit summary work', () => { + it.each([100, 1000])('normalizes %i Claude edits once and only new input on append', (count) => { + let contentReads = 0 + const edit = (index: number): NativeChatMessage => ({ + id: `edit-${index}`, + role: 'assistant', + source: 'transcript', + timestamp: index, + blocks: [ + { + type: 'tool-call', + name: 'Edit', + state: 'completed', + input: { + file_path: `/repo/file-${index}.ts`, + old_string: 'old-line\n'.repeat(40), + get new_string() { + contentReads += 1 + return 'new-line\n'.repeat(40) + } + } + }, + { type: 'tool-result', output: 'The file was updated successfully.' } + ] + }) + const messages = Array.from({ length: count }, (_, index) => edit(index)) + const keys = messages.map(() => 'u') + const first = nativeChatTurnDiffs(messages, keys).get('u')! + expect(first.files).toHaveLength(count) + const coldReads = contentReads + expect(coldReads).toBeGreaterThanOrEqual(count) + expect(coldReads).toBeLessThanOrEqual(count * 4) + const same = nativeChatTurnDiffs([...messages], [...keys]).get('u')! + expect(contentReads).toBe(coldReads) + expect(sameNativeChatTurnDiff(first, same)).toBe(true) + const appended = nativeChatTurnDiffs([...messages, edit(count)], [...keys, 'u']).get('u')! + expect(appended.files).toHaveLength(count + 1) + expect(contentReads - coldReads).toBe(coldReads / count) + expect(sameNativeChatTurnDiff(first, appended)).toBe(false) + const refreshed = [...messages] + const last = refreshed[count - 1]! + refreshed[count - 1] = { + ...last, + blocks: [last.blocks[0]!, { type: 'tool-result', output: 'Updated successfully.' }] + } + const beforeResult = contentReads + nativeChatTurnDiffs(refreshed, keys) + expect(contentReads - beforeResult).toBe(coldReads / count) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-turn-diffs.ts b/src/shared/native-chat/tools/native-chat-turn-diffs.ts similarity index 67% rename from src/renderer/src/components/native-chat/native-chat-turn-diffs.ts rename to src/shared/native-chat/tools/native-chat-turn-diffs.ts index 6f90837c20d..182a60224af 100644 --- a/src/renderer/src/components/native-chat/native-chat-turn-diffs.ts +++ b/src/shared/native-chat/tools/native-chat-turn-diffs.ts @@ -1,13 +1,10 @@ -import type { NativeChatEditFile } from '../../../../shared/native-chat/tools/native-chat-edit-model' +import type { NativeChatEditFile } from './native-chat-edit-model' import type { NativeChatMessage, NativeChatToolCallBlock, NativeChatToolResultBlock -} from '../../../../shared/native-chat/native-chat-types' -import { - buildDiffSummaries, - normalizedToolEditFiles -} from '../../../../shared/native-chat/tools/native-chat-edit-cards' +} from '../native-chat-types' +import { buildEditSummaries, normalizedToolEditFiles } from './native-chat-edit-cards' export type NativeChatDiffTarget = { messageId: string @@ -21,7 +18,7 @@ export type NativeChatDiffReveal = NativeChatDiffTarget & { requestId: number } export type NativeChatTurnDiffEdit = { call: NativeChatToolCallBlock - result: NativeChatToolResultBlock + result: NativeChatToolResultBlock | undefined fileIndex: number target: NativeChatDiffTarget order: number @@ -61,11 +58,11 @@ export function nativeChatTurnDiffs( let editOrder = 0 for (const [index, message] of messages.entries()) { const turnKey = turnKeys[index] - if (!turnKey || turnKey === partialTurnKey) { + if (!turnKey) { continue } const sections = subagentSectionsOf?.get(message.id) - for (const [call, edit] of buildDiffSummaries(message.blocks)) { + for (const [call, edit] of buildEditSummaries(message.blocks)) { let files = turns.get(turnKey) if (!files) { files = new Map() @@ -119,9 +116,47 @@ export function nativeChatTurnDiffs( files, added: files.reduce((sum, file) => sum + file.added, 0), removed: files.reduce((sum, file) => sum + file.removed, 0), - truncated: files.some((file) => file.truncated) + truncated: key === partialTurnKey || files.some((file) => file.truncated) } ] }) ) } + +export const NATIVE_CHAT_TURN_DIFF_FILE_LIMIT = 20 + +/** Compare recorded inputs so unrelated streaming turns do not repaint existing outcome cards. */ +export function sameNativeChatTurnDiff( + left: NativeChatTurnDiff | undefined, + right: NativeChatTurnDiff | undefined +): boolean { + return ( + left === right || + (!!left && + !!right && + left.added === right.added && + left.removed === right.removed && + left.truncated === right.truncated && + left.files.length === right.files.length && + left.files.every((file, index) => { + const other = right.files[index]! + return ( + file.path === other.path && + file.added === other.added && + file.removed === other.removed && + file.truncated === other.truncated && + file.edits.length === other.edits.length && + file.edits.every((edit, editIndex) => { + const candidate = other.edits[editIndex]! + return ( + edit.call === candidate.call && + edit.result === candidate.result && + edit.fileIndex === candidate.fileIndex && + edit.target.messageId === candidate.target.messageId && + edit.target.editKey === candidate.target.editKey + ) + }) + ) + })) + ) +}