Move to threads based system.

This commit is contained in:
Gabriel Brown
2026-06-22 10:37:26 -04:00
parent 8ae6c4b533
commit 206b64176b
82 changed files with 6169 additions and 1930 deletions
+4 -143
View File
@@ -1,146 +1,7 @@
'use client';
import { redirect } from 'next/navigation';
import { useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from '@spoon/ui';
const AgentsPage = () => {
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
const requests = useQuery(api.agentRequests.listRecent, { limit: 50 }) ?? [];
const createRequest = useMutation(api.agentRequests.create);
const [spoonId, setSpoonId] = useState('');
const [targetBranch, setTargetBranch] = useState('');
const [prompt, setPrompt] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!spoonId) {
toast.error('Choose a Spoon first.');
return;
}
setSubmitting(true);
try {
await createRequest({
spoonId: spoonId as Id<'spoons'>,
prompt,
targetBranch: targetBranch || undefined,
});
setPrompt('');
setTargetBranch('');
toast.success('Agent request queued.');
} catch (error) {
console.error(error);
toast.error('Could not queue agent request.');
} finally {
setSubmitting(false);
}
};
return (
<main className='space-y-6'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Agents</h1>
<p className='text-muted-foreground mt-2'>
Queue prompt-driven work for future AI merge request automation.
</p>
</div>
<div className='grid gap-6 xl:grid-cols-[0.9fr_1.1fr]'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Request work</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className='space-y-4'>
<div className='grid gap-2'>
<Label>Spoon</Label>
<Select value={spoonId} onValueChange={setSpoonId}>
<SelectTrigger className='w-full'>
<SelectValue placeholder='Choose a Spoon' />
</SelectTrigger>
<SelectContent>
{spoons.map((spoon) => (
<SelectItem key={spoon._id} value={spoon._id}>
{spoon.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='targetBranch'>Target branch</Label>
<Input
id='targetBranch'
value={targetBranch}
placeholder='feature/my-change'
onChange={(event) => setTargetBranch(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='prompt'>Prompt</Label>
<Textarea
id='prompt'
value={prompt}
required
onChange={(event) => setPrompt(event.target.value)}
/>
</div>
<Button type='submit' disabled={submitting || !spoons.length}>
{submitting ? 'Queueing...' : 'Queue request'}
</Button>
</form>
</CardContent>
</Card>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Recent requests</CardTitle>
</CardHeader>
<CardContent>
{requests.length ? (
<div className='space-y-3'>
{requests.map((request) => (
<div key={request._id} className='border-border border p-4'>
<p className='font-medium'>{request.prompt}</p>
<p className='text-muted-foreground mt-1 text-sm'>
{request.status.replaceAll('_', ' ')} ·{' '}
{(request.requestType ?? 'future_code_change').replaceAll(
'_',
' ',
)}{' '}
· {request.source ?? 'user'}
</p>
</div>
))}
</div>
) : (
<p className='text-muted-foreground'>
Agent requests will appear here after you create a Spoon and
queue work.
</p>
)}
</CardContent>
</Card>
</div>
</main>
);
const AgentsRedirectPage = () => {
redirect('/threads?source=user_request');
};
export default AgentsPage;
export default AgentsRedirectPage;
+26 -21
View File
@@ -3,9 +3,9 @@
import Link from 'next/link';
import { MetricCard } from '@/components/dashboard/metric-card';
import { SpoonCard } from '@/components/spoons/spoon-card';
import { MaintenanceQueue } from '@/components/updates/maintenance-queue';
import { MaintenanceQueue } from '@/components/threads/maintenance-queue';
import { useQuery } from 'convex/react';
import { Bot, GitBranch, RefreshCw, ShieldCheck } from 'lucide-react';
import { GitBranch, MessageSquare, RefreshCw, ShieldCheck } from 'lucide-react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
@@ -13,9 +13,7 @@ import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
const DashboardPage = () => {
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
const agentRequests =
useQuery(api.agentRequests.listRecent, { limit: 5 }) ?? [];
const aiReviews = useQuery(api.aiReviews.listRecent, { limit: 5 }) ?? [];
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
const activeSpoons = spoons.filter(
(spoon) => spoon.status === 'active',
).length;
@@ -34,7 +32,8 @@ const DashboardPage = () => {
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Dashboard</h1>
<p className='text-muted-foreground mt-2'>
Monitor managed forks, upstream activity, and queued agent work.
Monitor managed forks, upstream activity, and open maintenance
threads.
</p>
</div>
<Button asChild>
@@ -56,10 +55,17 @@ const DashboardPage = () => {
icon={RefreshCw}
/>
<MetricCard
label='Agent requests'
value={agentRequests.length}
note='Queued and recent'
icon={Bot}
label='Open threads'
value={
threads.filter(
(thread) =>
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
thread.status,
),
).length
}
note='Across all Spoons'
icon={MessageSquare}
/>
<MetricCard
label='Upstream commits'
@@ -71,7 +77,7 @@ const DashboardPage = () => {
<section className='space-y-3'>
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
<MaintenanceQueue spoons={spoons} />
<MaintenanceQueue threads={threads} />
</section>
<div className='grid gap-6 xl:grid-cols-2'>
@@ -126,29 +132,28 @@ const DashboardPage = () => {
</Card>
<Card className='mt-4 shadow-none'>
<CardHeader>
<CardTitle className='text-base'>AI reviews</CardTitle>
<CardTitle className='text-base'>Recent threads</CardTitle>
</CardHeader>
<CardContent>
{aiReviews.length ? (
{threads.length ? (
<div className='space-y-3'>
{aiReviews.map((review) => (
{threads.slice(0, 5).map((thread) => (
<div
key={review._id}
key={thread._id}
className='border-border border p-3 text-sm'
>
<p className='font-medium capitalize'>
{review.risk} risk
</p>
<p className='font-medium'>{thread.title}</p>
<p className='text-muted-foreground'>
{review.outputSummary ?? review.inputSummary}
{thread.status.replaceAll('_', ' ')} ·{' '}
{thread.source.replaceAll('_', ' ')}
</p>
</div>
))}
</div>
) : (
<p className='text-muted-foreground text-sm'>
OpenAI compatibility reviews will appear here after you run
them on a Spoon.
Threads appear when you request work or upstream changes need
review.
</p>
)}
</CardContent>
@@ -0,0 +1,16 @@
import { AiProviderProfilesPanel } from '@/components/integrations/ai-provider-profiles-panel';
const AiProvidersPage = () => (
<section className='max-w-5xl space-y-4'>
<div>
<h2 className='text-xl font-semibold'>AI providers</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Configure encrypted API-key profiles and OpenCode auth profiles for
agent workspaces.
</p>
</div>
<AiProviderProfilesPanel />
</section>
);
export default AiProvidersPage;
+2 -13
View File
@@ -1,16 +1,5 @@
import { OpenAiStatusPanel } from '@/components/integrations/openai-status-panel';
import { redirect } from 'next/navigation';
const AiSettingsPage = () => (
<section className='max-w-3xl space-y-4'>
<div>
<h2 className='text-xl font-semibold'>AI</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Configure the OpenAI key, review model, and thinking level used for
compatibility reviews.
</p>
</div>
<OpenAiStatusPanel />
</section>
);
const AiSettingsPage = () => redirect('/settings/ai-providers');
export default AiSettingsPage;
+1 -1
View File
@@ -10,7 +10,7 @@ import { cn } from '@spoon/ui';
const settingsItems = [
{ href: '/settings/profile', label: 'Profile', icon: User },
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
{ href: '/settings/ai', label: 'AI', icon: Brain },
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
{ href: '/settings/security', label: 'Security', icon: Shield },
];
@@ -0,0 +1,27 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell';
import { ArrowLeft } from 'lucide-react';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button } from '@spoon/ui';
const AgentWorkspacePage = () => {
const params = useParams<{ spoonId: string; jobId: string }>();
return (
<main className='space-y-4'>
<Button asChild variant='ghost' size='sm'>
<Link href={`/spoons/${params.spoonId}`}>
<ArrowLeft className='size-4' />
Back to Spoon
</Link>
</Button>
<AgentWorkspaceShell jobId={params.jobId as Id<'agentJobs'>} />
</main>
);
};
export default AgentWorkspacePage;
@@ -1,11 +1,11 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { AgentJobList } from '@/components/agents/agent-job-list';
import { AgentRequestForm } from '@/components/agents/agent-request-form';
import { SpoonActivityTimeline } from '@/components/spoons/spoon-activity-timeline';
import { SpoonAgentSettingsForm } from '@/components/spoons/spoon-agent-settings-form';
import { SpoonAiReviewPanel } from '@/components/spoons/spoon-ai-review-panel';
import { SpoonClonePanel } from '@/components/spoons/spoon-clone-panel';
import { SpoonCommitList } from '@/components/spoons/spoon-commit-list';
import { SpoonDetailHeader } from '@/components/spoons/spoon-detail-header';
@@ -46,12 +46,10 @@ const SpoonDetailPage = () => {
}) ?? [];
const pullRequests =
useQuery(api.spoonPullRequests.listForSpoon, { spoonId, limit: 100 }) ?? [];
const reviews =
useQuery(api.aiReviews.listForSpoon, { spoonId, limit: 25 }) ?? [];
const syncRuns =
useQuery(api.syncRuns.listForSpoon, { spoonId, limit: 25 }) ?? [];
const agentRequests =
useQuery(api.agentRequests.listForSpoon, { spoonId, limit: 25 }) ?? [];
const threads =
useQuery(api.threads.listForSpoon, { spoonId, limit: 25 }) ?? [];
const agentSettings = useQuery(api.spoonAgentSettings.getForSpoon, {
spoonId,
});
@@ -68,7 +66,7 @@ const SpoonDetailPage = () => {
<SpoonMetrics
spoon={details.spoon}
state={details.state}
latestReview={details.latestReview}
latestThread={threads[0]}
/>
{details.spoon.lastError ? (
<Card className='border-destructive shadow-none'>
@@ -95,11 +93,8 @@ const SpoonDetailPage = () => {
<TabsTrigger className='h-9 flex-none px-3' value='pulls'>
Pull requests
</TabsTrigger>
<TabsTrigger className='h-9 flex-none px-3' value='ai'>
AI review
</TabsTrigger>
<TabsTrigger className='h-9 flex-none px-3' value='agent'>
Agent work
<TabsTrigger className='h-9 flex-none px-3' value='threads'>
Threads
</TabsTrigger>
<TabsTrigger className='h-9 flex-none px-3' value='activity'>
Activity
@@ -125,6 +120,17 @@ const SpoonDetailPage = () => {
'unknown'
).replaceAll('_', ' ')}
</p>
{details.effectiveUpstreamAheadBy === 0 &&
(details.state?.upstreamAheadBy ??
details.spoon.upstreamAheadBy ??
0) > 0 ? (
<p className='text-muted-foreground mt-1 text-xs'>
Up to date after ignored upstream changes. Raw upstream
ahead:{' '}
{details.state?.upstreamAheadBy ??
details.spoon.upstreamAheadBy}
</p>
) : null}
</div>
<div>
<p className='text-muted-foreground'>Default branches</p>
@@ -155,37 +161,34 @@ const SpoonDetailPage = () => {
</Card>
<Card className='shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Latest AI review</CardTitle>
<CardTitle className='text-base'>Latest thread</CardTitle>
</CardHeader>
<CardContent className='space-y-3 text-sm'>
{details.latestReview ? (
{threads[0] ? (
<>
<div className='grid grid-cols-2 gap-3'>
<div>
<p className='text-muted-foreground'>Risk</p>
<p className='text-muted-foreground'>Status</p>
<p className='mt-1 font-semibold capitalize'>
{details.latestReview.risk}
{threads[0].status.replaceAll('_', ' ')}
</p>
</div>
<div>
<p className='text-muted-foreground'>Action</p>
<p className='text-muted-foreground'>Source</p>
<p className='mt-1 font-semibold capitalize'>
{details.latestReview.recommendedAction.replaceAll(
'_',
' ',
)}
{threads[0].source.replaceAll('_', ' ')}
</p>
</div>
</div>
<p className='text-muted-foreground'>
{details.latestReview.outputSummary ??
details.latestReview.inputSummary}
{threads[0].summary ??
'Open the thread to continue maintenance work.'}
</p>
</>
) : (
<p className='text-muted-foreground'>
Run a refresh and AI review to get a compatibility summary
for upstream changes.
Refresh GitHub state or create a thread to start maintenance
work for this Spoon.
</p>
)}
</CardContent>
@@ -239,26 +242,45 @@ const SpoonDetailPage = () => {
<SpoonPrList pullRequests={pullRequests} />
</TabsContent>
<TabsContent value='ai' className='space-y-4'>
<SpoonAiReviewPanel
latestReview={details.latestReview}
reviews={reviews}
/>
</TabsContent>
<TabsContent value='agent' className='space-y-4'>
<TabsContent value='threads' className='space-y-4'>
<AgentRequestForm
spoon={details.spoon}
agentSettings={agentSettings}
/>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>Spoon threads</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{threads.length ? (
threads.map((thread) => (
<Link
key={thread._id}
href={`/threads/${thread._id}`}
className='border-border hover:border-primary/50 block rounded-md border p-3 transition-colors'
>
<p className='font-medium'>{thread.title}</p>
<p className='text-muted-foreground mt-1 text-sm'>
{thread.status.replaceAll('_', ' ')} ·{' '}
{thread.source.replaceAll('_', ' ')}
</p>
</Link>
))
) : (
<p className='text-muted-foreground text-sm'>
No threads exist for this Spoon yet.
</p>
)}
</CardContent>
</Card>
<AgentJobList jobs={agentJobs} />
</TabsContent>
<TabsContent value='activity'>
<SpoonActivityTimeline
syncRuns={syncRuns}
reviews={reviews}
requests={agentRequests}
threads={threads}
jobs={agentJobs}
/>
</TabsContent>
+166 -8
View File
@@ -1,33 +1,185 @@
'use client';
import Link from 'next/link';
import { SpoonCard } from '@/components/spoons/spoon-card';
import { useRouter } from 'next/navigation';
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
import { useQuery } from 'convex/react';
import {
ArrowUpRight,
GitBranch,
MessageSquare,
RefreshCw,
} from 'lucide-react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent } from '@spoon/ui';
import {
Badge,
Button,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@spoon/ui';
const formatDate = (value?: number) =>
value
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
: 'Never';
const SpoonsPage = () => {
const router = useRouter();
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
const active = spoons.filter((spoon) => spoon.status === 'active').length;
const needsReview = threads.filter(
(thread) =>
thread.spoonId &&
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
).length;
const upstreamWaiting = spoons.reduce(
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
0,
);
return (
<main className='space-y-6'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Spoons</h1>
<p className='text-muted-foreground mt-2'>
Managed forks you want to keep close to their upstream projects.
Managed forks, upstream drift, active maintenance threads, and fork
metadata in one place.
</p>
</div>
<Button asChild>
<Link href='/spoons/new'>New Spoon</Link>
</Button>
</div>
<div className='grid gap-3 md:grid-cols-3'>
<Card className='shadow-none'>
<CardContent className='flex items-center justify-between p-4'>
<div>
<p className='text-muted-foreground text-sm'>Managed</p>
<p className='text-2xl font-semibold'>{spoons.length}</p>
</div>
<GitBranch className='text-muted-foreground size-5' />
</CardContent>
</Card>
<Card className='shadow-none'>
<CardContent className='flex items-center justify-between p-4'>
<div>
<p className='text-muted-foreground text-sm'>Active</p>
<p className='text-2xl font-semibold'>{active}</p>
</div>
<RefreshCw className='text-muted-foreground size-5' />
</CardContent>
</Card>
<Card className='shadow-none'>
<CardContent className='flex items-center justify-between p-4'>
<div>
<p className='text-muted-foreground text-sm'>Open threads</p>
<p className='text-2xl font-semibold'>{needsReview}</p>
</div>
<MessageSquare className='text-muted-foreground size-5' />
</CardContent>
</Card>
</div>
{spoons.length ? (
<div className='grid gap-4 xl:grid-cols-2'>
{spoons.map((spoon) => (
<SpoonCard key={spoon._id} spoon={spoon} />
))}
</div>
<Card className='shadow-none'>
<CardContent className='p-0'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='pl-4'>Spoon</TableHead>
<TableHead>Status</TableHead>
<TableHead>Fork</TableHead>
<TableHead>Drift</TableHead>
<TableHead>Cadence</TableHead>
<TableHead>Last checked</TableHead>
<TableHead className='pr-4 text-right'>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{spoons.map((spoon) => {
const href = `/spoons/${spoon._id}`;
return (
<TableRow
key={spoon._id}
role='link'
tabIndex={0}
className='hover:bg-muted/50 cursor-pointer'
onClick={() => router.push(href)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
router.push(href);
}
}}
>
<TableCell className='pl-4'>
<Link
href={href}
className='group inline-flex min-w-0 flex-col'
onClick={(event) => event.stopPropagation()}
>
<span className='group-hover:text-primary font-medium transition-colors'>
{spoon.name}
</span>
<span className='text-muted-foreground text-xs'>
{spoon.upstreamOwner}/{spoon.upstreamRepo}
</span>
</Link>
</TableCell>
<TableCell>
<SpoonStatusBadge
status={spoon.syncStatus ?? spoon.status}
/>
</TableCell>
<TableCell>
{spoon.forkOwner && spoon.forkRepo ? (
<span className='font-medium'>
{spoon.forkOwner}/{spoon.forkRepo}
</span>
) : (
<Badge variant='outline'>Missing metadata</Badge>
)}
</TableCell>
<TableCell>
<div className='text-sm'>
<p>{spoon.upstreamAheadBy ?? 0} upstream</p>
<p className='text-muted-foreground'>
{spoon.forkAheadBy ?? 0} fork-only
</p>
</div>
</TableCell>
<TableCell className='capitalize'>
{spoon.syncCadence}
</TableCell>
<TableCell>{formatDate(spoon.lastCheckedAt)}</TableCell>
<TableCell className='pr-4 text-right'>
<Button size='sm' variant='outline' asChild>
<Link
href={href}
onClick={(event) => event.stopPropagation()}
>
Open
<ArrowUpRight className='size-3' />
</Link>
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<Card className='shadow-none'>
<CardContent className='p-8'>
@@ -42,6 +194,12 @@ const SpoonsPage = () => {
</CardContent>
</Card>
)}
{spoons.length ? (
<p className='text-muted-foreground text-sm'>
Raw upstream commits waiting across all Spoons: {upstreamWaiting}
</p>
) : null}
</main>
);
};
@@ -0,0 +1,188 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useMutation, useQuery } from 'convex/react';
import { ArrowUpRight, CheckCircle2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Textarea,
} from '@spoon/ui';
const ThreadDetailPage = () => {
const params = useParams<{ threadId: string }>();
const threadId = params.threadId as Id<'threads'>;
const details = useQuery(api.threads.get, { threadId });
const messages = useQuery(api.threads.listMessages, { threadId }) ?? [];
const appendMessage = useMutation(api.threads.appendUserMessage);
const markResolved = useMutation(api.threads.markResolved);
const cancel = useMutation(api.threads.cancel);
if (details === undefined) {
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
}
const { thread, spoon, latestJob } = details;
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const value = form.get('message');
const content = typeof value === 'string' ? value : '';
try {
await appendMessage({ threadId, content });
event.currentTarget.reset();
toast.success('Message added.');
} catch (error) {
console.error(error);
toast.error('Could not add message.');
}
};
return (
<main className='space-y-6'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-start'>
<div>
<div className='flex flex-wrap items-center gap-2'>
<h1 className='text-3xl font-semibold tracking-normal'>
{thread.title}
</h1>
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
<Badge variant='outline'>
{thread.source.replaceAll('_', ' ')}
</Badge>
{thread.maintenanceOutcome ? (
<Badge variant='secondary'>
{thread.maintenanceOutcome.replaceAll('_', ' ')}
</Badge>
) : null}
</div>
<p className='text-muted-foreground mt-2 max-w-3xl'>
{thread.summary ?? 'No summary has been recorded yet.'}
</p>
{spoon ? (
<Button variant='link' className='mt-2 h-auto p-0' asChild>
<Link href={`/spoons/${spoon._id}`}>
{spoon.name}
<ArrowUpRight className='size-3' />
</Link>
</Button>
) : null}
</div>
<div className='flex flex-wrap gap-2'>
{latestJob ? (
<Button variant='outline' asChild>
<Link
href={`/spoons/${latestJob.spoonId}/agent/${latestJob._id}`}
>
Open workspace
</Link>
</Button>
) : null}
{latestJob?.pullRequestUrl ? (
<Button asChild>
<a
href={latestJob.pullRequestUrl}
target='_blank'
rel='noreferrer'
>
Open PR
</a>
</Button>
) : null}
<Button
variant='outline'
onClick={() =>
markResolved({ threadId }).then(() =>
toast.success('Thread resolved.'),
)
}
>
<CheckCircle2 className='size-4' />
Resolve
</Button>
<Button
variant='outline'
onClick={() =>
cancel({ threadId }).then(() =>
toast.success('Thread cancelled.'),
)
}
>
<XCircle className='size-4' />
Cancel
</Button>
</div>
</div>
<div className='grid gap-6 xl:grid-cols-[1fr_320px]'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Conversation</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
{messages.map((message) => (
<div
key={message._id}
className='border-border rounded-md border p-3'
>
<div className='mb-2 flex items-center justify-between gap-2'>
<Badge variant='outline'>{message.role}</Badge>
<span className='text-muted-foreground text-xs'>
{new Date(message.createdAt).toLocaleString()}
</span>
</div>
<p className='text-sm whitespace-pre-wrap'>{message.content}</p>
</div>
))}
<form onSubmit={submit} className='space-y-3'>
<Textarea
name='message'
required
minLength={2}
placeholder='Add context or instructions for this thread.'
/>
<Button type='submit'>Add message</Button>
</form>
</CardContent>
</Card>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Thread state</CardTitle>
</CardHeader>
<CardContent className='space-y-3 text-sm'>
<div>
<p className='text-muted-foreground'>Priority</p>
<p className='font-medium capitalize'>{thread.priority}</p>
</div>
<div>
<p className='text-muted-foreground'>Upstream range</p>
<p className='font-mono text-xs break-all'>
{thread.upstreamFrom ?? 'unknown'} {' '}
{thread.upstreamTo ?? 'unknown'}
</p>
</div>
<div>
<p className='text-muted-foreground'>Latest job</p>
<p className='font-medium'>
{latestJob?.status.replaceAll('_', ' ') ?? 'No job queued'}
</p>
</div>
</CardContent>
</Card>
</div>
</main>
);
};
export default ThreadDetailPage;
+128
View File
@@ -0,0 +1,128 @@
'use client';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useQuery } from 'convex/react';
import { MessageSquare, Plus } from 'lucide-react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Badge,
Button,
Card,
CardContent,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@spoon/ui';
const formatTime = (value: number) => new Date(value).toLocaleString();
const ThreadsPage = () => {
const params = useSearchParams();
const source = params.get('source') ?? 'all';
const threads =
useQuery(api.threads.listMine, {
source: source as
| 'all'
| 'user_request'
| 'upstream_update'
| 'merge_conflict'
| 'manual_review'
| 'system',
limit: 100,
}) ?? [];
return (
<main className='space-y-6'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Threads</h1>
<p className='text-muted-foreground mt-2'>
Maintenance reviews, upstream decisions, and user-requested fork
work across all Spoons.
</p>
</div>
<Button asChild>
<Link href='/spoons'>
<Plus className='size-4' />
New thread from Spoon
</Link>
</Button>
</div>
<div className='flex flex-col gap-3 md:flex-row'>
<Select
value={source}
onValueChange={(value) => {
window.location.href =
value === 'all' ? '/threads' : `/threads?source=${value}`;
}}
>
<SelectTrigger className='w-full md:w-56'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All sources</SelectItem>
<SelectItem value='user_request'>User requests</SelectItem>
<SelectItem value='upstream_update'>Upstream updates</SelectItem>
<SelectItem value='merge_conflict'>Merge conflicts</SelectItem>
<SelectItem value='manual_review'>Manual review</SelectItem>
<SelectItem value='system'>System</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-3'>
{threads.length ? (
threads.map((thread) => (
<Link
key={thread._id}
href={`/threads/${thread._id}`}
className='block'
>
<Card className='hover:border-primary/50 shadow-none transition-colors'>
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
<div className='min-w-0'>
<div className='flex flex-wrap items-center gap-2'>
<h2 className='truncate font-medium'>{thread.title}</h2>
<Badge variant='outline'>
{thread.source.replaceAll('_', ' ')}
</Badge>
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
{thread.maintenanceOutcome ? (
<Badge variant='secondary'>
{thread.maintenanceOutcome.replaceAll('_', ' ')}
</Badge>
) : null}
</div>
<p className='text-muted-foreground mt-1 line-clamp-2 text-sm'>
{thread.summary ??
'No summary has been recorded for this thread yet.'}
</p>
</div>
<div className='text-muted-foreground text-xs md:text-right'>
<p>{formatTime(thread.updatedAt)}</p>
<p className='capitalize'>{thread.priority} priority</p>
</div>
</CardContent>
</Card>
</Link>
))
) : (
<Card className='shadow-none'>
<CardContent className='text-muted-foreground flex items-center gap-3 p-6 text-sm'>
<MessageSquare className='size-4' />
Threads appear when you ask Spoon to change a fork or when
upstream changes need review.
</CardContent>
</Card>
)}
</div>
</main>
);
};
export default ThreadsPage;
+4 -85
View File
@@ -1,88 +1,7 @@
'use client';
import { redirect } from 'next/navigation';
import { MaintenanceQueue } from '@/components/updates/maintenance-queue';
import { useQuery } from 'convex/react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@spoon/ui';
const UpdatesPage = () => {
const runs = useQuery(api.syncRuns.listRecent, { limit: 50 }) ?? [];
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
return (
<main className='space-y-6'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Updates</h1>
<p className='text-muted-foreground mt-2'>
Upstream checks, merge attempts, and AI reviews will appear here.
</p>
</div>
<div className='flex flex-col gap-3 md:flex-row'>
<Select defaultValue='all'>
<SelectTrigger className='w-full md:w-48'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All statuses</SelectItem>
<SelectItem value='needs_review'>Needs review</SelectItem>
<SelectItem value='conflict'>Conflict</SelectItem>
<SelectItem value='clean'>Clean</SelectItem>
</SelectContent>
</Select>
<Select defaultValue='all'>
<SelectTrigger className='w-full md:w-64'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Spoons</SelectItem>
{spoons.map((spoon) => (
<SelectItem key={spoon._id} value={spoon._id}>
{spoon.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Recent sync runs</CardTitle>
</CardHeader>
<CardContent>
{runs.length ? (
<div className='space-y-3'>
{runs.map((run) => (
<div key={run._id} className='border-border border p-4'>
<p className='font-medium'>{run.kind.replaceAll('_', ' ')}</p>
<p className='text-muted-foreground text-sm'>
{run.status.replaceAll('_', ' ')}
</p>
</div>
))}
</div>
) : (
<p className='text-muted-foreground'>
Scheduled upstream checks will appear here once provider
connections and workers are added.
</p>
)}
</CardContent>
</Card>
<section className='space-y-3'>
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
<MaintenanceQueue spoons={spoons} />
</section>
</main>
);
const UpdatesRedirectPage = () => {
redirect('/threads?source=upstream_update');
};
export default UpdatesPage;
export default UpdatesRedirectPage;