From ae90681d9b5a17f45697aee61d557c2770ee78cb Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Wed, 24 Jun 2026 07:29:38 -0400 Subject: [PATCH] Add workspace loading state; auto-refresh diff on agent changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gate the tree/diff/status loads (and 5s poll) on workspaceStatus being active/idle, so we no longer hammer the 'workspace is not active' endpoint while a worker is still picking up the job - Show a 'Setting up your workspace…' pending state instead of surfacing startup as a console error / stale-workspace recovery box; escalate to a softer 'still waiting' hint after 90s if no worker picks it up - Auto-reload the diff and file tree (debounced) whenever the agent records a workspace change or a turn starts/ends, so diffs appear without a manual Refresh - The recovery box now only appears for a genuinely lost workspace (Convex reports active but the worker can't reach it) --- .../agent-workspace/agent-workspace-shell.tsx | 106 +++++++++++++++--- 1 file changed, 90 insertions(+), 16 deletions(-) diff --git a/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx b/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx index 3965e03..89bb86d 100644 --- a/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx +++ b/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx @@ -3,7 +3,7 @@ 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 { FileCode, GitCompare, Loader2, MessagesSquare } from 'lucide-react'; import { toast } from 'sonner'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; @@ -94,6 +94,25 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { ) || ['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()); @@ -181,31 +200,61 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { ); useEffect(() => { - if (!job) return; + 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((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 loadTree().catch(handleError); + void loadDiff().catch(handleError); void loadAgentStatus(); }, 0); return () => window.clearTimeout(timeout); - }, [job, loadAgentStatus, loadDiff, loadTree]); + }, [workspaceReady, loadAgentStatus, loadDiff, loadTree]); useEffect(() => { + if (!workspaceReady) return; const interval = window.setInterval(() => { void loadAgentStatus(); }, 5_000); return () => window.clearInterval(interval); - }, [loadAgentStatus]); + }, [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; @@ -418,6 +467,31 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { 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 ? (