From e8307de5a83109c96d1528eb3857ddf5253f4060 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Tue, 28 Apr 2026 23:21:09 +0200 Subject: [PATCH 1/3] Add hide whitespace option for diffs --- apps/desktop/src/clientPersistence.test.ts | 1 + .../orchestrationEngine.integration.test.ts | 2 + .../Layers/CheckpointDiffQuery.test.ts | 12 ++- .../Layers/CheckpointDiffQuery.ts | 3 + .../Layers/CheckpointStore.test.ts | 76 +++++++++++++++++++ .../checkpointing/Layers/CheckpointStore.ts | 12 ++- .../checkpointing/Services/CheckpointStore.ts | 1 + .../orchestration/Layers/CheckpointReactor.ts | 1 + apps/web/src/components/DiffPanel.tsx | 18 ++++- .../components/settings/SettingsPanels.tsx | 30 ++++++++ apps/web/src/lib/providerReactQuery.test.ts | 58 +++++++++++++- apps/web/src/lib/providerReactQuery.ts | 4 + apps/web/src/localApi.test.ts | 2 + packages/contracts/src/orchestration.test.ts | 25 ++++++ packages/contracts/src/orchestration.ts | 6 +- packages/contracts/src/settings.ts | 2 + 16 files changed, 247 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index 192d7ac106..fcd1786029 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -52,6 +52,7 @@ const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + diffIgnoreWhitespace: true, diffWordWrap: true, favorites: [], sidebarProjectGroupingMode: "repository_path", diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index a7f845672c..e97844d338 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -496,6 +496,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 1), toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2), fallbackFromToHead: false, + ignoreWhitespace: false, }); assert.equal(incrementalDiff.includes("README.md"), true); @@ -504,6 +505,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 0), toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2), fallbackFromToHead: false, + ignoreWhitespace: false, }); assert.equal(fullDiff.includes("README.md"), true); diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index e6c53dd06b..81aa6d563b 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -48,6 +48,7 @@ describe("CheckpointDiffQueryLive", () => { readonly fromCheckpointRef: CheckpointRef; readonly toCheckpointRef: CheckpointRef; readonly cwd: string; + readonly ignoreWhitespace: boolean; }> = []; const threadCheckpointContext = makeThreadCheckpointContext({ @@ -68,9 +69,14 @@ describe("CheckpointDiffQueryLive", () => { return true; }), restoreCheckpoint: () => Effect.succeed(true), - diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd }) => + diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => Effect.sync(() => { - diffCheckpointsCalls.push({ fromCheckpointRef, toCheckpointRef, cwd }); + diffCheckpointsCalls.push({ + fromCheckpointRef, + toCheckpointRef, + cwd, + ignoreWhitespace, + }); return "diff patch"; }), deleteCheckpointRefs: () => Effect.void, @@ -102,6 +108,7 @@ describe("CheckpointDiffQueryLive", () => { threadId, fromTurnCount: 0, toTurnCount: 1, + ignoreWhitespace: true, }); }).pipe(Effect.provide(layer)), ); @@ -113,6 +120,7 @@ describe("CheckpointDiffQueryLive", () => { cwd: "/tmp/workspace", fromCheckpointRef: expectedFromRef, toCheckpointRef, + ignoreWhitespace: true, }, ]); expect(result).toEqual({ diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts index 1c2edee469..cb1433c21c 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts @@ -24,6 +24,7 @@ const make = Effect.gen(function* () { const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = Effect.fn("getTurnDiff")( function* (input) { const operation = "CheckpointDiffQuery.getTurnDiff"; + const ignoreWhitespace = input.ignoreWhitespace ?? false; if (input.fromTurnCount === input.toTurnCount) { const emptyDiff: OrchestrationGetTurnDiffResultType = { @@ -131,6 +132,7 @@ const make = Effect.gen(function* () { fromCheckpointRef, toCheckpointRef, fallbackFromToHead: false, + ignoreWhitespace, }); const turnDiff: OrchestrationGetTurnDiffResultType = { @@ -157,6 +159,7 @@ const make = Effect.gen(function* () { threadId: input.threadId, fromTurnCount: 0, toTurnCount: input.toTurnCount, + ignoreWhitespace: input.ignoreWhitespace ?? false, }).pipe(Effect.map((result): OrchestrationGetFullThreadDiffResult => result)); return { diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts index fe377eb1ec..e7bb40bc03 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -111,6 +111,7 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { cwd: tmp, fromCheckpointRef, toCheckpointRef, + ignoreWhitespace: false, }); expect(diff).toContain("diff --git"); @@ -118,5 +119,80 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { expect(diff).toContain("+line 04999"); }), ); + + it.effect("can hide indentation churn when changes wrap existing lines", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore; + const threadId = ThreadId.make("thread-checkpoint-store-whitespace"); + const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + + const componentPath = path.join(tmp, "Component.tsx"); + yield* writeTextFile( + componentPath, + [ + "export function View() {", + " return (", + "
", + "

Title

", + "

Body

", + "
", + " );", + "}", + "", + ].join("\n"), + ); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: fromCheckpointRef, + }); + yield* writeTextFile( + componentPath, + [ + "export function View() {", + " return (", + "
", + " {isReady ? (", + "
", + "

Title

", + "

Body

", + "
", + " ) : null}", + "
", + " );", + "}", + "", + ].join("\n"), + ); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: toCheckpointRef, + }); + + const normalDiff = yield* checkpointStore.diffCheckpoints({ + cwd: tmp, + fromCheckpointRef, + toCheckpointRef, + ignoreWhitespace: false, + }); + const whitespaceIgnoredDiff = yield* checkpointStore.diffCheckpoints({ + cwd: tmp, + fromCheckpointRef, + toCheckpointRef, + ignoreWhitespace: true, + }); + + expect(normalDiff).toContain("diff --git"); + expect(normalDiff).toContain("-

Title

"); + expect(normalDiff).toContain("+

Title

"); + expect(whitespaceIgnoredDiff).toContain("diff --git"); + expect(whitespaceIgnoredDiff).toContain("+ {isReady ? ("); + expect(whitespaceIgnoredDiff).toContain("+
"); + expect(whitespaceIgnoredDiff).not.toContain("-

Title

"); + expect(whitespaceIgnoredDiff).not.toContain("+

Title

"); + }), + ); }); }); diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index 211877e9b1..d1efe475e0 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -243,10 +243,20 @@ const makeCheckpointStore = Effect.gen(function* () { }); } + const diffArgs = [ + "diff", + "--patch", + "--minimal", + "--no-color", + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + fromCommitOid, + toCommitOid, + ]; + const result = yield* git.execute({ operation, cwd: input.cwd, - args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid], + args: diffArgs, maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, }); diff --git a/apps/server/src/checkpointing/Services/CheckpointStore.ts b/apps/server/src/checkpointing/Services/CheckpointStore.ts index d9a43fa4e9..be6bdbccfa 100644 --- a/apps/server/src/checkpointing/Services/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Services/CheckpointStore.ts @@ -32,6 +32,7 @@ export interface DiffCheckpointsInput { readonly fromCheckpointRef: CheckpointRef; readonly toCheckpointRef: CheckpointRef; readonly fallbackFromToHead?: boolean; + readonly ignoreWhitespace: boolean; } export interface DeleteCheckpointRefsInput { diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 71445b4671..80caf99d2b 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -237,6 +237,7 @@ const make = Effect.gen(function* () { fromCheckpointRef, toCheckpointRef: targetCheckpointRef, fallbackFromToHead: false, + ignoreWhitespace: false, }) .pipe( Effect.map((diff) => diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index e6dbb57cc7..28db53e917 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -8,6 +8,7 @@ import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, + PilcrowIcon, Rows3Icon, TextWrapIcon, } from "lucide-react"; @@ -172,6 +173,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const settings = useSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); + const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); const patchViewportRef = useRef(null); const turnStripRef = useRef(null); const previousDiffOpenRef = useRef(false); @@ -277,6 +279,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { threadId: activeThreadId, fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, toTurnCount: activeCheckpointRange?.toTurnCount ?? null, + ignoreWhitespace: diffIgnoreWhitespace, cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, enabled: isGitRepo, }), @@ -317,9 +320,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { useEffect(() => { if (diffOpen && !previousDiffOpenRef.current) { setDiffWordWrap(settings.diffWordWrap); + setDiffIgnoreWhitespace(settings.diffIgnoreWhitespace); } previousDiffOpenRef.current = diffOpen; - }, [diffOpen, settings.diffWordWrap]); + }, [diffOpen, settings.diffIgnoreWhitespace, settings.diffWordWrap]); useEffect(() => { if (!selectedFilePath || !patchViewportRef.current) { @@ -551,6 +555,18 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { > + { + setDiffIgnoreWhitespace(Boolean(pressed)); + }} + > + +
); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 29d09a5cdc..092c3bd307 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -472,6 +472,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap ? ["Diff line wrapping"] : []), + ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace + ? ["Diff whitespace changes"] + : []), ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar ? ["Task sidebar"] : []), @@ -501,6 +504,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadDelete, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, + settings.diffIgnoreWhitespace, settings.diffWordWrap, settings.enableAssistantStreaming, settings.timestampFormat, @@ -922,6 +926,32 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, + }) + } + /> + ) : null + } + control={ + + updateSettings({ diffIgnoreWhitespace: Boolean(checked) }) + } + aria-label="Hide whitespace changes by default" + /> + } + /> + { threadId, fromTurnCount: 1, toTurnCount: 2, + ignoreWhitespace: false, } as const; expect( @@ -44,10 +45,58 @@ describe("providerQueryKeys.checkpointDiff", () => { }), ); }); + + it("includes ignoreWhitespace so normal and whitespace-hidden diffs do not collide", () => { + const baseInput = { + environmentId, + threadId, + fromTurnCount: 1, + toTurnCount: 2, + cacheScope: "turn:abc", + } as const; + + expect( + providerQueryKeys.checkpointDiff({ + ...baseInput, + ignoreWhitespace: false, + }), + ).not.toEqual( + providerQueryKeys.checkpointDiff({ + ...baseInput, + ignoreWhitespace: true, + }), + ); + }); }); describe("checkpointDiffQueryOptions", () => { - it("forwards checkpoint range to the provider API", async () => { + it("forwards checkpoint range to the provider API by default", async () => { + const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); + const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); + mockNativeApi({ getTurnDiff, getFullThreadDiff }); + + const options = checkpointDiffQueryOptions({ + environmentId, + threadId, + fromTurnCount: 3, + toTurnCount: 4, + ignoreWhitespace: false, + cacheScope: "turn:abc", + }); + + const queryClient = new QueryClient(); + await queryClient.fetchQuery(options); + + expect(getTurnDiff).toHaveBeenCalledWith({ + threadId, + fromTurnCount: 3, + toTurnCount: 4, + ignoreWhitespace: false, + }); + expect(getFullThreadDiff).not.toHaveBeenCalled(); + }); + + it("forwards whitespace-hidden checkpoint range to the provider API", async () => { const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); mockNativeApi({ getTurnDiff, getFullThreadDiff }); @@ -57,6 +106,7 @@ describe("checkpointDiffQueryOptions", () => { threadId, fromTurnCount: 3, toTurnCount: 4, + ignoreWhitespace: true, cacheScope: "turn:abc", }); @@ -67,6 +117,7 @@ describe("checkpointDiffQueryOptions", () => { threadId, fromTurnCount: 3, toTurnCount: 4, + ignoreWhitespace: true, }); expect(getFullThreadDiff).not.toHaveBeenCalled(); }); @@ -81,6 +132,7 @@ describe("checkpointDiffQueryOptions", () => { threadId, fromTurnCount: 0, toTurnCount: 2, + ignoreWhitespace: true, cacheScope: "thread:all", }); @@ -90,6 +142,7 @@ describe("checkpointDiffQueryOptions", () => { expect(getFullThreadDiff).toHaveBeenCalledWith({ threadId, toTurnCount: 2, + ignoreWhitespace: true, }); expect(getTurnDiff).not.toHaveBeenCalled(); }); @@ -104,6 +157,7 @@ describe("checkpointDiffQueryOptions", () => { threadId, fromTurnCount: 4, toTurnCount: 3, + ignoreWhitespace: false, cacheScope: "turn:invalid", }); @@ -122,6 +176,7 @@ describe("checkpointDiffQueryOptions", () => { threadId, fromTurnCount: 1, toTurnCount: 2, + ignoreWhitespace: false, cacheScope: "turn:abc", }); const retry = options.retry; @@ -147,6 +202,7 @@ describe("checkpointDiffQueryOptions", () => { threadId, fromTurnCount: 1, toTurnCount: 2, + ignoreWhitespace: false, cacheScope: "turn:abc", }); const retryDelay = options.retryDelay; diff --git a/apps/web/src/lib/providerReactQuery.ts b/apps/web/src/lib/providerReactQuery.ts index 20007fc8fb..663d618b92 100644 --- a/apps/web/src/lib/providerReactQuery.ts +++ b/apps/web/src/lib/providerReactQuery.ts @@ -13,6 +13,7 @@ interface CheckpointDiffQueryInput { threadId: ThreadId | null; fromTurnCount: number | null; toTurnCount: number | null; + ignoreWhitespace: boolean; cacheScope?: string | null; enabled?: boolean; } @@ -27,6 +28,7 @@ export const providerQueryKeys = { input.threadId, input.fromTurnCount, input.toTurnCount, + input.ignoreWhitespace, input.cacheScope ?? null, ] as const, }; @@ -36,6 +38,7 @@ function decodeCheckpointDiffRequest(input: CheckpointDiffQueryInput) { return Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput)({ threadId: input.threadId, toTurnCount: input.toTurnCount, + ignoreWhitespace: input.ignoreWhitespace, }).pipe(Option.map((fields) => ({ kind: "fullThreadDiff" as const, input: fields }))); } @@ -43,6 +46,7 @@ function decodeCheckpointDiffRequest(input: CheckpointDiffQueryInput) { threadId: input.threadId, fromTurnCount: input.fromTurnCount, toTurnCount: input.toTurnCount, + ignoreWhitespace: input.ignoreWhitespace, }).pipe(Option.map((fields) => ({ kind: "turnDiff" as const, input: fields }))); } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index c361cbd787..78e38efa8b 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -532,6 +532,7 @@ describe("wsApi", () => { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + diffIgnoreWhitespace: true, diffWordWrap: true, favorites: [], sidebarProjectGroupingMode: "repository_path" as const, @@ -591,6 +592,7 @@ describe("wsApi", () => { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + diffIgnoreWhitespace: true, diffWordWrap: true, favorites: [], sidebarProjectGroupingMode: "repository_path" as const, diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 190e09aa63..71781bc847 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -7,6 +7,7 @@ import { DEFAULT_RUNTIME_MODE, OrchestrationCommand, OrchestrationEvent, + OrchestrationGetFullThreadDiffInput, OrchestrationGetTurnDiffInput, OrchestrationLatestTurn, ProjectCreatedPayload, @@ -22,6 +23,7 @@ import { } from "./orchestration.ts"; const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); +const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput); const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload); @@ -57,6 +59,29 @@ it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => }), ); +it.effect("parses turn diff input with whitespace ignoring enabled", () => + Effect.gen(function* () { + const parsed = yield* decodeTurnDiffInput({ + threadId: "thread-1", + fromTurnCount: 1, + toTurnCount: 2, + ignoreWhitespace: true, + }); + assert.strictEqual(parsed.ignoreWhitespace, true); + }), +); + +it.effect("parses full thread diff input with whitespace ignoring enabled", () => + Effect.gen(function* () { + const parsed = yield* decodeFullThreadDiffInput({ + threadId: "thread-1", + toTurnCount: 2, + ignoreWhitespace: true, + }); + assert.strictEqual(parsed.ignoreWhitespace, true); + }), +); + it.effect("rejects turn diff input when fromTurnCount > toTurnCount", () => Effect.gen(function* () { const result = yield* Effect.exit( diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c201d6c82a..a463141fcc 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1140,7 +1140,10 @@ export const DispatchResult = Schema.Struct({ export type DispatchResult = typeof DispatchResult.Type; export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields( - Struct.assign({ threadId: ThreadId }), + Struct.assign({ + threadId: ThreadId, + ignoreWhitespace: Schema.optionalKey(Schema.Boolean), + }), { unsafePreserveChecks: true }, ); export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput.Type; @@ -1151,6 +1154,7 @@ export type OrchestrationGetTurnDiffResult = typeof OrchestrationGetTurnDiffResu export const OrchestrationGetFullThreadDiffInput = Schema.Struct({ threadId: ThreadId, toTurnCount: NonNegativeInt, + ignoreWhitespace: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationGetFullThreadDiffInput = typeof OrchestrationGetFullThreadDiffInput.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2b50957a79..7a1451a55d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -34,6 +34,7 @@ export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), favorites: Schema.Array( Schema.Struct({ @@ -247,6 +248,7 @@ export const ClientSettingsPatch = Schema.Struct({ autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), + diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), diffWordWrap: Schema.optionalKey(Schema.Boolean), favorites: Schema.optionalKey( Schema.Array( From 3c726e438520b3c877f292654d138e26d6baef13 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Wed, 29 Apr 2026 21:05:55 +0200 Subject: [PATCH 2/3] default to true + test --- .../Layers/CheckpointDiffQuery.test.ts | 61 +++++++++++++++++++ .../Layers/CheckpointDiffQuery.ts | 4 +- .../Layers/CheckpointStore.test.ts | 2 +- packages/contracts/src/settings.ts | 2 +- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 81aa6d563b..645568d734 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -131,6 +131,67 @@ describe("CheckpointDiffQueryLive", () => { }); }); + it("defaults to hide whitespace changes", async () => { + const projectId = ProjectId.make("project-default-whitespace"); + const threadId = ThreadId.make("thread-default-whitespace"); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + const diffCheckpointsCalls: Array<{ readonly ignoreWhitespace: boolean }> = []; + + const threadCheckpointContext = makeThreadCheckpointContext({ + projectId, + threadId, + workspaceRoot: "/tmp/workspace", + worktreePath: null, + checkpointTurnCount: 1, + checkpointRef: toCheckpointRef, + }); + + const checkpointStore: CheckpointStoreShape = { + isGitRepository: () => Effect.succeed(true), + captureCheckpoint: () => Effect.void, + hasCheckpointRef: () => Effect.succeed(true), + restoreCheckpoint: () => Effect.succeed(true), + diffCheckpoints: ({ ignoreWhitespace }) => + Effect.sync(() => { + diffCheckpointsCalls.push({ ignoreWhitespace }); + return "diff patch"; + }), + deleteCheckpointRefs: () => Effect.void, + }; + + const layer = CheckpointDiffQueryLive.pipe( + Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), + Layer.provideMerge( + Layer.succeed(ProjectionSnapshotQuery, { + getSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), + }), + ), + ); + + await Effect.runPromise( + Effect.gen(function* () { + const query = yield* CheckpointDiffQuery; + return yield* query.getTurnDiff({ + threadId, + fromTurnCount: 0, + toTurnCount: 1, + }); + }).pipe(Effect.provide(layer)), + ); + + expect(diffCheckpointsCalls).toEqual([{ ignoreWhitespace: true }]); + }); + it("fails when the thread is missing from the snapshot", async () => { const threadId = ThreadId.make("thread-missing"); diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts index cb1433c21c..c6b1513471 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts @@ -24,7 +24,7 @@ const make = Effect.gen(function* () { const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = Effect.fn("getTurnDiff")( function* (input) { const operation = "CheckpointDiffQuery.getTurnDiff"; - const ignoreWhitespace = input.ignoreWhitespace ?? false; + const ignoreWhitespace = input.ignoreWhitespace ?? true; if (input.fromTurnCount === input.toTurnCount) { const emptyDiff: OrchestrationGetTurnDiffResultType = { @@ -159,7 +159,7 @@ const make = Effect.gen(function* () { threadId: input.threadId, fromTurnCount: 0, toTurnCount: input.toTurnCount, - ignoreWhitespace: input.ignoreWhitespace ?? false, + ignoreWhitespace: input.ignoreWhitespace ?? true, }).pipe(Effect.map((result): OrchestrationGetFullThreadDiffResult => result)); return { diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts index e7bb40bc03..16bae3d5c6 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -111,7 +111,7 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { cwd: tmp, fromCheckpointRef, toCheckpointRef, - ignoreWhitespace: false, + ignoreWhitespace: true, }); expect(diff).toContain("diff --git"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7a1451a55d..1ed312c7b2 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -34,7 +34,7 @@ export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), - diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), favorites: Schema.Array( Schema.Struct({ From 27d3b21daec75cf6fa08171b7b8819edf9762aae Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Sun, 3 May 2026 14:50:01 +0200 Subject: [PATCH 3/3] fix merge --- apps/server/src/checkpointing/Layers/CheckpointStore.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index 94b63a80cf..7efe4cc0a5 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -269,6 +269,7 @@ const makeCheckpointStore = Effect.gen(function* () { toCommitOid, ]; + const result = yield* vcs.execute({ operation, cwd: input.cwd, args: diffArgs,