import type { EnvironmentProject, 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, useState, type ComponentProps } from "react"; import { Alert, Platform, Pressable, 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"; import { ProviderIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; import { relativeTime } from "../../lib/time"; 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 { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, type ThreadListV2Status, } from "./threadListV2"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; /** * Thread List v2 renders one flat native list: rich edge-to-edge rows for * active work and a receded settled tail, all with native swipe and * long-press actions. State reads through colored status labels and text * hierarchy rather than card fills. */ const MONO_FONT = Platform.select({ ios: "Menlo", android: "monospace", default: "monospace", }); // Status hues follow the system-wide convention set by sidebar v1 and the // Live Activity/widgets (amber approval, indigo input, sky working) so a // thread reads the same color everywhere it surfaces. const STATUS_LABEL_BY_STATUS: Partial< Record > = { approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" }, input: { label: "Input", className: "text-indigo-600 dark:text-indigo-300" }, working: { label: "Working", className: "text-sky-600 dark:text-sky-400" }, failed: { label: "Failed", className: "text-red-700 dark:text-red-300" }, }; function threadTimeLabel(thread: EnvironmentThreadShell): string { return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt); } // Menus keep lifecycle and title regeneration together. Archive keeps its // own surface (thread screen / settings) rather than crowding v2 rows. const CARD_MENU_ACTIONS: MenuAction[] = [ { id: "settle", title: "Settle", image: "checkmark" }, { 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 } }, ]; 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" }, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; /** Rounded-row radius shared with the v1 sidebar rows. */ const SIDEBAR_V2_ROW_RADIUS = 12; /** Section label + rule: the only structure in an otherwise flat list. */ export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivider(props: { readonly label: string; readonly pane?: "screen" | "sidebar"; }) { const borderColor = useThemeColor("--color-border"); return ( {props.label} ); }); 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 { themeAppearance: colorScheme } = useAppearancePreferences(); 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 } }, ]; /** * A queued new task, in the same idiom as an active v2 row: it is work the * user wrote, so it reads like the threads it will become. "Queued" takes * the status slot — the state is the one thing that differs — and stays * uncolored because nothing is asked of the user; the environment is simply * not reachable yet. */ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props: { readonly pendingTask: PendingNewTask; readonly project: EnvironmentProject | null; readonly projectTitle?: string; readonly environmentLabel: string | null; readonly pane?: "screen" | "sidebar"; /** Draws the "Pending" divider above the first queued row. */ readonly showPendingDivider: boolean; /** Keeps row hairlines inside a section; section headers draw their own rule. */ readonly showTrailingDivider?: boolean; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; }) { const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; const drawerColor = useThemeColor("--color-drawer"); const pressedBackgroundColor = useThemeColor("--color-subtle"); const sidebarPane = props.pane === "sidebar"; const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.creation.projectTitle ?? ""; const branch = pendingTask.creation.branch; const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask); }, [onDeletePendingTask, pendingTask], ); const rowContent = ( <> {props.project ? ( ) : null} {projectTitle} Queued {/* One line, unlike the two an active row allows: a queued title is derived from the whole prompt rather than written as a title, so the second line is usually a stray word or emoji rather than meaning. */} {pendingTask.title} {branch || props.environmentLabel ? ( {branch ? ( {branch} ) : null} {branch && props.environmentLabel ? " · " : null} {props.environmentLabel ? ( {props.environmentLabel} ) : null} ) : null} ); return ( <> {props.showPendingDivider ? ( ) : null} onSelectPendingTask(pendingTask)} style={ sidebarPane ? ({ pressed }) => ({ backgroundColor: pressed ? pressedBackgroundColor : drawerColor, borderRadius: SIDEBAR_V2_ROW_RADIUS, paddingHorizontal: 12, paddingVertical: 10, }) : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) } > {sidebarPane ? ( rowContent ) : ( {rowContent} {props.showTrailingDivider !== false ? ( ) : null} )} ); }); export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; /** Snoozed-shelf row: shows its wake time and offers Wake. */ readonly snoozed?: boolean; /** Pinned-block row: shows the pin glyph and offers Unpin. */ readonly pinned?: 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; /** Which machine hosts the thread. Null when only one environment is connected — repeating the same label on every row is noise. Mirrors the web sidebar's remote-environment cloud icon, but as text since phones have no hover tooltips. */ readonly environmentLabel: string | null; /** Hosting surface. "screen" (default) renders the compact Home idiom: flat edge-to-edge rows on the screen background with inset hairlines. "sidebar" renders the iPad split-view idiom: rounded rows blending into the drawer surface, selection filled with the accent color — matching the v1 sidebar rows. */ readonly pane?: "screen" | "sidebar"; /** Keeps row hairlines inside a section; section headers draw their own rule. */ readonly showTrailingDivider?: boolean; /** Highlights the thread open in the detail pane (iPad split view). The compact Home list never sets it — phones navigate away on select. */ readonly selected?: boolean; /** Override for narrow panes (iPad sidebar); defaults to window width. */ readonly fullSwipeWidth?: number; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (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; readonly onPinThread: (thread: EnvironmentThreadShell) => void; readonly onUnpinThread: (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; /** False on servers that predate thread.pin/unpin. */ readonly pinningSupported: boolean; /** False on servers that predate thread title regeneration. */ readonly titleRegenerationSupported: boolean; /** False on servers that predate thread.pin.reorder. Gates the pinned Move up / Move down menu items. */ readonly pinReorderSupported?: boolean; readonly onMovePinnedThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void; /** Position flags for the pinned block so the menu disables the move that would fall off the end of the list. */ readonly canMovePinnedUp?: boolean; readonly canMovePinnedDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; /** Reports this row's live PR state for the partition's merge and close rules. Mirrors web's onChangeRequestState. */ readonly onChangeRequestState?: ( threadKey: string, state: "open" | "closed" | "merged" | null, ) => void; readonly projectCwd?: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; readonly simultaneousSwipeGesture?: ComponentProps< typeof ThreadSwipeable >["simultaneousWithExternalGesture"]; }) { const { width: windowWidth } = useWindowDimensions(); const { thread, variant, onSelectThread, onDeleteThread, onRegenerateThreadTitle, onSettleThread, onSnoozeThread, onUnsnoozeThread, onUnsettleThread, onArchiveThread, onPinThread, onUnpinThread, onMovePinnedThread, onChangeRequestState, } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); const prState = pr?.state ?? null; const threadKey = `${thread.environmentId}:${thread.id}`; useEffect(() => { onChangeRequestState?.(threadKey, prState); }, [onChangeRequestState, prState, threadKey]); const screenColor = useThemeColor("--color-screen"); const drawerColor = useThemeColor("--color-drawer"); const pressedBackgroundColor = useThemeColor("--color-subtle"); const selectedBackgroundColor = useThemeColor("--color-user-bubble"); const pinTintColor = useThemeColor("--color-foreground-muted"); const sidebarPane = props.pane === "sidebar"; const selected = props.selected === true; const status = resolveThreadListV2Status(thread); const statusLabel = STATUS_LABEL_BY_STATUS[status]; const timeLabel = threadTimeLabel(thread); const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleRegenerateTitle = useCallback( () => onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, 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 handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]); const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]); const handleMovePinnedUp = useCallback( () => onMovePinnedThread?.(thread, "up"), [onMovePinnedThread, thread], ); const handleMovePinnedDown = useCallback( () => onMovePinnedThread?.(thread, "down"), [onMovePinnedThread, 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], ); // Pinned cards keep the full lifecycle menu; only the pin item flips to // Unpin. (Settling a pinned thread clears the pin server-side; snoozing // hides the card until wake with the pin intact.) const pinMenuItem = useMemo( () => props.pinningSupported ? [ ...(pinnedRow && props.pinReorderSupported === true ? [ { id: "move-pin-up", title: "Move up", image: "arrow.up", attributes: { disabled: props.canMovePinnedUp !== true }, } satisfies MenuAction, { id: "move-pin-down", title: "Move down", image: "arrow.down", attributes: { disabled: props.canMovePinnedDown !== true }, } satisfies MenuAction, ] : []), pinnedRow ? { id: "unpin", title: "Unpin", image: "pin.slash" } : { id: "pin", title: "Pin", image: "pin" }, ] : [], [ pinnedRow, props.canMovePinnedDown, props.canMovePinnedUp, props.pinReorderSupported, props.pinningSupported, ], ); const titleRegenerationMenuItems = useMemo( () => buildThreadTitleRegenerationMenuItems({ supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, }), [props.titleRegenerationSupported, thread.titleRegeneration], ); const snoozableCardMenuActions = useMemo( () => [ { id: "settle", title: "Settle", image: "checkmark" }, { id: "snooze", title: "Snooze", image: "clock", subactions: snoozePresetActions, }, ...pinMenuItem, ...titleRegenerationMenuItems, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], [pinMenuItem, snoozePresetActions, titleRegenerationMenuItems], ); const cardMenuActions = useMemo( () => [ CARD_MENU_ACTIONS[0]!, ...pinMenuItem, ...titleRegenerationMenuItems, ...CARD_MENU_ACTIONS.slice(1), ], [pinMenuItem, titleRegenerationMenuItems], ); const slimMenuActions = useMemo( () => [SLIM_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SLIM_MENU_ACTIONS[1]!], [titleRegenerationMenuItems], ); const snoozedMenuActions = useMemo( () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], [titleRegenerationMenuItems], ); const legacyMenuActions = useMemo( () => [LEGACY_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, LEGACY_MENU_ACTIONS[1]!], [titleRegenerationMenuItems], ); 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 === "pin") handlePin(); if (nativeEvent.event === "unpin") handleUnpin(); if (nativeEvent.event === "move-pin-up") handleMovePinnedUp(); if (nativeEvent.event === "move-pin-down") handleMovePinnedDown(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); 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, handleRegenerateTitle, handleMovePinnedDown, handleMovePinnedUp, handlePin, handleSettle, handleSnooze, handleUnpin, 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 // settled.) if (swipeActions.primary === "archive") { return { accessibilityLabel: `Archive ${thread.title}`, icon: "archivebox" as const, label: "Archive", onPress: handleArchive, }; } 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, label: "Un-settle", onPress: handleUnsettle, } : { accessibilityLabel: `Settle ${thread.title}`, icon: "checkmark" as const, label: "Settle", onPress: handleSettle, }; }, [ handleArchive, handleSettle, handleUnsettle, 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 theme's message surface, so // every piece of row text must use that surface's paired foreground. const cardContent = ( <> {props.project ? ( ) : null} {props.projectTitle ?? props.project?.title ?? ""} {pinnedRow ? ( ) : null} {statusLabel?.label ?? timeLabel} {thread.title} {props.searchMatch ? ( ) : null} {status === "failed" && thread.session?.lastError ? ( {thread.session.lastError} ) : thread.branch || props.environmentLabel ? ( /* "branch · machine" share one truncating line. The machine sits last so a tight fit cuts the repetitive label, not the branch — and machine-only fills the row for non-git projects. */ {thread.branch ? ( {thread.branch} ) : null} {thread.branch && props.environmentLabel ? " · " : null} {props.environmentLabel ? ( {props.environmentLabel} ) : null} ) : ( )} {pr ? ( #{pr.label} ) : null} {props.providerDriver ? ( ) : null} ); const rowContent = (close: () => void) => variant === "card" ? ( { close(); onSelectThread(thread); }} style={ sidebarPane ? ({ pressed }) => ({ backgroundColor: selected ? selectedBackgroundColor : pressed ? pressedBackgroundColor : drawerColor, borderRadius: SIDEBAR_V2_ROW_RADIUS, paddingHorizontal: 12, paddingVertical: 10, }) : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) } > {sidebarPane ? ( cardContent ) : ( /* Flat native list rows: no tonal containers — colored status labels and text hierarchy carry state, an inset hairline separates rows. The opaque screen background stays so swipe actions reveal behind the row. */ {cardContent} {props.showTrailingDivider !== false ? ( ) : null} )} ) : ( { close(); onSelectThread(thread); }} style={ sidebarPane ? ({ pressed }) => ({ backgroundColor: selected ? selectedBackgroundColor : pressed ? pressedBackgroundColor : drawerColor, borderRadius: SIDEBAR_V2_ROW_RADIUS, }) : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) } > {/* Settled history recedes: dimmed favicon + muted title. */} {props.project ? ( ) : null} {thread.title} {props.searchMatch ? ( ) : null} {snoozedRow && props.snoozeWakeLabelText !== undefined ? props.snoozeWakeLabelText : relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} ); return ( <> {(close) => ( {rowContent(close)} )} ); });