authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-24 14:41:49-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 14:41:49-07:00
log0109670411d13b9e380766e98b386b2f756ecf5b
treee3c6489552d5def9a54acf02bdd3aca0ef3e000a
parentd5d48742c9ab00dff82fe8bcbc8da0ba68748959
signature Signed by PGP key B5690EEEBB952194

feat(threads): add per-thread auto-settle switch (#11846)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

42 files changed, 862 insertions(+), 11 deletions(-)

apps/desktop/src/electron/ElectronMenu.ts+2
......@@ -80,6 +80,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
8080 destructive: sourceItem.destructive === true,
8181 disabled: sourceItem.disabled === true,
8282 ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}),
83 ...(typeof sourceItem.checked === "boolean" ? { checked: sourceItem.checked } : {}),
8384 };
8485
8586 if (sourceItem.children) {
......@@ -168,6 +169,7 @@ export const make = Effect.gen(function* () {
168169 const itemOption: Electron.MenuItemConstructorOptions = {
169170 label: item.label,
170171 enabled: !item.disabled,
172 ...(typeof item.checked === "boolean" ? { type: "checkbox", checked: item.checked } : {}),
171173 };
172174 if (item.children && item.children.length > 0) {
173175 itemOption.submenu = buildTemplate(item.children, complete);
apps/mobile/src/components/AppSymbol.tsx+1
......@@ -140,6 +140,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
140140 "checkmark.circle": IconCircleCheck,
141141 circle: IconCircle,
142142 clock: IconClock,
143 timer: IconClock,
143144 ticket: IconTicket,
144145 cloud: IconCloud,
145146 cube: IconBox,
apps/mobile/src/features/home/HomeRouteScreen.tsx+2
......@@ -49,6 +49,7 @@ export function HomeRouteScreen() {
4949 unsnoozeThread,
5050 pinThread,
5151 unpinThread,
52 setThreadAutoSettle,
5253 moveThread,
5354 renameThread,
5455 regenerateThreadTitle,
......@@ -206,6 +207,7 @@ export function HomeRouteScreen() {
206207 onUnsettleThread={unsettleThread}
207208 onPinThread={pinThread}
208209 onUnpinThread={unpinThread}
210 onSetThreadAutoSettle={setThreadAutoSettle}
209211 onMoveThread={moveThread}
210212 onRenameThread={renameThread}
211213 onRegenerateThreadTitle={regenerateThreadTitle}
apps/mobile/src/features/home/HomeScreen.tsx+23
......@@ -96,6 +96,10 @@ interface HomeScreenProps {
9696 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
9797 readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
9898 readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
99 readonly onSetThreadAutoSettle: (
100 thread: EnvironmentThreadShell,
101 enabled: boolean,
102 ) => Promise<boolean>;
99103 readonly onMoveThread: (
100104 thread: EnvironmentThreadShell,
101105 direction: ThreadMoveDestination,
......@@ -378,6 +382,12 @@ export function HomeScreen(props: HomeScreenProps) {
378382 },
379383 [props.onUnpinThread],
380384 );
385 const handleSetThreadAutoSettle = useCallback(
386 (thread: EnvironmentThreadShell, enabled: boolean) => {
387 void props.onSetThreadAutoSettle(thread, enabled);
388 },
389 [props.onSetThreadAutoSettle],
390 );
381391 const handleRegenerateThreadTitle = useCallback(
382392 (thread: EnvironmentThreadShell) => {
383393 void props.onRegenerateThreadTitle(thread);
......@@ -456,6 +466,15 @@ export function HomeScreen(props: HomeScreenProps) {
456466 }
457467 return supported;
458468 }, [serverConfigs]);
469 const autoSettleOptOutEnvironmentIds = useMemo(() => {
470 const supported = new Set<EnvironmentId>();
471 for (const [environmentId, config] of serverConfigs) {
472 if (config.environment.capabilities.threadAutoSettleOptOut === true) {
473 supported.add(environmentId);
474 }
475 }
476 return supported;
477 }, [serverConfigs]);
459478 const pinReorderEnvironmentIds = useMemo(() => {
460479 const supported = new Set<EnvironmentId>();
461480 for (const [environmentId, config] of serverConfigs) {
......@@ -726,6 +745,7 @@ export function HomeScreen(props: HomeScreenProps) {
726745 onSettleThread={handleSettleThread}
727746 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
728747 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
748 autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)}
729749 reorderSupported={
730750 item.item.pinned
731751 ? pinReorderEnvironmentIds.has(thread.environmentId)
......@@ -738,6 +758,7 @@ export function HomeScreen(props: HomeScreenProps) {
738758 onUnsettleThread={handleUnsettleThread}
739759 onPinThread={handlePinThread}
740760 onUnpinThread={handleUnpinThread}
761 onSetThreadAutoSettle={handleSetThreadAutoSettle}
741762 onMoveThread={handleMoveThread}
742763 onSwipeableClose={handleSwipeableClose}
743764 onSwipeableWillOpen={handleSwipeableWillOpen}
......@@ -758,6 +779,8 @@ export function HomeScreen(props: HomeScreenProps) {
758779 handleSwipeableClose,
759780 handleSwipeableWillOpen,
760781 handleUnsettleThread,
782 handleSetThreadAutoSettle,
783 autoSettleOptOutEnvironmentIds,
761784 pinningEnvironmentIds,
762785 machineByEnvironmentId,
763786 pinReorderEnvironmentIds,
apps/mobile/src/features/home/useThreadListActions.ts+46
......@@ -59,6 +59,15 @@ function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["en
5959 );
6060}
6161
62function environmentSupportsAutoSettleOptOut(
63 environmentId: EnvironmentThreadShell["environmentId"],
64) {
65 return (
66 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
67 .threadAutoSettleOptOut === true
68 );
69}
70
6271function environmentSupportsTitleRegeneration(
6372 environmentId: EnvironmentThreadShell["environmentId"],
6473) {
......@@ -237,6 +246,11 @@ export function useThreadListActions(): {
237246 readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
238247 readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
239248 readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
249 /** Sets per-thread automatic settlement on or off. */
250 readonly setThreadAutoSettle: (
251 thread: EnvironmentThreadShell,
252 enabled: boolean,
253 ) => Promise<boolean>;
240254 readonly moveThread: (
241255 thread: EnvironmentThreadShell,
242256 direction: ThreadMoveDestination,
......@@ -249,6 +263,9 @@ export function useThreadListActions(): {
249263 const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false });
250264 const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false });
251265 const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false });
266 const setAutoSettleMutation = useAtomCommand(threadEnvironment.setAutoSettle, {
267 reportFailure: false,
268 });
252269 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
253270 reportFailure: false,
254271 });
......@@ -435,6 +452,34 @@ export function useThreadListActions(): {
435452 },
436453 [unpinMutation],
437454 );
455 const setThreadAutoSettle = useCallback(
456 async (thread: EnvironmentThreadShell, enabled: boolean) => {
457 if (!environmentSupportsAutoSettleOptOut(thread.environmentId)) {
458 Alert.alert(
459 "Could not update auto-settle",
460 "This environment's server does not support turning auto-settle off per thread yet. Update the server to use it.",
461 );
462 return false;
463 }
464 selectionHaptic();
465 const result = await setAutoSettleMutation({
466 environmentId: thread.environmentId,
467 input: { threadId: thread.id, enabled },
468 });
469 if (result._tag === "Failure") {
470 const error = Cause.squash(result.cause);
471 Alert.alert(
472 "Could not update auto-settle",
473 error instanceof Error && error.message.trim().length > 0
474 ? error.message
475 : "The auto-settle setting could not be changed.",
476 );
477 return false;
478 }
479 return true;
480 },
481 [setAutoSettleMutation],
482 );
438483 const regenerateThreadTitle = useCallback(
439484 async (thread: EnvironmentThreadShell) => {
440485 const key = scopedThreadKey(thread.environmentId, thread.id);
......@@ -698,6 +743,7 @@ export function useThreadListActions(): {
698743 unsettleThread,
699744 pinThread,
700745 unpinThread,
746 setThreadAutoSettle,
701747 moveThread,
702748 renameThread,
703749 regenerateThreadTitle,
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+14
......@@ -150,6 +150,7 @@ function ThreadNavigationSidebarPane(
150150 unsettleThread,
151151 pinThread,
152152 unpinThread,
153 setThreadAutoSettle,
153154 moveThread,
154155 renameThread,
155156 regenerateThreadTitle,
......@@ -334,6 +335,15 @@ function ThreadNavigationSidebarPane(
334335 }
335336 return supported;
336337 }, [serverConfigs]);
338 const autoSettleOptOutEnvironmentIds = useMemo(() => {
339 const supported = new Set<EnvironmentId>();
340 for (const [environmentId, config] of serverConfigs) {
341 if (config.environment.capabilities.threadAutoSettleOptOut === true) {
342 supported.add(environmentId);
343 }
344 }
345 return supported;
346 }, [serverConfigs]);
337347 const pinReorderEnvironmentIds = useMemo(() => {
338348 const supported = new Set<EnvironmentId>();
339349 for (const [environmentId, config] of serverConfigs) {
......@@ -766,6 +776,7 @@ function ThreadNavigationSidebarPane(
766776 onSettleThread={settleThread}
767777 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
768778 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
779 autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)}
769780 reorderSupported={
770781 item.item.pinned
771782 ? pinReorderEnvironmentIds.has(thread.environmentId)
......@@ -778,6 +789,7 @@ function ThreadNavigationSidebarPane(
778789 onUnsettleThread={unsettleThread}
779790 onPinThread={pinThread}
780791 onUnpinThread={unpinThread}
792 onSetThreadAutoSettle={setThreadAutoSettle}
781793 onMoveThread={moveThread}
782794 onSwipeableClose={handleSwipeableClose}
783795 onSwipeableWillOpen={handleSwipeableWillOpen}
......@@ -829,6 +841,8 @@ function ThreadNavigationSidebarPane(
829841 pinReorderEnvironmentIds,
830842 pinThread,
831843 pinningEnvironmentIds,
844 autoSettleOptOutEnvironmentIds,
845 setThreadAutoSettle,
832846 projectByKey,
833847 projectTitleByProjectKey,
834848 regenerateThreadTitle,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+53-5
......@@ -493,6 +493,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
493493 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
494494 readonly onPinThread: (thread: EnvironmentThreadShell) => void;
495495 readonly onUnpinThread: (thread: EnvironmentThreadShell) => void;
496 readonly onSetThreadAutoSettle: (thread: EnvironmentThreadShell, enabled: boolean) => void;
496497 /** False on environments whose server predates thread.settle/unsettle:
497498 swipe + menu fall back to Archive instead of failing on use. */
498499 readonly settlementSupported: boolean;
......@@ -500,6 +501,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
500501 readonly snoozeSupported: boolean;
501502 /** False on servers that predate thread.pin/unpin. */
502503 readonly pinningSupported: boolean;
504 /** False on servers that predate thread.auto-settle.set. */
505 readonly autoSettleOptOutSupported: boolean;
503506 /** False on servers that predate thread title regeneration. */
504507 readonly titleRegenerationSupported: boolean;
505508 /** Server supports reordering this card's section. */
......@@ -536,6 +539,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
536539 onArchiveThread,
537540 onPinThread,
538541 onUnpinThread,
542 onSetThreadAutoSettle,
539543 onMoveThread,
540544 } = props;
541545 const snoozedRow = props.snoozed === true;
......@@ -583,6 +587,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
583587 const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);
584588 const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]);
585589 const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]);
590 const handleSetAutoSettle = useCallback(
591 (enabled: boolean) => onSetThreadAutoSettle(thread, enabled),
592 [onSetThreadAutoSettle, thread],
593 );
586594 const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]);
587595 const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]);
588596 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
......@@ -661,6 +669,33 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
661669 variant,
662670 ],
663671 );
672 // A submenu with the current option checked, matching web. This is a
673 // per-thread setting, not a lifecycle verb.
674 const autoSettleMenuItems = useMemo<MenuAction[]>(
675 () =>
676 props.autoSettleOptOutSupported
677 ? [
678 {
679 id: "auto-settle",
680 title: "Auto-settle behavior",
681 image: "timer",
682 subactions: [
683 {
684 id: "auto-settle:enabled",
685 title: "Enabled",
686 state: thread.autoSettleDisabledAt == null ? "on" : "off",
687 },
688 {
689 id: "auto-settle:disabled",
690 title: "Disabled",
691 state: thread.autoSettleDisabledAt == null ? "off" : "on",
692 },
693 ],
694 } satisfies MenuAction,
695 ]
696 : [],
697 [props.autoSettleOptOutSupported, thread.autoSettleDisabledAt],
698 );
664699 const titleMenuItems = useMemo<MenuAction[]>(
665700 () => [
666701 { id: "rename", title: "Rename", image: "square.and.pencil" },
......@@ -682,19 +717,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
682717 },
683718 ...arrangementMenuItems,
684719 ...titleMenuItems,
720 ...autoSettleMenuItems,
685721 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
686722 ],
687 [arrangementMenuItems, snoozePresetActions, titleMenuItems],
723 [arrangementMenuItems, autoSettleMenuItems, snoozePresetActions, titleMenuItems],
688724 );
689725 const cardMenuActions = useMemo<MenuAction[]>(
690726 () => [
691727 CARD_MENU_ACTIONS[0]!,
692728 ...arrangementMenuItems,
693729 ...titleMenuItems,
730 ...autoSettleMenuItems,
694731 ...CARD_MENU_ACTIONS.slice(1),
695732 ],
696 [arrangementMenuItems, titleMenuItems],
733 [arrangementMenuItems, autoSettleMenuItems, titleMenuItems],
697734 );
735 // Settled and snoozed rows keep the setting too, matching web where every
736 // row shares one menu builder.
698737 const slimMenuActions = useMemo<MenuAction[]>(
699738 () => [
700739 SLIM_MENU_ACTIONS[0]!,
......@@ -702,13 +741,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
702741 (action) => action.id !== "move-up" && action.id !== "move-down",
703742 ),
704743 ...titleMenuItems,
744 ...autoSettleMenuItems,
705745 SLIM_MENU_ACTIONS[1]!,
706746 ],
707 [arrangementMenuItems, titleMenuItems],
747 [arrangementMenuItems, autoSettleMenuItems, titleMenuItems],
708748 );
709749 const snoozedMenuActions = useMemo<MenuAction[]>(
710 () => [SNOOZED_MENU_ACTIONS[0]!, ...titleMenuItems, SNOOZED_MENU_ACTIONS[1]!],
711 [titleMenuItems],
750 () => [
751 SNOOZED_MENU_ACTIONS[0]!,
752 ...titleMenuItems,
753 ...autoSettleMenuItems,
754 SNOOZED_MENU_ACTIONS[1]!,
755 ],
756 [autoSettleMenuItems, titleMenuItems],
712757 );
713758 const legacyMenuActions = useMemo<MenuAction[]>(
714759 () => [
......@@ -727,6 +772,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
727772 if (nativeEvent.event === "unsnooze") handleUnsnooze();
728773 if (nativeEvent.event === "pin") handlePin();
729774 if (nativeEvent.event === "unpin") handleUnpin();
775 if (nativeEvent.event === "auto-settle:enabled") handleSetAutoSettle(true);
776 if (nativeEvent.event === "auto-settle:disabled") handleSetAutoSettle(false);
730777 if (nativeEvent.event === "arrange") appAtomRegistry.set(threadArrangementOpenAtom, true);
731778 if (nativeEvent.event === "move-up") handleMoveUp();
732779 if (nativeEvent.event === "move-down") handleMoveDown();
......@@ -764,6 +811,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
764811 handlePin,
765812 handleSettle,
766813 handleSnooze,
814 handleSetAutoSettle,
767815 handleUnpin,
768816 handleUnsettle,
769817 handleUnsnooze,
apps/mobile/src/state/use-thread-selection.ts+1
......@@ -74,6 +74,7 @@ function threadDetailToShell(
7474 settledAt: thread.settledAt,
7575 unsettledAt: thread.unsettledAt,
7676 activeOrderKey: thread.activeOrderKey,
77 autoSettleDisabledAt: thread.autoSettleDisabledAt,
7778 pinnedAt: thread.pinnedAt,
7879 pinOrderKey: thread.pinOrderKey,
7980 snoozedUntil: thread.snoozedUntil ?? null,
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -236,6 +236,7 @@ export const make = Effect.gen(function* () {
236236 threadPinning: true,
237237 threadPinReorder: true,
238238 threadActiveReorder: true,
239 threadAutoSettleOptOut: true,
239240 threadTitleRegeneration: true,
240241 threadPullRequests: true,
241242 pullRequestStackActions: true,
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+16
......@@ -633,6 +633,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
633633 pinnedAt: null,
634634 pinOrderKey: null,
635635 activeOrderKey: null,
636 autoSettleDisabledAt: null,
636637 titleRegenerationRequestId: null,
637638 titleRegenerationStartedAt: null,
638639 latestUserMessageAt: null,
......@@ -782,6 +783,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
782783 return;
783784 }
784785
786 case "thread.auto-settle-set": {
787 const existingRow = yield* projectionThreadRepository.getById({
788 threadId: event.payload.threadId,
789 });
790 if (Option.isNone(existingRow)) {
791 return;
792 }
793 yield* projectionThreadRepository.upsert({
794 ...existingRow.value,
795 autoSettleDisabledAt: event.payload.autoSettleDisabledAt,
796 updatedAt: event.payload.updatedAt,
797 });
798 return;
799 }
800
785801 case "thread.pin-reordered": {
786802 const existingRow = yield* projectionThreadRepository.getById({
787803 threadId: event.payload.threadId,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2
......@@ -485,6 +485,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
485485 pinnedAt: "2026-02-24T00:00:01.000Z",
486486 pinOrderKey: "gm",
487487 activeOrderKey: "hq",
488 autoSettleDisabledAt: null,
488489 titleRegeneration: null,
489490 titleState: null,
490491 deletedAt: null,
......@@ -611,6 +612,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
611612 pinnedAt: "2026-02-24T00:00:01.000Z",
612613 pinOrderKey: "gm",
613614 activeOrderKey: "hq",
615 autoSettleDisabledAt: null,
614616 titleRegeneration: null,
615617 titleState: null,
616618 session: {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+10
......@@ -587,6 +587,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
587587 pinned_at AS "pinnedAt",
588588 pin_order_key AS "pinOrderKey",
589589 active_order_key AS "activeOrderKey",
590 auto_settle_disabled_at AS "autoSettleDisabledAt",
590591 title_regeneration_request_id AS "titleRegenerationRequestId",
591592 title_regeneration_started_at AS "titleRegenerationStartedAt",
592593 latest_user_message_at AS "latestUserMessageAt",
......@@ -628,6 +629,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
628629 pinned_at AS "pinnedAt",
629630 pin_order_key AS "pinOrderKey",
630631 active_order_key AS "activeOrderKey",
632 auto_settle_disabled_at AS "autoSettleDisabledAt",
631633 title_regeneration_request_id AS "titleRegenerationRequestId",
632634 title_regeneration_started_at AS "titleRegenerationStartedAt",
633635 latest_user_message_at AS "latestUserMessageAt",
......@@ -701,6 +703,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
701703 pinned_at AS "pinnedAt",
702704 pin_order_key AS "pinOrderKey",
703705 active_order_key AS "activeOrderKey",
706 auto_settle_disabled_at AS "autoSettleDisabledAt",
704707 title_regeneration_request_id AS "titleRegenerationRequestId",
705708 title_regeneration_started_at AS "titleRegenerationStartedAt",
706709 latest_user_message_at AS "latestUserMessageAt",
......@@ -1266,6 +1269,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
12661269 pinned_at AS "pinnedAt",
12671270 pin_order_key AS "pinOrderKey",
12681271 active_order_key AS "activeOrderKey",
1272 auto_settle_disabled_at AS "autoSettleDisabledAt",
12691273 title_regeneration_request_id AS "titleRegenerationRequestId",
12701274 title_regeneration_started_at AS "titleRegenerationStartedAt",
12711275 latest_user_message_at AS "latestUserMessageAt",
......@@ -2341,6 +2345,7 @@ pending_approval_requests AS (
23412345 pinnedAt: row.pinnedAt,
23422346 pinOrderKey: row.pinOrderKey ?? null,
23432347 activeOrderKey: row.activeOrderKey ?? null,
2348 autoSettleDisabledAt: row.autoSettleDisabledAt ?? null,
23442349 titleRegeneration: mapTitleRegeneration(row),
23452350 titleState: row.titleState,
23462351 deletedAt: row.deletedAt,
......@@ -2586,6 +2591,7 @@ pending_approval_requests AS (
25862591 pinnedAt: row.pinnedAt,
25872592 pinOrderKey: row.pinOrderKey ?? null,
25882593 activeOrderKey: row.activeOrderKey ?? null,
2594 autoSettleDisabledAt: row.autoSettleDisabledAt ?? null,
25892595 titleRegeneration: mapTitleRegeneration(row),
25902596 titleState: row.titleState,
25912597 deletedAt: row.deletedAt,
......@@ -2742,6 +2748,7 @@ pending_approval_requests AS (
27422748 pinnedAt: row.pinnedAt,
27432749 pinOrderKey: row.pinOrderKey ?? null,
27442750 activeOrderKey: row.activeOrderKey ?? null,
2751 autoSettleDisabledAt: row.autoSettleDisabledAt ?? null,
27452752 titleRegeneration: mapTitleRegeneration(row),
27462753 titleState: row.titleState,
27472754 session: sessionByThread.get(row.threadId) ?? null,
......@@ -2905,6 +2912,7 @@ pending_approval_requests AS (
29052912 pinnedAt: row.pinnedAt,
29062913 pinOrderKey: row.pinOrderKey ?? null,
29072914 activeOrderKey: row.activeOrderKey ?? null,
2915 autoSettleDisabledAt: row.autoSettleDisabledAt ?? null,
29082916 titleRegeneration: mapTitleRegeneration(row),
29092917 titleState: row.titleState,
29102918 session: sessionByThread.get(row.threadId) ?? null,
......@@ -3261,6 +3269,7 @@ pending_approval_requests AS (
32613269 pinnedAt: threadRow.value.pinnedAt,
32623270 pinOrderKey: threadRow.value.pinOrderKey ?? null,
32633271 activeOrderKey: threadRow.value.activeOrderKey ?? null,
3272 autoSettleDisabledAt: threadRow.value.autoSettleDisabledAt ?? null,
32643273 titleRegeneration: mapTitleRegeneration(threadRow.value),
32653274 titleState: threadRow.value.titleState,
32663275 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,
......@@ -3562,6 +3571,7 @@ pending_approval_requests AS (
35623571 pinnedAt: threadRow.value.pinnedAt,
35633572 pinOrderKey: threadRow.value.pinOrderKey ?? null,
35643573 activeOrderKey: threadRow.value.activeOrderKey ?? null,
3574 autoSettleDisabledAt: threadRow.value.autoSettleDisabledAt ?? null,
35653575 titleRegeneration: mapTitleRegeneration(threadRow.value),
35663576 titleState: threadRow.value.titleState,
35673577 deletedAt: null,
apps/server/src/orchestration/Schemas.ts+2
......@@ -16,6 +16,7 @@ import {
1616 ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema,
1717 ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema,
1818 ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema,
19 ThreadAutoSettleSetPayload as ContractsThreadAutoSettleSetPayloadSchema,
1920 ThreadPullRequestLinkedPayload as ContractsThreadPullRequestLinkedPayloadSchema,
2021 ThreadPullRequestUnlinkedPayload as ContractsThreadPullRequestUnlinkedPayloadSchema,
2122 ThreadPullRequestSyncedPayload as ContractsThreadPullRequestSyncedPayloadSchema,
......@@ -51,6 +52,7 @@ export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema;
5152export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema;
5253export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema;
5354export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema;
55export const ThreadAutoSettleSetPayload = ContractsThreadAutoSettleSetPayloadSchema;
5456export const ThreadPullRequestLinkedPayload = ContractsThreadPullRequestLinkedPayloadSchema;
5557export const ThreadPullRequestUnlinkedPayload = ContractsThreadPullRequestUnlinkedPayloadSchema;
5658export const ThreadPullRequestSyncedPayload = ContractsThreadPullRequestSyncedPayloadSchema;
apps/server/src/orchestration/ThreadSettlementPolicy.test.ts+24
......@@ -171,6 +171,15 @@ describe("resolveAutoSettlementAt", () => {
171171
172172 it("blocks pins, snooze, pending work, live sessions, and queued starts", () => {
173173 expect(decide(makeThread({ settledOverride: "active" }))).toBe(false);
174 });
175
176 it("never settles a thread whose auto-settle is turned off, by inactivity or merge", () => {
177 const held = makeThread({ autoSettleDisabledAt: "2026-08-21T00:00:00.000Z" });
178 expect(decide(held)).toBe(false);
179 expect(
180 decide(held, { state: "merged", mergedAt: "2026-08-21T00:00:00.000Z", closedAt: null }),
181 ).toBe(false);
182 expect(decide(makeThread({ autoSettleDisabledAt: null }))).toBe(true);
174183 expect(decide(makeThread({ snoozedUntil: "2026-08-29T00:00:00.000Z" }))).toBe(false);
175184 expect(decide(makeThread({ hasPendingApprovals: true }))).toBe(false);
176185 expect(decide(makeThread({ hasPendingUserInput: true }))).toBe(false);
......@@ -248,6 +257,21 @@ const terminalSnapshot = (
248257 syncedAt: NOW,
249258});
250259
260describe("per-thread auto-settle opt out", () => {
261 it("blocks both inactivity and merge settlement while auto-settle is off", () => {
262 const merged = linkedRequest(1, terminalSnapshot("merged", NOW));
263 expect(decide(makeThread({ latestUserMessageAt: "2026-08-01T00:00:00.000Z" }))).toBe(true);
264 expect(decide(makeThread({ pullRequests: [merged] }), null, { days: null })).toBe(true);
265 const held = { autoSettleDisabledAt: NOW };
266 expect(decide(makeThread({ ...held, latestUserMessageAt: "2026-08-01T00:00:00.000Z" }))).toBe(
267 false,
268 );
269 expect(decide(makeThread({ ...held, pullRequests: [merged] }), null, { days: null })).toBe(
270 false,
271 );
272 });
273});
274
251275describe("linked request settlement", () => {
252276 it.each(["closed", "merged"] as const)(
253277 "uses the latest actual %s transition despite later comments on another PR",
apps/server/src/orchestration/ThreadSettlementPolicy.ts+1
......@@ -117,6 +117,7 @@ export function resolveAutoSettlementAt(input: {
117117/** Cheap checks that run before any source control lookup. */
118118export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now: string): boolean {
119119 if (thread.archivedAt !== null || thread.settledOverride !== null) return false;
120 if (thread.autoSettleDisabledAt != null) return false;
120121 if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false;
121122 if (thread.session?.status === "starting" || thread.session?.status === "running") return false;
122123 if (thread.backgroundLiveness != null) return false;
apps/server/src/orchestration/decider.autoSettleSet.test.ts created+154
......@@ -0,0 +1,154 @@
1import {
2 CommandId,
3 ProjectId,
4 ProviderInstanceId,
5 ThreadId,
6 type OrchestrationReadModel,
7} from "@t3tools/contracts";
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { expect, it } from "@effect/vitest";
10import * as Effect from "effect/Effect";
11
12import { decideOrchestrationCommand } from "./decider.ts";
13
14const NOW = "2026-01-01T00:00:00.000Z";
15const DISABLED_AT = "2025-12-30T00:00:00.000Z";
16
17function makeReadModel(input: {
18 readonly autoSettleDisabledAt?: string | null;
19 readonly settledOverride?: "settled" | "active" | null;
20}): OrchestrationReadModel {
21 return {
22 snapshotSequence: 0,
23 projects: [],
24 threads: [
25 {
26 id: ThreadId.make("thread-1"),
27 projectId: ProjectId.make("project-1"),
28 title: "Thread",
29 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
30 runtimeMode: "full-access",
31 interactionMode: "default",
32 branch: null,
33 worktreePath: null,
34 pullRequests: [],
35 latestTurn: null,
36 createdAt: NOW,
37 updatedAt: NOW,
38 archivedAt: null,
39 settledOverride: input.settledOverride ?? null,
40 settledAt: input.settledOverride === "settled" ? NOW : null,
41 autoSettleDisabledAt: input.autoSettleDisabledAt ?? null,
42 deletedAt: null,
43 messages: [],
44 proposedPlans: [],
45 activities: [],
46 checkpoints: [],
47 session: null,
48 },
49 ],
50 updatedAt: NOW,
51 };
52}
53
54const events = (event: Effect.Success<ReturnType<typeof decideOrchestrationCommand>>) =>
55 Array.isArray(event) ? event : [event];
56
57it.layer(NodeServices.layer)("thread.auto-settle.set decider", (it) => {
58 it.effect("turning auto-settle off stamps autoSettleDisabledAt and updatedAt together", () =>
59 Effect.gen(function* () {
60 const [event] = events(
61 yield* decideOrchestrationCommand({
62 command: {
63 type: "thread.auto-settle.set",
64 commandId: CommandId.make("cmd-off"),
65 threadId: ThreadId.make("thread-1"),
66 enabled: false,
67 },
68 readModel: makeReadModel({}),
69 }),
70 );
71 expect(event?.type).toBe("thread.auto-settle-set");
72 if (event?.type === "thread.auto-settle-set") {
73 expect(event.payload.autoSettleDisabledAt).toBe(event.payload.updatedAt);
74 expect(event.payload.updatedAt).not.toBe(NOW);
75 }
76 }),
77 );
78
79 it.effect("turning it off again keeps the original stamp and updatedAt", () =>
80 Effect.gen(function* () {
81 const [event] = events(
82 yield* decideOrchestrationCommand({
83 command: {
84 type: "thread.auto-settle.set",
85 commandId: CommandId.make("cmd-off-again"),
86 threadId: ThreadId.make("thread-1"),
87 enabled: false,
88 },
89 readModel: makeReadModel({ autoSettleDisabledAt: DISABLED_AT }),
90 }),
91 );
92 expect(event?.type).toBe("thread.auto-settle-set");
93 if (event?.type === "thread.auto-settle-set") {
94 expect(event.payload.autoSettleDisabledAt).toBe(DISABLED_AT);
95 expect(event.payload.updatedAt).toBe(NOW);
96 }
97 }),
98 );
99
100 it.effect("turning auto-settle back on clears the stamp", () =>
101 Effect.gen(function* () {
102 const [event] = events(
103 yield* decideOrchestrationCommand({
104 command: {
105 type: "thread.auto-settle.set",
106 commandId: CommandId.make("cmd-on"),
107 threadId: ThreadId.make("thread-1"),
108 enabled: true,
109 },
110 readModel: makeReadModel({ autoSettleDisabledAt: DISABLED_AT }),
111 }),
112 );
113 expect(event?.type).toBe("thread.auto-settle-set");
114 if (event?.type === "thread.auto-settle-set") {
115 expect(event.payload.autoSettleDisabledAt).toBeNull();
116 expect(event.payload.updatedAt).not.toBe(NOW);
117 }
118 }),
119 );
120
121 it.effect("automatic settlement is rejected while auto-settle is off", () =>
122 Effect.gen(function* () {
123 const result = yield* Effect.exit(
124 decideOrchestrationCommand({
125 command: {
126 type: "thread.auto-settle",
127 commandId: CommandId.make("cmd-auto"),
128 threadId: ThreadId.make("thread-1"),
129 snapshotSequence: 0,
130 settledAt: NOW,
131 },
132 readModel: makeReadModel({ autoSettleDisabledAt: DISABLED_AT }),
133 }),
134 );
135 expect(result._tag).toBe("Failure");
136 }),
137 );
138
139 it.effect("a manual settle still works while auto-settle is off", () =>
140 Effect.gen(function* () {
141 const [event] = events(
142 yield* decideOrchestrationCommand({
143 command: {
144 type: "thread.settle",
145 commandId: CommandId.make("cmd-manual"),
146 threadId: ThreadId.make("thread-1"),
147 },
148 readModel: makeReadModel({ autoSettleDisabledAt: DISABLED_AT }),
149 }),
150 );
151 expect(event?.type).toBe("thread.settled");
152 }),
153 );
154});
apps/server/src/orchestration/decider.ts+35-1
......@@ -484,7 +484,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
484484 command,
485485 threadId: command.threadId,
486486 });
487 if (command.type === "thread.auto-settle" && thread.settledOverride !== null) {
487 if (
488 command.type === "thread.auto-settle" &&
489 (thread.settledOverride !== null || thread.autoSettleDisabledAt != null)
490 ) {
488491 return yield* Effect.fail(
489492 new OrchestrationCommandInvariantError({
490493 commandType: command.type,
......@@ -860,6 +863,37 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
860863 };
861864 }
862865
866 case "thread.auto-settle.set": {
867 const thread = yield* requireThreadNotArchived({
868 readModel,
869 command,
870 threadId: command.threadId,
871 });
872 // Idempotent by re-emission (see thread.unpin): setting the current
873 // state again keeps the existing timestamps so duplicates do not churn
874 // ordering. The flag is independent of the settled lifecycle: it only
875 // gates the automatic paths, so it never blocks a manual settle.
876 const currentlyDisabledAt = thread.autoSettleDisabledAt ?? null;
877 const unchanged = command.enabled
878 ? currentlyDisabledAt === null
879 : currentlyDisabledAt !== null;
880 const occurredAt = yield* nowIso;
881 return {
882 ...(yield* withEventBase({
883 aggregateKind: "thread",
884 aggregateId: command.threadId,
885 occurredAt,
886 commandId: command.commandId,
887 })),
888 type: "thread.auto-settle-set",
889 payload: {
890 threadId: command.threadId,
891 autoSettleDisabledAt: command.enabled ? null : (currentlyDisabledAt ?? occurredAt),
892 updatedAt: unchanged ? thread.updatedAt : occurredAt,
893 },
894 };
895 }
896
863897 case "thread.active.reorder": {
864898 const thread = yield* requireThreadNotArchived({
865899 readModel,
apps/server/src/orchestration/projector.autoSettleSet.test.ts created+105
......@@ -0,0 +1,105 @@
1import {
2 CommandId,
3 EventId,
4 ProjectId,
5 ThreadId,
6 type OrchestrationEvent,
7} from "@t3tools/contracts";
8import { expect, it } from "@effect/vitest";
9import * as Effect from "effect/Effect";
10
11import { createEmptyReadModel, projectEvent } from "./projector.ts";
12
13function makeEvent(input: {
14 readonly sequence: number;
15 readonly type: OrchestrationEvent["type"];
16 readonly payload: unknown;
17}): OrchestrationEvent {
18 return {
19 sequence: input.sequence,
20 eventId: EventId.make(`event-${input.sequence}`),
21 type: input.type,
22 aggregateKind: "thread",
23 aggregateId: ThreadId.make("thread-1"),
24 occurredAt: "2026-01-01T00:00:00.000Z",
25 commandId: CommandId.make(`command-${input.sequence}`),
26 causationEventId: null,
27 correlationId: null,
28 metadata: {},
29 payload: input.payload as never,
30 } as OrchestrationEvent;
31}
32
33it.effect("projects auto-settle opt-out and survives a manual settle", () =>
34 Effect.gen(function* () {
35 const now = "2026-01-01T00:00:00.000Z";
36 const later = "2026-01-02T00:00:00.000Z";
37 const created = yield* projectEvent(
38 createEmptyReadModel(now),
39 makeEvent({
40 sequence: 1,
41 type: "thread.created",
42 payload: {
43 threadId: ThreadId.make("thread-1"),
44 projectId: ProjectId.make("project-1"),
45 title: "Thread",
46 modelSelection: { provider: "codex", model: "gpt-5.4" },
47 runtimeMode: "full-access",
48 interactionMode: "default",
49 branch: null,
50 worktreePath: null,
51 createdAt: now,
52 updatedAt: now,
53 },
54 }),
55 );
56 expect(created.threads[0]?.autoSettleDisabledAt ?? null).toBeNull();
57
58 const disabled = yield* projectEvent(
59 created,
60 makeEvent({
61 sequence: 2,
62 type: "thread.auto-settle-set",
63 payload: { threadId: ThreadId.make("thread-1"), autoSettleDisabledAt: now, updatedAt: now },
64 }),
65 );
66 expect(disabled.threads[0]?.autoSettleDisabledAt).toBe(now);
67
68 // The flag is independent of the settled lifecycle: settling by hand and
69 // un-settling later must not clear it.
70 const settled = yield* projectEvent(
71 disabled,
72 makeEvent({
73 sequence: 3,
74 type: "thread.settled",
75 payload: { threadId: ThreadId.make("thread-1"), settledAt: later, updatedAt: later },
76 }),
77 );
78 expect(settled.threads[0]?.settledOverride).toBe("settled");
79 expect(settled.threads[0]?.autoSettleDisabledAt).toBe(now);
80
81 const unsettled = yield* projectEvent(
82 settled,
83 makeEvent({
84 sequence: 4,
85 type: "thread.unsettled",
86 payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: later },
87 }),
88 );
89 expect(unsettled.threads[0]?.autoSettleDisabledAt).toBe(now);
90
91 const enabled = yield* projectEvent(
92 unsettled,
93 makeEvent({
94 sequence: 5,
95 type: "thread.auto-settle-set",
96 payload: {
97 threadId: ThreadId.make("thread-1"),
98 autoSettleDisabledAt: null,
99 updatedAt: later,
100 },
101 }),
102 );
103 expect(enabled.threads[0]?.autoSettleDisabledAt).toBeNull();
104 }),
105);
apps/server/src/orchestration/projector.test.ts+1
......@@ -93,6 +93,7 @@ describe("orchestration projector", () => {
9393 updatedAt: now,
9494 archivedAt: null,
9595 activeOrderKey: null,
96 autoSettleDisabledAt: null,
9697 settledOverride: null,
9798 settledAt: null,
9899 unsettledAt: null,
apps/server/src/orchestration/projector.ts+13
......@@ -42,6 +42,7 @@ import {
4242 ThreadSettledPayload,
4343 ThreadPinnedPayload,
4444 ThreadPinReorderedPayload,
45 ThreadAutoSettleSetPayload,
4546 ThreadPullRequestLinkedPayload,
4647 ThreadPullRequestSyncedPayload,
4748 ThreadPullRequestUnlinkedPayload,
......@@ -440,6 +441,7 @@ export function projectEvent(
440441 settledAt: null,
441442 unsettledAt: null,
442443 activeOrderKey: null,
444 autoSettleDisabledAt: null,
443445 snoozedUntil: null,
444446 snoozedAt: null,
445447 deletedAt: null,
......@@ -580,6 +582,17 @@ export function projectEvent(
580582 })),
581583 );
582584
585 case "thread.auto-settle-set":
586 return decodeForEvent(ThreadAutoSettleSetPayload, event.payload, event.type, "payload").pipe(
587 Effect.map((payload) => ({
588 ...nextBase,
589 threads: updateThread(nextBase.threads, payload.threadId, {
590 autoSettleDisabledAt: payload.autoSettleDisabledAt,
591 updatedAt: payload.updatedAt,
592 }),
593 })),
594 );
595
583596 case "thread.pin-reordered":
584597 return decodeForEvent(ThreadPinReorderedPayload, event.payload, event.type, "payload").pipe(
585598 Effect.map((payload) => ({
apps/server/src/persistence/Layers/ProjectionThreads.ts+4
......@@ -54,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
5454 pinned_at,
5555 pin_order_key,
5656 active_order_key,
57 auto_settle_disabled_at,
5758 title_regeneration_request_id,
5859 title_regeneration_started_at,
5960 latest_user_message_at,
......@@ -86,6 +87,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
8687 ${row.pinnedAt},
8788 ${row.pinOrderKey ?? null},
8889 ${row.activeOrderKey ?? null},
90 ${row.autoSettleDisabledAt ?? null},
8991 ${row.titleRegenerationRequestId ?? null},
9092 ${row.titleRegenerationStartedAt ?? null},
9193 ${row.latestUserMessageAt},
......@@ -118,6 +120,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
118120 pinned_at = excluded.pinned_at,
119121 pin_order_key = excluded.pin_order_key,
120122 active_order_key = excluded.active_order_key,
123 auto_settle_disabled_at = excluded.auto_settle_disabled_at,
121124 title_regeneration_request_id = excluded.title_regeneration_request_id,
122125 title_regeneration_started_at = excluded.title_regeneration_started_at,
123126 latest_user_message_at = excluded.latest_user_message_at,
......@@ -157,6 +160,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
157160 pinned_at AS "pinnedAt",
158161 pin_order_key AS "pinOrderKey",
159162 active_order_key AS "activeOrderKey",
163 auto_settle_disabled_at AS "autoSettleDisabledAt",
160164 title_regeneration_request_id AS "titleRegenerationRequestId",
161165 title_regeneration_started_at AS "titleRegenerationStartedAt",
162166 latest_user_message_at AS "latestUserMessageAt",
apps/server/src/persistence/Migrations.ts+2
......@@ -65,6 +65,7 @@ import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts";
6565import Migration0051 from "./Migrations/051_ProjectionThreadMessageContext.ts";
6666import Migration0052 from "./Migrations/052_ProjectionThreadTitleState.ts";
6767import Migration0053 from "./Migrations/053_PullRequestFilesViewed.ts";
68import Migration0054 from "./Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts";
6869
6970/**
7071 * Migration loader with all migrations defined inline.
......@@ -130,6 +131,7 @@ const migrationEntries = [
130131 [51, "ProjectionThreadMessageContext", Migration0051],
131132 [52, "ProjectionThreadTitleState", Migration0052],
132133 [53, "PullRequestFilesViewed", Migration0053],
134 [54, "ProjectionThreadsAutoSettleDisabledAt", Migration0054],
133135] as const;
134136
135137export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.test.ts created+41
......@@ -0,0 +1,41 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";
4import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
5
6import { runMigrations } from "../Migrations.ts";
7import migrateAutoSettleDisabledAt from "./054_ProjectionThreadsAutoSettleDisabledAt.ts";
8
9it.layer(NodeSqliteClient.layer({ filename: ":memory:" }))(
10 "054_ProjectionThreadsAutoSettleDisabledAt",
11 (it) => {
12 it.effect("adds the column with auto-settle left on for existing threads", () =>
13 Effect.gen(function* () {
14 const sql = yield* SqlClient.SqlClient;
15 yield* runMigrations({ toMigrationInclusive: 53 });
16 const now = "2026-01-01T00:00:00.000Z";
17 yield* sql`
18 INSERT INTO projection_threads (
19 thread_id, project_id, title, model_selection_json, runtime_mode,
20 created_at, updated_at
21 ) VALUES (
22 'thread-1', 'project-1', 'Existing thread',
23 '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', ${now}, ${now}
24 )
25 `;
26 yield* runMigrations({ toMigrationInclusive: 54 });
27 const migrated = yield* sql<{ readonly autoSettleDisabledAt: string | null }>`
28 SELECT auto_settle_disabled_at AS "autoSettleDisabledAt" FROM projection_threads WHERE thread_id = 'thread-1'
29 `;
30 assert.deepEqual(migrated, [{ autoSettleDisabledAt: null }]);
31 // Re-running against a database that already has the column keeps its value.
32 yield* sql`UPDATE projection_threads SET auto_settle_disabled_at = ${now} WHERE thread_id = 'thread-1'`;
33 yield* migrateAutoSettleDisabledAt;
34 const rows = yield* sql<{ readonly autoSettleDisabledAt: string | null }>`
35 SELECT auto_settle_disabled_at AS "autoSettleDisabledAt" FROM projection_threads WHERE thread_id = 'thread-1'
36 `;
37 assert.deepEqual(rows, [{ autoSettleDisabledAt: now }]);
38 }),
39 );
40 },
41);
apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts created+15
......@@ -0,0 +1,15 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6 const columns = yield* sql<{ readonly name: string }>`
7 PRAGMA table_info(projection_threads)
8 `;
9 if (!columns.some((column) => column.name === "auto_settle_disabled_at")) {
10 yield* sql`
11 ALTER TABLE projection_threads
12 ADD COLUMN auto_settle_disabled_at TEXT
13 `;
14 }
15});
apps/server/src/persistence/Services/ProjectionThreads.ts+1
......@@ -50,6 +50,7 @@ export const ProjectionThread = Schema.Struct({
5050 pinnedAt: Schema.NullOr(IsoDateTime),
5151 pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)),
5252 activeOrderKey: Schema.optional(Schema.NullOr(Schema.String)),
53 autoSettleDisabledAt: Schema.optional(Schema.NullOr(IsoDateTime)),
5354 titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)),
5455 titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
5556 latestUserMessageAt: Schema.NullOr(IsoDateTime),
apps/server/src/project/AgentSessionImporter.ts+1
......@@ -87,6 +87,7 @@ function hasImportBlockingActivity(
8787 thread.snoozedAt != null ||
8888 thread.pinnedAt != null ||
8989 thread.pinOrderKey != null ||
90 thread.autoSettleDisabledAt != null ||
9091 thread.titleRegeneration != null ||
9192 thread.linkedPullRequest != null ||
9293 thread.unsettledAt != null ||
apps/web/src/components/Sidebar.tsx+25
......@@ -2154,6 +2154,7 @@ export default function Sidebar() {
21542154 confirmAndUnpinThread,
21552155 reorderPinnedThread,
21562156 reorderActiveThread,
2157 setThreadAutoSettle,
21572158 archiveThread,
21582159 deleteThread,
21592160 } = useThreadActions();
......@@ -4020,6 +4021,9 @@ export default function Sidebar() {
40204021 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
40214022 const supportsPinning =
40224023 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true;
4024 const supportsAutoSettleOptOut =
4025 serverConfigs.get(thread.environmentId)?.environment.capabilities
4026 .threadAutoSettleOptOut === true;
40234027 const supportsTitleRegeneration =
40244028 serverConfigs.get(thread.environmentId)?.environment.capabilities
40254029 .threadTitleRegeneration === true;
......@@ -4049,6 +4053,7 @@ export default function Sidebar() {
40494053 : null,
40504054 isPinned,
40514055 isSettled,
4056 autoSettleEnabled: thread.autoSettleDisabledAt == null,
40524057 isSnoozed,
40534058 canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }),
40544059 isRegeneratingTitle,
......@@ -4056,6 +4061,7 @@ export default function Sidebar() {
40564061 thread.session?.status === "running" && thread.session.activeTurnId != null,
40574062 supports: {
40584063 settlement: supportsSettlement,
4064 autoSettleOptOut: supportsAutoSettleOptOut,
40594065 snooze: supportsSnooze,
40604066 pinning: supportsPinning,
40614067 titleRegeneration: supportsTitleRegeneration,
......@@ -4127,6 +4133,24 @@ export default function Sidebar() {
41274133 case "unpin":
41284134 attemptUnpin(threadRef);
41294135 return;
4136 case "auto-settle:enabled":
4137 case "auto-settle:disabled": {
4138 const result = await setThreadAutoSettle(
4139 threadRef,
4140 clicked.value === "auto-settle:enabled",
4141 );
4142 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
4143 const error = squashAtomCommandFailure(result);
4144 toastManager.add(
4145 stackedThreadToast({
4146 type: "error",
4147 title: "Failed to update auto-settle",
4148 description: error instanceof Error ? error.message : "An error occurred.",
4149 }),
4150 );
4151 }
4152 return;
4153 }
41304154 case "rename":
41314155 startThreadRename(threadRef, thread.title);
41324156 return;
......@@ -4253,6 +4277,7 @@ export default function Sidebar() {
42534277 projectByKey,
42544278 serverConfigs,
42554279 setProjectScopeKey,
4280 setThreadAutoSettle,
42564281 startThreadRename,
42574282 updateThreadMetadata,
42584283 timestampFormat,
apps/web/src/components/threadActionMenu.logic.test.ts+42-4
......@@ -7,11 +7,18 @@ const baseState: ThreadActionMenuState = {
77 projectFilter: null,
88 isPinned: false,
99 isSettled: false,
10 autoSettleEnabled: true,
1011 isSnoozed: false,
1112 canSnoozeNow: true,
1213 isRegeneratingTitle: false,
1314 isRunning: false,
14 supports: { settlement: true, snooze: true, pinning: true, titleRegeneration: true },
15 supports: {
16 settlement: true,
17 autoSettleOptOut: true,
18 snooze: true,
19 pinning: true,
20 titleRegeneration: true,
21 },
1522 snoozePresets: [
1623 { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" },
1724 ],
......@@ -32,7 +39,13 @@ describe("buildThreadActionMenuItems", () => {
3239 expect(
3340 ids({
3441 ...baseState,
35 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },
42 supports: {
43 settlement: false,
44 autoSettleOptOut: false,
45 snooze: false,
46 pinning: false,
47 titleRegeneration: false,
48 },
3649 }),
3750 ).toEqual(["rename", "mark-unread", "copy", "project-settings", "archive", "delete"]);
3851 });
......@@ -66,7 +79,7 @@ describe("buildThreadActionMenuItems", () => {
6679 const filterIndex = items.findIndex((candidate) => candidate.id === "filter-by-project");
6780 expect(items[filterIndex]).toMatchObject({ label: "Show all projects", icon: "folder-tree" });
6881 expect(items[filterIndex - 1]?.id).toBe("mark-unread");
69 expect(items[filterIndex + 1]?.id).toBe("copy");
82 expect(items[filterIndex + 1]?.id).toBe("auto-settle");
7083 });
7184
7285 it("includes branch items only for threads with a branch", () => {
......@@ -84,6 +97,25 @@ describe("buildThreadActionMenuItems", () => {
8497 expect(ids(baseState)).toEqual(expect.arrayContaining(["pin", "settle", "snooze"]));
8598 });
8699
100 it("offers auto-settle as a submenu with the current option checked", () => {
101 const find = (state: ThreadActionMenuState) =>
102 buildThreadActionMenuItems(state).find((item) => item.id === "auto-settle");
103 const on = find(baseState);
104 expect(on?.label).toBe("Auto-settle behavior");
105 expect(on?.children?.map((child) => [child.id, child.checked])).toEqual([
106 ["auto-settle:enabled", true],
107 ["auto-settle:disabled", false],
108 ]);
109 const off = find({ ...baseState, autoSettleEnabled: false });
110 expect(off?.children?.map((child) => child.checked)).toEqual([false, true]);
111 // Sits with the per-thread settings after Mark unread, not the lifecycle verbs.
112 const items = buildThreadActionMenuItems(baseState);
113 expect(items[items.findIndex((item) => item.id === "mark-unread") + 1]?.id).toBe("auto-settle");
114 expect(
115 ids({ ...baseState, supports: { ...baseState.supports, autoSettleOptOut: false } }),
116 ).not.toContain("auto-settle");
117 });
118
87119 it("disables snooze when the thread cannot snooze, keeping presets visible", () => {
88120 const snooze = buildThreadActionMenuItems({ ...baseState, canSnoozeNow: false }).find(
89121 (item) => item.id === "snooze",
......@@ -117,7 +149,13 @@ describe("buildThreadActionMenuItems", () => {
117149 expect(
118150 ids({
119151 ...baseState,
120 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },
152 supports: {
153 settlement: false,
154 autoSettleOptOut: false,
155 snooze: false,
156 pinning: false,
157 titleRegeneration: false,
158 },
121159 }),
122160 ).toContain("archive");
123161 });
apps/web/src/components/threadActionMenu.logic.ts+32
......@@ -14,6 +14,9 @@ export type ThreadActionMenuId =
1414 | "unpin"
1515 | "settle"
1616 | "unsettle"
17 | "auto-settle"
18 | "auto-settle:enabled"
19 | "auto-settle:disabled"
1720 | "snooze"
1821 | `snooze:${string}`
1922 | "unsnooze"
......@@ -40,6 +43,8 @@ export interface ThreadActionMenuState {
4043 } | null;
4144 readonly isPinned: boolean;
4245 readonly isSettled: boolean;
46 /** False while the user has turned automatic settlement off for this thread. */
47 readonly autoSettleEnabled: boolean;
4348 readonly isSnoozed: boolean;
4449 readonly canSnoozeNow: boolean;
4550 readonly isRegeneratingTitle: boolean;
......@@ -47,6 +52,8 @@ export interface ThreadActionMenuState {
4752 readonly isRunning: boolean;
4853 readonly supports: {
4954 readonly settlement: boolean;
55 /** Server understands thread.auto-settle.set. */
56 readonly autoSettleOptOut: boolean;
5057 readonly snooze: boolean;
5158 readonly pinning: boolean;
5259 readonly titleRegeneration: boolean;
......@@ -131,6 +138,31 @@ export function buildThreadActionMenuItems(
131138 },
132139 ]
133140 : []),
141 // A submenu with the current option checked, not a one-shot action:
142 // this is a setting, and it sits with the other per-thread settings
143 // rather than the lifecycle verbs above. Disabled keeps long-running
144 // threads out of the settled shelf no matter how quiet they get.
145 ...(state.supports.autoSettleOptOut
146 ? [
147 {
148 id: "auto-settle" as const,
149 label: "Auto-settle behavior",
150 icon: "timer",
151 children: [
152 {
153 id: "auto-settle:enabled" as const,
154 label: "Enabled",
155 checked: state.autoSettleEnabled,
156 },
157 {
158 id: "auto-settle:disabled" as const,
159 label: "Disabled",
160 checked: !state.autoSettleEnabled,
161 },
162 ],
163 },
164 ]
165 : []),
134166 {
135167 id: "copy",
136168 label: "Copy",
apps/web/src/contextMenuFallback.ts+22-1
......@@ -9,6 +9,12 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri
99 { tag: "path", attrs: { d: "M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" } },
1010 { tag: "path", attrs: { d: "M10 12h4" } },
1111 ],
12 check: [{ tag: "path", attrs: { d: "M20 6 9 17l-5-5" } }],
13 timer: [
14 { tag: "line", attrs: { x1: "10", x2: "14", y1: "2", y2: "2" } },
15 { tag: "line", attrs: { x1: "12", x2: "15", y1: "14", y2: "11" } },
16 { tag: "circle", attrs: { cx: "12", cy: "14", r: "8" } },
17 ],
1218 "chevron-right": [{ tag: "path", attrs: { d: "m9 19 7-7-7-7" } }],
1319 "circle-check": [
1420 { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } },
......@@ -347,7 +353,22 @@ export function showContextMenuFallback<T extends string>(
347353 button.style.pointerEvents = "none";
348354 }
349355
350 if (typeof item.icon === "string") {
356 if (typeof item.checked === "boolean") {
357 // Option rows use the icon slot for the check so labels line up
358 // with icon rows. The unselected option keeps the slot empty.
359 button.setAttribute("role", "menuitemradio");
360 button.setAttribute("aria-checked", item.checked ? "true" : "false");
361 const check = item.checked ? createIconElement("check", "neutral") : null;
362 if (check) {
363 button.appendChild(check);
364 } else {
365 const spacer = document.createElement("span");
366 spacer.className = "size-4.5 shrink-0 sm:size-4";
367 spacer.style.cssText = "display:inline-block;width:1rem;height:1rem;flex-shrink:0;";
368 spacer.setAttribute("aria-hidden", "true");
369 button.appendChild(spacer);
370 }
371 } else if (typeof item.icon === "string") {
351372 const icon = createIconElement(item.icon, isLeafDestructive ? "destructive" : "neutral");
352373 if (icon) {
353374 button.appendChild(icon);
apps/web/src/hooks/useThreadActionMenu.ts+11
......@@ -20,6 +20,7 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast";
2020import { threadEnvironment } from "../state/threads";
2121import { useAtomCommand } from "../state/use-atom-command";
2222import {
23 readEnvironmentSupportsAutoSettleOptOut,
2324 readEnvironmentSupportsPinning,
2425 readEnvironmentSupportsSettlement,
2526 readEnvironmentSupportsSnooze,
......@@ -88,6 +89,7 @@ export function useThreadActionMenu(input: {
8889 unsnoozeThread,
8990 pinThread,
9091 confirmAndUnpinThread,
92 setThreadAutoSettle,
9193 archiveThread,
9294 deleteThread,
9395 } = useThreadActions();
......@@ -132,6 +134,7 @@ export function useThreadActionMenu(input: {
132134 const now = new Date();
133135 const supports = {
134136 settlement: readEnvironmentSupportsSettlement(threadRef.environmentId),
137 autoSettleOptOut: readEnvironmentSupportsAutoSettleOptOut(threadRef.environmentId),
135138 snooze: readEnvironmentSupportsSnooze(threadRef.environmentId),
136139 pinning: readEnvironmentSupportsPinning(threadRef.environmentId),
137140 titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId),
......@@ -145,6 +148,7 @@ export function useThreadActionMenu(input: {
145148 projectFilter: null,
146149 isPinned: thread.pinnedAt != null,
147150 isSettled: supports.settlement && thread.settledOverride === "settled",
151 autoSettleEnabled: thread.autoSettleDisabledAt == null,
148152 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
149153 canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),
150154 isRegeneratingTitle,
......@@ -225,6 +229,12 @@ export function useThreadActionMenu(input: {
225229 await reportFailure("Failed to unpin thread", () => confirmAndUnpinThread(threadRef));
226230 return;
227231 }
232 case "auto-settle:enabled":
233 case "auto-settle:disabled":
234 await reportFailure("Failed to update auto-settle", () =>
235 setThreadAutoSettle(threadRef, action === "auto-settle:enabled"),
236 );
237 return;
228238 case "rename":
229239 onStartRename();
230240 return;
......@@ -333,6 +343,7 @@ export function useThreadActionMenu(input: {
333343 projectGroupingSettings,
334344 projects,
335345 router,
346 setThreadAutoSettle,
336347 settleThread,
337348 snoozeThread,
338349 threadRef,
apps/web/src/hooks/useThreadActions.ts+39
......@@ -26,6 +26,7 @@ import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsStat
2626import { releaseComposerDraftUploads } from "../lib/composerDraftUploads";
2727import { readLocalApi } from "../localApi";
2828import {
29 readEnvironmentSupportsAutoSettleOptOut,
2930 readEnvironmentSupportsPinning,
3031 readEnvironmentSupportsPinReorder,
3132 readEnvironmentSupportsActiveReorder,
......@@ -106,6 +107,18 @@ function topOfPinnedRunOrderKey(): string | undefined {
106107 return pinOrderKeyBetween(null, firstKey) ?? undefined;
107108}
108109
110export class ThreadAutoSettleOptOutUnsupportedError extends Schema.TaggedError<ThreadAutoSettleOptOutUnsupportedError>()(
111 "ThreadAutoSettleOptOutUnsupportedError",
112 {
113 environmentId: EnvironmentId,
114 threadId: ThreadId,
115 },
116) {
117 override get message(): string {
118 return "This environment's server does not support turning auto-settle off per thread yet. Update the server to use it.";
119 }
120}
121
109122export class ThreadPinningUnsupportedError extends Schema.TaggedError<ThreadPinningUnsupportedError>()(
110123 "ThreadPinningUnsupportedError",
111124 {
......@@ -200,6 +213,9 @@ export function useThreadActions() {
200213 const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, {
201214 reportFailure: false,
202215 });
216 const setThreadAutoSettleMutation = useAtomCommand(threadEnvironment.setAutoSettle, {
217 reportFailure: false,
218 });
203219 const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, {
204220 reportFailure: false,
205221 });
......@@ -556,6 +572,27 @@ export function useThreadActions() {
556572 [unsettleThreadMutation],
557573 );
558574
575 /** Turns automatic settlement (inactivity, merged PR) on or off for one thread. */
576 const setThreadAutoSettle = useCallback(
577 async (target: ScopedThreadRef, enabled: boolean) => {
578 if (!readEnvironmentSupportsAutoSettleOptOut(target.environmentId)) {
579 return AsyncResult.failure(
580 Cause.fail(
581 new ThreadAutoSettleOptOutUnsupportedError({
582 environmentId: target.environmentId,
583 threadId: target.threadId,
584 }),
585 ),
586 );
587 }
588 return setThreadAutoSettleMutation({
589 environmentId: target.environmentId,
590 input: { threadId: target.threadId, enabled },
591 });
592 },
593 [setThreadAutoSettleMutation],
594 );
595
559596 const pinThread = useCallback(
560597 async (target: ScopedThreadRef, opts: { orderKey?: string } = {}) => {
561598 // Version skew: never send the command to a server that predates it.
......@@ -875,6 +912,7 @@ export function useThreadActions() {
875912 confirmAndUnpinThread,
876913 reorderPinnedThread,
877914 reorderActiveThread,
915 setThreadAutoSettle,
878916 }),
879917 [
880918 archiveThread,
......@@ -884,6 +922,7 @@ export function useThreadActions() {
884922 pinThread,
885923 reorderPinnedThread,
886924 reorderActiveThread,
925 setThreadAutoSettle,
887926 settleThread,
888927 snoozeThread,
889928 unarchiveThread,
apps/web/src/state/entities.ts+9
......@@ -229,6 +229,15 @@ export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId):
229229 );
230230}
231231
232/** Whether the environment's server understands thread.auto-settle.set.
233 Same version-skew contract as settlement. */
234export function readEnvironmentSupportsAutoSettleOptOut(environmentId: EnvironmentId): boolean {
235 return (
236 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
237 .threadAutoSettleOptOut === true
238 );
239}
240
232241export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean {
233242 return (
234243 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
docs/user/thread-sidebar.md+5
......@@ -99,6 +99,11 @@ prevent automatic settlement. An open pull request does not prevent inactivity
9999settlement, but an old closed or merged pull request does not settle work you
100100resumed after it closed.
101101
102To keep one thread out of the settled shelf no matter how long it sits idle, open its menu,
103choose **Auto-settle behavior**, and pick **Disabled**. The current option is checked. Pick
104**Enabled** to return to the usual rules. Manual settle, snooze, and archive still work while it
105is disabled.
106
102107Change these rules in **Settings → General** on web and desktop, or **Settings → Thread behavior** on mobile.
103108They continue to run when your apps are closed. On web and desktop, choose an environment at the
104109top to change only its rules, or **All environments** to update connected environments together.
packages/client-runtime/src/operations/commands.ts+11
......@@ -42,6 +42,7 @@ export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;
4242export type PinThreadInput = CommandInput<"thread.pin">;
4343export type UnpinThreadInput = CommandInput<"thread.unpin">;
4444export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">;
45export type SetThreadAutoSettleInput = CommandInput<"thread.auto-settle.set">;
4546export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">;
4647export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
4748export type LinkThreadPullRequestInput = CommandInput<"thread.pull-request.link">;
......@@ -226,6 +227,16 @@ export const unpinThread: (input: UnpinThreadInput) => CommandEffect = Effect.fn
226227 });
227228});
228229
230export const setThreadAutoSettle: (input: SetThreadAutoSettleInput) => CommandEffect = Effect.fn(
231 "EnvironmentCommands.setThreadAutoSettle",
232)(function* (input) {
233 return yield* dispatch({
234 ...input,
235 type: "thread.auto-settle.set",
236 commandId: yield* commandId(input),
237 });
238});
239
229240export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEffect = Effect.fn(
230241 "EnvironmentCommands.reorderPinnedThread",
231242)(function* (input) {
packages/client-runtime/src/state/threadCommands.ts+9
......@@ -29,6 +29,7 @@ import {
2929 type PinThreadInput,
3030 type ReorderPinnedThreadInput,
3131 type ReorderActiveThreadInput,
32 type SetThreadAutoSettleInput,
3233 type SettleThreadInput,
3334 type SnoozeThreadInput,
3435 type StartThreadTurnInput,
......@@ -53,6 +54,7 @@ import {
5354 pinThread,
5455 reorderPinnedThread,
5556 reorderActiveThread,
57 setThreadAutoSettle,
5658 settleThread,
5759 snoozeThread,
5860 startThreadTurn,
......@@ -81,6 +83,7 @@ export type {
8183 PinThreadInput,
8284 ReorderPinnedThreadInput,
8385 ReorderActiveThreadInput,
86 SetThreadAutoSettleInput,
8487 SettleThreadInput,
8588 SnoozeThreadInput,
8689 StartThreadTurnInput,
......@@ -170,6 +173,12 @@ export function createThreadEnvironmentAtoms<R, E>(
170173 scheduler,
171174 concurrency,
172175 }),
176 setAutoSettle: createEnvironmentCommand(runtime, {
177 label: "environment-data:commands:thread:set-auto-settle",
178 execute: (input: SetThreadAutoSettleInput) => setThreadAutoSettle(input),
179 scheduler,
180 concurrency,
181 }),
173182 reorderActive: createEnvironmentCommand(runtime, {
174183 label: "environment-data:commands:thread:reorder-active",
175184 execute: (input: ReorderActiveThreadInput) => reorderActiveThread(input),
packages/client-runtime/src/state/threadDetail.ts+1
......@@ -60,6 +60,7 @@ export function mergeEnvironmentThread(
6060 settledAt: shell.settledAt,
6161 unsettledAt: shell.unsettledAt,
6262 activeOrderKey: shell.activeOrderKey,
63 autoSettleDisabledAt: shell.autoSettleDisabledAt,
6364 snoozedUntil: shell.snoozedUntil,
6465 snoozedAt: shell.snoozedAt,
6566 pinnedAt: shell.pinnedAt,
packages/client-runtime/src/state/threadReducer.test.ts+40
......@@ -290,6 +290,46 @@ describe("applyThreadDetailEvent", () => {
290290 });
291291 });
292292
293 describe("thread.auto-settle-set", () => {
294 it("stores and clears autoSettleDisabledAt", () => {
295 const disabledAt = "2026-04-01T05:00:00.000Z";
296 const off = applyThreadDetailEvent(baseThread, {
297 ...baseEventFields,
298 sequence: 7,
299 occurredAt: disabledAt,
300 aggregateKind: "thread",
301 aggregateId: ThreadId.make("thread-1"),
302 type: "thread.auto-settle-set",
303 payload: {
304 threadId: ThreadId.make("thread-1"),
305 autoSettleDisabledAt: disabledAt,
306 updatedAt: disabledAt,
307 },
308 });
309 expect(off.kind).toBe("updated");
310 if (off.kind !== "updated") return;
311 expect(off.thread.autoSettleDisabledAt).toBe(disabledAt);
312
313 const on = applyThreadDetailEvent(off.thread, {
314 ...baseEventFields,
315 sequence: 8,
316 occurredAt: "2026-04-01T06:00:00.000Z",
317 aggregateKind: "thread",
318 aggregateId: ThreadId.make("thread-1"),
319 type: "thread.auto-settle-set",
320 payload: {
321 threadId: ThreadId.make("thread-1"),
322 autoSettleDisabledAt: null,
323 updatedAt: "2026-04-01T06:00:00.000Z",
324 },
325 });
326 expect(on.kind).toBe("updated");
327 if (on.kind === "updated") {
328 expect(on.thread.autoSettleDisabledAt).toBeNull();
329 }
330 });
331 });
332
293333 describe("thread.meta-updated", () => {
294334 it.each(["f", null] as const)(
295335 "updates the active key to %s without activity",
packages/client-runtime/src/state/threadReducer.ts+11
......@@ -131,6 +131,7 @@ export function applyThreadDetailEvent(
131131 settledAt: null,
132132 unsettledAt: null,
133133 activeOrderKey: null,
134 autoSettleDisabledAt: null,
134135 snoozedUntil: null,
135136 snoozedAt: null,
136137 deletedAt: null,
......@@ -249,6 +250,16 @@ export function applyThreadDetailEvent(
249250 },
250251 };
251252
253 case "thread.auto-settle-set":
254 return {
255 kind: "updated",
256 thread: {
257 ...thread,
258 autoSettleDisabledAt: event.payload.autoSettleDisabledAt,
259 updatedAt: event.payload.updatedAt,
260 },
261 };
262
252263 // ── Thread metadata ─────────────────────────────────────────────
253264 case "thread.meta-updated":
254265 return {
packages/contracts/src/environment.ts+3
......@@ -141,6 +141,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
141141 threadPinReorder: Schema.optionalKey(Schema.Boolean),
142142 /** Server persists manual Active order through thread.active.reorder. */
143143 threadActiveReorder: Schema.optionalKey(Schema.Boolean),
144 /** Server understands thread.auto-settle.set (per-thread auto-settle off).
145 Same version-skew contract as threadSettlement. */
146 threadAutoSettleOptOut: Schema.optionalKey(Schema.Boolean),
144147 /** Server understands regenerateTitle on thread.meta.update. Absent on
145148 older servers, so clients hide the action instead of sending it. */
146149 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/ipc.ts+4
......@@ -40,6 +40,8 @@ export interface ContextMenuItem<T extends string = string> {
4040 icon?: string;
4141 /** Inserts a visual section divider immediately before this item. */
4242 separatorBefore?: boolean;
43 /** Shows a check mark. Used to mark the current option inside a submenu. */
44 checked?: boolean;
4345 children?: readonly ContextMenuItem<T>[];
4446}
4547
......@@ -55,6 +57,7 @@ export interface ContextMenuItemSchemaType {
5557 readonly header?: boolean;
5658 readonly icon?: string;
5759 readonly separatorBefore?: boolean;
60 readonly checked?: boolean;
5861 readonly children?: readonly ContextMenuItemSchemaType[];
5962}
6063
......@@ -66,6 +69,7 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc
6669 header: Schema.optionalKey(Schema.Boolean),
6770 icon: Schema.optionalKey(Schema.String),
6871 separatorBefore: Schema.optionalKey(Schema.Boolean),
72 checked: Schema.optionalKey(Schema.Boolean),
6973 children: Schema.optionalKey(
7074 Schema.Array(
7175 Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema),
packages/contracts/src/orchestration.ts+28
......@@ -838,6 +838,10 @@ export const OrchestrationThread = Schema.Struct({
838838 // Manual Active placement. Keyless threads retain their creation/re-entry
839839 // order above the arranged run. Settling clears this slot.
840840 activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
841 // Set while the user has turned automatic settlement off for this thread.
842 // Survives manual settle, un-settle, and activity: only the user clears it.
843 // Optional so payloads from older servers still decode.
844 autoSettleDisabledAt: Schema.optional(Schema.NullOr(IsoDateTime)),
841845 // Pending-only state. Optional so older servers remain compatible.
842846 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
843847 titleState: Schema.optional(Schema.NullOr(ThreadTitleState)),
......@@ -908,6 +912,7 @@ export const OrchestrationThreadShell = Schema.Struct({
908912 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
909913 pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
910914 activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
915 autoSettleDisabledAt: Schema.optional(Schema.NullOr(IsoDateTime)),
911916 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
912917 titleState: Schema.optional(Schema.NullOr(ThreadTitleState)),
913918 session: Schema.NullOr(OrchestrationSession),
......@@ -1218,6 +1223,14 @@ const ThreadPinReorderCommand = Schema.Struct({
12181223 orderKey: TrimmedNonEmptyString,
12191224});
12201225
1226const ThreadAutoSettleSetCommand = Schema.Struct({
1227 type: Schema.Literal("thread.auto-settle.set"),
1228 commandId: CommandId,
1229 threadId: ThreadId,
1230 // false turns automatic settlement off for this thread, true turns it back on.
1231 enabled: Schema.Boolean,
1232});
1233
12211234const ThreadActiveReorderCommand = Schema.Struct({
12221235 type: Schema.Literal("thread.active.reorder"),
12231236 commandId: CommandId,
......@@ -1426,6 +1439,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
14261439 ThreadPinCommand,
14271440 ThreadUnpinCommand,
14281441 ThreadPinReorderCommand,
1442 ThreadAutoSettleSetCommand,
14291443 ThreadActiveReorderCommand,
14301444 ThreadMetaUpdateCommand,
14311445 ThreadPullRequestLinkCommand,
......@@ -1459,6 +1473,7 @@ export const ClientOrchestrationCommand = Schema.Union([
14591473 ThreadPinCommand,
14601474 ThreadUnpinCommand,
14611475 ThreadPinReorderCommand,
1476 ThreadAutoSettleSetCommand,
14621477 ThreadActiveReorderCommand,
14631478 ThreadMetaUpdateCommand,
14641479 ThreadPullRequestLinkCommand,
......@@ -1687,6 +1702,7 @@ export const OrchestrationEventType = Schema.Literals([
16871702 "thread.pinned",
16881703 "thread.unpinned",
16891704 "thread.pin-reordered",
1705 "thread.auto-settle-set",
16901706 "thread.meta-updated",
16911707 "thread.pull-request-linked",
16921708 "thread.pull-request-unlinked",
......@@ -1825,6 +1841,13 @@ export const ThreadPinReorderedPayload = Schema.Struct({
18251841 updatedAt: IsoDateTime,
18261842});
18271843
1844export const ThreadAutoSettleSetPayload = Schema.Struct({
1845 threadId: ThreadId,
1846 // Null re-enables automatic settlement.
1847 autoSettleDisabledAt: Schema.NullOr(IsoDateTime),
1848 updatedAt: IsoDateTime,
1849});
1850
18281851export const ThreadMetaUpdatedPayload = Schema.Struct({
18291852 threadId: ThreadId,
18301853 // Order updates use this existing event so older clients can ignore the
......@@ -2089,6 +2112,11 @@ export const OrchestrationEvent = Schema.Union([
20892112 type: Schema.Literal("thread.pin-reordered"),
20902113 payload: ThreadPinReorderedPayload,
20912114 }),
2115 Schema.Struct({
2116 ...EventBaseFields,
2117 type: Schema.Literal("thread.auto-settle-set"),
2118 payload: ThreadAutoSettleSetPayload,
2119 }),
20922120 Schema.Struct({
20932121 ...EventBaseFields,
20942122 type: Schema.Literal("thread.meta-updated"),