From 317e9f1886fe80c00b00454b82cbe818d7e22634 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 16 Sep 2026 08:55:51 +0000 Subject: [PATCH] =?UTF-8?q?fix(rectification):=20=E9=A1=B6=E6=A0=8F?= =?UTF-8?q?=E8=A2=AB=20focus=20=E6=BB=9A=E5=87=BA=E8=A7=86=E9=87=8E?= =?UTF-8?q?=E2=80=94=E2=80=94=E9=9D=A2=E6=9D=BF=E6=94=B9=20clip=EF=BC=8C?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E5=86=85=20focus=20=E4=B8=80=E5=BE=8B=20prev?= =?UTF-8?q?entScroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 产品实测:生时校正答了三题后 46px 顶栏消失。devtools 取证——栅格是对的 (rows "46px 1297px"、panel y=0),但 header 自己的 rect 在 **y=-88**: 面板被程序化滚动了 88px。 根因:`overflow: hidden` 仍然是滚动容器,它只是去掉了滚动条。 BirthTimeChoiceQuestion 在每答完一题后 focus 新题的第一个选项,浏览器 为把它带进视野会滚动所有可滚动祖先,`.chat-panel` 就是其中之一—— 而用户没有滚动条可以滚回来,顶栏于是永久消失。 两处都修: - 病因:面板内 7 处程序化 focus 一律加 { preventScroll: true }。 消息区有自己的 useConversationScrollAnchor,本来就不需要浏览器代劳。 账户弹窗的 closeButton / returnTarget 不在此列——那是对话框焦点管理。 同一教训 use-billing-panel.ts 已经吃过一次(那里早写了 preventScroll)。 - 结构:.chat-panel 与 .chat-app 从 overflow:hidden 改成 overflow:clip。 clip 根本不创建滚动容器,此后任何 focus / scrollIntoView 都无法位移它。 新增 tests/chat-panel-scroll-guard.test.ts:锁住两个容器必须是 clip、 面板内不得有裸 focus(),并遍历 rectification/birth-time/chat- 全部组件, 新组件再写裸 focus 会直接打红。 测试 3372(+3),fail 仍 31 且与基线逐条一致;四个路由标记不变。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P --- frontend/src/app/globals.css | 8 +- frontend/src/app/page.tsx | 4 +- .../components/birth-time-choice-question.tsx | 7 +- .../components/birth-time-rectification.tsx | 2 +- .../components/rectification-agentic-chat.tsx | 2 +- .../src/components/rectification-board.tsx | 5 +- frontend/src/hooks/use-consultation-run.ts | 4 +- frontend/tests/chat-composer-queue.test.ts | 5 +- .../tests/chat-panel-scroll-guard.test.ts | 84 +++++++++++++++++++ .../tests/composer-isolation-contract.test.ts | 5 +- 10 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 frontend/tests/chat-panel-scroll-guard.test.ts 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, //);