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