1import { AppActivityIndicator } from "../../components/AppActivityIndicator";
2import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
3import type { ThreadMoveDestination } from "../threads/threadOrder";
4import { computeThreadMoveAvailability } from "../threads/threadOrder";
5import { LegendList, type LegendListRef } from "@legendapp/list/react-native";
6import {
7 type EnvironmentProject,
8 type EnvironmentThreadShell,
9} from "@t3tools/client-runtime/state/shell";
10import {
11 threadSearchMatchKey,
12 type EnvironmentThreadSearchMatch,
13} from "@t3tools/client-runtime/state/thread-search";
14import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts";
15import { useAtomValue } from "@effect/atom-react";
16import { useFocusEffect } from "@react-navigation/native";
17import { use, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
18import {
19 Platform,
20 ScrollView,
21 View,
22 type ScrollViewProps,
23 type GestureResponderEvent,
24 type NativeScrollEvent,
25 type NativeSyntheticEvent,
26} from "react-native";
27import { ScrollViewMarker } from "react-native-screens";
28import { GestureDetector, useNativeGesture } from "react-native-gesture-handler";
29import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
30import { useSafeAreaInsets } from "react-native-safe-area-context";
31
32import { cn } from "../../lib/cn";
33import { EmptyState } from "../../components/EmptyState";
34import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton";
35import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel";
36import type { SavedRemoteConnection } from "../../lib/connection";
37import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
38import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
39import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects";
40import {
41 useNativeColumnLayoutMetrics,
42 useNativeLayoutMetrics,
43} from "../../native/native-layout-metrics";
44import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
45import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns";
46import { useThreadSearch } from "../../state/queries";
47import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
48import { usePendingThreadOrder } from "../../state/thread-order";
49import { threadListEnvironmentsAtom } from "../../state/server";
50import type { PendingNewTask } from "../../state/use-pending-new-tasks";
51import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
52import {
53 ThreadListV2PendingRow,
54 ThreadListV2Row,
55 ThreadListV2SettledShelfHeader,
56 ThreadListV2ShowMoreRow,
57 ThreadListV2SnoozedShelfHeader,
58 ThreadListV2WorkingShelfHeader,
59} from "../threads/thread-list-v2-items";
60import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance";
61import {
62 buildThreadListV2Items,
63 getThreadListV2OrderedSection,
64 buildThreadListV2ListItems,
65 threadListV2ListItemsAreEqual,
66 threadListInboxReturns,
67 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
68 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
69 type ThreadListV2ListItem,
70} from "../threads/threadListV2";
71import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
72import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
73import {
74 buildHomeProjectScopes,
75 findHomeProjectScope,
76 type HomeProjectSortOrder,
77} from "./homeThreadList";
78import { createSwipeRowActivation } from "./swipe-row-activation";
79import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";
80import { useMaterialFabScroll } from "./MaterialFabScrollContext";
81import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
82import { ThreadListFilters } from "./ThreadListFilters";
83
84/* ─── Types ──────────────────────────────────────────────────────────── */
85
86interface HomeScreenProps {
87 readonly projects: ReadonlyArray<EnvironmentProject>;
88 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
89 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
90 readonly catalogState: WorkspaceState;
91 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;
92 readonly environments: ReadonlyArray<
93 HomeListFilterMenuEnvironment & Pick<WorkspaceEnvironment, "connectionState">
94 >;
95 readonly searchQuery: string;
96 readonly selectedEnvironmentId: EnvironmentId | null;
97 readonly selectedProjectKey: string | null;
98 readonly projectSortOrder: HomeProjectSortOrder;
99 readonly projectGroupingMode: SidebarProjectGroupingMode;
100 readonly onSearchQueryChange: (query: string) => void;
101 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
102 readonly onProjectChange: (projectKey: string | null) => void;
103 readonly onAddConnection: () => void;
104 readonly onOpenSettings: () => void;
105 readonly onStartNewTask: () => void;
106 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
107 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
108 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
109 /** Resolves true iff the settle was dispatched and succeeded. */
110 readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
111 readonly onSnoozeThread: (
112 thread: EnvironmentThreadShell,
113 snoozedUntil: string,
114 ) => Promise<boolean>;
115 readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
116 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
117 readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
118 readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
119 readonly onSetThreadAutoSettle: (
120 thread: EnvironmentThreadShell,
121 enabled: boolean,
122 ) => Promise<boolean>;
123 readonly onMoveThread: (
124 thread: EnvironmentThreadShell,
125 direction: ThreadMoveDestination,
126 ) => Promise<boolean>;
127 readonly onRenameThread: (thread: EnvironmentThreadShell) => void;
128 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
129 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
130 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
131 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
132 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
133}
134
135/* ─── Layout constants ───────────────────────────────────────────────── */
136
137// v2 rows are mixed-height: settled slim rows run ~60dp, single-line cards
138// measured ~74dp on device (252px on the Pixel 10 Pro screenshot), two-line
139// cards ~94dp. The estimate seeds the recycler's initial container count,
140// `ceil((scrollLength + 2 * INITIAL_DRAW_DISTANCE) / estimate)` with the
141// initial draw distance capped at 50, so an estimate at or below the average
142// row height starts the pool at or above the item count for the short lists
143// that LegendList otherwise keeps pooling to exactly its item count — that is
144// what stopped the dev-mode "no unused container available" warning on the
145// seeded short-list device passes. It is a mitigation, not an elimination:
146// after first layout the full drawDistance applies, and a sudden expansion
147// past the pooled headroom (~25+ items appearing at once) still creates a
148// container on demand with the dev-only warning one pass ahead of the
149// measured-height pool expansion. The old tallest-card estimate (~92) fired
150// that warning on every ordinary shelf expand, so the average wins.
151const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72;
152// Rows away from the viewport are cheap dormant frames (see
153// swipe-row-activation), so render further ahead: a fast fling then reaches
154// rows that are already built instead of rows still being rebuilt.
155const THREAD_LIST_V2_DRAW_DISTANCE = 1_000;
156/**
157 * Top spacing between the list and the Android custom header. The Android
158 * header is rendered in-flow above this screen and
159 * already consumes the top safe-area inset, so the list only needs breathing
160 * room here.
161 */
162
163function deriveEmptyState(props: {
164 readonly catalogState: WorkspaceState;
165 readonly projectCount: number;
166}): { readonly title: string; readonly detail: string; readonly loading: boolean } {
167 const { catalogState } = props;
168 if (catalogState.isLoadingConnections) {
169 return {
170 title: "Loading environments",
171 detail: "Checking saved environments on this device.",
172 loading: true,
173 };
174 }
175
176 if (!catalogState.hasConnections) {
177 return {
178 title: "No environments connected",
179 detail: "Add an environment to load projects and start coding sessions.",
180 loading: false,
181 };
182 }
183
184 if (
185 (catalogState.connectionState === "available" ||
186 catalogState.connectionState === "offline" ||
187 catalogState.connectionState === "error" ||
188 catalogState.connectionState === "unsupported") &&
189 !catalogState.hasLoadedShellSnapshot
190 ) {
191 return {
192 title:
193 catalogState.connectionState === "unsupported"
194 ? "Client not supported"
195 : "Environment unavailable",
196 detail:
197 catalogState.connectionError ??
198 "The saved environment is offline. Check the URL or start the environment, then retry.",
199 loading: false,
200 };
201 }
202
203 if (
204 catalogState.hasConnectingEnvironment &&
205 !catalogState.hasLoadedShellSnapshot &&
206 catalogState.connectionError === null
207 ) {
208 return {
209 title: "Connecting to environment",
210 detail: "Loading projects and threads from the saved environment.",
211 loading: true,
212 };
213 }
214
215 if (props.projectCount === 0 && catalogState.hasLoadedShellSnapshot) {
216 return {
217 title: "No projects found",
218 detail: "The connected environment did not report any projects.",
219 loading: false,
220 };
221 }
222
223 return {
224 title: "No threads yet",
225 detail: "Create a task to start a new coding runtime in one of your connected projects.",
226 loading: false,
227 };
228}
229
230function HomeTopContentSpacer() {
231 return <View className="h-4" />;
232}
233
234function HomeScrollView(props: ComponentProps<typeof ScrollView>) {
235 const insets = useSafeAreaInsets();
236 const primaryColumn = use(NativePrimaryColumnContext);
237 if (Platform.OS !== "ios") return <ScrollView {...props} />;
238
239 // v5 needs the actual content scroll view registered with its screen controller.
240 // The layout observer and header siblings prevent UIKit from finding it implicitly.
241 return (
242 <ScrollViewMarker
243 style={{ flex: 1 }}
244 scrollEdgeEffects={{
245 ...nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version),
246 // Retain the existing sidebar fade on iOS 26; iOS 27 uses the native bar material.
247 top: Number.parseInt(String(Platform.Version), 10) >= 27 ? "automatic" : "soft",
248 }}
249 >
250 <ScrollView
251 {...props}
252 // UIKit's scroll-edge material needs the scroll view's own surface color.
253 className={cn(props.className, primaryColumn ? "bg-drawer" : "bg-screen")}
254 // Use the column's measured inset so UIKit recognizes the resting top.
255 // Padding visually clears the bar but leaves the native scroll offset
256 // past its edge, activating scroll-edge protection before any scroll.
257 automaticallyAdjustContentInsets={false}
258 contentInsetAdjustmentBehavior="never"
259 contentOffset={props.contentOffset ?? { x: 0, y: -insets.top }}
260 />
261 </ScrollViewMarker>
262 );
263}
264
265function renderHomeScrollView(props: ScrollViewProps) {
266 // FlatList clones this element with its cells and ref. Keep the marker inside
267 // the component so those cells remain children of the actual ScrollView.
268 return <HomeScrollView {...props} />;
269}
270
271/* ─── Main screen ────────────────────────────────────────────────────── */
272
273export function HomeScreen(props: HomeScreenProps) {
274 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
275 const workspace = useWorkspaceTabs();
276 const [initialScrollOffset] = useState(() => workspace?.getScrollOffset("threads") ?? 0);
277 const primaryColumn = use(NativePrimaryColumnContext);
278 const contentBackground = primaryColumn ? "bg-drawer" : "bg-screen";
279 const containerClassName = cn(
280 "flex-1",
281 Platform.OS === "android" ? "bg-header" : contentBackground,
282 );
283 const columnMetrics = useNativeColumnLayoutMetrics();
284 const selectedThreadKey = primaryColumn?.selectedThreadKey ?? null;
285 const fullSwipeWidth = primaryColumn && columnMetrics ? columnMetrics.width - 20 : undefined;
286 const queuedThreadKeys = useQueuedThreadKeys();
287 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
288 // Let trackpad scrolling coexist with each row's horizontal swipe gesture.
289 const homeScrollGesture = useNativeGesture();
290 const insets = useSafeAreaInsets();
291 const { fabClearance } = useAndroidControlSizing();
292 const screenMetrics = useNativeLayoutMetrics();
293 const contentSideInsets = usesNativeWorkspaceColumns
294 ? (columnMetrics ?? screenMetrics)?.safeArea
295 : undefined;
296 // UIKit's column safe area already includes its bottom toolbar.
297 const iosBottomClearance = Math.max(
298 columnMetrics?.safeArea.bottom ?? insets.bottom,
299 24,
300 workspace?.bottomClearance ?? 0,
301 );
302 const searchEnvironmentIds = useMemo(
303 () =>
304 props.selectedEnvironmentId === null
305 ? props.environments
306 .filter((environment) => environment.connectionState === "connected")
307 .map((environment) => environment.environmentId)
308 : props.environments.some(
309 (environment) =>
310 environment.environmentId === props.selectedEnvironmentId &&
311 environment.connectionState === "connected",
312 )
313 ? [props.selectedEnvironmentId]
314 : [],
315 [props.environments, props.selectedEnvironmentId],
316 );
317 const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery);
318 const threadSearchMatchByKey = useMemo(() => {
319 const matches = new Map<string, EnvironmentThreadSearchMatch>();
320 for (const match of threadSearch.matches) {
321 if (match.source === "user" || match.source === "assistant") {
322 matches.set(threadSearchMatchKey(match), match);
323 }
324 }
325 return matches;
326 }, [threadSearch.matches]);
327 const matchedThreadKeys = useMemo(
328 () => new Set(threadSearch.matches.map(threadSearchMatchKey)),
329 [threadSearch.matches],
330 );
331 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
332 if (openSwipeableRef.current !== methods) {
333 openSwipeableRef.current?.close();
334 openSwipeableRef.current = methods;
335 }
336 }, []);
337
338 const handleSwipeableClose = useCallback((methods: SwipeableMethods) => {
339 if (openSwipeableRef.current === methods) {
340 openSwipeableRef.current = null;
341 }
342 }, []);
343
344 const handleScrollBeginDrag = useCallback(() => {
345 openSwipeableRef.current?.close();
346 }, []);
347 const onMaterialFabScroll = useMaterialFabScroll();
348 const listRef = useRef<LegendListRef>(null);
349 const searchMode = workspace?.state.tabs.threads.searchMode;
350 const restoredOffset = workspace?.state.tabs.threads.scrollOffset;
351 useEffect(() => {
352 if (searchMode !== "resting" || restoredOffset === undefined) return;
353 listRef.current?.scrollToOffset({
354 offset: restoredOffset - (Platform.OS === "ios" ? insets.top : 0),
355 animated: false,
356 });
357 }, [searchMode, insets.top, restoredOffset]);
358 const swipeRowActivation = useMemo(() => createSwipeRowActivation(), []);
359 const activateVisibleRows = useCallback(
360 (rows: ReadonlyArray<ThreadListV2ListItem>) => {
361 const state = listRef.current?.getState();
362 if (state === undefined || !(state.end >= 0)) return;
363 swipeRowActivation.activate(
364 rows.slice(Math.max(0, state.start - 2), state.end + 3).map((row) => row.key),
365 );
366 },
367 [swipeRowActivation],
368 );
369 // Status-bar, accessibility and programmatic scrolls never arm the scroll
370 // gate, so every scroll also activates the visible rows once it settles.
371 const activationTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
372 useEffect(() => () => clearTimeout(activationTimerRef.current), []);
373 const handleListScroll = useCallback(
374 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
375 onMaterialFabScroll?.(event);
376 if (workspace)
377 workspace.recordScrollOffset(
378 "threads",
379 Math.max(0, event.nativeEvent.contentOffset.y + (Platform.OS === "ios" ? insets.top : 0)),
380 );
381 clearTimeout(activationTimerRef.current);
382 activationTimerRef.current = setTimeout(
383 () => activateVisibleRows(listRef.current?.getState().data ?? []),
384 200,
385 );
386 },
387 [activateVisibleRows, onMaterialFabScroll, workspace, insets.top],
388 );
389 const trackListTouches = useCallback(
390 (event: GestureResponderEvent, started: boolean) => {
391 const { changedTouches, touches } = event.nativeEvent;
392 swipeRowActivation.trackTouches(
393 started ? changedTouches.map((touch) => String(touch.identifier)) : [],
394 touches.map((touch) => String(touch.identifier)),
395 );
396 },
397 [swipeRowActivation],
398 );
399 const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({
400 onScroll: handleListScroll,
401 onScrollBeginDrag: handleScrollBeginDrag,
402 });
403
404 const projectScopes = useMemo(
405 () =>
406 buildHomeProjectScopes({
407 projects: props.projects,
408 environmentId: props.selectedEnvironmentId,
409 projectGroupingMode: props.projectGroupingMode,
410 }),
411 [props.projectGroupingMode, props.projects, props.selectedEnvironmentId],
412 );
413 const hasSearchQuery = props.searchQuery.trim().length > 0;
414 const projectByKey = useMemo(() => {
415 const map = new Map<string, EnvironmentProject>();
416 for (const project of props.projects) {
417 map.set(scopedProjectKey(project.environmentId, project.id), project);
418 }
419 return map;
420 }, [props.projects]);
421
422 const v2ProjectScopeKey = props.selectedProjectKey;
423 const v2ScopedProjectGroup = useMemo(
424 () => findHomeProjectScope(projectScopes, v2ProjectScopeKey),
425 [v2ProjectScopeKey, projectScopes],
426 );
427 const v2ProjectTitleByProjectKey = useMemo(
428 () =>
429 new Map(
430 projectScopes.flatMap((scope) =>
431 scope.projectRefs.map(
432 (projectRef) =>
433 [
434 scopedProjectKey(projectRef.environmentId, projectRef.projectId),
435 scope.title,
436 ] as const,
437 ),
438 ),
439 ),
440 [projectScopes],
441 );
442 const v2ScopedProjectKeys = useMemo(
443 () =>
444 v2ScopedProjectGroup === null
445 ? null
446 : new Set(
447 v2ScopedProjectGroup.projectRefs.map((projectRef) =>
448 scopedProjectKey(projectRef.environmentId, projectRef.projectId),
449 ),
450 ),
451 [v2ScopedProjectGroup],
452 );
453 // Thread List v2 (beta): one flat list in creation order, no grouping.
454 // Settled threads collapse into a recency tail below the card block.
455 // Settled threads stay in the live shell stream (settled ≠ archived), so
456 // the partition works directly off live shells — no snapshot merging or
457 // optimistic holds.
458 const handleSettleThread = props.onSettleThread;
459 const handleSnoozeThread = useCallback(
460 (thread: EnvironmentThreadShell, snoozedUntil: string) => {
461 void props.onSnoozeThread(thread, snoozedUntil);
462 },
463 [props.onSnoozeThread],
464 );
465 const handleUnsnoozeThread = useCallback(
466 (thread: EnvironmentThreadShell) => {
467 void props.onUnsnoozeThread(thread);
468 },
469 [props.onUnsnoozeThread],
470 );
471 const handlePinThread = useCallback(
472 (thread: EnvironmentThreadShell) => {
473 void props.onPinThread(thread);
474 },
475 [props.onPinThread],
476 );
477 const handleMoveThread = useCallback(
478 (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => {
479 void props.onMoveThread(thread, direction);
480 },
481 [props.onMoveThread],
482 );
483 const handleUnpinThread = useCallback(
484 (thread: EnvironmentThreadShell) => {
485 void props.onUnpinThread(thread);
486 },
487 [props.onUnpinThread],
488 );
489 const handleSetThreadAutoSettle = useCallback(
490 (thread: EnvironmentThreadShell, enabled: boolean) => {
491 void props.onSetThreadAutoSettle(thread, enabled);
492 },
493 [props.onSetThreadAutoSettle],
494 );
495 const handleRegenerateThreadTitle = useCallback(
496 (thread: EnvironmentThreadShell) => {
497 void props.onRegenerateThreadTitle(thread);
498 },
499 [props.onRegenerateThreadTitle],
500 );
501 const handleRenameThread = useCallback(
502 (thread: EnvironmentThreadShell) => props.onRenameThread(thread),
503 [props.onRenameThread],
504 );
505 const handleDeleteThread = props.onDeleteThread;
506 const handleUnsettleThread = props.onUnsettleThread;
507 // The settled tail renders in pages; expansion resets when the filter
508 // context changes so environment/search flips never inherit a deep page.
509 const [settledVisibleCount, setSettledVisibleCount] = useState(
510 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
511 );
512 const settledResetKey = `${props.selectedEnvironmentId ?? "all"}:${v2ProjectScopeKey ?? "all"}:${props.searchQuery.trim()}`;
513 const lastSettledResetKeyRef = useRef(settledResetKey);
514 if (lastSettledResetKeyRef.current !== settledResetKey) {
515 lastSettledResetKeyRef.current = settledResetKey;
516 setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT);
517 }
518 const showMoreSettled = useCallback(
519 () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT),
520 [],
521 );
522 const {
523 loaded: shelfPreferencesLoaded,
524 settledShelfExpanded,
525 snoozedShelfExpanded,
526 workingShelfEnabled,
527 workingShelfExpanded,
528 toggleSettledShelf,
529 toggleSnoozedShelf,
530 toggleWorkingShelf,
531 } = useThreadListV2ShelfPreferences();
532 // The queued-start and snooze helpers need a clock while the list stays open.
533 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
534 // Snooze wake times are second-precise; a counter bumped exactly at the
535 // next wake boundary re-runs the partition with a fresh clock so a woken
536 // thread reappears immediately instead of on the next minute tick.
537 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
538 useFocusEffect(
539 useCallback(() => {
540 // Refresh immediately on enable or focus because the previous value can be hours old.
541 setNowMinute(new Date().toISOString().slice(0, 16));
542 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
543 return () => clearInterval(id);
544 }, []),
545 );
546 // Threads on servers without the settlement capability never classify as
547 // settled (the user could neither un-settle nor pin them).
548 const listEnvironments = useAtomValue(threadListEnvironmentsAtom);
549 const {
550 providersByEnvironmentId,
551 machineByEnvironmentId,
552 settlementEnvironmentIds,
553 snoozeEnvironmentIds,
554 pinningEnvironmentIds,
555 autoSettleOptOutEnvironmentIds,
556 pinReorderEnvironmentIds,
557 activeReorderEnvironmentIds,
558 titleRegenerationEnvironmentIds,
559 } = listEnvironments;
560 const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId);
561 const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
562 // Up/down menu availability for every card, computed once per section per
563 // rebuild (see computeThreadMoveAvailability): per-thread planner calls made
564 // list construction quadratic, and this list rebuilds on every minute tick.
565 const threadMoveAvailability = useMemo(() => {
566 const sectionAvailability = (section: "pinned" | "active") =>
567 computeThreadMoveAvailability({
568 allThreads: props.threads,
569 section,
570 pendingOrder,
571 reorderableEnvironmentIds:
572 section === "pinned" ? pinReorderEnvironmentIds : activeReorderEnvironmentIds,
573 ordered: getThreadListV2OrderedSection({
574 threads: props.threads,
575 section,
576 pendingOrder,
577 now: new Date().toISOString(),
578 settlementEnvironmentIds,
579 snoozeEnvironmentIds,
580 queuedThreadKeys,
581 }),
582 });
583 // The Working beta orders the inbox by time, so only pins can move.
584 return new Map([
585 ...sectionAvailability("pinned"),
586 ...(workingShelfEnabled ? [] : sectionAvailability("active")),
587 ]);
588 }, [
589 workingShelfEnabled,
590 pinReorderEnvironmentIds,
591 activeReorderEnvironmentIds,
592 props.threads,
593 pendingOrder,
594 queuedThreadKeys,
595 settlementEnvironmentIds,
596 snoozeEnvironmentIds,
597 nowMinute,
598 snoozeWakeTick,
599 ]);
600 const threadListV2Layout = useMemo(() => {
601 threadListInboxReturns.observe(workingShelfEnabled ? props.threads : null);
602 // Settled threads are live shells; archived threads keep their original
603 // "hidden from lists" meaning.
604 return buildThreadListV2Items({
605 pendingOrder,
606 threads: props.threads.filter((thread) => thread.archivedAt === null),
607 environmentId: props.selectedEnvironmentId,
608 projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
609 searchQuery: props.searchQuery,
610 matchedThreadKeys,
611 settlementEnvironmentIds,
612 snoozeEnvironmentIds,
613 queuedThreadKeys,
614 settledLimit: settledVisibleCount,
615 now: new Date().toISOString(),
616 workingShelfEnabled,
617 workingShelfExpanded,
618 inboxReturnAt: threadListInboxReturns.returnedAt,
619 snoozedShelfExpanded,
620 settledShelfExpanded,
621 selectedThreadKey,
622 });
623 }, [
624 workingShelfEnabled,
625 workingShelfExpanded,
626 pendingOrder,
627 selectedThreadKey,
628 queuedThreadKeys,
629 nowMinute,
630 snoozeWakeTick,
631 snoozedShelfExpanded,
632 settledShelfExpanded,
633 settledVisibleCount,
634 settlementEnvironmentIds,
635 snoozeEnvironmentIds,
636 props.searchQuery,
637 props.selectedEnvironmentId,
638 props.threads,
639 matchedThreadKeys,
640 v2ScopedProjectGroup,
641 ]);
642 // Re-partition the moment the earliest snooze expires (clamped to the
643 // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp).
644 const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt;
645 useEffect(() => {
646 if (nextSnoozeWakeAt === null) return;
647 const wakeAtMs = Date.parse(nextSnoozeWakeAt);
648 if (Number.isNaN(wakeAtMs)) return;
649 const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647);
650 const id = setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs);
651 return () => clearTimeout(id);
652 // snoozeWakeTick must re-arm the timer even when nextSnoozeWakeAt is
653 // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout
654 // range) the boundary string is identical and the chain would die.
655 }, [nextSnoozeWakeAt, snoozeWakeTick]);
656 // Queued tasks are not thread shells, so the v2 partition never sees them;
657 // they are spliced in below the active block and stay visible and deletable
658 // while their environment is offline. Same environment scope and search
659 // filter as the list itself.
660 const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase();
661 const v2PendingTasks = useMemo(
662 () =>
663 props.pendingTasks.filter(
664 (pendingTask) =>
665 (props.selectedEnvironmentId === null ||
666 pendingTask.environmentId === props.selectedEnvironmentId) &&
667 (v2ScopedProjectKeys === null ||
668 v2ScopedProjectKeys.has(
669 scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
670 )) &&
671 (v2SearchQuery.length === 0 ||
672 pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
673 ),
674 [props.pendingTasks, props.selectedEnvironmentId, v2ScopedProjectKeys, v2SearchQuery],
675 );
676 const threadListV2Items = useMemo(
677 () =>
678 buildThreadListV2ListItems({
679 items: threadListV2Layout.items,
680 pendingTasks: v2PendingTasks,
681 workingCount: threadListV2Layout.workingCount,
682 workingShelfExpanded,
683 workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex,
684 snoozedCount: threadListV2Layout.snoozedCount,
685 snoozedShelfExpanded,
686 snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex,
687 settledCount: threadListV2Layout.settledCount,
688 settledShelfExpanded,
689 settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex,
690 snoozeLabelNow: `${nowMinute}:00.000Z`,
691 snoozeEnvironmentIds,
692 queuedThreadKeys,
693 moveAvailability: threadMoveAvailability,
694 shelfPreferencesLoading: !shelfPreferencesLoaded,
695 }),
696 [
697 nowMinute,
698 queuedThreadKeys,
699 threadMoveAvailability,
700 settledShelfExpanded,
701 shelfPreferencesLoaded,
702 snoozedShelfExpanded,
703 snoozeEnvironmentIds,
704 threadListV2Layout,
705 v2PendingTasks,
706 workingShelfExpanded,
707 ],
708 );
709
710 useThreadJumpShortcuts(threadListV2Items, props.onSelectThread);
711 useEffect(() => {
712 if (swipeEnabled) activateVisibleRows(threadListV2Items);
713 }, [activateVisibleRows, swipeEnabled, threadListV2Items]);
714
715 const renderV2Item = useCallback(
716 ({ item }: { readonly item: ThreadListV2ListItem }) => {
717 if (item.type === "v2-pending") {
718 const pendingScopeKey = scopedProjectKey(
719 item.pendingTask.environmentId,
720 item.pendingTask.projectId,
721 );
722 return (
723 <ThreadListV2PendingRow
724 pendingTask={item.pendingTask}
725 project={projectByKey.get(pendingScopeKey) ?? null}
726 projectTitle={v2ProjectTitleByProjectKey.get(pendingScopeKey)}
727 environmentLabel={
728 Object.keys(props.savedConnectionsById).length > 1
729 ? (props.savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ??
730 null)
731 : null
732 }
733 environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
734 showPendingDivider={item.showPendingDivider}
735 showTrailingDivider={item.showTrailingDivider}
736 onSelectPendingTask={props.onSelectPendingTask}
737 onDeletePendingTask={props.onDeletePendingTask}
738 />
739 );
740 }
741 if (item.type === "v2-working-shelf") {
742 return (
743 <ThreadListV2WorkingShelfHeader
744 count={item.count}
745 disabled={item.disabled}
746 expanded={item.expanded}
747 onToggle={toggleWorkingShelf}
748 />
749 );
750 }
751 if (item.type === "v2-snoozed-shelf") {
752 return (
753 <ThreadListV2SnoozedShelfHeader
754 count={item.count}
755 disabled={item.disabled}
756 expanded={item.expanded}
757 onToggle={toggleSnoozedShelf}
758 />
759 );
760 }
761 if (item.type === "v2-settled-shelf") {
762 return (
763 <ThreadListV2SettledShelfHeader
764 count={item.count}
765 disabled={item.disabled}
766 expanded={item.expanded}
767 onToggle={toggleSettledShelf}
768 />
769 );
770 }
771 const thread = item.item.thread;
772 return (
773 <ThreadListV2Row
774 pane={primaryColumn ? "sidebar" : "screen"}
775 selected={scopedThreadKey(thread.environmentId, thread.id) === selectedThreadKey}
776 fullSwipeWidth={fullSwipeWidth}
777 onNewThreadOnBranch={props.onNewThreadOnBranch}
778 thread={thread}
779 variant={item.item.variant}
780 hasQueuedMessages={item.hasQueuedMessages}
781 snoozed={item.item.snoozed}
782 pinned={item.item.pinned}
783 snoozePresetMinute={item.snoozePresetMinute ?? ""}
784 snoozeWakeLabelText={item.snoozeWakeLabelText}
785 timeLabel={item.timeLabel}
786 showTrailingDivider={item.showTrailingDivider}
787 project={
788 projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
789 }
790 projectTitle={v2ProjectTitleByProjectKey.get(
791 scopedProjectKey(thread.environmentId, thread.projectId),
792 )}
793 providerInstance={resolveProviderInstance(thread)}
794 providers={providersByEnvironmentId.get(thread.environmentId)}
795 environmentLabel={
796 Object.keys(props.savedConnectionsById).length > 1
797 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
798 : null
799 }
800 environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
801 searchMatch={threadSearchMatchByKey.get(
802 threadSearchMatchKey({
803 environmentId: thread.environmentId,
804 threadId: thread.id,
805 }),
806 )}
807 searchQuery={props.searchQuery}
808 onSelectThread={props.onSelectThread}
809 onDeleteThread={handleDeleteThread}
810 onArchiveThread={props.onArchiveThread}
811 onRenameThread={handleRenameThread}
812 onRegenerateThreadTitle={handleRegenerateThreadTitle}
813 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
814 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
815 onSettleThread={handleSettleThread}
816 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
817 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
818 autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)}
819 reorderSupported={
820 item.item.pinned
821 ? pinReorderEnvironmentIds.has(thread.environmentId)
822 : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId)
823 }
824 canMoveUp={item.canMoveUp}
825 canMoveDown={item.canMoveDown}
826 onSnoozeThread={handleSnoozeThread}
827 onUnsnoozeThread={handleUnsnoozeThread}
828 onUnsettleThread={handleUnsettleThread}
829 onPinThread={handlePinThread}
830 onUnpinThread={handleUnpinThread}
831 onSetThreadAutoSettle={handleSetThreadAutoSettle}
832 onMoveThread={handleMoveThread}
833 onSwipeableClose={handleSwipeableClose}
834 onSwipeableWillOpen={handleSwipeableWillOpen}
835 simultaneousSwipeGesture={homeScrollGesture}
836 activationKey={item.key}
837 />
838 );
839 },
840 [
841 handleDeleteThread,
842 activeReorderEnvironmentIds,
843 handleMoveThread,
844 handlePinThread,
845 handleRegenerateThreadTitle,
846 handleRenameThread,
847 handleSettleThread,
848 handleSnoozeThread,
849 handleUnpinThread,
850 handleUnsnoozeThread,
851 handleSwipeableClose,
852 handleSwipeableWillOpen,
853 handleUnsettleThread,
854 handleSetThreadAutoSettle,
855 homeScrollGesture,
856 autoSettleOptOutEnvironmentIds,
857 pinningEnvironmentIds,
858 autoSettleOptOutEnvironmentIds,
859 machineByEnvironmentId,
860 pinReorderEnvironmentIds,
861 projectByKey,
862 props.onArchiveThread,
863 props.onDeletePendingTask,
864 props.onSelectPendingTask,
865 props.onSelectThread,
866 primaryColumn,
867 selectedThreadKey,
868 fullSwipeWidth,
869 props.onNewThreadOnBranch,
870 props.savedConnectionsById,
871 resolveProviderInstance,
872 providersByEnvironmentId,
873 settlementEnvironmentIds,
874 snoozeEnvironmentIds,
875 threadSearchMatchByKey,
876 titleRegenerationEnvironmentIds,
877 toggleSettledShelf,
878 toggleSnoozedShelf,
879 toggleWorkingShelf,
880 v2ProjectTitleByProjectKey,
881 props.searchQuery,
882 workingShelfEnabled,
883 ],
884 );
885 const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []);
886
887 // FlatList/LegendList treat a changed extraData identity as "re-render every
888 // visible row", so an inline object literal would invalidate all rows on
889 // every HomeScreen render — and the minute clock must stay out of it for
890 // the same reason: the clock text is precomputed per item instead.
891 const v2ExtraData = useMemo(
892 () => ({
893 projectByKey,
894 projectTitleByProjectKey: v2ProjectTitleByProjectKey,
895 listEnvironments,
896 savedConnectionsById: props.savedConnectionsById,
897 searchQuery: props.searchQuery,
898 selectedThreadKey,
899 threadSearchMatchByKey,
900 // Rows read it for their reorder menu items.
901 workingShelfEnabled,
902 }),
903 [
904 projectByKey,
905 props.searchQuery,
906 selectedThreadKey,
907 props.savedConnectionsById,
908 listEnvironments,
909 threadSearchMatchByKey,
910 v2ProjectTitleByProjectKey,
911 workingShelfEnabled,
912 ],
913 );
914
915 /* Empty states */
916 // The signal must ignore the search/environment filters: an active query
917 // that matches nothing needs the in-list "No results" state, not the
918 // full-page "No threads yet". Settled threads are unarchived live shells,
919 // so the archived-at check already covers the settled shelf.
920 const hasAnyThreads =
921 props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0;
922 const selectedEnvironmentLabel =
923 props.selectedEnvironmentId === null
924 ? null
925 : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ??
926 "this environment");
927 // Connection state surfaces in the header title slot
928 // (WorkspaceConnectionTitle) — nothing renders inside the list, so
929 // reconnects never shift the rows.
930 const emptyState = deriveEmptyState({
931 catalogState: props.catalogState,
932 projectCount: props.projects.length,
933 });
934
935 if (!hasAnyThreads) {
936 return (
937 <View className={containerClassName}>
938 <View
939 className={cn(
940 "flex-1 items-center justify-center px-8",
941 contentBackground,
942 Platform.OS === "android" && "overflow-hidden rounded-t-[28px]",
943 )}
944 style={{
945 paddingBottom: Platform.OS === "ios" ? iosBottomClearance : Math.max(insets.bottom, 24),
946 paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0,
947 paddingLeft: 32 + (contentSideInsets?.left ?? 0),
948 paddingRight: 32 + (contentSideInsets?.right ?? 0),
949 }}
950 >
951 <View className="w-full max-w-[430px]">
952 <EmptyState
953 title={emptyState.title}
954 detail={emptyState.detail}
955 actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined}
956 onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}
957 action={
958 Platform.OS === "android" && !props.catalogState.hasReadyEnvironment ? (
959 <MaterialFloatingActionButton
960 label="Add environment"
961 icon="plus"
962 variant="extended"
963 tone="primary"
964 onPress={props.onAddConnection}
965 />
966 ) : undefined
967 }
968 variant="plain"
969 />
970 {emptyState.loading ? (
971 <View className="mt-4 items-center">
972 <AppActivityIndicator colorClassName="accent-icon-muted" />
973 </View>
974 ) : null}
975 </View>
976 </View>
977 </View>
978 );
979 }
980
981 const listHeader = Platform.OS === "ios" ? undefined : <HomeTopContentSpacer />;
982
983 const v2ListHeader = (
984 <>
985 {listHeader}
986 {Platform.OS === "ios" && workspace ? (
987 <ThreadListFilters
988 environments={props.environments}
989 projects={projectScopes.map((scope) => ({ key: scope.key, label: scope.title }))}
990 selectedEnvironmentId={props.selectedEnvironmentId}
991 selectedProjectKey={props.selectedProjectKey}
992 onEnvironmentChange={props.onEnvironmentChange}
993 onProjectChange={props.onProjectChange}
994 />
995 ) : null}
996 </>
997 );
998
999 // Use the v2 project scope for its empty state. Snoozed threads need no
1000 // special empty state: their shelf header is a list row even while collapsed.
1001 const v2ListEmpty =
1002 hasSearchQuery && threadSearch.isPending ? undefined : hasSearchQuery ? (
1003 <EmptyState
1004 title="No results"
1005 detail={`No threads matching "${props.searchQuery}".`}
1006 variant={Platform.OS === "android" ? "plain" : undefined}
1007 />
1008 ) : v2ScopedProjectGroup !== null ? (
1009 <EmptyState
1010 title={`No threads in ${v2ScopedProjectGroup.title}`}
1011 detail="Choose another project or create a new task."
1012 variant={Platform.OS === "android" ? "plain" : undefined}
1013 />
1014 ) : selectedEnvironmentLabel ? (
1015 <EmptyState
1016 title={`No threads in ${selectedEnvironmentLabel}`}
1017 detail="Choose another environment or create a new task."
1018 variant={Platform.OS === "android" ? "plain" : undefined}
1019 />
1020 ) : (
1021 <EmptyState
1022 title="No threads yet"
1023 detail="Create a task to start a new coding session."
1024 variant={Platform.OS === "android" ? "plain" : undefined}
1025 />
1026 );
1027
1028 if (Platform.OS === "android" && threadListV2Items.length === 0) {
1029 return (
1030 <View className="flex-1 bg-header">
1031 <View
1032 className="flex-1 items-center justify-center overflow-hidden rounded-t-[28px] bg-screen px-4"
1033 style={{ paddingBottom: insets.bottom }}
1034 >
1035 {v2ListEmpty}
1036 </View>
1037 </View>
1038 );
1039 }
1040
1041 return (
1042 <View className={containerClassName}>
1043 <View
1044 className={
1045 Platform.OS === "android"
1046 ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen"
1047 : cn("flex-1", contentBackground)
1048 }
1049 >
1050 {/* Shared with the iPad sidebar: cells are reused across data
1051 rebuilds and `itemsAreEqual` keeps a minute tick (or an unrelated
1052 shell update) from re-rendering untouched rows. */}
1053 <SwipeableScrollGateProvider enabled={swipeEnabled} activation={swipeRowActivation}>
1054 <GestureDetector gesture={homeScrollGesture}>
1055 <LegendList
1056 ref={listRef}
1057 onLoad={() => activateVisibleRows(threadListV2Items)}
1058 onTouchStart={(event) => trackListTouches(event, true)}
1059 onTouchEnd={(event) => trackListTouches(event, false)}
1060 onTouchCancel={(event) => trackListTouches(event, false)}
1061 renderScrollComponent={Platform.OS === "ios" ? renderHomeScrollView : undefined}
1062 data={threadListV2Items}
1063 renderItem={renderV2Item}
1064 keyExtractor={v2KeyExtractor}
1065 getItemType={(item) => item.type}
1066 itemsAreEqual={threadListV2ListItemsAreEqual}
1067 estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT}
1068 drawDistance={THREAD_LIST_V2_DRAW_DISTANCE}
1069 recycleItems
1070 extraData={v2ExtraData}
1071 ListHeaderComponent={v2ListHeader}
1072 ListFooterComponent={
1073 settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? (
1074 <ThreadListV2ShowMoreRow
1075 hiddenCount={threadListV2Layout.hiddenSettledCount}
1076 onPress={showMoreSettled}
1077 />
1078 ) : null
1079 }
1080 ListEmptyComponent={v2ListEmpty}
1081 style={{ flex: 1 }}
1082 automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"}
1083 contentInsetAdjustmentBehavior="never"
1084 contentInset={Platform.OS === "ios" ? { top: insets.top } : undefined}
1085 contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0}
1086 initialScrollOffset={
1087 initialScrollOffset > 0
1088 ? initialScrollOffset - (Platform.OS === "ios" ? insets.top : 0)
1089 : undefined
1090 }
1091 showsVerticalScrollIndicator={false}
1092 keyboardDismissMode="on-drag"
1093 keyboardShouldPersistTaps="handled"
1094 {...scrollGateHandlers}
1095 scrollEventThrottle={16}
1096 contentContainerStyle={{
1097 paddingLeft: (contentSideInsets?.left ?? 0) + (primaryColumn ? 8 : 0),
1098 paddingRight: (contentSideInsets?.right ?? 0) + (primaryColumn ? 8 : 0),
1099 paddingBottom:
1100 Platform.OS === "ios"
1101 ? iosBottomClearance
1102 : (workspace?.bottomClearance ??
1103 Math.max(insets.bottom, 16) +
1104 (Platform.OS === "android" ? fabClearance : 88)),
1105 }}
1106 />
1107 </GestureDetector>
1108 </SwipeableScrollGateProvider>
1109 </View>
1110 </View>
1111 );
1112}