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 type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useMutation, useQuery } from 'convex/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 { toast } from 'sonner';
|
||||||
|
|
||||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
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 ?? '');
|
['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 loadTree = useCallback(async () => {
|
||||||
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
|
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
|
||||||
if (!response.ok) throw new Error(await response.text());
|
if (!response.ok) throw new Error(await response.text());
|
||||||
@@ -181,31 +200,61 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
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(() => {
|
const timeout = window.setTimeout(() => {
|
||||||
void loadTree().catch((error: unknown) => {
|
void loadTree().catch(handleError);
|
||||||
console.error(error);
|
void loadDiff().catch(handleError);
|
||||||
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 loadAgentStatus();
|
void loadAgentStatus();
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => window.clearTimeout(timeout);
|
return () => window.clearTimeout(timeout);
|
||||||
}, [job, loadAgentStatus, loadDiff, loadTree]);
|
}, [workspaceReady, loadAgentStatus, loadDiff, loadTree]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!workspaceReady) return;
|
||||||
const interval = window.setInterval(() => {
|
const interval = window.setInterval(() => {
|
||||||
void loadAgentStatus();
|
void loadAgentStatus();
|
||||||
}, 5_000);
|
}, 5_000);
|
||||||
return () => window.clearInterval(interval);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!uiState || hydratedUiState) return;
|
if (!uiState || hydratedUiState) return;
|
||||||
@@ -418,6 +467,31 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
return (
|
return (
|
||||||
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
|
<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} />
|
<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 ? (
|
{workspaceError ? (
|
||||||
<div className='border-border bg-background border-b p-4'>
|
<div className='border-border bg-background border-b p-4'>
|
||||||
<div className='border-destructive/40 bg-destructive/5 rounded-md border p-4'>
|
<div className='border-destructive/40 bg-destructive/5 rounded-md border p-4'>
|
||||||
|
|||||||
Reference in New Issue
Block a user