| author | |
| committer | |
| log | 0bbecfabf189349b16ad2d7e6558a95a98a3b196 |
| tree | ca0e698f5f008180be99e93211b91a45e8e3e252 |
| parent | cb49e5d7201ef6bcb288bf447785bbd374e8644d |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { |
| 51 | import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; | 51 | import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; |
| 52 | import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; | 52 | import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; |
| 53 | import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; | 53 | import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; |
| 54 | import { SettingsAutoSettleRouteScreen } from "./features/settings/SettingsAutoSettleRouteScreen"; | ||
| 54 | import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; | 55 | import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; |
| 55 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; | 56 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; |
| 56 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; | 57 | import { 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 |
| 30 | import type { SavedRemoteConnection } from "../../lib/connection"; | 30 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 31 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 31 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 32 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 32 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 33 | import { resolveMobileAutoSettlePreferences } from "../../persistence/mobile-preferences"; | ||
| 33 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | 34 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 34 | import { useThreadSearch } from "../../state/queries"; | 35 | import { useThreadSearch } from "../../state/queries"; |
| 35 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | 36 | import { 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 @@ | ||
| 1 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useNavigation } from "@react-navigation/native"; | ||
| 3 | import { | ||
| 4 | MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | ||
| 5 | MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | ||
| 6 | type SidebarAutoSettleMode, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 9 | import { Platform, Pressable, ScrollView, View } from "react-native"; | ||
| 10 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 11 | |||
| 12 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | ||
| 13 | import { AppText as Text } from "../../components/AppText"; | ||
| 14 | import { SymbolView } from "../../components/AppSymbol"; | ||
| 15 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | ||
| 16 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | ||
| 17 | import { resolveMobileAutoSettlePreferences } from "../../persistence/mobile-preferences"; | ||
| 18 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | ||
| 19 | import { SettingsSection } from "./components/SettingsSection"; | ||
| 20 | |||
| 21 | const 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 | |||
| 43 | export 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 | |||
| 49 | export 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"; |
| 4 | import * as Notifications from "expo-notifications"; | 4 | import * as Notifications from "expo-notifications"; |
| 5 | import { useNavigation } from "@react-navigation/native"; | 5 | import { useNavigation } from "@react-navigation/native"; |
| 6 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 6 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 7 | import { resolveMobileAutoSettlePreferences } from "../../persistence/mobile-preferences"; | ||
| 7 | import { SymbolView } from "../../components/AppSymbol"; | 8 | import { SymbolView } from "../../components/AppSymbol"; |
| 8 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 9 | import { AsyncResult } from "effect/unstable/reactivity"; | 10 | import { 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 |
| 45 | import { SettingsRow } from "./components/SettingsRow"; | 46 | import { SettingsRow } from "./components/SettingsRow"; |
| 46 | import { SettingsSection } from "./components/SettingsSection"; | 47 | import { SettingsSection } from "./components/SettingsSection"; |
| 47 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; | 48 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; |
| 49 | import { mobileAutoSettleModeLabel } from "./SettingsAutoSettleRouteScreen"; | ||
| 48 | import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; | 50 | import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; |
| 49 | 51 | ||
| 50 | type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; | 52 | type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; |
| ... | @@ -528,19 +530,18 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -528,19 +530,18 @@ function ConfiguredSettingsRouteScreen() { |
| 528 | 530 | ||
| 529 | function GeneralSettingsSection() { | 531 | function 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; | ||
| 535 | 536 | ||
| 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 | <SettingsSwitchRow | 540 | <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"; |
| 26 | import { SymbolView } from "../../components/AppSymbol"; | 26 | import { SymbolView } from "../../components/AppSymbol"; |
| 27 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 27 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 28 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 28 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 29 | import { resolveMobileAutoSettlePreferences } from "../../persistence/mobile-preferences"; | ||
| 29 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; | 30 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; |
| 30 | import { useProjects, useThreadShells } from "../../state/entities"; | 31 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 31 | import { mobilePreferencesAtom } from "../../state/preferences"; | 32 | import { 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 | }); |
| 369 | 371 | ||
| ... | @@ -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 | }); |
| 373 | 375 | ||
| 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 | }); |
| 386 | 388 | ||
| ... | @@ -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 | }); |
| 455 | 460 | ||
| ... | @@ -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 | }); |
| 483 | 489 | ||
| ... | @@ -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 | }); |
| 491 | 497 | ||
| 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 | }); |
| 506 | 514 |
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"; |
| 19 | import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts"; | 19 | import type { |
| 20 | EnvironmentId, | ||
| 21 | ProjectId, | ||
| 22 | SidebarAutoSettleMode, | ||
| 23 | ThreadLinkedPullRequest, | ||
| 24 | } from "@t3tools/contracts"; | ||
| 20 | 25 | ||
| 21 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 26 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 22 | 27 | ||
| ... | @@ -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.projectRefs | 400 | 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"; |
| 103 | import { resolveMobileAutoSettlePreferences } from "../persistence/mobile-preferences"; | ||
| 103 | import { toStableSavedRemoteConnection } from "./connection"; | 104 | import { toStableSavedRemoteConnection } from "./connection"; |
| 104 | 105 | ||
| 105 | const managedConnection = { | 106 | const 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 | }); |
| 204 | 205 | ||
| 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"; |
| 5 | import * as Ref from "effect/Ref"; | 5 | import * as Ref from "effect/Ref"; |
| 6 | import * as Schema from "effect/Schema"; | 6 | import * as Schema from "effect/Schema"; |
| 7 | import * as Semaphore from "effect/Semaphore"; | 7 | import * as Semaphore from "effect/Semaphore"; |
| 8 | import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; | 8 | import { |
| 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"; | ||
| 9 | import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; | 17 | import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; |
| 10 | 18 | ||
| 11 | import * as MobileDatabase from "./mobile-database"; | 19 | import * 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 has | 45 | * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has |
| 37 | * no client-settings sync, so the legacy grouped thread list is opted into | 46 | * 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 | } |
| 187 | 209 | ||
| 210 | export 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 | |||
| 188 | export const make = Effect.fn("MobilePreferencesStore.make")(function* () { | 220 | export 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.steps | 4394 | ? 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"; |
| 33 | import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | 33 | import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 34 | import type { TimestampFormat } from "@t3tools/contracts/settings"; | 34 | import type { SidebarAutoSettleMode, TimestampFormat } from "@t3tools/contracts/settings"; |
| 35 | import { | 35 | import { |
| 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 snoozed | 720 | // 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 for | 844 | // 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, and | 845 | // 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.capabilities | 3784 | serverConfigs.get(thread.environmentId)?.environment.capabilities |
| 3788 | .threadSettlement === true | 3785 | .threadSettlement === true |
| 3789 | } | 3786 | } |
| 3790 | autoSettleOnMerge={autoSettleOnMerge} | 3787 | autoSettleMode={autoSettleMode} |
| 3791 | snoozeSupported={ | 3788 | snoozeSupported={ |
| 3792 | serverConfigs.get(thread.environmentId)?.environment.capabilities | 3789 | serverConfigs.get(thread.environmentId)?.environment.capabilities |
| 3793 | .threadSnooze === true | 3790 | .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 | } |
| 52 | 53 | ||
| ... | @@ -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 | }); |
| 154 | 155 | ||
| 156 | describe("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 | |||
| 155 | describe("formatDiagnosticsDescription", () => { | 174 | describe("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 | } |
| 33 | 33 | ||
| 34 | export 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 | |||
| 34 | const LAST_ENABLED_PROJECT_GROUPING_MODE_KEY = "t3code:last-enabled-project-grouping-mode"; | 43 | const LAST_ENABLED_PROJECT_GROUPING_MODE_KEY = "t3code:last-enabled-project-grouping-mode"; |
| 35 | 44 | ||
| 36 | export function readLastEnabledProjectGroupingMode(): SidebarProjectGroupingMode { | 45 | export 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"; |
| 13 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | 14 | import { 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); | ||
| 474 | 477 | ||
| 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.sidebarProjectGroupingMode | 498 | 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.diffIgnoreWhitespace | 504 | ...(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 | } |
| 1686 | 1682 | ||
| 1687 | const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3; | 1683 | const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3; |
| 1684 | const 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 | }; | ||
| 1688 | 1689 | ||
| 1689 | function AutoSettleDaysInput({ | 1690 | function AutoSettleDaysInput({ |
| 1690 | value, | 1691 | value, |
| ... | @@ -1946,67 +1947,59 @@ export function GeneralSettingsPanel() { | ... | @@ -1946,67 +1947,59 @@ export function GeneralSettingsPanel() { |
| 1946 | /> | 1947 | /> |
| 1947 | 1948 | ||
| 1948 | <SettingsRow | 1949 | <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 | <SettingResetButton | 1954 | <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 | ) : null | 1963 | ) : null |
| 1963 | } | 1964 | } |
| 1964 | control={ | 1965 | control={ |
| 1965 | <Switch | 1966 | <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 | ) : null | 1978 | > |
| 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 | <Switch | 1981 | </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 | <SettingsRow | 1997 | <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 | <AutoSettleDaysInput | 2001 | <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 of | 4 | `mod+shift+p` pins or unpins the thread you have open. Pinned threads are shown independently of |
| 5 | their project, including when you connect to more than one environment. | 5 | their project, including when you connect to more than one environment. |
| 6 | 6 | ||
| 7 | Pinned threads still move to **Settled** when they become inactive. They also move when their pull | 7 | Pinned threads follow the auto-settle policy in **Settings → General**. Choose exactly one policy: |
| 8 | request merges if **Auto-settle merged threads** is enabled. | 8 | settle when a pull request merges or closes, settle after a configurable period of inactivity, or |
| 9 | never auto-settle. **Never** leaves every automatic path off, so threads settle only when you choose | ||
| 10 | **Settle**. | ||
| 9 | 11 | ||
| 10 | When you un-settle a thread, it returns to the top of the active list so you can find it right | 12 | When you un-settle a thread, it returns to the top of the active list so you can find it right |
| 11 | away. Its timestamps do not change. Other threads keep their positions. | 13 | away. Its timestamps do not change. Other threads keep their positions. |
| 12 | 14 | ||
| 13 | Right-click a pull request link in a thread and choose **Link to thread** to show that pull request | 15 | Right-click a pull request link in a thread and choose **Link to thread** to show that pull request |
| 14 | in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged | 16 | in the sidebar. The thread settles when the linked pull request merges or closes only when the |
| 15 | threads** is enabled. Right-click the same link and choose **Unlink from thread** to remove it. | 17 | pull-request policy is selected. Right-click the same link and choose **Unlink from thread** to |
| 18 | remove it. If T3 Code cannot determine when a pull request entered its terminal state, it keeps the | ||
| 19 | thread active. | ||
| 16 | 20 | ||
| 17 | On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu | 21 | On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu |
| 18 | and choose **Move up** or **Move down**. The order is stored by the server and appears on your | 22 | and 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"; |
| 21 | const STALE = "2026-04-06T23:59:59.999Z"; | 21 | const STALE = "2026-04-06T23:59:59.999Z"; |
| 22 | 22 | ||
| 23 | describe("changeRequestAutoSettles", () => { | 23 | describe("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 | }; |
| 42 | 30 | ||
| 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 | }); |
| 107 | 108 | ||
| 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 | }); |
| 124 | 125 | ||
| ... | @@ -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) AND | 219 | activityAt, |
| 219 | // the override says settled, or (with no override) a merged PR | 220 | running, |
| 220 | // or staleness auto-settles. The "active" pin suppresses both | 221 | 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 | ); |
| 235 | 237 | ||
| 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 === undefined | 256 | changeRequestState === undefined |
| 247 | ? {} | 257 | ? {} |
| 248 | : { changeRequest: { state: changeRequestState as ChangeRequestStateLike } }; | 258 | : { |
| 259 | changeRequest: { | ||
| 260 | state: changeRequestState as ChangeRequestStateLike, | ||
| 261 | updatedAt: NOW, | ||
| 262 | }, | ||
| 263 | }; | ||
| 249 | 264 | ||
| 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 | }); |
| 307 | 327 | ||
| 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 | }), |
| 318 | 338 | ).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 | }); |
| 328 | 341 | ||
| 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 }); |
| 428 | 446 | ||
| 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 | }); |
| 433 | 463 | ||
| ... | @@ -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); | ||
| 560 | 595 | ||
| 561 | // A message NEWER than settledAt is genuinely new work: still blocked | 596 | // 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. |
| 2 | import type { OrchestrationThreadShell } from "@t3tools/contracts"; | 2 | import type { OrchestrationThreadShell, SidebarAutoSettleMode } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | export type ChangeRequestStateLike = "open" | "closed" | "merged"; | 4 | export type ChangeRequestStateLike = "open" | "closed" | "merged"; |
| 5 | 5 | ||
| ... | @@ -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 older | 46 | * 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 a | 47 | * than the user's latest engagement was already adjudicated — re-engaging a |
| 48 | * thread whose PR merged is the user saying the conversation outlived the | 48 | * 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 | */ |
| 51 | export function changeRequestAutoSettles( | 52 | export 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 never | 64 | 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 | } |
| 71 | 67 | ||
| ... | @@ -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 any | 285 | * 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 a | 288 | * 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 is | 289 | * auto-settle: a merged/closed pull request whose terminal timestamp is the |
| 294 | * the thread's latest event, see changeRequestAutoSettles), or settles on | 290 | * 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 server | 292 | * 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-settle | 325 | // "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 has | 332 | // 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 the | 333 | // been quiet: review can take days, and hiding the thread would bury the |
| 342 | // work waiting on it. A configured merge, a close, or an explicit user | 334 | // 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; |
| 346 | 338 |
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 | }); |
| 118 | 118 | ||
| 119 | describe("ClientSettings sidebar", () => { | 119 | describe("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 | }); |
| 126 | 126 | ||
| 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 | }); |
| 149 | 149 | ||
| 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 | }); |
| 158 | 172 | ||
| 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 | ); |
| 66 | export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type; | 66 | export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type; |
| 67 | export const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; | 67 | export const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; |
| 68 | export const SidebarAutoSettleMode = Schema.Literals(["never", "change-request", "inactivity"]); | ||
| 69 | export type SidebarAutoSettleMode = typeof SidebarAutoSettleMode.Type; | ||
| 70 | export const DEFAULT_SIDEBAR_AUTO_SETTLE_MODE: SidebarAutoSettleMode = "never"; | ||
| 68 | export const MIN_GLASS_OPACITY = 40; | 71 | export const MIN_GLASS_OPACITY = 40; |
| 69 | export const MAX_GLASS_OPACITY = 100; | 72 | export const MAX_GLASS_OPACITY = 100; |
| 70 | export const GlassOpacity = Schema.Int.check( | 73 | export 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), |