Address #134 review: follow the thread only while pinned to its end
Build image / build-and-push (push) Successful in 11s
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:
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user