Add workspace loading state; auto-refresh diff on agent changes

- 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)
This commit is contained in:
Gabriel Brown
2026-06-24 07:29:38 -04:00
parent bb471a0917
commit ae90681d9b
@@ -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 (
<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} />
{workspacePending && !workspaceError ? (
<div className='border-border bg-background border-b p-4'>
<div
className={`flex items-center gap-3 rounded-md border p-4 ${
pendingTooLong
? 'border-amber-500/40 bg-amber-500/5'
: 'border-border bg-muted/30'
}`}
>
<Loader2 className='text-muted-foreground size-5 flex-none animate-spin' />
<div>
<p className='font-medium'>
{pendingTooLong
? 'Still waiting for a worker…'
: 'Setting up your workspace…'}
</p>
<p className='text-muted-foreground text-sm'>
{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.'}
</p>
</div>
</div>
</div>
) : null}
{workspaceError ? (
<div className='border-border bg-background border-b p-4'>
<div className='border-destructive/40 bg-destructive/5 rounded-md border p-4'>