From f7748a0d656f918080828130aceda039457897e0 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Sun, 3 May 2026 20:47:37 +0200 Subject: [PATCH] feat: add "hide whitespace changes" option for diffs (#2389) --- apps/desktop/src/clientPersistence.test.ts | 1 + .../orchestrationEngine.integration.test.ts | 2 + .../Layers/CheckpointDiffQuery.test.ts | 73 +++++++++++++++++- .../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, 308 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index 71b6b7df4e8246b3e9a416c006986f7f13402b36..a3cfec5ed33b8b3843360dd17b68ca02375f0c17 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: [], providerModelPreferences: {}, diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 6ea4d849086902ea75542944be2e42e5a5156ba2..6bd948198391788d506b838991c6cf0bbd9ce7de 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -506,6 +506,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); @@ -514,6 +515,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 e6c53dd06b02e0b26dc5ee778a27a8ff86b603fc..645568d734ddc3851cb15b2801670733e1d48de2 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({ @@ -123,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 1c2edee469e675baf84595c13d4011f026eac335..c6b15134711446e324fbe0c948770e4b967128c6 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 ?? true; 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 ?? 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 3673959484241631bb73b96b68e10238275a0495..204fd5657405b879d7bc1a0efe9fe62575f63247 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -114,6 +114,7 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { cwd: tmp, fromCheckpointRef, toCheckpointRef, + ignoreWhitespace: true, }); expect(diff).toContain("diff --git"); @@ -121,5 +122,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 c8b5956460481a14f9b5b2ddc08d02fe8bc1ffbf..7efe4cc0a56c3e4d540292ea4e48d4bd44fee8ff 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -259,10 +259,20 @@ const makeCheckpointStore = Effect.gen(function* () { }); } + const diffArgs = [ + "diff", + "--patch", + "--minimal", + "--no-color", + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + fromCommitOid, + toCommitOid, + ]; + const result = yield* vcs.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 d9a43fa4e9524f7cee7a23b80e65ee46bc4fc4b7..be6bdbccfaa13e1e0f43c11bb58c9bd28e120fd3 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 15677feaaf72c6ac59a5981ce6af54141b8202a8..e534f6851aeda85138244634bd44d4e2c353a7de 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 e6dbb57cc7df5769b92dff29fc2e9748e460f677..28db53e917ef349344819cf363da05b210bb532d 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 af904f562d932ea859395894d6afefec5f4b05bf..8fc36d4a32b63b0ce5a462f6f5da799aa53d25e0 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -386,6 +386,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 ? ["Auto-open task panel"] : []), @@ -415,6 +418,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadDelete, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, + settings.diffIgnoreWhitespace, settings.diffWordWrap, settings.enableAssistantStreaming, settings.timestampFormat, @@ -889,6 +893,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,32 @@ 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 }); @@ -57,6 +80,7 @@ describe("checkpointDiffQueryOptions", () => { threadId, fromTurnCount: 3, toTurnCount: 4, + ignoreWhitespace: false, cacheScope: "turn:abc", }); @@ -67,6 +91,33 @@ describe("checkpointDiffQueryOptions", () => { 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 }); + + const options = checkpointDiffQueryOptions({ + environmentId, + threadId, + fromTurnCount: 3, + toTurnCount: 4, + ignoreWhitespace: true, + cacheScope: "turn:abc", + }); + + const queryClient = new QueryClient(); + await queryClient.fetchQuery(options); + + expect(getTurnDiff).toHaveBeenCalledWith({ + 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 20007fc8fb679bb57c9aad66590ef6623f00c243..663d618b92ebd9356f0b9dd6f06903048fc63319 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 b627286199c7120f8e31c79266c69958169b8e30..47a791806a082b1d94bd2db0cebebb8bcfa9e2eb 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -537,6 +537,7 @@ describe("wsApi", () => { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + diffIgnoreWhitespace: true, diffWordWrap: true, favorites: [], providerModelPreferences: {}, @@ -597,6 +598,7 @@ describe("wsApi", () => { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + diffIgnoreWhitespace: true, diffWordWrap: true, favorites: [], providerModelPreferences: {}, diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 559f3016e0f65fa20d170ab8f3b62146cd7f3ffd..605d037534233216c5843f423d2f9388cc7cf9c0 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -8,6 +8,7 @@ import { ModelSelection, OrchestrationCommand, OrchestrationEvent, + OrchestrationGetFullThreadDiffInput, OrchestrationGetTurnDiffInput, OrchestrationLatestTurn, ProjectCreatedPayload, @@ -24,6 +25,7 @@ import { import { ProviderInstanceId } from "./providerInstance.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); @@ -59,6 +61,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 19160c0b6047f80c988c8d7208802baa7af5f895..047ef9e5cd9ac378f0e485afa0884a34badec7a6 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1171,7 +1171,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; @@ -1182,6 +1185,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 087b031402cb3eed37910fb2df2a2a078c9937f0..90b9099d17786effe396400cd089c75bf697d07a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -32,6 +32,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(true))), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model @@ -451,6 +452,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( -- 2.54.0