diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx index 5ff83dea728f..03b83875a3ca 100644 --- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx @@ -8,6 +8,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; +import { ControlPillMenu } from "../../components/ControlPill"; import { AppText as Text } from "../../components/AppText"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { serverEnvironment } from "../../state/server"; @@ -113,12 +114,17 @@ function AutoSettleSettingsRows() { environmentId: reference.environment.environmentId, projectId: reference.projectId, settings: referenceSettings, + supportsScope: + reference.environment.serverConfig.environment.capabilities.threadAutoSettlementScope === + true, }, displayTargets.map((target) => ({ environmentId: target.environment.environmentId, projectId: target.projectId, label: target.environment.label, settings: target.settings, + supportsScope: + target.environment.serverConfig.environment.capabilities.threadAutoSettlementScope === true, })), ); @@ -132,13 +138,15 @@ function AutoSettleSettingsRows() { syncTargets.some( (target) => target.sources.sidebarAutoSettleOnMerge === "project" || - target.sources.sidebarAutoSettleAfterDays === "project", + target.sources.sidebarAutoSettleAfterDays === "project" || + target.sources.sidebarAutoSettleScope === "project", ); const clearProjectOverrides = () => { if (writeInFlight.current) return; const writes = planMobileScopedSettingsClear(syncTargets, [ "sidebarAutoSettleOnMerge", "sidebarAutoSettleAfterDays", + "sidebarAutoSettleScope", ]); if (writes.length === 0) return; writeInFlight.current = true; @@ -194,15 +202,64 @@ function AutoSettleSettingsRows() { disabled={disabled} onValueChange={(value) => writeToAll({ sidebarAutoSettleOnMerge: value })} /> - - writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) - } - /> + + target.environment.serverConfig.environment.capabilities + .threadAutoSettlementScope !== true, + ), + }, + }, + ]} + onPressAction={({ nativeEvent }) => { + if (disabled) return; + const value = nativeEvent.event; + if (value !== "off" && value !== "all" && value !== "without-pr") return; + writeToAll({ + sidebarAutoSettleAfterDays: + value === "off" ? null : (afterDays ?? AUTO_SETTLE_DEFAULT_DAYS), + sidebarAutoSettleScope: value === "off" ? "all" : value, + }); + }} + > + + Auto-settle inactive threads + + {afterDays === null + ? "Off" + : referenceSettings.sidebarAutoSettleScope === "all" + ? "All threads" + : "Threads without a PR"} + + + {afterDays !== null ? ( diff --git a/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts b/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts index 996ebbd4979d..d140a4e7b022 100644 --- a/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts +++ b/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts @@ -67,6 +67,24 @@ describe("auto-settle settings sync", () => { expect(updated.sourceControlWritingStyle).toEqual(target.settings.sourceControlWritingStyle); }); + it("syncs differing scope only between servers supporting it", () => { + const target = { + environmentId: EnvironmentId.make("remote"), + label: "Remote", + settings: { ...reference.settings, sidebarAutoSettleScope: "without-pr" as const }, + supportsScope: true, + }; + expect( + planAutoSettleSettingsSync({ ...reference, supportsScope: true }, [target]).mismatches, + ).toEqual([target]); + expect( + planAutoSettleSettingsSync({ ...reference, supportsScope: true }, [ + { ...target, supportsScope: false }, + ]).mismatches, + ).toEqual([]); + expect(planAutoSettleSettingsSync(reference, [target]).mismatches).toEqual([]); + }); + it("does not compare the reference or a target without loaded settings", () => { const plan = planAutoSettleSettingsSync(reference, [ { ...reference, label: "Reference" }, diff --git a/apps/mobile/src/features/settings/autoSettleSettingsSync.ts b/apps/mobile/src/features/settings/autoSettleSettingsSync.ts index bbaaaa23d5b4..14bcfbc2142f 100644 --- a/apps/mobile/src/features/settings/autoSettleSettingsSync.ts +++ b/apps/mobile/src/features/settings/autoSettleSettingsSync.ts @@ -2,7 +2,7 @@ import type { EnvironmentId, ProjectId, ServerSettings } from "@t3tools/contract export type AutoSettleSettings = Pick< ServerSettings, - "sidebarAutoSettleAfterDays" | "sidebarAutoSettleOnMerge" + "sidebarAutoSettleAfterDays" | "sidebarAutoSettleOnMerge" | "sidebarAutoSettleScope" >; interface AutoSettleSyncTarget { @@ -10,6 +10,7 @@ interface AutoSettleSyncTarget { readonly projectId?: ProjectId | null; readonly label: string; readonly settings: AutoSettleSettings | null; + readonly supportsScope?: boolean; } /** Receives connected, capable targets. Applying these defaults must preserve other settings. */ @@ -18,12 +19,16 @@ export function planAutoSettleSettingsSync( readonly environmentId: EnvironmentId; readonly projectId?: ProjectId | null; readonly settings: AutoSettleSettings; + readonly supportsScope?: boolean; }, targets: readonly AutoSettleSyncTarget[], ) { - const patch: AutoSettleSettings = { + const patch = { sidebarAutoSettleAfterDays: reference.settings.sidebarAutoSettleAfterDays, sidebarAutoSettleOnMerge: reference.settings.sidebarAutoSettleOnMerge, + ...(reference.supportsScope + ? { sidebarAutoSettleScope: reference.settings.sidebarAutoSettleScope } + : {}), }; const mismatches = targets.filter( (target) => @@ -31,7 +36,10 @@ export function planAutoSettleSettingsSync( target.projectId !== reference.projectId) && target.settings !== null && (target.settings.sidebarAutoSettleAfterDays !== patch.sidebarAutoSettleAfterDays || - target.settings.sidebarAutoSettleOnMerge !== patch.sidebarAutoSettleOnMerge), + target.settings.sidebarAutoSettleOnMerge !== patch.sidebarAutoSettleOnMerge || + (target.supportsScope === true && + patch.sidebarAutoSettleScope !== undefined && + target.settings.sidebarAutoSettleScope !== patch.sidebarAutoSettleScope)), ); return { patch, mismatches }; } diff --git a/apps/mobile/src/features/settings/settings-scoped-server.test.ts b/apps/mobile/src/features/settings/settings-scoped-server.test.ts index 1c2de9b662f1..7d38ab45f217 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.test.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.test.ts @@ -55,17 +55,67 @@ describe("mobile usage-limit settings across environments", () => { ); }); -function environment(environmentId: EnvironmentId, settings: ServerSettings): SettingsTarget { +function environment( + environmentId: EnvironmentId, + settings: ServerSettings, + supportsScope = false, +): SettingsTarget { return { environmentId, serverConfig: { settings, - environment: { capabilities: { projectSettingsOverrides: true } }, + environment: { + capabilities: { projectSettingsOverrides: true, threadAutoSettlementScope: supportsScope }, + }, }, } as SettingsTarget; } describe("mobile project settings scope", () => { + it.each([false, true])( + "filters settlement scope per server with project scope %s", + (projectSelected) => { + const capable = environment(firstId, DEFAULT_SERVER_SETTINGS, true); + const legacy = environment(secondId, DEFAULT_SERVER_SETTINGS); + const targets = resolveMobileSettingsTargets( + [capable, legacy], + projectSelected + ? [ + { environmentId: firstId, id: firstProject }, + { environmentId: secondId, id: secondProject }, + ] + : null, + ); + const writes = planMobileScopedSettingsPatch(targets, projectSelected, { + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleScope: "all", + }); + expect(writes).toEqual([ + { + environmentId: firstId, + patch: projectSelected + ? { + projectSettingsOverrides: { + [firstProject]: { + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleScope: "all", + }, + }, + } + : { sidebarAutoSettleAfterDays: null, sidebarAutoSettleScope: "all" }, + }, + { + environmentId: secondId, + patch: projectSelected + ? { + projectSettingsOverrides: { [secondProject]: { sidebarAutoSettleAfterDays: null } }, + } + : { sidebarAutoSettleAfterDays: null }, + }, + ]); + }, + ); + it("edits each checkout's own override without changing either environment default", () => { const firstSettings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, diff --git a/apps/mobile/src/features/settings/settings-scoped-server.ts b/apps/mobile/src/features/settings/settings-scoped-server.ts index 81dd995fa02d..e2043698518b 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.ts @@ -13,6 +13,7 @@ import { } from "@t3tools/shared/projectSettings"; import type { SettingsTarget } from "./settings-environment-filter"; +import * as Struct from "effect/Struct"; export interface ScopedMobileSettingsTarget { readonly environment: SettingsTarget; @@ -51,8 +52,15 @@ export function planMobileScopedSettingsPatch( projectSelected: boolean, patch: ServerSettingsPatch, ) { + const supportedPatch = (target: ScopedMobileSettingsTarget) => + target.environment.serverConfig.environment.capabilities.threadAutoSettlementScope === true + ? patch + : Struct.omit(patch, ["sidebarAutoSettleScope"]); if (!projectSelected) { - return targets.map((target) => ({ environmentId: target.environment.environmentId, patch })); + return targets.map((target) => ({ + environmentId: target.environment.environmentId, + patch: supportedPatch(target), + })); } const keys = Object.keys(patch); if ( @@ -71,7 +79,7 @@ export function planMobileScopedSettingsPatch( const current = target.environment.serverConfig.settings.projectSettingsOverrides[target.projectId] ?? {}; const next: Record = { ...current }; - for (const [key, value] of Object.entries(patch)) { + for (const [key, value] of Object.entries(supportedPatch(target))) { // A picker's "Inherit" sends null; for keys whose override cannot // store null that means remove the override. if ( diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 91ab19b55e01..ce5de05def0a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -227,6 +227,7 @@ export const make = Effect.gen(function* () { threadAutoSettlement: true, storageCleanup: true, projectWorktreeCleanup: true, + threadAutoSettlementScope: true, threadRestartContinuation: true, projectSettingsOverrides: true, threadSnooze: true, diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 1f598d86136c..68ed40a0a931 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -214,6 +214,94 @@ describe("threadHasQueuedTurnStart", () => { }); describe("resolveAutoSettlementAt", () => { + it.each(["manual", "created", "agent", "stack", "stack-dismissed"] as const)( + "handles %s links for inactivity after work resumes", + (source) => { + const input = { + thread: shell({ + latestUserMessageAt: at(-4 * DAY_MS), + pullRequests: [ + { + host: "example.test", + repository: "owner/repo", + number: 1, + url: "https://example.test/owner/repo/pull/1", + source, + linkedAt: DateTime.formatIso(at(-10 * DAY_MS)), + snapshot: { + state: "closed", + title: "PR", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + closedAt: DateTime.formatIso(at(-5 * DAY_MS)), + updatedAt: DateTime.formatIso(at(-5 * DAY_MS)), + syncedAt: DateTime.formatIso(at(-1)), + }, + stack: null, + }, + ], + }), + pullRequest: null, + nowMs: NOW_MS, + autoSettleAfterDays: 3, + autoSettleOnMerge: true, + autoSettleScope: "without-pr" as const, + }; + expect(resolveAutoSettlementAt(input)).toEqual( + source === "stack-dismissed" ? at(-4 * DAY_MS) : null, + ); + expect(resolveAutoSettlementAt({ ...input, autoSettleScope: "all" })).toEqual( + at(-4 * DAY_MS), + ); + }, + ); + + it.each(["linkedPullRequest", "branchPullRequest"] as const)( + "excludes %s from inactivity while preserving merge and close rules", + (link) => { + const input = { + thread: shell({ + latestUserMessageAt: at(-4 * DAY_MS), + [link]: { + projectId: ProjectId.make("project-1"), + repository: "owner/repo", + number: 1, + url: "https://example.test/owner/repo/pull/1", + }, + }), + pullRequest: null, + nowMs: NOW_MS, + autoSettleAfterDays: 3, + autoSettleOnMerge: false, + autoSettleScope: "without-pr" as const, + }; + expect(resolveAutoSettlementAt(input)).toBeNull(); + expect(resolveAutoSettlementAt({ ...input, autoSettleScope: "all" })).toEqual( + at(-4 * DAY_MS), + ); + expect( + resolveAutoSettlementAt({ + ...input, + pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, + }), + ).toBeNull(); + expect( + resolveAutoSettlementAt({ + ...input, + autoSettleOnMerge: true, + pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, + }), + ).toEqual(at(-4 * DAY_MS)); + expect( + resolveAutoSettlementAt({ + ...input, + pullRequest: { state: "closed", closedAt: DateTime.formatIso(at(-1)) }, + }), + ).toEqual(at(-4 * DAY_MS)); + }, + ); + it("uses the latest activity time when the inactivity window elapses", () => { const idle = shell({ latestUserMessageAt: at(-4 * DAY_MS), @@ -292,6 +380,22 @@ const PROJECT_ID = ProjectId.make("settlement-project"); const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project"); describe("autoSettlementSettingsKey", () => { + it("includes environment and project inactivity scope", () => { + const base = autoSettlementSettingsKey(DEFAULT_SERVER_SETTINGS); + expect( + autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleScope: "without-pr", + }), + ).not.toBe(base); + expect( + autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleScope: "without-pr" } }, + }), + ).not.toBe(base); + }); + it("distinguishes a project that inherits the threshold from one that disables it", () => { const inherits = autoSettlementSettingsKey({ ...DEFAULT_SERVER_SETTINGS, @@ -581,6 +685,69 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( }); describe("ThreadSettlementServiceV2 worker", () => { + it.effect("excludes saved PR links from inactivity and rechecks when scope changes", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleScope: "without-pr" }, + snapshot: makeSnapshot([ + makeThread("open-linked", { + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + }, + }), + makeThread("open-branch", { + branch: "open", + branchPullRequest: { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + }, + }), + makeThread("failed-lookup", { + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 43, + url: "https://example.test/owner/repository/pull/43", + }, + }), + makeThread("no-pr-branch", { branch: "unlinked" }), + makeThread("exploration"), + ]), + pullRequestSummary: (reference) => + reference.number === 43 + ? Effect.die(new Error("host unavailable")) + : Effect.succeed(makePullRequestSummary({ ...reference, state: "open" })), + branchPullRequest: () => + Effect.die(new Error("inactivity should not look up unlinked branches")), + }); + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map(({ threadId }) => threadId).sort(), + [ThreadId.make("exploration"), ThreadId.make("no-pr-branch")], + ); + yield* fixture.updateSettings({ sidebarAutoSettleScope: "all" }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.ok( + (yield* Ref.get(fixture.commands)).some(({ threadId }) => threadId === "open-linked"), + ); + assert.ok( + (yield* Ref.get(fixture.commands)).some(({ threadId }) => threadId === "failed-lookup"), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("settles a merged pull request stored only in the thread links", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 65584acf133d..2e2023663e89 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -162,6 +162,7 @@ export function resolveAutoSettlementAt(input: { readonly nowMs: number; readonly autoSettleAfterDays: number | null; readonly autoSettleOnMerge: boolean; + readonly autoSettleScope?: "all" | "without-pr"; }): DateTime.Utc | null { const { thread } = input; let pullRequest = input.pullRequest; @@ -196,6 +197,11 @@ export function resolveAutoSettlementAt(input: { if (pullRequest !== null && pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) { return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs); } + if ( + input.autoSettleScope === "without-pr" && + (links.length > 0 || thread.linkedPullRequest != null || thread.branchPullRequest != null) + ) + return null; if (input.autoSettleAfterDays === null || activityAtMs === null) return null; return activityAtMs < input.nowMs - input.autoSettleAfterDays * DAY_MS ? DateTime.makeUnsafe(activityAtMs) @@ -229,6 +235,7 @@ export function autoSettlementSettingsKey( return JSON.stringify([ settings.sidebarAutoSettleOnMerge, settings.sidebarAutoSettleAfterDays, + settings.sidebarAutoSettleScope, // Only entries that touch settlement, in a stable order, so a project // override on an unrelated key does not queue a sweep. JSON drops // undefined, so inherit (absent) and never (null) need distinct marks. @@ -236,7 +243,8 @@ export function autoSettlementSettingsKey( .filter( ([, entry]) => entry.sidebarAutoSettleOnMerge !== undefined || - entry.sidebarAutoSettleAfterDays !== undefined, + entry.sidebarAutoSettleAfterDays !== undefined || + entry.sidebarAutoSettleScope !== undefined, ) .sort(([left], [right]) => left.localeCompare(right)) .map(([projectId, entry]) => [ @@ -245,6 +253,7 @@ export function autoSettlementSettingsKey( entry.sidebarAutoSettleAfterDays === undefined ? "inherit" : entry.sidebarAutoSettleAfterDays, + entry.sidebarAutoSettleScope ?? "inherit", ]), ]); } @@ -293,6 +302,7 @@ export const make = Effect.gen(function* () { nowMs: DateTime.toEpochMillis(decisionNow), autoSettleAfterDays: currentSettings.sidebarAutoSettleAfterDays, autoSettleOnMerge: currentSettings.sidebarAutoSettleOnMerge, + autoSettleScope: currentSettings.sidebarAutoSettleScope, }); if (settledAt === null) return thread; const uuid = yield* crypto.randomUUIDv4; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 942a1f9e58a7..71086ea3b6a2 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -553,7 +553,8 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Project Grouping"] : []), ...(settings.sidebarAutoSettleAfterDays !== - DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays + DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays || + settings.sidebarAutoSettleScope !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleScope ? ["Auto-settle inactive threads"] : []), ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge @@ -682,6 +683,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.enableProviderUpdateChecks, settings.continueThreadsAfterServerUpdate, settings.sidebarAutoSettleAfterDays, + settings.sidebarAutoSettleScope, settings.sidebarAutoSettleOnMerge, settings.autoResumeLimitedThreads, settings.snoozeLimitedThreads, @@ -785,6 +787,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, + sidebarAutoSettleScope: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleScope, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, autoResumeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads, snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads, @@ -2002,7 +2005,19 @@ function FontFamilySettingsRow({ ); } -const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3; +const AUTO_SETTLE_OPTIONS = [ + { value: "off", label: "Off" }, + { value: "all", label: "All threads" }, + { value: "without-pr", label: "Threads without a PR" }, +] as const; + +type AutoSettleMode = (typeof AUTO_SETTLE_OPTIONS)[number]["value"]; + +const DEFAULT_INACTIVITY_SETTINGS = { + sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, + sidebarAutoSettleScope: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleScope, +}; +const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_INACTIVITY_SETTINGS.sidebarAutoSettleAfterDays ?? 3; function AutoSettleDaysInput({ value, @@ -2167,6 +2182,31 @@ export function GeneralSettingsPanel() { connectedEnvironments.every( (target) => target.serverConfig?.environment.capabilities.threadAutoSettlement === true, ); + const mixedAutoSettle = useScopedSettingsMixed([ + "sidebarAutoSettleAfterDays", + "sidebarAutoSettleScope", + ]); + const supportsAutoSettleScope = connectedEnvironments.every( + (environment) => + environment.serverConfig?.environment.capabilities.threadAutoSettlementScope === true, + ); + const autoSettleAfterDays = settings.sidebarAutoSettleAfterDays; + const autoSettleScope = settings.sidebarAutoSettleScope; + const autoSettleMode = autoSettleAfterDays === null ? "off" : autoSettleScope; + const autoSettleChanged = + autoSettleAfterDays !== DEFAULT_INACTIVITY_SETTINGS.sidebarAutoSettleAfterDays || + autoSettleScope !== DEFAULT_INACTIVITY_SETTINGS.sidebarAutoSettleScope; + + function changeAutoSettleMode(value: AutoSettleMode | null) { + if (value === null) return; + const sidebarAutoSettleAfterDays = + value === "off" ? null : (autoSettleAfterDays ?? AUTO_SETTLE_DEFAULT_DAYS); + updateSettings({ + sidebarAutoSettleAfterDays, + sidebarAutoSettleScope: value === "off" ? "all" : value, + }); + } + const supportsRestartContinuation = connectedEnvironments.length > 0 && connectedEnvironments.every( @@ -2331,34 +2371,46 @@ export function GeneralSettingsPanel() { - updateSettings({ - sidebarAutoSettleAfterDays: - DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, - }) - } + onClick={() => updateSettings(DEFAULT_INACTIVITY_SETTINGS)} /> ) : null } control={ - - updateSettings({ - sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, - }) - } - aria-label="Auto-settle inactive threads" - /> + + items={AUTO_SETTLE_OPTIONS} + value={mixedAutoSettle ? null : autoSettleMode} + onValueChange={changeAutoSettleMode} + > + + + + + {AUTO_SETTLE_OPTIONS.map(({ value, label }) => ( + + {label} + + ))} + + } /> {settings.sidebarAutoSettleAfterDays !== null ? ( diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index 7e15b4e5a26f..6025bc8d57d7 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -29,6 +29,7 @@ function environment( loaded?: boolean; settings?: Partial; projectOverrides?: boolean; + settlementScope?: boolean; } = {}, ) { return { @@ -43,7 +44,10 @@ function environment( : { settings: { ...DEFAULT_SERVER_SETTINGS, ...options.settings }, environment: { - capabilities: { projectSettingsOverrides: options.projectOverrides !== false }, + capabilities: { + projectSettingsOverrides: options.projectOverrides !== false, + threadAutoSettlementScope: options.settlementScope === true, + }, }, }, }; @@ -148,6 +152,35 @@ describe("scoped settings targets", () => { }); describe("scoped settings writes", () => { + it.each([all, project])( + "restores all-thread settlement on capable targets in a mixed-version scope ($kind)", + (scope) => { + const settings = { sidebarAutoSettleScope: "without-pr" as const }; + const capable = environment("Laptop", { settlementScope: true, settings }); + const legacy = environment("Server", { settings }); + const plan = planScopedSettingsPatch(scope, [capable, legacy], { + sidebarAutoSettleAfterDays: 3, + sidebarAutoSettleScope: "all", + }); + const updated = plan.serverWrites.map((write) => { + const patch = + scope.kind === "project" + ? Object.values(write.patch.projectSettingsOverrides ?? {})[0] + : write.patch; + return { environmentId: write.environmentId, patch }; + }); + expect( + updated.sort((left, right) => left.environmentId.localeCompare(right.environmentId)), + ).toEqual([ + { + environmentId: capable.environmentId, + patch: { sidebarAutoSettleAfterDays: 3, sidebarAutoSettleScope: "all" }, + }, + { environmentId: legacy.environmentId, patch: { sidebarAutoSettleAfterDays: 3 } }, + ]); + }, + ); + it("edits the effective machine policy without changing other machines' rules", () => { const custom = environment("Laptop", { settings: { diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index e1e884632358..45418c08a426 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -19,6 +19,7 @@ import { type ProjectSettingSource, } from "@t3tools/shared/projectSettings"; import * as Equal from "effect/Equal"; +import * as Struct from "effect/Struct"; import type { ResolvedSettingsScope } from "./settingsScope"; @@ -31,7 +32,10 @@ interface ScopedSettingsEnvironment { readonly serverConfig: { readonly settings: ServerSettings; readonly environment?: { - readonly capabilities: { readonly projectSettingsOverrides?: boolean | undefined }; + readonly capabilities: { + readonly projectSettingsOverrides?: boolean | undefined; + readonly threadAutoSettlementScope?: boolean | undefined; + }; }; } | null; } @@ -226,7 +230,7 @@ export function planScopedSettingsPatch( const unscopableKeys = isProjectScope ? serverKeys.filter((key) => !isProjectScopedSettingKey(key)) : []; - const serverWrites: ScopedServerWrite[] = + const plannedServerWrites: ScopedServerWrite[] = serverKeys.length === 0 ? [] : isProjectScope @@ -289,6 +293,29 @@ export function planScopedSettingsPatch( : serverPatch, })) : []; + const serverWrites = plannedServerWrites.map((write) => { + const environment = connectedEnvironments.find( + (target) => target.environmentId === write.environmentId, + ); + if (environment?.serverConfig?.environment?.capabilities.threadAutoSettlementScope === true) + return write; + const patch = Struct.omit(write.patch, ["sidebarAutoSettleScope"]); + return { + ...write, + patch: + patch.projectSettingsOverrides === undefined + ? patch + : { + ...patch, + projectSettingsOverrides: Object.fromEntries( + Object.entries(patch.projectSettingsOverrides).map(([projectId, entry]) => [ + projectId, + entry === null ? null : Struct.omit(entry, ["sidebarAutoSettleScope"]), + ]), + ), + }, + }; + }); const hasClientWrite = Object.keys(clientPatch).length > 0; const hasWrite = hasClientWrite || serverWrites.length > 0; const unavailableReason = diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 79b46d72b2c1..b67fffc8cc0b 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -296,7 +296,9 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "auto-settle-inactive-threads", title: "Auto-settle inactive threads", to: "/settings/general", - searchTerms: ["sidebar inactivity days no activity automatically"], + searchTerms: [ + "sidebar inactivity days no activity automatically all threads without linked PR pull request off", + ], requiresThreadAutoSettlement: true, scope: "project-defaults", }, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index d0b56261c8f5..4e2dd9ec061b 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -106,18 +106,23 @@ runs a command, such as a dev server, stays open. By default, environments settle inactive threads after three days and settle threads whose pull request merged. A closed pull request can also settle an idle thread. Work in progress, pending questions or approvals, and live background work -prevent automatic settlement. An open pull request does not prevent inactivity -settlement, but an old closed or merged pull request does not settle work you -resumed after it closed. +prevent automatic settlement. An old closed or merged pull request does not +settle work you resumed after it closed. To keep one thread out of the settled shelf no matter how long it sits idle, open its menu, choose **Auto-settle behavior**, and pick **Disabled**. The current option is checked. Pick **Enabled** to return to the usual rules. Manual settle, snooze, and archive still work while it is disabled. -Change these rules in **Settings → General** on web and desktop, or **Settings → Thread behavior** on mobile. -They continue to run when your apps are closed. On web and desktop, choose an environment at the -top to change only its rules, or **All environments** to update connected environments together. +For inactivity settlement, choose **All threads**, **Threads without a PR**, +or **Off** in **Settings → General**. Threads without a PR excludes threads with +PRs linked manually (including older links), created through the app, linked by +an agent, associated through a stack, or detected from the branch. Dismissed +stack PRs do not count. Merge and close settlement still follows the PR rules. + +On mobile, these rules are in **Settings → Thread behavior**. +These rules continue to run when your apps are closed. On web and desktop, choose an environment at the top to change only +its rules, or **All environments** to update connected environments together. Mixed values show where the selected environments disagree. Mobile applies these rules to connected environments that support shared settings. Offline environments and older servers keep their previous values. Changing a rule does not reopen diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index 6f94fe0f9037..77f86c0bdcf2 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -215,6 +215,15 @@ describe("filterSharedServerPatch", () => { ).toEqual(availability === "enabled" ? [{ environmentId: boxId, label: "Remote Box" }] : []); }, ); + it("syncs settlement scope only to servers that support it", () => { + const patch = { + sidebarAutoSettleScope: "without-pr" as const, + sidebarAutoSettleAfterDays: 3, + }; + expect(filterSharedServerPatch(patch, { threadAutoSettlementScope: true })).toEqual(patch); + expect(filterSharedServerPatch(patch, {})).toEqual({ sidebarAutoSettleAfterDays: 3 }); + expect(splitSharedServerPatch(patch).sharedPatch).toEqual(patch); + }); it.each([true, false])("preserves supported restart preference %s", (enabled) => { const patch = { continueThreadsAfterServerUpdate: enabled, sidebarAutoSettleAfterDays: 7 }; diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index 1d6fb5e14ca7..90d680b7a3bd 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -24,6 +24,7 @@ import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; const SHARED_SERVER_SETTING_KEYS = [ "continueThreadsAfterServerUpdate", "sidebarAutoSettleAfterDays", + "sidebarAutoSettleScope", "sidebarAutoSettleOnMerge", "autoResumeLimitedThreads", "snoozeLimitedThreads", @@ -59,7 +60,12 @@ export function splitSharedServerPatch(patch: ServerSettingsPatch): { /** Filter unsupported preferences; direct model writes retain the server's fallback behavior. */ export function filterSharedServerPatch( patch: ServerSettingsPatch, - capabilities: Pick | undefined, + capabilities: + | Pick< + ExecutionEnvironmentCapabilities, + "threadRestartContinuation" | "threadAutoSettlementScope" + > + | undefined, settings?: ServerSettings, sourceSettings = settings, targetIsSource = false, @@ -81,6 +87,9 @@ export function filterSharedServerPatch( ) { patch = Struct.omit(patch, ["textGenerationModelSelection"]); } + if (!capabilities?.threadAutoSettlementScope) { + patch = Struct.omit(patch, ["sidebarAutoSettleScope"]); + } return capabilities?.threadRestartContinuation === true ? patch : Struct.omit(patch, ["continueThreadsAfterServerUpdate"]); @@ -89,7 +98,10 @@ export function filterSharedServerPatch( /** The shared subset supported by one environment. */ export function pickSharedServerSettings( settings: ServerSettings, - capabilities?: Pick, + capabilities?: Pick< + ExecutionEnvironmentCapabilities, + "threadRestartContinuation" | "threadAutoSettlementScope" + >, ): ServerSettingsPatch { return filterSharedServerPatch( Struct.pick(settings, SHARED_SERVER_SETTING_KEYS), @@ -122,7 +134,10 @@ export interface SharedSettingsEnvironment { readonly syncEligible: boolean; readonly settings: ServerSettings | null; readonly capabilities?: - | Pick + | Pick< + ExecutionEnvironmentCapabilities, + "threadRestartContinuation" | "threadAutoSettlementScope" + > | undefined; } @@ -138,7 +153,10 @@ export function findSharedSettingsMismatches(input: { readonly primaryEnvironmentId: EnvironmentId | null; readonly primarySettings: ServerSettings | null; readonly primaryCapabilities?: - | Pick + | Pick< + ExecutionEnvironmentCapabilities, + "threadRestartContinuation" | "threadAutoSettlementScope" + > | undefined; readonly environments: ReadonlyArray; }): ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }> { diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index a649cfe38de0..4444bed3d90a 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -119,6 +119,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadAutoSettlement: Schema.optionalKey(Schema.Boolean), storageCleanup: Schema.optionalKey(Schema.Boolean), projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), + /** Server can exclude PR-linked threads from inactivity settlement. */ + threadAutoSettlementScope: Schema.optionalKey(Schema.Boolean), /** Server persists the opt-in for continuing interrupted threads after restarts. */ threadRestartContinuation: Schema.optionalKey(Schema.Boolean), /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */ diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index f4a1432f4319..cf357f68f28a 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -700,6 +700,7 @@ describe("ServerSettings thread settlement", () => { it("defaults merge settlement on and inactivity settlement to three days", () => { const settings = decodeServerSettings({}); expect(settings.sidebarAutoSettleAfterDays).toBe(3); + expect(settings.sidebarAutoSettleScope).toBe("all"); expect(settings.sidebarAutoSettleOnMerge).toBe(true); }); @@ -718,6 +719,13 @@ describe("ServerSettings thread settlement", () => { ).toMatchObject({ sidebarAutoSettleAfterDays: null, sidebarAutoSettleOnMerge: false }); }); + it("accepts the linked-PR exclusion and rejects unknown scopes", () => { + expect(decodeServerSettingsPatch({ sidebarAutoSettleScope: "without-pr" })).toEqual({ + sidebarAutoSettleScope: "without-pr", + }); + expect(() => decodeServerSettingsPatch({ sidebarAutoSettleScope: "unknown" })).toThrow(); + }); + it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => { expect(() => decodeServerSettings({ sidebarAutoSettleAfterDays: value })).toThrow(); expect(() => decodeServerSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 42a12ffb1b64..6142a9798805 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1104,6 +1104,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "pullRequestMergeMethod", "sidebarAutoSettleOnMerge", "sidebarAutoSettleAfterDays", + "sidebarAutoSettleScope", "continueThreadsAfterServerUpdate", "responseStreamingMode", ] as const; @@ -1134,6 +1135,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), + sidebarAutoSettleScope: Schema.optionalKey(Schema.Literals(["all", "without-pr"])), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), } satisfies Record); @@ -1252,6 +1254,9 @@ export const ServerSettings = Schema.Struct({ ), snoozeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), autoResumeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + sidebarAutoSettleScope: Schema.Literals(["all", "without-pr"]).pipe( + Schema.withDecodingDefault(Effect.succeed("all")), + ), sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), backgroundActivity: BackgroundActivitySettings, // Legacy flat fields retained for old settings files and old clients. New @@ -1603,6 +1608,7 @@ export const ServerSettingsPatch = Schema.Struct({ deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean), deviceHosts: Schema.optionalKey(SshDeviceHostConfigs), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), + sidebarAutoSettleScope: Schema.optionalKey(Schema.Literals(["all", "without-pr"])), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean), snoozeLimitedThreads: Schema.optionalKey(Schema.Boolean),