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;
+}