From 18d7ddc13f90b24f53c07fbd56373a78ed4b4352 Mon Sep 17 00:00:00 2001 From: Cainan Travizanutto E Silva Date: Thu, 29 Oct 2026 18:28:38 -0300 Subject: [PATCH 1/5] feat(settings): add toggle to settle snoozed threads on PR merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snoozed threads were still auto-settled when a linked PR merged despite sidebar snooze state. Default keeps them snoozed; opt in via Settings → Threads. Co-authored-by: Cursor --- .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../ThreadSettlementService.test.ts | 11 +++++++ .../ThreadSettlementService.ts | 31 ++++++++++++++++--- .../components/settings/SettingsPanels.tsx | 27 ++++++++++++++++ .../settings/settingsSearch.test.ts | 1 + .../src/components/settings/settingsSearch.ts | 9 ++++++ packages/contracts/src/environment.ts | 2 ++ packages/contracts/src/settings.ts | 6 ++++ 9 files changed, 84 insertions(+), 5 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 7cebccb2a1f7c..bbc26a7903bee 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -118,6 +118,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { projectWorktreeCleanup: true, worktreesDirectory: true, threadAutoSettlementScope: true, + threadAutoSettleSnoozedOnMerge: true, }); } }), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 72d7f0c2de7ef..613fcafe028b4 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -237,6 +237,7 @@ export const make = Effect.gen(function* () { settledWorktreeCleanup: true, worktreesDirectory: true, threadAutoSettlementScope: true, + threadAutoSettleSnoozedOnMerge: true, threadRestartContinuation: true, projectSettingsOverrides: true, projectProviderInstanceOverrides: true, diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 0a0996c0ede0c..4e0f97e85e999 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -164,6 +164,7 @@ describe("isAutoSettlementCandidate", () => { snoozedAt: at(-60 * 60 * 1_000), }); expect(ThreadSettlementService.isAutoSettlementCandidate(snoozed, NOW_MS)).toBe(false); + expect(ThreadSettlementService.isAutoSettlementCandidate(snoozed, NOW_MS, true)).toBe(true); expect( ThreadSettlementService.isAutoSettlementCandidate( shell({ ...snoozed, status: "failed", latestRunCompletedAt: at(-30 * 60 * 1_000) }), @@ -1450,5 +1451,15 @@ describe("isSnoozed", () => { autoSettleOnMerge: true, }), ).toBeNull(); + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + thread, + pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, + nowMs: NOW_MS, + autoSettleAfterDays: null, + autoSettleOnMerge: true, + autoSettleSnoozedOnMerge: true, + }), + ).toEqual(at(-DAY_MS)); }); }); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 6a3d4fffb0399..77e0d36ee34cd 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -145,6 +145,7 @@ function pullRequestSettles( export function isAutoSettlementCandidate( thread: Omit, nowMs: number, + settleSnoozedOnMerge = false, ): boolean { if (thread.archivedAt !== null || thread.settledOverride !== null) return false; if (thread.pinnedAt != null || thread.autoSettleDisabledAt != null) return false; @@ -155,7 +156,7 @@ export function isAutoSettlementCandidate( if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) return false; if (threadHasQueuedTurnStart(thread, nowMs)) return false; // Interrupted runs must keep the same snooze protection the sidebar shows. - return !isSnoozed(thread, nowMs); + return settleSnoozedOnMerge || !isSnoozed(thread, nowMs); } /** Match client effectiveSnoozed so agents and sidebar agree. */ @@ -191,6 +192,7 @@ export function resolveAutoSettlementAt(input: { readonly autoSettleAfterDays: number | null; readonly autoSettleOnMerge: boolean; readonly autoSettleKeepPullRequests?: boolean; + readonly autoSettleSnoozedOnMerge?: boolean; }): DateTime.Utc | null { const { thread } = input; let pullRequest = input.pullRequest; @@ -214,7 +216,9 @@ export function resolveAutoSettlementAt(input: { closedAt: latest.snapshot.closedAt ?? null, }; } - if (!isAutoSettlementCandidate(thread, input.nowMs)) return null; + const settleSnoozedOnMerge = input.autoSettleOnMerge && input.autoSettleSnoozedOnMerge === true; + if (!isAutoSettlementCandidate(thread, input.nowMs, settleSnoozedOnMerge)) return null; + const snoozed = isSnoozed(thread, input.nowMs); const activityAtMs = latestMillis([ toMillis(thread.latestUserMessageAt), @@ -222,9 +226,14 @@ export function resolveAutoSettlementAt(input: { toMillis(thread.latestRunStartedAt), toMillis(thread.latestRunCompletedAt), ]) ?? DateTime.toEpochMillis(thread.createdAt); - if (pullRequest !== null && pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) { + if ( + pullRequest !== null && + (!snoozed || pullRequest.state === "merged") && + pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge) + ) { return DateTime.makeUnsafe(activityAtMs); } + if (snoozed) return null; const hasPullRequest = hasOpenLink || pullRequest !== null || @@ -269,12 +278,14 @@ export function autoSettlementSettingsKey( settings.sidebarAutoSettleOnMerge, settings.sidebarAutoSettleAfterDays, settings.sidebarAutoSettleKeepPullRequests, + settings.sidebarAutoSettleSnoozedOnMerge, Object.entries(settings.projectSettingsOverrides) .filter( ([, entry]) => entry.sidebarAutoSettleOnMerge !== undefined || entry.sidebarAutoSettleAfterDays !== undefined || - entry.sidebarAutoSettleKeepPullRequests !== undefined, + entry.sidebarAutoSettleKeepPullRequests !== undefined || + entry.sidebarAutoSettleSnoozedOnMerge !== undefined, ) .sort(([left], [right]) => left.localeCompare(right)) .map(([projectId, entry]) => [ @@ -284,6 +295,7 @@ export function autoSettlementSettingsKey( ? "inherit" : entry.sidebarAutoSettleAfterDays, entry.sidebarAutoSettleKeepPullRequests ?? "inherit", + entry.sidebarAutoSettleSnoozedOnMerge ?? "inherit", ]), ]); } @@ -319,7 +331,14 @@ export const make = Effect.gen(function* () { const nowMs = DateTime.toEpochMillis(yield* DateTime.now); const projects = new Map(projectShells.map((project) => [project.id, project])); // Merge events resweep all candidates because unlinked threads settle through branch lookup. - const candidates = threads.filter((thread) => isAutoSettlementCandidate(thread, nowMs)); + const candidates = threads.filter((thread) => { + const { settings: projectSettings } = resolveProjectSettings(settings, thread.projectId); + return isAutoSettlementCandidate( + thread, + nowMs, + projectSettings.sidebarAutoSettleOnMerge && projectSettings.sidebarAutoSettleSnoozedOnMerge, + ); + }); // Runs per candidate thread, thousands per sweep on busy servers; the sweep owns the span. const settleThread = Effect.fnUntraced( @@ -337,6 +356,7 @@ export const make = Effect.gen(function* () { autoSettleAfterDays: currentSettings.sidebarAutoSettleAfterDays, autoSettleOnMerge: currentSettings.sidebarAutoSettleOnMerge, autoSettleKeepPullRequests: currentSettings.sidebarAutoSettleKeepPullRequests, + autoSettleSnoozedOnMerge: currentSettings.sidebarAutoSettleSnoozedOnMerge, }); if (settledAt === null) return thread; const uuid = yield* crypto.randomUUIDv4(); @@ -433,6 +453,7 @@ export const make = Effect.gen(function* () { autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, autoSettleKeepPullRequests: settings.sidebarAutoSettleKeepPullRequests, + autoSettleSnoozedOnMerge: settings.sidebarAutoSettleSnoozedOnMerge, }) !== null ); }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index dca3eabc868aa..e6540c222251d 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -467,6 +467,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? ["Auto-settle merged threads"] : []), + ...(settings.sidebarAutoSettleSnoozedOnMerge !== + DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleSnoozedOnMerge + ? ["Settle snoozed threads on merge"] + : []), ...(settings.autoResumeLimitedThreads !== DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads ? ["Resume after usage limits"] : []), @@ -626,6 +630,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleKeepPullRequests: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleKeepPullRequests, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, + sidebarAutoSettleSnoozedOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleSnoozedOnMerge, autoResumeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads, snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads, snoozePresets: DEFAULT_UNIFIED_SETTINGS.snoozePresets, @@ -1484,6 +1489,10 @@ export function ThreadsSettingsPanel() { const supportsAutoSettlementScope = connectedEnvironments.every( (target) => target.serverConfig?.environment.capabilities.threadAutoSettlementScope === true, ); + const supportsAutoSettleSnoozedOnMerge = connectedEnvironments.every( + (target) => + target.serverConfig?.environment.capabilities.threadAutoSettleSnoozedOnMerge === true, + ); const textGenerationProviders = serverProviders.filter( (provider) => provider.supportsTextGeneration !== false, @@ -1634,6 +1643,24 @@ export function ThreadsSettingsPanel() { /> } /> + {settings.sidebarAutoSettleOnMerge && supportsAutoSettleSnoozedOnMerge ? ( + + updateSettings({ sidebarAutoSettleSnoozedOnMerge: Boolean(checked) }) + } + aria-label="Settle snoozed threads on merge" + /> + } + /> + ) : null} { "auto-settle-inactive-threads", "auto-settle-merged-threads", "days-before-auto-settle", + "settle-snoozed-threads-on-merge", "keep-threads-with-pull-requests", ]); }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index aabfd0300eda5..a80c4da4e0411 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -499,6 +499,15 @@ export const SETTINGS_SEARCH_ITEMS = [ requiresThreadAutoSettlement: true, scope: "project-defaults", }, + { + id: "settle-snoozed-threads-on-merge", + title: "Settle snoozed threads on merge", + to: "/settings/threads", + targetId: "auto-settle-merged-threads", + searchTerms: ["snooze pull request merge auto-settle sidebar wake"], + requiresThreadAutoSettlement: true, + scope: "project-defaults", + }, { id: "keep-threads-with-pull-requests", title: "Keep threads with a PR", diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 9d60bec43fe5c..99caf5b27a96b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -96,6 +96,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ settledWorktreeCleanup: Schema.optionalKey(Schema.Boolean), worktreesDirectory: Schema.optionalKey(Schema.Boolean), threadAutoSettlementScope: Schema.optionalKey(Schema.Boolean), + /** Hide the switch when absent; older servers keep snoozed threads through a merge. */ + threadAutoSettleSnoozedOnMerge: Schema.optionalKey(Schema.Boolean), threadRestartContinuation: Schema.optionalKey(Schema.Boolean), projectSettingsOverrides: Schema.optionalKey(Schema.Boolean), /** Hide the switch when absent; older servers silently drop per-project provider patches. */ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fff25149b2e3d..e10cf2b7c446a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1045,6 +1045,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "sidebarAutoSettleOnMerge", "sidebarAutoSettleAfterDays", "sidebarAutoSettleKeepPullRequests", + "sidebarAutoSettleSnoozedOnMerge", "continueThreadsAfterServerUpdate", "responseStreamingMode", ] as const; @@ -1082,6 +1083,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarAutoSettleKeepPullRequests: Schema.optionalKey(Schema.Boolean), + sidebarAutoSettleSnoozedOnMerge: Schema.optionalKey(Schema.Boolean), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), providerInstances: Schema.optionalKey( @@ -1265,6 +1267,9 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(false)), ), sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + sidebarAutoSettleSnoozedOnMerge: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), backgroundActivity: BackgroundActivitySettings, automaticGitFetchInterval: Schema.DurationFromMillis.pipe( @@ -1612,6 +1617,7 @@ export const ServerSettingsPatch = Schema.Struct({ idleCompaction: Schema.optionalKey(IdleCompactionMode), sidebarAutoSettleKeepPullRequests: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), + sidebarAutoSettleSnoozedOnMerge: Schema.optionalKey(Schema.Boolean), autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean), snoozeLimitedThreads: Schema.optionalKey(Schema.Boolean), snoozePresets: Schema.optionalKey(SnoozePresetSettings), From 2ab497778add794ce7036c86f343a10b66df7710 Mon Sep 17 00:00:00 2001 From: Cainan Travizanutto E Silva Date: Thu, 29 Oct 2026 18:57:35 -0300 Subject: [PATCH 2/5] fix: address CodeRabbit review on snoozed merge settlement Compare PR merge time to snooze start, show the child toggle when any scoped target enables merge settlement, and gate search on server capability. Co-authored-by: Cursor --- .../ThreadSettlementService.test.ts | 22 +++++++++++++++++++ .../ThreadSettlementService.ts | 9 ++++++-- .../components/settings/SettingsPanels.tsx | 10 +++++---- .../settings/settingsSearch.test.ts | 17 ++++++++++++-- .../src/components/settings/settingsSearch.ts | 5 +++++ .../useAvailableSettingsSearchItems.ts | 11 ++++++++++ 6 files changed, 66 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 4e0f97e85e999..de249bd5c013f 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -1462,4 +1462,26 @@ describe("isSnoozed", () => { }), ).toEqual(at(-DAY_MS)); }); + + it("ignores a merge that finished before the current snooze started", () => { + const thread = shell({ + ...snoozed, + status: "completed", + latestRunCompletedAt: at(-DAY_MS), + }); + + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + thread, + pullRequest: { + state: "merged", + mergedAt: DateTime.formatIso(at(-3 * DAY_MS)), + }, + nowMs: NOW_MS, + autoSettleAfterDays: null, + autoSettleOnMerge: true, + autoSettleSnoozedOnMerge: true, + }), + ).toBeNull(); + }); }); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 77e0d36ee34cd..d5e2cd25ae60e 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -122,7 +122,7 @@ export function threadHasQueuedTurnStart( function pullRequestSettles( thread: Pick< ProjectionStore.ProjectionSettlementCandidate, - "createdAt" | "latestUserAuthoredMessageAt" + "createdAt" | "latestUserAuthoredMessageAt" | "snoozedAt" >, pullRequest: SettlementPullRequest, autoSettleOnMerge: boolean, @@ -139,7 +139,12 @@ function pullRequestSettles( if (userAnchorMs === null) return false; const pullRequestAtMs = Date.parse(terminalAt); if (Number.isNaN(pullRequestAtMs)) return false; - return pullRequestAtMs >= userAnchorMs; + if (pullRequestAtMs < userAnchorMs) return false; + const snoozedAtMs = toMillis(thread.snoozedAt); + if (pullRequest.state === "merged" && snoozedAtMs !== null && pullRequestAtMs < snoozedAtMs) { + return false; + } + return true; } export function isAutoSettlementCandidate( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e6540c222251d..b388dd1cb0240 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1476,7 +1476,7 @@ export function ThreadsSettingsPanel() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); const navigate = useNavigate(); - const { environment, connectedEnvironments, target, scope } = useSettingsScope(); + const { environment, connectedEnvironments, target, targets, scope } = useSettingsScope(); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; const environmentId = environment?.environmentId ?? null; const hasServerTargets = connectedEnvironments.length > 0; @@ -1490,8 +1490,10 @@ export function ThreadsSettingsPanel() { (target) => target.serverConfig?.environment.capabilities.threadAutoSettlementScope === true, ); const supportsAutoSettleSnoozedOnMerge = connectedEnvironments.every( - (target) => - target.serverConfig?.environment.capabilities.threadAutoSettleSnoozedOnMerge === true, + (entry) => entry.serverConfig?.environment.capabilities.threadAutoSettleSnoozedOnMerge === true, + ); + const anyTargetHasAutoSettleOnMerge = targets.some( + (entry) => entry.settings.sidebarAutoSettleOnMerge === true, ); const textGenerationProviders = serverProviders.filter( @@ -1643,7 +1645,7 @@ export function ThreadsSettingsPanel() { /> } /> - {settings.sidebarAutoSettleOnMerge && supportsAutoSettleSnoozedOnMerge ? ( + {anyTargetHasAutoSettleOnMerge && supportsAutoSettleSnoozedOnMerge ? ( { canManageLocalBackend: false, isWslSettingsRowVisible: false, hasThreadAutoSettlement: false, + hasThreadAutoSettleSnoozedOnMerge: false, }); const gatedIds = new Set([ @@ -133,6 +134,7 @@ describe("searchSettings", () => { "auto-settle-inactive-threads", "auto-settle-merged-threads", "days-before-auto-settle", + "settle-snoozed-threads-on-merge", "linked-environments", ]); expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]); @@ -148,6 +150,7 @@ describe("searchSettings", () => { localEnvironmentDisabled: true, isWslSettingsRowVisible: false, hasThreadAutoSettlement: false, + hasThreadAutoSettleSnoozedOnMerge: false, }; const results = (hasPeerLinkHost: boolean) => searchSettings( @@ -215,11 +218,20 @@ describe("settings search targets", () => { }); describe("auto-settlement search availability", () => { - function environment(id: string, { connected = true, loaded = true } = {}) { + function environment( + id: string, + { connected = true, loaded = true, snoozedOnMerge = true } = {}, + ) { return { environmentId: EnvironmentId.make(id), connection: { phase: connected ? ("connected" as const) : ("offline" as const) }, - serverConfig: loaded ? { environment: { capabilities: {} } } : null, + serverConfig: loaded + ? { + environment: { + capabilities: snoozedOnMerge ? { threadAutoSettleSnoozedOnMerge: true } : {}, + }, + } + : null, }; } @@ -239,6 +251,7 @@ describe("auto-settlement search availability", () => { canManageLocalBackend: false, isWslSettingsRowVisible: false, hasThreadAutoSettlement: availability.eligibleEnvironmentIds.length > 0, + hasThreadAutoSettleSnoozedOnMerge: true, }); expect(searchSettings("auto-settle", items).map((item) => item.id)).toEqual([ "auto-settle-inactive-threads", diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index a80c4da4e0411..405384703c439 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -70,6 +70,7 @@ export interface SettingsSearchItem { readonly peerLinksOnly?: boolean; readonly secondary?: boolean; readonly requiresThreadAutoSettlement?: boolean; + readonly requiresThreadAutoSettleSnoozedOnMerge?: boolean; } export interface SettingsSearchAvailability { @@ -81,6 +82,7 @@ export interface SettingsSearchAvailability { readonly canManageLocalBackend: boolean; readonly isWslSettingsRowVisible: boolean; readonly hasThreadAutoSettlement: boolean; + readonly hasThreadAutoSettleSnoozedOnMerge: boolean; readonly managedTunnelActive?: boolean; readonly hasPeerLinkHost?: boolean; } @@ -506,6 +508,7 @@ export const SETTINGS_SEARCH_ITEMS = [ targetId: "auto-settle-merged-threads", searchTerms: ["snooze pull request merge auto-settle sidebar wake"], requiresThreadAutoSettlement: true, + requiresThreadAutoSettleSnoozedOnMerge: true, scope: "project-defaults", }, { @@ -1334,6 +1337,8 @@ export function filterAvailableSettingsSearchItems( (!item.localEnvironmentOnly || !availability.localEnvironmentDisabled) && (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) && (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement) && + (!item.requiresThreadAutoSettleSnoozedOnMerge || + availability.hasThreadAutoSettleSnoozedOnMerge) && (!item.managedTunnelOnly || availability.managedTunnelActive === true) && (!item.peerLinksOnly || availability.hasPeerLinkHost === true), ); diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index 712c4d87d7021..d80706abb1a62 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -41,6 +41,16 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch ) === true; const hasThreadAutoSettlement = getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0; + const connectedWithConfig = environments.filter( + (environment) => + environment.connection.phase === "connected" && environment.serverConfig !== null, + ); + const hasThreadAutoSettleSnoozedOnMerge = + connectedWithConfig.length > 0 && + connectedWithConfig.every( + (environment) => + environment.serverConfig!.environment.capabilities.threadAutoSettleSnoozedOnMerge === true, + ); const primaryEnvironmentId = usePrimaryEnvironmentId(); const canMaintain = useEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope); const hasPeerLinkHost = usePeerLinkHosts().length > 0; @@ -67,6 +77,7 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch canManageLocalBackend, isWslSettingsRowVisible: wslSettingsRowVisible, hasThreadAutoSettlement, + hasThreadAutoSettleSnoozedOnMerge, managedTunnelActive, hasPeerLinkHost, }); From a4d3a63534fc96a212e2fc42fb36e1a307893a0e Mon Sep 17 00:00:00 2001 From: Cainan Travizanutto E Silva Date: Thu, 29 Oct 2026 19:14:57 -0300 Subject: [PATCH 3/5] fix(settings): scope snoozed-on-merge search to settings scope Gate settle-snoozed search on connected targets in the resolved scope, matching sidebar capability checks. Co-authored-by: Cursor --- .../useAvailableSettingsSearchItems.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index d80706abb1a62..e6a0a15b24fe9 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -11,7 +11,9 @@ import { useEnvironmentQuery } from "~/state/query"; import { useEnvironmentScope } from "~/state/session"; import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; -import type { SettingsScopeSearch } from "./settingsScope"; +import { isConnectedWithConfig } from "./scopedSettings"; +import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; +import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; import { filterAvailableSettingsSearchItems, getThreadAutoSettlementSearchAvailability, @@ -19,6 +21,8 @@ import { export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch = {}) { const { environments } = useEnvironments(); + const groups = useSettingsProjectGroups(); + const resolvedScope = resolveSettingsScope(scopeSearch, groups, environments); // Explicit booleans keep flags out of the filter cache so environment updates do not rebuild the list. const hasEnvironment = environments.some((environment) => environment.serverConfig !== null) === true; @@ -41,10 +45,14 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch ) === true; const hasThreadAutoSettlement = getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0; - const connectedWithConfig = environments.filter( - (environment) => - environment.connection.phase === "connected" && environment.serverConfig !== null, - ); + const connectedWithConfig = + resolvedScope.kind === "unavailable" + ? [] + : environments.filter( + (environment) => + resolvedScope.environmentIds.includes(environment.environmentId) && + isConnectedWithConfig(environment), + ); const hasThreadAutoSettleSnoozedOnMerge = connectedWithConfig.length > 0 && connectedWithConfig.every( From d7e59f47cf434f3ebf754e4d195d352e0511e117 Mon Sep 17 00:00:00 2001 From: Cainan Travizanutto E Silva Date: Thu, 29 Oct 2026 20:24:48 -0300 Subject: [PATCH 4/5] test(settings): cover snoozed-on-merge capability gating and settlement Co-Authored-By: Claude Sonnet 5.5 --- .../ThreadSettlementService.test.ts | 127 ++++++++++++++++++ .../settings/settingsSearch.test.ts | 46 +++++++ .../src/components/settings/settingsSearch.ts | 28 ++++ .../useAvailableSettingsSearchItems.ts | 20 +-- 4 files changed, 206 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index de249bd5c013f..0462d55733568 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -462,6 +462,18 @@ describe("autoSettlementSettingsKey", () => { }); assert.notStrictEqual(inherits, never); }); + + it("changes when snoozed-on-merge is toggled", () => { + const off = ThreadSettlementService.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleSnoozedOnMerge: false, + }); + const on = ThreadSettlementService.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleSnoozedOnMerge: true, + }); + assert.notStrictEqual(off, on); + }); }); type AutoSettleCommand = Extract; @@ -747,6 +759,102 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( }); describe("ThreadSettlementServiceV2 worker", () => { + it.effect("skips a snoozed thread with a merged link until snoozed-on-merge is enabled", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const thread = makeThread("snoozed-merged-link", { + snoozedAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), + snoozedUntil: DateTime.makeUnsafe("2026-08-29T00:00:00.000Z"), + pullRequests: [ + { + host: "example.test", + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + source: "manual", + linkedAt: "2026-08-20T00:00:00.000Z", + snapshot: { + state: "merged", + title: "Pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: NOW, + syncedAt: NOW, + mergedAt: NOW, + }, + stack: null, + }, + ], + }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([thread]), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleSnoozedOnMerge: false, + }, + }); + + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(service, fixture.activation, fixture.snapshotReads); + expect(yield* Ref.get(fixture.commands)).toEqual([]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("settles a snoozed thread with a merged link when snoozed-on-merge is enabled", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const thread = makeThread("snoozed-merged-link-opt-in", { + snoozedAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), + snoozedUntil: DateTime.makeUnsafe("2026-08-29T00:00:00.000Z"), + pullRequests: [ + { + host: "example.test", + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + source: "manual", + linkedAt: "2026-08-20T00:00:00.000Z", + snapshot: { + state: "merged", + title: "Pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: NOW, + syncedAt: NOW, + mergedAt: NOW, + }, + stack: null, + }, + ], + }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([thread]), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleSnoozedOnMerge: true, + }, + }); + + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(service, fixture.activation, fixture.snapshotReads); + expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([ + thread.id, + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("settles a merged pull request stored only in the thread links", () => Effect.scoped( Effect.gen(function* () { @@ -1484,4 +1592,23 @@ describe("isSnoozed", () => { }), ).toBeNull(); }); + + it("requires merge settlement to be enabled before snoozed-on-merge applies", () => { + const thread = shell({ + ...snoozed, + status: "completed", + latestRunCompletedAt: at(-DAY_MS), + }); + + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + thread, + pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, + nowMs: NOW_MS, + autoSettleAfterDays: null, + autoSettleOnMerge: false, + autoSettleSnoozedOnMerge: true, + }), + ).toBeNull(); + }); }); diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 653efbac186aa..0863d26307ecc 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -11,6 +11,7 @@ import { filterAvailableSettingsSearchItems, getSettingsSearchTargetScope, getThreadAutoSettlementSearchAvailability, + hasThreadAutoSettleSnoozedOnMergeInScope, isSettingsSearchScopeAvailable, movedSettingsPath, searchSettings, @@ -262,6 +263,51 @@ describe("auto-settlement search availability", () => { ]); }); + it("hides snoozed-on-merge search when scoped environments lack the capability", () => { + const legacy = environment("legacy", { snoozedOnMerge: false }); + const scoped = { + kind: "environment" as const, + environmentIds: [legacy.environmentId], + }; + expect(hasThreadAutoSettleSnoozedOnMergeInScope([legacy, capable], scoped)).toBe(false); + const availability = getThreadAutoSettlementSearchAvailability([legacy, capable]); + const items = filterAvailableSettingsSearchItems({ + hasCloudPublicConfig: false, + hasEnvironment: true, + hasProviderSettingsEnvironment: true, + hasMacProviderSettingsEnvironment: false, + canManageLocalBackend: false, + isWslSettingsRowVisible: false, + hasThreadAutoSettlement: availability.eligibleEnvironmentIds.length > 0, + hasThreadAutoSettleSnoozedOnMerge: false, + }); + expect(searchSettings("snoozed merge", items).map((item) => item.id)).not.toContain( + "settle-snoozed-threads-on-merge", + ); + }); + + it("requires every connected environment in scope to advertise snoozed-on-merge", () => { + const scoped = { + kind: "all" as const, + environmentIds: [ + capable.environmentId, + environment("legacy", { snoozedOnMerge: false }).environmentId, + ], + }; + expect( + hasThreadAutoSettleSnoozedOnMergeInScope( + [capable, environment("legacy", { snoozedOnMerge: false })], + scoped, + ), + ).toBe(false); + expect( + hasThreadAutoSettleSnoozedOnMergeInScope([capable], { + kind: "environment", + environmentIds: [capable.environmentId], + }), + ).toBe(true); + }); + it.each([offline, loading])( "does not render the target on $environmentId or silently fall back", (selected) => { diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 405384703c439..7e0d482ac3cbe 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1268,6 +1268,34 @@ interface AutoSettlementSearchEnvironment { readonly serverConfig: object | null; } +export function hasThreadAutoSettleSnoozedOnMergeInScope( + environments: readonly AutoSettlementSearchEnvironment[], + scope: Pick, +): boolean { + if (scope.kind === "unavailable") { + return false; + } + const connected = environments.filter( + (environment) => + scope.environmentIds.includes(environment.environmentId) && + environment.connection.phase === "connected" && + environment.serverConfig !== null, + ); + return ( + connected.length > 0 && + connected.every((environment) => { + const capabilities = ( + environment.serverConfig as { + readonly environment?: { + readonly capabilities?: { readonly threadAutoSettleSnoozedOnMerge?: boolean }; + }; + } + ).environment?.capabilities; + return capabilities?.threadAutoSettleSnoozedOnMerge === true; + }) + ); +} + export function getThreadAutoSettlementSearchAvailability( environments: readonly AutoSettlementSearchEnvironment[], scope?: Pick, diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index e6a0a15b24fe9..9204a522f90ea 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -11,12 +11,12 @@ import { useEnvironmentQuery } from "~/state/query"; import { useEnvironmentScope } from "~/state/session"; import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; -import { isConnectedWithConfig } from "./scopedSettings"; import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; import { filterAvailableSettingsSearchItems, getThreadAutoSettlementSearchAvailability, + hasThreadAutoSettleSnoozedOnMergeInScope, } from "./settingsSearch"; export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch = {}) { @@ -45,20 +45,10 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch ) === true; const hasThreadAutoSettlement = getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0; - const connectedWithConfig = - resolvedScope.kind === "unavailable" - ? [] - : environments.filter( - (environment) => - resolvedScope.environmentIds.includes(environment.environmentId) && - isConnectedWithConfig(environment), - ); - const hasThreadAutoSettleSnoozedOnMerge = - connectedWithConfig.length > 0 && - connectedWithConfig.every( - (environment) => - environment.serverConfig!.environment.capabilities.threadAutoSettleSnoozedOnMerge === true, - ); + const hasThreadAutoSettleSnoozedOnMerge = hasThreadAutoSettleSnoozedOnMergeInScope( + environments, + resolvedScope, + ); const primaryEnvironmentId = usePrimaryEnvironmentId(); const canMaintain = useEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope); const hasPeerLinkHost = usePeerLinkHosts().length > 0; From 4338fff1304bad14bb2dcdcc469219ed89e6aecf Mon Sep 17 00:00:00 2001 From: Cainan Travizanutto E Silva Date: Fri, 30 Oct 2026 13:31:23 -0300 Subject: [PATCH 5/5] fix(settings): only an active snooze rejects an earlier merge, and search follows the panel gate - pullRequestSettles ignored a merge older than snoozedAt even after the snooze timer expired, so a stale snoozedAt blocked merge settlement with the toggle off too. - The settings search listed "Settle snoozed threads on merge" when merge settlement was off for the scope, opening a page where the panel hides the switch. Co-Authored-By: Claude Sonnet 5.5 --- .../ThreadSettlementService.test.ts | 20 +++++++++++++++++++ .../ThreadSettlementService.ts | 11 ++++++++-- .../useAvailableSettingsSearchItems.ts | 14 +++++++++---- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 0462d55733568..8890f87aa5394 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -1593,6 +1593,26 @@ describe("isSnoozed", () => { ).toBeNull(); }); + it("settles an earlier merge once the snooze timer has expired", () => { + const thread = shell({ + snoozedAt: at(-2 * DAY_MS), + snoozedUntil: at(-1_000), + status: "completed", + latestRunCompletedAt: at(-DAY_MS), + }); + + expect(ThreadSettlementService.isSnoozed(thread, NOW_MS)).toBe(false); + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + thread, + pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-3 * DAY_MS)) }, + nowMs: NOW_MS, + autoSettleAfterDays: null, + autoSettleOnMerge: true, + }), + ).not.toBeNull(); + }); + it("requires merge settlement to be enabled before snoozed-on-merge applies", () => { const thread = shell({ ...snoozed, diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index d5e2cd25ae60e..16ee6686dca27 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -126,6 +126,7 @@ function pullRequestSettles( >, pullRequest: SettlementPullRequest, autoSettleOnMerge: boolean, + snoozed: boolean, ): boolean { if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) { return false; @@ -141,7 +142,13 @@ function pullRequestSettles( if (Number.isNaN(pullRequestAtMs)) return false; if (pullRequestAtMs < userAnchorMs) return false; const snoozedAtMs = toMillis(thread.snoozedAt); - if (pullRequest.state === "merged" && snoozedAtMs !== null && pullRequestAtMs < snoozedAtMs) { + // `snoozedAt` outlives the timer, so only an active snooze can reject an earlier merge. + if ( + snoozed && + pullRequest.state === "merged" && + snoozedAtMs !== null && + pullRequestAtMs < snoozedAtMs + ) { return false; } return true; @@ -234,7 +241,7 @@ export function resolveAutoSettlementAt(input: { if ( pullRequest !== null && (!snoozed || pullRequest.state === "merged") && - pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge) + pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge, snoozed) ) { return DateTime.makeUnsafe(activityAtMs); } diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index 9204a522f90ea..e423c2ec1c1a9 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -11,6 +11,7 @@ import { useEnvironmentQuery } from "~/state/query"; import { useEnvironmentScope } from "~/state/session"; import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; +import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings"; import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; import { @@ -45,11 +46,16 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch ) === true; const hasThreadAutoSettlement = getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0; - const hasThreadAutoSettleSnoozedOnMerge = hasThreadAutoSettleSnoozedOnMergeInScope( - environments, - resolvedScope, - ); const primaryEnvironmentId = usePrimaryEnvironmentId(); + // The Threads panel hides the switch unless merge settlement is on for a scoped target. + const hasScopedAutoSettleOnMerge = resolveScopedSettingsTargets( + resolvedScope, + selectScopedSettingsEnvironments(resolvedScope, environments, primaryEnvironmentId) + .connectedEnvironments, + ).some((target) => target.settings.sidebarAutoSettleOnMerge === true); + const hasThreadAutoSettleSnoozedOnMerge = + hasScopedAutoSettleOnMerge && + hasThreadAutoSettleSnoozedOnMergeInScope(environments, resolvedScope); const canMaintain = useEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope); const hasPeerLinkHost = usePeerLinkHosts().length > 0; const cloudLinkState = usePrimaryCloudLinkState().data;