From 1d55c63c6375e529508a3b853c80fdfbe985c197 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 31 Mar 2026 21:40:54 -0500 Subject: [PATCH 1/5] Add file-scoped full-context diff viewing - Propagate relative file paths and context mode through diff queries - Add per-file patch/full toggle in the diff panel and persist review state - Extend orchestration contracts and tests for scoped full-context diffs --- .../Layers/CheckpointDiffQuery.ts | 4 + .../checkpointing/Layers/CheckpointStore.ts | 13 ++- .../checkpointing/Services/CheckpointStore.ts | 4 +- apps/web/src/components/DiffPanel.tsx | 96 ++++++++++++++++++- apps/web/src/lib/diffFileReviewState.test.ts | 41 +++++--- apps/web/src/lib/diffFileReviewState.ts | 21 ++++ apps/web/src/lib/providerReactQuery.ts | 9 ++ packages/contracts/src/orchestration.test.ts | 14 +++ packages/contracts/src/orchestration.ts | 11 ++- packages/contracts/src/ws.test.ts | 17 ++++ 10 files changed, 214 insertions(+), 16 deletions(-) diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts index cdd4c5f41..0bd5ab40a 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts @@ -133,6 +133,8 @@ const make = Effect.gen(function* () { fromCheckpointRef, toCheckpointRef, fallbackFromToHead: false, + relativePath: input.relativePath, + contextMode: input.contextMode, }); const turnDiff: OrchestrationGetTurnDiffResultType = { @@ -158,6 +160,8 @@ const make = Effect.gen(function* () { threadId: input.threadId, fromTurnCount: 0, toTurnCount: input.toTurnCount, + relativePath: input.relativePath, + contextMode: input.contextMode, }).pipe(Effect.map((result): OrchestrationGetFullThreadDiffResult => result)); return { diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index 125396826..103043b88 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -240,10 +240,21 @@ const makeCheckpointStore = Effect.gen(function* () { }); } + const args = [ + "diff", + "--patch", + "--minimal", + "--no-color", + ...(input.contextMode === "full" ? ["--unified=999999"] : []), + fromCommitOid, + toCommitOid, + ...(input.relativePath ? ["--", input.relativePath] : []), + ]; + const result = yield* git.execute({ operation, cwd: input.cwd, - args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid], + args, }); return result.stdout; diff --git a/apps/server/src/checkpointing/Services/CheckpointStore.ts b/apps/server/src/checkpointing/Services/CheckpointStore.ts index 8fb628b86..fd0a09e9e 100644 --- a/apps/server/src/checkpointing/Services/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Services/CheckpointStore.ts @@ -14,7 +14,7 @@ import { ServiceMap } from "effect"; import type { Effect } from "effect"; import type { CheckpointStoreError } from "../Errors.ts"; -import { CheckpointRef } from "@okcode/contracts"; +import { CheckpointRef, type OrchestrationDiffContextMode } from "@okcode/contracts"; export interface CaptureCheckpointInput { readonly cwd: string; @@ -32,6 +32,8 @@ export interface DiffCheckpointsInput { readonly fromCheckpointRef: CheckpointRef; readonly toCheckpointRef: CheckpointRef; readonly fallbackFromToHead?: boolean; + readonly relativePath?: string; + readonly contextMode?: OrchestrationDiffContextMode; } export interface DeleteCheckpointRefsInput { diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index c82e1f5a2..b9afc8306 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -15,6 +15,7 @@ import { buildPatchCacheKey } from "../lib/diffRendering"; import { expandDiffFile, reconcileDiffFileReviewState, + setDiffFileContextMode, toggleDiffFileAccepted, toggleDiffFileCollapsed, type DiffFileReviewStateByPath, @@ -165,33 +166,83 @@ function summarizeFileDiffStats(fileDiff: FileDiffMetadata): { ); } +function resolveRenderableFileDiff( + renderablePatch: RenderablePatch | null, + filePath: string, +): FileDiffMetadata | null { + if (!renderablePatch || renderablePatch.kind !== "files") { + return null; + } + return ( + renderablePatch.files.find((candidate) => resolveFileDiffPath(candidate) === filePath) ?? null + ); +} + +interface FileScopedCheckpointDiffInput { + threadId: ThreadId | null; + fromTurnCount: number | null; + toTurnCount: number | null; + cacheScope?: string | null; + enabled: boolean; +} + function DiffFileSection(props: { fileDiff: FileDiffMetadata; filePath: string; fileKey: string; + checkpointDiffInput: FileScopedCheckpointDiffInput; diffRenderMode: DiffRenderMode; diffWordWrap: boolean; resolvedTheme: "light" | "dark"; collapsed: boolean; accepted: boolean; + contextMode: "patch" | "full"; onOpenInEditor: (filePath: string) => void; onToggleCollapsed: (filePath: string) => void; onToggleAccepted: (filePath: string) => void; + onContextModeChange: (filePath: string, contextMode: "patch" | "full") => void; }) { const { accepted, + checkpointDiffInput, collapsed, + contextMode, diffRenderMode, diffWordWrap, fileDiff, fileKey, filePath, + onContextModeChange, onOpenInEditor, onToggleAccepted, onToggleCollapsed, resolvedTheme, } = props; const stats = summarizeFileDiffStats(fileDiff); + const fullContextDiffQuery = useQuery( + checkpointDiffQueryOptions({ + ...checkpointDiffInput, + relativePath: filePath, + contextMode: "full", + enabled: checkpointDiffInput.enabled && !collapsed && contextMode === "full", + }), + ); + const fullContextPatch = useMemo( + () => + getRenderablePatch( + contextMode === "full" ? fullContextDiffQuery.data?.diff : undefined, + `diff-panel:file:${resolvedTheme}:${filePath}:full`, + ), + [contextMode, filePath, fullContextDiffQuery.data?.diff, resolvedTheme], + ); + const resolvedFileDiff = + contextMode === "full" ? (resolveRenderableFileDiff(fullContextPatch, filePath) ?? fileDiff) : fileDiff; + const fullContextError = + contextMode === "full" && fullContextDiffQuery.error + ? fullContextDiffQuery.error instanceof Error + ? fullContextDiffQuery.error.message + : "Failed to load full-file context." + : null; return (
)} + { + const next = value[0]; + if (next === "patch" || next === "full") { + onContextModeChange(filePath, next); + } + }} + > + + Patch + + + Full + +