authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-02 20:16:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 20:16:25-07:00
log9409dd20a9fbce491d49d09c79b289d8fb8bfe3e
treee9e39232d05cca883563024b7ce8bb2c15aa4741
parent12f1fc427efad4b683835a72bda6f74bebb5d641
signature Signed by PGP key B5690EEEBB952194

fix(web): make the diff layout toggle a persisted setting (#9326)

Co-authored-by: Claude Code <noreply@anthropic.com>

9 files changed, 83 insertions(+), 48 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+1
......@@ -29,6 +29,7 @@ const clientSettings: ClientSettings = {
2929 contextWindowMeterEnabled: false,
3030 dismissedProviderUpdateNotificationKeys: [],
3131 diffIgnoreWhitespace: true,
32 diffLayout: "stacked",
3233 environmentIdentificationMode: "artwork",
3334 favorites: [],
3435 fontFamilyCode: "",
apps/web/src/clientPersistenceStorage.test.ts+13
......@@ -90,4 +90,17 @@ describe("clientPersistenceStorage", () => {
9090 expect(settings).not.toHaveProperty("chatWordWrap");
9191 expect(settings).not.toHaveProperty("diffWordWrap");
9292 });
93
94 it("keeps the diff layout across reloads and defaults it to stacked", async () => {
95 const testWindow = getTestWindow();
96 const { readBrowserClientSettings, writeBrowserClientSettings } =
97 await import("./clientPersistenceStorage");
98
99 expect(readBrowserClientSettings()).toBeNull();
100 testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify({}));
101 expect(readBrowserClientSettings()?.diffLayout).toBe("stacked");
102
103 writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffLayout: "split" });
104 expect(readBrowserClientSettings()?.diffLayout).toBe("split");
105 });
93106});
apps/web/src/components/DiffPanel.tsx+6-6
......@@ -44,7 +44,7 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
4444import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh";
4545import { useProject, useThread } from "../state/entities";
4646import { resolveThreadRouteRef } from "../threadRoutes";
47import { useClientSettings } from "../hooks/useSettings";
47import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings";
4848import { formatShortTimestamp } from "../timestampFormat";
4949import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton";
5050import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
......@@ -108,8 +108,8 @@ export default function DiffPanel({
108108 const { resolvedTheme } = useTheme();
109109 const settings = useClientSettings();
110110 const [initialGitScope] = useState(initialGitScopeProp);
111 const diffRenderMode = useDiffPanelStore((state) => state.diffRenderMode);
112 const setDiffRenderMode = useDiffPanelStore((state) => state.setDiffRenderMode);
111 const diffLayout = settings.diffLayout;
112 const updateClientSettings = useUpdateClientSettings();
113113 const [wordWrap, setWordWrap] = useState(settings.wordWrap);
114114 const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace);
115115 const [baseRefQuery, setBaseRefQuery] = useState("");
......@@ -768,11 +768,11 @@ export default function DiffPanel({
768768 <ToggleGroup
769769 className="shrink-0 gap-1"
770770 size="sm"
771 value={[diffRenderMode]}
771 value={[diffLayout]}
772772 onValueChange={(value) => {
773773 const next = value[0];
774774 if (next === "stacked" || next === "split") {
775 setDiffRenderMode(next);
775 updateClientSettings({ diffLayout: next });
776776 }
777777 }}
778778 >
......@@ -959,7 +959,7 @@ export default function DiffPanel({
959959 );
960960 }}
961961 options={{
962 diffStyle: diffRenderMode === "split" ? "split" : "unified",
962 diffStyle: diffLayout === "split" ? "split" : "unified",
963963 lineDiffType: "none",
964964 overflow: wordWrap ? "wrap" : "scroll",
965965 theme: resolveDiffThemeName(resolvedTheme),
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+7-14
......@@ -26,7 +26,7 @@ import {
2626import { useAtomRefresh } from "@effect/atom-react";
2727import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2828
29import { useClientSettings } from "~/hooks/useSettings";
29import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
3030import { useTheme } from "~/hooks/useTheme";
3131import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse";
3232import { pullRequestFindingKey, type PullRequestFinding } from "./pullRequestDetail.logic";
......@@ -217,7 +217,8 @@ export function PullRequestCodeTab({
217217 const [visibleCommitCount, setVisibleCommitCount] = useState(COMMIT_PAGE_SIZE);
218218 /** Set once the reader has asked for every file at once, until they pick a file apart again. */
219219 const [foldOverride, setFoldOverride] = useState<DiffFoldOverride>(null);
220 const [diffRenderMode, setDiffRenderMode] = useState<"stacked" | "split">("stacked");
220 const diffLayout = settings.diffLayout;
221 const updateClientSettings = useUpdateClientSettings();
221222 const [wordWrap, setWordWrap] = useState(settings.wordWrap);
222223 const [selectedLines, setSelectedLines] = useState<{
223224 id: string;
......@@ -740,7 +741,7 @@ export function PullRequestCodeTab({
740741
741742 const diffViewOptions = useMemo(
742743 () => ({
743 diffStyle: diffRenderMode === "split" ? ("split" as const) : ("unified" as const),
744 diffStyle: diffLayout === "split" ? ("split" as const) : ("unified" as const),
744745 lineDiffType: "none" as const,
745746 overflow: wordWrap ? ("wrap" as const) : ("scroll" as const),
746747 theme: resolveDiffThemeName(resolvedTheme),
......@@ -757,15 +758,7 @@ export function PullRequestCodeTab({
757758 onGutterUtilityClick: beginComment,
758759 onLineSelectionEnd: beginComment,
759760 }),
760 [
761 diffRenderMode,
762 wordWrap,
763 resolvedTheme,
764 loadDiffFiles,
765 canCommentOnLines,
766 draft,
767 beginComment,
768 ],
761 [diffLayout, wordWrap, resolvedTheme, loadDiffFiles, canCommentOnLines, draft, beginComment],
769762 );
770763
771764 const runThreadCommand = useCallback(
......@@ -1122,11 +1115,11 @@ export function PullRequestCodeTab({
11221115 <ToggleGroup
11231116 className="shrink-0 gap-1"
11241117 size="sm"
1125 value={[diffRenderMode]}
1118 value={[diffLayout]}
11261119 onValueChange={(value) => {
11271120 const next = value[0];
11281121 if (next === "stacked" || next === "split") {
1129 setDiffRenderMode(next);
1122 updateClientSettings({ diffLayout: next });
11301123 }
11311124 }}
11321125 >
apps/web/src/components/settings/SettingsPanels.tsx+44
......@@ -19,6 +19,7 @@ import {
1919import {
2020 DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
2121 DEFAULT_UNIFIED_SETTINGS,
22 type DiffLayout,
2223 type EnvironmentIdentificationMode,
2324 MAX_APPEARANCE_CONTRAST,
2425 MAX_CODE_FONT_SIZE,
......@@ -169,6 +170,11 @@ const TIMESTAMP_FORMAT_LABELS = {
169170 "24-hour": "24-hour",
170171} as const;
171172
173const DIFF_LAYOUT_LABELS: Record<DiffLayout, string> = {
174 stacked: "Stacked",
175 split: "Split",
176};
177
172178const QUIT_CONFIRMATION_MODE_LABELS: Record<QuitConfirmationMode, string> = {
173179 direct: "Direct",
174180 hold: "Hold",
......@@ -527,6 +533,7 @@ export function useSettingsRestore(onRestored?: () => void) {
527533 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
528534 ? ["Diff whitespace changes"]
529535 : []),
536 ...(settings.diffLayout !== DEFAULT_UNIFIED_SETTINGS.diffLayout ? ["Diff layout"] : []),
530537 ...(settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled
531538 ? ["Proactive panels"]
532539 : []),
......@@ -593,6 +600,7 @@ export function useSettingsRestore(onRestored?: () => void) {
593600 settings.defaultThreadEnvMode,
594601 settings.newWorktreesStartFromOrigin,
595602 settings.diffIgnoreWhitespace,
603 settings.diffLayout,
596604 settings.proactivePanelsEnabled,
597605 settings.environmentIdentificationMode,
598606 settings.contextWindowMeterEnabled,
......@@ -689,6 +697,7 @@ export function useSettingsRestore(onRestored?: () => void) {
689697 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
690698 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
691699 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
700 diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout,
692701 proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,
693702 showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,
694703 contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,
......@@ -2223,6 +2232,41 @@ export function GeneralSettingsPanel() {
22232232 }
22242233 />
22252234
2235 <SettingsRow
2236 {...searchableSetting("diff-layout")}
2237 description="Show diffs stacked or side by side. The toggle in the diff toolbar changes this too."
2238 resetAction={
2239 settings.diffLayout !== DEFAULT_UNIFIED_SETTINGS.diffLayout ? (
2240 <SettingResetButton
2241 label="diff layout"
2242 onClick={() => updateSettings({ diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout })}
2243 />
2244 ) : null
2245 }
2246 control={
2247 <Select
2248 value={settings.diffLayout}
2249 onValueChange={(value) => {
2250 if (value === "stacked" || value === "split") {
2251 updateSettings({ diffLayout: value });
2252 }
2253 }}
2254 >
2255 <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Diff layout">
2256 <SelectValue>{DIFF_LAYOUT_LABELS[settings.diffLayout]}</SelectValue>
2257 </SelectTrigger>
2258 <SelectPopup align="end" alignItemWithTrigger={false}>
2259 <SelectItem hideIndicator value="stacked">
2260 {DIFF_LAYOUT_LABELS.stacked}
2261 </SelectItem>
2262 <SelectItem hideIndicator value="split">
2263 {DIFF_LAYOUT_LABELS.split}
2264 </SelectItem>
2265 </SelectPopup>
2266 </Select>
2267 }
2268 />
2269
22262270 <SettingsRow
22272271 {...searchableSetting("proactive-panels")}
22282272 description="Automatically open the linked pull request when it appears and the turn diff when agent work finishes."
apps/web/src/components/settings/settingsSearch.ts+6
......@@ -184,6 +184,12 @@ export const SETTINGS_SEARCH_ITEMS = [
184184 to: "/settings/general",
185185 searchTerms: ["diff ignore spaces edits default"],
186186 },
187 {
188 id: "diff-layout",
189 title: "Diff layout",
190 to: "/settings/general",
191 searchTerms: ["stacked split side by side unified inline view"],
192 },
187193 {
188194 id: "proactive-panels",
189195 title: "Proactive panels",
apps/web/src/diffPanelStore.test.ts-21
......@@ -11,30 +11,9 @@ describe("diffPanelStore", () => {
1111 useDiffPanelStore.setState({
1212 byThreadKey: {},
1313 branchBaseRefByThreadKey: {},
14 diffRenderMode: "stacked",
1514 }),
1615 );
1716
18 it("keeps the selected render mode in panel and persisted state", async () => {
19 useDiffPanelStore.getState().setDiffRenderMode("split");
20
21 expect(useDiffPanelStore.getState().diffRenderMode).toBe("split");
22 expect(
23 useDiffPanelStore.persist.getOptions().partialize?.(useDiffPanelStore.getState()),
24 ).toMatchObject({ diffRenderMode: "split" });
25
26 const { name, storage } = useDiffPanelStore.persist.getOptions();
27 if (!name) throw new Error("Expected diff panel persistence to have a storage name");
28 const persisted = await storage?.getItem(name);
29 expect(persisted?.state).toMatchObject({ diffRenderMode: "split" });
30
31 useDiffPanelStore.setState({ diffRenderMode: "stacked" });
32 if (persisted) await storage?.setItem(name, persisted);
33 await useDiffPanelStore.persist.rehydrate();
34
35 expect(useDiffPanelStore.getState().diffRenderMode).toBe("split");
36 });
37
3817 it("defaults each thread to branch changes when the working tree is clean", () => {
3918 expect(
4019 selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
apps/web/src/diffPanelStore.ts-7
......@@ -10,16 +10,12 @@ export type DiffPanelSelection =
1010 | { kind: "unstaged" }
1111 | { kind: "turn"; turnId: TurnId; filePath: string | null; revealRequestId: number };
1212
13export type DiffRenderMode = "stacked" | "split";
14
1513const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null };
1614const DEFAULT_WORKING_TREE_SELECTION: DiffPanelSelection = { kind: "unstaged" };
1715
1816interface DiffPanelStoreState {
1917 byThreadKey: Record<string, DiffPanelSelection>;
2018 branchBaseRefByThreadKey: Record<string, string | null>;
21 diffRenderMode: DiffRenderMode;
22 setDiffRenderMode: (mode: DiffRenderMode) => void;
2319 selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void;
2420 selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void;
2521 selectTurn: (ref: ScopedThreadRef, turnId: TurnId, filePath?: string) => void;
......@@ -37,8 +33,6 @@ export const useDiffPanelStore = create<DiffPanelStoreState>()(
3733 (set) => ({
3834 byThreadKey: {},
3935 branchBaseRefByThreadKey: {},
40 diffRenderMode: "stacked",
41 setDiffRenderMode: (diffRenderMode) => set({ diffRenderMode }),
4236 selectGitScope: (ref, scope) =>
4337 set((state) => {
4438 const threadKey = scopedThreadKey(ref);
......@@ -132,7 +126,6 @@ export const useDiffPanelStore = create<DiffPanelStoreState>()(
132126 partialize: (state) => ({
133127 byThreadKey: state.byThreadKey,
134128 branchBaseRefByThreadKey: state.branchBaseRefByThreadKey,
135 diffRenderMode: state.diffRenderMode,
136129 }),
137130 },
138131 ),
packages/contracts/src/settings.ts+6
......@@ -31,6 +31,10 @@ export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"])
3131export type TimestampFormat = typeof TimestampFormat.Type;
3232export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
3333
34export const DiffLayout = Schema.Literals(["stacked", "split"]);
35export type DiffLayout = typeof DiffLayout.Type;
36export const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked";
37
3438export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]);
3539export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type;
3640export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at";
......@@ -242,6 +246,7 @@ export const ClientSettingsSchema = Schema.Struct({
242246 Schema.withDecodingDefault(Effect.succeed([])),
243247 ),
244248 diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
249 diffLayout: DiffLayout.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_DIFF_LAYOUT))),
245250 environmentIdentificationMode: EnvironmentIdentificationMode.pipe(
246251 Schema.withDecodingDefault(Effect.succeed(DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE)),
247252 ),
......@@ -1007,6 +1012,7 @@ export const ClientSettingsPatch = Schema.Struct({
10071012 confirmThreadUnpin: Schema.optionalKey(Schema.Boolean),
10081013 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
10091014 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
1015 diffLayout: Schema.optionalKey(DiffLayout),
10101016 environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),
10111017 glassOpacity: Schema.optionalKey(GlassOpacity),
10121018 fontSizeInterface: Schema.optionalKey(InterfaceFontSize),