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 = {...@@ -52,6 +52,7 @@ const clientSettings: ClientSettings = {
52 autoOpenPlanSidebar: false,52 autoOpenPlanSidebar: false,
53 confirmThreadArchive: true,53 confirmThreadArchive: true,
54 confirmThreadDelete: false,54 confirmThreadDelete: false,
55 diffIgnoreWhitespace: true,
55 diffWordWrap: true,56 diffWordWrap: true,
56 favorites: [],57 favorites: [],
57 providerModelPreferences: {},58 providerModelPreferences: {},
apps/server/integration/orchestrationEngine.integration.test.ts+2
...@@ -506,6 +506,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>...@@ -506,6 +506,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
506 fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 1),506 fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 1),
507 toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2),507 toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2),
508 fallbackFromToHead: false,508 fallbackFromToHead: false,
509 ignoreWhitespace: false,
509 });510 });
510 assert.equal(incrementalDiff.includes("README.md"), true);511 assert.equal(incrementalDiff.includes("README.md"), true);
511512
...@@ -514,6 +515,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>...@@ -514,6 +515,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
514 fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 0),515 fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 0),
515 toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2),516 toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2),
516 fallbackFromToHead: false,517 fallbackFromToHead: false,
518 ignoreWhitespace: false,
517 });519 });
518 assert.equal(fullDiff.includes("README.md"), true);520 assert.equal(fullDiff.includes("README.md"), true);
519521
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+71-2
...@@ -48,6 +48,7 @@ describe("CheckpointDiffQueryLive", () => {...@@ -48,6 +48,7 @@ describe("CheckpointDiffQueryLive", () => {
48 readonly fromCheckpointRef: CheckpointRef;48 readonly fromCheckpointRef: CheckpointRef;
49 readonly toCheckpointRef: CheckpointRef;49 readonly toCheckpointRef: CheckpointRef;
50 readonly cwd: string;50 readonly cwd: string;
51 readonly ignoreWhitespace: boolean;
51 }> = [];52 }> = [];
5253
53 const threadCheckpointContext = makeThreadCheckpointContext({54 const threadCheckpointContext = makeThreadCheckpointContext({
...@@ -68,9 +69,14 @@ describe("CheckpointDiffQueryLive", () => {...@@ -68,9 +69,14 @@ describe("CheckpointDiffQueryLive", () => {
68 return true;69 return true;
69 }),70 }),
70 restoreCheckpoint: () => Effect.succeed(true),71 restoreCheckpoint: () => Effect.succeed(true),
71 diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd }) =>72 diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) =>
72 Effect.sync(() => {73 Effect.sync(() => {
73 diffCheckpointsCalls.push({ fromCheckpointRef, toCheckpointRef, cwd });74 diffCheckpointsCalls.push({
75 fromCheckpointRef,
76 toCheckpointRef,
77 cwd,
78 ignoreWhitespace,
79 });
74 return "diff patch";80 return "diff patch";
75 }),81 }),
76 deleteCheckpointRefs: () => Effect.void,82 deleteCheckpointRefs: () => Effect.void,
...@@ -102,6 +108,7 @@ describe("CheckpointDiffQueryLive", () => {...@@ -102,6 +108,7 @@ describe("CheckpointDiffQueryLive", () => {
102 threadId,108 threadId,
103 fromTurnCount: 0,109 fromTurnCount: 0,
104 toTurnCount: 1,110 toTurnCount: 1,
111 ignoreWhitespace: true,
105 });112 });
106 }).pipe(Effect.provide(layer)),113 }).pipe(Effect.provide(layer)),
107 );114 );
...@@ -113,6 +120,7 @@ describe("CheckpointDiffQueryLive", () => {...@@ -113,6 +120,7 @@ describe("CheckpointDiffQueryLive", () => {
113 cwd: "/tmp/workspace",120 cwd: "/tmp/workspace",
114 fromCheckpointRef: expectedFromRef,121 fromCheckpointRef: expectedFromRef,
115 toCheckpointRef,122 toCheckpointRef,
123 ignoreWhitespace: true,
116 },124 },
117 ]);125 ]);
118 expect(result).toEqual({126 expect(result).toEqual({
...@@ -123,6 +131,67 @@ describe("CheckpointDiffQueryLive", () => {...@@ -123,6 +131,67 @@ describe("CheckpointDiffQueryLive", () => {
123 });131 });
124 });132 });
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
126 it("fails when the thread is missing from the snapshot", async () => {195 it("fails when the thread is missing from the snapshot", async () => {
127 const threadId = ThreadId.make("thread-missing");196 const threadId = ThreadId.make("thread-missing");
128197
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts+3
...@@ -24,6 +24,7 @@ const make = Effect.gen(function* () {...@@ -24,6 +24,7 @@ const make = Effect.gen(function* () {
24 const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = Effect.fn("getTurnDiff")(24 const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = Effect.fn("getTurnDiff")(
25 function* (input) {25 function* (input) {
26 const operation = "CheckpointDiffQuery.getTurnDiff";26 const operation = "CheckpointDiffQuery.getTurnDiff";
27 const ignoreWhitespace = input.ignoreWhitespace ?? true;
2728
28 if (input.fromTurnCount === input.toTurnCount) {29 if (input.fromTurnCount === input.toTurnCount) {
29 const emptyDiff: OrchestrationGetTurnDiffResultType = {30 const emptyDiff: OrchestrationGetTurnDiffResultType = {
...@@ -131,6 +132,7 @@ const make = Effect.gen(function* () {...@@ -131,6 +132,7 @@ const make = Effect.gen(function* () {
131 fromCheckpointRef,132 fromCheckpointRef,
132 toCheckpointRef,133 toCheckpointRef,
133 fallbackFromToHead: false,134 fallbackFromToHead: false,
135 ignoreWhitespace,
134 });136 });
135137
136 const turnDiff: OrchestrationGetTurnDiffResultType = {138 const turnDiff: OrchestrationGetTurnDiffResultType = {
...@@ -157,6 +159,7 @@ const make = Effect.gen(function* () {...@@ -157,6 +159,7 @@ const make = Effect.gen(function* () {
157 threadId: input.threadId,159 threadId: input.threadId,
158 fromTurnCount: 0,160 fromTurnCount: 0,
159 toTurnCount: input.toTurnCount,161 toTurnCount: input.toTurnCount,
162 ignoreWhitespace: input.ignoreWhitespace ?? true,
160 }).pipe(Effect.map((result): OrchestrationGetFullThreadDiffResult => result));163 }).pipe(Effect.map((result): OrchestrationGetFullThreadDiffResult => result));
161164
162 return {165 return {
apps/server/src/checkpointing/Layers/CheckpointStore.test.ts+76
...@@ -114,6 +114,7 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => {...@@ -114,6 +114,7 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => {
114 cwd: tmp,114 cwd: tmp,
115 fromCheckpointRef,115 fromCheckpointRef,
116 toCheckpointRef,116 toCheckpointRef,
117 ignoreWhitespace: true,
117 });118 });
118119
119 expect(diff).toContain("diff --git");120 expect(diff).toContain("diff --git");
...@@ -121,5 +122,80 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => {...@@ -121,5 +122,80 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => {
121 expect(diff).toContain("+line 04999");122 expect(diff).toContain("+line 04999");
122 }),123 }),
123 );124 );
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 );
124 });200 });
125});201});
apps/server/src/checkpointing/Layers/CheckpointStore.ts+11-1
...@@ -259,10 +259,20 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -259,10 +259,20 @@ const makeCheckpointStore = Effect.gen(function* () {
259 });259 });
260 }260 }
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
262 const result = yield* vcs.execute({272 const result = yield* vcs.execute({
263 operation,273 operation,
264 cwd: input.cwd,274 cwd: input.cwd,
265 args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid],275 args: diffArgs,
266 maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,276 maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,
267 });277 });
268278
apps/server/src/checkpointing/Services/CheckpointStore.ts+1
...@@ -32,6 +32,7 @@ export interface DiffCheckpointsInput {...@@ -32,6 +32,7 @@ export interface DiffCheckpointsInput {
32 readonly fromCheckpointRef: CheckpointRef;32 readonly fromCheckpointRef: CheckpointRef;
33 readonly toCheckpointRef: CheckpointRef;33 readonly toCheckpointRef: CheckpointRef;
34 readonly fallbackFromToHead?: boolean;34 readonly fallbackFromToHead?: boolean;
35 readonly ignoreWhitespace: boolean;
35}36}
3637
37export interface DeleteCheckpointRefsInput {38export interface DeleteCheckpointRefsInput {
apps/server/src/orchestration/Layers/CheckpointReactor.ts+1
...@@ -237,6 +237,7 @@ const make = Effect.gen(function* () {...@@ -237,6 +237,7 @@ const make = Effect.gen(function* () {
237 fromCheckpointRef,237 fromCheckpointRef,
238 toCheckpointRef: targetCheckpointRef,238 toCheckpointRef: targetCheckpointRef,
239 fallbackFromToHead: false,239 fallbackFromToHead: false,
240 ignoreWhitespace: false,
240 })241 })
241 .pipe(242 .pipe(
242 Effect.map((diff) =>243 Effect.map((diff) =>
apps/web/src/components/DiffPanel.tsx+17-1
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 ChevronLeftIcon,8 ChevronLeftIcon,
9 ChevronRightIcon,9 ChevronRightIcon,
10 Columns2Icon,10 Columns2Icon,
11 PilcrowIcon,
11 Rows3Icon,12 Rows3Icon,
12 TextWrapIcon,13 TextWrapIcon,
13} from "lucide-react";14} from "lucide-react";
...@@ -172,6 +173,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -172,6 +173,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
172 const settings = useSettings();173 const settings = useSettings();
173 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");174 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
174 const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);175 const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);
176 const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace);
175 const patchViewportRef = useRef<HTMLDivElement>(null);177 const patchViewportRef = useRef<HTMLDivElement>(null);
176 const turnStripRef = useRef<HTMLDivElement>(null);178 const turnStripRef = useRef<HTMLDivElement>(null);
177 const previousDiffOpenRef = useRef(false);179 const previousDiffOpenRef = useRef(false);
...@@ -277,6 +279,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -277,6 +279,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
277 threadId: activeThreadId,279 threadId: activeThreadId,
278 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,280 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
279 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,281 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
282 ignoreWhitespace: diffIgnoreWhitespace,
280 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,283 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,
281 enabled: isGitRepo,284 enabled: isGitRepo,
282 }),285 }),
...@@ -317,9 +320,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -317,9 +320,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
317 useEffect(() => {320 useEffect(() => {
318 if (diffOpen && !previousDiffOpenRef.current) {321 if (diffOpen && !previousDiffOpenRef.current) {
319 setDiffWordWrap(settings.diffWordWrap);322 setDiffWordWrap(settings.diffWordWrap);
323 setDiffIgnoreWhitespace(settings.diffIgnoreWhitespace);
320 }324 }
321 previousDiffOpenRef.current = diffOpen;325 previousDiffOpenRef.current = diffOpen;
322 }, [diffOpen, settings.diffWordWrap]);326 }, [diffOpen, settings.diffIgnoreWhitespace, settings.diffWordWrap]);
323327
324 useEffect(() => {328 useEffect(() => {
325 if (!selectedFilePath || !patchViewportRef.current) {329 if (!selectedFilePath || !patchViewportRef.current) {
...@@ -551,6 +555,18 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -551,6 +555,18 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
551 >555 >
552 <TextWrapIcon className="size-3" />556 <TextWrapIcon className="size-3" />
553 </Toggle>557 </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>
554 </div>570 </div>
555 </>571 </>
556 );572 );
apps/web/src/components/settings/SettingsPanels.tsx+30
...@@ -386,6 +386,9 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -386,6 +386,9 @@ export function useSettingsRestore(onRestored?: () => void) {
386 ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap386 ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap
387 ? ["Diff line wrapping"]387 ? ["Diff line wrapping"]
388 : []),388 : []),
389 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
390 ? ["Diff whitespace changes"]
391 : []),
389 ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar392 ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar
390 ? ["Auto-open task panel"]393 ? ["Auto-open task panel"]
391 : []),394 : []),
...@@ -415,6 +418,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -415,6 +418,7 @@ export function useSettingsRestore(onRestored?: () => void) {
415 settings.confirmThreadDelete,418 settings.confirmThreadDelete,
416 settings.addProjectBaseDirectory,419 settings.addProjectBaseDirectory,
417 settings.defaultThreadEnvMode,420 settings.defaultThreadEnvMode,
421 settings.diffIgnoreWhitespace,
418 settings.diffWordWrap,422 settings.diffWordWrap,
419 settings.enableAssistantStreaming,423 settings.enableAssistantStreaming,
420 settings.timestampFormat,424 settings.timestampFormat,
...@@ -889,6 +893,32 @@ export function GeneralSettingsPanel() {...@@ -889,6 +893,32 @@ export function GeneralSettingsPanel() {
889 }893 }
890 />894 />
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
892 <SettingsRow922 <SettingsRow
893 title="Assistant output"923 title="Assistant output"
894 description="Show token-by-token output while a response is in progress."924 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", () => {...@@ -30,6 +30,7 @@ describe("providerQueryKeys.checkpointDiff", () => {
30 threadId,30 threadId,
31 fromTurnCount: 1,31 fromTurnCount: 1,
32 toTurnCount: 2,32 toTurnCount: 2,
33 ignoreWhitespace: false,
33 } as const;34 } as const;
3435
35 expect(36 expect(
...@@ -44,10 +45,58 @@ describe("providerQueryKeys.checkpointDiff", () => {...@@ -44,10 +45,58 @@ describe("providerQueryKeys.checkpointDiff", () => {
44 }),45 }),
45 );46 );
46 });47 });
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 });
47});70});
4871
49describe("checkpointDiffQueryOptions", () => {72describe("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 () => {
51 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });100 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });
52 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });101 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });
53 mockNativeApi({ getTurnDiff, getFullThreadDiff });102 mockNativeApi({ getTurnDiff, getFullThreadDiff });
...@@ -57,6 +106,7 @@ describe("checkpointDiffQueryOptions", () => {...@@ -57,6 +106,7 @@ describe("checkpointDiffQueryOptions", () => {
57 threadId,106 threadId,
58 fromTurnCount: 3,107 fromTurnCount: 3,
59 toTurnCount: 4,108 toTurnCount: 4,
109 ignoreWhitespace: true,
60 cacheScope: "turn:abc",110 cacheScope: "turn:abc",
61 });111 });
62112
...@@ -67,6 +117,7 @@ describe("checkpointDiffQueryOptions", () => {...@@ -67,6 +117,7 @@ describe("checkpointDiffQueryOptions", () => {
67 threadId,117 threadId,
68 fromTurnCount: 3,118 fromTurnCount: 3,
69 toTurnCount: 4,119 toTurnCount: 4,
120 ignoreWhitespace: true,
70 });121 });
71 expect(getFullThreadDiff).not.toHaveBeenCalled();122 expect(getFullThreadDiff).not.toHaveBeenCalled();
72 });123 });
...@@ -81,6 +132,7 @@ describe("checkpointDiffQueryOptions", () => {...@@ -81,6 +132,7 @@ describe("checkpointDiffQueryOptions", () => {
81 threadId,132 threadId,
82 fromTurnCount: 0,133 fromTurnCount: 0,
83 toTurnCount: 2,134 toTurnCount: 2,
135 ignoreWhitespace: true,
84 cacheScope: "thread:all",136 cacheScope: "thread:all",
85 });137 });
86138
...@@ -90,6 +142,7 @@ describe("checkpointDiffQueryOptions", () => {...@@ -90,6 +142,7 @@ describe("checkpointDiffQueryOptions", () => {
90 expect(getFullThreadDiff).toHaveBeenCalledWith({142 expect(getFullThreadDiff).toHaveBeenCalledWith({
91 threadId,143 threadId,
92 toTurnCount: 2,144 toTurnCount: 2,
145 ignoreWhitespace: true,
93 });146 });
94 expect(getTurnDiff).not.toHaveBeenCalled();147 expect(getTurnDiff).not.toHaveBeenCalled();
95 });148 });
...@@ -104,6 +157,7 @@ describe("checkpointDiffQueryOptions", () => {...@@ -104,6 +157,7 @@ describe("checkpointDiffQueryOptions", () => {
104 threadId,157 threadId,
105 fromTurnCount: 4,158 fromTurnCount: 4,
106 toTurnCount: 3,159 toTurnCount: 3,
160 ignoreWhitespace: false,
107 cacheScope: "turn:invalid",161 cacheScope: "turn:invalid",
108 });162 });
109163
...@@ -122,6 +176,7 @@ describe("checkpointDiffQueryOptions", () => {...@@ -122,6 +176,7 @@ describe("checkpointDiffQueryOptions", () => {
122 threadId,176 threadId,
123 fromTurnCount: 1,177 fromTurnCount: 1,
124 toTurnCount: 2,178 toTurnCount: 2,
179 ignoreWhitespace: false,
125 cacheScope: "turn:abc",180 cacheScope: "turn:abc",
126 });181 });
127 const retry = options.retry;182 const retry = options.retry;
...@@ -147,6 +202,7 @@ describe("checkpointDiffQueryOptions", () => {...@@ -147,6 +202,7 @@ describe("checkpointDiffQueryOptions", () => {
147 threadId,202 threadId,
148 fromTurnCount: 1,203 fromTurnCount: 1,
149 toTurnCount: 2,204 toTurnCount: 2,
205 ignoreWhitespace: false,
150 cacheScope: "turn:abc",206 cacheScope: "turn:abc",
151 });207 });
152 const retryDelay = options.retryDelay;208 const retryDelay = options.retryDelay;
apps/web/src/lib/providerReactQuery.ts+4
...@@ -13,6 +13,7 @@ interface CheckpointDiffQueryInput {...@@ -13,6 +13,7 @@ interface CheckpointDiffQueryInput {
13 threadId: ThreadId | null;13 threadId: ThreadId | null;
14 fromTurnCount: number | null;14 fromTurnCount: number | null;
15 toTurnCount: number | null;15 toTurnCount: number | null;
16 ignoreWhitespace: boolean;
16 cacheScope?: string | null;17 cacheScope?: string | null;
17 enabled?: boolean;18 enabled?: boolean;
18}19}
...@@ -27,6 +28,7 @@ export const providerQueryKeys = {...@@ -27,6 +28,7 @@ export const providerQueryKeys = {
27 input.threadId,28 input.threadId,
28 input.fromTurnCount,29 input.fromTurnCount,
29 input.toTurnCount,30 input.toTurnCount,
31 input.ignoreWhitespace,
30 input.cacheScope ?? null,32 input.cacheScope ?? null,
31 ] as const,33 ] as const,
32};34};
...@@ -36,6 +38,7 @@ function decodeCheckpointDiffRequest(input: CheckpointDiffQueryInput) {...@@ -36,6 +38,7 @@ function decodeCheckpointDiffRequest(input: CheckpointDiffQueryInput) {
36 return Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput)({38 return Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput)({
37 threadId: input.threadId,39 threadId: input.threadId,
38 toTurnCount: input.toTurnCount,40 toTurnCount: input.toTurnCount,
41 ignoreWhitespace: input.ignoreWhitespace,
39 }).pipe(Option.map((fields) => ({ kind: "fullThreadDiff" as const, input: fields })));42 }).pipe(Option.map((fields) => ({ kind: "fullThreadDiff" as const, input: fields })));
40 }43 }
4144
...@@ -43,6 +46,7 @@ function decodeCheckpointDiffRequest(input: CheckpointDiffQueryInput) {...@@ -43,6 +46,7 @@ function decodeCheckpointDiffRequest(input: CheckpointDiffQueryInput) {
43 threadId: input.threadId,46 threadId: input.threadId,
44 fromTurnCount: input.fromTurnCount,47 fromTurnCount: input.fromTurnCount,
45 toTurnCount: input.toTurnCount,48 toTurnCount: input.toTurnCount,
49 ignoreWhitespace: input.ignoreWhitespace,
46 }).pipe(Option.map((fields) => ({ kind: "turnDiff" as const, input: fields })));50 }).pipe(Option.map((fields) => ({ kind: "turnDiff" as const, input: fields })));
47}51}
4852
apps/web/src/localApi.test.ts+2
...@@ -537,6 +537,7 @@ describe("wsApi", () => {...@@ -537,6 +537,7 @@ describe("wsApi", () => {
537 autoOpenPlanSidebar: false,537 autoOpenPlanSidebar: false,
538 confirmThreadArchive: true,538 confirmThreadArchive: true,
539 confirmThreadDelete: false,539 confirmThreadDelete: false,
540 diffIgnoreWhitespace: true,
540 diffWordWrap: true,541 diffWordWrap: true,
541 favorites: [],542 favorites: [],
542 providerModelPreferences: {},543 providerModelPreferences: {},
...@@ -597,6 +598,7 @@ describe("wsApi", () => {...@@ -597,6 +598,7 @@ describe("wsApi", () => {
597 autoOpenPlanSidebar: false,598 autoOpenPlanSidebar: false,
598 confirmThreadArchive: true,599 confirmThreadArchive: true,
599 confirmThreadDelete: false,600 confirmThreadDelete: false,
601 diffIgnoreWhitespace: true,
600 diffWordWrap: true,602 diffWordWrap: true,
601 favorites: [],603 favorites: [],
602 providerModelPreferences: {},604 providerModelPreferences: {},
packages/contracts/src/orchestration.test.ts+25
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 ModelSelection,8 ModelSelection,
9 OrchestrationCommand,9 OrchestrationCommand,
10 OrchestrationEvent,10 OrchestrationEvent,
11 OrchestrationGetFullThreadDiffInput,
11 OrchestrationGetTurnDiffInput,12 OrchestrationGetTurnDiffInput,
12 OrchestrationLatestTurn,13 OrchestrationLatestTurn,
13 ProjectCreatedPayload,14 ProjectCreatedPayload,
...@@ -24,6 +25,7 @@ import {...@@ -24,6 +25,7 @@ import {
24import { ProviderInstanceId } from "./providerInstance.ts";25import { ProviderInstanceId } from "./providerInstance.ts";
2526
26const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);27const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
28const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput);
27const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);29const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);
28const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);30const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);
29const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload);31const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload);
...@@ -59,6 +61,29 @@ it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>...@@ -59,6 +61,29 @@ it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>
59 }),61 }),
60);62);
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
62it.effect("rejects turn diff input when fromTurnCount > toTurnCount", () =>87it.effect("rejects turn diff input when fromTurnCount > toTurnCount", () =>
63 Effect.gen(function* () {88 Effect.gen(function* () {
64 const result = yield* Effect.exit(89 const result = yield* Effect.exit(
packages/contracts/src/orchestration.ts+5-1
...@@ -1171,7 +1171,10 @@ export const DispatchResult = Schema.Struct({...@@ -1171,7 +1171,10 @@ export const DispatchResult = Schema.Struct({
1171export type DispatchResult = typeof DispatchResult.Type;1171export type DispatchResult = typeof DispatchResult.Type;
11721172
1173export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields(1173export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields(
1174 Struct.assign({ threadId: ThreadId }),1174 Struct.assign({
1175 threadId: ThreadId,
1176 ignoreWhitespace: Schema.optionalKey(Schema.Boolean),
1177 }),
1175 { unsafePreserveChecks: true },1178 { unsafePreserveChecks: true },
1176);1179);
1177export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput.Type;1180export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput.Type;
...@@ -1182,6 +1185,7 @@ export type OrchestrationGetTurnDiffResult = typeof OrchestrationGetTurnDiffResu...@@ -1182,6 +1185,7 @@ export type OrchestrationGetTurnDiffResult = typeof OrchestrationGetTurnDiffResu
1182export const OrchestrationGetFullThreadDiffInput = Schema.Struct({1185export const OrchestrationGetFullThreadDiffInput = Schema.Struct({
1183 threadId: ThreadId,1186 threadId: ThreadId,
1184 toTurnCount: NonNegativeInt,1187 toTurnCount: NonNegativeInt,
1188 ignoreWhitespace: Schema.optionalKey(Schema.Boolean),
1185});1189});
1186export type OrchestrationGetFullThreadDiffInput = typeof OrchestrationGetFullThreadDiffInput.Type;1190export type OrchestrationGetFullThreadDiffInput = typeof OrchestrationGetFullThreadDiffInput.Type;
11871191
packages/contracts/src/settings.ts+2
...@@ -32,6 +32,7 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -32,6 +32,7 @@ export const ClientSettingsSchema = Schema.Struct({
32 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),32 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
33 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),33 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
34 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),34 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
35 diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
35 diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),36 diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
36 // Model favorites. Historically keyed by provider kind, now37 // Model favorites. Historically keyed by provider kind, now
37 // widened to `ProviderInstanceId` so users can favorite a specific model38 // widened to `ProviderInstanceId` so users can favorite a specific model
...@@ -451,6 +452,7 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -451,6 +452,7 @@ export const ClientSettingsPatch = Schema.Struct({
451 autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean),452 autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean),
452 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),453 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
453 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),454 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
455 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
454 diffWordWrap: Schema.optionalKey(Schema.Boolean),456 diffWordWrap: Schema.optionalKey(Schema.Boolean),
455 favorites: Schema.optionalKey(457 favorites: Schema.optionalKey(
456 Schema.Array(458 Schema.Array(