authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-08-29 03:58:48+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-29 03:58:48+05:30
log0bbecfabf189349b16ad2d7e6558a95a98a3b196
treeca0e698f5f008180be99e93211b91a45e8e3e252
parentcb49e5d7201ef6bcb288bf447785bbd374e8644d
signature Signed by PGP key B5690EEEBB952194

fix: make thread auto-settling opt-in (#8321)


25 files changed, 568 insertions(+), 231 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+1-1
......@@ -39,7 +39,7 @@ const clientSettings: ClientSettings = {
3939 showSkillsInSlashMenu: false,
4040 providerModelPreferences: {},
4141 sidebarAutoSettleAfterDays: 3,
42 sidebarAutoSettleOnMerge: true,
42 sidebarAutoSettleMode: "change-request",
4343 sidebarProjectGroupingMode: "repository_path",
4444 sidebarProjectGroupingOverrides: {
4545 "environment-1:/tmp/project-a": "separate",
apps/mobile/src/Stack.tsx+8
......@@ -51,6 +51,7 @@ import {
5151import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
5252import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
5353import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
54import { SettingsAutoSettleRouteScreen } from "./features/settings/SettingsAutoSettleRouteScreen";
5455import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";
5556import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
5657import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
......@@ -177,6 +178,13 @@ const SettingsContentStack = createNativeStackNavigator({
177178 title: "Appearance",
178179 },
179180 }),
181 SettingsAutoSettle: createNativeStackScreen({
182 screen: SettingsAutoSettleRouteScreen,
183 linking: "thread-settling",
184 options: {
185 title: "Thread Settling",
186 },
187 }),
180188 SettingsProjectGrouping: createNativeStackScreen({
181189 screen: SettingsProjectGroupingRouteScreen,
182190 linking: "project-grouping",
apps/mobile/src/features/home/HomeScreen.tsx+8-5
......@@ -30,6 +30,7 @@ import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspace
3030import type { SavedRemoteConnection } from "../../lib/connection";
3131import { scopedProjectKey } from "../../lib/scopedEntities";
3232import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
33import { resolveMobileAutoSettlePreferences } from "../../persistence/mobile-preferences";
3334import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
3435import { useThreadSearch } from "../../state/queries";
3536import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
......@@ -208,9 +209,9 @@ export function HomeScreen(props: HomeScreenProps) {
208209 >(() => new Map());
209210 const preferencesResult = useAtomValue(mobilePreferencesAtom);
210211 const threadListV2Enabled = useThreadListV2Enabled();
211 const autoSettleOnMerge =
212 !AsyncResult.isSuccess(preferencesResult) ||
213 preferencesResult.value.autoSettleOnMerge !== false;
212 const autoSettlePreferences = resolveMobileAutoSettlePreferences(
213 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {},
214 );
214215 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
215216 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
216217 const listRef = useRef<LegendListRef | null>(null);
......@@ -678,7 +679,8 @@ export function HomeScreen(props: HomeScreenProps) {
678679 searchQuery: props.searchQuery,
679680 matchedThreadKeys,
680681 changeRequestByKey,
681 autoSettleOnMerge,
682 autoSettleAfterDays: autoSettlePreferences.autoSettleAfterDays,
683 autoSettleMode: autoSettlePreferences.autoSettleMode,
682684 settlementEnvironmentIds,
683685 snoozeEnvironmentIds,
684686 settledLimit: settledVisibleCount,
......@@ -690,7 +692,8 @@ export function HomeScreen(props: HomeScreenProps) {
690692 });
691693 }, [
692694 changeRequestByKey,
693 autoSettleOnMerge,
695 autoSettlePreferences.autoSettleAfterDays,
696 autoSettlePreferences.autoSettleMode,
694697 nowMinute,
695698 snoozeWakeTick,
696699 snoozedShelfExpanded,
apps/mobile/src/features/settings/SettingsAutoSettleRouteScreen.tsx created+173
......@@ -0,0 +1,173 @@
1import { useAtomSet, useAtomValue } from "@effect/atom-react";
2import { useNavigation } from "@react-navigation/native";
3import {
4 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
5 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
6 type SidebarAutoSettleMode,
7} from "@t3tools/contracts";
8import { AsyncResult } from "effect/unstable/reactivity";
9import { Platform, Pressable, ScrollView, View } from "react-native";
10import { useSafeAreaInsets } from "react-native-safe-area-context";
11
12import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
13import { AppText as Text } from "../../components/AppText";
14import { SymbolView } from "../../components/AppSymbol";
15import { useUniwindTheme } from "../../lib/useUniwindTheme";
16import { NativeStackScreenOptions } from "../../native/StackHeader";
17import { resolveMobileAutoSettlePreferences } from "../../persistence/mobile-preferences";
18import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
19import { SettingsSection } from "./components/SettingsSection";
20
21const AUTO_SETTLE_OPTIONS: ReadonlyArray<{
22 readonly mode: SidebarAutoSettleMode;
23 readonly label: string;
24 readonly description: string;
25}> = [
26 {
27 mode: "never",
28 label: "Never",
29 description: "Threads settle only when you choose Settle.",
30 },
31 {
32 mode: "change-request",
33 label: "When a pull request merges or closes",
34 description: "A missing or uncertain pull-request timestamp keeps the thread active.",
35 },
36 {
37 mode: "inactivity",
38 label: "After inactivity",
39 description: "Running, blocked, and open-pull-request threads stay active.",
40 },
41];
42
43export function mobileAutoSettleModeLabel(mode: SidebarAutoSettleMode): string {
44 if (mode === "change-request") return "PR merged or closed";
45 if (mode === "inactivity") return "After inactivity";
46 return "Never";
47}
48
49export function SettingsAutoSettleRouteScreen() {
50 const navigation = useNavigation();
51 const insets = useSafeAreaInsets();
52 const checkmarkColor = useUniwindTheme()["--color-icon"];
53 const preferencesResult = useAtomValue(mobilePreferencesAtom);
54 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
55 const preferencesReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;
56 const resolved = resolveMobileAutoSettlePreferences(
57 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {},
58 );
59
60 const setDays = (days: number) => {
61 if (!preferencesReady) return;
62 savePreferences({
63 autoSettleAfterDays: Math.min(
64 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
65 Math.max(MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, days),
66 ),
67 });
68 };
69
70 return (
71 <View collapsable={false} className="flex-1 bg-sheet">
72 {Platform.OS === "android" ? (
73 <>
74 <NativeStackScreenOptions options={{ headerShown: false }} />
75 <AndroidScreenHeader title="Thread Settling" onBack={() => navigation.goBack()} />
76 </>
77 ) : null}
78 <ScrollView
79 contentInsetAdjustmentBehavior="automatic"
80 showsVerticalScrollIndicator={false}
81 className="flex-1"
82 contentContainerClassName="gap-4 px-5 pt-4"
83 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
84 >
85 <SettingsSection title="Automatic settling">
86 {AUTO_SETTLE_OPTIONS.map((option, index) => (
87 <Pressable
88 key={option.mode}
89 accessibilityRole="radio"
90 accessibilityState={{
91 checked: resolved.autoSettleMode === option.mode,
92 disabled: !preferencesReady,
93 }}
94 disabled={!preferencesReady}
95 onPress={() => savePreferences({ autoSettleMode: option.mode })}
96 className={
97 index === 0
98 ? "flex-row items-center gap-4 p-4"
99 : "flex-row items-center gap-4 border-t border-border-subtle p-4"
100 }
101 >
102 <View className="min-w-0 flex-1 gap-1">
103 <Text className="text-lg text-foreground">{option.label}</Text>
104 <Text className="text-sm leading-normal text-foreground-muted">
105 {option.description}
106 </Text>
107 </View>
108 {resolved.autoSettleMode === option.mode ? (
109 <SymbolView
110 name="checkmark"
111 size={18}
112 tintColor={checkmarkColor}
113 type="monochrome"
114 weight="semibold"
115 />
116 ) : null}
117 </Pressable>
118 ))}
119 </SettingsSection>
120
121 {resolved.autoSettleMode === "inactivity" ? (
122 <SettingsSection title="Inactivity window">
123 <View className="flex-row items-center gap-4 p-4">
124 <View className="min-w-0 flex-1 gap-1">
125 <Text className="text-lg text-foreground">Days without activity</Text>
126 <Text className="text-sm text-foreground-muted">Between 1 and 90 days</Text>
127 </View>
128 <Pressable
129 accessibilityLabel="Decrease inactivity days"
130 accessibilityRole="button"
131 disabled={
132 !preferencesReady ||
133 resolved.autoSettleAfterDays <= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS
134 }
135 onPress={() => setDays(resolved.autoSettleAfterDays - 1)}
136 className="size-10 items-center justify-center rounded-full bg-fill-secondary disabled:opacity-40"
137 >
138 <SymbolView
139 name="minus"
140 size={16}
141 tintColor={checkmarkColor}
142 type="monochrome"
143 weight="semibold"
144 />
145 </Pressable>
146 <Text className="w-8 text-center text-lg text-foreground">
147 {resolved.autoSettleAfterDays}
148 </Text>
149 <Pressable
150 accessibilityLabel="Increase inactivity days"
151 accessibilityRole="button"
152 disabled={
153 !preferencesReady ||
154 resolved.autoSettleAfterDays >= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS
155 }
156 onPress={() => setDays(resolved.autoSettleAfterDays + 1)}
157 className="size-10 items-center justify-center rounded-full bg-fill-secondary disabled:opacity-40"
158 >
159 <SymbolView
160 name="plus"
161 size={16}
162 tintColor={checkmarkColor}
163 type="monochrome"
164 weight="semibold"
165 />
166 </Pressable>
167 </View>
168 </SettingsSection>
169 ) : null}
170 </ScrollView>
171 </View>
172 );
173}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+9-8
......@@ -4,6 +4,7 @@ import Constants from "expo-constants";
44import * as Notifications from "expo-notifications";
55import { useNavigation } from "@react-navigation/native";
66import { NativeStackScreenOptions } from "../../native/StackHeader";
7import { resolveMobileAutoSettlePreferences } from "../../persistence/mobile-preferences";
78import { SymbolView } from "../../components/AppSymbol";
89import * as Effect from "effect/Effect";
910import { AsyncResult } from "effect/unstable/reactivity";
......@@ -45,6 +46,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
4546import { SettingsRow } from "./components/SettingsRow";
4647import { SettingsSection } from "./components/SettingsSection";
4748import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
49import { mobileAutoSettleModeLabel } from "./SettingsAutoSettleRouteScreen";
4850import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";
4951
5052type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
......@@ -528,19 +530,18 @@ function ConfiguredSettingsRouteScreen() {
528530
529531function GeneralSettingsSection() {
530532 const preferencesResult = useAtomValue(mobilePreferencesAtom);
531 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
532 const autoSettleOnMerge =
533 !AsyncResult.isSuccess(preferencesResult) ||
534 preferencesResult.value.autoSettleOnMerge !== false;
533 const autoSettlePreferences = resolveMobileAutoSettlePreferences(
534 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {},
535 );
535536
536537 return (
537538 <SettingsSection title="General">
538539 <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
539 <SettingsSwitchRow
540 <SettingsRow
540541 icon="arrow.triangle.branch"
541 label="Auto-settle merged threads"
542 value={autoSettleOnMerge}
543 onValueChange={(value) => savePreferences({ autoSettleOnMerge: value })}
542 label="Thread Settling"
543 value={mobileAutoSettleModeLabel(autoSettlePreferences.autoSettleMode)}
544 target="SettingsAutoSettle"
544545 />
545546 <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
546547 </SettingsSection>
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+1
......@@ -2,6 +2,7 @@ export type SettingsSheetTarget =
22 | "SettingsEnvironments"
33 | "SettingsArchive"
44 | "SettingsAppearance"
5 | "SettingsAutoSettle"
56 | "SettingsProjectGrouping"
67 | "SettingsClientStorage"
78 | "SettingsUsage";
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+8-5
......@@ -26,6 +26,7 @@ import { ControlPillMenu } from "../../components/ControlPill";
2626import { SymbolView } from "../../components/AppSymbol";
2727import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
2828import { NativeStackScreenOptions } from "../../native/StackHeader";
29import { resolveMobileAutoSettlePreferences } from "../../persistence/mobile-preferences";
2930import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
3031import { useProjects, useThreadShells } from "../../state/entities";
3132import { mobilePreferencesAtom } from "../../state/preferences";
......@@ -165,9 +166,9 @@ function ThreadNavigationSidebarPane(
165166 } = useThreadListActions();
166167 const threadListV2Enabled = useThreadListV2Enabled();
167168 const preferencesResult = useAtomValue(mobilePreferencesAtom);
168 const autoSettleOnMerge =
169 !AsyncResult.isSuccess(preferencesResult) ||
170 preferencesResult.value.autoSettleOnMerge !== false;
169 const autoSettlePreferences = resolveMobileAutoSettlePreferences(
170 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {},
171 );
171172 const pendingTasks = usePendingNewTasks();
172173 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
173174 const environments = useMemo(
......@@ -510,7 +511,8 @@ function ThreadNavigationSidebarPane(
510511 searchQuery: props.searchQuery,
511512 matchedThreadKeys,
512513 changeRequestByKey,
513 autoSettleOnMerge,
514 autoSettleAfterDays: autoSettlePreferences.autoSettleAfterDays,
515 autoSettleMode: autoSettlePreferences.autoSettleMode,
514516 settlementEnvironmentIds,
515517 snoozeEnvironmentIds,
516518 settledLimit: settledVisibleCount,
......@@ -522,7 +524,8 @@ function ThreadNavigationSidebarPane(
522524 });
523525 }, [
524526 changeRequestByKey,
525 autoSettleOnMerge,
527 autoSettlePreferences.autoSettleAfterDays,
528 autoSettlePreferences.autoSettleMode,
526529 nowMinute,
527530 snoozeWakeTick,
528531 snoozedShelfExpanded,
apps/mobile/src/features/threads/threadListV2.test.ts+15-7
......@@ -360,10 +360,12 @@ describe("buildThreadListV2Items", () => {
360360 `${environmentId}:${thread.id}`,
361361 {
362362 state: "merged" as const,
363 updatedAt: NOW,
363364 linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
364365 },
365366 ],
366367 ]),
368 autoSettleMode: "change-request",
367369 now: NOW,
368370 });
369371
......@@ -371,16 +373,16 @@ describe("buildThreadListV2Items", () => {
371373 expect(layout.items[0]?.variant).toBe("slim");
372374 });
373375
374 it("keeps a merged thread active when auto-settle on merge is off", () => {
376 it("keeps a merged thread active when automatic settling is off", () => {
375377 const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });
376378 const layout = buildThreadListV2Items({
377379 threads: [merged],
378380 environmentId: null,
379381 searchQuery: "",
380382 changeRequestByKey: new Map([
381 [`${environmentId}:${merged.id}`, { state: "merged" as const }],
383 [`${environmentId}:${merged.id}`, { state: "merged" as const, updatedAt: NOW }],
382384 ]),
383 autoSettleOnMerge: false,
385 autoSettleMode: "never",
384386 now: NOW,
385387 });
386388
......@@ -449,7 +451,10 @@ describe("buildThreadListV2Items", () => {
449451 threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged],
450452 environmentId: null,
451453 searchQuery: "",
452 changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]),
454 changeRequestByKey: new Map([
455 [`${environmentId}:${merged.id}`, { state: "merged", updatedAt: NOW }],
456 ]),
457 autoSettleMode: "change-request",
453458 now: NOW,
454459 });
455460
......@@ -478,6 +483,7 @@ describe("buildThreadListV2Items", () => {
478483 threads: [inactive],
479484 environmentId: null,
480485 searchQuery: "",
486 autoSettleMode: "inactivity",
481487 now: NOW,
482488 });
483489
......@@ -489,7 +495,7 @@ describe("buildThreadListV2Items", () => {
489495 expect(layout.settledCount).toBe(1);
490496 });
491497
492 it("keeps pinned merged threads pinned when auto-settle on merge is off", () => {
498 it("keeps pinned merged threads pinned when automatic settling is off", () => {
493499 const merged = makeThread({
494500 id: ThreadId.make("pinned-merged"),
495501 title: "Pinned merged pull request",
......@@ -499,8 +505,10 @@ describe("buildThreadListV2Items", () => {
499505 threads: [merged],
500506 environmentId: null,
501507 searchQuery: "",
502 changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]),
503 autoSettleOnMerge: false,
508 changeRequestByKey: new Map([
509 [`${environmentId}:${merged.id}`, { state: "merged", updatedAt: NOW }],
510 ]),
511 autoSettleMode: "never",
504512 now: NOW,
505513 });
506514
apps/mobile/src/features/threads/threadListV2.ts+9-4
......@@ -16,7 +16,12 @@ import {
1616 activeThreadAnchorTimestampMs,
1717 sortPinnedThreadsByOrderKey,
1818} from "@t3tools/client-runtime/state/thread-sort";
19import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts";
19import type {
20 EnvironmentId,
21 ProjectId,
22 SidebarAutoSettleMode,
23 ThreadLinkedPullRequest,
24} from "@t3tools/contracts";
2025
2126import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2227
......@@ -369,7 +374,7 @@ export function buildThreadListV2Items(input: {
369374 contract as settlementEnvironmentIds. */
370375 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
371376 readonly autoSettleAfterDays?: number;
372 readonly autoSettleOnMerge?: boolean;
377 readonly autoSettleMode?: SidebarAutoSettleMode;
373378 /** Max settled rows to render; the rest are counted, not built. */
374379 readonly settledLimit?: number;
375380 /** Injectable for tests; defaults to now. */
......@@ -390,7 +395,7 @@ export function buildThreadListV2Items(input: {
390395 const now = input.now ?? new Date().toISOString();
391396 const snoozeNow = input.snoozeNow ?? now;
392397 const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
393 const autoSettleOnMerge = input.autoSettleOnMerge ?? true;
398 const autoSettleMode = input.autoSettleMode ?? "never";
394399 const query = input.searchQuery.trim().toLocaleLowerCase();
395400 const projectKeys = input.projectRefs
396401 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
......@@ -447,7 +452,7 @@ export function buildThreadListV2Items(input: {
447452 effectiveSettled(thread, {
448453 now,
449454 autoSettleAfterDays,
450 autoSettleOnMerge,
455 autoSettleMode,
451456 changeRequest,
452457 })
453458 ) {
apps/mobile/src/lib/storage.test.ts+29
......@@ -100,6 +100,7 @@ import {
100100 saveConnection,
101101 savePreferencesPatch,
102102} from "../persistence/imperative";
103import { resolveMobileAutoSettlePreferences } from "../persistence/mobile-preferences";
103104import { toStableSavedRemoteConnection } from "./connection";
104105
105106const managedConnection = {
......@@ -202,6 +203,34 @@ describe("mobile connection storage", () => {
202203 await expect(loadPreferences()).resolves.toEqual({});
203204 });
204205
206 it("defaults retired mobile settling preferences to manual-only", async () => {
207 mocks.setPreferencesJson(JSON.stringify({ autoSettleOnMerge: true }), 10);
208
209 const preferences = await loadPreferences();
210 expect(preferences).toEqual({});
211 expect(resolveMobileAutoSettlePreferences(preferences)).toEqual({
212 autoSettleMode: "never",
213 autoSettleAfterDays: 3,
214 });
215 });
216
217 it("preserves valid mobile settling preferences", async () => {
218 mocks.setPreferencesJson(
219 JSON.stringify({ autoSettleMode: "inactivity", autoSettleAfterDays: 30 }),
220 10,
221 );
222
223 const preferences = await loadPreferences();
224 expect(preferences).toEqual({
225 autoSettleMode: "inactivity",
226 autoSettleAfterDays: 30,
227 });
228 expect(resolveMobileAutoSettlePreferences(preferences)).toEqual({
229 autoSettleMode: "inactivity",
230 autoSettleAfterDays: 30,
231 });
232 });
233
205234 it("falls back to secure storage when SQLite cannot save preferences", async () => {
206235 mocks.setDatabaseFailures(true, true);
207236 await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 });
apps/mobile/src/persistence/mobile-preferences.ts+37-5
......@@ -5,7 +5,15 @@ import * as Option from "effect/Option";
55import * as Ref from "effect/Ref";
66import * as Schema from "effect/Schema";
77import * as Semaphore from "effect/Semaphore";
8import type { SidebarProjectGroupingMode } from "@t3tools/contracts";
8import {
9 DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
10 DEFAULT_SIDEBAR_AUTO_SETTLE_MODE,
11 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
12 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
13 type SidebarAutoSettleAfterDays,
14 type SidebarAutoSettleMode,
15 type SidebarProjectGroupingMode,
16} from "@t3tools/contracts";
917import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
1018
1119import * as MobileDatabase from "./mobile-database";
......@@ -31,7 +39,8 @@ export interface Preferences {
3139 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
3240 readonly projectGroupingEnabled?: boolean;
3341 readonly projectGroupingMode?: SidebarProjectGroupingMode;
34 readonly autoSettleOnMerge?: boolean;
42 readonly autoSettleMode?: SidebarAutoSettleMode;
43 readonly autoSettleAfterDays?: SidebarAutoSettleAfterDays;
3544 /**
3645 * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has
3746 * no client-settings sync, so the legacy grouped thread list is opted into
......@@ -101,7 +110,8 @@ function sanitizePreferences(parsed: Preferences): Preferences {
101110 collapsedProjectGroups?: readonly string[];
102111 projectGroupingEnabled?: boolean;
103112 projectGroupingMode?: SidebarProjectGroupingMode;
104 autoSettleOnMerge?: boolean;
113 autoSettleMode?: SidebarAutoSettleMode;
114 autoSettleAfterDays?: SidebarAutoSettleAfterDays;
105115 legacyThreadListEnabled?: boolean;
106116 planModeEnabled?: boolean;
107117 threadListV2SettledShelfExpanded?: boolean;
......@@ -167,8 +177,20 @@ function sanitizePreferences(parsed: Preferences): Preferences {
167177 ) {
168178 preferences.projectGroupingMode = parsed.projectGroupingMode;
169179 }
170 if (typeof parsed.autoSettleOnMerge === "boolean") {
171 preferences.autoSettleOnMerge = parsed.autoSettleOnMerge;
180 if (
181 parsed.autoSettleMode === "never" ||
182 parsed.autoSettleMode === "change-request" ||
183 parsed.autoSettleMode === "inactivity"
184 ) {
185 preferences.autoSettleMode = parsed.autoSettleMode;
186 }
187 if (
188 typeof parsed.autoSettleAfterDays === "number" &&
189 Number.isInteger(parsed.autoSettleAfterDays) &&
190 parsed.autoSettleAfterDays >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS &&
191 parsed.autoSettleAfterDays <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS
192 ) {
193 preferences.autoSettleAfterDays = parsed.autoSettleAfterDays;
172194 }
173195 if (typeof parsed.legacyThreadListEnabled === "boolean") {
174196 preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
......@@ -185,6 +207,16 @@ function sanitizePreferences(parsed: Preferences): Preferences {
185207 return preferences;
186208}
187209
210export function resolveMobileAutoSettlePreferences(preferences: Preferences): {
211 readonly autoSettleMode: SidebarAutoSettleMode;
212 readonly autoSettleAfterDays: SidebarAutoSettleAfterDays;
213} {
214 return {
215 autoSettleMode: preferences.autoSettleMode ?? DEFAULT_SIDEBAR_AUTO_SETTLE_MODE,
216 autoSettleAfterDays: preferences.autoSettleAfterDays ?? DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
217 };
218}
219
188220export const make = Effect.fn("MobilePreferencesStore.make")(function* () {
189221 const database = yield* MobileDatabase.MobileDatabase;
190222 const secureStorage = yield* MobileSecureStorage.MobileSecureStorage;
apps/web/src/components/ChatView.tsx+6-8
......@@ -4394,7 +4394,7 @@ function ChatViewContent(props: ChatViewProps) {
43944394 ? activePlan.steps
43954395 : null;
43964396 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
4397 const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);
4397 const autoSettleMode = useClientSettings((settings) => settings.sidebarAutoSettleMode);
43984398 const linkedPullRequestStatus = useLinkedThreadPullRequest(
43994399 activeThreadRef?.environmentId ?? null,
44004400 linkedThreadPullRequest,
......@@ -4465,10 +4465,8 @@ function ChatViewContent(props: ChatViewProps) {
44654465 const activeThreadWokeVisible = useMemo(() => {
44664466 if (activeThreadWokeAt === null) return false;
44674467 if (
4468 changeRequestAutoSettles(activeThreadChangeRequest, {
4469 autoSettleOnMerge,
4470 thread: activeThreadShell,
4471 })
4468 autoSettleMode === "change-request" &&
4469 changeRequestAutoSettles(activeThreadChangeRequest, { thread: activeThreadShell })
44724470 ) {
44734471 return false;
44744472 }
......@@ -4494,21 +4492,21 @@ function ChatViewContent(props: ChatViewProps) {
44944492 activeThreadChangeRequest,
44954493 activeThreadShell,
44964494 activeThreadWokeAt,
4497 autoSettleOnMerge,
4495 autoSettleMode,
44984496 ]);
44994497 const activeThreadSettled = useMemo(() => {
45004498 if (activeThreadShell === null || !supportsSettlement) return false;
45014499 return effectiveSettled(activeThreadShell, {
45024500 now: `${nowMinute}:00.000Z`,
45034501 autoSettleAfterDays,
4504 autoSettleOnMerge,
4502 autoSettleMode,
45054503 changeRequest: activeThreadChangeRequest,
45064504 });
45074505 }, [
45084506 activeThreadChangeRequest,
45094507 activeThreadShell,
45104508 autoSettleAfterDays,
4511 autoSettleOnMerge,
4509 autoSettleMode,
45124510 changeRequestSnapshotByKey,
45134511 nowMinute,
45144512 supportsSettlement,
apps/web/src/components/Sidebar.tsx+7-10
......@@ -31,7 +31,7 @@ import {
3131 scopedThreadKey,
3232} from "@t3tools/client-runtime/environment";
3333import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";
34import type { TimestampFormat } from "@t3tools/contracts/settings";
34import type { SidebarAutoSettleMode, TimestampFormat } from "@t3tools/contracts/settings";
3535import {
3636 AlarmClockIcon,
3737 AlarmClockOffIcon,
......@@ -714,7 +714,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
714714 // False on environments whose server predates thread.settle/unsettle:
715715 // the lifecycle affordances hide entirely rather than fail on click.
716716 settlementSupported: boolean;
717 autoSettleOnMerge: boolean;
717 autoSettleMode: SidebarAutoSettleMode;
718718 // Same contract for thread.snooze/unsnooze.
719719 snoozeSupported: boolean;
720720 // Pinned threads show the same pin marker in active, settled, and snoozed
......@@ -839,10 +839,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
839839 const isWoke =
840840 wokeAtDate !== null &&
841841 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
842 !changeRequestAutoSettles(pr, {
843 autoSettleOnMerge: props.autoSettleOnMerge,
844 thread,
845 });
842 !(props.autoSettleMode === "change-request" && changeRequestAutoSettles(pr, { thread }));
846843 // In-flight rows (working, or waiting on approval/input) fade as a whole:
847844 // there is nothing for the user to do yet, so prominence is reserved for
848845 // rows that need a human — done (unread), read-but-unsettled, failed, and
......@@ -1740,7 +1737,7 @@ export default function Sidebar() {
17401737 const { isMobile, setOpenMobile } = useSidebar();
17411738 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
17421739 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
1743 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
1740 const autoSettleMode = useClientSettings((s) => s.sidebarAutoSettleMode);
17441741 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
17451742 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
17461743 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
......@@ -2125,7 +2122,7 @@ export default function Sidebar() {
21252122 effectiveSettled(thread, {
21262123 now,
21272124 autoSettleAfterDays,
2128 autoSettleOnMerge,
2125 autoSettleMode,
21292126 changeRequest,
21302127 })
21312128 ) {
......@@ -2164,7 +2161,7 @@ export default function Sidebar() {
21642161 };
21652162 }, [
21662163 autoSettleAfterDays,
2167 autoSettleOnMerge,
2164 autoSettleMode,
21682165 changeRequestSnapshotByKey,
21692166 nowMinute,
21702167 scopedProjectKeys,
......@@ -3787,7 +3784,7 @@ export default function Sidebar() {
37873784 serverConfigs.get(thread.environmentId)?.environment.capabilities
37883785 .threadSettlement === true
37893786 }
3790 autoSettleOnMerge={autoSettleOnMerge}
3787 autoSettleMode={autoSettleMode}
37913788 snoozeSupported={
37923789 serverConfigs.get(thread.environmentId)?.environment.capabilities
37933790 .threadSnooze === true
apps/web/src/components/ThreadStatusIndicators.test.ts+2
......@@ -47,6 +47,7 @@ function mergedFeaturePr(): NonNullable<VcsStatusResult["pr"]> {
4747 baseRef: "main",
4848 headRef: "feature/current",
4949 state: "merged",
50 updatedAt: "2026-04-10T00:00:00.000Z",
5051 };
5152}
5253
......@@ -545,6 +546,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
545546 effectiveSettled(shell, {
546547 now: "2026-04-10T00:00:00.000Z",
547548 autoSettleAfterDays: null,
549 autoSettleMode: "change-request",
548550 changeRequest: displayed,
549551 }),
550552 ).toBe(true);
apps/web/src/components/settings/SettingsPanels.logic.test.ts+20-1
......@@ -14,9 +14,10 @@ import {
1414 formatDiagnosticsDescription,
1515 getChangedBrowserSettingLabels,
1616 getChangedTypographySettingLabels,
17 isSamePreviewViewport,
1817 hasChangedBackgroundActivitySettings,
18 hasChangedThreadSettlingSettings,
1919 isProjectGroupingEnabled,
20 isSamePreviewViewport,
2021 projectGroupingModeFromToggle,
2122 resolveBackgroundActivityProfileOption,
2223} from "./SettingsPanels.logic";
......@@ -152,6 +153,24 @@ describe("project grouping toggle", () => {
152153 });
153154});
154155
156describe("thread settling settings restore", () => {
157 it("detects changes to the mode or the hidden inactivity window", () => {
158 expect(hasChangedThreadSettlingSettings(DEFAULT_UNIFIED_SETTINGS)).toBe(false);
159 expect(
160 hasChangedThreadSettlingSettings({
161 ...DEFAULT_UNIFIED_SETTINGS,
162 sidebarAutoSettleMode: "inactivity",
163 }),
164 ).toBe(true);
165 expect(
166 hasChangedThreadSettlingSettings({
167 ...DEFAULT_UNIFIED_SETTINGS,
168 sidebarAutoSettleAfterDays: 30,
169 }),
170 ).toBe(true);
171 });
172});
173
155174describe("formatDiagnosticsDescription", () => {
156175 it("collapses trace and metric URLs that share the same OTEL base path", () => {
157176 expect(
apps/web/src/components/settings/SettingsPanels.logic.ts+9
......@@ -31,6 +31,15 @@ export function projectGroupingModeFromToggle(
3131 return lastEnabledMode === "repository_path" ? "repository_path" : "repository";
3232}
3333
34export function hasChangedThreadSettlingSettings(
35 settings: Pick<UnifiedSettings, "sidebarAutoSettleMode" | "sidebarAutoSettleAfterDays">,
36): boolean {
37 return (
38 settings.sidebarAutoSettleMode !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleMode ||
39 settings.sidebarAutoSettleAfterDays !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays
40 );
41}
42
3443const LAST_ENABLED_PROJECT_GROUPING_MODE_KEY = "t3code:last-enabled-project-grouping-mode";
3544
3645export function readLastEnabledProjectGroupingMode(): SidebarProjectGroupingMode {
apps/web/src/components/settings/SettingsPanels.tsx+46-53
......@@ -8,6 +8,7 @@ import {
88 type DesktopUpdateChannel,
99 ProviderDriverKind,
1010 type ScopedThreadRef,
11 type SidebarAutoSettleMode,
1112 type SidebarProjectGroupingMode,
1213} from "@t3tools/contracts";
1314import { scopeThreadRef } from "@t3tools/client-runtime/environment";
......@@ -130,6 +131,7 @@ import {
130131 normalizeIntervalSeconds,
131132 PROVIDER_HEALTH_INTERVAL_STEP_SECONDS,
132133 hasChangedBackgroundActivitySettings,
134 hasChangedThreadSettlingSettings,
133135 isProjectGroupingEnabled,
134136 projectGroupingModeFromToggle,
135137 readLastEnabledProjectGroupingMode,
......@@ -471,6 +473,7 @@ export function useSettingsRestore(onRestored?: () => void) {
471473 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
472474 );
473475 const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings);
476 const isThreadSettlingDirty = hasChangedThreadSettlingSettings(settings);
474477
475478 const changedSettingLabels = useMemo(
476479 () => [
......@@ -495,13 +498,7 @@ export function useSettingsRestore(onRestored?: () => void) {
495498 DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode
496499 ? ["Project Grouping"]
497500 : []),
498 ...(settings.sidebarAutoSettleAfterDays !==
499 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays
500 ? ["Auto-settle inactive threads"]
501 : []),
502 ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge
503 ? ["Auto-settle merged threads"]
504 : []),
501 ...(isThreadSettlingDirty ? ["Thread settling"] : []),
505502 ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
506503 ...getChangedTypographySettingLabels(settings),
507504 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
......@@ -547,6 +544,7 @@ export function useSettingsRestore(onRestored?: () => void) {
547544 [
548545 isTextGenerationModelDirty,
549546 isBackgroundActivityDirty,
547 isThreadSettlingDirty,
550548 settings.browserDefaultViewport,
551549 settings.browserDefaultZoomFactor,
552550 settings.browserDefaultAppearance,
......@@ -572,8 +570,6 @@ export function useSettingsRestore(onRestored?: () => void) {
572570 settings.glassOpacity,
573571 settings.enableLegacyTokenStreaming,
574572 settings.enableProviderUpdateChecks,
575 settings.sidebarAutoSettleAfterDays,
576 settings.sidebarAutoSettleOnMerge,
577573 settings.sidebarProjectGroupingMode,
578574 settings.sidebarThreadPreviewCount,
579575 settings.showSkillsInSlashMenu,
......@@ -658,7 +654,7 @@ export function useSettingsRestore(onRestored?: () => void) {
658654 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
659655 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
660656 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
661 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
657 sidebarAutoSettleMode: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleMode,
662658 enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming,
663659 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
664660 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
......@@ -1685,6 +1681,11 @@ function FontFamilySettingsRow({
16851681}
16861682
16871683const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3;
1684const AUTO_SETTLE_MODE_LABELS: Readonly<Record<SidebarAutoSettleMode, string>> = {
1685 never: "Never (manual only)",
1686 "change-request": "When PR merges or closes",
1687 inactivity: "After inactivity",
1688};
16881689
16891690function AutoSettleDaysInput({
16901691 value,
......@@ -1946,67 +1947,59 @@ export function GeneralSettingsPanel() {
19461947 />
19471948
19481949 <SettingsRow
1949 {...searchableSetting("auto-settle-merged-threads")}
1950 description="Settle a thread when its pull request merges. Closed pull requests still settle automatically."
1950 {...searchableSetting("thread-settling")}
1951 description="Choose the only event allowed to settle a thread automatically. Manual settling always remains available."
19511952 resetAction={
1952 settings.sidebarAutoSettleOnMerge !==
1953 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? (
1953 hasChangedThreadSettlingSettings(settings) ? (
19541954 <SettingResetButton
1955 label="auto-settle on merge"
1955 label="thread settling"
19561956 onClick={() =>
19571957 updateSettings({
1958 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
1958 sidebarAutoSettleMode: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleMode,
1959 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
19591960 })
19601961 }
19611962 />
19621963 ) : null
19631964 }
19641965 control={
1965 <Switch
1966 checked={settings.sidebarAutoSettleOnMerge}
1967 onCheckedChange={(checked) =>
1968 updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) })
1969 }
1970 aria-label="Auto-settle merged threads"
1971 />
1972 }
1973 />
1974
1975 <SettingsRow
1976 {...searchableSetting("auto-settle-inactive-threads")}
1977 description="Sidebar threads with no activity for this long settle automatically."
1978 resetAction={
1979 settings.sidebarAutoSettleAfterDays !==
1980 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? (
1981 <SettingResetButton
1982 label="auto-settle"
1983 onClick={() =>
1966 <Select
1967 value={settings.sidebarAutoSettleMode}
1968 onValueChange={(value) => {
1969 if (value === "never" || value === "change-request" || value === "inactivity") {
19841970 updateSettings({
1985 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
1986 })
1971 sidebarAutoSettleMode: value,
1972 ...(value === "inactivity" && settings.sidebarAutoSettleAfterDays === null
1973 ? { sidebarAutoSettleAfterDays: AUTO_SETTLE_DEFAULT_DAYS }
1974 : {}),
1975 });
19871976 }
1988 />
1989 ) : null
1990 }
1991 control={
1992 <Switch
1993 checked={settings.sidebarAutoSettleAfterDays !== null}
1994 onCheckedChange={(checked) =>
1995 updateSettings({
1996 sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null,
1997 })
1998 }
1999 aria-label="Auto-settle inactive threads"
2000 />
1977 }}
1978 >
1979 <SelectTrigger className="w-full sm:w-64" aria-label="Thread settling">
1980 <SelectValue>{AUTO_SETTLE_MODE_LABELS[settings.sidebarAutoSettleMode]}</SelectValue>
1981 </SelectTrigger>
1982 <SelectPopup align="end" alignItemWithTrigger={false}>
1983 <SelectItem hideIndicator value="never">
1984 {AUTO_SETTLE_MODE_LABELS.never}
1985 </SelectItem>
1986 <SelectItem hideIndicator value="change-request">
1987 {AUTO_SETTLE_MODE_LABELS["change-request"]}
1988 </SelectItem>
1989 <SelectItem hideIndicator value="inactivity">
1990 {AUTO_SETTLE_MODE_LABELS.inactivity}
1991 </SelectItem>
1992 </SelectPopup>
1993 </Select>
20011994 }
20021995 />
2003 {settings.sidebarAutoSettleAfterDays !== null ? (
1996 {settings.sidebarAutoSettleMode === "inactivity" ? (
20041997 <SettingsRow
20051998 title="Days of inactivity before auto-settle"
2006 description="Any new activity un-settles a thread automatically."
1999 description="Running, blocked, and open-pull-request threads always stay active."
20072000 control={
20082001 <AutoSettleDaysInput
2009 value={settings.sidebarAutoSettleAfterDays}
2002 value={settings.sidebarAutoSettleAfterDays ?? AUTO_SETTLE_DEFAULT_DAYS}
20102003 onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}
20112004 />
20122005 }
apps/web/src/components/settings/settingsSearch.ts+2-7
......@@ -112,13 +112,8 @@ export const SETTINGS_SEARCH_ITEMS = [
112112 to: "/settings/general",
113113 },
114114 {
115 id: "auto-settle-inactive-threads",
116 title: "Auto-settle inactive threads",
117 to: "/settings/general",
118 },
119 {
120 id: "auto-settle-merged-threads",
121 title: "Auto-settle merged threads",
115 id: "thread-settling",
116 title: "Thread settling",
122117 to: "/settings/general",
123118 },
124119 {
apps/web/src/hooks/useThreadActionMenu.ts+3-3
......@@ -81,7 +81,7 @@ export function useThreadActionMenu(input: {
8181 const handleNewThread = useNewThreadHandler();
8282 const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
8383 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
84 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
84 const autoSettleMode = useClientSettings((s) => s.sidebarAutoSettleMode);
8585 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
8686 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
8787 const timestampFormat = useClientSettings((s) => s.timestampFormat);
......@@ -135,7 +135,7 @@ export function useThreadActionMenu(input: {
135135 // parked-thread banner within the same minute.
136136 now: `${now.toISOString().slice(0, 16)}:00.000Z`,
137137 autoSettleAfterDays,
138 autoSettleOnMerge,
138 autoSettleMode,
139139 changeRequest,
140140 }),
141141 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
......@@ -311,7 +311,7 @@ export function useThreadActionMenu(input: {
311311 [
312312 archiveThread,
313313 autoSettleAfterDays,
314 autoSettleOnMerge,
314 autoSettleMode,
315315 changeRequest,
316316 confirmThreadArchive,
317317 confirmThreadDelete,
docs/user/thread-sidebar.md+8-4
......@@ -4,15 +4,19 @@ Pin a thread from its context menu to keep it in the pinned section above your a
44`mod+shift+p` pins or unpins the thread you have open. Pinned threads are shown independently of
55their project, including when you connect to more than one environment.
66
7Pinned threads still move to **Settled** when they become inactive. They also move when their pull
8request merges if **Auto-settle merged threads** is enabled.
7Pinned threads follow the auto-settle policy in **Settings → General**. Choose exactly one policy:
8settle when a pull request merges or closes, settle after a configurable period of inactivity, or
9never auto-settle. **Never** leaves every automatic path off, so threads settle only when you choose
10**Settle**.
911
1012When you un-settle a thread, it returns to the top of the active list so you can find it right
1113away. Its timestamps do not change. Other threads keep their positions.
1214
1315Right-click a pull request link in a thread and choose **Link to thread** to show that pull request
14in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged
15threads** is enabled. Right-click the same link and choose **Unlink from thread** to remove it.
16in the sidebar. The thread settles when the linked pull request merges or closes only when the
17pull-request policy is selected. Right-click the same link and choose **Unlink from thread** to
18remove it. If T3 Code cannot determine when a pull request entered its terminal state, it keeps the
19thread active.
1620
1721On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu
1822and choose **Move up** or **Move down**. The order is stored by the server and appears on your
oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.ts+1
......@@ -17,6 +17,7 @@ const THEME_INTEROP_ALLOWLIST = new Set([
1717 "features/home/HomeHeader.tsx",
1818 "features/review/ReviewSheet.tsx",
1919 "features/review/useNativeReviewDiffBridge.ts",
20 "features/settings/SettingsAutoSettleRouteScreen.tsx",
2021 "features/settings/SettingsEnvironmentsRouteScreen.tsx",
2122 "features/settings/appearance/components/AppearancePreviews.tsx",
2223 "features/settings/appearance/components/FontSizeSliderRow.tsx",
packages/client-runtime/src/state/threadSettled.test.ts+116-74
......@@ -21,18 +21,6 @@ const FRESH = "2026-04-09T00:00:00.000Z";
2121const STALE = "2026-04-06T23:59:59.999Z";
2222
2323describe("changeRequestAutoSettles", () => {
24 it.each([
25 ["open", true, false],
26 ["merged", true, true],
27 ["merged", false, false],
28 ["closed", false, true],
29 [null, false, false],
30 ] as const)("state=%s autoSettleOnMerge=%s returns %s", (state, autoSettleOnMerge, expected) => {
31 expect(changeRequestAutoSettles(state === null ? null : { state }, { autoSettleOnMerge })).toBe(
32 expected,
33 );
34 });
35
3624 const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z";
3725 const idleThread = {
3826 createdAt: THREAD_CREATED_AT,
......@@ -40,6 +28,19 @@ describe("changeRequestAutoSettles", () => {
4028 latestTurn: null,
4129 };
4230
31 it.each([
32 ["open", false],
33 ["merged", true],
34 ["closed", true],
35 [null, false],
36 ] as const)("state=%s returns %s", (state, expected) => {
37 expect(
38 changeRequestAutoSettles(state === null ? null : { state, updatedAt: THREAD_CREATED_AT }, {
39 thread: idleThread,
40 }),
41 ).toBe(expected);
42 });
43
4344 it("ignores a terminal change request last touched before the thread existed", () => {
4445 for (const state of ["merged", "closed"] as const) {
4546 expect(
......@@ -105,20 +106,20 @@ describe("changeRequestAutoSettles", () => {
105106 ).toBe(true);
106107 });
107108
108 it("falls back to settling when either timestamp is missing or malformed", () => {
109 expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(true);
109 it("keeps the thread active when either timestamp is missing or malformed", () => {
110 expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(false);
110111 expect(
111112 changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }),
112 ).toBe(true);
113 ).toBe(false);
113114 expect(
114115 changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}),
115 ).toBe(true);
116 ).toBe(false);
116117 expect(
117118 changeRequestAutoSettles(
118119 { state: "merged", updatedAt: "not-a-date" },
119120 { thread: idleThread },
120121 ),
121 ).toBe(true);
122 ).toBe(false);
122123 });
123124});
124125
......@@ -204,38 +205,47 @@ describe("effectiveSettled", () => {
204205 ] as const;
205206 const runningCases = [false, true] as const;
206207 const pendingCases = [undefined, "approval", "user-input"] as const;
207 const truthTable = overrideCases.flatMap((settledOverride) =>
208 changeRequestStates.flatMap((changeRequestState) =>
209 inactivityCases.flatMap(([inactivity, activityAt]) =>
210 runningCases.flatMap((running) =>
211 pendingCases.map((pending) => ({
212 settledOverride,
213 changeRequestState,
214 inactivity,
215 activityAt,
216 running,
217 pending,
218 // Settled iff nothing blocks (pending work / live session) AND
219 // the override says settled, or (with no override) a merged PR
220 // or staleness auto-settles. The "active" pin suppresses both
221 // auto signals, and an open PR suppresses the inactivity path:
222 // a thread with a PR out for review is never done, however quiet.
223 expected:
224 pending === undefined &&
225 !running &&
226 (settledOverride === "settled" ||
227 (settledOverride === null &&
228 (changeRequestState === "merged" ||
229 (changeRequestState !== "open" && inactivity === "stale")))),
230 })),
208 const autoSettleModes = ["never", "change-request", "inactivity"] as const;
209 const truthTable = autoSettleModes.flatMap((autoSettleMode) =>
210 overrideCases.flatMap((settledOverride) =>
211 changeRequestStates.flatMap((changeRequestState) =>
212 inactivityCases.flatMap(([inactivity, activityAt]) =>
213 runningCases.flatMap((running) =>
214 pendingCases.map((pending) => ({
215 autoSettleMode,
216 settledOverride,
217 changeRequestState,
218 inactivity,
219 activityAt,
220 running,
221 pending,
222 expected:
223 pending === undefined &&
224 !running &&
225 (settledOverride === "settled" ||
226 (settledOverride === null &&
227 ((autoSettleMode === "change-request" && changeRequestState === "merged") ||
228 (autoSettleMode === "inactivity" &&
229 changeRequestState !== "open" &&
230 inactivity === "stale")))),
231 })),
232 ),
231233 ),
232234 ),
233235 ),
234236 );
235237
236238 it.each(truthTable)(
237 "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending",
238 ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => {
239 "mode=$autoSettleMode override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending",
240 ({
241 autoSettleMode,
242 settledOverride,
243 changeRequestState,
244 activityAt,
245 running,
246 pending,
247 expected,
248 }) => {
239249 const shell = makeShell({
240250 settledOverride,
241251 activityAt,
......@@ -245,12 +255,18 @@ describe("effectiveSettled", () => {
245255 const changeRequestOptions =
246256 changeRequestState === undefined
247257 ? {}
248 : { changeRequest: { state: changeRequestState as ChangeRequestStateLike } };
258 : {
259 changeRequest: {
260 state: changeRequestState as ChangeRequestStateLike,
261 updatedAt: NOW,
262 },
263 };
249264
250265 expect(
251266 effectiveSettled(shell, {
252267 now: NOW,
253268 autoSettleAfterDays: 3,
269 autoSettleMode,
254270 ...changeRequestOptions,
255271 }),
256272 ).toBe(expected);
......@@ -263,7 +279,8 @@ describe("effectiveSettled", () => {
263279 effectiveSettled(shell, {
264280 now: NOW,
265281 autoSettleAfterDays: null,
266 changeRequest: { state: "closed" },
282 autoSettleMode: "change-request",
283 changeRequest: { state: "closed", updatedAt: NOW },
267284 }),
268285 ).toBe(true);
269286 });
......@@ -275,7 +292,8 @@ describe("effectiveSettled", () => {
275292 effectiveSettled(recentlyActive, {
276293 now: NOW,
277294 autoSettleAfterDays: null,
278 changeRequest: { state: changeRequestState },
295 autoSettleMode: "change-request",
296 changeRequest: { state: changeRequestState, updatedAt: NOW },
279297 }),
280298 ).toBe(true);
281299 }
......@@ -291,6 +309,7 @@ describe("effectiveSettled", () => {
291309 effectiveSettled(fresh, {
292310 now: NOW,
293311 autoSettleAfterDays: null,
312 autoSettleMode: "change-request",
294313 changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" },
295314 }),
296315 ).toBe(false);
......@@ -300,30 +319,24 @@ describe("effectiveSettled", () => {
300319 effectiveSettled(fresh, {
301320 now: NOW,
302321 autoSettleAfterDays: null,
322 autoSettleMode: "change-request",
303323 changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" },
304324 }),
305325 ).toBe(true);
306326 });
307327
308 it("can keep a merged change request active", () => {
328 it("keeps a merged change request active in every other mode", () => {
309329 const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });
310 expect(
311 effectiveSettled(recentlyActive, {
312 now: NOW,
313 autoSettleAfterDays: null,
314 autoSettleOnMerge: false,
315 changeRequest: { state: "merged" },
316 }),
317 ).toBe(false);
318
319 expect(
320 effectiveSettled(recentlyActive, {
321 now: NOW,
322 autoSettleAfterDays: null,
323 autoSettleOnMerge: false,
324 changeRequest: { state: "closed" },
325 }),
326 ).toBe(true);
330 for (const autoSettleMode of ["never", "inactivity"] as const) {
331 expect(
332 effectiveSettled(recentlyActive, {
333 now: NOW,
334 autoSettleAfterDays: null,
335 autoSettleMode,
336 changeRequest: { state: "merged", updatedAt: NOW },
337 }),
338 ).toBe(false);
339 }
327340 });
328341
329342 it("never auto-settles a stale thread with an open change request", () => {
......@@ -332,6 +345,7 @@ describe("effectiveSettled", () => {
332345 effectiveSettled(stale, {
333346 now: NOW,
334347 autoSettleAfterDays: 3,
348 autoSettleMode: "inactivity",
335349 changeRequest: { state: "open" },
336350 }),
337351 ).toBe(false);
......@@ -341,6 +355,7 @@ describe("effectiveSettled", () => {
341355 effectiveSettled(settled, {
342356 now: NOW,
343357 autoSettleAfterDays: 3,
358 autoSettleMode: "inactivity",
344359 changeRequest: { state: "open" },
345360 }),
346361 ).toBe(true);
......@@ -355,7 +370,8 @@ describe("effectiveSettled", () => {
355370 effectiveSettled(shell, {
356371 now: NOW,
357372 autoSettleAfterDays: null,
358 changeRequest: { state: "merged" },
373 autoSettleMode: "change-request",
374 changeRequest: { state: "merged", updatedAt: NOW },
359375 }),
360376 ).toBe(false);
361377 });
......@@ -370,7 +386,8 @@ describe("effectiveSettled", () => {
370386 effectiveSettled(shell, {
371387 now: NOW,
372388 autoSettleAfterDays: 3,
373 changeRequest: { state: "merged" },
389 autoSettleMode: "change-request",
390 changeRequest: { state: "merged", updatedAt: NOW },
374391 }),
375392 ).toBe(false);
376393 });
......@@ -414,7 +431,8 @@ describe("effectiveSettled", () => {
414431 effectiveSettled(shell, {
415432 now: transitionNow,
416433 autoSettleAfterDays: 3,
417 changeRequest: { state: "merged" },
434 autoSettleMode: "change-request",
435 changeRequest: { state: "merged", updatedAt: transitionNow },
418436 }),
419437 ).toBe(false);
420438 }
......@@ -426,8 +444,20 @@ describe("effectiveSettled", () => {
426444 });
427445 const stale = makeShell({ activityAt: STALE });
428446
429 expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);
430 expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false);
447 expect(
448 effectiveSettled(boundary, {
449 now: NOW,
450 autoSettleAfterDays: 3,
451 autoSettleMode: "inactivity",
452 }),
453 ).toBe(false);
454 expect(
455 effectiveSettled(stale, {
456 now: NOW,
457 autoSettleAfterDays: null,
458 autoSettleMode: "inactivity",
459 }),
460 ).toBe(false);
431461 });
432462});
433463
......@@ -532,7 +562,8 @@ describe("canSettle", () => {
532562 effectiveSettled(queued, {
533563 now: justAfter,
534564 autoSettleAfterDays: 3,
535 changeRequest: { state: "merged" },
565 autoSettleMode: "change-request",
566 changeRequest: { state: "merged", updatedAt: justAfter },
536567 }),
537568 ).toBe(false);
538569 // Past the window the message is a failed/stale start: settleable again.
......@@ -554,9 +585,13 @@ describe("canSettle", () => {
554585 settledAt: "2026-04-09T12:02:10.000Z",
555586 };
556587 expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true);
557 expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe(
558 true,
559 );
588 expect(
589 effectiveSettled(settledAfterMessage, {
590 now: flooredNow,
591 autoSettleAfterDays: 3,
592 autoSettleMode: "never",
593 }),
594 ).toBe(true);
560595
561596 // A message NEWER than settledAt is genuinely new work: still blocked
562597 // until the server's auto-unsettle lands.
......@@ -569,6 +604,7 @@ describe("canSettle", () => {
569604 effectiveSettled(messageAfterSettle, {
570605 now: "2026-04-09T12:03:30.000Z",
571606 autoSettleAfterDays: 3,
607 autoSettleMode: "never",
572608 }),
573609 ).toBe(false);
574610 });
......@@ -582,6 +618,12 @@ describe("canSettle", () => {
582618 pending: "user-input",
583619 });
584620 expect(canSettle(blocked, { now: NOW })).toBe(false);
585 expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);
621 expect(
622 effectiveSettled(blocked, {
623 now: NOW,
624 autoSettleAfterDays: 3,
625 autoSettleMode: "never",
626 }),
627 ).toBe(false);
586628 });
587629});
packages/client-runtime/src/state/threadSettled.ts+17-25
......@@ -1,5 +1,5 @@
11// @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels.
2import type { OrchestrationThreadShell } from "@t3tools/contracts";
2import type { OrchestrationThreadShell, SidebarAutoSettleMode } from "@t3tools/contracts";
33
44export type ChangeRequestStateLike = "open" | "closed" | "merged";
55
......@@ -46,26 +46,22 @@ function threadUserActivityAnchorAt(thread: ThreadActivitySource): string {
4646 * thread started at a worktree root whose PR already merged), and one older
4747 * than the user's latest engagement was already adjudicated — re-engaging a
4848 * thread whose PR merged is the user saying the conversation outlived the
49 * PR. Unknown timestamps keep the old always-settle behavior.
49 * PR. Missing or malformed timestamps keep the thread active: an automatic
50 * transition must have enough evidence to be predictable.
5051 */
5152export function changeRequestAutoSettles(
5253 changeRequest: ChangeRequestSettleSource | null | undefined,
5354 options: {
54 readonly autoSettleOnMerge?: boolean | undefined;
5555 readonly thread?: ThreadActivitySource | null | undefined;
5656 } = {},
5757): boolean {
5858 if (changeRequest == null) return false;
59 const terminal =
60 changeRequest.state === "closed" ||
61 (changeRequest.state === "merged" && options.autoSettleOnMerge !== false);
59 const terminal = changeRequest.state === "closed" || changeRequest.state === "merged";
6260 if (!terminal) return false;
63 if (changeRequest.updatedAt == null || options.thread == null) return true;
61 if (changeRequest.updatedAt == null || options.thread == null) return false;
6462 const updatedAtMs = Date.parse(changeRequest.updatedAt);
6563 const anchorAtMs = Date.parse(threadUserActivityAnchorAt(options.thread));
66 // Malformed timestamps fall back to settling, matching servers that never
67 // report updatedAt.
68 if (Number.isNaN(updatedAtMs) || Number.isNaN(anchorAtMs)) return true;
64 if (Number.isNaN(updatedAtMs) || Number.isNaN(anchorAtMs)) return false;
6965 return updatedAtMs >= anchorAtMs;
7066}
7167
......@@ -289,11 +285,11 @@ export function threadWokeAt(
289285 * queued turn) are checked first and hold a thread active regardless of any
290286 * override. Past the blockers, the explicit user override (thread.settle /
291287 * thread.unsettle commands, projected into settledOverride + settledAt)
292 * wins in both directions; without one, a thread can auto-settle on a
293 * merged PR or always on a closed PR (both only while the terminal state is
294 * the thread's latest event, see changeRequestAutoSettles), or settles on
295 * inactivity past the window.
296 * An open PR blocks the inactivity path entirely. The server
288 * wins in both directions. Without one, exactly one selected policy can
289 * auto-settle: a merged/closed pull request whose terminal timestamp is the
290 * thread's latest user event (see changeRequestAutoSettles), inactivity past
291 * the configured window, or never. An open PR blocks the inactivity path.
292 * The server
297293 * un-settles on real activity (user message, session start, approval/
298294 * user-input request), so an override never goes stale silently.
299295 */
......@@ -302,7 +298,7 @@ export function effectiveSettled(
302298 options: {
303299 readonly now: string;
304300 readonly autoSettleAfterDays: number | null;
305 readonly autoSettleOnMerge?: boolean;
301 readonly autoSettleMode: SidebarAutoSettleMode;
306302 readonly changeRequest?: ChangeRequestSettleSource | null;
307303 },
308304): boolean {
......@@ -329,18 +325,14 @@ export function effectiveSettled(
329325 // "active" is the explicit keep-active pin: it suppresses auto-settle
330326 // until real activity clears it server-side.
331327 if (shell.settledOverride === "active") return false;
332 if (
333 changeRequestAutoSettles(options.changeRequest, {
334 autoSettleOnMerge: options.autoSettleOnMerge,
335 thread: shell,
336 })
337 ) {
338 return true;
328 if (options.autoSettleMode === "never") return false;
329 if (options.autoSettleMode === "change-request") {
330 return changeRequestAutoSettles(options.changeRequest, { thread: shell });
339331 }
340332 // An open PR is unfinished business regardless of how long the thread has
341333 // been quiet: review can take days, and hiding the thread would bury the
342 // work waiting on it. A configured merge, a close, or an explicit user
343 // settle resolves it.
334 // work waiting on it. Only an explicit user settle resolves it while the
335 // inactivity policy is selected.
344336 if (options.changeRequest?.state === "open") return false;
345337 if (options.autoSettleAfterDays === null) return false;
346338
packages/contracts/src/settings.test.ts+23-9
......@@ -117,11 +117,11 @@ describe("ClientSettings environment identification", () => {
117117});
118118
119119describe("ClientSettings sidebar", () => {
120 it("defaults to the current sidebar with automatic merge and inactivity settling", () => {
120 it("defaults to the current sidebar with manual settling", () => {
121121 const settings = decodeClientSettings({});
122122 expect(settings.legacySidebarEnabled).toBe(false);
123123 expect(settings.sidebarAutoSettleAfterDays).toBe(3);
124 expect(settings.sidebarAutoSettleOnMerge).toBe(true);
124 expect(settings.sidebarAutoSettleMode).toBe("never");
125125 });
126126
127127 it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => {
......@@ -147,13 +147,27 @@ describe("ClientSettings sidebar", () => {
147147 ).toBeNull();
148148 });
149149
150 it("allows auto-settle on merge to be disabled", () => {
151 expect(decodeClientSettings({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge).toBe(
152 false,
153 );
154 expect(
155 decodeClientSettingsPatch({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge,
156 ).toBe(false);
150 it("does not preserve the retired merge switch as a hidden automatic path", () => {
151 const decoded = decodeClientSettings({ sidebarAutoSettleOnMerge: true });
152 expect(decoded.sidebarAutoSettleMode).toBe("never");
153 expect(decoded).not.toHaveProperty("sidebarAutoSettleOnMerge");
154 });
155
156 it.each(["never", "change-request", "inactivity"] as const)(
157 "accepts the %s auto-settle mode",
158 (sidebarAutoSettleMode) => {
159 expect(decodeClientSettings({ sidebarAutoSettleMode }).sidebarAutoSettleMode).toBe(
160 sidebarAutoSettleMode,
161 );
162 expect(decodeClientSettingsPatch({ sidebarAutoSettleMode }).sidebarAutoSettleMode).toBe(
163 sidebarAutoSettleMode,
164 );
165 },
166 );
167
168 it("rejects unsupported auto-settle modes", () => {
169 expect(() => decodeClientSettings({ sidebarAutoSettleMode: "always" })).toThrow();
170 expect(() => decodeClientSettingsPatch({ sidebarAutoSettleMode: "always" })).toThrow();
157171 });
158172
159173 it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => {
packages/contracts/src/settings.ts+10-2
......@@ -65,6 +65,9 @@ export const SidebarAutoSettleAfterDays = Schema.Number.check(
6565);
6666export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type;
6767export const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3;
68export const SidebarAutoSettleMode = Schema.Literals(["never", "change-request", "inactivity"]);
69export type SidebarAutoSettleMode = typeof SidebarAutoSettleMode.Type;
70export const DEFAULT_SIDEBAR_AUTO_SETTLE_MODE: SidebarAutoSettleMode = "never";
6871export const MIN_GLASS_OPACITY = 40;
6972export const MAX_GLASS_OPACITY = 100;
7073export const GlassOpacity = Schema.Int.check(
......@@ -234,7 +237,12 @@ export const ClientSettingsSchema = Schema.Struct({
234237 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
235238 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
236239 ),
237 sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
240 // A fresh exclusive policy replaces the old independent inactivity and
241 // merge switches. Missing mode means manual-only, so an upgrade cannot
242 // preserve one hidden automatic path after the user disabled another.
243 sidebarAutoSettleMode: SidebarAutoSettleMode.pipe(
244 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_MODE)),
245 ),
238246 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
239247 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
240248 ),
......@@ -942,7 +950,7 @@ export const ClientSettingsPatch = Schema.Struct({
942950 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
943951 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
944952 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
945 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
953 sidebarAutoSettleMode: Schema.optionalKey(SidebarAutoSettleMode),
946954 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
947955 sidebarProjectGroupingOverrides: Schema.optionalKey(
948956 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),