authorgravatar for 30420087+gabrielelpidio@users.noreply.github.comGabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> 2026-07-31 10:22:52-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-31 10:22:52-04:00
loga041981276b4c789fed8132e3b8a320749bf25e8
treed8dc4bae1113191985a7f9d3a6c3a731b0afdd7e
parent964cc279014f512b4fc4c719377403417744ace2
signature Signed by PGP key B5690EEEBB952194

feat(mobile): add thread snoozing (#5053)


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 }, []);
3838
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";
44import { ThreadListV2PendingRow, ThreadListV2Row } from "../threads/thread-list-v2-items";44import {
45 ThreadListV2PendingRow,
46 ThreadListV2Row,
47 ThreadListV2SnoozedShelfHeader,
48} from "../threads/thread-list-v2-items";
45import {49import {
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 filter520 // 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-settle537 // 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 original584 // 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 );
626662
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 <ThreadListV2Row698 <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)) ?? null706 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 the1012 // Self-contained: v1's listEmpty keys off projectGroups, which ignores the
955 // v2 project scope, so it can be null (results elsewhere) while this list1013 // v2 project scope, so it can be null (results elsewhere) while this list
956 // is empty. Search outranks the scope — "No results" names the actionable1014 // is empty. Snoozed threads need no special empty state: their shelf header
957 // fact when a query is active. Snoozed threads outrank the rest: "No1015 // 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 <EmptyState1020 <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 @@
1import { SymbolView } from "../../components/AppSymbol";1import { SymbolView } from "../../components/AppSymbol";
2import { ControlPillMenu } from "../../components/ControlPill";
3import type { MenuAction } from "@react-native-menu/menu";
2import * as Haptics from "expo-haptics";4import * as Haptics from "expo-haptics";
3import {5import {
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};
4951
50interface ThreadSwipePrimaryAction {52interface 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}
5663
64interface ThreadSwipeSecondaryAction extends ThreadSwipeAction {
65 readonly backgroundColor: string;
66}
67
68function 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. */
73function 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 does114 * 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 deps115 * 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 Delete232 * What a full swipe commits. Omitted keeps the v1 Delete behavior only when
177 * button stretches) or "primary" — the advertised primary action fires and233 * the built-in Delete secondary action is in use; custom or absent
178 * its button stretches instead. A full swipe must always match the action234 * 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 this249 * 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 an250 * 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 <ThreadSwipeActions329 <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: {
290358
291function SwipeActionButton(props: {359function 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.stretchesOnFullSwipe400 const stretch = props.stretchesOnFullSwipe ? Math.max(reveal - props.actionsWidth, 0) : 0;
331 ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0)
332 : 0;
333401
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.stretchesOnFullSwipe409 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 }
359425
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 });
372438
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.View497 <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 <Pressable509 {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 <SwipeActionButton557 <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 <SwipeActionButton574 {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 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { canSettle } from "@t3tools/client-runtime/state/thread-settled";2import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled";
3import * as Cause from "effect/Cause";3import * as Cause from "effect/Cause";
4import * as Haptics from "expo-haptics";4import * as Haptics from "expo-haptics";
5import { useCallback, useRef } from "react";5import { 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}
2424
25function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["environmentId"]) {
26 return (
27 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
28 .threadSnooze === true
29 );
30}
31
25type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";32type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";
2633
27const ACTION_VERBS: Record<ThreadListAction, string> = {34const 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>());
198210
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(): {
213313
214 const confirmDeleteThread = useConfirmDeleteThread(executeAction);314 const confirmDeleteThread = useConfirmDeleteThread(executeAction);
215315
216 return { archiveThread, confirmDeleteThread, settleThread, unsettleThread };316 return {
317 archiveThread,
318 confirmDeleteThread,
319 settleThread,
320 snoozeThread,
321 unsnoozeThread,
322 unsettleThread,
323 };
217}324}
218325
219export function useArchivedThreadListActions(326export 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";
70import { ThreadListV2PendingRow, ThreadListV2Row } from "./thread-list-v2-items";70import {
71 ThreadListV2PendingRow,
72 ThreadListV2Row,
73 ThreadListV2SnoozedShelfHeader,
74} from "./thread-list-v2-items";
71import {75import {
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 actually443 // now ticks per minute so the inactivity auto-settle boundary is actually
432 // crossed while the pane stays open; without a clock dependency the444 // 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.showSettledDivider783 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.showPendingDivider794 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 <Pressable926 <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 as1091 // 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.isLoadingConnections1095 {catalogState.isLoadingConnections
1040 ? "Loading threads…"1096 ? "Loading threads…"
1041 : props.searchQuery.trim().length > 01097 : props.searchQuery.trim().length > 0
1042 ? threadSearch.isPending && snoozedCount === 01098 ? threadSearch.isPending
1043 ? "Searching thread messages…"1099 ? "Searching thread messages…"
1044 : snoozedCount > 01100 : "No matching threads"
1045 ? // Snoozed matches passed this same search filter — "No1101 : selectedProjectScope !== null
1046 // matching threads" would misreport them as nonexistent.1102 ? `No threads in ${selectedProjectScope.title}`
1047 snoozedCount === 11103 : "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 );
10601106
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";
5import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";5import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
6import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
6import type { MenuAction } from "@react-native-menu/menu";7import type { MenuAction } from "@react-native-menu/menu";
7import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react";8import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
8import { Platform, Pressable, useWindowDimensions, View } from "react-native";9import {
10 Alert,
11 Platform,
12 Pressable,
13 useColorScheme,
14 useWindowDimensions,
15 View,
16} from "react-native";
9import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";17import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
1018
19import { SymbolView } from "../../components/AppSymbol";
11import { AppText as Text } from "../../components/AppText";20import { AppText as Text } from "../../components/AppText";
12import { ControlPillMenu } from "../../components/ControlPill";21import { ControlPillMenu } from "../../components/ControlPill";
13import { ProjectFavicon } from "../../components/ProjectFavicon";22import { ProjectFavicon } from "../../components/ProjectFavicon";
...@@ -18,7 +27,13 @@ import { useThemeColor } from "../../lib/useThemeColor";...@@ -18,7 +27,13 @@ import { useThemeColor } from "../../lib/useThemeColor";
18import type { PendingNewTask } from "../../state/use-pending-new-tasks";27import type { PendingNewTask } from "../../state/use-pending-new-tasks";
19import { useThreadPr } from "../../state/use-thread-pr";28import { useThreadPr } from "../../state/use-thread-pr";
20import { ThreadSwipeable } from "../home/thread-swipe-actions";29import { ThreadSwipeable } from "../home/thread-swipe-actions";
21import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2";30import {
31 resolveThreadListV2SnoozeMenuSelection,
32 resolveThreadListV2SnoozeGateExpiryMs,
33 resolveThreadListV2Status,
34 resolveThreadListV2SwipeActions,
35 type ThreadListV2Status,
36} from "./threadListV2";
22import { ThreadSearchMatchExcerpt } from "./thread-search-match";37import { ThreadSearchMatchExcerpt } from "./thread-search-match";
2338
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];
6479
80const 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.
66const LEGACY_MENU_ACTIONS: MenuAction[] = [86const 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});
92112
113const SNOOZE_ACCENT_LIGHT = "#2563eb";
114const SNOOZE_ACCENT_DARK = "#60a5fa";
115
116export 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
93const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [152const 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-settle312 /** 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;
265339
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: {
283357
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. (Slim444 // Pre-settlement server: archive is the swipe action, as in v1. (Slim
304 // rows cannot occur here — unsupported environments never classify as445 // 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 canUnsettle455 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.`;
335505
336 // The sidebar pane fills selected rows with the accent color (matching the506 // 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 <Pressable625 <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 <Pressable663 <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 <Text721 <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 enableTrackpadSwipe751 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 <ControlPillMenu766 <ControlPillMenu
590 actions={767 actions={
591 !props.settlementSupported768 snoozedRow
592 ? LEGACY_MENU_ACTIONS769 ? SNOOZED_MENU_ACTIONS
593 : canUnsettle770 : !props.settlementSupported
594 ? SLIM_MENU_ACTIONS771 ? LEGACY_MENU_ACTIONS
595 : CARD_MENU_ACTIONS772 : canUnsettle
773 ? SLIM_MENU_ACTIONS
774 : swipeActions.secondary === "snooze"
775 ? snoozableCardMenuActions
776 : CARD_MENU_ACTIONS
596 }777 }
597 onPressAction={handleMenuAction}778 onPressAction={handleMenuAction}
598 shouldOpenOnLongPress779 shouldOpenOnLongPress
apps/mobile/src/features/threads/threadListV2.test.ts+280-1
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";2import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
3import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
3import {4import {
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";
2226
...@@ -50,6 +54,54 @@ function makeThread(...@@ -50,6 +54,54 @@ function makeThread(
5054
51const NOW = "2026-06-02T00:00:00.000Z";55const NOW = "2026-06-02T00:00:00.000Z";
5256
57describe("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
53describe("resolveThreadListV2Enabled", () => {105describe("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});
98150
151describe("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
217describe("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
99describe("sortThreadsForListV2", () => {250describe("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 });
169320
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", () => {
424662
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 @@
1import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";1import {
2 effectiveSettled,
3 effectiveSnoozed,
4 hasQueuedTurnStart,
5 QUEUED_TURN_START_GRACE_MS,
6 resolveSnoozePresets,
7 snoozeWakeLabel,
8} from "@t3tools/client-runtime/state/thread-settled";
9import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";10import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";11import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
4import type { EnvironmentId, ProjectId } from "@t3tools/contracts";12import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
513
6import type { PendingNewTask } from "../../state/use-pending-new-tasks";14import type { PendingNewTask } from "../../state/use-pending-new-tasks";
715
16export { snoozeWakeLabel };
17
8/**18/**
9 * Thread List v2 model, ported from the web sidebar v219 * 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 */
16export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";26export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";
27export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";
28
29export 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
53export 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 */
84export 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}
1797
18// Settled-tail paging: recent history is the common lookup; the deep tail98// 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 and99// 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}
105187
...@@ -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's192 /** 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 arm194 /** 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 a198 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}
124210
125export interface ThreadListV2PendingListItem {211export 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}
132218
133export type ThreadListV2ListItem = ThreadListV2ThreadListItem | ThreadListV2PendingListItem;219export interface ThreadListV2SnoozedShelfListItem {
220 readonly type: "v2-snoozed-shelf";
221 readonly key: "v2-snoozed-shelf";
222 readonly count: number;
223 readonly expanded: boolean;
224}
225
226export type ThreadListV2ListItem =
227 | ThreadListV2ThreadListItem
228 | ThreadListV2PendingListItem
229 | ThreadListV2SnoozedShelfListItem;
134230
135/**231/**
136 * Splices queued tasks between the active block and the settled tail, so the232 * Builds the shared mobile order: active → pending → snoozed shelf → settled.
137 * list reads active → pending → settled. Queued work sits below the live233 * 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 */
142export function buildThreadListV2ListItems(input: {236export 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; with263 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 a319 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: {
215335
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 them341 // 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/failed364 // 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 }
266386
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: {
276407
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 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { resolveSnoozePresets, snoozeWakeDescription, snoozeWakeLabel } from "./Sidebar.snooze";3import { resolveSnoozePresets, snoozeWakeDescription } from "./Sidebar.snooze";
44
5// Local-time constructor so preset math is timezone-stable in tests.5// Local-time constructor so preset math is timezone-stable in tests.
6function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date {6function 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});
6666
67describe("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
82describe("snoozeWakeDescription", () => {67describe("snoozeWakeDescription", () => {
83 const now = localDate(2026, 4, 8, 10);68 const now = localDate(2026, 4, 8, 10);
8469
apps/web/src/components/Sidebar.snooze.ts+7-105
...@@ -1,111 +1,13 @@...@@ -1,111 +1,13 @@
1/**1import { 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 */
9import { parseTimestampDate } from "../timestampFormat";2import { parseTimestampDate } from "../timestampFormat";
103
11type SnoozePresetId = "hour" | "evening" | "tomorrow" | "next-week";4export {
125 resolveSnoozePresets,
13export interface SnoozePreset {6 type SnoozePreset,
14 readonly id: SnoozePresetId;7} from "@t3tools/client-runtime/state/thread-settled";
15 readonly label: string;8export { 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
23function timeOfDayLabel(date: Date): string {
24 return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
25}
26
27const EVENING_HOUR = 18;
28const MORNING_HOUR = 9;
29const HOUR_MS = 60 * 60 * 1_000;
30const DAY_MS = 24 * HOUR_MS;
31
32function 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).
41function addDays(base: Date, days: number): Date {
42 const next = new Date(base);
43 next.setDate(next.getDate() + days);
44 return next;
45}
469
47/**10const 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 */
52export 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 */
100export 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}
10911
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 != null2613 section === "snoozed" && thread.snoozedUntil != null
2614 ? snoozeWakeLabel(thread.snoozedUntil, new Date())2614 ? snoozeWakeLabel(thread.snoozedUntil, {
2615 now: new Date().toISOString(),
2616 })
2615 : null2617 : null
2616 }2618 }
2617 // All sections: a woken thread can classify straight2619 // 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.
1import type { OrchestrationThreadShell } from "@t3tools/contracts";2import type { OrchestrationThreadShell } from "@t3tools/contracts";
23
3export type ChangeRequestStateLike = "open" | "closed" | "merged";4export 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_MS273 Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS
273 );274 );
274}275}
276
277const HOUR_MS = 60 * 60 * 1_000;
278const EVENING_HOUR = 18;
279const MORNING_HOUR = 9;
280
281export type SnoozePresetId = "hour" | "evening" | "tomorrow" | "next-week";
282
283export 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
293function snoozeTimeOfDayLabel(date: Date): string {
294 return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
295}
296
297function 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).
306function 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 */
317export 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 */
363export 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.
1import { ThreadId } from "@t3tools/contracts";2import { ThreadId } from "@t3tools/contracts";
2import { TurnId } from "@t3tools/contracts";3import { TurnId } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";4import { 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";
5import {6import {
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";
15const FUTURE_WAKE = "2026-04-11T09:00:00.000Z";18const FUTURE_WAKE = "2026-04-11T09:00:00.000Z";
16const PAST_WAKE = "2026-04-10T10:00:00.000Z";19const PAST_WAKE = "2026-04-10T10:00:00.000Z";
1720
21function 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
18function makeShell(input: {25function 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
246describe("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
263describe("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});