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 cef18fbe325c3..c451de380be62 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -236,6 +236,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..0462d55733568 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) }), @@ -461,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; @@ -746,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* () { @@ -1450,5 +1559,56 @@ 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)); + }); + + 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(); + }); + + 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/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 6a3d4fffb0399..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,12 +139,18 @@ 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( 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 +161,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 +197,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 +221,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 +231,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 +283,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 +300,7 @@ export function autoSettlementSettingsKey( ? "inherit" : entry.sidebarAutoSettleAfterDays, entry.sidebarAutoSettleKeepPullRequests ?? "inherit", + entry.sidebarAutoSettleSnoozedOnMerge ?? "inherit", ]), ]); } @@ -319,7 +336,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 +361,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 +458,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 c4e3d27a07fa7..0b87f025863ae 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -475,6 +475,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"] : []), @@ -634,6 +638,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, @@ -1479,7 +1484,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; @@ -1492,6 +1497,12 @@ export function ThreadsSettingsPanel() { const supportsAutoSettlementScope = connectedEnvironments.every( (target) => target.serverConfig?.environment.capabilities.threadAutoSettlementScope === true, ); + const supportsAutoSettleSnoozedOnMerge = connectedEnvironments.every( + (entry) => entry.serverConfig?.environment.capabilities.threadAutoSettleSnoozedOnMerge === true, + ); + const anyTargetHasAutoSettleOnMerge = targets.some( + (entry) => entry.settings.sidebarAutoSettleOnMerge === true, + ); const textGenerationProviders = serverProviders.filter( (provider) => provider.supportsTextGeneration !== false, @@ -1642,6 +1653,24 @@ export function ThreadsSettingsPanel() { /> } /> + {anyTargetHasAutoSettleOnMerge && supportsAutoSettleSnoozedOnMerge ? ( + + updateSettings({ sidebarAutoSettleSnoozedOnMerge: Boolean(checked) }) + } + aria-label="Settle snoozed threads on merge" + /> + } + /> + ) : null} { canManageLocalBackend: false, isWslSettingsRowVisible: false, hasThreadAutoSettlement: false, + hasThreadAutoSettleSnoozedOnMerge: false, }); const gatedIds = new Set([ @@ -133,6 +135,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 +151,7 @@ describe("searchSettings", () => { localEnvironmentDisabled: true, isWslSettingsRowVisible: false, hasThreadAutoSettlement: false, + hasThreadAutoSettleSnoozedOnMerge: false, }; const results = (hasPeerLinkHost: boolean) => searchSettings( @@ -215,11 +219,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,15 +252,62 @@ 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", "auto-settle-merged-threads", "days-before-auto-settle", + "settle-snoozed-threads-on-merge", "keep-threads-with-pull-requests", ]); }); + 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 dc494caf5344d..2728460b913c5 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; } @@ -499,6 +501,16 @@ 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, + requiresThreadAutoSettleSnoozedOnMerge: true, + scope: "project-defaults", + }, { id: "keep-threads-with-pull-requests", title: "Keep threads with a PR", @@ -1256,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, @@ -1325,6 +1365,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..9204a522f90ea 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -11,14 +11,18 @@ 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 { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; +import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; import { filterAvailableSettingsSearchItems, getThreadAutoSettlementSearchAvailability, + hasThreadAutoSettleSnoozedOnMergeInScope, } from "./settingsSearch"; 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,6 +45,10 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch ) === true; const hasThreadAutoSettlement = getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0; + const hasThreadAutoSettleSnoozedOnMerge = hasThreadAutoSettleSnoozedOnMergeInScope( + environments, + resolvedScope, + ); const primaryEnvironmentId = usePrimaryEnvironmentId(); const canMaintain = useEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope); const hasPeerLinkHost = usePeerLinkHosts().length > 0; @@ -67,6 +75,7 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch canManageLocalBackend, isWslSettingsRowVisible: wslSettingsRowVisible, hasThreadAutoSettlement, + hasThreadAutoSettleSnoozedOnMerge, managedTunnelActive, hasPeerLinkHost, }); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 75a546bed875f..de84530eca942 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -94,6 +94,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 179e58b7a0b56..7d70755c98ced 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1092,6 +1092,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "sidebarAutoSettleOnMerge", "sidebarAutoSettleAfterDays", "sidebarAutoSettleKeepPullRequests", + "sidebarAutoSettleSnoozedOnMerge", "continueThreadsAfterServerUpdate", "responseStreamingMode", ] as const; @@ -1129,6 +1130,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( @@ -1312,6 +1314,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( @@ -1658,6 +1663,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),