970 lines
33 KiB
TypeScript
970 lines
33 KiB
TypeScript
'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<FileTreeNode | null>(null);
|
|
const [files, setFiles] = useState<Record<string, OpenFileState>>({});
|
|
const [openFilePaths, setOpenFilePaths] = useState<string[]>([]);
|
|
const [activeFilePath, setActiveFilePath] = useState<string>();
|
|
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<string>();
|
|
const [workspaceError, setWorkspaceError] = useState<string>();
|
|
const [agentTurnActive, setAgentTurnActive] = useState(false);
|
|
const [activeWorkspaceTab, setActiveWorkspaceTab] =
|
|
useState<WorkspaceTab>('editor');
|
|
const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>();
|
|
const [pendingClosePath, setPendingClosePath] = useState<string>();
|
|
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<typeof createRequestSequencer> | undefined
|
|
>(undefined);
|
|
treeSequencerRef.current ??= createRequestSequencer();
|
|
const treeSequencer = treeSequencerRef.current;
|
|
const diffSequencerRef = useRef<
|
|
ReturnType<typeof createRequestSequencer> | 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<Set<string>>(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 (
|
|
<main className='text-muted-foreground p-6'>Loading workspace...</main>
|
|
);
|
|
}
|
|
|
|
if (job === null) {
|
|
return (
|
|
<main className='text-muted-foreground p-6'>
|
|
This workspace was not found.
|
|
</main>
|
|
);
|
|
}
|
|
|
|
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<HTMLDivElement>) => {
|
|
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 (
|
|
<main className='border-border bg-muted/20 flex h-[calc(100dvh-8.5rem)] min-h-[70dvh] flex-col overflow-hidden rounded-md border lg:min-h-[720px]'>
|
|
<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'>
|
|
<p className='font-medium'>Thread workspace needs recovery</p>
|
|
<p className='text-muted-foreground mt-1 text-sm'>
|
|
The saved workspace record exists, but this worker cannot reach
|
|
its active runtime. This usually happens after a worker restart or
|
|
local container cleanup.
|
|
</p>
|
|
<p className='text-muted-foreground mt-2 text-xs break-all'>
|
|
{workspaceError}
|
|
</p>
|
|
<div className='mt-3 flex flex-wrap gap-2'>
|
|
{job.threadId ? (
|
|
<Button type='button' onClick={() => void recoverWorkspace()}>
|
|
Start a fresh run
|
|
</Button>
|
|
) : null}
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button type='button' variant='outline'>
|
|
Delete stale record
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
Delete this stale workspace record?
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This marks the unreachable workspace as failed and removes
|
|
its stored messages, events, artifacts, diffs, and UI
|
|
state. The thread itself is kept.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Keep record</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
variant='destructive'
|
|
onClick={() => void deleteStaleWorkspace()}
|
|
>
|
|
Delete stale record
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
{job.threadId ? (
|
|
<Button type='button' variant='outline' asChild>
|
|
<a href={`/threads/${job.threadId}`}>Open thread</a>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<div className='border-border bg-background flex items-center justify-between gap-2 border-b px-4 py-2'>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
className='lg:hidden'
|
|
onClick={() => setFileTreeOpen(true)}
|
|
>
|
|
<PanelLeft className='size-4' />
|
|
Files
|
|
</Button>
|
|
<div className='flex flex-1 items-center justify-end'>
|
|
<WorkspaceActions job={job} disabled={workspaceDisabled} />
|
|
</div>
|
|
</div>
|
|
<Sheet open={fileTreeOpen} onOpenChange={setFileTreeOpen}>
|
|
<SheetContent side='left' className='w-[300px] p-0 lg:hidden'>
|
|
<SheetHeader className='border-border border-b'>
|
|
<SheetTitle>Files</SheetTitle>
|
|
</SheetHeader>
|
|
<div className='min-h-0 flex-1 overflow-y-auto'>
|
|
<FileTree
|
|
tree={tree}
|
|
selectedPath={activeFilePath}
|
|
expandedPaths={expandedDirectoryPaths}
|
|
onSelect={(path) => {
|
|
openFile(path);
|
|
setFileTreeOpen(false);
|
|
}}
|
|
onToggleDirectory={toggleDirectory}
|
|
/>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
<div
|
|
className='grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] 2xl:grid-cols-[300px_minmax(0,1fr)_6px_var(--agent-thread-width)]'
|
|
style={
|
|
{
|
|
'--agent-thread-width': `${agentThreadWidth}px`,
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
<aside className='border-border bg-background hidden min-h-0 border-r lg:block'>
|
|
<div className='border-border border-b p-3'>
|
|
<h2 className='text-sm font-semibold'>Files</h2>
|
|
<p className='text-muted-foreground text-xs'>Current workspace</p>
|
|
</div>
|
|
<FileTree
|
|
tree={tree}
|
|
selectedPath={activeFilePath}
|
|
expandedPaths={expandedDirectoryPaths}
|
|
onSelect={openFile}
|
|
onToggleDirectory={toggleDirectory}
|
|
/>
|
|
</aside>
|
|
<section className='bg-background flex min-w-0 flex-col overflow-hidden'>
|
|
<Tabs
|
|
value={activeWorkspaceTab}
|
|
onValueChange={(value) =>
|
|
setActiveWorkspaceTab(value as WorkspaceTab)
|
|
}
|
|
className='flex min-h-0 flex-1 flex-col'
|
|
>
|
|
<TabsList className='border-border bg-muted/30 h-12 flex-none justify-start rounded-none border-b px-3'>
|
|
<TabsTrigger
|
|
value='editor'
|
|
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
|
|
>
|
|
<FileCode className='size-4' />
|
|
Editor
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value='diff'
|
|
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
|
|
>
|
|
<GitCompare className='size-4' />
|
|
Diff viewer
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value='terminal'
|
|
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
|
|
>
|
|
<SquareTerminal className='size-4' />
|
|
Terminal
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value='thread'
|
|
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm 2xl:hidden'
|
|
>
|
|
<MessagesSquare className='size-4' />
|
|
Thread
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent
|
|
value='editor'
|
|
className='m-0 flex min-h-0 flex-1 flex-col'
|
|
>
|
|
<FileTabs
|
|
tabs={openFilePaths.map((path) => ({
|
|
path,
|
|
dirty: files[path]
|
|
? files[path].content !== files[path].savedContent
|
|
: false,
|
|
}))}
|
|
activePath={activeFilePath}
|
|
onActivate={setActiveFilePath}
|
|
onClose={closeFile}
|
|
/>
|
|
<CodeEditor
|
|
path={activeFilePath}
|
|
content={activeFile?.content ?? ''}
|
|
savedContent={activeFile?.savedContent ?? ''}
|
|
readOnly={workspaceDisabled}
|
|
vimEnabled={vimEnabled}
|
|
conflicted={activeFile?.conflicted}
|
|
onSave={saveFile}
|
|
onVimEnabledChange={setVimEnabled}
|
|
onChange={(content) => {
|
|
if (!activeFilePath) return;
|
|
setFiles((current) => ({
|
|
...current,
|
|
[activeFilePath]: {
|
|
...(current[activeFilePath] ?? {
|
|
path: activeFilePath,
|
|
savedContent: '',
|
|
loading: false,
|
|
saving: false,
|
|
}),
|
|
content,
|
|
},
|
|
}));
|
|
}}
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent
|
|
value='terminal'
|
|
forceMount
|
|
className={cn(
|
|
'm-0 min-h-0 flex-1 overflow-hidden',
|
|
activeWorkspaceTab !== 'terminal' && 'hidden',
|
|
)}
|
|
>
|
|
<WorkspaceTerminal
|
|
jobId={jobId}
|
|
active={workspaceReady}
|
|
visible={activeWorkspaceTab === 'terminal'}
|
|
waitingLabel={
|
|
workspacePending ? 'Waiting for workspace…' : undefined
|
|
}
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
|
|
<DiffViewer
|
|
diff={diff}
|
|
focusedPath={focusedDiffPath}
|
|
onRefresh={loadDiff}
|
|
onClearFocusedPath={() => setFocusedDiffPath(undefined)}
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent
|
|
value='thread'
|
|
className='m-0 min-h-0 flex-1 overflow-hidden 2xl:hidden'
|
|
>
|
|
<AgentThread
|
|
jobId={jobId}
|
|
messages={messages}
|
|
events={events}
|
|
interactions={interactions}
|
|
workspaceChanges={workspaceChanges}
|
|
disabled={workspaceDisabled}
|
|
agentTurnActive={agentTurnActive}
|
|
onOpenFile={openFileFromActivity}
|
|
onOpenDiff={openDiffFromActivity}
|
|
/>
|
|
</TabsContent>
|
|
</Tabs>
|
|
<CommandPanel jobId={jobId} disabled={workspaceDisabled} />
|
|
</section>
|
|
<div
|
|
role='separator'
|
|
aria-label='Resize agent thread'
|
|
aria-orientation='vertical'
|
|
className='bg-border hover:bg-primary/50 hidden cursor-col-resize transition-colors 2xl:block'
|
|
onPointerDown={resizeAgentThread}
|
|
/>
|
|
<aside className='border-border bg-muted/20 hidden min-h-0 min-w-0 overflow-hidden border-l 2xl:block'>
|
|
<AgentThread
|
|
jobId={jobId}
|
|
messages={messages}
|
|
events={events}
|
|
interactions={interactions}
|
|
workspaceChanges={workspaceChanges}
|
|
disabled={workspaceDisabled}
|
|
agentTurnActive={agentTurnActive}
|
|
onOpenFile={openFileFromActivity}
|
|
onOpenDiff={openDiffFromActivity}
|
|
/>
|
|
</aside>
|
|
</div>
|
|
<AlertDialog
|
|
open={Boolean(pendingOverwrite)}
|
|
onOpenChange={(open) => {
|
|
if (!open) setPendingOverwrite(undefined);
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
Overwrite newer workspace changes?
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{pendingOverwrite?.path} changed after you opened it. Overwriting
|
|
will replace the newer workspace contents with your editor
|
|
contents.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Keep editing</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
variant='destructive'
|
|
onClick={() => {
|
|
const pending = pendingOverwrite;
|
|
setPendingOverwrite(undefined);
|
|
if (pending) {
|
|
void writeFileContent(pending.path, pending.content);
|
|
}
|
|
}}
|
|
>
|
|
Overwrite file
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
<AlertDialog
|
|
open={Boolean(pendingClosePath)}
|
|
onOpenChange={(open) => {
|
|
if (!open) setPendingClosePath(undefined);
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Discard unsaved changes?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{pendingClosePath} has unsaved changes. Closing this tab will
|
|
discard the editor contents that have not been saved.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Keep tab open</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
variant='destructive'
|
|
onClick={() => {
|
|
const path = pendingClosePath;
|
|
setPendingClosePath(undefined);
|
|
if (path) closeFileUnchecked(path);
|
|
}}
|
|
>
|
|
Discard and close
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</main>
|
|
);
|
|
};
|