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