Clean up old stuff & fix ui errors
Build and Push Spoon Images / quality (push) Successful in 2m22s
Build and Push Spoon Images / build-images (push) Successful in 23m10s

This commit is contained in:
Gabriel Brown
2026-06-23 14:57:05 -04:00
parent d207b8b0b8
commit a6f7ea7f78
34 changed files with 1565 additions and 551 deletions
@@ -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>