diff --git a/BLOCKED.md b/BLOCKED.md index bd97f4a3..ef342cbe 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,5 +1,11 @@ # BLOCKED +## 会话列表:空咨询延迟落库未做(2026-09-17,BUG-928) + +- **让步:** 任务书 T3 要把 `startNewChat` / 启动落点改成本地创建、第一问前才 `POST /api/sessions`。牵动 `?c=` 深链和刷新恢复,本轮只做服务端过滤 + 复用已有空咨询。 +- **替代:** `GET /api/sessions` 排除 `messages = []`,响应带 `draft`;启动和「新建对话」优先用这份空咨询,不再连点就堆新行。 +- **解除需要:** 给未落库会话一个刷新可恢复的身份,且 `send()` 在咨询 POST 前补 create。另立单。 + ## 会话列表多键排序:无 Docker 未跑翻页重叠(2026-09-17,BUG-926) - **无 Docker:** 本机 `docker` 不在 PATH。任务书要求 `npm run test:db` 插入三条不同 `updated_at` 的会话,`GET /api/sessions?limit=2` 返回最新两条且 `nextCursor` 翻页拿到第三条、无重叠。**未跑,不得写成通过。** diff --git a/CHANGELOG.md b/CHANGELOG.md index 5660d309..5e19d683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-17 — 四个页面共用一份会话列表,切页不再重拉 + +「星盘」「星历」「我的报告」和对话页共用同一条侧栏。打开一次之后,在这四个页面之间切换不会再重新去拉会话列表,改名也会马上在各页看到。没说过话的「新对话」不再进列表。新开的生时校正和今日节奏标题改成「生时校正 · 9月14日」这种类别在前的写法,侧栏多一行创建时间。Skill 版本不变。 + ## 2026-09-17 — 会话列表按最近活动排 侧栏会话不再按编号乱序。置顶仍在最前,其余按你最近说过话的时间排。套餐列表也恢复按设定顺序。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 2747319d..bc26075f 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -12082,3 +12082,51 @@ - 相关记录:BUG-553 - 复发自:无 - 修复版本:待发布 + +## BUG-927 | `/` 与次级页各拉一份会话列表,切页就重拉 + +- 状态:resolved +- 首次发现:2026-09-17 +- 最近更新:2026-09-17 +- 影响面:`app/(app)/layout.tsx`、`session-list-context.tsx`、`page.tsx` +- 用户现象:对话、星盘、星历、报告四处侧栏不是同一份,每换一页都重新读一次。 +- 触发条件:在 `/` 与 `/chart` `/ephemeris` `/reports` 之间切换。 +- 根因:`/` 不在 `(secondary)` 路由组,离开即卸载 Home;次级页另用 `useSidebarData` 再拉一遍。 +- 修复:四个页面进 `(app)` 路由组。一份 `SessionListProvider` 拉 `/api/sessions` 与 `/api/account`。首页不再自己 `fetchSessions`/`fetchAccount`。侧栏不随切页卸载。按任务书让步,改名/置顶/删除仍由首页注册,未注册时侧栏只读。 +- 验证:`session-list-provider.test.ts`、`sidebar-contract.test.ts`、`chart-page-view.test.tsx`。 +- 防复发:`app/` 里 `SidebarProvider` 只许出现在 `(app)/layout.tsx`。 +- 相关记录:BUG-745、BUG-926 +- 复发自:无 +- 修复版本:待发布 + +## BUG-928 | 空的「新对话」落库堆积,两边列表还不一致 + +- 状态:resolved +- 首次发现:2026-09-17 +- 最近更新:2026-09-17 +- 影响面:`GET /api/sessions`、`startNewChat`、首页启动落点 +- 用户现象:首页 50 条里大量「新对话」;对话页把它们藏起来,星盘等页原样显示。 +- 触发条件:点「新建对话」或启动时自动落一个空咨询会话。 +- 根因:会话在用户开口前就 `POST /api/sessions`;列表页不过滤空咨询。 +- 修复:列表 `GET` 排除 `messages = []`;详情仍可读。启动和「新建对话」复用已有空咨询。延迟到第一问才落库未做,见 `BLOCKED.md`。 +- 验证:`chat-session-authority.test.ts`、`session-list-filter.test.ts`。无 Docker,未跑 `test:db` 空会话不入列。 +- 防复发:列表路由必须带空咨询过滤;详情路由不得带。 +- 相关记录:BUG-553、BUG-927 +- 复发自:无 +- 修复版本:待发布 + +## BUG-929 | 会话标题类别在后,同名靠墙钟 HH:MM + +- 状态:resolved +- 首次发现:2026-09-17 +- 最近更新:2026-09-17 +- 影响面:`resolveSessionTitle`、侧栏行映射、`GET /api/sessions` 的 `created_at` +- 用户现象:一列「新对话 / 9月14日 · 生时校正 / 9月14日 · 生时校正 02:14」,看不出类别,同日多条靠新建时的钟点区分。 +- 触发条件:新建校正或今日节奏,或同日开第二条。 +- 根因:`datedSessionTitle` 日期在前;`uniquifySessionTitle` 用墙钟。侧栏副标题只在他人盘时出现。 +- 修复:新标题「生时校正 · M月D日」「今日节奏 · M月D日」。删除 uniquify。两处侧栏共用 `toSidebarSessionRow`,副标题为上海时区创建时间。旧标题不批量改。 +- 验证:`agent-reply.test.ts`、`chart-library-session.test.ts`、`session-open-preserves-identity.test.ts`。 +- 防复发:不得再给同名标题追加 HH:MM;打开已有会话仍不得改 title / updatedAt(BUG-699)。 +- 相关记录:BUG-699、BUG-557 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-session-list-single-source-20260917.md b/docs/tasks/PROGRESS-session-list-single-source-20260917.md index 126cb074..48bc3e5c 100644 --- a/docs/tasks/PROGRESS-session-list-single-source-20260917.md +++ b/docs/tasks/PROGRESS-session-list-single-source-20260917.md @@ -2,21 +2,35 @@ 工作树:`.worktrees/session-list-single-source-20260917` 分支:`codex/session-list-single-source-20260917` -基线:`origin/staging` @ `6d81062b`(composer-guard 已合入) +基线:`origin/staging` @ `e60a3a4d`(T1 已合入)后继续 T2–T4。任务书更新为常驻外壳 layout(`f6db3c77`)。 ## 任务状态 | 任务 | 状态 | 说明 | | --- | --- | --- | -| T1 多键排序 BUG-926 | 完成 | `order()` 追加;SQL `order by a, b`;套餐路由未改代码 | -| T2 常驻外壳 layout BUG-927 | 未做 | 路由组重排 + SessionListProvider,本轮未开工 | -| T3 空会话不入列 BUG-928 | 未做 | 按让步顺序在 T2 之后 | -| T4 标题副标题 BUG-929 | 未做 | | -| T5 记录 | 部分 | T1 的 BUG-926 / BLOCKED / CHANGELOG 已写 | +| T1 多键排序 BUG-926 | 完成 | 已合入 `e60a3a4d` | +| T2 常驻外壳 layout BUG-927 | 完成 | `(app)` 路由组 + SessionListProvider;首页 `useHomeShellRegistration` 注册全部 controls(让步:元数据操作未上移到 provider,次级页只读) | +| T3 空会话不入列 BUG-928 | 完成(让步) | 服务端列表排除空 `messages`;启动/新建复用已有空咨询。延迟落库见 `BLOCKED.md` | +| T4 标题副标题 BUG-929 | 完成 | `生时校正 · M月D日`;删除 uniquify;共用 `toSidebarSessionRow` | +| T5 记录 | 完成 | | -## 测试(T1) +## T2 让步 + +任务书允许第一步只做「路由组 + layout 常驻 + provider 拥有列表 + 首页注册全部控制」,元数据操作上移作第二步。本轮按此交付:改名/置顶/删除仍由首页注册,未挂载首页时侧栏只读(与改前次级页一致)。列表所有者已是 provider,切页不重拉。 + +## T3 让步 + +延迟到第一问才 `POST /api/sessions` 未做(深链 `?c=`、刷新恢复牵动过大)。改为服务端过滤 + 复用已有空咨询。 + +`page.tsx` 行数:T2 开工约 1831,抽出 `useHomeShellRegistration` 后 **1825**。`Home()` useState 34(帽 36),useRef 37(帽 37)。 + +## 测试 | 项 | 结果 | | --- | --- | -| `local-postgres-order.test.ts` + query-value | 7 passed / 0 failed | -| `npm run test:db` | 无 Docker,见 `BLOCKED.md` | +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error(既有 warning 未动) | +| 定向 | session-list / sidebar / agent-reply / chat-session-authority / local-postgres-or / home-shell-growth 等 **26 + 先前 114/115(已修列表 messages 断言)** | +| Python | `test_session_management_entrypoints`、`test_supabase_user_data_contract` 过。`test_daily_and_rectification_entrypoints` 两条找 `daily-starlanguage-card` / `birth-rectification-card` 在 **T1 基线 `e60a3a4d` 已红**(前端测试明确断言这两类名已删除),不是本轮引入 | +| `test:db` | 无 Docker,见 `BLOCKED.md` | +| `next build` | 本 worktree `node_modules` 是 junction,未核 `/` Static,见既有 BLOCKED | diff --git a/docs/tasks/README.md b/docs/tasks/README.md index c04dcb4d..933dfd3e 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -131,7 +131,7 @@ | `TASK-rectification-p0-fix-20260915.md` | `PROGRESS-rectification-p0-fix-20260915.md` | **验收修复单**:`f51e494c` 六条缺陷全部实现且方式正确,但 `page.tsx` 从 1951 涨到 1964 行,撞了 `chart-view-route.test.ts` 的 `<= 1951` 上限(AGENTS.md §6 增长冻结)。全量 fail 32→33,就这一条。门禁红很可能是 staging 停在 `2d7698ea`、6 个提交未部署的原因。修法是把 BUG-705 的十来行接线搬出 page.tsx,不放宽上限 | 待验收 | `codex/rectification-p0-fix-20260915` | | `TASK-settings-dialog-size-and-nav-20260915.md` | — | **复发单**:设置弹窗四个分区尺寸仍随内容跳变(BUG-698,复发自 BUG-554——旧防复发只查「有没有写 height」,查不到「写了没生效」);首要嫌疑是 `.settings-modal` 的 `dvh` 没有 `vh` 回退,不支持时整条 `height` 作废退化成内容高度,需先复现确认。另按产品要求去掉分区菜单左侧强调条,并拆开与悬停共用的选中态 | 待领取 | `codex/settings-dialog-size-and-nav-20260915` | | `TASK-consult-followup-tool-contract-20260917.md` | `PROGRESS-consult-followup-tool-contract-20260917.md` | 真机:申报时段会话连发「?」「你在说什么鬼」都 `run.failed runtime_contract_incomplete`,回执无任何 `tool` 步骤。根因是 Agent 系统指令写明「简单追问可复用已有 packet / context、不调工具」,而 `contractReady()` 要求每次请求恰好一次成功排盘调用;「已有 packet」跨请求并不存在(缓存只在单次请求内)。本命与窗口两个 Agent 同构。**产品拍板方案 1**:每轮必调工具(BUG-922 删例外句 + BUG-923 第 0 步 `toolChoice: required`);否决「没调工具就走不扣点纯对话」。第一轮正经问题为何失败留 T4 取证(回执只在 web 容器日志) | 待验收 | `codex/consult-followup-tool-contract-20260917` | -| `TASK-session-list-single-source-20260917.md` | `PROGRESS-session-list-single-source-20260917.md` | 会话列表一处数据源:本地 PG 兼容层 `order()` 只保留最后一键,`/api/sessions` 实际按 `id` 排、与游标不一致;`/` 与次级页两份数据源、`/` 每次回来重启动(产品拍板:首页与三个次级页进同一路由组,侧栏外壳与列表 provider 常驻 layout,不做 store);空「新对话」落库堆积(首页 50 条里 28 条);标题类别在后、同名靠墙钟 HH:MM。串行在 composer-guard 单之后。BUG 段 926 起 | 执行中(T1 已合入) | `codex/session-list-single-source-20260917` | +| `TASK-session-list-single-source-20260917.md` | `PROGRESS-session-list-single-source-20260917.md` | 会话列表一处数据源:本地 PG 兼容层 `order()` 只保留最后一键,`/api/sessions` 实际按 `id` 排、与游标不一致;`/` 与次级页两份数据源、`/` 每次回来重启动(产品拍板:首页与三个次级页进同一路由组,侧栏外壳与列表 provider 常驻 layout,不做 store);空「新对话」落库堆积(首页 50 条里 28 条);标题类别在后、同名靠墙钟 HH:MM。串行在 composer-guard 单之后。BUG 段 926 起 | 待验收(T1–T4;延迟落库让步) | `codex/session-list-single-source-20260917` | | `TASK-consultation-answer-start-anchor-20260917.md` | — | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 待领取 | — | ### 个人报告 diff --git a/docs/testing/session-list-single-source-20260917.md b/docs/testing/session-list-single-source-20260917.md new file mode 100644 index 00000000..cbbe46a5 --- /dev/null +++ b/docs/testing/session-list-single-source-20260917.md @@ -0,0 +1,33 @@ +# 真机清单 · 会话列表只拉一次(2026-09-17) + +无登录态、无 Chrome。留给有真实账号的人在 staging 用 DevTools 走查。不要截会话正文。 + +## 列表只拉一次 + +1. 登录后打开 `/`,打开开发者工具 Network,过滤 `sessions` 与 `account`。 +2. 点侧栏「星盘」→「星历」→「我的报告」→ 再点一条对话回到 `/`。 +3. **看:** `/api/sessions` 与 `/api/account` 全程各一次(改名、删除、充值后的刷新除外)。 +4. **看:** Elements 里选中侧栏节点,切页后仍是同一个节点(不卸载重建)。 + +## 改名一致 + +1. 在对话页把一条会话改名。 +2. 再去星盘页。 +3. **看:** 侧栏已是新标题。 + +## 登录页无侧栏 + +1. 退出后打开 `/login`。 +2. **看:** 没有会话侧栏。`/admin` 也没有。 + +## 空会话不入列 + +1. 在对话页连点两次「新建对话」。 +2. 去星盘页看侧栏。 +3. **看:** 没有多出一排「新对话」。正在用的那条空会话只在对话页本地出现。 + +## 新标题 + +1. 新开一段生时校正。 +2. **看:** 侧栏标题是「生时校正 · M月D日」,下面一行是创建时间,没有「02:14」这种墙钟后缀。 +3. 旧的「9月14日 · 生时校正」标题保持原样。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 9a894e4d..37c5d0f7 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -327,7 +327,7 @@ The birth-time rectification session is the consultation transcript plus a house ### Navigation item -- **Structure:** title, optional subtitle for another person's chart, current-state marker. +- **Structure:** title is the session name. New dated titles are `生时校正 · M月D日` / `今日节奏 · M月D日` (category first). Subtitle is always `M月D日 HH:MM` in Asia/Shanghai from `created_at`, plus ` · ` when the chart is not the account holder. Same-day duplicates are not uniquified with wall-clock `HH:MM`. - **States:** default, hover, current, focus, disabled. - **Hierarchy:** section labels stay tertiary; session titles and primary actions use ink so history rows do not collapse into the same gray as “收藏对话 / 历史对话”. History groups use the overline token for “今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早”. When the chart is not the account holder, a secondary line shows the chart name under the title. A session title is named when it is created; opening a stored session must not rename it. `updatedAt` advances only on conversation activity, including rectification turns, choice, adopt, and stop. Opening, refresh, and metadata PATCH do not bump it. A `?c=` that is not on the loaded page is looked up with `GET /api/sessions/{id}` before anyone may say it was deleted. Locked by `frontend/tests/session-open-preserves-identity.test.ts` and `frontend/tests/session-lookup-unlisted.test.ts`. - **Request behavior:** existing sessions remain selectable for reading while a request is active; creating or sending another request stays locked until the active request settles. @@ -335,7 +335,8 @@ The birth-time rectification session is the consultation transcript plus a house ### Sidebar shell -- **Composition:** provider, fixed header, one scroll-owning content region, fixed footer, trigger, rail, and flexible chat inset. +- **Composition:** provider, fixed header, one scroll-owning content region, fixed footer, trigger, rail, and flexible chat inset. `/`、`/chart`、`/ephemeris`、`/reports` share one `(app)` layout: one `SessionListProvider` and one `SidebarProvider`. The session list is fetched once per tab; walking between those four pages does not remount the sidebar or refetch `/api/sessions`. +- **Home registers shell controls** (new chat, select session, rename, pin, archive, delete, account menu). The other three pages leave `controls` unset, so rows are links to `/?c=`. - **Trigger placement:** the single visible collapse/expand trigger sits beside the active session title in the chat header. The sidebar brand row has no duplicate trigger. - **Desktop:** 288px expanded by default at 1024px and above; 64px collapsed icon rail. - **Tablet:** 64px collapsed by default from 768px through 1023px; 240px when expanded. diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 85d68154..707c9d4f 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -67,6 +67,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 下面是词条式释义,不是对你个人的判断。 | (不写) | 基础信息 Tab 只放行星卡。词条本身不得写成运势,但不另印这句边界说明。 | | 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件。「带这天去提问」在顶栏。不另印定性句。 | | (校正会话上普通输入框仍可打字) | 正在打开生时校正… | 校正会话上普通输入框只有禁用态。打开失败时按钮写「重新打开生时校正」。 | +| 9月14日 · 生时校正 / 9月14日 · 生时校正 02:14 | 生时校正 · 9月14日 | 新建校正/今日节奏标题类别在前、日期在后。同日多条不再加墙钟时刻,靠副标题 `M月D日 HH:MM` 区分。旧标题不批量改。 | ## 服务端探针 → Agent 题干 diff --git a/frontend/src/app/(secondary)/chart/page.tsx b/frontend/src/app/(app)/chart/page.tsx similarity index 100% rename from frontend/src/app/(secondary)/chart/page.tsx rename to frontend/src/app/(app)/chart/page.tsx diff --git a/frontend/src/app/(secondary)/ephemeris/page.tsx b/frontend/src/app/(app)/ephemeris/page.tsx similarity index 100% rename from frontend/src/app/(secondary)/ephemeris/page.tsx rename to frontend/src/app/(app)/ephemeris/page.tsx diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx new file mode 100644 index 00000000..c9997360 --- /dev/null +++ b/frontend/src/app/(app)/layout.tsx @@ -0,0 +1,46 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { AppSidebar } from "@/components/app-sidebar"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { SessionListProvider, sessionListSidebarModel, useSessionList } from "@/hooks/use-session-list"; +import "../site-styles"; + +function AppShell({ children }: { children: ReactNode }) { + const list = useSessionList(); + const { sessions, account } = sessionListSidebarModel(list); + const registration = list.registration; + + return ( + +
+ + + {children} + +
+
+ ); +} + +export default function AppLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/(app)/page.tsx similarity index 93% rename from frontend/src/app/page.tsx rename to frontend/src/app/(app)/page.tsx index 5bf12403..e7b328f0 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -1,6 +1,5 @@ "use client"; -import "@/app/site-styles"; import Link from "next/link"; import dynamic from "next/dynamic"; import { Sparkles } from "lucide-react"; @@ -9,7 +8,6 @@ import type { FormEvent, KeyboardEvent } from "react"; import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay"; import { ProfilePanel } from "@/components/profile-panel"; import { ThemePreferencePanel } from "@/components/theme-preference-menu"; -import { AppSidebar } from "@/components/app-sidebar"; import { BirthTimeAssessmentOverlay, type BirthTimeAssessmentPhase, @@ -41,7 +39,9 @@ import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall" import { ChatComposer } from "@/components/chat-composer"; import { RectificationComposerReopen } from "@/components/rectification-composer-reopen"; import { JumpToLatestButton } from "@/components/jump-to-latest-button"; -import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { useHomeShellRegistration } from "@/hooks/use-home-shell-registration"; +import { useSessionList } from "@/hooks/use-session-list"; import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft"; import { clearStaleClientReload } from "@/lib/stale-client-recovery"; import { BirthLocationFields } from "@/components/birth-location-fields"; @@ -186,7 +186,7 @@ import { placeQuestion, readProfile, selectedBirthPlace, - sessionChartLabel, sessionSidebarSubtitle, sessionSidebarTitle, upsertSelfChart, + sessionChartLabel, upsertSelfChart, } from "@/lib/home-profile"; import { activeChartStorageKey, @@ -196,7 +196,6 @@ import { ConsultationStatusError, createSession, discardLegacyCloudMirrorKeys, - fetchAccount, fetchActiveConsultationStatus, fetchCloudChartLibrary, fetchCloudSynastryHistory, @@ -204,7 +203,6 @@ import { fetchDailyStarlanguage, fetchModelCatalog, fetchSessionDetail, - fetchSessions, friendlyError, LoginRedirectError, mergeHydratedSession, @@ -212,6 +210,7 @@ import { resolveLookupBootstrap, payloadCode, payloadMessage, + readDraftConsultation, readSessions, readStoredDailyStarlanguage, readStoredPendingConsultation, @@ -229,6 +228,7 @@ import { starterHomeLandingNeedsConsultation, type BootstrapPhase, } from "@/lib/home-bootstrap"; +import { findReusableEmptyConsultation } from "@/lib/session-list-filter"; const BirthTimeRectification = dynamic( () => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification), @@ -243,16 +243,24 @@ const BillingPanel = dynamic( ); export default function Home() { + const sessionList = useSessionList(); + const { + sessions, + setSessions, + account, + setAccount, + ready: sessionListReady, + boot: sessionListBoot, + registerShellControls, + } = sessionList; const [accountMenuOpen, setAccountMenuOpen] = useState(false); const [activeAccountDialog, setActiveAccountDialog] = useState(null); const [chartLibrary, setChartLibrary] = useState([]); const [activeChartId, setActiveChartId] = useState("self"); const [billingPane, setBillingPane] = useState({}); const [dailyStarlanguage, setDailyStarlanguage] = useState({ kind: "pending" }); - const [account, setAccount] = useState(null); const [accountError, setAccountError] = useState(""); const [signingOut, setSigningOut] = useState(false); - const [sessions, setSessions] = useState([]); const [modelCatalog, setModelCatalog] = useState(null); const [activeSessionId, setActiveSessionId] = useState(""); const draftTheme = useRef(null); @@ -903,21 +911,29 @@ export default function Home() { return; } - const [nextAccount, modelCatalogResult, sessionsPayload] = await Promise.all([ - fetchAccount(controller.signal), - fetchModelCatalog(controller.signal) - .then((catalog) => ({ catalog, unavailable: false })) - .catch((caught: unknown) => { - if (caught instanceof Error && caught.name === "AbortError") throw caught; - return { catalog: null, unavailable: true }; - }), - fetchSessions(controller.signal), - ]); + await sessionListReady; + if (controller.signal.aborted) return; + const listBoot = sessionListBoot(); + if (listBoot?.signedOut || !listBoot?.account) { + redirectToLogin(); + return; + } + const nextAccount = listBoot.account; + const modelCatalogResult = await fetchModelCatalog(controller.signal) + .then((catalog) => ({ catalog, unavailable: false })) + .catch((caught: unknown) => { + if (caught instanceof Error && caught.name === "AbortError") throw caught; + return { catalog: null, unavailable: true }; + }); const nextModelCatalog = modelCatalogResult.catalog; const nextProfile = readProfile(nextAccount.profile); - const parsedSessions = readSessions(sessionsPayload.sessions, nextModelCatalog); - setSessionsCursor(sessionsPayload.nextCursor); - let nextSessions = parsedSessions.sessions; + const parsedSessions = readSessions(listBoot.rawRows, nextModelCatalog); + setSessionsCursor(listBoot.cursor); + let nextSessions = parsedSessions.sessions.length > 0 ? parsedSessions.sessions : listBoot.sessions; + const draftSession = readDraftConsultation(listBoot.draftRow, nextModelCatalog); + if (draftSession && !nextSessions.some((session) => session.id === draftSession.id)) { + nextSessions = [...nextSessions, draftSession]; + } if (nextSessions.length === 0) { if (controller.signal.aborted) return; const initialSession = createSession( @@ -993,27 +1009,32 @@ export default function Home() { bootstrapSelection.urlAction, ); if (starterHomeLandingNeedsConsultation(nextSessions, landingSessionId, bootstrapSelection.urlAction)) { - if (controller.signal.aborted) return; - const homeSession = createSession( - nextModelCatalog?.defaultModelId ?? "", - "consultation", - chartSnapshotForSession("self", [], nextProfile), - ); - if (nextModelCatalog) { - await writeChatSession(homeSession.id, { - title: homeSession.title, - theme: homeSession.theme, - model_id: homeSession.modelId, - messages: [], - session_type: homeSession.sessionType, - rectification_case_id: homeSession.rectificationCaseId, - chart_profile_id: homeSession.chartProfileId, - chart_profile_name: homeSession.chartProfileName, - chart_profile_role: homeSession.chartProfileRole, - }, "create"); + const reusable = findReusableEmptyConsultation(nextSessions); + if (reusable) { + landingSessionId = reusable.id; + } else { + if (controller.signal.aborted) return; + const homeSession = createSession( + nextModelCatalog?.defaultModelId ?? "", + "consultation", + chartSnapshotForSession("self", [], nextProfile), + ); + if (nextModelCatalog) { + await writeChatSession(homeSession.id, { + title: homeSession.title, + theme: homeSession.theme, + model_id: homeSession.modelId, + messages: [], + session_type: homeSession.sessionType, + rectification_case_id: homeSession.rectificationCaseId, + chart_profile_id: homeSession.chartProfileId, + chart_profile_name: homeSession.chartProfileName, + chart_profile_role: homeSession.chartProfileRole, + }, "create"); + } + nextSessions = [homeSession, ...nextSessions]; + landingSessionId = homeSession.id; } - nextSessions = [homeSession, ...nextSessions]; - landingSessionId = homeSession.id; } const activeListed = nextSessions.find((session) => session.id === landingSessionId) ?? nextSessions[0]; @@ -1453,6 +1474,44 @@ export default function Home() { } } + const modalOpen = activeAccountDialog !== null || onboardingPaywallOpen; + useHomeShellRegistration({ + account, + accountMenuOpen, + accountTrigger, + activeSessionId: activeSession?.id, + cancellationPending, + chartLibrary, + creatingSession, + hydrated, + loadMoreSessions, + modalOpen, + modelCatalog, + openAccountDialog, + pendingSessionId, + profile, + rectificationErrorMessage, + rectificationErrorSessionId, + rectificationOpeningSessionId, + rectificationSurfaceOpen, + registerShellControls, + renameSession, + selectSession, + sessionMenuId, + sessions, + sessionsCursor, + setAccountMenuOpen, + setPendingSessionDeletion, + setSessionMenuId, + shareSession, + showArchivedSessions, + startNewChat, + toggleArchivedSession, + toggleArchivedView, + togglePinnedSession, + visibleSessions, + }); + if (!hydrated || (!account && !accountError)) { const loadingCopy = bootstrapLoadingCopy(bootstrapPhase); return ( @@ -1477,23 +1536,6 @@ export default function Home() { ); } - const sidebarAccount = { - name: profile.name.trim() || account.user.email || "账户", - email: account.user.email || "尚未读取邮箱", - credits: account.credits, - avatar: account.avatar, - initial: profile.name.trim().slice(0, 1) - || account.user.email?.slice(0, 1).toUpperCase() - || "你", - }; - - const sidebarSessions = visibleSessions.map((session) => ({ - id: session.id, title: sessionSidebarTitle(session, chartLibrary), - subtitle: sessionSidebarSubtitle(session, chartLibrary), - pinned: session.pinned, archived: Boolean(session.archivedAt), updatedAt: session.updatedAt, - })); - const modalOpen = activeAccountDialog !== null || onboardingPaywallOpen; - const accountOverlayModel: AccountOverlayModel | null = activeAccountDialog === null ? null : { title: activeAccountDialog ? accountDialogTitles[activeAccountDialog] : "", dialogClass: activeAccountDialog ? accountDialogClasses[activeAccountDialog] : "", @@ -1541,53 +1583,8 @@ export default function Home() { }; return ( - -
+ <> {replyAnnouncement} - session.archivedAt).length, showingArchived: showArchivedSessions, - hasMore: Boolean(sessionsCursor), onLoadMore: loadMoreSessions, - menuSessionId: sessionMenuId, - disabled: Boolean(pendingSessionId) || cancellationPending, - onToggleArchivedView: () => { void toggleArchivedView(); setSessionMenuId(null); }, - onMenuSessionChange: setSessionMenuId, - onTogglePinned: togglePinnedSession, - onRename: (sessionId) => { - const session = sessions.find((candidate) => candidate.id === sessionId); - if (session) void renameSession(session); - }, - onShare: (sessionId) => { - const session = sessions.find((candidate) => candidate.id === sessionId); - if (session) void shareSession(session); - }, - onToggleArchived: toggleArchivedSession, - onDelete: (sessionId) => { - const session = sessions.find((candidate) => candidate.id === sessionId); - if (session) setPendingSessionDeletion(session); - }, - }, - onAccountMenuOpenChange: setAccountMenuOpen, - onNewChat: () => void startNewChat(), - onSelectSession: selectSession, - onOpenProfile: () => openAccountDialog("profile"), - onOpenChartLibrary: () => openAccountDialog("chart-library"), - onOpenGeneral: () => openAccountDialog("general"), - onOpenBilling: () => openAccountDialog("billing", { source: "account-menu" }), - onOpenLogout: () => openAccountDialog("logout"), - }} - /> {pendingSessionDeletion ? (
setPendingSessionDeletion(null)}>
event.stopPropagation()}> @@ -1604,7 +1601,6 @@ export default function Home() {
) : null} -
@@ -1806,7 +1802,6 @@ export default function Home() { {starterHomeVisible && !rectificationSurfaceOpen && !onboardingFormActive ?
-
+ ); } diff --git a/frontend/src/app/(secondary)/reports/[reportId]/error.tsx b/frontend/src/app/(app)/reports/[reportId]/error.tsx similarity index 100% rename from frontend/src/app/(secondary)/reports/[reportId]/error.tsx rename to frontend/src/app/(app)/reports/[reportId]/error.tsx diff --git a/frontend/src/app/(secondary)/reports/[reportId]/loading.tsx b/frontend/src/app/(app)/reports/[reportId]/loading.tsx similarity index 100% rename from frontend/src/app/(secondary)/reports/[reportId]/loading.tsx rename to frontend/src/app/(app)/reports/[reportId]/loading.tsx diff --git a/frontend/src/app/(secondary)/reports/[reportId]/not-found.tsx b/frontend/src/app/(app)/reports/[reportId]/not-found.tsx similarity index 100% rename from frontend/src/app/(secondary)/reports/[reportId]/not-found.tsx rename to frontend/src/app/(app)/reports/[reportId]/not-found.tsx diff --git a/frontend/src/app/(secondary)/reports/[reportId]/page.tsx b/frontend/src/app/(app)/reports/[reportId]/page.tsx similarity index 100% rename from frontend/src/app/(secondary)/reports/[reportId]/page.tsx rename to frontend/src/app/(app)/reports/[reportId]/page.tsx diff --git a/frontend/src/app/(secondary)/reports/page.tsx b/frontend/src/app/(app)/reports/page.tsx similarity index 100% rename from frontend/src/app/(secondary)/reports/page.tsx rename to frontend/src/app/(app)/reports/page.tsx diff --git a/frontend/src/app/(secondary)/layout.tsx b/frontend/src/app/(secondary)/layout.tsx deleted file mode 100644 index 5451322d..00000000 --- a/frontend/src/app/(secondary)/layout.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; - -import { AppSidebar } from "@/components/app-sidebar"; -import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; -import { useSidebarData } from "@/hooks/use-sidebar-data"; -import "../site-styles"; - -/** - * One shell for `/chart`, `/ephemeris`, `/reports` and `/reports/[reportId]`. - * - * A route group changes no URL: the four routes keep their paths and their own - * `metadata` and `dynamic` declarations. What it changes is who owns the shell. - * Each page used to render `SecondaryShell`, so each carried a provider and a - * sidebar of its own; stepping between two of them tore the nav down and built - * it again, list request included. Hoisting it here means the sidebar is mounted - * once per visit to this section, and `useSidebarData` runs once with it. - * - * The sidebar is read-only on purpose — no renaming, archiving, deleting or - * account menu. Those are backed by `Home()`'s optimistic-update and rollback - * layer, and none of these four pages offers session management - * (TASK-cend-surfaces-claude-alignment-20260916 D9, carried forward by - * TASK-sidebar-unify-20260916 D1). - */ -export default function SecondaryLayout({ children }: { children: ReactNode }) { - const { sessions, account, settled, signedOut } = useSidebarData(); - - return ( - -
- - - {children} - -
-
- ); -} diff --git a/frontend/src/app/api/sessions/[id]/route.ts b/frontend/src/app/api/sessions/[id]/route.ts index 304b6aae..b2ab503c 100644 --- a/frontend/src/app/api/sessions/[id]/route.ts +++ b/frontend/src/app/api/sessions/[id]/route.ts @@ -13,7 +13,7 @@ import { metadataUpdateValues } from "@/lib/session-metadata-update"; type RouteContext = { params: Promise<{ id: string }> }; -const sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"; +const sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at"; const sessionIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function payloadHasMessages(payload: unknown): payload is { messages: unknown } { diff --git a/frontend/src/app/api/sessions/route.ts b/frontend/src/app/api/sessions/route.ts index 6c944067..75071d20 100644 --- a/frontend/src/app/api/sessions/route.ts +++ b/frontend/src/app/api/sessions/route.ts @@ -16,8 +16,15 @@ import { parseSessionCursor, sessionCursorFilter, } from "@/lib/session-cursor"; +const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at"; -const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"; +function excludeEmptyConsultations Query; +}>(query: Query): Query { + // Empty consultations are `messages = []`. Rectification rows keep an opening + // turn, so excluding empty arrays leaves them in the list (BUG-928). + return query.not("messages", "eq", []); +} function applyArchiveFilter Query; @@ -40,13 +47,15 @@ export async function GET(request: Request) { const { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); - let pageQuery = applyArchiveFilter( - supabase - .from("chat_sessions") - .select(SESSION_LIST_COLUMNS) - .eq("user_id", user.id) - .eq("pinned", false), - archived, + let pageQuery = excludeEmptyConsultations( + applyArchiveFilter( + supabase + .from("chat_sessions") + .select(SESSION_LIST_COLUMNS) + .eq("user_id", user.id) + .eq("pinned", false), + archived, + ), ) .order("updated_at", { ascending: false }) .order("id", { ascending: false }) @@ -58,25 +67,42 @@ export async function GET(request: Request) { if (pageError) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 }); let pinnedRows: typeof pageRows = []; + let draft: NonNullable[number] | null = null; if (!cursor) { - const { data: pinnedData, error: pinnedError } = await applyArchiveFilter( - supabase - .from("chat_sessions") - .select(SESSION_LIST_COLUMNS) - .eq("user_id", user.id) - .eq("pinned", true), - archived, + const { data: pinnedData, error: pinnedError } = await excludeEmptyConsultations( + applyArchiveFilter( + supabase + .from("chat_sessions") + .select(SESSION_LIST_COLUMNS) + .eq("user_id", user.id) + .eq("pinned", true), + archived, + ), ) .order("updated_at", { ascending: false }) .order("id", { ascending: false }); if (pinnedError) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 }); pinnedRows = pinnedData ?? []; + if (!archived) { + const { data: draftData, error: draftError } = await supabase + .from("chat_sessions") + .select(SESSION_LIST_COLUMNS) + .eq("user_id", user.id) + .eq("session_type", "consultation") + .eq("messages", []) + .is("archived_at", null) + .order("updated_at", { ascending: false }) + .order("id", { ascending: false }) + .limit(1); + if (draftError) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 }); + draft = draftData?.[0] ?? null; + } } const nextCursor = nextSessionCursor(pageRows ?? [], limit); const page = (pageRows ?? []).slice(0, limit); const sessions = cursor ? page : [...(pinnedRows ?? []), ...page]; - return NextResponse.json({ sessions, nextCursor }); + return NextResponse.json({ sessions, nextCursor, draft }); } catch (error) { if (isSupabaseConfigurationError(error)) { return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 }); diff --git a/frontend/src/hooks/use-home-shell-registration.ts b/frontend/src/hooks/use-home-shell-registration.ts new file mode 100644 index 00000000..5426d4f0 --- /dev/null +++ b/frontend/src/hooks/use-home-shell-registration.ts @@ -0,0 +1,163 @@ +"use client"; + +import { useEffect, type Dispatch, type RefObject, type SetStateAction } from "react"; + +import type { ChatSession } from "@/lib/home-types"; +import type { Account } from "@/lib/home-types"; +import type { ChartLibraryRecord, Profile } from "@/lib/home-types"; +import type { PublicLanguageModelCatalog } from "@/lib/public-models"; +import { toSidebarSessionRow } from "@/lib/session-sidebar-row"; +import type { ShellRegistration } from "@/lib/session-list-context"; + +type AccountDialogName = "profile" | "chart-library" | "general" | "billing" | "logout"; + +type HomeShellRegistrationParams = { + account: Account | null; + accountMenuOpen: boolean; + accountTrigger: RefObject; + activeSessionId: string | undefined; + cancellationPending: boolean; + chartLibrary: readonly ChartLibraryRecord[]; + creatingSession: boolean; + hydrated: boolean; + loadMoreSessions: () => void; + modalOpen: boolean; + modelCatalog: PublicLanguageModelCatalog | null; + openAccountDialog: (dialog: AccountDialogName, options?: { source?: string }) => void; + pendingSessionId: string | null; + profile: Profile; + rectificationErrorMessage: string; + rectificationErrorSessionId: string | null; + rectificationOpeningSessionId: string | null; + rectificationSurfaceOpen: boolean; + registerShellControls: (registration: ShellRegistration | null) => void; + renameSession: (session: ChatSession) => void | Promise; + selectSession: (sessionId: string) => void; + sessionMenuId: string | null; + sessions: readonly ChatSession[]; + sessionsCursor: string | null; + setAccountMenuOpen: Dispatch>; + setPendingSessionDeletion: Dispatch>; + setSessionMenuId: Dispatch>; + shareSession: (session: ChatSession) => void | Promise; + showArchivedSessions: boolean; + startNewChat: () => void | Promise; + toggleArchivedSession: (sessionId: string) => void; + toggleArchivedView: () => void | Promise; + togglePinnedSession: (sessionId: string) => void; + visibleSessions: readonly ChatSession[]; +}; + +export function useHomeShellRegistration(params: HomeShellRegistrationParams) { + const { + account, + accountMenuOpen, + accountTrigger, + activeSessionId, + cancellationPending, + chartLibrary, + creatingSession, + hydrated, + loadMoreSessions, + modalOpen, + modelCatalog, + openAccountDialog, + pendingSessionId, + profile, + rectificationErrorMessage, + rectificationErrorSessionId, + rectificationOpeningSessionId, + rectificationSurfaceOpen, + registerShellControls, + renameSession, + selectSession, + sessionMenuId, + sessions, + sessionsCursor, + setAccountMenuOpen, + setPendingSessionDeletion, + setSessionMenuId, + shareSession, + showArchivedSessions, + startNewChat, + toggleArchivedSession, + toggleArchivedView, + togglePinnedSession, + visibleSessions, + } = params; + + useEffect(() => { + if (!hydrated || !account) { + registerShellControls(null); + return; + } + const name = profile.name.trim(); + const email = account.user.email || "尚未读取邮箱"; + registerShellControls({ + escapeBlocked: accountMenuOpen || modalOpen, + activeSessionId: activeSessionId ?? null, + openingSessionId: rectificationOpeningSessionId, + openErrorSessionId: rectificationErrorSessionId, + openErrorMessage: rectificationErrorSessionId ? rectificationErrorMessage : "", + insetClassName: `chat-panel${rectificationSurfaceOpen ? " is-rectification" : ""}`, + insetInert: modalOpen, + sidebarAccount: { + name: name || account.user.email || "账户", + email, + credits: account.credits, + avatar: account.avatar, + initial: name.slice(0, 1) || account.user.email?.slice(0, 1).toUpperCase() || "你", + }, + sidebarSessions: visibleSessions.map((session) => toSidebarSessionRow(session, chartLibrary)), + controls: { + newChatDisabled: !hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending, + creatingSession, + accountMenuOpen, + accountTriggerRef: accountTrigger, + sessionControls: { + archivedCount: sessions.filter((session) => session.archivedAt).length, + showingArchived: showArchivedSessions, + hasMore: Boolean(sessionsCursor), + onLoadMore: loadMoreSessions, + menuSessionId: sessionMenuId, + disabled: Boolean(pendingSessionId) || cancellationPending, + onToggleArchivedView: () => { void toggleArchivedView(); setSessionMenuId(null); }, + onMenuSessionChange: setSessionMenuId, + onTogglePinned: togglePinnedSession, + onRename: (sessionId) => { + const session = sessions.find((candidate) => candidate.id === sessionId); + if (session) void renameSession(session); + }, + onShare: (sessionId) => { + const session = sessions.find((candidate) => candidate.id === sessionId); + if (session) void shareSession(session); + }, + onToggleArchived: toggleArchivedSession, + onDelete: (sessionId) => { + const session = sessions.find((candidate) => candidate.id === sessionId); + if (session) setPendingSessionDeletion(session); + }, + }, + onAccountMenuOpenChange: setAccountMenuOpen, + onNewChat: () => { void startNewChat(); }, + onSelectSession: selectSession, + onOpenProfile: () => openAccountDialog("profile"), + onOpenChartLibrary: () => openAccountDialog("chart-library"), + onOpenGeneral: () => openAccountDialog("general"), + onOpenBilling: () => openAccountDialog("billing", { source: "account-menu" }), + onOpenLogout: () => openAccountDialog("logout"), + }, + }); + }, [ + account, accountMenuOpen, accountTrigger, activeSessionId, cancellationPending, + chartLibrary, creatingSession, hydrated, loadMoreSessions, modalOpen, modelCatalog, + openAccountDialog, pendingSessionId, profile.name, rectificationErrorMessage, + rectificationErrorSessionId, rectificationOpeningSessionId, rectificationSurfaceOpen, + registerShellControls, renameSession, selectSession, sessionMenuId, sessions, + sessionsCursor, setAccountMenuOpen, setPendingSessionDeletion, setSessionMenuId, + shareSession, showArchivedSessions, startNewChat, toggleArchivedSession, + toggleArchivedView, togglePinnedSession, visibleSessions, + ]); + + useEffect(() => () => registerShellControls(null), [registerShellControls]); +} diff --git a/frontend/src/hooks/use-rectification-surface.ts b/frontend/src/hooks/use-rectification-surface.ts index f8cc3678..99e623f3 100644 --- a/frontend/src/hooks/use-rectification-surface.ts +++ b/frontend/src/hooks/use-rectification-surface.ts @@ -277,6 +277,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { theme: "general", modelId: modelCatalog.defaultModelId ?? "", messages: [], + createdAt: existing?.createdAt, updatedAt: openIdentity.updatedAt, sessionType: "birth_time_rectification", rectificationCaseId: opened.caseId, diff --git a/frontend/src/hooks/use-session-list.ts b/frontend/src/hooks/use-session-list.ts new file mode 100644 index 00000000..9ac76a31 --- /dev/null +++ b/frontend/src/hooks/use-session-list.ts @@ -0,0 +1 @@ +export { SessionListProvider, useSessionList, sessionListSidebarModel } from "@/lib/session-list-context"; diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index da129656..440217e3 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -14,7 +14,6 @@ import { import { consultationReportMarkdown } from "@/lib/consultation-report-export"; import { fallbackSessionId } from "@/lib/rectification-session-composer-guard"; import { sortSessions } from "@/lib/session-groups"; -import { invalidateSidebarCache } from "@/lib/sidebar-data-cache"; import { clearBirthTimeConsultationConsent, type BirthTimeConsultationConsentState, @@ -30,6 +29,7 @@ import { patchSessionModel, readSessions, } from "@/lib/home-cloud-sync"; +import { findReusableEmptyConsultation } from "@/lib/session-list-filter"; import { chartSnapshotForSession } from "@/lib/home-profile"; import { beginSessionPageLoad, mergeSessionPage } from "@/lib/session-groups"; import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint"; @@ -218,12 +218,6 @@ export function useSessionManagement(params: SessionManagementParams) { updateSession(session.id, () => nextSession); try { await persistSession(nextSession); - /* The secondary pages read their list from a 60s module cache; without - this, walking to /chart right after a rename still shows the old - title. Invalidate rather than write through: `Home()` owns the richer - ChatSession shape, and one refetch is cheaper than keeping two - representations in step. */ - invalidateSidebarCache(); } catch (caught) { setComposerNotice(caught instanceof Error ? caught.message : "重命名同步失败"); } @@ -242,7 +236,6 @@ export function useSessionManagement(params: SessionManagementParams) { const response = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" }); const payload = await response.json().catch(() => null) as { error?: string } | null; if (!response.ok) throw new Error(payload?.error || "删除聊天记录失败"); - invalidateSidebarCache(); } catch (caught) { setSessions(previousSessions); setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败"); @@ -254,7 +247,7 @@ export function useSessionManagement(params: SessionManagementParams) { if (!session) return; const nextPinned = !session.pinned; updateSession(sessionId, (current) => ({ ...current, pinned: nextPinned })); - void writeChatSession(sessionId, { pinned: nextPinned }, "update").then(invalidateSidebarCache).catch((caught) => { + void writeChatSession(sessionId, { pinned: nextPinned }, "update").catch((caught) => { updateSession(sessionId, (current) => ({ ...current, pinned: session.pinned })); setComposerNotice(caught instanceof Error ? caught.message : "置顶同步失败"); }); @@ -271,7 +264,7 @@ export function useSessionManagement(params: SessionManagementParams) { activateFallbackSession(visibleSessions.filter((item) => item.id !== sessionId)); } setComposerNotice(restoring ? "已恢复到聊天记录。" : "已归档,可在左侧归档中恢复。"); - void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").then(invalidateSidebarCache).catch((caught) => { + void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").catch((caught) => { updateSession(sessionId, (current) => ({ ...current, archivedAt: session.archivedAt })); if (!restoring && previousActiveId === sessionId) { setActiveSessionId(previousActiveId); @@ -312,6 +305,19 @@ export function useSessionManagement(params: SessionManagementParams) { async function startNewChat(options?: { continuedFromSessionId?: string }): Promise { if (!account || !modelCatalog || creatingSession) return null; + if (!options?.continuedFromSessionId) { + const reusable = findReusableEmptyConsultation(sessions); + if (reusable) { + setActiveSessionId(reusable.id); + if (!uiPreview.current) writeSessionUrl(reusable.id, "push"); + setDraft(""); + setDraftTheme(null); + setDraftEntrypoint(null); + setComposerNotice(""); + setRequestError(null); + return reusable; + } + } const nextSession = { ...createSession(modelCatalog.defaultModelId), ...chartSnapshotForSession(activeChartId, chartLibrary, profile), @@ -335,7 +341,6 @@ export function useSessionManagement(params: SessionManagementParams) { ? { continuedFromSessionId: options.continuedFromSessionId } : undefined, ); - invalidateSidebarCache(); return nextSession; } catch (caught) { setSessions((current) => current.filter((session) => session.id !== nextSession.id)); diff --git a/frontend/src/hooks/use-sidebar-data.ts b/frontend/src/hooks/use-sidebar-data.ts deleted file mode 100644 index c280958c..00000000 --- a/frontend/src/hooks/use-sidebar-data.ts +++ /dev/null @@ -1,164 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; - -import type { SidebarAccount } from "@/components/app-sidebar"; -import type { SidebarSession } from "@/components/sidebar-session-row"; -import type { BeamAvatar } from "@/lib/beam-avatar"; -import { - invalidateSidebarCache, - readSidebarCache, - sidebarCacheIsFresh, - writeSidebarCache, -} from "@/lib/sidebar-data-cache"; - -/** - * Read-only nav data for the secondary pages (/chart, /ephemeris, /reports). - * - * The chat page's sidebar is fed by `Home()`'s hooks, which also own renaming, - * deleting, pinning and archiving — optimistic updates, confirmation dialogs and - * rollback included. Lifting that whole layer so four routes can share it is far - * more than these pages need: none of them offers session management. Two GETs - * are enough, and this hook never writes. - * - * It is called once, from the `(secondary)` layout, so moving between those - * four routes does not re-run it at all. The module cache behind it covers the - * other trip: leaving for `/` and coming back unmounts the layout, and without - * it that would mean two more requests inside the same minute. - */ - -export type SidebarDataState = { - readonly sessions: readonly SidebarSession[]; - readonly account: SidebarAccount | null; - /** True once both requests have settled, however they settled. */ - readonly settled: boolean; - /** The viewer is not signed in; the sidebar degrades to brand plus a login link. */ - readonly signedOut: boolean; -}; - -type SessionRow = { - id?: unknown; - title?: unknown; - pinned?: unknown; - archived_at?: unknown; - updated_at?: unknown; -}; - -type AccountBody = { - credits?: unknown; - user?: { id?: unknown; email?: unknown }; - profile?: { name?: unknown }; - avatar?: BeamAvatar | null; -}; - -const EMPTY: SidebarDataState = { sessions: [], account: null, settled: false, signedOut: false }; - -export function toSidebarSession(row: SessionRow): SidebarSession | null { - if (typeof row.id !== "string" || !row.id) return null; - const updatedAt = typeof row.updated_at === "string" ? Date.parse(row.updated_at) : Number.NaN; - return { - id: row.id, - title: typeof row.title === "string" && row.title.trim() ? row.title.trim() : "新对话", - pinned: row.pinned === true, - archived: typeof row.archived_at === "string" && row.archived_at !== "", - updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0, - }; -} - -export function toSidebarAccount(body: AccountBody | null): SidebarAccount { - const name = typeof body?.profile?.name === "string" ? body.profile.name.trim() : ""; - const email = typeof body?.user?.email === "string" ? body.user.email : ""; - return { - name: name || email || "账户", - email, - initial: name.slice(0, 1) || email.slice(0, 1).toUpperCase() || "你", - credits: typeof body?.credits === "number" ? body.credits : 0, - avatar: body?.avatar ?? null, - }; -} - -/** Which account the payload belongs to; the cache key. */ -export function sidebarAccountKey(body: AccountBody | null): string { - const id = typeof body?.user?.id === "string" ? body.user.id : ""; - const email = typeof body?.user?.email === "string" ? body.user.email : ""; - return id || email || "anonymous"; -} - -export function useSidebarData(): SidebarDataState { - const [state, setState] = useState(() => { - /* Read once, at mount: an entry from a previous visit renders in the first - frame rather than after a round trip. Empty on the server, and empty on a - real page load, which is what makes the first render match. */ - const cached = readSidebarCache(); - if (cached === null) return EMPTY; - return { sessions: cached.sessions, account: cached.account, settled: true, signedOut: false }; - }); - - useEffect(() => { - const cached = readSidebarCache(); - if (sidebarCacheIsFresh(cached, Date.now())) return; - - const controller = new AbortController(); - let cancelled = false; - - async function load() { - if (typeof fetch !== "function") { - setState((current) => ({ ...current, settled: true })); - return; - } - const [sessionResult, accountResult] = await Promise.allSettled([ - fetch("/api/sessions?limit=40", { signal: controller.signal }), - fetch("/api/account", { signal: controller.signal }), - ]); - if (cancelled) return; - - let sessions: readonly SidebarSession[] | null = null; - let account: SidebarAccount | null = null; - let accountBody: AccountBody | null = null; - let signedOut = false; - - if (sessionResult.status === "fulfilled" && sessionResult.value.ok) { - const body = await sessionResult.value.json().catch(() => null) as { sessions?: unknown } | null; - const rows = Array.isArray(body?.sessions) ? body.sessions as SessionRow[] : []; - sessions = rows.map(toSidebarSession).filter((item): item is SidebarSession => item !== null); - } else if (sessionResult.status === "fulfilled" && sessionResult.value.status === 401) { - signedOut = true; - } - - if (accountResult.status === "fulfilled" && accountResult.value.ok) { - accountBody = await accountResult.value.json().catch(() => null) as AccountBody | null; - account = toSidebarAccount(accountBody); - } else if (accountResult.status === "fulfilled" && accountResult.value.status === 401) { - signedOut = true; - } - - if (cancelled) return; - if (signedOut) { - /* A rejected session must not leave a list on screen, here or on the - next page this tab opens. */ - invalidateSidebarCache(); - setState({ sessions: [], account: null, settled: true, signedOut: true }); - return; - } - if (sessions !== null || account !== null) { - writeSidebarCache({ - accountId: sidebarAccountKey(accountBody), - sessions: sessions ?? [], - account, - fetchedAt: Date.now(), - }); - } - setState({ sessions: sessions ?? [], account, settled: true, signedOut: false }); - } - - void load().catch(() => { - if (!cancelled) setState((current) => ({ ...current, settled: true })); - }); - return () => { - cancelled = true; - controller.abort(); - }; - }, []); - - return state; -} diff --git a/frontend/src/lib/agent-reply.ts b/frontend/src/lib/agent-reply.ts index ce84c35f..b3b9476c 100644 --- a/frontend/src/lib/agent-reply.ts +++ b/frontend/src/lib/agent-reply.ts @@ -5,6 +5,7 @@ import { isGeneralDailyFortuneQuestion, isRectificationHandoffQuestion, } from "./consultation-entrypoint.ts"; +import { shanghaiDateParts } from "./session-shanghai-clock.ts"; export type ReplyTheme = ConsultationDomain; @@ -64,10 +65,14 @@ export type SessionTitleOptions = { readonly existingTitles?: readonly string[]; }; +const DATED_CATEGORY_PREFIX = /^(?:生时校正|今日节奏)\s*·\s*\d{1,2}月\d{1,2}日/; +const DATED_CATEGORY_SUFFIX = /^\d{1,2}月\d{1,2}日\s*·\s*(?:生时校正|今日节奏)(?:\s+\d{2}:\d{2})?$/; + export function isGenericSessionTitle(title: string): boolean { const text = title.replace(/\s+/g, " ").trim(); if (!text) return true; if (GENERIC_SESSION_TITLES.has(text)) return true; + if (DATED_CATEGORY_PREFIX.test(text) || DATED_CATEGORY_SUFFIX.test(text)) return true; return /^(?:深入看今日|从今日问起|查看今日运势|生时校正|再次校正)/.test(text); } @@ -76,15 +81,9 @@ function clipTitle(value: string, maxChars = 14): string { return characters.length > maxChars ? `${characters.slice(0, maxChars).join("")}…` : value; } -function datedSessionTitle(at: Date, suffix: string): string { - return `${at.getMonth() + 1}月${at.getDate()}日 · ${suffix}`; -} - -function uniquifySessionTitle(title: string, existingTitles: readonly string[], at: Date): string { - if (!existingTitles.includes(title)) return title; - const hours = String(at.getHours()).padStart(2, "0"); - const minutes = String(at.getMinutes()).padStart(2, "0"); - return `${title} ${hours}:${minutes}`; +function datedSessionTitle(at: Date, category: string): string { + const { month, day } = shanghaiDateParts(at); + return `${category} · ${month}月${day}日`; } export function resolveSessionTitle( @@ -93,21 +92,20 @@ export function resolveSessionTitle( options: SessionTitleOptions = {}, ): string { const at = options.at ?? new Date(); // new sessions only; see SessionTitleOptions.at - const existingTitles = options.existingTitles ?? []; if (modelTitle && !isGenericSessionTitle(modelTitle)) { - return uniquifySessionTitle(clipTitle(modelTitle), existingTitles, at); + return clipTitle(modelTitle); } if (options.entrypoint === "daily_starlanguage" || isGeneralDailyFortuneQuestion(question)) { - return uniquifySessionTitle(datedSessionTitle(at, "今日节奏"), existingTitles, at); + return datedSessionTitle(at, "今日节奏"); } if (options.entrypoint === "birth_time_rectification" || isRectificationHandoffQuestion(question)) { - return uniquifySessionTitle(datedSessionTitle(at, "生时校正"), existingTitles, at); + return datedSessionTitle(at, "生时校正"); } const normalized = question.replace(/\s+/g, " ").trim().replace(/[??!!。.,,;;::]+$/u, ""); if (!normalized) return "新对话"; if (options.theme && options.theme !== "general") { const label = consultationDomainDefinition(options.theme).label; - return uniquifySessionTitle(`${label} · ${clipTitle(normalized, 10)}`, existingTitles, at); + return `${label} · ${clipTitle(normalized, 10)}`; } - return uniquifySessionTitle(clipTitle(normalized), existingTitles, at); + return clipTitle(normalized); } diff --git a/frontend/src/lib/db/local-postgres-client-core.ts b/frontend/src/lib/db/local-postgres-client-core.ts index 7c91f437..f21894ae 100644 --- a/frontend/src/lib/db/local-postgres-client-core.ts +++ b/frontend/src/lib/db/local-postgres-client-core.ts @@ -10,6 +10,8 @@ type QueryResult = Readonly<{ count?: number | null; }>; +type CmpOp = "eq" | "neq" | "lt" | "gt" | "lte" | "gte"; + type Filter = | Readonly<{ kind: "eq"; column: string; value: unknown }> | Readonly<{ kind: "neq"; column: string; value: unknown }> @@ -18,7 +20,12 @@ type Filter = | Readonly<{ kind: "like"; column: string; value: unknown }> | Readonly<{ kind: "in"; column: string; value: readonly unknown[] }> | Readonly<{ kind: "is"; column: string; value: unknown }> - | Readonly<{ kind: "notContains"; column: string; value: unknown }>; + | Readonly<{ kind: "notContains"; column: string; value: unknown }> + | Readonly<{ kind: "or"; expression: string }>; + +export type PostgrestOrNode = + | Readonly<{ kind: "cmp"; column: string; op: CmpOp; value: unknown }> + | Readonly<{ kind: "and"; nodes: readonly PostgrestOrNode[] }>; type Mutation = | Readonly<{ kind: "insert"; rows: readonly Record[] }> @@ -48,6 +55,93 @@ export function formatOrderClause( .join(", ")}`; } +const SQL_CMP: Record = { + eq: "=", + neq: "<>", + lt: "<", + gt: ">", + lte: "<=", + gte: ">=", +}; + +function splitTopLevel(expression: string): string[] { + const parts: string[] = []; + let depth = 0; + let inString = false; + let start = 0; + for (let index = 0; index < expression.length; index += 1) { + const char = expression[index]; + if (char === "\"" && expression[index - 1] !== "\\") inString = !inString; + if (inString) continue; + if (char === "(") depth += 1; + else if (char === ")") depth -= 1; + else if (char === "," && depth === 0) { + parts.push(expression.slice(start, index)); + start = index + 1; + } + } + parts.push(expression.slice(start)); + return parts.map((part) => part.trim()).filter(Boolean); +} + +function parsePostgrestValue(raw: string): unknown { + const trimmed = raw.trim(); + if (trimmed === "true") return true; + if (trimmed === "false") return false; + if (trimmed === "null") return null; + if (trimmed.startsWith("\"") || trimmed.startsWith("[") || trimmed.startsWith("{")) { + return JSON.parse(trimmed) as unknown; + } + return trimmed; +} + +function parsePostgrestTerm(term: string): PostgrestOrNode { + const andMatch = /^and\(([\s\S]*)\)$/.exec(term); + if (andMatch?.[1] != null) { + return { kind: "and", nodes: splitTopLevel(andMatch[1]).map(parsePostgrestTerm) }; + } + const cmp = /^([a-z_][a-z0-9_]*)\.(eq|neq|lt|gt|lte|gte)\.([\s\S]*)$/.exec(term); + if (!cmp) throw new Error(`unsupported or filter: ${term}`); + return { + kind: "cmp", + column: cmp[1] ?? "", + op: (cmp[2] ?? "eq") as CmpOp, + value: parsePostgrestValue(cmp[3] ?? ""), + }; +} + +export function parsePostgrestOr(expression: string): PostgrestOrNode[] { + return splitTopLevel(expression).map(parsePostgrestTerm); +} + +function compilePostgrestNode( + node: PostgrestOrNode, + parameters: unknown[], + types: Map, +): string { + if (node.kind === "and") { + return `(${node.nodes.map((child) => compilePostgrestNode(child, parameters, types)).join(" and ")})`; + } + identifier(node.column); + parameters.push(databaseValue(types.get(node.column), node.value)); + return `${identifier(node.column)} ${SQL_CMP[node.op]} $${parameters.length}`; +} + +export function compilePostgrestOr( + expression: string, + parameters: unknown[], + types: Map, +): string { + const nodes = parsePostgrestOr(expression); + if (nodes.length === 0) return "true"; + if (nodes.length === 1) { + const only = nodes[0]; + if (!only) return "true"; + return compilePostgrestNode(only, parameters, types); + } + return `(${nodes.map((node) => compilePostgrestNode(node, parameters, types)).join(" or ")})`; +} + export function upsertConflictColumns(options?: { onConflict?: string }): string[] { return (options?.onConflict ?? "") .split(",") @@ -290,11 +384,21 @@ class LocalPostgresQueryBuilder implements PromiseLike { not(column: string, operator: string, value: unknown) { identifier(column); + if (operator === "eq") { + this.filters.push({ kind: "neq", column, value }); + return this; + } if (operator !== "cs") throw new Error("unsupported not filter"); this.filters.push({ kind: "notContains", column, value }); return this; } + or(expression: string) { + if (!expression.trim()) throw new Error("empty or filter"); + this.filters.push({ kind: "or", expression }); + return this; + } + order(column: string, options: { ascending?: boolean } = {}) { identifier(column); this.ordering.push({ column, ascending: options.ascending !== false }); @@ -342,6 +446,9 @@ class LocalPostgresQueryBuilder implements PromiseLike { ): string { if (this.filters.length === 0) return ""; const parts = this.filters.map((filter) => { + if (filter.kind === "or") { + return compilePostgrestOr(filter.expression, parameters, types); + } const column = identifier(filter.column); if (filter.kind === "is") { if (filter.value === null) return `${column} is null`; diff --git a/frontend/src/lib/home-cloud-sync.ts b/frontend/src/lib/home-cloud-sync.ts index f9e9f062..3f775d38 100644 --- a/frontend/src/lib/home-cloud-sync.ts +++ b/frontend/src/lib/home-cloud-sync.ts @@ -79,6 +79,7 @@ export function createSession( theme: "general", modelId, messages: [], + createdAt: timestamp(), updatedAt: timestamp(), sessionType, rectificationCaseId: null, @@ -247,18 +248,30 @@ export async function fetchDailyStarlanguage(signal: AbortSignal): Promise record.role !== "self"); const others = library.filter((record) => record.role !== "self"); diff --git a/frontend/src/lib/home-types.ts b/frontend/src/lib/home-types.ts index 60f46b37..bb619b2c 100644 --- a/frontend/src/lib/home-types.ts +++ b/frontend/src/lib/home-types.ts @@ -79,6 +79,7 @@ export type ChatSession = { theme: Theme; modelId: string; messages: Message[]; + createdAt?: number; updatedAt: number; sessionType: ChatSessionType; rectificationCaseId: string | null; diff --git a/frontend/src/lib/session-list-context.tsx b/frontend/src/lib/session-list-context.tsx new file mode 100644 index 00000000..cab218d0 --- /dev/null +++ b/frontend/src/lib/session-list-context.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from "react"; + +import type { AppSidebarControls, SidebarAccount } from "@/components/app-sidebar"; +import type { SidebarSession } from "@/components/sidebar-session-row"; +import { readSessions } from "@/lib/home-cloud-sync"; +import type { Account, ChatSession } from "@/lib/home-types"; +import { SESSION_PAGE_SIZE } from "@/lib/session-cursor"; +import { isListedSidebarSession } from "@/lib/session-list-filter"; +import { toSidebarSessionRow } from "@/lib/session-sidebar-row"; + +export type SessionListBoot = { + readonly sessions: ChatSession[]; + readonly rawRows: unknown; + readonly draftRow: unknown; + readonly cursor: string | null; + readonly account: Account | null; + readonly signedOut: boolean; +}; + +export type ShellRegistration = { + readonly controls: AppSidebarControls; + readonly escapeBlocked: boolean; + readonly activeSessionId: string | null; + readonly openingSessionId: string | null; + readonly openErrorSessionId: string | null; + readonly openErrorMessage: string; + readonly insetClassName: string; + readonly insetInert: boolean; + readonly sidebarSessions?: readonly SidebarSession[]; + readonly sidebarAccount?: SidebarAccount; +}; + +export type SessionListContextValue = { + sessions: ChatSession[]; + setSessions: Dispatch>; + sessionsCursor: string | null; + setSessionsCursor: Dispatch>; + account: Account | null; + setAccount: Dispatch>; + signedOut: boolean; + settled: boolean; + ready: Promise; + boot: () => SessionListBoot | null; + registerShellControls: (registration: ShellRegistration | null) => void; + registration: ShellRegistration | null; +}; + +const SessionListContext = createContext(null); + +function createReadyGate(): { promise: Promise; resolve: () => void } { + let settle = () => {}; + const promise = new Promise((resolve) => { + settle = resolve; + }); + return { promise, resolve: () => settle() }; +} + +function toSidebarAccount(account: Account | null): SidebarAccount | null { + if (!account) return null; + const profileName = account.profile && typeof account.profile === "object" && "name" in account.profile + && typeof account.profile.name === "string" ? account.profile.name.trim() : ""; + const name = profileName; + const email = account.user.email || ""; + return { + name: name || email || "账户", + email, + initial: name.slice(0, 1) || email.slice(0, 1).toUpperCase() || "你", + credits: account.credits, + avatar: account.avatar, + }; +} + +function sessionsToSidebarRows(sessions: readonly ChatSession[]): SidebarSession[] { + return sessions.filter(isListedSidebarSession).map((session) => toSidebarSessionRow(session)); +} + +async function loadSessionList(signal: AbortSignal): Promise { + const [sessionResponse, accountResponse] = await Promise.all([ + fetch(`/api/sessions?limit=${SESSION_PAGE_SIZE}`, { signal, cache: "no-store" }), + fetch("/api/account", { signal, cache: "no-store" }), + ]); + if (sessionResponse.status === 401 || accountResponse.status === 401) { + return { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: true }; + } + const sessionPayload = await sessionResponse.json().catch(() => null) as { + sessions?: unknown; + nextCursor?: unknown; + draft?: unknown; + } | null; + const accountPayload = await accountResponse.json().catch(() => null); + if (!sessionResponse.ok || !accountResponse.ok) { + return { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: false }; + } + const rawRows = Array.isArray(sessionPayload?.sessions) ? sessionPayload.sessions : []; + const parsed = readSessions(rawRows, null); + const cursor = typeof sessionPayload?.nextCursor === "string" ? sessionPayload.nextCursor : null; + return { + sessions: parsed.sessions, + rawRows, + draftRow: sessionPayload?.draft ?? null, + cursor, + account: accountPayload as Account, + signedOut: false, + }; +} + +export function SessionListProvider({ children }: { children: ReactNode }) { + const [sessions, setSessions] = useState([]); + const [sessionsCursor, setSessionsCursor] = useState(null); + const [account, setAccount] = useState(null); + const [signedOut, setSignedOut] = useState(false); + const [settled, setSettled] = useState(false); + const [registration, setRegistration] = useState(null); + const bootRef = useRef(null); + const [readyPack] = useState(createReadyGate); + + useEffect(() => { + const controller = new AbortController(); + void loadSessionList(controller.signal) + .then((boot) => { + if (controller.signal.aborted) return; + bootRef.current = boot; + setSessions(boot.sessions); + setSessionsCursor(boot.cursor); + setAccount(boot.account); + setSignedOut(boot.signedOut); + setSettled(true); + readyPack.resolve(); + }) + .catch(() => { + if (controller.signal.aborted) return; + bootRef.current = { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: false }; + setSettled(true); + readyPack.resolve(); + }); + return () => controller.abort(); + }, [readyPack]); + + const registerShellControls = useCallback((next: ShellRegistration | null) => { + setRegistration(next); + }, []); + + const boot = useCallback(() => bootRef.current, []); + + const value = useMemo(() => ({ + sessions, + setSessions, + sessionsCursor, + setSessionsCursor, + account, + setAccount, + signedOut, + settled, + ready: readyPack.promise, + boot, + registerShellControls, + registration, + }), [ + account, + boot, + readyPack.promise, + registerShellControls, + registration, + sessions, + sessionsCursor, + settled, + signedOut, + ]); + + return {children}; +} + +export function useSessionList(): SessionListContextValue { + const value = useContext(SessionListContext); + if (!value) { + throw new Error("useSessionList must be used inside SessionListProvider"); + } + return value; +} + +export function sessionListSidebarModel(list: SessionListContextValue): { + sessions: readonly SidebarSession[]; + account: SidebarAccount | null; +} { + const registered = list.registration; + return { + sessions: registered?.sidebarSessions ?? sessionsToSidebarRows(list.sessions), + account: registered?.sidebarAccount ?? toSidebarAccount(list.account), + }; +} diff --git a/frontend/src/lib/session-list-filter.ts b/frontend/src/lib/session-list-filter.ts new file mode 100644 index 00000000..a8472148 --- /dev/null +++ b/frontend/src/lib/session-list-filter.ts @@ -0,0 +1,19 @@ +import type { ChatSession } from "@/lib/home-types"; + +export function findReusableEmptyConsultation( + sessions: readonly ChatSession[], +): ChatSession | undefined { + return sessions.find((session) => ( + session.sessionType === "consultation" + && !session.archivedAt + && session.messagesHydrated + && session.messages.length === 0 + )); +} + +export function isListedSidebarSession(session: ChatSession): boolean { + if (session.archivedAt) return false; + if (session.sessionType === "birth_time_rectification") return true; + if (!session.messagesHydrated) return true; + return session.messages.length > 0; +} diff --git a/frontend/src/lib/session-shanghai-clock.ts b/frontend/src/lib/session-shanghai-clock.ts new file mode 100644 index 00000000..78d4ccbb --- /dev/null +++ b/frontend/src/lib/session-shanghai-clock.ts @@ -0,0 +1,41 @@ +/** Product UI dates for session titles and subtitles are wall-clock in China. */ + +export const SESSION_CLOCK_TIMEZONE = "Asia/Shanghai"; + +function part( + parts: Intl.DateTimeFormatPart[], + type: Intl.DateTimeFormatPartTypes, +): string { + return parts.find((item) => item.type === type)?.value ?? ""; +} + +export function shanghaiDateParts(at: Date, timeZone = SESSION_CLOCK_TIMEZONE): { + month: number; + day: number; +} { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + month: "numeric", + day: "numeric", + }).formatToParts(at); + return { + month: Number(part(parts, "month")), + day: Number(part(parts, "day")), + }; +} + +export function shanghaiDateTimeLabel(at: Date, timeZone = SESSION_CLOCK_TIMEZONE): string { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(at); + const month = Number(part(parts, "month")); + const day = Number(part(parts, "day")); + const hour = part(parts, "hour").padStart(2, "0"); + const minute = part(parts, "minute").padStart(2, "0"); + return `${month}月${day}日 ${hour}:${minute}`; +} diff --git a/frontend/src/lib/session-sidebar-row.ts b/frontend/src/lib/session-sidebar-row.ts new file mode 100644 index 00000000..843dcf5f --- /dev/null +++ b/frontend/src/lib/session-sidebar-row.ts @@ -0,0 +1,36 @@ +import type { SidebarSession } from "@/components/sidebar-session-row"; +import type { ChartLibraryRecord, ChatSession } from "@/lib/home-types"; +import { sessionChartLabel } from "@/lib/home-profile"; +import { shanghaiDateTimeLabel } from "@/lib/session-shanghai-clock"; + +export function sessionSidebarTitle(session: ChatSession) { + return session.title?.trim() || "新对话"; +} + +export function sessionSidebarSubtitle( + session: ChatSession, + library: readonly ChartLibraryRecord[] = [], +): string { + const created = session.createdAt || session.updatedAt; + const clock = Number.isFinite(created) ? shanghaiDateTimeLabel(new Date(created)) : ""; + const parts: string[] = []; + if (clock) parts.push(clock); + if (session.chartProfileRole && session.chartProfileRole !== "self" && session.chartProfileId) { + parts.push(sessionChartLabel(session, library)); + } + return parts.join(" · "); +} + +export function toSidebarSessionRow( + session: ChatSession, + library: readonly ChartLibraryRecord[] = [], +): SidebarSession { + return { + id: session.id, + title: sessionSidebarTitle(session, library), + subtitle: sessionSidebarSubtitle(session, library) || null, + pinned: session.pinned, + archived: Boolean(session.archivedAt), + updatedAt: session.updatedAt, + }; +} diff --git a/frontend/src/lib/session-title.ts b/frontend/src/lib/session-title.ts index a589763f..8a522de1 100644 --- a/frontend/src/lib/session-title.ts +++ b/frontend/src/lib/session-title.ts @@ -10,7 +10,7 @@ function clipHan(value: string, maxChars: number): string { } const BIRTH_STAMP = /\d{4}年\d{1,2}月(?:\d{1,2}日)?|\d{1,2}:\d{2}/; -const DATED_ENTRY_TITLE = /^\d{1,2}月\d{1,2}日\s*·\s*(?:今日节奏|生时校正)$/; +const DATED_ENTRY_TITLE = /^(?:(?:生时校正|今日节奏)\s*·\s*\d{1,2}月\d{1,2}日|\d{1,2}月\d{1,2}日\s*·\s*(?:今日节奏|生时校正))(?:\s+\d{2}:\d{2})?$/; export function sanitizeSessionTitle(raw: string): string | null { if (/[\r\n]/.test(raw)) return null; diff --git a/frontend/src/lib/sidebar-data-cache.ts b/frontend/src/lib/sidebar-data-cache.ts deleted file mode 100644 index d61ba9d6..00000000 --- a/frontend/src/lib/sidebar-data-cache.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { SidebarAccount } from "@/components/app-sidebar"; -import type { SidebarSession } from "@/components/sidebar-session-row"; - -/** - * The session list and account the read-only sidebar shows, kept in memory for - * one tab. - * - * `/chart`, `/ephemeris` and `/reports` used to re-issue `GET /api/sessions` and - * `GET /api/account` on every arrival, because each page mounted its own shell. - * The shared `(secondary)` layout removes the per-page remount; this removes the - * repeat when the reader leaves for `/` and comes back. Nothing durable is - * written: a stale session list surviving a browser restart is worse than one - * fetch, and it would outlive a sign-out. - * - * Keyed by account id so a second account in the same tab never reads the - * first one's rows. The pointer is what makes a synchronous read possible: the - * account id only arrives with the payload, so the reader cannot name its own - * key before the first fetch has happened. - */ - -export const SIDEBAR_CACHE_TTL_MS = 60_000; - -export type SidebarCacheEntry = { - readonly accountId: string; - readonly sessions: readonly SidebarSession[]; - readonly account: SidebarAccount | null; - readonly fetchedAt: number; -}; - -const entries = new Map(); -let currentAccountId: string | null = null; - -export function readSidebarCache(): SidebarCacheEntry | null { - if (currentAccountId === null) return null; - return entries.get(currentAccountId) ?? null; -} - -export function writeSidebarCache(entry: SidebarCacheEntry): void { - entries.set(entry.accountId, entry); - currentAccountId = entry.accountId; -} - -/** True when the entry may be shown without going back to the network. */ -export function sidebarCacheIsFresh(entry: SidebarCacheEntry | null, now: number): boolean { - if (entry === null) return false; - const age = now - entry.fetchedAt; - return age >= 0 && age < SIDEBAR_CACHE_TTL_MS; -} - -/** - * Called from `/` after every session write — create, rename, delete, archive, - * pin — and on any 401. Renaming a session and walking to `/chart` has to show - * the new title, and a signed-out tab must not keep a list on screen. - */ -export function invalidateSidebarCache(): void { - entries.clear(); - currentAccountId = null; -} diff --git a/frontend/tests/account-dialog-overlay.test.ts b/frontend/tests/account-dialog-overlay.test.ts index 7d403bdf..2ce39809 100644 --- a/frontend/tests/account-dialog-overlay.test.ts +++ b/frontend/tests/account-dialog-overlay.test.ts @@ -57,7 +57,7 @@ test("a closed account overlay does not render profile or logout content", () => }); test("home does not write overlay epoch or chat actions during render", () => { - const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); const overlay = readFileSync(new URL("../src/components/account-dialog-overlay.tsx", import.meta.url), "utf8"); assert.match(overlay, /model: AccountOverlayModel \| null/); assert.doesNotMatch(overlay, /openEpoch/); diff --git a/frontend/tests/agent-reply.test.ts b/frontend/tests/agent-reply.test.ts index 234a79b9..1e14903b 100644 --- a/frontend/tests/agent-reply.test.ts +++ b/frontend/tests/agent-reply.test.ts @@ -59,18 +59,21 @@ test("general no-birth-time replies keep a question-specific session title", () }); test("daily and topic consultations get distinct dated or domain titles", () => { - const at = new Date(2026, 7, 22, 8, 36); + // 原值:8月22日 · 今日节奏;同名追加墙钟 08:36;8月22日 · 生时校正 + // 新值:今日节奏 · 8月22日;同名不再加 HH:MM;生时校正 · 8月22日 + // 原因:任务书 T4 类别在前,同名靠副标题区分 + const at = new Date(Date.UTC(2026, 7, 22, 0, 36)); assert.equal( resolveSessionTitle("深入看今日", undefined, { entrypoint: "daily_starlanguage", at }), - "8月22日 · 今日节奏", + "今日节奏 · 8月22日", ); assert.equal( resolveSessionTitle("深入看今日", undefined, { entrypoint: "daily_starlanguage", at, - existingTitles: ["8月22日 · 今日节奏"], + existingTitles: ["今日节奏 · 8月22日"], }), - "8月22日 · 今日节奏 08:36", + "今日节奏 · 8月22日", ); assert.equal( resolveSessionTitle("请帮我梳理目前的事业方向和下一步重点。", undefined, { theme: "career", at }), @@ -81,7 +84,7 @@ test("daily and topic consultations get distinct dated or domain titles", () => entrypoint: "birth_time_rectification", at, }), - "8月22日 · 生时校正", + "生时校正 · 8月22日", ); }); diff --git a/frontend/tests/beam-avatar.test.ts b/frontend/tests/beam-avatar.test.ts index 3e7c4f83..f16cff44 100644 --- a/frontend/tests/beam-avatar.test.ts +++ b/frontend/tests/beam-avatar.test.ts @@ -17,7 +17,7 @@ const accountRoute = readProjectFile("src/app/api/account/route.ts"); const avatarRoute = readProjectFile("src/app/api/account/avatar/route.ts"); const avatarComponent = readProjectFile("src/components/user-avatar.tsx"); const sidebar = readProjectFile("src/components/app-sidebar.tsx"); -const page = readProjectFile("src/app/page.tsx"); +const page = readProjectFile("src/app/(app)/page.tsx"); const profilePanel = readProjectFile("src/components/profile-panel.tsx"); const migration = readProjectFile("db/migrations/20260807010000_profile_beam_avatars.sql"); diff --git a/frontend/tests/billing-panel.test.ts b/frontend/tests/billing-panel.test.ts index 8e7717dd..e5fd583e 100644 --- a/frontend/tests/billing-panel.test.ts +++ b/frontend/tests/billing-panel.test.ts @@ -12,7 +12,7 @@ const panelSource = readProjectFile("src/components/billing-panel.tsx"); const billingSource = `${hookSource}\n${panelSource}`; const membershipLib = readProjectFile("src/lib/membership.ts"); const nextConfig = readProjectFile("next.config.ts"); -const pageSource = readProjectFile("src/app/page.tsx"); +const pageSource = readProjectFile("src/app/(app)/page.tsx"); const tabsSource = readProjectFile("src/components/ui/tabs.tsx"); function countMatches(source: string, pattern: RegExp) { diff --git a/frontend/tests/birth-time-guided-review-fixes.test.ts b/frontend/tests/birth-time-guided-review-fixes.test.ts index cfbb7423..9f6f3cbb 100644 --- a/frontend/tests/birth-time-guided-review-fixes.test.ts +++ b/frontend/tests/birth-time-guided-review-fixes.test.ts @@ -157,7 +157,7 @@ test("terminal CJK copy stays intact while homepage candidates remain unconfirme }); test("completed rectification transcript does not repeat the birth place turn", () => { - const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); assert.match( pageSource, diff --git a/frontend/tests/birth-time-rectification-contract.test.ts b/frontend/tests/birth-time-rectification-contract.test.ts index eaf2a67c..9b71ea51 100644 --- a/frontend/tests/birth-time-rectification-contract.test.ts +++ b/frontend/tests/birth-time-rectification-contract.test.ts @@ -17,7 +17,7 @@ const candidateSource = readFileSync( "utf8", ); const pageSource = readFileSync( - new URL("../src/app/page.tsx", import.meta.url), + new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8", ); const storeSource = readFileSync( diff --git a/frontend/tests/chart-library-session.test.ts b/frontend/tests/chart-library-session.test.ts index 7c0a22a3..87b8223a 100644 --- a/frontend/tests/chart-library-session.test.ts +++ b/frontend/tests/chart-library-session.test.ts @@ -6,7 +6,7 @@ import { chartLibraryOnCloudFailure, chartLibrarySessionBranch, } from "../src/lib/chart-library-session.ts"; -import { sessionSidebarSubtitle, sessionSidebarTitle } from "../src/lib/home-profile.ts"; +import { sessionSidebarSubtitle, sessionSidebarTitle, toSidebarSessionRow } from "../src/lib/session-sidebar-row.ts"; import { emptyProfile, type ChartLibraryRecord, type ChatSession } from "../src/lib/home-types.ts"; type RecordShape = { id: string; role: "self" | "other" }; @@ -57,6 +57,10 @@ test("a failed cloud read keeps only the profile-derived self chart", () => { }); test("sidebar titles drop the chart-name prefix and only subtitle others", () => { + // 原值:本人盘副标题 null;他人盘只有资料名 + // 新值:一律「M月D日 HH:MM」,他人盘再加「 · 盘主」 + // 原因:任务书 T4 副标题规则 + const createdAt = Date.parse("2026-09-14T04:14:00.000Z"); const selfRecord: ChartLibraryRecord = { id: "self", role: "self", @@ -71,7 +75,8 @@ test("sidebar titles drop the chart-name prefix and only subtitle others", () => theme: "career", modelId: "m", messages: [], - updatedAt: 1, + createdAt, + updatedAt: createdAt, sessionType: "consultation", rectificationCaseId: null, chartProfileId: "self", @@ -82,9 +87,9 @@ test("sidebar titles drop the chart-name prefix and only subtitle others", () => messagesHydrated: true, }; assert.equal(sessionSidebarTitle(base, library), "半年内换工作时机"); - assert.equal(sessionSidebarSubtitle(base, library), null); + assert.equal(sessionSidebarSubtitle(base, library), "9月14日 12:14"); const other = { ...base, chartProfileId: "other-1", chartProfileName: "对方", chartProfileRole: "other" as const }; - assert.equal(sessionSidebarSubtitle(other, library), "资料已删除 · 对方"); + assert.equal(sessionSidebarSubtitle(other, library), "9月14日 12:14 · 资料已删除 · 对方"); const liveOtherLibrary: ChartLibraryRecord[] = [{ ...selfRecord, id: "other-1", @@ -92,5 +97,13 @@ test("sidebar titles drop the chart-name prefix and only subtitle others", () => relationship: "partner", profile: { ...emptyProfile, name: "对方" }, }]; - assert.equal(sessionSidebarSubtitle(other, liveOtherLibrary), "对方"); + assert.equal(sessionSidebarSubtitle(other, liveOtherLibrary), "9月14日 12:14 · 对方"); + assert.deepEqual(toSidebarSessionRow(other, liveOtherLibrary), { + id: other.id, + title: "半年内换工作时机", + subtitle: "9月14日 12:14 · 对方", + pinned: false, + archived: false, + updatedAt: createdAt, + }); }); diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index 68e594b3..bcced104 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -72,7 +72,7 @@ async function okView(western: Record | null = null): Promise)` 新值:外面裹一层 `SidebarProvider` - 原因:次级页的 `SidebarProvider` 从每页各一份上移到 `app/(secondary)/layout.tsx` + 原因:次级页的 `SidebarProvider` 从每页各一份上移到 `app/(app)/layout.tsx` (TASK-sidebar-unify D2)。页面组件自己不再自带 provider,而 46px 顶栏里的 `SidebarTrigger` 仍要读它。断言主语一字未改,改的是测试挂载环境。 */ function withSidebarProvider(element: React.ReactElement) { @@ -236,7 +236,7 @@ test("the chart page shell is visible before the natal chart arrives", () => { // 新值:断言 46px 顶栏的侧栏触发器在场;侧栏本体改由本文件下一条 // 「(secondary) layout 挂一份只读侧栏」断言,读的是 layout 源码 // 原因:TASK-sidebar-unify D2 把 provider + 侧栏从每页各一份上移到 - // `app/(secondary)/layout.tsx`,页面组件的产物里本就不该再有侧栏。 + // `app/(app)/layout.tsx`,页面组件的产物里本就不该再有侧栏。 // 断言主语(这一页不靠一次性「返回对话」链接回去)没变,覆盖没有减少。 assert.match(markup, /data-sidebar="trigger"/); assert.doesNotMatch(markup, /返回对话/); @@ -264,23 +264,17 @@ test("a pending western tab uses inline waiting copy instead of a spinner", asyn assert.doesNotMatch(markup, /正在加载|InlineSpinner|skeleton/i); }); -test("the (secondary) layout mounts one read-only sidebar for all four routes", () => { - const layout = readFileSync(new URL("../src/app/(secondary)/layout.tsx", import.meta.url), "utf8"); - - // One provider, one sidebar, one inset — for /chart, /ephemeris, /reports and - // /reports/[reportId] together. Each page used to mount its own. - assert.match(layout, //); +test("the (app) layout mounts one sidebar for home and the four secondary routes", () => { + // 原值:`(secondary)/layout.tsx` 挂只读侧栏,`useSidebarData` 另拉一份列表 + // 新值:`(app)/layout.tsx` 一份 SessionListProvider + SidebarProvider,首页也在里面 + // 原因:TASK-session-list-single-source T2,列表只拉一次、侧栏不卸载 + const layout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8"); + assert.match(layout, //); + assert.match(layout, //); assert.match(layout, //); - assert.equal(layout.match(//g)?.length, 1); - - // Read-only: no `controls`, so no rename / archive / delete / account menu. - assert.doesNotMatch(layout, /controls=/); - assert.match(layout, /useSidebarData\(\)/); - - // The data hook never writes, and the pages never mount a second shell. - const hook = readFileSync(new URL("../src/hooks/use-sidebar-data.ts", import.meta.url), "utf8"); - assert.doesNotMatch(hook, /method:\s*"(?:POST|PATCH|PUT|DELETE)"/); - assert.match(hook, /fetch\("\/api\/sessions\?limit=40"/); - assert.match(hook, /fetch\("\/api\/account"/); + assert.equal(layout.match(/ { // panel through the default scroll-into-view; the transcript has its own // anchor and does not want the browser's. const insidePanel = [ - "src/app/page.tsx", + "src/app/(app)/page.tsx", "src/hooks/use-consultation-run.ts", "src/components/birth-time-choice-question.tsx", "src/components/birth-time-rectification.tsx", diff --git a/frontend/tests/chat-reading-load-home.test.ts b/frontend/tests/chat-reading-load-home.test.ts index 40c14de8..68a19aa3 100644 --- a/frontend/tests/chat-reading-load-home.test.ts +++ b/frontend/tests/chat-reading-load-home.test.ts @@ -4,7 +4,7 @@ import test from "node:test"; import { bootstrapPrepareSettled } from "../src/lib/home-bootstrap.ts"; -const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8"); const bootstrap = readFileSync(new URL("../src/lib/home-bootstrap.ts", import.meta.url), "utf8"); const entrypoint = readFileSync(new URL("../src/lib/consultation-entrypoint.ts", import.meta.url), "utf8"); diff --git a/frontend/tests/chat-session-authority.test.ts b/frontend/tests/chat-session-authority.test.ts index bc196527..d8a74f55 100644 --- a/frontend/tests/chat-session-authority.test.ts +++ b/frontend/tests/chat-session-authority.test.ts @@ -12,14 +12,27 @@ const sql = readFileSync( ); const sendSource = page.slice(page.indexOf(" async function send("), page.indexOf("\n\n consultationReplay.current")); +test("session list GET drops empty consultations and still returns a reusable draft", () => { + assert.match(listRoute, /\.not\("messages", "eq", \[\]\)/); + assert.match(listRoute, /\.eq\("session_type", "consultation"\)[\s\S]*\.eq\("messages", \[\]\)/); + assert.match(listRoute, /return NextResponse\.json\(\{ sessions, nextCursor, draft \}\)/); + assert.doesNotMatch(itemRoute, /\.not\("messages", "eq", \[\]\)/); +}); + test("session list GET omits messages while detail GET returns them", () => { // Former list/detail column strings ended at updated_at; pinned and archived_at // were added when those flags moved off localStorage. + // 原值:列表列到 updated_at,pinned,archived_at,不选 created_at + // 新值:加上 created_at,供侧栏副标题用会话创建时间 + // 原因:任务书 T4 副标题是 M月D日 HH:MM(创建时间,Asia/Shanghai) assert.match( listRoute, - /SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"/, + /SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at"/, ); - assert.doesNotMatch(listRoute, /select\(SESSION_LIST_COLUMNS\)[\s\S]*messages/); + // 原值:select(SESSION_LIST_COLUMNS) 之后不得出现 messages + // 新值:WHERE 用 .not("messages", "eq", []),SELECT 列仍不含 messages + // 原因:T3 空咨询过滤要看 messages,但不把正文带回列表 + assert.doesNotMatch(listRoute, /SESSION_LIST_COLUMNS = "[^"]*messages/); assert.doesNotMatch( listRoute, /SESSION_LIST_COLUMNS = "[^"]*context_summary/, @@ -27,7 +40,7 @@ test("session list GET omits messages while detail GET returns them", () => { assert.match(itemRoute, /export async function GET/); assert.match( itemRoute, - /sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"/, + /sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at"/, ); assert.match(page, /async function fetchSessionDetail\(/); assert.match(page, /async function ensureSessionMessages\(/); diff --git a/frontend/tests/composer-ime-contract.test.ts b/frontend/tests/composer-ime-contract.test.ts index dc62c6e8..e47da1e9 100644 --- a/frontend/tests/composer-ime-contract.test.ts +++ b/frontend/tests/composer-ime-contract.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); const rectification = readFileSync( new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8", diff --git a/frontend/tests/consultation-session-capacity.test.ts b/frontend/tests/consultation-session-capacity.test.ts index 0aed1c76..b0bd42a3 100644 --- a/frontend/tests/consultation-session-capacity.test.ts +++ b/frontend/tests/consultation-session-capacity.test.ts @@ -31,8 +31,11 @@ const postgresFixture = readFileSync( "utf8", ); +// 原值:列到 updated_at,pinned,archived_at +// 新值:加上 created_at +// 原因:任务书 T4 侧栏副标题要用创建时间 const SESSION_SELECT = - "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"; + "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at"; function functionBody(source: string): string { const match = source.match(/as \$\$\r?\n([\s\S]*?)\r?\n\$\$;/); diff --git a/frontend/tests/epay-settings.test.ts b/frontend/tests/epay-settings.test.ts index 0e571c6f..42ba326a 100644 --- a/frontend/tests/epay-settings.test.ts +++ b/frontend/tests/epay-settings.test.ts @@ -17,7 +17,7 @@ const availability = readFileSync(new URL("src/lib/epay/availability.ts", root), const packagesRoute = readFileSync(new URL("src/app/api/payment/packages/route.ts", root), "utf8"); const testRoute = readFileSync(new URL("src/app/api/admin/epay-settings/test/route.ts", root), "utf8"); const gatewayPolicy = readFileSync(new URL("src/lib/epay/gateway-policy.ts", root), "utf8"); -const page = readFileSync(new URL("src/app/page.tsx", root), "utf8"); +const page = readFileSync(new URL("src/app/(app)/page.tsx", root), "utf8"); const billingHook = readFileSync(new URL("src/hooks/use-billing-panel.ts", root), "utf8"); const billingPanel = readFileSync(new URL("src/components/billing-panel.tsx", root), "utf8"); const billingSource = `${billingHook}\n${billingPanel}`; diff --git a/frontend/tests/ephemeris-page.test.tsx b/frontend/tests/ephemeris-page.test.tsx index c8896dea..9be61215 100644 --- a/frontend/tests/ephemeris-page.test.tsx +++ b/frontend/tests/ephemeris-page.test.tsx @@ -19,10 +19,10 @@ Object.assign(globalThis, { React }); const pageSource = readFileSync(new URL("../src/components/ephemeris/ephemeris-page.tsx", import.meta.url), "utf8"); // 原值:`../src/app/ephemeris/page.tsx` -// 新值:`../src/app/(secondary)/ephemeris/page.tsx` +// 新值:`../src/app/(app)/ephemeris/page.tsx` // 原因:四个次级路由移进 `(secondary)` 路由组共享一份外壳(TASK-sidebar-unify D2)。 // 路由组括号不进 URL,`/ephemeris` 一字未改;这里改的只是源码位置。 -const routePage = readFileSync(new URL("../src/app/(secondary)/ephemeris/page.tsx", import.meta.url), "utf8"); +const routePage = readFileSync(new URL("../src/app/(app)/ephemeris/page.tsx", import.meta.url), "utf8"); const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const panchangaGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-panchanga-range.golden.json", import.meta.url), "utf8")); const natalGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-natal.golden.json", import.meta.url), "utf8")); @@ -59,7 +59,7 @@ function payloadFor(date: "2026-09-14" | "2026-09-15", events: EphemerisOkRespon /* 原值:直接 `renderToStaticMarkup()` 新值:外面裹一层 `SidebarProvider` - 原因:次级页的 `SidebarProvider` 从每页各一份上移到 `app/(secondary)/layout.tsx` + 原因:次级页的 `SidebarProvider` 从每页各一份上移到 `app/(app)/layout.tsx` (TASK-sidebar-unify D2)。页面组件自己不再自带 provider,而 46px 顶栏里的 `SidebarTrigger` 仍要读它。断言主语一字未改,改的是测试挂载环境。 */ function withSidebarProvider(element: React.ReactElement) { @@ -80,7 +80,7 @@ function render(payload: EphemerisOkResponse | null, date = payload?.date ?? "20 test("the ephemeris page is its own route and never grows page.tsx", () => { assert.match(routePage, /from "@\/components\/ephemeris\/ephemeris-page"/); - assert.doesNotMatch(readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"), /ephemeris-page/); + assert.doesNotMatch(readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"), /ephemeris-page/); // 原值: assert.doesNotMatch(app-sidebar.tsx, /ephemeris/) // 新值: 本单文件归属只锁 page.tsx / 本 route,不再锁 sidebar // 原因: TASK-chart-page-20260915 任务 3 要求 sidebar 同时加「星盘」「星历」入口;文件归属「本单不改 sidebar」不是产品事实「sidebar 不得出现 ephemeris」(BUG-711) diff --git a/frontend/tests/home-bootstrap-reveal.test.ts b/frontend/tests/home-bootstrap-reveal.test.ts index d768dce3..387f8497 100644 --- a/frontend/tests/home-bootstrap-reveal.test.ts +++ b/frontend/tests/home-bootstrap-reveal.test.ts @@ -20,7 +20,7 @@ import { homeSurface } from "./home-surface.ts"; // answer keeps a live marker, because that is content being generated, not data // being loaded. -const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8"); const globalsCss = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const staleClientRecovery = readFileSync(new URL("../src/components/stale-client-recovery.tsx", import.meta.url), "utf8"); diff --git a/frontend/tests/home-shell-growth-contract.test.ts b/frontend/tests/home-shell-growth-contract.test.ts index e390e2dc..184b9c83 100644 --- a/frontend/tests/home-shell-growth-contract.test.ts +++ b/frontend/tests/home-shell-growth-contract.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); // Measured 2026-09-16 after home-state-lowering batch 2 on origin/staging @ 37e6c519. // Line count: (pageSource.match(/\n/g) ?? []).length, same as `wc -l` = 1846. diff --git a/frontend/tests/home-surface.ts b/frontend/tests/home-surface.ts index 77c6d27e..58485b7b 100644 --- a/frontend/tests/home-surface.ts +++ b/frontend/tests/home-surface.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; const homeSurfaceFiles = [ - "../src/app/page.tsx", + "../src/app/(app)/page.tsx", "../src/lib/home-types.ts", "../src/components/onboarding-chat-message.tsx", "../src/lib/home-profile.ts", @@ -20,6 +20,10 @@ const optionalHomeHookFiles = [ "../src/hooks/use-profile-onboarding.ts", "../src/hooks/use-rectification-surface.ts", "../src/hooks/use-synastry.ts", + "../src/hooks/use-session-list.ts", + "../src/hooks/use-home-shell-registration.ts", + "../src/lib/session-list-context.tsx", + "../src/lib/session-list-filter.ts", ] as const; export const homeSurface = [ diff --git a/frontend/tests/local-postgres-or.test.ts b/frontend/tests/local-postgres-or.test.ts new file mode 100644 index 00000000..ee0eef5b --- /dev/null +++ b/frontend/tests/local-postgres-or.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { compilePostgrestOr } from "../src/lib/db/local-postgres-client-core.ts"; +import { sessionCursorFilter } from "../src/lib/session-cursor.ts"; + +const core = readFileSync(new URL("../src/lib/db/local-postgres-client-core.ts", import.meta.url), "utf8"); +const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8"); + +test("or() compiles a comma-separated PostgREST filter", () => { + const parameters: unknown[] = []; + const sql = compilePostgrestOr( + "session_type.neq.consultation,messages.neq.[]", + parameters, + new Map([ + ["session_type", "text"], + ["messages", "jsonb"], + ]), + ); + assert.equal(sql, '("session_type" <> $1 or "messages" <> $2)'); + assert.deepEqual(parameters, ["consultation", "[]"]); + assert.match(core, /or\(expression: string\)/); +}); + +test("or() compiles the session cursor filter with a nested and", () => { + const cursor = sessionCursorFilter({ + updatedAt: "2026-09-03T07:09:23Z", + id: "11111111-1111-4111-8111-111111111111", + }); + const parameters: unknown[] = []; + const sql = compilePostgrestOr( + cursor, + parameters, + new Map([ + ["updated_at", "timestamptz"], + ["id", "uuid"], + ]), + ); + assert.equal( + sql, + '("updated_at" < $1 or ("updated_at" = $2 and "id" < $3))', + ); + assert.deepEqual(parameters, [ + "2026-09-03T07:09:23Z", + "2026-09-03T07:09:23Z", + "11111111-1111-4111-8111-111111111111", + ]); + assert.match(listRoute, /pageQuery = pageQuery\.or\(sessionCursorFilter\(cursor\)\)/); +}); diff --git a/frontend/tests/parameter-freeze-panel.test.ts b/frontend/tests/parameter-freeze-panel.test.ts index 2af145d9..d60e6da3 100644 --- a/frontend/tests/parameter-freeze-panel.test.ts +++ b/frontend/tests/parameter-freeze-panel.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { existsSync, readFileSync } from "node:fs"; import test from "node:test"; -const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const panelPath = new URL("../src/components/parameter-freeze-panel.tsx", import.meta.url); diff --git a/frontend/tests/personal-report-entry.test.ts b/frontend/tests/personal-report-entry.test.ts index d250afc2..fc06b428 100644 --- a/frontend/tests/personal-report-entry.test.ts +++ b/frontend/tests/personal-report-entry.test.ts @@ -18,7 +18,7 @@ const componentSource = readFileSync( new URL("../src/components/personal-report/generate-personal-report-button.tsx", import.meta.url), "utf8", ); -const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); const sidebarSource = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8"); const reportCenterSource = readFileSync( new URL("../src/components/personal-report/personal-report-center.tsx", import.meta.url), diff --git a/frontend/tests/personal-report-view.test.ts b/frontend/tests/personal-report-view.test.ts index 878f3bec..e19d4963 100644 --- a/frontend/tests/personal-report-view.test.ts +++ b/frontend/tests/personal-report-view.test.ts @@ -449,7 +449,7 @@ test("the reader renders inside the app shell, in every phase", () => { // 原值:`SecondaryShell` 的 import 与 `` 的出现次数 // 新值:`SecondaryHeader` 的 import 与 `` 的出现次数 // 原因:TASK-sidebar-unify D2 把 provider + 侧栏 + inset 上移到 - // `app/(secondary)/layout.tsx`,`SecondaryShell` 拆剩 46px 顶栏并改名 + // `app/(app)/layout.tsx`,`SecondaryShell` 拆剩 46px 顶栏并改名 // `SecondaryHeader`。断言主语(每个阶段都在 app 外壳里、都恰好带一次外壳、 // 没有一个阶段返回裸 `
`)一字未改。 assert.match(pageSource, /import \{ SecondaryHeader \} from "@\/components\/secondary-header";/); diff --git a/frontend/tests/rectification-history-open-20260909.test.ts b/frontend/tests/rectification-history-open-20260909.test.ts index d5344eb9..34ae08ef 100644 --- a/frontend/tests/rectification-history-open-20260909.test.ts +++ b/frontend/tests/rectification-history-open-20260909.test.ts @@ -10,7 +10,7 @@ const sidebar = read("../src/components/app-sidebar.tsx"); const sidebarRow = read("../src/components/sidebar-session-row.tsx"); const starter = read("../src/components/starter-home.tsx"); const styles = read("../src/app/globals.css"); -const pageSource = read("../src/app/page.tsx"); +const pageSource = read("../src/app/(app)/page.tsx"); const hook = read("../src/hooks/use-rectification-surface.ts"); const sessions = read("../src/hooks/use-session-management.ts"); const caseService = read("../src/lib/rectification-agentic/v9/case-service.ts"); diff --git a/frontend/tests/rectification-lazy-loading.test.ts b/frontend/tests/rectification-lazy-loading.test.ts index 0f6bc75c..1ec025b2 100644 --- a/frontend/tests/rectification-lazy-loading.test.ts +++ b/frontend/tests/rectification-lazy-loading.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); test("loads birth-time rectification only when its onboarding stage is reached", () => { assert.match(page, /import dynamic from "next\/dynamic"/); diff --git a/frontend/tests/rectification-session-composer-guard.test.ts b/frontend/tests/rectification-session-composer-guard.test.ts index c8ff0825..5812d4c2 100644 --- a/frontend/tests/rectification-session-composer-guard.test.ts +++ b/frontend/tests/rectification-session-composer-guard.test.ts @@ -17,7 +17,7 @@ const read = (relativePath: string) => readFileSync(new URL(relativePath, import const run = read("../src/hooks/use-consultation-run.ts"); const sessions = read("../src/hooks/use-session-management.ts"); const consultRoute = read("../src/app/api/consult/route.ts"); -const pageSource = read("../src/app/page.tsx"); +const pageSource = read("../src/app/(app)/page.tsx"); const sendFn = run.slice(run.indexOf("async function send("), run.indexOf('await fetch("/api/consult"')); test("a rectification session blocks consult send and keeps the draft", () => { diff --git a/frontend/tests/session-conversation-layout.test.ts b/frontend/tests/session-conversation-layout.test.ts index d6d0982d..2f18d9a7 100644 --- a/frontend/tests/session-conversation-layout.test.ts +++ b/frontend/tests/session-conversation-layout.test.ts @@ -16,7 +16,7 @@ test("aligns the session transcript and composer to one readable column", () => }); test("keeps onboarding transcript and intake card on the same session column", () => { - const pageSource = readProjectFile("src/app/page.tsx"); + const pageSource = readProjectFile("src/app/(app)/page.tsx"); assert.match(pageSource, /\$\{!profileComplete \? " is-onboarding" : ""\}/); assert.match(globalStyles, /\.conversation\.is-onboarding \.welcome \{[\s\S]*width:\s*min\(calc\(var\(--session-column-width\) \+ \(var\(--session-column-gutter\) \* 2\)\), 100%\)/); assert.match(globalStyles, /\.conversation\.is-onboarding\.is-empty \.welcome \{[\s\S]*width:\s*min\(var\(--session-column-width\), 100%\)/); @@ -25,7 +25,7 @@ test("keeps onboarding transcript and intake card on the same session column", ( }); test("centers the starter home even when the conversation grid is not is-empty", () => { - const pageSource = readProjectFile("src/app/page.tsx"); + const pageSource = readProjectFile("src/app/(app)/page.tsx"); // 原值:空态 `.welcome` 与 `.starter-list` 都是 1040px // 新值:`.welcome` 收到 `--session-column-width`,`.starter-list` 已删 // 原因:空态输入框此前 1040px、会话态 760px,首条消息落地时输入框会当场变窄一截。 diff --git a/frontend/tests/session-list-filter.test.ts b/frontend/tests/session-list-filter.test.ts new file mode 100644 index 00000000..ae03424f --- /dev/null +++ b/frontend/tests/session-list-filter.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + findReusableEmptyConsultation, + isListedSidebarSession, +} from "../src/lib/session-list-filter.ts"; +import type { ChatSession } from "../src/lib/home-types.ts"; + +const management = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8"); +const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); + +function session(change: Partial): ChatSession { + return { + id: "11111111-1111-4111-8111-111111111111", + title: "新对话", + theme: "general", + modelId: "m", + messages: [], + updatedAt: 1, + sessionType: "consultation", + rectificationCaseId: null, + chartProfileId: null, + chartProfileName: null, + chartProfileRole: null, + pinned: false, + archivedAt: null, + messagesHydrated: true, + ...change, + }; +} + +test("an empty hydrated consultation can be reused instead of creating another", () => { + const empty = session({ id: "empty" }); + const listed = session({ + id: "listed", + messagesHydrated: false, + title: "半年内换工作时机", + }); + const rectification = session({ + id: "rect", + sessionType: "birth_time_rectification", + title: "生时校正 · 9月14日", + }); + assert.equal(findReusableEmptyConsultation([listed, empty, rectification])?.id, "empty"); + assert.equal(findReusableEmptyConsultation([listed, rectification]), undefined); + assert.equal(isListedSidebarSession(empty), false); + assert.equal(isListedSidebarSession(listed), true); + assert.equal(isListedSidebarSession(rectification), true); + assert.match(management, /findReusableEmptyConsultation\(sessions\)/); + assert.match(page, /readDraftConsultation\(listBoot\.draftRow/); +}); diff --git a/frontend/tests/session-list-provider.test.ts b/frontend/tests/session-list-provider.test.ts new file mode 100644 index 00000000..00c516ef --- /dev/null +++ b/frontend/tests/session-list-provider.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const provider = readFileSync(new URL("../src/lib/session-list-context.tsx", import.meta.url), "utf8"); +const layout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8"); +const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); +const sessionHook = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8"); + +test("the app layout owns one SessionListProvider and one SidebarProvider", () => { + assert.match(layout, //); + assert.equal(layout.match(//g)?.length, 1); + assert.match(layout, //); + assert.equal(layout.match(/ { + assert.match(provider, /fetch\(`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}`/); + assert.match(provider, /fetch\("\/api\/account"/); + assert.match(page, /await sessionListReady;/); + assert.doesNotMatch(page, /fetchSessions\(/); + assert.doesNotMatch(page, /fetchAccount\(/); + assert.match(page, /useHomeShellRegistration\(/); +}); + +test("a 401 clears the list and marks signedOut", () => { + assert.match(provider, /sessionResponse\.status === 401 \|\| accountResponse\.status === 401/); + assert.match(provider, /signedOut: true/); + assert.doesNotMatch(provider, /localStorage|sessionStorage|document\.cookie|indexedDB/i); +}); + +test("session writes no longer invalidate a second cache", () => { + assert.doesNotMatch(sessionHook, /invalidateSidebarCache/); +}); diff --git a/frontend/tests/session-lookup-unlisted.test.ts b/frontend/tests/session-lookup-unlisted.test.ts index 9a518ca2..e947ef9c 100644 --- a/frontend/tests/session-lookup-unlisted.test.ts +++ b/frontend/tests/session-lookup-unlisted.test.ts @@ -82,6 +82,6 @@ test("cloud lookup and session management call the server before clearing ?c=", assert.match(pop, /lookupSessionById/); assert.match(pop, /SESSION_LOOKUP_FAILED_NOTICE/); assert.match(pop, /status === "missing"/); - const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); assert.match(page, /resolveLookupBootstrap/); }); diff --git a/frontend/tests/session-title-agent.test.ts b/frontend/tests/session-title-agent.test.ts index c5bfc603..6550a123 100644 --- a/frontend/tests/session-title-agent.test.ts +++ b/frontend/tests/session-title-agent.test.ts @@ -36,6 +36,7 @@ test("shouldGenerateSessionTitle only runs on a first-round auto title", () => { assert.equal(shouldGenerateSessionTitle({ title: "新对话", sessionType: "consultation" }, [{ role: "user" }]), false); assert.equal(shouldGenerateSessionTitle({ title: "新对话", sessionType: "birth_time_rectification" }, []), false); assert.equal(shouldGenerateSessionTitle({ title: "9月6日 · 今日节奏", sessionType: "consultation" }, []), false); + assert.equal(shouldGenerateSessionTitle({ title: "今日节奏 · 9月6日", sessionType: "consultation" }, []), false); assert.equal(shouldGenerateSessionTitle({ title: "半年内换工作时机", sessionType: "consultation" }, []), false); }); diff --git a/frontend/tests/sidebar-contract.test.ts b/frontend/tests/sidebar-contract.test.ts index 32556018..1f8d7f9f 100644 --- a/frontend/tests/sidebar-contract.test.ts +++ b/frontend/tests/sidebar-contract.test.ts @@ -296,7 +296,7 @@ test("keeps app sidebar props as product data and callbacks", () => { test("removes the user-facing admin entry and routes credit control to membership", () => { const appSidebar = readProjectFile("src/components/app-sidebar.tsx"); - const page = readProjectFile("src/app/page.tsx"); + const page = readProjectFile("src/app/(app)/page.tsx"); assert.doesNotMatch(appSidebar, /adminUrl|account\.isAdmin|后台管理|KeyRound/); assert.doesNotMatch(page, /admin-button|ShieldCheck|后台管理/); @@ -307,20 +307,24 @@ test("removes the user-facing admin entry and routes credit control to membershi }); test("composes the chat page with the app sidebar shell", () => { - const page = readProjectFile("src/app/page.tsx"); + // 原值:page.tsx 自己挂 SidebarProvider / AppSidebar / SidebarInset + // 新值:外壳在 (app)/layout.tsx;首页只注册 controls 并保留 SidebarTrigger + // 原因:T2 列表只拉一次、四页共用一份侧栏 + const page = readProjectFile("src/app/(app)/page.tsx"); + const layout = readProjectFile("src/app/(app)/layout.tsx"); + const shell = readProjectFile("src/hooks/use-home-shell-registration.ts"); assert.match(page, /const modalOpen = activeAccountDialog !== null \|\| onboardingPaywallOpen/); - assert.match(page, //); - assert.match(page, /
[\s\S]*/, - ); + assert.match(layout, //); + assert.match(layout, /
[\s\S]*/); + assert.doesNotMatch(page, / { - const page = readProjectFile("src/app/page.tsx"); + const page = readProjectFile("src/app/(app)/page.tsx"); assert.doesNotMatch(page, /mobileSidebarOpen|setMobileSidebarOpen/); assert.doesNotMatch(page, /className="sidebar-backdrop"/); @@ -329,10 +333,12 @@ test("removes page-local mobile sidebar ownership", () => { }); test("blocks the provider mobile Escape action behind layered account UI", () => { - const page = readProjectFile("src/app/page.tsx"); + const layout = readProjectFile("src/app/(app)/layout.tsx"); + const shell = readProjectFile("src/hooks/use-home-shell-registration.ts"); const sidebar = readProjectFile("src/components/ui/sidebar.tsx"); - assert.match(page, /escapeBlocked=\{accountMenuOpen \|\| modalOpen\}/); + assert.match(shell, /escapeBlocked: accountMenuOpen \|\| modalOpen/); + assert.match(layout, /escapeBlocked=\{registration\?\.escapeBlocked \?\? false\}/); assert.match(sidebar, /event\.key === "Escape" && isMobile && openMobile && !escapeBlocked/); }); @@ -392,7 +398,7 @@ test("changes sidebar state without transition frames", () => { }); test("keeps the chat title in the flexible left-aligned header column", () => { - const page = readProjectFile("src/app/page.tsx"); + const page = readProjectFile("src/app/(app)/page.tsx"); const header = page.slice(page.indexOf('
'), page.indexOf("
", page.indexOf('
'))); assert.match(cssBlock(".chat-header"), /grid-template-columns:\s*auto\s+minmax\(0,\s*1fr\)\s+auto/); diff --git a/frontend/tests/sidebar-data-cache.test.ts b/frontend/tests/sidebar-data-cache.test.ts deleted file mode 100644 index 92bf2d44..00000000 --- a/frontend/tests/sidebar-data-cache.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import test from "node:test"; - -import { - SIDEBAR_CACHE_TTL_MS, - invalidateSidebarCache, - readSidebarCache, - sidebarCacheIsFresh, - writeSidebarCache, -} from "../src/lib/sidebar-data-cache.ts"; - -const hookSource = readFileSync(new URL("../src/hooks/use-sidebar-data.ts", import.meta.url), "utf8"); -const sessionHookSource = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8"); - -const row = (id: string, title: string) => ({ - id, - title, - pinned: false, - archived: false, - updatedAt: 1, -}); - -const account = { - name: "示例账户", - email: "example@invalid.test", - credits: 0, - initial: "示", - avatar: null, -}; - -test("the read-only sidebar reuses a fresh entry instead of fetching again", () => { - invalidateSidebarCache(); - assert.equal(readSidebarCache(), null); - - writeSidebarCache({ accountId: "acct-1", sessions: [row("a", "第一条")], account, fetchedAt: 1_000 }); - const entry = readSidebarCache(); - assert.ok(entry); - assert.equal(entry.sessions[0]?.title, "第一条"); - - // Inside the window: the hook returns without touching the network. - assert.equal(sidebarCacheIsFresh(entry, 1_000), true); - assert.equal(sidebarCacheIsFresh(entry, 1_000 + SIDEBAR_CACHE_TTL_MS - 1), true); - - // Past it: shown from cache first, refreshed in the background — exactly one - // refetch, because the write that follows resets `fetchedAt`. - assert.equal(sidebarCacheIsFresh(entry, 1_000 + SIDEBAR_CACHE_TTL_MS), false); - assert.equal(sidebarCacheIsFresh(entry, 60_000_000), false); - assert.equal(sidebarCacheIsFresh(null, 1_000), false); - - // A clock that jumped backwards must not read as fresh forever. - assert.equal(sidebarCacheIsFresh(entry, 0), false); -}); - -test("a session write on `/` makes the next secondary page read the new title", () => { - invalidateSidebarCache(); - writeSidebarCache({ accountId: "acct-1", sessions: [row("a", "旧标题")], account, fetchedAt: 1_000 }); - assert.equal(readSidebarCache()?.sessions[0]?.title, "旧标题"); - - invalidateSidebarCache(); - assert.equal(readSidebarCache(), null); - assert.equal(sidebarCacheIsFresh(readSidebarCache(), 1_001), false); - - // Every write path on `/` calls it: create, rename, delete, pin, archive. - for (const marker of [ - /await persistSession\(nextSession\);\n\s*\/\*[\s\S]*?\*\/\n\s*invalidateSidebarCache\(\);/, - /删除聊天记录失败"\);\n\s*invalidateSidebarCache\(\);/, - /\{ pinned: nextPinned \}, "update"\)\.then\(invalidateSidebarCache\)/, - /\{ archived_at: nextArchivedAt \}, "update"\)\.then\(invalidateSidebarCache\)/, - /\);\n\s*invalidateSidebarCache\(\);\n\s*return nextSession;/, - ]) { - assert.match(sessionHookSource, marker); - } -}); - -test("a second account in the same tab never reads the first one's rows", () => { - invalidateSidebarCache(); - writeSidebarCache({ accountId: "acct-1", sessions: [row("a", "甲的对话")], account, fetchedAt: 1_000 }); - writeSidebarCache({ accountId: "acct-2", sessions: [row("b", "乙的对话")], account, fetchedAt: 2_000 }); - - assert.equal(readSidebarCache()?.accountId, "acct-2"); - assert.equal(readSidebarCache()?.sessions[0]?.title, "乙的对话"); - invalidateSidebarCache(); -}); - -test("the cache is memory only, and a 401 empties it", () => { - // Nothing durable: a stale list surviving a browser restart is worse than one - // fetch, and it would outlive a sign-out. - const cacheSource = readFileSync(new URL("../src/lib/sidebar-data-cache.ts", import.meta.url), "utf8"); - assert.doesNotMatch(cacheSource, /localStorage|sessionStorage|document\.cookie|indexedDB/i); - - assert.match(hookSource, /if \(sidebarCacheIsFresh\(cached, Date\.now\(\)\)\) return;/); - assert.match(hookSource, /invalidateSidebarCache\(\);\n\s*setState\(\{ sessions: \[\], account: null, settled: true, signedOut: true \}\);/); - - // Read synchronously at mount so a cached list is on screen in the first - // frame — no skeleton, no spinner, per the unified-loading ruling. - assert.match(hookSource, /useState\(\(\) => \{[\s\S]*?readSidebarCache\(\)/); - assert.doesNotMatch(hookSource, /skeleton|Spinner/i); -}); diff --git a/frontend/tests/site-style-isolation-contract.test.ts b/frontend/tests/site-style-isolation-contract.test.ts index 6ba3fb51..1c4940e6 100644 --- a/frontend/tests/site-style-isolation-contract.test.ts +++ b/frontend/tests/site-style-isolation-contract.test.ts @@ -8,7 +8,7 @@ const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), " const siteStyles = readFileSync(new URL("../src/app/site-styles.ts", import.meta.url), "utf8"); const adminLayout = readFileSync(new URL("../src/app/admin/layout.tsx", import.meta.url), "utf8"); const adminCss = readFileSync(new URL("../src/app/admin/admin.css", import.meta.url), "utf8"); -const homePage = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +const homePage = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8"); test("Inter is loaded through next/font and applied as a CSS variable", () => { @@ -54,7 +54,12 @@ test("site chrome owns globals.css; admin does not import it", () => { assert.match(siteStyles, /import "\.\/globals\.css"/); assert.match(siteStyles, /import "\.\/birth-time-choice\.css"/); assert.doesNotMatch(layout, /globals\.css|birth-time-choice\.css|site-styles/); - assert.match(homePage, /import "@\/app\/site-styles"/); + // 原值:首页 page.tsx import site-styles + // 新值:(app)/layout.tsx import,首页不再各自引进 + // 原因:T2 四页共用一份外壳 + const appLayout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8"); + assert.match(appLayout, /import "\.\.\/site-styles"/); + assert.doesNotMatch(homePage, /site-styles/); assert.match(loginPage, /import "\.\.\/site-styles"/); assert.doesNotMatch(adminLayout, /globals\.css|site-styles/); assert.doesNotMatch( diff --git a/frontend/tests/stale-client-recovery.test.ts b/frontend/tests/stale-client-recovery.test.ts index e1f223e2..00d65e72 100644 --- a/frontend/tests/stale-client-recovery.test.ts +++ b/frontend/tests/stale-client-recovery.test.ts @@ -10,7 +10,7 @@ import { shouldReloadFrozenBootstrap, } from "../src/lib/stale-client-recovery.ts"; -const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); const layoutSource = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8"); const nextConfig = readFileSync(new URL("../next.config.ts", import.meta.url), "utf8"); const recoverySource = readFileSync(new URL("../src/components/stale-client-recovery.tsx", import.meta.url), "utf8"); @@ -73,10 +73,10 @@ test("the web build stamps the Git SHA as Next deploymentId and does not cache t assert.doesNotMatch(layoutSource, /export const dynamic = "force-dynamic"/); const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8"); // 原值:`../src/app/reports/page.tsx` - // 新值:`../src/app/(secondary)/reports/page.tsx` + // 新值:`../src/app/(app)/reports/page.tsx` // 原因:四个次级路由移进 `(secondary)` 路由组共享一份外壳(TASK-sidebar-unify D2)。 // 路由组括号不进 URL,`/reports` 一字未改;这里改的只是源码位置。 - const reportsPage = readFileSync(new URL("../src/app/(secondary)/reports/page.tsx", import.meta.url), "utf8"); + const reportsPage = readFileSync(new URL("../src/app/(app)/reports/page.tsx", import.meta.url), "utf8"); const adminLayout = readFileSync(new URL("../src/app/admin/layout.tsx", import.meta.url), "utf8"); assert.match(loginPage, /export const dynamic = "force-dynamic"/); assert.match(reportsPage, /export const dynamic = "force-dynamic"/); diff --git a/tests/test_daily_and_rectification_entrypoints.py b/tests/test_daily_and_rectification_entrypoints.py index 0428d6ef..cbf4754a 100644 --- a/tests/test_daily_and_rectification_entrypoints.py +++ b/tests/test_daily_and_rectification_entrypoints.py @@ -1,7 +1,7 @@ from pathlib import Path -PAGE = Path("frontend/src/app/page.tsx") +PAGE = Path("frontend/src/app/(app)/page.tsx") STARTER_HOME = Path("frontend/src/components/starter-home.tsx") HOME_CLOUD = Path("frontend/src/lib/home-cloud-sync.ts") RECTIFICATION_HOOK = Path("frontend/src/hooks/use-rectification-surface.ts") diff --git a/tests/test_session_management_entrypoints.py b/tests/test_session_management_entrypoints.py index bb1ad329..9d6798f9 100644 --- a/tests/test_session_management_entrypoints.py +++ b/tests/test_session_management_entrypoints.py @@ -1,7 +1,7 @@ from pathlib import Path -PAGE = Path("frontend/src/app/page.tsx") +PAGE = Path("frontend/src/app/(app)/page.tsx") _FRONTEND_SRC = Path("frontend/src") HOME_SURFACE_FILES = ( PAGE, diff --git a/tests/test_supabase_user_data_contract.py b/tests/test_supabase_user_data_contract.py index ac7b5078..1a71fbe0 100644 --- a/tests/test_supabase_user_data_contract.py +++ b/tests/test_supabase_user_data_contract.py @@ -37,7 +37,7 @@ SYNASTRY_REPORT_MIGRATION = ( / "migrations" / "20260718101000_repair_missing_synastry_reports.sql" ) -PAGE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "page.tsx" +PAGE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "(app)" / "page.tsx" _FRONTEND_SRC = Path(__file__).resolve().parents[1] / "frontend" / "src" HOME_SURFACE_FILES = ( PAGE, @@ -61,6 +61,10 @@ OPTIONAL_HOME_HOOKS = ( # 些文件的并集,新 hook 不进这张白名单,下面的合盘断言就会在代码完全正确 # 的情况下变红。新增 Home 级 hook 时必须同步加进来。 _FRONTEND_SRC / "hooks" / "use-synastry.ts", + _FRONTEND_SRC / "hooks" / "use-session-list.ts", + _FRONTEND_SRC / "hooks" / "use-home-shell-registration.ts", + _FRONTEND_SRC / "lib" / "session-list-context.tsx", + _FRONTEND_SRC / "lib" / "session-list-filter.ts", )