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 {
2222 IconChevronUp,
2323 IconCircleCheck,
2424 IconCircleXFilled,
25 IconClock,
2526 IconCopy,
2627 IconDeviceDesktop,
2728 IconDots,
......@@ -96,6 +97,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
9697 camera: IconCamera,
9798 checkmark: IconCheck,
9899 "checkmark.circle": IconCircleCheck,
100 clock: IconClock,
99101 "chevron.down": IconChevronDown,
100102 "chevron.left": IconChevronLeft,
101103 "chevron.left.forwardslash.chevron.right": IconCode,
apps/mobile/src/features/home/HomeRouteScreen.tsx+10-2
......@@ -36,8 +36,14 @@ export function HomeRouteScreen() {
3636 void checkForAppUpdateOnLaunch();
3737 }, []);
3838
39 const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } =
40 useThreadListActions();
39 const {
40 archiveThread,
41 confirmDeleteThread,
42 settleThread,
43 snoozeThread,
44 unsnoozeThread,
45 unsettleThread,
46 } = useThreadListActions();
4147 const pendingTasks = usePendingNewTasks();
4248 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
4349 const environments = useMemo(() => {
......@@ -146,6 +152,8 @@ export function HomeRouteScreen() {
146152 onArchiveThread={archiveThread}
147153 onDeleteThread={confirmDeleteThread}
148154 onSettleThread={settleThread}
155 onSnoozeThread={snoozeThread}
156 onUnsnoozeThread={unsnoozeThread}
149157 onUnsettleThread={unsettleThread}
150158 onEnvironmentChange={setSelectedEnvironmentId}
151159 onProjectChange={setSelectedProjectKey}
apps/mobile/src/features/home/HomeScreen.tsx+65-27
......@@ -41,7 +41,11 @@ import {
4141 ThreadListRow,
4242 ThreadListShowMoreRow,
4343} 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";
4549import {
4650 buildThreadListV2Items,
4751 buildThreadListV2ListItems,
......@@ -100,6 +104,11 @@ interface HomeScreenProps {
100104 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
101105 /** Resolves true iff the settle was dispatched and succeeded. */
102106 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>;
103112 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
104113 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
105114 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
......@@ -494,6 +503,18 @@ export function HomeScreen(props: HomeScreenProps) {
494503 },
495504 [props.onSettleThread],
496505 );
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 );
497518 const handleDeleteThread = props.onDeleteThread;
498519 const handleUnsettleThread = props.onUnsettleThread;
499520 // The settled tail renders in pages; expansion resets when the filter
......@@ -511,6 +532,8 @@ export function HomeScreen(props: HomeScreenProps) {
511532 () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT),
512533 [],
513534 );
535 const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false);
536 const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []);
514537 // now is quantized to the minute and ticks so the inactivity auto-settle
515538 // boundary is actually crossed while the app stays open (mirrors web);
516539 // without a clock dependency the partition memoizes a frozen "now".
......@@ -551,7 +574,13 @@ export function HomeScreen(props: HomeScreenProps) {
551574 }, [serverConfigs]);
552575 const threadListV2Layout = useMemo(() => {
553576 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 };
555584 // Settled threads are live shells; archived threads keep their original
556585 // "hidden from lists" meaning.
557586 return buildThreadListV2Items({
......@@ -566,11 +595,14 @@ export function HomeScreen(props: HomeScreenProps) {
566595 settledLimit: settledVisibleCount,
567596 now: `${nowMinute}:00.000Z`,
568597 snoozeNow: new Date().toISOString(),
598 snoozedShelfExpanded,
599 selectedThreadKey: null,
569600 });
570601 }, [
571602 changeRequestStateByKey,
572603 nowMinute,
573604 snoozeWakeTick,
605 snoozedShelfExpanded,
574606 settledVisibleCount,
575607 settlementEnvironmentIds,
576608 snoozeEnvironmentIds,
......@@ -620,8 +652,12 @@ export function HomeScreen(props: HomeScreenProps) {
620652 buildThreadListV2ListItems({
621653 items: threadListV2Layout.items,
622654 pendingTasks: v2PendingTasks,
655 snoozedCount: threadListV2Layout.snoozedCount,
656 snoozedShelfExpanded,
657 snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex,
658 snoozeLabelNow: `${nowMinute}:00.000Z`,
623659 }),
624 [threadListV2Layout.items, v2PendingTasks],
660 [snoozedShelfExpanded, threadListV2Layout, v2PendingTasks],
625661 );
626662
627663 const renderV2Item = useCallback(
......@@ -648,12 +684,24 @@ export function HomeScreen(props: HomeScreenProps) {
648684 />
649685 );
650686 }
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 }
651696 const thread = item.item.thread;
652697 return (
653698 <ThreadListV2Row
654699 thread={thread}
655700 variant={item.item.variant}
656701 showSettledDivider={item.item.showSettledDivider}
702 snoozed={item.item.snoozed}
703 snoozePresetMinute={nowMinute}
704 snoozeWakeLabelText={item.snoozeWakeLabelText}
657705 project={
658706 projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
659707 }
......@@ -686,6 +734,9 @@ export function HomeScreen(props: HomeScreenProps) {
686734 onArchiveThread={props.onArchiveThread}
687735 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
688736 onSettleThread={handleSettleThread}
737 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
738 onSnoozeThread={handleSnoozeThread}
739 onUnsnoozeThread={handleUnsnoozeThread}
689740 onUnsettleThread={handleUnsettleThread}
690741 onChangeRequestState={handleChangeRequestState}
691742 projectCwd={
......@@ -700,6 +751,8 @@ export function HomeScreen(props: HomeScreenProps) {
700751 handleChangeRequestState,
701752 handleDeleteThread,
702753 handleSettleThread,
754 handleSnoozeThread,
755 handleUnsnoozeThread,
703756 handleSwipeableClose,
704757 handleSwipeableWillOpen,
705758 handleUnsettleThread,
......@@ -712,9 +765,12 @@ export function HomeScreen(props: HomeScreenProps) {
712765 props.savedConnectionsById,
713766 serverConfigs,
714767 settlementEnvironmentIds,
768 snoozeEnvironmentIds,
715769 threadSearchMatchByKey,
770 toggleSnoozedShelf,
716771 v2ProjectTitleByProjectKey,
717772 props.searchQuery,
773 nowMinute,
718774 ],
719775 );
720776 const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []);
......@@ -730,6 +786,7 @@ export function HomeScreen(props: HomeScreenProps) {
730786 serverConfigs,
731787 savedConnectionsById: props.savedConnectionsById,
732788 searchQuery: props.searchQuery,
789 snoozePresetMinute: nowMinute,
733790 threadSearchMatchByKey,
734791 }),
735792 [
......@@ -738,6 +795,7 @@ export function HomeScreen(props: HomeScreenProps) {
738795 props.searchQuery,
739796 props.savedConnectionsById,
740797 serverConfigs,
798 nowMinute,
741799 threadSearchMatchByKey,
742800 v2ProjectTitleByProjectKey,
743801 ],
......@@ -953,31 +1011,11 @@ export function HomeScreen(props: HomeScreenProps) {
9531011 ) : null;
9541012 // Self-contained: v1's listEmpty keys off projectGroups, which ignores the
9551013 // v2 project scope, so it can be null (results elsewhere) while this list
956 // is empty. Search outranks the scope — "No results" names the actionable
957 // fact when a query is active. Snoozed threads outrank the rest: "No
958 // threads yet" over an inbox that is merely all-snoozed reads as data
959 // loss.
960 const v2SnoozedCount = threadListV2Layout.snoozedCount;
1014 // is empty. Snoozed threads need no special empty state: their shelf header
1015 // is a list row even while collapsed.
9611016 const v2ListEmpty =
962 hasSearchQuery && threadSearch.isPending && v2SnoozedCount === 0 ? null : hasSearchQuery ? (
963 v2SnoozedCount > 0 ? (
964 // The snoozed threads already passed this search filter: "No
965 // results" would claim nothing matched when matches are merely
966 // parked.
967 <EmptyState
968 title={
969 v2SnoozedCount === 1 ? "1 matching thread snoozed" : `All matching threads snoozed`
970 }
971 detail={`Threads matching "${props.searchQuery}" are snoozed and return when their wake time passes.`}
972 />
973 ) : (
974 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
975 )
976 ) : v2SnoozedCount > 0 ? (
977 <EmptyState
978 title={v2SnoozedCount === 1 ? "1 thread snoozed" : `${v2SnoozedCount} threads snoozed`}
979 detail="Snoozed threads return when their wake time passes."
980 />
1017 hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? (
1018 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
9811019 ) : v2ScopedProjectGroup !== null ? (
9821020 <EmptyState
9831021 title={`No threads in ${v2ScopedProjectGroup.title}`}
apps/mobile/src/features/home/thread-swipe-actions.tsx+184-97
......@@ -1,4 +1,6 @@
11import { SymbolView } from "../../components/AppSymbol";
2import { ControlPillMenu } from "../../components/ControlPill";
3import type { MenuAction } from "@react-native-menu/menu";
24import * as Haptics from "expo-haptics";
35import {
46 createContext,
......@@ -47,13 +49,67 @@ export const THREAD_SWIPE_SPRING = {
4749 stiffness: 330,
4850};
4951
50interface ThreadSwipePrimaryAction {
52interface ThreadSwipeAction {
5153 readonly accessibilityLabel: string;
5254 readonly icon: ComponentProps<typeof SymbolView>["name"];
5355 readonly label: string;
56 readonly menu?: {
57 readonly actions: MenuAction[];
58 readonly onPressAction: NonNullable<ComponentProps<typeof ControlPillMenu>["onPressAction"]>;
59 readonly title?: string;
60 };
5461 readonly onPress: () => void;
5562}
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
57113/**
58114 * Delivers the scroll gate to swipeables via context so that flipping it does
59115 * NOT re-render whole rows: putting the flag in list extraData/renderItem deps
......@@ -173,17 +229,22 @@ export function ThreadSwipeable(props: {
173229 readonly enabled?: boolean;
174230 readonly enableTrackpadSwipe?: boolean;
175231 /**
176 * What a full swipe commits: "delete" (default, v1 behavior — the Delete
177 * button stretches) or "primary" — the advertised primary action fires and
178 * its button stretches instead. A full swipe must always match the action
179 * the stretching button advertises.
232 * What a full swipe commits. Omitted keeps the v1 Delete behavior only when
233 * the built-in Delete secondary action is in use; custom or absent
234 * secondary actions default to the advertised primary action.
180235 */
181236 readonly fullSwipeAction?: "delete" | "primary";
182237 readonly fullSwipeWidth: number;
183238 readonly onDelete: () => void;
184239 readonly onSwipeableClose?: (methods: SwipeableMethods) => void;
185240 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;
187248 /**
188249 * Identity of the content being wrapped. When a recycled list reuses this
189250 * component for a different item, the swipeable snaps back to closed so an
......@@ -197,7 +258,11 @@ export function ThreadSwipeable(props: {
197258}) {
198259 const swipeableRef = useRef<SwipeableMethods | null>(null);
199260 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");
201266 const close = useCallback(() => swipeableRef.current?.close(), []);
202267 const gateEnabled = use(SwipeableScrollGateContext);
203268 const resetKey = props.resetKey;
......@@ -251,7 +316,7 @@ export function ThreadSwipeable(props: {
251316 if (fullSwipeArmedRef.current) {
252317 fullSwipeArmedRef.current = false;
253318 methods.close();
254 if (props.fullSwipeAction === "primary") {
319 if (fullSwipeAction === "primary") {
255320 props.primaryAction.onPress();
256321 } else {
257322 props.onDelete();
......@@ -264,9 +329,8 @@ export function ThreadSwipeable(props: {
264329 <ThreadSwipeActions
265330 backgroundColor={props.backgroundColor}
266331 compact={props.compactActions === true}
267 fullSwipeAction={props.fullSwipeAction ?? "delete"}
332 fullSwipeAction={fullSwipeAction}
268333 fullSwipeThreshold={fullSwipeThreshold}
269 onDelete={props.onDelete}
270334 onFullSwipeArmedChange={handleFullSwipeArmedChange}
271335 primaryAction={{
272336 ...props.primaryAction,
......@@ -275,12 +339,16 @@ export function ThreadSwipeable(props: {
275339 props.primaryAction.onPress();
276340 },
277341 }}
278 swipeableMethods={methods}
279 threadTitle={props.threadTitle}
342 secondaryAction={resolveSecondaryAction({
343 close: () => methods.close(),
344 onDelete: props.onDelete,
345 secondaryAction: props.secondaryAction,
346 threadTitle: props.threadTitle,
347 })}
280348 translation={translation}
281349 />
282350 )}
283 rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42}
351 rightThreshold={actionsWidth * 0.42}
284352 simultaneousWithExternalGesture={props.simultaneousWithExternalGesture}
285353 >
286354 {props.children(close)}
......@@ -290,12 +358,14 @@ export function ThreadSwipeable(props: {
290358
291359function SwipeActionButton(props: {
292360 readonly accessibilityLabel: string;
361 readonly actionsWidth: number;
293362 readonly backgroundColor: string;
294363 readonly compact: boolean;
295364 readonly entryRange: readonly [number, number];
296365 readonly fullSwipeThreshold: number;
297366 readonly icon: ComponentProps<typeof SymbolView>["name"];
298367 readonly label: string;
368 readonly menu?: ThreadSwipeAction["menu"];
299369 readonly onPress: () => void;
300370 readonly stretchesOnFullSwipe: boolean;
301371 readonly translation: SharedValue<number>;
......@@ -305,10 +375,10 @@ function SwipeActionButton(props: {
305375 const actionStyle = useAnimatedStyle(() => {
306376 const reveal = Math.max(-props.translation.value, 0);
307377 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);
309379 const fullSwipeProgress = interpolate(
310380 reveal,
311 [THREAD_SWIPE_ACTIONS_WIDTH, props.fullSwipeThreshold + 20],
381 [props.actionsWidth, props.fullSwipeThreshold + 20],
312382 [0, 1],
313383 Extrapolation.CLAMP,
314384 );
......@@ -327,9 +397,7 @@ function SwipeActionButton(props: {
327397 });
328398 const circleStyle = useAnimatedStyle(() => {
329399 const reveal = Math.max(-props.translation.value, 0);
330 const stretch = props.stretchesOnFullSwipe
331 ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0)
332 : 0;
400 const stretch = props.stretchesOnFullSwipe ? Math.max(reveal - props.actionsWidth, 0) : 0;
333401
334402 return {
335403 transform: [{ translateX: -stretch }],
......@@ -338,9 +406,7 @@ function SwipeActionButton(props: {
338406 });
339407 const iconStyle = useAnimatedStyle(() => {
340408 const reveal = Math.max(-props.translation.value, 0);
341 const stretch = props.stretchesOnFullSwipe
342 ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0)
343 : 0;
409 const stretch = props.stretchesOnFullSwipe ? Math.max(reveal - props.actionsWidth, 0) : 0;
344410 const armedProgress = interpolate(
345411 reveal,
346412 [props.fullSwipeThreshold, props.fullSwipeThreshold + 20],
......@@ -358,7 +424,7 @@ function SwipeActionButton(props: {
358424 }
359425
360426 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);
362428 return {
363429 opacity: interpolate(
364430 reveal,
......@@ -370,6 +436,63 @@ function SwipeActionButton(props: {
370436 };
371437 });
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
373496 return (
374497 <Animated.View
375498 style={[
......@@ -383,60 +506,18 @@ function SwipeActionButton(props: {
383506 actionStyle,
384507 ]}
385508 >
386 <Pressable
387 accessibilityLabel={props.accessibilityLabel}
388 accessibilityRole="button"
389 onPress={props.onPress}
390 style={({ pressed }) => ({
391 alignItems: "center",
392 height: "100%",
393 justifyContent: "center",
394 opacity: pressed ? 0.72 : 1,
395 width: "100%",
396 })}
397 >
398 <View style={{ height: circleSize, width: circleSize }}>
399 <Animated.View
400 style={[
401 {
402 backgroundColor: props.backgroundColor,
403 borderRadius: 999,
404 height: circleSize,
405 left: 0,
406 position: "absolute",
407 top: 0,
408 },
409 circleStyle,
410 ]}
411 />
412 <Animated.View
413 style={[
414 {
415 alignItems: "center",
416 height: circleSize,
417 justifyContent: "center",
418 left: 0,
419 position: "absolute",
420 top: 0,
421 width: circleSize,
422 },
423 iconStyle,
424 ]}
425 >
426 <SymbolView name={props.icon} size={iconSize} tintColor="#ffffff" type="monochrome" />
427 </Animated.View>
428 </View>
429 <Animated.View
430 style={[
431 { height: 14, justifyContent: "center", paddingTop: props.compact ? 0 : 2 },
432 labelStyle,
433 ]}
509 {props.menu === undefined ? (
510 button
511 ) : (
512 <ControlPillMenu
513 actions={props.menu.actions}
514 onPressAction={props.menu.onPressAction}
515 title={props.menu.title}
516 style={{ height: "100%", width: "100%" }}
434517 >
435 <Text className="text-3xs font-t3-medium text-foreground-muted" numberOfLines={1}>
436 {props.label}
437 </Text>
438 </Animated.View>
439 </Pressable>
518 {button}
519 </ControlPillMenu>
520 )}
440521 </Animated.View>
441522 );
442523}
......@@ -446,14 +527,14 @@ export function ThreadSwipeActions(props: {
446527 readonly compact: boolean;
447528 readonly fullSwipeAction?: "delete" | "primary";
448529 readonly fullSwipeThreshold: number;
449 readonly onDelete: () => void;
450530 readonly onFullSwipeArmedChange: (armed: boolean) => void;
451 readonly primaryAction: ThreadSwipePrimaryAction;
452 readonly swipeableMethods: SwipeableMethods;
453 readonly threadTitle: string;
531 readonly primaryAction: ThreadSwipeAction;
532 readonly secondaryAction: ThreadSwipeSecondaryAction | null;
454533 readonly translation: SharedValue<number>;
455534}) {
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);
457538 useAnimatedReaction(
458539 () => -props.translation.value >= props.fullSwipeThreshold,
459540 (armed, previous) => {
......@@ -470,14 +551,19 @@ export function ThreadSwipeActions(props: {
470551 backgroundColor: props.backgroundColor,
471552 flexDirection: "row",
472553 height: "100%",
473 width: THREAD_SWIPE_ACTIONS_WIDTH,
554 width: actionsWidth,
474555 }}
475556 >
476557 <SwipeActionButton
477558 accessibilityLabel={props.primaryAction.accessibilityLabel}
559 actionsWidth={actionsWidth}
478560 backgroundColor="#007aff"
479561 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 }
481567 fullSwipeThreshold={props.fullSwipeThreshold}
482568 icon={props.primaryAction.icon}
483569 label={props.primaryAction.label}
......@@ -485,21 +571,22 @@ export function ThreadSwipeActions(props: {
485571 stretchesOnFullSwipe={fullSwipeIsPrimary}
486572 translation={props.translation}
487573 />
488 <SwipeActionButton
489 accessibilityLabel={`Delete ${props.threadTitle}`}
490 backgroundColor="#ff2d55"
491 compact={props.compact}
492 entryRange={[8, ACTION_ITEM_WIDTH * 0.72]}
493 fullSwipeThreshold={props.fullSwipeThreshold}
494 icon="trash"
495 label="Delete"
496 onPress={() => {
497 props.swipeableMethods.close();
498 props.onDelete();
499 }}
500 stretchesOnFullSwipe={!fullSwipeIsPrimary}
501 translation={props.translation}
502 />
574 {secondaryAction === null ? null : (
575 <SwipeActionButton
576 accessibilityLabel={secondaryAction.accessibilityLabel}
577 actionsWidth={actionsWidth}
578 backgroundColor={secondaryAction.backgroundColor}
579 compact={props.compact}
580 entryRange={[8, ACTION_ITEM_WIDTH * 0.72]}
581 fullSwipeThreshold={props.fullSwipeThreshold}
582 icon={secondaryAction.icon}
583 label={secondaryAction.label}
584 menu={secondaryAction.menu}
585 onPress={secondaryAction.onPress}
586 stretchesOnFullSwipe={!fullSwipeIsPrimary}
587 translation={props.translation}
588 />
589 )}
503590 </View>
504591 );
505592}
apps/mobile/src/features/home/useThreadListActions.ts+109-2
......@@ -1,5 +1,5 @@
11import 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";
33import * as Cause from "effect/Cause";
44import * as Haptics from "expo-haptics";
55import { useCallback, useRef } from "react";
......@@ -22,6 +22,13 @@ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["en
2222 );
2323}
2424
25function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["environmentId"]) {
26 return (
27 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
28 .threadSnooze === true
29 );
30}
31
2532type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";
2633
2734const ACTION_VERBS: Record<ThreadListAction, string> = {
......@@ -192,9 +199,14 @@ export function useThreadListActions(): {
192199 readonly archiveThread: (thread: EnvironmentThreadShell) => void;
193200 readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void;
194201 readonly settleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
202 readonly snoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => Promise<boolean>;
203 readonly unsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
195204 readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
196205} {
197206 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
199211 const archiveThread = useCallback(
200212 (thread: EnvironmentThreadShell) => {
......@@ -206,6 +218,94 @@ export function useThreadListActions(): {
206218 async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true,
207219 [executeAction],
208220 );
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 );
209309 const unsettleThread = useCallback(
210310 async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true,
211311 [executeAction],
......@@ -213,7 +313,14 @@ export function useThreadListActions(): {
213313
214314 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 };
217324}
218325
219326export function useArchivedThreadListActions(
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+70-24
......@@ -67,7 +67,11 @@ import {
6767 ThreadListRow,
6868 ThreadListShowMoreRow,
6969} 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";
7175import {
7276 buildThreadListV2Items,
7377 buildThreadListV2ListItems,
......@@ -193,8 +197,14 @@ function ThreadNavigationSidebarPane(
193197 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
194198 const headerIsOverContentRef = useRef(false);
195199 const sidebarScrollGesture = useMemo(() => Gesture.Native(), []);
196 const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } =
197 useThreadListActions();
200 const {
201 archiveThread,
202 confirmDeleteThread,
203 settleThread,
204 snoozeThread,
205 unsnoozeThread,
206 unsettleThread,
207 } = useThreadListActions();
198208 const threadListV2Enabled = useThreadListV2Enabled();
199209 const pendingTasks = usePendingNewTasks();
200210 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
......@@ -428,6 +438,8 @@ function ThreadNavigationSidebarPane(
428438 () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT),
429439 [],
430440 );
441 const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false);
442 const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []);
431443 // now ticks per minute so the inactivity auto-settle boundary is actually
432444 // crossed while the pane stays open; without a clock dependency the
433445 // partition memoizes a frozen "now".
......@@ -468,7 +480,13 @@ function ThreadNavigationSidebarPane(
468480 }, [serverConfigs]);
469481 const threadListV2Layout = useMemo(() => {
470482 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 };
472490 return buildThreadListV2Items({
473491 threads: threads.filter((thread) => thread.archivedAt === null),
474492 environmentId: options.selectedEnvironmentId,
......@@ -481,11 +499,15 @@ function ThreadNavigationSidebarPane(
481499 settledLimit: settledVisibleCount,
482500 now: `${nowMinute}:00.000Z`,
483501 snoozeNow: new Date().toISOString(),
502 snoozedShelfExpanded,
503 selectedThreadKey: props.selectedThreadKey ?? null,
484504 });
485505 }, [
486506 changeRequestStateByKey,
487507 nowMinute,
488508 snoozeWakeTick,
509 snoozedShelfExpanded,
510 props.selectedThreadKey,
489511 options.selectedEnvironmentId,
490512 props.searchQuery,
491513 matchedThreadKeys,
......@@ -532,6 +554,10 @@ function ThreadNavigationSidebarPane(
532554 const items: SidebarListItem[] = buildThreadListV2ListItems({
533555 items: threadListV2Layout.items,
534556 pendingTasks: v2PendingTasks,
557 snoozedCount: threadListV2Layout.snoozedCount,
558 snoozedShelfExpanded,
559 snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex,
560 snoozeLabelNow: `${nowMinute}:00.000Z`,
535561 });
536562 if (threadListV2Layout.hiddenSettledCount > 0) {
537563 items.push({
......@@ -543,10 +569,12 @@ function ThreadNavigationSidebarPane(
543569 return items;
544570 }, [
545571 listLayout.items,
572 nowMinute,
546573 options.selectedEnvironmentId,
547574 pendingTasks,
548575 props.searchQuery,
549576 selectedProjectRefs,
577 snoozedShelfExpanded,
550578 threadListV2Enabled,
551579 threadListV2Layout,
552580 ]);
......@@ -731,6 +759,7 @@ function ThreadNavigationSidebarPane(
731759 projectTitleByProjectKey,
732760 savedConnectionsById,
733761 serverConfigs,
762 snoozePresetMinute: nowMinute,
734763 threadSearchMatchByKey,
735764 }),
736765 [
......@@ -740,6 +769,7 @@ function ThreadNavigationSidebarPane(
740769 projectTitleByProjectKey,
741770 savedConnectionsById,
742771 serverConfigs,
772 nowMinute,
743773 threadSearchMatchByKey,
744774 ],
745775 );
......@@ -750,7 +780,9 @@ function ThreadNavigationSidebarPane(
750780 previous.key === item.key &&
751781 previous.item.thread === item.item.thread &&
752782 previous.item.variant === item.item.variant &&
753 previous.item.showSettledDivider === item.item.showSettledDivider
783 previous.item.showSettledDivider === item.item.showSettledDivider &&
784 previous.item.snoozed === item.item.snoozed &&
785 previous.snoozeWakeLabelText === item.snoozeWakeLabelText
754786 );
755787 }
756788 if (previous.type === "v2-show-more" && item.type === "v2-show-more") {
......@@ -762,13 +794,18 @@ function ThreadNavigationSidebarPane(
762794 previous.showPendingDivider === item.showPendingDivider
763795 );
764796 }
797 if (previous.type === "v2-snoozed-shelf" && item.type === "v2-snoozed-shelf") {
798 return previous.count === item.count && previous.expanded === item.expanded;
799 }
765800 if (
766801 previous.type === "v2-thread" ||
767802 previous.type === "v2-show-more" ||
768803 previous.type === "v2-pending" ||
804 previous.type === "v2-snoozed-shelf" ||
769805 item.type === "v2-thread" ||
770806 item.type === "v2-show-more" ||
771 item.type === "v2-pending"
807 item.type === "v2-pending" ||
808 item.type === "v2-snoozed-shelf"
772809 ) {
773810 return false;
774811 }
......@@ -827,6 +864,9 @@ function ThreadNavigationSidebarPane(
827864 thread={thread}
828865 variant={item.item.variant}
829866 showSettledDivider={item.item.showSettledDivider}
867 snoozed={item.item.snoozed}
868 snoozePresetMinute={nowMinute}
869 snoozeWakeLabelText={item.snoozeWakeLabelText}
830870 project={projectByKey.get(scopeKey) ?? null}
831871 projectTitle={projectTitleByProjectKey.get(scopeKey)}
832872 providerDriver={
......@@ -860,6 +900,9 @@ function ThreadNavigationSidebarPane(
860900 onArchiveThread={archiveThread}
861901 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
862902 onSettleThread={settleThread}
903 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
904 onSnoozeThread={snoozeThread}
905 onUnsnoozeThread={unsnoozeThread}
863906 onUnsettleThread={unsettleThread}
864907 onChangeRequestState={handleChangeRequestState}
865908 projectCwd={projectCwdByKey.get(scopeKey) ?? null}
......@@ -869,6 +912,15 @@ function ThreadNavigationSidebarPane(
869912 />
870913 );
871914 }
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 );
872924 case "v2-show-more":
873925 return (
874926 <Pressable
......@@ -984,7 +1036,12 @@ function ThreadNavigationSidebarPane(
9841036 settlementEnvironmentIds,
9851037 showMoreSettled,
9861038 sidebarScrollGesture,
1039 snoozeEnvironmentIds,
1040 snoozeThread,
1041 nowMinute,
1042 toggleSnoozedShelf,
9871043 unsettleThread,
1044 unsnoozeThread,
9881045 updateGroupDisplay,
9891046 ],
9901047 );
......@@ -1031,30 +1088,19 @@ function ThreadNavigationSidebarPane(
10311088 }),
10321089 [filterIcon, filterMenu, props.onOpenSettings],
10331090 );
1034 // "No threads yet" over an inbox that is merely all-snoozed reads as
1035 // data loss; name the snoozed threads instead.
1036 const snoozedCount = threadListV2Layout.snoozedCount;
1091 // Snoozed threads need no special case: the shelf header is a list row
1092 // even while collapsed.
10371093 const listEmpty = (
10381094 <Text className="px-2 py-4 text-sm text-foreground-muted">
10391095 {catalogState.isLoadingConnections
10401096 ? "Loading threads…"
10411097 : props.searchQuery.trim().length > 0
1042 ? threadSearch.isPending && snoozedCount === 0
1098 ? threadSearch.isPending
10431099 ? "Searching thread messages…"
1044 : snoozedCount > 0
1045 ? // Snoozed matches passed this same search filter — "No
1046 // matching threads" would misreport them as nonexistent.
1047 snoozedCount === 1
1048 ? "1 matching thread snoozed"
1049 : "All matching threads snoozed"
1050 : "No matching threads"
1051 : snoozedCount > 0
1052 ? snoozedCount === 1
1053 ? "1 thread snoozed"
1054 : `${snoozedCount} threads snoozed`
1055 : selectedProjectScope !== null
1056 ? `No threads in ${selectedProjectScope.title}`
1057 : "No threads yet"}
1100 : "No matching threads"
1101 : selectedProjectScope !== null
1102 ? `No threads in ${selectedProjectScope.title}`
1103 : "No threads yet"}
10581104 </Text>
10591105 );
10601106
apps/mobile/src/features/threads/thread-list-v2-items.tsx+204-23
......@@ -3,11 +3,20 @@ import type {
33 EnvironmentThreadShell,
44} from "@t3tools/client-runtime/state/shell";
55import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
6import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
67import type { MenuAction } from "@react-native-menu/menu";
7import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react";
8import { Platform, Pressable, useWindowDimensions, View } from "react-native";
8import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
9import {
10 Alert,
11 Platform,
12 Pressable,
13 useColorScheme,
14 useWindowDimensions,
15 View,
16} from "react-native";
917import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
1018
19import { SymbolView } from "../../components/AppSymbol";
1120import { AppText as Text } from "../../components/AppText";
1221import { ControlPillMenu } from "../../components/ControlPill";
1322import { ProjectFavicon } from "../../components/ProjectFavicon";
......@@ -18,7 +27,13 @@ import { useThemeColor } from "../../lib/useThemeColor";
1827import type { PendingNewTask } from "../../state/use-pending-new-tasks";
1928import { useThreadPr } from "../../state/use-thread-pr";
2029import { 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";
2237import { ThreadSearchMatchExcerpt } from "./thread-search-match";
2338
2439/**
......@@ -62,6 +77,11 @@ const SLIM_MENU_ACTIONS: MenuAction[] = [
6277 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
6378];
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
6585// Pre-settlement servers: no lifecycle items, archive fills the gap.
6686const LEGACY_MENU_ACTIONS: MenuAction[] = [
6787 { id: "archive", title: "Archive", image: "archivebox" },
......@@ -90,6 +110,45 @@ export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivid
90110 );
91111});
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
93152const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [
94153 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
95154];
......@@ -209,6 +268,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
209268 readonly thread: EnvironmentThreadShell;
210269 readonly variant: "card" | "slim";
211270 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;
212279 readonly project: EnvironmentProject | null;
213280 readonly projectTitle?: string;
214281 readonly providerDriver: string | null;
......@@ -231,11 +298,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
231298 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
232299 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
233300 readonly onSettleThread: (thread: EnvironmentThreadShell) => void;
301 readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void;
302 readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void;
234303 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
235304 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
236305 /** False on environments whose server predates thread.settle/unsettle:
237306 swipe + menu fall back to Archive instead of failing on use. */
238307 readonly settlementSupported: boolean;
308 /** False on servers that predate thread.snooze/unsnooze. */
309 readonly snoozeSupported: boolean;
239310 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
240311 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
241312 /** 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: {
258329 onSelectThread,
259330 onDeleteThread,
260331 onSettleThread,
332 onSnoozeThread,
333 onUnsnoozeThread,
261334 onUnsettleThread,
262335 onArchiveThread,
263336 onChangeRequestState,
264337 } = props;
338 const snoozedRow = props.snoozed === true;
265339
266340 const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);
267341 const prState = pr?.state ?? null;
......@@ -283,27 +357,94 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
283357
284358 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
285359 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]);
286365 const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);
287366 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 );
288415 const handleMenuAction = useCallback(
289416 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
290417 if (nativeEvent.event === "settle") handleSettle();
291418 if (nativeEvent.event === "unsettle") handleUnsettle();
419 if (nativeEvent.event === "unsnooze") handleUnsnooze();
292420 if (nativeEvent.event === "archive") handleArchive();
293421 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 }
294432 },
295 [handleArchive, handleDelete, handleSettle, handleUnsettle],
433 [
434 handleArchive,
435 handleDelete,
436 handleSettle,
437 handleSnooze,
438 handleUnsettle,
439 handleUnsnooze,
440 snoozePresets,
441 ],
296442 );
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";
302443 const primaryAction = useMemo(() => {
303444 // Pre-settlement server: archive is the swipe action, as in v1. (Slim
304445 // rows cannot occur here — unsupported environments never classify as
305446 // settled.)
306 if (!props.settlementSupported) {
447 if (swipeActions.primary === "archive") {
307448 return {
308449 accessibilityLabel: `Archive ${thread.title}`,
309450 icon: "archivebox" as const,
......@@ -311,7 +452,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
311452 onPress: handleArchive,
312453 };
313454 }
314 return canUnsettle
455 if (swipeActions.primary === "unsnooze") {
456 return {
457 accessibilityLabel: `Wake ${thread.title} now`,
458 icon: "clock" as const,
459 label: "Wake",
460 onPress: handleUnsnooze,
461 };
462 }
463 return swipeActions.primary === "unsettle"
315464 ? {
316465 accessibilityLabel: `Un-settle ${thread.title}`,
317466 icon: "arrow.uturn.backward" as const,
......@@ -325,13 +474,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
325474 onPress: handleSettle,
326475 };
327476 }, [
328 canUnsettle,
329477 handleArchive,
330478 handleSettle,
331479 handleUnsettle,
332 props.settlementSupported,
480 handleUnsnooze,
481 swipeActions.primary,
333482 thread.title,
334483 ]);
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
336506 // The sidebar pane fills selected rows with the accent color (matching the
337507 // 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: {
453623 const rowContent = (close: () => void) =>
454624 variant === "card" ? (
455625 <Pressable
456 accessibilityHint={`Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`}
626 accessibilityHint={swipeAccessibilityHint}
457627 accessibilityLabel={thread.title}
458628 accessibilityRole="button"
459629 accessibilityState={{ selected }}
......@@ -491,7 +661,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
491661 </Pressable>
492662 ) : (
493663 <Pressable
494 accessibilityHint={`Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`}
664 accessibilityHint={swipeAccessibilityHint}
495665 accessibilityLabel={thread.title}
496666 accessibilityRole="button"
497667 accessibilityState={{ selected }}
......@@ -551,11 +721,17 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
551721 <Text
552722 className={cn(
553723 "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",
555729 )}
556730 style={{ fontFamily: MONO_FONT }}
557731 >
558 {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)}
732 {snoozedRow && props.snoozeWakeLabelText !== undefined
733 ? props.snoozeWakeLabelText
734 : relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)}
559735 </Text>
560736 </View>
561737 </Pressable>
......@@ -574,13 +750,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
574750 }
575751 enableTrackpadSwipe
576752 // Full swipe commits the advertised lifecycle action (Settle /
577 // Un-settle), never the destructive delete.
753 // Un-settle), never the secondary snooze action.
578754 fullSwipeAction="primary"
579755 fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32}
580756 onDelete={handleDelete}
581757 onSwipeableClose={props.onSwipeableClose}
582758 onSwipeableWillOpen={props.onSwipeableWillOpen}
583759 primaryAction={primaryAction}
760 secondaryAction={secondaryAction}
584761 resetKey={`${thread.environmentId}:${thread.id}`}
585762 simultaneousWithExternalGesture={props.simultaneousSwipeGesture}
586763 threadTitle={thread.title}
......@@ -588,11 +765,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
588765 {(close) => (
589766 <ControlPillMenu
590767 actions={
591 !props.settlementSupported
592 ? LEGACY_MENU_ACTIONS
593 : canUnsettle
594 ? SLIM_MENU_ACTIONS
595 : CARD_MENU_ACTIONS
768 snoozedRow
769 ? SNOOZED_MENU_ACTIONS
770 : !props.settlementSupported
771 ? LEGACY_MENU_ACTIONS
772 : canUnsettle
773 ? SLIM_MENU_ACTIONS
774 : swipeActions.secondary === "snooze"
775 ? snoozableCardMenuActions
776 : CARD_MENU_ACTIONS
596777 }
597778 onPressAction={handleMenuAction}
598779 shouldOpenOnLongPress
apps/mobile/src/features/threads/threadListV2.test.ts+280-1
......@@ -1,5 +1,6 @@
11import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
22import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
3import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
34import {
45 CommandId,
56 EnvironmentId,
......@@ -16,7 +17,10 @@ import {
1617 buildThreadListV2Items,
1718 buildThreadListV2ListItems,
1819 resolveThreadListV2Enabled,
20 resolveThreadListV2SnoozeMenuSelection,
21 resolveThreadListV2SnoozeGateExpiryMs,
1922 resolveThreadListV2Status,
23 resolveThreadListV2SwipeActions,
2024 sortThreadsForListV2,
2125} from "./threadListV2";
2226
......@@ -50,6 +54,54 @@ function makeThread(
5054
5155const 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
53105describe("resolveThreadListV2Enabled", () => {
54106 it("defaults on when the device has never chosen", () => {
55107 expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe(
......@@ -96,6 +148,105 @@ describe("resolveThreadListV2Status", () => {
96148 });
97149});
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
99250describe("sortThreadsForListV2", () => {
100251 it("orders by creation time, newest first, ignoring activity", () => {
101252 const sorted = sortThreadsForListV2([
......@@ -167,6 +318,93 @@ describe("buildThreadListV2Items", () => {
167318 expect(layout.nextSnoozeWakeAt).toBe("2026-06-02T09:00:00.000Z");
168319 });
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
170408 it("keeps snoozed threads visible on environments without the snooze capability", () => {
171409 const layout = buildThreadListV2Items({
172410 threads: [
......@@ -424,7 +662,11 @@ describe("buildThreadListV2ListItems", () => {
424662
425663 expect(
426664 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",
428670 ),
429671 ).toEqual(["active", "queued-1", "queued-2", "settled"]);
430672 // Only the leading queued row labels the section, exactly like Settled.
......@@ -456,4 +698,41 @@ describe("buildThreadListV2ListItems", () => {
456698 `v2-thread:${environmentId}:settled`,
457699 ]);
458700 });
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 });
459738});
apps/mobile/src/features/threads/threadListV2.ts+167-18
......@@ -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";
210import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
311import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
412import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
513
614import type { PendingNewTask } from "../../state/use-pending-new-tasks";
715
16export { snoozeWakeLabel };
17
818/**
919 * Thread List v2 model, ported from the web sidebar v2
1020 * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx).
......@@ -14,6 +24,76 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
1424 * unlabeled resting state.
1525 */
1626export 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
1898// Settled-tail paging: recent history is the common lookup; the deep tail
1999// stays behind an explicit Show more. Shared by the compact Home list and
......@@ -100,6 +180,8 @@ export interface ThreadListV2Item {
100180 readonly variant: "card" | "slim";
101181 /** First settled row after the card block draws the SETTLED divider. */
102182 readonly showSettledDivider: boolean;
183 /** Snoozed-shelf row: shows the wake countdown and offers Wake. */
184 readonly snoozed: boolean;
103185 readonly isLast: boolean;
104186}
105187
......@@ -107,10 +189,12 @@ export interface ThreadListV2Layout {
107189 readonly items: ThreadListV2Item[];
108190 /** Settled threads beyond the render limit (behind "Show more"). */
109191 readonly hiddenSettledCount: number;
110 /** Snoozed threads hidden from the list (visibility parity with web's
111 collapsed Snoozed shelf; mobile has no shelf UI yet). */
192 /** Snoozed threads matching the current filters. */
112193 readonly snoozedCount: number;
113 /** Soonest wake time among hidden snoozed threads, or null. Callers arm
194 /** Index in `items` where the Snoozed shelf header belongs. The header is
195 still rendered when the shelf is collapsed and no snoozed rows exist. */
196 readonly snoozedShelfHeaderIndex: number | null;
197 /** Soonest wake time among snoozed threads, or null. Callers arm
114198 a timeout at this boundary so the list re-partitions the moment a
115199 snooze expires instead of on the next minute tick. */
116200 readonly nextSnoozeWakeAt: string | null;
......@@ -120,6 +204,8 @@ export interface ThreadListV2ThreadListItem {
120204 readonly type: "v2-thread";
121205 readonly key: string;
122206 readonly item: ThreadListV2Item;
207 /** Precomputed so recycled-list equality can see a minute-tick change. */
208 readonly snoozeWakeLabelText: string | undefined;
123209}
124210
125211export interface ThreadListV2PendingListItem {
......@@ -130,28 +216,42 @@ export interface ThreadListV2PendingListItem {
130216 readonly showPendingDivider: boolean;
131217}
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
135231/**
136 * Splices queued tasks between the active block and the settled tail, so the
137 * list reads active → pending → settled. Queued work sits below the live
138 * threads because nothing can happen to it until its environment returns:
139 * it is waiting, not asking. Shared by the compact Home list and the iPad
140 * sidebar so both order and label the sections identically.
232 * Builds the shared mobile order: active → pending → snoozed shelf → settled.
233 * Pending tasks are waiting rather than asking, and parked work remains
234 * reachable without competing with either the inbox or settled history.
141235 */
142236export function buildThreadListV2ListItems(input: {
143237 readonly items: ReadonlyArray<ThreadListV2Item>;
144238 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
239 readonly snoozedCount?: number;
240 readonly snoozedShelfExpanded?: boolean;
241 readonly snoozedShelfHeaderIndex?: number | null;
242 readonly snoozeLabelNow?: string;
145243}): ThreadListV2ListItem[] {
146244 const threadItems = input.items.map(
147245 (item): ThreadListV2ListItem => ({
148246 type: "v2-thread",
149247 key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`,
150248 item,
249 snoozeWakeLabelText:
250 item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined
251 ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow })
252 : undefined,
151253 }),
152254 );
153 if (input.pendingTasks.length === 0) return threadItems;
154
155255 const pendingItems = input.pendingTasks.map(
156256 (pendingTask, index): ThreadListV2ListItem => ({
157257 type: "v2-pending",
......@@ -160,8 +260,23 @@ export function buildThreadListV2ListItems(input: {
160260 showPendingDivider: index === 0,
161261 }),
162262 );
163 // The settled tail begins at the row that draws the SETTLED divider; with
164 // no settled rows the queued block simply ends the list.
263 const snoozedCount = input.snoozedCount ?? 0;
264 const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null;
265 if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) {
266 return [
267 ...threadItems.slice(0, snoozedShelfHeaderIndex),
268 ...pendingItems,
269 {
270 type: "v2-snoozed-shelf",
271 key: "v2-snoozed-shelf",
272 count: snoozedCount,
273 expanded: input.snoozedShelfExpanded === true,
274 },
275 ...threadItems.slice(snoozedShelfHeaderIndex),
276 ];
277 }
278 if (pendingItems.length === 0) return threadItems;
279
165280 const settledStart = threadItems.findIndex(
166281 (entry) => entry.type === "v2-thread" && entry.item.showSettledDivider,
167282 );
......@@ -204,6 +319,11 @@ export function buildThreadListV2Items(input: {
204319 second-precise, so classifying with the floored minute would hold a
205320 woken thread hidden for up to a minute. Defaults to `now`. */
206321 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;
207327}): ThreadListV2Layout {
208328 const now = input.now ?? new Date().toISOString();
209329 const snoozeNow = input.snoozeNow ?? now;
......@@ -215,7 +335,7 @@ export function buildThreadListV2Items(input: {
215335
216336 const active: EnvironmentThreadShell[] = [];
217337 const settled: EnvironmentThreadShell[] = [];
218 let snoozedCount = 0;
338 const snoozed: EnvironmentThreadShell[] = [];
219339 let nextSnoozeWakeAt: string | null = null;
220340 for (const thread of input.threads) {
221341 // Callers pass live (unarchived) shells; settled threads are among them
......@@ -244,7 +364,7 @@ export function buildThreadListV2Items(input: {
244364 // wakes (or raises its hand — effectiveSnoozed refuses blocked/failed
245365 // work). Snooze outranks settled classification, same as web.
246366 if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {
247 snoozedCount += 1;
367 snoozed.push(thread);
248368 if (
249369 thread.snoozedUntil != null &&
250370 (nextSnoozeWakeAt === null ||
......@@ -265,6 +385,17 @@ export function buildThreadListV2Items(input: {
265385 }
266386
267387 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 );
268399 const orderedSettled = [...settled].sort(
269400 (left, right) =>
270401 firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) -
......@@ -276,13 +407,30 @@ export function buildThreadListV2Items(input: {
276407
277408 const items: ThreadListV2Item[] = [];
278409 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 });
280427 }
281428 for (const [index, thread] of visibleSettled.entries()) {
282429 items.push({
283430 thread,
284431 variant: "slim",
285432 showSettledDivider: index === 0,
433 snoozed: false,
286434 isLast: false,
287435 });
288436 }
......@@ -293,7 +441,8 @@ export function buildThreadListV2Items(input: {
293441 return {
294442 items,
295443 hiddenSettledCount: orderedSettled.length - visibleSettled.length,
296 snoozedCount,
444 snoozedCount: orderedSnoozed.length,
445 snoozedShelfHeaderIndex,
297446 nextSnoozeWakeAt,
298447 };
299448}
apps/web/src/components/Sidebar.snooze.test.ts+1-16
......@@ -1,6 +1,6 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { resolveSnoozePresets, snoozeWakeDescription, snoozeWakeLabel } from "./Sidebar.snooze";
3import { resolveSnoozePresets, snoozeWakeDescription } from "./Sidebar.snooze";
44
55// Local-time constructor so preset math is timezone-stable in tests.
66function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date {
......@@ -64,21 +64,6 @@ describe("resolveSnoozePresets", () => {
6464 });
6565});
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
8267describe("snoozeWakeDescription", () => {
8368 const now = localDate(2026, 4, 8, 10);
8469
apps/web/src/components/Sidebar.snooze.ts+7-105
......@@ -1,111 +1,13 @@
1/**
2 * Snooze preset resolution for the sidebar snooze menu. Pure functions so
3 * the preset math (evening/tomorrow/next-week boundaries) is unit-testable
4 * without a DOM.
5 *
6 * Presets deliberately skew short: agent-thread rhythms are hours (a CI
7 * run, a teammate review, the next work session), not days.
8 */
1import { snoozeWakeLabel } from "@t3tools/client-runtime/state/thread-settled";
92import { parseTimestampDate } from "../timestampFormat";
103
11type SnoozePresetId = "hour" | "evening" | "tomorrow" | "next-week";
12
13export interface SnoozePreset {
14 readonly id: SnoozePresetId;
15 readonly label: string;
16 /** Menu-row time column. Complements the label instead of repeating it:
17 "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". */
18 readonly whenLabel: string;
19 /** ISO wake time. */
20 readonly snoozedUntil: string;
21}
22
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}
4export {
5 resolveSnoozePresets,
6 type SnoozePreset,
7} from "@t3tools/client-runtime/state/thread-settled";
8export { snoozeWakeLabel };
469
47/**
48 * Presets for "snooze until", computed against local time. "This evening"
49 * only appears while it is still meaningfully before evening; after that
50 * the list starts at "Tomorrow".
51 */
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}
10const DAY_MS = 24 * 60 * 60 * 1_000;
10911
11012/**
11113 * 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() {
26112611 }
26122612 snoozeWakeLabelText={
26132613 section === "snoozed" && thread.snoozedUntil != null
2614 ? snoozeWakeLabel(thread.snoozedUntil, new Date())
2614 ? snoozeWakeLabel(thread.snoozedUntil, {
2615 now: new Date().toISOString(),
2616 })
26152617 : null
26162618 }
26172619 // All sections: a woken thread can classify straight
packages/client-runtime/src/state/threadSettled.ts+98
......@@ -1,3 +1,4 @@
1// @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels.
12import type { OrchestrationThreadShell } from "@t3tools/contracts";
23
34export type ChangeRequestStateLike = "open" | "closed" | "merged";
......@@ -272,3 +273,100 @@ export function effectiveSettled(
272273 Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS
273274 );
274275}
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// @effect-diagnostics globalDate:off -- Tests exercise local calendar snooze boundaries.
12import { ThreadId } from "@t3tools/contracts";
23import { TurnId } from "@t3tools/contracts";
34import { describe, expect, it } from "vite-plus/test";
......@@ -5,6 +6,8 @@ import { describe, expect, it } from "vite-plus/test";
56import {
67 canSnooze,
78 effectiveSnoozed,
9 resolveSnoozePresets,
10 snoozeWakeLabel,
811 threadRaisedHandWhileSnoozed,
912 threadWokeAt,
1013 type ThreadSnoozeShell,
......@@ -15,6 +18,10 @@ const SNOOZED_AT = "2026-04-10T09:00:00.000Z";
1518const FUTURE_WAKE = "2026-04-11T09:00:00.000Z";
1619const 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
1825function makeShell(input: {
1926 readonly snoozedUntil?: string | null;
2027 readonly snoozedAt?: string | null;
......@@ -235,3 +242,53 @@ describe("threadWokeAt", () => {
235242 ).toBe("2026-04-10T09:30:00.000Z");
236243 });
237244});
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});