import { View, Text, Pressable, Image, Platform } from 'react-native' import { useQueries } from '@tanstack/react-query' import { useCallback, useMemo, useState, useEffect, useRef, type ReactElement, type MutableRefObject, } from 'react' import { router, usePathname } from 'expo-router' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { type GestureType } from 'react-native-gesture-handler' import { ChevronDown, ChevronRight } from 'lucide-react-native' import { DraggableList, type DraggableRenderItemInfo } from './draggable-list' import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime' import { projectIconQueryKey } from '@/hooks/use-project-icon-query' import { buildHostWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from '@/utils/host-routes' import { type SidebarProjectEntry, type SidebarWorkspaceEntry, } from '@/hooks/use-sidebar-agents-list' import { useSidebarOrderStore } from '@/stores/sidebar-order-store' import { formatTimeAgo } from '@/utils/time' type SidebarTreeRow = | { kind: 'project' rowKey: string project: SidebarProjectEntry displayName: string } | { kind: 'workspace' rowKey: string projectKey: string workspace: SidebarWorkspaceEntry } function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { return null } return `data:${icon.mimeType};base64,${icon.data}` } interface SidebarAgentListProps { isOpen?: boolean projects: SidebarProjectEntry[] serverId: string | null isRefreshing?: boolean onRefresh?: () => void listFooterComponent?: ReactElement | null /** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */ parentGestureRef?: MutableRefObject } interface ProjectRowProps { project: SidebarProjectEntry displayName: string iconDataUri: string | null collapsed: boolean onToggle: () => void onLongPress: () => void } interface WorkspaceRowProps { workspace: SidebarWorkspaceEntry onPress: () => void onLongPress: () => void } function deriveProjectDisplayName(input: { projectKey: string; projectName: string }): string { const githubPrefix = 'remote:github.com/' if (input.projectKey.startsWith(githubPrefix)) { return input.projectKey.slice(githubPrefix.length) } if (input.projectKey.startsWith('remote:')) { const withoutPrefix = input.projectKey.slice('remote:'.length) const slashIdx = withoutPrefix.indexOf('/') if (slashIdx >= 0) { const remotePath = withoutPrefix.slice(slashIdx + 1).trim() if (remotePath.length > 0) { return remotePath } } return withoutPrefix } const trimmedProjectName = input.projectName.trim() if (trimmedProjectName.length > 0) { return trimmedProjectName } const normalized = input.projectKey.replace(/\\/g, '/').replace(/\/+$/, '') const segments = normalized.split('/').filter(Boolean) return segments[segments.length - 1] ?? input.projectKey } function resolveWorkspaceBranchLabel(workspace: SidebarWorkspaceEntry): string { const branch = workspace.branchName?.trim() if (branch && branch.length > 0) { return branch } return 'Unknown branch' } function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): string { if (!workspace.createdAt) { return 'No agents yet' } return formatTimeAgo(workspace.createdAt) } function ProjectStatusDot({ bucket }: { bucket: SidebarProjectEntry['statusBucket'] }) { const { theme } = useUnistyles() const color = bucket === 'needs_input' ? theme.colors.palette.amber[500] : bucket === 'failed' ? theme.colors.palette.red[500] : bucket === 'running' ? theme.colors.palette.blue[500] : bucket === 'attention' ? theme.colors.palette.green[500] : theme.colors.border return } function ProjectRow({ project, displayName, iconDataUri, collapsed, onToggle, onLongPress, }: ProjectRowProps) { const didLongPressRef = useRef(false) const handlePress = useCallback(() => { if (didLongPressRef.current) { didLongPressRef.current = false return } onToggle() }, [onToggle]) const handleLongPress = useCallback(() => { didLongPressRef.current = true onLongPress() }, [onLongPress]) return ( [ styles.projectRow, hovered && styles.projectRowHovered, pressed && styles.projectRowPressed, ]} onPress={handlePress} onLongPress={handleLongPress} delayLongPress={200} testID={`sidebar-project-row-${project.projectKey}`} > {collapsed ? ( ) : ( )} {iconDataUri ? ( ) : ( {displayName.charAt(0).toUpperCase()} )} {displayName} {project.workspaces.length} ) } function WorkspaceRow({ workspace, onPress, onLongPress }: WorkspaceRowProps) { const didLongPressRef = useRef(false) const handlePress = useCallback(() => { if (didLongPressRef.current) { didLongPressRef.current = false return } onPress() }, [onPress]) const handleLongPress = useCallback(() => { didLongPressRef.current = true onLongPress() }, [onLongPress]) return ( [ styles.workspaceRow, hovered && styles.workspaceRowHovered, pressed && styles.workspaceRowPressed, ]} onPress={handlePress} onLongPress={handleLongPress} delayLongPress={200} testID={`sidebar-workspace-row-${workspace.workspaceKey}`} > {resolveWorkspaceBranchLabel(workspace)} {resolveWorkspaceCreatedAtLabel(workspace)} ) } function mergeWithRemainder(input: { currentOrder: string[] reorderedVisibleKeys: string[] }): string[] { const reorderedSet = new Set(input.reorderedVisibleKeys) const remainder = input.currentOrder.filter((key) => !reorderedSet.has(key)) return [...input.reorderedVisibleKeys, ...remainder] } function hasVisibleOrderChanged(input: { currentOrder: string[] reorderedVisibleKeys: string[] }): boolean { const currentVisible = input.currentOrder.filter((key) => input.reorderedVisibleKeys.includes(key) ) if (currentVisible.length !== input.reorderedVisibleKeys.length) { return true } return input.reorderedVisibleKeys.some((key, index) => currentVisible[index] !== key) } export function SidebarAgentList({ isOpen = true, projects, serverId, isRefreshing = false, onRefresh, listFooterComponent, parentGestureRef, }: SidebarAgentListProps) { const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile const pathname = usePathname() const [collapsedProjectKeys, setCollapsedProjectKeys] = useState>(new Set()) const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder) const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder) const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder) const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder) useEffect(() => { setCollapsedProjectKeys((prev) => { const validProjectKeys = new Set(projects.map((project) => project.projectKey)) const next = new Set() for (const key of prev) { if (validProjectKeys.has(key)) { next.add(key) } } return next }) }, [projects]) const projectIconRequests = useMemo(() => { if (!isOpen || !serverId) { return [] } const unique = new Map() for (const project of projects) { const cwd = project.iconWorkingDir.trim() if (!cwd) { continue } unique.set(`${serverId}:${cwd}`, { serverId, cwd }) } return Array.from(unique.values()) }, [isOpen, projects, serverId]) const projectIconQueries = useQueries({ queries: projectIconRequests.map((request) => ({ queryKey: projectIconQueryKey(request.serverId, request.cwd), queryFn: async () => { const client = getHostRuntimeStore().getClient(request.serverId) if (!client) { return null } const result = await client.requestProjectIcon(request.cwd) return result.icon }, select: toProjectIconDataUri, enabled: Boolean( isOpen && getHostRuntimeStore().getClient(request.serverId) && isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) && request.cwd ), staleTime: Infinity, gcTime: 1000 * 60 * 60, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, })), }) const projectIconByProjectKey = useMemo(() => { const iconByServerAndCwd = new Map() for (let index = 0; index < projectIconRequests.length; index += 1) { const request = projectIconRequests[index] if (!request) { continue } iconByServerAndCwd.set( `${request.serverId}:${request.cwd}`, projectIconQueries[index]?.data ?? null ) } const byProject = new Map() for (const project of projects) { const cwd = project.iconWorkingDir.trim() if (!cwd || !serverId) { byProject.set(project.projectKey, null) continue } byProject.set(project.projectKey, iconByServerAndCwd.get(`${serverId}:${cwd}`) ?? null) } return byProject }, [projectIconQueries, projectIconRequests, projects, serverId]) const rows = useMemo(() => { const next: SidebarTreeRow[] = [] for (const project of projects) { next.push({ kind: 'project', rowKey: `project:${project.projectKey}`, project, displayName: deriveProjectDisplayName({ projectKey: project.projectKey, projectName: project.projectName, }), }) if (collapsedProjectKeys.has(project.projectKey)) { continue } for (const workspace of project.workspaces) { next.push({ kind: 'workspace', rowKey: `workspace:${project.projectKey}:${workspace.workspaceKey}`, projectKey: project.projectKey, workspace, }) } } return next }, [collapsedProjectKeys, projects]) const toggleProjectCollapsed = useCallback((projectKey: string) => { setCollapsedProjectKeys((prev) => { const next = new Set(prev) if (next.has(projectKey)) { next.delete(projectKey) } else { next.add(projectKey) } return next }) }, []) const renderRow = useCallback( ({ item, drag }: DraggableRenderItemInfo) => { if (item.kind === 'project') { return ( toggleProjectCollapsed(item.project.projectKey)} onLongPress={drag} /> ) } const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.cwd) const shouldReplace = Boolean(parseHostWorkspaceRouteFromPathname(pathname)) const navigate = shouldReplace ? router.replace : router.push return ( { if (!serverId) { return } navigate(workspaceRoute as any) }} onLongPress={drag} /> ) }, [ collapsedProjectKeys, pathname, projectIconByProjectKey, serverId, toggleProjectCollapsed, ] ) const keyExtractor = useCallback((entry: SidebarTreeRow) => entry.rowKey, []) const handleDragEnd = useCallback( (reorderedRows: SidebarTreeRow[]) => { if (!serverId) { return } const reorderedProjectKeys = reorderedRows .filter( (row): row is Extract => row.kind === 'project' ) .map((row) => row.project.projectKey) const currentProjectOrder = getProjectOrder(serverId) if ( hasVisibleOrderChanged({ currentOrder: currentProjectOrder, reorderedVisibleKeys: reorderedProjectKeys, }) ) { setProjectOrder( serverId, mergeWithRemainder({ currentOrder: currentProjectOrder, reorderedVisibleKeys: reorderedProjectKeys, }) ) } const workspaceRowsByProject = new Map() for (const row of reorderedRows) { if (row.kind !== 'workspace') { continue } const list = workspaceRowsByProject.get(row.projectKey) ?? [] list.push(row.workspace.workspaceKey) workspaceRowsByProject.set(row.projectKey, list) } for (const [projectKey, reorderedWorkspaceKeys] of workspaceRowsByProject.entries()) { const currentWorkspaceOrder = getWorkspaceOrder(serverId, projectKey) if ( !hasVisibleOrderChanged({ currentOrder: currentWorkspaceOrder, reorderedVisibleKeys: reorderedWorkspaceKeys, }) ) { continue } setWorkspaceOrder( serverId, projectKey, mergeWithRemainder({ currentOrder: currentWorkspaceOrder, reorderedVisibleKeys: reorderedWorkspaceKeys, }) ) } }, [getProjectOrder, getWorkspaceOrder, serverId, setProjectOrder, setWorkspaceOrder] ) return ( No projects yet} refreshing={isRefreshing} onRefresh={onRefresh} simultaneousGestureRef={parentGestureRef} /> ) } const styles = StyleSheet.create((theme) => ({ container: { flex: 1, }, list: { flex: 1, }, listContent: { paddingHorizontal: theme.spacing[2], paddingTop: theme.spacing[2], paddingBottom: theme.spacing[4], }, emptyText: { color: theme.colors.foregroundMuted, textAlign: 'center', marginTop: theme.spacing[8], marginHorizontal: theme.spacing[2], }, projectRow: { minHeight: 36, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, marginBottom: theme.spacing[1], flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: theme.spacing[2], }, projectRowHovered: { backgroundColor: theme.colors.surface1, }, projectRowPressed: { backgroundColor: theme.colors.surface2, }, projectRowLeft: { flexDirection: 'row', alignItems: 'center', gap: theme.spacing[2], flex: 1, minWidth: 0, }, projectIcon: { width: theme.iconSize.sm, height: theme.iconSize.sm, borderRadius: theme.borderRadius.sm, }, projectIconFallback: { width: theme.iconSize.sm, height: theme.iconSize.sm, borderRadius: theme.borderRadius.sm, borderWidth: 1, borderColor: theme.colors.border, alignItems: 'center', justifyContent: 'center', }, projectIconFallbackText: { color: theme.colors.foregroundMuted, fontSize: 9, }, projectStatusDot: { width: 8, height: 8, borderRadius: theme.borderRadius.full, }, projectTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, flex: 1, minWidth: 0, }, projectCountText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, flexShrink: 0, }, workspaceRow: { minHeight: 32, marginBottom: theme.spacing[1], marginLeft: theme.spacing[6], paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface0, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: theme.spacing[2], }, workspaceRowHovered: { backgroundColor: theme.colors.surface1, }, workspaceRowPressed: { backgroundColor: theme.colors.surface2, }, workspaceBranchText: { color: theme.colors.foreground, fontSize: theme.fontSize.xs, flex: 1, minWidth: 0, }, workspaceCreatedAtText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, flexShrink: 0, }, }))