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 { useUniwindTheme } from "../../lib/useUniwindTheme";
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 { 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 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 theme = useUniwindTheme();
const separatorColor = theme["--color-separator"];
const pressedBackgroundColor = theme["--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 theme = useUniwindTheme();
const separatorColor = theme["--color-separator"];
const screenColor = theme["--color-screen"];
const drawerColor = theme["--color-drawer"];
const pressedBackgroundColor = theme["--color-subtle"];
const selectedBackgroundColor = theme["--color-user-bubble"];
const selectedForegroundColor = theme["--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)}
)}
);
});