From e71e4f92006a46b485fc3750fc3247aadf3b6a8f Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Mon, 21 Sep 2026 16:39:32 +0800 Subject: [PATCH] fix(web): keep rectification sessions listed and persist chats on first send Empty-consultation filtering is now session_type scoped so birth-time rows stay in the sidebar. Subtitles use updatedAt with sort/group/cursor. New chats stay local until the first send. --- BLOCKED.md | 8 +- CHANGELOG.md | 4 + docs/BUG_HISTORY.md | 48 ++++++++++ .../PROGRESS-session-list-rebuild-20260921.md | 50 +++++++++++ docs/tasks/README.md | 2 +- frontend/DESIGN.md | 2 +- frontend/src/app/(app)/page.tsx | 67 ++++---------- frontend/src/app/api/sessions/route.ts | 34 +------ frontend/src/hooks/use-consultation-run.ts | 18 ++++ frontend/src/hooks/use-session-management.ts | 88 +++++++++---------- frontend/src/lib/home-cloud-sync.ts | 18 +--- frontend/src/lib/session-list-context.tsx | 9 +- frontend/src/lib/session-list-filter.ts | 45 ++++++++-- frontend/src/lib/session-sidebar-row.ts | 3 +- frontend/tests/chat-session-authority.test.ts | 32 ++++--- frontend/tests/chat-session-url.test.ts | 10 ++- frontend/tests/chat-session-write.test.ts | 6 +- .../database-session-list-visibility.test.ts | 76 ++++++++++++++++ frontend/tests/local-postgres-or.test.ts | 8 ++ frontend/tests/session-groups.test.ts | 41 +++++++++ frontend/tests/session-list-filter.test.ts | 78 ++++++++++++++-- 21 files changed, 461 insertions(+), 186 deletions(-) create mode 100644 docs/tasks/PROGRESS-session-list-rebuild-20260921.md create mode 100644 frontend/tests/database-session-list-visibility.test.ts diff --git a/BLOCKED.md b/BLOCKED.md index f5594efe..0040b82b 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -117,11 +117,11 @@ - **现状:** 四个页面共用一份列表、切页不重拉已落地。星盘 / 星历 / 报告页不能改名或删除。 - **解除需要:** 把只依赖 `sessions` + fetch 的元数据操作搬进 `SessionListProvider`,四页都能用。另立单。 -## 会话列表:空咨询延迟落库未做(2026-09-17,BUG-928) +## ~~会话列表:空咨询延迟落库未做(2026-09-17,BUG-928)~~ -- **让步:** 任务书 T3 要把 `startNewChat` / 启动落点改成本地创建、第一问前才 `POST /api/sessions`。牵动 `?c=` 深链和刷新恢复,本轮只做服务端过滤 + 复用已有空咨询。 -- **替代:** `GET /api/sessions` 排除 `messages = []`,响应带 `draft`;启动和「新建对话」优先用这份空咨询,不再连点就堆新行。 -- **解除需要:** 给未落库会话一个刷新可恢复的身份,且 `send()` 在咨询 POST 前补 create。另立单。 +- ~~**让步:** 任务书 T3 要把 `startNewChat` / 启动落点改成本地创建、第一问前才 `POST /api/sessions`。牵动 `?c=` 深链和刷新恢复,本轮只做服务端过滤 + 复用已有空咨询。~~ +- ~~**替代:** `GET /api/sessions` 排除 `messages = []`,响应带 `draft`;启动和「新建对话」优先用这份空咨询,不再连点就堆新行。~~ +- **解除(2026-09-21,BUG-989):** `startNewChat` 只在本地开一条,第一问 `send()` 才 `POST /api/sessions`;未落库不写 `?c=`。列表过滤收窄到咨询空行,校正会话不再被 `messages = []` 误删(BUG-987)。刷新丢掉未开口的本地空会话是可接受的。 ## 会话列表多键排序:无 Docker 未跑翻页重叠(2026-09-17,BUG-926) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bd4cb8a..fb8a7de1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-21 — 侧栏重新列出生时校正,时间按最后活动,新建对话开口前不落库 + +侧栏不再把生时校正会话藏起来。每条下面的时间改成最后一次说话或校正操作的时间,和列表排序、分组一致。点「新建对话」先只在这一页开着,真正发出第一句话才存到云端;连点不会堆出一排空的「新对话」。Skill 版本不变。 + ## 2026-09-20 — 校正窗口保留真实日期,跨午夜采用不再丢日期(本地实现,未发布) 凌晨申报的前半窗口落在前一天;只记得夜里时先问午夜前后,不知道或跳过则仅保留填报当天凌晨与深夜两段。候选排序和宽度使用日期序号、实际分钟与声明分段,不把间隙填成全天。交付显示实际日期及「前一天 / 后一天」;本命上升分段也保留完整日期。扩窗沿用已持久日期与用户选侧,未知/跳过的允许两段不因后续缩窄被改写,无法满足约束时不保存。经 D4 授权新增兼容迁移,独立保留采用日期及实际计算 offset,原填报日期不变,排盘、咨询和报告同源读取。固定 offset 不代表已修复 IANA/DST。算法身份更新,历史结果只读不重标;评分、权重、确认阈值与 Skill 版本不变。最终全量、受控真人与部署验收尚待完成。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index a879402b..d439b605 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -13055,3 +13055,51 @@ - 相关记录:BUG-142 - 复发自:BUG-142(publish 超时预算;本次是缓存被过早丢掉后的冷构建) - 修复版本:待本修复合入 staging 的门禁 run + +## BUG-987 | 列表用 messages=[] 过滤,生时校正会话全部从侧栏消失 + +- 状态:resolved +- 首次发现:2026-09-21 +- 最近更新:2026-09-21 +- 影响面:`GET /api/sessions`、`excludeEmptyConsultations`、侧栏历史 +- 用户现象:staging 侧栏一条生时校正都没有;深链 `?c=` 仍能打开同一会话。 +- 触发条件:打开 `/` 或次级页拉会话列表。校正会话的 `chat_sessions.messages` 永远是 `[]`。 +- 根因:`e4e73f56` 用全表 `messages <> '[]'` 当「有没有内容」。该判据只对咨询成立。客户端 `isListedSidebarSession` 放行校正,服务端先把行拿走。源码正则 `not("messages", "eq", [])` 从未拿真实行验证。 +- 修复:过滤收窄为 `session_type.neq.consultation,messages.neq.[]`。归档视图同一条规则。校正内容在 case 表,不在 `messages` 列。 +- 验证:`database-session-list-visibility.test.ts`(真实 Postgres,无 Docker 时 skip)、`session-list-filter.test.ts` 跨层对断、`chat-session-authority.test.ts`。 +- 防复发:不得用「某列为空」推断会话有没有内容。列表过滤必须带 `session_type` 限定。不得用源码正则代替真实行测试。 +- 相关记录:BUG-928、BUG-704 +- 复发自:BUG-928(空咨询过滤写成全表 `messages <> '[]'`) +- 修复版本:待本修复合入 staging + +## BUG-988 | 侧栏副标题用创建时间,排序用最后活动时间,列表读起来是乱的 + +- 状态:resolved +- 首次发现:2026-09-21 +- 最近更新:2026-09-21 +- 影响面:`sessionSidebarSubtitle`、`SESSION_LIST_COLUMNS` +- 用户现象:侧栏时间不单调(9/18 → 9/17 → 9/7 → 9/16),9/7 创建、9/16 仍在用的会话落在「最近 7 天」却显示 9/7。 +- 触发条件:会话创建后隔天继续使用。 +- 根因:`e4e73f56` 把 `created_at` 加进列表列,副标题读创建时间;排序、分组、游标仍用 `updated_at`。产品 2026-09-21 推翻 BUG-929 的「副标题为创建时间」。 +- 修复:副标题改 `updatedAt`。列表列去掉 `created_at`。详情接口仍返回 `created_at`。 +- 验证:`session-groups.test.ts`(早创建晚活动:位置按活动时间,副标题显示活动时间)、`chat-session-authority.test.ts` 列合同。 +- 防复发:显示、排序、分组、游标必须用同一个时间字段(`updated_at`)。`SESSION_LIST_COLUMNS` 不得再含 `created_at`。 +- 相关记录:BUG-929、BUG-553 +- 复发自:BUG-929(副标题改创建时间后与排序键分叉) +- 修复版本:待本修复合入 staging + +## BUG-989 | 点「新建对话」复用旧空会话,第一问之前就落库 + +- 状态:resolved +- 首次发现:2026-09-21 +- 最近更新:2026-09-21 +- 影响面:`startNewChat`、`send()`、`GET /api/sessions` 的 `draft`、首页启动 +- 用户现象:点新建落到 9/17 的旧「新对话」;连点不会开新的。空咨询在开口前就已经在库里。 +- 触发条件:点「新建对话」,或启动时并入 `draft`。 +- 根因:BUG-928 让步成「复用空咨询 + 服务端 draft」。复用既不 bump 时间也不改创建时间。产品 2026-09-21 收掉该让步。 +- 修复:`startNewChat` 只在本地创建,不 `POST`、不写 `?c=`。第一问 `send()` 先 `POST /api/sessions` 再走既有 append。落库失败撤销本地会话并保留输入框草稿。删除 `draft` 字段、`draftRow`、`findReusableEmptyConsultation`。 +- 验证:`session-list-filter.test.ts` 源码合同(新建无 POST / 无 URL,send 时 create,失败不 `setDraft`)、`chat-session-url.test.ts`、`session-list-lifecycle.test.tsx` 仍覆盖无 draft 的列表响应。 +- 防复发:第一问之前不得 `POST /api/sessions`。未落库会话不得写入 `?c=`。列表响应不得再带 `draft`。 +- 相关记录:BUG-928、BUG-987 +- 复发自:BUG-928(延迟落库让步未收口) +- 修复版本:待本修复合入 staging diff --git a/docs/tasks/PROGRESS-session-list-rebuild-20260921.md b/docs/tasks/PROGRESS-session-list-rebuild-20260921.md new file mode 100644 index 00000000..caac7687 --- /dev/null +++ b/docs/tasks/PROGRESS-session-list-rebuild-20260921.md @@ -0,0 +1,50 @@ +# PROGRESS · 会话列表重建(2026-09-21) + +工作树:`.worktrees/session-list-rebuild-20260921` +分支:`codex/session-list-rebuild-20260921` +基线:`origin/staging` @ `f8d65e48`(任务书;代码基线 `0c3c9d6b`) + +## 任务状态 + +| 任务 | 状态 | 说明 | +| --- | --- | --- | +| T1 列表不得吞掉生时校正 BUG-987 | 完成 | 过滤改为 `session_type.neq.consultation,messages.neq.[]`;归档同一规则 | +| T2 显示时间与排序同源 BUG-988 | 完成 | 副标题改 `updatedAt`;`SESSION_LIST_COLUMNS` 去掉 `created_at` | +| T3 第一问之前不落库 BUG-989 | 完成 | 删除复用与 `draft`;本地新建;`send()` 先 POST 再建问 | +| 记录 | 完成 | BUG-987/988/989、CHANGELOG、DESIGN、BLOCKED 划掉 120 行那条 | + +## 开工基线 + +| 项 | 值 | +| --- | --- | +| 分支 | `codex/session-list-rebuild-20260921` 跟踪 `origin/staging` @ `f8d65e48` | +| `page.tsx` 行数 | 1826 | +| `Home()` useState / useRef | 未改装配;帽 36 / 37 | + +## 实现要点 + +- 服务端与客户端共用 `excludeEmptyConsultations` / `isListedSidebarSession` / `cloudListIncludesSession`。校正会话 `messages` 永远是 `[]`,不能当空咨询删。 +- 副标题、排序、分组、游标都用 `updated_at`。详情接口仍返回 `created_at`(`home-cloud-sync` 回落链未动)。 +- `startNewChat` 只替换本地未开口咨询,不 POST、不写 `?c=`。`persistSession("create")` 成功后才 push URL。落库失败撤销本地会话,且发生在 `setDraft("")` 之前,输入框草稿仍在。 +- 已成功建行的 id 记入 `cloudCreatedIds`,避免第一问失败后再发时重复 POST。 + +## 测试 + +| 项 | 结果 | +| --- | --- | +| `page.tsx` 行数 | **1791**(开工 1826,未增长) | +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error(既有 warning 未动;去掉新建路径里未再使用的 `setCreatingSession` 解构,避免新增 warning) | +| 定向 | chat-session-authority / session-list-filter / session-groups / chat-session-url / local-postgres-or / chart-library-session / session-list-lifecycle / composer-isolation / home-shell-growth / chat-session-write **全部通过** | +| `npm test` | **3598** 条,**3509** 通过 / **89** 失败。失败几乎全是本机 Windows:`database-*.test.ts` 因 Docker 网段耗尽(`all predefined address pools have been fully subnetted`,其它 worktree 留下的 postgres 网络),以及 symlink / chmod / shell 脚本类用例。与本单相关的源码合同失败 1 条已修(`chat-session-write.test.ts` 启动不再 `writeChatSession(initialSession)`)。未在本机重跑 `0c3c9d6b` 全量作逐条基线。 | +| `npm run test:db` | Docker 在 PATH 里,但创建 compose 网络失败(同上网段耗尽)。**不得写成通过。** `database-session-list-visibility.test.ts` 照提交。 | +| `next build` / `--webpack` | 编译与 tsc 通过;收集 `/api/consult` 等页数据时 `symlink ... EPERM`(Windows 无开发者模式)。本机看不到 `/` Static 与 gzip。 | + +## 让步 + +T1–T3 都做了,功能上无让步。 + +环境缺口(替代证据): + +1. **真实 Postgres 列表可见性**:本机 Docker 网段被其它会话占满,`test:db` 跑不起来。替代:`local-postgres-or.test.ts` 编译 `session_type.neq.consultation,messages.neq.[]`;`session-list-filter.test.ts` 跨层对断;`chat-session-authority.test.ts` 断言路由走 `excludeEmptyConsultations`。 +2. **next build 静态与 gzip**:Windows symlink EPERM。未量首屏 gzip。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 872fd6f6..0483a594 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -152,7 +152,7 @@ | `TASK-consultation-answer-start-anchor-20260917.md` | `PROGRESS-consultation-answer-start-anchor-20260917.md` | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 已验收(经修复单) | `worktree/green-harbor-5be3` | | `TASK-consultation-answer-start-anchor-fix-20260917.md` | `PROGRESS-consultation-answer-start-anchor-fix-20260917.md` | 验收修复单:F1 头就是留白行时留白按整视口算(BUG-931);F2 留白只在钉住期间存在(BUG-932);前置:先修 e4e73f56 的两处 TS 错否则门禁不过 | 已验收 | `cc1a8980`(Claude 验收:tsc 0 / lint 0 error / npm test 3457 条 39 红与 11c0028d 逐条一致、新增 2 条绿 / `next build --webpack` 通过、`/` Static、首屏 gzip 591,242(较 09-16 基线 582,800 +1.45%,含会话列表单)/ Chrome 真实布局 S1–S6 全部通过,S6 新助手行距顶 16px 且增高不动,S5 不再写留白);真机六条欠 | | — | `PROGRESS-starter-greeting-20260917.md` | 首页开场语改成 claude.ai 式单行问候:`starter-greeting.ts` 的「称呼 + 追问句」五时段十五条收成三个池子(时段 / 星期 / 回访,按 `variantSelection` 确定性取一条),副标题行与 `.starter-salutation` 下线,h1 降到 `clamp(24px, 2.8vw, 30px)`;追问移到输入框占位符「想聊什么都可以」。产品直接拍板,非 Bug,不占 BUG 号 | 待验收 | `codex/starter-greeting-20260917` | -| `TASK-session-list-rebuild-20260921.md` | — | **P0 会话列表重建(`e4e73f56` 的返工)**:服务端 `GET /api/sessions` 用 `messages <> '[]'` 排除空咨询,但校正会话的 `messages` 永远是 `[]`(建行写死空数组、创建合同只收空数组、PATCH 忽略、`touch_chat_session_from_rectification_case` 只 bump `updated_at`)→ **全部生时校正会话从侧栏消失**,而客户端 `isListedSidebarSession` 明确放行校正会话,两层规则相反且无跨层对断(BUG-987);同一提交把副标题换成 `created_at`,排序/分组/游标仍是 `updated_at`,列表可见时间不单调(9/18→9/17→9/7→9/16)(BUG-988);BUG-928 让步下来的「复用空会话」不 bump 时间,点新建变成跳回 9/17 的旧草稿(BUG-989)。**产品 09-21 拍板**:D1 副标题改最后活动时间(推翻 BUG-929 该条,其余保留)、D2 收掉 `BLOCKED.md:120` 的让步,第一问前不落库、删复用与 `draft`。T1 不得让步,T3 可拆轮。BUG 段 987 起 | 待领取 | — | +| `TASK-session-list-rebuild-20260921.md` | `PROGRESS-session-list-rebuild-20260921.md` | **P0 会话列表重建(`e4e73f56` 的返工)**:服务端 `GET /api/sessions` 用 `messages <> '[]'` 排除空咨询,但校正会话的 `messages` 永远是 `[]`(建行写死空数组、创建合同只收空数组、PATCH 忽略、`touch_chat_session_from_rectification_case` 只 bump `updated_at`)→ **全部生时校正会话从侧栏消失**,而客户端 `isListedSidebarSession` 明确放行校正会话,两层规则相反且无跨层对断(BUG-987);同一提交把副标题换成 `created_at`,排序/分组/游标仍是 `updated_at`,列表可见时间不单调(9/18→9/17→9/7→9/16)(BUG-988);BUG-928 让步下来的「复用空会话」不 bump 时间,点新建变成跳回 9/17 的旧草稿(BUG-989)。**产品 09-21 拍板**:D1 副标题改最后活动时间(推翻 BUG-929 该条,其余保留)、D2 收掉 `BLOCKED.md:120` 的让步,第一问前不落库、删复用与 `draft`。T1 不得让步,T3 可拆轮。BUG 段 987 起 | 待验收 | `codex/session-list-rebuild-20260921` | ### 个人报告 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 65fb43f5..da803c9e 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -353,7 +353,7 @@ The birth-time rectification session is the consultation transcript plus a house ### Navigation item -- **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`. +- **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 `updated_at` (last activity), plus ` · ` when the chart is not the account holder. Same-day duplicates are not uniquified with wall-clock `HH:MM`. Display, sort, grouping, and cursor all use `updated_at`. - **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. diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 279e7abf..4bfd5760 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -127,7 +127,6 @@ import { CONSULTATION_EVIDENCE_VALIDATION_LABEL, CONSULTATION_LOADING_METHOD_LABEL, } from "@/lib/consultation-activity-labels"; -import { writeChatSession } from "@/lib/chat-session-write-contract"; import { SESSION_MISSING_NOTICE, clearLoginSessionReturn, @@ -210,7 +209,6 @@ import { resolveLookupBootstrap, payloadCode, payloadMessage, - readDraftConsultation, readSessions, readStoredDailyStarlanguage, readStoredPendingConsultation, @@ -228,7 +226,7 @@ import { starterHomeLandingNeedsConsultation, type BootstrapPhase, } from "@/lib/home-bootstrap"; -import { findReusableEmptyConsultation } from "@/lib/session-list-filter"; +import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter"; const BirthTimeRectification = dynamic( () => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification), @@ -931,31 +929,12 @@ export default function Home() { 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( + nextSessions = [createSession( nextModelCatalog?.defaultModelId ?? "", "consultation", chartSnapshotForSession("self", [], nextProfile), - ); - if (nextModelCatalog) { - await writeChatSession(initialSession.id, { - title: initialSession.title, - theme: initialSession.theme, - model_id: initialSession.modelId, - messages: [], - session_type: initialSession.sessionType, - rectification_case_id: initialSession.rectificationCaseId, - chart_profile_id: initialSession.chartProfileId, - chart_profile_name: initialSession.chartProfileName, - chart_profile_role: initialSession.chartProfileRole, - }, "create"); - } - nextSessions = [initialSession]; + )]; } nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions); @@ -1010,32 +989,13 @@ export default function Home() { bootstrapSelection.urlAction, ); if (starterHomeLandingNeedsConsultation(nextSessions, landingSessionId, bootstrapSelection.urlAction)) { - 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; - } + const homeSession = createSession( + nextModelCatalog?.defaultModelId ?? "", + "consultation", + chartSnapshotForSession("self", [], nextProfile), + ); + nextSessions = [homeSession, ...nextSessions]; + landingSessionId = homeSession.id; } const activeListed = nextSessions.find((session) => session.id === landingSessionId) ?? nextSessions[0]; @@ -1062,7 +1022,12 @@ export default function Home() { setActiveSessionId(landingSessionId); if (bootstrapSelection.clearStoredReturn) clearLoginSessionReturn(); if (bootstrapSelection.urlAction === "replace-clear") writeSessionUrl(null, "replace"); - if (bootstrapSelection.urlAction === "replace-selected") writeSessionUrl(bootstrapSelection.sessionId, "replace"); + if (bootstrapSelection.urlAction === "replace-selected") { + const selected = nextSessions.find((session) => session.id === bootstrapSelection.sessionId); + if (!selected || !isUnsavedEmptyConsultation(selected)) { + writeSessionUrl(bootstrapSelection.sessionId, "replace"); + } + } if (reservedConsultation?.status === "reserved") { const recoverySession = nextSessions.find((session) => session.id === reservedConsultation.sessionId); if (recoverySession) restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending); diff --git a/frontend/src/app/api/sessions/route.ts b/frontend/src/app/api/sessions/route.ts index 75071d20..d05b47d8 100644 --- a/frontend/src/app/api/sessions/route.ts +++ b/frontend/src/app/api/sessions/route.ts @@ -16,22 +16,9 @@ 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"; +import { applyArchiveFilter, excludeEmptyConsultations } from "@/lib/session-list-filter"; -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; - not: (column: string, operator: string, value: null) => Query; -}>(query: Query, archived: boolean): Query { - return archived ? query.not("archived_at", "is", null) : query.is("archived_at", null); -} +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"; export async function GET(request: Request) { try { @@ -67,7 +54,6 @@ 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 excludeEmptyConsultations( applyArchiveFilter( @@ -83,26 +69,12 @@ export async function GET(request: Request) { .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, draft }); + return NextResponse.json({ sessions, nextCursor }); } catch (error) { if (isSupabaseConfigurationError(error)) { return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 }); diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts index 2f7d6a25..50e79753 100644 --- a/frontend/src/hooks/use-consultation-run.ts +++ b/frontend/src/hooks/use-consultation-run.ts @@ -64,9 +64,11 @@ import { completedOnboardingTranscript, isProfileComplete, selectedBirthPlace } import { pendingConsultationStorageKey, timestamp } from "@/lib/home-types"; import { consultSendBlockedByRectificationSession, + fallbackSessionId, missingActiveSessionSendAction, SESSION_NOT_CONSULTATION_CODE, } from "@/lib/rectification-session-composer-guard"; +import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter"; import type { Account, AccountDialog, @@ -606,6 +608,22 @@ export function useConsultationRun(params: ConsultationRunParams) { return false; } + if (isUnsavedEmptyConsultation(currentSession)) { + try { + await persistSession(currentSession, "create"); + } catch (caught) { + const remaining = sessions.filter((session) => session.id !== currentSession.id); + setSessions(remaining); + const fallbackId = fallbackSessionId(remaining); + setActiveSessionId(fallbackId); + setRequestError({ + sessionId: fallbackId, + message: caught instanceof Error ? caught.message : "新对话未能保存到云端。", + }); + return false; + } + } + const [year, month, day] = profile.date.split("-").map(Number); const [hour, minute] = consultationRoute.time?.split(":").map(Number) ?? []; diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index 440217e3..bfc507f6 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -29,7 +29,11 @@ import { patchSessionModel, readSessions, } from "@/lib/home-cloud-sync"; -import { findReusableEmptyConsultation } from "@/lib/session-list-filter"; +import { + isListedSidebarSession, + isUnsavedEmptyConsultation, + replaceUnsavedEmptyConsultations, +} 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"; @@ -110,7 +114,6 @@ export function useSessionManagement(params: SessionManagementParams) { setActiveChartId, setActiveSessionId, setBirthTimeConsultationConsent, - setCreatingSession, setDraft, setDraftEntrypoint, setDraftTheme, @@ -131,12 +134,21 @@ export function useSessionManagement(params: SessionManagementParams) { const [sessionFullPrompt, setSessionFullPrompt] = useState<{ question: string; theme: Theme } | null>(null); sessionsRef.current = sessions; const loadMoreInFlight = useRef(false); + const pendingCreateById = useRef(new Map()); + const cloudCreatedIds = useRef(new Set()); + for (const session of sessions) { + if (!isUnsavedEmptyConsultation(session)) cloudCreatedIds.current.add(session.id); + } - const visibleSessions = sortSessions(sessions.filter((session) => (showArchivedSessions ? session.archivedAt : !session.archivedAt) - && (session.sessionType === "birth_time_rectification" - || session.messages.length > 0 - || !session.messagesHydrated - || session.id === activeSessionId))); + const visibleSessions = sortSessions(sessions.filter((session) => { + if (showArchivedSessions) { + return Boolean(session.archivedAt) + && (session.sessionType === "birth_time_rectification" + || session.messages.length > 0 + || !session.messagesHydrated); + } + return isListedSidebarSession(session); + })); function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) { setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session))); @@ -149,6 +161,9 @@ export function useSessionManagement(params: SessionManagementParams) { ) { if (!account) throw new Error("账户尚未加载完成"); if (process.env.NODE_ENV === "development" && uiPreview.current) return; + if (mode === "create" && cloudCreatedIds.current.has(session.id)) return; + const continuedFromSessionId = options?.continuedFromSessionId + ?? pendingCreateById.current.get(session.id)?.continuedFromSessionId; const values = mode === "create" ? { title: session.title, @@ -160,8 +175,8 @@ export function useSessionManagement(params: SessionManagementParams) { chart_profile_id: session.chartProfileId, chart_profile_name: session.chartProfileName, chart_profile_role: session.chartProfileRole, - ...(options?.continuedFromSessionId - ? { continued_from_session_id: options.continuedFromSessionId } + ...(continuedFromSessionId + ? { continued_from_session_id: continuedFromSessionId } : {}), } : { @@ -173,6 +188,11 @@ export function useSessionManagement(params: SessionManagementParams) { chart_profile_role: session.chartProfileRole, }; await writeChatSession(session.id, values, mode); + if (mode === "create") { + pendingCreateById.current.delete(session.id); + cloudCreatedIds.current.add(session.id); + if (!uiPreview.current) writeSessionUrl(session.id, "push"); + } } async function ensureSessionMessages(sessionId: string) { @@ -305,55 +325,24 @@ 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), }; - const previousSessionId = activeSession?.id ?? ""; - const previousHref = `${window.location.pathname}${window.location.search}`; - setCreatingSession(true); - setSessions((current) => [nextSession, ...current]); + for (const session of sessions) { + if (isUnsavedEmptyConsultation(session)) pendingCreateById.current.delete(session.id); + } + pendingCreateById.current.set(nextSession.id, { + continuedFromSessionId: options?.continuedFromSessionId, + }); + setSessions((current) => replaceUnsavedEmptyConsultations(current, nextSession)); setActiveSessionId(nextSession.id); - if (!uiPreview.current) writeSessionUrl(nextSession.id, "push"); setDraft(""); setDraftTheme(null); setDraftEntrypoint(null); setComposerNotice(""); setRequestError(null); - try { - await persistSession( - nextSession, - "create", - options?.continuedFromSessionId - ? { continuedFromSessionId: options.continuedFromSessionId } - : undefined, - ); - return nextSession; - } catch (caught) { - setSessions((current) => current.filter((session) => session.id !== nextSession.id)); - setActiveSessionId(previousSessionId); - if (!uiPreview.current) window.history.replaceState(null, "", previousHref); - setRequestError({ - sessionId: previousSessionId, - message: caught instanceof Error ? caught.message : "新对话未能保存到云端。", - }); - return null; - } finally { - setCreatingSession(false); - } + return nextSession; } function selectSession(sessionId: string) { @@ -418,6 +407,7 @@ export function useSessionManagement(params: SessionManagementParams) { archived: showArchivedSessions, }); const incoming = readSessions(page.sessions, modelCatalog).sessions; + for (const session of incoming) cloudCreatedIds.current.add(session.id); setSessions((current) => mergeSessionPage(current, incoming)); setSessionsCursor(page.nextCursor); } catch (caught) { @@ -434,6 +424,7 @@ export function useSessionManagement(params: SessionManagementParams) { try { const page = await fetchSessions(undefined, { archived: nextArchived }); const parsed = readSessions(page.sessions, modelCatalog).sessions; + for (const session of parsed) cloudCreatedIds.current.add(session.id); const active = sessionsRef.current.find((session) => session.id === activeSessionId); setSessions(active && !parsed.some((session) => session.id === active.id) ? mergeHydratedSession(parsed, active) @@ -462,6 +453,7 @@ export function useSessionManagement(params: SessionManagementParams) { if (query.present && requestedId && !listed.some((session) => session.id === requestedId)) { void lookupSessionById(requestedId, modelCatalog).then((looked) => { if (looked.status === "found") { + cloudCreatedIds.current.add(looked.session.id); setSessions((current) => mergeHydratedSession(current, looked.session)); sessionSelectionSource.current = "history"; selectSession(looked.session.id); diff --git a/frontend/src/lib/home-cloud-sync.ts b/frontend/src/lib/home-cloud-sync.ts index a5036eef..161c4c31 100644 --- a/frontend/src/lib/home-cloud-sync.ts +++ b/frontend/src/lib/home-cloud-sync.ts @@ -248,30 +248,18 @@ export async function fetchDailyStarlanguage(signal: AbortSignal): Promise { 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 }; + return { sessions: [], rawRows: [], 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 }; + return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false }; } const rawRows = Array.isArray(sessionPayload?.sessions) ? sessionPayload.sessions : []; const parsed = readSessions(rawRows, null); @@ -113,7 +111,6 @@ async function loadSessionList(signal: AbortSignal): Promise { return { sessions: parsed.sessions, rawRows, - draftRow: sessionPayload?.draft ?? null, cursor, account: accountPayload as Account, signedOut: false, @@ -145,7 +142,7 @@ export function SessionListProvider({ children }: { children: ReactNode }) { }) .catch(() => { if (controller.signal.aborted) return; - bootRef.current = { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: false }; + bootRef.current = { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false }; setSettled(true); readyPack.resolve(); }); diff --git a/frontend/src/lib/session-list-filter.ts b/frontend/src/lib/session-list-filter.ts index a8472148..293d5da3 100644 --- a/frontend/src/lib/session-list-filter.ts +++ b/frontend/src/lib/session-list-filter.ts @@ -1,14 +1,35 @@ import type { ChatSession } from "@/lib/home-types"; -export function findReusableEmptyConsultation( - sessions: readonly ChatSession[], -): ChatSession | undefined { - return sessions.find((session) => ( - session.sessionType === "consultation" +/** PostgREST `or()` that keeps rectification rows and non-empty consultations. */ +export const EMPTY_CONSULTATION_LIST_FILTER = "session_type.neq.consultation,messages.neq.[]"; + +export function excludeEmptyConsultations Query; +}>(query: Query): Query { + // Rectification content lives on the case table; chat_sessions.messages is + // always []. Only unpublished consultation drafts are `messages = []`. + return query.or(EMPTY_CONSULTATION_LIST_FILTER); +} + +export function applyArchiveFilter Query; + not: (column: string, operator: string, value: null) => Query; +}>(query: Query, archived: boolean): Query { + return archived ? query.not("archived_at", "is", null) : query.is("archived_at", null); +} + +export function isUnsavedEmptyConsultation(session: ChatSession): boolean { + return session.sessionType === "consultation" && !session.archivedAt && session.messagesHydrated - && session.messages.length === 0 - )); + && session.messages.length === 0; +} + +export function replaceUnsavedEmptyConsultations( + sessions: readonly ChatSession[], + nextSession: ChatSession, +): ChatSession[] { + return [nextSession, ...sessions.filter((session) => !isUnsavedEmptyConsultation(session))]; } export function isListedSidebarSession(session: ChatSession): boolean { @@ -17,3 +38,13 @@ export function isListedSidebarSession(session: ChatSession): boolean { if (!session.messagesHydrated) return true; return session.messages.length > 0; } + +export function cloudListIncludesSession(input: { + sessionType: ChatSession["sessionType"]; + messagesEmpty: boolean; + archived: boolean; + archivedView: boolean; +}): boolean { + if (input.sessionType === "consultation" && input.messagesEmpty) return false; + return input.archivedView ? input.archived : !input.archived; +} diff --git a/frontend/src/lib/session-sidebar-row.ts b/frontend/src/lib/session-sidebar-row.ts index 8489870a..433822ed 100644 --- a/frontend/src/lib/session-sidebar-row.ts +++ b/frontend/src/lib/session-sidebar-row.ts @@ -11,8 +11,7 @@ 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 clock = Number.isFinite(session.updatedAt) ? shanghaiDateTimeLabel(new Date(session.updatedAt)) : ""; const parts: string[] = []; if (clock) parts.push(clock); if (session.chartProfileRole && session.chartProfileRole !== "self" && session.chartProfileId) { diff --git a/frontend/tests/chat-session-authority.test.ts b/frontend/tests/chat-session-authority.test.ts index d8a74f55..13ffe091 100644 --- a/frontend/tests/chat-session-authority.test.ts +++ b/frontend/tests/chat-session-authority.test.ts @@ -4,6 +4,7 @@ import test from "node:test"; import { homeSurface as page } from "./home-surface.ts"; const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8"); +const listFilter = readFileSync(new URL("../src/lib/session-list-filter.ts", import.meta.url), "utf8"); const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8"); const consultRoute = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8"); const sql = readFileSync( @@ -12,26 +13,35 @@ 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 \}\)/); +test("session list GET drops empty consultations without swallowing rectification rows", () => { + // 原值:.not("messages", "eq", []) 全表过滤,响应带 draft + // 新值:or(session_type.neq.consultation,messages.neq.[]),响应只有 sessions/nextCursor + // 原因:校正会话 messages 永远是 [](BUG-987);第一问前不落库后不再返回 draft(BUG-989) + assert.match(listFilter, /session_type\.neq\.consultation,messages\.neq\.\[\]/); + assert.match(listRoute, /excludeEmptyConsultations/); + assert.match(listRoute, /session_type/); + assert.doesNotMatch(listRoute, /\.not\("messages", "eq", \[\]\)/); + assert.doesNotMatch(listFilter, /\.not\("messages", "eq", \[\]\)/); + assert.match(listRoute, /return NextResponse\.json\(\{ sessions, nextCursor \}\)/); + assert.doesNotMatch(listRoute, /draft/); + assert.doesNotMatch(itemRoute, /\.or\("session_type\.neq\.consultation,messages\.neq\.\[\]"\)/); 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) + // 原值:列表列含 created_at,供副标题用创建时间 + // 新值:SESSION_LIST_COLUMNS 不含 created_at + // 原因:副标题改最后活动时间,与排序/分组/游标同源(BUG-988) 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,created_at,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,updated_at,pinned,archived_at"/, ); - // 原值:select(SESSION_LIST_COLUMNS) 之后不得出现 messages - // 新值:WHERE 用 .not("messages", "eq", []),SELECT 列仍不含 messages - // 原因:T3 空咨询过滤要看 messages,但不把正文带回列表 + assert.doesNotMatch(listRoute, /SESSION_LIST_COLUMNS = "[^"]*created_at/); + // 原值:WHERE 用 .not("messages", "eq", []),SELECT 列仍不含 messages + // 新值:WHERE 用 session_type 限定的 or(),SELECT 列仍不含 messages + // 原因:T1 空咨询过滤要看 messages,但不把正文带回列表 assert.doesNotMatch(listRoute, /SESSION_LIST_COLUMNS = "[^"]*messages/); assert.doesNotMatch( listRoute, diff --git a/frontend/tests/chat-session-url.test.ts b/frontend/tests/chat-session-url.test.ts index 0ee32384..5825c50d 100644 --- a/frontend/tests/chat-session-url.test.ts +++ b/frontend/tests/chat-session-url.test.ts @@ -174,8 +174,14 @@ test("popstate to a missing session query reuses selectSession side effects for test("creating and leaving a session keep the address bar in sync", () => { const startNewChat = sourceBetween(page, "async function startNewChat(", "function selectSession("); - assert.match(startNewChat, /writeSessionUrl\(nextSession\.id, "push"\)/); - assert.match(startNewChat, /window\.history\.replaceState\(null, "", previousHref\)/); + // 原值:新建立刻 writeSessionUrl(nextSession.id, "push"),失败再 replaceState 回旧地址 + // 新值:本地创建不写 ?c=;POST 成功后 persistSession(create) 才 push + // 原因:第一问前不落库(BUG-989),未落库会话没有服务端身份 + assert.doesNotMatch(startNewChat, /writeSessionUrl\(nextSession\.id, "push"\)/); + assert.doesNotMatch(startNewChat, /persistSession\(/); + const persistSession = sourceBetween(page, "async function persistSession(", "async function ensureSessionMessages("); + assert.match(persistSession, /if \(mode === "create"\) \{/); + assert.match(persistSession, /writeSessionUrl\(session\.id, "push"\)/); // 原值:delete/archive 函数体内直接 `writeSessionUrl(fallbackId || null, "replace")` // 新值:删/归档当前会话走 `activateFallbackSession`,由它 `writeSessionUrl(null|fallbackId, "replace")` diff --git a/frontend/tests/chat-session-write.test.ts b/frontend/tests/chat-session-write.test.ts index 867bf3b2..96d377b3 100644 --- a/frontend/tests/chat-session-write.test.ts +++ b/frontend/tests/chat-session-write.test.ts @@ -313,7 +313,11 @@ test("self-hosted staging bootstrap reads profile and sessions through same-orig assert.doesNotMatch(page, /createBrowserSupabaseClient/); assert.match(page, /fetch\("\/api\/account"/); assert.match(page, /fetch\("\/api\/sessions"/); - assert.match(page, /writeChatSession\(initialSession\.id,[\s\S]*?"create"\)/); + // 原值:启动时空列表立刻 writeChatSession(initialSession.id, ..., "create") + // 新值:本地 createSession,第一问 send() 才 persistSession(..., "create") + // 原因:BUG-989 第一问之前不落库 + assert.doesNotMatch(page, /writeChatSession\(initialSession\.id,[\s\S]*?"create"\)/); + assert.match(page, /await persistSession\(currentSession, "create"\)/); assert.match(page, /fetch\(`\/api\/sessions\/\$\{encodeURIComponent\(sessionId\)\}`/); assert.match(accountRoute, /AUTH_PROVIDER\?\.trim\(\) === "self-hosted"/); assert.match(accountRoute, /profile,/); diff --git a/frontend/tests/database-session-list-visibility.test.ts b/frontend/tests/database-session-list-visibility.test.ts new file mode 100644 index 00000000..2292dee5 --- /dev/null +++ b/frontend/tests/database-session-list-visibility.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + closeLocalPostgresDataPools, + createLocalPostgresDataClient, +} from "../src/lib/db/local-postgres-client-core.ts"; +import { applyArchiveFilter, excludeEmptyConsultations } from "../src/lib/session-list-filter.ts"; +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); +const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0; + +const emptyConsultation = "11111111-1111-4111-8111-111111111111"; +const emptyRectification = "22222222-2222-4222-8222-222222222222"; +const filledConsultation = "33333333-3333-4333-8333-333333333333"; +const archivedRectification = "44444444-4444-4444-8444-444444444444"; + +test("session list query keeps empty rectification rows and drops empty consultations", { + skip: docker ? false : "docker unavailable", +}, async () => { + const fixture = startPostgresFixture(); + try { + const migration = spawnSync(process.execPath, [runner], { + encoding: "utf8", + env: { + ...process.env, + SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"), + }, + }); + assert.equal(migration.status, 0, migration.stderr); + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + `insert into identity.users(name,email,email_verified,email_verified_at) values ('Fictional List','list-visibility@example.com',true,now());`, + ); + const userId = fixture.psql("select id from identity.users where email='list-visibility@example.com'"); + fixture.psql(` + insert into public.chat_sessions (id, user_id, title, theme, model_id, messages, session_type, pinned, archived_at, updated_at) + values + ('${emptyConsultation}', '${userId}', 'Empty consult', 'general', 'test-model', '[]', 'consultation', false, null, now()), + ('${emptyRectification}', '${userId}', 'Empty rectification', 'general', 'test-model', '[]', 'birth_time_rectification', false, null, now()), + ('${filledConsultation}', '${userId}', 'Filled consult', 'general', 'test-model', '[{"role":"user","text":"问一句"}]', 'consultation', false, null, now()), + ('${archivedRectification}', '${userId}', 'Archived rectification', 'general', 'test-model', '[]', 'birth_time_rectification', false, '2026-09-08T00:00:00Z', now()); + `); + const local = createLocalPostgresDataClient( + fixture.connectionUrl("app_runtime", "app-runtime-test-password"), + { id: userId, email: "list-visibility@example.com" }, + ); + const live = await excludeEmptyConsultations( + applyArchiveFilter( + local.from("chat_sessions").select("id").eq("user_id", userId).eq("pinned", false), + false, + ), + ); + assert.equal(live.error, null, live.error?.message); + const liveIds = (live.data as { id: string }[]).map((row) => row.id).sort(); + assert.deepEqual(liveIds, [emptyRectification, filledConsultation].sort()); + const archived = await excludeEmptyConsultations( + applyArchiveFilter( + local.from("chat_sessions").select("id").eq("user_id", userId).eq("pinned", false), + true, + ), + ); + assert.equal(archived.error, null, archived.error?.message); + assert.deepEqual( + (archived.data as { id: string }[]).map((row) => row.id), + [archivedRectification], + ); + } finally { + await closeLocalPostgresDataPools(); + fixture.stop(); + } +}); diff --git a/frontend/tests/local-postgres-or.test.ts b/frontend/tests/local-postgres-or.test.ts index ee0eef5b..03fde9dc 100644 --- a/frontend/tests/local-postgres-or.test.ts +++ b/frontend/tests/local-postgres-or.test.ts @@ -7,6 +7,7 @@ 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"); +const listFilter = readFileSync(new URL("../src/lib/session-list-filter.ts", import.meta.url), "utf8"); test("or() compiles a comma-separated PostgREST filter", () => { const parameters: unknown[] = []; @@ -48,3 +49,10 @@ test("or() compiles the session cursor filter with a nested and", () => { ]); assert.match(listRoute, /pageQuery = pageQuery\.or\(sessionCursorFilter\(cursor\)\)/); }); + +test("session list empty-consultation filter is session_type scoped", () => { + assert.match(listFilter, /session_type\.neq\.consultation,messages\.neq\.\[\]/); + assert.match(listRoute, /excludeEmptyConsultations/); + assert.doesNotMatch(listRoute, /\.not\("messages", "eq", \[\]\)/); + assert.doesNotMatch(listFilter, /\.not\("messages", "eq", \[\]\)/); +}); diff --git a/frontend/tests/session-groups.test.ts b/frontend/tests/session-groups.test.ts index aa9d0475..90517ba5 100644 --- a/frontend/tests/session-groups.test.ts +++ b/frontend/tests/session-groups.test.ts @@ -8,6 +8,9 @@ import { recencyKeyFor, sortSessions, } from "../src/lib/session-groups.ts"; +import { sessionSidebarSubtitle } from "../src/lib/session-sidebar-row.ts"; +import { shanghaiDateTimeLabel } from "../src/lib/session-shanghai-clock.ts"; +import type { ChatSession } from "../src/lib/home-types.ts"; function session(id: string, updatedAt: number, pinned = false) { return { id, pinned, updatedAt }; @@ -72,6 +75,44 @@ test("mergeSessionPage keeps the local row and skips a duplicate id", () => { assert.equal(mergeSessionPage([local], incoming)[0]?.updatedAt, 900); }); +test("sidebar subtitle follows updatedAt so the visible clock matches sortSessions", () => { + const createdEarly = Date.parse("2026-09-07T05:42:00.000Z"); + const activeLate = Date.parse("2026-09-16T10:01:00.000Z"); + const mid = Date.parse("2026-09-17T14:53:00.000Z"); + const newest = Date.parse("2026-09-18T00:01:00.000Z"); + function row(id: string, createdAt: number, updatedAt: number): ChatSession { + return { + id, + title: id, + theme: "general", + modelId: "m", + messages: [{ role: "user", text: "问一句" }], + createdAt, + updatedAt, + sessionType: "consultation", + rectificationCaseId: null, + chartProfileId: null, + chartProfileName: null, + chartProfileRole: null, + pinned: false, + archivedAt: null, + messagesHydrated: true, + }; + } + const earlyCreatedLateActive = row("early-created", createdEarly, activeLate); + const sessions = [ + earlyCreatedLateActive, + row("mid", mid, mid), + row("newest", newest, newest), + ]; + assert.deepEqual(sortSessions(sessions).map((item) => item.id), ["newest", "mid", "early-created"]); + const clocks = sortSessions(sessions).map((item) => sessionSidebarSubtitle(item)); + const expected = sortSessions(sessions).map((item) => shanghaiDateTimeLabel(new Date(item.updatedAt))); + assert.deepEqual(clocks, expected); + assert.equal(sessionSidebarSubtitle(earlyCreatedLateActive), shanghaiDateTimeLabel(new Date(activeLate))); + assert.notEqual(sessionSidebarSubtitle(earlyCreatedLateActive), shanghaiDateTimeLabel(new Date(createdEarly))); +}); + test("beginSessionPageLoad only starts one in-flight request", () => { const inFlight = { current: false }; assert.equal(beginSessionPageLoad(inFlight, "cursor"), true); diff --git a/frontend/tests/session-list-filter.test.ts b/frontend/tests/session-list-filter.test.ts index ae03424f..4f67140b 100644 --- a/frontend/tests/session-list-filter.test.ts +++ b/frontend/tests/session-list-filter.test.ts @@ -3,13 +3,19 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import { - findReusableEmptyConsultation, + cloudListIncludesSession, + EMPTY_CONSULTATION_LIST_FILTER, isListedSidebarSession, + isUnsavedEmptyConsultation, + replaceUnsavedEmptyConsultations, } 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"); +const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8"); +const listContext = readFileSync(new URL("../src/lib/session-list-context.tsx", import.meta.url), "utf8"); +const consultRun = readFileSync(new URL("../src/hooks/use-consultation-run.ts", import.meta.url), "utf8"); function session(change: Partial): ChatSession { return { @@ -31,7 +37,7 @@ function session(change: Partial): ChatSession { }; } -test("an empty hydrated consultation can be reused instead of creating another", () => { +test("empty consultations stay off the sidebar while rectification rows stay on it", () => { const empty = session({ id: "empty" }); const listed = session({ id: "listed", @@ -43,11 +49,71 @@ test("an empty hydrated consultation can be reused instead of creating another", sessionType: "birth_time_rectification", title: "生时校正 · 9月14日", }); - assert.equal(findReusableEmptyConsultation([listed, empty, rectification])?.id, "empty"); - assert.equal(findReusableEmptyConsultation([listed, rectification]), undefined); + const archivedRectification = session({ + id: "archived-rect", + sessionType: "birth_time_rectification", + archivedAt: "2026-09-08T00:00:00.000Z", + }); 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/); + assert.equal(isListedSidebarSession(archivedRectification), false); + assert.equal(isUnsavedEmptyConsultation(empty), true); + assert.equal(isUnsavedEmptyConsultation(rectification), false); +}); + +test("server list filter and sidebar listing agree on empty consultation, rectification, and archive", () => { + const cases = [ + { sessionType: "consultation" as const, messagesEmpty: true, archived: false, archivedView: false, listed: false }, + { sessionType: "birth_time_rectification" as const, messagesEmpty: true, archived: false, archivedView: false, listed: true }, + { sessionType: "consultation" as const, messagesEmpty: false, archived: false, archivedView: false, listed: true }, + { sessionType: "birth_time_rectification" as const, messagesEmpty: true, archived: true, archivedView: false, listed: false }, + { sessionType: "birth_time_rectification" as const, messagesEmpty: true, archived: true, archivedView: true, listed: true }, + { sessionType: "consultation" as const, messagesEmpty: true, archived: true, archivedView: true, listed: false }, + ]; + for (const row of cases) { + const cloud = cloudListIncludesSession(row); + const client = isListedSidebarSession(session({ + sessionType: row.sessionType, + messages: row.messagesEmpty ? [] : [{ role: "user", text: "问一句" }], + archivedAt: row.archived ? "2026-09-08T00:00:00.000Z" : null, + messagesHydrated: true, + })); + assert.equal(cloud, row.listed, JSON.stringify(row)); + if (!row.archivedView) assert.equal(client, row.listed, JSON.stringify(row)); + } + assert.equal(EMPTY_CONSULTATION_LIST_FILTER, "session_type.neq.consultation,messages.neq.[]"); + assert.match(listRoute, /excludeEmptyConsultations/); + assert.match(listRoute, /from "@\/lib\/session-list-filter"/); +}); + +test("new chat stays local until the first send and does not reuse a draft row", () => { + const empty = session({ id: "empty" }); + const listed = session({ + id: "listed", + messages: [{ role: "user", text: "问一句" }], + title: "半年内换工作时机", + }); + const next = session({ id: "next" }); + assert.deepEqual( + replaceUnsavedEmptyConsultations([listed, empty], next).map((item) => item.id), + ["next", "listed"], + ); + assert.doesNotMatch(management, /findReusableEmptyConsultation/); + assert.doesNotMatch(page, /readDraftConsultation|findReusableEmptyConsultation|writeChatSession/); + assert.doesNotMatch(listContext, /draftRow/); + assert.match(management, /replaceUnsavedEmptyConsultations\(current, nextSession\)/); + assert.doesNotMatch(management, /writeSessionUrl\(nextSession\.id, "push"\)/); + assert.match(consultRun, /isUnsavedEmptyConsultation\(currentSession\)/); + assert.match(consultRun, /await persistSession\(currentSession, "create"\)/); + assert.match(management, /if \(mode === "create"\) \{/); + assert.match(management, /cloudCreatedIds\.current\.add\(session\.id\)/); + assert.match(management, /writeSessionUrl\(session\.id, "push"\)/); + const persistBlockStart = consultRun.indexOf("if (isUnsavedEmptyConsultation(currentSession))"); + const persistBlockEnd = consultRun.indexOf("const [year, month, day]"); + assert.ok(persistBlockStart >= 0 && persistBlockEnd > persistBlockStart); + const persistBlock = consultRun.slice(persistBlockStart, persistBlockEnd); + assert.match(persistBlock, /setSessions\(remaining\)/); + assert.match(persistBlock, /setRequestError/); + assert.doesNotMatch(persistBlock, /setDraft\(/); });