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 e5d0137ee406..207beb69d2e7 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -122,6 +122,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { checkmark: IconCheck, "checkmark.circle": IconCircleCheck, 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 00731ce5aeec..036b7df5b173 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, regenerateThreadTitle, unsettleThread, @@ -199,6 +200,7 @@ export function HomeRouteScreen() { onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onSetThreadAutoSettle={setThreadAutoSettle} onMoveThread={moveThread} onRegenerateThreadTitle={regenerateThreadTitle} onEnvironmentChange={setSelectedEnvironmentId} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 4e01ef6077da..8d0b21e5a00c 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -121,6 +121,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, @@ -531,6 +535,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); @@ -606,6 +616,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) { @@ -868,6 +887,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) @@ -880,6 +900,7 @@ export function HomeScreen(props: HomeScreenProps) { onUnsettleThread={handleUnsettleThread} onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} + onSetThreadAutoSettle={handleSetThreadAutoSettle} onMoveThread={handleMoveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -902,6 +923,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 72e45c9bbb4e..7ffe2ca67f4a 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -58,6 +58,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"], ) { @@ -236,6 +245,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, @@ -247,6 +261,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, }); @@ -433,6 +450,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); @@ -652,6 +697,7 @@ export function useThreadListActions(): { unsettleThread, pinThread, unpinThread, + setThreadAutoSettle, moveThread, regenerateThreadTitle, }; diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index dce02cac1d4b..984ce7fccf55 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -169,6 +169,7 @@ function ThreadNavigationSidebarPane( unsettleThread, pinThread, unpinThread, + setThreadAutoSettle, moveThread, regenerateThreadTitle, } = useThreadListActions(); @@ -442,6 +443,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) { @@ -936,6 +946,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) @@ -948,6 +959,7 @@ function ThreadNavigationSidebarPane( onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onSetThreadAutoSettle={setThreadAutoSettle} onMoveThread={moveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -1086,6 +1098,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 548080389d4d..88cda1dec01b 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -388,6 +388,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; @@ -395,6 +396,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. */ @@ -430,6 +433,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onArchiveThread, onPinThread, onUnpinThread, + onSetThreadAutoSettle, onMoveThread, } = props; const snoozedRow = props.snoozed === true; @@ -481,6 +485,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]); @@ -559,6 +567,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 titleRegenerationMenuItems = useMemo( () => buildThreadTitleRegenerationMenuItems({ @@ -578,19 +613,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }, ...arrangementMenuItems, ...titleRegenerationMenuItems, + ...autoSettleMenuItems, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [arrangementMenuItems, snoozePresetActions, titleRegenerationMenuItems], + [arrangementMenuItems, autoSettleMenuItems, snoozePresetActions, titleRegenerationMenuItems], ); const cardMenuActions = useMemo( () => [ CARD_MENU_ACTIONS[0]!, ...arrangementMenuItems, ...titleRegenerationMenuItems, + ...autoSettleMenuItems, ...CARD_MENU_ACTIONS.slice(1), ], - [arrangementMenuItems, titleRegenerationMenuItems], + [arrangementMenuItems, autoSettleMenuItems, titleRegenerationMenuItems], ); + // Settled and snoozed rows keep the setting too, matching web where every + // row shares one menu builder. const slimMenuActions = useMemo( () => [ SLIM_MENU_ACTIONS[0]!, @@ -598,13 +637,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { (action) => action.id !== "move-up" && action.id !== "move-down", ), ...titleRegenerationMenuItems, + ...autoSettleMenuItems, SLIM_MENU_ACTIONS[1]!, ], - [arrangementMenuItems, titleRegenerationMenuItems], + [arrangementMenuItems, autoSettleMenuItems, titleRegenerationMenuItems], ); const snoozedMenuActions = useMemo( - () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [ + SNOOZED_MENU_ACTIONS[0]!, + ...titleRegenerationMenuItems, + ...autoSettleMenuItems, + SNOOZED_MENU_ACTIONS[1]!, + ], + [autoSettleMenuItems, titleRegenerationMenuItems], ); const legacyMenuActions = useMemo( () => [ @@ -623,6 +668,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(); @@ -655,6 +702,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 9c25767245df..ebde596d1935 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -231,6 +231,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 f6913426a0a3..555a2fb7850f 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 2b113adec7ab..c23d816a7adc 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 d5487e6ffab5..bff10408b7d5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -582,6 +582,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", @@ -623,6 +624,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", @@ -666,6 +668,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", @@ -1227,6 +1230,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", @@ -2268,6 +2272,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, @@ -2513,6 +2518,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, @@ -2669,6 +2675,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, @@ -2832,6 +2839,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, @@ -3188,6 +3196,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, @@ -3489,6 +3498,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 f8809ccc27e2..5d5b52923d95 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -473,7 +473,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, @@ -849,6 +852,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 39aaacd8739d..3ba910c204a7 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 fae607eb70ee..31ea2ba67382 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -41,6 +41,7 @@ import { ThreadSettledPayload, ThreadPinnedPayload, ThreadPinReorderedPayload, + ThreadAutoSettleSetPayload, ThreadPullRequestLinkedPayload, ThreadPullRequestSyncedPayload, ThreadPullRequestUnlinkedPayload, @@ -433,6 +434,7 @@ export function projectEvent( settledAt: null, unsettledAt: null, activeOrderKey: null, + autoSettleDisabledAt: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -573,6 +575,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 4feaf3a185b9..0bd22e400cfd 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -57,6 +57,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, @@ -89,6 +90,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}, @@ -121,6 +123,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, @@ -160,6 +163,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", @@ -201,6 +205,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 ad015534ea01..91f4416489b4 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -64,6 +64,7 @@ import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts"; 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_ProjectionThreadsAutoSettleDisabledAt.ts"; /** * Migration loader with all migrations defined inline. @@ -128,6 +129,7 @@ const migrationEntries = [ [50, "ProjectionThreadPullRequests", Migration0050], [51, "ProjectionThreadMessageContext", Migration0051], [52, "ProjectionThreadTitleState", Migration0052], + [53, "ProjectionThreadsAutoSettleDisabledAt", Migration0053], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/053_ProjectionThreadsAutoSettleDisabledAt.test.ts b/apps/server/src/persistence/Migrations/053_ProjectionThreadsAutoSettleDisabledAt.test.ts new file mode 100644 index 000000000000..55dd822f41bd --- /dev/null +++ b/apps/server/src/persistence/Migrations/053_ProjectionThreadsAutoSettleDisabledAt.test.ts @@ -0,0 +1,38 @@ +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 "./053_ProjectionThreadsAutoSettleDisabledAt.ts"; + +it.layer(NodeSqliteClient.layerMemory())("053_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: 52 }); + 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: 53 }); + 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/053_ProjectionThreadsAutoSettleDisabledAt.ts b/apps/server/src/persistence/Migrations/053_ProjectionThreadsAutoSettleDisabledAt.ts new file mode 100644 index 000000000000..f91f6d8abdfa --- /dev/null +++ b/apps/server/src/persistence/Migrations/053_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 2c8186a321c0..64e9246d73d5 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 6b0f3d7c11ef..e31a69588673 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2147,6 +2147,7 @@ export default function Sidebar() { confirmAndUnpinThread, reorderPinnedThread, reorderActiveThread, + setThreadAutoSettle, archiveThread, deleteThread, } = useThreadActions(); @@ -4004,6 +4005,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; @@ -4019,6 +4023,7 @@ export default function Sidebar() { branch: thread.branch ?? null, isPinned, isSettled, + autoSettleEnabled: thread.autoSettleDisabledAt == null, isSnoozed, canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), isRegeneratingTitle, @@ -4026,6 +4031,7 @@ export default function Sidebar() { thread.session?.status === "running" && thread.session.activeTurnId != null, supports: { settlement: supportsSettlement, + autoSettleOptOut: supportsAutoSettleOptOut, snooze: supportsSnooze, pinning: supportsPinning, titleRegeneration: supportsTitleRegeneration, @@ -4094,6 +4100,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; @@ -4218,6 +4242,7 @@ export default function Sidebar() { openProjectSettings, projectByKey, serverConfigs, + 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 783453ac0082..b25c888b9ec3 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -6,11 +6,18 @@ const baseState: ThreadActionMenuState = { branch: 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" }, ], @@ -31,7 +38,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"]); }); @@ -62,6 +75,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", @@ -95,7 +127,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 35b14ec44397..8e5f0bd6d4b0 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -13,6 +13,9 @@ export type ThreadActionMenuId = | "unpin" | "settle" | "unsettle" + | "auto-settle" + | "auto-settle:enabled" + | "auto-settle:disabled" | "snooze" | `snooze:${string}` | "unsnooze" @@ -30,6 +33,8 @@ export interface ThreadActionMenuState { readonly branch: string | 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; @@ -37,6 +42,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; @@ -110,6 +117,31 @@ export function buildThreadActionMenuItems( ] : []), { id: "mark-unread", label: "Mark unread", icon: "mail-open" }, + // 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 7a33b336839e..26edddbbda79 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), @@ -142,6 +145,7 @@ export function useThreadActionMenu(input: { branch: thread.branch ?? 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, @@ -242,6 +246,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; @@ -350,6 +360,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 1d3aa4c3abba..cb3af9324c6a 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -23,6 +23,7 @@ import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsStat import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; import { readLocalApi } from "../localApi"; import { + readEnvironmentSupportsAutoSettleOptOut, readEnvironmentSupportsPinning, readEnvironmentSupportsPinReorder, readEnvironmentSupportsActiveReorder, @@ -101,6 +102,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", { @@ -195,6 +208,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, }); @@ -551,6 +567,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. @@ -766,6 +803,7 @@ export function useThreadActions() { confirmAndUnpinThread, reorderPinnedThread, reorderActiveThread, + setThreadAutoSettle, }), [ archiveThread, @@ -775,6 +813,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 67574f0b0286..ecc7b969862d 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -88,6 +88,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**. 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 1f10a0dff7ec..74098c674519 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -22,6 +22,7 @@ import { type PinThreadInput, type ReorderPinnedThreadInput, type ReorderActiveThreadInput, + type SetThreadAutoSettleInput, type SettleThreadInput, type SnoozeThreadInput, type StartThreadTurnInput, @@ -46,6 +47,7 @@ import { pinThread, reorderPinnedThread, reorderActiveThread, + setThreadAutoSettle, settleThread, snoozeThread, startThreadTurn, @@ -74,6 +76,7 @@ export type { PinThreadInput, ReorderPinnedThreadInput, ReorderActiveThreadInput, + SetThreadAutoSettleInput, SettleThreadInput, SnoozeThreadInput, StartThreadTurnInput, @@ -162,6 +165,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 c8b8833ead86..41d25f4cc01c 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -126,6 +126,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 dd972b7aa816..764b00bc085d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -124,6 +124,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[]; } @@ -139,6 +141,7 @@ export interface ContextMenuItemSchemaType { readonly header?: boolean; readonly icon?: string; readonly separatorBefore?: boolean; + readonly checked?: boolean; readonly children?: readonly ContextMenuItemSchemaType[]; } @@ -150,6 +153,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 f7aaf4fa2616..6849e1056d72 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -764,6 +764,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)), @@ -834,6 +838,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), @@ -1142,6 +1147,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, @@ -1349,6 +1362,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadPinCommand, ThreadUnpinCommand, ThreadPinReorderCommand, + ThreadAutoSettleSetCommand, ThreadActiveReorderCommand, ThreadMetaUpdateCommand, ThreadPullRequestLinkCommand, @@ -1382,6 +1396,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadPinCommand, ThreadUnpinCommand, ThreadPinReorderCommand, + ThreadAutoSettleSetCommand, ThreadActiveReorderCommand, ThreadMetaUpdateCommand, ThreadPullRequestLinkCommand, @@ -1570,6 +1585,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", @@ -1708,6 +1724,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 @@ -1965,6 +1988,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"),