From 638b6a60c0adf6bf39b233707a53c9c6b51b1358 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 26 Sep 2026 08:21:10 +0800 Subject: [PATCH] fix(home): warm return to / without replaying the loading ring (BUG-1040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coming back to / from /chart, /ephemeris, /reports or /people by client navigation remounted Home from hydrated=false and replayed the whole bootstrap: model catalog, consult status, entry summary and daily card, 3-5 round trips plus up to 4 s of reveal budget, every time. The reveal now counts per document load, not per mount: - lib/home-warm-snapshot.ts: memory-only, per-account snapshot of the model catalog, rectification entry summary, session cursor and today's card (keyed by person + birth fingerprint + day). Account, profile and the session list already survive in SessionListProvider. Cleared on account change, sign-out, provider 401 and redirectToLogin. - lib/home-warm-start.ts: Home's useState initializers take one warm decision per mount. Complete snapshot + settled list + complete profile + a landing computable in memory -> start ready, landing resolved with the cold-path functions (?new=1, ?c=, login-return stash and its person scope from BUG-1038). Anything missing or needing a lookup -> the unchanged cold path (no half-reveal, BUG-1021). - runHomeWarmRefresh commits the landing, then refreshes catalog, account and background-consultation recovery in the background (recovery shares resolveReservedConsultation with the cold path). Summary and daily card refresh through their existing effects. - Next renders the new page before it writes the address bar, so AppLink / navigateAppPath note the target href (lib/client-navigation-target.ts); unknown target -> cold path. Home() useState 33 / useRef 37 unchanged; page.tsx 1329 -> 1373 lines. Tests: home-warm-return-lifecycle (13, real Home + sidebar + provider, navigating in Next's render-then-write-URL order; 11 fail with warm start disabled) and home-warm-snapshot (12). Full suite 3953 / 61 failing, failure names identical to the 3928 / 61 baseline. Build: / stays Static, rootMainFiles gzip 130933 B unchanged. Local Chrome with every /api/* held 1.5 s: /chart -> 新建对话 interactive in 18-23 ms with no loading ring (baseline 5530 ms with ring). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8 --- BLOCKED.md | 6 + CHANGELOG.md | 7 + docs/BUG_HISTORY.md | 15 + .../PROGRESS-home-warm-return-20260926.md | 163 +++++ docs/tasks/README.md | 2 +- ...me-warm-return-20260926-chart-new-chat.png | Bin 0 -> 52977 bytes ...e-warm-return-20260926-reports-history.png | Bin 0 -> 49137 bytes docs/testing/home-warm-return-20260926.md | 31 + frontend/DESIGN.md | 5 +- frontend/src/app/(app)/page.tsx | 62 +- frontend/src/components/app-link.tsx | 9 +- .../components/daily-starlanguage-binder.tsx | 11 +- frontend/src/hooks/use-profile-onboarding.ts | 11 +- frontend/src/hooks/use-session-management.ts | 5 +- frontend/src/lib/app-navigation.ts | 3 + frontend/src/lib/client-navigation-target.ts | 28 + frontend/src/lib/home-bootstrap-run.ts | 217 ++++++- frontend/src/lib/home-cloud-sync.ts | 3 + frontend/src/lib/home-warm-snapshot.ts | 99 +++ frontend/src/lib/home-warm-start.ts | 321 ++++++++++ frontend/src/lib/session-list-context.tsx | 5 + .../tests/home-warm-return-lifecycle.test.tsx | 595 ++++++++++++++++++ frontend/tests/home-warm-snapshot.test.ts | 274 ++++++++ .../new-chat-from-people-lifecycle.test.tsx | 4 + 24 files changed, 1827 insertions(+), 49 deletions(-) create mode 100644 docs/tasks/PROGRESS-home-warm-return-20260926.md create mode 100644 docs/testing/home-warm-return-20260926-chart-new-chat.png create mode 100644 docs/testing/home-warm-return-20260926-reports-history.png create mode 100644 docs/testing/home-warm-return-20260926.md create mode 100644 frontend/src/lib/client-navigation-target.ts create mode 100644 frontend/src/lib/home-warm-snapshot.ts create mode 100644 frontend/src/lib/home-warm-start.ts create mode 100644 frontend/tests/home-warm-return-lifecycle.test.tsx create mode 100644 frontend/tests/home-warm-snapshot.test.ts diff --git a/BLOCKED.md b/BLOCKED.md index 00483f28..733489ae 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,5 +1,11 @@ # BLOCKED +## TASK-home-warm-return:登录态真机、Node 22 全量与部署待验(2026-09-26) + +- 无受控登录账号:暖返回只在本地 `next start` + Chrome 151 无头上验过(CDP 拦截 `/api/*` 返回虚构账户与会话、所有接口人为延迟 1.5 s;脚本在 scratchpad,未提交)。iPhone Safari 真机与真实 staging 数据按 `docs/testing/home-warm-return-20260926.md` 待产品走。 +- 本机只有 Node v20.19.2:`mock.module` 类测试失败(基线 61 条);本轮全量 3953 条 / 61 失败,失败名单与基线逐条一致、无消失测试名。任务书要求的 Linux Node 22 全量待协调方复跑。无 Docker,`npm run test:db` 未跑(本单不动表)。 +- 未部署;BUG-1040 保持 investigating。 + ## TASK-new-chat-from-people:登录态真机、Node 22 与 DB 待验(2026-09-26) - 无受控登录账号:修复只在本地 `next start` + Chrome 151 上验过(CDP 拦截 `/api/*` 返回虚构账户、会话与人物,未提交脚本)。iPhone Safari 真机、真实 staging 数据按 `docs/testing/secondary-new-chat-20260923.md` 追加节 A1–A6 待产品走。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1e7c3d..9f9cb53b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-09-26 — 从其他页面回首页不再重放加载动画(待验收) + +- 同一次打开网页里,从星盘、星历、我的报告、星盘档案点「新建对话」、点侧栏里的历史对话、点左下角头像或按浏览器返回回到首页,不再出现「正在载入账户 / 正在准备对话」加载环,首页立刻可以输入。 +- 刷新、第一次打开、登录后跳转仍会放一次加载动画,和以前一样。 +- 模型列表、后台回答恢复、生时校正入口、今日星语在页面可用之后在后台更新,结果到了直接替换;模型下线仍提示「此前选择的模型已下线,已切换为默认模型。」。换账号、退出、登录失效时不会带出上一个账号的内容;今日星语过了零点或换了人会重新取。 +- Skill 版本不 bump。不改接口、数据库和样式;登录态真机与部署待验收。 + ## 2026-09-26 — 从星盘档案页回首页不再卡在上一次的生时校正(待验收) - 从校正会话去过次级页、在星盘档案里换了人,再回首页时,不再出现「标题是旧校正、输入框写着正在打开生时校正却一直打不开、中间是新对话开场」的画面;落到当前这个人的新对话。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 78cef7f3..15b1c344 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -13913,3 +13913,18 @@ - 防复发:新增「组件用到的每个 `people-archive*` 类都必须在 `globals.css` 有规则」的合同测试,这类「写了类没写样式」的回归会在测试里失败。验收流程缺口:无登录态页面只做源码断言等于没看过页面;本轮起此类页面至少用本地宿主页在真实浏览器里截一次图,写进 PROGRESS。 - 相关记录:BUG-1030(本页由其引入)。 - 修复版本:`codex/people-ephemeris-ui-20260926`,未部署。 + +## BUG-1040 | 从其他页面回首页每次都重放加载动画、要等好几秒 + +- 状态:investigating(已修复待部署;登录态真机未复测) +- 首次发现 / 最近更新:2026-09-26 / 2026-09-26 +- 影响面:首页启动(`app/(app)/page.tsx` 的 `hydrated` / `bootstrapPhase` 初值与启动 effect、`lib/home-bootstrap-run.ts`)、新增 `lib/home-warm-snapshot.ts` / `lib/home-warm-start.ts` / `lib/client-navigation-target.ts`、`AppLink`、`DailyStarlanguageBinder`、`useSessionManagement.ensureSessionMessages`、`useProfileOnboarding`(初始资料、退出清快照)、`SessionListProvider`(401 / 换账户清快照)、`redirectToLogin`。 +- 用户现象:产品 09-26 真机:第一次进首页有加载动画是预期;从星盘 / 星历 / 我的报告 / 星盘档案点「新建对话」回首页,每次都要再看一遍加载动画、等很久。 +- 触发条件:同一次打开里,任何客户端导航回 `/`(侧栏新建对话、历史会话行、账户页脚、浏览器返回)。 +- 根因:首页的「一次等待一次揭幕」按组件挂载计,不是按这次打开网页计。客户端导航会卸载重挂 `Home`,`hydrated=false`、`bootstrapPhase="account"` 从头来:重新 `fetchModelCatalog` + `fetchActiveConsultationStatus`、prepare 阶段再拉入口摘要与今日星语、揭幕预算最多 4 秒。本地 Chrome 基线实测(所有 `/api/*` 人为延迟 1.5 s):`/chart` → 新建对话 5530 ms 才可交互且出现加载环。BUG-966 已给星盘 / 星历 / 报告做了模块级首屏缓存,首页没有同等机制。 +- 修复:第一次冷启动成功后,把模型目录、校正入口摘要、会话分页游标、今日星语(按人物 + 出生资料指纹 + 日期键)写进模块级暖快照(按账户隔离、只存内存);账户、资料、会话列表本来就在布局层 `SessionListProvider` 里跨页存活,不再复制。`Home` 重挂时 `useState(() => …)` 初始化函数读快照:快照齐全 + 列表已就绪 + 资料完整 + 落点可在内存里算出时,`hydrated` / `bootstrapPhase` 初值即就绪态,落点按冷启动同一套函数同步算出(`resolveBootstrapSessionSelection` / `resolveStarterHomeLandingSessionId`,`?new=1` 建本地空对话、`?c=` 保持、登录返回存根与其人物范围同 BUG-1038);启动 effect 改为提交落点后后台刷新模型目录(下线模型沿用原提示并 PATCH)、账户、后台回答恢复(与冷启动共用 `resolveReservedConsultation`),入口摘要与今日星语走各自既有 effect,结果静默替换。缺任何一项、落点需要 lookup、或客户端导航目标未知一律回冷启动。Next 先渲染新页面、后在 insertion effect 里写地址栏,挂载时 `location` 还是来源页,所以 `AppLink` / `navigateAppPath` 在点击时记下目标 href(`client-navigation-target.ts`),首页只信 10 秒内的一次记录。账户变化、退出、任一 401(`redirectToLogin`、provider 401)清空快照。`Home()` 的 useState 33 / useRef 37 不变,新增一个只写快照的 effect。 +- 验证:新增 `frontend/tests/home-warm-return-lifecycle.test.tsx`(13 条,真实 `Home` + `AppSidebar` + `SessionListProvider`,按 Next 的顺序先渲染、后在 insertion effect 写 URL):四个次级页 → 新建对话首帧无加载环、标题「新对话」、首帧提交前 0 个请求、所有接口挂起也不回加载态;报告页 → 历史会话;整页刷新仍放一次加载环;快照缺项回冷启动;模型下线提示 + PATCH;后台回答恢复(落到该会话 / 不抢新建对话);入口摘要静默替换;存根校正会话带 URL 打开;换账户清快照。关掉暖启动时其中 11 条失败;去掉目标记录时新建对话与历史会话两条失败。`frontend/tests/home-warm-snapshot.test.ts`(12 条纯函数 / 源码合同)。本地 `next start` + Chrome 151(CDP 拦截 `/api/*`、全部延迟 1.5 s):`/chart` → 新建对话 18–23 ms 可交互、0 次加载环、可交互前 0 个请求返回;`/reports` → 历史会话 18–19 ms;整页刷新 22 ms 出加载环。全量 3953 条 / 61 失败,失败名单与基线 3928 / 61 逐条一致。 +- 防复发:lifecycle 测试锁「客户端返回首帧即就绪、任何接口都不被等待」与「整页加载仍放一次」;测试 harness 按 Next 的「先渲染后写 URL」顺序导航,防止再出现只在测试里成立的 URL 读取。BUG-966 的缓存规则(模块级、组件卸载不丢)推广到首页。 +- 相关记录:BUG-966(次级页模块缓存先例)、BUG-479(一次等待一次揭幕)、BUG-1021(不半揭幕,快照缺项回冷启动)、BUG-1038(新建意图与存根规则,暖路径沿用)、BUG-936(首屏兜底,`data-hydrated` 不变)。 +- 复发自:无。 +- 修复版本:`codex/home-warm-return-20260926`,未部署。 diff --git a/docs/tasks/PROGRESS-home-warm-return-20260926.md b/docs/tasks/PROGRESS-home-warm-return-20260926.md new file mode 100644 index 00000000..682b2398 --- /dev/null +++ b/docs/tasks/PROGRESS-home-warm-return-20260926.md @@ -0,0 +1,163 @@ +# PROGRESS · 从其他页面回首页不再放加载动画(2026-09-26) + +任务书:`docs/tasks/TASK-home-warm-return-20260926.md`。分支 `codex/home-warm-return-20260926`,worktree `.worktrees/home-warm-return-20260926`。执行方:Claude 子代理(产品授权直接执行)。未推送、未部署。BUG-1040(开工时最大 BUG-1039,1040 空)。 + +## 结论 + +| 项 | 修复前(`origin/staging` 6a22626d) | 修复后 | +| --- | --- | --- | +| 首页 → 星盘 → 新建对话,所有 `/api/*` 人为延迟 1.5 s(本地 Chrome) | 出加载环,**5530 ms** 才可交互;可交互前等完 5 个请求 | **无加载环,23 ms** 可交互(复跑 18 ms);可交互前 **0** 个请求返回 | +| 首页 → 我的报告 → 点历史会话(同上) | 出加载环,**5532 ms** | **无加载环,19 ms**(复跑 18 ms) | +| 整页刷新 `/` | 22 ms 出加载环,约 4.1 s 进入 | 22–24 ms 出加载环,约 4.1 s 进入(不变) | +| 首次打开 `/` | 64 ms 出加载环,约 4.4 s | 32–63 ms 出加载环,约 4.1–4.4 s(不变) | + +冷启动约 4.1 s 是本地虚构数据下的 prepare 揭幕预算,基线与本分支一致,不属本单。 + +## 基线 + +- `origin/staging` = `6a22626d`(BUG-1038 `0a8350cc` 已合入,开工前 `git log` 核对)。实现期间 staging 前进到 `bd669aed`(只改 BUG_HISTORY 与任务索引两行文档),本分支已 rebase 到 `bd669aed`,`docs/tasks/README.md` 冲突一处:保留 staging 的 BUG-1038 行、本单行改「待验收」。 +- 全量基线(同一台机器、Node v20.19.2、Linux):协调方给的 `test9.log`,3928 条,61 失败(环境缺口:Node 20 没有 `mock.module`),27 跳过。 +- 首屏 gzip(`.next/build-manifest.json` `rootMainFiles`,gzip -9):130933 B。`/` 的 index.html 引用的全部 js/css:654283 B(同代码的 `new-chat-from-people` 工作树构建)。 +- `npm run lint`:0 error / 126 warning(同代码工作树实测)。 +- `page.tsx` 1329 行;`Home()` useState 33、useRef 37(合同上限 36 / 37,行数上限 1567)。 + +## 设计 + +### 快照里放什么(T1,D2) + +`frontend/src/lib/home-warm-snapshot.ts`:模块变量,一次一个账户,纯内存。 + +| 字段 | 来源 | 说明 | +| --- | --- | --- | +| `accountId` | 冷启动成功后的账户 | 键;读到别的账户立即清空 | +| `modelCatalog` | 冷启动 / 后台刷新 | 缺或为空 → 不算齐全 | +| `entrySummary` + `entrySummarySettled` | 校正入口摘要 effect | 未 settled → 不算齐全(揭幕预算先到时,等摘要落定才写) | +| `sessionsCursor` + `listBoot` | `useSessionManagement` 的分页游标,与写入时 provider 的 boot 对象身份 | provider 在外重载过(例如换人)则用 provider 自己的游标 | +| 今日星语 `{accountId, subjectId, fingerprint, day, card}` | `DailyStarlanguageBinder` 拿到 ready 时 | 人物、出生资料指纹、日期三者全等才用;否则 `pending`(卡片静态句),binder 按新键重取 | + +**与 D2 的偏离(有意)**:`account`、`profile`、`startGreeting`、`onboardingStep` 不复制进快照。账户本来就在布局层 `SessionListProvider` 的状态里跨客户端导航存活(那就是这份内存快照),而且比快照新(provider 换人重载会重读 `/api/account`,其它页面也不会改它);资料按冷启动同样的方式 `readProfile(account.profile)` 派生,问候语与引导步骤再由资料派生。复制一份只会多一个过期来源。齐全判定照样要求账户存在、同账户、资料完整。 + +写入:`Home()` 里一个只写快照的 effect,条件 `hydrated && !accountError && accountId && modelCatalog && entrySummarySettled`(不写半成品,BUG-1021)。只增 effect、不增 state / ref。 + +失效:`SessionListProvider` 的 `/api/account` 401、`/api/sessions` 401、换账户分支;`redirectToLogin()`(所有 401 跳登录的出口);`signOut()`;`readHomeWarmSnapshot(别的账户)`。 + +### 暖返回(T2,D3) + +`frontend/src/lib/home-warm-start.ts`,无 React hook: + +1. `Home()` 的 `useState(() => …)` 初始化函数(`activeSessionId`、`modelCatalog`、`dailyStarlanguage`、`rectification`、`hydrated`、`bootstrapPhase`、`onboardingStep`、`startGreeting`,以及 `useProfileOnboarding` 新增的 `initialProfile`)都调 `takeHomeWarmStart()`。同一次渲染(含 StrictMode 双调)共用一个结果,微任务后失效,下次挂载重新判断。 +2. 条件全部满足才是暖启动,否则返回 null,走**原封不动**的冷启动:provider 已 settled、未登出、有账户、boot 无错;同账户快照齐全;资料完整(引导中的用户走冷启动);非开发 `?preview=`;落点能在内存里算出;知道要打开的 `/` 的 query。 +3. 落点 `resolveWarmLanding()` 用冷启动同一组函数:`resolveBootstrapSessionSelection` → `resolveStarterHomeLandingSessionId` / `starterHomeLandingNeedsConsultation`。`?new=1` 建本地空咨询(替换旧的未保存空对话,BUG-989 不落库);`?c=` 在内存 → `keep`;存根在内存且属当前人物 → `replace-selected`,属别人 → `other-subject`(BUG-1038);需要 lookup(`?c=` 或存根不在内存)→ null → 冷启动(BUG-705 的查找只有冷路径做)。 +4. 首帧:`hydrated=true`、`bootstrapPhase="prepare"`、目录 / 资料 / 摘要 / 星语就位;新建的本地空咨询在进列表前由 `pendingWarmLandingSession()` 充当活动会话(`ensureSessionMessages` 同样认它,不去服务端拉)。 +5. 启动 effect:挂载时 `hydrated` 为真即暖启动,调 `runHomeWarmRefresh()`(`home-bootstrap-run.ts`):同步提交落点(列表、游标、写 / 清 URL、清存根、缺失提示;存根校正会话在写回 `?c=` 后补一次自动打开,因为自动打开 effect 已在 URL 写回之前跑过),然后后台并行:`/api/models`(变了才替换,下线模型沿用原提示并 PATCH,未保存的本地空对话不 PATCH)、`refreshAccount()`、后台回答恢复(与冷启动共用抽出的 `resolveReservedConsultation`;本标签页的 pending 记录在挂载时读取,因为首页自己的 pending effect 第一轮就会清掉它;新建落点不被恢复抢走)。入口摘要与今日星语走各自原有 effect(`bootstrapPhase !== "account"` 即触发)。全部不 await 在可交互之前,失败保持快照值,不回加载态。 + +### 客户端导航读不到目标 URL(浏览器实测才发现) + +第一版在真实浏览器里「新建对话」落到了旧会话、地址停在 `/?new=1`。原因:Next 16 的 app router 先渲染新页面,**之后**才在 `HistoryUpdater` 的 `useInsertionEffect` 里 `pushState`(`node_modules/next/dist/client/components/app-router.js`)。所以 `Home` 挂载渲染时 `window.location` 还是 `/chart`,初始化函数读不到 `?new=1`。冷启动没这个问题,因为它在 await 之后才读 URL。 + +修法:新增 `frontend/src/lib/client-navigation-target.ts`(无依赖,随共享侧栏加载)。`AppLink` 在普通左键点击、未被拦截时记下目标 href,`navigateAppPath` 在客户端跳转前同样记下。首页挂载时:地址栏已是 `/`(刷新、浏览器前进后退)就读地址栏;否则读 10 秒内的那一次记录;都没有 → 冷启动(安全回落,不猜)。侧栏的新建对话、会话行、账户页脚都是 `AppLink`。 + +lifecycle 测试 harness 也改成 Next 的顺序(先渲染,URL 在 insertion effect 里写),原先「先 pushState 再渲染」的写法测不出这个问题。去掉记录时新测试里新建对话与历史会话两条失败。 + +## 改了什么 + +| 文件 | 内容 | +| --- | --- | +| `frontend/src/lib/home-warm-snapshot.ts`(新) | 快照存取、按账户清空、齐全判定、今日星语按人物 + 指纹 + 日期存取 | +| `frontend/src/lib/home-warm-start.ts`(新) | 暖启动判断、落点、挂载 query、模型对账、StrictMode 幂等提交 | +| `frontend/src/lib/client-navigation-target.ts`(新) | 客户端导航目标记录 | +| `frontend/src/app/(app)/page.tsx` | 8 个 useState 改初始化函数;`activeSession` 多一个首帧回退;启动 effect 分暖 / 冷;一个写快照 effect。1329 → 1373 行;useState 33 / useRef 37 不变 | +| `frontend/src/lib/home-bootstrap-run.ts` | 抽出 `resolveReservedConsultation`(冷启动行为不变);新增 `runHomeWarmRefresh` | +| `frontend/src/components/daily-starlanguage-binder.tsx` | 先读内存卡再读 localStorage;ready 时记进内存;原有 guard 与依赖数组不动 | +| `frontend/src/hooks/use-profile-onboarding.ts` | `initialProfile` 参数;`signOut` 先清快照 | +| `frontend/src/hooks/use-session-management.ts` | `ensureSessionMessages` 认首帧的活动会话 | +| `frontend/src/lib/session-list-context.tsx`、`home-cloud-sync.ts` | 401 / 换账户 / `redirectToLogin` 清快照 | +| `frontend/src/components/app-link.tsx`、`frontend/src/lib/app-navigation.ts` | 记录导航目标 | +| `frontend/tests/home-warm-return-lifecycle.test.tsx`(新,13 条) | 见下 | +| `frontend/tests/home-warm-snapshot.test.ts`(新,12 条) | 见下 | +| `frontend/tests/new-chat-from-people-lifecycle.test.tsx` | 只在 `openDocument` 里加两行:文档加载时清快照(模块内存本来就随文档重置)。无断言改动 | +| 文档 | `docs/BUG_HISTORY.md`(BUG-1040)、`frontend/DESIGN.md` §9 与 Secondary page shell、`CHANGELOG.md`、`BLOCKED.md`、`docs/tasks/README.md`、`docs/testing/home-warm-return-20260926.md` 与两张截图 | + +## 测试 + +### 新增 + +`home-warm-return-lifecycle.test.tsx`(真实 `Home` + `AppSidebar` + `SessionListProvider`,与 `(app)/layout.tsx` 同构;用兄弟组件的 layout effect 记录每次挂载的**首个提交帧**): + +| # | 用例 | 关掉暖启动时 | +| --- | --- | --- | +| 1–4 | `/chart` `/ephemeris` `/reports` `/people` → 新建对话:导航前把所有接口设为永不返回;首帧无加载环、标题「新对话」、首帧提交时请求数 = 导航前、starter 首页、地址 `/`、存根清掉、校正卡用快照摘要;之后 `/api/models` 与 `/api/consult/status` 在后台发出,挂起也不回加载态 | 失败 | +| 5 | `/reports` → 历史会话:首帧即该会话、`?c=` 保留、消息出现 | 失败 | +| 6 | 暖返回后整页刷新:首帧是加载环 | 失败(前半段暖返回断言) | +| 7 | 快照被清空再回首页:首帧加载环,目录挂起时一直等(BUG-1021) | 通过(本来就冷) | +| 8 | 后台目录换成别的模型:首帧不等;随后模型切到新默认、出现原提示「此前选择的模型已下线,已切换为默认模型。」、PATCH 该会话 | 失败 | +| 9 | 离开时回答还在生成(pending 记录 + reserved)→ 点该会话回来:出现停止按钮、pending 记录保留 | 失败 | +| 10 | 同上但点新建对话:停在新对话,旧回答继续恢复 | 失败 | +| 11 | 摘要变了:校正卡从「再次校正」静默换成「开始新的生时校正」 | 失败 | +| 12 | 存根校正会话经账户页脚回裸 `/`:写回 `?c=`、校正界面打开(不是 BUG-1038 的锁死页) | 失败 | +| 13 | provider 读到另一个账户:快照清空 | 通过 | + +`home-warm-snapshot.test.ts`(12 条):写入 / 读取;换账户清空(含今日星语);`redirectToLogin` 清空;退出与 provider 三个分支清空(源码合同);快照源码不含任何持久存储;不齐全判定;今日星语跨日 / 换人 / 换账户不命中、无出生分钟无键;齐全时暖启动(新建本地空对话、默认模型、游标取舍);每种缺项都拒绝暖启动;落点与冷规则一致(keep / 存根写回 / 他人存根丢弃 / 裸 `/` 校正头落咨询 / 新建清存根 / 无会话的人只建一个 / 存根不在内存要 lookup);落点替换旧空对话、模型对账不改身份;挂载 query 的三种来源与记录只取一次、过期不信、只收同源路径。 + +### 全量 + +| 项 | 基线 | 本分支 | +| --- | --- | --- | +| `tsc --noEmit` | 0 | 0 | +| `npm run lint` | 0 error / 126 warning | 0 error / 126 warning | +| `npm test`(Node 20,Linux) | 3928 / pass 3840 / fail 61 / skip 27 | 3953 / pass 3865 / fail 61 / skip 27 | +| 失败名单 diff | — | 逐条一致(61 = 61,0 新增) | +| 测试名 diff | — | 0 消失,+25(13 + 12) | +| Python 源码合同(`tests/test_birth_time_journey_contract.py` 等 5 个文件) | 25 pass / 2 fail | 25 pass / 2 fail(同两条:`test_jyotish_web_auth.py` 仍查 Supabase 登录源码,基线即失败) | + +过程中一次全量多出 1 条失败:`chat-navigation-a11y-contract.test.ts`「auth redirects stay hard document loads…」要求 `await selfHostedOtpActions.signOut();` 与 `window.location.assign("/login")` 紧邻。改为把清快照挪到 `signOut()` 调用之前,断言未动。 + +### 改动的既有断言(三栏) + +无。唯一触及的既有测试文件 `new-chat-from-people-lifecycle.test.tsx` 只在文档加载的 setup 里加了清快照两行,不改任何断言。 + +## 构建 + +`npm run build -- --webpack`(Linux): + +| 项 | 基线 | 本分支 | +| --- | --- | --- | +| `/` | ○ Static | ○ Static | +| `/chart` `/ephemeris` `/people` | ○ | ○ | +| `/reports` | ƒ | ƒ | +| 路由表整体 | — | 与基线构建逐行一致 | +| `rootMainFiles` gzip -9 | 130933 B | 130933 B(0.00%) | +| `/` index.html 引用的全部 js/css gzip -9 | 654283 B | 656646 B(+2363 B,+0.36%) | + +## 浏览器与网络 + +本地 `next start`(端口 3431,本分支构建;基线用同代码工作树的构建,端口 3432),Chrome 151 `--headless=new`,CDP `Fetch` 拦截 `/api/*` 返回虚构账户(林遥)、一条生时校正、一条普通咨询。页面里用 `Page.addScriptToEvaluateOnNewDocument` 装 MutationObserver 记录「加载环出现」和「首页可交互(标题存在 + 输入框可用 + 路径 `/`)」的时间戳。返回阶段每个 `/api/*` 响应都人为延迟 1500 ms:如果首页等任何一个请求,可交互时间不可能早于 1500 ms。脚本在 scratchpad,未提交。 + +`/chart` → 新建对话(本分支): + +``` +interactiveMs 23, title 新对话, loadingRingShown 0, url / +POST /api/daily-starlanguage @+15ms → answered +1519ms +GET /api/rectification/cases/entry-summary @+16ms → +1519ms +GET /api/chart-profiles @+16ms → +1519ms +GET /api/synastry-reports @+16ms → +1520ms +GET /api/models @+21ms → +1523ms +GET /api/consult/status @+22ms → +1523ms +GET /api/account @+22ms → +1524ms +requestsAnsweredBeforeInteractive: [] +``` + +同一路径(基线):`interactiveMs 5530, loadingRingShown 1`,可交互前已返回 `chart-profiles` `synastry-reports` `models` `consult/status` `entry-summary`;`daily-starlanguage` 在 +5526 ms 才发出。 + +`/reports` → 历史会话:本分支 `interactiveMs 19, loadingRingShown 0, url /?c=…`,可交互前 0 个请求返回;基线 `5532 ms, loadingRingShown 1`。 + +整页刷新:两边都 22–24 ms 出加载环。控制台 error:0。 + +截图:`docs/testing/home-warm-return-20260926-chart-new-chat.png`(新建对话暖返回,今日星语卡与校正入口已就位)、`docs/testing/home-warm-return-20260926-reports-history.png`(历史会话暖返回)。 + +## 环境缺口 + +- Node 22:本机只有 v20.19.2,任务书要求的 Node 22 全量待协调方复跑(见 `BLOCKED.md`)。 +- 无受控登录账号:登录态真机、iPhone Safari、真实 staging 数据按 `docs/testing/home-warm-return-20260926.md` 由产品走。 +- 无 Docker:`npm run test:db` 未跑(不动表)。 +- 未部署。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 35503bbe..2860aa3e 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -120,7 +120,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | | `TASK-starter-home-polish-20260926.md` | — | **首页开场小字与图标**:今日趋势(每日模型生成,非写死)移到问候下方副行;入口下方提示只在有未完成校正或非本人时出现,删两句固定文案并修正已校正仍显示首次文案的分支;今日星语图标 MoonStar、点数图标 Coins。排在 BUG-1038、1040 之后 | 待领取 | — | -| `TASK-home-warm-return-20260926.md` | — | **从其他页面回首页不再放加载动画**:首页按组件挂载计揭幕,客户端返回会重跑模型列表 / 后台状态 / 入口摘要 / 今日星语(3–5 次往返 + 最多 4 秒)。产品定:每次打开网页只揭幕一次;模块级暖快照按账户隔离、只存内存,暖返回首帧即就绪、后台静默刷新;缺项回落冷启动,不许半揭幕。**排在 BUG-1038 合入后**。BUG-1040 | 待领取 | — | +| `TASK-home-warm-return-20260926.md` | — | **从其他页面回首页不再放加载动画**:首页按组件挂载计揭幕,客户端返回会重跑模型列表 / 后台状态 / 入口摘要 / 今日星语(3–5 次往返 + 最多 4 秒)。产品定:每次打开网页只揭幕一次;模块级暖快照按账户隔离、只存内存,暖返回首帧即就绪、后台静默刷新;缺项回落冷启动,不许半揭幕。**排在 BUG-1038 合入后**。BUG-1040 | 待验收 | `PROGRESS-home-warm-return-20260926.md`(分支 `codex/home-warm-return-20260926`,未推送) | | `TASK-new-chat-from-people-20260926.md` | `PROGRESS-new-chat-from-people-20260926.md` | **从星盘档案页点新建对话落回上一次生时校正**(产品 09-26 真机):标题显示旧校正会话、输入框「正在打开生时校正…」,中间却是新对话开场;另有两套新建入口(`?new=1` 与 `?newChat=1`)违反 BUG-1015 一处解析红线。先复现写失败测试、定位跨页残留状态再修,合并入口。BUG-1038 | 已验收(Claude 09-26 直接执行:子代理复现并定位 sessionStorage 登录返回存根;Claude 独立复验 tsc/lint 0、全量 3928 条失败名单与基线逐条一致、四路由 ○、gzip 不变、修复前后截图核对) | `0a8350cc`(已部署 `6a22626d`,health 一致) | | `TASK-people-archive-p1-fix-20260925.md` | `PROGRESS-people-archive-p1-fix-20260925.md` | **P0修复**:worker按持久化人物解析、真实删除预留退款、本人历史OR is、切人/消息时序及P2/P3;BUG-1031/1032,关联1030/990/1024。永久定向54/54、独立前端5探针与退款r4六场景通过;必须最终Node22 Linux全量+DB逐条基线对照 | 已验收(Claude 09-25:两 P0、两 P1、七 P2 通过;新 P2 删后跳错人、人物接口故障连累聊天 → 收尾单) | `4d801e53`(已部署,health 核对一致) | | `TASK-home-page-split-20260925.md` | `PROGRESS-home-page-split-20260925.md` | **首页 page.tsx 拆分(只搬不改)**:启动流程约 285 行抽成 lib 纯函数 `runHomeBootstrap`、恢复轮询约 100 行抽出、错误 / 引导屏抽组件,目标 ≤1300 行并下调增长合同基线;两个按源码切片的测试改为直接调用函数;还原 824647f2 的 import 挤行。排在 chart-ephemeris 之后、people-archive P1 之前 | 已验收(行为逐行一致;1417 行达让步线、合同基线已下调;流程违规:未跑全量致门禁红 3 次) | `da35b3e4`(已部署) | diff --git a/docs/testing/home-warm-return-20260926-chart-new-chat.png b/docs/testing/home-warm-return-20260926-chart-new-chat.png new file mode 100644 index 0000000000000000000000000000000000000000..eaa313b43a7d2e55457fecf6d04f95bfccf65ce7 GIT binary patch literal 52977 zcmd3Oby!s0+w~xVk4QX{0s_+A-H3F<(A^>39ZCrZNOwpN-AIFU4&9;j(B1JJMc*&3 z_xty~e*EJ>X6EdD_Py_W-D|HkAqsNhC`fonAP@*eQbI%t1VR8V;pzW*0{kOyEbRaS z{R@&5d8_P}y1R&=kD<|s_(O6`VsSBW3>ztSWxs22anZouReeUs>FC#WGs2uw^?ou( z_-4A(x7k~EO^Mm~Cg{dSI=7=~^lRL*L3OIXPJv z3*+wqs7%bvs%EVU>GUg{DytBbnTbL0zu)5*EkR~n#jUK-wqe(4_)(gcCS#89_c@Fp0Rua{Bjj_dh8>h#l`2tTs+DRF_4a0l3Q@$I z?eS8vut-hDr0dQE8CI-KmCO?pfBpTmg`$4q>HAgE(9m38higHoAvpxt=Sx#l-6JDR z)33m+<9V`p+*~tY@{Y+#_s-smz4@B2dL+EOH{ZKx4?32f1qUD^G*69=Ch<5cK?!wQ z>xrV{4+Pz)6jFL@H;U^^OBulVGR91=&_e=l%kAy$M@K70nVA`Nn>?;haf7qRtHjZ4 zZ5e+>BuMJRg!1~P5*Q5ji1KQamXu`Y!zNCK?(VLpN?*9tmozT!dt)uugSv;uR>12r_>b>HA%bj{P-t^R1ZrSY zr!AP$d1oou#%i%)b>yefuv`S0Y3mD5TN4ZSt1%5vPhJYJ(Pp`JgOehITD8rh=H$s{ zhHtg?bZu&|Sy)g$x5NA74U%e1Rk^5GFP3yaUm2KyM}uSrz>_@aOO^^>y6Qj@pzlfzj@voam9k8T>1 z{q340xYsQIBL`W^LW}oJ*8q;9qN1v5Jjym_F*qMp#n29gNhnFAKXNTet~D_8*@!>; z10i}$scxerp6kBAo30t*{iOYs6`3bh+Z8(*B^(?a_V!1Sg#39#^67k}9lFn&$*n8Y zg>6+TvY>${`KPC+1xjs0L(T7QP_;N7tj+ZmO0~IBgTeI)kzq*Skhp*?op3$u(Lv?`2o^W|)miii?qE565!DdLoEade@zICddsg z+uHBQgu So)w#xsv(nXo*;ZvyCAwAiU|XgK`-5_BT8Ze?-x1)fHVla9C5g``m7& z$HgHmo?wg}9Ip=;8X0Y!=St)7xNdu_tTB7o-!(YL9!m!t8}&qxqL|?%^nvrXcXl{! zm%O?{u}ZY+AlkX_m6VjcgthaB)2M+VgXpob2w*!qvrcpZ?hw4d~{gn zd$D|Yp2oYhv}7bk3pFN#bGr&%pDxoYQvdoyh2>vAxXhXTW#5^)puOf2;pF4zXVl|)cjuSHY8JJrLaVmD zebgO60M%#0W4DQoj;_>iZ+bsjcyS;oisVy8^F2L}9IBVP(BaPuCOVkCr#oUFWx5 zY>_Rb3ry9yB>g?*?WvNxtMO^Af8$Py?KaQZwY0Rx?k|s4wPv=BcGuW4W)92_h0Eik|`Aylg9h;cC)$$wz`~m?%$v_ z$m+p2IrTk}a+FvgU)I>(!GF!`^7)?qZhKw&_%$A$R7+3}Ww3uKJM%U&J%&rC-Z0Ho8uCcTd%2 z9TcfC!o%xbU0*LX`c6-EtG-pVU2cMG72T=`SWa0T-`vfNW}ly*^Lw0w~>L*#wl0;WQj{bRg(AKYjZ20tZUO>*F*vIE$czDAV8M zuaCXD-uhI4a=uRIv$*})Aui~B9~~cWV`#|dcG#v_v-1oQ zQKjGq_>#8Pdj87VuKaye3*t<7_r_0ut%*o(@9P}BxWcu0@p|o_ySsFlrnbv1-nW+_ z&ro+=om{-mcQxGHzFl|TwZzdZ``WcyRGF^x@tHx>*-Z!484`F+`qw05D8Ro`#$0<} zpGEg+Qigzc=;u&m4+1CHF}bCs<=8u@VO(OOzM0wP*49L!2`w6$D^Q|^T}<@#xlB*h z1q4Z{x%(!&_O?c|8yfiF;Q`n{B|DtnY`1-yM9AOprB;G0nZtW3o*rM&=XzyrO}$t3 zVRx=$)!vxpDj)>xdN5~dVltX5ogWYoApC?Wr2nZ!vDS@vqf3d(9S)<8MzuNanNcri z0@<=$I^X)n5WW-i1N<`@l|o@3|M4){WVYEVv*}ds6T1|>U-NKA<=%36|9-pa%2BTs zl?0B9nAm6Zdsm?LMasY~cNHBS9ffSFmAScy)Jypc{L zB=Yd^Xfq`AtY#HFQNWJsxj$D*@jj!WA|rDe-o*vr1dsD^@V>M1KWC@X0HEQS23_;^ zsxj*6);n-#iVmRQb1I*gEYyR|D9y~SIbS{B_VN)kHV(+n#`x74WFoMzjMW{?*qEA% z8J3rp>fT+S5$qBGwH7n>BU@+ShzDj%=hf&uBHdkaigtxKLf9HF9S+AIlC?K%p-Xks|at`DDWIWf( zS(0mum(u{xq=AKjLD~la?Ubt*%6|0sM|jd6vKp++`&!U|#l7Gl-fL9ij_$+T537_i zBh%xIn=to_{WN~h3o;6dFNaoUX7WAyTKFmH%^tsZo8}v4>Lk9%R{{@t^+%ZUb zJ%dv?Z6$J7;_0;%yCw=%ERQpNki?{vkaM}omXEKzLPShWO>L|Uh5MW95kIkjE5U-v zT*}$6kn3gQ)6>(VqX$i^kWo;ao$LfSZk5o{SNQwg70F2jh$~b}t_wvyj+;NqLEIjp zhRU&oF?p9kL$mdbARj}Wo}dZE?djD?vBpUPL(;+ie!6AM3mk?>T;BHH-rm;tajg%! zWj^wY#l>{q-rf!TM%}6Bd-GEeuyoD&=xWc+Ie}7I5X!IJC%E5nqu}{$(-xJKNFae; z4-X%I;HOut53OYW1mD8-!vAxrI@hq?@CgfidVCgPE=aZ4!gugP8l6K4_2Ix7e zoT~JiAe(4i)oU}v1T4&SW|Ez^(0{h38HG1yv3M^f<>5hd^>lFch{{h-Gz3v!%3aC+ zw8oNiY%CT-BrveKuBL`qk8B;!=)LJ&lZFv5FYoUT;D=V|D?bZnW@c4Y)k0y@_wQ*! zK6H0?Pe6|e;M9#o)!w4=@`~#;`yM=@86I}?KC_MgkSpEQ-5t*`O6mv6CpwOll00RFHSIdX`QhV#xd7nT%(sGgEHI5SH@ zlxL3^cJIdX8SywvZ6jxce$5Y*X=PZUp3~i@)W~fDA&-%4%{K2(7O+M-YtB)!aH$>X zo{DJKKa7dyM9w(;@tqv*9Yg$pQZD+-+z|too!Ud^<8p5t?8Vxe){-=fpfr1KNClPj z^#|Ih?!20@*(RWkR*aBNLD659u%n7c$IYmX3aZ&}9LL_lMJ*R!kM1GQU z@e-Y4Vq!UWTh~LZ!KlGI|8%|X|I$9JAVKYIbAvkRH87i-_gsb^xwu%yM%(+2fo-m9 zZp&pPVIHl+kOW*`^ zE!Wk|_6&Ztt)Ynz$OgTI>Zdm9FKpX60jQnvk#h z3dljWqPB=JQX6KJx%s|Q^6|!|lnB8Ui994xpMF(q0Gem`x0lz}0<1!oR9)2+&D%+kN{4yu?V!Qe+*|nZ{ zd0piKt+bw1MZwQali2jUIk~xdT)uqMWGazFf&>B%O^uB^Ax6)((!YKC<{um(=zhI> zvdQS;QXA(*d%itc(qzW%A;|uoJu7&z!8yND&w7l@$0;K?7(mR{3pJk0D}j#lb+)o- zKTfWKBU9b(S0+<9+xZx#`}*vEvWb}ghDv~6s`5@%m|yAcu(#y5NemMT$QbH101IbPL@ zSBsS2oBy3OsUF|a`V;k(xIvUersdN?1ez&HKU+1v$E1|{uJZ*x$e5eE!hf>7=C$qR8?PJpHg&^ zaQMj#s`sU*A1e%)yBdnLwCvuv?LU2OrKNHZ3yZpg1yZ!=fM8TyA+$G)Y+jw0gM%_0 zDLSR8jp}qQ4rkZvwA0sTd;HG*T))d0NND`?=WxmMkdO!_Ha62LZY$JH!3-QtiWsR* z!w}`~PzlPK|^@^NYn8=A~X{F8g3?OEPYYOdUhAM)2 zZ$uKw^zAjsK5i##x=#@ZS2$x~P8PW2lFs{7IhX&0CDFX{;?8Aa;@oB{y;-G2%l;@( z@iaOaaZ2Mkj3p^`?8|W4ToedsG`bW?gIVrd732)J&+zMdJuRne$#dzt^W+n`(sGtH zSo2+WN8(Qhj4N+0h=vlm0Rr!;@nKe}rD=1Den+nf`8AuHajq2B^nHD0CVBZ-u>4rPZykVYY5cA~TfBoO;SWpZU2o3z*q!^`Xf`AIc^_u$3|2Q5JJZJ)c~gGJX&+vRRsF5k(?iA$UC=l=c`ftkQVBhj`e^_s&mMF6gs4t!d*6Sbh|8&evY;eeJ4uGcs)LcSu9MJ9hp4F0wR$giSAgI29?O#z zTJLW`MSXC5Qd?6q8E}-XT*hv-<@W-I(fj&zy(1V6i?9L{;Tim=+P!h5N#S#v?MCt`^EZL`TS1Eh0{0gO&;1!Hwq|=6tRJVgSWR_S14+l z=kx(neaZK65~((`TcJv5*i)Tjul)s46=?lQLa$>Fs`XY^zI;Z^CPxXpgS7;j+vygw zit&0sb@l;(j3WsI>Eone!`iTf0?oGP4`@HySG z(AWQ0Lq-~!`D;2r@$YSEeKM%eBuTbv$CU&jNpDWirfhV{PG;wk>-*&O(&r8>P@5{A z7Skap*06jHnLB1kzi*A2*`dB|W=xePzVaJOpmzTkNvb>vcYzuiIvnXlrHa4`xonSg z)bn`z=htiGR6`c?6hAH|eRW#xE#=PHbaWOO^}hRm)NO2RjK-8glaP>j0|k1KdXDPq zY9>xjc_}FhK}Kp1ob{UP(|w(*%AU3?ldtzcGs4DMJ|pc*?nsk0=}(Z(=-`Q&D5 z$VpiwHPju#va*z}!+>*4O^_!utiXipfZ*S5O4X@>h#505vMxboP^-}XZv~ejA~=T> z2b|L+d0eJUhVeZ2Nt6u3s)C`dD@GYj+|?L~jzzO46z;3SOrxvoJ9vNLps11g29>aw z{9)ZDw;IRI^B(zB;e}7wzd)$UMApj3;0;s);;y%cd7Lo`*{P;BrWcqp$jJ{ zIXFaR)@NTfr2(C}9v)VtmOSF&u?mHB=rc)N#`WeP<0OkrN>sKeC5df`mO55Ca3G?Gh-Qo2(vvKgGf0aj}t0zPVc|{l_JqH6?9-SD5m67;R@kl zB?*Yt2PS&%@QnC&c{%8?1jaF|2rlkQv6CW&ATKHS%P@4~1q--}Wt@d|ys}Ef>KI+r zowAf+crD3T?QD1?RWu=0g~rDxj*o9aw*}eje#JydNh)kZ_VefaG=WC3@b_z1_je1^ z*Gu&$etgrVI$<=GXheeJIIqSf-hT6LRK=q8pv4T`Do7;RCllC=-NlM1=*)ON zmMl;x?Aa5hgp{|tuqzt`KO7=$*%?N zLzOL6(ms4B1q>Hx|suEdBJk4eUS*%ztW!HA&tYk26$jACsnA8r5Y3@ z<5MM@zZKRYSOfWCSPqH_Yr@_3dg#C$ixHjvY~Jzwxb zvT);^&_I5eHHnAp+`-A|n+#&GB#a3cSxRiBT!y9(TdInMJSk}w_jWLLZ7ufOImcs6EJb;pP{o&U_*72;_CkCLrAx4exH!kM~)6(og zIalnhjMn2kZ@9Uyj$+IXmb`l#GZ@n5Z5JzB3iaYLr)>eCf*B$Le8ggnqjcoTpr0sR zqM+k-bswRHqF%2OybXfUjAyoln@U&DEVPwXH!6ju-)*rBxWUZv66j%!0$>-B` zB@I;?L2dzNZeqDNYx62r8;V7aqqMnQBym#nwFmLBv0m$A7Obpbq|8n-vd%_NH^tu( zslanPLOl`?74=o`L||3}GOHmemKP*;{hB{L0yQitjh+lwokC3_7YQOl97k=7G-%B1 zr2e`hAle>FsX~FK&#?C!9YR>vnnQ>XML#AI>nx(`dE66#Y=T5rm>smw%zOh-AnDy> zH)I}1gwiHMG2Y+JK&@+FY-pMTWn%jg4H`6LUT3M0ks`4hrc+I~Qei87=F*a>SU9MWP)J?r=pEOnKPQ+qC9RLsmUAhubOiXGNN z!*GUc%BjZE+#c8UN5a>n0!UGj<*KIQw0SMt%2PFLahfJ{h#W-*aYAS=Nr*NLC6>Mt zy=w0o9b(s%;uaMdONAe?kz=9~s+QQ41M*&4G73jUIeBB2lwWRcSD4ua)nft~QN=5& ziT=@FT`j+PYGjJQ8f{Zqd5H;@qpm7brXYRL6H`RO4(l_FN{EFiAeTmybpr_;sC zdMI*8;veXsMBJwUq@;kM95+5f@yL8L{eEU=Twu;N8oW9^pETu%AxyGsVa?J6dAN$B_P!A2Gs z*Pv?EJwOu(Z@wtTZ3(9k~jHWL}#czlH7&I;N4dUu$xcp+Gd15hDSt{#<#)A(%4tMcM^<;ybF6Ax z8tQ#rBvr>)qPURTPtbde4L}O@>qlYA{1Wd+lf<=m{n^EYtL{EjaWV?>2n(qfD<7D(X||FBgo z1;+4pOQ$zpi|Al9+t$%>$#Oa`t%#@)E>hXl^yHUkT_aN=Mpjay!JE8FiP4zO4`OK= zMNX%u^xj3IY1>%(!5%{r*F!ZbE>a(v*sr!{(znx$N2>-vK#XjOHYEO$DYb%rA6^hg zxt94f*%gl-)1R(H<}Aiy-B*)uYl7P^JxHm}B+E$-w4d`= zg0DiuwLD#5zMkh2_8yu`oDjk|xML}aPp%h-S-+Pc(}}7b>98@oF&5@}>SS_#ce#Jn zZDOk1q7o!e47=z^^5>Y`#%2ktv>rlSdy7ZJ_?mJRh)uG+oF8gQqfsh7!-Jp0J?bso z@4E0|?Qy|!6LrqFPOw;E3Kxs12tKMBr1EPUjOC_FyM2YpiYf&AiXb@~vO9{vr^)Va zth2Ar3?fi8zldL+on%!TSI*eMOdABx`e6ZaF)1IR-f z!d!!r^JboPL8>t=#&bO@bsOUYE8P|nSV+BZ8qs}UjS1zSBF#Wp|TN*cG`M8>1F?z#$RbGr+6<1v7#0D2j++A8%x|R*b)a zbs352=U7uS6F2km(xd}{N0jKas!n=|&hA7GiV72Y2@yj|sb&r%er?fYx$yFz1{D2`Nx#k3yh12$TN~T~+1%lwK1tFE#PH_dl}=%VXU@qqLSrtBrBe z5mly^MgTl4q1br+ z)5^mkv)^N%{+V%qL^M)c>Cha^&TV5UPG+Hb<{DA?$h+P{6*?DTlker$v0{(#+#d1# zDwW+@tEj!%-6)*DX{^=jA6{p_ePuy}ag3>5RjqhGhE7ZPVdSPKaI5r{;uf`O% zy@_{qKjCAb=`cCk|H7k$Mun5{ph%N^2)k1Kfe@u$B=t4Avg5HZ{Ii6?#KR_61fn&R|oLn1vXP*k*47;$S0Hh($&oc~yUFot)sdx)4ntC}r8 zh`zOS-633uZ;Jzh<;&$;-Mtp5jlp`1xRTC)>9k(`Mu38soOyRmcM`QoHnE4DSvK-= zSI`DE?Bpy@oUYVCioUXMM`y>$=?us$is5FJ-0b=ExzCtpwJlqd>@Nd`giF}D`Isz0mhTPJs(dOoI%!fxsPs=xsr-~Eh>4dLJN{}+ z-*!xcV$?15>YkrD68{0dZJf-1Ko^Mqe;8E!-&)PbT=$;^d;Z%IIu)R2D^Nv8N3X7| z%F4>@?OG+u1Xw?AmP=@?1PB{RG#Hg=-uH)uu<(<`)s1b3{EW-nH@L8SIeKSf!%#o9 z~4R#adBpb>j0#Lg8IU<)qY$x zNoGTFxS8ta^+>Dh&3TEVb2^{%`Tbq+HorstEzqJuGnT?5BDft+*V^E`x4^}ljN0jm ziGMmzzo~I!P>cJHoSog)QTENTY-{J%Xa}eD-1SoFLl2K`BHz+h z#|t)ClVba7ZPe(IWGTQ**M)V08+d4`nx3P-6gp2})02I<<(| zQX$~2PT}x&QBtZ540Qk5cxQ_6?d@Fya2%-H;I-EoGOkru?0d4YH~(YAMu&IQRVTUC zu0FEPEOKAxZ);_P_o59rZe6QS-E37m7w|l9a?C#m=#d z;oRDrlKg`{>sH&b5SS<3?oSHxt;EMbO%aI*6j8%H!;P0)t+ON)gW@tIMF> z+u9%7HTW_VMebGIq~gemC@o&=9%*yzFOqV(`T30o6R8&)oe4Qzo-S7TbeKj-@HzKi zHu>`p#O-kEb6IR1e}ksCo?euZ#PsR4`s}FXMhkrz(r3BYUy>3?1=s!M0v?Cp=Q6%V zH-bjQkIU@PR1pvGfA9%P8w>`+pp-v3I%43vqq#7(c=_*0EZ{%qz#I*WwnJBZa){AE-?Odq}W6y>2NI=AS2U50?3Zu!LUx2lUzhZNd2u zU;;j(ssPfO%eC3w-tsSmkI&98BQNL4A#eoJdr}w|_5${22VPpdqLyTi-Oz4vSnZE1 zf>^)+aVABWjZJBxN+BT_V|A+JNiv7E2;A)$Aop72Z+gseR0M1qaB?y-fa+>KI`(iZ|{B#96(y0 zJh#R#R6EIk9G{Su1q(>f>r`1!{$LQ+Aw%H^9D}nYIrU}L7uFOKLX_t+4M4aHrPc$s z`;I~nOMCNm!;_Ne2X{AUlb*n-w^seiPTwSY$lBQsr%KWFBPj_2J;=wvaq;q|46H_- zO3#5Q!iQ>9C$qD&-vvE?`Y~(Q@gg05b69V1Qc}(D#auu!0l&*}_A}1E#MVQ8-!GjK zIXZ0I4BE;ajZEqfA)zuvP*0Pf8XF(~Gjz}dzz75fHgBG=0-8px&UR5;)lMWP|2`go zwC`8(E%2bZnN+P2k9fuNe(m2s0bHF(0bibP#{KSHw%~*M`A}301(d|UmD}q7R}e_M zjIm=z8`U_E--L16Ebw^To-X4*S z_cpzciRChd7kKvC9v%&Gaab0BOhcOt9p_)aTx|xmnpe13K=#kk92^`}YQ@KRToQ9i z05>?}bJ&nWt4!tk=BZW)zyJR;eH;c*!NOJUzJ4`ODNpk6&em6MdLsjkkP2bOSn@g{~MLa{_vT9%WA zCG$^!G{z}|?%#HIi#eg3+1IZtk3CuV4_>nbgheSN-wF8;i1 zS*6IBMZ?&*I=lw!zupy_{zS*g;9%Lz%&hSn7bQ7)B)%Z|n@o3=R$oSt4?xNT?tp8o z?~jAcOas`f0Z$J=D+5T>V{^eX$WXvSgnV*Lv&$htHpF4Q#9`ZdO{U%nq*9C(4Bl$n zqU`J{udZ!Wg1NKZ(LmkF8qZe1nD7c0S2md`=npz7>5-5O{mN!`{ka|}m^T~cwbq&p zIxHDaE^`4H{{O+nn{$CM6i1l>w?ZItc}D>rnb436R__7(QyV5RUW z{>(BH;XfdI{iiamZ-d9!l4P)7qtjp_P4cpiu>u~gp8rjPKYR>=KLms)h$y&Sn`VWs zz^f4b4Gax$ZwkrT3)Qk8pEbyb2PNZ)_ue(VxA)zdrJC(>?b|c;N;g8l=d#UXx*JO= zpP|@=iI4Ak|2>Z16ODq5OfHcv?X=15isqo4`vhCi`&_%`n@>l4UHez};9tR2oE2(1 ze+~!$5Z9Xw^%F|H|70(!DmHDn`&;fN2l8b^lhU<^RC;n50rZJD3L7 zxUF`V7`%J7w&>{K&>tL`Awfz{Ut+MTT0ezK#9=cfM6q!}-h6e9Cy~H#9i-3c0(OJIu-1-62VvIA66) zx6x-Y`FkCY{VvEI71^WN>Ix^?M>53*?uTdC*bx zRajWyDj$t{_6{l!mroX&oIt?cMXCgX4UC8D?~d)9oW4lpls)m^I+dn|F9(i0zK9L@ znHslMlE1)Xe=mfGf;aK?nRcs>C6GYZ+E?xGid1uX6Uwv~D@`1E{_+frnK?9V;rQ(O zEJ=t`{S$Mg*3!qBYO*N;7bz(*-)$e@I#j~8Hwih-CzK|aTCCrly}z%oT?Crw4#YF% zu$(8wMMdIR#TMgYYf(jx8>f(N3b8-k=q-&D4d~|5E$Z1>GmY9Yxv1v$4)us7D$#=I z#RU(Pz4;!U9XPQ|J*Vb0pI~SZRQ%B=LPF|fI7_beobM! zF(*Ht78B{2=gp_yEj;wTUPTN*4ph)kWS3y`u#{bp8r&EX5(1bV-O*Ni!utm?x3+zT z`p7td9e6ugB%~yk&f@+$VIYm!+F`5l0;rJ0mT0f}@Ngli_H&byff37(JN|HhY8IU7 z=vh$TpLe?tsjA*KKE#XjlTE0ntd*lDV;dGCP?pI0bCol>>U-AT%O# z0~??Hg)YgM?t*jo%ibfC0kzGWB|zSn>x01ZIHFje+*8agAkoOCa_P{-0QOiI>?ltz zNuf~{g)q`gKeN@lhr!dc(c7~Yr{x)-ElOg|7ZDScCS$<>8;yY{%f_PKH41uNdacie zC$ksj=jKMqRYHN|Sw@X(PeANQRkSx_blqW%`;~WiH z-6`O>rpw#?uU&RVecQk1(#A99JWlEdj*6802a2ny**?i;@i+g{X*AHB>Zk(}lMFt0 z#X7mYk&*Ip*Mplg@UUvG4~iWF;2AtRDN^dSteCY@!`VDM{D*)5`%fKgEl~16sty${ z)n_T_?JZzl%>yvxeU(v_1ljgdmG7P=yKW28_Cf^#zl<|Vxs;}6a!80Iz*OwZWy-}E$7=|SW6bREv0dzyp> z!Q0h45GF9{d0+3k6B`Nm+%7aYzu4BYS#01n7-J(P&Hey?t*)UFLBv_9G2VSzcy(g> z<$bvfLy>MXjmGZ8*Ak0i$>D84biwYvEi+bLRg-J0ddk6-Xnw2$-{xnaRIX&V2^C-S1CNX^4e~^yuMBsaIW53g{C($8GJV`bMn*=|;CI02$bXNHirVwy7F=humlX+6Hscw-B?GQ15)!@R`OuT|i-YNWml8Yx z_2(hE+zc<$Yt=S3G|=f(e%Y_6tjy`plTIjY^1{7b=_;Cc-KW=R`f~raQ0>BYG^@U* zCaxqryylpQ79Mz>@bDBdU53nidwUZXjR#(=1ZRk)`8Pg%{-AOAL)P!{A{$v2XwKI(n^mA|=5g9~zwN{w zYc<+T_ft)ar_&N$VOEI-2O%137foRR! zj*=iV>h?*yTj?5T1LQt{hhQ{}-_ylH<6%^ROL0mVn{Zs zqzp4NS8T5`>NaGxnCt7?J?yLenyS>Z<0F4QUT$v1+%^D{$Hc@;U<)d6=l24DS$z_= zx|#;`z=VWfft%(yk#cb$hyZKq|>SaJKQ$p`e_`1*~+x)rdq=i-8Ct$Q|A2=;+#PSG_VO2LKerKAM-xeGn2_UjO8BIdWfTyUd{3M0c>^ zxJj?9$Hva?eNT6hWWLturi6xus(1QGt3q76yEU#oMxFY;W7f zi`QJP_lBi%4Np>CnPU3L7e(SU0KHr%%(J#bNm zug+xRdv?5$aBzMB0J<0x1&hEvE&$5_(7!l1IN})scgH3QrmA_$G{k$Z1_xSo@84#K z0N+C3ak1}x{ALfpV``9H&$Df3fZ!>6qSsi@gO7_F#epl|KlcmAVhZ_J3E}JyB>a;k zBX%z|KR2h8+jh`e_6BXz@Pe>8gWvP5lKGFK{O!qUAc_HVc9|ydEdbvG!xA8=_&EGt zc3RFhj-zQ%YWx7-=W5;}7V~*ni~sy%u2^M?qQsd8EL-9Ryxp<@zj#FQNUcsCkMny` zF;RZs`z%10CLo{-IGHmzeJwmRuUAB?!dmKVRsDc25&!b|XYRl>U{!8*9=Ha+FQNqn zb#JN$1A++V&KJ6I^8`HWqBYhMy^V}N^#X*6AuHEcDp?K`?Ici@JDQkh{N00tvAa@V z73lPif=knvD#=gI&dz#Q089(m;psJ*QkJ-Z$;YA}EVoiZ0a{WShJl48JH{C2h;+K( z{vv^1C-$KH^XJbyoHnC-Yin!4Xhf4`dXY2{z^1&dapAWJXny#iIGlgjUZRO$wNM@p z>`xY8f7Vz|UK*Vp1Bfk^$Gf|;Gsi^eggT}t<{phm065e02CPTJqUmXd|1BMN+LET zh}ZpXAm58hgu)t+>ubPPr6kz3KJYM{?Y=eg66jw>v&Gj4T-LXpfo!JP}6{ZN8nTfaI-28LwZS~xRyapcB2mI3}r^awK>*C*3I7YK?pAr z0GTVR>yn`-Z{rkw3Peo6?Q*7XbaZKH|9jT}S#Htrcpd^`vu>>y%HmSvMdlmcl-oNd zc6N)Y>X4Ka>Fl7h?5r#xZtg4>^CF1^P+!joW{P6`%Hl}p;}q>ksHntH07$%v{Mh~F zRdKR^KeVj>$SAlVV`Xsmg_A$vZh7^pSob79J-zfg30fV7!w3PoaS!kITkhAW!89MO z=Uo`|DnfUCe>uvpPnFIN=IeZyZo9B(RcFqi9Kem(2>m2=y$=s1m6fZNX;hq}dCDVQ zuK*em*f}7t+ta?Pg*r36e*m}E;&m%43(G{=?qcIXX5k%lc<5Aez#s9<-pTGw>nY5K zpP&C)RcsSV2(5uSZZbq0-yf~!FXzequ$u{N@@VyGp1=XpK|*38V0-^x%>89ll-HaQ^^-cpl`6@>-cLV)f`5~v5$bLkP81ui+zVY0_1<^?^_5wZ<><7` z{7p@bu0)-@N)H*gx%{48T5I@;=lkA6ycD@B?x@&J59}EJDf_0E{k=$s3=zL&w6P3B zOr!6~aEbiAS9emHP1mOy;y(NAv9ocp)H*HV(_@X4XfO67t=9ydd#YG5BkzP~{5D>0 zC$@VSzpKaIX;Gp)YpbR^pNG9FNhhfciG2#jKA#2MwC@@T`^5q@`dN?I+q*kmMB>EY z$PrP89xS`-<6`&yEgnM{O`Yr1Th8Z<>4F}EeSNQ}znE2&OHNd)l(Z15|S_U7Wyu*Q&z$I!i z4^f>p=j`|kU2Rf6Hw)A=7r&8MIsi!|LIkaP%g!q(Dg_p7MeX%ffVH z?$4i~ZfczpqcnrU)Ksd+;Z&Zh`<57m)XOGfjq*}g_l!$lCaE&1^|aUM=;>X){P5mQ z&$39Imjo{8;o#P}srH(hCr{o)~&JaYq~GcYlTH zfWI2>pB+&41H?EoaV&9lu4}>;-QHIN58Jg)5O3>WAv!`G&wOkdVrrfp^he!&ta42J|Qkb7hAcs*W?LcH`sYp`i5J zV$ku4tEiaUoC^YT5ZNOb$60izb3b^nG}BmeNLD`dQz@k}{%tq@~NzN8JN8LDx_*ljUJirDP zY`1m`RRUxT`2A9Uj2pCndH0@XBB5~cduz}38^O14-_9SDh-qt-EU1(?I#-U2jO00* zk5cftN;e`zzMw~~-|4r*-1iefXbV`O9WRtBie?D6wze#Kb$&2l(fVwXxr@Dbaul(h zVL9ZBkylZn;eX+7Vsa<6<#45E2&BOpeQik0??_c`Qj5cg(};hrT~>%^yBkg$h>b#_ z7H*5@8Dq3a+4ba+2BFqAHaWhO^w!peyKCdA{2qsULjV?zs}MJ*Hyky3UoSD5YTUr` z#>|sD&hKnH6Zl^c$AvpCB*-7hQV8xQ5|~|0+ zGu~6Kq#VLdrhNe=Hf$Ob>sUm4bsGnQs>*aTv;Hmd5BJ0t7P zGO(f4^nLvned(NGtJm4lff;D+wZ}h-nz&;-R-fguWqwdx>%NKECa>V;=9cq1z_g?Z zlGyt`-$5gxb-#NHM^jymSUXof4$PFEo4z3Xr}sl9C(JALs_pJtI#P^iwabOR#!?h_-`>oX&s|+zg;RAJQ1bC{Pjq_PISL)45qNg$wGx?i*So$Ug~whk z+tcOoV{9QIeG&zak#;D&p>&>~H-!$u@ngEOyt15<9IYX^58>nEKmQp9;B4i&Hx*Ry zPl*`AD7oA-VJ7Lh7?s^>{wp!YIw!P3o%SgB{d;mg-+mY`)jF^1G-7sf@XVeUn3{REH(b6+P@SRxYDq$z!+r3Hv7X=Tn~=mXm%Ktqt8e z*RFsoPU};C|4v3wtH~QD8toz~um&+OB8I8U-3_l*~w!?sdEHwug! zfffiHjetNY9X74mDzh4c)A@FF7K`^w0qo}89Uc9!<-*fn2P~J^TYoV!I4a-#{}Fup z{{zTWK3h3Wl!$;ls(NO+8=@4)~;o#I%T9Q`Gf+$3H&?P#w#6MTr|-`7%m50Hm>u{-r&4{=^|LR?5<8Jj8E0N7OK|`7f;m{ zYHpS2WYH}j*dA`rXcy;T`4ay>IHrYINA5>D`eUopbrzT(!KM!F z$j1Q-vUlx<=uB_#e|95SOiw99uLz2Y+D*O1%ZZALI%RpV1g@C{np*K%B3{ltJfipR z4fX%+RnNaH{5XEo@G$RO>-&4*xt$3In)y^DB*ULAi5MC4QmXB8^DO2mQb8Q3+6LB` z|C*t8Qj8Vz7&s}CSudtPFXwYxM8~MxO(8TJjT#IBjqAOa*N{s8UAI`_@GB;2 z-6t3%cRtolYW`BE*7w5c>or@8w{PDDEw^@&5pnN_+?pD4k?5YDJ8`p1OG|BSOyHsV z-yBf9eLx4x_w4ipaS8jiEm-m9+YTk4 z<|oxxVZXY&Kib8|8asTa?2*}n3!*WYK!$SBAcgu}v>+-p5=9ZLh* z6e|+<;)V(d!KnZb&zlz0em{*i?u5?V^E)F?6eTO|r>jEkSOesqZ+-1=0y3_Eoomh= zth0xNW&95|YNDdbEIeq=mu=>%JLw@WcZ_j$zBD5B6QvHg>gwEo|Jn^M9&YH_TCxcTWR+C^h11~)6$q_uY{>J`E zNpBFZBusyh;>Z)8|F_)5S9f@L$VbkH)J+`O_1bS@W9grM@ed#jgi65-hA9-G?EpYR z2}7%Rm0RR)+W-ECLF{|eziWH{4P3oVZm7>VVeq8Nuz5iK_U{C0!waLZ}g+3n^&?hNilcbb7k46J`A+UL|FzV2tIhC_eyRA@r?L!R_S;k@~< z!3m~+ekVIYR#C_NGn0R3bdG&l^5X8&`zJ?xF3!$Iz@=T(mhl;S6G}=W>>i2MVk5Dh z4Y&f|`1(KBE5!T``4GiAh=;uFT0Y_vU)I1cj^Ng~=^No=GEJ1GJ2^fentJ~(46HNN!BOzs z!WQrMOa8f^DwS1b_nK2!C`d`Q*%K5f0MmaL5@s{{$pP`5ggtfS)04Tkc$9yA?95UD zmwX|+)IPunmMp-yskWV=jp-~rI6I0eb1BrCm#Chp_c#JZcDhI{{D#<6B<**Yr7KoL z<#KoGj>y`C+pphwZm3b@eEyuLun2ETh0js^?rp*0Pew8A0MRU>HaLviWSyM0Jhn&* zb{|DoNVDbZ4P~(46MpMS6%l#S8!6e3&ial`Bl=aPno*HVA-ywU59iv8_E|^JngV7k zrrgXQ=F8JcWVe)OFZmP3 zrl!`v=<^<;yM0H$@*2}2wX|ebc#$ob2CJkBuV=*Aw}z*fSX$P&u4NQ}tH|--GLcfs z!+}2LpBzKK6h)!vwVE6qsV1C)?vadw0!cAk@t6Z6D=!b&VKYpwiVf@JzgabS;-!oF zNERUIZ^UrIkf6XYkG&lQ{(z>6v>ZTg02N8i2zp%e&5f9n^6o$Gi*=~+ zA=Y-eb~|cnji@M%gS~~uPLEjYnc}-sQkabbP1Zu~5@q?`E4IY9iF`KyctkhJ-Paoe zJ03!k-C{SiXe@(9u>rCrbP-TvDjusFaa(v7J$S22GvU5qb%?@ogW=FR@UjmlWnrP!!SXArO2Q@v(eI-P~ZyYolpfx0<@$dZTcgHucVzL z@+%CA07)q2DTGa)r8Z4otFulHYfYO_CSp(PCFwKK4{eA-v1B32tT z7+GyML3hi1q)6S^XCAC@&kihttQ>|qkn;yUVk40>S=J*ZBM}CT4n^ig<0uPeOM(64i$m1rp!GZD?6U1ek+ktJ?Rj{oQcK^2)FjI|u{?R|T9F7Ytiw zOY;=dB}Z5X^J>PyCMAI*qc!|?P=pqisv~q6S(lFFq`fiwPo7C0*H-!A({M&_ld4^? z8~n=YgSWxwmL8vcxVL~ODoShwKB%E^wL`+fRB6OBoRuhfZKPX55SyEupaih9ww~~7 zx^p#9qD0X5kIp{mXRhM&R_|xb1ZfuR_YvEuEf_YQay)*V*BltsUq~(HD}Cl1K`lxq z;HX&4x3K!B|D%h3?frkQxvq`N5hn%bFCQP^p8MUWqVZQ(sm?jc{>A|eB^QQAMvTkd z9g4fBy!Mt>C?hp%4gs$Px9f`+^`;xoGJTF*&v&{znllYvZWRKndOTL5J{Z%%Pi3H( z!MS-e52xt!_l^G>3&0{Z{CFqEyKbaV+g=dM4LvO)q%T`?2({x-VFIj~e2UfInTpY1 zlv-I`jh?XgXE_o5>W7}566#8+W_l5CDR>esZ2>{{BPD$tFhN>={!mWlXVz|%%;Ja1<(i(0 z)$pe>*R@)pt5{foz*2nR+xN!E=v8PQ)E$e>%*u4Y&6PHMYq6W8ToaKe&N}Z%5))^g z6WB0UY015ua-Bd~nY}<0eC3)=3e-Mzl+u3psI4v7KH6`egN1r<0LCR>8VuNpu9f56 zIJJ1R#8+ZWBU~xQ%^g#jr<5Y;_MVbYf+y29#T7jhWZ2-DYgmUL`{??cG4%IusKH`| z^BwJ&Q2qfHu!7Qr{1D(_ZNUDx!I&3l;vY$0B=oRCBnr>7U3EOe~|V(-SWE{>JVV3oUr zH$9A$9E-^hk}GM1zxwTONT@B`dm@~P9Pw%cvus13pm|cuBiGxm>yy(Tl|aB+hEQ>J zb(NOpPn!UeyCj0^lWG>-d4I5>z?6@rXjT9CJyns$Sas^+nc`;(Y67so4|YU$RM`%^ zW%uVFov5~+@Eln;jDxeqxFr3`0>u@qaJuD$Sd}7Mn@3?}GRRcrdYiF7SyDk>9zJRP z0?f?JBKNx*A_Y>3JCcea7(kQps`U>zulH8RkSG+d_r7d23{wH~)G9WBhc#b0t+w}h z8^yNj>3236+=D&Q;XjgxBS>Jsdff~%`W}3r?Pf9+^L%=I@J4wjN$RH@9|45^a_*UQ{K}q1nv$3jyiPk*7##h^) z2Q`$nX!uQfjjhkNSyX&E4kM{CAHpg5XzdriwQS8l>7Ax0WIh;w!bDi$Imp=jVHt%Y zuJllQ_RP@{13c7|!b6pGOiEGs{jrJkhPpPE9!KPG$(c23(DLe6;=k~M4^aGWr=>o^ zr%5kVS5a}B{Cdnv=(@Y>bNFI`As!10l`iaSYHnu^{_d!J{c2IUQeo-{(>Y4M;wr15 zh3=^d@PS?$EK@M~T?oAKsC`m`tW>5#mqK1)LnD}Vlihe^qj;m?V25%<)~Cuf4c*AK z+uwpaj}(SVU~9|oaK7x0kUl-3`Sr`+R&AioL2lwx3qJ7LHQ0$M#1*_EC=h%cL$)pW zuH97cA~altOnW;9%~YD3bw22EK&VwJ1D+8Jv3t8Uww|WQh3({ z-BxRTT8I5f?iYP7s&ihpdiqq38g?++=M=m)ijvFYRelBQVuO`(+-i?LB1k=+MEl0s zug*Pw`X#dES=6c(^uW)aJu|oZXrBp~@)T2)B#JxZRz^k{&_b=e8W^}Z;~e8DS`{sJr^U(V7I0okyQsucy&WEan*fr}#6)qVhFd## z%#N6VR-AA4hirT%MkLEfk-o}M*bN(Ai=|QmAvYTZS=q|6vaIu`r-Kc!mi?B#<%By) zPRNcKTncj=n`ry9xz-4{&-6Q*1*&#->%#ad4XsU0O+#HgXxXcQS)y~!i2C|^O($mp zpgX`;R`?{Fq2a4p=Zvr+wI)?k5_t+QvlCbFCb5st+Un}o?FAEbsY&yPdMBTxq$H&i zTfy^PNcAU}_&j66V!H6RdzY!bMuhmjQ%IW3YyYQ_pmG($|3>yXusG~JWFY9$2)YwBK!28+(&G)9#)&HD&% z^6iOAf&aDN&CW2iJ=oHQ(KGntz6m22o8^Q4v09tW7)Ijq#NipnxoLH~-UoybwecCX z+FykZilD(A|IyKbLLmqK`!g@XGj92F7$LM8jn=-kUcTCZ@Yy1?+@^eBz($}h(_7dB ze#ZaQ`Yd+))Rvj=l$$G2(ujRg@tFr+6p!^V%=x(s5<$Fkwr@QEL$w>*JFwaD_)f~Q zClreQ2Hk6RtMcwkx1OC46o9~)&3BEjAdG6Bah^~E`o!ngtTyET?a~3%@?fiZbbO!y z$8fm^&?c6A%dyL8hDyOrF#f(_r1b>A9vnJfh40@#IUI5#Az5hgH2iyu?&ORmMaR-R z=#?w7smb1R%zy37 zNop``0((sfEv1&XG0nE zABRh{9}m@7pe59>v}c^2J&VrHJ~{T_UY7ZSUzfQj{^-Y6!piV-{{mIHLu`DCK5(oAc5rkHapCC)>jG+OyBxx!Rur;;DC;gA zUHb?pSaS~U0pQmjN?NkJI<8k!&Y?T+C;(Xwl%h6~sC!zcr+J8UqtJyeY7tMxy~tnQ zN4th4nDhdyW6)WKr+n#76*ZgvgS-(U;RZB70iceRESz=iyTXctEvME=%bub`6E&m7j`yyw&_GrQ=g^pA1nsJS75X`mBZ7uegZkAM~05dqn zO-C#K&eF~VtYxSJcW>7b<6ODzucWAG-kr$hw0jY{u?S%hZ--O5eWC$uyBRjFk?M;L zOU6SgV+ZRZ4endRYv9Drq05!gX~ufXn2(c}7vZ*O1*5)Y8H~AkkD)ZiJB9ZG%1c>M zKKk9g?~lTYMl#R&_?H5WNVm^5VI`==yhB9OOe5FzviVQZG^r0oeGezA^k;r5>GdR4 zCu+DkHC%8s1v@tc+U4)=RT(46-g8Wrdbq3GH&7Cy5t&4CoNA*C!uvlLMkmje@IJe) zt}|5a&&s~o0eV$jTx{y_+kcvp*OnN^sfW}DeR`Lho9HnkHqxU7EtZ&+Jn5JDnb^4A zd1pVk0?s5^Ik_&kGq|rstJ*2PR6^-T-$vZf-^_pM{X#V}b2?Vts z)pW#Dz_7QUtaNGn?Tb2r8T4o4lH!Df#Tdq(;m=0O9i>4xGd}6(?Iu-_LiEX0jk-_) zR>Ad8n7HU<*X8~=U!U4Sw`hz#$Ox>WdbGbRC!;>Ju-e#sev5ZooOso6^r&dU6^I}; zNlBK)LSlH-+vuCfm6d^FsXgfQX$0ySN2uFIu$!CFTosqC&=LptV}pY^^RM@$2nDWx ze$tSPKuiNc6^xo4LPXb9YkXpn>k049_aDpc`LAv&vFukk?=Um* z`1Z5)GOAE%R5f}4k3OM$O)g2hfe{`mwy4oj9VdP&Pc9b1w;0O1vK0w9`0eWnoD zz$He)Z9~gQ&G)aTVL5N}Gy$M`rw7>}ESKEiqVM7P#c$`ZpgG6H>u5nntyQZ_!Ufsk zf_$hK^Re(}B#X0ZZwiFa(8oRXJ7aOiSa8L;1<{E;@#t~%m?87HiS5|E_c3A=#z{hz@ap94jmQ z0_EEiBL$ji)OSgOA4jAKJO1b$E=q}M0ptdBNXTktA~UY#cw3>VDMRbw&*^Bd7enO- zaIDBR*MO1slP8>TVCNQo1qW@AO@TAS3?!bs-p>iAtOkNX`rxa9fdQwkZ@I-wC4arq zGBx*U0`DC^l+TvV4>qlSOHItgbX)!j*2j_y?B0q%AaWN@N4d&tbXR?~9yy)*YpR!%|@G0)WPz3ZIq6WONHa28`8 zipHKIxfYwpSywA4BqR{^v;Q70$$cYH$L-5rUK5@!D`C_;v0dF~@1;RzP_0@Wh}>Ni!5MKQ8NYu|<271kg^u?TALOyd z?vH#+ejebFrEulkRZ25@ebU5R7k24?c>xV#cE^L?T0iSHd4T|)TDXdLA=g0;`tmW8$1 z_OqCeGmU)c(RwqGCppx8IXI>i57>O~w|oBZR=atW>lAA35Vh{Ejn?pqioW`>Oo#Ya zv;9KgsibpzuCcs_Jw4(9XS{`_LbG4d57s6uJzV17gl{;`^=IPh{Q=aWJU%=2>{L=j zG4eb>dHwtTGuULA^Ro2O2^^QeF+fw~DOxNNVCK(sm*~GGs zb87R96gGCybi;zRIQoDZLVBVR+hy7 zl&j&BDpNe0ArW>Ty+XgC9q~A#`bT*A=;`l*zRzvbTN41vH z4W2t~USKU9Rj@dacT-MI&IL87zGLtea#y~T78=&Ntw_9=f^0IP7(&1f($kH~7taj{ zV_Ev?68x68J9)X{%s1NuZEdU!vRDG249ec?`jz>+@h#4S*r%ZCgIV?iK5jt9=tCi; z?Y7XD=MQ4>%Aw^OEwS&Jj~xsl_Ga=wPSj8I@?t&P`J`Ug9T;>IaFo;Su^5V>xRFI+ z@9mBCDWM!I`GOMLiOL;}+Npv}^el)&0*tVimr9S1kA+;9qzhVmGQ^I&wmz0U|CuK2 zLC4Knp;v`U6LekJbSEH)f*3pq-*a|W^&MGRL%OUTR;{ku5D~?A?@2LNth9o=ZoL<& zmF3g1(x-PZs(#hLMeWpP1C@9(|5L)yot;!xm^9I3;J}5j>7(;s_t@BofsHEEJ zuI39hy7i%o5yZ(+8_l|RL|}IIT_37RgQYf*_WT}J$6?iu_M#>L{8nBr#!&0;5OPH!Yn*GQw^G4GdTUA$qI2oGvQZPl`6({=nH!|Yl= zP)p`W?74e+MO`o@yRvbg{!+{f3XsF%2et-W3J)omsnkfTq#D&^ZK9*}O~{ftNf^`Y^MM_MgG z6aoE`ngFAyu-|FrqwwTyG7tjWuEb-I4Ul4I92=NfQt~*R7*pc+4WhsXYBg8dV?IXi z9gi6M0o%!WWjL*V5&rn;R?qtntE%*b)IzS?0MvtpUzBsm8%dZAX5M0wXV+9|{8LF^ zs~(%2NPr2kHI?Lg`2^MENN~D)vUhGBupgqKenq!K|BgYOZik=}bwN8R_ixhrU;-bQs7Xr2I{%}bsZ@}F5UJN6a;#onm<_J4Q|CYk zAJ{t`3Ax!7YPGbst}HRdEXL-?4DvlOd}?QBuFMDdWz5JcP2Pn5wl+40dn+nPMOojy zXBhSaAZCYAadxz|ecfQ49XT#bwj-lViM(uQVeyy)wT$WupO3w@lhHS6 zFb1QjQ**43r(%2kDk8mzJ~aUS#!^| zrlh1}c!NenBcS5uufnzQoJBO zw!OQS5*vFfBNex;9ZiBsvFgpFMO_LAoQXaUri>A^o$hMqAOe+IZBzyJ95BJ8rC&oK z@s>?bSJ!oUGVVtos8^||s4!Cz!THSB<5{(j)7=&Uj4IOm0$TC!Fh11(ViV^ke&V?U z0ev!r=)$I^_zT91!Vo$}a1g32UsdoFa#rp(BaCNGYj_#k$KdGfZR>3oJc3lw9*6ikxhj6bJ;ABq?`^%}7Pxp3N%JTFc~LpO^5#4g8+_yHV@U zg+%Y;@h`SP#+r?g9r)wN4=N#Fo2AGs=bhgv0aB6|11}!!E=z?_gK!YxyzF?iyCw_N zM(odjA<1K2{(Tswu(Pv&;pNqEfE2UnYf~RxD7Cc{oECvH>rrlxp3XU2Z}0+?Ec;RT zKcTTuUsPD|SP$-RY~DS%Gf^fL6dC!uVzFnWP=5~waNEAJw|XdRE@Xa|RhDNsGmLp; zV6eJL#F(09$fC7*0tDZI838T<=b8tfxCC9PU= zHoVp+bzj{nR8A9gW*p11?~af{de>F^eiw}X2{|1xd;Fgmnpv)$RM7C3M*Pq&FXXmR z)-2JQ8m#;Tq~rD`w%bVKR2Vy+^HFlI$ISQJUoi&z@wz?O+y% zz-ym+)_QN^(a*~xH^t|rWs+;rQnag;Z(A@jUh?rNNCf7AL35sDl~srJM6Ju(7w;C#V5NpS(ZHvrPk_&|{kqSD zY_X4%ea0BR|JB{o2t*h{p7U*^iTojl6#n3wp%}*5Oen6qv$QFNxdV!G81`om7a^vx zM`s$7-~Fhsun_e*lttCfcXE-EBIR0$7!-wl7JeOH*^({#3L(TW&(~Nhfb`3{y;X+B zwBwT#{c1HWpN578m|gm9^u8(-P#4$L*H zYmu(Y8017X`V%@*RFfm&$Lq_UR^*Z@DkB{oEbQ#>FPm5}CV(xUhnJUJPBg$dZv|=^ zARKXC0(p5q^*#dOhe?X(yQyv3xJ%nzZN~Qa zZJq`XkGMU(ibK%+K-I$#Q6Gpq9#L30G0HjT=)$hE;Zl4#S~5>1MS?g}CKPKZEfVu* z)4AGyA_>j9Fi~<^3$pY%cx=x7mKv$PWCb%JCm0``@!$55!a22u1!!x#M*nOR3nU7;!c%_6dfF=5^lIJz3}pIF{r+9es#Q#Ht4*p+V_y<56YJQY ze066dEx^fK4eEw z3G`Q4lM)djJ$wXhz7&Pq7HW0q8nNtaxbGf&yp(Xz-UW<{6ZdAzjP=p+<}eay%F;H^`^k+mnpwjZqI0XAoyRSjW$fPYQ{hNjTwy z#$Feu40?9uH`?R6{$zZW2GeHKM=&)yYif?{{U`dhJu^oF-d5qC$A@NIy1grRXwcsl zRJyynr^}u*UjJ~aSy&+08ONFs9ZR^a2l^Gar)avnA6PUtK8ECjgAFN%aXpTATynDK z@&0v6eM&-4@*8h+-?YG^)22^+tsFgBGneR8O*llaQxnOunf~_Ypi$jR=J@87d6BB*1> z;o}1*XLn`dtammL*j^)m2hNma6tgq9{$0LIUfXHH{Zhti_r5o^#02!eii*Akf}kAN zTA!o$=^|dBF7FqSjDr0uj{ZGZH}0%Y4GjK>Wf3JT==5;s7d+`1yJFFkR`>+yLU_ge zK^gb|HJuHJb~r)3`PilZmvcq&?QDZrNHnDXp`kUlR0NI;O~g%sd#J|iO<3bt7rsi* z+Mj=z7m1Qlhljz^*`${$@RSsNtW|G3>f>t;N+o3VMeZ{CpLlcH_S{yW=Npjg5&kw~$wh{$bh9 zRVGUt*Op6I4rxE;OQpJ0?5#giZOyZ?t;Hqs|8uy&QY9m_b6Lf6UNdB3ntO1a1UQPI zcBUu%Yd_cc4`%bNcUxBG4;XfeCz+bOh;8r5JpW?8wBEk8Sy`OG9fUXUpqG-mEBg16 z9P7319nI_e;`|K^j+OhDnYhy4Cdt zzw)+s969*EOdkOA^Tv&E;9Ei}>)+3p{7#xqR7@l$Ljq!ZaB&q3_Kvt`t)VfL;TJp? zp59j6l3!53i^M~X*f4uo~iNDroXj-GOZ8@r{Kw%0MXqruayuH|r$A!-kAKU&!z_NW*DqT(dgW z%>F=UKT|XCDT|A64>dM0I9M5Z`{T!4m{+Ar>xvi}0^b?%)ZC-Fxj7C)1Qh{+#5bqF z!=3sDA4gk$&p{^5qt!7dQyuVp_!k$1==8H@o~E^lszS{|^CyowkQnGADJhorRwfwX z(68HDzk-b&;~Okp?lvPDBH^+;39Z9s@9f>{*RRivk_P_0pdi=zIE%AUncs+9vze~1 zIRyLFoRSOi?WWKanV0F1lateDQjk;B)YS1=k4y8Yu@Pv0R?UL0Yx_;}O=^*_kdWQY zO=wzeJv|Q-`?6hJT-yJDvj|$uy*K}P>&59?_u}LsiSWOxtf%<=!v0Q5H8e^f9y9-F zE*bTJmAi9t(!fU?M50iHo*EpHS^{OK7_yhg#=dRN{lvJ}gF#57hQT?v{D@vampEN+ zjSmVcqpEfetN6sNCVnLb|%T`pEGn zw&fsz$KR*Y9_A1-Cmf;Q3R5Sn%KH$(3>hQotk0gnp^s2hQSs~l5q#rn_v>?}1dIsP zfQo-;y8#PYNA1Fwii!zXE0S5g;xpn5F=p4x9lP*vQxZM5G315)T46#S}eB*49D z7B_BmnEOf3t3ZFoV3ehrlOE)B-IsE8>9;Je#};p5j`R6539m}E>&gK`)haZ1pJ&g0 z5Ary2*UtZwV7(Tkk}+w3a3FMa{qh?K5Nsp^&_Ya1ZRqeW@7Z<<>JD;+~fDa z3{F%x^EH^!3t+zpo+kFAr4W4@nwx7@L{a66o+?az`Cpbx<;}mwMz248 z`UK29#My=f8;M*lmygKF>7~QgpFhCBtz|0PAP@icsLdD2tkIe+nkXG<3L_&U7weG) zq@rnDeEi4ETVnw>HIlO?_9ci3iyq@igcB>zy|`wRea-y-EGcY~P?^KKbl*UO_lS4i zkslz1Vy!N*kt0A652{v1F6v$0KRBVR;4rSiY>y2HRsm(sP+eO);v$pW|3k8Y+vE*f zpGaC>;V~lwt3P0RcUcenaqA;I%F7MDTi~_rYCA!(v;tymgj-L#>2dbs2&tJj^7qts z(%d~gfh7=m`1C3I`}%avNZIos1MVk8Ibq&M-Dz4E?oT={3|wRq_zBs5K zBURKEd?dE0dI`%L`<%hzZ=9sV_GfEASMl~Gspo1D*_CmQ_yi;eliA^- z=a-0mSgBCx4IlV2;_81rm3*0#j-7!i`|RqKmZvXXNOO{%{}d52%lwZjX$nSlkxB#y z>vtYVEr2bM?Qro!}O~_>X4f&G%M*IVP)%^#KpLBoG2o4fTn4=cm=9Buh1cH(e zx}@;1GIjsGT<>gNiT8+S9A0Q{b;Dkpp>Xu(Xhk|yzr|G81NwRn`)@yZrx<6Sh}!ra=+O8yyzQ6t?jg9 z5LZU_s`i6I)CB?%Geee9L)KuKr;4s|m>ZsNZ_MBOl`63KYs^XG`rX!zl=BN+;e3Ab zF&&w{Hu(AUtk>s-+@Gpqjr+9o_bl_guOX_RU9}W8YRs7qBaWYr3oXYc{H0Z_mz|f# z=iGLOlytHFlGf*ApOxX;2X+&=POUS3XMEi!yAxr2_5t_Vx}5M7!!_E{WO4M6O0*rmR44W$750jZC|OV#qI`DDU*c8p|Oxz@p6rGs$0+Ls7v(O zI{tV)M8B9tTF);<6Ko1_(l5;osh4q350i0sHvK$J5dvGwi7&Pmjfe(Zn&BMFrUZ^z zXRpbxec+m#7xH{{v=7iS+mSDLfz7HeyDMT#rzhXun|i4904l306YunC09RlT3yb1! z6}!RuPw;Dj#aOOS=z9dFIMIYGyJUx^b6owrE_-xQt9@@W44f zLDv+n6!tirqS~SdUqPX>{`;c{YEGAq9D_7~AkQ{trHZ?0rLbMr`kA%Qm7aDph(bO3 zGJ$&nW=;OIz<)j4exge%;&Ql(?9@J+u6%#UK2PR2YU3!pvyCa)WVy+cpgk#dKS7@Q zw922VUH;_J@=KT8535ZgDJ*+5wnm5#kHuS?eEh_Yt$hdX`Ek39zCkbk2v+y?)dcS{ z#6bwS)iKOSL_*3P#_}gbF9U3iuzYpOR!m@XrL%F`<9YOzG4K*_^qV`Ze_(xv8d3R_>nN7*L#?8Yn$*7QSIJRgXMsyJqHKMYi;zjXL+E8HN^RfPJ_j0X>*#8+i%C3 z!ja<|%K?(wfiA;?TKZ=`?lcCIr5&@FAI}J6kQp5av}&V3ec-%NqXZF|z>xBjN1ppr ztVXwwmUK|4C6wlE;mw7BP5X#7%$mUG$TXPAg4>S|IK-STzYua=RjUF$N||%JfK3EAmqsx2 zF>K8*sLmUbrV?@Xgq709tsc)u+X)c-BW5IEtDY|yY%xrH5*UFdFwaS<_2eJ2)ru7X z*wlHt{!I>dz@vq&BSG zP37`jv}u%GGEWIhRAjC2L-NU8Ylb`Y*mR+5Jqe(R@o_utyQ$xibzPtI?koIHG?*E%awF zHr8e3ls*)S_!tHiA+H7g>rLe3$ACRhiTTR%OlgDlnY>kq_5mjL?JeK)tsDRE)~zkB zrt5(A%@M|R+k3lYv6uVJM(uUpOYF)2VF8sz2HBE(%{h&Zjhyxo>(?eLB*uCM?z#MC zx%9J)|G*fN92;9{()=ehp*Yg<4?G;AzQ@uRt(={IVUVE~J<00}3xdAi=HD`+dJ<1l zf`WuJ6=yzU(FplEj#nn-NEjv8Uyh5}`}S?Ru9k&EY+;MLOR;fLv|(Ph12;VmC4}$t zXpl7em6D+dF8Onmkr4yQ=5xBRRYT0}$n^9wvz0Ghe)gFI_6`soo0OP1Uk9@MrpQQD zOk-M1%p4dfeN;?(VQsB&2;BGG?J;hQ%+G)Wr==)}(Wvv>IgIb3WWxJD)`Nde`Jd?+ zUS@ix<7u8k9N+c(g5!rnYC9|XV}oSeDG#3+3m?BkYV1!H;`31IX&0oUm1UzIh|)ehKVg3T8$kWhVUb3+yJ-4)a7YM4kF&3WoD$DNuFMdXq>gz^ zz_4#?R(D9mECofyS7FTvbz{~8TcAAJA z!g7ECHewZJYMGHyr{K^~Z837Mqc2@b$qzq-WN(}wf(~LrSh$x17B1XgpJ~u*ON4L? z0l`r|D~+?^qB_nphXyYf))%JWqx|j((Jp$b8%e@TGzY}YIif$1C~bPRS9VGMQSY9(vFHt*z-EzF))AOaR1p4|rXC|8-Ht8B5*o2KpViFIOpBxdbrtrI zmu!;5_qQ$uP!i{pfd5&r6INK2GQR;%%IG@hxwtoS^A-l$G&e=6|Q^P_r<8aeQ&Cf*#4+)iKXV z&#=2ZQ5X>67V6L2;JI5`T`lCk(YEn4uqR!>(j@Y1@7lay8covW(8FCQzj{jvZ#G@O z9t@>|+WijVjvK<@zq7-sK+;oDe&`kt?64PxhR8`mxpwC5y{}gwxs%lxF}WsmQ}3a@ zK`&XphUcI8x{;mwg(^@-ecvcgXrwUO{1tX)zn6q2dX4)?h`F zVKh@+Y&fu=DG6b-+kV zP*hwtVTTM5#q74mwoCK;e8?QyY}#gudG?t24c%SAZ*9}TmkraS+P_zY-r~}ICTp@5 zjQiQ=BovWiz}J>Fh45LdwyYqzpQ=Jk)ZTDzOEmCUAc1&Fs-OGYO-gyKVgu8)w{02yNlFF1cWp^$GNv%}{r}b8cLz1u zzH6d>h=72CfQW!dm!=>@q~udX1*G>bU3#wpq9P)o(t8u6_g)MTrHJ(2A)$p1p@kMg z_Tl@Tvu9^^&ObXlvpZ)Fe+GEdp7(j~`?{~vGFN0=`7}QFaLXvKVDB_2Y*98+xwkRC zGZugA582ffaHqA$3mXIOTT>Rc-{9zcP1L2kQ|$Mfnzzwj5AV__B?)UYlYy=umHY1} zc&rn*h7e?YYfCdJ_O7$D1Hdn2d}@NU>GXtjZ_pAc`DmPG#box&EODZ~zQ6?2K)|Ac z1Wk^~0K~9O$A2(4oM8MX^E5P1pVb!hagP(& z$z$?G#T^XotgQ{E9RTxIIaLb8N4cr%=D;#DGheDxQ2D9fkofYx- zy21!fpr{#Z#O0Gvd?@YqB0s{ZQin%}gUZP#{$aAO>y^C$(W5P`ma6?SM_l_|t&dbrhuw8uZI~XK# zSiWHERVp?SReuLiIfKH%D1^^Po8~7V2+{EDRn3)eN>X9dO8M7frs8P zHSfGYzZ@eiE$y{_zqObUh|^4>)Nw8F1kEo^mA3OOKgdz#Z8eK5mw$JtqRzUuuUT+( z)qaCnc=DR>$7T8-q^W zdYFesny}H)EdUx+lkn^EU9g@HHYN8M1w3Vo=4u?UF`o1fBt|)4;TdL66gMjAuzQmq z21g3<@ROSg^gTb{)vM%3D`exCAASiBKg8i2&_YD@ib&QA)}42I)}rVp(zVmx9IQ!i&>oE{aiVS{y*g;5;`dCbSF> zpmCh2v0MB!9p9RjYyC{FZ5ja^*(NSy&f~!*aq#n%rXTVb;C%7?nP=pWjZEV7IYrQk zx92;GuC+&`c&;r*z8^iP284i{IwJU_;KHR7wYFzp+2dRI z7lmFB;d!D+4w9jpS921BX?#?nkzc>|wvZj8EAoJ+637w-31hw0od9~Mg?wI zr;axaA)7@UvTn@av6~m%wB8<{c7vq#gUzAuY8)8GMqpZ3s9n4k$-y&xc;9w$_Hnfx zW^%LTML54s!J9l!ogc$h?xGqgPXWv_8P+~TA66mPY%in|{~mj{yI1=U20%VRG%Xot zE{m!f6m5`mxWOvco20~~RN228YqqET7Kp#50mt6Bs=Owr$PLTxbixH>ykqqmWVp1W zqi2*|e!VWCAUOuJ`QgJe07=d=$|?96rm;f9`j(s%>uPOxIxrR80!>0H*yI=3^^D%` zpBZN|uJux){NH*1Y3Boe^YsJ2-LaCHm$l#t^XC!XtG4>S&+!LDw_^nhi>$^}Bh)Xz zgG2jInSujPQ%Q(URc;| z2uqx@8uj_xB77z>{MD{V|KvC^v3SZ|U2yBv8cYcr<^lEO=aaw}1y0r89b?Hyn^5Nj zM|*TlhOZcLfv2}fx87>#H*^^Lj`Icu77c$08?7=FRQaL#})1|{G4ilRm3(W7+{`Ueh%QL&?ii{?j+EYLq%mdyT$K6%&~?F-Te=o z>fDs(21(4@48g3@4LboF`eTVN=$6MG?CXM-t( zC_OBY12C$d9S>Fg-VS zVjp<_*H!ftO;t?=+f3IVMt@X1YwmD~h1}+HEou^uBEa(KHC-vFJ>LDvP*#?|`(hRG z&qYrgG`?MUxV0O*4%m`rB6_0MuYfk3hMIb!*2JgH`#)hDlf0p$z5nPoH6KaXv*zxi z&l?*8(wAg~f)$@oN9xAJw(0z_kV<2X?;08yINo~sQP$`Y)jxrgzIgVt(qXz1u_Xbbzm=iHWI)B8;NVx*C;xV8T9QU!EyBjNSq0UFn7kWEp z^tE4`1)tkZgWw)3OuxMtZE(_w-uZLj|J`Yd*I|*UA{}KTFK(V^HbMNZ5ar2gtJO+K z1b_8S*O>xw09Q zr{{s-rKG7TN!8A-XKg@~f4s|O7~JOS;Rftc?NFj_j?;BOrpy~F&kyDc{h4y?u=6Q4 zcuf_Vt)-o+yf=X1Nm*3|KqS<=-o6ElPEbBDx=d|KdTzUWY;JDk&qsG*@HDJdWo5Ua zjzxiiU~~{nR_O8h-?4y~`&`Yg8H*k|^eS3jmZf>3?1AET5tgayGeRO}7XA=w! zBH)u=Z^i&%KU@3jkWGr$!s(w^L87W1<4yWVMC2(agP84jMAiR7*s`JXbpUVgAHr6& z_r~0uaxE|?HOP;`E)^T(&r_09MRqMZOjdC_sJBr8=Q|2tqv9mErn{yX(UxI}AuIic zx4Bu2+gnTWzpzhblf`VF-W#N0lX^3l6@p0KmHz9mD&WZdbmp-K#U%5;P1r13Rgjkx zo`CtvfKtAut(m=u-_d;>ZY}rk=>23bGLS#j1Q-XtDt=F&qD*&O$j>9=YNb| zHZVXxo*ronGUmE=rA|sn>lJXn3U#F_dvl%FgS zDv?1E-QsUyil^u)W{BvBc8pqQ*GDFgtkd z;K0H{zZt8+^flzm15r^9CK+_&zXjf@dd3_H^n-yBp62#daw>NDqtQ)CVAXJ}wZlOG z=`$-H5#e4w1r~Mn_1QT&s5%!w5DaW^w9wQ9oY((g)Ffa37o%q7TSM}7X>x5(PjMTa zBHf)eoLxGQfrp0vaGo0G%Vw#i@f zdPIc-TE-t>4}J?vN?-;DD!tWJJ{5g{L;gdF5j1|q*H?Y8GVz@Zhm3wbQfR;TZ|PD@G-xw;N{>*^}GKaOfj#X&-YGNfo%z>i+v9^Ol@ry4zI{H1D|Jw zY;^+z1bB|5q=Y2|dq@vPO6qoM%CIEhUF&?;7qbKL4&NTi|67dvH+uyIg~=Z{tX}2P z65?RAhnea0Gq`5d0WfE_P`3$t8|M=Yiifr^#@a8d-O$Bn;JXY+ti0hi(9dhAG2kY72(ufZ^!A@Eb(d1s4Wn+M9Y7YCGi7 z9&Ahuecu1ft(3fE6dM&~W@Yuf7V75>UmXkrIHfA|j%YNTh-!QrS4_bM)EG=aD&dQ} z)gN}Q`ghqo0V;_+HoBWfYa?BZmo8K1D6s7!n*m=P1Rx|}xr}&}S;Mc;^i4TAVf%{> zyhbu#a_ZLxY_^g|zf65AU}b3;aB>9Ps>8D!9IcFz?{=Rvv;X=9qC~VuwX+0oPylyH z3CqrhGpF3@$NM3-k7xw-|H|{C_)CvpN6@71H94;F4?IwV7~Y5{iOy^wk~FomIJ-xn z=+%6$-zek%_MVC~dy?OlR_wup=jI{5S-%hHnm=hnF@GX0W6!*!{#TYf)@$FD><}K_ z)yA-%7Xrmq{gQf_SkS5QLZUauvxalHga3)PBbi~9*5BjOz*#3_jx4YTjh zNaHQyx?hdFrW`0YZ^sHtZH=4Lk*E$ghhS5^P?1T}XGe}yx9rUmewUm0`|NwVF4Up} z42tz}7AI8P$_f2Bn@u5`SBihqKu`NCEEUOQaIK|qT(X6qAg{#&p9VLa7zAj?rwjBt zj!u>A9>DeXO?SL`vwL{7a*09i7FY|iN93!P$GaQbr0XN4Y=g>qWJ?%~Qkqn~eZA{) zioc^-Jcw!0|6#v+7+-2sxfEyMXg^pdpWc)kBcSKH8`Gh6PiJC@!4ZL50?s`4d-;ur zj_w0;NadcdHy_;nKx|$5gzuG4T(z!o81hVJo~Sf!`Q=2TU-tURmx~VTkZEV5 zai@??UW1wq6JSO;%4SvC$OhXO*y7iT;n$JbS@^;R-O31N<@HMy?R!*+Tl!0Kaq}0u z^T*drld8?Z@-hhLxn>v^0V~|UsqKv$iFXsXKO$g-flpZfn2CziQo_PL&7*Mj?Ngt< zrwhu!Xof=Qsvvi1D+SK|3PFDmC)~Bm85{$79N+_pJbyPQ!9?299zz@7bndaenPQ=iiCe!6t6|jOo}WJZl)E;8m=M zZ#QGRyY$jRICRagHPXNz9VUFHm8l9R;*Huuvw1>$zk;_%x%9Kogcon<+TlcL z!jrNCmvme5KO|JnHg^dlGqsB+!=-jhBj{XR-JbrjAI?+DRetd*O^PooshvzJ$#8Qi z*-0_B8K}#S`ehG;D>v;*>V;aG=$UirrDQ@8vHRlHI8 zu_I;1npUl2Bajya>oP0!^Xrt8L^aw7#=Er<7XFHlz_#!O(vfR4egWaKq7 z5<>iVEu{ppb)sVe!_`}-%iyX+xj!>Qyin8E)4#qfR?Sz>E-Y;lZ%sxM=fbC^n{`aS z5Vl)r)GL`Gj;Gr#bBx9b>A~UB2S?j4g@~tNeZe%$lU4??eaTKIc1KU5hafXmToCT> zuH(U^V}7&0@RL;2%|2nDn+!g`qO ziQrK|8E~~-Dn;&`=qxndlGq*ErVCHMZ@RzIhfjx_zKOaL0x7R2weF1JVVr(A{h+^%v^JhUK zBOtW;?hgb*mK>$LPJQR9Q!4-OS?mEx(mn&s-}hh#*Gohc8nNTK7XF~n%=2J z`Ja5?VuPQ$bZHlZDxdb)6gIS>hYpbN?5?sIDrr3E%XrM#`4-6EI-Jys#)a7KxWPn3 z6>)15u%4t5bbzGW$l8b(%Ev3u^kYB0$aCJb;CqfDRz*gLgJ>0*t>JvPIRi?qX)C2< zpf?nYxve6Nt12^ZX^u?);1*2(v98L+9y^X3NEWN?IcVpCbaHkz%rf%~{+KgNkW z)n{pfOCjb8)!%_+NP7JTHIw>pCOSGMB9fH2rndc^eyqkV?(12nikdbbIbW%E8f$vZ zxZ<25Y*;s2)>1Q&T6Rs+TlXUJ1{imq8i2XE>~o{EM!$m;J*TGQ6VqUpCfkuts&%(} zTiP~@xg5-Tyi?&D{iTRe0OU9=vB(-{1jNifKKw)1|f zTXC^?s_bm$5G8Tg05>_K>)nXfQ4SE9kA@h-iw96($}$6aw(e{;Q;N=KG;|)=>yw5} z71z2;6m&(t@Q}}`X5&w(u(rs8BJ2BWU(dZ)#Z6VY43YD=r$1a>*D>*_ca6P=>58YM zuetVCR%?4T_i__{D+1IOK~C3g?eg#J6NNbwp9Egs0Qdf!0rGGwV$x&nLo4mPZ^5DW zm7i>WkBYWQfZuqP>XP)<_(Hnh{0>+lLDTdH&v|22QpKyE1}Ok^=%w+9Ua4c_wJ(kc z!f0HN0jDs1z*fEWlP`;_#4I5%-X4(8!DV*oq_M zIyUSqu`xH&qPt)~*eWsm_7bHYyib~WYv9|jvbmP8TfW^D*@|BH5~L$<;YZz@gOd($ zM7Cc&ab-ItwsGUzB^n9D-^jzHYFqm5AAHr2!#gi2dLTyOy~xWBYgJCv(u>;;0SV3# z@V%ZGK)nuU+LK#Wez7r7Lg*4+MsHS_pf#{R*0FPYn)wx&1rLde^+}Io$ikImig%(& zPotbyVkYDgl|bJE1Ysd|L1xM zuD#qCS?#WOC3Eg7tRsrcvNNu@+yE0nD?9ga_+5(++}Id3oUhT6fBb}u&2OMIfUeUd z)^keGBdrk#N@>yjBn*ALsOqi((C$*Ia9z?88T=Tf`6rX-aHAT#G_<1Hgjh}|dM`%8 zQv*&C8}a)NsD?JXQe*|574|{5i6dP~vrL0f7|kRw+!__z_j-3x9*ZOzC%l=g((~p- z{2d&!QB*W18=U4l$;1ZVH_c0m6G%J;JK$)s0Wpwk6l>7vtMz9pm<9DBzFnQdVpfk678uzV;-NgCU5 zR@$(u&P&efJ^O3Ut~cSqLJ*_L6|wecl`W=Ga$H}%o0YYo!DzY0a{ph~Q2ct`S1%M< z_o2e2Pqwkd?e@iR$0i@#m$(8rzYRx`nT7k_TPO$o1dE ziM+l8`||CT`-d%T+{n*fGN;p{z z`p&Kqbbs0^y+*j{sF^GNbZbP%1eH4?BiyikIv4C!WLWR2i?mpZQ{=*)w5QHJ^O>2f z7PB3dn*AC0^fGKQGB|*UKm9wlTN5;kjY~;(8cbrE3Glsj#iuD(u&ml|P?>VxH%{M9 z-KNi9@;ba>Kb5n|iomLso3*KaLm^u(C?$!{pcq-CR*HT0mN%<)&M-+%4Lc{lf^XlV z_4cj=8_ZaN+FtdT$tIeZ*&1GJ)idd`&@SeH%G@StAYLFlKq=Rdi?#I0Fdw%=jT0ND zANr+DR9XioP0{NZGSS-DR&Nw-ID8GhG07K2$1;gUs;rBH>bon1#}m*s=fCiEIa?rJ#NvzMm+>-FwU2J5A z7?+#Lt>w^@22HwpbLPVKAFW2nQY)THWqA%mG?1&~$I$r`Q1OmdSQeJ)v_#g}EaVgw zSt?e>ftbL$r){Af^v#T=YTvCM1qdNFXiN`;;q^!7UM3m%F&<%6EA1YVP&k>2mO2%g*C?LPsrigl_f(3Nkp z%?oL;tt5z3fH%6o^I8R_tF+-pK%|2>gp*h#j&Y!9J6M#X@bO7j?+cK2nJ3UpC#Re( zE`&$X<$uS73CpP1H^s>e@0s{-VxDDul{p#zE*`$s6d2649R}dKp9~W96055QXMK+V zvc-XL7t|7Q>x5`bR@<34SF7?l3}(q6Q(%}O4!3$6cwXH`K-aq~IqzvUv7!h~a4eMZ!LxNmWv`jAGZ^iM)$x^FeQiP1cl^e63bwm~FEF)o$ z>Pu_%)*Yy>RdawK!IN6#OyW3sOE6XP!{c=eow6$$g-`~4r) zb3JELq2i~9pGL_4t#OsXJ#Q)Gp|w~ zi%CcaG#>$kht+R8qhkUbcRV!7NJF^N8!B?8OR16`B}hCgwh`}mu)EQ^WW1m8fJ=}V zwQ7ye(iz3wjLY!%;9V;-2u;4ylLJ4ZEF4$}%UKfI^2U|9ETb zQvF8X{e%Gz+mS>F-0@FhM*pn>!kp9GxOq7L>HIR5@*OdQ3`--2eG-V0n)|RtBjauI zX%sparzz>9dl3o&b*p_j(BR&SjdP#4YZZQGG1xg6<2&P{{5FY8JEzT&P6^`sPKeeAbPRChdnvfUA+(7naizoKXM(_appmH zP2V)Xq~>Xo&JcQR*%!=x@cR&S3I{=4iIl;3%z1 za8i1#RiUiY!TrlsNN}R^6>YFdsM{Y5(Q@%Y zAd=f(f&b;wjN2jf#Kd_(T(XKW^Ch+7>~ag6Ac_40r>&IHVtt71@L~r~dS0XeZ4QP~ zu7Q=ufl11fc!^p-Ewi<(hl7OctWtPK!a5y~8>6F)OQ0~mWVrq1DSgTAL8V^a0tWP_ zXLSp7P*f;=mqE2XVacGBtqdM8EK#61wpjLRstVLh7cQ}GdVA+;o>2J&0B7qCyC_iK zk2kfPnP&PU4l;~u3xP5FjVq)bP@)9U6EP<$CAJ-M?Ol`-35N_}v5nDWWu(x{wdeFf z`CbhowGYZhzgBWiJqe|!toLvIq{1MOfE+RM)1a1F?7ucep^a>zz8c%1I_I}MHN$s>E)(Sq2Gi$*nuXF=5&t@YL|AA{@*_NcGQiMy}Bz{=36 zrTYQW8~Ll!uKE)j!=cA{aAFuZa&`}q=M+w z>Zy-C&;hXi1EEtO`S-0Cv6SufKS_Moe%sCP$nwx;s78NejpecX%aZMpy7G3mF^8Fx zQ#?=bV>j=tdxc=Lq23XbU@2IcyY7kySFF+M`((kmqNx%Jju=qw=K|HT>&dEhmSdLt zu$obGZ#`}_8IO~+EL^`rQN93b07XFS|GP<5)}YQA)RBl_iV-I36p2SZld19858w_d zcn*r{ib!C}qea{*kqE&GX}{|EIJ6ILf}o2tD+4C(6UssW()lfkr8*{9#yeH+xy=Q> z4(bCwBaSSR9{P38XW%Fe&1Nh#LMTsESR#p0RLtU^nM6;DpEy-bmHOviT;Tk2>i+EB z?&eJ0PT8Ssh3)=oZu;%I`q9Io&nKsq!>`r&NMF2Dwr@)FMAzVPSoo8pDiHPMvp5&r zsgvj2E3xe0Q-ikgns%9*@j`p-b~M@oXs*PtUe)93TnzvXYYHCsCQ9;bd;nEVI2%+b zMpw46&jw#Qu zy_x?&PKwl7i<&JnNBiyJDtjOE)JrXHHz*l=e3SIhQoRiux(#4PD`56MVyCHcqu@2A zV&|He9R;eS-ffv9+7!0cP_H5*^X+MNXuQ!uu!!X@{1B`o{K1qHd1A3mrIo=5tXA)W z@|i9ul3>T#jL{q~z1BUOWB8(%yHlrt96N-L=i^d_stygDx}7Z@s%>a|SNP$MJD_P6 zq3tE}EBx_<_Rp+@@wYF)@Pg&uo=r&W_FT);0F)QfbMDeui5C7_AT$rc4OvINZ2n)@?hwXi0zhl*9#_!4X~9Q2Gqs$0NvnzYjOoNBfC9` zq6m+5acq+)=ZLlU__k?B$OUvz`d;6<$#yR}uU^5)NNLS}pVZU8>0U>%*EX=HeNcMr5XdYC5zaFB-W@k4NOPS!z%S&4F=usC*C;9r;FIJ0T&dlL9mVA{4UZ*#&t{B%Rw;?FF1PZA5P+a>>{ zMuBF&qR{b7gx6(YU~eJ@+!@25Rfc1 zYjbdKb{ec({Q^Nn-I}sl=|4o?7PUJ0yvmLPu{5SjpP5voB<^V1$qvbab0*z?E3rF@ zOO}<|;W@ufvF$_!M%%!k!F#9i5Y;&47&}#M2gk(1m92KScbd(?m?B)}Y-Peay=ie? z5%}g5spUJ*=^y}-+d}qc%u!LgUM?(7$H>Fuc+=4AT9WuvAD?zF*rPnp?XtfD_y*$y z6XQud-^qnBY)v>bQsczas4hO8i_l2^{#V{rCg^ZLFAcJ4z?hHsA1TqZ zf0DlLP@F3hNdABS7u|2~T1iN#8RVZm(fqIUzW)2Y{Fhmx|NT||_gDFUve}AwT4aO0 zpZo2_0SRhjV(c;x^2?#&cp#4QZ5?y1$#Rynb4sgPqHE1Imc z^3d1U_stiXQ)_UouK1!K>)8KD?bXHe7fx&uBLA5@THJH8PoOu4c64>{)kz6Q{xM2q zVO?kJF~Z>z%(z6ls_jkkW!udXvuVM*EaRv5AVF2R95bd{eDSj$`r!d>*!t+v1!Ze; zJ^d5P@>s{-?RBQE?>R@t^GoK{pUjXVgQL!cbt@%;S}BE^&qNtyv@gvVlYm;~5}`Z4 zBIS(hCcs!HmEourzlO_1r_bJEMO1=e*M zb%i?^R^B_evydz11;18euUoPbR@$h#mciH{a>xGuKffxta?$x9Z*G0rKbiNgsfKAF zIrs-&bxG#8kP^LE!YV%GZmifY;Y*>%x$(d^-c5Uw-V^UXIVrfHBGsBF3zu_^4V~pH zq0?!+ny8OBz4WW{-~xXSOyrX^*bpXT7hZ6AzKdOe(lDcc#nMO}eR^N{U4}USK1@i^ zf&f5d72ufNKkLPMGG;zz2wND=AEJH6Cn*VwrtG~ya_5$$2it6QWzMq0biz34uK3c& zFYyzFdYQbZJE3;^HTJ05z78+K0#ID#09(9!(v7;nR{a25hFE=-i}s!o>$4NG*6_4s2PuZ9N}9IAvyKvgGIF zx%bcx9}PMCKJ83M2X5CcSbv&SpWl34H}!#=r?T>v^r4I<0I2>_TLW&h zpA$vNu6j#BAQ15Jo|Y;a)L0nL=O#h6(>(U9>6?^O&GB(|!)wWSr$0uGBHh~dO-_Wg z{VDhf49i6L}aq^GKj4jA5oE!a6UXiqzCv8jJ;zN z)UUH{`3=6|*=5t*i`zwHPTfY4M&q|^Y?R<$UB3a5NjOPzZvx-)UiySkVAx{CpR1hk zU_0$uhDfJQkryhR7P{CHE^|Cm3O`)oIINw>)pG5if;Zxh8I4&_N4r3h!niF4z)5MY zlM?J#b@B}*s>?KTTYPaFk$1EaLYF`&A<1@Sg@(~L__mcCeR9o2MB_sqSjSqu=)GD= zflAw+&x4kb5~I|Y{MrqE*{RdsgLo{BilQQ7OBm9Vn4G@o=H`ZGJ9(0Dw6dTQ%@6Vs z8b)OB!R>z{zY8@j()jxx`mF}#{_vEpkdOXNBjfDl{Sp~zSLO8`&=R{(J(qsIy992i zpTv^XMk@8Wf(^8UT{n-Z*-pD`MZ3TD!lx}o8D<769Y5)u$tN$kJtvdI?6_6VfE-eU zaUY5gg&Hv{Ae@?Tp2hlNAku4pDLF7|{dT~3hI#wvN11zbA*XgSh}7Zl1{cpX^o{jb z2hTTzpTw%vwe3XX9D8=7Z-uPhiytvvqt=4G=^b5#<5dnjyY!X61N7JPqT{|0ss`l7 z;vDXKj_k_^*-P!nUY5c6#S6WV+MeYw`ug~;B&-bt;GN;N-wvs)AENLr3ULAw2Zb&j zly5aYQsK7&s6|Bw+=>mr-7se24Amnq~qplUinlWEG;% zPN2TI4ZaM7*);*M{;Vx?FFJ?VR#W(R_7vH4M5aeuU2U-5Y?jEMz|b}6idvtjpuF#& z28Q=Z?F|Dm5SKMDi#!;A%LB$=(~BDy$lMboy-?qNVL4w*o~KWK>QqkMkDqKD2rWEZH|W@62-n99;fdqFb*_u31~?An*-*W|pf=2#XcaDmiXKWL_~W zISvRhTMfy)zq2k6{KTN^-5SmN%udv-B8!A|r#egE1q9;!6xwFP;BN0oZfAfqm>Zl7 zZ?XgpM1;%f#7m4HtY;+exw_HG@d$E!N7ch8Bh|jmE!z)d2EoMbQ^0}-Bhw5SbRmCn4#amBuYUtce-kT(S>9pI4@M1H+;kW*Jv9v)oL-@LUl8W;;i$&~saB zHM?`m(>dN%@IWyu`@Cg*J;vKfk|Hcv?APUqxmDYHvfqd?m+1uOwZ+W&30;dqss}6E za4B$}|FO>G1fGNu=)bHtyPdR^Sd%N=2Yy!tmNtI_x}UePJSBz literal 0 HcmV?d00001 diff --git a/docs/testing/home-warm-return-20260926-reports-history.png b/docs/testing/home-warm-return-20260926-reports-history.png new file mode 100644 index 0000000000000000000000000000000000000000..338bfdd0258faeffbe8cbce0d99858860b31c68e GIT binary patch literal 49137 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 literal 0 HcmV?d00001 diff --git a/docs/testing/home-warm-return-20260926.md b/docs/testing/home-warm-return-20260926.md new file mode 100644 index 00000000..fa860718 --- /dev/null +++ b/docs/testing/home-warm-return-20260926.md @@ -0,0 +1,31 @@ +# 从其他页面回首页不再放加载动画 · 真人验收(2026-09-26) + +关联:`docs/tasks/TASK-home-warm-return-20260926.md`、`docs/tasks/PROGRESS-home-warm-return-20260926.md`、BUG-1040。 + +## 前置与记录边界 + +- 本清单尚未执行。本机只在本地 `next start` + Chrome 151 无头、用虚构账户(林遥)和虚构数据走过前三项,截图见 `home-warm-return-20260926-chart-new-chat.png`、`home-warm-return-20260926-reports-history.png`;不以此代替登录态真机。 +- 在本轮已部署的 staging 用受控测试账号;先核对 `/api/health` 的 `deployment.gitCommit`。不借用他人登录态。 +- 测试账号需要:资料完整(有出生时间)、至少一条普通咨询、一条生时校正会话;星盘档案里至少一个他人。 +- 打开浏览器开发者工具的网络面板(iPhone 可用 Mac 上的 Safari「开发」菜单连手机)。只记方法、路径、先后与耗时,不保存 Cookie、JWT、请求正文、会话 ID 或出生资料。 +- 每项记录:通过 / 未通过、设备与浏览器、日期、脱敏现象(截图可以,遮住姓名)。 + +## 必验 + +| 步骤 | 操作 | 预期 | +| --- | --- | --- | +| 1 | 打开首页,等加载动画结束。点侧栏「星盘」,等星盘页出来,再点侧栏「新建对话」 | **不出现**「正在载入账户 / 正在准备对话」加载环;首页立刻可输入,标题「新对话」,中间是开场问候与「今日星语」「生时校正」两个入口;地址为 `/`(没有 `new=1`)。网络面板:首页可输入之前没有任何请求在等;之后出现的 `/api/models`、`/api/consult/status`、`/api/account`、`/api/rectification/cases/entry-summary`、`/api/daily-starlanguage` 都在页面已可用之后,结果到了页面不闪、不回加载态 | +| 2 | 分别从「星历」「我的报告」「星盘档案」重复步骤 1(桌面一次、iPhone 抽屉一次) | 与步骤 1 一致;手机抽屉点完关闭 | +| 3 | 首页 → 「我的报告」→ 点侧栏里一条历史普通咨询 | 不出现加载环;直接打开那条对话,地址 `/?c=…`;消息已看过的立刻显示,没看过的消息区先留白再出现,不转圈 | +| 4 | 在首页按浏览器刷新(或关掉标签页重新打开) | 仍然**放一次**加载环,然后正常进入;这是预期 | +| 5 | 首页打开一条生时校正会话 → 点「星盘」→ 点左下角自己的头像回首页 | 不出现加载环;回到那条生时校正,校正界面正常打开,输入框不停在「正在打开生时校正…」 | +| 6 | 登出,换另一个受控账号登录,进首页 → 「星盘」→「新建对话」 | 登录后第一次有加载环;之后回首页不放;页面上的名字、余额、今日星语、校正入口都是新账号的,**没有**上一个账号的任何内容 | +| 7 | 晚上 23:5x 打开首页看一眼今日星语,停在「星盘」页过零点后点「新建对话」 | 回首页不放加载环;今日星语卡先显示「今天的星语还没写出来。」或新一天的内容,随后换成新一天的内容,不显示前一天那句 | +| 8 | 在星盘档案顶栏把当前人物换成他人,再点「新建对话」 | 不放加载环(若本次放了一次,记下来,不算未通过——换人会重载列表,允许回冷启动);新对话标题旁是那个人的名字;今日星语按那个人重取 | +| 9 | 首页发一个问题,回答还在生成时点「星盘」,再点侧栏里这条对话回来 | 不放加载环;回答继续恢复(出现停止按钮或「回答仍在后台生成,正在自动恢复。」),最终回答完整出现 | +| 10 | 同步骤 9,但回来时点「新建对话」 | 停在新对话;旧回答在后台继续,回到那条对话能看到完整回答 | + +## 边界 + +- 暖返回只在「这次打开网页已经完整进过一次首页」时成立。直接从 `/chart` 打开网页、再点「新建对话」,首页会放一次加载环,这是预期(这次打开的第一次揭幕)。 +- 真实模型回复(步骤 9、10 的回答内容)需要模型凭据;只看恢复链路即可,回复质量不在本单。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index d561c56b..594d3404 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -436,7 +436,8 @@ one skeleton, none of them carrying the sidebar. tall content does not change the shell height. No spinner or 「正在加载」; skeletons are limited to the chart-page plot positions (§15, 2026-09-24). First-screen data for the three pages lives in a module-level cache; a second visit paints the last result immediately and refreshes in - the background. Sidebar `pointerenter` / `pointerdown` prefetches that data + the background. Home follows the same rule for a client return (§9, warm + snapshot, BUG-1040): the loading ring plays once per document load. Sidebar `pointerenter` / `pointerdown` prefetches that data (`` only prefetches JS). A tab that outlived a deploy compares `NEXT_PUBLIC_GIT_COMMIT` with `/api/health` `.deployment.gitCommit` on visibility and before navigation; a mismatch uses `window.location.assign`, @@ -700,6 +701,8 @@ Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小 校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。区间交付卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下。有未答的采集或选择题时卡仍在,「更像这个」置灰并写「先答完上面这道,再选时间」,不得整卡消失。卡上至多三列并排,相同性格句只写一次,点「更像这个」即采用该列分钟,按钮显示「正在采用…」或「已采用」。采用过程中整张卡留在原处,不得因 `busy` 卸掉。采用后前事核对结束走 `verified_idle`:一行收尾文案跟在卡片下面、与助手列对齐,没有 live 行、没有重载、没有采用状态条。卡片与右栏细则见 §11、§12。 +**每次打开网页只揭幕一次,客户端返回首页走暖快照(2026-09-26,BUG-1040)。** 整页加载(首次打开、刷新、登录后跳转)照旧放下面这一次加载屏。同一次打开里从星盘 / 星历 / 我的报告 / 星盘档案经侧栏「新建对话」、历史会话行、账户页脚或浏览器返回回到 `/`,不再出现加载环:第一次冷启动成功后,模型目录、校正入口摘要、会话分页游标和今日星语记进模块级暖快照(`lib/home-warm-snapshot.ts`,按账户隔离、只在内存、不落任何存储;账户、资料与会话列表本来就在布局层的会话列表 provider 里跨页存活)。首页重挂时若快照齐全,首帧即可用,落点按冷启动同一套规则同步算出(`?new=1` 新建本地空对话、`?c=` 打开该会话、登录返回存根与其人物范围同 BUG-1038),随后后台刷新模型目录、账户、后台回答恢复、入口摘要与今日星语,结果到了静默替换;消息没缓存的历史会话沿用首页内切换会话的留白方式。快照缺任何一项、落点需要去服务端查、或客户端导航没留下目标地址(Next 先渲染新页面、后写地址栏,`AppLink` 在点击时记下目标),一律回到冷启动,不半揭幕。账户变化、退出、任一 401 清空快照;今日星语跨日或换人按新键重取,卡片先用静态句。 + 首页只揭幕一次。揭幕前的加载屏先并行取账户 / 会话、模型目录和咨询状态,再等待 lookup、会话详情等依赖数据全部到齐;不设 8 秒硬超时,超过 `HOME_BOOTSTRAP_SLOW_MS = 20_000` 才进入现有全屏错误屏。错误屏的「重试」只取消旧请求、重载 provider 并局部重跑 bootstrap,不调用 `location.reload()`。揭幕后不得再出现任何阻塞等待或组件级 spinner:今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。登录后的空首页只剩问候语、今日星语卡、生时校正卡和输入框,没有主题卡。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。 ### 首屏死屏兜底 diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index e3e7f137..21be2d9e 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -161,7 +161,15 @@ import { payloadMessage, waitForUndoWindow, } from "@/lib/home-cloud-sync"; -import { runHomeBootstrap } from "@/lib/home-bootstrap-run"; +import { runHomeBootstrap, runHomeWarmRefresh } from "@/lib/home-bootstrap-run"; +import { writeHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; +import { + homeWarmStartInput, + mountedHomeWarmStart, + pendingWarmLandingSession, + takeHomeWarmStart, + warmRectificationEntry, +} from "@/lib/home-warm-start"; import { runConsultationRecoveryPoll } from "@/lib/consultation-recovery-poll"; import { bootstrapLoadingCopy, @@ -192,15 +200,18 @@ export default function Home() { reload: reloadSessionList, registerShellControls, } = sessionList; + // Warm return (BUG-1040): read only by the useState initializers below, so + // it decides once per mount. Null means the unchanged cold bootstrap. + const warmStart = () => takeHomeWarmStart(() => homeWarmStartInput(sessionList)); const [accountMenuOpen, setAccountMenuOpen] = useState(false); const [activeAccountDialog, setActiveAccountDialog] = useState(null); const [chartLibrary, setChartLibrary] = useState([]); const [billingPane, setBillingPane] = useState({}); - const [dailyStarlanguage, setDailyStarlanguage] = useState({ kind: "pending" }); + const [dailyStarlanguage, setDailyStarlanguage] = useState(() => warmStart()?.daily ?? { kind: "pending" }); const [accountError, setAccountError] = useState(""); const [signingOut, setSigningOut] = useState(false); - const [modelCatalog, setModelCatalog] = useState(null); - const [activeSessionId, setActiveSessionId] = useState(""); + const [modelCatalog, setModelCatalog] = useState(() => warmStart()?.modelCatalog ?? null); + const [activeSessionId, setActiveSessionId] = useState(() => warmStart()?.landing.activeSessionId ?? ""); const draftTheme = useRef(null); const draftEntrypoint = useRef(null); const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null); @@ -215,7 +226,7 @@ export default function Home() { const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState( createBirthTimeConsultationConsentState, ); - const [rectification, setRectification] = useState(EMPTY_RECTIFICATION_SHELL); + const [rectification, setRectification] = useState(() => ({ ...EMPTY_RECTIFICATION_SHELL, ...warmRectificationEntry(warmStart()) })); const { sessionId: rectificationSessionId, caseId: rectificationCaseId, @@ -233,18 +244,18 @@ export default function Home() { setRectificationEntrySummary, setRectificationEntrySummarySettled, } = useMemo(() => createRectificationShellSetters(setRectification), [setRectification]); - const [hydrated, setHydrated] = useState(false); - const [bootstrapPhase, setBootstrapPhase] = useState("account"); + const [hydrated, setHydrated] = useState(() => warmStart() !== null); + const [bootstrapPhase, setBootstrapPhase] = useState(() => (warmStart() ? "prepare" : "account")); const prepareStartedAt = useRef(null); const [guidedJourneyPreview, setGuidedJourneyPreview] = useState(false); const [creatingSession, setCreatingSession] = useState(false); - const [onboardingStep, setOnboardingStep] = useState("name"); + const [onboardingStep, setOnboardingStep] = useState(() => warmStart()?.onboardingStep ?? "name"); const [onboardingJustCompleted, setOnboardingJustCompleted] = useState(false); const [onboardingPaywallOpen, setOnboardingPaywallOpen] = useState(false); const [birthTimeJourney, setBirthTimeJourney] = useState(null); const [birthTimeError, setBirthTimeError] = useState(""); const [birthTimeAssessmentPhase, setBirthTimeAssessmentPhase] = useState(null); - const [startGreeting, setStartGreeting] = useState(""); + const [startGreeting, setStartGreeting] = useState(() => warmStart()?.startGreeting ?? ""); const [starterGreetingSelection, setStarterGreetingSelection] = useState(() => Math.random()); const [presetMessageLength, setPresetMessageLength] = useState(0); const conversation = useRef(null); @@ -317,6 +328,7 @@ export default function Home() { setOnboardingJustCompleted, setOnboardingStep, setPresetMessageLength, setRectificationError, setSigningOut, setStartGreeting, signingOut, uiPreview, + initialProfile: () => warmStart()?.profile ?? null, }); const birthTimeGuided = useBirthTimeGuidedJourney({ @@ -328,6 +340,7 @@ export default function Home() { }); const activeSession = sessions.find((session) => session.id === activeSessionId) + ?? pendingWarmLandingSession(activeSessionId) ?? sessions.find((session) => session.sessionType !== "birth_time_rectification" && sessionMatchesSubject(session, readCurrentSubjectId())); const activeRectificationSession = composerLocksAsRectification(activeSession, sessions); const rectificationSurfaceOpen = Boolean(activeRectificationSession && activeSession && activeSession.id === rectificationSessionId); @@ -503,6 +516,20 @@ export default function Home() { })(); }, [accountId, bootstrapPhase, setRectificationEntrySummary, setRectificationEntrySummarySettled]); + // Keep the warm snapshot current once a bootstrap has fully succeeded; an + // incomplete one (no catalog, summary not settled, error) is never written. + useEffect(() => { + if (!hydrated || uiPreview.current || accountError || !accountId || !modelCatalog || !rectificationEntrySummarySettled) return; + writeHomeWarmSnapshot({ + accountId, + modelCatalog, + entrySummary: rectificationEntrySummary, + entrySummarySettled: true, + sessionsCursor, + listBoot: sessionListBoot(), + }); + }, [accountError, accountId, hydrated, modelCatalog, rectificationEntrySummary, rectificationEntrySummarySettled, sessionListBoot, sessionsCursor]); + const { status: chatProfileStatus, markReady: markChatProfilesReady, @@ -743,6 +770,23 @@ export default function Home() { useEffect(() => { const controller = new AbortController(); + // `hydrated` is true on mount only for a warm start: paint first, then + // commit the landing and refresh in the background (BUG-1040). + const mountedWarmStart = hydrated ? mountedHomeWarmStart() : null; + if (mountedWarmStart) { + void runHomeWarmRefresh({ + start: mountedWarmStart, + storage: sessionStorage, + consultationStatusMissingCount, + currentSessions: () => sessionsRef.current, + restoreConsultationRecovery, + resumeRectification: (session) => resumeRectificationSession.current(session), + refreshAccount, + setComposerNotice, + commit: { setActiveSessionId, setModelCatalog, setSessions, setSessionsCursor }, + }, controller.signal); + return () => controller.abort(); + } void runHomeBootstrap({ sessionListReady, waitForSessionList, diff --git a/frontend/src/components/app-link.tsx b/frontend/src/components/app-link.tsx index 3ae89613..1fdf39e0 100644 --- a/frontend/src/components/app-link.tsx +++ b/frontend/src/components/app-link.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { forwardRef, type ComponentProps } from "react"; import { hrefFromLinkTarget, interceptAppNavigation } from "@/lib/app-navigation"; +import { noteClientNavigationTarget } from "@/lib/client-navigation-target"; export type AppLinkProps = ComponentProps; @@ -25,7 +26,13 @@ export const AppLink = forwardRef(function AppL href={href} onClick={(event) => { onClick?.(event); - interceptAppNavigation(event, hrefFromLinkTarget(href)); + const target = hrefFromLinkTarget(href); + interceptAppNavigation(event, target); + // A plain click the client router will take: remember where it goes, + // for a page that reads its URL before the router writes it (BUG-1040). + if (!event.defaultPrevented && event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) { + noteClientNavigationTarget(target); + } }} {...props} /> diff --git a/frontend/src/components/daily-starlanguage-binder.tsx b/frontend/src/components/daily-starlanguage-binder.tsx index 868103e4..17cf9e90 100644 --- a/frontend/src/components/daily-starlanguage-binder.tsx +++ b/frontend/src/components/daily-starlanguage-binder.tsx @@ -7,6 +7,7 @@ import { resolveBirthTimeConsultationRoute } from "@/lib/birth-time-consultation import { missingOtherProfileStep } from "@/lib/home-profile"; import { calendarDateInTimeZone, dailyStarlanguageProfileKey } from "@/lib/daily-starlanguage"; import type { BootstrapPhase } from "@/lib/home-bootstrap"; +import { readHomeWarmDaily, rememberHomeWarmDaily } from "@/lib/home-warm-snapshot"; import { fetchDailyStarlanguage, readStoredDailyStarlanguage, @@ -45,9 +46,10 @@ export function DailyStarlanguageBinder(props: { const stored = readStoredDailyStarlanguage(accountId); const controller = new AbortController(); let retryTimer: ReturnType | undefined; - const cachedCard = stored && stored.day === today && stored.fingerprint === fingerprint - ? stored.card - : null; + // The in-memory card (BUG-1040) first: a warm return to `/` must not blink + // back to the static sentence when localStorage is unavailable. + const cachedCard = readHomeWarmDaily({ accountId, subjectId, fingerprint, day: today }) + ?? (stored && stored.day === today && stored.fingerprint === fingerprint ? stored.card : null); queueMicrotask(() => { if (controller.signal.aborted) return; if (cachedCard) setDailyStarlanguage({ kind: "ready", card: cachedCard }); @@ -62,16 +64,19 @@ export function DailyStarlanguageBinder(props: { return; } if (next.kind === "ready") { + rememberHomeWarmDaily({ accountId, subjectId, fingerprint, day: today, card: next.card }); writeStoredDailyStarlanguage(accountId, { day: today, fingerprint, card: next.card }); setDailyStarlanguage(next); return; } if (stored && stored.day === today && stored.fingerprint === fingerprint) return; + if (cachedCard) return; setDailyStarlanguage(next); }) .catch(() => { if (controller.signal.aborted) return; if (stored && stored.day === today && stored.fingerprint === fingerprint) return; + if (cachedCard) return; setDailyStarlanguage({ kind: "unavailable" }); }); }; diff --git a/frontend/src/hooks/use-profile-onboarding.ts b/frontend/src/hooks/use-profile-onboarding.ts index 947919d4..69523334 100644 --- a/frontend/src/hooks/use-profile-onboarding.ts +++ b/frontend/src/hooks/use-profile-onboarding.ts @@ -37,6 +37,7 @@ import { } from "@/lib/home-profile"; import { resolveAyanamsa } from "@/lib/ayanamsa"; import { createStartGreeting } from "@/lib/starter-greeting"; +import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; import { type Account, type AccountDialog, @@ -79,6 +80,8 @@ export type ProfileOnboardingParams = { setStartGreeting: Dispatch>; signingOut: boolean; uiPreview: MutableRefObject; + /** Warm return to `/` (BUG-1040): the profile Home starts with instead of empty. */ + initialProfile?: () => Profile | null; }; export function useProfileOnboarding(params: ProfileOnboardingParams) { @@ -106,12 +109,13 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) { setStartGreeting, signingOut, uiPreview, + initialProfile, } = params; // Owned here since 2026-09-16 (state lowering batch 2). Home reads them back // from this hook's return instead of declaring them itself. - const [profile, setProfile] = useState(emptyProfile); - const [profileDraft, setProfileDraft] = useState(emptyProfile); + const [profile, setProfile] = useState(() => initialProfile?.() ?? emptyProfile); + const [profileDraft, setProfileDraft] = useState(() => initialProfile?.() ?? emptyProfile); const [profileNotice, setProfileNotice] = useState(""); const [avatarNotice, setAvatarNotice] = useState(""); const [avatarSaving, setAvatarSaving] = useState(false); @@ -396,6 +400,9 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) { setSigningOut(true); setAccountError(""); try { + // Signing out ends this account's warm return to `/` (BUG-1040); a + // failed sign-out only costs one loading ring on the next return. + clearHomeWarmSnapshot(); await selfHostedOtpActions.signOut(); window.location.assign("/login"); } catch (caught) { diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index ed85c441..db1223c8 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -234,7 +234,10 @@ export function useSessionManagement(params: SessionManagementParams) { if (!sessionId || uiPreview.current) return; if (pendingSessionId === sessionId) return; if (sessionDetailInFlight.current.has(sessionId)) return; - const known = sessionsRef.current.find((session) => session.id === sessionId); + // A warm return's landing consultation (BUG-1040) is active one frame + // before it is in the list; it is local and empty, never a server row. + const known = sessionsRef.current.find((session) => session.id === sessionId) + ?? (activeSession?.id === sessionId ? activeSession : undefined); if (known?.messagesHydrated || known?.sessionType === "birth_time_rectification") return; sessionDetailInFlight.current.add(sessionId); setSessionDetailLoadingId(sessionId); diff --git a/frontend/src/lib/app-navigation.ts b/frontend/src/lib/app-navigation.ts index 5d9fdb4e..e50bd997 100644 --- a/frontend/src/lib/app-navigation.ts +++ b/frontend/src/lib/app-navigation.ts @@ -1,3 +1,5 @@ +import { noteClientNavigationTarget } from "./client-navigation-target.ts"; + export type AppNavigationHost = { assign: (href: string) => void; }; @@ -84,6 +86,7 @@ export function navigateAppPath( return; } if (clientNavigate) { + noteClientNavigationTarget(href); clientNavigate(href); return; } diff --git a/frontend/src/lib/client-navigation-target.ts b/frontend/src/lib/client-navigation-target.ts new file mode 100644 index 00000000..d0e02da8 --- /dev/null +++ b/frontend/src/lib/client-navigation-target.ts @@ -0,0 +1,28 @@ +/** + * Where the client navigation in flight is going (BUG-1040). + * + * Next's app router renders the next page first and writes the address bar + * afterwards (HistoryUpdater, an insertion effect). A page that decides its + * first frame from the URL — Home's warm return reads `?new=1` / `?c=` — would + * otherwise read the page it is leaving. `AppLink` and `navigateAppPath` note + * the href here just before the router starts; the page takes it once. + * + * Memory only, no dependencies (it ships with the shared sidebar). + */ + +const MAX_AGE_MS = 10_000; + +let target: { href: string; at: number } | null = null; + +export function noteClientNavigationTarget(href: string): void { + if (!href.startsWith("/") || href.startsWith("//")) return; + target = { href, at: Date.now() }; +} + +/** The noted href (then forgotten), or null when none is recent enough to trust. */ +export function takeClientNavigationTarget(now = Date.now()): string | null { + const noted = target; + target = null; + if (!noted || now - noted.at > MAX_AGE_MS) return null; + return noted.href; +} diff --git a/frontend/src/lib/home-bootstrap-run.ts b/frontend/src/lib/home-bootstrap-run.ts index 9a802302..e6c8c7c1 100644 --- a/frontend/src/lib/home-bootstrap-run.ts +++ b/frontend/src/lib/home-bootstrap-run.ts @@ -63,6 +63,13 @@ import { dropRectificationStoredPending } from "@/lib/rectification-session-comp import type { SessionListBoot } from "@/lib/session-list-context"; import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter"; import { clearStaleClientReload } from "@/lib/stale-client-recovery"; +import { + applyWarmLanding, + claimWarmLandingCommit, + reconcileSessionModels, + sameModelCatalog, + type HomeWarmStart, +} from "@/lib/home-warm-start"; export const HOME_BOOTSTRAP_TIMEOUT_MESSAGE = "连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。"; @@ -155,6 +162,54 @@ function resolveIo(deps: HomeBootstrapDeps): HomeBootstrapIo { }; } +/** + * Which consultation is still generating in the background: the account-wide + * active status, overridden by this tab's stored pending request. Shared by + * the cold bootstrap and the warm return (BUG-1040), so recovery is the same. + */ +async function resolveReservedConsultation(input: { + io: Pick; + storage: HomeBootstrapStorage; + storedRaw: string | null; + sessions: readonly ChatSession[]; + activeStatus: ConsultationStatus | null; + consultationStatusMissingCount: { current: number }; + signal: AbortSignal; +}): Promise<{ reservedConsultation: ConsultationStatus | null; storedPending: StoredPendingConsultation | null }> { + let reservedConsultation = input.activeStatus; + const storedPending = dropRectificationStoredPending( + readStoredPendingConsultation(input.storedRaw, input.sessions.map((session) => session.id)), + [...input.sessions], + ); + if (!storedPending) input.storage.removeItem(pendingConsultationStorageKey); + + if (storedPending) { + try { + const status = await input.io.fetchConsultationStatus( + storedPending.sessionId, + storedPending.requestId, + input.signal, + ); + if (status.status === "reserved") { + input.consultationStatusMissingCount.current = 0; + reservedConsultation = status; + } else { + input.storage.removeItem(pendingConsultationStorageKey); + reservedConsultation = null; + } + } catch (caught) { + if (caught instanceof Error && caught.name === "AbortError") throw caught; + input.consultationStatusMissingCount.current = caught instanceof ConsultationStatusError && caught.status === 404 ? 1 : 0; + reservedConsultation = { + sessionId: storedPending.sessionId, + requestId: storedPending.requestId, + status: "reserved", + }; + } + } + return { reservedConsultation, storedPending }; +} + export async function runAccountPhase( deps: HomeBootstrapDeps, signal: AbortSignal, @@ -294,37 +349,16 @@ export async function runAccountPhase( } nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions); - let reservedConsultation: ConsultationStatus | null = await activeStatusPromise; - const storedPending = dropRectificationStoredPending( - readStoredPendingConsultation(deps.storage.getItem(pendingConsultationStorageKey), nextSessions.map((session) => session.id)), - nextSessions, - ); - if (!storedPending) deps.storage.removeItem(pendingConsultationStorageKey); - - if (storedPending) { - try { - const status = await io.fetchConsultationStatus( - storedPending.sessionId, - storedPending.requestId, - signal, - ); - if (status.status === "reserved") { - deps.consultationStatusMissingCount.current = 0; - reservedConsultation = status; - } else { - deps.storage.removeItem(pendingConsultationStorageKey); - reservedConsultation = null; - } - } catch (caught) { - if (caught instanceof Error && caught.name === "AbortError") throw caught; - deps.consultationStatusMissingCount.current = caught instanceof ConsultationStatusError && caught.status === 404 ? 1 : 0; - reservedConsultation = { - sessionId: storedPending.sessionId, - requestId: storedPending.requestId, - status: "reserved", - }; - } - } + const activeStatus = await activeStatusPromise; + const { reservedConsultation, storedPending } = await resolveReservedConsultation({ + io, + storage: deps.storage, + storedRaw: deps.storage.getItem(pendingConsultationStorageKey), + sessions: nextSessions, + activeStatus, + consultationStatusMissingCount: deps.consultationStatusMissingCount, + signal, + }); if (signal.aborted) return { kind: "stop" }; return { @@ -479,3 +513,124 @@ export async function runHomeBootstrap(deps: HomeBootstrapDeps, signal: AbortSig } } } + +export type HomeWarmRefreshDeps = { + start: HomeWarmStart; + storage: HomeBootstrapStorage; + consultationStatusMissingCount: { current: number }; + /** The list as Home holds it now (sessionsRef). */ + currentSessions: () => ChatSession[]; + restoreConsultationRecovery: HomeBootstrapIo["restoreConsultationRecovery"]; + /** Home's rectification auto-open, for a stashed return whose `?c=` is written only now. */ + resumeRectification: (session: ChatSession) => void; + refreshAccount: () => Promise; + setComposerNotice: (notice: string) => void; + commit: { + setActiveSessionId: (id: string) => void; + setModelCatalog: (catalog: PublicLanguageModelCatalog) => void; + setSessions: (update: (current: ChatSession[]) => ChatSession[]) => void; + setSessionsCursor: (cursor: string | null) => void; + }; + io?: Partial; +}; + +function applyWarmCatalog( + deps: HomeWarmRefreshDeps, + io: Pick, + catalog: PublicLanguageModelCatalog, + signal: AbortSignal, +) { + const fallbackSessionIds = reconcileSessionModels(deps.currentSessions(), catalog).fallbackSessionIds; + deps.commit.setSessions((current) => reconcileSessionModels(current, catalog).sessions); + // A local chat that was never saved has nothing to patch or to announce. + const known = new Set(deps.currentSessions().filter((session) => !isUnsavedEmptyConsultation(session)).map((session) => session.id)); + const saved = fallbackSessionIds.filter((id) => known.has(id)); + if (saved.length === 0) return false; + deps.setComposerNotice("此前选择的模型已下线,已切换为默认模型。"); + void Promise.all(saved.map((sessionId) => io.patchSessionModel(sessionId, catalog.defaultModelId, signal))).catch(() => { + if (!signal.aborted) deps.setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。"); + }); + return true; +} + +/** Synchronous part of a warm return: the same landing side effects as `resolveLanding`. */ +function commitWarmLanding( + deps: HomeWarmRefreshDeps, + io: Pick, + signal: AbortSignal, +) { + const { landing, modelCatalog, sessionsCursor } = deps.start; + const selection = landing.selection; + const landed = applyWarmLanding(deps.currentSessions(), landing); + deps.commit.setSessions((current) => applyWarmLanding(current, landing)); + deps.commit.setSessionsCursor(sessionsCursor); + if (selection.urlAction === "new-chat") io.writeSessionUrl(null, "replace"); + if (selection.clearStoredReturn) io.clearLoginSessionReturn(); + if (selection.urlAction === "replace-clear") io.writeSessionUrl(null, "replace"); + if (selection.urlAction === "replace-selected") { + const selected = landed.find((session) => session.id === selection.sessionId); + if (!selected || !isUnsavedEmptyConsultation(selected)) { + io.writeSessionUrl(selection.sessionId, "replace"); + // Home's auto-open effect already ran on this mount, before the URL + // named the session; without this a stashed rectification session + // would sit locked, as in BUG-1038. + if (selected?.sessionType === "birth_time_rectification" && selected.id === landing.activeSessionId) { + deps.resumeRectification(selected); + } + } + } + const fellBack = applyWarmCatalog(deps, io, modelCatalog, signal); + if (!fellBack && selection.missing) deps.setComposerNotice(SESSION_MISSING_NOTICE); +} + +/** + * Warm return to `/` (BUG-1040). Home is already painted from the snapshot; + * this commits the landing and then refreshes in the background: model + * catalog, account, and background-consultation recovery. Results replace + * values silently. Nothing here is awaited before the page is usable, and + * nothing here returns Home to the loading state. The rectification entry + * summary and today's card refresh through their own effects. + */ +export async function runHomeWarmRefresh(deps: HomeWarmRefreshDeps, signal: AbortSignal): Promise { + const io = { ...defaultIo, ...deps.io }; + const { start } = deps; + if (claimWarmLandingCommit(start)) commitWarmLanding(deps, io, signal); + if (signal.aborted) return; + + const catalogTask = io.fetchModelCatalog(signal) + .then((next) => { + if (signal.aborted || sameModelCatalog(next, start.modelCatalog)) return; + deps.commit.setModelCatalog(next); + applyWarmCatalog(deps, io, next, signal); + }) + // The snapshot catalog stays; the next document load reads it again. + .catch(() => undefined); + + const recoveryTask = (async () => { + const activeStatus = await io.fetchActiveConsultationStatus(signal).catch((caught: unknown) => { + if (caught instanceof Error && caught.name === "AbortError") throw caught; + return null; + }); + const { reservedConsultation, storedPending } = await resolveReservedConsultation({ + io, + storage: deps.storage, + storedRaw: start.storedPendingRaw, + sessions: deps.currentSessions(), + activeStatus, + consultationStatusMissingCount: deps.consultationStatusMissingCount, + signal, + }); + if (signal.aborted || reservedConsultation?.status !== "reserved") return; + const recoverySession = deps.currentSessions().find((session) => session.id === reservedConsultation.sessionId); + if (recoverySession) deps.restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending); + if (start.landing.selection.urlAction === "new-chat") { + // Same rule as the cold path: recovery continues in the background and + // does not take over an explicit new-chat landing. + deps.commit.setActiveSessionId(start.landing.activeSessionId); + deps.setComposerNotice(""); + } + })().catch(() => undefined); + + const accountTask = deps.refreshAccount().catch(() => undefined); + await Promise.all([catalogTask, recoveryTask, accountTask]); +} diff --git a/frontend/src/lib/home-cloud-sync.ts b/frontend/src/lib/home-cloud-sync.ts index 7caf422f..1baf4fd7 100644 --- a/frontend/src/lib/home-cloud-sync.ts +++ b/frontend/src/lib/home-cloud-sync.ts @@ -1,4 +1,5 @@ import { writeChatSession } from "@/lib/chat-session-write-contract"; +import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; import { bootstrapSelectionFromLookup, parseSessionUrlQuery, @@ -412,6 +413,8 @@ export class LoginRedirectError extends Error { } export function redirectToLogin(): never { + // Any 401 ends this account's warm return to `/` (BUG-1040). + clearHomeWarmSnapshot(); persistLoginSessionReturn(); window.location.replace("/login"); throw new LoginRedirectError(); diff --git a/frontend/src/lib/home-warm-snapshot.ts b/frontend/src/lib/home-warm-snapshot.ts new file mode 100644 index 00000000..5fd4c77c --- /dev/null +++ b/frontend/src/lib/home-warm-snapshot.ts @@ -0,0 +1,99 @@ +/** + * Home warm snapshot (BUG-1040). + * + * The loading ring plays once per document load, not once per Home mount. + * After the first cold bootstrap succeeds, Home keeps here what it would + * otherwise re-fetch on every client navigation back to `/`: the model + * catalog, the rectification entry summary, the session-page cursor and + * today's starlanguage card. Account, profile and the session list are not + * copied: they already live in the layout-level SessionListProvider, which + * survives client navigation. + * + * Memory only, one account at a time. Nothing here is written to + * localStorage / sessionStorage / IndexedDB; a document load starts empty. + * Cleared on account change, sign-out and any 401 login redirect. + * + * Pure module state, no React. Callers: `home-warm-start.ts` (read), + * Home's sync effect (write), `DailyStarlanguageBinder` (daily card), + * `redirectToLogin` / `signOut` / the provider's signed-out branch (clear). + */ + +import type { DailyStarlanguageCard } from "./home-types.ts"; +import type { PublicLanguageModelCatalog } from "./public-models.ts"; +import type { RectificationEntrySummary } from "./rectification-entry.ts"; + +export type HomeWarmSnapshot = Readonly<{ + accountId: string; + modelCatalog: PublicLanguageModelCatalog; + entrySummary: RectificationEntrySummary | null; + entrySummarySettled: boolean; + sessionsCursor: string | null; + /** + * Identity of the provider's boot object when the cursor was taken. A + * provider reload (person switch while away) replaces it, and its own + * cursor then wins over ours. + */ + listBoot: object | null; +}>; + +/** Today's card, keyed by person + birth-data fingerprint + calendar day. */ +export type HomeWarmDaily = Readonly<{ + accountId: string; + subjectId: string; + fingerprint: string; + day: string; + card: DailyStarlanguageCard; +}>; + +let snapshot: HomeWarmSnapshot | null = null; +let daily: HomeWarmDaily | null = null; + +export function clearHomeWarmSnapshot(): void { + snapshot = null; + daily = null; +} + +/** Written after a successful cold bootstrap and kept current while Home is mounted. */ +export function writeHomeWarmSnapshot(next: HomeWarmSnapshot): void { + if (!next.accountId) return; + if (snapshot && snapshot.accountId !== next.accountId) clearHomeWarmSnapshot(); + if (daily && daily.accountId !== next.accountId) daily = null; + snapshot = next; +} + +/** + * The snapshot for this account, or null. Another account's snapshot is + * dropped on sight: an account change must never paint the previous + * account's catalog, summary or card. + */ +export function readHomeWarmSnapshot(accountId: string | null | undefined): HomeWarmSnapshot | null { + if (!accountId) return null; + if (snapshot && snapshot.accountId !== accountId) { + clearHomeWarmSnapshot(); + return null; + } + return snapshot; +} + +/** Complete means a warm return can paint without waiting for anything. */ +export function isCompleteHomeWarmSnapshot(value: HomeWarmSnapshot | null): value is HomeWarmSnapshot { + return Boolean(value && value.accountId && value.modelCatalog && value.modelCatalog.models.length > 0 && value.entrySummarySettled); +} + +export function rememberHomeWarmDaily(next: HomeWarmDaily): void { + if (!next.accountId) return; + if (snapshot && snapshot.accountId !== next.accountId) clearHomeWarmSnapshot(); + daily = next; +} + +/** The remembered card only when person, fingerprint and day all still match. */ +export function readHomeWarmDaily(key: Readonly<{ + accountId: string; + subjectId: string; + fingerprint: string; + day: string; +}>): DailyStarlanguageCard | null { + if (!daily || daily.accountId !== key.accountId) return null; + if (daily.subjectId !== key.subjectId || daily.fingerprint !== key.fingerprint || daily.day !== key.day) return null; + return daily.card; +} diff --git a/frontend/src/lib/home-warm-start.ts b/frontend/src/lib/home-warm-start.ts new file mode 100644 index 00000000..17e3d715 --- /dev/null +++ b/frontend/src/lib/home-warm-start.ts @@ -0,0 +1,321 @@ +/** + * Warm return to `/` (BUG-1040). No React hooks. + * + * Home's `useState(() => …)` initializers call `takeHomeWarmStart()`. When + * the layout-level session list is settled for a signed-in account and this + * document already completed one cold bootstrap for that account (a complete + * `home-warm-snapshot`), Home starts ready: no loading ring, no awaited + * request, landing resolved synchronously with the same rules as the cold + * path (`?new=1`, `?c=`, the login-return stash and its person scope from + * BUG-1038). Anything missing, or a landing that needs a network lookup, + * returns null and Home takes the unchanged cold path (BUG-1021: never a + * half-reveal). + */ + +import { resolveBirthTimeConsultationRoute } from "@/lib/birth-time-consultation-consent"; +import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model"; +import { + bootstrapSelectionFromLookup, + readLoginSessionReturn, + resolveBootstrapSessionSelection, + type BootstrapSessionSelection, +} from "@/lib/chat-session-url"; +import { takeClientNavigationTarget } from "@/lib/client-navigation-target"; +import { peekSubjectCatalog, readCurrentSubjectId } from "@/lib/current-subject"; +import { calendarDateInTimeZone, dailyStarlanguageProfileKey } from "@/lib/daily-starlanguage"; +import { resolveStarterHomeLandingSessionId, starterHomeLandingNeedsConsultation } from "@/lib/home-bootstrap"; +import { createSession, readStoredDailyStarlanguage } from "@/lib/home-cloud-sync"; +import { + chartSnapshotForSession, + isProfileComplete, + missingOtherProfileStep, + missingProfileStep, + readProfile, +} from "@/lib/home-profile"; +import { + pendingConsultationStorageKey, + type Account, + type ChartLibraryRecord, + type ChatSession, + type DailyStarlanguageState, + type OnboardingStep, + type Profile, +} from "@/lib/home-types"; +import { isCompleteHomeWarmSnapshot, readHomeWarmDaily, readHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; +import { resolveSessionModelId, type PublicLanguageModelCatalog } from "@/lib/public-models"; +import type { RectificationEntrySummary } from "@/lib/rectification-entry"; +import type { SessionListBoot, SessionListContextValue } from "@/lib/session-list-context"; +import { replaceUnsavedEmptyConsultations } from "@/lib/session-list-filter"; +import { createStartGreeting } from "@/lib/starter-greeting"; + +export type WarmLanding = Readonly<{ + activeSessionId: string; + /** A local empty consultation Home lands on before it is in the list. */ + pendingSession: ChatSession | null; + selection: BootstrapSessionSelection; +}>; + +export type HomeWarmStart = Readonly<{ + accountId: string; + modelCatalog: PublicLanguageModelCatalog; + profile: Profile; + startGreeting: string; + onboardingStep: OnboardingStep; + entrySummary: RectificationEntrySummary | null; + daily: DailyStarlanguageState; + sessionsCursor: string | null; + /** + * This tab's pending-consultation record, read at mount: Home's own + * pending-request effect clears the key on the first effect pass. + */ + storedPendingRaw: string | null; + landing: WarmLanding; +}>; + +export type HomeWarmStartInput = Readonly<{ + account: Account | null; + settled: boolean; + signedOut: boolean; + boot: SessionListBoot | null; + sessions: readonly ChatSession[]; + /** The query of the `/` being opened; null when it cannot be known yet. */ + search: string | null; + storedReturnId: string | null; + storedPendingRaw: string | null; + subjectId: string; + subjectCatalog: readonly ChartLibraryRecord[] | null; + /** Development `?preview=` fixtures always take the cold path. */ + previewRequested: boolean; + now: Date; +}>; + +/** + * The key today's card is cached under, or null when the card does not apply + * (incomplete person, no birth minute). Mirrors DailyStarlanguageBinder. + */ +export function dailyStarlanguageWarmKey(subjectId: string, profile: Profile | null, now: Date): { day: string; fingerprint: string } | null { + if (!profile || missingOtherProfileStep(profile) !== null) return null; + const route = resolveBirthTimeConsultationRoute(profile, {}, ""); + if (route.kind !== "consult" || !route.time) return null; + return { + day: calendarDateInTimeZone(now, profile.timezoneId), + fingerprint: `${subjectId}:${dailyStarlanguageProfileKey(profile)}`, + }; +} + +function subjectProfile(subjectId: string, self: Profile, catalog: readonly ChartLibraryRecord[] | null): Profile | null { + if (!subjectId || subjectId === "self") return self; + return catalog?.find((record) => record.id === subjectId)?.profile ?? null; +} + +function warmDaily(accountId: string, subjectId: string, profile: Profile | null, now: Date): DailyStarlanguageState { + const key = dailyStarlanguageWarmKey(subjectId, profile, now); + if (!key) return { kind: "unavailable" }; + const remembered = readHomeWarmDaily({ accountId, subjectId, ...key }); + if (remembered) return { kind: "ready", card: remembered }; + const stored = readStoredDailyStarlanguage(accountId); + if (stored && stored.day === key.day && stored.fingerprint === key.fingerprint) return { kind: "ready", card: stored.card }; + // Another day or another person: the card area keeps its static sentence + // and the binder fetches under the new key. It never holds up the page. + return { kind: "pending" }; +} + +/** + * Same landing rules as `resolveLanding`, over the list the provider already + * holds. Null when the requested session is not in memory (the cold path + * owns lookups, BUG-705). + */ +export function resolveWarmLanding(input: { + sessions: readonly ChatSession[]; + search: string; + storedReturnId: string | null; + inSubjectScope: (session: ChatSession) => boolean; + createHomeSession: () => ChatSession; +}): WarmLanding | null { + let pendingSession: ChatSession | null = null; + let scoped = input.sessions.filter(input.inSubjectScope); + if (scoped.length === 0) { + pendingSession = input.createHomeSession(); + scoped = [pendingSession]; + } + const known = pendingSession ? [pendingSession, ...input.sessions] : [...input.sessions]; + const defaultSessionId = scoped[0].id; + let selection = resolveBootstrapSessionSelection({ + listedIds: known.map((session) => session.id), + defaultSessionId, + search: input.search, + storedReturnId: input.storedReturnId, + }); + if (selection.urlAction === "lookup") return null; + if (selection.urlAction === "replace-selected") { + // Only a stashed return lands here. A stash of another person is not a + // place to return to (BUG-1038). + const stashed = known.find((session) => session.id === selection.sessionId); + if (stashed && !input.inSubjectScope(stashed)) { + selection = bootstrapSelectionFromLookup("other-subject", stashed.id, defaultSessionId, "stored-return"); + } + } + let activeSessionId = resolveStarterHomeLandingSessionId(scoped, selection.sessionId, selection.urlAction); + if (starterHomeLandingNeedsConsultation(scoped, activeSessionId, selection.urlAction)) { + pendingSession ??= input.createHomeSession(); + activeSessionId = pendingSession.id; + } + return { activeSessionId, pendingSession, selection }; +} + +export function resolveHomeWarmStart(input: HomeWarmStartInput): HomeWarmStart | null { + if (input.previewRequested || input.search === null) return null; + const search = input.search; + const account = input.account; + if (!input.settled || input.signedOut || !account || !input.boot || input.boot.error) return null; + const snapshot = readHomeWarmSnapshot(account.user.id); + if (!isCompleteHomeWarmSnapshot(snapshot)) return null; + const profile = readProfile(account.profile); + // Onboarding keeps the cold path: its streamed questions and birth-time + // assessment are not part of the snapshot. + if (!isProfileComplete(profile)) return null; + const catalog = snapshot.modelCatalog; + const landing = resolveWarmLanding({ + sessions: input.sessions, + search, + storedReturnId: input.storedReturnId, + inSubjectScope: (session) => sessionMatchesSubject(session, input.subjectId), + createHomeSession: () => createSession( + catalog.defaultModelId, + "consultation", + chartSnapshotForSession(input.subjectId, [...(input.subjectCatalog ?? [])], profile), + ), + }); + if (!landing) return null; + return { + accountId: account.user.id, + modelCatalog: catalog, + profile, + startGreeting: profile.name.trim() ? createStartGreeting(profile.name) : "", + onboardingStep: missingProfileStep(profile) ?? "name", + entrySummary: snapshot.entrySummary, + daily: warmDaily(account.user.id, input.subjectId, subjectProfile(input.subjectId, profile, input.subjectCatalog), input.now), + sessionsCursor: input.boot === snapshot.listBoot ? snapshot.sessionsCursor : input.boot.cursor, + storedPendingRaw: input.storedPendingRaw, + landing, + }; +} + +function readSessionStorage(key: string): string | null { + try { + return globalThis.sessionStorage.getItem(key); + } catch { + return null; + } +} + +/** Everything the warm decision reads, taken from the layout-level list and this tab. */ +/** + * The query of the `/` this mount opens. During a client navigation Next + * renders the page before it writes the address bar, so `location` still + * shows the page being left; the href noted by AppLink says where we are + * going. Unknown (a navigation that noted nothing) → null → cold path. + */ +export function homeSearchForMount(location: Pick, noted: string | null): string | null { + if (location.pathname === "/") return location.search; + if (!noted) return null; + const url = new URL(noted, location.origin); + return url.pathname === "/" ? url.search : null; +} + +export function homeWarmStartInput(list: Pick): HomeWarmStartInput { + const search = homeSearchForMount(window.location, takeClientNavigationTarget()); + return { + account: list.account, + settled: list.settled, + signedOut: list.signedOut, + boot: list.boot(), + sessions: list.sessions, + search, + storedReturnId: readLoginSessionReturn(), + storedPendingRaw: readSessionStorage(pendingConsultationStorageKey), + subjectId: readCurrentSubjectId(), + subjectCatalog: peekSubjectCatalog(), + previewRequested: process.env.NODE_ENV === "development" && new URLSearchParams(window.location.search).has("preview"), + now: new Date(), + }; +} + +/** The rectification shell fields a warm start fills; empty for a cold start. */ +export function warmRectificationEntry(start: HomeWarmStart | null): { entrySummary?: RectificationEntrySummary | null; entrySummarySettled?: boolean } { + return start ? { entrySummary: start.entrySummary, entrySummarySettled: true } : {}; +} + +let memo: { result: HomeWarmStart | null } | null = null; +let mounted: HomeWarmStart | null = null; +let landingCommitted: HomeWarmStart | null = null; + +/** + * One decision per Home mount. Every initializer in the same render (and + * React's StrictMode double call) reads the same result; the memo expires on + * the next microtask, so the next mount decides again. + */ +export function takeHomeWarmStart(read: () => HomeWarmStartInput): HomeWarmStart | null { + if (memo) return memo.result; + const result = typeof window === "undefined" ? null : resolveHomeWarmStart(read()); + memo = { result }; + mounted = result; + queueMicrotask(() => { + memo = null; + }); + return result; +} + +/** The warm start of the Home that is mounting now, for its bootstrap effect. */ +export function mountedHomeWarmStart(): HomeWarmStart | null { + return mounted; +} + +/** + * Before the landing commit puts the pending consultation into the list, + * the first frame still needs it as the active session. + */ +export function pendingWarmLandingSession(activeSessionId: string): ChatSession | undefined { + const pending = mounted?.landing.pendingSession; + return pending && pending.id === activeSessionId && landingCommitted !== mounted ? pending : undefined; +} + +/** True once per warm start; StrictMode's second effect run skips the commit. */ +export function claimWarmLandingCommit(start: HomeWarmStart): boolean { + if (landingCommitted === start) return false; + landingCommitted = start; + return true; +} + +/** List with the landing's local consultation in front (replacing older unsaved empties). */ +export function applyWarmLanding(sessions: ChatSession[], landing: WarmLanding): ChatSession[] { + const pending = landing.pendingSession; + if (!pending || sessions.some((session) => session.id === pending.id)) return sessions; + return replaceUnsavedEmptyConsultations(sessions, pending); +} + +/** Same fallback as `readSessions(rows, catalog)`, over sessions already in memory. */ +export function reconcileSessionModels( + sessions: ChatSession[], + catalog: PublicLanguageModelCatalog, +): { sessions: ChatSession[]; fallbackSessionIds: string[] } { + const fallbackSessionIds: string[] = []; + const next = sessions.map((session) => { + const selection = resolveSessionModelId(session.modelId, catalog); + if (!selection.fellBack) return session; + fallbackSessionIds.push(session.id); + return { ...session, modelId: selection.modelId }; + }); + // Unchanged input keeps its identity, so a no-op reconcile does not re-render. + return { sessions: fallbackSessionIds.length > 0 ? next : sessions, fallbackSessionIds }; +} + +export function sameModelCatalog(left: PublicLanguageModelCatalog | null, right: PublicLanguageModelCatalog | null): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function resetHomeWarmStartForTests(): void { + memo = null; + mounted = null; + landingCommitted = null; +} diff --git a/frontend/src/lib/session-list-context.tsx b/frontend/src/lib/session-list-context.tsx index e58caed2..b7c0dcc5 100644 --- a/frontend/src/lib/session-list-context.tsx +++ b/frontend/src/lib/session-list-context.tsx @@ -18,6 +18,7 @@ import type { SidebarSession } from "@/components/sidebar-session-row"; import { bindCurrentSubjectAccount, invalidateAccountFlight, invalidateSubjectCatalog, loadSubjectCatalog, readBootAccount, readCurrentSubjectAccountId, readCurrentSubjectId, subscribeCurrentSubject, useCurrentSubject } from "@/lib/current-subject"; import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model"; import { readSessions } from "@/lib/home-cloud-sync"; +import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; import type { Account, ChatSession } from "@/lib/home-types"; import { SESSION_PAGE_SIZE } from "@/lib/session-cursor"; import { isListedSidebarSession } from "@/lib/session-list-filter"; @@ -101,6 +102,7 @@ async function loadSessionList(signal: AbortSignal, forceCatalog = false): Promi const accountResult = await readBootAccount(); signal.throwIfAborted(); if (accountResult.status === 401) { + clearHomeWarmSnapshot(); invalidateSubjectCatalog(); bindCurrentSubjectAccount("", null); return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: true }; @@ -108,6 +110,8 @@ async function loadSessionList(signal: AbortSignal, forceCatalog = false): Promi if (!accountResult.account) throw new Error("account_unavailable"); const accountPayload = accountResult.account; if (readCurrentSubjectAccountId() !== accountPayload.user.id) { + // Another account: nothing of the previous one may paint on `/` (BUG-1040). + clearHomeWarmSnapshot(); invalidateSubjectCatalog(); bindCurrentSubjectAccount(accountPayload.user.id, null); } @@ -116,6 +120,7 @@ async function loadSessionList(signal: AbortSignal, forceCatalog = false): Promi const subjectId = readCurrentSubjectId(); const sessionResponse = await fetch(`/api/sessions?limit=${SESSION_PAGE_SIZE}&subject=${encodeURIComponent(subjectId)}`, { signal, cache: "no-store" }); if (sessionResponse.status === 401) { + clearHomeWarmSnapshot(); return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: true }; } const sessionPayload = await sessionResponse.json().catch(() => null) as { diff --git a/frontend/tests/home-warm-return-lifecycle.test.tsx b/frontend/tests/home-warm-return-lifecycle.test.tsx new file mode 100644 index 00000000..459063b7 --- /dev/null +++ b/frontend/tests/home-warm-return-lifecycle.test.tsx @@ -0,0 +1,595 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React, { useInsertionEffect, useLayoutEffect, type ReactNode } from "react"; + +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; + +Object.assign(globalThis, { React }); + +// BUG-1040. Real React lifecycle: the real Home, the real read-only sidebar and +// the real SessionListProvider composed like app/(app)/layout.tsx. A client +// navigation swaps the route child (Home unmounts and remounts) while the +// provider stays; a document load resets module state and keeps storages. +// The claim under test: the loading ring plays once per document load, and a +// client return to `/` is ready in its first committed frame. + +// Synthetic ids and profiles only (no real person data). +const USER = "11111111-1111-4111-8111-111111111111"; +const OTHER_USER = "22222222-2222-4222-8222-222222222222"; +const RECT = "33333333-3333-4333-8333-333333333333"; +const CASE = "44444444-4444-4444-8444-444444444444"; +const CONS = "55555555-5555-4555-8555-555555555555"; +const REQUEST = "77777777-7777-4777-8777-777777777777"; + +const selfProfile = { + name: "林遥", date: "1990-06-15", time: "12:30", reportedTime: "12:30", birthTimeSource: "legacy_import", + birthTimePeriod: "", declaredWindowStart: "", declaredWindowEnd: "", birthTimeClue: "", + uncertaintyBeforeMinutes: null, uncertaintyAfterMinutes: null, birthTimeStatus: "confirmed", rectificationCaseId: "", + countryCode: "CN", provinceCode: "110000", cityCode: "110000-city", districtCode: "110101", ayanamsa: "lahiri", +}; +const rectRow = { + id: RECT, title: "生时校正 · 04:45–05:15", theme: "general", model_id: "deepseek-pro", messages: [], + session_type: "birth_time_rectification", rectification_case_id: CASE, chart_profile_id: "self", + chart_profile_name: "林遥", chart_profile_role: "self", pinned: false, archived_at: null, + created_at: "2026-09-20T00:00:00Z", updated_at: "2026-09-25T00:00:00Z", +}; +const consultationRow = { + id: CONS, title: "事业方向", theme: "career", model_id: "deepseek-pro", + messages: [{ role: "user", text: "虚构问题" }, { role: "assistant", text: "虚构回答" }], + session_type: "consultation", rectification_case_id: null, chart_profile_id: "self", + chart_profile_name: "林遥", chart_profile_role: "self", pinned: false, archived_at: null, + created_at: "2026-09-01T00:00:00Z", updated_at: "2026-09-01T00:00:00Z", +}; +const catalogPro = { defaultModelId: "deepseek-pro", models: [{ id: "deepseek-pro", label: "DeepSeek V4 Pro", description: "x", creditCost: 1, isDefault: true }] }; +const catalogFlash = { defaultModelId: "deepseek-flash", models: [{ id: "deepseek-flash", label: "DeepSeek V4 Flash", description: "x", creditCost: 1, isDefault: true }] }; +const terminalSummary = { has_resumable_case: false, has_terminal_case_with_time: true, latest_resumable: null, latest_terminal: { case_id: CASE, status: "completed" } }; +const emptySummary = { has_resumable_case: false, has_terminal_case_with_time: false, latest_resumable: null, latest_terminal: null }; + +type Mode = { + /** Paths whose response never arrives (to prove nothing waits for them). */ + hang: Set; + models: unknown; + summary: unknown; + consultStatus: unknown; + accountUser: string; +}; + +function installApi() { + const originalFetch = globalThis.fetch; + const requests: string[] = []; + const mode: Mode = { hang: new Set(), models: catalogPro, summary: terminalSummary, consultStatus: null, accountUser: USER }; + const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + const path = url.pathname; + requests.push(`${method} ${path}${url.search}`); + if (mode.hang.has(path)) return new Promise(() => undefined); + if (path === "/api/account") { + return json({ + user: { id: mode.accountUser, email: "fictional@example.test" }, profile: selfProfile, avatar: null, credits: 9, + isAdmin: false, adminUrl: null, rectificationPriceCredits: 1, activeSubscription: null, + hasConfirmedBirthTime: true, hasUsableBirthTime: true, + }); + } + if (path === "/api/chart-profiles") return json({ profiles: [] }); + if (path === "/api/models") return json(mode.models); + if (path === "/api/sessions" && method === "GET") return json({ sessions: [rectRow, consultationRow], nextCursor: null }); + if (path === `/api/sessions/${RECT}`) return json(method === "GET" ? { session: rectRow } : { ok: true }); + if (path === `/api/sessions/${CONS}`) return json(method === "GET" ? { session: consultationRow } : { ok: true }); + if (path.startsWith("/api/sessions")) return json({ ok: true }); + if (path === "/api/consult/status") { + if (!mode.consultStatus) return new Response(null, { status: 204 }); + return json(mode.consultStatus); + } + if (path === "/api/rectification/cases/entry-summary") return json(mode.summary); + if (path === "/api/rectification/cases/open") { + return json({ disposition: "readonly", caseId: CASE, sessionId: RECT, status: "completed", shouldStartOpening: false, skillVersion: "test" }); + } + if (path === `/api/rectification/cases/${CASE}`) return json({ turns: [], snapshot: null }); + if (path === "/api/daily-starlanguage") return new Response(null, { status: 204 }); + return json({ error: "not_mocked" }, 404); + }) as typeof fetch; + return { requests, mode, restore: () => { globalThis.fetch = originalFetch; } }; +} + +function memoryStorage(): Storage { + const values = new Map(); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { values.set(key, String(value)); }, + removeItem: (key) => { values.delete(key); }, + clear: () => values.clear(), + key: (index) => Array.from(values.keys())[index] ?? null, + get length() { return values.size; }, + }; +} + +function createTab() { + const session = memoryStorage(); + const local = memoryStorage(); + const location = { + origin: "http://localhost", protocol: "http:", host: "localhost", hostname: "localhost", + href: "http://localhost/", pathname: "/", search: "", hash: "", + assigned: [] as string[], + assign(href: string) { this.assigned.push(href); }, + replace(href: string) { this.assigned.push(href); }, + }; + const setUrl = (href: string) => { + const next = new URL(href, "http://localhost"); + location.pathname = next.pathname; + location.search = next.search; + location.href = next.href; + }; + const history = { + pushState(_state: unknown, _title: string, url: string) { setUrl(url); }, + replaceState(_state: unknown, _title: string, url: string) { setUrl(url); }, + }; + return { session, local, location, history, setUrl }; +} + +type Tab = ReturnType; +type FirstCommit = { loading: boolean; title: string | null; url: string; requests: number }; + +async function openDocument(tab: Tab, href: string, requests: string[]) { + const h = createClientLifecycleHarness(); + const win = globalThis.window as unknown as Record; + tab.setUrl(href); + Object.assign(win, { + location: tab.location, + history: tab.history, + navigator: { onLine: true, userAgent: "node" }, + sessionStorage: tab.session, + localStorage: tab.local, + innerWidth: 1440, + Element: (globalThis as unknown as { Element: unknown }).Element, + Node: (globalThis as unknown as { Element: unknown }).Element, + ShadowRoot: class {}, + matchMedia: (query: string) => ({ + matches: false, + media: query, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, + }), + }); + const hostProto = (globalThis as unknown as { HTMLElement: { prototype: Record } }).HTMLElement.prototype; + Object.assign(hostProto, { + querySelector: () => null, + querySelectorAll: () => [], + closest: () => null, + matches: () => false, + getBoundingClientRect: () => ({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0 }), + getClientRects: () => [], + scrollTo() {}, + scrollIntoView() {}, + scrollHeight: 0, clientHeight: 0, scrollTop: 0, offsetHeight: 0, offsetTop: 0, + }); + if (!Object.getOwnPropertyDescriptor(hostProto, "children")) { + Object.defineProperty(hostProto, "children", { configurable: true, get(this: { childNodes: unknown[] }) { return this.childNodes; } }); + } + const doc = globalThis.document as unknown as Record; + const createElement = (doc.createElement as (tag: string) => Record).bind(doc); + doc.createElement = (tag: string) => { + const node = createElement(tag); + node.style = { setProperty() {}, removeProperty() {}, getPropertyValue: () => "" }; + return node; + }; + doc.getElementsByTagName = () => [doc.head]; + doc.head = createElement("head"); + doc.documentElement = Object.assign(createElement("html"), { dataset: {} }); + doc.body = createElement("body"); + const previous = { + sessionStorage: Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"), + localStorage: Object.getOwnPropertyDescriptor(globalThis, "localStorage"), + navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"), + matchMedia: Object.getOwnPropertyDescriptor(globalThis, "matchMedia"), + Node: Object.getOwnPropertyDescriptor(globalThis, "Node"), + requestAnimationFrame: Object.getOwnPropertyDescriptor(globalThis, "requestAnimationFrame"), + cancelAnimationFrame: Object.getOwnPropertyDescriptor(globalThis, "cancelAnimationFrame"), + }; + Object.defineProperty(globalThis, "sessionStorage", { value: tab.session, configurable: true, writable: true }); + Object.defineProperty(globalThis, "localStorage", { value: tab.local, configurable: true, writable: true }); + Object.defineProperty(globalThis, "navigator", { value: win.navigator, configurable: true, writable: true }); + Object.defineProperty(globalThis, "matchMedia", { value: win.matchMedia, configurable: true, writable: true }); + Object.defineProperty(globalThis, "Node", { value: win.Node, configurable: true, writable: true }); + Object.defineProperty(globalThis, "requestAnimationFrame", { value: win.requestAnimationFrame, configurable: true, writable: true }); + Object.defineProperty(globalThis, "cancelAnimationFrame", { value: win.cancelAnimationFrame, configurable: true, writable: true }); + // A document load re-evaluates every module: person store, warm snapshot. + const subject = await import("../src/lib/current-subject.ts"); + subject.resetSubjectCatalogForTests(); + (await import("../src/lib/home-warm-snapshot.ts")).clearHomeWarmSnapshot(); + (await import("../src/lib/home-warm-start.ts")).resetHomeWarmStartForTests(); + + const { SessionListProvider, useSessionList, useShellRegistration, sessionListSidebarModel } = await import("../src/lib/session-list-context.tsx"); + const { SidebarInset, SidebarProvider } = await import("../src/components/ui/sidebar.tsx"); + const { AppSidebar } = await import("../src/components/app-sidebar.tsx"); + + function AppShell({ children }: { children: ReactNode }) { + const list = useSessionList(); + const registration = useShellRegistration(); + const { sessions, account } = sessionListSidebarModel(list, registration); + return ( + +
+ + + {children} + +
+
+ ); + } + + const byClass = (name: string) => h.elements().filter((node) => String(node.props.className ?? "").split(/\s+/).includes(name)); + const view = () => { + const title = byClass("chat-header-title")[0]?.childNodes.find((node) => node.tagName === "STRONG")?.text ?? null; + const textarea = h.elements().find((node) => node.tagName === "TEXTAREA"); + return { + url: `${tab.location.pathname}${tab.location.search}`, + loading: byClass("app-loading").length > 0 || byClass("app-loading-orbit").length > 0, + title, + inputDisabled: Boolean(textarea?.props.disabled), + rectificationSurface: byClass("is-rectification").length > 0, + starter: byClass("composer-wrap-starter").length > 0, + transcript: h.elements().some((node) => node.text === "虚构回答"), + rectificationCard: (byClass("starter-entry").find((node) => /再次校正|开始新的生时校正/.test(String(node.props["aria-label"] ?? "")))?.props["aria-label"] as string | undefined) ?? null, + stopVisible: h.elements().some((node) => node.tagName === "BUTTON" && String(node.props["aria-label"] ?? "").includes("停止回答")), + modelLabel: h.elements().find((node) => node.tagName === "BUTTON" && /DeepSeek V4/.test(node.text))?.text ?? null, + }; + }; + + // Records the first committed frame of each Home mount: a layout effect of + // a sibling mounted after Home runs once Home's host tree is committed and + // before the browser would paint. + const firstCommits: FirstCommit[] = []; + function FirstCommitProbe() { + useLayoutEffect(() => { + const v = view(); + firstCommits.push({ loading: v.loading, title: v.title, url: v.url, requests: requests.length }); + }, []); + return null; + } + + const Home = (await import("../src/app/(app)/page.tsx")).default; + const route = (pathname: string): ReactNode => { + if (pathname === "/") return ; + return
; + }; + // Next's order, not the test's convenience: the router renders the next + // page while the address bar still shows the page being left, and writes + // the URL in an insertion effect of that commit (app-router HistoryUpdater). + let routePath = tab.location.pathname; + let pendingHref: string | null = null; + function HistoryUpdater() { + useInsertionEffect(() => { + if (!pendingHref) return; + tab.history.pushState(null, "", pendingHref); + pendingHref = null; + }); + return null; + } + const renderRoute = () => h.render( + {route(routePath)}, + ); + await renderRoute(); + + const link = (label: string) => { + // Session rows carry their date after the title; match on the title. + const found = h.elements().find((node) => node.tagName === "A" + && (node.text.trim() === label || node.props["aria-label"] === label)) + ?? h.elements().find((node) => node.tagName === "A" && String(node.props.href ?? "").startsWith("/?c=") && node.text.startsWith(label)); + assert.ok(found, `link ${label} is rendered`); + return found; + }; + async function followLink(label: string) { + const anchor = link(label); + const href = String(anchor.props.href); + const own = anchor.props.onClick as ((event: unknown) => void) | undefined; + const click = { + button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, defaultPrevented: false, + preventDefault() { click.defaultPrevented = true; }, stopPropagation() {}, + currentTarget: anchor, target: anchor, nativeEvent: {}, + }; + await h.update(() => { own?.(click); }); + routePath = new URL(href, "http://localhost").pathname; + pendingHref = href; + await renderRoute(); + return href; + } + async function settle(until: () => boolean, label: string, ms = 12000) { + const started = Date.now(); + while (Date.now() - started < ms) { + await h.idle(); + if (until()) return; + } + assert.fail(`timed out waiting for ${label}: ${JSON.stringify(view())}`); + } + async function close() { + try { await h.close(); } finally { + for (const [key, descriptor] of Object.entries(previous)) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + } + } + return { h, view, link, followLink, settle, close, firstCommits, errors: h.errors }; +} + +type Doc = Awaited>; + +/** Cold document load of `/`, revealed and settled (the snapshot is written). */ +async function coldHome(tab: Tab, api: ReturnType, href = "/"): Promise { + const doc = await openDocument(tab, href, api.requests); + assert.equal(doc.firstCommits[0]?.loading, true, "a document load starts on the loading ring"); + const { readHomeWarmSnapshot } = await import("../src/lib/home-warm-snapshot.ts"); + await doc.settle(() => doc.view().title !== null && readHomeWarmSnapshot(USER) !== null, "the cold reveal and its snapshot"); + for (let i = 0; i < 5; i += 1) await doc.h.idle(); + return doc; +} + +const WAITED_PATHS = ["/api/models", "/api/consult/status", "/api/rectification/cases/entry-summary", "/api/daily-starlanguage", "/api/account", "/api/sessions", "/api/chart-profiles"]; + +for (const page of [ + { label: "星盘", path: "/chart" }, + { label: "星历", path: "/ephemeris" }, + { label: "我的报告", path: "/reports" }, + { label: "星盘档案", path: "/people" }, +] as const) { + test(`${page.path} → 新建对话 paints a ready home in its first frame, with every request still unanswered`, async () => { + const tab = createTab(); + const api = installApi(); + let doc: Doc | undefined; + try { + doc = await coldHome(tab, api); + assert.equal(doc.firstCommits.length, 1); + await doc.followLink(page.label); + assert.equal(tab.location.pathname, page.path); + // From here on nothing answers. A home that waited for any of these + // could never leave the loading ring. + for (const path of WAITED_PATHS) api.mode.hang.add(path); + const before = api.requests.length; + const href = await doc.followLink("新建对话"); + assert.equal(href, "/?new=1"); + const first = doc.firstCommits[1]; + assert.ok(first, "home mounted again"); + assert.equal(first.loading, false, "no loading ring on the warm return"); + assert.equal(first.title, "新对话", "the new empty chat is active in the first frame"); + assert.equal(first.requests, before, "the first frame was committed before any request was sent"); + const view = doc.view(); + assert.equal(view.loading, false); + assert.equal(view.title, "新对话"); + assert.equal(view.starter, true, "starter home, not a transcript"); + assert.equal(view.transcript, false); + assert.equal(view.inputDisabled, false); + assert.equal(view.url, "/", "?new=1 is consumed"); + assert.equal(tab.session.getItem("jyotisha.session-url-return"), null); + assert.equal(view.rectificationCard, "再次校正", "the rectification card uses the remembered summary"); + const background = api.requests.slice(before); + assert.ok(background.includes("GET /api/models"), `the catalog refreshes in the background: ${background.join(", ")}`); + assert.ok(background.includes("GET /api/consult/status"), "background consultation recovery still checks"); + for (let i = 0; i < 5; i += 1) await doc.h.idle(); + assert.equal(doc.view().loading, false, "an unanswered background refresh never brings the ring back"); + assert.deepEqual(doc.errors, []); + } finally { + await doc?.close(); + api.restore(); + } + }); +} + +test("/reports → a history row opens that conversation without the loading ring", async () => { + const tab = createTab(); + const api = installApi(); + let doc: Doc | undefined; + try { + doc = await coldHome(tab, api, "/?new=1"); + assert.equal(doc.view().title, "新对话"); + await doc.followLink("我的报告"); + for (const path of WAITED_PATHS) api.mode.hang.add(path); + const before = api.requests.length; + const href = await doc.followLink("事业方向"); + assert.equal(href, `/?c=${CONS}`); + const first = doc.firstCommits[1]; + assert.equal(first.loading, false); + assert.equal(first.title, "事业方向"); + assert.equal(first.requests, before); + assert.equal(doc.view().url, `/?c=${CONS}`, "?c= stays"); + await doc.settle(() => doc!.view().transcript, "the conversation's messages"); + assert.equal(doc.view().loading, false); + assert.deepEqual(doc.errors, []); + } finally { + await doc?.close(); + api.restore(); + } +}); + +test("a full reload of / still plays the loading ring once", async () => { + const tab = createTab(); + const api = installApi(); + let doc: Doc | undefined; + try { + doc = await coldHome(tab, api); + await doc.followLink("星盘"); + await doc.followLink("新建对话"); + assert.equal(doc.firstCommits[1].loading, false); + await doc.close(); + doc = undefined; + // Same tab, new document: module memory is gone, storages survive. + doc = await openDocument(tab, "/", api.requests); + assert.equal(doc.firstCommits[0].loading, true, "reload starts on the ring"); + await doc.settle(() => doc!.view().title !== null, "the cold reveal after reload"); + assert.deepEqual(doc.errors, []); + } finally { + await doc?.close(); + api.restore(); + } +}); + +test("a snapshot missing a piece falls back to the unchanged cold path", async () => { + const tab = createTab(); + const api = installApi(); + let doc: Doc | undefined; + try { + doc = await coldHome(tab, api); + await doc.followLink("星盘"); + const { clearHomeWarmSnapshot } = await import("../src/lib/home-warm-snapshot.ts"); + clearHomeWarmSnapshot(); + api.mode.hang.add("/api/models"); + await doc.followLink("新建对话"); + assert.equal(doc.firstCommits[1].loading, true, "no snapshot → the loading ring, never a half-reveal"); + for (let i = 0; i < 5; i += 1) await doc.h.idle(); + assert.equal(doc.view().loading, true, "the cold path waits for the catalog as before (BUG-1021)"); + assert.equal(doc.view().title, null); + api.mode.hang.delete("/api/models"); + // The cold path's own retry is out of scope; a new document proves it recovers. + await doc.close(); + doc = undefined; + } finally { + await doc?.close(); + api.restore(); + } +}); + +test("background refresh: a model that went offline switches to the default with the existing notice", async () => { + const tab = createTab(); + const api = installApi(); + let doc: Doc | undefined; + try { + doc = await coldHome(tab, api, `/?c=${CONS}`); + assert.equal(doc.view().title, "事业方向"); + await doc.followLink("星盘"); + api.mode.models = catalogFlash; + const { toast } = await import("sonner"); + const toastsBefore = toast.getHistory().length; + await doc.followLink("事业方向"); + assert.equal(doc.firstCommits[1].loading, false); + assert.match(doc.firstCommits[1].title ?? "", /事业方向/); + await doc.settle(() => /Flash/.test(doc!.view().modelLabel ?? ""), "the default model after the catalog refresh"); + const notices = toast.getHistory().slice(toastsBefore).map((item) => String((item as { title?: unknown }).title ?? "")); + assert.ok(notices.includes("此前选择的模型已下线,已切换为默认模型。"), `notice: ${notices.join(" | ")}`); + assert.ok(api.requests.some((line) => line === `PATCH /api/sessions/${CONS}`), "the fallback is synced like the cold path"); + assert.equal(doc.view().loading, false); + assert.deepEqual(doc.errors, []); + } finally { + await doc?.close(); + api.restore(); + } +}); + +test("background refresh: an unfinished consultation resumes on a warm return to its conversation", async () => { + const tab = createTab(); + const api = installApi(); + let doc: Doc | undefined; + try { + doc = await coldHome(tab, api, `/?c=${CONS}`); + await doc.followLink("星盘"); + // Left while an answer was still generating: the tab's pending record + // survives and the server still reports the request as reserved. + tab.session.setItem("jyotisha.pending-consultation", JSON.stringify({ sessionId: CONS, requestId: REQUEST, question: "虚构追问", theme: "career", entrypoint: null })); + api.mode.consultStatus = { sessionId: CONS, requestId: REQUEST, status: "reserved" }; + await doc.followLink("事业方向"); + assert.equal(doc.firstCommits[1].loading, false); + await doc.settle(() => doc!.view().stopVisible, "the recovering answer's stop control"); + assert.equal(doc.view().title, "事业方向"); + assert.ok(tab.session.getItem("jyotisha.pending-consultation"), "the pending record is kept while recovering"); + assert.deepEqual(doc.errors, []); + } finally { + await doc?.close(); + api.restore(); + } +}); + +test("background refresh: a reserved consultation keeps recovering without taking over 新建对话", async () => { + const tab = createTab(); + const api = installApi(); + let doc: Doc | undefined; + try { + doc = await coldHome(tab, api); + await doc.followLink("星盘"); + tab.session.setItem("jyotisha.pending-consultation", JSON.stringify({ sessionId: CONS, requestId: REQUEST, question: "虚构追问", theme: "career", entrypoint: null })); + api.mode.consultStatus = { sessionId: CONS, requestId: REQUEST, status: "reserved" }; + await doc.followLink("新建对话"); + await doc.settle(() => Boolean(tab.session.getItem("jyotisha.pending-consultation")) && api.requests.some((line) => line.startsWith(`GET /api/consult/status?sessionId=${CONS}`)), "the stored request to be checked"); + for (let i = 0; i < 5; i += 1) await doc.h.idle(); + assert.equal(doc.view().title, "新对话", "the explicit new chat stays active"); + assert.equal(doc.view().starter, true); + assert.ok(tab.session.getItem("jyotisha.pending-consultation"), "the older answer is still being recovered"); + assert.deepEqual(doc.errors, []); + } finally { + await doc?.close(); + api.restore(); + } +}); + +test("background refresh: a changed rectification entry summary replaces the card label silently", async () => { + const tab = createTab(); + const api = installApi(); + let doc: Doc | undefined; + try { + doc = await coldHome(tab, api, "/?new=1"); + assert.equal(doc.view().rectificationCard, "再次校正"); + await doc.followLink("星盘"); + api.mode.summary = emptySummary; + await doc.followLink("新建对话"); + assert.equal(doc.firstCommits[1].loading, false); + await doc.settle(() => doc!.view().rectificationCard === "开始新的生时校正", "the refreshed summary"); + assert.equal(doc.view().loading, false); + assert.deepEqual(doc.errors, []); + } finally { + await doc?.close(); + api.restore(); + } +}); + +test("a stashed return to a rectification session reopens it on a warm return, with its URL", async () => { + const tab = createTab(); + const api = installApi(); + let doc: Doc | undefined; + try { + doc = await coldHome(tab, api, `/?c=${RECT}`); + await doc.settle(() => doc!.view().rectificationSurface, "the rectification surface"); + await doc.followLink("星盘"); + assert.equal(tab.session.getItem("jyotisha.session-url-return"), RECT); + // The read-only footer links to bare `/`: the stash decides the landing. + await doc.followLink("林遥,打开账户"); + assert.equal(doc.firstCommits[1].loading, false); + assert.equal(doc.view().url, `/?c=${RECT}`, "the stashed ?c= is written back"); + await doc.settle(() => doc!.view().rectificationSurface, "the stashed rectification session to open (not a locked page, BUG-1038)"); + assert.equal(tab.session.getItem("jyotisha.session-url-return"), null); + assert.deepEqual(doc.errors, []); + } finally { + await doc?.close(); + api.restore(); + } +}); + +test("another account in the same tab never paints the previous account's snapshot", async () => { + const tab = createTab(); + const api = installApi(); + let doc: Doc | undefined; + try { + doc = await coldHome(tab, api); + const snapshot = await import("../src/lib/home-warm-snapshot.ts"); + assert.ok(snapshot.readHomeWarmSnapshot(USER), "the cold reveal wrote a snapshot"); + // The provider reloads (as after a person switch) and now sees another account. + api.mode.accountUser = OTHER_USER; + const subject = await import("../src/lib/current-subject.ts"); + await doc.followLink("星盘"); + await doc.h.update(() => { subject.setCurrentSubject("self"); subject.invalidateAccountFlight(); }); + await doc.h.update(() => { subject.setCurrentSubject("someone-else"); }); + await doc.settle(() => snapshot.readHomeWarmSnapshot(USER) === null, "the other account to clear the snapshot"); + assert.equal(snapshot.readHomeWarmSnapshot(OTHER_USER), null); + assert.deepEqual(doc.errors, []); + } finally { + await doc?.close(); + api.restore(); + } +}); diff --git a/frontend/tests/home-warm-snapshot.test.ts b/frontend/tests/home-warm-snapshot.test.ts new file mode 100644 index 00000000..a668977e --- /dev/null +++ b/frontend/tests/home-warm-snapshot.test.ts @@ -0,0 +1,274 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + clearHomeWarmSnapshot, + isCompleteHomeWarmSnapshot, + readHomeWarmDaily, + readHomeWarmSnapshot, + rememberHomeWarmDaily, + writeHomeWarmSnapshot, + type HomeWarmSnapshot, +} from "../src/lib/home-warm-snapshot.ts"; +import { + applyWarmLanding, + dailyStarlanguageWarmKey, + reconcileSessionModels, + resolveHomeWarmStart, + resolveWarmLanding, + type HomeWarmStartInput, +} from "../src/lib/home-warm-start.ts"; +import type { Account, ChatSession, Profile } from "../src/lib/home-types.ts"; + +// BUG-1040 T1: the warm snapshot store and the warm-start decision, as pure +// functions. Synthetic ids and profiles only. + +const USER = "11111111-1111-4111-8111-111111111111"; +const OTHER_USER = "22222222-2222-4222-8222-222222222222"; +const CONS = "55555555-5555-4555-8555-555555555555"; +const RECT = "33333333-3333-4333-8333-333333333333"; +const PERSON = "66666666-6666-4666-8666-666666666666"; +const catalog = { defaultModelId: "deepseek-pro", models: [{ id: "deepseek-pro", label: "DeepSeek V4 Pro", description: "x", creditCost: 1, isDefault: true }] }; +const card = { trend: "虚构趋势", action: "虚构行动", caution: "虚构提醒" }; +const selfProfile = { + name: "林遥", date: "1990-06-15", time: "12:30", reportedTime: "12:30", birthTimeSource: "legacy_import", + birthTimePeriod: "", declaredWindowStart: "", declaredWindowEnd: "", birthTimeClue: "", + uncertaintyBeforeMinutes: null, uncertaintyAfterMinutes: null, birthTimeStatus: "confirmed", rectificationCaseId: "", + countryCode: "CN", provinceCode: "110000", cityCode: "110000-city", districtCode: "110101", ayanamsa: "lahiri", +}; + +function snapshotFor(accountId: string, patch: Partial = {}): HomeWarmSnapshot { + return { accountId, modelCatalog: catalog, entrySummary: null, entrySummarySettled: true, sessionsCursor: null, listBoot: null, ...patch }; +} + +function session(id: string, patch: Partial = {}): ChatSession { + return { + id, title: "事业方向", theme: "career", modelId: "deepseek-pro", messages: [{ role: "user", text: "虚构问题" }], + createdAt: 1, updatedAt: 1, sessionType: "consultation", rectificationCaseId: null, pinned: false, archivedAt: null, + messagesHydrated: true, chartProfileId: "self", chartProfileName: "林遥", chartProfileRole: "self", ...patch, + }; +} + +function account(id = USER, profile: unknown = selfProfile): Account { + return { + user: { id, email: "fictional@example.test" }, profile, avatar: null, credits: 9, isAdmin: false, adminUrl: null, + rectificationPriceCredits: 1, activeSubscription: null, hasConfirmedBirthTime: true, hasUsableBirthTime: true, + } as unknown as Account; +} + +function input(patch: Partial = {}): HomeWarmStartInput { + const boot = { sessions: [], rawRows: [], cursor: null, account: account(), signedOut: false }; + return { + account: account(), settled: true, signedOut: false, boot, sessions: [session(CONS)], search: "?new=1", + storedReturnId: null, storedPendingRaw: null, subjectId: "self", subjectCatalog: [], previewRequested: false, + now: new Date("2026-09-26T04:00:00Z"), ...patch, + }; +} + +function withStorage(run: () => T): T { + const values = new Map(); + const storage = { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } }; + const previous = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true, writable: true }); + try { return run(); } finally { + if (previous) Object.defineProperty(globalThis, "localStorage", previous); + else Reflect.deleteProperty(globalThis, "localStorage"); + } +} + +test("write → read returns the snapshot for the same account", () => { + clearHomeWarmSnapshot(); + writeHomeWarmSnapshot(snapshotFor(USER)); + assert.equal(readHomeWarmSnapshot(USER)?.accountId, USER); + assert.equal(isCompleteHomeWarmSnapshot(readHomeWarmSnapshot(USER)), true); +}); + +test("another account clears the snapshot instead of returning it", () => { + clearHomeWarmSnapshot(); + writeHomeWarmSnapshot(snapshotFor(USER)); + rememberHomeWarmDaily({ accountId: USER, subjectId: "self", fingerprint: "f", day: "2026-09-26", card }); + assert.equal(readHomeWarmSnapshot(OTHER_USER), null); + assert.equal(readHomeWarmSnapshot(USER), null, "the previous account's snapshot is gone, not just hidden"); + assert.equal(readHomeWarmDaily({ accountId: USER, subjectId: "self", fingerprint: "f", day: "2026-09-26" }), null); + writeHomeWarmSnapshot(snapshotFor(USER)); + writeHomeWarmSnapshot(snapshotFor(OTHER_USER)); + assert.equal(readHomeWarmSnapshot(USER), null); +}); + +test("a 401 login redirect clears the snapshot", async () => { + clearHomeWarmSnapshot(); + writeHomeWarmSnapshot(snapshotFor(USER)); + const { redirectToLogin, LoginRedirectError } = await import("../src/lib/home-cloud-sync.ts"); + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); + const previousSession = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"); + const replaced: string[] = []; + Object.defineProperty(globalThis, "window", { value: { location: { search: "", replace: (href: string) => replaced.push(href) } }, configurable: true, writable: true }); + Object.defineProperty(globalThis, "sessionStorage", { value: { setItem() {} }, configurable: true, writable: true }); + try { + assert.throws(() => redirectToLogin(), LoginRedirectError); + } finally { + if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); + else Reflect.deleteProperty(globalThis, "window"); + if (previousSession) Object.defineProperty(globalThis, "sessionStorage", previousSession); + else Reflect.deleteProperty(globalThis, "sessionStorage"); + } + assert.deepEqual(replaced, ["/login"]); + assert.equal(readHomeWarmSnapshot(USER), null); +}); + +test("sign-out and the provider's signed-out and account-change branches clear the snapshot", () => { + const onboarding = readFileSync(new URL("../src/hooks/use-profile-onboarding.ts", import.meta.url), "utf8"); + assert.match(onboarding, /clearHomeWarmSnapshot\(\);\n\s+await selfHostedOtpActions\.signOut\(\);\n\s+window\.location\.assign\("\/login"\);/); + const provider = readFileSync(new URL("../src/lib/session-list-context.tsx", import.meta.url), "utf8"); + assert.match(provider, /accountResult\.status === 401\) \{\n\s+clearHomeWarmSnapshot\(\);/); + assert.match(provider, /sessionResponse\.status === 401\) \{\n\s+clearHomeWarmSnapshot\(\);/); + assert.match(provider, /readCurrentSubjectAccountId\(\) !== accountPayload\.user\.id\) \{\n(?:\s+\/\/.*\n)*\s+clearHomeWarmSnapshot\(\);/); +}); + +test("the snapshot lives in memory only", () => { + const source = readFileSync(new URL("../src/lib/home-warm-snapshot.ts", import.meta.url), "utf8"); + const code = source.replace(/\/\*\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); + assert.doesNotMatch(code, /localStorage|sessionStorage|indexedDB|document\.cookie/); +}); + +test("an incomplete snapshot is not a warm start", () => { + assert.equal(isCompleteHomeWarmSnapshot(null), false); + assert.equal(isCompleteHomeWarmSnapshot(snapshotFor(USER, { entrySummarySettled: false })), false); + assert.equal(isCompleteHomeWarmSnapshot(snapshotFor(USER, { modelCatalog: { defaultModelId: "", models: [] } })), false); + assert.equal(isCompleteHomeWarmSnapshot(snapshotFor("")), false); +}); + +test("today's card is keyed by person, fingerprint and day: another day or person refetches", () => { + clearHomeWarmSnapshot(); + const profile = selfProfile as unknown as Profile; + const today = dailyStarlanguageWarmKey("self", profile, new Date("2026-09-26T04:00:00Z")); + assert.ok(today); + rememberHomeWarmDaily({ accountId: USER, subjectId: "self", ...today, card }); + assert.deepEqual(readHomeWarmDaily({ accountId: USER, subjectId: "self", ...today }), card); + const tomorrow = dailyStarlanguageWarmKey("self", profile, new Date("2026-09-27T04:00:00Z")); + assert.ok(tomorrow); + assert.notEqual(tomorrow.day, today.day); + assert.equal(readHomeWarmDaily({ accountId: USER, subjectId: "self", ...tomorrow }), null, "a new day refetches"); + const other = dailyStarlanguageWarmKey(PERSON, profile, new Date("2026-09-26T04:00:00Z")); + assert.ok(other); + assert.equal(readHomeWarmDaily({ accountId: USER, subjectId: PERSON, ...other }), null, "another person refetches"); + assert.equal(readHomeWarmDaily({ accountId: OTHER_USER, subjectId: "self", ...today }), null); + assert.equal(dailyStarlanguageWarmKey("self", { ...profile, time: "", reportedTime: "", birthTimeStatus: "reported", birthTimeSource: "unknown" }, new Date()), null, "no birth minute → no card key"); +}); + +test("warm start: complete snapshot + settled list → ready with a new empty chat for ?new=1", () => { + clearHomeWarmSnapshot(); + writeHomeWarmSnapshot(snapshotFor(USER, { entrySummary: null, sessionsCursor: "cursor-a" })); + const start = withStorage(() => resolveHomeWarmStart(input())); + assert.ok(start, "warm start"); + assert.equal(start.modelCatalog, catalog); + assert.equal(start.profile.name, "林遥"); + assert.equal(start.onboardingStep, "name"); + assert.ok(start.startGreeting.length > 0); + assert.equal(start.landing.selection.urlAction, "new-chat"); + assert.ok(start.landing.pendingSession, "a local empty consultation"); + assert.equal(start.landing.activeSessionId, start.landing.pendingSession.id); + assert.equal(start.landing.pendingSession.messages.length, 0); + assert.equal(start.landing.pendingSession.modelId, "deepseek-pro"); + assert.equal(start.daily.kind, "pending", "no remembered card → static sentence, not a wait"); + assert.equal(start.sessionsCursor, null, "the provider reloaded since the snapshot: its cursor wins"); + const boot = { sessions: [], rawRows: [], cursor: "cursor-boot", account: account(), signedOut: false }; + writeHomeWarmSnapshot(snapshotFor(USER, { sessionsCursor: "cursor-after-load-more", listBoot: boot })); + const same = withStorage(() => resolveHomeWarmStart(input({ boot }))); + assert.equal(same?.sessionsCursor, "cursor-after-load-more", "same provider boot: the cursor Home last held"); +}); + +test("warm start is refused whenever a piece is missing", () => { + clearHomeWarmSnapshot(); + withStorage(() => { + assert.equal(resolveHomeWarmStart(input()), null, "no snapshot"); + writeHomeWarmSnapshot(snapshotFor(USER)); + assert.ok(resolveHomeWarmStart(input())); + assert.equal(resolveHomeWarmStart(input({ settled: false })), null, "list not settled"); + assert.equal(resolveHomeWarmStart(input({ signedOut: true })), null, "signed out"); + assert.equal(resolveHomeWarmStart(input({ account: null })), null, "no account"); + assert.equal(resolveHomeWarmStart(input({ boot: { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false, error: "session_list_unavailable" } })), null, "list error"); + assert.equal(resolveHomeWarmStart(input({ previewRequested: true })), null, "development preview"); + assert.equal(resolveHomeWarmStart(input({ account: account(USER, { name: "林遥" }) })), null, "onboarding profile"); + assert.equal(resolveHomeWarmStart(input({ search: "?c=99999999-9999-4999-8999-999999999999" })), null, "a ?c= not in memory needs the cold lookup"); + writeHomeWarmSnapshot(snapshotFor(USER, { entrySummarySettled: false })); + assert.equal(resolveHomeWarmStart(input()), null, "summary never settled"); + writeHomeWarmSnapshot(snapshotFor(USER)); + assert.equal(resolveHomeWarmStart(input({ account: account(OTHER_USER) })), null, "another account"); + }); +}); + +test("warm landing follows the cold rules: ?c= keeps, stash writes back, other person's stash is dropped", () => { + const scope = (subject: string) => (item: ChatSession) => (subject === "self" ? item.chartProfileId === "self" : item.chartProfileId === subject); + const create = () => session("new-local", { messages: [], title: "新对话" }); + const rect = session(RECT, { sessionType: "birth_time_rectification", messages: [] }); + const other = session(PERSON, { chartProfileId: PERSON, chartProfileRole: "other" }); + const list = [rect, session(CONS), other]; + + const keep = resolveWarmLanding({ sessions: list, search: `?c=${CONS}`, storedReturnId: null, inSubjectScope: scope("self"), createHomeSession: create }); + assert.equal(keep?.activeSessionId, CONS); + assert.equal(keep?.selection.urlAction, "keep"); + assert.equal(keep?.pendingSession, null); + + const stash = resolveWarmLanding({ sessions: list, search: "", storedReturnId: RECT, inSubjectScope: scope("self"), createHomeSession: create }); + assert.equal(stash?.activeSessionId, RECT); + assert.equal(stash?.selection.urlAction, "replace-selected"); + assert.equal(stash?.selection.clearStoredReturn, true); + + const foreign = resolveWarmLanding({ sessions: list, search: "", storedReturnId: PERSON, inSubjectScope: scope("self"), createHomeSession: create }); + assert.notEqual(foreign?.activeSessionId, PERSON, "another person's stash is not a place to return to (BUG-1038)"); + assert.equal(foreign?.selection.urlAction, "none"); + assert.equal(foreign?.selection.clearStoredReturn, true); + + const bare = resolveWarmLanding({ sessions: list, search: "", storedReturnId: null, inSubjectScope: scope("self"), createHomeSession: create }); + assert.equal(bare?.activeSessionId, CONS, "bare / on a rectification head lands on a consultation, as cold"); + + const newChat = resolveWarmLanding({ sessions: list, search: "?new=1", storedReturnId: RECT, inSubjectScope: scope("self"), createHomeSession: create }); + assert.equal(newChat?.activeSessionId, "new-local"); + assert.equal(newChat?.selection.clearStoredReturn, true, "the stash is ignored and cleared by a new chat"); + + const emptyPerson = resolveWarmLanding({ sessions: list, search: "?new=1", storedReturnId: null, inSubjectScope: scope("nobody"), createHomeSession: create }); + assert.equal(emptyPerson?.activeSessionId, "new-local", "a person with no sessions gets exactly one local chat"); + + assert.equal(resolveWarmLanding({ sessions: list, search: "", storedReturnId: "99999999-9999-4999-8999-999999999999", inSubjectScope: scope("self"), createHomeSession: create }), null, "a stash not in memory needs the cold lookup"); +}); + +test("applying the landing replaces older unsaved empty chats; model reconcile mirrors readSessions", () => { + const oldEmpty = session("old-empty", { messages: [] }); + const pending = session("new-local", { messages: [] }); + const landing = { activeSessionId: pending.id, pendingSession: pending, selection: { sessionId: pending.id, urlAction: "new-chat" as const, missing: false, clearStoredReturn: true } }; + const next = applyWarmLanding([oldEmpty, session(CONS)], landing); + assert.deepEqual(next.map((item) => item.id), ["new-local", CONS]); + const same = [pending, session(CONS)]; + assert.equal(applyWarmLanding(same, landing), same, "already applied → unchanged"); + + const unchanged = [session(CONS)]; + const kept = reconcileSessionModels(unchanged, catalog); + assert.equal(kept.sessions, unchanged, "no fallback keeps identity (no re-render)"); + const offline = reconcileSessionModels([session(CONS, { modelId: "retired-model" })], catalog); + assert.deepEqual(offline.fallbackSessionIds, [CONS]); + assert.equal(offline.sessions[0].modelId, "deepseek-pro"); +}); + +test("the query of the / being opened: location when already there, else the noted AppLink href, else unknown", async () => { + const { homeSearchForMount } = await import("../src/lib/home-warm-start.ts"); + const { noteClientNavigationTarget, takeClientNavigationTarget } = await import("../src/lib/client-navigation-target.ts"); + const origin = "http://localhost"; + // Popstate / document load: the address bar already shows `/`. + assert.equal(homeSearchForMount({ pathname: "/", search: "?c=abc", origin }, "/chart"), "?c=abc"); + // Client navigation from /chart: Next writes the URL after rendering. + assert.equal(homeSearchForMount({ pathname: "/chart", search: "", origin }, "/?new=1"), "?new=1"); + assert.equal(homeSearchForMount({ pathname: "/reports", search: "", origin }, `/?c=${CONS}`), `?c=${CONS}`); + assert.equal(homeSearchForMount({ pathname: "/chart", search: "", origin }, "/"), ""); + assert.equal(homeSearchForMount({ pathname: "/chart", search: "", origin }, null), null, "nothing noted → cold path"); + assert.equal(homeSearchForMount({ pathname: "/chart", search: "", origin }, "/ephemeris"), null); + + noteClientNavigationTarget("/?new=1"); + assert.equal(takeClientNavigationTarget(), "/?new=1"); + assert.equal(takeClientNavigationTarget(), null, "taken once"); + noteClientNavigationTarget("/?new=1"); + assert.equal(takeClientNavigationTarget(Date.now() + 60_000), null, "a stale note is not trusted"); + noteClientNavigationTarget("https://example.test/"); + assert.equal(takeClientNavigationTarget(), null, "only same-origin paths"); +}); diff --git a/frontend/tests/new-chat-from-people-lifecycle.test.tsx b/frontend/tests/new-chat-from-people-lifecycle.test.tsx index f7d2862d..dc851c96 100644 --- a/frontend/tests/new-chat-from-people-lifecycle.test.tsx +++ b/frontend/tests/new-chat-from-people-lifecycle.test.tsx @@ -193,6 +193,10 @@ async function openDocument(tab: Tab, href: string, options: { mobile?: boolean // (it decides which list the provider reads); it re-reads localStorage. const subject = await import("../src/lib/current-subject.ts"); subject.resetSubjectCatalogForTests(); + // BUG-1040: the home warm snapshot is module memory too; a document load + // starts without it. (Setup only; no assertion in this file changes.) + (await import("../src/lib/home-warm-snapshot.ts")).clearHomeWarmSnapshot(); + (await import("../src/lib/home-warm-start.ts")).resetHomeWarmStartForTests(); const { SessionListProvider, useSessionList, useShellRegistration, sessionListSidebarModel } = await import("../src/lib/session-list-context.tsx"); const { SidebarInset, SidebarProvider } = await import("../src/components/ui/sidebar.tsx");