'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'; import { api } from '@spoon/backend/convex/_generated/api.js'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, Button, Tabs, TabsContent, TabsList, TabsTrigger, } from '@spoon/ui'; import type { DiffResponse, FileResponse, FileTreeNode } from './types'; import { AgentThread } from './agent-thread'; import { CodeEditor } from './code-editor'; import { CommandPanel } from './command-panel'; import { DiffViewer } from './diff-viewer'; import { FileTabs } from './file-tabs'; import { FileTree } from './file-tree'; import { JobStatusBar } from './job-status-bar'; import { WorkspaceActions } from './workspace-actions'; type OpenFileState = { path: string; content: string; savedContent: string; loading: boolean; saving: boolean; error?: string; }; type PendingOverwrite = { path: string; content: string; }; export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { const job = useQuery(api.agentJobs.get, { jobId }); const messages = 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, status: 'all', }) ?? []; 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(null); const [files, setFiles] = useState>({}); const [openFilePaths, setOpenFilePaths] = useState([]); const [activeFilePath, setActiveFilePath] = useState(); 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(); const [workspaceError, setWorkspaceError] = useState(); const [agentTurnActive, setAgentTurnActive] = useState(false); const [activeWorkspaceTab, setActiveWorkspaceTab] = useState< 'editor' | 'diff' | 'thread' >('editor'); const [pendingOverwrite, setPendingOverwrite] = useState(); const [pendingClosePath, setPendingClosePath] = useState(); const workspaceDisabled = !job || ['draft_pr_opened', 'failed', 'cancelled', 'timed_out'].includes( job.status, ) || ['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? ''); const loadTree = useCallback(async () => { 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]); const loadDiff = useCallback(async () => { 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) => ({ ...current, [path]: current[path] ?? { path, content: '', savedContent: '', loading: true, saving: false, }, })); const response = await fetch( `/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`, ); if (!response.ok) throw new Error(await response.text()); const data = (await response.json()) as FileResponse; setFiles((current) => ({ ...current, [data.path]: { path: data.path, content: data.content, savedContent: data.content, loading: false, saving: false, }, })); }, [jobId], ); const openFile = useCallback( (path: string) => { setOpenFilePaths((current) => current.includes(path) ? current : [...current, path], ); setActiveFilePath(path); if (!files[path]) { void loadFile(path).catch((error) => { console.error(error); setFiles((current) => { const next = { ...current }; delete next[path]; return next; }); setOpenFilePaths((current) => current.filter((filePath) => filePath !== path), ); toast.error('Could not load file.'); }); } }, [files, loadFile], ); useEffect(() => { if (!job) return; 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, loadAgentStatus, loadDiff, loadTree]); useEffect(() => { const interval = window.setInterval(() => { void loadAgentStatus(); }, 5_000); return () => window.clearInterval(interval); }, [loadAgentStatus]); useEffect(() => { if (!uiState || hydratedUiState) return; const timeout = window.setTimeout(() => { setOpenFilePaths(uiState.openFilePaths); setActiveFilePath(uiState.activeFilePath); setExpandedDirectoryPaths(uiState.expandedDirectoryPaths); setAgentThreadWidth(uiState.agentThreadWidth ?? 420); setVimEnabled(uiState.vimEnabled); setHydratedUiState(true); }, 0); return () => window.clearTimeout(timeout); }, [hydratedUiState, uiState]); useEffect(() => { if (!hydratedUiState) return; const timeout = window.setTimeout(() => { void patchUiState({ jobId, openFilePaths, activeFilePath, vimEnabled, expandedDirectoryPaths, agentThreadWidth, }).catch((error: unknown) => { console.error(error); }); }, 400); return () => window.clearTimeout(timeout); }, [ activeFilePath, expandedDirectoryPaths, agentThreadWidth, hydratedUiState, jobId, openFilePaths, patchUiState, vimEnabled, ]); useEffect(() => { if (!hydratedUiState) return; const timeout = window.setTimeout(() => { for (const path of openFilePaths) { if (!files[path]) { void loadFile(path).catch((error) => { console.error(error); }); } } }, 0); return () => window.clearTimeout(timeout); }, [files, hydratedUiState, loadFile, openFilePaths]); if (job === undefined) { return (
Loading workspace...
); } const activeFile = activeFilePath ? files[activeFilePath] : undefined; const recoverWorkspace = async () => { if (!job.threadId) return; await createJobForThread({ threadId: job.threadId, jobType: job.jobType ?? 'user_change', }); window.location.href = `/threads/${job.threadId}`; }; const deleteStaleWorkspace = async () => { await markWorkspaceLost({ jobId }); await deleteWorkspace({ jobId }); window.location.href = job.threadId ? `/threads/${job.threadId}` : `/spoons/${job.spoonId}`; }; const writeFileContent = async (path: string, content: string) => { setFiles((current) => ({ ...current, [path]: { ...(current[path] ?? { path, savedContent: '', loading: false, }), content, saving: true, }, })); const response = await fetch(`/api/agent-jobs/${jobId}/file`, { method: 'PUT', body: JSON.stringify({ path, content }), }); if (!response.ok) { toast.error('Could not save file.'); setFiles((current) => ({ ...current, [path]: { ...(current[path] ?? { path, content, savedContent: '', loading: false, }), saving: false, }, })); throw new Error(await response.text()); } setFiles((current) => ({ ...current, [path]: { ...(current[path] ?? { path, loading: false, }), content, savedContent: content, saving: false, }, })); await loadDiff(); toast.success('File saved.'); }; const saveFile = async (content: string) => { if (!activeFilePath) return; const path = activeFilePath; const activeFileBeforeSave = files[path]; if (activeFileBeforeSave) { const latestResponse = await fetch( `/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`, ); if (latestResponse.ok) { const latestData = (await latestResponse.json()) as FileResponse; if (latestData.content !== activeFileBeforeSave.savedContent) { setPendingOverwrite({ path, content, }); return; } } } await writeFileContent(path, content); }; const closeFileUnchecked = (path: string) => { const index = openFilePaths.indexOf(path); const nextOpen = openFilePaths.filter((filePath) => filePath !== path); setOpenFilePaths(nextOpen); setFiles((current) => { const next = { ...current }; delete next[path]; return next; }); if (activeFilePath === path) { setActiveFilePath(nextOpen[index - 1] ?? nextOpen[index] ?? undefined); } }; const closeFile = (path: string) => { const file = files[path]; if (file && file.content !== file.savedContent) { setPendingClosePath(path); return; } closeFileUnchecked(path); }; const toggleDirectory = (path: string) => { setExpandedDirectoryPaths((current) => current.includes(path) ? current.filter((directoryPath) => directoryPath !== path) : [...current, path], ); }; const openFileFromActivity = (path: string) => { openFile(path); setActiveWorkspaceTab('editor'); }; const openDiffFromActivity = (path: string) => { setFocusedDiffPath(path); setActiveWorkspaceTab('diff'); }; const resizeAgentThread = (event: ReactPointerEvent) => { 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 (
{workspaceError ? (

Thread workspace needs recovery

The saved workspace record exists, but this worker cannot reach its active runtime. This usually happens after a worker restart or local container cleanup.

{workspaceError}

{job.threadId ? ( ) : null} Delete this stale workspace record? This marks the unreachable workspace as failed and removes its stored messages, events, artifacts, diffs, and UI state. The thread itself is kept. Keep record void deleteStaleWorkspace()} > Delete stale record {job.threadId ? ( ) : null}
) : null}
setActiveWorkspaceTab(value as 'editor' | 'diff' | 'thread') } className='flex min-h-0 flex-1 flex-col' > Editor Diff viewer Thread ({ path, dirty: files[path] ? files[path].content !== files[path].savedContent : false, }))} activePath={activeFilePath} onActivate={setActiveFilePath} onClose={closeFile} /> { if (!activeFilePath) return; setFiles((current) => ({ ...current, [activeFilePath]: { ...(current[activeFilePath] ?? { path: activeFilePath, savedContent: '', loading: false, saving: false, }), content, }, })); }} /> setFocusedDiffPath(undefined)} />
{ if (!open) setPendingOverwrite(undefined); }} > Overwrite newer workspace changes? {pendingOverwrite?.path} changed after you opened it. Overwriting will replace the newer workspace contents with your editor contents. Keep editing { const pending = pendingOverwrite; setPendingOverwrite(undefined); if (pending) { void writeFileContent(pending.path, pending.content); } }} > Overwrite file { if (!open) setPendingClosePath(undefined); }} > Discard unsaved changes? {pendingClosePath} has unsaved changes. Closing this tab will discard the editor contents that have not been saved. Keep tab open { const path = pendingClosePath; setPendingClosePath(undefined); if (path) closeFileUnchecked(path); }} > Discard and close
); };