diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx index e209ab0b6ce5..fa81ab0e4534 100644 --- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx @@ -6,7 +6,11 @@ import { useRef, useState } from "react"; import { Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + DEFAULT_SIDEBAR_AUTO_ARCHIVE_AFTER_DAYS, + type ServerSettingsPatch, +} from "@t3tools/contracts"; import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; import { AppText as Text } from "../../components/AppText"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; @@ -20,7 +24,7 @@ import { AndroidSettingsEnvironmentFilter, SettingsEnvironmentFilterHeader, } from "./components/SettingsEnvironmentFilterHeader"; -import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; +import { planAutoSettleSettingsSync } from "./autoSettleSettingsSync"; import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; import { planMobileScopedSettingsClear, @@ -84,7 +88,7 @@ function AutoSettleSettingsRows() { return null; } - const writeToAll = (patch: Partial) => { + const writeToAll = (patch: ServerSettingsPatch) => { if (writeInFlight.current) return; const writes = planMobileScopedSettingsPatch(syncTargets, projectSelected, patch); if (writes.length === 0) return; @@ -116,6 +120,9 @@ function AutoSettleSettingsRows() { })), ); + const supportsAutoArchive = syncTargets.every( + (target) => target.environment.serverConfig.environment.capabilities.threadAutoArchive === true, + ); const supportsProjectOverrides = syncTargets.every( (target) => target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true, @@ -126,13 +133,15 @@ function AutoSettleSettingsRows() { syncTargets.some( (target) => target.sources.sidebarAutoSettleOnMerge === "project" || - target.sources.sidebarAutoSettleAfterDays === "project", + target.sources.sidebarAutoSettleAfterDays === "project" || + target.sources.sidebarAutoArchiveAfterDays === "project", ); const clearProjectOverrides = () => { if (writeInFlight.current) return; const writes = planMobileScopedSettingsClear(syncTargets, [ "sidebarAutoSettleOnMerge", "sidebarAutoSettleAfterDays", + "sidebarAutoArchiveAfterDays", ]); if (writes.length === 0) return; writeInFlight.current = true; @@ -150,6 +159,7 @@ function AutoSettleSettingsRows() { }; const afterDays = referenceSettings.sidebarAutoSettleAfterDays; + const archiveAfterDays = referenceSettings.sidebarAutoArchiveAfterDays; return ( @@ -186,11 +196,39 @@ function AutoSettleSettingsRows() { writeToAll({ sidebarAutoSettleAfterDays: value })} /> ) : null} + {supportsAutoArchive ? ( + + + writeToAll({ + sidebarAutoArchiveAfterDays: value ? DEFAULT_SIDEBAR_AUTO_ARCHIVE_AFTER_DAYS : null, + }) + } + /> + {archiveAfterDays !== null ? ( + + + Settled days + writeToAll({ sidebarAutoArchiveAfterDays: value })} + /> + + ) : null} + + ) : null} {pendingWrites === 0 && mismatches.length > 0 ? ( diff --git a/apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx b/apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx index 572812880ada..0953edb37c1b 100644 --- a/apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx +++ b/apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx @@ -28,21 +28,21 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { /> adjust(-1)} /> {props.value} = MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS} onPress={() => adjust(1)} /> diff --git a/apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx b/apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx index d7734f5a923c..cd1af8e18433 100644 --- a/apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx +++ b/apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx @@ -42,7 +42,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { modifiers={[ buttonStyle("bordered"), disabled(props.disabled), - accessibilityLabel(`Days before auto-settle: ${props.value}`), + accessibilityLabel(`Days before ${props.action}: ${props.value}`), frame({ minWidth: 64, minHeight: 44 }), foregroundStyle(colors["--color-primary-text"]), font({ size: appearance.baseFontSize }), @@ -60,7 +60,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { ]} > void; readonly disabled?: boolean; + /** Names the automation in accessibility labels, such as "auto-settle". */ + readonly action: string; } export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { @@ -41,7 +43,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { onChangeText={setDraft} onBlur={commit} onSubmitEditing={commit} - accessibilityLabel="Days before auto-settle" + accessibilityLabel={`Days before ${props.action}`} editable={!props.disabled} /> ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index d58c014d86e8..58fa44a5b6f2 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* () { threadPinReorder: true, threadActiveReorder: true, threadAutoSettleOptOut: true, + threadAutoArchive: true, threadTitleRegeneration: true, threadPullRequests: true, pullRequestStackActions: true, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 4163168157e7..fc67f5ef705c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -671,6 +671,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, archivedAt: null, + ...(event.payload.settledAt !== undefined + ? { settledAt: event.payload.settledAt } + : {}), updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts index 8a1588b18752..9ade231228a8 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -7,7 +7,11 @@ import { type OrchestrationThreadShell, type ThreadPullRequestLink, } from "@t3tools/contracts"; -import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; +import { + type SettlementPullRequest, + isAutoArchiveDue, + resolveAutoSettlementAt, +} from "./ThreadSettlementPolicy.ts"; const NOW = "2026-08-28T12:00:00.000Z"; const makeThread = ( @@ -311,3 +315,30 @@ describe("linked request settlement", () => { ); }); }); + +describe("isAutoArchiveDue", () => { + const settled = { settledOverride: "settled" as const, settledAt: "2026-07-28T12:00:00.000Z" }; + + it("archives once a thread has been settled for the configured days", () => { + expect(isAutoArchiveDue(makeThread(settled), NOW, 31)).toBe(true); + expect(isAutoArchiveDue(makeThread(settled), NOW, 32)).toBe(false); + expect(isAutoArchiveDue(makeThread(settled), NOW, null)).toBe(false); + }); + + it("leaves active, archived, and live threads alone", () => { + expect(isAutoArchiveDue(makeThread({ ...settled, settledOverride: "active" }), NOW, 1)).toBe( + false, + ); + expect(isAutoArchiveDue(makeThread({ ...settled, archivedAt: NOW }), NOW, 1)).toBe(false); + const session = { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + } as const; + expect(isAutoArchiveDue(makeThread({ ...settled, session }), NOW, 1)).toBe(false); + }); +}); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index 113d68204e59..7d1acdaf7da8 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -135,3 +135,18 @@ export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now: Date.parse(thread.latestTurn.completedAt) > Date.parse(thread.snoozedAt); return wokeOnError || wokeOnCompletion; } + +/** Whether a settled thread has stayed settled long enough to archive. */ +export function isAutoArchiveDue( + thread: Pick< + OrchestrationThreadShell, + "archivedAt" | "settledOverride" | "settledAt" | "session" + >, + now: string, + autoArchiveAfterDays: number | null, +): boolean { + if (autoArchiveAfterDays === null || thread.archivedAt !== null) return false; + if (thread.settledOverride !== "settled" || thread.settledAt === null) return false; + if (thread.session?.status === "starting" || thread.session?.status === "running") return false; + return Date.parse(thread.settledAt) <= Date.parse(now) - autoArchiveAfterDays * DAY_MS; +} diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 07afbaf05e6a..b903b518a8c9 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -188,6 +188,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const mergedPullRequests = yield* PubSub.unbounded(); const domainEvents = yield* PubSub.unbounded(); const commands = yield* Ref.make>([]); + const archivedThreadIds = yield* Ref.make>([]); const branchCalls = yield* Ref.make< ReadonlyArray<{ readonly cwd: string; readonly branch: string }> >([]); @@ -232,6 +233,11 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: }); const dispatch: OrchestrationEngineShape["dispatch"] = (command) => { + if (command.type === "thread.auto-archive") { + return Ref.update(archivedThreadIds, (ids) => [...ids, command.threadId]).pipe( + Effect.as({ sequence: 1 }), + ); + } if (command.type !== "thread.auto-settle") { return Effect.die(new Error(`Unexpected command: ${command.type}`)); } @@ -294,6 +300,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: snapshotReads, settingsReads, commands, + archivedThreadIds, branchCalls, summaryCalls, summaryRecovery, @@ -651,6 +658,57 @@ describe("ThreadSettlementReactor", () => { ), ); + it.effect("archives threads settled longer than the configured days", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const keptProject = ProjectId.make("kept-project"); + const longSettled = { + settledOverride: "settled", + settledAt: "2026-07-01T00:00:00.000Z", + } as const; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("old-settled", longSettled), + makeThread("recently-settled", { + settledOverride: "settled", + settledAt: "2026-08-20T00:00:00.000Z", + }), + makeThread("old-active", { settledAt: "2026-07-01T00:00:00.000Z" }), + makeThread("kept-project-settled", { ...longSettled, projectId: keptProject }), + ], + [makeProject(), makeProject(keptProject, "/workspace/kept")], + ), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + projectSettingsOverrides: { [keptProject]: { sidebarAutoArchiveAfterDays: null } }, + }, + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + yield* Queue.take(fixture.settingsReads); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.settingsReads); + yield* reactor.drain; + // Off by default: nothing archives until the setting is turned on. + assert.deepStrictEqual(yield* Ref.get(fixture.archivedThreadIds), []); + + yield* fixture.updateSettings({ sidebarAutoArchiveAfterDays: 30 }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.deepStrictEqual(yield* Ref.get(fixture.archivedThreadIds), [ + ThreadId.make("old-settled"), + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("a project override settles only that project's inactive threads", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 4192896efed5..bbad9d653138 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -25,6 +25,7 @@ import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; import { pullRequestMatchesProject } from "./ThreadPullRequestReactor.ts"; import { + isAutoArchiveDue, isAutoSettlementCandidate, resolveAutoSettlementAt, type SettlementPullRequest, @@ -51,12 +52,25 @@ function autoSettlementConfigured(settings: ServerSettingsValue): boolean { ); } +/** Whether any environment default or project override can archive a settled thread. */ +function autoArchiveConfigured(settings: ServerSettingsValue): boolean { + return ( + settings.sidebarAutoArchiveAfterDays !== null || + Object.values(settings.projectSettingsOverrides).some( + (entry) => + entry.sidebarAutoArchiveAfterDays !== undefined && + entry.sidebarAutoArchiveAfterDays !== null, + ) + ); +} + /** Identity of every settlement input, so unrelated settings edits do not trigger a sweep. */ /** @internal Exported for tests. */ export function autoSettlementSettingsKey(settings: ServerSettingsValue): string { return JSON.stringify([ settings.sidebarAutoSettleOnMerge, settings.sidebarAutoSettleAfterDays, + settings.sidebarAutoArchiveAfterDays, // 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. @@ -64,7 +78,8 @@ export function autoSettlementSettingsKey(settings: ServerSettingsValue): string .filter( ([, entry]) => entry.sidebarAutoSettleOnMerge !== undefined || - entry.sidebarAutoSettleAfterDays !== undefined, + entry.sidebarAutoSettleAfterDays !== undefined || + entry.sidebarAutoArchiveAfterDays !== undefined, ) .sort(([left], [right]) => left.localeCompare(right)) .map(([projectId, entry]) => [ @@ -73,6 +88,9 @@ export function autoSettlementSettingsKey(settings: ServerSettingsValue): string entry.sidebarAutoSettleAfterDays === undefined ? "inherit" : entry.sidebarAutoSettleAfterDays, + entry.sidebarAutoArchiveAfterDays === undefined + ? "inherit" + : entry.sidebarAutoArchiveAfterDays, ]), ]); } @@ -318,6 +336,52 @@ export const make = Effect.gen(function* () { ); }); + // Settled threads archive after their own delay. The command carries the + // settledAt it saw so a thread un-settled since the snapshot stays put. + const archiveSettled = Effect.fn("ThreadSettlementReactor.archiveSettled")(function* ( + threadId?: ThreadId, + ) { + const settings = yield* settingsService.getSettings; + if (!autoArchiveConfigured(settings)) { + return; + } + const snapshot = yield* snapshots.getShellSnapshot(); + const now = DateTime.formatIso(yield* DateTime.now); + const due = snapshot.threads.filter( + (thread) => + (threadId === undefined || thread.id === threadId) && + isAutoArchiveDue( + thread, + now, + resolveProjectSettings(settings, thread.projectId).settings.sidebarAutoArchiveAfterDays, + ), + ); + yield* Effect.forEach( + due, + (thread) => + Effect.gen(function* () { + if (thread.settledAt === null) return; + const uuid = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.auto-archive", + commandId: CommandId.make(`server:auto-archive:${thread.id}:${uuid}`), + threadId: thread.id, + settledAt: thread.settledAt, + }); + }).pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("automatic thread archive skipped", { + threadId: thread.id, + cause: Cause.pretty(cause), + }), + ), + ), + { concurrency: 8, discard: true }, + ); + }); + const runSweep = ( mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, threadId?: ThreadId, @@ -331,8 +395,18 @@ export const make = Effect.gen(function* () { }), ), ); + const runArchive = (threadId?: ThreadId) => + archiveSettled(threadId).pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("automatic thread archive sweep failed", { + cause: Cause.pretty(cause), + }), + ), + ); const worker = yield* makeDrainableWorker((threadId: ThreadId | undefined) => - runSweep(null, threadId), + runSweep(null, threadId).pipe(Effect.andThen(runArchive(threadId))), ); const processEvent = (event: OrchestrationEvent) => { diff --git a/apps/server/src/orchestration/decider.autoArchive.test.ts b/apps/server/src/orchestration/decider.autoArchive.test.ts new file mode 100644 index 000000000000..86b87f7172e4 --- /dev/null +++ b/apps/server/src/orchestration/decider.autoArchive.test.ts @@ -0,0 +1,127 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const SETTLED_AT = "2025-11-01T00:00:00.000Z"; + +function makeReadModel(input: { + readonly settledOverride: "settled" | "active" | null; + readonly settledAt: string | null; + readonly archivedAt?: string | null; +}): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: input.archivedAt ?? null, + settledOverride: input.settledOverride, + settledAt: input.settledAt, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +const autoArchive = (readModel: OrchestrationReadModel) => + decideOrchestrationCommand({ + command: { + type: "thread.auto-archive", + commandId: CommandId.make("cmd-auto-archive"), + threadId: ThreadId.make("thread-1"), + settledAt: SETTLED_AT, + }, + readModel, + }); + +it.layer(NodeServices.layer)("thread.auto-archive decider", (it) => { + it.effect("archives a thread that is still settled at the observed time", () => + Effect.gen(function* () { + const event = yield* autoArchive( + makeReadModel({ settledOverride: "settled", settledAt: SETTLED_AT }), + ); + const [archived] = Array.isArray(event) ? event : [event]; + expect(archived?.type).toBe("thread.archived"); + }), + ); + + it.effect("restoring a settled thread restarts its settled clock", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.unarchive", + commandId: CommandId.make("cmd-unarchive"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ + settledOverride: "settled", + settledAt: SETTLED_AT, + archivedAt: NOW, + }), + }); + const [restored] = Array.isArray(event) ? event : [event]; + expect(restored?.type).toBe("thread.unarchived"); + if (restored?.type !== "thread.unarchived") return; + // A newer settledAt gives the thread a fresh window and fails the guard + // of any auto-archive queued with the old one. + expect(restored.payload.settledAt).toBe(restored.payload.updatedAt); + expect(restored.payload.settledAt).not.toBe(SETTLED_AT); + }), + ); + + it.effect("restoring an active thread leaves settledAt alone", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.unarchive", + commandId: CommandId.make("cmd-unarchive-active"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ settledOverride: null, settledAt: null, archivedAt: NOW }), + }); + const [restored] = Array.isArray(event) ? event : [event]; + expect(restored?.type).toBe("thread.unarchived"); + expect(restored?.payload).not.toHaveProperty("settledAt"); + }), + ); + + it.effect.each([ + ["un-settled", { settledOverride: "active", settledAt: null }], + ["cleared by activity", { settledOverride: null, settledAt: null }], + ["settled again later", { settledOverride: "settled", settledAt: NOW }], + ["already archived", { settledOverride: "settled", settledAt: SETTLED_AT, archivedAt: NOW }], + ] as const)("rejects a thread %s since the snapshot", ([, state]) => + Effect.gen(function* () { + const result = yield* Effect.exit(autoArchive(makeReadModel(state))); + expect(result._tag).toBe("Failure"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 21df39a162e6..e250c588aecd 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -455,8 +455,37 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.auto-archive": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + if (thread.settledOverride !== "settled" || thread.settledAt !== command.settledAt) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} changed before automatic archive`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.archived", + payload: { + threadId: command.threadId, + archivedAt: occurredAt, + updatedAt: occurredAt, + }, + }; + } + case "thread.unarchive": { - yield* requireThreadArchived({ + const thread = yield* requireThreadArchived({ readModel, command, threadId: command.threadId, @@ -472,6 +501,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" type: "thread.unarchived", payload: { threadId: command.threadId, + ...(thread.settledOverride === "settled" ? { settledAt: occurredAt } : {}), updatedAt: occurredAt, }, }; diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 516b85fbdae8..ca2ca3df9885 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -298,6 +298,27 @@ describe("orchestration projector", () => { ), ); expect(unarchived.threads[0]?.archivedAt).toBeNull(); + + // Restoring a settled thread restarts its settled clock. + const restoredSettled = await Effect.runPromise( + projectEvent( + archived, + makeEvent({ + sequence: 3, + type: "thread.unarchived", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: later, + commandId: "cmd-thread-unarchive-settled", + payload: { + threadId: "thread-1", + settledAt: later, + updatedAt: later, + }, + }), + ), + ); + expect(restoredSettled.threads[0]?.settledAt).toBe(later); }); it("keeps projector forward-compatible for unhandled event types", async () => { diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 85d9db3fdfed..e0a900d35f6e 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -491,6 +491,7 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { archivedAt: null, + ...(payload.settledAt !== undefined ? { settledAt: payload.settledAt } : {}), updatedAt: payload.updatedAt, }), })), diff --git a/apps/web/src/components/settings/SettingInheritance.tsx b/apps/web/src/components/settings/SettingInheritance.tsx index e08ca680649f..6e771d531448 100644 --- a/apps/web/src/components/settings/SettingInheritance.tsx +++ b/apps/web/src/components/settings/SettingInheritance.tsx @@ -39,7 +39,7 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { if (value === null || value === undefined) { return key === "pullRequestMergeMethod" ? "Last selected" - : key === "sidebarAutoSettleAfterDays" + : key === "sidebarAutoSettleAfterDays" || key === "sidebarAutoArchiveAfterDays" ? "Never" : key === "defaultModelSelection" ? "Automatic" @@ -51,7 +51,7 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { } if (typeof value === "boolean") return value ? "On" : "Off"; if (typeof value === "number") { - return key === "sidebarAutoSettleAfterDays" + return key === "sidebarAutoSettleAfterDays" || key === "sidebarAutoArchiveAfterDays" ? `${value} ${value === 1 ? "day" : "days"}` : String(value); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f0a9113981b1..def4d6bb54aa 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -21,6 +21,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, + DEFAULT_SIDEBAR_AUTO_ARCHIVE_AFTER_DAYS, DEFAULT_UNIFIED_SETTINGS, type DiffLayout, type EnvironmentIdentificationMode, @@ -562,6 +563,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? ["Auto-settle merged threads"] : []), + ...(settings.sidebarAutoArchiveAfterDays !== + DEFAULT_UNIFIED_SETTINGS.sidebarAutoArchiveAfterDays + ? ["Auto-archive settled threads"] + : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...getChangedTypographySettingLabels(settings), ...(settings.diffFilesCollapsed !== DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed @@ -674,6 +679,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.continueThreadsAfterServerUpdate, settings.sidebarAutoSettleAfterDays, settings.sidebarAutoSettleOnMerge, + settings.sidebarAutoArchiveAfterDays, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.showSkillsInSlashMenu, @@ -773,6 +779,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, + sidebarAutoArchiveAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoArchiveAfterDays, responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, @@ -1968,9 +1975,11 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfter function AutoSettleDaysInput({ value, onCommit, + "aria-label": ariaLabel, }: { value: number; onCommit: (days: number) => void; + "aria-label": string; }) { // 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. @@ -2002,7 +2011,7 @@ function AutoSettleDaysInput({ } }} onBlur={() => setDraft(String(value))} - aria-label="Days of inactivity before auto-settle" + aria-label={ariaLabel} /> ); } @@ -2129,6 +2138,11 @@ export function GeneralSettingsPanel() { connectedEnvironments.every( (target) => target.serverConfig?.environment.capabilities.threadAutoSettlement === true, ); + const supportsAutoArchive = + supportsAutoSettlement && + connectedEnvironments.every( + (target) => target.serverConfig?.environment.capabilities.threadAutoArchive === true, + ); const supportsRestartContinuation = connectedEnvironments.length > 0 && connectedEnvironments.every( @@ -2301,6 +2315,61 @@ export function GeneralSettingsPanel() { updateSettings({ sidebarAutoSettleAfterDays: days })} + aria-label="Days of inactivity before auto-settle" + /> + } + /> + ) : null} + + ) : null} + + {supportsAutoArchive ? ( + <> + + updateSettings({ + sidebarAutoArchiveAfterDays: + DEFAULT_UNIFIED_SETTINGS.sidebarAutoArchiveAfterDays, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + sidebarAutoArchiveAfterDays: checked + ? DEFAULT_SIDEBAR_AUTO_ARCHIVE_AFTER_DAYS + : null, + }) + } + aria-label="Auto-archive settled threads" + /> + } + /> + {settings.sidebarAutoArchiveAfterDays !== null ? ( + updateSettings({ sidebarAutoArchiveAfterDays: days })} + aria-label="Days settled before auto-archive" /> } /> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 539e780baaa8..303922826565 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -292,6 +292,23 @@ export const SETTINGS_SEARCH_ITEMS = [ requiresThreadAutoSettlement: true, scope: "project-defaults", }, + { + id: "auto-archive-settled-threads", + title: "Auto-archive settled threads", + to: "/settings/general", + searchTerms: ["sidebar settled archive old threads automatically cleanup"], + requiresThreadAutoSettlement: true, + scope: "project-defaults", + }, + { + id: "days-before-auto-archive", + title: "Days settled before auto-archive", + to: "/settings/general", + targetId: "auto-archive-settled-threads", + searchTerms: ["thread archive settled sidebar"], + requiresThreadAutoSettlement: true, + scope: "project-defaults", + }, { id: "thread-notifications", title: "Thread notifications", diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 0d7cfb736774..8a47280d8bf6 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -104,6 +104,10 @@ choose **Auto-settle behavior**, and pick **Disabled**. The current option is ch **Enabled** to return to the usual rules. Manual settle, snooze, and archive still work while it is disabled. +Turn on **Auto-archive settled threads** to archive threads that stay settled for 30 days, or +choose another number of days. It is off by default. Archived threads stay in **Settings → Archive**, +where you can restore them. A restored thread starts a new count. + 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. diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index 3e9934f6f782..c26db151bea5 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -233,6 +233,14 @@ describe("filterSharedServerPatch", () => { ); }, ); + + it("sends auto-archive only to servers that archive settled threads", () => { + const patch = { sidebarAutoArchiveAfterDays: 30, sidebarAutoSettleAfterDays: 7 }; + expect(filterSharedServerPatch(patch, { threadAutoArchive: true })).toEqual(patch); + expect(filterSharedServerPatch(patch, restartCapabilities)).toEqual({ + sidebarAutoSettleAfterDays: 7, + }); + }); }); describe("findSharedSettingsMismatches", () => { diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index 9845764d5e07..9b92bdbd8049 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -25,6 +25,7 @@ const SHARED_SERVER_SETTING_KEYS = [ "continueThreadsAfterServerUpdate", "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", + "sidebarAutoArchiveAfterDays", "newWorktreesStartFromOrigin", "sourceControlWritingStyle", "textGenerationModelSelection", @@ -34,6 +35,12 @@ export type SharedServerSettingKey = (typeof SHARED_SERVER_SETTING_KEYS)[number] const SHARED_KEY_SET = new Set(SHARED_SERVER_SETTING_KEYS); +/** Capabilities that gate newer shared preferences. */ +type SharedSettingsCapabilities = Pick< + ExecutionEnvironmentCapabilities, + "threadRestartContinuation" | "threadAutoArchive" +>; + /** Split a server patch into the keys every environment should receive and the primary-only rest. */ export function splitSharedServerPatch(patch: ServerSettingsPatch): { sharedPatch: ServerSettingsPatch; @@ -57,7 +64,7 @@ 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: SharedSettingsCapabilities | undefined, settings?: ServerSettings, sourceSettings = settings, targetIsSource = false, @@ -79,15 +86,18 @@ export function filterSharedServerPatch( ) { patch = Struct.omit(patch, ["textGenerationModelSelection"]); } - return capabilities?.threadRestartContinuation === true + if (capabilities?.threadRestartContinuation !== true) { + patch = Struct.omit(patch, ["continueThreadsAfterServerUpdate"]); + } + return capabilities?.threadAutoArchive === true ? patch - : Struct.omit(patch, ["continueThreadsAfterServerUpdate"]); + : Struct.omit(patch, ["sidebarAutoArchiveAfterDays"]); } /** The shared subset supported by one environment. */ export function pickSharedServerSettings( settings: ServerSettings, - capabilities?: Pick, + capabilities?: SharedSettingsCapabilities, ): ServerSettingsPatch { return filterSharedServerPatch( Struct.pick(settings, SHARED_SERVER_SETTING_KEYS), @@ -119,9 +129,7 @@ export interface SharedSettingsEnvironment { readonly label: string; readonly syncEligible: boolean; readonly settings: ServerSettings | null; - readonly capabilities?: - | Pick - | undefined; + readonly capabilities?: SharedSettingsCapabilities | undefined; } /** @@ -135,9 +143,7 @@ export interface SharedSettingsEnvironment { export function findSharedSettingsMismatches(input: { readonly primaryEnvironmentId: EnvironmentId | null; readonly primarySettings: ServerSettings | null; - readonly primaryCapabilities?: - | Pick - | undefined; + readonly primaryCapabilities?: SharedSettingsCapabilities | undefined; readonly environments: ReadonlyArray; }): ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }> { if (input.primaryEnvironmentId === null || input.primarySettings === null) { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 66f78a0464ec..368c8be963f5 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -161,7 +161,12 @@ export function applyThreadDetailEvent( case "thread.unarchived": return { kind: "updated", - thread: { ...thread, archivedAt: null, updatedAt: event.payload.updatedAt }, + thread: { + ...thread, + archivedAt: null, + ...(event.payload.settledAt !== undefined ? { settledAt: event.payload.settledAt } : {}), + updatedAt: event.payload.updatedAt, + }, }; case "thread.settled": diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 089133d878a4..113742ab9288 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -144,6 +144,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.auto-settle.set (per-thread auto-settle off). Same version-skew contract as threadSettlement. */ threadAutoSettleOptOut: Schema.optionalKey(Schema.Boolean), + /** Server archives settled threads after `sidebarAutoArchiveAfterDays`. Absent on + older servers, which ignore the setting, so clients hide its controls. */ + threadAutoArchive: Schema.optionalKey(Schema.Boolean), /** Server understands regenerateTitle on thread.meta.update. Absent on older servers, so clients hide the action instead of sending it. */ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 1ae704d3f666..37135e92af7c 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1165,6 +1165,16 @@ const ThreadAutoSettleCommand = Schema.Struct({ settledAt: IsoDateTime, }); +// Archives a thread that is still settled with this settledAt. Carrying the +// value lets the decider reject a thread that was un-settled or re-settled +// after the server read its snapshot. +const ThreadAutoArchiveCommand = Schema.Struct({ + type: Schema.Literal("thread.auto-archive"), + commandId: CommandId, + threadId: ThreadId, + settledAt: IsoDateTime, +}); + const ThreadUnsettleCommand = Schema.Struct({ type: Schema.Literal("thread.unsettle"), commandId: CommandId, @@ -1660,6 +1670,7 @@ const ThreadPullRequestLinkSyncCommand = Schema.Struct({ const InternalOrchestrationCommand = Schema.Union([ ThreadAutoSettleCommand, + ThreadAutoArchiveCommand, ThreadPullRequestSyncCommand, ThreadPullRequestLinkSyncCommand, ThreadSessionSetCommand, @@ -1789,6 +1800,9 @@ export const ThreadArchivedPayload = Schema.Struct({ export const ThreadUnarchivedPayload = Schema.Struct({ threadId: ThreadId, + // Present when a settled thread is restored: its settled clock restarts so + // auto-archive does not take it straight back. Absent from older servers. + settledAt: Schema.optional(IsoDateTime), updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a8cce4487549..e92f5910b228 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -89,6 +89,8 @@ export const SidebarAutoSettleAfterDays = Schema.Number.check( ); export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type; const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; +/** Days a thread stays settled before it archives, when auto-archive is on. */ +export const DEFAULT_SIDEBAR_AUTO_ARCHIVE_AFTER_DAYS: SidebarAutoSettleAfterDays = 30; export const MIN_GLASS_OPACITY = 40; export const MAX_GLASS_OPACITY = 100; export const GlassOpacity = Schema.Int.check( @@ -1015,6 +1017,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "pullRequestMergeMethod", "sidebarAutoSettleOnMerge", "sidebarAutoSettleAfterDays", + "sidebarAutoArchiveAfterDays", "continueThreadsAfterServerUpdate", "responseStreamingMode", ] as const; @@ -1023,7 +1026,7 @@ export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTIN /** * One project's overrides. An absent key inherits the environment value; * `null` is a real value where the environment type is nullable (no default - * model, no dedicated writer model, never auto-settle). + * model, no dedicated writer model, never auto-settle or auto-archive). */ export const ProjectSettingsOverrides = Schema.Struct({ worktreeCleanup: Schema.optionalKey(WorktreeCleanup), @@ -1042,6 +1045,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), + sidebarAutoArchiveAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), } satisfies Record); @@ -1065,6 +1069,7 @@ const NULLABLE_PROJECT_SETTINGS_OVERRIDES: ReadonlySet