Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export default function TabLayout() {
light: '#0891b2',
})}
>
<NativeTabs.Trigger name="projects">
<Icon sf={{ default: 'folder', selected: 'folder.fill' }} />
<Label>Projects</Label>
</NativeTabs.Trigger>

<NativeTabs.Trigger name="sessions">
<Icon sf={{ default: 'bubble.left', selected: 'bubble.left.fill' }} />
<Label>Sessions</Label>
Expand Down
9 changes: 9 additions & 0 deletions app/(tabs)/projects/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Stack } from 'expo-router';

export default function ProjectsLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
</Stack>
);
}
25 changes: 25 additions & 0 deletions app/(tabs)/projects/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ProjectsScreen
projects={projects}
loading={projectsLoading}
onRefresh={refreshProjects}
onSelectProject={handleSelectProject}
/>
);
}
8 changes: 8 additions & 0 deletions app/(tabs)/sessions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,28 @@ export default function Sessions() {
sessionsLoading,
sessionsRefreshing,
refreshSessions,
selectedProject,
setSelectedProject,
} = useOpenCode();

const handleSelectSession = useCallback((session: Session) => {
// Navigate to chat screen outside of tabs (hides tab bar)
router.push(`/chat/${session.id}`);
}, [router]);

const handleClearProject = useCallback(() => {
setSelectedProject(null);
}, [setSelectedProject]);

return (
<SessionsScreen
sessions={sessions}
loading={sessionsLoading}
refreshing={sessionsRefreshing}
onRefresh={refreshSessions}
onSelectSession={handleSelectSession}
selectedProject={selectedProject}
onClearProject={handleClearProject}
/>
);
}
5 changes: 4 additions & 1 deletion src/components/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
Mic,
Send,
ArrowUp,
X,
} from 'lucide-react-native';
import { useTheme } from '../hooks/useTheme';

Expand Down Expand Up @@ -81,7 +82,8 @@ export type IconName =
| 'inbox'
| 'mic'
| 'send'
| 'arrow-up';
| 'arrow-up'
| 'x';

interface IconProps {
name: IconName;
Expand Down Expand Up @@ -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) {
Expand Down
37 changes: 28 additions & 9 deletions src/providers/OpenCodeProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof createOpencodeClient>;
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -148,6 +153,7 @@ export function OpenCodeProvider({ children, defaultServerUrl = 'http://10.0.10.
// UI state for projects
const [projects, setProjects] = useState<Project[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);

// SSE subscription state
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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[] => {
Expand Down Expand Up @@ -594,6 +611,8 @@ export function OpenCodeProvider({ children, defaultServerUrl = 'http://10.0.10.
projects,
projectsLoading,
refreshProjects,
selectedProject,
setSelectedProject,
client: clientRef.current,
};

Expand Down
60 changes: 21 additions & 39 deletions src/screens/ProjectsScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react';
import React from 'react';
import {
View,
Text,
Expand All @@ -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<Project[]>;
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<Project[]>([]);
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 (
<TouchableOpacity
Expand All @@ -69,9 +49,9 @@ export function ProjectsScreen({
<Text style={[theme.bodyMedium]} numberOfLines={1}>
{name}
</Text>
{item.path && (
{path && (
<Text style={[theme.small, theme.textSecondary]} numberOfLines={1}>
{item.path}
{path}
</Text>
)}
</View>
Expand All @@ -98,11 +78,13 @@ export function ProjectsScreen({
keyExtractor={(item) => item.id}
renderItem={renderProject}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={c.accent}
/>
onRefresh ? (
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={c.accent}
/>
) : undefined
}
contentContainerStyle={[
styles.list,
Expand Down
34 changes: 29 additions & 5 deletions src/screens/SessionsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@ 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[];
loading: boolean;
refreshing: boolean;
onRefresh: () => void;
onSelectSession: (session: Session) => void;
selectedProject?: Project | null;
onClearProject?: () => void;
}

interface GroupedSession extends SessionWithPreview {
Expand All @@ -32,6 +35,8 @@ export function SessionsScreen({
refreshing,
onRefresh,
onSelectSession,
selectedProject,
onClearProject,
}: SessionsScreenProps) {
const { theme, colors: c } = useTheme();
const insets = useSafeAreaInsets();
Expand Down Expand Up @@ -191,11 +196,24 @@ export function SessionsScreen({
<View style={theme.container}>
{/* Header */}
<View style={[theme.header, { paddingTop: topPadding }]}>
<View>
<View style={{ flex: 1 }}>
<Text style={theme.title}>Sessions</Text>
<Text style={[theme.small, theme.textSecondary]}>
{parentCount} {parentCount === 1 ? 'session' : 'sessions'}
</Text>
{selectedProject ? (
<TouchableOpacity
onPress={onClearProject}
style={styles.projectFilter}
>
<Icon name="folder-open" size={12} color={c.accent} />
<Text style={[theme.small, { color: c.accent }]} numberOfLines={1}>
{getProjectName(selectedProject)}
</Text>
<Icon name="x" size={14} color={c.accent} />
</TouchableOpacity>
) : (
<Text style={[theme.small, theme.textSecondary]}>
{parentCount} {parentCount === 1 ? 'session' : 'sessions'}
</Text>
)}
</View>
</View>

Expand Down Expand Up @@ -315,4 +333,10 @@ const styles = StyleSheet.create({
textAlign: 'center',
marginTop: spacing.sm,
},
projectFilter: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.xs,
gap: spacing.xs,
},
});
Loading