diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx index 361045b4642..2dc864c4de0 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx @@ -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() + return { + ...actual, + editLinesFromUnifiedPatch: (...args: Parameters) => { + patchCalls.detailed += 1 + return actual.editLinesFromUnifiedPatch(...args) + }, + summarizeUnifiedPatch: (...args: Parameters) => { + 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[]) => ( + + ) + 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() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-history.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-history.test.tsx index b9598859c67..628d78b4c58 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-history.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-history.test.tsx @@ -80,6 +80,8 @@ describe('turn history presentation', () => { 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) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageTimestamp.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageTimestamp.test.tsx new file mode 100644 index 00000000000..4c4dadd56bf --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageTimestamp.test.tsx @@ -0,0 +1,61 @@ +// @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 { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp' + +afterEach(async () => { + cleanup() + await i18n.changeLanguage('en') +}) + +describe('NativeChatMessageTimestamp', () => { + it.each([null, Number.NaN, Infinity, -Infinity, 8.64e15 + 1])( + 'omits missing or invalid time %s', + (timestamp) => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + } + ) + + it.each([0, Date.parse('2026-09-06T19:04:05Z')])( + 'renders absolute time and full metadata for %s', + (timestamp) => { + render() + const time = screen.getByRole('time') + expect(time).toHaveAttribute('datetime', new Date(timestamp).toISOString()) + expect(time).toHaveTextContent( + new Intl.DateTimeFormat('en', { hour: 'numeric', minute: '2-digit' }).format(timestamp) + ) + expect(time).toHaveAccessibleName( + new Intl.DateTimeFormat('en', { dateStyle: 'full', timeStyle: 'long' }).format(timestamp) + ) + expect(time).not.toHaveAttribute('tabindex') + } + ) + + it('provides a focus target only when requested for user metadata', () => { + render() + const time = screen.getByRole('time') + time.focus() + expect(time).toHaveFocus() + expect(time).toHaveAttribute('tabindex', '0') + }) + + it('updates settled time when the UI language changes without a parent rerender', async () => { + const timestamp = Date.parse('2026-09-06T19:04:05Z') + render() + await act(async () => { + await i18n.changeLanguage('fr') + }) + const time = screen.getByRole('time') + expect(time).toHaveTextContent( + new Intl.DateTimeFormat('fr', { hour: 'numeric', minute: '2-digit' }).format(timestamp) + ) + expect(time).toHaveAccessibleName( + new Intl.DateTimeFormat('fr', { dateStyle: 'full', timeStyle: 'long' }).format(timestamp) + ) + expect(time).toHaveAttribute('datetime', new Date(timestamp).toISOString()) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageTimestamp.tsx b/src/renderer/src/components/native-chat/NativeChatMessageTimestamp.tsx new file mode 100644 index 00000000000..f2361d41bab --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageTimestamp.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from 'react-i18next' +import { getIntlLocale } from '@/i18n/i18n' +import { cn } from '@/lib/utils' + +let cached: { + locale: string + time: Intl.DateTimeFormat + full: Intl.DateTimeFormat +} | null = null + +function getTimestampFormatters(): NonNullable { + const locale = getIntlLocale() + if (!cached || cached.locale !== locale) { + cached = { + locale, + time: new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }), + full: new Intl.DateTimeFormat(locale, { dateStyle: 'full', timeStyle: 'long' }) + } + } + return cached +} + +export function NativeChatMessageTimestamp({ + timestamp, + focusable = false, + className +}: { + timestamp: number | null + focusable?: boolean + className?: string +}): React.JSX.Element | null { + useTranslation() + if (timestamp === null) { + return null + } + const date = new Date(timestamp) + if (Number.isNaN(date.getTime())) { + return null + } + const formatters = getTimestampFormatters() + + return ( + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx index e248174a3dc..6945bc6aa98 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx @@ -1,7 +1,8 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' -import { cleanup, render, screen } from '@testing-library/react' +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' @@ -10,7 +11,10 @@ import { type NativeChatResolvedPrompt } from './native-chat-resolution-receipt' -afterEach(cleanup) +afterEach(async () => { + cleanup() + await i18n.changeLanguage('en') +}) const approval: NativeChatResolvedPrompt = { kind: 'approval', title: 'Run command?', @@ -28,6 +32,19 @@ const approval: NativeChatResolvedPrompt = { } describe('resolution receipts', () => { + it('localizes the resolved time when the UI language changes', async () => { + render() + 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'] diff --git a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx index c5511e7f262..e564c789344 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx @@ -1,5 +1,5 @@ import { translate } from '@/i18n/i18n' -import { formatShortTimeAgo } from '@/lib/short-time-ago' +import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp' import { nativeChatReceiptAnswers, type NativeChatResolvedPrompt @@ -26,7 +26,7 @@ export function NativeChatResolutionReceipt({

{body.detail}

) : null} {answers.map((answer, index) => ( -
+
{answer.question ?

{answer.question}

: null}

{answer.answer ?? @@ -54,12 +54,7 @@ export function NativeChatResolutionReceipt({ })} ) : null} - {resolution.resolvedAt !== null && - Number.isFinite(new Date(resolution.resolvedAt).getTime()) ? ( - - ) : null} +

) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index e126f07d75d..1b7a8466835 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { Check, ChevronRight } from 'lucide-react' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -152,15 +152,23 @@ export function NativeChatToolRun({ activeTurnIsWorking?: boolean structuredActivityUi?: boolean }): React.JSX.Element | null { - const [open, setOpen] = useState(expandOverride ?? expandSignal) - // Re-sync when the global toolbar toggle flips. - useEffect(() => setOpen(expandOverride ?? expandSignal), [expandOverride, expandSignal]) - - useEffect(() => { - if (revealedDiff) { + 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) } - }, [revealedDiff]) + } const callCount = countToolCalls(blocks) || blocks.length const summary = summarizeToolRun(blocks) @@ -171,7 +179,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 - // Other edits remain lazy; journal diffs reuse the rollup's normalized files. + // 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] 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 index 4bd0efd63da..f907dc89ce3 100644 --- a/src/renderer/src/components/native-chat/native-chat-edit-cards.ts +++ b/src/renderer/src/components/native-chat/native-chat-edit-cards.ts @@ -4,6 +4,10 @@ import { 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< @@ -39,12 +43,12 @@ export const NO_EDIT_CARDS: EditCardModel = { editCards: new Map(), consumedResu /** 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[], diffOnly = false): EditCardModel { +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) || (diffOnly && call.name !== 'Diff')) { + if (!call || !isEditToolName(call.name)) { continue } const files = normalizedEditFiles(call, pair.result, () => @@ -73,3 +77,56 @@ export function buildEditCards(blocks: NativeChatBlock[], diffOnly = false): Edi } 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-turn-diffs.test.ts b/src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts index 589f85792e9..7b079279a8b 100644 --- a/src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts @@ -1,7 +1,8 @@ +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 { buildEditCards } from './native-chat-edit-cards' +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 { @@ -103,9 +104,20 @@ describe('turn diff rollups', () => { ).toBe(0) }) - it('reuses parsed file identity across rollup/card consumers and refreshes new results', () => { + 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 first = [...buildEditCards(message.blocks, true).editCards.values()][0]!.files + 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]!, @@ -114,5 +126,25 @@ describe('turn diff rollups', () => { const updated = [...buildEditCards(message.blocks).editCards.values()][0]!.files expect(updated).not.toBe(first) expect(updated[0]?.added).toBe(2) + const updatedSummary = [...buildDiffSummaries(message.blocks).values()][0]!.files + 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 index e696753e27d..54298c470d4 100644 --- a/src/renderer/src/components/native-chat/native-chat-turn-diffs.ts +++ b/src/renderer/src/components/native-chat/native-chat-turn-diffs.ts @@ -1,5 +1,5 @@ import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { buildEditCards } from './native-chat-edit-cards' +import { buildDiffSummaries } from './native-chat-edit-cards' export type NativeChatDiffTarget = { messageId: string @@ -35,7 +35,7 @@ export function nativeChatTurnDiffs( if (!turnKey) { continue } - for (const edit of buildEditCards(message.blocks, true).editCards.values()) { + for (const edit of buildDiffSummaries(message.blocks).values()) { let files = turns.get(turnKey) if (!files) { files = new Map() 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 08d4de2fa0c..7a87cfe350b 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -20,6 +20,64 @@ 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?', + 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 7c55f2b8379..c92e0b11eeb 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -108,29 +108,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(