Clean up old stuff & fix ui errors
This commit is contained in:
@@ -1,15 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
const AgentWorkspacePage = () => {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ spoonId: string; jobId: string }>();
|
||||
const jobId = params.jobId as Id<'agentJobs'>;
|
||||
const job = useQuery(api.agentJobs.get, { jobId });
|
||||
|
||||
useEffect(() => {
|
||||
if (job?.threadId) router.replace(`/threads/${job.threadId}`);
|
||||
}, [job?.threadId, router]);
|
||||
|
||||
if (job?.threadId) {
|
||||
return (
|
||||
<main className='text-muted-foreground p-6'>
|
||||
Opening thread workspace...
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className='space-y-4'>
|
||||
@@ -19,7 +37,7 @@ const AgentWorkspacePage = () => {
|
||||
Back to Spoon
|
||||
</Link>
|
||||
</Button>
|
||||
<AgentWorkspaceShell jobId={params.jobId as Id<'agentJobs'>} />
|
||||
<AgentWorkspaceShell jobId={jobId} />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
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 { SpoonClonePanel } from '@/components/spoons/spoon-clone-panel';
|
||||
@@ -13,6 +11,7 @@ import { SpoonMetrics } from '@/components/spoons/spoon-metrics';
|
||||
import { SpoonPrList } from '@/components/spoons/spoon-pr-list';
|
||||
import { SpoonSecretsForm } from '@/components/spoons/spoon-secrets-form';
|
||||
import { SpoonSettingsForm } from '@/components/spoons/spoon-settings-form';
|
||||
import { ThreadWorkspaceForm } from '@/components/threads/thread-workspace-form';
|
||||
import { useQuery } from 'convex/react';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
@@ -243,7 +242,7 @@ const SpoonDetailPage = () => {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='threads' className='space-y-4'>
|
||||
<AgentRequestForm
|
||||
<ThreadWorkspaceForm
|
||||
spoon={details.spoon}
|
||||
agentSettings={agentSettings}
|
||||
/>
|
||||
@@ -273,7 +272,6 @@ const SpoonDetailPage = () => {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AgentJobList jobs={agentJobs} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='activity'>
|
||||
|
||||
@@ -2,9 +2,16 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { ArrowUpRight, CheckCircle2, Play, XCircle } from 'lucide-react';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CheckCircle2,
|
||||
Play,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
@@ -16,41 +23,45 @@ import {
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Textarea,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const ThreadDetailPage = () => {
|
||||
const router = useRouter();
|
||||
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 createJob = useMutation(api.agentJobs.createForThread);
|
||||
const markResolved = useMutation(api.threads.markResolved);
|
||||
const cancel = useMutation(api.threads.cancel);
|
||||
const [sending, setSending] = useState(false);
|
||||
const deleteThread = useMutation(api.threads.deleteThread);
|
||||
const [queueing, setQueueing] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
if (details === undefined) {
|
||||
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
|
||||
}
|
||||
|
||||
const { thread, spoon, latestJob } = details;
|
||||
if (latestJob && spoon) {
|
||||
return (
|
||||
<main className='space-y-4'>
|
||||
<Button asChild variant='ghost' size='sm'>
|
||||
<Link href={`/spoons/${spoon._id}`}>
|
||||
<ArrowUpRight className='size-4 rotate-180' />
|
||||
Back to Spoon
|
||||
</Link>
|
||||
</Button>
|
||||
<AgentWorkspaceShell jobId={latestJob._id} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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 ||
|
||||
@@ -67,40 +78,12 @@ const ThreadDetailPage = () => {
|
||||
? ('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 {
|
||||
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(activeJob ? 'Message sent to agent.' : 'Message added.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not send message.');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startRun = async () => {
|
||||
setQueueing(true);
|
||||
try {
|
||||
const jobId = await createJob({ threadId, jobType });
|
||||
await createJob({ threadId, jobType });
|
||||
toast.success('Workspace run queued.');
|
||||
window.location.href = `/spoons/${spoon?._id}/agent/${jobId}`;
|
||||
router.replace(`/threads/${threadId}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not queue workspace run.');
|
||||
@@ -109,6 +92,29 @@ const ThreadDetailPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const removeThread = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
'Delete this thread and any terminal workspace records attached to it? This cannot be undone.',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteThread({ threadId });
|
||||
toast.success('Thread deleted.');
|
||||
router.push('/threads');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Could not delete thread.',
|
||||
);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-start'>
|
||||
@@ -142,11 +148,7 @@ const ThreadDetailPage = () => {
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{latestJob ? (
|
||||
<Button variant='outline' asChild>
|
||||
<Link
|
||||
href={`/spoons/${latestJob.spoonId}/agent/${latestJob._id}`}
|
||||
>
|
||||
Open workspace
|
||||
</Link>
|
||||
<Link href={`/threads/${threadId}`}>Open workspace</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{latestJob?.pullRequestUrl ? (
|
||||
@@ -194,57 +196,38 @@ const ThreadDetailPage = () => {
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
variant='destructive'
|
||||
disabled={deleting}
|
||||
onClick={() => void removeThread()}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[1fr_320px]'>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>Conversation</CardTitle>
|
||||
<CardTitle>Workspace</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={
|
||||
activeJob
|
||||
? 'Send instructions to the active agent workspace.'
|
||||
: 'Add context or instructions for the next run.'
|
||||
}
|
||||
disabled={sending || terminalThread}
|
||||
/>
|
||||
<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 className='space-y-4 text-sm'>
|
||||
<p className='text-muted-foreground'>
|
||||
Threads open into a full workspace where you can review agent
|
||||
activity, edit files, inspect diffs, and reply to the agent.
|
||||
</p>
|
||||
{canQueueRun ? (
|
||||
<Button disabled={queueing} onClick={() => void startRun()}>
|
||||
<Play className='size-4' />
|
||||
{latestJob ? 'Create new workspace run' : 'Start workspace run'}
|
||||
</Button>
|
||||
) : (
|
||||
<p className='text-muted-foreground'>
|
||||
This thread does not currently have a workspace that can be
|
||||
opened.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { MessageSquare, Plus } from 'lucide-react';
|
||||
import { MessageSquare, Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
@@ -43,7 +43,9 @@ const ThreadsPage = () => {
|
||||
const [materializeEnvFile, setMaterializeEnvFile] = useState(false);
|
||||
const [envFilePath, setEnvFilePath] = useState('.env.local');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deletingThreadId, setDeletingThreadId] = useState<string>();
|
||||
const createThread = useMutation(api.threads.createUserThread);
|
||||
const deleteThread = useMutation(api.threads.deleteThread);
|
||||
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||
const defaultProfile = profiles.find((profile) => profile.isDefault);
|
||||
@@ -91,6 +93,20 @@ const ThreadsPage = () => {
|
||||
router.push(next.size ? `/threads?${next.toString()}` : '/threads');
|
||||
};
|
||||
|
||||
const threadTarget = (thread: (typeof visibleThreads)[number]) =>
|
||||
`/threads/${thread._id}`;
|
||||
const canDeleteThread = (thread: (typeof visibleThreads)[number]) => {
|
||||
const latestJobStatus = thread.latestJobStatus;
|
||||
const latestWorkspaceStatus = thread.latestJobWorkspaceStatus;
|
||||
if (!latestJobStatus && !latestWorkspaceStatus) return true;
|
||||
return (
|
||||
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
|
||||
latestJobStatus ?? '',
|
||||
) ||
|
||||
['stopped', 'expired', 'failed'].includes(latestWorkspaceStatus ?? '')
|
||||
);
|
||||
};
|
||||
|
||||
const submitThread = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!spoonId || !prompt.trim()) return;
|
||||
@@ -113,6 +129,32 @@ const ThreadsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const removeThread = async (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
threadId: Id<'threads'>,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
if (
|
||||
!window.confirm(
|
||||
'Delete this thread and any terminal workspace records attached to it? This cannot be undone.',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setDeletingThreadId(threadId);
|
||||
try {
|
||||
await deleteThread({ threadId });
|
||||
toast.success('Thread deleted.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Could not delete thread.',
|
||||
);
|
||||
} finally {
|
||||
setDeletingThreadId(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
|
||||
@@ -304,11 +346,11 @@ const ThreadsPage = () => {
|
||||
role='link'
|
||||
tabIndex={0}
|
||||
className='hover:border-primary/50 cursor-pointer shadow-none transition-colors'
|
||||
onClick={() => router.push(`/threads/${thread._id}`)}
|
||||
onClick={() => router.push(threadTarget(thread))}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
router.push(`/threads/${thread._id}`);
|
||||
router.push(threadTarget(thread));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -340,14 +382,20 @@ const ThreadsPage = () => {
|
||||
{thread.latestJobStatus ? (
|
||||
<p>{thread.latestJobStatus.replaceAll('_', ' ')}</p>
|
||||
) : null}
|
||||
{thread.latestJobWorkspaceStatus ? (
|
||||
<p>
|
||||
Workspace:{' '}
|
||||
{thread.latestJobWorkspaceStatus.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}`}
|
||||
href={threadTarget(thread)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
Workspace
|
||||
Open workspace
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -363,6 +411,26 @@ const ThreadsPage = () => {
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
disabled={
|
||||
deletingThreadId === thread._id ||
|
||||
!canDeleteThread(thread)
|
||||
}
|
||||
title={
|
||||
canDeleteThread(thread)
|
||||
? 'Delete thread'
|
||||
: 'Stop or cancel the active workspace before deleting this thread.'
|
||||
}
|
||||
onClick={(event) =>
|
||||
void removeThread(event, thread._id as Id<'threads'>)
|
||||
}
|
||||
>
|
||||
<Trash2 className='size-3' />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user