Files
spoon/apps/next/src/components/agent-workspace/agent-thread.tsx
T
Gabriel Brown d207b8b0b8
Build and Push Spoon Images / quality (push) Successful in 1m41s
Build and Push Spoon Images / build-images (push) Successful in 7m4s
Add features & update project
2026-06-23 02:06:58 -04:00

205 lines
6.5 KiB
TypeScript

'use client';
import { useState } from 'react';
import { Ban, Send } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Button, Textarea } from '@spoon/ui';
export const AgentThread = ({
jobId,
messages,
events,
interactions,
disabled,
agentTurnActive,
}: {
jobId: string;
messages: Doc<'agentJobMessages'>[];
events: Doc<'agentJobEvents'>[];
interactions: Doc<'agentInteractionRequests'>[];
disabled: boolean;
agentTurnActive: boolean;
}) => {
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
const [replying, setReplying] = useState<string>();
const send = async () => {
if (!content.trim()) return;
setSending(true);
try {
const response = await fetch(`/api/agent-jobs/${jobId}/message`, {
method: 'POST',
body: JSON.stringify({ content }),
});
if (!response.ok) throw new Error(await response.text());
setContent('');
} catch (error) {
console.error(error);
toast.error('Could not send message.');
} finally {
setSending(false);
}
};
const abort = async () => {
try {
const response = await fetch(`/api/agent-jobs/${jobId}/agent/abort`, {
method: 'POST',
});
if (!response.ok) throw new Error(await response.text());
toast.success('Agent turn aborted.');
} catch (error) {
console.error(error);
toast.error('Could not abort agent.');
}
};
const reply = async (
interaction: Doc<'agentInteractionRequests'>,
responseValue: string,
) => {
setReplying(interaction._id);
try {
const response = await fetch(
`/api/agent-jobs/${jobId}/interactions/${interaction._id}/reply`,
{
method: 'POST',
body: JSON.stringify({
externalRequestId: interaction.externalRequestId,
response: responseValue,
}),
},
);
if (!response.ok) throw new Error(await response.text());
toast.success('Response sent.');
} catch (error) {
console.error(error);
toast.error('Could not answer interaction.');
} finally {
setReplying(undefined);
}
};
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>
<h2 className='text-sm font-semibold'>Agent thread</h2>
<p className='text-muted-foreground text-xs'>
Messages, tool activity, and requests persist with this workspace.
</p>
</div>
<Button
type='button'
variant='outline'
size='sm'
disabled={disabled || !agentTurnActive}
onClick={abort}
>
<Ban className='size-3' />
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='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>
))}
{messages.map((message) => (
<article
key={message._id}
className={
message.role === 'user'
? 'border-border bg-muted ml-6 rounded-md border p-3 text-sm'
: message.status === 'failed'
? 'border-destructive/40 bg-destructive/5 rounded-md border p-3 text-sm'
: 'border-border bg-background rounded-md border p-3 text-sm'
}
>
<div className='mb-2 flex items-center justify-between gap-2'>
<span className='font-medium capitalize'>{message.role}</span>
<Badge
variant={
message.status === 'failed' ? 'destructive' : 'outline'
}
className='capitalize'
>
{message.status}
</Badge>
</div>
<p className='whitespace-pre-wrap'>
{message.content ||
(message.status === 'streaming' ? 'Working...' : '')}
</p>
</article>
))}
{events.slice(-20).map((event) => (
<article
key={event._id}
className='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='font-medium capitalize'>
{event.phase} / {event.level}
</span>
<span>{new Date(event.createdAt).toLocaleTimeString()}</span>
</div>
<p className='mt-1 whitespace-pre-wrap'>{event.message}</p>
</article>
))}
</div>
<div className='border-border 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)}
/>
<Button
type='button'
className='w-full'
disabled={disabled || sending || !content.trim()}
onClick={send}
>
<Send className='size-4' />
{sending ? 'Sending...' : 'Send'}
</Button>
</div>
</div>
);
};