authorgravatar for hottdogg2008@gmail.comIllia Panasenko <hottdogg2008@gmail.com> 2026-05-03 20:47:37+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-03 11:47:37-07:00
logf7748a0d656f918080828130aceda039457897e0
tree7d3d8a25cc6e724863d4e1bc3c75de65b692e182
parent0ce7e56e436ba31e2f0b51f3203a09758c866cd1
signature Signed by PGP key B5690EEEBB952194

feat: add "hide whitespace changes" option for diffs (#2389)


16 files changed, 308 insertions(+), 6 deletions(-)

apps/desktop/src/clientPersistence.test.ts+1
......@@ -52,6 +52,7 @@ const clientSettings: ClientSettings = {
5252 autoOpenPlanSidebar: false,
5353 confirmThreadArchive: true,
5454 confirmThreadDelete: false,
55 diffIgnoreWhitespace: true,
5556 diffWordWrap: true,
5657 favorites: [],
5758 providerModelPreferences: {},
apps/server/integration/orchestrationEngine.integration.test.ts+2
......@@ -506,6 +506,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
506506 fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 1),
507507 toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2),
508508 fallbackFromToHead: false,
509 ignoreWhitespace: false,
509510 });
510511 assert.equal(incrementalDiff.includes("README.md"), true);
511512
......@@ -514,6 +515,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
514515 fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 0),
515516 toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2),
516517 fallbackFromToHead: false,
518 ignoreWhitespace: false,
517519 });
518520 assert.equal(fullDiff.includes("README.md"), true);
519521
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+71-2
......@@ -48,6 +48,7 @@ describe("CheckpointDiffQueryLive", () => {
4848 readonly fromCheckpointRef: CheckpointRef;
4949 readonly toCheckpointRef: CheckpointRef;
5050 readonly cwd: string;
51 readonly ignoreWhitespace: boolean;
5152 }> = [];
5253
5354 const threadCheckpointContext = makeThreadCheckpointContext({
......@@ -68,9 +69,14 @@ describe("CheckpointDiffQueryLive", () => {
6869 return true;
6970 }),
7071 restoreCheckpoint: () => Effect.succeed(true),
71 diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd }) =>
72 diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) =>
7273 Effect.sync(() => {
73 diffCheckpointsCalls.push({ fromCheckpointRef, toCheckpointRef, cwd });
74 diffCheckpointsCalls.push({
75 fromCheckpointRef,
76 toCheckpointRef,
77 cwd,
78 ignoreWhitespace,
79 });
7480 return "diff patch";
7581 }),
7682 deleteCheckpointRefs: () => Effect.void,
......@@ -102,6 +108,7 @@ describe("CheckpointDiffQueryLive", () => {
102108 threadId,
103109 fromTurnCount: 0,
104110 toTurnCount: 1,
111 ignoreWhitespace: true,
105112 });
106113 }).pipe(Effect.provide(layer)),
107114 );
......@@ -113,6 +120,7 @@ describe("CheckpointDiffQueryLive", () => {
113120 cwd: "/tmp/workspace",
114121 fromCheckpointRef: expectedFromRef,
115122 toCheckpointRef,
123 ignoreWhitespace: true,
116124 },
117125 ]);
118126 expect(result).toEqual({
......@@ -123,6 +131,67 @@ describe("CheckpointDiffQueryLive", () => {
123131 });
124132 });
125133
134 it("defaults to hide whitespace changes", async () => {
135 const projectId = ProjectId.make("project-default-whitespace");
136 const threadId = ThreadId.make("thread-default-whitespace");
137 const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1);
138 const diffCheckpointsCalls: Array<{ readonly ignoreWhitespace: boolean }> = [];
139
140 const threadCheckpointContext = makeThreadCheckpointContext({
141 projectId,
142 threadId,
143 workspaceRoot: "/tmp/workspace",
144 worktreePath: null,
145 checkpointTurnCount: 1,
146 checkpointRef: toCheckpointRef,
147 });
148
149 const checkpointStore: CheckpointStoreShape = {
150 isGitRepository: () => Effect.succeed(true),
151 captureCheckpoint: () => Effect.void,
152 hasCheckpointRef: () => Effect.succeed(true),
153 restoreCheckpoint: () => Effect.succeed(true),
154 diffCheckpoints: ({ ignoreWhitespace }) =>
155 Effect.sync(() => {
156 diffCheckpointsCalls.push({ ignoreWhitespace });
157 return "diff patch";
158 }),
159 deleteCheckpointRefs: () => Effect.void,
160 };
161
162 const layer = CheckpointDiffQueryLive.pipe(
163 Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)),
164 Layer.provideMerge(
165 Layer.succeed(ProjectionSnapshotQuery, {
166 getSnapshot: () =>
167 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
168 getShellSnapshot: () =>
169 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
170 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
171 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
172 getProjectShellById: () => Effect.succeed(Option.none()),
173 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
174 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
175 getThreadShellById: () => Effect.succeed(Option.none()),
176 getThreadDetailById: () => Effect.succeed(Option.none()),
177 }),
178 ),
179 );
180
181 await Effect.runPromise(
182 Effect.gen(function* () {
183 const query = yield* CheckpointDiffQuery;
184 return yield* query.getTurnDiff({
185 threadId,
186 fromTurnCount: 0,
187 toTurnCount: 1,
188 });
189 }).pipe(Effect.provide(layer)),
190 );
191
192 expect(diffCheckpointsCalls).toEqual([{ ignoreWhitespace: true }]);
193 });
194
126195 it("fails when the thread is missing from the snapshot", async () => {
127196 const threadId = ThreadId.make("thread-missing");
128197
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts+3
......@@ -24,6 +24,7 @@ const make = Effect.gen(function* () {
2424 const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = Effect.fn("getTurnDiff")(
2525 function* (input) {
2626 const operation = "CheckpointDiffQuery.getTurnDiff";
27 const ignoreWhitespace = input.ignoreWhitespace ?? true;
2728
2829 if (input.fromTurnCount === input.toTurnCount) {
2930 const emptyDiff: OrchestrationGetTurnDiffResultType = {
......@@ -131,6 +132,7 @@ const make = Effect.gen(function* () {
131132 fromCheckpointRef,
132133 toCheckpointRef,
133134 fallbackFromToHead: false,
135 ignoreWhitespace,
134136 });
135137
136138 const turnDiff: OrchestrationGetTurnDiffResultType = {
......@@ -157,6 +159,7 @@ const make = Effect.gen(function* () {
157159 threadId: input.threadId,
158160 fromTurnCount: 0,
159161 toTurnCount: input.toTurnCount,
162 ignoreWhitespace: input.ignoreWhitespace ?? true,
160163 }).pipe(Effect.map((result): OrchestrationGetFullThreadDiffResult => result));
161164
162165 return {
apps/server/src/checkpointing/Layers/CheckpointStore.test.ts+76
......@@ -114,6 +114,7 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => {
114114 cwd: tmp,
115115 fromCheckpointRef,
116116 toCheckpointRef,
117 ignoreWhitespace: true,
117118 });
118119
119120 expect(diff).toContain("diff --git");
......@@ -121,5 +122,80 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => {
121122 expect(diff).toContain("+line 04999");
122123 }),
123124 );
125
126 it.effect("can hide indentation churn when changes wrap existing lines", () =>
127 Effect.gen(function* () {
128 const tmp = yield* makeTmpDir();
129 yield* initRepoWithCommit(tmp);
130 const checkpointStore = yield* CheckpointStore;
131 const threadId = ThreadId.make("thread-checkpoint-store-whitespace");
132 const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0);
133 const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1);
134
135 const componentPath = path.join(tmp, "Component.tsx");
136 yield* writeTextFile(
137 componentPath,
138 [
139 "export function View() {",
140 " return (",
141 " <section>",
142 " <h1>Title</h1>",
143 " <p>Body</p>",
144 " </section>",
145 " );",
146 "}",
147 "",
148 ].join("\n"),
149 );
150 yield* checkpointStore.captureCheckpoint({
151 cwd: tmp,
152 checkpointRef: fromCheckpointRef,
153 });
154 yield* writeTextFile(
155 componentPath,
156 [
157 "export function View() {",
158 " return (",
159 " <section>",
160 " {isReady ? (",
161 " <div>",
162 " <h1>Title</h1>",
163 " <p>Body</p>",
164 " </div>",
165 " ) : null}",
166 " </section>",
167 " );",
168 "}",
169 "",
170 ].join("\n"),
171 );
172 yield* checkpointStore.captureCheckpoint({
173 cwd: tmp,
174 checkpointRef: toCheckpointRef,
175 });
176
177 const normalDiff = yield* checkpointStore.diffCheckpoints({
178 cwd: tmp,
179 fromCheckpointRef,
180 toCheckpointRef,
181 ignoreWhitespace: false,
182 });
183 const whitespaceIgnoredDiff = yield* checkpointStore.diffCheckpoints({
184 cwd: tmp,
185 fromCheckpointRef,
186 toCheckpointRef,
187 ignoreWhitespace: true,
188 });
189
190 expect(normalDiff).toContain("diff --git");
191 expect(normalDiff).toContain("- <h1>Title</h1>");
192 expect(normalDiff).toContain("+ <h1>Title</h1>");
193 expect(whitespaceIgnoredDiff).toContain("diff --git");
194 expect(whitespaceIgnoredDiff).toContain("+ {isReady ? (");
195 expect(whitespaceIgnoredDiff).toContain("+ <div>");
196 expect(whitespaceIgnoredDiff).not.toContain("- <h1>Title</h1>");
197 expect(whitespaceIgnoredDiff).not.toContain("+ <h1>Title</h1>");
198 }),
199 );
124200 });
125201});
apps/server/src/checkpointing/Layers/CheckpointStore.ts+11-1
......@@ -259,10 +259,20 @@ const makeCheckpointStore = Effect.gen(function* () {
259259 });
260260 }
261261
262 const diffArgs = [
263 "diff",
264 "--patch",
265 "--minimal",
266 "--no-color",
267 ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []),
268 fromCommitOid,
269 toCommitOid,
270 ];
271
262272 const result = yield* vcs.execute({
263273 operation,
264274 cwd: input.cwd,
265 args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid],
275 args: diffArgs,
266276 maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,
267277 });
268278
apps/server/src/checkpointing/Services/CheckpointStore.ts+1
......@@ -32,6 +32,7 @@ export interface DiffCheckpointsInput {
3232 readonly fromCheckpointRef: CheckpointRef;
3333 readonly toCheckpointRef: CheckpointRef;
3434 readonly fallbackFromToHead?: boolean;
35 readonly ignoreWhitespace: boolean;
3536}
3637
3738export interface DeleteCheckpointRefsInput {
apps/server/src/orchestration/Layers/CheckpointReactor.ts+1
......@@ -237,6 +237,7 @@ const make = Effect.gen(function* () {
237237 fromCheckpointRef,
238238 toCheckpointRef: targetCheckpointRef,
239239 fallbackFromToHead: false,
240 ignoreWhitespace: false,
240241 })
241242 .pipe(
242243 Effect.map((diff) =>
apps/web/src/components/DiffPanel.tsx+17-1
......@@ -8,6 +8,7 @@ import {
88 ChevronLeftIcon,
99 ChevronRightIcon,
1010 Columns2Icon,
11 PilcrowIcon,
1112 Rows3Icon,
1213 TextWrapIcon,
1314} from "lucide-react";
......@@ -172,6 +173,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
172173 const settings = useSettings();
173174 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
174175 const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);
176 const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace);
175177 const patchViewportRef = useRef<HTMLDivElement>(null);
176178 const turnStripRef = useRef<HTMLDivElement>(null);
177179 const previousDiffOpenRef = useRef(false);
......@@ -277,6 +279,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
277279 threadId: activeThreadId,
278280 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
279281 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
282 ignoreWhitespace: diffIgnoreWhitespace,
280283 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,
281284 enabled: isGitRepo,
282285 }),
......@@ -317,9 +320,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
317320 useEffect(() => {
318321 if (diffOpen && !previousDiffOpenRef.current) {
319322 setDiffWordWrap(settings.diffWordWrap);
323 setDiffIgnoreWhitespace(settings.diffIgnoreWhitespace);
320324 }
321325 previousDiffOpenRef.current = diffOpen;
322 }, [diffOpen, settings.diffWordWrap]);
326 }, [diffOpen, settings.diffIgnoreWhitespace, settings.diffWordWrap]);
323327
324328 useEffect(() => {
325329 if (!selectedFilePath || !patchViewportRef.current) {
......@@ -551,6 +555,18 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
551555 >
552556 <TextWrapIcon className="size-3" />
553557 </Toggle>
558 <Toggle
559 aria-label={diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}
560 title={diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}
561 variant="outline"
562 size="xs"
563 pressed={diffIgnoreWhitespace}
564 onPressedChange={(pressed) => {
565 setDiffIgnoreWhitespace(Boolean(pressed));
566 }}
567 >
568 <PilcrowIcon className="size-3" />
569 </Toggle>
554570 </div>
555571 </>
556572 );
apps/web/src/components/settings/SettingsPanels.tsx+30
......@@ -386,6 +386,9 @@ export function useSettingsRestore(onRestored?: () => void) {
386386 ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap
387387 ? ["Diff line wrapping"]
388388 : []),
389 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
390 ? ["Diff whitespace changes"]
391 : []),
389392 ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar
390393 ? ["Auto-open task panel"]
391394 : []),
......@@ -415,6 +418,7 @@ export function useSettingsRestore(onRestored?: () => void) {
415418 settings.confirmThreadDelete,
416419 settings.addProjectBaseDirectory,
417420 settings.defaultThreadEnvMode,
421 settings.diffIgnoreWhitespace,
418422 settings.diffWordWrap,
419423 settings.enableAssistantStreaming,
420424 settings.timestampFormat,
......@@ -889,6 +893,32 @@ export function GeneralSettingsPanel() {
889893 }
890894 />
891895
896 <SettingsRow
897 title="Hide whitespace changes"
898 description="Set whether the diff panel ignores whitespace-only edits by default."
899 resetAction={
900 settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? (
901 <SettingResetButton
902 label="diff whitespace changes"
903 onClick={() =>
904 updateSettings({
905 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
906 })
907 }
908 />
909 ) : null
910 }
911 control={
912 <Switch
913 checked={settings.diffIgnoreWhitespace}
914 onCheckedChange={(checked) =>
915 updateSettings({ diffIgnoreWhitespace: Boolean(checked) })
916 }
917 aria-label="Hide whitespace changes by default"
918 />
919 }
920 />
921
892922 <SettingsRow
893923 title="Assistant output"
894924 description="Show token-by-token output while a response is in progress."
apps/web/src/lib/providerReactQuery.test.ts+57-1
......@@ -30,6 +30,7 @@ describe("providerQueryKeys.checkpointDiff", () => {
3030 threadId,
3131 fromTurnCount: 1,
3232 toTurnCount: 2,
33 ignoreWhitespace: false,
3334 } as const;
3435
3536 expect(
......@@ -44,10 +45,58 @@ describe("providerQueryKeys.checkpointDiff", () => {
4445 }),
4546 );
4647 });
48
49 it("includes ignoreWhitespace so normal and whitespace-hidden diffs do not collide", () => {
50 const baseInput = {
51 environmentId,
52 threadId,
53 fromTurnCount: 1,
54 toTurnCount: 2,
55 cacheScope: "turn:abc",
56 } as const;
57
58 expect(
59 providerQueryKeys.checkpointDiff({
60 ...baseInput,
61 ignoreWhitespace: false,
62 }),
63 ).not.toEqual(
64 providerQueryKeys.checkpointDiff({
65 ...baseInput,
66 ignoreWhitespace: true,
67 }),
68 );
69 });
4770});
4871
4972describe("checkpointDiffQueryOptions", () => {
50 it("forwards checkpoint range to the provider API", async () => {
73 it("forwards checkpoint range to the provider API by default", async () => {
74 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });
75 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });
76 mockNativeApi({ getTurnDiff, getFullThreadDiff });
77
78 const options = checkpointDiffQueryOptions({
79 environmentId,
80 threadId,
81 fromTurnCount: 3,
82 toTurnCount: 4,
83 ignoreWhitespace: false,
84 cacheScope: "turn:abc",
85 });
86
87 const queryClient = new QueryClient();
88 await queryClient.fetchQuery(options);
89
90 expect(getTurnDiff).toHaveBeenCalledWith({
91 threadId,
92 fromTurnCount: 3,
93 toTurnCount: 4,
94 ignoreWhitespace: false,
95 });
96 expect(getFullThreadDiff).not.toHaveBeenCalled();
97 });
98
99 it("forwards whitespace-hidden checkpoint range to the provider API", async () => {
51100 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });
52101 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });
53102 mockNativeApi({ getTurnDiff, getFullThreadDiff });
......@@ -57,6 +106,7 @@ describe("checkpointDiffQueryOptions", () => {
57106 threadId,
58107 fromTurnCount: 3,
59108 toTurnCount: 4,
109 ignoreWhitespace: true,
60110 cacheScope: "turn:abc",
61111 });
62112
......@@ -67,6 +117,7 @@ describe("checkpointDiffQueryOptions", () => {
67117 threadId,
68118 fromTurnCount: 3,
69119 toTurnCount: 4,
120 ignoreWhitespace: true,
70121 });
71122 expect(getFullThreadDiff).not.toHaveBeenCalled();
72123 });
......@@ -81,6 +132,7 @@ describe("checkpointDiffQueryOptions", () => {
81132 threadId,
82133 fromTurnCount: 0,
83134 toTurnCount: 2,
135 ignoreWhitespace: true,
84136 cacheScope: "thread:all",
85137 });
86138
......@@ -90,6 +142,7 @@ describe("checkpointDiffQueryOptions", () => {
90142 expect(getFullThreadDiff).toHaveBeenCalledWith({
91143 threadId,
92144 toTurnCount: 2,
145 ignoreWhitespace: true,
93146 });
94147 expect(getTurnDiff).not.toHaveBeenCalled();
95148 });
......@@ -104,6 +157,7 @@ describe("checkpointDiffQueryOptions", () => {
104157 threadId,
105158 fromTurnCount: 4,
106159 toTurnCount: 3,
160 ignoreWhitespace: false,
107161 cacheScope: "turn:invalid",
108162 });
109163
......@@ -122,6 +176,7 @@ describe("checkpointDiffQueryOptions", () => {
122176 threadId,
123177 fromTurnCount: 1,
124178 toTurnCount: 2,
179 ignoreWhitespace: false,
125180 cacheScope: "turn:abc",
126181 });
127182 const retry = options.retry;
......@@ -147,6 +202,7 @@ describe("checkpointDiffQueryOptions", () => {
147202 threadId,
148203 fromTurnCount: 1,
149204 toTurnCount: 2,
205 ignoreWhitespace: false,
150206 cacheScope: "turn:abc",
151207 });
152208 const retryDelay = options.retryDelay;
apps/web/src/lib/providerReactQuery.ts+4
......@@ -13,6 +13,7 @@ interface CheckpointDiffQueryInput {
1313 threadId: ThreadId | null;
1414 fromTurnCount: number | null;
1515 toTurnCount: number | null;
16 ignoreWhitespace: boolean;
1617 cacheScope?: string | null;
1718 enabled?: boolean;
1819}
......@@ -27,6 +28,7 @@ export const providerQueryKeys = {
2728 input.threadId,
2829 input.fromTurnCount,
2930 input.toTurnCount,
31 input.ignoreWhitespace,
3032 input.cacheScope ?? null,
3133 ] as const,
3234};
......@@ -36,6 +38,7 @@ function decodeCheckpointDiffRequest(input: CheckpointDiffQueryInput) {
3638 return Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput)({
3739 threadId: input.threadId,
3840 toTurnCount: input.toTurnCount,
41 ignoreWhitespace: input.ignoreWhitespace,
3942 }).pipe(Option.map((fields) => ({ kind: "fullThreadDiff" as const, input: fields })));
4043 }
4144
......@@ -43,6 +46,7 @@ function decodeCheckpointDiffRequest(input: CheckpointDiffQueryInput) {
4346 threadId: input.threadId,
4447 fromTurnCount: input.fromTurnCount,
4548 toTurnCount: input.toTurnCount,
49 ignoreWhitespace: input.ignoreWhitespace,
4650 }).pipe(Option.map((fields) => ({ kind: "turnDiff" as const, input: fields })));
4751}
4852
apps/web/src/localApi.test.ts+2
......@@ -537,6 +537,7 @@ describe("wsApi", () => {
537537 autoOpenPlanSidebar: false,
538538 confirmThreadArchive: true,
539539 confirmThreadDelete: false,
540 diffIgnoreWhitespace: true,
540541 diffWordWrap: true,
541542 favorites: [],
542543 providerModelPreferences: {},
......@@ -597,6 +598,7 @@ describe("wsApi", () => {
597598 autoOpenPlanSidebar: false,
598599 confirmThreadArchive: true,
599600 confirmThreadDelete: false,
601 diffIgnoreWhitespace: true,
600602 diffWordWrap: true,
601603 favorites: [],
602604 providerModelPreferences: {},
packages/contracts/src/orchestration.test.ts+25
......@@ -8,6 +8,7 @@ import {
88 ModelSelection,
99 OrchestrationCommand,
1010 OrchestrationEvent,
11 OrchestrationGetFullThreadDiffInput,
1112 OrchestrationGetTurnDiffInput,
1213 OrchestrationLatestTurn,
1314 ProjectCreatedPayload,
......@@ -24,6 +25,7 @@ import {
2425import { ProviderInstanceId } from "./providerInstance.ts";
2526
2627const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
28const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput);
2729const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);
2830const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);
2931const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload);
......@@ -59,6 +61,29 @@ it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>
5961 }),
6062);
6163
64it.effect("parses turn diff input with whitespace ignoring enabled", () =>
65 Effect.gen(function* () {
66 const parsed = yield* decodeTurnDiffInput({
67 threadId: "thread-1",
68 fromTurnCount: 1,
69 toTurnCount: 2,
70 ignoreWhitespace: true,
71 });
72 assert.strictEqual(parsed.ignoreWhitespace, true);
73 }),
74);
75
76it.effect("parses full thread diff input with whitespace ignoring enabled", () =>
77 Effect.gen(function* () {
78 const parsed = yield* decodeFullThreadDiffInput({
79 threadId: "thread-1",
80 toTurnCount: 2,
81 ignoreWhitespace: true,
82 });
83 assert.strictEqual(parsed.ignoreWhitespace, true);
84 }),
85);
86
6287it.effect("rejects turn diff input when fromTurnCount > toTurnCount", () =>
6388 Effect.gen(function* () {
6489 const result = yield* Effect.exit(
packages/contracts/src/orchestration.ts+5-1
......@@ -1171,7 +1171,10 @@ export const DispatchResult = Schema.Struct({
11711171export type DispatchResult = typeof DispatchResult.Type;
11721172
11731173export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields(
1174 Struct.assign({ threadId: ThreadId }),
1174 Struct.assign({
1175 threadId: ThreadId,
1176 ignoreWhitespace: Schema.optionalKey(Schema.Boolean),
1177 }),
11751178 { unsafePreserveChecks: true },
11761179);
11771180export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput.Type;
......@@ -1182,6 +1185,7 @@ export type OrchestrationGetTurnDiffResult = typeof OrchestrationGetTurnDiffResu
11821185export const OrchestrationGetFullThreadDiffInput = Schema.Struct({
11831186 threadId: ThreadId,
11841187 toTurnCount: NonNegativeInt,
1188 ignoreWhitespace: Schema.optionalKey(Schema.Boolean),
11851189});
11861190export type OrchestrationGetFullThreadDiffInput = typeof OrchestrationGetFullThreadDiffInput.Type;
11871191
packages/contracts/src/settings.ts+2
......@@ -32,6 +32,7 @@ export const ClientSettingsSchema = Schema.Struct({
3232 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
3333 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
3434 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
35 diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
3536 diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
3637 // Model favorites. Historically keyed by provider kind, now
3738 // widened to `ProviderInstanceId` so users can favorite a specific model
......@@ -451,6 +452,7 @@ export const ClientSettingsPatch = Schema.Struct({
451452 autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean),
452453 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
453454 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
455 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
454456 diffWordWrap: Schema.optionalKey(Schema.Boolean),
455457 favorites: Schema.optionalKey(
456458 Schema.Array(