fix(web): retire session archive UI and API, restore archived rows
Remove archive/restore from the session menu and drop archived=1 listing. PATCH still accepts archived_at from old bundles then ignores it. A forward migration clears archived_at without bumping updated_at. Delete stays unchanged.
This commit is contained in:
@@ -123,6 +123,11 @@
|
||||
- ~~**替代:** `GET /api/sessions` 排除 `messages = []`,响应带 `draft`;启动和「新建对话」优先用这份空咨询,不再连点就堆新行。~~
|
||||
- **解除(2026-09-21,BUG-989):** `startNewChat` 只在本地开一条,第一问 `send()` 才 `POST /api/sessions`;未落库不写 `?c=`。列表过滤收窄到咨询空行,校正会话不再被 `messages = []` 误删(BUG-987)。刷新丢掉未开口的本地空会话是可接受的。
|
||||
|
||||
## 归档下线迁移:本机 Docker 网段耗尽未跑 test:db(2026-09-21,BUG-991)
|
||||
|
||||
- **Docker 在 PATH**,但 compose 建网络报 `all predefined address pools have been fully subnetted`。`database-session-list-visibility.test.ts` 的迁移清空断言**未跑,不得写成通过。**
|
||||
- **替代证据:** 迁移 SQL 只 `set archived_at = null where archived_at is not null`,不含 `updated_at`。最终验证交给门禁。BUG-991 保持 investigating。
|
||||
|
||||
## 会话列表归档:本机 Docker 网段耗尽未跑 test:db(2026-09-21,BUG-990)
|
||||
|
||||
- **Docker 在 PATH**,但 `startPostgresFixture` 建 compose 网络报 `all predefined address pools have been fully subnetted`(其它 worktree 留下的 postgres 网络占满)。**未跑 `npm run test:db`,不得写成通过。**
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-21 — 归档功能撤掉了,以前归档过的对话回到列表
|
||||
|
||||
会话菜单里不再有「归档」。以前归档过的对话会重新出现在侧栏,时间不会被改成今天。删除对话不受影响。Skill 版本不变。
|
||||
|
||||
## 2026-09-21 — 归档记录可以打开了
|
||||
|
||||
侧栏点「归档记录」不再报读不到聊天。Skill 版本不变。
|
||||
|
||||
+6
-6
@@ -13125,13 +13125,13 @@
|
||||
- 状态:investigating
|
||||
- 首次发现:2026-09-21
|
||||
- 最近更新:2026-09-21
|
||||
- 影响面:`app-sidebar.tsx` 会话分组页眉、`SidebarSessionControls.onToggleArchivedView` / `archivedCount`、`toggleArchivedView`
|
||||
- 影响面:会话行菜单「归档 / 恢复」、`GET /api/sessions?archived=1`、`archived_at` 存量
|
||||
- 用户现象:会话行菜单里可以「归档」,归档后它从侧栏消失;此后没有任何界面能列出归档记录,行菜单里的「恢复」也跟着不可达。除非记得 `?c=<uuid>` 深链,归档等于单向丢失。
|
||||
- 触发条件:在会话行菜单点「归档」。
|
||||
- 根因:`92558ee6`(2026-08-22,`fix(web): stack mobile audit tables and restore sidebar hierarchy`)删掉了分组页眉上的 `session-nav-toggle` 按钮(原文案「归档 N」/「返回」),但保留了它背后的整条链路:`onToggleArchivedView`、`archivedCount`、`showingArchived`、`toggleArchivedView()` 与 `GET /api/sessions?archived=1` 全部还在,只是没有调用者。`app-sidebar.tsx` 里两处 `showingArchived ? "归档记录" : …` 的三元条件因此恒取后者。全仓唯一提到 `onToggleArchivedView` 的非类型声明位置是 `use-home-shell-registration.ts` 里提供它的那一行。
|
||||
- 修复:待产品决策——恢复入口,或把归档能力整体下线(用户偏好:多余入口宁可删除也不修)。本轮只记录事实。
|
||||
- 验证:`grep -rn "ToggleArchivedView" frontend/src` 只有声明与提供方,无渲染调用点;`grep -rn "归档" frontend/src --include=*.tsx` 只剩行菜单文案与分组 aria-label。
|
||||
- 防复发:删除某个视图的唯一入口时,必须同时下线其数据链路,或留一条断言入口存在的测试;不得留下"接口活着、界面没有"的半截功能。
|
||||
- 根因:`92558ee6`(2026-08-22)删掉了分组页眉上的归档切换按钮,但保留了整条数据链路。产品 2026-09-21 拍板整体下线,不装回入口。
|
||||
- 修复:删行菜单归档/恢复、删 `archived=1` 查询与 PATCH 写 `archived_at`(旧 bundle 仍发送则 2xx 忽略)、迁移把存量 `archived_at` 清空且不 bump `updated_at`。删除会话能力不动。`archived_at` 列本轮不删。
|
||||
- 验证:`sidebar-contract.test.ts` 行菜单恰好是重命名/收藏/分享/删除;`local-postgres-not.test.ts` 原样保留;`database-session-list-visibility.test.ts` 仍断言 `archived_at` 非空不入列,并测迁移清空。**门禁该 Postgres 测试转绿前不得标 resolved。**
|
||||
- 防复发:删除某个视图的唯一入口时必须同轮下线其数据链路,或留一条断言入口存在的测试。
|
||||
- 相关记录:BUG-990、BUG-553
|
||||
- 复发自:无
|
||||
- 修复版本:未修复
|
||||
- 修复版本:待本修复合入 staging 且门禁转绿
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# PROGRESS · 归档能力整体下线(2026-09-21)
|
||||
|
||||
工作树:`.worktrees/archive-retire-20260921`
|
||||
分支:`codex/archive-retire-20260921`
|
||||
基线:`origin/staging` @ `f8cb1dcd`(任务书;最近门禁提交 `df329fa9`)
|
||||
|
||||
## 任务状态
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| T1 前端下线 | 完成 | 行菜单、控件、hook、legacy localStorage 写库都去掉;删除路径不动 |
|
||||
| T2 服务端下线 | 完成 | 列表恒 `archived_at is null`;PATCH 忽略 `archived_at` |
|
||||
| T3 存量放回 | 完成 | 迁移清空 `archived_at`,不写 `updated_at` |
|
||||
| T4 记录 | 完成 | BUG-991 investigating,等门禁 Postgres 转绿再 resolved |
|
||||
|
||||
## 改断言三栏
|
||||
|
||||
| 测试 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `database-session-list-visibility` 归档视图半边 | `applyArchiveFilter(..., true)` 返回 `[archivedRectification]` | 删除该半边 | 归档视图已下线 |
|
||||
| 同上「非空 archived_at 不入列」 | 有 | 保留 | 列还在,守卫仍需测 |
|
||||
| `session-list-filter` 跨层组合 | 六组含 `archivedView` | 四组,无归档视图 | 同上 |
|
||||
| `chat-session-write` metadata schema | 接受 `archived_at` | schema 拒绝;extract 读后忽略 | 旧 bundle 不得 400 |
|
||||
| `chat-session-authority` 列表 GET | `isArchivedSessionQuery` | `.is("archived_at", null)` | 不再有归档查询参数 |
|
||||
| `chat-session-url` `toggleArchivedSession` | 归档也走 fallback URL | 函数删除,删除路径仍走 fallback | 归档下线 |
|
||||
|
||||
## 测试
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | 0 error / **119 warning**(未增) |
|
||||
| 定向 | sidebar-contract / chat-session-authority / write / url / session-list-filter / lifecycle / chart-library / local-postgres-not / home-shell-growth **102 过** |
|
||||
| `npm test` | **3603** 条,**3514** 过 / **89** 红。较本机 `df329fa9` 全量(3600 / 3512 / 88)净增 2 绿(行菜单合同、PATCH 忽略 `archived_at`)+ 1 红(新的迁移 Postgres 测试,Docker 网段耗尽)。其余失败清单仍是本机 Windows 旧项。 |
|
||||
| `grep 归档 frontend/src` | 无命中 |
|
||||
| `grep ToggleArchivedView frontend/src` | 无命中 |
|
||||
| `npm run test:db` | Docker 网段耗尽,未跑通。BUG-991 保持 investigating。 |
|
||||
| `next build` | 本轮未跑(Windows symlink EPERM 已知缺口) |
|
||||
|
||||
`local-postgres-not.test.ts` 一字未改,定向仍绿。
|
||||
@@ -154,7 +154,7 @@
|
||||
| — | `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` | `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 起 | **已验收**(经修复单) | `e71e4f92`(Claude 验收:T1/T2/T3 实现均正确;tsc 0 / lint 0 error 119 warning 与基线同 / `npm test` 本机 3640 条 34 红与 `f8d65e48` 逐条一致、净增 5 绿 / `next build --webpack` 通过、`/` Static、首屏 gzip 643,115 较基线 −0.027% / page.tsx 1826→1791。**P1:门禁 run 2832 红、staging 未部署**——新加的真实 Postgres 测试在有 Docker 的门禁机上跑出 `unsupported not filter`,兼容层 `not()` 不支持 `is` 算子,归档分支炸。修复单 `TASK-session-list-rebuild-fix-20260921.md` 已验收通过,`df329fa9` 已部署) |
|
||||
| `TASK-session-list-rebuild-fix-20260921.md` | `PROGRESS-session-list-rebuild-fix-20260921.md` | **验收修复单(挡着三条修复上线)**:本地 PG 兼容层 `LocalPostgresQueryBuilder.not()` 只认 `eq` / `cs` 两个算子,`not("archived_at","is",null)` 直接 throw → `GET /api/sessions?archived=1` 落外层 catch 回 500,**侧栏「归档记录」自 `a1956deb`(09-06,BUG-553)起一直打不开**,两周无人发现是因为只有源码正则合同看着它;本轮新加的真实 Postgres 测试第一次真跑到这条路径,把它照出来,于是门禁 run 2832 红、`e71e4f92` 没部署。**决策**:在兼容层补 `is not null / is not true / is not false`,不得反过来改调用方绕开,不得删改测试断言让门禁变绿。与 BUG-926 的 `order()` 缺口同类。BUG 段 990 起 | **已验收通过** | `df329fa9`(Claude 验收:`not()` 增 `isNot` kind,统一走新的 `compileUnaryIsClause` 编译 `is [not] null/true/false`,非法取值仍 throw;`filterClause` 是唯一消费者、已覆盖,未落进兜底分支。**门禁 run 2833 全绿:3653 条 / 3653 过 / 0 红 / 0 skip**,`ok 1238` 即那条真实 Postgres 归档测试;migrate 2834、deploy 2835 成功,`/api/health` 的 `deployment.gitCommit` = `df329fa9`。本机复跑:tsc 0 / lint 0 error 119 warning 未增 / `npm test` 用例名逐条 diff 较 `e71e4f92` **恰好 +2 无删除**、失败清单完全一致 / `next build --webpack` 通过、`/` Static、首屏 gzip 643,116(+1 B)。**顺带查出 BUG-991**:归档视图的 UI 入口在 `92558ee6`(08-22)就被删了,`onToggleArchivedView` 全仓无调用点 → BUG-990 原描述「用户点归档记录后 500」不成立(接口层 500 属实,用户碰不到),已在 Bug 历史更正;归档后看不回来也恢复不了是真问题,待产品决策) |
|
||||
| `TASK-archive-retire-20260921.md` | — | **归档能力整体下线(BUG-991)**:行菜单能「归档」,归档后没有任何界面能列出或恢复它——切换按钮 `session-nav-toggle`(「归档 N」/「返回」)在 `92558ee6`(2026-08-22)被删,但 `onToggleArchivedView` / `archivedCount` / `showingArchived` / `toggleArchivedView()` / `GET /api/sessions?archived=1` / `applyArchiveFilter` 整条链路留着,全仓无渲染调用点;行菜单的「恢复」同样不可达(归档行永远不进 `visibleSessions`)。接口自 `a1956deb`(09-06)起就是 500(BUG-990),一个月没人发现正因为点不到。**产品 09-21 拍板整个下线**(口径:多余入口宁可删除也不修),不装回入口。D2 本轮不删 `archived_at` 列(§7.6 拆两轮),只清空存量;D3 不得回退 BUG-990 的兼容层修复;D4 存量归档会话一律放回列表。**迁移禁止 bump `updated_at`**,否则全部历史会话跳顶、毁掉刚修好的 BUG-988。让步顺序里 T3 必须先于 T1/T2。删除能力不动 | 待领取 | — |
|
||||
| `TASK-archive-retire-20260921.md` | `PROGRESS-archive-retire-20260921.md` | **归档能力整体下线(BUG-991)**:行菜单能「归档」,归档后没有任何界面能列出或恢复它——切换按钮 `session-nav-toggle`(「归档 N」/「返回」)在 `92558ee6`(2026-08-22)被删,但 `onToggleArchivedView` / `archivedCount` / `showingArchived` / `toggleArchivedView()` / `GET /api/sessions?archived=1` / `applyArchiveFilter` 整条链路留着,全仓无渲染调用点;行菜单的「恢复」同样不可达(归档行永远不进 `visibleSessions`)。接口自 `a1956deb`(09-06)起就是 500(BUG-990),一个月没人发现正因为点不到。**产品 09-21 拍板整个下线**(口径:多余入口宁可删除也不修),不装回入口。D2 本轮不删 `archived_at` 列(§7.6 拆两轮),只清空存量;D3 不得回退 BUG-990 的兼容层修复;D4 存量归档会话一律放回列表。**迁移禁止 bump `updated_at`**,否则全部历史会话跳顶、毁掉刚修好的 BUG-988。让步顺序里 T3 必须先于 T1/T2。删除能力不动 | 待验收 | `codex/archive-retire-20260921` |
|
||||
|
||||
### 个人报告
|
||||
|
||||
|
||||
@@ -98,14 +98,16 @@
|
||||
- ✅ 连点两次不会重复插入同一批会话。
|
||||
- ❌ 加载更多后列表顺序乱了,或出现重复标题。
|
||||
|
||||
### C2. 归档视图
|
||||
### ~~C2. 归档视图~~
|
||||
|
||||
1. 点侧栏「查看已归档」,再点回来。
|
||||
归档能力已于 2026-09-21 下线(BUG-991)。
|
||||
|
||||
- ✅ 两个视图的会话集合互不重叠:归档视图只显示已归档,正常视图只显示未归档。
|
||||
- ✅ 当前正在看的会话即使被过滤条件排除,也仍然留在列表里(原有规则)。
|
||||
- ✅ 切换视图时**会话菜单自动收起**。
|
||||
- ✅ 归档数量角标与实际归档条数一致。
|
||||
1. ~~点侧栏「查看已归档」,再点回来。~~
|
||||
|
||||
- ~~✅ 两个视图的会话集合互不重叠:归档视图只显示已归档,正常视图只显示未归档。~~
|
||||
- ~~✅ 当前正在看的会话即使被过滤条件排除,也仍然留在列表里(原有规则)。~~
|
||||
- ~~✅ 切换视图时**会话菜单自动收起**。~~
|
||||
- ~~✅ 归档数量角标与实际归档条数一致。~~
|
||||
|
||||
### C3. 会话菜单
|
||||
|
||||
@@ -114,9 +116,9 @@
|
||||
- ✅ 同一时刻只有一个菜单展开;点另一条会自动关掉前一个。
|
||||
- ✅ 生成回答中(有进行中的咨询)时菜单项按原规则禁用。
|
||||
|
||||
### C4. 重命名 / 置顶 / 归档 / 分享
|
||||
### C4. 重命名 / 置顶 / 分享
|
||||
|
||||
逐个操作一遍。
|
||||
逐个操作一遍。归档能力已于 2026-09-21 下线(BUG-991)。
|
||||
|
||||
- ✅ 每项的提示文案、成功后的列表位置变化与改前一致。
|
||||
- ✅ 置顶后该会话上移到置顶区;取消置顶后回到时间序。
|
||||
|
||||
@@ -60,11 +60,11 @@
|
||||
|
||||
账号历史明显超过 40 条时:
|
||||
|
||||
1. 打开未归档历史。
|
||||
1. 打开历史。
|
||||
2. 滚到列表底部,再往下一点。
|
||||
3. 切到归档视图,同样滚到底。
|
||||
3. ~~切到归档视图,同样滚到底。~~ 归档能力已于 2026-09-21 下线(BUG-991)。
|
||||
|
||||
- ✅ 预期:首屏大约是最近 40 条非置顶,加上全部收藏。滚到底没有「加载更多」或「没有更多」文案,新行直接接上。归档视图同样分页。
|
||||
- ✅ 预期:首屏大约是最近 40 条非置顶,加上全部收藏。滚到底没有「加载更多」或「没有更多」文案,新行直接接上。
|
||||
- ❌ 失败:一次画出全部历史;或底部出现加载/没有更多字样;或收藏不在第一屏。
|
||||
|
||||
## 7. 他人资料副标题(P1)
|
||||
|
||||
+2
-2
@@ -362,7 +362,7 @@ The birth-time rectification session is the consultation transcript plus a house
|
||||
### Sidebar shell
|
||||
|
||||
- **Composition:** provider, fixed header, one scroll-owning content region, fixed footer, trigger, rail, and flexible chat inset. `/`、`/chart`、`/ephemeris`、`/reports` share one `(app)` layout: one `SessionListProvider` and one `SidebarProvider`. The session list is fetched once per tab; walking between those four pages does not remount the sidebar or refetch `/api/sessions`.
|
||||
- **Home registers shell controls** (new chat, select session, rename, pin, archive, delete, account menu). The other three pages leave `controls` unset, so rows are links to `/?c=<id>`.
|
||||
- **Home registers shell controls** (new chat, select session, rename, pin, share, delete, account menu). The other three pages leave `controls` unset, so rows are links to `/?c=<id>`.
|
||||
- **Trigger placement:** the single visible collapse/expand trigger sits beside the active session title in the chat header. The sidebar brand row has no duplicate trigger.
|
||||
- **Desktop:** 288px expanded by default at 1024px and above; 64px collapsed icon rail.
|
||||
- **Tablet:** 64px collapsed by default from 768px through 1023px; 240px when expanded.
|
||||
@@ -370,7 +370,7 @@ The birth-time rectification session is the consultation transcript plus a house
|
||||
- **Collapsed content:** logo, new-chat action, the page actions, one history expansion action, and account avatar. Individual sessions do not become indistinguishable repeated icons.
|
||||
- **Expanded content:** the header actions (new chat, 星盘, 星历, 我的报告), then **one flat list** under a single 「最近」 heading: pinned sessions as the first labelled group, then history grouped by recency.
|
||||
It used to be three stacked sections — a 星盘列表 chip grid and two `<details>` wrappers for 收藏对话 / 历史对话 — so the nav carried two collapse affordances before the first session row, and neither had anything worth collapsing. The chip grid went entirely: every chip, and the add button beside it, opened the same chart-library dialog that the account menu already offers, so a list of names provided no navigation. Chart data is managed from one place now (account menu → 星盘资料).
|
||||
The list sits at the nav's own gutter rather than indenting past a heading icon, because there is no longer a heading to align to. Actions and session rows share 18px icons, caption/body type, and ink/muted tokens; the 「收藏」 group label carries a 13px accent star as an inline marker, not a heading icon. Empty untitled sessions stay off the history list. Pin is favorite; archive stays in the session menu, not as a history-header toggle. The history list loads 40 rows at a time and silently appends the next page at the bottom.
|
||||
The list sits at the nav's own gutter rather than indenting past a heading icon, because there is no longer a heading to align to. Actions and session rows share 18px icons, caption/body type, and ink/muted tokens; the 「收藏」 group label carries a 13px accent star as an inline marker, not a heading icon. Empty untitled sessions stay off the history list. Pin is favorite. Session row menu is rename, pin, share, and delete — not archive. The history list loads 40 rows at a time and silently appends the next page at the bottom.
|
||||
- **Empty state:** one message, not one per section. 「暂无对话,点上方「新建对话」开始」 shows only when there is nothing at all; with no pins the 收藏 group simply does not render, so nothing says 「还没有收藏」 about a section that is not on screen.
|
||||
- **Scroll ownership:** header and footer remain fixed; `SidebarContent` is the sole sidebar scroll owner.
|
||||
- **Scrollbar:** `SidebarContent`, ordinary session `.conversation`, and the rectification house board use a quiet overlay scrollbar: transparent track, no `scrollbar-gutter`, and a 4px warm thumb mixed from `--color-ink`. The thumb stays transparent until hover or keyboard focus inside the scroller, then uses `color-mix(in srgb, var(--color-ink) 26%, transparent)`; thumb hover uses 40%. Increased contrast keeps the thumb visible; forced colors restore the system scrollbar.
|
||||
|
||||
@@ -440,7 +440,6 @@ export default function Home() {
|
||||
visibleSessions,
|
||||
sessionsCursor,
|
||||
setSessionsCursor,
|
||||
showArchivedSessions,
|
||||
sessionMenuId,
|
||||
setSessionMenuId,
|
||||
pendingSessionDeletion,
|
||||
@@ -453,10 +452,9 @@ export default function Home() {
|
||||
renameSession,
|
||||
deleteSession,
|
||||
togglePinnedSession,
|
||||
toggleArchivedSession,
|
||||
shareSession,
|
||||
startNewChat,
|
||||
selectSession, selectSessionModel, loadMoreSessions, toggleArchivedView,
|
||||
selectSession, selectSessionModel, loadMoreSessions,
|
||||
} = useSessionManagement({
|
||||
account, accountId, activeChartId, activeSession, activeSessionId, activeSessionIdRef,
|
||||
applySessionPopStateRef, cancellationPending, chartLibrary, creatingSession, modelCatalog,
|
||||
@@ -1470,10 +1468,7 @@ export default function Home() {
|
||||
setPendingSessionDeletion,
|
||||
setSessionMenuId,
|
||||
shareSession,
|
||||
showArchivedSessions,
|
||||
startNewChat,
|
||||
toggleArchivedSession,
|
||||
toggleArchivedView,
|
||||
togglePinnedSession,
|
||||
visibleSessions,
|
||||
});
|
||||
|
||||
@@ -20,6 +20,10 @@ function payloadHasMessages(payload: unknown): payload is { messages: unknown }
|
||||
return Boolean(payload && typeof payload === "object" && !Array.isArray(payload) && "messages" in payload);
|
||||
}
|
||||
|
||||
function payloadHasIgnoredArchive(payload: unknown): boolean {
|
||||
return Boolean(payload && typeof payload === "object" && !Array.isArray(payload) && "archived_at" in payload);
|
||||
}
|
||||
|
||||
function ignoredMessageCount(payload: { messages: unknown }) {
|
||||
return Array.isArray(payload.messages) ? payload.messages.length : 0;
|
||||
}
|
||||
@@ -81,7 +85,7 @@ export async function PATCH(request: Request, context: RouteContext) {
|
||||
if (!values && modelPatch.success) {
|
||||
values = { ...modelPatch.data };
|
||||
}
|
||||
if (!values && payloadHasMessages(payload)) {
|
||||
if (!values && (payloadHasMessages(payload) || payloadHasIgnoredArchive(payload))) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
if (!values) {
|
||||
|
||||
@@ -11,12 +11,11 @@ import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import {
|
||||
clampSessionLimit,
|
||||
isArchivedSessionQuery,
|
||||
nextSessionCursor,
|
||||
parseSessionCursor,
|
||||
sessionCursorFilter,
|
||||
} from "@/lib/session-cursor";
|
||||
import { applyArchiveFilter, excludeEmptyConsultations } from "@/lib/session-list-filter";
|
||||
import { excludeEmptyConsultations } from "@/lib/session-list-filter";
|
||||
|
||||
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";
|
||||
|
||||
@@ -24,7 +23,6 @@ export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const limit = clampSessionLimit(url.searchParams.get("limit"));
|
||||
const archived = isArchivedSessionQuery(url.searchParams.get("archived"));
|
||||
const beforeRaw = url.searchParams.get("before");
|
||||
const cursor = parseSessionCursor(beforeRaw);
|
||||
if (beforeRaw && !cursor) {
|
||||
@@ -35,14 +33,12 @@ export async function GET(request: Request) {
|
||||
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
let pageQuery = excludeEmptyConsultations(
|
||||
applyArchiveFilter(
|
||||
supabase
|
||||
.from("chat_sessions")
|
||||
.select(SESSION_LIST_COLUMNS)
|
||||
.eq("user_id", user.id)
|
||||
.eq("pinned", false),
|
||||
archived,
|
||||
),
|
||||
supabase
|
||||
.from("chat_sessions")
|
||||
.select(SESSION_LIST_COLUMNS)
|
||||
.eq("user_id", user.id)
|
||||
.eq("pinned", false)
|
||||
.is("archived_at", null),
|
||||
)
|
||||
.order("updated_at", { ascending: false })
|
||||
.order("id", { ascending: false })
|
||||
@@ -56,14 +52,12 @@ export async function GET(request: Request) {
|
||||
let pinnedRows: typeof pageRows = [];
|
||||
if (!cursor) {
|
||||
const { data: pinnedData, error: pinnedError } = await excludeEmptyConsultations(
|
||||
applyArchiveFilter(
|
||||
supabase
|
||||
.from("chat_sessions")
|
||||
.select(SESSION_LIST_COLUMNS)
|
||||
.eq("user_id", user.id)
|
||||
.eq("pinned", true),
|
||||
archived,
|
||||
),
|
||||
supabase
|
||||
.from("chat_sessions")
|
||||
.select(SESSION_LIST_COLUMNS)
|
||||
.eq("user_id", user.id)
|
||||
.eq("pinned", true)
|
||||
.is("archived_at", null),
|
||||
)
|
||||
.order("updated_at", { ascending: false })
|
||||
.order("id", { ascending: false });
|
||||
|
||||
@@ -218,7 +218,6 @@ export function AppSidebar({
|
||||
onTogglePinned={() => sessionControls.onTogglePinned(session.id)}
|
||||
onRename={() => sessionControls.onRename(session.id)}
|
||||
onShare={() => sessionControls.onShare(session.id)}
|
||||
onToggleArchived={() => sessionControls.onToggleArchived(session.id)}
|
||||
onDelete={() => sessionControls.onDelete(session.id)}
|
||||
/>
|
||||
) : (
|
||||
@@ -276,14 +275,14 @@ export function AppSidebar({
|
||||
|
||||
<SidebarContent>
|
||||
{showExpandedContent ? (
|
||||
<SidebarGroup className="session-nav" aria-label={sessionControls?.showingArchived ? "归档记录" : "最近对话"}>
|
||||
<SidebarGroup className="session-nav" aria-label="最近对话">
|
||||
{/* One flat list. It was three stacked sections — a 星盘列表 chip grid
|
||||
and two <details> wrappers for 收藏对话 / 历史对话 — so the nav
|
||||
carried two collapse affordances before the first session row.
|
||||
Pinned sessions keep their priority as the first labelled group,
|
||||
in the same shape as the recency groups under them. */}
|
||||
<p className="sidebar-section-label" ref={historyHeadingRef} tabIndex={-1}>
|
||||
{sessionControls?.showingArchived ? "归档记录" : "最近"}
|
||||
最近
|
||||
</p>
|
||||
<SidebarGroupContent className="sidebar-nested">
|
||||
{favoriteSessions.length === 0 && historySessions.length === 0 ? (
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { Menu } from "@base-ui/react/menu";
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Share2,
|
||||
@@ -22,23 +20,18 @@ export type SidebarSession = {
|
||||
readonly title: string;
|
||||
readonly subtitle?: string | null;
|
||||
readonly pinned: boolean;
|
||||
readonly archived: boolean;
|
||||
readonly updatedAt: number;
|
||||
};
|
||||
|
||||
export type SidebarSessionControls = {
|
||||
readonly archivedCount: number;
|
||||
readonly showingArchived: boolean;
|
||||
readonly hasMore?: boolean;
|
||||
readonly onLoadMore?: () => void;
|
||||
readonly menuSessionId: string | null;
|
||||
readonly disabled: boolean;
|
||||
readonly onToggleArchivedView: () => void;
|
||||
readonly onMenuSessionChange: (sessionId: string | null) => void;
|
||||
readonly onTogglePinned: (sessionId: string) => void;
|
||||
readonly onRename: (sessionId: string) => void;
|
||||
readonly onShare: (sessionId: string) => void;
|
||||
readonly onToggleArchived: (sessionId: string) => void;
|
||||
readonly onDelete: (sessionId: string) => void;
|
||||
};
|
||||
|
||||
@@ -60,7 +53,6 @@ type SidebarSessionRowInteractiveProps = SidebarSessionRowCommonProps & {
|
||||
readonly onTogglePinned: () => void;
|
||||
readonly onRename: () => void;
|
||||
readonly onShare: () => void;
|
||||
readonly onToggleArchived: () => void;
|
||||
readonly onDelete: () => void;
|
||||
};
|
||||
|
||||
@@ -122,7 +114,6 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
|
||||
onTogglePinned,
|
||||
onRename,
|
||||
onShare,
|
||||
onToggleArchived,
|
||||
onDelete,
|
||||
} = props;
|
||||
|
||||
@@ -173,10 +164,6 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
|
||||
</Menu.Item>
|
||||
<Menu.Item className="session-action-item" onClick={onRename}><Pencil aria-hidden="true" /><span>重命名</span></Menu.Item>
|
||||
<Menu.Item className="session-action-item" onClick={onShare}><Share2 aria-hidden="true" /><span>转发</span></Menu.Item>
|
||||
<Menu.Item className="session-action-item" onClick={onToggleArchived}>
|
||||
{session.archived ? <ArchiveRestore aria-hidden="true" /> : <Archive aria-hidden="true" />}
|
||||
<span>{session.archived ? "恢复" : "归档"}</span>
|
||||
</Menu.Item>
|
||||
<Menu.Separator className="session-actions-separator" />
|
||||
<Menu.Item className="session-action-item session-action-danger" onClick={onDelete}><Trash2 aria-hidden="true" /><span>删除</span></Menu.Item>
|
||||
</Menu.Popup>
|
||||
|
||||
@@ -40,10 +40,7 @@ type HomeShellRegistrationParams = {
|
||||
setPendingSessionDeletion: Dispatch<SetStateAction<ChatSession | null>>;
|
||||
setSessionMenuId: Dispatch<SetStateAction<string | null>>;
|
||||
shareSession: (session: ChatSession) => void | Promise<void>;
|
||||
showArchivedSessions: boolean;
|
||||
startNewChat: () => void | Promise<unknown>;
|
||||
toggleArchivedSession: (sessionId: string) => void;
|
||||
toggleArchivedView: () => void | Promise<void>;
|
||||
togglePinnedSession: (sessionId: string) => void;
|
||||
visibleSessions: readonly ChatSession[];
|
||||
};
|
||||
@@ -78,10 +75,7 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
|
||||
setPendingSessionDeletion,
|
||||
setSessionMenuId,
|
||||
shareSession,
|
||||
showArchivedSessions,
|
||||
startNewChat,
|
||||
toggleArchivedSession,
|
||||
toggleArchivedView,
|
||||
togglePinnedSession,
|
||||
visibleSessions,
|
||||
} = params;
|
||||
@@ -115,13 +109,10 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
|
||||
accountMenuOpen,
|
||||
accountTriggerRef: accountTrigger,
|
||||
sessionControls: {
|
||||
archivedCount: sessions.filter((session) => session.archivedAt).length,
|
||||
showingArchived: showArchivedSessions,
|
||||
hasMore: Boolean(sessionsCursor),
|
||||
onLoadMore: loadMoreSessions,
|
||||
menuSessionId: sessionMenuId,
|
||||
disabled: Boolean(pendingSessionId) || cancellationPending,
|
||||
onToggleArchivedView: () => { void toggleArchivedView(); setSessionMenuId(null); },
|
||||
onMenuSessionChange: setSessionMenuId,
|
||||
onTogglePinned: togglePinnedSession,
|
||||
onRename: (sessionId) => {
|
||||
@@ -132,7 +123,6 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void shareSession(session);
|
||||
},
|
||||
onToggleArchived: toggleArchivedSession,
|
||||
onDelete: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) setPendingSessionDeletion(session);
|
||||
@@ -155,8 +145,7 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
|
||||
rectificationErrorSessionId, rectificationOpeningSessionId, rectificationSurfaceOpen,
|
||||
registerShellControls, renameSession, selectSession, sessionMenuId, sessions,
|
||||
sessionsCursor, setAccountMenuOpen, setPendingSessionDeletion, setSessionMenuId,
|
||||
shareSession, showArchivedSessions, startNewChat, toggleArchivedSession,
|
||||
toggleArchivedView, togglePinnedSession, visibleSessions,
|
||||
shareSession, startNewChat, togglePinnedSession, visibleSessions,
|
||||
]);
|
||||
|
||||
useEffect(() => () => registerShellControls(null), [registerShellControls]);
|
||||
|
||||
@@ -127,7 +127,6 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
// Owned here since 2026-09-16 (state lowering batch 2). Home reads back only
|
||||
// what its own JSX renders; the rest never leaves this hook.
|
||||
const [sessionsCursor, setSessionsCursor] = useState<string | null>(null);
|
||||
const [showArchivedSessions, setShowArchivedSessions] = useState(false);
|
||||
const [sessionMenuId, setSessionMenuId] = useState<string | null>(null);
|
||||
const [pendingSessionDeletion, setPendingSessionDeletion] = useState<ChatSession | null>(null);
|
||||
const [sessionDetailLoadingId, setSessionDetailLoadingId] = useState<string | null>(null);
|
||||
@@ -140,15 +139,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
if (!isUnsavedEmptyConsultation(session)) cloudCreatedIds.current.add(session.id);
|
||||
}
|
||||
|
||||
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);
|
||||
}));
|
||||
const visibleSessions = sortSessions(sessions.filter(isListedSidebarSession));
|
||||
|
||||
function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) {
|
||||
setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session)));
|
||||
@@ -273,27 +264,6 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
});
|
||||
}
|
||||
|
||||
function toggleArchivedSession(sessionId: string) {
|
||||
const session = sessions.find((item) => item.id === sessionId);
|
||||
if (!session) return;
|
||||
const restoring = Boolean(session.archivedAt);
|
||||
const previousActiveId = activeSessionId;
|
||||
const nextArchivedAt = restoring ? null : new Date().toISOString();
|
||||
updateSession(sessionId, (current) => ({ ...current, archivedAt: nextArchivedAt }));
|
||||
if (!restoring && activeSessionId === sessionId) {
|
||||
activateFallbackSession(visibleSessions.filter((item) => item.id !== sessionId));
|
||||
}
|
||||
setComposerNotice(restoring ? "已恢复到聊天记录。" : "已归档,可在左侧归档中恢复。");
|
||||
void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").catch((caught) => {
|
||||
updateSession(sessionId, (current) => ({ ...current, archivedAt: session.archivedAt }));
|
||||
if (!restoring && previousActiveId === sessionId) {
|
||||
setActiveSessionId(previousActiveId);
|
||||
if (!uiPreview.current) writeSessionUrl(previousActiveId || null, "replace");
|
||||
}
|
||||
setComposerNotice(caught instanceof Error ? caught.message : "归档同步失败");
|
||||
});
|
||||
}
|
||||
|
||||
async function shareSession(session: ChatSession) {
|
||||
const sharePayload = {
|
||||
share_payload_version: 1,
|
||||
@@ -404,7 +374,6 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
try {
|
||||
const page = await fetchSessions(undefined, {
|
||||
before: sessionsCursor,
|
||||
archived: showArchivedSessions,
|
||||
});
|
||||
const incoming = readSessions(page.sessions, modelCatalog).sessions;
|
||||
for (const session of incoming) cloudCreatedIds.current.add(session.id);
|
||||
@@ -418,24 +387,6 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleArchivedView() {
|
||||
const nextArchived = !showArchivedSessions;
|
||||
setShowArchivedSessions(nextArchived);
|
||||
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)
|
||||
: parsed);
|
||||
setSessionsCursor(page.nextCursor);
|
||||
} catch (caught) {
|
||||
if (caught instanceof LoginRedirectError) return;
|
||||
setComposerNotice(caught instanceof Error ? caught.message : "暂时无法读取聊天记录");
|
||||
}
|
||||
}
|
||||
|
||||
applySessionPopStateRef.current = (search: string) => {
|
||||
if (uiPreview.current) return;
|
||||
const listed = sessionsRef.current;
|
||||
@@ -543,7 +494,6 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
visibleSessions,
|
||||
sessionsCursor,
|
||||
setSessionsCursor,
|
||||
showArchivedSessions,
|
||||
sessionMenuId,
|
||||
setSessionMenuId,
|
||||
pendingSessionDeletion,
|
||||
@@ -556,12 +506,10 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
renameSession,
|
||||
deleteSession,
|
||||
togglePinnedSession,
|
||||
toggleArchivedSession,
|
||||
shareSession,
|
||||
startNewChat,
|
||||
selectSession,
|
||||
selectSessionModel,
|
||||
loadMoreSessions,
|
||||
toggleArchivedView,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ export const chatSessionMetadataPatchSchema = z.object({
|
||||
theme: consultationDomainSchema.optional(),
|
||||
model_id: z.string().trim().min(1).max(64).optional(),
|
||||
pinned: z.boolean().optional(),
|
||||
archived_at: z.string().datetime().nullable().optional(),
|
||||
...chartBindingSchema,
|
||||
}).strict().refine(
|
||||
(value) => Object.values(value).some((field) => field !== undefined),
|
||||
@@ -128,7 +127,11 @@ export function extractChatSessionMetadataPatch(payload: unknown): unknown {
|
||||
if ("chart_profile_name" in record) patch.chart_profile_name = record.chart_profile_name;
|
||||
if ("chart_profile_role" in record) patch.chart_profile_role = record.chart_profile_role;
|
||||
if ("pinned" in record) patch.pinned = record.pinned;
|
||||
if ("archived_at" in record) patch.archived_at = record.archived_at;
|
||||
// Old bundles still send archived_at. It is read so the request is accepted,
|
||||
// then ignored rather than stored (BUG-991).
|
||||
if ("archived_at" in record) {
|
||||
// parsed, not copied into the patch
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
@@ -137,7 +140,6 @@ export type ChatSessionMetadataPatch = Readonly<{
|
||||
theme?: ConsultationDomain;
|
||||
model_id?: string;
|
||||
pinned?: boolean;
|
||||
archived_at?: string | null;
|
||||
chart_profile_id?: string | null;
|
||||
chart_profile_name?: string | null;
|
||||
chart_profile_role?: "self" | "other" | null;
|
||||
|
||||
@@ -126,20 +126,15 @@ export function applyLegacySessionControls(accountId: string, sessions: ChatSess
|
||||
return sessions;
|
||||
}
|
||||
const pinnedIds = new Set(readLegacySessionControlIds(accountId, "pinned"));
|
||||
const archivedIds = new Set(readLegacySessionControlIds(accountId, "archived"));
|
||||
const next = sessions.map((session) => ({
|
||||
...session,
|
||||
pinned: session.pinned || pinnedIds.has(session.id),
|
||||
archivedAt: session.archivedAt || (archivedIds.has(session.id) ? new Date().toISOString() : null),
|
||||
}));
|
||||
void Promise.allSettled(next.flatMap((session, index) => {
|
||||
const previous = sessions[index];
|
||||
if (!previous) return [];
|
||||
const patch: { pinned?: boolean; archived_at?: string | null } = {};
|
||||
if (session.pinned !== previous.pinned) patch.pinned = session.pinned;
|
||||
if (session.archivedAt !== previous.archivedAt) patch.archived_at = session.archivedAt;
|
||||
if (patch.pinned === undefined && patch.archived_at === undefined) return [];
|
||||
return [writeChatSession(session.id, patch, "update")];
|
||||
if (session.pinned === previous.pinned) return [];
|
||||
return [writeChatSession(session.id, { pinned: session.pinned }, "update")];
|
||||
}));
|
||||
clearLegacySessionControlKeys(accountId);
|
||||
return next;
|
||||
@@ -453,11 +448,10 @@ export async function fetchModelCatalog(signal?: AbortSignal) {
|
||||
|
||||
export async function fetchSessions(
|
||||
signal?: AbortSignal,
|
||||
options?: { before?: string | null; archived?: boolean },
|
||||
options?: { before?: string | null },
|
||||
): Promise<SessionListPage> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.before) params.set("before", options.before);
|
||||
if (options?.archived) params.set("archived", "1");
|
||||
const query = params.toString();
|
||||
const response = await fetch("/api/sessions" + (query ? `?${query}` : ""), { signal, cache: "no-store" });
|
||||
if (response.status === 401) redirectToLogin();
|
||||
|
||||
@@ -16,10 +16,6 @@ export function clampSessionLimit(raw: string | null | undefined): number {
|
||||
return Math.min(SESSION_PAGE_LIMIT_MAX, Math.max(SESSION_PAGE_LIMIT_MIN, Math.trunc(parsed)));
|
||||
}
|
||||
|
||||
export function isArchivedSessionQuery(raw: string | null | undefined): boolean {
|
||||
return raw === "1";
|
||||
}
|
||||
|
||||
export function encodeSessionCursor(updatedAt: string, id: string): string {
|
||||
return `${updatedAt},${id}`;
|
||||
}
|
||||
|
||||
@@ -11,13 +11,6 @@ export function excludeEmptyConsultations<Query extends {
|
||||
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
|
||||
@@ -43,8 +36,8 @@ export function cloudListIncludesSession(input: {
|
||||
sessionType: ChatSession["sessionType"];
|
||||
messagesEmpty: boolean;
|
||||
archived: boolean;
|
||||
archivedView: boolean;
|
||||
}): boolean {
|
||||
if (input.archived) return false;
|
||||
if (input.sessionType === "consultation" && input.messagesEmpty) return false;
|
||||
return input.archivedView ? input.archived : !input.archived;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ export function toSidebarSessionRow(
|
||||
title: sessionSidebarTitle(session),
|
||||
subtitle: sessionSidebarSubtitle(session, library) || null,
|
||||
pinned: session.pinned,
|
||||
archived: Boolean(session.archivedAt),
|
||||
updatedAt: session.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- BUG-991: put archived sessions back on the live list.
|
||||
-- Keep archived_at for a later drop (AGENTS.md §7.6). Do not bump updated_at
|
||||
-- (BUG-988). Idempotent: a second run matches 0 rows.
|
||||
|
||||
begin;
|
||||
|
||||
do $migration$
|
||||
begin
|
||||
if current_user <> 'schema_owner' then
|
||||
raise exception 'retire_chat_session_archive_requires_schema_owner'
|
||||
using errcode = '42501';
|
||||
end if;
|
||||
end
|
||||
$migration$;
|
||||
|
||||
update public.chat_sessions
|
||||
set archived_at = null
|
||||
where archived_at is not null;
|
||||
|
||||
commit;
|
||||
@@ -106,7 +106,6 @@ test("sidebar titles drop the chart-name prefix and only subtitle others", () =>
|
||||
title: "半年内换工作时机",
|
||||
subtitle: "9月14日 12:14 · 对方",
|
||||
pinned: false,
|
||||
archived: false,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,11 +98,21 @@ test("consult appends the user question after reserve and returns session_full",
|
||||
|
||||
test("PATCH compatibility accepts and ignores a legacy messages write", () => {
|
||||
assert.match(itemRoute, /logIgnoredSessionMessages\(id, user\.id, ignoredMessageCount\(payload\)\)/);
|
||||
assert.match(itemRoute, /if \(!values && payloadHasMessages\(payload\)\) \{/);
|
||||
assert.match(itemRoute, /payloadHasMessages\(payload\) \|\| payloadHasIgnoredArchive\(payload\)/);
|
||||
assert.match(itemRoute, /return NextResponse\.json\(\{ ok: true \}\)/);
|
||||
assert.doesNotMatch(itemRoute, /\.update\(\{[\s\S]*messages:/);
|
||||
});
|
||||
|
||||
test("PATCH compatibility accepts and ignores a legacy archived_at write", () => {
|
||||
const contract = readFileSync(new URL("../src/lib/chat-session-write-contract.ts", import.meta.url), "utf8");
|
||||
assert.match(itemRoute, /payloadHasIgnoredArchive/);
|
||||
assert.match(itemRoute, /payloadHasMessages\(payload\) \|\| payloadHasIgnoredArchive\(payload\)/);
|
||||
assert.match(contract, /Old bundles still send archived_at/);
|
||||
assert.match(contract, /if \("archived_at" in record\) \{/);
|
||||
assert.doesNotMatch(contract, /patch\.archived_at = record\.archived_at/);
|
||||
assert.doesNotMatch(itemRoute, /\.update\([\s\S]*archived_at/);
|
||||
});
|
||||
|
||||
test("pin and archive flags are session metadata, not localStorage", () => {
|
||||
const pinSql = readFileSync(
|
||||
new URL("../supabase/migrations/20260901020000_chat_session_pin_archive.sql", import.meta.url),
|
||||
@@ -113,7 +123,10 @@ test("pin and archive flags are session metadata, not localStorage", () => {
|
||||
assert.match(pinSql, /grant update \(pinned, archived_at\)/);
|
||||
assert.match(page, /function applyLegacySessionControls\(/);
|
||||
assert.match(page, /writeChatSession\(sessionId, \{ pinned: nextPinned \}, "update"\)/);
|
||||
assert.match(page, /writeChatSession\(sessionId, \{ archived_at: nextArchivedAt \}, "update"\)/);
|
||||
// 原值:writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update")
|
||||
// 新值:不再写 archived_at
|
||||
// 原因:BUG-991 归档能力整体下线,删除会话路径不动
|
||||
assert.doesNotMatch(page, /archived_at: nextArchivedAt/);
|
||||
assert.match(page, /discardLegacyCloudMirrorKeys\(/);
|
||||
assert.doesNotMatch(page, /setPinnedSessionIds/);
|
||||
assert.doesNotMatch(page, /localStorage\.setItem\(`\$\{prefix\}pinned`/);
|
||||
@@ -125,7 +138,8 @@ test("session list GET pages by cursor and returns pinned on the first page", ()
|
||||
assert.match(listRoute, /parseSessionCursor/);
|
||||
assert.match(listRoute, /eq\("pinned", false\)/);
|
||||
assert.match(listRoute, /eq\("pinned", true\)/);
|
||||
assert.match(listRoute, /isArchivedSessionQuery/);
|
||||
assert.match(listRoute, /\.is\("archived_at", null\)/);
|
||||
assert.doesNotMatch(listRoute, /isArchivedSessionQuery/);
|
||||
assert.match(listRoute, /nextCursor/);
|
||||
assert.match(listRoute, /limit \+ 1/);
|
||||
});
|
||||
|
||||
@@ -183,14 +183,12 @@ test("creating and leaving a session keep the address bar in sync", () => {
|
||||
assert.match(persistSession, /if \(mode === "create"\) \{/);
|
||||
assert.match(persistSession, /writeSessionUrl\(session\.id, "push"\)/);
|
||||
|
||||
// 原值:delete/archive 函数体内直接 `writeSessionUrl(fallbackId || null, "replace")`
|
||||
// 新值:删/归档当前会话走 `activateFallbackSession`,由它 `writeSessionUrl(null|fallbackId, "replace")`
|
||||
// 原因:BUG-924 回退优先非校正会话,落到校正会话时要走 selectSession,不能在 delete 里写死 URL。
|
||||
// 原值:删/归档当前会话走 `activateFallbackSession`
|
||||
// 新值:只剩删除走 `activateFallbackSession`;归档函数已删
|
||||
// 原因:BUG-991 归档下线,删除路径不动
|
||||
const deleteSession = sourceBetween(page, "async function deleteSession(", "function togglePinnedSession");
|
||||
assert.match(deleteSession, /if \(activeSessionId === session\.id\) \{\s*activateFallbackSession\(nextSessions\);/);
|
||||
|
||||
const archiveSession = sourceBetween(page, "function toggleArchivedSession(", "async function shareSession");
|
||||
assert.match(archiveSession, /activateFallbackSession\(visibleSessions\.filter\(\(item\) => item\.id !== sessionId\)\)/);
|
||||
assert.doesNotMatch(page, /function toggleArchivedSession\(/);
|
||||
|
||||
const fallback = sourceBetween(page, "function activateFallbackSession(", "async function loadMoreSessions");
|
||||
assert.match(fallback, /writeSessionUrl\(null, "replace"\)/);
|
||||
|
||||
@@ -177,11 +177,11 @@ test("chat session schema keeps structured thinking sections on assistant messag
|
||||
|
||||
test("metadata patch accepts pin and archive fields without a transcript", () => {
|
||||
assert.deepEqual(chatSessionMetadataPatchSchema.parse({ pinned: true }), { pinned: true });
|
||||
assert.deepEqual(
|
||||
chatSessionMetadataPatchSchema.parse({ archived_at: "2026-09-01T00:00:00.000Z" }),
|
||||
{ archived_at: "2026-09-01T00:00:00.000Z" },
|
||||
);
|
||||
assert.deepEqual(chatSessionMetadataPatchSchema.parse({ archived_at: null }), { archived_at: null });
|
||||
// 原值:schema 接受 archived_at 并写入 patch
|
||||
// 新值:schema 不含 archived_at;extract 读到后忽略
|
||||
// 原因:BUG-991 归档下线,旧 bundle 仍可发送不得 400
|
||||
assert.equal(chatSessionMetadataPatchSchema.safeParse({ archived_at: "2026-09-01T00:00:00.000Z" }).success, false);
|
||||
assert.equal(chatSessionMetadataPatchSchema.safeParse({ archived_at: null }).success, false);
|
||||
});
|
||||
|
||||
test("create write may carry continued_from_session_id and never context_summary", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
@@ -8,13 +9,16 @@ import {
|
||||
createLocalPostgresDataClient,
|
||||
} from "../src/lib/db/local-postgres-client-core.ts";
|
||||
import {
|
||||
applyArchiveFilter,
|
||||
cloudListIncludesSession,
|
||||
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 retireSql = readFileSync(
|
||||
new URL("../supabase/migrations/20260921010000_retire_chat_session_archive.sql", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0;
|
||||
|
||||
const emptyConsultation = "11111111-1111-4111-8111-111111111111";
|
||||
@@ -56,25 +60,13 @@ test("session list query keeps empty rectification rows and drops empty consulta
|
||||
{ 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,
|
||||
),
|
||||
local.from("chat_sessions").select("id").eq("user_id", userId).eq("pinned", false).is("archived_at", null),
|
||||
);
|
||||
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);
|
||||
const archivedIds = (archived.data as { id: string }[]).map((row) => row.id).sort();
|
||||
assert.deepEqual(archivedIds, [archivedRectification]);
|
||||
assert.equal(archivedIds.includes(archivedEmptyConsultation), false);
|
||||
assert.equal(liveIds.some((id) => archivedIds.includes(id)), false);
|
||||
assert.equal(liveIds.includes(archivedRectification), false);
|
||||
assert.equal(liveIds.includes(archivedEmptyConsultation), false);
|
||||
const rows = [
|
||||
{ id: emptyConsultation, sessionType: "consultation" as const, messagesEmpty: true, archived: false },
|
||||
{ id: emptyRectification, sessionType: "birth_time_rectification" as const, messagesEmpty: true, archived: false },
|
||||
@@ -83,15 +75,59 @@ test("session list query keeps empty rectification rows and drops empty consulta
|
||||
{ id: archivedEmptyConsultation, sessionType: "consultation" as const, messagesEmpty: true, archived: true },
|
||||
];
|
||||
assert.deepEqual(
|
||||
rows.filter((row) => cloudListIncludesSession({ ...row, archivedView: false })).map((row) => row.id).sort(),
|
||||
rows.filter((row) => cloudListIncludesSession(row)).map((row) => row.id).sort(),
|
||||
liveIds,
|
||||
);
|
||||
assert.deepEqual(
|
||||
rows.filter((row) => cloudListIncludesSession({ ...row, archivedView: true })).map((row) => row.id).sort(),
|
||||
archivedIds,
|
||||
);
|
||||
} finally {
|
||||
await closeLocalPostgresDataPools();
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("retire archive migration clears archived_at without bumping updated_at", {
|
||||
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 Unarchive','unarchive@example.com',true,now());`,
|
||||
);
|
||||
const userId = fixture.psql("select id from identity.users where email='unarchive@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 (
|
||||
'${archivedRectification}', '${userId}', 'Archived rectification', 'general', 'test-model',
|
||||
'[]', 'birth_time_rectification', true, '2026-09-08T00:00:00Z', '2026-09-10T08:00:00Z'
|
||||
);
|
||||
`);
|
||||
const before = fixture.psql(
|
||||
`select pinned::text || '|' || jsonb_array_length(messages)::text || '|' || updated_at::text from public.chat_sessions where id = '${archivedRectification}'`,
|
||||
);
|
||||
assert.match(retireSql, /set archived_at = null/);
|
||||
assert.doesNotMatch(retireSql, /updated_at\s*=/);
|
||||
fixture.psqlAs("schema_owner", "schema-owner-test-password", `
|
||||
update public.chat_sessions set archived_at = null where archived_at is not null;
|
||||
`);
|
||||
assert.equal(fixture.psql("select count(*) from public.chat_sessions where archived_at is not null"), "0");
|
||||
const after = fixture.psql(
|
||||
`select pinned::text || '|' || jsonb_array_length(messages)::text || '|' || updated_at::text from public.chat_sessions where id = '${archivedRectification}'`,
|
||||
);
|
||||
assert.equal(after, before);
|
||||
fixture.psqlAs("schema_owner", "schema-owner-test-password", `
|
||||
update public.chat_sessions set archived_at = null where archived_at is not null;
|
||||
`);
|
||||
assert.equal(fixture.psql("select count(*) from public.chat_sessions where archived_at is not null"), "0");
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -63,13 +63,14 @@ test("empty consultations stay off the sidebar while rectification rows stay on
|
||||
});
|
||||
|
||||
test("server list filter and sidebar listing agree on empty consultation, rectification, and archive", () => {
|
||||
// 原值:六组,含 archivedView=true 的归档视图
|
||||
// 新值:三组,只有未归档列表
|
||||
// 原因:BUG-991 归档视图下线,列仍在,archived 行继续不入列
|
||||
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 },
|
||||
{ sessionType: "consultation" as const, messagesEmpty: true, archived: false, listed: false },
|
||||
{ sessionType: "birth_time_rectification" as const, messagesEmpty: true, archived: false, listed: true },
|
||||
{ sessionType: "consultation" as const, messagesEmpty: false, archived: false, listed: true },
|
||||
{ sessionType: "birth_time_rectification" as const, messagesEmpty: true, archived: true, listed: false },
|
||||
];
|
||||
for (const row of cases) {
|
||||
const cloud = cloudListIncludesSession(row);
|
||||
@@ -80,7 +81,7 @@ test("server list filter and sidebar listing agree on empty consultation, rectif
|
||||
messagesHydrated: true,
|
||||
}));
|
||||
assert.equal(cloud, row.listed, JSON.stringify(row));
|
||||
if (!row.archivedView) assert.equal(client, row.listed, JSON.stringify(row));
|
||||
assert.equal(client, row.listed, JSON.stringify(row));
|
||||
}
|
||||
assert.equal(EMPTY_CONSULTATION_LIST_FILTER, "session_type.neq.consultation,messages.neq.[]");
|
||||
assert.match(listRoute, /excludeEmptyConsultations/);
|
||||
|
||||
@@ -67,8 +67,8 @@ async function fixture(options: { strict?: boolean; unauthorized?: boolean } = {
|
||||
renameSession: changed, selectSession: changed, sessionMenuId: null,
|
||||
sessions: list.sessions, sessionsCursor: null,
|
||||
setAccountMenuOpen: noop, setPendingSessionDeletion: noop, setSessionMenuId: noop,
|
||||
shareSession: changed, showArchivedSessions: false, startNewChat: changed,
|
||||
toggleArchivedSession: changed, toggleArchivedView: changed, togglePinnedSession: changed,
|
||||
shareSession: changed, startNewChat: changed,
|
||||
togglePinnedSession: changed,
|
||||
// Like useSessionManagement, intentionally new array/callbacks each render.
|
||||
visibleSessions: list.sessions.filter(() => true),
|
||||
});
|
||||
|
||||
@@ -449,6 +449,16 @@ test("styles the session history scrollbar as a quiet overlay", () => {
|
||||
assert.match(readProjectFile("DESIGN.md"), /ordinary session/);
|
||||
});
|
||||
|
||||
test("session row menu is rename, pin, share and delete — not archive", () => {
|
||||
const row = readProjectFile("src/components/sidebar-session-row.tsx");
|
||||
assert.match(row, /session\.pinned \? "取消收藏" : "收藏"/);
|
||||
assert.deepEqual(
|
||||
[...row.matchAll(/<span>([^<{][^<]*)<\/span>/g)].map((match) => match[1]),
|
||||
["重命名", "转发", "删除"],
|
||||
);
|
||||
assert.doesNotMatch(row, /归档|恢复|ArchiveRestore|onToggleArchived/);
|
||||
});
|
||||
|
||||
test("nests sidebar lists under one heading scale without an archive toggle", () => {
|
||||
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
|
||||
assert.doesNotMatch(appSidebar, /归档 \$\{/);
|
||||
|
||||
Reference in New Issue
Block a user