From 8ebdbd50e30d00072603c4621b1e74bc5185e331 Mon Sep 17 00:00:00 2001 From: Derek Hearst Date: Wed, 23 Sep 2026 20:11:28 -0600 Subject: [PATCH 1/4] fix(engine): a file the agent creates is its contents, not "no change" (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK reports a Write that creates a file with an empty structuredPatch and originalFile null: there is no "before" to diff. fileEditDetails read that as unavailable 'no_change' with 0 additions, so the rail's Files tab dropped every created file and its edit card said "No changes — the file already matched". A create with no patch is now its content as one all-added hunk, inside MAX_DIFF_LINES and a new MAX_CREATED_FILE_CHARS budget, with every line counted (git's counts still win), and is never 'no_change'. Replies saved before this stored creates as 'no_change', so Files still lists a create whatever its details say, and the card no longer calls one unchanged. The specs now build their fixtures with toolResultDetails from the SDK's real payloads instead of hand-written shapes the engine never emits. Co-Authored-By: Claude Opus 5.5 --- src/lib/chat-console/changed-files.ts | 6 +- src/lib/chat/FileEditCard.svelte | 16 +++-- src/lib/engine/tool-result-details.ts | 63 +++++++++++++--- tests/chat-console.changed-files.spec.ts | 92 ++++++++++++++++++------ tests/engine.tool-result-details.spec.ts | 86 ++++++++++++++++++++++ 5 files changed, 225 insertions(+), 38 deletions(-) diff --git a/src/lib/chat-console/changed-files.ts b/src/lib/chat-console/changed-files.ts index 2e41f8ab..7e3bca2e 100644 --- a/src/lib/chat-console/changed-files.ts +++ b/src/lib/chat-console/changed-files.ts @@ -75,7 +75,9 @@ function splitPath(path: string): { name: string; dir: string } { * - Lists are read in order, oldest first: saved messages by sequence, then the live turn. * - A block with an `id` is counted once even when two lists hold it. * - Only successful `file_edit` blocks count; a write that changed nothing - * (`unavailable: 'no_change'`) is not a change. + * (`unavailable: 'no_change'`) is not a change. A created file always is: replies saved + * before `tool-result-details` learned to diff a new file stored every `Write` create as + * `no_change`, and those files belong on the list too. * - Counts add up across edits, and a file created at any point stays `create`. */ export function collectChangedFiles(blockLists: Iterable): ChangedFile[] { @@ -93,7 +95,7 @@ export function collectChangedFiles(blockLists: Iterable= MAX_DIFF_LINES || budget <= 0) break + const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw + kept.push(`+${line.slice(0, budget)}`) + budget -= line.length + } + + const truncated = kept.length < lineCount || budget < 0 + const hunks = + kept.length > 0 ? [{ oldStart: 0, oldLines: 0, newStart: 1, newLines: kept.length, lines: kept }] : [] + return { hunks, truncated, lineCount } +} + function fileEditDetails( toolName: string, result: Record, @@ -200,24 +239,32 @@ function fileEditDetails( if (!path) return null const all = readHunks(result.structuredPatch) - const { hunks, truncated } = capHunks(all) + let { hunks, truncated } = capHunks(all) // `type` is FileWriteOutput's own field; Edit has no equivalent, and an edit by // definition changes a file that already existed. const changeType = str(result.type) === 'create' ? 'create' : 'update' + let counted = countChanges(hunks) + if (changeType === 'create' && all.length === 0 && typeof result.content === 'string') { + const created = createdFileHunk(result.content) + hunks = created.hunks + truncated = created.truncated + counted = { additions: created.lineCount, deletions: 0 } + } + const gitDiff = asRecord(result.gitDiff) - const counted = countChanges(hunks) // Prefer git's counts: they describe the whole change even when hunks were dropped. const additions = num(gitDiff?.additions) ?? counted.additions const deletions = num(gitDiff?.deletions) ?? counted.deletions + // A created file changed something even when it is empty: it did not exist before. let unavailable: DiffUnavailable = 'none' - if (all.length === 0) { + if (all.length === 0 && changeType === 'update') { // An empty patch with the original contents in hand means nothing actually changed. - // An empty patch with `originalFile: null` on an update means the SDK could not - // produce one — a diff that timed out, or previous contents too large to include. - unavailable = changeType === 'update' && result.originalFile === null ? 'diff_missing' : 'no_change' + // An empty patch with `originalFile: null` means the SDK could not produce one — a + // diff that timed out, or previous contents too large to include. + unavailable = result.originalFile === null ? 'diff_missing' : 'no_change' } return { diff --git a/tests/chat-console.changed-files.spec.ts b/tests/chat-console.changed-files.spec.ts index ad47e70a..5a84e879 100644 --- a/tests/chat-console.changed-files.spec.ts +++ b/tests/chat-console.changed-files.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test' import { changedFilesInThread, collectChangedFiles, sameChangedFiles } from '../src/lib/chat-console/changed-files' +import { toolResultDetails } from '../src/lib/engine/tool-result-details' /** * #14 — the rail's Files tab: "changed in this chat". @@ -8,9 +9,16 @@ import { changedFilesInThread, collectChangedFiles, sameChangedFiles } from '../ * import), so this runs without Postgres or a dev server, like * `engine.tool-result-details.spec.ts`. * + * The edits are not hand-written `file_edit` objects: each one is what the engine's own + * `toolResultDetails` makes of the payload the Agent SDK returns for that tool, so these + * rows are the shape production stores. (A hand-written create once hid the fact that every + * file the agent created with `Write` was missing from the tab.) + * * What is pinned here: * - one row per file, with the +/- counts of every edit to it added up - * - a file created at any point in the chat stays "new" + * - a file created with `Write` is listed, marked new, with every line counted, and it + * stays new whatever edits follow — including a create saved before new files were + * diffed, which was stored as "no change" * - newest first, by the most recent edit * - a block the live stream and a saved list both hold is counted once * - only successful `file_edit` blocks count: other tools, failed or denied calls, @@ -19,23 +27,37 @@ import { changedFilesInThread, collectChangedFiles, sameChangedFiles } from '../ * - `sameChangedFiles`, which spares the rail a redraw on every streamed token */ -function edit( - path: string, - additions: number, - deletions: number, - extra: { changeType?: 'create' | 'update'; unavailable?: 'none' | 'no_change' | 'diff_missing'; tool?: string } = {}, -) { - return { - kind: 'file_edit' as const, - tool: extra.tool ?? 'Edit', - path, - changeType: extra.changeType ?? 'update', - hunks: [], - additions, - deletions, - unavailable: extra.unavailable ?? 'none', - truncated: false, - } +/** An `Edit` as the SDK answers one: a patch of `deletions` removed and `additions` added lines. */ +function edit(path: string, additions: number, deletions: number) { + const lines = [ + ...Array.from({ length: deletions }, (_, i) => `-old ${i}`), + ...Array.from({ length: additions }, (_, i) => `+new ${i}`), + ] + return toolResultDetails('Edit', { + filePath: path, + oldString: 'old', + newString: 'new', + originalFile: 'old\n', + structuredPatch: [{ oldStart: 1, oldLines: deletions, newStart: 1, newLines: additions, lines }], + userModified: false, + replaceAll: false, + }) +} + +/** A `Write` that created a file, as the SDK answers one: no patch and no original. */ +function create(path: string, content: string) { + return toolResultDetails('Write', { type: 'create', filePath: path, content, structuredPatch: [], originalFile: null }) +} + +/** A `Write` that left an existing file exactly as it was. */ +function unchangedWrite(path: string) { + return toolResultDetails('Write', { + type: 'update', + filePath: path, + content: 'same\n', + structuredPatch: [], + originalFile: 'same\n', + }) } /** A saved tool block: no id, `success` rather than a status. */ @@ -59,13 +81,37 @@ test.describe('collectChangedFiles', () => { expect(app).toMatchObject({ name: 'app.ts', dir: '/w/src/', additions: 7, deletions: 3, edits: 2, changeType: 'update' }) }) + test('a file the agent created with Write is listed, marked new, with every line counted', () => { + expect(collectChangedFiles([[saved(create('/w/new.ts', 'a\nb\nc\n'))]])).toEqual([ + { path: '/w/new.ts', name: 'new.ts', dir: '/w/', changeType: 'create', additions: 3, deletions: 0, edits: 1 }, + ]) + // An empty new file is still a new file. + expect(collectChangedFiles([[saved(create('/w/.keep', ''))]])).toMatchObject([{ path: '/w/.keep', changeType: 'create' }]) + }) + test('a file created anywhere in the chat stays new', () => { const [file] = collectChangedFiles([ - [saved(edit('/w/new.ts', 10, 0, { changeType: 'create', tool: 'Write' }))], + [saved(create('/w/new.ts', 'one\ntwo\n'))], [saved(edit('/w/new.ts', 1, 1))], ]) - expect(file.changeType).toBe('create') - expect(file.edits).toBe(2) + expect(file).toMatchObject({ changeType: 'create', edits: 2, additions: 3, deletions: 1 }) + }) + + test('a create saved before new files were diffed is still listed as new', () => { + // What the engine stored for every Write create until the fix: an empty patch, read as + // "nothing changed". Those replies are still in people's chats. + const legacy = { + kind: 'file_edit', + tool: 'Write', + path: '/w/old-new.ts', + changeType: 'create', + hunks: [], + additions: 0, + deletions: 0, + unavailable: 'no_change', + truncated: false, + } + expect(collectChangedFiles([[saved(legacy)]])).toMatchObject([{ path: '/w/old-new.ts', changeType: 'create', edits: 1 }]) }) test('orders files by their most recent edit, newest first', () => { @@ -95,7 +141,7 @@ test.describe('collectChangedFiles', () => { saved({ kind: 'file_edit', path: ' ' }), saved(null), saved('not an object'), - saved(edit('/w/same.ts', 0, 0, { tool: 'Write', unavailable: 'no_change' })), + saved(unchangedWrite('/w/same.ts')), null, undefined, saved(edit('/w/kept.ts', 1, 0)), @@ -107,7 +153,7 @@ test.describe('collectChangedFiles', () => { }) test('treats a missing or nonsense count as zero', () => { - const [file] = collectChangedFiles([[saved({ ...edit('/w/a.ts', 0, 0), additions: 'many', deletions: -4 })]]) + const [file] = collectChangedFiles([[saved({ ...edit('/w/a.ts', 1, 0), additions: 'many', deletions: -4 })]]) expect(file).toMatchObject({ additions: 0, deletions: 0 }) }) diff --git a/tests/engine.tool-result-details.spec.ts b/tests/engine.tool-result-details.spec.ts index 3e1282d5..6611fe47 100644 --- a/tests/engine.tool-result-details.spec.ts +++ b/tests/engine.tool-result-details.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test' import { + MAX_CREATED_FILE_CHARS, MAX_DIFF_LINES, MAX_STREAM_CHARS, MAX_TODO_ITEMS, @@ -22,6 +23,8 @@ import { * than throwing — this runs inside the stream loop, where an exception kills the turn * - the difference between "nothing changed" and "we could not produce a diff", which * the card renders as two different sentences + * - a created file, which the SDK reports with no patch at all, is neither: it is its + * own contents, added, inside the same caps */ /** A `structuredPatch` hunk in the SDK's shape. */ @@ -80,6 +83,89 @@ test.describe('file edits', () => { expect(details?.kind === 'file_edit' && details.unavailable).toBe('none') }) + test('a new file, as the SDK reports it, is its contents added — not "nothing changed"', () => { + // What the bundled CLI returns for a Write that creates a file: no patch (there is no + // "before" to diff) and no original. This used to read as `no_change`, which hid every + // created file from the rail's Files tab and labelled its card "No changes". + const details = toolResultDetails('Write', { + type: 'create', + filePath: '/w/new.ts', + content: 'a\nb\r\nc\n', + structuredPatch: [], + originalFile: null, + }) + + expect(details?.kind).toBe('file_edit') + if (details?.kind !== 'file_edit') return + expect(details.changeType).toBe('create') + expect(details.unavailable).toBe('none') + expect(details.additions).toBe(3) + expect(details.deletions).toBe(0) + expect(details.truncated).toBe(false) + // Numbered from line 1 of the new file; a Windows line ending is not part of the text. + expect(details.hunks).toEqual([{ oldStart: 0, oldLines: 0, newStart: 1, newLines: 3, lines: ['+a', '+b', '+c'] }]) + }) + + test('a new file prefers git’s counts, and an empty one is still a change', () => { + const counted = toolResultDetails('Write', { + type: 'create', + filePath: '/w/new.ts', + content: 'a\n', + structuredPatch: [], + originalFile: null, + gitDiff: { filename: 'new.ts', status: 'added', additions: 7, deletions: 0, changes: 7, patch: '' }, + }) + expect(counted?.kind === 'file_edit' && counted.additions).toBe(7) + + const empty = toolResultDetails('Write', { + type: 'create', + filePath: '/w/empty.ts', + content: '', + structuredPatch: [], + originalFile: null, + }) + expect(empty?.kind).toBe('file_edit') + if (empty?.kind !== 'file_edit') return + expect(empty.unavailable).toBe('none') + expect(empty.hunks).toEqual([]) + expect(empty.additions).toBe(0) + }) + + test('a large new file keeps its first lines, counts all of them, and is flagged', () => { + const lines = Array.from({ length: MAX_DIFF_LINES + 25 }, (_, i) => `line ${i}`) + const details = toolResultDetails('Write', { + type: 'create', + filePath: '/w/big.ts', + content: `${lines.join('\n')}\n`, + structuredPatch: [], + originalFile: null, + }) + + expect(details?.kind).toBe('file_edit') + if (details?.kind !== 'file_edit') return + expect(details.additions).toBe(MAX_DIFF_LINES + 25) + expect(details.truncated).toBe(true) + expect(details.hunks[0].lines).toHaveLength(MAX_DIFF_LINES) + expect(details.hunks[0].lines[0]).toBe('+line 0') + }) + + test('a new file of one enormous line is cut to the character budget', () => { + const details = toolResultDetails('Write', { + type: 'create', + filePath: '/w/bundle.min.js', + content: 'x'.repeat(MAX_CREATED_FILE_CHARS * 3), + structuredPatch: [], + originalFile: null, + }) + + expect(details?.kind).toBe('file_edit') + if (details?.kind !== 'file_edit') return + expect(details.additions).toBe(1) + expect(details.truncated).toBe(true) + // The kept text plus the `+` marker; the rest of the file never reaches the database. + expect(details.hunks[0].lines[0]).toHaveLength(MAX_CREATED_FILE_CHARS + 1) + }) + test('an empty patch means "nothing changed" when the original is in hand', () => { const details = toolResultDetails('Write', { type: 'update', From e63bfbce189202cc0a12b8f4a1d3d1b4e0eba825 Mon Sep 17 00:00:00 2001 From: Derek Hearst Date: Wed, 23 Sep 2026 20:11:28 -0600 Subject: [PATCH 2/4] fix(chat-console): save a fold choice made before the stored one loads (#14) With this browser's copy saying expanded and the database saying folded, opening a preview before getRailOpen answered hit setOpen's "already open" early return, so nothing was saved and the next load folded the rail again. setOpen now also saves when the value it matches is not known to be stored. Co-Authored-By: Claude Opus 5.5 --- src/lib/chat-console/preview-state.svelte.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/lib/chat-console/preview-state.svelte.ts b/src/lib/chat-console/preview-state.svelte.ts index 7978af6d..85da403a 100644 --- a/src/lib/chat-console/preview-state.svelte.ts +++ b/src/lib/chat-console/preview-state.svelte.ts @@ -164,6 +164,12 @@ export async function hydratePreviewState(conversationId: string | null) { let openLoaded = false; /** Set once anything changes `open`; a stored value arriving later must not undo it. */ let openTouched = false; +/** + * What the database holds, as far as this page knows: null until it has been read or + * written. While it is unknown, `open` may be this browser's copy rather than the stored + * value, so a choice that matches it must still be saved. + */ +let savedOpen: boolean | null = null; let openSaveTimer: ReturnType | null = null; /** Load the viewer's remembered fold once per page load. Later chats reuse it from memory. */ @@ -172,6 +178,7 @@ export async function hydrateRailOpen() { openLoaded = true; try { const stored = await getRailOpen(); + savedOpen ??= stored; // a save that landed first knows better if (!openTouched) { previewState.open = stored; writeRailOpenMirror(stored); @@ -193,15 +200,20 @@ export async function hydrateRailOpen() { function setOpen(open: boolean) { if (isDrawerViewport()) return; openTouched = true; - if (previewState.open === open) return; + if (previewState.open === open && (savedOpen === open || openSaveTimer)) return; previewState.open = open; writeRailOpenMirror(open); if (openSaveTimer) clearTimeout(openSaveTimer); openSaveTimer = setTimeout(() => { openSaveTimer = null; - void setRailOpen(previewState.open).catch(() => { - // Same as the selection: the fold still holds for this session. - }); + const next = previewState.open; + void setRailOpen(next) + .then(() => { + savedOpen = next; + }) + .catch(() => { + // Same as the selection: the fold still holds for this session. + }); }, 400); } From aa7f9ed19ac1f5d0e1e0564ce8009b714a8c42c9 Mon Sep 17 00:00:00 2001 From: Derek Hearst Date: Wed, 23 Sep 2026 20:11:29 -0600 Subject: [PATCH 3/4] fix(chat-console): keep focus across the rail's fold; tablets show the cost (#14) Expanding or folding the rail swaps the strip for the full rail, which destroyed the pressed button and dropped keyboard focus to the page. Focus now moves to the control that took its place: the requested tab or the collapse button on expand, the expand button on fold, the Preview tab after opening a file from Files or closing a preview. The chat header's rail button (RailHeaderButton) carries aria-expanded and a name for what it will do: "Open chat rail" on a phone, "Expand/Collapse chat rail" on a tablet. Tablet widths had lost the metered cost with the rail's stats footer; the header shows it there now. The rail spec seeds its edits through toolResultDetails from the SDK's real payloads, checks a created file's card, pins focus, the header button and the tablet cost, and no longer leaves a preferences row behind for a user who had none. Co-Authored-By: Claude Opus 5.5 --- src/lib/chat-console/ChatConsoleRail.svelte | 76 +++++++++- src/lib/chat-console/PreviewPane.svelte | 19 ++- src/lib/chat-console/RailHeaderButton.svelte | 35 +++++ src/routes/chat/[id]/+page.svelte | 15 +- tests/chat.rail.spec.ts | 150 +++++++++++++++---- 5 files changed, 251 insertions(+), 44 deletions(-) create mode 100644 src/lib/chat-console/RailHeaderButton.svelte diff --git a/src/lib/chat-console/ChatConsoleRail.svelte b/src/lib/chat-console/ChatConsoleRail.svelte index f79dd1c2..1e99a411 100644 --- a/src/lib/chat-console/ChatConsoleRail.svelte +++ b/src/lib/chat-console/ChatConsoleRail.svelte @@ -1,5 +1,5 @@ {#if collapsed}