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 = {...@@ -40,7 +40,7 @@ const clientSettings: ClientSettings = {
40 showSkillsInSlashMenu: false,40 showSkillsInSlashMenu: false,
41 providerModelPreferences: {},41 providerModelPreferences: {},
42 sidebarAutoSettleAfterDays: 3,42 sidebarAutoSettleAfterDays: 3,
43 sidebarAutoSettleMode: "change-request",43 sidebarAutoSettleOnMerge: true,
44 sidebarProjectGroupingMode: "repository_path",44 sidebarProjectGroupingMode: "repository_path",
45 sidebarProjectGroupingOverrides: {45 sidebarProjectGroupingOverrides: {
46 "environment-1:/tmp/project-a": "separate",46 "environment-1:/tmp/project-a": "separate",
apps/mobile/src/Stack.tsx-8
...@@ -51,7 +51,6 @@ import {...@@ -51,7 +51,6 @@ import {
51import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";51import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
52import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";52import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
53import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";53import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
54import { SettingsAutoSettleRouteScreen } from "./features/settings/SettingsAutoSettleRouteScreen";
55import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";54import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";
56import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";55import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
57import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";56import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
...@@ -178,13 +177,6 @@ const SettingsContentStack = createNativeStackNavigator({...@@ -178,13 +177,6 @@ const SettingsContentStack = createNativeStackNavigator({
178 title: "Appearance",177 title: "Appearance",
179 },178 },
180 }),179 }),
181 SettingsAutoSettle: createNativeStackScreen({
182 screen: SettingsAutoSettleRouteScreen,
183 linking: "thread-settling",
184 options: {
185 title: "Thread Settling",
186 },
187 }),
188 SettingsProjectGrouping: createNativeStackScreen({180 SettingsProjectGrouping: createNativeStackScreen({
189 screen: SettingsProjectGroupingRouteScreen,181 screen: SettingsProjectGroupingRouteScreen,
190 linking: "project-grouping",182 linking: "project-grouping",
apps/mobile/src/features/home/HomeScreen.tsx+5-8
...@@ -30,7 +30,6 @@ import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspace...@@ -30,7 +30,6 @@ import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspace
30import type { SavedRemoteConnection } from "../../lib/connection";30import type { SavedRemoteConnection } from "../../lib/connection";
31import { scopedProjectKey } from "../../lib/scopedEntities";31import { scopedProjectKey } from "../../lib/scopedEntities";
32import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";32import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
33import { resolveMobileAutoSettlePreferences } from "../../persistence/mobile-preferences";
34import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";33import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
35import { useThreadSearch } from "../../state/queries";34import { useThreadSearch } from "../../state/queries";
36import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";35import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
...@@ -209,9 +208,9 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -209,9 +208,9 @@ export function HomeScreen(props: HomeScreenProps) {
209 >(() => new Map());208 >(() => new Map());
210 const preferencesResult = useAtomValue(mobilePreferencesAtom);209 const preferencesResult = useAtomValue(mobilePreferencesAtom);
211 const threadListV2Enabled = useThreadListV2Enabled();210 const threadListV2Enabled = useThreadListV2Enabled();
212 const autoSettlePreferences = resolveMobileAutoSettlePreferences(211 const autoSettleOnMerge =
213 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {},212 !AsyncResult.isSuccess(preferencesResult) ||
214 );213 preferencesResult.value.autoSettleOnMerge !== false;
215 const savePreferences = useAtomSet(updateMobilePreferencesAtom);214 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
216 const openSwipeableRef = useRef<SwipeableMethods | null>(null);215 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
217 const listRef = useRef<LegendListRef | null>(null);216 const listRef = useRef<LegendListRef | null>(null);
...@@ -679,8 +678,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -679,8 +678,7 @@ export function HomeScreen(props: HomeScreenProps) {
679 searchQuery: props.searchQuery,678 searchQuery: props.searchQuery,
680 matchedThreadKeys,679 matchedThreadKeys,
681 changeRequestByKey,680 changeRequestByKey,
682 autoSettleAfterDays: autoSettlePreferences.autoSettleAfterDays,681 autoSettleOnMerge,
683 autoSettleMode: autoSettlePreferences.autoSettleMode,
684 settlementEnvironmentIds,682 settlementEnvironmentIds,
685 snoozeEnvironmentIds,683 snoozeEnvironmentIds,
686 settledLimit: settledVisibleCount,684 settledLimit: settledVisibleCount,
...@@ -692,8 +690,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -692,8 +690,7 @@ export function HomeScreen(props: HomeScreenProps) {
692 });690 });
693 }, [691 }, [
694 changeRequestByKey,692 changeRequestByKey,
695 autoSettlePreferences.autoSettleAfterDays,693 autoSettleOnMerge,
696 autoSettlePreferences.autoSettleMode,
697 nowMinute,694 nowMinute,
698 snoozeWakeTick,695 snoozeWakeTick,
699 snoozedShelfExpanded,696 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";...@@ -4,7 +4,6 @@ import Constants from "expo-constants";
4import * as Notifications from "expo-notifications";4import * as Notifications from "expo-notifications";
5import { useNavigation } from "@react-navigation/native";5import { useNavigation } from "@react-navigation/native";
6import { NativeStackScreenOptions } from "../../native/StackHeader";6import { NativeStackScreenOptions } from "../../native/StackHeader";
7import { resolveMobileAutoSettlePreferences } from "../../persistence/mobile-preferences";
8import { SymbolView } from "../../components/AppSymbol";7import { SymbolView } from "../../components/AppSymbol";
9import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
10import { AsyncResult } from "effect/unstable/reactivity";9import { AsyncResult } from "effect/unstable/reactivity";
...@@ -46,7 +45,6 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re...@@ -46,7 +45,6 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
46import { SettingsRow } from "./components/SettingsRow";45import { SettingsRow } from "./components/SettingsRow";
47import { SettingsSection } from "./components/SettingsSection";46import { SettingsSection } from "./components/SettingsSection";
48import { SettingsSwitchRow } from "./components/SettingsSwitchRow";47import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
49import { mobileAutoSettleModeLabel } from "./SettingsAutoSettleRouteScreen";
50import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";48import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";
5149
52type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";50type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
...@@ -530,18 +528,19 @@ function ConfiguredSettingsRouteScreen() {...@@ -530,18 +528,19 @@ function ConfiguredSettingsRouteScreen() {
530528
531function GeneralSettingsSection() {529function GeneralSettingsSection() {
532 const preferencesResult = useAtomValue(mobilePreferencesAtom);530 const preferencesResult = useAtomValue(mobilePreferencesAtom);
533 const autoSettlePreferences = resolveMobileAutoSettlePreferences(531 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
534 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {},532 const autoSettleOnMerge =
535 );533 !AsyncResult.isSuccess(preferencesResult) ||
534 preferencesResult.value.autoSettleOnMerge !== false;
536535
537 return (536 return (
538 <SettingsSection title="General">537 <SettingsSection title="General">
539 <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />538 <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
540 <SettingsRow539 <SettingsSwitchRow
541 icon="arrow.triangle.branch"540 icon="arrow.triangle.branch"
542 label="Thread Settling"541 label="Auto-settle merged threads"
543 value={mobileAutoSettleModeLabel(autoSettlePreferences.autoSettleMode)}542 value={autoSettleOnMerge}
544 target="SettingsAutoSettle"543 onValueChange={(value) => savePreferences({ autoSettleOnMerge: value })}
545 />544 />
546 <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />545 <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
547 </SettingsSection>546 </SettingsSection>
apps/mobile/src/features/settings/components/settings-sheet-targets.ts-1
...@@ -2,7 +2,6 @@ export type SettingsSheetTarget =...@@ -2,7 +2,6 @@ export type SettingsSheetTarget =
2 | "SettingsEnvironments"2 | "SettingsEnvironments"
3 | "SettingsArchive"3 | "SettingsArchive"
4 | "SettingsAppearance"4 | "SettingsAppearance"
5 | "SettingsAutoSettle"
6 | "SettingsProjectGrouping"5 | "SettingsProjectGrouping"
7 | "SettingsClientStorage"6 | "SettingsClientStorage"
8 | "SettingsUsage";7 | "SettingsUsage";
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+5-8
...@@ -26,7 +26,6 @@ import { ControlPillMenu } from "../../components/ControlPill";...@@ -26,7 +26,6 @@ import { ControlPillMenu } from "../../components/ControlPill";
26import { SymbolView } from "../../components/AppSymbol";26import { SymbolView } from "../../components/AppSymbol";
27import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";27import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
28import { NativeStackScreenOptions } from "../../native/StackHeader";28import { NativeStackScreenOptions } from "../../native/StackHeader";
29import { resolveMobileAutoSettlePreferences } from "../../persistence/mobile-preferences";
30import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";29import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
31import { useProjects, useThreadShells } from "../../state/entities";30import { useProjects, useThreadShells } from "../../state/entities";
32import { mobilePreferencesAtom } from "../../state/preferences";31import { mobilePreferencesAtom } from "../../state/preferences";
...@@ -166,9 +165,9 @@ function ThreadNavigationSidebarPane(...@@ -166,9 +165,9 @@ function ThreadNavigationSidebarPane(
166 } = useThreadListActions();165 } = useThreadListActions();
167 const threadListV2Enabled = useThreadListV2Enabled();166 const threadListV2Enabled = useThreadListV2Enabled();
168 const preferencesResult = useAtomValue(mobilePreferencesAtom);167 const preferencesResult = useAtomValue(mobilePreferencesAtom);
169 const autoSettlePreferences = resolveMobileAutoSettlePreferences(168 const autoSettleOnMerge =
170 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {},169 !AsyncResult.isSuccess(preferencesResult) ||
171 );170 preferencesResult.value.autoSettleOnMerge !== false;
172 const pendingTasks = usePendingNewTasks();171 const pendingTasks = usePendingNewTasks();
173 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();172 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
174 const environments = useMemo(173 const environments = useMemo(
...@@ -511,8 +510,7 @@ function ThreadNavigationSidebarPane(...@@ -511,8 +510,7 @@ function ThreadNavigationSidebarPane(
511 searchQuery: props.searchQuery,510 searchQuery: props.searchQuery,
512 matchedThreadKeys,511 matchedThreadKeys,
513 changeRequestByKey,512 changeRequestByKey,
514 autoSettleAfterDays: autoSettlePreferences.autoSettleAfterDays,513 autoSettleOnMerge,
515 autoSettleMode: autoSettlePreferences.autoSettleMode,
516 settlementEnvironmentIds,514 settlementEnvironmentIds,
517 snoozeEnvironmentIds,515 snoozeEnvironmentIds,
518 settledLimit: settledVisibleCount,516 settledLimit: settledVisibleCount,
...@@ -524,8 +522,7 @@ function ThreadNavigationSidebarPane(...@@ -524,8 +522,7 @@ function ThreadNavigationSidebarPane(
524 });522 });
525 }, [523 }, [
526 changeRequestByKey,524 changeRequestByKey,
527 autoSettlePreferences.autoSettleAfterDays,525 autoSettleOnMerge,
528 autoSettlePreferences.autoSettleMode,
529 nowMinute,526 nowMinute,
530 snoozeWakeTick,527 snoozeWakeTick,
531 snoozedShelfExpanded,528 snoozedShelfExpanded,
apps/mobile/src/features/threads/threadListV2.test.ts+7-15
...@@ -360,12 +360,10 @@ describe("buildThreadListV2Items", () => {...@@ -360,12 +360,10 @@ describe("buildThreadListV2Items", () => {
360 `${environmentId}:${thread.id}`,360 `${environmentId}:${thread.id}`,
361 {361 {
362 state: "merged" as const,362 state: "merged" as const,
363 updatedAt: NOW,
364 linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',363 linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
365 },364 },
366 ],365 ],
367 ]),366 ]),
368 autoSettleMode: "change-request",
369 now: NOW,367 now: NOW,
370 });368 });
371369
...@@ -373,16 +371,16 @@ describe("buildThreadListV2Items", () => {...@@ -373,16 +371,16 @@ describe("buildThreadListV2Items", () => {
373 expect(layout.items[0]?.variant).toBe("slim");371 expect(layout.items[0]?.variant).toBe("slim");
374 });372 });
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", () => {
377 const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });375 const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });
378 const layout = buildThreadListV2Items({376 const layout = buildThreadListV2Items({
379 threads: [merged],377 threads: [merged],
380 environmentId: null,378 environmentId: null,
381 searchQuery: "",379 searchQuery: "",
382 changeRequestByKey: new Map([380 changeRequestByKey: new Map([
383 [`${environmentId}:${merged.id}`, { state: "merged" as const, updatedAt: NOW }],381 [`${environmentId}:${merged.id}`, { state: "merged" as const }],
384 ]),382 ]),
385 autoSettleMode: "never",383 autoSettleOnMerge: false,
386 now: NOW,384 now: NOW,
387 });385 });
388386
...@@ -451,10 +449,7 @@ describe("buildThreadListV2Items", () => {...@@ -451,10 +449,7 @@ describe("buildThreadListV2Items", () => {
451 threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged],449 threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged],
452 environmentId: null,450 environmentId: null,
453 searchQuery: "",451 searchQuery: "",
454 changeRequestByKey: new Map([452 changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]),
455 [`${environmentId}:${merged.id}`, { state: "merged", updatedAt: NOW }],
456 ]),
457 autoSettleMode: "change-request",
458 now: NOW,453 now: NOW,
459 });454 });
460455
...@@ -483,7 +478,6 @@ describe("buildThreadListV2Items", () => {...@@ -483,7 +478,6 @@ describe("buildThreadListV2Items", () => {
483 threads: [inactive],478 threads: [inactive],
484 environmentId: null,479 environmentId: null,
485 searchQuery: "",480 searchQuery: "",
486 autoSettleMode: "inactivity",
487 now: NOW,481 now: NOW,
488 });482 });
489483
...@@ -495,7 +489,7 @@ describe("buildThreadListV2Items", () => {...@@ -495,7 +489,7 @@ describe("buildThreadListV2Items", () => {
495 expect(layout.settledCount).toBe(1);489 expect(layout.settledCount).toBe(1);
496 });490 });
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", () => {
499 const merged = makeThread({493 const merged = makeThread({
500 id: ThreadId.make("pinned-merged"),494 id: ThreadId.make("pinned-merged"),
501 title: "Pinned merged pull request",495 title: "Pinned merged pull request",
...@@ -505,10 +499,8 @@ describe("buildThreadListV2Items", () => {...@@ -505,10 +499,8 @@ describe("buildThreadListV2Items", () => {
505 threads: [merged],499 threads: [merged],
506 environmentId: null,500 environmentId: null,
507 searchQuery: "",501 searchQuery: "",
508 changeRequestByKey: new Map([502 changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]),
509 [`${environmentId}:${merged.id}`, { state: "merged", updatedAt: NOW }],503 autoSettleOnMerge: false,
510 ]),
511 autoSettleMode: "never",
512 now: NOW,504 now: NOW,
513 });505 });
514506
apps/mobile/src/features/threads/threadListV2.ts+4-9
...@@ -16,12 +16,7 @@ import {...@@ -16,12 +16,7 @@ import {
16 activeThreadAnchorTimestampMs,16 activeThreadAnchorTimestampMs,
17 sortPinnedThreadsByOrderKey,17 sortPinnedThreadsByOrderKey,
18} from "@t3tools/client-runtime/state/thread-sort";18} from "@t3tools/client-runtime/state/thread-sort";
19import type {19import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts";
20 EnvironmentId,
21 ProjectId,
22 SidebarAutoSettleMode,
23 ThreadLinkedPullRequest,
24} from "@t3tools/contracts";
2520
26import type { PendingNewTask } from "../../state/use-pending-new-tasks";21import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2722
...@@ -374,7 +369,7 @@ export function buildThreadListV2Items(input: {...@@ -374,7 +369,7 @@ export function buildThreadListV2Items(input: {
374 contract as settlementEnvironmentIds. */369 contract as settlementEnvironmentIds. */
375 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;370 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
376 readonly autoSettleAfterDays?: number;371 readonly autoSettleAfterDays?: number;
377 readonly autoSettleMode?: SidebarAutoSettleMode;372 readonly autoSettleOnMerge?: boolean;
378 /** Max settled rows to render; the rest are counted, not built. */373 /** Max settled rows to render; the rest are counted, not built. */
379 readonly settledLimit?: number;374 readonly settledLimit?: number;
380 /** Injectable for tests; defaults to now. */375 /** Injectable for tests; defaults to now. */
...@@ -395,7 +390,7 @@ export function buildThreadListV2Items(input: {...@@ -395,7 +390,7 @@ export function buildThreadListV2Items(input: {
395 const now = input.now ?? new Date().toISOString();390 const now = input.now ?? new Date().toISOString();
396 const snoozeNow = input.snoozeNow ?? now;391 const snoozeNow = input.snoozeNow ?? now;
397 const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;392 const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
398 const autoSettleMode = input.autoSettleMode ?? "never";393 const autoSettleOnMerge = input.autoSettleOnMerge ?? true;
399 const query = input.searchQuery.trim().toLocaleLowerCase();394 const query = input.searchQuery.trim().toLocaleLowerCase();
400 const projectKeys = input.projectRefs395 const projectKeys = input.projectRefs
401 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))396 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
...@@ -452,7 +447,7 @@ export function buildThreadListV2Items(input: {...@@ -452,7 +447,7 @@ export function buildThreadListV2Items(input: {
452 effectiveSettled(thread, {447 effectiveSettled(thread, {
453 now,448 now,
454 autoSettleAfterDays,449 autoSettleAfterDays,
455 autoSettleMode,450 autoSettleOnMerge,
456 changeRequest,451 changeRequest,
457 })452 })
458 ) {453 ) {
apps/mobile/src/lib/storage.test.ts-29
...@@ -100,7 +100,6 @@ import {...@@ -100,7 +100,6 @@ import {
100 saveConnection,100 saveConnection,
101 savePreferencesPatch,101 savePreferencesPatch,
102} from "../persistence/imperative";102} from "../persistence/imperative";
103import { resolveMobileAutoSettlePreferences } from "../persistence/mobile-preferences";
104import { toStableSavedRemoteConnection } from "./connection";103import { toStableSavedRemoteConnection } from "./connection";
105104
106const managedConnection = {105const managedConnection = {
...@@ -203,34 +202,6 @@ describe("mobile connection storage", () => {...@@ -203,34 +202,6 @@ describe("mobile connection storage", () => {
203 await expect(loadPreferences()).resolves.toEqual({});202 await expect(loadPreferences()).resolves.toEqual({});
204 });203 });
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
234 it("falls back to secure storage when SQLite cannot save preferences", async () => {205 it("falls back to secure storage when SQLite cannot save preferences", async () => {
235 mocks.setDatabaseFailures(true, true);206 mocks.setDatabaseFailures(true, true);
236 await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 });207 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";...@@ -5,15 +5,7 @@ import * as Option from "effect/Option";
5import * as Ref from "effect/Ref";5import * as Ref from "effect/Ref";
6import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
7import * as Semaphore from "effect/Semaphore";7import * as Semaphore from "effect/Semaphore";
8import {8import type { SidebarProjectGroupingMode } from "@t3tools/contracts";
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";
17import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";9import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
1810
19import * as MobileDatabase from "./mobile-database";11import * as MobileDatabase from "./mobile-database";
...@@ -39,8 +31,7 @@ export interface Preferences {...@@ -39,8 +31,7 @@ export interface Preferences {
39 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */31 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
40 readonly projectGroupingEnabled?: boolean;32 readonly projectGroupingEnabled?: boolean;
41 readonly projectGroupingMode?: SidebarProjectGroupingMode;33 readonly projectGroupingMode?: SidebarProjectGroupingMode;
42 readonly autoSettleMode?: SidebarAutoSettleMode;34 readonly autoSettleOnMerge?: boolean;
43 readonly autoSettleAfterDays?: SidebarAutoSettleAfterDays;
44 /**35 /**
45 * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has36 * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has
46 * no client-settings sync, so the legacy grouped thread list is opted into37 * no client-settings sync, so the legacy grouped thread list is opted into
...@@ -110,8 +101,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -110,8 +101,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
110 collapsedProjectGroups?: readonly string[];101 collapsedProjectGroups?: readonly string[];
111 projectGroupingEnabled?: boolean;102 projectGroupingEnabled?: boolean;
112 projectGroupingMode?: SidebarProjectGroupingMode;103 projectGroupingMode?: SidebarProjectGroupingMode;
113 autoSettleMode?: SidebarAutoSettleMode;104 autoSettleOnMerge?: boolean;
114 autoSettleAfterDays?: SidebarAutoSettleAfterDays;
115 legacyThreadListEnabled?: boolean;105 legacyThreadListEnabled?: boolean;
116 planModeEnabled?: boolean;106 planModeEnabled?: boolean;
117 threadListV2SettledShelfExpanded?: boolean;107 threadListV2SettledShelfExpanded?: boolean;
...@@ -177,20 +167,8 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -177,20 +167,8 @@ function sanitizePreferences(parsed: Preferences): Preferences {
177 ) {167 ) {
178 preferences.projectGroupingMode = parsed.projectGroupingMode;168 preferences.projectGroupingMode = parsed.projectGroupingMode;
179 }169 }
180 if (170 if (typeof parsed.autoSettleOnMerge === "boolean") {
181 parsed.autoSettleMode === "never" ||171 preferences.autoSettleOnMerge = parsed.autoSettleOnMerge;
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;
194 }172 }
195 if (typeof parsed.legacyThreadListEnabled === "boolean") {173 if (typeof parsed.legacyThreadListEnabled === "boolean") {
196 preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;174 preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
...@@ -207,16 +185,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -207,16 +185,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {
207 return preferences;185 return preferences;
208}186}
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
220export const make = Effect.fn("MobilePreferencesStore.make")(function* () {188export const make = Effect.fn("MobilePreferencesStore.make")(function* () {
221 const database = yield* MobileDatabase.MobileDatabase;189 const database = yield* MobileDatabase.MobileDatabase;
222 const secureStorage = yield* MobileSecureStorage.MobileSecureStorage;190 const secureStorage = yield* MobileSecureStorage.MobileSecureStorage;
apps/web/src/components/ChatView.tsx+8-6
...@@ -4394,7 +4394,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4394,7 +4394,7 @@ function ChatViewContent(props: ChatViewProps) {
4394 ? activePlan.steps4394 ? activePlan.steps
4395 : null;4395 : null;
4396 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);4396 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
4397 const autoSettleMode = useClientSettings((settings) => settings.sidebarAutoSettleMode);4397 const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);
4398 const linkedPullRequestStatus = useLinkedThreadPullRequest(4398 const linkedPullRequestStatus = useLinkedThreadPullRequest(
4399 activeThreadRef?.environmentId ?? null,4399 activeThreadRef?.environmentId ?? null,
4400 linkedThreadPullRequest,4400 linkedThreadPullRequest,
...@@ -4465,8 +4465,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4465,8 +4465,10 @@ function ChatViewContent(props: ChatViewProps) {
4465 const activeThreadWokeVisible = useMemo(() => {4465 const activeThreadWokeVisible = useMemo(() => {
4466 if (activeThreadWokeAt === null) return false;4466 if (activeThreadWokeAt === null) return false;
4467 if (4467 if (
4468 autoSettleMode === "change-request" &&4468 changeRequestAutoSettles(activeThreadChangeRequest, {
4469 changeRequestAutoSettles(activeThreadChangeRequest, { thread: activeThreadShell })4469 autoSettleOnMerge,
4470 thread: activeThreadShell,
4471 })
4470 ) {4472 ) {
4471 return false;4473 return false;
4472 }4474 }
...@@ -4492,21 +4494,21 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4492,21 +4494,21 @@ function ChatViewContent(props: ChatViewProps) {
4492 activeThreadChangeRequest,4494 activeThreadChangeRequest,
4493 activeThreadShell,4495 activeThreadShell,
4494 activeThreadWokeAt,4496 activeThreadWokeAt,
4495 autoSettleMode,4497 autoSettleOnMerge,
4496 ]);4498 ]);
4497 const activeThreadSettled = useMemo(() => {4499 const activeThreadSettled = useMemo(() => {
4498 if (activeThreadShell === null || !supportsSettlement) return false;4500 if (activeThreadShell === null || !supportsSettlement) return false;
4499 return effectiveSettled(activeThreadShell, {4501 return effectiveSettled(activeThreadShell, {
4500 now: `${nowMinute}:00.000Z`,4502 now: `${nowMinute}:00.000Z`,
4501 autoSettleAfterDays,4503 autoSettleAfterDays,
4502 autoSettleMode,4504 autoSettleOnMerge,
4503 changeRequest: activeThreadChangeRequest,4505 changeRequest: activeThreadChangeRequest,
4504 });4506 });
4505 }, [4507 }, [
4506 activeThreadChangeRequest,4508 activeThreadChangeRequest,
4507 activeThreadShell,4509 activeThreadShell,
4508 autoSettleAfterDays,4510 autoSettleAfterDays,
4509 autoSettleMode,4511 autoSettleOnMerge,
4510 changeRequestSnapshotByKey,4512 changeRequestSnapshotByKey,
4511 nowMinute,4513 nowMinute,
4512 supportsSettlement,4514 supportsSettlement,
apps/web/src/components/Sidebar.tsx+10-7
...@@ -31,7 +31,7 @@ import {...@@ -31,7 +31,7 @@ import {
31 scopedThreadKey,31 scopedThreadKey,
32} from "@t3tools/client-runtime/environment";32} from "@t3tools/client-runtime/environment";
33import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";33import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";
34import type { SidebarAutoSettleMode, TimestampFormat } from "@t3tools/contracts/settings";34import type { TimestampFormat } from "@t3tools/contracts/settings";
35import {35import {
36 AlarmClockIcon,36 AlarmClockIcon,
37 AlarmClockOffIcon,37 AlarmClockOffIcon,
...@@ -714,7 +714,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -714,7 +714,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
714 // False on environments whose server predates thread.settle/unsettle:714 // False on environments whose server predates thread.settle/unsettle:
715 // the lifecycle affordances hide entirely rather than fail on click.715 // the lifecycle affordances hide entirely rather than fail on click.
716 settlementSupported: boolean;716 settlementSupported: boolean;
717 autoSettleMode: SidebarAutoSettleMode;717 autoSettleOnMerge: boolean;
718 // Same contract for thread.snooze/unsnooze.718 // Same contract for thread.snooze/unsnooze.
719 snoozeSupported: boolean;719 snoozeSupported: boolean;
720 // Pinned threads show the same pin marker in active, settled, and snoozed720 // Pinned threads show the same pin marker in active, settled, and snoozed
...@@ -839,7 +839,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -839,7 +839,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
839 const isWoke =839 const isWoke =
840 wokeAtDate !== null &&840 wokeAtDate !== null &&
841 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&841 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
842 !(props.autoSettleMode === "change-request" && changeRequestAutoSettles(pr, { thread }));842 !changeRequestAutoSettles(pr, {
843 autoSettleOnMerge: props.autoSettleOnMerge,
844 thread,
845 });
843 // In-flight rows (working, or waiting on approval/input) fade as a whole:846 // In-flight rows (working, or waiting on approval/input) fade as a whole:
844 // there is nothing for the user to do yet, so prominence is reserved for847 // there is nothing for the user to do yet, so prominence is reserved for
845 // rows that need a human — done (unread), read-but-unsettled, failed, and848 // rows that need a human — done (unread), read-but-unsettled, failed, and
...@@ -1737,7 +1740,7 @@ export default function Sidebar() {...@@ -1737,7 +1740,7 @@ export default function Sidebar() {
1737 const { isMobile, setOpenMobile } = useSidebar();1740 const { isMobile, setOpenMobile } = useSidebar();
1738 const keybindings = useAtomValue(primaryServerKeybindingsAtom);1741 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
1739 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);1742 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
1740 const autoSettleMode = useClientSettings((s) => s.sidebarAutoSettleMode);1743 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
1741 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);1744 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
1742 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);1745 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
1743 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);1746 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
...@@ -2122,7 +2125,7 @@ export default function Sidebar() {...@@ -2122,7 +2125,7 @@ export default function Sidebar() {
2122 effectiveSettled(thread, {2125 effectiveSettled(thread, {
2123 now,2126 now,
2124 autoSettleAfterDays,2127 autoSettleAfterDays,
2125 autoSettleMode,2128 autoSettleOnMerge,
2126 changeRequest,2129 changeRequest,
2127 })2130 })
2128 ) {2131 ) {
...@@ -2161,7 +2164,7 @@ export default function Sidebar() {...@@ -2161,7 +2164,7 @@ export default function Sidebar() {
2161 };2164 };
2162 }, [2165 }, [
2163 autoSettleAfterDays,2166 autoSettleAfterDays,
2164 autoSettleMode,2167 autoSettleOnMerge,
2165 changeRequestSnapshotByKey,2168 changeRequestSnapshotByKey,
2166 nowMinute,2169 nowMinute,
2167 scopedProjectKeys,2170 scopedProjectKeys,
...@@ -3784,7 +3787,7 @@ export default function Sidebar() {...@@ -3784,7 +3787,7 @@ export default function Sidebar() {
3784 serverConfigs.get(thread.environmentId)?.environment.capabilities3787 serverConfigs.get(thread.environmentId)?.environment.capabilities
3785 .threadSettlement === true3788 .threadSettlement === true
3786 }3789 }
3787 autoSettleMode={autoSettleMode}3790 autoSettleOnMerge={autoSettleOnMerge}
3788 snoozeSupported={3791 snoozeSupported={
3789 serverConfigs.get(thread.environmentId)?.environment.capabilities3792 serverConfigs.get(thread.environmentId)?.environment.capabilities
3790 .threadSnooze === true3793 .threadSnooze === true
apps/web/src/components/ThreadStatusIndicators.test.ts-2
...@@ -47,7 +47,6 @@ function mergedFeaturePr(): NonNullable<VcsStatusResult["pr"]> {...@@ -47,7 +47,6 @@ function mergedFeaturePr(): NonNullable<VcsStatusResult["pr"]> {
47 baseRef: "main",47 baseRef: "main",
48 headRef: "feature/current",48 headRef: "feature/current",
49 state: "merged",49 state: "merged",
50 updatedAt: "2026-04-10T00:00:00.000Z",
51 };50 };
52}51}
5352
...@@ -546,7 +545,6 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {...@@ -546,7 +545,6 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
546 effectiveSettled(shell, {545 effectiveSettled(shell, {
547 now: "2026-04-10T00:00:00.000Z",546 now: "2026-04-10T00:00:00.000Z",
548 autoSettleAfterDays: null,547 autoSettleAfterDays: null,
549 autoSettleMode: "change-request",
550 changeRequest: displayed,548 changeRequest: displayed,
551 }),549 }),
552 ).toBe(true);550 ).toBe(true);
apps/web/src/components/settings/SettingsPanels.logic.test.ts+1-20
...@@ -14,10 +14,9 @@ import {...@@ -14,10 +14,9 @@ import {
14 formatDiagnosticsDescription,14 formatDiagnosticsDescription,
15 getChangedBrowserSettingLabels,15 getChangedBrowserSettingLabels,
16 getChangedTypographySettingLabels,16 getChangedTypographySettingLabels,
17 isSamePreviewViewport,
17 hasChangedBackgroundActivitySettings,18 hasChangedBackgroundActivitySettings,
18 hasChangedThreadSettlingSettings,
19 isProjectGroupingEnabled,19 isProjectGroupingEnabled,
20 isSamePreviewViewport,
21 projectGroupingModeFromToggle,20 projectGroupingModeFromToggle,
22 resolveBackgroundActivityProfileOption,21 resolveBackgroundActivityProfileOption,
23} from "./SettingsPanels.logic";22} from "./SettingsPanels.logic";
...@@ -153,24 +152,6 @@ describe("project grouping toggle", () => {...@@ -153,24 +152,6 @@ describe("project grouping toggle", () => {
153 });152 });
154});153});
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
174describe("formatDiagnosticsDescription", () => {155describe("formatDiagnosticsDescription", () => {
175 it("collapses trace and metric URLs that share the same OTEL base path", () => {156 it("collapses trace and metric URLs that share the same OTEL base path", () => {
176 expect(157 expect(
apps/web/src/components/settings/SettingsPanels.logic.ts-9
...@@ -31,15 +31,6 @@ export function projectGroupingModeFromToggle(...@@ -31,15 +31,6 @@ export function projectGroupingModeFromToggle(
31 return lastEnabledMode === "repository_path" ? "repository_path" : "repository";31 return lastEnabledMode === "repository_path" ? "repository_path" : "repository";
32}32}
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
43const LAST_ENABLED_PROJECT_GROUPING_MODE_KEY = "t3code:last-enabled-project-grouping-mode";34const LAST_ENABLED_PROJECT_GROUPING_MODE_KEY = "t3code:last-enabled-project-grouping-mode";
4435
45export function readLastEnabledProjectGroupingMode(): SidebarProjectGroupingMode {36export function readLastEnabledProjectGroupingMode(): SidebarProjectGroupingMode {
apps/web/src/components/settings/SettingsPanels.tsx+53-46
...@@ -8,7 +8,6 @@ import {...@@ -8,7 +8,6 @@ import {
8 type DesktopUpdateChannel,8 type DesktopUpdateChannel,
9 ProviderDriverKind,9 ProviderDriverKind,
10 type ScopedThreadRef,10 type ScopedThreadRef,
11 type SidebarAutoSettleMode,
12 type SidebarProjectGroupingMode,11 type SidebarProjectGroupingMode,
13} from "@t3tools/contracts";12} from "@t3tools/contracts";
14import { scopeThreadRef } from "@t3tools/client-runtime/environment";13import { scopeThreadRef } from "@t3tools/client-runtime/environment";
...@@ -131,7 +130,6 @@ import {...@@ -131,7 +130,6 @@ import {
131 normalizeIntervalSeconds,130 normalizeIntervalSeconds,
132 PROVIDER_HEALTH_INTERVAL_STEP_SECONDS,131 PROVIDER_HEALTH_INTERVAL_STEP_SECONDS,
133 hasChangedBackgroundActivitySettings,132 hasChangedBackgroundActivitySettings,
134 hasChangedThreadSettlingSettings,
135 isProjectGroupingEnabled,133 isProjectGroupingEnabled,
136 projectGroupingModeFromToggle,134 projectGroupingModeFromToggle,
137 readLastEnabledProjectGroupingMode,135 readLastEnabledProjectGroupingMode,
...@@ -473,7 +471,6 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -473,7 +471,6 @@ export function useSettingsRestore(onRestored?: () => void) {
473 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,471 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
474 );472 );
475 const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings);473 const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings);
476 const isThreadSettlingDirty = hasChangedThreadSettlingSettings(settings);
477474
478 const changedSettingLabels = useMemo(475 const changedSettingLabels = useMemo(
479 () => [476 () => [
...@@ -498,7 +495,13 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -498,7 +495,13 @@ export function useSettingsRestore(onRestored?: () => void) {
498 DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode495 DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode
499 ? ["Project Grouping"]496 ? ["Project Grouping"]
500 : []),497 : []),
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 : []),
502 ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),505 ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
503 ...getChangedTypographySettingLabels(settings),506 ...getChangedTypographySettingLabels(settings),
504 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace507 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
...@@ -547,7 +550,6 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -547,7 +550,6 @@ export function useSettingsRestore(onRestored?: () => void) {
547 [550 [
548 isTextGenerationModelDirty,551 isTextGenerationModelDirty,
549 isBackgroundActivityDirty,552 isBackgroundActivityDirty,
550 isThreadSettlingDirty,
551 settings.browserDefaultViewport,553 settings.browserDefaultViewport,
552 settings.browserDefaultZoomFactor,554 settings.browserDefaultZoomFactor,
553 settings.browserDefaultAppearance,555 settings.browserDefaultAppearance,
...@@ -574,6 +576,8 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -574,6 +576,8 @@ export function useSettingsRestore(onRestored?: () => void) {
574 settings.glassOpacity,576 settings.glassOpacity,
575 settings.enableLegacyTokenStreaming,577 settings.enableLegacyTokenStreaming,
576 settings.enableProviderUpdateChecks,578 settings.enableProviderUpdateChecks,
579 settings.sidebarAutoSettleAfterDays,
580 settings.sidebarAutoSettleOnMerge,
577 settings.sidebarProjectGroupingMode,581 settings.sidebarProjectGroupingMode,
578 settings.sidebarThreadPreviewCount,582 settings.sidebarThreadPreviewCount,
579 settings.showSkillsInSlashMenu,583 settings.showSkillsInSlashMenu,
...@@ -658,7 +662,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -658,7 +662,7 @@ export function useSettingsRestore(onRestored?: () => void) {
658 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,662 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
659 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,663 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
660 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,664 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
661 sidebarAutoSettleMode: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleMode,665 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
662 enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming,666 enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming,
663 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,667 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
664 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,668 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
...@@ -1686,11 +1690,6 @@ function FontFamilySettingsRow({...@@ -1686,11 +1690,6 @@ function FontFamilySettingsRow({
1686}1690}
16871691
1688const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3;1692const 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
1695function AutoSettleDaysInput({1694function AutoSettleDaysInput({
1696 value,1695 value,
...@@ -1952,59 +1951,67 @@ export function GeneralSettingsPanel() {...@@ -1952,59 +1951,67 @@ export function GeneralSettingsPanel() {
1952 />1951 />
19531952
1954 <SettingsRow1953 <SettingsRow
1955 {...searchableSetting("thread-settling")}1954 {...searchableSetting("auto-settle-merged-threads")}
1956 description="Choose the only event allowed to settle a thread automatically. Manual settling always remains available."1955 description="Settle a thread when its pull request merges. Closed pull requests still settle automatically."
1957 resetAction={1956 resetAction={
1958 hasChangedThreadSettlingSettings(settings) ? (1957 settings.sidebarAutoSettleOnMerge !==
1958 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? (
1959 <SettingResetButton1959 <SettingResetButton
1960 label="thread settling"1960 label="auto-settle on merge"
1961 onClick={() =>1961 onClick={() =>
1962 updateSettings({1962 updateSettings({
1963 sidebarAutoSettleMode: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleMode,1963 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
1964 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
1965 })1964 })
1966 }1965 }
1967 />1966 />
1968 ) : null1967 ) : null
1969 }1968 }
1970 control={1969 control={
1971 <Select1970 <Switch
1972 value={settings.sidebarAutoSettleMode}1971 checked={settings.sidebarAutoSettleOnMerge}
1973 onValueChange={(value) => {1972 onCheckedChange={(checked) =>
1974 if (value === "never" || value === "change-request" || value === "inactivity") {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={() =>
1975 updateSettings({1989 updateSettings({
1976 sidebarAutoSettleMode: value,1990 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
1977 ...(value === "inactivity" && settings.sidebarAutoSettleAfterDays === null1991 })
1978 ? { sidebarAutoSettleAfterDays: AUTO_SETTLE_DEFAULT_DAYS }
1979 : {}),
1980 });
1981 }1992 }
1982 }}1993 />
1983 >1994 ) : null
1984 <SelectTrigger className="w-full sm:w-64" aria-label="Thread settling">1995 }
1985 <SelectValue>{AUTO_SETTLE_MODE_LABELS[settings.sidebarAutoSettleMode]}</SelectValue>1996 control={
1986 </SelectTrigger>1997 <Switch
1987 <SelectPopup align="end" alignItemWithTrigger={false}>1998 checked={settings.sidebarAutoSettleAfterDays !== null}
1988 <SelectItem hideIndicator value="never">1999 onCheckedChange={(checked) =>
1989 {AUTO_SETTLE_MODE_LABELS.never}2000 updateSettings({
1990 </SelectItem>2001 sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null,
1991 <SelectItem hideIndicator value="change-request">2002 })
1992 {AUTO_SETTLE_MODE_LABELS["change-request"]}2003 }
1993 </SelectItem>2004 aria-label="Auto-settle inactive threads"
1994 <SelectItem hideIndicator value="inactivity">2005 />
1995 {AUTO_SETTLE_MODE_LABELS.inactivity}
1996 </SelectItem>
1997 </SelectPopup>
1998 </Select>
1999 }2006 }
2000 />2007 />
2001 {settings.sidebarAutoSettleMode === "inactivity" ? (2008 {settings.sidebarAutoSettleAfterDays !== null ? (
2002 <SettingsRow2009 <SettingsRow
2003 title="Days of inactivity before auto-settle"2010 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."
2005 control={2012 control={
2006 <AutoSettleDaysInput2013 <AutoSettleDaysInput
2007 value={settings.sidebarAutoSettleAfterDays ?? AUTO_SETTLE_DEFAULT_DAYS}2014 value={settings.sidebarAutoSettleAfterDays}
2008 onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}2015 onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}
2009 />2016 />
2010 }2017 }
apps/web/src/components/settings/settingsSearch.ts+7-2
...@@ -112,8 +112,13 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -112,8 +112,13 @@ export const SETTINGS_SEARCH_ITEMS = [
112 to: "/settings/general",112 to: "/settings/general",
113 },113 },
114 {114 {
115 id: "thread-settling",115 id: "auto-settle-inactive-threads",
116 title: "Thread settling",116 title: "Auto-settle inactive threads",
117 to: "/settings/general",
118 },
119 {
120 id: "auto-settle-merged-threads",
121 title: "Auto-settle merged threads",
117 to: "/settings/general",122 to: "/settings/general",
118 },123 },
119 {124 {
apps/web/src/hooks/useThreadActionMenu.ts+3-3
...@@ -81,7 +81,7 @@ export function useThreadActionMenu(input: {...@@ -81,7 +81,7 @@ export function useThreadActionMenu(input: {
81 const handleNewThread = useNewThreadHandler();81 const handleNewThread = useNewThreadHandler();
82 const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);82 const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
83 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);83 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
84 const autoSettleMode = useClientSettings((s) => s.sidebarAutoSettleMode);84 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
85 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);85 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
86 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);86 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
87 const timestampFormat = useClientSettings((s) => s.timestampFormat);87 const timestampFormat = useClientSettings((s) => s.timestampFormat);
...@@ -135,7 +135,7 @@ export function useThreadActionMenu(input: {...@@ -135,7 +135,7 @@ export function useThreadActionMenu(input: {
135 // parked-thread banner within the same minute.135 // parked-thread banner within the same minute.
136 now: `${now.toISOString().slice(0, 16)}:00.000Z`,136 now: `${now.toISOString().slice(0, 16)}:00.000Z`,
137 autoSettleAfterDays,137 autoSettleAfterDays,
138 autoSettleMode,138 autoSettleOnMerge,
139 changeRequest,139 changeRequest,
140 }),140 }),
141 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),141 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
...@@ -312,7 +312,7 @@ export function useThreadActionMenu(input: {...@@ -312,7 +312,7 @@ export function useThreadActionMenu(input: {
312 [312 [
313 archiveThread,313 archiveThread,
314 autoSettleAfterDays,314 autoSettleAfterDays,
315 autoSettleMode,315 autoSettleOnMerge,
316 changeRequest,316 changeRequest,
317 confirmThreadArchive,317 confirmThreadArchive,
318 confirmThreadDelete,318 confirmThreadDelete,
docs/user/thread-sidebar.md+4-8
...@@ -7,19 +7,15 @@ their project, including when you connect to more than one environment....@@ -7,19 +7,15 @@ their project, including when you connect to more than one environment.
7To require confirmation before unpinning, enable **Settings → General → Unpin confirmation**. The7To require confirmation before unpinning, enable **Settings → General → Unpin confirmation**. The
8confirmation applies to the sidebar controls, thread menus, and the `mod+shift+p` shortcut.8confirmation 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:10Pinned threads still move to **Settled** when they become inactive. They also move when their pull
11settle when a pull request merges or closes, settle after a configurable period of inactivity, or11request merges if **Auto-settle merged threads** is enabled.
12never auto-settle. **Never** leaves every automatic path off, so threads settle only when you choose
13**Settle**.
1412
15When you un-settle a thread, it returns to the top of the active list so you can find it right13When you un-settle a thread, it returns to the top of the active list so you can find it right
16away. Its timestamps do not change. Other threads keep their positions.14away. Its timestamps do not change. Other threads keep their positions.
1715
18Right-click a pull request link in a thread and choose **Link to thread** to show that pull request16Right-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 the17in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged
20pull-request policy is selected. Right-click the same link and choose **Unlink from thread** to18threads** is enabled. Right-click the same link and choose **Unlink from thread** to remove it.
21remove it. If T3 Code cannot determine when a pull request entered its terminal state, it keeps the
22thread active.
2319
24On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu20On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu
25and choose **Move up** or **Move down**. The order is stored by the server and appears on your21and 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([...@@ -17,7 +17,6 @@ const THEME_INTEROP_ALLOWLIST = new Set([
17 "features/home/HomeHeader.tsx",17 "features/home/HomeHeader.tsx",
18 "features/review/ReviewSheet.tsx",18 "features/review/ReviewSheet.tsx",
19 "features/review/useNativeReviewDiffBridge.ts",19 "features/review/useNativeReviewDiffBridge.ts",
20 "features/settings/SettingsAutoSettleRouteScreen.tsx",
21 "features/settings/SettingsEnvironmentsRouteScreen.tsx",20 "features/settings/SettingsEnvironmentsRouteScreen.tsx",
22 "features/settings/appearance/components/AppearancePreviews.tsx",21 "features/settings/appearance/components/AppearancePreviews.tsx",
23 "features/settings/appearance/components/FontSizeSliderRow.tsx",22 "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";...@@ -21,6 +21,18 @@ const FRESH = "2026-04-09T00:00:00.000Z";
21const STALE = "2026-04-06T23:59:59.999Z";21const STALE = "2026-04-06T23:59:59.999Z";
2222
23describe("changeRequestAutoSettles", () => {23describe("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
24 const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z";36 const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z";
25 const idleThread = {37 const idleThread = {
26 createdAt: THREAD_CREATED_AT,38 createdAt: THREAD_CREATED_AT,
...@@ -28,19 +40,6 @@ describe("changeRequestAutoSettles", () => {...@@ -28,19 +40,6 @@ describe("changeRequestAutoSettles", () => {
28 latestTurn: null,40 latestTurn: null,
29 };41 };
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
44 it("ignores a terminal change request last touched before the thread existed", () => {43 it("ignores a terminal change request last touched before the thread existed", () => {
45 for (const state of ["merged", "closed"] as const) {44 for (const state of ["merged", "closed"] as const) {
46 expect(45 expect(
...@@ -106,20 +105,20 @@ describe("changeRequestAutoSettles", () => {...@@ -106,20 +105,20 @@ describe("changeRequestAutoSettles", () => {
106 ).toBe(true);105 ).toBe(true);
107 });106 });
108107
109 it("keeps the thread active when either timestamp is missing or malformed", () => {108 it("falls back to settling when either timestamp is missing or malformed", () => {
110 expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(false);109 expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(true);
111 expect(110 expect(
112 changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }),111 changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }),
113 ).toBe(false);112 ).toBe(true);
114 expect(113 expect(
115 changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}),114 changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}),
116 ).toBe(false);115 ).toBe(true);
117 expect(116 expect(
118 changeRequestAutoSettles(117 changeRequestAutoSettles(
119 { state: "merged", updatedAt: "not-a-date" },118 { state: "merged", updatedAt: "not-a-date" },
120 { thread: idleThread },119 { thread: idleThread },
121 ),120 ),
122 ).toBe(false);121 ).toBe(true);
123 });122 });
124});123});
125124
...@@ -205,47 +204,38 @@ describe("effectiveSettled", () => {...@@ -205,47 +204,38 @@ describe("effectiveSettled", () => {
205 ] as const;204 ] as const;
206 const runningCases = [false, true] as const;205 const runningCases = [false, true] as const;
207 const pendingCases = [undefined, "approval", "user-input"] as const;206 const pendingCases = [undefined, "approval", "user-input"] as const;
208 const autoSettleModes = ["never", "change-request", "inactivity"] as const;207 const truthTable = overrideCases.flatMap((settledOverride) =>
209 const truthTable = autoSettleModes.flatMap((autoSettleMode) =>208 changeRequestStates.flatMap((changeRequestState) =>
210 overrideCases.flatMap((settledOverride) =>209 inactivityCases.flatMap(([inactivity, activityAt]) =>
211 changeRequestStates.flatMap((changeRequestState) =>210 runningCases.flatMap((running) =>
212 inactivityCases.flatMap(([inactivity, activityAt]) =>211 pendingCases.map((pending) => ({
213 runningCases.flatMap((running) =>212 settledOverride,
214 pendingCases.map((pending) => ({213 changeRequestState,
215 autoSettleMode,214 inactivity,
216 settledOverride,215 activityAt,
217 changeRequestState,216 running,
218 inactivity,217 pending,
219 activityAt,218 // Settled iff nothing blocks (pending work / live session) AND
220 running,219 // the override says settled, or (with no override) a merged PR
221 pending,220 // or staleness auto-settles. The "active" pin suppresses both
222 expected:221 // auto signals, and an open PR suppresses the inactivity path:
223 pending === undefined &&222 // a thread with a PR out for review is never done, however quiet.
224 !running &&223 expected:
225 (settledOverride === "settled" ||224 pending === undefined &&
226 (settledOverride === null &&225 !running &&
227 ((autoSettleMode === "change-request" && changeRequestState === "merged") ||226 (settledOverride === "settled" ||
228 (autoSettleMode === "inactivity" &&227 (settledOverride === null &&
229 changeRequestState !== "open" &&228 (changeRequestState === "merged" ||
230 inactivity === "stale")))),229 (changeRequestState !== "open" && inactivity === "stale")))),
231 })),230 })),
232 ),
233 ),231 ),
234 ),232 ),
235 ),233 ),
236 );234 );
237235
238 it.each(truthTable)(236 it.each(truthTable)(
239 "mode=$autoSettleMode override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending",237 "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending",
240 ({238 ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => {
241 autoSettleMode,
242 settledOverride,
243 changeRequestState,
244 activityAt,
245 running,
246 pending,
247 expected,
248 }) => {
249 const shell = makeShell({239 const shell = makeShell({
250 settledOverride,240 settledOverride,
251 activityAt,241 activityAt,
...@@ -255,18 +245,12 @@ describe("effectiveSettled", () => {...@@ -255,18 +245,12 @@ describe("effectiveSettled", () => {
255 const changeRequestOptions =245 const changeRequestOptions =
256 changeRequestState === undefined246 changeRequestState === undefined
257 ? {}247 ? {}
258 : {248 : { changeRequest: { state: changeRequestState as ChangeRequestStateLike } };
259 changeRequest: {
260 state: changeRequestState as ChangeRequestStateLike,
261 updatedAt: NOW,
262 },
263 };
264249
265 expect(250 expect(
266 effectiveSettled(shell, {251 effectiveSettled(shell, {
267 now: NOW,252 now: NOW,
268 autoSettleAfterDays: 3,253 autoSettleAfterDays: 3,
269 autoSettleMode,
270 ...changeRequestOptions,254 ...changeRequestOptions,
271 }),255 }),
272 ).toBe(expected);256 ).toBe(expected);
...@@ -279,8 +263,7 @@ describe("effectiveSettled", () => {...@@ -279,8 +263,7 @@ describe("effectiveSettled", () => {
279 effectiveSettled(shell, {263 effectiveSettled(shell, {
280 now: NOW,264 now: NOW,
281 autoSettleAfterDays: null,265 autoSettleAfterDays: null,
282 autoSettleMode: "change-request",266 changeRequest: { state: "closed" },
283 changeRequest: { state: "closed", updatedAt: NOW },
284 }),267 }),
285 ).toBe(true);268 ).toBe(true);
286 });269 });
...@@ -292,8 +275,7 @@ describe("effectiveSettled", () => {...@@ -292,8 +275,7 @@ describe("effectiveSettled", () => {
292 effectiveSettled(recentlyActive, {275 effectiveSettled(recentlyActive, {
293 now: NOW,276 now: NOW,
294 autoSettleAfterDays: null,277 autoSettleAfterDays: null,
295 autoSettleMode: "change-request",278 changeRequest: { state: changeRequestState },
296 changeRequest: { state: changeRequestState, updatedAt: NOW },
297 }),279 }),
298 ).toBe(true);280 ).toBe(true);
299 }281 }
...@@ -309,7 +291,6 @@ describe("effectiveSettled", () => {...@@ -309,7 +291,6 @@ describe("effectiveSettled", () => {
309 effectiveSettled(fresh, {291 effectiveSettled(fresh, {
310 now: NOW,292 now: NOW,
311 autoSettleAfterDays: null,293 autoSettleAfterDays: null,
312 autoSettleMode: "change-request",
313 changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" },294 changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" },
314 }),295 }),
315 ).toBe(false);296 ).toBe(false);
...@@ -319,24 +300,30 @@ describe("effectiveSettled", () => {...@@ -319,24 +300,30 @@ describe("effectiveSettled", () => {
319 effectiveSettled(fresh, {300 effectiveSettled(fresh, {
320 now: NOW,301 now: NOW,
321 autoSettleAfterDays: null,302 autoSettleAfterDays: null,
322 autoSettleMode: "change-request",
323 changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" },303 changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" },
324 }),304 }),
325 ).toBe(true);305 ).toBe(true);
326 });306 });
327307
328 it("keeps a merged change request active in every other mode", () => {308 it("can keep a merged change request active", () => {
329 const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });309 const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });
330 for (const autoSettleMode of ["never", "inactivity"] as const) {310 expect(
331 expect(311 effectiveSettled(recentlyActive, {
332 effectiveSettled(recentlyActive, {312 now: NOW,
333 now: NOW,313 autoSettleAfterDays: null,
334 autoSettleAfterDays: null,314 autoSettleOnMerge: false,
335 autoSettleMode,315 changeRequest: { state: "merged" },
336 changeRequest: { state: "merged", updatedAt: NOW },316 }),
337 }),317 ).toBe(false);
338 ).toBe(false);318
339 }319 expect(
320 effectiveSettled(recentlyActive, {
321 now: NOW,
322 autoSettleAfterDays: null,
323 autoSettleOnMerge: false,
324 changeRequest: { state: "closed" },
325 }),
326 ).toBe(true);
340 });327 });
341328
342 it("never auto-settles a stale thread with an open change request", () => {329 it("never auto-settles a stale thread with an open change request", () => {
...@@ -345,7 +332,6 @@ describe("effectiveSettled", () => {...@@ -345,7 +332,6 @@ describe("effectiveSettled", () => {
345 effectiveSettled(stale, {332 effectiveSettled(stale, {
346 now: NOW,333 now: NOW,
347 autoSettleAfterDays: 3,334 autoSettleAfterDays: 3,
348 autoSettleMode: "inactivity",
349 changeRequest: { state: "open" },335 changeRequest: { state: "open" },
350 }),336 }),
351 ).toBe(false);337 ).toBe(false);
...@@ -355,7 +341,6 @@ describe("effectiveSettled", () => {...@@ -355,7 +341,6 @@ describe("effectiveSettled", () => {
355 effectiveSettled(settled, {341 effectiveSettled(settled, {
356 now: NOW,342 now: NOW,
357 autoSettleAfterDays: 3,343 autoSettleAfterDays: 3,
358 autoSettleMode: "inactivity",
359 changeRequest: { state: "open" },344 changeRequest: { state: "open" },
360 }),345 }),
361 ).toBe(true);346 ).toBe(true);
...@@ -370,8 +355,7 @@ describe("effectiveSettled", () => {...@@ -370,8 +355,7 @@ describe("effectiveSettled", () => {
370 effectiveSettled(shell, {355 effectiveSettled(shell, {
371 now: NOW,356 now: NOW,
372 autoSettleAfterDays: null,357 autoSettleAfterDays: null,
373 autoSettleMode: "change-request",358 changeRequest: { state: "merged" },
374 changeRequest: { state: "merged", updatedAt: NOW },
375 }),359 }),
376 ).toBe(false);360 ).toBe(false);
377 });361 });
...@@ -386,8 +370,7 @@ describe("effectiveSettled", () => {...@@ -386,8 +370,7 @@ describe("effectiveSettled", () => {
386 effectiveSettled(shell, {370 effectiveSettled(shell, {
387 now: NOW,371 now: NOW,
388 autoSettleAfterDays: 3,372 autoSettleAfterDays: 3,
389 autoSettleMode: "change-request",373 changeRequest: { state: "merged" },
390 changeRequest: { state: "merged", updatedAt: NOW },
391 }),374 }),
392 ).toBe(false);375 ).toBe(false);
393 });376 });
...@@ -431,8 +414,7 @@ describe("effectiveSettled", () => {...@@ -431,8 +414,7 @@ describe("effectiveSettled", () => {
431 effectiveSettled(shell, {414 effectiveSettled(shell, {
432 now: transitionNow,415 now: transitionNow,
433 autoSettleAfterDays: 3,416 autoSettleAfterDays: 3,
434 autoSettleMode: "change-request",417 changeRequest: { state: "merged" },
435 changeRequest: { state: "merged", updatedAt: transitionNow },
436 }),418 }),
437 ).toBe(false);419 ).toBe(false);
438 }420 }
...@@ -444,20 +426,8 @@ describe("effectiveSettled", () => {...@@ -444,20 +426,8 @@ describe("effectiveSettled", () => {
444 });426 });
445 const stale = makeShell({ activityAt: STALE });427 const stale = makeShell({ activityAt: STALE });
446428
447 expect(429 expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);
448 effectiveSettled(boundary, {430 expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false);
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);
461 });431 });
462});432});
463433
...@@ -562,8 +532,7 @@ describe("canSettle", () => {...@@ -562,8 +532,7 @@ describe("canSettle", () => {
562 effectiveSettled(queued, {532 effectiveSettled(queued, {
563 now: justAfter,533 now: justAfter,
564 autoSettleAfterDays: 3,534 autoSettleAfterDays: 3,
565 autoSettleMode: "change-request",535 changeRequest: { state: "merged" },
566 changeRequest: { state: "merged", updatedAt: justAfter },
567 }),536 }),
568 ).toBe(false);537 ).toBe(false);
569 // Past the window the message is a failed/stale start: settleable again.538 // Past the window the message is a failed/stale start: settleable again.
...@@ -585,13 +554,9 @@ describe("canSettle", () => {...@@ -585,13 +554,9 @@ describe("canSettle", () => {
585 settledAt: "2026-04-09T12:02:10.000Z",554 settledAt: "2026-04-09T12:02:10.000Z",
586 };555 };
587 expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true);556 expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true);
588 expect(557 expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe(
589 effectiveSettled(settledAfterMessage, {558 true,
590 now: flooredNow,559 );
591 autoSettleAfterDays: 3,
592 autoSettleMode: "never",
593 }),
594 ).toBe(true);
595560
596 // A message NEWER than settledAt is genuinely new work: still blocked561 // A message NEWER than settledAt is genuinely new work: still blocked
597 // until the server's auto-unsettle lands.562 // until the server's auto-unsettle lands.
...@@ -604,7 +569,6 @@ describe("canSettle", () => {...@@ -604,7 +569,6 @@ describe("canSettle", () => {
604 effectiveSettled(messageAfterSettle, {569 effectiveSettled(messageAfterSettle, {
605 now: "2026-04-09T12:03:30.000Z",570 now: "2026-04-09T12:03:30.000Z",
606 autoSettleAfterDays: 3,571 autoSettleAfterDays: 3,
607 autoSettleMode: "never",
608 }),572 }),
609 ).toBe(false);573 ).toBe(false);
610 });574 });
...@@ -618,12 +582,6 @@ describe("canSettle", () => {...@@ -618,12 +582,6 @@ describe("canSettle", () => {
618 pending: "user-input",582 pending: "user-input",
619 });583 });
620 expect(canSettle(blocked, { now: NOW })).toBe(false);584 expect(canSettle(blocked, { now: NOW })).toBe(false);
621 expect(585 expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);
622 effectiveSettled(blocked, {
623 now: NOW,
624 autoSettleAfterDays: 3,
625 autoSettleMode: "never",
626 }),
627 ).toBe(false);
628 });586 });
629});587});
packages/client-runtime/src/state/threadSettled.ts+25-17
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1// @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels.1// @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
4export type ChangeRequestStateLike = "open" | "closed" | "merged";4export type ChangeRequestStateLike = "open" | "closed" | "merged";
55
...@@ -46,22 +46,26 @@ function threadUserActivityAnchorAt(thread: ThreadActivitySource): string {...@@ -46,22 +46,26 @@ function threadUserActivityAnchorAt(thread: ThreadActivitySource): string {
46 * thread started at a worktree root whose PR already merged), and one older46 * thread started at a worktree root whose PR already merged), and one older
47 * than the user's latest engagement was already adjudicated — re-engaging a47 * than the user's latest engagement was already adjudicated — re-engaging a
48 * thread whose PR merged is the user saying the conversation outlived the48 * thread whose PR merged is the user saying the conversation outlived the
49 * PR. Missing or malformed timestamps keep the thread active: an automatic49 * PR. Unknown timestamps keep the old always-settle behavior.
50 * transition must have enough evidence to be predictable.
51 */50 */
52export function changeRequestAutoSettles(51export function changeRequestAutoSettles(
53 changeRequest: ChangeRequestSettleSource | null | undefined,52 changeRequest: ChangeRequestSettleSource | null | undefined,
54 options: {53 options: {
54 readonly autoSettleOnMerge?: boolean | undefined;
55 readonly thread?: ThreadActivitySource | null | undefined;55 readonly thread?: ThreadActivitySource | null | undefined;
56 } = {},56 } = {},
57): boolean {57): boolean {
58 if (changeRequest == null) return false;58 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);
60 if (!terminal) return false;62 if (!terminal) return false;
61 if (changeRequest.updatedAt == null || options.thread == null) return false;63 if (changeRequest.updatedAt == null || options.thread == null) return true;
62 const updatedAtMs = Date.parse(changeRequest.updatedAt);64 const updatedAtMs = Date.parse(changeRequest.updatedAt);
63 const anchorAtMs = Date.parse(threadUserActivityAnchorAt(options.thread));65 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;
65 return updatedAtMs >= anchorAtMs;69 return updatedAtMs >= anchorAtMs;
66}70}
6771
...@@ -285,11 +289,11 @@ export function threadWokeAt(...@@ -285,11 +289,11 @@ export function threadWokeAt(
285 * queued turn) are checked first and hold a thread active regardless of any289 * queued turn) are checked first and hold a thread active regardless of any
286 * override. Past the blockers, the explicit user override (thread.settle /290 * override. Past the blockers, the explicit user override (thread.settle /
287 * thread.unsettle commands, projected into settledOverride + settledAt)291 * thread.unsettle commands, projected into settledOverride + settledAt)
288 * wins in both directions. Without one, exactly one selected policy can292 * wins in both directions; without one, a thread can auto-settle on a
289 * auto-settle: a merged/closed pull request whose terminal timestamp is the293 * merged PR or always on a closed PR (both only while the terminal state is
290 * thread's latest user event (see changeRequestAutoSettles), inactivity past294 * the thread's latest event, see changeRequestAutoSettles), or settles on
291 * the configured window, or never. An open PR blocks the inactivity path.295 * inactivity past the window.
292 * The server296 * An open PR blocks the inactivity path entirely. The server
293 * un-settles on real activity (user message, session start, approval/297 * un-settles on real activity (user message, session start, approval/
294 * user-input request), so an override never goes stale silently.298 * user-input request), so an override never goes stale silently.
295 */299 */
...@@ -298,7 +302,7 @@ export function effectiveSettled(...@@ -298,7 +302,7 @@ export function effectiveSettled(
298 options: {302 options: {
299 readonly now: string;303 readonly now: string;
300 readonly autoSettleAfterDays: number | null;304 readonly autoSettleAfterDays: number | null;
301 readonly autoSettleMode: SidebarAutoSettleMode;305 readonly autoSettleOnMerge?: boolean;
302 readonly changeRequest?: ChangeRequestSettleSource | null;306 readonly changeRequest?: ChangeRequestSettleSource | null;
303 },307 },
304): boolean {308): boolean {
...@@ -325,14 +329,18 @@ export function effectiveSettled(...@@ -325,14 +329,18 @@ export function effectiveSettled(
325 // "active" is the explicit keep-active pin: it suppresses auto-settle329 // "active" is the explicit keep-active pin: it suppresses auto-settle
326 // until real activity clears it server-side.330 // until real activity clears it server-side.
327 if (shell.settledOverride === "active") return false;331 if (shell.settledOverride === "active") return false;
328 if (options.autoSettleMode === "never") return false;332 if (
329 if (options.autoSettleMode === "change-request") {333 changeRequestAutoSettles(options.changeRequest, {
330 return changeRequestAutoSettles(options.changeRequest, { thread: shell });334 autoSettleOnMerge: options.autoSettleOnMerge,
335 thread: shell,
336 })
337 ) {
338 return true;
331 }339 }
332 // An open PR is unfinished business regardless of how long the thread has340 // An open PR is unfinished business regardless of how long the thread has
333 // been quiet: review can take days, and hiding the thread would bury the341 // 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 the342 // work waiting on it. A configured merge, a close, or an explicit user
335 // inactivity policy is selected.343 // settle resolves it.
336 if (options.changeRequest?.state === "open") return false;344 if (options.changeRequest?.state === "open") return false;
337 if (options.autoSettleAfterDays === null) return false;345 if (options.autoSettleAfterDays === null) return false;
338346
packages/contracts/src/settings.test.ts+9-23
...@@ -117,11 +117,11 @@ describe("ClientSettings environment identification", () => {...@@ -117,11 +117,11 @@ describe("ClientSettings environment identification", () => {
117});117});
118118
119describe("ClientSettings sidebar", () => {119describe("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", () => {
121 const settings = decodeClientSettings({});121 const settings = decodeClientSettings({});
122 expect(settings.legacySidebarEnabled).toBe(false);122 expect(settings.legacySidebarEnabled).toBe(false);
123 expect(settings.sidebarAutoSettleAfterDays).toBe(3);123 expect(settings.sidebarAutoSettleAfterDays).toBe(3);
124 expect(settings.sidebarAutoSettleMode).toBe("never");124 expect(settings.sidebarAutoSettleOnMerge).toBe(true);
125 });125 });
126126
127 it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => {127 it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => {
...@@ -153,27 +153,13 @@ describe("ClientSettings sidebar", () => {...@@ -153,27 +153,13 @@ describe("ClientSettings sidebar", () => {
153 ).toBeNull();153 ).toBeNull();
154 });154 });
155155
156 it("does not preserve the retired merge switch as a hidden automatic path", () => {156 it("allows auto-settle on merge to be disabled", () => {
157 const decoded = decodeClientSettings({ sidebarAutoSettleOnMerge: true });157 expect(decodeClientSettings({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge).toBe(
158 expect(decoded.sidebarAutoSettleMode).toBe("never");158 false,
159 expect(decoded).not.toHaveProperty("sidebarAutoSettleOnMerge");159 );
160 });160 expect(
161161 decodeClientSettingsPatch({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge,
162 it.each(["never", "change-request", "inactivity"] as const)(162 ).toBe(false);
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();
177 });163 });
178164
179 it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => {165 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(...@@ -65,9 +65,6 @@ export const SidebarAutoSettleAfterDays = Schema.Number.check(
65);65);
66export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type;66export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type;
67export const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3;67export 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";
71export const MIN_GLASS_OPACITY = 40;68export const MIN_GLASS_OPACITY = 40;
72export const MAX_GLASS_OPACITY = 100;69export const MAX_GLASS_OPACITY = 100;
73export const GlassOpacity = Schema.Int.check(70export const GlassOpacity = Schema.Int.check(
...@@ -238,12 +235,7 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -238,12 +235,7 @@ export const ClientSettingsSchema = Schema.Struct({
238 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(235 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
239 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),236 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
240 ),237 ),
241 // A fresh exclusive policy replaces the old independent inactivity and238 sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
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 ),
247 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(239 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
248 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),240 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
249 ),241 ),
...@@ -952,7 +944,7 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -952,7 +944,7 @@ export const ClientSettingsPatch = Schema.Struct({
952 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),944 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
953 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),945 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
954 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),946 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
955 sidebarAutoSettleMode: Schema.optionalKey(SidebarAutoSettleMode),947 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
956 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),948 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
957 sidebarProjectGroupingOverrides: Schema.optionalKey(949 sidebarProjectGroupingOverrides: Schema.optionalKey(
958 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),950 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),