fix(workspace): scroll/diff-race/recovery/editor/mobile polish

This commit is contained in:
Gabriel Brown
2026-07-11 17:59:25 -04:00
parent b00141130c
commit f89dfce3b8
6 changed files with 477 additions and 18 deletions
@@ -1,13 +1,14 @@
'use client';
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useState } 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';
@@ -26,6 +27,10 @@ import {
AlertDialogTrigger,
Button,
cn,
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
Tabs,
TabsContent,
TabsList,
@@ -42,6 +47,7 @@ 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';
@@ -52,6 +58,9 @@ type OpenFileState = {
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 = {
@@ -105,6 +114,39 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
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 ||
@@ -133,20 +175,24 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
);
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 };
setWorkspaceError(undefined);
if (!treeSequencer.isLatest(token)) return;
recordWorkspaceSuccess();
setTree(data.tree);
}, [jobId]);
}, [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;
setWorkspaceError(undefined);
if (!diffSequencer.isLatest(token)) return;
recordWorkspaceSuccess();
setDiff(data.diff);
}, [jobId]);
}, [diffSequencer, jobId, recordWorkspaceSuccess]);
const loadAgentStatus = useCallback(async () => {
const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
@@ -154,14 +200,14 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
setAgentTurnActive(false);
const body = await response.text();
if (body.includes('workspace is not active')) {
setWorkspaceError(body);
recordWorkspaceFailure(body);
}
return;
}
const data = (await response.json()) as { active?: boolean };
setWorkspaceError(undefined);
recordWorkspaceSuccess();
setAgentTurnActive(Boolean(data.active));
}, [jobId]);
}, [jobId, recordWorkspaceFailure, recordWorkspaceSuccess]);
const loadFile = useCallback(
async (path: string) => {
@@ -194,6 +240,40 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
[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) =>
@@ -220,9 +300,10 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
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);
setWorkspaceError(error instanceof Error ? error.message : String(error));
};
const timeout = window.setTimeout(() => {
void loadTree().catch(handleError);
@@ -260,6 +341,11 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
(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(() => {
@@ -275,6 +361,46 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
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(() => {
@@ -393,6 +519,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}));
throw new Error(await response.text());
}
conflictToastPathsRef.current.delete(path);
setFiles((current) => ({
...current,
[path]: {
@@ -403,6 +530,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
content,
savedContent: content,
saving: false,
conflicted: false,
},
}));
await loadDiff();
@@ -492,7 +620,7 @@ 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'>
<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'>
@@ -574,9 +702,40 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
</div>
</div>
) : null}
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
<WorkspaceActions job={job} disabled={workspaceDisabled} />
<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={
@@ -585,7 +744,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
} as CSSProperties
}
>
<aside className='border-border bg-background min-h-0 border-r'>
<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>
@@ -657,6 +816,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
savedContent={activeFile?.savedContent ?? ''}
readOnly={workspaceDisabled}
vimEnabled={vimEnabled}
conflicted={activeFile?.conflicted}
onSave={saveFile}
onVimEnabledChange={setVimEnabled}
onChange={(content) => {