From f89dfce3b899e3c916dd1db42551e8eaa8183448 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sat, 11 Jul 2026 17:54:33 -0400 Subject: [PATCH] fix(workspace): scroll/diff-race/recovery/editor/mobile polish --- .../agent-workspace/agent-thread.tsx | 44 ++++- .../agent-workspace/agent-workspace-shell.tsx | 186 ++++++++++++++++-- .../agent-workspace/code-editor.tsx | 21 +- .../agent-workspace/workspace-helpers.ts | 54 +++++ .../component/agent-thread-scroll.test.tsx | 118 +++++++++++ .../next/tests/unit/workspace-helpers.test.ts | 72 +++++++ 6 files changed, 477 insertions(+), 18 deletions(-) create mode 100644 apps/next/src/components/agent-workspace/workspace-helpers.ts create mode 100644 apps/next/tests/component/agent-thread-scroll.test.tsx create mode 100644 apps/next/tests/unit/workspace-helpers.test.ts diff --git a/apps/next/src/components/agent-workspace/agent-thread.tsx b/apps/next/src/components/agent-workspace/agent-thread.tsx index 9a7bff5..b6da874 100644 --- a/apps/next/src/components/agent-workspace/agent-thread.tsx +++ b/apps/next/src/components/agent-workspace/agent-thread.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { + ArrowDown, Ban, FilePenLine, MessagesSquare, @@ -16,6 +17,7 @@ import { Badge, Button, Textarea } from '@spoon/ui'; import { DiffFileView, useDiffTheme } from './diff-file-view'; import { parseDiffFileForPath } from './diff-utils'; +import { isNearBottom } from './workspace-helpers'; type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors'; @@ -70,6 +72,7 @@ export const AgentThread = ({ const [filter, setFilter] = useState('all'); const diffTheme = useDiffTheme(); const scrollRef = useRef(null); + const [showJumpToLatest, setShowJumpToLatest] = useState(false); const chatMessages = useMemo( () => messages.filter((message) => { @@ -111,20 +114,35 @@ export const AgentThread = ({ ? [] : workspaceChanges; + const scrollToLatest = () => { + const node = scrollRef.current; + if (!node) return; + if (typeof node.scrollTo === 'function') { + node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' }); + } else { + 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; - const distanceFromBottom = - node.scrollHeight - node.scrollTop - node.clientHeight; - if (distanceFromBottom < 160 || agentTurnActive) { + 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, interactions.length, messages.length, @@ -227,8 +245,13 @@ export const AgentThread = ({ ))} +
{ + 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' > {(filter === 'all' || filter === 'chat') && interactions.length > 0 @@ -430,6 +453,19 @@ export const AgentThread = ({ yet.

) : null} +
+ {showJumpToLatest ? ( + + ) : null}