From e184ae55657cf432cd36c75397d24aea46cf14ce Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 23 Aug 2026 03:02:50 -0400 Subject: [PATCH 1/2] Assistant: scroll the thread to a new reply for real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scrollIntoView({ behavior: 'smooth' }) on the thread's nested scroller never moved it in Chrome — measured live: scrollTop stayed 0 after sending and after the reply, while the instant form scrolled to the end. A long conversation therefore showed its oldest messages after every turn, with the new reply out of view below. Instant it is. Co-Authored-By: Claude Fable 5 --- web/src/editor/AssistantTab.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/editor/AssistantTab.tsx b/web/src/editor/AssistantTab.tsx index d42faf5..42ce553 100644 --- a/web/src/editor/AssistantTab.tsx +++ b/web/src/editor/AssistantTab.tsx @@ -40,8 +40,12 @@ export function AssistantTab({ gardenId, canEdit, undo, large = false }: { garde // 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. useEffect(() => { - bottom.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) + bottom.current?.scrollIntoView({ block: 'end' }) }, [history.data, pending]) const send = () => { From 82fbeb121b7b893dcd5d50e4d2b25ae36d11ff2d Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 23 Aug 2026 03:08:48 -0400 Subject: [PATCH 2/2] Address #134 review: follow the thread only while pinned to its end 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 --- web/src/editor/AssistantTab.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/web/src/editor/AssistantTab.tsx b/web/src/editor/AssistantTab.tsx index 42ce553..4fea1d8 100644 --- a/web/src/editor/AssistantTab.tsx +++ b/web/src/editor/AssistantTab.tsx @@ -36,6 +36,11 @@ export function AssistantTab({ gardenId, canEdit, undo, large = false }: { garde const [warning, setWarning] = useState(null) const abort = useRef(null) const bottom = useRef(null) + const thread = useRef(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. */} -
+
{!canEdit && You can only view this garden, so the assistant can't change anything in it.} {history.isPending &&

Loading the conversation…

} {history.isError && {errorMessage(history.error, "Couldn't load the conversation.")}}