From 846ed6cca645a9e50f3f9d72e113d3a0d37ef227 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Mon, 26 Oct 2026 23:14:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B2=A1=E6=9C=89=E5=BE=85=E5=87=BA=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=97=B6=E4=B8=8D=E5=86=8D=E6=89=AB=E6=95=B4=E9=A1=B5?= =?UTF-8?q?=EF=BC=88BUG-1446=EF=BC=89=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提示都看过、清单已关、或正在写回答时,不挂监听。需要时只盯锚点旁边,不再跟着每个字扫整页。 检查:onboarding-hint-observer 与 onboarding-checklist-20261022。 结果:14 通过,0 失败,退出码 0。 Co-Authored-By: Grok (agent loop) --- docs/BUG_HISTORY.md | 16 ++ ...PROGRESS-ui-kit-onboarding-fix-20261026.md | 8 + docs/tasks/README.md | 2 +- .../src/components/onboarding-hint-layer.tsx | 27 +++- frontend/src/lib/onboarding-hints.ts | 10 ++ frontend/src/lib/onboarding-journey.tsx | 12 +- .../tests/onboarding-hint-observer.test.tsx | 145 ++++++++++++++++++ 7 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 frontend/tests/onboarding-hint-observer.test.tsx diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index dd63ca18..89e1eea4 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19658,3 +19658,19 @@ - 复发自:无 - 修复版本:分支 `codex/ui-kit-onboarding-fix-20261026`。 +## BUG-1446 | 一次性提示对每次页面变化都扫整页 + +- 状态:resolved +- 首次发现:2026-10-26 +- 最近更新:2026-10-26 +- 影响面:所有已登录用户。提示文案和出现条件没改。回答正在生成时本来就不该出提示。 +- 用户现象:没有要看的提示时,页面每变一个字也会被扫一遍。长回答生成时尤其明显。 +- 触发条件:提示层一直对整个页面挂着变化监听,包括提示都看过、清单已经关掉、以及回答还在往外写的时候。 +- 根因:监听挂在 `document.body` 上,连页面深处的每个新字都算一次变化,然后重新找锚点。 +- 修复:提示都看过、清单已关、或当前没有能出的提示时,不挂监听。回答正在生成时也不扫描。需要看的时候只盯锚点旁边的容器;弹窗只看页面最外层新加的一层,不再跟着回答里的每个字走。 +- 验证:`frontend/tests/onboarding-hint-observer.test.tsx` 与 `frontend/tests/onboarding-checklist-20261022.test.ts` 共 14 通过、0 失败、退出码 0。全满或清单已关时监听数是 0。连续画出 200 个回答片段时,扫描次数是 0。 +- 防复发:上面的监听计数和扫描计数。再对整页深层挂监听,源码检查会失败。 +- 相关记录:issue #111 验收 U3。#97。红线 6。 +- 复发自:无 +- 修复版本:分支 `codex/ui-kit-onboarding-fix-20261026`。 + diff --git a/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md b/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md index dbacf402..3f8aa48f 100644 --- a/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md +++ b/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md @@ -23,3 +23,11 @@ | 选中态 | `node node_modules/tsx/dist/cli.mjs --test tests/language-switch-selected.test.tsx`;同一命令再跑 `tests/report-buttons-scope-20260928.test.ts tests/class-name-definition-contract.test.ts` | 1 通过;随后 9 通过。两轮都是 0 失败,退出码 0 | 断言三栏(`report-buttons-scope-20260928.test.ts`):原值选中规则只认 `aria-checked="true"`。新值认 `data-active` 或 `aria-selected="true"`。原因是页签不设 `aria-checked`,旧规则对不上。 + +## U3 · 没有待出提示时不再扫整页(BUG-1446) + +提示都看过、清单已关、或当前没有能出的提示时,不挂监听。回答正在生成时不扫描。需要时只盯锚点旁边的容器。 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 提示监听 | `node node_modules/tsx/dist/cli.mjs --test tests/onboarding-hint-observer.test.tsx tests/onboarding-checklist-20261022.test.ts` | 14 通过,0 失败,退出码 0 | diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 18f1a90f..f60b5733 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -40,7 +40,7 @@ | `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` | | `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `codex/chart-table-span-20261023` | | `TASK-ui-kit-migration-20261023.md` | `PROGRESS-ui-kit-0-base-20261023.md`;`PROGRESS-ui-kit-1-shell-20261023.md`;`PROGRESS-ui-kit-2-chat-20261023.md`;`PROGRESS-ui-kit-3-chart-20261023.md`;`PROGRESS-ui-kit-4-people-20261023.md`;`PROGRESS-ui-kit-5-pages-20261023.md`;`PROGRESS-ui-kit-6-rectification-20261023.md`;`PROGRESS-ui-kit-7-cleanup-20261023.md` | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | K0–K7 已在测试环境,待验收 | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG | -| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1–U2 已提交) | `codex/ui-kit-onboarding-fix-20261026` | +| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1–U3 已提交) | `codex/ui-kit-onboarding-fix-20261026` | | —(门禁修复,无新任务书) | `PROGRESS-birth-journey-contract-20261025.md` | **测试分支过不了门禁,网站停在完整数据版(二)**:BUG-1439 出生时间合同;BUG-1440 测试画布;BUG-1441 侧栏流式检查等第一次补画结束再数帧。页面不改 | 已合入 staging,等门禁 | `codex/gate-birth-journey-contract-20261025` | | `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md`;`PROGRESS-onboarding-2-intake-20261022.md`;`PROGRESS-onboarding-4-motion-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022`。(二)T3+T4 记 BUG-1431,在 `codex/onboarding-2-intake-20261022`。(四)T7 在 `codex/onboarding-4-motion-20261022` | (一)(二)(三)已在 staging;(四)本分支待验收 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U | | `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd | diff --git a/frontend/src/components/onboarding-hint-layer.tsx b/frontend/src/components/onboarding-hint-layer.tsx index edfcf03d..01a724b2 100644 --- a/frontend/src/components/onboarding-hint-layer.tsx +++ b/frontend/src/components/onboarding-hint-layer.tsx @@ -29,7 +29,10 @@ export function OnboardingHintLayer() { }); }, [dismissHint]); + const hintWatch = journey.hintWatch; + useEffect(() => { + if (!hintWatch) return; const scan = () => { const anchors: Record = {}; for (const name of ANCHOR_NAMES) { @@ -46,12 +49,32 @@ export function OnboardingHintLayer() { }; const timer = window.setTimeout(scan, 0); const observer = new MutationObserver(scan); - observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["role", "data-slot", "hidden", "open", "data-onboarding-anchor", "data-onboarding-hint"] }); + const seen = new Set(); + const watchNode = (node: Element | null, subtree: boolean) => { + if (!node || seen.has(node)) return; + seen.add(node); + if (subtree) { + observer.observe(node, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["role", "data-slot", "hidden", "open", "data-onboarding-anchor", "data-onboarding-hint"], + }); + return; + } + observer.observe(node, { childList: true, subtree: false }); + }; + for (const name of ANCHOR_NAMES) { + watchNode(document.querySelector(`[data-onboarding-anchor="${name}"]`)?.parentElement ?? null, true); + } + if (seen.size === 0) watchNode(document.querySelector(".shell-frame"), true); + // Portaled dialogs sit on the body. Direct children only, so a streaming reply inside the shell does not scan. + watchNode(document.body, false); return () => { window.clearTimeout(timer); observer.disconnect(); }; - }, [hint, setLive]); + }, [hint, hintWatch, setLive]); useEffect(() => { if (!hint || !anchor) return; diff --git a/frontend/src/lib/onboarding-hints.ts b/frontend/src/lib/onboarding-hints.ts index 434b7f78..918ceed9 100644 --- a/frontend/src/lib/onboarding-hints.ts +++ b/frontend/src/lib/onboarding-hints.ts @@ -158,3 +158,13 @@ export function selectHint(facts: HintFacts): OnboardingHintId | null { export function queuedHint(facts: HintFacts): OnboardingHintId | null { return eligible({ ...facts, blocked: false, anchors: {} }); } + +/** No observer when every hint was seen, the checklist was closed, nothing is eligible, or an answer is streaming. */ +export function shouldAttachHintObserver(input: { + readonly checklistClosed: boolean; + readonly streaming: boolean; + readonly facts: HintFacts; +}): boolean { + if (input.checklistClosed || input.streaming) return false; + return queuedHint(input.facts) != null; +} diff --git a/frontend/src/lib/onboarding-journey.tsx b/frontend/src/lib/onboarding-journey.tsx index 03131902..069625aa 100644 --- a/frontend/src/lib/onboarding-journey.tsx +++ b/frontend/src/lib/onboarding-journey.tsx @@ -30,6 +30,7 @@ import { hintsBlocked, nextEphemerisPhase, selectHint, + shouldAttachHintObserver, showChartMenuDot, synReadyFact, tabHasDot, @@ -100,9 +101,11 @@ export type OnboardingJourney = { readonly setChecklistOpen: (open: boolean) => void; readonly setLive: (patch: Partial) => void; readonly registerHome: (actions: HomeActions | null) => void; + /** False when no hint can appear, the checklist was closed, or an answer is streaming. */ + readonly hintWatch: boolean; }; -const OnboardingJourneyContext = createContext(null); +export const OnboardingJourneyContext = createContext(null); const INITIAL_REMOTE: RemoteSnapshot = { status: "loading", @@ -538,6 +541,11 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea }; const activeHint = selectHint(hintFacts); const hintText = activeHint ? hintCopy(activeHint, live.chartSubjectName) : ""; + const hintWatch = shouldAttachHintObserver({ + checklistClosed: Boolean(merged.checklistDismissedAt), + streaming: live.streaming, + facts: hintFacts, + }); const showChecklist = remote.status === "ready" && shouldShowChecklist({ profileComplete, @@ -647,6 +655,7 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea setChecklistOpen, setLive, registerHome, + hintWatch, }), [ showChecklist, items, @@ -664,6 +673,7 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea setChecklistOpen, setLive, registerHome, + hintWatch, ]); return {children}; diff --git a/frontend/tests/onboarding-hint-observer.test.tsx b/frontend/tests/onboarding-hint-observer.test.tsx new file mode 100644 index 00000000..91703f88 --- /dev/null +++ b/frontend/tests/onboarding-hint-observer.test.tsx @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { OnboardingHintLayer } from "../src/components/onboarding-hint-layer.tsx"; +import { shouldAttachHintObserver, type HintFacts } from "../src/lib/onboarding-hints.ts"; +import { OnboardingJourneyContext, type OnboardingJourney } from "../src/lib/onboarding-journey.tsx"; +import { ONBOARDING_HINT_IDS } from "../src/lib/onboarding-state.ts"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; + +const layerSource = readFileSync(new URL("../src/components/onboarding-hint-layer.tsx", import.meta.url), "utf8"); + +function facts(patch: Partial = {}): HintFacts { + return { + hintsSeen: [], + followUpArmed: false, + chartVisited: false, + sidebarOpen: false, + activeChartTab: null, + synReady: false, + ephemerisArmed: false, + blocked: false, + anchors: {}, + ...patch, + }; +} + +function journey(hintWatch: boolean): OnboardingJourney { + return { + showChecklist: false, + items: [], + label: "", + celebration: false, + activeHint: null, + hintText: "", + showChartDot: false, + tabHasDot: () => false, + dismissChecklist: () => {}, + dismissHint: async () => true, + go: () => {}, + setChartTab: () => {}, + markChartVisited: () => {}, + setChecklistOpen: () => {}, + setLive: () => {}, + registerHome: () => {}, + hintWatch, + }; +} + +function installObserverCount() { + let live = 0; + class CountingObserver { + constructor(_callback: MutationCallback) { live += 1; } + observe() {} + disconnect() { live -= 1; } + takeRecords() { return []; } + } + globalThis.MutationObserver = CountingObserver as unknown as typeof MutationObserver; + return () => live; +} + +test("a full hint list or a closed checklist attaches no observer", async () => { + assert.doesNotMatch(layerSource, /observe\(\s*document\.body,\s*\{[^}]*subtree:\s*true/); + const armed = facts({ followUpArmed: true, sidebarOpen: true }); + assert.equal(shouldAttachHintObserver({ + checklistClosed: false, + streaming: false, + facts: facts({ hintsSeen: [...ONBOARDING_HINT_IDS], followUpArmed: true }), + }), false); + assert.equal(shouldAttachHintObserver({ checklistClosed: true, streaming: false, facts: armed }), false); + assert.equal(shouldAttachHintObserver({ checklistClosed: false, streaming: false, facts: armed }), true); + + const live = installObserverCount(); + const harness = createClientLifecycleHarness(); + try { + await harness.render( + + + , + ); + await harness.idle(); + await harness.render( + + + , + ); + await harness.idle(); + assert.equal(live(), 0); + assert.equal(harness.errors.length, 0, harness.errors.map(String).join("\n")); + } finally { + await harness.close(); + } +}); + +function StreamingPage({ count, hintWatch }: { count: number; hintWatch: boolean }) { + return ( + + +
{Array.from({ length: count }, (_, index) => 字)}
+
+ ); +} + +test("two hundred streaming fragments do not scan for hints", async () => { + const armed = facts({ followUpArmed: true, sidebarOpen: true }); + assert.equal(shouldAttachHintObserver({ checklistClosed: false, streaming: true, facts: armed }), false); + const harness = createClientLifecycleHarness(); + let streaming = false; + let scans = 0; + const countScan = (selector: string) => { + if (streaming && selector.includes("data-onboarding")) scans += 1; + return selector.includes("All") ? [] : null; + }; + harness.document.querySelector = ((selector: string) => countScan(selector)) as typeof harness.document.querySelector; + harness.document.querySelectorAll = ((selector: string) => countScan(selector) ?? []) as typeof harness.document.querySelectorAll; + try { + await harness.render(); + await harness.idle(); + streaming = true; + for (let count = 1; count <= 200; count += 1) { + await harness.render(); + } + await harness.idle(); + assert.equal(scans, 0); + assert.equal(harness.errors.length, 0, harness.errors.map(String).join("\n")); + } finally { + await harness.close(); + } +});