import type { EnvironmentScopedProjectShell, EnvironmentScopedThreadShell, VcsStatusState, } from "@t3tools/client-runtime"; import { SymbolView } from "expo-symbols"; import { useCallback, useMemo, useState } from "react"; import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { relativeTime } from "../../lib/time"; import type { RemoteCatalogState } from "../../state/use-remote-catalog"; import { useVcsStatus } from "../../state/use-vcs-status"; import { threadStatusTone } from "../threads/threadPresentation"; /* ─── Types ──────────────────────────────────────────────────────────── */ interface HomeScreenProps { readonly projects: ReadonlyArray; readonly threads: ReadonlyArray; readonly catalogState: RemoteCatalogState; readonly savedConnectionsById: Readonly>; readonly searchQuery: string; readonly onAddConnection: () => void; readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void; } interface ProjectGroup { readonly key: string; readonly project: EnvironmentScopedProjectShell; readonly threads: ReadonlyArray; } const projectGroupActivityOrder = Order.mapInput( Order.Struct({ activityAt: Order.flip(Order.Number), }), (group: ProjectGroup) => ({ activityAt: new Date(group.threads[0]!.updatedAt ?? group.threads[0]!.createdAt).getTime(), }), ); /* ─── Status indicator colors ────────────────────────────────────────── */ function statusColors(thread: EnvironmentScopedThreadShell): { 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; function deriveEmptyState(props: { readonly catalogState: RemoteCatalogState; readonly projectCount: number; }): { readonly title: string; readonly detail: string; readonly loading: boolean } { const { catalogState } = props; if (catalogState.isLoadingSavedConnections) { return { title: "Loading environments", detail: "Checking saved environments on this device.", loading: true, }; } if (!catalogState.hasSavedConnections) { return { title: "No environments connected", detail: "Add an environment to load projects and start coding sessions.", loading: false, }; } if (catalogState.connectionState === "disconnected" && !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: EnvironmentScopedProjectShell; readonly totalThreadCount: number; readonly httpBaseUrl: string | null; readonly bearerToken: string | null; readonly isExpanded: boolean; readonly onToggleExpand: () => void; }) { const hiddenCount = props.totalThreadCount - COLLAPSED_THREAD_LIMIT; return ( {props.project.title} {hiddenCount > 0 ? ( {props.isExpanded ? "Show less" : `${hiddenCount} more`} ) : null} ); } /* ─── Git summary line ──────────────────────────────────────────────── */ function gitSummaryParts(gitStatus: VcsStatusState): ReadonlyArray { if (!gitStatus.data) return []; const { data } = gitStatus; const parts: string[] = []; if (data.hasWorkingTreeChanges) { parts.push(`${data.workingTree.files.length} changed`); } if (data.aheadCount > 0) parts.push(`${data.aheadCount} ahead`); if (data.behindCount > 0) parts.push(`${data.behindCount} behind`); if (data.pr?.state === "open") parts.push(`PR #${data.pr.number}`); return parts; } /* ─── Thread row ─────────────────────────────────────────────────────── */ function ThreadRow(props: { readonly thread: EnvironmentScopedThreadShell; readonly projectCwd: string | null; readonly onPress: () => void; readonly isLast: boolean; }) { const separatorColor = useThemeColor("--color-separator"); const { bg, fg } = statusColors(props.thread); const tone = threadStatusTone(props.thread); const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt); const branch = props.thread.branch; // Subscribe to live git status — only when thread has a branch set. // Threads sharing the same cwd share one WS subscription via ref-counting. const cwd = branch ? (props.thread.worktreePath ?? props.projectCwd) : null; const gitStatus = useVcsStatus({ environmentId: cwd ? props.thread.environmentId : null, cwd, }); const gitParts = gitSummaryParts(gitStatus); return ( ({ opacity: pressed ? 0.7 : 1 })}> {/* Git status indicator */} {/* Content */} {/* Title + Status + Timestamp */} {props.thread.title} {tone.label} {timestamp} {/* Branch + git info */} {branch ? ( {branch} {gitParts.length > 0 ? ( {" · " + gitParts.join(" · ")} ) : null} ) : null} ); } /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { const [expandedProjects, setExpandedProjects] = useState>(() => new Set()); 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; }); }, []); /* Build project title lookup for search */ const projectTitleByKey = useMemo(() => { const map = new Map(); for (const p of props.projects) { map.set(scopedProjectKey(p.environmentId, p.id), p.title); } return map; }, [props.projects]); /* Filter threads by search query */ const filteredThreads = useMemo(() => { const q = props.searchQuery.trim().toLowerCase(); if (!q) return props.threads; return props.threads.filter((t) => { if (t.title.toLowerCase().includes(q)) return true; const key = scopedProjectKey(t.environmentId, t.projectId); return projectTitleByKey.get(key)?.toLowerCase().includes(q) ?? false; }); }, [props.threads, props.searchQuery, projectTitleByKey]); /* Group filtered threads by project */ const projectGroups = useMemo>(() => { const byProject = new Map(); for (const thread of filteredThreads) { const key = scopedProjectKey(thread.environmentId, thread.projectId); const existing = byProject.get(key); if (existing) existing.push(thread); else byProject.set(key, [thread]); } const groups: ProjectGroup[] = []; for (const project of props.projects) { const key = scopedProjectKey(project.environmentId, project.id); const threads = byProject.get(key); if (threads && threads.length > 0) { groups.push({ key, project, threads }); } } return Arr.sort(groups, projectGroupActivityOrder); }, [props.projects, filteredThreads]); /* Empty states */ const hasAnyThreads = props.threads.length > 0; const hasResults = filteredThreads.length > 0; const emptyState = deriveEmptyState({ catalogState: props.catalogState, projectCount: props.projects.length, }); return ( {!hasAnyThreads ? ( {emptyState.loading ? ( ) : null} ) : !hasResults ? ( ) : ( projectGroups.map((group) => { const connection = props.savedConnectionsById[group.project.environmentId]; const isExpanded = expandedProjects.has(group.key); const visibleThreads = isExpanded ? group.threads : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); return ( toggleExpanded(group.key)} /> {visibleThreads.map((thread, i) => ( props.onSelectThread(thread)} isLast={i === visibleThreads.length - 1} /> ))} ); }) )} ); }