import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";import { appAtomRegistry } from "../../state/atom-registry";import { threadArrangementOpenAtom } from "../../state/thread-order";import type { ThreadMoveDestination } from "./threadOrder";import type { EnvironmentProject, EnvironmentThreadShell,} from "@t3tools/client-runtime/state/shell";import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";import type { EnvironmentMachineKind } from "@t3tools/contracts";import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";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 { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";import { ProjectFavicon } from "../../components/ProjectFavicon";import { ProviderInstanceIcon } from "../../components/ProviderIcon";import type { ThreadRowProviderInstance } from "./thread-provider-instance";import { cn } from "../../lib/cn";import { relativeTime } from "../../lib/time";import { useUniwindTheme } from "../../lib/useUniwindTheme";import type { PendingNewTask } from "../../state/use-pending-new-tasks";import { useThreadPr } from "../../state/use-thread-pr";import { ThreadSwipeable } from "../home/thread-swipe-actions";import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";import { resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, type ThreadListV2Status,} from "./threadListV2";import { QueuedMessageIcon } from "./queued-message-icon";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<ThreadListV2Status, { label: string; className: string }>> = { approval: { label: "Approval", className: "text-warning-foreground" }, input: { label: "Input", className: "text-foreground-secondary" }, working: { label: "Working", className: "text-adaptive-sky-600-400" }, failed: { label: "Failed", className: "text-danger-foreground" },}; 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";}) { return ( <View className={cn( "mb-1.5 mt-4 flex-row items-center gap-2.5", props.pane === "sidebar" ? "px-3" : "px-5", )} > <Text className="text-xs font-t3-medium text-foreground-tertiary">{props.label}</Text> <View className="h-px flex-1 bg-border" /> </View> );}); export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: { readonly count: number; readonly disabled?: boolean; readonly expanded: boolean; readonly onToggle: () => void; readonly pane?: "screen" | "sidebar";}) { return ( <Pressable accessibilityHint={ props.expanded ? "Collapses the snoozed threads." : "Expands the snoozed threads." } accessibilityLabel={props.count === 1 ? "1 snoozed thread" : `${props.count} snoozed threads`} accessibilityRole="button" accessibilityState={{ disabled: props.disabled, expanded: props.expanded }} className={cn( "mb-1.5 mt-4 flex-row items-center gap-2.5", props.pane === "sidebar" ? "px-3" : "px-5", )} disabled={props.disabled} onPress={props.onToggle} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > <Text className="text-xs font-t3-medium text-foreground-secondary"> {props.expanded ? "Snoozed" : `Snoozed (${props.count})`} </Text> <View className="h-px flex-1 bg-primary/20" /> <SymbolView name="chevron.down" size={10} tintColorClassName="accent-icon-muted" type="monochrome" style={{ transform: [{ rotate: props.expanded ? "180deg" : "0deg" }] }} /> </Pressable> );}); export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(props: { readonly count: number; readonly disabled?: boolean; readonly expanded: boolean; readonly onToggle: () => void; readonly pane?: "screen" | "sidebar";}) { return ( <Pressable accessibilityHint={ props.expanded ? "Collapses the settled threads." : "Expands the settled threads." } accessibilityLabel={props.count === 1 ? "1 settled thread" : `${props.count} settled threads`} accessibilityRole="button" accessibilityState={{ disabled: props.disabled, expanded: props.expanded }} className={cn( "mb-1.5 mt-4 flex-row items-center gap-2.5", props.pane === "sidebar" ? "px-3" : "px-5", )} disabled={props.disabled} onPress={props.onToggle} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > <Text className="text-xs font-t3-medium text-foreground-tertiary"> {props.expanded ? "Settled" : `Settled (${props.count})`} </Text> <View className="h-px flex-1 bg-border" /> <SymbolView name="chevron.down" size={10} tintColorClassName={"accent-foreground-muted"} type="monochrome" style={{ transform: [{ rotate: props.expanded ? "180deg" : "0deg" }] }} /> </Pressable> );}); const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },]; const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } },]; /** * Unsent work, in the same idiom as an active v2 row: it is work the user * wrote, so it reads like the thread it will become. The status slot says * what happens next, not where the item sits: "Sends on reconnect" stays * uncolored because nothing is asked of the user; "Draft" takes the amber the * web sidebar uses for drafts, because this one waits on the user. */export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props: { readonly pendingTask: PendingNewTask; readonly project: EnvironmentProject | null; readonly projectTitle?: string; readonly environmentLabel: string | null; /** Drawn beside the label; ignored while the label is null. */ readonly environmentMachine?: EnvironmentMachineKind; readonly pane?: "screen" | "sidebar"; /** Draws the "Unsent" divider above the first draft or 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 sidebarPane = props.pane === "sidebar"; const isDraft = pendingTask.kind === "draft"; const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.projectTitle ?? ""; const branch = pendingTask.branch; const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask); }, [onDeletePendingTask, pendingTask], ); const rowContent = ( <> <View className="flex-row items-center gap-1.5"> {props.project ? ( <ProjectFavicon environmentId={pendingTask.environmentId} faviconPath={props.project.faviconPath} size={15} projectTitle={projectTitle} workspaceRoot={props.project.workspaceRoot} /> ) : null} <Text className="flex-1 text-sm font-t3-medium text-foreground-muted" numberOfLines={1}> {projectTitle} </Text> {isDraft ? ( <View className="flex-row items-center gap-1"> <SymbolView name="square.and.pencil" size={10} tintColorClassName="accent-adaptive-amber-700-300" type="monochrome" /> <Text className="text-xs text-adaptive-amber-700-300">Draft</Text> </View> ) : ( <Text className="text-xs text-foreground-tertiary">Sends on reconnect</Text> )} </View> {/* 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. */} <Text className="mt-1 text-base font-t3-medium text-foreground" numberOfLines={1}> {pendingTask.title} </Text> {branch || props.environmentLabel ? ( <View className="mt-1 flex-row items-center gap-1"> <Text className="shrink text-xs text-foreground-muted" numberOfLines={1}> {branch ? ( <Text className="text-xs text-foreground-muted" style={{ fontFamily: MONO_FONT }}> {branch} </Text> ) : null} {branch && props.environmentLabel ? " · " : null} {props.environmentLabel ? ( <Text className="text-xs text-foreground-tertiary">{props.environmentLabel}</Text> ) : null} </Text> {props.environmentLabel && props.environmentMachine ? ( <EnvironmentMachineSymbol kind={props.environmentMachine} size={11} tintColorClassName="accent-foreground-tertiary" /> ) : null} </View> ) : null} </> ); return ( <> {props.showPendingDivider ? ( <ThreadListV2SectionDivider label="Unsent" pane={props.pane} /> ) : null} <ControlPillMenu actions={isDraft ? DRAFT_TASK_MENU_ACTIONS : PENDING_TASK_MENU_ACTIONS} onPressAction={handleMenuAction} shouldOpenOnLongPress > <Pressable accessibilityHint={ isDraft ? "Opens the draft in the new task composer" : "Sends when the environment reconnects. Opens the task for editing" } accessibilityLabel={pendingTask.title} accessibilityRole="button" className={sidebarPane ? "bg-drawer active:bg-subtle" : undefined} onPress={() => onSelectPendingTask(pendingTask)} style={ sidebarPane ? { borderRadius: SIDEBAR_V2_ROW_RADIUS, paddingHorizontal: 12, paddingVertical: 10, } : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) } > {sidebarPane ? ( rowContent ) : ( <View className="bg-screen"> <View className="px-5 py-2.5">{rowContent}</View> {props.showTrailingDivider !== false ? ( <View className="ml-5 h-px bg-border-subtle" /> ) : null} </View> )} </Pressable> </ControlPillMenu> </> );}); export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; /** A message for this thread is waiting in the outbox. */ readonly hasQueuedMessages?: boolean; /** 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 providerInstance: ThreadRowProviderInstance | 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; /** Drawn after the label so the machine reads at a glance; ignored while the label is null. */ readonly environmentMachine?: EnvironmentMachineKind; /** 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 onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>; 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; /** Server supports reordering this card's section. */ readonly reorderSupported?: boolean; readonly onMoveThread?: ( thread: EnvironmentThreadShell, direction: ThreadMoveDestination, ) => void; /** Position flags for the card's section so the menu disables the move that would fall off the end of the list. */ readonly canMoveUp?: boolean; readonly canMoveDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; readonly simultaneousSwipeGesture?: ComponentProps< typeof ThreadSwipeable >["simultaneousWithExternalGesture"];}) { const { width: windowWidth } = useWindowDimensions(); const { thread, variant, onSelectThread, onDeleteThread, onRegenerateThreadTitle, onNewThreadOnBranch, onSettleThread, onSnoozeThread, onUnsnoozeThread, onUnsettleThread, onArchiveThread, onPinThread, onUnpinThread, onMoveThread, } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; const pr = useThreadPr(thread); const { materialYouStyleLayoutActive } = useAppearancePreferences(); const theme = useUniwindTheme(); const screenColor = theme["--color-screen"]; const drawerColor = theme["--color-drawer"]; const pressedBackgroundColor = theme["--color-subtle"]; const selectedBackgroundColor = theme[materialYouStyleLayoutActive ? "--color-thread-selected" : "--color-user-bubble"]; const sidebarPane = props.pane === "sidebar"; const selected = props.selected === true; // The provider badge's border blends into the row's own surface, which // differs by pane and (for the sidebar pane) selection: the sidebar row // background becomes the selected fill or the drawer surface, while the // flat "screen" pane rows always sit on the screen background. const providerIconSurfaceColor = sidebarPane ? selected ? selectedBackgroundColor : drawerColor : screenColor; const status = resolveThreadListV2Status(thread); const statusLabel = STATUS_LABEL_BY_STATUS[status]; // Settled rows label by the same stamp they sort by, so order and label // can't disagree. updatedAt is always present, so the resolver never // returns null here. const settledTimestamp = variant === "slim" && !snoozedRow ? resolveSettledThreadTimestamp(thread) : null; const timeLabel = settledTimestamp !== null ? relativeTime(settledTimestamp) : 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 handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]); const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); // Swipe: the v2 primary action is the lifecycle transition. Un-settling a // settled row keeps it active until new activity clears the user override. 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,Lines 1–500 of 1108. Load more
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { appAtomRegistry } from "../../state/atom-registry"; import { threadArrangementOpenAtom } from "../../state/thread-order"; import type { ThreadMoveDestination } from "./threadOrder"; import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentMachineKind } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; 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 { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { ProviderInstanceIcon } from "../../components/ProviderIcon"; import type { ThreadRowProviderInstance } from "./thread-provider-instance"; import { cn } from "../../lib/cn"; import { relativeTime } from "../../lib/time"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, type ThreadListV2Status, } from "./threadListV2"; import { QueuedMessageIcon } from "./queued-message-icon"; 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<ThreadListV2Status, { label: string; className: string }> > = { approval: { label: "Approval", className: "text-warning-foreground" }, input: { label: "Input", className: "text-foreground-secondary" }, working: { label: "Working", className: "text-adaptive-sky-600-400" }, failed: { label: "Failed", className: "text-danger-foreground" }, }; 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"; }) { return ( <View className={cn( "mb-1.5 mt-4 flex-row items-center gap-2.5", props.pane === "sidebar" ? "px-3" : "px-5", )} > <Text className="text-xs font-t3-medium text-foreground-tertiary">{props.label}</Text> <View className="h-px flex-1 bg-border" /> </View> ); }); export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: { readonly count: number; readonly disabled?: boolean; readonly expanded: boolean; readonly onToggle: () => void; readonly pane?: "screen" | "sidebar"; }) { return ( <Pressable accessibilityHint={ props.expanded ? "Collapses the snoozed threads." : "Expands the snoozed threads." } accessibilityLabel={props.count === 1 ? "1 snoozed thread" : `${props.count} snoozed threads`} accessibilityRole="button" accessibilityState={{ disabled: props.disabled, expanded: props.expanded }} className={cn( "mb-1.5 mt-4 flex-row items-center gap-2.5", props.pane === "sidebar" ? "px-3" : "px-5", )} disabled={props.disabled} onPress={props.onToggle} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > <Text className="text-xs font-t3-medium text-foreground-secondary"> {props.expanded ? "Snoozed" : `Snoozed (${props.count})`} </Text> <View className="h-px flex-1 bg-primary/20" /> <SymbolView name="chevron.down" size={10} tintColorClassName="accent-icon-muted" type="monochrome" style={{ transform: [{ rotate: props.expanded ? "180deg" : "0deg" }] }} /> </Pressable> ); }); export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(props: { readonly count: number; readonly disabled?: boolean; readonly expanded: boolean; readonly onToggle: () => void; readonly pane?: "screen" | "sidebar"; }) { return ( <Pressable accessibilityHint={ props.expanded ? "Collapses the settled threads." : "Expands the settled threads." } accessibilityLabel={props.count === 1 ? "1 settled thread" : `${props.count} settled threads`} accessibilityRole="button" accessibilityState={{ disabled: props.disabled, expanded: props.expanded }} className={cn( "mb-1.5 mt-4 flex-row items-center gap-2.5", props.pane === "sidebar" ? "px-3" : "px-5", )} disabled={props.disabled} onPress={props.onToggle} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > <Text className="text-xs font-t3-medium text-foreground-tertiary"> {props.expanded ? "Settled" : `Settled (${props.count})`} </Text> <View className="h-px flex-1 bg-border" /> <SymbolView name="chevron.down" size={10} tintColorClassName={"accent-foreground-muted"} type="monochrome" style={{ transform: [{ rotate: props.expanded ? "180deg" : "0deg" }] }} /> </Pressable> ); }); const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } }, ]; /** * Unsent work, in the same idiom as an active v2 row: it is work the user * wrote, so it reads like the thread it will become. The status slot says * what happens next, not where the item sits: "Sends on reconnect" stays * uncolored because nothing is asked of the user; "Draft" takes the amber the * web sidebar uses for drafts, because this one waits on the user. */ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props: { readonly pendingTask: PendingNewTask; readonly project: EnvironmentProject | null; readonly projectTitle?: string; readonly environmentLabel: string | null; /** Drawn beside the label; ignored while the label is null. */ readonly environmentMachine?: EnvironmentMachineKind; readonly pane?: "screen" | "sidebar"; /** Draws the "Unsent" divider above the first draft or 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 sidebarPane = props.pane === "sidebar"; const isDraft = pendingTask.kind === "draft"; const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.projectTitle ?? ""; const branch = pendingTask.branch; const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask); }, [onDeletePendingTask, pendingTask], ); const rowContent = ( <> <View className="flex-row items-center gap-1.5"> {props.project ? ( <ProjectFavicon environmentId={pendingTask.environmentId} faviconPath={props.project.faviconPath} size={15} projectTitle={projectTitle} workspaceRoot={props.project.workspaceRoot} /> ) : null} <Text className="flex-1 text-sm font-t3-medium text-foreground-muted" numberOfLines={1}> {projectTitle} </Text> {isDraft ? ( <View className="flex-row items-center gap-1"> <SymbolView name="square.and.pencil" size={10} tintColorClassName="accent-adaptive-amber-700-300" type="monochrome" /> <Text className="text-xs text-adaptive-amber-700-300">Draft</Text> </View> ) : ( <Text className="text-xs text-foreground-tertiary">Sends on reconnect</Text> )} </View> {/* 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. */} <Text className="mt-1 text-base font-t3-medium text-foreground" numberOfLines={1}> {pendingTask.title} </Text> {branch || props.environmentLabel ? ( <View className="mt-1 flex-row items-center gap-1"> <Text className="shrink text-xs text-foreground-muted" numberOfLines={1}> {branch ? ( <Text className="text-xs text-foreground-muted" style={{ fontFamily: MONO_FONT }}> {branch} </Text> ) : null} {branch && props.environmentLabel ? " · " : null} {props.environmentLabel ? ( <Text className="text-xs text-foreground-tertiary">{props.environmentLabel}</Text> ) : null} </Text> {props.environmentLabel && props.environmentMachine ? ( <EnvironmentMachineSymbol kind={props.environmentMachine} size={11} tintColorClassName="accent-foreground-tertiary" /> ) : null} </View> ) : null} </> ); return ( <> {props.showPendingDivider ? ( <ThreadListV2SectionDivider label="Unsent" pane={props.pane} /> ) : null} <ControlPillMenu actions={isDraft ? DRAFT_TASK_MENU_ACTIONS : PENDING_TASK_MENU_ACTIONS} onPressAction={handleMenuAction} shouldOpenOnLongPress > <Pressable accessibilityHint={ isDraft ? "Opens the draft in the new task composer" : "Sends when the environment reconnects. Opens the task for editing" } accessibilityLabel={pendingTask.title} accessibilityRole="button" className={sidebarPane ? "bg-drawer active:bg-subtle" : undefined} onPress={() => onSelectPendingTask(pendingTask)} style={ sidebarPane ? { borderRadius: SIDEBAR_V2_ROW_RADIUS, paddingHorizontal: 12, paddingVertical: 10, } : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) } > {sidebarPane ? ( rowContent ) : ( <View className="bg-screen"> <View className="px-5 py-2.5">{rowContent}</View> {props.showTrailingDivider !== false ? ( <View className="ml-5 h-px bg-border-subtle" /> ) : null} </View> )} </Pressable> </ControlPillMenu> </> ); }); export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; /** A message for this thread is waiting in the outbox. */ readonly hasQueuedMessages?: boolean; /** 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 providerInstance: ThreadRowProviderInstance | 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; /** Drawn after the label so the machine reads at a glance; ignored while the label is null. */ readonly environmentMachine?: EnvironmentMachineKind; /** 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 onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>; 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; /** Server supports reordering this card's section. */ readonly reorderSupported?: boolean; readonly onMoveThread?: ( thread: EnvironmentThreadShell, direction: ThreadMoveDestination, ) => void; /** Position flags for the card's section so the menu disables the move that would fall off the end of the list. */ readonly canMoveUp?: boolean; readonly canMoveDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; readonly simultaneousSwipeGesture?: ComponentProps< typeof ThreadSwipeable >["simultaneousWithExternalGesture"]; }) { const { width: windowWidth } = useWindowDimensions(); const { thread, variant, onSelectThread, onDeleteThread, onRegenerateThreadTitle, onNewThreadOnBranch, onSettleThread, onSnoozeThread, onUnsnoozeThread, onUnsettleThread, onArchiveThread, onPinThread, onUnpinThread, onMoveThread, } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; const pr = useThreadPr(thread); const { materialYouStyleLayoutActive } = useAppearancePreferences(); const theme = useUniwindTheme(); const screenColor = theme["--color-screen"]; const drawerColor = theme["--color-drawer"]; const pressedBackgroundColor = theme["--color-subtle"]; const selectedBackgroundColor = theme[materialYouStyleLayoutActive ? "--color-thread-selected" : "--color-user-bubble"]; const sidebarPane = props.pane === "sidebar"; const selected = props.selected === true; // The provider badge's border blends into the row's own surface, which // differs by pane and (for the sidebar pane) selection: the sidebar row // background becomes the selected fill or the drawer surface, while the // flat "screen" pane rows always sit on the screen background. const providerIconSurfaceColor = sidebarPane ? selected ? selectedBackgroundColor : drawerColor : screenColor; const status = resolveThreadListV2Status(thread); const statusLabel = STATUS_LABEL_BY_STATUS[status]; // Settled rows label by the same stamp they sort by, so order and label // can't disagree. updatedAt is always present, so the resolver never // returns null here. const settledTimestamp = variant === "slim" && !snoozedRow ? resolveSettledThreadTimestamp(thread) : null; const timeLabel = settledTimestamp !== null ? relativeTime(settledTimestamp) : 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 handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]); const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); // Swipe: the v2 primary action is the lifecycle transition. Un-settling a // settled row keeps it active until new activity clears the user override. 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,