Update expo application
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useColorScheme } from 'react-native';
|
||||
import { Redirect, Tabs } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useConvexAuth } from 'convex/react';
|
||||
|
||||
import { LoadingState } from '~/components/ui/loading-state';
|
||||
|
||||
const iconName = (route: string, focused: boolean) => {
|
||||
if (route === 'dashboard') return focused ? 'grid' : 'grid-outline';
|
||||
if (route === 'spoons') return focused ? 'git-branch' : 'git-branch-outline';
|
||||
if (route === 'threads')
|
||||
return focused ? 'chatbubbles' : 'chatbubbles-outline';
|
||||
return focused ? 'settings' : 'settings-outline';
|
||||
};
|
||||
|
||||
const AppTabs = () => {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
// Keeps the auth subscription warm while tab routes mount.
|
||||
}, [isAuthenticated]);
|
||||
|
||||
if (isLoading) return <LoadingState />;
|
||||
if (!isAuthenticated) return <Redirect href='/sign-in' />;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={({ route }) => ({
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: '#0f766e',
|
||||
tabBarInactiveTintColor: colorScheme === 'dark' ? '#94a3b8' : '#64748b',
|
||||
tabBarStyle: {
|
||||
backgroundColor: colorScheme === 'dark' ? '#111827' : '#f8fafc',
|
||||
borderTopColor: colorScheme === 'dark' ? '#334155' : '#e2e8f0',
|
||||
},
|
||||
tabBarIcon: ({ color, focused, size }) => (
|
||||
<Ionicons
|
||||
color={color}
|
||||
name={iconName(route.name, focused)}
|
||||
size={size}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
>
|
||||
<Tabs.Screen name='dashboard' options={{ title: 'Dashboard' }} />
|
||||
<Tabs.Screen name='spoons' options={{ title: 'Spoons' }} />
|
||||
<Tabs.Screen name='threads' options={{ title: 'Threads' }} />
|
||||
<Tabs.Screen name='workspace/[jobId]' options={{ href: null }} />
|
||||
<Tabs.Screen name='settings' options={{ title: 'Settings' }} />
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppTabs;
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
import { Link, Stack, useRouter } from 'expo-router';
|
||||
import { useQuery } from 'convex/react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import { SpoonListRow } from '~/components/spoons/spoon-list-row';
|
||||
import { ThreadListRow } from '~/components/threads/thread-list-row';
|
||||
import { AppScreen } from '~/components/ui/app-screen';
|
||||
import { Button } from '~/components/ui/button';
|
||||
import { Card } from '~/components/ui/card';
|
||||
import { EmptyState } from '~/components/ui/empty-state';
|
||||
import { MetricCard } from '~/components/ui/metric-card';
|
||||
import { titleize } from '~/utils/format';
|
||||
|
||||
const openThreadStatuses = ['resolved', 'ignored', 'failed', 'cancelled'];
|
||||
|
||||
const DashboardRoute = () => {
|
||||
const router = useRouter();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
|
||||
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
|
||||
const active = spoons.filter((spoon) => spoon.status === 'active').length;
|
||||
const behind = spoons.filter((spoon) => spoon.syncStatus === 'behind').length;
|
||||
const diverged = spoons.filter(
|
||||
(spoon) => spoon.syncStatus === 'diverged',
|
||||
).length;
|
||||
const openThreads = threads.filter(
|
||||
(thread) => !openThreadStatuses.includes(thread.status),
|
||||
);
|
||||
const upstreamWaiting = spoons.reduce(
|
||||
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const softRefresh = () => {
|
||||
setRefreshing(true);
|
||||
setTimeout(() => setRefreshing(false), 600);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
|
||||
<Stack.Screen options={{ title: 'Dashboard' }} />
|
||||
<View className='flex-row items-start justify-between gap-3'>
|
||||
<View className='min-w-0 flex-1'>
|
||||
<Text className='text-foreground text-3xl font-bold'>Dashboard</Text>
|
||||
<Text className='text-muted-foreground mt-1'>
|
||||
Managed forks, upstream drift, and open maintenance threads.
|
||||
</Text>
|
||||
</View>
|
||||
<Link href='/spoons/new' asChild>
|
||||
<Button>New</Button>
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
<View className='gap-3'>
|
||||
<View className='flex-row gap-3'>
|
||||
<MetricCard
|
||||
label='Spoons'
|
||||
note={`${active} active`}
|
||||
value={spoons.length}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Behind'
|
||||
note={`${diverged} diverged`}
|
||||
value={behind}
|
||||
/>
|
||||
</View>
|
||||
<View className='flex-row gap-3'>
|
||||
<MetricCard label='Open threads' value={openThreads.length} />
|
||||
<MetricCard label='Upstream commits' value={upstreamWaiting} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='gap-3'>
|
||||
<Text className='text-foreground text-lg font-semibold'>
|
||||
Maintenance queue
|
||||
</Text>
|
||||
{openThreads.length ? (
|
||||
openThreads
|
||||
.slice(0, 5)
|
||||
.map((thread) => (
|
||||
<ThreadListRow
|
||||
key={thread._id}
|
||||
thread={thread}
|
||||
onPress={() => router.push(`/threads/${thread._id}`)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<EmptyState
|
||||
description='Threads appear when you request work or upstream changes need review.'
|
||||
title='No open maintenance threads'
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='gap-3'>
|
||||
<Text className='text-foreground text-lg font-semibold'>
|
||||
Recent Spoons
|
||||
</Text>
|
||||
{spoons.length ? (
|
||||
spoons
|
||||
.slice(0, 5)
|
||||
.map((spoon) => (
|
||||
<SpoonListRow
|
||||
key={spoon._id}
|
||||
spoon={spoon}
|
||||
onPress={() => router.push(`/spoons/${spoon._id}`)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<EmptyState
|
||||
description='Create your first managed fork to start tracking upstream drift.'
|
||||
title='No Spoons yet'
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='gap-3'>
|
||||
<Text className='text-foreground text-lg font-semibold'>
|
||||
Recent activity
|
||||
</Text>
|
||||
<Card className='gap-3'>
|
||||
{syncRuns.length ? (
|
||||
syncRuns.map((run) => (
|
||||
<View key={run._id} className='border-border border-b pb-3'>
|
||||
<Text className='text-foreground font-medium'>
|
||||
{titleize(run.kind)}
|
||||
</Text>
|
||||
<Text className='text-muted-foreground text-sm'>
|
||||
{titleize(run.status)}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<Text className='text-muted-foreground text-sm'>
|
||||
Upstream checks will appear here.
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</View>
|
||||
</AppScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardRoute;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
const SettingsLayout = () => <Stack screenOptions={{ headerShown: false }} />;
|
||||
|
||||
export default SettingsLayout;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Text } from 'react-native';
|
||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import { AiProviderProfileForm } from '~/components/settings/ai-provider-profile-form';
|
||||
import { AppScreen } from '~/components/ui/app-screen';
|
||||
|
||||
const AiProviderFormRoute = () => {
|
||||
const router = useRouter();
|
||||
const { profileId: rawProfileId } = useLocalSearchParams<{
|
||||
profileId?: string;
|
||||
}>();
|
||||
const profileId = rawProfileId as Id<'aiProviderProfiles'> | undefined;
|
||||
const existing = useQuery(
|
||||
api.aiProviderProfiles.get,
|
||||
profileId ? { profileId } : 'skip',
|
||||
);
|
||||
const save = useAction(api.aiProviderProfilesNode.save);
|
||||
const updateMetadata = useMutation(api.aiProviderProfiles.updateMetadata);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const submit = async (values: Parameters<typeof save>[0]) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (profileId && !values.secret) {
|
||||
await updateMetadata({
|
||||
baseUrl: values.baseUrl,
|
||||
defaultModel: values.defaultModel,
|
||||
enabled: values.enabled,
|
||||
modelOptions: values.modelOptions,
|
||||
name: values.name,
|
||||
profileId,
|
||||
reasoningEffort: values.reasoningEffort,
|
||||
});
|
||||
} else {
|
||||
await save({ ...values, profileId });
|
||||
}
|
||||
router.replace('/settings/ai-providers');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Could not save AI provider.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (profileId && !existing) {
|
||||
return (
|
||||
<AppScreen>
|
||||
<Stack.Screen options={{ title: 'Edit provider' }} />
|
||||
<Text className='text-muted-foreground'>Loading provider...</Text>
|
||||
</AppScreen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppScreen>
|
||||
<Stack.Screen
|
||||
options={{ title: profileId ? 'Edit provider' : 'New provider' }}
|
||||
/>
|
||||
<Text className='text-foreground text-3xl font-bold'>
|
||||
{profileId ? 'Edit provider' : 'New provider'}
|
||||
</Text>
|
||||
<AiProviderProfileForm
|
||||
existing={existing ?? undefined}
|
||||
saving={saving}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
</AppScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiProviderFormRoute;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Alert, Text, View } from 'react-native';
|
||||
import { Link, Stack, useRouter } from 'expo-router';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import { AppScreen } from '~/components/ui/app-screen';
|
||||
import { Badge } from '~/components/ui/badge';
|
||||
import { Button } from '~/components/ui/button';
|
||||
import { EmptyState } from '~/components/ui/empty-state';
|
||||
import { ListRow } from '~/components/ui/list-row';
|
||||
import { titleize } from '~/utils/format';
|
||||
|
||||
const AiProvidersRoute = () => {
|
||||
const router = useRouter();
|
||||
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||
const setDefault = useMutation(api.aiProviderProfiles.setDefault);
|
||||
const remove = useMutation(api.aiProviderProfiles.remove);
|
||||
|
||||
return (
|
||||
<AppScreen>
|
||||
<Stack.Screen options={{ title: 'AI providers' }} />
|
||||
<View className='flex-row items-start justify-between gap-3'>
|
||||
<View className='min-w-0 flex-1'>
|
||||
<Text className='text-foreground text-3xl font-bold'>
|
||||
AI providers
|
||||
</Text>
|
||||
<Text className='text-muted-foreground mt-1'>
|
||||
Provider profiles for OpenCode workspaces.
|
||||
</Text>
|
||||
</View>
|
||||
<Link href='/settings/ai-provider-form' asChild>
|
||||
<Button>New</Button>
|
||||
</Link>
|
||||
</View>
|
||||
{profiles.length ? (
|
||||
profiles.map((profile) => (
|
||||
<ListRow
|
||||
key={profile._id}
|
||||
subtitle={`${titleize(profile.provider)} · ${profile.defaultModel}`}
|
||||
title={profile.name}
|
||||
onPress={() =>
|
||||
router.push(`/settings/ai-provider-form?profileId=${profile._id}`)
|
||||
}
|
||||
>
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
<Badge
|
||||
label={profile.configured ? 'configured' : 'missing credential'}
|
||||
tone={profile.configured ? 'success' : 'warning'}
|
||||
/>
|
||||
{profile.isDefault ? (
|
||||
<Badge label='default' tone='primary' />
|
||||
) : null}
|
||||
<Badge label={profile.enabled ? 'enabled' : 'disabled'} />
|
||||
</View>
|
||||
<View className='mt-3 flex-row gap-2'>
|
||||
<Button
|
||||
disabled={!profile.configured || !profile.enabled}
|
||||
variant='outline'
|
||||
onPress={() => void setDefault({ profileId: profile._id })}
|
||||
>
|
||||
Set default
|
||||
</Button>
|
||||
<Button
|
||||
variant='danger'
|
||||
onPress={() =>
|
||||
Alert.alert('Remove provider', `Remove ${profile.name}?`, [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Remove',
|
||||
style: 'destructive',
|
||||
onPress: () => void remove({ profileId: profile._id }),
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</View>
|
||||
</ListRow>
|
||||
))
|
||||
) : (
|
||||
<EmptyState
|
||||
description='Add an OpenAI, Codex/OpenCode, Anthropic, OpenRouter, or compatible provider before queueing agent work.'
|
||||
title='No AI providers'
|
||||
/>
|
||||
)}
|
||||
</AppScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiProvidersRoute;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Alert, Text } from 'react-native';
|
||||
import { Link, Stack } from 'expo-router';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { useQuery } from 'convex/react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import { AppScreen } from '~/components/ui/app-screen';
|
||||
import { Button } from '~/components/ui/button';
|
||||
import { ListRow } from '~/components/ui/list-row';
|
||||
|
||||
const SettingsRoute = () => {
|
||||
const { signOut } = useAuthActions();
|
||||
const user = useQuery(api.auth.getUser, {});
|
||||
const connection = useQuery(api.github.getConnection, {});
|
||||
const providers = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||
const defaultProvider = providers.find((provider) => provider.isDefault);
|
||||
|
||||
return (
|
||||
<AppScreen>
|
||||
<Stack.Screen options={{ title: 'Settings' }} />
|
||||
<Text className='text-foreground text-3xl font-bold'>Settings</Text>
|
||||
<Link href='/settings/profile' asChild>
|
||||
<ListRow
|
||||
subtitle={
|
||||
user?.email ?? 'Name, email, provider, and password settings'
|
||||
}
|
||||
title='Profile'
|
||||
/>
|
||||
</Link>
|
||||
<Link href='/settings/integrations' asChild>
|
||||
<ListRow
|
||||
subtitle={
|
||||
connection
|
||||
? `GitHub connected as ${connection.displayName}`
|
||||
: 'GitHub App connection and accessible repositories'
|
||||
}
|
||||
title='Integrations'
|
||||
/>
|
||||
</Link>
|
||||
<Link href='/settings/ai-providers' asChild>
|
||||
<ListRow
|
||||
subtitle={
|
||||
defaultProvider
|
||||
? `${providers.length} provider${providers.length === 1 ? '' : 's'}, default ${defaultProvider.name}`
|
||||
: 'OpenCode, Codex auth, API keys, and default models'
|
||||
}
|
||||
title='AI providers'
|
||||
/>
|
||||
</Link>
|
||||
<Button
|
||||
variant='danger'
|
||||
onPress={() =>
|
||||
Alert.alert('Sign out', 'Sign out of Spoon on this device?', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Sign out',
|
||||
style: 'destructive',
|
||||
onPress: () => void signOut(),
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
</AppScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsRoute;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { Text } from 'react-native';
|
||||
import { Stack } from 'expo-router';
|
||||
import { useAction, useQuery } from 'convex/react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import { GitHubIntegrationPanel } from '~/components/settings/github-integration-panel';
|
||||
import { AppScreen } from '~/components/ui/app-screen';
|
||||
|
||||
const IntegrationsRoute = () => {
|
||||
const installUrl = useQuery(api.github.getInstallUrl, {});
|
||||
const connection = useQuery(api.github.getConnection, {});
|
||||
const status = useQuery(api.integrations.getStatus, {});
|
||||
const syncInstallation = useAction(api.githubNode.syncConfiguredInstallation);
|
||||
const repositories = useAction(api.githubNode.listInstallationRepositories);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [loadingRepos, setLoadingRepos] = useState(false);
|
||||
|
||||
const sync = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await syncInstallation({});
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const listRepos = async () => {
|
||||
setLoadingRepos(true);
|
||||
try {
|
||||
const result = await repositories({});
|
||||
return result.map((repo) => repo.fullName);
|
||||
} finally {
|
||||
setLoadingRepos(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppScreen onRefresh={() => void sync()} refreshing={syncing}>
|
||||
<Stack.Screen options={{ title: 'Integrations' }} />
|
||||
<Text className='text-foreground text-3xl font-bold'>Integrations</Text>
|
||||
<GitHubIntegrationPanel
|
||||
connection={connection}
|
||||
installUrl={installUrl}
|
||||
loadingRepos={loadingRepos}
|
||||
runtimeStatus={status}
|
||||
syncing={syncing}
|
||||
onListRepos={listRepos}
|
||||
onSync={sync}
|
||||
/>
|
||||
</AppScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntegrationsRoute;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Text } from 'react-native';
|
||||
import { Stack } from 'expo-router';
|
||||
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import { AppScreen } from '~/components/ui/app-screen';
|
||||
import { Button } from '~/components/ui/button';
|
||||
import { Card } from '~/components/ui/card';
|
||||
import { Field } from '~/components/ui/field';
|
||||
import { titleize } from '~/utils/format';
|
||||
|
||||
const ProfileRoute = () => {
|
||||
const user = useQuery(api.auth.getUser, {});
|
||||
const provider = useQuery(api.auth.getUserProvider, {});
|
||||
const updateUser = useMutation(api.auth.updateUser);
|
||||
const updatePassword = useAction(api.auth.updateUserPassword);
|
||||
const [name, setName] = useState(user?.name ?? '');
|
||||
const [email, setEmail] = useState(user?.email ?? '');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [savingPassword, setSavingPassword] = useState(false);
|
||||
|
||||
const saveProfile = async () => {
|
||||
setSavingProfile(true);
|
||||
try {
|
||||
await updateUser({ name, email });
|
||||
Alert.alert('Saved', 'Profile updated.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Could not save profile.');
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const savePassword = async () => {
|
||||
setSavingPassword(true);
|
||||
try {
|
||||
await updatePassword({ currentPassword, newPassword });
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
Alert.alert('Saved', 'Password updated.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Could not update password.');
|
||||
} finally {
|
||||
setSavingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppScreen>
|
||||
<Stack.Screen options={{ title: 'Profile' }} />
|
||||
<Text className='text-foreground text-3xl font-bold'>Profile</Text>
|
||||
<Card className='gap-4'>
|
||||
<Text className='text-muted-foreground text-sm'>
|
||||
Email is currently managed by {titleize(provider ?? 'your provider')}.
|
||||
</Text>
|
||||
<Field label='Name' value={name} onChangeText={setName} />
|
||||
<Field
|
||||
keyboardType='email-address'
|
||||
label='Email'
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
<Button disabled={savingProfile} onPress={() => void saveProfile()}>
|
||||
{savingProfile ? 'Saving...' : 'Save profile'}
|
||||
</Button>
|
||||
</Card>
|
||||
{provider === 'password' ? (
|
||||
<Card className='gap-4'>
|
||||
<Text className='text-foreground font-semibold'>Password</Text>
|
||||
<Field
|
||||
label='Current password'
|
||||
secureTextEntry
|
||||
value={currentPassword}
|
||||
onChangeText={setCurrentPassword}
|
||||
/>
|
||||
<Field
|
||||
label='New password'
|
||||
secureTextEntry
|
||||
value={newPassword}
|
||||
onChangeText={setNewPassword}
|
||||
/>
|
||||
<Button
|
||||
disabled={savingPassword}
|
||||
variant='outline'
|
||||
onPress={() => void savePassword()}
|
||||
>
|
||||
{savingPassword ? 'Updating...' : 'Update password'}
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Text className='text-muted-foreground text-sm leading-5'>
|
||||
Password changes are hidden because this account is currently using{' '}
|
||||
{titleize(provider ?? 'an OAuth provider')}.
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
</AppScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileRoute;
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Text, View } from 'react-native';
|
||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import type { SpoonDetailSegment } from '~/components/spoons/segment-control';
|
||||
import { SegmentControl } from '~/components/spoons/segment-control';
|
||||
import { SpoonDetailFork } from '~/components/spoons/spoon-detail-fork';
|
||||
import { SpoonDetailOverview } from '~/components/spoons/spoon-detail-overview';
|
||||
import { SpoonDetailPrs } from '~/components/spoons/spoon-detail-prs';
|
||||
import { SpoonDetailSettings } from '~/components/spoons/spoon-detail-settings';
|
||||
import { SpoonDetailThreads } from '~/components/spoons/spoon-detail-threads';
|
||||
import { SpoonDetailUpstream } from '~/components/spoons/spoon-detail-upstream';
|
||||
import { SpoonStatusBadge } from '~/components/spoons/spoon-status-badge';
|
||||
import { AppScreen } from '~/components/ui/app-screen';
|
||||
import { Button } from '~/components/ui/button';
|
||||
|
||||
const SpoonDetailRoute = () => {
|
||||
const router = useRouter();
|
||||
const { spoonId: rawSpoonId } = useLocalSearchParams<{ spoonId: string }>();
|
||||
const spoonId = rawSpoonId as Id<'spoons'>;
|
||||
const [segment, setSegment] = useState<SpoonDetailSegment>('overview');
|
||||
const [threadPrompt, setThreadPrompt] = useState('');
|
||||
const [pending, setPending] = useState<string | undefined>();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const details = useQuery(api.spoons.getDetails, { spoonId });
|
||||
const upstreamCommits =
|
||||
useQuery(api.spoonCommits.listForSpoon, {
|
||||
limit: 50,
|
||||
side: 'upstream',
|
||||
spoonId,
|
||||
}) ?? [];
|
||||
const forkCommits =
|
||||
useQuery(api.spoonCommits.listForSpoon, {
|
||||
limit: 50,
|
||||
side: 'fork',
|
||||
spoonId,
|
||||
}) ?? [];
|
||||
const pullRequests =
|
||||
useQuery(api.spoonPullRequests.listForSpoon, { limit: 50, spoonId }) ?? [];
|
||||
const remotes = useQuery(api.spoonRemotes.listForSpoon, { spoonId }) ?? [];
|
||||
const threads =
|
||||
useQuery(api.threads.listForSpoon, { limit: 25, spoonId }) ?? [];
|
||||
const spoonSettings = useQuery(api.spoonSettings.getForSpoon, { spoonId });
|
||||
const agentSettings = useQuery(api.spoonAgentSettings.getForSpoon, {
|
||||
spoonId,
|
||||
});
|
||||
const secrets = useQuery(api.spoonSecrets.listForSpoon, { spoonId }) ?? [];
|
||||
const providerProfiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||
|
||||
const refresh = useAction(api.githubSync.refreshSpoonGithubState);
|
||||
const sync = useAction(api.githubSync.syncForkWithUpstream);
|
||||
const updateSpoonSettings = useMutation(api.spoons.updateSettings);
|
||||
const updateMaintenanceSettings = useMutation(api.spoonSettings.update);
|
||||
const updateAgentSettings = useMutation(api.spoonAgentSettings.update);
|
||||
const createThread = useMutation(api.threads.createUserThread);
|
||||
const createSecret = useAction(api.spoonSecretsNode.create);
|
||||
const removeSecretMutation = useMutation(api.spoonSecrets.remove);
|
||||
const createRemote = useMutation(api.spoonRemotes.create);
|
||||
const removeRemoteMutation = useMutation(api.spoonRemotes.remove);
|
||||
|
||||
const runRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
setPending('refresh');
|
||||
try {
|
||||
await refresh({ spoonId });
|
||||
Alert.alert('Refresh started', 'Spoon is checking GitHub state.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Could not refresh this Spoon.');
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
setPending(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const runSync = async () => {
|
||||
setPending('sync');
|
||||
try {
|
||||
await sync({ spoonId });
|
||||
Alert.alert('Sync started', 'Spoon is syncing the fork.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Could not sync this Spoon.');
|
||||
} finally {
|
||||
setPending(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const submitThread = async () => {
|
||||
if (!threadPrompt.trim()) return;
|
||||
setPending('thread');
|
||||
try {
|
||||
const threadId = await createThread({
|
||||
envFilePath:
|
||||
agentSettings?.envFilePath === 'custom'
|
||||
? agentSettings.customEnvFilePath
|
||||
: agentSettings?.envFilePath,
|
||||
materializeEnvFile: agentSettings?.materializeEnvFileByDefault,
|
||||
prompt: threadPrompt,
|
||||
spoonId,
|
||||
});
|
||||
setThreadPrompt('');
|
||||
router.push(`/threads/${threadId}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Could not create thread.');
|
||||
} finally {
|
||||
setPending(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
if (!details) {
|
||||
return (
|
||||
<AppScreen>
|
||||
<Stack.Screen options={{ title: 'Spoon' }} />
|
||||
<Text className='text-muted-foreground'>Loading Spoon...</Text>
|
||||
</AppScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const { effectiveUpstreamAheadBy, spoon } = details;
|
||||
const canSync =
|
||||
spoon.provider === 'github' &&
|
||||
(spoon.syncStatus === 'behind' || spoon.syncStatus === 'up_to_date') &&
|
||||
(spoon.forkAheadBy ?? 0) === 0;
|
||||
|
||||
const settingsActions = {
|
||||
addRemote: async (label: string, url: string) => {
|
||||
setPending('addRemote');
|
||||
try {
|
||||
await createRemote({ label, spoonId, url });
|
||||
} finally {
|
||||
setPending(undefined);
|
||||
}
|
||||
},
|
||||
addSecret: async (name: string, value: string) => {
|
||||
setPending('addSecret');
|
||||
try {
|
||||
await createSecret({ name, spoonId, value });
|
||||
} finally {
|
||||
setPending(undefined);
|
||||
}
|
||||
},
|
||||
importSecrets: async (items: { name: string; value: string }[]) => {
|
||||
setPending('importSecrets');
|
||||
let failed = 0;
|
||||
try {
|
||||
for (const item of items) {
|
||||
try {
|
||||
await createSecret({ name: item.name, spoonId, value: item.value });
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
if (failed > 0) {
|
||||
throw new Error(
|
||||
`${items.length - failed} imported, ${failed} failed.`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setPending(undefined);
|
||||
}
|
||||
},
|
||||
removeRemote: async (remoteId: string) => {
|
||||
setPending(`remote:${remoteId}`);
|
||||
try {
|
||||
await removeRemoteMutation({
|
||||
remoteId: remoteId as Id<'spoonRemotes'>,
|
||||
});
|
||||
} finally {
|
||||
setPending(undefined);
|
||||
}
|
||||
},
|
||||
removeSecret: async (secretId: string) => {
|
||||
setPending(`secret:${secretId}`);
|
||||
try {
|
||||
await removeSecretMutation({
|
||||
secretId: secretId as Id<'spoonSecrets'>,
|
||||
});
|
||||
} finally {
|
||||
setPending(undefined);
|
||||
}
|
||||
},
|
||||
updateAgent: async (patch: Record<string, unknown>) => {
|
||||
setPending('settings');
|
||||
try {
|
||||
await updateAgentSettings({ spoonId, ...patch });
|
||||
} finally {
|
||||
setPending(undefined);
|
||||
}
|
||||
},
|
||||
updateMaintenance: async (patch: Record<string, unknown>) => {
|
||||
setPending('settings');
|
||||
try {
|
||||
await updateMaintenanceSettings({ spoonId, ...patch });
|
||||
} finally {
|
||||
setPending(undefined);
|
||||
}
|
||||
},
|
||||
updateSpoon: async (patch: Record<string, unknown>) => {
|
||||
setPending('settings');
|
||||
try {
|
||||
await updateSpoonSettings({ spoonId, ...patch });
|
||||
} finally {
|
||||
setPending(undefined);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<AppScreen onRefresh={() => void runRefresh()} refreshing={refreshing}>
|
||||
<Stack.Screen options={{ title: spoon.name }} />
|
||||
<View className='gap-2'>
|
||||
<Text className='text-foreground text-3xl font-bold'>{spoon.name}</Text>
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
<SpoonStatusBadge status={spoon.syncStatus ?? spoon.status} />
|
||||
<SpoonStatusBadge status={spoon.status} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='flex-row gap-3'>
|
||||
<Button
|
||||
disabled={pending === 'refresh'}
|
||||
onPress={() => void runRefresh()}
|
||||
>
|
||||
{pending === 'refresh' ? 'Refreshing...' : 'Refresh'}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canSync || pending === 'sync'}
|
||||
variant='outline'
|
||||
onPress={() => void runSync()}
|
||||
>
|
||||
{pending === 'sync' ? 'Syncing...' : 'Sync fork'}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<SegmentControl value={segment} onChange={setSegment} />
|
||||
|
||||
{segment === 'overview' ? (
|
||||
<SpoonDetailOverview
|
||||
effectiveUpstreamAheadBy={effectiveUpstreamAheadBy}
|
||||
remotes={remotes}
|
||||
spoon={spoon}
|
||||
/>
|
||||
) : null}
|
||||
{segment === 'upstream' ? (
|
||||
<SpoonDetailUpstream commits={upstreamCommits} />
|
||||
) : null}
|
||||
{segment === 'fork' ? <SpoonDetailFork commits={forkCommits} /> : null}
|
||||
{segment === 'prs' ? (
|
||||
<SpoonDetailPrs pullRequests={pullRequests} />
|
||||
) : null}
|
||||
{segment === 'threads' ? (
|
||||
<SpoonDetailThreads
|
||||
creating={pending === 'thread'}
|
||||
prompt={threadPrompt}
|
||||
setPrompt={setThreadPrompt}
|
||||
threads={threads}
|
||||
onCreate={() => void submitThread()}
|
||||
onOpenThread={(threadId) => router.push(`/threads/${threadId}`)}
|
||||
/>
|
||||
) : null}
|
||||
{segment === 'settings' ? (
|
||||
<SpoonDetailSettings
|
||||
actions={settingsActions}
|
||||
agentSettings={agentSettings ?? undefined}
|
||||
maintenanceSettings={spoonSettings ?? undefined}
|
||||
pending={{
|
||||
addingRemote: pending === 'addRemote',
|
||||
addingSecret: pending === 'addSecret',
|
||||
importingSecrets: pending === 'importSecrets',
|
||||
removingRemoteId: pending?.startsWith('remote:')
|
||||
? pending.slice('remote:'.length)
|
||||
: undefined,
|
||||
removingSecretId: pending?.startsWith('secret:')
|
||||
? pending.slice('secret:'.length)
|
||||
: undefined,
|
||||
savingSettings: pending === 'settings',
|
||||
}}
|
||||
providerProfiles={providerProfiles}
|
||||
remotes={remotes}
|
||||
secrets={secrets}
|
||||
spoon={spoon}
|
||||
/>
|
||||
) : null}
|
||||
</AppScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpoonDetailRoute;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
const SpoonsLayout = () => <Stack screenOptions={{ headerShown: false }} />;
|
||||
|
||||
export default SpoonsLayout;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
import { Link, Stack, useRouter } from 'expo-router';
|
||||
import { useQuery } from 'convex/react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import { SpoonListRow } from '~/components/spoons/spoon-list-row';
|
||||
import { AppScreen } from '~/components/ui/app-screen';
|
||||
import { Button } from '~/components/ui/button';
|
||||
import { EmptyState } from '~/components/ui/empty-state';
|
||||
import { MetricCard } from '~/components/ui/metric-card';
|
||||
|
||||
const openThreadStatuses = ['resolved', 'ignored', 'failed', 'cancelled'];
|
||||
|
||||
const SpoonsRoute = () => {
|
||||
const router = useRouter();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
|
||||
const active = spoons.filter((spoon) => spoon.status === 'active').length;
|
||||
const upstreamWaiting = spoons.reduce(
|
||||
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const openThreadsFor = (spoonId: string) =>
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.spoonId === spoonId &&
|
||||
!openThreadStatuses.includes(thread.status),
|
||||
).length;
|
||||
|
||||
const softRefresh = () => {
|
||||
setRefreshing(true);
|
||||
setTimeout(() => setRefreshing(false), 600);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
|
||||
<Stack.Screen options={{ title: 'Spoons' }} />
|
||||
<View className='flex-row items-start justify-between gap-3'>
|
||||
<View className='min-w-0 flex-1'>
|
||||
<Text className='text-foreground text-3xl font-bold'>Spoons</Text>
|
||||
<Text className='text-muted-foreground mt-1'>
|
||||
Managed forks and their relationship with upstream.
|
||||
</Text>
|
||||
</View>
|
||||
<Link href='/spoons/new' asChild>
|
||||
<Button>New</Button>
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
<View className='flex-row gap-3'>
|
||||
<MetricCard label='Managed' value={spoons.length} />
|
||||
<MetricCard label='Active' value={active} />
|
||||
<MetricCard label='Waiting' value={upstreamWaiting} />
|
||||
</View>
|
||||
|
||||
<View className='gap-3'>
|
||||
{spoons.length ? (
|
||||
spoons.map((spoon) => (
|
||||
<SpoonListRow
|
||||
key={spoon._id}
|
||||
openThreads={openThreadsFor(spoon._id)}
|
||||
spoon={spoon}
|
||||
onPress={() => router.push(`/spoons/${spoon._id}`)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<EmptyState
|
||||
description='Create a manual Spoon record to start shaping fork maintenance.'
|
||||
title='No managed forks yet'
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</AppScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpoonsRoute;
|
||||
@@ -0,0 +1,396 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Linking, Text, View } from 'react-native';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import { AppScreen } from '~/components/ui/app-screen';
|
||||
import { Badge } from '~/components/ui/badge';
|
||||
import { Button } from '~/components/ui/button';
|
||||
import { Card } from '~/components/ui/card';
|
||||
import { Field } from '~/components/ui/field';
|
||||
import { FormSection } from '~/components/ui/form-section';
|
||||
import { PillTabs } from '~/components/ui/pill-tabs';
|
||||
import { SheetSelect } from '~/components/ui/sheet-select';
|
||||
|
||||
type CreateMode = 'manual' | 'github';
|
||||
type Provider = 'github' | 'gitea' | 'gitlab' | 'other';
|
||||
type Visibility = 'public' | 'private' | 'internal' | 'unknown';
|
||||
type MaintenanceMode = 'watch' | 'auto_pr' | 'paused';
|
||||
type SyncCadence = 'daily' | 'weekly' | 'manual';
|
||||
type ProductionRefStrategy =
|
||||
| 'default_branch'
|
||||
| 'latest_release'
|
||||
| 'tag_pattern';
|
||||
|
||||
type Repository = Awaited<
|
||||
ReturnType<
|
||||
ReturnType<
|
||||
typeof useAction<typeof api.githubNode.listInstallationRepositories>
|
||||
>
|
||||
>
|
||||
>[number];
|
||||
|
||||
const NewSpoonRoute = () => {
|
||||
const router = useRouter();
|
||||
const createManual = useMutation(api.spoons.createManual);
|
||||
const syncInstallation = useAction(api.githubNode.syncConfiguredInstallation);
|
||||
const listRepositories = useAction(
|
||||
api.githubNode.listInstallationRepositories,
|
||||
);
|
||||
const installUrl = useQuery(api.github.getInstallUrl, {});
|
||||
const connection = useQuery(api.github.getConnection, {});
|
||||
const [mode, setMode] = useState<CreateMode>('manual');
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [provider, setProvider] = useState<Provider>('github');
|
||||
const [upstreamOwner, setUpstreamOwner] = useState('');
|
||||
const [upstreamRepo, setUpstreamRepo] = useState('');
|
||||
const [upstreamDefaultBranch, setUpstreamDefaultBranch] = useState('main');
|
||||
const [upstreamUrl, setUpstreamUrl] = useState('');
|
||||
const [forkOwner, setForkOwner] = useState('');
|
||||
const [forkRepo, setForkRepo] = useState('');
|
||||
const [forkDefaultBranch, setForkDefaultBranch] = useState('main');
|
||||
const [forkUrl, setForkUrl] = useState('');
|
||||
const [visibility, setVisibility] = useState<Visibility>('unknown');
|
||||
const [maintenanceMode, setMaintenanceMode] =
|
||||
useState<MaintenanceMode>('watch');
|
||||
const [syncCadence, setSyncCadence] = useState<SyncCadence>('daily');
|
||||
const [productionRefStrategy, setProductionRefStrategy] =
|
||||
useState<ProductionRefStrategy>('default_branch');
|
||||
const [tagPattern, setTagPattern] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loadingRepos, setLoadingRepos] = useState(false);
|
||||
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||
|
||||
const submitManual = async () => {
|
||||
if (!name || !upstreamOwner || !upstreamRepo || !upstreamUrl) {
|
||||
Alert.alert('Missing fields', 'Name and upstream metadata are required.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const spoonId = await createManual({
|
||||
description: description || undefined,
|
||||
forkDefaultBranch: forkDefaultBranch || undefined,
|
||||
forkOwner: forkOwner || undefined,
|
||||
forkRepo: forkRepo || undefined,
|
||||
forkUrl: forkUrl || undefined,
|
||||
maintenanceMode,
|
||||
name,
|
||||
productionRefStrategy,
|
||||
provider,
|
||||
syncCadence,
|
||||
tagPattern: tagPattern || undefined,
|
||||
upstreamDefaultBranch,
|
||||
upstreamOwner,
|
||||
upstreamRepo,
|
||||
upstreamUrl,
|
||||
visibility,
|
||||
});
|
||||
router.replace(`/spoons/${spoonId}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Could not create Spoon', 'Check the fields and try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRepos = async () => {
|
||||
setLoadingRepos(true);
|
||||
try {
|
||||
const result = await listRepositories({});
|
||||
setRepositories(result);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Could not list repositories.');
|
||||
} finally {
|
||||
setLoadingRepos(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createFromRepo = async (repo: Repository) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const upstreamOwnerValue = upstreamOwner.trim() || repo.owner;
|
||||
const upstreamRepoValue = upstreamRepo.trim() || repo.name;
|
||||
const upstreamUrlValue = upstreamUrl.trim() || repo.url;
|
||||
const spoonId = await createManual({
|
||||
forkDefaultBranch: repo.defaultBranch,
|
||||
forkOwner: repo.owner,
|
||||
forkRepo: repo.name,
|
||||
forkUrl: repo.url,
|
||||
maintenanceMode: 'watch',
|
||||
name: repo.name,
|
||||
productionRefStrategy: 'default_branch',
|
||||
provider: 'github',
|
||||
syncCadence: 'daily',
|
||||
upstreamDefaultBranch: repo.defaultBranch,
|
||||
upstreamOwner: upstreamOwnerValue,
|
||||
upstreamRepo: upstreamRepoValue,
|
||||
upstreamUrl: upstreamUrlValue,
|
||||
visibility: repo.private ? 'private' : 'public',
|
||||
});
|
||||
router.replace(`/spoons/${spoonId}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Could not create Spoon from repository.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmCreateFromRepo = (repo: Repository) => {
|
||||
const message = repo.fork
|
||||
? 'GitHub did not provide parent repository metadata here. Add upstream fields above if you want Spoon to compare against the original project immediately.'
|
||||
: 'This will create a manual Spoon record using this repository as both upstream and fork unless you add upstream fields above.';
|
||||
|
||||
Alert.alert('Create Spoon from repository metadata?', message, [
|
||||
{ style: 'cancel', text: 'Cancel' },
|
||||
{
|
||||
onPress: () => void createFromRepo(repo),
|
||||
text: 'Create Spoon',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const syncGithub = async () => {
|
||||
setLoadingRepos(true);
|
||||
try {
|
||||
await syncInstallation({});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Could not sync GitHub installation.');
|
||||
} finally {
|
||||
setLoadingRepos(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppScreen>
|
||||
<Stack.Screen options={{ title: 'New Spoon' }} />
|
||||
<View>
|
||||
<Text className='text-foreground text-3xl font-bold'>New Spoon</Text>
|
||||
<Text className='text-muted-foreground mt-1'>
|
||||
Create a managed fork record manually or from GitHub.
|
||||
</Text>
|
||||
</View>
|
||||
<PillTabs
|
||||
tabs={[
|
||||
{ label: 'Manual', value: 'manual' },
|
||||
{ label: 'GitHub', value: 'github' },
|
||||
]}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
/>
|
||||
|
||||
{mode === 'manual' ? (
|
||||
<>
|
||||
<FormSection title='Basics'>
|
||||
<Field label='Spoon name' value={name} onChangeText={setName} />
|
||||
<Field
|
||||
label='Description'
|
||||
multiline
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
/>
|
||||
<SheetSelect
|
||||
label='Git provider'
|
||||
options={[
|
||||
{ label: 'GitHub', value: 'github' },
|
||||
{ label: 'Gitea', value: 'gitea' },
|
||||
{ label: 'GitLab', value: 'gitlab' },
|
||||
{ label: 'Other', value: 'other' },
|
||||
]}
|
||||
value={provider}
|
||||
onChange={setProvider}
|
||||
/>
|
||||
</FormSection>
|
||||
<FormSection title='Upstream'>
|
||||
<Field
|
||||
label='Owner/org'
|
||||
value={upstreamOwner}
|
||||
onChangeText={setUpstreamOwner}
|
||||
/>
|
||||
<Field
|
||||
label='Repository'
|
||||
value={upstreamRepo}
|
||||
onChangeText={setUpstreamRepo}
|
||||
/>
|
||||
<Field
|
||||
label='Default branch'
|
||||
value={upstreamDefaultBranch}
|
||||
onChangeText={setUpstreamDefaultBranch}
|
||||
/>
|
||||
<Field
|
||||
keyboardType='url'
|
||||
label='Upstream URL'
|
||||
value={upstreamUrl}
|
||||
onChangeText={setUpstreamUrl}
|
||||
/>
|
||||
</FormSection>
|
||||
<FormSection title='Fork'>
|
||||
<Field
|
||||
label='Owner/org'
|
||||
value={forkOwner}
|
||||
onChangeText={setForkOwner}
|
||||
/>
|
||||
<Field
|
||||
label='Repository'
|
||||
value={forkRepo}
|
||||
onChangeText={setForkRepo}
|
||||
/>
|
||||
<Field
|
||||
label='Default branch'
|
||||
value={forkDefaultBranch}
|
||||
onChangeText={setForkDefaultBranch}
|
||||
/>
|
||||
<Field
|
||||
keyboardType='url'
|
||||
label='Fork URL'
|
||||
value={forkUrl}
|
||||
onChangeText={setForkUrl}
|
||||
/>
|
||||
</FormSection>
|
||||
<FormSection title='Maintenance'>
|
||||
<SheetSelect
|
||||
label='Visibility'
|
||||
options={[
|
||||
{ label: 'Unknown', value: 'unknown' },
|
||||
{ label: 'Public', value: 'public' },
|
||||
{ label: 'Private', value: 'private' },
|
||||
{ label: 'Internal', value: 'internal' },
|
||||
]}
|
||||
value={visibility}
|
||||
onChange={setVisibility}
|
||||
/>
|
||||
<SheetSelect
|
||||
label='Maintenance mode'
|
||||
options={[
|
||||
{ label: 'Watch', value: 'watch' },
|
||||
{ label: 'Auto PR', value: 'auto_pr' },
|
||||
{ label: 'Paused', value: 'paused' },
|
||||
]}
|
||||
value={maintenanceMode}
|
||||
onChange={setMaintenanceMode}
|
||||
/>
|
||||
<SheetSelect
|
||||
label='Sync cadence'
|
||||
options={[
|
||||
{ label: 'Daily', value: 'daily' },
|
||||
{ label: 'Weekly', value: 'weekly' },
|
||||
{ label: 'Manual', value: 'manual' },
|
||||
]}
|
||||
value={syncCadence}
|
||||
onChange={setSyncCadence}
|
||||
/>
|
||||
<SheetSelect
|
||||
label='Production ref'
|
||||
options={[
|
||||
{ label: 'Default branch', value: 'default_branch' },
|
||||
{ label: 'Latest release', value: 'latest_release' },
|
||||
{ label: 'Tag pattern', value: 'tag_pattern' },
|
||||
]}
|
||||
value={productionRefStrategy}
|
||||
onChange={setProductionRefStrategy}
|
||||
/>
|
||||
{productionRefStrategy === 'tag_pattern' ? (
|
||||
<Field
|
||||
label='Tag pattern'
|
||||
value={tagPattern}
|
||||
onChangeText={setTagPattern}
|
||||
/>
|
||||
) : null}
|
||||
</FormSection>
|
||||
<Button disabled={submitting} onPress={() => void submitManual()}>
|
||||
{submitting ? 'Creating...' : 'Create Spoon'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<FormSection
|
||||
description='Repository listing is read from the GitHub App installation.'
|
||||
title='GitHub'
|
||||
>
|
||||
<View className='flex-row items-center justify-between'>
|
||||
<Text className='text-foreground font-medium'>Connection</Text>
|
||||
<Badge
|
||||
label={connection?.status ?? 'not connected'}
|
||||
tone={connection ? 'success' : 'warning'}
|
||||
/>
|
||||
</View>
|
||||
{installUrl ? (
|
||||
<Button onPress={() => void Linking.openURL(installUrl)}>
|
||||
Install or manage GitHub App
|
||||
</Button>
|
||||
) : null}
|
||||
<View className='flex-row gap-3'>
|
||||
<Button
|
||||
disabled={loadingRepos}
|
||||
variant='outline'
|
||||
onPress={() => void syncGithub()}
|
||||
>
|
||||
Sync
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!connection || loadingRepos}
|
||||
onPress={() => void loadRepos()}
|
||||
>
|
||||
{loadingRepos ? 'Loading...' : 'Load repositories'}
|
||||
</Button>
|
||||
</View>
|
||||
<Text className='text-muted-foreground text-sm leading-5'>
|
||||
Optional upstream fields are used when the selected repository is a
|
||||
fork. If you leave them blank, Spoon tracks the selected repository
|
||||
as both upstream and fork until you correct it later.
|
||||
</Text>
|
||||
<Field
|
||||
label='Upstream owner/org'
|
||||
value={upstreamOwner}
|
||||
onChangeText={setUpstreamOwner}
|
||||
/>
|
||||
<Field
|
||||
label='Upstream repository'
|
||||
value={upstreamRepo}
|
||||
onChangeText={setUpstreamRepo}
|
||||
/>
|
||||
<Field
|
||||
keyboardType='url'
|
||||
label='Upstream URL'
|
||||
value={upstreamUrl}
|
||||
onChangeText={setUpstreamUrl}
|
||||
/>
|
||||
{!loadingRepos && connection && repositories.length === 0 ? (
|
||||
<Card>
|
||||
<Text className='text-muted-foreground text-sm leading-5'>
|
||||
Load accessible repositories to create a Spoon from GitHub
|
||||
metadata.
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
{repositories.map((repo) => (
|
||||
<Card key={repo.id} className='gap-2'>
|
||||
<Text className='text-foreground font-semibold'>
|
||||
{repo.fullName}
|
||||
</Text>
|
||||
<Text className='text-muted-foreground text-xs'>
|
||||
{repo.private ? 'Private' : 'Public'} ·{' '}
|
||||
{repo.fork ? 'Fork' : 'Repository'} · {repo.defaultBranch}
|
||||
</Text>
|
||||
<Button
|
||||
disabled={submitting}
|
||||
variant='outline'
|
||||
onPress={() => confirmCreateFromRepo(repo)}
|
||||
>
|
||||
Create Spoon from metadata
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</FormSection>
|
||||
)}
|
||||
</AppScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewSpoonRoute;
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Linking, Text, View } from 'react-native';
|
||||
import { Link, Stack, useLocalSearchParams } from 'expo-router';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import { ThreadMessageList } from '~/components/threads/thread-message-list';
|
||||
import { ThreadStatusBadge } from '~/components/threads/thread-status-badge';
|
||||
import { AppScreen } from '~/components/ui/app-screen';
|
||||
import { Badge } from '~/components/ui/badge';
|
||||
import { Button } from '~/components/ui/button';
|
||||
import { Card } from '~/components/ui/card';
|
||||
import { ConfirmButton } from '~/components/ui/confirm-button';
|
||||
import { Field } from '~/components/ui/field';
|
||||
import { formatDateTime, titleize } from '~/utils/format';
|
||||
|
||||
const ThreadDetailRoute = () => {
|
||||
const { threadId: rawThreadId } = useLocalSearchParams<{
|
||||
threadId: string;
|
||||
}>();
|
||||
const threadId = rawThreadId as Id<'threads'>;
|
||||
const details = useQuery(api.threads.get, { threadId });
|
||||
const messages = useQuery(api.threads.listMessages, { threadId }) ?? [];
|
||||
const appendMessage = useMutation(api.threads.appendUserMessage);
|
||||
const markResolved = useMutation(api.threads.markResolved);
|
||||
const cancel = useMutation(api.threads.cancel);
|
||||
const [message, setMessage] = useState('');
|
||||
const [pending, setPending] = useState<string | undefined>();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const send = async () => {
|
||||
if (!message.trim()) return;
|
||||
setPending('send');
|
||||
try {
|
||||
await appendMessage({ threadId, content: message });
|
||||
setMessage('');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Could not send message.');
|
||||
} finally {
|
||||
setPending(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const softRefresh = () => {
|
||||
setRefreshing(true);
|
||||
setTimeout(() => setRefreshing(false), 600);
|
||||
};
|
||||
|
||||
const resolveThread = async () => {
|
||||
setPending('resolve');
|
||||
try {
|
||||
await markResolved({ threadId });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Could not resolve thread.');
|
||||
} finally {
|
||||
setPending(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelThread = async () => {
|
||||
setPending('cancel');
|
||||
try {
|
||||
await cancel({ threadId });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Could not cancel thread.');
|
||||
} finally {
|
||||
setPending(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
if (!details) {
|
||||
return (
|
||||
<AppScreen>
|
||||
<Stack.Screen options={{ title: 'Thread' }} />
|
||||
<Text className='text-muted-foreground'>Loading thread...</Text>
|
||||
</AppScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const { thread, spoon, latestJob } = details;
|
||||
const pullRequestUrl = latestJob?.pullRequestUrl;
|
||||
const completed = ['resolved', 'ignored', 'failed', 'cancelled'].includes(
|
||||
thread.status,
|
||||
);
|
||||
|
||||
return (
|
||||
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
|
||||
<Stack.Screen options={{ title: thread.title }} />
|
||||
<View className='gap-2'>
|
||||
<Text className='text-foreground text-3xl font-bold'>
|
||||
{thread.title}
|
||||
</Text>
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
<ThreadStatusBadge status={thread.status} />
|
||||
<Badge label={titleize(thread.source)} />
|
||||
{thread.maintenanceOutcome ? (
|
||||
<Badge label={titleize(thread.maintenanceOutcome)} tone='primary' />
|
||||
) : null}
|
||||
</View>
|
||||
<Text className='text-muted-foreground text-sm'>
|
||||
Updated {formatDateTime(thread.updatedAt)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{spoon ? (
|
||||
<Card>
|
||||
<Text className='text-muted-foreground text-xs'>Spoon</Text>
|
||||
<Text className='text-foreground mt-1 font-semibold'>
|
||||
{spoon.name}
|
||||
</Text>
|
||||
<Link href={`/spoons/${spoon._id}`} asChild>
|
||||
<Button variant='outline'>Open Spoon</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{latestJob ? (
|
||||
<Card className='gap-3'>
|
||||
<Text className='text-foreground font-semibold'>Latest job</Text>
|
||||
<Text className='text-muted-foreground text-sm'>
|
||||
{titleize(latestJob.status)} · {titleize(latestJob.workspaceStatus)}
|
||||
</Text>
|
||||
<Text className='text-muted-foreground text-sm'>
|
||||
Branch: {latestJob.workBranch}
|
||||
</Text>
|
||||
<Link href={`/workspace/${latestJob._id}`} asChild>
|
||||
<Button variant='outline'>Open workspace review</Button>
|
||||
</Link>
|
||||
{pullRequestUrl ? (
|
||||
<Button onPress={() => void Linking.openURL(pullRequestUrl)}>
|
||||
Open draft PR
|
||||
</Button>
|
||||
) : null}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<ThreadMessageList messages={messages} />
|
||||
|
||||
<Card className='gap-3'>
|
||||
<Text className='text-foreground font-semibold'>Reply</Text>
|
||||
<Field
|
||||
label='Message'
|
||||
multiline
|
||||
value={message}
|
||||
onChangeText={setMessage}
|
||||
/>
|
||||
<Button
|
||||
disabled={completed || pending === 'send'}
|
||||
onPress={() => void send()}
|
||||
>
|
||||
{pending === 'send' ? 'Sending...' : 'Send message'}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<View className='flex-row gap-3'>
|
||||
<Button
|
||||
disabled={completed || pending === 'resolve'}
|
||||
variant='outline'
|
||||
onPress={() => void resolveThread()}
|
||||
>
|
||||
{pending === 'resolve' ? 'Resolving...' : 'Resolve'}
|
||||
</Button>
|
||||
<ConfirmButton
|
||||
confirmLabel='Cancel thread'
|
||||
destructive
|
||||
disabled={completed || pending === 'cancel'}
|
||||
message='Cancel this thread?'
|
||||
title='Cancel thread'
|
||||
onConfirm={() => void cancelThread()}
|
||||
>
|
||||
{pending === 'cancel' ? 'Cancelling...' : 'Cancel'}
|
||||
</ConfirmButton>
|
||||
</View>
|
||||
</AppScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThreadDetailRoute;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
const ThreadsLayout = () => <Stack screenOptions={{ headerShown: false }} />;
|
||||
|
||||
export default ThreadsLayout;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { useQuery } from 'convex/react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import type { PillTab } from '~/components/ui/pill-tabs';
|
||||
import { ThreadListRow } from '~/components/threads/thread-list-row';
|
||||
import { AppScreen } from '~/components/ui/app-screen';
|
||||
import { EmptyState } from '~/components/ui/empty-state';
|
||||
import { PillTabs } from '~/components/ui/pill-tabs';
|
||||
|
||||
type StatusFilter =
|
||||
| 'all'
|
||||
| 'open'
|
||||
| 'running'
|
||||
| 'waiting_for_user'
|
||||
| 'resolved';
|
||||
|
||||
const filters: PillTab<StatusFilter>[] = [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Open', value: 'open' },
|
||||
{ label: 'Running', value: 'running' },
|
||||
{ label: 'Waiting', value: 'waiting_for_user' },
|
||||
{ label: 'Resolved', value: 'resolved' },
|
||||
];
|
||||
|
||||
const ThreadsRoute = () => {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<StatusFilter>('all');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const threads =
|
||||
useQuery(api.threads.listMine, {
|
||||
limit: 50,
|
||||
status,
|
||||
}) ?? [];
|
||||
|
||||
const softRefresh = () => {
|
||||
setRefreshing(true);
|
||||
setTimeout(() => setRefreshing(false), 600);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
|
||||
<Stack.Screen options={{ title: 'Threads' }} />
|
||||
<View>
|
||||
<Text className='text-foreground text-3xl font-bold'>Threads</Text>
|
||||
<Text className='text-muted-foreground mt-1'>
|
||||
Maintenance decisions, user requests, and workspace handoffs.
|
||||
</Text>
|
||||
</View>
|
||||
<PillTabs onChange={setStatus} tabs={filters} value={status} />
|
||||
<View className='gap-3'>
|
||||
{threads.length ? (
|
||||
threads.map((thread) => (
|
||||
<ThreadListRow
|
||||
key={thread._id}
|
||||
thread={thread}
|
||||
onPress={() => router.push(`/threads/${thread._id}`)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<EmptyState
|
||||
description='Threads appear when you ask Spoon to change a fork or upstream changes need review.'
|
||||
title='No threads'
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</AppScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThreadsRoute;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Text, View } from 'react-native';
|
||||
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import type { PillTab } from '~/components/ui/pill-tabs';
|
||||
import { AppScreen } from '~/components/ui/app-screen';
|
||||
import { PillTabs } from '~/components/ui/pill-tabs';
|
||||
import { WorkspaceArtifacts } from '~/components/workspace/workspace-artifacts';
|
||||
import { WorkspaceEvents } from '~/components/workspace/workspace-events';
|
||||
import { WorkspaceMessages } from '~/components/workspace/workspace-messages';
|
||||
import { WorkspaceSummary } from '~/components/workspace/workspace-summary';
|
||||
|
||||
type WorkspaceTab = 'status' | 'messages' | 'diffs' | 'events' | 'artifacts';
|
||||
|
||||
const tabs: PillTab<WorkspaceTab>[] = [
|
||||
{ label: 'Status', value: 'status' },
|
||||
{ label: 'Messages', value: 'messages' },
|
||||
{ label: 'Diffs', value: 'diffs' },
|
||||
{ label: 'Events', value: 'events' },
|
||||
{ label: 'Artifacts', value: 'artifacts' },
|
||||
];
|
||||
|
||||
const WorkspaceRoute = () => {
|
||||
const { jobId: rawJobId } = useLocalSearchParams<{ jobId: string }>();
|
||||
const jobId = rawJobId as Id<'agentJobs'>;
|
||||
const [tab, setTab] = useState<WorkspaceTab>('status');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const job = useQuery(api.agentJobs.get, { jobId });
|
||||
const messages = useQuery(api.agentJobs.listMessages, { jobId }) ?? [];
|
||||
const events =
|
||||
useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
|
||||
const artifacts = useQuery(api.agentJobs.listArtifacts, { jobId }) ?? [];
|
||||
const cancel = useMutation(api.agentJobs.cancel);
|
||||
|
||||
const softRefresh = () => {
|
||||
setRefreshing(true);
|
||||
setTimeout(() => setRefreshing(false), 600);
|
||||
};
|
||||
|
||||
const cancelJob = async () => {
|
||||
setCancelling(true);
|
||||
try {
|
||||
await cancel({ jobId });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Could not cancel job.');
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!job) {
|
||||
return (
|
||||
<AppScreen>
|
||||
<Stack.Screen options={{ title: 'Workspace' }} />
|
||||
<Text className='text-muted-foreground'>Loading workspace...</Text>
|
||||
</AppScreen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
|
||||
<Stack.Screen options={{ title: 'Workspace' }} />
|
||||
<View className='gap-2'>
|
||||
<Text className='text-foreground text-3xl font-bold'>
|
||||
Workspace review
|
||||
</Text>
|
||||
<Text className='text-muted-foreground'>
|
||||
Inspect the active job without exposing worker internals to mobile.
|
||||
</Text>
|
||||
</View>
|
||||
<PillTabs onChange={setTab} tabs={tabs} value={tab} />
|
||||
{tab === 'status' ? (
|
||||
<WorkspaceSummary
|
||||
cancelling={cancelling}
|
||||
job={job}
|
||||
onCancel={() => void cancelJob()}
|
||||
/>
|
||||
) : null}
|
||||
{tab === 'messages' ? <WorkspaceMessages messages={messages} /> : null}
|
||||
{tab === 'diffs' ? (
|
||||
<WorkspaceArtifacts artifacts={artifacts} mode='diffs' />
|
||||
) : null}
|
||||
{tab === 'events' ? <WorkspaceEvents events={events} /> : null}
|
||||
{tab === 'artifacts' ? (
|
||||
<WorkspaceArtifacts artifacts={artifacts} mode='artifacts' />
|
||||
) : null}
|
||||
</AppScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceRoute;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
const WorkspaceLayout = () => <Stack screenOptions={{ headerShown: false }} />;
|
||||
|
||||
export default WorkspaceLayout;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
import { SignInScreen } from '~/components/auth/sign-in-screen';
|
||||
|
||||
const SignInRoute = () => (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Sign in' }} />
|
||||
<SignInScreen />
|
||||
</>
|
||||
);
|
||||
|
||||
export default SignInRoute;
|
||||
+17
-166
@@ -1,177 +1,28 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Alert, Pressable, Text, TextInput, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import * as Linking from 'expo-linking';
|
||||
import { Stack } from 'expo-router';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { useConvexAuth, useQuery } from 'convex/react';
|
||||
import { useEffect } from 'react';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { useConvexAuth } from 'convex/react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { LoadingState } from '~/components/ui/loading-state';
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
const Stat = ({ label, value }: { label: string; value: number }) => (
|
||||
<View className='border-border bg-card flex-1 rounded-lg border p-4'>
|
||||
<Text className='text-muted-foreground text-xs'>{label}</Text>
|
||||
<Text className='text-foreground mt-2 text-2xl font-bold'>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const Index = () => {
|
||||
const IndexRoute = () => {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||
const { signIn, signOut } = useAuthActions();
|
||||
const user = useQuery(api.auth.getUser, isAuthenticated ? {} : 'skip');
|
||||
const spoons =
|
||||
useQuery(api.spoons.listMine, isAuthenticated ? {} : 'skip') ?? [];
|
||||
const syncRuns =
|
||||
useQuery(
|
||||
api.syncRuns.listRecent,
|
||||
isAuthenticated ? { limit: 5 } : 'skip',
|
||||
) ?? [];
|
||||
const threads =
|
||||
useQuery(api.threads.listMine, isAuthenticated ? { limit: 5 } : 'skip') ??
|
||||
[];
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const redirectTo = useMemo(() => Linking.createURL(''), []);
|
||||
const router = useRouter();
|
||||
|
||||
const handlePasswordSignIn = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await signIn('password', { email, password, flow: 'signIn' });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Sign in failed', 'Check your email and password.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (isAuthenticated) {
|
||||
router.replace('/dashboard');
|
||||
} else {
|
||||
router.replace('/sign-in');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthentikSignIn = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await signIn('authentik', { redirectTo });
|
||||
if (!result.redirect) return;
|
||||
const authResult = await WebBrowser.openAuthSessionAsync(
|
||||
result.redirect.toString(),
|
||||
redirectTo,
|
||||
);
|
||||
if (authResult.type !== 'success') return;
|
||||
const parsed = Linking.parse(authResult.url);
|
||||
const code = parsed.queryParams?.code;
|
||||
if (typeof code !== 'string') {
|
||||
Alert.alert('Sign in failed', 'Authentik did not return a code.');
|
||||
return;
|
||||
}
|
||||
await signIn('authentik', { code });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Sign in failed', 'Could not complete Authentik sign in.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
return (
|
||||
<SafeAreaView className='bg-background flex-1'>
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Spoon' }} />
|
||||
<View className='flex-1 gap-5 p-6'>
|
||||
<View>
|
||||
<Text className='text-foreground text-4xl font-bold'>Spoon</Text>
|
||||
<Text className='text-muted-foreground mt-2 text-base leading-6'>
|
||||
Fork freely. Stay close to upstream.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<Text className='text-muted-foreground'>Loading...</Text>
|
||||
) : isAuthenticated ? (
|
||||
<View className='gap-5'>
|
||||
<View>
|
||||
<Text className='text-foreground text-xl font-semibold'>
|
||||
Welcome{user?.name ? `, ${user.name}` : ''}
|
||||
</Text>
|
||||
<Text className='text-muted-foreground mt-1'>
|
||||
Monitor your managed forks from anywhere.
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex-row gap-3'>
|
||||
<Stat label='Spoons' value={spoons.length} />
|
||||
<Stat label='Checks' value={syncRuns.length} />
|
||||
<Stat label='Threads' value={threads.length} />
|
||||
</View>
|
||||
<View className='border-border bg-card rounded-lg border p-4'>
|
||||
<Text className='text-foreground font-semibold'>
|
||||
Recent Spoons
|
||||
</Text>
|
||||
{spoons.length ? (
|
||||
spoons.slice(0, 4).map((spoon) => (
|
||||
<Text key={spoon._id} className='text-muted-foreground mt-3'>
|
||||
{spoon.name} - {spoon.status.replaceAll('_', ' ')}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text className='text-muted-foreground mt-3'>
|
||||
Create your first Spoon from the web dashboard.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Pressable
|
||||
className='bg-primary items-center rounded-md p-3'
|
||||
onPress={() => void signOut()}
|
||||
>
|
||||
<Text className='text-primary-foreground font-semibold'>
|
||||
Sign out
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<View className='gap-4'>
|
||||
<TextInput
|
||||
className='border-input text-foreground rounded-md border px-3 py-3'
|
||||
autoCapitalize='none'
|
||||
keyboardType='email-address'
|
||||
placeholder='Email'
|
||||
placeholderTextColor='#64748b'
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
<TextInput
|
||||
className='border-input text-foreground rounded-md border px-3 py-3'
|
||||
secureTextEntry
|
||||
placeholder='Password'
|
||||
placeholderTextColor='#64748b'
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
<Pressable
|
||||
className='bg-primary items-center rounded-md p-3 disabled:opacity-60'
|
||||
disabled={submitting}
|
||||
onPress={() => void handlePasswordSignIn()}
|
||||
>
|
||||
<Text className='text-primary-foreground font-semibold'>
|
||||
Sign in with password
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
className='border-border items-center rounded-md border p-3 disabled:opacity-60'
|
||||
disabled={submitting}
|
||||
onPress={() => void handleAuthentikSignIn()}
|
||||
>
|
||||
<Text className='text-foreground font-semibold'>
|
||||
Continue with Authentik
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Text className='text-muted-foreground text-sm'>
|
||||
Register the native redirect URI based on spoon:// in Authentik.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
<LoadingState label='Opening Spoon...' />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
export default IndexRoute;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||
|
||||
const Post = () => {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
|
||||
return (
|
||||
<SafeAreaView className='bg-background flex-1'>
|
||||
<Stack.Screen options={{ title: 'Post' }} />
|
||||
<View className='flex-1 p-4'>
|
||||
<Text className='text-foreground text-2xl font-bold'>Post {id}</Text>
|
||||
<Text className='text-muted-foreground mt-2'>
|
||||
Implement your post detail screen here using Convex queries.
|
||||
</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Post;
|
||||
Reference in New Issue
Block a user