diff --git a/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx b/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx index 8bbdb6ce988..a69ae1252f1 100644 --- a/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx @@ -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(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 ( -
+
+ + +

+ {translate( + 'components.native-chat.turnDiff.recorded', + 'Totals from recorded edits in this turn.' + )} +

+ {diff.files.map((file) => ( + + ))} +
+ + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-edit-cards.ts b/src/renderer/src/components/native-chat/native-chat-edit-cards.ts new file mode 100644 index 00000000000..f907dc89ce3 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-edit-cards.ts @@ -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 + /** Result blocks the card already speaks for, so they render no second row. */ + consumedResults: Set +} + +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() + 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 +} diff --git a/src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts b/src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts new file mode 100644 index 00000000000..f9a0c64437d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts @@ -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 }] +} diff --git a/src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts b/src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts new file mode 100644 index 00000000000..7b079279a8b --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts @@ -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) + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-turn-diffs.ts b/src/renderer/src/components/native-chat/native-chat-turn-diffs.ts new file mode 100644 index 00000000000..54298c470d4 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-turn-diffs.ts @@ -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 { + const turns = new Map>() + 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) + } + ] + }) + ) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index aa7efcb7a4e..d5980fc90e0 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -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(null) const operationIds = useRef(new Map()) @@ -267,6 +258,7 @@ export function useStructuredAgentSession(args: { { command } ) }), + journalItems: state.items, messages, status: state.status, error: state.error ?? writeError ?? outboxController.error, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index fb8e84b7ef7..2f33a2d9745 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/shared/native-chat-edit-normalize.ts b/src/shared/native-chat-edit-normalize.ts index 95c2ca18bd5..558261fae7b 100644 --- a/src/shared/native-chat-edit-normalize.ts +++ b/src/shared/native-chat-edit-normalize.ts @@ -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)) } diff --git a/src/shared/native-chat-edit-patch-files.ts b/src/shared/native-chat-edit-patch-files.ts new file mode 100644 index 00000000000..3ad996051ff --- /dev/null +++ b/src/shared/native-chat-edit-patch-files.ts @@ -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 +} diff --git a/src/shared/native-chat-unified-patch.ts b/src/shared/native-chat-unified-patch.ts index 2e34c79a6b3..9d6d4470a7e 100644 --- a/src/shared/native-chat-unified-patch.ts +++ b/src/shared/native-chat-unified-patch.ts @@ -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 | 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 ' diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 9b68e4fcdd8..77980fa86cc 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -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', diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 5bb142b2949..02ad872c211 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -121,29 +121,37 @@ function itemBlocks(item: AgentJournalRenderItem): { } } +const projectedItems = new WeakMap() + 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(