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