diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 62de6f8..1f40c92 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -19,6 +19,11 @@ export default function TabLayout() { light: '#0891b2', })} > + + + + + diff --git a/app/(tabs)/projects/_layout.tsx b/app/(tabs)/projects/_layout.tsx new file mode 100644 index 0000000..b61923e --- /dev/null +++ b/app/(tabs)/projects/_layout.tsx @@ -0,0 +1,9 @@ +import { Stack } from 'expo-router'; + +export default function ProjectsLayout() { + return ( + + + + ); +} diff --git a/app/(tabs)/projects/index.tsx b/app/(tabs)/projects/index.tsx new file mode 100644 index 0000000..b537820 --- /dev/null +++ b/app/(tabs)/projects/index.tsx @@ -0,0 +1,25 @@ +import { useCallback } from 'react'; +import { useRouter } from 'expo-router'; +import { ProjectsScreen } from '../../../src/screens/ProjectsScreen'; +import { useOpenCode, Project } from '../../../src/providers/OpenCodeProvider'; + +export default function Projects() { + const router = useRouter(); + const { projects, projectsLoading, refreshProjects, setSelectedProject } = useOpenCode(); + + const handleSelectProject = useCallback((project: Project) => { + // Set the selected project to filter sessions + setSelectedProject(project); + // Navigate to sessions tab + router.push('/(tabs)/sessions'); + }, [router, setSelectedProject]); + + return ( + + ); +} diff --git a/app/(tabs)/sessions/index.tsx b/app/(tabs)/sessions/index.tsx index eca3c13..d028091 100644 --- a/app/(tabs)/sessions/index.tsx +++ b/app/(tabs)/sessions/index.tsx @@ -10,6 +10,8 @@ export default function Sessions() { sessionsLoading, sessionsRefreshing, refreshSessions, + selectedProject, + setSelectedProject, } = useOpenCode(); const handleSelectSession = useCallback((session: Session) => { @@ -17,6 +19,10 @@ export default function Sessions() { router.push(`/chat/${session.id}`); }, [router]); + const handleClearProject = useCallback(() => { + setSelectedProject(null); + }, [setSelectedProject]); + return ( ); } diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 53867d1..3c138ff 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -39,6 +39,7 @@ import { Mic, Send, ArrowUp, + X, } from 'lucide-react-native'; import { useTheme } from '../hooks/useTheme'; @@ -81,7 +82,8 @@ export type IconName = | 'inbox' | 'mic' | 'send' - | 'arrow-up'; + | 'arrow-up' + | 'x'; interface IconProps { name: IconName; @@ -130,6 +132,7 @@ const iconMap = { 'mic': Mic, 'send': Send, 'arrow-up': ArrowUp, + 'x': X, }; export function Icon({ name, size = 24, color, strokeWidth = 2 }: IconProps) { diff --git a/src/providers/OpenCodeProvider.tsx b/src/providers/OpenCodeProvider.tsx index 165a54f..815a881 100644 --- a/src/providers/OpenCodeProvider.tsx +++ b/src/providers/OpenCodeProvider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react'; +import React, { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo, ReactNode } from 'react'; import { createOpencodeClient } from '@opencode-ai/sdk/client'; export type OpenCodeClient = ReturnType; @@ -20,6 +20,7 @@ export interface Project { id: string; name?: string; path?: string; + worktree?: string; // SDK returns this as the project path } export interface Message { @@ -108,6 +109,10 @@ interface OpenCodeContextValue { projectsLoading: boolean; refreshProjects: () => void; + // Selected project for filtering sessions + selectedProject: Project | null; + setSelectedProject: (project: Project | null) => void; + // Client access client: OpenCodeClient | null; } @@ -148,6 +153,7 @@ export function OpenCodeProvider({ children, defaultServerUrl = 'http://10.0.10. // UI state for projects const [projects, setProjects] = useState([]); const [projectsLoading, setProjectsLoading] = useState(false); + const [selectedProject, setSelectedProject] = useState(null); // SSE subscription state const [activeSessionId, setActiveSessionId] = useState(null); @@ -227,7 +233,7 @@ export function OpenCodeProvider({ children, defaultServerUrl = 'http://10.0.10. }, []); // Fetch sessions with stale-while-revalidate - const fetchSessions = useCallback(async (isRefresh = false) => { + const fetchSessions = useCallback(async (isRefresh = false, directory?: string) => { if (!clientRef.current) return; const cache = cacheRef.current; @@ -245,7 +251,9 @@ export function OpenCodeProvider({ children, defaultServerUrl = 'http://10.0.10. } try { - const result = await clientRef.current.session.list(); + const result = await clientRef.current.session.list({ + query: directory ? { directory } : undefined, + }); const sessionsData = (result.data ?? []) as Session[]; // Sort by updated/created date @@ -311,17 +319,26 @@ export function OpenCodeProvider({ children, defaultServerUrl = 'http://10.0.10. } }, []); - // Refresh sessions + // Derive the directory for session filtering + const selectedProjectDirectory = useMemo(() => { + return selectedProject?.worktree || selectedProject?.path; + }, [selectedProject]); + + // Refresh sessions (uses selected project's directory if set) const refreshSessions = useCallback(() => { - fetchSessions(true); - }, [fetchSessions]); + fetchSessions(true, selectedProjectDirectory); + }, [fetchSessions, selectedProjectDirectory]); - // Auto-fetch sessions when connected + // Auto-fetch sessions when connected or project changes useEffect(() => { if (connected) { - fetchSessions(); + // Clear sessions cache when project changes to avoid showing stale data + cacheRef.current.sessions = null; + setSessions([]); + + fetchSessions(false, selectedProjectDirectory); } - }, [connected, fetchSessions]); + }, [connected, fetchSessions, selectedProjectDirectory]); // Get session messages (from cache or state) const getSessionMessages = useCallback((sessionId: string): MessageWithParts[] => { @@ -594,6 +611,8 @@ export function OpenCodeProvider({ children, defaultServerUrl = 'http://10.0.10. projects, projectsLoading, refreshProjects, + selectedProject, + setSelectedProject, client: clientRef.current, }; diff --git a/src/screens/ProjectsScreen.tsx b/src/screens/ProjectsScreen.tsx index 1ee065c..e5db4de 100644 --- a/src/screens/ProjectsScreen.tsx +++ b/src/screens/ProjectsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React from 'react'; import { View, Text, @@ -11,49 +11,29 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useTheme } from '../hooks/useTheme'; import { Icon } from '../components/Icon'; import { spacing, radius, typography } from '../theme'; -import type { Project } from '../hooks/useOpenCode'; +import type { Project } from '../providers/OpenCodeProvider'; +import { getProjectName, getProjectPath } from '../utils/project'; interface ProjectsScreenProps { - getProjects: () => Promise; + projects: Project[]; + loading?: boolean; + refreshing?: boolean; + onRefresh?: () => void; onSelectProject?: (project: Project) => void; } export function ProjectsScreen({ - getProjects, + projects, + loading = false, + refreshing = false, + onRefresh, onSelectProject, }: ProjectsScreenProps) { const { theme, colors: c } = useTheme(); - const [projects, setProjects] = useState([]); - const [refreshing, setRefreshing] = useState(false); - const [loading, setLoading] = useState(true); - - const loadProjects = useCallback(async () => { - const data = await getProjects(); - setProjects(data); - setLoading(false); - }, [getProjects]); - - useEffect(() => { - loadProjects(); - }, [loadProjects]); - - const onRefresh = useCallback(async () => { - setRefreshing(true); - await loadProjects(); - setRefreshing(false); - }, [loadProjects]); - - const getProjectName = (project: Project) => { - if (project.name) return project.name; - if (project.path) { - const parts = project.path.split('/'); - return parts[parts.length - 1] || project.path; - } - return 'Unknown Project'; - }; const renderProject = ({ item }: { item: Project }) => { const name = getProjectName(item); + const path = getProjectPath(item); return ( {name} - {item.path && ( + {path && ( - {item.path} + {path} )} @@ -98,11 +78,13 @@ export function ProjectsScreen({ keyExtractor={(item) => item.id} renderItem={renderProject} refreshControl={ - + onRefresh ? ( + + ) : undefined } contentContainerStyle={[ styles.list, diff --git a/src/screens/SessionsScreen.tsx b/src/screens/SessionsScreen.tsx index 44d05cf..b3daf6f 100644 --- a/src/screens/SessionsScreen.tsx +++ b/src/screens/SessionsScreen.tsx @@ -11,7 +11,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTheme } from '../hooks/useTheme'; import { Icon } from '../components/Icon'; import { spacing, typography } from '../theme'; -import type { Session, SessionWithPreview } from '../providers/OpenCodeProvider'; +import type { Session, SessionWithPreview, Project } from '../providers/OpenCodeProvider'; +import { getProjectName } from '../utils/project'; interface SessionsScreenProps { sessions: SessionWithPreview[]; @@ -19,6 +20,8 @@ interface SessionsScreenProps { refreshing: boolean; onRefresh: () => void; onSelectSession: (session: Session) => void; + selectedProject?: Project | null; + onClearProject?: () => void; } interface GroupedSession extends SessionWithPreview { @@ -32,6 +35,8 @@ export function SessionsScreen({ refreshing, onRefresh, onSelectSession, + selectedProject, + onClearProject, }: SessionsScreenProps) { const { theme, colors: c } = useTheme(); const insets = useSafeAreaInsets(); @@ -191,11 +196,24 @@ export function SessionsScreen({ {/* Header */} - + Sessions - - {parentCount} {parentCount === 1 ? 'session' : 'sessions'} - + {selectedProject ? ( + + + + {getProjectName(selectedProject)} + + + + ) : ( + + {parentCount} {parentCount === 1 ? 'session' : 'sessions'} + + )} @@ -315,4 +333,10 @@ const styles = StyleSheet.create({ textAlign: 'center', marginTop: spacing.sm, }, + projectFilter: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.xs, + gap: spacing.xs, + }, }); diff --git a/src/utils/project.ts b/src/utils/project.ts new file mode 100644 index 0000000..7ba8b46 --- /dev/null +++ b/src/utils/project.ts @@ -0,0 +1,57 @@ +import type { Project } from '../providers/OpenCodeProvider'; + +/** + * Get the raw project path from either 'worktree' (SDK) or 'path' field. + */ +export function getProjectPathRaw(project: Project): string | undefined { + return project.worktree || project.path; +} + +/** + * Get a display name for a project. + * Uses the project name if available, otherwise extracts the folder name from the path. + */ +export function getProjectName(project: Project): string { + if (project.name) return project.name; + + let path = getProjectPathRaw(project); + if (path) { + // Normalize Windows backslashes to forward slashes before splitting + path = path.replace(/\\+/g, '/'); + const parts = path.split('/').filter(Boolean); + return parts[parts.length - 1] || path; + } + + return project.id; +} + +/** + * Convert an absolute path to a ~/relative format for display. + * Handles macOS, Linux, and Windows home directory patterns. + */ +export function formatPath(path: string): string { + const homePatterns = [ + /^\/Users\/[^/]+\//, // macOS: /Users/username/ + /^\/home\/[^/]+\//, // Linux: /home/username/ + /^C:\\Users\\[^\\]+\\/i, // Windows: C:\Users\username\ + ]; + + for (const pattern of homePatterns) { + if (pattern.test(path)) { + return '~/' + path.replace(pattern, '').replace(/\\/g, '/'); + } + } + return path; +} + +/** + * Get a formatted display path for a project. + * Returns the path relative to home directory, or null if no path available. + */ +export function getProjectPath(project: Project): string | null { + const projectPath = getProjectPathRaw(project); + if (projectPath) { + return formatPath(projectPath); + } + return null; +}