feat(web): scroll question forms to top of viewport instead of pinning to bottom (#1044)

* feat(web): scroll question forms to top of viewport instead of pinning to bottom

* fix: reset scroll state on composer send after form has claimed control

* fix: use getBoundingClientRect for form scroll position; guard early-return on streaming

* fix: smooth scroll for all chat-scroll operations; polyfill scrollTo for jsdom tests

* fix: revert streaming bottom-pin to instant to avoid scroll event thrash

* fix: revert initial-load bottom-pin to instant to avoid scroll event thrash
This commit is contained in:
Paul Stean 2026-05-09 21:29:54 +10:00 committed by GitHub
parent 343c1080e9
commit f3535cdd9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 81 additions and 4 deletions

View file

@ -151,6 +151,7 @@ export function ChatPane({
// 80px cutoff: scrolling ~90px up is an intentional pause that
// shouldn't be yanked back the moment the next chunk streams in.
const pinnedToBottomRef = useRef(true);
const scrolledToFormRef = useRef<Set<string>>(new Set());
const [tab, setTab] = useState<Tab>('chat');
const [showConvList, setShowConvList] = useState(false);
const [scrolledFromBottom, setScrolledFromBottom] = useState(false);
@ -178,6 +179,7 @@ export function ChatPane({
// A new conversation should land at the bottom (its own initial
// scroll), not inherit the previous conversation's saved position.
savedChatScrollRef.current = null;
scrolledToFormRef.current = new Set();
}, [activeConversationId]);
useEffect(() => {
@ -185,6 +187,25 @@ export function ChatPane({
if (!el || didInitialScrollRef.current || messages.length === 0) return;
didInitialScrollRef.current = true;
requestAnimationFrame(() => {
// If the last assistant message contains a question form, scroll to
// the form instead of the bottom, so the user sees the form first.
const lastAssistantMsg = [...messages].reverse().find((m) => m.role === 'assistant');
if (lastAssistantMsg?.content.includes('<question-form')) {
const assistantEls = el.querySelectorAll('.msg.assistant');
const lastAssistantEl = assistantEls[assistantEls.length - 1];
const formEl = lastAssistantEl?.querySelector<HTMLElement>('[data-form-id]');
if (formEl && !scrolledToFormRef.current.has(formEl.dataset.formId!)) {
scrolledToFormRef.current.add(formEl.dataset.formId!);
formEl.scrollIntoView({ block: 'start', behavior: 'smooth' });
pinnedToBottomRef.current = false;
setScrolledFromBottom(true);
return;
}
// Already handled by the auto-scroll effect — don't bottom-scroll.
if (formEl) return;
}
// Initial-load bottom-pin must be instant — smooth scrollTo emits
// intermediate scroll events that flip pinnedToBottomRef to false.
el.scrollTop = el.scrollHeight;
setScrolledFromBottom(false);
pinnedToBottomRef.current = true;
@ -212,9 +233,31 @@ export function ChatPane({
// threshold) so a deliberate ~90px scroll-up isn't snapped back the
// next time content streams in. Issue #983.
if (pinnedToBottomRef.current) {
// If the last assistant message contains a question form, scroll to
// the form instead of the bottom, so the user lands on the form.
const lastAssistantMsg = [...messages].reverse().find((m) => m.role === 'assistant');
if (lastAssistantMsg?.content.includes('<question-form')) {
const assistantEls = el.querySelectorAll('.msg.assistant');
const lastAssistantEl = assistantEls[assistantEls.length - 1];
const formEl = lastAssistantEl?.querySelector<HTMLElement>('[data-form-id]');
if (formEl && !scrolledToFormRef.current.has(formEl.dataset.formId!)) {
scrolledToFormRef.current.add(formEl.dataset.formId!);
formEl.scrollIntoView({ block: 'start', behavior: 'smooth' });
pinnedToBottomRef.current = false;
setScrolledFromBottom(true);
return;
}
// Form tag in content but the DOM element isn't ready yet (partial
// stream) — skip bottom-scroll to avoid a jarring jump that gets
// undone when the form finishes rendering.
if (streaming) return;
}
// Streaming bottom-pin must be instant — smooth scrollTo emits
// intermediate scroll events that flip pinnedToBottomRef to false,
// breaking auto-follow for subsequent chunks.
el.scrollTop = el.scrollHeight;
}
}, [messages, error]);
}, [messages, error, streaming]);
// Saved chat-log scroll state, preserved across tab switches. The
// chat-log <div> is conditionally rendered so it unmounts when the
@ -504,7 +547,11 @@ export function ChatPane({
onRequestOpenFile={onRequestOpenFile}
isLast={m.id === lastAssistantId}
nextUserContent={nextUserContentByAssistantId.get(m.id)}
onSubmitForm={onSubmitForm}
onSubmitForm={(text) => {
pinnedToBottomRef.current = true;
scrolledToFormRef.current = new Set();
onSubmitForm?.(text);
}}
onContinueRemainingTasks={
m.id === lastAssistantId && onContinueRemainingTasks
? (todos) => onContinueRemainingTasks(m, todos)
@ -538,7 +585,11 @@ export function ChatPane({
onEnsureProject={onEnsureProject}
commentAttachments={commentsToAttachments(attachedComments)}
onRemoveCommentAttachment={onDetachComment}
onSend={onSend}
onSend={(prompt, attachments, commentAttachments, meta) => {
pinnedToBottomRef.current = true;
scrolledToFormRef.current = new Set();
onSend(prompt, attachments, commentAttachments, meta);
}}
onStop={onStop}
onOpenSettings={onOpenSettings}
onOpenMcpSettings={onOpenMcpSettings}

View file

@ -76,7 +76,7 @@ export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit
});
return (
<div className={`question-form${locked ? ' question-form-locked' : ''}`}>
<div className={`question-form${locked ? ' question-form-locked' : ''}`} data-form-id={form.id}>
<div className="question-form-head">
<span className="question-form-icon" aria-hidden>?</span>
<div className="question-form-titles">

View file

@ -1,5 +1,18 @@
// @vitest-environment jsdom
// Polyfill scrollTo for jsdom (not available in jsdom's HTMLElement)
if (typeof HTMLElement.prototype.scrollTo !== 'function') {
HTMLElement.prototype.scrollTo = function (
options?: ScrollToOptions | number,
_y?: number,
) {
if (typeof options === 'object' && options !== null) {
if (options.top !== undefined) this.scrollTop = options.top;
if (options.left !== undefined) this.scrollLeft = options.left;
}
};
}
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatPane } from '../../src/components/ChatPane';

View file

@ -1,5 +1,18 @@
// @vitest-environment jsdom
// Polyfill scrollTo for jsdom (not available in jsdom's HTMLElement)
if (typeof HTMLElement.prototype.scrollTo !== 'function') {
HTMLElement.prototype.scrollTo = function (
options?: ScrollToOptions | number,
_y?: number,
) {
if (typeof options === 'object' && options !== null) {
if (options.top !== undefined) this.scrollTop = options.top;
if (options.left !== undefined) this.scrollLeft = options.left;
}
};
}
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ChatPane } from '../../src/components/ChatPane';