Files
spoon/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx
T

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>
);
};