import * as Dialog from "../../components/ui/Dialog"; import type { ThreadRowProviderInstance } from "./thread-provider-instance"; import { THREAD_LIST_V2_MONO_FONT as MONO_FONT, THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME, THREAD_LIST_V2_ROW_DIVIDERS, selectedThreadRowColors, getThreadListV2NewBranchMenuTitle, getThreadListV2RowAppearance, } from "./thread-list-v2-row-appearance"; import { RowPressable } from "../../components/ui/RowPressable"; import { CustomSnoozeSheet } from "./CustomSnoozeSheet"; import { appAtomRegistry } from "../../state/atom-registry"; import { threadArrangementOpenAtom } from "../../state/thread-order"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { useVisibleSecondClock } from "./use-visible-second-clock"; 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 { formatWorkingDurationLabel, resolveThreadWorkingStartedAt, } from "@t3tools/client-runtime/state/models"; import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import type { ThreadListProvider } from "../../state/thread-list-environments"; import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; import { Menu } from "../../components/ui/Menu"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useEnvironmentScope } from "../../state/session"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { resolveThreadPrBadgeAction, type ThreadPrPresentation, } from "../../state/thread-pr-presentation"; import { useThreadPr } from "../../state/use-thread-pr"; import { useSwipeRowDormant } from "../home/swipe-row-activation"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { THREAD_LIST_V2_SETTLED_PAGE_COUNT, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2SnoozeMenuSelection, threadHasUnseenCompletion, resolveThreadListV2Status, resolveThreadListV2ProviderDrivers, resolveThreadListV2SwipeActions, type ThreadListV2Status, } from "./threadListV2"; import { QueuedMessageIcon } from "./queued-message-icon"; import { shouldRecedeThreadRow } from "./thread-row-emphasis"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; import { pullRequestStatus } from "../pull-requests/pullRequestStatus"; interface StatusLabel { readonly label: string; readonly icon: AppSymbolName; readonly className: string; readonly iconTintClassName: string; } const STATUS_LABEL_BY_STATUS: Partial> = { approval: { label: "Approval", icon: "exclamationmark.shield", className: "text-warning-foreground", iconTintClassName: "accent-warning-foreground", }, input: { label: "Input", icon: "questionmark.bubble", className: "text-adaptive-indigo-600-300", iconTintClassName: "accent-adaptive-indigo-600-300", }, request: { label: "Request", icon: "person.crop.circle", className: "text-adaptive-fuchsia-600-300", iconTintClassName: "accent-adaptive-fuchsia-600-300", }, working: { label: "Working", icon: "circle.dashed", className: "text-adaptive-sky-600-400", iconTintClassName: "accent-adaptive-sky-600-400", }, waiting: { label: "Waiting", icon: "circle.dashed.inset.filled", className: "text-foreground-muted", iconTintClassName: "accent-foreground-muted", }, failed: { label: "Failed", icon: "exclamationmark.circle", className: "text-danger-foreground", iconTintClassName: "accent-danger-foreground", }, limited: { label: "Limited", icon: "exclamationmark.circle", className: "text-warning-foreground", iconTintClassName: "accent-warning-foreground", }, }; const DONE_STATUS_LABEL: StatusLabel = { label: "Done", icon: "checkmark.circle", className: "text-adaptive-emerald-700-300", iconTintClassName: "accent-adaptive-emerald-700-300", }; function ThreadListV2Duration(props: { readonly startedAt: string | null }) { const startedAtMs = Date.parse(props.startedAt ?? ""); const nowMs = useVisibleSecondClock(Number.isFinite(startedAtMs)); if (!Number.isFinite(startedAtMs)) return null; return ` ${formatWorkingDurationLabel(nowMs - startedAtMs)}`; } 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 } }, ]; const LEGACY_MENU_ACTIONS: MenuAction[] = [ { id: "archive", title: "Archive", image: "archivebox" }, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; function ThreadListV2Section(props: { readonly label: string; readonly pane?: "screen" | "sidebar"; readonly tone?: "default" | "snoozed"; readonly disclosure?: { readonly expanded: boolean; readonly disabled?: boolean; readonly onToggle: () => void; readonly accessibilityLabel: string; readonly accessibilityHint: string; }; }) { const snoozed = props.tone === "snoozed"; const sidebarPane = props.pane === "sidebar"; const className = cn( "mb-1.5 mt-4 flex-row items-center gap-2.5", props.pane === "sidebar" ? "px-3" : "px-5", ); const content = ( <> {props.label} {props.disclosure ? ( ) : null} ); return props.disclosure ? ( ({ opacity: pressed ? 0.6 : 1 })} > {content} ) : ( {content} ); } export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivider(props: { readonly label: string; readonly pane?: "screen" | "sidebar"; }) { return ; }); type ThreadListV2ShelfHeaderProps = { readonly count: number; readonly disabled?: boolean; readonly expanded: boolean; readonly onToggle: () => void; readonly pane?: "screen" | "sidebar"; }; const SHELF_LABEL = { working: "Working", snoozed: "Snoozed", settled: "Settled" } as const; function ThreadListV2ShelfHeader( props: ThreadListV2ShelfHeaderProps & { readonly kind: keyof typeof SHELF_LABEL }, ) { const label = SHELF_LABEL[props.kind]; return ( ); } export const ThreadListV2WorkingShelfHeader = memo(function ThreadListV2WorkingShelfHeader( props: ThreadListV2ShelfHeaderProps, ) { return ; }); export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader( props: ThreadListV2ShelfHeaderProps, ) { return ; }); export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader( props: ThreadListV2ShelfHeaderProps, ) { return ; }); export const ThreadListV2ShowMoreRow = memo(function ThreadListV2ShowMoreRow(props: { readonly pane?: "screen" | "sidebar"; readonly hiddenCount: number; /** Older settled threads are loading, or their last read failed. */ readonly state?: "loading" | "failed"; readonly onPress: () => void; }) { const label = props.state === "loading" ? "Loading" : props.state === "failed" ? "Could not load older threads. Retry" : `Show more (${props.hiddenCount} settled hidden)`; return ( ({ opacity: pressed ? 0.6 : 1 })} > {label} ); }); 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 } }, ]; export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props: { readonly pendingTask: PendingNewTask; readonly selected?: boolean; readonly project: EnvironmentProject | null; readonly projectTitle?: string; readonly environmentLabel: string | null; readonly environmentMachine?: EnvironmentMachineKind; readonly pane?: "screen" | "sidebar"; readonly showPendingDivider: boolean; readonly showTrailingDivider?: boolean; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; readonly fullSwipeWidth?: number; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly activationKey?: string; readonly simultaneousSwipeGesture?: ComponentProps["simultaneousWith"]; }) { const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; const { width: windowWidth } = useWindowDimensions(); const dormant = useSwipeRowDormant(props.activationKey); const theme = useUniwindTheme(); const sidebarPane = props.pane === "sidebar"; const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, props.selected ?? false); const isDraft = pendingTask.kind === "draft"; const deleteLabel = isDraft ? "Discard" : "Delete"; const handleDelete = useCallback( () => onDeletePendingTask(pendingTask), [onDeletePendingTask, pendingTask], ); const deleteAction = useMemo( () => ({ accessibilityLabel: `${deleteLabel} ${pendingTask.title}`, destructive: true, icon: "trash" as const, label: deleteLabel, onPress: handleDelete, }), [deleteLabel, handleDelete, pendingTask.title], ); 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 = ( <> {props.project ? ( ) : null} {projectTitle} {isDraft ? ( Draft ) : ( Sends on reconnect )} {pendingTask.title} {branch || props.environmentLabel ? ( {branch ? ( {branch} ) : null} {branch && props.environmentLabel ? " · " : null} {props.environmentLabel ? ( {props.environmentLabel} ) : null} {props.environmentLabel && props.environmentMachine ? ( ) : null} ) : null} ); return ( <> {props.showPendingDivider ? ( ) : null} {() => ( onSelectPendingTask(pendingTask)} style={rowAppearance.cardStyle} > {sidebarPane ? ( rowContent ) : ( {rowContent} {props.showTrailingDivider !== false ? ( ) : null} )} )} ); }); export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; readonly hasQueuedMessages?: boolean; readonly snoozed?: boolean; readonly pinned?: boolean; readonly wokeAt: string | null; readonly snoozeWakeLabelText?: string; readonly timeLabel: string; readonly snoozePresetMinute: string; readonly project: EnvironmentProject | null; readonly providers: ReadonlyArray | undefined; readonly providerInstance: ThreadRowProviderInstance | null; readonly environmentLabel: string | null; readonly environmentMachine?: EnvironmentMachineKind; readonly pane?: "screen" | "sidebar"; readonly showTrailingDivider?: boolean; readonly selected?: boolean; readonly fullSwipeWidth?: number; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onOpenThreadPullRequests: ( thread: EnvironmentThreadShell, pr: ThreadPrPresentation, ) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; readonly onRenameThread: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; 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; readonly onSetThreadAutoSettle: (thread: EnvironmentThreadShell, enabled: boolean) => void; readonly settlementSupported: boolean; readonly snoozeSupported: boolean; readonly pinningSupported: boolean; readonly autoSettleOptOutSupported: boolean; readonly titleRegenerationSupported: boolean; readonly reorderSupported?: boolean; readonly onMoveThread?: ( thread: EnvironmentThreadShell, direction: ThreadMoveDestination, ) => void; readonly canMoveUp?: boolean; readonly canMoveDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly activationKey?: string; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; readonly simultaneousSwipeGesture?: ComponentProps["simultaneousWith"]; }) { const { width: windowWidth, fontScale } = useWindowDimensions(); const titleTypography = useScaledTextRole("body"); const metadataTypography = useScaledTextRole("label"); const projectIconSize = titleTypography.fontSize * 1.25 * fontScale; const machineIconSize = metadataTypography.fontSize * fontScale; const titleLineHeight = titleTypography.lineHeight * fontScale; const { thread, variant, onSelectThread, onOpenThreadPullRequests, onDeleteThread, onRenameThread, onRegenerateThreadTitle, onNewThreadOnBranch, onSettleThread, onSnoozeThread, onUnsnoozeThread, onUnsettleThread, onArchiveThread, onPinThread, onUnpinThread, onSetThreadAutoSettle, onMoveThread, } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; const dormant = useSwipeRowDormant(props.activationKey); const selectThread = () => onSelectThread(thread); const { providerDrivers, providerIconUrl } = useMemo(() => { const provider = props.providers?.find( (candidate) => candidate.instanceId === (thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId), ); return { providerDrivers: resolveThreadListV2ProviderDrivers(thread, props.providers), providerIconUrl: provider?.iconUrl, }; }, [thread, props.providers]); const providerInstance = props.providerInstance; const pr = useThreadPr(thread); const prBadgeLabel = pr ? resolveThreadPrBadgeAction(pr).accessibilityLabel : null; const handleOpenPullRequests = useCallback(() => { if (pr) onOpenThreadPullRequests(thread, pr); }, [onOpenThreadPullRequests, pr, thread]); const theme = useUniwindTheme(); const sidebarPane = props.pane === "sidebar"; const selected = props.selected === true; const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected); const status = resolveThreadListV2Status(thread); const isUnread = status === "ready" && threadHasUnseenCompletion(thread); const workingLabel = STATUS_LABEL_BY_STATUS[status]; const showsWoke = status === "ready" && props.wokeAt !== null; const statusLabel = (status === "working" && workingLabel !== undefined && thread.goal?.status === "active" ? { ...workingLabel, label: "Goal" } : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined); const recede = shouldRecedeThreadRow({ status, selected }); const timeLabel = props.timeLabel; const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const handleAcknowledgeWoke = useCallback(() => { if (props.wokeAt === null) return; void visitThread({ environmentId: thread.environmentId, input: { threadId: thread.id, visitedAt: props.wokeAt }, }); }, [props.wokeAt, thread.environmentId, thread.id, visitThread]); // VoiceOver cannot focus buttons inside the accessible row. const rowAccessibilityActions = useMemo( () => [ ...(prBadgeLabel ? [{ name: "openPullRequests", label: prBadgeLabel }] : []), ...(showsWoke ? [{ name: "dismissWoke", label: "Dismiss Woke notification" }] : []), ], [prBadgeLabel, showsWoke], ); const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); const handleRegenerateTitle = useCallback( () => onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], ); const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); const [customSnoozeOpen, setCustomSnoozeOpen] = useState(false); // Recycling changes identity without remounting; clear local UI directly because equality may skip the render closure. const rowIdentity = `${thread.environmentId}:${thread.id}`; const [boundIdentity, setBoundIdentity] = useState(rowIdentity); if (boundIdentity !== rowIdentity) { setBoundIdentity(rowIdentity); setCustomSnoozeOpen(false); } 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 handleSetAutoSettle = useCallback( (enabled: boolean) => onSetThreadAutoSettle(thread, enabled), [onSetThreadAutoSettle, thread], ); const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]); const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); const canUnsettle = variant === "slim"; // Keep time in state; cached render-time reads freeze the gate and trigger 50ms expiry loops. const [snoozeGateClock, setSnoozeGateClock] = useState(() => ({ thread, now: Date.now() })); // oxlint-disable-next-line react/purity -- Reseeds the snooze gate clock only in the render-phase adjustment that runs when the thread changes. if (snoozeGateClock.thread !== thread) setSnoozeGateClock({ thread, now: Date.now() }); const snoozeGateNow = new Date(snoozeGateClock.now).toISOString(); const snoozeGateExpiryMs = props.snoozeSupported ? resolveThreadListV2SnoozeGateExpiryMs(thread, { now: snoozeGateNow }) : null; useEffect(() => { // A gate still closed past its expiry is held by runtime status, not time, // so no clock tick can open it. if (snoozeGateExpiryMs === null || snoozeGateExpiryMs <= snoozeGateClock.now) return; const delayMs = Math.min(snoozeGateExpiryMs - Date.now() + 50, 2_147_483_647); const id = setTimeout(() => setSnoozeGateClock({ thread, now: Date.now() }), delayMs); return () => clearTimeout(id); }, [snoozeGateExpiryMs, snoozeGateClock.now, thread]); const swipeActions = resolveThreadListV2SwipeActions({ variant, settlementSupported: props.settlementSupported, snoozeSupported: props.snoozeSupported, snoozable: canSnooze(thread, { now: snoozeGateNow }), snoozed: snoozedRow, }); const snoozePresets = useMemo( // oxlint-disable-next-line react/purity -- The memo recomputes on the snoozePresetMinute tick, so preset times refresh every minute. () => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)), // oxlint-disable-next-line react/memo-dependencies -- The minute tick refreshes relative snooze times while the row remains mounted. [props.snoozePresetMinute, swipeActions.secondary], ); const snoozePresetActions = useMemo( () => [ ...snoozePresets.map((preset) => ({ id: `snooze:${preset.id}`, title: preset.label, subtitle: preset.whenLabel, })), { id: "snooze:custom", title: "Custom…" }, ], [snoozePresets], ); const arrangementMenuItems = useMemo( () => [ ...(props.reorderSupported === true ? [ { id: "arrange", title: "Arrange threads…", image: "line.3.horizontal" }, { id: "move-up", title: "Move up", image: "arrow.up", attributes: { disabled: props.canMoveUp !== true }, } satisfies MenuAction, { id: "move-down", title: "Move down", image: "arrow.down", attributes: { disabled: props.canMoveDown !== true }, } satisfies MenuAction, ] : []), ...(props.pinningSupported ? [ thread.pinnedAt != null ? { id: "unpin", title: "Unpin", image: "pin.slash" } : { id: "pin", title: "Pin", image: "pin" }, ] : []), ], [ props.canMoveDown, props.canMoveUp, props.reorderSupported, props.pinningSupported, thread.pinnedAt, ], ); const autoSettleMenuItems = useMemo( () => props.autoSettleOptOutSupported ? [ { id: "auto-settle", title: "Auto-settle behavior", image: "timer", subactions: [ { id: "auto-settle:enabled", title: "Enabled", state: thread.autoSettleDisabledAt == null ? "on" : "off", }, { id: "auto-settle:disabled", title: "Disabled", state: thread.autoSettleDisabledAt == null ? "off" : "on", }, ], } satisfies MenuAction, ] : [], [props.autoSettleOptOutSupported, thread.autoSettleDisabledAt], ); const titleMenuItems = useMemo( () => [ { id: "rename", title: "Rename", image: "square.and.pencil" }, ...buildThreadTitleRegenerationMenuItems({ supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, }), ], [props.titleRegenerationSupported, thread.titleRegeneration], ); const snoozableCardMenuActions = useMemo( () => [ { id: "settle", title: "Settle", image: "checkmark" }, { id: "snooze", title: "Snooze", image: "clock", subactions: snoozePresetActions, }, ...arrangementMenuItems, ...titleMenuItems, ...autoSettleMenuItems, LEGACY_MENU_ACTIONS[0]!, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], [arrangementMenuItems, autoSettleMenuItems, snoozePresetActions, titleMenuItems], ); const cardMenuActions = useMemo( () => [ CARD_MENU_ACTIONS[0]!, ...arrangementMenuItems, ...titleMenuItems, ...autoSettleMenuItems, LEGACY_MENU_ACTIONS[0]!, ...CARD_MENU_ACTIONS.slice(1), ], [arrangementMenuItems, autoSettleMenuItems, titleMenuItems], ); const slimMenuActions = useMemo( () => [ SLIM_MENU_ACTIONS[0]!, ...arrangementMenuItems.filter( (action) => action.id !== "move-up" && action.id !== "move-down", ), ...titleMenuItems, ...autoSettleMenuItems, LEGACY_MENU_ACTIONS[0]!, SLIM_MENU_ACTIONS[1]!, ], [arrangementMenuItems, autoSettleMenuItems, titleMenuItems], ); const snoozedMenuActions = useMemo( () => [ SNOOZED_MENU_ACTIONS[0]!, ...titleMenuItems, ...autoSettleMenuItems, LEGACY_MENU_ACTIONS[0]!, SNOOZED_MENU_ACTIONS[1]!, ], [autoSettleMenuItems, titleMenuItems], ); const legacyMenuActions = useMemo( () => [ LEGACY_MENU_ACTIONS[0]!, ...arrangementMenuItems, ...titleMenuItems, LEGACY_MENU_ACTIONS[1]!, ], [arrangementMenuItems, titleMenuItems], ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread); if (nativeEvent.event === "settle") handleSettle(); if (nativeEvent.event === "unsettle") handleUnsettle(); if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "pin") handlePin(); if (nativeEvent.event === "unpin") handleUnpin(); if (nativeEvent.event === "auto-settle:enabled") handleSetAutoSettle(true); if (nativeEvent.event === "auto-settle:disabled") handleSetAutoSettle(false); if (nativeEvent.event === "arrange") appAtomRegistry.set(threadArrangementOpenAtom, true); if (nativeEvent.event === "move-up") handleMoveUp(); if (nativeEvent.event === "move-down") handleMoveDown(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "rename") handleRename(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "copy-thread-id") { copyTextWithHaptic(thread.id, { target: "thread-id" }); } if (nativeEvent.event === "delete") handleDelete(); if (nativeEvent.event === "snooze:custom") { setCustomSnoozeOpen(true); return; } const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ event: nativeEvent.event, displayedPresets: snoozePresets, now: new Date(), }); if (snoozeSelection._tag === "selected") { handleSnooze(snoozeSelection.preset.snoozedUntil); } else if (snoozeSelection._tag === "expired") { Dialog.alert( "Could not snooze thread", "That snooze time has passed. Choose another time.", ); } }, [ onNewThreadOnBranch, thread, handleArchive, handleDelete, handleRegenerateTitle, handleRename, handleMoveDown, handleMoveUp, handlePin, handleSettle, handleSnooze, handleSetAutoSettle, handleUnpin, handleUnsettle, handleUnsnooze, setCustomSnoozeOpen, snoozePresets, ], ); const primaryAction = useMemo(() => { if (swipeActions.primary === "archive") { return { accessibilityLabel: `Archive ${thread.title}`, icon: "archivebox" as const, label: "Archive", onPress: handleArchive, }; } if (swipeActions.primary === "unsnooze") { return { accessibilityLabel: `Wake ${thread.title} now`, icon: "clock" as const, label: "Wake", onPress: handleUnsnooze, }; } return swipeActions.primary === "unsettle" ? { accessibilityLabel: `Un-settle ${thread.title}`, icon: "arrow.uturn.backward" as const, label: "Un-settle", onPress: handleUnsettle, } : { accessibilityLabel: `Settle ${thread.title}`, icon: "checkmark" as const, label: "Settle", onPress: handleSettle, }; }, [ handleArchive, handleSettle, handleUnsettle, handleUnsnooze, swipeActions.primary, thread.title, ]); const secondaryAction = useMemo( () => swipeActions.secondary === "snooze" ? { accessibilityLabel: `Choose when to snooze ${thread.title}`, icon: "clock" as const, label: "Snooze", menu: { actions: snoozePresetActions, onPressAction: handleMenuAction, title: "Snooze until", }, onPress: () => undefined, } : null, [handleMenuAction, snoozePresetActions, swipeActions.secondary, thread.title], ); const swipeAccessibilityHint = !canOperateThread ? "Opens the thread" : secondaryAction === null ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.` : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`; const titleTextInset = props.project ? projectIconSize + 8 : 0; const cardContent = ( <> {props.project ? ( ) : null} {thread.title} {props.hasQueuedMessages ? : null} {pinnedRow ? ( ) : null} {showsWoke ? ( undefined} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > Woke ) : statusLabel ? ( {statusLabel.label} {status === "working" ? ( ) : status === "waiting" ? ( ) : null} ) : ( {timeLabel} )} {props.searchMatch ? ( ) : null} {(status === "failed" || status === "limited") && thread.runtime?.lastError ? ( {thread.runtime.lastError} ) : props.environmentLabel ? ( {props.environmentMachine ? ( ) : null} {props.environmentLabel} ) : ( )} {pr ? ( undefined} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > {pr.label} ) : null} {providerInstance ? ( {providerDrivers.slice(0, -1).map( (driver, index) => ( /* oxlint-disable react/no-array-index-key -- Provider history can repeat a driver; these decorative icons have no local state. */ ) /* oxlint-enable react/no-array-index-key */, )} ) : null} ); const fadedCardContent = recede ? ( {cardContent} ) : ( cardContent ); const rowContent = (close: () => void) => variant === "card" ? ( { if (event.nativeEvent.actionName === "openPullRequests") handleOpenPullRequests(); if (event.nativeEvent.actionName === "dismissWoke") handleAcknowledgeWoke(); }} onPress={() => { close(); selectThread(); }} style={rowAppearance.cardStyle} > {sidebarPane ? ( fadedCardContent ) : ( {fadedCardContent} {THREAD_LIST_V2_ROW_DIVIDERS && props.showTrailingDivider !== false ? ( ) : null} )} ) : ( { close(); selectThread(); }} style={rowAppearance.style} > {props.project ? ( ) : null} {thread.title} {props.searchMatch ? ( ) : null} {props.hasQueuedMessages ? : null} {snoozedRow && props.snoozeWakeLabelText !== undefined ? props.snoozeWakeLabelText : timeLabel} ); if (!canOperateThread) return {rowContent(() => {})}; return ( {customSnoozeOpen && ( setCustomSnoozeOpen(false)} onSnooze={handleSnooze} /> )} {(close) => ( {rowContent(close)} )} ); });