Move to threads based system.

This commit is contained in:
Gabriel Brown
2026-06-22 10:37:26 -04:00
parent 8ae6c4b533
commit 206b64176b
82 changed files with 6169 additions and 1930 deletions
+4 -143
View File
@@ -1,146 +1,7 @@
'use client';
import { redirect } from 'next/navigation';
import { useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from '@spoon/ui';
const AgentsPage = () => {
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
const requests = useQuery(api.agentRequests.listRecent, { limit: 50 }) ?? [];
const createRequest = useMutation(api.agentRequests.create);
const [spoonId, setSpoonId] = useState('');
const [targetBranch, setTargetBranch] = useState('');
const [prompt, setPrompt] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!spoonId) {
toast.error('Choose a Spoon first.');
return;
}
setSubmitting(true);
try {
await createRequest({
spoonId: spoonId as Id<'spoons'>,
prompt,
targetBranch: targetBranch || undefined,
});
setPrompt('');
setTargetBranch('');
toast.success('Agent request queued.');
} catch (error) {
console.error(error);
toast.error('Could not queue agent request.');
} finally {
setSubmitting(false);
}
};
return (
<main className='space-y-6'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Agents</h1>
<p className='text-muted-foreground mt-2'>
Queue prompt-driven work for future AI merge request automation.
</p>
</div>
<div className='grid gap-6 xl:grid-cols-[0.9fr_1.1fr]'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Request work</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className='space-y-4'>
<div className='grid gap-2'>
<Label>Spoon</Label>
<Select value={spoonId} onValueChange={setSpoonId}>
<SelectTrigger className='w-full'>
<SelectValue placeholder='Choose a Spoon' />
</SelectTrigger>
<SelectContent>
{spoons.map((spoon) => (
<SelectItem key={spoon._id} value={spoon._id}>
{spoon.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='targetBranch'>Target branch</Label>
<Input
id='targetBranch'
value={targetBranch}
placeholder='feature/my-change'
onChange={(event) => setTargetBranch(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='prompt'>Prompt</Label>
<Textarea
id='prompt'
value={prompt}
required
onChange={(event) => setPrompt(event.target.value)}
/>
</div>
<Button type='submit' disabled={submitting || !spoons.length}>
{submitting ? 'Queueing...' : 'Queue request'}
</Button>
</form>
</CardContent>
</Card>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Recent requests</CardTitle>
</CardHeader>
<CardContent>
{requests.length ? (
<div className='space-y-3'>
{requests.map((request) => (
<div key={request._id} className='border-border border p-4'>
<p className='font-medium'>{request.prompt}</p>
<p className='text-muted-foreground mt-1 text-sm'>
{request.status.replaceAll('_', ' ')} ·{' '}
{(request.requestType ?? 'future_code_change').replaceAll(
'_',
' ',
)}{' '}
· {request.source ?? 'user'}
</p>
</div>
))}
</div>
) : (
<p className='text-muted-foreground'>
Agent requests will appear here after you create a Spoon and
queue work.
</p>
)}
</CardContent>
</Card>
</div>
</main>
);
const AgentsRedirectPage = () => {
redirect('/threads?source=user_request');
};
export default AgentsPage;
export default AgentsRedirectPage;
+26 -21
View File
@@ -3,9 +3,9 @@
import Link from 'next/link';
import { MetricCard } from '@/components/dashboard/metric-card';
import { SpoonCard } from '@/components/spoons/spoon-card';
import { MaintenanceQueue } from '@/components/updates/maintenance-queue';
import { MaintenanceQueue } from '@/components/threads/maintenance-queue';
import { useQuery } from 'convex/react';
import { Bot, GitBranch, RefreshCw, ShieldCheck } from 'lucide-react';
import { GitBranch, MessageSquare, RefreshCw, ShieldCheck } from 'lucide-react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
@@ -13,9 +13,7 @@ import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
const DashboardPage = () => {
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
const agentRequests =
useQuery(api.agentRequests.listRecent, { limit: 5 }) ?? [];
const aiReviews = useQuery(api.aiReviews.listRecent, { limit: 5 }) ?? [];
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
const activeSpoons = spoons.filter(
(spoon) => spoon.status === 'active',
).length;
@@ -34,7 +32,8 @@ const DashboardPage = () => {
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Dashboard</h1>
<p className='text-muted-foreground mt-2'>
Monitor managed forks, upstream activity, and queued agent work.
Monitor managed forks, upstream activity, and open maintenance
threads.
</p>
</div>
<Button asChild>
@@ -56,10 +55,17 @@ const DashboardPage = () => {
icon={RefreshCw}
/>
<MetricCard
label='Agent requests'
value={agentRequests.length}
note='Queued and recent'
icon={Bot}
label='Open threads'
value={
threads.filter(
(thread) =>
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
thread.status,
),
).length
}
note='Across all Spoons'
icon={MessageSquare}
/>
<MetricCard
label='Upstream commits'
@@ -71,7 +77,7 @@ const DashboardPage = () => {
<section className='space-y-3'>
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
<MaintenanceQueue spoons={spoons} />
<MaintenanceQueue threads={threads} />
</section>
<div className='grid gap-6 xl:grid-cols-2'>
@@ -126,29 +132,28 @@ const DashboardPage = () => {
</Card>
<Card className='mt-4 shadow-none'>
<CardHeader>
<CardTitle className='text-base'>AI reviews</CardTitle>
<CardTitle className='text-base'>Recent threads</CardTitle>
</CardHeader>
<CardContent>
{aiReviews.length ? (
{threads.length ? (
<div className='space-y-3'>
{aiReviews.map((review) => (
{threads.slice(0, 5).map((thread) => (
<div
key={review._id}
key={thread._id}
className='border-border border p-3 text-sm'
>
<p className='font-medium capitalize'>
{review.risk} risk
</p>
<p className='font-medium'>{thread.title}</p>
<p className='text-muted-foreground'>
{review.outputSummary ?? review.inputSummary}
{thread.status.replaceAll('_', ' ')} ·{' '}
{thread.source.replaceAll('_', ' ')}
</p>
</div>
))}
</div>
) : (
<p className='text-muted-foreground text-sm'>
OpenAI compatibility reviews will appear here after you run
them on a Spoon.
Threads appear when you request work or upstream changes need
review.
</p>
)}
</CardContent>
@@ -0,0 +1,16 @@
import { AiProviderProfilesPanel } from '@/components/integrations/ai-provider-profiles-panel';
const AiProvidersPage = () => (
<section className='max-w-5xl space-y-4'>
<div>
<h2 className='text-xl font-semibold'>AI providers</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Configure encrypted API-key profiles and OpenCode auth profiles for
agent workspaces.
</p>
</div>
<AiProviderProfilesPanel />
</section>
);
export default AiProvidersPage;
+2 -13
View File
@@ -1,16 +1,5 @@
import { OpenAiStatusPanel } from '@/components/integrations/openai-status-panel';
import { redirect } from 'next/navigation';
const AiSettingsPage = () => (
<section className='max-w-3xl space-y-4'>
<div>
<h2 className='text-xl font-semibold'>AI</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Configure the OpenAI key, review model, and thinking level used for
compatibility reviews.
</p>
</div>
<OpenAiStatusPanel />
</section>
);
const AiSettingsPage = () => redirect('/settings/ai-providers');
export default AiSettingsPage;
+1 -1
View File
@@ -10,7 +10,7 @@ import { cn } from '@spoon/ui';
const settingsItems = [
{ href: '/settings/profile', label: 'Profile', icon: User },
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
{ href: '/settings/ai', label: 'AI', icon: Brain },
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
{ href: '/settings/security', label: 'Security', icon: Shield },
];
@@ -0,0 +1,27 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell';
import { ArrowLeft } from 'lucide-react';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button } from '@spoon/ui';
const AgentWorkspacePage = () => {
const params = useParams<{ spoonId: string; jobId: string }>();
return (
<main className='space-y-4'>
<Button asChild variant='ghost' size='sm'>
<Link href={`/spoons/${params.spoonId}`}>
<ArrowLeft className='size-4' />
Back to Spoon
</Link>
</Button>
<AgentWorkspaceShell jobId={params.jobId as Id<'agentJobs'>} />
</main>
);
};
export default AgentWorkspacePage;
@@ -1,11 +1,11 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { AgentJobList } from '@/components/agents/agent-job-list';
import { AgentRequestForm } from '@/components/agents/agent-request-form';
import { SpoonActivityTimeline } from '@/components/spoons/spoon-activity-timeline';
import { SpoonAgentSettingsForm } from '@/components/spoons/spoon-agent-settings-form';
import { SpoonAiReviewPanel } from '@/components/spoons/spoon-ai-review-panel';
import { SpoonClonePanel } from '@/components/spoons/spoon-clone-panel';
import { SpoonCommitList } from '@/components/spoons/spoon-commit-list';
import { SpoonDetailHeader } from '@/components/spoons/spoon-detail-header';
@@ -46,12 +46,10 @@ const SpoonDetailPage = () => {
}) ?? [];
const pullRequests =
useQuery(api.spoonPullRequests.listForSpoon, { spoonId, limit: 100 }) ?? [];
const reviews =
useQuery(api.aiReviews.listForSpoon, { spoonId, limit: 25 }) ?? [];
const syncRuns =
useQuery(api.syncRuns.listForSpoon, { spoonId, limit: 25 }) ?? [];
const agentRequests =
useQuery(api.agentRequests.listForSpoon, { spoonId, limit: 25 }) ?? [];
const threads =
useQuery(api.threads.listForSpoon, { spoonId, limit: 25 }) ?? [];
const agentSettings = useQuery(api.spoonAgentSettings.getForSpoon, {
spoonId,
});
@@ -68,7 +66,7 @@ const SpoonDetailPage = () => {
<SpoonMetrics
spoon={details.spoon}
state={details.state}
latestReview={details.latestReview}
latestThread={threads[0]}
/>
{details.spoon.lastError ? (
<Card className='border-destructive shadow-none'>
@@ -95,11 +93,8 @@ const SpoonDetailPage = () => {
<TabsTrigger className='h-9 flex-none px-3' value='pulls'>
Pull requests
</TabsTrigger>
<TabsTrigger className='h-9 flex-none px-3' value='ai'>
AI review
</TabsTrigger>
<TabsTrigger className='h-9 flex-none px-3' value='agent'>
Agent work
<TabsTrigger className='h-9 flex-none px-3' value='threads'>
Threads
</TabsTrigger>
<TabsTrigger className='h-9 flex-none px-3' value='activity'>
Activity
@@ -125,6 +120,17 @@ const SpoonDetailPage = () => {
'unknown'
).replaceAll('_', ' ')}
</p>
{details.effectiveUpstreamAheadBy === 0 &&
(details.state?.upstreamAheadBy ??
details.spoon.upstreamAheadBy ??
0) > 0 ? (
<p className='text-muted-foreground mt-1 text-xs'>
Up to date after ignored upstream changes. Raw upstream
ahead:{' '}
{details.state?.upstreamAheadBy ??
details.spoon.upstreamAheadBy}
</p>
) : null}
</div>
<div>
<p className='text-muted-foreground'>Default branches</p>
@@ -155,37 +161,34 @@ const SpoonDetailPage = () => {
</Card>
<Card className='shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Latest AI review</CardTitle>
<CardTitle className='text-base'>Latest thread</CardTitle>
</CardHeader>
<CardContent className='space-y-3 text-sm'>
{details.latestReview ? (
{threads[0] ? (
<>
<div className='grid grid-cols-2 gap-3'>
<div>
<p className='text-muted-foreground'>Risk</p>
<p className='text-muted-foreground'>Status</p>
<p className='mt-1 font-semibold capitalize'>
{details.latestReview.risk}
{threads[0].status.replaceAll('_', ' ')}
</p>
</div>
<div>
<p className='text-muted-foreground'>Action</p>
<p className='text-muted-foreground'>Source</p>
<p className='mt-1 font-semibold capitalize'>
{details.latestReview.recommendedAction.replaceAll(
'_',
' ',
)}
{threads[0].source.replaceAll('_', ' ')}
</p>
</div>
</div>
<p className='text-muted-foreground'>
{details.latestReview.outputSummary ??
details.latestReview.inputSummary}
{threads[0].summary ??
'Open the thread to continue maintenance work.'}
</p>
</>
) : (
<p className='text-muted-foreground'>
Run a refresh and AI review to get a compatibility summary
for upstream changes.
Refresh GitHub state or create a thread to start maintenance
work for this Spoon.
</p>
)}
</CardContent>
@@ -239,26 +242,45 @@ const SpoonDetailPage = () => {
<SpoonPrList pullRequests={pullRequests} />
</TabsContent>
<TabsContent value='ai' className='space-y-4'>
<SpoonAiReviewPanel
latestReview={details.latestReview}
reviews={reviews}
/>
</TabsContent>
<TabsContent value='agent' className='space-y-4'>
<TabsContent value='threads' className='space-y-4'>
<AgentRequestForm
spoon={details.spoon}
agentSettings={agentSettings}
/>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>Spoon threads</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{threads.length ? (
threads.map((thread) => (
<Link
key={thread._id}
href={`/threads/${thread._id}`}
className='border-border hover:border-primary/50 block rounded-md border p-3 transition-colors'
>
<p className='font-medium'>{thread.title}</p>
<p className='text-muted-foreground mt-1 text-sm'>
{thread.status.replaceAll('_', ' ')} ·{' '}
{thread.source.replaceAll('_', ' ')}
</p>
</Link>
))
) : (
<p className='text-muted-foreground text-sm'>
No threads exist for this Spoon yet.
</p>
)}
</CardContent>
</Card>
<AgentJobList jobs={agentJobs} />
</TabsContent>
<TabsContent value='activity'>
<SpoonActivityTimeline
syncRuns={syncRuns}
reviews={reviews}
requests={agentRequests}
threads={threads}
jobs={agentJobs}
/>
</TabsContent>
+166 -8
View File
@@ -1,33 +1,185 @@
'use client';
import Link from 'next/link';
import { SpoonCard } from '@/components/spoons/spoon-card';
import { useRouter } from 'next/navigation';
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
import { useQuery } from 'convex/react';
import {
ArrowUpRight,
GitBranch,
MessageSquare,
RefreshCw,
} from 'lucide-react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent } from '@spoon/ui';
import {
Badge,
Button,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@spoon/ui';
const formatDate = (value?: number) =>
value
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
: 'Never';
const SpoonsPage = () => {
const router = useRouter();
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
const active = spoons.filter((spoon) => spoon.status === 'active').length;
const needsReview = threads.filter(
(thread) =>
thread.spoonId &&
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
).length;
const upstreamWaiting = spoons.reduce(
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
0,
);
return (
<main className='space-y-6'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Spoons</h1>
<p className='text-muted-foreground mt-2'>
Managed forks you want to keep close to their upstream projects.
Managed forks, upstream drift, active maintenance threads, and fork
metadata in one place.
</p>
</div>
<Button asChild>
<Link href='/spoons/new'>New Spoon</Link>
</Button>
</div>
<div className='grid gap-3 md:grid-cols-3'>
<Card className='shadow-none'>
<CardContent className='flex items-center justify-between p-4'>
<div>
<p className='text-muted-foreground text-sm'>Managed</p>
<p className='text-2xl font-semibold'>{spoons.length}</p>
</div>
<GitBranch className='text-muted-foreground size-5' />
</CardContent>
</Card>
<Card className='shadow-none'>
<CardContent className='flex items-center justify-between p-4'>
<div>
<p className='text-muted-foreground text-sm'>Active</p>
<p className='text-2xl font-semibold'>{active}</p>
</div>
<RefreshCw className='text-muted-foreground size-5' />
</CardContent>
</Card>
<Card className='shadow-none'>
<CardContent className='flex items-center justify-between p-4'>
<div>
<p className='text-muted-foreground text-sm'>Open threads</p>
<p className='text-2xl font-semibold'>{needsReview}</p>
</div>
<MessageSquare className='text-muted-foreground size-5' />
</CardContent>
</Card>
</div>
{spoons.length ? (
<div className='grid gap-4 xl:grid-cols-2'>
{spoons.map((spoon) => (
<SpoonCard key={spoon._id} spoon={spoon} />
))}
</div>
<Card className='shadow-none'>
<CardContent className='p-0'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='pl-4'>Spoon</TableHead>
<TableHead>Status</TableHead>
<TableHead>Fork</TableHead>
<TableHead>Drift</TableHead>
<TableHead>Cadence</TableHead>
<TableHead>Last checked</TableHead>
<TableHead className='pr-4 text-right'>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{spoons.map((spoon) => {
const href = `/spoons/${spoon._id}`;
return (
<TableRow
key={spoon._id}
role='link'
tabIndex={0}
className='hover:bg-muted/50 cursor-pointer'
onClick={() => router.push(href)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
router.push(href);
}
}}
>
<TableCell className='pl-4'>
<Link
href={href}
className='group inline-flex min-w-0 flex-col'
onClick={(event) => event.stopPropagation()}
>
<span className='group-hover:text-primary font-medium transition-colors'>
{spoon.name}
</span>
<span className='text-muted-foreground text-xs'>
{spoon.upstreamOwner}/{spoon.upstreamRepo}
</span>
</Link>
</TableCell>
<TableCell>
<SpoonStatusBadge
status={spoon.syncStatus ?? spoon.status}
/>
</TableCell>
<TableCell>
{spoon.forkOwner && spoon.forkRepo ? (
<span className='font-medium'>
{spoon.forkOwner}/{spoon.forkRepo}
</span>
) : (
<Badge variant='outline'>Missing metadata</Badge>
)}
</TableCell>
<TableCell>
<div className='text-sm'>
<p>{spoon.upstreamAheadBy ?? 0} upstream</p>
<p className='text-muted-foreground'>
{spoon.forkAheadBy ?? 0} fork-only
</p>
</div>
</TableCell>
<TableCell className='capitalize'>
{spoon.syncCadence}
</TableCell>
<TableCell>{formatDate(spoon.lastCheckedAt)}</TableCell>
<TableCell className='pr-4 text-right'>
<Button size='sm' variant='outline' asChild>
<Link
href={href}
onClick={(event) => event.stopPropagation()}
>
Open
<ArrowUpRight className='size-3' />
</Link>
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<Card className='shadow-none'>
<CardContent className='p-8'>
@@ -42,6 +194,12 @@ const SpoonsPage = () => {
</CardContent>
</Card>
)}
{spoons.length ? (
<p className='text-muted-foreground text-sm'>
Raw upstream commits waiting across all Spoons: {upstreamWaiting}
</p>
) : null}
</main>
);
};
@@ -0,0 +1,188 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useMutation, useQuery } from 'convex/react';
import { ArrowUpRight, CheckCircle2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Textarea,
} from '@spoon/ui';
const ThreadDetailPage = () => {
const params = useParams<{ threadId: string }>();
const threadId = params.threadId 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);
if (details === undefined) {
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
}
const { thread, spoon, latestJob } = details;
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const value = form.get('message');
const content = typeof value === 'string' ? value : '';
try {
await appendMessage({ threadId, content });
event.currentTarget.reset();
toast.success('Message added.');
} catch (error) {
console.error(error);
toast.error('Could not add message.');
}
};
return (
<main className='space-y-6'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-start'>
<div>
<div className='flex flex-wrap items-center gap-2'>
<h1 className='text-3xl font-semibold tracking-normal'>
{thread.title}
</h1>
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
<Badge variant='outline'>
{thread.source.replaceAll('_', ' ')}
</Badge>
{thread.maintenanceOutcome ? (
<Badge variant='secondary'>
{thread.maintenanceOutcome.replaceAll('_', ' ')}
</Badge>
) : null}
</div>
<p className='text-muted-foreground mt-2 max-w-3xl'>
{thread.summary ?? 'No summary has been recorded yet.'}
</p>
{spoon ? (
<Button variant='link' className='mt-2 h-auto p-0' asChild>
<Link href={`/spoons/${spoon._id}`}>
{spoon.name}
<ArrowUpRight className='size-3' />
</Link>
</Button>
) : null}
</div>
<div className='flex flex-wrap gap-2'>
{latestJob ? (
<Button variant='outline' asChild>
<Link
href={`/spoons/${latestJob.spoonId}/agent/${latestJob._id}`}
>
Open workspace
</Link>
</Button>
) : null}
{latestJob?.pullRequestUrl ? (
<Button asChild>
<a
href={latestJob.pullRequestUrl}
target='_blank'
rel='noreferrer'
>
Open PR
</a>
</Button>
) : null}
<Button
variant='outline'
onClick={() =>
markResolved({ threadId }).then(() =>
toast.success('Thread resolved.'),
)
}
>
<CheckCircle2 className='size-4' />
Resolve
</Button>
<Button
variant='outline'
onClick={() =>
cancel({ threadId }).then(() =>
toast.success('Thread cancelled.'),
)
}
>
<XCircle className='size-4' />
Cancel
</Button>
</div>
</div>
<div className='grid gap-6 xl:grid-cols-[1fr_320px]'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Conversation</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
{messages.map((message) => (
<div
key={message._id}
className='border-border rounded-md border p-3'
>
<div className='mb-2 flex items-center justify-between gap-2'>
<Badge variant='outline'>{message.role}</Badge>
<span className='text-muted-foreground text-xs'>
{new Date(message.createdAt).toLocaleString()}
</span>
</div>
<p className='text-sm whitespace-pre-wrap'>{message.content}</p>
</div>
))}
<form onSubmit={submit} className='space-y-3'>
<Textarea
name='message'
required
minLength={2}
placeholder='Add context or instructions for this thread.'
/>
<Button type='submit'>Add message</Button>
</form>
</CardContent>
</Card>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Thread state</CardTitle>
</CardHeader>
<CardContent className='space-y-3 text-sm'>
<div>
<p className='text-muted-foreground'>Priority</p>
<p className='font-medium capitalize'>{thread.priority}</p>
</div>
<div>
<p className='text-muted-foreground'>Upstream range</p>
<p className='font-mono text-xs break-all'>
{thread.upstreamFrom ?? 'unknown'} {' '}
{thread.upstreamTo ?? 'unknown'}
</p>
</div>
<div>
<p className='text-muted-foreground'>Latest job</p>
<p className='font-medium'>
{latestJob?.status.replaceAll('_', ' ') ?? 'No job queued'}
</p>
</div>
</CardContent>
</Card>
</div>
</main>
);
};
export default ThreadDetailPage;
+128
View File
@@ -0,0 +1,128 @@
'use client';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useQuery } from 'convex/react';
import { MessageSquare, Plus } from 'lucide-react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Badge,
Button,
Card,
CardContent,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@spoon/ui';
const formatTime = (value: number) => new Date(value).toLocaleString();
const ThreadsPage = () => {
const params = useSearchParams();
const source = params.get('source') ?? 'all';
const threads =
useQuery(api.threads.listMine, {
source: source as
| 'all'
| 'user_request'
| 'upstream_update'
| 'merge_conflict'
| 'manual_review'
| 'system',
limit: 100,
}) ?? [];
return (
<main className='space-y-6'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Threads</h1>
<p className='text-muted-foreground mt-2'>
Maintenance reviews, upstream decisions, and user-requested fork
work across all Spoons.
</p>
</div>
<Button asChild>
<Link href='/spoons'>
<Plus className='size-4' />
New thread from Spoon
</Link>
</Button>
</div>
<div className='flex flex-col gap-3 md:flex-row'>
<Select
value={source}
onValueChange={(value) => {
window.location.href =
value === 'all' ? '/threads' : `/threads?source=${value}`;
}}
>
<SelectTrigger className='w-full md:w-56'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All sources</SelectItem>
<SelectItem value='user_request'>User requests</SelectItem>
<SelectItem value='upstream_update'>Upstream updates</SelectItem>
<SelectItem value='merge_conflict'>Merge conflicts</SelectItem>
<SelectItem value='manual_review'>Manual review</SelectItem>
<SelectItem value='system'>System</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-3'>
{threads.length ? (
threads.map((thread) => (
<Link
key={thread._id}
href={`/threads/${thread._id}`}
className='block'
>
<Card className='hover:border-primary/50 shadow-none transition-colors'>
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
<div className='min-w-0'>
<div className='flex flex-wrap items-center gap-2'>
<h2 className='truncate font-medium'>{thread.title}</h2>
<Badge variant='outline'>
{thread.source.replaceAll('_', ' ')}
</Badge>
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
{thread.maintenanceOutcome ? (
<Badge variant='secondary'>
{thread.maintenanceOutcome.replaceAll('_', ' ')}
</Badge>
) : null}
</div>
<p className='text-muted-foreground mt-1 line-clamp-2 text-sm'>
{thread.summary ??
'No summary has been recorded for this thread yet.'}
</p>
</div>
<div className='text-muted-foreground text-xs md:text-right'>
<p>{formatTime(thread.updatedAt)}</p>
<p className='capitalize'>{thread.priority} priority</p>
</div>
</CardContent>
</Card>
</Link>
))
) : (
<Card className='shadow-none'>
<CardContent className='text-muted-foreground flex items-center gap-3 p-6 text-sm'>
<MessageSquare className='size-4' />
Threads appear when you ask Spoon to change a fork or when
upstream changes need review.
</CardContent>
</Card>
)}
</div>
</main>
);
};
export default ThreadsPage;
+4 -85
View File
@@ -1,88 +1,7 @@
'use client';
import { redirect } from 'next/navigation';
import { MaintenanceQueue } from '@/components/updates/maintenance-queue';
import { useQuery } from 'convex/react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@spoon/ui';
const UpdatesPage = () => {
const runs = useQuery(api.syncRuns.listRecent, { limit: 50 }) ?? [];
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
return (
<main className='space-y-6'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Updates</h1>
<p className='text-muted-foreground mt-2'>
Upstream checks, merge attempts, and AI reviews will appear here.
</p>
</div>
<div className='flex flex-col gap-3 md:flex-row'>
<Select defaultValue='all'>
<SelectTrigger className='w-full md:w-48'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All statuses</SelectItem>
<SelectItem value='needs_review'>Needs review</SelectItem>
<SelectItem value='conflict'>Conflict</SelectItem>
<SelectItem value='clean'>Clean</SelectItem>
</SelectContent>
</Select>
<Select defaultValue='all'>
<SelectTrigger className='w-full md:w-64'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Spoons</SelectItem>
{spoons.map((spoon) => (
<SelectItem key={spoon._id} value={spoon._id}>
{spoon.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Recent sync runs</CardTitle>
</CardHeader>
<CardContent>
{runs.length ? (
<div className='space-y-3'>
{runs.map((run) => (
<div key={run._id} className='border-border border p-4'>
<p className='font-medium'>{run.kind.replaceAll('_', ' ')}</p>
<p className='text-muted-foreground text-sm'>
{run.status.replaceAll('_', ' ')}
</p>
</div>
))}
</div>
) : (
<p className='text-muted-foreground'>
Scheduled upstream checks will appear here once provider
connections and workers are added.
</p>
)}
</CardContent>
</Card>
<section className='space-y-3'>
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
<MaintenanceQueue spoons={spoons} />
</section>
</main>
);
const UpdatesRedirectPage = () => {
redirect('/threads?source=upstream_update');
};
export default UpdatesPage;
export default UpdatesRedirectPage;
@@ -0,0 +1,14 @@
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
export const POST = async (
request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(
context,
async (jobId) =>
await proxyWorker(jobId, 'run-command', {
method: 'POST',
body: await request.text(),
}),
);
@@ -0,0 +1,10 @@
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
export const GET = async (
_request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(
context,
async (jobId) => await proxyWorker(jobId, 'diff', { method: 'GET' }),
);
@@ -0,0 +1,28 @@
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
export const GET = async (
request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(context, async (jobId) => {
const url = new URL(request.url);
return await proxyWorker(
jobId,
'file',
{ method: 'GET' },
new URLSearchParams({ path: url.searchParams.get('path') ?? '' }),
);
});
export const PUT = async (
request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(
context,
async (jobId) =>
await proxyWorker(jobId, 'file', {
method: 'PUT',
body: await request.text(),
}),
);
@@ -0,0 +1,14 @@
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
export const POST = async (
request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(
context,
async (jobId) =>
await proxyWorker(jobId, 'message', {
method: 'POST',
body: await request.text(),
}),
);
@@ -0,0 +1,10 @@
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
export const POST = async (
_request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(
context,
async (jobId) => await proxyWorker(jobId, 'open-pr', { method: 'POST' }),
);
@@ -0,0 +1,10 @@
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
export const POST = async (
_request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(
context,
async (jobId) => await proxyWorker(jobId, 'stop', { method: 'POST' }),
);
@@ -0,0 +1,10 @@
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
export const GET = async (
_request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(
context,
async (jobId) => await proxyWorker(jobId, 'tree', { method: 'GET' }),
);
+6 -2
View File
@@ -1,10 +1,12 @@
import {
Agents,
CTA,
Features,
Hero,
MaintenanceDecisions,
Security,
ThreadedWork,
Workflow,
WorkspaceShowcase,
} from '@/components/landing';
const Home = () => (
@@ -12,7 +14,9 @@ const Home = () => (
<Hero />
<Workflow />
<Features />
<Agents />
<MaintenanceDecisions />
<ThreadedWork />
<WorkspaceShowcase />
<Security />
<CTA />
</main>
@@ -0,0 +1,83 @@
'use client';
import { useState } from 'react';
import { Send } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button, Textarea } from '@spoon/ui';
export const AgentThread = ({
jobId,
messages,
disabled,
}: {
jobId: string;
messages: Doc<'agentJobMessages'>[];
disabled: boolean;
}) => {
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
const send = async () => {
if (!content.trim()) return;
setSending(true);
try {
const response = await fetch(`/api/agent-jobs/${jobId}/message`, {
method: 'POST',
body: JSON.stringify({ content }),
});
if (!response.ok) throw new Error(await response.text());
setContent('');
} catch (error) {
console.error(error);
toast.error('Could not send message.');
} finally {
setSending(false);
}
};
return (
<div className='flex h-full min-h-[520px] flex-col'>
<div className='border-border border-b p-3'>
<h2 className='text-sm font-semibold'>Agent thread</h2>
<p className='text-muted-foreground text-xs'>
Messages persist with this workspace.
</p>
</div>
<div className='min-h-0 flex-1 space-y-3 overflow-auto p-3'>
{messages.map((message) => (
<article
key={message._id}
className='border-border bg-background rounded-md border p-3 text-sm'
>
<div className='mb-2 flex items-center justify-between gap-2'>
<span className='font-medium capitalize'>{message.role}</span>
<span className='text-muted-foreground text-xs capitalize'>
{message.status}
</span>
</div>
<p className='whitespace-pre-wrap'>{message.content}</p>
</article>
))}
</div>
<div className='border-border space-y-2 border-t p-3'>
<Textarea
value={content}
placeholder='Ask the agent to inspect, explain, or change this fork.'
disabled={disabled || sending}
onChange={(event) => setContent(event.target.value)}
/>
<Button
type='button'
className='w-full'
disabled={disabled || sending || !content.trim()}
onClick={send}
>
<Send className='size-4' />
{sending ? 'Sending...' : 'Send'}
</Button>
</div>
</div>
);
};
@@ -0,0 +1,153 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useQuery } from 'convex/react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
import type { DiffResponse, FileResponse, FileTreeNode } from './types';
import { AgentThread } from './agent-thread';
import { CodeEditor } from './code-editor';
import { CommandPanel } from './command-panel';
import { DiffViewer } from './diff-viewer';
import { FileTree } from './file-tree';
import { JobStatusBar } from './job-status-bar';
import { WorkspaceActions } from './workspace-actions';
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const job = useQuery(api.agentJobs.get, { jobId });
const messages =
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
const [tree, setTree] = useState<FileTreeNode | null>(null);
const [selectedPath, setSelectedPath] = useState<string>();
const [fileContent, setFileContent] = useState('');
const [diff, setDiff] = useState('');
const workspaceDisabled =
!job ||
['draft_pr_opened', 'failed', 'cancelled', 'timed_out'].includes(
job.status,
) ||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
const loadTree = useCallback(async () => {
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as { tree: FileTreeNode | null };
setTree(data.tree);
}, [jobId]);
const loadDiff = useCallback(async () => {
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as DiffResponse;
setDiff(data.diff);
}, [jobId]);
const loadFile = useCallback(
async (path: string) => {
const response = await fetch(
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as FileResponse;
setSelectedPath(data.path);
setFileContent(data.content);
},
[jobId],
);
useEffect(() => {
if (!job) return;
const timeout = window.setTimeout(() => {
void loadTree().catch((error: unknown) => {
console.error(error);
});
void loadDiff().catch((error: unknown) => {
console.error(error);
});
}, 0);
return () => window.clearTimeout(timeout);
}, [job, loadDiff, loadTree]);
if (job === undefined) {
return (
<main className='text-muted-foreground p-6'>Loading workspace...</main>
);
}
const saveFile = async (content: string) => {
if (!selectedPath) return;
const response = await fetch(`/api/agent-jobs/${jobId}/file`, {
method: 'PUT',
body: JSON.stringify({ path: selectedPath, content }),
});
if (!response.ok) {
toast.error('Could not save file.');
throw new Error(await response.text());
}
setFileContent(content);
await loadDiff();
toast.success('File saved.');
};
return (
<main className='border-border bg-muted/20 min-h-[calc(100vh-5rem)] overflow-hidden rounded-md border'>
<JobStatusBar job={job} />
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
<WorkspaceActions job={job} disabled={workspaceDisabled} />
</div>
<div className='grid min-h-[680px] grid-cols-1 xl:grid-cols-[260px_minmax(0,1fr)_360px]'>
<aside className='border-border bg-background min-h-[260px] border-r'>
<div className='border-border border-b p-3'>
<h2 className='text-sm font-semibold'>Files</h2>
<p className='text-muted-foreground text-xs'>Current workspace</p>
</div>
<FileTree
tree={tree}
selectedPath={selectedPath}
onSelect={(path) => {
void loadFile(path).catch((error) => {
console.error(error);
toast.error('Could not load file.');
});
}}
/>
</aside>
<section className='bg-background min-w-0'>
<Tabs defaultValue='editor' className='h-full'>
<TabsList
variant='line'
className='border-border h-11 w-full justify-start rounded-none border-b px-3'
>
<TabsTrigger value='editor'>Editor</TabsTrigger>
<TabsTrigger value='diff'>Diff</TabsTrigger>
</TabsList>
<TabsContent value='editor' className='m-0'>
<CodeEditor
path={selectedPath}
content={fileContent}
readOnly={workspaceDisabled}
onSave={saveFile}
/>
</TabsContent>
<TabsContent value='diff' className='m-0'>
<DiffViewer diff={diff} onRefresh={loadDiff} />
</TabsContent>
</Tabs>
<CommandPanel jobId={jobId} disabled={workspaceDisabled} />
</section>
<aside className='border-border bg-muted/20 min-w-0 border-l'>
<AgentThread
jobId={jobId}
messages={messages}
disabled={workspaceDisabled}
/>
</aside>
</div>
</main>
);
};
@@ -0,0 +1,133 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import { Button, Switch } from '@spoon/ui';
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false,
});
type MonacoEditorInstance = {
getModel?: () => unknown;
};
type VimMode = {
dispose: () => void;
};
export const CodeEditor = ({
path,
content,
readOnly,
onSave,
}: {
path?: string;
content: string;
readOnly: boolean;
onSave: (content: string) => Promise<void>;
}) => {
const [value, setValue] = useState(content);
const [saving, setSaving] = useState(false);
const [vimEnabled, setVimEnabled] = useState(false);
const [dirty, setDirty] = useState(false);
const editorRef = useRef<MonacoEditorInstance | null>(null);
const vimRef = useRef<VimMode | null>(null);
const statusRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setValue(content);
setDirty(false);
}, [content, path]);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
vimRef.current?.dispose();
vimRef.current = null;
if (!vimEnabled) return;
void import('monaco-vim').then((module) => {
const initVimMode = module.initVimMode as unknown as (
editor: MonacoEditorInstance,
statusNode?: HTMLElement | null,
) => VimMode;
vimRef.current = initVimMode(editor, statusRef.current);
});
return () => {
vimRef.current?.dispose();
vimRef.current = null;
};
}, [vimEnabled, path]);
if (!path) {
return (
<div className='text-muted-foreground flex h-full items-center justify-center text-sm'>
Select a file to inspect or edit.
</div>
);
}
const save = async () => {
setSaving(true);
try {
await onSave(value);
setDirty(false);
} finally {
setSaving(false);
}
};
return (
<div className='flex h-full min-h-[520px] flex-col'>
<div className='border-border flex h-11 items-center justify-between gap-3 border-b px-3'>
<div className='min-w-0'>
<p className='truncate font-mono text-xs'>{path}</p>
{dirty ? (
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
) : null}
</div>
<div className='flex items-center gap-3'>
<label className='flex items-center gap-2 text-xs'>
Vim
<Switch checked={vimEnabled} onCheckedChange={setVimEnabled} />
</label>
<Button
type='button'
size='sm'
disabled={readOnly || saving || !dirty}
onClick={save}
>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
<div className='min-h-0 flex-1'>
<MonacoEditor
height='520px'
path={path}
value={value}
theme='vs-dark'
options={{
readOnly,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
wordWrap: 'on',
}}
onMount={(editor) => {
editorRef.current = editor as MonacoEditorInstance;
}}
onChange={(next) => {
setValue(next ?? '');
setDirty((next ?? '') !== content);
}}
/>
</div>
<div
ref={statusRef}
className='border-border text-muted-foreground h-6 border-t px-3 py-1 font-mono text-xs'
/>
</div>
);
};
@@ -0,0 +1,56 @@
'use client';
import { useState } from 'react';
import { Terminal } from 'lucide-react';
import { toast } from 'sonner';
import { Button, Input } from '@spoon/ui';
export const CommandPanel = ({
jobId,
disabled,
}: {
jobId: string;
disabled: boolean;
}) => {
const [command, setCommand] = useState('');
const [running, setRunning] = useState(false);
const run = async () => {
setRunning(true);
try {
const response = await fetch(`/api/agent-jobs/${jobId}/command`, {
method: 'POST',
body: JSON.stringify({ command }),
});
if (!response.ok) throw new Error(await response.text());
toast.success('Command completed.');
setCommand('');
} catch (error) {
console.error(error);
toast.error('Command failed.');
} finally {
setRunning(false);
}
};
return (
<div className='border-border flex items-center gap-2 border-t p-3'>
<Terminal className='text-muted-foreground size-4' />
<Input
value={command}
placeholder='bun test, pnpm lint, npm run typecheck...'
disabled={disabled || running}
onChange={(event) => setCommand(event.target.value)}
/>
<Button
type='button'
variant='outline'
disabled={disabled || running || !command.trim()}
onClick={run}
>
{running ? 'Running...' : 'Run'}
</Button>
</div>
);
};
@@ -0,0 +1,47 @@
'use client';
import dynamic from 'next/dynamic';
import { Button } from '@spoon/ui';
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false,
});
export const DiffViewer = ({
diff,
onRefresh,
}: {
diff: string;
onRefresh: () => Promise<void>;
}) => (
<div className='flex h-full min-h-[520px] flex-col'>
<div className='border-border flex h-11 items-center justify-between border-b px-3'>
<div>
<p className='text-sm font-medium'>Workspace diff</p>
<p className='text-muted-foreground text-xs'>Current git diff</p>
</div>
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
Refresh
</Button>
</div>
{diff.trim() ? (
<MonacoEditor
height='520px'
language='diff'
theme='vs-dark'
value={diff}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
}}
/>
) : (
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
No workspace diff yet.
</div>
)}
</div>
);
@@ -0,0 +1,83 @@
'use client';
import { ChevronRight, FileCode, Folder } from 'lucide-react';
import { Button } from '@spoon/ui';
import type { FileTreeNode } from './types';
const TreeNode = ({
node,
selectedPath,
onSelect,
depth = 0,
}: {
node: FileTreeNode;
selectedPath?: string;
onSelect: (path: string) => void;
depth?: number;
}) => {
if (node.type === 'directory') {
return (
<div>
{node.path ? (
<div
className='text-muted-foreground flex h-7 items-center gap-1 px-2 text-xs font-medium'
style={{ paddingLeft: depth * 12 + 8 }}
>
<ChevronRight className='size-3' />
<Folder className='size-3' />
<span className='truncate'>{node.name}</span>
</div>
) : null}
<div>
{node.children?.map((child) => (
<TreeNode
key={`${child.type}:${child.path}`}
node={child}
selectedPath={selectedPath}
onSelect={onSelect}
depth={node.path ? depth + 1 : depth}
/>
))}
</div>
</div>
);
}
return (
<Button
type='button'
variant={selectedPath === node.path ? 'secondary' : 'ghost'}
className='h-7 w-full justify-start gap-2 rounded-none px-2 text-left text-xs font-normal'
style={{ paddingLeft: depth * 12 + 8 }}
onClick={() => onSelect(node.path)}
>
<FileCode className='size-3 flex-none' />
<span className='truncate'>{node.name}</span>
</Button>
);
};
export const FileTree = ({
tree,
selectedPath,
onSelect,
}: {
tree: FileTreeNode | null;
selectedPath?: string;
onSelect: (path: string) => void;
}) => {
if (!tree) {
return (
<p className='text-muted-foreground p-3 text-sm'>
Workspace files are not available yet.
</p>
);
}
return (
<div className='overflow-auto py-2'>
<TreeNode node={tree} selectedPath={selectedPath} onSelect={onSelect} />
</div>
);
};
@@ -0,0 +1,24 @@
'use client';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge } from '@spoon/ui';
export const JobStatusBar = ({ job }: { job: Doc<'agentJobs'> }) => (
<div className='border-border bg-background flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3'>
<div className='min-w-0'>
<h1 className='truncate text-base font-semibold'>{job.forkRepo}</h1>
<p className='text-muted-foreground truncate font-mono text-xs'>
{job.baseBranch} {'->'} {job.workBranch}
</p>
</div>
<div className='flex items-center gap-2'>
<Badge variant='outline' className='capitalize'>
{job.status.replaceAll('_', ' ')}
</Badge>
<Badge variant='secondary' className='capitalize'>
{(job.workspaceStatus ?? 'not_started').replaceAll('_', ' ')}
</Badge>
<Badge variant='outline'>{job.runtime ?? 'opencode'}</Badge>
</div>
</div>
);
@@ -0,0 +1,15 @@
export type FileTreeNode = {
name: string;
path: string;
type: 'file' | 'directory';
children?: FileTreeNode[];
};
export type FileResponse = {
path: string;
content: string;
};
export type DiffResponse = {
diff: string;
};
@@ -0,0 +1,68 @@
'use client';
import { ExternalLink, GitPullRequestDraft, Square } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button } from '@spoon/ui';
export const WorkspaceActions = ({
job,
disabled,
}: {
job: Doc<'agentJobs'>;
disabled: boolean;
}) => {
const openPr = async () => {
try {
const response = await fetch(`/api/agent-jobs/${job._id}/open-pr`, {
method: 'POST',
});
if (!response.ok) throw new Error(await response.text());
toast.success('Draft PR opened.');
} catch (error) {
console.error(error);
toast.error('Could not open draft PR.');
}
};
const stop = async () => {
try {
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
method: 'POST',
});
if (!response.ok) throw new Error(await response.text());
toast.success('Workspace stopped.');
} catch (error) {
console.error(error);
toast.error('Could not stop workspace.');
}
};
return (
<div className='flex flex-wrap items-center gap-2'>
{job.pullRequestUrl ? (
<Button asChild variant='outline' size='sm'>
<a href={job.pullRequestUrl} target='_blank' rel='noreferrer'>
<ExternalLink className='size-4' />
Open PR
</a>
</Button>
) : null}
<Button type='button' size='sm' disabled={disabled} onClick={openPr}>
<GitPullRequestDraft className='size-4' />
Open draft PR
</Button>
<Button
type='button'
variant='outline'
size='sm'
disabled={disabled}
onClick={stop}
>
<Square className='size-4' />
Stop
</Button>
</div>
);
};
@@ -1,8 +1,9 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useMutation } from 'convex/react';
import { ExternalLink, XCircle } from 'lucide-react';
import { ExternalLink, MonitorUp, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -101,6 +102,14 @@ export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
Cancel job
</Button>
) : null}
<Button asChild>
<Link
href={`/spoons/${selectedJob.spoonId}/agent/${selectedJob._id}`}
>
<MonitorUp className='size-4' />
Open workspace
</Link>
</Button>
<AgentJobDetail job={selectedJob} />
</div>
) : null}
@@ -15,15 +15,24 @@ import {
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
Textarea,
} from '@spoon/ui';
import { SecretSelector } from './secret-selector';
type AgentSettings = {
defaultBaseBranch?: string;
runtime?: 'opencode' | 'openai_direct';
agentModel: string;
reasoningEffort: string;
envFilePath?: string;
customEnvFilePath?: string;
materializeEnvFileByDefault?: boolean;
aiProviderProfileId?: Id<'aiProviderProfiles'>;
};
export const AgentRequestForm = ({
@@ -33,11 +42,16 @@ export const AgentRequestForm = ({
spoon: Doc<'spoons'>;
agentSettings?: AgentSettings | null;
}) => {
const secrets = useQuery(api.spoonSecrets.listForSpoon, {
spoonId: spoon._id,
});
const createRequest = useMutation(api.agentRequests.create);
const createJob = useMutation(api.agentJobs.createFromRequest);
const secrets =
useQuery(api.spoonSecrets.listForSpoon, {
spoonId: spoon._id,
}) ?? [];
const profiles =
useQuery(api.aiProviderProfiles.listMine, {})?.filter(
(profile) => profile.enabled && profile.configured,
) ?? [];
const defaultProfile = profiles.find((profile) => profile.isDefault);
const createThread = useMutation(api.threads.createUserThread);
const [prompt, setPrompt] = useState('');
const [baseBranch, setBaseBranch] = useState(
agentSettings?.defaultBaseBranch ??
@@ -45,30 +59,52 @@ export const AgentRequestForm = ({
spoon.upstreamDefaultBranch,
);
const [requestedBranchName, setRequestedBranchName] = useState('');
const [selectedSecretIds, setSelectedSecretIds] = useState<
Id<'spoonSecrets'>[]
>([]);
const [materializeEnvFile, setMaterializeEnvFile] = useState(
agentSettings?.materializeEnvFileByDefault ?? false,
);
const [envFilePath, setEnvFilePath] = useState(
agentSettings?.envFilePath === 'custom'
? (agentSettings.customEnvFilePath ?? '.env.local')
: (agentSettings?.envFilePath ?? '.env.local'),
);
const [aiProviderProfileId, setAiProviderProfileId] = useState(
agentSettings?.aiProviderProfileId ?? '__settings',
);
const [submitting, setSubmitting] = useState(false);
const effectiveProviderProfileId =
aiProviderProfileId === '__settings'
? (agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
: aiProviderProfileId;
const hasProvider = Boolean(
effectiveProviderProfileId &&
profiles.some((profile) => profile._id === effectiveProviderProfileId),
);
const selectedProfile = profiles.find((profile) =>
aiProviderProfileId === '__settings'
? profile._id ===
(agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
: profile._id === aiProviderProfileId,
);
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitting(true);
try {
const requestId = await createRequest({
await createThread({
spoonId: spoon._id,
prompt,
targetBranch: baseBranch,
});
await createJob({
requestId,
selectedSecretIds,
baseBranch,
requestedBranchName: requestedBranchName || undefined,
materializeEnvFile,
envFilePath: materializeEnvFile ? envFilePath : undefined,
aiProviderProfileId:
aiProviderProfileId === '__settings'
? undefined
: (aiProviderProfileId as Id<'aiProviderProfiles'>),
});
setPrompt('');
setRequestedBranchName('');
setSelectedSecretIds([]);
toast.success('Agent job queued.');
toast.success('Thread created.');
} catch (error) {
console.error(error);
toast.error('Could not queue agent job.');
@@ -99,6 +135,32 @@ export const AgentRequestForm = ({
/>
</div>
<div className='grid gap-3 md:grid-cols-2'>
<div className='grid gap-2'>
<Label>Workspace runtime</Label>
<Input value='OpenCode workspace' disabled />
</div>
<div className='grid gap-2'>
<Label>AI provider</Label>
<Select
value={aiProviderProfileId}
onValueChange={setAiProviderProfileId}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='__settings'>
Use default
{defaultProfile ? ` (${defaultProfile.name})` : ''}
</SelectItem>
{profiles.map((profile) => (
<SelectItem key={profile._id} value={profile._id}>
{profile.name} · {profile.provider.replaceAll('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='baseBranch'>Base branch</Label>
<Input
@@ -117,26 +179,45 @@ export const AgentRequestForm = ({
/>
</div>
</div>
<div className='grid gap-2'>
<Label>Secrets exposed to this job</Label>
<SecretSelector
secrets={secrets ?? []}
selectedSecretIds={selectedSecretIds}
onChange={setSelectedSecretIds}
/>
<div className='grid gap-3 md:grid-cols-[1fr_1fr]'>
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
<div>
<Label>Write Spoon secrets to env file</Label>
<p className='text-muted-foreground text-xs'>
All {secrets.length} Spoon secret(s) are available as process
env. When enabled, Spoon also writes them to this file and
refuses to commit .env files.
</p>
</div>
<Switch
checked={materializeEnvFile}
onCheckedChange={setMaterializeEnvFile}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='envFilePath'>Env file path</Label>
<Input
id='envFilePath'
value={envFilePath}
disabled={!materializeEnvFile}
onChange={(event) => setEnvFilePath(event.target.value)}
/>
</div>
</div>
<div className='bg-muted/40 grid gap-1 rounded-md p-3 text-xs'>
<span>
Model:{' '}
<strong>{agentSettings?.agentModel ?? 'gpt-5.1-codex'}</strong>
<strong>
{selectedProfile?.defaultModel ?? 'Configure an AI provider'}
</strong>
</span>
<span>
Reasoning:{' '}
<strong>{agentSettings?.reasoningEffort ?? 'high'}</strong>
<strong>{selectedProfile?.reasoningEffort ?? 'medium'}</strong>
</span>
</div>
<Button type='submit' disabled={submitting}>
{submitting ? 'Queueing...' : 'Queue agent job'}
<Button type='submit' disabled={submitting || !hasProvider}>
{submitting ? 'Creating...' : 'Create thread'}
</Button>
</form>
</CardContent>
@@ -1,60 +0,0 @@
'use client';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { Checkbox, Label } from '@spoon/ui';
type Secret = {
_id: Id<'spoonSecrets'>;
name: string;
valuePreview?: string;
description?: string;
};
export const SecretSelector = ({
secrets,
selectedSecretIds,
onChange,
}: {
secrets: Secret[];
selectedSecretIds: Id<'spoonSecrets'>[];
onChange: (secretIds: Id<'spoonSecrets'>[]) => void;
}) => {
const toggle = (secretId: Id<'spoonSecrets'>, checked: boolean) => {
onChange(
checked
? [...selectedSecretIds, secretId]
: selectedSecretIds.filter((id) => id !== secretId),
);
};
if (!secrets.length) {
return (
<p className='text-muted-foreground text-sm'>
No Spoon secrets saved. Add project secrets in Settings when a job needs
environment variables.
</p>
);
}
return (
<div className='grid gap-2'>
{secrets.map((secret) => (
<label
key={secret._id}
className='border-border flex items-start gap-3 rounded-md border p-3'
>
<Checkbox
checked={selectedSecretIds.includes(secret._id)}
onCheckedChange={(checked) => toggle(secret._id, checked === true)}
/>
<span className='grid gap-1'>
<Label className='font-mono text-xs'>{secret.name}</Label>
<span className='text-muted-foreground text-xs'>
{secret.description ?? secret.valuePreview ?? 'Configured'}
</span>
</span>
</label>
))}
</div>
);
};
@@ -0,0 +1,451 @@
'use client';
import type { ProviderModelOption } from '@/lib/models-dev';
import { useEffect, useMemo, useState } from 'react';
import { loadModelsDevOptions } from '@/lib/models-dev';
import { useAction, useMutation, useQuery } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { KeyRound, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
Textarea,
} from '@spoon/ui';
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';
const saveProfileRef = makeFunctionReference<
'action',
{
profileId?: Id<'aiProviderProfiles'>;
name: string;
provider: Provider;
authType: AuthType;
secret?: string;
baseUrl?: string;
defaultModel: string;
reasoningEffort: ReasoningEffort;
enabled: boolean;
},
Id<'aiProviderProfiles'>
>('aiProviderProfilesNode:save');
const setDefaultProfileRef = makeFunctionReference<
'mutation',
{ profileId: Id<'aiProviderProfiles'> },
{ success: true }
>('aiProviderProfiles:setDefault');
const providerOptions: {
value: Provider;
label: string;
authType: AuthType;
}[] = [
{ value: 'openai', label: 'OpenAI API key', authType: 'api_key' },
{ value: 'anthropic', label: 'Anthropic API key', authType: 'api_key' },
{ value: 'google', label: 'Google Gemini API key', authType: 'api_key' },
{ value: 'openrouter', label: 'OpenRouter', authType: 'api_key' },
{ value: 'requesty', label: 'Requesty', authType: 'api_key' },
{ value: 'litellm', label: 'LiteLLM', authType: 'api_key' },
{
value: 'cloudflare_ai_gateway',
label: 'Cloudflare AI Gateway',
authType: 'api_key',
},
{
value: 'custom_openai_compatible',
label: 'Custom OpenAI-compatible',
authType: 'api_key',
},
{
value: 'opencode_openai_login',
label: 'OpenCode OpenAI login',
authType: 'opencode_auth_json',
},
];
const reasoningOptions: ReasoningEffort[] = [
'none',
'minimal',
'low',
'medium',
'high',
'xhigh',
];
export const AiProviderProfilesPanel = () => {
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
const saveProfile = useAction(saveProfileRef);
const setDefaultProfile = useMutation(setDefaultProfileRef);
const removeProfile = useMutation(api.aiProviderProfiles.remove);
const [profileId, setProfileId] = useState<Id<'aiProviderProfiles'>>();
const [name, setName] = useState('OpenAI');
const [provider, setProvider] = useState<Provider>('openai');
const selectedProvider = useMemo(
() =>
providerOptions.find((option) => option.value === provider) ??
({
value: 'openai',
label: 'OpenAI API key',
authType: 'api_key',
} satisfies (typeof providerOptions)[number]),
[provider],
);
const [secret, setSecret] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [defaultModelValue, setDefaultModelValue] = useState('');
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>([]);
const [reasoningEffort, setReasoningEffort] =
useState<ReasoningEffort>('medium');
const [enabled, setEnabled] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
loadModelsDevOptions(provider)
.then((options) => {
if (cancelled) return;
setModelOptions(options);
setDefaultModelValue((current) =>
current && options.some((option) => option.id === current)
? current
: (options[0]?.id ?? ''),
);
})
.catch((error: unknown) => {
console.error(error);
if (!cancelled) setModelOptions([]);
});
return () => {
cancelled = true;
};
}, [provider]);
const reset = () => {
setProfileId(undefined);
setProvider('openai');
setSecret('');
setBaseUrl('');
setDefaultModelValue('');
setReasoningEffort('medium');
setEnabled(true);
setName('OpenAI');
};
const edit = (profile: (typeof profiles)[number]) => {
setProfileId(profile._id);
setName(profile.name);
setProvider(profile.provider as Provider);
setSecret('');
setBaseUrl(profile.baseUrl ?? '');
setDefaultModelValue(profile.defaultModel);
setReasoningEffort(profile.reasoningEffort as ReasoningEffort);
setEnabled(profile.enabled);
};
const save = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSaving(true);
try {
await saveProfile({
profileId,
name,
provider,
authType: selectedProvider.authType,
secret: secret.trim() ? secret : undefined,
baseUrl: baseUrl.trim() || undefined,
defaultModel: defaultModelValue,
reasoningEffort,
enabled,
});
toast.success('AI provider saved.');
reset();
} catch (error) {
console.error(error);
toast.error('Could not save AI provider.');
} finally {
setSaving(false);
}
};
const selectedProfile = profileId
? profiles.find((profile) => profile._id === profileId)
: undefined;
const hasCredential =
selectedProvider.authType === 'none' ||
Boolean(secret.trim()) ||
Boolean(selectedProfile?.configured);
const canSelectModel = hasCredential && modelOptions.length > 0;
const configuredProfiles = profiles.filter(
(profile) => profile.enabled && profile.configured,
);
const defaultProfile = configuredProfiles.find(
(profile) => profile.isDefault,
);
return (
<div className='grid gap-4 xl:grid-cols-[1fr_0.9fr]'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-base'>
<KeyRound className='size-4' />
Provider profiles
</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{configuredProfiles.length > 1 ? (
<div className='grid gap-2 rounded-md border p-3'>
<Label>Default provider</Label>
<Select
value={defaultProfile?._id ?? ''}
onValueChange={async (value) => {
await setDefaultProfile({
profileId: value as Id<'aiProviderProfiles'>,
});
toast.success('Default AI provider updated.');
}}
>
<SelectTrigger>
<SelectValue placeholder='Choose default provider' />
</SelectTrigger>
<SelectContent>
{configuredProfiles.map((profile) => (
<SelectItem key={profile._id} value={profile._id}>
{profile.name} · {profile.defaultModel}
</SelectItem>
))}
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
Spoons using account default will use this provider.
</p>
</div>
) : null}
{profiles.length ? (
profiles.map((profile) => (
<div
key={profile._id}
className='border-border flex items-center justify-between gap-3 rounded-md border p-3'
>
<button
type='button'
className='min-w-0 text-left'
onClick={() => edit(profile)}
>
<p className='truncate text-sm font-medium'>{profile.name}</p>
<p className='text-muted-foreground text-xs'>
{profile.provider.replaceAll('_', ' ')} ·{' '}
{profile.secretPreview ?? 'not configured'} ·{' '}
{profile.defaultModel}
{profile.isDefault ? ' · default' : ''}
</p>
</button>
<Button
type='button'
variant='ghost'
size='icon'
aria-label='Remove provider'
onClick={async () => {
await removeProfile({ profileId: profile._id });
toast.success('AI provider removed.');
}}
>
<Trash2 className='size-4' />
</Button>
</div>
))
) : (
<p className='text-muted-foreground text-sm'>
Add API-key providers for OpenCode, or store an OpenCode OpenAI
login profile for the next auth-file injection pass.
</p>
)}
</CardContent>
</Card>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>
{profileId ? 'Edit provider' : 'Add provider'}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={save} className='space-y-4'>
<div className='grid gap-2'>
<Label>Name</Label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label>Provider</Label>
<Select
value={provider}
onValueChange={(value) => {
const nextProvider = value as Provider;
setProvider(nextProvider);
setName(
providerOptions
.find((option) => option.value === nextProvider)
?.label.replace(' API key', '') ?? 'AI provider',
);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{providerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label>
{selectedProvider.authType === 'opencode_auth_json'
? 'OpenCode auth JSON'
: 'API key'}
</Label>
{selectedProvider.authType === 'opencode_auth_json' ? (
<>
<Textarea
value={secret}
placeholder='Paste the full auth.json contents.'
onChange={(event) => setSecret(event.target.value)}
/>
<p className='text-muted-foreground text-xs'>
Copy your Codex auth file from{' '}
<code className='bg-muted rounded px-1 py-0.5'>
~/.codex/auth.json
</code>
. It is stored encrypted and should be treated like a
password.
</p>
</>
) : (
<Input
type='password'
value={secret}
placeholder={
profileId ? 'Leave blank to keep current secret' : 'sk-...'
}
onChange={(event) => setSecret(event.target.value)}
/>
)}
</div>
<div className='grid gap-2'>
<Label>Base URL</Label>
<Input
value={baseUrl}
placeholder='Optional for LiteLLM, Requesty, custom providers'
onChange={(event) => setBaseUrl(event.target.value)}
/>
</div>
<div className='grid gap-2 md:grid-cols-2'>
<div className='grid gap-2'>
<Label>Default model</Label>
<Select
value={defaultModelValue}
onValueChange={setDefaultModelValue}
disabled={!canSelectModel}
>
<SelectTrigger>
<SelectValue
placeholder={
hasCredential
? 'Choose a model'
: 'Add credentials first'
}
/>
</SelectTrigger>
<SelectContent>
{modelOptions.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
Models are loaded from Models.dev, the catalog OpenCode uses
for provider/model metadata.
</p>
</div>
<div className='grid gap-2'>
<Label>Thinking</Label>
<Select
value={reasoningEffort}
onValueChange={(value) =>
setReasoningEffort(value as ReasoningEffort)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{reasoningOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
<div>
<Label>Enabled</Label>
<p className='text-muted-foreground text-xs'>
Disabled profiles cannot be selected for new jobs.
</p>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className='flex flex-wrap gap-2'>
<Button
type='submit'
disabled={saving || !hasCredential || !defaultModelValue}
>
{saving ? 'Saving...' : 'Save provider'}
</Button>
{profileId ? (
<Button type='button' variant='outline' onClick={reset}>
New provider
</Button>
) : null}
</div>
</form>
</CardContent>
</Card>
</div>
);
};
@@ -1,197 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { useAction, useMutation, useQuery } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { Brain } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@spoon/ui';
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
const saveOpenAiSettingsRef = makeFunctionReference<
'action',
{ apiKey: string; model: string; reasoningEffort: ReasoningEffort },
{ success: true }
>('aiSettingsNode:saveOpenAiSettings');
const modelOptions = [
{ value: 'gpt-5.5', label: 'GPT-5.5' },
{ value: 'gpt-5.5-pro', label: 'GPT-5.5 Pro' },
{ value: 'gpt-5.4', label: 'GPT-5.4' },
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
];
const reasoningOptions: { value: ReasoningEffort; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'minimal', label: 'Minimal' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'Extra high' },
];
export const OpenAiStatusPanel = () => {
const status = useQuery(api.integrations.getStatus, {});
const settings = useQuery(api.aiSettings.getMine, {});
const saveOpenAiSettings = useAction(saveOpenAiSettingsRef);
const updatePreferences = useMutation(api.aiSettings.updatePreferences);
const removeOpenAiKey = useMutation(api.aiSettings.removeOpenAiKey);
const [apiKey, setApiKey] = useState('');
const [model, setModel] = useState('gpt-5.5');
const [reasoningEffort, setReasoningEffort] =
useState<ReasoningEffort>('medium');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!settings) return;
setModel(settings.model);
setReasoningEffort(settings.reasoningEffort as ReasoningEffort);
}, [settings]);
const save = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitting(true);
try {
if (apiKey.trim()) {
await saveOpenAiSettings({
apiKey,
model,
reasoningEffort,
});
setApiKey('');
} else {
await updatePreferences({
model,
reasoningEffort,
});
}
toast.success('OpenAI settings saved.');
} catch (error) {
console.error(error);
toast.error('Could not save OpenAI settings.');
} finally {
setSubmitting(false);
}
};
const remove = async () => {
try {
await removeOpenAiKey({});
toast.success('OpenAI API key removed.');
} catch (error) {
console.error(error);
toast.error('Could not remove OpenAI API key.');
}
};
return (
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-base'>
<Brain className='size-4' />
OpenAI reviews
</CardTitle>
</CardHeader>
<CardContent className='space-y-2 text-sm'>
<p className='text-muted-foreground'>
Compatibility reviews use your own OpenAI API key. Spoon encrypts the
key before storing it and only shows a short preview.
</p>
<div>
<p className='text-muted-foreground'>Encryption</p>
<p className='font-medium'>
{status?.encryptionConfigured ? 'Configured' : 'Missing server key'}
</p>
</div>
<div>
<p className='text-muted-foreground'>OpenAI API key</p>
<p className='font-medium'>
{settings?.configured ? settings.apiKeyPreview : 'Not configured'}
</p>
</div>
<form onSubmit={save} className='space-y-4 pt-2'>
<div className='grid gap-2'>
<Label htmlFor='openai-api-key'>API key</Label>
<Input
id='openai-api-key'
type='password'
value={apiKey}
placeholder={
settings?.configured
? 'Leave blank to keep current key'
: 'sk-...'
}
onChange={(event) => setApiKey(event.target.value)}
/>
</div>
<div className='grid gap-2 md:grid-cols-2'>
<div className='grid gap-2'>
<Label>Review model</Label>
<Select value={model} onValueChange={(value) => setModel(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{modelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label>Thinking</Label>
<Select
value={reasoningEffort}
onValueChange={(value) =>
setReasoningEffort(value as ReasoningEffort)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{reasoningOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button type='submit' disabled={submitting}>
{submitting ? 'Saving...' : 'Save OpenAI settings'}
</Button>
<Button
type='button'
variant='outline'
onClick={remove}
disabled={!settings?.configured}
>
Remove key
</Button>
</div>
</form>
</CardContent>
</Card>
);
};
+5 -4
View File
@@ -15,16 +15,17 @@ export const CTA = () => {
<div className='flex flex-col items-start justify-between gap-8 md:flex-row md:items-center'>
<div>
<h2 className='text-2xl font-semibold tracking-normal md:text-3xl'>
Keep the fork. Lose the maintenance dread.
Fork the project. Keep the relationship.
</h2>
<p className='text-primary-foreground/80 mt-3 max-w-2xl leading-7'>
Create your first Spoon, connect GitHub, and make upstream drift
something you can see, review, and act on.
Create your first Spoon, connect GitHub, and let upstream
maintenance become a visible thread instead of a lonely recurring
chore.
</p>
</div>
<Button variant='secondary' size='lg' asChild>
<Link href={isAuthenticated ? '/spoons/new' : '/sign-in'}>
{isAuthenticated ? 'New Spoon' : 'Start with Spoon'}
{isAuthenticated ? 'Create a Spoon' : 'Start with Spoon'}
<ArrowRight className='size-4' />
</Link>
</Button>
+207 -127
View File
@@ -4,107 +4,135 @@ import {
GitBranch,
GitCompare,
GitPullRequest,
History,
KeyRound,
LockKeyhole,
MessagesSquare,
RefreshCw,
ServerCog,
Sparkles,
ShieldCheck,
} from 'lucide-react';
import { Badge } from '@spoon/ui';
const workflow = [
{
title: 'Connect GitHub',
title: 'Create the Spoon',
description:
'Install the Spoon GitHub App so Spoon can read forks, compare branches, push agent branches, and open draft pull requests.',
'Register the upstream project, your GitHub fork, default branches, clone URLs, and any extra remotes you want visible.',
},
{
title: 'Create a Spoon',
title: 'Watch upstream',
description:
'Register a managed fork with its upstream project, fork repository, default branches, sync cadence, and additional remote URLs.',
'Spoon intermittently checks the upstream default branch and compares it against the current fork state.',
},
{
title: 'Watch drift',
title: 'Auto-sync clean drift',
description:
'Refresh GitHub state to see upstream commits waiting, fork-only commits, open pull requests, sync health, and merge history.',
'If the fork has no custom commits and upstream moved, Spoon can fast-forward the fork without turning it into a chore.',
},
{
title: 'Review safely',
title: 'Open a thread when it matters',
description:
'Use AI compatibility reviews to summarize upstream changes, flag important files, and decide when a manual review is needed.',
'If your fork has custom commits, Spoon creates a durable maintenance thread instead of guessing.',
},
{
title: 'Ship through PRs',
title: 'Resolve with context',
description:
'Queue agent jobs that work on fresh branches and open draft pull requests, while GitHub remains the source of truth.',
'Review commits, changed files, pull requests, fork-only work, ignored upstream changes, and workspace output together.',
},
{
title: 'Ship through draft PRs',
description:
'When code is needed, OpenCode works in an isolated workspace and hands changes back as a draft PR.',
},
];
const features = [
{
title: 'Project dashboards',
title: 'Spoon dashboards',
description:
'Each Spoon gets a focused dashboard with upstream drift, fork-only changes, pull requests, AI reviews, activity, settings, clone URLs, and extra remotes.',
'See drift, fork-only commits, pull requests, clone URLs, extra remotes, threads, activity, and settings for each managed fork.',
icon: GitCompare,
},
{
title: 'Upstream maintenance queue',
title: 'Thread-first maintenance',
description:
'The global dashboard makes it obvious which forks are up to date, behind, ahead, diverged, or waiting for review.',
'Every review, conflict, ignore decision, and requested code change has a durable conversation attached to it.',
icon: MessagesSquare,
},
{
title: 'Effective drift',
description:
'Spoon shows raw upstream state and the effective maintenance state after intentional ignore decisions.',
icon: RefreshCw,
},
{
title: 'Pull request visibility',
title: 'OpenCode workspaces',
description:
'Spoon caches fork pull requests and relevant upstream pull requests so maintenance decisions are tied to real GitHub activity.',
icon: GitPullRequest,
},
{
title: 'AI compatibility review',
description:
'OpenAI reviews upstream changes against fork-only commits and returns structured risk, summary, recommended action, and conflict signals.',
icon: Sparkles,
},
{
title: 'Per-user AI settings',
description:
'Users bring their own OpenAI API key, choose a review model, and set reasoning effort. Keys are encrypted before storage.',
icon: KeyRound,
},
{
title: 'Agent job foundation',
description:
'Spoon can queue coding-agent work per project with selected secrets, job settings, event logs, artifacts, and draft PR targets.',
icon: Bot,
},
];
const builtFor = [
{
title: 'Self-hosted by design',
description:
'Run the app, Convex backend, Postgres, and optional agent worker on your own server so code automation stays under your control.',
icon: ServerCog,
},
{
title: 'Secrets stay deliberate',
description:
'Project secrets are per Spoon and selected per job. The agent runtime never receives every secret by default.',
icon: LockKeyhole,
},
{
title: 'Outside work is expected',
description:
'Spoon assumes you may push to GitHub directly, edit locally, or use another CI system. Refresh always starts from current GitHub state.',
'Open a file tree, browser editor, diff view, job logs, command panel, and thread context when a fork needs code.',
icon: Code2,
},
{
title: 'History stays inspectable',
title: 'Provider-owned AI',
description:
'Sync runs, AI reviews, job logs, PR URLs, errors, and artifacts are stored so maintenance work is reviewable after the fact.',
icon: History,
'Use encrypted provider profiles, OpenCode auth, or user-owned API keys rather than a shared application key.',
icon: KeyRound,
},
{
title: 'Draft PR handoff',
description:
'Agent work becomes a branch and draft pull request. Spoon does not auto-merge custom forks behind your back.',
icon: GitPullRequest,
},
];
const decisions = [
{
condition: 'No fork-only commits + upstream ahead',
action: 'Auto-sync',
explanation: 'The fork is still close enough to fast-forward.',
},
{
condition: 'Fork-only commits + upstream ahead',
action: 'Create thread',
explanation: 'Spoon reviews whether upstream affects custom work.',
},
{
condition: 'Merge conflicts',
action: 'Open workspace',
explanation: 'Resolve in an isolated worker and ship a draft PR.',
},
{
condition: 'Irrelevant upstream changes',
action: 'Ignore intentionally',
explanation: 'Record why those commits no longer matter to this fork.',
},
];
const ownership = [
{
title: 'Your GitHub App',
description:
'GitHub remains the active source of truth for forks, branches, compares, and draft PRs.',
icon: GitBranch,
},
{
title: 'Your providers',
description:
'AI provider profiles and Codex/OpenCode auth stay encrypted and selected by you.',
icon: ShieldCheck,
},
{
title: 'Your secrets',
description:
'Project secrets are per Spoon, redacted in logs, and refused from commits when materialized.',
icon: LockKeyhole,
},
{
title: 'Your workflow',
description:
'Local commits, Gitea mirrors, CI changes, and direct GitHub edits are expected parts of the loop.',
icon: ServerCog,
},
];
@@ -116,44 +144,42 @@ export const Workflow = () => (
Workflow
</Badge>
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
Forking should not mean drifting alone.
Forking should start a relationship, not a support burden.
</h2>
<p className='text-muted-foreground mt-4 text-lg leading-8'>
Spoon treats a fork as an ongoing relationship with upstream. The
product keeps the original project, your custom work, and future
automation visible in one place.
Spoon keeps watching the upstream project after the fork. When
upstream moves, it decides whether your fork can fast-forward, needs a
maintenance thread, or should ignore changes that no longer matter to
your version. Forking a project should not mean supporting it alone.
</p>
</div>
<div className='grid gap-8 lg:grid-cols-[0.8fr_1.2fr]'>
<div className='grid gap-8 lg:grid-cols-[0.75fr_1.25fr]'>
<div className='border-border bg-background rounded-lg border p-6'>
<GitBranch className='text-primary size-6' />
<MessagesSquare className='text-primary size-6' />
<h3 className='mt-5 text-xl font-semibold'>
A Spoon is a managed fork
Spoon keeps the conversation going
</h3>
<p className='text-muted-foreground mt-3 leading-7'>
It knows where upstream lives, where your fork lives, which branch
matters, what extra remotes you care about, and what rules should
govern updates. That gives maintenance a durable home instead of a
pile of one-off Git commands.
A fork is not a one-time split. Spoon keeps the fork and upstream in
conversation by turning maintenance into visible, reviewable threads
instead of surprise drift.
</p>
</div>
<ol className='grid gap-3'>
<ol className='grid gap-3 md:grid-cols-2'>
{workflow.map((step, index) => (
<li
key={step.title}
className='border-border bg-background grid gap-4 rounded-lg border p-5 sm:grid-cols-[4rem_1fr]'
className='border-border bg-background rounded-lg border p-5'
>
<span className='text-primary text-sm font-semibold'>
{String(index + 1).padStart(2, '0')}
</span>
<div>
<h3 className='font-semibold'>{step.title}</h3>
<p className='text-muted-foreground mt-1 text-sm leading-6'>
{step.description}
</p>
</div>
<h3 className='mt-3 font-semibold'>{step.title}</h3>
<p className='text-muted-foreground mt-2 text-sm leading-6'>
{step.description}
</p>
</li>
))}
</ol>
@@ -170,12 +196,12 @@ export const Features = () => (
Product surface
</Badge>
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
Everything important about a fork, without opening six tabs.
The maintenance cockpit for forks you actually care about.
</h2>
</div>
<p className='text-muted-foreground max-w-xl leading-7'>
Spoon is not trying to replace GitHub. It is the layer that explains how
your fork relates to upstream and what should happen next.
Custom work should not mean permanent drift. Spoon keeps the operational
picture clear from the first upstream check to the final draft PR.
</p>
</div>
@@ -195,58 +221,112 @@ export const Features = () => (
</section>
);
export const Agents = () => (
<section id='agents' className='border-border/60 bg-muted/30 border-y'>
<div className='container mx-auto grid gap-10 px-4 py-24 lg:grid-cols-[0.95fr_1.05fr]'>
<div>
export const MaintenanceDecisions = () => (
<section id='maintenance' className='border-border/60 bg-muted/30 border-y'>
<div className='container mx-auto px-4 py-24'>
<div className='mb-10 max-w-3xl'>
<Badge variant='outline' className='mb-4'>
Agent work
Maintenance decisions
</Badge>
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
The agent belongs inside the fork dashboard.
Spoon knows when to sync, when to thread, and when to stay out of the
way.
</h2>
</div>
<div className='overflow-hidden rounded-lg border'>
{decisions.map((decision) => (
<div
key={decision.condition}
className='bg-background border-border grid gap-4 border-b p-5 last:border-b-0 lg:grid-cols-[1fr_12rem_1.3fr]'
>
<p className='font-medium'>{decision.condition}</p>
<Badge
variant='outline'
className='bg-primary/10 text-primary w-fit'
>
{decision.action}
</Badge>
<p className='text-muted-foreground text-sm leading-6'>
{decision.explanation}
</p>
</div>
))}
</div>
</div>
</section>
);
export const ThreadedWork = () => (
<section id='threads' className='container mx-auto px-4 py-24'>
<div className='grid gap-10 lg:grid-cols-[0.9fr_1.1fr]'>
<div>
<Badge variant='outline' className='mb-4'>
Threads
</Badge>
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
Threads keep the whole maintenance conversation in one place.
</h2>
<p className='text-muted-foreground mt-4 text-lg leading-8'>
The goal is simple: ask for a change, let a worker clone the current
fork, expose only the secrets you selected, run checks, push a branch,
and open a draft pull request. The first pieces are already modeled:
encrypted Spoon secrets, agent settings, queued jobs, logs, and
artifacts.
Upstream changed, a fork drifted, a conflict appeared, or you asked
for a code change. Spoon puts the reasoning, messages, workspace,
artifacts, and draft PR handoff in the same thread.
</p>
</div>
<div className='border-border bg-background rounded-lg border'>
<div className='border-border border-b p-5'>
<div className='flex items-center gap-3'>
<span className='bg-primary/10 text-primary flex size-9 items-center justify-center rounded-md'>
<Bot className='size-4' />
</span>
<div className='border-border bg-card overflow-hidden rounded-lg border shadow-sm'>
<div className='border-border flex flex-wrap items-center justify-between gap-3 border-b p-5'>
<div>
<p className='font-medium'>Thread: Upstream auth changes landed</p>
<p className='text-muted-foreground text-sm'>
Source: upstream update
</p>
</div>
<Badge className='bg-amber-500/10 text-amber-700 hover:bg-amber-500/10'>
Waiting for review
</Badge>
</div>
<div className='bg-border grid gap-px lg:grid-cols-[1.35fr_0.65fr]'>
<div className='bg-background space-y-3 p-5'>
{[
['system', 'Spoon found 3 upstream commits after 8f3a2c1.'],
[
'assistant',
'These touch auth callback handling and package scripts. Your fork has Authentik-only changes in the same area.',
],
[
'user',
'Open a review PR and preserve Authentik as the only provider.',
],
].map(([role, message]) => (
<div key={role} className='rounded-md border p-3 text-sm'>
<p className='text-muted-foreground text-xs'>{role}</p>
<p className='mt-1 leading-6'>{message}</p>
</div>
))}
</div>
<div className='bg-background space-y-4 p-5 text-sm'>
<div>
<p className='font-medium'>Draft PR agent flow</p>
<p className='text-muted-foreground text-sm'>
Built for review, not automatic merge.
<p className='text-muted-foreground text-xs'>Latest job</p>
<p className='mt-1 font-medium'>OpenCode workspace active</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>Model/provider</p>
<p className='mt-1 font-medium'>Codex profile</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>PR target</p>
<p className='mt-1 font-medium'>fork:main</p>
</div>
<div className='bg-muted/60 rounded-md p-3'>
<Bot className='text-primary mb-2 size-4' />
<p className='text-muted-foreground leading-6'>
Workspace logs, diffs, checks, and the final PR stay attached to
the thread.
</p>
</div>
</div>
</div>
<div className='divide-border divide-y'>
{[
['Clone', 'Start from the current GitHub fork state.'],
['Branch', 'Create a short-lived agent branch.'],
['Edit', 'Apply focused changes with selected project context.'],
[
'Check',
'Run configured install, lint, typecheck, or test steps.',
],
['Review', 'Open a draft pull request with logs and artifacts.'],
].map(([phase, detail]) => (
<div key={phase} className='grid gap-3 p-5 sm:grid-cols-[8rem_1fr]'>
<p className='text-sm font-semibold'>{phase}</p>
<p className='text-muted-foreground text-sm leading-6'>
{detail}
</p>
</div>
))}
</div>
</div>
</div>
</section>
@@ -260,18 +340,18 @@ export const Security = () => (
Ownership
</Badge>
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
Useful because it respects how forks are really maintained.
Self-hosted because the fork is yours.
</h2>
<p className='text-muted-foreground mt-4 text-lg leading-8'>
A fork can have local experiments, CI changes, private deployment
settings, and emergency upstream fixes all happening at once. Spoon
keeps those threads visible without pretending every change must come
through the app.
Your fork can have local commits, private deploy settings, Gitea
mirrors, CI experiments, and emergency GitHub edits. Spoon keeps that
relationship with upstream visible without taking ownership away from
you.
</p>
</div>
<div className='grid gap-4 sm:grid-cols-2'>
{builtFor.map(({ title, description, icon: Icon }) => (
{ownership.map(({ title, description, icon: Icon }) => (
<div key={title} className='border-border rounded-lg border p-5'>
<div className='flex items-center gap-3'>
<Icon className='text-primary size-5 shrink-0' />
+14 -108
View File
@@ -2,42 +2,11 @@
import Link from 'next/link';
import { useConvexAuth } from 'convex/react';
import {
ArrowRight,
Bot,
CheckCircle2,
CircleDot,
GitBranch,
GitPullRequest,
KeyRound,
ShieldCheck,
} from 'lucide-react';
import { ArrowRight, CircleDot, ShieldCheck } from 'lucide-react';
import { Badge, Button } from '@spoon/ui';
const previewRows = [
{
name: 'gibsend',
upstream: 'usesend/usesend',
status: '3 upstream commits',
icon: CheckCircle2,
tone: 'text-emerald-600',
},
{
name: 'internal-docs',
upstream: 'platform/docs',
status: 'AI review ready',
icon: Bot,
tone: 'text-teal-600',
},
{
name: 'ops-console',
upstream: 'console/main',
status: 'fork-only changes',
icon: GitPullRequest,
tone: 'text-amber-600',
},
];
import { ProductStoryDemo } from './product-story-demo';
export const Hero = () => {
const { isAuthenticated } = useConvexAuth();
@@ -47,17 +16,17 @@ export const Hero = () => {
<div className='max-w-3xl'>
<Badge variant='outline' className='mb-5 gap-2'>
<ShieldCheck className='size-3.5 text-emerald-600' />
Self-hostable fork maintenance cockpit
Self-hostable fork maintenance with Threads
</Badge>
<h1 className='max-w-4xl text-4xl font-semibold tracking-normal text-balance sm:text-5xl md:text-6xl'>
Make your forks <em className='text-primary'>intimately</em> close
to upstream.
Fork freely & keep them all{' '}
<em className='text-primary'>intimately</em> close to upstream.
</h1>
<p className='text-muted-foreground mt-6 max-w-2xl text-lg leading-8'>
Spoon gives every important fork a living maintenance dashboard.
Track upstream drift, preserve your custom commits, review pull
requests, and queue AI-assisted work without losing sight of the
project you forked from.
Spoon is a self-hostable maintenance cockpit for forks. It watches
upstream, understands your fork-only changes, opens threads when
decisions are needed, and helps keep your managed forks close
without asking you to support them alone.
</p>
<div className='mt-8 flex flex-col gap-3 sm:flex-row'>
<Button size='lg' asChild>
@@ -67,13 +36,14 @@ export const Hero = () => {
</Link>
</Button>
<Button size='lg' variant='outline' asChild>
<Link href='#workflow'>See how it works</Link>
<Link href='#demo'>Watch the flow</Link>
</Button>
</div>
<div className='text-muted-foreground mt-8 grid max-w-xl gap-3 text-sm sm:grid-cols-3'>
<div className='text-muted-foreground mt-8 grid max-w-2xl gap-3 text-sm sm:grid-cols-2'>
{[
'GitHub App backed',
'OpenAI key per user',
'Thread-first maintenance',
'OpenCode workspaces',
'Draft PR workflow',
].map((item) => (
<span key={item} className='flex items-center gap-2'>
@@ -84,71 +54,7 @@ export const Hero = () => {
</div>
</div>
<div className='border-border bg-card overflow-hidden rounded-lg border shadow-sm'>
<div className='border-border flex items-center justify-between border-b px-5 py-4'>
<div>
<p className='text-sm font-medium'>Fork health</p>
<p className='text-muted-foreground text-xs'>
Current state across managed Spoons
</p>
</div>
<Badge className='bg-primary/10 text-primary hover:bg-primary/10'>
Live GitHub sync
</Badge>
</div>
<div className='grid gap-4 p-5 md:grid-cols-3'>
{[
['Behind', '3', 'upstream commits'],
['Fork-only', '12', 'custom changes'],
['AI risk', 'Low', 'reviewed'],
].map(([label, value, note]) => (
<div
key={label}
className='border-border bg-background rounded-md border p-4'
>
<p className='text-muted-foreground text-xs'>{label}</p>
<p className='mt-2 text-2xl font-semibold'>{value}</p>
<p className='text-muted-foreground mt-1 text-xs'>{note}</p>
</div>
))}
</div>
<div className='space-y-3 px-5 pb-5'>
{previewRows.map(({ name, upstream, status, icon: Icon, tone }) => (
<div
key={name}
className='border-border bg-background flex items-center justify-between gap-4 rounded-md border p-4'
>
<div className='flex items-center gap-3'>
<span className='bg-muted flex size-9 items-center justify-center rounded-md'>
<GitBranch className='size-4' />
</span>
<div>
<p className='text-sm font-medium'>{name}</p>
<p className='text-muted-foreground text-xs'>{upstream}</p>
</div>
</div>
<span className='flex items-center gap-2 text-sm'>
<Icon className={`size-4 ${tone}`} />
{status}
</span>
</div>
))}
</div>
<div className='border-border bg-muted/30 grid gap-3 border-t p-5 text-sm sm:grid-cols-2'>
<div className='flex items-start gap-3'>
<KeyRound className='text-primary mt-0.5 size-4' />
<p className='text-muted-foreground'>
User-owned OpenAI keys stay encrypted and selectable.
</p>
</div>
<div className='flex items-start gap-3'>
<GitPullRequest className='text-primary mt-0.5 size-4' />
<p className='text-muted-foreground'>
Agent jobs are shaped around draft pull requests.
</p>
</div>
</div>
</div>
<ProductStoryDemo />
</div>
</section>
);
+9 -1
View File
@@ -1,3 +1,11 @@
export { Hero } from './hero';
export { Agents, Features, Security, Workflow } from './features';
export {
Features,
MaintenanceDecisions,
Security,
ThreadedWork,
Workflow,
} from './features';
export { ProductStoryDemo } from './product-story-demo';
export { WorkspaceShowcase } from './workspace-showcase';
export { CTA } from './cta';
@@ -0,0 +1,370 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import {
AlertTriangle,
CheckCircle2,
Code2,
GitBranch,
GitPullRequest,
MessagesSquare,
RefreshCw,
} from 'lucide-react';
import { Badge, Button } from '@spoon/ui';
type DemoStep = 'check' | 'thread' | 'workspace' | 'decision' | 'pr';
type DemoStepConfig = {
id: DemoStep;
label: string;
title: string;
description: string;
};
const defaultStep: DemoStepConfig = {
id: 'check',
label: 'Check',
title: 'Upstream moved',
description: 'Spoon checks default branches and compares the fork network.',
};
const steps: DemoStepConfig[] = [
defaultStep,
{
id: 'thread',
label: 'Thread',
title: 'Custom work needs context',
description:
'Fork-only commits turn an upstream update into a durable thread.',
},
{
id: 'workspace',
label: 'Workspace',
title: 'OpenCode gets a sandbox',
description:
'The worker opens a repo workspace with files, thread context, and checks.',
},
{
id: 'decision',
label: 'Decision',
title: 'Review the maintenance call',
description:
'Spoon records risk, conflicts, and the recommended next step.',
},
{
id: 'pr',
label: 'Draft PR',
title: 'Ship as reviewable work',
description:
'Code changes leave the workspace as a branch and draft pull request.',
},
];
const usePrefersReducedMotion = () => {
const [reducedMotion, setReducedMotion] = useState(true);
useEffect(() => {
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
const update = () => setReducedMotion(query.matches);
update();
query.addEventListener('change', update);
return () => query.removeEventListener('change', update);
}, []);
return reducedMotion;
};
const Metric = ({
label,
value,
tone = 'default',
}: {
label: string;
value: string;
tone?: 'default' | 'warning' | 'good';
}) => (
<div className='border-border bg-background rounded-md border p-3'>
<p className='text-muted-foreground text-xs'>{label}</p>
<p
className={
tone === 'warning'
? 'mt-1 text-lg font-semibold text-amber-600'
: tone === 'good'
? 'mt-1 text-lg font-semibold text-emerald-600'
: 'mt-1 text-lg font-semibold'
}
>
{value}
</p>
</div>
);
const CheckPreview = () => (
<div className='space-y-4'>
<div className='grid gap-3 sm:grid-cols-3'>
<Metric label='Raw upstream ahead' value='3 commits' tone='warning' />
<Metric label='Fork-only work' value='5 commits' />
<Metric label='Status' value='Needs thread' tone='warning' />
</div>
<div className='border-border bg-background rounded-md border p-4'>
<div className='flex items-center justify-between gap-4'>
<div className='min-w-0'>
<p className='font-medium'>usesend-authentik</p>
<p className='text-muted-foreground text-xs'>
usesend/usesend {'->'} gibbyb/usesend
</p>
</div>
<Badge variant='outline'>daily check</Badge>
</div>
<div className='mt-4 grid gap-2 text-sm'>
<div className='bg-muted/50 flex items-center justify-between rounded-md px-3 py-2'>
<span className='flex items-center gap-2'>
<RefreshCw className='text-primary size-4' />
Compare upstream main
</span>
<span className='text-muted-foreground'>complete</span>
</div>
<div className='bg-muted/50 flex items-center justify-between rounded-md px-3 py-2'>
<span className='flex items-center gap-2'>
<GitBranch className='text-primary size-4' />
Detect fork-only commits
</span>
<span className='text-muted-foreground'>custom auth work</span>
</div>
</div>
</div>
</div>
);
const ThreadPreview = () => (
<div className='space-y-4'>
<div className='border-border bg-background rounded-md border p-4'>
<div className='flex flex-wrap items-center gap-2'>
<MessagesSquare className='text-primary size-4' />
<p className='font-medium'>
Upstream changed: auth and webhook updates
</p>
<Badge className='bg-amber-500/10 text-amber-700 hover:bg-amber-500/10'>
Review required
</Badge>
</div>
<div className='mt-4 space-y-3 text-sm'>
<div className='bg-muted/60 rounded-md p-3'>
<p className='text-muted-foreground text-xs'>system</p>
<p className='mt-1'>
Spoon found upstream commits that touch auth-adjacent files. Fork
has custom Authentik work.
</p>
</div>
<div className='rounded-md border p-3'>
<p className='text-muted-foreground text-xs'>assistant</p>
<p className='mt-1'>
These updates are probably valuable, but they overlap with fork-only
provider changes.
</p>
</div>
</div>
</div>
</div>
);
const WorkspacePreview = () => (
<div className='grid min-h-[19rem] gap-3 lg:grid-cols-[0.8fr_1.4fr_0.9fr]'>
<div className='border-border bg-background rounded-md border p-3 text-xs'>
<p className='mb-3 font-medium'>Files</p>
{['packages/auth/providers.ts', '.env.example', 'apps/web/auth.ts'].map(
(file) => (
<div
key={file}
className='text-muted-foreground hover:text-foreground rounded px-2 py-1.5'
>
{file}
</div>
),
)}
</div>
<div className='border-border rounded-md border bg-zinc-950 p-3 font-mono text-xs text-zinc-100'>
<div className='mb-3 flex items-center justify-between text-zinc-400'>
<span>providers.ts</span>
<span>vim mode</span>
</div>
<pre className='overflow-hidden leading-6'>
<code>{`export const providers = [
Authentik({
issuer: env.AUTHENTIK_ISSUER,
clientId: env.AUTHENTIK_CLIENT_ID,
}),
];
// GitHub provider removed in fork`}</code>
</pre>
</div>
<div className='border-border bg-background rounded-md border p-3 text-xs'>
<p className='mb-3 font-medium'>Thread</p>
<div className='space-y-2'>
<p className='bg-muted/60 rounded-md p-2'>
Preserve Authentik-only auth.
</p>
<p className='rounded-md border p-2'>
Running typecheck after provider update.
</p>
<p className='text-muted-foreground bg-muted/40 rounded-md p-2'>
Secrets available as process env.
</p>
</div>
</div>
</div>
);
const DecisionPreview = () => (
<div className='space-y-3'>
{[
['Risk', 'Medium', 'Auth provider wiring overlaps custom fork changes.'],
[
'Recommended action',
'Open review PR',
'Keep the fork branch reviewable.',
],
[
'Conflict signals',
'2 files',
'OAuth callback copy and package scripts.',
],
].map(([label, value, detail]) => (
<div
key={label}
className='border-border bg-background rounded-md border p-4'
>
<div className='flex items-center justify-between gap-3'>
<p className='text-muted-foreground text-sm'>{label}</p>
<Badge variant='outline'>{value}</Badge>
</div>
<p className='mt-2 text-sm'>{detail}</p>
</div>
))}
</div>
);
const DraftPrPreview = () => (
<div className='border-border bg-background rounded-md border p-5'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div>
<p className='font-medium'>Draft PR opened</p>
<p className='text-muted-foreground text-xs'>
spoon/thread/authentik-upstream
</p>
</div>
<Badge className='bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/10'>
ready for review
</Badge>
</div>
<div className='mt-5 grid gap-2 text-sm'>
<div className='bg-muted/50 flex items-center justify-between rounded-md px-3 py-2'>
<span className='flex items-center gap-2'>
<CheckCircle2 className='size-4 text-emerald-600' />
lint
</span>
<span>passed</span>
</div>
<div className='bg-muted/50 flex items-center justify-between rounded-md px-3 py-2'>
<span className='flex items-center gap-2'>
<AlertTriangle className='size-4 text-amber-600' />
typecheck
</span>
<span>queued</span>
</div>
</div>
<Button className='mt-5 w-full' variant='outline'>
Review PR
<GitPullRequest className='size-4' />
</Button>
</div>
);
const renderPreview = (step: DemoStep) => {
if (step === 'check') return <CheckPreview />;
if (step === 'thread') return <ThreadPreview />;
if (step === 'workspace') return <WorkspacePreview />;
if (step === 'decision') return <DecisionPreview />;
return <DraftPrPreview />;
};
export const ProductStoryDemo = () => {
const [activeStep, setActiveStep] = useState<DemoStep>('check');
const [paused, setPaused] = useState(false);
const reducedMotion = usePrefersReducedMotion();
const active = useMemo(
() => steps.find((step) => step.id === activeStep) ?? defaultStep,
[activeStep],
);
useEffect(() => {
if (paused || reducedMotion) return;
const interval = window.setInterval(() => {
setActiveStep((current) => {
const index = steps.findIndex((step) => step.id === current);
return steps[(index + 1) % steps.length]?.id ?? 'check';
});
}, 3500);
return () => window.clearInterval(interval);
}, [paused, reducedMotion]);
return (
<div
id='demo'
aria-label='Animated Spoon maintenance flow demo'
className='border-border bg-card overflow-hidden rounded-lg border shadow-sm'
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
>
<div className='border-border flex items-start justify-between gap-4 border-b px-5 py-4'>
<div>
<p className='text-sm font-medium'>{active.title}</p>
<p className='text-muted-foreground mt-1 text-xs'>
{active.description}
</p>
</div>
<Badge className='bg-primary/10 text-primary hover:bg-primary/10'>
Live flow
</Badge>
</div>
<div className='border-border flex gap-2 overflow-x-auto border-b p-2'>
{steps.map((step) => (
<button
key={step.id}
type='button'
aria-current={step.id === activeStep ? 'step' : undefined}
onClick={() => setActiveStep(step.id)}
className={
step.id === activeStep
? 'bg-primary text-primary-foreground flex-none rounded-md px-3 py-2 text-xs font-medium'
: 'text-muted-foreground hover:bg-muted hover:text-foreground flex-none rounded-md px-3 py-2 text-xs font-medium'
}
>
{step.label}
</button>
))}
</div>
<div className='min-h-[24rem] p-4 sm:p-5'>
{renderPreview(activeStep)}
</div>
<div className='border-border bg-muted/30 grid gap-3 border-t p-4 text-sm sm:grid-cols-3'>
<span className='flex items-center gap-2'>
<GitBranch className='text-primary size-4' />
fork stays source of truth
</span>
<span className='flex items-center gap-2'>
<MessagesSquare className='text-primary size-4' />
decisions stay threaded
</span>
<span className='flex items-center gap-2'>
<Code2 className='text-primary size-4' />
workspace opens when needed
</span>
</div>
</div>
);
};
@@ -0,0 +1,150 @@
import {
CheckCircle2,
Code2,
FileCode2,
GitBranch,
MessagesSquare,
Terminal,
} from 'lucide-react';
import { Badge } from '@spoon/ui';
const files = [
'apps/web/auth.ts',
'packages/auth/providers.ts',
'packages/auth/env.ts',
'.env.example',
];
const codeLines = [
'export const authProviders = [',
' Authentik({',
' issuer: env.AUTHENTIK_ISSUER,',
' clientId: env.AUTHENTIK_CLIENT_ID,',
' }),',
'];',
];
export const WorkspaceShowcase = () => (
<section id='workspace' className='border-border/60 bg-muted/30 border-y'>
<div className='container mx-auto px-4 py-24'>
<div className='mb-10 max-w-3xl'>
<Badge variant='outline' className='mb-4'>
Workspace
</Badge>
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
When a thread needs code, open a real workspace.
</h2>
<p className='text-muted-foreground mt-4 text-lg leading-8'>
Spoon can expose project secrets as process env, optionally
materialize an env file, run configured checks, and refuse to commit
`.env*` files. The result is reviewable code, not a mystery patch.
</p>
</div>
<div className='border-border bg-card overflow-hidden rounded-lg border shadow-sm'>
<div className='border-border flex flex-wrap items-center justify-between gap-3 border-b px-5 py-4'>
<div className='flex flex-wrap items-center gap-3 text-sm'>
<span className='flex items-center gap-2'>
<GitBranch className='text-primary size-4' />
spoon/thread/authentik-upstream
</span>
<span className='text-muted-foreground hidden sm:inline'>/</span>
<span className='flex items-center gap-2'>
<Code2 className='text-primary size-4' />
OpenCode
</span>
</div>
<Badge className='bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/10'>
Workspace active
</Badge>
</div>
<div className='bg-border grid min-h-[30rem] gap-px lg:grid-cols-[0.8fr_1.5fr_0.9fr]'>
<div className='bg-background p-4'>
<p className='mb-3 flex items-center gap-2 text-sm font-medium'>
<FileCode2 className='text-primary size-4' />
Files
</p>
<div className='space-y-1 text-sm'>
{files.map((file, index) => (
<div
key={file}
className={
index === 1
? 'bg-primary/10 text-primary rounded-md px-3 py-2 font-medium'
: 'text-muted-foreground rounded-md px-3 py-2'
}
>
{file}
</div>
))}
</div>
</div>
<div className='bg-zinc-950 p-4 text-zinc-100'>
<div className='mb-4 flex items-center justify-between text-xs text-zinc-400'>
<span>packages/auth/providers.ts</span>
<span>vim mode on</span>
</div>
<pre className='overflow-x-auto rounded-md bg-zinc-900 p-4 text-xs leading-7'>
<code>
{codeLines.map((line, index) => (
<span key={line} className='block'>
<span className='mr-4 text-zinc-600'>
{String(index + 1).padStart(2, '0')}
</span>
{line}
</span>
))}
</code>
</pre>
<div className='mt-4 rounded-md border border-zinc-800 bg-zinc-900 p-4 text-xs'>
<p className='text-emerald-400'>+ Authentik provider</p>
<p className='text-red-300'>- GitHub provider fallback</p>
<p className='mt-2 text-zinc-400'>
Diff stays attached to the thread before the draft PR opens.
</p>
</div>
</div>
<div className='bg-background bg-border grid gap-px'>
<div className='bg-background p-4'>
<p className='mb-3 flex items-center gap-2 text-sm font-medium'>
<MessagesSquare className='text-primary size-4' />
Thread
</p>
<div className='space-y-3 text-sm'>
<p className='bg-muted/60 rounded-md p-3'>
Preserve Authentik as the only provider.
</p>
<p className='rounded-md border p-3'>
I found the provider wiring and updated the env example.
</p>
</div>
</div>
<div className='bg-background p-4'>
<p className='mb-3 flex items-center gap-2 text-sm font-medium'>
<Terminal className='text-primary size-4' />
Checks
</p>
<div className='space-y-2 text-sm'>
<div className='bg-muted/60 flex items-center justify-between rounded-md px-3 py-2'>
<span>lint</span>
<span className='flex items-center gap-1 text-emerald-600'>
<CheckCircle2 className='size-4' />
passed
</span>
</div>
<div className='bg-muted/60 flex items-center justify-between rounded-md px-3 py-2'>
<span>typecheck</span>
<span className='text-muted-foreground'>queued</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
@@ -9,9 +9,8 @@ export default function Footer() {
<div className='md:col-span-2'>
<SpoonLogo className='mb-4' />
<p className='text-muted-foreground max-w-md text-sm'>
Spoon is a self-hostable fork maintenance dashboard for teams who
want to customize upstream projects without drifting away from
security fixes, product updates, and merge history.
Spoon is a self-hostable fork maintenance cockpit for keeping
important forks close to upstream without supporting them alone.
</p>
</div>
@@ -36,10 +35,10 @@ export default function Footer() {
</li>
<li>
<Link
href='/updates'
href='/threads'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Updates
Threads
</Link>
</li>
</ul>
@@ -50,18 +49,18 @@ export default function Footer() {
<ul className='space-y-2 text-sm'>
<li>
<Link
href='/agents'
href='/settings/ai-providers'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Agents
AI providers
</Link>
</li>
<li>
<Link
href='/profile'
href='/settings/integrations'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Profile
Integrations
</Link>
</li>
<li>
@@ -80,7 +79,7 @@ export default function Footer() {
<div className='border-border/40 text-muted-foreground mt-12 border-t pt-8 text-center text-sm'>
<p>
Self-hostable fork maintenance for teams that stay close to
Self-hostable fork maintenance for projects that stay close to
upstream.
</p>
</div>
@@ -7,10 +7,9 @@ import { useConvexAuth } from 'convex/react';
import {
GitBranch,
LayoutDashboard,
RefreshCw,
MessagesSquare,
Settings,
ShieldCheck,
Sparkles,
} from 'lucide-react';
import { Button } from '@spoon/ui';
@@ -31,12 +30,12 @@ const Header = (headerProps: ComponentProps<'header'>) => {
{
href: '/spoons',
icon: GitBranch,
label: 'My Spoons',
label: 'Spoons',
},
{
href: '/updates',
icon: RefreshCw,
label: 'Updates',
href: '/threads',
icon: MessagesSquare,
label: 'Threads',
},
{
href: '/settings/profile',
@@ -51,9 +50,9 @@ const Header = (headerProps: ComponentProps<'header'>) => {
label: 'Workflow',
},
{
href: '/#features',
icon: Sparkles,
label: 'Features',
href: '/#threads',
icon: MessagesSquare,
label: 'Threads',
},
{
href: '/#security',
@@ -9,12 +9,12 @@ const formatDate = (value: number) =>
export const SpoonActivityTimeline = ({
syncRuns,
reviews,
requests,
threads,
jobs,
}: {
syncRuns: Doc<'syncRuns'>[];
reviews: Doc<'aiReviews'>[];
requests: Doc<'agentRequests'>[];
threads: Doc<'threads'>[];
jobs: Doc<'agentJobs'>[];
}) => {
const items = [
...syncRuns.map((item) => ({
@@ -24,18 +24,18 @@ export const SpoonActivityTimeline = ({
summary: item.summary ?? item.error ?? 'Sync run recorded.',
time: item.createdAt,
})),
...reviews.map((item) => ({
...threads.map((item) => ({
id: item._id,
kind: 'AI review',
kind: item.source.replaceAll('_', ' '),
status: item.status,
summary: item.outputSummary ?? item.inputSummary,
summary: item.summary ?? item.title,
time: item.createdAt,
})),
...requests.map((item) => ({
...jobs.map((item) => ({
id: item._id,
kind: 'Agent request',
kind: item.jobType?.replaceAll('_', ' ') ?? 'workspace job',
status: item.status,
summary: item.prompt,
summary: item.summary ?? item.prompt,
time: item.createdAt,
})),
].sort((a, b) => b.time - a.time);
@@ -62,7 +62,7 @@ export const SpoonActivityTimeline = ({
) : (
<Card className='shadow-none'>
<CardContent className='text-muted-foreground p-6 text-sm'>
Refreshes, AI reviews, and queued requests will build this timeline.
Refreshes, threads, and workspace jobs will build this timeline.
</CardContent>
</Card>
)}
@@ -1,11 +1,13 @@
'use client';
import { useState } from 'react';
import { useMutation } from 'convex/react';
import type { ProviderModelOption } from '@/lib/models-dev';
import { useEffect, useState } from 'react';
import { loadModelsDevOptions } from '@/lib/models-dev';
import { useMutation, useQuery } from 'convex/react';
import { Bot } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
@@ -24,17 +26,9 @@ import {
} from '@spoon/ui';
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
const modelOptions = [
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
{ value: 'gpt-5.5', label: 'GPT-5.5' },
{ value: 'gpt-5.5-pro', label: 'GPT-5.5 Pro' },
{ value: 'gpt-5.4', label: 'GPT-5.4' },
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
] as const;
type AgentModel = (typeof modelOptions)[number]['value'];
type AgentSettings = {
enabled: boolean;
runtime?: 'opencode' | 'openai_direct';
defaultBaseBranch?: string;
branchPrefix: string;
installCommand?: string;
@@ -42,13 +36,14 @@ type AgentSettings = {
testCommand?: string;
agentModel: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
envFilePath?: string;
customEnvFilePath?: string;
materializeEnvFileByDefault?: boolean;
autoDetectCommands?: boolean;
allowUserFileEditing?: boolean;
aiProviderProfileId?: Id<'aiProviderProfiles'>;
};
const toAgentModel = (value?: string): AgentModel =>
modelOptions.some((option) => option.value === value)
? (value as AgentModel)
: 'gpt-5.1-codex';
export const SpoonAgentSettingsForm = ({
spoon,
settings,
@@ -57,6 +52,13 @@ export const SpoonAgentSettingsForm = ({
settings?: AgentSettings | null;
}) => {
const update = useMutation(api.spoonAgentSettings.update);
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
const configuredProfiles = profiles.filter(
(profile) => profile.enabled && profile.configured,
);
const defaultProfile = configuredProfiles.find(
(profile) => profile.isDefault,
);
const [enabled, setEnabled] = useState(settings?.enabled ?? true);
const [defaultBaseBranch, setDefaultBaseBranch] = useState(
settings?.defaultBaseBranch ??
@@ -73,29 +75,113 @@ export const SpoonAgentSettingsForm = ({
settings?.checkCommand ?? '',
);
const [testCommand, setTestCommand] = useState(settings?.testCommand ?? '');
const [agentModel, setAgentModel] = useState<AgentModel>(
toAgentModel(settings?.agentModel),
const [envFilePath, setEnvFilePath] = useState(
settings?.envFilePath ?? '.env.local',
);
const [customEnvFilePath, setCustomEnvFilePath] = useState(
settings?.customEnvFilePath ?? '',
);
const [materializeEnvFileByDefault, setMaterializeEnvFileByDefault] =
useState(settings?.materializeEnvFileByDefault ?? false);
const [autoDetectCommands, setAutoDetectCommands] = useState(
settings?.autoDetectCommands ?? true,
);
const [allowUserFileEditing, setAllowUserFileEditing] = useState(
settings?.allowUserFileEditing ?? true,
);
const [aiProviderProfileId, setAiProviderProfileId] = useState(
settings?.aiProviderProfileId ?? '__default',
);
const selectedProfile = profiles.find(
(profile) =>
profile._id ===
(aiProviderProfileId === '__default'
? defaultProfile?._id
: aiProviderProfileId),
);
const [availableModels, setAvailableModels] = useState<ProviderModelOption[]>(
[],
);
const [agentModel, setAgentModel] = useState(
settings?.aiProviderProfileId ? settings.agentModel : '',
);
const [reasoningEffort, setReasoningEffort] = useState<
'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
>(
settings?.reasoningEffort === 'none'
? 'minimal'
: (settings?.reasoningEffort ?? 'high'),
!settings?.aiProviderProfileId
? 'medium'
: settings.reasoningEffort === 'none'
? 'minimal'
: settings.reasoningEffort,
);
useEffect(() => {
if (!selectedProfile?.configured) {
return;
}
let cancelled = false;
loadModelsDevOptions(selectedProfile.provider)
.then((models) => {
if (cancelled) return;
setAvailableModels(models);
setAgentModel((current) =>
current && models.some((model) => model.id === current)
? current
: models.some((model) => model.id === selectedProfile.defaultModel)
? selectedProfile.defaultModel
: (models[0]?.id ?? ''),
);
setReasoningEffort(
selectedProfile.reasoningEffort === 'none'
? 'minimal'
: selectedProfile.reasoningEffort,
);
})
.catch((error: unknown) => {
console.error(error);
if (!cancelled) setAvailableModels([]);
});
return () => {
cancelled = true;
};
}, [
selectedProfile?.configured,
selectedProfile?.defaultModel,
selectedProfile?.provider,
selectedProfile?.reasoningEffort,
]);
const selectableModels = selectedProfile?.configured ? availableModels : [];
const save = async () => {
try {
await update({
spoonId: spoon._id,
enabled,
runtime: 'opencode',
defaultBaseBranch,
branchPrefix,
installCommand: installCommand || undefined,
checkCommand: checkCommand || undefined,
testCommand: testCommand || undefined,
agentModel,
agentModel: agentModel.trim()
? agentModel
: (selectableModels[0]?.id ?? undefined),
reasoningEffort,
envFilePath: envFilePath as
| '.env'
| '.env.local'
| '.env.production'
| '.env.production.local'
| 'custom',
customEnvFilePath: customEnvFilePath || undefined,
materializeEnvFileByDefault,
autoDetectCommands,
allowUserFileEditing,
aiProviderProfileId:
aiProviderProfileId === '__default'
? undefined
: (aiProviderProfileId as Id<'aiProviderProfiles'>),
clearAiProviderProfile: aiProviderProfileId === '__default',
});
toast.success('Agent settings saved.');
} catch (error) {
@@ -122,6 +208,50 @@ export const SpoonAgentSettingsForm = ({
/>
</div>
<div className='grid gap-3 md:grid-cols-2'>
<div className='grid gap-2'>
<Label>Runtime</Label>
<Input value='OpenCode workspace' disabled />
</div>
<div className='grid gap-2'>
<Label>AI provider profile</Label>
<Select
value={aiProviderProfileId}
onValueChange={(value) => {
setAiProviderProfileId(value);
const nextProfile = profiles.find(
(profile) =>
profile._id ===
(value === '__default' ? defaultProfile?._id : value),
);
if (!nextProfile?.configured) setAgentModel('');
if (nextProfile?.configured) {
setReasoningEffort(
nextProfile.reasoningEffort === 'none'
? 'minimal'
: nextProfile.reasoningEffort,
);
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='__default'>
Use account default
{defaultProfile ? ` (${defaultProfile.name})` : ''}
</SelectItem>
{configuredProfiles.map((profile) => (
<SelectItem key={profile._id} value={profile._id}>
{profile.name} · {profile.provider.replaceAll('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
OpenCode jobs and maintenance review threads use this profile.
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='defaultBaseBranch'>Default base branch</Label>
<Input
@@ -142,19 +272,26 @@ export const SpoonAgentSettingsForm = ({
<Label htmlFor='agentModel'>Model</Label>
<Select
value={agentModel}
onValueChange={(value) => setAgentModel(value as AgentModel)}
onValueChange={setAgentModel}
disabled={!selectableModels.length}
>
<SelectTrigger id='agentModel'>
<SelectValue />
<SelectValue placeholder='Choose a configured model' />
</SelectTrigger>
<SelectContent>
{modelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{selectableModels.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{!selectableModels.length ? (
<p className='text-muted-foreground text-xs'>
Configure an enabled AI provider profile in Settings before
choosing a model.
</p>
) : null}
</div>
<div className='grid gap-2'>
<Label>Reasoning effort</Label>
@@ -215,8 +352,80 @@ export const SpoonAgentSettingsForm = ({
Leave blank to run the detected test script when one exists.
</p>
</div>
<div className='grid gap-2'>
<Label>Env file path</Label>
<Select value={envFilePath} onValueChange={setEnvFilePath}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='.env'>.env</SelectItem>
<SelectItem value='.env.local'>.env.local</SelectItem>
<SelectItem value='.env.production'>.env.production</SelectItem>
<SelectItem value='.env.production.local'>
.env.production.local
</SelectItem>
<SelectItem value='custom'>Custom path</SelectItem>
</SelectContent>
</Select>
</div>
{envFilePath === 'custom' ? (
<div className='grid gap-2'>
<Label htmlFor='customEnvFilePath'>Custom env path</Label>
<Input
id='customEnvFilePath'
value={customEnvFilePath}
placeholder='.env.spoon'
onChange={(event) => setCustomEnvFilePath(event.target.value)}
/>
</div>
) : null}
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
<div>
<Label>Materialize env file by default</Label>
<p className='text-muted-foreground text-xs'>
Write all Spoon secrets into the chosen .env file for new
workspaces.
</p>
</div>
<Switch
checked={materializeEnvFileByDefault}
onCheckedChange={setMaterializeEnvFileByDefault}
/>
</div>
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
<div>
<Label>Auto-detect commands</Label>
<p className='text-muted-foreground text-xs'>
Inspect package files after cloning.
</p>
</div>
<Switch
checked={autoDetectCommands}
onCheckedChange={setAutoDetectCommands}
/>
</div>
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
<div>
<Label>Allow browser file editing</Label>
<p className='text-muted-foreground text-xs'>
Let users edit workspace files manually.
</p>
</div>
<Switch
checked={allowUserFileEditing}
onCheckedChange={setAllowUserFileEditing}
/>
</div>
</div>
<Button type='button' onClick={save}>
<Button
type='button'
onClick={save}
disabled={
!selectedProfile?.configured ||
!selectableModels.some((model) => model.id === agentModel)
}
>
Save agent settings
</Button>
</CardContent>
@@ -1,75 +0,0 @@
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
export const SpoonAiReviewPanel = ({
latestReview,
reviews,
}: {
latestReview?: Doc<'aiReviews'> | null;
reviews: Doc<'aiReviews'>[];
}) => (
<div className='space-y-4'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>Latest compatibility review</CardTitle>
</CardHeader>
<CardContent>
{latestReview ? (
<div className='space-y-4'>
<div className='flex flex-wrap gap-2'>
<Badge>{latestReview.risk}</Badge>
<Badge variant='outline'>{latestReview.recommendedAction}</Badge>
{latestReview.requiresHumanReview ? (
<Badge variant='secondary'>Human review required</Badge>
) : null}
</div>
<p className='text-sm'>
{latestReview.outputSummary ?? latestReview.inputSummary}
</p>
{latestReview.reasoningSummary ? (
<p className='text-muted-foreground text-sm'>
{latestReview.reasoningSummary}
</p>
) : null}
{latestReview.potentialConflicts?.length ? (
<div>
<p className='text-sm font-medium'>Potential conflicts</p>
<ul className='text-muted-foreground mt-2 list-disc space-y-1 pl-5 text-sm'>
{latestReview.potentialConflicts.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
) : null}
</div>
) : (
<p className='text-muted-foreground text-sm'>
Run an AI review after a GitHub refresh to get compatibility notes.
</p>
)}
</CardContent>
</Card>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>Review history</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{reviews.length ? (
reviews.map((review) => (
<div key={review._id} className='border-border border p-3 text-sm'>
<div className='flex flex-wrap gap-2'>
<Badge variant='outline'>{review.status}</Badge>
<Badge variant='secondary'>{review.risk}</Badge>
</div>
<p className='mt-2'>
{review.outputSummary ?? review.inputSummary}
</p>
</div>
))
) : (
<p className='text-muted-foreground text-sm'>No AI reviews yet.</p>
)}
</CardContent>
</Card>
</div>
);
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
import { useAction } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { Brain, RefreshCw, RotateCw } from 'lucide-react';
import { RefreshCw, RotateCw } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -27,12 +27,6 @@ const syncRef = makeFunctionReference<
unknown
>('githubSync:syncForkWithUpstream');
const reviewRef = makeFunctionReference<
'action',
{ spoonId: Id<'spoons'> },
{ reviewId: Id<'aiReviews'>; risk: string; recommendedAction: string }
>('aiReviewActions:reviewLatestUpstreamChanges');
export const SpoonDetailHeader = ({
spoon,
state,
@@ -42,7 +36,6 @@ export const SpoonDetailHeader = ({
}) => {
const refresh = useAction(refreshRef);
const sync = useAction(syncRef);
const review = useAction(reviewRef);
const [busy, setBusy] = useState<string | null>(null);
const canSync =
spoon.provider === 'github' &&
@@ -110,14 +103,6 @@ export const SpoonDetailHeader = ({
<RefreshCw className='size-4' />
Refresh
</Button>
<Button
variant='outline'
onClick={() => run('AI review', () => review({ spoonId: spoon._id }))}
disabled={Boolean(busy)}
>
<Brain className='size-4' />
Review with AI
</Button>
<Button
onClick={() => run('Sync', () => sync({ spoonId: spoon._id }))}
disabled={Boolean(busy) || !canSync}
@@ -2,7 +2,7 @@ import {
Clock,
GitCommit,
GitPullRequest,
ShieldCheck,
MessagesSquare,
TrendingUp,
} from 'lucide-react';
@@ -17,11 +17,11 @@ const formatDate = (value?: number) =>
export const SpoonMetrics = ({
spoon,
state,
latestReview,
latestThread,
}: {
spoon: Doc<'spoons'>;
state?: Doc<'spoonRepositoryStates'> | null;
latestReview?: Doc<'aiReviews'> | null;
latestThread?: Doc<'threads'> | null;
}) => {
const metrics = [
{
@@ -42,9 +42,9 @@ export const SpoonMetrics = ({
icon: GitPullRequest,
},
{
label: 'Latest AI risk',
value: latestReview?.risk ?? 'unknown',
icon: ShieldCheck,
label: 'Latest thread',
value: latestThread?.status.replaceAll('_', ' ') ?? 'none',
icon: MessagesSquare,
},
{
label: 'Last check',
@@ -93,7 +93,7 @@ export const SpoonSettingsForm = ({
onChange: setAutoRefreshEnabled,
},
{
label: 'Auto AI review',
label: 'Auto maintenance threads',
value: autoReviewEnabled,
onChange: setAutoReviewEnabled,
},
@@ -103,7 +103,7 @@ export const SpoonSettingsForm = ({
onChange: setAutoSyncEnabled,
},
{
label: 'Require low AI risk for sync',
label: 'Require low-risk thread decision for sync',
value: requireAiLowRiskForSync,
onChange: setRequireAiLowRiskForSync,
},
@@ -0,0 +1,56 @@
import Link from 'next/link';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Button, Card, CardContent } from '@spoon/ui';
export const MaintenanceQueue = ({
threads,
}: {
threads: Doc<'threads'>[];
}) => {
const queued = threads.filter(
(thread) =>
['upstream_update', 'merge_conflict'].includes(thread.source) &&
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
);
return (
<div className='space-y-3'>
{queued.length ? (
queued.map((thread) => (
<Card key={thread._id} className='shadow-none'>
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
<div>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-medium'>{thread.title}</p>
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
{thread.maintenanceOutcome ? (
<Badge variant='secondary'>
{thread.maintenanceOutcome.replaceAll('_', ' ')}
</Badge>
) : null}
</div>
<p className='text-muted-foreground mt-1 text-sm'>
{thread.summary ?? 'Maintenance thread waiting for review.'}
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{new Date(thread.updatedAt).toLocaleString()}
</p>
</div>
<Button variant='outline' size='sm' asChild>
<Link href={`/threads/${thread._id}`}>Open thread</Link>
</Button>
</CardContent>
</Card>
))
) : (
<Card className='shadow-none'>
<CardContent className='text-muted-foreground p-6 text-sm'>
No Spoons currently need review. Refresh GitHub state to populate
this queue.
</CardContent>
</Card>
)}
</div>
);
};
@@ -1,52 +0,0 @@
import Link from 'next/link';
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button, Card, CardContent } from '@spoon/ui';
export const MaintenanceQueue = ({ spoons }: { spoons: Doc<'spoons'>[] }) => {
const queued = spoons
.filter((spoon) =>
['behind', 'diverged', 'conflict', 'error'].includes(
spoon.syncStatus ?? '',
),
)
.sort((a, b) => (b.upstreamAheadBy ?? 0) - (a.upstreamAheadBy ?? 0));
return (
<div className='space-y-3'>
{queued.length ? (
queued.map((spoon) => (
<Card key={spoon._id} className='shadow-none'>
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
<div>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-medium'>{spoon.name}</p>
<SpoonStatusBadge status={spoon.syncStatus} />
</div>
<p className='text-muted-foreground mt-1 text-sm'>
{spoon.upstreamOwner}/{spoon.upstreamRepo} {' '}
{spoon.forkOwner ?? 'unknown'}/{spoon.forkRepo ?? 'unknown'}
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{spoon.upstreamAheadBy ?? 0} upstream commit(s),{' '}
{spoon.forkAheadBy ?? 0} fork-only commit(s)
</p>
</div>
<Button variant='outline' size='sm' asChild>
<Link href={`/spoons/${spoon._id}`}>Open Spoon</Link>
</Button>
</CardContent>
</Card>
))
) : (
<Card className='shadow-none'>
<CardContent className='text-muted-foreground p-6 text-sm'>
No Spoons currently need review. Refresh GitHub state to populate
this queue.
</CardContent>
</Card>
)}
</div>
);
};
+7
View File
@@ -19,6 +19,9 @@ export const env = createEnv({
GITHUB_APP_SLUG: z.string().optional(),
GITHUB_APP_INSTALLATION_ID: z.string().optional(),
GITHUB_APP_OWNER: z.string().optional(),
SPOON_AGENT_WORKER_URL: z.url().default('http://localhost:3921'),
SPOON_AGENT_WORKER_INTERNAL_TOKEN: z.string().optional(),
SPOON_WORKER_TOKEN: z.string().optional(),
},
/**
@@ -52,6 +55,10 @@ export const env = createEnv({
GITHUB_APP_SLUG: process.env.GITHUB_APP_SLUG,
GITHUB_APP_INSTALLATION_ID: process.env.GITHUB_APP_INSTALLATION_ID,
GITHUB_APP_OWNER: process.env.GITHUB_APP_OWNER,
SPOON_AGENT_WORKER_URL: process.env.SPOON_AGENT_WORKER_URL,
SPOON_AGENT_WORKER_INTERNAL_TOKEN:
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN,
SPOON_WORKER_TOKEN: process.env.SPOON_WORKER_TOKEN,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
+86
View File
@@ -0,0 +1,86 @@
import 'server-only';
import { NextResponse } from 'next/server';
import { env } from '@/env';
import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server';
import { fetchQuery } from 'convex/nextjs';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
type RouteContext = {
params: Promise<{ jobId: string }> | { jobId: string };
};
export const routeJobId = async (context: RouteContext) => {
const params = await context.params;
return params.jobId as Id<'agentJobs'>;
};
const workerToken = () =>
env.SPOON_AGENT_WORKER_INTERNAL_TOKEN ?? env.SPOON_WORKER_TOKEN;
export const requireOwnedJob = async (jobId: Id<'agentJobs'>) => {
const token = await convexAuthNextjsToken();
if (!token) {
return {
ok: false as const,
response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
};
}
await fetchQuery(api.agentJobs.assertOwned, { jobId }, { token });
return { ok: true as const };
};
export const proxyWorker = async (
jobId: Id<'agentJobs'>,
action: string,
init?: RequestInit,
search?: URLSearchParams,
) => {
const token = workerToken();
if (!token) {
return NextResponse.json(
{ error: 'SPOON_AGENT_WORKER_INTERNAL_TOKEN is not configured.' },
{ status: 500 },
);
}
const url = new URL(
`/jobs/${encodeURIComponent(jobId)}/${action}`,
env.SPOON_AGENT_WORKER_URL,
);
if (search) {
for (const [key, value] of search) url.searchParams.set(key, value);
}
const response = await fetch(url, {
...init,
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
...init?.headers,
},
});
const text = await response.text();
return new NextResponse(text, {
status: response.status,
headers: {
'content-type':
response.headers.get('content-type') ?? 'application/json',
},
});
};
export const withOwnedJob = async (
context: RouteContext,
handler: (jobId: Id<'agentJobs'>) => Promise<Response>,
) => {
try {
const jobId = await routeJobId(context);
const owned = await requireOwnedJob(jobId);
if (!owned.ok) return owned.response;
return await handler(jobId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: message }, { status: 500 });
}
};
+56
View File
@@ -0,0 +1,56 @@
type ModelsDevModel = {
id?: string;
name?: string;
tool_call?: boolean;
reasoning?: boolean;
limit?: { context?: number };
};
type ModelsDevProvider = {
id?: string;
name?: string;
models?: Record<string, ModelsDevModel>;
};
const providerMap = {
openai: 'openai',
anthropic: 'anthropic',
google: 'google',
openrouter: 'openrouter',
requesty: 'requesty',
litellm: 'litellm',
cloudflare_ai_gateway: 'cloudflare',
custom_openai_compatible: '',
opencode_openai_login: 'openai',
} as const;
export type ProviderModelOption = {
id: string;
label: string;
reasoning: boolean;
toolCall: boolean;
context?: number;
};
export const loadModelsDevOptions = async (provider: string) => {
const mapped = providerMap[provider as keyof typeof providerMap];
if (!mapped) return [];
const response = await fetch('https://models.dev/api.json', {
cache: 'force-cache',
});
if (!response.ok) return [];
const catalog = (await response.json()) as Record<string, ModelsDevProvider>;
const providerCatalog = catalog[mapped];
return Object.entries(providerCatalog?.models ?? {})
.map(
([id, model]): ProviderModelOption => ({
id: model.id ?? id,
label: model.name ?? model.id ?? id,
reasoning: Boolean(model.reasoning),
toolCall: Boolean(model.tool_call),
context: model.limit?.context,
}),
)
.filter((model) => model.toolCall)
.sort((a, b) => a.label.localeCompare(b.label));
};
+1
View File
@@ -10,6 +10,7 @@ const isProtectedRoute = createRouteMatcher([
'/spoons(.*)',
'/updates(.*)',
'/agents(.*)',
'/threads(.*)',
'/github(.*)',
'/settings(.*)',
'/profile(.*)',