fix(workspace): scroll/diff-race/recovery/editor/mobile polish
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
ArrowDown,
|
||||||
Ban,
|
Ban,
|
||||||
FilePenLine,
|
FilePenLine,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
@@ -16,6 +17,7 @@ import { Badge, Button, Textarea } from '@spoon/ui';
|
|||||||
|
|
||||||
import { DiffFileView, useDiffTheme } from './diff-file-view';
|
import { DiffFileView, useDiffTheme } from './diff-file-view';
|
||||||
import { parseDiffFileForPath } from './diff-utils';
|
import { parseDiffFileForPath } from './diff-utils';
|
||||||
|
import { isNearBottom } from './workspace-helpers';
|
||||||
|
|
||||||
type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors';
|
type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors';
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ export const AgentThread = ({
|
|||||||
const [filter, setFilter] = useState<ActivityFilter>('all');
|
const [filter, setFilter] = useState<ActivityFilter>('all');
|
||||||
const diffTheme = useDiffTheme();
|
const diffTheme = useDiffTheme();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [showJumpToLatest, setShowJumpToLatest] = useState(false);
|
||||||
const chatMessages = useMemo(
|
const chatMessages = useMemo(
|
||||||
() =>
|
() =>
|
||||||
messages.filter((message) => {
|
messages.filter((message) => {
|
||||||
@@ -111,20 +114,35 @@ export const AgentThread = ({
|
|||||||
? []
|
? []
|
||||||
: workspaceChanges;
|
: workspaceChanges;
|
||||||
|
|
||||||
useEffect(() => {
|
const scrollToLatest = () => {
|
||||||
const node = scrollRef.current;
|
const node = scrollRef.current;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const distanceFromBottom =
|
|
||||||
node.scrollHeight - node.scrollTop - node.clientHeight;
|
|
||||||
if (distanceFromBottom < 160 || agentTurnActive) {
|
|
||||||
if (typeof node.scrollTo === 'function') {
|
if (typeof node.scrollTo === 'function') {
|
||||||
node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' });
|
node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' });
|
||||||
} else {
|
} else {
|
||||||
node.scrollTop = node.scrollHeight;
|
node.scrollTop = node.scrollHeight;
|
||||||
}
|
}
|
||||||
|
setShowJumpToLatest(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only auto-scroll to the newest content when the user is already near the
|
||||||
|
// bottom. If they have scrolled up to read, don't yank them — surface a
|
||||||
|
// "jump to latest" affordance instead. (The agent streaming no longer forces
|
||||||
|
// a scroll; that was fighting the reader.)
|
||||||
|
useEffect(() => {
|
||||||
|
const node = scrollRef.current;
|
||||||
|
if (!node) return;
|
||||||
|
if (isNearBottom(node)) {
|
||||||
|
if (typeof node.scrollTo === 'function') {
|
||||||
|
node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
node.scrollTop = node.scrollHeight;
|
||||||
|
}
|
||||||
|
setShowJumpToLatest(false);
|
||||||
|
} else {
|
||||||
|
setShowJumpToLatest(true);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
agentTurnActive,
|
|
||||||
events.length,
|
events.length,
|
||||||
interactions.length,
|
interactions.length,
|
||||||
messages.length,
|
messages.length,
|
||||||
@@ -227,8 +245,13 @@ export const AgentThread = ({
|
|||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className='relative flex min-h-0 flex-1 flex-col'>
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
|
onScroll={() => {
|
||||||
|
const node = scrollRef.current;
|
||||||
|
if (node && isNearBottom(node)) setShowJumpToLatest(false);
|
||||||
|
}}
|
||||||
className='min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain p-3'
|
className='min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain p-3'
|
||||||
>
|
>
|
||||||
{(filter === 'all' || filter === 'chat') && interactions.length > 0
|
{(filter === 'all' || filter === 'chat') && interactions.length > 0
|
||||||
@@ -430,6 +453,19 @@ export const AgentThread = ({
|
|||||||
yet.
|
yet.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
{showJumpToLatest ? (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
size='sm'
|
||||||
|
variant='secondary'
|
||||||
|
className='absolute bottom-3 left-1/2 z-10 -translate-x-1/2 shadow-md'
|
||||||
|
onClick={scrollToLatest}
|
||||||
|
>
|
||||||
|
<ArrowDown className='size-3' />
|
||||||
|
Jump to latest
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className='border-border flex-none space-y-2 border-t p-3'>
|
<div className='border-border flex-none space-y-2 border-t p-3'>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
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, useRef, useState } from 'react';
|
||||||
import { useMutation, useQuery } from 'convex/react';
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
import {
|
import {
|
||||||
FileCode,
|
FileCode,
|
||||||
GitCompare,
|
GitCompare,
|
||||||
Loader2,
|
Loader2,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
|
PanelLeft,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -26,6 +27,10 @@ import {
|
|||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
Button,
|
Button,
|
||||||
cn,
|
cn,
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
TabsList,
|
TabsList,
|
||||||
@@ -42,6 +47,7 @@ import { FileTree } from './file-tree';
|
|||||||
import { JobStatusBar } from './job-status-bar';
|
import { JobStatusBar } from './job-status-bar';
|
||||||
import { WorkspaceActions } from './workspace-actions';
|
import { WorkspaceActions } from './workspace-actions';
|
||||||
import { WorkspaceTerminal } from './workspace-terminal';
|
import { WorkspaceTerminal } from './workspace-terminal';
|
||||||
|
import { createRequestSequencer, shouldShowRecovery } from './workspace-helpers';
|
||||||
|
|
||||||
type WorkspaceTab = 'editor' | 'diff' | 'thread' | 'terminal';
|
type WorkspaceTab = 'editor' | 'diff' | 'thread' | 'terminal';
|
||||||
|
|
||||||
@@ -52,6 +58,9 @@ type OpenFileState = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
error?: string;
|
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 = {
|
type PendingOverwrite = {
|
||||||
@@ -105,6 +114,39 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
useState<WorkspaceTab>('editor');
|
useState<WorkspaceTab>('editor');
|
||||||
const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>();
|
const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>();
|
||||||
const [pendingClosePath, setPendingClosePath] = useState<string>();
|
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 =
|
const workspaceDisabled =
|
||||||
!job ||
|
!job ||
|
||||||
@@ -133,20 +175,24 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const loadTree = useCallback(async () => {
|
const loadTree = useCallback(async () => {
|
||||||
|
const token = treeSequencer.next();
|
||||||
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());
|
||||||
const data = (await response.json()) as { tree: FileTreeNode | null };
|
const data = (await response.json()) as { tree: FileTreeNode | null };
|
||||||
setWorkspaceError(undefined);
|
if (!treeSequencer.isLatest(token)) return;
|
||||||
|
recordWorkspaceSuccess();
|
||||||
setTree(data.tree);
|
setTree(data.tree);
|
||||||
}, [jobId]);
|
}, [jobId, recordWorkspaceSuccess, treeSequencer]);
|
||||||
|
|
||||||
const loadDiff = useCallback(async () => {
|
const loadDiff = useCallback(async () => {
|
||||||
|
const token = diffSequencer.next();
|
||||||
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
|
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
|
||||||
if (!response.ok) throw new Error(await response.text());
|
if (!response.ok) throw new Error(await response.text());
|
||||||
const data = (await response.json()) as DiffResponse;
|
const data = (await response.json()) as DiffResponse;
|
||||||
setWorkspaceError(undefined);
|
if (!diffSequencer.isLatest(token)) return;
|
||||||
|
recordWorkspaceSuccess();
|
||||||
setDiff(data.diff);
|
setDiff(data.diff);
|
||||||
}, [jobId]);
|
}, [diffSequencer, jobId, recordWorkspaceSuccess]);
|
||||||
|
|
||||||
const loadAgentStatus = useCallback(async () => {
|
const loadAgentStatus = useCallback(async () => {
|
||||||
const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
|
const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
|
||||||
@@ -154,14 +200,14 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
setAgentTurnActive(false);
|
setAgentTurnActive(false);
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
if (body.includes('workspace is not active')) {
|
if (body.includes('workspace is not active')) {
|
||||||
setWorkspaceError(body);
|
recordWorkspaceFailure(body);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = (await response.json()) as { active?: boolean };
|
const data = (await response.json()) as { active?: boolean };
|
||||||
setWorkspaceError(undefined);
|
recordWorkspaceSuccess();
|
||||||
setAgentTurnActive(Boolean(data.active));
|
setAgentTurnActive(Boolean(data.active));
|
||||||
}, [jobId]);
|
}, [jobId, recordWorkspaceFailure, recordWorkspaceSuccess]);
|
||||||
|
|
||||||
const loadFile = useCallback(
|
const loadFile = useCallback(
|
||||||
async (path: string) => {
|
async (path: string) => {
|
||||||
@@ -194,6 +240,40 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
[jobId],
|
[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(
|
const openFile = useCallback(
|
||||||
(path: string) => {
|
(path: string) => {
|
||||||
setOpenFilePaths((current) =>
|
setOpenFilePaths((current) =>
|
||||||
@@ -220,9 +300,10 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspaceReady) return;
|
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) => {
|
const handleError = (error: unknown) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
setWorkspaceError(error instanceof Error ? error.message : String(error));
|
|
||||||
};
|
};
|
||||||
const timeout = window.setTimeout(() => {
|
const timeout = window.setTimeout(() => {
|
||||||
void loadTree().catch(handleError);
|
void loadTree().catch(handleError);
|
||||||
@@ -260,6 +341,11 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
(latest, change) => Math.max(latest, change._creationTime),
|
(latest, change) => Math.max(latest, change._creationTime),
|
||||||
0,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!workspaceReady) return;
|
if (!workspaceReady) return;
|
||||||
const timeout = window.setTimeout(() => {
|
const timeout = window.setTimeout(() => {
|
||||||
@@ -275,6 +361,46 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
loadTree,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!uiState || hydratedUiState) return;
|
if (!uiState || hydratedUiState) return;
|
||||||
const timeout = window.setTimeout(() => {
|
const timeout = window.setTimeout(() => {
|
||||||
@@ -393,6 +519,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
}));
|
}));
|
||||||
throw new Error(await response.text());
|
throw new Error(await response.text());
|
||||||
}
|
}
|
||||||
|
conflictToastPathsRef.current.delete(path);
|
||||||
setFiles((current) => ({
|
setFiles((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[path]: {
|
[path]: {
|
||||||
@@ -403,6 +530,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
content,
|
content,
|
||||||
savedContent: content,
|
savedContent: content,
|
||||||
saving: false,
|
saving: false,
|
||||||
|
conflicted: false,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
await loadDiff();
|
await loadDiff();
|
||||||
@@ -492,7 +620,7 @@ 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(100dvh-8.5rem)] min-h-[70dvh] flex-col overflow-hidden rounded-md border lg:min-h-[720px]'>
|
||||||
<JobStatusBar job={job} />
|
<JobStatusBar job={job} />
|
||||||
{workspacePending && !workspaceError ? (
|
{workspacePending && !workspaceError ? (
|
||||||
<div className='border-border bg-background border-b p-4'>
|
<div className='border-border bg-background border-b p-4'>
|
||||||
@@ -574,9 +702,40 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
|
<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} />
|
<WorkspaceActions job={job} disabled={workspaceDisabled} />
|
||||||
</div>
|
</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
|
<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)]'
|
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={
|
style={
|
||||||
@@ -585,7 +744,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
} as CSSProperties
|
} 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'>
|
<div className='border-border border-b p-3'>
|
||||||
<h2 className='text-sm font-semibold'>Files</h2>
|
<h2 className='text-sm font-semibold'>Files</h2>
|
||||||
<p className='text-muted-foreground text-xs'>Current workspace</p>
|
<p className='text-muted-foreground text-xs'>Current workspace</p>
|
||||||
@@ -657,6 +816,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
savedContent={activeFile?.savedContent ?? ''}
|
savedContent={activeFile?.savedContent ?? ''}
|
||||||
readOnly={workspaceDisabled}
|
readOnly={workspaceDisabled}
|
||||||
vimEnabled={vimEnabled}
|
vimEnabled={vimEnabled}
|
||||||
|
conflicted={activeFile?.conflicted}
|
||||||
onSave={saveFile}
|
onSave={saveFile}
|
||||||
onVimEnabledChange={setVimEnabled}
|
onVimEnabledChange={setVimEnabled}
|
||||||
onChange={(content) => {
|
onChange={(content) => {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const EDITOR_FONT_FAMILY =
|
|||||||
|
|
||||||
type MonacoEditorInstance = {
|
type MonacoEditorInstance = {
|
||||||
getModel?: () => unknown;
|
getModel?: () => unknown;
|
||||||
|
dispose?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VimMode = {
|
type VimMode = {
|
||||||
@@ -36,6 +37,7 @@ export const CodeEditor = ({
|
|||||||
savedContent,
|
savedContent,
|
||||||
readOnly,
|
readOnly,
|
||||||
vimEnabled,
|
vimEnabled,
|
||||||
|
conflicted,
|
||||||
onSave,
|
onSave,
|
||||||
onChange,
|
onChange,
|
||||||
onVimEnabledChange,
|
onVimEnabledChange,
|
||||||
@@ -45,6 +47,7 @@ export const CodeEditor = ({
|
|||||||
savedContent: string;
|
savedContent: string;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
vimEnabled: boolean;
|
vimEnabled: boolean;
|
||||||
|
conflicted?: boolean;
|
||||||
onSave: (content: string) => Promise<void>;
|
onSave: (content: string) => Promise<void>;
|
||||||
onChange: (content: string) => void;
|
onChange: (content: string) => void;
|
||||||
onVimEnabledChange: (enabled: boolean) => void;
|
onVimEnabledChange: (enabled: boolean) => void;
|
||||||
@@ -56,6 +59,18 @@ export const CodeEditor = ({
|
|||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const editorTheme = resolvedTheme === 'light' ? SPOON_LIGHT : SPOON_DARK;
|
const editorTheme = resolvedTheme === 'light' ? SPOON_LIGHT : SPOON_DARK;
|
||||||
|
|
||||||
|
// Dispose the Monaco instance and clear the ref on unmount so a remount
|
||||||
|
// (e.g. the Phase-1 key={jobId} swap) doesn't leave a stale editor/vim
|
||||||
|
// binding leaked behind the new mount.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
vimRef.current?.dispose();
|
||||||
|
vimRef.current = null;
|
||||||
|
editorRef.current?.dispose?.();
|
||||||
|
editorRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editor = editorRef.current;
|
const editor = editorRef.current;
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
@@ -102,7 +117,11 @@ export const CodeEditor = ({
|
|||||||
Editor
|
Editor
|
||||||
</p>
|
</p>
|
||||||
<p className='truncate font-mono text-xs'>{path}</p>
|
<p className='truncate font-mono text-xs'>{path}</p>
|
||||||
{dirty ? (
|
{conflicted ? (
|
||||||
|
<p className='text-amber-500 text-xs'>
|
||||||
|
This file changed on disk — your unsaved edits may conflict.
|
||||||
|
</p>
|
||||||
|
) : dirty ? (
|
||||||
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
|
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Small pure helpers extracted from the agent workspace so the tricky UX
|
||||||
|
* decisions (out-of-order response guarding, recovery thresholding, and
|
||||||
|
* near-bottom auto-scroll) can be unit tested in isolation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A monotonically increasing request sequencer. Each loader call takes a fresh
|
||||||
|
* token via {@link RequestSequencer.next} before firing its fetch, then checks
|
||||||
|
* {@link RequestSequencer.isLatest} once the response resolves. A slower earlier
|
||||||
|
* request whose token is no longer the latest is dropped so it can't overwrite
|
||||||
|
* newer data (the diff/tree load race).
|
||||||
|
*/
|
||||||
|
export type RequestSequencer = {
|
||||||
|
next: () => number;
|
||||||
|
isLatest: (token: number) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRequestSequencer = (): RequestSequencer => {
|
||||||
|
let current = 0;
|
||||||
|
return {
|
||||||
|
next: () => {
|
||||||
|
current += 1;
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
isLatest: (token: number) => token === current,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of consecutive workspace status/health failures required before the
|
||||||
|
* "needs recovery" panel is shown. A single transient
|
||||||
|
* `workspace is not active` blip (e.g. during a worker restart) must not trip
|
||||||
|
* it.
|
||||||
|
*/
|
||||||
|
export const RECOVERY_THRESHOLD = 3;
|
||||||
|
|
||||||
|
export const shouldShowRecovery = (
|
||||||
|
consecutiveFailures: number,
|
||||||
|
threshold: number = RECOVERY_THRESHOLD,
|
||||||
|
): boolean => consecutiveFailures >= threshold;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance from the bottom (px) within which the thread is considered "near the
|
||||||
|
* bottom" and safe to auto-scroll on new content. Outside this band the user
|
||||||
|
* has scrolled up to read and must not be yanked.
|
||||||
|
*/
|
||||||
|
export const NEAR_BOTTOM_THRESHOLD = 80;
|
||||||
|
|
||||||
|
export const isNearBottom = (
|
||||||
|
metrics: { scrollHeight: number; scrollTop: number; clientHeight: number },
|
||||||
|
threshold: number = NEAR_BOTTOM_THRESHOLD,
|
||||||
|
): boolean =>
|
||||||
|
metrics.scrollHeight - metrics.scrollTop - metrics.clientHeight < threshold;
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AgentThread } from '../../src/components/agent-workspace/agent-thread';
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Message = {
|
||||||
|
_id: string;
|
||||||
|
_creationTime: number;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = (id: string, content: string): Message => ({
|
||||||
|
_id: id,
|
||||||
|
_creationTime: Date.now(),
|
||||||
|
role: 'assistant',
|
||||||
|
content,
|
||||||
|
status: 'complete',
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
jobId: 'job-1',
|
||||||
|
events: [],
|
||||||
|
interactions: [],
|
||||||
|
workspaceChanges: [],
|
||||||
|
disabled: false,
|
||||||
|
agentTurnActive: true,
|
||||||
|
onOpenFile: vi.fn(),
|
||||||
|
onOpenDiff: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderThread = (messages: Message[]) =>
|
||||||
|
render(
|
||||||
|
// Types are exercised at runtime; cast the fixture shapes to satisfy the
|
||||||
|
// Doc<> generics without pulling the full backend types into the test.
|
||||||
|
<AgentThread {...baseProps} messages={messages as never} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Stubs the scroll geometry of the messages list so we control near-bottom. */
|
||||||
|
const stubScrollGeometry = (
|
||||||
|
container: HTMLElement,
|
||||||
|
geometry: { scrollHeight: number; scrollTop: number; clientHeight: number },
|
||||||
|
) => {
|
||||||
|
const node = container.querySelector<HTMLElement>('.overflow-y-auto');
|
||||||
|
if (!node) throw new Error('scroll container not found');
|
||||||
|
const scrollTo = vi.fn();
|
||||||
|
Object.defineProperty(node, 'scrollTo', {
|
||||||
|
configurable: true,
|
||||||
|
value: scrollTo,
|
||||||
|
});
|
||||||
|
Object.defineProperty(node, 'scrollHeight', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => geometry.scrollHeight,
|
||||||
|
});
|
||||||
|
Object.defineProperty(node, 'clientHeight', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => geometry.clientHeight,
|
||||||
|
});
|
||||||
|
let scrollTop = geometry.scrollTop;
|
||||||
|
Object.defineProperty(node, 'scrollTop', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => scrollTop,
|
||||||
|
set: (value: number) => {
|
||||||
|
scrollTop = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { node, scrollTo };
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AgentThread auto-scroll', () => {
|
||||||
|
it('does not yank to the bottom while the user is scrolled up', () => {
|
||||||
|
const { container, rerender } = renderThread([message('m1', 'first')]);
|
||||||
|
const { scrollTo } = stubScrollGeometry(container, {
|
||||||
|
scrollHeight: 1000,
|
||||||
|
scrollTop: 100,
|
||||||
|
clientHeight: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AgentThread
|
||||||
|
{...baseProps}
|
||||||
|
messages={[message('m1', 'first'), message('m2', 'second')] as never}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-scrolls to newest when already near the bottom', () => {
|
||||||
|
const { container, rerender } = renderThread([message('m1', 'first')]);
|
||||||
|
const { scrollTo } = stubScrollGeometry(container, {
|
||||||
|
scrollHeight: 1000,
|
||||||
|
scrollTop: 780,
|
||||||
|
clientHeight: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AgentThread
|
||||||
|
{...baseProps}
|
||||||
|
messages={[message('m1', 'first'), message('m2', 'second')] as never}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(scrollTo).toHaveBeenCalledWith({
|
||||||
|
top: 1000,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createRequestSequencer,
|
||||||
|
isNearBottom,
|
||||||
|
NEAR_BOTTOM_THRESHOLD,
|
||||||
|
RECOVERY_THRESHOLD,
|
||||||
|
shouldShowRecovery,
|
||||||
|
} from '@/components/agent-workspace/workspace-helpers';
|
||||||
|
|
||||||
|
describe('createRequestSequencer', () => {
|
||||||
|
test('only the latest issued token is considered current', () => {
|
||||||
|
const sequencer = createRequestSequencer();
|
||||||
|
const first = sequencer.next();
|
||||||
|
const second = sequencer.next();
|
||||||
|
|
||||||
|
// Simulate the first (slower) request resolving after the second: it is
|
||||||
|
// stale and must be dropped.
|
||||||
|
expect(sequencer.isLatest(first)).toBe(false);
|
||||||
|
// The newest request wins.
|
||||||
|
expect(sequencer.isLatest(second)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fresh request supersedes a previously latest one', () => {
|
||||||
|
const sequencer = createRequestSequencer();
|
||||||
|
const token = sequencer.next();
|
||||||
|
expect(sequencer.isLatest(token)).toBe(true);
|
||||||
|
sequencer.next();
|
||||||
|
expect(sequencer.isLatest(token)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldShowRecovery', () => {
|
||||||
|
test('defaults to a threshold of 3 consecutive failures', () => {
|
||||||
|
expect(RECOVERY_THRESHOLD).toBe(3);
|
||||||
|
expect(shouldShowRecovery(0)).toBe(false);
|
||||||
|
expect(shouldShowRecovery(1)).toBe(false);
|
||||||
|
expect(shouldShowRecovery(2)).toBe(false);
|
||||||
|
expect(shouldShowRecovery(3)).toBe(true);
|
||||||
|
expect(shouldShowRecovery(4)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honours a custom threshold', () => {
|
||||||
|
expect(shouldShowRecovery(1, 2)).toBe(false);
|
||||||
|
expect(shouldShowRecovery(2, 2)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNearBottom', () => {
|
||||||
|
test('true when within the threshold band of the bottom', () => {
|
||||||
|
expect(
|
||||||
|
isNearBottom({ scrollHeight: 1000, scrollTop: 940, clientHeight: 100 }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('false when scrolled up beyond the threshold', () => {
|
||||||
|
expect(
|
||||||
|
isNearBottom({ scrollHeight: 1000, scrollTop: 200, clientHeight: 100 }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses NEAR_BOTTOM_THRESHOLD as the default band', () => {
|
||||||
|
expect(NEAR_BOTTOM_THRESHOLD).toBe(80);
|
||||||
|
// Exactly at the threshold is not "near" (strict less-than).
|
||||||
|
expect(
|
||||||
|
isNearBottom({ scrollHeight: 1000, scrollTop: 820, clientHeight: 100 }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isNearBottom({ scrollHeight: 1000, scrollTop: 821, clientHeight: 100 }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user