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>
|
||||
|
||||
@@ -1,30 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Ban, Send } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Ban,
|
||||
FilePenLine,
|
||||
MessagesSquare,
|
||||
Send,
|
||||
Terminal,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Badge, Button, Textarea } from '@spoon/ui';
|
||||
|
||||
import { extractFileDiff } from './diff-utils';
|
||||
|
||||
type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors';
|
||||
|
||||
const filters: { value: ActivityFilter; label: string }[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'chat', label: 'Chat' },
|
||||
{ value: 'activity', label: 'Activity' },
|
||||
{ value: 'files', label: 'Files' },
|
||||
{ value: 'errors', label: 'Errors' },
|
||||
];
|
||||
|
||||
const formatEventTime = (value: number) =>
|
||||
new Date(value).toLocaleTimeString([], {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
|
||||
const eventIcon = (event: Doc<'agentJobEvents'>) => {
|
||||
if (event.level === 'error') return <TriangleAlert className='size-3' />;
|
||||
if (event.phase === 'edit') return <FilePenLine className='size-3' />;
|
||||
if (event.phase === 'check' || event.phase === 'test') {
|
||||
return <Terminal className='size-3' />;
|
||||
}
|
||||
return <MessagesSquare className='size-3' />;
|
||||
};
|
||||
|
||||
export const AgentThread = ({
|
||||
jobId,
|
||||
messages,
|
||||
events,
|
||||
interactions,
|
||||
workspaceChanges,
|
||||
disabled,
|
||||
agentTurnActive,
|
||||
onOpenFile,
|
||||
onOpenDiff,
|
||||
}: {
|
||||
jobId: string;
|
||||
messages: Doc<'agentJobMessages'>[];
|
||||
events: Doc<'agentJobEvents'>[];
|
||||
interactions: Doc<'agentInteractionRequests'>[];
|
||||
workspaceChanges: Doc<'agentWorkspaceChanges'>[];
|
||||
disabled: boolean;
|
||||
agentTurnActive: boolean;
|
||||
onOpenFile: (path: string) => void;
|
||||
onOpenDiff: (path: string) => void;
|
||||
}) => {
|
||||
const [content, setContent] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [replying, setReplying] = useState<string>();
|
||||
const [filter, setFilter] = useState<ActivityFilter>('all');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const failedMessages = useMemo(
|
||||
() => messages.filter((message) => message.status === 'failed'),
|
||||
[messages],
|
||||
);
|
||||
const visibleMessages =
|
||||
filter === 'activity' || filter === 'files' || filter === 'errors'
|
||||
? filter === 'errors'
|
||||
? failedMessages
|
||||
: []
|
||||
: messages;
|
||||
const visibleEvents =
|
||||
filter === 'chat' || filter === 'files'
|
||||
? []
|
||||
: filter === 'errors'
|
||||
? events.filter((event) => event.level === 'error')
|
||||
: events;
|
||||
const visibleChanges =
|
||||
filter === 'chat' || filter === 'activity' || filter === 'errors'
|
||||
? []
|
||||
: workspaceChanges;
|
||||
|
||||
useEffect(() => {
|
||||
const node = scrollRef.current;
|
||||
if (!node) return;
|
||||
const distanceFromBottom =
|
||||
node.scrollHeight - node.scrollTop - node.clientHeight;
|
||||
if (distanceFromBottom < 160 || agentTurnActive) {
|
||||
if (typeof node.scrollTo === 'function') {
|
||||
node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' });
|
||||
} else {
|
||||
node.scrollTop = node.scrollHeight;
|
||||
}
|
||||
}
|
||||
}, [
|
||||
agentTurnActive,
|
||||
events.length,
|
||||
interactions.length,
|
||||
messages.length,
|
||||
workspaceChanges.length,
|
||||
]);
|
||||
|
||||
const send = async () => {
|
||||
if (!content.trim()) return;
|
||||
@@ -84,10 +167,15 @@ export const AgentThread = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[520px] flex-col'>
|
||||
<div className='border-border flex items-start justify-between gap-3 border-b p-3'>
|
||||
<div className='flex h-full min-h-0 flex-col overflow-hidden'>
|
||||
<div className='border-border flex flex-none items-start justify-between gap-3 border-b p-3'>
|
||||
<div>
|
||||
<h2 className='text-sm font-semibold'>Agent thread</h2>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h2 className='text-sm font-semibold'>Agent thread</h2>
|
||||
{agentTurnActive ? (
|
||||
<Badge variant='secondary'>Working</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Messages, tool activity, and requests persist with this workspace.
|
||||
</p>
|
||||
@@ -103,43 +191,64 @@ export const AgentThread = ({
|
||||
Abort
|
||||
</Button>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 space-y-3 overflow-auto p-3'>
|
||||
{interactions.map((interaction) => (
|
||||
<article
|
||||
key={interaction._id}
|
||||
className='border-primary/40 bg-primary/5 rounded-md border p-3 text-sm'
|
||||
<div className='border-border flex flex-none gap-1 overflow-x-auto border-b px-3 py-2'>
|
||||
{filters.map((item) => (
|
||||
<Button
|
||||
key={item.value}
|
||||
type='button'
|
||||
variant={filter === item.value ? 'secondary' : 'ghost'}
|
||||
size='sm'
|
||||
className='h-7 flex-none text-xs'
|
||||
onClick={() => setFilter(item.value)}
|
||||
>
|
||||
<div className='mb-2 flex items-center justify-between gap-2'>
|
||||
<span className='font-medium'>{interaction.title}</span>
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
{interaction.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className='text-sm whitespace-pre-wrap'>{interaction.body}</p>
|
||||
{interaction.status === 'pending' ? (
|
||||
<div className='mt-3 flex gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
disabled={replying === interaction._id}
|
||||
onClick={() => void reply(interaction, 'once')}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='outline'
|
||||
disabled={replying === interaction._id}
|
||||
onClick={() => void reply(interaction, 'reject')}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
{messages.map((message) => (
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className='min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain p-3'
|
||||
>
|
||||
{(filter === 'all' || filter === 'chat') && interactions.length > 0
|
||||
? interactions.map((interaction) => (
|
||||
<article
|
||||
key={interaction._id}
|
||||
className='border-primary/40 bg-primary/5 rounded-md border p-3 text-sm'
|
||||
>
|
||||
<div className='mb-2 flex items-center justify-between gap-2'>
|
||||
<span className='font-medium'>{interaction.title}</span>
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
{interaction.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className='text-sm whitespace-pre-wrap'>
|
||||
{interaction.body}
|
||||
</p>
|
||||
{interaction.status === 'pending' ? (
|
||||
<div className='mt-3 flex gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
disabled={replying === interaction._id}
|
||||
onClick={() => void reply(interaction, 'once')}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='outline'
|
||||
disabled={replying === interaction._id}
|
||||
onClick={() => void reply(interaction, 'reject')}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
))
|
||||
: null}
|
||||
{visibleMessages.map((message) => (
|
||||
<article
|
||||
key={message._id}
|
||||
className={
|
||||
@@ -167,27 +276,107 @@ export const AgentThread = ({
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
{events.slice(-20).map((event) => (
|
||||
{visibleChanges.map((change) => (
|
||||
<article
|
||||
key={event._id}
|
||||
className='border-border text-muted-foreground rounded-md border border-dashed p-2 text-xs'
|
||||
key={change._id}
|
||||
className='border-border bg-background rounded-md border p-3 text-sm'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<span className='font-medium capitalize'>
|
||||
{event.phase} / {event.level}
|
||||
</span>
|
||||
<span>{new Date(event.createdAt).toLocaleTimeString()}</span>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='min-w-0'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<FilePenLine className='text-primary size-4 flex-none' />
|
||||
<span className='truncate font-mono text-xs'>
|
||||
{change.path}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-xs capitalize'>
|
||||
{change.source} {change.changeType}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-none items-center gap-2'>
|
||||
{extractFileDiff(change.diff, change.path) ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onOpenDiff(change.path)}
|
||||
>
|
||||
View diff
|
||||
</Button>
|
||||
) : null}
|
||||
{change.path !== '.' ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onOpenFile(change.path)}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className='mt-1 whitespace-pre-wrap'>{event.message}</p>
|
||||
{extractFileDiff(change.diff, change.path) ? (
|
||||
<details className='mt-3'>
|
||||
<summary className='text-muted-foreground cursor-pointer text-xs'>
|
||||
File diff
|
||||
</summary>
|
||||
<pre className='bg-muted mt-2 max-h-72 overflow-auto rounded p-2 text-xs whitespace-pre-wrap'>
|
||||
{extractFileDiff(change.diff, change.path)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
{visibleEvents.slice(-80).map((event) => (
|
||||
<article
|
||||
key={event._id}
|
||||
className={
|
||||
event.level === 'error'
|
||||
? 'border-destructive/40 bg-destructive/5 rounded-md border p-2 text-xs'
|
||||
: 'border-border text-muted-foreground rounded-md border border-dashed p-2 text-xs'
|
||||
}
|
||||
>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<span className='flex min-w-0 items-center gap-1 font-medium capitalize'>
|
||||
{eventIcon(event)}
|
||||
{event.phase} / {event.level}
|
||||
</span>
|
||||
<span>{formatEventTime(event.createdAt)}</span>
|
||||
</div>
|
||||
<p className='mt-1 whitespace-pre-wrap'>{event.message}</p>
|
||||
{event.metadata ? (
|
||||
<details className='mt-2'>
|
||||
<summary className='cursor-pointer'>Details</summary>
|
||||
<pre className='bg-muted mt-1 max-h-40 overflow-auto rounded p-2 whitespace-pre-wrap'>
|
||||
{event.metadata}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
{visibleMessages.length === 0 &&
|
||||
visibleEvents.length === 0 &&
|
||||
visibleChanges.length === 0 &&
|
||||
(filter !== 'chat' || interactions.length === 0) ? (
|
||||
<p className='text-muted-foreground p-3 text-sm'>
|
||||
No {filter === 'all' ? 'agent activity' : filter} has been recorded
|
||||
yet.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='border-border space-y-2 border-t p-3'>
|
||||
<div className='border-border flex-none space-y-2 border-t p-3'>
|
||||
<Textarea
|
||||
value={content}
|
||||
placeholder='Ask the agent to inspect, explain, or change this fork.'
|
||||
disabled={disabled || sending}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { FileCode, GitCompare, MessagesSquare } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
@@ -33,6 +35,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
|
||||
const events =
|
||||
useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
|
||||
const workspaceChanges =
|
||||
useQuery(api.agentJobs.listWorkspaceChanges, { jobId, limit: 200 }) ?? [];
|
||||
const interactions =
|
||||
useQuery(api.agentJobs.listInteractionRequests, {
|
||||
jobId,
|
||||
@@ -50,11 +54,16 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
const [expandedDirectoryPaths, setExpandedDirectoryPaths] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [agentThreadWidth, setAgentThreadWidth] = useState(420);
|
||||
const [vimEnabled, setVimEnabled] = useState(false);
|
||||
const [hydratedUiState, setHydratedUiState] = useState(false);
|
||||
const [diff, setDiff] = useState('');
|
||||
const [focusedDiffPath, setFocusedDiffPath] = useState<string>();
|
||||
const [workspaceError, setWorkspaceError] = useState<string>();
|
||||
const [agentTurnActive, setAgentTurnActive] = useState(false);
|
||||
const [activeWorkspaceTab, setActiveWorkspaceTab] = useState<
|
||||
'editor' | 'diff' | 'thread'
|
||||
>('editor');
|
||||
|
||||
const workspaceDisabled =
|
||||
!job ||
|
||||
@@ -177,6 +186,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
setOpenFilePaths(uiState.openFilePaths);
|
||||
setActiveFilePath(uiState.activeFilePath);
|
||||
setExpandedDirectoryPaths(uiState.expandedDirectoryPaths);
|
||||
setAgentThreadWidth(uiState.agentThreadWidth ?? 420);
|
||||
setVimEnabled(uiState.vimEnabled);
|
||||
setHydratedUiState(true);
|
||||
}, 0);
|
||||
@@ -192,6 +202,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
activeFilePath,
|
||||
vimEnabled,
|
||||
expandedDirectoryPaths,
|
||||
agentThreadWidth,
|
||||
}).catch((error: unknown) => {
|
||||
console.error(error);
|
||||
});
|
||||
@@ -200,6 +211,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
}, [
|
||||
activeFilePath,
|
||||
expandedDirectoryPaths,
|
||||
agentThreadWidth,
|
||||
hydratedUiState,
|
||||
jobId,
|
||||
openFilePaths,
|
||||
@@ -230,11 +242,11 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
|
||||
const recoverWorkspace = async () => {
|
||||
if (!job.threadId) return;
|
||||
const newJobId = await createJobForThread({
|
||||
await createJobForThread({
|
||||
threadId: job.threadId,
|
||||
jobType: job.jobType ?? 'user_change',
|
||||
});
|
||||
window.location.href = `/spoons/${job.spoonId}/agent/${newJobId}`;
|
||||
window.location.href = `/threads/${job.threadId}`;
|
||||
};
|
||||
|
||||
const deleteStaleWorkspace = async () => {
|
||||
@@ -248,6 +260,33 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
|
||||
const saveFile = async (content: string) => {
|
||||
if (!activeFilePath) return;
|
||||
const activeFileBeforeSave = files[activeFilePath];
|
||||
if (activeFileBeforeSave) {
|
||||
const latestResponse = await fetch(
|
||||
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(activeFilePath)}`,
|
||||
);
|
||||
if (latestResponse.ok) {
|
||||
const latestData = (await latestResponse.json()) as FileResponse;
|
||||
if (latestData.content !== activeFileBeforeSave.savedContent) {
|
||||
const overwrite = window.confirm(
|
||||
`${activeFilePath} changed in the workspace after you opened it. Overwrite those newer changes with your editor contents?`,
|
||||
);
|
||||
if (!overwrite) {
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[activeFilePath]: {
|
||||
...activeFileBeforeSave,
|
||||
content: latestData.content,
|
||||
savedContent: latestData.content,
|
||||
saving: false,
|
||||
},
|
||||
}));
|
||||
toast.info('File reloaded with latest workspace contents.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[activeFilePath]: {
|
||||
@@ -325,20 +364,54 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const openFileFromActivity = (path: string) => {
|
||||
openFile(path);
|
||||
setActiveWorkspaceTab('editor');
|
||||
};
|
||||
|
||||
const openDiffFromActivity = (path: string) => {
|
||||
setFocusedDiffPath(path);
|
||||
setActiveWorkspaceTab('diff');
|
||||
};
|
||||
|
||||
const resizeAgentThread = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
const startWidth = agentThreadWidth;
|
||||
const move = (moveEvent: PointerEvent) => {
|
||||
const nextWidth = Math.min(
|
||||
Math.max(startWidth - (moveEvent.clientX - startX), 320),
|
||||
720,
|
||||
);
|
||||
setAgentThreadWidth(Math.round(nextWidth));
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', up);
|
||||
};
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
|
||||
<JobStatusBar job={job} />
|
||||
{workspaceError ? (
|
||||
<div className='border-border bg-background border-b p-4'>
|
||||
<div className='border-destructive/40 bg-destructive/5 rounded-md border p-4'>
|
||||
<p className='font-medium'>Workspace not active on this worker</p>
|
||||
<p className='font-medium'>Thread workspace needs recovery</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
The saved workspace record exists, but this worker cannot reach
|
||||
its active runtime. This usually happens after a worker restart or
|
||||
local container cleanup.
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-2 text-xs break-all'>
|
||||
{workspaceError}
|
||||
</p>
|
||||
<div className='mt-3 flex flex-wrap gap-2'>
|
||||
{job.threadId ? (
|
||||
<Button type='button' onClick={() => void recoverWorkspace()}>
|
||||
Recreate workspace run
|
||||
Start a fresh run
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
@@ -346,7 +419,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
variant='outline'
|
||||
onClick={() => void deleteStaleWorkspace()}
|
||||
>
|
||||
Delete stale workspace
|
||||
Delete stale record
|
||||
</Button>
|
||||
{job.threadId ? (
|
||||
<Button type='button' variant='outline' asChild>
|
||||
@@ -360,7 +433,14 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
|
||||
<WorkspaceActions job={job} disabled={workspaceDisabled} />
|
||||
</div>
|
||||
<div className='grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] 2xl:grid-cols-[300px_minmax(0,1fr)_420px]'>
|
||||
<div
|
||||
className='grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] 2xl:grid-cols-[300px_minmax(0,1fr)_6px_var(--agent-thread-width)]'
|
||||
style={
|
||||
{
|
||||
'--agent-thread-width': `${agentThreadWidth}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<aside className='border-border bg-background min-h-0 border-r'>
|
||||
<div className='border-border border-b p-3'>
|
||||
<h2 className='text-sm font-semibold'>Files</h2>
|
||||
@@ -374,15 +454,34 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
onToggleDirectory={toggleDirectory}
|
||||
/>
|
||||
</aside>
|
||||
<section className='bg-background flex min-w-0 flex-col'>
|
||||
<Tabs defaultValue='editor' className='flex min-h-0 flex-1 flex-col'>
|
||||
<TabsList
|
||||
variant='line'
|
||||
className='border-border h-11 flex-none justify-start rounded-none border-b px-3'
|
||||
>
|
||||
<TabsTrigger value='editor'>Editor</TabsTrigger>
|
||||
<TabsTrigger value='diff'>Diff</TabsTrigger>
|
||||
<TabsTrigger value='thread' className='2xl:hidden'>
|
||||
<section className='bg-background flex min-w-0 flex-col overflow-hidden'>
|
||||
<Tabs
|
||||
value={activeWorkspaceTab}
|
||||
onValueChange={(value) =>
|
||||
setActiveWorkspaceTab(value as 'editor' | 'diff' | 'thread')
|
||||
}
|
||||
className='flex min-h-0 flex-1 flex-col'
|
||||
>
|
||||
<TabsList className='border-border bg-muted/30 h-12 flex-none justify-start rounded-none border-b px-3'>
|
||||
<TabsTrigger
|
||||
value='editor'
|
||||
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
|
||||
>
|
||||
<FileCode className='size-4' />
|
||||
Editor
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='diff'
|
||||
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
|
||||
>
|
||||
<GitCompare className='size-4' />
|
||||
Diff viewer
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='thread'
|
||||
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm 2xl:hidden'
|
||||
>
|
||||
<MessagesSquare className='size-4' />
|
||||
Thread
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -427,32 +526,50 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
|
||||
<DiffViewer diff={diff} onRefresh={loadDiff} />
|
||||
<DiffViewer
|
||||
diff={diff}
|
||||
focusedPath={focusedDiffPath}
|
||||
onRefresh={loadDiff}
|
||||
onClearFocusedPath={() => setFocusedDiffPath(undefined)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value='thread'
|
||||
className='m-0 min-h-0 flex-1 2xl:hidden'
|
||||
className='m-0 min-h-0 flex-1 overflow-hidden 2xl:hidden'
|
||||
>
|
||||
<AgentThread
|
||||
jobId={jobId}
|
||||
messages={messages}
|
||||
events={events}
|
||||
interactions={interactions}
|
||||
workspaceChanges={workspaceChanges}
|
||||
disabled={workspaceDisabled}
|
||||
agentTurnActive={agentTurnActive}
|
||||
onOpenFile={openFileFromActivity}
|
||||
onOpenDiff={openDiffFromActivity}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<CommandPanel jobId={jobId} disabled={workspaceDisabled} />
|
||||
</section>
|
||||
<aside className='border-border bg-muted/20 hidden min-w-0 border-l 2xl:block'>
|
||||
<div
|
||||
role='separator'
|
||||
aria-label='Resize agent thread'
|
||||
aria-orientation='vertical'
|
||||
className='bg-border hover:bg-primary/50 hidden cursor-col-resize transition-colors 2xl:block'
|
||||
onPointerDown={resizeAgentThread}
|
||||
/>
|
||||
<aside className='border-border bg-muted/20 hidden min-h-0 min-w-0 overflow-hidden border-l 2xl:block'>
|
||||
<AgentThread
|
||||
jobId={jobId}
|
||||
messages={messages}
|
||||
events={events}
|
||||
interactions={interactions}
|
||||
workspaceChanges={workspaceChanges}
|
||||
disabled={workspaceDisabled}
|
||||
agentTurnActive={agentTurnActive}
|
||||
onOpenFile={openFileFromActivity}
|
||||
onOpenDiff={openDiffFromActivity}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -83,8 +83,11 @@ export const CodeEditor = ({
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-0 flex-col'>
|
||||
<div className='border-border flex h-11 items-center justify-between gap-3 border-b px-3'>
|
||||
<div className='border-border flex h-14 items-center justify-between gap-3 border-b px-3'>
|
||||
<div className='min-w-0'>
|
||||
<p className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase'>
|
||||
Editor
|
||||
</p>
|
||||
<p className='truncate font-mono text-xs'>{path}</p>
|
||||
{dirty ? (
|
||||
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export const extractFileDiff = (diff: string | undefined, filePath: string) => {
|
||||
if (!diff?.trim() || filePath === '.') return '';
|
||||
const lines = diff.split('\n');
|
||||
const sections: string[][] = [];
|
||||
let current: string[] | null = null;
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('diff --git ')) {
|
||||
if (current) sections.push(current);
|
||||
current = [line];
|
||||
continue;
|
||||
}
|
||||
current?.push(line);
|
||||
}
|
||||
if (current) sections.push(current);
|
||||
const normalizedPath = filePath.replace(/^\.\/+/, '');
|
||||
const section = sections.find((item) => {
|
||||
const header = item[0] ?? '';
|
||||
return (
|
||||
header.includes(` a/${normalizedPath} `) ||
|
||||
header.endsWith(` a/${normalizedPath}`) ||
|
||||
header.includes(` b/${normalizedPath}`) ||
|
||||
header.endsWith(` b/${normalizedPath}`)
|
||||
);
|
||||
});
|
||||
return section?.join('\n') ?? '';
|
||||
};
|
||||
@@ -4,6 +4,8 @@ import dynamic from 'next/dynamic';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
import { extractFileDiff } from './diff-utils';
|
||||
|
||||
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||
ssr: false,
|
||||
});
|
||||
@@ -22,34 +24,56 @@ const diffStats = (diff: string) => {
|
||||
|
||||
export const DiffViewer = ({
|
||||
diff,
|
||||
focusedPath,
|
||||
onRefresh,
|
||||
onClearFocusedPath,
|
||||
}: {
|
||||
diff: string;
|
||||
focusedPath?: string;
|
||||
onRefresh: () => Promise<void>;
|
||||
onClearFocusedPath?: () => void;
|
||||
}) => {
|
||||
const stats = diffStats(diff);
|
||||
const focusedDiff = focusedPath ? extractFileDiff(diff, focusedPath) : '';
|
||||
const visibleDiff = focusedPath ? focusedDiff : diff;
|
||||
const stats = diffStats(visibleDiff);
|
||||
return (
|
||||
<div className='flex h-full min-h-0 flex-col'>
|
||||
<div className='border-border flex h-12 items-center justify-between gap-3 border-b px-3'>
|
||||
<div className='min-w-0'>
|
||||
<p className='text-sm font-medium'>Workspace diff</p>
|
||||
<p className='truncate text-sm font-medium'>
|
||||
{focusedPath ? `Diff viewer: ${focusedPath}` : 'Diff viewer'}
|
||||
</p>
|
||||
<p className='text-muted-foreground truncate text-xs'>
|
||||
{diff.trim()
|
||||
{visibleDiff.trim()
|
||||
? `${stats.files} files, +${stats.additions} -${stats.removals}`
|
||||
: 'Current git diff'}
|
||||
: focusedPath
|
||||
? 'No diff for this file'
|
||||
: 'Current git diff'}
|
||||
</p>
|
||||
</div>
|
||||
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
<div className='flex flex-none items-center gap-2'>
|
||||
{focusedPath ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={onClearFocusedPath}
|
||||
>
|
||||
Show all
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{diff.trim() ? (
|
||||
{visibleDiff.trim() ? (
|
||||
<MonacoEditor
|
||||
height='100%'
|
||||
width='100%'
|
||||
language='diff'
|
||||
theme='vs-dark'
|
||||
value={diff}
|
||||
value={visibleDiff}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
@@ -61,7 +85,9 @@ export const DiffViewer = ({
|
||||
/>
|
||||
) : (
|
||||
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
|
||||
No workspace diff yet.
|
||||
{focusedPath
|
||||
? 'No diff is recorded for this file yet.'
|
||||
: 'No workspace diff yet.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ export const WorkspaceActions = ({
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
|
||||
const deleteThread = useMutation(api.threads.deleteThread);
|
||||
const canDelete =
|
||||
['failed', 'cancelled', 'timed_out'].includes(job.status) ||
|
||||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
||||
@@ -58,6 +59,27 @@ export const WorkspaceActions = ({
|
||||
}
|
||||
};
|
||||
|
||||
const removeThread = async () => {
|
||||
if (!job.threadId) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
'Delete this thread and any terminal workspace records attached to it? This cannot be undone.',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteThread({ threadId: job.threadId });
|
||||
toast.success('Thread deleted.');
|
||||
router.push('/threads');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Could not delete thread.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
|
||||
@@ -96,10 +118,23 @@ export const WorkspaceActions = ({
|
||||
Stop
|
||||
</Button>
|
||||
{canDelete ? (
|
||||
<Button type='button' variant='destructive' size='sm' onClick={remove}>
|
||||
<Trash2 className='size-4' />
|
||||
Delete workspace
|
||||
</Button>
|
||||
<>
|
||||
{job.threadId ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={removeThread}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
Delete thread
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type='button' variant='outline' size='sm' onClick={remove}>
|
||||
<Trash2 className='size-4' />
|
||||
Delete workspace
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
export const AgentArtifactViewer = ({
|
||||
artifacts,
|
||||
}: {
|
||||
artifacts: Doc<'agentJobArtifacts'>[];
|
||||
}) => {
|
||||
if (!artifacts.length) {
|
||||
return (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
No artifacts captured yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{artifacts.map((artifact) => (
|
||||
<section key={artifact._id} className='border-border rounded-md border'>
|
||||
<div className='flex items-center justify-between gap-3 border-b p-3'>
|
||||
<div>
|
||||
<h3 className='text-sm font-semibold'>{artifact.title}</h3>
|
||||
<p className='text-muted-foreground text-xs'>{artifact.kind}</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='icon'
|
||||
aria-label='Copy artifact'
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(artifact.content);
|
||||
toast.success('Artifact copied.');
|
||||
}}
|
||||
>
|
||||
<Copy className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className='bg-muted/40 max-h-96 overflow-auto p-3 text-xs whitespace-pre-wrap'>
|
||||
{artifact.content}
|
||||
</pre>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
|
||||
const formatTime = (value: number) =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(value);
|
||||
|
||||
export const AgentEventLog = ({
|
||||
events,
|
||||
}: {
|
||||
events: Doc<'agentJobEvents'>[];
|
||||
}) => {
|
||||
if (!events.length) {
|
||||
return (
|
||||
<p className='text-muted-foreground text-sm'>No worker events yet.</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='divide-border overflow-hidden rounded-md border'>
|
||||
{events.map((event) => (
|
||||
<div key={event._id} className='grid gap-1 border-b p-3 text-sm'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='font-mono text-xs uppercase'>{event.phase}</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{formatTime(event.createdAt)}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-xs capitalize'>
|
||||
{event.level}
|
||||
</span>
|
||||
</div>
|
||||
<p className='whitespace-pre-wrap'>{event.message}</p>
|
||||
{event.metadata ? (
|
||||
<pre className='bg-muted overflow-auto rounded p-2 text-xs'>
|
||||
{event.metadata}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from 'convex/react';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
|
||||
import { AgentArtifactViewer } from './agent-artifact-viewer';
|
||||
import { AgentEventLog } from './agent-event-log';
|
||||
|
||||
export const AgentJobDetail = ({ job }: { job: Doc<'agentJobs'> }) => {
|
||||
const events =
|
||||
useQuery(api.agentJobs.listEvents, { jobId: job._id, limit: 200 }) ?? [];
|
||||
const artifacts =
|
||||
useQuery(api.agentJobs.listArtifacts, { jobId: job._id }) ?? [];
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Job details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-5'>
|
||||
<div className='grid gap-3 text-sm md:grid-cols-3'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>Status</p>
|
||||
<p className='font-medium capitalize'>
|
||||
{job.status.replaceAll('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>Branch</p>
|
||||
<p className='font-mono text-xs'>{job.workBranch}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>Model</p>
|
||||
<p className='font-medium'>{job.model}</p>
|
||||
</div>
|
||||
</div>
|
||||
{job.pullRequestUrl ? (
|
||||
<a
|
||||
href={job.pullRequestUrl}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='text-primary text-sm font-medium underline-offset-4 hover:underline'
|
||||
>
|
||||
Open draft PR #{job.pullRequestNumber}
|
||||
</a>
|
||||
) : null}
|
||||
{job.error ? (
|
||||
<pre className='border-destructive bg-destructive/5 text-destructive overflow-auto rounded-md border p-3 text-xs whitespace-pre-wrap'>
|
||||
{job.error}
|
||||
</pre>
|
||||
) : null}
|
||||
<section className='space-y-2'>
|
||||
<h3 className='text-sm font-semibold'>Events</h3>
|
||||
<AgentEventLog events={events} />
|
||||
</section>
|
||||
<section className='space-y-2'>
|
||||
<h3 className='text-sm font-semibold'>Artifacts</h3>
|
||||
<AgentArtifactViewer artifacts={artifacts} />
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,151 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useMutation } from 'convex/react';
|
||||
import { ExternalLink, MonitorUp, Trash2, XCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Badge, Button } from '@spoon/ui';
|
||||
|
||||
import { AgentJobDetail } from './agent-job-detail';
|
||||
|
||||
const formatTime = (value: number) =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(value);
|
||||
|
||||
export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
|
||||
const cancel = useMutation(api.agentJobs.cancel);
|
||||
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
|
||||
const [selectedJobId, setSelectedJobId] = useState<string | null>(
|
||||
jobs[0]?._id ?? null,
|
||||
);
|
||||
const selectedJob = jobs.find((job) => job._id === selectedJobId) ?? jobs[0];
|
||||
const selectedJobCanDelete = selectedJob
|
||||
? ['failed', 'cancelled', 'timed_out'].includes(selectedJob.status) ||
|
||||
['stopped', 'expired', 'failed'].includes(
|
||||
selectedJob.workspaceStatus ?? '',
|
||||
)
|
||||
: false;
|
||||
|
||||
if (!jobs.length) {
|
||||
return (
|
||||
<div className='border-border rounded-md border p-5'>
|
||||
<h3 className='text-sm font-semibold'>No agent jobs yet</h3>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Queue a job to have Spoon open a draft PR against this fork.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 xl:grid-cols-[0.85fr_1.15fr]'>
|
||||
<div className='divide-border overflow-hidden rounded-md border'>
|
||||
{jobs.map((job) => (
|
||||
<button
|
||||
key={job._id}
|
||||
type='button'
|
||||
className='hover:bg-muted/40 data-[selected=true]:bg-muted/60 block w-full border-b p-3 text-left'
|
||||
data-selected={job._id === selectedJob?._id}
|
||||
onClick={() => setSelectedJobId(job._id)}
|
||||
>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='min-w-0'>
|
||||
<p className='truncate text-sm font-medium'>{job.prompt}</p>
|
||||
<p className='text-muted-foreground mt-1 font-mono text-xs'>
|
||||
{job.workBranch}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
{job.status.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-2 flex flex-wrap gap-2 text-xs'>
|
||||
<span>{formatTime(job.createdAt)}</span>
|
||||
{job.pullRequestUrl ? (
|
||||
<a
|
||||
href={job.pullRequestUrl}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='text-primary inline-flex items-center gap-1'
|
||||
>
|
||||
PR <ExternalLink className='size-3' />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedJob ? (
|
||||
<div className='space-y-3'>
|
||||
{[
|
||||
'queued',
|
||||
'claimed',
|
||||
'preparing',
|
||||
'running',
|
||||
'checks_running',
|
||||
].includes(selectedJob.status) ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={async () => {
|
||||
try {
|
||||
await cancel({ jobId: selectedJob._id });
|
||||
toast.success('Agent job cancelled.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not cancel job.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XCircle className='size-4' />
|
||||
Cancel job
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/spoons/${selectedJob.spoonId}/agent/${selectedJob._id}`}
|
||||
>
|
||||
<MonitorUp className='size-4' />
|
||||
Open workspace
|
||||
</Link>
|
||||
</Button>
|
||||
{selectedJobCanDelete ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
onClick={async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
'Delete this workspace and its messages, events, artifacts, diffs, and UI state? This cannot be undone.',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteWorkspace({ jobId: selectedJob._id });
|
||||
toast.success('Workspace deleted.');
|
||||
setSelectedJobId(null);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not delete workspace.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
Delete workspace
|
||||
</Button>
|
||||
) : null}
|
||||
<AgentJobDetail job={selectedJob} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,9 @@ import { usePathname } from 'next/navigation';
|
||||
|
||||
export const AppShell = ({ children }: { children: ReactNode }) => {
|
||||
const pathname = usePathname();
|
||||
const isWorkspace = /\/spoons\/[^/]+\/agent\/[^/]+/.test(pathname);
|
||||
const isWorkspace =
|
||||
/\/spoons\/[^/]+\/agent\/[^/]+/.test(pathname) ||
|
||||
/^\/threads\/[^/]+/.test(pathname);
|
||||
|
||||
return (
|
||||
<div className='bg-muted/20 flex-1 border-t'>
|
||||
|
||||
@@ -176,7 +176,7 @@ export const SpoonAgentSettingsForm = ({
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<Label htmlFor='agentEnabled'>Enable agent jobs</Label>
|
||||
<Label htmlFor='agentEnabled'>Enable thread workspaces</Label>
|
||||
<Switch
|
||||
id='agentEnabled'
|
||||
checked={enabled}
|
||||
|
||||
+11
-8
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { MessageSquarePlus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
@@ -35,13 +36,14 @@ type AgentSettings = {
|
||||
aiProviderProfileId?: Id<'aiProviderProfiles'>;
|
||||
};
|
||||
|
||||
export const AgentRequestForm = ({
|
||||
export const ThreadWorkspaceForm = ({
|
||||
spoon,
|
||||
agentSettings,
|
||||
}: {
|
||||
spoon: Doc<'spoons'>;
|
||||
agentSettings?: AgentSettings | null;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const secrets =
|
||||
useQuery(api.spoonSecrets.listForSpoon, {
|
||||
spoonId: spoon._id,
|
||||
@@ -90,7 +92,7 @@ export const AgentRequestForm = ({
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createThread({
|
||||
const threadId = await createThread({
|
||||
spoonId: spoon._id,
|
||||
prompt,
|
||||
baseBranch,
|
||||
@@ -105,9 +107,10 @@ export const AgentRequestForm = ({
|
||||
setPrompt('');
|
||||
setRequestedBranchName('');
|
||||
toast.success('Thread created.');
|
||||
router.push(`/threads/${threadId}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not queue agent job.');
|
||||
toast.error('Could not create thread workspace.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -117,16 +120,16 @@ export const AgentRequestForm = ({
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='flex items-center gap-2 text-base'>
|
||||
<Bot className='size-4' />
|
||||
Request agent work
|
||||
<MessageSquarePlus className='size-4' />
|
||||
Create thread workspace
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={submit} className='space-y-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='agentPrompt'>Prompt</Label>
|
||||
<Label htmlFor='threadPrompt'>Prompt</Label>
|
||||
<Textarea
|
||||
id='agentPrompt'
|
||||
id='threadPrompt'
|
||||
required
|
||||
minLength={12}
|
||||
value={prompt}
|
||||
Reference in New Issue
Block a user