diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index ce9e0fb48979..1360fb545bd0 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -80,6 +80,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), + ...(typeof sourceItem.checked === "boolean" ? { checked: sourceItem.checked } : {}), }; if (sourceItem.children) { @@ -168,6 +169,7 @@ export const make = Effect.gen(function* () { const itemOption: Electron.MenuItemConstructorOptions = { label: item.label, enabled: !item.disabled, + ...(typeof item.checked === "boolean" ? { type: "checkbox", checked: item.checked } : {}), }; if (item.children && item.children.length > 0) { itemOption.submenu = buildTemplate(item.children, complete); diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index d85d9db7d780..eef05ee94f40 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -140,6 +140,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "checkmark.circle": IconCircleCheck, circle: IconCircle, clock: IconClock, + timer: IconClock, ticket: IconTicket, cloud: IconCloud, cube: IconBox, diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 1ef8ea7b286b..6adae686442f 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -49,6 +49,7 @@ export function HomeRouteScreen() { unsnoozeThread, pinThread, unpinThread, + setThreadAutoSettle, moveThread, renameThread, regenerateThreadTitle, @@ -214,6 +215,7 @@ export function HomeRouteScreen() { onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onSetThreadAutoSettle={setThreadAutoSettle} onMoveThread={moveThread} onRenameThread={renameThread} onRegenerateThreadTitle={regenerateThreadTitle} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index a01f5fbe606e..137bd320b748 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -123,6 +123,10 @@ interface HomeScreenProps { readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise; + readonly onSetThreadAutoSettle: ( + thread: EnvironmentThreadShell, + enabled: boolean, + ) => Promise; readonly onMoveThread: ( thread: EnvironmentThreadShell, direction: ThreadMoveDestination, @@ -539,6 +543,12 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onUnpinThread], ); + const handleSetThreadAutoSettle = useCallback( + (thread: EnvironmentThreadShell, enabled: boolean) => { + void props.onSetThreadAutoSettle(thread, enabled); + }, + [props.onSetThreadAutoSettle], + ); const handleRegenerateThreadTitle = useCallback( (thread: EnvironmentThreadShell) => { void props.onRegenerateThreadTitle(thread); @@ -618,6 +628,15 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const autoSettleOptOutEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadAutoSettleOptOut === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const pinReorderEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -886,6 +905,7 @@ export function HomeScreen(props: HomeScreenProps) { onSettleThread={handleSettleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)} reorderSupported={ item.item.pinned ? pinReorderEnvironmentIds.has(thread.environmentId) @@ -898,6 +918,7 @@ export function HomeScreen(props: HomeScreenProps) { onUnsettleThread={handleUnsettleThread} onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} + onSetThreadAutoSettle={handleSetThreadAutoSettle} onMoveThread={handleMoveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -921,6 +942,8 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableClose, handleSwipeableWillOpen, handleUnsettleThread, + handleSetThreadAutoSettle, + autoSettleOptOutEnvironmentIds, pinningEnvironmentIds, machineByEnvironmentId, pinReorderEnvironmentIds, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 9b599110883c..4a43facc6172 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -59,6 +59,15 @@ function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["en ); } +function environmentSupportsAutoSettleOptOut( + environmentId: EnvironmentThreadShell["environmentId"], +) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadAutoSettleOptOut === true + ); +} + function environmentSupportsTitleRegeneration( environmentId: EnvironmentThreadShell["environmentId"], ) { @@ -237,6 +246,11 @@ export function useThreadListActions(): { readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; readonly pinThread: (thread: EnvironmentThreadShell) => Promise; readonly unpinThread: (thread: EnvironmentThreadShell) => Promise; + /** Sets per-thread automatic settlement on or off. */ + readonly setThreadAutoSettle: ( + thread: EnvironmentThreadShell, + enabled: boolean, + ) => Promise; readonly moveThread: ( thread: EnvironmentThreadShell, direction: ThreadMoveDestination, @@ -249,6 +263,9 @@ export function useThreadListActions(): { const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false }); const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false }); + const setAutoSettleMutation = useAtomCommand(threadEnvironment.setAutoSettle, { + reportFailure: false, + }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -435,6 +452,34 @@ export function useThreadListActions(): { }, [unpinMutation], ); + const setThreadAutoSettle = useCallback( + async (thread: EnvironmentThreadShell, enabled: boolean) => { + if (!environmentSupportsAutoSettleOptOut(thread.environmentId)) { + Alert.alert( + "Could not update auto-settle", + "This environment's server does not support turning auto-settle off per thread yet. Update the server to use it.", + ); + return false; + } + selectionHaptic(); + const result = await setAutoSettleMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, enabled }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not update auto-settle", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The auto-settle setting could not be changed.", + ); + return false; + } + return true; + }, + [setAutoSettleMutation], + ); const regenerateThreadTitle = useCallback( async (thread: EnvironmentThreadShell) => { const key = scopedThreadKey(thread.environmentId, thread.id); @@ -698,6 +743,7 @@ export function useThreadListActions(): { unsettleThread, pinThread, unpinThread, + setThreadAutoSettle, moveThread, renameThread, regenerateThreadTitle, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index bf957ad54195..8db9007f419c 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -172,6 +172,7 @@ function ThreadNavigationSidebarPane( unsettleThread, pinThread, unpinThread, + setThreadAutoSettle, moveThread, renameThread, regenerateThreadTitle, @@ -446,6 +447,15 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const autoSettleOptOutEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadAutoSettleOptOut === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const pinReorderEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -950,6 +960,7 @@ function ThreadNavigationSidebarPane( onSettleThread={settleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)} reorderSupported={ item.item.pinned ? pinReorderEnvironmentIds.has(thread.environmentId) @@ -962,6 +973,7 @@ function ThreadNavigationSidebarPane( onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onSetThreadAutoSettle={setThreadAutoSettle} onMoveThread={moveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -1094,6 +1106,8 @@ function ThreadNavigationSidebarPane( pinReorderEnvironmentIds, pinThread, pinningEnvironmentIds, + autoSettleOptOutEnvironmentIds, + setThreadAutoSettle, projectByKey, projectTitleByProjectKey, regenerateThreadTitle, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 797781d16a4c..288a9960dbbb 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -492,6 +492,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => void; readonly onUnpinThread: (thread: EnvironmentThreadShell) => void; + readonly onSetThreadAutoSettle: (thread: EnvironmentThreadShell, enabled: boolean) => void; /** False on environments whose server predates thread.settle/unsettle: swipe + menu fall back to Archive instead of failing on use. */ readonly settlementSupported: boolean; @@ -499,6 +500,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly snoozeSupported: boolean; /** False on servers that predate thread.pin/unpin. */ readonly pinningSupported: boolean; + /** False on servers that predate thread.auto-settle.set. */ + readonly autoSettleOptOutSupported: boolean; /** False on servers that predate thread title regeneration. */ readonly titleRegenerationSupported: boolean; /** Server supports reordering this card's section. */ @@ -535,6 +538,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onArchiveThread, onPinThread, onUnpinThread, + onSetThreadAutoSettle, onMoveThread, } = props; const snoozedRow = props.snoozed === true; @@ -573,6 +577,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]); const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]); + const handleSetAutoSettle = useCallback( + (enabled: boolean) => onSetThreadAutoSettle(thread, enabled), + [onSetThreadAutoSettle, thread], + ); const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]); const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); @@ -651,6 +659,33 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { variant, ], ); + // A submenu with the current option checked, matching web. This is a + // per-thread setting, not a lifecycle verb. + const autoSettleMenuItems = useMemo( + () => + props.autoSettleOptOutSupported + ? [ + { + id: "auto-settle", + title: "Auto-settle behavior", + image: "timer", + subactions: [ + { + id: "auto-settle:enabled", + title: "Enabled", + state: thread.autoSettleDisabledAt == null ? "on" : "off", + }, + { + id: "auto-settle:disabled", + title: "Disabled", + state: thread.autoSettleDisabledAt == null ? "off" : "on", + }, + ], + } satisfies MenuAction, + ] + : [], + [props.autoSettleOptOutSupported, thread.autoSettleDisabledAt], + ); const titleMenuItems = useMemo( () => [ { id: "rename", title: "Rename", image: "square.and.pencil" }, @@ -672,19 +707,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }, ...arrangementMenuItems, ...titleMenuItems, + ...autoSettleMenuItems, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [arrangementMenuItems, snoozePresetActions, titleMenuItems], + [arrangementMenuItems, autoSettleMenuItems, snoozePresetActions, titleMenuItems], ); const cardMenuActions = useMemo( () => [ CARD_MENU_ACTIONS[0]!, ...arrangementMenuItems, ...titleMenuItems, + ...autoSettleMenuItems, ...CARD_MENU_ACTIONS.slice(1), ], - [arrangementMenuItems, titleMenuItems], + [arrangementMenuItems, autoSettleMenuItems, titleMenuItems], ); + // Settled and snoozed rows keep the setting too, matching web where every + // row shares one menu builder. const slimMenuActions = useMemo( () => [ SLIM_MENU_ACTIONS[0]!, @@ -692,13 +731,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { (action) => action.id !== "move-up" && action.id !== "move-down", ), ...titleMenuItems, + ...autoSettleMenuItems, SLIM_MENU_ACTIONS[1]!, ], - [arrangementMenuItems, titleMenuItems], + [arrangementMenuItems, autoSettleMenuItems, titleMenuItems], ); const snoozedMenuActions = useMemo( - () => [SNOOZED_MENU_ACTIONS[0]!, ...titleMenuItems, SNOOZED_MENU_ACTIONS[1]!], - [titleMenuItems], + () => [ + SNOOZED_MENU_ACTIONS[0]!, + ...titleMenuItems, + ...autoSettleMenuItems, + SNOOZED_MENU_ACTIONS[1]!, + ], + [autoSettleMenuItems, titleMenuItems], ); const legacyMenuActions = useMemo( () => [ @@ -717,6 +762,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "pin") handlePin(); if (nativeEvent.event === "unpin") handleUnpin(); + if (nativeEvent.event === "auto-settle:enabled") handleSetAutoSettle(true); + if (nativeEvent.event === "auto-settle:disabled") handleSetAutoSettle(false); if (nativeEvent.event === "arrange") appAtomRegistry.set(threadArrangementOpenAtom, true); if (nativeEvent.event === "move-up") handleMoveUp(); if (nativeEvent.event === "move-down") handleMoveDown(); @@ -754,6 +801,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handlePin, handleSettle, handleSnooze, + handleSetAutoSettle, handleUnpin, handleUnsettle, handleUnsnooze, diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index d922eb7f6d5c..108afbbc9416 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -74,6 +74,7 @@ function threadDetailToShell( settledAt: thread.settledAt, unsettledAt: thread.unsettledAt, activeOrderKey: thread.activeOrderKey, + autoSettleDisabledAt: thread.autoSettleDisabledAt, pinnedAt: thread.pinnedAt, pinOrderKey: thread.pinOrderKey, snoozedUntil: thread.snoozedUntil ?? null, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index a36c0a03b6f2..17fe73cc326f 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* () { threadPinning: true, threadPinReorder: true, threadActiveReorder: true, + threadAutoSettleOptOut: 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 b5e6cb0cdd54..4163168157e7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -633,6 +633,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti pinnedAt: null, pinOrderKey: null, activeOrderKey: null, + autoSettleDisabledAt: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, latestUserMessageAt: null, @@ -782,6 +783,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.auto-settle-set": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + autoSettleDisabledAt: event.payload.autoSettleDisabledAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.pin-reordered": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 843eb8343d84..890c8ae55c53 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -485,6 +485,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", activeOrderKey: "hq", + autoSettleDisabledAt: null, titleRegeneration: null, titleState: null, deletedAt: null, @@ -611,6 +612,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", activeOrderKey: "hq", + autoSettleDisabledAt: null, titleRegeneration: null, titleState: null, session: { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 1e7058742e25..1b44054c7a32 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -587,6 +587,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", active_order_key AS "activeOrderKey", + auto_settle_disabled_at AS "autoSettleDisabledAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -628,6 +629,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", active_order_key AS "activeOrderKey", + auto_settle_disabled_at AS "autoSettleDisabledAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -701,6 +703,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", active_order_key AS "activeOrderKey", + auto_settle_disabled_at AS "autoSettleDisabledAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -1266,6 +1269,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", active_order_key AS "activeOrderKey", + auto_settle_disabled_at AS "autoSettleDisabledAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -2341,6 +2345,7 @@ pending_approval_requests AS ( pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, + autoSettleDisabledAt: row.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, deletedAt: row.deletedAt, @@ -2586,6 +2591,7 @@ pending_approval_requests AS ( pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, + autoSettleDisabledAt: row.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, deletedAt: row.deletedAt, @@ -2742,6 +2748,7 @@ pending_approval_requests AS ( pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, + autoSettleDisabledAt: row.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, session: sessionByThread.get(row.threadId) ?? null, @@ -2905,6 +2912,7 @@ pending_approval_requests AS ( pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, + autoSettleDisabledAt: row.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, session: sessionByThread.get(row.threadId) ?? null, @@ -3261,6 +3269,7 @@ pending_approval_requests AS ( pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, activeOrderKey: threadRow.value.activeOrderKey ?? null, + autoSettleDisabledAt: threadRow.value.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), titleState: threadRow.value.titleState, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, @@ -3562,6 +3571,7 @@ pending_approval_requests AS ( pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, activeOrderKey: threadRow.value.activeOrderKey ?? null, + autoSettleDisabledAt: threadRow.value.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), titleState: threadRow.value.titleState, deletedAt: null, diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 29468dc3f84e..f4fe2e0104f6 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -16,6 +16,7 @@ import { ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema, + ThreadAutoSettleSetPayload as ContractsThreadAutoSettleSetPayloadSchema, ThreadPullRequestLinkedPayload as ContractsThreadPullRequestLinkedPayloadSchema, ThreadPullRequestUnlinkedPayload as ContractsThreadPullRequestUnlinkedPayloadSchema, ThreadPullRequestSyncedPayload as ContractsThreadPullRequestSyncedPayloadSchema, @@ -51,6 +52,7 @@ export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; +export const ThreadAutoSettleSetPayload = ContractsThreadAutoSettleSetPayloadSchema; export const ThreadPullRequestLinkedPayload = ContractsThreadPullRequestLinkedPayloadSchema; export const ThreadPullRequestUnlinkedPayload = ContractsThreadPullRequestUnlinkedPayloadSchema; export const ThreadPullRequestSyncedPayload = ContractsThreadPullRequestSyncedPayloadSchema; diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts index 252b99439400..8a1588b18752 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -171,6 +171,15 @@ describe("resolveAutoSettlementAt", () => { it("blocks pins, snooze, pending work, live sessions, and queued starts", () => { expect(decide(makeThread({ settledOverride: "active" }))).toBe(false); + }); + + it("never settles a thread whose auto-settle is turned off, by inactivity or merge", () => { + const held = makeThread({ autoSettleDisabledAt: "2026-08-21T00:00:00.000Z" }); + expect(decide(held)).toBe(false); + expect( + decide(held, { state: "merged", mergedAt: "2026-08-21T00:00:00.000Z", closedAt: null }), + ).toBe(false); + expect(decide(makeThread({ autoSettleDisabledAt: null }))).toBe(true); expect(decide(makeThread({ snoozedUntil: "2026-08-29T00:00:00.000Z" }))).toBe(false); expect(decide(makeThread({ hasPendingApprovals: true }))).toBe(false); expect(decide(makeThread({ hasPendingUserInput: true }))).toBe(false); @@ -248,6 +257,21 @@ const terminalSnapshot = ( syncedAt: NOW, }); +describe("per-thread auto-settle opt out", () => { + it("blocks both inactivity and merge settlement while auto-settle is off", () => { + const merged = linkedRequest(1, terminalSnapshot("merged", NOW)); + expect(decide(makeThread({ latestUserMessageAt: "2026-08-01T00:00:00.000Z" }))).toBe(true); + expect(decide(makeThread({ pullRequests: [merged] }), null, { days: null })).toBe(true); + const held = { autoSettleDisabledAt: NOW }; + expect(decide(makeThread({ ...held, latestUserMessageAt: "2026-08-01T00:00:00.000Z" }))).toBe( + false, + ); + expect(decide(makeThread({ ...held, pullRequests: [merged] }), null, { days: null })).toBe( + false, + ); + }); +}); + describe("linked request settlement", () => { it.each(["closed", "merged"] as const)( "uses the latest actual %s transition despite later comments on another PR", diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index 92063745eff5..113d68204e59 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -117,6 +117,7 @@ export function resolveAutoSettlementAt(input: { /** Cheap checks that run before any source control lookup. */ export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now: string): boolean { if (thread.archivedAt !== null || thread.settledOverride !== null) return false; + if (thread.autoSettleDisabledAt != null) return false; if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; if (thread.session?.status === "starting" || thread.session?.status === "running") return false; if (thread.backgroundLiveness != null) return false; diff --git a/apps/server/src/orchestration/decider.autoSettleSet.test.ts b/apps/server/src/orchestration/decider.autoSettleSet.test.ts new file mode 100644 index 000000000000..99657069cc74 --- /dev/null +++ b/apps/server/src/orchestration/decider.autoSettleSet.test.ts @@ -0,0 +1,154 @@ +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 DISABLED_AT = "2025-12-30T00:00:00.000Z"; + +function makeReadModel(input: { + readonly autoSettleDisabledAt?: string | null; + readonly settledOverride?: "settled" | "active" | 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: null, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledOverride === "settled" ? NOW : null, + autoSettleDisabledAt: input.autoSettleDisabledAt ?? null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +const events = (event: Effect.Success>) => + Array.isArray(event) ? event : [event]; + +it.layer(NodeServices.layer)("thread.auto-settle.set decider", (it) => { + it.effect("turning auto-settle off stamps autoSettleDisabledAt and updatedAt together", () => + Effect.gen(function* () { + const [event] = events( + yield* decideOrchestrationCommand({ + command: { + type: "thread.auto-settle.set", + commandId: CommandId.make("cmd-off"), + threadId: ThreadId.make("thread-1"), + enabled: false, + }, + readModel: makeReadModel({}), + }), + ); + expect(event?.type).toBe("thread.auto-settle-set"); + if (event?.type === "thread.auto-settle-set") { + expect(event.payload.autoSettleDisabledAt).toBe(event.payload.updatedAt); + expect(event.payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("turning it off again keeps the original stamp and updatedAt", () => + Effect.gen(function* () { + const [event] = events( + yield* decideOrchestrationCommand({ + command: { + type: "thread.auto-settle.set", + commandId: CommandId.make("cmd-off-again"), + threadId: ThreadId.make("thread-1"), + enabled: false, + }, + readModel: makeReadModel({ autoSettleDisabledAt: DISABLED_AT }), + }), + ); + expect(event?.type).toBe("thread.auto-settle-set"); + if (event?.type === "thread.auto-settle-set") { + expect(event.payload.autoSettleDisabledAt).toBe(DISABLED_AT); + expect(event.payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("turning auto-settle back on clears the stamp", () => + Effect.gen(function* () { + const [event] = events( + yield* decideOrchestrationCommand({ + command: { + type: "thread.auto-settle.set", + commandId: CommandId.make("cmd-on"), + threadId: ThreadId.make("thread-1"), + enabled: true, + }, + readModel: makeReadModel({ autoSettleDisabledAt: DISABLED_AT }), + }), + ); + expect(event?.type).toBe("thread.auto-settle-set"); + if (event?.type === "thread.auto-settle-set") { + expect(event.payload.autoSettleDisabledAt).toBeNull(); + expect(event.payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("automatic settlement is rejected while auto-settle is off", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decideOrchestrationCommand({ + command: { + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto"), + threadId: ThreadId.make("thread-1"), + snapshotSequence: 0, + settledAt: NOW, + }, + readModel: makeReadModel({ autoSettleDisabledAt: DISABLED_AT }), + }), + ); + expect(result._tag).toBe("Failure"); + }), + ); + + it.effect("a manual settle still works while auto-settle is off", () => + Effect.gen(function* () { + const [event] = events( + yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-manual"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ autoSettleDisabledAt: DISABLED_AT }), + }), + ); + expect(event?.type).toBe("thread.settled"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 0119c0e8599a..b610f3868525 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -484,7 +484,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); - if (command.type === "thread.auto-settle" && thread.settledOverride !== null) { + if ( + command.type === "thread.auto-settle" && + (thread.settledOverride !== null || thread.autoSettleDisabledAt != null) + ) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, @@ -860,6 +863,37 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.auto-settle.set": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Idempotent by re-emission (see thread.unpin): setting the current + // state again keeps the existing timestamps so duplicates do not churn + // ordering. The flag is independent of the settled lifecycle: it only + // gates the automatic paths, so it never blocks a manual settle. + const currentlyDisabledAt = thread.autoSettleDisabledAt ?? null; + const unchanged = command.enabled + ? currentlyDisabledAt === null + : currentlyDisabledAt !== null; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.auto-settle-set", + payload: { + threadId: command.threadId, + autoSettleDisabledAt: command.enabled ? null : (currentlyDisabledAt ?? occurredAt), + updatedAt: unchanged ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.active.reorder": { const thread = yield* requireThreadNotArchived({ readModel, diff --git a/apps/server/src/orchestration/projector.autoSettleSet.test.ts b/apps/server/src/orchestration/projector.autoSettleSet.test.ts new file mode 100644 index 000000000000..cc12f6905910 --- /dev/null +++ b/apps/server/src/orchestration/projector.autoSettleSet.test.ts @@ -0,0 +1,105 @@ +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +function makeEvent(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +it.effect("projects auto-settle opt-out and survives a manual settle", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const later = "2026-01-02T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + expect(created.threads[0]?.autoSettleDisabledAt ?? null).toBeNull(); + + const disabled = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.auto-settle-set", + payload: { threadId: ThreadId.make("thread-1"), autoSettleDisabledAt: now, updatedAt: now }, + }), + ); + expect(disabled.threads[0]?.autoSettleDisabledAt).toBe(now); + + // The flag is independent of the settled lifecycle: settling by hand and + // un-settling later must not clear it. + const settled = yield* projectEvent( + disabled, + makeEvent({ + sequence: 3, + type: "thread.settled", + payload: { threadId: ThreadId.make("thread-1"), settledAt: later, updatedAt: later }, + }), + ); + expect(settled.threads[0]?.settledOverride).toBe("settled"); + expect(settled.threads[0]?.autoSettleDisabledAt).toBe(now); + + const unsettled = yield* projectEvent( + settled, + makeEvent({ + sequence: 4, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: later }, + }), + ); + expect(unsettled.threads[0]?.autoSettleDisabledAt).toBe(now); + + const enabled = yield* projectEvent( + unsettled, + makeEvent({ + sequence: 5, + type: "thread.auto-settle-set", + payload: { + threadId: ThreadId.make("thread-1"), + autoSettleDisabledAt: null, + updatedAt: later, + }, + }), + ); + expect(enabled.threads[0]?.autoSettleDisabledAt).toBeNull(); + }), +); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index c4e1996f1ddd..516b85fbdae8 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -93,6 +93,7 @@ describe("orchestration projector", () => { updatedAt: now, archivedAt: null, activeOrderKey: null, + autoSettleDisabledAt: null, settledOverride: null, settledAt: null, unsettledAt: null, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 53013770b15b..85d9db3fdfed 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -42,6 +42,7 @@ import { ThreadSettledPayload, ThreadPinnedPayload, ThreadPinReorderedPayload, + ThreadAutoSettleSetPayload, ThreadPullRequestLinkedPayload, ThreadPullRequestSyncedPayload, ThreadPullRequestUnlinkedPayload, @@ -440,6 +441,7 @@ export function projectEvent( settledAt: null, unsettledAt: null, activeOrderKey: null, + autoSettleDisabledAt: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -580,6 +582,17 @@ export function projectEvent( })), ); + case "thread.auto-settle-set": + return decodeForEvent(ThreadAutoSettleSetPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + autoSettleDisabledAt: payload.autoSettleDisabledAt, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.pin-reordered": return decodeForEvent(ThreadPinReorderedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index af36578f286e..595bc0b5594c 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -54,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pinned_at, pin_order_key, active_order_key, + auto_settle_disabled_at, title_regeneration_request_id, title_regeneration_started_at, latest_user_message_at, @@ -86,6 +87,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.pinnedAt}, ${row.pinOrderKey ?? null}, ${row.activeOrderKey ?? null}, + ${row.autoSettleDisabledAt ?? null}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, ${row.latestUserMessageAt}, @@ -118,6 +120,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pinned_at = excluded.pinned_at, pin_order_key = excluded.pin_order_key, active_order_key = excluded.active_order_key, + auto_settle_disabled_at = excluded.auto_settle_disabled_at, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, latest_user_message_at = excluded.latest_user_message_at, @@ -157,6 +160,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", active_order_key AS "activeOrderKey", + auto_settle_disabled_at AS "autoSettleDisabledAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 18aae09febf8..c837ae3f4c50 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -65,6 +65,7 @@ import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts"; import Migration0051 from "./Migrations/051_ProjectionThreadMessageContext.ts"; import Migration0052 from "./Migrations/052_ProjectionThreadTitleState.ts"; import Migration0053 from "./Migrations/053_PullRequestFilesViewed.ts"; +import Migration0054 from "./Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts"; /** * Migration loader with all migrations defined inline. @@ -130,6 +131,7 @@ const migrationEntries = [ [51, "ProjectionThreadMessageContext", Migration0051], [52, "ProjectionThreadTitleState", Migration0052], [53, "PullRequestFilesViewed", Migration0053], + [54, "ProjectionThreadsAutoSettleDisabledAt", Migration0054], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.test.ts b/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.test.ts new file mode 100644 index 000000000000..df6403316e5b --- /dev/null +++ b/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.test.ts @@ -0,0 +1,41 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +import { runMigrations } from "../Migrations.ts"; +import migrateAutoSettleDisabledAt from "./054_ProjectionThreadsAutoSettleDisabledAt.ts"; + +it.layer(NodeSqliteClient.layer({ filename: ":memory:" }))( + "054_ProjectionThreadsAutoSettleDisabledAt", + (it) => { + it.effect("adds the column with auto-settle left on for existing threads", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 53 }); + const now = "2026-01-01T00:00:00.000Z"; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, + created_at, updated_at + ) VALUES ( + 'thread-1', 'project-1', 'Existing thread', + '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', ${now}, ${now} + ) + `; + yield* runMigrations({ toMigrationInclusive: 54 }); + const migrated = yield* sql<{ readonly autoSettleDisabledAt: string | null }>` + SELECT auto_settle_disabled_at AS "autoSettleDisabledAt" FROM projection_threads WHERE thread_id = 'thread-1' + `; + assert.deepEqual(migrated, [{ autoSettleDisabledAt: null }]); + // Re-running against a database that already has the column keeps its value. + yield* sql`UPDATE projection_threads SET auto_settle_disabled_at = ${now} WHERE thread_id = 'thread-1'`; + yield* migrateAutoSettleDisabledAt; + const rows = yield* sql<{ readonly autoSettleDisabledAt: string | null }>` + SELECT auto_settle_disabled_at AS "autoSettleDisabledAt" FROM projection_threads WHERE thread_id = 'thread-1' + `; + assert.deepEqual(rows, [{ autoSettleDisabledAt: now }]); + }), + ); + }, +); diff --git a/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts b/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts new file mode 100644 index 000000000000..f91f6d8abdfa --- /dev/null +++ b/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts @@ -0,0 +1,15 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + if (!columns.some((column) => column.name === "auto_settle_disabled_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN auto_settle_disabled_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 895fe596db24..abd964382a09 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -50,6 +50,7 @@ export const ProjectionThread = Schema.Struct({ pinnedAt: Schema.NullOr(IsoDateTime), pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)), activeOrderKey: Schema.optional(Schema.NullOr(Schema.String)), + autoSettleDisabledAt: Schema.optional(Schema.NullOr(IsoDateTime)), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), latestUserMessageAt: Schema.NullOr(IsoDateTime), diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 5ebb41a1bb54..bf9eb702ebe5 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -87,6 +87,7 @@ function hasImportBlockingActivity( thread.snoozedAt != null || thread.pinnedAt != null || thread.pinOrderKey != null || + thread.autoSettleDisabledAt != null || thread.titleRegeneration != null || thread.linkedPullRequest != null || thread.unsettledAt != null || diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 95f0e062d8e0..94ad6f696958 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2169,6 +2169,7 @@ export default function Sidebar() { confirmAndUnpinThread, reorderPinnedThread, reorderActiveThread, + setThreadAutoSettle, archiveThread, deleteThread, } = useThreadActions(); @@ -4035,6 +4036,9 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const supportsPinning = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true; + const supportsAutoSettleOptOut = + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadAutoSettleOptOut === true; const supportsTitleRegeneration = serverConfigs.get(thread.environmentId)?.environment.capabilities .threadTitleRegeneration === true; @@ -4064,6 +4068,7 @@ export default function Sidebar() { : null, isPinned, isSettled, + autoSettleEnabled: thread.autoSettleDisabledAt == null, isSnoozed, canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), isRegeneratingTitle, @@ -4071,6 +4076,7 @@ export default function Sidebar() { thread.session?.status === "running" && thread.session.activeTurnId != null, supports: { settlement: supportsSettlement, + autoSettleOptOut: supportsAutoSettleOptOut, snooze: supportsSnooze, pinning: supportsPinning, titleRegeneration: supportsTitleRegeneration, @@ -4142,6 +4148,24 @@ export default function Sidebar() { case "unpin": attemptUnpin(threadRef); return; + case "auto-settle:enabled": + case "auto-settle:disabled": { + const result = await setThreadAutoSettle( + threadRef, + clicked.value === "auto-settle:enabled", + ); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to update auto-settle", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } case "rename": startThreadRename(threadRef, thread.title); return; @@ -4268,6 +4292,7 @@ export default function Sidebar() { projectByKey, serverConfigs, setProjectScopeKey, + setThreadAutoSettle, startThreadRename, updateThreadMetadata, timestampFormat, diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 14965d4add58..c5ebeda55627 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -7,11 +7,18 @@ const baseState: ThreadActionMenuState = { projectFilter: null, isPinned: false, isSettled: false, + autoSettleEnabled: true, isSnoozed: false, canSnoozeNow: true, isRegeneratingTitle: false, isRunning: false, - supports: { settlement: true, snooze: true, pinning: true, titleRegeneration: true }, + supports: { + settlement: true, + autoSettleOptOut: true, + snooze: true, + pinning: true, + titleRegeneration: true, + }, snoozePresets: [ { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" }, ], @@ -32,7 +39,13 @@ describe("buildThreadActionMenuItems", () => { expect( ids({ ...baseState, - supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, + supports: { + settlement: false, + autoSettleOptOut: false, + snooze: false, + pinning: false, + titleRegeneration: false, + }, }), ).toEqual(["rename", "mark-unread", "copy", "project-settings", "archive", "delete"]); }); @@ -66,7 +79,7 @@ describe("buildThreadActionMenuItems", () => { const filterIndex = items.findIndex((candidate) => candidate.id === "filter-by-project"); expect(items[filterIndex]).toMatchObject({ label: "Show all projects", icon: "folder-tree" }); expect(items[filterIndex - 1]?.id).toBe("mark-unread"); - expect(items[filterIndex + 1]?.id).toBe("copy"); + expect(items[filterIndex + 1]?.id).toBe("auto-settle"); }); it("includes branch items only for threads with a branch", () => { @@ -84,6 +97,25 @@ describe("buildThreadActionMenuItems", () => { expect(ids(baseState)).toEqual(expect.arrayContaining(["pin", "settle", "snooze"])); }); + it("offers auto-settle as a submenu with the current option checked", () => { + const find = (state: ThreadActionMenuState) => + buildThreadActionMenuItems(state).find((item) => item.id === "auto-settle"); + const on = find(baseState); + expect(on?.label).toBe("Auto-settle behavior"); + expect(on?.children?.map((child) => [child.id, child.checked])).toEqual([ + ["auto-settle:enabled", true], + ["auto-settle:disabled", false], + ]); + const off = find({ ...baseState, autoSettleEnabled: false }); + expect(off?.children?.map((child) => child.checked)).toEqual([false, true]); + // Sits with the per-thread settings after Mark unread, not the lifecycle verbs. + const items = buildThreadActionMenuItems(baseState); + expect(items[items.findIndex((item) => item.id === "mark-unread") + 1]?.id).toBe("auto-settle"); + expect( + ids({ ...baseState, supports: { ...baseState.supports, autoSettleOptOut: false } }), + ).not.toContain("auto-settle"); + }); + it("disables snooze when the thread cannot snooze, keeping presets visible", () => { const snooze = buildThreadActionMenuItems({ ...baseState, canSnoozeNow: false }).find( (item) => item.id === "snooze", @@ -117,7 +149,13 @@ describe("buildThreadActionMenuItems", () => { expect( ids({ ...baseState, - supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, + supports: { + settlement: false, + autoSettleOptOut: false, + snooze: false, + pinning: false, + titleRegeneration: false, + }, }), ).toContain("archive"); }); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index c37ec31f929c..838fc91a636c 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -14,6 +14,9 @@ export type ThreadActionMenuId = | "unpin" | "settle" | "unsettle" + | "auto-settle" + | "auto-settle:enabled" + | "auto-settle:disabled" | "snooze" | `snooze:${string}` | "unsnooze" @@ -40,6 +43,8 @@ export interface ThreadActionMenuState { } | null; readonly isPinned: boolean; readonly isSettled: boolean; + /** False while the user has turned automatic settlement off for this thread. */ + readonly autoSettleEnabled: boolean; readonly isSnoozed: boolean; readonly canSnoozeNow: boolean; readonly isRegeneratingTitle: boolean; @@ -47,6 +52,8 @@ export interface ThreadActionMenuState { readonly isRunning: boolean; readonly supports: { readonly settlement: boolean; + /** Server understands thread.auto-settle.set. */ + readonly autoSettleOptOut: boolean; readonly snooze: boolean; readonly pinning: boolean; readonly titleRegeneration: boolean; @@ -131,6 +138,31 @@ export function buildThreadActionMenuItems( }, ] : []), + // A submenu with the current option checked, not a one-shot action: + // this is a setting, and it sits with the other per-thread settings + // rather than the lifecycle verbs above. Disabled keeps long-running + // threads out of the settled shelf no matter how quiet they get. + ...(state.supports.autoSettleOptOut + ? [ + { + id: "auto-settle" as const, + label: "Auto-settle behavior", + icon: "timer", + children: [ + { + id: "auto-settle:enabled" as const, + label: "Enabled", + checked: state.autoSettleEnabled, + }, + { + id: "auto-settle:disabled" as const, + label: "Disabled", + checked: !state.autoSettleEnabled, + }, + ], + }, + ] + : []), { id: "copy", label: "Copy", diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 2c43641b2b56..db14ed8c860c 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -9,6 +9,12 @@ const ICON_PATHS: Record( button.style.pointerEvents = "none"; } - if (typeof item.icon === "string") { + if (typeof item.checked === "boolean") { + // Option rows use the icon slot for the check so labels line up + // with icon rows. The unselected option keeps the slot empty. + button.setAttribute("role", "menuitemradio"); + button.setAttribute("aria-checked", item.checked ? "true" : "false"); + const check = item.checked ? createIconElement("check", "neutral") : null; + if (check) { + button.appendChild(check); + } else { + const spacer = document.createElement("span"); + spacer.className = "size-4.5 shrink-0 sm:size-4"; + spacer.style.cssText = "display:inline-block;width:1rem;height:1rem;flex-shrink:0;"; + spacer.setAttribute("aria-hidden", "true"); + button.appendChild(spacer); + } + } else if (typeof item.icon === "string") { const icon = createIconElement(item.icon, isLeafDestructive ? "destructive" : "neutral"); if (icon) { button.appendChild(icon); diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 8a24c5110fa7..4a99fa8f5c73 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -20,6 +20,7 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; import { + readEnvironmentSupportsAutoSettleOptOut, readEnvironmentSupportsPinning, readEnvironmentSupportsSettlement, readEnvironmentSupportsSnooze, @@ -88,6 +89,7 @@ export function useThreadActionMenu(input: { unsnoozeThread, pinThread, confirmAndUnpinThread, + setThreadAutoSettle, archiveThread, deleteThread, } = useThreadActions(); @@ -132,6 +134,7 @@ export function useThreadActionMenu(input: { const now = new Date(); const supports = { settlement: readEnvironmentSupportsSettlement(threadRef.environmentId), + autoSettleOptOut: readEnvironmentSupportsAutoSettleOptOut(threadRef.environmentId), snooze: readEnvironmentSupportsSnooze(threadRef.environmentId), pinning: readEnvironmentSupportsPinning(threadRef.environmentId), titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId), @@ -145,6 +148,7 @@ export function useThreadActionMenu(input: { projectFilter: null, isPinned: thread.pinnedAt != null, isSettled: supports.settlement && thread.settledOverride === "settled", + autoSettleEnabled: thread.autoSettleDisabledAt == null, isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), isRegeneratingTitle, @@ -225,6 +229,12 @@ export function useThreadActionMenu(input: { await reportFailure("Failed to unpin thread", () => confirmAndUnpinThread(threadRef)); return; } + case "auto-settle:enabled": + case "auto-settle:disabled": + await reportFailure("Failed to update auto-settle", () => + setThreadAutoSettle(threadRef, action === "auto-settle:enabled"), + ); + return; case "rename": onStartRename(); return; @@ -333,6 +343,7 @@ export function useThreadActionMenu(input: { projectGroupingSettings, projects, router, + setThreadAutoSettle, settleThread, snoozeThread, threadRef, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 0e5491099223..6a82b920ab31 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -26,6 +26,7 @@ import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsStat import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; import { readLocalApi } from "../localApi"; import { + readEnvironmentSupportsAutoSettleOptOut, readEnvironmentSupportsPinning, readEnvironmentSupportsPinReorder, readEnvironmentSupportsActiveReorder, @@ -106,6 +107,18 @@ function topOfPinnedRunOrderKey(): string | undefined { return pinOrderKeyBetween(null, firstKey) ?? undefined; } +export class ThreadAutoSettleOptOutUnsupportedError extends Schema.TaggedError()( + "ThreadAutoSettleOptOutUnsupportedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "This environment's server does not support turning auto-settle off per thread yet. Update the server to use it."; + } +} + export class ThreadPinningUnsupportedError extends Schema.TaggedError()( "ThreadPinningUnsupportedError", { @@ -200,6 +213,9 @@ export function useThreadActions() { const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false, }); + const setThreadAutoSettleMutation = useAtomCommand(threadEnvironment.setAutoSettle, { + reportFailure: false, + }); const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, { reportFailure: false, }); @@ -556,6 +572,27 @@ export function useThreadActions() { [unsettleThreadMutation], ); + /** Turns automatic settlement (inactivity, merged PR) on or off for one thread. */ + const setThreadAutoSettle = useCallback( + async (target: ScopedThreadRef, enabled: boolean) => { + if (!readEnvironmentSupportsAutoSettleOptOut(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadAutoSettleOptOutUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + return setThreadAutoSettleMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, enabled }, + }); + }, + [setThreadAutoSettleMutation], + ); + const pinThread = useCallback( async (target: ScopedThreadRef, opts: { orderKey?: string } = {}) => { // Version skew: never send the command to a server that predates it. @@ -875,6 +912,7 @@ export function useThreadActions() { confirmAndUnpinThread, reorderPinnedThread, reorderActiveThread, + setThreadAutoSettle, }), [ archiveThread, @@ -884,6 +922,7 @@ export function useThreadActions() { pinThread, reorderPinnedThread, reorderActiveThread, + setThreadAutoSettle, settleThread, snoozeThread, unarchiveThread, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index d9610e20717f..dcd6358cb52c 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -229,6 +229,15 @@ export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): ); } +/** Whether the environment's server understands thread.auto-settle.set. + Same version-skew contract as settlement. */ +export function readEnvironmentSupportsAutoSettleOptOut(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadAutoSettleOptOut === true + ); +} + export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean { return ( appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 3d3215a193f6..0d7cfb736774 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -99,6 +99,11 @@ 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. +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. diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 8f313c1632ff..a08adba22949 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -42,6 +42,7 @@ export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">; export type PinThreadInput = CommandInput<"thread.pin">; export type UnpinThreadInput = CommandInput<"thread.unpin">; export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">; +export type SetThreadAutoSettleInput = CommandInput<"thread.auto-settle.set">; export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">; export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; export type LinkThreadPullRequestInput = CommandInput<"thread.pull-request.link">; @@ -226,6 +227,16 @@ export const unpinThread: (input: UnpinThreadInput) => CommandEffect = Effect.fn }); }); +export const setThreadAutoSettle: (input: SetThreadAutoSettleInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.setThreadAutoSettle", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.auto-settle.set", + commandId: yield* commandId(input), + }); +}); + export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEffect = Effect.fn( "EnvironmentCommands.reorderPinnedThread", )(function* (input) { diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 93e22cfd0c70..bb70356aab6a 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -29,6 +29,7 @@ import { type PinThreadInput, type ReorderPinnedThreadInput, type ReorderActiveThreadInput, + type SetThreadAutoSettleInput, type SettleThreadInput, type SnoozeThreadInput, type StartThreadTurnInput, @@ -53,6 +54,7 @@ import { pinThread, reorderPinnedThread, reorderActiveThread, + setThreadAutoSettle, settleThread, snoozeThread, startThreadTurn, @@ -81,6 +83,7 @@ export type { PinThreadInput, ReorderPinnedThreadInput, ReorderActiveThreadInput, + SetThreadAutoSettleInput, SettleThreadInput, SnoozeThreadInput, StartThreadTurnInput, @@ -170,6 +173,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + setAutoSettle: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:set-auto-settle", + execute: (input: SetThreadAutoSettleInput) => setThreadAutoSettle(input), + scheduler, + concurrency, + }), reorderActive: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:reorder-active", execute: (input: ReorderActiveThreadInput) => reorderActiveThread(input), diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 379985b71243..d32329a741b4 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -60,6 +60,7 @@ export function mergeEnvironmentThread( settledAt: shell.settledAt, unsettledAt: shell.unsettledAt, activeOrderKey: shell.activeOrderKey, + autoSettleDisabledAt: shell.autoSettleDisabledAt, snoozedUntil: shell.snoozedUntil, snoozedAt: shell.snoozedAt, pinnedAt: shell.pinnedAt, diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index dbee48d7808c..0330d3dd8bd9 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -290,6 +290,46 @@ describe("applyThreadDetailEvent", () => { }); }); + describe("thread.auto-settle-set", () => { + it("stores and clears autoSettleDisabledAt", () => { + const disabledAt = "2026-04-01T05:00:00.000Z"; + const off = applyThreadDetailEvent(baseThread, { + ...baseEventFields, + sequence: 7, + occurredAt: disabledAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.auto-settle-set", + payload: { + threadId: ThreadId.make("thread-1"), + autoSettleDisabledAt: disabledAt, + updatedAt: disabledAt, + }, + }); + expect(off.kind).toBe("updated"); + if (off.kind !== "updated") return; + expect(off.thread.autoSettleDisabledAt).toBe(disabledAt); + + const on = applyThreadDetailEvent(off.thread, { + ...baseEventFields, + sequence: 8, + occurredAt: "2026-04-01T06:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.auto-settle-set", + payload: { + threadId: ThreadId.make("thread-1"), + autoSettleDisabledAt: null, + updatedAt: "2026-04-01T06:00:00.000Z", + }, + }); + expect(on.kind).toBe("updated"); + if (on.kind === "updated") { + expect(on.thread.autoSettleDisabledAt).toBeNull(); + } + }); + }); + describe("thread.meta-updated", () => { it.each(["f", null] as const)( "updates the active key to %s without activity", diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 101bb34fba91..66f78a0464ec 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -131,6 +131,7 @@ export function applyThreadDetailEvent( settledAt: null, unsettledAt: null, activeOrderKey: null, + autoSettleDisabledAt: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -249,6 +250,16 @@ export function applyThreadDetailEvent( }, }; + case "thread.auto-settle-set": + return { + kind: "updated", + thread: { + ...thread, + autoSettleDisabledAt: event.payload.autoSettleDisabledAt, + updatedAt: event.payload.updatedAt, + }, + }; + // ── Thread metadata ───────────────────────────────────────────── case "thread.meta-updated": return { diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index b5a3290f6314..089133d878a4 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -141,6 +141,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadPinReorder: Schema.optionalKey(Schema.Boolean), /** Server persists manual Active order through thread.active.reorder. */ threadActiveReorder: Schema.optionalKey(Schema.Boolean), + /** Server understands thread.auto-settle.set (per-thread auto-settle off). + Same version-skew contract as threadSettlement. */ + threadAutoSettleOptOut: 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/ipc.ts b/packages/contracts/src/ipc.ts index c67ecd27c0e1..a7a7d918bc59 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -40,6 +40,8 @@ export interface ContextMenuItem { icon?: string; /** Inserts a visual section divider immediately before this item. */ separatorBefore?: boolean; + /** Shows a check mark. Used to mark the current option inside a submenu. */ + checked?: boolean; children?: readonly ContextMenuItem[]; } @@ -55,6 +57,7 @@ export interface ContextMenuItemSchemaType { readonly header?: boolean; readonly icon?: string; readonly separatorBefore?: boolean; + readonly checked?: boolean; readonly children?: readonly ContextMenuItemSchemaType[]; } @@ -66,6 +69,7 @@ export const ContextMenuItemSchema: Schema.Codec = Sc header: Schema.optionalKey(Schema.Boolean), icon: Schema.optionalKey(Schema.String), separatorBefore: Schema.optionalKey(Schema.Boolean), + checked: Schema.optionalKey(Schema.Boolean), children: Schema.optionalKey( Schema.Array( Schema.suspend((): Schema.Codec => ContextMenuItemSchema), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3e323e4964d5..1ae704d3f666 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -838,6 +838,10 @@ export const OrchestrationThread = Schema.Struct({ // Manual Active placement. Keyless threads retain their creation/re-entry // order above the arranged run. Settling clears this slot. activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + // Set while the user has turned automatic settlement off for this thread. + // Survives manual settle, un-settle, and activity: only the user clears it. + // Optional so payloads from older servers still decode. + autoSettleDisabledAt: Schema.optional(Schema.NullOr(IsoDateTime)), // Pending-only state. Optional so older servers remain compatible. titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), titleState: Schema.optional(Schema.NullOr(ThreadTitleState)), @@ -908,6 +912,7 @@ export const OrchestrationThreadShell = Schema.Struct({ pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + autoSettleDisabledAt: Schema.optional(Schema.NullOr(IsoDateTime)), titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), titleState: Schema.optional(Schema.NullOr(ThreadTitleState)), session: Schema.NullOr(OrchestrationSession), @@ -1218,6 +1223,14 @@ const ThreadPinReorderCommand = Schema.Struct({ orderKey: TrimmedNonEmptyString, }); +const ThreadAutoSettleSetCommand = Schema.Struct({ + type: Schema.Literal("thread.auto-settle.set"), + commandId: CommandId, + threadId: ThreadId, + // false turns automatic settlement off for this thread, true turns it back on. + enabled: Schema.Boolean, +}); + const ThreadActiveReorderCommand = Schema.Struct({ type: Schema.Literal("thread.active.reorder"), commandId: CommandId, @@ -1426,6 +1439,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadPinCommand, ThreadUnpinCommand, ThreadPinReorderCommand, + ThreadAutoSettleSetCommand, ThreadActiveReorderCommand, ThreadMetaUpdateCommand, ThreadPullRequestLinkCommand, @@ -1459,6 +1473,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadPinCommand, ThreadUnpinCommand, ThreadPinReorderCommand, + ThreadAutoSettleSetCommand, ThreadActiveReorderCommand, ThreadMetaUpdateCommand, ThreadPullRequestLinkCommand, @@ -1687,6 +1702,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.pinned", "thread.unpinned", "thread.pin-reordered", + "thread.auto-settle-set", "thread.meta-updated", "thread.pull-request-linked", "thread.pull-request-unlinked", @@ -1825,6 +1841,13 @@ export const ThreadPinReorderedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadAutoSettleSetPayload = Schema.Struct({ + threadId: ThreadId, + // Null re-enables automatic settlement. + autoSettleDisabledAt: Schema.NullOr(IsoDateTime), + updatedAt: IsoDateTime, +}); + export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, // Order updates use this existing event so older clients can ignore the @@ -2089,6 +2112,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.pin-reordered"), payload: ThreadPinReorderedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.auto-settle-set"), + payload: ThreadAutoSettleSetPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.meta-updated"),