Address #134 review: follow the thread only while pinned to its end
Build image / build-and-push (push) Successful in 11s

The instant scroll runs on every step of a turn, so it now follows new
content only while the view is at the end of the thread (within 80px).
Scrolling up to read something stays put until the person comes back
down or sends the next message, which returns them to the end.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-08-23 03:08:48 -04:00
co-authored by Claude Fable 5
parent e184ae5565
commit 82fbeb121b
+16 -3
View File
@@ -36,6 +36,11 @@ export function AssistantTab({ gardenId, canEdit, undo, large = false }: { garde
const [warning, setWarning] = useState<string | null>(null)
const abort = useRef<AbortController | null>(null)
const bottom = useRef<HTMLDivElement>(null)
const thread = useRef<HTMLDivElement>(null)
// Whether the view is pinned to the end of the thread. It follows new
// content only while it is; a person who scrolled up to read something is
// left there until they come back down or send the next message.
const stuck = useRef(true)
// Deliberately NOT aborted on unmount: selecting a bed switches the rail to
// the inspector, and that must not kill a turn mid-flight. The request runs
@@ -43,15 +48,23 @@ export function AssistantTab({ gardenId, canEdit, undo, large = false }: { garde
// Instant, not smooth: Chrome left the thread at the top with
// `behavior: 'smooth'` — a smooth scrollIntoView into this nested scroller
// never moved it, so every new reply landed out of view below a long
// conversation (found live, 2026-08-23). The instant form scrolls.
// conversation (found live, 2026-08-23). The instant form scrolls. It runs
// on every step of a turn too, so it is gated on `stuck`: following the
// stream is right when the person is at the end, and a snap they didn't
// ask for when they had scrolled up.
useEffect(() => {
bottom.current?.scrollIntoView({ block: 'end' })
if (stuck.current) bottom.current?.scrollIntoView({ block: 'end' })
}, [history.data, pending])
const onThreadScroll = () => {
const el = thread.current
if (el) stuck.current = el.scrollHeight - el.clientHeight - el.scrollTop < 80
}
const send = () => {
const message = input.trim()
if (!message || pending) return
setInput('')
stuck.current = true // sending is a return to the end of the thread
setError(null)
setWarning(null)
setPending({ message, steps: [] })
@@ -95,7 +108,7 @@ export function AssistantTab({ gardenId, canEdit, undo, large = false }: { garde
{/* The thread scrolls on its own so the composer stays put: with the whole
tab scrolling, a long conversation pushed the input off the bottom and
every new message scrolled it further away. */}
<div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto">
<div ref={thread} onScroll={onThreadScroll} className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto">
{!canEdit && <Alert tone="info">You can only view this garden, so the assistant can't change anything in it.</Alert>}
{history.isPending && <p className="text-[13px] text-ink-mute">Loading the conversation…</p>}
{history.isError && <Alert>{errorMessage(history.error, "Couldn't load the conversation.")}</Alert>}