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:
@@ -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'>
|
||||
|
||||
Reference in New Issue
Block a user