fix(web): retire session archive UI and API, restore archived rows
Independent Staging Quality Gate / validate (push) Failing after 6m35s
Independent Staging Quality Gate / publish (push) Skipped

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:
jesse-ux
2026-09-21 18:49:40 +08:00
parent f8cb1dcdee
commit 5a4b87383f
29 changed files with 227 additions and 198 deletions
+5
View File
@@ -123,6 +123,11 @@
- ~~**替代:** `GET /api/sessions` 排除 `messages = []`,响应带 `draft`;启动和「新建对话」优先用这份空咨询,不再连点就堆新行。~~
- **解除(2026-09-21BUG-989):** `startNewChat` 只在本地开一条,第一问 `send()``POST /api/sessions`;未落库不写 `?c=`。列表过滤收窄到咨询空行,校正会话不再被 `messages = []` 误删(BUG-987)。刷新丢掉未开口的本地空会话是可接受的。
## 归档下线迁移:本机 Docker 网段耗尽未跑 test:db2026-09-21BUG-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:db2026-09-21BUG-990
- **Docker 在 PATH**,但 `startPostgresFixture` 建 compose 网络报 `all predefined address pools have been fully subnetted`(其它 worktree 留下的 postgres 网络占满)。**未跑 `npm run test:db`,不得写成通过。**
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-09-21 — 归档功能撤掉了,以前归档过的对话回到列表
会话菜单里不再有「归档」。以前归档过的对话会重新出现在侧栏,时间不会被改成今天。删除对话不受影响。Skill 版本不变。
## 2026-09-21 — 归档记录可以打开了
侧栏点「归档记录」不再报读不到聊天。Skill 版本不变。
+6 -6
View File
@@ -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` 一字未改,定向仍绿。
+1 -1
View File
@@ -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-06BUG-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
View File
@@ -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.
+1 -6
View File
@@ -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,
});
+5 -1
View File
@@ -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) {
+13 -19
View File
@@ -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 });
+2 -3
View File
@@ -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]);
+1 -53
View File
@@ -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;
+3 -9
View File
@@ -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();
-4
View File
@@ -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}`;
}
+2 -9
View File
@@ -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;
}
-1
View File
@@ -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,
});
});
+17 -3
View File
@@ -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/);
});
+4 -6
View File
@@ -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"\)/);
+5 -5
View File
@@ -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_atextract 读到后忽略
// 原因: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();
}
});
+8 -7
View File
@@ -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),
});
+10
View File
@@ -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, /归档 \$\{/);