| author | |
| committer | |
| log | a041981276b4c789fed8132e3b8a320749bf25e8 |
| tree | d8dc4bae1113191985a7f9d3a6c3a731b0afdd7e |
| parent | 964cc279014f512b4fc4c719377403417744ace2 |
| signature | Signed by PGP key B5690EEEBB952194 |
14 files changed, 1257 insertions(+), 316 deletions(-)
apps/mobile/src/components/AppSymbol.tsx+2| ... | ... | @@ -22,6 +22,7 @@ import { |
| 22 | 22 | IconChevronUp, |
| 23 | 23 | IconCircleCheck, |
| 24 | 24 | IconCircleXFilled, |
| 25 | IconClock, | |
| 25 | 26 | IconCopy, |
| 26 | 27 | IconDeviceDesktop, |
| 27 | 28 | IconDots, |
| ... | ... | @@ -96,6 +97,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 96 | 97 | camera: IconCamera, |
| 97 | 98 | checkmark: IconCheck, |
| 98 | 99 | "checkmark.circle": IconCircleCheck, |
| 100 | clock: IconClock, | |
| 99 | 101 | "chevron.down": IconChevronDown, |
| 100 | 102 | "chevron.left": IconChevronLeft, |
| 101 | 103 | "chevron.left.forwardslash.chevron.right": IconCode, |
apps/mobile/src/features/home/HomeRouteScreen.tsx+10-2| ... | ... | @@ -36,8 +36,14 @@ export function HomeRouteScreen() { |
| 36 | 36 | void checkForAppUpdateOnLaunch(); |
| 37 | 37 | }, []); |
| 38 | 38 | |
| 39 | const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = | |
| 40 | useThreadListActions(); | |
| 39 | const { | |
| 40 | archiveThread, | |
| 41 | confirmDeleteThread, | |
| 42 | settleThread, | |
| 43 | snoozeThread, | |
| 44 | unsnoozeThread, | |
| 45 | unsettleThread, | |
| 46 | } = useThreadListActions(); | |
| 41 | 47 | const pendingTasks = usePendingNewTasks(); |
| 42 | 48 | const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); |
| 43 | 49 | const environments = useMemo(() => { |
| ... | ... | @@ -146,6 +152,8 @@ export function HomeRouteScreen() { |
| 146 | 152 | onArchiveThread={archiveThread} |
| 147 | 153 | onDeleteThread={confirmDeleteThread} |
| 148 | 154 | onSettleThread={settleThread} |
| 155 | onSnoozeThread={snoozeThread} | |
| 156 | onUnsnoozeThread={unsnoozeThread} | |
| 149 | 157 | onUnsettleThread={unsettleThread} |
| 150 | 158 | onEnvironmentChange={setSelectedEnvironmentId} |
| 151 | 159 | onProjectChange={setSelectedProjectKey} |
apps/mobile/src/features/home/HomeScreen.tsx+65-27| ... | ... | @@ -41,7 +41,11 @@ import { |
| 41 | 41 | ThreadListRow, |
| 42 | 42 | ThreadListShowMoreRow, |
| 43 | 43 | } from "../threads/thread-list-items"; |
| 44 | import { ThreadListV2PendingRow, ThreadListV2Row } from "../threads/thread-list-v2-items"; | |
| 44 | import { | |
| 45 | ThreadListV2PendingRow, | |
| 46 | ThreadListV2Row, | |
| 47 | ThreadListV2SnoozedShelfHeader, | |
| 48 | } from "../threads/thread-list-v2-items"; | |
| 45 | 49 | import { |
| 46 | 50 | buildThreadListV2Items, |
| 47 | 51 | buildThreadListV2ListItems, |
| ... | ... | @@ -100,6 +104,11 @@ interface HomeScreenProps { |
| 100 | 104 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; |
| 101 | 105 | /** Resolves true iff the settle was dispatched and succeeded. */ |
| 102 | 106 | readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>; |
| 107 | readonly onSnoozeThread: ( | |
| 108 | thread: EnvironmentThreadShell, | |
| 109 | snoozedUntil: string, | |
| 110 | ) => Promise<boolean>; | |
| 111 | readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | |
| 103 | 112 | readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; |
| 104 | 113 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; |
| 105 | 114 | readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; |
| ... | ... | @@ -494,6 +503,18 @@ export function HomeScreen(props: HomeScreenProps) { |
| 494 | 503 | }, |
| 495 | 504 | [props.onSettleThread], |
| 496 | 505 | ); |
| 506 | const handleSnoozeThread = useCallback( | |
| 507 | (thread: EnvironmentThreadShell, snoozedUntil: string) => { | |
| 508 | void props.onSnoozeThread(thread, snoozedUntil); | |
| 509 | }, | |
| 510 | [props.onSnoozeThread], | |
| 511 | ); | |
| 512 | const handleUnsnoozeThread = useCallback( | |
| 513 | (thread: EnvironmentThreadShell) => { | |
| 514 | void props.onUnsnoozeThread(thread); | |
| 515 | }, | |
| 516 | [props.onUnsnoozeThread], | |
| 517 | ); | |
| 497 | 518 | const handleDeleteThread = props.onDeleteThread; |
| 498 | 519 | const handleUnsettleThread = props.onUnsettleThread; |
| 499 | 520 | // The settled tail renders in pages; expansion resets when the filter |
| ... | ... | @@ -511,6 +532,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 511 | 532 | () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), |
| 512 | 533 | [], |
| 513 | 534 | ); |
| 535 | const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); | |
| 536 | const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); | |
| 514 | 537 | // now is quantized to the minute and ticks so the inactivity auto-settle |
| 515 | 538 | // boundary is actually crossed while the app stays open (mirrors web); |
| 516 | 539 | // without a clock dependency the partition memoizes a frozen "now". |
| ... | ... | @@ -551,7 +574,13 @@ export function HomeScreen(props: HomeScreenProps) { |
| 551 | 574 | }, [serverConfigs]); |
| 552 | 575 | const threadListV2Layout = useMemo(() => { |
| 553 | 576 | if (!threadListV2Enabled) |
| 554 | return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; | |
| 577 | return { | |
| 578 | items: [], | |
| 579 | hiddenSettledCount: 0, | |
| 580 | snoozedCount: 0, | |
| 581 | snoozedShelfHeaderIndex: null, | |
| 582 | nextSnoozeWakeAt: null, | |
| 583 | }; | |
| 555 | 584 | // Settled threads are live shells; archived threads keep their original |
| 556 | 585 | // "hidden from lists" meaning. |
| 557 | 586 | return buildThreadListV2Items({ |
| ... | ... | @@ -566,11 +595,14 @@ export function HomeScreen(props: HomeScreenProps) { |
| 566 | 595 | settledLimit: settledVisibleCount, |
| 567 | 596 | now: `${nowMinute}:00.000Z`, |
| 568 | 597 | snoozeNow: new Date().toISOString(), |
| 598 | snoozedShelfExpanded, | |
| 599 | selectedThreadKey: null, | |
| 569 | 600 | }); |
| 570 | 601 | }, [ |
| 571 | 602 | changeRequestStateByKey, |
| 572 | 603 | nowMinute, |
| 573 | 604 | snoozeWakeTick, |
| 605 | snoozedShelfExpanded, | |
| 574 | 606 | settledVisibleCount, |
| 575 | 607 | settlementEnvironmentIds, |
| 576 | 608 | snoozeEnvironmentIds, |
| ... | ... | @@ -620,8 +652,12 @@ export function HomeScreen(props: HomeScreenProps) { |
| 620 | 652 | buildThreadListV2ListItems({ |
| 621 | 653 | items: threadListV2Layout.items, |
| 622 | 654 | pendingTasks: v2PendingTasks, |
| 655 | snoozedCount: threadListV2Layout.snoozedCount, | |
| 656 | snoozedShelfExpanded, | |
| 657 | snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, | |
| 658 | snoozeLabelNow: `${nowMinute}:00.000Z`, | |
| 623 | 659 | }), |
| 624 | [threadListV2Layout.items, v2PendingTasks], | |
| 660 | [snoozedShelfExpanded, threadListV2Layout, v2PendingTasks], | |
| 625 | 661 | ); |
| 626 | 662 | |
| 627 | 663 | const renderV2Item = useCallback( |
| ... | ... | @@ -648,12 +684,24 @@ export function HomeScreen(props: HomeScreenProps) { |
| 648 | 684 | /> |
| 649 | 685 | ); |
| 650 | 686 | } |
| 687 | if (item.type === "v2-snoozed-shelf") { | |
| 688 | return ( | |
| 689 | <ThreadListV2SnoozedShelfHeader | |
| 690 | count={item.count} | |
| 691 | expanded={item.expanded} | |
| 692 | onToggle={toggleSnoozedShelf} | |
| 693 | /> | |
| 694 | ); | |
| 695 | } | |
| 651 | 696 | const thread = item.item.thread; |
| 652 | 697 | return ( |
| 653 | 698 | <ThreadListV2Row |
| 654 | 699 | thread={thread} |
| 655 | 700 | variant={item.item.variant} |
| 656 | 701 | showSettledDivider={item.item.showSettledDivider} |
| 702 | snoozed={item.item.snoozed} | |
| 703 | snoozePresetMinute={nowMinute} | |
| 704 | snoozeWakeLabelText={item.snoozeWakeLabelText} | |
| 657 | 705 | project={ |
| 658 | 706 | projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null |
| 659 | 707 | } |
| ... | ... | @@ -686,6 +734,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 686 | 734 | onArchiveThread={props.onArchiveThread} |
| 687 | 735 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} |
| 688 | 736 | onSettleThread={handleSettleThread} |
| 737 | snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} | |
| 738 | onSnoozeThread={handleSnoozeThread} | |
| 739 | onUnsnoozeThread={handleUnsnoozeThread} | |
| 689 | 740 | onUnsettleThread={handleUnsettleThread} |
| 690 | 741 | onChangeRequestState={handleChangeRequestState} |
| 691 | 742 | projectCwd={ |
| ... | ... | @@ -700,6 +751,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 700 | 751 | handleChangeRequestState, |
| 701 | 752 | handleDeleteThread, |
| 702 | 753 | handleSettleThread, |
| 754 | handleSnoozeThread, | |
| 755 | handleUnsnoozeThread, | |
| 703 | 756 | handleSwipeableClose, |
| 704 | 757 | handleSwipeableWillOpen, |
| 705 | 758 | handleUnsettleThread, |
| ... | ... | @@ -712,9 +765,12 @@ export function HomeScreen(props: HomeScreenProps) { |
| 712 | 765 | props.savedConnectionsById, |
| 713 | 766 | serverConfigs, |
| 714 | 767 | settlementEnvironmentIds, |
| 768 | snoozeEnvironmentIds, | |
| 715 | 769 | threadSearchMatchByKey, |
| 770 | toggleSnoozedShelf, | |
| 716 | 771 | v2ProjectTitleByProjectKey, |
| 717 | 772 | props.searchQuery, |
| 773 | nowMinute, | |
| 718 | 774 | ], |
| 719 | 775 | ); |
| 720 | 776 | const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []); |
| ... | ... | @@ -730,6 +786,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 730 | 786 | serverConfigs, |
| 731 | 787 | savedConnectionsById: props.savedConnectionsById, |
| 732 | 788 | searchQuery: props.searchQuery, |
| 789 | snoozePresetMinute: nowMinute, | |
| 733 | 790 | threadSearchMatchByKey, |
| 734 | 791 | }), |
| 735 | 792 | [ |
| ... | ... | @@ -738,6 +795,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 738 | 795 | props.searchQuery, |
| 739 | 796 | props.savedConnectionsById, |
| 740 | 797 | serverConfigs, |
| 798 | nowMinute, | |
| 741 | 799 | threadSearchMatchByKey, |
| 742 | 800 | v2ProjectTitleByProjectKey, |
| 743 | 801 | ], |
| ... | ... | @@ -953,31 +1011,11 @@ export function HomeScreen(props: HomeScreenProps) { |
| 953 | 1011 | ) : null; |
| 954 | 1012 | // Self-contained: v1's listEmpty keys off projectGroups, which ignores the |
| 955 | 1013 | // v2 project scope, so it can be null (results elsewhere) while this list |
| 956 | // is empty. Search outranks the scope — "No results" names the actionable | |
| 957 | // fact when a query is active. Snoozed threads outrank the rest: "No | |
| 958 | // threads yet" over an inbox that is merely all-snoozed reads as data | |
| 959 | // loss. | |
| 960 | const v2SnoozedCount = threadListV2Layout.snoozedCount; | |
| 1014 | // is empty. Snoozed threads need no special empty state: their shelf header | |
| 1015 | // is a list row even while collapsed. | |
| 961 | 1016 | const v2ListEmpty = |
| 962 | hasSearchQuery && threadSearch.isPending && v2SnoozedCount === 0 ? null : hasSearchQuery ? ( | |
| 963 | v2SnoozedCount > 0 ? ( | |
| 964 | // The snoozed threads already passed this search filter: "No | |
| 965 | // results" would claim nothing matched when matches are merely | |
| 966 | // parked. | |
| 967 | <EmptyState | |
| 968 | title={ | |
| 969 | v2SnoozedCount === 1 ? "1 matching thread snoozed" : `All matching threads snoozed` | |
| 970 | } | |
| 971 | detail={`Threads matching "${props.searchQuery}" are snoozed and return when their wake time passes.`} | |
| 972 | /> | |
| 973 | ) : ( | |
| 974 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | |
| 975 | ) | |
| 976 | ) : v2SnoozedCount > 0 ? ( | |
| 977 | <EmptyState | |
| 978 | title={v2SnoozedCount === 1 ? "1 thread snoozed" : `${v2SnoozedCount} threads snoozed`} | |
| 979 | detail="Snoozed threads return when their wake time passes." | |
| 980 | /> | |
| 1017 | hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( | |
| 1018 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | |
| 981 | 1019 | ) : v2ScopedProjectGroup !== null ? ( |
| 982 | 1020 | <EmptyState |
| 983 | 1021 | title={`No threads in ${v2ScopedProjectGroup.title}`} |
apps/mobile/src/features/home/thread-swipe-actions.tsx+184-97| ... | ... | @@ -1,4 +1,6 @@ |
| 1 | 1 | import { SymbolView } from "../../components/AppSymbol"; |
| 2 | import { ControlPillMenu } from "../../components/ControlPill"; | |
| 3 | import type { MenuAction } from "@react-native-menu/menu"; | |
| 2 | 4 | import * as Haptics from "expo-haptics"; |
| 3 | 5 | import { |
| 4 | 6 | createContext, |
| ... | ... | @@ -47,13 +49,67 @@ export const THREAD_SWIPE_SPRING = { |
| 47 | 49 | stiffness: 330, |
| 48 | 50 | }; |
| 49 | 51 | |
| 50 | interface ThreadSwipePrimaryAction { | |
| 52 | interface ThreadSwipeAction { | |
| 51 | 53 | readonly accessibilityLabel: string; |
| 52 | 54 | readonly icon: ComponentProps<typeof SymbolView>["name"]; |
| 53 | 55 | readonly label: string; |
| 56 | readonly menu?: { | |
| 57 | readonly actions: MenuAction[]; | |
| 58 | readonly onPressAction: NonNullable<ComponentProps<typeof ControlPillMenu>["onPressAction"]>; | |
| 59 | readonly title?: string; | |
| 60 | }; | |
| 54 | 61 | readonly onPress: () => void; |
| 55 | 62 | } |
| 56 | 63 | |
| 64 | interface ThreadSwipeSecondaryAction extends ThreadSwipeAction { | |
| 65 | readonly backgroundColor: string; | |
| 66 | } | |
| 67 | ||
| 68 | function swipeActionsWidth(hasSecondaryAction: boolean) { | |
| 69 | return hasSecondaryAction ? THREAD_SWIPE_ACTIONS_WIDTH : ACTION_ITEM_WIDTH; | |
| 70 | } | |
| 71 | ||
| 72 | /** `undefined` keeps the v1 Delete default; `null` means one action only. */ | |
| 73 | function resolveSecondaryAction(input: { | |
| 74 | readonly close: () => void; | |
| 75 | readonly onDelete: () => void; | |
| 76 | readonly secondaryAction: ThreadSwipeAction | null | undefined; | |
| 77 | readonly threadTitle: string; | |
| 78 | }): ThreadSwipeSecondaryAction | null { | |
| 79 | if (input.secondaryAction === null) return null; | |
| 80 | if (input.secondaryAction === undefined) { | |
| 81 | return { | |
| 82 | accessibilityLabel: `Delete ${input.threadTitle}`, | |
| 83 | backgroundColor: "#ff2d55", | |
| 84 | icon: "trash", | |
| 85 | label: "Delete", | |
| 86 | onPress: () => { | |
| 87 | input.close(); | |
| 88 | input.onDelete(); | |
| 89 | }, | |
| 90 | }; | |
| 91 | } | |
| 92 | const action = input.secondaryAction; | |
| 93 | return { | |
| 94 | ...action, | |
| 95 | backgroundColor: "#5856d6", | |
| 96 | menu: | |
| 97 | action.menu === undefined | |
| 98 | ? undefined | |
| 99 | : { | |
| 100 | ...action.menu, | |
| 101 | onPressAction: (event) => { | |
| 102 | input.close(); | |
| 103 | action.menu?.onPressAction(event); | |
| 104 | }, | |
| 105 | }, | |
| 106 | onPress: () => { | |
| 107 | input.close(); | |
| 108 | action.onPress(); | |
| 109 | }, | |
| 110 | }; | |
| 111 | } | |
| 112 | ||
| 57 | 113 | /** |
| 58 | 114 | * Delivers the scroll gate to swipeables via context so that flipping it does |
| 59 | 115 | * NOT re-render whole rows: putting the flag in list extraData/renderItem deps |
| ... | ... | @@ -173,17 +229,22 @@ export function ThreadSwipeable(props: { |
| 173 | 229 | readonly enabled?: boolean; |
| 174 | 230 | readonly enableTrackpadSwipe?: boolean; |
| 175 | 231 | /** |
| 176 | * What a full swipe commits: "delete" (default, v1 behavior — the Delete | |
| 177 | * button stretches) or "primary" — the advertised primary action fires and | |
| 178 | * its button stretches instead. A full swipe must always match the action | |
| 179 | * the stretching button advertises. | |
| 232 | * What a full swipe commits. Omitted keeps the v1 Delete behavior only when | |
| 233 | * the built-in Delete secondary action is in use; custom or absent | |
| 234 | * secondary actions default to the advertised primary action. | |
| 180 | 235 | */ |
| 181 | 236 | readonly fullSwipeAction?: "delete" | "primary"; |
| 182 | 237 | readonly fullSwipeWidth: number; |
| 183 | 238 | readonly onDelete: () => void; |
| 184 | 239 | readonly onSwipeableClose?: (methods: SwipeableMethods) => void; |
| 185 | 240 | readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void; |
| 186 | readonly primaryAction: ThreadSwipePrimaryAction; | |
| 241 | readonly primaryAction: ThreadSwipeAction; | |
| 242 | /** | |
| 243 | * Omitted keeps the v1 destructive Delete action. Explicit null opts out of | |
| 244 | * a secondary action entirely so a gated Snooze can never fall back to an | |
| 245 | * unadvertised Delete. | |
| 246 | */ | |
| 247 | readonly secondaryAction?: ThreadSwipeAction | null; | |
| 187 | 248 | /** |
| 188 | 249 | * Identity of the content being wrapped. When a recycled list reuses this |
| 189 | 250 | * component for a different item, the swipeable snaps back to closed so an |
| ... | ... | @@ -197,7 +258,11 @@ export function ThreadSwipeable(props: { |
| 197 | 258 | }) { |
| 198 | 259 | const swipeableRef = useRef<SwipeableMethods | null>(null); |
| 199 | 260 | const fullSwipeArmedRef = useRef(false); |
| 200 | const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, props.fullSwipeWidth * 0.58); | |
| 261 | const hasSecondaryAction = props.secondaryAction !== null; | |
| 262 | const actionsWidth = swipeActionsWidth(hasSecondaryAction); | |
| 263 | const fullSwipeThreshold = Math.max(actionsWidth + 44, props.fullSwipeWidth * 0.58); | |
| 264 | const fullSwipeAction = | |
| 265 | props.fullSwipeAction ?? (props.secondaryAction === undefined ? "delete" : "primary"); | |
| 201 | 266 | const close = useCallback(() => swipeableRef.current?.close(), []); |
| 202 | 267 | const gateEnabled = use(SwipeableScrollGateContext); |
| 203 | 268 | const resetKey = props.resetKey; |
| ... | ... | @@ -251,7 +316,7 @@ export function ThreadSwipeable(props: { |
| 251 | 316 | if (fullSwipeArmedRef.current) { |
| 252 | 317 | fullSwipeArmedRef.current = false; |
| 253 | 318 | methods.close(); |
| 254 | if (props.fullSwipeAction === "primary") { | |
| 319 | if (fullSwipeAction === "primary") { | |
| 255 | 320 | props.primaryAction.onPress(); |
| 256 | 321 | } else { |
| 257 | 322 | props.onDelete(); |
| ... | ... | @@ -264,9 +329,8 @@ export function ThreadSwipeable(props: { |
| 264 | 329 | <ThreadSwipeActions |
| 265 | 330 | backgroundColor={props.backgroundColor} |
| 266 | 331 | compact={props.compactActions === true} |
| 267 | fullSwipeAction={props.fullSwipeAction ?? "delete"} | |
| 332 | fullSwipeAction={fullSwipeAction} | |
| 268 | 333 | fullSwipeThreshold={fullSwipeThreshold} |
| 269 | onDelete={props.onDelete} | |
| 270 | 334 | onFullSwipeArmedChange={handleFullSwipeArmedChange} |
| 271 | 335 | primaryAction={{ |
| 272 | 336 | ...props.primaryAction, |
| ... | ... | @@ -275,12 +339,16 @@ export function ThreadSwipeable(props: { |
| 275 | 339 | props.primaryAction.onPress(); |
| 276 | 340 | }, |
| 277 | 341 | }} |
| 278 | swipeableMethods={methods} | |
| 279 | threadTitle={props.threadTitle} | |
| 342 | secondaryAction={resolveSecondaryAction({ | |
| 343 | close: () => methods.close(), | |
| 344 | onDelete: props.onDelete, | |
| 345 | secondaryAction: props.secondaryAction, | |
| 346 | threadTitle: props.threadTitle, | |
| 347 | })} | |
| 280 | 348 | translation={translation} |
| 281 | 349 | /> |
| 282 | 350 | )} |
| 283 | rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} | |
| 351 | rightThreshold={actionsWidth * 0.42} | |
| 284 | 352 | simultaneousWithExternalGesture={props.simultaneousWithExternalGesture} |
| 285 | 353 | > |
| 286 | 354 | {props.children(close)} |
| ... | ... | @@ -290,12 +358,14 @@ export function ThreadSwipeable(props: { |
| 290 | 358 | |
| 291 | 359 | function SwipeActionButton(props: { |
| 292 | 360 | readonly accessibilityLabel: string; |
| 361 | readonly actionsWidth: number; | |
| 293 | 362 | readonly backgroundColor: string; |
| 294 | 363 | readonly compact: boolean; |
| 295 | 364 | readonly entryRange: readonly [number, number]; |
| 296 | 365 | readonly fullSwipeThreshold: number; |
| 297 | 366 | readonly icon: ComponentProps<typeof SymbolView>["name"]; |
| 298 | 367 | readonly label: string; |
| 368 | readonly menu?: ThreadSwipeAction["menu"]; | |
| 299 | 369 | readonly onPress: () => void; |
| 300 | 370 | readonly stretchesOnFullSwipe: boolean; |
| 301 | 371 | readonly translation: SharedValue<number>; |
| ... | ... | @@ -305,10 +375,10 @@ function SwipeActionButton(props: { |
| 305 | 375 | const actionStyle = useAnimatedStyle(() => { |
| 306 | 376 | const reveal = Math.max(-props.translation.value, 0); |
| 307 | 377 | const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP); |
| 308 | const stretch = Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0); | |
| 378 | const stretch = Math.max(reveal - props.actionsWidth, 0); | |
| 309 | 379 | const fullSwipeProgress = interpolate( |
| 310 | 380 | reveal, |
| 311 | [THREAD_SWIPE_ACTIONS_WIDTH, props.fullSwipeThreshold + 20], | |
| 381 | [props.actionsWidth, props.fullSwipeThreshold + 20], | |
| 312 | 382 | [0, 1], |
| 313 | 383 | Extrapolation.CLAMP, |
| 314 | 384 | ); |
| ... | ... | @@ -327,9 +397,7 @@ function SwipeActionButton(props: { |
| 327 | 397 | }); |
| 328 | 398 | const circleStyle = useAnimatedStyle(() => { |
| 329 | 399 | const reveal = Math.max(-props.translation.value, 0); |
| 330 | const stretch = props.stretchesOnFullSwipe | |
| 331 | ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0) | |
| 332 | : 0; | |
| 400 | const stretch = props.stretchesOnFullSwipe ? Math.max(reveal - props.actionsWidth, 0) : 0; | |
| 333 | 401 | |
| 334 | 402 | return { |
| 335 | 403 | transform: [{ translateX: -stretch }], |
| ... | ... | @@ -338,9 +406,7 @@ function SwipeActionButton(props: { |
| 338 | 406 | }); |
| 339 | 407 | const iconStyle = useAnimatedStyle(() => { |
| 340 | 408 | const reveal = Math.max(-props.translation.value, 0); |
| 341 | const stretch = props.stretchesOnFullSwipe | |
| 342 | ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0) | |
| 343 | : 0; | |
| 409 | const stretch = props.stretchesOnFullSwipe ? Math.max(reveal - props.actionsWidth, 0) : 0; | |
| 344 | 410 | const armedProgress = interpolate( |
| 345 | 411 | reveal, |
| 346 | 412 | [props.fullSwipeThreshold, props.fullSwipeThreshold + 20], |
| ... | ... | @@ -358,7 +424,7 @@ function SwipeActionButton(props: { |
| 358 | 424 | } |
| 359 | 425 | |
| 360 | 426 | const reveal = Math.max(-props.translation.value, 0); |
| 361 | const stretch = Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0); | |
| 427 | const stretch = Math.max(reveal - props.actionsWidth, 0); | |
| 362 | 428 | return { |
| 363 | 429 | opacity: interpolate( |
| 364 | 430 | reveal, |
| ... | ... | @@ -370,6 +436,63 @@ function SwipeActionButton(props: { |
| 370 | 436 | }; |
| 371 | 437 | }); |
| 372 | 438 | |
| 439 | const button = ( | |
| 440 | <Pressable | |
| 441 | accessibilityLabel={props.accessibilityLabel} | |
| 442 | accessibilityRole="button" | |
| 443 | onPress={props.menu === undefined ? props.onPress : undefined} | |
| 444 | style={({ pressed }) => ({ | |
| 445 | alignItems: "center", | |
| 446 | height: "100%", | |
| 447 | justifyContent: "center", | |
| 448 | opacity: pressed ? 0.72 : 1, | |
| 449 | width: "100%", | |
| 450 | })} | |
| 451 | > | |
| 452 | <View style={{ height: circleSize, width: circleSize }}> | |
| 453 | <Animated.View | |
| 454 | style={[ | |
| 455 | { | |
| 456 | backgroundColor: props.backgroundColor, | |
| 457 | borderRadius: 999, | |
| 458 | height: circleSize, | |
| 459 | left: 0, | |
| 460 | position: "absolute", | |
| 461 | top: 0, | |
| 462 | }, | |
| 463 | circleStyle, | |
| 464 | ]} | |
| 465 | /> | |
| 466 | <Animated.View | |
| 467 | style={[ | |
| 468 | { | |
| 469 | alignItems: "center", | |
| 470 | height: circleSize, | |
| 471 | justifyContent: "center", | |
| 472 | left: 0, | |
| 473 | position: "absolute", | |
| 474 | top: 0, | |
| 475 | width: circleSize, | |
| 476 | }, | |
| 477 | iconStyle, | |
| 478 | ]} | |
| 479 | > | |
| 480 | <SymbolView name={props.icon} size={iconSize} tintColor="#ffffff" type="monochrome" /> | |
| 481 | </Animated.View> | |
| 482 | </View> | |
| 483 | <Animated.View | |
| 484 | style={[ | |
| 485 | { height: 14, justifyContent: "center", paddingTop: props.compact ? 0 : 2 }, | |
| 486 | labelStyle, | |
| 487 | ]} | |
| 488 | > | |
| 489 | <Text className="text-3xs font-t3-medium text-foreground-muted" numberOfLines={1}> | |
| 490 | {props.label} | |
| 491 | </Text> | |
| 492 | </Animated.View> | |
| 493 | </Pressable> | |
| 494 | ); | |
| 495 | ||
| 373 | 496 | return ( |
| 374 | 497 | <Animated.View |
| 375 | 498 | style={[ |
| ... | ... | @@ -383,60 +506,18 @@ function SwipeActionButton(props: { |
| 383 | 506 | actionStyle, |
| 384 | 507 | ]} |
| 385 | 508 | > |
| 386 | <Pressable | |
| 387 | accessibilityLabel={props.accessibilityLabel} | |
| 388 | accessibilityRole="button" | |
| 389 | onPress={props.onPress} | |
| 390 | style={({ pressed }) => ({ | |
| 391 | alignItems: "center", | |
| 392 | height: "100%", | |
| 393 | justifyContent: "center", | |
| 394 | opacity: pressed ? 0.72 : 1, | |
| 395 | width: "100%", | |
| 396 | })} | |
| 397 | > | |
| 398 | <View style={{ height: circleSize, width: circleSize }}> | |
| 399 | <Animated.View | |
| 400 | style={[ | |
| 401 | { | |
| 402 | backgroundColor: props.backgroundColor, | |
| 403 | borderRadius: 999, | |
| 404 | height: circleSize, | |
| 405 | left: 0, | |
| 406 | position: "absolute", | |
| 407 | top: 0, | |
| 408 | }, | |
| 409 | circleStyle, | |
| 410 | ]} | |
| 411 | /> | |
| 412 | <Animated.View | |
| 413 | style={[ | |
| 414 | { | |
| 415 | alignItems: "center", | |
| 416 | height: circleSize, | |
| 417 | justifyContent: "center", | |
| 418 | left: 0, | |
| 419 | position: "absolute", | |
| 420 | top: 0, | |
| 421 | width: circleSize, | |
| 422 | }, | |
| 423 | iconStyle, | |
| 424 | ]} | |
| 425 | > | |
| 426 | <SymbolView name={props.icon} size={iconSize} tintColor="#ffffff" type="monochrome" /> | |
| 427 | </Animated.View> | |
| 428 | </View> | |
| 429 | <Animated.View | |
| 430 | style={[ | |
| 431 | { height: 14, justifyContent: "center", paddingTop: props.compact ? 0 : 2 }, | |
| 432 | labelStyle, | |
| 433 | ]} | |
| 509 | {props.menu === undefined ? ( | |
| 510 | button | |
| 511 | ) : ( | |
| 512 | <ControlPillMenu | |
| 513 | actions={props.menu.actions} | |
| 514 | onPressAction={props.menu.onPressAction} | |
| 515 | title={props.menu.title} | |
| 516 | style={{ height: "100%", width: "100%" }} | |
| 434 | 517 | > |
| 435 | <Text className="text-3xs font-t3-medium text-foreground-muted" numberOfLines={1}> | |
| 436 | {props.label} | |
| 437 | </Text> | |
| 438 | </Animated.View> | |
| 439 | </Pressable> | |
| 518 | {button} | |
| 519 | </ControlPillMenu> | |
| 520 | )} | |
| 440 | 521 | </Animated.View> |
| 441 | 522 | ); |
| 442 | 523 | } |
| ... | ... | @@ -446,14 +527,14 @@ export function ThreadSwipeActions(props: { |
| 446 | 527 | readonly compact: boolean; |
| 447 | 528 | readonly fullSwipeAction?: "delete" | "primary"; |
| 448 | 529 | readonly fullSwipeThreshold: number; |
| 449 | readonly onDelete: () => void; | |
| 450 | 530 | readonly onFullSwipeArmedChange: (armed: boolean) => void; |
| 451 | readonly primaryAction: ThreadSwipePrimaryAction; | |
| 452 | readonly swipeableMethods: SwipeableMethods; | |
| 453 | readonly threadTitle: string; | |
| 531 | readonly primaryAction: ThreadSwipeAction; | |
| 532 | readonly secondaryAction: ThreadSwipeSecondaryAction | null; | |
| 454 | 533 | readonly translation: SharedValue<number>; |
| 455 | 534 | }) { |
| 456 | const fullSwipeIsPrimary = props.fullSwipeAction === "primary"; | |
| 535 | const secondaryAction = props.secondaryAction; | |
| 536 | const fullSwipeIsPrimary = props.fullSwipeAction === "primary" || secondaryAction === null; | |
| 537 | const actionsWidth = swipeActionsWidth(secondaryAction !== null); | |
| 457 | 538 | useAnimatedReaction( |
| 458 | 539 | () => -props.translation.value >= props.fullSwipeThreshold, |
| 459 | 540 | (armed, previous) => { |
| ... | ... | @@ -470,14 +551,19 @@ export function ThreadSwipeActions(props: { |
| 470 | 551 | backgroundColor: props.backgroundColor, |
| 471 | 552 | flexDirection: "row", |
| 472 | 553 | height: "100%", |
| 473 | width: THREAD_SWIPE_ACTIONS_WIDTH, | |
| 554 | width: actionsWidth, | |
| 474 | 555 | }} |
| 475 | 556 | > |
| 476 | 557 | <SwipeActionButton |
| 477 | 558 | accessibilityLabel={props.primaryAction.accessibilityLabel} |
| 559 | actionsWidth={actionsWidth} | |
| 478 | 560 | backgroundColor="#007aff" |
| 479 | 561 | compact={props.compact} |
| 480 | entryRange={[ACTION_ITEM_WIDTH * 0.55, THREAD_SWIPE_ACTIONS_WIDTH * 0.85]} | |
| 562 | entryRange={ | |
| 563 | secondaryAction === null | |
| 564 | ? [8, ACTION_ITEM_WIDTH * 0.72] | |
| 565 | : [ACTION_ITEM_WIDTH * 0.55, THREAD_SWIPE_ACTIONS_WIDTH * 0.85] | |
| 566 | } | |
| 481 | 567 | fullSwipeThreshold={props.fullSwipeThreshold} |
| 482 | 568 | icon={props.primaryAction.icon} |
| 483 | 569 | label={props.primaryAction.label} |
| ... | ... | @@ -485,21 +571,22 @@ export function ThreadSwipeActions(props: { |
| 485 | 571 | stretchesOnFullSwipe={fullSwipeIsPrimary} |
| 486 | 572 | translation={props.translation} |
| 487 | 573 | /> |
| 488 | <SwipeActionButton | |
| 489 | accessibilityLabel={`Delete ${props.threadTitle}`} | |
| 490 | backgroundColor="#ff2d55" | |
| 491 | compact={props.compact} | |
| 492 | entryRange={[8, ACTION_ITEM_WIDTH * 0.72]} | |
| 493 | fullSwipeThreshold={props.fullSwipeThreshold} | |
| 494 | icon="trash" | |
| 495 | label="Delete" | |
| 496 | onPress={() => { | |
| 497 | props.swipeableMethods.close(); | |
| 498 | props.onDelete(); | |
| 499 | }} | |
| 500 | stretchesOnFullSwipe={!fullSwipeIsPrimary} | |
| 501 | translation={props.translation} | |
| 502 | /> | |
| 574 | {secondaryAction === null ? null : ( | |
| 575 | <SwipeActionButton | |
| 576 | accessibilityLabel={secondaryAction.accessibilityLabel} | |
| 577 | actionsWidth={actionsWidth} | |
| 578 | backgroundColor={secondaryAction.backgroundColor} | |
| 579 | compact={props.compact} | |
| 580 | entryRange={[8, ACTION_ITEM_WIDTH * 0.72]} | |
| 581 | fullSwipeThreshold={props.fullSwipeThreshold} | |
| 582 | icon={secondaryAction.icon} | |
| 583 | label={secondaryAction.label} | |
| 584 | menu={secondaryAction.menu} | |
| 585 | onPress={secondaryAction.onPress} | |
| 586 | stretchesOnFullSwipe={!fullSwipeIsPrimary} | |
| 587 | translation={props.translation} | |
| 588 | /> | |
| 589 | )} | |
| 503 | 590 | </View> |
| 504 | 591 | ); |
| 505 | 592 | } |
apps/mobile/src/features/home/useThreadListActions.ts+109-2| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 2 | import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; | |
| 2 | import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; | |
| 3 | 3 | import * as Cause from "effect/Cause"; |
| 4 | 4 | import * as Haptics from "expo-haptics"; |
| 5 | 5 | import { useCallback, useRef } from "react"; |
| ... | ... | @@ -22,6 +22,13 @@ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["en |
| 22 | 22 | ); |
| 23 | 23 | } |
| 24 | 24 | |
| 25 | function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["environmentId"]) { | |
| 26 | return ( | |
| 27 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | |
| 28 | .threadSnooze === true | |
| 29 | ); | |
| 30 | } | |
| 31 | ||
| 25 | 32 | type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; |
| 26 | 33 | |
| 27 | 34 | const ACTION_VERBS: Record<ThreadListAction, string> = { |
| ... | ... | @@ -192,9 +199,14 @@ export function useThreadListActions(): { |
| 192 | 199 | readonly archiveThread: (thread: EnvironmentThreadShell) => void; |
| 193 | 200 | readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; |
| 194 | 201 | readonly settleThread: (thread: EnvironmentThreadShell) => Promise<boolean>; |
| 202 | readonly snoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => Promise<boolean>; | |
| 203 | readonly unsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | |
| 195 | 204 | readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>; |
| 196 | 205 | } { |
| 197 | 206 | const executeAction = useThreadActionExecutor(); |
| 207 | const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); | |
| 208 | const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); | |
| 209 | const snoozeInFlightThreadKeys = useRef(new Set<string>()); | |
| 198 | 210 | |
| 199 | 211 | const archiveThread = useCallback( |
| 200 | 212 | (thread: EnvironmentThreadShell) => { |
| ... | ... | @@ -206,6 +218,94 @@ export function useThreadListActions(): { |
| 206 | 218 | async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true, |
| 207 | 219 | [executeAction], |
| 208 | 220 | ); |
| 221 | const snoozeThread = useCallback( | |
| 222 | async (thread: EnvironmentThreadShell, snoozedUntil: string) => { | |
| 223 | const key = scopedThreadKey(thread.environmentId, thread.id); | |
| 224 | if (snoozeInFlightThreadKeys.current.has(key)) { | |
| 225 | return false; | |
| 226 | } | |
| 227 | snoozeInFlightThreadKeys.current.add(key); | |
| 228 | try { | |
| 229 | if (!environmentSupportsSnooze(thread.environmentId)) { | |
| 230 | Alert.alert( | |
| 231 | "Could not snooze thread", | |
| 232 | "This environment's server does not support snoozing yet. Update the server to use Snooze.", | |
| 233 | ); | |
| 234 | return false; | |
| 235 | } | |
| 236 | if (!canSnooze(thread, { now: new Date().toISOString() })) { | |
| 237 | Alert.alert( | |
| 238 | "Could not snooze thread", | |
| 239 | thread.hasPendingApprovals || thread.hasPendingUserInput | |
| 240 | ? "This thread is waiting on you. Respond to the pending request before snoozing it." | |
| 241 | : "This thread is still starting a turn. Try again once it's running.", | |
| 242 | ); | |
| 243 | return false; | |
| 244 | } | |
| 245 | ||
| 246 | selectionHaptic(); | |
| 247 | const result = await snoozeMutation({ | |
| 248 | environmentId: thread.environmentId, | |
| 249 | input: { | |
| 250 | threadId: thread.id, | |
| 251 | snoozedUntil, | |
| 252 | }, | |
| 253 | }); | |
| 254 | if (result._tag === "Failure") { | |
| 255 | const error = Cause.squash(result.cause); | |
| 256 | Alert.alert( | |
| 257 | "Could not snooze thread", | |
| 258 | error instanceof Error && error.message.trim().length > 0 | |
| 259 | ? error.message | |
| 260 | : "The thread could not be snoozed.", | |
| 261 | ); | |
| 262 | return false; | |
| 263 | } | |
| 264 | return true; | |
| 265 | } finally { | |
| 266 | snoozeInFlightThreadKeys.current.delete(key); | |
| 267 | } | |
| 268 | }, | |
| 269 | [snoozeMutation], | |
| 270 | ); | |
| 271 | const unsnoozeThread = useCallback( | |
| 272 | async (thread: EnvironmentThreadShell) => { | |
| 273 | const key = scopedThreadKey(thread.environmentId, thread.id); | |
| 274 | if (snoozeInFlightThreadKeys.current.has(key)) { | |
| 275 | return false; | |
| 276 | } | |
| 277 | snoozeInFlightThreadKeys.current.add(key); | |
| 278 | try { | |
| 279 | if (!environmentSupportsSnooze(thread.environmentId)) { | |
| 280 | Alert.alert( | |
| 281 | "Could not wake thread", | |
| 282 | "This environment's server does not support snoozing yet. Update the server to wake this thread.", | |
| 283 | ); | |
| 284 | return false; | |
| 285 | } | |
| 286 | ||
| 287 | selectionHaptic(); | |
| 288 | const result = await unsnoozeMutation({ | |
| 289 | environmentId: thread.environmentId, | |
| 290 | input: { threadId: thread.id, reason: "user" }, | |
| 291 | }); | |
| 292 | if (result._tag === "Failure") { | |
| 293 | const error = Cause.squash(result.cause); | |
| 294 | Alert.alert( | |
| 295 | "Could not wake thread", | |
| 296 | error instanceof Error && error.message.trim().length > 0 | |
| 297 | ? error.message | |
| 298 | : "The thread could not be woken.", | |
| 299 | ); | |
| 300 | return false; | |
| 301 | } | |
| 302 | return true; | |
| 303 | } finally { | |
| 304 | snoozeInFlightThreadKeys.current.delete(key); | |
| 305 | } | |
| 306 | }, | |
| 307 | [unsnoozeMutation], | |
| 308 | ); | |
| 209 | 309 | const unsettleThread = useCallback( |
| 210 | 310 | async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true, |
| 211 | 311 | [executeAction], |
| ... | ... | @@ -213,7 +313,14 @@ export function useThreadListActions(): { |
| 213 | 313 | |
| 214 | 314 | const confirmDeleteThread = useConfirmDeleteThread(executeAction); |
| 215 | 315 | |
| 216 | return { archiveThread, confirmDeleteThread, settleThread, unsettleThread }; | |
| 316 | return { | |
| 317 | archiveThread, | |
| 318 | confirmDeleteThread, | |
| 319 | settleThread, | |
| 320 | snoozeThread, | |
| 321 | unsnoozeThread, | |
| 322 | unsettleThread, | |
| 323 | }; | |
| 217 | 324 | } |
| 218 | 325 | |
| 219 | 326 | export function useArchivedThreadListActions( |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+70-24| ... | ... | @@ -67,7 +67,11 @@ import { |
| 67 | 67 | ThreadListRow, |
| 68 | 68 | ThreadListShowMoreRow, |
| 69 | 69 | } from "./thread-list-items"; |
| 70 | import { ThreadListV2PendingRow, ThreadListV2Row } from "./thread-list-v2-items"; | |
| 70 | import { | |
| 71 | ThreadListV2PendingRow, | |
| 72 | ThreadListV2Row, | |
| 73 | ThreadListV2SnoozedShelfHeader, | |
| 74 | } from "./thread-list-v2-items"; | |
| 71 | 75 | import { |
| 72 | 76 | buildThreadListV2Items, |
| 73 | 77 | buildThreadListV2ListItems, |
| ... | ... | @@ -193,8 +197,14 @@ function ThreadNavigationSidebarPane( |
| 193 | 197 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 194 | 198 | const headerIsOverContentRef = useRef(false); |
| 195 | 199 | const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); |
| 196 | const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = | |
| 197 | useThreadListActions(); | |
| 200 | const { | |
| 201 | archiveThread, | |
| 202 | confirmDeleteThread, | |
| 203 | settleThread, | |
| 204 | snoozeThread, | |
| 205 | unsnoozeThread, | |
| 206 | unsettleThread, | |
| 207 | } = useThreadListActions(); | |
| 198 | 208 | const threadListV2Enabled = useThreadListV2Enabled(); |
| 199 | 209 | const pendingTasks = usePendingNewTasks(); |
| 200 | 210 | const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); |
| ... | ... | @@ -428,6 +438,8 @@ function ThreadNavigationSidebarPane( |
| 428 | 438 | () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), |
| 429 | 439 | [], |
| 430 | 440 | ); |
| 441 | const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); | |
| 442 | const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); | |
| 431 | 443 | // now ticks per minute so the inactivity auto-settle boundary is actually |
| 432 | 444 | // crossed while the pane stays open; without a clock dependency the |
| 433 | 445 | // partition memoizes a frozen "now". |
| ... | ... | @@ -468,7 +480,13 @@ function ThreadNavigationSidebarPane( |
| 468 | 480 | }, [serverConfigs]); |
| 469 | 481 | const threadListV2Layout = useMemo(() => { |
| 470 | 482 | if (!threadListV2Enabled) |
| 471 | return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; | |
| 483 | return { | |
| 484 | items: [], | |
| 485 | hiddenSettledCount: 0, | |
| 486 | snoozedCount: 0, | |
| 487 | snoozedShelfHeaderIndex: null, | |
| 488 | nextSnoozeWakeAt: null, | |
| 489 | }; | |
| 472 | 490 | return buildThreadListV2Items({ |
| 473 | 491 | threads: threads.filter((thread) => thread.archivedAt === null), |
| 474 | 492 | environmentId: options.selectedEnvironmentId, |
| ... | ... | @@ -481,11 +499,15 @@ function ThreadNavigationSidebarPane( |
| 481 | 499 | settledLimit: settledVisibleCount, |
| 482 | 500 | now: `${nowMinute}:00.000Z`, |
| 483 | 501 | snoozeNow: new Date().toISOString(), |
| 502 | snoozedShelfExpanded, | |
| 503 | selectedThreadKey: props.selectedThreadKey ?? null, | |
| 484 | 504 | }); |
| 485 | 505 | }, [ |
| 486 | 506 | changeRequestStateByKey, |
| 487 | 507 | nowMinute, |
| 488 | 508 | snoozeWakeTick, |
| 509 | snoozedShelfExpanded, | |
| 510 | props.selectedThreadKey, | |
| 489 | 511 | options.selectedEnvironmentId, |
| 490 | 512 | props.searchQuery, |
| 491 | 513 | matchedThreadKeys, |
| ... | ... | @@ -532,6 +554,10 @@ function ThreadNavigationSidebarPane( |
| 532 | 554 | const items: SidebarListItem[] = buildThreadListV2ListItems({ |
| 533 | 555 | items: threadListV2Layout.items, |
| 534 | 556 | pendingTasks: v2PendingTasks, |
| 557 | snoozedCount: threadListV2Layout.snoozedCount, | |
| 558 | snoozedShelfExpanded, | |
| 559 | snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, | |
| 560 | snoozeLabelNow: `${nowMinute}:00.000Z`, | |
| 535 | 561 | }); |
| 536 | 562 | if (threadListV2Layout.hiddenSettledCount > 0) { |
| 537 | 563 | items.push({ |
| ... | ... | @@ -543,10 +569,12 @@ function ThreadNavigationSidebarPane( |
| 543 | 569 | return items; |
| 544 | 570 | }, [ |
| 545 | 571 | listLayout.items, |
| 572 | nowMinute, | |
| 546 | 573 | options.selectedEnvironmentId, |
| 547 | 574 | pendingTasks, |
| 548 | 575 | props.searchQuery, |
| 549 | 576 | selectedProjectRefs, |
| 577 | snoozedShelfExpanded, | |
| 550 | 578 | threadListV2Enabled, |
| 551 | 579 | threadListV2Layout, |
| 552 | 580 | ]); |
| ... | ... | @@ -731,6 +759,7 @@ function ThreadNavigationSidebarPane( |
| 731 | 759 | projectTitleByProjectKey, |
| 732 | 760 | savedConnectionsById, |
| 733 | 761 | serverConfigs, |
| 762 | snoozePresetMinute: nowMinute, | |
| 734 | 763 | threadSearchMatchByKey, |
| 735 | 764 | }), |
| 736 | 765 | [ |
| ... | ... | @@ -740,6 +769,7 @@ function ThreadNavigationSidebarPane( |
| 740 | 769 | projectTitleByProjectKey, |
| 741 | 770 | savedConnectionsById, |
| 742 | 771 | serverConfigs, |
| 772 | nowMinute, | |
| 743 | 773 | threadSearchMatchByKey, |
| 744 | 774 | ], |
| 745 | 775 | ); |
| ... | ... | @@ -750,7 +780,9 @@ function ThreadNavigationSidebarPane( |
| 750 | 780 | previous.key === item.key && |
| 751 | 781 | previous.item.thread === item.item.thread && |
| 752 | 782 | previous.item.variant === item.item.variant && |
| 753 | previous.item.showSettledDivider === item.item.showSettledDivider | |
| 783 | previous.item.showSettledDivider === item.item.showSettledDivider && | |
| 784 | previous.item.snoozed === item.item.snoozed && | |
| 785 | previous.snoozeWakeLabelText === item.snoozeWakeLabelText | |
| 754 | 786 | ); |
| 755 | 787 | } |
| 756 | 788 | if (previous.type === "v2-show-more" && item.type === "v2-show-more") { |
| ... | ... | @@ -762,13 +794,18 @@ function ThreadNavigationSidebarPane( |
| 762 | 794 | previous.showPendingDivider === item.showPendingDivider |
| 763 | 795 | ); |
| 764 | 796 | } |
| 797 | if (previous.type === "v2-snoozed-shelf" && item.type === "v2-snoozed-shelf") { | |
| 798 | return previous.count === item.count && previous.expanded === item.expanded; | |
| 799 | } | |
| 765 | 800 | if ( |
| 766 | 801 | previous.type === "v2-thread" || |
| 767 | 802 | previous.type === "v2-show-more" || |
| 768 | 803 | previous.type === "v2-pending" || |
| 804 | previous.type === "v2-snoozed-shelf" || | |
| 769 | 805 | item.type === "v2-thread" || |
| 770 | 806 | item.type === "v2-show-more" || |
| 771 | item.type === "v2-pending" | |
| 807 | item.type === "v2-pending" || | |
| 808 | item.type === "v2-snoozed-shelf" | |
| 772 | 809 | ) { |
| 773 | 810 | return false; |
| 774 | 811 | } |
| ... | ... | @@ -827,6 +864,9 @@ function ThreadNavigationSidebarPane( |
| 827 | 864 | thread={thread} |
| 828 | 865 | variant={item.item.variant} |
| 829 | 866 | showSettledDivider={item.item.showSettledDivider} |
| 867 | snoozed={item.item.snoozed} | |
| 868 | snoozePresetMinute={nowMinute} | |
| 869 | snoozeWakeLabelText={item.snoozeWakeLabelText} | |
| 830 | 870 | project={projectByKey.get(scopeKey) ?? null} |
| 831 | 871 | projectTitle={projectTitleByProjectKey.get(scopeKey)} |
| 832 | 872 | providerDriver={ |
| ... | ... | @@ -860,6 +900,9 @@ function ThreadNavigationSidebarPane( |
| 860 | 900 | onArchiveThread={archiveThread} |
| 861 | 901 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} |
| 862 | 902 | onSettleThread={settleThread} |
| 903 | snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} | |
| 904 | onSnoozeThread={snoozeThread} | |
| 905 | onUnsnoozeThread={unsnoozeThread} | |
| 863 | 906 | onUnsettleThread={unsettleThread} |
| 864 | 907 | onChangeRequestState={handleChangeRequestState} |
| 865 | 908 | projectCwd={projectCwdByKey.get(scopeKey) ?? null} |
| ... | ... | @@ -869,6 +912,15 @@ function ThreadNavigationSidebarPane( |
| 869 | 912 | /> |
| 870 | 913 | ); |
| 871 | 914 | } |
| 915 | case "v2-snoozed-shelf": | |
| 916 | return ( | |
| 917 | <ThreadListV2SnoozedShelfHeader | |
| 918 | count={item.count} | |
| 919 | expanded={item.expanded} | |
| 920 | onToggle={toggleSnoozedShelf} | |
| 921 | pane="sidebar" | |
| 922 | /> | |
| 923 | ); | |
| 872 | 924 | case "v2-show-more": |
| 873 | 925 | return ( |
| 874 | 926 | <Pressable |
| ... | ... | @@ -984,7 +1036,12 @@ function ThreadNavigationSidebarPane( |
| 984 | 1036 | settlementEnvironmentIds, |
| 985 | 1037 | showMoreSettled, |
| 986 | 1038 | sidebarScrollGesture, |
| 1039 | snoozeEnvironmentIds, | |
| 1040 | snoozeThread, | |
| 1041 | nowMinute, | |
| 1042 | toggleSnoozedShelf, | |
| 987 | 1043 | unsettleThread, |
| 1044 | unsnoozeThread, | |
| 988 | 1045 | updateGroupDisplay, |
| 989 | 1046 | ], |
| 990 | 1047 | ); |
| ... | ... | @@ -1031,30 +1088,19 @@ function ThreadNavigationSidebarPane( |
| 1031 | 1088 | }), |
| 1032 | 1089 | [filterIcon, filterMenu, props.onOpenSettings], |
| 1033 | 1090 | ); |
| 1034 | // "No threads yet" over an inbox that is merely all-snoozed reads as | |
| 1035 | // data loss; name the snoozed threads instead. | |
| 1036 | const snoozedCount = threadListV2Layout.snoozedCount; | |
| 1091 | // Snoozed threads need no special case: the shelf header is a list row | |
| 1092 | // even while collapsed. | |
| 1037 | 1093 | const listEmpty = ( |
| 1038 | 1094 | <Text className="px-2 py-4 text-sm text-foreground-muted"> |
| 1039 | 1095 | {catalogState.isLoadingConnections |
| 1040 | 1096 | ? "Loading threads…" |
| 1041 | 1097 | : props.searchQuery.trim().length > 0 |
| 1042 | ? threadSearch.isPending && snoozedCount === 0 | |
| 1098 | ? threadSearch.isPending | |
| 1043 | 1099 | ? "Searching thread messages…" |
| 1044 | : snoozedCount > 0 | |
| 1045 | ? // Snoozed matches passed this same search filter — "No | |
| 1046 | // matching threads" would misreport them as nonexistent. | |
| 1047 | snoozedCount === 1 | |
| 1048 | ? "1 matching thread snoozed" | |
| 1049 | : "All matching threads snoozed" | |
| 1050 | : "No matching threads" | |
| 1051 | : snoozedCount > 0 | |
| 1052 | ? snoozedCount === 1 | |
| 1053 | ? "1 thread snoozed" | |
| 1054 | : `${snoozedCount} threads snoozed` | |
| 1055 | : selectedProjectScope !== null | |
| 1056 | ? `No threads in ${selectedProjectScope.title}` | |
| 1057 | : "No threads yet"} | |
| 1100 | : "No matching threads" | |
| 1101 | : selectedProjectScope !== null | |
| 1102 | ? `No threads in ${selectedProjectScope.title}` | |
| 1103 | : "No threads yet"} | |
| 1058 | 1104 | </Text> |
| 1059 | 1105 | ); |
| 1060 | 1106 |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+204-23| ... | ... | @@ -3,11 +3,20 @@ import type { |
| 3 | 3 | EnvironmentThreadShell, |
| 4 | 4 | } from "@t3tools/client-runtime/state/shell"; |
| 5 | 5 | import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; |
| 6 | import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; | |
| 6 | 7 | import type { MenuAction } from "@react-native-menu/menu"; |
| 7 | import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react"; | |
| 8 | import { Platform, Pressable, useWindowDimensions, View } from "react-native"; | |
| 8 | import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; | |
| 9 | import { | |
| 10 | Alert, | |
| 11 | Platform, | |
| 12 | Pressable, | |
| 13 | useColorScheme, | |
| 14 | useWindowDimensions, | |
| 15 | View, | |
| 16 | } from "react-native"; | |
| 9 | 17 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 10 | 18 | |
| 19 | import { SymbolView } from "../../components/AppSymbol"; | |
| 11 | 20 | import { AppText as Text } from "../../components/AppText"; |
| 12 | 21 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 13 | 22 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| ... | ... | @@ -18,7 +27,13 @@ import { useThemeColor } from "../../lib/useThemeColor"; |
| 18 | 27 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 19 | 28 | import { useThreadPr } from "../../state/use-thread-pr"; |
| 20 | 29 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; |
| 21 | import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; | |
| 30 | import { | |
| 31 | resolveThreadListV2SnoozeMenuSelection, | |
| 32 | resolveThreadListV2SnoozeGateExpiryMs, | |
| 33 | resolveThreadListV2Status, | |
| 34 | resolveThreadListV2SwipeActions, | |
| 35 | type ThreadListV2Status, | |
| 36 | } from "./threadListV2"; | |
| 22 | 37 | import { ThreadSearchMatchExcerpt } from "./thread-search-match"; |
| 23 | 38 | |
| 24 | 39 | /** |
| ... | ... | @@ -62,6 +77,11 @@ const SLIM_MENU_ACTIONS: MenuAction[] = [ |
| 62 | 77 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, |
| 63 | 78 | ]; |
| 64 | 79 | |
| 80 | const SNOOZED_MENU_ACTIONS: MenuAction[] = [ | |
| 81 | { id: "unsnooze", title: "Wake thread", image: "clock" }, | |
| 82 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, | |
| 83 | ]; | |
| 84 | ||
| 65 | 85 | // Pre-settlement servers: no lifecycle items, archive fills the gap. |
| 66 | 86 | const LEGACY_MENU_ACTIONS: MenuAction[] = [ |
| 67 | 87 | { id: "archive", title: "Archive", image: "archivebox" }, |
| ... | ... | @@ -90,6 +110,45 @@ export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivid |
| 90 | 110 | ); |
| 91 | 111 | }); |
| 92 | 112 | |
| 113 | const SNOOZE_ACCENT_LIGHT = "#2563eb"; | |
| 114 | const SNOOZE_ACCENT_DARK = "#60a5fa"; | |
| 115 | ||
| 116 | export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: { | |
| 117 | readonly count: number; | |
| 118 | readonly expanded: boolean; | |
| 119 | readonly onToggle: () => void; | |
| 120 | readonly pane?: "screen" | "sidebar"; | |
| 121 | }) { | |
| 122 | const colorScheme = useColorScheme(); | |
| 123 | return ( | |
| 124 | <Pressable | |
| 125 | accessibilityHint={ | |
| 126 | props.expanded ? "Collapses the snoozed threads." : "Expands the snoozed threads." | |
| 127 | } | |
| 128 | accessibilityLabel={props.count === 1 ? "1 snoozed thread" : `${props.count} snoozed threads`} | |
| 129 | accessibilityRole="button" | |
| 130 | accessibilityState={{ expanded: props.expanded }} | |
| 131 | className={cn( | |
| 132 | "mb-1.5 mt-4 flex-row items-center gap-2.5", | |
| 133 | props.pane === "sidebar" ? "px-3" : "px-5", | |
| 134 | )} | |
| 135 | onPress={props.onToggle} | |
| 136 | style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} | |
| 137 | > | |
| 138 | <Text className="text-xs font-t3-medium text-blue-600 dark:text-blue-400"> | |
| 139 | {props.expanded ? "Snoozed" : `Snoozed (${props.count})`} | |
| 140 | </Text> | |
| 141 | <View className="h-px flex-1 bg-blue-500/20 dark:bg-blue-400/15" /> | |
| 142 | <SymbolView | |
| 143 | name={props.expanded ? "chevron.up" : "chevron.down"} | |
| 144 | size={10} | |
| 145 | tintColor={colorScheme === "dark" ? SNOOZE_ACCENT_DARK : SNOOZE_ACCENT_LIGHT} | |
| 146 | type="monochrome" | |
| 147 | /> | |
| 148 | </Pressable> | |
| 149 | ); | |
| 150 | }); | |
| 151 | ||
| 93 | 152 | const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ |
| 94 | 153 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, |
| 95 | 154 | ]; |
| ... | ... | @@ -209,6 +268,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 209 | 268 | readonly thread: EnvironmentThreadShell; |
| 210 | 269 | readonly variant: "card" | "slim"; |
| 211 | 270 | readonly showSettledDivider: boolean; |
| 271 | /** Snoozed-shelf row: shows its wake time and offers Wake. */ | |
| 272 | readonly snoozed?: boolean; | |
| 273 | /** Preformatted against the parent minute tick so this memoized row's | |
| 274 | countdown keeps moving. */ | |
| 275 | readonly snoozeWakeLabelText?: string; | |
| 276 | /** Parent minute tick passed as a prop so this memoized row refreshes its | |
| 277 | native snooze menu while mounted. */ | |
| 278 | readonly snoozePresetMinute: string; | |
| 212 | 279 | readonly project: EnvironmentProject | null; |
| 213 | 280 | readonly projectTitle?: string; |
| 214 | 281 | readonly providerDriver: string | null; |
| ... | ... | @@ -231,11 +298,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 231 | 298 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; |
| 232 | 299 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; |
| 233 | 300 | readonly onSettleThread: (thread: EnvironmentThreadShell) => void; |
| 301 | readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void; | |
| 302 | readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void; | |
| 234 | 303 | readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; |
| 235 | 304 | readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; |
| 236 | 305 | /** False on environments whose server predates thread.settle/unsettle: |
| 237 | 306 | swipe + menu fall back to Archive instead of failing on use. */ |
| 238 | 307 | readonly settlementSupported: boolean; |
| 308 | /** False on servers that predate thread.snooze/unsnooze. */ | |
| 309 | readonly snoozeSupported: boolean; | |
| 239 | 310 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; |
| 240 | 311 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; |
| 241 | 312 | /** Reports this row's live PR state up so the partition can auto-settle |
| ... | ... | @@ -258,10 +329,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 258 | 329 | onSelectThread, |
| 259 | 330 | onDeleteThread, |
| 260 | 331 | onSettleThread, |
| 332 | onSnoozeThread, | |
| 333 | onUnsnoozeThread, | |
| 261 | 334 | onUnsettleThread, |
| 262 | 335 | onArchiveThread, |
| 263 | 336 | onChangeRequestState, |
| 264 | 337 | } = props; |
| 338 | const snoozedRow = props.snoozed === true; | |
| 265 | 339 | |
| 266 | 340 | const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); |
| 267 | 341 | const prState = pr?.state ?? null; |
| ... | ... | @@ -283,27 +357,94 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 283 | 357 | |
| 284 | 358 | const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); |
| 285 | 359 | const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); |
| 360 | const handleSnooze = useCallback( | |
| 361 | (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil), | |
| 362 | [onSnoozeThread, thread], | |
| 363 | ); | |
| 364 | const handleUnsnooze = useCallback(() => onUnsnoozeThread(thread), [onUnsnoozeThread, thread]); | |
| 286 | 365 | const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); |
| 287 | 366 | const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); |
| 367 | ||
| 368 | // Swipe: the v2 primary action is the lifecycle transition. Every settled | |
| 369 | // row can un-settle — explicit settles clear the override, auto-settled | |
| 370 | // rows get pinned active until real activity clears the pin. | |
| 371 | const canUnsettle = variant === "slim"; | |
| 372 | const [snoozeGateTick, bumpSnoozeGateTick] = useState(0); | |
| 373 | const snoozeGateExpiryMs = props.snoozeSupported | |
| 374 | ? resolveThreadListV2SnoozeGateExpiryMs(thread, { now: new Date().toISOString() }) | |
| 375 | : null; | |
| 376 | useEffect(() => { | |
| 377 | if (snoozeGateExpiryMs === null) return; | |
| 378 | const delayMs = Math.min(Math.max(0, snoozeGateExpiryMs - Date.now()) + 50, 2_147_483_647); | |
| 379 | const id = setTimeout(() => bumpSnoozeGateTick((tick) => tick + 1), delayMs); | |
| 380 | return () => clearTimeout(id); | |
| 381 | }, [snoozeGateExpiryMs, snoozeGateTick]); | |
| 382 | const swipeActions = resolveThreadListV2SwipeActions({ | |
| 383 | variant, | |
| 384 | settlementSupported: props.settlementSupported, | |
| 385 | snoozeSupported: props.snoozeSupported, | |
| 386 | snoozable: canSnooze(thread, { now: new Date().toISOString() }), | |
| 387 | snoozed: snoozedRow, | |
| 388 | }); | |
| 389 | const snoozePresets = useMemo( | |
| 390 | () => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)), | |
| 391 | [props.snoozePresetMinute, swipeActions.secondary], | |
| 392 | ); | |
| 393 | const snoozePresetActions = useMemo<MenuAction[]>( | |
| 394 | () => | |
| 395 | snoozePresets.map((preset) => ({ | |
| 396 | id: `snooze:${preset.id}`, | |
| 397 | title: preset.label, | |
| 398 | subtitle: preset.whenLabel, | |
| 399 | })), | |
| 400 | [snoozePresets], | |
| 401 | ); | |
| 402 | const snoozableCardMenuActions = useMemo<MenuAction[]>( | |
| 403 | () => [ | |
| 404 | { id: "settle", title: "Settle", image: "checkmark" }, | |
| 405 | { | |
| 406 | id: "snooze", | |
| 407 | title: "Snooze", | |
| 408 | image: "clock", | |
| 409 | subactions: snoozePresetActions, | |
| 410 | }, | |
| 411 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, | |
| 412 | ], | |
| 413 | [snoozePresetActions], | |
| 414 | ); | |
| 288 | 415 | const handleMenuAction = useCallback( |
| 289 | 416 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { |
| 290 | 417 | if (nativeEvent.event === "settle") handleSettle(); |
| 291 | 418 | if (nativeEvent.event === "unsettle") handleUnsettle(); |
| 419 | if (nativeEvent.event === "unsnooze") handleUnsnooze(); | |
| 292 | 420 | if (nativeEvent.event === "archive") handleArchive(); |
| 293 | 421 | if (nativeEvent.event === "delete") handleDelete(); |
| 422 | const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ | |
| 423 | event: nativeEvent.event, | |
| 424 | displayedPresets: snoozePresets, | |
| 425 | now: new Date(), | |
| 426 | }); | |
| 427 | if (snoozeSelection._tag === "selected") { | |
| 428 | handleSnooze(snoozeSelection.preset.snoozedUntil); | |
| 429 | } else if (snoozeSelection._tag === "expired") { | |
| 430 | Alert.alert("Could not snooze thread", "That snooze time has passed. Choose another time."); | |
| 431 | } | |
| 294 | 432 | }, |
| 295 | [handleArchive, handleDelete, handleSettle, handleUnsettle], | |
| 433 | [ | |
| 434 | handleArchive, | |
| 435 | handleDelete, | |
| 436 | handleSettle, | |
| 437 | handleSnooze, | |
| 438 | handleUnsettle, | |
| 439 | handleUnsnooze, | |
| 440 | snoozePresets, | |
| 441 | ], | |
| 296 | 442 | ); |
| 297 | ||
| 298 | // Swipe: the v2 primary action is the lifecycle transition. Every settled | |
| 299 | // row can un-settle — explicit settles clear the override, auto-settled | |
| 300 | // rows get pinned active until real activity clears the pin. | |
| 301 | const canUnsettle = variant === "slim"; | |
| 302 | 443 | const primaryAction = useMemo(() => { |
| 303 | 444 | // Pre-settlement server: archive is the swipe action, as in v1. (Slim |
| 304 | 445 | // rows cannot occur here — unsupported environments never classify as |
| 305 | 446 | // settled.) |
| 306 | if (!props.settlementSupported) { | |
| 447 | if (swipeActions.primary === "archive") { | |
| 307 | 448 | return { |
| 308 | 449 | accessibilityLabel: `Archive ${thread.title}`, |
| 309 | 450 | icon: "archivebox" as const, |
| ... | ... | @@ -311,7 +452,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 311 | 452 | onPress: handleArchive, |
| 312 | 453 | }; |
| 313 | 454 | } |
| 314 | return canUnsettle | |
| 455 | if (swipeActions.primary === "unsnooze") { | |
| 456 | return { | |
| 457 | accessibilityLabel: `Wake ${thread.title} now`, | |
| 458 | icon: "clock" as const, | |
| 459 | label: "Wake", | |
| 460 | onPress: handleUnsnooze, | |
| 461 | }; | |
| 462 | } | |
| 463 | return swipeActions.primary === "unsettle" | |
| 315 | 464 | ? { |
| 316 | 465 | accessibilityLabel: `Un-settle ${thread.title}`, |
| 317 | 466 | icon: "arrow.uturn.backward" as const, |
| ... | ... | @@ -325,13 +474,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 325 | 474 | onPress: handleSettle, |
| 326 | 475 | }; |
| 327 | 476 | }, [ |
| 328 | canUnsettle, | |
| 329 | 477 | handleArchive, |
| 330 | 478 | handleSettle, |
| 331 | 479 | handleUnsettle, |
| 332 | props.settlementSupported, | |
| 480 | handleUnsnooze, | |
| 481 | swipeActions.primary, | |
| 333 | 482 | thread.title, |
| 334 | 483 | ]); |
| 484 | const secondaryAction = useMemo( | |
| 485 | () => | |
| 486 | swipeActions.secondary === "snooze" | |
| 487 | ? { | |
| 488 | accessibilityLabel: `Choose when to snooze ${thread.title}`, | |
| 489 | icon: "clock" as const, | |
| 490 | label: "Snooze", | |
| 491 | menu: { | |
| 492 | actions: snoozePresetActions, | |
| 493 | onPressAction: handleMenuAction, | |
| 494 | title: "Snooze until", | |
| 495 | }, | |
| 496 | onPress: () => undefined, | |
| 497 | } | |
| 498 | : null, | |
| 499 | [handleMenuAction, snoozePresetActions, swipeActions.secondary, thread.title], | |
| 500 | ); | |
| 501 | const swipeAccessibilityHint = | |
| 502 | secondaryAction === null | |
| 503 | ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.` | |
| 504 | : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`; | |
| 335 | 505 | |
| 336 | 506 | // The sidebar pane fills selected rows with the accent color (matching the |
| 337 | 507 | // v1 sidebar), so every piece of row text needs a white-on-accent variant. |
| ... | ... | @@ -453,7 +623,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 453 | 623 | const rowContent = (close: () => void) => |
| 454 | 624 | variant === "card" ? ( |
| 455 | 625 | <Pressable |
| 456 | accessibilityHint={`Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`} | |
| 626 | accessibilityHint={swipeAccessibilityHint} | |
| 457 | 627 | accessibilityLabel={thread.title} |
| 458 | 628 | accessibilityRole="button" |
| 459 | 629 | accessibilityState={{ selected }} |
| ... | ... | @@ -491,7 +661,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 491 | 661 | </Pressable> |
| 492 | 662 | ) : ( |
| 493 | 663 | <Pressable |
| 494 | accessibilityHint={`Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`} | |
| 664 | accessibilityHint={swipeAccessibilityHint} | |
| 495 | 665 | accessibilityLabel={thread.title} |
| 496 | 666 | accessibilityRole="button" |
| 497 | 667 | accessibilityState={{ selected }} |
| ... | ... | @@ -551,11 +721,17 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 551 | 721 | <Text |
| 552 | 722 | className={cn( |
| 553 | 723 | "text-sm tabular-nums", |
| 554 | selected ? "text-user-bubble-foreground-muted" : "text-foreground-tertiary", | |
| 724 | selected | |
| 725 | ? "text-user-bubble-foreground-muted" | |
| 726 | : snoozedRow | |
| 727 | ? "text-blue-600 dark:text-blue-400" | |
| 728 | : "text-foreground-tertiary", | |
| 555 | 729 | )} |
| 556 | 730 | style={{ fontFamily: MONO_FONT }} |
| 557 | 731 | > |
| 558 | {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} | |
| 732 | {snoozedRow && props.snoozeWakeLabelText !== undefined | |
| 733 | ? props.snoozeWakeLabelText | |
| 734 | : relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} | |
| 559 | 735 | </Text> |
| 560 | 736 | </View> |
| 561 | 737 | </Pressable> |
| ... | ... | @@ -574,13 +750,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 574 | 750 | } |
| 575 | 751 | enableTrackpadSwipe |
| 576 | 752 | // Full swipe commits the advertised lifecycle action (Settle / |
| 577 | // Un-settle), never the destructive delete. | |
| 753 | // Un-settle), never the secondary snooze action. | |
| 578 | 754 | fullSwipeAction="primary" |
| 579 | 755 | fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32} |
| 580 | 756 | onDelete={handleDelete} |
| 581 | 757 | onSwipeableClose={props.onSwipeableClose} |
| 582 | 758 | onSwipeableWillOpen={props.onSwipeableWillOpen} |
| 583 | 759 | primaryAction={primaryAction} |
| 760 | secondaryAction={secondaryAction} | |
| 584 | 761 | resetKey={`${thread.environmentId}:${thread.id}`} |
| 585 | 762 | simultaneousWithExternalGesture={props.simultaneousSwipeGesture} |
| 586 | 763 | threadTitle={thread.title} |
| ... | ... | @@ -588,11 +765,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 588 | 765 | {(close) => ( |
| 589 | 766 | <ControlPillMenu |
| 590 | 767 | actions={ |
| 591 | !props.settlementSupported | |
| 592 | ? LEGACY_MENU_ACTIONS | |
| 593 | : canUnsettle | |
| 594 | ? SLIM_MENU_ACTIONS | |
| 595 | : CARD_MENU_ACTIONS | |
| 768 | snoozedRow | |
| 769 | ? SNOOZED_MENU_ACTIONS | |
| 770 | : !props.settlementSupported | |
| 771 | ? LEGACY_MENU_ACTIONS | |
| 772 | : canUnsettle | |
| 773 | ? SLIM_MENU_ACTIONS | |
| 774 | : swipeActions.secondary === "snooze" | |
| 775 | ? snoozableCardMenuActions | |
| 776 | : CARD_MENU_ACTIONS | |
| 596 | 777 | } |
| 597 | 778 | onPressAction={handleMenuAction} |
| 598 | 779 | shouldOpenOnLongPress |
apps/mobile/src/features/threads/threadListV2.test.ts+280-1| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 2 | 2 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 3 | import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; | |
| 3 | 4 | import { |
| 4 | 5 | CommandId, |
| 5 | 6 | EnvironmentId, |
| ... | ... | @@ -16,7 +17,10 @@ import { |
| 16 | 17 | buildThreadListV2Items, |
| 17 | 18 | buildThreadListV2ListItems, |
| 18 | 19 | resolveThreadListV2Enabled, |
| 20 | resolveThreadListV2SnoozeMenuSelection, | |
| 21 | resolveThreadListV2SnoozeGateExpiryMs, | |
| 19 | 22 | resolveThreadListV2Status, |
| 23 | resolveThreadListV2SwipeActions, | |
| 20 | 24 | sortThreadsForListV2, |
| 21 | 25 | } from "./threadListV2"; |
| 22 | 26 | |
| ... | ... | @@ -50,6 +54,54 @@ function makeThread( |
| 50 | 54 | |
| 51 | 55 | const NOW = "2026-06-02T00:00:00.000Z"; |
| 52 | 56 | |
| 57 | describe("resolveThreadListV2SnoozeMenuSelection", () => { | |
| 58 | it("accepts a displayed evening preset while its wake time is still future", () => { | |
| 59 | const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30); | |
| 60 | const selectedAt = new Date(2026, 4, 8, 17, 0, 30); | |
| 61 | const displayedPresets = resolveSnoozePresets(menuOpenedAt); | |
| 62 | ||
| 63 | const selection = resolveThreadListV2SnoozeMenuSelection({ | |
| 64 | event: "snooze:evening", | |
| 65 | displayedPresets, | |
| 66 | now: selectedAt, | |
| 67 | }); | |
| 68 | ||
| 69 | expect(selection).toEqual({ | |
| 70 | _tag: "selected", | |
| 71 | preset: displayedPresets.find((preset) => preset.id === "evening"), | |
| 72 | }); | |
| 73 | }); | |
| 74 | ||
| 75 | it("expires a displayed preset once its wake time has passed", () => { | |
| 76 | const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 16, 59, 30)); | |
| 77 | ||
| 78 | expect( | |
| 79 | resolveThreadListV2SnoozeMenuSelection({ | |
| 80 | event: "snooze:evening", | |
| 81 | displayedPresets, | |
| 82 | now: new Date(2026, 4, 8, 18, 0, 1), | |
| 83 | }), | |
| 84 | ).toEqual({ _tag: "expired" }); | |
| 85 | }); | |
| 86 | ||
| 87 | it("recomputes presets that remain available instead of using old timestamps", () => { | |
| 88 | const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 10)); | |
| 89 | const selectedAt = new Date(2026, 4, 8, 10, 30); | |
| 90 | const selection = resolveThreadListV2SnoozeMenuSelection({ | |
| 91 | event: "snooze:hour", | |
| 92 | displayedPresets, | |
| 93 | now: selectedAt, | |
| 94 | }); | |
| 95 | ||
| 96 | expect(selection._tag).toBe("selected"); | |
| 97 | if (selection._tag === "selected") { | |
| 98 | expect(selection.preset.snoozedUntil).toBe( | |
| 99 | new Date(selectedAt.getTime() + 60 * 60 * 1_000).toISOString(), | |
| 100 | ); | |
| 101 | } | |
| 102 | }); | |
| 103 | }); | |
| 104 | ||
| 53 | 105 | describe("resolveThreadListV2Enabled", () => { |
| 54 | 106 | it("defaults on when the device has never chosen", () => { |
| 55 | 107 | expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe( |
| ... | ... | @@ -96,6 +148,105 @@ describe("resolveThreadListV2Status", () => { |
| 96 | 148 | }); |
| 97 | 149 | }); |
| 98 | 150 | |
| 151 | describe("resolveThreadListV2SwipeActions", () => { | |
| 152 | it("offers settle and snooze for an active snoozable thread", () => { | |
| 153 | expect( | |
| 154 | resolveThreadListV2SwipeActions({ | |
| 155 | variant: "card", | |
| 156 | settlementSupported: true, | |
| 157 | snoozeSupported: true, | |
| 158 | snoozable: true, | |
| 159 | }), | |
| 160 | ).toEqual({ primary: "settle", secondary: "snooze" }); | |
| 161 | }); | |
| 162 | ||
| 163 | it("offers un-settle and snooze for settled history", () => { | |
| 164 | expect( | |
| 165 | resolveThreadListV2SwipeActions({ | |
| 166 | variant: "slim", | |
| 167 | settlementSupported: true, | |
| 168 | snoozeSupported: true, | |
| 169 | snoozable: true, | |
| 170 | }), | |
| 171 | ).toEqual({ primary: "unsettle", secondary: "snooze" }); | |
| 172 | }); | |
| 173 | ||
| 174 | it("omits snooze when the server or thread does not allow it", () => { | |
| 175 | expect( | |
| 176 | resolveThreadListV2SwipeActions({ | |
| 177 | variant: "card", | |
| 178 | settlementSupported: true, | |
| 179 | snoozeSupported: false, | |
| 180 | snoozable: true, | |
| 181 | }), | |
| 182 | ).toEqual({ primary: "settle", secondary: null }); | |
| 183 | expect( | |
| 184 | resolveThreadListV2SwipeActions({ | |
| 185 | variant: "card", | |
| 186 | settlementSupported: true, | |
| 187 | snoozeSupported: true, | |
| 188 | snoozable: false, | |
| 189 | }), | |
| 190 | ).toEqual({ primary: "settle", secondary: null }); | |
| 191 | }); | |
| 192 | ||
| 193 | it("falls back to archive only for a pre-lifecycle server", () => { | |
| 194 | expect( | |
| 195 | resolveThreadListV2SwipeActions({ | |
| 196 | variant: "card", | |
| 197 | settlementSupported: false, | |
| 198 | snoozeSupported: false, | |
| 199 | snoozable: true, | |
| 200 | }), | |
| 201 | ).toEqual({ primary: "archive", secondary: null }); | |
| 202 | }); | |
| 203 | ||
| 204 | it("offers wake and no snooze on a snoozed row", () => { | |
| 205 | expect( | |
| 206 | resolveThreadListV2SwipeActions({ | |
| 207 | variant: "slim", | |
| 208 | settlementSupported: true, | |
| 209 | snoozeSupported: true, | |
| 210 | snoozable: true, | |
| 211 | snoozed: true, | |
| 212 | }), | |
| 213 | ).toEqual({ primary: "unsnooze", secondary: null }); | |
| 214 | }); | |
| 215 | }); | |
| 216 | ||
| 217 | describe("resolveThreadListV2SnoozeGateExpiryMs", () => { | |
| 218 | it("reports when an unadopted turn's grace window lapses", () => { | |
| 219 | const thread = makeThread({ | |
| 220 | id: ThreadId.make("t"), | |
| 221 | title: "t", | |
| 222 | latestUserMessageAt: "2026-06-02T00:00:30.000Z", | |
| 223 | }); | |
| 224 | expect(resolveThreadListV2SnoozeGateExpiryMs(thread, { now: "2026-06-02T00:01:00.000Z" })).toBe( | |
| 225 | Date.parse("2026-06-02T00:02:30.000Z"), | |
| 226 | ); | |
| 227 | }); | |
| 228 | ||
| 229 | it("returns null once the thread is snoozable or when only data can unblock it", () => { | |
| 230 | expect( | |
| 231 | resolveThreadListV2SnoozeGateExpiryMs( | |
| 232 | makeThread({ id: ThreadId.make("ready"), title: "Ready" }), | |
| 233 | { now: NOW }, | |
| 234 | ), | |
| 235 | ).toBe(null); | |
| 236 | expect( | |
| 237 | resolveThreadListV2SnoozeGateExpiryMs( | |
| 238 | makeThread({ | |
| 239 | id: ThreadId.make("blocked"), | |
| 240 | title: "Blocked", | |
| 241 | hasPendingApprovals: true, | |
| 242 | latestUserMessageAt: NOW, | |
| 243 | }), | |
| 244 | { now: NOW }, | |
| 245 | ), | |
| 246 | ).toBe(null); | |
| 247 | }); | |
| 248 | }); | |
| 249 | ||
| 99 | 250 | describe("sortThreadsForListV2", () => { |
| 100 | 251 | it("orders by creation time, newest first, ignoring activity", () => { |
| 101 | 252 | const sorted = sortThreadsForListV2([ |
| ... | ... | @@ -167,6 +318,93 @@ describe("buildThreadListV2Items", () => { |
| 167 | 318 | expect(layout.nextSnoozeWakeAt).toBe("2026-06-02T09:00:00.000Z"); |
| 168 | 319 | }); |
| 169 | 320 | |
| 321 | it("builds snoozed rows between active and settled when the shelf is expanded", () => { | |
| 322 | const layout = buildThreadListV2Items({ | |
| 323 | threads: [ | |
| 324 | makeThread({ id: ThreadId.make("active"), title: "Active" }), | |
| 325 | makeThread({ | |
| 326 | id: ThreadId.make("settled"), | |
| 327 | title: "Settled", | |
| 328 | settledOverride: "settled", | |
| 329 | settledAt: NOW, | |
| 330 | }), | |
| 331 | makeThread({ | |
| 332 | id: ThreadId.make("later"), | |
| 333 | title: "Wakes later", | |
| 334 | snoozedUntil: "2026-06-03T09:00:00.000Z", | |
| 335 | snoozedAt: "2026-06-01T12:00:00.000Z", | |
| 336 | }), | |
| 337 | makeThread({ | |
| 338 | id: ThreadId.make("sooner"), | |
| 339 | title: "Wakes sooner", | |
| 340 | snoozedUntil: "2026-06-02T09:00:00.000Z", | |
| 341 | snoozedAt: "2026-06-01T12:00:00.000Z", | |
| 342 | }), | |
| 343 | ], | |
| 344 | environmentId: null, | |
| 345 | searchQuery: "", | |
| 346 | now: NOW, | |
| 347 | snoozedShelfExpanded: true, | |
| 348 | }); | |
| 349 | ||
| 350 | expect(layout.items.map((item) => item.thread.id)).toEqual([ | |
| 351 | "active", | |
| 352 | "sooner", | |
| 353 | "later", | |
| 354 | "settled", | |
| 355 | ]); | |
| 356 | expect(layout.items.map((item) => item.snoozed)).toEqual([false, true, true, false]); | |
| 357 | expect(layout.snoozedShelfHeaderIndex).toBe(1); | |
| 358 | expect(layout.snoozedCount).toBe(2); | |
| 359 | }); | |
| 360 | ||
| 361 | it("collapses to a header-only shelf", () => { | |
| 362 | const layout = buildThreadListV2Items({ | |
| 363 | threads: [ | |
| 364 | makeThread({ | |
| 365 | id: ThreadId.make("snoozed"), | |
| 366 | title: "Snoozed", | |
| 367 | snoozedUntil: "2026-06-03T09:00:00.000Z", | |
| 368 | snoozedAt: "2026-06-01T12:00:00.000Z", | |
| 369 | }), | |
| 370 | ], | |
| 371 | environmentId: null, | |
| 372 | searchQuery: "", | |
| 373 | now: NOW, | |
| 374 | }); | |
| 375 | ||
| 376 | expect(layout.items).toEqual([]); | |
| 377 | expect(layout.snoozedCount).toBe(1); | |
| 378 | expect(layout.snoozedShelfHeaderIndex).toBe(0); | |
| 379 | }); | |
| 380 | ||
| 381 | it("keeps the selected thread on a collapsed shelf", () => { | |
| 382 | const layout = buildThreadListV2Items({ | |
| 383 | threads: [ | |
| 384 | makeThread({ | |
| 385 | id: ThreadId.make("open"), | |
| 386 | title: "Open", | |
| 387 | snoozedUntil: "2026-06-03T09:00:00.000Z", | |
| 388 | snoozedAt: "2026-06-01T12:00:00.000Z", | |
| 389 | }), | |
| 390 | makeThread({ | |
| 391 | id: ThreadId.make("other"), | |
| 392 | title: "Other", | |
| 393 | snoozedUntil: "2026-06-03T10:00:00.000Z", | |
| 394 | snoozedAt: "2026-06-01T12:00:00.000Z", | |
| 395 | }), | |
| 396 | ], | |
| 397 | environmentId: null, | |
| 398 | searchQuery: "", | |
| 399 | now: NOW, | |
| 400 | selectedThreadKey: `${environmentId}:open`, | |
| 401 | }); | |
| 402 | ||
| 403 | expect(layout.items.map((item) => item.thread.id)).toEqual(["open"]); | |
| 404 | expect(layout.items[0]?.snoozed).toBe(true); | |
| 405 | expect(layout.snoozedCount).toBe(2); | |
| 406 | }); | |
| 407 | ||
| 170 | 408 | it("keeps snoozed threads visible on environments without the snooze capability", () => { |
| 171 | 409 | const layout = buildThreadListV2Items({ |
| 172 | 410 | threads: [ |
| ... | ... | @@ -424,7 +662,11 @@ describe("buildThreadListV2ListItems", () => { |
| 424 | 662 | |
| 425 | 663 | expect( |
| 426 | 664 | items.map((item) => |
| 427 | item.type === "v2-pending" ? item.pendingTask.title : item.item.thread.id, | |
| 665 | item.type === "v2-pending" | |
| 666 | ? item.pendingTask.title | |
| 667 | : item.type === "v2-thread" | |
| 668 | ? item.item.thread.id | |
| 669 | : "snoozed-shelf", | |
| 428 | 670 | ), |
| 429 | 671 | ).toEqual(["active", "queued-1", "queued-2", "settled"]); |
| 430 | 672 | // Only the leading queued row labels the section, exactly like Settled. |
| ... | ... | @@ -456,4 +698,41 @@ describe("buildThreadListV2ListItems", () => { |
| 456 | 698 | `v2-thread:${environmentId}:settled`, |
| 457 | 699 | ]); |
| 458 | 700 | }); |
| 701 | ||
| 702 | it("places queued tasks before a collapsed snoozed shelf", () => { | |
| 703 | const snoozedLayout = buildThreadListV2Items({ | |
| 704 | threads: [ | |
| 705 | makeThread({ id: ThreadId.make("active"), title: "active" }), | |
| 706 | makeThread({ | |
| 707 | id: ThreadId.make("snoozed"), | |
| 708 | title: "snoozed", | |
| 709 | snoozedUntil: "2026-06-03T09:00:00.000Z", | |
| 710 | snoozedAt: "2026-06-01T12:00:00.000Z", | |
| 711 | }), | |
| 712 | makeThread({ | |
| 713 | id: ThreadId.make("settled"), | |
| 714 | title: "settled", | |
| 715 | settledOverride: "settled", | |
| 716 | settledAt: NOW, | |
| 717 | }), | |
| 718 | ], | |
| 719 | environmentId: null, | |
| 720 | searchQuery: "", | |
| 721 | now: NOW, | |
| 722 | }); | |
| 723 | const items = buildThreadListV2ListItems({ | |
| 724 | items: snoozedLayout.items, | |
| 725 | pendingTasks: [makePendingTask("queued")], | |
| 726 | snoozedCount: snoozedLayout.snoozedCount, | |
| 727 | snoozedShelfExpanded: false, | |
| 728 | snoozedShelfHeaderIndex: snoozedLayout.snoozedShelfHeaderIndex, | |
| 729 | }); | |
| 730 | ||
| 731 | expect(items.map((item) => item.type)).toEqual([ | |
| 732 | "v2-thread", | |
| 733 | "v2-pending", | |
| 734 | "v2-snoozed-shelf", | |
| 735 | "v2-thread", | |
| 736 | ]); | |
| 737 | }); | |
| 459 | 738 | }); |
apps/mobile/src/features/threads/threadListV2.ts+167-18| ... | ... | @@ -1,10 +1,20 @@ |
| 1 | import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; | |
| 1 | import { | |
| 2 | effectiveSettled, | |
| 3 | effectiveSnoozed, | |
| 4 | hasQueuedTurnStart, | |
| 5 | QUEUED_TURN_START_GRACE_MS, | |
| 6 | resolveSnoozePresets, | |
| 7 | snoozeWakeLabel, | |
| 8 | } from "@t3tools/client-runtime/state/thread-settled"; | |
| 9 | import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; | |
| 2 | 10 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 3 | 11 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 4 | 12 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 5 | 13 | |
| 6 | 14 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 7 | 15 | |
| 16 | export { snoozeWakeLabel }; | |
| 17 | ||
| 8 | 18 | /** |
| 9 | 19 | * Thread List v2 model, ported from the web sidebar v2 |
| 10 | 20 | * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). |
| ... | ... | @@ -14,6 +24,76 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 14 | 24 | * unlabeled resting state. |
| 15 | 25 | */ |
| 16 | 26 | export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; |
| 27 | export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; | |
| 28 | ||
| 29 | export function resolveThreadListV2SnoozeMenuSelection(input: { | |
| 30 | readonly event: string; | |
| 31 | readonly displayedPresets: ReadonlyArray<SnoozePreset>; | |
| 32 | readonly now: Date; | |
| 33 | }): | |
| 34 | | { readonly _tag: "selected"; readonly preset: SnoozePreset } | |
| 35 | | { readonly _tag: "expired" } | |
| 36 | | { readonly _tag: "not-snooze" } { | |
| 37 | if (!input.event.startsWith("snooze:")) return { _tag: "not-snooze" }; | |
| 38 | ||
| 39 | const currentPreset = resolveSnoozePresets(input.now).find( | |
| 40 | (candidate) => input.event === `snooze:${candidate.id}`, | |
| 41 | ); | |
| 42 | if (currentPreset) return { _tag: "selected", preset: currentPreset }; | |
| 43 | ||
| 44 | const displayedPreset = input.displayedPresets.find( | |
| 45 | (candidate) => input.event === `snooze:${candidate.id}`, | |
| 46 | ); | |
| 47 | if (displayedPreset && Date.parse(displayedPreset.snoozedUntil) > input.now.getTime()) { | |
| 48 | return { _tag: "selected", preset: displayedPreset }; | |
| 49 | } | |
| 50 | return { _tag: "expired" }; | |
| 51 | } | |
| 52 | ||
| 53 | export function resolveThreadListV2SwipeActions(input: { | |
| 54 | readonly variant: "card" | "slim"; | |
| 55 | readonly settlementSupported: boolean; | |
| 56 | readonly snoozeSupported: boolean; | |
| 57 | readonly snoozable: boolean; | |
| 58 | /** Row is on the snoozed shelf. */ | |
| 59 | readonly snoozed?: boolean; | |
| 60 | }): { | |
| 61 | readonly primary: Exclude<ThreadListV2SwipeAction, "snooze">; | |
| 62 | readonly secondary: "snooze" | null; | |
| 63 | } { | |
| 64 | if (input.snoozed === true) { | |
| 65 | return { primary: "unsnooze", secondary: null }; | |
| 66 | } | |
| 67 | const primary = input.settlementSupported | |
| 68 | ? input.variant === "slim" | |
| 69 | ? "unsettle" | |
| 70 | : "settle" | |
| 71 | : "archive"; | |
| 72 | return { | |
| 73 | primary, | |
| 74 | secondary: input.snoozeSupported && input.snoozable ? "snooze" : null, | |
| 75 | }; | |
| 76 | } | |
| 77 | ||
| 78 | /** | |
| 79 | * The point at which a queued-turn snooze guard expires on its own. Rows arm | |
| 80 | * a one-shot timer for this boundary so Snooze appears without waiting for an | |
| 81 | * unrelated render. User-blocked threads return null because only fresh | |
| 82 | * server data can make them snoozable. | |
| 83 | */ | |
| 84 | export function resolveThreadListV2SnoozeGateExpiryMs( | |
| 85 | thread: Pick< | |
| 86 | EnvironmentThreadShell, | |
| 87 | "hasPendingApprovals" | "hasPendingUserInput" | "latestUserMessageAt" | "latestTurn" | "session" | |
| 88 | >, | |
| 89 | options: { readonly now: string }, | |
| 90 | ): number | null { | |
| 91 | if (thread.hasPendingApprovals || thread.hasPendingUserInput) return null; | |
| 92 | if (!hasQueuedTurnStart(thread, options)) return null; | |
| 93 | const messageAtMs = Date.parse(thread.latestUserMessageAt ?? ""); | |
| 94 | if (Number.isNaN(messageAtMs)) return null; | |
| 95 | return messageAtMs + QUEUED_TURN_START_GRACE_MS; | |
| 96 | } | |
| 17 | 97 | |
| 18 | 98 | // Settled-tail paging: recent history is the common lookup; the deep tail |
| 19 | 99 | // stays behind an explicit Show more. Shared by the compact Home list and |
| ... | ... | @@ -100,6 +180,8 @@ export interface ThreadListV2Item { |
| 100 | 180 | readonly variant: "card" | "slim"; |
| 101 | 181 | /** First settled row after the card block draws the SETTLED divider. */ |
| 102 | 182 | readonly showSettledDivider: boolean; |
| 183 | /** Snoozed-shelf row: shows the wake countdown and offers Wake. */ | |
| 184 | readonly snoozed: boolean; | |
| 103 | 185 | readonly isLast: boolean; |
| 104 | 186 | } |
| 105 | 187 | |
| ... | ... | @@ -107,10 +189,12 @@ export interface ThreadListV2Layout { |
| 107 | 189 | readonly items: ThreadListV2Item[]; |
| 108 | 190 | /** Settled threads beyond the render limit (behind "Show more"). */ |
| 109 | 191 | readonly hiddenSettledCount: number; |
| 110 | /** Snoozed threads hidden from the list (visibility parity with web's | |
| 111 | collapsed Snoozed shelf; mobile has no shelf UI yet). */ | |
| 192 | /** Snoozed threads matching the current filters. */ | |
| 112 | 193 | readonly snoozedCount: number; |
| 113 | /** Soonest wake time among hidden snoozed threads, or null. Callers arm | |
| 194 | /** Index in `items` where the Snoozed shelf header belongs. The header is | |
| 195 | still rendered when the shelf is collapsed and no snoozed rows exist. */ | |
| 196 | readonly snoozedShelfHeaderIndex: number | null; | |
| 197 | /** Soonest wake time among snoozed threads, or null. Callers arm | |
| 114 | 198 | a timeout at this boundary so the list re-partitions the moment a |
| 115 | 199 | snooze expires instead of on the next minute tick. */ |
| 116 | 200 | readonly nextSnoozeWakeAt: string | null; |
| ... | ... | @@ -120,6 +204,8 @@ export interface ThreadListV2ThreadListItem { |
| 120 | 204 | readonly type: "v2-thread"; |
| 121 | 205 | readonly key: string; |
| 122 | 206 | readonly item: ThreadListV2Item; |
| 207 | /** Precomputed so recycled-list equality can see a minute-tick change. */ | |
| 208 | readonly snoozeWakeLabelText: string | undefined; | |
| 123 | 209 | } |
| 124 | 210 | |
| 125 | 211 | export interface ThreadListV2PendingListItem { |
| ... | ... | @@ -130,28 +216,42 @@ export interface ThreadListV2PendingListItem { |
| 130 | 216 | readonly showPendingDivider: boolean; |
| 131 | 217 | } |
| 132 | 218 | |
| 133 | export type ThreadListV2ListItem = ThreadListV2ThreadListItem | ThreadListV2PendingListItem; | |
| 219 | export interface ThreadListV2SnoozedShelfListItem { | |
| 220 | readonly type: "v2-snoozed-shelf"; | |
| 221 | readonly key: "v2-snoozed-shelf"; | |
| 222 | readonly count: number; | |
| 223 | readonly expanded: boolean; | |
| 224 | } | |
| 225 | ||
| 226 | export type ThreadListV2ListItem = | |
| 227 | | ThreadListV2ThreadListItem | |
| 228 | | ThreadListV2PendingListItem | |
| 229 | | ThreadListV2SnoozedShelfListItem; | |
| 134 | 230 | |
| 135 | 231 | /** |
| 136 | * Splices queued tasks between the active block and the settled tail, so the | |
| 137 | * list reads active → pending → settled. Queued work sits below the live | |
| 138 | * threads because nothing can happen to it until its environment returns: | |
| 139 | * it is waiting, not asking. Shared by the compact Home list and the iPad | |
| 140 | * sidebar so both order and label the sections identically. | |
| 232 | * Builds the shared mobile order: active → pending → snoozed shelf → settled. | |
| 233 | * Pending tasks are waiting rather than asking, and parked work remains | |
| 234 | * reachable without competing with either the inbox or settled history. | |
| 141 | 235 | */ |
| 142 | 236 | export function buildThreadListV2ListItems(input: { |
| 143 | 237 | readonly items: ReadonlyArray<ThreadListV2Item>; |
| 144 | 238 | readonly pendingTasks: ReadonlyArray<PendingNewTask>; |
| 239 | readonly snoozedCount?: number; | |
| 240 | readonly snoozedShelfExpanded?: boolean; | |
| 241 | readonly snoozedShelfHeaderIndex?: number | null; | |
| 242 | readonly snoozeLabelNow?: string; | |
| 145 | 243 | }): ThreadListV2ListItem[] { |
| 146 | 244 | const threadItems = input.items.map( |
| 147 | 245 | (item): ThreadListV2ListItem => ({ |
| 148 | 246 | type: "v2-thread", |
| 149 | 247 | key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, |
| 150 | 248 | item, |
| 249 | snoozeWakeLabelText: | |
| 250 | item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined | |
| 251 | ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow }) | |
| 252 | : undefined, | |
| 151 | 253 | }), |
| 152 | 254 | ); |
| 153 | if (input.pendingTasks.length === 0) return threadItems; | |
| 154 | ||
| 155 | 255 | const pendingItems = input.pendingTasks.map( |
| 156 | 256 | (pendingTask, index): ThreadListV2ListItem => ({ |
| 157 | 257 | type: "v2-pending", |
| ... | ... | @@ -160,8 +260,23 @@ export function buildThreadListV2ListItems(input: { |
| 160 | 260 | showPendingDivider: index === 0, |
| 161 | 261 | }), |
| 162 | 262 | ); |
| 163 | // The settled tail begins at the row that draws the SETTLED divider; with | |
| 164 | // no settled rows the queued block simply ends the list. | |
| 263 | const snoozedCount = input.snoozedCount ?? 0; | |
| 264 | const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null; | |
| 265 | if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) { | |
| 266 | return [ | |
| 267 | ...threadItems.slice(0, snoozedShelfHeaderIndex), | |
| 268 | ...pendingItems, | |
| 269 | { | |
| 270 | type: "v2-snoozed-shelf", | |
| 271 | key: "v2-snoozed-shelf", | |
| 272 | count: snoozedCount, | |
| 273 | expanded: input.snoozedShelfExpanded === true, | |
| 274 | }, | |
| 275 | ...threadItems.slice(snoozedShelfHeaderIndex), | |
| 276 | ]; | |
| 277 | } | |
| 278 | if (pendingItems.length === 0) return threadItems; | |
| 279 | ||
| 165 | 280 | const settledStart = threadItems.findIndex( |
| 166 | 281 | (entry) => entry.type === "v2-thread" && entry.item.showSettledDivider, |
| 167 | 282 | ); |
| ... | ... | @@ -204,6 +319,11 @@ export function buildThreadListV2Items(input: { |
| 204 | 319 | second-precise, so classifying with the floored minute would hold a |
| 205 | 320 | woken thread hidden for up to a minute. Defaults to `now`. */ |
| 206 | 321 | readonly snoozeNow?: string; |
| 322 | /** Expands the snoozed shelf into rows. Collapsed is the default. */ | |
| 323 | readonly snoozedShelfExpanded?: boolean; | |
| 324 | /** The selected thread remains visible on an otherwise collapsed shelf so | |
| 325 | a split-view detail can never lose its navigation row. */ | |
| 326 | readonly selectedThreadKey?: string | null; | |
| 207 | 327 | }): ThreadListV2Layout { |
| 208 | 328 | const now = input.now ?? new Date().toISOString(); |
| 209 | 329 | const snoozeNow = input.snoozeNow ?? now; |
| ... | ... | @@ -215,7 +335,7 @@ export function buildThreadListV2Items(input: { |
| 215 | 335 | |
| 216 | 336 | const active: EnvironmentThreadShell[] = []; |
| 217 | 337 | const settled: EnvironmentThreadShell[] = []; |
| 218 | let snoozedCount = 0; | |
| 338 | const snoozed: EnvironmentThreadShell[] = []; | |
| 219 | 339 | let nextSnoozeWakeAt: string | null = null; |
| 220 | 340 | for (const thread of input.threads) { |
| 221 | 341 | // Callers pass live (unarchived) shells; settled threads are among them |
| ... | ... | @@ -244,7 +364,7 @@ export function buildThreadListV2Items(input: { |
| 244 | 364 | // wakes (or raises its hand — effectiveSnoozed refuses blocked/failed |
| 245 | 365 | // work). Snooze outranks settled classification, same as web. |
| 246 | 366 | if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { |
| 247 | snoozedCount += 1; | |
| 367 | snoozed.push(thread); | |
| 248 | 368 | if ( |
| 249 | 369 | thread.snoozedUntil != null && |
| 250 | 370 | (nextSnoozeWakeAt === null || |
| ... | ... | @@ -265,6 +385,17 @@ export function buildThreadListV2Items(input: { |
| 265 | 385 | } |
| 266 | 386 | |
| 267 | 387 | const orderedActive = sortThreadsForListV2(active); |
| 388 | const orderedSnoozed = [...snoozed].sort( | |
| 389 | (left, right) => | |
| 390 | parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""), | |
| 391 | ); | |
| 392 | const selectedThreadKey = input.selectedThreadKey ?? null; | |
| 393 | const visibleSnoozed = | |
| 394 | input.snoozedShelfExpanded === true | |
| 395 | ? orderedSnoozed | |
| 396 | : orderedSnoozed.filter( | |
| 397 | (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, | |
| 398 | ); | |
| 268 | 399 | const orderedSettled = [...settled].sort( |
| 269 | 400 | (left, right) => |
| 270 | 401 | firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - |
| ... | ... | @@ -276,13 +407,30 @@ export function buildThreadListV2Items(input: { |
| 276 | 407 | |
| 277 | 408 | const items: ThreadListV2Item[] = []; |
| 278 | 409 | for (const thread of orderedActive) { |
| 279 | items.push({ thread, variant: "card", showSettledDivider: false, isLast: false }); | |
| 410 | items.push({ | |
| 411 | thread, | |
| 412 | variant: "card", | |
| 413 | showSettledDivider: false, | |
| 414 | snoozed: false, | |
| 415 | isLast: false, | |
| 416 | }); | |
| 417 | } | |
| 418 | const snoozedShelfHeaderIndex = orderedSnoozed.length > 0 ? items.length : null; | |
| 419 | for (const thread of visibleSnoozed) { | |
| 420 | items.push({ | |
| 421 | thread, | |
| 422 | variant: "slim", | |
| 423 | showSettledDivider: false, | |
| 424 | snoozed: true, | |
| 425 | isLast: false, | |
| 426 | }); | |
| 280 | 427 | } |
| 281 | 428 | for (const [index, thread] of visibleSettled.entries()) { |
| 282 | 429 | items.push({ |
| 283 | 430 | thread, |
| 284 | 431 | variant: "slim", |
| 285 | 432 | showSettledDivider: index === 0, |
| 433 | snoozed: false, | |
| 286 | 434 | isLast: false, |
| 287 | 435 | }); |
| 288 | 436 | } |
| ... | ... | @@ -293,7 +441,8 @@ export function buildThreadListV2Items(input: { |
| 293 | 441 | return { |
| 294 | 442 | items, |
| 295 | 443 | hiddenSettledCount: orderedSettled.length - visibleSettled.length, |
| 296 | snoozedCount, | |
| 444 | snoozedCount: orderedSnoozed.length, | |
| 445 | snoozedShelfHeaderIndex, | |
| 297 | 446 | nextSnoozeWakeAt, |
| 298 | 447 | }; |
| 299 | 448 | } |
apps/web/src/components/Sidebar.snooze.test.ts+1-16| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { resolveSnoozePresets, snoozeWakeDescription, snoozeWakeLabel } from "./Sidebar.snooze"; | |
| 3 | import { resolveSnoozePresets, snoozeWakeDescription } from "./Sidebar.snooze"; | |
| 4 | 4 | |
| 5 | 5 | // Local-time constructor so preset math is timezone-stable in tests. |
| 6 | 6 | function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date { |
| ... | ... | @@ -64,21 +64,6 @@ describe("resolveSnoozePresets", () => { |
| 64 | 64 | }); |
| 65 | 65 | }); |
| 66 | 66 | |
| 67 | describe("snoozeWakeLabel", () => { | |
| 68 | const now = localDate(2026, 4, 8, 10); | |
| 69 | ||
| 70 | it("formats minutes, hours, and days, rounding up", () => { | |
| 71 | expect(snoozeWakeLabel(new Date(now.getTime() + 30 * 60_000).toISOString(), now)).toBe("30m"); | |
| 72 | expect(snoozeWakeLabel(new Date(now.getTime() + 90 * 60_000).toISOString(), now)).toBe("2h"); | |
| 73 | expect(snoozeWakeLabel(new Date(now.getTime() + 26 * 3_600_000).toISOString(), now)).toBe("2d"); | |
| 74 | }); | |
| 75 | ||
| 76 | it("reports now for past and malformed wake times", () => { | |
| 77 | expect(snoozeWakeLabel(new Date(now.getTime() - 1000).toISOString(), now)).toBe("now"); | |
| 78 | expect(snoozeWakeLabel("not-a-date", now)).toBe("now"); | |
| 79 | }); | |
| 80 | }); | |
| 81 | ||
| 82 | 67 | describe("snoozeWakeDescription", () => { |
| 83 | 68 | const now = localDate(2026, 4, 8, 10); |
| 84 | 69 |
apps/web/src/components/Sidebar.snooze.ts+7-105| ... | ... | @@ -1,111 +1,13 @@ |
| 1 | /** | |
| 2 | * Snooze preset resolution for the sidebar snooze menu. Pure functions so | |
| 3 | * the preset math (evening/tomorrow/next-week boundaries) is unit-testable | |
| 4 | * without a DOM. | |
| 5 | * | |
| 6 | * Presets deliberately skew short: agent-thread rhythms are hours (a CI | |
| 7 | * run, a teammate review, the next work session), not days. | |
| 8 | */ | |
| 1 | import { snoozeWakeLabel } from "@t3tools/client-runtime/state/thread-settled"; | |
| 9 | 2 | import { parseTimestampDate } from "../timestampFormat"; |
| 10 | 3 | |
| 11 | type SnoozePresetId = "hour" | "evening" | "tomorrow" | "next-week"; | |
| 12 | ||
| 13 | export interface SnoozePreset { | |
| 14 | readonly id: SnoozePresetId; | |
| 15 | readonly label: string; | |
| 16 | /** Menu-row time column. Complements the label instead of repeating it: | |
| 17 | "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". */ | |
| 18 | readonly whenLabel: string; | |
| 19 | /** ISO wake time. */ | |
| 20 | readonly snoozedUntil: string; | |
| 21 | } | |
| 22 | ||
| 23 | function timeOfDayLabel(date: Date): string { | |
| 24 | return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); | |
| 25 | } | |
| 26 | ||
| 27 | const EVENING_HOUR = 18; | |
| 28 | const MORNING_HOUR = 9; | |
| 29 | const HOUR_MS = 60 * 60 * 1_000; | |
| 30 | const DAY_MS = 24 * HOUR_MS; | |
| 31 | ||
| 32 | function atHour(base: Date, hour: number): Date { | |
| 33 | const next = new Date(base); | |
| 34 | next.setHours(hour, 0, 0, 0); | |
| 35 | return next; | |
| 36 | } | |
| 37 | ||
| 38 | // Calendar-day advance instead of adding DAY_MS: fixed millisecond offsets | |
| 39 | // land on the wrong local day across DST transitions (a spring-forward day | |
| 40 | // is 23 hours, so 23:30 + 24h skips the whole next day). | |
| 41 | function addDays(base: Date, days: number): Date { | |
| 42 | const next = new Date(base); | |
| 43 | next.setDate(next.getDate() + days); | |
| 44 | return next; | |
| 45 | } | |
| 4 | export { | |
| 5 | resolveSnoozePresets, | |
| 6 | type SnoozePreset, | |
| 7 | } from "@t3tools/client-runtime/state/thread-settled"; | |
| 8 | export { snoozeWakeLabel }; | |
| 46 | 9 | |
| 47 | /** | |
| 48 | * Presets for "snooze until", computed against local time. "This evening" | |
| 49 | * only appears while it is still meaningfully before evening; after that | |
| 50 | * the list starts at "Tomorrow". | |
| 51 | */ | |
| 52 | export function resolveSnoozePresets(now: Date): ReadonlyArray<SnoozePreset> { | |
| 53 | const inAnHour = new Date(now.getTime() + HOUR_MS); | |
| 54 | const presets: SnoozePreset[] = [ | |
| 55 | { | |
| 56 | id: "hour", | |
| 57 | label: "In 1 hour", | |
| 58 | whenLabel: timeOfDayLabel(inAnHour), | |
| 59 | snoozedUntil: inAnHour.toISOString(), | |
| 60 | }, | |
| 61 | ]; | |
| 62 | ||
| 63 | const evening = atHour(now, EVENING_HOUR); | |
| 64 | // Suppress the evening preset once it is within an hour (or past): it | |
| 65 | // would duplicate "In 1 hour" or point at the past. | |
| 66 | if (evening.getTime() - now.getTime() > HOUR_MS) { | |
| 67 | presets.push({ | |
| 68 | id: "evening", | |
| 69 | label: "This evening", | |
| 70 | whenLabel: timeOfDayLabel(evening), | |
| 71 | snoozedUntil: evening.toISOString(), | |
| 72 | }); | |
| 73 | } | |
| 74 | ||
| 75 | const tomorrow = atHour(addDays(now, 1), MORNING_HOUR); | |
| 76 | presets.push({ | |
| 77 | id: "tomorrow", | |
| 78 | label: "Tomorrow", | |
| 79 | whenLabel: timeOfDayLabel(tomorrow), | |
| 80 | snoozedUntil: tomorrow.toISOString(), | |
| 81 | }); | |
| 82 | ||
| 83 | // Next Monday 9:00 (a week out when today is Monday). | |
| 84 | const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; | |
| 85 | const nextWeek = atHour(addDays(now, daysUntilMonday), MORNING_HOUR); | |
| 86 | presets.push({ | |
| 87 | id: "next-week", | |
| 88 | label: "Next week", | |
| 89 | whenLabel: `${nextWeek.toLocaleDateString(undefined, { weekday: "short" })} ${timeOfDayLabel(nextWeek)}`, | |
| 90 | snoozedUntil: nextWeek.toISOString(), | |
| 91 | }); | |
| 92 | ||
| 93 | return presets; | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * Compact "wakes in" label for snoozed rows: "2h", "18h", "3d". Minutes | |
| 98 | * round up so a snooze never reads "0m" while still hidden. | |
| 99 | */ | |
| 100 | export function snoozeWakeLabel(snoozedUntil: string, now: Date): string { | |
| 101 | const wake = parseTimestampDate(snoozedUntil); | |
| 102 | if (wake === null) return "now"; | |
| 103 | const remainingMs = wake.getTime() - now.getTime(); | |
| 104 | if (remainingMs <= 0) return "now"; | |
| 105 | if (remainingMs < HOUR_MS) return `${Math.max(1, Math.ceil(remainingMs / 60_000))}m`; | |
| 106 | if (remainingMs < DAY_MS) return `${Math.ceil(remainingMs / HOUR_MS)}h`; | |
| 107 | return `${Math.ceil(remainingMs / DAY_MS)}d`; | |
| 108 | } | |
| 10 | const DAY_MS = 24 * 60 * 60 * 1_000; | |
| 109 | 11 | |
| 110 | 12 | /** |
| 111 | 13 | * Human wake time for menus and toasts: "tomorrow 9:00", "Mon 9:00", |
apps/web/src/components/SidebarV2.tsx+3-1| ... | ... | @@ -2611,7 +2611,9 @@ export default function SidebarV2() { |
| 2611 | 2611 | } |
| 2612 | 2612 | snoozeWakeLabelText={ |
| 2613 | 2613 | section === "snoozed" && thread.snoozedUntil != null |
| 2614 | ? snoozeWakeLabel(thread.snoozedUntil, new Date()) | |
| 2614 | ? snoozeWakeLabel(thread.snoozedUntil, { | |
| 2615 | now: new Date().toISOString(), | |
| 2616 | }) | |
| 2615 | 2617 | : null |
| 2616 | 2618 | } |
| 2617 | 2619 | // All sections: a woken thread can classify straight |
packages/client-runtime/src/state/threadSettled.ts+98| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. | |
| 1 | 2 | import type { OrchestrationThreadShell } from "@t3tools/contracts"; |
| 2 | 3 | |
| 3 | 4 | export type ChangeRequestStateLike = "open" | "closed" | "merged"; |
| ... | ... | @@ -272,3 +273,100 @@ export function effectiveSettled( |
| 272 | 273 | Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS |
| 273 | 274 | ); |
| 274 | 275 | } |
| 276 | ||
| 277 | const HOUR_MS = 60 * 60 * 1_000; | |
| 278 | const EVENING_HOUR = 18; | |
| 279 | const MORNING_HOUR = 9; | |
| 280 | ||
| 281 | export type SnoozePresetId = "hour" | "evening" | "tomorrow" | "next-week"; | |
| 282 | ||
| 283 | export interface SnoozePreset { | |
| 284 | readonly id: SnoozePresetId; | |
| 285 | readonly label: string; | |
| 286 | /** Menu-row time column. Complements the label instead of repeating it: | |
| 287 | "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". */ | |
| 288 | readonly whenLabel: string; | |
| 289 | /** ISO wake time. */ | |
| 290 | readonly snoozedUntil: string; | |
| 291 | } | |
| 292 | ||
| 293 | function snoozeTimeOfDayLabel(date: Date): string { | |
| 294 | return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); | |
| 295 | } | |
| 296 | ||
| 297 | function snoozeAtHour(base: Date, hour: number): Date { | |
| 298 | const next = new Date(base); | |
| 299 | next.setHours(hour, 0, 0, 0); | |
| 300 | return next; | |
| 301 | } | |
| 302 | ||
| 303 | // Calendar-day advance instead of adding DAY_MS: fixed millisecond offsets | |
| 304 | // land on the wrong local day across DST transitions (a spring-forward day | |
| 305 | // is 23 hours, so 23:30 + 24h skips the whole next day). | |
| 306 | function addSnoozeDays(base: Date, days: number): Date { | |
| 307 | const next = new Date(base); | |
| 308 | next.setDate(next.getDate() + days); | |
| 309 | return next; | |
| 310 | } | |
| 311 | ||
| 312 | /** | |
| 313 | * Shared "snooze until" choices for every client. "This evening" only | |
| 314 | * appears while it is meaningfully before evening; after that the list | |
| 315 | * starts at "Tomorrow". | |
| 316 | */ | |
| 317 | export function resolveSnoozePresets(now: Date): ReadonlyArray<SnoozePreset> { | |
| 318 | const inAnHour = new Date(now.getTime() + HOUR_MS); | |
| 319 | const presets: SnoozePreset[] = [ | |
| 320 | { | |
| 321 | id: "hour", | |
| 322 | label: "In 1 hour", | |
| 323 | whenLabel: snoozeTimeOfDayLabel(inAnHour), | |
| 324 | snoozedUntil: inAnHour.toISOString(), | |
| 325 | }, | |
| 326 | ]; | |
| 327 | ||
| 328 | const evening = snoozeAtHour(now, EVENING_HOUR); | |
| 329 | if (evening.getTime() - now.getTime() > HOUR_MS) { | |
| 330 | presets.push({ | |
| 331 | id: "evening", | |
| 332 | label: "This evening", | |
| 333 | whenLabel: snoozeTimeOfDayLabel(evening), | |
| 334 | snoozedUntil: evening.toISOString(), | |
| 335 | }); | |
| 336 | } | |
| 337 | ||
| 338 | const tomorrow = snoozeAtHour(addSnoozeDays(now, 1), MORNING_HOUR); | |
| 339 | presets.push({ | |
| 340 | id: "tomorrow", | |
| 341 | label: "Tomorrow", | |
| 342 | whenLabel: snoozeTimeOfDayLabel(tomorrow), | |
| 343 | snoozedUntil: tomorrow.toISOString(), | |
| 344 | }); | |
| 345 | ||
| 346 | const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; | |
| 347 | const nextWeek = snoozeAtHour(addSnoozeDays(now, daysUntilMonday), MORNING_HOUR); | |
| 348 | presets.push({ | |
| 349 | id: "next-week", | |
| 350 | label: "Next week", | |
| 351 | whenLabel: `${nextWeek.toLocaleDateString(undefined, { weekday: "short" })} ${snoozeTimeOfDayLabel(nextWeek)}`, | |
| 352 | snoozedUntil: nextWeek.toISOString(), | |
| 353 | }); | |
| 354 | ||
| 355 | return presets; | |
| 356 | } | |
| 357 | ||
| 358 | /** | |
| 359 | * Compact "wakes in" label for snoozed rows: "2h", "18h", "3d". Minutes | |
| 360 | * round up so a snooze never reads "0m" while still hidden. Shared by web | |
| 361 | * and mobile so the same wake time never reads differently per client. | |
| 362 | */ | |
| 363 | export function snoozeWakeLabel(snoozedUntil: string, options: { readonly now: string }): string { | |
| 364 | const wakeMs = Date.parse(snoozedUntil); | |
| 365 | const nowMs = Date.parse(options.now); | |
| 366 | if (Number.isNaN(wakeMs) || Number.isNaN(nowMs)) return "now"; | |
| 367 | const remainingMs = wakeMs - nowMs; | |
| 368 | if (remainingMs <= 0) return "now"; | |
| 369 | if (remainingMs < HOUR_MS) return `${Math.max(1, Math.ceil(remainingMs / 60_000))}m`; | |
| 370 | if (remainingMs < DAY_MS) return `${Math.ceil(remainingMs / HOUR_MS)}h`; | |
| 371 | return `${Math.ceil(remainingMs / DAY_MS)}d`; | |
| 372 | } |
packages/client-runtime/src/state/threadSnoozed.test.ts+57| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | // @effect-diagnostics globalDate:off -- Tests exercise local calendar snooze boundaries. | |
| 1 | 2 | import { ThreadId } from "@t3tools/contracts"; |
| 2 | 3 | import { TurnId } from "@t3tools/contracts"; |
| 3 | 4 | import { describe, expect, it } from "vite-plus/test"; |
| ... | ... | @@ -5,6 +6,8 @@ import { describe, expect, it } from "vite-plus/test"; |
| 5 | 6 | import { |
| 6 | 7 | canSnooze, |
| 7 | 8 | effectiveSnoozed, |
| 9 | resolveSnoozePresets, | |
| 10 | snoozeWakeLabel, | |
| 8 | 11 | threadRaisedHandWhileSnoozed, |
| 9 | 12 | threadWokeAt, |
| 10 | 13 | type ThreadSnoozeShell, |
| ... | ... | @@ -15,6 +18,10 @@ const SNOOZED_AT = "2026-04-10T09:00:00.000Z"; |
| 15 | 18 | const FUTURE_WAKE = "2026-04-11T09:00:00.000Z"; |
| 16 | 19 | const PAST_WAKE = "2026-04-10T10:00:00.000Z"; |
| 17 | 20 | |
| 21 | function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date { | |
| 22 | return new Date(year, month - 1, day, hour, minute, 0, 0); | |
| 23 | } | |
| 24 | ||
| 18 | 25 | function makeShell(input: { |
| 19 | 26 | readonly snoozedUntil?: string | null; |
| 20 | 27 | readonly snoozedAt?: string | null; |
| ... | ... | @@ -235,3 +242,53 @@ describe("threadWokeAt", () => { |
| 235 | 242 | ).toBe("2026-04-10T09:30:00.000Z"); |
| 236 | 243 | }); |
| 237 | 244 | }); |
| 245 | ||
| 246 | describe("snoozeWakeLabel", () => { | |
| 247 | const now = "2026-06-02T00:00:00.000Z"; | |
| 248 | ||
| 249 | it("formats remaining time coarsely, rounding up", () => { | |
| 250 | expect(snoozeWakeLabel("2026-06-02T00:30:00.000Z", { now })).toBe("30m"); | |
| 251 | expect(snoozeWakeLabel("2026-06-02T01:30:00.000Z", { now })).toBe("2h"); | |
| 252 | expect(snoozeWakeLabel("2026-06-03T02:00:00.000Z", { now })).toBe("2d"); | |
| 253 | }); | |
| 254 | ||
| 255 | it("never reads zero or negative while still snoozed", () => { | |
| 256 | expect(snoozeWakeLabel("2026-06-02T00:00:30.000Z", { now })).toBe("1m"); | |
| 257 | expect(snoozeWakeLabel("2026-06-01T23:59:59.000Z", { now })).toBe("now"); | |
| 258 | expect(snoozeWakeLabel("not-a-date", { now })).toBe("now"); | |
| 259 | expect(snoozeWakeLabel("2026-06-02T09:00:00.000Z", { now: "bad" })).toBe("now"); | |
| 260 | }); | |
| 261 | }); | |
| 262 | ||
| 263 | describe("resolveSnoozePresets", () => { | |
| 264 | it("offers the shared desktop and mobile choices", () => { | |
| 265 | const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10)); | |
| 266 | expect(presets.map((preset) => preset.id)).toEqual([ | |
| 267 | "hour", | |
| 268 | "evening", | |
| 269 | "tomorrow", | |
| 270 | "next-week", | |
| 271 | ]); | |
| 272 | expect(presets.find((preset) => preset.id === "evening")?.label).toBe("This evening"); | |
| 273 | expect( | |
| 274 | new Date(presets.find((preset) => preset.id === "tomorrow")!.snoozedUntil).getHours(), | |
| 275 | ).toBe(9); | |
| 276 | }); | |
| 277 | ||
| 278 | it("drops the evening choice once evening is near or past", () => { | |
| 279 | expect(resolveSnoozePresets(localDate(2026, 4, 8, 17, 30)).map((preset) => preset.id)).toEqual([ | |
| 280 | "hour", | |
| 281 | "tomorrow", | |
| 282 | "next-week", | |
| 283 | ]); | |
| 284 | }); | |
| 285 | ||
| 286 | it("puts next week on the following Monday", () => { | |
| 287 | const nextWeek = new Date( | |
| 288 | resolveSnoozePresets(localDate(2026, 4, 6, 10)).find((preset) => preset.id === "next-week")! | |
| 289 | .snoozedUntil, | |
| 290 | ); | |
| 291 | expect(nextWeek.getDay()).toBe(1); | |
| 292 | expect(nextWeek.getDate()).toBe(13); | |
| 293 | }); | |
| 294 | }); |