| 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,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 | }; |
| 84 | 85 | ||
| 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.pinned | 750 | 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 | } |
| 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 | function environmentSupportsTitleRegeneration( | 71 | function 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.pinned | 781 | 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 | } |
| 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 | 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; |
| 51 | export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; | 52 | export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; |
| 52 | export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; | 53 | export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; |
| 53 | export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; | 54 | export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; |
| 55 | export const ThreadAutoSettleSetPayload = ContractsThreadAutoSettleSetPayloadSchema; | ||
| 54 | export const ThreadPullRequestLinkedPayload = ContractsThreadPullRequestLinkedPayloadSchema; | 56 | export const ThreadPullRequestLinkedPayload = ContractsThreadPullRequestLinkedPayloadSchema; |
| 55 | export const ThreadPullRequestUnlinkedPayload = ContractsThreadPullRequestUnlinkedPayloadSchema; | 57 | export const ThreadPullRequestUnlinkedPayload = ContractsThreadPullRequestUnlinkedPayloadSchema; |
| 56 | export const ThreadPullRequestSyncedPayload = ContractsThreadPullRequestSyncedPayloadSchema; | 58 | export const ThreadPullRequestSyncedPayload = ContractsThreadPullRequestSyncedPayloadSchema; |
apps/server/src/orchestration/ThreadSettlementPolicy.test.ts+24| ... | @@ -171,6 +171,15 @@ describe("resolveAutoSettlementAt", () => { | ... | @@ -171,6 +171,15 @@ describe("resolveAutoSettlementAt", () => { |
| 171 | 171 | ||
| 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 | }); |
| 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 | describe("linked request settlement", () => { | 275 | describe("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. */ |
| 118 | export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now: string): boolean { | 118 | export 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 @@ | ||
| 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,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 | } |
| 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 | 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 @@ | ||
| 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,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 | ); |
| 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 | 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"; |
| 65 | import Migration0051 from "./Migrations/051_ProjectionThreadMessageContext.ts"; | 65 | import Migration0051 from "./Migrations/051_ProjectionThreadMessageContext.ts"; |
| 66 | import Migration0052 from "./Migrations/052_ProjectionThreadTitleState.ts"; | 66 | import Migration0052 from "./Migrations/052_ProjectionThreadTitleState.ts"; |
| 67 | import Migration0053 from "./Migrations/053_PullRequestFilesViewed.ts"; | 67 | import Migration0053 from "./Migrations/053_PullRequestFilesViewed.ts"; |
| 68 | import Migration0054 from "./Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts"; | ||
| 68 | 69 | ||
| 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; |
| 134 | 136 | ||
| 135 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); | 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,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.capabilities | 4028 | 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 | }); |
| 71 | 84 | ||
| 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 | }); |
| 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 | 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 | } |
| 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 | 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"; |
| 20 | import { threadEnvironment } from "../state/threads"; | 20 | import { threadEnvironment } from "../state/threads"; |
| 21 | import { useAtomCommand } from "../state/use-atom-command"; | 21 | import { useAtomCommand } from "../state/use-atom-command"; |
| 22 | import { | 22 | import { |
| 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 |
| 26 | import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; | 26 | import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; |
| 27 | import { readLocalApi } from "../localApi"; | 27 | import { readLocalApi } from "../localApi"; |
| 28 | import { | 28 | import { |
| 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 | } |
| 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 | export class ThreadPinningUnsupportedError extends Schema.TaggedError<ThreadPinningUnsupportedError>()( | 122 | export 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 | ); |
| 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 | 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 | } |
| 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 | export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean { | 241 | export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean { |
| 233 | return ( | 242 | return ( |
| 234 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | 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,6 +99,11 @@ prevent automatic settlement. An open pull request does not prevent inactivity |
| 99 | settlement, but an old closed or merged pull request does not settle work you | 99 | settlement, but an old closed or merged pull request does not settle work you |
| 100 | resumed after it closed. | 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 | Change these rules in **Settings → General** on web and desktop, or **Settings → Thread behavior** on mobile. | 107 | Change these rules in **Settings → General** on web and desktop, or **Settings → Thread behavior** on mobile. |
| 103 | They continue to run when your apps are closed. On web and desktop, choose an environment at the | 108 | They continue to run when your apps are closed. On web and desktop, choose an environment at the |
| 104 | top to change only its rules, or **All environments** to update connected environments together. | 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,6 +42,7 @@ export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">; |
| 42 | export type PinThreadInput = CommandInput<"thread.pin">; | 42 | export type PinThreadInput = CommandInput<"thread.pin">; |
| 43 | export type UnpinThreadInput = CommandInput<"thread.unpin">; | 43 | export type UnpinThreadInput = CommandInput<"thread.unpin">; |
| 44 | export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">; | 44 | export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">; |
| 45 | export type SetThreadAutoSettleInput = CommandInput<"thread.auto-settle.set">; | ||
| 45 | export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">; | 46 | export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">; |
| 46 | export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; | 47 | export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; |
| 47 | export type LinkThreadPullRequestInput = CommandInput<"thread.pull-request.link">; | 48 | export 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 | }); |
| 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 | export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEffect = Effect.fn( | 240 | export 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 | }); |
| 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 | 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 | }; |
| 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 | // ── 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 on | 147 | /** 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 | } |
| 45 | 47 | ||
| ... | @@ -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 | } |
| 60 | 63 | ||
| ... | @@ -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-entry | 838 | // 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 | }); |
| 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 | const ThreadActiveReorderCommand = Schema.Struct({ | 1234 | const 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 | }); |
| 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 | export const ThreadMetaUpdatedPayload = Schema.Struct({ | 1851 | export const ThreadMetaUpdatedPayload = Schema.Struct({ |
| 1829 | threadId: ThreadId, | 1852 | threadId: ThreadId, |
| 1830 | // Order updates use this existing event so older clients can ignore the | 1853 | // 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"), |