diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a558e1aa..ee024386 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -655,7 +655,7 @@ button:disabled { cursor: default; opacity: .45; } .app-loading-error .app-loading-content { padding: 28px; border: 1px solid var(--color-border); background: var(--color-canvas); border-color: var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-elevated); } .group\/sidebar-provider[data-viewport] { height: 100vh; min-height: 0; overflow: hidden; } -.chat-app { width: 100%; height: 100%; min-height: 0; overflow: hidden; display: grid; background: var(--color-canvas); grid-template-columns: minmax(0, 1fr); } +.chat-app { width: 100%; height: 100%; min-height: 0; overflow: clip; display: grid; background: var(--color-canvas); grid-template-columns: minmax(0, 1fr); } .sidebar { position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; border-right: 1px solid var(--sidebar-border); color: var(--sidebar-foreground); padding: var(--space-5) var(--space-3) 0; background: var(--sidebar-background); backdrop-filter: saturate(130%) blur(20px); } [data-sidebar="header"] { flex: 0 0 auto; padding-bottom: var(--space-3); } [data-sidebar="content"] { min-height: 0; flex: 1 1 auto; display: flex; flex-direction: column; gap: var(--space-3); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; } @@ -844,7 +844,11 @@ button:disabled { cursor: default; opacity: .45; } [data-state="collapsed"] .sidebar-footer { padding-top: var(--space-3); } } -.chat-panel { height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: grid; background: var(--color-canvas); grid-template-rows: 46px minmax(0, 1fr) auto; } +/* `clip`, not `hidden`: an overflow:hidden box is still programmatically + scrollable. A focus() inside the transcript scrolled this panel by 88px and + pushed the 46px header off the top for good — there is no scrollbar to get + it back. `clip` creates no scroll container, so it cannot happen again. */ +.chat-panel { height: 100%; min-width: 0; min-height: 0; overflow: clip; display: grid; background: var(--color-canvas); grid-template-rows: 46px minmax(0, 1fr) auto; } .chat-panel.is-rectification { grid-template-rows: 46px minmax(0, 1fr); } .chat-header { z-index: 2; min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: 0 var(--space-4); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); text-align: left; } .chat-header > div { min-width: 0; } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 160445c7..ebfed438 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1283,7 +1283,7 @@ export default function Home() { useEffect(() => { if (hydrated && accountId && !profileComplete && onboardingStep === "name" && presetMessageFinished && activeAccountDialog === null) { - composerInput.current?.focus(); + composerInput.current?.focus({ preventScroll: true }); } }, [accountId, activeAccountDialog, hydrated, onboardingStep, presetMessageFinished, profileComplete]); @@ -1360,7 +1360,7 @@ export default function Home() { setDraftTheme(theme ?? null); setDraftEntrypoint(entrypoint); setComposerNotice(""); - window.requestAnimationFrame(() => composerInput.current?.focus()); + window.requestAnimationFrame(() => composerInput.current?.focus({ preventScroll: true })); } async function startSuggestedConsultation( diff --git a/frontend/src/components/birth-time-choice-question.tsx b/frontend/src/components/birth-time-choice-question.tsx index a82173b0..c1e7aa7b 100644 --- a/frontend/src/components/birth-time-choice-question.tsx +++ b/frontend/src/components/birth-time-choice-question.tsx @@ -48,7 +48,10 @@ export function BirthTimeChoiceQuestion(props: ChoiceQuestionProps) { const firstChoiceRef = useRef(null); useEffect(() => { if (!props.pending && (props.progress.answeredCount > 0 || props.error)) { - firstChoiceRef.current?.focus(); + /* preventScroll: the transcript has its own anchor; letting the browser + scroll ancestors here scrolls `.chat-panel`, an overflow:hidden box with + no scrollbar, and the 46px header never comes back. */ + firstChoiceRef.current?.focus({ preventScroll: true }); } }, [props.error, props.pending, props.progress.answeredCount, props.question.questionId]); const select = (option: PublicDynamicChoiceQuestion["options"][number]) => { @@ -103,7 +106,7 @@ export function BirthTimeUnmatchedClarification(props: ClarificationProps) { const reframeRef = useRef(null); useEffect(() => { if (!props.pending && (props.progress.answeredCount > 0 || props.error)) { - reframeRef.current?.focus(); + reframeRef.current?.focus({ preventScroll: true }); } }, [props.error, props.pending, props.progress.answeredCount]); return ( diff --git a/frontend/src/components/birth-time-rectification.tsx b/frontend/src/components/birth-time-rectification.tsx index b608f12c..be957a80 100644 --- a/frontend/src/components/birth-time-rectification.tsx +++ b/frontend/src/components/birth-time-rectification.tsx @@ -59,7 +59,7 @@ export function BirthTimeRectification(props: BirthTimeRectificationProps) { if (!changed || props.journey.journeyProtocol !== "dynamic-choice-v2") return; const action = props.journey.nextAction; if (action.kind !== "ask_dynamic_choice" && action.kind !== "clarify_unmatched_answer") { - assessmentHeadingRef.current?.focus(); + assessmentHeadingRef.current?.focus({ preventScroll: true }); } }, [props.journey]); if (props.journey.journeyProtocol !== "dynamic-choice-v2") { diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 778dfa7c..e02917bd 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -1824,7 +1824,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const recalled = queued.take(); if (!recalled) return; setDraft(appendQueuedText(recalled, draft)); - composer.current?.focus(); + composer.current?.focus({ preventScroll: true }); } } : undefined} onSubmit={submit} onChange={(event) => setDraft(event.target.value)} diff --git a/frontend/src/components/rectification-board.tsx b/frontend/src/components/rectification-board.tsx index 8ad62933..1611f078 100644 --- a/frontend/src/components/rectification-board.tsx +++ b/frontend/src/components/rectification-board.tsx @@ -273,7 +273,10 @@ export function RectificationBoard({ useEffect(() => { if (!compact || !open) return; - sheetRef.current?.focus(); + /* preventScroll for the same reason as the choice options: this sheet + lives in the panel's grid cell, and the browser would scroll the + panel to reach it. */ + sheetRef.current?.focus({ preventScroll: true }); const handleKey = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); }; diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts index 2d5fa971..441e85d7 100644 --- a/frontend/src/hooks/use-consultation-run.ts +++ b/frontend/src/hooks/use-consultation-run.ts @@ -209,7 +209,7 @@ export function useConsultationRun(params: ConsultationRunParams) { const recalled = queued.take(); if (!recalled) return; setDraft(appendQueuedText(composerDraftSnapshot(), recalled)); - composerInput.current?.focus(); + composerInput.current?.focus({ preventScroll: true }); } @@ -442,7 +442,7 @@ export function useConsultationRun(params: ConsultationRunParams) { setRequestError(null); cancellationFeedbackRequest.current = pending.requestId; setComposerNotice("已停止,问题已放回输入框,正在确认点数…"); - window.requestAnimationFrame(() => composerInput.current?.focus()); + window.requestAnimationFrame(() => composerInput.current?.focus({ preventScroll: true })); if (pending.phase === "undo" || isPreview) { if (pendingConsultation.current?.requestId === pending.requestId) { diff --git a/frontend/tests/chat-composer-queue.test.ts b/frontend/tests/chat-composer-queue.test.ts index 2e2b9ac2..c3707fb4 100644 --- a/frontend/tests/chat-composer-queue.test.ts +++ b/frontend/tests/chat-composer-queue.test.ts @@ -58,7 +58,10 @@ test("generating does not disable the textarea; Enter queues instead of dropping assert.match(page, /if \(isLoading \|\| cancellationPending\) return void enqueueQueuedDraft\(composerDraftSnapshot\(\)\);/); assert.match(consultationRun, /queuedDraftSettleAction\(settlePhase\) === "send"/); assert.match(consultationRun, /function enqueueQueuedDraft/); - assert.match(consultationRun, /composerInput\.current\?\.focus\(\)/); + // 原值 `composerInput.current?.focus()` / 新值 `focus({ preventScroll: true })` + // / 原因:裸 focus 会让浏览器滚动所有可滚动祖先,包括 overflow 的 `.chat-panel`—— + // 线上实测把 46px 顶栏顶到了 y=-88 且无法滚回。焦点行为本身没变,只是不再连带滚动。 + assert.match(consultationRun, /composerInput\.current\?\.focus\(\{ preventScroll: true \}\)/); assert.match(rectification, /inputDisabled=\{readonly\}/); assert.doesNotMatch( diff --git a/frontend/tests/chat-panel-scroll-guard.test.ts b/frontend/tests/chat-panel-scroll-guard.test.ts new file mode 100644 index 00000000..10ffc59a --- /dev/null +++ b/frontend/tests/chat-panel-scroll-guard.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import test from "node:test"; + +/** + * The chat panel must never be scrollable, and nothing inside it may ask the + * browser to scroll an ancestor. + * + * Observed on staging: after answering three rectification questions the 46px + * header was gone. The grid was right (`46px 1297px`, panel at y=0) but the + * header's own rect was at **y = -88** — the panel had been scrolled 88px. + * `overflow: hidden` still creates a scroll container: it only removes the + * scrollbar, so a programmatic scroll sticks and the user cannot undo it. + * + * The cause was `BirthTimeChoiceQuestion` focusing the first option after every + * answered question. Focus scrolls every scrollable ancestor by default, and the + * transcript's own `useConversationScrollAnchor` was not the box that moved. + */ + +const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); + +function rule(selector: string): string { + const match = globalStyles.match(new RegExp(`^\\${selector} \\{([^}]*)\\}`, "m")); + assert.ok(match, `${selector} must exist in globals.css`); + return match![1]; +} + +test("the chat panel and app shell clip rather than hide, so neither is a scroll container", () => { + // `hidden` would pass a naive "does it overflow" check while still being + // scrollable; `clip` is the only value that makes the box unscrollable. + assert.match(rule(".chat-panel"), /overflow: clip/); + assert.match(rule(".chat-app"), /overflow: clip/); + assert.doesNotMatch(rule(".chat-panel"), /overflow: hidden/); + assert.doesNotMatch(rule(".chat-app"), /overflow: hidden/); +}); + +test("nothing inside the chat panel focuses without preventScroll", () => { + // Components that render inside `.chat-panel`. A focus() here reaches the + // panel through the default scroll-into-view; the transcript has its own + // anchor and does not want the browser's. + const insidePanel = [ + "src/app/page.tsx", + "src/hooks/use-consultation-run.ts", + "src/components/birth-time-choice-question.tsx", + "src/components/birth-time-rectification.tsx", + "src/components/rectification-agentic-chat.tsx", + "src/components/rectification-board.tsx", + ]; + /* Dialog focus management is the one legitimate bare focus() in these files: + an account dialog is an overlay above the panel, not content inside it, and + moving focus into it must not be suppressed. */ + const dialogFocus = /closeButton|returnTarget|focusTrap/; + for (const path of insidePanel) { + const source = readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); + for (const [line] of source.matchAll(/^.*\.focus\((.*)$/gm)) { + if (/focus-visible|:focus|onFocus/.test(line)) continue; + if (dialogFocus.test(line)) continue; + assert.match( + line, + /focus\(\{ preventScroll: true \}\)/, + `${path}: ${line.trim()}\n` + + "A bare focus() inside the chat panel scrolls it. Pass { preventScroll: true } " + + "and let useConversationScrollAnchor own the transcript's scroll position.", + ); + } + } +}); + +test("the guard covers every component that renders inside the panel today", () => { + // If a new rectification/consultation component starts calling focus(), this + // list has to grow with it — otherwise the contract above silently stops + // covering the surface it was written for. + const components = readdirSync(new URL("../src/components/", import.meta.url), { recursive: true, encoding: "utf8" }) + .filter((entry) => entry.endsWith(".tsx") && /rectification|birth-time|chat-/.test(entry)); + const missing: string[] = []; + for (const entry of components) { + const source = readFileSync(new URL(`../src/components/${entry}`, import.meta.url), "utf8"); + for (const [line] of source.matchAll(/^.*\.focus\((.*)$/gm)) { + if (/focus-visible|:focus|onFocus/.test(line)) continue; + if (!/preventScroll: true/.test(line)) missing.push(`${entry}: ${line.trim()}`); + } + } + assert.deepEqual(missing, [], "these focus() calls inside the chat surface would scroll the panel"); +}); diff --git a/frontend/tests/composer-isolation-contract.test.ts b/frontend/tests/composer-isolation-contract.test.ts index 3668a603..c04cebea 100644 --- a/frontend/tests/composer-isolation-contract.test.ts +++ b/frontend/tests/composer-isolation-contract.test.ts @@ -69,7 +69,10 @@ test("the composer keeps its Chinese input, focus and accessibility contract", ( assert.match(pageSource, /const composerInput = useRef\(null\)/); assert.match(pageSource, /inputRef=\{composerInput\}/); assert.match(composerSource, //);