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...@@ -80,6 +80,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
80 destructive: sourceItem.destructive === true,80 destructive: sourceItem.destructive === true,
81 disabled: sourceItem.disabled === true,81 disabled: sourceItem.disabled === true,
82 ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}),82 ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}),
83 ...(typeof sourceItem.checked === "boolean" ? { checked: sourceItem.checked } : {}),
83 };84 };
8485
85 if (sourceItem.children) {86 if (sourceItem.children) {
...@@ -168,6 +169,7 @@ export const make = Effect.gen(function* () {...@@ -168,6 +169,7 @@ export const make = Effect.gen(function* () {
168 const itemOption: Electron.MenuItemConstructorOptions = {169 const itemOption: Electron.MenuItemConstructorOptions = {
169 label: item.label,170 label: item.label,
170 enabled: !item.disabled,171 enabled: !item.disabled,
172 ...(typeof item.checked === "boolean" ? { type: "checkbox", checked: item.checked } : {}),
171 };173 };
172 if (item.children && item.children.length > 0) {174 if (item.children && item.children.length > 0) {
173 itemOption.submenu = buildTemplate(item.children, complete);175 itemOption.submenu = buildTemplate(item.children, complete);
apps/mobile/src/components/AppSymbol.tsx+1
...@@ -140,6 +140,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {...@@ -140,6 +140,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
140 "checkmark.circle": IconCircleCheck,140 "checkmark.circle": IconCircleCheck,
141 circle: IconCircle,141 circle: IconCircle,
142 clock: IconClock,142 clock: IconClock,
143 timer: IconClock,
143 ticket: IconTicket,144 ticket: IconTicket,
144 cloud: IconCloud,145 cloud: IconCloud,
145 cube: IconBox,146 cube: IconBox,
apps/mobile/src/features/home/HomeRouteScreen.tsx+2
...@@ -49,6 +49,7 @@ export function HomeRouteScreen() {...@@ -49,6 +49,7 @@ export function HomeRouteScreen() {
49 unsnoozeThread,49 unsnoozeThread,
50 pinThread,50 pinThread,
51 unpinThread,51 unpinThread,
52 setThreadAutoSettle,
52 moveThread,53 moveThread,
53 renameThread,54 renameThread,
54 regenerateThreadTitle,55 regenerateThreadTitle,
...@@ -206,6 +207,7 @@ export function HomeRouteScreen() {...@@ -206,6 +207,7 @@ export function HomeRouteScreen() {
206 onUnsettleThread={unsettleThread}207 onUnsettleThread={unsettleThread}
207 onPinThread={pinThread}208 onPinThread={pinThread}
208 onUnpinThread={unpinThread}209 onUnpinThread={unpinThread}
210 onSetThreadAutoSettle={setThreadAutoSettle}
209 onMoveThread={moveThread}211 onMoveThread={moveThread}
210 onRenameThread={renameThread}212 onRenameThread={renameThread}
211 onRegenerateThreadTitle={regenerateThreadTitle}213 onRegenerateThreadTitle={regenerateThreadTitle}
apps/mobile/src/features/home/HomeScreen.tsx+23
...@@ -96,6 +96,10 @@ interface HomeScreenProps {...@@ -96,6 +96,10 @@ interface HomeScreenProps {
96 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;96 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
97 readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;97 readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
98 readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;98 readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
99 readonly onSetThreadAutoSettle: (
100 thread: EnvironmentThreadShell,
101 enabled: boolean,
102 ) => Promise<boolean>;
99 readonly onMoveThread: (103 readonly onMoveThread: (
100 thread: EnvironmentThreadShell,104 thread: EnvironmentThreadShell,
101 direction: ThreadMoveDestination,105 direction: ThreadMoveDestination,
...@@ -378,6 +382,12 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -378,6 +382,12 @@ export function HomeScreen(props: HomeScreenProps) {
378 },382 },
379 [props.onUnpinThread],383 [props.onUnpinThread],
380 );384 );
385 const handleSetThreadAutoSettle = useCallback(
386 (thread: EnvironmentThreadShell, enabled: boolean) => {
387 void props.onSetThreadAutoSettle(thread, enabled);
388 },
389 [props.onSetThreadAutoSettle],
390 );
381 const handleRegenerateThreadTitle = useCallback(391 const handleRegenerateThreadTitle = useCallback(
382 (thread: EnvironmentThreadShell) => {392 (thread: EnvironmentThreadShell) => {
383 void props.onRegenerateThreadTitle(thread);393 void props.onRegenerateThreadTitle(thread);
...@@ -456,6 +466,15 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -456,6 +466,15 @@ export function HomeScreen(props: HomeScreenProps) {
456 }466 }
457 return supported;467 return supported;
458 }, [serverConfigs]);468 }, [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]);
459 const pinReorderEnvironmentIds = useMemo(() => {478 const pinReorderEnvironmentIds = useMemo(() => {
460 const supported = new Set<EnvironmentId>();479 const supported = new Set<EnvironmentId>();
461 for (const [environmentId, config] of serverConfigs) {480 for (const [environmentId, config] of serverConfigs) {
...@@ -726,6 +745,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -726,6 +745,7 @@ export function HomeScreen(props: HomeScreenProps) {
726 onSettleThread={handleSettleThread}745 onSettleThread={handleSettleThread}
727 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}746 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
728 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}747 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
748 autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)}
729 reorderSupported={749 reorderSupported={
730 item.item.pinned750 item.item.pinned
731 ? pinReorderEnvironmentIds.has(thread.environmentId)751 ? pinReorderEnvironmentIds.has(thread.environmentId)
...@@ -738,6 +758,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -738,6 +758,7 @@ export function HomeScreen(props: HomeScreenProps) {
738 onUnsettleThread={handleUnsettleThread}758 onUnsettleThread={handleUnsettleThread}
739 onPinThread={handlePinThread}759 onPinThread={handlePinThread}
740 onUnpinThread={handleUnpinThread}760 onUnpinThread={handleUnpinThread}
761 onSetThreadAutoSettle={handleSetThreadAutoSettle}
741 onMoveThread={handleMoveThread}762 onMoveThread={handleMoveThread}
742 onSwipeableClose={handleSwipeableClose}763 onSwipeableClose={handleSwipeableClose}
743 onSwipeableWillOpen={handleSwipeableWillOpen}764 onSwipeableWillOpen={handleSwipeableWillOpen}
...@@ -758,6 +779,8 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -758,6 +779,8 @@ export function HomeScreen(props: HomeScreenProps) {
758 handleSwipeableClose,779 handleSwipeableClose,
759 handleSwipeableWillOpen,780 handleSwipeableWillOpen,
760 handleUnsettleThread,781 handleUnsettleThread,
782 handleSetThreadAutoSettle,
783 autoSettleOptOutEnvironmentIds,
761 pinningEnvironmentIds,784 pinningEnvironmentIds,
762 machineByEnvironmentId,785 machineByEnvironmentId,
763 pinReorderEnvironmentIds,786 pinReorderEnvironmentIds,
apps/mobile/src/features/home/useThreadListActions.ts+46
...@@ -59,6 +59,15 @@ function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["en...@@ -59,6 +59,15 @@ function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["en
59 );59 );
60}60}
6161
62function environmentSupportsAutoSettleOptOut(
63 environmentId: EnvironmentThreadShell["environmentId"],
64) {
65 return (
66 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
67 .threadAutoSettleOptOut === true
68 );
69}
70
62function environmentSupportsTitleRegeneration(71function environmentSupportsTitleRegeneration(
63 environmentId: EnvironmentThreadShell["environmentId"],72 environmentId: EnvironmentThreadShell["environmentId"],
64) {73) {
...@@ -237,6 +246,11 @@ export function useThreadListActions(): {...@@ -237,6 +246,11 @@ export function useThreadListActions(): {
237 readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;246 readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
238 readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;247 readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
239 readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;248 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>;
240 readonly moveThread: (254 readonly moveThread: (
241 thread: EnvironmentThreadShell,255 thread: EnvironmentThreadShell,
242 direction: ThreadMoveDestination,256 direction: ThreadMoveDestination,
...@@ -249,6 +263,9 @@ export function useThreadListActions(): {...@@ -249,6 +263,9 @@ export function useThreadListActions(): {
249 const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false });263 const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false });
250 const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false });264 const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false });
251 const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false });265 const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false });
266 const setAutoSettleMutation = useAtomCommand(threadEnvironment.setAutoSettle, {
267 reportFailure: false,
268 });
252 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {269 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
253 reportFailure: false,270 reportFailure: false,
254 });271 });
...@@ -435,6 +452,34 @@ export function useThreadListActions(): {...@@ -435,6 +452,34 @@ export function useThreadListActions(): {
435 },452 },
436 [unpinMutation],453 [unpinMutation],
437 );454 );
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 );
438 const regenerateThreadTitle = useCallback(483 const regenerateThreadTitle = useCallback(
439 async (thread: EnvironmentThreadShell) => {484 async (thread: EnvironmentThreadShell) => {
440 const key = scopedThreadKey(thread.environmentId, thread.id);485 const key = scopedThreadKey(thread.environmentId, thread.id);
...@@ -698,6 +743,7 @@ export function useThreadListActions(): {...@@ -698,6 +743,7 @@ export function useThreadListActions(): {
698 unsettleThread,743 unsettleThread,
699 pinThread,744 pinThread,
700 unpinThread,745 unpinThread,
746 setThreadAutoSettle,
701 moveThread,747 moveThread,
702 renameThread,748 renameThread,
703 regenerateThreadTitle,749 regenerateThreadTitle,
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+14
...@@ -150,6 +150,7 @@ function ThreadNavigationSidebarPane(...@@ -150,6 +150,7 @@ function ThreadNavigationSidebarPane(
150 unsettleThread,150 unsettleThread,
151 pinThread,151 pinThread,
152 unpinThread,152 unpinThread,
153 setThreadAutoSettle,
153 moveThread,154 moveThread,
154 renameThread,155 renameThread,
155 regenerateThreadTitle,156 regenerateThreadTitle,
...@@ -334,6 +335,15 @@ function ThreadNavigationSidebarPane(...@@ -334,6 +335,15 @@ function ThreadNavigationSidebarPane(
334 }335 }
335 return supported;336 return supported;
336 }, [serverConfigs]);337 }, [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]);
337 const pinReorderEnvironmentIds = useMemo(() => {347 const pinReorderEnvironmentIds = useMemo(() => {
338 const supported = new Set<EnvironmentId>();348 const supported = new Set<EnvironmentId>();
339 for (const [environmentId, config] of serverConfigs) {349 for (const [environmentId, config] of serverConfigs) {
...@@ -766,6 +776,7 @@ function ThreadNavigationSidebarPane(...@@ -766,6 +776,7 @@ function ThreadNavigationSidebarPane(
766 onSettleThread={settleThread}776 onSettleThread={settleThread}
767 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}777 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
768 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}778 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
779 autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)}
769 reorderSupported={780 reorderSupported={
770 item.item.pinned781 item.item.pinned
771 ? pinReorderEnvironmentIds.has(thread.environmentId)782 ? pinReorderEnvironmentIds.has(thread.environmentId)
...@@ -778,6 +789,7 @@ function ThreadNavigationSidebarPane(...@@ -778,6 +789,7 @@ function ThreadNavigationSidebarPane(
778 onUnsettleThread={unsettleThread}789 onUnsettleThread={unsettleThread}
779 onPinThread={pinThread}790 onPinThread={pinThread}
780 onUnpinThread={unpinThread}791 onUnpinThread={unpinThread}
792 onSetThreadAutoSettle={setThreadAutoSettle}
781 onMoveThread={moveThread}793 onMoveThread={moveThread}
782 onSwipeableClose={handleSwipeableClose}794 onSwipeableClose={handleSwipeableClose}
783 onSwipeableWillOpen={handleSwipeableWillOpen}795 onSwipeableWillOpen={handleSwipeableWillOpen}
...@@ -829,6 +841,8 @@ function ThreadNavigationSidebarPane(...@@ -829,6 +841,8 @@ function ThreadNavigationSidebarPane(
829 pinReorderEnvironmentIds,841 pinReorderEnvironmentIds,
830 pinThread,842 pinThread,
831 pinningEnvironmentIds,843 pinningEnvironmentIds,
844 autoSettleOptOutEnvironmentIds,
845 setThreadAutoSettle,
832 projectByKey,846 projectByKey,
833 projectTitleByProjectKey,847 projectTitleByProjectKey,
834 regenerateThreadTitle,848 regenerateThreadTitle,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+53-5
...@@ -493,6 +493,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -493,6 +493,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
493 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;493 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
494 readonly onPinThread: (thread: EnvironmentThreadShell) => void;494 readonly onPinThread: (thread: EnvironmentThreadShell) => void;
495 readonly onUnpinThread: (thread: EnvironmentThreadShell) => void;495 readonly onUnpinThread: (thread: EnvironmentThreadShell) => void;
496 readonly onSetThreadAutoSettle: (thread: EnvironmentThreadShell, enabled: boolean) => void;
496 /** False on environments whose server predates thread.settle/unsettle:497 /** False on environments whose server predates thread.settle/unsettle:
497 swipe + menu fall back to Archive instead of failing on use. */498 swipe + menu fall back to Archive instead of failing on use. */
498 readonly settlementSupported: boolean;499 readonly settlementSupported: boolean;
...@@ -500,6 +501,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -500,6 +501,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
500 readonly snoozeSupported: boolean;501 readonly snoozeSupported: boolean;
501 /** False on servers that predate thread.pin/unpin. */502 /** False on servers that predate thread.pin/unpin. */
502 readonly pinningSupported: boolean;503 readonly pinningSupported: boolean;
504 /** False on servers that predate thread.auto-settle.set. */
505 readonly autoSettleOptOutSupported: boolean;
503 /** False on servers that predate thread title regeneration. */506 /** False on servers that predate thread title regeneration. */
504 readonly titleRegenerationSupported: boolean;507 readonly titleRegenerationSupported: boolean;
505 /** Server supports reordering this card's section. */508 /** Server supports reordering this card's section. */
...@@ -536,6 +539,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -536,6 +539,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
536 onArchiveThread,539 onArchiveThread,
537 onPinThread,540 onPinThread,
538 onUnpinThread,541 onUnpinThread,
542 onSetThreadAutoSettle,
539 onMoveThread,543 onMoveThread,
540 } = props;544 } = props;
541 const snoozedRow = props.snoozed === true;545 const snoozedRow = props.snoozed === true;
...@@ -583,6 +587,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -583,6 +587,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
583 const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);587 const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);
584 const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]);588 const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]);
585 const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]);589 const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]);
590 const handleSetAutoSettle = useCallback(
591 (enabled: boolean) => onSetThreadAutoSettle(thread, enabled),
592 [onSetThreadAutoSettle, thread],
593 );
586 const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]);594 const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]);
587 const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]);595 const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]);
588 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);596 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
...@@ -661,6 +669,33 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -661,6 +669,33 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
661 variant,669 variant,
662 ],670 ],
663 );671 );
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 );
664 const titleMenuItems = useMemo<MenuAction[]>(699 const titleMenuItems = useMemo<MenuAction[]>(
665 () => [700 () => [
666 { id: "rename", title: "Rename", image: "square.and.pencil" },701 { id: "rename", title: "Rename", image: "square.and.pencil" },
...@@ -682,19 +717,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -682,19 +717,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
682 },717 },
683 ...arrangementMenuItems,718 ...arrangementMenuItems,
684 ...titleMenuItems,719 ...titleMenuItems,
720 ...autoSettleMenuItems,
685 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },721 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
686 ],722 ],
687 [arrangementMenuItems, snoozePresetActions, titleMenuItems],723 [arrangementMenuItems, autoSettleMenuItems, snoozePresetActions, titleMenuItems],
688 );724 );
689 const cardMenuActions = useMemo<MenuAction[]>(725 const cardMenuActions = useMemo<MenuAction[]>(
690 () => [726 () => [
691 CARD_MENU_ACTIONS[0]!,727 CARD_MENU_ACTIONS[0]!,
692 ...arrangementMenuItems,728 ...arrangementMenuItems,
693 ...titleMenuItems,729 ...titleMenuItems,
730 ...autoSettleMenuItems,
694 ...CARD_MENU_ACTIONS.slice(1),731 ...CARD_MENU_ACTIONS.slice(1),
695 ],732 ],
696 [arrangementMenuItems, titleMenuItems],733 [arrangementMenuItems, autoSettleMenuItems, titleMenuItems],
697 );734 );
735 // Settled and snoozed rows keep the setting too, matching web where every
736 // row shares one menu builder.
698 const slimMenuActions = useMemo<MenuAction[]>(737 const slimMenuActions = useMemo<MenuAction[]>(
699 () => [738 () => [
700 SLIM_MENU_ACTIONS[0]!,739 SLIM_MENU_ACTIONS[0]!,
...@@ -702,13 +741,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -702,13 +741,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
702 (action) => action.id !== "move-up" && action.id !== "move-down",741 (action) => action.id !== "move-up" && action.id !== "move-down",
703 ),742 ),
704 ...titleMenuItems,743 ...titleMenuItems,
744 ...autoSettleMenuItems,
705 SLIM_MENU_ACTIONS[1]!,745 SLIM_MENU_ACTIONS[1]!,
706 ],746 ],
707 [arrangementMenuItems, titleMenuItems],747 [arrangementMenuItems, autoSettleMenuItems, titleMenuItems],
708 );748 );
709 const snoozedMenuActions = useMemo<MenuAction[]>(749 const snoozedMenuActions = useMemo<MenuAction[]>(
710 () => [SNOOZED_MENU_ACTIONS[0]!, ...titleMenuItems, SNOOZED_MENU_ACTIONS[1]!],750 () => [
711 [titleMenuItems],751 SNOOZED_MENU_ACTIONS[0]!,
752 ...titleMenuItems,
753 ...autoSettleMenuItems,
754 SNOOZED_MENU_ACTIONS[1]!,
755 ],
756 [autoSettleMenuItems, titleMenuItems],
712 );757 );
713 const legacyMenuActions = useMemo<MenuAction[]>(758 const legacyMenuActions = useMemo<MenuAction[]>(
714 () => [759 () => [
...@@ -727,6 +772,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -727,6 +772,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
727 if (nativeEvent.event === "unsnooze") handleUnsnooze();772 if (nativeEvent.event === "unsnooze") handleUnsnooze();
728 if (nativeEvent.event === "pin") handlePin();773 if (nativeEvent.event === "pin") handlePin();
729 if (nativeEvent.event === "unpin") handleUnpin();774 if (nativeEvent.event === "unpin") handleUnpin();
775 if (nativeEvent.event === "auto-settle:enabled") handleSetAutoSettle(true);
776 if (nativeEvent.event === "auto-settle:disabled") handleSetAutoSettle(false);
730 if (nativeEvent.event === "arrange") appAtomRegistry.set(threadArrangementOpenAtom, true);777 if (nativeEvent.event === "arrange") appAtomRegistry.set(threadArrangementOpenAtom, true);
731 if (nativeEvent.event === "move-up") handleMoveUp();778 if (nativeEvent.event === "move-up") handleMoveUp();
732 if (nativeEvent.event === "move-down") handleMoveDown();779 if (nativeEvent.event === "move-down") handleMoveDown();
...@@ -764,6 +811,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -764,6 +811,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
764 handlePin,811 handlePin,
765 handleSettle,812 handleSettle,
766 handleSnooze,813 handleSnooze,
814 handleSetAutoSettle,
767 handleUnpin,815 handleUnpin,
768 handleUnsettle,816 handleUnsettle,
769 handleUnsnooze,817 handleUnsnooze,
apps/mobile/src/state/use-thread-selection.ts+1
...@@ -74,6 +74,7 @@ function threadDetailToShell(...@@ -74,6 +74,7 @@ function threadDetailToShell(
74 settledAt: thread.settledAt,74 settledAt: thread.settledAt,
75 unsettledAt: thread.unsettledAt,75 unsettledAt: thread.unsettledAt,
76 activeOrderKey: thread.activeOrderKey,76 activeOrderKey: thread.activeOrderKey,
77 autoSettleDisabledAt: thread.autoSettleDisabledAt,
77 pinnedAt: thread.pinnedAt,78 pinnedAt: thread.pinnedAt,
78 pinOrderKey: thread.pinOrderKey,79 pinOrderKey: thread.pinOrderKey,
79 snoozedUntil: thread.snoozedUntil ?? null,80 snoozedUntil: thread.snoozedUntil ?? null,
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -236,6 +236,7 @@ export const make = Effect.gen(function* () {...@@ -236,6 +236,7 @@ export const make = Effect.gen(function* () {
236 threadPinning: true,236 threadPinning: true,
237 threadPinReorder: true,237 threadPinReorder: true,
238 threadActiveReorder: true,238 threadActiveReorder: true,
239 threadAutoSettleOptOut: true,
239 threadTitleRegeneration: true,240 threadTitleRegeneration: true,
240 threadPullRequests: true,241 threadPullRequests: true,
241 pullRequestStackActions: true,242 pullRequestStackActions: true,
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+16
...@@ -633,6 +633,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -633,6 +633,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
633 pinnedAt: null,633 pinnedAt: null,
634 pinOrderKey: null,634 pinOrderKey: null,
635 activeOrderKey: null,635 activeOrderKey: null,
636 autoSettleDisabledAt: null,
636 titleRegenerationRequestId: null,637 titleRegenerationRequestId: null,
637 titleRegenerationStartedAt: null,638 titleRegenerationStartedAt: null,
638 latestUserMessageAt: null,639 latestUserMessageAt: null,
...@@ -782,6 +783,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -782,6 +783,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
782 return;783 return;
783 }784 }
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
785 case "thread.pin-reordered": {801 case "thread.pin-reordered": {
786 const existingRow = yield* projectionThreadRepository.getById({802 const existingRow = yield* projectionThreadRepository.getById({
787 threadId: event.payload.threadId,803 threadId: event.payload.threadId,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2
...@@ -485,6 +485,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -485,6 +485,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
485 pinnedAt: "2026-02-24T00:00:01.000Z",485 pinnedAt: "2026-02-24T00:00:01.000Z",
486 pinOrderKey: "gm",486 pinOrderKey: "gm",
487 activeOrderKey: "hq",487 activeOrderKey: "hq",
488 autoSettleDisabledAt: null,
488 titleRegeneration: null,489 titleRegeneration: null,
489 titleState: null,490 titleState: null,
490 deletedAt: null,491 deletedAt: null,
...@@ -611,6 +612,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -611,6 +612,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
611 pinnedAt: "2026-02-24T00:00:01.000Z",612 pinnedAt: "2026-02-24T00:00:01.000Z",
612 pinOrderKey: "gm",613 pinOrderKey: "gm",
613 activeOrderKey: "hq",614 activeOrderKey: "hq",
615 autoSettleDisabledAt: null,
614 titleRegeneration: null,616 titleRegeneration: null,
615 titleState: null,617 titleState: null,
616 session: {618 session: {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+10
...@@ -587,6 +587,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -587,6 +587,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
587 pinned_at AS "pinnedAt",587 pinned_at AS "pinnedAt",
588 pin_order_key AS "pinOrderKey",588 pin_order_key AS "pinOrderKey",
589 active_order_key AS "activeOrderKey",589 active_order_key AS "activeOrderKey",
590 auto_settle_disabled_at AS "autoSettleDisabledAt",
590 title_regeneration_request_id AS "titleRegenerationRequestId",591 title_regeneration_request_id AS "titleRegenerationRequestId",
591 title_regeneration_started_at AS "titleRegenerationStartedAt",592 title_regeneration_started_at AS "titleRegenerationStartedAt",
592 latest_user_message_at AS "latestUserMessageAt",593 latest_user_message_at AS "latestUserMessageAt",
...@@ -628,6 +629,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -628,6 +629,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
628 pinned_at AS "pinnedAt",629 pinned_at AS "pinnedAt",
629 pin_order_key AS "pinOrderKey",630 pin_order_key AS "pinOrderKey",
630 active_order_key AS "activeOrderKey",631 active_order_key AS "activeOrderKey",
632 auto_settle_disabled_at AS "autoSettleDisabledAt",
631 title_regeneration_request_id AS "titleRegenerationRequestId",633 title_regeneration_request_id AS "titleRegenerationRequestId",
632 title_regeneration_started_at AS "titleRegenerationStartedAt",634 title_regeneration_started_at AS "titleRegenerationStartedAt",
633 latest_user_message_at AS "latestUserMessageAt",635 latest_user_message_at AS "latestUserMessageAt",
...@@ -701,6 +703,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -701,6 +703,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
701 pinned_at AS "pinnedAt",703 pinned_at AS "pinnedAt",
702 pin_order_key AS "pinOrderKey",704 pin_order_key AS "pinOrderKey",
703 active_order_key AS "activeOrderKey",705 active_order_key AS "activeOrderKey",
706 auto_settle_disabled_at AS "autoSettleDisabledAt",
704 title_regeneration_request_id AS "titleRegenerationRequestId",707 title_regeneration_request_id AS "titleRegenerationRequestId",
705 title_regeneration_started_at AS "titleRegenerationStartedAt",708 title_regeneration_started_at AS "titleRegenerationStartedAt",
706 latest_user_message_at AS "latestUserMessageAt",709 latest_user_message_at AS "latestUserMessageAt",
...@@ -1266,6 +1269,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1266,6 +1269,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1266 pinned_at AS "pinnedAt",1269 pinned_at AS "pinnedAt",
1267 pin_order_key AS "pinOrderKey",1270 pin_order_key AS "pinOrderKey",
1268 active_order_key AS "activeOrderKey",1271 active_order_key AS "activeOrderKey",
1272 auto_settle_disabled_at AS "autoSettleDisabledAt",
1269 title_regeneration_request_id AS "titleRegenerationRequestId",1273 title_regeneration_request_id AS "titleRegenerationRequestId",
1270 title_regeneration_started_at AS "titleRegenerationStartedAt",1274 title_regeneration_started_at AS "titleRegenerationStartedAt",
1271 latest_user_message_at AS "latestUserMessageAt",1275 latest_user_message_at AS "latestUserMessageAt",
...@@ -2341,6 +2345,7 @@ pending_approval_requests AS (...@@ -2341,6 +2345,7 @@ pending_approval_requests AS (
2341 pinnedAt: row.pinnedAt,2345 pinnedAt: row.pinnedAt,
2342 pinOrderKey: row.pinOrderKey ?? null,2346 pinOrderKey: row.pinOrderKey ?? null,
2343 activeOrderKey: row.activeOrderKey ?? null,2347 activeOrderKey: row.activeOrderKey ?? null,
2348 autoSettleDisabledAt: row.autoSettleDisabledAt ?? null,
2344 titleRegeneration: mapTitleRegeneration(row),2349 titleRegeneration: mapTitleRegeneration(row),
2345 titleState: row.titleState,2350 titleState: row.titleState,
2346 deletedAt: row.deletedAt,2351 deletedAt: row.deletedAt,
...@@ -2586,6 +2591,7 @@ pending_approval_requests AS (...@@ -2586,6 +2591,7 @@ pending_approval_requests AS (
2586 pinnedAt: row.pinnedAt,2591 pinnedAt: row.pinnedAt,
2587 pinOrderKey: row.pinOrderKey ?? null,2592 pinOrderKey: row.pinOrderKey ?? null,
2588 activeOrderKey: row.activeOrderKey ?? null,2593 activeOrderKey: row.activeOrderKey ?? null,
2594 autoSettleDisabledAt: row.autoSettleDisabledAt ?? null,
2589 titleRegeneration: mapTitleRegeneration(row),2595 titleRegeneration: mapTitleRegeneration(row),
2590 titleState: row.titleState,2596 titleState: row.titleState,
2591 deletedAt: row.deletedAt,2597 deletedAt: row.deletedAt,
...@@ -2742,6 +2748,7 @@ pending_approval_requests AS (...@@ -2742,6 +2748,7 @@ pending_approval_requests AS (
2742 pinnedAt: row.pinnedAt,2748 pinnedAt: row.pinnedAt,
2743 pinOrderKey: row.pinOrderKey ?? null,2749 pinOrderKey: row.pinOrderKey ?? null,
2744 activeOrderKey: row.activeOrderKey ?? null,2750 activeOrderKey: row.activeOrderKey ?? null,
2751 autoSettleDisabledAt: row.autoSettleDisabledAt ?? null,
2745 titleRegeneration: mapTitleRegeneration(row),2752 titleRegeneration: mapTitleRegeneration(row),
2746 titleState: row.titleState,2753 titleState: row.titleState,
2747 session: sessionByThread.get(row.threadId) ?? null,2754 session: sessionByThread.get(row.threadId) ?? null,
...@@ -2905,6 +2912,7 @@ pending_approval_requests AS (...@@ -2905,6 +2912,7 @@ pending_approval_requests AS (
2905 pinnedAt: row.pinnedAt,2912 pinnedAt: row.pinnedAt,
2906 pinOrderKey: row.pinOrderKey ?? null,2913 pinOrderKey: row.pinOrderKey ?? null,
2907 activeOrderKey: row.activeOrderKey ?? null,2914 activeOrderKey: row.activeOrderKey ?? null,
2915 autoSettleDisabledAt: row.autoSettleDisabledAt ?? null,
2908 titleRegeneration: mapTitleRegeneration(row),2916 titleRegeneration: mapTitleRegeneration(row),
2909 titleState: row.titleState,2917 titleState: row.titleState,
2910 session: sessionByThread.get(row.threadId) ?? null,2918 session: sessionByThread.get(row.threadId) ?? null,
...@@ -3261,6 +3269,7 @@ pending_approval_requests AS (...@@ -3261,6 +3269,7 @@ pending_approval_requests AS (
3261 pinnedAt: threadRow.value.pinnedAt,3269 pinnedAt: threadRow.value.pinnedAt,
3262 pinOrderKey: threadRow.value.pinOrderKey ?? null,3270 pinOrderKey: threadRow.value.pinOrderKey ?? null,
3263 activeOrderKey: threadRow.value.activeOrderKey ?? null,3271 activeOrderKey: threadRow.value.activeOrderKey ?? null,
3272 autoSettleDisabledAt: threadRow.value.autoSettleDisabledAt ?? null,
3264 titleRegeneration: mapTitleRegeneration(threadRow.value),3273 titleRegeneration: mapTitleRegeneration(threadRow.value),
3265 titleState: threadRow.value.titleState,3274 titleState: threadRow.value.titleState,
3266 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,3275 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,
...@@ -3562,6 +3571,7 @@ pending_approval_requests AS (...@@ -3562,6 +3571,7 @@ pending_approval_requests AS (
3562 pinnedAt: threadRow.value.pinnedAt,3571 pinnedAt: threadRow.value.pinnedAt,
3563 pinOrderKey: threadRow.value.pinOrderKey ?? null,3572 pinOrderKey: threadRow.value.pinOrderKey ?? null,
3564 activeOrderKey: threadRow.value.activeOrderKey ?? null,3573 activeOrderKey: threadRow.value.activeOrderKey ?? null,
3574 autoSettleDisabledAt: threadRow.value.autoSettleDisabledAt ?? null,
3565 titleRegeneration: mapTitleRegeneration(threadRow.value),3575 titleRegeneration: mapTitleRegeneration(threadRow.value),
3566 titleState: threadRow.value.titleState,3576 titleState: threadRow.value.titleState,
3567 deletedAt: null,3577 deletedAt: null,
apps/server/src/orchestration/Schemas.ts+2
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema,16 ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema,
17 ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema,17 ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema,
18 ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema,18 ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema,
19 ThreadAutoSettleSetPayload as ContractsThreadAutoSettleSetPayloadSchema,
19 ThreadPullRequestLinkedPayload as ContractsThreadPullRequestLinkedPayloadSchema,20 ThreadPullRequestLinkedPayload as ContractsThreadPullRequestLinkedPayloadSchema,
20 ThreadPullRequestUnlinkedPayload as ContractsThreadPullRequestUnlinkedPayloadSchema,21 ThreadPullRequestUnlinkedPayload as ContractsThreadPullRequestUnlinkedPayloadSchema,
21 ThreadPullRequestSyncedPayload as ContractsThreadPullRequestSyncedPayloadSchema,22 ThreadPullRequestSyncedPayload as ContractsThreadPullRequestSyncedPayloadSchema,
...@@ -51,6 +52,7 @@ export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema;...@@ -51,6 +52,7 @@ export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema;
51export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema;52export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema;
52export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema;53export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema;
53export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema;54export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema;
55export const ThreadAutoSettleSetPayload = ContractsThreadAutoSettleSetPayloadSchema;
54export const ThreadPullRequestLinkedPayload = ContractsThreadPullRequestLinkedPayloadSchema;56export const ThreadPullRequestLinkedPayload = ContractsThreadPullRequestLinkedPayloadSchema;
55export const ThreadPullRequestUnlinkedPayload = ContractsThreadPullRequestUnlinkedPayloadSchema;57export const ThreadPullRequestUnlinkedPayload = ContractsThreadPullRequestUnlinkedPayloadSchema;
56export const ThreadPullRequestSyncedPayload = ContractsThreadPullRequestSyncedPayloadSchema;58export const ThreadPullRequestSyncedPayload = ContractsThreadPullRequestSyncedPayloadSchema;
apps/server/src/orchestration/ThreadSettlementPolicy.test.ts+24
...@@ -171,6 +171,15 @@ describe("resolveAutoSettlementAt", () => {...@@ -171,6 +171,15 @@ describe("resolveAutoSettlementAt", () => {
171171
172 it("blocks pins, snooze, pending work, live sessions, and queued starts", () => {172 it("blocks pins, snooze, pending work, live sessions, and queued starts", () => {
173 expect(decide(makeThread({ settledOverride: "active" }))).toBe(false);173 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);
174 expect(decide(makeThread({ snoozedUntil: "2026-08-29T00:00:00.000Z" }))).toBe(false);183 expect(decide(makeThread({ snoozedUntil: "2026-08-29T00:00:00.000Z" }))).toBe(false);
175 expect(decide(makeThread({ hasPendingApprovals: true }))).toBe(false);184 expect(decide(makeThread({ hasPendingApprovals: true }))).toBe(false);
176 expect(decide(makeThread({ hasPendingUserInput: true }))).toBe(false);185 expect(decide(makeThread({ hasPendingUserInput: true }))).toBe(false);
...@@ -248,6 +257,21 @@ const terminalSnapshot = (...@@ -248,6 +257,21 @@ const terminalSnapshot = (
248 syncedAt: NOW,257 syncedAt: NOW,
249});258});
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
251describe("linked request settlement", () => {275describe("linked request settlement", () => {
252 it.each(["closed", "merged"] as const)(276 it.each(["closed", "merged"] as const)(
253 "uses the latest actual %s transition despite later comments on another PR",277 "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: {...@@ -117,6 +117,7 @@ export function resolveAutoSettlementAt(input: {
117/** Cheap checks that run before any source control lookup. */117/** Cheap checks that run before any source control lookup. */
118export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now: string): boolean {118export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now: string): boolean {
119 if (thread.archivedAt !== null || thread.settledOverride !== null) return false;119 if (thread.archivedAt !== null || thread.settledOverride !== null) return false;
120 if (thread.autoSettleDisabledAt != null) return false;
120 if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false;121 if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false;
121 if (thread.session?.status === "starting" || thread.session?.status === "running") return false;122 if (thread.session?.status === "starting" || thread.session?.status === "running") return false;
122 if (thread.backgroundLiveness != null) return false;123 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"...@@ -484,7 +484,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
484 command,484 command,
485 threadId: command.threadId,485 threadId: command.threadId,
486 });486 });
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 ) {
488 return yield* Effect.fail(491 return yield* Effect.fail(
489 new OrchestrationCommandInvariantError({492 new OrchestrationCommandInvariantError({
490 commandType: command.type,493 commandType: command.type,
...@@ -860,6 +863,37 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -860,6 +863,37 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
860 };863 };
861 }864 }
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
863 case "thread.active.reorder": {897 case "thread.active.reorder": {
864 const thread = yield* requireThreadNotArchived({898 const thread = yield* requireThreadNotArchived({
865 readModel,899 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", () => {...@@ -93,6 +93,7 @@ describe("orchestration projector", () => {
93 updatedAt: now,93 updatedAt: now,
94 archivedAt: null,94 archivedAt: null,
95 activeOrderKey: null,95 activeOrderKey: null,
96 autoSettleDisabledAt: null,
96 settledOverride: null,97 settledOverride: null,
97 settledAt: null,98 settledAt: null,
98 unsettledAt: null,99 unsettledAt: null,
apps/server/src/orchestration/projector.ts+13
...@@ -42,6 +42,7 @@ import {...@@ -42,6 +42,7 @@ import {
42 ThreadSettledPayload,42 ThreadSettledPayload,
43 ThreadPinnedPayload,43 ThreadPinnedPayload,
44 ThreadPinReorderedPayload,44 ThreadPinReorderedPayload,
45 ThreadAutoSettleSetPayload,
45 ThreadPullRequestLinkedPayload,46 ThreadPullRequestLinkedPayload,
46 ThreadPullRequestSyncedPayload,47 ThreadPullRequestSyncedPayload,
47 ThreadPullRequestUnlinkedPayload,48 ThreadPullRequestUnlinkedPayload,
...@@ -440,6 +441,7 @@ export function projectEvent(...@@ -440,6 +441,7 @@ export function projectEvent(
440 settledAt: null,441 settledAt: null,
441 unsettledAt: null,442 unsettledAt: null,
442 activeOrderKey: null,443 activeOrderKey: null,
444 autoSettleDisabledAt: null,
443 snoozedUntil: null,445 snoozedUntil: null,
444 snoozedAt: null,446 snoozedAt: null,
445 deletedAt: null,447 deletedAt: null,
...@@ -580,6 +582,17 @@ export function projectEvent(...@@ -580,6 +582,17 @@ export function projectEvent(
580 })),582 })),
581 );583 );
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
583 case "thread.pin-reordered":596 case "thread.pin-reordered":
584 return decodeForEvent(ThreadPinReorderedPayload, event.payload, event.type, "payload").pipe(597 return decodeForEvent(ThreadPinReorderedPayload, event.payload, event.type, "payload").pipe(
585 Effect.map((payload) => ({598 Effect.map((payload) => ({
apps/server/src/persistence/Layers/ProjectionThreads.ts+4
...@@ -54,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -54,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
54 pinned_at,54 pinned_at,
55 pin_order_key,55 pin_order_key,
56 active_order_key,56 active_order_key,
57 auto_settle_disabled_at,
57 title_regeneration_request_id,58 title_regeneration_request_id,
58 title_regeneration_started_at,59 title_regeneration_started_at,
59 latest_user_message_at,60 latest_user_message_at,
...@@ -86,6 +87,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -86,6 +87,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
86 ${row.pinnedAt},87 ${row.pinnedAt},
87 ${row.pinOrderKey ?? null},88 ${row.pinOrderKey ?? null},
88 ${row.activeOrderKey ?? null},89 ${row.activeOrderKey ?? null},
90 ${row.autoSettleDisabledAt ?? null},
89 ${row.titleRegenerationRequestId ?? null},91 ${row.titleRegenerationRequestId ?? null},
90 ${row.titleRegenerationStartedAt ?? null},92 ${row.titleRegenerationStartedAt ?? null},
91 ${row.latestUserMessageAt},93 ${row.latestUserMessageAt},
...@@ -118,6 +120,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -118,6 +120,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
118 pinned_at = excluded.pinned_at,120 pinned_at = excluded.pinned_at,
119 pin_order_key = excluded.pin_order_key,121 pin_order_key = excluded.pin_order_key,
120 active_order_key = excluded.active_order_key,122 active_order_key = excluded.active_order_key,
123 auto_settle_disabled_at = excluded.auto_settle_disabled_at,
121 title_regeneration_request_id = excluded.title_regeneration_request_id,124 title_regeneration_request_id = excluded.title_regeneration_request_id,
122 title_regeneration_started_at = excluded.title_regeneration_started_at,125 title_regeneration_started_at = excluded.title_regeneration_started_at,
123 latest_user_message_at = excluded.latest_user_message_at,126 latest_user_message_at = excluded.latest_user_message_at,
...@@ -157,6 +160,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -157,6 +160,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
157 pinned_at AS "pinnedAt",160 pinned_at AS "pinnedAt",
158 pin_order_key AS "pinOrderKey",161 pin_order_key AS "pinOrderKey",
159 active_order_key AS "activeOrderKey",162 active_order_key AS "activeOrderKey",
163 auto_settle_disabled_at AS "autoSettleDisabledAt",
160 title_regeneration_request_id AS "titleRegenerationRequestId",164 title_regeneration_request_id AS "titleRegenerationRequestId",
161 title_regeneration_started_at AS "titleRegenerationStartedAt",165 title_regeneration_started_at AS "titleRegenerationStartedAt",
162 latest_user_message_at AS "latestUserMessageAt",166 latest_user_message_at AS "latestUserMessageAt",
apps/server/src/persistence/Migrations.ts+2
...@@ -65,6 +65,7 @@ import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts";...@@ -65,6 +65,7 @@ import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts";
65import Migration0051 from "./Migrations/051_ProjectionThreadMessageContext.ts";65import Migration0051 from "./Migrations/051_ProjectionThreadMessageContext.ts";
66import Migration0052 from "./Migrations/052_ProjectionThreadTitleState.ts";66import Migration0052 from "./Migrations/052_ProjectionThreadTitleState.ts";
67import Migration0053 from "./Migrations/053_PullRequestFilesViewed.ts";67import Migration0053 from "./Migrations/053_PullRequestFilesViewed.ts";
68import Migration0054 from "./Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts";
6869
69/**70/**
70 * Migration loader with all migrations defined inline.71 * Migration loader with all migrations defined inline.
...@@ -130,6 +131,7 @@ const migrationEntries = [...@@ -130,6 +131,7 @@ const migrationEntries = [
130 [51, "ProjectionThreadMessageContext", Migration0051],131 [51, "ProjectionThreadMessageContext", Migration0051],
131 [52, "ProjectionThreadTitleState", Migration0052],132 [52, "ProjectionThreadTitleState", Migration0052],
132 [53, "PullRequestFilesViewed", Migration0053],133 [53, "PullRequestFilesViewed", Migration0053],
134 [54, "ProjectionThreadsAutoSettleDisabledAt", Migration0054],
133] as const;135] as const;
134136
135export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);137export 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({...@@ -50,6 +50,7 @@ export const ProjectionThread = Schema.Struct({
50 pinnedAt: Schema.NullOr(IsoDateTime),50 pinnedAt: Schema.NullOr(IsoDateTime),
51 pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)),51 pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)),
52 activeOrderKey: Schema.optional(Schema.NullOr(Schema.String)),52 activeOrderKey: Schema.optional(Schema.NullOr(Schema.String)),
53 autoSettleDisabledAt: Schema.optional(Schema.NullOr(IsoDateTime)),
53 titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)),54 titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)),
54 titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)),55 titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
55 latestUserMessageAt: Schema.NullOr(IsoDateTime),56 latestUserMessageAt: Schema.NullOr(IsoDateTime),
apps/server/src/project/AgentSessionImporter.ts+1
...@@ -87,6 +87,7 @@ function hasImportBlockingActivity(...@@ -87,6 +87,7 @@ function hasImportBlockingActivity(
87 thread.snoozedAt != null ||87 thread.snoozedAt != null ||
88 thread.pinnedAt != null ||88 thread.pinnedAt != null ||
89 thread.pinOrderKey != null ||89 thread.pinOrderKey != null ||
90 thread.autoSettleDisabledAt != null ||
90 thread.titleRegeneration != null ||91 thread.titleRegeneration != null ||
91 thread.linkedPullRequest != null ||92 thread.linkedPullRequest != null ||
92 thread.unsettledAt != null ||93 thread.unsettledAt != null ||
apps/web/src/components/Sidebar.tsx+25
...@@ -2154,6 +2154,7 @@ export default function Sidebar() {...@@ -2154,6 +2154,7 @@ export default function Sidebar() {
2154 confirmAndUnpinThread,2154 confirmAndUnpinThread,
2155 reorderPinnedThread,2155 reorderPinnedThread,
2156 reorderActiveThread,2156 reorderActiveThread,
2157 setThreadAutoSettle,
2157 archiveThread,2158 archiveThread,
2158 deleteThread,2159 deleteThread,
2159 } = useThreadActions();2160 } = useThreadActions();
...@@ -4020,6 +4021,9 @@ export default function Sidebar() {...@@ -4020,6 +4021,9 @@ export default function Sidebar() {
4020 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;4021 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
4021 const supportsPinning =4022 const supportsPinning =
4022 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true;4023 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true;
4024 const supportsAutoSettleOptOut =
4025 serverConfigs.get(thread.environmentId)?.environment.capabilities
4026 .threadAutoSettleOptOut === true;
4023 const supportsTitleRegeneration =4027 const supportsTitleRegeneration =
4024 serverConfigs.get(thread.environmentId)?.environment.capabilities4028 serverConfigs.get(thread.environmentId)?.environment.capabilities
4025 .threadTitleRegeneration === true;4029 .threadTitleRegeneration === true;
...@@ -4049,6 +4053,7 @@ export default function Sidebar() {...@@ -4049,6 +4053,7 @@ export default function Sidebar() {
4049 : null,4053 : null,
4050 isPinned,4054 isPinned,
4051 isSettled,4055 isSettled,
4056 autoSettleEnabled: thread.autoSettleDisabledAt == null,
4052 isSnoozed,4057 isSnoozed,
4053 canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }),4058 canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }),
4054 isRegeneratingTitle,4059 isRegeneratingTitle,
...@@ -4056,6 +4061,7 @@ export default function Sidebar() {...@@ -4056,6 +4061,7 @@ export default function Sidebar() {
4056 thread.session?.status === "running" && thread.session.activeTurnId != null,4061 thread.session?.status === "running" && thread.session.activeTurnId != null,
4057 supports: {4062 supports: {
4058 settlement: supportsSettlement,4063 settlement: supportsSettlement,
4064 autoSettleOptOut: supportsAutoSettleOptOut,
4059 snooze: supportsSnooze,4065 snooze: supportsSnooze,
4060 pinning: supportsPinning,4066 pinning: supportsPinning,
4061 titleRegeneration: supportsTitleRegeneration,4067 titleRegeneration: supportsTitleRegeneration,
...@@ -4127,6 +4133,24 @@ export default function Sidebar() {...@@ -4127,6 +4133,24 @@ export default function Sidebar() {
4127 case "unpin":4133 case "unpin":
4128 attemptUnpin(threadRef);4134 attemptUnpin(threadRef);
4129 return;4135 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 }
4130 case "rename":4154 case "rename":
4131 startThreadRename(threadRef, thread.title);4155 startThreadRename(threadRef, thread.title);
4132 return;4156 return;
...@@ -4253,6 +4277,7 @@ export default function Sidebar() {...@@ -4253,6 +4277,7 @@ export default function Sidebar() {
4253 projectByKey,4277 projectByKey,
4254 serverConfigs,4278 serverConfigs,
4255 setProjectScopeKey,4279 setProjectScopeKey,
4280 setThreadAutoSettle,
4256 startThreadRename,4281 startThreadRename,
4257 updateThreadMetadata,4282 updateThreadMetadata,
4258 timestampFormat,4283 timestampFormat,
apps/web/src/components/threadActionMenu.logic.test.ts+42-4
...@@ -7,11 +7,18 @@ const baseState: ThreadActionMenuState = {...@@ -7,11 +7,18 @@ const baseState: ThreadActionMenuState = {
7 projectFilter: null,7 projectFilter: null,
8 isPinned: false,8 isPinned: false,
9 isSettled: false,9 isSettled: false,
10 autoSettleEnabled: true,
10 isSnoozed: false,11 isSnoozed: false,
11 canSnoozeNow: true,12 canSnoozeNow: true,
12 isRegeneratingTitle: false,13 isRegeneratingTitle: false,
13 isRunning: false,14 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 },
15 snoozePresets: [22 snoozePresets: [
16 { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" },23 { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" },
17 ],24 ],
...@@ -32,7 +39,13 @@ describe("buildThreadActionMenuItems", () => {...@@ -32,7 +39,13 @@ describe("buildThreadActionMenuItems", () => {
32 expect(39 expect(
33 ids({40 ids({
34 ...baseState,41 ...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 },
36 }),49 }),
37 ).toEqual(["rename", "mark-unread", "copy", "project-settings", "archive", "delete"]);50 ).toEqual(["rename", "mark-unread", "copy", "project-settings", "archive", "delete"]);
38 });51 });
...@@ -66,7 +79,7 @@ describe("buildThreadActionMenuItems", () => {...@@ -66,7 +79,7 @@ describe("buildThreadActionMenuItems", () => {
66 const filterIndex = items.findIndex((candidate) => candidate.id === "filter-by-project");79 const filterIndex = items.findIndex((candidate) => candidate.id === "filter-by-project");
67 expect(items[filterIndex]).toMatchObject({ label: "Show all projects", icon: "folder-tree" });80 expect(items[filterIndex]).toMatchObject({ label: "Show all projects", icon: "folder-tree" });
68 expect(items[filterIndex - 1]?.id).toBe("mark-unread");81 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");
70 });83 });
7184
72 it("includes branch items only for threads with a branch", () => {85 it("includes branch items only for threads with a branch", () => {
...@@ -84,6 +97,25 @@ describe("buildThreadActionMenuItems", () => {...@@ -84,6 +97,25 @@ describe("buildThreadActionMenuItems", () => {
84 expect(ids(baseState)).toEqual(expect.arrayContaining(["pin", "settle", "snooze"]));97 expect(ids(baseState)).toEqual(expect.arrayContaining(["pin", "settle", "snooze"]));
85 });98 });
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
87 it("disables snooze when the thread cannot snooze, keeping presets visible", () => {119 it("disables snooze when the thread cannot snooze, keeping presets visible", () => {
88 const snooze = buildThreadActionMenuItems({ ...baseState, canSnoozeNow: false }).find(120 const snooze = buildThreadActionMenuItems({ ...baseState, canSnoozeNow: false }).find(
89 (item) => item.id === "snooze",121 (item) => item.id === "snooze",
...@@ -117,7 +149,13 @@ describe("buildThreadActionMenuItems", () => {...@@ -117,7 +149,13 @@ describe("buildThreadActionMenuItems", () => {
117 expect(149 expect(
118 ids({150 ids({
119 ...baseState,151 ...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 },
121 }),159 }),
122 ).toContain("archive");160 ).toContain("archive");
123 });161 });
apps/web/src/components/threadActionMenu.logic.ts+32
...@@ -14,6 +14,9 @@ export type ThreadActionMenuId =...@@ -14,6 +14,9 @@ export type ThreadActionMenuId =
14 | "unpin"14 | "unpin"
15 | "settle"15 | "settle"
16 | "unsettle"16 | "unsettle"
17 | "auto-settle"
18 | "auto-settle:enabled"
19 | "auto-settle:disabled"
17 | "snooze"20 | "snooze"
18 | `snooze:${string}`21 | `snooze:${string}`
19 | "unsnooze"22 | "unsnooze"
...@@ -40,6 +43,8 @@ export interface ThreadActionMenuState {...@@ -40,6 +43,8 @@ export interface ThreadActionMenuState {
40 } | null;43 } | null;
41 readonly isPinned: boolean;44 readonly isPinned: boolean;
42 readonly isSettled: boolean;45 readonly isSettled: boolean;
46 /** False while the user has turned automatic settlement off for this thread. */
47 readonly autoSettleEnabled: boolean;
43 readonly isSnoozed: boolean;48 readonly isSnoozed: boolean;
44 readonly canSnoozeNow: boolean;49 readonly canSnoozeNow: boolean;
45 readonly isRegeneratingTitle: boolean;50 readonly isRegeneratingTitle: boolean;
...@@ -47,6 +52,8 @@ export interface ThreadActionMenuState {...@@ -47,6 +52,8 @@ export interface ThreadActionMenuState {
47 readonly isRunning: boolean;52 readonly isRunning: boolean;
48 readonly supports: {53 readonly supports: {
49 readonly settlement: boolean;54 readonly settlement: boolean;
55 /** Server understands thread.auto-settle.set. */
56 readonly autoSettleOptOut: boolean;
50 readonly snooze: boolean;57 readonly snooze: boolean;
51 readonly pinning: boolean;58 readonly pinning: boolean;
52 readonly titleRegeneration: boolean;59 readonly titleRegeneration: boolean;
...@@ -131,6 +138,31 @@ export function buildThreadActionMenuItems(...@@ -131,6 +138,31 @@ export function buildThreadActionMenuItems(
131 },138 },
132 ]139 ]
133 : []),140 : []),
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 : []),
134 {166 {
135 id: "copy",167 id: "copy",
136 label: "Copy",168 label: "Copy",
apps/web/src/contextMenuFallback.ts+22-1
...@@ -9,6 +9,12 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri...@@ -9,6 +9,12 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri
9 { tag: "path", attrs: { d: "M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" } },9 { tag: "path", attrs: { d: "M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" } },
10 { tag: "path", attrs: { d: "M10 12h4" } },10 { tag: "path", attrs: { d: "M10 12h4" } },
11 ],11 ],
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 ],
12 "chevron-right": [{ tag: "path", attrs: { d: "m9 19 7-7-7-7" } }],18 "chevron-right": [{ tag: "path", attrs: { d: "m9 19 7-7-7-7" } }],
13 "circle-check": [19 "circle-check": [
14 { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } },20 { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } },
...@@ -347,7 +353,22 @@ export function showContextMenuFallback<T extends string>(...@@ -347,7 +353,22 @@ export function showContextMenuFallback<T extends string>(
347 button.style.pointerEvents = "none";353 button.style.pointerEvents = "none";
348 }354 }
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") {
351 const icon = createIconElement(item.icon, isLeafDestructive ? "destructive" : "neutral");372 const icon = createIconElement(item.icon, isLeafDestructive ? "destructive" : "neutral");
352 if (icon) {373 if (icon) {
353 button.appendChild(icon);374 button.appendChild(icon);
apps/web/src/hooks/useThreadActionMenu.ts+11
...@@ -20,6 +20,7 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast";...@@ -20,6 +20,7 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast";
20import { threadEnvironment } from "../state/threads";20import { threadEnvironment } from "../state/threads";
21import { useAtomCommand } from "../state/use-atom-command";21import { useAtomCommand } from "../state/use-atom-command";
22import {22import {
23 readEnvironmentSupportsAutoSettleOptOut,
23 readEnvironmentSupportsPinning,24 readEnvironmentSupportsPinning,
24 readEnvironmentSupportsSettlement,25 readEnvironmentSupportsSettlement,
25 readEnvironmentSupportsSnooze,26 readEnvironmentSupportsSnooze,
...@@ -88,6 +89,7 @@ export function useThreadActionMenu(input: {...@@ -88,6 +89,7 @@ export function useThreadActionMenu(input: {
88 unsnoozeThread,89 unsnoozeThread,
89 pinThread,90 pinThread,
90 confirmAndUnpinThread,91 confirmAndUnpinThread,
92 setThreadAutoSettle,
91 archiveThread,93 archiveThread,
92 deleteThread,94 deleteThread,
93 } = useThreadActions();95 } = useThreadActions();
...@@ -132,6 +134,7 @@ export function useThreadActionMenu(input: {...@@ -132,6 +134,7 @@ export function useThreadActionMenu(input: {
132 const now = new Date();134 const now = new Date();
133 const supports = {135 const supports = {
134 settlement: readEnvironmentSupportsSettlement(threadRef.environmentId),136 settlement: readEnvironmentSupportsSettlement(threadRef.environmentId),
137 autoSettleOptOut: readEnvironmentSupportsAutoSettleOptOut(threadRef.environmentId),
135 snooze: readEnvironmentSupportsSnooze(threadRef.environmentId),138 snooze: readEnvironmentSupportsSnooze(threadRef.environmentId),
136 pinning: readEnvironmentSupportsPinning(threadRef.environmentId),139 pinning: readEnvironmentSupportsPinning(threadRef.environmentId),
137 titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId),140 titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId),
...@@ -145,6 +148,7 @@ export function useThreadActionMenu(input: {...@@ -145,6 +148,7 @@ export function useThreadActionMenu(input: {
145 projectFilter: null,148 projectFilter: null,
146 isPinned: thread.pinnedAt != null,149 isPinned: thread.pinnedAt != null,
147 isSettled: supports.settlement && thread.settledOverride === "settled",150 isSettled: supports.settlement && thread.settledOverride === "settled",
151 autoSettleEnabled: thread.autoSettleDisabledAt == null,
148 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),152 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
149 canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),153 canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),
150 isRegeneratingTitle,154 isRegeneratingTitle,
...@@ -225,6 +229,12 @@ export function useThreadActionMenu(input: {...@@ -225,6 +229,12 @@ export function useThreadActionMenu(input: {
225 await reportFailure("Failed to unpin thread", () => confirmAndUnpinThread(threadRef));229 await reportFailure("Failed to unpin thread", () => confirmAndUnpinThread(threadRef));
226 return;230 return;
227 }231 }
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;
228 case "rename":238 case "rename":
229 onStartRename();239 onStartRename();
230 return;240 return;
...@@ -333,6 +343,7 @@ export function useThreadActionMenu(input: {...@@ -333,6 +343,7 @@ export function useThreadActionMenu(input: {
333 projectGroupingSettings,343 projectGroupingSettings,
334 projects,344 projects,
335 router,345 router,
346 setThreadAutoSettle,
336 settleThread,347 settleThread,
337 snoozeThread,348 snoozeThread,
338 threadRef,349 threadRef,
apps/web/src/hooks/useThreadActions.ts+39
...@@ -26,6 +26,7 @@ import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsStat...@@ -26,6 +26,7 @@ import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsStat
26import { releaseComposerDraftUploads } from "../lib/composerDraftUploads";26import { releaseComposerDraftUploads } from "../lib/composerDraftUploads";
27import { readLocalApi } from "../localApi";27import { readLocalApi } from "../localApi";
28import {28import {
29 readEnvironmentSupportsAutoSettleOptOut,
29 readEnvironmentSupportsPinning,30 readEnvironmentSupportsPinning,
30 readEnvironmentSupportsPinReorder,31 readEnvironmentSupportsPinReorder,
31 readEnvironmentSupportsActiveReorder,32 readEnvironmentSupportsActiveReorder,
...@@ -106,6 +107,18 @@ function topOfPinnedRunOrderKey(): string | undefined {...@@ -106,6 +107,18 @@ function topOfPinnedRunOrderKey(): string | undefined {
106 return pinOrderKeyBetween(null, firstKey) ?? undefined;107 return pinOrderKeyBetween(null, firstKey) ?? undefined;
107}108}
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
109export class ThreadPinningUnsupportedError extends Schema.TaggedError<ThreadPinningUnsupportedError>()(122export class ThreadPinningUnsupportedError extends Schema.TaggedError<ThreadPinningUnsupportedError>()(
110 "ThreadPinningUnsupportedError",123 "ThreadPinningUnsupportedError",
111 {124 {
...@@ -200,6 +213,9 @@ export function useThreadActions() {...@@ -200,6 +213,9 @@ export function useThreadActions() {
200 const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, {213 const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, {
201 reportFailure: false,214 reportFailure: false,
202 });215 });
216 const setThreadAutoSettleMutation = useAtomCommand(threadEnvironment.setAutoSettle, {
217 reportFailure: false,
218 });
203 const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, {219 const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, {
204 reportFailure: false,220 reportFailure: false,
205 });221 });
...@@ -556,6 +572,27 @@ export function useThreadActions() {...@@ -556,6 +572,27 @@ export function useThreadActions() {
556 [unsettleThreadMutation],572 [unsettleThreadMutation],
557 );573 );
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
559 const pinThread = useCallback(596 const pinThread = useCallback(
560 async (target: ScopedThreadRef, opts: { orderKey?: string } = {}) => {597 async (target: ScopedThreadRef, opts: { orderKey?: string } = {}) => {
561 // Version skew: never send the command to a server that predates it.598 // Version skew: never send the command to a server that predates it.
...@@ -875,6 +912,7 @@ export function useThreadActions() {...@@ -875,6 +912,7 @@ export function useThreadActions() {
875 confirmAndUnpinThread,912 confirmAndUnpinThread,
876 reorderPinnedThread,913 reorderPinnedThread,
877 reorderActiveThread,914 reorderActiveThread,
915 setThreadAutoSettle,
878 }),916 }),
879 [917 [
880 archiveThread,918 archiveThread,
...@@ -884,6 +922,7 @@ export function useThreadActions() {...@@ -884,6 +922,7 @@ export function useThreadActions() {
884 pinThread,922 pinThread,
885 reorderPinnedThread,923 reorderPinnedThread,
886 reorderActiveThread,924 reorderActiveThread,
925 setThreadAutoSettle,
887 settleThread,926 settleThread,
888 snoozeThread,927 snoozeThread,
889 unarchiveThread,928 unarchiveThread,
apps/web/src/state/entities.ts+9
...@@ -229,6 +229,15 @@ export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId):...@@ -229,6 +229,15 @@ export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId):
229 );229 );
230}230}
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
232export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean {241export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean {
233 return (242 return (
234 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities243 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...@@ -99,6 +99,11 @@ prevent automatic settlement. An open pull request does not prevent inactivity
99settlement, but an old closed or merged pull request does not settle work you99settlement, but an old closed or merged pull request does not settle work you
100resumed after it closed.100resumed 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
102Change these rules in **Settings → General** on web and desktop, or **Settings → Thread behavior** on mobile.107Change these rules in **Settings → General** on web and desktop, or **Settings → Thread behavior** on mobile.
103They continue to run when your apps are closed. On web and desktop, choose an environment at the108They continue to run when your apps are closed. On web and desktop, choose an environment at the
104top to change only its rules, or **All environments** to update connected environments together.109top 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">;...@@ -42,6 +42,7 @@ export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;
42export type PinThreadInput = CommandInput<"thread.pin">;42export type PinThreadInput = CommandInput<"thread.pin">;
43export type UnpinThreadInput = CommandInput<"thread.unpin">;43export type UnpinThreadInput = CommandInput<"thread.unpin">;
44export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">;44export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">;
45export type SetThreadAutoSettleInput = CommandInput<"thread.auto-settle.set">;
45export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">;46export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">;
46export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;47export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
47export type LinkThreadPullRequestInput = CommandInput<"thread.pull-request.link">;48export type LinkThreadPullRequestInput = CommandInput<"thread.pull-request.link">;
...@@ -226,6 +227,16 @@ export const unpinThread: (input: UnpinThreadInput) => CommandEffect = Effect.fn...@@ -226,6 +227,16 @@ export const unpinThread: (input: UnpinThreadInput) => CommandEffect = Effect.fn
226 });227 });
227});228});
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
229export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEffect = Effect.fn(240export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEffect = Effect.fn(
230 "EnvironmentCommands.reorderPinnedThread",241 "EnvironmentCommands.reorderPinnedThread",
231)(function* (input) {242)(function* (input) {
packages/client-runtime/src/state/threadCommands.ts+9
...@@ -29,6 +29,7 @@ import {...@@ -29,6 +29,7 @@ import {
29 type PinThreadInput,29 type PinThreadInput,
30 type ReorderPinnedThreadInput,30 type ReorderPinnedThreadInput,
31 type ReorderActiveThreadInput,31 type ReorderActiveThreadInput,
32 type SetThreadAutoSettleInput,
32 type SettleThreadInput,33 type SettleThreadInput,
33 type SnoozeThreadInput,34 type SnoozeThreadInput,
34 type StartThreadTurnInput,35 type StartThreadTurnInput,
...@@ -53,6 +54,7 @@ import {...@@ -53,6 +54,7 @@ import {
53 pinThread,54 pinThread,
54 reorderPinnedThread,55 reorderPinnedThread,
55 reorderActiveThread,56 reorderActiveThread,
57 setThreadAutoSettle,
56 settleThread,58 settleThread,
57 snoozeThread,59 snoozeThread,
58 startThreadTurn,60 startThreadTurn,
...@@ -81,6 +83,7 @@ export type {...@@ -81,6 +83,7 @@ export type {
81 PinThreadInput,83 PinThreadInput,
82 ReorderPinnedThreadInput,84 ReorderPinnedThreadInput,
83 ReorderActiveThreadInput,85 ReorderActiveThreadInput,
86 SetThreadAutoSettleInput,
84 SettleThreadInput,87 SettleThreadInput,
85 SnoozeThreadInput,88 SnoozeThreadInput,
86 StartThreadTurnInput,89 StartThreadTurnInput,
...@@ -170,6 +173,12 @@ export function createThreadEnvironmentAtoms<R, E>(...@@ -170,6 +173,12 @@ export function createThreadEnvironmentAtoms<R, E>(
170 scheduler,173 scheduler,
171 concurrency,174 concurrency,
172 }),175 }),
176 setAutoSettle: createEnvironmentCommand(runtime, {
177 label: "environment-data:commands:thread:set-auto-settle",
178 execute: (input: SetThreadAutoSettleInput) => setThreadAutoSettle(input),
179 scheduler,
180 concurrency,
181 }),
173 reorderActive: createEnvironmentCommand(runtime, {182 reorderActive: createEnvironmentCommand(runtime, {
174 label: "environment-data:commands:thread:reorder-active",183 label: "environment-data:commands:thread:reorder-active",
175 execute: (input: ReorderActiveThreadInput) => reorderActiveThread(input),184 execute: (input: ReorderActiveThreadInput) => reorderActiveThread(input),
packages/client-runtime/src/state/threadDetail.ts+1
...@@ -60,6 +60,7 @@ export function mergeEnvironmentThread(...@@ -60,6 +60,7 @@ export function mergeEnvironmentThread(
60 settledAt: shell.settledAt,60 settledAt: shell.settledAt,
61 unsettledAt: shell.unsettledAt,61 unsettledAt: shell.unsettledAt,
62 activeOrderKey: shell.activeOrderKey,62 activeOrderKey: shell.activeOrderKey,
63 autoSettleDisabledAt: shell.autoSettleDisabledAt,
63 snoozedUntil: shell.snoozedUntil,64 snoozedUntil: shell.snoozedUntil,
64 snoozedAt: shell.snoozedAt,65 snoozedAt: shell.snoozedAt,
65 pinnedAt: shell.pinnedAt,66 pinnedAt: shell.pinnedAt,
packages/client-runtime/src/state/threadReducer.test.ts+40
...@@ -290,6 +290,46 @@ describe("applyThreadDetailEvent", () => {...@@ -290,6 +290,46 @@ describe("applyThreadDetailEvent", () => {
290 });290 });
291 });291 });
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
293 describe("thread.meta-updated", () => {333 describe("thread.meta-updated", () => {
294 it.each(["f", null] as const)(334 it.each(["f", null] as const)(
295 "updates the active key to %s without activity",335 "updates the active key to %s without activity",
packages/client-runtime/src/state/threadReducer.ts+11
...@@ -131,6 +131,7 @@ export function applyThreadDetailEvent(...@@ -131,6 +131,7 @@ export function applyThreadDetailEvent(
131 settledAt: null,131 settledAt: null,
132 unsettledAt: null,132 unsettledAt: null,
133 activeOrderKey: null,133 activeOrderKey: null,
134 autoSettleDisabledAt: null,
134 snoozedUntil: null,135 snoozedUntil: null,
135 snoozedAt: null,136 snoozedAt: null,
136 deletedAt: null,137 deletedAt: null,
...@@ -249,6 +250,16 @@ export function applyThreadDetailEvent(...@@ -249,6 +250,16 @@ export function applyThreadDetailEvent(
249 },250 },
250 };251 };
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
252 // ── Thread metadata ─────────────────────────────────────────────263 // ── Thread metadata ─────────────────────────────────────────────
253 case "thread.meta-updated":264 case "thread.meta-updated":
254 return {265 return {
packages/contracts/src/environment.ts+3
...@@ -141,6 +141,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -141,6 +141,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
141 threadPinReorder: Schema.optionalKey(Schema.Boolean),141 threadPinReorder: Schema.optionalKey(Schema.Boolean),
142 /** Server persists manual Active order through thread.active.reorder. */142 /** Server persists manual Active order through thread.active.reorder. */
143 threadActiveReorder: Schema.optionalKey(Schema.Boolean),143 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),
144 /** Server understands regenerateTitle on thread.meta.update. Absent on147 /** Server understands regenerateTitle on thread.meta.update. Absent on
145 older servers, so clients hide the action instead of sending it. */148 older servers, so clients hide the action instead of sending it. */
146 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),149 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/ipc.ts+4
...@@ -40,6 +40,8 @@ export interface ContextMenuItem<T extends string = string> {...@@ -40,6 +40,8 @@ export interface ContextMenuItem<T extends string = string> {
40 icon?: string;40 icon?: string;
41 /** Inserts a visual section divider immediately before this item. */41 /** Inserts a visual section divider immediately before this item. */
42 separatorBefore?: boolean;42 separatorBefore?: boolean;
43 /** Shows a check mark. Used to mark the current option inside a submenu. */
44 checked?: boolean;
43 children?: readonly ContextMenuItem<T>[];45 children?: readonly ContextMenuItem<T>[];
44}46}
4547
...@@ -55,6 +57,7 @@ export interface ContextMenuItemSchemaType {...@@ -55,6 +57,7 @@ export interface ContextMenuItemSchemaType {
55 readonly header?: boolean;57 readonly header?: boolean;
56 readonly icon?: string;58 readonly icon?: string;
57 readonly separatorBefore?: boolean;59 readonly separatorBefore?: boolean;
60 readonly checked?: boolean;
58 readonly children?: readonly ContextMenuItemSchemaType[];61 readonly children?: readonly ContextMenuItemSchemaType[];
59}62}
6063
...@@ -66,6 +69,7 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc...@@ -66,6 +69,7 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc
66 header: Schema.optionalKey(Schema.Boolean),69 header: Schema.optionalKey(Schema.Boolean),
67 icon: Schema.optionalKey(Schema.String),70 icon: Schema.optionalKey(Schema.String),
68 separatorBefore: Schema.optionalKey(Schema.Boolean),71 separatorBefore: Schema.optionalKey(Schema.Boolean),
72 checked: Schema.optionalKey(Schema.Boolean),
69 children: Schema.optionalKey(73 children: Schema.optionalKey(
70 Schema.Array(74 Schema.Array(
71 Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema),75 Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema),
packages/contracts/src/orchestration.ts+28
...@@ -838,6 +838,10 @@ export const OrchestrationThread = Schema.Struct({...@@ -838,6 +838,10 @@ export const OrchestrationThread = Schema.Struct({
838 // Manual Active placement. Keyless threads retain their creation/re-entry838 // Manual Active placement. Keyless threads retain their creation/re-entry
839 // order above the arranged run. Settling clears this slot.839 // order above the arranged run. Settling clears this slot.
840 activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),840 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)),
841 // Pending-only state. Optional so older servers remain compatible.845 // Pending-only state. Optional so older servers remain compatible.
842 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),846 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
843 titleState: Schema.optional(Schema.NullOr(ThreadTitleState)),847 titleState: Schema.optional(Schema.NullOr(ThreadTitleState)),
...@@ -908,6 +912,7 @@ export const OrchestrationThreadShell = Schema.Struct({...@@ -908,6 +912,7 @@ export const OrchestrationThreadShell = Schema.Struct({
908 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),912 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
909 pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),913 pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
910 activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),914 activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
915 autoSettleDisabledAt: Schema.optional(Schema.NullOr(IsoDateTime)),
911 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),916 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
912 titleState: Schema.optional(Schema.NullOr(ThreadTitleState)),917 titleState: Schema.optional(Schema.NullOr(ThreadTitleState)),
913 session: Schema.NullOr(OrchestrationSession),918 session: Schema.NullOr(OrchestrationSession),
...@@ -1218,6 +1223,14 @@ const ThreadPinReorderCommand = Schema.Struct({...@@ -1218,6 +1223,14 @@ const ThreadPinReorderCommand = Schema.Struct({
1218 orderKey: TrimmedNonEmptyString,1223 orderKey: TrimmedNonEmptyString,
1219});1224});
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
1221const ThreadActiveReorderCommand = Schema.Struct({1234const ThreadActiveReorderCommand = Schema.Struct({
1222 type: Schema.Literal("thread.active.reorder"),1235 type: Schema.Literal("thread.active.reorder"),
1223 commandId: CommandId,1236 commandId: CommandId,
...@@ -1426,6 +1439,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([...@@ -1426,6 +1439,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
1426 ThreadPinCommand,1439 ThreadPinCommand,
1427 ThreadUnpinCommand,1440 ThreadUnpinCommand,
1428 ThreadPinReorderCommand,1441 ThreadPinReorderCommand,
1442 ThreadAutoSettleSetCommand,
1429 ThreadActiveReorderCommand,1443 ThreadActiveReorderCommand,
1430 ThreadMetaUpdateCommand,1444 ThreadMetaUpdateCommand,
1431 ThreadPullRequestLinkCommand,1445 ThreadPullRequestLinkCommand,
...@@ -1459,6 +1473,7 @@ export const ClientOrchestrationCommand = Schema.Union([...@@ -1459,6 +1473,7 @@ export const ClientOrchestrationCommand = Schema.Union([
1459 ThreadPinCommand,1473 ThreadPinCommand,
1460 ThreadUnpinCommand,1474 ThreadUnpinCommand,
1461 ThreadPinReorderCommand,1475 ThreadPinReorderCommand,
1476 ThreadAutoSettleSetCommand,
1462 ThreadActiveReorderCommand,1477 ThreadActiveReorderCommand,
1463 ThreadMetaUpdateCommand,1478 ThreadMetaUpdateCommand,
1464 ThreadPullRequestLinkCommand,1479 ThreadPullRequestLinkCommand,
...@@ -1687,6 +1702,7 @@ export const OrchestrationEventType = Schema.Literals([...@@ -1687,6 +1702,7 @@ export const OrchestrationEventType = Schema.Literals([
1687 "thread.pinned",1702 "thread.pinned",
1688 "thread.unpinned",1703 "thread.unpinned",
1689 "thread.pin-reordered",1704 "thread.pin-reordered",
1705 "thread.auto-settle-set",
1690 "thread.meta-updated",1706 "thread.meta-updated",
1691 "thread.pull-request-linked",1707 "thread.pull-request-linked",
1692 "thread.pull-request-unlinked",1708 "thread.pull-request-unlinked",
...@@ -1825,6 +1841,13 @@ export const ThreadPinReorderedPayload = Schema.Struct({...@@ -1825,6 +1841,13 @@ export const ThreadPinReorderedPayload = Schema.Struct({
1825 updatedAt: IsoDateTime,1841 updatedAt: IsoDateTime,
1826});1842});
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
1828export const ThreadMetaUpdatedPayload = Schema.Struct({1851export const ThreadMetaUpdatedPayload = Schema.Struct({
1829 threadId: ThreadId,1852 threadId: ThreadId,
1830 // Order updates use this existing event so older clients can ignore the1853 // Order updates use this existing event so older clients can ignore the
...@@ -2089,6 +2112,11 @@ export const OrchestrationEvent = Schema.Union([...@@ -2089,6 +2112,11 @@ export const OrchestrationEvent = Schema.Union([
2089 type: Schema.Literal("thread.pin-reordered"),2112 type: Schema.Literal("thread.pin-reordered"),
2090 payload: ThreadPinReorderedPayload,2113 payload: ThreadPinReorderedPayload,
2091 }),2114 }),
2115 Schema.Struct({
2116 ...EventBaseFields,
2117 type: Schema.Literal("thread.auto-settle-set"),
2118 payload: ThreadAutoSettleSetPayload,
2119 }),
2092 Schema.Struct({2120 Schema.Struct({
2093 ...EventBaseFields,2121 ...EventBaseFields,
2094 type: Schema.Literal("thread.meta-updated"),2122 type: Schema.Literal("thread.meta-updated"),