From ca309389410828fe5d39033eeb4c9a3a7d4db193 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:01:36 -0400 Subject: [PATCH 1/5] feat(mobile): add thread snoozing - Add snooze and wake actions to mobile thread lists - Add collapsible snoozed-thread shelf with wake times --- apps/mobile/src/components/AppSymbol.tsx | 2 + .../src/features/home/HomeRouteScreen.tsx | 12 +- apps/mobile/src/features/home/HomeScreen.tsx | 86 +++++-- .../features/home/thread-swipe-actions.tsx | 133 +++++++--- .../src/features/home/useThreadListActions.ts | 94 ++++++- .../threads/ThreadNavigationSidebar.tsx | 90 +++++-- .../features/threads/thread-list-v2-items.tsx | 152 ++++++++++-- .../src/features/threads/threadListV2.test.ts | 231 +++++++++++++++++- .../src/features/threads/threadListV2.ts | 159 ++++++++++-- .../web/src/components/Sidebar.snooze.test.ts | 17 +- apps/web/src/components/Sidebar.snooze.ts | 15 +- apps/web/src/components/SidebarV2.tsx | 4 +- .../client-runtime/src/state/threadSettled.ts | 18 ++ .../src/state/threadSnoozed.test.ts | 18 ++ 14 files changed, 869 insertions(+), 162 deletions(-) diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index ac813bdbe0a..d4c7088fe50 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -22,6 +22,7 @@ import { IconChevronUp, IconCircleCheck, IconCircleXFilled, + IconClock, IconCopy, IconDeviceDesktop, IconDots, @@ -96,6 +97,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { camera: IconCamera, checkmark: IconCheck, "checkmark.circle": IconCircleCheck, + clock: IconClock, "chevron.down": IconChevronDown, "chevron.left": IconChevronLeft, "chevron.left.forwardslash.chevron.right": IconCode, diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 62f6e324602..0ec6ba34268 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -35,8 +35,14 @@ export function HomeRouteScreen() { void checkForAppUpdateOnLaunch(); }, []); - const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = - useThreadListActions(); + const { + archiveThread, + confirmDeleteThread, + settleThread, + snoozeThread, + unsnoozeThread, + unsettleThread, + } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo(() => { @@ -143,6 +149,8 @@ export function HomeRouteScreen() { onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} onSettleThread={settleThread} + onSnoozeThread={snoozeThread} + onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 507a39d94a1..9424e31a49a 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -41,7 +41,11 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "../threads/thread-list-items"; -import { ThreadListV2PendingRow, ThreadListV2Row } from "../threads/thread-list-v2-items"; +import { + ThreadListV2PendingRow, + ThreadListV2Row, + ThreadListV2SnoozedShelfHeader, +} from "../threads/thread-list-v2-items"; import { buildThreadListV2Items, buildThreadListV2ListItems, @@ -100,6 +104,8 @@ interface HomeScreenProps { readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; /** Resolves true iff the settle was dispatched and succeeded. */ readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; + readonly onSnoozeThread: (thread: EnvironmentThreadShell) => Promise; + readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; @@ -494,6 +500,18 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onSettleThread], ); + const handleSnoozeThread = useCallback( + (thread: EnvironmentThreadShell) => { + void props.onSnoozeThread(thread); + }, + [props.onSnoozeThread], + ); + const handleUnsnoozeThread = useCallback( + (thread: EnvironmentThreadShell) => { + void props.onUnsnoozeThread(thread); + }, + [props.onUnsnoozeThread], + ); const handleDeleteThread = props.onDeleteThread; const handleUnsettleThread = props.onUnsettleThread; // The settled tail renders in pages; expansion resets when the filter @@ -511,6 +529,8 @@ export function HomeScreen(props: HomeScreenProps) { () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), [], ); + const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); + const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); // now is quantized to the minute and ticks so the inactivity auto-settle // boundary is actually crossed while the app stays open (mirrors web); // without a clock dependency the partition memoizes a frozen "now". @@ -551,7 +571,13 @@ export function HomeScreen(props: HomeScreenProps) { }, [serverConfigs]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) - return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; + return { + items: [], + hiddenSettledCount: 0, + snoozedCount: 0, + snoozedShelfHeaderIndex: null, + nextSnoozeWakeAt: null, + }; // Settled threads are live shells; archived threads keep their original // "hidden from lists" meaning. return buildThreadListV2Items({ @@ -566,11 +592,14 @@ export function HomeScreen(props: HomeScreenProps) { settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, snoozeNow: new Date().toISOString(), + snoozedShelfExpanded, + selectedThreadKey: null, }); }, [ changeRequestStateByKey, nowMinute, snoozeWakeTick, + snoozedShelfExpanded, settledVisibleCount, settlementEnvironmentIds, snoozeEnvironmentIds, @@ -620,8 +649,12 @@ export function HomeScreen(props: HomeScreenProps) { buildThreadListV2ListItems({ items: threadListV2Layout.items, pendingTasks: v2PendingTasks, + snoozedCount: threadListV2Layout.snoozedCount, + snoozedShelfExpanded, + snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, + snoozeLabelNow: `${nowMinute}:00.000Z`, }), - [threadListV2Layout.items, v2PendingTasks], + [snoozedShelfExpanded, threadListV2Layout, v2PendingTasks], ); const renderV2Item = useCallback( @@ -648,12 +681,23 @@ export function HomeScreen(props: HomeScreenProps) { /> ); } + if (item.type === "v2-snoozed-shelf") { + return ( + + ); + } const thread = item.item.thread; return ( item.key, []); @@ -953,31 +1005,11 @@ export function HomeScreen(props: HomeScreenProps) { ) : null; // Self-contained: v1's listEmpty keys off projectGroups, which ignores the // v2 project scope, so it can be null (results elsewhere) while this list - // is empty. Search outranks the scope — "No results" names the actionable - // fact when a query is active. Snoozed threads outrank the rest: "No - // threads yet" over an inbox that is merely all-snoozed reads as data - // loss. - const v2SnoozedCount = threadListV2Layout.snoozedCount; + // is empty. Snoozed threads need no special empty state: their shelf header + // is a list row even while collapsed. const v2ListEmpty = - hasSearchQuery && threadSearch.isPending && v2SnoozedCount === 0 ? null : hasSearchQuery ? ( - v2SnoozedCount > 0 ? ( - // The snoozed threads already passed this search filter: "No - // results" would claim nothing matched when matches are merely - // parked. - - ) : ( - - ) - ) : v2SnoozedCount > 0 ? ( - + hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( + ) : v2ScopedProjectGroup !== null ? ( ["name"]; readonly label: string; readonly onPress: () => void; } +interface ThreadSwipeSecondaryAction extends ThreadSwipeAction { + readonly backgroundColor: string; +} + +function swipeActionsWidth(hasSecondaryAction: boolean) { + return hasSecondaryAction ? THREAD_SWIPE_ACTIONS_WIDTH : ACTION_ITEM_WIDTH; +} + +/** `undefined` keeps the v1 Delete default; `null` means one action only. */ +function resolveSecondaryAction(input: { + readonly close: () => void; + readonly onDelete: () => void; + readonly secondaryAction: ThreadSwipeAction | null | undefined; + readonly threadTitle: string; +}): ThreadSwipeSecondaryAction | null { + if (input.secondaryAction === null) return null; + if (input.secondaryAction === undefined) { + return { + accessibilityLabel: `Delete ${input.threadTitle}`, + backgroundColor: "#ff2d55", + icon: "trash", + label: "Delete", + onPress: () => { + input.close(); + input.onDelete(); + }, + }; + } + const action = input.secondaryAction; + return { + ...action, + backgroundColor: "#5856d6", + onPress: () => { + input.close(); + action.onPress(); + }, + }; +} + /** * Delivers the scroll gate to swipeables via context so that flipping it does * NOT re-render whole rows: putting the flag in list extraData/renderItem deps @@ -183,7 +222,13 @@ export function ThreadSwipeable(props: { readonly onDelete: () => void; readonly onSwipeableClose?: (methods: SwipeableMethods) => void; readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void; - readonly primaryAction: ThreadSwipePrimaryAction; + readonly primaryAction: ThreadSwipeAction; + /** + * Omitted keeps the v1 destructive Delete action. Explicit null opts out of + * a secondary action entirely so a gated Snooze can never fall back to an + * unadvertised Delete. + */ + readonly secondaryAction?: ThreadSwipeAction | null; /** * Identity of the content being wrapped. When a recycled list reuses this * component for a different item, the swipeable snaps back to closed so an @@ -197,7 +242,10 @@ export function ThreadSwipeable(props: { }) { const swipeableRef = useRef(null); const fullSwipeArmedRef = useRef(false); - const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, props.fullSwipeWidth * 0.58); + const hasSecondaryAction = props.secondaryAction !== null; + const actionsWidth = swipeActionsWidth(hasSecondaryAction); + const fullSwipeThreshold = Math.max(actionsWidth + 44, props.fullSwipeWidth * 0.58); + const fullSwipeAction = hasSecondaryAction ? (props.fullSwipeAction ?? "delete") : "primary"; const close = useCallback(() => swipeableRef.current?.close(), []); const gateEnabled = use(SwipeableScrollGateContext); const resetKey = props.resetKey; @@ -251,7 +299,7 @@ export function ThreadSwipeable(props: { if (fullSwipeArmedRef.current) { fullSwipeArmedRef.current = false; methods.close(); - if (props.fullSwipeAction === "primary") { + if (fullSwipeAction === "primary") { props.primaryAction.onPress(); } else { props.onDelete(); @@ -264,9 +312,8 @@ export function ThreadSwipeable(props: { methods.close(), + onDelete: props.onDelete, + secondaryAction: props.secondaryAction, + threadTitle: props.threadTitle, + })} translation={translation} /> )} - rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} + rightThreshold={actionsWidth * 0.42} simultaneousWithExternalGesture={props.simultaneousWithExternalGesture} > {props.children(close)} @@ -290,6 +341,7 @@ export function ThreadSwipeable(props: { function SwipeActionButton(props: { readonly accessibilityLabel: string; + readonly actionsWidth: number; readonly backgroundColor: string; readonly compact: boolean; readonly entryRange: readonly [number, number]; @@ -305,10 +357,10 @@ function SwipeActionButton(props: { const actionStyle = useAnimatedStyle(() => { const reveal = Math.max(-props.translation.value, 0); const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP); - const stretch = Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0); + const stretch = Math.max(reveal - props.actionsWidth, 0); const fullSwipeProgress = interpolate( reveal, - [THREAD_SWIPE_ACTIONS_WIDTH, props.fullSwipeThreshold + 20], + [props.actionsWidth, props.fullSwipeThreshold + 20], [0, 1], Extrapolation.CLAMP, ); @@ -327,9 +379,7 @@ function SwipeActionButton(props: { }); const circleStyle = useAnimatedStyle(() => { const reveal = Math.max(-props.translation.value, 0); - const stretch = props.stretchesOnFullSwipe - ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0) - : 0; + const stretch = props.stretchesOnFullSwipe ? Math.max(reveal - props.actionsWidth, 0) : 0; return { transform: [{ translateX: -stretch }], @@ -338,9 +388,7 @@ function SwipeActionButton(props: { }); const iconStyle = useAnimatedStyle(() => { const reveal = Math.max(-props.translation.value, 0); - const stretch = props.stretchesOnFullSwipe - ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0) - : 0; + const stretch = props.stretchesOnFullSwipe ? Math.max(reveal - props.actionsWidth, 0) : 0; const armedProgress = interpolate( reveal, [props.fullSwipeThreshold, props.fullSwipeThreshold + 20], @@ -358,7 +406,7 @@ function SwipeActionButton(props: { } const reveal = Math.max(-props.translation.value, 0); - const stretch = Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0); + const stretch = Math.max(reveal - props.actionsWidth, 0); return { opacity: interpolate( reveal, @@ -446,14 +494,14 @@ export function ThreadSwipeActions(props: { readonly compact: boolean; readonly fullSwipeAction?: "delete" | "primary"; readonly fullSwipeThreshold: number; - readonly onDelete: () => void; readonly onFullSwipeArmedChange: (armed: boolean) => void; - readonly primaryAction: ThreadSwipePrimaryAction; - readonly swipeableMethods: SwipeableMethods; - readonly threadTitle: string; + readonly primaryAction: ThreadSwipeAction; + readonly secondaryAction: ThreadSwipeSecondaryAction | null; readonly translation: SharedValue; }) { - const fullSwipeIsPrimary = props.fullSwipeAction === "primary"; + const secondaryAction = props.secondaryAction; + const fullSwipeIsPrimary = props.fullSwipeAction === "primary" || secondaryAction === null; + const actionsWidth = swipeActionsWidth(secondaryAction !== null); useAnimatedReaction( () => -props.translation.value >= props.fullSwipeThreshold, (armed, previous) => { @@ -470,14 +518,19 @@ export function ThreadSwipeActions(props: { backgroundColor: props.backgroundColor, flexDirection: "row", height: "100%", - width: THREAD_SWIPE_ACTIONS_WIDTH, + width: actionsWidth, }} > - { - props.swipeableMethods.close(); - props.onDelete(); - }} - stretchesOnFullSwipe={!fullSwipeIsPrimary} - translation={props.translation} - /> + {secondaryAction === null ? null : ( + + )} ); } diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index e200eb7acde..f6f517eb490 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,5 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; +import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; @@ -13,6 +13,8 @@ import { environmentServerConfigsAtom } from "../../state/server"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; +const SWIPE_SNOOZE_DURATION_MS = 60 * 60 * 1_000; + /** Version skew: never send settle/unsettle to a server that predates them (capability defaults false on decode for older servers). */ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["environmentId"]) { @@ -22,6 +24,13 @@ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["en ); } +function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadSnooze === true + ); +} + type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; const ACTION_VERBS: Record = { @@ -192,9 +201,13 @@ export function useThreadListActions(): { readonly archiveThread: (thread: EnvironmentThreadShell) => void; readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; readonly settleThread: (thread: EnvironmentThreadShell) => Promise; + readonly snoozeThread: (thread: EnvironmentThreadShell) => Promise; + readonly unsnoozeThread: (thread: EnvironmentThreadShell) => Promise; readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; } { const executeAction = useThreadActionExecutor(); + const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); + const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); const archiveThread = useCallback( (thread: EnvironmentThreadShell) => { @@ -206,6 +219,76 @@ export function useThreadListActions(): { async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true, [executeAction], ); + const snoozeThread = useCallback( + async (thread: EnvironmentThreadShell) => { + if (!environmentSupportsSnooze(thread.environmentId)) { + Alert.alert( + "Could not snooze thread", + "This environment's server does not support snoozing yet. Update the server to use Snooze.", + ); + return false; + } + if (!canSnooze(thread, { now: new Date().toISOString() })) { + Alert.alert( + "Could not snooze thread", + thread.hasPendingApprovals || thread.hasPendingUserInput + ? "This thread is waiting on you. Respond to the pending request before snoozing it." + : "This thread is still starting a turn. Try again once it's running.", + ); + return false; + } + + selectionHaptic(); + const result = await snoozeMutation({ + environmentId: thread.environmentId, + input: { + threadId: thread.id, + snoozedUntil: new Date(Date.now() + SWIPE_SNOOZE_DURATION_MS).toISOString(), + }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not snooze thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be snoozed.", + ); + return false; + } + return true; + }, + [snoozeMutation], + ); + const unsnoozeThread = useCallback( + async (thread: EnvironmentThreadShell) => { + if (!environmentSupportsSnooze(thread.environmentId)) { + Alert.alert( + "Could not wake thread", + "This environment's server does not support snoozing yet. Update the server to wake this thread.", + ); + return false; + } + + selectionHaptic(); + const result = await unsnoozeMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not wake thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be woken.", + ); + return false; + } + return true; + }, + [unsnoozeMutation], + ); const unsettleThread = useCallback( async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true, [executeAction], @@ -213,7 +296,14 @@ export function useThreadListActions(): { const confirmDeleteThread = useConfirmDeleteThread(executeAction); - return { archiveThread, confirmDeleteThread, settleThread, unsettleThread }; + return { + archiveThread, + confirmDeleteThread, + settleThread, + snoozeThread, + unsnoozeThread, + unsettleThread, + }; } export function useArchivedThreadListActions( diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 36a86ceb1e3..060d8133004 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -67,7 +67,11 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "./thread-list-items"; -import { ThreadListV2PendingRow, ThreadListV2Row } from "./thread-list-v2-items"; +import { + ThreadListV2PendingRow, + ThreadListV2Row, + ThreadListV2SnoozedShelfHeader, +} from "./thread-list-v2-items"; import { buildThreadListV2Items, buildThreadListV2ListItems, @@ -193,8 +197,14 @@ function ThreadNavigationSidebarPane( const openSwipeableRef = useRef(null); const headerIsOverContentRef = useRef(false); const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); - const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = - useThreadListActions(); + const { + archiveThread, + confirmDeleteThread, + settleThread, + snoozeThread, + unsnoozeThread, + unsettleThread, + } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); @@ -428,6 +438,8 @@ function ThreadNavigationSidebarPane( () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), [], ); + const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); + const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); // now ticks per minute so the inactivity auto-settle boundary is actually // crossed while the pane stays open; without a clock dependency the // partition memoizes a frozen "now". @@ -468,7 +480,13 @@ function ThreadNavigationSidebarPane( }, [serverConfigs]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) - return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; + return { + items: [], + hiddenSettledCount: 0, + snoozedCount: 0, + snoozedShelfHeaderIndex: null, + nextSnoozeWakeAt: null, + }; return buildThreadListV2Items({ threads: threads.filter((thread) => thread.archivedAt === null), environmentId: options.selectedEnvironmentId, @@ -481,11 +499,15 @@ function ThreadNavigationSidebarPane( settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, snoozeNow: new Date().toISOString(), + snoozedShelfExpanded, + selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ changeRequestStateByKey, nowMinute, snoozeWakeTick, + snoozedShelfExpanded, + props.selectedThreadKey, options.selectedEnvironmentId, props.searchQuery, matchedThreadKeys, @@ -532,6 +554,10 @@ function ThreadNavigationSidebarPane( const items: SidebarListItem[] = buildThreadListV2ListItems({ items: threadListV2Layout.items, pendingTasks: v2PendingTasks, + snoozedCount: threadListV2Layout.snoozedCount, + snoozedShelfExpanded, + snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, + snoozeLabelNow: `${nowMinute}:00.000Z`, }); if (threadListV2Layout.hiddenSettledCount > 0) { items.push({ @@ -543,10 +569,12 @@ function ThreadNavigationSidebarPane( return items; }, [ listLayout.items, + nowMinute, options.selectedEnvironmentId, pendingTasks, props.searchQuery, selectedProjectRefs, + snoozedShelfExpanded, threadListV2Enabled, threadListV2Layout, ]); @@ -750,7 +778,9 @@ function ThreadNavigationSidebarPane( previous.key === item.key && previous.item.thread === item.item.thread && previous.item.variant === item.item.variant && - previous.item.showSettledDivider === item.item.showSettledDivider + previous.item.showSettledDivider === item.item.showSettledDivider && + previous.item.snoozed === item.item.snoozed && + previous.snoozeWakeLabelText === item.snoozeWakeLabelText ); } if (previous.type === "v2-show-more" && item.type === "v2-show-more") { @@ -762,13 +792,18 @@ function ThreadNavigationSidebarPane( previous.showPendingDivider === item.showPendingDivider ); } + if (previous.type === "v2-snoozed-shelf" && item.type === "v2-snoozed-shelf") { + return previous.count === item.count && previous.expanded === item.expanded; + } if ( previous.type === "v2-thread" || previous.type === "v2-show-more" || previous.type === "v2-pending" || + previous.type === "v2-snoozed-shelf" || item.type === "v2-thread" || item.type === "v2-show-more" || - item.type === "v2-pending" + item.type === "v2-pending" || + item.type === "v2-snoozed-shelf" ) { return false; } @@ -827,6 +862,8 @@ function ThreadNavigationSidebarPane( thread={thread} variant={item.item.variant} showSettledDivider={item.item.showSettledDivider} + snoozed={item.item.snoozed} + snoozeWakeLabelText={item.snoozeWakeLabelText} project={projectByKey.get(scopeKey) ?? null} projectTitle={projectTitleByProjectKey.get(scopeKey)} providerDriver={ @@ -860,6 +897,9 @@ function ThreadNavigationSidebarPane( onArchiveThread={archiveThread} settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} onSettleThread={settleThread} + snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} + onSnoozeThread={snoozeThread} + onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} @@ -869,6 +909,15 @@ function ThreadNavigationSidebarPane( /> ); } + case "v2-snoozed-shelf": + return ( + + ); case "v2-show-more": return ( {catalogState.isLoadingConnections ? "Loading threads…" : props.searchQuery.trim().length > 0 - ? threadSearch.isPending && snoozedCount === 0 + ? threadSearch.isPending ? "Searching thread messages…" - : snoozedCount > 0 - ? // Snoozed matches passed this same search filter — "No - // matching threads" would misreport them as nonexistent. - snoozedCount === 1 - ? "1 matching thread snoozed" - : "All matching threads snoozed" - : "No matching threads" - : snoozedCount > 0 - ? snoozedCount === 1 - ? "1 thread snoozed" - : `${snoozedCount} threads snoozed` - : selectedProjectScope !== null - ? `No threads in ${selectedProjectScope.title}` - : "No threads yet"} + : "No matching threads" + : selectedProjectScope !== null + ? `No threads in ${selectedProjectScope.title}` + : "No threads yet"} ); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 6af5795a94a..79a3a48c36a 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -3,11 +3,13 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; +import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; -import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react"; -import { Platform, Pressable, useWindowDimensions, View } from "react-native"; +import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; +import { Platform, Pressable, useColorScheme, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; +import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { ProjectFavicon } from "../../components/ProjectFavicon"; @@ -18,7 +20,12 @@ import { useThemeColor } from "../../lib/useThemeColor"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; +import { + resolveThreadListV2SnoozeGateExpiryMs, + resolveThreadListV2Status, + resolveThreadListV2SwipeActions, + type ThreadListV2Status, +} from "./threadListV2"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; /** @@ -62,6 +69,11 @@ const SLIM_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; +const SNOOZED_MENU_ACTIONS: MenuAction[] = [ + { id: "unsnooze", title: "Wake thread", image: "clock" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + // Pre-settlement servers: no lifecycle items, archive fills the gap. const LEGACY_MENU_ACTIONS: MenuAction[] = [ { id: "archive", title: "Archive", image: "archivebox" }, @@ -90,6 +102,45 @@ export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivid ); }); +const SNOOZE_ACCENT_LIGHT = "#2563eb"; +const SNOOZE_ACCENT_DARK = "#60a5fa"; + +export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: { + readonly count: number; + readonly expanded: boolean; + readonly onToggle: () => void; + readonly pane?: "screen" | "sidebar"; +}) { + const colorScheme = useColorScheme(); + return ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + {props.expanded ? "Snoozed" : `Snoozed (${props.count})`} + + + + + ); +}); + const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; @@ -209,6 +260,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; readonly showSettledDivider: boolean; + /** Snoozed-shelf row: shows its wake time and offers Wake. */ + readonly snoozed?: boolean; + /** Preformatted against the parent minute tick so this memoized row's + countdown keeps moving. */ + readonly snoozeWakeLabelText?: string; readonly project: EnvironmentProject | null; readonly projectTitle?: string; readonly providerDriver: string | null; @@ -231,11 +287,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onSettleThread: (thread: EnvironmentThreadShell) => void; + readonly onSnoozeThread: (thread: EnvironmentThreadShell) => void; + readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; /** False on environments whose server predates thread.settle/unsettle: swipe + menu fall back to Archive instead of failing on use. */ readonly settlementSupported: boolean; + /** False on servers that predate thread.snooze/unsnooze. */ + readonly snoozeSupported: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; /** Reports this row's live PR state up so the partition can auto-settle @@ -258,10 +318,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onSelectThread, onDeleteThread, onSettleThread, + onSnoozeThread, + onUnsnoozeThread, onUnsettleThread, onArchiveThread, onChangeRequestState, } = props; + const snoozedRow = props.snoozed === true; const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); const prState = pr?.state ?? null; @@ -283,27 +346,47 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); + const handleSnooze = useCallback(() => onSnoozeThread(thread), [onSnoozeThread, thread]); + const handleUnsnooze = useCallback(() => onUnsnoozeThread(thread), [onUnsnoozeThread, thread]); const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "settle") handleSettle(); if (nativeEvent.event === "unsettle") handleUnsettle(); + if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); }, - [handleArchive, handleDelete, handleSettle, handleUnsettle], + [handleArchive, handleDelete, handleSettle, handleUnsettle, handleUnsnooze], ); // Swipe: the v2 primary action is the lifecycle transition. Every settled // row can un-settle — explicit settles clear the override, auto-settled // rows get pinned active until real activity clears the pin. const canUnsettle = variant === "slim"; + const [snoozeGateTick, bumpSnoozeGateTick] = useState(0); + const snoozeGateExpiryMs = props.snoozeSupported + ? resolveThreadListV2SnoozeGateExpiryMs(thread, { now: new Date().toISOString() }) + : null; + useEffect(() => { + if (snoozeGateExpiryMs === null) return; + const delayMs = Math.min(Math.max(0, snoozeGateExpiryMs - Date.now()) + 50, 2_147_483_647); + const id = setTimeout(() => bumpSnoozeGateTick((tick) => tick + 1), delayMs); + return () => clearTimeout(id); + }, [snoozeGateExpiryMs, snoozeGateTick]); + const swipeActions = resolveThreadListV2SwipeActions({ + variant, + settlementSupported: props.settlementSupported, + snoozeSupported: props.snoozeSupported, + snoozable: canSnooze(thread, { now: new Date().toISOString() }), + snoozed: snoozedRow, + }); const primaryAction = useMemo(() => { // Pre-settlement server: archive is the swipe action, as in v1. (Slim // rows cannot occur here — unsupported environments never classify as // settled.) - if (!props.settlementSupported) { + if (swipeActions.primary === "archive") { return { accessibilityLabel: `Archive ${thread.title}`, icon: "archivebox" as const, @@ -311,7 +394,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onPress: handleArchive, }; } - return canUnsettle + if (swipeActions.primary === "unsnooze") { + return { + accessibilityLabel: `Wake ${thread.title} now`, + icon: "clock" as const, + label: "Wake", + onPress: handleUnsnooze, + }; + } + return swipeActions.primary === "unsettle" ? { accessibilityLabel: `Un-settle ${thread.title}`, icon: "arrow.uturn.backward" as const, @@ -325,13 +416,29 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onPress: handleSettle, }; }, [ - canUnsettle, handleArchive, handleSettle, handleUnsettle, - props.settlementSupported, + handleUnsnooze, + swipeActions.primary, thread.title, ]); + const secondaryAction = useMemo( + () => + swipeActions.secondary === "snooze" + ? { + accessibilityLabel: `Snooze ${thread.title} for 1 hour`, + icon: "clock" as const, + label: "Snooze 1h", + onPress: handleSnooze, + } + : null, + [handleSnooze, swipeActions.secondary, thread.title], + ); + const swipeAccessibilityHint = + secondaryAction === null + ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.` + : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`; // The sidebar pane fills selected rows with the accent color (matching the // v1 sidebar), so every piece of row text needs a white-on-accent variant. @@ -453,7 +560,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const rowContent = (close: () => void) => variant === "card" ? ( ) : ( - {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} + {snoozedRow && props.snoozeWakeLabelText !== undefined + ? props.snoozeWakeLabelText + : relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} @@ -574,13 +687,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } enableTrackpadSwipe // Full swipe commits the advertised lifecycle action (Settle / - // Un-settle), never the destructive delete. + // Un-settle), never the secondary snooze action. fullSwipeAction="primary" fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32} onDelete={handleDelete} onSwipeableClose={props.onSwipeableClose} onSwipeableWillOpen={props.onSwipeableWillOpen} primaryAction={primaryAction} + secondaryAction={secondaryAction} resetKey={`${thread.environmentId}:${thread.id}`} simultaneousWithExternalGesture={props.simultaneousSwipeGesture} threadTitle={thread.title} @@ -588,11 +702,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {(close) => ( { }); }); +describe("resolveThreadListV2SwipeActions", () => { + it("offers settle and snooze for an active snoozable thread", () => { + expect( + resolveThreadListV2SwipeActions({ + variant: "card", + settlementSupported: true, + snoozeSupported: true, + snoozable: true, + }), + ).toEqual({ primary: "settle", secondary: "snooze" }); + }); + + it("offers un-settle and snooze for settled history", () => { + expect( + resolveThreadListV2SwipeActions({ + variant: "slim", + settlementSupported: true, + snoozeSupported: true, + snoozable: true, + }), + ).toEqual({ primary: "unsettle", secondary: "snooze" }); + }); + + it("omits snooze when the server or thread does not allow it", () => { + expect( + resolveThreadListV2SwipeActions({ + variant: "card", + settlementSupported: true, + snoozeSupported: false, + snoozable: true, + }), + ).toEqual({ primary: "settle", secondary: null }); + expect( + resolveThreadListV2SwipeActions({ + variant: "card", + settlementSupported: true, + snoozeSupported: true, + snoozable: false, + }), + ).toEqual({ primary: "settle", secondary: null }); + }); + + it("falls back to archive only for a pre-lifecycle server", () => { + expect( + resolveThreadListV2SwipeActions({ + variant: "card", + settlementSupported: false, + snoozeSupported: false, + snoozable: true, + }), + ).toEqual({ primary: "archive", secondary: null }); + }); + + it("offers wake and no snooze on a snoozed row", () => { + expect( + resolveThreadListV2SwipeActions({ + variant: "slim", + settlementSupported: true, + snoozeSupported: true, + snoozable: true, + snoozed: true, + }), + ).toEqual({ primary: "unsnooze", secondary: null }); + }); +}); + +describe("resolveThreadListV2SnoozeGateExpiryMs", () => { + it("reports when an unadopted turn's grace window lapses", () => { + const thread = makeThread({ + id: ThreadId.make("t"), + title: "t", + latestUserMessageAt: "2026-06-02T00:00:30.000Z", + }); + expect(resolveThreadListV2SnoozeGateExpiryMs(thread, { now: "2026-06-02T00:01:00.000Z" })).toBe( + Date.parse("2026-06-02T00:02:30.000Z"), + ); + }); + + it("returns null once the thread is snoozable or when only data can unblock it", () => { + expect( + resolveThreadListV2SnoozeGateExpiryMs( + makeThread({ id: ThreadId.make("ready"), title: "Ready" }), + { now: NOW }, + ), + ).toBe(null); + expect( + resolveThreadListV2SnoozeGateExpiryMs( + makeThread({ + id: ThreadId.make("blocked"), + title: "Blocked", + hasPendingApprovals: true, + latestUserMessageAt: NOW, + }), + { now: NOW }, + ), + ).toBe(null); + }); +}); + describe("sortThreadsForListV2", () => { it("orders by creation time, newest first, ignoring activity", () => { const sorted = sortThreadsForListV2([ @@ -167,6 +268,93 @@ describe("buildThreadListV2Items", () => { expect(layout.nextSnoozeWakeAt).toBe("2026-06-02T09:00:00.000Z"); }); + it("builds snoozed rows between active and settled when the shelf is expanded", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("settled"), + title: "Settled", + settledOverride: "settled", + settledAt: NOW, + }), + makeThread({ + id: ThreadId.make("later"), + title: "Wakes later", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("sooner"), + title: "Wakes sooner", + snoozedUntil: "2026-06-02T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + snoozedShelfExpanded: true, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual([ + "active", + "sooner", + "later", + "settled", + ]); + expect(layout.items.map((item) => item.snoozed)).toEqual([false, true, true, false]); + expect(layout.snoozedShelfHeaderIndex).toBe(1); + expect(layout.snoozedCount).toBe(2); + }); + + it("collapses to a header-only shelf", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("snoozed"), + title: "Snoozed", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(layout.items).toEqual([]); + expect(layout.snoozedCount).toBe(1); + expect(layout.snoozedShelfHeaderIndex).toBe(0); + }); + + it("keeps the selected thread on a collapsed shelf", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("open"), + title: "Open", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("other"), + title: "Other", + snoozedUntil: "2026-06-03T10:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + selectedThreadKey: `${environmentId}:open`, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["open"]); + expect(layout.items[0]?.snoozed).toBe(true); + expect(layout.snoozedCount).toBe(2); + }); + it("keeps snoozed threads visible on environments without the snooze capability", () => { const layout = buildThreadListV2Items({ threads: [ @@ -424,7 +612,11 @@ describe("buildThreadListV2ListItems", () => { expect( items.map((item) => - item.type === "v2-pending" ? item.pendingTask.title : item.item.thread.id, + item.type === "v2-pending" + ? item.pendingTask.title + : item.type === "v2-thread" + ? item.item.thread.id + : "snoozed-shelf", ), ).toEqual(["active", "queued-1", "queued-2", "settled"]); // Only the leading queued row labels the section, exactly like Settled. @@ -456,4 +648,41 @@ describe("buildThreadListV2ListItems", () => { `v2-thread:${environmentId}:settled`, ]); }); + + it("places queued tasks before a collapsed snoozed shelf", () => { + const snoozedLayout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "active" }), + makeThread({ + id: ThreadId.make("snoozed"), + title: "snoozed", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("settled"), + title: "settled", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + const items = buildThreadListV2ListItems({ + items: snoozedLayout.items, + pendingTasks: [makePendingTask("queued")], + snoozedCount: snoozedLayout.snoozedCount, + snoozedShelfExpanded: false, + snoozedShelfHeaderIndex: snoozedLayout.snoozedShelfHeaderIndex, + }); + + expect(items.map((item) => item.type)).toEqual([ + "v2-thread", + "v2-pending", + "v2-snoozed-shelf", + "v2-thread", + ]); + }); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 920b7f0b53a..57c81264a03 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,10 +1,18 @@ -import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { + effectiveSettled, + effectiveSnoozed, + hasQueuedTurnStart, + QUEUED_TURN_START_GRACE_MS, + snoozeWakeLabel, +} from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +export { snoozeWakeLabel }; + /** * Thread List v2 model, ported from the web sidebar v2 * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). @@ -14,6 +22,52 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; * unlabeled resting state. */ export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; +export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; + +export function resolveThreadListV2SwipeActions(input: { + readonly variant: "card" | "slim"; + readonly settlementSupported: boolean; + readonly snoozeSupported: boolean; + readonly snoozable: boolean; + /** Row is on the snoozed shelf. */ + readonly snoozed?: boolean; +}): { + readonly primary: Exclude; + readonly secondary: "snooze" | null; +} { + if (input.snoozed === true) { + return { primary: "unsnooze", secondary: null }; + } + const primary = input.settlementSupported + ? input.variant === "slim" + ? "unsettle" + : "settle" + : "archive"; + return { + primary, + secondary: input.snoozeSupported && input.snoozable ? "snooze" : null, + }; +} + +/** + * The point at which a queued-turn snooze guard expires on its own. Rows arm + * a one-shot timer for this boundary so Snooze appears without waiting for an + * unrelated render. User-blocked threads return null because only fresh + * server data can make them snoozable. + */ +export function resolveThreadListV2SnoozeGateExpiryMs( + thread: Pick< + EnvironmentThreadShell, + "hasPendingApprovals" | "hasPendingUserInput" | "latestUserMessageAt" | "latestTurn" | "session" + >, + options: { readonly now: string }, +): number | null { + if (thread.hasPendingApprovals || thread.hasPendingUserInput) return null; + if (!hasQueuedTurnStart(thread, options)) return null; + const messageAtMs = Date.parse(thread.latestUserMessageAt ?? ""); + if (Number.isNaN(messageAtMs)) return null; + return messageAtMs + QUEUED_TURN_START_GRACE_MS; +} // Settled-tail paging: recent history is the common lookup; the deep tail // stays behind an explicit Show more. Shared by the compact Home list and @@ -100,6 +154,8 @@ export interface ThreadListV2Item { readonly variant: "card" | "slim"; /** First settled row after the card block draws the SETTLED divider. */ readonly showSettledDivider: boolean; + /** Snoozed-shelf row: shows the wake countdown and offers Wake. */ + readonly snoozed: boolean; readonly isLast: boolean; } @@ -107,10 +163,12 @@ export interface ThreadListV2Layout { readonly items: ThreadListV2Item[]; /** Settled threads beyond the render limit (behind "Show more"). */ readonly hiddenSettledCount: number; - /** Snoozed threads hidden from the list (visibility parity with web's - collapsed Snoozed shelf; mobile has no shelf UI yet). */ + /** Snoozed threads matching the current filters. */ readonly snoozedCount: number; - /** Soonest wake time among hidden snoozed threads, or null. Callers arm + /** Index in `items` where the Snoozed shelf header belongs. The header is + still rendered when the shelf is collapsed and no snoozed rows exist. */ + readonly snoozedShelfHeaderIndex: number | null; + /** Soonest wake time among snoozed threads, or null. Callers arm a timeout at this boundary so the list re-partitions the moment a snooze expires instead of on the next minute tick. */ readonly nextSnoozeWakeAt: string | null; @@ -120,6 +178,8 @@ export interface ThreadListV2ThreadListItem { readonly type: "v2-thread"; readonly key: string; readonly item: ThreadListV2Item; + /** Precomputed so recycled-list equality can see a minute-tick change. */ + readonly snoozeWakeLabelText: string | undefined; } export interface ThreadListV2PendingListItem { @@ -130,28 +190,42 @@ export interface ThreadListV2PendingListItem { readonly showPendingDivider: boolean; } -export type ThreadListV2ListItem = ThreadListV2ThreadListItem | ThreadListV2PendingListItem; +export interface ThreadListV2SnoozedShelfListItem { + readonly type: "v2-snoozed-shelf"; + readonly key: "v2-snoozed-shelf"; + readonly count: number; + readonly expanded: boolean; +} + +export type ThreadListV2ListItem = + | ThreadListV2ThreadListItem + | ThreadListV2PendingListItem + | ThreadListV2SnoozedShelfListItem; /** - * Splices queued tasks between the active block and the settled tail, so the - * list reads active → pending → settled. Queued work sits below the live - * threads because nothing can happen to it until its environment returns: - * it is waiting, not asking. Shared by the compact Home list and the iPad - * sidebar so both order and label the sections identically. + * Builds the shared mobile order: active → pending → snoozed shelf → settled. + * Pending tasks are waiting rather than asking, and parked work remains + * reachable without competing with either the inbox or settled history. */ export function buildThreadListV2ListItems(input: { readonly items: ReadonlyArray; readonly pendingTasks: ReadonlyArray; + readonly snoozedCount?: number; + readonly snoozedShelfExpanded?: boolean; + readonly snoozedShelfHeaderIndex?: number | null; + readonly snoozeLabelNow?: string; }): ThreadListV2ListItem[] { const threadItems = input.items.map( (item): ThreadListV2ListItem => ({ type: "v2-thread", key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, item, + snoozeWakeLabelText: + item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined + ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow }) + : undefined, }), ); - if (input.pendingTasks.length === 0) return threadItems; - const pendingItems = input.pendingTasks.map( (pendingTask, index): ThreadListV2ListItem => ({ type: "v2-pending", @@ -160,8 +234,23 @@ export function buildThreadListV2ListItems(input: { showPendingDivider: index === 0, }), ); - // The settled tail begins at the row that draws the SETTLED divider; with - // no settled rows the queued block simply ends the list. + const snoozedCount = input.snoozedCount ?? 0; + const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null; + if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) { + return [ + ...threadItems.slice(0, snoozedShelfHeaderIndex), + ...pendingItems, + { + type: "v2-snoozed-shelf", + key: "v2-snoozed-shelf", + count: snoozedCount, + expanded: input.snoozedShelfExpanded === true, + }, + ...threadItems.slice(snoozedShelfHeaderIndex), + ]; + } + if (pendingItems.length === 0) return threadItems; + const settledStart = threadItems.findIndex( (entry) => entry.type === "v2-thread" && entry.item.showSettledDivider, ); @@ -204,6 +293,11 @@ export function buildThreadListV2Items(input: { second-precise, so classifying with the floored minute would hold a woken thread hidden for up to a minute. Defaults to `now`. */ readonly snoozeNow?: string; + /** Expands the snoozed shelf into rows. Collapsed is the default. */ + readonly snoozedShelfExpanded?: boolean; + /** The selected thread remains visible on an otherwise collapsed shelf so + a split-view detail can never lose its navigation row. */ + readonly selectedThreadKey?: string | null; }): ThreadListV2Layout { const now = input.now ?? new Date().toISOString(); const snoozeNow = input.snoozeNow ?? now; @@ -215,7 +309,7 @@ export function buildThreadListV2Items(input: { const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; - let snoozedCount = 0; + const snoozed: EnvironmentThreadShell[] = []; let nextSnoozeWakeAt: string | null = null; for (const thread of input.threads) { // Callers pass live (unarchived) shells; settled threads are among them @@ -244,7 +338,7 @@ export function buildThreadListV2Items(input: { // wakes (or raises its hand — effectiveSnoozed refuses blocked/failed // work). Snooze outranks settled classification, same as web. if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { - snoozedCount += 1; + snoozed.push(thread); if ( thread.snoozedUntil != null && (nextSnoozeWakeAt === null || @@ -265,6 +359,17 @@ export function buildThreadListV2Items(input: { } const orderedActive = sortThreadsForListV2(active); + const orderedSnoozed = [...snoozed].sort( + (left, right) => + parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""), + ); + const selectedThreadKey = input.selectedThreadKey ?? null; + const visibleSnoozed = + input.snoozedShelfExpanded === true + ? orderedSnoozed + : orderedSnoozed.filter( + (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, + ); const orderedSettled = [...settled].sort( (left, right) => firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - @@ -276,13 +381,30 @@ export function buildThreadListV2Items(input: { const items: ThreadListV2Item[] = []; for (const thread of orderedActive) { - items.push({ thread, variant: "card", showSettledDivider: false, isLast: false }); + items.push({ + thread, + variant: "card", + showSettledDivider: false, + snoozed: false, + isLast: false, + }); + } + const snoozedShelfHeaderIndex = orderedSnoozed.length > 0 ? items.length : null; + for (const thread of visibleSnoozed) { + items.push({ + thread, + variant: "slim", + showSettledDivider: false, + snoozed: true, + isLast: false, + }); } for (const [index, thread] of visibleSettled.entries()) { items.push({ thread, variant: "slim", showSettledDivider: index === 0, + snoozed: false, isLast: false, }); } @@ -293,7 +415,8 @@ export function buildThreadListV2Items(input: { return { items, hiddenSettledCount: orderedSettled.length - visibleSettled.length, - snoozedCount, + snoozedCount: orderedSnoozed.length, + snoozedShelfHeaderIndex, nextSnoozeWakeAt, }; } diff --git a/apps/web/src/components/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts index 4a4518a2591..88c1d9ea52d 100644 --- a/apps/web/src/components/Sidebar.snooze.test.ts +++ b/apps/web/src/components/Sidebar.snooze.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveSnoozePresets, snoozeWakeDescription, snoozeWakeLabel } from "./Sidebar.snooze"; +import { resolveSnoozePresets, snoozeWakeDescription } from "./Sidebar.snooze"; // Local-time constructor so preset math is timezone-stable in tests. function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date { @@ -64,21 +64,6 @@ describe("resolveSnoozePresets", () => { }); }); -describe("snoozeWakeLabel", () => { - const now = localDate(2026, 4, 8, 10); - - it("formats minutes, hours, and days, rounding up", () => { - expect(snoozeWakeLabel(new Date(now.getTime() + 30 * 60_000).toISOString(), now)).toBe("30m"); - expect(snoozeWakeLabel(new Date(now.getTime() + 90 * 60_000).toISOString(), now)).toBe("2h"); - expect(snoozeWakeLabel(new Date(now.getTime() + 26 * 3_600_000).toISOString(), now)).toBe("2d"); - }); - - it("reports now for past and malformed wake times", () => { - expect(snoozeWakeLabel(new Date(now.getTime() - 1000).toISOString(), now)).toBe("now"); - expect(snoozeWakeLabel("not-a-date", now)).toBe("now"); - }); -}); - describe("snoozeWakeDescription", () => { const now = localDate(2026, 4, 8, 10); diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index dd56cec7290..7c61afcc2bc 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -93,19 +93,8 @@ export function resolveSnoozePresets(now: Date): ReadonlyArray { return presets; } -/** - * Compact "wakes in" label for snoozed rows: "2h", "18h", "3d". Minutes - * round up so a snooze never reads "0m" while still hidden. - */ -export function snoozeWakeLabel(snoozedUntil: string, now: Date): string { - const wake = parseTimestampDate(snoozedUntil); - if (wake === null) return "now"; - const remainingMs = wake.getTime() - now.getTime(); - if (remainingMs <= 0) return "now"; - if (remainingMs < HOUR_MS) return `${Math.max(1, Math.ceil(remainingMs / 60_000))}m`; - if (remainingMs < DAY_MS) return `${Math.ceil(remainingMs / HOUR_MS)}h`; - return `${Math.ceil(remainingMs / DAY_MS)}d`; -} +/** Shared with mobile so a wake time never reads differently per platform. */ +export { snoozeWakeLabel } from "@t3tools/client-runtime/state/thread-settled"; /** * Human wake time for menus and toasts: "tomorrow 9:00", "Mon 9:00", diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index d58d2b37cd1..e36a4407072 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -2611,7 +2611,9 @@ export default function SidebarV2() { } snoozeWakeLabelText={ section === "snoozed" && thread.snoozedUntil != null - ? snoozeWakeLabel(thread.snoozedUntil, new Date()) + ? snoozeWakeLabel(thread.snoozedUntil, { + now: new Date().toISOString(), + }) : null } // All sections: a woken thread can classify straight diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 3a98b9d2f27..384e9c0f967 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -272,3 +272,21 @@ export function effectiveSettled( Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS ); } + +const HOUR_MS = 60 * 60 * 1_000; + +/** + * Compact "wakes in" label for snoozed rows: "2h", "18h", "3d". Minutes + * round up so a snooze never reads "0m" while still hidden. Shared by web + * and mobile so the same wake time never reads differently per client. + */ +export function snoozeWakeLabel(snoozedUntil: string, options: { readonly now: string }): string { + const wakeMs = Date.parse(snoozedUntil); + const nowMs = Date.parse(options.now); + if (Number.isNaN(wakeMs) || Number.isNaN(nowMs)) return "now"; + const remainingMs = wakeMs - nowMs; + if (remainingMs <= 0) return "now"; + if (remainingMs < HOUR_MS) return `${Math.max(1, Math.ceil(remainingMs / 60_000))}m`; + if (remainingMs < DAY_MS) return `${Math.ceil(remainingMs / HOUR_MS)}h`; + return `${Math.ceil(remainingMs / DAY_MS)}d`; +} diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index c26d23e264c..dda1940ac7f 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vite-plus/test"; import { canSnooze, effectiveSnoozed, + snoozeWakeLabel, threadRaisedHandWhileSnoozed, threadWokeAt, type ThreadSnoozeShell, @@ -235,3 +236,20 @@ describe("threadWokeAt", () => { ).toBe("2026-04-10T09:30:00.000Z"); }); }); + +describe("snoozeWakeLabel", () => { + const now = "2026-06-02T00:00:00.000Z"; + + it("formats remaining time coarsely, rounding up", () => { + expect(snoozeWakeLabel("2026-06-02T00:30:00.000Z", { now })).toBe("30m"); + expect(snoozeWakeLabel("2026-06-02T01:30:00.000Z", { now })).toBe("2h"); + expect(snoozeWakeLabel("2026-06-03T02:00:00.000Z", { now })).toBe("2d"); + }); + + it("never reads zero or negative while still snoozed", () => { + expect(snoozeWakeLabel("2026-06-02T00:00:30.000Z", { now })).toBe("1m"); + expect(snoozeWakeLabel("2026-06-01T23:59:59.000Z", { now })).toBe("now"); + expect(snoozeWakeLabel("not-a-date", { now })).toBe("now"); + expect(snoozeWakeLabel("2026-06-02T09:00:00.000Z", { now: "bad" })).toBe("now"); + }); +}); From 80a5b67c253a79f684c7c5a74959fc521c0ed9a5 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:39:05 -0400 Subject: [PATCH 2/5] fix(mobile): add snooze to thread long-press menu --- .../src/features/threads/thread-list-v2-items.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 79a3a48c36a..53339eba1fd 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -64,6 +64,12 @@ const CARD_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; +const SNOOZABLE_CARD_MENU_ACTIONS: MenuAction[] = [ + { id: "settle", title: "Settle", image: "checkmark" }, + { id: "snooze", title: "Snooze 1h", image: "clock" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + const SLIM_MENU_ACTIONS: MenuAction[] = [ { id: "unsettle", title: "Un-settle", image: "arrow.uturn.backward" }, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, @@ -354,11 +360,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "settle") handleSettle(); if (nativeEvent.event === "unsettle") handleUnsettle(); + if (nativeEvent.event === "snooze") handleSnooze(); if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); }, - [handleArchive, handleDelete, handleSettle, handleUnsettle, handleUnsnooze], + [handleArchive, handleDelete, handleSettle, handleSnooze, handleUnsettle, handleUnsnooze], ); // Swipe: the v2 primary action is the lifecycle transition. Every settled @@ -708,7 +715,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ? LEGACY_MENU_ACTIONS : canUnsettle ? SLIM_MENU_ACTIONS - : CARD_MENU_ACTIONS + : swipeActions.secondary === "snooze" + ? SNOOZABLE_CARD_MENU_ACTIONS + : CARD_MENU_ACTIONS } onPressAction={handleMenuAction} shouldOpenOnLongPress From 571605a679b26a2eabe76c97a54544acb0eb8235 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:33:11 -0400 Subject: [PATCH 3/5] fix(mobile): match desktop snooze presets --- apps/mobile/src/features/home/HomeScreen.tsx | 9 +- .../features/home/thread-swipe-actions.tsx | 140 +++++++++++------- .../src/features/home/useThreadListActions.ts | 8 +- .../features/threads/thread-list-v2-items.tsx | 91 ++++++++---- apps/web/src/components/Sidebar.snooze.ts | 101 +------------ .../client-runtime/src/state/threadSettled.ts | 80 ++++++++++ .../src/state/threadSnoozed.test.ts | 39 +++++ 7 files changed, 288 insertions(+), 180 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 9424e31a49a..e969bace87e 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -104,7 +104,10 @@ interface HomeScreenProps { readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; /** Resolves true iff the settle was dispatched and succeeded. */ readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; - readonly onSnoozeThread: (thread: EnvironmentThreadShell) => Promise; + readonly onSnoozeThread: ( + thread: EnvironmentThreadShell, + snoozedUntil: string, + ) => Promise; readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; @@ -501,8 +504,8 @@ export function HomeScreen(props: HomeScreenProps) { [props.onSettleThread], ); const handleSnoozeThread = useCallback( - (thread: EnvironmentThreadShell) => { - void props.onSnoozeThread(thread); + (thread: EnvironmentThreadShell, snoozedUntil: string) => { + void props.onSnoozeThread(thread, snoozedUntil); }, [props.onSnoozeThread], ); diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index a9da317c6dc..73af8acb4af 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -1,4 +1,6 @@ import { SymbolView } from "../../components/AppSymbol"; +import { ControlPillMenu } from "../../components/ControlPill"; +import type { MenuAction } from "@react-native-menu/menu"; import * as Haptics from "expo-haptics"; import { createContext, @@ -51,6 +53,11 @@ interface ThreadSwipeAction { readonly accessibilityLabel: string; readonly icon: ComponentProps["name"]; readonly label: string; + readonly menu?: { + readonly actions: MenuAction[]; + readonly onPressAction: NonNullable["onPressAction"]>; + readonly title?: string; + }; readonly onPress: () => void; } @@ -86,6 +93,16 @@ function resolveSecondaryAction(input: { return { ...action, backgroundColor: "#5856d6", + menu: + action.menu === undefined + ? undefined + : { + ...action.menu, + onPressAction: (event) => { + input.close(); + action.menu?.onPressAction(event); + }, + }, onPress: () => { input.close(); action.onPress(); @@ -348,6 +365,7 @@ function SwipeActionButton(props: { readonly fullSwipeThreshold: number; readonly icon: ComponentProps["name"]; readonly label: string; + readonly menu?: ThreadSwipeAction["menu"]; readonly onPress: () => void; readonly stretchesOnFullSwipe: boolean; readonly translation: SharedValue; @@ -418,6 +436,63 @@ function SwipeActionButton(props: { }; }); + const button = ( + ({ + alignItems: "center", + height: "100%", + justifyContent: "center", + opacity: pressed ? 0.72 : 1, + width: "100%", + })} + > + + + + + + + + + {props.label} + + + + ); + return ( - ({ - alignItems: "center", - height: "100%", - justifyContent: "center", - opacity: pressed ? 0.72 : 1, - width: "100%", - })} - > - - - - - - - - - {props.label} - - - + {button} + + )} ); } @@ -548,6 +581,7 @@ export function ThreadSwipeActions(props: { fullSwipeThreshold={props.fullSwipeThreshold} icon={secondaryAction.icon} label={secondaryAction.label} + menu={secondaryAction.menu} onPress={secondaryAction.onPress} stretchesOnFullSwipe={!fullSwipeIsPrimary} translation={props.translation} diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index f6f517eb490..442115c2e06 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -13,8 +13,6 @@ import { environmentServerConfigsAtom } from "../../state/server"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; -const SWIPE_SNOOZE_DURATION_MS = 60 * 60 * 1_000; - /** Version skew: never send settle/unsettle to a server that predates them (capability defaults false on decode for older servers). */ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["environmentId"]) { @@ -201,7 +199,7 @@ export function useThreadListActions(): { readonly archiveThread: (thread: EnvironmentThreadShell) => void; readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; readonly settleThread: (thread: EnvironmentThreadShell) => Promise; - readonly snoozeThread: (thread: EnvironmentThreadShell) => Promise; + readonly snoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => Promise; readonly unsnoozeThread: (thread: EnvironmentThreadShell) => Promise; readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; } { @@ -220,7 +218,7 @@ export function useThreadListActions(): { [executeAction], ); const snoozeThread = useCallback( - async (thread: EnvironmentThreadShell) => { + async (thread: EnvironmentThreadShell, snoozedUntil: string) => { if (!environmentSupportsSnooze(thread.environmentId)) { Alert.alert( "Could not snooze thread", @@ -243,7 +241,7 @@ export function useThreadListActions(): { environmentId: thread.environmentId, input: { threadId: thread.id, - snoozedUntil: new Date(Date.now() + SWIPE_SNOOZE_DURATION_MS).toISOString(), + snoozedUntil, }, }); if (result._tag === "Failure") { diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 53339eba1fd..396e9663e98 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -3,7 +3,7 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; -import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { Platform, Pressable, useColorScheme, useWindowDimensions, View } from "react-native"; @@ -64,12 +64,6 @@ const CARD_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; -const SNOOZABLE_CARD_MENU_ACTIONS: MenuAction[] = [ - { id: "settle", title: "Settle", image: "checkmark" }, - { id: "snooze", title: "Snooze 1h", image: "clock" }, - { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, -]; - const SLIM_MENU_ACTIONS: MenuAction[] = [ { id: "unsettle", title: "Un-settle", image: "arrow.uturn.backward" }, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, @@ -293,7 +287,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onSettleThread: (thread: EnvironmentThreadShell) => void; - readonly onSnoozeThread: (thread: EnvironmentThreadShell) => void; + readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void; readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; @@ -352,21 +346,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); - const handleSnooze = useCallback(() => onSnoozeThread(thread), [onSnoozeThread, thread]); + const handleSnooze = useCallback( + (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil), + [onSnoozeThread, thread], + ); const handleUnsnooze = useCallback(() => onUnsnoozeThread(thread), [onUnsnoozeThread, thread]); const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); - const handleMenuAction = useCallback( - ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { - if (nativeEvent.event === "settle") handleSettle(); - if (nativeEvent.event === "unsettle") handleUnsettle(); - if (nativeEvent.event === "snooze") handleSnooze(); - if (nativeEvent.event === "unsnooze") handleUnsnooze(); - if (nativeEvent.event === "archive") handleArchive(); - if (nativeEvent.event === "delete") handleDelete(); - }, - [handleArchive, handleDelete, handleSettle, handleSnooze, handleUnsettle, handleUnsnooze], - ); // Swipe: the v2 primary action is the lifecycle transition. Every settled // row can un-settle — explicit settles clear the override, auto-settled @@ -389,6 +375,56 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { snoozable: canSnooze(thread, { now: new Date().toISOString() }), snoozed: snoozedRow, }); + const snoozePresetMinute = new Date().toISOString().slice(0, 16); + const snoozePresets = useMemo( + () => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)), + [snoozePresetMinute, swipeActions.secondary], + ); + const snoozePresetActions = useMemo( + () => + snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}`, + title: preset.label, + subtitle: preset.whenLabel, + })), + [snoozePresets], + ); + const snoozableCardMenuActions = useMemo( + () => [ + { id: "settle", title: "Settle", image: "checkmark" }, + { + id: "snooze", + title: "Snooze", + image: "clock", + subactions: snoozePresetActions, + }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, + ], + [snoozePresetActions], + ); + const handleMenuAction = useCallback( + ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "settle") handleSettle(); + if (nativeEvent.event === "unsettle") handleUnsettle(); + if (nativeEvent.event === "unsnooze") handleUnsnooze(); + if (nativeEvent.event === "archive") handleArchive(); + if (nativeEvent.event === "delete") handleDelete(); + const preset = + resolveSnoozePresets(new Date()).find( + (candidate) => nativeEvent.event === `snooze:${candidate.id}`, + ) ?? snoozePresets.find((candidate) => nativeEvent.event === `snooze:${candidate.id}`); + if (preset) handleSnooze(preset.snoozedUntil); + }, + [ + handleArchive, + handleDelete, + handleSettle, + handleSnooze, + handleUnsettle, + handleUnsnooze, + snoozePresets, + ], + ); const primaryAction = useMemo(() => { // Pre-settlement server: archive is the swipe action, as in v1. (Slim // rows cannot occur here — unsupported environments never classify as @@ -434,13 +470,18 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { () => swipeActions.secondary === "snooze" ? { - accessibilityLabel: `Snooze ${thread.title} for 1 hour`, + accessibilityLabel: `Choose when to snooze ${thread.title}`, icon: "clock" as const, - label: "Snooze 1h", - onPress: handleSnooze, + label: "Snooze", + menu: { + actions: snoozePresetActions, + onPressAction: handleMenuAction, + title: "Snooze until", + }, + onPress: () => undefined, } : null, - [handleSnooze, swipeActions.secondary, thread.title], + [handleMenuAction, snoozePresetActions, swipeActions.secondary, thread.title], ); const swipeAccessibilityHint = secondaryAction === null @@ -716,7 +757,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : canUnsettle ? SLIM_MENU_ACTIONS : swipeActions.secondary === "snooze" - ? SNOOZABLE_CARD_MENU_ACTIONS + ? snoozableCardMenuActions : CARD_MENU_ACTIONS } onPressAction={handleMenuAction} diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index 7c61afcc2bc..adbc361a8ee 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -1,100 +1,13 @@ -/** - * Snooze preset resolution for the sidebar snooze menu. Pure functions so - * the preset math (evening/tomorrow/next-week boundaries) is unit-testable - * without a DOM. - * - * Presets deliberately skew short: agent-thread rhythms are hours (a CI - * run, a teammate review, the next work session), not days. - */ +import { snoozeWakeLabel } from "@t3tools/client-runtime/state/thread-settled"; import { parseTimestampDate } from "../timestampFormat"; -type SnoozePresetId = "hour" | "evening" | "tomorrow" | "next-week"; - -export interface SnoozePreset { - readonly id: SnoozePresetId; - readonly label: string; - /** Menu-row time column. Complements the label instead of repeating it: - "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". */ - readonly whenLabel: string; - /** ISO wake time. */ - readonly snoozedUntil: string; -} - -function timeOfDayLabel(date: Date): string { - return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); -} - -const EVENING_HOUR = 18; -const MORNING_HOUR = 9; -const HOUR_MS = 60 * 60 * 1_000; -const DAY_MS = 24 * HOUR_MS; - -function atHour(base: Date, hour: number): Date { - const next = new Date(base); - next.setHours(hour, 0, 0, 0); - return next; -} - -// Calendar-day advance instead of adding DAY_MS: fixed millisecond offsets -// land on the wrong local day across DST transitions (a spring-forward day -// is 23 hours, so 23:30 + 24h skips the whole next day). -function addDays(base: Date, days: number): Date { - const next = new Date(base); - next.setDate(next.getDate() + days); - return next; -} - -/** - * Presets for "snooze until", computed against local time. "This evening" - * only appears while it is still meaningfully before evening; after that - * the list starts at "Tomorrow". - */ -export function resolveSnoozePresets(now: Date): ReadonlyArray { - const inAnHour = new Date(now.getTime() + HOUR_MS); - const presets: SnoozePreset[] = [ - { - id: "hour", - label: "In 1 hour", - whenLabel: timeOfDayLabel(inAnHour), - snoozedUntil: inAnHour.toISOString(), - }, - ]; - - const evening = atHour(now, EVENING_HOUR); - // Suppress the evening preset once it is within an hour (or past): it - // would duplicate "In 1 hour" or point at the past. - if (evening.getTime() - now.getTime() > HOUR_MS) { - presets.push({ - id: "evening", - label: "This evening", - whenLabel: timeOfDayLabel(evening), - snoozedUntil: evening.toISOString(), - }); - } - - const tomorrow = atHour(addDays(now, 1), MORNING_HOUR); - presets.push({ - id: "tomorrow", - label: "Tomorrow", - whenLabel: timeOfDayLabel(tomorrow), - snoozedUntil: tomorrow.toISOString(), - }); - - // Next Monday 9:00 (a week out when today is Monday). - const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; - const nextWeek = atHour(addDays(now, daysUntilMonday), MORNING_HOUR); - presets.push({ - id: "next-week", - label: "Next week", - whenLabel: `${nextWeek.toLocaleDateString(undefined, { weekday: "short" })} ${timeOfDayLabel(nextWeek)}`, - snoozedUntil: nextWeek.toISOString(), - }); - - return presets; -} +export { + resolveSnoozePresets, + type SnoozePreset, +} from "@t3tools/client-runtime/state/thread-settled"; +export { snoozeWakeLabel }; -/** Shared with mobile so a wake time never reads differently per platform. */ -export { snoozeWakeLabel } from "@t3tools/client-runtime/state/thread-settled"; +const DAY_MS = 24 * 60 * 60 * 1_000; /** * Human wake time for menus and toasts: "tomorrow 9:00", "Mon 9:00", diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 384e9c0f967..51a82339655 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. import type { OrchestrationThreadShell } from "@t3tools/contracts"; export type ChangeRequestStateLike = "open" | "closed" | "merged"; @@ -274,6 +275,85 @@ export function effectiveSettled( } const HOUR_MS = 60 * 60 * 1_000; +const EVENING_HOUR = 18; +const MORNING_HOUR = 9; + +export type SnoozePresetId = "hour" | "evening" | "tomorrow" | "next-week"; + +export interface SnoozePreset { + readonly id: SnoozePresetId; + readonly label: string; + /** Menu-row time column. Complements the label instead of repeating it: + "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". */ + readonly whenLabel: string; + /** ISO wake time. */ + readonly snoozedUntil: string; +} + +function snoozeTimeOfDayLabel(date: Date): string { + return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); +} + +function snoozeAtHour(base: Date, hour: number): Date { + const next = new Date(base); + next.setHours(hour, 0, 0, 0); + return next; +} + +// Calendar-day advance instead of adding DAY_MS: fixed millisecond offsets +// land on the wrong local day across DST transitions (a spring-forward day +// is 23 hours, so 23:30 + 24h skips the whole next day). +function addSnoozeDays(base: Date, days: number): Date { + const next = new Date(base); + next.setDate(next.getDate() + days); + return next; +} + +/** + * Shared "snooze until" choices for every client. "This evening" only + * appears while it is meaningfully before evening; after that the list + * starts at "Tomorrow". + */ +export function resolveSnoozePresets(now: Date): ReadonlyArray { + const inAnHour = new Date(now.getTime() + HOUR_MS); + const presets: SnoozePreset[] = [ + { + id: "hour", + label: "In 1 hour", + whenLabel: snoozeTimeOfDayLabel(inAnHour), + snoozedUntil: inAnHour.toISOString(), + }, + ]; + + const evening = snoozeAtHour(now, EVENING_HOUR); + if (evening.getTime() - now.getTime() > HOUR_MS) { + presets.push({ + id: "evening", + label: "This evening", + whenLabel: snoozeTimeOfDayLabel(evening), + snoozedUntil: evening.toISOString(), + }); + } + + const tomorrow = snoozeAtHour(addSnoozeDays(now, 1), MORNING_HOUR); + presets.push({ + id: "tomorrow", + label: "Tomorrow", + whenLabel: snoozeTimeOfDayLabel(tomorrow), + snoozedUntil: tomorrow.toISOString(), + }); + + const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; + const nextWeek = snoozeAtHour(addSnoozeDays(now, daysUntilMonday), MORNING_HOUR); + presets.push({ + id: "next-week", + label: "Next week", + whenLabel: `${nextWeek.toLocaleDateString(undefined, { weekday: "short" })} ${snoozeTimeOfDayLabel(nextWeek)}`, + snoozedUntil: nextWeek.toISOString(), + }); + + return presets; +} /** * Compact "wakes in" label for snoozed rows: "2h", "18h", "3d". Minutes diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index dda1940ac7f..2c1c58d37a1 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off -- Tests exercise local calendar snooze boundaries. import { ThreadId } from "@t3tools/contracts"; import { TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -5,6 +6,7 @@ import { describe, expect, it } from "vite-plus/test"; import { canSnooze, effectiveSnoozed, + resolveSnoozePresets, snoozeWakeLabel, threadRaisedHandWhileSnoozed, threadWokeAt, @@ -16,6 +18,10 @@ const SNOOZED_AT = "2026-04-10T09:00:00.000Z"; const FUTURE_WAKE = "2026-04-11T09:00:00.000Z"; const PAST_WAKE = "2026-04-10T10:00:00.000Z"; +function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date { + return new Date(year, month - 1, day, hour, minute, 0, 0); +} + function makeShell(input: { readonly snoozedUntil?: string | null; readonly snoozedAt?: string | null; @@ -253,3 +259,36 @@ describe("snoozeWakeLabel", () => { expect(snoozeWakeLabel("2026-06-02T09:00:00.000Z", { now: "bad" })).toBe("now"); }); }); + +describe("resolveSnoozePresets", () => { + it("offers the shared desktop and mobile choices", () => { + const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10)); + expect(presets.map((preset) => preset.id)).toEqual([ + "hour", + "evening", + "tomorrow", + "next-week", + ]); + expect(presets.find((preset) => preset.id === "evening")?.label).toBe("This evening"); + expect( + new Date(presets.find((preset) => preset.id === "tomorrow")!.snoozedUntil).getHours(), + ).toBe(9); + }); + + it("drops the evening choice once evening is near or past", () => { + expect(resolveSnoozePresets(localDate(2026, 4, 8, 17, 30)).map((preset) => preset.id)).toEqual([ + "hour", + "tomorrow", + "next-week", + ]); + }); + + it("puts next week on the following Monday", () => { + const nextWeek = new Date( + resolveSnoozePresets(localDate(2026, 4, 6, 10)).find((preset) => preset.id === "next-week")! + .snoozedUntil, + ); + expect(nextWeek.getDay()).toBe(1); + expect(nextWeek.getDate()).toBe(13); + }); +}); From feab8cd440e22792f49035ffd0215397835c83a7 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:05:28 -0400 Subject: [PATCH 4/5] fix(mobile): address snooze review feedback --- apps/mobile/src/features/home/HomeScreen.tsx | 3 + .../features/home/thread-swipe-actions.tsx | 10 +- .../src/features/home/useThreadListActions.ts | 123 ++++++++++-------- .../threads/ThreadNavigationSidebar.tsx | 4 + .../features/threads/thread-list-v2-items.tsx | 23 ++-- 5 files changed, 91 insertions(+), 72 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index e969bace87e..78907cf21ca 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -700,6 +700,7 @@ export function HomeScreen(props: HomeScreenProps) { variant={item.item.variant} showSettledDivider={item.item.showSettledDivider} snoozed={item.item.snoozed} + snoozePresetMinute={nowMinute} snoozeWakeLabelText={item.snoozeWakeLabelText} project={ projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null @@ -785,6 +786,7 @@ export function HomeScreen(props: HomeScreenProps) { serverConfigs, savedConnectionsById: props.savedConnectionsById, searchQuery: props.searchQuery, + snoozePresetMinute: nowMinute, threadSearchMatchByKey, }), [ @@ -793,6 +795,7 @@ export function HomeScreen(props: HomeScreenProps) { props.searchQuery, props.savedConnectionsById, serverConfigs, + nowMinute, threadSearchMatchByKey, v2ProjectTitleByProjectKey, ], diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 73af8acb4af..973c4fae9ce 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -229,10 +229,9 @@ export function ThreadSwipeable(props: { readonly enabled?: boolean; readonly enableTrackpadSwipe?: boolean; /** - * What a full swipe commits: "delete" (default, v1 behavior — the Delete - * button stretches) or "primary" — the advertised primary action fires and - * its button stretches instead. A full swipe must always match the action - * the stretching button advertises. + * What a full swipe commits. Omitted keeps the v1 Delete behavior only when + * the built-in Delete secondary action is in use; custom or absent + * secondary actions default to the advertised primary action. */ readonly fullSwipeAction?: "delete" | "primary"; readonly fullSwipeWidth: number; @@ -262,7 +261,8 @@ export function ThreadSwipeable(props: { const hasSecondaryAction = props.secondaryAction !== null; const actionsWidth = swipeActionsWidth(hasSecondaryAction); const fullSwipeThreshold = Math.max(actionsWidth + 44, props.fullSwipeWidth * 0.58); - const fullSwipeAction = hasSecondaryAction ? (props.fullSwipeAction ?? "delete") : "primary"; + const fullSwipeAction = + props.fullSwipeAction ?? (props.secondaryAction === undefined ? "delete" : "primary"); const close = useCallback(() => swipeableRef.current?.close(), []); const gateEnabled = use(SwipeableScrollGateContext); const resetKey = props.resetKey; diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 442115c2e06..0c621a04e38 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -206,6 +206,7 @@ export function useThreadListActions(): { const executeAction = useThreadActionExecutor(); const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); + const snoozeInFlightThreadKeys = useRef(new Set()); const archiveThread = useCallback( (thread: EnvironmentThreadShell) => { @@ -219,71 +220,89 @@ export function useThreadListActions(): { ); const snoozeThread = useCallback( async (thread: EnvironmentThreadShell, snoozedUntil: string) => { - if (!environmentSupportsSnooze(thread.environmentId)) { - Alert.alert( - "Could not snooze thread", - "This environment's server does not support snoozing yet. Update the server to use Snooze.", - ); - return false; - } - if (!canSnooze(thread, { now: new Date().toISOString() })) { - Alert.alert( - "Could not snooze thread", - thread.hasPendingApprovals || thread.hasPendingUserInput - ? "This thread is waiting on you. Respond to the pending request before snoozing it." - : "This thread is still starting a turn. Try again once it's running.", - ); + const key = scopedThreadKey(thread.environmentId, thread.id); + if (snoozeInFlightThreadKeys.current.has(key)) { return false; } + snoozeInFlightThreadKeys.current.add(key); + try { + if (!environmentSupportsSnooze(thread.environmentId)) { + Alert.alert( + "Could not snooze thread", + "This environment's server does not support snoozing yet. Update the server to use Snooze.", + ); + return false; + } + if (!canSnooze(thread, { now: new Date().toISOString() })) { + Alert.alert( + "Could not snooze thread", + thread.hasPendingApprovals || thread.hasPendingUserInput + ? "This thread is waiting on you. Respond to the pending request before snoozing it." + : "This thread is still starting a turn. Try again once it's running.", + ); + return false; + } - selectionHaptic(); - const result = await snoozeMutation({ - environmentId: thread.environmentId, - input: { - threadId: thread.id, - snoozedUntil, - }, - }); - if (result._tag === "Failure") { - const error = Cause.squash(result.cause); - Alert.alert( - "Could not snooze thread", - error instanceof Error && error.message.trim().length > 0 - ? error.message - : "The thread could not be snoozed.", - ); - return false; + selectionHaptic(); + const result = await snoozeMutation({ + environmentId: thread.environmentId, + input: { + threadId: thread.id, + snoozedUntil, + }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not snooze thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be snoozed.", + ); + return false; + } + return true; + } finally { + snoozeInFlightThreadKeys.current.delete(key); } - return true; }, [snoozeMutation], ); const unsnoozeThread = useCallback( async (thread: EnvironmentThreadShell) => { - if (!environmentSupportsSnooze(thread.environmentId)) { - Alert.alert( - "Could not wake thread", - "This environment's server does not support snoozing yet. Update the server to wake this thread.", - ); + const key = scopedThreadKey(thread.environmentId, thread.id); + if (snoozeInFlightThreadKeys.current.has(key)) { return false; } + snoozeInFlightThreadKeys.current.add(key); + try { + if (!environmentSupportsSnooze(thread.environmentId)) { + Alert.alert( + "Could not wake thread", + "This environment's server does not support snoozing yet. Update the server to wake this thread.", + ); + return false; + } - selectionHaptic(); - const result = await unsnoozeMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id, reason: "user" }, - }); - if (result._tag === "Failure") { - const error = Cause.squash(result.cause); - Alert.alert( - "Could not wake thread", - error instanceof Error && error.message.trim().length > 0 - ? error.message - : "The thread could not be woken.", - ); - return false; + selectionHaptic(); + const result = await unsnoozeMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not wake thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be woken.", + ); + return false; + } + return true; + } finally { + snoozeInFlightThreadKeys.current.delete(key); } - return true; }, [unsnoozeMutation], ); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 060d8133004..adcec1ae538 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -759,6 +759,7 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, savedConnectionsById, serverConfigs, + snoozePresetMinute: nowMinute, threadSearchMatchByKey, }), [ @@ -768,6 +769,7 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, savedConnectionsById, serverConfigs, + nowMinute, threadSearchMatchByKey, ], ); @@ -863,6 +865,7 @@ function ThreadNavigationSidebarPane( variant={item.item.variant} showSettledDivider={item.item.showSettledDivider} snoozed={item.item.snoozed} + snoozePresetMinute={nowMinute} snoozeWakeLabelText={item.snoozeWakeLabelText} project={projectByKey.get(scopeKey) ?? null} projectTitle={projectTitleByProjectKey.get(scopeKey)} @@ -1035,6 +1038,7 @@ function ThreadNavigationSidebarPane( sidebarScrollGesture, snoozeEnvironmentIds, snoozeThread, + nowMinute, toggleSnoozedShelf, unsettleThread, unsnoozeThread, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 396e9663e98..fa8b68e66b3 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -265,6 +265,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { /** Preformatted against the parent minute tick so this memoized row's countdown keeps moving. */ readonly snoozeWakeLabelText?: string; + /** Parent minute tick passed as a prop so this memoized row refreshes its + native snooze menu while mounted. */ + readonly snoozePresetMinute: string; readonly project: EnvironmentProject | null; readonly projectTitle?: string; readonly providerDriver: string | null; @@ -375,10 +378,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { snoozable: canSnooze(thread, { now: new Date().toISOString() }), snoozed: snoozedRow, }); - const snoozePresetMinute = new Date().toISOString().slice(0, 16); const snoozePresets = useMemo( () => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)), - [snoozePresetMinute, swipeActions.secondary], + [props.snoozePresetMinute, swipeActions.secondary], ); const snoozePresetActions = useMemo( () => @@ -409,21 +411,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); - const preset = - resolveSnoozePresets(new Date()).find( - (candidate) => nativeEvent.event === `snooze:${candidate.id}`, - ) ?? snoozePresets.find((candidate) => nativeEvent.event === `snooze:${candidate.id}`); + const preset = resolveSnoozePresets(new Date()).find( + (candidate) => nativeEvent.event === `snooze:${candidate.id}`, + ); if (preset) handleSnooze(preset.snoozedUntil); }, - [ - handleArchive, - handleDelete, - handleSettle, - handleSnooze, - handleUnsettle, - handleUnsnooze, - snoozePresets, - ], + [handleArchive, handleDelete, handleSettle, handleSnooze, handleUnsettle, handleUnsnooze], ); const primaryAction = useMemo(() => { // Pre-settlement server: archive is the swipe action, as in v1. (Slim From 448bbbc7ccbe99dffce9678679996b9ddf6ca32f Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:15:12 -0400 Subject: [PATCH 5/5] fix(mobile): handle expired snooze choices --- .../features/threads/thread-list-v2-items.tsx | 34 ++++++++++--- .../src/features/threads/threadListV2.test.ts | 50 +++++++++++++++++++ .../src/features/threads/threadListV2.ts | 26 ++++++++++ 3 files changed, 104 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index fa8b68e66b3..42fb12f5e3f 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -6,7 +6,14 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; -import { Platform, Pressable, useColorScheme, useWindowDimensions, View } from "react-native"; +import { + Alert, + Platform, + Pressable, + useColorScheme, + useWindowDimensions, + View, +} from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { SymbolView } from "../../components/AppSymbol"; @@ -21,6 +28,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { + resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, @@ -411,12 +419,26 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); - const preset = resolveSnoozePresets(new Date()).find( - (candidate) => nativeEvent.event === `snooze:${candidate.id}`, - ); - if (preset) handleSnooze(preset.snoozedUntil); + const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ + event: nativeEvent.event, + displayedPresets: snoozePresets, + now: new Date(), + }); + if (snoozeSelection._tag === "selected") { + handleSnooze(snoozeSelection.preset.snoozedUntil); + } else if (snoozeSelection._tag === "expired") { + Alert.alert("Could not snooze thread", "That snooze time has passed. Choose another time."); + } }, - [handleArchive, handleDelete, handleSettle, handleSnooze, handleUnsettle, handleUnsnooze], + [ + handleArchive, + handleDelete, + handleSettle, + handleSnooze, + handleUnsettle, + handleUnsnooze, + snoozePresets, + ], ); const primaryAction = useMemo(() => { // Pre-settlement server: archive is the swipe action, as in v1. (Slim diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index a911cbe8ec2..9008b29fdb0 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1,5 +1,6 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import { CommandId, EnvironmentId, @@ -16,6 +17,7 @@ import { buildThreadListV2Items, buildThreadListV2ListItems, resolveThreadListV2Enabled, + resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, @@ -52,6 +54,54 @@ function makeThread( const NOW = "2026-06-02T00:00:00.000Z"; +describe("resolveThreadListV2SnoozeMenuSelection", () => { + it("accepts a displayed evening preset while its wake time is still future", () => { + const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30); + const selectedAt = new Date(2026, 4, 8, 17, 0, 30); + const displayedPresets = resolveSnoozePresets(menuOpenedAt); + + const selection = resolveThreadListV2SnoozeMenuSelection({ + event: "snooze:evening", + displayedPresets, + now: selectedAt, + }); + + expect(selection).toEqual({ + _tag: "selected", + preset: displayedPresets.find((preset) => preset.id === "evening"), + }); + }); + + it("expires a displayed preset once its wake time has passed", () => { + const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 16, 59, 30)); + + expect( + resolveThreadListV2SnoozeMenuSelection({ + event: "snooze:evening", + displayedPresets, + now: new Date(2026, 4, 8, 18, 0, 1), + }), + ).toEqual({ _tag: "expired" }); + }); + + it("recomputes presets that remain available instead of using old timestamps", () => { + const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 10)); + const selectedAt = new Date(2026, 4, 8, 10, 30); + const selection = resolveThreadListV2SnoozeMenuSelection({ + event: "snooze:hour", + displayedPresets, + now: selectedAt, + }); + + expect(selection._tag).toBe("selected"); + if (selection._tag === "selected") { + expect(selection.preset.snoozedUntil).toBe( + new Date(selectedAt.getTime() + 60 * 60 * 1_000).toISOString(), + ); + } + }); +}); + describe("resolveThreadListV2Enabled", () => { it("defaults on when the device has never chosen", () => { expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe( diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 57c81264a03..6ed22f5c0b3 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -3,8 +3,10 @@ import { effectiveSnoozed, hasQueuedTurnStart, QUEUED_TURN_START_GRACE_MS, + resolveSnoozePresets, snoozeWakeLabel, } from "@t3tools/client-runtime/state/thread-settled"; +import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; @@ -24,6 +26,30 @@ export { snoozeWakeLabel }; export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; +export function resolveThreadListV2SnoozeMenuSelection(input: { + readonly event: string; + readonly displayedPresets: ReadonlyArray; + readonly now: Date; +}): + | { readonly _tag: "selected"; readonly preset: SnoozePreset } + | { readonly _tag: "expired" } + | { readonly _tag: "not-snooze" } { + if (!input.event.startsWith("snooze:")) return { _tag: "not-snooze" }; + + const currentPreset = resolveSnoozePresets(input.now).find( + (candidate) => input.event === `snooze:${candidate.id}`, + ); + if (currentPreset) return { _tag: "selected", preset: currentPreset }; + + const displayedPreset = input.displayedPresets.find( + (candidate) => input.event === `snooze:${candidate.id}`, + ); + if (displayedPreset && Date.parse(displayedPreset.snoozedUntil) > input.now.getTime()) { + return { _tag: "selected", preset: displayedPreset }; + } + return { _tag: "expired" }; +} + export function resolveThreadListV2SwipeActions(input: { readonly variant: "card" | "slim"; readonly settlementSupported: boolean;