Update expo application
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
Reference in New Issue
Block a user