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

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


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

apps/desktop/src/settings/DesktopClientSettings.test.ts+1-1
...@@ -39,7 +39,7 @@ const clientSettings: ClientSettings = {...@@ -39,7 +39,7 @@ const clientSettings: ClientSettings = {
39 showSkillsInSlashMenu: false,39 showSkillsInSlashMenu: false,
40 providerModelPreferences: {},40 providerModelPreferences: {},
41 sidebarAutoSettleAfterDays: 3,41 sidebarAutoSettleAfterDays: 3,
42 sidebarAutoSettleOnMerge: true,42 sidebarAutoSettleMode: "change-request",
43 sidebarProjectGroupingMode: "repository_path",43 sidebarProjectGroupingMode: "repository_path",
44 sidebarProjectGroupingOverrides: {44 sidebarProjectGroupingOverrides: {
45 "environment-1:/tmp/project-a": "separate",45 "environment-1:/tmp/project-a": "separate",
apps/mobile/src/Stack.tsx+8
...@@ -51,6 +51,7 @@ import {...@@ -51,6 +51,7 @@ 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";
54import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";55import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";
55import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";56import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
56import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";57import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
...@@ -177,6 +178,13 @@ const SettingsContentStack = createNativeStackNavigator({...@@ -177,6 +178,13 @@ const SettingsContentStack = createNativeStackNavigator({
177 title: "Appearance",178 title: "Appearance",
178 },179 },
179 }),180 }),
181 SettingsAutoSettle: createNativeStackScreen({
182 screen: SettingsAutoSettleRouteScreen,
183 linking: "thread-settling",
184 options: {
185 title: "Thread Settling",
186 },
187 }),
180 SettingsProjectGrouping: createNativeStackScreen({188 SettingsProjectGrouping: createNativeStackScreen({
181 screen: SettingsProjectGroupingRouteScreen,189 screen: SettingsProjectGroupingRouteScreen,
182 linking: "project-grouping",190 linking: "project-grouping",
apps/mobile/src/features/home/HomeScreen.tsx+8-5
...@@ -30,6 +30,7 @@ import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspace...@@ -30,6 +30,7 @@ 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";
33import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";34import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
34import { useThreadSearch } from "../../state/queries";35import { useThreadSearch } from "../../state/queries";
35import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";36import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
...@@ -208,9 +209,9 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -208,9 +209,9 @@ export function HomeScreen(props: HomeScreenProps) {
208 >(() => new Map());209 >(() => new Map());
209 const preferencesResult = useAtomValue(mobilePreferencesAtom);210 const preferencesResult = useAtomValue(mobilePreferencesAtom);
210 const threadListV2Enabled = useThreadListV2Enabled();211 const threadListV2Enabled = useThreadListV2Enabled();
211 const autoSettleOnMerge =212 const autoSettlePreferences = resolveMobileAutoSettlePreferences(
212 !AsyncResult.isSuccess(preferencesResult) ||213 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {},
213 preferencesResult.value.autoSettleOnMerge !== false;214 );
214 const savePreferences = useAtomSet(updateMobilePreferencesAtom);215 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
215 const openSwipeableRef = useRef<SwipeableMethods | null>(null);216 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
216 const listRef = useRef<LegendListRef | null>(null);217 const listRef = useRef<LegendListRef | null>(null);
...@@ -678,7 +679,8 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -678,7 +679,8 @@ export function HomeScreen(props: HomeScreenProps) {
678 searchQuery: props.searchQuery,679 searchQuery: props.searchQuery,
679 matchedThreadKeys,680 matchedThreadKeys,
680 changeRequestByKey,681 changeRequestByKey,
681 autoSettleOnMerge,682 autoSettleAfterDays: autoSettlePreferences.autoSettleAfterDays,
683 autoSettleMode: autoSettlePreferences.autoSettleMode,
682 settlementEnvironmentIds,684 settlementEnvironmentIds,
683 snoozeEnvironmentIds,685 snoozeEnvironmentIds,
684 settledLimit: settledVisibleCount,686 settledLimit: settledVisibleCount,
...@@ -690,7 +692,8 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -690,7 +692,8 @@ export function HomeScreen(props: HomeScreenProps) {
690 });692 });
691 }, [693 }, [
692 changeRequestByKey,694 changeRequestByKey,
693 autoSettleOnMerge,695 autoSettlePreferences.autoSettleAfterDays,
696 autoSettlePreferences.autoSettleMode,
694 nowMinute,697 nowMinute,
695 snoozeWakeTick,698 snoozeWakeTick,
696 snoozedShelfExpanded,699 snoozedShelfExpanded,
apps/mobile/src/features/settings/SettingsAutoSettleRouteScreen.tsx created+173
...@@ -0,0 +1,173 @@
1import { useAtomSet, useAtomValue } from "@effect/atom-react";
2import { useNavigation } from "@react-navigation/native";
3import {
4 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
5 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
6 type SidebarAutoSettleMode,
7} from "@t3tools/contracts";
8import { AsyncResult } from "effect/unstable/reactivity";
9import { Platform, Pressable, ScrollView, View } from "react-native";
10import { useSafeAreaInsets } from "react-native-safe-area-context";
11
12import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
13import { AppText as Text } from "../../components/AppText";
14import { SymbolView } from "../../components/AppSymbol";
15import { useUniwindTheme } from "../../lib/useUniwindTheme";
16import { NativeStackScreenOptions } from "../../native/StackHeader";
17import { resolveMobileAutoSettlePreferences } from "../../persistence/mobile-preferences";
18import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
19import { SettingsSection } from "./components/SettingsSection";
20
21const AUTO_SETTLE_OPTIONS: ReadonlyArray<{
22 readonly mode: SidebarAutoSettleMode;
23 readonly label: string;
24 readonly description: string;
25}> = [
26 {
27 mode: "never",
28 label: "Never",
29 description: "Threads settle only when you choose Settle.",
30 },
31 {
32 mode: "change-request",
33 label: "When a pull request merges or closes",
34 description: "A missing or uncertain pull-request timestamp keeps the thread active.",
35 },
36 {
37 mode: "inactivity",
38 label: "After inactivity",
39 description: "Running, blocked, and open-pull-request threads stay active.",
40 },
41];
42
43export function mobileAutoSettleModeLabel(mode: SidebarAutoSettleMode): string {
44 if (mode === "change-request") return "PR merged or closed";
45 if (mode === "inactivity") return "After inactivity";
46 return "Never";
47}
48
49export function SettingsAutoSettleRouteScreen() {
50 const navigation = useNavigation();
51 const insets = useSafeAreaInsets();
52 const checkmarkColor = useUniwindTheme()["--color-icon"];
53 const preferencesResult = useAtomValue(mobilePreferencesAtom);
54 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
55 const preferencesReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;
56 const resolved = resolveMobileAutoSettlePreferences(
57 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {},
58 );
59
60 const setDays = (days: number) => {
61 if (!preferencesReady) return;
62 savePreferences({
63 autoSettleAfterDays: Math.min(
64 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
65 Math.max(MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, days),
66 ),
67 });
68 };
69
70 return (
71 <View collapsable={false} className="flex-1 bg-sheet">
72 {Platform.OS === "android" ? (
73 <>
74 <NativeStackScreenOptions options={{ headerShown: false }} />
75 <AndroidScreenHeader title="Thread Settling" onBack={() => navigation.goBack()} />
76 </>
77 ) : null}
78 <ScrollView
79 contentInsetAdjustmentBehavior="automatic"
80 showsVerticalScrollIndicator={false}
81 className="flex-1"
82 contentContainerClassName="gap-4 px-5 pt-4"
83 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
84 >
85 <SettingsSection title="Automatic settling">
86 {AUTO_SETTLE_OPTIONS.map((option, index) => (
87 <Pressable
88 key={option.mode}
89 accessibilityRole="radio"
90 accessibilityState={{
91 checked: resolved.autoSettleMode === option.mode,
92 disabled: !preferencesReady,
93 }}
94 disabled={!preferencesReady}
95 onPress={() => savePreferences({ autoSettleMode: option.mode })}
96 className={
97 index === 0
98 ? "flex-row items-center gap-4 p-4"
99 : "flex-row items-center gap-4 border-t border-border-subtle p-4"
100 }
101 >
102 <View className="min-w-0 flex-1 gap-1">
103 <Text className="text-lg text-foreground">{option.label}</Text>
104 <Text className="text-sm leading-normal text-foreground-muted">
105 {option.description}
106 </Text>
107 </View>
108 {resolved.autoSettleMode === option.mode ? (
109 <SymbolView
110 name="checkmark"
111 size={18}
112 tintColor={checkmarkColor}
113 type="monochrome"
114 weight="semibold"
115 />
116 ) : null}
117 </Pressable>
118 ))}
119 </SettingsSection>
120
121 {resolved.autoSettleMode === "inactivity" ? (
122 <SettingsSection title="Inactivity window">
123 <View className="flex-row items-center gap-4 p-4">
124 <View className="min-w-0 flex-1 gap-1">
125 <Text className="text-lg text-foreground">Days without activity</Text>
126 <Text className="text-sm text-foreground-muted">Between 1 and 90 days</Text>
127 </View>
128 <Pressable
129 accessibilityLabel="Decrease inactivity days"
130 accessibilityRole="button"
131 disabled={
132 !preferencesReady ||
133 resolved.autoSettleAfterDays <= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS
134 }
135 onPress={() => setDays(resolved.autoSettleAfterDays - 1)}
136 className="size-10 items-center justify-center rounded-full bg-fill-secondary disabled:opacity-40"
137 >
138 <SymbolView
139 name="minus"
140 size={16}
141 tintColor={checkmarkColor}
142 type="monochrome"
143 weight="semibold"
144 />
145 </Pressable>
146 <Text className="w-8 text-center text-lg text-foreground">
147 {resolved.autoSettleAfterDays}
148 </Text>
149 <Pressable
150 accessibilityLabel="Increase inactivity days"
151 accessibilityRole="button"
152 disabled={
153 !preferencesReady ||
154 resolved.autoSettleAfterDays >= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS
155 }
156 onPress={() => setDays(resolved.autoSettleAfterDays + 1)}
157 className="size-10 items-center justify-center rounded-full bg-fill-secondary disabled:opacity-40"
158 >
159 <SymbolView
160 name="plus"
161 size={16}
162 tintColor={checkmarkColor}
163 type="monochrome"
164 weight="semibold"
165 />
166 </Pressable>
167 </View>
168 </SettingsSection>
169 ) : null}
170 </ScrollView>
171 </View>
172 );
173}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+9-8
...@@ -4,6 +4,7 @@ import Constants from "expo-constants";...@@ -4,6 +4,7 @@ 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";
7import { SymbolView } from "../../components/AppSymbol";8import { SymbolView } from "../../components/AppSymbol";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
9import { AsyncResult } from "effect/unstable/reactivity";10import { AsyncResult } from "effect/unstable/reactivity";
...@@ -45,6 +46,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re...@@ -45,6 +46,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
45import { SettingsRow } from "./components/SettingsRow";46import { SettingsRow } from "./components/SettingsRow";
46import { SettingsSection } from "./components/SettingsSection";47import { SettingsSection } from "./components/SettingsSection";
47import { SettingsSwitchRow } from "./components/SettingsSwitchRow";48import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
49import { mobileAutoSettleModeLabel } from "./SettingsAutoSettleRouteScreen";
48import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";50import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";
4951
50type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";52type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
...@@ -528,19 +530,18 @@ function ConfiguredSettingsRouteScreen() {...@@ -528,19 +530,18 @@ function ConfiguredSettingsRouteScreen() {
528530
529function GeneralSettingsSection() {531function GeneralSettingsSection() {
530 const preferencesResult = useAtomValue(mobilePreferencesAtom);532 const preferencesResult = useAtomValue(mobilePreferencesAtom);
531 const savePreferences = useAtomSet(updateMobilePreferencesAtom);533 const autoSettlePreferences = resolveMobileAutoSettlePreferences(
532 const autoSettleOnMerge =534 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {},
533 !AsyncResult.isSuccess(preferencesResult) ||535 );
534 preferencesResult.value.autoSettleOnMerge !== false;
535536
536 return (537 return (
537 <SettingsSection title="General">538 <SettingsSection title="General">
538 <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />539 <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
539 <SettingsSwitchRow540 <SettingsRow
540 icon="arrow.triangle.branch"541 icon="arrow.triangle.branch"
541 label="Auto-settle merged threads"542 label="Thread Settling"
542 value={autoSettleOnMerge}543 value={mobileAutoSettleModeLabel(autoSettlePreferences.autoSettleMode)}
543 onValueChange={(value) => savePreferences({ autoSettleOnMerge: value })}544 target="SettingsAutoSettle"
544 />545 />
545 <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />546 <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
546 </SettingsSection>547 </SettingsSection>
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+1
...@@ -2,6 +2,7 @@ export type SettingsSheetTarget =...@@ -2,6 +2,7 @@ export type SettingsSheetTarget =
2 | "SettingsEnvironments"2 | "SettingsEnvironments"
3 | "SettingsArchive"3 | "SettingsArchive"
4 | "SettingsAppearance"4 | "SettingsAppearance"
5 | "SettingsAutoSettle"
5 | "SettingsProjectGrouping"6 | "SettingsProjectGrouping"
6 | "SettingsClientStorage"7 | "SettingsClientStorage"
7 | "SettingsUsage";8 | "SettingsUsage";
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+8-5
...@@ -26,6 +26,7 @@ import { ControlPillMenu } from "../../components/ControlPill";...@@ -26,6 +26,7 @@ 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";
29import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";30import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
30import { useProjects, useThreadShells } from "../../state/entities";31import { useProjects, useThreadShells } from "../../state/entities";
31import { mobilePreferencesAtom } from "../../state/preferences";32import { mobilePreferencesAtom } from "../../state/preferences";
...@@ -165,9 +166,9 @@ function ThreadNavigationSidebarPane(...@@ -165,9 +166,9 @@ function ThreadNavigationSidebarPane(
165 } = useThreadListActions();166 } = useThreadListActions();
166 const threadListV2Enabled = useThreadListV2Enabled();167 const threadListV2Enabled = useThreadListV2Enabled();
167 const preferencesResult = useAtomValue(mobilePreferencesAtom);168 const preferencesResult = useAtomValue(mobilePreferencesAtom);
168 const autoSettleOnMerge =169 const autoSettlePreferences = resolveMobileAutoSettlePreferences(
169 !AsyncResult.isSuccess(preferencesResult) ||170 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {},
170 preferencesResult.value.autoSettleOnMerge !== false;171 );
171 const pendingTasks = usePendingNewTasks();172 const pendingTasks = usePendingNewTasks();
172 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();173 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
173 const environments = useMemo(174 const environments = useMemo(
...@@ -510,7 +511,8 @@ function ThreadNavigationSidebarPane(...@@ -510,7 +511,8 @@ function ThreadNavigationSidebarPane(
510 searchQuery: props.searchQuery,511 searchQuery: props.searchQuery,
511 matchedThreadKeys,512 matchedThreadKeys,
512 changeRequestByKey,513 changeRequestByKey,
513 autoSettleOnMerge,514 autoSettleAfterDays: autoSettlePreferences.autoSettleAfterDays,
515 autoSettleMode: autoSettlePreferences.autoSettleMode,
514 settlementEnvironmentIds,516 settlementEnvironmentIds,
515 snoozeEnvironmentIds,517 snoozeEnvironmentIds,
516 settledLimit: settledVisibleCount,518 settledLimit: settledVisibleCount,
...@@ -522,7 +524,8 @@ function ThreadNavigationSidebarPane(...@@ -522,7 +524,8 @@ function ThreadNavigationSidebarPane(
522 });524 });
523 }, [525 }, [
524 changeRequestByKey,526 changeRequestByKey,
525 autoSettleOnMerge,527 autoSettlePreferences.autoSettleAfterDays,
528 autoSettlePreferences.autoSettleMode,
526 nowMinute,529 nowMinute,
527 snoozeWakeTick,530 snoozeWakeTick,
528 snoozedShelfExpanded,531 snoozedShelfExpanded,
apps/mobile/src/features/threads/threadListV2.test.ts+15-7
...@@ -360,10 +360,12 @@ describe("buildThreadListV2Items", () => {...@@ -360,10 +360,12 @@ 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,
363 linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',364 linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
364 },365 },
365 ],366 ],
366 ]),367 ]),
368 autoSettleMode: "change-request",
367 now: NOW,369 now: NOW,
368 });370 });
369371
...@@ -371,16 +373,16 @@ describe("buildThreadListV2Items", () => {...@@ -371,16 +373,16 @@ describe("buildThreadListV2Items", () => {
371 expect(layout.items[0]?.variant).toBe("slim");373 expect(layout.items[0]?.variant).toBe("slim");
372 });374 });
373375
374 it("keeps a merged thread active when auto-settle on merge is off", () => {376 it("keeps a merged thread active when automatic settling is off", () => {
375 const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });377 const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });
376 const layout = buildThreadListV2Items({378 const layout = buildThreadListV2Items({
377 threads: [merged],379 threads: [merged],
378 environmentId: null,380 environmentId: null,
379 searchQuery: "",381 searchQuery: "",
380 changeRequestByKey: new Map([382 changeRequestByKey: new Map([
381 [`${environmentId}:${merged.id}`, { state: "merged" as const }],383 [`${environmentId}:${merged.id}`, { state: "merged" as const, updatedAt: NOW }],
382 ]),384 ]),
383 autoSettleOnMerge: false,385 autoSettleMode: "never",
384 now: NOW,386 now: NOW,
385 });387 });
386388
...@@ -449,7 +451,10 @@ describe("buildThreadListV2Items", () => {...@@ -449,7 +451,10 @@ describe("buildThreadListV2Items", () => {
449 threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged],451 threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged],
450 environmentId: null,452 environmentId: null,
451 searchQuery: "",453 searchQuery: "",
452 changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]),454 changeRequestByKey: new Map([
455 [`${environmentId}:${merged.id}`, { state: "merged", updatedAt: NOW }],
456 ]),
457 autoSettleMode: "change-request",
453 now: NOW,458 now: NOW,
454 });459 });
455460
...@@ -478,6 +483,7 @@ describe("buildThreadListV2Items", () => {...@@ -478,6 +483,7 @@ describe("buildThreadListV2Items", () => {
478 threads: [inactive],483 threads: [inactive],
479 environmentId: null,484 environmentId: null,
480 searchQuery: "",485 searchQuery: "",
486 autoSettleMode: "inactivity",
481 now: NOW,487 now: NOW,
482 });488 });
483489
...@@ -489,7 +495,7 @@ describe("buildThreadListV2Items", () => {...@@ -489,7 +495,7 @@ describe("buildThreadListV2Items", () => {
489 expect(layout.settledCount).toBe(1);495 expect(layout.settledCount).toBe(1);
490 });496 });
491497
492 it("keeps pinned merged threads pinned when auto-settle on merge is off", () => {498 it("keeps pinned merged threads pinned when automatic settling is off", () => {
493 const merged = makeThread({499 const merged = makeThread({
494 id: ThreadId.make("pinned-merged"),500 id: ThreadId.make("pinned-merged"),
495 title: "Pinned merged pull request",501 title: "Pinned merged pull request",
...@@ -499,8 +505,10 @@ describe("buildThreadListV2Items", () => {...@@ -499,8 +505,10 @@ describe("buildThreadListV2Items", () => {
499 threads: [merged],505 threads: [merged],
500 environmentId: null,506 environmentId: null,
501 searchQuery: "",507 searchQuery: "",
502 changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]),508 changeRequestByKey: new Map([
503 autoSettleOnMerge: false,509 [`${environmentId}:${merged.id}`, { state: "merged", updatedAt: NOW }],
510 ]),
511 autoSettleMode: "never",
504 now: NOW,512 now: NOW,
505 });513 });
506514
apps/mobile/src/features/threads/threadListV2.ts+9-4
...@@ -16,7 +16,12 @@ import {...@@ -16,7 +16,12 @@ 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 { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts";19import type {
20 EnvironmentId,
21 ProjectId,
22 SidebarAutoSettleMode,
23 ThreadLinkedPullRequest,
24} from "@t3tools/contracts";
2025
21import type { PendingNewTask } from "../../state/use-pending-new-tasks";26import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2227
...@@ -369,7 +374,7 @@ export function buildThreadListV2Items(input: {...@@ -369,7 +374,7 @@ export function buildThreadListV2Items(input: {
369 contract as settlementEnvironmentIds. */374 contract as settlementEnvironmentIds. */
370 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;375 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
371 readonly autoSettleAfterDays?: number;376 readonly autoSettleAfterDays?: number;
372 readonly autoSettleOnMerge?: boolean;377 readonly autoSettleMode?: SidebarAutoSettleMode;
373 /** Max settled rows to render; the rest are counted, not built. */378 /** Max settled rows to render; the rest are counted, not built. */
374 readonly settledLimit?: number;379 readonly settledLimit?: number;
375 /** Injectable for tests; defaults to now. */380 /** Injectable for tests; defaults to now. */
...@@ -390,7 +395,7 @@ export function buildThreadListV2Items(input: {...@@ -390,7 +395,7 @@ export function buildThreadListV2Items(input: {
390 const now = input.now ?? new Date().toISOString();395 const now = input.now ?? new Date().toISOString();
391 const snoozeNow = input.snoozeNow ?? now;396 const snoozeNow = input.snoozeNow ?? now;
392 const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;397 const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
393 const autoSettleOnMerge = input.autoSettleOnMerge ?? true;398 const autoSettleMode = input.autoSettleMode ?? "never";
394 const query = input.searchQuery.trim().toLocaleLowerCase();399 const query = input.searchQuery.trim().toLocaleLowerCase();
395 const projectKeys = input.projectRefs400 const projectKeys = input.projectRefs
396 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))401 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
...@@ -447,7 +452,7 @@ export function buildThreadListV2Items(input: {...@@ -447,7 +452,7 @@ export function buildThreadListV2Items(input: {
447 effectiveSettled(thread, {452 effectiveSettled(thread, {
448 now,453 now,
449 autoSettleAfterDays,454 autoSettleAfterDays,
450 autoSettleOnMerge,455 autoSettleMode,
451 changeRequest,456 changeRequest,
452 })457 })
453 ) {458 ) {
apps/mobile/src/lib/storage.test.ts+29
...@@ -100,6 +100,7 @@ import {...@@ -100,6 +100,7 @@ import {
100 saveConnection,100 saveConnection,
101 savePreferencesPatch,101 savePreferencesPatch,
102} from "../persistence/imperative";102} from "../persistence/imperative";
103import { resolveMobileAutoSettlePreferences } from "../persistence/mobile-preferences";
103import { toStableSavedRemoteConnection } from "./connection";104import { toStableSavedRemoteConnection } from "./connection";
104105
105const managedConnection = {106const managedConnection = {
...@@ -202,6 +203,34 @@ describe("mobile connection storage", () => {...@@ -202,6 +203,34 @@ describe("mobile connection storage", () => {
202 await expect(loadPreferences()).resolves.toEqual({});203 await expect(loadPreferences()).resolves.toEqual({});
203 });204 });
204205
206 it("defaults retired mobile settling preferences to manual-only", async () => {
207 mocks.setPreferencesJson(JSON.stringify({ autoSettleOnMerge: true }), 10);
208
209 const preferences = await loadPreferences();
210 expect(preferences).toEqual({});
211 expect(resolveMobileAutoSettlePreferences(preferences)).toEqual({
212 autoSettleMode: "never",
213 autoSettleAfterDays: 3,
214 });
215 });
216
217 it("preserves valid mobile settling preferences", async () => {
218 mocks.setPreferencesJson(
219 JSON.stringify({ autoSettleMode: "inactivity", autoSettleAfterDays: 30 }),
220 10,
221 );
222
223 const preferences = await loadPreferences();
224 expect(preferences).toEqual({
225 autoSettleMode: "inactivity",
226 autoSettleAfterDays: 30,
227 });
228 expect(resolveMobileAutoSettlePreferences(preferences)).toEqual({
229 autoSettleMode: "inactivity",
230 autoSettleAfterDays: 30,
231 });
232 });
233
205 it("falls back to secure storage when SQLite cannot save preferences", async () => {234 it("falls back to secure storage when SQLite cannot save preferences", async () => {
206 mocks.setDatabaseFailures(true, true);235 mocks.setDatabaseFailures(true, true);
207 await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 });236 await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 });
apps/mobile/src/persistence/mobile-preferences.ts+37-5
...@@ -5,7 +5,15 @@ import * as Option from "effect/Option";...@@ -5,7 +5,15 @@ 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 type { SidebarProjectGroupingMode } from "@t3tools/contracts";8import {
9 DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
10 DEFAULT_SIDEBAR_AUTO_SETTLE_MODE,
11 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
12 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
13 type SidebarAutoSettleAfterDays,
14 type SidebarAutoSettleMode,
15 type SidebarProjectGroupingMode,
16} from "@t3tools/contracts";
9import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";17import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
1018
11import * as MobileDatabase from "./mobile-database";19import * as MobileDatabase from "./mobile-database";
...@@ -31,7 +39,8 @@ export interface Preferences {...@@ -31,7 +39,8 @@ export interface Preferences {
31 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */39 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
32 readonly projectGroupingEnabled?: boolean;40 readonly projectGroupingEnabled?: boolean;
33 readonly projectGroupingMode?: SidebarProjectGroupingMode;41 readonly projectGroupingMode?: SidebarProjectGroupingMode;
34 readonly autoSettleOnMerge?: boolean;42 readonly autoSettleMode?: SidebarAutoSettleMode;
43 readonly autoSettleAfterDays?: SidebarAutoSettleAfterDays;
35 /**44 /**
36 * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has45 * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has
37 * no client-settings sync, so the legacy grouped thread list is opted into46 * no client-settings sync, so the legacy grouped thread list is opted into
...@@ -101,7 +110,8 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -101,7 +110,8 @@ function sanitizePreferences(parsed: Preferences): Preferences {
101 collapsedProjectGroups?: readonly string[];110 collapsedProjectGroups?: readonly string[];
102 projectGroupingEnabled?: boolean;111 projectGroupingEnabled?: boolean;
103 projectGroupingMode?: SidebarProjectGroupingMode;112 projectGroupingMode?: SidebarProjectGroupingMode;
104 autoSettleOnMerge?: boolean;113 autoSettleMode?: SidebarAutoSettleMode;
114 autoSettleAfterDays?: SidebarAutoSettleAfterDays;
105 legacyThreadListEnabled?: boolean;115 legacyThreadListEnabled?: boolean;
106 planModeEnabled?: boolean;116 planModeEnabled?: boolean;
107 threadListV2SettledShelfExpanded?: boolean;117 threadListV2SettledShelfExpanded?: boolean;
...@@ -167,8 +177,20 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -167,8 +177,20 @@ function sanitizePreferences(parsed: Preferences): Preferences {
167 ) {177 ) {
168 preferences.projectGroupingMode = parsed.projectGroupingMode;178 preferences.projectGroupingMode = parsed.projectGroupingMode;
169 }179 }
170 if (typeof parsed.autoSettleOnMerge === "boolean") {180 if (
171 preferences.autoSettleOnMerge = parsed.autoSettleOnMerge;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;
172 }194 }
173 if (typeof parsed.legacyThreadListEnabled === "boolean") {195 if (typeof parsed.legacyThreadListEnabled === "boolean") {
174 preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;196 preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
...@@ -185,6 +207,16 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -185,6 +207,16 @@ function sanitizePreferences(parsed: Preferences): Preferences {
185 return preferences;207 return preferences;
186}208}
187209
210export function resolveMobileAutoSettlePreferences(preferences: Preferences): {
211 readonly autoSettleMode: SidebarAutoSettleMode;
212 readonly autoSettleAfterDays: SidebarAutoSettleAfterDays;
213} {
214 return {
215 autoSettleMode: preferences.autoSettleMode ?? DEFAULT_SIDEBAR_AUTO_SETTLE_MODE,
216 autoSettleAfterDays: preferences.autoSettleAfterDays ?? DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
217 };
218}
219
188export const make = Effect.fn("MobilePreferencesStore.make")(function* () {220export const make = Effect.fn("MobilePreferencesStore.make")(function* () {
189 const database = yield* MobileDatabase.MobileDatabase;221 const database = yield* MobileDatabase.MobileDatabase;
190 const secureStorage = yield* MobileSecureStorage.MobileSecureStorage;222 const secureStorage = yield* MobileSecureStorage.MobileSecureStorage;
apps/web/src/components/ChatView.tsx+6-8
...@@ -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 autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);4397 const autoSettleMode = useClientSettings((settings) => settings.sidebarAutoSettleMode);
4398 const linkedPullRequestStatus = useLinkedThreadPullRequest(4398 const linkedPullRequestStatus = useLinkedThreadPullRequest(
4399 activeThreadRef?.environmentId ?? null,4399 activeThreadRef?.environmentId ?? null,
4400 linkedThreadPullRequest,4400 linkedThreadPullRequest,
...@@ -4465,10 +4465,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4465,10 +4465,8 @@ 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 changeRequestAutoSettles(activeThreadChangeRequest, {4468 autoSettleMode === "change-request" &&
4469 autoSettleOnMerge,4469 changeRequestAutoSettles(activeThreadChangeRequest, { thread: activeThreadShell })
4470 thread: activeThreadShell,
4471 })
4472 ) {4470 ) {
4473 return false;4471 return false;
4474 }4472 }
...@@ -4494,21 +4492,21 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4494,21 +4492,21 @@ function ChatViewContent(props: ChatViewProps) {
4494 activeThreadChangeRequest,4492 activeThreadChangeRequest,
4495 activeThreadShell,4493 activeThreadShell,
4496 activeThreadWokeAt,4494 activeThreadWokeAt,
4497 autoSettleOnMerge,4495 autoSettleMode,
4498 ]);4496 ]);
4499 const activeThreadSettled = useMemo(() => {4497 const activeThreadSettled = useMemo(() => {
4500 if (activeThreadShell === null || !supportsSettlement) return false;4498 if (activeThreadShell === null || !supportsSettlement) return false;
4501 return effectiveSettled(activeThreadShell, {4499 return effectiveSettled(activeThreadShell, {
4502 now: `${nowMinute}:00.000Z`,4500 now: `${nowMinute}:00.000Z`,
4503 autoSettleAfterDays,4501 autoSettleAfterDays,
4504 autoSettleOnMerge,4502 autoSettleMode,
4505 changeRequest: activeThreadChangeRequest,4503 changeRequest: activeThreadChangeRequest,
4506 });4504 });
4507 }, [4505 }, [
4508 activeThreadChangeRequest,4506 activeThreadChangeRequest,
4509 activeThreadShell,4507 activeThreadShell,
4510 autoSettleAfterDays,4508 autoSettleAfterDays,
4511 autoSettleOnMerge,4509 autoSettleMode,
4512 changeRequestSnapshotByKey,4510 changeRequestSnapshotByKey,
4513 nowMinute,4511 nowMinute,
4514 supportsSettlement,4512 supportsSettlement,
apps/web/src/components/Sidebar.tsx+7-10
...@@ -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 { TimestampFormat } from "@t3tools/contracts/settings";34import type { SidebarAutoSettleMode, 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 autoSettleOnMerge: boolean;717 autoSettleMode: SidebarAutoSettleMode;
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,10 +839,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -839,10 +839,7 @@ 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 !changeRequestAutoSettles(pr, {842 !(props.autoSettleMode === "change-request" && changeRequestAutoSettles(pr, { thread }));
843 autoSettleOnMerge: props.autoSettleOnMerge,
844 thread,
845 });
846 // In-flight rows (working, or waiting on approval/input) fade as a whole:843 // In-flight rows (working, or waiting on approval/input) fade as a whole:
847 // there is nothing for the user to do yet, so prominence is reserved for844 // there is nothing for the user to do yet, so prominence is reserved for
848 // rows that need a human — done (unread), read-but-unsettled, failed, and845 // rows that need a human — done (unread), read-but-unsettled, failed, and
...@@ -1740,7 +1737,7 @@ export default function Sidebar() {...@@ -1740,7 +1737,7 @@ export default function Sidebar() {
1740 const { isMobile, setOpenMobile } = useSidebar();1737 const { isMobile, setOpenMobile } = useSidebar();
1741 const keybindings = useAtomValue(primaryServerKeybindingsAtom);1738 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
1742 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);1739 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
1743 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);1740 const autoSettleMode = useClientSettings((s) => s.sidebarAutoSettleMode);
1744 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);1741 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
1745 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);1742 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
1746 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);1743 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
...@@ -2125,7 +2122,7 @@ export default function Sidebar() {...@@ -2125,7 +2122,7 @@ export default function Sidebar() {
2125 effectiveSettled(thread, {2122 effectiveSettled(thread, {
2126 now,2123 now,
2127 autoSettleAfterDays,2124 autoSettleAfterDays,
2128 autoSettleOnMerge,2125 autoSettleMode,
2129 changeRequest,2126 changeRequest,
2130 })2127 })
2131 ) {2128 ) {
...@@ -2164,7 +2161,7 @@ export default function Sidebar() {...@@ -2164,7 +2161,7 @@ export default function Sidebar() {
2164 };2161 };
2165 }, [2162 }, [
2166 autoSettleAfterDays,2163 autoSettleAfterDays,
2167 autoSettleOnMerge,2164 autoSettleMode,
2168 changeRequestSnapshotByKey,2165 changeRequestSnapshotByKey,
2169 nowMinute,2166 nowMinute,
2170 scopedProjectKeys,2167 scopedProjectKeys,
...@@ -3787,7 +3784,7 @@ export default function Sidebar() {...@@ -3787,7 +3784,7 @@ export default function Sidebar() {
3787 serverConfigs.get(thread.environmentId)?.environment.capabilities3784 serverConfigs.get(thread.environmentId)?.environment.capabilities
3788 .threadSettlement === true3785 .threadSettlement === true
3789 }3786 }
3790 autoSettleOnMerge={autoSettleOnMerge}3787 autoSettleMode={autoSettleMode}
3791 snoozeSupported={3788 snoozeSupported={
3792 serverConfigs.get(thread.environmentId)?.environment.capabilities3789 serverConfigs.get(thread.environmentId)?.environment.capabilities
3793 .threadSnooze === true3790 .threadSnooze === true
apps/web/src/components/ThreadStatusIndicators.test.ts+2
...@@ -47,6 +47,7 @@ function mergedFeaturePr(): NonNullable<VcsStatusResult["pr"]> {...@@ -47,6 +47,7 @@ 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",
50 };51 };
51}52}
5253
...@@ -545,6 +546,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {...@@ -545,6 +546,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
545 effectiveSettled(shell, {546 effectiveSettled(shell, {
546 now: "2026-04-10T00:00:00.000Z",547 now: "2026-04-10T00:00:00.000Z",
547 autoSettleAfterDays: null,548 autoSettleAfterDays: null,
549 autoSettleMode: "change-request",
548 changeRequest: displayed,550 changeRequest: displayed,
549 }),551 }),
550 ).toBe(true);552 ).toBe(true);
apps/web/src/components/settings/SettingsPanels.logic.test.ts+20-1
...@@ -14,9 +14,10 @@ import {...@@ -14,9 +14,10 @@ import {
14 formatDiagnosticsDescription,14 formatDiagnosticsDescription,
15 getChangedBrowserSettingLabels,15 getChangedBrowserSettingLabels,
16 getChangedTypographySettingLabels,16 getChangedTypographySettingLabels,
17 isSamePreviewViewport,
18 hasChangedBackgroundActivitySettings,17 hasChangedBackgroundActivitySettings,
18 hasChangedThreadSettlingSettings,
19 isProjectGroupingEnabled,19 isProjectGroupingEnabled,
20 isSamePreviewViewport,
20 projectGroupingModeFromToggle,21 projectGroupingModeFromToggle,
21 resolveBackgroundActivityProfileOption,22 resolveBackgroundActivityProfileOption,
22} from "./SettingsPanels.logic";23} from "./SettingsPanels.logic";
...@@ -152,6 +153,24 @@ describe("project grouping toggle", () => {...@@ -152,6 +153,24 @@ describe("project grouping toggle", () => {
152 });153 });
153});154});
154155
156describe("thread settling settings restore", () => {
157 it("detects changes to the mode or the hidden inactivity window", () => {
158 expect(hasChangedThreadSettlingSettings(DEFAULT_UNIFIED_SETTINGS)).toBe(false);
159 expect(
160 hasChangedThreadSettlingSettings({
161 ...DEFAULT_UNIFIED_SETTINGS,
162 sidebarAutoSettleMode: "inactivity",
163 }),
164 ).toBe(true);
165 expect(
166 hasChangedThreadSettlingSettings({
167 ...DEFAULT_UNIFIED_SETTINGS,
168 sidebarAutoSettleAfterDays: 30,
169 }),
170 ).toBe(true);
171 });
172});
173
155describe("formatDiagnosticsDescription", () => {174describe("formatDiagnosticsDescription", () => {
156 it("collapses trace and metric URLs that share the same OTEL base path", () => {175 it("collapses trace and metric URLs that share the same OTEL base path", () => {
157 expect(176 expect(
apps/web/src/components/settings/SettingsPanels.logic.ts+9
...@@ -31,6 +31,15 @@ export function projectGroupingModeFromToggle(...@@ -31,6 +31,15 @@ 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
34const LAST_ENABLED_PROJECT_GROUPING_MODE_KEY = "t3code:last-enabled-project-grouping-mode";43const LAST_ENABLED_PROJECT_GROUPING_MODE_KEY = "t3code:last-enabled-project-grouping-mode";
3544
36export function readLastEnabledProjectGroupingMode(): SidebarProjectGroupingMode {45export function readLastEnabledProjectGroupingMode(): SidebarProjectGroupingMode {
apps/web/src/components/settings/SettingsPanels.tsx+46-53
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 type DesktopUpdateChannel,8 type DesktopUpdateChannel,
9 ProviderDriverKind,9 ProviderDriverKind,
10 type ScopedThreadRef,10 type ScopedThreadRef,
11 type SidebarAutoSettleMode,
11 type SidebarProjectGroupingMode,12 type SidebarProjectGroupingMode,
12} from "@t3tools/contracts";13} from "@t3tools/contracts";
13import { scopeThreadRef } from "@t3tools/client-runtime/environment";14import { scopeThreadRef } from "@t3tools/client-runtime/environment";
...@@ -130,6 +131,7 @@ import {...@@ -130,6 +131,7 @@ import {
130 normalizeIntervalSeconds,131 normalizeIntervalSeconds,
131 PROVIDER_HEALTH_INTERVAL_STEP_SECONDS,132 PROVIDER_HEALTH_INTERVAL_STEP_SECONDS,
132 hasChangedBackgroundActivitySettings,133 hasChangedBackgroundActivitySettings,
134 hasChangedThreadSettlingSettings,
133 isProjectGroupingEnabled,135 isProjectGroupingEnabled,
134 projectGroupingModeFromToggle,136 projectGroupingModeFromToggle,
135 readLastEnabledProjectGroupingMode,137 readLastEnabledProjectGroupingMode,
...@@ -471,6 +473,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -471,6 +473,7 @@ export function useSettingsRestore(onRestored?: () => void) {
471 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,473 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
472 );474 );
473 const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings);475 const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings);
476 const isThreadSettlingDirty = hasChangedThreadSettlingSettings(settings);
474477
475 const changedSettingLabels = useMemo(478 const changedSettingLabels = useMemo(
476 () => [479 () => [
...@@ -495,13 +498,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -495,13 +498,7 @@ export function useSettingsRestore(onRestored?: () => void) {
495 DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode498 DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode
496 ? ["Project Grouping"]499 ? ["Project Grouping"]
497 : []),500 : []),
498 ...(settings.sidebarAutoSettleAfterDays !==501 ...(isThreadSettlingDirty ? ["Thread settling"] : []),
499 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays
500 ? ["Auto-settle inactive threads"]
501 : []),
502 ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge
503 ? ["Auto-settle merged threads"]
504 : []),
505 ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),502 ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
506 ...getChangedTypographySettingLabels(settings),503 ...getChangedTypographySettingLabels(settings),
507 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace504 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
...@@ -547,6 +544,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -547,6 +544,7 @@ export function useSettingsRestore(onRestored?: () => void) {
547 [544 [
548 isTextGenerationModelDirty,545 isTextGenerationModelDirty,
549 isBackgroundActivityDirty,546 isBackgroundActivityDirty,
547 isThreadSettlingDirty,
550 settings.browserDefaultViewport,548 settings.browserDefaultViewport,
551 settings.browserDefaultZoomFactor,549 settings.browserDefaultZoomFactor,
552 settings.browserDefaultAppearance,550 settings.browserDefaultAppearance,
...@@ -572,8 +570,6 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -572,8 +570,6 @@ export function useSettingsRestore(onRestored?: () => void) {
572 settings.glassOpacity,570 settings.glassOpacity,
573 settings.enableLegacyTokenStreaming,571 settings.enableLegacyTokenStreaming,
574 settings.enableProviderUpdateChecks,572 settings.enableProviderUpdateChecks,
575 settings.sidebarAutoSettleAfterDays,
576 settings.sidebarAutoSettleOnMerge,
577 settings.sidebarProjectGroupingMode,573 settings.sidebarProjectGroupingMode,
578 settings.sidebarThreadPreviewCount,574 settings.sidebarThreadPreviewCount,
579 settings.showSkillsInSlashMenu,575 settings.showSkillsInSlashMenu,
...@@ -658,7 +654,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -658,7 +654,7 @@ export function useSettingsRestore(onRestored?: () => void) {
658 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,654 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
659 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,655 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
660 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,656 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
661 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,657 sidebarAutoSettleMode: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleMode,
662 enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming,658 enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming,
663 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,659 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
664 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,660 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
...@@ -1685,6 +1681,11 @@ function FontFamilySettingsRow({...@@ -1685,6 +1681,11 @@ function FontFamilySettingsRow({
1685}1681}
16861682
1687const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3;1683const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3;
1684const AUTO_SETTLE_MODE_LABELS: Readonly<Record<SidebarAutoSettleMode, string>> = {
1685 never: "Never (manual only)",
1686 "change-request": "When PR merges or closes",
1687 inactivity: "After inactivity",
1688};
16881689
1689function AutoSettleDaysInput({1690function AutoSettleDaysInput({
1690 value,1691 value,
...@@ -1946,67 +1947,59 @@ export function GeneralSettingsPanel() {...@@ -1946,67 +1947,59 @@ export function GeneralSettingsPanel() {
1946 />1947 />
19471948
1948 <SettingsRow1949 <SettingsRow
1949 {...searchableSetting("auto-settle-merged-threads")}1950 {...searchableSetting("thread-settling")}
1950 description="Settle a thread when its pull request merges. Closed pull requests still settle automatically."1951 description="Choose the only event allowed to settle a thread automatically. Manual settling always remains available."
1951 resetAction={1952 resetAction={
1952 settings.sidebarAutoSettleOnMerge !==1953 hasChangedThreadSettlingSettings(settings) ? (
1953 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? (
1954 <SettingResetButton1954 <SettingResetButton
1955 label="auto-settle on merge"1955 label="thread settling"
1956 onClick={() =>1956 onClick={() =>
1957 updateSettings({1957 updateSettings({
1958 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,1958 sidebarAutoSettleMode: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleMode,
1959 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
1959 })1960 })
1960 }1961 }
1961 />1962 />
1962 ) : null1963 ) : null
1963 }1964 }
1964 control={1965 control={
1965 <Switch1966 <Select
1966 checked={settings.sidebarAutoSettleOnMerge}1967 value={settings.sidebarAutoSettleMode}
1967 onCheckedChange={(checked) =>1968 onValueChange={(value) => {
1968 updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) })1969 if (value === "never" || value === "change-request" || value === "inactivity") {
1969 }
1970 aria-label="Auto-settle merged threads"
1971 />
1972 }
1973 />
1974
1975 <SettingsRow
1976 {...searchableSetting("auto-settle-inactive-threads")}
1977 description="Sidebar threads with no activity for this long settle automatically."
1978 resetAction={
1979 settings.sidebarAutoSettleAfterDays !==
1980 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? (
1981 <SettingResetButton
1982 label="auto-settle"
1983 onClick={() =>
1984 updateSettings({1970 updateSettings({
1985 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,1971 sidebarAutoSettleMode: value,
1986 })1972 ...(value === "inactivity" && settings.sidebarAutoSettleAfterDays === null
1973 ? { sidebarAutoSettleAfterDays: AUTO_SETTLE_DEFAULT_DAYS }
1974 : {}),
1975 });
1987 }1976 }
1988 />1977 }}
1989 ) : null1978 >
1990 }1979 <SelectTrigger className="w-full sm:w-64" aria-label="Thread settling">
1991 control={1980 <SelectValue>{AUTO_SETTLE_MODE_LABELS[settings.sidebarAutoSettleMode]}</SelectValue>
1992 <Switch1981 </SelectTrigger>
1993 checked={settings.sidebarAutoSettleAfterDays !== null}1982 <SelectPopup align="end" alignItemWithTrigger={false}>
1994 onCheckedChange={(checked) =>1983 <SelectItem hideIndicator value="never">
1995 updateSettings({1984 {AUTO_SETTLE_MODE_LABELS.never}
1996 sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null,1985 </SelectItem>
1997 })1986 <SelectItem hideIndicator value="change-request">
1998 }1987 {AUTO_SETTLE_MODE_LABELS["change-request"]}
1999 aria-label="Auto-settle inactive threads"1988 </SelectItem>
2000 />1989 <SelectItem hideIndicator value="inactivity">
1990 {AUTO_SETTLE_MODE_LABELS.inactivity}
1991 </SelectItem>
1992 </SelectPopup>
1993 </Select>
2001 }1994 }
2002 />1995 />
2003 {settings.sidebarAutoSettleAfterDays !== null ? (1996 {settings.sidebarAutoSettleMode === "inactivity" ? (
2004 <SettingsRow1997 <SettingsRow
2005 title="Days of inactivity before auto-settle"1998 title="Days of inactivity before auto-settle"
2006 description="Any new activity un-settles a thread automatically."1999 description="Running, blocked, and open-pull-request threads always stay active."
2007 control={2000 control={
2008 <AutoSettleDaysInput2001 <AutoSettleDaysInput
2009 value={settings.sidebarAutoSettleAfterDays}2002 value={settings.sidebarAutoSettleAfterDays ?? AUTO_SETTLE_DEFAULT_DAYS}
2010 onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}2003 onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}
2011 />2004 />
2012 }2005 }
apps/web/src/components/settings/settingsSearch.ts+2-7
...@@ -112,13 +112,8 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -112,13 +112,8 @@ export const SETTINGS_SEARCH_ITEMS = [
112 to: "/settings/general",112 to: "/settings/general",
113 },113 },
114 {114 {
115 id: "auto-settle-inactive-threads",115 id: "thread-settling",
116 title: "Auto-settle inactive threads",116 title: "Thread settling",
117 to: "/settings/general",
118 },
119 {
120 id: "auto-settle-merged-threads",
121 title: "Auto-settle merged threads",
122 to: "/settings/general",117 to: "/settings/general",
123 },118 },
124 {119 {
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 autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);84 const autoSettleMode = useClientSettings((s) => s.sidebarAutoSettleMode);
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 autoSettleOnMerge,138 autoSettleMode,
139 changeRequest,139 changeRequest,
140 }),140 }),
141 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),141 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
...@@ -311,7 +311,7 @@ export function useThreadActionMenu(input: {...@@ -311,7 +311,7 @@ export function useThreadActionMenu(input: {
311 [311 [
312 archiveThread,312 archiveThread,
313 autoSettleAfterDays,313 autoSettleAfterDays,
314 autoSettleOnMerge,314 autoSettleMode,
315 changeRequest,315 changeRequest,
316 confirmThreadArchive,316 confirmThreadArchive,
317 confirmThreadDelete,317 confirmThreadDelete,
docs/user/thread-sidebar.md+8-4
...@@ -4,15 +4,19 @@ Pin a thread from its context menu to keep it in the pinned section above your a...@@ -4,15 +4,19 @@ Pin a thread from its context menu to keep it in the pinned section above your a
4`mod+shift+p` pins or unpins the thread you have open. Pinned threads are shown independently of4`mod+shift+p` pins or unpins the thread you have open. Pinned threads are shown independently of
5their project, including when you connect to more than one environment.5their project, including when you connect to more than one environment.
66
7Pinned threads still move to **Settled** when they become inactive. They also move when their pull7Pinned threads follow the auto-settle policy in **Settings → General**. Choose exactly one policy:
8request merges if **Auto-settle merged threads** is enabled.8settle when a pull request merges or closes, settle after a configurable period of inactivity, or
9never auto-settle. **Never** leaves every automatic path off, so threads settle only when you choose
10**Settle**.
911
10When you un-settle a thread, it returns to the top of the active list so you can find it right12When you un-settle a thread, it returns to the top of the active list so you can find it right
11away. Its timestamps do not change. Other threads keep their positions.13away. Its timestamps do not change. Other threads keep their positions.
1214
13Right-click a pull request link in a thread and choose **Link to thread** to show that pull request15Right-click a pull request link in a thread and choose **Link to thread** to show that pull request
14in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged16in the sidebar. The thread settles when the linked pull request merges or closes only when the
15threads** is enabled. Right-click the same link and choose **Unlink from thread** to remove it.17pull-request policy is selected. Right-click the same link and choose **Unlink from thread** to
18remove it. If T3 Code cannot determine when a pull request entered its terminal state, it keeps the
19thread active.
1620
17On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu21On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu
18and choose **Move up** or **Move down**. The order is stored by the server and appears on your22and choose **Move up** or **Move down**. The order is stored by the server and appears on your
oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.ts+1
...@@ -17,6 +17,7 @@ const THEME_INTEROP_ALLOWLIST = new Set([...@@ -17,6 +17,7 @@ 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",
20 "features/settings/SettingsEnvironmentsRouteScreen.tsx",21 "features/settings/SettingsEnvironmentsRouteScreen.tsx",
21 "features/settings/appearance/components/AppearancePreviews.tsx",22 "features/settings/appearance/components/AppearancePreviews.tsx",
22 "features/settings/appearance/components/FontSizeSliderRow.tsx",23 "features/settings/appearance/components/FontSizeSliderRow.tsx",
packages/client-runtime/src/state/threadSettled.test.ts+116-74
...@@ -21,18 +21,6 @@ const FRESH = "2026-04-09T00:00:00.000Z";...@@ -21,18 +21,6 @@ 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
36 const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z";24 const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z";
37 const idleThread = {25 const idleThread = {
38 createdAt: THREAD_CREATED_AT,26 createdAt: THREAD_CREATED_AT,
...@@ -40,6 +28,19 @@ describe("changeRequestAutoSettles", () => {...@@ -40,6 +28,19 @@ describe("changeRequestAutoSettles", () => {
40 latestTurn: null,28 latestTurn: null,
41 };29 };
4230
31 it.each([
32 ["open", false],
33 ["merged", true],
34 ["closed", true],
35 [null, false],
36 ] as const)("state=%s returns %s", (state, expected) => {
37 expect(
38 changeRequestAutoSettles(state === null ? null : { state, updatedAt: THREAD_CREATED_AT }, {
39 thread: idleThread,
40 }),
41 ).toBe(expected);
42 });
43
43 it("ignores a terminal change request last touched before the thread existed", () => {44 it("ignores a terminal change request last touched before the thread existed", () => {
44 for (const state of ["merged", "closed"] as const) {45 for (const state of ["merged", "closed"] as const) {
45 expect(46 expect(
...@@ -105,20 +106,20 @@ describe("changeRequestAutoSettles", () => {...@@ -105,20 +106,20 @@ describe("changeRequestAutoSettles", () => {
105 ).toBe(true);106 ).toBe(true);
106 });107 });
107108
108 it("falls back to settling when either timestamp is missing or malformed", () => {109 it("keeps the thread active when either timestamp is missing or malformed", () => {
109 expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(true);110 expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(false);
110 expect(111 expect(
111 changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }),112 changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }),
112 ).toBe(true);113 ).toBe(false);
113 expect(114 expect(
114 changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}),115 changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}),
115 ).toBe(true);116 ).toBe(false);
116 expect(117 expect(
117 changeRequestAutoSettles(118 changeRequestAutoSettles(
118 { state: "merged", updatedAt: "not-a-date" },119 { state: "merged", updatedAt: "not-a-date" },
119 { thread: idleThread },120 { thread: idleThread },
120 ),121 ),
121 ).toBe(true);122 ).toBe(false);
122 });123 });
123});124});
124125
...@@ -204,38 +205,47 @@ describe("effectiveSettled", () => {...@@ -204,38 +205,47 @@ describe("effectiveSettled", () => {
204 ] as const;205 ] as const;
205 const runningCases = [false, true] as const;206 const runningCases = [false, true] as const;
206 const pendingCases = [undefined, "approval", "user-input"] as const;207 const pendingCases = [undefined, "approval", "user-input"] as const;
207 const truthTable = overrideCases.flatMap((settledOverride) =>208 const autoSettleModes = ["never", "change-request", "inactivity"] as const;
208 changeRequestStates.flatMap((changeRequestState) =>209 const truthTable = autoSettleModes.flatMap((autoSettleMode) =>
209 inactivityCases.flatMap(([inactivity, activityAt]) =>210 overrideCases.flatMap((settledOverride) =>
210 runningCases.flatMap((running) =>211 changeRequestStates.flatMap((changeRequestState) =>
211 pendingCases.map((pending) => ({212 inactivityCases.flatMap(([inactivity, activityAt]) =>
212 settledOverride,213 runningCases.flatMap((running) =>
213 changeRequestState,214 pendingCases.map((pending) => ({
214 inactivity,215 autoSettleMode,
215 activityAt,216 settledOverride,
216 running,217 changeRequestState,
217 pending,218 inactivity,
218 // Settled iff nothing blocks (pending work / live session) AND219 activityAt,
219 // the override says settled, or (with no override) a merged PR220 running,
220 // or staleness auto-settles. The "active" pin suppresses both221 pending,
221 // auto signals, and an open PR suppresses the inactivity path:222 expected:
222 // a thread with a PR out for review is never done, however quiet.223 pending === undefined &&
223 expected:224 !running &&
224 pending === undefined &&225 (settledOverride === "settled" ||
225 !running &&226 (settledOverride === null &&
226 (settledOverride === "settled" ||227 ((autoSettleMode === "change-request" && changeRequestState === "merged") ||
227 (settledOverride === null &&228 (autoSettleMode === "inactivity" &&
228 (changeRequestState === "merged" ||229 changeRequestState !== "open" &&
229 (changeRequestState !== "open" && inactivity === "stale")))),230 inactivity === "stale")))),
230 })),231 })),
232 ),
231 ),233 ),
232 ),234 ),
233 ),235 ),
234 );236 );
235237
236 it.each(truthTable)(238 it.each(truthTable)(
237 "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending",239 "mode=$autoSettleMode override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending",
238 ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => {240 ({
241 autoSettleMode,
242 settledOverride,
243 changeRequestState,
244 activityAt,
245 running,
246 pending,
247 expected,
248 }) => {
239 const shell = makeShell({249 const shell = makeShell({
240 settledOverride,250 settledOverride,
241 activityAt,251 activityAt,
...@@ -245,12 +255,18 @@ describe("effectiveSettled", () => {...@@ -245,12 +255,18 @@ describe("effectiveSettled", () => {
245 const changeRequestOptions =255 const changeRequestOptions =
246 changeRequestState === undefined256 changeRequestState === undefined
247 ? {}257 ? {}
248 : { changeRequest: { state: changeRequestState as ChangeRequestStateLike } };258 : {
259 changeRequest: {
260 state: changeRequestState as ChangeRequestStateLike,
261 updatedAt: NOW,
262 },
263 };
249264
250 expect(265 expect(
251 effectiveSettled(shell, {266 effectiveSettled(shell, {
252 now: NOW,267 now: NOW,
253 autoSettleAfterDays: 3,268 autoSettleAfterDays: 3,
269 autoSettleMode,
254 ...changeRequestOptions,270 ...changeRequestOptions,
255 }),271 }),
256 ).toBe(expected);272 ).toBe(expected);
...@@ -263,7 +279,8 @@ describe("effectiveSettled", () => {...@@ -263,7 +279,8 @@ describe("effectiveSettled", () => {
263 effectiveSettled(shell, {279 effectiveSettled(shell, {
264 now: NOW,280 now: NOW,
265 autoSettleAfterDays: null,281 autoSettleAfterDays: null,
266 changeRequest: { state: "closed" },282 autoSettleMode: "change-request",
283 changeRequest: { state: "closed", updatedAt: NOW },
267 }),284 }),
268 ).toBe(true);285 ).toBe(true);
269 });286 });
...@@ -275,7 +292,8 @@ describe("effectiveSettled", () => {...@@ -275,7 +292,8 @@ describe("effectiveSettled", () => {
275 effectiveSettled(recentlyActive, {292 effectiveSettled(recentlyActive, {
276 now: NOW,293 now: NOW,
277 autoSettleAfterDays: null,294 autoSettleAfterDays: null,
278 changeRequest: { state: changeRequestState },295 autoSettleMode: "change-request",
296 changeRequest: { state: changeRequestState, updatedAt: NOW },
279 }),297 }),
280 ).toBe(true);298 ).toBe(true);
281 }299 }
...@@ -291,6 +309,7 @@ describe("effectiveSettled", () => {...@@ -291,6 +309,7 @@ describe("effectiveSettled", () => {
291 effectiveSettled(fresh, {309 effectiveSettled(fresh, {
292 now: NOW,310 now: NOW,
293 autoSettleAfterDays: null,311 autoSettleAfterDays: null,
312 autoSettleMode: "change-request",
294 changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" },313 changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" },
295 }),314 }),
296 ).toBe(false);315 ).toBe(false);
...@@ -300,30 +319,24 @@ describe("effectiveSettled", () => {...@@ -300,30 +319,24 @@ describe("effectiveSettled", () => {
300 effectiveSettled(fresh, {319 effectiveSettled(fresh, {
301 now: NOW,320 now: NOW,
302 autoSettleAfterDays: null,321 autoSettleAfterDays: null,
322 autoSettleMode: "change-request",
303 changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" },323 changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" },
304 }),324 }),
305 ).toBe(true);325 ).toBe(true);
306 });326 });
307327
308 it("can keep a merged change request active", () => {328 it("keeps a merged change request active in every other mode", () => {
309 const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });329 const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });
310 expect(330 for (const autoSettleMode of ["never", "inactivity"] as const) {
311 effectiveSettled(recentlyActive, {331 expect(
312 now: NOW,332 effectiveSettled(recentlyActive, {
313 autoSettleAfterDays: null,333 now: NOW,
314 autoSettleOnMerge: false,334 autoSettleAfterDays: null,
315 changeRequest: { state: "merged" },335 autoSettleMode,
316 }),336 changeRequest: { state: "merged", updatedAt: NOW },
317 ).toBe(false);337 }),
318338 ).toBe(false);
319 expect(339 }
320 effectiveSettled(recentlyActive, {
321 now: NOW,
322 autoSettleAfterDays: null,
323 autoSettleOnMerge: false,
324 changeRequest: { state: "closed" },
325 }),
326 ).toBe(true);
327 });340 });
328341
329 it("never auto-settles a stale thread with an open change request", () => {342 it("never auto-settles a stale thread with an open change request", () => {
...@@ -332,6 +345,7 @@ describe("effectiveSettled", () => {...@@ -332,6 +345,7 @@ describe("effectiveSettled", () => {
332 effectiveSettled(stale, {345 effectiveSettled(stale, {
333 now: NOW,346 now: NOW,
334 autoSettleAfterDays: 3,347 autoSettleAfterDays: 3,
348 autoSettleMode: "inactivity",
335 changeRequest: { state: "open" },349 changeRequest: { state: "open" },
336 }),350 }),
337 ).toBe(false);351 ).toBe(false);
...@@ -341,6 +355,7 @@ describe("effectiveSettled", () => {...@@ -341,6 +355,7 @@ describe("effectiveSettled", () => {
341 effectiveSettled(settled, {355 effectiveSettled(settled, {
342 now: NOW,356 now: NOW,
343 autoSettleAfterDays: 3,357 autoSettleAfterDays: 3,
358 autoSettleMode: "inactivity",
344 changeRequest: { state: "open" },359 changeRequest: { state: "open" },
345 }),360 }),
346 ).toBe(true);361 ).toBe(true);
...@@ -355,7 +370,8 @@ describe("effectiveSettled", () => {...@@ -355,7 +370,8 @@ describe("effectiveSettled", () => {
355 effectiveSettled(shell, {370 effectiveSettled(shell, {
356 now: NOW,371 now: NOW,
357 autoSettleAfterDays: null,372 autoSettleAfterDays: null,
358 changeRequest: { state: "merged" },373 autoSettleMode: "change-request",
374 changeRequest: { state: "merged", updatedAt: NOW },
359 }),375 }),
360 ).toBe(false);376 ).toBe(false);
361 });377 });
...@@ -370,7 +386,8 @@ describe("effectiveSettled", () => {...@@ -370,7 +386,8 @@ describe("effectiveSettled", () => {
370 effectiveSettled(shell, {386 effectiveSettled(shell, {
371 now: NOW,387 now: NOW,
372 autoSettleAfterDays: 3,388 autoSettleAfterDays: 3,
373 changeRequest: { state: "merged" },389 autoSettleMode: "change-request",
390 changeRequest: { state: "merged", updatedAt: NOW },
374 }),391 }),
375 ).toBe(false);392 ).toBe(false);
376 });393 });
...@@ -414,7 +431,8 @@ describe("effectiveSettled", () => {...@@ -414,7 +431,8 @@ describe("effectiveSettled", () => {
414 effectiveSettled(shell, {431 effectiveSettled(shell, {
415 now: transitionNow,432 now: transitionNow,
416 autoSettleAfterDays: 3,433 autoSettleAfterDays: 3,
417 changeRequest: { state: "merged" },434 autoSettleMode: "change-request",
435 changeRequest: { state: "merged", updatedAt: transitionNow },
418 }),436 }),
419 ).toBe(false);437 ).toBe(false);
420 }438 }
...@@ -426,8 +444,20 @@ describe("effectiveSettled", () => {...@@ -426,8 +444,20 @@ describe("effectiveSettled", () => {
426 });444 });
427 const stale = makeShell({ activityAt: STALE });445 const stale = makeShell({ activityAt: STALE });
428446
429 expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);447 expect(
430 expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false);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);
431 });461 });
432});462});
433463
...@@ -532,7 +562,8 @@ describe("canSettle", () => {...@@ -532,7 +562,8 @@ describe("canSettle", () => {
532 effectiveSettled(queued, {562 effectiveSettled(queued, {
533 now: justAfter,563 now: justAfter,
534 autoSettleAfterDays: 3,564 autoSettleAfterDays: 3,
535 changeRequest: { state: "merged" },565 autoSettleMode: "change-request",
566 changeRequest: { state: "merged", updatedAt: justAfter },
536 }),567 }),
537 ).toBe(false);568 ).toBe(false);
538 // Past the window the message is a failed/stale start: settleable again.569 // Past the window the message is a failed/stale start: settleable again.
...@@ -554,9 +585,13 @@ describe("canSettle", () => {...@@ -554,9 +585,13 @@ describe("canSettle", () => {
554 settledAt: "2026-04-09T12:02:10.000Z",585 settledAt: "2026-04-09T12:02:10.000Z",
555 };586 };
556 expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true);587 expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true);
557 expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe(588 expect(
558 true,589 effectiveSettled(settledAfterMessage, {
559 );590 now: flooredNow,
591 autoSettleAfterDays: 3,
592 autoSettleMode: "never",
593 }),
594 ).toBe(true);
560595
561 // A message NEWER than settledAt is genuinely new work: still blocked596 // A message NEWER than settledAt is genuinely new work: still blocked
562 // until the server's auto-unsettle lands.597 // until the server's auto-unsettle lands.
...@@ -569,6 +604,7 @@ describe("canSettle", () => {...@@ -569,6 +604,7 @@ describe("canSettle", () => {
569 effectiveSettled(messageAfterSettle, {604 effectiveSettled(messageAfterSettle, {
570 now: "2026-04-09T12:03:30.000Z",605 now: "2026-04-09T12:03:30.000Z",
571 autoSettleAfterDays: 3,606 autoSettleAfterDays: 3,
607 autoSettleMode: "never",
572 }),608 }),
573 ).toBe(false);609 ).toBe(false);
574 });610 });
...@@ -582,6 +618,12 @@ describe("canSettle", () => {...@@ -582,6 +618,12 @@ describe("canSettle", () => {
582 pending: "user-input",618 pending: "user-input",
583 });619 });
584 expect(canSettle(blocked, { now: NOW })).toBe(false);620 expect(canSettle(blocked, { now: NOW })).toBe(false);
585 expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);621 expect(
622 effectiveSettled(blocked, {
623 now: NOW,
624 autoSettleAfterDays: 3,
625 autoSettleMode: "never",
626 }),
627 ).toBe(false);
586 });628 });
587});629});
packages/client-runtime/src/state/threadSettled.ts+17-25
...@@ -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 } from "@t3tools/contracts";2import type { OrchestrationThreadShell, SidebarAutoSettleMode } from "@t3tools/contracts";
33
4export type ChangeRequestStateLike = "open" | "closed" | "merged";4export type ChangeRequestStateLike = "open" | "closed" | "merged";
55
...@@ -46,26 +46,22 @@ function threadUserActivityAnchorAt(thread: ThreadActivitySource): string {...@@ -46,26 +46,22 @@ 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. Unknown timestamps keep the old always-settle behavior.49 * PR. Missing or malformed timestamps keep the thread active: an automatic
50 * transition must have enough evidence to be predictable.
50 */51 */
51export function changeRequestAutoSettles(52export function changeRequestAutoSettles(
52 changeRequest: ChangeRequestSettleSource | null | undefined,53 changeRequest: ChangeRequestSettleSource | null | undefined,
53 options: {54 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 =59 const terminal = changeRequest.state === "closed" || changeRequest.state === "merged";
60 changeRequest.state === "closed" ||
61 (changeRequest.state === "merged" && options.autoSettleOnMerge !== false);
62 if (!terminal) return false;60 if (!terminal) return false;
63 if (changeRequest.updatedAt == null || options.thread == null) return true;61 if (changeRequest.updatedAt == null || options.thread == null) return false;
64 const updatedAtMs = Date.parse(changeRequest.updatedAt);62 const updatedAtMs = Date.parse(changeRequest.updatedAt);
65 const anchorAtMs = Date.parse(threadUserActivityAnchorAt(options.thread));63 const anchorAtMs = Date.parse(threadUserActivityAnchorAt(options.thread));
66 // Malformed timestamps fall back to settling, matching servers that never64 if (Number.isNaN(updatedAtMs) || Number.isNaN(anchorAtMs)) return false;
67 // report updatedAt.
68 if (Number.isNaN(updatedAtMs) || Number.isNaN(anchorAtMs)) return true;
69 return updatedAtMs >= anchorAtMs;65 return updatedAtMs >= anchorAtMs;
70}66}
7167
...@@ -289,11 +285,11 @@ export function threadWokeAt(...@@ -289,11 +285,11 @@ export function threadWokeAt(
289 * queued turn) are checked first and hold a thread active regardless of any285 * queued turn) are checked first and hold a thread active regardless of any
290 * override. Past the blockers, the explicit user override (thread.settle /286 * override. Past the blockers, the explicit user override (thread.settle /
291 * thread.unsettle commands, projected into settledOverride + settledAt)287 * thread.unsettle commands, projected into settledOverride + settledAt)
292 * wins in both directions; without one, a thread can auto-settle on a288 * wins in both directions. Without one, exactly one selected policy can
293 * merged PR or always on a closed PR (both only while the terminal state is289 * auto-settle: a merged/closed pull request whose terminal timestamp is the
294 * the thread's latest event, see changeRequestAutoSettles), or settles on290 * thread's latest user event (see changeRequestAutoSettles), inactivity past
295 * inactivity past the window.291 * the configured window, or never. An open PR blocks the inactivity path.
296 * An open PR blocks the inactivity path entirely. The server292 * The server
297 * un-settles on real activity (user message, session start, approval/293 * un-settles on real activity (user message, session start, approval/
298 * user-input request), so an override never goes stale silently.294 * user-input request), so an override never goes stale silently.
299 */295 */
...@@ -302,7 +298,7 @@ export function effectiveSettled(...@@ -302,7 +298,7 @@ export function effectiveSettled(
302 options: {298 options: {
303 readonly now: string;299 readonly now: string;
304 readonly autoSettleAfterDays: number | null;300 readonly autoSettleAfterDays: number | null;
305 readonly autoSettleOnMerge?: boolean;301 readonly autoSettleMode: SidebarAutoSettleMode;
306 readonly changeRequest?: ChangeRequestSettleSource | null;302 readonly changeRequest?: ChangeRequestSettleSource | null;
307 },303 },
308): boolean {304): boolean {
...@@ -329,18 +325,14 @@ export function effectiveSettled(...@@ -329,18 +325,14 @@ export function effectiveSettled(
329 // "active" is the explicit keep-active pin: it suppresses auto-settle325 // "active" is the explicit keep-active pin: it suppresses auto-settle
330 // until real activity clears it server-side.326 // until real activity clears it server-side.
331 if (shell.settledOverride === "active") return false;327 if (shell.settledOverride === "active") return false;
332 if (328 if (options.autoSettleMode === "never") return false;
333 changeRequestAutoSettles(options.changeRequest, {329 if (options.autoSettleMode === "change-request") {
334 autoSettleOnMerge: options.autoSettleOnMerge,330 return changeRequestAutoSettles(options.changeRequest, { thread: shell });
335 thread: shell,
336 })
337 ) {
338 return true;
339 }331 }
340 // An open PR is unfinished business regardless of how long the thread has332 // An open PR is unfinished business regardless of how long the thread has
341 // been quiet: review can take days, and hiding the thread would bury the333 // been quiet: review can take days, and hiding the thread would bury the
342 // work waiting on it. A configured merge, a close, or an explicit user334 // work waiting on it. Only an explicit user settle resolves it while the
343 // settle resolves it.335 // inactivity policy is selected.
344 if (options.changeRequest?.state === "open") return false;336 if (options.changeRequest?.state === "open") return false;
345 if (options.autoSettleAfterDays === null) return false;337 if (options.autoSettleAfterDays === null) return false;
346338
packages/contracts/src/settings.test.ts+23-9
...@@ -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 automatic merge and inactivity settling", () => {120 it("defaults to the current sidebar with manual 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.sidebarAutoSettleOnMerge).toBe(true);124 expect(settings.sidebarAutoSettleMode).toBe("never");
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", () => {
...@@ -147,13 +147,27 @@ describe("ClientSettings sidebar", () => {...@@ -147,13 +147,27 @@ describe("ClientSettings sidebar", () => {
147 ).toBeNull();147 ).toBeNull();
148 });148 });
149149
150 it("allows auto-settle on merge to be disabled", () => {150 it("does not preserve the retired merge switch as a hidden automatic path", () => {
151 expect(decodeClientSettings({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge).toBe(151 const decoded = decodeClientSettings({ sidebarAutoSettleOnMerge: true });
152 false,152 expect(decoded.sidebarAutoSettleMode).toBe("never");
153 );153 expect(decoded).not.toHaveProperty("sidebarAutoSettleOnMerge");
154 expect(154 });
155 decodeClientSettingsPatch({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge,155
156 ).toBe(false);156 it.each(["never", "change-request", "inactivity"] as const)(
157 "accepts the %s auto-settle mode",
158 (sidebarAutoSettleMode) => {
159 expect(decodeClientSettings({ sidebarAutoSettleMode }).sidebarAutoSettleMode).toBe(
160 sidebarAutoSettleMode,
161 );
162 expect(decodeClientSettingsPatch({ sidebarAutoSettleMode }).sidebarAutoSettleMode).toBe(
163 sidebarAutoSettleMode,
164 );
165 },
166 );
167
168 it("rejects unsupported auto-settle modes", () => {
169 expect(() => decodeClientSettings({ sidebarAutoSettleMode: "always" })).toThrow();
170 expect(() => decodeClientSettingsPatch({ sidebarAutoSettleMode: "always" })).toThrow();
157 });171 });
158172
159 it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => {173 it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => {
packages/contracts/src/settings.ts+10-2
...@@ -65,6 +65,9 @@ export const SidebarAutoSettleAfterDays = Schema.Number.check(...@@ -65,6 +65,9 @@ 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";
68export const MIN_GLASS_OPACITY = 40;71export const MIN_GLASS_OPACITY = 40;
69export const MAX_GLASS_OPACITY = 100;72export const MAX_GLASS_OPACITY = 100;
70export const GlassOpacity = Schema.Int.check(73export const GlassOpacity = Schema.Int.check(
...@@ -234,7 +237,12 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -234,7 +237,12 @@ export const ClientSettingsSchema = Schema.Struct({
234 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(237 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
235 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),238 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
236 ),239 ),
237 sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),240 // A fresh exclusive policy replaces the old independent inactivity and
241 // merge switches. Missing mode means manual-only, so an upgrade cannot
242 // preserve one hidden automatic path after the user disabled another.
243 sidebarAutoSettleMode: SidebarAutoSettleMode.pipe(
244 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_MODE)),
245 ),
238 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(246 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
239 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),247 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
240 ),248 ),
...@@ -942,7 +950,7 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -942,7 +950,7 @@ export const ClientSettingsPatch = Schema.Struct({
942 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),950 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
943 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),951 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
944 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),952 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
945 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),953 sidebarAutoSettleMode: Schema.optionalKey(SidebarAutoSettleMode),
946 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),954 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
947 sidebarProjectGroupingOverrides: Schema.optionalKey(955 sidebarProjectGroupingOverrides: Schema.optionalKey(
948 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),956 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),