Update expo application
Build and Push Next App / quality (push) Successful in 1m27s
Build and Push Next App / build-next (push) Successful in 3m58s

This commit is contained in:
Gabriel Brown
2026-06-22 12:13:02 -04:00
parent ddce5efb13
commit 42f95530de
78 changed files with 5315 additions and 421 deletions
+8 -1
View File
@@ -12,6 +12,9 @@
"ios": "expo run:ios",
"format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore",
"lint": "eslint --flag unstable_native_nodejs_ts_config",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration --passWithNoTests",
"test:component": "vitest run --project component",
"typecheck": "tsc --noEmit",
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
},
@@ -27,6 +30,7 @@
"convex": "catalog:convex",
"expo": "~54.0.33",
"expo-apple-authentication": "~8.0.8",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20",
"expo-font": "~14.0.11",
@@ -57,11 +61,14 @@
"@spoon/prettier-config": "workspace:*",
"@spoon/tailwind-config": "workspace:*",
"@spoon/tsconfig": "workspace:*",
"@spoon/vitest-config": "workspace:*",
"@testing-library/react": "catalog:test",
"@types/react": "catalog:react19",
"eslint": "catalog:",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "catalog:test"
},
"prettier": "@spoon/prettier-config"
}
+56
View File
@@ -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;
+148
View File
@@ -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;
+81
View File
@@ -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;
+396
View File
@@ -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;
+74
View File
@@ -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;
+12
View File
@@ -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
View File
@@ -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;
-21
View File
@@ -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;
@@ -0,0 +1,113 @@
import { useMemo, useState } from 'react';
import { Alert, Text, View } from 'react-native';
import * as Linking from 'expo-linking';
import * as WebBrowser from 'expo-web-browser';
import { useAuthActions } from '@convex-dev/auth/react';
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';
WebBrowser.maybeCompleteAuthSession();
type OAuthProvider = 'github' | 'authentik';
export const SignInScreen = () => {
const { signIn } = useAuthActions();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const redirectTo = useMemo(() => Linking.createURL(''), []);
const signInWithPassword = 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);
}
};
const signInWithOAuth = async (provider: OAuthProvider) => {
setSubmitting(true);
try {
const result = await signIn(provider, { 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', 'The provider did not return a code.');
return;
}
await signIn(provider, { code });
} catch (error) {
console.error(error);
Alert.alert('Sign in failed', `Could not complete ${provider} sign in.`);
} finally {
setSubmitting(false);
}
};
return (
<AppScreen>
<View className='gap-2'>
<Text className='text-foreground text-4xl font-bold'>Spoon</Text>
<Text className='text-muted-foreground text-base leading-6'>
Fork freely & keep them close to upstream.
</Text>
</View>
<Card className='gap-3'>
<Button
disabled={submitting}
onPress={() => void signInWithOAuth('github')}
>
Continue with GitHub
</Button>
<Button
disabled={submitting}
variant='outline'
onPress={() => void signInWithOAuth('authentik')}
>
Continue with Authentik
</Button>
</Card>
<Card className='gap-4'>
<Text className='text-foreground font-semibold'>
Sign in with email
</Text>
<Field
keyboardType='email-address'
label='Email'
placeholder='you@example.com'
value={email}
onChangeText={setEmail}
/>
<Field
label='Password'
placeholder='Password'
secureTextEntry
value={password}
onChangeText={setPassword}
/>
<Button disabled={submitting} onPress={() => void signInWithPassword()}>
Sign in with email
</Button>
</Card>
<Text className='text-muted-foreground text-sm leading-5'>
Native OAuth callbacks should allow the `spoon://` redirect scheme.
</Text>
</AppScreen>
);
};
@@ -0,0 +1,233 @@
import { useMemo, useState } from 'react';
import { Text } from 'react-native';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button } from '~/components/ui/button';
import { Field } from '~/components/ui/field';
import { FormSection } from '~/components/ui/form-section';
import { SheetSelect } from '~/components/ui/sheet-select';
import { SwitchRow } from '~/components/ui/switch-row';
import { Textarea } from '~/components/ui/textarea';
type Provider =
| 'openai'
| 'anthropic'
| 'google'
| 'openrouter'
| 'requesty'
| 'litellm'
| 'cloudflare_ai_gateway'
| 'custom_openai_compatible'
| 'opencode_openai_login';
type AuthType = 'api_key' | 'opencode_auth_json' | 'none';
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
type ExistingProfile = {
_id: Id<'aiProviderProfiles'>;
authType: AuthType;
baseUrl?: string;
defaultModel: string;
enabled: boolean;
modelOptions?: string[];
name: string;
provider: Provider;
reasoningEffort: ReasoningEffort;
};
const providerDefaults: Record<
Provider,
{ authType: AuthType; model: string; name: string }
> = {
anthropic: {
authType: 'api_key',
model: 'claude-sonnet-4-5',
name: 'Anthropic',
},
cloudflare_ai_gateway: {
authType: 'api_key',
model: 'gpt-5.1-codex',
name: 'Cloudflare AI Gateway',
},
custom_openai_compatible: {
authType: 'api_key',
model: 'gpt-5.1-codex',
name: 'Custom compatible',
},
google: { authType: 'api_key', model: 'gemini-2.5-pro', name: 'Google' },
litellm: { authType: 'api_key', model: 'gpt-5.1-codex', name: 'LiteLLM' },
opencode_openai_login: {
authType: 'opencode_auth_json',
model: 'gpt-5.1-codex',
name: 'OpenCode provider',
},
openai: { authType: 'api_key', model: 'gpt-5.1-codex', name: 'OpenAI' },
openrouter: {
authType: 'api_key',
model: 'openai/gpt-5.1-codex',
name: 'OpenRouter',
},
requesty: {
authType: 'api_key',
model: 'openai/gpt-5.1-codex',
name: 'Requesty',
},
};
const parseModelOptions = (text: string) =>
text
.split(/\r?\n|,/)
.map((model) => model.trim())
.filter(Boolean);
export const AiProviderProfileForm = ({
existing,
onSubmit,
saving,
}: {
existing?: ExistingProfile;
onSubmit: (values: {
authType: AuthType;
baseUrl?: string;
defaultModel: string;
enabled: boolean;
modelOptions: string[];
name: string;
provider: Provider;
reasoningEffort: ReasoningEffort;
secret?: string;
}) => Promise<void>;
saving: boolean;
}) => {
const [name, setName] = useState(existing?.name ?? 'OpenCode provider');
const [provider, setProvider] = useState<Provider>(
existing?.provider ?? 'opencode_openai_login',
);
const [authType, setAuthType] = useState<AuthType>(
existing?.authType ?? 'opencode_auth_json',
);
const [secret, setSecret] = useState('');
const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? '');
const [modelOptions, setModelOptions] = useState(
(existing?.modelOptions?.length
? existing.modelOptions
: [existing?.defaultModel ?? 'gpt-5.1-codex']
).join('\n'),
);
const models = useMemo(() => parseModelOptions(modelOptions), [modelOptions]);
const [defaultModel, setDefaultModel] = useState(
existing?.defaultModel ?? 'gpt-5.1-codex',
);
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
existing?.reasoningEffort ?? 'medium',
);
const [enabled, setEnabled] = useState(existing?.enabled ?? true);
const changeProvider = (nextProvider: Provider) => {
const defaults = providerDefaults[nextProvider];
setProvider(nextProvider);
setName(defaults.name);
setAuthType(defaults.authType);
setDefaultModel(defaults.model);
setModelOptions(defaults.model);
};
const submit = () =>
void onSubmit({
authType,
baseUrl: baseUrl || undefined,
defaultModel: models.includes(defaultModel)
? defaultModel
: (models[0] ?? defaultModel),
enabled,
modelOptions: models,
name,
provider,
reasoningEffort,
secret: secret || undefined,
});
return (
<FormSection title={existing ? 'Edit provider' : 'New provider'}>
<Field label='Name' value={name} onChangeText={setName} />
<SheetSelect
label='Provider'
options={[
{ label: 'OpenCode OpenAI login', value: 'opencode_openai_login' },
{ label: 'OpenAI', value: 'openai' },
{ label: 'Anthropic', value: 'anthropic' },
{ label: 'Google', value: 'google' },
{ label: 'OpenRouter', value: 'openrouter' },
{ label: 'Requesty', value: 'requesty' },
{ label: 'LiteLLM', value: 'litellm' },
{ label: 'Cloudflare AI Gateway', value: 'cloudflare_ai_gateway' },
{
label: 'Custom OpenAI compatible',
value: 'custom_openai_compatible',
},
]}
value={provider}
onChange={changeProvider}
/>
<SheetSelect
label='Auth type'
options={[
{ label: 'API key', value: 'api_key' },
{ label: 'OpenCode auth JSON', value: 'opencode_auth_json' },
{ label: 'None', value: 'none' },
]}
value={authType}
onChange={setAuthType}
/>
{authType === 'opencode_auth_json' ? (
<Text className='text-muted-foreground text-sm leading-5'>
Copy auth.json from your Codex/OpenCode auth folder, for example
~/.codex/auth.json, and paste it here.
</Text>
) : null}
{authType !== 'none' ? (
<Field
label={authType === 'api_key' ? 'API key' : 'Auth JSON'}
multiline={authType === 'opencode_auth_json'}
secureTextEntry={authType === 'api_key'}
value={secret}
onChangeText={setSecret}
/>
) : null}
<Field label='Base URL' value={baseUrl} onChangeText={setBaseUrl} />
<Textarea
label='Model options'
value={modelOptions}
onChangeText={setModelOptions}
/>
<SheetSelect
disabled={!models.length}
label='Default model'
options={
models.length
? models.map((model) => ({ label: model, value: model }))
: [{ label: 'Add model options first', value: '' }]
}
value={models.includes(defaultModel) ? defaultModel : (models[0] ?? '')}
onChange={setDefaultModel}
/>
<SheetSelect
label='Reasoning effort'
options={[
{ label: 'None', value: 'none' },
{ label: 'Minimal', value: 'minimal' },
{ label: 'Low', value: 'low' },
{ label: 'Medium', value: 'medium' },
{ label: 'High', value: 'high' },
{ label: 'XHigh', value: 'xhigh' },
]}
value={reasoningEffort}
onChange={setReasoningEffort}
/>
<SwitchRow label='Enabled' value={enabled} onValueChange={setEnabled} />
<Button disabled={saving || !models.length} onPress={submit}>
{saving ? 'Saving...' : 'Save provider'}
</Button>
</FormSection>
);
};
@@ -0,0 +1,112 @@
import { Alert, Linking, Text, View } from 'react-native';
import { Badge } from '~/components/ui/badge';
import { Button } from '~/components/ui/button';
import { Card } from '~/components/ui/card';
import { EmptyState } from '~/components/ui/empty-state';
export const GitHubIntegrationPanel = ({
connection,
installUrl,
loadingRepos,
onListRepos,
onSync,
runtimeStatus,
syncing,
}: {
connection?: {
displayName?: string;
installationId?: string;
status?: string;
} | null;
installUrl?: string | null;
loadingRepos: boolean;
onListRepos: () => Promise<string[]>;
onSync: () => Promise<void>;
runtimeStatus?: { encryptionConfigured?: boolean } | null;
syncing: boolean;
}) => {
const showRepos = async () => {
try {
const repos = await onListRepos();
Alert.alert(
'Accessible repositories',
repos.slice(0, 20).join('\n') || 'No repositories returned.',
);
} catch (error) {
console.error(error);
Alert.alert('Could not list repositories.');
}
};
const sync = async () => {
try {
await onSync();
Alert.alert('GitHub synced', 'Installation metadata was refreshed.');
} catch (error) {
console.error(error);
Alert.alert('Could not sync GitHub installation.');
}
};
return (
<>
<Card className='gap-3'>
<View className='flex-row items-center justify-between'>
<Text className='text-foreground font-semibold'>GitHub App</Text>
<Badge
label={connection?.status ?? 'not connected'}
tone={connection ? 'success' : 'warning'}
/>
</View>
{connection ? (
<>
<Text className='text-muted-foreground text-sm'>
{connection.displayName}
</Text>
<Text className='text-muted-foreground text-xs'>
Installation {connection.installationId ?? 'unknown'}
</Text>
</>
) : (
<Text className='text-muted-foreground text-sm'>
Connect GitHub so Spoon can create forks, compare branches, and open
draft PRs.
</Text>
)}
{installUrl ? (
<Button onPress={() => void Linking.openURL(installUrl)}>
Install or manage GitHub App
</Button>
) : null}
<Button
disabled={syncing}
variant='outline'
onPress={() => void sync()}
>
{syncing ? 'Syncing...' : 'Sync installation'}
</Button>
<Button
disabled={loadingRepos}
variant='outline'
onPress={() => void showRepos()}
>
{loadingRepos ? 'Loading...' : 'List repositories'}
</Button>
</Card>
<Card>
<Text className='text-foreground font-semibold'>Runtime status</Text>
<Text className='text-muted-foreground mt-2 text-sm'>
Encryption configured:{' '}
{runtimeStatus?.encryptionConfigured ? 'yes' : 'not reported'}
</Text>
</Card>
{!connection ? (
<EmptyState
description='Install the GitHub App, then sync the installation.'
title='GitHub is not connected yet'
/>
) : null}
</>
);
};
@@ -0,0 +1,27 @@
import type { PillTab } from '~/components/ui/pill-tabs';
import { PillTabs } from '~/components/ui/pill-tabs';
export type SpoonDetailSegment =
| 'overview'
| 'upstream'
| 'fork'
| 'prs'
| 'threads'
| 'settings';
const tabs: PillTab<SpoonDetailSegment>[] = [
{ label: 'Overview', value: 'overview' },
{ label: 'Upstream', value: 'upstream' },
{ label: 'Fork', value: 'fork' },
{ label: 'PRs', value: 'prs' },
{ label: 'Threads', value: 'threads' },
{ label: 'Settings', value: 'settings' },
];
export const SegmentControl = ({
onChange,
value,
}: {
onChange: (value: SpoonDetailSegment) => void;
value: SpoonDetailSegment;
}) => <PillTabs onChange={onChange} tabs={tabs} value={value} />;
@@ -0,0 +1,192 @@
import { Alert, Text, View } from 'react-native';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { Field } from '~/components/ui/field';
import { FormSection } from '~/components/ui/form-section';
import { SheetSelect } from '~/components/ui/sheet-select';
import { SwitchRow } from '~/components/ui/switch-row';
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
type ProviderProfile = {
_id: Id<'aiProviderProfiles'>;
defaultModel: string;
enabled: boolean;
isDefault?: boolean;
modelOptions?: string[];
name: string;
reasoningEffort: ReasoningEffort;
};
export const SpoonAgentSettingsForm = ({
agent,
onUpdate,
profiles,
}: {
agent?: {
agentModel: string;
aiProviderProfileId?: Id<'aiProviderProfiles'>;
autoDetectCommands?: boolean;
branchPrefix: string;
checkCommand?: string;
enabled?: boolean;
envFilePath?: string;
installCommand?: string;
materializeEnvFileByDefault?: boolean;
reasoningEffort: ReasoningEffort;
testCommand?: string;
};
onUpdate: (patch: {
agentModel?: string;
aiProviderProfileId?: Id<'aiProviderProfiles'>;
autoDetectCommands?: boolean;
branchPrefix?: string;
checkCommand?: string;
enabled?: boolean;
envFilePath?: string;
installCommand?: string;
materializeEnvFileByDefault?: boolean;
reasoningEffort?: ReasoningEffort;
testCommand?: string;
}) => Promise<void>;
profiles: ProviderProfile[];
}) => {
const enabledProfiles = profiles.filter((profile) => profile.enabled);
const selectedProfile =
enabledProfiles.find(
(profile) => profile._id === agent?.aiProviderProfileId,
) ??
enabledProfiles.find((profile) => profile.isDefault) ??
enabledProfiles[0];
const models = Array.from(
new Set(
selectedProfile
? [
selectedProfile.defaultModel,
...(selectedProfile.modelOptions ?? []),
].filter(Boolean)
: [],
),
);
const currentModel =
models.find((model) => model === agent?.agentModel) ??
selectedProfile?.defaultModel ??
'';
const save = (patch: Parameters<typeof onUpdate>[0]) =>
void onUpdate(patch).catch((error: unknown) => {
console.error(error);
Alert.alert('Could not save agent settings.');
});
return (
<FormSection
description='Mobile can configure the runtime, but code editing still happens from the web workspace.'
title='Agent settings'
>
<SwitchRow
label='Enabled'
value={agent?.enabled ?? true}
onValueChange={(enabled) => save({ enabled })}
/>
<SwitchRow
label='Auto-detect commands'
value={agent?.autoDetectCommands ?? true}
onValueChange={(autoDetectCommands) => save({ autoDetectCommands })}
/>
<SwitchRow
label='Materialize env file'
value={agent?.materializeEnvFileByDefault ?? false}
onValueChange={(materializeEnvFileByDefault) =>
save({ materializeEnvFileByDefault })
}
/>
<SheetSelect
disabled={!enabledProfiles.length}
label='AI provider'
options={
enabledProfiles.length
? enabledProfiles.map((profile) => ({
label: profile.isDefault
? `${profile.name} (default)`
: profile.name,
value: profile._id,
}))
: [{ label: 'Configure an AI provider in Settings', value: '' }]
}
value={selectedProfile?._id ?? ''}
onChange={(aiProviderProfileId) => {
const profile = enabledProfiles.find(
(item) => item._id === aiProviderProfileId,
);
if (profile) {
save({
agentModel: profile.defaultModel,
aiProviderProfileId: profile._id,
reasoningEffort: profile.reasoningEffort,
});
}
}}
/>
<SheetSelect
disabled={!models.length}
label='Model'
options={
models.length
? models.map((model) => ({ label: model, value: model }))
: [{ label: 'No models available', value: '' }]
}
value={currentModel}
onChange={(agentModel) => save({ agentModel })}
/>
<SheetSelect
label='Reasoning effort'
options={[
{ label: 'None', value: 'none' },
{ label: 'Minimal', value: 'minimal' },
{ label: 'Low', value: 'low' },
{ label: 'Medium', value: 'medium' },
{ label: 'High', value: 'high' },
{ label: 'XHigh', value: 'xhigh' },
]}
value={
agent?.reasoningEffort ?? selectedProfile?.reasoningEffort ?? 'medium'
}
onChange={(reasoningEffort) => save({ reasoningEffort })}
/>
{!enabledProfiles.length ? (
<Text className='text-muted-foreground text-sm leading-5'>
Configure an AI provider in Settings before queueing agent work.
</Text>
) : null}
<View className='gap-3'>
<Field
label='Branch prefix'
value={agent?.branchPrefix ?? 'spoon/agent'}
onChangeText={(branchPrefix) => save({ branchPrefix })}
/>
<Field
label='Install command'
value={agent?.installCommand ?? ''}
onChangeText={(installCommand) => save({ installCommand })}
/>
<Field
label='Check command'
value={agent?.checkCommand ?? ''}
onChangeText={(checkCommand) => save({ checkCommand })}
/>
<Field
label='Test command'
value={agent?.testCommand ?? ''}
onChangeText={(testCommand) => save({ testCommand })}
/>
<Field
label='Env file path'
value={agent?.envFilePath ?? '.env.local'}
onChangeText={(envFilePath) => save({ envFilePath })}
/>
</View>
</FormSection>
);
};
@@ -0,0 +1,58 @@
import { Linking, Text, View } from 'react-native';
import { Button } from '~/components/ui/button';
import { Card } from '~/components/ui/card';
import { EmptyState } from '~/components/ui/empty-state';
import { formatDateTime, truncate } from '~/utils/format';
type Commit = {
_id: string;
authorLogin?: string;
authorName?: string;
committedAt?: number;
htmlUrl?: string;
message: string;
};
export const SpoonCommitList = ({
commits,
emptyDescription,
emptyTitle,
intro,
showOpenButton = false,
}: {
commits: Commit[];
emptyDescription: string;
emptyTitle: string;
intro?: string;
showOpenButton?: boolean;
}) => (
<View className='gap-3'>
{intro ? (
<Text className='text-muted-foreground text-sm'>{intro}</Text>
) : null}
{commits.length ? (
commits.map((commit) => (
<Card key={commit._id}>
<Text className='text-foreground font-medium'>
{truncate(commit.message, 100)}
</Text>
<Text className='text-muted-foreground mt-2 text-xs'>
{commit.authorLogin ?? commit.authorName ?? 'unknown'} ·{' '}
{formatDateTime(commit.committedAt)}
</Text>
{showOpenButton && commit.htmlUrl ? (
<Button
variant='ghost'
onPress={() => void Linking.openURL(commit.htmlUrl ?? '')}
>
Open commit
</Button>
) : null}
</Card>
))
) : (
<EmptyState description={emptyDescription} title={emptyTitle} />
)}
</View>
);
@@ -0,0 +1,14 @@
import { SpoonCommitList } from './spoon-commit-list';
export const SpoonDetailFork = ({
commits,
}: {
commits: Parameters<typeof SpoonCommitList>[0]['commits'];
}) => (
<SpoonCommitList
commits={commits}
emptyDescription='Fork-only commits appear after Spoon compares your fork with upstream.'
emptyTitle='No fork-only commits cached'
intro='Fork-only commits are customizations Spoon should preserve.'
/>
);
@@ -0,0 +1,70 @@
import { Text, View } from 'react-native';
import { Card } from '~/components/ui/card';
import { CopyRow } from '~/components/ui/copy-row';
import { MetricCard } from '~/components/ui/metric-card';
import { formatDate, titleize } from '~/utils/format';
type SpoonOverview = {
description?: string;
forkAheadBy?: number;
forkOwner?: string;
forkRepo?: string;
forkUrl?: string;
lastCheckedAt?: number;
syncCadence: string;
upstreamAheadBy?: number;
upstreamOwner: string;
upstreamRepo: string;
upstreamUrl: string;
};
export const SpoonDetailOverview = ({
effectiveUpstreamAheadBy,
remotes,
spoon,
}: {
effectiveUpstreamAheadBy: number;
remotes: { _id: string; label: string; url: string }[];
spoon: SpoonOverview;
}) => (
<View className='gap-4'>
<View className='flex-row gap-3'>
<MetricCard label='Raw upstream' value={spoon.upstreamAheadBy ?? 0} />
<MetricCard label='Effective' value={effectiveUpstreamAheadBy} />
<MetricCard label='Fork-only' value={spoon.forkAheadBy ?? 0} />
</View>
{spoon.description ? (
<Card>
<Text className='text-foreground font-semibold'>Description</Text>
<Text className='text-muted-foreground mt-2 text-sm leading-5'>
{spoon.description}
</Text>
</Card>
) : null}
<Card>
<CopyRow label='Upstream' value={spoon.upstreamUrl} />
<CopyRow label='Fork clone URL' value={spoon.forkUrl} />
{remotes.map((remote) => (
<CopyRow key={remote._id} label={remote.label} value={remote.url} />
))}
</Card>
<Card>
<Text className='text-foreground font-semibold'>Details</Text>
<Text className='text-muted-foreground mt-2 text-sm'>
Last checked: {formatDate(spoon.lastCheckedAt)}
</Text>
<Text className='text-muted-foreground mt-1 text-sm'>
Cadence: {titleize(spoon.syncCadence)}
</Text>
<Text className='text-muted-foreground mt-1 text-sm'>
Upstream: {spoon.upstreamOwner}/{spoon.upstreamRepo}
</Text>
{spoon.forkOwner && spoon.forkRepo ? (
<Text className='text-muted-foreground mt-1 text-sm'>
Fork: {spoon.forkOwner}/{spoon.forkRepo}
</Text>
) : null}
</Card>
</View>
);
@@ -0,0 +1,47 @@
import { Linking, Text, View } from 'react-native';
import { Button } from '~/components/ui/button';
import { Card } from '~/components/ui/card';
import { EmptyState } from '~/components/ui/empty-state';
import { titleize } from '~/utils/format';
type PullRequest = {
_id: string;
htmlUrl: string;
number: number;
repoFullName: string;
state: string;
title: string;
};
export const SpoonDetailPrs = ({
pullRequests,
}: {
pullRequests: PullRequest[];
}) => (
<View className='gap-3'>
{pullRequests.length ? (
pullRequests.map((pullRequest) => (
<Card key={pullRequest._id}>
<Text className='text-foreground font-medium'>
#{pullRequest.number} {pullRequest.title}
</Text>
<Text className='text-muted-foreground mt-2 text-xs'>
{titleize(pullRequest.state)} · {pullRequest.repoFullName}
</Text>
<Button
variant='outline'
onPress={() => void Linking.openURL(pullRequest.htmlUrl)}
>
Open PR
</Button>
</Card>
))
) : (
<EmptyState
description='Cached fork and upstream pull requests appear here.'
title='No pull requests cached'
/>
)}
</View>
);
@@ -0,0 +1,106 @@
import { View } from 'react-native';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { SpoonAgentSettingsForm } from './spoon-agent-settings-form';
import { SpoonMaintenanceSettingsForm } from './spoon-maintenance-settings-form';
import { SpoonRemotesPanel } from './spoon-remotes-panel';
import { SpoonSecretsPanel } from './spoon-secrets-panel';
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
export const SpoonDetailSettings = ({
actions,
agentSettings,
maintenanceSettings,
pending,
providerProfiles,
remotes,
secrets,
spoon,
}: {
actions: {
addRemote: (label: string, url: string) => Promise<void>;
addSecret: (name: string, value: string) => Promise<void>;
importSecrets: (
secrets: { name: string; value: string }[],
) => Promise<void>;
removeRemote: (remoteId: string) => Promise<void>;
removeSecret: (secretId: string) => Promise<void>;
updateAgent: (patch: Record<string, unknown>) => Promise<void>;
updateMaintenance: (patch: Record<string, unknown>) => Promise<void>;
updateSpoon: (patch: Record<string, unknown>) => Promise<void>;
};
agentSettings?: {
agentModel: string;
aiProviderProfileId?: Id<'aiProviderProfiles'>;
autoDetectCommands?: boolean;
branchPrefix: string;
checkCommand?: string;
enabled?: boolean;
envFilePath?: string;
installCommand?: string;
materializeEnvFileByDefault?: boolean;
reasoningEffort: ReasoningEffort;
testCommand?: string;
};
maintenanceSettings?: {
autoRefreshEnabled: boolean;
autoReviewEnabled: boolean;
autoSyncEnabled: boolean;
};
pending: {
addingRemote: boolean;
addingSecret: boolean;
importingSecrets: boolean;
removingRemoteId?: string;
removingSecretId?: string;
savingSettings: boolean;
};
providerProfiles: {
_id: Id<'aiProviderProfiles'>;
defaultModel: string;
enabled: boolean;
isDefault?: boolean;
modelOptions?: string[];
name: string;
reasoningEffort: ReasoningEffort;
}[];
remotes: { _id: string; label: string; url: string }[];
secrets: { _id: string; name: string; valuePreview?: string }[];
spoon: {
maintenanceMode: 'watch' | 'auto_pr' | 'paused';
syncCadence: 'daily' | 'weekly' | 'manual';
};
}) => (
<View className='gap-4'>
<SpoonMaintenanceSettingsForm
maintenance={maintenanceSettings}
saving={pending.savingSettings}
spoon={spoon}
onUpdateMaintenance={actions.updateMaintenance}
onUpdateSpoon={actions.updateSpoon}
/>
<SpoonAgentSettingsForm
agent={agentSettings}
profiles={providerProfiles}
onUpdate={actions.updateAgent}
/>
<SpoonSecretsPanel
adding={pending.addingSecret}
importing={pending.importingSecrets}
removingId={pending.removingSecretId}
secrets={secrets}
onAddSecret={actions.addSecret}
onImportSecrets={actions.importSecrets}
onRemoveSecret={actions.removeSecret}
/>
<SpoonRemotesPanel
adding={pending.addingRemote}
remotes={remotes}
removingId={pending.removingRemoteId}
onAddRemote={actions.addRemote}
onRemoveRemote={actions.removeRemote}
/>
</View>
);
@@ -0,0 +1,54 @@
import { Text, View } from 'react-native';
import { ThreadListRow } from '~/components/threads/thread-list-row';
import { Button } from '~/components/ui/button';
import { Card } from '~/components/ui/card';
import { EmptyState } from '~/components/ui/empty-state';
import { Textarea } from '~/components/ui/textarea';
type Thread = Parameters<typeof ThreadListRow>[0]['thread'];
export const SpoonDetailThreads = ({
creating,
onCreate,
onOpenThread,
prompt,
setPrompt,
threads,
}: {
creating: boolean;
onCreate: () => void;
onOpenThread: (threadId: string) => void;
prompt: string;
setPrompt: (prompt: string) => void;
threads: Thread[];
}) => (
<View className='gap-3'>
<Card className='gap-3'>
<Text className='text-foreground font-semibold'>New thread</Text>
<Textarea
label='Prompt'
placeholder='Ask Spoon to review or change this fork...'
value={prompt}
onChangeText={setPrompt}
/>
<Button disabled={creating || !prompt.trim()} onPress={onCreate}>
{creating ? 'Creating...' : 'Create thread'}
</Button>
</Card>
{threads.length ? (
threads.map((thread) => (
<ThreadListRow
key={thread._id}
thread={thread}
onPress={() => onOpenThread(thread._id)}
/>
))
) : (
<EmptyState
description='Create a thread when this fork needs review or code.'
title='No threads yet'
/>
)}
</View>
);
@@ -0,0 +1,14 @@
import { SpoonCommitList } from './spoon-commit-list';
export const SpoonDetailUpstream = ({
commits,
}: {
commits: Parameters<typeof SpoonCommitList>[0]['commits'];
}) => (
<SpoonCommitList
commits={commits}
emptyDescription='Upstream commits waiting for this fork will appear after refresh.'
emptyTitle='No upstream commits cached'
showOpenButton
/>
);
@@ -0,0 +1,48 @@
import { Text, View } from 'react-native';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { ListRow } from '~/components/ui/list-row';
import { formatDate } from '~/utils/format';
import { SpoonStatusBadge } from './spoon-status-badge';
export const SpoonListRow = ({
spoon,
openThreads,
onPress,
}: {
spoon: Doc<'spoons'>;
openThreads?: number;
onPress: () => void;
}) => (
<ListRow
meta={formatDate(spoon.lastCheckedAt)}
subtitle={`${spoon.upstreamOwner}/${spoon.upstreamRepo}`}
title={spoon.name}
onPress={onPress}
>
<View className='gap-3'>
<View className='flex-row flex-wrap items-center gap-2'>
<SpoonStatusBadge status={spoon.syncStatus ?? spoon.status} />
{spoon.forkOwner && spoon.forkRepo ? (
<Text className='text-muted-foreground text-xs'>
fork {spoon.forkOwner}/{spoon.forkRepo}
</Text>
) : (
<Text className='text-muted-foreground text-xs'>missing fork</Text>
)}
</View>
<View className='flex-row gap-4'>
<Text className='text-muted-foreground text-xs'>
{spoon.upstreamAheadBy ?? 0} upstream
</Text>
<Text className='text-muted-foreground text-xs'>
{spoon.forkAheadBy ?? 0} fork-only
</Text>
<Text className='text-muted-foreground text-xs'>
{openThreads ?? 0} threads
</Text>
</View>
</View>
</ListRow>
);
@@ -0,0 +1,104 @@
import { Alert, Text } from 'react-native';
import { FormSection } from '~/components/ui/form-section';
import { SheetSelect } from '~/components/ui/sheet-select';
import { SwitchRow } from '~/components/ui/switch-row';
type Cadence = 'daily' | 'weekly' | 'manual';
type MaintenanceMode = 'watch' | 'auto_pr' | 'paused';
export const SpoonMaintenanceSettingsForm = ({
maintenance,
onUpdateMaintenance,
onUpdateSpoon,
saving,
spoon,
}: {
maintenance?: {
autoRefreshEnabled: boolean;
autoReviewEnabled: boolean;
autoSyncEnabled: boolean;
};
onUpdateMaintenance: (patch: {
autoRefreshEnabled?: boolean;
autoReviewEnabled?: boolean;
autoSyncEnabled?: boolean;
}) => Promise<void>;
onUpdateSpoon: (patch: {
maintenanceMode?: MaintenanceMode;
syncCadence?: Cadence;
}) => Promise<void>;
saving: boolean;
spoon: { maintenanceMode: MaintenanceMode; syncCadence: Cadence };
}) => {
const updateMaintenance = (
patch: Parameters<typeof onUpdateMaintenance>[0],
) =>
void onUpdateMaintenance(patch).catch((error: unknown) => {
console.error(error);
Alert.alert('Could not save maintenance settings.');
});
const updateSpoon = (patch: Parameters<typeof onUpdateSpoon>[0]) =>
void onUpdateSpoon(patch).catch((error: unknown) => {
console.error(error);
Alert.alert('Could not save Spoon settings.');
});
return (
<>
<FormSection
description='These settings control scheduled checks and safe automation.'
title='Maintenance settings'
>
<SwitchRow
description='Let scheduled checks consider this Spoon.'
label='Auto refresh'
value={maintenance?.autoRefreshEnabled ?? true}
onValueChange={(autoRefreshEnabled) =>
updateMaintenance({ autoRefreshEnabled })
}
/>
<SwitchRow
label='Auto review'
value={maintenance?.autoReviewEnabled ?? true}
onValueChange={(autoReviewEnabled) =>
updateMaintenance({ autoReviewEnabled })
}
/>
<SwitchRow
label='Auto sync'
value={maintenance?.autoSyncEnabled ?? false}
onValueChange={(autoSyncEnabled) =>
updateMaintenance({ autoSyncEnabled })
}
/>
{saving ? (
<Text className='text-muted-foreground text-xs'>Saving...</Text>
) : null}
</FormSection>
<FormSection title='Spoon settings'>
<SheetSelect
label='Cadence'
options={[
{ label: 'Daily', value: 'daily' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Manual', value: 'manual' },
]}
value={spoon.syncCadence}
onChange={(syncCadence) => updateSpoon({ syncCadence })}
/>
<SheetSelect
label='Maintenance mode'
options={[
{ label: 'Watch', value: 'watch' },
{ label: 'Auto PR', value: 'auto_pr' },
{ label: 'Paused', value: 'paused' },
]}
value={spoon.maintenanceMode}
onChange={(maintenanceMode) => updateSpoon({ maintenanceMode })}
/>
</FormSection>
</>
);
};
@@ -0,0 +1,67 @@
import { useState } from 'react';
import { Alert, Text, View } from 'react-native';
import { Button } from '~/components/ui/button';
import { ConfirmButton } from '~/components/ui/confirm-button';
import { Field } from '~/components/ui/field';
import { FormSection } from '~/components/ui/form-section';
export const SpoonRemotesPanel = ({
adding,
onAddRemote,
onRemoveRemote,
remotes,
removingId,
}: {
adding: boolean;
onAddRemote: (label: string, url: string) => Promise<void>;
onRemoveRemote: (remoteId: string) => Promise<void>;
remotes: { _id: string; label: string; url: string }[];
removingId?: string;
}) => {
const [label, setLabel] = useState('');
const [url, setUrl] = useState('');
const add = async () => {
if (!label.trim() || !url.trim()) return;
try {
await onAddRemote(label.trim(), url.trim());
setLabel('');
setUrl('');
} catch (error) {
console.error(error);
Alert.alert('Could not add remote.');
}
};
return (
<FormSection title='Additional remotes'>
{remotes.map((remote) => (
<View
key={remote._id}
className='border-border flex-row items-center justify-between gap-3 border-b py-2'
>
<View className='min-w-0 flex-1'>
<Text className='text-foreground font-medium'>{remote.label}</Text>
<Text className='text-muted-foreground text-xs'>{remote.url}</Text>
</View>
<ConfirmButton
confirmLabel='Remove'
destructive
disabled={removingId === remote._id}
message={`Remove ${remote.label} from this Spoon?`}
title='Remove remote'
onConfirm={() => void onRemoveRemote(remote._id)}
>
{removingId === remote._id ? 'Removing...' : 'Remove'}
</ConfirmButton>
</View>
))}
<Field label='Label' value={label} onChangeText={setLabel} />
<Field keyboardType='url' label='URL' value={url} onChangeText={setUrl} />
<Button disabled={adding || !label.trim() || !url.trim()} onPress={add}>
{adding ? 'Adding...' : 'Add remote'}
</Button>
</FormSection>
);
};
@@ -0,0 +1,138 @@
import { useMemo, useState } from 'react';
import { Alert, Text, View } from 'react-native';
import { Button } from '~/components/ui/button';
import { ConfirmButton } from '~/components/ui/confirm-button';
import { Field } from '~/components/ui/field';
import { FormSection } from '~/components/ui/form-section';
import { Textarea } from '~/components/ui/textarea';
import { parseEnvText } from '~/utils/env';
export const SpoonSecretsPanel = ({
adding,
importing,
onAddSecret,
onImportSecrets,
onRemoveSecret,
removingId,
secrets,
}: {
adding: boolean;
importing: boolean;
onAddSecret: (name: string, value: string) => Promise<void>;
onImportSecrets: (
secrets: { name: string; value: string }[],
) => Promise<void>;
onRemoveSecret: (secretId: string) => Promise<void>;
removingId?: string;
secrets: { _id: string; name: string; valuePreview?: string }[];
}) => {
const [name, setName] = useState('');
const [value, setValue] = useState('');
const [envText, setEnvText] = useState('');
const parsed = useMemo(() => parseEnvText(envText), [envText]);
const preview = parsed.slice(0, 25);
const add = async () => {
if (!name.trim() || !value.trim()) return;
try {
await onAddSecret(name.trim(), value);
setName('');
setValue('');
} catch (error) {
console.error(error);
Alert.alert('Could not save secret.');
}
};
const importAll = async () => {
if (!parsed.length) return;
try {
await onImportSecrets(parsed);
setEnvText('');
Alert.alert('Secrets imported', `${parsed.length} secrets were saved.`);
} catch (error) {
console.error(error);
Alert.alert(
'Could not import every secret',
error instanceof Error
? error.message
: 'Some secrets may have been saved. Review the list and try again.',
);
}
};
return (
<FormSection
description='Secret values are encrypted and never shown after saving.'
title='Secrets'
>
{secrets.map((secret) => (
<View
key={secret._id}
className='border-border flex-row items-center justify-between gap-3 border-b py-2'
>
<View className='min-w-0 flex-1'>
<Text className='text-foreground font-medium'>{secret.name}</Text>
<Text className='text-muted-foreground text-xs'>
{secret.valuePreview ?? 'configured'}
</Text>
</View>
<ConfirmButton
confirmLabel='Remove'
destructive
disabled={removingId === secret._id}
message={`Remove ${secret.name} from this Spoon?`}
title='Remove secret'
onConfirm={() => void onRemoveSecret(secret._id)}
>
{removingId === secret._id ? 'Removing...' : 'Remove'}
</ConfirmButton>
</View>
))}
<View className='gap-3'>
<Text className='text-foreground font-semibold'>Add one secret</Text>
<Field label='Name' value={name} onChangeText={setName} />
<Field
label='Value'
secureTextEntry
value={value}
onChangeText={setValue}
/>
<Button
disabled={adding || !name.trim() || !value.trim()}
onPress={add}
>
{adding ? 'Adding...' : 'Add secret'}
</Button>
</View>
<View className='gap-3'>
<Text className='text-foreground font-semibold'>Import .env</Text>
<Textarea
label='.env contents'
placeholder='AUTH_SECRET=...'
value={envText}
onChangeText={setEnvText}
/>
<Text className='text-muted-foreground text-sm'>
{parsed.length
? `${parsed.length} valid secrets found: ${preview
.map((secret) => secret.name)
.join(', ')}${parsed.length > preview.length ? ', ...' : ''}`
: 'Paste .env contents to preview secret names.'}
</Text>
<View className='flex-row gap-3'>
<Button
disabled={importing || !parsed.length}
onPress={() => void importAll()}
>
{importing ? 'Importing...' : 'Import secrets'}
</Button>
<Button variant='outline' onPress={() => setEnvText('')}>
Clear
</Button>
</View>
</View>
</FormSection>
);
};
@@ -0,0 +1,16 @@
import { Badge } from '~/components/ui/badge';
import { titleize } from '~/utils/format';
const toneForStatus = (status?: string) => {
if (status === 'up_to_date' || status === 'active') return 'success';
if (status === 'behind' || status === 'diverged' || status === 'conflict') {
return 'warning';
}
if (status === 'error' || status === 'archived') return 'danger';
if (status === 'checking') return 'primary';
return 'neutral';
};
export const SpoonStatusBadge = ({ status }: { status?: string }) => (
<Badge label={titleize(status)} tone={toneForStatus(status)} />
);
@@ -0,0 +1,36 @@
import { Text, View } from 'react-native';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge } from '~/components/ui/badge';
import { ListRow } from '~/components/ui/list-row';
import { formatDateTime, titleize, truncate } from '~/utils/format';
import { ThreadStatusBadge } from './thread-status-badge';
export const ThreadListRow = ({
thread,
onPress,
}: {
thread: Doc<'threads'>;
onPress: () => void;
}) => (
<ListRow
meta={formatDateTime(thread.updatedAt)}
subtitle={thread.summary ? truncate(thread.summary, 90) : undefined}
title={thread.title}
onPress={onPress}
>
<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>
{thread.upstreamTo ? (
<Text className='text-muted-foreground mt-3 text-xs'>
upstream {thread.upstreamTo.slice(0, 12)}
</Text>
) : null}
</ListRow>
);
@@ -0,0 +1,31 @@
import { Text, View } from 'react-native';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { titleize } from '~/utils/format';
export const ThreadMessageList = ({
messages,
}: {
messages: Doc<'threadMessages'>[];
}) => (
<View className='gap-3'>
{messages.map((message) => (
<View
key={message._id}
className={
message.role === 'user'
? 'border-primary/30 bg-primary/10 rounded-lg border p-3'
: 'border-border bg-card rounded-lg border p-3'
}
>
<Text className='text-muted-foreground text-xs'>
{titleize(message.role)} · {titleize(message.status)}
</Text>
<Text className='text-foreground mt-2 leading-5'>
{message.content}
</Text>
</View>
))}
</View>
);
@@ -0,0 +1,16 @@
import { Badge } from '~/components/ui/badge';
import { titleize } from '~/utils/format';
const toneForStatus = (status?: string) => {
if (status === 'resolved' || status === 'ignored') return 'success';
if (status === 'failed' || status === 'cancelled') return 'danger';
if (status === 'waiting_for_user' || status === 'changes_ready') {
return 'warning';
}
if (status === 'running' || status === 'queued') return 'primary';
return 'neutral';
};
export const ThreadStatusBadge = ({ status }: { status?: string }) => (
<Badge label={titleize(status)} tone={toneForStatus(status)} />
);
@@ -0,0 +1,24 @@
import type { PressableProps } from 'react-native';
import { Pressable, Text, View } from 'react-native';
export const ActionRow = ({
detail,
label,
...props
}: PressableProps & {
detail?: string;
label: string;
}) => (
<Pressable
className='border-border min-h-14 flex-row items-center justify-between gap-3 border-b py-3'
{...props}
>
<View className='min-w-0 flex-1'>
<Text className='text-foreground font-medium'>{label}</Text>
{detail ? (
<Text className='text-muted-foreground mt-1 text-xs'>{detail}</Text>
) : null}
</View>
<Text className='text-muted-foreground text-lg'></Text>
</Pressable>
);
@@ -0,0 +1,40 @@
import type { ReactNode } from 'react';
import { RefreshControl, ScrollView, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
export const AppScreen = ({
children,
onRefresh,
refreshing = false,
scroll = true,
}: {
children: ReactNode;
onRefresh?: () => void;
refreshing?: boolean;
scroll?: boolean;
}) => {
if (!scroll) {
return (
<SafeAreaView className='bg-background flex-1'>
<View className='flex-1 p-4'>{children}</View>
</SafeAreaView>
);
}
return (
<SafeAreaView className='bg-background flex-1'>
<ScrollView
className='flex-1'
contentContainerClassName='gap-4 p-4 pb-10'
keyboardShouldPersistTaps='handled'
refreshControl={
onRefresh ? (
<RefreshControl onRefresh={onRefresh} refreshing={refreshing} />
) : undefined
}
>
{children}
</ScrollView>
</SafeAreaView>
);
};
+27
View File
@@ -0,0 +1,27 @@
import { Text } from 'react-native';
export const Badge = ({
label,
tone = 'neutral',
}: {
label: string;
tone?: 'neutral' | 'primary' | 'success' | 'warning' | 'danger';
}) => {
const toneClass =
tone === 'primary'
? 'bg-primary/10 text-primary'
: tone === 'success'
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
: tone === 'warning'
? 'bg-amber-500/10 text-amber-700 dark:text-amber-300'
: tone === 'danger'
? 'bg-red-500/10 text-red-700 dark:text-red-300'
: 'bg-muted text-muted-foreground';
return (
<Text
className={`self-start rounded-md px-2 py-1 text-xs font-semibold capitalize ${toneClass}`}
>
{label}
</Text>
);
};
+39
View File
@@ -0,0 +1,39 @@
import type { ComponentProps, ReactNode } from 'react';
import { Pressable, Text } from 'react-native';
export const Button = ({
children,
onPress,
variant = 'primary',
disabled = false,
...props
}: {
children: ReactNode;
onPress?: () => void;
variant?: 'primary' | 'outline' | 'danger' | 'ghost';
disabled?: boolean;
} & Omit<ComponentProps<typeof Pressable>, 'children'>) => {
const variantClass =
variant === 'outline'
? 'border-border border bg-transparent'
: variant === 'danger'
? 'bg-red-600'
: variant === 'ghost'
? 'bg-transparent'
: 'bg-primary';
const textClass =
variant === 'outline' || variant === 'ghost'
? 'text-foreground'
: 'text-primary-foreground';
return (
<Pressable
className={`items-center rounded-md px-4 py-3 disabled:opacity-50 ${variantClass}`}
disabled={disabled}
onPress={onPress}
{...props}
>
<Text className={`font-semibold ${textClass}`}>{children}</Text>
</Pressable>
);
};
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
import { View } from 'react-native';
export const Card = ({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) => (
<View className={`border-border bg-card rounded-lg border p-4 ${className}`}>
{children}
</View>
);
+42
View File
@@ -0,0 +1,42 @@
import { Pressable, ScrollView, Text } from 'react-native';
export const ChipRow = <T extends string>({
onChange,
options,
value,
}: {
onChange: (value: T) => void;
options: { label: string; value: T }[];
value: T;
}) => (
<ScrollView
horizontal
contentContainerClassName='gap-2'
showsHorizontalScrollIndicator={false}
>
{options.map((option) => {
const active = option.value === value;
return (
<Pressable
key={option.value}
className={
active
? 'bg-primary rounded-md px-3 py-2'
: 'bg-muted rounded-md px-3 py-2'
}
onPress={() => onChange(option.value)}
>
<Text
className={
active
? 'text-primary-foreground text-xs font-semibold'
: 'text-muted-foreground text-xs font-semibold'
}
>
{option.label}
</Text>
</Pressable>
);
})}
</ScrollView>
);
@@ -0,0 +1,38 @@
import { Alert } from 'react-native';
import { Button } from './button';
export const ConfirmButton = ({
children,
confirmLabel,
destructive = false,
disabled = false,
message,
onConfirm,
title,
}: {
children: string;
confirmLabel: string;
destructive?: boolean;
disabled?: boolean;
message: string;
onConfirm: () => void;
title: string;
}) => (
<Button
disabled={disabled}
variant={destructive ? 'danger' : 'outline'}
onPress={() =>
Alert.alert(title, message, [
{ style: 'cancel', text: 'Cancel' },
{
onPress: onConfirm,
style: destructive ? 'destructive' : 'default',
text: confirmLabel,
},
])
}
>
{children}
</Button>
);
+27
View File
@@ -0,0 +1,27 @@
import { Alert, Pressable, Text, View } from 'react-native';
import * as Clipboard from 'expo-clipboard';
import * as Haptics from 'expo-haptics';
export const CopyRow = ({
label,
value,
}: {
label: string;
value?: string;
}) => {
if (!value) return null;
const copy = async () => {
await Clipboard.setStringAsync(value);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert('Copied', `${label} copied to clipboard.`);
};
return (
<Pressable className='border-border border-b py-3' onPress={copy}>
<Text className='text-muted-foreground text-xs'>{label}</Text>
<View className='mt-1 flex-row items-center justify-between gap-3'>
<Text className='text-foreground min-w-0 flex-1 text-sm'>{value}</Text>
<Text className='text-primary text-sm font-semibold'>Copy</Text>
</View>
</Pressable>
);
};
@@ -0,0 +1,18 @@
import { Text } from 'react-native';
import { Card } from './card';
export const EmptyState = ({
title,
description,
}: {
title: string;
description: string;
}) => (
<Card>
<Text className='text-foreground font-semibold'>{title}</Text>
<Text className='text-muted-foreground mt-2 text-sm leading-5'>
{description}
</Text>
</Card>
);
@@ -0,0 +1,10 @@
import { Text } from 'react-native';
import { Card } from './card';
export const ErrorState = ({ message }: { message: string }) => (
<Card>
<Text className='font-semibold text-red-600'>Something went wrong</Text>
<Text className='text-muted-foreground mt-2 text-sm'>{message}</Text>
</Card>
);
+34
View File
@@ -0,0 +1,34 @@
import { Text, TextInput, View } from 'react-native';
export const Field = ({
label,
value,
onChangeText,
placeholder,
multiline = false,
secureTextEntry = false,
keyboardType,
}: {
label: string;
value: string;
onChangeText: (value: string) => void;
placeholder?: string;
multiline?: boolean;
secureTextEntry?: boolean;
keyboardType?: 'default' | 'email-address' | 'url';
}) => (
<View className='gap-2'>
<Text className='text-foreground text-sm font-medium'>{label}</Text>
<TextInput
className='border-input text-foreground rounded-md border px-3 py-3'
keyboardType={keyboardType}
multiline={multiline}
placeholder={placeholder}
placeholderTextColor='#64748b'
secureTextEntry={secureTextEntry}
textAlignVertical={multiline ? 'top' : 'center'}
value={value}
onChangeText={onChangeText}
/>
</View>
);
@@ -0,0 +1,24 @@
import type { ReactNode } from 'react';
import { Text } from 'react-native';
import { Card } from './card';
export const FormSection = ({
children,
description,
title,
}: {
children: ReactNode;
description?: string;
title: string;
}) => (
<Card className='gap-4'>
<Text className='text-foreground font-semibold'>{title}</Text>
{description ? (
<Text className='text-muted-foreground -mt-2 text-sm leading-5'>
{description}
</Text>
) : null}
{children}
</Card>
);
+36
View File
@@ -0,0 +1,36 @@
import type { ComponentProps, ReactNode } from 'react';
import { Pressable, Text, View } from 'react-native';
export const ListRow = ({
title,
subtitle,
meta,
children,
onPress,
...props
}: {
title: string;
subtitle?: string;
meta?: string;
children?: ReactNode;
onPress?: () => void;
} & Omit<ComponentProps<typeof Pressable>, 'children'>) => (
<Pressable
className='border-border bg-card rounded-lg border p-4'
onPress={onPress}
{...props}
>
<View className='flex-row items-start justify-between gap-3'>
<View className='min-w-0 flex-1'>
<Text className='text-foreground font-semibold'>{title}</Text>
{subtitle ? (
<Text className='text-muted-foreground mt-1 text-sm'>{subtitle}</Text>
) : null}
</View>
{meta ? (
<Text className='text-muted-foreground text-xs'>{meta}</Text>
) : null}
</View>
{children ? <View className='mt-3'>{children}</View> : null}
</Pressable>
);
@@ -0,0 +1,7 @@
import { Text, View } from 'react-native';
export const LoadingState = ({ label = 'Loading...' }: { label?: string }) => (
<View className='flex-1 items-center justify-center p-6'>
<Text className='text-muted-foreground'>{label}</Text>
</View>
);
@@ -0,0 +1,21 @@
import { Text } from 'react-native';
import { Card } from './card';
export const MetricCard = ({
label,
value,
note,
}: {
label: string;
value: string | number;
note?: string;
}) => (
<Card className='flex-1'>
<Text className='text-muted-foreground text-xs'>{label}</Text>
<Text className='text-foreground mt-2 text-2xl font-bold'>{value}</Text>
{note ? (
<Text className='text-muted-foreground mt-1 text-xs'>{note}</Text>
) : null}
</Card>
);
+61
View File
@@ -0,0 +1,61 @@
import { Pressable, ScrollView, Text } from 'react-native';
export type PillTab<T extends string> = {
badge?: number | string;
label: string;
value: T;
};
export const PillTabs = <T extends string>({
onChange,
tabs,
value,
}: {
onChange: (value: T) => void;
tabs: PillTab<T>[];
value: T;
}) => (
<ScrollView
horizontal
className='-mx-1'
contentContainerClassName='gap-2 px-1'
keyboardShouldPersistTaps='handled'
showsHorizontalScrollIndicator={false}
>
{tabs.map((tab) => {
const active = tab.value === value;
return (
<Pressable
key={tab.value}
className={
active
? 'bg-primary min-h-9 flex-row items-center gap-2 rounded-md px-3'
: 'bg-muted min-h-9 flex-row items-center gap-2 rounded-md px-3'
}
onPress={() => onChange(tab.value)}
>
<Text
className={
active
? 'text-primary-foreground text-xs font-semibold'
: 'text-muted-foreground text-xs font-semibold'
}
>
{tab.label}
</Text>
{tab.badge === undefined ? null : (
<Text
className={
active
? 'text-primary-foreground text-xs'
: 'text-muted-foreground text-xs'
}
>
{tab.badge}
</Text>
)}
</Pressable>
);
})}
</ScrollView>
);
@@ -0,0 +1,46 @@
import { Pressable, Text, View } from 'react-native';
export type RadioOption<T extends string> = {
description?: string;
label: string;
value: T;
};
export const RadioList = <T extends string>({
label,
onChange,
options,
value,
}: {
label: string;
onChange: (value: T) => void;
options: RadioOption<T>[];
value: T;
}) => (
<View className='gap-2'>
<Text className='text-muted-foreground text-xs font-medium'>{label}</Text>
<View className='gap-2'>
{options.map((option) => {
const active = option.value === value;
return (
<Pressable
key={option.value}
className={
active
? 'border-primary bg-primary/10 rounded-md border p-3'
: 'border-border rounded-md border p-3'
}
onPress={() => onChange(option.value)}
>
<Text className='text-foreground font-medium'>{option.label}</Text>
{option.description ? (
<Text className='text-muted-foreground mt-1 text-xs'>
{option.description}
</Text>
) : null}
</Pressable>
);
})}
</View>
</View>
);
@@ -0,0 +1,100 @@
import { useState } from 'react';
import { Modal, Pressable, Text, View } from 'react-native';
import { Button } from './button';
export type SheetSelectOption<T extends string> = {
description?: string;
label: string;
value: T;
};
export const SheetSelect = <T extends string>({
disabled = false,
label,
onChange,
options,
value,
}: {
disabled?: boolean;
label: string;
onChange: (value: T) => void;
options: SheetSelectOption<T>[];
value: T;
}) => {
const [open, setOpen] = useState(false);
const selected = options.find((option) => option.value === value);
const choose = (nextValue: T) => {
onChange(nextValue);
setOpen(false);
};
return (
<View className='gap-2'>
<Text className='text-muted-foreground text-xs font-medium'>{label}</Text>
<Pressable
className={
disabled
? 'border-border bg-muted/50 rounded-md border px-3 py-3 opacity-60'
: 'border-border bg-background rounded-md border px-3 py-3'
}
disabled={disabled}
onPress={() => setOpen(true)}
>
<Text className='text-foreground font-medium'>
{selected?.label ?? 'Select'}
</Text>
{selected?.description ? (
<Text className='text-muted-foreground mt-1 text-xs'>
{selected.description}
</Text>
) : null}
</Pressable>
<Modal
animationType='slide'
onRequestClose={() => setOpen(false)}
transparent
visible={open}
>
<View className='flex-1 justify-end bg-black/40'>
<View className='bg-background border-border max-h-[80%] gap-3 rounded-t-lg border-t p-4'>
<View className='flex-row items-center justify-between'>
<Text className='text-foreground text-lg font-semibold'>
{label}
</Text>
<Button variant='ghost' onPress={() => setOpen(false)}>
Cancel
</Button>
</View>
<View className='gap-2'>
{options.map((option) => {
const active = option.value === value;
return (
<Pressable
key={option.value}
className={
active
? 'border-primary bg-primary/10 rounded-md border p-3'
: 'border-border rounded-md border p-3'
}
onPress={() => choose(option.value)}
>
<Text className='text-foreground font-medium'>
{option.label}
</Text>
{option.description ? (
<Text className='text-muted-foreground mt-1 text-xs'>
{option.description}
</Text>
) : null}
</Pressable>
);
})}
</View>
</View>
</View>
</Modal>
</View>
);
};
@@ -0,0 +1,25 @@
import { Switch, Text, View } from 'react-native';
export const SwitchRow = ({
label,
description,
value,
onValueChange,
}: {
label: string;
description?: string;
value: boolean;
onValueChange: (value: boolean) => void;
}) => (
<View className='border-border flex-row items-center justify-between gap-4 border-b py-3'>
<View className='min-w-0 flex-1'>
<Text className='text-foreground font-medium'>{label}</Text>
{description ? (
<Text className='text-muted-foreground mt-1 text-xs'>
{description}
</Text>
) : null}
</View>
<Switch value={value} onValueChange={onValueChange} />
</View>
);
+18
View File
@@ -0,0 +1,18 @@
import type { TextInputProps } from 'react-native';
import { Text, TextInput, View } from 'react-native';
export const Textarea = ({
label,
...props
}: TextInputProps & { label: string }) => (
<View className='gap-2'>
<Text className='text-muted-foreground text-xs font-medium'>{label}</Text>
<TextInput
className='border-border bg-background text-foreground min-h-28 rounded-md border px-3 py-3 align-top'
multiline
placeholderTextColor='#71717a'
textAlignVertical='top'
{...props}
/>
</View>
);
@@ -0,0 +1,46 @@
import { useState } from 'react';
import { Text, View } from 'react-native';
import { Button } from '~/components/ui/button';
export const DiffPreview = ({
content,
initialLines = 120,
}: {
content: string;
initialLines?: number;
}) => {
const [expanded, setExpanded] = useState(false);
const lines = content.split('\n');
const visibleLines = expanded ? lines : lines.slice(0, initialLines);
const hiddenCount = Math.max(lines.length - visibleLines.length, 0);
return (
<View className='gap-3 rounded-lg bg-zinc-950 p-3'>
<View>
{visibleLines.map((line, index) => {
const color = line.startsWith('+')
? 'text-emerald-300'
: line.startsWith('-')
? 'text-red-300'
: line.startsWith('@@')
? 'text-sky-300'
: 'text-zinc-100';
return (
<Text
key={`${index}-${line.slice(0, 12)}`}
className={`font-mono text-xs leading-5 ${color}`}
>
{line || ' '}
</Text>
);
})}
</View>
{hiddenCount > 0 ? (
<Button variant='outline' onPress={() => setExpanded(true)}>
Show {hiddenCount} more lines
</Button>
) : null}
</View>
);
};
@@ -0,0 +1,71 @@
import { Text, View } from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { Button } from '~/components/ui/button';
import { Card } from '~/components/ui/card';
import { DiffPreview } from './diff-preview';
type Artifact = {
_id: string;
content: string;
contentType: string;
kind: string;
title: string;
};
export const WorkspaceArtifacts = ({
artifacts,
mode,
}: {
artifacts: Artifact[];
mode: 'diffs' | 'artifacts';
}) => {
const diffArtifacts = artifacts.filter(
(artifact) =>
artifact.contentType === 'text/x-diff' || artifact.kind === 'diff',
);
const visible =
mode === 'diffs'
? diffArtifacts
: artifacts.filter((artifact) => !diffArtifacts.includes(artifact));
return (
<Card className='gap-3'>
<Text className='text-foreground font-semibold'>
{mode === 'diffs' ? 'Diffs' : 'Artifacts'}
</Text>
{visible.length ? (
visible.map((artifact) => (
<View key={artifact._id} className='gap-2'>
<Text className='text-muted-foreground text-sm'>
{artifact.title}
</Text>
{mode === 'diffs' ? (
<DiffPreview content={artifact.content} />
) : (
<>
<Text className='bg-muted text-foreground rounded-md p-3 font-mono text-xs leading-5'>
{artifact.content.slice(0, 2_000)}
</Text>
<Button
variant='outline'
onPress={() =>
void Clipboard.setStringAsync(artifact.content)
}
>
Copy artifact
</Button>
</>
)}
</View>
))
) : (
<Text className='text-muted-foreground text-sm'>
{mode === 'diffs'
? 'Diff artifacts will appear here when the worker records them.'
: 'No non-diff artifacts recorded.'}
</Text>
)}
</Card>
);
};
@@ -0,0 +1,49 @@
import { useState } from 'react';
import { Text, View } from 'react-native';
import { Card } from '~/components/ui/card';
import { ChipRow } from '~/components/ui/chip-row';
import { formatDateTime, titleize } from '~/utils/format';
type Event = {
_id: string;
createdAt: number;
level: string;
message: string;
phase: string;
};
export const WorkspaceEvents = ({ events }: { events: Event[] }) => {
const [level, setLevel] = useState<'all' | 'info' | 'warn' | 'error'>('all');
const filtered =
level === 'all' ? events : events.filter((event) => event.level === level);
return (
<Card className='gap-3'>
<Text className='text-foreground font-semibold'>Events</Text>
<ChipRow
options={[
{ label: 'All', value: 'all' },
{ label: 'Info', value: 'info' },
{ label: 'Warn', value: 'warn' },
{ label: 'Error', value: 'error' },
]}
value={level}
onChange={setLevel}
/>
{filtered.length ? (
filtered.map((event) => (
<View key={event._id} className='border-border border-b pb-2'>
<Text className='text-muted-foreground text-xs'>
{formatDateTime(event.createdAt)} · {titleize(event.phase)} ·{' '}
{titleize(event.level)}
</Text>
<Text className='text-foreground mt-1'>{event.message}</Text>
</View>
))
) : (
<Text className='text-muted-foreground text-sm'>No events.</Text>
)}
</Card>
);
};
@@ -0,0 +1,26 @@
import { Text, View } from 'react-native';
import { Card } from '~/components/ui/card';
import { titleize } from '~/utils/format';
export const WorkspaceMessages = ({
messages,
}: {
messages: { _id: string; content: string; role: string; status: string }[];
}) => (
<Card className='gap-3'>
<Text className='text-foreground font-semibold'>Messages</Text>
{messages.length ? (
messages.map((message) => (
<View key={message._id} className='border-border border-b pb-2'>
<Text className='text-muted-foreground text-xs'>
{titleize(message.role)} · {titleize(message.status)}
</Text>
<Text className='text-foreground mt-1'>{message.content}</Text>
</View>
))
) : (
<Text className='text-muted-foreground text-sm'>No messages yet.</Text>
)}
</Card>
);
@@ -0,0 +1,68 @@
import { Linking, Text, View } from 'react-native';
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 { CopyRow } from '~/components/ui/copy-row';
import { formatDateTime, titleize } from '~/utils/format';
export const WorkspaceSummary = ({
cancelling,
job,
onCancel,
}: {
cancelling: boolean;
job: {
completedAt?: number;
model: string;
pullRequestUrl?: string;
reasoningEffort: string;
startedAt?: number;
status: string;
workBranch: string;
workspaceStatus?: string;
};
onCancel: () => void;
}) => (
<Card className='gap-3'>
<View className='flex-row flex-wrap gap-2'>
<Badge label={titleize(job.status)} tone='primary' />
<Badge label={titleize(job.workspaceStatus ?? 'not_started')} />
</View>
<Text className='text-muted-foreground text-sm'>
Branch: {job.workBranch}
</Text>
<Text className='text-muted-foreground text-sm'>Model: {job.model}</Text>
<Text className='text-muted-foreground text-sm'>
Reasoning: {titleize(job.reasoningEffort)}
</Text>
<Text className='text-muted-foreground text-sm'>
Started: {formatDateTime(job.startedAt)}
</Text>
{job.completedAt ? (
<Text className='text-muted-foreground text-sm'>
Completed: {formatDateTime(job.completedAt)}
</Text>
) : null}
<CopyRow label='Draft PR' value={job.pullRequestUrl} />
{job.pullRequestUrl ? (
<Button onPress={() => void Linking.openURL(job.pullRequestUrl ?? '')}>
Open draft PR
</Button>
) : null}
<ConfirmButton
confirmLabel='Cancel job'
destructive
disabled={
cancelling ||
['cancelled', 'draft_pr_opened', 'failed'].includes(job.status)
}
message='Cancel this workspace job? Running work will be stopped where possible.'
title='Cancel job'
onConfirm={onCancel}
>
{cancelling ? 'Cancelling...' : 'Cancel job'}
</ConfirmButton>
</Card>
);
+26
View File
@@ -0,0 +1,26 @@
export type ParsedEnvSecret = {
name: string;
value: string;
};
export const parseEnvText = (text: string): ParsedEnvSecret[] => {
const secrets: ParsedEnvSecret[] = [];
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const normalized = line.startsWith('export ') ? line.slice(7).trim() : line;
const separator = normalized.indexOf('=');
if (separator <= 0) continue;
const name = normalized.slice(0, separator).trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue;
let value = normalized.slice(separator + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
secrets.push({ name: name.toUpperCase(), value });
}
return secrets;
};
+18
View File
@@ -0,0 +1,18 @@
export const formatDate = (value?: number) =>
value
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
: 'Never';
export const formatDateTime = (value?: number) =>
value
? new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(value)
: 'Never';
export const titleize = (value?: string) =>
value?.replaceAll('_', ' ') ?? 'unknown';
export const truncate = (value: string, length = 80) =>
value.length > length ? `${value.slice(0, length - 3)}...` : value;
+187
View File
@@ -0,0 +1,187 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import { AiProviderProfileForm } from '../../src/components/settings/ai-provider-profile-form';
import { SpoonAgentSettingsForm } from '../../src/components/spoons/spoon-agent-settings-form';
import { SpoonSecretsPanel } from '../../src/components/spoons/spoon-secrets-panel';
describe('mobile forms', () => {
test('SpoonSecretsPanel previews secret names only and imports parsed env values', async () => {
const onImportSecrets = vi.fn().mockResolvedValue(undefined);
render(
<SpoonSecretsPanel
adding={false}
importing={false}
removingId={undefined}
secrets={[]}
onAddSecret={vi.fn()}
onImportSecrets={onImportSecrets}
onRemoveSecret={vi.fn()}
/>,
);
fireEvent.change(screen.getByPlaceholderText('AUTH_SECRET=...'), {
target: {
value: 'AUTH_SECRET=super-secret\nexport AUTHENTIK_CLIENT_ID=client',
},
});
expect(screen.getAllByText(/AUTH_SECRET/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/AUTHENTIK_CLIENT_ID/).length).toBeGreaterThan(
0,
);
expect(screen.getByText(/valid secrets found/).textContent).not.toContain(
'super-secret',
);
fireEvent.click(screen.getByText('Import secrets'));
await waitFor(() =>
expect(onImportSecrets).toHaveBeenCalledWith([
{ name: 'AUTH_SECRET', value: 'super-secret' },
{ name: 'AUTHENTIK_CLIENT_ID', value: 'client' },
]),
);
});
test('SpoonSecretsPanel disables import with no parsed secrets', () => {
render(
<SpoonSecretsPanel
adding={false}
importing={false}
removingId={undefined}
secrets={[]}
onAddSecret={vi.fn()}
onImportSecrets={vi.fn()}
onRemoveSecret={vi.fn()}
/>,
);
expect(screen.getByText('Import secrets').closest('button')).toBeDisabled();
});
test('AiProviderProfileForm selects default model from model options', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<AiProviderProfileForm
saving={false}
onSubmit={onSubmit}
existing={{
_id: 'profile' as never,
authType: 'api_key',
defaultModel: 'gpt-5.1-codex',
enabled: true,
modelOptions: ['gpt-5.1-codex', 'gpt-5.5'],
name: 'OpenAI',
provider: 'openai',
reasoningEffort: 'medium',
}}
/>,
);
fireEvent.click(screen.getByText('gpt-5.1-codex'));
fireEvent.click(screen.getByText('gpt-5.5'));
fireEvent.click(screen.getByText('Save provider'));
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ defaultModel: 'gpt-5.5' }),
),
);
});
test('AiProviderProfileForm shows Codex auth JSON instructions', () => {
render(
<AiProviderProfileForm
saving={false}
onSubmit={vi.fn()}
existing={{
_id: 'profile' as never,
authType: 'opencode_auth_json',
defaultModel: 'gpt-5.1-codex',
enabled: true,
modelOptions: ['gpt-5.1-codex'],
name: 'Codex',
provider: 'opencode_openai_login',
reasoningEffort: 'medium',
}}
/>,
);
expect(screen.getByText(/~\/.codex\/auth.json/)).toBeTruthy();
});
test('SpoonAgentSettingsForm disables provider/model controls without provider profiles', () => {
render(
<SpoonAgentSettingsForm
profiles={[]}
onUpdate={vi.fn()}
agent={{
agentModel: '',
autoDetectCommands: true,
branchPrefix: 'spoon/agent',
enabled: true,
materializeEnvFileByDefault: false,
reasoningEffort: 'medium',
}}
/>,
);
expect(
screen.getByText('Configure an AI provider in Settings'),
).toBeTruthy();
expect(
screen.getByText('No models available').closest('button'),
).toBeDisabled();
});
test('SpoonAgentSettingsForm applies selected provider defaults', async () => {
const onUpdate = vi.fn().mockResolvedValue(undefined);
render(
<SpoonAgentSettingsForm
agent={{
agentModel: 'gpt-5.1-codex',
autoDetectCommands: true,
branchPrefix: 'spoon/agent',
enabled: true,
materializeEnvFileByDefault: false,
reasoningEffort: 'high',
}}
profiles={[
{
_id: 'profile-a' as never,
defaultModel: 'gpt-5.1-codex',
enabled: true,
modelOptions: ['gpt-5.1-codex'],
name: 'OpenAI',
reasoningEffort: 'medium',
},
{
_id: 'profile-b' as never,
defaultModel: 'claude-sonnet-4-5',
enabled: true,
modelOptions: ['claude-sonnet-4-5'],
name: 'Anthropic',
reasoningEffort: 'low',
},
]}
onUpdate={onUpdate}
/>,
);
fireEvent.click(screen.getByText('OpenAI'));
fireEvent.click(screen.getByText('Anthropic'));
await waitFor(() =>
expect(onUpdate).toHaveBeenCalledWith(
expect.objectContaining({
agentModel: 'claude-sonnet-4-5',
reasoningEffort: 'low',
}),
),
);
});
});
@@ -0,0 +1,127 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import DashboardRoute from '../../src/app/(app)/dashboard';
import SettingsRoute from '../../src/app/(app)/settings';
import SpoonsRoute from '../../src/app/(app)/spoons';
import ThreadsRoute from '../../src/app/(app)/threads';
import WorkspaceRoute from '../../src/app/(app)/workspace/[jobId]';
import { mockedUseQuery } from '../setup';
describe('mobile route smoke tests', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedUseQuery.mockReset();
});
test('Dashboard renders metrics from mocked Convex data', () => {
mockedUseQuery
.mockReturnValueOnce([
{
_id: 'spoon-1',
status: 'active',
syncStatus: 'behind',
upstreamAheadBy: 3,
},
] as never)
.mockReturnValueOnce([] as never)
.mockReturnValueOnce([
{
_id: 'thread-1',
source: 'user_request',
status: 'open',
title: 'Update auth',
updatedAt: Date.UTC(2026, 0, 1),
},
] as never);
render(<DashboardRoute />);
expect(screen.getByText('Dashboard')).toBeTruthy();
expect(screen.getByText('Update auth')).toBeTruthy();
expect(screen.getByText('Upstream commits')).toBeTruthy();
});
test('Spoons list renders empty state and one row', () => {
mockedUseQuery
.mockReturnValueOnce([
{
_id: 'spoon-1',
forkOwner: 'gib',
forkRepo: 'usesend',
name: 'usesend-authentik',
status: 'active',
syncStatus: 'up_to_date',
upstreamAheadBy: 0,
upstreamOwner: 'usesend',
upstreamRepo: 'usesend',
},
] as never)
.mockReturnValueOnce([] as never);
render(<SpoonsRoute />);
expect(screen.getByText('Spoons')).toBeTruthy();
expect(screen.getByText('usesend-authentik')).toBeTruthy();
});
test('Threads list renders filters and rows', () => {
mockedUseQuery.mockReturnValueOnce([
{
_id: 'thread-1',
source: 'upstream_update',
status: 'waiting_for_user',
title: 'Upstream auth changes landed',
updatedAt: Date.UTC(2026, 0, 1),
},
] as never);
render(<ThreadsRoute />);
expect(screen.getByText('Waiting')).toBeTruthy();
expect(screen.getByText('Upstream auth changes landed')).toBeTruthy();
});
test('Workspace route renders tabs and job status', () => {
mockedUseQuery
.mockReturnValueOnce({
_id: 'job-1',
model: 'gpt-5.1-codex',
reasoningEffort: 'medium',
status: 'running',
workBranch: 'spoon/thread/example',
workspaceStatus: 'active',
} as never)
.mockReturnValueOnce([] as never)
.mockReturnValueOnce([] as never)
.mockReturnValueOnce([] as never);
render(<WorkspaceRoute />);
expect(screen.getByText('Workspace review')).toBeTruthy();
expect(screen.getByText('Messages')).toBeTruthy();
expect(screen.getByText('running')).toBeTruthy();
});
test('Settings index renders GitHub and AI provider summaries', () => {
mockedUseQuery
.mockReturnValueOnce({ email: 'gib@example.com' } as never)
.mockReturnValueOnce({
displayName: 'gibbyb',
status: 'active',
} as never)
.mockReturnValueOnce([
{
_id: 'provider-1',
isDefault: true,
name: 'Codex',
},
] as never);
render(<SettingsRoute />);
expect(screen.getByText('gib@example.com')).toBeTruthy();
expect(screen.getByText('GitHub connected as gibbyb')).toBeTruthy();
expect(screen.getByText('1 provider, default Codex')).toBeTruthy();
});
});
@@ -0,0 +1,124 @@
import { Alert } from 'react-native';
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { SpoonStatusBadge } from '../../src/components/spoons/spoon-status-badge';
import { ThreadStatusBadge } from '../../src/components/threads/thread-status-badge';
import { ConfirmButton } from '../../src/components/ui/confirm-button';
import { PillTabs } from '../../src/components/ui/pill-tabs';
import { SheetSelect } from '../../src/components/ui/sheet-select';
import { DiffPreview } from '../../src/components/workspace/diff-preview';
describe('mobile UI primitives', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('PillTabs renders labels and changes selection', () => {
const onChange = vi.fn();
render(
<PillTabs
tabs={[
{ label: 'Overview', value: 'overview' },
{ label: 'Settings', value: 'settings' },
]}
value='overview'
onChange={onChange}
/>,
);
fireEvent.click(screen.getByText('Settings'));
expect(screen.getByText('Overview')).toBeTruthy();
expect(onChange).toHaveBeenCalledWith('settings');
});
test('SheetSelect opens and chooses an option', () => {
const onChange = vi.fn();
render(
<SheetSelect
label='Provider'
options={[
{ label: 'OpenAI', value: 'openai' },
{ label: 'Anthropic', value: 'anthropic' },
]}
value='openai'
onChange={onChange}
/>,
);
fireEvent.click(screen.getByText('OpenAI'));
fireEvent.click(screen.getByText('Anthropic'));
expect(onChange).toHaveBeenCalledWith('anthropic');
});
test('SheetSelect respects disabled state', () => {
const onChange = vi.fn();
render(
<SheetSelect
disabled
label='Provider'
options={[{ label: 'OpenAI', value: 'openai' }]}
value='openai'
onChange={onChange}
/>,
);
fireEvent.click(screen.getByText('OpenAI'));
expect(onChange).not.toHaveBeenCalled();
});
test('ConfirmButton delegates confirmation to Alert', () => {
const onConfirm = vi.fn();
render(
<ConfirmButton
confirmLabel='Delete'
message='Delete this?'
title='Delete'
onConfirm={onConfirm}
>
Remove
</ConfirmButton>,
);
fireEvent.click(screen.getByText('Remove'));
const calls = vi.mocked(Alert.alert).mock.calls;
const confirm = calls[0]?.[2]?.[1];
confirm?.onPress?.();
expect(onConfirm).toHaveBeenCalledOnce();
});
test('DiffPreview truncates and expands long diffs', () => {
const diff = Array.from({ length: 125 }, (_, index) =>
index % 2 === 0 ? `+added ${index}` : `-removed ${index}`,
).join('\n');
render(<DiffPreview content={diff} initialLines={3} />);
expect(screen.getByText('+added 0')).toBeTruthy();
expect(screen.queryByText('-removed 5')).toBeNull();
fireEvent.click(screen.getByText('Show 122 more lines'));
expect(screen.getByText('-removed 5')).toBeTruthy();
});
test('status badges render readable labels', () => {
render(
<>
<SpoonStatusBadge status='up_to_date' />
<ThreadStatusBadge status='waiting_for_user' />
</>,
);
expect(screen.getByText('up to date')).toBeTruthy();
expect(screen.getByText('waiting for user')).toBeTruthy();
});
});
+138
View File
@@ -0,0 +1,138 @@
import React from 'react';
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
Object.defineProperty(globalThis, '__DEV__', {
configurable: true,
value: false,
});
const createElement =
(tag: string) =>
({
children,
onChangeText,
onPress,
value,
...props
}: {
children?: React.ReactNode;
onChangeText?: (value: string) => void;
onPress?: () => void;
value?: string;
[key: string]: unknown;
}) => {
const safeProps: Record<string, unknown> = {
...props,
className:
typeof props.className === 'string' ? props.className : undefined,
disabled: props.disabled as boolean | undefined,
onChange: onChangeText
? (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
onChangeText(event.currentTarget.value)
: undefined,
onClick: onPress,
value,
};
delete safeProps.keyboardType;
delete safeProps.keyboardShouldPersistTaps;
delete safeProps.placeholderTextColor;
delete safeProps.secureTextEntry;
delete safeProps.showsHorizontalScrollIndicator;
delete safeProps.textAlignVertical;
return React.createElement(tag, safeProps, children);
};
const TextInput = ({
multiline,
...props
}: {
multiline?: boolean;
[key: string]: unknown;
}) => createElement(multiline ? 'textarea' : 'input')(props);
const mocks = vi.hoisted(() => ({
alert: vi.fn(),
useAction: vi.fn(() => vi.fn()),
useMutation: vi.fn(() => vi.fn()),
useQuery: vi.fn(() => undefined),
}));
vi.mock('react-native', () => ({
Alert: { alert: mocks.alert },
Linking: { openURL: vi.fn() },
Modal: ({
children,
visible,
}: {
children?: React.ReactNode;
visible?: boolean;
}) => (visible ? React.createElement('div', {}, children) : null),
Pressable: createElement('button'),
Platform: {
OS: 'web',
select: (values: Record<string, unknown>) => values.web ?? values.default,
},
RefreshControl: createElement('div'),
ScrollView: createElement('div'),
Switch: createElement('input'),
Text: createElement('span'),
TextInput,
TurboModuleRegistry: {
get: vi.fn(() => undefined),
getEnforcing: vi.fn(() => ({})),
},
View: createElement('div'),
}));
vi.mock('expo-clipboard', () => ({
setStringAsync: vi.fn(),
}));
vi.mock('expo-haptics', () => ({
impactAsync: vi.fn(),
notificationAsync: vi.fn(),
selectionAsync: vi.fn(),
}));
vi.mock('react-native-safe-area-context', () => ({
SafeAreaView: createElement('div'),
}));
vi.mock('expo-router', () => ({
Link: ({ children }: { children?: React.ReactNode }) => children,
Stack: {
Screen: () => null,
},
useLocalSearchParams: () => ({}),
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
}),
}));
vi.mock('convex/react', () => ({
useAction: mocks.useAction,
useMutation: mocks.useMutation,
useQuery: mocks.useQuery,
}));
vi.mock('@convex-dev/auth/react', () => ({
useAuthActions: () => ({
signIn: vi.fn(),
signOut: vi.fn(),
}),
}));
export const mockedAlert = mocks.alert;
export const mockedUseAction = mocks.useAction;
export const mockedUseMutation = mocks.useMutation;
export const mockedUseQuery = mocks.useQuery;
afterEach(() => {
cleanup();
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, test } from 'vitest';
import { parseEnvText } from '../../src/utils/env';
describe('parseEnvText', () => {
test('parses dotenv content without exposing invalid rows', () => {
expect(
parseEnvText(`
# comment
AUTH_SECRET="secret=value"
export authentik_client_id='client'
1INVALID=nope
EMPTY=
`),
).toEqual([
{ name: 'AUTH_SECRET', value: 'secret=value' },
{ name: 'AUTHENTIK_CLIENT_ID', value: 'client' },
{ name: 'EMPTY', value: '' },
]);
});
test('ignores blank lines and strips matching quotes only', () => {
expect(
parseEnvText(`
PLAIN=value
QUOTED="value"
SINGLE='value'
UNMATCHED="value
`),
).toEqual([
{ name: 'PLAIN', value: 'value' },
{ name: 'QUOTED', value: 'value' },
{ name: 'SINGLE', value: 'value' },
{ name: 'UNMATCHED', value: '"value' },
]);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'vitest';
import {
formatDate,
formatDateTime,
titleize,
truncate,
} from '../../src/utils/format';
describe('format utilities', () => {
test('formats missing timestamps as never', () => {
expect(formatDate(undefined)).toBe('Never');
expect(formatDateTime(undefined)).toBe('Never');
});
test('formats known timestamps', () => {
const value = Date.UTC(2026, 0, 2, 3, 4, 5);
expect(formatDate(value)).toContain('2026');
expect(formatDateTime(value)).toContain('2026');
});
test('titleizes machine values', () => {
expect(titleize('waiting_for_user')).toBe('waiting for user');
});
test('truncates long text', () => {
expect(truncate('abcdef', 4)).toBe('a...');
expect(truncate('abc', 4)).toBe('abc');
});
});
+1
View File
@@ -10,6 +10,7 @@
},
"include": [
"src",
"tests",
"*.ts",
"*.js",
".expo/types/**/*.ts",
+35
View File
@@ -0,0 +1,35 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
import { jsdomProject, nodeProject } from '@spoon/vitest-config';
const srcRoot = fileURLToPath(new URL('./src', import.meta.url));
const setupFile = fileURLToPath(new URL('./tests/setup.ts', import.meta.url));
const alias = {
'~': srcRoot,
'~/': `${srcRoot}/`,
};
const componentProject = jsdomProject('component', [
'tests/component/**/*.test.{ts,tsx}',
]);
export default defineConfig({
resolve: {
alias,
},
test: {
projects: [
nodeProject('unit', ['tests/unit/**/*.test.{ts,tsx}']),
nodeProject('integration', ['tests/integration/**/*.test.{ts,tsx}']),
{
...componentProject,
resolve: { alias },
test: {
...componentProject.test,
setupFiles: [setupFile],
},
},
],
},
});