Move to threads based system.
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user