Show changed files directly in chat turns (#38256)

* Show changed files directly in chat turns

* Use the fixture frame for the diff interaction

* Keep changed filenames readable and preserve file actions

* Keep unconfirmed file changes in tool activity

* Capture changed files in completed transcript fixtures

* Preserve patch data with authoritative history status

* Keep mobile diff interaction coverage beside its card

* Assert the decoded patch line contract

* Exercise full returned history in catch-up coverage

* test(chat): target file summary when reopening patches
This commit is contained in:
Brennan Benson
2026-11-04 17:53:16 -08:00
committed by GitHub
parent 9661361266
commit e5a960cbf9
56 changed files with 1295 additions and 470 deletions
+7 -1
View File
@@ -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",
+8
View File
@@ -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": {
@@ -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')
})
@@ -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)
@@ -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<typeof MobileNativeChatMessage> & {
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 (
<MobileNativeChatItemSourceContext.Provider value={value}>
<MobileNativeChatMessage {...props} />
{turnDiff ? (
<TurnDiffOutcome
diff={turnDiff}
fontScale={props.fontScale ?? 1}
onOpenFile={props.onOpenFile}
/>
) : null}
</MobileNativeChatItemSourceContext.Provider>
)
}, equalMobileNativeChatRowContent)
@@ -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<typeof TurnDiffRollupModule>()
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')
}
)
@@ -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 (
<View>
<Pressable
style={({ pressed }) => [styles.file, pressed && pressedStyle]}
accessibilityRole="button"
accessibilityLabel={file.path}
accessibilityState={{ expanded: open }}
onPress={() => setOpen(!open)}
>
<ChevronRight
size={14 * fontScale}
color={colors.textMuted}
style={open ? styles.open : undefined}
/>
<Text style={[styles.path, type.meta]} numberOfLines={1}>
{name}
</Text>
<Text style={[styles.directory, type.meta]} numberOfLines={1}>
{file.path.slice(0, -name.length)}
</Text>
<Text style={[styles.added, type.meta]}>+{file.added}</Text>
<Text style={[styles.removed, type.meta]}>-{file.removed}</Text>
</Pressable>
{open
? file.edits.map((edit) => {
const patch = nativeChatTurnDiffEditFile(edit)
const key = `turn-diff-card:${edit.target.messageId}:${edit.target.editKey}:${edit.fileIndex}`
return patch ? (
<MobileNativeChatDiffCard
key={key}
file={patch}
fontScale={fontScale}
initialOpen
disclosureKey={key}
rootIdentity={key}
onOpenFile={onOpenFile}
/>
) : null
})
: null}
</View>
)
}
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 (
<View style={styles.card}>
<View style={styles.header}>
<Text style={[styles.label, type.meta]}>
{t('components.native-chat.turnDiff.changedFiles', '{{count}} changed files', {
count: diff.files.length,
defaultValue_one: '{{count}} changed file'
})}
</Text>
<Text style={[styles.added, type.meta]}>+{diff.added}</Text>
<Text style={[styles.removed, type.meta]}>-{diff.removed}</Text>
{diff.truncated ? (
<Text style={[styles.label, type.meta]}>
{t('components.native-chat.turnDiff.partial', 'Partial diff')}
</Text>
) : null}
</View>
{files.map((file) => (
<FileRow key={file.path} file={file} fontScale={fontScale} onOpenFile={onOpenFile} />
))}
{remaining > 0 ? (
<Pressable
accessibilityRole="button"
style={({ pressed }) => [styles.file, pressed && pressedStyle]}
onPress={() => setShowAll(true)}
>
<Text style={[styles.label, type.meta]}>
{t('components.native-chat.turnDiff.showMoreFiles', 'Show {{count}} more', {
count: remaining
})}
</Text>
</Pressable>
) : null}
</View>
)
}
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' }] }
}))
@@ -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)
}
@@ -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) => (
<MobileNativeChatRowContent
key={message.id}
{...turns.resolveRow(index, message)}
message={message}
structuredActivityUi
source={null}
scope="test"
onToggleTurn={turns.onToggleTurn}
/>
))}
</>
)
}
export function textIn(node: ReactTestInstance): string[] {
return findAllByHostType(node, 'Text').map((text) => String(text.children.join('')))
}
@@ -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<string>
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<string>
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 & {
@@ -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<string, string>()
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])
}
@@ -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<string> = 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<string>
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<string>
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<string>, (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,
@@ -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' &&
@@ -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()
})
@@ -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 {
@@ -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' }
]
})
})
@@ -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'
})
})
@@ -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)
})
})
}
@@ -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<void> }): React.JSX.Element {
const [loadedOlder, setLoadedOlder] = useState(false)
const items = useMemo(() => (loadedOlder ? [OLDER, ...LOADED] : LOADED), [loadedOlder])
const loadEarlier = useCallback(async (): Promise<NativeChatOlderPageResult> => {
await holdPage()
setLoadedOlder(true)
return 'applied'
}, [holdPage])
return (
<NativeChatMessageList
session={{
...session(projectStructuredItemsToNativeChat(items)),
hasMore: !loadedOlder,
loadEarlier
}}
journalItems={items}
railOutline={
loadedOlder ? [] : [{ id: 'older', text: 'Oldest prompt', hasImages: false }]
}
isWorking={false}
expandSignal={false}
/>
)
}
function expectDiffInView(): void {
expect(
screen.getByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/, expanded: true })
).toBeInTheDocument()
const row = screen.getByText('Edited').closest<HTMLElement>('[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(<DiffTranscript holdPage={pages.holdPage} />)
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()
})
})
})
@@ -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()
})
@@ -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<HTMLElement>('[data-index]')
const scroller = this.closest<HTMLElement>('[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 => (
<NativeChatMessageList
session={session(projectStructuredItemsToNativeChat(withPrompts))}
journalItems={withPrompts}
isWorking={false}
expandSignal={false}
/>
)
const { container, rerender } = render(transcript())
const selectedTop =
screen.getByText('Second prompt').closest<HTMLElement>('[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<HTMLElement>('[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)
@@ -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 }),
@@ -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)
})
})
@@ -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
@@ -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'
@@ -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. */
@@ -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 ? (
<NativeChatTurnDiffRollup diff={turnDiff} onReveal={context.onRevealDiff} />
<NativeChatTurnDiffRollup diff={turnDiff} onLinkClick={context.onLinkClick} />
) : null}
</div>
)
@@ -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: () => <NativeChatTurnDiffRollup diff={diff} />
})
export const opened = defineFixture({
width: 560,
render: () => <NativeChatTurnDiffRollup diff={diff} />,
interact: (frame) => {
frame.querySelector<HTMLButtonElement>('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 (
<div className="flex h-[600px] flex-col">
<NativeChatMessageList
session={session}
isWorking={working}
expandSignal={false}
settledTurns={working ? undefined : new Map([['user', { startedAt: 0, workedSeconds: 2 }]])}
/>
</div>
)
}
export const completedTranscript = defineFixture({ width: 560, render: () => <Transcript /> })
export const completedTranscriptOpened = defineFixture({
width: 560,
render: () => <Transcript />,
interact: (frame) => {
frame.querySelector<HTMLButtonElement>('button[title$="greeting.ts"]')?.click()
}
})
export const liveTranscript = defineFixture({ width: 560, render: () => <Transcript working /> })
@@ -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) => (
<TooltipProvider>
<NativeChatMessageList
session={session(messages)}
isWorking={working}
expandSignal={false}
onLinkClick={onLinkClick}
settledTurns={working ? undefined : new Map([['u', { startedAt: 1, workedSeconds: 2 }]])}
/>
</TooltipProvider>
)
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(<NativeChatTurnDiffRollup diff={diff} />)
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()
})
@@ -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 (
<div className="pl-2">
{file.edits.map((edit) => {
@@ -23,6 +29,7 @@ function TurnDiffFilePatches({ file }: { file: NativeChatTurnDiffFile }): React.
<NativeChatDiffCard
key={`${edit.target.messageId}:${edit.target.editKey}:${edit.fileIndex}`}
file={patch}
onLinkClick={onLinkClick}
initialOpen
disclosureKey={`turn-diff-card:${edit.target.messageId}:${edit.target.editKey}:${edit.fileIndex}`}
/>
@@ -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 (
<div>
<div className="flex min-w-0 items-center">
<Button
variant="ghost"
size="xs"
className="min-w-0 flex-1 justify-start"
onClick={() => onReveal(file.target)}
>
<span id={pathId} className="min-w-0 truncate font-mono" title={file.path}>
{file.path}
</span>
<DiffLineCounts added={file.added} removed={file.removed} />
</Button>
<Button
variant="ghost"
size="icon-xs"
aria-label={
open
? translate('components.native-chat.turnDiff.hideDiff', 'Hide diff')
: translate('components.native-chat.turnDiff.showDiff', 'Show diff')
}
aria-describedby={pathId}
title={file.path}
aria-expanded={open}
onClick={() => setOpen(!open)}
>
<ChevronRight aria-hidden className={open ? 'size-3.5 rotate-90' : 'size-3.5'} />
</Button>
</div>
<Button
variant="ghost"
size="xs"
className="w-full min-w-0 justify-start"
aria-expanded={open}
aria-label={`${file.path} +${file.added} -${file.removed}`}
title={file.path}
onClick={() => setOpen(!open)}
>
<ChevronRight aria-hidden className={open ? 'size-3.5 rotate-90' : 'size-3.5'} />
<span className="max-w-2/3 shrink-0 truncate font-mono text-chat-foreground">{name}</span>
<span aria-hidden className="min-w-0 flex-1 truncate text-right font-mono">
{file.path.slice(0, -name.length)}
</span>
<DiffLineCounts added={file.added} removed={file.removed} />
</Button>
<NativeChatExpandable open={open}>
<TurnDiffFilePatches file={file} />
<TurnDiffFilePatches file={file} onLinkClick={onLinkClick} />
</NativeChatExpandable>
</div>
)
@@ -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 (
<Collapsible className="text-xs text-muted-foreground">
<CollapsibleTrigger asChild>
<Button variant="ghost" size="xs" className="group w-full min-w-0 justify-start gap-1.5">
<span className="min-w-0 truncate">
{translate('components.native-chat.turnDiff.changedFiles', '{{count}} changed files', {
count: diff.files.length,
defaultValue_one: '{{count}} changed file'
})}
</span>
<DiffLineCounts added={diff.added} removed={diff.removed} />
{diff.truncated ? (
<span>{translate('components.native-chat.turnDiff.partial', 'Partial diff')}</span>
) : null}
<ChevronRight
aria-hidden
className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-90 motion-reduce:transition-none"
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-1 space-y-1 pl-4">
{diff.files.map((file) => (
<TurnDiffFileRow key={file.path} file={file} onReveal={onReveal} />
<div className="rounded-lg border border-chat-code-border p-2 text-xs text-chat-foreground-faint">
<div className="flex min-w-0 items-center gap-1.5 px-1 pb-1">
<span className="min-w-0 truncate">
{translate('components.native-chat.turnDiff.changedFiles', '{{count}} changed files', {
count: diff.files.length,
defaultValue_one: '{{count}} changed file'
})}
</span>
<DiffLineCounts added={diff.added} removed={diff.removed} />
{diff.truncated ? (
<span>{translate('components.native-chat.turnDiff.partial', 'Partial diff')}</span>
) : null}
</div>
<div className="space-y-1">
{files.map((file) => (
<TurnDiffFileRow key={file.path} file={file} onLinkClick={onLinkClick} />
))}
</CollapsibleContent>
</Collapsible>
{remaining > 0 ? (
<Button variant="ghost" size="xs" onClick={() => setShowAll(true)}>
{translate('components.native-chat.turnDiff.showMoreFiles', 'Show {{count}} more', {
count: remaining
})}
</Button>
) : null}
</div>
</div>
)
}
@@ -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,
@@ -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}`) {
@@ -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
@@ -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
@@ -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,
@@ -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
@@ -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,
@@ -58,6 +58,7 @@ function workRunSlot(
hasReceipt: false,
hasStatus: head.status !== undefined,
hasTurnDiff: turnDiff !== undefined,
turnDiffFileCount: turnDiff?.files.length,
inSubagentSection: head.depth > 0
},
typography
@@ -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)
})
@@ -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'
@@ -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<string, NativeChatTurnDiff> = new Map()
@@ -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. */
-2
View File
@@ -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"
},
+2 -3
View File
@@ -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",
-2
View File
@@ -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"
-2
View File
@@ -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"
-2
View File
@@ -18950,8 +18950,6 @@
},
"turnDiff": {
"partial": "部分的な差分",
"showDiff": "差分を表示",
"hideDiff": "差分を非表示",
"changedFiles_other": "変更されたファイル {{count}} 個"
},
"receipt": {
-2
View File
@@ -18950,8 +18950,6 @@
},
"turnDiff": {
"partial": "부분적인 차이점",
"showDiff": "차이 보기",
"hideDiff": "차이 숨기기",
"changedFiles_other": "변경된 파일 {{count}}개"
},
"receipt": {
-2
View File
@@ -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"
-2
View File
@@ -19911,8 +19911,6 @@
},
"turnDiff": {
"partial": "Показана часть изменений",
"showDiff": "Показать изменения",
"hideDiff": "Скрыть изменения",
"changedFiles_one": "Изменён {{count}} файл",
"changedFiles_few": "Изменено {{count}} файла",
"changedFiles_many": "Изменено {{count}} файлов",
-2
View File
@@ -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"
},
@@ -19078,8 +19078,6 @@
},
"turnDiff": {
"partial": "部分差異",
"showDiff": "顯示差異",
"hideDiff": "隱藏差異",
"changedFiles_other": "{{count}} 個已變更的檔案"
},
"receipt": {
-2
View File
@@ -18895,8 +18895,6 @@
},
"turnDiff": {
"partial": "部分差异",
"showDiff": "显示差异",
"hideDiff": "隐藏差异",
"changedFiles_other": "{{count}} 个已更改的文件"
},
"receipt": {
@@ -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) {
@@ -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)
})
})
@@ -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
)
})
)
}))
)
}