Files
paseo/packages/app/src/components/sidebar-agent-list.tsx
T

634 lines
18 KiB
TypeScript

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<GestureType | undefined>
}
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 <View style={[styles.projectStatusDot, { backgroundColor: color }]} />
}
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 (
<Pressable
style={({ pressed, hovered = false }) => [
styles.projectRow,
hovered && styles.projectRowHovered,
pressed && styles.projectRowPressed,
]}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={200}
testID={`sidebar-project-row-${project.projectKey}`}
>
<View style={styles.projectRowLeft}>
{collapsed ? (
<ChevronRight size={14} color="#9ca3af" />
) : (
<ChevronDown size={14} color="#9ca3af" />
)}
{iconDataUri ? (
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
) : (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>
{displayName.charAt(0).toUpperCase()}
</Text>
</View>
)}
<ProjectStatusDot bucket={project.statusBucket} />
<Text style={styles.projectTitle} numberOfLines={1}>
{displayName}
</Text>
</View>
<Text style={styles.projectCountText}>{project.workspaces.length}</Text>
</Pressable>
)
}
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 (
<Pressable
style={({ pressed, hovered = false }) => [
styles.workspaceRow,
hovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
]}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={200}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<Text style={styles.workspaceBranchText} numberOfLines={1}>
{resolveWorkspaceBranchLabel(workspace)}
</Text>
<Text style={styles.workspaceCreatedAtText} numberOfLines={1}>
{resolveWorkspaceCreatedAtLabel(workspace)}
</Text>
</Pressable>
)
}
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<Set<string>>(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<string>()
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<string, { serverId: string; cwd: string }>()
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<string, string | null>()
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<string, string | null>()
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<SidebarTreeRow>) => {
if (item.kind === 'project') {
return (
<ProjectRow
project={item.project}
displayName={item.displayName}
iconDataUri={projectIconByProjectKey.get(item.project.projectKey) ?? null}
collapsed={collapsedProjectKeys.has(item.project.projectKey)}
onToggle={() => 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 (
<WorkspaceRow
workspace={item.workspace}
onPress={() => {
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<SidebarTreeRow, { kind: 'project' }> => 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<string, string[]>()
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 (
<View style={styles.container}>
<DraggableList
data={rows}
style={styles.list}
contentContainerStyle={styles.listContent}
testID="sidebar-project-workspace-list-scroll"
keyExtractor={keyExtractor}
renderItem={renderRow}
onDragEnd={handleDragEnd}
showsVerticalScrollIndicator={false}
enableDesktopWebScrollbar={showDesktopWebScrollbar}
ListFooterComponent={listFooterComponent}
ListEmptyComponent={<Text style={styles.emptyText}>No projects yet</Text>}
refreshing={isRefreshing}
onRefresh={onRefresh}
simultaneousGestureRef={parentGestureRef}
/>
</View>
)
}
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,
},
}))