diff --git a/web/src/editor/AssistantTab.tsx b/web/src/editor/AssistantTab.tsx index d42faf5..4fea1d8 100644 --- a/web/src/editor/AssistantTab.tsx +++ b/web/src/editor/AssistantTab.tsx @@ -36,18 +36,35 @@ 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 // 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. */} -
+
{!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.")}}