'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, Loader2, 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 ?? ''); // The worker only exposes the live runtime (tree/diff/file endpoints) once it // has claimed the job and materialized the workspace (workspaceStatus // 'active'/'idle'). Before that, hitting those endpoints returns "workspace is // not active" — which is expected startup, not an error. const workspaceReady = ['active', 'idle'].includes( job?.workspaceStatus ?? '', ); const workspaceFailed = ['failed', 'cancelled', 'timed_out'].includes(job?.status ?? '') || ['stopped', 'expired', 'failed'].includes(job?.workspaceStatus ?? ''); // Waiting for a worker to pick up the job and start the runtime. const workspacePending = Boolean(job) && !workspaceReady && !workspaceFailed && ['queued', 'claimed', 'preparing', 'running', 'checks_running'].includes( job?.status ?? '', ); 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); const body = await response.text(); if (body.includes('workspace is not active')) { setWorkspaceError(body); } return; } const data = (await response.json()) as { active?: boolean }; setWorkspaceError(undefined); 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 (!workspaceReady) return; const handleError = (error: unknown) => { console.error(error); setWorkspaceError(error instanceof Error ? error.message : String(error)); }; const timeout = window.setTimeout(() => { void loadTree().catch(handleError); void loadDiff().catch(handleError); void loadAgentStatus(); }, 0); return () => window.clearTimeout(timeout); }, [workspaceReady, loadAgentStatus, loadDiff, loadTree]); useEffect(() => { if (!workspaceReady) return; const interval = window.setInterval(() => { void loadAgentStatus(); }, 5_000); return () => window.clearInterval(interval); }, [workspaceReady, loadAgentStatus]); // Surface a gentle "taking longer than usual" hint if a worker never picks the // job up (e.g. the worker is offline) instead of spinning forever. const [pendingTooLong, setPendingTooLong] = useState(false); useEffect(() => { if (!workspacePending) return; const timer = window.setTimeout(() => setPendingTooLong(true), 90_000); return () => { window.clearTimeout(timer); setPendingTooLong(false); }; }, [workspacePending]); // Refresh the tree and diff whenever the agent records a workspace change // (file edit / tool call that touched files) or a turn starts/ends, so the // diff viewer stays current without a manual refresh. Rapid bursts of changes // debounce into a single reload via the timeout cleanup. const workspaceChangeSignature = workspaceChanges.reduce( (latest, change) => Math.max(latest, change._creationTime), 0, ); useEffect(() => { if (!workspaceReady) return; const timeout = window.setTimeout(() => { void loadDiff().catch(() => undefined); void loadTree().catch(() => undefined); }, 200); return () => window.clearTimeout(timeout); }, [ workspaceChangeSignature, agentTurnActive, workspaceReady, loadDiff, loadTree, ]); 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 (
{workspacePending && !workspaceError ? (

{pendingTooLong ? 'Still waiting for a worker…' : 'Setting up your workspace…'}

{pendingTooLong ? 'This is taking longer than usual — the worker may be busy or offline. It will start automatically once a worker is available.' : 'Waiting for a worker to pick up this job. Files and diffs will appear automatically once the agent starts.'}

) : null} {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
); };