| author | |
| committer | |
| log | db1507e986591ae8e82f8fa1e173a9013309c64e |
| tree | 2f7dcda908c0066a6d871e523b5d7c56a8c08723 |
| parent | 96bfa67b3d05d8adb2fb23d1017bd10fe4b9a338 |
| signature | Signed by PGP key B5690EEEBB952194 |
17 files changed, 192 insertions(+), 33 deletions(-)
apps/desktop/src/settings/DesktopClientSettings.test.ts+1| ... | @@ -32,6 +32,7 @@ const clientSettings: ClientSettings = { | ... | @@ -32,6 +32,7 @@ const clientSettings: ClientSettings = { |
| 32 | planModeEnabled: false, | 32 | planModeEnabled: false, |
| 33 | providerModelPreferences: {}, | 33 | providerModelPreferences: {}, |
| 34 | sidebarAutoSettleAfterDays: 3, | 34 | sidebarAutoSettleAfterDays: 3, |
| 35 | sidebarAutoSettleOnMerge: true, | ||
| 35 | sidebarProjectGroupingMode: "repository_path", | 36 | sidebarProjectGroupingMode: "repository_path", |
| 36 | sidebarProjectGroupingOverrides: { | 37 | sidebarProjectGroupingOverrides: { |
| 37 | "environment-1:/tmp/project-a": "separate", | 38 | "environment-1:/tmp/project-a": "separate", |
apps/mobile/src/features/home/HomeScreen.tsx+7-2| ... | @@ -207,6 +207,9 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -207,6 +207,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 207 | >(() => new Map()); | 207 | >(() => new Map()); |
| 208 | const preferencesResult = useAtomValue(mobilePreferencesAtom); | 208 | const preferencesResult = useAtomValue(mobilePreferencesAtom); |
| 209 | const threadListV2Enabled = useThreadListV2Enabled(); | 209 | const threadListV2Enabled = useThreadListV2Enabled(); |
| 210 | const autoSettleOnMerge = | ||
| 211 | !AsyncResult.isSuccess(preferencesResult) || | ||
| 212 | preferencesResult.value.autoSettleOnMerge !== false; | ||
| 210 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | 213 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| 211 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); | 214 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 212 | const listRef = useRef<LegendListRef | null>(null); | 215 | const listRef = useRef<LegendListRef | null>(null); |
| ... | @@ -483,8 +486,8 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -483,8 +486,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 483 | // Settled threads stay in the live shell stream (settled ≠ archived), so | 486 | // Settled threads stay in the live shell stream (settled ≠ archived), so |
| 484 | // the partition works directly off live shells — no snapshot merging or | 487 | // the partition works directly off live shells — no snapshot merging or |
| 485 | // optimistic holds. | 488 | // optimistic holds. |
| 486 | // PR states stream in per-row (rows own the VCS subscriptions); a merged or | 489 | // PR states stream in per-row. The next partition applies the configured |
| 487 | // closed PR auto-settles its thread on the next partition (mirrors web). | 490 | // merge rule and the always-on close rule, matching web. |
| 488 | const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< | 491 | const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< |
| 489 | ReadonlyMap<string, "open" | "closed" | "merged"> | 492 | ReadonlyMap<string, "open" | "closed" | "merged"> |
| 490 | >(() => new Map()); | 493 | >(() => new Map()); |
| ... | @@ -665,6 +668,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -665,6 +668,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 665 | searchQuery: props.searchQuery, | 668 | searchQuery: props.searchQuery, |
| 666 | matchedThreadKeys, | 669 | matchedThreadKeys, |
| 667 | changeRequestStateByKey, | 670 | changeRequestStateByKey, |
| 671 | autoSettleOnMerge, | ||
| 668 | settlementEnvironmentIds, | 672 | settlementEnvironmentIds, |
| 669 | snoozeEnvironmentIds, | 673 | snoozeEnvironmentIds, |
| 670 | settledLimit: settledVisibleCount, | 674 | settledLimit: settledVisibleCount, |
| ... | @@ -676,6 +680,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -676,6 +680,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 676 | }); | 680 | }); |
| 677 | }, [ | 681 | }, [ |
| 678 | changeRequestStateByKey, | 682 | changeRequestStateByKey, |
| 683 | autoSettleOnMerge, | ||
| 679 | nowMinute, | 684 | nowMinute, |
| 680 | snoozeWakeTick, | 685 | snoozeWakeTick, |
| 681 | snoozedShelfExpanded, | 686 | snoozedShelfExpanded, |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+12| ... | @@ -522,9 +522,21 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -522,9 +522,21 @@ function ConfiguredSettingsRouteScreen() { |
| 522 | } | 522 | } |
| 523 | 523 | ||
| 524 | function GeneralSettingsSection() { | 524 | function GeneralSettingsSection() { |
| 525 | const preferencesResult = useAtomValue(mobilePreferencesAtom); | ||
| 526 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | ||
| 527 | const autoSettleOnMerge = | ||
| 528 | !AsyncResult.isSuccess(preferencesResult) || | ||
| 529 | preferencesResult.value.autoSettleOnMerge !== false; | ||
| 530 | |||
| 525 | return ( | 531 | return ( |
| 526 | <SettingsSection title="General"> | 532 | <SettingsSection title="General"> |
| 527 | <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" /> | 533 | <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" /> |
| 534 | <SettingsSwitchRow | ||
| 535 | icon="arrow.triangle.branch" | ||
| 536 | label="Auto-settle merged threads" | ||
| 537 | value={autoSettleOnMerge} | ||
| 538 | onValueChange={(value) => savePreferences({ autoSettleOnMerge: value })} | ||
| 539 | /> | ||
| 528 | <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" /> | 540 | <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" /> |
| 529 | </SettingsSection> | 541 | </SettingsSection> |
| 530 | ); | 542 | ); |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+10-2| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | import { LegendList } from "@legendapp/list/react-native"; | 10 | import { LegendList } from "@legendapp/list/react-native"; |
| 11 | import type { MenuAction } from "@react-native-menu/menu"; | 11 | import type { MenuAction } from "@react-native-menu/menu"; |
| 12 | import { useAtomValue } from "@effect/atom-react"; | 12 | import { useAtomValue } from "@effect/atom-react"; |
| 13 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 13 | import type { EnvironmentId } from "@t3tools/contracts"; | 14 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 14 | import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; | 15 | import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; |
| 15 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | 16 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| ... | @@ -29,6 +30,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; | ... | @@ -29,6 +30,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 29 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; | 30 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; |
| 30 | import { useThemeColor } from "../../lib/useThemeColor"; | 31 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 31 | import { useProjects, useThreadShells } from "../../state/entities"; | 32 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 33 | import { mobilePreferencesAtom } from "../../state/preferences"; | ||
| 32 | import { useThreadSearch } from "../../state/queries"; | 34 | import { useThreadSearch } from "../../state/queries"; |
| 33 | import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; | 35 | import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; |
| 34 | import { environmentServerConfigsAtom } from "../../state/server"; | 36 | import { environmentServerConfigsAtom } from "../../state/server"; |
| ... | @@ -214,6 +216,10 @@ function ThreadNavigationSidebarPane( | ... | @@ -214,6 +216,10 @@ function ThreadNavigationSidebarPane( |
| 214 | regenerateThreadTitle, | 216 | regenerateThreadTitle, |
| 215 | } = useThreadListActions(); | 217 | } = useThreadListActions(); |
| 216 | const threadListV2Enabled = useThreadListV2Enabled(); | 218 | const threadListV2Enabled = useThreadListV2Enabled(); |
| 219 | const preferencesResult = useAtomValue(mobilePreferencesAtom); | ||
| 220 | const autoSettleOnMerge = | ||
| 221 | !AsyncResult.isSuccess(preferencesResult) || | ||
| 222 | preferencesResult.value.autoSettleOnMerge !== false; | ||
| 217 | const pendingTasks = usePendingNewTasks(); | 223 | const pendingTasks = usePendingNewTasks(); |
| 218 | const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); | 224 | const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); |
| 219 | const environments = useMemo( | 225 | const environments = useMemo( |
| ... | @@ -411,8 +417,8 @@ function ThreadNavigationSidebarPane( | ... | @@ -411,8 +417,8 @@ function ThreadNavigationSidebarPane( |
| 411 | 417 | ||
| 412 | // Thread List v2 (beta) support — same model as the compact Home list | 418 | // Thread List v2 (beta) support — same model as the compact Home list |
| 413 | // (HomeScreen.tsx): flat creation-order card block + settled recency tail. | 419 | // (HomeScreen.tsx): flat creation-order card block + settled recency tail. |
| 414 | // PR states stream in per-row; merged/closed PRs auto-settle their thread | 420 | // PR states stream in per-row. The next partition applies the configured |
| 415 | // on the next partition. | 421 | // merge rule and the always-on close rule. |
| 416 | const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< | 422 | const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< |
| 417 | ReadonlyMap<string, "open" | "closed" | "merged"> | 423 | ReadonlyMap<string, "open" | "closed" | "merged"> |
| 418 | >(() => new Map()); | 424 | >(() => new Map()); |
| ... | @@ -546,6 +552,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -546,6 +552,7 @@ function ThreadNavigationSidebarPane( |
| 546 | searchQuery: props.searchQuery, | 552 | searchQuery: props.searchQuery, |
| 547 | matchedThreadKeys, | 553 | matchedThreadKeys, |
| 548 | changeRequestStateByKey, | 554 | changeRequestStateByKey, |
| 555 | autoSettleOnMerge, | ||
| 549 | settlementEnvironmentIds, | 556 | settlementEnvironmentIds, |
| 550 | snoozeEnvironmentIds, | 557 | snoozeEnvironmentIds, |
| 551 | settledLimit: settledVisibleCount, | 558 | settledLimit: settledVisibleCount, |
| ... | @@ -557,6 +564,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -557,6 +564,7 @@ function ThreadNavigationSidebarPane( |
| 557 | }); | 564 | }); |
| 558 | }, [ | 565 | }, [ |
| 559 | changeRequestStateByKey, | 566 | changeRequestStateByKey, |
| 567 | autoSettleOnMerge, | ||
| 560 | nowMinute, | 568 | nowMinute, |
| 561 | snoozeWakeTick, | 569 | snoozeWakeTick, |
| 562 | snoozedShelfExpanded, | 570 | snoozedShelfExpanded, |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+2-2| ... | @@ -369,8 +369,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -369,8 +369,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 369 | readonly canMovePinnedDown?: boolean; | 369 | readonly canMovePinnedDown?: boolean; |
| 370 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; | 370 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; |
| 371 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; | 371 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; |
| 372 | /** Reports this row's live PR state up so the partition can auto-settle | 372 | /** Reports this row's live PR state for the partition's merge and close |
| 373 | merged/closed work (mirrors web's onChangeRequestState). */ | 373 | rules. Mirrors web's onChangeRequestState. */ |
| 374 | readonly onChangeRequestState?: ( | 374 | readonly onChangeRequestState?: ( |
| 375 | threadKey: string, | 375 | threadKey: string, |
| 376 | state: "open" | "closed" | "merged" | null, | 376 | state: "open" | "closed" | "merged" | null, |
apps/mobile/src/features/threads/threadListV2.test.ts+15| ... | @@ -263,6 +263,21 @@ describe("sortThreadsForListV2", () => { | ... | @@ -263,6 +263,21 @@ describe("sortThreadsForListV2", () => { |
| 263 | }); | 263 | }); |
| 264 | 264 | ||
| 265 | describe("buildThreadListV2Items", () => { | 265 | describe("buildThreadListV2Items", () => { |
| 266 | it("keeps a merged thread active when auto-settle on merge is off", () => { | ||
| 267 | const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" }); | ||
| 268 | const layout = buildThreadListV2Items({ | ||
| 269 | threads: [merged], | ||
| 270 | environmentId: null, | ||
| 271 | searchQuery: "", | ||
| 272 | changeRequestStateByKey: new Map([[`${environmentId}:${merged.id}`, "merged"]]), | ||
| 273 | autoSettleOnMerge: false, | ||
| 274 | now: NOW, | ||
| 275 | }); | ||
| 276 | |||
| 277 | expect(layout.items.map((item) => item.thread.id)).toEqual(["merged"]); | ||
| 278 | expect(layout.settledCount).toBe(0); | ||
| 279 | }); | ||
| 280 | |||
| 266 | it("hides snoozed threads and counts them — visibility parity with web", () => { | 281 | it("hides snoozed threads and counts them — visibility parity with web", () => { |
| 267 | const layout = buildThreadListV2Items({ | 282 | const layout = buildThreadListV2Items({ |
| 268 | threads: [ | 283 | threads: [ |
apps/mobile/src/features/threads/threadListV2.ts+10-4| ... | @@ -306,9 +306,8 @@ export function buildThreadListV2ListItems(input: { | ... | @@ -306,9 +306,8 @@ export function buildThreadListV2ListItems(input: { |
| 306 | 306 | ||
| 307 | /** | 307 | /** |
| 308 | * Partitions visible threads into the active card block (creation order) and | 308 | * Partitions visible threads into the active card block (creation order) and |
| 309 | * the settled recency tail, matching the web v2 list. `autoSettleAfterDays` | 309 | * the settled recency tail, matching the web v2 list. Mobile stores these |
| 310 | * mirrors the web default of 3 — mobile has no client-settings sync yet, so | 310 | * auto-settle preferences per device. |
| 311 | * the default is fixed here rather than user-configurable. | ||
| 312 | */ | 311 | */ |
| 313 | export function buildThreadListV2Items(input: { | 312 | export function buildThreadListV2Items(input: { |
| 314 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | 313 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| ... | @@ -329,6 +328,7 @@ export function buildThreadListV2Items(input: { | ... | @@ -329,6 +328,7 @@ export function buildThreadListV2Items(input: { |
| 329 | contract as settlementEnvironmentIds. */ | 328 | contract as settlementEnvironmentIds. */ |
| 330 | readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>; | 329 | readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>; |
| 331 | readonly autoSettleAfterDays?: number; | 330 | readonly autoSettleAfterDays?: number; |
| 331 | readonly autoSettleOnMerge?: boolean; | ||
| 332 | /** Max settled rows to render; the rest are counted, not built. */ | 332 | /** Max settled rows to render; the rest are counted, not built. */ |
| 333 | readonly settledLimit?: number; | 333 | readonly settledLimit?: number; |
| 334 | /** Injectable for tests; defaults to now. */ | 334 | /** Injectable for tests; defaults to now. */ |
| ... | @@ -349,6 +349,7 @@ export function buildThreadListV2Items(input: { | ... | @@ -349,6 +349,7 @@ export function buildThreadListV2Items(input: { |
| 349 | const now = input.now ?? new Date().toISOString(); | 349 | const now = input.now ?? new Date().toISOString(); |
| 350 | const snoozeNow = input.snoozeNow ?? now; | 350 | const snoozeNow = input.snoozeNow ?? now; |
| 351 | const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; | 351 | const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; |
| 352 | const autoSettleOnMerge = input.autoSettleOnMerge ?? true; | ||
| 352 | const query = input.searchQuery.trim().toLocaleLowerCase(); | 353 | const query = input.searchQuery.trim().toLocaleLowerCase(); |
| 353 | const projectKeys = input.projectRefs | 354 | const projectKeys = input.projectRefs |
| 354 | ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) | 355 | ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) |
| ... | @@ -405,7 +406,12 @@ export function buildThreadListV2Items(input: { | ... | @@ -405,7 +406,12 @@ export function buildThreadListV2Items(input: { |
| 405 | } | 406 | } |
| 406 | if ( | 407 | if ( |
| 407 | supportsSettlement && | 408 | supportsSettlement && |
| 408 | effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) | 409 | effectiveSettled(thread, { |
| 410 | now, | ||
| 411 | autoSettleAfterDays, | ||
| 412 | autoSettleOnMerge, | ||
| 413 | changeRequestState, | ||
| 414 | }) | ||
| 409 | ) { | 415 | ) { |
| 410 | settled.push(thread); | 416 | settled.push(thread); |
| 411 | } else { | 417 | } else { |
apps/mobile/src/persistence/mobile-preferences.ts+5| ... | @@ -26,6 +26,7 @@ export interface Preferences { | ... | @@ -26,6 +26,7 @@ export interface Preferences { |
| 26 | /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ | 26 | /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ |
| 27 | readonly projectGroupingEnabled?: boolean; | 27 | readonly projectGroupingEnabled?: boolean; |
| 28 | readonly projectGroupingMode?: SidebarProjectGroupingMode; | 28 | readonly projectGroupingMode?: SidebarProjectGroupingMode; |
| 29 | readonly autoSettleOnMerge?: boolean; | ||
| 29 | /** | 30 | /** |
| 30 | * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has | 31 | * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has |
| 31 | * no client-settings sync, so the legacy grouped thread list is opted into | 32 | * no client-settings sync, so the legacy grouped thread list is opted into |
| ... | @@ -85,6 +86,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { | ... | @@ -85,6 +86,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 85 | collapsedProjectGroups?: readonly string[]; | 86 | collapsedProjectGroups?: readonly string[]; |
| 86 | projectGroupingEnabled?: boolean; | 87 | projectGroupingEnabled?: boolean; |
| 87 | projectGroupingMode?: SidebarProjectGroupingMode; | 88 | projectGroupingMode?: SidebarProjectGroupingMode; |
| 89 | autoSettleOnMerge?: boolean; | ||
| 88 | legacyThreadListEnabled?: boolean; | 90 | legacyThreadListEnabled?: boolean; |
| 89 | } = {}; | 91 | } = {}; |
| 90 | 92 | ||
| ... | @@ -122,6 +124,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { | ... | @@ -122,6 +124,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 122 | ) { | 124 | ) { |
| 123 | preferences.projectGroupingMode = parsed.projectGroupingMode; | 125 | preferences.projectGroupingMode = parsed.projectGroupingMode; |
| 124 | } | 126 | } |
| 127 | if (typeof parsed.autoSettleOnMerge === "boolean") { | ||
| 128 | preferences.autoSettleOnMerge = parsed.autoSettleOnMerge; | ||
| 129 | } | ||
| 125 | if (typeof parsed.legacyThreadListEnabled === "boolean") { | 130 | if (typeof parsed.legacyThreadListEnabled === "boolean") { |
| 126 | preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; | 131 | preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; |
| 127 | } | 132 | } |
apps/web/src/components/ChatView.tsx+8-4| ... | @@ -27,6 +27,7 @@ import { | ... | @@ -27,6 +27,7 @@ import { |
| 27 | type EnvironmentConnectionPresentation, | 27 | type EnvironmentConnectionPresentation, |
| 28 | } from "@t3tools/client-runtime/connection"; | 28 | } from "@t3tools/client-runtime/connection"; |
| 29 | import { | 29 | import { |
| 30 | changeRequestAutoSettles, | ||
| 30 | effectiveSettled, | 31 | effectiveSettled, |
| 31 | effectiveSnoozed, | 32 | effectiveSnoozed, |
| 32 | threadWokeAt, | 33 | threadWokeAt, |
| ... | @@ -4101,6 +4102,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4101,6 +4102,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4101 | // so the banner and the sidebar row never disagree. | 4102 | // so the banner and the sidebar row never disagree. |
| 4102 | const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); | 4103 | const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); |
| 4103 | const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); | 4104 | const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); |
| 4105 | const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); | ||
| 4104 | const activeThreadPr = resolveThreadPr({ | 4106 | const activeThreadPr = resolveThreadPr({ |
| 4105 | threadBranch: activeThread?.branch ?? null, | 4107 | threadBranch: activeThread?.branch ?? null, |
| 4106 | gitStatus: gitStatusQuery.data ?? null, | 4108 | gitStatus: gitStatusQuery.data ?? null, |
| ... | @@ -4141,15 +4143,14 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4141,15 +4143,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 4141 | if (activeThreadRef === null || activeThreadWokeAt === null) return; | 4143 | if (activeThreadRef === null || activeThreadWokeAt === null) return; |
| 4142 | markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); | 4144 | markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); |
| 4143 | }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); | 4145 | }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); |
| 4144 | // Mirror of the sidebar's Woke pill for the open thread: same visit | 4146 | // Mirror of the sidebar's Woke pill for the open thread. It uses the same |
| 4145 | // comparison, same merged/closed-PR suppression (finished work needs no | 4147 | // visit comparison and change request settle rule. |
| 4146 | // wake-up call). Drives the dismissible composer banner below. | ||
| 4147 | const activeThreadLastVisitedAt = useUiStateStore((store) => | 4148 | const activeThreadLastVisitedAt = useUiStateStore((store) => |
| 4148 | activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], | 4149 | activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], |
| 4149 | ); | 4150 | ); |
| 4150 | const activeThreadWokeVisible = useMemo(() => { | 4151 | const activeThreadWokeVisible = useMemo(() => { |
| 4151 | if (activeThreadWokeAt === null) return false; | 4152 | if (activeThreadWokeAt === null) return false; |
| 4152 | if (activeThreadPr?.state === "merged" || activeThreadPr?.state === "closed") return false; | 4153 | if (changeRequestAutoSettles(activeThreadPr?.state, autoSettleOnMerge)) return false; |
| 4153 | const wokeAtMs = Date.parse(activeThreadWokeAt); | 4154 | const wokeAtMs = Date.parse(activeThreadWokeAt); |
| 4154 | if (Number.isNaN(wokeAtMs)) return false; | 4155 | if (Number.isNaN(wokeAtMs)) return false; |
| 4155 | // Having the thread open counts as a visit at completedAt (the effect | 4156 | // Having the thread open counts as a visit at completedAt (the effect |
| ... | @@ -4171,18 +4172,21 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4171,18 +4172,21 @@ function ChatViewContent(props: ChatViewProps) { |
| 4171 | activeThreadLastVisitedAt, | 4172 | activeThreadLastVisitedAt, |
| 4172 | activeThreadPr?.state, | 4173 | activeThreadPr?.state, |
| 4173 | activeThreadWokeAt, | 4174 | activeThreadWokeAt, |
| 4175 | autoSettleOnMerge, | ||
| 4174 | ]); | 4176 | ]); |
| 4175 | const activeThreadSettled = useMemo(() => { | 4177 | const activeThreadSettled = useMemo(() => { |
| 4176 | if (activeThreadShell === null || !supportsSettlement) return false; | 4178 | if (activeThreadShell === null || !supportsSettlement) return false; |
| 4177 | return effectiveSettled(activeThreadShell, { | 4179 | return effectiveSettled(activeThreadShell, { |
| 4178 | now: `${nowMinute}:00.000Z`, | 4180 | now: `${nowMinute}:00.000Z`, |
| 4179 | autoSettleAfterDays, | 4181 | autoSettleAfterDays, |
| 4182 | autoSettleOnMerge, | ||
| 4180 | changeRequestState: activeThreadPr?.state ?? null, | 4183 | changeRequestState: activeThreadPr?.state ?? null, |
| 4181 | }); | 4184 | }); |
| 4182 | }, [ | 4185 | }, [ |
| 4183 | activeThreadPr?.state, | 4186 | activeThreadPr?.state, |
| 4184 | activeThreadShell, | 4187 | activeThreadShell, |
| 4185 | autoSettleAfterDays, | 4188 | autoSettleAfterDays, |
| 4189 | autoSettleOnMerge, | ||
| 4186 | nowMinute, | 4190 | nowMinute, |
| 4187 | supportsSettlement, | 4191 | supportsSettlement, |
| 4188 | ]); | 4192 | ]); |
apps/web/src/components/Sidebar.tsx+19-11| ... | @@ -19,6 +19,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- | ... | @@ -19,6 +19,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- |
| 19 | import { CSS } from "@dnd-kit/utilities"; | 19 | import { CSS } from "@dnd-kit/utilities"; |
| 20 | import { | 20 | import { |
| 21 | canSnooze, | 21 | canSnooze, |
| 22 | changeRequestAutoSettles, | ||
| 22 | effectiveSettled, | 23 | effectiveSettled, |
| 23 | effectiveSnoozed, | 24 | effectiveSnoozed, |
| 24 | threadWokeAt, | 25 | threadWokeAt, |
| ... | @@ -657,6 +658,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -657,6 +658,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 657 | // False on environments whose server predates thread.settle/unsettle: | 658 | // False on environments whose server predates thread.settle/unsettle: |
| 658 | // the lifecycle affordances hide entirely rather than fail on click. | 659 | // the lifecycle affordances hide entirely rather than fail on click. |
| 659 | settlementSupported: boolean; | 660 | settlementSupported: boolean; |
| 661 | autoSettleOnMerge: boolean; | ||
| 660 | // Same contract for thread.snooze/unsnooze. | 662 | // Same contract for thread.snooze/unsnooze. |
| 661 | snoozeSupported: boolean; | 663 | snoozeSupported: boolean; |
| 662 | // Renders the pin glyph. Pinned cards keep the full settle/snooze quick | 664 | // Renders the pin glyph. Pinned cards keep the full settle/snooze quick |
| ... | @@ -765,17 +767,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -765,17 +767,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 765 | // deliberately static), so the pill has to carry the weight. Snoozing is | 767 | // deliberately static), so the pill has to carry the weight. Snoozing is |
| 766 | // an explicit act, so the pill clears only when the user re-engages: | 768 | // an explicit act, so the pill clears only when the user re-engages: |
| 767 | // reading a completion-triggered wake, clicking the pill, sending a | 769 | // reading a completion-triggered wake, clicking the pill, sending a |
| 768 | // message, settling, archiving — or finishing the work outright (merged | 770 | // message, settling, archiving, or a change request state that settles the |
| 769 | // or closed PR). Timer wakes survive a mere visit. An unparseable visit | 771 | // thread. Timer wakes survive a mere visit. An unparseable visit timestamp |
| 770 | // timestamp counts as never-visited — corrupt local data must not eat | 772 | // counts as never-visited, so corrupt local data cannot eat the wake signal. |
| 771 | // the wake signal. | ||
| 772 | const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); | 773 | const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); |
| 773 | const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); | 774 | const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); |
| 774 | const isWoke = | 775 | const isWoke = |
| 775 | wokeAtDate !== null && | 776 | wokeAtDate !== null && |
| 776 | (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && | 777 | (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && |
| 777 | prState !== "merged" && | 778 | !changeRequestAutoSettles(prState, props.autoSettleOnMerge); |
| 778 | prState !== "closed"; | ||
| 779 | // In-flight rows (working, or waiting on approval/input) fade as a whole: | 779 | // In-flight rows (working, or waiting on approval/input) fade as a whole: |
| 780 | // there is nothing for the user to do yet, so prominence is reserved for | 780 | // there is nothing for the user to do yet, so prominence is reserved for |
| 781 | // rows that need a human — done (unread), read-but-unsettled, failed, and | 781 | // rows that need a human — done (unread), read-but-unsettled, failed, and |
| ... | @@ -851,8 +851,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -851,8 +851,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 851 | }); | 851 | }); |
| 852 | const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); | 852 | const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); |
| 853 | const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; | 853 | const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; |
| 854 | // Report the PR state up: the parent partitions rows with effectiveSettled, | 854 | // Report the PR state so the parent can apply the configured merge rule |
| 855 | // and a merged/closed PR auto-settles a thread — data only rows have. | 855 | // and the always-on close rule during partitioning. |
| 856 | useEffect(() => { | 856 | useEffect(() => { |
| 857 | onChangeRequestState(threadKey, prState); | 857 | onChangeRequestState(threadKey, prState); |
| 858 | }, [onChangeRequestState, prState, threadKey]); | 858 | }, [onChangeRequestState, prState, threadKey]); |
| ... | @@ -1599,6 +1599,7 @@ export default function Sidebar() { | ... | @@ -1599,6 +1599,7 @@ export default function Sidebar() { |
| 1599 | const { isMobile, setOpenMobile } = useSidebar(); | 1599 | const { isMobile, setOpenMobile } = useSidebar(); |
| 1600 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | 1600 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 1601 | const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); | 1601 | const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); |
| 1602 | const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); | ||
| 1602 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); | 1603 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); |
| 1603 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); | 1604 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); |
| 1604 | const timestampFormat = useClientSettings((s) => s.timestampFormat); | 1605 | const timestampFormat = useClientSettings((s) => s.timestampFormat); |
| ... | @@ -1801,8 +1802,8 @@ export default function Sidebar() { | ... | @@ -1801,8 +1802,8 @@ export default function Sidebar() { |
| 1801 | // fresh clock whenever it recomputes. | 1802 | // fresh clock whenever it recomputes. |
| 1802 | const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); | 1803 | const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); |
| 1803 | 1804 | ||
| 1804 | // PR states stream in per-row (rows own the VCS subscriptions); a merged or | 1805 | // PR states stream in per-row. The next partition applies the configured |
| 1805 | // closed PR auto-settles its thread on the next partition. | 1806 | // merge rule and the always-on close rule. |
| 1806 | const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< | 1807 | const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< |
| 1807 | ReadonlyMap<string, "open" | "closed" | "merged"> | 1808 | ReadonlyMap<string, "open" | "closed" | "merged"> |
| 1808 | >(() => new Map()); | 1809 | >(() => new Map()); |
| ... | @@ -1953,7 +1954,12 @@ export default function Sidebar() { | ... | @@ -1953,7 +1954,12 @@ export default function Sidebar() { |
| 1953 | pinned.push(thread); | 1954 | pinned.push(thread); |
| 1954 | } else if ( | 1955 | } else if ( |
| 1955 | supportsSettlement && | 1956 | supportsSettlement && |
| 1956 | effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) | 1957 | effectiveSettled(thread, { |
| 1958 | now, | ||
| 1959 | autoSettleAfterDays, | ||
| 1960 | autoSettleOnMerge, | ||
| 1961 | changeRequestState, | ||
| 1962 | }) | ||
| 1957 | ) { | 1963 | ) { |
| 1958 | settled.push(thread); | 1964 | settled.push(thread); |
| 1959 | } else { | 1965 | } else { |
| ... | @@ -1988,6 +1994,7 @@ export default function Sidebar() { | ... | @@ -1988,6 +1994,7 @@ export default function Sidebar() { |
| 1988 | }; | 1994 | }; |
| 1989 | }, [ | 1995 | }, [ |
| 1990 | autoSettleAfterDays, | 1996 | autoSettleAfterDays, |
| 1997 | autoSettleOnMerge, | ||
| 1991 | changeRequestStateByKey, | 1998 | changeRequestStateByKey, |
| 1992 | nowMinute, | 1999 | nowMinute, |
| 1993 | scopedProjectKeys, | 2000 | scopedProjectKeys, |
| ... | @@ -3532,6 +3539,7 @@ export default function Sidebar() { | ... | @@ -3532,6 +3539,7 @@ export default function Sidebar() { |
| 3532 | serverConfigs.get(thread.environmentId)?.environment.capabilities | 3539 | serverConfigs.get(thread.environmentId)?.environment.capabilities |
| 3533 | .threadSettlement === true | 3540 | .threadSettlement === true |
| 3534 | } | 3541 | } |
| 3542 | autoSettleOnMerge={autoSettleOnMerge} | ||
| 3535 | snoozeSupported={ | 3543 | snoozeSupported={ |
| 3536 | serverConfigs.get(thread.environmentId)?.environment.capabilities | 3544 | serverConfigs.get(thread.environmentId)?.environment.capabilities |
| 3537 | .threadSnooze === true | 3545 | .threadSnooze === true |
apps/web/src/components/settings/SettingsPanels.tsx+33-1| ... | @@ -493,6 +493,9 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -493,6 +493,9 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 493 | DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays | 493 | DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays |
| 494 | ? ["Auto-settle inactive threads"] | 494 | ? ["Auto-settle inactive threads"] |
| 495 | : []), | 495 | : []), |
| 496 | ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge | ||
| 497 | ? ["Auto-settle merged threads"] | ||
| 498 | : []), | ||
| 496 | ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), | 499 | ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), |
| 497 | ...getChangedTypographySettingLabels(settings), | 500 | ...getChangedTypographySettingLabels(settings), |
| 498 | ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace | 501 | ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace |
| ... | @@ -547,6 +550,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -547,6 +550,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 547 | settings.enableLegacyTokenStreaming, | 550 | settings.enableLegacyTokenStreaming, |
| 548 | settings.enableProviderUpdateChecks, | 551 | settings.enableProviderUpdateChecks, |
| 549 | settings.sidebarAutoSettleAfterDays, | 552 | settings.sidebarAutoSettleAfterDays, |
| 553 | settings.sidebarAutoSettleOnMerge, | ||
| 550 | settings.sidebarProjectGroupingMode, | 554 | settings.sidebarProjectGroupingMode, |
| 551 | settings.sidebarThreadPreviewCount, | 555 | settings.sidebarThreadPreviewCount, |
| 552 | settings.timestampFormat, | 556 | settings.timestampFormat, |
| ... | @@ -628,6 +632,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -628,6 +632,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 628 | sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, | 632 | sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, |
| 629 | sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, | 633 | sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, |
| 630 | sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, | 634 | sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, |
| 635 | sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, | ||
| 631 | enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, | 636 | enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, |
| 632 | enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, | 637 | enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, |
| 633 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, | 638 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, |
| ... | @@ -1828,9 +1833,36 @@ export function GeneralSettingsPanel() { | ... | @@ -1828,9 +1833,36 @@ export function GeneralSettingsPanel() { |
| 1828 | } | 1833 | } |
| 1829 | /> | 1834 | /> |
| 1830 | 1835 | ||
| 1836 | <SettingsRow | ||
| 1837 | {...searchableSetting("auto-settle-merged-threads")} | ||
| 1838 | description="Settle a thread when its pull request merges. Closed pull requests still settle automatically." | ||
| 1839 | resetAction={ | ||
| 1840 | settings.sidebarAutoSettleOnMerge !== | ||
| 1841 | DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? ( | ||
| 1842 | <SettingResetButton | ||
| 1843 | label="auto-settle on merge" | ||
| 1844 | onClick={() => | ||
| 1845 | updateSettings({ | ||
| 1846 | sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, | ||
| 1847 | }) | ||
| 1848 | } | ||
| 1849 | /> | ||
| 1850 | ) : null | ||
| 1851 | } | ||
| 1852 | control={ | ||
| 1853 | <Switch | ||
| 1854 | checked={settings.sidebarAutoSettleOnMerge} | ||
| 1855 | onCheckedChange={(checked) => | ||
| 1856 | updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) | ||
| 1857 | } | ||
| 1858 | aria-label="Auto-settle merged threads" | ||
| 1859 | /> | ||
| 1860 | } | ||
| 1861 | /> | ||
| 1862 | |||
| 1831 | <SettingsRow | 1863 | <SettingsRow |
| 1832 | {...searchableSetting("auto-settle-inactive-threads")} | 1864 | {...searchableSetting("auto-settle-inactive-threads")} |
| 1833 | description="Sidebar threads with no activity for this long settle automatically. Threads on merged or closed PRs always settle." | 1865 | description="Sidebar threads with no activity for this long settle automatically." |
| 1834 | resetAction={ | 1866 | resetAction={ |
| 1835 | settings.sidebarAutoSettleAfterDays !== | 1867 | settings.sidebarAutoSettleAfterDays !== |
| 1836 | DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? ( | 1868 | DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? ( |
apps/web/src/components/settings/settingsSearch.ts+5| ... | @@ -103,6 +103,11 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -103,6 +103,11 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 103 | title: "Auto-settle inactive threads", | 103 | title: "Auto-settle inactive threads", |
| 104 | to: "/settings/general", | 104 | to: "/settings/general", |
| 105 | }, | 105 | }, |
| 106 | { | ||
| 107 | id: "auto-settle-merged-threads", | ||
| 108 | title: "Auto-settle merged threads", | ||
| 109 | to: "/settings/general", | ||
| 110 | }, | ||
| 106 | { | 111 | { |
| 107 | id: "time-format", | 112 | id: "time-format", |
| 108 | title: "Time format", | 113 | title: "Time format", |
apps/web/src/hooks/useThreadActionMenu.ts+3| ... | @@ -80,6 +80,7 @@ export function useThreadActionMenu(input: { | ... | @@ -80,6 +80,7 @@ export function useThreadActionMenu(input: { |
| 80 | const handleNewThread = useNewThreadHandler(); | 80 | const handleNewThread = useNewThreadHandler(); |
| 81 | const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); | 81 | const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); |
| 82 | const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); | 82 | const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); |
| 83 | const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); | ||
| 83 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); | 84 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); |
| 84 | const timestampFormat = useClientSettings((s) => s.timestampFormat); | 85 | const timestampFormat = useClientSettings((s) => s.timestampFormat); |
| 85 | const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ | 86 | const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ |
| ... | @@ -132,6 +133,7 @@ export function useThreadActionMenu(input: { | ... | @@ -132,6 +133,7 @@ export function useThreadActionMenu(input: { |
| 132 | // parked-thread banner within the same minute. | 133 | // parked-thread banner within the same minute. |
| 133 | now: `${now.toISOString().slice(0, 16)}:00.000Z`, | 134 | now: `${now.toISOString().slice(0, 16)}:00.000Z`, |
| 134 | autoSettleAfterDays, | 135 | autoSettleAfterDays, |
| 136 | autoSettleOnMerge, | ||
| 135 | changeRequestState, | 137 | changeRequestState, |
| 136 | }), | 138 | }), |
| 137 | isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), | 139 | isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), |
| ... | @@ -284,6 +286,7 @@ export function useThreadActionMenu(input: { | ... | @@ -284,6 +286,7 @@ export function useThreadActionMenu(input: { |
| 284 | }, | 286 | }, |
| 285 | [ | 287 | [ |
| 286 | autoSettleAfterDays, | 288 | autoSettleAfterDays, |
| 289 | autoSettleOnMerge, | ||
| 287 | changeRequestState, | 290 | changeRequestState, |
| 288 | confirmThreadDelete, | 291 | confirmThreadDelete, |
| 289 | copyBranchToClipboard, | 292 | copyBranchToClipboard, |
packages/client-runtime/src/state/threadSettled.test.ts+34| ... | @@ -9,6 +9,7 @@ import { describe, expect, it } from "vite-plus/test"; | ... | @@ -9,6 +9,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 9 | 9 | ||
| 10 | import { | 10 | import { |
| 11 | canSettle, | 11 | canSettle, |
| 12 | changeRequestAutoSettles, | ||
| 12 | effectiveSettled, | 13 | effectiveSettled, |
| 13 | hasQueuedTurnStart, | 14 | hasQueuedTurnStart, |
| 14 | threadLastActivityAt, | 15 | threadLastActivityAt, |
| ... | @@ -19,6 +20,18 @@ const NOW = "2026-04-10T00:00:00.000Z"; | ... | @@ -19,6 +20,18 @@ const NOW = "2026-04-10T00:00:00.000Z"; |
| 19 | const FRESH = "2026-04-09T00:00:00.000Z"; | 20 | const FRESH = "2026-04-09T00:00:00.000Z"; |
| 20 | const STALE = "2026-04-06T23:59:59.999Z"; | 21 | const STALE = "2026-04-06T23:59:59.999Z"; |
| 21 | 22 | ||
| 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, autoSettleOnMerge)).toBe(expected); | ||
| 32 | }); | ||
| 33 | }); | ||
| 34 | |||
| 22 | function makeShell(input: { | 35 | function makeShell(input: { |
| 23 | readonly settledOverride?: "settled" | "active" | null; | 36 | readonly settledOverride?: "settled" | "active" | null; |
| 24 | readonly activityAt: string | null; | 37 | readonly activityAt: string | null; |
| ... | @@ -178,6 +191,27 @@ describe("effectiveSettled", () => { | ... | @@ -178,6 +191,27 @@ describe("effectiveSettled", () => { |
| 178 | } | 191 | } |
| 179 | }); | 192 | }); |
| 180 | 193 | ||
| 194 | it("can keep a merged change request active", () => { | ||
| 195 | const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); | ||
| 196 | expect( | ||
| 197 | effectiveSettled(recentlyActive, { | ||
| 198 | now: NOW, | ||
| 199 | autoSettleAfterDays: null, | ||
| 200 | autoSettleOnMerge: false, | ||
| 201 | changeRequestState: "merged", | ||
| 202 | }), | ||
| 203 | ).toBe(false); | ||
| 204 | |||
| 205 | expect( | ||
| 206 | effectiveSettled(recentlyActive, { | ||
| 207 | now: NOW, | ||
| 208 | autoSettleAfterDays: null, | ||
| 209 | autoSettleOnMerge: false, | ||
| 210 | changeRequestState: "closed", | ||
| 211 | }), | ||
| 212 | ).toBe(true); | ||
| 213 | }); | ||
| 214 | |||
| 181 | it("never auto-settles a stale thread with an open change request", () => { | 215 | it("never auto-settles a stale thread with an open change request", () => { |
| 182 | const stale = makeShell({ activityAt: STALE }); | 216 | const stale = makeShell({ activityAt: STALE }); |
| 183 | expect( | 217 | expect( |
packages/client-runtime/src/state/threadSettled.ts+15-6| ... | @@ -3,6 +3,14 @@ import type { OrchestrationThreadShell } from "@t3tools/contracts"; | ... | @@ -3,6 +3,14 @@ import type { OrchestrationThreadShell } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | export type ChangeRequestStateLike = "open" | "closed" | "merged"; | 4 | export type ChangeRequestStateLike = "open" | "closed" | "merged"; |
| 5 | 5 | ||
| 6 | /** Returns whether the change request state settles the thread immediately. */ | ||
| 7 | export function changeRequestAutoSettles( | ||
| 8 | state: ChangeRequestStateLike | null | undefined, | ||
| 9 | autoSettleOnMerge = true, | ||
| 10 | ): boolean { | ||
| 11 | return state === "closed" || (state === "merged" && autoSettleOnMerge); | ||
| 12 | } | ||
| 13 | |||
| 6 | const DAY_MS = 24 * 60 * 60 * 1_000; | 14 | const DAY_MS = 24 * 60 * 60 * 1_000; |
| 7 | 15 | ||
| 8 | export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null { | 16 | export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null { |
| ... | @@ -221,9 +229,9 @@ export function threadWokeAt( | ... | @@ -221,9 +229,9 @@ export function threadWokeAt( |
| 221 | * queued turn) are checked first and hold a thread active regardless of any | 229 | * queued turn) are checked first and hold a thread active regardless of any |
| 222 | * override. Past the blockers, the explicit user override (thread.settle / | 230 | * override. Past the blockers, the explicit user override (thread.settle / |
| 223 | * thread.unsettle commands, projected into settledOverride + settledAt) | 231 | * thread.unsettle commands, projected into settledOverride + settledAt) |
| 224 | * wins in both directions; without one, a thread auto-settles on a | 232 | * wins in both directions; without one, a thread can auto-settle on a |
| 225 | * merged/closed PR immediately or on inactivity past the window — except | 233 | * merged PR, always settles on a closed PR, or settles on inactivity past |
| 226 | * that an open PR blocks the inactivity path entirely. The server | 234 | * the window. An open PR blocks the inactivity path entirely. The server |
| 227 | * un-settles on real activity (user message, session start, approval/ | 235 | * un-settles on real activity (user message, session start, approval/ |
| 228 | * user-input request), so an override never goes stale silently. | 236 | * user-input request), so an override never goes stale silently. |
| 229 | */ | 237 | */ |
| ... | @@ -232,6 +240,7 @@ export function effectiveSettled( | ... | @@ -232,6 +240,7 @@ export function effectiveSettled( |
| 232 | options: { | 240 | options: { |
| 233 | readonly now: string; | 241 | readonly now: string; |
| 234 | readonly autoSettleAfterDays: number | null; | 242 | readonly autoSettleAfterDays: number | null; |
| 243 | readonly autoSettleOnMerge?: boolean; | ||
| 235 | readonly changeRequestState?: ChangeRequestStateLike | null; | 244 | readonly changeRequestState?: ChangeRequestStateLike | null; |
| 236 | }, | 245 | }, |
| 237 | ): boolean { | 246 | ): boolean { |
| ... | @@ -258,13 +267,13 @@ export function effectiveSettled( | ... | @@ -258,13 +267,13 @@ export function effectiveSettled( |
| 258 | // "active" is the explicit keep-active pin: it suppresses auto-settle | 267 | // "active" is the explicit keep-active pin: it suppresses auto-settle |
| 259 | // until real activity clears it server-side. | 268 | // until real activity clears it server-side. |
| 260 | if (shell.settledOverride === "active") return false; | 269 | if (shell.settledOverride === "active") return false; |
| 261 | if (options.changeRequestState === "merged" || options.changeRequestState === "closed") { | 270 | if (changeRequestAutoSettles(options.changeRequestState, options.autoSettleOnMerge !== false)) { |
| 262 | return true; | 271 | return true; |
| 263 | } | 272 | } |
| 264 | // An open PR is unfinished business regardless of how long the thread has | 273 | // An open PR is unfinished business regardless of how long the thread has |
| 265 | // been quiet: review can take days, and hiding the thread would bury the | 274 | // been quiet: review can take days, and hiding the thread would bury the |
| 266 | // work waiting on it. Only merge/close (above) or an explicit user settle | 275 | // work waiting on it. A configured merge, a close, or an explicit user |
| 267 | // resolves it. | 276 | // settle resolves it. |
| 268 | if (options.changeRequestState === "open") return false; | 277 | if (options.changeRequestState === "open") return false; |
| 269 | if (options.autoSettleAfterDays === null) return false; | 278 | if (options.autoSettleAfterDays === null) return false; |
| 270 | 279 |
packages/contracts/src/settings.test.ts+11-1| ... | @@ -68,10 +68,11 @@ describe("ClientSettings environment identification", () => { | ... | @@ -68,10 +68,11 @@ describe("ClientSettings environment identification", () => { |
| 68 | }); | 68 | }); |
| 69 | 69 | ||
| 70 | describe("ClientSettings sidebar", () => { | 70 | describe("ClientSettings sidebar", () => { |
| 71 | it("defaults to the current sidebar with a three-day auto-settle threshold", () => { | 71 | it("defaults to the current sidebar with automatic merge and inactivity settling", () => { |
| 72 | const settings = decodeClientSettings({}); | 72 | const settings = decodeClientSettings({}); |
| 73 | expect(settings.legacySidebarEnabled).toBe(false); | 73 | expect(settings.legacySidebarEnabled).toBe(false); |
| 74 | expect(settings.sidebarAutoSettleAfterDays).toBe(3); | 74 | expect(settings.sidebarAutoSettleAfterDays).toBe(3); |
| 75 | expect(settings.sidebarAutoSettleOnMerge).toBe(true); | ||
| 75 | }); | 76 | }); |
| 76 | 77 | ||
| 77 | it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { | 78 | it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { |
| ... | @@ -97,6 +98,15 @@ describe("ClientSettings sidebar", () => { | ... | @@ -97,6 +98,15 @@ describe("ClientSettings sidebar", () => { |
| 97 | ).toBeNull(); | 98 | ).toBeNull(); |
| 98 | }); | 99 | }); |
| 99 | 100 | ||
| 101 | it("allows auto-settle on merge to be disabled", () => { | ||
| 102 | expect(decodeClientSettings({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge).toBe( | ||
| 103 | false, | ||
| 104 | ); | ||
| 105 | expect( | ||
| 106 | decodeClientSettingsPatch({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge, | ||
| 107 | ).toBe(false); | ||
| 108 | }); | ||
| 109 | |||
| 100 | it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => { | 110 | it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => { |
| 101 | expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow(); | 111 | expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow(); |
| 102 | expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); | 112 | expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); |
packages/contracts/src/settings.ts+2| ... | @@ -180,6 +180,7 @@ export const ClientSettingsSchema = Schema.Struct({ | ... | @@ -180,6 +180,7 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 180 | sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( | 180 | sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( |
| 181 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), | 181 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), |
| 182 | ), | 182 | ), |
| 183 | sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | ||
| 183 | sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( | 184 | sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( |
| 184 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), | 185 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), |
| 185 | ), | 186 | ), |
| ... | @@ -793,6 +794,7 @@ export const ClientSettingsPatch = Schema.Struct({ | ... | @@ -793,6 +794,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 793 | planModeEnabled: Schema.optionalKey(Schema.Boolean), | 794 | planModeEnabled: Schema.optionalKey(Schema.Boolean), |
| 794 | legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), | 795 | legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), |
| 795 | sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), | 796 | sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), |
| 797 | sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), | ||
| 796 | sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), | 798 | sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), |
| 797 | sidebarProjectGroupingOverrides: Schema.optionalKey( | 799 | sidebarProjectGroupingOverrides: Schema.optionalKey( |
| 798 | Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), | 800 | Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), |