import { useRecyclingState } from "@legendapp/list/react-native"; import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import type { MenuAction } from "@react-native-menu/menu"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useMemo, type ComponentProps } from "react"; import { Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import Svg, { Circle, Path } from "react-native-svg"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { cn } from "../../lib/cn"; import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; import { relativeTime } from "../../lib/time"; import { themeColorWithAlpha } from "../../lib/mobileTheme"; import { useThemeColor } from "../../lib/useThemeColor"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; import type { HomeGroupDisplayAction } from "../home/homeListItems"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { resolveThreadStatus } from "./threadPresentation"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; /** * Shared presentation for the thread lists: the compact (phone) Home list and * the iPad sidebar render the SAME items — group headers with collapse, * thread rows with status/PR/subtitle, and show-more rows — differing only in * metrics and chrome via `variant`. */ export type ThreadListVariant = "compact" | "sidebar"; /** Left inset that aligns compact secondary rows with the title column. */ export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; const SIDEBAR_ROW_RADIUS = 12; function pullRequestTintColor(state: ThreadPr["state"], colorScheme: "light" | "dark") { const dark = colorScheme === "dark"; switch (state) { case "open": return dark ? "#34d399" : "#059669"; case "merged": return dark ? "#a78bfa" : "#7c3aed"; case "closed": return dark ? "#a1a1aa" : "#71717a"; } } function PullRequestIcon(props: { readonly size: number; readonly color: string }) { return ( ); } /* ─── Project group header ───────────────────────────────────────────── */ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: { readonly variant: ThreadListVariant; readonly project: EnvironmentProject; readonly title: string; readonly threadCount: number; readonly collapsed: boolean; readonly isFirst: boolean; readonly groupKey: string; readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; /** Project a quick new thread should target; null hides the button. */ readonly newThreadTarget?: EnvironmentProject | null; readonly onNewThread?: (project: EnvironmentProject) => void; }) { const iconMutedColor = useThemeColor("--color-icon-muted"); const { groupKey, onGroupAction, onNewThread } = props; const newThreadTarget = props.newThreadTarget ?? null; const compact = props.variant === "compact"; const handleToggle = useCallback( () => onGroupAction(groupKey, "toggle-collapsed"), [groupKey, onGroupAction], ); const handleNewThread = useCallback(() => { if (newThreadTarget) { onNewThread?.(newThreadTarget); } }, [newThreadTarget, onNewThread]); const showNewThreadButton = onNewThread !== undefined && newThreadTarget !== null; // The new-thread button is a SIBLING of the collapse toggle, not a child: // nested touchables are unreachable to VoiceOver/TalkBack (the parent // swallows focus). Row padding lives on the container (explicit styles — // dynamic padding classes on Pressable did not apply reliably) so both // children share one centerline; hitSlop restores the padded tap area. const verticalHitSlop = { top: props.isFirst ? 8 : 24, bottom: 12 }; return ( {props.title} {props.threadCount} {showNewThreadButton ? ( ({ opacity: pressed ? 0.5 : 1, paddingLeft: 12 })} > ) : null} ); }); /* ─── Show more / show less row ──────────────────────────────────────── */ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: { readonly variant: ThreadListVariant; readonly hiddenCount: number; readonly canShowLess: boolean; readonly groupKey: string; readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; }) { const iconSubtleColor = useThemeColor("--color-icon-subtle"); const showsMore = props.hiddenCount > 0; const compact = props.variant === "compact"; const { groupKey, onGroupAction } = props; const handleShowMore = useCallback( () => onGroupAction(groupKey, "show-more"), [groupKey, onGroupAction], ); const handleShowLess = useCallback( () => onGroupAction(groupKey, "show-less"), [groupKey, onGroupAction], ); const button = (label: string, icon: "chevron.down" | "chevron.up", onPress: () => void) => ( ({ opacity: pressed ? 0.6 : 1, paddingHorizontal: compact ? 14 : 12, paddingVertical: compact ? 7 : 6, borderCurve: "continuous", })} > {label} ); return ( {showsMore ? button("Show more", "chevron.down", handleShowMore) : null} {props.canShowLess ? button("Show less", "chevron.up", handleShowLess) : null} ); }); /* ─── Pending task row ───────────────────────────────────────────────── */ const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; /** * A queued new task waiting in the outbox for its environment to reconnect. * Tapping reopens the new-task composer with everything prefilled; the row * disappears once the task is delivered and the real thread arrives. */ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { readonly variant: ThreadListVariant; readonly pendingTask: PendingNewTask; readonly environmentLabel: string | null; readonly isLast: boolean; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; }) { const compact = props.variant === "compact"; const separatorColor = useThemeColor("--color-separator"); const iconSubtleColor = useThemeColor("--color-icon-subtle"); const mutedColor = useThemeColor("--color-foreground-muted"); const pressedBackgroundColor = useThemeColor("--color-subtle"); const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; const timestamp = relativeTime(pendingTask.message.createdAt); const subtitleParts = [props.environmentLabel, pendingTask.creation.branch].filter( (part): part is string => Boolean(part), ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask); }, [onDeletePendingTask, pendingTask], ); const statusPill = ( Pending ); const subtitleRow = subtitleParts.length > 0 ? ( {subtitleParts.join(" · ")} ) : null; const rowContent = compact ? ( onSelectPendingTask(pendingTask)} style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} > {pendingTask.title} {statusPill} {timestamp} {subtitleRow} ) : ( onSelectPendingTask(pendingTask)} style={({ pressed }) => ({ backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderRadius: SIDEBAR_ROW_RADIUS, cursor: "pointer", minHeight: 64, justifyContent: "center", paddingHorizontal: 12, paddingVertical: 10, })} > {pendingTask.title} {statusPill} {timestamp} {subtitleRow} ); return ( {rowContent} ); }); /* ─── Thread row ─────────────────────────────────────────────────────── */ const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [ { id: "archive", title: "Archive", image: "archivebox" }, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; export const ThreadListRow = memo(function ThreadListRow(props: { readonly variant: ThreadListVariant; readonly thread: EnvironmentThreadShell; readonly environmentLabel: string | null; readonly projectCwd: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; readonly isLast: boolean; /** Sidebar only: the thread currently open in the detail pane. */ readonly selected?: boolean; /** Defaults to window width minus compact margins. */ readonly fullSwipeWidth?: number; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly titleRegenerationSupported: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly simultaneousSwipeGesture?: ComponentProps< typeof ThreadSwipeable >["simultaneousWithExternalGesture"]; }) { const { width: windowWidth } = useWindowDimensions(); const { themeAppearance: colorScheme } = useAppearancePreferences(); const compact = props.variant === "compact"; const selected = props.selected === true; // Recycling-safe: resets when the list container is reused for another // thread, so a hover highlight can't leak across rows. const [hovered, setHovered] = useRecyclingState(false); const separatorColor = useThemeColor("--color-separator"); const iconSubtleColor = useThemeColor("--color-icon-subtle"); const screenColor = useThemeColor("--color-screen"); const drawerColor = useThemeColor("--color-drawer"); const pressedBackgroundColor = useThemeColor("--color-subtle"); const selectedBackgroundColor = useThemeColor("--color-user-bubble"); const selectedForegroundColor = useThemeColor("--color-user-bubble-foreground"); const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } = props; const status = resolveThreadStatus(thread); const pr = useThreadPr(thread, props.projectCwd); const timestamp = relativeTime( thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, ); const threadAccessibilityLabel = pr ? `${thread.title}, ${pr.accessibilityLabel}` : thread.title; const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string => Boolean(part), ); const backgroundColor = compact ? screenColor : drawerColor; const effectivePressedBackground = selected ? themeColorWithAlpha(String(selectedForegroundColor), 0.16) : pressedBackgroundColor; const effectiveStatus = selected && status ? { ...status, pillClassName: "bg-user-bubble-foreground/20", textClassName: "text-user-bubble-foreground", } : status; const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); const handleRegenerateTitle = useCallback( () => onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], ); const menuActions = useMemo( () => [ THREAD_ROW_MENU_ACTIONS[0]!, ...buildThreadTitleRegenerationMenuItems({ supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, }), THREAD_ROW_MENU_ACTIONS[1]!, ], [props.titleRegenerationSupported, thread.titleRegeneration], ); const primaryAction = useMemo( () => ({ accessibilityLabel: `Archive ${thread.title}`, icon: "archivebox" as const, label: "Archive", onPress: handleArchive, }), [handleArchive, thread.title], ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "delete") handleDelete(); }, [handleArchive, handleDelete, handleRegenerateTitle], ); const statusPill = effectiveStatus ? ( {effectiveStatus.label} ) : null; const subtitleRow = subtitleParts.length > 0 || pr !== null ? ( {subtitleParts.length > 0 ? ( <> {subtitleParts.join(" · ")} ) : null} {pr !== null ? ( {pr.label} ) : null} ) : null; const rowContent = (close: () => void) => compact ? ( { close(); onSelectThread(thread); }} style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} > {thread.title} {statusPill} {timestamp} {props.searchMatch ? ( ) : null} {subtitleRow} ) : ( setHovered(true)} onHoverOut={() => setHovered(false)} onPress={() => { close(); onSelectThread(thread); }} style={({ pressed }) => ({ backgroundColor: selected ? selectedBackgroundColor : pressed || hovered ? effectivePressedBackground : backgroundColor, borderRadius: SIDEBAR_ROW_RADIUS, cursor: "pointer", minHeight: 64, justifyContent: "center", paddingHorizontal: 12, paddingVertical: 10, })} > {thread.title} {statusPill} {timestamp} {props.searchMatch ? ( ) : null} {subtitleRow} ); return ( {(close) => ( // Messages-style row actions on long-press. iOS: a real // UIContextMenuInteraction with the row as the zoom preview (needs the // patched @react-native-menu, see // patches/@react-native-menu__menu@2.0.0.patch — in long-press mode the // interaction is hosted by the component view and the underlying // UIButton passes touches through, so row taps keep working). Android: // ControlPillMenu injects onLongPress into the row and anchors the // token-styled dropdown to it; taps and swipes are untouched. {rowContent(close)} )} ); });