Add features & update project
This commit is contained in:
@@ -11,18 +11,20 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
|
||||
const DashboardPage = () => {
|
||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
|
||||
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
|
||||
const activeSpoons = spoons.filter(
|
||||
(spoon) => spoon.status === 'active',
|
||||
).length;
|
||||
const behind = spoons.filter((spoon) => spoon.syncStatus === 'behind').length;
|
||||
const behind = spoons.filter(
|
||||
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy === 0,
|
||||
).length;
|
||||
const diverged = spoons.filter(
|
||||
(spoon) => spoon.syncStatus === 'diverged',
|
||||
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0,
|
||||
).length;
|
||||
const openPullRequests = spoons.reduce(
|
||||
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
||||
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -70,7 +72,7 @@ const DashboardPage = () => {
|
||||
<MetricCard
|
||||
label='Upstream commits'
|
||||
value={openPullRequests}
|
||||
note='Waiting across Spoons'
|
||||
note='Actionable after ignores'
|
||||
icon={ShieldCheck}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ const formatDate = (value?: number) =>
|
||||
|
||||
const SpoonsPage = () => {
|
||||
const router = useRouter();
|
||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
|
||||
const active = spoons.filter((spoon) => spoon.status === 'active').length;
|
||||
const needsReview = threads.filter(
|
||||
@@ -41,7 +41,7 @@ const SpoonsPage = () => {
|
||||
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
|
||||
).length;
|
||||
const upstreamWaiting = spoons.reduce(
|
||||
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
||||
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -152,10 +152,16 @@ const SpoonsPage = () => {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='text-sm'>
|
||||
<p>{spoon.upstreamAheadBy ?? 0} upstream</p>
|
||||
<p>{spoon.effectiveUpstreamAheadBy} actionable</p>
|
||||
<p className='text-muted-foreground'>
|
||||
{spoon.forkAheadBy ?? 0} fork-only
|
||||
{spoon.rawUpstreamAheadBy} raw upstream ·{' '}
|
||||
{spoon.forkAheadBy} fork-only
|
||||
</p>
|
||||
{spoon.ignoredUpstreamCount ? (
|
||||
<p className='text-muted-foreground'>
|
||||
{spoon.ignoredUpstreamCount} ignored
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='capitalize'>
|
||||
@@ -197,7 +203,8 @@ const SpoonsPage = () => {
|
||||
|
||||
{spoons.length ? (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Raw upstream commits waiting across all Spoons: {upstreamWaiting}
|
||||
Actionable upstream commits waiting across all Spoons:{' '}
|
||||
{upstreamWaiting}
|
||||
</p>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { ArrowUpRight, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { ArrowUpRight, CheckCircle2, Play, XCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
@@ -23,28 +24,88 @@ const ThreadDetailPage = () => {
|
||||
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 createJob = useMutation(api.agentJobs.createForThread);
|
||||
const markResolved = useMutation(api.threads.markResolved);
|
||||
const cancel = useMutation(api.threads.cancel);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [queueing, setQueueing] = useState(false);
|
||||
|
||||
if (details === undefined) {
|
||||
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
|
||||
}
|
||||
|
||||
const { thread, spoon, latestJob } = details;
|
||||
const terminalThread = [
|
||||
'resolved',
|
||||
'ignored',
|
||||
'failed',
|
||||
'cancelled',
|
||||
].includes(thread.status);
|
||||
const activeJob =
|
||||
latestJob &&
|
||||
[
|
||||
'claimed',
|
||||
'preparing',
|
||||
'running',
|
||||
'checks_running',
|
||||
'changes_ready',
|
||||
].includes(latestJob.status) &&
|
||||
['active', 'idle'].includes(latestJob.workspaceStatus ?? '');
|
||||
const canQueueRun =
|
||||
spoon &&
|
||||
(!latestJob ||
|
||||
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
|
||||
latestJob.status,
|
||||
) ||
|
||||
['stopped', 'expired', 'failed'].includes(
|
||||
latestJob.workspaceStatus ?? '',
|
||||
));
|
||||
const jobType =
|
||||
thread.source === 'merge_conflict'
|
||||
? ('conflict_resolution' as const)
|
||||
: thread.source === 'upstream_update'
|
||||
? ('maintenance_review' as const)
|
||||
: ('user_change' as const);
|
||||
|
||||
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 : '';
|
||||
setSending(true);
|
||||
try {
|
||||
await appendMessage({ threadId, content });
|
||||
const response = await fetch(`/api/threads/${threadId}/message`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
recoverable?: boolean;
|
||||
} | null;
|
||||
throw new Error(payload?.error ?? (await response.text()));
|
||||
}
|
||||
event.currentTarget.reset();
|
||||
toast.success('Message added.');
|
||||
toast.success(activeJob ? 'Message sent to agent.' : 'Message added.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not add message.');
|
||||
toast.error('Could not send message.');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startRun = async () => {
|
||||
setQueueing(true);
|
||||
try {
|
||||
const jobId = await createJob({ threadId, jobType });
|
||||
toast.success('Workspace run queued.');
|
||||
window.location.href = `/spoons/${spoon?._id}/agent/${jobId}`;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not queue workspace run.');
|
||||
} finally {
|
||||
setQueueing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,28 +160,40 @@ const ThreadDetailPage = () => {
|
||||
</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>
|
||||
{canQueueRun ? (
|
||||
<Button disabled={queueing} onClick={() => void startRun()}>
|
||||
<Play className='size-4' />
|
||||
{latestJob ? 'Rerun' : 'Start workspace run'}
|
||||
</Button>
|
||||
) : null}
|
||||
{!terminalThread ? (
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
if (!window.confirm('Mark this thread as resolved?')) return;
|
||||
void markResolved({ threadId }).then(() =>
|
||||
toast.success('Thread resolved.'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 className='size-4' />
|
||||
Resolve
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
if (!window.confirm('Cancel this thread?')) return;
|
||||
void cancel({ threadId }).then(() =>
|
||||
toast.success('Thread cancelled.'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<XCircle className='size-4' />
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,9 +222,28 @@ const ThreadDetailPage = () => {
|
||||
name='message'
|
||||
required
|
||||
minLength={2}
|
||||
placeholder='Add context or instructions for this thread.'
|
||||
placeholder={
|
||||
activeJob
|
||||
? 'Send instructions to the active agent workspace.'
|
||||
: 'Add context or instructions for the next run.'
|
||||
}
|
||||
disabled={sending || terminalThread}
|
||||
/>
|
||||
<Button type='submit'>Add message</Button>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Button type='submit' disabled={sending || terminalThread}>
|
||||
{sending
|
||||
? 'Sending...'
|
||||
: activeJob
|
||||
? 'Send to agent'
|
||||
: 'Add note'}
|
||||
</Button>
|
||||
{!activeJob ? (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
No active workspace is attached, so messages are saved as
|
||||
thread notes until a run is started.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,28 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { MessageSquare, Plus } 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,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const formatTime = (value: number) => new Date(value).toLocaleString();
|
||||
|
||||
const ThreadsPage = () => {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const source = params.get('source') ?? 'all';
|
||||
const status = params.get('status') ?? 'all';
|
||||
const [spoonFilter, setSpoonFilter] = useState('all');
|
||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||
const [outcomeFilter, setOutcomeFilter] = useState('all');
|
||||
const [spoonId, setSpoonId] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [materializeEnvFile, setMaterializeEnvFile] = useState(false);
|
||||
const [envFilePath, setEnvFilePath] = useState('.env.local');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const createThread = useMutation(api.threads.createUserThread);
|
||||
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||
const defaultProfile = profiles.find((profile) => profile.isDefault);
|
||||
const threads =
|
||||
useQuery(api.threads.listMine, {
|
||||
source: source as
|
||||
@@ -32,8 +56,62 @@ const ThreadsPage = () => {
|
||||
| 'merge_conflict'
|
||||
| 'manual_review'
|
||||
| 'system',
|
||||
status: status as
|
||||
| 'all'
|
||||
| 'open'
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'waiting_for_user'
|
||||
| 'changes_ready'
|
||||
| 'draft_pr_opened'
|
||||
| 'resolved'
|
||||
| 'ignored'
|
||||
| 'failed'
|
||||
| 'cancelled',
|
||||
limit: 100,
|
||||
}) ?? [];
|
||||
const visibleThreads = threads.filter((thread) => {
|
||||
if (spoonFilter !== 'all' && thread.spoonId !== spoonFilter) return false;
|
||||
if (priorityFilter !== 'all' && thread.priority !== priorityFilter) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
outcomeFilter !== 'all' &&
|
||||
(thread.maintenanceOutcome ?? 'none') !== outcomeFilter
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const updateFilter = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(params.toString());
|
||||
if (value === 'all') next.delete(key);
|
||||
else next.set(key, value);
|
||||
router.push(next.size ? `/threads?${next.toString()}` : '/threads');
|
||||
};
|
||||
|
||||
const submitThread = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!spoonId || !prompt.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const threadId = await createThread({
|
||||
spoonId: spoonId as Id<'spoons'>,
|
||||
title: title.trim() || undefined,
|
||||
prompt,
|
||||
materializeEnvFile,
|
||||
envFilePath,
|
||||
});
|
||||
toast.success('Thread created.');
|
||||
router.push(`/threads/${threadId}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not create thread.');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
@@ -46,20 +124,97 @@ const ThreadsPage = () => {
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href='/spoons'>
|
||||
<a href='#new-thread'>
|
||||
<Plus className='size-4' />
|
||||
New thread from Spoon
|
||||
</Link>
|
||||
New thread
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-3 md:flex-row'>
|
||||
<Card id='new-thread' className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>New thread</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={submitThread} className='grid gap-4 lg:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Spoon</Label>
|
||||
<Select value={spoonId} onValueChange={setSpoonId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Choose a Spoon' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{spoons.map((spoon) => (
|
||||
<SelectItem key={spoon._id} value={spoon._id}>
|
||||
{spoon.name} · {spoon.upstreamOwner}/{spoon.upstreamRepo}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
value={title}
|
||||
placeholder='Optional'
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2 lg:col-span-2'>
|
||||
<Label>Prompt</Label>
|
||||
<Textarea
|
||||
value={prompt}
|
||||
placeholder='Describe the change, review, or maintenance task.'
|
||||
required
|
||||
minLength={4}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<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 Spoon secrets are always available as process env.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={materializeEnvFile}
|
||||
onCheckedChange={setMaterializeEnvFile}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Env file path</Label>
|
||||
<Input
|
||||
value={envFilePath}
|
||||
onChange={(event) => setEnvFilePath(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='text-muted-foreground text-sm lg:col-span-2'>
|
||||
Provider:{' '}
|
||||
<span className='text-foreground font-medium'>
|
||||
{defaultProfile
|
||||
? `${defaultProfile.name} · ${defaultProfile.defaultModel}`
|
||||
: 'Configure an AI provider in Settings'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='lg:col-span-2'>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={
|
||||
creating || !spoonId || !prompt.trim() || !defaultProfile
|
||||
}
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create thread'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-5'>
|
||||
<Select
|
||||
value={source}
|
||||
onValueChange={(value) => {
|
||||
window.location.href =
|
||||
value === 'all' ? '/threads' : `/threads?source=${value}`;
|
||||
}}
|
||||
onValueChange={(value) => updateFilter('source', value)}
|
||||
>
|
||||
<SelectTrigger className='w-full md:w-56'>
|
||||
<SelectValue />
|
||||
@@ -73,43 +228,145 @@ const ThreadsPage = () => {
|
||||
<SelectItem value='system'>System</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(value) => updateFilter('status', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All statuses</SelectItem>
|
||||
<SelectItem value='open'>Open</SelectItem>
|
||||
<SelectItem value='queued'>Queued</SelectItem>
|
||||
<SelectItem value='running'>Running</SelectItem>
|
||||
<SelectItem value='waiting_for_user'>Waiting</SelectItem>
|
||||
<SelectItem value='changes_ready'>Changes ready</SelectItem>
|
||||
<SelectItem value='draft_pr_opened'>Draft PR opened</SelectItem>
|
||||
<SelectItem value='resolved'>Resolved</SelectItem>
|
||||
<SelectItem value='ignored'>Ignored</SelectItem>
|
||||
<SelectItem value='failed'>Failed</SelectItem>
|
||||
<SelectItem value='cancelled'>Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={spoonFilter} onValueChange={setSpoonFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All Spoons</SelectItem>
|
||||
{spoons.map((spoon) => (
|
||||
<SelectItem key={spoon._id} value={spoon._id}>
|
||||
{spoon.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All priorities</SelectItem>
|
||||
<SelectItem value='low'>Low</SelectItem>
|
||||
<SelectItem value='normal'>Normal</SelectItem>
|
||||
<SelectItem value='high'>High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={outcomeFilter} onValueChange={setOutcomeFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All outcomes</SelectItem>
|
||||
<SelectItem value='none'>No outcome</SelectItem>
|
||||
<SelectItem value='auto_synced'>Auto synced</SelectItem>
|
||||
<SelectItem value='sync_recommended'>Sync recommended</SelectItem>
|
||||
<SelectItem value='ignored'>Ignored</SelectItem>
|
||||
<SelectItem value='review_pr_recommended'>Review PR</SelectItem>
|
||||
<SelectItem value='manual_review_required'>
|
||||
Manual review
|
||||
</SelectItem>
|
||||
<SelectItem value='conflict_resolution_required'>
|
||||
Conflict
|
||||
</SelectItem>
|
||||
<SelectItem value='failed'>Failed</SelectItem>
|
||||
<SelectItem value='unknown'>Unknown</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
{threads.length ? (
|
||||
threads.map((thread) => (
|
||||
<Link
|
||||
{visibleThreads.length ? (
|
||||
visibleThreads.map((thread) => (
|
||||
<Card
|
||||
key={thread._id}
|
||||
href={`/threads/${thread._id}`}
|
||||
className='block'
|
||||
role='link'
|
||||
tabIndex={0}
|
||||
className='hover:border-primary/50 cursor-pointer shadow-none transition-colors'
|
||||
onClick={() => router.push(`/threads/${thread._id}`)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
router.push(`/threads/${thread._id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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('_', ' ')}
|
||||
<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>
|
||||
{thread.spoonName ? (
|
||||
<Badge variant='outline'>{thread.spoonName}</Badge>
|
||||
) : null}
|
||||
<Badge variant='outline'>
|
||||
{thread.source.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
|
||||
{thread.maintenanceOutcome ? (
|
||||
<Badge variant='secondary'>
|
||||
{thread.maintenanceOutcome.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>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs md:text-right'>
|
||||
<p>{formatTime(thread.updatedAt)}</p>
|
||||
<p className='capitalize'>{thread.priority} priority</p>
|
||||
<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>
|
||||
{thread.latestJobStatus ? (
|
||||
<p>{thread.latestJobStatus.replaceAll('_', ' ')}</p>
|
||||
) : null}
|
||||
<div className='mt-2 flex justify-start gap-2 md:justify-end'>
|
||||
{thread.latestAgentJobId ? (
|
||||
<Button size='sm' variant='outline' asChild>
|
||||
<Link
|
||||
href={`/spoons/${thread.spoonId}/agent/${thread.latestAgentJobId}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
Workspace
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{thread.latestJobPullRequestUrl ? (
|
||||
<Button size='sm' asChild>
|
||||
<a
|
||||
href={thread.latestJobPullRequestUrl}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
PR
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<Card className='shadow-none'>
|
||||
|
||||
Reference in New Issue
Block a user