From d3fdb9659524640f54d060f5d7b31d80e5181160 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Wed, 30 Sep 2026 09:28:40 -0600 Subject: [PATCH] feat(threads): add a per-thread switch to turn auto-settle off Threads can now opt out of automatic settlement (inactivity and merged or closed pull request) from the thread menu on web, desktop and mobile. Manual settle, snooze and archive are unaffected, and auto-settle stays on by default, including for threads and projections that predate the switch. Ported from upstream 0109670411d13b9e380766e98b386b2f756ecf5b (#11846). Pylon adaptations: the projection column lands as migration 067 instead of upstream 054, the docs keep Pylon wording, and the decider and projector tests also cover threads that predate the switch, the off-to-on transition and archived threads. --- apps/desktop/src/electron/ElectronMenu.ts | 2 + apps/mobile/src/components/AppSymbol.tsx | 1 + .../src/features/home/HomeRouteScreen.tsx | 2 + apps/mobile/src/features/home/HomeScreen.tsx | 23 ++ .../src/features/home/useThreadListActions.ts | 46 ++++ .../threads/ThreadNavigationSidebar.tsx | 14 ++ .../features/threads/thread-list-v2-items.tsx | 58 ++++- apps/mobile/src/state/use-thread-selection.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../Layers/ProjectionPipeline.ts | 16 ++ .../Layers/ProjectionSnapshotQuery.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.ts | 10 + apps/server/src/orchestration/Schemas.ts | 2 + .../ThreadSettlementPolicy.test.ts | 24 ++ .../orchestration/ThreadSettlementPolicy.ts | 1 + .../decider.autoSettleSet.test.ts | 228 ++++++++++++++++++ apps/server/src/orchestration/decider.ts | 36 ++- .../projector.autoSettleSet.test.ts | 170 +++++++++++++ .../src/orchestration/projector.test.ts | 1 + apps/server/src/orchestration/projector.ts | 13 + .../persistence/Layers/ProjectionThreads.ts | 4 + apps/server/src/persistence/Migrations.ts | 3 + ...jectionThreadsAutoSettleDisabledAt.test.ts | 41 ++++ ...7_ProjectionThreadsAutoSettleDisabledAt.ts | 15 ++ .../persistence/Services/ProjectionThreads.ts | 1 + .../src/project/AgentSessionImporter.ts | 1 + apps/web/src/components/Sidebar.tsx | 25 ++ .../components/threadActionMenu.logic.test.ts | 46 +++- .../src/components/threadActionMenu.logic.ts | 32 +++ apps/web/src/contextMenuFallback.ts | 23 +- apps/web/src/hooks/useThreadActionMenu.ts | 11 + apps/web/src/hooks/useThreadActions.ts | 39 +++ apps/web/src/state/entities.ts | 9 + docs/user/thread-sidebar.md | 5 + .../client-runtime/src/operations/commands.ts | 11 + .../src/state/threadCommands.ts | 9 + .../client-runtime/src/state/threadDetail.ts | 1 + .../src/state/threadReducer.test.ts | 40 +++ .../client-runtime/src/state/threadReducer.ts | 11 + packages/contracts/src/environment.ts | 3 + packages/contracts/src/ipc.ts | 4 + packages/contracts/src/orchestration.ts | 28 +++ 42 files changed, 1002 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/orchestration/decider.autoSettleSet.test.ts create mode 100644 apps/server/src/orchestration/projector.autoSettleSet.test.ts create mode 100644 apps/server/src/persistence/Migrations/067_ProjectionThreadsAutoSettleDisabledAt.test.ts create mode 100644 apps/server/src/persistence/Migrations/067_ProjectionThreadsAutoSettleDisabledAt.ts diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index ce9e0fb489..1360fb545b 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 74f41cc9c4..1b971c82bd 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -130,6 +130,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "checkmark.circle": IconCircleCheck, circle: IconCircle, clock: IconClock, + timer: IconClock, cloud: IconCloud, laptopcomputer: IconDeviceLaptop, macmini: IconServer, diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index b2da5af8ed..d536af65d6 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, @@ -200,6 +201,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 7acb7f1fb3..9dd45a752c 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -124,6 +124,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, @@ -548,6 +552,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); @@ -647,6 +657,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) { @@ -917,6 +936,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) @@ -929,6 +949,7 @@ export function HomeScreen(props: HomeScreenProps) { onUnsettleThread={handleUnsettleThread} onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} + onSetThreadAutoSettle={handleSetThreadAutoSettle} onMoveThread={handleMoveThread} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null @@ -955,6 +976,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 9b59911088..4a43facc61 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 7e7ba82174..5c8cd7a301 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -171,6 +171,7 @@ function ThreadNavigationSidebarPane( unsettleThread, pinThread, unpinThread, + setThreadAutoSettle, moveThread, renameThread, regenerateThreadTitle, @@ -472,6 +473,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) { @@ -971,6 +981,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) @@ -983,6 +994,7 @@ function ThreadNavigationSidebarPane( onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onSetThreadAutoSettle={setThreadAutoSettle} onMoveThread={moveThread} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} @@ -1129,6 +1141,8 @@ function ThreadNavigationSidebarPane( pinReorderEnvironmentIds, pinThread, pinningEnvironmentIds, + autoSettleOptOutEnvironmentIds, + setThreadAutoSettle, projectByKey, projectCwdByKey, projectTitleByProjectKey, 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 1db1f94f21..2e285ec565 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -406,6 +406,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; @@ -413,6 +414,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. */ @@ -450,6 +453,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onArchiveThread, onPinThread, onUnpinThread, + onSetThreadAutoSettle, onMoveThread, } = props; const snoozedRow = props.snoozed === true; @@ -508,6 +512,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]); @@ -586,6 +594,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" }, @@ -607,19 +642,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]!, @@ -627,13 +666,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( () => [ @@ -652,6 +697,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(); @@ -689,6 +736,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 fc648d6f64..9bd95db7da 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -76,6 +76,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 0cc8013b2d..1221a89ec4 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -237,6 +237,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 7cdaff1885..e82e67d529 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -630,6 +630,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti continuedFromThreadId: event.payload.continuedFromThreadId ?? 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 232fc2e028..adac3d4cd8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -511,6 +511,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", activeOrderKey: "hq", + autoSettleDisabledAt: null, titleRegeneration: null, titleState: null, continuedFromThreadId: null, @@ -648,6 +649,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", activeOrderKey: "hq", + autoSettleDisabledAt: null, titleRegeneration: null, titleState: null, continuedFromThreadId: null, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 6132227183..007a39945a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -733,6 +733,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { continued_from_thread_id AS "continuedFromThreadId", 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", @@ -785,6 +786,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { continued_from_thread_id AS "continuedFromThreadId", 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", @@ -834,6 +836,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { continued_from_thread_id AS "continuedFromThreadId", 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", @@ -1523,6 +1526,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { continued_from_thread_id AS "continuedFromThreadId", 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", @@ -2832,6 +2836,7 @@ pending_approval_requests AS ( continuedFromThreadId: row.continuedFromThreadId ?? null, pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, + autoSettleDisabledAt: row.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, deletedAt: row.deletedAt, @@ -3089,6 +3094,7 @@ pending_approval_requests AS ( continuedFromThreadId: row.continuedFromThreadId ?? null, pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, + autoSettleDisabledAt: row.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, deletedAt: row.deletedAt, @@ -3258,6 +3264,7 @@ pending_approval_requests AS ( continuedFromThreadId: row.continuedFromThreadId ?? null, pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, + autoSettleDisabledAt: row.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, session: sessionByThread.get(row.threadId) ?? null, @@ -3458,6 +3465,7 @@ pending_approval_requests AS ( continuedFromThreadId: row.continuedFromThreadId ?? null, pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, + autoSettleDisabledAt: row.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -3833,6 +3841,7 @@ pending_approval_requests AS ( continuedFromThreadId: threadRow.value.continuedFromThreadId ?? null, 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, @@ -4153,6 +4162,7 @@ pending_approval_requests AS ( continuedFromThreadId: threadRow.value.continuedFromThreadId ?? null, 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 29468dc3f8..f4fe2e0104 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 252b994394..615ca848a1 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -248,6 +248,30 @@ const terminalSnapshot = ( syncedAt: NOW, }); +describe("per-thread auto-settle opt out", () => { + 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); + }); + + 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 92063745ef..113d68204e 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 0000000000..b8365d9531 --- /dev/null +++ b/apps/server/src/orchestration/decider.autoSettleSet.test.ts @@ -0,0 +1,228 @@ +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; + /** Omits the field, as in a thread projected before the switch existed. */ + readonly predatesSwitch?: boolean; +}): 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, + ...(input.predatesSwitch === true + ? {} + : { 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"); + }), + ); + + const autoSettle = (readModel: OrchestrationReadModel) => + decideOrchestrationCommand({ + command: { + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto"), + threadId: ThreadId.make("thread-1"), + snapshotSequence: 0, + settledAt: NOW, + }, + readModel, + }); + + it.effect("a thread that predates the switch still settles automatically", () => + Effect.gen(function* () { + const readModel = makeReadModel({ predatesSwitch: true }); + const [settled] = events(yield* autoSettle(readModel)); + expect(settled?.type).toBe("thread.settled"); + + // Enabling what was never disabled keeps the null stamp and updatedAt. + const [event] = events( + yield* decideOrchestrationCommand({ + command: { + type: "thread.auto-settle.set", + commandId: CommandId.make("cmd-on-legacy"), + threadId: ThreadId.make("thread-1"), + enabled: true, + }, + readModel, + }), + ); + expect(event?.type).toBe("thread.auto-settle-set"); + if (event?.type === "thread.auto-settle-set") { + expect(event.payload.autoSettleDisabledAt).toBeNull(); + expect(event.payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("automatic settlement resumes once auto-settle is turned back on", () => + Effect.gen(function* () { + const blocked = yield* Effect.exit( + autoSettle(makeReadModel({ autoSettleDisabledAt: DISABLED_AT })), + ); + expect(blocked._tag).toBe("Failure"); + const [settled] = events(yield* autoSettle(makeReadModel({ autoSettleDisabledAt: null }))); + expect(settled?.type).toBe("thread.settled"); + }), + ); + + it.effect("rejects the switch on an archived thread", () => + Effect.gen(function* () { + const base = makeReadModel({}); + const result = yield* Effect.exit( + decideOrchestrationCommand({ + command: { + type: "thread.auto-settle.set", + commandId: CommandId.make("cmd-archived"), + threadId: ThreadId.make("thread-1"), + enabled: false, + }, + readModel: { + ...base, + threads: base.threads.map((thread) => ({ ...thread, archivedAt: NOW })), + }, + }), + ); + expect(result._tag).toBe("Failure"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 32cf77f73d..8707760fa0 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -602,7 +602,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, @@ -984,6 +987,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 0000000000..c6824d657f --- /dev/null +++ b/apps/server/src/orchestration/projector.autoSettleSet.test.ts @@ -0,0 +1,170 @@ +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(); + }), +); + +it.effect("replays a thread created before the switch existed with auto-settle left on", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const later = "2026-01-02T00:00:00.000Z"; + // Neither event carries the field, as in an event log written by an older server. + 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, + }, + }), + ); + const settled = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + 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).toBeNull(); + + // Off then on returns the thread to the state it replayed into. + const disabled = yield* projectEvent( + settled, + makeEvent({ + sequence: 3, + type: "thread.auto-settle-set", + payload: { + threadId: ThreadId.make("thread-1"), + autoSettleDisabledAt: later, + updatedAt: later, + }, + }), + ); + const enabled = yield* projectEvent( + disabled, + makeEvent({ + sequence: 4, + type: "thread.auto-settle-set", + payload: { + threadId: ThreadId.make("thread-1"), + autoSettleDisabledAt: null, + updatedAt: later, + }, + }), + ); + expect(enabled.threads[0]?.autoSettleDisabledAt).toBeNull(); + expect(enabled.threads[0]?.settledOverride).toBe("settled"); + }), +); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 1822dcdb45..42af2375c7 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -195,6 +195,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 6e740c67d7..2d9338da59 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, @@ -457,6 +458,7 @@ export function projectEvent( settledAt: null, unsettledAt: null, activeOrderKey: null, + autoSettleDisabledAt: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -598,6 +600,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 6904f7384c..cd7809a404 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -62,6 +62,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { continued_from_thread_id, pin_order_key, active_order_key, + auto_settle_disabled_at, title_regeneration_request_id, title_regeneration_started_at, latest_user_message_at, @@ -99,6 +100,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.continuedFromThreadId ?? null}, ${row.pinOrderKey ?? null}, ${row.activeOrderKey ?? null}, + ${row.autoSettleDisabledAt ?? null}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, ${row.latestUserMessageAt}, @@ -136,6 +138,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { continued_from_thread_id = excluded.continued_from_thread_id, 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, @@ -180,6 +183,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { continued_from_thread_id AS "continuedFromThreadId", 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 70d180da59..8ad58e0d8e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -77,6 +77,7 @@ import Migration0063 from "./Migrations/063_ClearUnprojectedRelayOutages.ts"; import Migration0064 from "./Migrations/064_PullRequestFilesViewed.ts"; import Migration0065 from "./Migrations/065_ProjectionThreadTitleState.ts"; import Migration0066 from "./Migrations/066_ProjectionRollbackOperationId.ts"; +import Migration0067 from "./Migrations/067_ProjectionThreadsAutoSettleDisabledAt.ts"; /** * Migration loader with all migrations defined inline. * @@ -187,6 +188,8 @@ const migrationEntries = [ [64, "PullRequestFilesViewed", Migration0064], [65, "ProjectionThreadTitleState", Migration0065], [66, "ProjectionRollbackOperationId", Migration0066], + // Upstream shipped this as 54 (#11846); Pylon already uses ids through 66. + [67, "ProjectionThreadsAutoSettleDisabledAt", Migration0067], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/067_ProjectionThreadsAutoSettleDisabledAt.test.ts b/apps/server/src/persistence/Migrations/067_ProjectionThreadsAutoSettleDisabledAt.test.ts new file mode 100644 index 0000000000..9d0b38285c --- /dev/null +++ b/apps/server/src/persistence/Migrations/067_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 "./067_ProjectionThreadsAutoSettleDisabledAt.ts"; + +it.layer(NodeSqliteClient.layer({ filename: ":memory:" }))( + "067_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: 66 }); + 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: 67 }); + 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/067_ProjectionThreadsAutoSettleDisabledAt.ts b/apps/server/src/persistence/Migrations/067_ProjectionThreadsAutoSettleDisabledAt.ts new file mode 100644 index 0000000000..f91f6d8abd --- /dev/null +++ b/apps/server/src/persistence/Migrations/067_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 326fd6184b..a76229fbaf 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -58,6 +58,7 @@ export const ProjectionThread = Schema.Struct({ continuedFromThreadId: Schema.optional(Schema.NullOr(ThreadId)), 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 1263669c35..a502c9d94e 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.branchPullRequest != null || diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d76cc4b237..14898558a9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2620,6 +2620,7 @@ export default function Sidebar() { confirmAndUnpinThread, reorderPinnedThread, reorderActiveThread, + setThreadAutoSettle, archiveThread, deleteThread, } = useThreadActions(); @@ -4571,6 +4572,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; @@ -4600,6 +4604,7 @@ export default function Sidebar() { : null, isPinned, isSettled, + autoSettleEnabled: thread.autoSettleDisabledAt == null, isSnoozed, canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), isRegeneratingTitle, @@ -4607,6 +4612,7 @@ export default function Sidebar() { thread.session?.status === "running" && thread.session.activeTurnId != null, supports: { settlement: supportsSettlement, + autoSettleOptOut: supportsAutoSettleOptOut, snooze: supportsSnooze, pinning: supportsPinning, titleRegeneration: supportsTitleRegeneration, @@ -4676,6 +4682,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; @@ -4802,6 +4826,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 dcd5e07b1d..01e31b0bec 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((item) => item.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 2944d3af93..e597885ecc 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" @@ -36,6 +39,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; @@ -43,6 +48,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; @@ -127,6 +134,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 c6cfc44985..16474e78e7 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 607cbfa627..792c6884e1 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), @@ -143,6 +146,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, @@ -223,6 +227,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; @@ -331,6 +341,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 fe38d2af1f..80c82a2aea 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -24,6 +24,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, }); @@ -589,6 +605,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, @@ -990,6 +1027,7 @@ export function useThreadActions() { confirmAndUnpinThread, reorderPinnedThread, reorderActiveThread, + setThreadAutoSettle, }), [ archiveThread, @@ -999,6 +1037,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 684c902262..581489a92b 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -266,6 +266,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 854bd40f81..b9f982f661 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -86,6 +86,11 @@ does not prevent inactivity settlement, and an old closed or merged pull request work you resumed after it closed. **Settled** lists threads newest first by when their work finished, or by when you settled them yourself. +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. The option appears only for environments whose Pylon server supports it. + 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 a535ca83e2..cd6f2c0d2e 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -43,6 +43,7 @@ export type UnsnoozeThreadInput = UndoBound>; export type PinThreadInput = UndoBound>; 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">; @@ -254,6 +255,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 7f7637ffa3..30e76dfcb5 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -30,6 +30,7 @@ import { type PinThreadInput, type ReorderPinnedThreadInput, type ReorderActiveThreadInput, + type SetThreadAutoSettleInput, type SettleThreadInput, type SnoozeThreadInput, type StartThreadTurnInput, @@ -55,6 +56,7 @@ import { pinThread, reorderPinnedThread, reorderActiveThread, + setThreadAutoSettle, settleThread, snoozeThread, startThreadTurn, @@ -84,6 +86,7 @@ export type { PinThreadInput, ReorderPinnedThreadInput, ReorderActiveThreadInput, + SetThreadAutoSettleInput, SettleThreadInput, SnoozeThreadInput, StartThreadTurnInput, @@ -174,6 +177,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 379985b712..d32329a741 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 f2acc9d6af..5c7d05f3ac 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 68412e4937..b61062c7d9 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -132,6 +132,7 @@ export function applyThreadDetailEvent( settledAt: null, unsettledAt: null, activeOrderKey: null, + autoSettleDisabledAt: null, snoozedUntil: null, snoozedAt: null, // Carried on the live event too, not just the snapshot: the user @@ -257,6 +258,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 af82f3ebb4..6e0326faba 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -143,6 +143,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 eb2ffb6dcf..a2905fb1cf 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 5d8bd86faf..9dd85361c3 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1073,6 +1073,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)), @@ -1157,6 +1161,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)), /** @@ -1482,6 +1487,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, @@ -1738,6 +1751,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadPinCommand, ThreadUnpinCommand, ThreadPinReorderCommand, + ThreadAutoSettleSetCommand, ThreadActiveReorderCommand, ThreadMetaUpdateCommand, ThreadPullRequestLinkCommand, @@ -1772,6 +1786,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadPinCommand, ThreadUnpinCommand, ThreadPinReorderCommand, + ThreadAutoSettleSetCommand, ThreadActiveReorderCommand, ThreadMetaUpdateCommand, ThreadPullRequestLinkCommand, @@ -2091,6 +2106,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", @@ -2236,6 +2252,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 @@ -2556,6 +2579,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"),