Summarize turn file changes and preserve resolved prompt receipts (#19229)

* feat(chat): summarize turn changes and retain resolution receipts

* fix(chat): defer turn diff details and localize resolution times

* fix: complete approval projection test fixture

* Align turn diff disclosure chevron and indent details

---------

Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-09-08 17:50:51 -07:00
committed by GitHub
co-authored by Merge Sim
parent d196942220
commit e80fae0c4d
21 changed files with 1299 additions and 214 deletions
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { ChevronRight, FilePlus2, FileMinus2, FilePen } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@@ -111,12 +111,23 @@ function DiffRow({ line, gutterWidth }: { line: NativeChatEditLine; gutterWidth:
* nothing else — keeps the header rows and offers no empty disclosure. */
export function NativeChatDiffCard({
file,
revealSignal,
onReveal,
initiallyExpanded = false
}: {
file: NativeChatEditFile
revealSignal?: number
onReveal?: (element: HTMLElement) => void
initiallyExpanded?: boolean
}): React.JSX.Element {
const [expanded, setExpanded] = useState(initiallyExpanded)
const cardRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
if (revealSignal && cardRef.current) {
setExpanded(true)
onReveal?.(cardRef.current)
}
}, [revealSignal, onReveal])
// Joining every row to seed the copy button is the card's most expensive
// work, and a collapsed card renders none of those rows.
const copyText = useMemo(() => patchText(file.lines), [file.lines])
@@ -127,7 +138,7 @@ export function NativeChatDiffCard({
const gutterWidth = file.lineNumbersKnown ? Math.max(3, String(widest).length + 1) : 0
return (
<div className="my-1 overflow-hidden rounded-md border border-border">
<div ref={cardRef} className="my-1 overflow-hidden rounded-md border border-border">
<button
type="button"
onClick={() => hasBody && setExpanded((value) => !value)}
@@ -2,10 +2,13 @@
import '@testing-library/jest-dom/vitest'
import { cleanup, render } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as NativeChatProseModule from './native-chat-prose'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import type * as UnifiedPatchModule from '../../../../shared/native-chat-unified-patch'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
// Counting real per-row work rather than a render counter: a future refactor could keep the
@@ -22,6 +25,22 @@ vi.mock('./native-chat-prose', async (importOriginal) => {
}
})
const patchCalls = vi.hoisted(() => ({ detailed: 0, summary: 0 }))
vi.mock('../../../../shared/native-chat-unified-patch', async (importOriginal) => {
const actual = await importOriginal<typeof UnifiedPatchModule>()
return {
...actual,
editLinesFromUnifiedPatch: (...args: Parameters<typeof actual.editLinesFromUnifiedPatch>) => {
patchCalls.detailed += 1
return actual.editLinesFromUnifiedPatch(...args)
},
summarizeUnifiedPatch: (...args: Parameters<typeof actual.summarizeUnifiedPatch>) => {
patchCalls.summary += 1
return actual.summarizeUnifiedPatch(...args)
}
}
})
const { NativeChatMessageList } = await import('./NativeChatMessageList')
afterEach(cleanup)
@@ -88,4 +107,68 @@ describe('native chat transcript re-render cost during a streaming turn', () =>
// rows keep their block identity, so only the streaming tail should rebuild.
expect(perFrame).toBeLessThan(TRANSCRIPT_LENGTH / 10)
})
it('keeps structured diff rows lazy and reuses counts across journal updates', () => {
patchCalls.detailed = 0
patchCalls.summary = 0
const user: AgentJournalRenderItem = {
itemId: 'user',
revision: 1,
sequence: 1,
observedAt: 1000,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Edit a file' }] }
}
const diff: AgentJournalRenderItem = {
itemId: 'diff',
revision: 1,
sequence: 2,
observedAt: 2000,
body: {
kind: 'diff',
path: 'src/a.ts',
patch: {
head: '@@ -1 +1 @@\n-old\n+new',
truncated: false,
digest: 'fixture',
byteLength: 25
}
}
}
const view = (items: AgentJournalRenderItem[]) => (
<NativeChatMessageList
session={sessionWith(projectStructuredAgentSessionMessages(items, [], []))}
journalItems={items}
isWorking={false}
expandSignal={false}
fontScale={1}
/>
)
const { rerender } = render(view([user, diff]))
expect(patchCalls).toEqual({ summary: 1, detailed: 0 })
for (let frame = 0; frame < 20; frame += 1) {
rerender(
view([
user,
diff,
{
itemId: 'tail',
revision: frame + 1,
sequence: 3,
observedAt: 3000,
body: {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: `Token ${frame}` }]
}
}
])
)
}
expect(patchCalls).toEqual({ summary: 1, detailed: 0 })
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
expect(patchCalls.detailed).toBe(0)
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
expect(patchCalls).toEqual({ summary: 1, detailed: 1 })
expect(screen.getByText('new')).toBeInTheDocument()
})
})
@@ -14,6 +14,16 @@ import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
import type { NativeChatTurnActivity } from './native-chat-turn-activity'
import { NativeChatTurnActivityLine } from './NativeChatTurnActivityLine'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import {
nativeChatTurnDiffs,
type NativeChatDiffReveal,
type NativeChatDiffTarget,
type NativeChatTurnDiff
} from './native-chat-turn-diffs'
import { NativeChatTurnDiffRollup } from './NativeChatTurnDiffRollup'
import { NativeChatResolutionReceipt } from './NativeChatResolutionReceipt'
export { ProviderFrameRow } from './NativeChatTranscriptChrome'
function geometryOf(el: HTMLElement): ScrollGeometry {
@@ -24,6 +34,7 @@ const MAX_EXPANDED_TURNS = 128
export function NativeChatMessageList({
session,
journalItems,
isWorking,
expandSignal,
fontScale,
@@ -36,6 +47,7 @@ export function NativeChatMessageList({
runtimeContext
}: {
session: NativeChatLiveSession
journalItems?: readonly AgentJournalRenderItem[]
isWorking: boolean
/** Toolbar-driven desired open state for every tool run; each flip re-syncs. */
expandSignal: boolean
@@ -50,6 +62,22 @@ export function NativeChatMessageList({
turnActivity?: NativeChatTurnActivity | null
runtimeContext?: RuntimeFileOperationArgs | null
}): React.JSX.Element {
const [revealedDiff, setRevealedDiff] = useState<NativeChatDiffReveal | null>(null)
const revealDiff = useCallback((target: NativeChatDiffTarget) => {
setRevealedDiff((current) => ({ ...target, requestId: (current?.requestId ?? 0) + 1 }))
}, [])
const receipts = useMemo(
() =>
new Map(
journalItems?.flatMap((item) =>
(item.body.kind === 'approval' || item.body.kind === 'question') &&
item.body.resolution.state !== 'pending'
? [[item.itemId, item.body] as const]
: []
)
),
[journalItems]
)
const scrollRef = useRef<HTMLDivElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(null)
const [stuckToBottom, setStuckToBottom] = useState(true)
@@ -104,6 +132,13 @@ export function NativeChatMessageList({
return currentTurnKey
})
}, [messages])
const turnDiffs = useMemo(
() =>
journalItems
? nativeChatTurnDiffs(messages, turnKeys)
: new Map<string, NativeChatTurnDiff>(),
[journalItems, messages, turnKeys]
)
const turnStatuses = useNativeChatTurnStatus({
messages,
latestUserIndex,
@@ -230,27 +265,35 @@ export function NativeChatMessageList({
: message.role === 'user' && turnKey
? turnStatuses.completedByTurn[turnKey]
: undefined
const receipt = receipts.get(message.id)
const turnDiff =
turnKey && turnKeys[index + 1] !== turnKey ? turnDiffs.get(turnKey) : undefined
return (
<Fragment key={message.id}>
<MessageRow
message={message}
expandSignal={expandSignal}
// A missing transcript lifecycle is not evidence that the turn
// ended. Structured sessions and legacy live hooks still expose
// the authoritative session-level working state.
activeTurnIsWorking={
showTurnStatus &&
isCurrentTurn &&
(isWorking || session.transcriptLifecycle?.state === 'working')
}
onScrollMessageToTop={scrollMessageToTop}
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
deliveryFailed={failedDeliveryMessageIds?.has(message.id) === true}
structuredActivityUi={showTurnStatus}
activityExpandOverride={turnKey ? expandedTurnIds.has(turnKey) : undefined}
runtimeContext={runtimeContext}
/>
{receipt ? (
<NativeChatResolutionReceipt body={receipt} />
) : (
<MessageRow
message={message}
revealedDiff={revealedDiff?.messageId === message.id ? revealedDiff : undefined}
expandSignal={expandSignal}
// A missing transcript lifecycle is not evidence that the turn
// ended. Structured sessions and legacy live hooks still expose
// the authoritative session-level working state.
activeTurnIsWorking={
showTurnStatus &&
isCurrentTurn &&
(isWorking || session.transcriptLifecycle?.state === 'working')
}
onScrollMessageToTop={scrollMessageToTop}
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
deliveryFailed={failedDeliveryMessageIds?.has(message.id) === true}
structuredActivityUi={showTurnStatus}
activityExpandOverride={turnKey ? expandedTurnIds.has(turnKey) : undefined}
runtimeContext={runtimeContext}
/>
)}
{showTurnStatus &&
status &&
(index !== latestUserIndex || showTypingIndicator || !isWorking) ? (
@@ -266,6 +309,9 @@ export function NativeChatMessageList({
}
/>
) : null}
{turnDiff ? (
<NativeChatTurnDiffRollup diff={turnDiff} onReveal={revealDiff} />
) : null}
</Fragment>
)
})}
@@ -0,0 +1,145 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalRenderItem
} from '../../../../shared/agent-session-journal-types'
import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection'
import { NativeChatMessageList } from './NativeChatMessageList'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
const scrollTo = vi.fn()
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
function item(
itemId: string,
body: AgentJournalItemBody,
sequence: number
): AgentJournalRenderItem {
return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1 }
}
const user = item(
'user',
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Make the change' }] },
1
)
const prose = item(
'prose',
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Updating the files.' }] },
2
)
function diff(patch = '@@ -1 +1 @@\n-before\n+after'): AgentJournalRenderItem {
return item(
'diff',
{
kind: 'diff',
path: 'src/a.ts',
patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length }
},
3
)
}
function session(items: AgentJournalRenderItem[]): NativeChatLiveSession {
return {
messages: projectStructuredItemsToNativeChat(items),
status: 'ready',
sessionId: 'session',
agent: 'codex',
hasMore: false,
loadingEarlier: false,
loadEarlier: vi.fn(),
readPhase: 'ready'
}
}
function view(items: AgentJournalRenderItem[], structured = true) {
return (
<NativeChatMessageList
session={session(items)}
journalItems={structured ? items : undefined}
isWorking={false}
expandSignal={false}
fontScale={1}
/>
)
}
describe('turn history presentation', () => {
it('reveals and scrolls to a folded diff card from a collapsed completed turn', () => {
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo)
render(view([user, prose, diff()]))
expect(screen.queryByText('Edited file')).toBeNull()
const header = screen.getByRole('button', { name: /1 changed file/ })
expect(header).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(header)
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
expect(screen.getByText('Edited file')).toBeInTheDocument()
expect(screen.getByText('after')).toBeInTheDocument()
expect(screen.getByText('before')).toBeInTheDocument()
expect(scrollTo).toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: /1× Diff/ }))
expect(screen.queryByText('Edited file')).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)
})
it('updates journal revisions and replaces flat approval text with a passive receipt', () => {
const approval = item(
'approval',
{
kind: 'approval',
title: 'Run tests?',
detail: 'pnpm test',
options: [{ id: 'allow', label: 'Allow once' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
},
4
)
const initial = [user, prose, diff(), approval]
const { rerender } = render(view(initial))
expect(screen.queryByText('Run tests?')).toBeNull()
if (approval.body.kind !== 'approval') {
throw new Error('fixture')
}
const resolved = {
...approval,
revision: 2,
body: {
...approval.body,
resolution: {
state: 'resolved' as const,
selectedOptionId: 'allow',
resolvedBy: 'desktop',
resolvedAt: 5000
}
}
}
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.getByText('Run tests?')).toBeInTheDocument()
expect(screen.getByText('Allow once')).toBeInTheDocument()
expect(screen.getByText('Answered on desktop')).toBeInTheDocument()
expect(screen.getByText('Resolved').closest('[data-native-chat-receipt]')).not.toBeNull()
expect(screen.queryByText('resolved')).toBeNull()
})
it('keeps rollups turn-local and leaves legacy message lists unchanged', () => {
const secondUser = item(
'user-two',
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Again' }] },
5
)
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)
rerender(view(items, false))
expect(screen.queryByRole('button', { name: /changed file/ })).toBeNull()
})
})
@@ -19,6 +19,7 @@ import {
NativeChatImageAttachments,
ProviderFrameRow
} from './NativeChatTranscriptChrome'
import type { NativeChatDiffReveal } from './native-chat-turn-diffs'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
/** One message: its prose first, then a collapsible run folding all of the
@@ -28,6 +29,7 @@ import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
* keep their block identity, so only the changed row re-renders. */
export const MessageRow = memo(function MessageRow({
message,
revealedDiff,
expandSignal,
activeTurnIsWorking,
onScrollMessageToTop,
@@ -39,6 +41,7 @@ export const MessageRow = memo(function MessageRow({
runtimeContext
}: {
message: NativeChatMessage
revealedDiff?: NativeChatDiffReveal
expandSignal: boolean
activeTurnIsWorking?: boolean
/** Align this message's top to the top of the scroll viewport. */
@@ -199,6 +202,8 @@ export const MessageRow = memo(function MessageRow({
{tools.length > 0 || subagentGroups.length > 0 ? (
<NativeChatToolRun
blocks={tools}
revealedDiff={revealedDiff}
onRevealDiff={onScrollMessageToTop}
onLinkClick={onLinkClick}
subagentGroups={subagentGroups}
expandSignal={expandSignal}
@@ -0,0 +1,160 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { i18n } from '@/i18n/i18n'
import type { AgentJournalQuestionItem } from '../../../../shared/agent-session-journal-types'
import { encodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer'
import { NativeChatResolutionReceipt } from './NativeChatResolutionReceipt'
import {
nativeChatReceiptAnswers,
type NativeChatResolvedPrompt
} from './native-chat-resolution-receipt'
afterEach(async () => {
cleanup()
await i18n.changeLanguage('en')
})
const approval: NativeChatResolvedPrompt = {
kind: 'approval',
title: 'Run command?',
detail: 'pnpm test',
options: [
{ id: 'yes', label: 'Allow once' },
{ id: 'no', label: 'Deny' }
],
resolution: {
state: 'resolved',
selectedOptionId: 'yes',
resolvedBy: 'phone-client',
resolvedAt: 1000
}
}
describe('resolution receipts', () => {
it('localizes the resolved time when the UI language changes', async () => {
render(<NativeChatResolutionReceipt body={approval} />)
await act(async () => {
await i18n.changeLanguage('fr')
})
expect(screen.getByRole('time')).toHaveTextContent(
new Intl.DateTimeFormat('fr', { hour: 'numeric', minute: '2-digit' }).format(1000)
)
expect(screen.getByRole('time')).toHaveAccessibleName(
new Intl.DateTimeFormat('fr', { dateStyle: 'full', timeStyle: 'long' }).format(1000)
)
})
it.each([
['yes', 'Allow once'],
['no', 'Deny']
])('shows the exact selected approval label for %s', (id, label) => {
render(
<NativeChatResolutionReceipt
body={{ ...approval, resolution: { ...approval.resolution, selectedOptionId: id } }}
/>
)
expect(screen.getByText('Run command?')).toBeInTheDocument()
expect(screen.getByText('pnpm test')).toBeInTheDocument()
expect(screen.getByText(label)).toBeInTheDocument()
expect(screen.getByText('Answered on phone-client')).toBeInTheDocument()
expect(document.querySelector('time')).toHaveAttribute('datetime', new Date(1000).toISOString())
expect(screen.queryByRole('button')).toBeNull()
})
it('renders cancellation quietly without inventing a choice or resolver', () => {
render(
<NativeChatResolutionReceipt
body={{
...approval,
resolution: {
state: 'cancelled',
selectedOptionId: null,
resolvedBy: null,
resolvedAt: null
}
}}
/>
)
expect(screen.getByText('Cancelled')).toBeInTheDocument()
expect(screen.queryByText('Allow once')).toBeNull()
expect(screen.queryByText('Selected answer unavailable')).toBeNull()
expect(document.querySelector('time')).toBeNull()
})
it.each([null, 'unknown'])('handles absent or unknown selections (%s)', (selectedOptionId) => {
render(
<NativeChatResolutionReceipt
body={{ ...approval, resolution: { ...approval.resolution, selectedOptionId } }}
/>
)
expect(screen.getByText('Selected answer unavailable')).toBeInTheDocument()
expect(screen.queryByText('Allow once')).toBeNull()
})
it('excludes pending prompts', () => {
const { container } = render(
<NativeChatResolutionReceipt
body={{ ...approval, resolution: { ...approval.resolution, state: 'pending' } }}
/>
)
expect(container).toBeEmptyDOMElement()
})
it('reads grouped options from each question despite an empty flat options list', () => {
const body: AgentJournalQuestionItem = {
kind: 'question',
question: 'Choose settings',
options: [],
questions: [
{
id: 'q1',
question: 'Features?',
multiSelect: true,
options: [
{ id: 'a', label: 'First' },
{ id: 'b', label: 'Second' }
]
},
{ id: 'q2', question: 'Name?', multiSelect: false, options: [], freeTextQuestionId: 'q2' }
],
resolution: {
...approval.resolution,
selectedOptionId: encodeAgentSessionQuestionAnswers([
{ questionId: 'q1', optionIds: ['a', 'b'] },
{ questionId: 'q2', optionIds: [], other: 'Custom 100% name' }
])
}
}
render(<NativeChatResolutionReceipt body={body} />)
expect(screen.getByText('Features?')).toBeInTheDocument()
expect(screen.getByText('First · Second')).toBeInTheDocument()
expect(screen.getByText('Custom 100% name')).toBeInTheDocument()
expect(screen.queryByText('Selected answer unavailable')).toBeNull()
expect(
nativeChatReceiptAnswers({
...body,
resolution: { ...body.resolution, selectedOptionId: 'question-group:invalid' }
})
).toEqual([
{ question: 'Features?', answer: null },
{ question: 'Name?', answer: null }
])
})
it('decodes single free-text answers only for the declared question', () => {
const body: AgentJournalQuestionItem = {
kind: 'question',
question: 'Name?',
options: [],
freeTextQuestionId: 'name/id',
resolution: { ...approval.resolution, selectedOptionId: 'name%2Fid:hello%20world' }
}
expect(nativeChatReceiptAnswers(body)).toEqual([{ question: null, answer: 'hello world' }])
for (const selectedOptionId of ['other:hello', 'name%2Fid:%invalid']) {
expect(
nativeChatReceiptAnswers({ ...body, resolution: { ...body.resolution, selectedOptionId } })
).toEqual([{ question: null, answer: null }])
}
})
})
@@ -0,0 +1,61 @@
import { translate } from '@/i18n/i18n'
import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp'
import {
nativeChatReceiptAnswers,
type NativeChatResolvedPrompt
} from './native-chat-resolution-receipt'
export function NativeChatResolutionReceipt({
body
}: {
body: NativeChatResolvedPrompt
}): React.JSX.Element | null {
if (body.resolution.state === 'pending') {
return null
}
const { resolution } = body
const title = body.kind === 'approval' ? body.title : body.question
const answers = nativeChatReceiptAnswers(body)
return (
<div
className="space-y-1 border-l border-border pl-3 text-xs text-muted-foreground"
data-native-chat-receipt={body.kind}
>
<div className="font-medium">{title}</div>
{body.kind === 'approval' && body.detail ? (
<p className="line-clamp-3 whitespace-pre-wrap break-words">{body.detail}</p>
) : null}
{answers.map((answer, index) => (
<div key={body.kind === 'question' ? (body.questions?.[index]?.id ?? 'answer') : 'answer'}>
{answer.question ? <p>{answer.question}</p> : null}
<p className="line-clamp-3 whitespace-pre-wrap break-words">
{answer.answer ??
translate(
'components.native-chat.receipt.unavailable',
'Selected answer unavailable'
)}
</p>
</div>
))}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>
{resolution.state === 'cancelled'
? translate('components.native-chat.receipt.cancelled', 'Cancelled')
: translate('components.native-chat.receipt.resolved', 'Resolved')}
</span>
{resolution.resolvedBy ? (
<span>
{resolution.state === 'cancelled'
? translate('components.native-chat.receipt.cancelledBy', 'Cancelled on {{device}}', {
device: resolution.resolvedBy
})
: translate('components.native-chat.receipt.resolver', 'Answered on {{device}}', {
device: resolution.resolvedBy
})}
</span>
) : null}
<NativeChatMessageTimestamp timestamp={resolution.resolvedAt} />
</div>
</div>
)
}
@@ -192,6 +192,7 @@ export function NativeChatStructuredSession(
) : (
<NativeChatMessageList
session={session}
journalItems={controller.journalItems}
isWorking={controller.isWorking}
expandSignal={false}
fontScale={fontScale.scale}
@@ -4,7 +4,7 @@ import {
NativeChatCommandMetadata,
NativeChatSearchResults
} from './NativeChatToolAnnotations'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { Fragment, useMemo, useState } from 'react'
import { Check, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@@ -17,12 +17,8 @@ import {
import { isRenderableSubagentGroup } from '../../../../shared/native-chat-subagent-summary'
import { diffFromText, diffFromToolCall, type DiffLine } from './native-chat-diff'
import { NativeChatDiffCard } from './NativeChatDiffCard'
import { pairToolBlocks } from './native-chat-tool-fold'
import {
editFilesFromToolPair,
isEditToolName
} from '../../../../shared/native-chat-edit-normalize'
import type { NativeChatEditFile } from '../../../../shared/native-chat-edit-model'
import type { NativeChatDiffReveal } from './native-chat-turn-diffs'
import { buildEditCards, NO_EDIT_CARDS } from './native-chat-edit-cards'
import {
countToolCalls,
createToolInputDisplay,
@@ -157,55 +153,13 @@ function ToolLine({
)
}
type EditCardModel = {
editCards: Map<NativeChatBlock, { files: NativeChatEditFile[]; key: string }>
/** Result blocks the card already speaks for, so they render no second row. */
consumedResults: Set<NativeChatBlock>
}
const NO_EDIT_CARDS: EditCardModel = { editCards: new Map(), consumedResults: new Set() }
/** An edit renders as one card, so its result block is folded into the call. The
* model decides which calls have landed; a call that has not keeps the generic
* tool view, its result still visible as the provider's own error. */
function buildEditCards(blocks: NativeChatBlock[]): EditCardModel {
const editCards: EditCardModel['editCards'] = new Map()
const consumedResults: EditCardModel['consumedResults'] = new Set()
for (const [index, pair] of pairToolBlocks(blocks).entries()) {
const call = pair.call
if (!call || !isEditToolName(call.name)) {
continue
}
const files = editFilesFromToolPair({
name: call.name,
input: call.input,
...(call.state ? { state: call.state } : {}),
...(pair.result
? {
result: {
output: pair.result.output,
isError: pair.result.isError,
editPatch: pair.result.editPatch
}
}
: {})
})
if (!files || files.length === 0) {
continue
}
editCards.set(call, { files, key: `${call.name}:${index}` })
if (pair.result) {
consumedResults.add(pair.result)
}
}
return { editCards, consumedResults }
}
/** A run of a message's tool calls/results, collapsed to a one-line summary that
* expands to the individual inline tool lines. `expandSignal` lets the global
* toolbar toggle drive every run at once while still allowing per-run override. */
export function NativeChatToolRun({
blocks,
revealedDiff,
onRevealDiff,
subagentGroups = NO_SUBAGENT_GROUPS,
expandSignal,
activeTurnIsWorking,
@@ -214,6 +168,8 @@ export function NativeChatToolRun({
onLinkClick
}: {
blocks: NativeChatBlock[]
revealedDiff?: NativeChatDiffReveal
onRevealDiff?: (element: HTMLElement) => void
/** Spawn-group rosters that belong with this run's activity, one row each. */
subagentGroups?: NativeChatSubagentGroupBlock[]
/** Toolbar-driven desired open state. Each change re-syncs this run's state. */
@@ -225,9 +181,23 @@ export function NativeChatToolRun({
structuredActivityUi?: boolean
onLinkClick?: CommentMarkdownLinkClickHandler
}): React.JSX.Element | null {
const [open, setOpen] = useState(expandOverride ?? expandSignal)
// Re-sync when the global toolbar toggle flips.
useEffect(() => setOpen(expandOverride ?? expandSignal), [expandOverride, expandSignal])
const [open, setOpen] = useState(revealedDiff ? true : (expandOverride ?? expandSignal))
const [controls, setControls] = useState({ expandOverride, expandSignal, revealedDiff })
if (
controls.expandOverride !== expandOverride ||
controls.expandSignal !== expandSignal ||
controls.revealedDiff !== revealedDiff
) {
setControls({ expandOverride, expandSignal, revealedDiff })
if (revealedDiff && controls.revealedDiff !== revealedDiff) {
setOpen(true)
} else if (
controls.expandOverride !== expandOverride ||
controls.expandSignal !== expandSignal
) {
setOpen(expandOverride ?? expandSignal)
}
}
// Childless groups are dropped so `subagentRows.length` stays an honest test of
// "something will draw": the roster-only branch below returns a margin-bearing
@@ -261,8 +231,7 @@ export function NativeChatToolRun({
// The turn caret opens the activity group, while each child tool remains
// collapsed. The global expand toolbar still opens child details together.
const expandToolLines = expandOverride === undefined ? open : false
// Diffing every edit is the run's most expensive work, so a collapsed run —
// which renders none of it — never pays for it.
// Rollups cache counts only; detailed diff rows are built when the run opens.
const { editCards, consumedResults } = useMemo(
() => (open ? buildEditCards(blocks) : NO_EDIT_CARDS),
[open, blocks]
@@ -300,6 +269,7 @@ export function NativeChatToolRun({
if (
structuredActivityUi &&
expandOverride === false &&
!(revealedDiff && open) &&
isSettled &&
activeTurnIsWorking === false
) {
@@ -420,6 +390,12 @@ export function NativeChatToolRun({
<NativeChatDiffCard
key={`${edit.key}:${fileIndex}`}
file={file}
revealSignal={
revealedDiff?.editKey === edit.key && revealedDiff.fileIndex === fileIndex
? revealedDiff.requestId
: undefined
}
onReveal={onRevealDiff}
initiallyExpanded={expandToolLines}
/>
))}
@@ -0,0 +1,60 @@
import { ChevronRight } from 'lucide-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 type { NativeChatDiffTarget, NativeChatTurnDiff } from './native-chat-turn-diffs'
export function NativeChatTurnDiffRollup({
diff,
onReveal
}: {
diff: NativeChatTurnDiff
onReveal: (target: NativeChatDiffTarget) => void
}): React.JSX.Element {
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">
{diff.files.length === 1
? translate('components.native-chat.turnDiff.one', '1 changed file')
: translate('components.native-chat.turnDiff.many', '{{count}} changed files', {
count: diff.files.length
})}
</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="ml-auto 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">
<p className="px-2">
{translate(
'components.native-chat.turnDiff.recorded',
'Totals from recorded edits in this turn.'
)}
</p>
{diff.files.map((file) => (
<Button
key={file.path}
variant="ghost"
size="xs"
className="flex w-full justify-start gap-2"
onClick={() => onReveal(file.target)}
>
<span className="min-w-0 truncate font-mono" title={file.path}>
{file.path}
</span>
<DiffLineCounts added={file.added} removed={file.removed} />
</Button>
))}
</CollapsibleContent>
</Collapsible>
)
}
@@ -0,0 +1,132 @@
import type { NativeChatBlock } from '../../../../shared/native-chat-types'
import {
editFilesFromToolPair,
isEditToolName
} from '../../../../shared/native-chat-edit-normalize'
import type { NativeChatEditFile } from '../../../../shared/native-chat-edit-model'
import {
editFilesFromPatchText,
type NativeChatEditFileSummary
} from '../../../../shared/native-chat-edit-patch-files'
import { pairToolBlocks } from './native-chat-tool-fold'
const normalizedEdits = new WeakMap<
NativeChatBlock,
{
result: NativeChatBlock | undefined
files: NativeChatEditFile[] | null
}
>()
function normalizedEditFiles(
call: NativeChatBlock,
result: NativeChatBlock | undefined,
derive: () => NativeChatEditFile[] | null
): NativeChatEditFile[] | null {
const cached = normalizedEdits.get(call)
if (cached && cached.result === result) {
return cached.files
}
const files = derive()
normalizedEdits.set(call, { result, files })
return files
}
export type EditCardModel = {
editCards: Map<NativeChatBlock, { files: NativeChatEditFile[]; key: string }>
/** Result blocks the card already speaks for, so they render no second row. */
consumedResults: Set<NativeChatBlock>
}
export const NO_EDIT_CARDS: EditCardModel = { editCards: new Map(), consumedResults: new Set() }
/** An edit renders as one card, so its result block is folded into the call. The
* model decides which calls have landed; a call that has not keeps the generic
* tool view, its result still visible as the provider's own error. */
export function buildEditCards(blocks: NativeChatBlock[]): EditCardModel {
const editCards: EditCardModel['editCards'] = new Map()
const consumedResults: EditCardModel['consumedResults'] = new Set()
for (const [index, pair] of pairToolBlocks(blocks).entries()) {
const call = pair.call
if (!call || !isEditToolName(call.name)) {
continue
}
const files = normalizedEditFiles(call, pair.result, () =>
editFilesFromToolPair({
name: call.name,
input: call.input,
...(call.state ? { state: call.state } : {}),
...(pair.result
? {
result: {
output: pair.result.output,
isError: pair.result.isError,
editPatch: pair.result.editPatch
}
}
: {})
})
)
if (!files || files.length === 0) {
continue
}
editCards.set(call, { files, key: `${call.name}:${index}` })
if (pair.result) {
consumedResults.add(pair.result)
}
}
return { editCards, consumedResults }
}
const diffSummaries = new WeakMap<
NativeChatBlock,
{
result: NativeChatBlock | undefined
files: NativeChatEditFileSummary[] | null
}
>()
// Only the journal's path-only Diff envelope has counts that can be read without tool normalization.
export function buildDiffSummaries(blocks: NativeChatBlock[]): Map<
NativeChatBlock,
{
files: NativeChatEditFileSummary[]
key: string
}
> {
const summaries = new Map<NativeChatBlock, { files: NativeChatEditFileSummary[]; key: string }>()
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
) {
continue
}
const input = call.input
if (
!input ||
typeof input !== 'object' ||
!('path' in input) ||
typeof input.path !== 'string' ||
Object.keys(input).some((key) => key !== 'path')
) {
continue
}
const cached = diffSummaries.get(call)
let files = cached?.result === result ? cached.files : undefined
if (files === undefined) {
files = editFilesFromPatchText(result.output, input.path, true)
diffSummaries.set(call, { result, files })
}
if (files?.length) {
summaries.set(call, { files, key: `${call.name}:${index}` })
}
}
return summaries
}
@@ -0,0 +1,50 @@
import type {
AgentJournalApprovalItem,
AgentJournalQuestionItem
} from '../../../../shared/agent-session-journal-types'
import { decodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer'
export type NativeChatResolvedPrompt = AgentJournalApprovalItem | AgentJournalQuestionItem
export type NativeChatReceiptAnswer = { question: string | null; answer: string | null }
export function nativeChatReceiptAnswers(
body: NativeChatResolvedPrompt
): NativeChatReceiptAnswer[] {
if (body.resolution.state !== 'resolved') {
return []
}
const selected = body.resolution.selectedOptionId
if (body.kind === 'question' && body.questions) {
const answers = selected ? decodeAgentSessionQuestionAnswers(selected) : null
return body.questions.map((question) => {
const answer = answers?.find((entry) => entry.questionId === question.id)
const labels = answer?.optionIds.map(
(id) => question.options.find((option) => option.id === id)?.label
)
const valid = labels?.every((label) => label !== undefined)
return {
question: question.question,
answer: valid
? [...(labels ?? []), ...(answer?.other ? [answer.other] : [])].join(' · ') || null
: null
}
})
}
const option = body.options.find((option) => option.id === selected)
if (option) {
return [{ question: null, answer: option.label }]
}
if (body.kind === 'question' && body.freeTextQuestionId && selected) {
const prefix = `${encodeURIComponent(body.freeTextQuestionId)}:`
if (selected.startsWith(prefix)) {
try {
return [
{ question: null, answer: decodeURIComponent(selected.slice(prefix.length)) || null }
]
} catch {
// Malformed persisted answers remain readable as an unavailable selection.
}
}
}
return [{ question: null, answer: null }]
}
@@ -0,0 +1,150 @@
import { MAX_EDIT_LINES } from '../../../../shared/native-chat-edit-model'
import { describe, expect, it } from 'vitest'
import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types'
import { foldToolMessages } from './native-chat-tool-fold'
import { buildDiffSummaries, buildEditCards } from './native-chat-edit-cards'
import { nativeChatTurnDiffs } from './native-chat-turn-diffs'
function diff(id: string, path: string, patch = '@@ -1 +1 @@\n-old\n+new'): NativeChatMessage {
return {
id,
role: 'assistant',
source: 'transcript',
timestamp: 1,
blocks: [
{ type: 'tool-call', name: 'Diff', input: { path } },
{ type: 'tool-result', output: patch }
]
}
}
describe('turn diff rollups', () => {
it('counts unique files, sums recorded edits, and targets the last existing card', () => {
const messages = foldToolMessages([
diff('first', 'a.ts'),
diff('second', 'a.ts'),
diff('third', 'b.ts')
])
const turn = nativeChatTurnDiffs(messages, ['turn']).get('turn')!
expect(turn.files).toHaveLength(2)
expect(turn).toMatchObject({ added: 3, removed: 3, truncated: false })
expect(turn.files[0]).toMatchObject({
path: 'a.ts',
added: 2,
target: { messageId: 'first', editKey: 'Diff:1', fileIndex: 0 }
})
})
it('folds edits through chained renames into the destination and counts a deletion once', () => {
const messages = [
diff('edit', 'old.ts'),
diff(
'rename',
'old.ts',
'diff --git a/old.ts b/new.ts\nrename from old.ts\nrename to new.ts'
),
diff(
'rename-again',
'new.ts',
'diff --git a/new.ts b/final.ts\nrename from new.ts\nrename to final.ts'
),
diff(
'delete',
'gone.ts',
'diff --git a/gone.ts b/gone.ts\ndeleted file mode 100644\n--- a/gone.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-gone'
)
]
const turn = nativeChatTurnDiffs(
messages,
messages.map(() => 'turn')
).get('turn')!
expect(turn.files.map((file) => file.path)).toEqual(['final.ts', 'gone.ts'])
expect(turn).toMatchObject({ added: 1, removed: 2 })
expect(turn.files[0]?.target.messageId).toBe('rename-again')
})
it('keeps turns separate and excludes history without a known boundary', () => {
const result = nativeChatTurnDiffs(
[diff('orphan', 'orphan.ts'), diff('a', 'a.ts'), diff('b', 'a.ts')],
[undefined, 'one', 'two']
)
expect([...result.keys()]).toEqual(['one', 'two'])
expect(result.get('one')?.added).toBe(1)
expect(result.get('two')?.files).toHaveLength(1)
})
it('uses the existing multi-file parser and preserves truncated counts', () => {
const patch =
'diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-old\n+new\ndiff --git a/b.ts b/b.ts\n--- a/b.ts\n+++ b/b.ts\n@@ -1 +1 @@\n-old\n+new\n… (9999 bytes)'
const turn = nativeChatTurnDiffs([diff('multi', 'changes', patch)], ['turn']).get('turn')!
expect(turn.files.map((file) => file.path)).toEqual(['a.ts', 'b.ts'])
expect(turn).toMatchObject({ added: 2, removed: 2, truncated: true })
expect(turn.files[1]?.target.fileIndex).toBe(1)
})
it('does not count generic output, failed edits, running edits, or unparseable patches', () => {
const messages = ['shell', 'Edit'].map((name) => ({
...diff(name, 'x'),
blocks: [
{ type: 'tool-call', name, input: { path: 'x' } },
{ type: 'tool-result', output: '@@ -1 +1 @@\n-old\n+new' }
] as NativeChatBlock[]
}))
messages.push(diff('invalid', 'x', 'no patch'))
for (const state of ['running', 'failed'] as const) {
const message = diff(state, 'x')
message.blocks[0] = { type: 'tool-call', name: 'Diff', input: { path: 'x' }, state }
messages.push(message)
}
expect(
nativeChatTurnDiffs(
messages,
messages.map(() => 'turn')
).size
).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)
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)
message.blocks = [
message.blocks[0]!,
{ type: 'tool-result', output: '@@ -0,0 +1,2 @@\n+one\n+two' }
]
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
expect(updatedSummary).not.toBe(summary)
expect(updatedSummary[0]?.added).toBe(2)
})
it.each([
'@@ -1 +1 @@\n-old\n+new',
'@@\n--- content\n+++ content\n\\ No newline at end of file',
'@@ -1 +1 @@\n-old\n+new\n@@ -5 +5 @@\n-again\n+again',
'diff --git a/a.ts b/b.ts\nrename from a.ts\nrename to b.ts',
`@@ -0,0 +1,2500 @@\n${'+new\n'.repeat(MAX_EDIT_LINES + 1)}`,
`@@ -0,0 +1,2500 @@\n${'+new\n'.repeat(MAX_EDIT_LINES - 1)}@@ -1 +1 @@\n+last`,
'@@ -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 detailed = [...buildEditCards(message.blocks).editCards.values()][0]!.files
expect(summary).toEqual(
detailed.map(({ lines: _lines, lineNumbersKnown: _known, ...file }) => file)
)
})
})
@@ -0,0 +1,76 @@
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { buildDiffSummaries } from './native-chat-edit-cards'
export type NativeChatDiffTarget = {
messageId: string
editKey: string
fileIndex: number
}
export type NativeChatDiffReveal = NativeChatDiffTarget & { requestId: number }
export type NativeChatTurnDiffFile = {
path: string
added: number
removed: number
truncated: boolean
target: NativeChatDiffTarget
}
export type NativeChatTurnDiff = {
files: NativeChatTurnDiffFile[]
added: number
removed: number
truncated: boolean
}
/** Recorded edit totals, grouped by the transcript's already-resolved turn boundaries. */
export function nativeChatTurnDiffs(
messages: readonly NativeChatMessage[],
turnKeys: readonly (string | undefined)[]
): Map<string, NativeChatTurnDiff> {
const turns = new Map<string, Map<string, NativeChatTurnDiffFile>>()
for (const [index, message] of messages.entries()) {
const turnKey = turnKeys[index]
if (!turnKey) {
continue
}
for (const edit of buildDiffSummaries(message.blocks).values()) {
let files = turns.get(turnKey)
if (!files) {
files = new Map()
turns.set(turnKey, files)
}
for (const [fileIndex, file] of edit.files.entries()) {
const previous = files.get(file.path)
const renamed =
file.oldPath && file.oldPath !== file.path ? files.get(file.oldPath) : undefined
if (renamed) {
files.delete(renamed.path)
}
files.set(file.path, {
path: file.path,
added: file.added + (previous?.added ?? 0) + (renamed?.added ?? 0),
removed: file.removed + (previous?.removed ?? 0) + (renamed?.removed ?? 0),
truncated:
file.truncated || (previous?.truncated ?? false) || (renamed?.truncated ?? false),
target: { messageId: message.id, editKey: edit.key, fileIndex }
})
}
}
}
return new Map(
Array.from(turns, ([key, byPath]) => {
const files = Array.from(byPath.values())
return [
key,
{
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)
}
]
})
)
}
@@ -51,17 +51,8 @@ export function useStructuredAgentSession(args: {
const { agent, isVisible, sessionId, target } = args
// Declared first: the hold is what gives a restored session its provider child back, and the
// read below is useless for sending until it lands.
useStructuredAgentSessionHold({
sessionId,
target,
surface: 'desktop-chat',
enabled: isVisible
})
const { state, loadingOlder, loadOlder } = useStructuredAgentSessionRead({
sessionId,
target,
isVisible
})
useStructuredAgentSessionHold({ sessionId, target, surface: 'desktop-chat', enabled: isVisible })
const { state, loadingOlder, loadOlder } = useStructuredAgentSessionRead(args)
const stateRef = useRef(state)
const [writeError, setWriteError] = useState<string | null>(null)
const operationIds = useRef(new Map<string, string>())
@@ -267,6 +258,7 @@ export function useStructuredAgentSession(args: {
{ command }
)
}),
journalItems: state.items,
messages,
status: state.status,
error: state.error ?? writeError ?? outboxController.error,
+13
View File
@@ -16994,6 +16994,19 @@
"empty": "No users found"
},
"native-chat": {
"turnDiff": {
"one": "1 changed file",
"many": "{{count}} changed files",
"partial": "Partial diff",
"recorded": "Totals from recorded edits in this turn."
},
"receipt": {
"unavailable": "Selected answer unavailable",
"cancelled": "Cancelled",
"resolved": "Resolved",
"resolver": "Answered on {{device}}",
"cancelledBy": "Cancelled on {{device}}"
},
"notices": {
"compaction": "Context compacted",
"details": "Details",
+3 -74
View File
@@ -6,13 +6,8 @@ import {
type NativeChatEditFile,
type NativeChatEditLine
} from './native-chat-edit-model'
import { stripBoundedTextMarker } from './structured-agent-session-projection'
import {
editLinesFromUnifiedPatch,
editLinesFromWholeFile,
unifiedPatchSections,
type UnifiedPatchSection
} from './native-chat-unified-patch'
import { editFilesFromPatchText, splitMoveMarker } from './native-chat-edit-patch-files'
import { editLinesFromUnifiedPatch, editLinesFromWholeFile } from './native-chat-unified-patch'
import type { NativeChatEditPatch } from './native-chat-types'
// `NotebookEdit` is deliberately absent: its input carries only the new cell
@@ -25,9 +20,6 @@ const COMMAND_PATCH_TOOLS = new Set(['exec', 'shell', 'local_shell'])
/** Tools whose input may wrap a `*** Begin Patch` envelope. The dedicated patch
* tool applies whatever it is given; a command tool must say that it is. */
const PATCH_ENVELOPE_TOOLS = new Set(['apply_patch', ...COMMAND_PATCH_TOOLS])
/** A count standing in for a path, from a producer that joined several files'
* patches and kept no per-file path. */
const FILE_COUNT_PATH = /^\d+ files?$/
/** Tools whose whole payload is patch text. `Diff` reaches its patch only
* through the result, because the structured journal projects a diff item as a
* call carrying just the path. */
@@ -172,22 +164,6 @@ function claudeEditFiles(
]
}
/** A move is appended to the patch body as prose rather than a header field, on
* every lane that carries the body as text. Left in place it renders as a
* numbered line of the file it moved.
*
* Anchored to the start of the final line: unanchored, a row whose own content
* mentions a move was cut in half and the file it names claimed as a rename
* that never happened. */
const MOVE_MARKER = /(?:^|\n)Moved to: (.+)$/
function splitMoveMarker(patch: string): { body: string; movedTo: string | null } {
const match = MOVE_MARKER.exec(patch)
return match
? { body: patch.slice(0, match.index), movedTo: match[1]!.trim() }
: { body: patch, movedTo: null }
}
function codexChangeFiles(changes: unknown[]): NativeChatEditFile[] {
return changes.flatMap((entry) => {
const change = record(entry)
@@ -298,52 +274,5 @@ export function editFilesFromToolPair(pair: {
if (!patchText) {
return null
}
// The body carries its own marker when the journal clipped it. Read as
// content it becomes a numbered line of the file, and the rows that follow
// are reported complete.
const bounded = stripBoundedTextMarker(patchText)
const moved = splitMoveMarker(bounded.text)
// One card per file the patch touches: run together, the later files' rows
// and gutter numbers sit under the first file's name.
const split = unifiedPatchSections(moved.body)
const callerPath = text(input?.path) ?? text(input?.file_path)
if (callerPath !== null && FILE_COUNT_PATH.test(callerPath)) {
// The producer joined several files' patches and kept a count in place of a
// path, so nothing here can name a file. Naming the card after the count
// would assert a file that does not exist.
return null
}
// A patch that names one file is the file the call is reporting on, so the
// call's own path wins — it is the provider's, where the header's is relative
// to the patch. A patch naming several has no one path, and a rename's
// destination is only ever in the header. Sections that name nothing are
// preamble and must not change that count.
const namedSections = split.sections.filter((section) => section.path !== null).length
const named = (section: UnifiedPatchSection): string =>
(namedSections <= 1 && section.oldPath === null
? (callerPath ?? section.path)
: (section.path ?? callerPath)) ?? 'file'
const files = split.sections.flatMap((section) => {
const parsed = editLinesFromUnifiedPatch(section.body)
if (!parsed && section.path === null) {
return []
}
return [
finalizeEditFile({
path: named(section),
oldPath: section.oldPath,
changeKind: section.changeKind,
lines: parsed?.lines ?? [],
lineNumbersKnown: parsed?.lineNumbersKnown ?? false,
truncated: bounded.truncated || split.truncated || (parsed?.truncated ?? false)
})
]
})
// The move marker names where the whole patch moved, so it can only speak for
// a patch describing one file.
if (moved.movedTo !== null && files.length === 1 && files[0]) {
const only = files[0]
return [{ ...only, path: moved.movedTo, oldPath: only.path, changeKind: 'renamed' }]
}
return files.length > 0 ? files : null
return editFilesFromPatchText(patchText, text(input?.path) ?? text(input?.file_path))
}
+106
View File
@@ -0,0 +1,106 @@
import { finalizeEditFile, type NativeChatEditFile } from './native-chat-edit-model'
import { stripBoundedTextMarker } from './structured-agent-session-projection'
import {
editLinesFromUnifiedPatch,
summarizeUnifiedPatch,
unifiedPatchSections,
type UnifiedPatchSection
} from './native-chat-unified-patch'
const FILE_COUNT_PATH = /^\d+ files?$/
/** A move is appended to the patch body as prose rather than a header field, on
* every lane that carries the body as text. Left in place it renders as a
* numbered line of the file it moved.
*
* Anchored to the start of the final line: unanchored, a row whose own content
* mentions a move was cut in half and the file it names claimed as a rename
* that never happened. */
const MOVE_MARKER = /(?:^|\n)Moved to: (.+)$/
export function splitMoveMarker(patch: string): { body: string; movedTo: string | null } {
const match = MOVE_MARKER.exec(patch)
return match
? { body: patch.slice(0, match.index), movedTo: match[1]!.trim() }
: { body: patch, movedTo: null }
}
export type NativeChatEditFileSummary = Pick<
NativeChatEditFile,
'path' | 'oldPath' | 'changeKind' | 'added' | 'removed' | 'truncated'
>
export function editFilesFromPatchText(
patchText: string,
callerPath: string | null
): NativeChatEditFile[] | null
export function editFilesFromPatchText(
patchText: string,
callerPath: string | null,
summaryOnly: true
): NativeChatEditFileSummary[] | null
export function editFilesFromPatchText(
patchText: string,
callerPath: string | null,
summaryOnly = false
): NativeChatEditFileSummary[] | null {
// The body carries its own marker when the journal clipped it. Read as
// content it becomes a numbered line of the file, and the rows that follow
// are reported complete.
const bounded = stripBoundedTextMarker(patchText)
const moved = splitMoveMarker(bounded.text)
// One card per file the patch touches: run together, the later files' rows
// and gutter numbers sit under the first file's name.
const split = unifiedPatchSections(moved.body)
if (callerPath !== null && FILE_COUNT_PATH.test(callerPath)) {
// The producer joined several files' patches and kept a count in place of a
// path, so nothing here can name a file. Naming the card after the count
// would assert a file that does not exist.
return null
}
// A patch that names one file is the file the call is reporting on, so the
// call's own path wins — it is the provider's, where the header's is relative
// to the patch. A patch naming several has no one path, and a rename's
// destination is only ever in the header. Sections that name nothing are
// preamble and must not change that count.
const namedSections = split.sections.filter((section) => section.path !== null).length
const named = (section: UnifiedPatchSection): string =>
(namedSections <= 1 && section.oldPath === null
? (callerPath ?? section.path)
: (section.path ?? callerPath)) ?? 'file'
const files = split.sections.flatMap((section) => {
const parsed = summaryOnly
? summarizeUnifiedPatch(section.body)
: editLinesFromUnifiedPatch(section.body)
if (!parsed && section.path === null) {
return []
}
const metadata = {
path: named(section),
oldPath: section.oldPath,
changeKind: section.changeKind,
truncated: bounded.truncated || split.truncated || (parsed?.truncated ?? false)
}
return [
summaryOnly
? {
...metadata,
added: parsed && 'added' in parsed ? parsed.added : 0,
removed: parsed && 'removed' in parsed ? parsed.removed : 0
}
: finalizeEditFile({
...metadata,
lines: parsed && 'lines' in parsed ? parsed.lines : [],
lineNumbersKnown:
parsed && 'lineNumbersKnown' in parsed ? parsed.lineNumbersKnown : false
})
]
})
// The move marker names where the whole patch moved, so it can only speak for
// a patch describing one file.
if (moved.movedTo !== null && files.length === 1 && files[0]) {
const only = files[0]
return [{ ...only, path: moved.movedTo, oldPath: only.path, changeKind: 'renamed' }]
}
return files.length > 0 ? files : null
}
+60 -38
View File
@@ -1,5 +1,5 @@
import { FILE_SECTION_START, isFileHeaderPair } from './native-chat-diff'
import { pushEditGap, splitEditContent, type NativeChatEditLine } from './native-chat-edit-model'
import { MAX_EDIT_LINES, splitEditContent, type NativeChatEditLine } from './native-chat-edit-model'
const HUNK_RANGES = /^@@+ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
@@ -22,9 +22,52 @@ export function editLinesFromUnifiedPatch(
text: string,
options?: { implicitFirstHunk?: boolean }
): UnifiedPatchLines | null {
const lines: NativeChatEditLine[] = []
const metadata = visitUnifiedPatch(
text,
(kind, raw, oldLineNumber, newLineNumber) => {
lines.push({ kind, text: raw, oldLineNumber, newLineNumber })
},
options
)
return metadata ? { lines, ...metadata } : null
}
/** Counts the same capped rows as a card without allocating its line models. */
export function summarizeUnifiedPatch(text: string): {
added: number
removed: number
truncated: boolean
} | null {
let added = 0
let removed = 0
let rowCount = 0
const metadata = visitUnifiedPatch(text, (kind) => {
rowCount += 1
if (rowCount <= MAX_EDIT_LINES) {
added += Number(kind === 'add')
removed += Number(kind === 'del')
}
})
return metadata
? { added, removed, truncated: metadata.truncated || rowCount > MAX_EDIT_LINES }
: null
}
function visitUnifiedPatch(
text: string,
visit: (
kind: NativeChatEditLine['kind'],
text: string,
oldLineNumber: number | null,
newLineNumber: number | null
) => void,
options?: { implicitFirstHunk?: boolean }
): Omit<UnifiedPatchLines, 'lines'> | null {
const source = splitEditContent(text)
const rows = source.lines
const lines: NativeChatEditLine[] = []
let rowCount = 0
let lastWasGap = false
let oldNo: number | null = null
let newNo: number | null = null
let sawHunk = options?.implicitFirstHunk === true
@@ -37,15 +80,15 @@ export function editLinesFromUnifiedPatch(
const match = HUNK_RANGES.exec(raw)
oldNo = match ? Number(match[1]) : null
newNo = match ? Number(match[3]) : null
// Successive hunks are separate regions of the file; concatenated with no
// break the gutter jumps and the reader sees one continuous block.
pushEditGap(lines)
if (rowCount > 0 && !lastWasGap) {
visit('gap', '', null, null)
rowCount += 1
lastWasGap = true
}
sawHunk = true
inHunk = true
continue
}
// `\ No newline at end of file` sits mid-hunk, between the removed old last
// line and the added new one, so it ends nothing.
if (raw.startsWith('\\')) {
continue
}
@@ -60,43 +103,22 @@ export function editLinesFromUnifiedPatch(
if (!inHunk) {
continue
}
// Read off the rows rather than the header, so a body that opened with no
// header is reported as unlocatable just like a rangeless `@@`.
ranged &&= oldNo !== null || newNo !== null
rowCount += 1
lastWasGap = false
if (raw.startsWith('+')) {
lines.push({
kind: 'add',
text: raw.slice(1),
oldLineNumber: null,
newLineNumber: newNo
})
visit('add', raw.slice(1), null, newNo)
newNo = newNo === null ? null : newNo + 1
continue
}
if (raw.startsWith('-')) {
lines.push({
kind: 'del',
text: raw.slice(1),
oldLineNumber: oldNo,
newLineNumber: null
})
} else if (raw.startsWith('-')) {
visit('del', raw.slice(1), oldNo, null)
oldNo = oldNo === null ? null : oldNo + 1
continue
} else {
visit('context', raw.startsWith(' ') ? raw.slice(1) : raw, oldNo, newNo)
oldNo = oldNo === null ? null : oldNo + 1
newNo = newNo === null ? null : newNo + 1
}
lines.push({
kind: 'context',
text: raw.startsWith(' ') ? raw.slice(1) : raw,
oldLineNumber: oldNo,
newLineNumber: newNo
})
oldNo = oldNo === null ? null : oldNo + 1
newNo = newNo === null ? null : newNo + 1
}
if (!sawHunk || lines.length === 0) {
return null
}
return { lines, lineNumbersKnown: ranged, truncated: source.truncated }
return sawHunk && rowCount > 0 ? { lineNumbersKnown: ranged, truncated: source.truncated } : null
}
const GIT_DIFF_HEADER = 'diff --git '
@@ -20,6 +20,65 @@ function item(
}
describe('structured agent session status projection', () => {
it('reuses immutable item projections and refreshes revisions and resolved prompts', () => {
const original = item('diff', 1, {
kind: 'diff',
path: 'a.ts',
patch: {
head: '@@\n+first',
digest: 'one',
byteLength: 10,
truncated: false
}
})
const first = projectStructuredItemToNativeChat(original)
expect(projectStructuredItemToNativeChat(original)).toBe(first)
const revised = {
...original,
revision: 2,
observedAt: 2000,
body: {
kind: 'diff' as const,
path: 'a.ts',
patch: {
head: '@@\n+second',
digest: 'two',
byteLength: 11,
truncated: false
}
}
}
const second = projectStructuredItemToNativeChat(revised)
expect(second).not.toBe(first)
expect(second).toMatchObject({
timestamp: 2000,
blocks: [{ type: 'tool-call' }, { type: 'tool-result', output: '@@\n+second' }]
})
const pending = item('approval', 2, {
kind: 'approval',
title: 'Allow?',
detail: null,
options: [],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
})
expect(projectStructuredItemToNativeChat(pending)).toBeNull()
if (pending.body.kind !== 'approval') {
throw new Error('fixture')
}
const resolved = {
...pending,
revision: 2,
body: {
...pending.body,
resolution: { ...pending.body.resolution, state: 'resolved' as const }
}
}
expect(projectStructuredItemToNativeChat(resolved)).toMatchObject({
id: 'approval',
role: 'system'
})
})
it('projects running, attention, and completed lifecycle states', () => {
const running = item('running', 1, {
kind: 'status',
@@ -121,29 +121,37 @@ function itemBlocks(item: AgentJournalRenderItem): {
}
}
const projectedItems = new WeakMap<AgentJournalRenderItem, NativeChatMessage | null>()
export function projectStructuredItemsToNativeChat(
items: readonly AgentJournalRenderItem[]
): NativeChatMessage[] {
return items.flatMap((item) => {
const projected = itemBlocks(item)
return projected
? [
{
id: item.itemId,
role: projected.role,
blocks: projected.blocks,
timestamp: item.observedAt,
source: 'transcript'
}
]
: []
const projected = projectStructuredItemToNativeChat(item)
return projected ? [projected] : []
})
}
export function projectStructuredItemToNativeChat(
item: AgentJournalRenderItem
): NativeChatMessage | null {
return projectStructuredItemsToNativeChat([item])[0] ?? null
const cached = projectedItems.get(item)
if (cached !== undefined) {
return cached
}
// Reducer updates replace journal items, so unchanged rows keep their render caches.
const projected = itemBlocks(item)
const message: NativeChatMessage | null = projected
? {
id: item.itemId,
role: projected.role,
blocks: projected.blocks,
timestamp: item.observedAt,
source: 'transcript'
}
: null
projectedItems.set(item, message)
return message
}
export function activeStructuredAgentSessionTurnId(