diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a9ca5c..e349f0ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ 回答里的清单重新画出圆点和序号,不再只是往右缩进的孤行。口语段落里的冒号不会再被改成列表。思考条和正文之间的空隙收成一档。Skill 版本不变。 +## 2026-09-18 — 次级页进入不再推挤;跨部署旧标签页下一次跳转会整页重载 + +从侧栏进星盘、星历、我的报告时,标题和等待句不再把整页从矮块推成高块。第二次进入会直接显示上次的内容。如果这个标签页开着的时候网站刚更新过,下一次点这些入口会整页打开新版本,而不是点了没反应。Skill 版本不变。 + ## 2026-09-18 — 申报时段咨询重新能回答 只知道一段出生范围时,问事业 / 运势会在几十毫秒内失败,提示「未完成必要的方法与计算步骤」,从八月下旬起一直如此。根因是这条路线的助手装了方法校验、却没把方法写进系统提示,模型一次都没被叫到。现在方法已经装上;没有跑完计算但模型写过字时,会把那段留下并注明「没跑完星盘计算」,不再整轮空白。Skill 版本不变。 diff --git a/deploy/railway-web.Dockerfile b/deploy/railway-web.Dockerfile index 3b8d9614..e134d2e2 100644 --- a/deploy/railway-web.Dockerfile +++ b/deploy/railway-web.Dockerfile @@ -15,6 +15,7 @@ ARG NEXT_DEPLOYMENT_ID ARG NEXT_PUBLIC_SUPABASE_URL ARG NEXT_PUBLIC_SUPABASE_ANON_KEY ENV NEXT_DEPLOYMENT_ID=${NEXT_DEPLOYMENT_ID} \ + NEXT_PUBLIC_GIT_COMMIT=${NEXT_DEPLOYMENT_ID} \ NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} \ NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY} COPY SKILL.md /app/SKILL.md diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 164bf380..afc1c85f 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -12533,7 +12533,7 @@ ## BUG-965 | 侧栏「星盘 / 星历 / 我的报告」点了不跳转,账户入口也没反应 -- 状态:investigating(触发条件已由产品复现确认,修复未做) +- 状态:resolved(由 BUG-967 修复) - 首次发现:2026-09-18 - 最近更新:2026-09-18 - 影响面:`AppSidebar` 的 `NAV_PAGES` 链接与页脚账户入口;staging `877128ce` @@ -12545,12 +12545,48 @@ 3. 链接是真链接:三项导航是 `SidebarMenuLink` → `next/link` 的 ``,`onClick={leaveChat}` 只做 `persistLoginSessionReturn()` 与关抽屉,**没有 `preventDefault`**。 4. 没有覆盖桌面侧栏的 `pointer-events: none` 或 `inert`:`inert` 只出现在移动端抽屉、时间轴折叠体、`SidebarInset`(`insetInert = modalOpen`,只盖正文区不盖侧栏)。 5. 次级页页脚是 `Link href="/"`(账户菜单只在 `/`),因此「在次级页点头像」应当跳回首页而不是没反应。 -- 待取证据:控制台报错原文与 Network 面板是否有 404 的 `/_next/static/**`;点击时所在页面的 URL;该标签页是否在 17:12 部署之前就已打开;硬刷新(Cmd+Shift+R)后是否恢复。 -- 根因(按复现结论):标签页跨过部署后,客户端导航要拉的构建产物已被新镜像替换(构建 id 变了,旧 chunk / RSC 负载不再存在),路由切换静默失败;已经加载好的对话页因为代码都在内存里,照常工作。Next 16 自带的构建不匹配回退在这次没有把用户带到整页重载,具体是被哪一层吞掉(404 形状、Caddy、还是 RSC 负载不匹配的判定)需要在修复单里查证。 -- 修复方向:见 `docs/tasks/TASK-secondary-page-entry-20260918.md` BUG-967——客户端用编译期注入的 `NEXT_PUBLIC_GIT_COMMIT` 与 `/api/health` 的 `deployment.gitCommit` 比对,发现自己是旧版本就在下一次导航时整页重载,而不是静默失败。 -- 关联记录:BUG-744~746(侧栏统一为 `Link` 跳转)、BUG-926~929(会话列表单一数据源与外壳搬家)、BUG-936(首屏 bundle 未执行) -- 复发自:待确认 -- 修复版本:待发布 +- 根因:见 BUG-967。标签页跨过部署后,客户端导航要拉的构建产物已被新镜像替换;Next 16.3.1 自带的构建不匹配回退在自托管 + 预取缓存这条路上没有把用户带到整页重载,点击表现为静默无反应。已经加载好的对话页因为代码都在内存里,照常工作。 +- 修复:BUG-967 的版本比对自愈。旧标签页在下一次站内跳转走 `window.location.assign`。 +- 验证:见 BUG-967。 +- 防复发:见 BUG-967。不得再把「刷新就好」当成侧栏无响应的结案。 +- 关联记录:BUG-967、BUG-204、BUG-744~746、BUG-926~929、BUG-936 +- 复发自:BUG-204 同类(跨发布客户端导航),那次修的是 `deploymentId` / `?dpl=`,在 Vercel 上靠部署路由 404 触发 MPA;本仓是自托管单镜像,那条路径不成立。 +- 修复版本:本轮提交 + +## BUG-966 | 星盘 / 星历 / 我的报告进入时矮等待块推挤成高正文 + +- 状态:resolved +- 首次发现:2026-09-18 +- 最近更新:2026-09-18 +- 影响面:`chart-page-view.tsx`、`ephemeris-page.tsx`、`personal-report-center.tsx`、`use-chart-page.ts`、`SecondaryHeader` +- 用户现象:从侧栏进「星盘 / 星历 / 我的报告」有一瞬间抖动。 +- 触发条件:进入这三页,尤其是同一会话里每次进入(没有首屏缓存)。 +- 根因:三页各写各的等待布局——先渲染 46px 标题加一行短等待文案,数据到达后整块换成高正文。`SecondaryHeader` 的 `note` 未到时不渲染,标题区二次填充。`use-chart-page` 进页才 fetch,零缓存,每次都重放中间态。产品 2026-09-18 选定方案一:消灭中间态,不加 spinner,红线不动。 +- 修复:三页共用 `SecondaryPageShell`(标题行高度固定、note 槽占位不塌、内容区撑满剩余视口,等待文案居中在该区)。星盘 / 星历 / 报告首屏进模块级缓存,同一会话再进直接用上次结果并后台静默刷新。侧栏三项在 `pointerenter` / `pointerdown` 预热数据。等待句仍是静态句(「这一张盘还没拿到。」等),没有 spinner / 骨架 / 「正在加载」。 +- 验证:`frontend/tests/secondary-page-entry.test.ts`(三页 import 共享外壳、暖缓存第二次不经 null 等待、预取命中同一 inflight、CSS 内容区 min-height:0 且等待居中);既有 `chart-page-view` / `ephemeris-page` / sidebar 合同。浏览器量高度见 `docs/testing/secondary-page-entry-20260918.md`(无 Chrome)。 +- 防复发:次级页进入不得再各写一套等待布局。揭幕后不得加 spinner / 骨架 / 「正在加载」。缓存必须模块级,组件 unmount 不能丢掉。 +- 关联记录:BUG-716、BUG-717、BUG-745 +- 复发自:无 +- 修复版本:本轮提交 + +## BUG-967 | 跨部署旧标签页的客户端导航静默失效 + +- 状态:resolved +- 首次发现:2026-09-18 +- 最近更新:2026-09-18 +- 影响面:`AppLink`、`app-navigation.ts`、`StaleBuildGuard`、侧栏 `NAV_PAGES` 与次级页账户入口、Web 镜像 `NEXT_PUBLIC_GIT_COMMIT` +- 用户现象:见 BUG-965。 +- 触发条件:标签页在一次部署之前打开,部署后不刷新,点站内次级页或账户入口。 +- 根因(查证 Next 16.3.1 源码,不得跳过): + 1. 不匹配回退写在 `fetchServerResponse`:RSC 不是 `text/x-component`、或非 2xx、或 `x-nextjs-deployment-id` / `flightResponse.b` ≠ `getNavigationBuildId()` 时才 `doMpaNavigation`。`` 进页时已经把 `/chart` `/ephemeris` `/reports` 的 Flight 预取进内存;点击走缓存、不再 fetch,这条检查根本不跑。 + 2. 自托管只有当前镜像。Caddy 对 `/_next/static` 没有改写,只是 `reverse_proxy web:3000`。`?dpl=` 是 Vercel 按部署分流的参数,在本机被忽略。目标路由在新镜像上仍 200 返回新的 Flight,`!res.ok` 也不成立。 + 3. `handleHardNavError` / `useNavFailureHandler` 编译期开关 `__NEXT_APP_NAV_FAIL_HANDLING`,standalone 默认关。旧 chunk 的 `ChunkLoadError` 若被 router reducer 吃掉,既有 `StaleClientRecovery`(只听 `window.error` / `unhandledrejection`)也看不见。客户端导航于是表现为点击无反应;挂起的 `useTransition` 还能让同一页的账户入口一起没反应。 +- 修复:编译期 `NEXT_PUBLIC_GIT_COMMIT`(Docker 构建时与 `NEXT_DEPLOYMENT_ID` 同值写入)对照 `/api/health` `.deployment.gitCommit`。页面重新可见时拉一次 health,导航前读这份快照。不一致则 `window.location.assign`,一致则继续客户端路由。health 失败不强制刷新。无轮询、无定时重载。 +- 验证:合同测试——版本不一致走 `assign`,一致走传入的 `router.push` / 不 preventDefault;health 失败或 commit 为 `unknown` 不强制跳。真机跨部署见 `docs/testing/secondary-page-entry-20260918.md`。 +- 防复发:站内跨布局跳转走 `AppLink` / `navigateAppPath`,不得假定 Next 的 mismatch fallback 在自托管上会救场。不得用「每次导航都整页重载」或定时刷新糊过去。 +- 关联记录:BUG-965、BUG-204 +- 复发自:BUG-204 +- 修复版本:本轮提交 ## BUG-955 | 输入处理器 abort 与「合同未完成」同码,窗口装配失败藏了四周 diff --git a/docs/tasks/PROGRESS-secondary-page-entry-20260918.md b/docs/tasks/PROGRESS-secondary-page-entry-20260918.md new file mode 100644 index 00000000..b89378dc --- /dev/null +++ b/docs/tasks/PROGRESS-secondary-page-entry-20260918.md @@ -0,0 +1,65 @@ +# PROGRESS · 次级页进入时的等待态与抖动(2026-09-18) + +分支:`codex/secondary-page-entry-20260918` +基线:`origin/staging` @ `1061514f`(任务书写的 `41902067` 是写单时的 head;本 worktree 按开工指令跟当前 `origin/staging`) +方案:产品 2026-09-18 选定方案一。方案 B 未做。 + +## BUG-967 调查:Next 16.3.1 的 mismatch fallback 为什么没开火 + +对照 `next@16.3.1` 源码(GitHub tag `v16.3.1`),不是猜的。 + +### Next 自己会在什么时候整页跳 + +`packages/next/src/client/components/router-reducer/fetch-server-response.ts`: + +1. RSC 响应 `Content-Type` 不是 `text/x-component`(export 模式下还接受 `text/plain`),或 `!res.ok`,或没有 body → `doMpaNavigation`。 +2. 解出 Flight 之后:`(res.headers.get(NEXT_NAV_DEPLOYMENT_ID_HEADER) ?? flightResponse.b) !== getNavigationBuildId()` → 同样 MPA。 +3. `app-router.tsx` 看到 `pushRef.mpaNavigation` 才 `location.assign` / `replace`,并 `throw unresolvedThenable` 卡住当前树。 + +另有 `nav-failure-handler.ts` 的 `handleHardNavError`:导航过程中未捕获错误则 `window.location.href = window.next.__pendingUrl`。**整份文件包在 `process.env.__NEXT_APP_NAV_FAIL_HANDLING` 里**,standalone 默认不打开。 + +既有 `StaleClientRecovery` 只在 `window.error` / `unhandledrejection` 文本像 chunk 失败、且 `sessionStorage` 还没记过一次时 `reload()`。router reducer 如果把 rejection 吃掉,它看不见。 + +### 为什么这次三条都不触发 + +1. **预取缓存让检查根本不跑。** 侧栏三项是 ``。标签页在部署前已经打开对话页时,Next 已经把 `/chart` `/ephemeris` `/reports` 的 Flight 预取进内存。点击走这段缓存,不再 `fetchServerResponse`。构建 id 比对、非 2xx、非 Flight Content-Type,全部不执行。 +2. **自托管没有 Vercel 那种 `?dpl=` 分流。** `next.config.ts` 写了 `deploymentId: process.env.NEXT_DEPLOYMENT_ID`,静态资源带 `?dpl=`。在 Vercel 上这个 query 把请求打到对应部署,旧部署不在了就是 404,于是走条件 1。本仓 Caddy(`deploy/Caddyfile.staging` / `Caddyfile.production.selfhosted`)对 `/_next/static` **没有任何特殊 404 改写**,只 `reverse_proxy web:3000`。主机上只有当前镜像。旧 tab 就算真的去 fetch `/chart`,新服务器仍对这条路径返回 **200 + 新的 Flight**。条件 1 的 `!res.ok` 不成立。BUG-204 当年靠 `deploymentId` 修好的是 Vercel 形状,不是这台 VPS。 +3. **错误回退开关是关的。** 旧 chunk 404 变成 `ChunkLoadError` 时,默认 standalone 不会把它升级成 MPA。点击看起来像没反应。`app-router.tsx` 若已经丢出 `unresolvedThenable` 或 `startTransition` 一直 pending,同一页上的账户按钮(本就不是 Link)也会一起没反应——这与「对话还能发」不矛盾:对话代码在内存里,路由切换被卡住。 + +Caddy 不是元凶:它没有把 404 变成 200 HTML。问题是 **200 的新 Flight** 加上 **根本不再 fetch 的预取缓存**。 + +### 自愈怎么做(才没有废掉正常客户端导航) + +- 客户端 commit:构建期 `NEXT_PUBLIC_GIT_COMMIT`(Dockerfile 里与 `NEXT_DEPLOYMENT_ID` 同值写入,不改 workflow)。 +- 服务端 commit:已有的 `GET /api/health` → `.deployment.gitCommit`(运行时 `GITHUB_SHA`)。 +- `StaleBuildGuard` 在挂载和 `visibilitychange → visible` 时拉一次 health。不轮询、不定时 reload。 +- 导航前读这份快照。不一致:`window.location.assign`。一致:原样走 `Link` / `router.push`。health 失败或任一侧是空/`unknown`:不强制跳。 + +## BUG-966 + +三页改为 `SecondaryPageShell`。`.secondary-page` 是 chat-panel 第二行(`minmax(0,1fr)`),等待与正文同一格子;等待句居中。`SecondaryHeader reserveNote` 让出生行晚到时标题行不塌。模块级缓存 + 侧栏 `pointerenter`/`pointerdown` 预取。报告列表首屏去掉 `InlineSpinner`「正在读取报告…」,改静态句「报告列表还没拿到。」刷新按钮上的 spinner 仍是用户点的动作,DESIGN 允许。 + +未做方案 B(延迟揭幕)。 + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| `npx tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error(既有 120 warning,本轮文件未新增) | +| 定向:`secondary-page-entry` / `chart-page-view` / `ephemeris-page` / `sidebar-contract` / `stale-client-recovery` / `personal-report-entry` / `chat-navigation-a11y-contract` / `class-name-definition-contract` | 114 / 114 | +| `personal-report-view.test.ts` | 通过(详情页仍用 `SecondaryHeader`) | +| `next build` | compile + tsc 过;收集 `/api/daily-starlanguage` 时 Windows `EPERM` 无法 symlink skill runtime。环境缺口,与本轮无关。`/` Static 与 gzip 未在本机量到。 | + +无 Chrome:高度真机清单在 `docs/testing/secondary-page-entry-20260918.md`。无登录态:缓存第二次进入与跨部署自愈的浏览器步骤同样写在那里。 + +改过的既有断言: + +| 测试 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `sidebar-contract` 只读行 / 页脚 | ``、`` | `AppLink`,class 与 href 不变 | BUG-967 跨部署自愈;只读行仍是链接、页脚仍去 `/` | + +## 偏离 + +- 任务书写 `app/(secondary)/layout.tsx`,当前树是 `app/(app)/layout.tsx`(会话列表单源之后)。外壳挂在这里,没有把路由组改回去。 +- 报告详情 `/reports/[reportId]` 仍用 `SecondaryHeader` 各阶段一份,不进三页进入抖动的范围;loading.tsx 里的 spinner 本轮不动。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 5c92a715..fe86127e 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -142,7 +142,7 @@ | `TASK-contract-degraded-pass4-20260918.md` | `PROGRESS-contract-degraded-pass4-20260918.md` | 验收 review:BUG-956 新增的降级交付路径绕过 Pass 4,保证句原样送达(BUG-959);`uncontractedText` 跨 attempt 不清零,同一轮正文说两遍(960);降级后还空跑一轮 compose(961)。基线 `877128ce` | 待验收 | `e07d5190` | | `TASK-window-consult-contract-20260918.md` §5 | `PROGRESS-window-consult-precompute-20260918.md` | BUG-957:申报时段计算改为服务端预跑并走同请求缓存。产品确认 BUG-954 已在 staging 验证,本轮单独做。不得 `toolChoice: "required"`,本命不预跑 | 待验收 | `efd40d43` | | `TASK-chat-markdown-list-20260918.md` | `PROGRESS-chat-markdown-list-20260918.md` | 真机排版反馈:聊天正文列表**没有项目符号**(Tailwind v4 preflight 清了 `list-style`,`.markdown-list` 没恢复,BUG-962);`promoteDefinitionLists` 把四标题口语体的散文误判成并列项,三段正文被改写成列表(BUG-963,判据太松,收紧而非删除——BUG-356 的场景要留);思考条与正文之间实测 56px=报告 grid gap 24 + 首标题 margin-top 32,后者是被特指度压掉的「首元素清零」规则(BUG-964)。基线 `ea0280c1` | 待验收 | `8b102d52` | -| `TASK-secondary-page-entry-20260918.md` | — | 真机反馈:星盘 / 星历 / 报告进入时抖一下——三页都是「矮的等待文案块 → 高的正文块」一次推挤,且 `use-chart-page` 无缓存所以每次进入都抖(BUG-966)。产品已拍板方案一:统一外壳 + 缓存 + 预取消灭中间态,**不加 spinner**,红线不动。另含 BUG-967:标签页跨过部署后客户端导航静默失效(BUG-965 已复现确认,刷新即恢复),要按 `NEXT_PUBLIC_GIT_COMMIT` 与 `/api/health` 比对自愈。基线 `41902067` | 待领取 | — | +| `TASK-secondary-page-entry-20260918.md` | `PROGRESS-secondary-page-entry-20260918.md` | 真机反馈:星盘 / 星历 / 报告进入时抖一下——三页都是「矮的等待文案块 → 高的正文块」一次推挤,且 `use-chart-page` 无缓存所以每次进入都抖(BUG-966)。产品已拍板方案一:统一外壳 + 缓存 + 预取消灭中间态,**不加 spinner**,红线不动。另含 BUG-967:标签页跨过部署后客户端导航静默失效(BUG-965 已复现确认,刷新即恢复),要按 `NEXT_PUBLIC_GIT_COMMIT` 与 `/api/health` 比对自愈。基线 `41902067` | 待验收 | `4c69a3dc` | | `TASK-account-dialog-inert-20260918.md` | — | 真机:账户弹窗打开后整个弹窗点不动、退出登录做不了,刷新依旧——`e4e73f56` 把 `SidebarInset` 搬进 layout 后 `inert={modalOpen}` 罩住了没有 portal 的 `AccountDialogOverlay`(BUG-968,P0,代码级确认)。另含 BUG-969:校正「换一件事问」后无下文,服务端已出下一题且快照重算完整,客户端停在开场状态,GET 路由与客户端两端静默,本单只做可观测 + 不静默 + 题干进正文。基线 `1061514f` | 待领取 | — | | `TASK-first-paint-dead-screen-fallback-20260917.md` | — | 真机:首页永远停在「正在载入账户」,兜底全在没跑起来的 bundle 里(BUG-936 investigating)。根 layout 加与 bundle 无关的内联兜底 + 去掉本仓正则后行断言 | 待领取 | — | | `TASK-consultation-answer-start-anchor-20260917.md` | `PROGRESS-consultation-answer-start-anchor-20260917.md` | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 已验收(经修复单) | `worktree/green-harbor-5be3` | diff --git a/docs/testing/secondary-page-entry-20260918.md b/docs/testing/secondary-page-entry-20260918.md new file mode 100644 index 00000000..924e0f5f --- /dev/null +++ b/docs/testing/secondary-page-entry-20260918.md @@ -0,0 +1,75 @@ +# 真机清单 · 次级页进入不再推挤 + 跨部署导航自愈(2026-09-18) + +对应 `TASK-secondary-page-entry-20260918.md`、`PROGRESS-secondary-page-entry-20260918.md`、BUG-965 / 966 / 967。 + +自动化做不到的部分:需要**浏览器 + 已登录账号**。执行环境无 Chrome、无登录态,因此这些是环境缺口,不是「已通过」。 + +准备:桌面 Chrome,宽度 ≥ 1024px,已登录。DevTools → Network 勾上 `Preserve log`。 + +## A · 进入三页时外壳高度不变(BUG-966) + +1. 停在 `/`。Console 里先贴: + + ```js + (() => { + const measure = () => { + const node = document.querySelector(".secondary-page"); + if (!node) return null; + const r = node.getBoundingClientRect(); + return { h: Math.round(r.height), top: Math.round(r.top) }; + }; + const log = []; + const obs = new ResizeObserver(() => log.push({ t: performance.now(), ...measure() })); + const start = () => { + const node = document.querySelector(".secondary-page"); + if (!node) { requestAnimationFrame(start); return; } + obs.observe(node); + log.push({ t: performance.now(), ...measure(), event: "first" }); + }; + window.__secondaryPageLog = log; + window.__secondaryPageStop = () => { obs.disconnect(); console.table(log); }; + document.addEventListener("click", (event) => { + const a = event.target.closest("a[href='/chart'], a[href='/ephemeris'], a[href='/reports']"); + if (a) requestAnimationFrame(start); + }, true); + console.log("ready: click 星盘 / 星历 / 我的报告, then __secondaryPageStop()"); + })(); + ``` + +2. 点侧栏「星盘」。内容出现后立刻在 Console 执行 `__secondaryPageStop()`。 + - **期望**:表格里所有 `h` 相同(等待态与内容态同高),`top` 不变。 + - **不期望**:高度从一行文案跳到整页正文;出现转圈、骨架、「正在加载」。 +3. 对「星历」「我的报告」各做一次。三页的等待句可以不同(盘还没拿到 / 星历还没拿到 / 报告列表还没拿到),但都必须写在 `.secondary-page` 里居中,不能是矮块。 + +## B · 第二次进入没有等待句(BUG-966 缓存) + +1. 打开过「星盘」并等到盘出现。 +2. 点「新建对话」回 `/`(不要刷新)。 +3. Network Clear,再点「星盘」。 + - **期望**:第一帧就是盘(或失败句),**没有**「这一张盘还没拿到。」。Network 里可以有一条后台 `/api/chart-view`(静默刷新)。 +4. 「星历」「我的报告」同样:第二次不得再出现各自的等待句。 + +## C · 悬停会预取数据(BUG-966 预取) + +1. 刷新 `/`,Network Clear,筛选 `chart-view|ephemeris|reports`。 +2. 鼠标移到侧栏「星盘」上,**先不要点**。 + - **期望**:出现 `GET /api/chart-view`。对「星历」「我的报告」分别是 `/api/ephemeris`、`/api/reports`。 +3. 再点进去。 + - **期望**:若预取已完成,进页不再先闪等待句。 + +## D · 跨部署旧标签页会在下一次跳转整页重载(BUG-965 / 967) + +这一条必须跨一次真实部署,属环境依赖。 + +1. 在 staging 打开 `/` 并停留在对话页(不要关标签)。确认对话还能发、还能收。 +2. 让负责人发一版新的 staging(或等下一轮门禁部署)。记下新 SHA。 +3. **不要刷新**这个旧标签。切到别的应用再切回来(触发 `visibilitychange`)。 +4. Network Clear。点侧栏「星盘」。 + - **期望**:出现 Type = `document` 的 `/chart` 请求(整页重载),页面打开;不是点击无反应。 + - 地址栏最终是 `/chart`,之后再点「星历」应走客户端导航(没有新的 document 请求),因为这时已经是新构建。 +5. 另开一个**部署之后**才打开的新标签,点「星盘」。 + - **期望**:没有 document 请求(版本一致,仍是客户端路由)。 +6. 若 health 接口人为断掉(DevTools Offline 只挡 `/api/health` 不挡其它): + - **期望**:当前标签继续能点、不强制刷新。 + +失败时记下:点击时的 URL、Console 全文、Network 里 `/_next/static` 与 `/chart?_rsc` 的状态码。不要把用户资料贴进仓库。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index c2f9632a..40d4b07e 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -395,14 +395,21 @@ shell. Each used to own a full-screen layout — a `*-shell` root, a `*-topbar` with one 「返回对话」 link, and a `*-hero` with a page-sized h1 — three copies of one skeleton, none of them carrying the sidebar. -- **Composition:** `app/(secondary)/layout.tsx` = `SidebarProvider` → - `AppSidebar` (read-only) → `SidebarInset.chat-panel.secondary-panel`. The - route group changes no URL; what it changes is that the four routes share - **one** shell instance, so moving between them neither remounts the sidebar - nor re-reads the session list. Each page contributes only `SecondaryHeader`: - the same 46px row the chat page uses — sidebar trigger, page name, an optional - quiet note chip, and up to two right-aligned actions. The body is the only - thing that scrolls, and it owns the page floor (`--color-canvas-soft`). +- **Composition:** `app/(app)/layout.tsx` = `SessionListProvider` → + `SidebarProvider` → `AppSidebar` → `SidebarInset.chat-panel.secondary-panel` + (home registers its own inset class). `/chart`, `/ephemeris` and `/reports` + share `SecondaryPageShell`: the same 46px `SecondaryHeader` (sidebar trigger, + page name, a reserved note slot so a late birth line cannot collapse the row, + up to two actions) and a `.secondary-page` body that always fills the + remaining viewport. Waiting copy is centered in that cell; swapping in the + tall content does not change the shell height. No spinner, skeleton, or + 「正在加载」. 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 + (`` 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`, + a match keeps the client router. - **One sidebar component, two modes.** `AppSidebar` is the only sidebar in the product. `/` passes `controls`; the secondary layout does not, and without it the same markup renders read-only. There used to be a second component for @@ -419,10 +426,11 @@ one skeleton, none of them carrying the sidebar. deleting stay on `/`, backed by `Home()`'s optimistic-update and rollback layer, which is far more than these four routes need. - **Leaving the chat is a navigation, not a reload.** 星盘 / 星历 / 我的报告 are - `` on every page, including `/`, where they used to be a full document + `AppLink` on every page, including `/`, where they used to be a full document load that threw away the React tree, the session list and the account. Only `/login` stays a hard exit, and `persistLoginSessionReturn()` still stashes the - `?c=` before any of them. + `?c=` before any of them. After a deploy, a stale tab's next `AppLink` is the + hard exit: `window.location.assign`, not a silent client-router no-op. - **Loading:** the sidebar renders its nav immediately and fills the list when `/api/sessions` returns. While that is in flight the list area carries static copy — never a skeleton, per the unified-loading ruling. A list read in the @@ -446,7 +454,7 @@ one skeleton, none of them carrying the sidebar. - **Surface:** `--color-canvas-soft` floor; rows use `--color-canvas`, a warm hairline, and `--radius-lg` on the list as a whole. No drop shadow. - **Width:** 900px centered, matching long-form chat reading. Rows stack at the global 767px cut. - **Actions:** “生成完整报告” is the one filled action. A ready row keeps “查看报告” as the primary document action with “导出报告(.md)” quiet beside it. The export downloads the same longform Markdown the detail page renders. Generating copy is one-step (“正在生成报告,大约 10–30 秒”); there is no chapter-count progress. A failed row states the reason in place and offers no second generate entry — the header already has one. Card summary is a deterministic excerpt of the Markdown 「摘要」 section, stored on the cover document at generation time. -- **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never starts a second writing flow. A missing Markdown appendix is an old report: the detail page asks the reader to regenerate. +- **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. First-screen loading uses `SecondaryPageShell` waiting copy 「报告列表还没拿到。」 — never a spinner, skeleton, or 「正在加载」. Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never starts a second writing flow. A missing Markdown appendix is an old report: the detail page asks the reader to regenerate. - **Accessibility:** generate, refresh, and row actions are 44px. The generating row's chip is a live region; a failed export is an alert. ### Personal report reader @@ -828,7 +836,7 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 ### 等待态 -星盘页是独立文档。外壳揭幕(标题、返回)可以在主盘到达之前发生;揭幕之后填数据不得再出现 spinner / 骨架 / 「正在加载」,沿用 §9 星历页那一类静态句,不新造第五套加载动画。 +星盘页是独立文档。`SecondaryPageShell` 揭幕(标题、保留的 note 槽、撑满剩余视口的内容区)可以在主盘到达之前发生;揭幕之后填数据不得再出现 spinner / 骨架 / 「正在加载」,沿用 §9 星历页那一类静态句,不新造第五套加载动画。同一会话再次进入直接用模块缓存,不重放等待句。 | 时刻 | 文案 | | --- | --- | @@ -850,6 +858,6 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 - **当日行运:** 九曜的星座、度数、相对本命宫位、顺逆。桌面表格,767px 以下每行改成带标签的堆叠。没有本命上升时,相对本命列为空,并写「还没有本命上升,相对本命这一列空着。」 - **未来九十天:** 按日期升序只写事实(「某星 进入 某座」「某星 停滞转顺 · 度数」)。`/api/ephemeris_events` 未上线时整段静态说明:「换座和停滞还没接上。这一页其余部分不受影响。」不得让整页失败。 - **出口:** 主按钮「带这天去提问」在顶栏,把当前日期预填进对话草稿并回到 `/`。不写「这一页是天象本身,不是对你的判断。」 -- **等待:** 揭幕后不得再出现 spinner / 骨架 / 「正在加载」。未登录是静态「请先登录」加去登录,不是转圈。 +- **等待:** 揭幕后不得再出现 spinner / 骨架 / 「正在加载」。首屏未到时 `SecondaryPageShell` 写「这一天的星历还没拿到。」,居中在已撑满的内容区里。未登录是静态「请先登录」加去登录,不是转圈。同一会话再次进入直接用模块缓存。 - **文案:** 不得写运势结论,不得把三套坐标系换算或叠加。不另印「这是天象本身」这类边界说明。 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 62ac86b5..2448db66 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2094,6 +2094,24 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class overscroll-behavior: contain; -webkit-overflow-scrolling: touch; } +.secondary-page { + display: grid; + align-content: start; + min-height: 0; +} +.secondary-page:has(> .secondary-page-waiting) { + align-content: center; + justify-items: center; +} +.secondary-page-waiting { + max-width: 44ch; + margin: 0; + padding: 0 var(--space-6); + color: var(--color-ink-secondary); + font-size: var(--type-body-sm); + line-height: 1.6; + text-align: center; +} .chart-page-body, .ephemeris-body, .report-center-body, @@ -2138,7 +2156,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .report-center-body, .chart-page-message, .ephemeris-message, - .report-center-message { padding-inline: var(--space-4); } + .report-center-message, + .secondary-page-waiting { padding-inline: var(--space-4); } } /* Starter home: greeting, composer, entry pills */ diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 54306584..70714be5 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next"; import localFont from "next/font/local"; import Script from "next/script"; import { Toaster } from "@/components/ui/sonner"; +import { StaleBuildGuard } from "@/components/stale-build-guard"; import { StaleClientRecovery } from "@/components/stale-client-recovery"; import { ViewportScrollLock } from "@/components/viewport-scroll-lock"; import { themePreferenceBootScript } from "@/lib/theme-preference"; @@ -71,6 +72,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac + {children} diff --git a/frontend/src/components/app-link.tsx b/frontend/src/components/app-link.tsx new file mode 100644 index 00000000..3ae89613 --- /dev/null +++ b/frontend/src/components/app-link.tsx @@ -0,0 +1,33 @@ +"use client"; + +import Link from "next/link"; +import { forwardRef, type ComponentProps } from "react"; + +import { hrefFromLinkTarget, interceptAppNavigation } from "@/lib/app-navigation"; + +export type AppLinkProps = ComponentProps; + +/** + * In-app link that stays on the client router when this tab's build matches + * the running image, and does a document load when it does not. + * + * Next 16.3.1's own mismatch fallback did not fire on this host after a + * deploy (see BUG-967); wrapping the destinations that actually no-op'd is + * the self-heal. + */ +export const AppLink = forwardRef(function AppLink( + { onClick, href, ...props }, + ref, +) { + return ( + { + onClick?.(event); + interceptAppNavigation(event, hrefFromLinkTarget(href)); + }} + {...props} + /> + ); +}); diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 277ba6ba..b2a57138 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -15,11 +15,12 @@ import { UserRound, Users, } from "lucide-react"; -import Link from "next/link"; import { usePathname } from "next/navigation"; import { useEffect, useRef } from "react"; import type { Ref } from "react"; +import { AppLink } from "@/components/app-link"; import { persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url"; +import { prefetchSecondaryPage } from "@/lib/secondary-page-data"; import { Sidebar, SidebarContent, @@ -261,6 +262,8 @@ export function AppSidebar({ tooltip={label} isActive={pathname === href || pathname.startsWith(`${href}/`)} href={href} + onPointerEnter={() => prefetchSecondaryPage(href)} + onPointerDown={() => prefetchSecondaryPage(href)} onClick={leaveChat} >