Skip to content
Source
Sign inSign up
Open History to see the last commit

HomeScreen.tsx

825 lines34.2 KB

1import { useContentTopInset } from "../../components/ui/useBarInset";
2import { usePaneRole } from "../../components/ui/Pane";
3import { ScreenContent } from "../../components/ui/ScreenContent";
4import { useActiveNewTask } from "../../state/active-new-task";
5import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
6import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing";
7import type { ThreadMoveDestination } from "../threads/threadOrder";
8import type { LegendListRef } from "@legendapp/list/react-native";
9import { AnimatedLegendList } from "@legendapp/list/reanimated";
10import { THREAD_SIDEBAR_ROW_LAYOUT } from "../threads/threadListAnimations";
11import {
12 type EnvironmentProject,
13 type EnvironmentThreadShell,
14} from "@t3tools/client-runtime/state/shell";
15import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping";
16import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
17import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts";
18import { useAtomValue } from "@effect/atom-react";
19import {
20 use,
21 useEffect,
22 useLayoutEffect,
23 useMemo,
24 useRef,
25 useState,
26 type ComponentProps,
27} from "react";
28import {
29 Platform,
30 ScrollView,
31 useWindowDimensions,
32 View,
33 type ScrollViewProps,
34 type GestureResponderEvent,
35 type NativeScrollEvent,
36 type NativeSyntheticEvent,
37} from "react-native";
38import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker";
39import { GestureDetector, useNativeGesture } from "react-native-gesture-handler";
40import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
41import { useSafeAreaInsets } from "react-native-safe-area-context";
42​
43import { cn } from "../../lib/cn";
44import {
45 ANDROID_READING_MAX_WIDTH,
46 deriveCenteredContentHorizontalPadding,
47} from "../../lib/layout";
48import { EmptyState } from "../../components/ui/EmptyState";
49import { FloatingActionButton } from "../../components/ui/FloatingActionButton";
50import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel";
51import type { SavedRemoteConnection } from "../../lib/connection";
52import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
53import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
54import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects";
55import {
56 useNativeColumnLayoutMetrics,
57 useNativeLayoutMetrics,
58} from "../../native/native-layout-metrics";
59import { useWorkspaceListEmptyTopInset } from "../layout/workspace/workspace-list-insets";
60import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns";
61import { useThreadSearch } from "../../state/queries";
62import { requestThreadMessageJump } from "../threads/threadMessageJump";
63import { useThreadListSearchMatches } from "../threads/useThreadListSearchMatches";
64import { useThreadListV2 } from "../threads/useThreadListV2";
65import { useThreadListNow } from "../threads/use-thread-list-now";
66import { useSearchScrollRestoration } from "../layout/workspace/useSearchScrollRestoration";
67import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
68import { usePendingThreadOrder } from "../../state/thread-order";
69import { threadListEnvironmentsAtom } from "../../state/presentation";
70import type { PendingNewTask } from "../../state/use-pending-new-tasks";
71import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
72import {
73 ThreadListV2PendingRow,
74 ThreadListV2Row,
75 ThreadListV2SettledShelfHeader,
76 ThreadListV2ShowMoreRow,
77 ThreadListV2SnoozedShelfHeader,
78 ThreadListV2WorkingShelfHeader,
79} from "../threads/thread-list-v2-items";
80import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance";
81import { useOpenThreadPullRequests } from "../threads/use-open-thread-pull-requests";
82import type { ThreadPrPresentation } from "../../state/thread-pr-presentation";
83import {
84 threadListV2ListItemsAreEqual,
85 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
86 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
87 type ThreadListV2ListItem,
88} from "../threads/threadListV2";
89import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
90import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
91import {
92 buildHomeProjectScopes,
93 findHomeProjectScope,
94 type HomeProjectSortOrder,
95 sortHomeProjectScopes,
96} from "./homeThreadList";
97import { createSwipeRowActivation } from "./swipe-row-activation";
98import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";
99import { useFabScroll } from "./FabScrollContext";
100import { holdLaunchSplashForHomeRows, settleLaunchSplashHomeRows } from "./home-launch-splash";
101import { useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext";
102import { ThreadListFilters } from "./ThreadListFilters";
103import { deriveHomeEmptyState } from "./home-empty-state";
104​
105interface HomeScreenProps {
106 /** Whether this list is the one on screen; see `useHomeRouteVisible`. */
107 readonly visible: boolean;
108 readonly projects: ReadonlyArray<EnvironmentProject>;
109 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
110 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
111 readonly catalogState: WorkspaceState;
112 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;
113 readonly environments: ReadonlyArray<
114 HomeListFilterMenuEnvironment & Pick<WorkspaceEnvironment, "connectionState">
115 >;
116 readonly searchQuery: string;
117 readonly selectedEnvironmentId: EnvironmentId | null;
118 readonly selectedProjectKey: string | null;
119 readonly projectSortOrder: HomeProjectSortOrder;
120 readonly projectGroupingMode: SidebarProjectGroupingMode;
121 readonly onSearchQueryChange: (query: string) => void;
122 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
123 readonly onProjectChange: (projectKey: string | null) => void;
124 readonly onAddConnection: () => void;
125 readonly onOpenSettings: () => void;
126 readonly onStartNewTask: () => void;
127 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
128 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
129 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
130 readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
131 readonly onSnoozeThread: (
132 thread: EnvironmentThreadShell,
133 snoozedUntil: string,
134 ) => Promise<boolean>;
135 readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
136 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
137 readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
138 readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
139 readonly onSetThreadAutoSettle: (
140 thread: EnvironmentThreadShell,
141 enabled: boolean,
142 ) => Promise<boolean>;
143 readonly onMoveThread: (
144 thread: EnvironmentThreadShell,
145 direction: ThreadMoveDestination,
146 ) => Promise<boolean>;
147 readonly onRenameThread: (thread: EnvironmentThreadShell) => void;
148 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
149 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
150 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
151 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
152 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
154​
155const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72;
156// Rows away from the viewport skip their swipe actions (see
157// swipe-row-activation), so render further ahead: a fast fling then reaches
158// rows that are already built instead of rows still being rebuilt.
159const THREAD_LIST_V2_DRAW_DISTANCE = 1_000;
160​
161function HomeTopContentSpacer() {
162 return <View className="h-4" />;
164​
165function HomeScrollView(props: ComponentProps<typeof ScrollView>) {
166 const primaryColumn = use(NativePrimaryColumnContext);
167 const sidebarSurface = usePaneRole() === "sidebar" && primaryColumn !== null;
168 if (Platform.OS !== "ios") return <ScrollView {...props} />;
169​
170 // Register the scroll view explicitly; layout and header siblings hide it from UIKit.
171 return (
172 <ScrollViewMarker
173 style={{ flex: 1 }}
174 scrollEdgeEffects={{
175 ...nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version),
176 top: Number.parseInt(String(Platform.Version), 10) >= 27 ? "automatic" : "soft",
177 }}
178 >
179 <ScrollView
180 {...props}
181 // UIKit's scroll-edge material needs the scroll view's own surface color.
182 className={cn(props.className, sidebarSurface ? "bg-drawer" : "bg-screen")}
183 />
184 </ScrollViewMarker>
185 );
187​
188function renderHomeScrollView(props: ScrollViewProps) {
189 // The list passes its content and ref in these props; keep the marker inside the real ScrollView.
190 return <HomeScrollView {...props} />;
192​
193export function HomeScreen(props: HomeScreenProps) {
194 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
195 const workspace = useWorkspaceTabs();
196 const [initialScrollOffset] = useState(() => workspace?.getScrollOffset("threads") ?? 0);
197 const primaryColumn = use(NativePrimaryColumnContext);
198 const sidebarSurface = usePaneRole() === "sidebar" && primaryColumn !== null;
199 const contentBackground = sidebarSurface ? "bg-drawer" : "bg-screen";
200 const containerClassName = cn(
201 "flex-1",
202 Platform.OS === "android" ? "bg-header" : contentBackground,
203 );
204 const columnMetrics = useNativeColumnLayoutMetrics();
205 const activeNewTask = useActiveNewTask();
206 const selectedThreadKey = activeNewTask ? null : (primaryColumn?.selectedThreadKey ?? null);
207 const fullSwipeWidth = primaryColumn && columnMetrics ? columnMetrics.width - 20 : undefined;
208 const queuedThreadKeys = useQueuedThreadKeys();
209 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
210 const homeScrollGesture = useNativeGesture();
211 const insets = useSafeAreaInsets();
212 const contentTopInset = useContentTopInset();
213 const emptyTopInset = useWorkspaceListEmptyTopInset();
214 const { fabClearance } = useAndroidControlSizing();
215 const screenMetrics = useNativeLayoutMetrics();
216 const contentSideInsets = usesNativeWorkspaceColumns
217 ? (columnMetrics ?? screenMetrics)?.safeArea
218 : undefined;
219 // Wide Android lists need reading-width gutters to clear edges and camera cutouts.
220 const { width: windowWidth } = useWindowDimensions();
221 const androidListGutter =
222 Platform.OS === "android"
223 ? deriveCenteredContentHorizontalPadding({
224 viewportWidth: windowWidth,
225 maxContentWidth: ANDROID_READING_MAX_WIDTH,
226 minimumPadding: 0,
227 })
228 : 0;
229 const iosBottomClearance = Math.max(
230 columnMetrics?.safeArea.bottom ?? insets.bottom,
231 24,
232 workspace?.bottomClearance ?? 0,
233 );
234 const searchEnvironmentIds =
235 props.selectedEnvironmentId === null
236 ? props.environments
237 .filter((environment) => environment.connectionState === "connected")
238 .map((environment) => environment.environmentId)
239 : props.environments.some(
240 (environment) =>
241 environment.environmentId === props.selectedEnvironmentId &&
242 environment.connectionState === "connected",
243 )
244 ? [props.selectedEnvironmentId]
245 : [];
246 const handleSwipeableWillOpen = (methods: SwipeableMethods) => {
247 if (openSwipeableRef.current !== methods) {
248 openSwipeableRef.current?.close();
249 openSwipeableRef.current = methods;
250 }
251 };
252​
253 const handleSwipeableClose = (methods: SwipeableMethods) => {
254 if (openSwipeableRef.current === methods) {
255 openSwipeableRef.current = null;
256 }
257 };
258​
259 const handleScrollBeginDrag = () => {
260 openSwipeableRef.current?.close();
261 };
262 const openThreadPullRequests = useOpenThreadPullRequests();
263 const handleOpenThreadPullRequests = (
264 thread: EnvironmentThreadShell,
265 pr: ThreadPrPresentation,
266 ) => {
267 openSwipeableRef.current?.close();
268 openThreadPullRequests(thread, pr);
269 };
270 const onFabScroll = useFabScroll();
271 const listRef = useRef<LegendListRef>(null);
272 const searchMode = workspace?.state.tabs.threads.searchMode;
273 const restoredOffset = workspace?.state.tabs.threads.scrollOffset;
274 useSearchScrollRestoration(searchMode, () => {
275 if (restoredOffset === undefined) return;
276 void listRef.current?.scrollToOffset({
277 offset: restoredOffset - contentTopInset,
278 animated: false,
279 });
280 });
281 const swipeRowActivation = createSwipeRowActivation();
282 const activateVisibleRows = (rows: ReadonlyArray<ThreadListV2ListItem>) => {
283 const state = listRef.current?.getState();
284 if (state === undefined || !(state.end >= 0)) return;
285 swipeRowActivation.activate(
286 rows.slice(Math.max(0, state.start - 2), state.end + 3).map((row) => row.key),
287 );
288 };
289 // Status-bar, accessibility and programmatic scrolls bypass the gate; activate visible rows after settling.
290 const activationTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
291 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the row activation timer on unmount
292 useEffect(() => () => clearTimeout(activationTimerRef.current), []);
293 const handleListScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
294 onFabScroll?.(event);
295 if (workspace)
296 workspace.recordScrollOffset(
297 "threads",
298 Math.max(0, event.nativeEvent.contentOffset.y + contentTopInset),
299 );
300 clearTimeout(activationTimerRef.current);
301 activationTimerRef.current = setTimeout(
302 () => activateVisibleRows(listRef.current?.getState().data ?? []),
303 200,
304 );
305 };
306 const trackListTouches = (event: GestureResponderEvent, started: boolean) => {
307 const { changedTouches, touches } = event.nativeEvent;
308 swipeRowActivation.trackTouches(
309 started ? changedTouches.map((touch) => String(touch.identifier)) : [],
310 touches.map((touch) => String(touch.identifier)),
311 );
312 };
313 const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({
314 onScroll: handleListScroll,
315 onScrollBeginDrag: handleScrollBeginDrag,
316 });
317​
318 const { projects, selectedEnvironmentId, projectGroupingMode } = props;
319 const projectScopes = useMemo(
320 () =>
321 buildHomeProjectScopes({
322 projects,
323 environmentId: selectedEnvironmentId,
324 projectGroupingMode,
325 }),
326 [projects, selectedEnvironmentId, projectGroupingMode],
327 );
328 const hasSearchQuery = props.searchQuery.trim().length > 0;
329 const projectByKey = (() => {
330 const map = new Map<string, EnvironmentProject>();
331 for (const project of props.projects) {
332 map.set(scopedProjectKey(project.environmentId, project.id), project);
333 }
334 return map;
335 })();
336​
337 const v2ProjectScopeKey = props.selectedProjectKey;
338 // An archived project's filter reads as all projects, matching the filter menu.
339 const v2ScopedProjectGroup = findHomeProjectScope(
340 pickableProjectGroups(projectScopes),
341 v2ProjectScopeKey,
342 );
343 const threadSearch = useThreadSearch(
344 searchEnvironmentIds,
345 props.searchQuery,
346 v2ScopedProjectGroup === null
347 ? {}
348 : { projectIds: v2ScopedProjectGroup.projectRefs.map((ref) => ref.projectId) },
349 );
350 const threadSearchMatchByKey = useThreadListSearchMatches(props.threads, threadSearch);
351 const v2ProjectTitleByProjectKey = new Map(
352 projectScopes.flatMap((scope) =>
353 scope.projectRefs.map(
354 (projectRef) =>
355 [scopedProjectKey(projectRef.environmentId, projectRef.projectId), scope.title] as const,
356 ),
357 ),
358 );
359 const handleSettleThread = props.onSettleThread;
360 const handleSnoozeThread = props.onSnoozeThread;
361 const handleUnsnoozeThread = props.onUnsnoozeThread;
362 const handlePinThread = props.onPinThread;
363 const handleMoveThread = props.onMoveThread;
364 const handleUnpinThread = props.onUnpinThread;
365 const handleSetThreadAutoSettle = props.onSetThreadAutoSettle;
366 const handleRegenerateThreadTitle = props.onRegenerateThreadTitle;
367 const handleRenameThread = props.onRenameThread;
368 const handleDeleteThread = props.onDeleteThread;
369 const handleUnsettleThread = props.onUnsettleThread;
370 const [settledVisibleCount, setSettledVisibleCount] = useState(
371 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
372 );
373 const settledResetKey = `${props.selectedEnvironmentId ?? "all"}:${v2ProjectScopeKey ?? "all"}:${props.searchQuery.trim()}`;
374 const [lastSettledResetKey, setLastSettledResetKey] = useState(settledResetKey);
375 if (lastSettledResetKey !== settledResetKey) {
376 setLastSettledResetKey(settledResetKey);
377 setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT);
378 }
379 const showMoreSettled = () =>
380 setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT);
381 const {
382 loaded: shelfPreferencesLoaded,
383 settledShelfExpanded,
384 snoozedShelfExpanded,
385 workingShelfEnabled,
386 workingShelfExpanded,
387 toggleSettledShelf,
388 toggleSnoozedShelf,
389 toggleWorkingShelf,
390 } = useThreadListV2ShelfPreferences();
391 const now = useThreadListNow(props.threads);
392 const listEnvironments = useAtomValue(threadListEnvironmentsAtom);
393 const {
394 providersByEnvironmentId,
395 machineByEnvironmentId,
396 settlementEnvironmentIds,
397 snoozeEnvironmentIds,
398 snoozePresetsByEnvironmentId,
399 pinningEnvironmentIds,
400 autoSettleOptOutEnvironmentIds,
401 pinReorderEnvironmentIds,
402 activeReorderEnvironmentIds,
403 titleRegenerationEnvironmentIds,
404 } = listEnvironments;
405 const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId);
406 const pendingOrder = usePendingThreadOrder();
407 const {
408 layout: threadListV2Layout,
409 items: threadListV2Items,
410 contentOnlyThreadKeys,
411 olderSettledShelf,
412 } = useThreadListV2({
413 threads: props.threads,
414 pendingTasks: props.pendingTasks,
415 environmentId: props.selectedEnvironmentId,
416 projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
417 searchQuery: props.searchQuery,
418 searchMatches: threadSearchMatchByKey,
419 settlementEnvironmentIds,
420 snoozeEnvironmentIds,
421 pinReorderEnvironmentIds,
422 activeReorderEnvironmentIds,
423 queuedThreadKeys,
424 settledLimit: settledVisibleCount,
425 now,
426 pendingOrder,
427 shelfPreferencesLoaded,
428 workingShelfEnabled,
429 workingShelfExpanded,
430 snoozedShelfExpanded,
431 settledShelfExpanded,
432 selectedThreadKey,
433 });
434 // Called as `props.onSelectThread(...)`, the compiler would cache this handler on the whole
435 // props object, which is new on every render of Home's parent.
436 const { onSelectThread } = props;
437 // A row listed for a message match opens the thread at that message, by
438 // tap or by shortcut.
439 const selectThread = (thread: EnvironmentThreadShell) => {
440 const key = threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id });
441 const messageId = contentOnlyThreadKeys.has(key)
442 ? threadSearchMatchByKey.get(key)?.messageId
443 : undefined;
444 if (messageId) {
445 requestThreadMessageJump(scopedThreadKey(thread.environmentId, thread.id), messageId);
446 }
447 onSelectThread(thread);
448 };
449 useThreadJumpShortcuts(threadListV2Items, selectThread);
450 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reads the list's visible range after commit to enable swipe gestures on those rows
451 useEffect(() => {
452 if (swipeEnabled) activateVisibleRows(threadListV2Items);
453 }, [activateVisibleRows, swipeEnabled, threadListV2Items]);
454​
455 const renderV2Item = ({ item }: { readonly item: ThreadListV2ListItem }) => {
456 if (item.type === "v2-pending") {
457 const pendingScopeKey = scopedProjectKey(
458 item.pendingTask.environmentId,
459 item.pendingTask.projectId,
460 );
461 return (
462 <ThreadListV2PendingRow
463 pane={sidebarSurface ? "sidebar" : "screen"}
464 pendingTask={item.pendingTask}
465 selected={
466 activeNewTask?.pendingKey === item.pendingTask.key ||
467 (item.pendingTask.kind === "pending" &&
468 scopedThreadKey(item.pendingTask.environmentId, item.pendingTask.message.threadId) ===
469 selectedThreadKey)
470 }
471 project={projectByKey.get(pendingScopeKey) ?? null}
472 projectTitle={v2ProjectTitleByProjectKey.get(pendingScopeKey)}
473 environmentLabel={
474 Object.keys(props.savedConnectionsById).length > 1
475 ? (props.savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ??
476 null)
477 : null
478 }
479 environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
480 showPendingDivider={item.showPendingDivider}
481 showTrailingDivider={item.showTrailingDivider}
482 onSelectPendingTask={props.onSelectPendingTask}
483 onDeletePendingTask={props.onDeletePendingTask}
484 fullSwipeWidth={fullSwipeWidth}
485 onSwipeableClose={handleSwipeableClose}
486 onSwipeableWillOpen={handleSwipeableWillOpen}
487 simultaneousSwipeGesture={homeScrollGesture}
488 activationKey={item.key}
489 />
490 );
491 }
492 if (item.type === "v2-working-shelf") {
493 return (
494 <ThreadListV2WorkingShelfHeader
495 count={item.count}
496 disabled={item.disabled}
497 expanded={item.expanded}
498 onToggle={toggleWorkingShelf}
499 />
500 );
501 }
502 if (item.type === "v2-snoozed-shelf") {
503 return (
504 <ThreadListV2SnoozedShelfHeader
505 count={item.count}
506 disabled={item.disabled}
507 expanded={item.expanded}
508 onToggle={toggleSnoozedShelf}
509 />
510 );
511 }
512 if (item.type === "v2-settled-shelf") {
513 return (
514 <ThreadListV2SettledShelfHeader
515 count={item.count}
516 disabled={item.disabled}
517 expanded={item.expanded}
518 onToggle={toggleSettledShelf}
519 />
520 );
521 }
522 const thread = item.item.thread;
523 return (
524 <ThreadListV2Row
525 pane={sidebarSurface ? "sidebar" : "screen"}
526 selected={scopedThreadKey(thread.environmentId, thread.id) === selectedThreadKey}
527 fullSwipeWidth={fullSwipeWidth}
528 onNewThreadOnBranch={props.onNewThreadOnBranch}
529 thread={thread}
530 variant={item.item.variant}
531 hasQueuedMessages={item.hasQueuedMessages}
532 snoozed={item.item.snoozed}
533 pinned={item.item.pinned}
534 wokeAt={item.item.wokeAt}
535 snoozePresetMinute={item.snoozePresetMinute ?? ""}
536 snoozeWakeLabelText={item.snoozeWakeLabelText}
537 timeLabel={item.timeLabel}
538 showTrailingDivider={item.showTrailingDivider}
539 project={projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null}
540 providerInstance={resolveProviderInstance(thread)}
541 providers={providersByEnvironmentId.get(thread.environmentId)}
542 environmentLabel={
543 Object.keys(props.savedConnectionsById).length > 1
544 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
545 : null
546 }
547 environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
548 searchMatch={
549 // Only rows listed for their messages show one and open at it.
550 contentOnlyThreadKeys.has(
551 threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }),
552 )
553 ? threadSearchMatchByKey.get(
554 threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }),
555 )
556 : undefined
557 }
558 searchQuery={threadSearch.query}
559 onSelectThread={selectThread}
560 onOpenThreadPullRequests={handleOpenThreadPullRequests}
561 onDeleteThread={handleDeleteThread}
562 onArchiveThread={props.onArchiveThread}
563 onRenameThread={handleRenameThread}
564 onRegenerateThreadTitle={handleRegenerateThreadTitle}
565 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
566 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
567 onSettleThread={handleSettleThread}
568 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
569 snoozePresets={snoozePresetsByEnvironmentId.get(thread.environmentId)}
570 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
571 autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)}
572 reorderSupported={
573 item.item.pinned
574 ? pinReorderEnvironmentIds.has(thread.environmentId)
575 : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId)
576 }
577 canMoveUp={item.canMoveUp}
578 canMoveDown={item.canMoveDown}
579 onSnoozeThread={handleSnoozeThread}
580 onUnsnoozeThread={handleUnsnoozeThread}
581 onUnsettleThread={handleUnsettleThread}
582 onPinThread={handlePinThread}
583 onUnpinThread={handleUnpinThread}
584 onSetThreadAutoSettle={handleSetThreadAutoSettle}
585 onMoveThread={handleMoveThread}
586 onSwipeableClose={handleSwipeableClose}
587 onSwipeableWillOpen={handleSwipeableWillOpen}
588 simultaneousSwipeGesture={homeScrollGesture}
589 activationKey={item.key}
590 />
591 );
592 };
593 const v2KeyExtractor = (item: ThreadListV2ListItem) => item.key;
594​
595 // extraData changes invalidate every visible row; put the minute clock on items to limit updates.
596 const v2ExtraData = {
597 activeNewTask,
598 projectByKey,
599 projectTitleByProjectKey: v2ProjectTitleByProjectKey,
600 listEnvironments,
601 savedConnectionsById: props.savedConnectionsById,
602 searchQuery: props.searchQuery,
603 selectedThreadKey,
604 threadSearchMatchByKey,
605 workingShelfEnabled,
606 };
607​
608 const hasAnyThreads =
609 props.threads.some((thread) => thread.archivedAt === null) ||
610 props.pendingTasks.length > 0 ||
611 threadListV2Layout.settledCount > 0;
612 // A hidden list gets no layout, so it never reports its rows as laid out.
613 const hasRowsToLayOut = props.visible && hasAnyThreads && threadListV2Items.length > 0;
614 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- holds the native launch splash, which is outside React, until the rows are gone or this list unmounts; a layout effect runs before the splash hides in this commit
615 useLayoutEffect(() => {
616 return hasRowsToLayOut ? holdLaunchSplashForHomeRows() : undefined;
617 }, [hasRowsToLayOut]);
618 const selectedEnvironmentLabel =
619 props.selectedEnvironmentId === null
620 ? null
621 : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ??
622 "this environment");
623 const emptyState = deriveHomeEmptyState(props.catalogState, props.projects.length);
624​
625 if (!hasAnyThreads) {
626 return (
627 <View className={containerClassName}>
628 <ScreenContent surface={sidebarSurface ? "drawer" : "screen"}>
629 <View
630 className={cn(
631 "flex-1 items-center justify-center px-8",
632 Platform.OS !== "android" && contentBackground,
633 )}
634 style={{
635 paddingBottom:
636 Platform.OS === "ios" ? iosBottomClearance : Math.max(insets.bottom, 24),
637 paddingTop: emptyTopInset,
638 paddingLeft: 32 + (contentSideInsets?.left ?? 0),
639 paddingRight: 32 + (contentSideInsets?.right ?? 0),
640 }}
641 >
642 <View className="w-full max-w-[430px]">
643 <EmptyState
644 title={emptyState.title}
645 detail={emptyState.detail}
646 actionLabel={emptyState.showsAddEnvironment ? "Add environment" : undefined}
647 onAction={emptyState.showsAddEnvironment ? props.onAddConnection : undefined}
648 action={
649 Platform.OS === "android" && emptyState.showsAddEnvironment ? (
650 <FloatingActionButton
651 label="Add environment"
652 icon="plus"
653 variant="extended"
654 tone="primary"
655 onPress={props.onAddConnection}
656 />
657 ) : undefined
658 }
659 variant="plain"
660 />
661 {emptyState.loading ? (
662 <View className="mt-4 items-center">
663 <AppActivityIndicator colorClassName="accent-icon-muted" />
664 </View>
665 ) : null}
666 </View>
667 </View>
668 </ScreenContent>
669 </View>
670 );
671 }
672​
673 const listHeader = Platform.OS === "ios" ? undefined : <HomeTopContentSpacer />;
674​
675 const v2ListHeader = (
676 <>
677 {listHeader}
678 {Platform.OS === "ios" && workspace ? (
679 <ThreadListFilters
680 gutter={sidebarSurface ? 8 : 0}
681 environments={props.environments}
682 projects={pickableProjectGroups(
683 sortHomeProjectScopes({
684 scopes: projectScopes,
685 threads: props.threads,
686 pendingTasks: [],
687 projectSortOrder: "updated_at",
688 }),
689 ).map((scope) => ({
690 key: scope.key,
691 label: scope.title,
692 }))}
693 selectedEnvironmentId={props.selectedEnvironmentId}
694 selectedProjectKey={props.selectedProjectKey}
695 onEnvironmentChange={props.onEnvironmentChange}
696 onProjectChange={props.onProjectChange}
697 />
698 ) : null}
699 </>
700 );
701​
702 const v2ListEmpty =
703 hasSearchQuery && threadSearch.isPending ? (
704 <EmptyState
705 title="Searching thread messages…"
706 detail={`Looking for "${props.searchQuery}" in messages.`}
707 variant={Platform.OS === "android" ? "plain" : undefined}
708 />
709 ) : hasSearchQuery ? (
710 <EmptyState
711 title="No results"
712 detail={`No threads matching "${props.searchQuery}".`}
713 variant={Platform.OS === "android" ? "plain" : undefined}
714 />
715 ) : v2ScopedProjectGroup !== null ? (
716 <EmptyState
717 title={`No threads in ${v2ScopedProjectGroup.title}`}
718 detail="Choose another project or create a new task."
719 variant={Platform.OS === "android" ? "plain" : undefined}
720 />
721 ) : selectedEnvironmentLabel ? (
722 <EmptyState
723 title={`No threads in ${selectedEnvironmentLabel}`}
724 detail="Choose another environment or create a new task."
725 variant={Platform.OS === "android" ? "plain" : undefined}
726 />
727 ) : (
728 <EmptyState
729 title="No threads yet"
730 detail="Create a task to start a new coding session."
731 variant={Platform.OS === "android" ? "plain" : undefined}
732 />
733 );
734​
735 if (Platform.OS === "android" && threadListV2Items.length === 0) {
736 return (
737 <ScreenContent surface="screen">
738 <View
739 className="flex-1 items-center justify-center px-4"
740 style={{ paddingBottom: insets.bottom }}
741 >
742 {v2ListEmpty}
743 </View>
744 </ScreenContent>
745 );
746 }
747​
748 return (
749 <View className={containerClassName}>
750 <ScreenContent surface="screen">
751 <View className={cn("flex-1", Platform.OS !== "android" && contentBackground)}>
752 <SwipeableScrollGateProvider enabled={swipeEnabled} activation={swipeRowActivation}>
753 <GestureDetector gesture={homeScrollGesture}>
754 <AnimatedLegendList
755 itemLayoutAnimation={sidebarSurface ? THREAD_SIDEBAR_ROW_LAYOUT : undefined}
756 ref={listRef}
757 onLoad={() => {
758 settleLaunchSplashHomeRows();
759 activateVisibleRows(threadListV2Items);
760 }}
761 onTouchStart={(event) => trackListTouches(event, true)}
762 onTouchEnd={(event) => trackListTouches(event, false)}
763 onTouchCancel={(event) => trackListTouches(event, false)}
764 renderScrollComponent={Platform.OS === "ios" ? renderHomeScrollView : undefined}
765 data={threadListV2Items}
766 renderItem={renderV2Item}
767 keyExtractor={v2KeyExtractor}
768 getItemType={(item) => item.type}
769 itemsAreEqual={threadListV2ListItemsAreEqual}
770 estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT}
771 drawDistance={THREAD_LIST_V2_DRAW_DISTANCE}
772 recycleItems
773 extraData={v2ExtraData}
774 ListHeaderComponent={v2ListHeader}
775 // Lifted above the rows so an iOS filter menu's dismissal morph is not covered.
776 ListHeaderComponentStyle={{ zIndex: 1 }}
777 ListFooterComponent={
778 settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? (
779 <ThreadListV2ShowMoreRow
780 hiddenCount={threadListV2Layout.hiddenSettledCount}
781 {...(olderSettledShelf.loading
782 ? { state: "loading" as const }
783 : olderSettledShelf.failed
784 ? { state: "failed" as const }
785 : {})}
786 onPress={() => {
787 olderSettledShelf.retry();
788 showMoreSettled();
789 }}
790 />
791 ) : null
792 }
793 ListEmptyComponent={v2ListEmpty}
794 style={{ flex: 1 }}
795 automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"}
796 contentInsetAdjustmentBehavior="never"
797 contentInset={Platform.OS === "ios" ? { top: contentTopInset } : undefined}
798 initialScrollOffset={
799 initialScrollOffset > 0 ? initialScrollOffset - contentTopInset : undefined
800 }
801 showsVerticalScrollIndicator={false}
802 keyboardDismissMode="on-drag"
803 keyboardShouldPersistTaps="handled"
804 {...scrollGateHandlers}
805 scrollEventThrottle={16}
806 contentContainerStyle={{
807 paddingLeft:
808 (contentSideInsets?.left ?? 0) + (sidebarSurface ? 8 : 0) + androidListGutter,
809 paddingRight:
810 (contentSideInsets?.right ?? 0) + (sidebarSurface ? 8 : 0) + androidListGutter,
811 paddingBottom:
812 Platform.OS === "ios"
813 ? iosBottomClearance
814 : (workspace?.bottomClearance ??
815 Math.max(insets.bottom, 16) +
816 (Platform.OS === "android" ? fabClearance : 88)),
817 }}
818 />
819 </GestureDetector>
820 </SwipeableScrollGateProvider>
821 </View>
822 </ScreenContent>
823 </View>
824 );