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, useColorScheme, useWindowDimensions, View } from "react-native";import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";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 { 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: ReturnType<typeof useColorScheme>,) { 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 ( <Svg width={props.size} height={props.size} viewBox="0 0 24 24" fill="none" stroke={props.color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" > <Circle cx={18} cy={18} r={3} /> <Circle cx={6} cy={6} r={3} /> <Path d="M13 6h3a2 2 0 0 1 2 2v7" /> <Path d="M6 9v12" /> </Svg> );} /* ─── 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 ( <View className={compact ? "flex-row items-center bg-screen" : "flex-row items-center"} style={{ minHeight: compact ? 44 : 36, paddingLeft: compact ? 20 : 12, // Compact right padding centers the 20pt plus glyph on the thread // rows' trailing chevron column (18 + 13/2 ≈ 24.5 from the edge). paddingRight: compact ? 14 : 12, paddingBottom: compact ? 12 : 8, paddingTop: props.isFirst ? (compact ? 8 : 4) : compact ? 24 : 20, }} > <Pressable accessibilityRole="button" accessibilityState={{ expanded: !props.collapsed }} accessibilityLabel={`${props.title}, ${props.threadCount} threads`} accessibilityHint={props.collapsed ? "Expands the project" : "Collapses the project"} className={ compact ? "flex-1 flex-row items-center gap-2.5" : "flex-1 flex-row items-center gap-2" } hitSlop={{ ...verticalHitSlop, left: compact ? 20 : 12 }} onPress={handleToggle} > <ProjectFavicon environmentId={props.project.environmentId} faviconPath={props.project.faviconPath} open={!props.collapsed} size={compact ? 22 : 18} projectTitle={props.project.title} workspaceRoot={props.project.workspaceRoot} /> <Text className={ compact ? "flex-shrink text-base font-t3-bold tracking-[0.2px] text-foreground-muted" : "flex-shrink text-sm font-t3-bold tracking-[0.2px] text-foreground-muted" } numberOfLines={1} > {props.title} </Text> <Text className={ compact ? "flex-1 text-sm font-t3-medium text-foreground-tertiary" : "flex-1 text-xs font-t3-medium text-foreground-tertiary" } > {props.threadCount} </Text> </Pressable> {showNewThreadButton ? ( <Pressable accessibilityLabel={`Create new thread in ${props.title}`} accessibilityRole="button" hitSlop={{ ...verticalHitSlop, left: 10, right: 14 }} onPress={handleNewThread} style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, paddingLeft: 12 })} > <SymbolView name="plus" size={compact ? 20 : 16} tintColor={iconMutedColor} type="monochrome" weight="medium" /> </Pressable> ) : null} </View> );}); /* ─── 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) => ( <Pressable accessibilityRole="button" accessibilityLabel={label === "Show more" ? "Show more threads" : "Show fewer threads"} className="rounded-full bg-subtle" hitSlop={6} onPress={onPress} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1, paddingHorizontal: compact ? 14 : 12, paddingVertical: compact ? 7 : 6, borderCurve: "continuous", })} > <View className="flex-row items-center gap-1.5"> <SymbolView name={icon} size={10} tintColor={iconSubtleColor} type="monochrome" weight="semibold" /> <Text className={ compact ? "text-sm font-t3-medium text-foreground-muted" : "text-xs font-t3-medium text-foreground-muted" } > {label} </Text> </View> </Pressable> ); return ( <View className={ compact ? "flex-row items-center gap-2.5 bg-screen" : "flex-row items-center gap-2" } style={{ paddingLeft: compact ? THREAD_LIST_COMPACT_INSET : 12, paddingRight: compact ? 18 : 12, paddingVertical: compact ? 12 : 8, }} > {showsMore ? button("Show more", "chevron.down", handleShowMore) : null} {props.canShowLess ? button("Show less", "chevron.up", handleShowLess) : null} </View> );}); /* ─── 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 = ( <View className="rounded-full bg-zinc-500/12 px-1.5 py-0.5 dark:bg-zinc-500/16"> <Text className="text-3xs font-t3-bold text-zinc-600 dark:text-zinc-300">Pending</Text> </View> ); const subtitleRow = subtitleParts.length > 0 ? ( <View className="mt-px flex-row items-center gap-1.5"> <SymbolView name="tray.and.arrow.up" size={10} tintColor={compact ? iconSubtleColor : mutedColor} type="monochrome" /> <Text className={ compact ? "shrink text-sm text-foreground-muted" : "shrink text-xs text-foreground-muted" } numberOfLines={1} > {subtitleParts.join(" · ")} </Text> </View> ) : null; const rowContent = compact ? ( <Pressable accessibilityHint="Opens the queued task for editing" accessibilityLabel={pendingTask.title} accessibilityRole="button" className="bg-screen" onPress={() => onSelectPendingTask(pendingTask)} style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} > <View style={{ paddingLeft: THREAD_LIST_COMPACT_INSET, paddingRight: 18, paddingTop: 10, }} > <View style={{ gap: 3, borderBottomWidth: props.isLast ? 0 : 1, borderBottomColor: separatorColor, paddingBottom: 10, }} > <View className="flex-row items-center justify-between gap-2"> <Text className="flex-1 text-lg font-t3-bold text-foreground" numberOfLines={1}> {pendingTask.title} </Text> <View className="flex-row items-center gap-2"> {statusPill} <Text className="text-base tabular-nums text-foreground-tertiary">{timestamp}</Text> <SymbolView name="chevron.right" size={13} tintColor={iconSubtleColor} type="monochrome" /> </View> </View> {subtitleRow} </View> </View> </Pressable> ) : ( <Pressable accessibilityHint="Opens the queued task for editing" accessibilityLabel={pendingTask.title} accessibilityRole="button" onPress={() => onSelectPendingTask(pendingTask)} style={({ pressed }) => ({ backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderRadius: SIDEBAR_ROW_RADIUS, cursor: "pointer", minHeight: 64, justifyContent: "center", paddingHorizontal: 12, paddingVertical: 10, })} > <View className="gap-[3px]"> <View className="flex-row items-center justify-between gap-2"> <Text className="flex-1 text-base font-t3-medium text-foreground" numberOfLines={1}> {pendingTask.title} </Text> <View className="flex-row items-center gap-2"> {statusPill} <Text className="text-xs tabular-nums text-foreground-muted" numberOfLines={1}> {timestamp} </Text> </View> </View> {subtitleRow} </View> </Pressable> ); return ( <ControlPillMenu actions={PENDING_TASK_MENU_ACTIONS} onPressAction={handleMenuAction} shouldOpenOnLongPress > {rowContent} </ControlPillMenu> );}); /* ─── 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 colorScheme = useColorScheme(); 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 { 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 ? "rgba(255,255,255,0.16)" : pressedBackgroundColor; const effectiveStatus = selected && status ? { ...status, pillClassName: "bg-white/20", textClassName: "text-white" } : 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<MenuAction[]>( () => [ 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],Lines 1–500 of 706. Load more
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, useColorScheme, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; 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 { 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: ReturnType<typeof useColorScheme>, ) { 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 ( <Svg width={props.size} height={props.size} viewBox="0 0 24 24" fill="none" stroke={props.color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" > <Circle cx={18} cy={18} r={3} /> <Circle cx={6} cy={6} r={3} /> <Path d="M13 6h3a2 2 0 0 1 2 2v7" /> <Path d="M6 9v12" /> </Svg> ); } /* ─── 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 ( <View className={compact ? "flex-row items-center bg-screen" : "flex-row items-center"} style={{ minHeight: compact ? 44 : 36, paddingLeft: compact ? 20 : 12, // Compact right padding centers the 20pt plus glyph on the thread // rows' trailing chevron column (18 + 13/2 ≈ 24.5 from the edge). paddingRight: compact ? 14 : 12, paddingBottom: compact ? 12 : 8, paddingTop: props.isFirst ? (compact ? 8 : 4) : compact ? 24 : 20, }} > <Pressable accessibilityRole="button" accessibilityState={{ expanded: !props.collapsed }} accessibilityLabel={`${props.title}, ${props.threadCount} threads`} accessibilityHint={props.collapsed ? "Expands the project" : "Collapses the project"} className={ compact ? "flex-1 flex-row items-center gap-2.5" : "flex-1 flex-row items-center gap-2" } hitSlop={{ ...verticalHitSlop, left: compact ? 20 : 12 }} onPress={handleToggle} > <ProjectFavicon environmentId={props.project.environmentId} faviconPath={props.project.faviconPath} open={!props.collapsed} size={compact ? 22 : 18} projectTitle={props.project.title} workspaceRoot={props.project.workspaceRoot} /> <Text className={ compact ? "flex-shrink text-base font-t3-bold tracking-[0.2px] text-foreground-muted" : "flex-shrink text-sm font-t3-bold tracking-[0.2px] text-foreground-muted" } numberOfLines={1} > {props.title} </Text> <Text className={ compact ? "flex-1 text-sm font-t3-medium text-foreground-tertiary" : "flex-1 text-xs font-t3-medium text-foreground-tertiary" } > {props.threadCount} </Text> </Pressable> {showNewThreadButton ? ( <Pressable accessibilityLabel={`Create new thread in ${props.title}`} accessibilityRole="button" hitSlop={{ ...verticalHitSlop, left: 10, right: 14 }} onPress={handleNewThread} style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, paddingLeft: 12 })} > <SymbolView name="plus" size={compact ? 20 : 16} tintColor={iconMutedColor} type="monochrome" weight="medium" /> </Pressable> ) : null} </View> ); }); /* ─── 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) => ( <Pressable accessibilityRole="button" accessibilityLabel={label === "Show more" ? "Show more threads" : "Show fewer threads"} className="rounded-full bg-subtle" hitSlop={6} onPress={onPress} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1, paddingHorizontal: compact ? 14 : 12, paddingVertical: compact ? 7 : 6, borderCurve: "continuous", })} > <View className="flex-row items-center gap-1.5"> <SymbolView name={icon} size={10} tintColor={iconSubtleColor} type="monochrome" weight="semibold" /> <Text className={ compact ? "text-sm font-t3-medium text-foreground-muted" : "text-xs font-t3-medium text-foreground-muted" } > {label} </Text> </View> </Pressable> ); return ( <View className={ compact ? "flex-row items-center gap-2.5 bg-screen" : "flex-row items-center gap-2" } style={{ paddingLeft: compact ? THREAD_LIST_COMPACT_INSET : 12, paddingRight: compact ? 18 : 12, paddingVertical: compact ? 12 : 8, }} > {showsMore ? button("Show more", "chevron.down", handleShowMore) : null} {props.canShowLess ? button("Show less", "chevron.up", handleShowLess) : null} </View> ); }); /* ─── 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 = ( <View className="rounded-full bg-zinc-500/12 px-1.5 py-0.5 dark:bg-zinc-500/16"> <Text className="text-3xs font-t3-bold text-zinc-600 dark:text-zinc-300">Pending</Text> </View> ); const subtitleRow = subtitleParts.length > 0 ? ( <View className="mt-px flex-row items-center gap-1.5"> <SymbolView name="tray.and.arrow.up" size={10} tintColor={compact ? iconSubtleColor : mutedColor} type="monochrome" /> <Text className={ compact ? "shrink text-sm text-foreground-muted" : "shrink text-xs text-foreground-muted" } numberOfLines={1} > {subtitleParts.join(" · ")} </Text> </View> ) : null; const rowContent = compact ? ( <Pressable accessibilityHint="Opens the queued task for editing" accessibilityLabel={pendingTask.title} accessibilityRole="button" className="bg-screen" onPress={() => onSelectPendingTask(pendingTask)} style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} > <View style={{ paddingLeft: THREAD_LIST_COMPACT_INSET, paddingRight: 18, paddingTop: 10, }} > <View style={{ gap: 3, borderBottomWidth: props.isLast ? 0 : 1, borderBottomColor: separatorColor, paddingBottom: 10, }} > <View className="flex-row items-center justify-between gap-2"> <Text className="flex-1 text-lg font-t3-bold text-foreground" numberOfLines={1}> {pendingTask.title} </Text> <View className="flex-row items-center gap-2"> {statusPill} <Text className="text-base tabular-nums text-foreground-tertiary">{timestamp}</Text> <SymbolView name="chevron.right" size={13} tintColor={iconSubtleColor} type="monochrome" /> </View> </View> {subtitleRow} </View> </View> </Pressable> ) : ( <Pressable accessibilityHint="Opens the queued task for editing" accessibilityLabel={pendingTask.title} accessibilityRole="button" onPress={() => onSelectPendingTask(pendingTask)} style={({ pressed }) => ({ backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderRadius: SIDEBAR_ROW_RADIUS, cursor: "pointer", minHeight: 64, justifyContent: "center", paddingHorizontal: 12, paddingVertical: 10, })} > <View className="gap-[3px]"> <View className="flex-row items-center justify-between gap-2"> <Text className="flex-1 text-base font-t3-medium text-foreground" numberOfLines={1}> {pendingTask.title} </Text> <View className="flex-row items-center gap-2"> {statusPill} <Text className="text-xs tabular-nums text-foreground-muted" numberOfLines={1}> {timestamp} </Text> </View> </View> {subtitleRow} </View> </Pressable> ); return ( <ControlPillMenu actions={PENDING_TASK_MENU_ACTIONS} onPressAction={handleMenuAction} shouldOpenOnLongPress > {rowContent} </ControlPillMenu> ); }); /* ─── 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 colorScheme = useColorScheme(); 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 { 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 ? "rgba(255,255,255,0.16)" : pressedBackgroundColor; const effectiveStatus = selected && status ? { ...status, pillClassName: "bg-white/20", textClassName: "text-white" } : 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<MenuAction[]>( () => [ 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],