1import type { ThreadRowProviderInstance } from "./thread-provider-instance";
2import {
3 THREAD_LIST_V2_MONO_FONT as MONO_FONT,
4 THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME,
5 THREAD_LIST_V2_ROW_DIVIDERS,
6 selectedThreadRowColors,
7 getThreadListV2NewBranchMenuTitle,
8 getThreadListV2RowAppearance,
9} from "./thread-list-v2-row-appearance";
10import { RowPressable } from "../../components/RowPressable";
11import { CustomSnoozeSheet } from "./CustomSnoozeSheet";
12import { appAtomRegistry } from "../../state/atom-registry";
13import { threadArrangementOpenAtom } from "../../state/thread-order";
14import type { ThreadMoveDestination } from "./threadOrder";
15import type {
16 EnvironmentProject,
17 EnvironmentThreadShell,
18} from "@t3tools/client-runtime/state/shell";
19import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
20import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts";
21import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
22import type { MenuAction } from "@react-native-menu/menu";
23import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
24import { Alert, Pressable, useWindowDimensions, View } from "react-native";
25import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
26
27import type { ThreadListProvider } from "../../state/thread-list-environments";
28import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
29import { AppText as Text } from "../../components/AppText";
30import { ControlPillMenu } from "../../components/ControlPill";
31import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
32import { ProjectFavicon } from "../../components/ProjectFavicon";
33import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIcon";
34import { cn } from "../../lib/cn";
35import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
36import { useUniwindTheme } from "../../lib/useUniwindTheme";
37import { useEnvironmentScope, useThreadOrganizationPermission } from "../../state/session";
38import type { PendingNewTask } from "../../state/use-pending-new-tasks";
39import { useThreadPr } from "../../state/use-thread-pr";
40import { useSwipeRowDormant } from "../home/swipe-row-activation";
41import { ThreadSwipeable } from "../home/thread-swipe-actions";
42import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
43import {
44 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
45 resolveThreadListV2SnoozeGateExpiryMs,
46 resolveThreadListV2SnoozeMenuSelection,
47 threadHasUnseenCompletion,
48 resolveThreadListV2Status,
49 resolveThreadListV2ProviderDrivers,
50 resolveThreadListV2SwipeActions,
51 type ThreadListV2Status,
52} from "./threadListV2";
53import { QueuedMessageIcon } from "./queued-message-icon";
54import { shouldRecedeThreadRow } from "./thread-row-emphasis";
55import { ThreadSearchMatchExcerpt } from "./thread-search-match";
56
57/**
58 * Thread List v2 renders one flat native list: rich edge-to-edge rows for
59 * active work and a receded settled tail, all with native swipe and
60 * long-press actions. State reads through colored status labels and text
61 * hierarchy rather than card fills.
62 */
63
64// Status hues follow the system-wide convention set by sidebar v1 and the
65// Live Activity/widgets (amber approval, indigo input, sky working) so a
66// thread reads the same color everywhere it surfaces.
67// Icons match the web sidebar's status glyphs.
68interface StatusLabel {
69 readonly label: string;
70 readonly icon: AppSymbolName;
71 readonly className: string;
72 readonly iconTintClassName: string;
73}
74const STATUS_LABEL_BY_STATUS: Partial<Record<ThreadListV2Status, StatusLabel>> = {
75 approval: {
76 label: "Approval",
77 icon: "exclamationmark.shield",
78 className: "text-warning-foreground",
79 iconTintClassName: "accent-warning-foreground",
80 },
81 input: {
82 label: "Input",
83 icon: "questionmark.bubble",
84 className: "text-adaptive-indigo-600-300",
85 iconTintClassName: "accent-adaptive-indigo-600-300",
86 },
87 working: {
88 label: "Working",
89 icon: "circle.dashed",
90 className: "text-adaptive-sky-600-400",
91 iconTintClassName: "accent-adaptive-sky-600-400",
92 },
93 waiting: {
94 label: "Waiting",
95 icon: "circle.dashed.inset.filled",
96 className: "text-foreground-muted",
97 iconTintClassName: "accent-foreground-muted",
98 },
99 failed: {
100 label: "Failed",
101 icon: "exclamationmark.circle",
102 className: "text-danger-foreground",
103 iconTintClassName: "accent-danger-foreground",
104 },
105 limited: {
106 label: "Limited",
107 icon: "exclamationmark.circle",
108 className: "text-warning-foreground",
109 iconTintClassName: "accent-warning-foreground",
110 },
111};
112const DONE_STATUS_LABEL: StatusLabel = {
113 label: "Done",
114 icon: "checkmark.circle",
115 className: "text-adaptive-emerald-700-300",
116 iconTintClassName: "accent-adaptive-emerald-700-300",
117};
118
119// Menus keep lifecycle and title regeneration together. Archive keeps its
120// own surface (thread screen / settings) rather than crowding v2 rows.
121const CARD_MENU_ACTIONS: MenuAction[] = [
122 { id: "settle", title: "Settle", image: "checkmark" },
123 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
124];
125
126const SLIM_MENU_ACTIONS: MenuAction[] = [
127 { id: "unsettle", title: "Un-settle", image: "arrow.uturn.backward" },
128 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
129];
130
131const SNOOZED_MENU_ACTIONS: MenuAction[] = [
132 { id: "unsnooze", title: "Wake thread", image: "clock" },
133 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
134];
135
136// Pre-settlement servers: no lifecycle items, archive fills the gap.
137const LEGACY_MENU_ACTIONS: MenuAction[] = [
138 { id: "archive", title: "Archive", image: "archivebox" },
139 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
140];
141
142/** Rounded-row radius shared with the v1 sidebar rows. */
143const SIDEBAR_V2_ROW_RADIUS = 12;
144
145function ThreadListV2Section(props: {
146 readonly label: string;
147 readonly pane?: "screen" | "sidebar";
148 readonly tone?: "default" | "snoozed";
149 readonly disclosure?: {
150 readonly expanded: boolean;
151 readonly disabled?: boolean;
152 readonly onToggle: () => void;
153 readonly accessibilityLabel: string;
154 readonly accessibilityHint: string;
155 };
156}) {
157 const snoozed = props.tone === "snoozed";
158 const sidebarPane = props.pane === "sidebar";
159 const className = cn(
160 "mb-1.5 mt-4 flex-row items-center gap-2.5",
161 props.pane === "sidebar" ? "px-3" : "px-5",
162 );
163 const content = (
164 <>
165 <Text
166 className={cn(
167 "text-xs font-t3-medium",
168 sidebarPane
169 ? "text-drawer-foreground-muted"
170 : snoozed
171 ? "text-foreground-secondary"
172 : "text-foreground-tertiary",
173 )}
174 >
175 {props.label}
176 </Text>
177 <View
178 className={cn(
179 "h-px flex-1",
180 snoozed ? "bg-primary/20" : sidebarPane ? "bg-drawer-border" : "bg-border",
181 )}
182 />
183 {props.disclosure ? (
184 <SymbolView
185 name="chevron.down"
186 size={10}
187 tintColorClassName={
188 sidebarPane
189 ? "accent-drawer-foreground-muted"
190 : snoozed
191 ? "accent-icon-muted"
192 : "accent-foreground-muted"
193 }
194 type="monochrome"
195 style={{ transform: [{ rotate: props.disclosure.expanded ? "180deg" : "0deg" }] }}
196 />
197 ) : null}
198 </>
199 );
200
201 return props.disclosure ? (
202 <Pressable
203 accessibilityHint={props.disclosure.accessibilityHint}
204 accessibilityLabel={props.disclosure.accessibilityLabel}
205 accessibilityRole="button"
206 accessibilityState={{
207 disabled: props.disclosure.disabled,
208 expanded: props.disclosure.expanded,
209 }}
210 className={className}
211 disabled={props.disclosure.disabled}
212 onPress={props.disclosure.onToggle}
213 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
214 >
215 {content}
216 </Pressable>
217 ) : (
218 <View className={className}>{content}</View>
219 );
220}
221
222/** Section label + rule: the only structure in an otherwise flat list. */
223export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivider(props: {
224 readonly label: string;
225 readonly pane?: "screen" | "sidebar";
226}) {
227 return <ThreadListV2Section {...props} />;
228});
229
230type ThreadListV2ShelfHeaderProps = {
231 readonly count: number;
232 readonly disabled?: boolean;
233 readonly expanded: boolean;
234 readonly onToggle: () => void;
235 readonly pane?: "screen" | "sidebar";
236};
237
238const SHELF_LABEL = { working: "Working", snoozed: "Snoozed", settled: "Settled" } as const;
239
240function ThreadListV2ShelfHeader(
241 props: ThreadListV2ShelfHeaderProps & { readonly kind: keyof typeof SHELF_LABEL },
242) {
243 const label = SHELF_LABEL[props.kind];
244 return (
245 <ThreadListV2Section
246 label={props.expanded ? label : `${label} (${props.count})`}
247 pane={props.pane}
248 tone={props.kind === "snoozed" ? "snoozed" : "default"}
249 disclosure={{
250 expanded: props.expanded,
251 disabled: props.disabled,
252 onToggle: props.onToggle,
253 accessibilityLabel: `${props.count} ${props.kind} ${props.count === 1 ? "thread" : "threads"}`,
254 accessibilityHint: `${props.expanded ? "Collapses" : "Expands"} the ${props.kind} threads.`,
255 }}
256 />
257 );
258}
259
260export const ThreadListV2WorkingShelfHeader = memo(function ThreadListV2WorkingShelfHeader(
261 props: ThreadListV2ShelfHeaderProps,
262) {
263 return <ThreadListV2ShelfHeader {...props} kind="working" />;
264});
265
266export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(
267 props: ThreadListV2ShelfHeaderProps,
268) {
269 return <ThreadListV2ShelfHeader {...props} kind="snoozed" />;
270});
271
272export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(
273 props: ThreadListV2ShelfHeaderProps,
274) {
275 return <ThreadListV2ShelfHeader {...props} kind="settled" />;
276});
277
278export const ThreadListV2ShowMoreRow = memo(function ThreadListV2ShowMoreRow(props: {
279 readonly pane?: "screen" | "sidebar";
280 readonly hiddenCount: number;
281 readonly onPress: () => void;
282}) {
283 return (
284 <Pressable
285 accessibilityRole="button"
286 accessibilityLabel={`Show ${Math.min(props.hiddenCount, THREAD_LIST_V2_SETTLED_PAGE_COUNT)} more settled threads`}
287 onPress={props.onPress}
288 className="mx-4 mt-2 items-center rounded-lg border border-dashed border-border py-2.5"
289 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
290 >
291 <Text
292 className={
293 props.pane === "sidebar"
294 ? "text-xs font-t3-medium text-drawer-foreground-muted"
295 : "text-xs font-t3-medium text-foreground-muted"
296 }
297 >
298 Show more ({props.hiddenCount} settled hidden)
299 </Text>
300 </Pressable>
301 );
302});
303
304const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [
305 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
306];
307
308const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [
309 { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } },
310];
311
312/**
313 * Unsent work, in the same idiom as an active v2 row: it is work the user
314 * wrote, so it reads like the thread it will become. The status slot says
315 * what happens next, not where the item sits: "Sends on reconnect" stays
316 * uncolored because nothing is asked of the user; "Draft" takes the amber the
317 * web sidebar uses for drafts, because this one waits on the user.
318 */
319export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props: {
320 readonly pendingTask: PendingNewTask;
321 readonly project: EnvironmentProject | null;
322 readonly projectTitle?: string;
323 readonly environmentLabel: string | null;
324 /** Drawn beside the label; ignored while the label is null. */
325 readonly environmentMachine?: EnvironmentMachineKind;
326 readonly pane?: "screen" | "sidebar";
327 /** Draws the "Unsent" divider above the first draft or queued row. */
328 readonly showPendingDivider: boolean;
329 /** Keeps row hairlines inside a section; section headers draw their own rule. */
330 readonly showTrailingDivider?: boolean;
331 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
332 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
333}) {
334 const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props;
335 const sidebarPane = props.pane === "sidebar";
336 const isDraft = pendingTask.kind === "draft";
337 const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.projectTitle ?? "";
338 const branch = pendingTask.branch;
339
340 const handleMenuAction = useCallback(
341 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
342 if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask);
343 },
344 [onDeletePendingTask, pendingTask],
345 );
346
347 const rowContent = (
348 <>
349 <View className="flex-row items-center gap-1.5">
350 {props.project ? (
351 <ProjectFavicon
352 environmentId={pendingTask.environmentId}
353 faviconPath={props.project.faviconPath}
354 projectIcon={props.project.projectIcon}
355 size={15}
356 projectTitle={props.project.title}
357 workspaceRoot={props.project.workspaceRoot}
358 />
359 ) : null}
360 <Text
361 className={cn(
362 "flex-1 text-sm font-t3-medium text-foreground-muted",
363 sidebarPane && "text-drawer-foreground-muted",
364 )}
365 numberOfLines={1}
366 >
367 {projectTitle}
368 </Text>
369 {isDraft ? (
370 <View className="flex-row items-center gap-1">
371 <SymbolView
372 name="square.and.pencil"
373 size={10}
374 tintColorClassName="accent-adaptive-amber-700-300"
375 type="monochrome"
376 />
377 <Text className="text-xs text-adaptive-amber-700-300">Draft</Text>
378 </View>
379 ) : (
380 <Text
381 className={cn(
382 "text-xs text-foreground-tertiary",
383 sidebarPane && "text-drawer-foreground-muted",
384 )}
385 >
386 Sends on reconnect
387 </Text>
388 )}
389 </View>
390 {/* One line, unlike the two an active row allows: a queued title is
391 derived from the whole prompt rather than written as a title, so the
392 second line is usually a stray word or emoji rather than meaning. */}
393 <Text
394 className={cn(
395 "mt-1 text-base font-t3-medium text-foreground",
396 sidebarPane && "text-drawer-foreground",
397 )}
398 numberOfLines={1}
399 >
400 {pendingTask.title}
401 </Text>
402 {branch || props.environmentLabel ? (
403 <View className="mt-1 flex-row items-center gap-1">
404 <Text
405 className={cn(
406 "shrink text-xs text-foreground-muted",
407 sidebarPane && "text-drawer-foreground-muted",
408 )}
409 numberOfLines={1}
410 >
411 {branch ? (
412 <Text
413 className={cn(
414 "text-xs text-foreground-muted",
415 sidebarPane && "text-drawer-foreground-muted",
416 )}
417 style={{ fontFamily: MONO_FONT }}
418 >
419 {branch}
420 </Text>
421 ) : null}
422 {branch && props.environmentLabel ? " · " : null}
423 {props.environmentLabel ? (
424 <Text
425 className={cn(
426 "text-xs text-foreground-tertiary",
427 sidebarPane && "text-drawer-foreground-muted",
428 )}
429 >
430 {props.environmentLabel}
431 </Text>
432 ) : null}
433 </Text>
434 {props.environmentLabel && props.environmentMachine ? (
435 <EnvironmentMachineSymbol
436 kind={props.environmentMachine}
437 size={11}
438 tintColorClassName={
439 sidebarPane ? "accent-drawer-foreground-muted" : "accent-foreground-tertiary"
440 }
441 />
442 ) : null}
443 </View>
444 ) : null}
445 </>
446 );
447
448 return (
449 <>
450 {props.showPendingDivider ? (
451 <ThreadListV2SectionDivider label="Unsent" pane={props.pane} />
452 ) : null}
453 <ControlPillMenu
454 actions={isDraft ? DRAFT_TASK_MENU_ACTIONS : PENDING_TASK_MENU_ACTIONS}
455 onPressAction={handleMenuAction}
456 shouldOpenOnLongPress
457 >
458 <RowPressable
459 accessibilityHint={
460 isDraft
461 ? "Opens the draft in the new task composer"
462 : "Sends when the environment reconnects. Opens the task for editing"
463 }
464 accessibilityLabel={pendingTask.title}
465 accessibilityRole="button"
466 key={pendingTask.key}
467 className={sidebarPane ? "bg-drawer" : "bg-screen"}
468 interactionClassName={sidebarPane ? "bg-thread-hover" : "bg-row-hover"}
469 onPress={() => onSelectPendingTask(pendingTask)}
470 style={
471 sidebarPane
472 ? {
473 borderRadius: SIDEBAR_V2_ROW_RADIUS,
474 paddingHorizontal: 12,
475 paddingVertical: 10,
476 }
477 : undefined
478 }
479 >
480 {sidebarPane ? (
481 rowContent
482 ) : (
483 <View>
484 <View className="px-5 py-2.5">{rowContent}</View>
485 {props.showTrailingDivider !== false ? (
486 <View className="ml-5 h-px bg-border-subtle" />
487 ) : null}
488 </View>
489 )}
490 </RowPressable>
491 </ControlPillMenu>
492 </>
493 );
494});
495
496export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
497 readonly thread: EnvironmentThreadShell;
498 readonly variant: "card" | "slim";
499 /** A message for this thread is waiting in the outbox. */
500 readonly hasQueuedMessages?: boolean;
501 /** Snoozed-shelf row: shows its wake time and offers Wake. */
502 readonly snoozed?: boolean;
503 /** Pinned-block row: shows the pin glyph and offers Unpin. */
504 readonly pinned?: boolean;
505 /** Preformatted against the parent minute tick so this memoized row's
506 countdown keeps moving. */
507 readonly snoozeWakeLabelText?: string;
508 /** Preformatted against the parent clock (row order timestamp: settle stamp
509 on settled rows, latest activity otherwise). Blank while a status label
510 or the wake countdown owns that slot. Precomputed per row — not via the
511 list's extraData — so the minute tick re-renders only rows whose
512 displayed text moved. */
513 readonly timeLabel: string;
514 /** Parent minute tick carried on the row's list item, present only when the
515 row's menu offers snooze presets, so those menus refresh while mounted
516 without invalidating every other row. */
517 readonly snoozePresetMinute: string;
518 readonly project: EnvironmentProject | null;
519 readonly projectTitle?: string;
520 /** Keep the environment's provider array stable across unrelated list updates. */
521 readonly providers: ReadonlyArray<ThreadListProvider> | undefined;
522 readonly providerInstance: ThreadRowProviderInstance | null;
523 /** Which machine hosts the thread. Null when only one environment is
524 connected — repeating the same label on every row is noise. Mirrors
525 the web sidebar's remote-environment cloud icon, but as text since
526 phones have no hover tooltips. */
527 readonly environmentLabel: string | null;
528 /** Drawn after the label so the machine reads at a glance; ignored while
529 the label is null. */
530 readonly environmentMachine?: EnvironmentMachineKind;
531 /** Hosting surface. "screen" (default) renders the compact Home idiom:
532 flat edge-to-edge rows on the screen background with inset hairlines.
533 "sidebar" renders the iPad split-view idiom: rounded rows blending
534 into the drawer surface, selection filled with the accent color —
535 matching the v1 sidebar rows. */
536 readonly pane?: "screen" | "sidebar";
537 /** Keeps row hairlines inside a section; section headers draw their own rule. */
538 readonly showTrailingDivider?: boolean;
539 /** Highlights the thread open in the detail pane (iPad split view). The
540 compact Home list never sets it — phones navigate away on select. */
541 readonly selected?: boolean;
542 /** Override for narrow panes (iPad sidebar); defaults to window width. */
543 readonly fullSwipeWidth?: number;
544 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
545 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
546 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
547 readonly onRenameThread: (thread: EnvironmentThreadShell) => void;
548 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;
549 readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
550 readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void;
551 readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void;
552 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
553 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
554 readonly onPinThread: (thread: EnvironmentThreadShell) => void;
555 readonly onUnpinThread: (thread: EnvironmentThreadShell) => void;
556 readonly onSetThreadAutoSettle: (thread: EnvironmentThreadShell, enabled: boolean) => void;
557 /** False on environments whose server predates thread.settle/unsettle:
558 swipe + menu fall back to Archive instead of failing on use. */
559 readonly settlementSupported: boolean;
560 /** False on servers that predate thread.snooze/unsnooze. */
561 readonly snoozeSupported: boolean;
562 /** False on servers that predate thread.pin/unpin. */
563 readonly pinningSupported: boolean;
564 /** False on servers that predate thread.auto-settle.set. */
565 readonly autoSettleOptOutSupported: boolean;
566 /** False on servers that predate thread title regeneration. */
567 readonly titleRegenerationSupported: boolean;
568 /** Server supports reordering this card's section. */
569 readonly reorderSupported?: boolean;
570 readonly onMoveThread?: (
571 thread: EnvironmentThreadShell,
572 direction: ThreadMoveDestination,
573 ) => void;
574 /** Position flags for the card's section so the menu disables the move that
575 would fall off the end of the list. */
576 readonly canMoveUp?: boolean;
577 readonly canMoveDown?: boolean;
578 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
579 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
580 /** List key checked against the Home swipe row activation. */
581 readonly activationKey?: string;
582 readonly searchMatch?: EnvironmentThreadSearchMatch;
583 readonly searchQuery?: string;
584 readonly simultaneousSwipeGesture?: ComponentProps<typeof ThreadSwipeable>["simultaneousWith"];
585}) {
586 const { width: windowWidth } = useWindowDimensions();
587 const {
588 thread,
589 variant,
590 onSelectThread,
591 onDeleteThread,
592 onRenameThread,
593 onRegenerateThreadTitle,
594 onNewThreadOnBranch,
595 onSettleThread,
596 onSnoozeThread,
597 onUnsnoozeThread,
598 onUnsettleThread,
599 onArchiveThread,
600 onPinThread,
601 onUnpinThread,
602 onSetThreadAutoSettle,
603 onMoveThread,
604 } = props;
605 const snoozedRow = props.snoozed === true;
606 const pinnedRow = props.pinned === true;
607 const dormant = useSwipeRowDormant(props.activationKey);
608
609 const { providerDrivers, providerIconUrl } = useMemo(() => {
610 const provider = props.providers?.find(
611 (candidate) =>
612 candidate.instanceId ===
613 (thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId),
614 );
615 return {
616 providerDrivers: resolveThreadListV2ProviderDrivers(thread, props.providers),
617 providerIconUrl: provider?.iconUrl,
618 };
619 }, [thread, props.providers]);
620
621 const providerInstance = props.providerInstance;
622 const pr = useThreadPr(thread);
623
624 const theme = useUniwindTheme();
625 const sidebarPane = props.pane === "sidebar";
626 const selected = props.selected === true;
627 const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected);
628
629 const status = resolveThreadListV2Status(thread);
630 // "Done" marks a completion the user has not opened yet — same emerald
631 // label as the web sidebar, sourced from the server-side visited watermark
632 // so checking a thread on any device clears it everywhere.
633 const isUnread = status === "ready" && threadHasUnseenCompletion(thread);
634 const workingLabel = STATUS_LABEL_BY_STATUS[status];
635 const statusLabel =
636 // A native /goal keeps the agent going across turns until it is met.
637 (status === "working" && workingLabel !== undefined && thread.goal?.status === "active"
638 ? { ...workingLabel, label: "Goal" }
639 : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined);
640 const recede = shouldRecedeThreadRow({ status, selected });
641 // The timestamp is precomputed on the list item (same stamps the settled
642 // tail sorts by) so a minute tick only re-renders rows that draw it.
643 const timeLabel = props.timeLabel;
644
645 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
646 const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]);
647 const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope);
648 const canOrganizeThread = useThreadOrganizationPermission(thread.environmentId);
649 const handleRegenerateTitle = useCallback(
650 () => onRegenerateThreadTitle(thread),
651 [onRegenerateThreadTitle, thread],
652 );
653 const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]);
654 const [customSnoozeOpen, setCustomSnoozeOpen] = useState(false);
655 // A recycled cell reassigns this mounted row to a different thread without
656 // remounting it, and the render closure stops running while list equality
657 // says the item is unchanged — so any row-local UI state must be dismissed
658 // when the identity under it changes. Without this, a custom snooze sheet
659 // opened for one thread survives the thread's removal/reorder and its
660 // submit snoozes whichever thread the cell was reassigned to. (ThreadSwipeable
661 // enforces the same contract on the swipe layer with its resetKey.)
662 const rowIdentity = `${thread.environmentId}:${thread.id}`;
663 const [boundIdentity, setBoundIdentity] = useState(rowIdentity);
664 if (boundIdentity !== rowIdentity) {
665 setBoundIdentity(rowIdentity);
666 setCustomSnoozeOpen(false);
667 }
668 const handleSnooze = useCallback(
669 (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil),
670 [onSnoozeThread, thread],
671 );
672 const handleUnsnooze = useCallback(() => onUnsnoozeThread(thread), [onUnsnoozeThread, thread]);
673 const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);
674 const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]);
675 const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]);
676 const handleSetAutoSettle = useCallback(
677 (enabled: boolean) => onSetThreadAutoSettle(thread, enabled),
678 [onSetThreadAutoSettle, thread],
679 );
680 const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]);
681 const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]);
682 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
683
684 // Swipe: the v2 primary action is the lifecycle transition. Un-settling a
685 // settled row keeps it active until new activity clears the user override.
686 const canUnsettle = variant === "slim";
687 const [snoozeGateTick, bumpSnoozeGateTick] = useState(0);
688 const snoozeGateExpiryMs = props.snoozeSupported
689 ? resolveThreadListV2SnoozeGateExpiryMs(thread, { now: new Date().toISOString() })
690 : null;
691 useEffect(() => {
692 if (snoozeGateExpiryMs === null) return;
693 const delayMs = Math.min(Math.max(0, snoozeGateExpiryMs - Date.now()) + 50, 2_147_483_647);
694 const id = setTimeout(() => bumpSnoozeGateTick((tick) => tick + 1), delayMs);
695 return () => clearTimeout(id);
696 }, [snoozeGateExpiryMs, snoozeGateTick]);
697 const swipeActions = resolveThreadListV2SwipeActions({
698 variant,
699 settlementSupported: props.settlementSupported,
700 snoozeSupported: props.snoozeSupported,
701 snoozable: canSnooze(thread, { now: new Date().toISOString() }),
702 snoozed: snoozedRow,
703 });
704 const snoozePresets = useMemo(
705 () => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)),
706 [props.snoozePresetMinute, swipeActions.secondary],
707 );
708 const snoozePresetActions = useMemo<MenuAction[]>(
709 () => [
710 ...snoozePresets.map((preset) => ({
711 id: `snooze:${preset.id}`,
712 title: preset.label,
713 subtitle: preset.whenLabel,
714 })),
715 { id: "snooze:custom", title: "Custom…" },
716 ],
717 [snoozePresets],
718 );
719 // Pinned cards keep the full lifecycle menu; only the pin item flips to
720 // Unpin. (Settling a pinned thread clears the pin server-side; snoozing
721 // hides the card until wake with the pin intact.)
722 const arrangementMenuItems = useMemo<MenuAction[]>(
723 () => [
724 ...(props.reorderSupported === true
725 ? [
726 { id: "arrange", title: "Arrange threads…", image: "line.3.horizontal" },
727 {
728 id: "move-up",
729 title: "Move up",
730 image: "arrow.up",
731 attributes: { disabled: props.canMoveUp !== true },
732 } satisfies MenuAction,
733 {
734 id: "move-down",
735 title: "Move down",
736 image: "arrow.down",
737 attributes: { disabled: props.canMoveDown !== true },
738 } satisfies MenuAction,
739 ]
740 : []),
741 ...(props.pinningSupported
742 ? [
743 thread.pinnedAt != null
744 ? { id: "unpin", title: "Unpin", image: "pin.slash" }
745 : { id: "pin", title: "Pin", image: "pin" },
746 ]
747 : []),
748 ],
749 [
750 props.canMoveDown,
751 props.canMoveUp,
752 props.reorderSupported,
753 props.pinningSupported,
754 thread.pinnedAt,
755 variant,
756 ],
757 );
758 // A submenu with the current option checked, matching web. This is a
759 // per-thread setting, not a lifecycle verb.
760 const autoSettleMenuItems = useMemo<MenuAction[]>(
761 () =>
762 props.autoSettleOptOutSupported
763 ? [
764 {
765 id: "auto-settle",
766 title: "Auto-settle behavior",
767 image: "timer",
768 subactions: [
769 {
770 id: "auto-settle:enabled",
771 title: "Enabled",
772 state: thread.autoSettleDisabledAt == null ? "on" : "off",
773 },
774 {
775 id: "auto-settle:disabled",
776 title: "Disabled",
777 state: thread.autoSettleDisabledAt == null ? "off" : "on",
778 },
779 ],
780 } satisfies MenuAction,
781 ]
782 : [],
783 [props.autoSettleOptOutSupported, thread.autoSettleDisabledAt],
784 );
785 const titleMenuItems = useMemo<MenuAction[]>(
786 () => [
787 { id: "rename", title: "Rename", image: "square.and.pencil" },
788 ...buildThreadTitleRegenerationMenuItems({
789 supported: props.titleRegenerationSupported,
790 isRegenerating: thread.titleRegeneration != null,
791 }),
792 ],
793 [props.titleRegenerationSupported, thread.titleRegeneration],
794 );
795 const snoozableCardMenuActions = useMemo<MenuAction[]>(
796 () => [
797 { id: "settle", title: "Settle", image: "checkmark" },
798 {
799 id: "snooze",
800 title: "Snooze",
801 image: "clock",
802 subactions: snoozePresetActions,
803 },
804 ...arrangementMenuItems,
805 ...titleMenuItems,
806 ...autoSettleMenuItems,
807 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
808 ],
809 [arrangementMenuItems, autoSettleMenuItems, snoozePresetActions, titleMenuItems],
810 );
811 const cardMenuActions = useMemo<MenuAction[]>(
812 () => [
813 CARD_MENU_ACTIONS[0]!,
814 ...arrangementMenuItems,
815 ...titleMenuItems,
816 ...autoSettleMenuItems,
817 ...CARD_MENU_ACTIONS.slice(1),
818 ],
819 [arrangementMenuItems, autoSettleMenuItems, titleMenuItems],
820 );
821 // Settled and snoozed rows keep the setting too, matching web where every
822 // row shares one menu builder.
823 const slimMenuActions = useMemo<MenuAction[]>(
824 () => [
825 SLIM_MENU_ACTIONS[0]!,
826 ...arrangementMenuItems.filter(
827 (action) => action.id !== "move-up" && action.id !== "move-down",
828 ),
829 ...titleMenuItems,
830 ...autoSettleMenuItems,
831 SLIM_MENU_ACTIONS[1]!,
832 ],
833 [arrangementMenuItems, autoSettleMenuItems, titleMenuItems],
834 );
835 const snoozedMenuActions = useMemo<MenuAction[]>(
836 () => [
837 SNOOZED_MENU_ACTIONS[0]!,
838 ...titleMenuItems,
839 ...autoSettleMenuItems,
840 SNOOZED_MENU_ACTIONS[1]!,
841 ],
842 [autoSettleMenuItems, titleMenuItems],
843 );
844 const legacyMenuActions = useMemo<MenuAction[]>(
845 () => [
846 LEGACY_MENU_ACTIONS[0]!,
847 ...arrangementMenuItems,
848 ...titleMenuItems,
849 LEGACY_MENU_ACTIONS[1]!,
850 ],
851 [arrangementMenuItems, titleMenuItems],
852 );
853 const handleMenuAction = useCallback(
854 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
855 if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread);
856 if (nativeEvent.event === "settle") handleSettle();
857 if (nativeEvent.event === "unsettle") handleUnsettle();
858 if (nativeEvent.event === "unsnooze") handleUnsnooze();
859 if (nativeEvent.event === "pin") handlePin();
860 if (nativeEvent.event === "unpin") handleUnpin();
861 if (nativeEvent.event === "auto-settle:enabled") handleSetAutoSettle(true);
862 if (nativeEvent.event === "auto-settle:disabled") handleSetAutoSettle(false);
863 if (nativeEvent.event === "arrange") appAtomRegistry.set(threadArrangementOpenAtom, true);
864 if (nativeEvent.event === "move-up") handleMoveUp();
865 if (nativeEvent.event === "move-down") handleMoveDown();
866 if (nativeEvent.event === "archive") handleArchive();
867 if (nativeEvent.event === "rename") handleRename();
868 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
869 if (nativeEvent.event === "copy-thread-id") {
870 copyTextWithHaptic(thread.id, { target: "thread-id" });
871 }
872 if (nativeEvent.event === "delete") handleDelete();
873 if (nativeEvent.event === "snooze:custom") {
874 setCustomSnoozeOpen(true);
875 return;
876 }
877 const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({
878 event: nativeEvent.event,
879 displayedPresets: snoozePresets,
880 now: new Date(),
881 });
882 if (snoozeSelection._tag === "selected") {
883 handleSnooze(snoozeSelection.preset.snoozedUntil);
884 } else if (snoozeSelection._tag === "expired") {
885 Alert.alert("Could not snooze thread", "That snooze time has passed. Choose another time.");
886 }
887 },
888 [
889 onNewThreadOnBranch,
890 thread,
891 handleArchive,
892 handleDelete,
893 handleRegenerateTitle,
894 handleRename,
895 handleMoveDown,
896 handleMoveUp,
897 handlePin,
898 handleSettle,
899 handleSnooze,
900 handleSetAutoSettle,
901 handleUnpin,
902 handleUnsettle,
903 handleUnsnooze,
904 setCustomSnoozeOpen,
905 snoozePresets,
906 ],
907 );
908 const primaryAction = useMemo(() => {
909 // Pre-settlement server: archive is the swipe action, as in v1. (Slim
910 // rows cannot occur here — unsupported environments never classify as
911 // settled.)
912 if (swipeActions.primary === "archive") {
913 return {
914 accessibilityLabel: `Archive ${thread.title}`,
915 icon: "archivebox" as const,
916 label: "Archive",
917 onPress: handleArchive,
918 };
919 }
920 if (swipeActions.primary === "unsnooze") {
921 return {
922 accessibilityLabel: `Wake ${thread.title} now`,
923 icon: "clock" as const,
924 label: "Wake",
925 onPress: handleUnsnooze,
926 };
927 }
928 return swipeActions.primary === "unsettle"
929 ? {
930 accessibilityLabel: `Un-settle ${thread.title}`,
931 icon: "arrow.uturn.backward" as const,
932 label: "Un-settle",
933 onPress: handleUnsettle,
934 }
935 : {
936 accessibilityLabel: `Settle ${thread.title}`,
937 icon: "checkmark" as const,
938 label: "Settle",
939 onPress: handleSettle,
940 };
941 }, [
942 handleArchive,
943 handleSettle,
944 handleUnsettle,
945 handleUnsnooze,
946 swipeActions.primary,
947 thread.title,
948 ]);
949 const secondaryAction = useMemo(
950 () =>
951 swipeActions.secondary === "snooze"
952 ? {
953 accessibilityLabel: `Choose when to snooze ${thread.title}`,
954 icon: "clock" as const,
955 label: "Snooze",
956 menu: {
957 actions: snoozePresetActions,
958 onPressAction: handleMenuAction,
959 title: "Snooze until",
960 },
961 onPress: () => undefined,
962 }
963 : null,
964 [handleMenuAction, snoozePresetActions, swipeActions.secondary, thread.title],
965 );
966 const swipeAccessibilityHint = !canOrganizeThread
967 ? "Opens the thread"
968 : secondaryAction === null
969 ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`
970 : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`;
971
972 // Sidebar rows use navigation foregrounds on their active and idle surfaces.
973 const cardContent = (
974 <>
975 <View className="flex-row items-center gap-1.5">
976 {props.project ? (
977 <ProjectFavicon
978 environmentId={thread.environmentId}
979 faviconPath={props.project.faviconPath}
980 projectIcon={props.project.projectIcon}
981 size={15}
982 projectTitle={props.project.title}
983 workspaceRoot={props.project.workspaceRoot}
984 />
985 ) : null}
986 <Text
987 className={cn(
988 "flex-1 text-sm font-t3-medium",
989 selected
990 ? selectedThreadRowColors.mutedForegroundClassName
991 : rowAppearance.mutedForegroundClassName,
992 )}
993 numberOfLines={1}
994 >
995 {props.projectTitle ?? props.project?.title ?? ""}
996 </Text>
997 {props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null}
998 {pinnedRow ? (
999 <SymbolView
1000 name="pin"
1001 size={11}
1002 tintColorClassName={rowAppearance.mutedIconTintClassName}
1003 type="monochrome"
1004 />
1005 ) : null}
1006 {statusLabel ? (
1007 <View className="flex-row items-center gap-1">
1008 <SymbolView
1009 name={statusLabel.icon}
1010 size={13}
1011 tintColorClassName={
1012 selected ? selectedThreadRowColors.iconTintClassName : statusLabel.iconTintClassName
1013 }
1014 type="monochrome"
1015 weight="semibold"
1016 />
1017 <Text
1018 className={cn(
1019 "text-xs font-t3-bold",
1020 selected ? selectedThreadRowColors.foregroundClassName : statusLabel.className,
1021 )}
1022 >
1023 {statusLabel.label}
1024 </Text>
1025 </View>
1026 ) : (
1027 <Text
1028 className={cn(
1029 "text-xs tabular-nums",
1030 selected
1031 ? selectedThreadRowColors.foregroundClassName
1032 : rowAppearance.tertiaryForegroundClassName,
1033 )}
1034 >
1035 {timeLabel}
1036 </Text>
1037 )}
1038 </View>
1039 <Text
1040 className={cn(
1041 "mt-1 text-base",
1042 // Background work recedes to regular weight, matching web.
1043 !recede && "font-t3-medium",
1044 selected
1045 ? selectedThreadRowColors.foregroundClassName
1046 : rowAppearance.foregroundClassName,
1047 )}
1048 numberOfLines={2}
1049 >
1050 {thread.title}
1051 </Text>
1052 {props.searchMatch ? (
1053 <View className="mt-1">
1054 <ThreadSearchMatchExcerpt
1055 sidebar={sidebarPane}
1056 match={props.searchMatch}
1057 query={props.searchQuery ?? ""}
1058 selected={selected}
1059 />
1060 </View>
1061 ) : null}
1062 <View className="mt-1 flex-row items-center gap-2">
1063 {(status === "failed" || status === "limited") && thread.runtime?.lastError ? (
1064 <Text
1065 className={cn(
1066 "flex-1 text-xs",
1067 selected
1068 ? selectedThreadRowColors.mutedForegroundClassName
1069 : status === "limited"
1070 ? "text-warning-foreground"
1071 : "text-danger-foreground",
1072 )}
1073 numberOfLines={1}
1074 >
1075 {thread.runtime.lastError}
1076 </Text>
1077 ) : thread.branch || props.environmentLabel ? (
1078 /* "branch · machine" share one truncating line. The machine sits
1079 last so a tight fit cuts the repetitive label, not the branch —
1080 and machine-only fills the row for non-git projects. The glyph
1081 hugs the label (it cannot live inside the Text without breaking
1082 truncation), and the wrapper takes the slack so the trailers
1083 stay pinned right. */
1084 <View className="min-w-0 flex-1 flex-row items-center gap-1">
1085 <Text
1086 className={cn(
1087 "shrink text-xs",
1088 selected
1089 ? selectedThreadRowColors.mutedForegroundClassName
1090 : rowAppearance.mutedForegroundClassName,
1091 )}
1092 numberOfLines={1}
1093 >
1094 {thread.branch ? (
1095 <Text
1096 className={cn(
1097 "text-xs",
1098 selected
1099 ? selectedThreadRowColors.mutedForegroundClassName
1100 : rowAppearance.mutedForegroundClassName,
1101 )}
1102 style={{ fontFamily: MONO_FONT }}
1103 >
1104 {thread.branch}
1105 </Text>
1106 ) : null}
1107 {thread.branch && props.environmentLabel ? " · " : null}
1108 {props.environmentLabel ? (
1109 <Text
1110 className={cn(
1111 "text-xs",
1112 selected
1113 ? selectedThreadRowColors.mutedForegroundClassName
1114 : rowAppearance.tertiaryForegroundClassName,
1115 )}
1116 >
1117 {props.environmentLabel}
1118 </Text>
1119 ) : null}
1120 </Text>
1121 {props.environmentLabel && props.environmentMachine ? (
1122 <EnvironmentMachineSymbol
1123 kind={props.environmentMachine}
1124 size={11}
1125 tintColorClassName={
1126 selected
1127 ? selectedThreadRowColors.mutedIconTintClassName
1128 : rowAppearance.tertiaryIconTintClassName
1129 }
1130 />
1131 ) : null}
1132 </View>
1133 ) : (
1134 <View className="flex-1" />
1135 )}
1136 {pr ? (
1137 <View className="flex-row items-center gap-1" accessibilityLabel={pr.accessibilityLabel}>
1138 <SymbolView
1139 name={pr.kind === "stack" ? "square.3.layers.3d" : "arrow.triangle.pull"}
1140 size={12}
1141 tintColorClassName={
1142 pr.state === null || pr.isDraft
1143 ? rowAppearance.mutedIconTintClassName
1144 : pr.state === "open"
1145 ? "accent-adaptive-emerald-600-400"
1146 : pr.state === "closed"
1147 ? "accent-adaptive-rose-600-400"
1148 : "accent-adaptive-violet-600-400"
1149 }
1150 />
1151 <Text
1152 accessibilityLabel={pr.accessibilityLabel}
1153 className={cn("text-xs", pr.textClassName)}
1154 style={{ fontFamily: MONO_FONT }}
1155 >
1156 {pr.label}
1157 </Text>
1158 </View>
1159 ) : null}
1160 {providerInstance ? (
1161 // Earlier owners peek out behind the current provider so a
1162 // handed-off thread shows where it has been. The current owner
1163 // keeps its account badge so same-driver instances stay distinct.
1164 <View className="flex-row items-center">
1165 {providerDrivers.slice(0, -1).map((driver, index) => (
1166 <View key={`${driver}:${index}`} className="-mr-1 opacity-30">
1167 <ProviderIcon provider={driver} size={12} />
1168 </View>
1169 ))}
1170 <ProviderInstanceIcon
1171 iconUrl={providerIconUrl}
1172 provider={providerInstance.driverKind}
1173 size={14}
1174 displayName={providerInstance.displayName}
1175 accentColor={providerInstance.accentColor}
1176 showBadge={providerInstance.showBadge}
1177 surfaceColor={rowAppearance.providerIconSurfaceColor}
1178 />
1179 </View>
1180 ) : null}
1181 </View>
1182 </>
1183 );
1184
1185 // Background work fades as a whole, status label included, so it takes
1186 // less attention than rows that need a human (input, approval, done).
1187 const fadedCardContent = recede ? (
1188 <View style={{ opacity: 0.55 }}>{cardContent}</View>
1189 ) : (
1190 cardContent
1191 );
1192
1193 const rowContent = (close: () => void) =>
1194 variant === "card" ? (
1195 <RowPressable
1196 key={`${thread.environmentId}:${thread.id}`}
1197 interactionClassName={rowAppearance.interactionClassName}
1198 interactionOpacity={rowAppearance.interactionOpacity}
1199 className={rowAppearance.className}
1200 accessibilityHint={swipeAccessibilityHint}
1201 accessibilityLabel={
1202 props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
1203 }
1204 accessibilityRole="button"
1205 accessibilityState={{ selected }}
1206 onPress={() => {
1207 close();
1208 onSelectThread(thread);
1209 }}
1210 style={rowAppearance.cardStyle}
1211 >
1212 {sidebarPane ? (
1213 fadedCardContent
1214 ) : (
1215 /* Flat native list rows: no tonal containers — colored status
1216 labels and text hierarchy carry state, an inset hairline
1217 separates rows. The opaque screen background stays so swipe
1218 actions reveal behind the row. */
1219 <View>
1220 <View className={THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME}>{fadedCardContent}</View>
1221 {THREAD_LIST_V2_ROW_DIVIDERS && props.showTrailingDivider !== false ? (
1222 <View className="ml-5 h-px bg-border-subtle" />
1223 ) : null}
1224 </View>
1225 )}
1226 </RowPressable>
1227 ) : (
1228 <RowPressable
1229 key={`${thread.environmentId}:${thread.id}`}
1230 interactionClassName={rowAppearance.interactionClassName}
1231 interactionOpacity={rowAppearance.interactionOpacity}
1232 accessibilityHint={swipeAccessibilityHint}
1233 accessibilityLabel={
1234 props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
1235 }
1236 accessibilityRole="button"
1237 accessibilityState={{ selected }}
1238 className={rowAppearance.className}
1239 onPress={() => {
1240 close();
1241 onSelectThread(thread);
1242 }}
1243 style={rowAppearance.style}
1244 >
1245 {/* Settled history recedes: dimmed favicon + muted title. */}
1246 <View
1247 className={cn(
1248 "min-h-[44px] flex-row items-center gap-2.5 py-2",
1249 sidebarPane ? "px-3" : "px-5",
1250 )}
1251 >
1252 {props.project ? (
1253 <View className="opacity-40">
1254 <ProjectFavicon
1255 environmentId={thread.environmentId}
1256 faviconPath={props.project.faviconPath}
1257 projectIcon={props.project.projectIcon}
1258 size={15}
1259 projectTitle={props.project.title}
1260 workspaceRoot={props.project.workspaceRoot}
1261 />
1262 </View>
1263 ) : null}
1264 <View className="min-w-0 flex-1">
1265 <Text
1266 className={cn(
1267 "text-base",
1268 selected
1269 ? selectedThreadRowColors.foregroundClassName
1270 : rowAppearance.mutedForegroundClassName,
1271 )}
1272 numberOfLines={1}
1273 >
1274 {thread.title}
1275 </Text>
1276 {props.searchMatch ? (
1277 <ThreadSearchMatchExcerpt
1278 sidebar={sidebarPane}
1279 match={props.searchMatch}
1280 query={props.searchQuery ?? ""}
1281 selected={selected}
1282 />
1283 ) : null}
1284 </View>
1285 {props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null}
1286 <Text
1287 className={cn(
1288 "text-sm tabular-nums",
1289 selected
1290 ? selectedThreadRowColors.mutedForegroundClassName
1291 : snoozedRow
1292 ? rowAppearance.mutedForegroundClassName
1293 : rowAppearance.tertiaryForegroundClassName,
1294 )}
1295 style={{ fontFamily: MONO_FONT }}
1296 >
1297 {snoozedRow && props.snoozeWakeLabelText !== undefined
1298 ? props.snoozeWakeLabelText
1299 : timeLabel}
1300 </Text>
1301 </View>
1302 </RowPressable>
1303 );
1304
1305 if (!canOrganizeThread) return rowContent(() => {});
1306
1307 return (
1308 <View collapsable={false}>
1309 {customSnoozeOpen && (
1310 <CustomSnoozeSheet onClose={() => setCustomSnoozeOpen(false)} onSnooze={handleSnooze} />
1311 )}
1312 <ThreadSwipeable
1313 dormant={dormant}
1314 threadKey={`${thread.environmentId}:${thread.id}`}
1315 backgroundColor={rowAppearance.swipeBackgroundColor}
1316 compactActions={variant === "slim"}
1317 containerStyle={rowAppearance.swipeContainerStyle}
1318 enableTrackpadSwipe
1319 // Full swipe commits the advertised lifecycle action (Settle /
1320 // Un-settle), never the secondary snooze action.
1321 fullSwipeAction="primary"
1322 fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32}
1323 onDelete={handleDelete}
1324 onSwipeableClose={props.onSwipeableClose}
1325 onSwipeableWillOpen={props.onSwipeableWillOpen}
1326 primaryAction={primaryAction}
1327 secondaryAction={secondaryAction}
1328 resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}:${thread.settledAt}:${thread.unsettledAt}:${thread.snoozedUntil}`}
1329 simultaneousWith={props.simultaneousSwipeGesture}
1330 threadTitle={thread.title}
1331 >
1332 {(close) => (
1333 <ControlPillMenu
1334 actions={(
1335 [
1336 ...(thread.branch
1337 ? [
1338 {
1339 id: "new-thread-on-branch",
1340 title: getThreadListV2NewBranchMenuTitle(thread.branch),
1341 image: "square.and.pencil",
1342 },
1343 ]
1344 : []),
1345 { id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" },
1346 ...(snoozedRow
1347 ? snoozedMenuActions
1348 : !props.settlementSupported
1349 ? legacyMenuActions
1350 : canUnsettle
1351 ? slimMenuActions
1352 : swipeActions.secondary === "snooze"
1353 ? snoozableCardMenuActions
1354 : cardMenuActions),
1355 ] satisfies MenuAction[]
1356 ).map((action) =>
1357 !canOperateThread &&
1358 ["new-thread-on-branch", "rename", "regenerate-title", "archive", "delete"].includes(
1359 action.id ?? "",
1360 )
1361 ? { ...action, attributes: { ...action.attributes, disabled: true } }
1362 : action,
1363 )}
1364 onPressAction={handleMenuAction}
1365 shouldOpenOnLongPress
1366 >
1367 {rowContent(close)}
1368 </ControlPillMenu>
1369 )}
1370 </ThreadSwipeable>
1371 </View>
1372 );
1373});