| 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 | 39 | showSkillsInSlashMenu: false, |
| 40 | 40 | providerModelPreferences: {}, |
| 41 | 41 | sidebarAutoSettleAfterDays: 3, |
| 42 | sidebarAutoSettleOnMerge: true, | |
| 42 | sidebarAutoSettleMode: "change-request", | |
| 43 | 43 | sidebarProjectGroupingMode: "repository_path", |
| 44 | 44 | sidebarProjectGroupingOverrides: { |
| 45 | 45 | "environment-1:/tmp/project-a": "separate", |
apps/mobile/src/Stack.tsx+8| ... | ... | @@ -51,6 +51,7 @@ 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"; | |
| 54 | 55 | import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; |
| 55 | 56 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; |
| 56 | 57 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| ... | ... | @@ -177,6 +178,13 @@ const SettingsContentStack = createNativeStackNavigator({ |
| 177 | 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 | 188 | SettingsProjectGrouping: createNativeStackScreen({ |
| 181 | 189 | screen: SettingsProjectGroupingRouteScreen, |
| 182 | 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 | 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"; | |
| 33 | 34 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 34 | 35 | import { useThreadSearch } from "../../state/queries"; |
| 35 | 36 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| ... | ... | @@ -208,9 +209,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 208 | 209 | >(() => new Map()); |
| 209 | 210 | const preferencesResult = useAtomValue(mobilePreferencesAtom); |
| 210 | 211 | const threadListV2Enabled = useThreadListV2Enabled(); |
| 211 | const autoSettleOnMerge = | |
| 212 | !AsyncResult.isSuccess(preferencesResult) || | |
| 213 | preferencesResult.value.autoSettleOnMerge !== false; | |
| 212 | const autoSettlePreferences = resolveMobileAutoSettlePreferences( | |
| 213 | AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {}, | |
| 214 | ); | |
| 214 | 215 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| 215 | 216 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 216 | 217 | const listRef = useRef<LegendListRef | null>(null); |
| ... | ... | @@ -678,7 +679,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 678 | 679 | searchQuery: props.searchQuery, |
| 679 | 680 | matchedThreadKeys, |
| 680 | 681 | changeRequestByKey, |
| 681 | autoSettleOnMerge, | |
| 682 | autoSettleAfterDays: autoSettlePreferences.autoSettleAfterDays, | |
| 683 | autoSettleMode: autoSettlePreferences.autoSettleMode, | |
| 682 | 684 | settlementEnvironmentIds, |
| 683 | 685 | snoozeEnvironmentIds, |
| 684 | 686 | settledLimit: settledVisibleCount, |
| ... | ... | @@ -690,7 +692,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 690 | 692 | }); |
| 691 | 693 | }, [ |
| 692 | 694 | changeRequestByKey, |
| 693 | autoSettleOnMerge, | |
| 695 | autoSettlePreferences.autoSettleAfterDays, | |
| 696 | autoSettlePreferences.autoSettleMode, | |
| 694 | 697 | nowMinute, |
| 695 | 698 | snoozeWakeTick, |
| 696 | 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 | 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"; | |
| 7 | 8 | import { SymbolView } from "../../components/AppSymbol"; |
| 8 | 9 | import * as Effect from "effect/Effect"; |
| 9 | 10 | import { AsyncResult } from "effect/unstable/reactivity"; |
| ... | ... | @@ -45,6 +46,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re |
| 45 | 46 | import { SettingsRow } from "./components/SettingsRow"; |
| 46 | 47 | import { SettingsSection } from "./components/SettingsSection"; |
| 47 | 48 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; |
| 49 | import { mobileAutoSettleModeLabel } from "./SettingsAutoSettleRouteScreen"; | |
| 48 | 50 | import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; |
| 49 | 51 | |
| 50 | 52 | type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; |
| ... | ... | @@ -528,19 +530,18 @@ function ConfiguredSettingsRouteScreen() { |
| 528 | 530 | |
| 529 | 531 | function GeneralSettingsSection() { |
| 530 | 532 | const preferencesResult = useAtomValue(mobilePreferencesAtom); |
| 531 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | |
| 532 | const autoSettleOnMerge = | |
| 533 | !AsyncResult.isSuccess(preferencesResult) || | |
| 534 | preferencesResult.value.autoSettleOnMerge !== false; | |
| 533 | const autoSettlePreferences = resolveMobileAutoSettlePreferences( | |
| 534 | AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {}, | |
| 535 | ); | |
| 535 | 536 | |
| 536 | 537 | return ( |
| 537 | 538 | <SettingsSection title="General"> |
| 538 | 539 | <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" /> |
| 539 | <SettingsSwitchRow | |
| 540 | <SettingsRow | |
| 540 | 541 | icon="arrow.triangle.branch" |
| 541 | label="Auto-settle merged threads" | |
| 542 | value={autoSettleOnMerge} | |
| 543 | onValueChange={(value) => savePreferences({ autoSettleOnMerge: value })} | |
| 542 | label="Thread Settling" | |
| 543 | value={mobileAutoSettleModeLabel(autoSettlePreferences.autoSettleMode)} | |
| 544 | target="SettingsAutoSettle" | |
| 544 | 545 | /> |
| 545 | 546 | <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" /> |
| 546 | 547 | </SettingsSection> |
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+1| ... | ... | @@ -2,6 +2,7 @@ export type SettingsSheetTarget = |
| 2 | 2 | | "SettingsEnvironments" |
| 3 | 3 | | "SettingsArchive" |
| 4 | 4 | | "SettingsAppearance" |
| 5 | | "SettingsAutoSettle" | |
| 5 | 6 | | "SettingsProjectGrouping" |
| 6 | 7 | | "SettingsClientStorage" |
| 7 | 8 | | "SettingsUsage"; |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+8-5| ... | ... | @@ -26,6 +26,7 @@ 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"; | |
| 29 | 30 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; |
| 30 | 31 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 31 | 32 | import { mobilePreferencesAtom } from "../../state/preferences"; |
| ... | ... | @@ -165,9 +166,9 @@ function ThreadNavigationSidebarPane( |
| 165 | 166 | } = useThreadListActions(); |
| 166 | 167 | const threadListV2Enabled = useThreadListV2Enabled(); |
| 167 | 168 | const preferencesResult = useAtomValue(mobilePreferencesAtom); |
| 168 | const autoSettleOnMerge = | |
| 169 | !AsyncResult.isSuccess(preferencesResult) || | |
| 170 | preferencesResult.value.autoSettleOnMerge !== false; | |
| 169 | const autoSettlePreferences = resolveMobileAutoSettlePreferences( | |
| 170 | AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {}, | |
| 171 | ); | |
| 171 | 172 | const pendingTasks = usePendingNewTasks(); |
| 172 | 173 | const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); |
| 173 | 174 | const environments = useMemo( |
| ... | ... | @@ -510,7 +511,8 @@ function ThreadNavigationSidebarPane( |
| 510 | 511 | searchQuery: props.searchQuery, |
| 511 | 512 | matchedThreadKeys, |
| 512 | 513 | changeRequestByKey, |
| 513 | autoSettleOnMerge, | |
| 514 | autoSettleAfterDays: autoSettlePreferences.autoSettleAfterDays, | |
| 515 | autoSettleMode: autoSettlePreferences.autoSettleMode, | |
| 514 | 516 | settlementEnvironmentIds, |
| 515 | 517 | snoozeEnvironmentIds, |
| 516 | 518 | settledLimit: settledVisibleCount, |
| ... | ... | @@ -522,7 +524,8 @@ function ThreadNavigationSidebarPane( |
| 522 | 524 | }); |
| 523 | 525 | }, [ |
| 524 | 526 | changeRequestByKey, |
| 525 | autoSettleOnMerge, | |
| 527 | autoSettlePreferences.autoSettleAfterDays, | |
| 528 | autoSettlePreferences.autoSettleMode, | |
| 526 | 529 | nowMinute, |
| 527 | 530 | snoozeWakeTick, |
| 528 | 531 | snoozedShelfExpanded, |
apps/mobile/src/features/threads/threadListV2.test.ts+15-7| ... | ... | @@ -360,10 +360,12 @@ describe("buildThreadListV2Items", () => { |
| 360 | 360 | `${environmentId}:${thread.id}`, |
| 361 | 361 | { |
| 362 | 362 | state: "merged" as const, |
| 363 | updatedAt: NOW, | |
| 363 | 364 | linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]', |
| 364 | 365 | }, |
| 365 | 366 | ], |
| 366 | 367 | ]), |
| 368 | autoSettleMode: "change-request", | |
| 367 | 369 | now: NOW, |
| 368 | 370 | }); |
| 369 | 371 | |
| ... | ... | @@ -371,16 +373,16 @@ describe("buildThreadListV2Items", () => { |
| 371 | 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 | 377 | const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" }); |
| 376 | 378 | const layout = buildThreadListV2Items({ |
| 377 | 379 | threads: [merged], |
| 378 | 380 | environmentId: null, |
| 379 | 381 | searchQuery: "", |
| 380 | 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 | 386 | now: NOW, |
| 385 | 387 | }); |
| 386 | 388 | |
| ... | ... | @@ -449,7 +451,10 @@ describe("buildThreadListV2Items", () => { |
| 449 | 451 | threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged], |
| 450 | 452 | environmentId: null, |
| 451 | 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 | 458 | now: NOW, |
| 454 | 459 | }); |
| 455 | 460 | |
| ... | ... | @@ -478,6 +483,7 @@ describe("buildThreadListV2Items", () => { |
| 478 | 483 | threads: [inactive], |
| 479 | 484 | environmentId: null, |
| 480 | 485 | searchQuery: "", |
| 486 | autoSettleMode: "inactivity", | |
| 481 | 487 | now: NOW, |
| 482 | 488 | }); |
| 483 | 489 | |
| ... | ... | @@ -489,7 +495,7 @@ describe("buildThreadListV2Items", () => { |
| 489 | 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 | 499 | const merged = makeThread({ |
| 494 | 500 | id: ThreadId.make("pinned-merged"), |
| 495 | 501 | title: "Pinned merged pull request", |
| ... | ... | @@ -499,8 +505,10 @@ describe("buildThreadListV2Items", () => { |
| 499 | 505 | threads: [merged], |
| 500 | 506 | environmentId: null, |
| 501 | 507 | searchQuery: "", |
| 502 | changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), | |
| 503 | autoSettleOnMerge: false, | |
| 508 | changeRequestByKey: new Map([ | |
| 509 | [`${environmentId}:${merged.id}`, { state: "merged", updatedAt: NOW }], | |
| 510 | ]), | |
| 511 | autoSettleMode: "never", | |
| 504 | 512 | now: NOW, |
| 505 | 513 | }); |
| 506 | 514 |
apps/mobile/src/features/threads/threadListV2.ts+9-4| ... | ... | @@ -16,7 +16,12 @@ import { |
| 16 | 16 | activeThreadAnchorTimestampMs, |
| 17 | 17 | sortPinnedThreadsByOrderKey, |
| 18 | 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 | 26 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 22 | 27 | |
| ... | ... | @@ -369,7 +374,7 @@ export function buildThreadListV2Items(input: { |
| 369 | 374 | contract as settlementEnvironmentIds. */ |
| 370 | 375 | readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>; |
| 371 | 376 | readonly autoSettleAfterDays?: number; |
| 372 | readonly autoSettleOnMerge?: boolean; | |
| 377 | readonly autoSettleMode?: SidebarAutoSettleMode; | |
| 373 | 378 | /** Max settled rows to render; the rest are counted, not built. */ |
| 374 | 379 | readonly settledLimit?: number; |
| 375 | 380 | /** Injectable for tests; defaults to now. */ |
| ... | ... | @@ -390,7 +395,7 @@ export function buildThreadListV2Items(input: { |
| 390 | 395 | const now = input.now ?? new Date().toISOString(); |
| 391 | 396 | const snoozeNow = input.snoozeNow ?? now; |
| 392 | 397 | const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; |
| 393 | const autoSettleOnMerge = input.autoSettleOnMerge ?? true; | |
| 398 | const autoSettleMode = input.autoSettleMode ?? "never"; | |
| 394 | 399 | const query = input.searchQuery.trim().toLocaleLowerCase(); |
| 395 | 400 | const projectKeys = input.projectRefs |
| 396 | 401 | ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) |
| ... | ... | @@ -447,7 +452,7 @@ export function buildThreadListV2Items(input: { |
| 447 | 452 | effectiveSettled(thread, { |
| 448 | 453 | now, |
| 449 | 454 | autoSettleAfterDays, |
| 450 | autoSettleOnMerge, | |
| 455 | autoSettleMode, | |
| 451 | 456 | changeRequest, |
| 452 | 457 | }) |
| 453 | 458 | ) { |
apps/mobile/src/lib/storage.test.ts+29| ... | ... | @@ -100,6 +100,7 @@ import { |
| 100 | 100 | saveConnection, |
| 101 | 101 | savePreferencesPatch, |
| 102 | 102 | } from "../persistence/imperative"; |
| 103 | import { resolveMobileAutoSettlePreferences } from "../persistence/mobile-preferences"; | |
| 103 | 104 | import { toStableSavedRemoteConnection } from "./connection"; |
| 104 | 105 | |
| 105 | 106 | const managedConnection = { |
| ... | ... | @@ -202,6 +203,34 @@ describe("mobile connection storage", () => { |
| 202 | 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 | 234 | it("falls back to secure storage when SQLite cannot save preferences", async () => { |
| 206 | 235 | mocks.setDatabaseFailures(true, true); |
| 207 | 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 | 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 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 | 17 | import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; |
| 10 | 18 | |
| 11 | 19 | import * as MobileDatabase from "./mobile-database"; |
| ... | ... | @@ -31,7 +39,8 @@ export interface Preferences { |
| 31 | 39 | /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ |
| 32 | 40 | readonly projectGroupingEnabled?: boolean; |
| 33 | 41 | readonly projectGroupingMode?: SidebarProjectGroupingMode; |
| 34 | readonly autoSettleOnMerge?: boolean; | |
| 42 | readonly autoSettleMode?: SidebarAutoSettleMode; | |
| 43 | readonly autoSettleAfterDays?: SidebarAutoSettleAfterDays; | |
| 35 | 44 | /** |
| 36 | 45 | * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has |
| 37 | 46 | * no client-settings sync, so the legacy grouped thread list is opted into |
| ... | ... | @@ -101,7 +110,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 101 | 110 | collapsedProjectGroups?: readonly string[]; |
| 102 | 111 | projectGroupingEnabled?: boolean; |
| 103 | 112 | projectGroupingMode?: SidebarProjectGroupingMode; |
| 104 | autoSettleOnMerge?: boolean; | |
| 113 | autoSettleMode?: SidebarAutoSettleMode; | |
| 114 | autoSettleAfterDays?: SidebarAutoSettleAfterDays; | |
| 105 | 115 | legacyThreadListEnabled?: boolean; |
| 106 | 116 | planModeEnabled?: boolean; |
| 107 | 117 | threadListV2SettledShelfExpanded?: boolean; |
| ... | ... | @@ -167,8 +177,20 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 167 | 177 | ) { |
| 168 | 178 | preferences.projectGroupingMode = parsed.projectGroupingMode; |
| 169 | 179 | } |
| 170 | if (typeof parsed.autoSettleOnMerge === "boolean") { | |
| 171 | preferences.autoSettleOnMerge = parsed.autoSettleOnMerge; | |
| 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; | |
| 172 | 194 | } |
| 173 | 195 | if (typeof parsed.legacyThreadListEnabled === "boolean") { |
| 174 | 196 | preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; |
| ... | ... | @@ -185,6 +207,16 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 185 | 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 | 220 | export const make = Effect.fn("MobilePreferencesStore.make")(function* () { |
| 189 | 221 | const database = yield* MobileDatabase.MobileDatabase; |
| 190 | 222 | const secureStorage = yield* MobileSecureStorage.MobileSecureStorage; |
apps/web/src/components/ChatView.tsx+6-8| ... | ... | @@ -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 autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); | |
| 4397 | const autoSettleMode = useClientSettings((settings) => settings.sidebarAutoSettleMode); | |
| 4398 | 4398 | const linkedPullRequestStatus = useLinkedThreadPullRequest( |
| 4399 | 4399 | activeThreadRef?.environmentId ?? null, |
| 4400 | 4400 | linkedThreadPullRequest, |
| ... | ... | @@ -4465,10 +4465,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 4465 | 4465 | const activeThreadWokeVisible = useMemo(() => { |
| 4466 | 4466 | if (activeThreadWokeAt === null) return false; |
| 4467 | 4467 | if ( |
| 4468 | changeRequestAutoSettles(activeThreadChangeRequest, { | |
| 4469 | autoSettleOnMerge, | |
| 4470 | thread: activeThreadShell, | |
| 4471 | }) | |
| 4468 | autoSettleMode === "change-request" && | |
| 4469 | changeRequestAutoSettles(activeThreadChangeRequest, { thread: activeThreadShell }) | |
| 4472 | 4470 | ) { |
| 4473 | 4471 | return false; |
| 4474 | 4472 | } |
| ... | ... | @@ -4494,21 +4492,21 @@ function ChatViewContent(props: ChatViewProps) { |
| 4494 | 4492 | activeThreadChangeRequest, |
| 4495 | 4493 | activeThreadShell, |
| 4496 | 4494 | activeThreadWokeAt, |
| 4497 | autoSettleOnMerge, | |
| 4495 | autoSettleMode, | |
| 4498 | 4496 | ]); |
| 4499 | 4497 | const activeThreadSettled = useMemo(() => { |
| 4500 | 4498 | if (activeThreadShell === null || !supportsSettlement) return false; |
| 4501 | 4499 | return effectiveSettled(activeThreadShell, { |
| 4502 | 4500 | now: `${nowMinute}:00.000Z`, |
| 4503 | 4501 | autoSettleAfterDays, |
| 4504 | autoSettleOnMerge, | |
| 4502 | autoSettleMode, | |
| 4505 | 4503 | changeRequest: activeThreadChangeRequest, |
| 4506 | 4504 | }); |
| 4507 | 4505 | }, [ |
| 4508 | 4506 | activeThreadChangeRequest, |
| 4509 | 4507 | activeThreadShell, |
| 4510 | 4508 | autoSettleAfterDays, |
| 4511 | autoSettleOnMerge, | |
| 4509 | autoSettleMode, | |
| 4512 | 4510 | changeRequestSnapshotByKey, |
| 4513 | 4511 | nowMinute, |
| 4514 | 4512 | supportsSettlement, |
apps/web/src/components/Sidebar.tsx+7-10| ... | ... | @@ -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 { TimestampFormat } from "@t3tools/contracts/settings"; | |
| 34 | import type { SidebarAutoSettleMode, 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 | autoSettleOnMerge: boolean; | |
| 717 | autoSettleMode: SidebarAutoSettleMode; | |
| 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,10 +839,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 839 | 839 | const isWoke = |
| 840 | 840 | wokeAtDate !== null && |
| 841 | 841 | (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && |
| 842 | !changeRequestAutoSettles(pr, { | |
| 843 | autoSettleOnMerge: props.autoSettleOnMerge, | |
| 844 | thread, | |
| 845 | }); | |
| 842 | !(props.autoSettleMode === "change-request" && changeRequestAutoSettles(pr, { thread })); | |
| 846 | 843 | // In-flight rows (working, or waiting on approval/input) fade as a whole: |
| 847 | 844 | // there is nothing for the user to do yet, so prominence is reserved for |
| 848 | 845 | // rows that need a human — done (unread), read-but-unsettled, failed, and |
| ... | ... | @@ -1740,7 +1737,7 @@ export default function Sidebar() { |
| 1740 | 1737 | const { isMobile, setOpenMobile } = useSidebar(); |
| 1741 | 1738 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 1742 | 1739 | const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); |
| 1743 | const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); | |
| 1740 | const autoSettleMode = useClientSettings((s) => s.sidebarAutoSettleMode); | |
| 1744 | 1741 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); |
| 1745 | 1742 | const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); |
| 1746 | 1743 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); |
| ... | ... | @@ -2125,7 +2122,7 @@ export default function Sidebar() { |
| 2125 | 2122 | effectiveSettled(thread, { |
| 2126 | 2123 | now, |
| 2127 | 2124 | autoSettleAfterDays, |
| 2128 | autoSettleOnMerge, | |
| 2125 | autoSettleMode, | |
| 2129 | 2126 | changeRequest, |
| 2130 | 2127 | }) |
| 2131 | 2128 | ) { |
| ... | ... | @@ -2164,7 +2161,7 @@ export default function Sidebar() { |
| 2164 | 2161 | }; |
| 2165 | 2162 | }, [ |
| 2166 | 2163 | autoSettleAfterDays, |
| 2167 | autoSettleOnMerge, | |
| 2164 | autoSettleMode, | |
| 2168 | 2165 | changeRequestSnapshotByKey, |
| 2169 | 2166 | nowMinute, |
| 2170 | 2167 | scopedProjectKeys, |
| ... | ... | @@ -3787,7 +3784,7 @@ export default function Sidebar() { |
| 3787 | 3784 | serverConfigs.get(thread.environmentId)?.environment.capabilities |
| 3788 | 3785 | .threadSettlement === true |
| 3789 | 3786 | } |
| 3790 | autoSettleOnMerge={autoSettleOnMerge} | |
| 3787 | autoSettleMode={autoSettleMode} | |
| 3791 | 3788 | snoozeSupported={ |
| 3792 | 3789 | serverConfigs.get(thread.environmentId)?.environment.capabilities |
| 3793 | 3790 | .threadSnooze === true |
apps/web/src/components/ThreadStatusIndicators.test.ts+2| ... | ... | @@ -47,6 +47,7 @@ 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", | |
| 50 | 51 | }; |
| 51 | 52 | } |
| 52 | 53 | |
| ... | ... | @@ -545,6 +546,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { |
| 545 | 546 | effectiveSettled(shell, { |
| 546 | 547 | now: "2026-04-10T00:00:00.000Z", |
| 547 | 548 | autoSettleAfterDays: null, |
| 549 | autoSettleMode: "change-request", | |
| 548 | 550 | changeRequest: displayed, |
| 549 | 551 | }), |
| 550 | 552 | ).toBe(true); |
apps/web/src/components/settings/SettingsPanels.logic.test.ts+20-1| ... | ... | @@ -14,9 +14,10 @@ import { |
| 14 | 14 | formatDiagnosticsDescription, |
| 15 | 15 | getChangedBrowserSettingLabels, |
| 16 | 16 | getChangedTypographySettingLabels, |
| 17 | isSamePreviewViewport, | |
| 18 | 17 | hasChangedBackgroundActivitySettings, |
| 18 | hasChangedThreadSettlingSettings, | |
| 19 | 19 | isProjectGroupingEnabled, |
| 20 | isSamePreviewViewport, | |
| 20 | 21 | projectGroupingModeFromToggle, |
| 21 | 22 | resolveBackgroundActivityProfileOption, |
| 22 | 23 | } from "./SettingsPanels.logic"; |
| ... | ... | @@ -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 | 174 | describe("formatDiagnosticsDescription", () => { |
| 156 | 175 | it("collapses trace and metric URLs that share the same OTEL base path", () => { |
| 157 | 176 | expect( |
apps/web/src/components/settings/SettingsPanels.logic.ts+9| ... | ... | @@ -31,6 +31,15 @@ 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 | ||
| 34 | 43 | const LAST_ENABLED_PROJECT_GROUPING_MODE_KEY = "t3code:last-enabled-project-grouping-mode"; |
| 35 | 44 | |
| 36 | 45 | export function readLastEnabledProjectGroupingMode(): SidebarProjectGroupingMode { |
apps/web/src/components/settings/SettingsPanels.tsx+46-53| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | type DesktopUpdateChannel, |
| 9 | 9 | ProviderDriverKind, |
| 10 | 10 | type ScopedThreadRef, |
| 11 | type SidebarAutoSettleMode, | |
| 11 | 12 | type SidebarProjectGroupingMode, |
| 12 | 13 | } from "@t3tools/contracts"; |
| 13 | 14 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| ... | ... | @@ -130,6 +131,7 @@ import { |
| 130 | 131 | normalizeIntervalSeconds, |
| 131 | 132 | PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, |
| 132 | 133 | hasChangedBackgroundActivitySettings, |
| 134 | hasChangedThreadSettlingSettings, | |
| 133 | 135 | isProjectGroupingEnabled, |
| 134 | 136 | projectGroupingModeFromToggle, |
| 135 | 137 | readLastEnabledProjectGroupingMode, |
| ... | ... | @@ -471,6 +473,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 471 | 473 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, |
| 472 | 474 | ); |
| 473 | 475 | const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings); |
| 476 | const isThreadSettlingDirty = hasChangedThreadSettlingSettings(settings); | |
| 474 | 477 | |
| 475 | 478 | const changedSettingLabels = useMemo( |
| 476 | 479 | () => [ |
| ... | ... | @@ -495,13 +498,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 495 | 498 | DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode |
| 496 | 499 | ? ["Project Grouping"] |
| 497 | 500 | : []), |
| 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 | : []), | |
| 501 | ...(isThreadSettlingDirty ? ["Thread settling"] : []), | |
| 505 | 502 | ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), |
| 506 | 503 | ...getChangedTypographySettingLabels(settings), |
| 507 | 504 | ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace |
| ... | ... | @@ -547,6 +544,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 547 | 544 | [ |
| 548 | 545 | isTextGenerationModelDirty, |
| 549 | 546 | isBackgroundActivityDirty, |
| 547 | isThreadSettlingDirty, | |
| 550 | 548 | settings.browserDefaultViewport, |
| 551 | 549 | settings.browserDefaultZoomFactor, |
| 552 | 550 | settings.browserDefaultAppearance, |
| ... | ... | @@ -572,8 +570,6 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 572 | 570 | settings.glassOpacity, |
| 573 | 571 | settings.enableLegacyTokenStreaming, |
| 574 | 572 | settings.enableProviderUpdateChecks, |
| 575 | settings.sidebarAutoSettleAfterDays, | |
| 576 | settings.sidebarAutoSettleOnMerge, | |
| 577 | 573 | settings.sidebarProjectGroupingMode, |
| 578 | 574 | settings.sidebarThreadPreviewCount, |
| 579 | 575 | settings.showSkillsInSlashMenu, |
| ... | ... | @@ -658,7 +654,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 658 | 654 | sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, |
| 659 | 655 | sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, |
| 660 | 656 | sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, |
| 661 | sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, | |
| 657 | sidebarAutoSettleMode: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleMode, | |
| 662 | 658 | enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, |
| 663 | 659 | enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, |
| 664 | 660 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, |
| ... | ... | @@ -1685,6 +1681,11 @@ function FontFamilySettingsRow({ |
| 1685 | 1681 | } |
| 1686 | 1682 | |
| 1687 | 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 | 1690 | function AutoSettleDaysInput({ |
| 1690 | 1691 | value, |
| ... | ... | @@ -1946,67 +1947,59 @@ export function GeneralSettingsPanel() { |
| 1946 | 1947 | /> |
| 1947 | 1948 | |
| 1948 | 1949 | <SettingsRow |
| 1949 | {...searchableSetting("auto-settle-merged-threads")} | |
| 1950 | description="Settle a thread when its pull request merges. Closed pull requests still settle automatically." | |
| 1950 | {...searchableSetting("thread-settling")} | |
| 1951 | description="Choose the only event allowed to settle a thread automatically. Manual settling always remains available." | |
| 1951 | 1952 | resetAction={ |
| 1952 | settings.sidebarAutoSettleOnMerge !== | |
| 1953 | DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? ( | |
| 1953 | hasChangedThreadSettlingSettings(settings) ? ( | |
| 1954 | 1954 | <SettingResetButton |
| 1955 | label="auto-settle on merge" | |
| 1955 | label="thread settling" | |
| 1956 | 1956 | onClick={() => |
| 1957 | 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 | 1963 | ) : null |
| 1963 | 1964 | } |
| 1964 | 1965 | control={ |
| 1965 | <Switch | |
| 1966 | checked={settings.sidebarAutoSettleOnMerge} | |
| 1967 | onCheckedChange={(checked) => | |
| 1968 | updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) | |
| 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={() => | |
| 1966 | <Select | |
| 1967 | value={settings.sidebarAutoSettleMode} | |
| 1968 | onValueChange={(value) => { | |
| 1969 | if (value === "never" || value === "change-request" || value === "inactivity") { | |
| 1984 | 1970 | updateSettings({ |
| 1985 | sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, | |
| 1986 | }) | |
| 1971 | sidebarAutoSettleMode: value, | |
| 1972 | ...(value === "inactivity" && settings.sidebarAutoSettleAfterDays === null | |
| 1973 | ? { sidebarAutoSettleAfterDays: AUTO_SETTLE_DEFAULT_DAYS } | |
| 1974 | : {}), | |
| 1975 | }); | |
| 1987 | 1976 | } |
| 1988 | /> | |
| 1989 | ) : null | |
| 1990 | } | |
| 1991 | control={ | |
| 1992 | <Switch | |
| 1993 | checked={settings.sidebarAutoSettleAfterDays !== null} | |
| 1994 | onCheckedChange={(checked) => | |
| 1995 | updateSettings({ | |
| 1996 | sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, | |
| 1997 | }) | |
| 1998 | } | |
| 1999 | aria-label="Auto-settle inactive threads" | |
| 2000 | /> | |
| 1977 | }} | |
| 1978 | > | |
| 1979 | <SelectTrigger className="w-full sm:w-64" aria-label="Thread settling"> | |
| 1980 | <SelectValue>{AUTO_SETTLE_MODE_LABELS[settings.sidebarAutoSettleMode]}</SelectValue> | |
| 1981 | </SelectTrigger> | |
| 1982 | <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 1983 | <SelectItem hideIndicator value="never"> | |
| 1984 | {AUTO_SETTLE_MODE_LABELS.never} | |
| 1985 | </SelectItem> | |
| 1986 | <SelectItem hideIndicator value="change-request"> | |
| 1987 | {AUTO_SETTLE_MODE_LABELS["change-request"]} | |
| 1988 | </SelectItem> | |
| 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 | 1997 | <SettingsRow |
| 2005 | 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 | 2000 | control={ |
| 2008 | 2001 | <AutoSettleDaysInput |
| 2009 | value={settings.sidebarAutoSettleAfterDays} | |
| 2002 | value={settings.sidebarAutoSettleAfterDays ?? AUTO_SETTLE_DEFAULT_DAYS} | |
| 2010 | 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 | 112 | to: "/settings/general", |
| 113 | 113 | }, |
| 114 | 114 | { |
| 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", | |
| 115 | id: "thread-settling", | |
| 116 | title: "Thread settling", | |
| 122 | 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 | 81 | const handleNewThread = useNewThreadHandler(); |
| 82 | 82 | const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); |
| 83 | 83 | const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); |
| 84 | const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); | |
| 84 | const autoSettleMode = useClientSettings((s) => s.sidebarAutoSettleMode); | |
| 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 | autoSettleOnMerge, | |
| 138 | autoSettleMode, | |
| 139 | 139 | changeRequest, |
| 140 | 140 | }), |
| 141 | 141 | isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), |
| ... | ... | @@ -311,7 +311,7 @@ export function useThreadActionMenu(input: { |
| 311 | 311 | [ |
| 312 | 312 | archiveThread, |
| 313 | 313 | autoSettleAfterDays, |
| 314 | autoSettleOnMerge, | |
| 314 | autoSettleMode, | |
| 315 | 315 | changeRequest, |
| 316 | 316 | confirmThreadArchive, |
| 317 | 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 | 4 | `mod+shift+p` pins or unpins the thread you have open. Pinned threads are shown independently of |
| 5 | 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 | |
| 8 | request merges if **Auto-settle merged threads** is enabled. | |
| 7 | Pinned threads follow the auto-settle policy in **Settings → General**. Choose exactly one policy: | |
| 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 | 12 | When you un-settle a thread, it returns to the top of the active list so you can find it right |
| 11 | 13 | away. Its timestamps do not change. Other threads keep their positions. |
| 12 | 14 | |
| 13 | 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 | |
| 15 | threads** is enabled. Right-click the same link and choose **Unlink from thread** to remove it. | |
| 16 | in the sidebar. The thread settles when the linked pull request merges or closes only when the | |
| 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 | 21 | On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu |
| 18 | 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 | 17 | "features/home/HomeHeader.tsx", |
| 18 | 18 | "features/review/ReviewSheet.tsx", |
| 19 | 19 | "features/review/useNativeReviewDiffBridge.ts", |
| 20 | "features/settings/SettingsAutoSettleRouteScreen.tsx", | |
| 20 | 21 | "features/settings/SettingsEnvironmentsRouteScreen.tsx", |
| 21 | 22 | "features/settings/appearance/components/AppearancePreviews.tsx", |
| 22 | 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 | 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 | ||
| 36 | 24 | const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z"; |
| 37 | 25 | const idleThread = { |
| 38 | 26 | createdAt: THREAD_CREATED_AT, |
| ... | ... | @@ -40,6 +28,19 @@ describe("changeRequestAutoSettles", () => { |
| 40 | 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 | 44 | it("ignores a terminal change request last touched before the thread existed", () => { |
| 44 | 45 | for (const state of ["merged", "closed"] as const) { |
| 45 | 46 | expect( |
| ... | ... | @@ -105,20 +106,20 @@ describe("changeRequestAutoSettles", () => { |
| 105 | 106 | ).toBe(true); |
| 106 | 107 | }); |
| 107 | 108 | |
| 108 | it("falls back to settling when either timestamp is missing or malformed", () => { | |
| 109 | expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(true); | |
| 109 | it("keeps the thread active when either timestamp is missing or malformed", () => { | |
| 110 | expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(false); | |
| 110 | 111 | expect( |
| 111 | 112 | changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }), |
| 112 | ).toBe(true); | |
| 113 | ).toBe(false); | |
| 113 | 114 | expect( |
| 114 | 115 | changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}), |
| 115 | ).toBe(true); | |
| 116 | ).toBe(false); | |
| 116 | 117 | expect( |
| 117 | 118 | changeRequestAutoSettles( |
| 118 | 119 | { state: "merged", updatedAt: "not-a-date" }, |
| 119 | 120 | { thread: idleThread }, |
| 120 | 121 | ), |
| 121 | ).toBe(true); | |
| 122 | ).toBe(false); | |
| 122 | 123 | }); |
| 123 | 124 | }); |
| 124 | 125 | |
| ... | ... | @@ -204,38 +205,47 @@ describe("effectiveSettled", () => { |
| 204 | 205 | ] as const; |
| 205 | 206 | const runningCases = [false, true] as const; |
| 206 | 207 | const pendingCases = [undefined, "approval", "user-input"] as const; |
| 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 | })), | |
| 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 | ), | |
| 231 | 233 | ), |
| 232 | 234 | ), |
| 233 | 235 | ), |
| 234 | 236 | ); |
| 235 | 237 | |
| 236 | 238 | it.each(truthTable)( |
| 237 | "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending", | |
| 238 | ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => { | |
| 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 | }) => { | |
| 239 | 249 | const shell = makeShell({ |
| 240 | 250 | settledOverride, |
| 241 | 251 | activityAt, |
| ... | ... | @@ -245,12 +255,18 @@ describe("effectiveSettled", () => { |
| 245 | 255 | const changeRequestOptions = |
| 246 | 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 | 265 | expect( |
| 251 | 266 | effectiveSettled(shell, { |
| 252 | 267 | now: NOW, |
| 253 | 268 | autoSettleAfterDays: 3, |
| 269 | autoSettleMode, | |
| 254 | 270 | ...changeRequestOptions, |
| 255 | 271 | }), |
| 256 | 272 | ).toBe(expected); |
| ... | ... | @@ -263,7 +279,8 @@ describe("effectiveSettled", () => { |
| 263 | 279 | effectiveSettled(shell, { |
| 264 | 280 | now: NOW, |
| 265 | 281 | autoSettleAfterDays: null, |
| 266 | changeRequest: { state: "closed" }, | |
| 282 | autoSettleMode: "change-request", | |
| 283 | changeRequest: { state: "closed", updatedAt: NOW }, | |
| 267 | 284 | }), |
| 268 | 285 | ).toBe(true); |
| 269 | 286 | }); |
| ... | ... | @@ -275,7 +292,8 @@ describe("effectiveSettled", () => { |
| 275 | 292 | effectiveSettled(recentlyActive, { |
| 276 | 293 | now: NOW, |
| 277 | 294 | autoSettleAfterDays: null, |
| 278 | changeRequest: { state: changeRequestState }, | |
| 295 | autoSettleMode: "change-request", | |
| 296 | changeRequest: { state: changeRequestState, updatedAt: NOW }, | |
| 279 | 297 | }), |
| 280 | 298 | ).toBe(true); |
| 281 | 299 | } |
| ... | ... | @@ -291,6 +309,7 @@ describe("effectiveSettled", () => { |
| 291 | 309 | effectiveSettled(fresh, { |
| 292 | 310 | now: NOW, |
| 293 | 311 | autoSettleAfterDays: null, |
| 312 | autoSettleMode: "change-request", | |
| 294 | 313 | changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" }, |
| 295 | 314 | }), |
| 296 | 315 | ).toBe(false); |
| ... | ... | @@ -300,30 +319,24 @@ describe("effectiveSettled", () => { |
| 300 | 319 | effectiveSettled(fresh, { |
| 301 | 320 | now: NOW, |
| 302 | 321 | autoSettleAfterDays: null, |
| 322 | autoSettleMode: "change-request", | |
| 303 | 323 | changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" }, |
| 304 | 324 | }), |
| 305 | 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 | 329 | const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); |
| 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); | |
| 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 | } | |
| 327 | 340 | }); |
| 328 | 341 | |
| 329 | 342 | it("never auto-settles a stale thread with an open change request", () => { |
| ... | ... | @@ -332,6 +345,7 @@ describe("effectiveSettled", () => { |
| 332 | 345 | effectiveSettled(stale, { |
| 333 | 346 | now: NOW, |
| 334 | 347 | autoSettleAfterDays: 3, |
| 348 | autoSettleMode: "inactivity", | |
| 335 | 349 | changeRequest: { state: "open" }, |
| 336 | 350 | }), |
| 337 | 351 | ).toBe(false); |
| ... | ... | @@ -341,6 +355,7 @@ describe("effectiveSettled", () => { |
| 341 | 355 | effectiveSettled(settled, { |
| 342 | 356 | now: NOW, |
| 343 | 357 | autoSettleAfterDays: 3, |
| 358 | autoSettleMode: "inactivity", | |
| 344 | 359 | changeRequest: { state: "open" }, |
| 345 | 360 | }), |
| 346 | 361 | ).toBe(true); |
| ... | ... | @@ -355,7 +370,8 @@ describe("effectiveSettled", () => { |
| 355 | 370 | effectiveSettled(shell, { |
| 356 | 371 | now: NOW, |
| 357 | 372 | autoSettleAfterDays: null, |
| 358 | changeRequest: { state: "merged" }, | |
| 373 | autoSettleMode: "change-request", | |
| 374 | changeRequest: { state: "merged", updatedAt: NOW }, | |
| 359 | 375 | }), |
| 360 | 376 | ).toBe(false); |
| 361 | 377 | }); |
| ... | ... | @@ -370,7 +386,8 @@ describe("effectiveSettled", () => { |
| 370 | 386 | effectiveSettled(shell, { |
| 371 | 387 | now: NOW, |
| 372 | 388 | autoSettleAfterDays: 3, |
| 373 | changeRequest: { state: "merged" }, | |
| 389 | autoSettleMode: "change-request", | |
| 390 | changeRequest: { state: "merged", updatedAt: NOW }, | |
| 374 | 391 | }), |
| 375 | 392 | ).toBe(false); |
| 376 | 393 | }); |
| ... | ... | @@ -414,7 +431,8 @@ describe("effectiveSettled", () => { |
| 414 | 431 | effectiveSettled(shell, { |
| 415 | 432 | now: transitionNow, |
| 416 | 433 | autoSettleAfterDays: 3, |
| 417 | changeRequest: { state: "merged" }, | |
| 434 | autoSettleMode: "change-request", | |
| 435 | changeRequest: { state: "merged", updatedAt: transitionNow }, | |
| 418 | 436 | }), |
| 419 | 437 | ).toBe(false); |
| 420 | 438 | } |
| ... | ... | @@ -426,8 +444,20 @@ describe("effectiveSettled", () => { |
| 426 | 444 | }); |
| 427 | 445 | const stale = makeShell({ activityAt: STALE }); |
| 428 | 446 | |
| 429 | expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); | |
| 430 | expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false); | |
| 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); | |
| 431 | 461 | }); |
| 432 | 462 | }); |
| 433 | 463 | |
| ... | ... | @@ -532,7 +562,8 @@ describe("canSettle", () => { |
| 532 | 562 | effectiveSettled(queued, { |
| 533 | 563 | now: justAfter, |
| 534 | 564 | autoSettleAfterDays: 3, |
| 535 | changeRequest: { state: "merged" }, | |
| 565 | autoSettleMode: "change-request", | |
| 566 | changeRequest: { state: "merged", updatedAt: justAfter }, | |
| 536 | 567 | }), |
| 537 | 568 | ).toBe(false); |
| 538 | 569 | // Past the window the message is a failed/stale start: settleable again. |
| ... | ... | @@ -554,9 +585,13 @@ describe("canSettle", () => { |
| 554 | 585 | settledAt: "2026-04-09T12:02:10.000Z", |
| 555 | 586 | }; |
| 556 | 587 | expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true); |
| 557 | expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe( | |
| 558 | true, | |
| 559 | ); | |
| 588 | expect( | |
| 589 | effectiveSettled(settledAfterMessage, { | |
| 590 | now: flooredNow, | |
| 591 | autoSettleAfterDays: 3, | |
| 592 | autoSettleMode: "never", | |
| 593 | }), | |
| 594 | ).toBe(true); | |
| 560 | 595 | |
| 561 | 596 | // A message NEWER than settledAt is genuinely new work: still blocked |
| 562 | 597 | // until the server's auto-unsettle lands. |
| ... | ... | @@ -569,6 +604,7 @@ describe("canSettle", () => { |
| 569 | 604 | effectiveSettled(messageAfterSettle, { |
| 570 | 605 | now: "2026-04-09T12:03:30.000Z", |
| 571 | 606 | autoSettleAfterDays: 3, |
| 607 | autoSettleMode: "never", | |
| 572 | 608 | }), |
| 573 | 609 | ).toBe(false); |
| 574 | 610 | }); |
| ... | ... | @@ -582,6 +618,12 @@ describe("canSettle", () => { |
| 582 | 618 | pending: "user-input", |
| 583 | 619 | }); |
| 584 | 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 | 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 | 4 | export type ChangeRequestStateLike = "open" | "closed" | "merged"; |
| 5 | 5 | |
| ... | ... | @@ -46,26 +46,22 @@ 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. 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 | 52 | export function changeRequestAutoSettles( |
| 52 | 53 | changeRequest: ChangeRequestSettleSource | null | undefined, |
| 53 | 54 | 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 = | |
| 60 | changeRequest.state === "closed" || | |
| 61 | (changeRequest.state === "merged" && options.autoSettleOnMerge !== false); | |
| 59 | const terminal = changeRequest.state === "closed" || changeRequest.state === "merged"; | |
| 62 | 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 | 62 | const updatedAtMs = Date.parse(changeRequest.updatedAt); |
| 65 | 63 | const anchorAtMs = Date.parse(threadUserActivityAnchorAt(options.thread)); |
| 66 | // Malformed timestamps fall back to settling, matching servers that never | |
| 67 | // report updatedAt. | |
| 68 | if (Number.isNaN(updatedAtMs) || Number.isNaN(anchorAtMs)) return true; | |
| 64 | if (Number.isNaN(updatedAtMs) || Number.isNaN(anchorAtMs)) return false; | |
| 69 | 65 | return updatedAtMs >= anchorAtMs; |
| 70 | 66 | } |
| 71 | 67 | |
| ... | ... | @@ -289,11 +285,11 @@ export function threadWokeAt( |
| 289 | 285 | * queued turn) are checked first and hold a thread active regardless of any |
| 290 | 286 | * override. Past the blockers, the explicit user override (thread.settle / |
| 291 | 287 | * thread.unsettle commands, projected into settledOverride + settledAt) |
| 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 | |
| 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 | |
| 297 | 293 | * un-settles on real activity (user message, session start, approval/ |
| 298 | 294 | * user-input request), so an override never goes stale silently. |
| 299 | 295 | */ |
| ... | ... | @@ -302,7 +298,7 @@ export function effectiveSettled( |
| 302 | 298 | options: { |
| 303 | 299 | readonly now: string; |
| 304 | 300 | readonly autoSettleAfterDays: number | null; |
| 305 | readonly autoSettleOnMerge?: boolean; | |
| 301 | readonly autoSettleMode: SidebarAutoSettleMode; | |
| 306 | 302 | readonly changeRequest?: ChangeRequestSettleSource | null; |
| 307 | 303 | }, |
| 308 | 304 | ): boolean { |
| ... | ... | @@ -329,18 +325,14 @@ export function effectiveSettled( |
| 329 | 325 | // "active" is the explicit keep-active pin: it suppresses auto-settle |
| 330 | 326 | // until real activity clears it server-side. |
| 331 | 327 | if (shell.settledOverride === "active") return false; |
| 332 | if ( | |
| 333 | changeRequestAutoSettles(options.changeRequest, { | |
| 334 | autoSettleOnMerge: options.autoSettleOnMerge, | |
| 335 | thread: shell, | |
| 336 | }) | |
| 337 | ) { | |
| 338 | return true; | |
| 328 | if (options.autoSettleMode === "never") return false; | |
| 329 | if (options.autoSettleMode === "change-request") { | |
| 330 | return changeRequestAutoSettles(options.changeRequest, { thread: shell }); | |
| 339 | 331 | } |
| 340 | 332 | // An open PR is unfinished business regardless of how long the thread has |
| 341 | 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 | |
| 343 | // settle resolves it. | |
| 334 | // work waiting on it. Only an explicit user settle resolves it while the | |
| 335 | // inactivity policy is selected. | |
| 344 | 336 | if (options.changeRequest?.state === "open") return false; |
| 345 | 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 | 117 | }); |
| 118 | 118 | |
| 119 | 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 | 121 | const settings = decodeClientSettings({}); |
| 122 | 122 | expect(settings.legacySidebarEnabled).toBe(false); |
| 123 | 123 | expect(settings.sidebarAutoSettleAfterDays).toBe(3); |
| 124 | expect(settings.sidebarAutoSettleOnMerge).toBe(true); | |
| 124 | expect(settings.sidebarAutoSettleMode).toBe("never"); | |
| 125 | 125 | }); |
| 126 | 126 | |
| 127 | 127 | it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { |
| ... | ... | @@ -147,13 +147,27 @@ describe("ClientSettings sidebar", () => { |
| 147 | 147 | ).toBeNull(); |
| 148 | 148 | }); |
| 149 | 149 | |
| 150 | it("allows auto-settle on merge to be disabled", () => { | |
| 151 | expect(decodeClientSettings({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge).toBe( | |
| 152 | false, | |
| 153 | ); | |
| 154 | expect( | |
| 155 | decodeClientSettingsPatch({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge, | |
| 156 | ).toBe(false); | |
| 150 | it("does not preserve the retired merge switch as a hidden automatic path", () => { | |
| 151 | const decoded = decodeClientSettings({ sidebarAutoSettleOnMerge: true }); | |
| 152 | expect(decoded.sidebarAutoSettleMode).toBe("never"); | |
| 153 | expect(decoded).not.toHaveProperty("sidebarAutoSettleOnMerge"); | |
| 154 | }); | |
| 155 | ||
| 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 | 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 | 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"; | |
| 68 | 71 | export const MIN_GLASS_OPACITY = 40; |
| 69 | 72 | export const MAX_GLASS_OPACITY = 100; |
| 70 | 73 | export const GlassOpacity = Schema.Int.check( |
| ... | ... | @@ -234,7 +237,12 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 234 | 237 | sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( |
| 235 | 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 | 246 | sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( |
| 239 | 247 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), |
| 240 | 248 | ), |
| ... | ... | @@ -942,7 +950,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 942 | 950 | showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), |
| 943 | 951 | legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), |
| 944 | 952 | sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), |
| 945 | sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), | |
| 953 | sidebarAutoSettleMode: Schema.optionalKey(SidebarAutoSettleMode), | |
| 946 | 954 | sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), |
| 947 | 955 | sidebarProjectGroupingOverrides: Schema.optionalKey( |
| 948 | 956 | Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), |