Add features & update project

This commit is contained in:
Gabriel Brown
2026-06-23 01:46:08 -04:00
parent 930fbf5965
commit fe72fc2957
39 changed files with 3106 additions and 178 deletions
@@ -1,7 +1,7 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useQuery } from 'convex/react';
import { useMutation, useQuery } from 'convex/react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -13,17 +13,42 @@ 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';
type OpenFileState = {
path: string;
content: string;
savedContent: string;
loading: boolean;
saving: boolean;
error?: string;
};
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const job = useQuery(api.agentJobs.get, { jobId });
const messages =
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
const events =
useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
const interactions =
useQuery(api.agentJobs.listInteractionRequests, {
jobId,
status: 'all',
}) ?? [];
const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId });
const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
const [tree, setTree] = useState<FileTreeNode | null>(null);
const [selectedPath, setSelectedPath] = useState<string>();
const [fileContent, setFileContent] = useState('');
const [files, setFiles] = useState<Record<string, OpenFileState>>({});
const [openFilePaths, setOpenFilePaths] = useState<string[]>([]);
const [activeFilePath, setActiveFilePath] = useState<string>();
const [expandedDirectoryPaths, setExpandedDirectoryPaths] = useState<
string[]
>([]);
const [vimEnabled, setVimEnabled] = useState(false);
const [hydratedUiState, setHydratedUiState] = useState(false);
const [diff, setDiff] = useState('');
const workspaceDisabled =
@@ -49,17 +74,59 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
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;
setSelectedPath(data.path);
setFileContent(data.content);
setFiles((current) => ({
...current,
[data.path]: {
path: data.path,
content: data.content,
savedContent: data.content,
loading: false,
saving: 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 (!job) return;
const timeout = window.setTimeout(() => {
@@ -73,27 +140,143 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
return () => window.clearTimeout(timeout);
}, [job, loadDiff, loadTree]);
useEffect(() => {
if (!uiState || hydratedUiState) return;
const timeout = window.setTimeout(() => {
setOpenFilePaths(uiState.openFilePaths);
setActiveFilePath(uiState.activeFilePath);
setExpandedDirectoryPaths(uiState.expandedDirectoryPaths);
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,
}).catch((error: unknown) => {
console.error(error);
});
}, 400);
return () => window.clearTimeout(timeout);
}, [
activeFilePath,
expandedDirectoryPaths,
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>
);
}
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
const saveFile = async (content: string) => {
if (!selectedPath) return;
if (!activeFilePath) return;
setFiles((current) => ({
...current,
[activeFilePath]: {
...(current[activeFilePath] ?? {
path: activeFilePath,
savedContent: '',
loading: false,
}),
content,
saving: true,
},
}));
const response = await fetch(`/api/agent-jobs/${jobId}/file`, {
method: 'PUT',
body: JSON.stringify({ path: selectedPath, content }),
body: JSON.stringify({ path: activeFilePath, content }),
});
if (!response.ok) {
toast.error('Could not save file.');
setFiles((current) => ({
...current,
[activeFilePath]: {
...(current[activeFilePath] ?? {
path: activeFilePath,
content,
savedContent: '',
loading: false,
}),
saving: false,
},
}));
throw new Error(await response.text());
}
setFileContent(content);
setFiles((current) => ({
...current,
[activeFilePath]: {
...(current[activeFilePath] ?? {
path: activeFilePath,
loading: false,
}),
content,
savedContent: content,
saving: false,
},
}));
await loadDiff();
toast.success('File saved.');
};
const closeFile = (path: string) => {
const file = files[path];
if (file && file.content !== file.savedContent) {
const confirmed = window.confirm(
`Close ${path} and discard unsaved changes?`,
);
if (!confirmed) return;
}
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 toggleDirectory = (path: string) => {
setExpandedDirectoryPaths((current) =>
current.includes(path)
? current.filter((directoryPath) => directoryPath !== path)
: [...current, path],
);
};
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} />
@@ -108,13 +291,10 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
</div>
<FileTree
tree={tree}
selectedPath={selectedPath}
onSelect={(path) => {
void loadFile(path).catch((error) => {
console.error(error);
toast.error('Could not load file.');
});
}}
selectedPath={activeFilePath}
expandedPaths={expandedDirectoryPaths}
onSelect={openFile}
onToggleDirectory={toggleDirectory}
/>
</aside>
<section className='bg-background flex min-w-0 flex-col'>
@@ -129,12 +309,44 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
Thread
</TabsTrigger>
</TabsList>
<TabsContent value='editor' className='m-0 min-h-0 flex-1'>
<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={selectedPath}
content={fileContent}
path={activeFilePath}
content={activeFile?.content ?? ''}
savedContent={activeFile?.savedContent ?? ''}
readOnly={workspaceDisabled}
vimEnabled={vimEnabled}
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='diff' className='m-0 min-h-0 flex-1'>
@@ -147,6 +359,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
<AgentThread
jobId={jobId}
messages={messages}
events={events}
interactions={interactions}
disabled={workspaceDisabled}
/>
</TabsContent>
@@ -157,6 +371,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
<AgentThread
jobId={jobId}
messages={messages}
events={events}
interactions={interactions}
disabled={workspaceDisabled}
/>
</aside>