diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index ac813bdbe0a5..d4c7088fe507 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 7fcf78f83b40..0aefde991002 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -36,8 +36,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(() => { @@ -146,6 +152,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 507a39d94a17..d54fb7d48903 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -41,7 +41,12 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "../threads/thread-list-items"; -import { ThreadListV2PendingRow, ThreadListV2Row } from "../threads/thread-list-v2-items"; +import { + ThreadListV2PendingRow, + ThreadListV2Row, + ThreadListV2SettledShelfHeader, + ThreadListV2SnoozedShelfHeader, +} from "../threads/thread-list-v2-items"; import { buildThreadListV2Items, buildThreadListV2ListItems, @@ -100,6 +105,11 @@ 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, + snoozedUntil: string, + ) => Promise; + readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; @@ -494,6 +504,18 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onSettleThread], ); + const handleSnoozeThread = useCallback( + (thread: EnvironmentThreadShell, snoozedUntil: string) => { + void props.onSnoozeThread(thread, snoozedUntil); + }, + [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 +533,10 @@ 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), []); + const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); + const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((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 +577,15 @@ 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, + settledCount: 0, + settledShelfHeaderIndex: null, + nextSnoozeWakeAt: null, + }; // Settled threads are live shells; archived threads keep their original // "hidden from lists" meaning. return buildThreadListV2Items({ @@ -566,11 +600,16 @@ export function HomeScreen(props: HomeScreenProps) { settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, snoozeNow: new Date().toISOString(), + snoozedShelfExpanded, + settledShelfExpanded, + selectedThreadKey: null, }); }, [ changeRequestStateByKey, nowMinute, snoozeWakeTick, + snoozedShelfExpanded, + settledShelfExpanded, settledVisibleCount, settlementEnvironmentIds, snoozeEnvironmentIds, @@ -620,8 +659,15 @@ export function HomeScreen(props: HomeScreenProps) { buildThreadListV2ListItems({ items: threadListV2Layout.items, pendingTasks: v2PendingTasks, + snoozedCount: threadListV2Layout.snoozedCount, + snoozedShelfExpanded, + snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, + settledCount: threadListV2Layout.settledCount, + settledShelfExpanded, + settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, + snoozeLabelNow: `${nowMinute}:00.000Z`, }), - [threadListV2Layout.items, v2PendingTasks], + [settledShelfExpanded, snoozedShelfExpanded, threadListV2Layout, v2PendingTasks], ); const renderV2Item = useCallback( @@ -648,12 +694,32 @@ export function HomeScreen(props: HomeScreenProps) { /> ); } + if (item.type === "v2-snoozed-shelf") { + return ( + + ); + } + if (item.type === "v2-settled-shelf") { + return ( + + ); + } const thread = item.item.thread; return ( item.key, []); @@ -730,6 +805,7 @@ export function HomeScreen(props: HomeScreenProps) { serverConfigs, savedConnectionsById: props.savedConnectionsById, searchQuery: props.searchQuery, + snoozePresetMinute: nowMinute, threadSearchMatchByKey, }), [ @@ -738,6 +814,7 @@ export function HomeScreen(props: HomeScreenProps) { props.searchQuery, props.savedConnectionsById, serverConfigs, + nowMinute, threadSearchMatchByKey, v2ProjectTitleByProjectKey, ], @@ -953,31 +1030,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 ? ( 0 ? ( + settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? ( ["name"]; readonly label: string; + readonly menu?: { + readonly actions: MenuAction[]; + readonly onPressAction: NonNullable["onPressAction"]>; + readonly title?: 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", + menu: + action.menu === undefined + ? undefined + : { + ...action.menu, + onPressAction: (event) => { + input.close(); + action.menu?.onPressAction(event); + }, + }, + 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 @@ -173,17 +229,22 @@ 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; 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 +258,11 @@ 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 = + props.fullSwipeAction ?? (props.secondaryAction === undefined ? "delete" : "primary"); const close = useCallback(() => swipeableRef.current?.close(), []); const gateEnabled = use(SwipeableScrollGateContext); const resetKey = props.resetKey; @@ -251,7 +316,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 +329,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,12 +358,14 @@ export function ThreadSwipeable(props: { function SwipeActionButton(props: { readonly accessibilityLabel: string; + readonly actionsWidth: number; readonly backgroundColor: string; readonly compact: boolean; readonly entryRange: readonly [number, number]; readonly fullSwipeThreshold: number; readonly icon: ComponentProps["name"]; readonly label: string; + readonly menu?: ThreadSwipeAction["menu"]; readonly onPress: () => void; readonly stretchesOnFullSwipe: boolean; readonly translation: SharedValue; @@ -305,10 +375,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 +397,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 +406,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 +424,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, @@ -370,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} + + )} ); } @@ -446,14 +527,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 +551,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 e200eb7acde5..0c621a04e38b 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"; @@ -22,6 +22,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 +199,14 @@ export function useThreadListActions(): { readonly archiveThread: (thread: EnvironmentThreadShell) => void; readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; readonly settleThread: (thread: EnvironmentThreadShell) => Promise; + readonly snoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => 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 snoozeInFlightThreadKeys = useRef(new Set()); const archiveThread = useCallback( (thread: EnvironmentThreadShell) => { @@ -206,6 +218,94 @@ export function useThreadListActions(): { async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true, [executeAction], ); + const snoozeThread = useCallback( + async (thread: EnvironmentThreadShell, snoozedUntil: string) => { + 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; + } + return true; + } finally { + snoozeInFlightThreadKeys.current.delete(key); + } + }, + [snoozeMutation], + ); + const unsnoozeThread = useCallback( + async (thread: EnvironmentThreadShell) => { + 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; + } + return true; + } finally { + snoozeInFlightThreadKeys.current.delete(key); + } + }, + [unsnoozeMutation], + ); const unsettleThread = useCallback( async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true, [executeAction], @@ -213,7 +313,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 36a86ceb1e31..8a7fc2ed6dfa 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -67,7 +67,12 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "./thread-list-items"; -import { ThreadListV2PendingRow, ThreadListV2Row } from "./thread-list-v2-items"; +import { + ThreadListV2PendingRow, + ThreadListV2Row, + ThreadListV2SettledShelfHeader, + ThreadListV2SnoozedShelfHeader, +} from "./thread-list-v2-items"; import { buildThreadListV2Items, buildThreadListV2ListItems, @@ -193,8 +198,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 +439,10 @@ function ThreadNavigationSidebarPane( () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), [], ); + const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); + const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); + const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); + const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((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 +483,15 @@ function ThreadNavigationSidebarPane( }, [serverConfigs]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) - return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; + return { + items: [], + hiddenSettledCount: 0, + snoozedCount: 0, + snoozedShelfHeaderIndex: null, + settledCount: 0, + settledShelfHeaderIndex: null, + nextSnoozeWakeAt: null, + }; return buildThreadListV2Items({ threads: threads.filter((thread) => thread.archivedAt === null), environmentId: options.selectedEnvironmentId, @@ -481,11 +504,17 @@ function ThreadNavigationSidebarPane( settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, snoozeNow: new Date().toISOString(), + snoozedShelfExpanded, + settledShelfExpanded, + selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ changeRequestStateByKey, nowMinute, snoozeWakeTick, + snoozedShelfExpanded, + settledShelfExpanded, + props.selectedThreadKey, options.selectedEnvironmentId, props.searchQuery, matchedThreadKeys, @@ -532,8 +561,15 @@ function ThreadNavigationSidebarPane( const items: SidebarListItem[] = buildThreadListV2ListItems({ items: threadListV2Layout.items, pendingTasks: v2PendingTasks, + snoozedCount: threadListV2Layout.snoozedCount, + snoozedShelfExpanded, + snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, + settledCount: threadListV2Layout.settledCount, + settledShelfExpanded, + settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, + snoozeLabelNow: `${nowMinute}:00.000Z`, }); - if (threadListV2Layout.hiddenSettledCount > 0) { + if (settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0) { items.push({ type: "v2-show-more", key: "v2-show-more", @@ -543,10 +579,13 @@ function ThreadNavigationSidebarPane( return items; }, [ listLayout.items, + nowMinute, options.selectedEnvironmentId, pendingTasks, props.searchQuery, selectedProjectRefs, + settledShelfExpanded, + snoozedShelfExpanded, threadListV2Enabled, threadListV2Layout, ]); @@ -731,6 +770,7 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, savedConnectionsById, serverConfigs, + snoozePresetMinute: nowMinute, threadSearchMatchByKey, }), [ @@ -740,6 +780,7 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, savedConnectionsById, serverConfigs, + nowMinute, threadSearchMatchByKey, ], ); @@ -750,7 +791,8 @@ 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.snoozed === item.item.snoozed && + previous.snoozeWakeLabelText === item.snoozeWakeLabelText ); } if (previous.type === "v2-show-more" && item.type === "v2-show-more") { @@ -762,13 +804,23 @@ 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-settled-shelf" && item.type === "v2-settled-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" || + previous.type === "v2-settled-shelf" || item.type === "v2-thread" || item.type === "v2-show-more" || - item.type === "v2-pending" + item.type === "v2-pending" || + item.type === "v2-snoozed-shelf" || + item.type === "v2-settled-shelf" ) { return false; } @@ -826,7 +878,9 @@ function ThreadNavigationSidebarPane( ); } + case "v2-snoozed-shelf": + return ( + + ); + case "v2-settled-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 6af5795a94a5..8d6874c78558 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,20 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; +import { canSnooze, resolveSnoozePresets } 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 { + Alert, + 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 +27,13 @@ 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 { + resolveThreadListV2SnoozeMenuSelection, + resolveThreadListV2SnoozeGateExpiryMs, + resolveThreadListV2Status, + resolveThreadListV2SwipeActions, + type ThreadListV2Status, +} from "./threadListV2"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; /** @@ -62,6 +77,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 +110,81 @@ 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})`} + + + + + ); +}); + +export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(props: { + readonly count: number; + readonly expanded: boolean; + readonly onToggle: () => void; + readonly pane?: "screen" | "sidebar"; +}) { + const mutedColor = useThemeColor("--color-foreground-muted"); + return ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + {props.expanded ? "Settled" : `Settled (${props.count})`} + + + + + ); +}); + const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; @@ -208,7 +303,14 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props 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; + /** 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; @@ -231,11 +333,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, snoozedUntil: string) => 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 +364,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 +392,94 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, 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]); + + // 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 snoozePresets = useMemo( + () => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)), + [props.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 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, handleUnsettle], + [ + handleArchive, + handleDelete, + handleSettle, + handleSnooze, + handleUnsettle, + handleUnsnooze, + snoozePresets, + ], ); - - // 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 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 +487,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 +509,34 @@ 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: `Choose when to snooze ${thread.title}`, + icon: "clock" as const, + label: "Snooze", + menu: { + actions: snoozePresetActions, + onPressAction: handleMenuAction, + title: "Snooze until", + }, + onPress: () => undefined, + } + : null, + [handleMenuAction, snoozePresetActions, 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 +658,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)} @@ -563,9 +774,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { return ( <> - {props.showSettledDivider ? ( - - ) : null} ( { + 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( @@ -96,6 +148,105 @@ describe("resolveThreadListV2Status", () => { }); }); +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 +318,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: [ @@ -187,8 +425,8 @@ describe("buildThreadListV2Items", () => { expect(layout.snoozedCount).toBe(0); }); - it("partitions settled threads into a slim tail with one divider", () => { - const { items } = buildThreadListV2Items({ + it("partitions settled threads into a slim shelf", () => { + const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ @@ -209,13 +447,64 @@ describe("buildThreadListV2Items", () => { now: NOW, }); - expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ + expect(layout.items.map((item) => [item.thread.id, item.variant])).toEqual([ ["active", "card"], ["settled", "slim"], ["settled-2", "slim"], ]); - expect(items.map((item) => item.showSettledDivider)).toEqual([false, true, false]); - expect(items.map((item) => item.isLast)).toEqual([false, false, true]); + expect(layout.items.map((item) => item.isLast)).toEqual([false, false, true]); + expect(layout.settledCount).toBe(2); + expect(layout.settledShelfHeaderIndex).toBe(1); + }); + + it("collapses settled threads to a counted shelf header", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("settled"), + title: "Settled", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + settledShelfExpanded: false, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["active"]); + expect(layout.settledCount).toBe(1); + expect(layout.settledShelfHeaderIndex).toBe(1); + }); + + it("keeps the selected settled thread visible when its shelf is collapsed", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("selected"), + title: "Selected", + settledOverride: "settled", + settledAt: NOW, + }), + makeThread({ + id: ThreadId.make("other"), + title: "Other", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + settledShelfExpanded: false, + selectedThreadKey: `${environmentId}:selected`, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["selected"]); + expect(layout.settledCount).toBe(2); + expect(layout.settledShelfHeaderIndex).toBe(0); }); it("keeps cards in creation order while settled sorts by recency", () => { @@ -420,13 +709,21 @@ describe("buildThreadListV2ListItems", () => { const items = buildThreadListV2ListItems({ items: layout.items, pendingTasks: [makePendingTask("queued-1"), makePendingTask("queued-2")], + settledCount: layout.settledCount, + settledShelfHeaderIndex: layout.settledShelfHeaderIndex, }); 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 + : item.type === "v2-snoozed-shelf" + ? "snoozed-shelf" + : "settled-shelf", ), - ).toEqual(["active", "queued-1", "queued-2", "settled"]); + ).toEqual(["active", "queued-1", "queued-2", "settled-shelf", "settled"]); // Only the leading queued row labels the section, exactly like Settled. expect( items.filter((item) => item.type === "v2-pending" && item.showPendingDivider), @@ -448,12 +745,58 @@ describe("buildThreadListV2ListItems", () => { expect(items.map((item) => item.type)).toEqual(["v2-thread", "v2-pending"]); }); - it("leaves the thread order untouched when nothing is queued", () => { - const items = buildThreadListV2ListItems({ items: layout.items, pendingTasks: [] }); + it("keeps the settled shelf between active and settled rows when nothing is queued", () => { + const items = buildThreadListV2ListItems({ + items: layout.items, + pendingTasks: [], + settledCount: layout.settledCount, + settledShelfHeaderIndex: layout.settledShelfHeaderIndex, + }); expect(items.map((item) => item.key)).toEqual([ `v2-thread:${environmentId}:active`, + "v2-settled-shelf", `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, + settledCount: snoozedLayout.settledCount, + settledShelfHeaderIndex: snoozedLayout.settledShelfHeaderIndex, + }); + + expect(items.map((item) => item.type)).toEqual([ + "v2-thread", + "v2-pending", + "v2-snoozed-shelf", + "v2-settled-shelf", + "v2-thread", + ]); + }); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 920b7f0b53aa..c88aff4ec02d 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,10 +1,20 @@ -import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { + effectiveSettled, + 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"; 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 +24,76 @@ 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 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; + 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 @@ -98,8 +178,8 @@ export function sortThreadsForListV2