diff --git a/BLOCKED.md b/BLOCKED.md index e1c8d722..2c5c3d53 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -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`,不得写成通过。** diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ea08772..56f9705b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-21 — 归档功能撤掉了,以前归档过的对话回到列表 + +会话菜单里不再有「归档」。以前归档过的对话会重新出现在侧栏,时间不会被改成今天。删除对话不受影响。Skill 版本不变。 + ## 2026-09-21 — 归档记录可以打开了 侧栏点「归档记录」不再报读不到聊天。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index e40e313d..69d5f4ad 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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=` 深链,归档等于单向丢失。 - 触发条件:在会话行菜单点「归档」。 -- 根因:`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 且门禁转绿 diff --git a/docs/tasks/PROGRESS-archive-retire-20260921.md b/docs/tasks/PROGRESS-archive-retire-20260921.md new file mode 100644 index 00000000..f614d6eb --- /dev/null +++ b/docs/tasks/PROGRESS-archive-retire-20260921.md @@ -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` 一字未改,定向仍绿。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 4a09dc34..bd0c042b 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -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` | ### 个人报告 diff --git a/docs/testing/home-state-lowering-batch2-20260916.md b/docs/testing/home-state-lowering-batch2-20260916.md index 199f711f..4168ced1 100644 --- a/docs/testing/home-state-lowering-batch2-20260916.md +++ b/docs/testing/home-state-lowering-batch2-20260916.md @@ -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)。 - ✅ 每项的提示文案、成功后的列表位置变化与改前一致。 - ✅ 置顶后该会话上移到置顶区;取消置顶后回到时间序。 diff --git a/docs/testing/session-list-title-and-order-20260906.md b/docs/testing/session-list-title-and-order-20260906.md index 9920036c..bf7455a8 100644 --- a/docs/testing/session-list-title-and-order-20260906.md +++ b/docs/testing/session-list-title-and-order-20260906.md @@ -60,11 +60,11 @@ 账号历史明显超过 40 条时: -1. 打开未归档历史。 +1. 打开历史。 2. 滚到列表底部,再往下一点。 -3. 切到归档视图,同样滚到底。 +3. ~~切到归档视图,同样滚到底。~~ 归档能力已于 2026-09-21 下线(BUG-991)。 -- ✅ 预期:首屏大约是最近 40 条非置顶,加上全部收藏。滚到底没有「加载更多」或「没有更多」文案,新行直接接上。归档视图同样分页。 +- ✅ 预期:首屏大约是最近 40 条非置顶,加上全部收藏。滚到底没有「加载更多」或「没有更多」文案,新行直接接上。 - ❌ 失败:一次画出全部历史;或底部出现加载/没有更多字样;或收藏不在第一屏。 ## 7. 他人资料副标题(P1) diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index da803c9e..80865f28 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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=`. +- **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=`. - **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 `
` 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. diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 4bfd5760..f349f8d8 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -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, }); diff --git a/frontend/src/app/api/sessions/[id]/route.ts b/frontend/src/app/api/sessions/[id]/route.ts index b2ab503c..e965bde5 100644 --- a/frontend/src/app/api/sessions/[id]/route.ts +++ b/frontend/src/app/api/sessions/[id]/route.ts @@ -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) { diff --git a/frontend/src/app/api/sessions/route.ts b/frontend/src/app/api/sessions/route.ts index d05b47d8..fe2bce8b 100644 --- a/frontend/src/app/api/sessions/route.ts +++ b/frontend/src/app/api/sessions/route.ts @@ -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 }); diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index b2a57138..9adafaa7 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -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({ {showExpandedContent ? ( - + {/* One flat list. It was three stacked sections — a 星盘列表 chip grid and two
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. */}

- {sessionControls?.showingArchived ? "归档记录" : "最近"} + 最近

{favoriteSessions.length === 0 && historySessions.length === 0 ? ( diff --git a/frontend/src/components/sidebar-session-row.tsx b/frontend/src/components/sidebar-session-row.tsx index 65ee9850..17c600de 100644 --- a/frontend/src/components/sidebar-session-row.tsx +++ b/frontend/src/components/sidebar-session-row.tsx @@ -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 - - {session.archived ? diff --git a/frontend/src/hooks/use-home-shell-registration.ts b/frontend/src/hooks/use-home-shell-registration.ts index 5426d4f0..8f42d86f 100644 --- a/frontend/src/hooks/use-home-shell-registration.ts +++ b/frontend/src/hooks/use-home-shell-registration.ts @@ -40,10 +40,7 @@ type HomeShellRegistrationParams = { setPendingSessionDeletion: Dispatch>; setSessionMenuId: Dispatch>; shareSession: (session: ChatSession) => void | Promise; - showArchivedSessions: boolean; startNewChat: () => void | Promise; - toggleArchivedSession: (sessionId: string) => void; - toggleArchivedView: () => void | Promise; togglePinnedSession: (sessionId: string) => void; visibleSessions: readonly ChatSession[]; }; @@ -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]); diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index bfc507f6..11c267fb 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -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(null); - const [showArchivedSessions, setShowArchivedSessions] = useState(false); const [sessionMenuId, setSessionMenuId] = useState(null); const [pendingSessionDeletion, setPendingSessionDeletion] = useState(null); const [sessionDetailLoadingId, setSessionDetailLoadingId] = useState(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, }; } diff --git a/frontend/src/lib/chat-session-write-contract.ts b/frontend/src/lib/chat-session-write-contract.ts index d18a23ff..6dce3a9b 100644 --- a/frontend/src/lib/chat-session-write-contract.ts +++ b/frontend/src/lib/chat-session-write-contract.ts @@ -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; diff --git a/frontend/src/lib/home-cloud-sync.ts b/frontend/src/lib/home-cloud-sync.ts index 161c4c31..66a3be56 100644 --- a/frontend/src/lib/home-cloud-sync.ts +++ b/frontend/src/lib/home-cloud-sync.ts @@ -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 { 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(); diff --git a/frontend/src/lib/session-cursor.ts b/frontend/src/lib/session-cursor.ts index 70bf93d9..aa6fe07f 100644 --- a/frontend/src/lib/session-cursor.ts +++ b/frontend/src/lib/session-cursor.ts @@ -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}`; } diff --git a/frontend/src/lib/session-list-filter.ts b/frontend/src/lib/session-list-filter.ts index 293d5da3..d9ecfb98 100644 --- a/frontend/src/lib/session-list-filter.ts +++ b/frontend/src/lib/session-list-filter.ts @@ -11,13 +11,6 @@ export function excludeEmptyConsultations 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; } diff --git a/frontend/src/lib/session-sidebar-row.ts b/frontend/src/lib/session-sidebar-row.ts index 433822ed..caaf7d66 100644 --- a/frontend/src/lib/session-sidebar-row.ts +++ b/frontend/src/lib/session-sidebar-row.ts @@ -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, }; } diff --git a/frontend/supabase/migrations/20260921010000_retire_chat_session_archive.sql b/frontend/supabase/migrations/20260921010000_retire_chat_session_archive.sql new file mode 100644 index 00000000..f779e6f7 --- /dev/null +++ b/frontend/supabase/migrations/20260921010000_retire_chat_session_archive.sql @@ -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; diff --git a/frontend/tests/chart-library-session.test.ts b/frontend/tests/chart-library-session.test.ts index ed3af84d..7f03ede9 100644 --- a/frontend/tests/chart-library-session.test.ts +++ b/frontend/tests/chart-library-session.test.ts @@ -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, }); }); diff --git a/frontend/tests/chat-session-authority.test.ts b/frontend/tests/chat-session-authority.test.ts index 13ffe091..ea18fd3f 100644 --- a/frontend/tests/chat-session-authority.test.ts +++ b/frontend/tests/chat-session-authority.test.ts @@ -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/); }); diff --git a/frontend/tests/chat-session-url.test.ts b/frontend/tests/chat-session-url.test.ts index 5825c50d..1c2e97a1 100644 --- a/frontend/tests/chat-session-url.test.ts +++ b/frontend/tests/chat-session-url.test.ts @@ -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"\)/); diff --git a/frontend/tests/chat-session-write.test.ts b/frontend/tests/chat-session-write.test.ts index 96d377b3..db3a015d 100644 --- a/frontend/tests/chat-session-write.test.ts +++ b/frontend/tests/chat-session-write.test.ts @@ -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 () => { diff --git a/frontend/tests/database-session-list-visibility.test.ts b/frontend/tests/database-session-list-visibility.test.ts index 82c090ae..8114efcf 100644 --- a/frontend/tests/database-session-list-visibility.test.ts +++ b/frontend/tests/database-session-list-visibility.test.ts @@ -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(); + } +}); diff --git a/frontend/tests/session-list-filter.test.ts b/frontend/tests/session-list-filter.test.ts index 4f67140b..73057b48 100644 --- a/frontend/tests/session-list-filter.test.ts +++ b/frontend/tests/session-list-filter.test.ts @@ -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/); diff --git a/frontend/tests/session-list-lifecycle.test.tsx b/frontend/tests/session-list-lifecycle.test.tsx index 28d91f71..4fcc687a 100644 --- a/frontend/tests/session-list-lifecycle.test.tsx +++ b/frontend/tests/session-list-lifecycle.test.tsx @@ -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), }); diff --git a/frontend/tests/sidebar-contract.test.ts b/frontend/tests/sidebar-contract.test.ts index 3025aa07..f7a8b91e 100644 --- a/frontend/tests/sidebar-contract.test.ts +++ b/frontend/tests/sidebar-contract.test.ts @@ -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>/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, /归档 \$\{/);