'use client'; import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useMutation, useQuery } from 'convex/react'; import { FileCode, GitCompare, Loader2, MessagesSquare, PanelLeft, SquareTerminal, } 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, cn, Sheet, SheetContent, SheetHeader, SheetTitle, 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'; import { WorkspaceTerminal } from './workspace-terminal'; import { createRequestSequencer, shouldShowRecovery } from './workspace-helpers'; type WorkspaceTab = 'editor' | 'diff' | 'thread' | 'terminal'; type OpenFileState = { path: string; content: string; savedContent: string; loading: boolean; saving: boolean; error?: string; // Set when the agent edited this file on disk while the user had unsaved // local edits, so the UI can warn instead of silently stomping either side. conflicted?: boolean; }; 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, job ? { jobId, limit: 200 } : 'skip', ) ?? []; const events = useQuery(api.agentJobs.listEvents, job ? { jobId, limit: 200 } : 'skip') ?? []; const workspaceChanges = useQuery( api.agentJobs.listWorkspaceChanges, job ? { jobId, limit: 200 } : 'skip', ) ?? []; const interactions = useQuery( api.agentJobs.listInteractionRequests, job ? { jobId, status: 'all' } : 'skip', ) ?? []; const uiState = useQuery( api.agentJobs.getWorkspaceUiState, job ? { jobId } : 'skip', ); 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'); const [pendingOverwrite, setPendingOverwrite] = useState(); const [pendingClosePath, setPendingClosePath] = useState(); const [fileTreeOpen, setFileTreeOpen] = useState(false); // Per-loader sequence guards: only the latest response for each loader is // applied, so a slower earlier fetch can't overwrite newer tree/diff data. const treeSequencerRef = useRef< ReturnType | undefined >(undefined); treeSequencerRef.current ??= createRequestSequencer(); const treeSequencer = treeSequencerRef.current; const diffSequencerRef = useRef< ReturnType | undefined >(undefined); diffSequencerRef.current ??= createRequestSequencer(); const diffSequencer = diffSequencerRef.current; // Count consecutive workspace status/health failures. A single transient // 'workspace is not active' blip (worker restart) must not flash the big red // recovery panel — only surface it after RECOVERY_THRESHOLD in a row. const consecutiveFailuresRef = useRef(0); const processedChangeSignatureRef = useRef(0); const conflictToastPathsRef = useRef>(new Set()); const recordWorkspaceSuccess = useCallback(() => { consecutiveFailuresRef.current = 0; setWorkspaceError(undefined); }, []); const recordWorkspaceFailure = useCallback((message: string) => { consecutiveFailuresRef.current += 1; if (shouldShowRecovery(consecutiveFailuresRef.current)) { setWorkspaceError(message); } }, []); 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 token = treeSequencer.next(); 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 }; if (!treeSequencer.isLatest(token)) return; recordWorkspaceSuccess(); setTree(data.tree); }, [jobId, recordWorkspaceSuccess, treeSequencer]); const loadDiff = useCallback(async () => { const token = diffSequencer.next(); 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; if (!diffSequencer.isLatest(token)) return; recordWorkspaceSuccess(); setDiff(data.diff); }, [diffSequencer, jobId, recordWorkspaceSuccess]); 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')) { recordWorkspaceFailure(body); } return; } const data = (await response.json()) as { active?: boolean }; recordWorkspaceSuccess(); setAgentTurnActive(Boolean(data.active)); }, [jobId, recordWorkspaceFailure, recordWorkspaceSuccess]); 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], ); // Re-fetch an already-open file and replace its editor buffer. Used when the // agent edits a file that is currently open: if the local buffer is clean we // adopt the new on-disk content; if the user has unsaved edits we flag a // conflict instead of stomping their work. const refreshFileBuffer = useCallback( async (path: string) => { const response = await fetch( `/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`, ); if (!response.ok) return; const data = (await response.json()) as FileResponse; setFiles((current) => { const existing = current[path]; if (!existing) return current; if (existing.content !== existing.savedContent) { return { ...current, [path]: { ...existing, conflicted: true } }; } conflictToastPathsRef.current.delete(path); return { ...current, [path]: { path, content: data.content, savedContent: data.content, loading: false, saving: false, conflicted: 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; // Recovery is driven solely by the recurring status heartbeat // (loadAgentStatus) so a one-off tree/diff hiccup can't trip the panel. const handleError = (error: unknown) => { console.error(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, ); // Read the (freshly-allocated-each-render) changes array via a ref inside the // signature-gated effect below, so the effect's deps stay on the stable // numeric signature instead of the array identity. const workspaceChangesRef = useRef(workspaceChanges); workspaceChangesRef.current = workspaceChanges; 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, ]); // When the agent records a workspace change that touches a currently OPEN // file, refresh that file's editor buffer (clean buffers adopt the new // content; dirty buffers get a conflict flag + one-time toast) so the editor // doesn't keep showing pre-edit content. useEffect(() => { if (!workspaceReady) return; if (workspaceChangeSignature <= processedChangeSignatureRef.current) return; const previousSignature = processedChangeSignatureRef.current; processedChangeSignatureRef.current = workspaceChangeSignature; const changedPaths = new Set( workspaceChangesRef.current .filter((change) => change._creationTime > previousSignature) .map((change) => change.path), ); for (const path of openFilePaths) { if (!changedPaths.has(path)) continue; const file = files[path]; if (!file) continue; if (file.content !== file.savedContent) { setFiles((current) => current[path] ? { ...current, [path]: { ...current[path], conflicted: true } } : current, ); if (!conflictToastPathsRef.current.has(path)) { conflictToastPathsRef.current.add(path); toast.warning(`${path} changed on disk`); } } else { void refreshFileBuffer(path).catch(() => undefined); } } }, [ files, openFilePaths, refreshFileBuffer, workspaceChangeSignature, workspaceReady, ]); 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...
); } if (job === null) { return (
This workspace was not found.
); } 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()); } conflictToastPathsRef.current.delete(path); setFiles((current) => ({ ...current, [path]: { ...(current[path] ?? { path, loading: false, }), content, savedContent: content, saving: false, conflicted: 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}
Files
{ openFile(path); setFileTreeOpen(false); }} onToggleDirectory={toggleDirectory} />
setActiveWorkspaceTab(value as WorkspaceTab) } className='flex min-h-0 flex-1 flex-col' > Editor Diff viewer Terminal 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
); };