fix(web): keep rectification sessions listed and persist chats on first send
Independent Staging Quality Gate / validate (push) Failing after 16m52s
Independent Staging Quality Gate / publish (push) Skipped

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.
This commit is contained in:
jesse-ux
2026-09-21 16:39:32 +08:00
parent f8d65e484d
commit e71e4f9200
21 changed files with 461 additions and 186 deletions
+4 -4
View File
@@ -117,11 +117,11 @@
- **现状:** 四个页面共用一份列表、切页不重拉已落地。星盘 / 星历 / 报告页不能改名或删除。
- **解除需要:** 把只依赖 `sessions` + fetch 的元数据操作搬进 `SessionListProvider`,四页都能用。另立单。
## 会话列表:空咨询延迟落库未做(2026-09-17BUG-928
## ~~会话列表:空咨询延迟落库未做(2026-09-17BUG-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-21BUG-989):** `startNewChat` 只在本地开一条,第一问 `send()` `POST /api/sessions`;未落库不写 `?c=`。列表过滤收窄到咨询空行,校正会话不再被 `messages = []` 误删(BUG-987)。刷新丢掉未开口的本地空会话是可接受的
## 会话列表多键排序:无 Docker 未跑翻页重叠(2026-09-17BUG-926
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-09-21 — 侧栏重新列出生时校正,时间按最后活动,新建对话开口前不落库
侧栏不再把生时校正会话藏起来。每条下面的时间改成最后一次说话或校正操作的时间,和列表排序、分组一致。点「新建对话」先只在这一页开着,真正发出第一句话才存到云端;连点不会堆出一排空的「新对话」。Skill 版本不变。
## 2026-09-20 — 校正窗口保留真实日期,跨午夜采用不再丢日期(本地实现,未发布)
凌晨申报的前半窗口落在前一天;只记得夜里时先问午夜前后,不知道或跳过则仅保留填报当天凌晨与深夜两段。候选排序和宽度使用日期序号、实际分钟与声明分段,不把间隙填成全天。交付显示实际日期及「前一天 / 后一天」;本命上升分段也保留完整日期。扩窗沿用已持久日期与用户选侧,未知/跳过的允许两段不因后续缩窄被改写,无法满足约束时不保存。经 D4 授权新增兼容迁移,独立保留采用日期及实际计算 offset,原填报日期不变,排盘、咨询和报告同源读取。固定 offset 不代表已修复 IANA/DST。算法身份更新,历史结果只读不重标;评分、权重、确认阈值与 Skill 版本不变。最终全量、受控真人与部署验收尚待完成。
+48
View File
@@ -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 / 无 URLsend 时 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
@@ -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。 |
## 让步
T1T3 都做了,功能上无让步。
环境缺口(替代证据):
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。
+1 -1
View File
@@ -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` |
### 个人报告
+1 -1
View File
@@ -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 ` · <chart name>` 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 ` · <chart name>` 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.
+16 -51
View File
@@ -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);
+3 -31
View File
@@ -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 extends {
not: (column: string, operator: string, value: unknown) => 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 extends {
is: (column: string, value: null) => 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<typeof pageRows>[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 });
@@ -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) ?? [];
+40 -48
View File
@@ -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<string, { continuedFromSessionId?: string }>());
const cloudCreatedIds = useRef(new Set<string>());
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<ChatSession | null> {
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);
+3 -15
View File
@@ -248,30 +248,18 @@ export async function fetchDailyStarlanguage(signal: AbortSignal): Promise<Daily
export type SessionListPage = {
readonly sessions: unknown;
readonly nextCursor: string | null;
readonly draft: unknown;
};
export function readSessionListPage(value: unknown): SessionListPage {
if (Array.isArray(value)) return { sessions: value, nextCursor: null, draft: null };
if (!value || typeof value !== "object") return { sessions: [], nextCursor: null, draft: null };
const page = value as { sessions?: unknown; nextCursor?: unknown; draft?: unknown };
if (Array.isArray(value)) return { sessions: value, nextCursor: null };
if (!value || typeof value !== "object") return { sessions: [], nextCursor: null };
const page = value as { sessions?: unknown; nextCursor?: unknown };
return {
sessions: page.sessions,
nextCursor: typeof page.nextCursor === "string" && page.nextCursor ? page.nextCursor : null,
draft: page.draft ?? null,
};
}
export function readDraftConsultation(
value: unknown,
catalog: PublicLanguageModelCatalog | null,
): ChatSession | null {
if (!value || typeof value !== "object") return null;
const parsed = readSessions([{ ...(value as object), messages: [] }], catalog).sessions[0];
if (!parsed || parsed.sessionType !== "consultation") return null;
return { ...parsed, messages: [], messagesHydrated: true };
}
export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null): SessionReadResult {
if (!Array.isArray(value)) return { sessions: [], fallbackSessionIds: [] };
const fallbackSessionIds: string[] = [];
+3 -6
View File
@@ -24,7 +24,6 @@ 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;
@@ -96,16 +95,15 @@ async function loadSessionList(signal: AbortSignal): Promise<SessionListBoot> {
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<SessionListBoot> {
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();
});
+38 -7
View File
@@ -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 extends {
or: (expression: string) => 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 extends {
is: (column: string, value: null) => 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;
}
+1 -2
View File
@@ -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) {
+21 -11
View File
@@ -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,
+8 -2
View File
@@ -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")`
+5 -1
View File
@@ -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,/);
@@ -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();
}
});
+8
View File
@@ -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", \[\]\)/);
});
+41
View File
@@ -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);
+72 -6
View File
@@ -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>): ChatSession {
return {
@@ -31,7 +37,7 @@ function session(change: Partial<ChatSession>): 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\(/);
});