From 8697d53a16f2d0ed4b4f48e0e901b33115274b0f Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Tue, 11 Aug 2026 03:00:25 +0200 Subject: [PATCH] fix: show cumulative diff in Files Changed panel (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add three-layer diff resolution: primary diffFull, per-file fallback A (git show from-ref vs disk), then existing summary/metadata fallbacks - Track in-flight files from completed tool parts with filediff metadata - Log warning on diffFull failure before falling through (observability) - Add Snapshot.diffFromDisk(ref, files) for per-file fallback - Rename 'Modified Files' → 'Files Changed' across TUI and web app - Update empty-state copy to 'File changes from current session will appear here' --- packages/app/src/pages/session.tsx | 4 +- .../src/pages/session/session-side-panel.tsx | 4 +- packages/opencode/src/session/session.ts | 28 ++++++++++- packages/opencode/src/snapshot/index.ts | 48 ++++++++++++++++++- .../tui/src/feature-plugins/sidebar/files.tsx | 2 +- packages/ui/src/i18n/en.ts | 2 +- 6 files changed, 79 insertions(+), 9 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index a5a6933242..2e282591a0 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1969,8 +1969,8 @@ export default function Page() { classes={{ button: compact ? "w-full !py-2" : "w-full" }} onClick={() => setStore("mobileTab", "changes")} > - {"Modified Files"} - +{"Files Changed"} + ) diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 8573a75a64..71119f10bc 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -595,7 +595,7 @@ export function SessionSidePanel(props: { aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined} > {props.hasReview() - ? "Modified Files" + ? "Files Changed" : language.t("session.tab.review")} @@ -818,7 +818,7 @@ export function SessionSidePanel(props: { } > - {"Modified Files"} + {"Files Changed"} diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 9ad90a1987..46657e1c9d 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -843,7 +843,7 @@ const layer: Layer.Layer< // and the last step-finish snapshot hash (session-end ref) let from: string | undefined let lastStepFinish: string | undefined - // Collect all agent-touched files from PatchParts (absolute paths) + // Collect all agent-touched files from PatchParts and completed tool parts (absolute paths) const agentFilesAbsolute = new Set() for (const msg of all) { @@ -857,6 +857,14 @@ const layer: Layer.Layer< if (part.type === "patch" && part.files) { for (const file of part.files) agentFilesAbsolute.add(file) } + // In-flight file tracking: also collect files from completed tool parts with filediff metadata + if (part.type === "tool") { + const toolPart = part as { tool?: string; state?: { status?: string; metadata?: Record } } + if (toolPart.state?.status === "completed") { + const filediff = toolPart.state?.metadata?.filediff as { file?: string } | undefined + if (filediff?.file) agentFilesAbsolute.add(filediff.file) + } + } } } @@ -878,12 +886,28 @@ const layer: Layer.Layer< } const allDiffs = yield* snapshot.diffFull(from, to).pipe( - Effect.catchCause(() => Effect.succeed([] as Snapshot.FileDiff[])), + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logWarning("diffFull failed, falling through to per-file fallback", { cause }) + return [] as Snapshot.FileDiff[] + }), + ), ) const filtered = allDiffs.filter( (d: Snapshot.FileDiff) => d.file && agentFiles.has(d.file) && (d.additions ?? 0) + (d.deletions ?? 0) > 0, ) if (filtered.length > 0) return filtered + + // Fallback A: primary returned empty but from hash exists — per-file git show vs current disk + const perFileDiffs = yield* snapshot.diffFromDisk(from, [...agentFiles]).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logWarning("diffFromDisk failed, falling through to summary fallback", { cause }) + return [] as Snapshot.FileDiff[] + }), + ), + ) + if (perFileDiffs.length > 0) return perFileDiffs } } diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 4da9bc3ca8..0fcecd9439 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -42,6 +42,7 @@ export interface Interface { readonly revert: (patches: Patch[]) => Effect.Effect readonly diff: (hash: string) => Effect.Effect readonly diffFull: (from: string, to: string) => Effect.Effect + readonly diffFromDisk: (ref: string, files: string[]) => Effect.Effect } export class Service extends Context.Service()("@opencode/Snapshot") {} @@ -758,6 +759,48 @@ const layer: Layer.Layer + formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) + + for (const file of files) { + const beforeResult = yield* git([...cfg, ...args(["show", `${ref}:${file}`])]) + const before = beforeResult.code === 0 ? beforeResult.text : "" + + const diskPath = path.join(state.worktree, file) + const after = yield* read(diskPath) + + if (before === after) continue + + // Compute actual line-level additions/deletions from the structured patch + const sp = structuredPatch(file, file, before, after, "", "") + let adds = 0 + let dels = 0 + for (const hunk of sp.hunks) { + for (const line of hunk.lines) { + if (line.startsWith("+")) adds++ + else if (line.startsWith("-")) dels++ + } + } + + const status: "added" | "deleted" | "modified" = before === "" ? "added" : after === "" ? "deleted" : "modified" + result.push({ + file, + patch: patchFn(file, before, after), + additions: adds, + deletions: dels, + status, + }) + } + + return result + }), + ) + }) + yield* cleanup().pipe( Effect.catchCause((cause) => Effect.logError("cleanup loop failed", { cause: Cause.pretty(cause) })), Effect.repeat(Schedule.spaced(Duration.hours(1))), @@ -765,7 +808,7 @@ const layer: Layer.Layer s.diffFull(from, to)) }), + diffFromDisk: Effect.fn("Snapshot.diffFromDisk")(function* (ref: string, files: string[]) { + return yield* InstanceState.useEffect(state, (s) => s.diffFromDisk(ref, files)) + }), }) }), ) diff --git a/packages/tui/src/feature-plugins/sidebar/files.tsx b/packages/tui/src/feature-plugins/sidebar/files.tsx index 01f33f647d..7ed915050a 100644 --- a/packages/tui/src/feature-plugins/sidebar/files.tsx +++ b/packages/tui/src/feature-plugins/sidebar/files.tsx @@ -24,7 +24,7 @@ function View(props: { api: TuiPluginApi; session_id: string }) { {open() ? "▼" : "▶"} - Modified Files + Files Changed diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 7603d227d1..c492953e84 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -30,7 +30,7 @@ export const dict: Record = { "ui.sessionReviewV2.empty.noGit.action": "Create Git repository", "ui.sessionReviewV2.empty.noGit.actionLoading": "Creating Git repository...", "ui.sessionReviewV2.empty.changes.title": "No file changes yet", - "ui.sessionReviewV2.empty.changes.description": "Project changes will appear here", + "ui.sessionReviewV2.empty.changes.description": "File changes from current session will appear here", "ui.sessionReview.openFile": "Open file", "ui.sessionReview.selection.line": "line {{line}}",