authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-28 17:01:43-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-28 17:01:43-07:00
log38154388d7b0274aefd3f29c38db3b2659c46c29
tree7622c4283ba5c8494160d8e5b08ce99d2e98bed4
parent22c311ddecfbab2e541a374a46f2df87d4fc6305
signature Signed by PGP key B5690EEEBB952194

fix: restore automatic thread settling defaults (#8596)


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

apps/desktop/src/settings/DesktopClientSettings.test.ts+1-1
......@@ -40,7 +40,7 @@ const clientSettings: ClientSettings = {
4040 showSkillsInSlashMenu: false,
4141 providerModelPreferences: {},
4242 sidebarAutoSettleAfterDays: 3,
43 sidebarAutoSettleMode: "change-request",
43 sidebarAutoSettleOnMerge: true,
4444 sidebarProjectGroupingMode: "repository_path",
4545 sidebarProjectGroupingOverrides: {
4646 "environment-1:/tmp/project-a": "separate",
apps/mobile/src/Stack.tsx-8
......@@ -51,7 +51,6 @@ 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";
5554import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";
5655import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
5756import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
......@@ -178,13 +177,6 @@ const SettingsContentStack = createNativeStackNavigator({
178177 title: "Appearance",
179178 },
180179 }),
181 SettingsAutoSettle: createNativeStackScreen({
182 screen: SettingsAutoSettleRouteScreen,
183 linking: "thread-settling",
184 options: {
185 title: "Thread Settling",
186 },
187 }),
188180 SettingsProjectGrouping: createNativeStackScreen({
189181 screen: SettingsProjectGroupingRouteScreen,
190182 linking: "project-grouping",
apps/mobile/src/features/home/HomeScreen.tsx+5-8
......@@ -30,7 +30,6 @@ 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";
3433import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
3534import { useThreadSearch } from "../../state/queries";
3635import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
......@@ -209,9 +208,9 @@ export function HomeScreen(props: HomeScreenProps) {
209208 >(() => new Map());
210209 const preferencesResult = useAtomValue(mobilePreferencesAtom);
211210 const threadListV2Enabled = useThreadListV2Enabled();
212 const autoSettlePreferences = resolveMobileAutoSettlePreferences(
213 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {},
214 );
211 const autoSettleOnMerge =
212 !AsyncResult.isSuccess(preferencesResult) ||
213 preferencesResult.value.autoSettleOnMerge !== false;
215214 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
216215 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
217216 const listRef = useRef<LegendListRef | null>(null);
......@@ -679,8 +678,7 @@ export function HomeScreen(props: HomeScreenProps) {
679678 searchQuery: props.searchQuery,
680679 matchedThreadKeys,
681680 changeRequestByKey,
682 autoSettleAfterDays: autoSettlePreferences.autoSettleAfterDays,
683 autoSettleMode: autoSettlePreferences.autoSettleMode,
681 autoSettleOnMerge,
684682 settlementEnvironmentIds,
685683 snoozeEnvironmentIds,
686684 settledLimit: settledVisibleCount,
......@@ -692,8 +690,7 @@ export function HomeScreen(props: HomeScreenProps) {
692690 });
693691 }, [
694692 changeRequestByKey,
695 autoSettlePreferences.autoSettleAfterDays,
696 autoSettlePreferences.autoSettleMode,
693 autoSettleOnMerge,
697694 nowMinute,
698695 snoozeWakeTick,
699696 snoozedShelfExpanded,
apps/mobile/src/features/settings/SettingsAutoSettleRouteScreen.tsx deleted-173
......@@ -1,173 +0,0 @@
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+8-9
......@@ -4,7 +4,6 @@ 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";
87import { SymbolView } from "../../components/AppSymbol";
98import * as Effect from "effect/Effect";
109import { AsyncResult } from "effect/unstable/reactivity";
......@@ -46,7 +45,6 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
4645import { SettingsRow } from "./components/SettingsRow";
4746import { SettingsSection } from "./components/SettingsSection";
4847import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
49import { mobileAutoSettleModeLabel } from "./SettingsAutoSettleRouteScreen";
5048import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";
5149
5250type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
......@@ -530,18 +528,19 @@ function ConfiguredSettingsRouteScreen() {
530528
531529function GeneralSettingsSection() {
532530 const preferencesResult = useAtomValue(mobilePreferencesAtom);
533 const autoSettlePreferences = resolveMobileAutoSettlePreferences(
534 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {},
535 );
531 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
532 const autoSettleOnMerge =
533 !AsyncResult.isSuccess(preferencesResult) ||
534 preferencesResult.value.autoSettleOnMerge !== false;
536535
537536 return (
538537 <SettingsSection title="General">
539538 <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
540 <SettingsRow
539 <SettingsSwitchRow
541540 icon="arrow.triangle.branch"
542 label="Thread Settling"
543 value={mobileAutoSettleModeLabel(autoSettlePreferences.autoSettleMode)}
544 target="SettingsAutoSettle"
541 label="Auto-settle merged threads"
542 value={autoSettleOnMerge}
543 onValueChange={(value) => savePreferences({ autoSettleOnMerge: value })}
545544 />
546545 <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
547546 </SettingsSection>
apps/mobile/src/features/settings/components/settings-sheet-targets.ts-1
......@@ -2,7 +2,6 @@ export type SettingsSheetTarget =
22 | "SettingsEnvironments"
33 | "SettingsArchive"
44 | "SettingsAppearance"
5 | "SettingsAutoSettle"
65 | "SettingsProjectGrouping"
76 | "SettingsClientStorage"
87 | "SettingsUsage";
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+5-8
......@@ -26,7 +26,6 @@ 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";
3029import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
3130import { useProjects, useThreadShells } from "../../state/entities";
3231import { mobilePreferencesAtom } from "../../state/preferences";
......@@ -166,9 +165,9 @@ function ThreadNavigationSidebarPane(
166165 } = useThreadListActions();
167166 const threadListV2Enabled = useThreadListV2Enabled();
168167 const preferencesResult = useAtomValue(mobilePreferencesAtom);
169 const autoSettlePreferences = resolveMobileAutoSettlePreferences(
170 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {},
171 );
168 const autoSettleOnMerge =
169 !AsyncResult.isSuccess(preferencesResult) ||
170 preferencesResult.value.autoSettleOnMerge !== false;
172171 const pendingTasks = usePendingNewTasks();
173172 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
174173 const environments = useMemo(
......@@ -511,8 +510,7 @@ function ThreadNavigationSidebarPane(
511510 searchQuery: props.searchQuery,
512511 matchedThreadKeys,
513512 changeRequestByKey,
514 autoSettleAfterDays: autoSettlePreferences.autoSettleAfterDays,
515 autoSettleMode: autoSettlePreferences.autoSettleMode,
513 autoSettleOnMerge,
516514 settlementEnvironmentIds,
517515 snoozeEnvironmentIds,
518516 settledLimit: settledVisibleCount,
......@@ -524,8 +522,7 @@ function ThreadNavigationSidebarPane(
524522 });
525523 }, [
526524 changeRequestByKey,
527 autoSettlePreferences.autoSettleAfterDays,
528 autoSettlePreferences.autoSettleMode,
525 autoSettleOnMerge,
529526 nowMinute,
530527 snoozeWakeTick,
531528 snoozedShelfExpanded,
apps/mobile/src/features/threads/threadListV2.test.ts+7-15
......@@ -360,12 +360,10 @@ describe("buildThreadListV2Items", () => {
360360 `${environmentId}:${thread.id}`,
361361 {
362362 state: "merged" as const,
363 updatedAt: NOW,
364363 linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
365364 },
366365 ],
367366 ]),
368 autoSettleMode: "change-request",
369367 now: NOW,
370368 });
371369
......@@ -373,16 +371,16 @@ describe("buildThreadListV2Items", () => {
373371 expect(layout.items[0]?.variant).toBe("slim");
374372 });
375373
376 it("keeps a merged thread active when automatic settling is off", () => {
374 it("keeps a merged thread active when auto-settle on merge is off", () => {
377375 const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });
378376 const layout = buildThreadListV2Items({
379377 threads: [merged],
380378 environmentId: null,
381379 searchQuery: "",
382380 changeRequestByKey: new Map([
383 [`${environmentId}:${merged.id}`, { state: "merged" as const, updatedAt: NOW }],
381 [`${environmentId}:${merged.id}`, { state: "merged" as const }],
384382 ]),
385 autoSettleMode: "never",
383 autoSettleOnMerge: false,
386384 now: NOW,
387385 });
388386
......@@ -451,10 +449,7 @@ describe("buildThreadListV2Items", () => {
451449 threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged],
452450 environmentId: null,
453451 searchQuery: "",
454 changeRequestByKey: new Map([
455 [`${environmentId}:${merged.id}`, { state: "merged", updatedAt: NOW }],
456 ]),
457 autoSettleMode: "change-request",
452 changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]),
458453 now: NOW,
459454 });
460455
......@@ -483,7 +478,6 @@ describe("buildThreadListV2Items", () => {
483478 threads: [inactive],
484479 environmentId: null,
485480 searchQuery: "",
486 autoSettleMode: "inactivity",
487481 now: NOW,
488482 });
489483
......@@ -495,7 +489,7 @@ describe("buildThreadListV2Items", () => {
495489 expect(layout.settledCount).toBe(1);
496490 });
497491
498 it("keeps pinned merged threads pinned when automatic settling is off", () => {
492 it("keeps pinned merged threads pinned when auto-settle on merge is off", () => {
499493 const merged = makeThread({
500494 id: ThreadId.make("pinned-merged"),
501495 title: "Pinned merged pull request",
......@@ -505,10 +499,8 @@ describe("buildThreadListV2Items", () => {
505499 threads: [merged],
506500 environmentId: null,
507501 searchQuery: "",
508 changeRequestByKey: new Map([
509 [`${environmentId}:${merged.id}`, { state: "merged", updatedAt: NOW }],
510 ]),
511 autoSettleMode: "never",
502 changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]),
503 autoSettleOnMerge: false,
512504 now: NOW,
513505 });
514506
apps/mobile/src/features/threads/threadListV2.ts+4-9
......@@ -16,12 +16,7 @@ import {
1616 activeThreadAnchorTimestampMs,
1717 sortPinnedThreadsByOrderKey,
1818} from "@t3tools/client-runtime/state/thread-sort";
19import type {
20 EnvironmentId,
21 ProjectId,
22 SidebarAutoSettleMode,
23 ThreadLinkedPullRequest,
24} from "@t3tools/contracts";
19import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts";
2520
2621import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2722
......@@ -374,7 +369,7 @@ export function buildThreadListV2Items(input: {
374369 contract as settlementEnvironmentIds. */
375370 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
376371 readonly autoSettleAfterDays?: number;
377 readonly autoSettleMode?: SidebarAutoSettleMode;
372 readonly autoSettleOnMerge?: boolean;
378373 /** Max settled rows to render; the rest are counted, not built. */
379374 readonly settledLimit?: number;
380375 /** Injectable for tests; defaults to now. */
......@@ -395,7 +390,7 @@ export function buildThreadListV2Items(input: {
395390 const now = input.now ?? new Date().toISOString();
396391 const snoozeNow = input.snoozeNow ?? now;
397392 const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
398 const autoSettleMode = input.autoSettleMode ?? "never";
393 const autoSettleOnMerge = input.autoSettleOnMerge ?? true;
399394 const query = input.searchQuery.trim().toLocaleLowerCase();
400395 const projectKeys = input.projectRefs
401396 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
......@@ -452,7 +447,7 @@ export function buildThreadListV2Items(input: {
452447 effectiveSettled(thread, {
453448 now,
454449 autoSettleAfterDays,
455 autoSettleMode,
450 autoSettleOnMerge,
456451 changeRequest,
457452 })
458453 ) {
apps/mobile/src/lib/storage.test.ts-29
......@@ -100,7 +100,6 @@ import {
100100 saveConnection,
101101 savePreferencesPatch,
102102} from "../persistence/imperative";
103import { resolveMobileAutoSettlePreferences } from "../persistence/mobile-preferences";
104103import { toStableSavedRemoteConnection } from "./connection";
105104
106105const managedConnection = {
......@@ -203,34 +202,6 @@ describe("mobile connection storage", () => {
203202 await expect(loadPreferences()).resolves.toEqual({});
204203 });
205204
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
234205 it("falls back to secure storage when SQLite cannot save preferences", async () => {
235206 mocks.setDatabaseFailures(true, true);
236207 await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 });
apps/mobile/src/persistence/mobile-preferences.ts+5-37
......@@ -5,15 +5,7 @@ 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 {
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";
8import type { SidebarProjectGroupingMode } from "@t3tools/contracts";
179import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
1810
1911import * as MobileDatabase from "./mobile-database";
......@@ -39,8 +31,7 @@ export interface Preferences {
3931 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
4032 readonly projectGroupingEnabled?: boolean;
4133 readonly projectGroupingMode?: SidebarProjectGroupingMode;
42 readonly autoSettleMode?: SidebarAutoSettleMode;
43 readonly autoSettleAfterDays?: SidebarAutoSettleAfterDays;
34 readonly autoSettleOnMerge?: boolean;
4435 /**
4536 * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has
4637 * no client-settings sync, so the legacy grouped thread list is opted into
......@@ -110,8 +101,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
110101 collapsedProjectGroups?: readonly string[];
111102 projectGroupingEnabled?: boolean;
112103 projectGroupingMode?: SidebarProjectGroupingMode;
113 autoSettleMode?: SidebarAutoSettleMode;
114 autoSettleAfterDays?: SidebarAutoSettleAfterDays;
104 autoSettleOnMerge?: boolean;
115105 legacyThreadListEnabled?: boolean;
116106 planModeEnabled?: boolean;
117107 threadListV2SettledShelfExpanded?: boolean;
......@@ -177,20 +167,8 @@ function sanitizePreferences(parsed: Preferences): Preferences {
177167 ) {
178168 preferences.projectGroupingMode = parsed.projectGroupingMode;
179169 }
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;
170 if (typeof parsed.autoSettleOnMerge === "boolean") {
171 preferences.autoSettleOnMerge = parsed.autoSettleOnMerge;
194172 }
195173 if (typeof parsed.legacyThreadListEnabled === "boolean") {
196174 preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
......@@ -207,16 +185,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {
207185 return preferences;
208186}
209187
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
220188export const make = Effect.fn("MobilePreferencesStore.make")(function* () {
221189 const database = yield* MobileDatabase.MobileDatabase;
222190 const secureStorage = yield* MobileSecureStorage.MobileSecureStorage;
apps/web/src/components/ChatView.tsx+8-6
......@@ -4394,7 +4394,7 @@ function ChatViewContent(props: ChatViewProps) {
43944394 ? activePlan.steps
43954395 : null;
43964396 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
4397 const autoSettleMode = useClientSettings((settings) => settings.sidebarAutoSettleMode);
4397 const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);
43984398 const linkedPullRequestStatus = useLinkedThreadPullRequest(
43994399 activeThreadRef?.environmentId ?? null,
44004400 linkedThreadPullRequest,
......@@ -4465,8 +4465,10 @@ function ChatViewContent(props: ChatViewProps) {
44654465 const activeThreadWokeVisible = useMemo(() => {
44664466 if (activeThreadWokeAt === null) return false;
44674467 if (
4468 autoSettleMode === "change-request" &&
4469 changeRequestAutoSettles(activeThreadChangeRequest, { thread: activeThreadShell })
4468 changeRequestAutoSettles(activeThreadChangeRequest, {
4469 autoSettleOnMerge,
4470 thread: activeThreadShell,
4471 })
44704472 ) {
44714473 return false;
44724474 }
......@@ -4492,21 +4494,21 @@ function ChatViewContent(props: ChatViewProps) {
44924494 activeThreadChangeRequest,
44934495 activeThreadShell,
44944496 activeThreadWokeAt,
4495 autoSettleMode,
4497 autoSettleOnMerge,
44964498 ]);
44974499 const activeThreadSettled = useMemo(() => {
44984500 if (activeThreadShell === null || !supportsSettlement) return false;
44994501 return effectiveSettled(activeThreadShell, {
45004502 now: `${nowMinute}:00.000Z`,
45014503 autoSettleAfterDays,
4502 autoSettleMode,
4504 autoSettleOnMerge,
45034505 changeRequest: activeThreadChangeRequest,
45044506 });
45054507 }, [
45064508 activeThreadChangeRequest,
45074509 activeThreadShell,
45084510 autoSettleAfterDays,
4509 autoSettleMode,
4511 autoSettleOnMerge,
45104512 changeRequestSnapshotByKey,
45114513 nowMinute,
45124514 supportsSettlement,
apps/web/src/components/Sidebar.tsx+10-7
......@@ -31,7 +31,7 @@ import {
3131 scopedThreadKey,
3232} from "@t3tools/client-runtime/environment";
3333import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";
34import type { SidebarAutoSettleMode, TimestampFormat } from "@t3tools/contracts/settings";
34import type { 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 autoSettleMode: SidebarAutoSettleMode;
717 autoSettleOnMerge: boolean;
718718 // Same contract for thread.snooze/unsnooze.
719719 snoozeSupported: boolean;
720720 // Pinned threads show the same pin marker in active, settled, and snoozed
......@@ -839,7 +839,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
839839 const isWoke =
840840 wokeAtDate !== null &&
841841 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
842 !(props.autoSettleMode === "change-request" && changeRequestAutoSettles(pr, { thread }));
842 !changeRequestAutoSettles(pr, {
843 autoSettleOnMerge: props.autoSettleOnMerge,
844 thread,
845 });
843846 // In-flight rows (working, or waiting on approval/input) fade as a whole:
844847 // there is nothing for the user to do yet, so prominence is reserved for
845848 // rows that need a human — done (unread), read-but-unsettled, failed, and
......@@ -1737,7 +1740,7 @@ export default function Sidebar() {
17371740 const { isMobile, setOpenMobile } = useSidebar();
17381741 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
17391742 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
1740 const autoSettleMode = useClientSettings((s) => s.sidebarAutoSettleMode);
1743 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
17411744 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
17421745 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
17431746 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
......@@ -2122,7 +2125,7 @@ export default function Sidebar() {
21222125 effectiveSettled(thread, {
21232126 now,
21242127 autoSettleAfterDays,
2125 autoSettleMode,
2128 autoSettleOnMerge,
21262129 changeRequest,
21272130 })
21282131 ) {
......@@ -2161,7 +2164,7 @@ export default function Sidebar() {
21612164 };
21622165 }, [
21632166 autoSettleAfterDays,
2164 autoSettleMode,
2167 autoSettleOnMerge,
21652168 changeRequestSnapshotByKey,
21662169 nowMinute,
21672170 scopedProjectKeys,
......@@ -3784,7 +3787,7 @@ export default function Sidebar() {
37843787 serverConfigs.get(thread.environmentId)?.environment.capabilities
37853788 .threadSettlement === true
37863789 }
3787 autoSettleMode={autoSettleMode}
3790 autoSettleOnMerge={autoSettleOnMerge}
37883791 snoozeSupported={
37893792 serverConfigs.get(thread.environmentId)?.environment.capabilities
37903793 .threadSnooze === true
apps/web/src/components/ThreadStatusIndicators.test.ts-2
......@@ -47,7 +47,6 @@ function mergedFeaturePr(): NonNullable<VcsStatusResult["pr"]> {
4747 baseRef: "main",
4848 headRef: "feature/current",
4949 state: "merged",
50 updatedAt: "2026-04-10T00:00:00.000Z",
5150 };
5251}
5352
......@@ -546,7 +545,6 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
546545 effectiveSettled(shell, {
547546 now: "2026-04-10T00:00:00.000Z",
548547 autoSettleAfterDays: null,
549 autoSettleMode: "change-request",
550548 changeRequest: displayed,
551549 }),
552550 ).toBe(true);
apps/web/src/components/settings/SettingsPanels.logic.test.ts+1-20
......@@ -14,10 +14,9 @@ import {
1414 formatDiagnosticsDescription,
1515 getChangedBrowserSettingLabels,
1616 getChangedTypographySettingLabels,
17 isSamePreviewViewport,
1718 hasChangedBackgroundActivitySettings,
18 hasChangedThreadSettlingSettings,
1919 isProjectGroupingEnabled,
20 isSamePreviewViewport,
2120 projectGroupingModeFromToggle,
2221 resolveBackgroundActivityProfileOption,
2322} from "./SettingsPanels.logic";
......@@ -153,24 +152,6 @@ describe("project grouping toggle", () => {
153152 });
154153});
155154
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
174155describe("formatDiagnosticsDescription", () => {
175156 it("collapses trace and metric URLs that share the same OTEL base path", () => {
176157 expect(
apps/web/src/components/settings/SettingsPanels.logic.ts-9
......@@ -31,15 +31,6 @@ 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
4334const LAST_ENABLED_PROJECT_GROUPING_MODE_KEY = "t3code:last-enabled-project-grouping-mode";
4435
4536export function readLastEnabledProjectGroupingMode(): SidebarProjectGroupingMode {
apps/web/src/components/settings/SettingsPanels.tsx+53-46
......@@ -8,7 +8,6 @@ import {
88 type DesktopUpdateChannel,
99 ProviderDriverKind,
1010 type ScopedThreadRef,
11 type SidebarAutoSettleMode,
1211 type SidebarProjectGroupingMode,
1312} from "@t3tools/contracts";
1413import { scopeThreadRef } from "@t3tools/client-runtime/environment";
......@@ -131,7 +130,6 @@ import {
131130 normalizeIntervalSeconds,
132131 PROVIDER_HEALTH_INTERVAL_STEP_SECONDS,
133132 hasChangedBackgroundActivitySettings,
134 hasChangedThreadSettlingSettings,
135133 isProjectGroupingEnabled,
136134 projectGroupingModeFromToggle,
137135 readLastEnabledProjectGroupingMode,
......@@ -473,7 +471,6 @@ export function useSettingsRestore(onRestored?: () => void) {
473471 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
474472 );
475473 const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings);
476 const isThreadSettlingDirty = hasChangedThreadSettlingSettings(settings);
477474
478475 const changedSettingLabels = useMemo(
479476 () => [
......@@ -498,7 +495,13 @@ export function useSettingsRestore(onRestored?: () => void) {
498495 DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode
499496 ? ["Project Grouping"]
500497 : []),
501 ...(isThreadSettlingDirty ? ["Thread settling"] : []),
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 : []),
502505 ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
503506 ...getChangedTypographySettingLabels(settings),
504507 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
......@@ -547,7 +550,6 @@ export function useSettingsRestore(onRestored?: () => void) {
547550 [
548551 isTextGenerationModelDirty,
549552 isBackgroundActivityDirty,
550 isThreadSettlingDirty,
551553 settings.browserDefaultViewport,
552554 settings.browserDefaultZoomFactor,
553555 settings.browserDefaultAppearance,
......@@ -574,6 +576,8 @@ export function useSettingsRestore(onRestored?: () => void) {
574576 settings.glassOpacity,
575577 settings.enableLegacyTokenStreaming,
576578 settings.enableProviderUpdateChecks,
579 settings.sidebarAutoSettleAfterDays,
580 settings.sidebarAutoSettleOnMerge,
577581 settings.sidebarProjectGroupingMode,
578582 settings.sidebarThreadPreviewCount,
579583 settings.showSkillsInSlashMenu,
......@@ -658,7 +662,7 @@ export function useSettingsRestore(onRestored?: () => void) {
658662 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
659663 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
660664 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
661 sidebarAutoSettleMode: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleMode,
665 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
662666 enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming,
663667 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
664668 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
......@@ -1686,11 +1690,6 @@ function FontFamilySettingsRow({
16861690}
16871691
16881692const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3;
1689const AUTO_SETTLE_MODE_LABELS: Readonly<Record<SidebarAutoSettleMode, string>> = {
1690 never: "Never (manual only)",
1691 "change-request": "When PR merges or closes",
1692 inactivity: "After inactivity",
1693};
16941693
16951694function AutoSettleDaysInput({
16961695 value,
......@@ -1952,59 +1951,67 @@ export function GeneralSettingsPanel() {
19521951 />
19531952
19541953 <SettingsRow
1955 {...searchableSetting("thread-settling")}
1956 description="Choose the only event allowed to settle a thread automatically. Manual settling always remains available."
1954 {...searchableSetting("auto-settle-merged-threads")}
1955 description="Settle a thread when its pull request merges. Closed pull requests still settle automatically."
19571956 resetAction={
1958 hasChangedThreadSettlingSettings(settings) ? (
1957 settings.sidebarAutoSettleOnMerge !==
1958 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? (
19591959 <SettingResetButton
1960 label="thread settling"
1960 label="auto-settle on merge"
19611961 onClick={() =>
19621962 updateSettings({
1963 sidebarAutoSettleMode: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleMode,
1964 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
1963 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
19651964 })
19661965 }
19671966 />
19681967 ) : null
19691968 }
19701969 control={
1971 <Select
1972 value={settings.sidebarAutoSettleMode}
1973 onValueChange={(value) => {
1974 if (value === "never" || value === "change-request" || value === "inactivity") {
1970 <Switch
1971 checked={settings.sidebarAutoSettleOnMerge}
1972 onCheckedChange={(checked) =>
1973 updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) })
1974 }
1975 aria-label="Auto-settle merged threads"
1976 />
1977 }
1978 />
1979
1980 <SettingsRow
1981 {...searchableSetting("auto-settle-inactive-threads")}
1982 description="Sidebar threads with no activity for this long settle automatically."
1983 resetAction={
1984 settings.sidebarAutoSettleAfterDays !==
1985 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? (
1986 <SettingResetButton
1987 label="auto-settle"
1988 onClick={() =>
19751989 updateSettings({
1976 sidebarAutoSettleMode: value,
1977 ...(value === "inactivity" && settings.sidebarAutoSettleAfterDays === null
1978 ? { sidebarAutoSettleAfterDays: AUTO_SETTLE_DEFAULT_DAYS }
1979 : {}),
1980 });
1990 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
1991 })
19811992 }
1982 }}
1983 >
1984 <SelectTrigger className="w-full sm:w-64" aria-label="Thread settling">
1985 <SelectValue>{AUTO_SETTLE_MODE_LABELS[settings.sidebarAutoSettleMode]}</SelectValue>
1986 </SelectTrigger>
1987 <SelectPopup align="end" alignItemWithTrigger={false}>
1988 <SelectItem hideIndicator value="never">
1989 {AUTO_SETTLE_MODE_LABELS.never}
1990 </SelectItem>
1991 <SelectItem hideIndicator value="change-request">
1992 {AUTO_SETTLE_MODE_LABELS["change-request"]}
1993 </SelectItem>
1994 <SelectItem hideIndicator value="inactivity">
1995 {AUTO_SETTLE_MODE_LABELS.inactivity}
1996 </SelectItem>
1997 </SelectPopup>
1998 </Select>
1993 />
1994 ) : null
1995 }
1996 control={
1997 <Switch
1998 checked={settings.sidebarAutoSettleAfterDays !== null}
1999 onCheckedChange={(checked) =>
2000 updateSettings({
2001 sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null,
2002 })
2003 }
2004 aria-label="Auto-settle inactive threads"
2005 />
19992006 }
20002007 />
2001 {settings.sidebarAutoSettleMode === "inactivity" ? (
2008 {settings.sidebarAutoSettleAfterDays !== null ? (
20022009 <SettingsRow
20032010 title="Days of inactivity before auto-settle"
2004 description="Running, blocked, and open-pull-request threads always stay active."
2011 description="Any new activity un-settles a thread automatically."
20052012 control={
20062013 <AutoSettleDaysInput
2007 value={settings.sidebarAutoSettleAfterDays ?? AUTO_SETTLE_DEFAULT_DAYS}
2014 value={settings.sidebarAutoSettleAfterDays}
20082015 onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}
20092016 />
20102017 }
apps/web/src/components/settings/settingsSearch.ts+7-2
......@@ -112,8 +112,13 @@ export const SETTINGS_SEARCH_ITEMS = [
112112 to: "/settings/general",
113113 },
114114 {
115 id: "thread-settling",
116 title: "Thread settling",
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",
117122 to: "/settings/general",
118123 },
119124 {
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 autoSettleMode = useClientSettings((s) => s.sidebarAutoSettleMode);
84 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
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 autoSettleMode,
138 autoSettleOnMerge,
139139 changeRequest,
140140 }),
141141 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
......@@ -312,7 +312,7 @@ export function useThreadActionMenu(input: {
312312 [
313313 archiveThread,
314314 autoSettleAfterDays,
315 autoSettleMode,
315 autoSettleOnMerge,
316316 changeRequest,
317317 confirmThreadArchive,
318318 confirmThreadDelete,
docs/user/thread-sidebar.md+4-8
......@@ -7,19 +7,15 @@ their project, including when you connect to more than one environment.
77To require confirmation before unpinning, enable **Settings → General → Unpin confirmation**. The
88confirmation applies to the sidebar controls, thread menus, and the `mod+shift+p` shortcut.
99
10Pinned threads follow the auto-settle policy in **Settings → General**. Choose exactly one policy:
11settle when a pull request merges or closes, settle after a configurable period of inactivity, or
12never auto-settle. **Never** leaves every automatic path off, so threads settle only when you choose
13**Settle**.
10Pinned threads still move to **Settled** when they become inactive. They also move when their pull
11request merges if **Auto-settle merged threads** is enabled.
1412
1513When you un-settle a thread, it returns to the top of the active list so you can find it right
1614away. Its timestamps do not change. Other threads keep their positions.
1715
1816Right-click a pull request link in a thread and choose **Link to thread** to show that pull request
19in the sidebar. The thread settles when the linked pull request merges or closes only when the
20pull-request policy is selected. Right-click the same link and choose **Unlink from thread** to
21remove it. If T3 Code cannot determine when a pull request entered its terminal state, it keeps the
22thread active.
17in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged
18threads** is enabled. Right-click the same link and choose **Unlink from thread** to remove it.
2319
2420On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu
2521and 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,7 +17,6 @@ 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",
2120 "features/settings/SettingsEnvironmentsRouteScreen.tsx",
2221 "features/settings/appearance/components/AppearancePreviews.tsx",
2322 "features/settings/appearance/components/FontSizeSliderRow.tsx",
packages/client-runtime/src/state/threadSettled.test.ts+74-116
......@@ -21,6 +21,18 @@ 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
2436 const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z";
2537 const idleThread = {
2638 createdAt: THREAD_CREATED_AT,
......@@ -28,19 +40,6 @@ describe("changeRequestAutoSettles", () => {
2840 latestTurn: null,
2941 };
3042
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
4443 it("ignores a terminal change request last touched before the thread existed", () => {
4544 for (const state of ["merged", "closed"] as const) {
4645 expect(
......@@ -106,20 +105,20 @@ describe("changeRequestAutoSettles", () => {
106105 ).toBe(true);
107106 });
108107
109 it("keeps the thread active when either timestamp is missing or malformed", () => {
110 expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(false);
108 it("falls back to settling when either timestamp is missing or malformed", () => {
109 expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(true);
111110 expect(
112111 changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }),
113 ).toBe(false);
112 ).toBe(true);
114113 expect(
115114 changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}),
116 ).toBe(false);
115 ).toBe(true);
117116 expect(
118117 changeRequestAutoSettles(
119118 { state: "merged", updatedAt: "not-a-date" },
120119 { thread: idleThread },
121120 ),
122 ).toBe(false);
121 ).toBe(true);
123122 });
124123});
125124
......@@ -205,47 +204,38 @@ describe("effectiveSettled", () => {
205204 ] as const;
206205 const runningCases = [false, true] as const;
207206 const pendingCases = [undefined, "approval", "user-input"] as const;
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 ),
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 })),
233231 ),
234232 ),
235233 ),
236234 );
237235
238236 it.each(truthTable)(
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 }) => {
237 "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending",
238 ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => {
249239 const shell = makeShell({
250240 settledOverride,
251241 activityAt,
......@@ -255,18 +245,12 @@ describe("effectiveSettled", () => {
255245 const changeRequestOptions =
256246 changeRequestState === undefined
257247 ? {}
258 : {
259 changeRequest: {
260 state: changeRequestState as ChangeRequestStateLike,
261 updatedAt: NOW,
262 },
263 };
248 : { changeRequest: { state: changeRequestState as ChangeRequestStateLike } };
264249
265250 expect(
266251 effectiveSettled(shell, {
267252 now: NOW,
268253 autoSettleAfterDays: 3,
269 autoSettleMode,
270254 ...changeRequestOptions,
271255 }),
272256 ).toBe(expected);
......@@ -279,8 +263,7 @@ describe("effectiveSettled", () => {
279263 effectiveSettled(shell, {
280264 now: NOW,
281265 autoSettleAfterDays: null,
282 autoSettleMode: "change-request",
283 changeRequest: { state: "closed", updatedAt: NOW },
266 changeRequest: { state: "closed" },
284267 }),
285268 ).toBe(true);
286269 });
......@@ -292,8 +275,7 @@ describe("effectiveSettled", () => {
292275 effectiveSettled(recentlyActive, {
293276 now: NOW,
294277 autoSettleAfterDays: null,
295 autoSettleMode: "change-request",
296 changeRequest: { state: changeRequestState, updatedAt: NOW },
278 changeRequest: { state: changeRequestState },
297279 }),
298280 ).toBe(true);
299281 }
......@@ -309,7 +291,6 @@ describe("effectiveSettled", () => {
309291 effectiveSettled(fresh, {
310292 now: NOW,
311293 autoSettleAfterDays: null,
312 autoSettleMode: "change-request",
313294 changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" },
314295 }),
315296 ).toBe(false);
......@@ -319,24 +300,30 @@ describe("effectiveSettled", () => {
319300 effectiveSettled(fresh, {
320301 now: NOW,
321302 autoSettleAfterDays: null,
322 autoSettleMode: "change-request",
323303 changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" },
324304 }),
325305 ).toBe(true);
326306 });
327307
328 it("keeps a merged change request active in every other mode", () => {
308 it("can keep a merged change request active", () => {
329309 const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });
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 }
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);
340327 });
341328
342329 it("never auto-settles a stale thread with an open change request", () => {
......@@ -345,7 +332,6 @@ describe("effectiveSettled", () => {
345332 effectiveSettled(stale, {
346333 now: NOW,
347334 autoSettleAfterDays: 3,
348 autoSettleMode: "inactivity",
349335 changeRequest: { state: "open" },
350336 }),
351337 ).toBe(false);
......@@ -355,7 +341,6 @@ describe("effectiveSettled", () => {
355341 effectiveSettled(settled, {
356342 now: NOW,
357343 autoSettleAfterDays: 3,
358 autoSettleMode: "inactivity",
359344 changeRequest: { state: "open" },
360345 }),
361346 ).toBe(true);
......@@ -370,8 +355,7 @@ describe("effectiveSettled", () => {
370355 effectiveSettled(shell, {
371356 now: NOW,
372357 autoSettleAfterDays: null,
373 autoSettleMode: "change-request",
374 changeRequest: { state: "merged", updatedAt: NOW },
358 changeRequest: { state: "merged" },
375359 }),
376360 ).toBe(false);
377361 });
......@@ -386,8 +370,7 @@ describe("effectiveSettled", () => {
386370 effectiveSettled(shell, {
387371 now: NOW,
388372 autoSettleAfterDays: 3,
389 autoSettleMode: "change-request",
390 changeRequest: { state: "merged", updatedAt: NOW },
373 changeRequest: { state: "merged" },
391374 }),
392375 ).toBe(false);
393376 });
......@@ -431,8 +414,7 @@ describe("effectiveSettled", () => {
431414 effectiveSettled(shell, {
432415 now: transitionNow,
433416 autoSettleAfterDays: 3,
434 autoSettleMode: "change-request",
435 changeRequest: { state: "merged", updatedAt: transitionNow },
417 changeRequest: { state: "merged" },
436418 }),
437419 ).toBe(false);
438420 }
......@@ -444,20 +426,8 @@ describe("effectiveSettled", () => {
444426 });
445427 const stale = makeShell({ activityAt: STALE });
446428
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);
429 expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);
430 expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false);
461431 });
462432});
463433
......@@ -562,8 +532,7 @@ describe("canSettle", () => {
562532 effectiveSettled(queued, {
563533 now: justAfter,
564534 autoSettleAfterDays: 3,
565 autoSettleMode: "change-request",
566 changeRequest: { state: "merged", updatedAt: justAfter },
535 changeRequest: { state: "merged" },
567536 }),
568537 ).toBe(false);
569538 // Past the window the message is a failed/stale start: settleable again.
......@@ -585,13 +554,9 @@ describe("canSettle", () => {
585554 settledAt: "2026-04-09T12:02:10.000Z",
586555 };
587556 expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true);
588 expect(
589 effectiveSettled(settledAfterMessage, {
590 now: flooredNow,
591 autoSettleAfterDays: 3,
592 autoSettleMode: "never",
593 }),
594 ).toBe(true);
557 expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe(
558 true,
559 );
595560
596561 // A message NEWER than settledAt is genuinely new work: still blocked
597562 // until the server's auto-unsettle lands.
......@@ -604,7 +569,6 @@ describe("canSettle", () => {
604569 effectiveSettled(messageAfterSettle, {
605570 now: "2026-04-09T12:03:30.000Z",
606571 autoSettleAfterDays: 3,
607 autoSettleMode: "never",
608572 }),
609573 ).toBe(false);
610574 });
......@@ -618,12 +582,6 @@ describe("canSettle", () => {
618582 pending: "user-input",
619583 });
620584 expect(canSettle(blocked, { now: NOW })).toBe(false);
621 expect(
622 effectiveSettled(blocked, {
623 now: NOW,
624 autoSettleAfterDays: 3,
625 autoSettleMode: "never",
626 }),
627 ).toBe(false);
585 expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);
628586 });
629587});
packages/client-runtime/src/state/threadSettled.ts+25-17
......@@ -1,5 +1,5 @@
11// @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels.
2import type { OrchestrationThreadShell, SidebarAutoSettleMode } from "@t3tools/contracts";
2import type { OrchestrationThreadShell } from "@t3tools/contracts";
33
44export type ChangeRequestStateLike = "open" | "closed" | "merged";
55
......@@ -46,22 +46,26 @@ 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. Missing or malformed timestamps keep the thread active: an automatic
50 * transition must have enough evidence to be predictable.
49 * PR. Unknown timestamps keep the old always-settle behavior.
5150 */
5251export function changeRequestAutoSettles(
5352 changeRequest: ChangeRequestSettleSource | null | undefined,
5453 options: {
54 readonly autoSettleOnMerge?: boolean | undefined;
5555 readonly thread?: ThreadActivitySource | null | undefined;
5656 } = {},
5757): boolean {
5858 if (changeRequest == null) return false;
59 const terminal = changeRequest.state === "closed" || changeRequest.state === "merged";
59 const terminal =
60 changeRequest.state === "closed" ||
61 (changeRequest.state === "merged" && options.autoSettleOnMerge !== false);
6062 if (!terminal) return false;
61 if (changeRequest.updatedAt == null || options.thread == null) return false;
63 if (changeRequest.updatedAt == null || options.thread == null) return true;
6264 const updatedAtMs = Date.parse(changeRequest.updatedAt);
6365 const anchorAtMs = Date.parse(threadUserActivityAnchorAt(options.thread));
64 if (Number.isNaN(updatedAtMs) || Number.isNaN(anchorAtMs)) return false;
66 // Malformed timestamps fall back to settling, matching servers that never
67 // report updatedAt.
68 if (Number.isNaN(updatedAtMs) || Number.isNaN(anchorAtMs)) return true;
6569 return updatedAtMs >= anchorAtMs;
6670}
6771
......@@ -285,11 +289,11 @@ export function threadWokeAt(
285289 * queued turn) are checked first and hold a thread active regardless of any
286290 * override. Past the blockers, the explicit user override (thread.settle /
287291 * thread.unsettle commands, projected into settledOverride + settledAt)
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
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
293297 * un-settles on real activity (user message, session start, approval/
294298 * user-input request), so an override never goes stale silently.
295299 */
......@@ -298,7 +302,7 @@ export function effectiveSettled(
298302 options: {
299303 readonly now: string;
300304 readonly autoSettleAfterDays: number | null;
301 readonly autoSettleMode: SidebarAutoSettleMode;
305 readonly autoSettleOnMerge?: boolean;
302306 readonly changeRequest?: ChangeRequestSettleSource | null;
303307 },
304308): boolean {
......@@ -325,14 +329,18 @@ export function effectiveSettled(
325329 // "active" is the explicit keep-active pin: it suppresses auto-settle
326330 // until real activity clears it server-side.
327331 if (shell.settledOverride === "active") return false;
328 if (options.autoSettleMode === "never") return false;
329 if (options.autoSettleMode === "change-request") {
330 return changeRequestAutoSettles(options.changeRequest, { thread: shell });
332 if (
333 changeRequestAutoSettles(options.changeRequest, {
334 autoSettleOnMerge: options.autoSettleOnMerge,
335 thread: shell,
336 })
337 ) {
338 return true;
331339 }
332340 // An open PR is unfinished business regardless of how long the thread has
333341 // been quiet: review can take days, and hiding the thread would bury the
334 // work waiting on it. Only an explicit user settle resolves it while the
335 // inactivity policy is selected.
342 // work waiting on it. A configured merge, a close, or an explicit user
343 // settle resolves it.
336344 if (options.changeRequest?.state === "open") return false;
337345 if (options.autoSettleAfterDays === null) return false;
338346
packages/contracts/src/settings.test.ts+9-23
......@@ -117,11 +117,11 @@ describe("ClientSettings environment identification", () => {
117117});
118118
119119describe("ClientSettings sidebar", () => {
120 it("defaults to the current sidebar with manual settling", () => {
120 it("defaults to the current sidebar with automatic merge and inactivity settling", () => {
121121 const settings = decodeClientSettings({});
122122 expect(settings.legacySidebarEnabled).toBe(false);
123123 expect(settings.sidebarAutoSettleAfterDays).toBe(3);
124 expect(settings.sidebarAutoSettleMode).toBe("never");
124 expect(settings.sidebarAutoSettleOnMerge).toBe(true);
125125 });
126126
127127 it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => {
......@@ -153,27 +153,13 @@ describe("ClientSettings sidebar", () => {
153153 ).toBeNull();
154154 });
155155
156 it("does not preserve the retired merge switch as a hidden automatic path", () => {
157 const decoded = decodeClientSettings({ sidebarAutoSettleOnMerge: true });
158 expect(decoded.sidebarAutoSettleMode).toBe("never");
159 expect(decoded).not.toHaveProperty("sidebarAutoSettleOnMerge");
160 });
161
162 it.each(["never", "change-request", "inactivity"] as const)(
163 "accepts the %s auto-settle mode",
164 (sidebarAutoSettleMode) => {
165 expect(decodeClientSettings({ sidebarAutoSettleMode }).sidebarAutoSettleMode).toBe(
166 sidebarAutoSettleMode,
167 );
168 expect(decodeClientSettingsPatch({ sidebarAutoSettleMode }).sidebarAutoSettleMode).toBe(
169 sidebarAutoSettleMode,
170 );
171 },
172 );
173
174 it("rejects unsupported auto-settle modes", () => {
175 expect(() => decodeClientSettings({ sidebarAutoSettleMode: "always" })).toThrow();
176 expect(() => decodeClientSettingsPatch({ sidebarAutoSettleMode: "always" })).toThrow();
156 it("allows auto-settle on merge to be disabled", () => {
157 expect(decodeClientSettings({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge).toBe(
158 false,
159 );
160 expect(
161 decodeClientSettingsPatch({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge,
162 ).toBe(false);
177163 });
178164
179165 it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => {
packages/contracts/src/settings.ts+2-10
......@@ -65,9 +65,6 @@ 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";
7168export const MIN_GLASS_OPACITY = 40;
7269export const MAX_GLASS_OPACITY = 100;
7370export const GlassOpacity = Schema.Int.check(
......@@ -238,12 +235,7 @@ export const ClientSettingsSchema = Schema.Struct({
238235 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
239236 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
240237 ),
241 // A fresh exclusive policy replaces the old independent inactivity and
242 // merge switches. Missing mode means manual-only, so an upgrade cannot
243 // preserve one hidden automatic path after the user disabled another.
244 sidebarAutoSettleMode: SidebarAutoSettleMode.pipe(
245 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_MODE)),
246 ),
238 sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
247239 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
248240 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
249241 ),
......@@ -952,7 +944,7 @@ export const ClientSettingsPatch = Schema.Struct({
952944 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
953945 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
954946 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
955 sidebarAutoSettleMode: Schema.optionalKey(SidebarAutoSettleMode),
947 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
956948 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
957949 sidebarProjectGroupingOverrides: Schema.optionalKey(
958950 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),