| author | |
| committer | |
| log | da6e1a96782594cab3a6925f441731a65be57c11 |
| tree | 8b6eaf5f3b003a304dbd170bbd419f480d527c41 |
| parent | 8eca20005b47e197b3610f7996f3fd02355c1891 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>32 files changed, 1180 insertions(+), 80 deletions(-)
apps/mobile/src/components/AppSymbol.tsx+4| ... | @@ -50,6 +50,8 @@ import { | ... | @@ -50,6 +50,8 @@ import { |
| 50 | IconMinus, | 50 | IconMinus, |
| 51 | IconNetwork, | 51 | IconNetwork, |
| 52 | IconPalette, | 52 | IconPalette, |
| 53 | IconPin, | ||
| 54 | IconPinnedOff, | ||
| 53 | IconPlayerPlay, | 55 | IconPlayerPlay, |
| 54 | IconPlayerStopFilled, | 56 | IconPlayerStopFilled, |
| 55 | IconPlus, | 57 | IconPlus, |
| ... | @@ -121,6 +123,8 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { | ... | @@ -121,6 +123,8 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 121 | magnifyingglass: IconSearch, | 123 | magnifyingglass: IconSearch, |
| 122 | paintbrush: IconPalette, | 124 | paintbrush: IconPalette, |
| 123 | "person.crop.circle": IconUserCircle, | 125 | "person.crop.circle": IconUserCircle, |
| 126 | pin: IconPin, | ||
| 127 | "pin.slash": IconPinnedOff, | ||
| 124 | play: IconPlayerPlay, | 128 | play: IconPlayerPlay, |
| 125 | plus: IconPlus, | 129 | plus: IconPlus, |
| 126 | "qrcode.viewfinder": IconQrcode, | 130 | "qrcode.viewfinder": IconQrcode, |
apps/mobile/src/features/home/HomeRouteScreen.tsx+4| ... | @@ -42,6 +42,8 @@ export function HomeRouteScreen() { | ... | @@ -42,6 +42,8 @@ export function HomeRouteScreen() { |
| 42 | settleThread, | 42 | settleThread, |
| 43 | snoozeThread, | 43 | snoozeThread, |
| 44 | unsnoozeThread, | 44 | unsnoozeThread, |
| 45 | pinThread, | ||
| 46 | unpinThread, | ||
| 45 | unsettleThread, | 47 | unsettleThread, |
| 46 | } = useThreadListActions(); | 48 | } = useThreadListActions(); |
| 47 | const pendingTasks = usePendingNewTasks(); | 49 | const pendingTasks = usePendingNewTasks(); |
| ... | @@ -155,6 +157,8 @@ export function HomeRouteScreen() { | ... | @@ -155,6 +157,8 @@ export function HomeRouteScreen() { |
| 155 | onSnoozeThread={snoozeThread} | 157 | onSnoozeThread={snoozeThread} |
| 156 | onUnsnoozeThread={unsnoozeThread} | 158 | onUnsnoozeThread={unsnoozeThread} |
| 157 | onUnsettleThread={unsettleThread} | 159 | onUnsettleThread={unsettleThread} |
| 160 | onPinThread={pinThread} | ||
| 161 | onUnpinThread={unpinThread} | ||
| 158 | onEnvironmentChange={setSelectedEnvironmentId} | 162 | onEnvironmentChange={setSelectedEnvironmentId} |
| 159 | onProjectChange={setSelectedProjectKey} | 163 | onProjectChange={setSelectedProjectKey} |
| 160 | onOpenEnvironments={() => | 164 | onOpenEnvironments={() => |
apps/mobile/src/features/home/HomeScreen.tsx+30| ... | @@ -111,6 +111,8 @@ interface HomeScreenProps { | ... | @@ -111,6 +111,8 @@ interface HomeScreenProps { |
| 111 | ) => Promise<boolean>; | 111 | ) => Promise<boolean>; |
| 112 | readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | 112 | readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>; |
| 113 | readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; | 113 | readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; |
| 114 | readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | ||
| 115 | readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | ||
| 114 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; | 116 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; |
| 115 | readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; | 117 | readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; |
| 116 | readonly onNewThreadInProject: (project: EnvironmentProject) => void; | 118 | readonly onNewThreadInProject: (project: EnvironmentProject) => void; |
| ... | @@ -516,6 +518,18 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -516,6 +518,18 @@ export function HomeScreen(props: HomeScreenProps) { |
| 516 | }, | 518 | }, |
| 517 | [props.onUnsnoozeThread], | 519 | [props.onUnsnoozeThread], |
| 518 | ); | 520 | ); |
| 521 | const handlePinThread = useCallback( | ||
| 522 | (thread: EnvironmentThreadShell) => { | ||
| 523 | void props.onPinThread(thread); | ||
| 524 | }, | ||
| 525 | [props.onPinThread], | ||
| 526 | ); | ||
| 527 | const handleUnpinThread = useCallback( | ||
| 528 | (thread: EnvironmentThreadShell) => { | ||
| 529 | void props.onUnpinThread(thread); | ||
| 530 | }, | ||
| 531 | [props.onUnpinThread], | ||
| 532 | ); | ||
| 519 | const handleDeleteThread = props.onDeleteThread; | 533 | const handleDeleteThread = props.onDeleteThread; |
| 520 | const handleUnsettleThread = props.onUnsettleThread; | 534 | const handleUnsettleThread = props.onUnsettleThread; |
| 521 | // The settled tail renders in pages; expansion resets when the filter | 535 | // The settled tail renders in pages; expansion resets when the filter |
| ... | @@ -575,6 +589,15 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -575,6 +589,15 @@ export function HomeScreen(props: HomeScreenProps) { |
| 575 | } | 589 | } |
| 576 | return supported; | 590 | return supported; |
| 577 | }, [serverConfigs]); | 591 | }, [serverConfigs]); |
| 592 | const pinningEnvironmentIds = useMemo(() => { | ||
| 593 | const supported = new Set<EnvironmentId>(); | ||
| 594 | for (const [environmentId, config] of serverConfigs) { | ||
| 595 | if (config.environment.capabilities.threadPinning === true) { | ||
| 596 | supported.add(environmentId); | ||
| 597 | } | ||
| 598 | } | ||
| 599 | return supported; | ||
| 600 | }, [serverConfigs]); | ||
| 578 | const threadListV2Layout = useMemo(() => { | 601 | const threadListV2Layout = useMemo(() => { |
| 579 | if (!threadListV2Enabled) | 602 | if (!threadListV2Enabled) |
| 580 | return { | 603 | return { |
| ... | @@ -718,6 +741,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -718,6 +741,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 718 | thread={thread} | 741 | thread={thread} |
| 719 | variant={item.item.variant} | 742 | variant={item.item.variant} |
| 720 | snoozed={item.item.snoozed} | 743 | snoozed={item.item.snoozed} |
| 744 | pinned={item.item.pinned} | ||
| 721 | snoozePresetMinute={nowMinute} | 745 | snoozePresetMinute={nowMinute} |
| 722 | snoozeWakeLabelText={item.snoozeWakeLabelText} | 746 | snoozeWakeLabelText={item.snoozeWakeLabelText} |
| 723 | project={ | 747 | project={ |
| ... | @@ -753,9 +777,12 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -753,9 +777,12 @@ export function HomeScreen(props: HomeScreenProps) { |
| 753 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} | 777 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} |
| 754 | onSettleThread={handleSettleThread} | 778 | onSettleThread={handleSettleThread} |
| 755 | snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} | 779 | snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} |
| 780 | pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} | ||
| 756 | onSnoozeThread={handleSnoozeThread} | 781 | onSnoozeThread={handleSnoozeThread} |
| 757 | onUnsnoozeThread={handleUnsnoozeThread} | 782 | onUnsnoozeThread={handleUnsnoozeThread} |
| 758 | onUnsettleThread={handleUnsettleThread} | 783 | onUnsettleThread={handleUnsettleThread} |
| 784 | onPinThread={handlePinThread} | ||
| 785 | onUnpinThread={handleUnpinThread} | ||
| 759 | onChangeRequestState={handleChangeRequestState} | 786 | onChangeRequestState={handleChangeRequestState} |
| 760 | projectCwd={ | 787 | projectCwd={ |
| 761 | projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null | 788 | projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null |
| ... | @@ -768,12 +795,15 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -768,12 +795,15 @@ export function HomeScreen(props: HomeScreenProps) { |
| 768 | [ | 795 | [ |
| 769 | handleChangeRequestState, | 796 | handleChangeRequestState, |
| 770 | handleDeleteThread, | 797 | handleDeleteThread, |
| 798 | handlePinThread, | ||
| 771 | handleSettleThread, | 799 | handleSettleThread, |
| 772 | handleSnoozeThread, | 800 | handleSnoozeThread, |
| 801 | handleUnpinThread, | ||
| 773 | handleUnsnoozeThread, | 802 | handleUnsnoozeThread, |
| 774 | handleSwipeableClose, | 803 | handleSwipeableClose, |
| 775 | handleSwipeableWillOpen, | 804 | handleSwipeableWillOpen, |
| 776 | handleUnsettleThread, | 805 | handleUnsettleThread, |
| 806 | pinningEnvironmentIds, | ||
| 777 | projectByKey, | 807 | projectByKey, |
| 778 | projectCwdByKey, | 808 | projectCwdByKey, |
| 779 | props.onArchiveThread, | 809 | props.onArchiveThread, |
apps/mobile/src/features/home/useThreadListActions.ts+69| ... | @@ -29,6 +29,13 @@ function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["enviro | ... | @@ -29,6 +29,13 @@ function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["enviro |
| 29 | ); | 29 | ); |
| 30 | } | 30 | } |
| 31 | 31 | ||
| 32 | function environmentSupportsPinning(environmentId: EnvironmentThreadShell["environmentId"]) { | ||
| 33 | return ( | ||
| 34 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | ||
| 35 | .threadPinning === true | ||
| 36 | ); | ||
| 37 | } | ||
| 38 | |||
| 32 | type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; | 39 | type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; |
| 33 | 40 | ||
| 34 | const ACTION_VERBS: Record<ThreadListAction, string> = { | 41 | const ACTION_VERBS: Record<ThreadListAction, string> = { |
| ... | @@ -202,10 +209,14 @@ export function useThreadListActions(): { | ... | @@ -202,10 +209,14 @@ export function useThreadListActions(): { |
| 202 | readonly snoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => Promise<boolean>; | 209 | readonly snoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => Promise<boolean>; |
| 203 | readonly unsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | 210 | readonly unsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>; |
| 204 | readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | 211 | readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>; |
| 212 | readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | ||
| 213 | readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | ||
| 205 | } { | 214 | } { |
| 206 | const executeAction = useThreadActionExecutor(); | 215 | const executeAction = useThreadActionExecutor(); |
| 207 | const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); | 216 | const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); |
| 208 | const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); | 217 | const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); |
| 218 | const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false }); | ||
| 219 | const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false }); | ||
| 209 | const snoozeInFlightThreadKeys = useRef(new Set<string>()); | 220 | const snoozeInFlightThreadKeys = useRef(new Set<string>()); |
| 210 | 221 | ||
| 211 | const archiveThread = useCallback( | 222 | const archiveThread = useCallback( |
| ... | @@ -310,6 +321,62 @@ export function useThreadListActions(): { | ... | @@ -310,6 +321,62 @@ export function useThreadListActions(): { |
| 310 | async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true, | 321 | async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true, |
| 311 | [executeAction], | 322 | [executeAction], |
| 312 | ); | 323 | ); |
| 324 | const pinThread = useCallback( | ||
| 325 | async (thread: EnvironmentThreadShell) => { | ||
| 326 | if (!environmentSupportsPinning(thread.environmentId)) { | ||
| 327 | Alert.alert( | ||
| 328 | "Could not pin thread", | ||
| 329 | "This environment's server does not support pinning yet. Update the server to use Pin.", | ||
| 330 | ); | ||
| 331 | return false; | ||
| 332 | } | ||
| 333 | selectionHaptic(); | ||
| 334 | const result = await pinMutation({ | ||
| 335 | environmentId: thread.environmentId, | ||
| 336 | input: { threadId: thread.id }, | ||
| 337 | }); | ||
| 338 | if (result._tag === "Failure") { | ||
| 339 | const error = Cause.squash(result.cause); | ||
| 340 | Alert.alert( | ||
| 341 | "Could not pin thread", | ||
| 342 | error instanceof Error && error.message.trim().length > 0 | ||
| 343 | ? error.message | ||
| 344 | : "The thread could not be pinned.", | ||
| 345 | ); | ||
| 346 | return false; | ||
| 347 | } | ||
| 348 | return true; | ||
| 349 | }, | ||
| 350 | [pinMutation], | ||
| 351 | ); | ||
| 352 | const unpinThread = useCallback( | ||
| 353 | async (thread: EnvironmentThreadShell) => { | ||
| 354 | if (!environmentSupportsPinning(thread.environmentId)) { | ||
| 355 | Alert.alert( | ||
| 356 | "Could not unpin thread", | ||
| 357 | "This environment's server does not support pinning yet. Update the server to use Pin.", | ||
| 358 | ); | ||
| 359 | return false; | ||
| 360 | } | ||
| 361 | selectionHaptic(); | ||
| 362 | const result = await unpinMutation({ | ||
| 363 | environmentId: thread.environmentId, | ||
| 364 | input: { threadId: thread.id }, | ||
| 365 | }); | ||
| 366 | if (result._tag === "Failure") { | ||
| 367 | const error = Cause.squash(result.cause); | ||
| 368 | Alert.alert( | ||
| 369 | "Could not unpin thread", | ||
| 370 | error instanceof Error && error.message.trim().length > 0 | ||
| 371 | ? error.message | ||
| 372 | : "The thread could not be unpinned.", | ||
| 373 | ); | ||
| 374 | return false; | ||
| 375 | } | ||
| 376 | return true; | ||
| 377 | }, | ||
| 378 | [unpinMutation], | ||
| 379 | ); | ||
| 313 | 380 | ||
| 314 | const confirmDeleteThread = useConfirmDeleteThread(executeAction); | 381 | const confirmDeleteThread = useConfirmDeleteThread(executeAction); |
| 315 | 382 | ||
| ... | @@ -320,6 +387,8 @@ export function useThreadListActions(): { | ... | @@ -320,6 +387,8 @@ export function useThreadListActions(): { |
| 320 | snoozeThread, | 387 | snoozeThread, |
| 321 | unsnoozeThread, | 388 | unsnoozeThread, |
| 322 | unsettleThread, | 389 | unsettleThread, |
| 390 | pinThread, | ||
| 391 | unpinThread, | ||
| 323 | }; | 392 | }; |
| 324 | } | 393 | } |
| 325 | 394 |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+19| ... | @@ -205,6 +205,8 @@ function ThreadNavigationSidebarPane( | ... | @@ -205,6 +205,8 @@ function ThreadNavigationSidebarPane( |
| 205 | snoozeThread, | 205 | snoozeThread, |
| 206 | unsnoozeThread, | 206 | unsnoozeThread, |
| 207 | unsettleThread, | 207 | unsettleThread, |
| 208 | pinThread, | ||
| 209 | unpinThread, | ||
| 208 | } = useThreadListActions(); | 210 | } = useThreadListActions(); |
| 209 | const threadListV2Enabled = useThreadListV2Enabled(); | 211 | const threadListV2Enabled = useThreadListV2Enabled(); |
| 210 | const pendingTasks = usePendingNewTasks(); | 212 | const pendingTasks = usePendingNewTasks(); |
| ... | @@ -481,6 +483,15 @@ function ThreadNavigationSidebarPane( | ... | @@ -481,6 +483,15 @@ function ThreadNavigationSidebarPane( |
| 481 | } | 483 | } |
| 482 | return supported; | 484 | return supported; |
| 483 | }, [serverConfigs]); | 485 | }, [serverConfigs]); |
| 486 | const pinningEnvironmentIds = useMemo(() => { | ||
| 487 | const supported = new Set<EnvironmentId>(); | ||
| 488 | for (const [environmentId, config] of serverConfigs) { | ||
| 489 | if (config.environment.capabilities.threadPinning === true) { | ||
| 490 | supported.add(environmentId); | ||
| 491 | } | ||
| 492 | } | ||
| 493 | return supported; | ||
| 494 | }, [serverConfigs]); | ||
| 484 | const threadListV2Layout = useMemo(() => { | 495 | const threadListV2Layout = useMemo(() => { |
| 485 | if (!threadListV2Enabled) | 496 | if (!threadListV2Enabled) |
| 486 | return { | 497 | return { |
| ... | @@ -792,6 +803,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -792,6 +803,7 @@ function ThreadNavigationSidebarPane( |
| 792 | previous.item.thread === item.item.thread && | 803 | previous.item.thread === item.item.thread && |
| 793 | previous.item.variant === item.item.variant && | 804 | previous.item.variant === item.item.variant && |
| 794 | previous.item.snoozed === item.item.snoozed && | 805 | previous.item.snoozed === item.item.snoozed && |
| 806 | previous.item.pinned === item.item.pinned && | ||
| 795 | previous.snoozeWakeLabelText === item.snoozeWakeLabelText | 807 | previous.snoozeWakeLabelText === item.snoozeWakeLabelText |
| 796 | ); | 808 | ); |
| 797 | } | 809 | } |
| ... | @@ -879,6 +891,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -879,6 +891,7 @@ function ThreadNavigationSidebarPane( |
| 879 | thread={thread} | 891 | thread={thread} |
| 880 | variant={item.item.variant} | 892 | variant={item.item.variant} |
| 881 | snoozed={item.item.snoozed} | 893 | snoozed={item.item.snoozed} |
| 894 | pinned={item.item.pinned} | ||
| 882 | snoozePresetMinute={nowMinute} | 895 | snoozePresetMinute={nowMinute} |
| 883 | snoozeWakeLabelText={item.snoozeWakeLabelText} | 896 | snoozeWakeLabelText={item.snoozeWakeLabelText} |
| 884 | project={projectByKey.get(scopeKey) ?? null} | 897 | project={projectByKey.get(scopeKey) ?? null} |
| ... | @@ -915,9 +928,12 @@ function ThreadNavigationSidebarPane( | ... | @@ -915,9 +928,12 @@ function ThreadNavigationSidebarPane( |
| 915 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} | 928 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} |
| 916 | onSettleThread={settleThread} | 929 | onSettleThread={settleThread} |
| 917 | snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} | 930 | snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} |
| 931 | pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} | ||
| 918 | onSnoozeThread={snoozeThread} | 932 | onSnoozeThread={snoozeThread} |
| 919 | onUnsnoozeThread={unsnoozeThread} | 933 | onUnsnoozeThread={unsnoozeThread} |
| 920 | onUnsettleThread={unsettleThread} | 934 | onUnsettleThread={unsettleThread} |
| 935 | onPinThread={pinThread} | ||
| 936 | onUnpinThread={unpinThread} | ||
| 921 | onChangeRequestState={handleChangeRequestState} | 937 | onChangeRequestState={handleChangeRequestState} |
| 922 | projectCwd={projectCwdByKey.get(scopeKey) ?? null} | 938 | projectCwd={projectCwdByKey.get(scopeKey) ?? null} |
| 923 | onSwipeableClose={handleSwipeableClose} | 939 | onSwipeableClose={handleSwipeableClose} |
| ... | @@ -1045,6 +1061,8 @@ function ThreadNavigationSidebarPane( | ... | @@ -1045,6 +1061,8 @@ function ThreadNavigationSidebarPane( |
| 1045 | handleSwipeableClose, | 1061 | handleSwipeableClose, |
| 1046 | handleSwipeableWillOpen, | 1062 | handleSwipeableWillOpen, |
| 1047 | openPendingTask, | 1063 | openPendingTask, |
| 1064 | pinThread, | ||
| 1065 | pinningEnvironmentIds, | ||
| 1048 | projectByKey, | 1066 | projectByKey, |
| 1049 | projectCwdByKey, | 1067 | projectCwdByKey, |
| 1050 | projectTitleByProjectKey, | 1068 | projectTitleByProjectKey, |
| ... | @@ -1064,6 +1082,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -1064,6 +1082,7 @@ function ThreadNavigationSidebarPane( |
| 1064 | nowMinute, | 1082 | nowMinute, |
| 1065 | toggleSettledShelf, | 1083 | toggleSettledShelf, |
| 1066 | toggleSnoozedShelf, | 1084 | toggleSnoozedShelf, |
| 1085 | unpinThread, | ||
| 1067 | unsettleThread, | 1086 | unsettleThread, |
| 1068 | unsnoozeThread, | 1087 | unsnoozeThread, |
| 1069 | updateGroupDisplay, | 1088 | updateGroupDisplay, |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+40-2| ... | @@ -305,6 +305,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -305,6 +305,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 305 | readonly variant: "card" | "slim"; | 305 | readonly variant: "card" | "slim"; |
| 306 | /** Snoozed-shelf row: shows its wake time and offers Wake. */ | 306 | /** Snoozed-shelf row: shows its wake time and offers Wake. */ |
| 307 | readonly snoozed?: boolean; | 307 | readonly snoozed?: boolean; |
| 308 | /** Pinned-block row: shows the pin glyph and offers Unpin. */ | ||
| 309 | readonly pinned?: boolean; | ||
| 308 | /** Preformatted against the parent minute tick so this memoized row's | 310 | /** Preformatted against the parent minute tick so this memoized row's |
| 309 | countdown keeps moving. */ | 311 | countdown keeps moving. */ |
| 310 | readonly snoozeWakeLabelText?: string; | 312 | readonly snoozeWakeLabelText?: string; |
| ... | @@ -337,11 +339,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -337,11 +339,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 337 | readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void; | 339 | readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void; |
| 338 | readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; | 340 | readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; |
| 339 | readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; | 341 | readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; |
| 342 | readonly onPinThread: (thread: EnvironmentThreadShell) => void; | ||
| 343 | readonly onUnpinThread: (thread: EnvironmentThreadShell) => void; | ||
| 340 | /** False on environments whose server predates thread.settle/unsettle: | 344 | /** False on environments whose server predates thread.settle/unsettle: |
| 341 | swipe + menu fall back to Archive instead of failing on use. */ | 345 | swipe + menu fall back to Archive instead of failing on use. */ |
| 342 | readonly settlementSupported: boolean; | 346 | readonly settlementSupported: boolean; |
| 343 | /** False on servers that predate thread.snooze/unsnooze. */ | 347 | /** False on servers that predate thread.snooze/unsnooze. */ |
| 344 | readonly snoozeSupported: boolean; | 348 | readonly snoozeSupported: boolean; |
| 349 | /** False on servers that predate thread.pin/unpin. */ | ||
| 350 | readonly pinningSupported: boolean; | ||
| 345 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; | 351 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; |
| 346 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; | 352 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; |
| 347 | /** Reports this row's live PR state up so the partition can auto-settle | 353 | /** Reports this row's live PR state up so the partition can auto-settle |
| ... | @@ -368,9 +374,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -368,9 +374,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 368 | onUnsnoozeThread, | 374 | onUnsnoozeThread, |
| 369 | onUnsettleThread, | 375 | onUnsettleThread, |
| 370 | onArchiveThread, | 376 | onArchiveThread, |
| 377 | onPinThread, | ||
| 378 | onUnpinThread, | ||
| 371 | onChangeRequestState, | 379 | onChangeRequestState, |
| 372 | } = props; | 380 | } = props; |
| 373 | const snoozedRow = props.snoozed === true; | 381 | const snoozedRow = props.snoozed === true; |
| 382 | const pinnedRow = props.pinned === true; | ||
| 374 | 383 | ||
| 375 | const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); | 384 | const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); |
| 376 | const prState = pr?.state ?? null; | 385 | const prState = pr?.state ?? null; |
| ... | @@ -383,6 +392,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -383,6 +392,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 383 | const drawerColor = useThemeColor("--color-drawer"); | 392 | const drawerColor = useThemeColor("--color-drawer"); |
| 384 | const pressedBackgroundColor = useThemeColor("--color-subtle"); | 393 | const pressedBackgroundColor = useThemeColor("--color-subtle"); |
| 385 | const selectedBackgroundColor = useThemeColor("--color-user-bubble"); | 394 | const selectedBackgroundColor = useThemeColor("--color-user-bubble"); |
| 395 | const pinTintColor = useThemeColor("--color-foreground-muted"); | ||
| 386 | const sidebarPane = props.pane === "sidebar"; | 396 | const sidebarPane = props.pane === "sidebar"; |
| 387 | const selected = props.selected === true; | 397 | const selected = props.selected === true; |
| 388 | 398 | ||
| ... | @@ -398,6 +408,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -398,6 +408,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 398 | ); | 408 | ); |
| 399 | const handleUnsnooze = useCallback(() => onUnsnoozeThread(thread), [onUnsnoozeThread, thread]); | 409 | const handleUnsnooze = useCallback(() => onUnsnoozeThread(thread), [onUnsnoozeThread, thread]); |
| 400 | const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); | 410 | const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); |
| 411 | const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]); | ||
| 412 | const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]); | ||
| 401 | const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); | 413 | const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); |
| 402 | 414 | ||
| 403 | // Swipe: the v2 primary action is the lifecycle transition. Every settled | 415 | // Swipe: the v2 primary action is the lifecycle transition. Every settled |
| ... | @@ -434,6 +446,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -434,6 +446,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 434 | })), | 446 | })), |
| 435 | [snoozePresets], | 447 | [snoozePresets], |
| 436 | ); | 448 | ); |
| 449 | // Pinned cards keep the full lifecycle menu; only the pin item flips to | ||
| 450 | // Unpin. (Settling a pinned thread clears the pin server-side; snoozing | ||
| 451 | // hides the card until wake with the pin intact.) | ||
| 452 | const pinMenuItem = useMemo<MenuAction[]>( | ||
| 453 | () => | ||
| 454 | props.pinningSupported | ||
| 455 | ? [ | ||
| 456 | pinnedRow | ||
| 457 | ? { id: "unpin", title: "Unpin", image: "pin.slash" } | ||
| 458 | : { id: "pin", title: "Pin", image: "pin" }, | ||
| 459 | ] | ||
| 460 | : [], | ||
| 461 | [pinnedRow, props.pinningSupported], | ||
| 462 | ); | ||
| 437 | const snoozableCardMenuActions = useMemo<MenuAction[]>( | 463 | const snoozableCardMenuActions = useMemo<MenuAction[]>( |
| 438 | () => [ | 464 | () => [ |
| 439 | { id: "settle", title: "Settle", image: "checkmark" }, | 465 | { id: "settle", title: "Settle", image: "checkmark" }, |
| ... | @@ -443,15 +469,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -443,15 +469,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 443 | image: "clock", | 469 | image: "clock", |
| 444 | subactions: snoozePresetActions, | 470 | subactions: snoozePresetActions, |
| 445 | }, | 471 | }, |
| 472 | ...pinMenuItem, | ||
| 446 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, | 473 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, |
| 447 | ], | 474 | ], |
| 448 | [snoozePresetActions], | 475 | [pinMenuItem, snoozePresetActions], |
| 476 | ); | ||
| 477 | const cardMenuActions = useMemo<MenuAction[]>( | ||
| 478 | () => [CARD_MENU_ACTIONS[0]!, ...pinMenuItem, ...CARD_MENU_ACTIONS.slice(1)], | ||
| 479 | [pinMenuItem], | ||
| 449 | ); | 480 | ); |
| 450 | const handleMenuAction = useCallback( | 481 | const handleMenuAction = useCallback( |
| 451 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { | 482 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { |
| 452 | if (nativeEvent.event === "settle") handleSettle(); | 483 | if (nativeEvent.event === "settle") handleSettle(); |
| 453 | if (nativeEvent.event === "unsettle") handleUnsettle(); | 484 | if (nativeEvent.event === "unsettle") handleUnsettle(); |
| 454 | if (nativeEvent.event === "unsnooze") handleUnsnooze(); | 485 | if (nativeEvent.event === "unsnooze") handleUnsnooze(); |
| 486 | if (nativeEvent.event === "pin") handlePin(); | ||
| 487 | if (nativeEvent.event === "unpin") handleUnpin(); | ||
| 455 | if (nativeEvent.event === "archive") handleArchive(); | 488 | if (nativeEvent.event === "archive") handleArchive(); |
| 456 | if (nativeEvent.event === "delete") handleDelete(); | 489 | if (nativeEvent.event === "delete") handleDelete(); |
| 457 | const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ | 490 | const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ |
| ... | @@ -468,8 +501,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -468,8 +501,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 468 | [ | 501 | [ |
| 469 | handleArchive, | 502 | handleArchive, |
| 470 | handleDelete, | 503 | handleDelete, |
| 504 | handlePin, | ||
| 471 | handleSettle, | 505 | handleSettle, |
| 472 | handleSnooze, | 506 | handleSnooze, |
| 507 | handleUnpin, | ||
| 473 | handleUnsettle, | 508 | handleUnsettle, |
| 474 | handleUnsnooze, | 509 | handleUnsnooze, |
| 475 | snoozePresets, | 510 | snoozePresets, |
| ... | @@ -560,6 +595,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -560,6 +595,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 560 | > | 595 | > |
| 561 | {props.projectTitle ?? props.project?.title ?? ""} | 596 | {props.projectTitle ?? props.project?.title ?? ""} |
| 562 | </Text> | 597 | </Text> |
| 598 | {pinnedRow ? ( | ||
| 599 | <SymbolView name="pin" size={11} tintColor={pinTintColor} type="monochrome" /> | ||
| 600 | ) : null} | ||
| 563 | <Text | 601 | <Text |
| 564 | className={cn( | 602 | className={cn( |
| 565 | "text-xs tabular-nums", | 603 | "text-xs tabular-nums", |
| ... | @@ -805,7 +843,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -805,7 +843,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 805 | ? SLIM_MENU_ACTIONS | 843 | ? SLIM_MENU_ACTIONS |
| 806 | : swipeActions.secondary === "snooze" | 844 | : swipeActions.secondary === "snooze" |
| 807 | ? snoozableCardMenuActions | 845 | ? snoozableCardMenuActions |
| 808 | : CARD_MENU_ACTIONS | 846 | : cardMenuActions |
| 809 | } | 847 | } |
| 810 | onPressAction={handleMenuAction} | 848 | onPressAction={handleMenuAction} |
| 811 | shouldOpenOnLongPress | 849 | shouldOpenOnLongPress |
apps/mobile/src/features/threads/threadListV2.test.ts+51| ... | @@ -288,6 +288,57 @@ describe("buildThreadListV2Items", () => { | ... | @@ -288,6 +288,57 @@ describe("buildThreadListV2Items", () => { |
| 288 | expect(layout.snoozedCount).toBe(1); | 288 | expect(layout.snoozedCount).toBe(1); |
| 289 | }); | 289 | }); |
| 290 | 290 | ||
| 291 | it("renders pinned threads first and exempts them from auto-settle — parity with web", () => { | ||
| 292 | const layout = buildThreadListV2Items({ | ||
| 293 | threads: [ | ||
| 294 | makeThread({ id: ThreadId.make("active"), title: "Active" }), | ||
| 295 | makeThread({ | ||
| 296 | id: ThreadId.make("pinned-settled"), | ||
| 297 | title: "Pinned while settled", | ||
| 298 | pinnedAt: "2026-06-01T12:00:00.000Z", | ||
| 299 | // Stale settled state (the decider clears it on pin): the pin wins. | ||
| 300 | settledOverride: "settled", | ||
| 301 | settledAt: "2026-06-01T12:00:00.000Z", | ||
| 302 | }), | ||
| 303 | ], | ||
| 304 | environmentId: null, | ||
| 305 | searchQuery: "", | ||
| 306 | now: NOW, | ||
| 307 | }); | ||
| 308 | |||
| 309 | expect(layout.items.map((item) => item.thread.id)).toEqual(["pinned-settled", "active"]); | ||
| 310 | expect(layout.items.map((item) => item.pinned)).toEqual([true, false]); | ||
| 311 | expect(layout.settledCount).toBe(0); | ||
| 312 | }); | ||
| 313 | |||
| 314 | it("snooze hides a pinned thread and wake restores it to the pinned block", () => { | ||
| 315 | const snoozedInput = { | ||
| 316 | threads: [ | ||
| 317 | makeThread({ id: ThreadId.make("active"), title: "Active" }), | ||
| 318 | makeThread({ | ||
| 319 | id: ThreadId.make("pinned-snoozed"), | ||
| 320 | title: "Pinned and snoozed", | ||
| 321 | pinnedAt: "2026-06-01T12:00:00.000Z", | ||
| 322 | snoozedUntil: "2026-06-03T09:00:00.000Z", | ||
| 323 | snoozedAt: "2026-06-01T11:00:00.000Z", | ||
| 324 | }), | ||
| 325 | ], | ||
| 326 | environmentId: null, | ||
| 327 | searchQuery: "", | ||
| 328 | }; | ||
| 329 | |||
| 330 | // Before the wake time: the snooze wins; the pin holds underneath. | ||
| 331 | const whileSnoozed = buildThreadListV2Items({ ...snoozedInput, now: NOW }); | ||
| 332 | expect(whileSnoozed.items.map((item) => item.thread.id)).toEqual(["active"]); | ||
| 333 | expect(whileSnoozed.snoozedCount).toBe(1); | ||
| 334 | |||
| 335 | // After the wake time: the thread returns pinned, back on top. | ||
| 336 | const afterWake = buildThreadListV2Items({ ...snoozedInput, now: "2026-06-03T10:00:00.000Z" }); | ||
| 337 | expect(afterWake.items.map((item) => item.thread.id)).toEqual(["pinned-snoozed", "active"]); | ||
| 338 | expect(afterWake.items[0]?.pinned).toBe(true); | ||
| 339 | expect(afterWake.snoozedCount).toBe(0); | ||
| 340 | }); | ||
| 341 | |||
| 291 | it("classifies snooze with the second-precise clock and reports the next wake", () => { | 342 | it("classifies snooze with the second-precise clock and reports the next wake", () => { |
| 292 | const layout = buildThreadListV2Items({ | 343 | const layout = buildThreadListV2Items({ |
| 293 | threads: [ | 344 | threads: [ |
apps/mobile/src/features/threads/threadListV2.ts+25-3| ... | @@ -180,6 +180,8 @@ export interface ThreadListV2Item { | ... | @@ -180,6 +180,8 @@ export interface ThreadListV2Item { |
| 180 | readonly variant: "card" | "slim"; | 180 | readonly variant: "card" | "slim"; |
| 181 | /** Snoozed-shelf row: shows the wake countdown and offers Wake. */ | 181 | /** Snoozed-shelf row: shows the wake countdown and offers Wake. */ |
| 182 | readonly snoozed: boolean; | 182 | readonly snoozed: boolean; |
| 183 | /** Pinned-block row: renders the pin glyph and offers Unpin. */ | ||
| 184 | readonly pinned: boolean; | ||
| 183 | readonly isLast: boolean; | 185 | readonly isLast: boolean; |
| 184 | } | 186 | } |
| 185 | 187 | ||
| ... | @@ -351,6 +353,7 @@ export function buildThreadListV2Items(input: { | ... | @@ -351,6 +353,7 @@ export function buildThreadListV2Items(input: { |
| 351 | ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) | 353 | ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) |
| 352 | : null; | 354 | : null; |
| 353 | 355 | ||
| 356 | const pinned: EnvironmentThreadShell[] = []; | ||
| 354 | const active: EnvironmentThreadShell[] = []; | 357 | const active: EnvironmentThreadShell[] = []; |
| 355 | const settled: EnvironmentThreadShell[] = []; | 358 | const settled: EnvironmentThreadShell[] = []; |
| 356 | const snoozed: EnvironmentThreadShell[] = []; | 359 | const snoozed: EnvironmentThreadShell[] = []; |
| ... | @@ -378,9 +381,10 @@ export function buildThreadListV2Items(input: { | ... | @@ -378,9 +381,10 @@ export function buildThreadListV2Items(input: { |
| 378 | const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; | 381 | const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; |
| 379 | const changeRequestState = | 382 | const changeRequestState = |
| 380 | input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; | 383 | input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; |
| 381 | // Visibility parity with web: a snoozed thread leaves the list until it | 384 | // Visibility parity with web: snooze outranks everything, including a |
| 382 | // wakes (or raises its hand — effectiveSnoozed refuses blocked/failed | 385 | // pin — a snoozed thread leaves the list until it wakes (or raises its |
| 383 | // work). Snooze outranks settled classification, same as web. | 386 | // hand). The pin survives underneath, so a woken thread reappears at |
| 387 | // its original spot in the creation-ordered pinned block. | ||
| 384 | if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { | 388 | if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { |
| 385 | snoozed.push(thread); | 389 | snoozed.push(thread); |
| 386 | if ( | 390 | if ( |
| ... | @@ -392,6 +396,12 @@ export function buildThreadListV2Items(input: { | ... | @@ -392,6 +396,12 @@ export function buildThreadListV2Items(input: { |
| 392 | } | 396 | } |
| 393 | continue; | 397 | continue; |
| 394 | } | 398 | } |
| 399 | // A pin otherwise overrides the lifecycle: pinned threads render above | ||
| 400 | // the inbox and never auto-settle out of sight. | ||
| 401 | if (thread.pinnedAt != null) { | ||
| 402 | pinned.push(thread); | ||
| 403 | continue; | ||
| 404 | } | ||
| 395 | if ( | 405 | if ( |
| 396 | supportsSettlement && | 406 | supportsSettlement && |
| 397 | effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) | 407 | effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) |
| ... | @@ -434,11 +444,21 @@ export function buildThreadListV2Items(input: { | ... | @@ -434,11 +444,21 @@ export function buildThreadListV2Items(input: { |
| 434 | ); | 444 | ); |
| 435 | 445 | ||
| 436 | const items: ThreadListV2Item[] = []; | 446 | const items: ThreadListV2Item[] = []; |
| 447 | for (const thread of sortThreadsForListV2(pinned)) { | ||
| 448 | items.push({ | ||
| 449 | thread, | ||
| 450 | variant: "card", | ||
| 451 | snoozed: false, | ||
| 452 | pinned: true, | ||
| 453 | isLast: false, | ||
| 454 | }); | ||
| 455 | } | ||
| 437 | for (const thread of orderedActive) { | 456 | for (const thread of orderedActive) { |
| 438 | items.push({ | 457 | items.push({ |
| 439 | thread, | 458 | thread, |
| 440 | variant: "card", | 459 | variant: "card", |
| 441 | snoozed: false, | 460 | snoozed: false, |
| 461 | pinned: false, | ||
| 442 | isLast: false, | 462 | isLast: false, |
| 443 | }); | 463 | }); |
| 444 | } | 464 | } |
| ... | @@ -448,6 +468,7 @@ export function buildThreadListV2Items(input: { | ... | @@ -448,6 +468,7 @@ export function buildThreadListV2Items(input: { |
| 448 | thread, | 468 | thread, |
| 449 | variant: "slim", | 469 | variant: "slim", |
| 450 | snoozed: true, | 470 | snoozed: true, |
| 471 | pinned: false, | ||
| 451 | isLast: false, | 472 | isLast: false, |
| 452 | }); | 473 | }); |
| 453 | } | 474 | } |
| ... | @@ -457,6 +478,7 @@ export function buildThreadListV2Items(input: { | ... | @@ -457,6 +478,7 @@ export function buildThreadListV2Items(input: { |
| 457 | thread, | 478 | thread, |
| 458 | variant: "slim", | 479 | variant: "slim", |
| 459 | snoozed: false, | 480 | snoozed: false, |
| 481 | pinned: false, | ||
| 460 | isLast: false, | 482 | isLast: false, |
| 461 | }); | 483 | }); |
| 462 | } | 484 | } |
apps/server/src/environment/ServerEnvironment.ts+1| ... | @@ -145,6 +145,7 @@ export const make = Effect.gen(function* () { | ... | @@ -145,6 +145,7 @@ export const make = Effect.gen(function* () { |
| 145 | connectionProbe: true, | 145 | connectionProbe: true, |
| 146 | threadSettlement: true, | 146 | threadSettlement: true, |
| 147 | threadSnooze: true, | 147 | threadSnooze: true, |
| 148 | threadPinning: true, | ||
| 148 | threadTitleRegeneration: true, | 149 | threadTitleRegeneration: true, |
| 149 | ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), | 150 | ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), |
| 150 | ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), | 151 | ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+31| ... | @@ -611,6 +611,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -611,6 +611,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 611 | settledAt: null, | 611 | settledAt: null, |
| 612 | snoozedUntil: null, | 612 | snoozedUntil: null, |
| 613 | snoozedAt: null, | 613 | snoozedAt: null, |
| 614 | pinnedAt: null, | ||
| 614 | titleRegenerationRequestId: null, | 615 | titleRegenerationRequestId: null, |
| 615 | titleRegenerationStartedAt: null, | 616 | titleRegenerationStartedAt: null, |
| 616 | latestUserMessageAt: null, | 617 | latestUserMessageAt: null, |
| ... | @@ -717,6 +718,36 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -717,6 +718,36 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 717 | return; | 718 | return; |
| 718 | } | 719 | } |
| 719 | 720 | ||
| 721 | case "thread.pinned": { | ||
| 722 | const existingRow = yield* projectionThreadRepository.getById({ | ||
| 723 | threadId: event.payload.threadId, | ||
| 724 | }); | ||
| 725 | if (Option.isNone(existingRow)) { | ||
| 726 | return; | ||
| 727 | } | ||
| 728 | yield* projectionThreadRepository.upsert({ | ||
| 729 | ...existingRow.value, | ||
| 730 | pinnedAt: event.payload.pinnedAt, | ||
| 731 | updatedAt: event.payload.updatedAt, | ||
| 732 | }); | ||
| 733 | return; | ||
| 734 | } | ||
| 735 | |||
| 736 | case "thread.unpinned": { | ||
| 737 | const existingRow = yield* projectionThreadRepository.getById({ | ||
| 738 | threadId: event.payload.threadId, | ||
| 739 | }); | ||
| 740 | if (Option.isNone(existingRow)) { | ||
| 741 | return; | ||
| 742 | } | ||
| 743 | yield* projectionThreadRepository.upsert({ | ||
| 744 | ...existingRow.value, | ||
| 745 | pinnedAt: null, | ||
| 746 | updatedAt: event.payload.updatedAt, | ||
| 747 | }); | ||
| 748 | return; | ||
| 749 | } | ||
| 750 | |||
| 720 | case "thread.meta-updated": { | 751 | case "thread.meta-updated": { |
| 721 | const existingRow = yield* projectionThreadRepository.getById({ | 752 | const existingRow = yield* projectionThreadRepository.getById({ |
| 722 | threadId: event.payload.threadId, | 753 | threadId: event.payload.threadId, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2| ... | @@ -312,6 +312,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -312,6 +312,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 312 | settledAt: null, | 312 | settledAt: null, |
| 313 | snoozedUntil: null, | 313 | snoozedUntil: null, |
| 314 | snoozedAt: null, | 314 | snoozedAt: null, |
| 315 | pinnedAt: null, | ||
| 315 | titleRegeneration: null, | 316 | titleRegeneration: null, |
| 316 | deletedAt: null, | 317 | deletedAt: null, |
| 317 | messages: [ | 318 | messages: [ |
| ... | @@ -427,6 +428,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -427,6 +428,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 427 | settledAt: null, | 428 | settledAt: null, |
| 428 | snoozedUntil: null, | 429 | snoozedUntil: null, |
| 429 | snoozedAt: null, | 430 | snoozedAt: null, |
| 431 | pinnedAt: null, | ||
| 430 | titleRegeneration: null, | 432 | titleRegeneration: null, |
| 431 | session: { | 433 | session: { |
| 432 | threadId: ThreadId.make("thread-1"), | 434 | threadId: ThreadId.make("thread-1"), |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+10| ... | @@ -384,6 +384,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -384,6 +384,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 384 | settled_at AS "settledAt", | 384 | settled_at AS "settledAt", |
| 385 | snoozed_until AS "snoozedUntil", | 385 | snoozed_until AS "snoozedUntil", |
| 386 | snoozed_at AS "snoozedAt", | 386 | snoozed_at AS "snoozedAt", |
| 387 | pinned_at AS "pinnedAt", | ||
| 387 | title_regeneration_request_id AS "titleRegenerationRequestId", | 388 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 388 | title_regeneration_started_at AS "titleRegenerationStartedAt", | 389 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 389 | latest_user_message_at AS "latestUserMessageAt", | 390 | latest_user_message_at AS "latestUserMessageAt", |
| ... | @@ -418,6 +419,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -418,6 +419,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 418 | settled_at AS "settledAt", | 419 | settled_at AS "settledAt", |
| 419 | snoozed_until AS "snoozedUntil", | 420 | snoozed_until AS "snoozedUntil", |
| 420 | snoozed_at AS "snoozedAt", | 421 | snoozed_at AS "snoozedAt", |
| 422 | pinned_at AS "pinnedAt", | ||
| 421 | title_regeneration_request_id AS "titleRegenerationRequestId", | 423 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 422 | title_regeneration_started_at AS "titleRegenerationStartedAt", | 424 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 423 | latest_user_message_at AS "latestUserMessageAt", | 425 | latest_user_message_at AS "latestUserMessageAt", |
| ... | @@ -454,6 +456,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -454,6 +456,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 454 | settled_at AS "settledAt", | 456 | settled_at AS "settledAt", |
| 455 | snoozed_until AS "snoozedUntil", | 457 | snoozed_until AS "snoozedUntil", |
| 456 | snoozed_at AS "snoozedAt", | 458 | snoozed_at AS "snoozedAt", |
| 459 | pinned_at AS "pinnedAt", | ||
| 457 | title_regeneration_request_id AS "titleRegenerationRequestId", | 460 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 458 | title_regeneration_started_at AS "titleRegenerationStartedAt", | 461 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 459 | latest_user_message_at AS "latestUserMessageAt", | 462 | latest_user_message_at AS "latestUserMessageAt", |
| ... | @@ -890,6 +893,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -890,6 +893,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 890 | settled_at AS "settledAt", | 893 | settled_at AS "settledAt", |
| 891 | snoozed_until AS "snoozedUntil", | 894 | snoozed_until AS "snoozedUntil", |
| 892 | snoozed_at AS "snoozedAt", | 895 | snoozed_at AS "snoozedAt", |
| 896 | pinned_at AS "pinnedAt", | ||
| 893 | title_regeneration_request_id AS "titleRegenerationRequestId", | 897 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 894 | title_regeneration_started_at AS "titleRegenerationStartedAt", | 898 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 895 | latest_user_message_at AS "latestUserMessageAt", | 899 | latest_user_message_at AS "latestUserMessageAt", |
| ... | @@ -1328,6 +1332,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -1328,6 +1332,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1328 | settledAt: row.settledAt, | 1332 | settledAt: row.settledAt, |
| 1329 | snoozedUntil: row.snoozedUntil, | 1333 | snoozedUntil: row.snoozedUntil, |
| 1330 | snoozedAt: row.snoozedAt, | 1334 | snoozedAt: row.snoozedAt, |
| 1335 | pinnedAt: row.pinnedAt, | ||
| 1331 | titleRegeneration: mapTitleRegeneration(row), | 1336 | titleRegeneration: mapTitleRegeneration(row), |
| 1332 | deletedAt: row.deletedAt, | 1337 | deletedAt: row.deletedAt, |
| 1333 | messages: messagesByThread.get(row.threadId) ?? [], | 1338 | messages: messagesByThread.get(row.threadId) ?? [], |
| ... | @@ -1531,6 +1536,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -1531,6 +1536,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1531 | settledAt: row.settledAt, | 1536 | settledAt: row.settledAt, |
| 1532 | snoozedUntil: row.snoozedUntil, | 1537 | snoozedUntil: row.snoozedUntil, |
| 1533 | snoozedAt: row.snoozedAt, | 1538 | snoozedAt: row.snoozedAt, |
| 1539 | pinnedAt: row.pinnedAt, | ||
| 1534 | titleRegeneration: mapTitleRegeneration(row), | 1540 | titleRegeneration: mapTitleRegeneration(row), |
| 1535 | deletedAt: row.deletedAt, | 1541 | deletedAt: row.deletedAt, |
| 1536 | messages: [], | 1542 | messages: [], |
| ... | @@ -1665,6 +1671,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -1665,6 +1671,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1665 | settledAt: row.settledAt, | 1671 | settledAt: row.settledAt, |
| 1666 | snoozedUntil: row.snoozedUntil, | 1672 | snoozedUntil: row.snoozedUntil, |
| 1667 | snoozedAt: row.snoozedAt, | 1673 | snoozedAt: row.snoozedAt, |
| 1674 | pinnedAt: row.pinnedAt, | ||
| 1668 | titleRegeneration: mapTitleRegeneration(row), | 1675 | titleRegeneration: mapTitleRegeneration(row), |
| 1669 | session: sessionByThread.get(row.threadId) ?? null, | 1676 | session: sessionByThread.get(row.threadId) ?? null, |
| 1670 | latestUserMessageAt: row.latestUserMessageAt, | 1677 | latestUserMessageAt: row.latestUserMessageAt, |
| ... | @@ -1804,6 +1811,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -1804,6 +1811,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1804 | settledAt: row.settledAt, | 1811 | settledAt: row.settledAt, |
| 1805 | snoozedUntil: row.snoozedUntil, | 1812 | snoozedUntil: row.snoozedUntil, |
| 1806 | snoozedAt: row.snoozedAt, | 1813 | snoozedAt: row.snoozedAt, |
| 1814 | pinnedAt: row.pinnedAt, | ||
| 1807 | titleRegeneration: mapTitleRegeneration(row), | 1815 | titleRegeneration: mapTitleRegeneration(row), |
| 1808 | session: sessionByThread.get(row.threadId) ?? null, | 1816 | session: sessionByThread.get(row.threadId) ?? null, |
| 1809 | latestUserMessageAt: row.latestUserMessageAt, | 1817 | latestUserMessageAt: row.latestUserMessageAt, |
| ... | @@ -2075,6 +2083,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -2075,6 +2083,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 2075 | settledAt: threadRow.value.settledAt, | 2083 | settledAt: threadRow.value.settledAt, |
| 2076 | snoozedUntil: threadRow.value.snoozedUntil, | 2084 | snoozedUntil: threadRow.value.snoozedUntil, |
| 2077 | snoozedAt: threadRow.value.snoozedAt, | 2085 | snoozedAt: threadRow.value.snoozedAt, |
| 2086 | pinnedAt: threadRow.value.pinnedAt, | ||
| 2078 | titleRegeneration: mapTitleRegeneration(threadRow.value), | 2087 | titleRegeneration: mapTitleRegeneration(threadRow.value), |
| 2079 | session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, | 2088 | session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, |
| 2080 | latestUserMessageAt: threadRow.value.latestUserMessageAt, | 2089 | latestUserMessageAt: threadRow.value.latestUserMessageAt, |
| ... | @@ -2174,6 +2183,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -2174,6 +2183,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 2174 | settledAt: threadRow.value.settledAt, | 2183 | settledAt: threadRow.value.settledAt, |
| 2175 | snoozedUntil: threadRow.value.snoozedUntil, | 2184 | snoozedUntil: threadRow.value.snoozedUntil, |
| 2176 | snoozedAt: threadRow.value.snoozedAt, | 2185 | snoozedAt: threadRow.value.snoozedAt, |
| 2186 | pinnedAt: threadRow.value.pinnedAt, | ||
| 2177 | titleRegeneration: mapTitleRegeneration(threadRow.value), | 2187 | titleRegeneration: mapTitleRegeneration(threadRow.value), |
| 2178 | deletedAt: null, | 2188 | deletedAt: null, |
| 2179 | messages: messageRows.map((row) => { | 2189 | messages: messageRows.map((row) => { |
apps/server/src/orchestration/Schemas.ts+4| ... | @@ -13,6 +13,8 @@ import { | ... | @@ -13,6 +13,8 @@ import { |
| 13 | ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, | 13 | ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, |
| 14 | ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema, | 14 | ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema, |
| 15 | ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, | 15 | ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, |
| 16 | ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, | ||
| 17 | ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, | ||
| 16 | ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, | 18 | ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, |
| 17 | ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, | 19 | ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, |
| 18 | ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, | 20 | ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, |
| ... | @@ -42,6 +44,8 @@ export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; | ... | @@ -42,6 +44,8 @@ export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; |
| 42 | export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; | 44 | export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; |
| 43 | export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; | 45 | export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; |
| 44 | export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; | 46 | export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; |
| 47 | export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; | ||
| 48 | export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; | ||
| 45 | 49 | ||
| 46 | export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; | 50 | export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; |
| 47 | export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; | 51 | export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; |
apps/server/src/orchestration/decider.pinned.test.ts created+226| ... | @@ -0,0 +1,226 @@ | ||
| 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 PINNED_AT = "1969-12-30T00:00:00.000Z"; | ||
| 16 | |||
| 17 | function makeReadModel(input: { | ||
| 18 | readonly pinnedAt?: string | null; | ||
| 19 | readonly archivedAt?: string | null; | ||
| 20 | readonly settledOverride?: "settled" | "active" | null; | ||
| 21 | readonly settledAt?: string | null; | ||
| 22 | readonly snoozedUntil?: string | null; | ||
| 23 | readonly snoozedAt?: string | null; | ||
| 24 | }): OrchestrationReadModel { | ||
| 25 | return { | ||
| 26 | snapshotSequence: 0, | ||
| 27 | projects: [], | ||
| 28 | threads: [ | ||
| 29 | { | ||
| 30 | id: ThreadId.make("thread-1"), | ||
| 31 | projectId: ProjectId.make("project-1"), | ||
| 32 | title: "Thread", | ||
| 33 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 34 | runtimeMode: "full-access", | ||
| 35 | interactionMode: "default", | ||
| 36 | branch: null, | ||
| 37 | worktreePath: null, | ||
| 38 | latestTurn: null, | ||
| 39 | createdAt: NOW, | ||
| 40 | updatedAt: NOW, | ||
| 41 | archivedAt: input.archivedAt ?? null, | ||
| 42 | settledOverride: input.settledOverride ?? null, | ||
| 43 | settledAt: input.settledAt ?? (input.settledOverride === "settled" ? NOW : null), | ||
| 44 | snoozedUntil: input.snoozedUntil ?? null, | ||
| 45 | snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? PINNED_AT : null), | ||
| 46 | pinnedAt: input.pinnedAt ?? null, | ||
| 47 | deletedAt: null, | ||
| 48 | messages: [], | ||
| 49 | proposedPlans: [], | ||
| 50 | activities: [], | ||
| 51 | checkpoints: [], | ||
| 52 | session: null, | ||
| 53 | }, | ||
| 54 | ], | ||
| 55 | updatedAt: NOW, | ||
| 56 | }; | ||
| 57 | } | ||
| 58 | |||
| 59 | it.layer(NodeServices.layer)("pinned thread decider", (it) => { | ||
| 60 | it.effect("pins a thread, stamping pinnedAt and updatedAt together", () => | ||
| 61 | Effect.gen(function* () { | ||
| 62 | const event = yield* decideOrchestrationCommand({ | ||
| 63 | command: { | ||
| 64 | type: "thread.pin", | ||
| 65 | commandId: CommandId.make("cmd-pin"), | ||
| 66 | threadId: ThreadId.make("thread-1"), | ||
| 67 | }, | ||
| 68 | readModel: makeReadModel({}), | ||
| 69 | }); | ||
| 70 | const events = Array.isArray(event) ? event : [event]; | ||
| 71 | expect(events).toHaveLength(1); | ||
| 72 | expect(events[0]?.type).toBe("thread.pinned"); | ||
| 73 | if (events[0]?.type === "thread.pinned") { | ||
| 74 | expect(events[0].payload.pinnedAt).toBe(events[0].payload.updatedAt); | ||
| 75 | } | ||
| 76 | }), | ||
| 77 | ); | ||
| 78 | |||
| 79 | it.effect("re-pinning preserves the original pinnedAt and updatedAt", () => | ||
| 80 | Effect.gen(function* () { | ||
| 81 | const event = yield* decideOrchestrationCommand({ | ||
| 82 | command: { | ||
| 83 | type: "thread.pin", | ||
| 84 | commandId: CommandId.make("cmd-pin-again"), | ||
| 85 | threadId: ThreadId.make("thread-1"), | ||
| 86 | }, | ||
| 87 | readModel: makeReadModel({ pinnedAt: PINNED_AT }), | ||
| 88 | }); | ||
| 89 | const events = Array.isArray(event) ? event : [event]; | ||
| 90 | expect(events[0]?.type).toBe("thread.pinned"); | ||
| 91 | if (events[0]?.type === "thread.pinned") { | ||
| 92 | expect(events[0].payload.pinnedAt).toBe(PINNED_AT); | ||
| 93 | expect(events[0].payload.updatedAt).toBe(NOW); | ||
| 94 | } | ||
| 95 | }), | ||
| 96 | ); | ||
| 97 | |||
| 98 | it.effect("unpins a pinned thread", () => | ||
| 99 | Effect.gen(function* () { | ||
| 100 | const event = yield* decideOrchestrationCommand({ | ||
| 101 | command: { | ||
| 102 | type: "thread.unpin", | ||
| 103 | commandId: CommandId.make("cmd-unpin"), | ||
| 104 | threadId: ThreadId.make("thread-1"), | ||
| 105 | }, | ||
| 106 | readModel: makeReadModel({ pinnedAt: PINNED_AT }), | ||
| 107 | }); | ||
| 108 | const events = Array.isArray(event) ? event : [event]; | ||
| 109 | expect(events[0]?.type).toBe("thread.unpinned"); | ||
| 110 | if (events[0]?.type === "thread.unpinned") { | ||
| 111 | expect(events[0].payload.updatedAt).not.toBe(NOW); | ||
| 112 | } | ||
| 113 | }), | ||
| 114 | ); | ||
| 115 | |||
| 116 | it.effect("unpinning an unpinned thread preserves updatedAt", () => | ||
| 117 | Effect.gen(function* () { | ||
| 118 | const event = yield* decideOrchestrationCommand({ | ||
| 119 | command: { | ||
| 120 | type: "thread.unpin", | ||
| 121 | commandId: CommandId.make("cmd-unpin-noop"), | ||
| 122 | threadId: ThreadId.make("thread-1"), | ||
| 123 | }, | ||
| 124 | readModel: makeReadModel({}), | ||
| 125 | }); | ||
| 126 | const events = Array.isArray(event) ? event : [event]; | ||
| 127 | expect(events[0]?.type).toBe("thread.unpinned"); | ||
| 128 | if (events[0]?.type === "thread.unpinned") { | ||
| 129 | expect(events[0].payload.updatedAt).toBe(NOW); | ||
| 130 | } | ||
| 131 | }), | ||
| 132 | ); | ||
| 133 | |||
| 134 | it.effect("pinning a settled thread also un-settles it", () => | ||
| 135 | Effect.gen(function* () { | ||
| 136 | const event = yield* decideOrchestrationCommand({ | ||
| 137 | command: { | ||
| 138 | type: "thread.pin", | ||
| 139 | commandId: CommandId.make("cmd-pin-settled"), | ||
| 140 | threadId: ThreadId.make("thread-1"), | ||
| 141 | }, | ||
| 142 | readModel: makeReadModel({ settledOverride: "settled" }), | ||
| 143 | }); | ||
| 144 | const events = Array.isArray(event) ? event : [event]; | ||
| 145 | expect(events.map((entry) => entry.type)).toEqual(["thread.pinned", "thread.unsettled"]); | ||
| 146 | const unsettled = events.find((entry) => entry.type === "thread.unsettled"); | ||
| 147 | if (unsettled?.type === "thread.unsettled") { | ||
| 148 | expect(unsettled.payload.reason).toBe("user"); | ||
| 149 | } | ||
| 150 | }), | ||
| 151 | ); | ||
| 152 | |||
| 153 | it.effect("pinning a snoozed thread also wakes it", () => | ||
| 154 | Effect.gen(function* () { | ||
| 155 | const event = yield* decideOrchestrationCommand({ | ||
| 156 | command: { | ||
| 157 | type: "thread.pin", | ||
| 158 | commandId: CommandId.make("cmd-pin-snoozed"), | ||
| 159 | threadId: ThreadId.make("thread-1"), | ||
| 160 | }, | ||
| 161 | readModel: makeReadModel({ snoozedUntil: "1970-01-02T09:00:00.000Z" }), | ||
| 162 | }); | ||
| 163 | const events = Array.isArray(event) ? event : [event]; | ||
| 164 | expect(events.map((entry) => entry.type)).toEqual(["thread.pinned", "thread.unsnoozed"]); | ||
| 165 | }), | ||
| 166 | ); | ||
| 167 | |||
| 168 | it.effect("pinning an unparked thread emits only thread.pinned", () => | ||
| 169 | Effect.gen(function* () { | ||
| 170 | const event = yield* decideOrchestrationCommand({ | ||
| 171 | command: { | ||
| 172 | type: "thread.pin", | ||
| 173 | commandId: CommandId.make("cmd-pin-plain"), | ||
| 174 | threadId: ThreadId.make("thread-1"), | ||
| 175 | }, | ||
| 176 | readModel: makeReadModel({}), | ||
| 177 | }); | ||
| 178 | const events = Array.isArray(event) ? event : [event]; | ||
| 179 | expect(events.map((entry) => entry.type)).toEqual(["thread.pinned"]); | ||
| 180 | }), | ||
| 181 | ); | ||
| 182 | |||
| 183 | it.effect("settling a pinned thread also unpins it", () => | ||
| 184 | Effect.gen(function* () { | ||
| 185 | const event = yield* decideOrchestrationCommand({ | ||
| 186 | command: { | ||
| 187 | type: "thread.settle", | ||
| 188 | commandId: CommandId.make("cmd-settle-pinned"), | ||
| 189 | threadId: ThreadId.make("thread-1"), | ||
| 190 | }, | ||
| 191 | readModel: makeReadModel({ pinnedAt: PINNED_AT }), | ||
| 192 | }); | ||
| 193 | const events = Array.isArray(event) ? event : [event]; | ||
| 194 | expect(events.map((entry) => entry.type)).toEqual(["thread.settled", "thread.unpinned"]); | ||
| 195 | }), | ||
| 196 | ); | ||
| 197 | |||
| 198 | it.effect("settling an unpinned thread emits no unpin event", () => | ||
| 199 | Effect.gen(function* () { | ||
| 200 | const event = yield* decideOrchestrationCommand({ | ||
| 201 | command: { | ||
| 202 | type: "thread.settle", | ||
| 203 | commandId: CommandId.make("cmd-settle-unpinned"), | ||
| 204 | threadId: ThreadId.make("thread-1"), | ||
| 205 | }, | ||
| 206 | readModel: makeReadModel({}), | ||
| 207 | }); | ||
| 208 | const events = Array.isArray(event) ? event : [event]; | ||
| 209 | expect(events.map((entry) => entry.type)).toEqual(["thread.settled"]); | ||
| 210 | }), | ||
| 211 | ); | ||
| 212 | |||
| 213 | it.effect("rejects pinning an archived thread", () => | ||
| 214 | Effect.gen(function* () { | ||
| 215 | const error = yield* decideOrchestrationCommand({ | ||
| 216 | command: { | ||
| 217 | type: "thread.pin", | ||
| 218 | commandId: CommandId.make("cmd-pin-archived"), | ||
| 219 | threadId: ThreadId.make("thread-1"), | ||
| 220 | }, | ||
| 221 | readModel: makeReadModel({ archivedAt: NOW }), | ||
| 222 | }).pipe(Effect.flip); | ||
| 223 | expect(error._tag).toBe("OrchestrationCommandInvariantError"); | ||
| 224 | }), | ||
| 225 | ); | ||
| 226 | }); | ||
apps/server/src/orchestration/decider.ts+117-2| ... | @@ -487,14 +487,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -487,14 +487,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 487 | // settledAt: the engine rejects zero-event commands, and bulk-settle / | 487 | // settledAt: the engine rejects zero-event commands, and bulk-settle / |
| 488 | // double-click must stay silent no-ops rather than surface errors. | 488 | // double-click must stay silent no-ops rather than surface errors. |
| 489 | const alreadySettled = thread.settledOverride === "settled" && thread.settledAt !== null; | 489 | const alreadySettled = thread.settledOverride === "settled" && thread.settledAt !== null; |
| 490 | return { | 490 | const settledEvent = { |
| 491 | ...(yield* withEventBase({ | 491 | ...(yield* withEventBase({ |
| 492 | aggregateKind: "thread", | 492 | aggregateKind: "thread", |
| 493 | aggregateId: command.threadId, | 493 | aggregateId: command.threadId, |
| 494 | occurredAt, | 494 | occurredAt, |
| 495 | commandId: command.commandId, | 495 | commandId: command.commandId, |
| 496 | })), | 496 | })), |
| 497 | type: "thread.settled", | 497 | type: "thread.settled" as const, |
| 498 | payload: { | 498 | payload: { |
| 499 | threadId: command.threadId, | 499 | threadId: command.threadId, |
| 500 | settledAt: alreadySettled ? thread.settledAt : occurredAt, | 500 | settledAt: alreadySettled ? thread.settledAt : occurredAt, |
| ... | @@ -504,6 +504,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -504,6 +504,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 504 | updatedAt: alreadySettled ? thread.updatedAt : occurredAt, | 504 | updatedAt: alreadySettled ? thread.updatedAt : occurredAt, |
| 505 | }, | 505 | }, |
| 506 | }; | 506 | }; |
| 507 | // Settling is "I'm done with this": it clears a pin the same way it | ||
| 508 | // parks the thread. Without this, settling a pinned thread would only | ||
| 509 | // stamp invisible state — the pin would hold the card in place until | ||
| 510 | // a separate unpin. | ||
| 511 | if (thread.pinnedAt == null) { | ||
| 512 | return settledEvent; | ||
| 513 | } | ||
| 514 | return [ | ||
| 515 | settledEvent, | ||
| 516 | { | ||
| 517 | ...(yield* withEventBase({ | ||
| 518 | aggregateKind: "thread", | ||
| 519 | aggregateId: command.threadId, | ||
| 520 | occurredAt, | ||
| 521 | commandId: command.commandId, | ||
| 522 | })), | ||
| 523 | type: "thread.unpinned" as const, | ||
| 524 | payload: { | ||
| 525 | threadId: command.threadId, | ||
| 526 | updatedAt: occurredAt, | ||
| 527 | }, | ||
| 528 | }, | ||
| 529 | ]; | ||
| 507 | } | 530 | } |
| 508 | 531 | ||
| 509 | case "thread.unsettle": { | 532 | case "thread.unsettle": { |
| ... | @@ -630,6 +653,98 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -630,6 +653,98 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 630 | }; | 653 | }; |
| 631 | } | 654 | } |
| 632 | 655 | ||
| 656 | case "thread.pin": { | ||
| 657 | const thread = yield* requireThreadNotArchived({ | ||
| 658 | readModel, | ||
| 659 | command, | ||
| 660 | threadId: command.threadId, | ||
| 661 | }); | ||
| 662 | const occurredAt = yield* nowIso; | ||
| 663 | // Re-pinning an already-pinned thread is a duplicate (double-click, | ||
| 664 | // raced clients): re-emit with the original timestamps so the | ||
| 665 | // projection is a no-op. Pinning has no lifecycle invariants — a pin | ||
| 666 | // only ever promotes visibility, so it can never hide pending work. | ||
| 667 | const existingPinnedAt = thread.pinnedAt ?? null; | ||
| 668 | const pinnedEvent = { | ||
| 669 | ...(yield* withEventBase({ | ||
| 670 | aggregateKind: "thread", | ||
| 671 | aggregateId: command.threadId, | ||
| 672 | occurredAt, | ||
| 673 | commandId: command.commandId, | ||
| 674 | })), | ||
| 675 | type: "thread.pinned" as const, | ||
| 676 | payload: { | ||
| 677 | threadId: command.threadId, | ||
| 678 | pinnedAt: existingPinnedAt ?? occurredAt, | ||
| 679 | updatedAt: existingPinnedAt !== null ? thread.updatedAt : occurredAt, | ||
| 680 | }, | ||
| 681 | }; | ||
| 682 | // Pinning is a promotion: it clears the parked states rather than | ||
| 683 | // silently outranking them. An explicit settle un-settles (reason | ||
| 684 | // "user", same override the un-settle button stamps), and a snooze's | ||
| 685 | // return ticket is spent — the thread is on top NOW, not on Tuesday. | ||
| 686 | const promotionEvents: Array<Omit<OrchestrationEvent, "sequence">> = []; | ||
| 687 | if (thread.settledOverride === "settled") { | ||
| 688 | promotionEvents.push({ | ||
| 689 | ...(yield* withEventBase({ | ||
| 690 | aggregateKind: "thread", | ||
| 691 | aggregateId: command.threadId, | ||
| 692 | occurredAt, | ||
| 693 | commandId: command.commandId, | ||
| 694 | })), | ||
| 695 | type: "thread.unsettled", | ||
| 696 | payload: { | ||
| 697 | threadId: command.threadId, | ||
| 698 | reason: "user", | ||
| 699 | updatedAt: occurredAt, | ||
| 700 | }, | ||
| 701 | }); | ||
| 702 | } | ||
| 703 | if (thread.snoozedUntil != null) { | ||
| 704 | promotionEvents.push({ | ||
| 705 | ...(yield* withEventBase({ | ||
| 706 | aggregateKind: "thread", | ||
| 707 | aggregateId: command.threadId, | ||
| 708 | occurredAt, | ||
| 709 | commandId: command.commandId, | ||
| 710 | })), | ||
| 711 | type: "thread.unsnoozed", | ||
| 712 | payload: { | ||
| 713 | threadId: command.threadId, | ||
| 714 | reason: "user", | ||
| 715 | updatedAt: occurredAt, | ||
| 716 | }, | ||
| 717 | }); | ||
| 718 | } | ||
| 719 | return promotionEvents.length > 0 ? [pinnedEvent, ...promotionEvents] : pinnedEvent; | ||
| 720 | } | ||
| 721 | |||
| 722 | case "thread.unpin": { | ||
| 723 | const thread = yield* requireThreadNotArchived({ | ||
| 724 | readModel, | ||
| 725 | command, | ||
| 726 | threadId: command.threadId, | ||
| 727 | }); | ||
| 728 | // Idempotent by re-emission (see thread.settle): unpinning a thread | ||
| 729 | // that is not pinned lands on the same null state without churning | ||
| 730 | // updatedAt. | ||
| 731 | const alreadyUnpinned = thread.pinnedAt == null; | ||
| 732 | const occurredAt = yield* nowIso; | ||
| 733 | return { | ||
| 734 | ...(yield* withEventBase({ | ||
| 735 | aggregateKind: "thread", | ||
| 736 | aggregateId: command.threadId, | ||
| 737 | occurredAt, | ||
| 738 | commandId: command.commandId, | ||
| 739 | })), | ||
| 740 | type: "thread.unpinned", | ||
| 741 | payload: { | ||
| 742 | threadId: command.threadId, | ||
| 743 | updatedAt: alreadyUnpinned ? thread.updatedAt : occurredAt, | ||
| 744 | }, | ||
| 745 | }; | ||
| 746 | } | ||
| 747 | |||
| 633 | case "thread.meta.update": { | 748 | case "thread.meta.update": { |
| 634 | const thread = yield* requireThread({ | 749 | const thread = yield* requireThread({ |
| 635 | readModel, | 750 | readModel, |
apps/server/src/orchestration/projector.pinned.test.ts created+77| ... | @@ -0,0 +1,77 @@ | ||
| 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 pin lifecycle events", () => | ||
| 34 | Effect.gen(function* () { | ||
| 35 | const now = "2026-01-01T00:00:00.000Z"; | ||
| 36 | const created = yield* projectEvent( | ||
| 37 | createEmptyReadModel(now), | ||
| 38 | makeEvent({ | ||
| 39 | sequence: 1, | ||
| 40 | type: "thread.created", | ||
| 41 | payload: { | ||
| 42 | threadId: ThreadId.make("thread-1"), | ||
| 43 | projectId: ProjectId.make("project-1"), | ||
| 44 | title: "Thread", | ||
| 45 | modelSelection: { provider: "codex", model: "gpt-5.4" }, | ||
| 46 | runtimeMode: "full-access", | ||
| 47 | interactionMode: "default", | ||
| 48 | branch: null, | ||
| 49 | worktreePath: null, | ||
| 50 | createdAt: now, | ||
| 51 | updatedAt: now, | ||
| 52 | }, | ||
| 53 | }), | ||
| 54 | ); | ||
| 55 | expect(created.threads[0]?.pinnedAt ?? null).toBeNull(); | ||
| 56 | |||
| 57 | const pinned = yield* projectEvent( | ||
| 58 | created, | ||
| 59 | makeEvent({ | ||
| 60 | sequence: 2, | ||
| 61 | type: "thread.pinned", | ||
| 62 | payload: { threadId: ThreadId.make("thread-1"), pinnedAt: now, updatedAt: now }, | ||
| 63 | }), | ||
| 64 | ); | ||
| 65 | expect(pinned.threads[0]?.pinnedAt).toBe(now); | ||
| 66 | |||
| 67 | const unpinned = yield* projectEvent( | ||
| 68 | pinned, | ||
| 69 | makeEvent({ | ||
| 70 | sequence: 3, | ||
| 71 | type: "thread.unpinned", | ||
| 72 | payload: { threadId: ThreadId.make("thread-1"), updatedAt: now }, | ||
| 73 | }), | ||
| 74 | ); | ||
| 75 | expect(unpinned.threads[0]?.pinnedAt).toBeNull(); | ||
| 76 | }), | ||
| 77 | ); | ||
apps/server/src/orchestration/projector.ts+24| ... | @@ -23,7 +23,9 @@ import { | ... | @@ -23,7 +23,9 @@ import { |
| 23 | ThreadProposedPlanUpsertedPayload, | 23 | ThreadProposedPlanUpsertedPayload, |
| 24 | ThreadRuntimeModeSetPayload, | 24 | ThreadRuntimeModeSetPayload, |
| 25 | ThreadSettledPayload, | 25 | ThreadSettledPayload, |
| 26 | ThreadPinnedPayload, | ||
| 26 | ThreadSnoozedPayload, | 27 | ThreadSnoozedPayload, |
| 28 | ThreadUnpinnedPayload, | ||
| 27 | ThreadUnarchivedPayload, | 29 | ThreadUnarchivedPayload, |
| 28 | ThreadUnsettledPayload, | 30 | ThreadUnsettledPayload, |
| 29 | ThreadUnsnoozedPayload, | 31 | ThreadUnsnoozedPayload, |
| ... | @@ -394,6 +396,28 @@ export function projectEvent( | ... | @@ -394,6 +396,28 @@ export function projectEvent( |
| 394 | })), | 396 | })), |
| 395 | ); | 397 | ); |
| 396 | 398 | ||
| 399 | case "thread.pinned": | ||
| 400 | return decodeForEvent(ThreadPinnedPayload, event.payload, event.type, "payload").pipe( | ||
| 401 | Effect.map((payload) => ({ | ||
| 402 | ...nextBase, | ||
| 403 | threads: updateThread(nextBase.threads, payload.threadId, { | ||
| 404 | pinnedAt: payload.pinnedAt, | ||
| 405 | updatedAt: payload.updatedAt, | ||
| 406 | }), | ||
| 407 | })), | ||
| 408 | ); | ||
| 409 | |||
| 410 | case "thread.unpinned": | ||
| 411 | return decodeForEvent(ThreadUnpinnedPayload, event.payload, event.type, "payload").pipe( | ||
| 412 | Effect.map((payload) => ({ | ||
| 413 | ...nextBase, | ||
| 414 | threads: updateThread(nextBase.threads, payload.threadId, { | ||
| 415 | pinnedAt: null, | ||
| 416 | updatedAt: payload.updatedAt, | ||
| 417 | }), | ||
| 418 | })), | ||
| 419 | ); | ||
| 420 | |||
| 397 | case "thread.meta-updated": | 421 | case "thread.meta-updated": |
| 398 | return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( | 422 | return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( |
| 399 | Effect.map((payload) => ({ | 423 | Effect.map((payload) => ({ |
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+5| ... | @@ -95,6 +95,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -95,6 +95,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 95 | settledAt: null, | 95 | settledAt: null, |
| 96 | snoozedUntil: null, | 96 | snoozedUntil: null, |
| 97 | snoozedAt: null, | 97 | snoozedAt: null, |
| 98 | pinnedAt: null, | ||
| 98 | latestUserMessageAt: null, | 99 | latestUserMessageAt: null, |
| 99 | pendingApprovalCount: 0, | 100 | pendingApprovalCount: 0, |
| 100 | pendingUserInputCount: 0, | 101 | pendingUserInputCount: 0, |
| ... | @@ -157,6 +158,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -157,6 +158,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 157 | settledAt: "2026-03-25T00:00:00.000Z", | 158 | settledAt: "2026-03-25T00:00:00.000Z", |
| 158 | snoozedUntil: "2026-03-26T09:00:00.000Z", | 159 | snoozedUntil: "2026-03-26T09:00:00.000Z", |
| 159 | snoozedAt: "2026-03-25T00:00:00.000Z", | 160 | snoozedAt: "2026-03-25T00:00:00.000Z", |
| 161 | pinnedAt: "2026-03-25T00:00:00.000Z", | ||
| 160 | latestUserMessageAt: null, | 162 | latestUserMessageAt: null, |
| 161 | pendingApprovalCount: 0, | 163 | pendingApprovalCount: 0, |
| 162 | pendingUserInputCount: 0, | 164 | pendingUserInputCount: 0, |
| ... | @@ -175,6 +177,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -175,6 +177,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 175 | assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z"); | 177 | assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z"); |
| 176 | assert.strictEqual(row.snoozedUntil, "2026-03-26T09:00:00.000Z"); | 178 | assert.strictEqual(row.snoozedUntil, "2026-03-26T09:00:00.000Z"); |
| 177 | assert.strictEqual(row.snoozedAt, "2026-03-25T00:00:00.000Z"); | 179 | assert.strictEqual(row.snoozedAt, "2026-03-25T00:00:00.000Z"); |
| 180 | assert.strictEqual(row.pinnedAt, "2026-03-25T00:00:00.000Z"); | ||
| 178 | 181 | ||
| 179 | // Un-settle to the keep-active pin and wake the snooze; confirm the | 182 | // Un-settle to the keep-active pin and wake the snooze; confirm the |
| 180 | // flips persist. | 183 | // flips persist. |
| ... | @@ -184,6 +187,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -184,6 +187,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 184 | settledAt: null, | 187 | settledAt: null, |
| 185 | snoozedUntil: null, | 188 | snoozedUntil: null, |
| 186 | snoozedAt: null, | 189 | snoozedAt: null, |
| 190 | pinnedAt: null, | ||
| 187 | }); | 191 | }); |
| 188 | const repersisted = yield* threads.getById({ | 192 | const repersisted = yield* threads.getById({ |
| 189 | threadId: ThreadId.make("thread-settled"), | 193 | threadId: ThreadId.make("thread-settled"), |
| ... | @@ -193,6 +197,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -193,6 +197,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 193 | assert.strictEqual(updated?.settledAt, null); | 197 | assert.strictEqual(updated?.settledAt, null); |
| 194 | assert.strictEqual(updated?.snoozedUntil, null); | 198 | assert.strictEqual(updated?.snoozedUntil, null); |
| 195 | assert.strictEqual(updated?.snoozedAt, null); | 199 | assert.strictEqual(updated?.snoozedAt, null); |
| 200 | assert.strictEqual(updated?.pinnedAt, null); | ||
| 196 | }), | 201 | }), |
| 197 | ); | 202 | ); |
| 198 | }); | 203 | }); |
apps/server/src/persistence/Layers/ProjectionThreads.ts+5| ... | @@ -47,6 +47,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -47,6 +47,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 47 | settled_at, | 47 | settled_at, |
| 48 | snoozed_until, | 48 | snoozed_until, |
| 49 | snoozed_at, | 49 | snoozed_at, |
| 50 | pinned_at, | ||
| 50 | title_regeneration_request_id, | 51 | title_regeneration_request_id, |
| 51 | title_regeneration_started_at, | 52 | title_regeneration_started_at, |
| 52 | latest_user_message_at, | 53 | latest_user_message_at, |
| ... | @@ -72,6 +73,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -72,6 +73,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 72 | ${row.settledAt}, | 73 | ${row.settledAt}, |
| 73 | ${row.snoozedUntil}, | 74 | ${row.snoozedUntil}, |
| 74 | ${row.snoozedAt}, | 75 | ${row.snoozedAt}, |
| 76 | ${row.pinnedAt}, | ||
| 75 | ${row.titleRegenerationRequestId ?? null}, | 77 | ${row.titleRegenerationRequestId ?? null}, |
| 76 | ${row.titleRegenerationStartedAt ?? null}, | 78 | ${row.titleRegenerationStartedAt ?? null}, |
| 77 | ${row.latestUserMessageAt}, | 79 | ${row.latestUserMessageAt}, |
| ... | @@ -97,6 +99,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -97,6 +99,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 97 | settled_at = excluded.settled_at, | 99 | settled_at = excluded.settled_at, |
| 98 | snoozed_until = excluded.snoozed_until, | 100 | snoozed_until = excluded.snoozed_until, |
| 99 | snoozed_at = excluded.snoozed_at, | 101 | snoozed_at = excluded.snoozed_at, |
| 102 | pinned_at = excluded.pinned_at, | ||
| 100 | title_regeneration_request_id = excluded.title_regeneration_request_id, | 103 | title_regeneration_request_id = excluded.title_regeneration_request_id, |
| 101 | title_regeneration_started_at = excluded.title_regeneration_started_at, | 104 | title_regeneration_started_at = excluded.title_regeneration_started_at, |
| 102 | latest_user_message_at = excluded.latest_user_message_at, | 105 | latest_user_message_at = excluded.latest_user_message_at, |
| ... | @@ -129,6 +132,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -129,6 +132,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 129 | settled_at AS "settledAt", | 132 | settled_at AS "settledAt", |
| 130 | snoozed_until AS "snoozedUntil", | 133 | snoozed_until AS "snoozedUntil", |
| 131 | snoozed_at AS "snoozedAt", | 134 | snoozed_at AS "snoozedAt", |
| 135 | pinned_at AS "pinnedAt", | ||
| 132 | title_regeneration_request_id AS "titleRegenerationRequestId", | 136 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 133 | title_regeneration_started_at AS "titleRegenerationStartedAt", | 137 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 134 | latest_user_message_at AS "latestUserMessageAt", | 138 | latest_user_message_at AS "latestUserMessageAt", |
| ... | @@ -163,6 +167,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -163,6 +167,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 163 | settled_at AS "settledAt", | 167 | settled_at AS "settledAt", |
| 164 | snoozed_until AS "snoozedUntil", | 168 | snoozed_until AS "snoozedUntil", |
| 165 | snoozed_at AS "snoozedAt", | 169 | snoozed_at AS "snoozedAt", |
| 170 | pinned_at AS "pinnedAt", | ||
| 166 | title_regeneration_request_id AS "titleRegenerationRequestId", | 171 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 167 | title_regeneration_started_at AS "titleRegenerationStartedAt", | 172 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 168 | latest_user_message_at AS "latestUserMessageAt", | 173 | latest_user_message_at AS "latestUserMessageAt", |
apps/server/src/persistence/Migrations.ts+2| ... | @@ -48,6 +48,7 @@ import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; | ... | @@ -48,6 +48,7 @@ import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; |
| 48 | import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; | 48 | import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; |
| 49 | import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; | 49 | import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; |
| 50 | import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; | 50 | import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; |
| 51 | import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; | ||
| 51 | 52 | ||
| 52 | /** | 53 | /** |
| 53 | * Migration loader with all migrations defined inline. | 54 | * Migration loader with all migrations defined inline. |
| ... | @@ -95,6 +96,7 @@ export const migrationEntries = [ | ... | @@ -95,6 +96,7 @@ export const migrationEntries = [ |
| 95 | [33, "ProjectionThreadsSettled", Migration0033], | 96 | [33, "ProjectionThreadsSettled", Migration0033], |
| 96 | [34, "ProjectionThreadsSnoozed", Migration0034], | 97 | [34, "ProjectionThreadsSnoozed", Migration0034], |
| 97 | [35, "ProjectionThreadTitleRegeneration", Migration0035], | 98 | [35, "ProjectionThreadTitleRegeneration", Migration0035], |
| 99 | [36, "ProjectionThreadsPinned", Migration0036], | ||
| 98 | ] as const; | 100 | ] as const; |
| 99 | 101 | ||
| 100 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); | 102 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); |
apps/server/src/persistence/Migrations/036_ProjectionThreadsPinned.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 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 | |||
| 10 | if (!columns.some((column) => column.name === "pinned_at")) { | ||
| 11 | yield* sql` | ||
| 12 | ALTER TABLE projection_threads | ||
| 13 | ADD COLUMN pinned_at TEXT | ||
| 14 | `; | ||
| 15 | } | ||
| 16 | }); | ||
apps/server/src/persistence/Services/ProjectionThreads.ts+1| ... | @@ -41,6 +41,7 @@ export const ProjectionThread = Schema.Struct({ | ... | @@ -41,6 +41,7 @@ export const ProjectionThread = Schema.Struct({ |
| 41 | settledAt: Schema.NullOr(IsoDateTime), | 41 | settledAt: Schema.NullOr(IsoDateTime), |
| 42 | snoozedUntil: Schema.NullOr(IsoDateTime), | 42 | snoozedUntil: Schema.NullOr(IsoDateTime), |
| 43 | snoozedAt: Schema.NullOr(IsoDateTime), | 43 | snoozedAt: Schema.NullOr(IsoDateTime), |
| 44 | pinnedAt: Schema.NullOr(IsoDateTime), | ||
| 44 | titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), | 45 | titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), |
| 45 | titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), | 46 | titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), |
| 46 | latestUserMessageAt: Schema.NullOr(IsoDateTime), | 47 | latestUserMessageAt: Schema.NullOr(IsoDateTime), |
apps/web/src/components/SidebarV2.tsx+187-73| ... | @@ -28,6 +28,7 @@ import { | ... | @@ -28,6 +28,7 @@ import { |
| 28 | GitBranchIcon, | 28 | GitBranchIcon, |
| 29 | EllipsisIcon, | 29 | EllipsisIcon, |
| 30 | MessageSquareIcon, | 30 | MessageSquareIcon, |
| 31 | PinIcon, | ||
| 31 | PlusIcon, | 32 | PlusIcon, |
| 32 | SearchIcon, | 33 | SearchIcon, |
| 33 | ServerIcon, | 34 | ServerIcon, |
| ... | @@ -394,6 +395,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -394,6 +395,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 394 | settlementSupported: boolean; | 395 | settlementSupported: boolean; |
| 395 | // Same contract for thread.snooze/unsnooze. | 396 | // Same contract for thread.snooze/unsnooze. |
| 396 | snoozeSupported: boolean; | 397 | snoozeSupported: boolean; |
| 398 | // Renders the pin glyph. Pinned cards keep the full settle/snooze quick | ||
| 399 | // actions: settling clears the pin server-side, and snoozing hides the | ||
| 400 | // card until wake with the pin intact underneath. Pin/unpin themselves | ||
| 401 | // live in the context menu only. | ||
| 402 | isPinned: boolean; | ||
| 397 | // Compact wake countdown ("2h") for rows in the snoozed shelf. | 403 | // Compact wake countdown ("2h") for rows in the snoozed shelf. |
| 398 | snoozeWakeLabelText: string | null; | 404 | snoozeWakeLabelText: string | null; |
| 399 | // When a snooze ended (timer or early wake); drives the Woke pill until | 405 | // When a snooze ended (timer or early wake); drives the Woke pill until |
| ... | @@ -939,6 +945,13 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -939,6 +945,13 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 939 | ) : ( | 945 | ) : ( |
| 940 | <span className="flex-1" /> | 946 | <span className="flex-1" /> |
| 941 | )} | 947 | )} |
| 948 | {props.isPinned ? ( | ||
| 949 | <PinIcon | ||
| 950 | aria-label="Pinned" | ||
| 951 | role="img" | ||
| 952 | className="size-3 shrink-0 text-muted-foreground/65" | ||
| 953 | /> | ||
| 954 | ) : null} | ||
| 942 | {/* The visible state owns this slot's width: status at rest, | 955 | {/* The visible state owns this slot's width: status at rest, |
| 943 | actions on hover/keyboard focus or while the popover is open. Keeping | 956 | actions on hover/keyboard focus or while the popover is open. Keeping |
| 944 | the hidden state out of flow lets the project label reclaim | 957 | the hidden state out of flow lets the project label reclaim |
| ... | @@ -1186,8 +1199,15 @@ export default function SidebarV2() { | ... | @@ -1186,8 +1199,15 @@ export default function SidebarV2() { |
| 1186 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); | 1199 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); |
| 1187 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); | 1200 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); |
| 1188 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); | 1201 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 1189 | const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, deleteThread } = | 1202 | const { |
| 1190 | useThreadActions(); | 1203 | settleThread, |
| 1204 | unsettleThread, | ||
| 1205 | snoozeThread, | ||
| 1206 | unsnoozeThread, | ||
| 1207 | pinThread, | ||
| 1208 | unpinThread, | ||
| 1209 | deleteThread, | ||
| 1210 | } = useThreadActions(); | ||
| 1191 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | 1211 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { |
| 1192 | reportFailure: false, | 1212 | reportFailure: false, |
| 1193 | }); | 1213 | }); |
| ... | @@ -1564,76 +1584,91 @@ export default function SidebarV2() { | ... | @@ -1564,76 +1584,91 @@ export default function SidebarV2() { |
| 1564 | // merging, no optimistic holds. Archived threads remain hidden here — | 1584 | // merging, no optimistic holds. Archived threads remain hidden here — |
| 1565 | // archive keeps its original "remove from sidebar" meaning. | 1585 | // archive keeps its original "remove from sidebar" meaning. |
| 1566 | const serverConfigs = useAtomValue(environmentServerConfigsAtom); | 1586 | const serverConfigs = useAtomValue(environmentServerConfigsAtom); |
| 1567 | const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { | 1587 | const { pinnedThreads, activeThreads, snoozedThreads, settledThreads, snoozeNow } = |
| 1568 | const now = `${nowMinute}:00.000Z`; | 1588 | useMemo(() => { |
| 1569 | // Snooze classification uses a REAL clock, not the quantized minute: | 1589 | const now = `${nowMinute}:00.000Z`; |
| 1570 | // wake times are second-precise and a woken thread must not linger on | 1590 | // Snooze classification uses a REAL clock, not the quantized minute: |
| 1571 | // the shelf for the rest of the minute. snoozeWakeTick re-runs this | 1591 | // wake times are second-precise and a woken thread must not linger on |
| 1572 | // memo exactly at the next wake boundary. | 1592 | // the shelf for the rest of the minute. snoozeWakeTick re-runs this |
| 1573 | void snoozeWakeTick; | 1593 | // memo exactly at the next wake boundary. |
| 1574 | const preciseNow = new Date().toISOString(); | 1594 | void snoozeWakeTick; |
| 1575 | const visible = threads.filter( | 1595 | const preciseNow = new Date().toISOString(); |
| 1576 | (thread) => | 1596 | const visible = threads.filter( |
| 1577 | thread.archivedAt === null && | 1597 | (thread) => |
| 1578 | (scopedProjectKeys === null || | 1598 | thread.archivedAt === null && |
| 1579 | scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), | 1599 | (scopedProjectKeys === null || |
| 1580 | ); | 1600 | scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), |
| 1581 | const active: EnvironmentThreadShell[] = []; | 1601 | ); |
| 1582 | const snoozed: EnvironmentThreadShell[] = []; | 1602 | const pinned: EnvironmentThreadShell[] = []; |
| 1583 | const settled: EnvironmentThreadShell[] = []; | 1603 | const active: EnvironmentThreadShell[] = []; |
| 1584 | for (const thread of visible) { | 1604 | const snoozed: EnvironmentThreadShell[] = []; |
| 1585 | // Threads on servers without the settlement capability (old server, | 1605 | const settled: EnvironmentThreadShell[] = []; |
| 1586 | // or descriptor not loaded yet) never classify as settled: the user | 1606 | for (const thread of visible) { |
| 1587 | // could neither un-settle nor pin them, so auto-settling them would | 1607 | // Threads on servers without the settlement capability (old server, |
| 1588 | // strand rows in a tail with no working affordances. | 1608 | // or descriptor not loaded yet) never classify as settled: the user |
| 1589 | const supportsSettlement = | 1609 | // could neither un-settle nor pin them, so auto-settling them would |
| 1590 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; | 1610 | // strand rows in a tail with no working affordances. |
| 1591 | const supportsSnooze = | 1611 | const supportsSettlement = |
| 1592 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; | 1612 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === |
| 1593 | const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); | 1613 | true; |
| 1594 | const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; | 1614 | const supportsSnooze = |
| 1595 | // Snooze outranks settled classification: an explicitly snoozed thread | 1615 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; |
| 1596 | // belongs to the shelf even if it would also auto-settle (the shelf's | 1616 | const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); |
| 1597 | // wake time is a stronger statement about when it matters again). | 1617 | const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; |
| 1598 | if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { | 1618 | // Snooze outranks everything, including a pin: "hide until Tuesday" |
| 1599 | snoozed.push(thread); | 1619 | // temporarily suspends "keep on top". The pin survives underneath — |
| 1600 | } else if ( | 1620 | // pinned cards are creation-ordered, so on wake the thread reappears |
| 1601 | supportsSettlement && | 1621 | // at its original spot in the pinned block. (For unpinned threads |
| 1602 | effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) | 1622 | // this is also the snooze-beats-auto-settle rule: the wake time is a |
| 1603 | ) { | 1623 | // stronger statement about when the thread matters again.) |
| 1604 | settled.push(thread); | 1624 | if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { |
| 1605 | } else { | 1625 | snoozed.push(thread); |
| 1606 | active.push(thread); | 1626 | // A pin otherwise overrides the lifecycle: pinned threads never |
| 1627 | // auto-settle out of sight. (The decider clears settled state on | ||
| 1628 | // pin and the pin on settle, so pin-vs-settled conflicts only | ||
| 1629 | // arise from stale or raced writes.) | ||
| 1630 | } else if (thread.pinnedAt != null) { | ||
| 1631 | pinned.push(thread); | ||
| 1632 | } else if ( | ||
| 1633 | supportsSettlement && | ||
| 1634 | effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) | ||
| 1635 | ) { | ||
| 1636 | settled.push(thread); | ||
| 1637 | } else { | ||
| 1638 | active.push(thread); | ||
| 1639 | } | ||
| 1607 | } | 1640 | } |
| 1608 | } | 1641 | return { |
| 1609 | return { | 1642 | // Same static creation order as the inbox: a pin freezes prominence, |
| 1610 | activeThreads: sortThreadsForSidebarV2(active), | 1643 | // it does not introduce a new ordering scheme. |
| 1611 | // Soonest wake first: "what comes back next" is the shelf's question. | 1644 | pinnedThreads: sortThreadsForSidebarV2(pinned), |
| 1612 | snoozedThreads: snoozed.toSorted( | 1645 | activeThreads: sortThreadsForSidebarV2(active), |
| 1613 | (left, right) => | 1646 | // Soonest wake first: "what comes back next" is the shelf's question. |
| 1614 | firstValidTimestampMs(left.snoozedUntil ?? null) - | 1647 | snoozedThreads: snoozed.toSorted( |
| 1615 | firstValidTimestampMs(right.snoozedUntil ?? null), | 1648 | (left, right) => |
| 1616 | ), | 1649 | firstValidTimestampMs(left.snoozedUntil ?? null) - |
| 1617 | settledThreads: sortSettledThreadsForSidebarV2(settled), | 1650 | firstValidTimestampMs(right.snoozedUntil ?? null), |
| 1618 | snoozeNow: preciseNow, | 1651 | ), |
| 1619 | }; | 1652 | settledThreads: sortSettledThreadsForSidebarV2(settled), |
| 1620 | }, [ | 1653 | snoozeNow: preciseNow, |
| 1621 | autoSettleAfterDays, | 1654 | }; |
| 1622 | changeRequestStateByKey, | 1655 | }, [ |
| 1623 | nowMinute, | 1656 | autoSettleAfterDays, |
| 1624 | scopedProjectKeys, | 1657 | changeRequestStateByKey, |
| 1625 | serverConfigs, | 1658 | nowMinute, |
| 1626 | snoozeWakeTick, | 1659 | scopedProjectKeys, |
| 1627 | threads, | 1660 | serverConfigs, |
| 1628 | ]); | 1661 | snoozeWakeTick, |
| 1662 | threads, | ||
| 1663 | ]); | ||
| 1629 | 1664 | ||
| 1630 | const threadSearchInputRef = useRef<HTMLInputElement>(null); | 1665 | const threadSearchInputRef = useRef<HTMLInputElement>(null); |
| 1631 | const [threadSearchQuery, setThreadSearchQuery] = useState(""); | 1666 | const [threadSearchQuery, setThreadSearchQuery] = useState(""); |
| 1632 | const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); | 1667 | const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); |
| 1633 | const isSearchingThreads = threadSearchQuery.trim().length > 0; | 1668 | const isSearchingThreads = threadSearchQuery.trim().length > 0; |
| 1634 | const searchableThreads = useMemo( | 1669 | const searchableThreads = useMemo( |
| 1635 | () => [...activeThreads, ...snoozedThreads, ...settledThreads], | 1670 | () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], |
| 1636 | [activeThreads, settledThreads, snoozedThreads], | 1671 | [activeThreads, pinnedThreads, settledThreads, snoozedThreads], |
| 1637 | ); | 1672 | ); |
| 1638 | const threadSearchResults = useMemo( | 1673 | const threadSearchResults = useMemo( |
| 1639 | () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery), | 1674 | () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery), |
| ... | @@ -1737,8 +1772,8 @@ export default function SidebarV2() { | ... | @@ -1737,8 +1772,8 @@ export default function SidebarV2() { |
| 1737 | }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); | 1772 | }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); |
| 1738 | 1773 | ||
| 1739 | const orderedThreads = useMemo( | 1774 | const orderedThreads = useMemo( |
| 1740 | () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], | 1775 | () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], |
| 1741 | [activeThreads, visibleSnoozedThreads, renderedSettledThreads], | 1776 | [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads], |
| 1742 | ); | 1777 | ); |
| 1743 | const orderedThreadKeys = useMemo( | 1778 | const orderedThreadKeys = useMemo( |
| 1744 | () => | 1779 | () => |
| ... | @@ -2040,6 +2075,42 @@ export default function SidebarV2() { | ... | @@ -2040,6 +2075,42 @@ export default function SidebarV2() { |
| 2040 | }, | 2075 | }, |
| 2041 | [unsnoozeThread], | 2076 | [unsnoozeThread], |
| 2042 | ); | 2077 | ); |
| 2078 | const attemptPin = useCallback( | ||
| 2079 | (threadRef: ScopedThreadRef) => { | ||
| 2080 | void (async () => { | ||
| 2081 | const result = await pinThread(threadRef); | ||
| 2082 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 2083 | const error = squashAtomCommandFailure(result); | ||
| 2084 | toastManager.add( | ||
| 2085 | stackedThreadToast({ | ||
| 2086 | type: "error", | ||
| 2087 | title: "Failed to pin thread", | ||
| 2088 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 2089 | }), | ||
| 2090 | ); | ||
| 2091 | } | ||
| 2092 | })(); | ||
| 2093 | }, | ||
| 2094 | [pinThread], | ||
| 2095 | ); | ||
| 2096 | const attemptUnpin = useCallback( | ||
| 2097 | (threadRef: ScopedThreadRef) => { | ||
| 2098 | void (async () => { | ||
| 2099 | const result = await unpinThread(threadRef); | ||
| 2100 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 2101 | const error = squashAtomCommandFailure(result); | ||
| 2102 | toastManager.add( | ||
| 2103 | stackedThreadToast({ | ||
| 2104 | type: "error", | ||
| 2105 | title: "Failed to unpin thread", | ||
| 2106 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 2107 | }), | ||
| 2108 | ); | ||
| 2109 | } | ||
| 2110 | })(); | ||
| 2111 | }, | ||
| 2112 | [unpinThread], | ||
| 2113 | ); | ||
| 2043 | // One snooze per thread at a time — same double-dispatch guard as settle. | 2114 | // One snooze per thread at a time — same double-dispatch guard as settle. |
| 2044 | const snoozingThreadKeysRef = useRef(new Set<string>()); | 2115 | const snoozingThreadKeysRef = useRef(new Set<string>()); |
| 2045 | const attemptSnooze = useCallback( | 2116 | const attemptSnooze = useCallback( |
| ... | @@ -2203,7 +2274,8 @@ export default function SidebarV2() { | ... | @@ -2203,7 +2274,8 @@ export default function SidebarV2() { |
| 2203 | // Post-settle navigation must skip threads settling in this same | 2274 | // Post-settle navigation must skip threads settling in this same |
| 2204 | // batch — they are all leaving the card block together. Rows that | 2275 | // batch — they are all leaving the card block together. Rows that |
| 2205 | // are already explicitly settled are skipped: nothing to do on a | 2276 | // are already explicitly settled are skipped: nothing to do on a |
| 2206 | // valid mixed selection. | 2277 | // valid mixed selection. Pinned rows ARE included: the decider |
| 2278 | // clears the pin as part of settling, so they park like the rest. | ||
| 2207 | const coSettlingKeys = new Set(threadKeys); | 2279 | const coSettlingKeys = new Set(threadKeys); |
| 2208 | for (const threadKey of threadKeys) { | 2280 | for (const threadKey of threadKeys) { |
| 2209 | const thread = threadByKeyRef.current.get(threadKey); | 2281 | const thread = threadByKeyRef.current.get(threadKey); |
| ... | @@ -2300,12 +2372,15 @@ export default function SidebarV2() { | ... | @@ -2300,12 +2372,15 @@ export default function SidebarV2() { |
| 2300 | true; | 2372 | true; |
| 2301 | const supportsSnooze = | 2373 | const supportsSnooze = |
| 2302 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; | 2374 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; |
| 2375 | const supportsPinning = | ||
| 2376 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true; | ||
| 2303 | const supportsTitleRegeneration = | 2377 | const supportsTitleRegeneration = |
| 2304 | serverConfigs.get(thread.environmentId)?.environment.capabilities | 2378 | serverConfigs.get(thread.environmentId)?.environment.capabilities |
| 2305 | .threadTitleRegeneration === true; | 2379 | .threadTitleRegeneration === true; |
| 2306 | const isRegeneratingTitle = thread.titleRegeneration != null; | 2380 | const isRegeneratingTitle = thread.titleRegeneration != null; |
| 2307 | const isSettled = settledThreadKeysRef.current.has(threadKey); | 2381 | const isSettled = settledThreadKeysRef.current.has(threadKey); |
| 2308 | const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); | 2382 | const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); |
| 2383 | const isPinned = thread.pinnedAt != null; | ||
| 2309 | // Presets resolve at menu-open time (same as the popover). | 2384 | // Presets resolve at menu-open time (same as the popover). |
| 2310 | const snoozePresets = resolveSnoozePresets(new Date()); | 2385 | const snoozePresets = resolveSnoozePresets(new Date()); |
| 2311 | const clicked = await settlePromise(() => | 2386 | const clicked = await settlePromise(() => |
| ... | @@ -2319,6 +2394,16 @@ export default function SidebarV2() { | ... | @@ -2319,6 +2394,16 @@ export default function SidebarV2() { |
| 2319 | }, | 2394 | }, |
| 2320 | ] | 2395 | ] |
| 2321 | : []), | 2396 | : []), |
| 2397 | ...(supportsPinning | ||
| 2398 | ? [ | ||
| 2399 | isPinned | ||
| 2400 | ? { id: "unpin", label: "Unpin thread" } | ||
| 2401 | : { id: "pin", label: "Pin thread" }, | ||
| 2402 | ] | ||
| 2403 | : []), | ||
| 2404 | // Both lifecycle actions stay available on pinned threads: | ||
| 2405 | // settling clears the pin ("done" beats "keep on top"), and | ||
| 2406 | // snoozing hides the card until wake with the pin intact. | ||
| 2322 | ...(supportsSettlement | 2407 | ...(supportsSettlement |
| 2323 | ? [ | 2408 | ? [ |
| 2324 | isSettled | 2409 | isSettled |
| ... | @@ -2400,6 +2485,12 @@ export default function SidebarV2() { | ... | @@ -2400,6 +2485,12 @@ export default function SidebarV2() { |
| 2400 | case "unsnooze": | 2485 | case "unsnooze": |
| 2401 | attemptUnsnooze(threadRef); | 2486 | attemptUnsnooze(threadRef); |
| 2402 | return; | 2487 | return; |
| 2488 | case "pin": | ||
| 2489 | attemptPin(threadRef); | ||
| 2490 | return; | ||
| 2491 | case "unpin": | ||
| 2492 | attemptUnpin(threadRef); | ||
| 2493 | return; | ||
| 2403 | case "rename": | 2494 | case "rename": |
| 2404 | startThreadRename(threadRef, thread.title); | 2495 | startThreadRename(threadRef, thread.title); |
| 2405 | return; | 2496 | return; |
| ... | @@ -2474,8 +2565,10 @@ export default function SidebarV2() { | ... | @@ -2474,8 +2565,10 @@ export default function SidebarV2() { |
| 2474 | })(); | 2565 | })(); |
| 2475 | }, | 2566 | }, |
| 2476 | [ | 2567 | [ |
| 2568 | attemptPin, | ||
| 2477 | attemptSettle, | 2569 | attemptSettle, |
| 2478 | attemptSnooze, | 2570 | attemptSnooze, |
| 2571 | attemptUnpin, | ||
| 2479 | attemptUnsettle, | 2572 | attemptUnsettle, |
| 2480 | attemptUnsnooze, | 2573 | attemptUnsnooze, |
| 2481 | confirmThreadDelete, | 2574 | confirmThreadDelete, |
| ... | @@ -2830,7 +2923,7 @@ export default function SidebarV2() { | ... | @@ -2830,7 +2923,7 @@ export default function SidebarV2() { |
| 2830 | {(() => { | 2923 | {(() => { |
| 2831 | const renderThreadRow = ( | 2924 | const renderThreadRow = ( |
| 2832 | thread: EnvironmentThreadShell, | 2925 | thread: EnvironmentThreadShell, |
| 2833 | section: "active" | "snoozed" | "settled", | 2926 | section: "pinned" | "active" | "snoozed" | "settled", |
| 2834 | ) => { | 2927 | ) => { |
| 2835 | const threadKey = scopedThreadKey( | 2928 | const threadKey = scopedThreadKey( |
| 2836 | scopeThreadRef(thread.environmentId, thread.id), | 2929 | scopeThreadRef(thread.environmentId, thread.id), |
| ... | @@ -2839,7 +2932,7 @@ export default function SidebarV2() { | ... | @@ -2839,7 +2932,7 @@ export default function SidebarV2() { |
| 2839 | // row: every other thread is a full card. Density comes | 2932 | // row: every other thread is a full card. Density comes |
| 2840 | // from users (or the auto rules) actually parking work, | 2933 | // from users (or the auto rules) actually parking work, |
| 2841 | // not from the sidebar second-guessing what still matters. | 2934 | // not from the sidebar second-guessing what still matters. |
| 2842 | const isCard = section === "active"; | 2935 | const isCard = section === "active" || section === "pinned"; |
| 2843 | const rowVariant = isCard ? "card" : "slim"; | 2936 | const rowVariant = isCard ? "card" : "slim"; |
| 2844 | return ( | 2937 | return ( |
| 2845 | <SidebarV2Row | 2938 | <SidebarV2Row |
| ... | @@ -2870,6 +2963,7 @@ export default function SidebarV2() { | ... | @@ -2870,6 +2963,7 @@ export default function SidebarV2() { |
| 2870 | serverConfigs.get(thread.environmentId)?.environment.capabilities | 2963 | serverConfigs.get(thread.environmentId)?.environment.capabilities |
| 2871 | .threadSnooze === true | 2964 | .threadSnooze === true |
| 2872 | } | 2965 | } |
| 2966 | isPinned={section === "pinned"} | ||
| 2873 | snoozeWakeLabelText={ | 2967 | snoozeWakeLabelText={ |
| 2874 | section === "snoozed" && thread.snoozedUntil != null | 2968 | section === "snoozed" && thread.snoozedUntil != null |
| 2875 | ? snoozeWakeLabel(thread.snoozedUntil, { | 2969 | ? snoozeWakeLabel(thread.snoozedUntil, { |
| ... | @@ -2912,9 +3006,25 @@ export default function SidebarV2() { | ... | @@ -2912,9 +3006,25 @@ export default function SidebarV2() { |
| 2912 | /> | 3006 | /> |
| 2913 | ); | 3007 | ); |
| 2914 | }; | 3008 | }; |
| 2915 | const items: ReactNode[] = activeThreads.map((thread) => | 3009 | // Pinned block: full cards above the inbox, closed by a |
| 2916 | renderThreadRow(thread, "active"), | 3010 | // thin divider (the pin glyphs carry the meaning, so no |
| 3011 | // header text). Vanishes entirely at count 0. | ||
| 3012 | const items: ReactNode[] = pinnedThreads.map((thread) => | ||
| 3013 | renderThreadRow(thread, "pinned"), | ||
| 2917 | ); | 3014 | ); |
| 3015 | if (pinnedThreads.length > 0) { | ||
| 3016 | items.push( | ||
| 3017 | <li | ||
| 3018 | key="pinned-divider" | ||
| 3019 | aria-hidden | ||
| 3020 | data-testid="sidebar-v2-pinned-divider" | ||
| 3021 | className="mx-2.5 my-1.5 h-px list-none bg-sidebar-border/60" | ||
| 3022 | />, | ||
| 3023 | ); | ||
| 3024 | } | ||
| 3025 | for (const thread of activeThreads) { | ||
| 3026 | items.push(renderThreadRow(thread, "active")); | ||
| 3027 | } | ||
| 2918 | // Snoozed shelf: between the inbox and Settled — out of the | 3028 | // Snoozed shelf: between the inbox and Settled — out of the |
| 2919 | // way, never gone. The header always renders while anything | 3029 | // way, never gone. The header always renders while anything |
| 2920 | // is snoozed (the count is the whole footprint when | 3030 | // is snoozed (the count is the whole footprint when |
| ... | @@ -3006,7 +3116,11 @@ export default function SidebarV2() { | ... | @@ -3006,7 +3116,11 @@ export default function SidebarV2() { |
| 3006 | </TooltipProvider> | 3116 | </TooltipProvider> |
| 3007 | ) : null} | 3117 | ) : null} |
| 3008 | {!isSearchingThreads && | 3118 | {!isSearchingThreads && |
| 3009 | activeThreads.length + snoozedThreads.length + settledThreads.length === 0 ? ( | 3119 | pinnedThreads.length + |
| 3120 | activeThreads.length + | ||
| 3121 | snoozedThreads.length + | ||
| 3122 | settledThreads.length === | ||
| 3123 | 0 ? ( | ||
| 3010 | <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60"> | 3124 | <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60"> |
| 3011 | {projects.length === 0 ? ( | 3125 | {projects.length === 0 ? ( |
| 3012 | <> | 3126 | <> |
apps/web/src/hooks/useThreadActions.ts+64| ... | @@ -21,6 +21,7 @@ import { useNewThreadHandler } from "./useHandleNewThread"; | ... | @@ -21,6 +21,7 @@ import { useNewThreadHandler } from "./useHandleNewThread"; |
| 21 | import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; | 21 | import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; |
| 22 | import { readLocalApi } from "../localApi"; | 22 | import { readLocalApi } from "../localApi"; |
| 23 | import { | 23 | import { |
| 24 | readEnvironmentSupportsPinning, | ||
| 24 | readEnvironmentSupportsSettlement, | 25 | readEnvironmentSupportsSettlement, |
| 25 | readEnvironmentSupportsSnooze, | 26 | readEnvironmentSupportsSnooze, |
| 26 | readEnvironmentThreadRefs, | 27 | readEnvironmentThreadRefs, |
| ... | @@ -94,6 +95,18 @@ export class ThreadSnoozeBlockedError extends Schema.TaggedErrorClass<ThreadSnoo | ... | @@ -94,6 +95,18 @@ export class ThreadSnoozeBlockedError extends Schema.TaggedErrorClass<ThreadSnoo |
| 94 | } | 95 | } |
| 95 | } | 96 | } |
| 96 | 97 | ||
| 98 | export class ThreadPinningUnsupportedError extends Schema.TaggedErrorClass<ThreadPinningUnsupportedError>()( | ||
| 99 | "ThreadPinningUnsupportedError", | ||
| 100 | { | ||
| 101 | environmentId: EnvironmentId, | ||
| 102 | threadId: ThreadId, | ||
| 103 | }, | ||
| 104 | ) { | ||
| 105 | override get message(): string { | ||
| 106 | return "This environment's server does not support pinning yet. Update the server to use Pin."; | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 97 | export function useThreadActions() { | 110 | export function useThreadActions() { |
| 98 | const closeTerminal = useAtomCommand(terminalEnvironment.close); | 111 | const closeTerminal = useAtomCommand(terminalEnvironment.close); |
| 99 | const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { | 112 | const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { |
| ... | @@ -111,6 +124,12 @@ export function useThreadActions() { | ... | @@ -111,6 +124,12 @@ export function useThreadActions() { |
| 111 | const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { | 124 | const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { |
| 112 | reportFailure: false, | 125 | reportFailure: false, |
| 113 | }); | 126 | }); |
| 127 | const pinThreadMutation = useAtomCommand(threadEnvironment.pin, { | ||
| 128 | reportFailure: false, | ||
| 129 | }); | ||
| 130 | const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, { | ||
| 131 | reportFailure: false, | ||
| 132 | }); | ||
| 114 | const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, { | 133 | const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, { |
| 115 | reportFailure: false, | 134 | reportFailure: false, |
| 116 | }); | 135 | }); |
| ... | @@ -472,6 +491,47 @@ export function useThreadActions() { | ... | @@ -472,6 +491,47 @@ export function useThreadActions() { |
| 472 | [unsettleThreadMutation], | 491 | [unsettleThreadMutation], |
| 473 | ); | 492 | ); |
| 474 | 493 | ||
| 494 | const pinThread = useCallback( | ||
| 495 | async (target: ScopedThreadRef) => { | ||
| 496 | // Version skew: never send the command to a server that predates it. | ||
| 497 | if (!readEnvironmentSupportsPinning(target.environmentId)) { | ||
| 498 | return AsyncResult.failure( | ||
| 499 | Cause.fail( | ||
| 500 | new ThreadPinningUnsupportedError({ | ||
| 501 | environmentId: target.environmentId, | ||
| 502 | threadId: target.threadId, | ||
| 503 | }), | ||
| 504 | ), | ||
| 505 | ); | ||
| 506 | } | ||
| 507 | return pinThreadMutation({ | ||
| 508 | environmentId: target.environmentId, | ||
| 509 | input: { threadId: target.threadId }, | ||
| 510 | }); | ||
| 511 | }, | ||
| 512 | [pinThreadMutation], | ||
| 513 | ); | ||
| 514 | |||
| 515 | const unpinThread = useCallback( | ||
| 516 | async (target: ScopedThreadRef) => { | ||
| 517 | if (!readEnvironmentSupportsPinning(target.environmentId)) { | ||
| 518 | return AsyncResult.failure( | ||
| 519 | Cause.fail( | ||
| 520 | new ThreadPinningUnsupportedError({ | ||
| 521 | environmentId: target.environmentId, | ||
| 522 | threadId: target.threadId, | ||
| 523 | }), | ||
| 524 | ), | ||
| 525 | ); | ||
| 526 | } | ||
| 527 | return unpinThreadMutation({ | ||
| 528 | environmentId: target.environmentId, | ||
| 529 | input: { threadId: target.threadId }, | ||
| 530 | }); | ||
| 531 | }, | ||
| 532 | [unpinThreadMutation], | ||
| 533 | ); | ||
| 534 | |||
| 475 | const snoozeThread = useCallback( | 535 | const snoozeThread = useCallback( |
| 476 | async (target: ScopedThreadRef, snoozedUntil: string) => { | 536 | async (target: ScopedThreadRef, snoozedUntil: string) => { |
| 477 | // Version skew: never send the command to a server that predates it. | 537 | // Version skew: never send the command to a server that predates it. |
| ... | @@ -565,14 +625,18 @@ export function useThreadActions() { | ... | @@ -565,14 +625,18 @@ export function useThreadActions() { |
| 565 | unsettleThread, | 625 | unsettleThread, |
| 566 | snoozeThread, | 626 | snoozeThread, |
| 567 | unsnoozeThread, | 627 | unsnoozeThread, |
| 628 | pinThread, | ||
| 629 | unpinThread, | ||
| 568 | }), | 630 | }), |
| 569 | [ | 631 | [ |
| 570 | archiveThread, | 632 | archiveThread, |
| 571 | confirmAndDeleteThread, | 633 | confirmAndDeleteThread, |
| 572 | deleteThread, | 634 | deleteThread, |
| 635 | pinThread, | ||
| 573 | settleThread, | 636 | settleThread, |
| 574 | snoozeThread, | 637 | snoozeThread, |
| 575 | unarchiveThread, | 638 | unarchiveThread, |
| 639 | unpinThread, | ||
| 576 | unsettleThread, | 640 | unsettleThread, |
| 577 | unsnoozeThread, | 641 | unsnoozeThread, |
| 578 | ], | 642 | ], |
apps/web/src/state/entities.ts+9| ... | @@ -241,6 +241,15 @@ export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boo | ... | @@ -241,6 +241,15 @@ export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boo |
| 241 | ); | 241 | ); |
| 242 | } | 242 | } |
| 243 | 243 | ||
| 244 | /** Whether the environment's server understands thread.pin/unpin. | ||
| 245 | Same version-skew contract as settlement. */ | ||
| 246 | export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): boolean { | ||
| 247 | return ( | ||
| 248 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | ||
| 249 | .threadPinning === true | ||
| 250 | ); | ||
| 251 | } | ||
| 252 | |||
| 244 | export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { | 253 | export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { |
| 245 | return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); | 254 | return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); |
| 246 | } | 255 | } |
packages/client-runtime/src/operations/commands.ts+22| ... | @@ -39,6 +39,8 @@ export type SettleThreadInput = CommandInput<"thread.settle">; | ... | @@ -39,6 +39,8 @@ export type SettleThreadInput = CommandInput<"thread.settle">; |
| 39 | export type UnsettleThreadInput = CommandInput<"thread.unsettle">; | 39 | export type UnsettleThreadInput = CommandInput<"thread.unsettle">; |
| 40 | export type SnoozeThreadInput = CommandInput<"thread.snooze">; | 40 | export type SnoozeThreadInput = CommandInput<"thread.snooze">; |
| 41 | export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">; | 41 | export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">; |
| 42 | export type PinThreadInput = CommandInput<"thread.pin">; | ||
| 43 | export type UnpinThreadInput = CommandInput<"thread.unpin">; | ||
| 42 | export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; | 44 | export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; |
| 43 | export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; | 45 | export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; |
| 44 | export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; | 46 | export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; |
| ... | @@ -197,6 +199,26 @@ export const unsnoozeThread: (input: UnsnoozeThreadInput) => CommandEffect = Eff | ... | @@ -197,6 +199,26 @@ export const unsnoozeThread: (input: UnsnoozeThreadInput) => CommandEffect = Eff |
| 197 | }); | 199 | }); |
| 198 | }); | 200 | }); |
| 199 | 201 | ||
| 202 | export const pinThread: (input: PinThreadInput) => CommandEffect = Effect.fn( | ||
| 203 | "EnvironmentCommands.pinThread", | ||
| 204 | )(function* (input) { | ||
| 205 | return yield* dispatch({ | ||
| 206 | ...input, | ||
| 207 | type: "thread.pin", | ||
| 208 | commandId: yield* commandId(input), | ||
| 209 | }); | ||
| 210 | }); | ||
| 211 | |||
| 212 | export const unpinThread: (input: UnpinThreadInput) => CommandEffect = Effect.fn( | ||
| 213 | "EnvironmentCommands.unpinThread", | ||
| 214 | )(function* (input) { | ||
| 215 | return yield* dispatch({ | ||
| 216 | ...input, | ||
| 217 | type: "thread.unpin", | ||
| 218 | commandId: yield* commandId(input), | ||
| 219 | }); | ||
| 220 | }); | ||
| 221 | |||
| 200 | export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( | 222 | export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( |
| 201 | "EnvironmentCommands.updateThreadMetadata", | 223 | "EnvironmentCommands.updateThreadMetadata", |
| 202 | )(function* (input) { | 224 | )(function* (input) { |
packages/client-runtime/src/state/threadCommands.ts+18| ... | @@ -12,11 +12,13 @@ import { | ... | @@ -12,11 +12,13 @@ import { |
| 12 | type RevertThreadCheckpointInput, | 12 | type RevertThreadCheckpointInput, |
| 13 | type SetThreadInteractionModeInput, | 13 | type SetThreadInteractionModeInput, |
| 14 | type SetThreadRuntimeModeInput, | 14 | type SetThreadRuntimeModeInput, |
| 15 | type PinThreadInput, | ||
| 15 | type SettleThreadInput, | 16 | type SettleThreadInput, |
| 16 | type SnoozeThreadInput, | 17 | type SnoozeThreadInput, |
| 17 | type StartThreadTurnInput, | 18 | type StartThreadTurnInput, |
| 18 | type StopThreadSessionInput, | 19 | type StopThreadSessionInput, |
| 19 | type UnarchiveThreadInput, | 20 | type UnarchiveThreadInput, |
| 21 | type UnpinThreadInput, | ||
| 20 | type UnsettleThreadInput, | 22 | type UnsettleThreadInput, |
| 21 | type UnsnoozeThreadInput, | 23 | type UnsnoozeThreadInput, |
| 22 | type UpdateThreadMetadataInput, | 24 | type UpdateThreadMetadataInput, |
| ... | @@ -29,11 +31,13 @@ import { | ... | @@ -29,11 +31,13 @@ import { |
| 29 | revertThreadCheckpoint, | 31 | revertThreadCheckpoint, |
| 30 | setThreadInteractionMode, | 32 | setThreadInteractionMode, |
| 31 | setThreadRuntimeMode, | 33 | setThreadRuntimeMode, |
| 34 | pinThread, | ||
| 32 | settleThread, | 35 | settleThread, |
| 33 | snoozeThread, | 36 | snoozeThread, |
| 34 | startThreadTurn, | 37 | startThreadTurn, |
| 35 | stopThreadSession, | 38 | stopThreadSession, |
| 36 | unarchiveThread, | 39 | unarchiveThread, |
| 40 | unpinThread, | ||
| 37 | unsettleThread, | 41 | unsettleThread, |
| 38 | unsnoozeThread, | 42 | unsnoozeThread, |
| 39 | updateThreadMetadata, | 43 | updateThreadMetadata, |
| ... | @@ -50,11 +54,13 @@ export type { | ... | @@ -50,11 +54,13 @@ export type { |
| 50 | RevertThreadCheckpointInput, | 54 | RevertThreadCheckpointInput, |
| 51 | SetThreadInteractionModeInput, | 55 | SetThreadInteractionModeInput, |
| 52 | SetThreadRuntimeModeInput, | 56 | SetThreadRuntimeModeInput, |
| 57 | PinThreadInput, | ||
| 53 | SettleThreadInput, | 58 | SettleThreadInput, |
| 54 | SnoozeThreadInput, | 59 | SnoozeThreadInput, |
| 55 | StartThreadTurnInput, | 60 | StartThreadTurnInput, |
| 56 | StopThreadSessionInput, | 61 | StopThreadSessionInput, |
| 57 | UnarchiveThreadInput, | 62 | UnarchiveThreadInput, |
| 63 | UnpinThreadInput, | ||
| 58 | UnsettleThreadInput, | 64 | UnsettleThreadInput, |
| 59 | UnsnoozeThreadInput, | 65 | UnsnoozeThreadInput, |
| 60 | UpdateThreadMetadataInput, | 66 | UpdateThreadMetadataInput, |
| ... | @@ -118,6 +124,18 @@ export function createThreadEnvironmentAtoms<R, E>( | ... | @@ -118,6 +124,18 @@ export function createThreadEnvironmentAtoms<R, E>( |
| 118 | scheduler, | 124 | scheduler, |
| 119 | concurrency, | 125 | concurrency, |
| 120 | }), | 126 | }), |
| 127 | pin: createEnvironmentCommand(runtime, { | ||
| 128 | label: "environment-data:commands:thread:pin", | ||
| 129 | execute: (input: PinThreadInput) => pinThread(input), | ||
| 130 | scheduler, | ||
| 131 | concurrency, | ||
| 132 | }), | ||
| 133 | unpin: createEnvironmentCommand(runtime, { | ||
| 134 | label: "environment-data:commands:thread:unpin", | ||
| 135 | execute: (input: UnpinThreadInput) => unpinThread(input), | ||
| 136 | scheduler, | ||
| 137 | concurrency, | ||
| 138 | }), | ||
| 121 | updateMetadata: createEnvironmentCommand(runtime, { | 139 | updateMetadata: createEnvironmentCommand(runtime, { |
| 122 | label: "environment-data:commands:thread:update-metadata", | 140 | label: "environment-data:commands:thread:update-metadata", |
| 123 | execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), | 141 | execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), |
packages/client-runtime/src/state/threadDetail.ts+1| ... | @@ -61,6 +61,7 @@ export function mergeEnvironmentThread( | ... | @@ -61,6 +61,7 @@ export function mergeEnvironmentThread( |
| 61 | settledAt: shell.settledAt, | 61 | settledAt: shell.settledAt, |
| 62 | snoozedUntil: shell.snoozedUntil, | 62 | snoozedUntil: shell.snoozedUntil, |
| 63 | snoozedAt: shell.snoozedAt, | 63 | snoozedAt: shell.snoozedAt, |
| 64 | pinnedAt: shell.pinnedAt, | ||
| 64 | session: shell.session, | 65 | session: shell.session, |
| 65 | }; | 66 | }; |
| 66 | } | 67 | } |
packages/client-runtime/src/state/threadReducer.test.ts+49| ... | @@ -231,6 +231,55 @@ describe("applyThreadDetailEvent", () => { | ... | @@ -231,6 +231,55 @@ describe("applyThreadDetailEvent", () => { |
| 231 | }); | 231 | }); |
| 232 | }); | 232 | }); |
| 233 | 233 | ||
| 234 | describe("thread.pinned / thread.unpinned", () => { | ||
| 235 | it("sets pinnedAt", () => { | ||
| 236 | const pinnedAt = "2026-04-01T05:00:00.000Z"; | ||
| 237 | const result = applyThreadDetailEvent(baseThread, { | ||
| 238 | ...baseEventFields, | ||
| 239 | sequence: 5, | ||
| 240 | occurredAt: pinnedAt, | ||
| 241 | aggregateKind: "thread", | ||
| 242 | aggregateId: ThreadId.make("thread-1"), | ||
| 243 | type: "thread.pinned", | ||
| 244 | payload: { | ||
| 245 | threadId: ThreadId.make("thread-1"), | ||
| 246 | pinnedAt, | ||
| 247 | updatedAt: pinnedAt, | ||
| 248 | }, | ||
| 249 | }); | ||
| 250 | |||
| 251 | expect(result.kind).toBe("updated"); | ||
| 252 | if (result.kind === "updated") { | ||
| 253 | expect(result.thread.pinnedAt).toBe(pinnedAt); | ||
| 254 | } | ||
| 255 | }); | ||
| 256 | |||
| 257 | it("clears pinnedAt", () => { | ||
| 258 | const pinnedThread: OrchestrationThread = { | ||
| 259 | ...baseThread, | ||
| 260 | pinnedAt: "2026-04-01T05:00:00.000Z", | ||
| 261 | }; | ||
| 262 | const updatedAt = "2026-04-01T06:00:00.000Z"; | ||
| 263 | const result = applyThreadDetailEvent(pinnedThread, { | ||
| 264 | ...baseEventFields, | ||
| 265 | sequence: 6, | ||
| 266 | occurredAt: updatedAt, | ||
| 267 | aggregateKind: "thread", | ||
| 268 | aggregateId: ThreadId.make("thread-1"), | ||
| 269 | type: "thread.unpinned", | ||
| 270 | payload: { | ||
| 271 | threadId: ThreadId.make("thread-1"), | ||
| 272 | updatedAt, | ||
| 273 | }, | ||
| 274 | }); | ||
| 275 | |||
| 276 | expect(result.kind).toBe("updated"); | ||
| 277 | if (result.kind === "updated") { | ||
| 278 | expect(result.thread.pinnedAt).toBeNull(); | ||
| 279 | } | ||
| 280 | }); | ||
| 281 | }); | ||
| 282 | |||
| 234 | describe("thread.meta-updated", () => { | 283 | describe("thread.meta-updated", () => { |
| 235 | it("patches title and branch", () => { | 284 | it("patches title and branch", () => { |
| 236 | const result = applyThreadDetailEvent(baseThread, { | 285 | const result = applyThreadDetailEvent(baseThread, { |
packages/client-runtime/src/state/threadReducer.ts+20| ... | @@ -167,6 +167,26 @@ export function applyThreadDetailEvent( | ... | @@ -167,6 +167,26 @@ export function applyThreadDetailEvent( |
| 167 | }, | 167 | }, |
| 168 | }; | 168 | }; |
| 169 | 169 | ||
| 170 | case "thread.pinned": | ||
| 171 | return { | ||
| 172 | kind: "updated", | ||
| 173 | thread: { | ||
| 174 | ...thread, | ||
| 175 | pinnedAt: event.payload.pinnedAt, | ||
| 176 | updatedAt: event.payload.updatedAt, | ||
| 177 | }, | ||
| 178 | }; | ||
| 179 | |||
| 180 | case "thread.unpinned": | ||
| 181 | return { | ||
| 182 | kind: "updated", | ||
| 183 | thread: { | ||
| 184 | ...thread, | ||
| 185 | pinnedAt: null, | ||
| 186 | updatedAt: event.payload.updatedAt, | ||
| 187 | }, | ||
| 188 | }; | ||
| 189 | |||
| 170 | // ── Thread metadata ───────────────────────────────────────────── | 190 | // ── Thread metadata ───────────────────────────────────────────── |
| 171 | case "thread.meta-updated": | 191 | case "thread.meta-updated": |
| 172 | return { | 192 | return { |
packages/contracts/src/environment.ts+3| ... | @@ -47,6 +47,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ | ... | @@ -47,6 +47,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 47 | /** Server understands thread.snooze / thread.unsnooze commands. Same | 47 | /** Server understands thread.snooze / thread.unsnooze commands. Same |
| 48 | version-skew contract as threadSettlement. */ | 48 | version-skew contract as threadSettlement. */ |
| 49 | threadSnooze: Schema.optionalKey(Schema.Boolean), | 49 | threadSnooze: Schema.optionalKey(Schema.Boolean), |
| 50 | /** Server understands thread.pin / thread.unpin commands. Same | ||
| 51 | version-skew contract as threadSettlement. */ | ||
| 52 | threadPinning: Schema.optionalKey(Schema.Boolean), | ||
| 50 | /** Server understands regenerateTitle on thread.meta.update. Absent on | 53 | /** Server understands regenerateTitle on thread.meta.update. Absent on |
| 51 | older servers, so clients hide the action instead of sending it. */ | 54 | older servers, so clients hide the action instead of sending it. */ |
| 52 | threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), | 55 | threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), |
packages/contracts/src/orchestration.ts+44| ... | @@ -373,6 +373,10 @@ export const OrchestrationThread = Schema.Struct({ | ... | @@ -373,6 +373,10 @@ export const OrchestrationThread = Schema.Struct({ |
| 373 | // Optional so payloads from pre-snooze servers still decode. | 373 | // Optional so payloads from pre-snooze servers still decode. |
| 374 | snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), | 374 | snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), |
| 375 | snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), | 375 | snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), |
| 376 | // A pin overrides the settled/snoozed lifecycle: while pinnedAt is set the | ||
| 377 | // thread renders in the pinned block and never classifies into a shelf. | ||
| 378 | // Optional so payloads from pre-pinning servers still decode. | ||
| 379 | pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), | ||
| 376 | // Pending-only state. Optional so older servers remain compatible. | 380 | // Pending-only state. Optional so older servers remain compatible. |
| 377 | titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), | 381 | titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), |
| 378 | deletedAt: Schema.NullOr(IsoDateTime), | 382 | deletedAt: Schema.NullOr(IsoDateTime), |
| ... | @@ -427,6 +431,7 @@ export const OrchestrationThreadShell = Schema.Struct({ | ... | @@ -427,6 +431,7 @@ export const OrchestrationThreadShell = Schema.Struct({ |
| 427 | settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), | 431 | settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), |
| 428 | snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), | 432 | snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), |
| 429 | snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), | 433 | snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), |
| 434 | pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), | ||
| 430 | titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), | 435 | titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), |
| 431 | session: Schema.NullOr(OrchestrationSession), | 436 | session: Schema.NullOr(OrchestrationSession), |
| 432 | latestUserMessageAt: Schema.NullOr(IsoDateTime), | 437 | latestUserMessageAt: Schema.NullOr(IsoDateTime), |
| ... | @@ -621,6 +626,18 @@ const ThreadUnsnoozeCommand = Schema.Struct({ | ... | @@ -621,6 +626,18 @@ const ThreadUnsnoozeCommand = Schema.Struct({ |
| 621 | reason: Schema.Literal("user"), | 626 | reason: Schema.Literal("user"), |
| 622 | }); | 627 | }); |
| 623 | 628 | ||
| 629 | const ThreadPinCommand = Schema.Struct({ | ||
| 630 | type: Schema.Literal("thread.pin"), | ||
| 631 | commandId: CommandId, | ||
| 632 | threadId: ThreadId, | ||
| 633 | }); | ||
| 634 | |||
| 635 | const ThreadUnpinCommand = Schema.Struct({ | ||
| 636 | type: Schema.Literal("thread.unpin"), | ||
| 637 | commandId: CommandId, | ||
| 638 | threadId: ThreadId, | ||
| 639 | }); | ||
| 640 | |||
| 624 | const ThreadMetaUpdateCommand = Schema.Struct({ | 641 | const ThreadMetaUpdateCommand = Schema.Struct({ |
| 625 | type: Schema.Literal("thread.meta.update"), | 642 | type: Schema.Literal("thread.meta.update"), |
| 626 | commandId: CommandId, | 643 | commandId: CommandId, |
| ... | @@ -774,6 +791,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ | ... | @@ -774,6 +791,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ |
| 774 | ThreadUnsettleCommand, | 791 | ThreadUnsettleCommand, |
| 775 | ThreadSnoozeCommand, | 792 | ThreadSnoozeCommand, |
| 776 | ThreadUnsnoozeCommand, | 793 | ThreadUnsnoozeCommand, |
| 794 | ThreadPinCommand, | ||
| 795 | ThreadUnpinCommand, | ||
| 777 | ThreadMetaUpdateCommand, | 796 | ThreadMetaUpdateCommand, |
| 778 | ThreadRuntimeModeSetCommand, | 797 | ThreadRuntimeModeSetCommand, |
| 779 | ThreadInteractionModeSetCommand, | 798 | ThreadInteractionModeSetCommand, |
| ... | @@ -799,6 +818,8 @@ export const ClientOrchestrationCommand = Schema.Union([ | ... | @@ -799,6 +818,8 @@ export const ClientOrchestrationCommand = Schema.Union([ |
| 799 | ThreadUnsettleCommand, | 818 | ThreadUnsettleCommand, |
| 800 | ThreadSnoozeCommand, | 819 | ThreadSnoozeCommand, |
| 801 | ThreadUnsnoozeCommand, | 820 | ThreadUnsnoozeCommand, |
| 821 | ThreadPinCommand, | ||
| 822 | ThreadUnpinCommand, | ||
| 802 | ThreadMetaUpdateCommand, | 823 | ThreadMetaUpdateCommand, |
| 803 | ThreadRuntimeModeSetCommand, | 824 | ThreadRuntimeModeSetCommand, |
| 804 | ThreadInteractionModeSetCommand, | 825 | ThreadInteractionModeSetCommand, |
| ... | @@ -914,6 +935,8 @@ export const OrchestrationEventType = Schema.Literals([ | ... | @@ -914,6 +935,8 @@ export const OrchestrationEventType = Schema.Literals([ |
| 914 | "thread.unsettled", | 935 | "thread.unsettled", |
| 915 | "thread.snoozed", | 936 | "thread.snoozed", |
| 916 | "thread.unsnoozed", | 937 | "thread.unsnoozed", |
| 938 | "thread.pinned", | ||
| 939 | "thread.unpinned", | ||
| 917 | "thread.meta-updated", | 940 | "thread.meta-updated", |
| 918 | "thread.runtime-mode-set", | 941 | "thread.runtime-mode-set", |
| 919 | "thread.interaction-mode-set", | 942 | "thread.interaction-mode-set", |
| ... | @@ -1022,6 +1045,17 @@ export const ThreadUnsnoozedPayload = Schema.Struct({ | ... | @@ -1022,6 +1045,17 @@ export const ThreadUnsnoozedPayload = Schema.Struct({ |
| 1022 | updatedAt: IsoDateTime, | 1045 | updatedAt: IsoDateTime, |
| 1023 | }); | 1046 | }); |
| 1024 | 1047 | ||
| 1048 | export const ThreadPinnedPayload = Schema.Struct({ | ||
| 1049 | threadId: ThreadId, | ||
| 1050 | pinnedAt: IsoDateTime, | ||
| 1051 | updatedAt: IsoDateTime, | ||
| 1052 | }); | ||
| 1053 | |||
| 1054 | export const ThreadUnpinnedPayload = Schema.Struct({ | ||
| 1055 | threadId: ThreadId, | ||
| 1056 | updatedAt: IsoDateTime, | ||
| 1057 | }); | ||
| 1058 | |||
| 1025 | export const ThreadMetaUpdatedPayload = Schema.Struct({ | 1059 | export const ThreadMetaUpdatedPayload = Schema.Struct({ |
| 1026 | threadId: ThreadId, | 1060 | threadId: ThreadId, |
| 1027 | title: Schema.optional(TrimmedNonEmptyString), | 1061 | title: Schema.optional(TrimmedNonEmptyString), |
| ... | @@ -1216,6 +1250,16 @@ export const OrchestrationEvent = Schema.Union([ | ... | @@ -1216,6 +1250,16 @@ export const OrchestrationEvent = Schema.Union([ |
| 1216 | type: Schema.Literal("thread.unsnoozed"), | 1250 | type: Schema.Literal("thread.unsnoozed"), |
| 1217 | payload: ThreadUnsnoozedPayload, | 1251 | payload: ThreadUnsnoozedPayload, |
| 1218 | }), | 1252 | }), |
| 1253 | Schema.Struct({ | ||
| 1254 | ...EventBaseFields, | ||
| 1255 | type: Schema.Literal("thread.pinned"), | ||
| 1256 | payload: ThreadPinnedPayload, | ||
| 1257 | }), | ||
| 1258 | Schema.Struct({ | ||
| 1259 | ...EventBaseFields, | ||
| 1260 | type: Schema.Literal("thread.unpinned"), | ||
| 1261 | payload: ThreadUnpinnedPayload, | ||
| 1262 | }), | ||
| 1219 | Schema.Struct({ | 1263 | Schema.Struct({ |
| 1220 | ...EventBaseFields, | 1264 | ...EventBaseFields, |
| 1221 | type: Schema.Literal("thread.meta-updated"), | 1265 | type: Schema.Literal("thread.meta-updated"), |