Move to threads based system.
This commit is contained in:
@@ -21,11 +21,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@convex-dev/auth": "catalog:convex",
|
||||
"@monaco-editor/react": "latest",
|
||||
"@sentry/nextjs": "^10.46.0",
|
||||
"@spoon/backend": "workspace:*",
|
||||
"@spoon/ui": "workspace:*",
|
||||
"@t3-oss/env-nextjs": "^0.13.11",
|
||||
"convex": "catalog:convex",
|
||||
"monaco-editor": "latest",
|
||||
"monaco-vim": "latest",
|
||||
"next": "^16.2.1",
|
||||
"next-plausible": "^3.12.5",
|
||||
"react": "catalog:react19",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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' }),
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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' />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
@@ -10,6 +10,7 @@ const isProtectedRoute = createRouteMatcher([
|
||||
'/spoons(.*)',
|
||||
'/updates(.*)',
|
||||
'/agents(.*)',
|
||||
'/threads(.*)',
|
||||
'/github(.*)',
|
||||
'/settings(.*)',
|
||||
'/profile(.*)',
|
||||
|
||||
Reference in New Issue
Block a user