没有待出提示时不再扫整页(BUG-1446)。
提示都看过、清单已关、或正在写回答时,不挂监听。需要时只盯锚点旁边,不再跟着每个字扫整页。 检查:onboarding-hint-observer 与 onboarding-checklist-20261022。 结果:14 通过,0 失败,退出码 0。 Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
10ecb091a8
commit
846ed6cca6
@@ -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`。
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -29,7 +29,10 @@ export function OnboardingHintLayer() {
|
||||
});
|
||||
}, [dismissHint]);
|
||||
|
||||
const hintWatch = journey.hintWatch;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hintWatch) return;
|
||||
const scan = () => {
|
||||
const anchors: Record<string, boolean> = {};
|
||||
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<Element>();
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<LiveState>) => 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<OnboardingJourney | null>(null);
|
||||
export const OnboardingJourneyContext = createContext<OnboardingJourney | null>(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 <OnboardingJourneyContext.Provider value={value}>{children}</OnboardingJourneyContext.Provider>;
|
||||
|
||||
@@ -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> = {}): 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(
|
||||
<OnboardingJourneyContext.Provider value={journey(shouldAttachHintObserver({
|
||||
checklistClosed: false,
|
||||
streaming: false,
|
||||
facts: facts({ hintsSeen: [...ONBOARDING_HINT_IDS], followUpArmed: true }),
|
||||
}))}>
|
||||
<OnboardingHintLayer />
|
||||
</OnboardingJourneyContext.Provider>,
|
||||
);
|
||||
await harness.idle();
|
||||
await harness.render(
|
||||
<OnboardingJourneyContext.Provider value={journey(shouldAttachHintObserver({
|
||||
checklistClosed: true,
|
||||
streaming: false,
|
||||
facts: armed,
|
||||
}))}>
|
||||
<OnboardingHintLayer />
|
||||
</OnboardingJourneyContext.Provider>,
|
||||
);
|
||||
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 (
|
||||
<OnboardingJourneyContext.Provider value={journey(hintWatch)}>
|
||||
<OnboardingHintLayer />
|
||||
<div>{Array.from({ length: count }, (_, index) => <span key={index}>字</span>)}</div>
|
||||
</OnboardingJourneyContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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(<StreamingPage count={0} hintWatch={shouldAttachHintObserver({
|
||||
checklistClosed: false,
|
||||
streaming: false,
|
||||
facts: armed,
|
||||
})} />);
|
||||
await harness.idle();
|
||||
streaming = true;
|
||||
for (let count = 1; count <= 200; count += 1) {
|
||||
await harness.render(<StreamingPage count={count} hintWatch={shouldAttachHintObserver({
|
||||
checklistClosed: false,
|
||||
streaming: true,
|
||||
facts: armed,
|
||||
})} />);
|
||||
}
|
||||
await harness.idle();
|
||||
assert.equal(scans, 0);
|
||||
assert.equal(harness.errors.length, 0, harness.errors.map(String).join("\n"));
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user