authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-13 19:18:15-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-13 16:18:15-07:00
logdb1507e986591ae8e82f8fa1e173a9013309c64e
tree2f7dcda908c0066a6d871e523b5d7c56a8c08723
parent96bfa67b3d05d8adb2fb23d1017bd10fe4b9a338
signature Signed by PGP key B5690EEEBB952194

feat: allow disabling auto-settle on merge (#5880)


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), so486 // Settled threads stay in the live shell stream (settled ≠ archived), so
484 // the partition works directly off live shells — no snapshot merging or487 // 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 or489 // 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}
523523
524function GeneralSettingsSection() {524function 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 {
10import { LegendList } from "@legendapp/list/react-native";10import { LegendList } from "@legendapp/list/react-native";
11import type { MenuAction } from "@react-native-menu/menu";11import type { MenuAction } from "@react-native-menu/menu";
12import { useAtomValue } from "@effect/atom-react";12import { useAtomValue } from "@effect/atom-react";
13import { AsyncResult } from "effect/unstable/reactivity";
13import type { EnvironmentId } from "@t3tools/contracts";14import type { EnvironmentId } from "@t3tools/contracts";
14import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";15import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
15import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";16import { 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";
29import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";30import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
30import { useThemeColor } from "../../lib/useThemeColor";31import { useThemeColor } from "../../lib/useThemeColor";
31import { useProjects, useThreadShells } from "../../state/entities";32import { useProjects, useThreadShells } from "../../state/entities";
33import { mobilePreferencesAtom } from "../../state/preferences";
32import { useThreadSearch } from "../../state/queries";34import { useThreadSearch } from "../../state/queries";
33import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";35import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
34import { environmentServerConfigsAtom } from "../../state/server";36import { 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(
411417
412 // Thread List v2 (beta) support — same model as the compact Home list418 // 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 thread420 // 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-settle372 /** 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});
264264
265describe("buildThreadListV2Items", () => {265describe("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: {
306306
307/**307/**
308 * Partitions visible threads into the active card block (creation order) and308 * 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, so310 * auto-settle preferences per device.
311 * the default is fixed here rather than user-configurable.
312 */311 */
313export function buildThreadListV2Items(input: {312export 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.projectRefs354 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 has31 * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has
31 * no client-settings sync, so the legacy grouped thread list is opted into32 * 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 } = {};
9092
...@@ -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";
29import {29import {
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 visit4146 // 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 no4147 // 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 effect4156 // 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-
19import { CSS } from "@dnd-kit/utilities";19import { CSS } from "@dnd-kit/utilities";
20import {20import {
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 quick664 // 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 is767 // 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 a769 // reading a completion-triggered wake, clicking the pill, sending a
768 // message, settling, archiving — or finishing the work outright (merged770 // message, settling, archiving, or a change request state that settles the
769 // or closed PR). Timer wakes survive a mere visit. An unparseable visit771 // thread. Timer wakes survive a mere visit. An unparseable visit timestamp
770 // timestamp counts as never-visited — corrupt local data must not eat772 // 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 for780 // 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, and781 // 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);
18031804
1804 // PR states stream in per-row (rows own the VCS subscriptions); a merged or1805 // 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.capabilities3539 serverConfigs.get(thread.environmentId)?.environment.capabilities
3533 .threadSettlement === true3540 .threadSettlement === true
3534 }3541 }
3542 autoSettleOnMerge={autoSettleOnMerge}
3535 snoozeSupported={3543 snoozeSupported={
3536 serverConfigs.get(thread.environmentId)?.environment.capabilities3544 serverConfigs.get(thread.environmentId)?.environment.capabilities
3537 .threadSnooze === true3545 .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.sidebarAutoSettleAfterDays493 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.diffIgnoreWhitespace501 ...(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 />
18301835
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 <SettingsRow1863 <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";
99
10import {10import {
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";
19const FRESH = "2026-04-09T00:00:00.000Z";20const FRESH = "2026-04-09T00:00:00.000Z";
20const STALE = "2026-04-06T23:59:59.999Z";21const STALE = "2026-04-06T23:59:59.999Z";
2122
23describe("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
22function makeShell(input: {35function 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 });
180193
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";
33
4export type ChangeRequestStateLike = "open" | "closed" | "merged";4export type ChangeRequestStateLike = "open" | "closed" | "merged";
55
6/** Returns whether the change request state settles the thread immediately. */
7export function changeRequestAutoSettles(
8 state: ChangeRequestStateLike | null | undefined,
9 autoSettleOnMerge = true,
10): boolean {
11 return state === "closed" || (state === "merged" && autoSettleOnMerge);
12}
13
6const DAY_MS = 24 * 60 * 60 * 1_000;14const DAY_MS = 24 * 60 * 60 * 1_000;
715
8export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null {16export 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 any229 * 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 a232 * wins in both directions; without one, a thread can auto-settle on a
225 * merged/closed PR immediately or on inactivity past the window — except233 * merged PR, always settles on a closed PR, or settles on inactivity past
226 * that an open PR blocks the inactivity path entirely. The server234 * 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-settle267 // "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 has273 // 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 the274 // 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 settle275 // 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;
270279
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});
6969
70describe("ClientSettings sidebar", () => {70describe("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 });
7677
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 });
99100
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),