顺手收掉死样式、旧校正入口和整页跳转。

测试文档只在需要的测试里安装。提示和圆点按项分开。清单「去做」走客户端跳转。输入框高亮在离开页面时清掉。六个没有入口的旧校正组件删掉。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-27 01:39:28 +08:00
co-authored by Grok (agent loop)
parent d7e8cc46c2
commit 6998dc6ead
45 changed files with 133 additions and 788 deletions
+1 -1
View File
@@ -653,7 +653,7 @@
- **`Home` 要真正受益很可能得拆组件,属业务代码重构,本轮明令禁止。** 诊断结果进一步说明这条路该怎么走:为迎合编译器去改 `Home`(删 `finally`、重排 6 个被声明前引用的函数、逐个绕内部断言)是被编译器 bug 牵着走的打地鼠,每步都拿确定的正确性换不确定的收益;值得做的是按职责把 `Home` 拆小。 证据是同一文件里两个小组件(原始行 760、815)被正常编译、全项目另有 44 个函数被编译,唯独这个 2730 行、24 个 `useState`、18 个 `useEffect` 的函数被拒。任务书要求「哪处代码挡住了编译器就写进 BLOCKED.md,不要自行改写」,故 `frontend/src/**` 一行业务代码未动(仅反向验证时临时加删一行 `"use no memo"`,已还原,`git diff` 为空)。
- **~~待决策~~ 已决策(2026-08-17):维持回滚,不为那 44 个组件保留 `reactCompiler: true`;若要翻案,前置条件是先测出那 44 个的渲染基准。** 本轮按任务 2 止损条款已回滚该配置。数据是:构建耗时无可测量变化(噪声内),`/` 首屏 JS gzip +12031 B / +2.50 %,换来 44 个函数自动记忆化,其中 `app-sidebar`(112 槽)、`sidebar-session-row`(73 槽)、`birth-date-picker`(58 槽)与三个引导 hook 都在 `/` 上渲染。这 44 个的实际运行时收益本轮未测量(无渲染基准),所以不替决策者拍板。重新开启只需在 `frontend/next.config.ts` 顶层加 `reactCompiler: true`、`experimental` 里加 `turbopackRustReactCompiler: true`。
- **`compilationMode: "all"` 不可用,会破坏构建。** 它会编译模块作用域的普通回调,`frontend/src/components/birth-time-intake.tsx:22` 的 `Array.from({ length: 24 }, (_, index) => ...)` 被插入 `useMemoCache`,预渲染 `/` 时抛 `TypeError: Cannot read properties of null (reading 'useMemoCache')`。这是该模式本身的性质(官方标注为不安全),不是本仓库代码的缺陷,未做任何改动。
- **顺手活一律未做,登记在此:** 其一,`frontend/src/lib/skill-package-registry.ts:523` 的 `readFileSync(currentRegistryPath, "utf8")` 触发 Turbopack 构建警告「Dynamic filesystem access causes tracing of the whole project」,会把整个项目(含 `public/`)打进 server 产物,影响部署体积;升级前后都存在,与本轮无关,未改。其二,`npx eslint` 有 4 个既有 `no-unused-vars` warning,分别在 `birth-time-candidate-result.tsx:143`、~~`birth-time-candidate-completion.ts:10,11`~~(2026-10-13 已随该文件删除)、`tests/identity-auth-factory.test.ts:48`,未改。其三,`eslint-config-next` 仍是 16.2.10、与 next 16.3.1 版本号不同步,但 eslint 实测 0 error,按「不许顺手升别的依赖」未动。
- **顺手活一律未做,登记在此:** 其一,`frontend/src/lib/skill-package-registry.ts:523` 的 `readFileSync(currentRegistryPath, "utf8")` 触发 Turbopack 构建警告「Dynamic filesystem access causes tracing of the whole project」,会把整个项目(含 `public/`)打进 server 产物,影响部署体积;升级前后都存在,与本轮无关,未改。其二,`npx eslint` 有 4 个既有 `no-unused-vars` warning,分别在 ~~`birth-time-candidate-result.tsx:143`~~(2026-10-27 该组件已删)、~~`birth-time-candidate-completion.ts:10,11`~~(2026-10-13 已随该文件删除)、`tests/identity-auth-factory.test.ts:48`,其余未改。其三,`eslint-config-next` 仍是 16.2.10、与 next 16.3.1 版本号不同步,但 eslint 实测 0 error,按「不许顺手升别的依赖」未动。
- **测试环境噪声(非阻塞,已自行消化):** `frontend/tests/rectification-v9-database.test.ts` 的「v9 migration applies on a fresh database and re-applies idempotently」在全量并发下偶发失败(`database migration failed`,1 !== 0),单独重跑 7/7 通过、全量重跑 1592/1592 通过。靠 Docker 起临时 Postgres,判定为资源争用型 flake,与 Next 版本无关。未改任何测试文件。
- **文件名偏离:** 任务书要求新建 `PROGRESS.md`,但根目录已有受版本控制的 `progress.md`(1025 行)且本机文件系统大小写不敏感,写 `PROGRESS.md` 等于覆盖清单外的文件,故进度记录落在 `PROGRESS-react-compiler-20260817.md`。
@@ -103,3 +103,19 @@
断言三栏(引导页是否预载):原值是目录还没到也按空列表预载。新值是已知空列表才预载,未知不预载、步骤停在称呼。原因是未知不能当成没建完档。
断言三栏(输入框标签和禁用):原值认 `onboardingStep`。新值认 `shownOnboardingStep`,并在目录未到时不收起输入框。原因同上。称呼和发送的两句文案没改。
## 顺手项
没有新的产品故障,所以没有新的 BUG 编号。
- 测试用的文档只在需要文档的测试里安装。做服务端渲染的七个测试文件不再因为导入测试工具就装上文档。
- 外观菜单里已经没人用的 `.theme-menu-check` 删了。选中勾仍由菜单自己画出来。
- 校正里选了「这题跳过」时,停止按钮呈选中。
- 六个没有入口的旧校正组件删了:引导轮、旧选项题、候选结果、证据草稿、旧校正页、更早的旧校正页。首页不再预热其中那一页。
- 提示和星盘圆点按每一项分开,不再共用同一个键。
- 清单「去做」在版本一致时走页面内跳转。版本对不上时仍整页打开。
- 点第三问把输入框点亮后,离开页面会把计时器清掉。
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 上面这几处,加上首页行数和外观菜单 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs --test` 跑清单、校正表面、清单刷新、首屏揭幕、滚动、输入法、字数、旧引导、起步问、首页行数、外观、首屏屏、起步页、提示监听、分栏挂载 | 85 通过,0 失败,0 取消,退出码 0 |
+1 -1
View File
@@ -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–U6、U9、U10、U11 已提交) | `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–U6、U9、U10、U11 与顺手项已提交) | `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 |
+15 -6
View File
@@ -201,6 +201,14 @@ const BillingPanel = dynamic(loadBillingPanel, {
loading: () => <p className="secondary-page-waiting">账户信息还没拿到。</p>,
});
let nudgeComposerTimer: number | undefined;
function clearNudgeComposerTimer() {
if (nudgeComposerTimer === undefined) return;
window.clearTimeout(nudgeComposerTimer);
nudgeComposerTimer = undefined;
}
export default function Home() {
const sessionList = useSessionList();
const {
@@ -706,10 +714,7 @@ export default function Home() {
return () => window.clearTimeout(timer);
}, [bootstrapPhase, bootstrapPrepareReady, hydrated]);
useEffect(() => {
if (bootstrapPhase !== "prepare" || uiPreview.current) return;
void import("@/components/birth-time-rectification");
}, [bootstrapPhase]);
useEffect(() => () => { clearNudgeComposerTimer(); }, []);
useEffect(() => {
if (!hydrated || uiPreview.current) return;
@@ -1075,7 +1080,11 @@ export default function Home() {
if (!input) return;
input.classList.add("is-starter-focus");
input.focus({ preventScroll: true });
window.setTimeout(() => input.classList.remove("is-starter-focus"), 700);
clearNudgeComposerTimer();
nudgeComposerTimer = window.setTimeout(() => {
nudgeComposerTimer = undefined;
input.classList.remove("is-starter-focus");
}, 700);
}
async function copyAssistantMessage(renderKey: string, text: string) {
@@ -1270,7 +1279,7 @@ export default function Home() {
<header className="chat-header">
<span className="onboarding-menu-anchor">
<SidebarTrigger placement="inset" />
<OnboardingChartDot />
<OnboardingChartDot playKey="home-chart" />
</span>
<div className="chat-header-title">
<strong>{activeSession?.title || "新对话"}</strong>
-2
View File
@@ -1974,9 +1974,7 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
.theme-menu { display: grid; gap: 1px; padding: var(--space-1) 0; }
.theme-menu-label { padding: var(--space-1) var(--space-3); color: var(--color-ink-tertiary); font-size: var(--type-caption); font-weight: 500; }
.theme-menu-item { cursor: pointer; }
.theme-menu-check { display: inline-grid; margin-left: auto; place-items: center; color: var(--color-action); }
.theme-menu [data-slot="dropdown-menu-radio-item-indicator"] { color: var(--color-action); }
.theme-menu-check svg { width: 15px; height: 15px; }
.account-menu-separator { height: 1px; margin: var(--space-2) var(--space-3); background: var(--color-border); }
.account-info-list { display: grid; gap: var(--space-3); margin: var(--space-5) 0 0; }
@@ -1,159 +0,0 @@
"use client";
import type { JourneyClientResponse } from "@/lib/birth-time-journey-client";
import type { BirthTimeGuidedController } from "@/hooks/use-birth-time-guided-journey";
import { guidedTerminalPath } from "@/lib/birth-time-guided-terminal";
import { ErrorToast } from "@/components/error-toast";
import { Button } from "@/components/ui/button";
type CandidateResultProps = {
readonly journey: JourneyClientResponse;
readonly controller: BirthTimeGuidedController;
readonly error: string;
};
const confidenceLabels = {
low: "证据不足",
medium: "中等置信",
high: "较高置信",
} as const;
const gateLabels: Readonly<Record<string, string>> = {
event_quality: "经历质量",
cross_domain_coverage: "跨领域覆盖",
required_layers: "必需计算层",
neighbor_stability: "相邻分钟稳定性",
leave_one_event_out: "删除单条经历复算",
three_engine_input_parity: "三引擎同输入对照",
public_holdout_release: "公开 AA 盲测",
};
const gateStatusLabels: Readonly<Record<string, string>> = {
pass: "通过",
fail: "未通过",
blocked: "尚未具备条件",
not_evaluated: "尚未执行",
};
export function BirthTimeCandidateResult({ journey, controller, error }: CandidateResultProps) {
const result = journey.candidateResult;
const action = journey.nextAction;
const dynamic = journey.journeyProtocol === "dynamic-choice-v2";
const terminalPath = guidedTerminalPath(journey);
if (result?.eventCount === 0) {
return (
<div className="birth-time-candidate-result" aria-live="polite">
<p className="birth-time-assessment-unavailable">尚未进入分钟计算:还没有可评分的关键经历资料。</p>
<p className="birth-time-evidence-boundary">当前范围仅为填报范围,不是校正结果;请补充跨领域、可注明年月的重大事件后重新评估。</p>
{terminalPath && <Button className="button-secondary birth-time-guided-action" disabled={controller.pending} type="button" variant="outline" onClick={controller.editBirthTimeDetails}>补充资料并重新评估</Button>}
</div>
);
}
if (!result && action.kind === "present_low_result") {
return (
<div className="birth-time-candidate-result" aria-live="polite">
<p className="birth-time-evidence-boundary">系统不会选择或应用未经证据支持的具体分钟,<span className="phrase-nowrap">当前排盘使用时间</span>保持不变。</p>
{terminalPath && <TerminalAction controller={controller} error={error} path={terminalPath} />}
{!terminalPath ? <ErrorToast message={error} /> : null}
</div>
);
}
if (!result) return null;
const winner = result.winningSegment;
const receipt = result.techniqueReceipt;
return (
<div className="birth-time-candidate-result" aria-live="polite">
<div className="birth-time-candidate-heading">
<b>{dynamic ? "候选范围评估结果" : "关键经历评分结果"}</b>
<span>{confidenceLabels[result.confidence]}</span>
</div>
{winner ? (
<dl className="birth-time-candidate-grid">
<div><dt>候选时间范围</dt><dd>{winner.startTime}—{winner.endTime}</dd></div>
<div><dt>候选代表时间</dt><dd>{winner.representativeTime}</dd></div>
{!dynamic && <div><dt>有效经历</dt><dd>{result.eventCount} 条 / {result.domainCount} 个领域</dd></div>}
{!dynamic && <div><dt>领先幅度</dt><dd>{result.marginPercent}%</dd></div>}
</dl>
) : (
<p className="birth-time-assessment-unavailable">候选仍然并列或缺少必要计算层,系统不会选择具体分钟。</p>
)}
<p className="birth-time-evidence-boundary">这是可复现的候选评分,不代表已经证明出生记录中的具体分钟。</p>
{receipt && (
<details className="birth-time-evidence-receipt">
<summary>本次计算回执</summary>
<p>已用:{[...receipt.usedDivisionalCharts, ...receipt.usedArudha, ...receipt.dashaTracks].join("、") || "无"}</p>
<p>辅助:{receipt.auxiliaryLayers.join("、") || "无"}</p>
<p>未完成:{receipt.missingLayers.join("、") || "无"}</p>
{receipt.gates && (
<ul>
{Object.entries(receipt.gates).map(([name, gate]) => (
<li key={name}>{gateLabels[name] ?? name}:{gateStatusLabels[gate.status] ?? gate.status}</li>
))}
</ul>
)}
{receipt.confirmationAllowed === false && <p>当前仅保留候选范围,分钟确认尚未开放。</p>}
</details>
)}
{action.kind === "present_low_result" && (
<div className="birth-time-candidate-terminal" role="status">
<p>{dynamic ? "目前没有足够的新信息继续稳定缩小范围,本次评估已结束并保存当前候选范围。" : "本轮校正已安全结束,只保留候选范围,当前排盘使用时间保持不变。"}</p>
{terminalPath && <TerminalAction controller={controller} error={error} path={terminalPath} />}
</div>
)}
{action.kind === "present_medium_result" && winner && dynamic && (
<div className="birth-time-candidate-terminal" role="status">
<p>已形成较窄的候选范围,本次评估已结束;它不会自动改动<span className="phrase-nowrap">当前排盘使用时间</span>。</p>
{terminalPath && <TerminalAction controller={controller} error={error} path={terminalPath} />}
</div>
)}
{action.kind === "present_medium_result" && winner && !dynamic && (
<div className="birth-time-candidate-terminal">
<p>可以保存候选时间范围,代表时间不会<span className="phrase-nowrap">用于</span>排盘。</p>
<Button className="button-primary birth-time-guided-action" disabled={controller.pending} type="button" onClick={() => controller.saveCandidate(result.resultId)}>
{controller.pending ? "保存中…" : "保存候选范围"}
</Button>
</div>
)}
{action.kind === "candidate_saved" && (
<div className="birth-time-candidate-terminal" role="status">
<p className="birth-time-success-note">候选时间范围已保存。<span className="phrase-nowrap">当前排盘使用时间</span>没有改变。</p>
{terminalPath && <TerminalAction controller={controller} error={error} path={terminalPath} />}
</div>
)}
{action.kind === "request_candidate_confirmation" && winner && (
<div className="birth-time-confirmation-panel">
<b>确认<span className="phrase-nowrap">当前排盘使用时间</span></b>
<p><span className="phrase-nowrap">候选时间</span>为 {winner.representativeTime}。确认后它会成为<span className="phrase-nowrap">当前排盘使用时间</span>,<span className="phrase-nowrap">原始填报时间</span>仍会保留。</p>
<Button className="button-primary birth-time-guided-action" disabled={controller.pending} type="button" onClick={() => controller.confirmCandidate(result.resultId, winner.representativeTime)}>
{controller.pending ? "确认中…" : `确认使用 ${winner.representativeTime}`}
</Button>
</div>
)}
{action.kind === "ready" && (
<div className="birth-time-candidate-terminal" role="status">
<p className="birth-time-success-note"><span className="phrase-nowrap">当前排盘使用时间</span>已更新为 {action.activeTime},<span className="phrase-nowrap">原始填报时间</span>仍已保留。</p>
<Button className="button-primary birth-time-guided-action" disabled={controller.pending} type="button" onClick={controller.acknowledgeReady}>继续使用此排盘</Button>
</div>
)}
{!terminalPath ? <ErrorToast message={error} /> : null}
</div>
);
}
function TerminalAction({ controller, error, path }: {
readonly controller: BirthTimeGuidedController;
readonly error: string;
readonly path: NonNullable<ReturnType<typeof guidedTerminalPath>>;
}) {
return (
<div className="birth-time-new-assessment">
<b>尚未达到采用条件</b>
<p>候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。</p>
<Button className="button-secondary birth-time-guided-action" disabled={controller.pending} onClick={controller.editBirthTimeDetails} type="button" variant="outline">开始新的评估</Button>
<ErrorToast message={error} />
<small>会建立新的记录,当前结果仍会保留。</small>
</div>
);
}
@@ -1,171 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { CharacterRemaining } from "@/components/character-remaining";
import { RectificationFade } from "@/components/rectification-fade";
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { characterRemainingVisible } from "@/lib/character-remaining";
import {
choiceQuestionGroups,
choiceSelectionIntent,
normalizeUnmatchedNote,
rangeLabel,
} from "@/lib/birth-time-choice-question-model";
import type {
DynamicJourneyProgress,
PublicDynamicChoiceQuestion,
} from "@/lib/birth-time-journey-turn-protocol";
import { ErrorToast } from "@/components/error-toast";
type SharedQuestionProps = {
readonly progress: DynamicJourneyProgress;
readonly pending: boolean;
readonly error: string;
readonly onPause: () => void;
readonly onFinish: () => void;
};
type ChoiceQuestionProps = SharedQuestionProps & {
readonly question: PublicDynamicChoiceQuestion;
readonly onSelect: (optionId: string) => void;
};
type ClarificationProps = SharedQuestionProps & {
readonly onReframe: (note: string) => void;
};
export function BirthTimeQuestionProgress({ progress }: {
readonly progress: DynamicJourneyProgress;
}) {
return (
<div className="birth-time-question-progress">
<b>已完成 {progress.effectiveAnswerCount} 个有效判断</b>
<span>当前候选范围:{rangeLabel(progress.currentRange)}</span>
</div>
);
}
export function BirthTimeChoiceQuestion(props: ChoiceQuestionProps) {
const groups = choiceQuestionGroups(props.question);
const [selectedId, setSelectedId] = useState("");
const firstChoiceRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!props.pending && (props.progress.answeredCount > 0 || props.error)) {
/* 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]) => {
const intent = choiceSelectionIntent(option);
setSelectedId(intent.optionId);
props.onSelect(intent.optionId);
};
return (
<RectificationFade>
<div className="birth-time-choice-surface" aria-busy={props.pending}>
<BirthTimeQuestionProgress progress={props.progress} />
<fieldset className="birth-time-choice-question" disabled={props.pending}>
<legend>{props.question.prompt}</legend>
<ToggleGroup
className="contents"
style={{ display: "contents" }}
value={selectedId ? [selectedId] : []}
onValueChange={(next) => {
const id = next[0];
if (!id || id === selectedId) return;
const option = [...groups.primary, groups.unknown, groups.unmatched].find((item) => item.optionId === id);
if (option) select(option);
}}
>
<div className="birth-time-primary-choices">
{groups.primary.map((option, index) => (
<ToggleGroupItem
className="birth-time-choice-option is-primary"
data-selected={selectedId === option.optionId}
key={option.optionId}
ref={index === 0 ? firstChoiceRef : undefined}
type="button"
value={option.optionId}
>
{option.label}
</ToggleGroupItem>
))}
</div>
<div className="birth-time-special-choices">
{[groups.unknown, groups.unmatched].map((option) => (
<ToggleGroupItem
className="birth-time-choice-option is-secondary"
data-selected={selectedId === option.optionId}
key={option.optionId}
type="button"
value={option.optionId}
>
{option.label}
</ToggleGroupItem>
))}
</div>
</ToggleGroup>
</fieldset>
{props.pending && <p className="birth-time-choice-pending" role="status">正在缩小候选范围…</p>}
<JourneyControls {...props} />
</div>
</RectificationFade>
);
}
const unmatchedRemainingId = "birth-time-unmatched-remaining";
export function BirthTimeUnmatchedClarification(props: ClarificationProps) {
const [note, setNote] = useState("");
const reframeRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!props.pending && (props.progress.answeredCount > 0 || props.error)) {
reframeRef.current?.focus({ preventScroll: true });
}
}, [props.error, props.pending, props.progress.answeredCount]);
return (
<RectificationFade>
<div className="birth-time-choice-surface" aria-busy={props.pending}>
<BirthTimeQuestionProgress progress={props.progress} />
<fieldset className="birth-time-choice-question" disabled={props.pending}>
<legend>这道题都不符合你的情况吗?</legend>
<label className="birth-time-unmatched-note">
<span className="character-remaining-row">
补充一句(可选)
<CharacterRemaining id={unmatchedRemainingId} length={note.length} maxLength={240} />
</span>
<textarea
maxLength={240}
aria-describedby={characterRemainingVisible(note.length, 240) ? unmatchedRemainingId : undefined}
onChange={(event) => setNote(event.target.value)}
placeholder="例如:这段变化发生得更早,或情况不太一样"
value={note}
/>
</label>
<div className="birth-time-reframe-actions">
<Button className="button-secondary birth-time-guided-action" onClick={() => props.onReframe("")} ref={reframeRef} type="button" variant="outline">换一道题</Button>
<Button className="button-primary birth-time-guided-action" onClick={() => props.onReframe(normalizeUnmatchedNote(note))} type="button">提交补充并换题</Button>
</div>
</fieldset>
{props.pending && <p className="birth-time-choice-pending" role="status">正在换一道更合适的问题…</p>}
<JourneyControls {...props} />
</div>
</RectificationFade>
);
}
function JourneyControls(props: SharedQuestionProps) {
return (
<>
<div className="birth-time-journey-controls">
<Button className="button-text birth-time-guided-action" disabled={props.pending} onClick={props.onPause} type="button" variant="ghost">暂停,稍后继续</Button>
<Button className="button-text birth-time-guided-action" disabled={props.pending} onClick={props.onFinish} type="button" variant="ghost">结束并保存当前范围</Button>
</div>
<ErrorToast message={props.error} />
</>
);
}
@@ -1,87 +0,0 @@
"use client";
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { lifeEventSchema } from "@/lib/birth-time-evidence";
import type { EvidenceDraft } from "@/lib/birth-time-journey-turn";
import type { EvidenceDatePrecision, LegacyGuidedEvidenceDomain } from "@/lib/birth-time-question-planner";
const domainLabels = {
education: "学业与学习环境",
relocation: "搬迁与长期居住地",
relationship: "重要关系",
career: "工作与身份变化",
finance: "收入、资产或资源变化",
health_pressure: "健康或生活压力",
} as const satisfies Readonly<Record<LegacyGuidedEvidenceDomain, string>>;
type DraftCardProps = {
readonly draft: EvidenceDraft;
readonly pending: boolean;
readonly onConfirm: (precision: EvidenceDatePrecision, date: string) => void;
readonly onSkip: () => void;
};
export function BirthTimeEvidenceDraftCard(props: DraftCardProps) {
const precisionLabelId = useId();
const [precision, setPrecision] = useState<EvidenceDatePrecision>(props.draft.precision ?? "year");
const [date, setDate] = useState(props.draft.date ?? "");
const isValid = lifeEventSchema.safeParse({
id: props.draft.draftId,
domain: props.draft.domain,
precision,
date,
}).success;
const inputType = precision === "year" ? "number" : precision;
return (
<div className="birth-time-evidence-draft-card">
<div className="birth-time-candidate-heading"><b>确认关键经历</b><span>待确认草稿</span></div>
<dl className="birth-time-draft-domain"><div><dt>经历领域</dt><dd>{domainLabels[props.draft.domain]}</dd></div></dl>
<div className="birth-time-draft-fields">
<label>
<span id={precisionLabelId}>记得的精度</span>
<Select
disabled={props.pending}
value={precision}
onValueChange={(next) => {
if (next !== "year" && next !== "month" && next !== "day") return;
setPrecision(next);
setDate("");
}}
>
<SelectTrigger aria-labelledby={precisionLabelId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="year">只记得年份</SelectItem>
<SelectItem value="month">记得月份</SelectItem>
<SelectItem value="day">记得具体日期</SelectItem>
</SelectContent>
</Select>
</label>
<label>
<span>发生时间</span>
<input
aria-invalid={date.length > 0 && !isValid}
disabled={props.pending}
inputMode={precision === "year" ? "numeric" : undefined}
max={precision === "year" ? String(new Date().getFullYear()) : undefined}
min={precision === "year" ? "1900" : undefined}
type={inputType}
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</label>
</div>
{!isValid && <p className="form-error" role="alert">请填写与所选精度一致的有效日期;<span className="phrase-nowrap">不会自动补猜缺失时间</span>。</p>}
<div className="birth-time-guided-actions">
<Button className="button-secondary birth-time-guided-action" disabled={props.pending} type="button" variant="outline" onClick={props.onSkip}>放弃这条并跳过</Button>
<Button className="button-primary birth-time-guided-action" disabled={props.pending || !isValid} type="button" onClick={() => props.onConfirm(precision, date)}>
{props.pending ? "确认中…" : "确认并用于校正"}
</Button>
</div>
</div>
);
}
@@ -1,69 +0,0 @@
"use client";
import { useState } from "react";
import { CharacterRemaining } from "@/components/character-remaining";
import { Button } from "@/components/ui/button";
import { characterRemainingVisible } from "@/lib/character-remaining";
import type { JourneyProgress } from "@/lib/birth-time-journey-turn";
import { protectOnboardingPhrases } from "@/lib/onboarding-copy";
type BirthTimeGuideTurnProps = {
readonly question: string;
readonly progress: JourneyProgress;
readonly pending: boolean;
readonly onSubmit: (message: string) => void;
readonly onSkip: () => void;
readonly onPause: () => void;
};
const guideRemainingId = "birth-time-guide-remaining";
export function BirthTimeGuideTurn(props: BirthTimeGuideTurnProps) {
const [message, setMessage] = useState("");
const phase = props.progress.adaptiveRound > 0
? `自适应第 ${props.progress.adaptiveRound} / ${props.progress.maxAdaptiveRounds} 轮`
: "基础证据";
const showRemaining = characterRemainingVisible(message.length, 500);
const describedBy = showRemaining
? `birth-time-guide-hint ${guideRemainingId}`
: "birth-time-guide-hint";
return (
<div className="birth-time-guide-turn" aria-live="polite">
<div className="birth-time-question-progress">
<b>{phase}</b>
<span>已确认 {props.progress.confirmedEvidenceCount} 条 / {props.progress.baselineDomainCount} 个领域</span>
</div>
<p className="birth-time-guide-question">{protectOnboardingPhrases(props.question)}</p>
<label className="birth-time-guide-composer">
<span className="character-remaining-row">
说说这段经历
<CharacterRemaining id={guideRemainingId} length={message.length} maxLength={500} />
</span>
<textarea
aria-describedby={describedBy}
disabled={props.pending}
maxLength={500}
rows={3}
value={message}
onChange={(event) => setMessage(event.target.value)}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Enter" && !event.shiftKey && message.trim()) {
event.preventDefault();
props.onSubmit(message.trim());
}
}}
/>
</label>
<p id="birth-time-guide-hint" className="birth-time-guide-hint">Enter 发送,Shift+Enter 换行。说出大概年份也可以,不需要为了校正而猜测。</p>
<div className="birth-time-guided-actions">
<Button className="button-text birth-time-guided-action" disabled={props.pending} type="button" variant="ghost" onClick={props.onPause}>暂停,稍后继续</Button>
<Button className="button-secondary birth-time-guided-action" disabled={props.pending} type="button" variant="outline" onClick={props.onSkip}>不记得,跳过</Button>
<Button className="button-primary birth-time-guided-action" disabled={props.pending || !message.trim()} type="button" onClick={() => props.onSubmit(message.trim())}>
{props.pending ? "整理中…" : "整理为经历草稿"}
</Button>
</div>
</div>
);
}
@@ -1,91 +0,0 @@
"use client";
import { BirthTimeCandidateResult } from "@/components/birth-time-candidate-result";
import { BirthTimeEvidenceDraftCard } from "@/components/birth-time-evidence-draft-card";
import { BirthTimeGuideTurn } from "@/components/birth-time-guide-turn";
import type { BirthTimeGuidedController } from "@/hooks/use-birth-time-guided-journey";
import { assistantIntentCopy } from "@/lib/birth-time-intake-model";
import type { LegacyJourneyClientResponse } from "@/lib/birth-time-journey-response-schema";
import { guidedTurnIdentity } from "@/lib/birth-time-guided-turn-identity";
import type { NextAction } from "@/lib/birth-time-journey-turn";
import { ErrorToast } from "@/components/error-toast";
import { Button } from "@/components/ui/button";
type LegacyRectificationProps = {
readonly journey: LegacyJourneyClientResponse;
readonly controller: BirthTimeGuidedController;
readonly externalError: string;
};
function actionHeading(action: NextAction): { readonly title: string; readonly badge: string } {
switch (action.kind) {
case "ask_baseline_evidence": return { title: "回想一条关键经历", badge: "基础证据" };
case "ask_adaptive_evidence": return { title: "继续缩小候选范围", badge: "自适应校正" };
case "review_evidence_draft": return { title: "确认经历草稿", badge: "待确认" };
case "score_pending": return { title: "正在比较候选时间", badge: "评分中" };
case "retry_scoring": return { title: "评分需要重试", badge: "可恢复" };
case "present_low_result": return { title: "候选范围已保存", badge: "证据不足" };
case "present_medium_result": return { title: "候选范围已形成", badge: "中等置信" };
case "candidate_saved": return { title: "候选范围已保存", badge: "已保存" };
case "request_candidate_confirmation": return { title: "确认候选时间", badge: "待确认" };
case "ready": return { title: "排盘时间已更新", badge: "已完成" };
case "paused": return { title: "校正已暂停", badge: "已保存" };
}
}
export function BirthTimeLegacyRectification(props: LegacyRectificationProps) {
const action = props.journey.nextAction;
const heading = actionHeading(action);
const asksQuestion = action.kind === "ask_baseline_evidence" || action.kind === "ask_adaptive_evidence";
const showsCandidate = action.kind === "present_low_result"
|| action.kind === "present_medium_result"
|| action.kind === "candidate_saved"
|| action.kind === "request_candidate_confirmation"
|| action.kind === "ready";
const error = props.controller.error || props.externalError;
return (
<section className="birth-time-rectification onboarding-card" aria-labelledby="birth-time-assessment-title">
<div className="birth-time-assessment-heading">
<div><span>出生时间评估</span><h2 id="birth-time-assessment-title">{heading.title}</h2></div>
<span className="birth-time-status-badge">{heading.badge}</span>
</div>
<dl className="birth-time-range-summary">
<div><dt>当前范围</dt><dd>{props.journey.snapshot.reportedRange.label}</dd></div>
<div><dt>应用状态</dt><dd>{action.kind === "ready" ? `当前使用 ${action.activeTime}` : "尚未更新排盘时间"}</dd></div>
</dl>
<p className="birth-time-assistant-intent" role="status">{assistantIntentCopy(props.journey.snapshot.assistantIntent)}</p>
{asksQuestion && (
<BirthTimeGuideTurn
key={guidedTurnIdentity(props.journey.turnVersion, action.question.questionId)}
pending={props.controller.pending}
progress={props.journey.progress}
question={props.controller.question}
onPause={props.controller.pause}
onSkip={props.controller.skip}
onSubmit={props.controller.submitMessage}
/>
)}
{action.kind === "review_evidence_draft" && props.journey.evidenceDraft && (
<BirthTimeEvidenceDraftCard
key={props.journey.evidenceDraft.draftId}
draft={props.journey.evidenceDraft}
pending={props.controller.pending}
onConfirm={props.controller.confirmDraft}
onSkip={props.controller.skip}
/>
)}
{action.kind === "score_pending" && <p className="birth-time-scoring-status" role="status">正在使用已确认的关键经历评分…</p>}
{action.kind === "retry_scoring" && <Button className="button-primary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.retryScoring} type="button">重新评分</Button>}
{action.kind === "paused" && <Button className="button-primary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.resume} type="button">继续校正</Button>}
{showsCandidate ? (
<BirthTimeCandidateResult
controller={props.controller}
error={error}
journey={props.journey}
/>
) : null}
{error && !showsCandidate ? <ErrorToast message={error} /> : null}
</section>
);
}
@@ -1,132 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { BirthTimeCandidateResult } from "@/components/birth-time-candidate-result";
import {
BirthTimeChoiceQuestion,
BirthTimeQuestionProgress,
BirthTimeUnmatchedClarification,
} from "@/components/birth-time-choice-question";
import { BirthTimeLegacyRectification } from "@/components/birth-time-legacy-rectification";
import type { BirthTimeGuidedController } from "@/hooks/use-birth-time-guided-journey";
import type { JourneyClientResponse } from "@/lib/birth-time-journey-client";
import type { DynamicNextAction } from "@/lib/birth-time-journey-turn-protocol";
import { ErrorToast } from "@/components/error-toast";
import { Button } from "@/components/ui/button";
type BirthTimeRectificationProps = {
readonly journey: JourneyClientResponse;
readonly controller: BirthTimeGuidedController;
readonly externalError: string;
};
function dynamicHeading(action: DynamicNextAction): { readonly title: string; readonly badge: string } {
switch (action.kind) {
case "generate_dynamic_question": return { title: "准备下一道问题", badge: "正在生成" };
case "retry_question_generation": return { title: "换一道问题", badge: "可重试" };
case "ask_dynamic_choice": return { title: "选择更接近的情况", badge: "点击作答" };
case "clarify_unmatched_answer": return { title: "换一道更合适的问题", badge: "可选补充" };
case "score_pending": return { title: "正在缩小候选范围", badge: "评分中" };
case "retry_scoring": return { title: "评分需要重试", badge: "可恢复" };
case "present_low_result": return { title: "本次评估已结束", badge: "范围已保存" };
case "present_medium_result": return { title: "候选范围已形成", badge: "评估已结束" };
case "request_candidate_confirmation": return { title: "确认候选时间", badge: "待确认" };
case "ready": return { title: "排盘时间已更新", badge: "已完成" };
case "paused": return { title: "评估已暂停", badge: "进度已保存" };
}
}
function statusCopy(action: DynamicNextAction): string {
switch (action.kind) {
case "generate_dynamic_question": return "系统正在根据当前候选范围准备更有区分度的问题。";
case "retry_question_generation": return "这道问题暂时没有生成成功,可以原地重试。";
case "ask_dynamic_choice": return "点击最接近的选项即可,不需要再填写日期或时间。";
case "clarify_unmatched_answer": return "补充说明完全可选,也可以直接换一道题。";
case "score_pending": return "正在应用刚才的选择,完成后会自动进入下一步。";
case "retry_scoring": return "刚才的选择已经保存,可以安全重试同一评分任务。";
case "present_low_result": return "目前没有足够的新信息继续稳定缩小范围,本次评估已结束并保存当前候选范围。";
case "present_medium_result": return "已形成较窄的候选范围,本次评估已结束;它不会自动改动当前排盘时间。";
case "request_candidate_confirmation": return "只有明确确认后,候选时间才会用于当前排盘。";
case "ready": return `当前排盘使用时间已更新为 ${action.activeTime}。`;
case "paused": return "当前问题和候选范围已保存,可以稍后从这里继续。";
}
}
export function BirthTimeRectification(props: BirthTimeRectificationProps) {
const assessmentHeadingRef = useRef<HTMLHeadingElement | null>(null);
const previousTurnVersion = useRef(props.journey.turnVersion);
useEffect(() => {
const changed = previousTurnVersion.current !== props.journey.turnVersion;
previousTurnVersion.current = props.journey.turnVersion;
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({ preventScroll: true });
}
}, [props.journey]);
if (props.journey.journeyProtocol !== "dynamic-choice-v2") {
return <BirthTimeLegacyRectification {...props} journey={props.journey} />;
}
const action = props.journey.nextAction;
const heading = dynamicHeading(action);
const error = props.controller.error || props.externalError;
const showsProgress = action.kind !== "ask_dynamic_choice" && action.kind !== "clarify_unmatched_answer";
const childOwnsError = action.kind === "ask_dynamic_choice"
|| action.kind === "clarify_unmatched_answer";
const showsCandidate = action.kind === "present_low_result"
|| action.kind === "present_medium_result"
|| action.kind === "request_candidate_confirmation"
|| action.kind === "ready";
return (
<section className="birth-time-rectification onboarding-card" aria-labelledby="birth-time-assessment-title">
<div className="birth-time-assessment-heading">
<div><span>出生时间评估</span><h2 id="birth-time-assessment-title" ref={assessmentHeadingRef} tabIndex={-1}>{heading.title}</h2></div>
<span className="birth-time-status-badge">{heading.badge}</span>
</div>
{showsProgress && <BirthTimeQuestionProgress progress={props.journey.progress} />}
<p className="birth-time-assistant-intent" role="status">{statusCopy(action)}</p>
{action.kind === "ask_dynamic_choice" && (
<BirthTimeChoiceQuestion
key={`${props.journey.turnVersion}:${action.question.questionId}`}
error={error}
onFinish={props.controller.finish}
onPause={props.controller.pause}
onSelect={props.controller.selectOption}
pending={props.controller.pending}
progress={props.journey.progress}
question={action.question}
/>
)}
{action.kind === "clarify_unmatched_answer" && (
<BirthTimeUnmatchedClarification
error={error}
onFinish={props.controller.finish}
onPause={props.controller.pause}
onReframe={props.controller.submitUnmatchedContext}
pending={props.controller.pending}
progress={props.journey.progress}
/>
)}
{(action.kind === "generate_dynamic_question" || action.kind === "retry_question_generation") && error && (
<Button className="button-secondary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.retryQuestionGeneration} type="button" variant="outline">重试当前问题</Button>
)}
{action.kind === "score_pending" && (
<div className="birth-time-scoring-status" aria-live="polite">
<b>正在缩小候选范围…</b>
{props.controller.pollRecoverable && <Button className="button-secondary birth-time-guided-action" onClick={props.controller.retryScoring} type="button" variant="outline">重新检查评分状态</Button>}
</div>
)}
{action.kind === "retry_scoring" && <Button className="button-primary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.retryScoring} type="button">重新评分</Button>}
{action.kind === "paused" && <Button className="button-primary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.resume} type="button">继续本次评估</Button>}
{showsCandidate ? (
<BirthTimeCandidateResult
controller={props.controller}
error={error}
journey={props.journey}
/>
) : null}
{error && !showsCandidate && !childOwnsError ? <ErrorToast message={error} /> : null}
</section>
);
}
+3 -3
View File
@@ -2,12 +2,12 @@
import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey";
export function OnboardingDot({ playKey = "menu" }: { readonly playKey?: string }) {
export function OnboardingDot({ playKey }: { readonly playKey: string }) {
return <span className="onboarding-dot" data-onboarding-blink={playKey} aria-hidden="true" />;
}
export function OnboardingChartDot() {
export function OnboardingChartDot({ playKey }: { readonly playKey: string }) {
const journey = useOnboardingJourneyOptional();
if (!journey?.showChartDot) return null;
return <OnboardingDot playKey="menu" />;
return <OnboardingDot playKey={playKey} />;
}
@@ -113,7 +113,7 @@ export function OnboardingHintLayer() {
sideOffset={8}
className="onboarding-hint"
data-onboarding-hint=""
data-onboarding-pop="hint"
data-onboarding-pop={`hint-${hint}`}
>
<p>{journey.hintText}</p>
<Button variant="outline" size="sm" data-onboarding-press="" onClick={() => dismiss(hint)}>{HINT_DISMISS_LABEL}</Button>
@@ -25,6 +25,9 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
const [localSelected, setLocalSelected] = useState<ChoiceKey | "stop" | "skip_probe" | "">("");
const selectedKey = props.selectedKey || localSelected;
const answered = Boolean(selectedKey);
// skip_probe is the stop control. The toggle's value is "stop", so the
// pressed value has to be "stop" or the button stays aria-pressed=false.
const pressedValue = selectedKey === "skip_probe" ? "stop" : selectedKey;
function select(key: ChoiceKey) {
if (props.pending || props.disabled || selectedKey) return;
@@ -72,7 +75,7 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
<ToggleGroup
className="contents"
style={{ display: "contents" }}
value={selectedKey ? [selectedKey] : []}
value={pressedValue ? [pressedValue] : []}
onValueChange={(next) => {
const key = next[0];
if (!key || key === selectedKey) return;
+1 -1
View File
@@ -38,7 +38,7 @@ export function SecondaryHeader({ title, actions, note, besideTitle, reserveNote
<header className="chat-header has-title">
<span className="onboarding-menu-anchor">
<SidebarTrigger placement="inset" />
<OnboardingChartDot />
<OnboardingChartDot playKey="secondary-chart" />
</span>
<div className="chat-header-title">
<strong>{title}</strong>
+8 -4
View File
@@ -10,9 +10,11 @@ import {
useState,
type ReactNode,
} from "react";
import { useRouter } from "next/navigation";
import { useSidebar } from "@/components/ui/sidebar";
import { useSessionList } from "@/hooks/use-session-list";
import { navigateAppPath } from "@/lib/app-navigation";
import {
buildChecklist,
checklistAllDone,
@@ -302,6 +304,7 @@ export function useOneTimeHint(id: OnboardingHintId, condition: boolean): { show
export function OnboardingJourneyProvider({ children }: { readonly children: ReactNode }) {
const list = useSessionList();
const { open, openMobile, viewport, ready } = useSidebar();
const router = useRouter();
const sidebarOpen = ready && (viewport === "mobile" ? openMobile : open);
const [remote, setRemote] = useState<RemoteSnapshot>(INITIAL_REMOTE);
const [overlay, setOverlay] = useState<OnboardingState>({});
@@ -645,18 +648,19 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea
setLive({ checklistOpen: false });
const target = item.go;
if (!target) return;
const clientNavigate = (href: string) => router.push(href);
if (target.kind === "compose") {
if (homeRef.current) homeRef.current.focusComposer();
else window.location.assign("/?compose=1");
else navigateAppPath("/?compose=1", clientNavigate);
return;
}
if (target.kind === "daily") {
if (homeRef.current) homeRef.current.startDaily();
else window.location.assign("/?daily=1");
else navigateAppPath("/?daily=1", clientNavigate);
return;
}
window.location.assign(target.href);
}, [setLive]);
navigateAppPath(target.href, clientNavigate);
}, [router, setLive]);
const dotVisible = showChartMenuDot({ askDone: hasAnswer, chartVisited });
const tabDot = useCallback((tabId: string) => tabHasDot(tabId, hintsSeen), [hintsSeen]);
+1
View File
@@ -1,3 +1,4 @@
import "./install-test-document";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
@@ -120,41 +120,20 @@ test("a resolved mutation cannot publish over a changed case or version", () =>
test("ready completion is explicit and terminal low has no finish mutation", () => {
const hookSource = readFileSync(new URL("../src/hooks/use-birth-time-guided-journey.ts", import.meta.url), "utf8");
const candidateSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8");
// 原值: 还核对 birth-time-candidate-result 里的 acknowledgeReady,并且不调用 controller.finish。
// 新值: 只核对引导 hook 不把 ready 直接当成完成。
// 原因: 旧候选卡片没有入口,已删除。完成仍不走 completeGuidedBirthTimeCandidate。
assert.doesNotMatch(hookSource, /turn\.nextAction\.kind === "ready"\) onReady/);
assert.match(candidateSource, /acknowledgeReady/);
assert.doesNotMatch(candidateSource, /controller\.finish/);
assert.doesNotMatch(hookSource, /completeGuidedBirthTimeCandidate/);
assert.doesNotMatch(hookSource, /completeCandidate:/);
});
test("unconfirmed terminal candidates preserve the range without offering direct adoption", () => {
const candidateResultSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8");
const choiceQuestionSource = readFileSync(new URL("../src/components/birth-time-choice-question.tsx", import.meta.url), "utf8");
const rectificationSource = readFileSync(new URL("../src/components/birth-time-rectification.tsx", import.meta.url), "utf8");
const legacyRectificationSource = readFileSync(new URL("../src/components/birth-time-legacy-rectification.tsx", import.meta.url), "utf8");
assert.match(candidateResultSource, /尚未达到采用条件/);
assert.match(candidateResultSource, /补充资料并重新评估/);
assert.doesNotMatch(candidateResultSource, /采用 \$\{path\.time\} 并进入对话/);
assert.doesNotMatch(candidateResultSource, /birth-time-next-step/);
assert.match(rectificationSource, /error=\{error\}/);
assert.match(rectificationSource, /const childOwnsError = action\.kind === "ask_dynamic_choice"\s*\|\| action\.kind === "clarify_unmatched_answer"/);
assert.match(rectificationSource, /error && !showsCandidate && !childOwnsError/);
// 原值: choiceQuestionSource 里 role="alert" 恰好 1 处(选项题自己画错误)
// 新值: 0 处;同一错误由 <ErrorToast message={props.error} /> 说一次(仍只由选项题这一处负责,childOwnsError 分工不变)
// 原因: 2026-09-30 产品「报错和提示不要占页面布局」
assert.equal(choiceQuestionSource.match(/role="alert"/g)?.length ?? 0, 0);
assert.equal(choiceQuestionSource.match(/<ErrorToast message=\{props\.error\} \/>/g)?.length, 1);
assert.match(legacyRectificationSource, /error=\{error\}/);
assert.match(legacyRectificationSource, /error && !showsCandidate/);
});
test("terminal CJK copy stays intact while homepage candidates remain unconfirmed in v3", () => {
const candidateResultSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8");
const pageSource = homeSurface;
assert.match(candidateResultSource, /候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。/);
// 原值: 旧候选卡片写着「候选范围已保留…」。
// 新值: 首页仍只挂对话里的校正,不写「当前使用候选时间排盘」。
// 原因: 旧候选卡片没有入口,已删除。首页这条保证还在。
// 原值: assert.match(pageSource, /`出生资料已保存。\$\{birthTimeConsultationOptionsCopy\(savedProfile\)\}`/)
// 新值: 首页不再有这条保存提示(doesNotMatch);本条要守的「候选未确认时不写成当前排盘时间」由其余断言继续锁住
// 原因: BUG-1081——这句只在无 UI 调用的 `saveProfile` 里,随 TASK-self-edit-avatar-menu-20260928 S1 删除
@@ -12,8 +12,6 @@ import { homeSurface as page } from "./home-surface.ts";
const composer = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8");
const remainingSource = readFileSync(new URL("../src/components/character-remaining.tsx", import.meta.url), "utf8");
const guide = readFileSync(new URL("../src/components/birth-time-guide-turn.tsx", import.meta.url), "utf8");
const choice = readFileSync(new URL("../src/components/birth-time-choice-question.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
test("thresholds stay quiet until the last 10 percent, floored at 8 and capped at 50", () => {
@@ -42,7 +40,7 @@ test("the count region is polite, atomic, and only mounted inside the threshold"
assert.match(css, /\.character-remaining\[data-limit\] \{ color: var\(--color-danger\); \}/);
});
test("the four capped fields wire describedby to a count that lives in existing chrome", () => {
test("the capped fields wire describedby to a count that lives in existing chrome", () => {
// Former lock: `aria-describedby={showRemaining ? composerRemainingId : undefined}`. The composer
// is now shared with the rectification surface, which passes its own count id (BUG-477).
assert.match(composer, /remainingId = composerRemainingId/);
@@ -54,8 +52,9 @@ test("the four capped fields wire describedby to a count that lives in existing
assert.match(composer, /\{showRemaining \? \(\s*<CharacterRemaining id=\{remainingId\} length=\{draft\.length\} maxLength=\{maxLength\} \/>/);
assert.doesNotMatch(page, /composer-footer/);
assert.match(page, /aria-describedby=\{characterRemainingVisible\(value\.name\.length, 80\) \? nameRemainingId : undefined\}/);
assert.match(guide, /aria-describedby=\{describedBy\}/);
assert.match(choice, /aria-describedby=\{characterRemainingVisible\(note\.length, 240\) \? unmatchedRemainingId : undefined\}/);
// 原值: 还核对旧引导轮和旧选项题的 aria-describedby。
// 新值: 只核对仍在页面上的输入框和称呼栏。
// 原因: 那两个旧校正组件没有入口,已删除。
// 原值 `.composer-footer > p { display: none; }`(窄屏藏计数)/ 新值:`.composer-row` 无内容时不渲染
// / 原因:计数不再有常驻容器,窄屏不需要再单独藏它。
assert.match(css, /\.composer-row \{[^}]*display: flex/);
@@ -41,8 +41,9 @@ test("nothing inside the chat panel focuses without preventScroll", () => {
const insidePanel = [
"src/app/(app)/page.tsx",
"src/hooks/use-consultation-run.ts",
"src/components/birth-time-choice-question.tsx",
"src/components/birth-time-rectification.tsx",
// 原值: 名单里还有 birth-time-choice-question.tsx 和 birth-time-rectification.tsx。
// 新值: 这两份不在名单里。
// 原因: 旧校正入口已删,不再画在对话列里。
"src/components/rectification-agentic-chat.tsx",
"src/components/rectification-board.tsx",
];
+5 -11
View File
@@ -6,7 +6,6 @@ import { rectificationChatSurface } from "./rectification-chat-surface.ts";
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const rectification = rectificationChatSurface;
const guide = readFileSync(new URL("../src/components/birth-time-guide-turn.tsx", import.meta.url), "utf8");
function onKeyDownBlock(source: string, marker: string) {
const start = source.indexOf(marker);
@@ -18,26 +17,21 @@ function onKeyDownBlock(source: string, marker: string) {
return handler[0];
}
test("all three composers ignore Enter while an IME is composing", () => {
test("the live composers ignore Enter while an IME is composing", () => {
// 原值: 首页、校正对话、旧引导轮三处都要忽略组字中的 Enter。
// 新值: 首页和校正对话两处。两处仍是 Enter 发送、Shift+Enter 换行。
// 原因: 旧引导轮组件没有入口,已删除。
const main = page.slice(
page.indexOf("function handleComposerKeyDown"),
page.indexOf("\n\n if (!hydrated"),
);
const rectificationHandler = onKeyDownBlock(rectification, "onKeyDown={(event) => {");
const guideHandler = onKeyDownBlock(guide, "onKeyDown={(event) => {");
for (const [name, handler] of [
["main composer", main],
["rectification composer", rectificationHandler],
["birth-time guide", guideHandler],
] as const) {
assert.match(handler, /event\.nativeEvent\.isComposing/, `${name} must ignore composing Enter`);
assert.match(handler, /event\.key === "Enter" && !event\.shiftKey/, `${name} must send on Enter`);
}
});
test("the guide turn sends with Enter and wraps with Shift+Enter, matching the other two", () => {
const handler = onKeyDownBlock(guide, "onKeyDown={(event) => {");
assert.match(handler, /event\.key === "Enter" && !event\.shiftKey/);
assert.doesNotMatch(handler, /metaKey \|\| event\.ctrlKey/);
assert.match(guide, /Enter 发送,Shift\+Enter 换行/);
});
@@ -1,3 +1,4 @@
import "./install-test-document";
import assert from "node:assert/strict";
import test from "node:test";
import React, { useRef } from "react";
@@ -1,3 +1,4 @@
import "./install-test-document";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
@@ -1,3 +1,4 @@
import "./install-test-document";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
+4 -1
View File
@@ -205,7 +205,10 @@ test("bootstrap enters the prepare phase instead of revealing after the account
assert.match(page, /setRectificationEntrySummarySettled\(true\);/);
assert.match(page, /bootstrapRevealDelayMs\(Date\.now\(\), prepareStartedAt\.current, bootstrapPrepareReady\)/);
assert.match(page, /window\.setTimeout\(\(\) => setHydrated\(true\), delay\)/);
assert.match(page, /void import\("@\/components\/birth-time-rectification"\);/);
// 原值: 准备阶段预热 `void import("@/components/birth-time-rectification")`。
// 新值: 不再预热。页面不渲染这组旧校正组件。
// 原因: 这 6 个组件没有入口,按「多余入口宁可删除」去掉。
assert.doesNotMatch(page, /birth-time-rectification"\)/);
});
test("revealed home carries no component loading state", () => {
@@ -1,3 +1,4 @@
import "./install-test-document";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import test from "node:test";
@@ -1,3 +1,4 @@
import "./install-test-document";
import assert from "node:assert/strict";
import test from "node:test";
import React, { useInsertionEffect, useLayoutEffect, type ReactNode } from "react";
@@ -1,3 +1,4 @@
import "./install-test-document";
import assert from "node:assert/strict";
import test from "node:test";
import React, { type ReactNode } from "react";
@@ -294,6 +294,24 @@ test("checklist and hint wiring stay inside the existing shell", () => {
assert.match(hint, /initialFocus=\{false\}/);
assert.match(hint, /finalFocus=\{false\}/);
assert.match(hint, /event\.key === "Escape"/);
// 原值: 提示层一律 data-onboarding-pop="hint",星盘圆点一律 playKey="menu"。
// 新值: 提示按 id 区分,首页和次级页的圆点各用各的键。
// 原因: 同一个键只会闪或弹一次,后面的项不再动。
assert.match(hint, /data-onboarding-pop=\{`hint-\$\{hint\}`\}/);
assert.doesNotMatch(hint, /data-onboarding-pop="hint"/);
const dot = read("../src/components/onboarding-dot.tsx");
const secondary = read("../src/components/secondary-header.tsx");
assert.match(page, /<OnboardingChartDot playKey="home-chart" \/>/);
assert.match(secondary, /<OnboardingChartDot playKey="secondary-chart" \/>/);
assert.doesNotMatch(dot, /playKey="menu"/);
// 原值: 清单「去做」用 window.location.assign 整页打开。
// 新值: navigateAppPath,客户端用 router.push。版本对不上时仍整页打开。
// 原因: 同一版本里点「去做」不要整页刷新。
assert.match(journey, /navigateAppPath\(/);
assert.match(journey, /router\.push\(href\)/);
assert.doesNotMatch(journey, /window\.location\.assign\("\/\?compose=1"\)/);
assert.doesNotMatch(journey, /window\.location\.assign\("\/\?daily=1"\)/);
assert.doesNotMatch(journey, /window\.location\.assign\(target\.href\)/);
assert.match(journey, /export function useOneTimeHint\(/);
assert.match(css, /\.chat-app \{[^}]*overflow:\s*clip/);
assert.match(css, /\.chat-header \{[^}]*grid-template-columns:\s*auto minmax\(0,\s*1fr\) auto/);
@@ -1,6 +1,8 @@
import "./install-test-document";
import assert from "node:assert/strict";
import test, { describe } from "node:test";
import { AppRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
import type { DataClient } from "../src/lib/db/data-client.ts";
import { emptyProfile, type Account, type Profile } from "../src/lib/home-types.ts";
@@ -11,6 +13,14 @@ import { invalidateAccountFlight, resetSubjectCatalogForTests } from "../src/lib
import { SessionListProvider } from "../src/lib/session-list-context.tsx";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
const testAppRouter = {
back() {},
forward() {},
prefetch() {},
push() {},
refresh() {},
replace() {},
};
const ACCOUNT_ID = "11111111-1111-4111-8111-111111111448";
const OTHER_ID = "22222222-2222-4222-8222-222222222448";
const DISMISSED_AT = "2026-10-01T00:00:00.000Z";
@@ -194,9 +204,11 @@ describe("checklist refresh inside the shell", { concurrency: 1 }, () => {
await harness.render(
<SessionListProvider>
<SidebarProvider>
<OnboardingJourneyProvider>
<PersonProbe />
</OnboardingJourneyProvider>
<AppRouterContext.Provider value={testAppRouter}>
<OnboardingJourneyProvider>
<PersonProbe />
</OnboardingJourneyProvider>
</AppRouterContext.Provider>
</SidebarProvider>
</SessionListProvider>,
);
@@ -1,3 +1,4 @@
import "./install-test-document";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
@@ -1,3 +1,4 @@
import "./install-test-document";
import assert from "node:assert/strict";
import test from "node:test";
@@ -1,3 +1,4 @@
import "./install-test-document";
import assert from 'node:assert/strict';
import test from 'node:test';
import React,{useState,useRef} from 'react';
@@ -181,16 +181,10 @@ class HostDocument {
createElementNS(_namespace: string, tag: string) { return this.createElement(tag); }
}
// Base UI chooses `useLayoutEffect` or a no-op once, the first time it loads,
// by reading `typeof document`. Tests import this harness before those
// components so the choice sticks. Each test still installs its own document.
if (typeof globalThis.document === "undefined") {
Object.defineProperty(globalThis, "document", {
value: new HostDocument(),
writable: true,
configurable: true,
});
}
// Do not install `document` on import. Seven server-render files import this
// harness and must still see a missing document. A test that mounts Base UI
// imports ./install-test-document first. createClientLifecycleHarness()
// installs the host document for that render and removes it on close().
export function createClientLifecycleHarness() {
const document = new HostDocument();
@@ -14,6 +14,7 @@
* and drive the LIVE path through a fake `fetch`: stream → settle → snapshot
* merge. They count what the reader would see, not helper return values.
*/
import "./install-test-document";
import assert from "node:assert/strict";
import test from "node:test";
@@ -9,6 +9,7 @@
*
* Harness copied from `rectification-dup-question-20260926.test.tsx`.
*/
import "./install-test-document";
import assert from "node:assert/strict";
import test from "node:test";
@@ -4,6 +4,7 @@
* merge) and count what the reader sees — the body sentences, the stem once
* with its examples, and a receipt with one row per shown step.
*/
import "./install-test-document";
import assert from "node:assert/strict";
import test from "node:test";
@@ -207,6 +207,10 @@ test("the choice card confirms the tap and the board's first state shows the dec
assert.doesNotMatch(choiceCard, /已选择/);
assert.doesNotMatch(choiceCard, /rectification-choice-card__selected/);
assert.match(choiceCard, /data-selected=\{selectedKey === option\.key \? "true" : "false"\}/);
// 原值: 分组的 value 直接用 selectedKey。skip_probe 对不上停止钮的 value="stop",aria-pressed 仍是 false。
// 新值: skip_probe 映射成 stop,停止钮按下。
// 原因: 「这题跳过」选中时,停止钮就是被选中的那一颗。
assert.match(choiceCard, /selectedKey === "skip_probe" \? "stop" : selectedKey/);
assert.match(choiceCard, /rectification-choice-card__pending/);
assert.match(choiceCard, /InlineSpinner size=\{12\}/);
assert.doesNotMatch(choiceCard, /onMouseEnter/);
@@ -1,3 +1,4 @@
import "./install-test-document";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
@@ -1,3 +1,4 @@
import "./install-test-document";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
@@ -1,6 +1,7 @@
// 我的报告: keyset pages of 10 with 「加载更多」 (BUG-1124) and entering the page
// joins the sidebar's prefetch instead of discarding it (BUG-1123).
// Fictional list metadata only (AGENTS §8).
import "./install-test-document";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
@@ -1,3 +1,4 @@
import "./install-test-document";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
@@ -1,3 +1,4 @@
import "./install-test-document";
import assert from "node:assert/strict";
import test from "node:test";
import { StrictMode, startTransition, useEffect, useState, type ReactNode } from "react";
@@ -1,3 +1,4 @@
import "./install-test-document";
import assert from "node:assert/strict";
import test from "node:test";
import { useEffect } from "react";
@@ -1,3 +1,4 @@
import "./install-test-document";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";