mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
343c1080e9
commit
f3535cdd9f
4 changed files with 81 additions and 4 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue