From cb360a6ea7e24642b9891b4dff185f2d632b0b63 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 3 Aug 2026 10:35:07 -0700 Subject: [PATCH] fix: keep auto-settle policy consistent across clients --- apps/mobile/src/features/home/HomeScreen.tsx | 12 ++++ .../threads/ThreadNavigationSidebar.tsx | 12 ++++ .../src/features/threads/threadListV2.test.ts | 64 +++++++++++++++++++ .../src/features/threads/threadListV2.ts | 18 ++++-- .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/serverSettings.test.ts | 22 +++++++ apps/web/src/components/ChatView.tsx | 4 +- apps/web/src/components/SidebarV2.tsx | 11 ++-- .../components/settings/BetaSettingsPanel.tsx | 62 ++++++++++++------ docs/README.md | 1 + docs/user/thread-settlement.md | 13 ++++ packages/contracts/src/environment.ts | 3 + packages/contracts/src/settings.test.ts | 32 +++++++++- packages/contracts/src/settings.ts | 45 ++++++++++--- 14 files changed, 255 insertions(+), 45 deletions(-) create mode 100644 docs/user/thread-settlement.md diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index d54fb7d4890..3197d7dbd27 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -566,6 +566,16 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const autoSettleAfterDaysByEnvironment = useMemo( + () => + new Map( + [...serverConfigs].map( + ([environmentId, config]) => + [environmentId, config.settings.threadSettlement.autoSettleAfterDays] as const, + ), + ), + [serverConfigs], + ); const snoozeEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -596,6 +606,7 @@ export function HomeScreen(props: HomeScreenProps) { matchedThreadKeys, changeRequestStateByKey, settlementEnvironmentIds, + autoSettleAfterDaysByEnvironment, snoozeEnvironmentIds, settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, @@ -612,6 +623,7 @@ export function HomeScreen(props: HomeScreenProps) { settledShelfExpanded, settledVisibleCount, settlementEnvironmentIds, + autoSettleAfterDaysByEnvironment, snoozeEnvironmentIds, props.searchQuery, props.selectedEnvironmentId, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 8a7fc2ed6df..5f94556e40e 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -472,6 +472,16 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const autoSettleAfterDaysByEnvironment = useMemo( + () => + new Map( + [...serverConfigs].map( + ([environmentId, config]) => + [environmentId, config.settings.threadSettlement.autoSettleAfterDays] as const, + ), + ), + [serverConfigs], + ); const snoozeEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -500,6 +510,7 @@ function ThreadNavigationSidebarPane( matchedThreadKeys, changeRequestStateByKey, settlementEnvironmentIds, + autoSettleAfterDaysByEnvironment, snoozeEnvironmentIds, settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, @@ -520,6 +531,7 @@ function ThreadNavigationSidebarPane( matchedThreadKeys, settledVisibleCount, settlementEnvironmentIds, + autoSettleAfterDaysByEnvironment, snoozeEnvironmentIds, threadListV2Enabled, threads, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 99b5700f7b0..79e73c62aa7 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -259,6 +259,70 @@ describe("sortThreadsForListV2", () => { }); describe("buildThreadListV2Items", () => { + it("uses each thread's environment policy without client drift", () => { + const threeDayEnvironment = EnvironmentId.make("environment-three-days"); + const sevenDayEnvironment = EnvironmentId.make("environment-seven-days"); + const disabledEnvironment = EnvironmentId.make("environment-disabled"); + const missingEnvironment = EnvironmentId.make("environment-missing-policy"); + const sameAge = { + createdAt: "2026-06-05T00:00:00.000Z", + updatedAt: "2026-06-05T00:00:00.000Z", + latestUserMessageAt: "2026-06-05T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("same-age-turn"), + state: "completed", + requestedAt: "2026-06-05T00:00:00.000Z", + startedAt: "2026-06-05T00:00:01.000Z", + completedAt: "2026-06-05T00:01:00.000Z", + assistantMessageId: null, + }, + } as const; + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + ...sameAge, + environmentId: threeDayEnvironment, + id: ThreadId.make("three-days"), + title: "Three days", + }), + makeThread({ + ...sameAge, + environmentId: sevenDayEnvironment, + id: ThreadId.make("seven-days"), + title: "Seven days", + }), + makeThread({ + ...sameAge, + environmentId: disabledEnvironment, + id: ThreadId.make("disabled"), + title: "Disabled", + }), + makeThread({ + ...sameAge, + environmentId: missingEnvironment, + id: ThreadId.make("missing"), + title: "Missing", + }), + ], + environmentId: null, + searchQuery: "", + autoSettleAfterDaysByEnvironment: new Map([ + [threeDayEnvironment, 3], + [sevenDayEnvironment, 7], + [disabledEnvironment, null], + ]), + now: "2026-06-10T00:00:00.000Z", + }); + + expect(Object.fromEntries(layout.items.map((item) => [item.thread.id, item.variant]))).toEqual({ + disabled: "card", + "seven-days": "card", + missing: "slim", + "three-days": "slim", + }); + expect(layout.settledCount).toBe(2); + }); + it("hides snoozed threads and counts them โ€” visibility parity with web", () => { const layout = buildThreadListV2Items({ threads: [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index c88aff4ec02..7225d7d0a2f 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -9,7 +9,11 @@ import { import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { + DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS, + type EnvironmentId, + type ProjectId, +} from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -303,9 +307,9 @@ export function buildThreadListV2ListItems(input: { /** * Partitions visible threads into the active card block (creation order) and - * the settled recency tail, matching the web v2 list. `autoSettleAfterDays` - * mirrors the web default of 3 โ€” mobile has no client-settings sync yet, so - * the default is fixed here rather than user-configurable. + * the settled recency tail, matching the web v2 list. Inactivity policy is + * resolved from each thread's owning environment. Missing policy data keeps + * the test-friendly and version-skew-compatible server default of three days. */ export function buildThreadListV2Items(input: { readonly threads: ReadonlyArray; @@ -325,7 +329,7 @@ export function buildThreadListV2Items(input: { /** Environments whose server supports thread.snooze/unsnooze. Same contract as settlementEnvironmentIds. */ readonly snoozeEnvironmentIds?: ReadonlySet; - readonly autoSettleAfterDays?: number; + readonly autoSettleAfterDaysByEnvironment?: ReadonlyMap; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; /** Injectable for tests; defaults to now. */ @@ -345,7 +349,6 @@ export function buildThreadListV2Items(input: { }): ThreadListV2Layout { const now = input.now ?? new Date().toISOString(); const snoozeNow = input.snoozeNow ?? now; - const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) @@ -376,6 +379,9 @@ export function buildThreadListV2Items(input: { } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; + const autoSettleAfterDays = input.autoSettleAfterDaysByEnvironment?.has(thread.environmentId) + ? (input.autoSettleAfterDaysByEnvironment.get(thread.environmentId) ?? null) + : DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS; const changeRequestState = input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; // Visibility parity with web: a snoozed thread leaves the list until it diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index a14f89fd031..5a27c595ad6 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -144,6 +144,7 @@ export const make = Effect.gen(function* () { repositoryIdentity: true, connectionProbe: true, threadSettlement: true, + threadSettlementPolicy: true, threadSnooze: true, threadTitleRegeneration: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index d38a3064910..be78496d682 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -116,6 +116,28 @@ it.layer(NodeServices.layer)("server settings", (it) => { }), ); + it.effect("persists thread settlement policy patches", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + + const updated = yield* serverSettings.updateSettings({ + threadSettlement: { autoSettleAfterDays: 14 }, + }); + assert.deepEqual(updated.threadSettlement, { autoSettleAfterDays: 14 }); + assert.include( + yield* fileSystem.readFileString(serverConfig.settingsPath), + '"threadSettlement": {', + ); + + const disabled = yield* serverSettings.updateSettings({ + threadSettlement: { autoSettleAfterDays: null }, + }); + assert.isNull(disabled.threadSettlement.autoSettleAfterDays); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect( "decodes legacy object-shaped textGenerationModelSelection.options from settings.json", () => diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 836687af3df..23d5e003a5b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -166,7 +166,7 @@ import { import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; -import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; +import { useEnvironmentSettings } from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; @@ -3961,7 +3961,7 @@ function ChatViewContent(props: ChatViewProps) { // partition (same shell, same capability gate, same PR auto-settle input) // so the banner and the sidebar row never disagree. const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); - const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); + const autoSettleAfterDays = settings.threadSettlement.autoSettleAfterDays; const activeThreadPr = resolveThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 9e86e5fb6b4..a0b5c663a14 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1180,7 +1180,6 @@ export default function SidebarV2() { const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -1584,10 +1583,11 @@ export default function SidebarV2() { // or descriptor not loaded yet) never classify as settled: the user // could neither un-settle nor pin them, so auto-settling them would // strand rows in a tail with no working affordances. - const supportsSettlement = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; - const supportsSnooze = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; + const serverConfig = serverConfigs.get(thread.environmentId); + const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; + const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; + const autoSettleAfterDays = + serverConfig?.settings.threadSettlement.autoSettleAfterDays ?? null; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; // Snooze outranks settled classification: an explicitly snoozed thread @@ -1616,7 +1616,6 @@ export default function SidebarV2() { snoozeNow: preciseNow, }; }, [ - autoSettleAfterDays, changeRequestStateByKey, nowMinute, scopedProjectKeys, diff --git a/apps/web/src/components/settings/BetaSettingsPanel.tsx b/apps/web/src/components/settings/BetaSettingsPanel.tsx index 740d3048f0e..949cfbe058c 100644 --- a/apps/web/src/components/settings/BetaSettingsPanel.tsx +++ b/apps/web/src/components/settings/BetaSettingsPanel.tsx @@ -1,25 +1,30 @@ import { useEffect, useState } from "react"; +import { + DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS, + MAX_THREAD_AUTO_SETTLE_AFTER_DAYS, + MIN_THREAD_AUTO_SETTLE_AFTER_DAYS, +} from "@t3tools/contracts"; import { - useClientSettings, + usePrimarySettings, useSidebarV2Enabled, useUpdateClientSettings, + useUpdatePrimarySettings, } from "../../hooks/useSettings"; +import { usePrimaryEnvironment } from "../../state/environments"; import { Input } from "../ui/input"; import { Switch } from "../ui/switch"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; -const AUTO_SETTLE_MIN_DAYS = 1; -const AUTO_SETTLE_MAX_DAYS = 90; -const AUTO_SETTLE_DEFAULT_DAYS = 3; - function AutoSettleDaysInput({ value, onCommit, + disabled, }: { value: number; onCommit: (days: number) => void; + disabled: boolean; }) { // Local draft so the field can be emptied mid-edit; the setting only moves // on valid input and snaps back to the persisted value on blur. @@ -31,8 +36,9 @@ function AutoSettleDaysInput({ return ( { @@ -43,8 +49,8 @@ function AutoSettleDaysInput({ const parsed = Number(event.target.value); if ( Number.isInteger(parsed) && - parsed >= AUTO_SETTLE_MIN_DAYS && - parsed <= AUTO_SETTLE_MAX_DAYS + parsed >= MIN_THREAD_AUTO_SETTLE_AFTER_DAYS && + parsed <= MAX_THREAD_AUTO_SETTLE_AFTER_DAYS ) { onCommit(parsed); } @@ -57,10 +63,14 @@ function AutoSettleDaysInput({ export function BetaSettingsPanel() { const sidebarV2Enabled = useSidebarV2Enabled(); - const sidebarAutoSettleAfterDays = useClientSettings( - (settings) => settings.sidebarAutoSettleAfterDays, + const autoSettleAfterDays = usePrimarySettings( + (settings) => settings.threadSettlement.autoSettleAfterDays, ); - const updateSettings = useUpdateClientSettings(); + const primaryEnvironment = usePrimaryEnvironment(); + const supportsThreadSettlementPolicy = + primaryEnvironment?.serverConfig?.environment.capabilities.threadSettlementPolicy === true; + const updateClientSettings = useUpdateClientSettings(); + const updatePrimarySettings = useUpdatePrimarySettings(); return ( @@ -74,7 +84,7 @@ export function BetaSettingsPanel() { // Touching the switch pins the choice, so a nightly build that // defaults v2 on does not flip it back after the user opts out. onCheckedChange={(checked) => - updateSettings({ + updateClientSettings({ sidebarV2Enabled: Boolean(checked), sidebarV2ConfiguredByUser: true, }) @@ -87,27 +97,39 @@ export function BetaSettingsPanel() { <> - updateSettings({ - sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, + updatePrimarySettings({ + threadSettlement: { + autoSettleAfterDays: checked ? DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS : null, + }, }) } aria-label="Auto-settle inactive threads" /> } /> - {sidebarAutoSettleAfterDays !== null ? ( + {autoSettleAfterDays !== null ? ( updateSettings({ sidebarAutoSettleAfterDays: days })} + value={autoSettleAfterDays} + disabled={!supportsThreadSettlementPolicy} + onCommit={(days) => + updatePrimarySettings({ + threadSettlement: { autoSettleAfterDays: days }, + }) + } /> } /> diff --git a/docs/README.md b/docs/README.md index bc359826a04..f1d0274dcd4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) +- [Thread settlement](./user/thread-settlement.md) - [Background service (Linux)](./user/background-service.md) - Providers: [Codex](./user/providers-codex.md) ยท [Claude](./user/providers-claude.md) diff --git a/docs/user/thread-settlement.md b/docs/user/thread-settlement.md new file mode 100644 index 00000000000..b22b7a9ca91 --- /dev/null +++ b/docs/user/thread-settlement.md @@ -0,0 +1,13 @@ +# Thread settlement + +Settling keeps completed work out of the active thread list without archiving or deleting it. You +can settle or un-settle a thread explicitly. New activity makes a settled thread active again. + +T3 Code can also classify inactive threads as settled after a configurable number of days. This is +a derived view: crossing the inactivity threshold does not explicitly settle the thread or change +its stored lifecycle state. + +The inactivity policy belongs to the environment that owns the thread. Up-to-date web, desktop, +and mobile clients connected to that environment use the same value, so switching clients does not +change which threads appear inactive. In a multi-environment list, each thread uses its owning +environment's policy. Change the primary environment's policy in **Settings โ†’ Beta**. diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index b49860c6388..9415331871e 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -44,6 +44,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ pre-settlement servers, so clients treat missing as unsupported and never send the commands under version skew. */ threadSettlement: Schema.optionalKey(Schema.Boolean), + /** Server owns the per-environment inactivity settlement policy and accepts + updates through server settings. Missing means clients must not edit it. */ + threadSettlementPolicy: Schema.optionalKey(Schema.Boolean), /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 2bc61d72f21..9e8847c6367 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -68,7 +68,7 @@ describe("ClientSettings environment identification", () => { }); describe("ClientSettings sidebar v2", () => { - it("defaults the beta off with a three-day auto-settle threshold", () => { + it("keeps the legacy auto-settle preference for downgrade compatibility", () => { const settings = decodeClientSettings({}); expect(settings.sidebarV2Enabled).toBe(false); expect(settings.sidebarAutoSettleAfterDays).toBe(3); @@ -111,6 +111,36 @@ describe("ClientSettings sidebar v2", () => { }); }); +describe("ServerSettings.threadSettlement", () => { + it("defaults inactivity classification to three days", () => { + expect(decodeServerSettings({}).threadSettlement).toEqual({ + autoSettleAfterDays: 3, + }); + }); + + it("accepts disabling and patching inactivity classification", () => { + expect( + decodeServerSettings({ + threadSettlement: { autoSettleAfterDays: null }, + }).threadSettlement.autoSettleAfterDays, + ).toBeNull(); + expect( + decodeServerSettingsPatch({ + threadSettlement: { autoSettleAfterDays: 14 }, + }).threadSettlement, + ).toEqual({ autoSettleAfterDays: 14 }); + }); + + it.each([-1, 0, 91])("rejects an inactivity threshold outside 1..90: %s", (value) => { + expect(() => + decodeServerSettings({ threadSettlement: { autoSettleAfterDays: value } }), + ).toThrow(); + expect(() => + decodeServerSettingsPatch({ threadSettlement: { autoSettleAfterDays: value } }), + ).toThrow(); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7edda2e52e5..eed99a4c8c2 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -38,16 +38,26 @@ export const SidebarThreadPreviewCount = Schema.Int.check( ); export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; -export const MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 1; -export const MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 90; -export const SidebarAutoSettleAfterDays = Schema.Number.check( +export const MIN_THREAD_AUTO_SETTLE_AFTER_DAYS = 1; +export const MAX_THREAD_AUTO_SETTLE_AFTER_DAYS = 90; +export const ThreadAutoSettleAfterDays = Schema.Number.check( Schema.isBetween({ - minimum: MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, - maximum: MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + minimum: MIN_THREAD_AUTO_SETTLE_AFTER_DAYS, + maximum: MAX_THREAD_AUTO_SETTLE_AFTER_DAYS, }), ); -export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type; -export const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; +export type ThreadAutoSettleAfterDays = typeof ThreadAutoSettleAfterDays.Type; +export const DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS: ThreadAutoSettleAfterDays = 3; +/** @deprecated Use MIN_THREAD_AUTO_SETTLE_AFTER_DAYS. */ +export const MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = MIN_THREAD_AUTO_SETTLE_AFTER_DAYS; +/** @deprecated Use MAX_THREAD_AUTO_SETTLE_AFTER_DAYS. */ +export const MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = MAX_THREAD_AUTO_SETTLE_AFTER_DAYS; +/** @deprecated Use ThreadAutoSettleAfterDays. */ +export const SidebarAutoSettleAfterDays = ThreadAutoSettleAfterDays; +/** @deprecated Use ThreadAutoSettleAfterDays. */ +export type SidebarAutoSettleAfterDays = ThreadAutoSettleAfterDays; +/** @deprecated Use DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS. */ +export const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS; export const MIN_GLASS_OPACITY = 40; export const MAX_GLASS_OPACITY = 100; export const GlassOpacity = Schema.Int.check( @@ -101,8 +111,10 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), - sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), + /** @deprecated Persisted for downgrade compatibility. Auto-settlement is + now configured per environment in ServerSettings.threadSettlement. */ + sidebarAutoSettleAfterDays: Schema.NullOr(ThreadAutoSettleAfterDays).pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS)), ), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), @@ -467,10 +479,18 @@ export const BackgroundActivitySettings = Schema.Struct({ }).pipe(Schema.withDecodingDefault(Effect.succeed({}))); export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; +export const ThreadSettlementSettings = Schema.Struct({ + autoSettleAfterDays: Schema.NullOr(ThreadAutoSettleAfterDays).pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS)), + ), +}).pipe(Schema.withDecodingDefault(Effect.succeed({}))); +export type ThreadSettlementSettings = typeof ThreadSettlementSettings.Type; + export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), backgroundActivity: BackgroundActivitySettings, + threadSettlement: ThreadSettlementSettings, // Legacy flat fields retained for old settings files and old clients. New // consumers should resolve `backgroundActivity` instead. automaticGitFetchInterval: Schema.DurationFromMillis.pipe( @@ -635,6 +655,11 @@ export const ServerSettingsPatch = Schema.Struct({ overrides: Schema.optionalKey(BackgroundActivityOverrides), }), ), + threadSettlement: Schema.optionalKey( + Schema.Struct({ + autoSettleAfterDays: Schema.optionalKey(Schema.NullOr(ThreadAutoSettleAfterDays)), + }), + ), automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis), backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), @@ -701,7 +726,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), - sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), + sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(ThreadAutoSettleAfterDays)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),