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 = {
3232 planModeEnabled: false,
3333 providerModelPreferences: {},
3434 sidebarAutoSettleAfterDays: 3,
35 sidebarAutoSettleOnMerge: true,
3536 sidebarProjectGroupingMode: "repository_path",
3637 sidebarProjectGroupingOverrides: {
3738 "environment-1:/tmp/project-a": "separate",
apps/mobile/src/features/home/HomeScreen.tsx+7-2
......@@ -207,6 +207,9 @@ export function HomeScreen(props: HomeScreenProps) {
207207 >(() => new Map());
208208 const preferencesResult = useAtomValue(mobilePreferencesAtom);
209209 const threadListV2Enabled = useThreadListV2Enabled();
210 const autoSettleOnMerge =
211 !AsyncResult.isSuccess(preferencesResult) ||
212 preferencesResult.value.autoSettleOnMerge !== false;
210213 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
211214 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
212215 const listRef = useRef<LegendListRef | null>(null);
......@@ -483,8 +486,8 @@ export function HomeScreen(props: HomeScreenProps) {
483486 // Settled threads stay in the live shell stream (settled ≠ archived), so
484487 // the partition works directly off live shells — no snapshot merging or
485488 // 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.
488491 const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
489492 ReadonlyMap<string, "open" | "closed" | "merged">
490493 >(() => new Map());
......@@ -665,6 +668,7 @@ export function HomeScreen(props: HomeScreenProps) {
665668 searchQuery: props.searchQuery,
666669 matchedThreadKeys,
667670 changeRequestStateByKey,
671 autoSettleOnMerge,
668672 settlementEnvironmentIds,
669673 snoozeEnvironmentIds,
670674 settledLimit: settledVisibleCount,
......@@ -676,6 +680,7 @@ export function HomeScreen(props: HomeScreenProps) {
676680 });
677681 }, [
678682 changeRequestStateByKey,
683 autoSettleOnMerge,
679684 nowMinute,
680685 snoozeWakeTick,
681686 snoozedShelfExpanded,
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+12
......@@ -522,9 +522,21 @@ function ConfiguredSettingsRouteScreen() {
522522}
523523
524524function GeneralSettingsSection() {
525 const preferencesResult = useAtomValue(mobilePreferencesAtom);
526 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
527 const autoSettleOnMerge =
528 !AsyncResult.isSuccess(preferencesResult) ||
529 preferencesResult.value.autoSettleOnMerge !== false;
530
525531 return (
526532 <SettingsSection title="General">
527533 <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 />
528540 <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
529541 </SettingsSection>
530542 );
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+10-2
......@@ -10,6 +10,7 @@ import {
1010import { LegendList } from "@legendapp/list/react-native";
1111import type { MenuAction } from "@react-native-menu/menu";
1212import { useAtomValue } from "@effect/atom-react";
13import { AsyncResult } from "effect/unstable/reactivity";
1314import type { EnvironmentId } from "@t3tools/contracts";
1415import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
1516import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
......@@ -29,6 +30,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
2930import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
3031import { useThemeColor } from "../../lib/useThemeColor";
3132import { useProjects, useThreadShells } from "../../state/entities";
33import { mobilePreferencesAtom } from "../../state/preferences";
3234import { useThreadSearch } from "../../state/queries";
3335import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
3436import { environmentServerConfigsAtom } from "../../state/server";
......@@ -214,6 +216,10 @@ function ThreadNavigationSidebarPane(
214216 regenerateThreadTitle,
215217 } = useThreadListActions();
216218 const threadListV2Enabled = useThreadListV2Enabled();
219 const preferencesResult = useAtomValue(mobilePreferencesAtom);
220 const autoSettleOnMerge =
221 !AsyncResult.isSuccess(preferencesResult) ||
222 preferencesResult.value.autoSettleOnMerge !== false;
217223 const pendingTasks = usePendingNewTasks();
218224 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
219225 const environments = useMemo(
......@@ -411,8 +417,8 @@ function ThreadNavigationSidebarPane(
411417
412418 // Thread List v2 (beta) support — same model as the compact Home list
413419 // (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.
416422 const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
417423 ReadonlyMap<string, "open" | "closed" | "merged">
418424 >(() => new Map());
......@@ -546,6 +552,7 @@ function ThreadNavigationSidebarPane(
546552 searchQuery: props.searchQuery,
547553 matchedThreadKeys,
548554 changeRequestStateByKey,
555 autoSettleOnMerge,
549556 settlementEnvironmentIds,
550557 snoozeEnvironmentIds,
551558 settledLimit: settledVisibleCount,
......@@ -557,6 +564,7 @@ function ThreadNavigationSidebarPane(
557564 });
558565 }, [
559566 changeRequestStateByKey,
567 autoSettleOnMerge,
560568 nowMinute,
561569 snoozeWakeTick,
562570 snoozedShelfExpanded,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+2-2
......@@ -369,8 +369,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
369369 readonly canMovePinnedDown?: boolean;
370370 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
371371 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. */
374374 readonly onChangeRequestState?: (
375375 threadKey: string,
376376 state: "open" | "closed" | "merged" | null,
apps/mobile/src/features/threads/threadListV2.test.ts+15
......@@ -263,6 +263,21 @@ describe("sortThreadsForListV2", () => {
263263});
264264
265265describe("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
266281 it("hides snoozed threads and counts them — visibility parity with web", () => {
267282 const layout = buildThreadListV2Items({
268283 threads: [
apps/mobile/src/features/threads/threadListV2.ts+10-4
......@@ -306,9 +306,8 @@ export function buildThreadListV2ListItems(input: {
306306
307307/**
308308 * 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.
312311 */
313312export function buildThreadListV2Items(input: {
314313 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
......@@ -329,6 +328,7 @@ export function buildThreadListV2Items(input: {
329328 contract as settlementEnvironmentIds. */
330329 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
331330 readonly autoSettleAfterDays?: number;
331 readonly autoSettleOnMerge?: boolean;
332332 /** Max settled rows to render; the rest are counted, not built. */
333333 readonly settledLimit?: number;
334334 /** Injectable for tests; defaults to now. */
......@@ -349,6 +349,7 @@ export function buildThreadListV2Items(input: {
349349 const now = input.now ?? new Date().toISOString();
350350 const snoozeNow = input.snoozeNow ?? now;
351351 const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
352 const autoSettleOnMerge = input.autoSettleOnMerge ?? true;
352353 const query = input.searchQuery.trim().toLocaleLowerCase();
353354 const projectKeys = input.projectRefs
354355 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
......@@ -405,7 +406,12 @@ export function buildThreadListV2Items(input: {
405406 }
406407 if (
407408 supportsSettlement &&
408 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
409 effectiveSettled(thread, {
410 now,
411 autoSettleAfterDays,
412 autoSettleOnMerge,
413 changeRequestState,
414 })
409415 ) {
410416 settled.push(thread);
411417 } else {
apps/mobile/src/persistence/mobile-preferences.ts+5
......@@ -26,6 +26,7 @@ export interface Preferences {
2626 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
2727 readonly projectGroupingEnabled?: boolean;
2828 readonly projectGroupingMode?: SidebarProjectGroupingMode;
29 readonly autoSettleOnMerge?: boolean;
2930 /**
3031 * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has
3132 * no client-settings sync, so the legacy grouped thread list is opted into
......@@ -85,6 +86,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
8586 collapsedProjectGroups?: readonly string[];
8687 projectGroupingEnabled?: boolean;
8788 projectGroupingMode?: SidebarProjectGroupingMode;
89 autoSettleOnMerge?: boolean;
8890 legacyThreadListEnabled?: boolean;
8991 } = {};
9092
......@@ -122,6 +124,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
122124 ) {
123125 preferences.projectGroupingMode = parsed.projectGroupingMode;
124126 }
127 if (typeof parsed.autoSettleOnMerge === "boolean") {
128 preferences.autoSettleOnMerge = parsed.autoSettleOnMerge;
129 }
125130 if (typeof parsed.legacyThreadListEnabled === "boolean") {
126131 preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
127132 }
apps/web/src/components/ChatView.tsx+8-4
......@@ -27,6 +27,7 @@ import {
2727 type EnvironmentConnectionPresentation,
2828} from "@t3tools/client-runtime/connection";
2929import {
30 changeRequestAutoSettles,
3031 effectiveSettled,
3132 effectiveSnoozed,
3233 threadWokeAt,
......@@ -4101,6 +4102,7 @@ function ChatViewContent(props: ChatViewProps) {
41014102 // so the banner and the sidebar row never disagree.
41024103 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
41034104 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
4105 const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);
41044106 const activeThreadPr = resolveThreadPr({
41054107 threadBranch: activeThread?.branch ?? null,
41064108 gitStatus: gitStatusQuery.data ?? null,
......@@ -4141,15 +4143,14 @@ function ChatViewContent(props: ChatViewProps) {
41414143 if (activeThreadRef === null || activeThreadWokeAt === null) return;
41424144 markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
41434145 }, [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.
41474148 const activeThreadLastVisitedAt = useUiStateStore((store) =>
41484149 activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey],
41494150 );
41504151 const activeThreadWokeVisible = useMemo(() => {
41514152 if (activeThreadWokeAt === null) return false;
4152 if (activeThreadPr?.state === "merged" || activeThreadPr?.state === "closed") return false;
4153 if (changeRequestAutoSettles(activeThreadPr?.state, autoSettleOnMerge)) return false;
41534154 const wokeAtMs = Date.parse(activeThreadWokeAt);
41544155 if (Number.isNaN(wokeAtMs)) return false;
41554156 // Having the thread open counts as a visit at completedAt (the effect
......@@ -4171,18 +4172,21 @@ function ChatViewContent(props: ChatViewProps) {
41714172 activeThreadLastVisitedAt,
41724173 activeThreadPr?.state,
41734174 activeThreadWokeAt,
4175 autoSettleOnMerge,
41744176 ]);
41754177 const activeThreadSettled = useMemo(() => {
41764178 if (activeThreadShell === null || !supportsSettlement) return false;
41774179 return effectiveSettled(activeThreadShell, {
41784180 now: `${nowMinute}:00.000Z`,
41794181 autoSettleAfterDays,
4182 autoSettleOnMerge,
41804183 changeRequestState: activeThreadPr?.state ?? null,
41814184 });
41824185 }, [
41834186 activeThreadPr?.state,
41844187 activeThreadShell,
41854188 autoSettleAfterDays,
4189 autoSettleOnMerge,
41864190 nowMinute,
41874191 supportsSettlement,
41884192 ]);
apps/web/src/components/Sidebar.tsx+19-11
......@@ -19,6 +19,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-
1919import { CSS } from "@dnd-kit/utilities";
2020import {
2121 canSnooze,
22 changeRequestAutoSettles,
2223 effectiveSettled,
2324 effectiveSnoozed,
2425 threadWokeAt,
......@@ -657,6 +658,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
657658 // False on environments whose server predates thread.settle/unsettle:
658659 // the lifecycle affordances hide entirely rather than fail on click.
659660 settlementSupported: boolean;
661 autoSettleOnMerge: boolean;
660662 // Same contract for thread.snooze/unsnooze.
661663 snoozeSupported: boolean;
662664 // Renders the pin glyph. Pinned cards keep the full settle/snooze quick
......@@ -765,17 +767,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
765767 // deliberately static), so the pill has to carry the weight. Snoozing is
766768 // an explicit act, so the pill clears only when the user re-engages:
767769 // 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.
772773 const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt);
773774 const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt);
774775 const isWoke =
775776 wokeAtDate !== null &&
776777 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
777 prState !== "merged" &&
778 prState !== "closed";
778 !changeRequestAutoSettles(prState, props.autoSettleOnMerge);
779779 // In-flight rows (working, or waiting on approval/input) fade as a whole:
780780 // there is nothing for the user to do yet, so prominence is reserved for
781781 // rows that need a human — done (unread), read-but-unsettled, failed, and
......@@ -851,8 +851,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
851851 });
852852 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
853853 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.
856856 useEffect(() => {
857857 onChangeRequestState(threadKey, prState);
858858 }, [onChangeRequestState, prState, threadKey]);
......@@ -1599,6 +1599,7 @@ export default function Sidebar() {
15991599 const { isMobile, setOpenMobile } = useSidebar();
16001600 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
16011601 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
1602 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
16021603 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
16031604 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
16041605 const timestampFormat = useClientSettings((s) => s.timestampFormat);
......@@ -1801,8 +1802,8 @@ export default function Sidebar() {
18011802 // fresh clock whenever it recomputes.
18021803 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
18031804
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.
18061807 const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
18071808 ReadonlyMap<string, "open" | "closed" | "merged">
18081809 >(() => new Map());
......@@ -1953,7 +1954,12 @@ export default function Sidebar() {
19531954 pinned.push(thread);
19541955 } else if (
19551956 supportsSettlement &&
1956 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
1957 effectiveSettled(thread, {
1958 now,
1959 autoSettleAfterDays,
1960 autoSettleOnMerge,
1961 changeRequestState,
1962 })
19571963 ) {
19581964 settled.push(thread);
19591965 } else {
......@@ -1988,6 +1994,7 @@ export default function Sidebar() {
19881994 };
19891995 }, [
19901996 autoSettleAfterDays,
1997 autoSettleOnMerge,
19911998 changeRequestStateByKey,
19921999 nowMinute,
19932000 scopedProjectKeys,
......@@ -3532,6 +3539,7 @@ export default function Sidebar() {
35323539 serverConfigs.get(thread.environmentId)?.environment.capabilities
35333540 .threadSettlement === true
35343541 }
3542 autoSettleOnMerge={autoSettleOnMerge}
35353543 snoozeSupported={
35363544 serverConfigs.get(thread.environmentId)?.environment.capabilities
35373545 .threadSnooze === true
apps/web/src/components/settings/SettingsPanels.tsx+33-1
......@@ -493,6 +493,9 @@ export function useSettingsRestore(onRestored?: () => void) {
493493 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays
494494 ? ["Auto-settle inactive threads"]
495495 : []),
496 ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge
497 ? ["Auto-settle merged threads"]
498 : []),
496499 ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
497500 ...getChangedTypographySettingLabels(settings),
498501 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
......@@ -547,6 +550,7 @@ export function useSettingsRestore(onRestored?: () => void) {
547550 settings.enableLegacyTokenStreaming,
548551 settings.enableProviderUpdateChecks,
549552 settings.sidebarAutoSettleAfterDays,
553 settings.sidebarAutoSettleOnMerge,
550554 settings.sidebarProjectGroupingMode,
551555 settings.sidebarThreadPreviewCount,
552556 settings.timestampFormat,
......@@ -628,6 +632,7 @@ export function useSettingsRestore(onRestored?: () => void) {
628632 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
629633 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
630634 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
635 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
631636 enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming,
632637 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
633638 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
......@@ -1828,9 +1833,36 @@ export function GeneralSettingsPanel() {
18281833 }
18291834 />
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
18311863 <SettingsRow
18321864 {...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."
18341866 resetAction={
18351867 settings.sidebarAutoSettleAfterDays !==
18361868 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? (
apps/web/src/components/settings/settingsSearch.ts+5
......@@ -103,6 +103,11 @@ export const SETTINGS_SEARCH_ITEMS = [
103103 title: "Auto-settle inactive threads",
104104 to: "/settings/general",
105105 },
106 {
107 id: "auto-settle-merged-threads",
108 title: "Auto-settle merged threads",
109 to: "/settings/general",
110 },
106111 {
107112 id: "time-format",
108113 title: "Time format",
apps/web/src/hooks/useThreadActionMenu.ts+3
......@@ -80,6 +80,7 @@ export function useThreadActionMenu(input: {
8080 const handleNewThread = useNewThreadHandler();
8181 const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
8282 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
83 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
8384 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
8485 const timestampFormat = useClientSettings((s) => s.timestampFormat);
8586 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
......@@ -132,6 +133,7 @@ export function useThreadActionMenu(input: {
132133 // parked-thread banner within the same minute.
133134 now: `${now.toISOString().slice(0, 16)}:00.000Z`,
134135 autoSettleAfterDays,
136 autoSettleOnMerge,
135137 changeRequestState,
136138 }),
137139 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
......@@ -284,6 +286,7 @@ export function useThreadActionMenu(input: {
284286 },
285287 [
286288 autoSettleAfterDays,
289 autoSettleOnMerge,
287290 changeRequestState,
288291 confirmThreadDelete,
289292 copyBranchToClipboard,
packages/client-runtime/src/state/threadSettled.test.ts+34
......@@ -9,6 +9,7 @@ import { describe, expect, it } from "vite-plus/test";
99
1010import {
1111 canSettle,
12 changeRequestAutoSettles,
1213 effectiveSettled,
1314 hasQueuedTurnStart,
1415 threadLastActivityAt,
......@@ -19,6 +20,18 @@ const NOW = "2026-04-10T00:00:00.000Z";
1920const FRESH = "2026-04-09T00:00:00.000Z";
2021const 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
2235function makeShell(input: {
2336 readonly settledOverride?: "settled" | "active" | null;
2437 readonly activityAt: string | null;
......@@ -178,6 +191,27 @@ describe("effectiveSettled", () => {
178191 }
179192 });
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
181215 it("never auto-settles a stale thread with an open change request", () => {
182216 const stale = makeShell({ activityAt: STALE });
183217 expect(
packages/client-runtime/src/state/threadSettled.ts+15-6
......@@ -3,6 +3,14 @@ import type { OrchestrationThreadShell } from "@t3tools/contracts";
33
44export 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
614const DAY_MS = 24 * 60 * 60 * 1_000;
715
816export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null {
......@@ -221,9 +229,9 @@ export function threadWokeAt(
221229 * queued turn) are checked first and hold a thread active regardless of any
222230 * override. Past the blockers, the explicit user override (thread.settle /
223231 * 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
227235 * un-settles on real activity (user message, session start, approval/
228236 * user-input request), so an override never goes stale silently.
229237 */
......@@ -232,6 +240,7 @@ export function effectiveSettled(
232240 options: {
233241 readonly now: string;
234242 readonly autoSettleAfterDays: number | null;
243 readonly autoSettleOnMerge?: boolean;
235244 readonly changeRequestState?: ChangeRequestStateLike | null;
236245 },
237246): boolean {
......@@ -258,13 +267,13 @@ export function effectiveSettled(
258267 // "active" is the explicit keep-active pin: it suppresses auto-settle
259268 // until real activity clears it server-side.
260269 if (shell.settledOverride === "active") return false;
261 if (options.changeRequestState === "merged" || options.changeRequestState === "closed") {
270 if (changeRequestAutoSettles(options.changeRequestState, options.autoSettleOnMerge !== false)) {
262271 return true;
263272 }
264273 // An open PR is unfinished business regardless of how long the thread has
265274 // 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.
268277 if (options.changeRequestState === "open") return false;
269278 if (options.autoSettleAfterDays === null) return false;
270279
packages/contracts/src/settings.test.ts+11-1
......@@ -68,10 +68,11 @@ describe("ClientSettings environment identification", () => {
6868});
6969
7070describe("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", () => {
7272 const settings = decodeClientSettings({});
7373 expect(settings.legacySidebarEnabled).toBe(false);
7474 expect(settings.sidebarAutoSettleAfterDays).toBe(3);
75 expect(settings.sidebarAutoSettleOnMerge).toBe(true);
7576 });
7677
7778 it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => {
......@@ -97,6 +98,15 @@ describe("ClientSettings sidebar", () => {
9798 ).toBeNull();
9899 });
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
100110 it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => {
101111 expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow();
102112 expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow();
packages/contracts/src/settings.ts+2
......@@ -180,6 +180,7 @@ export const ClientSettingsSchema = Schema.Struct({
180180 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
181181 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
182182 ),
183 sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
183184 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
184185 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
185186 ),
......@@ -793,6 +794,7 @@ export const ClientSettingsPatch = Schema.Struct({
793794 planModeEnabled: Schema.optionalKey(Schema.Boolean),
794795 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
795796 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
797 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
796798 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
797799 sidebarProjectGroupingOverrides: Schema.optionalKey(
798800 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),