| author | |
| committer | |
| log | 9409dd20a9fbce491d49d09c79b289d8fb8bfe3e |
| tree | e9e39232d05cca883563024b7ce8bb2c15aa4741 |
| parent | 12f1fc427efad4b683835a72bda6f74bebb5d641 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 = { |
| 29 | 29 | contextWindowMeterEnabled: false, |
| 30 | 30 | dismissedProviderUpdateNotificationKeys: [], |
| 31 | 31 | diffIgnoreWhitespace: true, |
| 32 | diffLayout: "stacked", | |
| 32 | 33 | environmentIdentificationMode: "artwork", |
| 33 | 34 | favorites: [], |
| 34 | 35 | fontFamilyCode: "", |
apps/web/src/clientPersistenceStorage.test.ts+13| ... | ... | @@ -90,4 +90,17 @@ describe("clientPersistenceStorage", () => { |
| 90 | 90 | expect(settings).not.toHaveProperty("chatWordWrap"); |
| 91 | 91 | expect(settings).not.toHaveProperty("diffWordWrap"); |
| 92 | 92 | }); |
| 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 | }); | |
| 93 | 106 | }); |
apps/web/src/components/DiffPanel.tsx+6-6| ... | ... | @@ -44,7 +44,7 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 44 | 44 | import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; |
| 45 | 45 | import { useProject, useThread } from "../state/entities"; |
| 46 | 46 | import { resolveThreadRouteRef } from "../threadRoutes"; |
| 47 | import { useClientSettings } from "../hooks/useSettings"; | |
| 47 | import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; | |
| 48 | 48 | import { formatShortTimestamp } from "../timestampFormat"; |
| 49 | 49 | import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; |
| 50 | 50 | import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; |
| ... | ... | @@ -108,8 +108,8 @@ export default function DiffPanel({ |
| 108 | 108 | const { resolvedTheme } = useTheme(); |
| 109 | 109 | const settings = useClientSettings(); |
| 110 | 110 | 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(); | |
| 113 | 113 | const [wordWrap, setWordWrap] = useState(settings.wordWrap); |
| 114 | 114 | const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); |
| 115 | 115 | const [baseRefQuery, setBaseRefQuery] = useState(""); |
| ... | ... | @@ -768,11 +768,11 @@ export default function DiffPanel({ |
| 768 | 768 | <ToggleGroup |
| 769 | 769 | className="shrink-0 gap-1" |
| 770 | 770 | size="sm" |
| 771 | value={[diffRenderMode]} | |
| 771 | value={[diffLayout]} | |
| 772 | 772 | onValueChange={(value) => { |
| 773 | 773 | const next = value[0]; |
| 774 | 774 | if (next === "stacked" || next === "split") { |
| 775 | setDiffRenderMode(next); | |
| 775 | updateClientSettings({ diffLayout: next }); | |
| 776 | 776 | } |
| 777 | 777 | }} |
| 778 | 778 | > |
| ... | ... | @@ -959,7 +959,7 @@ export default function DiffPanel({ |
| 959 | 959 | ); |
| 960 | 960 | }} |
| 961 | 961 | options={{ |
| 962 | diffStyle: diffRenderMode === "split" ? "split" : "unified", | |
| 962 | diffStyle: diffLayout === "split" ? "split" : "unified", | |
| 963 | 963 | lineDiffType: "none", |
| 964 | 964 | overflow: wordWrap ? "wrap" : "scroll", |
| 965 | 965 | theme: resolveDiffThemeName(resolvedTheme), |
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+7-14| ... | ... | @@ -26,7 +26,7 @@ import { |
| 26 | 26 | import { useAtomRefresh } from "@effect/atom-react"; |
| 27 | 27 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 28 | 28 | |
| 29 | import { useClientSettings } from "~/hooks/useSettings"; | |
| 29 | import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; | |
| 30 | 30 | import { useTheme } from "~/hooks/useTheme"; |
| 31 | 31 | import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse"; |
| 32 | 32 | import { pullRequestFindingKey, type PullRequestFinding } from "./pullRequestDetail.logic"; |
| ... | ... | @@ -217,7 +217,8 @@ export function PullRequestCodeTab({ |
| 217 | 217 | const [visibleCommitCount, setVisibleCommitCount] = useState(COMMIT_PAGE_SIZE); |
| 218 | 218 | /** Set once the reader has asked for every file at once, until they pick a file apart again. */ |
| 219 | 219 | const [foldOverride, setFoldOverride] = useState<DiffFoldOverride>(null); |
| 220 | const [diffRenderMode, setDiffRenderMode] = useState<"stacked" | "split">("stacked"); | |
| 220 | const diffLayout = settings.diffLayout; | |
| 221 | const updateClientSettings = useUpdateClientSettings(); | |
| 221 | 222 | const [wordWrap, setWordWrap] = useState(settings.wordWrap); |
| 222 | 223 | const [selectedLines, setSelectedLines] = useState<{ |
| 223 | 224 | id: string; |
| ... | ... | @@ -740,7 +741,7 @@ export function PullRequestCodeTab({ |
| 740 | 741 | |
| 741 | 742 | const diffViewOptions = useMemo( |
| 742 | 743 | () => ({ |
| 743 | diffStyle: diffRenderMode === "split" ? ("split" as const) : ("unified" as const), | |
| 744 | diffStyle: diffLayout === "split" ? ("split" as const) : ("unified" as const), | |
| 744 | 745 | lineDiffType: "none" as const, |
| 745 | 746 | overflow: wordWrap ? ("wrap" as const) : ("scroll" as const), |
| 746 | 747 | theme: resolveDiffThemeName(resolvedTheme), |
| ... | ... | @@ -757,15 +758,7 @@ export function PullRequestCodeTab({ |
| 757 | 758 | onGutterUtilityClick: beginComment, |
| 758 | 759 | onLineSelectionEnd: beginComment, |
| 759 | 760 | }), |
| 760 | [ | |
| 761 | diffRenderMode, | |
| 762 | wordWrap, | |
| 763 | resolvedTheme, | |
| 764 | loadDiffFiles, | |
| 765 | canCommentOnLines, | |
| 766 | draft, | |
| 767 | beginComment, | |
| 768 | ], | |
| 761 | [diffLayout, wordWrap, resolvedTheme, loadDiffFiles, canCommentOnLines, draft, beginComment], | |
| 769 | 762 | ); |
| 770 | 763 | |
| 771 | 764 | const runThreadCommand = useCallback( |
| ... | ... | @@ -1122,11 +1115,11 @@ export function PullRequestCodeTab({ |
| 1122 | 1115 | <ToggleGroup |
| 1123 | 1116 | className="shrink-0 gap-1" |
| 1124 | 1117 | size="sm" |
| 1125 | value={[diffRenderMode]} | |
| 1118 | value={[diffLayout]} | |
| 1126 | 1119 | onValueChange={(value) => { |
| 1127 | 1120 | const next = value[0]; |
| 1128 | 1121 | if (next === "stacked" || next === "split") { |
| 1129 | setDiffRenderMode(next); | |
| 1122 | updateClientSettings({ diffLayout: next }); | |
| 1130 | 1123 | } |
| 1131 | 1124 | }} |
| 1132 | 1125 | > |
apps/web/src/components/settings/SettingsPanels.tsx+44| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | import { |
| 20 | 20 | DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, |
| 21 | 21 | DEFAULT_UNIFIED_SETTINGS, |
| 22 | type DiffLayout, | |
| 22 | 23 | type EnvironmentIdentificationMode, |
| 23 | 24 | MAX_APPEARANCE_CONTRAST, |
| 24 | 25 | MAX_CODE_FONT_SIZE, |
| ... | ... | @@ -169,6 +170,11 @@ const TIMESTAMP_FORMAT_LABELS = { |
| 169 | 170 | "24-hour": "24-hour", |
| 170 | 171 | } as const; |
| 171 | 172 | |
| 173 | const DIFF_LAYOUT_LABELS: Record<DiffLayout, string> = { | |
| 174 | stacked: "Stacked", | |
| 175 | split: "Split", | |
| 176 | }; | |
| 177 | ||
| 172 | 178 | const QUIT_CONFIRMATION_MODE_LABELS: Record<QuitConfirmationMode, string> = { |
| 173 | 179 | direct: "Direct", |
| 174 | 180 | hold: "Hold", |
| ... | ... | @@ -527,6 +533,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 527 | 533 | ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace |
| 528 | 534 | ? ["Diff whitespace changes"] |
| 529 | 535 | : []), |
| 536 | ...(settings.diffLayout !== DEFAULT_UNIFIED_SETTINGS.diffLayout ? ["Diff layout"] : []), | |
| 530 | 537 | ...(settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled |
| 531 | 538 | ? ["Proactive panels"] |
| 532 | 539 | : []), |
| ... | ... | @@ -593,6 +600,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 593 | 600 | settings.defaultThreadEnvMode, |
| 594 | 601 | settings.newWorktreesStartFromOrigin, |
| 595 | 602 | settings.diffIgnoreWhitespace, |
| 603 | settings.diffLayout, | |
| 596 | 604 | settings.proactivePanelsEnabled, |
| 597 | 605 | settings.environmentIdentificationMode, |
| 598 | 606 | settings.contextWindowMeterEnabled, |
| ... | ... | @@ -689,6 +697,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 689 | 697 | timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, |
| 690 | 698 | wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, |
| 691 | 699 | diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, |
| 700 | diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout, | |
| 692 | 701 | proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, |
| 693 | 702 | showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, |
| 694 | 703 | contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, |
| ... | ... | @@ -2223,6 +2232,41 @@ export function GeneralSettingsPanel() { |
| 2223 | 2232 | } |
| 2224 | 2233 | /> |
| 2225 | 2234 | |
| 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 | ||
| 2226 | 2270 | <SettingsRow |
| 2227 | 2271 | {...searchableSetting("proactive-panels")} |
| 2228 | 2272 | 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 = [ |
| 184 | 184 | to: "/settings/general", |
| 185 | 185 | searchTerms: ["diff ignore spaces edits default"], |
| 186 | 186 | }, |
| 187 | { | |
| 188 | id: "diff-layout", | |
| 189 | title: "Diff layout", | |
| 190 | to: "/settings/general", | |
| 191 | searchTerms: ["stacked split side by side unified inline view"], | |
| 192 | }, | |
| 187 | 193 | { |
| 188 | 194 | id: "proactive-panels", |
| 189 | 195 | title: "Proactive panels", |
apps/web/src/diffPanelStore.test.ts-21| ... | ... | @@ -11,30 +11,9 @@ describe("diffPanelStore", () => { |
| 11 | 11 | useDiffPanelStore.setState({ |
| 12 | 12 | byThreadKey: {}, |
| 13 | 13 | branchBaseRefByThreadKey: {}, |
| 14 | diffRenderMode: "stacked", | |
| 15 | 14 | }), |
| 16 | 15 | ); |
| 17 | 16 | |
| 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 | ||
| 38 | 17 | it("defaults each thread to branch changes when the working tree is clean", () => { |
| 39 | 18 | expect( |
| 40 | 19 | selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), |
apps/web/src/diffPanelStore.ts-7| ... | ... | @@ -10,16 +10,12 @@ export type DiffPanelSelection = |
| 10 | 10 | | { kind: "unstaged" } |
| 11 | 11 | | { kind: "turn"; turnId: TurnId; filePath: string | null; revealRequestId: number }; |
| 12 | 12 | |
| 13 | export type DiffRenderMode = "stacked" | "split"; | |
| 14 | ||
| 15 | 13 | const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; |
| 16 | 14 | const DEFAULT_WORKING_TREE_SELECTION: DiffPanelSelection = { kind: "unstaged" }; |
| 17 | 15 | |
| 18 | 16 | interface DiffPanelStoreState { |
| 19 | 17 | byThreadKey: Record<string, DiffPanelSelection>; |
| 20 | 18 | branchBaseRefByThreadKey: Record<string, string | null>; |
| 21 | diffRenderMode: DiffRenderMode; | |
| 22 | setDiffRenderMode: (mode: DiffRenderMode) => void; | |
| 23 | 19 | selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; |
| 24 | 20 | selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; |
| 25 | 21 | selectTurn: (ref: ScopedThreadRef, turnId: TurnId, filePath?: string) => void; |
| ... | ... | @@ -37,8 +33,6 @@ export const useDiffPanelStore = create<DiffPanelStoreState>()( |
| 37 | 33 | (set) => ({ |
| 38 | 34 | byThreadKey: {}, |
| 39 | 35 | branchBaseRefByThreadKey: {}, |
| 40 | diffRenderMode: "stacked", | |
| 41 | setDiffRenderMode: (diffRenderMode) => set({ diffRenderMode }), | |
| 42 | 36 | selectGitScope: (ref, scope) => |
| 43 | 37 | set((state) => { |
| 44 | 38 | const threadKey = scopedThreadKey(ref); |
| ... | ... | @@ -132,7 +126,6 @@ export const useDiffPanelStore = create<DiffPanelStoreState>()( |
| 132 | 126 | partialize: (state) => ({ |
| 133 | 127 | byThreadKey: state.byThreadKey, |
| 134 | 128 | branchBaseRefByThreadKey: state.branchBaseRefByThreadKey, |
| 135 | diffRenderMode: state.diffRenderMode, | |
| 136 | 129 | }), |
| 137 | 130 | }, |
| 138 | 131 | ), |
packages/contracts/src/settings.ts+6| ... | ... | @@ -31,6 +31,10 @@ export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]) |
| 31 | 31 | export type TimestampFormat = typeof TimestampFormat.Type; |
| 32 | 32 | export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; |
| 33 | 33 | |
| 34 | export const DiffLayout = Schema.Literals(["stacked", "split"]); | |
| 35 | export type DiffLayout = typeof DiffLayout.Type; | |
| 36 | export const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked"; | |
| 37 | ||
| 34 | 38 | export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); |
| 35 | 39 | export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; |
| 36 | 40 | export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; |
| ... | ... | @@ -242,6 +246,7 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 242 | 246 | Schema.withDecodingDefault(Effect.succeed([])), |
| 243 | 247 | ), |
| 244 | 248 | diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 249 | diffLayout: DiffLayout.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_DIFF_LAYOUT))), | |
| 245 | 250 | environmentIdentificationMode: EnvironmentIdentificationMode.pipe( |
| 246 | 251 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE)), |
| 247 | 252 | ), |
| ... | ... | @@ -1007,6 +1012,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 1007 | 1012 | confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), |
| 1008 | 1013 | continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), |
| 1009 | 1014 | diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), |
| 1015 | diffLayout: Schema.optionalKey(DiffLayout), | |
| 1010 | 1016 | environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), |
| 1011 | 1017 | glassOpacity: Schema.optionalKey(GlassOpacity), |
| 1012 | 1018 | fontSizeInterface: Schema.optionalKey(InterfaceFontSize), |