| 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 | 50 | IconMinus, |
| 51 | 51 | IconNetwork, |
| 52 | 52 | IconPalette, |
| 53 | IconPin, | |
| 54 | IconPinnedOff, | |
| 53 | 55 | IconPlayerPlay, |
| 54 | 56 | IconPlayerStopFilled, |
| 55 | 57 | IconPlus, |
| ... | ... | @@ -121,6 +123,8 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 121 | 123 | magnifyingglass: IconSearch, |
| 122 | 124 | paintbrush: IconPalette, |
| 123 | 125 | "person.crop.circle": IconUserCircle, |
| 126 | pin: IconPin, | |
| 127 | "pin.slash": IconPinnedOff, | |
| 124 | 128 | play: IconPlayerPlay, |
| 125 | 129 | plus: IconPlus, |
| 126 | 130 | "qrcode.viewfinder": IconQrcode, |
apps/mobile/src/features/home/HomeRouteScreen.tsx+4| ... | ... | @@ -42,6 +42,8 @@ export function HomeRouteScreen() { |
| 42 | 42 | settleThread, |
| 43 | 43 | snoozeThread, |
| 44 | 44 | unsnoozeThread, |
| 45 | pinThread, | |
| 46 | unpinThread, | |
| 45 | 47 | unsettleThread, |
| 46 | 48 | } = useThreadListActions(); |
| 47 | 49 | const pendingTasks = usePendingNewTasks(); |
| ... | ... | @@ -155,6 +157,8 @@ export function HomeRouteScreen() { |
| 155 | 157 | onSnoozeThread={snoozeThread} |
| 156 | 158 | onUnsnoozeThread={unsnoozeThread} |
| 157 | 159 | onUnsettleThread={unsettleThread} |
| 160 | onPinThread={pinThread} | |
| 161 | onUnpinThread={unpinThread} | |
| 158 | 162 | onEnvironmentChange={setSelectedEnvironmentId} |
| 159 | 163 | onProjectChange={setSelectedProjectKey} |
| 160 | 164 | onOpenEnvironments={() => |
apps/mobile/src/features/home/HomeScreen.tsx+30| ... | ... | @@ -111,6 +111,8 @@ interface HomeScreenProps { |
| 111 | 111 | ) => Promise<boolean>; |
| 112 | 112 | readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>; |
| 113 | 113 | readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; |
| 114 | readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | |
| 115 | readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | |
| 114 | 116 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; |
| 115 | 117 | readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; |
| 116 | 118 | readonly onNewThreadInProject: (project: EnvironmentProject) => void; |
| ... | ... | @@ -516,6 +518,18 @@ export function HomeScreen(props: HomeScreenProps) { |
| 516 | 518 | }, |
| 517 | 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 | 533 | const handleDeleteThread = props.onDeleteThread; |
| 520 | 534 | const handleUnsettleThread = props.onUnsettleThread; |
| 521 | 535 | // The settled tail renders in pages; expansion resets when the filter |
| ... | ... | @@ -575,6 +589,15 @@ export function HomeScreen(props: HomeScreenProps) { |
| 575 | 589 | } |
| 576 | 590 | return supported; |
| 577 | 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 | 601 | const threadListV2Layout = useMemo(() => { |
| 579 | 602 | if (!threadListV2Enabled) |
| 580 | 603 | return { |
| ... | ... | @@ -718,6 +741,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 718 | 741 | thread={thread} |
| 719 | 742 | variant={item.item.variant} |
| 720 | 743 | snoozed={item.item.snoozed} |
| 744 | pinned={item.item.pinned} | |
| 721 | 745 | snoozePresetMinute={nowMinute} |
| 722 | 746 | snoozeWakeLabelText={item.snoozeWakeLabelText} |
| 723 | 747 | project={ |
| ... | ... | @@ -753,9 +777,12 @@ export function HomeScreen(props: HomeScreenProps) { |
| 753 | 777 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} |
| 754 | 778 | onSettleThread={handleSettleThread} |
| 755 | 779 | snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} |
| 780 | pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} | |
| 756 | 781 | onSnoozeThread={handleSnoozeThread} |
| 757 | 782 | onUnsnoozeThread={handleUnsnoozeThread} |
| 758 | 783 | onUnsettleThread={handleUnsettleThread} |
| 784 | onPinThread={handlePinThread} | |
| 785 | onUnpinThread={handleUnpinThread} | |
| 759 | 786 | onChangeRequestState={handleChangeRequestState} |
| 760 | 787 | projectCwd={ |
| 761 | 788 | projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null |
| ... | ... | @@ -768,12 +795,15 @@ export function HomeScreen(props: HomeScreenProps) { |
| 768 | 795 | [ |
| 769 | 796 | handleChangeRequestState, |
| 770 | 797 | handleDeleteThread, |
| 798 | handlePinThread, | |
| 771 | 799 | handleSettleThread, |
| 772 | 800 | handleSnoozeThread, |
| 801 | handleUnpinThread, | |
| 773 | 802 | handleUnsnoozeThread, |
| 774 | 803 | handleSwipeableClose, |
| 775 | 804 | handleSwipeableWillOpen, |
| 776 | 805 | handleUnsettleThread, |
| 806 | pinningEnvironmentIds, | |
| 777 | 807 | projectByKey, |
| 778 | 808 | projectCwdByKey, |
| 779 | 809 | props.onArchiveThread, |
apps/mobile/src/features/home/useThreadListActions.ts+69| ... | ... | @@ -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 | 39 | type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; |
| 33 | 40 | |
| 34 | 41 | const ACTION_VERBS: Record<ThreadListAction, string> = { |
| ... | ... | @@ -202,10 +209,14 @@ export function useThreadListActions(): { |
| 202 | 209 | readonly snoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => Promise<boolean>; |
| 203 | 210 | readonly unsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>; |
| 204 | 211 | readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>; |
| 212 | readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | |
| 213 | readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | |
| 205 | 214 | } { |
| 206 | 215 | const executeAction = useThreadActionExecutor(); |
| 207 | 216 | const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); |
| 208 | 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 | 220 | const snoozeInFlightThreadKeys = useRef(new Set<string>()); |
| 210 | 221 | |
| 211 | 222 | const archiveThread = useCallback( |
| ... | ... | @@ -310,6 +321,62 @@ export function useThreadListActions(): { |
| 310 | 321 | async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true, |
| 311 | 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 | 381 | const confirmDeleteThread = useConfirmDeleteThread(executeAction); |
| 315 | 382 | |
| ... | ... | @@ -320,6 +387,8 @@ export function useThreadListActions(): { |
| 320 | 387 | snoozeThread, |
| 321 | 388 | unsnoozeThread, |
| 322 | 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 | 205 | snoozeThread, |
| 206 | 206 | unsnoozeThread, |
| 207 | 207 | unsettleThread, |
| 208 | pinThread, | |
| 209 | unpinThread, | |
| 208 | 210 | } = useThreadListActions(); |
| 209 | 211 | const threadListV2Enabled = useThreadListV2Enabled(); |
| 210 | 212 | const pendingTasks = usePendingNewTasks(); |
| ... | ... | @@ -481,6 +483,15 @@ function ThreadNavigationSidebarPane( |
| 481 | 483 | } |
| 482 | 484 | return supported; |
| 483 | 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 | 495 | const threadListV2Layout = useMemo(() => { |
| 485 | 496 | if (!threadListV2Enabled) |
| 486 | 497 | return { |
| ... | ... | @@ -792,6 +803,7 @@ function ThreadNavigationSidebarPane( |
| 792 | 803 | previous.item.thread === item.item.thread && |
| 793 | 804 | previous.item.variant === item.item.variant && |
| 794 | 805 | previous.item.snoozed === item.item.snoozed && |
| 806 | previous.item.pinned === item.item.pinned && | |
| 795 | 807 | previous.snoozeWakeLabelText === item.snoozeWakeLabelText |
| 796 | 808 | ); |
| 797 | 809 | } |
| ... | ... | @@ -879,6 +891,7 @@ function ThreadNavigationSidebarPane( |
| 879 | 891 | thread={thread} |
| 880 | 892 | variant={item.item.variant} |
| 881 | 893 | snoozed={item.item.snoozed} |
| 894 | pinned={item.item.pinned} | |
| 882 | 895 | snoozePresetMinute={nowMinute} |
| 883 | 896 | snoozeWakeLabelText={item.snoozeWakeLabelText} |
| 884 | 897 | project={projectByKey.get(scopeKey) ?? null} |
| ... | ... | @@ -915,9 +928,12 @@ function ThreadNavigationSidebarPane( |
| 915 | 928 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} |
| 916 | 929 | onSettleThread={settleThread} |
| 917 | 930 | snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} |
| 931 | pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} | |
| 918 | 932 | onSnoozeThread={snoozeThread} |
| 919 | 933 | onUnsnoozeThread={unsnoozeThread} |
| 920 | 934 | onUnsettleThread={unsettleThread} |
| 935 | onPinThread={pinThread} | |
| 936 | onUnpinThread={unpinThread} | |
| 921 | 937 | onChangeRequestState={handleChangeRequestState} |
| 922 | 938 | projectCwd={projectCwdByKey.get(scopeKey) ?? null} |
| 923 | 939 | onSwipeableClose={handleSwipeableClose} |
| ... | ... | @@ -1045,6 +1061,8 @@ function ThreadNavigationSidebarPane( |
| 1045 | 1061 | handleSwipeableClose, |
| 1046 | 1062 | handleSwipeableWillOpen, |
| 1047 | 1063 | openPendingTask, |
| 1064 | pinThread, | |
| 1065 | pinningEnvironmentIds, | |
| 1048 | 1066 | projectByKey, |
| 1049 | 1067 | projectCwdByKey, |
| 1050 | 1068 | projectTitleByProjectKey, |
| ... | ... | @@ -1064,6 +1082,7 @@ function ThreadNavigationSidebarPane( |
| 1064 | 1082 | nowMinute, |
| 1065 | 1083 | toggleSettledShelf, |
| 1066 | 1084 | toggleSnoozedShelf, |
| 1085 | unpinThread, | |
| 1067 | 1086 | unsettleThread, |
| 1068 | 1087 | unsnoozeThread, |
| 1069 | 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 | 305 | readonly variant: "card" | "slim"; |
| 306 | 306 | /** Snoozed-shelf row: shows its wake time and offers Wake. */ |
| 307 | 307 | readonly snoozed?: boolean; |
| 308 | /** Pinned-block row: shows the pin glyph and offers Unpin. */ | |
| 309 | readonly pinned?: boolean; | |
| 308 | 310 | /** Preformatted against the parent minute tick so this memoized row's |
| 309 | 311 | countdown keeps moving. */ |
| 310 | 312 | readonly snoozeWakeLabelText?: string; |
| ... | ... | @@ -337,11 +339,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 337 | 339 | readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void; |
| 338 | 340 | readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; |
| 339 | 341 | readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; |
| 342 | readonly onPinThread: (thread: EnvironmentThreadShell) => void; | |
| 343 | readonly onUnpinThread: (thread: EnvironmentThreadShell) => void; | |
| 340 | 344 | /** False on environments whose server predates thread.settle/unsettle: |
| 341 | 345 | swipe + menu fall back to Archive instead of failing on use. */ |
| 342 | 346 | readonly settlementSupported: boolean; |
| 343 | 347 | /** False on servers that predate thread.snooze/unsnooze. */ |
| 344 | 348 | readonly snoozeSupported: boolean; |
| 349 | /** False on servers that predate thread.pin/unpin. */ | |
| 350 | readonly pinningSupported: boolean; | |
| 345 | 351 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; |
| 346 | 352 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; |
| 347 | 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 | 374 | onUnsnoozeThread, |
| 369 | 375 | onUnsettleThread, |
| 370 | 376 | onArchiveThread, |
| 377 | onPinThread, | |
| 378 | onUnpinThread, | |
| 371 | 379 | onChangeRequestState, |
| 372 | 380 | } = props; |
| 373 | 381 | const snoozedRow = props.snoozed === true; |
| 382 | const pinnedRow = props.pinned === true; | |
| 374 | 383 | |
| 375 | 384 | const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); |
| 376 | 385 | const prState = pr?.state ?? null; |
| ... | ... | @@ -383,6 +392,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 383 | 392 | const drawerColor = useThemeColor("--color-drawer"); |
| 384 | 393 | const pressedBackgroundColor = useThemeColor("--color-subtle"); |
| 385 | 394 | const selectedBackgroundColor = useThemeColor("--color-user-bubble"); |
| 395 | const pinTintColor = useThemeColor("--color-foreground-muted"); | |
| 386 | 396 | const sidebarPane = props.pane === "sidebar"; |
| 387 | 397 | const selected = props.selected === true; |
| 388 | 398 | |
| ... | ... | @@ -398,6 +408,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 398 | 408 | ); |
| 399 | 409 | const handleUnsnooze = useCallback(() => onUnsnoozeThread(thread), [onUnsnoozeThread, thread]); |
| 400 | 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 | 413 | const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); |
| 402 | 414 | |
| 403 | 415 | // Swipe: the v2 primary action is the lifecycle transition. Every settled |
| ... | ... | @@ -434,6 +446,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 434 | 446 | })), |
| 435 | 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 | 463 | const snoozableCardMenuActions = useMemo<MenuAction[]>( |
| 438 | 464 | () => [ |
| 439 | 465 | { id: "settle", title: "Settle", image: "checkmark" }, |
| ... | ... | @@ -443,15 +469,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 443 | 469 | image: "clock", |
| 444 | 470 | subactions: snoozePresetActions, |
| 445 | 471 | }, |
| 472 | ...pinMenuItem, | |
| 446 | 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 | 481 | const handleMenuAction = useCallback( |
| 451 | 482 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { |
| 452 | 483 | if (nativeEvent.event === "settle") handleSettle(); |
| 453 | 484 | if (nativeEvent.event === "unsettle") handleUnsettle(); |
| 454 | 485 | if (nativeEvent.event === "unsnooze") handleUnsnooze(); |
| 486 | if (nativeEvent.event === "pin") handlePin(); | |
| 487 | if (nativeEvent.event === "unpin") handleUnpin(); | |
| 455 | 488 | if (nativeEvent.event === "archive") handleArchive(); |
| 456 | 489 | if (nativeEvent.event === "delete") handleDelete(); |
| 457 | 490 | const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ |
| ... | ... | @@ -468,8 +501,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 468 | 501 | [ |
| 469 | 502 | handleArchive, |
| 470 | 503 | handleDelete, |
| 504 | handlePin, | |
| 471 | 505 | handleSettle, |
| 472 | 506 | handleSnooze, |
| 507 | handleUnpin, | |
| 473 | 508 | handleUnsettle, |
| 474 | 509 | handleUnsnooze, |
| 475 | 510 | snoozePresets, |
| ... | ... | @@ -560,6 +595,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 560 | 595 | > |
| 561 | 596 | {props.projectTitle ?? props.project?.title ?? ""} |
| 562 | 597 | </Text> |
| 598 | {pinnedRow ? ( | |
| 599 | <SymbolView name="pin" size={11} tintColor={pinTintColor} type="monochrome" /> | |
| 600 | ) : null} | |
| 563 | 601 | <Text |
| 564 | 602 | className={cn( |
| 565 | 603 | "text-xs tabular-nums", |
| ... | ... | @@ -805,7 +843,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 805 | 843 | ? SLIM_MENU_ACTIONS |
| 806 | 844 | : swipeActions.secondary === "snooze" |
| 807 | 845 | ? snoozableCardMenuActions |
| 808 | : CARD_MENU_ACTIONS | |
| 846 | : cardMenuActions | |
| 809 | 847 | } |
| 810 | 848 | onPressAction={handleMenuAction} |
| 811 | 849 | shouldOpenOnLongPress |
apps/mobile/src/features/threads/threadListV2.test.ts+51| ... | ... | @@ -288,6 +288,57 @@ describe("buildThreadListV2Items", () => { |
| 288 | 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 | 342 | it("classifies snooze with the second-precise clock and reports the next wake", () => { |
| 292 | 343 | const layout = buildThreadListV2Items({ |
| 293 | 344 | threads: [ |
apps/mobile/src/features/threads/threadListV2.ts+25-3| ... | ... | @@ -180,6 +180,8 @@ export interface ThreadListV2Item { |
| 180 | 180 | readonly variant: "card" | "slim"; |
| 181 | 181 | /** Snoozed-shelf row: shows the wake countdown and offers Wake. */ |
| 182 | 182 | readonly snoozed: boolean; |
| 183 | /** Pinned-block row: renders the pin glyph and offers Unpin. */ | |
| 184 | readonly pinned: boolean; | |
| 183 | 185 | readonly isLast: boolean; |
| 184 | 186 | } |
| 185 | 187 | |
| ... | ... | @@ -351,6 +353,7 @@ export function buildThreadListV2Items(input: { |
| 351 | 353 | ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) |
| 352 | 354 | : null; |
| 353 | 355 | |
| 356 | const pinned: EnvironmentThreadShell[] = []; | |
| 354 | 357 | const active: EnvironmentThreadShell[] = []; |
| 355 | 358 | const settled: EnvironmentThreadShell[] = []; |
| 356 | 359 | const snoozed: EnvironmentThreadShell[] = []; |
| ... | ... | @@ -378,9 +381,10 @@ export function buildThreadListV2Items(input: { |
| 378 | 381 | const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; |
| 379 | 382 | const changeRequestState = |
| 380 | 383 | input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; |
| 381 | // Visibility parity with web: a snoozed thread leaves the list until it | |
| 382 | // wakes (or raises its hand — effectiveSnoozed refuses blocked/failed | |
| 383 | // work). Snooze outranks settled classification, same as web. | |
| 384 | // Visibility parity with web: snooze outranks everything, including a | |
| 385 | // pin — a snoozed thread leaves the list until it wakes (or raises its | |
| 386 | // hand). The pin survives underneath, so a woken thread reappears at | |
| 387 | // its original spot in the creation-ordered pinned block. | |
| 384 | 388 | if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { |
| 385 | 389 | snoozed.push(thread); |
| 386 | 390 | if ( |
| ... | ... | @@ -392,6 +396,12 @@ export function buildThreadListV2Items(input: { |
| 392 | 396 | } |
| 393 | 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 | 405 | if ( |
| 396 | 406 | supportsSettlement && |
| 397 | 407 | effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) |
| ... | ... | @@ -434,11 +444,21 @@ export function buildThreadListV2Items(input: { |
| 434 | 444 | ); |
| 435 | 445 | |
| 436 | 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 | 456 | for (const thread of orderedActive) { |
| 438 | 457 | items.push({ |
| 439 | 458 | thread, |
| 440 | 459 | variant: "card", |
| 441 | 460 | snoozed: false, |
| 461 | pinned: false, | |
| 442 | 462 | isLast: false, |
| 443 | 463 | }); |
| 444 | 464 | } |
| ... | ... | @@ -448,6 +468,7 @@ export function buildThreadListV2Items(input: { |
| 448 | 468 | thread, |
| 449 | 469 | variant: "slim", |
| 450 | 470 | snoozed: true, |
| 471 | pinned: false, | |
| 451 | 472 | isLast: false, |
| 452 | 473 | }); |
| 453 | 474 | } |
| ... | ... | @@ -457,6 +478,7 @@ export function buildThreadListV2Items(input: { |
| 457 | 478 | thread, |
| 458 | 479 | variant: "slim", |
| 459 | 480 | snoozed: false, |
| 481 | pinned: false, | |
| 460 | 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 | 145 | connectionProbe: true, |
| 146 | 146 | threadSettlement: true, |
| 147 | 147 | threadSnooze: true, |
| 148 | threadPinning: true, | |
| 148 | 149 | threadTitleRegeneration: true, |
| 149 | 150 | ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), |
| 150 | 151 | ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+31| ... | ... | @@ -611,6 +611,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 611 | 611 | settledAt: null, |
| 612 | 612 | snoozedUntil: null, |
| 613 | 613 | snoozedAt: null, |
| 614 | pinnedAt: null, | |
| 614 | 615 | titleRegenerationRequestId: null, |
| 615 | 616 | titleRegenerationStartedAt: null, |
| 616 | 617 | latestUserMessageAt: null, |
| ... | ... | @@ -717,6 +718,36 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 717 | 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 | 751 | case "thread.meta-updated": { |
| 721 | 752 | const existingRow = yield* projectionThreadRepository.getById({ |
| 722 | 753 | threadId: event.payload.threadId, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2| ... | ... | @@ -312,6 +312,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 312 | 312 | settledAt: null, |
| 313 | 313 | snoozedUntil: null, |
| 314 | 314 | snoozedAt: null, |
| 315 | pinnedAt: null, | |
| 315 | 316 | titleRegeneration: null, |
| 316 | 317 | deletedAt: null, |
| 317 | 318 | messages: [ |
| ... | ... | @@ -427,6 +428,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 427 | 428 | settledAt: null, |
| 428 | 429 | snoozedUntil: null, |
| 429 | 430 | snoozedAt: null, |
| 431 | pinnedAt: null, | |
| 430 | 432 | titleRegeneration: null, |
| 431 | 433 | session: { |
| 432 | 434 | threadId: ThreadId.make("thread-1"), |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+10| ... | ... | @@ -384,6 +384,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 384 | 384 | settled_at AS "settledAt", |
| 385 | 385 | snoozed_until AS "snoozedUntil", |
| 386 | 386 | snoozed_at AS "snoozedAt", |
| 387 | pinned_at AS "pinnedAt", | |
| 387 | 388 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 388 | 389 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 389 | 390 | latest_user_message_at AS "latestUserMessageAt", |
| ... | ... | @@ -418,6 +419,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 418 | 419 | settled_at AS "settledAt", |
| 419 | 420 | snoozed_until AS "snoozedUntil", |
| 420 | 421 | snoozed_at AS "snoozedAt", |
| 422 | pinned_at AS "pinnedAt", | |
| 421 | 423 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 422 | 424 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 423 | 425 | latest_user_message_at AS "latestUserMessageAt", |
| ... | ... | @@ -454,6 +456,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 454 | 456 | settled_at AS "settledAt", |
| 455 | 457 | snoozed_until AS "snoozedUntil", |
| 456 | 458 | snoozed_at AS "snoozedAt", |
| 459 | pinned_at AS "pinnedAt", | |
| 457 | 460 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 458 | 461 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 459 | 462 | latest_user_message_at AS "latestUserMessageAt", |
| ... | ... | @@ -890,6 +893,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 890 | 893 | settled_at AS "settledAt", |
| 891 | 894 | snoozed_until AS "snoozedUntil", |
| 892 | 895 | snoozed_at AS "snoozedAt", |
| 896 | pinned_at AS "pinnedAt", | |
| 893 | 897 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 894 | 898 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 895 | 899 | latest_user_message_at AS "latestUserMessageAt", |
| ... | ... | @@ -1328,6 +1332,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1328 | 1332 | settledAt: row.settledAt, |
| 1329 | 1333 | snoozedUntil: row.snoozedUntil, |
| 1330 | 1334 | snoozedAt: row.snoozedAt, |
| 1335 | pinnedAt: row.pinnedAt, | |
| 1331 | 1336 | titleRegeneration: mapTitleRegeneration(row), |
| 1332 | 1337 | deletedAt: row.deletedAt, |
| 1333 | 1338 | messages: messagesByThread.get(row.threadId) ?? [], |
| ... | ... | @@ -1531,6 +1536,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1531 | 1536 | settledAt: row.settledAt, |
| 1532 | 1537 | snoozedUntil: row.snoozedUntil, |
| 1533 | 1538 | snoozedAt: row.snoozedAt, |
| 1539 | pinnedAt: row.pinnedAt, | |
| 1534 | 1540 | titleRegeneration: mapTitleRegeneration(row), |
| 1535 | 1541 | deletedAt: row.deletedAt, |
| 1536 | 1542 | messages: [], |
| ... | ... | @@ -1665,6 +1671,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1665 | 1671 | settledAt: row.settledAt, |
| 1666 | 1672 | snoozedUntil: row.snoozedUntil, |
| 1667 | 1673 | snoozedAt: row.snoozedAt, |
| 1674 | pinnedAt: row.pinnedAt, | |
| 1668 | 1675 | titleRegeneration: mapTitleRegeneration(row), |
| 1669 | 1676 | session: sessionByThread.get(row.threadId) ?? null, |
| 1670 | 1677 | latestUserMessageAt: row.latestUserMessageAt, |
| ... | ... | @@ -1804,6 +1811,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1804 | 1811 | settledAt: row.settledAt, |
| 1805 | 1812 | snoozedUntil: row.snoozedUntil, |
| 1806 | 1813 | snoozedAt: row.snoozedAt, |
| 1814 | pinnedAt: row.pinnedAt, | |
| 1807 | 1815 | titleRegeneration: mapTitleRegeneration(row), |
| 1808 | 1816 | session: sessionByThread.get(row.threadId) ?? null, |
| 1809 | 1817 | latestUserMessageAt: row.latestUserMessageAt, |
| ... | ... | @@ -2075,6 +2083,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 2075 | 2083 | settledAt: threadRow.value.settledAt, |
| 2076 | 2084 | snoozedUntil: threadRow.value.snoozedUntil, |
| 2077 | 2085 | snoozedAt: threadRow.value.snoozedAt, |
| 2086 | pinnedAt: threadRow.value.pinnedAt, | |
| 2078 | 2087 | titleRegeneration: mapTitleRegeneration(threadRow.value), |
| 2079 | 2088 | session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, |
| 2080 | 2089 | latestUserMessageAt: threadRow.value.latestUserMessageAt, |
| ... | ... | @@ -2174,6 +2183,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 2174 | 2183 | settledAt: threadRow.value.settledAt, |
| 2175 | 2184 | snoozedUntil: threadRow.value.snoozedUntil, |
| 2176 | 2185 | snoozedAt: threadRow.value.snoozedAt, |
| 2186 | pinnedAt: threadRow.value.pinnedAt, | |
| 2177 | 2187 | titleRegeneration: mapTitleRegeneration(threadRow.value), |
| 2178 | 2188 | deletedAt: null, |
| 2179 | 2189 | messages: messageRows.map((row) => { |
apps/server/src/orchestration/Schemas.ts+4| ... | ... | @@ -13,6 +13,8 @@ import { |
| 13 | 13 | ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, |
| 14 | 14 | ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema, |
| 15 | 15 | ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, |
| 16 | ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, | |
| 17 | ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, | |
| 16 | 18 | ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, |
| 17 | 19 | ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, |
| 18 | 20 | ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, |
| ... | ... | @@ -42,6 +44,8 @@ export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; |
| 42 | 44 | export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; |
| 43 | 45 | export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; |
| 44 | 46 | export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; |
| 47 | export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; | |
| 48 | export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; | |
| 45 | 49 | |
| 46 | 50 | export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; |
| 47 | 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 | 487 | // settledAt: the engine rejects zero-event commands, and bulk-settle / |
| 488 | 488 | // double-click must stay silent no-ops rather than surface errors. |
| 489 | 489 | const alreadySettled = thread.settledOverride === "settled" && thread.settledAt !== null; |
| 490 | return { | |
| 490 | const settledEvent = { | |
| 491 | 491 | ...(yield* withEventBase({ |
| 492 | 492 | aggregateKind: "thread", |
| 493 | 493 | aggregateId: command.threadId, |
| 494 | 494 | occurredAt, |
| 495 | 495 | commandId: command.commandId, |
| 496 | 496 | })), |
| 497 | type: "thread.settled", | |
| 497 | type: "thread.settled" as const, | |
| 498 | 498 | payload: { |
| 499 | 499 | threadId: command.threadId, |
| 500 | 500 | settledAt: alreadySettled ? thread.settledAt : occurredAt, |
| ... | ... | @@ -504,6 +504,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 504 | 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 | 532 | case "thread.unsettle": { |
| ... | ... | @@ -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 | 748 | case "thread.meta.update": { |
| 634 | 749 | const thread = yield* requireThread({ |
| 635 | 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 | 23 | ThreadProposedPlanUpsertedPayload, |
| 24 | 24 | ThreadRuntimeModeSetPayload, |
| 25 | 25 | ThreadSettledPayload, |
| 26 | ThreadPinnedPayload, | |
| 26 | 27 | ThreadSnoozedPayload, |
| 28 | ThreadUnpinnedPayload, | |
| 27 | 29 | ThreadUnarchivedPayload, |
| 28 | 30 | ThreadUnsettledPayload, |
| 29 | 31 | ThreadUnsnoozedPayload, |
| ... | ... | @@ -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 | 421 | case "thread.meta-updated": |
| 398 | 422 | return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( |
| 399 | 423 | Effect.map((payload) => ({ |
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+5| ... | ... | @@ -95,6 +95,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 95 | 95 | settledAt: null, |
| 96 | 96 | snoozedUntil: null, |
| 97 | 97 | snoozedAt: null, |
| 98 | pinnedAt: null, | |
| 98 | 99 | latestUserMessageAt: null, |
| 99 | 100 | pendingApprovalCount: 0, |
| 100 | 101 | pendingUserInputCount: 0, |
| ... | ... | @@ -157,6 +158,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 157 | 158 | settledAt: "2026-03-25T00:00:00.000Z", |
| 158 | 159 | snoozedUntil: "2026-03-26T09:00:00.000Z", |
| 159 | 160 | snoozedAt: "2026-03-25T00:00:00.000Z", |
| 161 | pinnedAt: "2026-03-25T00:00:00.000Z", | |
| 160 | 162 | latestUserMessageAt: null, |
| 161 | 163 | pendingApprovalCount: 0, |
| 162 | 164 | pendingUserInputCount: 0, |
| ... | ... | @@ -175,6 +177,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 175 | 177 | assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z"); |
| 176 | 178 | assert.strictEqual(row.snoozedUntil, "2026-03-26T09:00:00.000Z"); |
| 177 | 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 | 182 | // Un-settle to the keep-active pin and wake the snooze; confirm the |
| 180 | 183 | // flips persist. |
| ... | ... | @@ -184,6 +187,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 184 | 187 | settledAt: null, |
| 185 | 188 | snoozedUntil: null, |
| 186 | 189 | snoozedAt: null, |
| 190 | pinnedAt: null, | |
| 187 | 191 | }); |
| 188 | 192 | const repersisted = yield* threads.getById({ |
| 189 | 193 | threadId: ThreadId.make("thread-settled"), |
| ... | ... | @@ -193,6 +197,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 193 | 197 | assert.strictEqual(updated?.settledAt, null); |
| 194 | 198 | assert.strictEqual(updated?.snoozedUntil, null); |
| 195 | 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 | 47 | settled_at, |
| 48 | 48 | snoozed_until, |
| 49 | 49 | snoozed_at, |
| 50 | pinned_at, | |
| 50 | 51 | title_regeneration_request_id, |
| 51 | 52 | title_regeneration_started_at, |
| 52 | 53 | latest_user_message_at, |
| ... | ... | @@ -72,6 +73,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 72 | 73 | ${row.settledAt}, |
| 73 | 74 | ${row.snoozedUntil}, |
| 74 | 75 | ${row.snoozedAt}, |
| 76 | ${row.pinnedAt}, | |
| 75 | 77 | ${row.titleRegenerationRequestId ?? null}, |
| 76 | 78 | ${row.titleRegenerationStartedAt ?? null}, |
| 77 | 79 | ${row.latestUserMessageAt}, |
| ... | ... | @@ -97,6 +99,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 97 | 99 | settled_at = excluded.settled_at, |
| 98 | 100 | snoozed_until = excluded.snoozed_until, |
| 99 | 101 | snoozed_at = excluded.snoozed_at, |
| 102 | pinned_at = excluded.pinned_at, | |
| 100 | 103 | title_regeneration_request_id = excluded.title_regeneration_request_id, |
| 101 | 104 | title_regeneration_started_at = excluded.title_regeneration_started_at, |
| 102 | 105 | latest_user_message_at = excluded.latest_user_message_at, |
| ... | ... | @@ -129,6 +132,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 129 | 132 | settled_at AS "settledAt", |
| 130 | 133 | snoozed_until AS "snoozedUntil", |
| 131 | 134 | snoozed_at AS "snoozedAt", |
| 135 | pinned_at AS "pinnedAt", | |
| 132 | 136 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 133 | 137 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 134 | 138 | latest_user_message_at AS "latestUserMessageAt", |
| ... | ... | @@ -163,6 +167,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 163 | 167 | settled_at AS "settledAt", |
| 164 | 168 | snoozed_until AS "snoozedUntil", |
| 165 | 169 | snoozed_at AS "snoozedAt", |
| 170 | pinned_at AS "pinnedAt", | |
| 166 | 171 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 167 | 172 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 168 | 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 | 48 | import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; |
| 49 | 49 | import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; |
| 50 | 50 | import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; |
| 51 | import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; | |
| 51 | 52 | |
| 52 | 53 | /** |
| 53 | 54 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -95,6 +96,7 @@ export const migrationEntries = [ |
| 95 | 96 | [33, "ProjectionThreadsSettled", Migration0033], |
| 96 | 97 | [34, "ProjectionThreadsSnoozed", Migration0034], |
| 97 | 98 | [35, "ProjectionThreadTitleRegeneration", Migration0035], |
| 99 | [36, "ProjectionThreadsPinned", Migration0036], | |
| 98 | 100 | ] as const; |
| 99 | 101 | |
| 100 | 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 | 41 | settledAt: Schema.NullOr(IsoDateTime), |
| 42 | 42 | snoozedUntil: Schema.NullOr(IsoDateTime), |
| 43 | 43 | snoozedAt: Schema.NullOr(IsoDateTime), |
| 44 | pinnedAt: Schema.NullOr(IsoDateTime), | |
| 44 | 45 | titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), |
| 45 | 46 | titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), |
| 46 | 47 | latestUserMessageAt: Schema.NullOr(IsoDateTime), |
apps/web/src/components/SidebarV2.tsx+187-73| ... | ... | @@ -28,6 +28,7 @@ import { |
| 28 | 28 | GitBranchIcon, |
| 29 | 29 | EllipsisIcon, |
| 30 | 30 | MessageSquareIcon, |
| 31 | PinIcon, | |
| 31 | 32 | PlusIcon, |
| 32 | 33 | SearchIcon, |
| 33 | 34 | ServerIcon, |
| ... | ... | @@ -394,6 +395,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 394 | 395 | settlementSupported: boolean; |
| 395 | 396 | // Same contract for thread.snooze/unsnooze. |
| 396 | 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 | 403 | // Compact wake countdown ("2h") for rows in the snoozed shelf. |
| 398 | 404 | snoozeWakeLabelText: string | null; |
| 399 | 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 | 945 | ) : ( |
| 940 | 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 | 955 | {/* The visible state owns this slot's width: status at rest, |
| 943 | 956 | actions on hover/keyboard focus or while the popover is open. Keeping |
| 944 | 957 | the hidden state out of flow lets the project label reclaim |
| ... | ... | @@ -1186,8 +1199,15 @@ export default function SidebarV2() { |
| 1186 | 1199 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); |
| 1187 | 1200 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); |
| 1188 | 1201 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 1189 | const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, deleteThread } = | |
| 1190 | useThreadActions(); | |
| 1202 | const { | |
| 1203 | settleThread, | |
| 1204 | unsettleThread, | |
| 1205 | snoozeThread, | |
| 1206 | unsnoozeThread, | |
| 1207 | pinThread, | |
| 1208 | unpinThread, | |
| 1209 | deleteThread, | |
| 1210 | } = useThreadActions(); | |
| 1191 | 1211 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { |
| 1192 | 1212 | reportFailure: false, |
| 1193 | 1213 | }); |
| ... | ... | @@ -1564,76 +1584,91 @@ export default function SidebarV2() { |
| 1564 | 1584 | // merging, no optimistic holds. Archived threads remain hidden here — |
| 1565 | 1585 | // archive keeps its original "remove from sidebar" meaning. |
| 1566 | 1586 | const serverConfigs = useAtomValue(environmentServerConfigsAtom); |
| 1567 | const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { | |
| 1568 | const now = `${nowMinute}:00.000Z`; | |
| 1569 | // Snooze classification uses a REAL clock, not the quantized minute: | |
| 1570 | // wake times are second-precise and a woken thread must not linger on | |
| 1571 | // the shelf for the rest of the minute. snoozeWakeTick re-runs this | |
| 1572 | // memo exactly at the next wake boundary. | |
| 1573 | void snoozeWakeTick; | |
| 1574 | const preciseNow = new Date().toISOString(); | |
| 1575 | const visible = threads.filter( | |
| 1576 | (thread) => | |
| 1577 | thread.archivedAt === null && | |
| 1578 | (scopedProjectKeys === null || | |
| 1579 | scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), | |
| 1580 | ); | |
| 1581 | const active: EnvironmentThreadShell[] = []; | |
| 1582 | const snoozed: EnvironmentThreadShell[] = []; | |
| 1583 | const settled: EnvironmentThreadShell[] = []; | |
| 1584 | for (const thread of visible) { | |
| 1585 | // Threads on servers without the settlement capability (old server, | |
| 1586 | // or descriptor not loaded yet) never classify as settled: the user | |
| 1587 | // could neither un-settle nor pin them, so auto-settling them would | |
| 1588 | // strand rows in a tail with no working affordances. | |
| 1589 | const supportsSettlement = | |
| 1590 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; | |
| 1591 | const supportsSnooze = | |
| 1592 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; | |
| 1593 | const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); | |
| 1594 | const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; | |
| 1595 | // Snooze outranks settled classification: an explicitly snoozed thread | |
| 1596 | // belongs to the shelf even if it would also auto-settle (the shelf's | |
| 1597 | // wake time is a stronger statement about when it matters again). | |
| 1598 | if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { | |
| 1599 | snoozed.push(thread); | |
| 1600 | } else if ( | |
| 1601 | supportsSettlement && | |
| 1602 | effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) | |
| 1603 | ) { | |
| 1604 | settled.push(thread); | |
| 1605 | } else { | |
| 1606 | active.push(thread); | |
| 1587 | const { pinnedThreads, activeThreads, snoozedThreads, settledThreads, snoozeNow } = | |
| 1588 | useMemo(() => { | |
| 1589 | const now = `${nowMinute}:00.000Z`; | |
| 1590 | // Snooze classification uses a REAL clock, not the quantized minute: | |
| 1591 | // wake times are second-precise and a woken thread must not linger on | |
| 1592 | // the shelf for the rest of the minute. snoozeWakeTick re-runs this | |
| 1593 | // memo exactly at the next wake boundary. | |
| 1594 | void snoozeWakeTick; | |
| 1595 | const preciseNow = new Date().toISOString(); | |
| 1596 | const visible = threads.filter( | |
| 1597 | (thread) => | |
| 1598 | thread.archivedAt === null && | |
| 1599 | (scopedProjectKeys === null || | |
| 1600 | scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), | |
| 1601 | ); | |
| 1602 | const pinned: EnvironmentThreadShell[] = []; | |
| 1603 | const active: EnvironmentThreadShell[] = []; | |
| 1604 | const snoozed: EnvironmentThreadShell[] = []; | |
| 1605 | const settled: EnvironmentThreadShell[] = []; | |
| 1606 | for (const thread of visible) { | |
| 1607 | // Threads on servers without the settlement capability (old server, | |
| 1608 | // or descriptor not loaded yet) never classify as settled: the user | |
| 1609 | // could neither un-settle nor pin them, so auto-settling them would | |
| 1610 | // strand rows in a tail with no working affordances. | |
| 1611 | const supportsSettlement = | |
| 1612 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === | |
| 1613 | true; | |
| 1614 | const supportsSnooze = | |
| 1615 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; | |
| 1616 | const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); | |
| 1617 | const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; | |
| 1618 | // Snooze outranks everything, including a pin: "hide until Tuesday" | |
| 1619 | // temporarily suspends "keep on top". The pin survives underneath — | |
| 1620 | // pinned cards are creation-ordered, so on wake the thread reappears | |
| 1621 | // at its original spot in the pinned block. (For unpinned threads | |
| 1622 | // this is also the snooze-beats-auto-settle rule: the wake time is a | |
| 1623 | // stronger statement about when the thread matters again.) | |
| 1624 | if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { | |
| 1625 | snoozed.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 | } | |
| 1609 | return { | |
| 1610 | activeThreads: sortThreadsForSidebarV2(active), | |
| 1611 | // Soonest wake first: "what comes back next" is the shelf's question. | |
| 1612 | snoozedThreads: snoozed.toSorted( | |
| 1613 | (left, right) => | |
| 1614 | firstValidTimestampMs(left.snoozedUntil ?? null) - | |
| 1615 | firstValidTimestampMs(right.snoozedUntil ?? null), | |
| 1616 | ), | |
| 1617 | settledThreads: sortSettledThreadsForSidebarV2(settled), | |
| 1618 | snoozeNow: preciseNow, | |
| 1619 | }; | |
| 1620 | }, [ | |
| 1621 | autoSettleAfterDays, | |
| 1622 | changeRequestStateByKey, | |
| 1623 | nowMinute, | |
| 1624 | scopedProjectKeys, | |
| 1625 | serverConfigs, | |
| 1626 | snoozeWakeTick, | |
| 1627 | threads, | |
| 1628 | ]); | |
| 1641 | return { | |
| 1642 | // Same static creation order as the inbox: a pin freezes prominence, | |
| 1643 | // it does not introduce a new ordering scheme. | |
| 1644 | pinnedThreads: sortThreadsForSidebarV2(pinned), | |
| 1645 | activeThreads: sortThreadsForSidebarV2(active), | |
| 1646 | // Soonest wake first: "what comes back next" is the shelf's question. | |
| 1647 | snoozedThreads: snoozed.toSorted( | |
| 1648 | (left, right) => | |
| 1649 | firstValidTimestampMs(left.snoozedUntil ?? null) - | |
| 1650 | firstValidTimestampMs(right.snoozedUntil ?? null), | |
| 1651 | ), | |
| 1652 | settledThreads: sortSettledThreadsForSidebarV2(settled), | |
| 1653 | snoozeNow: preciseNow, | |
| 1654 | }; | |
| 1655 | }, [ | |
| 1656 | autoSettleAfterDays, | |
| 1657 | changeRequestStateByKey, | |
| 1658 | nowMinute, | |
| 1659 | scopedProjectKeys, | |
| 1660 | serverConfigs, | |
| 1661 | snoozeWakeTick, | |
| 1662 | threads, | |
| 1663 | ]); | |
| 1629 | 1664 | |
| 1630 | 1665 | const threadSearchInputRef = useRef<HTMLInputElement>(null); |
| 1631 | 1666 | const [threadSearchQuery, setThreadSearchQuery] = useState(""); |
| 1632 | 1667 | const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); |
| 1633 | 1668 | const isSearchingThreads = threadSearchQuery.trim().length > 0; |
| 1634 | 1669 | const searchableThreads = useMemo( |
| 1635 | () => [...activeThreads, ...snoozedThreads, ...settledThreads], | |
| 1636 | [activeThreads, settledThreads, snoozedThreads], | |
| 1670 | () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], | |
| 1671 | [activeThreads, pinnedThreads, settledThreads, snoozedThreads], | |
| 1637 | 1672 | ); |
| 1638 | 1673 | const threadSearchResults = useMemo( |
| 1639 | 1674 | () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery), |
| ... | ... | @@ -1737,8 +1772,8 @@ export default function SidebarV2() { |
| 1737 | 1772 | }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); |
| 1738 | 1773 | |
| 1739 | 1774 | const orderedThreads = useMemo( |
| 1740 | () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], | |
| 1741 | [activeThreads, visibleSnoozedThreads, renderedSettledThreads], | |
| 1775 | () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], | |
| 1776 | [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads], | |
| 1742 | 1777 | ); |
| 1743 | 1778 | const orderedThreadKeys = useMemo( |
| 1744 | 1779 | () => |
| ... | ... | @@ -2040,6 +2075,42 @@ export default function SidebarV2() { |
| 2040 | 2075 | }, |
| 2041 | 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 | 2114 | // One snooze per thread at a time — same double-dispatch guard as settle. |
| 2044 | 2115 | const snoozingThreadKeysRef = useRef(new Set<string>()); |
| 2045 | 2116 | const attemptSnooze = useCallback( |
| ... | ... | @@ -2203,7 +2274,8 @@ export default function SidebarV2() { |
| 2203 | 2274 | // Post-settle navigation must skip threads settling in this same |
| 2204 | 2275 | // batch — they are all leaving the card block together. Rows that |
| 2205 | 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 | 2279 | const coSettlingKeys = new Set(threadKeys); |
| 2208 | 2280 | for (const threadKey of threadKeys) { |
| 2209 | 2281 | const thread = threadByKeyRef.current.get(threadKey); |
| ... | ... | @@ -2300,12 +2372,15 @@ export default function SidebarV2() { |
| 2300 | 2372 | true; |
| 2301 | 2373 | const supportsSnooze = |
| 2302 | 2374 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; |
| 2375 | const supportsPinning = | |
| 2376 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true; | |
| 2303 | 2377 | const supportsTitleRegeneration = |
| 2304 | 2378 | serverConfigs.get(thread.environmentId)?.environment.capabilities |
| 2305 | 2379 | .threadTitleRegeneration === true; |
| 2306 | 2380 | const isRegeneratingTitle = thread.titleRegeneration != null; |
| 2307 | 2381 | const isSettled = settledThreadKeysRef.current.has(threadKey); |
| 2308 | 2382 | const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); |
| 2383 | const isPinned = thread.pinnedAt != null; | |
| 2309 | 2384 | // Presets resolve at menu-open time (same as the popover). |
| 2310 | 2385 | const snoozePresets = resolveSnoozePresets(new Date()); |
| 2311 | 2386 | const clicked = await settlePromise(() => |
| ... | ... | @@ -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 | 2407 | ...(supportsSettlement |
| 2323 | 2408 | ? [ |
| 2324 | 2409 | isSettled |
| ... | ... | @@ -2400,6 +2485,12 @@ export default function SidebarV2() { |
| 2400 | 2485 | case "unsnooze": |
| 2401 | 2486 | attemptUnsnooze(threadRef); |
| 2402 | 2487 | return; |
| 2488 | case "pin": | |
| 2489 | attemptPin(threadRef); | |
| 2490 | return; | |
| 2491 | case "unpin": | |
| 2492 | attemptUnpin(threadRef); | |
| 2493 | return; | |
| 2403 | 2494 | case "rename": |
| 2404 | 2495 | startThreadRename(threadRef, thread.title); |
| 2405 | 2496 | return; |
| ... | ... | @@ -2474,8 +2565,10 @@ export default function SidebarV2() { |
| 2474 | 2565 | })(); |
| 2475 | 2566 | }, |
| 2476 | 2567 | [ |
| 2568 | attemptPin, | |
| 2477 | 2569 | attemptSettle, |
| 2478 | 2570 | attemptSnooze, |
| 2571 | attemptUnpin, | |
| 2479 | 2572 | attemptUnsettle, |
| 2480 | 2573 | attemptUnsnooze, |
| 2481 | 2574 | confirmThreadDelete, |
| ... | ... | @@ -2830,7 +2923,7 @@ export default function SidebarV2() { |
| 2830 | 2923 | {(() => { |
| 2831 | 2924 | const renderThreadRow = ( |
| 2832 | 2925 | thread: EnvironmentThreadShell, |
| 2833 | section: "active" | "snoozed" | "settled", | |
| 2926 | section: "pinned" | "active" | "snoozed" | "settled", | |
| 2834 | 2927 | ) => { |
| 2835 | 2928 | const threadKey = scopedThreadKey( |
| 2836 | 2929 | scopeThreadRef(thread.environmentId, thread.id), |
| ... | ... | @@ -2839,7 +2932,7 @@ export default function SidebarV2() { |
| 2839 | 2932 | // row: every other thread is a full card. Density comes |
| 2840 | 2933 | // from users (or the auto rules) actually parking work, |
| 2841 | 2934 | // not from the sidebar second-guessing what still matters. |
| 2842 | const isCard = section === "active"; | |
| 2935 | const isCard = section === "active" || section === "pinned"; | |
| 2843 | 2936 | const rowVariant = isCard ? "card" : "slim"; |
| 2844 | 2937 | return ( |
| 2845 | 2938 | <SidebarV2Row |
| ... | ... | @@ -2870,6 +2963,7 @@ export default function SidebarV2() { |
| 2870 | 2963 | serverConfigs.get(thread.environmentId)?.environment.capabilities |
| 2871 | 2964 | .threadSnooze === true |
| 2872 | 2965 | } |
| 2966 | isPinned={section === "pinned"} | |
| 2873 | 2967 | snoozeWakeLabelText={ |
| 2874 | 2968 | section === "snoozed" && thread.snoozedUntil != null |
| 2875 | 2969 | ? snoozeWakeLabel(thread.snoozedUntil, { |
| ... | ... | @@ -2912,9 +3006,25 @@ export default function SidebarV2() { |
| 2912 | 3006 | /> |
| 2913 | 3007 | ); |
| 2914 | 3008 | }; |
| 2915 | const items: ReactNode[] = activeThreads.map((thread) => | |
| 2916 | renderThreadRow(thread, "active"), | |
| 3009 | // Pinned block: full cards above the inbox, closed by a | |
| 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 | 3028 | // Snoozed shelf: between the inbox and Settled — out of the |
| 2919 | 3029 | // way, never gone. The header always renders while anything |
| 2920 | 3030 | // is snoozed (the count is the whole footprint when |
| ... | ... | @@ -3006,7 +3116,11 @@ export default function SidebarV2() { |
| 3006 | 3116 | </TooltipProvider> |
| 3007 | 3117 | ) : null} |
| 3008 | 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 | 3124 | <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60"> |
| 3011 | 3125 | {projects.length === 0 ? ( |
| 3012 | 3126 | <> |
apps/web/src/hooks/useThreadActions.ts+64| ... | ... | @@ -21,6 +21,7 @@ import { useNewThreadHandler } from "./useHandleNewThread"; |
| 21 | 21 | import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; |
| 22 | 22 | import { readLocalApi } from "../localApi"; |
| 23 | 23 | import { |
| 24 | readEnvironmentSupportsPinning, | |
| 24 | 25 | readEnvironmentSupportsSettlement, |
| 25 | 26 | readEnvironmentSupportsSnooze, |
| 26 | 27 | readEnvironmentThreadRefs, |
| ... | ... | @@ -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 | 110 | export function useThreadActions() { |
| 98 | 111 | const closeTerminal = useAtomCommand(terminalEnvironment.close); |
| 99 | 112 | const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { |
| ... | ... | @@ -111,6 +124,12 @@ export function useThreadActions() { |
| 111 | 124 | const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { |
| 112 | 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 | 133 | const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, { |
| 115 | 134 | reportFailure: false, |
| 116 | 135 | }); |
| ... | ... | @@ -472,6 +491,47 @@ export function useThreadActions() { |
| 472 | 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 | 535 | const snoozeThread = useCallback( |
| 476 | 536 | async (target: ScopedThreadRef, snoozedUntil: string) => { |
| 477 | 537 | // Version skew: never send the command to a server that predates it. |
| ... | ... | @@ -565,14 +625,18 @@ export function useThreadActions() { |
| 565 | 625 | unsettleThread, |
| 566 | 626 | snoozeThread, |
| 567 | 627 | unsnoozeThread, |
| 628 | pinThread, | |
| 629 | unpinThread, | |
| 568 | 630 | }), |
| 569 | 631 | [ |
| 570 | 632 | archiveThread, |
| 571 | 633 | confirmAndDeleteThread, |
| 572 | 634 | deleteThread, |
| 635 | pinThread, | |
| 573 | 636 | settleThread, |
| 574 | 637 | snoozeThread, |
| 575 | 638 | unarchiveThread, |
| 639 | unpinThread, | |
| 576 | 640 | unsettleThread, |
| 577 | 641 | unsnoozeThread, |
| 578 | 642 | ], |
apps/web/src/state/entities.ts+9| ... | ... | @@ -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 | 253 | export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { |
| 245 | 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 | 39 | export type UnsettleThreadInput = CommandInput<"thread.unsettle">; |
| 40 | 40 | export type SnoozeThreadInput = CommandInput<"thread.snooze">; |
| 41 | 41 | export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">; |
| 42 | export type PinThreadInput = CommandInput<"thread.pin">; | |
| 43 | export type UnpinThreadInput = CommandInput<"thread.unpin">; | |
| 42 | 44 | export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; |
| 43 | 45 | export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; |
| 44 | 46 | export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; |
| ... | ... | @@ -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 | 222 | export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( |
| 201 | 223 | "EnvironmentCommands.updateThreadMetadata", |
| 202 | 224 | )(function* (input) { |
packages/client-runtime/src/state/threadCommands.ts+18| ... | ... | @@ -12,11 +12,13 @@ import { |
| 12 | 12 | type RevertThreadCheckpointInput, |
| 13 | 13 | type SetThreadInteractionModeInput, |
| 14 | 14 | type SetThreadRuntimeModeInput, |
| 15 | type PinThreadInput, | |
| 15 | 16 | type SettleThreadInput, |
| 16 | 17 | type SnoozeThreadInput, |
| 17 | 18 | type StartThreadTurnInput, |
| 18 | 19 | type StopThreadSessionInput, |
| 19 | 20 | type UnarchiveThreadInput, |
| 21 | type UnpinThreadInput, | |
| 20 | 22 | type UnsettleThreadInput, |
| 21 | 23 | type UnsnoozeThreadInput, |
| 22 | 24 | type UpdateThreadMetadataInput, |
| ... | ... | @@ -29,11 +31,13 @@ import { |
| 29 | 31 | revertThreadCheckpoint, |
| 30 | 32 | setThreadInteractionMode, |
| 31 | 33 | setThreadRuntimeMode, |
| 34 | pinThread, | |
| 32 | 35 | settleThread, |
| 33 | 36 | snoozeThread, |
| 34 | 37 | startThreadTurn, |
| 35 | 38 | stopThreadSession, |
| 36 | 39 | unarchiveThread, |
| 40 | unpinThread, | |
| 37 | 41 | unsettleThread, |
| 38 | 42 | unsnoozeThread, |
| 39 | 43 | updateThreadMetadata, |
| ... | ... | @@ -50,11 +54,13 @@ export type { |
| 50 | 54 | RevertThreadCheckpointInput, |
| 51 | 55 | SetThreadInteractionModeInput, |
| 52 | 56 | SetThreadRuntimeModeInput, |
| 57 | PinThreadInput, | |
| 53 | 58 | SettleThreadInput, |
| 54 | 59 | SnoozeThreadInput, |
| 55 | 60 | StartThreadTurnInput, |
| 56 | 61 | StopThreadSessionInput, |
| 57 | 62 | UnarchiveThreadInput, |
| 63 | UnpinThreadInput, | |
| 58 | 64 | UnsettleThreadInput, |
| 59 | 65 | UnsnoozeThreadInput, |
| 60 | 66 | UpdateThreadMetadataInput, |
| ... | ... | @@ -118,6 +124,18 @@ export function createThreadEnvironmentAtoms<R, E>( |
| 118 | 124 | scheduler, |
| 119 | 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 | 139 | updateMetadata: createEnvironmentCommand(runtime, { |
| 122 | 140 | label: "environment-data:commands:thread:update-metadata", |
| 123 | 141 | execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), |
packages/client-runtime/src/state/threadDetail.ts+1| ... | ... | @@ -61,6 +61,7 @@ export function mergeEnvironmentThread( |
| 61 | 61 | settledAt: shell.settledAt, |
| 62 | 62 | snoozedUntil: shell.snoozedUntil, |
| 63 | 63 | snoozedAt: shell.snoozedAt, |
| 64 | pinnedAt: shell.pinnedAt, | |
| 64 | 65 | session: shell.session, |
| 65 | 66 | }; |
| 66 | 67 | } |
packages/client-runtime/src/state/threadReducer.test.ts+49| ... | ... | @@ -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 | 283 | describe("thread.meta-updated", () => { |
| 235 | 284 | it("patches title and branch", () => { |
| 236 | 285 | const result = applyThreadDetailEvent(baseThread, { |
packages/client-runtime/src/state/threadReducer.ts+20| ... | ... | @@ -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 | 190 | // ── Thread metadata ───────────────────────────────────────────── |
| 171 | 191 | case "thread.meta-updated": |
| 172 | 192 | return { |
packages/contracts/src/environment.ts+3| ... | ... | @@ -47,6 +47,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 47 | 47 | /** Server understands thread.snooze / thread.unsnooze commands. Same |
| 48 | 48 | version-skew contract as threadSettlement. */ |
| 49 | 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 | 53 | /** Server understands regenerateTitle on thread.meta.update. Absent on |
| 51 | 54 | older servers, so clients hide the action instead of sending it. */ |
| 52 | 55 | threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), |
packages/contracts/src/orchestration.ts+44| ... | ... | @@ -373,6 +373,10 @@ export const OrchestrationThread = Schema.Struct({ |
| 373 | 373 | // Optional so payloads from pre-snooze servers still decode. |
| 374 | 374 | snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), |
| 375 | 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 | 380 | // Pending-only state. Optional so older servers remain compatible. |
| 377 | 381 | titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), |
| 378 | 382 | deletedAt: Schema.NullOr(IsoDateTime), |
| ... | ... | @@ -427,6 +431,7 @@ export const OrchestrationThreadShell = Schema.Struct({ |
| 427 | 431 | settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), |
| 428 | 432 | snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), |
| 429 | 433 | snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), |
| 434 | pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), | |
| 430 | 435 | titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), |
| 431 | 436 | session: Schema.NullOr(OrchestrationSession), |
| 432 | 437 | latestUserMessageAt: Schema.NullOr(IsoDateTime), |
| ... | ... | @@ -621,6 +626,18 @@ const ThreadUnsnoozeCommand = Schema.Struct({ |
| 621 | 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 | 641 | const ThreadMetaUpdateCommand = Schema.Struct({ |
| 625 | 642 | type: Schema.Literal("thread.meta.update"), |
| 626 | 643 | commandId: CommandId, |
| ... | ... | @@ -774,6 +791,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ |
| 774 | 791 | ThreadUnsettleCommand, |
| 775 | 792 | ThreadSnoozeCommand, |
| 776 | 793 | ThreadUnsnoozeCommand, |
| 794 | ThreadPinCommand, | |
| 795 | ThreadUnpinCommand, | |
| 777 | 796 | ThreadMetaUpdateCommand, |
| 778 | 797 | ThreadRuntimeModeSetCommand, |
| 779 | 798 | ThreadInteractionModeSetCommand, |
| ... | ... | @@ -799,6 +818,8 @@ export const ClientOrchestrationCommand = Schema.Union([ |
| 799 | 818 | ThreadUnsettleCommand, |
| 800 | 819 | ThreadSnoozeCommand, |
| 801 | 820 | ThreadUnsnoozeCommand, |
| 821 | ThreadPinCommand, | |
| 822 | ThreadUnpinCommand, | |
| 802 | 823 | ThreadMetaUpdateCommand, |
| 803 | 824 | ThreadRuntimeModeSetCommand, |
| 804 | 825 | ThreadInteractionModeSetCommand, |
| ... | ... | @@ -914,6 +935,8 @@ export const OrchestrationEventType = Schema.Literals([ |
| 914 | 935 | "thread.unsettled", |
| 915 | 936 | "thread.snoozed", |
| 916 | 937 | "thread.unsnoozed", |
| 938 | "thread.pinned", | |
| 939 | "thread.unpinned", | |
| 917 | 940 | "thread.meta-updated", |
| 918 | 941 | "thread.runtime-mode-set", |
| 919 | 942 | "thread.interaction-mode-set", |
| ... | ... | @@ -1022,6 +1045,17 @@ export const ThreadUnsnoozedPayload = Schema.Struct({ |
| 1022 | 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 | 1059 | export const ThreadMetaUpdatedPayload = Schema.Struct({ |
| 1026 | 1060 | threadId: ThreadId, |
| 1027 | 1061 | title: Schema.optional(TrimmedNonEmptyString), |
| ... | ... | @@ -1216,6 +1250,16 @@ export const OrchestrationEvent = Schema.Union([ |
| 1216 | 1250 | type: Schema.Literal("thread.unsnoozed"), |
| 1217 | 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 | 1263 | Schema.Struct({ |
| 1220 | 1264 | ...EventBaseFields, |
| 1221 | 1265 | type: Schema.Literal("thread.meta-updated"), |