Add features & update project
Build and Push Spoon Images / quality (push) Successful in 1m41s
Build and Push Spoon Images / build-images (push) Successful in 7m4s

This commit is contained in:
Gabriel Brown
2026-06-23 02:06:58 -04:00
parent fe72fc2957
commit d207b8b0b8
26 changed files with 1257 additions and 231 deletions
@@ -6,7 +6,7 @@ import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
import { Button, Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
import type { DiffResponse, FileResponse, FileTreeNode } from './types';
import { AgentThread } from './agent-thread';
@@ -40,6 +40,9 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}) ?? [];
const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId });
const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
const createJobForThread = useMutation(api.agentJobs.createForThread);
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
const markWorkspaceLost = useMutation(api.agentJobs.markWorkspaceLost);
const [tree, setTree] = useState<FileTreeNode | null>(null);
const [files, setFiles] = useState<Record<string, OpenFileState>>({});
const [openFilePaths, setOpenFilePaths] = useState<string[]>([]);
@@ -50,6 +53,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const [vimEnabled, setVimEnabled] = useState(false);
const [hydratedUiState, setHydratedUiState] = useState(false);
const [diff, setDiff] = useState('');
const [workspaceError, setWorkspaceError] = useState<string>();
const [agentTurnActive, setAgentTurnActive] = useState(false);
const workspaceDisabled =
!job ||
@@ -62,6 +67,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as { tree: FileTreeNode | null };
setWorkspaceError(undefined);
setTree(data.tree);
}, [jobId]);
@@ -69,9 +75,20 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as DiffResponse;
setWorkspaceError(undefined);
setDiff(data.diff);
}, [jobId]);
const loadAgentStatus = useCallback(async () => {
const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
if (!response.ok) {
setAgentTurnActive(false);
return;
}
const data = (await response.json()) as { active?: boolean };
setAgentTurnActive(Boolean(data.active));
}, [jobId]);
const loadFile = useCallback(
async (path: string) => {
setFiles((current) => ({
@@ -132,13 +149,27 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const timeout = window.setTimeout(() => {
void loadTree().catch((error: unknown) => {
console.error(error);
setWorkspaceError(
error instanceof Error ? error.message : String(error),
);
});
void loadDiff().catch((error: unknown) => {
console.error(error);
setWorkspaceError(
error instanceof Error ? error.message : String(error),
);
});
void loadAgentStatus();
}, 0);
return () => window.clearTimeout(timeout);
}, [job, loadDiff, loadTree]);
}, [job, loadAgentStatus, loadDiff, loadTree]);
useEffect(() => {
const interval = window.setInterval(() => {
void loadAgentStatus();
}, 5_000);
return () => window.clearInterval(interval);
}, [loadAgentStatus]);
useEffect(() => {
if (!uiState || hydratedUiState) return;
@@ -197,6 +228,23 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
const recoverWorkspace = async () => {
if (!job.threadId) return;
const newJobId = await createJobForThread({
threadId: job.threadId,
jobType: job.jobType ?? 'user_change',
});
window.location.href = `/spoons/${job.spoonId}/agent/${newJobId}`;
};
const deleteStaleWorkspace = async () => {
if (!window.confirm('Delete this stale workspace record?')) return;
await markWorkspaceLost({ jobId });
await deleteWorkspace({ jobId });
window.location.href = job.threadId
? `/threads/${job.threadId}`
: `/spoons/${job.spoonId}`;
};
const saveFile = async (content: string) => {
if (!activeFilePath) return;
@@ -280,6 +328,35 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
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='text-muted-foreground mt-1 text-sm'>
{workspaceError}
</p>
<div className='mt-3 flex flex-wrap gap-2'>
{job.threadId ? (
<Button type='button' onClick={() => void recoverWorkspace()}>
Recreate workspace run
</Button>
) : null}
<Button
type='button'
variant='outline'
onClick={() => void deleteStaleWorkspace()}
>
Delete stale workspace
</Button>
{job.threadId ? (
<Button type='button' variant='outline' asChild>
<a href={`/threads/${job.threadId}`}>Open thread</a>
</Button>
) : null}
</div>
</div>
</div>
) : null}
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
<WorkspaceActions job={job} disabled={workspaceDisabled} />
</div>
@@ -362,6 +439,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
events={events}
interactions={interactions}
disabled={workspaceDisabled}
agentTurnActive={agentTurnActive}
/>
</TabsContent>
</Tabs>
@@ -374,6 +452,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
events={events}
interactions={interactions}
disabled={workspaceDisabled}
agentTurnActive={agentTurnActive}
/>
</aside>
</div>