| 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,6 +22,7 @@ import { |
| 22 | IconChevronUp, | 22 | IconChevronUp, |
| 23 | IconCircleCheck, | 23 | IconCircleCheck, |
| 24 | IconCircleXFilled, | 24 | IconCircleXFilled, |
| 25 | IconClock, | ||
| 25 | IconCopy, | 26 | IconCopy, |
| 26 | IconDeviceDesktop, | 27 | IconDeviceDesktop, |
| 27 | IconDots, | 28 | IconDots, |
| ... | @@ -96,6 +97,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { | ... | @@ -96,6 +97,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 96 | camera: IconCamera, | 97 | camera: IconCamera, |
| 97 | checkmark: IconCheck, | 98 | checkmark: IconCheck, |
| 98 | "checkmark.circle": IconCircleCheck, | 99 | "checkmark.circle": IconCircleCheck, |
| 100 | clock: IconClock, | ||
| 99 | "chevron.down": IconChevronDown, | 101 | "chevron.down": IconChevronDown, |
| 100 | "chevron.left": IconChevronLeft, | 102 | "chevron.left": IconChevronLeft, |
| 101 | "chevron.left.forwardslash.chevron.right": IconCode, | 103 | "chevron.left.forwardslash.chevron.right": IconCode, |
apps/mobile/src/features/home/HomeRouteScreen.tsx+10-2| ... | @@ -36,8 +36,14 @@ export function HomeRouteScreen() { | ... | @@ -36,8 +36,14 @@ export function HomeRouteScreen() { |
| 36 | void checkForAppUpdateOnLaunch(); | 36 | void checkForAppUpdateOnLaunch(); |
| 37 | }, []); | 37 | }, []); |
| 38 | 38 | ||
| 39 | const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = | 39 | const { |
| 40 | useThreadListActions(); | 40 | archiveThread, |
| 41 | confirmDeleteThread, | ||
| 42 | settleThread, | ||
| 43 | snoozeThread, | ||
| 44 | unsnoozeThread, | ||
| 45 | unsettleThread, | ||
| 46 | } = useThreadListActions(); | ||
| 41 | const pendingTasks = usePendingNewTasks(); | 47 | const pendingTasks = usePendingNewTasks(); |
| 42 | const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); | 48 | const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); |
| 43 | const environments = useMemo(() => { | 49 | const environments = useMemo(() => { |
| ... | @@ -146,6 +152,8 @@ export function HomeRouteScreen() { | ... | @@ -146,6 +152,8 @@ export function HomeRouteScreen() { |
| 146 | onArchiveThread={archiveThread} | 152 | onArchiveThread={archiveThread} |
| 147 | onDeleteThread={confirmDeleteThread} | 153 | onDeleteThread={confirmDeleteThread} |
| 148 | onSettleThread={settleThread} | 154 | onSettleThread={settleThread} |
| 155 | onSnoozeThread={snoozeThread} | ||
| 156 | onUnsnoozeThread={unsnoozeThread} | ||
| 149 | onUnsettleThread={unsettleThread} | 157 | onUnsettleThread={unsettleThread} |
| 150 | onEnvironmentChange={setSelectedEnvironmentId} | 158 | onEnvironmentChange={setSelectedEnvironmentId} |
| 151 | onProjectChange={setSelectedProjectKey} | 159 | onProjectChange={setSelectedProjectKey} |
apps/mobile/src/features/home/HomeScreen.tsx+65-27| ... | @@ -41,7 +41,11 @@ import { | ... | @@ -41,7 +41,11 @@ import { |
| 41 | ThreadListRow, | 41 | ThreadListRow, |
| 42 | ThreadListShowMoreRow, | 42 | ThreadListShowMoreRow, |
| 43 | } from "../threads/thread-list-items"; | 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 | import { | 49 | import { |
| 46 | buildThreadListV2Items, | 50 | buildThreadListV2Items, |
| 47 | buildThreadListV2ListItems, | 51 | buildThreadListV2ListItems, |
| ... | @@ -100,6 +104,11 @@ interface HomeScreenProps { | ... | @@ -100,6 +104,11 @@ interface HomeScreenProps { |
| 100 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; | 104 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; |
| 101 | /** Resolves true iff the settle was dispatched and succeeded. */ | 105 | /** Resolves true iff the settle was dispatched and succeeded. */ |
| 102 | readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | 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 | readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; | 112 | readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; |
| 104 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; | 113 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; |
| 105 | readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; | 114 | readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; |
| ... | @@ -494,6 +503,18 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -494,6 +503,18 @@ export function HomeScreen(props: HomeScreenProps) { |
| 494 | }, | 503 | }, |
| 495 | [props.onSettleThread], | 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 | const handleDeleteThread = props.onDeleteThread; | 518 | const handleDeleteThread = props.onDeleteThread; |
| 498 | const handleUnsettleThread = props.onUnsettleThread; | 519 | const handleUnsettleThread = props.onUnsettleThread; |
| 499 | // The settled tail renders in pages; expansion resets when the filter | 520 | // The settled tail renders in pages; expansion resets when the filter |
| ... | @@ -511,6 +532,8 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -511,6 +532,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 511 | () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), | 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 | // now is quantized to the minute and ticks so the inactivity auto-settle | 537 | // now is quantized to the minute and ticks so the inactivity auto-settle |
| 515 | // boundary is actually crossed while the app stays open (mirrors web); | 538 | // boundary is actually crossed while the app stays open (mirrors web); |
| 516 | // without a clock dependency the partition memoizes a frozen "now". | 539 | // without a clock dependency the partition memoizes a frozen "now". |
| ... | @@ -551,7 +574,13 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -551,7 +574,13 @@ export function HomeScreen(props: HomeScreenProps) { |
| 551 | }, [serverConfigs]); | 574 | }, [serverConfigs]); |
| 552 | const threadListV2Layout = useMemo(() => { | 575 | const threadListV2Layout = useMemo(() => { |
| 553 | if (!threadListV2Enabled) | 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 | // Settled threads are live shells; archived threads keep their original | 584 | // Settled threads are live shells; archived threads keep their original |
| 556 | // "hidden from lists" meaning. | 585 | // "hidden from lists" meaning. |
| 557 | return buildThreadListV2Items({ | 586 | return buildThreadListV2Items({ |
| ... | @@ -566,11 +595,14 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -566,11 +595,14 @@ export function HomeScreen(props: HomeScreenProps) { |
| 566 | settledLimit: settledVisibleCount, | 595 | settledLimit: settledVisibleCount, |
| 567 | now: `${nowMinute}:00.000Z`, | 596 | now: `${nowMinute}:00.000Z`, |
| 568 | snoozeNow: new Date().toISOString(), | 597 | snoozeNow: new Date().toISOString(), |
| 598 | snoozedShelfExpanded, | ||
| 599 | selectedThreadKey: null, | ||
| 569 | }); | 600 | }); |
| 570 | }, [ | 601 | }, [ |
| 571 | changeRequestStateByKey, | 602 | changeRequestStateByKey, |
| 572 | nowMinute, | 603 | nowMinute, |
| 573 | snoozeWakeTick, | 604 | snoozeWakeTick, |
| 605 | snoozedShelfExpanded, | ||
| 574 | settledVisibleCount, | 606 | settledVisibleCount, |
| 575 | settlementEnvironmentIds, | 607 | settlementEnvironmentIds, |
| 576 | snoozeEnvironmentIds, | 608 | snoozeEnvironmentIds, |
| ... | @@ -620,8 +652,12 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -620,8 +652,12 @@ export function HomeScreen(props: HomeScreenProps) { |
| 620 | buildThreadListV2ListItems({ | 652 | buildThreadListV2ListItems({ |
| 621 | items: threadListV2Layout.items, | 653 | items: threadListV2Layout.items, |
| 622 | pendingTasks: v2PendingTasks, | 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 | const renderV2Item = useCallback( | 663 | const renderV2Item = useCallback( |
| ... | @@ -648,12 +684,24 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -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 | const thread = item.item.thread; | 696 | const thread = item.item.thread; |
| 652 | return ( | 697 | return ( |
| 653 | <ThreadListV2Row | 698 | <ThreadListV2Row |
| 654 | thread={thread} | 699 | thread={thread} |
| 655 | variant={item.item.variant} | 700 | variant={item.item.variant} |
| 656 | showSettledDivider={item.item.showSettledDivider} | 701 | showSettledDivider={item.item.showSettledDivider} |
| 702 | snoozed={item.item.snoozed} | ||
| 703 | snoozePresetMinute={nowMinute} | ||
| 704 | snoozeWakeLabelText={item.snoozeWakeLabelText} | ||
| 657 | project={ | 705 | project={ |
| 658 | projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null | 706 | projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null |
| 659 | } | 707 | } |
| ... | @@ -686,6 +734,9 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -686,6 +734,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 686 | onArchiveThread={props.onArchiveThread} | 734 | onArchiveThread={props.onArchiveThread} |
| 687 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} | 735 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} |
| 688 | onSettleThread={handleSettleThread} | 736 | onSettleThread={handleSettleThread} |
| 737 | snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} | ||
| 738 | onSnoozeThread={handleSnoozeThread} | ||
| 739 | onUnsnoozeThread={handleUnsnoozeThread} | ||
| 689 | onUnsettleThread={handleUnsettleThread} | 740 | onUnsettleThread={handleUnsettleThread} |
| 690 | onChangeRequestState={handleChangeRequestState} | 741 | onChangeRequestState={handleChangeRequestState} |
| 691 | projectCwd={ | 742 | projectCwd={ |
| ... | @@ -700,6 +751,8 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -700,6 +751,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 700 | handleChangeRequestState, | 751 | handleChangeRequestState, |
| 701 | handleDeleteThread, | 752 | handleDeleteThread, |
| 702 | handleSettleThread, | 753 | handleSettleThread, |
| 754 | handleSnoozeThread, | ||
| 755 | handleUnsnoozeThread, | ||
| 703 | handleSwipeableClose, | 756 | handleSwipeableClose, |
| 704 | handleSwipeableWillOpen, | 757 | handleSwipeableWillOpen, |
| 705 | handleUnsettleThread, | 758 | handleUnsettleThread, |
| ... | @@ -712,9 +765,12 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -712,9 +765,12 @@ export function HomeScreen(props: HomeScreenProps) { |
| 712 | props.savedConnectionsById, | 765 | props.savedConnectionsById, |
| 713 | serverConfigs, | 766 | serverConfigs, |
| 714 | settlementEnvironmentIds, | 767 | settlementEnvironmentIds, |
| 768 | snoozeEnvironmentIds, | ||
| 715 | threadSearchMatchByKey, | 769 | threadSearchMatchByKey, |
| 770 | toggleSnoozedShelf, | ||
| 716 | v2ProjectTitleByProjectKey, | 771 | v2ProjectTitleByProjectKey, |
| 717 | props.searchQuery, | 772 | props.searchQuery, |
| 773 | nowMinute, | ||
| 718 | ], | 774 | ], |
| 719 | ); | 775 | ); |
| 720 | const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []); | 776 | const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []); |
| ... | @@ -730,6 +786,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -730,6 +786,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 730 | serverConfigs, | 786 | serverConfigs, |
| 731 | savedConnectionsById: props.savedConnectionsById, | 787 | savedConnectionsById: props.savedConnectionsById, |
| 732 | searchQuery: props.searchQuery, | 788 | searchQuery: props.searchQuery, |
| 789 | snoozePresetMinute: nowMinute, | ||
| 733 | threadSearchMatchByKey, | 790 | threadSearchMatchByKey, |
| 734 | }), | 791 | }), |
| 735 | [ | 792 | [ |
| ... | @@ -738,6 +795,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -738,6 +795,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 738 | props.searchQuery, | 795 | props.searchQuery, |
| 739 | props.savedConnectionsById, | 796 | props.savedConnectionsById, |
| 740 | serverConfigs, | 797 | serverConfigs, |
| 798 | nowMinute, | ||
| 741 | threadSearchMatchByKey, | 799 | threadSearchMatchByKey, |
| 742 | v2ProjectTitleByProjectKey, | 800 | v2ProjectTitleByProjectKey, |
| 743 | ], | 801 | ], |
| ... | @@ -953,31 +1011,11 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -953,31 +1011,11 @@ export function HomeScreen(props: HomeScreenProps) { |
| 953 | ) : null; | 1011 | ) : null; |
| 954 | // Self-contained: v1's listEmpty keys off projectGroups, which ignores the | 1012 | // Self-contained: v1's listEmpty keys off projectGroups, which ignores the |
| 955 | // v2 project scope, so it can be null (results elsewhere) while this list | 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 | 1014 | // is empty. Snoozed threads need no special empty state: their shelf header |
| 957 | // fact when a query is active. Snoozed threads outrank the rest: "No | 1015 | // is a list row even while collapsed. |
| 958 | // threads yet" over an inbox that is merely all-snoozed reads as data | ||
| 959 | // loss. | ||
| 960 | const v2SnoozedCount = threadListV2Layout.snoozedCount; | ||
| 961 | const v2ListEmpty = | 1016 | const v2ListEmpty = |
| 962 | hasSearchQuery && threadSearch.isPending && v2SnoozedCount === 0 ? null : hasSearchQuery ? ( | 1017 | hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( |
| 963 | v2SnoozedCount > 0 ? ( | 1018 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> |
| 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 | /> | ||
| 981 | ) : v2ScopedProjectGroup !== null ? ( | 1019 | ) : v2ScopedProjectGroup !== null ? ( |
| 982 | <EmptyState | 1020 | <EmptyState |
| 983 | title={`No threads in ${v2ScopedProjectGroup.title}`} | 1021 | title={`No threads in ${v2ScopedProjectGroup.title}`} |
apps/mobile/src/features/home/thread-swipe-actions.tsx+184-97| ... | @@ -1,4 +1,6 @@ | ... | @@ -1,4 +1,6 @@ |
| 1 | import { SymbolView } from "../../components/AppSymbol"; | 1 | import { SymbolView } from "../../components/AppSymbol"; |
| 2 | import { ControlPillMenu } from "../../components/ControlPill"; | ||
| 3 | import type { MenuAction } from "@react-native-menu/menu"; | ||
| 2 | import * as Haptics from "expo-haptics"; | 4 | import * as Haptics from "expo-haptics"; |
| 3 | import { | 5 | import { |
| 4 | createContext, | 6 | createContext, |
| ... | @@ -47,13 +49,67 @@ export const THREAD_SWIPE_SPRING = { | ... | @@ -47,13 +49,67 @@ export const THREAD_SWIPE_SPRING = { |
| 47 | stiffness: 330, | 49 | stiffness: 330, |
| 48 | }; | 50 | }; |
| 49 | 51 | ||
| 50 | interface ThreadSwipePrimaryAction { | 52 | interface ThreadSwipeAction { |
| 51 | readonly accessibilityLabel: string; | 53 | readonly accessibilityLabel: string; |
| 52 | readonly icon: ComponentProps<typeof SymbolView>["name"]; | 54 | readonly icon: ComponentProps<typeof SymbolView>["name"]; |
| 53 | readonly label: string; | 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 | readonly onPress: () => void; | 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 | * Delivers the scroll gate to swipeables via context so that flipping it does | 114 | * Delivers the scroll gate to swipeables via context so that flipping it does |
| 59 | * NOT re-render whole rows: putting the flag in list extraData/renderItem deps | 115 | * NOT re-render whole rows: putting the flag in list extraData/renderItem deps |
| ... | @@ -173,17 +229,22 @@ export function ThreadSwipeable(props: { | ... | @@ -173,17 +229,22 @@ export function ThreadSwipeable(props: { |
| 173 | readonly enabled?: boolean; | 229 | readonly enabled?: boolean; |
| 174 | readonly enableTrackpadSwipe?: boolean; | 230 | readonly enableTrackpadSwipe?: boolean; |
| 175 | /** | 231 | /** |
| 176 | * What a full swipe commits: "delete" (default, v1 behavior — the Delete | 232 | * What a full swipe commits. Omitted keeps the v1 Delete behavior only when |
| 177 | * button stretches) or "primary" — the advertised primary action fires and | 233 | * the built-in Delete secondary action is in use; custom or absent |
| 178 | * its button stretches instead. A full swipe must always match the action | 234 | * secondary actions default to the advertised primary action. |
| 179 | * the stretching button advertises. | ||
| 180 | */ | 235 | */ |
| 181 | readonly fullSwipeAction?: "delete" | "primary"; | 236 | readonly fullSwipeAction?: "delete" | "primary"; |
| 182 | readonly fullSwipeWidth: number; | 237 | readonly fullSwipeWidth: number; |
| 183 | readonly onDelete: () => void; | 238 | readonly onDelete: () => void; |
| 184 | readonly onSwipeableClose?: (methods: SwipeableMethods) => void; | 239 | readonly onSwipeableClose?: (methods: SwipeableMethods) => void; |
| 185 | readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void; | 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 | * Identity of the content being wrapped. When a recycled list reuses this | 249 | * Identity of the content being wrapped. When a recycled list reuses this |
| 189 | * component for a different item, the swipeable snaps back to closed so an | 250 | * component for a different item, the swipeable snaps back to closed so an |
| ... | @@ -197,7 +258,11 @@ export function ThreadSwipeable(props: { | ... | @@ -197,7 +258,11 @@ export function ThreadSwipeable(props: { |
| 197 | }) { | 258 | }) { |
| 198 | const swipeableRef = useRef<SwipeableMethods | null>(null); | 259 | const swipeableRef = useRef<SwipeableMethods | null>(null); |
| 199 | const fullSwipeArmedRef = useRef(false); | 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 | const close = useCallback(() => swipeableRef.current?.close(), []); | 266 | const close = useCallback(() => swipeableRef.current?.close(), []); |
| 202 | const gateEnabled = use(SwipeableScrollGateContext); | 267 | const gateEnabled = use(SwipeableScrollGateContext); |
| 203 | const resetKey = props.resetKey; | 268 | const resetKey = props.resetKey; |
| ... | @@ -251,7 +316,7 @@ export function ThreadSwipeable(props: { | ... | @@ -251,7 +316,7 @@ export function ThreadSwipeable(props: { |
| 251 | if (fullSwipeArmedRef.current) { | 316 | if (fullSwipeArmedRef.current) { |
| 252 | fullSwipeArmedRef.current = false; | 317 | fullSwipeArmedRef.current = false; |
| 253 | methods.close(); | 318 | methods.close(); |
| 254 | if (props.fullSwipeAction === "primary") { | 319 | if (fullSwipeAction === "primary") { |
| 255 | props.primaryAction.onPress(); | 320 | props.primaryAction.onPress(); |
| 256 | } else { | 321 | } else { |
| 257 | props.onDelete(); | 322 | props.onDelete(); |
| ... | @@ -264,9 +329,8 @@ export function ThreadSwipeable(props: { | ... | @@ -264,9 +329,8 @@ export function ThreadSwipeable(props: { |
| 264 | <ThreadSwipeActions | 329 | <ThreadSwipeActions |
| 265 | backgroundColor={props.backgroundColor} | 330 | backgroundColor={props.backgroundColor} |
| 266 | compact={props.compactActions === true} | 331 | compact={props.compactActions === true} |
| 267 | fullSwipeAction={props.fullSwipeAction ?? "delete"} | 332 | fullSwipeAction={fullSwipeAction} |
| 268 | fullSwipeThreshold={fullSwipeThreshold} | 333 | fullSwipeThreshold={fullSwipeThreshold} |
| 269 | onDelete={props.onDelete} | ||
| 270 | onFullSwipeArmedChange={handleFullSwipeArmedChange} | 334 | onFullSwipeArmedChange={handleFullSwipeArmedChange} |
| 271 | primaryAction={{ | 335 | primaryAction={{ |
| 272 | ...props.primaryAction, | 336 | ...props.primaryAction, |
| ... | @@ -275,12 +339,16 @@ export function ThreadSwipeable(props: { | ... | @@ -275,12 +339,16 @@ export function ThreadSwipeable(props: { |
| 275 | props.primaryAction.onPress(); | 339 | props.primaryAction.onPress(); |
| 276 | }, | 340 | }, |
| 277 | }} | 341 | }} |
| 278 | swipeableMethods={methods} | 342 | secondaryAction={resolveSecondaryAction({ |
| 279 | threadTitle={props.threadTitle} | 343 | close: () => methods.close(), |
| 344 | onDelete: props.onDelete, | ||
| 345 | secondaryAction: props.secondaryAction, | ||
| 346 | threadTitle: props.threadTitle, | ||
| 347 | })} | ||
| 280 | translation={translation} | 348 | translation={translation} |
| 281 | /> | 349 | /> |
| 282 | )} | 350 | )} |
| 283 | rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} | 351 | rightThreshold={actionsWidth * 0.42} |
| 284 | simultaneousWithExternalGesture={props.simultaneousWithExternalGesture} | 352 | simultaneousWithExternalGesture={props.simultaneousWithExternalGesture} |
| 285 | > | 353 | > |
| 286 | {props.children(close)} | 354 | {props.children(close)} |
| ... | @@ -290,12 +358,14 @@ export function ThreadSwipeable(props: { | ... | @@ -290,12 +358,14 @@ export function ThreadSwipeable(props: { |
| 290 | 358 | ||
| 291 | function SwipeActionButton(props: { | 359 | function SwipeActionButton(props: { |
| 292 | readonly accessibilityLabel: string; | 360 | readonly accessibilityLabel: string; |
| 361 | readonly actionsWidth: number; | ||
| 293 | readonly backgroundColor: string; | 362 | readonly backgroundColor: string; |
| 294 | readonly compact: boolean; | 363 | readonly compact: boolean; |
| 295 | readonly entryRange: readonly [number, number]; | 364 | readonly entryRange: readonly [number, number]; |
| 296 | readonly fullSwipeThreshold: number; | 365 | readonly fullSwipeThreshold: number; |
| 297 | readonly icon: ComponentProps<typeof SymbolView>["name"]; | 366 | readonly icon: ComponentProps<typeof SymbolView>["name"]; |
| 298 | readonly label: string; | 367 | readonly label: string; |
| 368 | readonly menu?: ThreadSwipeAction["menu"]; | ||
| 299 | readonly onPress: () => void; | 369 | readonly onPress: () => void; |
| 300 | readonly stretchesOnFullSwipe: boolean; | 370 | readonly stretchesOnFullSwipe: boolean; |
| 301 | readonly translation: SharedValue<number>; | 371 | readonly translation: SharedValue<number>; |
| ... | @@ -305,10 +375,10 @@ function SwipeActionButton(props: { | ... | @@ -305,10 +375,10 @@ function SwipeActionButton(props: { |
| 305 | const actionStyle = useAnimatedStyle(() => { | 375 | const actionStyle = useAnimatedStyle(() => { |
| 306 | const reveal = Math.max(-props.translation.value, 0); | 376 | const reveal = Math.max(-props.translation.value, 0); |
| 307 | const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP); | 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 | const fullSwipeProgress = interpolate( | 379 | const fullSwipeProgress = interpolate( |
| 310 | reveal, | 380 | reveal, |
| 311 | [THREAD_SWIPE_ACTIONS_WIDTH, props.fullSwipeThreshold + 20], | 381 | [props.actionsWidth, props.fullSwipeThreshold + 20], |
| 312 | [0, 1], | 382 | [0, 1], |
| 313 | Extrapolation.CLAMP, | 383 | Extrapolation.CLAMP, |
| 314 | ); | 384 | ); |
| ... | @@ -327,9 +397,7 @@ function SwipeActionButton(props: { | ... | @@ -327,9 +397,7 @@ function SwipeActionButton(props: { |
| 327 | }); | 397 | }); |
| 328 | const circleStyle = useAnimatedStyle(() => { | 398 | const circleStyle = useAnimatedStyle(() => { |
| 329 | const reveal = Math.max(-props.translation.value, 0); | 399 | const reveal = Math.max(-props.translation.value, 0); |
| 330 | const stretch = props.stretchesOnFullSwipe | 400 | const stretch = props.stretchesOnFullSwipe ? Math.max(reveal - props.actionsWidth, 0) : 0; |
| 331 | ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0) | ||
| 332 | : 0; | ||
| 333 | 401 | ||
| 334 | return { | 402 | return { |
| 335 | transform: [{ translateX: -stretch }], | 403 | transform: [{ translateX: -stretch }], |
| ... | @@ -338,9 +406,7 @@ function SwipeActionButton(props: { | ... | @@ -338,9 +406,7 @@ function SwipeActionButton(props: { |
| 338 | }); | 406 | }); |
| 339 | const iconStyle = useAnimatedStyle(() => { | 407 | const iconStyle = useAnimatedStyle(() => { |
| 340 | const reveal = Math.max(-props.translation.value, 0); | 408 | const reveal = Math.max(-props.translation.value, 0); |
| 341 | const stretch = props.stretchesOnFullSwipe | 409 | const stretch = props.stretchesOnFullSwipe ? Math.max(reveal - props.actionsWidth, 0) : 0; |
| 342 | ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0) | ||
| 343 | : 0; | ||
| 344 | const armedProgress = interpolate( | 410 | const armedProgress = interpolate( |
| 345 | reveal, | 411 | reveal, |
| 346 | [props.fullSwipeThreshold, props.fullSwipeThreshold + 20], | 412 | [props.fullSwipeThreshold, props.fullSwipeThreshold + 20], |
| ... | @@ -358,7 +424,7 @@ function SwipeActionButton(props: { | ... | @@ -358,7 +424,7 @@ function SwipeActionButton(props: { |
| 358 | } | 424 | } |
| 359 | 425 | ||
| 360 | const reveal = Math.max(-props.translation.value, 0); | 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 | return { | 428 | return { |
| 363 | opacity: interpolate( | 429 | opacity: interpolate( |
| 364 | reveal, | 430 | reveal, |
| ... | @@ -370,6 +436,63 @@ function SwipeActionButton(props: { | ... | @@ -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 | return ( | 496 | return ( |
| 374 | <Animated.View | 497 | <Animated.View |
| 375 | style={[ | 498 | style={[ |
| ... | @@ -383,60 +506,18 @@ function SwipeActionButton(props: { | ... | @@ -383,60 +506,18 @@ function SwipeActionButton(props: { |
| 383 | actionStyle, | 506 | actionStyle, |
| 384 | ]} | 507 | ]} |
| 385 | > | 508 | > |
| 386 | <Pressable | 509 | {props.menu === undefined ? ( |
| 387 | accessibilityLabel={props.accessibilityLabel} | 510 | button |
| 388 | accessibilityRole="button" | 511 | ) : ( |
| 389 | onPress={props.onPress} | 512 | <ControlPillMenu |
| 390 | style={({ pressed }) => ({ | 513 | actions={props.menu.actions} |
| 391 | alignItems: "center", | 514 | onPressAction={props.menu.onPressAction} |
| 392 | height: "100%", | 515 | title={props.menu.title} |
| 393 | justifyContent: "center", | 516 | style={{ height: "100%", width: "100%" }} |
| 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 | ]} | ||
| 434 | > | 517 | > |
| 435 | <Text className="text-3xs font-t3-medium text-foreground-muted" numberOfLines={1}> | 518 | {button} |
| 436 | {props.label} | 519 | </ControlPillMenu> |
| 437 | </Text> | 520 | )} |
| 438 | </Animated.View> | ||
| 439 | </Pressable> | ||
| 440 | </Animated.View> | 521 | </Animated.View> |
| 441 | ); | 522 | ); |
| 442 | } | 523 | } |
| ... | @@ -446,14 +527,14 @@ export function ThreadSwipeActions(props: { | ... | @@ -446,14 +527,14 @@ export function ThreadSwipeActions(props: { |
| 446 | readonly compact: boolean; | 527 | readonly compact: boolean; |
| 447 | readonly fullSwipeAction?: "delete" | "primary"; | 528 | readonly fullSwipeAction?: "delete" | "primary"; |
| 448 | readonly fullSwipeThreshold: number; | 529 | readonly fullSwipeThreshold: number; |
| 449 | readonly onDelete: () => void; | ||
| 450 | readonly onFullSwipeArmedChange: (armed: boolean) => void; | 530 | readonly onFullSwipeArmedChange: (armed: boolean) => void; |
| 451 | readonly primaryAction: ThreadSwipePrimaryAction; | 531 | readonly primaryAction: ThreadSwipeAction; |
| 452 | readonly swipeableMethods: SwipeableMethods; | 532 | readonly secondaryAction: ThreadSwipeSecondaryAction | null; |
| 453 | readonly threadTitle: string; | ||
| 454 | readonly translation: SharedValue<number>; | 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 | useAnimatedReaction( | 538 | useAnimatedReaction( |
| 458 | () => -props.translation.value >= props.fullSwipeThreshold, | 539 | () => -props.translation.value >= props.fullSwipeThreshold, |
| 459 | (armed, previous) => { | 540 | (armed, previous) => { |
| ... | @@ -470,14 +551,19 @@ export function ThreadSwipeActions(props: { | ... | @@ -470,14 +551,19 @@ export function ThreadSwipeActions(props: { |
| 470 | backgroundColor: props.backgroundColor, | 551 | backgroundColor: props.backgroundColor, |
| 471 | flexDirection: "row", | 552 | flexDirection: "row", |
| 472 | height: "100%", | 553 | height: "100%", |
| 473 | width: THREAD_SWIPE_ACTIONS_WIDTH, | 554 | width: actionsWidth, |
| 474 | }} | 555 | }} |
| 475 | > | 556 | > |
| 476 | <SwipeActionButton | 557 | <SwipeActionButton |
| 477 | accessibilityLabel={props.primaryAction.accessibilityLabel} | 558 | accessibilityLabel={props.primaryAction.accessibilityLabel} |
| 559 | actionsWidth={actionsWidth} | ||
| 478 | backgroundColor="#007aff" | 560 | backgroundColor="#007aff" |
| 479 | compact={props.compact} | 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 | fullSwipeThreshold={props.fullSwipeThreshold} | 567 | fullSwipeThreshold={props.fullSwipeThreshold} |
| 482 | icon={props.primaryAction.icon} | 568 | icon={props.primaryAction.icon} |
| 483 | label={props.primaryAction.label} | 569 | label={props.primaryAction.label} |
| ... | @@ -485,21 +571,22 @@ export function ThreadSwipeActions(props: { | ... | @@ -485,21 +571,22 @@ export function ThreadSwipeActions(props: { |
| 485 | stretchesOnFullSwipe={fullSwipeIsPrimary} | 571 | stretchesOnFullSwipe={fullSwipeIsPrimary} |
| 486 | translation={props.translation} | 572 | translation={props.translation} |
| 487 | /> | 573 | /> |
| 488 | <SwipeActionButton | 574 | {secondaryAction === null ? null : ( |
| 489 | accessibilityLabel={`Delete ${props.threadTitle}`} | 575 | <SwipeActionButton |
| 490 | backgroundColor="#ff2d55" | 576 | accessibilityLabel={secondaryAction.accessibilityLabel} |
| 491 | compact={props.compact} | 577 | actionsWidth={actionsWidth} |
| 492 | entryRange={[8, ACTION_ITEM_WIDTH * 0.72]} | 578 | backgroundColor={secondaryAction.backgroundColor} |
| 493 | fullSwipeThreshold={props.fullSwipeThreshold} | 579 | compact={props.compact} |
| 494 | icon="trash" | 580 | entryRange={[8, ACTION_ITEM_WIDTH * 0.72]} |
| 495 | label="Delete" | 581 | fullSwipeThreshold={props.fullSwipeThreshold} |
| 496 | onPress={() => { | 582 | icon={secondaryAction.icon} |
| 497 | props.swipeableMethods.close(); | 583 | label={secondaryAction.label} |
| 498 | props.onDelete(); | 584 | menu={secondaryAction.menu} |
| 499 | }} | 585 | onPress={secondaryAction.onPress} |
| 500 | stretchesOnFullSwipe={!fullSwipeIsPrimary} | 586 | stretchesOnFullSwipe={!fullSwipeIsPrimary} |
| 501 | translation={props.translation} | 587 | translation={props.translation} |
| 502 | /> | 588 | /> |
| 589 | )} | ||
| 503 | </View> | 590 | </View> |
| 504 | ); | 591 | ); |
| 505 | } | 592 | } |
apps/mobile/src/features/home/useThreadListActions.ts+109-2| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 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 | import * as Cause from "effect/Cause"; | 3 | import * as Cause from "effect/Cause"; |
| 4 | import * as Haptics from "expo-haptics"; | 4 | import * as Haptics from "expo-haptics"; |
| 5 | import { useCallback, useRef } from "react"; | 5 | import { useCallback, useRef } from "react"; |
| ... | @@ -22,6 +22,13 @@ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["en | ... | @@ -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 | type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; | 32 | type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; |
| 26 | 33 | ||
| 27 | const ACTION_VERBS: Record<ThreadListAction, string> = { | 34 | const ACTION_VERBS: Record<ThreadListAction, string> = { |
| ... | @@ -192,9 +199,14 @@ export function useThreadListActions(): { | ... | @@ -192,9 +199,14 @@ export function useThreadListActions(): { |
| 192 | readonly archiveThread: (thread: EnvironmentThreadShell) => void; | 199 | readonly archiveThread: (thread: EnvironmentThreadShell) => void; |
| 193 | readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; | 200 | readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; |
| 194 | readonly settleThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | 201 | readonly settleThread: (thread: EnvironmentThreadShell) => Promise<boolean>; |
| 202 | readonly snoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => Promise<boolean>; | ||
| 203 | readonly unsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | ||
| 195 | readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | 204 | readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>; |
| 196 | } { | 205 | } { |
| 197 | const executeAction = useThreadActionExecutor(); | 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 | const archiveThread = useCallback( | 211 | const archiveThread = useCallback( |
| 200 | (thread: EnvironmentThreadShell) => { | 212 | (thread: EnvironmentThreadShell) => { |
| ... | @@ -206,6 +218,94 @@ export function useThreadListActions(): { | ... | @@ -206,6 +218,94 @@ export function useThreadListActions(): { |
| 206 | async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true, | 218 | async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true, |
| 207 | [executeAction], | 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 | const unsettleThread = useCallback( | 309 | const unsettleThread = useCallback( |
| 210 | async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true, | 310 | async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true, |
| 211 | [executeAction], | 311 | [executeAction], |
| ... | @@ -213,7 +313,14 @@ export function useThreadListActions(): { | ... | @@ -213,7 +313,14 @@ export function useThreadListActions(): { |
| 213 | 313 | ||
| 214 | const confirmDeleteThread = useConfirmDeleteThread(executeAction); | 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 | export function useArchivedThreadListActions( | 326 | export function useArchivedThreadListActions( |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+70-24| ... | @@ -67,7 +67,11 @@ import { | ... | @@ -67,7 +67,11 @@ import { |
| 67 | ThreadListRow, | 67 | ThreadListRow, |
| 68 | ThreadListShowMoreRow, | 68 | ThreadListShowMoreRow, |
| 69 | } from "./thread-list-items"; | 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 | import { | 75 | import { |
| 72 | buildThreadListV2Items, | 76 | buildThreadListV2Items, |
| 73 | buildThreadListV2ListItems, | 77 | buildThreadListV2ListItems, |
| ... | @@ -193,8 +197,14 @@ function ThreadNavigationSidebarPane( | ... | @@ -193,8 +197,14 @@ function ThreadNavigationSidebarPane( |
| 193 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); | 197 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 194 | const headerIsOverContentRef = useRef(false); | 198 | const headerIsOverContentRef = useRef(false); |
| 195 | const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); | 199 | const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); |
| 196 | const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = | 200 | const { |
| 197 | useThreadListActions(); | 201 | archiveThread, |
| 202 | confirmDeleteThread, | ||
| 203 | settleThread, | ||
| 204 | snoozeThread, | ||
| 205 | unsnoozeThread, | ||
| 206 | unsettleThread, | ||
| 207 | } = useThreadListActions(); | ||
| 198 | const threadListV2Enabled = useThreadListV2Enabled(); | 208 | const threadListV2Enabled = useThreadListV2Enabled(); |
| 199 | const pendingTasks = usePendingNewTasks(); | 209 | const pendingTasks = usePendingNewTasks(); |
| 200 | const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); | 210 | const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); |
| ... | @@ -428,6 +438,8 @@ function ThreadNavigationSidebarPane( | ... | @@ -428,6 +438,8 @@ function ThreadNavigationSidebarPane( |
| 428 | () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), | 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 | // now ticks per minute so the inactivity auto-settle boundary is actually | 443 | // now ticks per minute so the inactivity auto-settle boundary is actually |
| 432 | // crossed while the pane stays open; without a clock dependency the | 444 | // crossed while the pane stays open; without a clock dependency the |
| 433 | // partition memoizes a frozen "now". | 445 | // partition memoizes a frozen "now". |
| ... | @@ -468,7 +480,13 @@ function ThreadNavigationSidebarPane( | ... | @@ -468,7 +480,13 @@ function ThreadNavigationSidebarPane( |
| 468 | }, [serverConfigs]); | 480 | }, [serverConfigs]); |
| 469 | const threadListV2Layout = useMemo(() => { | 481 | const threadListV2Layout = useMemo(() => { |
| 470 | if (!threadListV2Enabled) | 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 | return buildThreadListV2Items({ | 490 | return buildThreadListV2Items({ |
| 473 | threads: threads.filter((thread) => thread.archivedAt === null), | 491 | threads: threads.filter((thread) => thread.archivedAt === null), |
| 474 | environmentId: options.selectedEnvironmentId, | 492 | environmentId: options.selectedEnvironmentId, |
| ... | @@ -481,11 +499,15 @@ function ThreadNavigationSidebarPane( | ... | @@ -481,11 +499,15 @@ function ThreadNavigationSidebarPane( |
| 481 | settledLimit: settledVisibleCount, | 499 | settledLimit: settledVisibleCount, |
| 482 | now: `${nowMinute}:00.000Z`, | 500 | now: `${nowMinute}:00.000Z`, |
| 483 | snoozeNow: new Date().toISOString(), | 501 | snoozeNow: new Date().toISOString(), |
| 502 | snoozedShelfExpanded, | ||
| 503 | selectedThreadKey: props.selectedThreadKey ?? null, | ||
| 484 | }); | 504 | }); |
| 485 | }, [ | 505 | }, [ |
| 486 | changeRequestStateByKey, | 506 | changeRequestStateByKey, |
| 487 | nowMinute, | 507 | nowMinute, |
| 488 | snoozeWakeTick, | 508 | snoozeWakeTick, |
| 509 | snoozedShelfExpanded, | ||
| 510 | props.selectedThreadKey, | ||
| 489 | options.selectedEnvironmentId, | 511 | options.selectedEnvironmentId, |
| 490 | props.searchQuery, | 512 | props.searchQuery, |
| 491 | matchedThreadKeys, | 513 | matchedThreadKeys, |
| ... | @@ -532,6 +554,10 @@ function ThreadNavigationSidebarPane( | ... | @@ -532,6 +554,10 @@ function ThreadNavigationSidebarPane( |
| 532 | const items: SidebarListItem[] = buildThreadListV2ListItems({ | 554 | const items: SidebarListItem[] = buildThreadListV2ListItems({ |
| 533 | items: threadListV2Layout.items, | 555 | items: threadListV2Layout.items, |
| 534 | pendingTasks: v2PendingTasks, | 556 | pendingTasks: v2PendingTasks, |
| 557 | snoozedCount: threadListV2Layout.snoozedCount, | ||
| 558 | snoozedShelfExpanded, | ||
| 559 | snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, | ||
| 560 | snoozeLabelNow: `${nowMinute}:00.000Z`, | ||
| 535 | }); | 561 | }); |
| 536 | if (threadListV2Layout.hiddenSettledCount > 0) { | 562 | if (threadListV2Layout.hiddenSettledCount > 0) { |
| 537 | items.push({ | 563 | items.push({ |
| ... | @@ -543,10 +569,12 @@ function ThreadNavigationSidebarPane( | ... | @@ -543,10 +569,12 @@ function ThreadNavigationSidebarPane( |
| 543 | return items; | 569 | return items; |
| 544 | }, [ | 570 | }, [ |
| 545 | listLayout.items, | 571 | listLayout.items, |
| 572 | nowMinute, | ||
| 546 | options.selectedEnvironmentId, | 573 | options.selectedEnvironmentId, |
| 547 | pendingTasks, | 574 | pendingTasks, |
| 548 | props.searchQuery, | 575 | props.searchQuery, |
| 549 | selectedProjectRefs, | 576 | selectedProjectRefs, |
| 577 | snoozedShelfExpanded, | ||
| 550 | threadListV2Enabled, | 578 | threadListV2Enabled, |
| 551 | threadListV2Layout, | 579 | threadListV2Layout, |
| 552 | ]); | 580 | ]); |
| ... | @@ -731,6 +759,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -731,6 +759,7 @@ function ThreadNavigationSidebarPane( |
| 731 | projectTitleByProjectKey, | 759 | projectTitleByProjectKey, |
| 732 | savedConnectionsById, | 760 | savedConnectionsById, |
| 733 | serverConfigs, | 761 | serverConfigs, |
| 762 | snoozePresetMinute: nowMinute, | ||
| 734 | threadSearchMatchByKey, | 763 | threadSearchMatchByKey, |
| 735 | }), | 764 | }), |
| 736 | [ | 765 | [ |
| ... | @@ -740,6 +769,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -740,6 +769,7 @@ function ThreadNavigationSidebarPane( |
| 740 | projectTitleByProjectKey, | 769 | projectTitleByProjectKey, |
| 741 | savedConnectionsById, | 770 | savedConnectionsById, |
| 742 | serverConfigs, | 771 | serverConfigs, |
| 772 | nowMinute, | ||
| 743 | threadSearchMatchByKey, | 773 | threadSearchMatchByKey, |
| 744 | ], | 774 | ], |
| 745 | ); | 775 | ); |
| ... | @@ -750,7 +780,9 @@ function ThreadNavigationSidebarPane( | ... | @@ -750,7 +780,9 @@ function ThreadNavigationSidebarPane( |
| 750 | previous.key === item.key && | 780 | previous.key === item.key && |
| 751 | previous.item.thread === item.item.thread && | 781 | previous.item.thread === item.item.thread && |
| 752 | previous.item.variant === item.item.variant && | 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 | if (previous.type === "v2-show-more" && item.type === "v2-show-more") { | 788 | if (previous.type === "v2-show-more" && item.type === "v2-show-more") { |
| ... | @@ -762,13 +794,18 @@ function ThreadNavigationSidebarPane( | ... | @@ -762,13 +794,18 @@ function ThreadNavigationSidebarPane( |
| 762 | previous.showPendingDivider === item.showPendingDivider | 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 | if ( | 800 | if ( |
| 766 | previous.type === "v2-thread" || | 801 | previous.type === "v2-thread" || |
| 767 | previous.type === "v2-show-more" || | 802 | previous.type === "v2-show-more" || |
| 768 | previous.type === "v2-pending" || | 803 | previous.type === "v2-pending" || |
| 804 | previous.type === "v2-snoozed-shelf" || | ||
| 769 | item.type === "v2-thread" || | 805 | item.type === "v2-thread" || |
| 770 | item.type === "v2-show-more" || | 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 | return false; | 810 | return false; |
| 774 | } | 811 | } |
| ... | @@ -827,6 +864,9 @@ function ThreadNavigationSidebarPane( | ... | @@ -827,6 +864,9 @@ function ThreadNavigationSidebarPane( |
| 827 | thread={thread} | 864 | thread={thread} |
| 828 | variant={item.item.variant} | 865 | variant={item.item.variant} |
| 829 | showSettledDivider={item.item.showSettledDivider} | 866 | showSettledDivider={item.item.showSettledDivider} |
| 867 | snoozed={item.item.snoozed} | ||
| 868 | snoozePresetMinute={nowMinute} | ||
| 869 | snoozeWakeLabelText={item.snoozeWakeLabelText} | ||
| 830 | project={projectByKey.get(scopeKey) ?? null} | 870 | project={projectByKey.get(scopeKey) ?? null} |
| 831 | projectTitle={projectTitleByProjectKey.get(scopeKey)} | 871 | projectTitle={projectTitleByProjectKey.get(scopeKey)} |
| 832 | providerDriver={ | 872 | providerDriver={ |
| ... | @@ -860,6 +900,9 @@ function ThreadNavigationSidebarPane( | ... | @@ -860,6 +900,9 @@ function ThreadNavigationSidebarPane( |
| 860 | onArchiveThread={archiveThread} | 900 | onArchiveThread={archiveThread} |
| 861 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} | 901 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} |
| 862 | onSettleThread={settleThread} | 902 | onSettleThread={settleThread} |
| 903 | snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} | ||
| 904 | onSnoozeThread={snoozeThread} | ||
| 905 | onUnsnoozeThread={unsnoozeThread} | ||
| 863 | onUnsettleThread={unsettleThread} | 906 | onUnsettleThread={unsettleThread} |
| 864 | onChangeRequestState={handleChangeRequestState} | 907 | onChangeRequestState={handleChangeRequestState} |
| 865 | projectCwd={projectCwdByKey.get(scopeKey) ?? null} | 908 | projectCwd={projectCwdByKey.get(scopeKey) ?? null} |
| ... | @@ -869,6 +912,15 @@ function ThreadNavigationSidebarPane( | ... | @@ -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 | case "v2-show-more": | 924 | case "v2-show-more": |
| 873 | return ( | 925 | return ( |
| 874 | <Pressable | 926 | <Pressable |
| ... | @@ -984,7 +1036,12 @@ function ThreadNavigationSidebarPane( | ... | @@ -984,7 +1036,12 @@ function ThreadNavigationSidebarPane( |
| 984 | settlementEnvironmentIds, | 1036 | settlementEnvironmentIds, |
| 985 | showMoreSettled, | 1037 | showMoreSettled, |
| 986 | sidebarScrollGesture, | 1038 | sidebarScrollGesture, |
| 1039 | snoozeEnvironmentIds, | ||
| 1040 | snoozeThread, | ||
| 1041 | nowMinute, | ||
| 1042 | toggleSnoozedShelf, | ||
| 987 | unsettleThread, | 1043 | unsettleThread, |
| 1044 | unsnoozeThread, | ||
| 988 | updateGroupDisplay, | 1045 | updateGroupDisplay, |
| 989 | ], | 1046 | ], |
| 990 | ); | 1047 | ); |
| ... | @@ -1031,30 +1088,19 @@ function ThreadNavigationSidebarPane( | ... | @@ -1031,30 +1088,19 @@ function ThreadNavigationSidebarPane( |
| 1031 | }), | 1088 | }), |
| 1032 | [filterIcon, filterMenu, props.onOpenSettings], | 1089 | [filterIcon, filterMenu, props.onOpenSettings], |
| 1033 | ); | 1090 | ); |
| 1034 | // "No threads yet" over an inbox that is merely all-snoozed reads as | 1091 | // Snoozed threads need no special case: the shelf header is a list row |
| 1035 | // data loss; name the snoozed threads instead. | 1092 | // even while collapsed. |
| 1036 | const snoozedCount = threadListV2Layout.snoozedCount; | ||
| 1037 | const listEmpty = ( | 1093 | const listEmpty = ( |
| 1038 | <Text className="px-2 py-4 text-sm text-foreground-muted"> | 1094 | <Text className="px-2 py-4 text-sm text-foreground-muted"> |
| 1039 | {catalogState.isLoadingConnections | 1095 | {catalogState.isLoadingConnections |
| 1040 | ? "Loading threads…" | 1096 | ? "Loading threads…" |
| 1041 | : props.searchQuery.trim().length > 0 | 1097 | : props.searchQuery.trim().length > 0 |
| 1042 | ? threadSearch.isPending && snoozedCount === 0 | 1098 | ? threadSearch.isPending |
| 1043 | ? "Searching thread messages…" | 1099 | ? "Searching thread messages…" |
| 1044 | : snoozedCount > 0 | 1100 | : "No matching threads" |
| 1045 | ? // Snoozed matches passed this same search filter — "No | 1101 | : selectedProjectScope !== null |
| 1046 | // matching threads" would misreport them as nonexistent. | 1102 | ? `No threads in ${selectedProjectScope.title}` |
| 1047 | snoozedCount === 1 | 1103 | : "No threads yet"} |
| 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"} | ||
| 1058 | </Text> | 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,11 +3,20 @@ import type { |
| 3 | EnvironmentThreadShell, | 3 | EnvironmentThreadShell, |
| 4 | } from "@t3tools/client-runtime/state/shell"; | 4 | } from "@t3tools/client-runtime/state/shell"; |
| 5 | import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; | 5 | import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; |
| 6 | import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; | ||
| 6 | import type { MenuAction } from "@react-native-menu/menu"; | 7 | import type { MenuAction } from "@react-native-menu/menu"; |
| 7 | import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react"; | 8 | import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; |
| 8 | import { Platform, Pressable, useWindowDimensions, View } from "react-native"; | 9 | import { |
| 10 | Alert, | ||
| 11 | Platform, | ||
| 12 | Pressable, | ||
| 13 | useColorScheme, | ||
| 14 | useWindowDimensions, | ||
| 15 | View, | ||
| 16 | } from "react-native"; | ||
| 9 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | 17 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 10 | 18 | ||
| 19 | import { SymbolView } from "../../components/AppSymbol"; | ||
| 11 | import { AppText as Text } from "../../components/AppText"; | 20 | import { AppText as Text } from "../../components/AppText"; |
| 12 | import { ControlPillMenu } from "../../components/ControlPill"; | 21 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 13 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | 22 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| ... | @@ -18,7 +27,13 @@ import { useThemeColor } from "../../lib/useThemeColor"; | ... | @@ -18,7 +27,13 @@ import { useThemeColor } from "../../lib/useThemeColor"; |
| 18 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 27 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 19 | import { useThreadPr } from "../../state/use-thread-pr"; | 28 | import { useThreadPr } from "../../state/use-thread-pr"; |
| 20 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; | 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 | import { ThreadSearchMatchExcerpt } from "./thread-search-match"; | 37 | import { ThreadSearchMatchExcerpt } from "./thread-search-match"; |
| 23 | 38 | ||
| 24 | /** | 39 | /** |
| ... | @@ -62,6 +77,11 @@ const SLIM_MENU_ACTIONS: MenuAction[] = [ | ... | @@ -62,6 +77,11 @@ const SLIM_MENU_ACTIONS: MenuAction[] = [ |
| 62 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, | 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 | // Pre-settlement servers: no lifecycle items, archive fills the gap. | 85 | // Pre-settlement servers: no lifecycle items, archive fills the gap. |
| 66 | const LEGACY_MENU_ACTIONS: MenuAction[] = [ | 86 | const LEGACY_MENU_ACTIONS: MenuAction[] = [ |
| 67 | { id: "archive", title: "Archive", image: "archivebox" }, | 87 | { id: "archive", title: "Archive", image: "archivebox" }, |
| ... | @@ -90,6 +110,45 @@ export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivid | ... | @@ -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 | const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ | 152 | const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ |
| 94 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, | 153 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, |
| 95 | ]; | 154 | ]; |
| ... | @@ -209,6 +268,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -209,6 +268,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 209 | readonly thread: EnvironmentThreadShell; | 268 | readonly thread: EnvironmentThreadShell; |
| 210 | readonly variant: "card" | "slim"; | 269 | readonly variant: "card" | "slim"; |
| 211 | readonly showSettledDivider: boolean; | 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 | readonly project: EnvironmentProject | null; | 279 | readonly project: EnvironmentProject | null; |
| 213 | readonly projectTitle?: string; | 280 | readonly projectTitle?: string; |
| 214 | readonly providerDriver: string | null; | 281 | readonly providerDriver: string | null; |
| ... | @@ -231,11 +298,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -231,11 +298,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 231 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; | 298 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; |
| 232 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; | 299 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; |
| 233 | readonly onSettleThread: (thread: EnvironmentThreadShell) => void; | 300 | readonly onSettleThread: (thread: EnvironmentThreadShell) => void; |
| 301 | readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void; | ||
| 302 | readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void; | ||
| 234 | readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; | 303 | readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; |
| 235 | readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; | 304 | readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; |
| 236 | /** False on environments whose server predates thread.settle/unsettle: | 305 | /** False on environments whose server predates thread.settle/unsettle: |
| 237 | swipe + menu fall back to Archive instead of failing on use. */ | 306 | swipe + menu fall back to Archive instead of failing on use. */ |
| 238 | readonly settlementSupported: boolean; | 307 | readonly settlementSupported: boolean; |
| 308 | /** False on servers that predate thread.snooze/unsnooze. */ | ||
| 309 | readonly snoozeSupported: boolean; | ||
| 239 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; | 310 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; |
| 240 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; | 311 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; |
| 241 | /** Reports this row's live PR state up so the partition can auto-settle | 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,10 +329,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 258 | onSelectThread, | 329 | onSelectThread, |
| 259 | onDeleteThread, | 330 | onDeleteThread, |
| 260 | onSettleThread, | 331 | onSettleThread, |
| 332 | onSnoozeThread, | ||
| 333 | onUnsnoozeThread, | ||
| 261 | onUnsettleThread, | 334 | onUnsettleThread, |
| 262 | onArchiveThread, | 335 | onArchiveThread, |
| 263 | onChangeRequestState, | 336 | onChangeRequestState, |
| 264 | } = props; | 337 | } = props; |
| 338 | const snoozedRow = props.snoozed === true; | ||
| 265 | 339 | ||
| 266 | const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); | 340 | const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); |
| 267 | const prState = pr?.state ?? null; | 341 | const prState = pr?.state ?? null; |
| ... | @@ -283,27 +357,94 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -283,27 +357,94 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 283 | 357 | ||
| 284 | const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); | 358 | const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); |
| 285 | const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); | 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 | const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); | 365 | const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); |
| 287 | const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); | 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 | const handleMenuAction = useCallback( | 415 | const handleMenuAction = useCallback( |
| 289 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { | 416 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { |
| 290 | if (nativeEvent.event === "settle") handleSettle(); | 417 | if (nativeEvent.event === "settle") handleSettle(); |
| 291 | if (nativeEvent.event === "unsettle") handleUnsettle(); | 418 | if (nativeEvent.event === "unsettle") handleUnsettle(); |
| 419 | if (nativeEvent.event === "unsnooze") handleUnsnooze(); | ||
| 292 | if (nativeEvent.event === "archive") handleArchive(); | 420 | if (nativeEvent.event === "archive") handleArchive(); |
| 293 | if (nativeEvent.event === "delete") handleDelete(); | 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 | const primaryAction = useMemo(() => { | 443 | const primaryAction = useMemo(() => { |
| 303 | // Pre-settlement server: archive is the swipe action, as in v1. (Slim | 444 | // Pre-settlement server: archive is the swipe action, as in v1. (Slim |
| 304 | // rows cannot occur here — unsupported environments never classify as | 445 | // rows cannot occur here — unsupported environments never classify as |
| 305 | // settled.) | 446 | // settled.) |
| 306 | if (!props.settlementSupported) { | 447 | if (swipeActions.primary === "archive") { |
| 307 | return { | 448 | return { |
| 308 | accessibilityLabel: `Archive ${thread.title}`, | 449 | accessibilityLabel: `Archive ${thread.title}`, |
| 309 | icon: "archivebox" as const, | 450 | icon: "archivebox" as const, |
| ... | @@ -311,7 +452,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -311,7 +452,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 311 | onPress: handleArchive, | 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 | accessibilityLabel: `Un-settle ${thread.title}`, | 465 | accessibilityLabel: `Un-settle ${thread.title}`, |
| 317 | icon: "arrow.uturn.backward" as const, | 466 | icon: "arrow.uturn.backward" as const, |
| ... | @@ -325,13 +474,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -325,13 +474,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 325 | onPress: handleSettle, | 474 | onPress: handleSettle, |
| 326 | }; | 475 | }; |
| 327 | }, [ | 476 | }, [ |
| 328 | canUnsettle, | ||
| 329 | handleArchive, | 477 | handleArchive, |
| 330 | handleSettle, | 478 | handleSettle, |
| 331 | handleUnsettle, | 479 | handleUnsettle, |
| 332 | props.settlementSupported, | 480 | handleUnsnooze, |
| 481 | swipeActions.primary, | ||
| 333 | thread.title, | 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 | // The sidebar pane fills selected rows with the accent color (matching the | 506 | // The sidebar pane fills selected rows with the accent color (matching the |
| 337 | // v1 sidebar), so every piece of row text needs a white-on-accent variant. | 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,7 +623,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 453 | const rowContent = (close: () => void) => | 623 | const rowContent = (close: () => void) => |
| 454 | variant === "card" ? ( | 624 | variant === "card" ? ( |
| 455 | <Pressable | 625 | <Pressable |
| 456 | accessibilityHint={`Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`} | 626 | accessibilityHint={swipeAccessibilityHint} |
| 457 | accessibilityLabel={thread.title} | 627 | accessibilityLabel={thread.title} |
| 458 | accessibilityRole="button" | 628 | accessibilityRole="button" |
| 459 | accessibilityState={{ selected }} | 629 | accessibilityState={{ selected }} |
| ... | @@ -491,7 +661,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -491,7 +661,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 491 | </Pressable> | 661 | </Pressable> |
| 492 | ) : ( | 662 | ) : ( |
| 493 | <Pressable | 663 | <Pressable |
| 494 | accessibilityHint={`Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`} | 664 | accessibilityHint={swipeAccessibilityHint} |
| 495 | accessibilityLabel={thread.title} | 665 | accessibilityLabel={thread.title} |
| 496 | accessibilityRole="button" | 666 | accessibilityRole="button" |
| 497 | accessibilityState={{ selected }} | 667 | accessibilityState={{ selected }} |
| ... | @@ -551,11 +721,17 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -551,11 +721,17 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 551 | <Text | 721 | <Text |
| 552 | className={cn( | 722 | className={cn( |
| 553 | "text-sm tabular-nums", | 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 | style={{ fontFamily: MONO_FONT }} | 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 | </Text> | 735 | </Text> |
| 560 | </View> | 736 | </View> |
| 561 | </Pressable> | 737 | </Pressable> |
| ... | @@ -574,13 +750,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -574,13 +750,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 574 | } | 750 | } |
| 575 | enableTrackpadSwipe | 751 | enableTrackpadSwipe |
| 576 | // Full swipe commits the advertised lifecycle action (Settle / | 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 | fullSwipeAction="primary" | 754 | fullSwipeAction="primary" |
| 579 | fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32} | 755 | fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32} |
| 580 | onDelete={handleDelete} | 756 | onDelete={handleDelete} |
| 581 | onSwipeableClose={props.onSwipeableClose} | 757 | onSwipeableClose={props.onSwipeableClose} |
| 582 | onSwipeableWillOpen={props.onSwipeableWillOpen} | 758 | onSwipeableWillOpen={props.onSwipeableWillOpen} |
| 583 | primaryAction={primaryAction} | 759 | primaryAction={primaryAction} |
| 760 | secondaryAction={secondaryAction} | ||
| 584 | resetKey={`${thread.environmentId}:${thread.id}`} | 761 | resetKey={`${thread.environmentId}:${thread.id}`} |
| 585 | simultaneousWithExternalGesture={props.simultaneousSwipeGesture} | 762 | simultaneousWithExternalGesture={props.simultaneousSwipeGesture} |
| 586 | threadTitle={thread.title} | 763 | threadTitle={thread.title} |
| ... | @@ -588,11 +765,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -588,11 +765,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 588 | {(close) => ( | 765 | {(close) => ( |
| 589 | <ControlPillMenu | 766 | <ControlPillMenu |
| 590 | actions={ | 767 | actions={ |
| 591 | !props.settlementSupported | 768 | snoozedRow |
| 592 | ? LEGACY_MENU_ACTIONS | 769 | ? SNOOZED_MENU_ACTIONS |
| 593 | : canUnsettle | 770 | : !props.settlementSupported |
| 594 | ? SLIM_MENU_ACTIONS | 771 | ? LEGACY_MENU_ACTIONS |
| 595 | : CARD_MENU_ACTIONS | 772 | : canUnsettle |
| 773 | ? SLIM_MENU_ACTIONS | ||
| 774 | : swipeActions.secondary === "snooze" | ||
| 775 | ? snoozableCardMenuActions | ||
| 776 | : CARD_MENU_ACTIONS | ||
| 596 | } | 777 | } |
| 597 | onPressAction={handleMenuAction} | 778 | onPressAction={handleMenuAction} |
| 598 | shouldOpenOnLongPress | 779 | shouldOpenOnLongPress |
apps/mobile/src/features/threads/threadListV2.test.ts+280-1| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 2 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; | 2 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 3 | import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; | ||
| 3 | import { | 4 | import { |
| 4 | CommandId, | 5 | CommandId, |
| 5 | EnvironmentId, | 6 | EnvironmentId, |
| ... | @@ -16,7 +17,10 @@ import { | ... | @@ -16,7 +17,10 @@ import { |
| 16 | buildThreadListV2Items, | 17 | buildThreadListV2Items, |
| 17 | buildThreadListV2ListItems, | 18 | buildThreadListV2ListItems, |
| 18 | resolveThreadListV2Enabled, | 19 | resolveThreadListV2Enabled, |
| 20 | resolveThreadListV2SnoozeMenuSelection, | ||
| 21 | resolveThreadListV2SnoozeGateExpiryMs, | ||
| 19 | resolveThreadListV2Status, | 22 | resolveThreadListV2Status, |
| 23 | resolveThreadListV2SwipeActions, | ||
| 20 | sortThreadsForListV2, | 24 | sortThreadsForListV2, |
| 21 | } from "./threadListV2"; | 25 | } from "./threadListV2"; |
| 22 | 26 | ||
| ... | @@ -50,6 +54,54 @@ function makeThread( | ... | @@ -50,6 +54,54 @@ function makeThread( |
| 50 | 54 | ||
| 51 | const NOW = "2026-06-02T00:00:00.000Z"; | 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 | describe("resolveThreadListV2Enabled", () => { | 105 | describe("resolveThreadListV2Enabled", () => { |
| 54 | it("defaults on when the device has never chosen", () => { | 106 | it("defaults on when the device has never chosen", () => { |
| 55 | expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe( | 107 | expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe( |
| ... | @@ -96,6 +148,105 @@ describe("resolveThreadListV2Status", () => { | ... | @@ -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 | describe("sortThreadsForListV2", () => { | 250 | describe("sortThreadsForListV2", () => { |
| 100 | it("orders by creation time, newest first, ignoring activity", () => { | 251 | it("orders by creation time, newest first, ignoring activity", () => { |
| 101 | const sorted = sortThreadsForListV2([ | 252 | const sorted = sortThreadsForListV2([ |
| ... | @@ -167,6 +318,93 @@ describe("buildThreadListV2Items", () => { | ... | @@ -167,6 +318,93 @@ describe("buildThreadListV2Items", () => { |
| 167 | expect(layout.nextSnoozeWakeAt).toBe("2026-06-02T09:00:00.000Z"); | 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 | it("keeps snoozed threads visible on environments without the snooze capability", () => { | 408 | it("keeps snoozed threads visible on environments without the snooze capability", () => { |
| 171 | const layout = buildThreadListV2Items({ | 409 | const layout = buildThreadListV2Items({ |
| 172 | threads: [ | 410 | threads: [ |
| ... | @@ -424,7 +662,11 @@ describe("buildThreadListV2ListItems", () => { | ... | @@ -424,7 +662,11 @@ describe("buildThreadListV2ListItems", () => { |
| 424 | 662 | ||
| 425 | expect( | 663 | expect( |
| 426 | items.map((item) => | 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 | ).toEqual(["active", "queued-1", "queued-2", "settled"]); | 671 | ).toEqual(["active", "queued-1", "queued-2", "settled"]); |
| 430 | // Only the leading queued row labels the section, exactly like Settled. | 672 | // Only the leading queued row labels the section, exactly like Settled. |
| ... | @@ -456,4 +698,41 @@ describe("buildThreadListV2ListItems", () => { | ... | @@ -456,4 +698,41 @@ describe("buildThreadListV2ListItems", () => { |
| 456 | `v2-thread:${environmentId}:settled`, | 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,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 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 10 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 3 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; | 11 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 4 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; | 12 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 5 | 13 | ||
| 6 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 14 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 7 | 15 | ||
| 16 | export { snoozeWakeLabel }; | ||
| 17 | |||
| 8 | /** | 18 | /** |
| 9 | * Thread List v2 model, ported from the web sidebar v2 | 19 | * Thread List v2 model, ported from the web sidebar v2 |
| 10 | * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). | 20 | * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). |
| ... | @@ -14,6 +24,76 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | ... | @@ -14,6 +24,76 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 14 | * unlabeled resting state. | 24 | * unlabeled resting state. |
| 15 | */ | 25 | */ |
| 16 | export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; | 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 | // Settled-tail paging: recent history is the common lookup; the deep tail | 98 | // Settled-tail paging: recent history is the common lookup; the deep tail |
| 19 | // stays behind an explicit Show more. Shared by the compact Home list and | 99 | // stays behind an explicit Show more. Shared by the compact Home list and |
| ... | @@ -100,6 +180,8 @@ export interface ThreadListV2Item { | ... | @@ -100,6 +180,8 @@ export interface ThreadListV2Item { |
| 100 | readonly variant: "card" | "slim"; | 180 | readonly variant: "card" | "slim"; |
| 101 | /** First settled row after the card block draws the SETTLED divider. */ | 181 | /** First settled row after the card block draws the SETTLED divider. */ |
| 102 | readonly showSettledDivider: boolean; | 182 | readonly showSettledDivider: boolean; |
| 183 | /** Snoozed-shelf row: shows the wake countdown and offers Wake. */ | ||
| 184 | readonly snoozed: boolean; | ||
| 103 | readonly isLast: boolean; | 185 | readonly isLast: boolean; |
| 104 | } | 186 | } |
| 105 | 187 | ||
| ... | @@ -107,10 +189,12 @@ export interface ThreadListV2Layout { | ... | @@ -107,10 +189,12 @@ export interface ThreadListV2Layout { |
| 107 | readonly items: ThreadListV2Item[]; | 189 | readonly items: ThreadListV2Item[]; |
| 108 | /** Settled threads beyond the render limit (behind "Show more"). */ | 190 | /** Settled threads beyond the render limit (behind "Show more"). */ |
| 109 | readonly hiddenSettledCount: number; | 191 | readonly hiddenSettledCount: number; |
| 110 | /** Snoozed threads hidden from the list (visibility parity with web's | 192 | /** Snoozed threads matching the current filters. */ |
| 111 | collapsed Snoozed shelf; mobile has no shelf UI yet). */ | ||
| 112 | readonly snoozedCount: number; | 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 | a timeout at this boundary so the list re-partitions the moment a | 198 | a timeout at this boundary so the list re-partitions the moment a |
| 115 | snooze expires instead of on the next minute tick. */ | 199 | snooze expires instead of on the next minute tick. */ |
| 116 | readonly nextSnoozeWakeAt: string | null; | 200 | readonly nextSnoozeWakeAt: string | null; |
| ... | @@ -120,6 +204,8 @@ export interface ThreadListV2ThreadListItem { | ... | @@ -120,6 +204,8 @@ export interface ThreadListV2ThreadListItem { |
| 120 | readonly type: "v2-thread"; | 204 | readonly type: "v2-thread"; |
| 121 | readonly key: string; | 205 | readonly key: string; |
| 122 | readonly item: ThreadListV2Item; | 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 | export interface ThreadListV2PendingListItem { | 211 | export interface ThreadListV2PendingListItem { |
| ... | @@ -130,28 +216,42 @@ export interface ThreadListV2PendingListItem { | ... | @@ -130,28 +216,42 @@ export interface ThreadListV2PendingListItem { |
| 130 | readonly showPendingDivider: boolean; | 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 | 232 | * Builds the shared mobile order: active → pending → snoozed shelf → settled. |
| 137 | * list reads active → pending → settled. Queued work sits below the live | 233 | * Pending tasks are waiting rather than asking, and parked work remains |
| 138 | * threads because nothing can happen to it until its environment returns: | 234 | * reachable without competing with either the inbox or settled history. |
| 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. | ||
| 141 | */ | 235 | */ |
| 142 | export function buildThreadListV2ListItems(input: { | 236 | export function buildThreadListV2ListItems(input: { |
| 143 | readonly items: ReadonlyArray<ThreadListV2Item>; | 237 | readonly items: ReadonlyArray<ThreadListV2Item>; |
| 144 | readonly pendingTasks: ReadonlyArray<PendingNewTask>; | 238 | readonly pendingTasks: ReadonlyArray<PendingNewTask>; |
| 239 | readonly snoozedCount?: number; | ||
| 240 | readonly snoozedShelfExpanded?: boolean; | ||
| 241 | readonly snoozedShelfHeaderIndex?: number | null; | ||
| 242 | readonly snoozeLabelNow?: string; | ||
| 145 | }): ThreadListV2ListItem[] { | 243 | }): ThreadListV2ListItem[] { |
| 146 | const threadItems = input.items.map( | 244 | const threadItems = input.items.map( |
| 147 | (item): ThreadListV2ListItem => ({ | 245 | (item): ThreadListV2ListItem => ({ |
| 148 | type: "v2-thread", | 246 | type: "v2-thread", |
| 149 | key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, | 247 | key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, |
| 150 | item, | 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 | const pendingItems = input.pendingTasks.map( | 255 | const pendingItems = input.pendingTasks.map( |
| 156 | (pendingTask, index): ThreadListV2ListItem => ({ | 256 | (pendingTask, index): ThreadListV2ListItem => ({ |
| 157 | type: "v2-pending", | 257 | type: "v2-pending", |
| ... | @@ -160,8 +260,23 @@ export function buildThreadListV2ListItems(input: { | ... | @@ -160,8 +260,23 @@ export function buildThreadListV2ListItems(input: { |
| 160 | showPendingDivider: index === 0, | 260 | showPendingDivider: index === 0, |
| 161 | }), | 261 | }), |
| 162 | ); | 262 | ); |
| 163 | // The settled tail begins at the row that draws the SETTLED divider; with | 263 | const snoozedCount = input.snoozedCount ?? 0; |
| 164 | // no settled rows the queued block simply ends the list. | 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 | const settledStart = threadItems.findIndex( | 280 | const settledStart = threadItems.findIndex( |
| 166 | (entry) => entry.type === "v2-thread" && entry.item.showSettledDivider, | 281 | (entry) => entry.type === "v2-thread" && entry.item.showSettledDivider, |
| 167 | ); | 282 | ); |
| ... | @@ -204,6 +319,11 @@ export function buildThreadListV2Items(input: { | ... | @@ -204,6 +319,11 @@ export function buildThreadListV2Items(input: { |
| 204 | second-precise, so classifying with the floored minute would hold a | 319 | second-precise, so classifying with the floored minute would hold a |
| 205 | woken thread hidden for up to a minute. Defaults to `now`. */ | 320 | woken thread hidden for up to a minute. Defaults to `now`. */ |
| 206 | readonly snoozeNow?: string; | 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 | }): ThreadListV2Layout { | 327 | }): ThreadListV2Layout { |
| 208 | const now = input.now ?? new Date().toISOString(); | 328 | const now = input.now ?? new Date().toISOString(); |
| 209 | const snoozeNow = input.snoozeNow ?? now; | 329 | const snoozeNow = input.snoozeNow ?? now; |
| ... | @@ -215,7 +335,7 @@ export function buildThreadListV2Items(input: { | ... | @@ -215,7 +335,7 @@ export function buildThreadListV2Items(input: { |
| 215 | 335 | ||
| 216 | const active: EnvironmentThreadShell[] = []; | 336 | const active: EnvironmentThreadShell[] = []; |
| 217 | const settled: EnvironmentThreadShell[] = []; | 337 | const settled: EnvironmentThreadShell[] = []; |
| 218 | let snoozedCount = 0; | 338 | const snoozed: EnvironmentThreadShell[] = []; |
| 219 | let nextSnoozeWakeAt: string | null = null; | 339 | let nextSnoozeWakeAt: string | null = null; |
| 220 | for (const thread of input.threads) { | 340 | for (const thread of input.threads) { |
| 221 | // Callers pass live (unarchived) shells; settled threads are among them | 341 | // Callers pass live (unarchived) shells; settled threads are among them |
| ... | @@ -244,7 +364,7 @@ export function buildThreadListV2Items(input: { | ... | @@ -244,7 +364,7 @@ export function buildThreadListV2Items(input: { |
| 244 | // wakes (or raises its hand — effectiveSnoozed refuses blocked/failed | 364 | // wakes (or raises its hand — effectiveSnoozed refuses blocked/failed |
| 245 | // work). Snooze outranks settled classification, same as web. | 365 | // work). Snooze outranks settled classification, same as web. |
| 246 | if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { | 366 | if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { |
| 247 | snoozedCount += 1; | 367 | snoozed.push(thread); |
| 248 | if ( | 368 | if ( |
| 249 | thread.snoozedUntil != null && | 369 | thread.snoozedUntil != null && |
| 250 | (nextSnoozeWakeAt === null || | 370 | (nextSnoozeWakeAt === null || |
| ... | @@ -265,6 +385,17 @@ export function buildThreadListV2Items(input: { | ... | @@ -265,6 +385,17 @@ export function buildThreadListV2Items(input: { |
| 265 | } | 385 | } |
| 266 | 386 | ||
| 267 | const orderedActive = sortThreadsForListV2(active); | 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 | const orderedSettled = [...settled].sort( | 399 | const orderedSettled = [...settled].sort( |
| 269 | (left, right) => | 400 | (left, right) => |
| 270 | firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - | 401 | firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - |
| ... | @@ -276,13 +407,30 @@ export function buildThreadListV2Items(input: { | ... | @@ -276,13 +407,30 @@ export function buildThreadListV2Items(input: { |
| 276 | 407 | ||
| 277 | const items: ThreadListV2Item[] = []; | 408 | const items: ThreadListV2Item[] = []; |
| 278 | for (const thread of orderedActive) { | 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 | for (const [index, thread] of visibleSettled.entries()) { | 428 | for (const [index, thread] of visibleSettled.entries()) { |
| 282 | items.push({ | 429 | items.push({ |
| 283 | thread, | 430 | thread, |
| 284 | variant: "slim", | 431 | variant: "slim", |
| 285 | showSettledDivider: index === 0, | 432 | showSettledDivider: index === 0, |
| 433 | snoozed: false, | ||
| 286 | isLast: false, | 434 | isLast: false, |
| 287 | }); | 435 | }); |
| 288 | } | 436 | } |
| ... | @@ -293,7 +441,8 @@ export function buildThreadListV2Items(input: { | ... | @@ -293,7 +441,8 @@ export function buildThreadListV2Items(input: { |
| 293 | return { | 441 | return { |
| 294 | items, | 442 | items, |
| 295 | hiddenSettledCount: orderedSettled.length - visibleSettled.length, | 443 | hiddenSettledCount: orderedSettled.length - visibleSettled.length, |
| 296 | snoozedCount, | 444 | snoozedCount: orderedSnoozed.length, |
| 445 | snoozedShelfHeaderIndex, | ||
| 297 | nextSnoozeWakeAt, | 446 | nextSnoozeWakeAt, |
| 298 | }; | 447 | }; |
| 299 | } | 448 | } |
apps/web/src/components/Sidebar.snooze.test.ts+1-16| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 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 | // Local-time constructor so preset math is timezone-stable in tests. | 5 | // Local-time constructor so preset math is timezone-stable in tests. |
| 6 | function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date { | 6 | function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date { |
| ... | @@ -64,21 +64,6 @@ describe("resolveSnoozePresets", () => { | ... | @@ -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 | describe("snoozeWakeDescription", () => { | 67 | describe("snoozeWakeDescription", () => { |
| 83 | const now = localDate(2026, 4, 8, 10); | 68 | const now = localDate(2026, 4, 8, 10); |
| 84 | 69 |
apps/web/src/components/Sidebar.snooze.ts+7-105| ... | @@ -1,111 +1,13 @@ | ... | @@ -1,111 +1,13 @@ |
| 1 | /** | 1 | import { snoozeWakeLabel } from "@t3tools/client-runtime/state/thread-settled"; |
| 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 | */ | ||
| 9 | import { parseTimestampDate } from "../timestampFormat"; | 2 | import { parseTimestampDate } from "../timestampFormat"; |
| 10 | 3 | ||
| 11 | type SnoozePresetId = "hour" | "evening" | "tomorrow" | "next-week"; | 4 | export { |
| 12 | 5 | resolveSnoozePresets, | |
| 13 | export interface SnoozePreset { | 6 | type SnoozePreset, |
| 14 | readonly id: SnoozePresetId; | 7 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 15 | readonly label: string; | 8 | export { snoozeWakeLabel }; |
| 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 | } | ||
| 46 | 9 | ||
| 47 | /** | 10 | const DAY_MS = 24 * 60 * 60 * 1_000; |
| 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 | } | ||
| 109 | 11 | ||
| 110 | /** | 12 | /** |
| 111 | * Human wake time for menus and toasts: "tomorrow 9:00", "Mon 9:00", | 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,7 +2611,9 @@ export default function SidebarV2() { |
| 2611 | } | 2611 | } |
| 2612 | snoozeWakeLabelText={ | 2612 | snoozeWakeLabelText={ |
| 2613 | section === "snoozed" && thread.snoozedUntil != null | 2613 | section === "snoozed" && thread.snoozedUntil != null |
| 2614 | ? snoozeWakeLabel(thread.snoozedUntil, new Date()) | 2614 | ? snoozeWakeLabel(thread.snoozedUntil, { |
| 2615 | now: new Date().toISOString(), | ||
| 2616 | }) | ||
| 2615 | : null | 2617 | : null |
| 2616 | } | 2618 | } |
| 2617 | // All sections: a woken thread can classify straight | 2619 | // All sections: a woken thread can classify straight |
packages/client-runtime/src/state/threadSettled.ts+98| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. | ||
| 1 | import type { OrchestrationThreadShell } from "@t3tools/contracts"; | 2 | import type { OrchestrationThreadShell } from "@t3tools/contracts"; |
| 2 | 3 | ||
| 3 | export type ChangeRequestStateLike = "open" | "closed" | "merged"; | 4 | export type ChangeRequestStateLike = "open" | "closed" | "merged"; |
| ... | @@ -272,3 +273,100 @@ export function effectiveSettled( | ... | @@ -272,3 +273,100 @@ export function effectiveSettled( |
| 272 | Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS | 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,3 +1,4 @@ |
| 1 | // @effect-diagnostics globalDate:off -- Tests exercise local calendar snooze boundaries. | ||
| 1 | import { ThreadId } from "@t3tools/contracts"; | 2 | import { ThreadId } from "@t3tools/contracts"; |
| 2 | import { TurnId } from "@t3tools/contracts"; | 3 | import { TurnId } from "@t3tools/contracts"; |
| 3 | import { describe, expect, it } from "vite-plus/test"; | 4 | import { describe, expect, it } from "vite-plus/test"; |
| ... | @@ -5,6 +6,8 @@ import { describe, expect, it } from "vite-plus/test"; | ... | @@ -5,6 +6,8 @@ import { describe, expect, it } from "vite-plus/test"; |
| 5 | import { | 6 | import { |
| 6 | canSnooze, | 7 | canSnooze, |
| 7 | effectiveSnoozed, | 8 | effectiveSnoozed, |
| 9 | resolveSnoozePresets, | ||
| 10 | snoozeWakeLabel, | ||
| 8 | threadRaisedHandWhileSnoozed, | 11 | threadRaisedHandWhileSnoozed, |
| 9 | threadWokeAt, | 12 | threadWokeAt, |
| 10 | type ThreadSnoozeShell, | 13 | type ThreadSnoozeShell, |
| ... | @@ -15,6 +18,10 @@ const SNOOZED_AT = "2026-04-10T09:00:00.000Z"; | ... | @@ -15,6 +18,10 @@ const SNOOZED_AT = "2026-04-10T09:00:00.000Z"; |
| 15 | const FUTURE_WAKE = "2026-04-11T09:00:00.000Z"; | 18 | const FUTURE_WAKE = "2026-04-11T09:00:00.000Z"; |
| 16 | const PAST_WAKE = "2026-04-10T10:00:00.000Z"; | 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 | function makeShell(input: { | 25 | function makeShell(input: { |
| 19 | readonly snoozedUntil?: string | null; | 26 | readonly snoozedUntil?: string | null; |
| 20 | readonly snoozedAt?: string | null; | 27 | readonly snoozedAt?: string | null; |
| ... | @@ -235,3 +242,53 @@ describe("threadWokeAt", () => { | ... | @@ -235,3 +242,53 @@ describe("threadWokeAt", () => { |
| 235 | ).toBe("2026-04-10T09:30:00.000Z"); | 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 | }); |