Assistant: scroll the thread to a new reply for real #134

Merged
steve merged 2 commits from fix/assistant-thread-scroll into main 2026-08-23 07:10:27 +00:00
+19 -2
View File
@@ -36,18 +36,35 @@ 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
// on; the exchange is persisted server-side; coming back shows it.
// 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. 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({ behavior: 'smooth', 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: [] })
@@ -91,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>}