import { type EnvironmentProject, type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, SidebarProjectGroupingMode, SidebarThreadSortOrder, } from "@t3tools/contracts"; import * as Haptics from "expo-haptics"; import { SymbolView } from "expo-symbols"; import { useCallback, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; import ReanimatedSwipeable, { type SwipeableMethods, } from "react-native-gesture-handler/ReanimatedSwipeable"; import Animated, { Easing, LinearTransition, type ExitAnimationsValues, withDelay, withTiming, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; import { relativeTime } from "../../lib/time"; import { threadStatusTone } from "../threads/threadPresentation"; import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList"; import { THREAD_SWIPE_ACTIONS_WIDTH, THREAD_SWIPE_SPRING, ThreadSwipeActions, } from "./thread-swipe-actions"; /* ─── Types ──────────────────────────────────────────────────────────── */ interface HomeScreenProps { readonly projects: ReadonlyArray; readonly threads: ReadonlyArray; readonly catalogState: WorkspaceState; readonly savedConnectionsById: Readonly>; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onAddConnection: () => void; readonly onOpenEnvironments: () => void; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; } /* ─── Status indicator colors ────────────────────────────────────────── */ function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } { switch (thread.session?.status) { case "running": return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" }; case "ready": return { bg: "rgba(34,197,94,0.14)", fg: "#22c55e" }; case "starting": return { bg: "rgba(59,130,246,0.14)", fg: "#3b82f6" }; case "error": return { bg: "rgba(239,68,68,0.14)", fg: "#ef4444" }; default: return { bg: "rgba(163,163,163,0.10)", fg: "#a3a3a3" }; } } const COLLAPSED_THREAD_LIMIT = 6; const THREAD_LAYOUT_TRANSITION = LinearTransition.duration(220).easing(Easing.out(Easing.cubic)); function threadRowExit(values: ExitAnimationsValues) { "worklet"; return { initialValues: { height: values.currentHeight, opacity: 1, originX: values.currentOriginX, }, animations: { height: withDelay( 90, withTiming(0, { duration: 170, easing: Easing.inOut(Easing.cubic), }), ), opacity: withDelay(80, withTiming(0, { duration: 100 })), originX: withTiming(values.currentOriginX - values.windowWidth, { duration: 190, easing: Easing.out(Easing.cubic), }), }, }; } function deriveEmptyState(props: { readonly catalogState: WorkspaceState; readonly projectCount: number; }): { readonly title: string; readonly detail: string; readonly loading: boolean } { const { catalogState } = props; if (catalogState.isLoadingConnections) { return { title: "Loading environments", detail: "Checking saved environments on this device.", loading: true, }; } if (!catalogState.hasConnections) { return { title: "No environments connected", detail: "Add an environment to load projects and start coding sessions.", loading: false, }; } if ( (catalogState.connectionState === "available" || catalogState.connectionState === "offline" || catalogState.connectionState === "error") && !catalogState.hasLoadedShellSnapshot ) { return { title: "Environment unavailable", detail: catalogState.connectionError ?? "The saved environment is offline. Check the URL or start the environment, then retry.", loading: false, }; } if ( catalogState.hasConnectingEnvironment && !catalogState.hasLoadedShellSnapshot && catalogState.connectionError === null ) { return { title: "Connecting to environment", detail: "Loading projects and threads from the saved environment.", loading: true, }; } if (props.projectCount === 0 && catalogState.hasLoadedShellSnapshot) { return { title: "No projects found", detail: "The connected environment did not report any projects.", loading: false, }; } return { title: "No threads yet", detail: "Create a task to start a new coding session in one of your connected projects.", loading: false, }; } /* ─── Project group header ───────────────────────────────────────────── */ function ProjectGroupLabel(props: { readonly project: EnvironmentProject; readonly title: string; readonly totalThreadCount: number; readonly isExpanded: boolean; readonly onToggleExpand: () => void; }) { const hiddenCount = props.totalThreadCount - COLLAPSED_THREAD_LIMIT; return ( {props.title} {hiddenCount > 0 ? ( {props.isExpanded ? "Show less" : `${hiddenCount} more`} ) : null} ); } /* ─── Thread row ─────────────────────────────────────────────────────── */ function ThreadRow(props: { readonly thread: EnvironmentThreadShell; readonly environmentLabel: string | null; readonly onPress: () => void; readonly onArchive: () => void; readonly onDelete: () => void; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly isLast: boolean; }) { const swipeableRef = useRef(null); const fullSwipeArmedRef = useRef(false); const { width: windowWidth } = useWindowDimensions(); const separatorColor = useThemeColor("--color-separator"); const iconSubtleColor = useThemeColor("--color-icon-subtle"); const cardColor = useThemeColor("--color-card"); const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58); const { bg, fg } = statusColors(props.thread); const tone = threadStatusTone(props.thread); const timestamp = relativeTime( props.thread.latestUserMessageAt ?? props.thread.updatedAt ?? props.thread.createdAt, ); const branch = props.thread.branch; const subtitleParts = [props.environmentLabel, branch].filter((part): part is string => Boolean(part), ); const handleFullSwipeArmedChange = useCallback((armed: boolean) => { if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); } fullSwipeArmedRef.current = armed; }, []); return ( { fullSwipeArmedRef.current = false; if (swipeableRef.current) { props.onSwipeableClose(swipeableRef.current); } }} onSwipeableOpenStartDrag={() => { if (swipeableRef.current) { props.onSwipeableWillOpen(swipeableRef.current); } }} onSwipeableWillOpen={() => { const methods = swipeableRef.current; if (!methods) { return; } props.onSwipeableWillOpen(methods); if (fullSwipeArmedRef.current) { fullSwipeArmedRef.current = false; methods.close(); props.onDelete(); } }} overshootFriction={1} overshootRight renderRightActions={(_progress, translation, methods) => ( )} rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} > { swipeableRef.current?.close(); props.onPress(); }} style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} > {props.thread.title} {tone.label} {timestamp} {subtitleParts.length > 0 ? ( {subtitleParts.join(" · ")} ) : null} ); } /* ─── Main screen ────────────────────────────────────────────────────── */ function staleCatalogPillLabel(props: { readonly catalogState: WorkspaceState }): string { if (props.catalogState.networkStatus === "offline") { return "You are offline"; } const connectingEnvironments = props.catalogState.connectingEnvironments; if (connectingEnvironments.length === 1) { return `Reconnecting to ${connectingEnvironments[0]!.environmentLabel}`; } if (connectingEnvironments.length > 1) { return `Reconnecting ${connectingEnvironments.length} environments`; } return "Not connected"; } function StaleCatalogStatusPill(props: { readonly catalogState: WorkspaceState; readonly onPress: () => void; }) { const iconColor = useThemeColor("--color-icon-muted"); const label = staleCatalogPillLabel(props); const isReconnecting = props.catalogState.connectingEnvironments.length > 0; return ( {isReconnecting ? ( ) : ( )} {label} ); } export function HomeScreen(props: HomeScreenProps) { const [expandedProjects, setExpandedProjects] = useState>(() => new Set()); const openSwipeableRef = useRef(null); const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); const toggleExpanded = useCallback((key: string) => { setExpandedProjects((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, []); const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current !== methods) { openSwipeableRef.current?.close(); openSwipeableRef.current = methods; } }, []); const handleSwipeableClose = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current === methods) { openSwipeableRef.current = null; } }, []); const projectGroups = useMemo( () => buildHomeThreadGroups({ projects: props.projects, threads: props.threads, environmentId: props.selectedEnvironmentId, searchQuery: props.searchQuery, projectSortOrder: props.projectSortOrder, threadSortOrder: props.threadSortOrder, projectGroupingMode: props.projectGroupingMode, }), [ props.projectGroupingMode, props.projects, props.projectSortOrder, props.searchQuery, props.selectedEnvironmentId, props.threadSortOrder, props.threads, ], ); /* Empty states */ const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null); const hasResults = projectGroups.length > 0; const selectedEnvironmentLabel = props.selectedEnvironmentId === null ? null : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? "this environment"); const hasSearchQuery = props.searchQuery.trim().length > 0; const shouldShowConnectionStatus = props.catalogState.networkStatus === "offline" || props.catalogState.hasConnectingEnvironment || (props.catalogState.hasLoadedShellSnapshot && !props.catalogState.hasReadyEnvironment); const emptyState = deriveEmptyState({ catalogState: props.catalogState, projectCount: props.projects.length, }); return ( openSwipeableRef.current?.close()} className="flex-1" contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 8, paddingBottom: 24, gap: 20, }} > {!hasAnyThreads ? ( {emptyState.loading ? ( ) : null} ) : !hasResults && hasSearchQuery ? ( ) : !hasResults && selectedEnvironmentLabel ? ( ) : !hasResults ? ( ) : ( projectGroups.map((group) => { const isExpanded = expandedProjects.has(group.key); const visibleThreads = isExpanded ? group.threads : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); return ( toggleExpanded(group.key)} project={group.representative} title={group.title} totalThreadCount={group.threads.length} /> {visibleThreads.map((thread, i) => { const threadKey = `${thread.environmentId}:${thread.id}`; return ( props.onArchiveThread(thread)} onDelete={() => props.onDeleteThread(thread)} onPress={() => props.onSelectThread(thread)} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} /> ); })} ); }) )} {shouldShowConnectionStatus ? ( ) : null} ); }