From db1507e986591ae8e82f8fa1e173a9013309c64e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 13 Aug 2026 19:18:15 -0400 Subject: [PATCH] feat: allow disabling auto-settle on merge (#5880) --- .../settings/DesktopClientSettings.test.ts | 1 + apps/mobile/src/features/home/HomeScreen.tsx | 9 +++-- .../features/settings/SettingsRouteScreen.tsx | 12 +++++++ .../threads/ThreadNavigationSidebar.tsx | 12 +++++-- .../features/threads/thread-list-v2-items.tsx | 4 +-- .../src/features/threads/threadListV2.test.ts | 15 ++++++++ .../src/features/threads/threadListV2.ts | 14 +++++--- .../src/persistence/mobile-preferences.ts | 5 +++ apps/web/src/components/ChatView.tsx | 12 ++++--- apps/web/src/components/Sidebar.tsx | 30 ++++++++++------ .../components/settings/SettingsPanels.tsx | 34 ++++++++++++++++++- .../src/components/settings/settingsSearch.ts | 5 +++ apps/web/src/hooks/useThreadActionMenu.ts | 3 ++ .../src/state/threadSettled.test.ts | 34 +++++++++++++++++++ .../client-runtime/src/state/threadSettled.ts | 21 ++++++++---- packages/contracts/src/settings.test.ts | 12 ++++++- packages/contracts/src/settings.ts | 2 ++ 17 files changed, 192 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 861f72178a68f5eb99bb15d8a87bedca49912d68..44c12cc554ad63d5a117ea4e27a9b0d38b16ca57 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -32,6 +32,7 @@ const clientSettings: ClientSettings = { planModeEnabled: false, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, + sidebarAutoSettleOnMerge: true, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 6c364787301c676e27e6a1b5aada94a9810b3509..60cb1b475569038643f91035da6fd2727b684e05 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -207,6 +207,9 @@ export function HomeScreen(props: HomeScreenProps) { >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); + const autoSettleOnMerge = + !AsyncResult.isSuccess(preferencesResult) || + preferencesResult.value.autoSettleOnMerge !== false; const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -483,8 +486,8 @@ export function HomeScreen(props: HomeScreenProps) { // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells — no snapshot merging or // optimistic holds. - // PR states stream in per-row (rows own the VCS subscriptions); a merged or - // closed PR auto-settles its thread on the next partition (mirrors web). + // PR states stream in per-row. The next partition applies the configured + // merge rule and the always-on close rule, matching web. const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< ReadonlyMap >(() => new Map()); @@ -665,6 +668,7 @@ export function HomeScreen(props: HomeScreenProps) { searchQuery: props.searchQuery, matchedThreadKeys, changeRequestStateByKey, + autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, @@ -676,6 +680,7 @@ export function HomeScreen(props: HomeScreenProps) { }); }, [ changeRequestStateByKey, + autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 4fb4b1a97a5ada8f2f5449436364ff4289c2eeaf..c718558a2e663032ed0c6b106dc00ebf942e0c86 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -522,9 +522,21 @@ function ConfiguredSettingsRouteScreen() { } function GeneralSettingsSection() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const autoSettleOnMerge = + !AsyncResult.isSuccess(preferencesResult) || + preferencesResult.value.autoSettleOnMerge !== false; + return ( + savePreferences({ autoSettleOnMerge: value })} + /> ); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index b03ba9468d9661b5cf68341c0e35a50a5b5137ee..12e974fe830cbbcaebb6641413a93a7388ff3ec4 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -10,6 +10,7 @@ import { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; @@ -29,6 +30,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; +import { mobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; @@ -214,6 +216,10 @@ function ThreadNavigationSidebarPane( regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const autoSettleOnMerge = + !AsyncResult.isSuccess(preferencesResult) || + preferencesResult.value.autoSettleOnMerge !== false; const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -411,8 +417,8 @@ function ThreadNavigationSidebarPane( // Thread List v2 (beta) support — same model as the compact Home list // (HomeScreen.tsx): flat creation-order card block + settled recency tail. - // PR states stream in per-row; merged/closed PRs auto-settle their thread - // on the next partition. + // PR states stream in per-row. The next partition applies the configured + // merge rule and the always-on close rule. const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< ReadonlyMap >(() => new Map()); @@ -546,6 +552,7 @@ function ThreadNavigationSidebarPane( searchQuery: props.searchQuery, matchedThreadKeys, changeRequestStateByKey, + autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, @@ -557,6 +564,7 @@ function ThreadNavigationSidebarPane( }); }, [ changeRequestStateByKey, + autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index db2e805d0fc39adceb19c04682b1276ef8ff9aa3..1c25f949ed7cd1edd7cdd806144b7481ee06d7c9 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -369,8 +369,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly canMovePinnedDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; - /** Reports this row's live PR state up so the partition can auto-settle - merged/closed work (mirrors web's onChangeRequestState). */ + /** Reports this row's live PR state for the partition's merge and close + rules. Mirrors web's onChangeRequestState. */ readonly onChangeRequestState?: ( threadKey: string, state: "open" | "closed" | "merged" | null, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index a9ea0138b8451fe13728d0ac8e014d55f30ebca0..c4a1a844c7770337a412051a0fbeef8af2d8508a 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -263,6 +263,21 @@ describe("sortThreadsForListV2", () => { }); describe("buildThreadListV2Items", () => { + it("keeps a merged thread active when auto-settle on merge is off", () => { + const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" }); + const layout = buildThreadListV2Items({ + threads: [merged], + environmentId: null, + searchQuery: "", + changeRequestStateByKey: new Map([[`${environmentId}:${merged.id}`, "merged"]]), + autoSettleOnMerge: false, + now: NOW, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["merged"]); + expect(layout.settledCount).toBe(0); + }); + it("hides snoozed threads and counts them — visibility parity with web", () => { const layout = buildThreadListV2Items({ threads: [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index eba56ac8de5e7cbf57a75f3b54b066c8073b2614..53b80e52c4f1b31ebc100f329ebd826ba1e4358d 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -306,9 +306,8 @@ export function buildThreadListV2ListItems(input: { /** * Partitions visible threads into the active card block (creation order) and - * the settled recency tail, matching the web v2 list. `autoSettleAfterDays` - * mirrors the web default of 3 — mobile has no client-settings sync yet, so - * the default is fixed here rather than user-configurable. + * the settled recency tail, matching the web v2 list. Mobile stores these + * auto-settle preferences per device. */ export function buildThreadListV2Items(input: { readonly threads: ReadonlyArray; @@ -329,6 +328,7 @@ export function buildThreadListV2Items(input: { contract as settlementEnvironmentIds. */ readonly snoozeEnvironmentIds?: ReadonlySet; readonly autoSettleAfterDays?: number; + readonly autoSettleOnMerge?: boolean; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; /** Injectable for tests; defaults to now. */ @@ -349,6 +349,7 @@ export function buildThreadListV2Items(input: { const now = input.now ?? new Date().toISOString(); const snoozeNow = input.snoozeNow ?? now; const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; + const autoSettleOnMerge = input.autoSettleOnMerge ?? true; const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) @@ -405,7 +406,12 @@ export function buildThreadListV2Items(input: { } if ( supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + effectiveSettled(thread, { + now, + autoSettleAfterDays, + autoSettleOnMerge, + changeRequestState, + }) ) { settled.push(thread); } else { diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index bf40acb053b7a5cdad922775fa5ca03c041d3a46..b504fb190c6d4911538c97ab24952e498a087608 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -26,6 +26,7 @@ export interface Preferences { /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; + readonly autoSettleOnMerge?: boolean; /** * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has * no client-settings sync, so the legacy grouped thread list is opted into @@ -85,6 +86,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; + autoSettleOnMerge?: boolean; legacyThreadListEnabled?: boolean; } = {}; @@ -122,6 +124,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } + if (typeof parsed.autoSettleOnMerge === "boolean") { + preferences.autoSettleOnMerge = parsed.autoSettleOnMerge; + } if (typeof parsed.legacyThreadListEnabled === "boolean") { preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1f00c177c307fdbdfd3588056f342f9a602e7204..fdc7e7dee382e3da9e24b6fd48ff9b6301e42423 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,6 +27,7 @@ import { type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; import { + changeRequestAutoSettles, effectiveSettled, effectiveSnoozed, threadWokeAt, @@ -4101,6 +4102,7 @@ function ChatViewContent(props: ChatViewProps) { // so the banner and the sidebar row never disagree. const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); + const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); const activeThreadPr = resolveThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, @@ -4141,15 +4143,14 @@ function ChatViewContent(props: ChatViewProps) { if (activeThreadRef === null || activeThreadWokeAt === null) return; markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); - // Mirror of the sidebar's Woke pill for the open thread: same visit - // comparison, same merged/closed-PR suppression (finished work needs no - // wake-up call). Drives the dismissible composer banner below. + // Mirror of the sidebar's Woke pill for the open thread. It uses the same + // visit comparison and change request settle rule. const activeThreadLastVisitedAt = useUiStateStore((store) => activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], ); const activeThreadWokeVisible = useMemo(() => { if (activeThreadWokeAt === null) return false; - if (activeThreadPr?.state === "merged" || activeThreadPr?.state === "closed") return false; + if (changeRequestAutoSettles(activeThreadPr?.state, autoSettleOnMerge)) return false; const wokeAtMs = Date.parse(activeThreadWokeAt); if (Number.isNaN(wokeAtMs)) return false; // Having the thread open counts as a visit at completedAt (the effect @@ -4171,18 +4172,21 @@ function ChatViewContent(props: ChatViewProps) { activeThreadLastVisitedAt, activeThreadPr?.state, activeThreadWokeAt, + autoSettleOnMerge, ]); const activeThreadSettled = useMemo(() => { if (activeThreadShell === null || !supportsSettlement) return false; return effectiveSettled(activeThreadShell, { now: `${nowMinute}:00.000Z`, autoSettleAfterDays, + autoSettleOnMerge, changeRequestState: activeThreadPr?.state ?? null, }); }, [ activeThreadPr?.state, activeThreadShell, autoSettleAfterDays, + autoSettleOnMerge, nowMinute, supportsSettlement, ]); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 31fd7cdbaf9778a25b54268ce254cfeb816f8dcf..6b44479b0cb56f47bdc93ad0fb96becc5603e811 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -19,6 +19,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- import { CSS } from "@dnd-kit/utilities"; import { canSnooze, + changeRequestAutoSettles, effectiveSettled, effectiveSnoozed, threadWokeAt, @@ -657,6 +658,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; + autoSettleOnMerge: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; // Renders the pin glyph. Pinned cards keep the full settle/snooze quick @@ -765,17 +767,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // deliberately static), so the pill has to carry the weight. Snoozing is // an explicit act, so the pill clears only when the user re-engages: // reading a completion-triggered wake, clicking the pill, sending a - // message, settling, archiving — or finishing the work outright (merged - // or closed PR). Timer wakes survive a mere visit. An unparseable visit - // timestamp counts as never-visited — corrupt local data must not eat - // the wake signal. + // message, settling, archiving, or a change request state that settles the + // thread. Timer wakes survive a mere visit. An unparseable visit timestamp + // counts as never-visited, so corrupt local data cannot eat the wake signal. const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && - prState !== "merged" && - prState !== "closed"; + !changeRequestAutoSettles(prState, props.autoSettleOnMerge); // In-flight rows (working, or waiting on approval/input) fade as a whole: // there is nothing for the user to do yet, so prominence is reserved for // rows that need a human — done (unread), read-but-unsettled, failed, and @@ -851,8 +851,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; - // Report the PR state up: the parent partitions rows with effectiveSettled, - // and a merged/closed PR auto-settles a thread — data only rows have. + // Report the PR state so the parent can apply the configured merge rule + // and the always-on close rule during partitioning. useEffect(() => { onChangeRequestState(threadKey, prState); }, [onChangeRequestState, prState, threadKey]); @@ -1599,6 +1599,7 @@ export default function Sidebar() { const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); @@ -1801,8 +1802,8 @@ export default function Sidebar() { // fresh clock whenever it recomputes. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - // PR states stream in per-row (rows own the VCS subscriptions); a merged or - // closed PR auto-settles its thread on the next partition. + // PR states stream in per-row. The next partition applies the configured + // merge rule and the always-on close rule. const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< ReadonlyMap >(() => new Map()); @@ -1953,7 +1954,12 @@ export default function Sidebar() { pinned.push(thread); } else if ( supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + effectiveSettled(thread, { + now, + autoSettleAfterDays, + autoSettleOnMerge, + changeRequestState, + }) ) { settled.push(thread); } else { @@ -1988,6 +1994,7 @@ export default function Sidebar() { }; }, [ autoSettleAfterDays, + autoSettleOnMerge, changeRequestStateByKey, nowMinute, scopedProjectKeys, @@ -3532,6 +3539,7 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSettlement === true } + autoSettleOnMerge={autoSettleOnMerge} snoozeSupported={ serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSnooze === true diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e4cfbe9ac03336d61edf71a177f30dac01604c31..9df7f88ab1dd1905e1df1bf4cd60eabd1c27733c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -493,6 +493,9 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? ["Auto-settle inactive threads"] : []), + ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge + ? ["Auto-settle merged threads"] + : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...getChangedTypographySettingLabels(settings), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace @@ -547,6 +550,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.enableLegacyTokenStreaming, settings.enableProviderUpdateChecks, settings.sidebarAutoSettleAfterDays, + settings.sidebarAutoSettleOnMerge, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.timestampFormat, @@ -628,6 +632,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, + sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, @@ -1828,9 +1833,36 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, + }) + } + /> + ) : null + } + control={ + + updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) + } + aria-label="Auto-settle merged threads" + /> + } + /> + s.markThreadUnread); const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const timestampFormat = useClientSettings((s) => s.timestampFormat); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ @@ -132,6 +133,7 @@ export function useThreadActionMenu(input: { // parked-thread banner within the same minute. now: `${now.toISOString().slice(0, 16)}:00.000Z`, autoSettleAfterDays, + autoSettleOnMerge, changeRequestState, }), isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), @@ -284,6 +286,7 @@ export function useThreadActionMenu(input: { }, [ autoSettleAfterDays, + autoSettleOnMerge, changeRequestState, confirmThreadDelete, copyBranchToClipboard, diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index a7dd4b1eab835ab2a2d065a9df8a96cb8b9e07e2..97f397da3e800f101f2286835345e1d5b04debc6 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from "vite-plus/test"; import { canSettle, + changeRequestAutoSettles, effectiveSettled, hasQueuedTurnStart, threadLastActivityAt, @@ -19,6 +20,18 @@ const NOW = "2026-04-10T00:00:00.000Z"; const FRESH = "2026-04-09T00:00:00.000Z"; const STALE = "2026-04-06T23:59:59.999Z"; +describe("changeRequestAutoSettles", () => { + it.each([ + ["open", true, false], + ["merged", true, true], + ["merged", false, false], + ["closed", false, true], + [null, false, false], + ] as const)("state=%s autoSettleOnMerge=%s returns %s", (state, autoSettleOnMerge, expected) => { + expect(changeRequestAutoSettles(state, autoSettleOnMerge)).toBe(expected); + }); +}); + function makeShell(input: { readonly settledOverride?: "settled" | "active" | null; readonly activityAt: string | null; @@ -178,6 +191,27 @@ describe("effectiveSettled", () => { } }); + it("can keep a merged change request active", () => { + const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); + expect( + effectiveSettled(recentlyActive, { + now: NOW, + autoSettleAfterDays: null, + autoSettleOnMerge: false, + changeRequestState: "merged", + }), + ).toBe(false); + + expect( + effectiveSettled(recentlyActive, { + now: NOW, + autoSettleAfterDays: null, + autoSettleOnMerge: false, + changeRequestState: "closed", + }), + ).toBe(true); + }); + it("never auto-settles a stale thread with an open change request", () => { const stale = makeShell({ activityAt: STALE }); expect( diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index f8002d1c97b97fbc14ee50905d3d419995c10ea8..e2e93f28888925c816506979c8266fa5f2f1889a 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -3,6 +3,14 @@ import type { OrchestrationThreadShell } from "@t3tools/contracts"; export type ChangeRequestStateLike = "open" | "closed" | "merged"; +/** Returns whether the change request state settles the thread immediately. */ +export function changeRequestAutoSettles( + state: ChangeRequestStateLike | null | undefined, + autoSettleOnMerge = true, +): boolean { + return state === "closed" || (state === "merged" && autoSettleOnMerge); +} + const DAY_MS = 24 * 60 * 60 * 1_000; export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null { @@ -221,9 +229,9 @@ export function threadWokeAt( * queued turn) are checked first and hold a thread active regardless of any * override. Past the blockers, the explicit user override (thread.settle / * thread.unsettle commands, projected into settledOverride + settledAt) - * wins in both directions; without one, a thread auto-settles on a - * merged/closed PR immediately or on inactivity past the window — except - * that an open PR blocks the inactivity path entirely. The server + * wins in both directions; without one, a thread can auto-settle on a + * merged PR, always settles on a closed PR, or settles on inactivity past + * the window. An open PR blocks the inactivity path entirely. The server * un-settles on real activity (user message, session start, approval/ * user-input request), so an override never goes stale silently. */ @@ -232,6 +240,7 @@ export function effectiveSettled( options: { readonly now: string; readonly autoSettleAfterDays: number | null; + readonly autoSettleOnMerge?: boolean; readonly changeRequestState?: ChangeRequestStateLike | null; }, ): boolean { @@ -258,13 +267,13 @@ export function effectiveSettled( // "active" is the explicit keep-active pin: it suppresses auto-settle // until real activity clears it server-side. if (shell.settledOverride === "active") return false; - if (options.changeRequestState === "merged" || options.changeRequestState === "closed") { + if (changeRequestAutoSettles(options.changeRequestState, options.autoSettleOnMerge !== false)) { return true; } // An open PR is unfinished business regardless of how long the thread has // been quiet: review can take days, and hiding the thread would bury the - // work waiting on it. Only merge/close (above) or an explicit user settle - // resolves it. + // work waiting on it. A configured merge, a close, or an explicit user + // settle resolves it. if (options.changeRequestState === "open") return false; if (options.autoSettleAfterDays === null) return false; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 46705837afa4a4ab15c53f89adbb99c1dde96b86..570157292b54ede455ba3b63e7491904c20550c2 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -68,10 +68,11 @@ describe("ClientSettings environment identification", () => { }); describe("ClientSettings sidebar", () => { - it("defaults to the current sidebar with a three-day auto-settle threshold", () => { + it("defaults to the current sidebar with automatic merge and inactivity settling", () => { const settings = decodeClientSettings({}); expect(settings.legacySidebarEnabled).toBe(false); expect(settings.sidebarAutoSettleAfterDays).toBe(3); + expect(settings.sidebarAutoSettleOnMerge).toBe(true); }); it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { @@ -97,6 +98,15 @@ describe("ClientSettings sidebar", () => { ).toBeNull(); }); + it("allows auto-settle on merge to be disabled", () => { + expect(decodeClientSettings({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge).toBe( + false, + ); + expect( + decodeClientSettingsPatch({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge, + ).toBe(false); + }); + it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => { expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow(); expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 388205649c85d92663d6d73d7cbc8e44cf140617..ee1970639adff795974ee4d786c18812d28a7b42 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -180,6 +180,7 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), + sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -793,6 +794,7 @@ export const ClientSettingsPatch = Schema.Struct({ planModeEnabled: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), + sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), -- 2.54.0