diff --git a/CHANGELOG.md b/CHANGELOG.md index c9fcfb84..37d31503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-10-28 — 每个页面的侧栏会话都能「…」和拖动 + +- 在星盘、星历、我的报告、星盘档案页,侧栏的会话也有「…」菜单(收藏、重命名、转发、删除),也能拖动排序,跟首页一样。 +- 删除照旧先弹确认;没删成会退回原样并提示。在哪个页面改的,回到首页列表都一致。 +- 在这些页面点一条会话,仍回到首页打开它。 +- Skill 版本不变(BUG-1467,issue #116)。 + ## 2026-10-27 — 星盘页按签过的原型改回来 - 盘面按原型画:每一宫写星座符号和名字,下一行写宫号(上升宫写「上升 1」);星按庙旺绿、落陷红着色,逆行写 R。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 7ba0873d..b8922ba2 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19982,7 +19982,7 @@ ## BUG-1466 | 首页输入框整块发灰;首页会话列表每行把手加「…」很乱 -- 状态:resolved(输入框、列表样子);其他页面会话行没有菜单另见下文,未改 +- 状态:resolved(输入框、列表样子);其他页面会话行没有菜单,已在 BUG-1467(issue #116)改掉 - 首次发现:2026-10-27(issue #114 产品评论,附三张截图) - 最近更新:2026-10-27 - 影响面:首页和对话页的输入框;首页侧栏会话列表。 @@ -19995,3 +19995,25 @@ - 相关记录:BUG-1433(K1 外壳换组件库)、BUG-1453;TASK-sidebar-unify-20260916。 - 复发自:无 - 修复版本:分支 `codex/chart-ui-114-20261027` 第四轮。 + +## BUG-1467 | 星盘、星历、报告、档案页的侧栏会话没有「…」,也不能拖动 + +- 状态:resolved(自动化);真机待产品 +- 首次发现:2026-10-27(issue #114 产品评论;产品选 A,另开 issue #116) +- 最近更新:2026-10-28 +- 影响面:`(app)` 外壳下除首页外的所有页面的侧栏会话列表;首页的同一列表。 +- 用户现象:首页侧栏的会话行有「…」菜单(收藏、重命名、转发、删除),也能拖动排序;到星盘、星历、我的报告、星盘档案页,同一列表只有标题和日期,点了只能跳回首页。 +- 触发条件:登录后打开任何非首页页面。 +- 根因:列表本身早已放在外壳的 store 里,但会话管理(重命名、删除确认、收藏、转发、排序,以及它们的乐观更新和失败回滚)写在首页的 `use-session-management.ts` 里,经 `use-home-shell-registration.ts` 只在首页挂着时登记给外壳。别的页面外壳拿不到这些回调,侧栏按 TASK-sidebar-unify-20260916 D1 的约定渲染成只读链接。外壳兜底列表也不按收藏 / 排序号排序,即使能拖,其他页面也看不到拖动结果。 +- 修复:新增 `hooks/use-shell-session-actions.ts`,由 `(app)/layout.tsx` 调用,直接改共用 store。这五项连同删除确认框(`components/session-delete-dialog.tsx`)、翻页兜底,在每个页面都可用。菜单开合与待删会话放进 `session-store`。首页不再自己维护一套,只经 `sessionHooks` 补三件首页才知道的事:一、自己的翻页游标;二、删掉当前打开的会话时清掉校正授权并跳到下一条(`onSessionDeleted`);三、哪些新对话还没落库(`isSessionSaved`)。侧栏的 `sessionControls` 从首页专属的 `controls` 里拆出,成为独立入参;不在首页时点一行用 `navigateAppPath` 去 `/?c=`。外壳兜底列表改为 `sortSessions` 排序。转发时若这一行还没读过聊天内容,先读再复制,剪贴板接一个待定的 Blob,Safari 仍算这次点击。未登录或账户未读到时仍是只读链接。 +- 验证:新增 `frontend/tests/shell-session-actions-20261028.test.tsx` 三条: + - 没有首页控制时行上有「…」、没有只读标记;旧侧栏没有这个入参,测试失败。 + - 删除先弹确认,确认后乐观删行、通知首页、服务端拒绝时退回原样。 + - 未登录不给控制。 + 另外,前端全量 5262 条与 staging `36fd4b2c8` 基线逐条对比,没有新失败,也没有测试名消失。 + tsc 0 错,lint 0 error,警告数与基线相同。`/` 仍 Static。首屏 gzip 679693 → 681714(+0.30%)。Python 快速门通过。 + 改了七个按源码断言的测试,三栏说明都写在测试里:`chat-session-url`、`order-batch`、`home-sidebar-rerender`、`session-list-lifecycle`;`home-surface.ts` 和两份 Python 首页清单加了新文件。 +- 防复发:上面的新测试;首页表面清单(TS 与 Python)包含外壳会话文件,删除 / 排序的源码断言跟着文件走。 +- 相关记录:BUG-1466、BUG-1433、BUG-989(新对话首问才落库)、BUG-725 / BUG-1329(外壳拿稳定包装、调用最新回调);TASK-sidebar-unify-20260916 D1(只读侧栏的约定,本条推翻其中「别的页面不写」一项,产品 2026-10-27 选 A 授权)。 +- 复发自:无 +- 修复版本:分支 `codex/session-shell-116-20261027`。 diff --git a/docs/tasks/PROGRESS-session-shell-116-20261028.md b/docs/tasks/PROGRESS-session-shell-116-20261028.md new file mode 100644 index 00000000..1e062d65 --- /dev/null +++ b/docs/tasks/PROGRESS-session-shell-116-20261028.md @@ -0,0 +1,35 @@ +# 进度 · 侧栏会话菜单搬进共用外壳(issue #116) + +- 执行方:Claude 直接执行(产品 2026-10-27 指定;issue #114 讨论选 A) +- 分支:`codex/session-shell-116-20261027`,基线 `origin/staging` `36fd4b2c8` +- Bug:BUG-1467(BUG-1466 的后续) + +## 做了什么 + +- `hooks/use-shell-session-actions.ts`:重命名、删除(确认 + 乐观删除 + 失败回滚)、收藏、转发、排序、翻页兜底,由 `(app)/layout.tsx` 调用,改共用 store。 +- `components/session-delete-dialog.tsx`:删除确认框从首页搬到外壳,文案不变。 +- `session-store`:加 `sessionMenuId`、`pendingSessionDeletion`。 +- 首页 `use-session-management.ts` 删掉这五项和两份状态,只留 `onSessionDeleted`(清校正授权、删当前会话时跳下一条)与 `isSessionSaved`;`use-home-shell-registration.ts` 经 `sessionHooks` 发布翻页游标和这两项,均为稳定包装。 +- `AppSidebar`:`sessionControls` 拆成独立入参;`onOpenSession` 在非首页把行导向 `/?c=`。 +- 外壳兜底列表按 `sortSessions` 排序。 + +## 验收(本机) + +| 项 | 结果 | +| --- | --- | +| tsc | 0 错 | +| lint | 0 error;警告数与基线一致(改动文件里多出的一条已清掉) | +| 前端全量 | 5262 条;失败 28 条,全部在基线 29 条之内(DB / 部署套件,无 Docker);无测试名消失,新增 3 条 | +| Python 快速门 | 通过(临时环境;字体切片测试需补装 brotli,属环境) | +| `next build` | `/` 仍 Static | +| 首屏 gzip | 679693 → 681714(+0.30%);同一脚本量基线和新版 | +| 首页 useState / useRef | 未增加(`home-shell-growth-contract` 通过) | + +## 改动的既有断言 + +三栏说明都写在测试文件里:`chat-session-url.test.ts`(删除跳下一条)、`order-batch.test.ts`(读新文件)、`home-sidebar-rerender.test.tsx`(重命名改为翻页验证稳定包装)、`session-list-lifecycle.test.tsx`(入参)。`home-surface.ts`、`tests/test_session_management_entrypoints.py`、`tests/test_supabase_user_data_contract.py` 的首页文件清单加上两个新文件。 + +## 环境缺口 + +- 无登录态浏览器:真机清单见 `docs/testing/session-shell-116-20261028.md`。 +- 本机工作区与 `.venv` 曾被其他会话清理腾盘,重建后验收;不影响结果。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 30de8d08..03b13610 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -541,4 +541,5 @@ | `TASK-lagna-precheck-fix-20261027.md` | — | **#112 第 1 次打回**:已有进行中的校正时点链接报冲突(应走首页续上);别人的星盘页不该给校正链接 | 待执行 | `codex/lagna-precheck-20261027` | | `TASK-voice-reading-20261027.md` | `PROGRESS-voice-reading-20261027.md` | **#113 选 R1 C1**:报告前面加一段模型写的「解读」(八节,素材只用引擎事实,失败不影响报告);对话依据写进正文、宫位带生活领域、时间最多三件(BUG-1460、BUG-1461) | Claude 直接执行,待验收 | `codex/voice-reading-20261027` | | — | `PROGRESS-chart-ui-114-20261027.md` | **#114 星盘页一页铺开**:南北印度可点盘、检视、深读四页、对话右栏完整盘面(直接执行) | 待推 staging | `codex/chart-ui-114-20261027` | +| — | `PROGRESS-session-shell-116-20261028.md` | **#116 侧栏会话菜单搬进外壳**:每个页面都能「…」和拖动(直接执行,BUG-1467) | 已推 staging | `codex/session-shell-116-20261027` | | `TASK-home-crash-saved-people-20261027.md` | `PROGRESS-home-crash-saved-people-20261027.md` | **#115 首页崩溃**:存过别人的账户一进首页就「页面出错了」——人物列表的资料缺两个字段,启程引导一读就报错;入口补齐默认值(BUG-1464) | 待验收 | `codex/home-crash-saved-people-20261027` | diff --git a/docs/testing/session-shell-116-20261028.md b/docs/testing/session-shell-116-20261028.md new file mode 100644 index 00000000..d21c87b8 --- /dev/null +++ b/docs/testing/session-shell-116-20261028.md @@ -0,0 +1,19 @@ +# 侧栏会话菜单搬进共用外壳 · 真机清单(issue #116,BUG-1467) + +需要登录后的真实账户;用测试账户,不要用别人的资料。手机(iPhone Safari)和电脑(Chrome)各走一遍。 + +| # | 操作 | 应该看到 | +| --- | --- | --- | +| 1 | 打开「星盘」页,看侧栏会话 | 电脑:鼠标移到一行上出现「…」;手机:「…」常驻 | +| 2 | 星盘页,点「…」→ 收藏 | 这条移到「收藏」组;刷新后仍在 | +| 3 | 星盘页,「…」→ 重命名,改一个名字 | 标题立刻变;切到首页,名字一样 | +| 4 | 星盘页,「…」→ 删除 | 先弹「删除聊天记录?」确认框;点取消什么都不变 | +| 5 | 再删一次,点「确认删除」 | 这一行消失;回到首页列表里也没有 | +| 6 | 星历页,按住一行拖到另一行下面(手机长按约 0.25 秒再拖) | 松手后停在新位置;切到首页,顺序一样;刷新后仍在 | +| 7 | 拖完松手 | 不会顺带打开那条对话 | +| 8 | 报告页,「…」→ 转发 | 提示「已复制当前聊天」;粘贴出来有完整对话内容 | +| 9 | 档案页,点一条会话 | 回到首页并打开这一条 | +| 10 | 首页打开一条对话,在首页删掉它 | 跳到下一条(或新对话),不留空白 | +| 11 | 首页正在生成回答时,看其他行的「…」 | 暂时点不了,回答结束后恢复 | +| 12 | 断网后在星盘页删除一条 | 行先消失,随后退回原样并提示删除失败 | +| 13 | 退出登录,打开星盘页 | 侧栏没有会话菜单,底部是「去登录」 | diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index d8e2ef68..818143cf 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1291,7 +1291,7 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting` ## 外壳(2026-10-24) -桌面侧栏宽 200–360px,默认 288。右侧把手是组件库按钮,仍是 6px 透明细条;拖它调整宽度。把手能用 Tab 选中,左右方向键每次 16px,Home / End 到最窄 / 最宽,键盘不会把侧栏收起;读屏读作竖向分隔条,带当前宽度(BUG-1457)。拖到比 200 更窄就收成 64px 窄轨;收起不把 64 写成宽度。宽度记在本机 `sidebar_width`。侧栏和主区始终在各自的容器里:视口就绪、跨过手机断点,都不会把整页卸掉再挂上。手机用左侧抽屉,宽 `min(86vw, 320px)`。当前页高亮用已有的 `layoutId` 滑动。翻页用已有的页面进入 280ms、离开 150ms,方向跟着侧栏顺序。顶栏点数是描边按钮:硬币图标加数字,44px;「点」只在可读名称里。数字变少时往下滚,变多时往上滚,360ms;系统要求减少动态时立刻换数字。启程清单胶囊仍是 32px,仍在点数左边。账户按钮只显示头像和名字。设置仍是三个分区。会话只在收藏组内、或最近一组内拖动排序;收藏整组仍在最前。可排序的行和其他行长得一样(2026-10-27,BUG-1466):没有常驻把手,鼠标按住整行移动 6px 开始拖,触摸长按 250ms;把手只在键盘聚焦时出现,供键盘排序。能悬停的设备上「…」只在悬停、聚焦、当前行、菜单打开时出现,触摸设备常驻。输入框为空时发送按钮禁用,但输入框本身不变灰(覆盖 `InputGroup` 的 `has-disabled:` 两项)。翻页、高亮滑动和拖动排序用的库在页面出来后再载入,不进首屏。拖宽不再另载一套分栏库。 +桌面侧栏宽 200–360px,默认 288。右侧把手是组件库按钮,仍是 6px 透明细条;拖它调整宽度。把手能用 Tab 选中,左右方向键每次 16px,Home / End 到最窄 / 最宽,键盘不会把侧栏收起;读屏读作竖向分隔条,带当前宽度(BUG-1457)。拖到比 200 更窄就收成 64px 窄轨;收起不把 64 写成宽度。宽度记在本机 `sidebar_width`。侧栏和主区始终在各自的容器里:视口就绪、跨过手机断点,都不会把整页卸掉再挂上。手机用左侧抽屉,宽 `min(86vw, 320px)`。当前页高亮用已有的 `layoutId` 滑动。翻页用已有的页面进入 280ms、离开 150ms,方向跟着侧栏顺序。顶栏点数是描边按钮:硬币图标加数字,44px;「点」只在可读名称里。数字变少时往下滚,变多时往上滚,360ms;系统要求减少动态时立刻换数字。启程清单胶囊仍是 32px,仍在点数左边。账户按钮只显示头像和名字。设置仍是三个分区。会话只在收藏组内、或最近一组内拖动排序;收藏整组仍在最前。可排序的行和其他行长得一样(2026-10-27,BUG-1466):没有常驻把手,鼠标按住整行移动 6px 开始拖,触摸长按 250ms;把手只在键盘聚焦时出现,供键盘排序。能悬停的设备上「…」只在悬停、聚焦、当前行、菜单打开时出现,触摸设备常驻。这套行菜单、删除确认框和拖动排序在每个页面都一样,由外壳提供,不只首页(2026-10-28,BUG-1467);在其他页面点一行回首页打开,未登录时行仍是只读链接。输入框为空时发送按钮禁用,但输入框本身不变灰(覆盖 `InputGroup` 的 `has-disabled:` 两项)。翻页、高亮滑动和拖动排序用的库在页面出来后再载入,不进首屏。拖宽不再另载一套分栏库。 ## 对话页(2026-10-25) diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index f7b4fdb8..05d8762b 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -2,13 +2,18 @@ import type { ReactNode } from "react"; import dynamic from "next/dynamic"; +import { useRouter } from "next/navigation"; import { AccountDeletionGate } from "@/components/account-deletion-gate"; import { AppSidebar } from "@/components/app-sidebar"; import { LegalConsentGate } from "@/components/legal/legal-consent-gate"; import { PageSwitch } from "@/components/shell-motion"; import { ShellFrame } from "@/components/shell-frame"; +import { SessionDeleteDialog } from "@/components/session-delete-dialog"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { useShellSessionActions } from "@/hooks/use-shell-session-actions"; +import { navigateAppPath } from "@/lib/app-navigation"; +import { sessionHref } from "@/lib/chat-session-url"; import { OnboardingJourneyProvider } from "@/lib/onboarding-journey"; import { SessionListProvider, sessionListSidebarModel, useSessionList, useShellRegistration } from "@/hooks/use-session-list"; import "../site-styles"; @@ -28,6 +33,9 @@ function AppShell({ children }: { children: ReactNode }) { const registration = useShellRegistration(); const { sessions, account } = sessionListSidebarModel(list, registration); const compatibilityEnabled = list.account?.compatibility === true; + const router = useRouter(); + // The rows' menu, delete confirmation and drag sorting, on every page (issue #116). + const sessionActions = useShellSessionActions(list, registration?.sessionHooks ?? null); return ( @@ -44,6 +52,8 @@ function AppShell({ children }: { children: ReactNode }) { signedOut={list.signedOut} compatibilityEnabled={compatibilityEnabled} controls={registration?.controls} + sessionControls={sessionActions.controls} + onOpenSession={(sessionId) => navigateAppPath(sessionHref("", sessionId), router.push)} /> )} > @@ -53,6 +63,11 @@ function AppShell({ children }: { children: ReactNode }) { + diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 5addae33..a4ab0ad1 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -6,12 +6,6 @@ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "reac import type { FormEvent, KeyboardEvent } from "react"; import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay"; import { CreditBalanceButton } from "@/components/credit-balance-button"; -import { - AlertDialog, - AlertDialogContent, - AlertDialogDescription, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { ProfilePanel } from "@/components/profile-panel"; import { AccountDeletionSection } from "@/components/account-deletion-section"; @@ -256,6 +250,7 @@ export default function Home() { setMessageFeedback, copiedMessageKey, setCopiedMessageKey, + sessionDeletionOpen, } = useSessionStore(useShallow((state) => ({ activeSessionId: state.activeSessionId, setActiveSessionId: state.setActiveSessionId, @@ -273,6 +268,8 @@ export default function Home() { setMessageFeedback: state.setMessageFeedback, copiedMessageKey: state.copiedMessageKey, setCopiedMessageKey: state.setCopiedMessageKey, + // The confirmation is the shell's now (issue #116); onboarding still waits behind it. + sessionDeletionOpen: state.pendingSessionDeletion !== null, }))); const draftTheme = useRef(null); const draftEntrypoint = useRef(null); @@ -451,20 +448,13 @@ export default function Home() { visibleSessions, sessionsCursor, setSessionsCursor, - sessionMenuId, - setSessionMenuId, - pendingSessionDeletion, - setPendingSessionDeletion, setSessionFullPrompt, updateSession, persistSession, ensureSessionMessages, continueInNewChat, - renameSession, - deleteSession, - togglePinnedSession, - reorderSessions, - shareSession, + onSessionDeleted, + isSessionSaved, startNewChat, bindEmptyChatSubject, openChatBoundToProfile, @@ -1164,18 +1154,13 @@ export default function Home() { rectificationOpeningSessionId, rectificationSurfaceOpen, registerShellControls, - renameSession, selectSession, - sessionMenuId, - sessions, sessionsCursor, setAccountMenuOpen, - setPendingSessionDeletion, - setSessionMenuId, - shareSession, + onSessionDeleted, + isSessionSaved, + uiPreview, startNewChat, - togglePinnedSession, - reorderSessions, visibleSessions, }); @@ -1255,28 +1240,12 @@ export default function Home() { streaming={consultationPhase === "streaming" || consultationPhase === "recovering"} streamingSessionId={activeSessionId} skyOpen={birthSkyReveal.cover != null} - dialogOpen={activeAccountDialog !== null || onboardingPaywallOpen || Boolean(pendingSessionDeletion)} + dialogOpen={activeAccountDialog !== null || onboardingPaywallOpen || sessionDeletionOpen} profile={profile} /> {replyAnnouncement} - {pendingSessionDeletion ? ( - { if (!next) setPendingSessionDeletion(null); }}> - - 删除聊天记录? - “{pendingSessionDeletion.title}”将被永久删除,无法恢复。 -
- - -
-
-
- ) : null}
diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 4fe630c4..500f9394 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -71,26 +71,26 @@ export type SidebarAccount = { }; /** - * Everything only `/` can supply: the session-management layer with its - * optimistic updates and rollback, and the settings dialogs behind the - * account menu. Omit `controls` and the same sidebar renders read-only — rows - * are links, nothing writes — and the same account menu's dialog entries - * become links to `/?account=` (TASK-self-edit-avatar-menu-20260928 S2). + * Everything only `/` can supply: new chat in place, opening a row in place, + * and the settings dialogs behind the account menu. Omit `controls` and new + * chat is a link, a row opens `/?c=`, and the account menu's dialog + * entries become links to `/?account=` + * (TASK-self-edit-avatar-menu-20260928 S2). + * + * The rows' own menu (收藏、重命名、转发、删除) and drag sorting are not in + * here: they come from the shell through `sessionControls` on every page + * (issue #116). Without them — signed out, or the account not read yet — the + * rows are plain links. * * `/chart`, `/ephemeris` and `/reports` used to carry a second sidebar - * component of their own, deleted in this round. Two components meant two sets - * of markup: its rows had no `.session-main`, so they lost the 44px minimum, - * the padding and the 2px current-item marker, while `.session-row` still - * reserved 44px for a menu button that was never rendered. Read-only was the - * requirement; a different shape was never part of it. - * See TASK-sidebar-unify-20260916 D1. + * component of their own, deleted in TASK-sidebar-unify-20260916 D1: two + * components meant two sets of markup. */ export type AppSidebarControls = { accountMenuOpen: boolean; accountTriggerRef: Ref; newChatDisabled: boolean; creatingSession: boolean; - sessionControls: SidebarSessionControls; onAccountMenuOpenChange: (open: boolean) => void; onNewChat: () => void; onSelectSession: (sessionId: string) => void; @@ -114,6 +114,10 @@ export type AppSidebarProps = { /** Product flag from the shell. Absent means hidden, so the entry does not flash before the flag arrives. */ compatibilityEnabled?: boolean; controls?: AppSidebarControls; + /** The rows' menu and drag sorting, from the shell on every page. */ + sessionControls?: SidebarSessionControls | null; + /** Opens a row off `/`: the shell routes it to `/?c=`. `controls` wins on `/`. */ + onOpenSession?: (sessionId: string) => void; }; const NAV_PAGES = [ @@ -133,6 +137,8 @@ export function AppSidebar({ signedOut = false, compatibilityEnabled = false, controls, + sessionControls, + onOpenSession, }: AppSidebarProps) { const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar(); const showChartDot = useOnboardingJourneyOptional()?.showChartDot ?? false; @@ -154,15 +160,15 @@ export function AppSidebar({ accountTriggerRef, newChatDisabled = false, creatingSession = false, - sessionControls, onAccountMenuOpenChange: controlledMenuOpenChange, onNewChat, - onSelectSession, + onSelectSession: controlledSelectSession, onOpenProfile, onOpenGeneral, onOpenBilling, onOpenLogout, }: Partial = controls ?? {}; + const onSelectSession = controlledSelectSession ?? onOpenSession; /* Off `/` nothing controls the account menu, so it keeps its own open state (S2). */ const [ownMenuOpen, setOwnMenuOpen] = useState(false); /* 反馈与投诉 is the sidebar's own dialog, so it opens the same way on every page. */ diff --git a/frontend/src/components/session-delete-dialog.tsx b/frontend/src/components/session-delete-dialog.tsx new file mode 100644 index 00000000..fb5e4794 --- /dev/null +++ b/frontend/src/components/session-delete-dialog.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import type { ChatSession } from "@/lib/home-types"; + +/** The shell's delete confirmation for a sidebar row, on every page (issue #116). */ +export function SessionDeleteDialog({ + session, + onCancel, + onConfirm, +}: { + session: ChatSession | null; + onCancel: () => void; + onConfirm: () => void; +}) { + if (!session) return null; + return ( + { if (!next) onCancel(); }}> + + 删除聊天记录? + “{session.title}”将被永久删除,无法恢复。 +
+ + +
+
+
+ ); +} diff --git a/frontend/src/hooks/use-home-shell-registration.ts b/frontend/src/hooks/use-home-shell-registration.ts index 2d18f40d..c78b69dc 100644 --- a/frontend/src/hooks/use-home-shell-registration.ts +++ b/frontend/src/hooks/use-home-shell-registration.ts @@ -29,34 +29,26 @@ type HomeShellRegistrationParams = { rectificationOpeningSessionId: string | null; rectificationSurfaceOpen: boolean; registerShellControls: (registration: ShellRegistration | null) => void; - renameSession: (session: ChatSession) => void | Promise; selectSession: (sessionId: string) => void; - sessionMenuId: string | null; - sessions: readonly ChatSession[]; sessionsCursor: string | null; setAccountMenuOpen: Dispatch>; - setPendingSessionDeletion: Dispatch>; - setSessionMenuId: Dispatch>; - shareSession: (session: ChatSession) => void | Promise; + /** Home's part of a row deletion the shell runs (issue #116). */ + onSessionDeleted: (session: ChatSession, remaining: ChatSession[]) => void; + isSessionSaved: (session: ChatSession) => boolean; + uiPreview: RefObject; startNewChat: () => void | Promise; - togglePinnedSession: (sessionId: string) => void; - reorderSessions: (orderedIds: readonly string[]) => void; visibleSessions: readonly ChatSession[]; }; type LatestShellActions = { - sessions: readonly ChatSession[]; loadMoreSessions: () => void; - renameSession: (session: ChatSession) => void | Promise; selectSession: (sessionId: string) => void; - shareSession: (session: ChatSession) => void | Promise; startNewChat: () => void | Promise; - togglePinnedSession: (sessionId: string) => void; - reorderSessions: (orderedIds: readonly string[]) => void; openAccountDialog: (dialog: AccountDialogName, options?: { source?: string }) => void; - setSessionMenuId: Dispatch>; setAccountMenuOpen: Dispatch>; - setPendingSessionDeletion: Dispatch>; + onSessionDeleted: (session: ChatSession, remaining: ChatSession[]) => void; + isSessionSaved: (session: ChatSession) => boolean; + uiPreview: RefObject; }; type PublishedShellInputs = { @@ -72,7 +64,6 @@ type PublishedShellInputs = { pendingSessionId: string | null; cancellationPending: boolean; modelCatalog: PublicLanguageModelCatalog | null; - sessionMenuId: string | null; sessionsCursor: string | null; accountTrigger: RefObject; chartLibrary: readonly ChartLibraryRecord[]; @@ -100,7 +91,6 @@ function samePublishedInputs(previous: PublishedShellInputs, next: PublishedShel && previous.pendingSessionId === next.pendingSessionId && previous.cancellationPending === next.cancellationPending && previous.modelCatalog === next.modelCatalog - && previous.sessionMenuId === next.sessionMenuId && previous.sessionsCursor === next.sessionsCursor && previous.accountTrigger === next.accountTrigger && previous.chartLibrary === next.chartLibrary @@ -126,18 +116,13 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) { rectificationOpeningSessionId, rectificationSurfaceOpen, registerShellControls, - renameSession, selectSession, - sessionMenuId, - sessions, sessionsCursor, setAccountMenuOpen, - setPendingSessionDeletion, - setSessionMenuId, - shareSession, + onSessionDeleted, + isSessionSaved, + uiPreview, startNewChat, - togglePinnedSession, - reorderSessions, visibleSessions, } = params; const profileName = profile.name; @@ -148,18 +133,14 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) { // No dependency list: this has to land after every render and before the // publish effect, so a later click runs this render's callbacks (BUG-725). latestRef.current = { - sessions, loadMoreSessions, - renameSession, selectSession, - shareSession, startNewChat, - togglePinnedSession, - reorderSessions, openAccountDialog, - setSessionMenuId, setAccountMenuOpen, - setPendingSessionDeletion, + onSessionDeleted, + isSessionSaved, + uiPreview, }; }); @@ -173,21 +154,9 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) { const stableControls = useMemo(() => ({ onLoadMore: () => { latestRef.current?.loadMoreSessions(); }, - onMenuSessionChange: (sessionId: string | null) => { latestRef.current?.setSessionMenuId(sessionId); }, - onTogglePinned: (sessionId: string) => { latestRef.current?.togglePinnedSession(sessionId); }, - onReorder: (orderedIds: readonly string[]) => { latestRef.current?.reorderSessions(orderedIds); }, - onRename: (sessionId: string) => { - const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId); - if (session) void latestRef.current?.renameSession(session); - }, - onShare: (sessionId: string) => { - const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId); - if (session) void latestRef.current?.shareSession(session); - }, - onDelete: (sessionId: string) => { - const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId); - if (session) latestRef.current?.setPendingSessionDeletion(session); - }, + onSessionDeleted: (session: ChatSession, remaining: ChatSession[]) => { latestRef.current?.onSessionDeleted(session, remaining); }, + isSessionSaved: (session: ChatSession) => latestRef.current?.isSessionSaved(session) ?? false, + preview: () => latestRef.current?.uiPreview.current ?? false, onAccountMenuOpenChange: (open: boolean) => { latestRef.current?.setAccountMenuOpen(open); }, onNewChat: () => { void latestRef.current?.startNewChat(); }, onSelectSession: (sessionId: string) => { latestRef.current?.selectSession(sessionId); }, @@ -216,7 +185,6 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) { pendingSessionId, cancellationPending, modelCatalog, - sessionMenuId, sessionsCursor, accountTrigger, chartLibrary, @@ -240,23 +208,18 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) { initial: name.slice(0, 1) || account.user.email?.slice(0, 1).toUpperCase() || "你", }, sidebarSessions: visibleSessions.map((session) => toSidebarSessionRow(session, chartLibrary)), + sessionHooks: { + hasMore: Boolean(sessionsCursor), + onLoadMore: stableControls.onLoadMore, + onDeleted: stableControls.onSessionDeleted, + isSaved: stableControls.isSessionSaved, + preview: stableControls.preview, + }, controls: { newChatDisabled: !hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending, creatingSession, accountMenuOpen, accountTriggerRef: accountTrigger, - sessionControls: { - hasMore: Boolean(sessionsCursor), - onLoadMore: stableControls.onLoadMore, - menuSessionId: sessionMenuId, - disabled: Boolean(pendingSessionId) || cancellationPending, - onMenuSessionChange: stableControls.onMenuSessionChange, - onTogglePinned: stableControls.onTogglePinned, - onReorder: stableControls.onReorder, - onRename: stableControls.onRename, - onShare: stableControls.onShare, - onDelete: stableControls.onDelete, - }, onAccountMenuOpenChange: stableControls.onAccountMenuOpenChange, onNewChat: stableControls.onNewChat, onSelectSession: stableControls.onSelectSession, @@ -271,7 +234,7 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) { chartLibrary, creatingSession, hydrated, modalOpen, modelCatalog, pendingSessionId, profileName, rectificationOpeningSessionId, rectificationSurfaceOpen, - registerShellControls, sessionMenuId, sessionsCursor, stableControls, visibleSessions, + registerShellControls, sessionsCursor, stableControls, visibleSessions, ]); useEffect(() => () => { diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index 342445d7..ae498f11 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -14,11 +14,9 @@ import { import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model"; import { currentSubjectCatalogReady, peekSubjectCatalog, readCurrentSubjectId, readCurrentSubjectScope, subscribeCurrentSubject, subscribeSubjectCatalog, useCurrentSubject } from "@/lib/current-subject"; import { mergeSubjectSessionRows } from "@/lib/subject-session-merge"; -import { consultationReportMarkdown } from "@/lib/consultation-report-export"; import { fallbackSessionId } from "@/lib/rectification-session-composer-guard"; import { takeSynastryChatDraft } from "@/lib/synastry-report-chat-client"; -import { postOrderBatch, sidebarOrderBatch } from "@/lib/order-batch"; -import { sidebarOrdersForIds, sortSessions } from "@/lib/session-groups"; +import { sortSessions } from "@/lib/session-groups"; import { clearBirthTimeConsultationConsent, type BirthTimeConsultationConsentState, @@ -179,8 +177,6 @@ export function useSessionManagement(params: SessionManagementParams) { unsubscribe(); }; }, [account?.user.id, modelCatalog, setSessions]); - const [sessionMenuId, setSessionMenuId] = useState(null); - const [pendingSessionDeletion, setPendingSessionDeletion] = useState(null); const [sessionDetailLoadingId, setSessionDetailLoadingId] = useState(null); const [sessionFullPrompt, setSessionFullPrompt] = useState<{ question: string; theme: Theme } | null>(null); sessionsRef.current = sessions; @@ -278,99 +274,18 @@ export function useSessionManagement(params: SessionManagementParams) { setDraftTheme(prompt.theme); } - async function renameSession(session: ChatSession) { - const title = window.prompt("重命名聊天记录", session.title)?.trim(); - if (!title || title === session.title) return; - const nextSession = { ...session, title }; - updateSession(session.id, () => nextSession); - try { - await persistSession(nextSession); - } catch (caught) { - setComposerNotice(caught instanceof Error ? caught.message : "重命名同步失败"); - } - } - - async function deleteSession(session: ChatSession) { - if (!account) return; - const previousSessions = sessions; - const nextSessions = sessions.filter((item) => item.id !== session.id); - setSessions(nextSessions); + // Rename, delete, pin, share and reorder are the shell's on every page + // (use-shell-session-actions, issue #116). Home adds what the open + // conversation does when its row is deleted, and which rows are saved. + function onSessionDeleted(session: ChatSession, nextSessions: ChatSession[]) { setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent(current, session.id)); if (activeSessionId === session.id) { activateFallbackSession(nextSessions); } - try { - const response = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" }); - const payload = await response.json().catch(() => null) as { error?: string } | null; - if (!response.ok) throw new Error(payload?.error || "删除聊天记录失败"); - } catch (caught) { - setSessions(previousSessions); - setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败"); - } } - function reorderSessions(orderedIds: readonly string[]) { - if (uiPreview.current || orderedIds.length === 0) return; - const sessions = sessionsRef.current; - const orders = sidebarOrdersForIds(orderedIds); - const batch = sidebarOrderBatch(orderedIds, sessions, (session) => ( - !isUnsavedEmptyConsultation(session) && cloudCreatedIds.current.has(session.id) - )); - const previous = new Map(sessions.map((session) => [session.id, session.sidebarOrder ?? null])); - setSessions((list) => list.map((session) => { - const next = orders.get(session.id); - if (next === undefined || (session.sidebarOrder ?? null) === next) return session; - return { ...session, sidebarOrder: next }; - })); - if (!batch) return; - void postOrderBatch("/api/sessions/order", batch).catch(() => { - setSessions((list) => list.map((session) => { - const prior = previous.get(session.id); - if (prior === undefined || (session.sidebarOrder ?? null) === prior) return session; - return { ...session, sidebarOrder: prior }; - })); - setComposerNotice("排序没有保存"); - }); - } - - function togglePinnedSession(sessionId: string) { - const session = sessions.find((item) => item.id === sessionId); - if (!session) return; - const nextPinned = !session.pinned; - updateSession(sessionId, (current) => ({ ...current, pinned: nextPinned })); - void writeChatSession(sessionId, { pinned: nextPinned }, "update").catch((caught) => { - updateSession(sessionId, (current) => ({ ...current, pinned: session.pinned })); - setComposerNotice(caught instanceof Error ? caught.message : "置顶同步失败"); - }); - } - - async function shareSession(session: ChatSession) { - const sharePayload = { - share_payload_version: 1, - exported_at: new Date().toISOString(), - title: session.title, - theme: session.theme, - message_count: session.messages.length, - messages: session.messages.map((message) => ({ role: message.role, text: message.text })), - }; - const reportMarkdown = consultationReportMarkdown({ title: session.title, messages: session.messages }); - const transcript = [ - `Jyotisha 对话:${session.title}`, - "", - ...session.messages.map((message) => `${message.role === "user" ? "我" : "Jyotisha"}:${message.text}`), - "", - "---- Markdown 报告 ----", - reportMarkdown, - "", - "---- JSON 分享包 ----", - JSON.stringify(sharePayload, null, 2), - ].join("\n"); - try { - await navigator.clipboard.writeText(transcript); - setComposerNotice("已复制当前聊天,可粘贴转发。"); - } catch { - setComposerNotice("无法访问剪贴板,请手动复制聊天内容。"); - } + function isSessionSaved(session: ChatSession) { + return !isUnsavedEmptyConsultation(session) && cloudCreatedIds.current.has(session.id); } async function startNewChat(options?: { continuedFromSessionId?: string; subjectId?: "self" }): Promise { @@ -676,20 +591,13 @@ export function useSessionManagement(params: SessionManagementParams) { visibleSessions, sessionsCursor, setSessionsCursor, - sessionMenuId, - setSessionMenuId, - pendingSessionDeletion, - setPendingSessionDeletion, setSessionFullPrompt, updateSession, persistSession, ensureSessionMessages, continueInNewChat, - renameSession, - deleteSession, - togglePinnedSession, - reorderSessions, - shareSession, + onSessionDeleted, + isSessionSaved, startNewChat, bindEmptyChatSubject, openChatBoundToProfile, diff --git a/frontend/src/hooks/use-shell-session-actions.ts b/frontend/src/hooks/use-shell-session-actions.ts new file mode 100644 index 00000000..37a7dbce --- /dev/null +++ b/frontend/src/hooks/use-shell-session-actions.ts @@ -0,0 +1,208 @@ +"use client"; + +import { useRef } from "react"; + +import type { SidebarSessionControls } from "@/components/sidebar-session-row"; +import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; +import { writeChatSession } from "@/lib/chat-session-write-contract"; +import { consultationReportMarkdown } from "@/lib/consultation-report-export"; +import { currentSubjectCatalogReady, readCurrentSubjectId, readCurrentSubjectScope } from "@/lib/current-subject"; +import { fetchSessionDetail, fetchSessions, LoginRedirectError, readSessions } from "@/lib/home-cloud-sync"; +import type { ChatSession } from "@/lib/home-types"; +import { postOrderBatch, sidebarOrderBatch } from "@/lib/order-batch"; +import { beginSessionPageLoad, mergeSessionPage, sidebarOrdersForIds } from "@/lib/session-groups"; +import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter"; +import type { SessionListContextValue, ShellSessionHooks } from "@/lib/session-list-context"; +import { useSessionStore } from "@/stores/session-store"; + +/** + * Rename, delete, pin, share and reorder for the sidebar rows, on every page + * (issue #116). These used to live in Home's session hook and reached the + * sidebar only while `/` was mounted, so `/chart`, `/ephemeris`, `/reports` + * and `/people` showed a read-only list. The list itself already lived in the + * shell's store; the actions now sit next to it. + * + * Home adds only what is Home's: its paging cursor, what happens to the open + * conversation when it is deleted, and which local new chats are not saved yet. + */ +export function useShellSessionActions( + list: SessionListContextValue, + hooks: ShellSessionHooks | null, +): { + controls: SidebarSessionControls | null; + pendingSessionDeletion: ChatSession | null; + cancelDeletion: () => void; + confirmDeletion: () => void; +} { + const setSessions = useSessionStore((state) => state.setSessions); + const sessionMenuId = useSessionStore((state) => state.sessionMenuId); + const setSessionMenuId = useSessionStore((state) => state.setSessionMenuId); + const pendingSessionDeletion = useSessionStore((state) => state.pendingSessionDeletion); + const setPendingSessionDeletion = useSessionStore((state) => state.setPendingSessionDeletion); + // A reply in flight locks the rows, as it did on `/` alone. + const busy = useSessionStore((state) => Boolean(state.pendingSessionId) || state.cancellationPending); + const loadMoreInFlight = useRef(false); + + const preview = () => hooks?.preview?.() ?? false; + const isSaved = (session: ChatSession) => hooks?.isSaved?.(session) ?? !isUnsavedEmptyConsultation(session); + const find = (sessionId: string) => useSessionStore.getState().sessions.find((session) => session.id === sessionId); + + function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) { + setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session))); + } + + async function renameSession(session: ChatSession) { + const title = window.prompt("重命名聊天记录", session.title)?.trim(); + if (!title || title === session.title) return; + const nextSession = { ...session, title }; + updateSession(session.id, () => nextSession); + if (preview()) return; + try { + await writeChatSession(session.id, { title }, "update"); + } catch (caught) { + setComposerNotice(caught instanceof Error ? caught.message : "重命名同步失败"); + } + } + + async function deleteSession(session: ChatSession) { + if (!list.account) return; + const previousSessions = useSessionStore.getState().sessions; + const nextSessions = previousSessions.filter((item) => item.id !== session.id); + setSessions(nextSessions); + hooks?.onDeleted?.(session, nextSessions); + try { + const response = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" }); + const payload = await response.json().catch(() => null) as { error?: string } | null; + if (!response.ok) throw new Error(payload?.error || "删除聊天记录失败"); + } catch (caught) { + setSessions(previousSessions); + setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败"); + } + } + + function reorderSessions(orderedIds: readonly string[]) { + if (preview() || orderedIds.length === 0) return; + const sessions = useSessionStore.getState().sessions; + const orders = sidebarOrdersForIds(orderedIds); + const batch = sidebarOrderBatch(orderedIds, sessions, isSaved); + const previous = new Map(sessions.map((session) => [session.id, session.sidebarOrder ?? null])); + setSessions((current) => current.map((session) => { + const next = orders.get(session.id); + if (next === undefined || (session.sidebarOrder ?? null) === next) return session; + return { ...session, sidebarOrder: next }; + })); + if (!batch) return; + void postOrderBatch("/api/sessions/order", batch).catch(() => { + setSessions((current) => current.map((session) => { + const prior = previous.get(session.id); + if (prior === undefined || (session.sidebarOrder ?? null) === prior) return session; + return { ...session, sidebarOrder: prior }; + })); + setComposerNotice("排序没有保存"); + }); + } + + function togglePinnedSession(sessionId: string) { + const session = find(sessionId); + if (!session) return; + const nextPinned = !session.pinned; + updateSession(sessionId, (current) => ({ ...current, pinned: nextPinned })); + if (preview()) return; + void writeChatSession(sessionId, { pinned: nextPinned }, "update").catch((caught) => { + updateSession(sessionId, (current) => ({ ...current, pinned: session.pinned })); + setComposerNotice(caught instanceof Error ? caught.message : "置顶同步失败"); + }); + } + + async function shareSession(session: ChatSession) { + // Off `/` a row carries no transcript yet; read it first. The clipboard + // takes a pending blob, so Safari still counts the click as the gesture. + const transcriptFor = (source: ChatSession) => shareTranscript(source); + const hydrated = session.messagesHydrated || session.messages.length > 0; + try { + if (hydrated) { + await navigator.clipboard.writeText(transcriptFor(session)); + } else { + const text = fetchSessionDetail(session.id, null).then((detailed) => transcriptFor(detailed ?? session)); + if (typeof ClipboardItem === "function" && navigator.clipboard.write) { + await navigator.clipboard.write([new ClipboardItem({ "text/plain": text.then((value) => new Blob([value], { type: "text/plain" })) })]); + } else { + await navigator.clipboard.writeText(await text); + } + } + setComposerNotice("已复制当前聊天,可粘贴转发。"); + } catch (caught) { + if (caught instanceof LoginRedirectError) return; + setComposerNotice("无法访问剪贴板,请手动复制聊天内容。"); + } + } + + async function loadMoreSessions() { + const cursor = list.sessionsCursor; + if (!currentSubjectCatalogReady() || !beginSessionPageLoad(loadMoreInFlight, cursor)) return; + const scope = readCurrentSubjectScope(); + try { + const page = await fetchSessions(undefined, { before: cursor, subject: readCurrentSubjectId() }); + if (scope !== readCurrentSubjectScope()) return; + setSessions((current) => mergeSessionPage(current, readSessions(page.sessions, null).sessions)); + list.setSessionsCursor(page.nextCursor); + } catch (caught) { + if (caught instanceof LoginRedirectError) return; + setComposerNotice(caught instanceof Error ? caught.message : "暂时无法读取聊天记录"); + } finally { + loadMoreInFlight.current = false; + } + } + + const withSession = (sessionId: string, run: (session: ChatSession) => void) => { + const session = find(sessionId); + if (session) run(session); + }; + + const controls: SidebarSessionControls | null = list.account && !list.signedOut ? { + hasMore: hooks ? hooks.hasMore : Boolean(list.sessionsCursor), + onLoadMore: hooks ? hooks.onLoadMore : () => { void loadMoreSessions(); }, + menuSessionId: sessionMenuId, + disabled: busy, + onMenuSessionChange: setSessionMenuId, + onTogglePinned: togglePinnedSession, + onReorder: reorderSessions, + onRename: (sessionId) => withSession(sessionId, (session) => { void renameSession(session); }), + onShare: (sessionId) => withSession(sessionId, (session) => { void shareSession(session); }), + onDelete: (sessionId) => withSession(sessionId, setPendingSessionDeletion), + } : null; + + return { + controls, + pendingSessionDeletion, + cancelDeletion: () => setPendingSessionDeletion(null), + confirmDeletion: () => { + const session = pendingSessionDeletion; + setPendingSessionDeletion(null); + if (session) void deleteSession(session); + }, + }; +} + +function shareTranscript(session: ChatSession): string { + const sharePayload = { + share_payload_version: 1, + exported_at: new Date().toISOString(), + title: session.title, + theme: session.theme, + message_count: session.messages.length, + messages: session.messages.map((message) => ({ role: message.role, text: message.text })), + }; + const reportMarkdown = consultationReportMarkdown({ title: session.title, messages: session.messages }); + return [ + `Jyotisha 对话:${session.title}`, + "", + ...session.messages.map((message) => `${message.role === "user" ? "我" : "Jyotisha"}:${message.text}`), + "", + "---- Markdown 报告 ----", + reportMarkdown, + "", + "---- JSON 分享包 ----", + JSON.stringify(sharePayload, null, 2), + ].join("\n"); +} diff --git a/frontend/src/lib/session-list-context.tsx b/frontend/src/lib/session-list-context.tsx index f04a9e27..a1ea5c39 100644 --- a/frontend/src/lib/session-list-context.tsx +++ b/frontend/src/lib/session-list-context.tsx @@ -25,6 +25,7 @@ import { isProfileComplete, readProfile } from "@/lib/home-profile"; import type { Account, ChatSession } from "@/lib/home-types"; import { BOOT_READ_INIT, bootSessionsPath, takeBootSessionsRead, withBootFallback } from "@/lib/boot-early-reads"; import { isListedSidebarSession } from "@/lib/session-list-filter"; +import { sortSessions } from "@/lib/session-groups"; import { toSidebarSessionRow } from "@/lib/session-sidebar-row"; import { mergeSubjectSessionRows } from "@/lib/subject-session-merge"; import { useSessionStore } from "@/stores/session-store"; @@ -39,8 +40,22 @@ export type SessionListBoot = { readonly error?: string; }; +/** + * What only Home knows about the rows the shell manages (issue #116): its own + * paging cursor, what the open conversation does when it is deleted, and + * which local new chats have no server row yet. + */ +export type ShellSessionHooks = { + readonly hasMore: boolean; + readonly onLoadMore: () => void; + readonly onDeleted: (session: ChatSession, remaining: ChatSession[]) => void; + readonly isSaved: (session: ChatSession) => boolean; + readonly preview: () => boolean; +}; + export type ShellRegistration = { readonly controls: AppSidebarControls; + readonly sessionHooks: ShellSessionHooks; readonly escapeBlocked: boolean; readonly activeSessionId: string | null; readonly openingSessionId: string | null; @@ -96,7 +111,8 @@ function toSidebarAccount(account: Account | null): SidebarAccount | null { } function sessionsToSidebarRows(sessions: readonly ChatSession[]): SidebarSession[] { - return sessions.filter((session) => isListedSidebarSession(session) + // Sorted like Home's list, so a drag off `/` lands where it was dropped. + return sortSessions(sessions).filter((session) => isListedSidebarSession(session) && sessionMatchesSubject(session, readCurrentSubjectId())).map((session) => toSidebarSessionRow(session)); } diff --git a/frontend/src/stores/session-store.ts b/frontend/src/stores/session-store.ts index 9569262a..208c6230 100644 --- a/frontend/src/stores/session-store.ts +++ b/frontend/src/stores/session-store.ts @@ -33,6 +33,12 @@ type SessionState = { copiedMessageKey: string | null; setCopiedMessageKey: Dispatch>; warmSeeded: boolean; + /** The row whose 「…」 menu is open. The shell owns the menu on every page (issue #116). */ + sessionMenuId: string | null; + setSessionMenuId: Dispatch>; + /** The session waiting on the shell's delete confirmation. */ + pendingSessionDeletion: ChatSession | null; + setPendingSessionDeletion: Dispatch>; /** Reset the fields Home() used to own, once per Home mount. Sessions stay. */ mountHomeConversation: (activeSessionId: string) => void; /** Reset sessions and the conversation fields when a new list provider mounts. */ @@ -48,6 +54,8 @@ const emptyConversation = { streamingReply: null as StreamingReply | null, copiedMessageKey: null as string | null, warmSeeded: false, + sessionMenuId: null as string | null, + pendingSessionDeletion: null as ChatSession | null, }; let claimedOwner: symbol | null = null; @@ -66,6 +74,8 @@ export const useSessionStore = create((set) => ({ setStreamingReply: (update) => set((state) => ({ streamingReply: apply(state.streamingReply, update) })), setMessageFeedback: (update) => set((state) => ({ messageFeedback: apply(state.messageFeedback, update) })), setCopiedMessageKey: (update) => set((state) => ({ copiedMessageKey: apply(state.copiedMessageKey, update) })), + setSessionMenuId: (update) => set((state) => ({ sessionMenuId: apply(state.sessionMenuId, update) })), + setPendingSessionDeletion: (update) => set((state) => ({ pendingSessionDeletion: apply(state.pendingSessionDeletion, update) })), mountHomeConversation: (activeSessionId) => set({ ...emptyConversation, activeSessionId, diff --git a/frontend/tests/chat-session-url.test.ts b/frontend/tests/chat-session-url.test.ts index d4247c10..ae23548a 100644 --- a/frontend/tests/chat-session-url.test.ts +++ b/frontend/tests/chat-session-url.test.ts @@ -283,8 +283,13 @@ test("creating and leaving a session keep the address bar in sync", () => { // 原值:删/归档当前会话走 `activateFallbackSession` // 新值:只剩删除走 `activateFallbackSession`;归档函数已删 // 原因:BUG-991 归档下线,删除路径不动 - const deleteSession = sourceBetween(page, "async function deleteSession(", "function togglePinnedSession"); - assert.match(deleteSession, /if \(activeSessionId === session\.id\) \{\s*activateFallbackSession\(nextSessions\);/); + // 原值:deleteSession 里直接 `if (activeSessionId === session.id) { activateFallbackSession(nextSessions);` + // 新值:外壳的 deleteSession 先乐观删行再调 Home 的 onDeleted;Home 的 onSessionDeleted 里做同一个判断 + // 原因:issue #116 删除搬进共用外壳,其他页面也能删;删掉当前会话时首页仍跳到下一条 + const deleteSession = sourceBetween(page, "async function deleteSession(", "function reorderSessions"); + assert.match(deleteSession, /setSessions\(nextSessions\);\s*hooks\?\.onDeleted\?\.\(session, nextSessions\);/); + const onDeleted = sourceBetween(page, "function onSessionDeleted(", "function isSessionSaved"); + assert.match(onDeleted, /if \(activeSessionId === session\.id\) \{\s*activateFallbackSession\(nextSessions\);/); assert.doesNotMatch(page, /function toggleArchivedSession\(/); const fallback = sourceBetween(page, "function activateFallbackSession(", "async function loadMoreSessions"); diff --git a/frontend/tests/home-sidebar-rerender.test.tsx b/frontend/tests/home-sidebar-rerender.test.tsx index cddebee4..22fe2d28 100644 --- a/frontend/tests/home-sidebar-rerender.test.tsx +++ b/frontend/tests/home-sidebar-rerender.test.tsx @@ -61,9 +61,10 @@ function registrationData(registration: ShellRegistration | null) { newChatDisabled: registration.controls.newChatDisabled, creatingSession: registration.controls.creatingSession, accountMenuOpen: registration.controls.accountMenuOpen, - hasMore: registration.controls.sessionControls.hasMore ?? false, - menuSessionId: registration.controls.sessionControls.menuSessionId, - disabled: registration.controls.sessionControls.disabled, + // 原值:hasMore / menuSessionId / disabled 都读 registration.controls.sessionControls + // 新值:只剩 hasMore,读 registration.sessionHooks;菜单开合与锁行改由外壳从 store 读 + // 原因:issue #116 会话行的「…」与拖动搬进共用外壳,Home 不再发布这两项 + hasMore: registration.sessionHooks.hasMore, }; } @@ -113,7 +114,10 @@ async function runProbe(sessionCount: number) { const metrics = { registers: 0, commits: 0, actualDuration: 0, recording: false }; const selectRevisions: number[] = []; - const renameRevisions: number[] = []; + // 原值:renameRevisions,经 sessionControls.onRename 记录 + // 新值:loadMoreRevisions,经 sessionHooks.onLoadMore 记录 + // 原因:重命名归外壳(issue #116);Home 仍发布的稳定包装换成翻页,断言同一件事:点下去跑的是最新一次渲染的回调 + const loadMoreRevisions: number[] = []; const driver: { setFrame: (frame: number) => void; setActive: (id: string) => void; @@ -153,7 +157,7 @@ async function runProbe(sessionCount: number) { chartLibrary: charts, creatingSession: false, hydrated: list.settled, - loadMoreSessions: () => {}, + loadMoreSessions: () => { loadMoreRevisions.push(revision); }, modalOpen: false, modelCatalog: null, openAccountDialog: () => {}, @@ -162,18 +166,13 @@ async function runProbe(sessionCount: number) { rectificationOpeningSessionId: null, rectificationSurfaceOpen: false, registerShellControls, - renameSession: () => { renameRevisions.push(revision); }, selectSession: () => { selectRevisions.push(revision); }, - sessionMenuId: null, - sessions, sessionsCursor: null, setAccountMenuOpen: () => {}, - setPendingSessionDeletion: () => {}, - setSessionMenuId: () => {}, - shareSession: () => {}, + onSessionDeleted: () => {}, + isSessionSaved: () => true, + uiPreview: { current: false }, startNewChat: () => {}, - togglePinnedSession: () => {}, - reorderSessions: () => {}, // New array every render. Element identity stays until a real edit. visibleSessions: sessions.filter(() => true), }); @@ -284,7 +283,7 @@ async function runProbe(sessionCount: number) { await harness.update(() => driver.setRevision(4)); const afterRevisionRegisters = metrics.registers; currentShell()?.controls.onSelectSession("session-0"); - currentShell()?.controls.sessionControls.onRename("session-0"); + currentShell()?.sessionHooks.onLoadMore(); metrics.registers = 0; await harness.update(() => driver.switchPerson()); const afterSwitchRegisters = metrics.registers; @@ -300,7 +299,7 @@ async function runProbe(sessionCount: number) { renamedTitle, afterRevisionRegisters, selectRevisions, - renameRevisions, + loadMoreRevisions, afterSwitchRegisters, otherTitle, }; @@ -313,7 +312,7 @@ async function runProbe(sessionCount: number) { assert.equal(renamedTitle, "Renamed synthetic"); assert.equal(afterRevisionRegisters, 0, "a revision republished the sidebar"); assert.deepEqual(selectRevisions, [4]); - assert.deepEqual(renameRevisions, [4]); + assert.deepEqual(loadMoreRevisions, [4]); assert.equal(afterSwitchRegisters, 1); assert.equal(otherTitle, "Other person session"); return report; diff --git a/frontend/tests/home-surface.ts b/frontend/tests/home-surface.ts index 4a9a5235..c9cc0037 100644 --- a/frontend/tests/home-surface.ts +++ b/frontend/tests/home-surface.ts @@ -24,6 +24,9 @@ const optionalHomeHookFiles = [ "../src/hooks/use-synastry.ts", "../src/hooks/use-session-list.ts", "../src/hooks/use-home-shell-registration.ts", + // issue #116:会话行的重命名、删除、收藏、转发、排序和删除确认框搬进共用外壳。 + "../src/hooks/use-shell-session-actions.ts", + "../src/components/session-delete-dialog.tsx", "../src/lib/session-list-context.tsx", "../src/lib/session-list-filter.ts", "../src/lib/home-bootstrap-run.ts", diff --git a/frontend/tests/order-batch.test.ts b/frontend/tests/order-batch.test.ts index 86802dbe..0704a539 100644 --- a/frontend/tests/order-batch.test.ts +++ b/frontend/tests/order-batch.test.ts @@ -76,7 +76,10 @@ test("order batch rejects a short, duplicate, or non-integer body", () => { }); test("reorder and archive each post one batch, and the migration stays out of db/migrations", () => { - const hook = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8"); + // 原值:读 src/hooks/use-session-management.ts + // 新值:读 src/hooks/use-shell-session-actions.ts + // 原因:issue #116 排序搬进共用外壳,每个页面都能拖;断言内容不变 + const hook = readFileSync(new URL("../src/hooks/use-shell-session-actions.ts", import.meta.url), "utf8"); const reorder = hook.slice(hook.indexOf("function reorderSessions"), hook.indexOf("function togglePinnedSession")); assert.match(reorder, /postOrderBatch\(\s*"\/api\/sessions\/order"/); assert.doesNotMatch(reorder, /Promise\.allSettled|writeChatSession\(/); diff --git a/frontend/tests/session-list-lifecycle.test.tsx b/frontend/tests/session-list-lifecycle.test.tsx index 6db73096..0fbf2729 100644 --- a/frontend/tests/session-list-lifecycle.test.tsx +++ b/frontend/tests/session-list-lifecycle.test.tsx @@ -90,12 +90,10 @@ async function fixture(options: { strict?: boolean; unauthorized?: boolean } = { profile: { ...emptyProfile, name: `Synthetic ${revision}` }, rectificationOpeningSessionId: null, rectificationSurfaceOpen: false, registerShellControls: list.registerShellControls, - renameSession: changed, selectSession: changed, sessionMenuId: null, - sessions: list.sessions, sessionsCursor: null, - setAccountMenuOpen: noop, setPendingSessionDeletion: noop, setSessionMenuId: noop, - shareSession: changed, startNewChat: changed, - togglePinnedSession: changed, - reorderSessions: changed, + // issue #116:重命名、转发、收藏、排序、删除确认归外壳,这些入参随之删除。 + selectSession: changed, sessionsCursor: null, + setAccountMenuOpen: noop, onSessionDeleted: changed, isSessionSaved: () => true, + uiPreview: { current: false }, startNewChat: changed, // Like useSessionManagement, intentionally new array/callbacks each render. visibleSessions: list.sessions.filter(() => true), }); diff --git a/frontend/tests/shell-session-actions-20261028.test.tsx b/frontend/tests/shell-session-actions-20261028.test.tsx new file mode 100644 index 00000000..bf0d332d --- /dev/null +++ b/frontend/tests/shell-session-actions-20261028.test.tsx @@ -0,0 +1,147 @@ +import "./install-test-document"; +import assert from "node:assert/strict"; +import test from "node:test"; +import React, { useEffect } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { AppSidebar } from "../src/components/app-sidebar.tsx"; +import { SidebarProvider } from "../src/components/ui/sidebar.tsx"; +import { useShellSessionActions } from "../src/hooks/use-shell-session-actions.ts"; +import type { Account, ChatSession } from "../src/lib/home-types.ts"; +import type { SessionListContextValue, ShellSessionHooks } from "../src/lib/session-list-context.tsx"; +import { useSessionStore } from "../src/stores/session-store.ts"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; + +Object.assign(globalThis, { React }); + +function syntheticSession(index: number): ChatSession { + return { + id: `session-${index}`, + title: `Synthetic session ${index}`, + theme: "general", + modelId: "test", + messages: [], + createdAt: 1, + updatedAt: 1_700_000_000_000 + index, + sessionType: "consultation", + rectificationCaseId: null, + chartProfileId: null, + chartProfileName: null, + chartProfileRole: null, + pinned: false, + archivedAt: null, + messagesHydrated: false, + }; +} + +const account = { user: { id: "user-1", email: "synthetic@example.test" }, credits: 3, profile: null, avatar: null } as unknown as Account; + +function fakeList(): SessionListContextValue { + return { + sessions: [], + setSessions: () => {}, + sessionsCursor: null, + setSessionsCursor: () => {}, + account, + setAccount: () => {}, + signedOut: false, + settled: true, + ready: Promise.resolve(), + waitUntilReady: async () => {}, + boot: () => null, + reload: () => {}, + registerShellControls: () => {}, + }; +} + +const row = { id: "session-1", title: "Synthetic session 1", pinned: false, updatedAt: 0 }; +const noop = () => {}; +const sessionControls = { + menuSessionId: null, + disabled: false, + onMenuSessionChange: noop, + onTogglePinned: noop, + onRename: noop, + onShare: noop, + onDelete: noop, +}; + +// Issue #116: off `/` nothing registers Home's controls, yet the rows carry the menu. +test("a row off `/` has the 「…」 menu once the shell supplies session controls", () => { + const render = (withControls: boolean) => renderToStaticMarkup( + + + , + ); + const managed = render(true); + assert.match(managed, /session-menu-trigger/); + assert.doesNotMatch(managed, /data-readonly="true"/); + // Signed out or not read yet: still plain links. + assert.match(render(false), /data-readonly="true"/); +}); + +test("the shell deletes after confirmation, tells Home, and puts the row back when the server refuses", async () => { + const harness = createClientLifecycleHarness(); + const originalFetch = globalThis.fetch; + const requests: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push(`${init?.method ?? "GET"} ${String(input)}`); + return new Response(JSON.stringify({ error: "服务暂时不可用" }), { status: 503 }); + }) as typeof fetch; + const sessions = [syntheticSession(1), syntheticSession(2)]; + useSessionStore.setState({ sessions, pendingSessionDeletion: null, sessionMenuId: null, pendingSessionId: null, cancellationPending: false }); + const deleted: Array<{ id: string; remaining: string[] }> = []; + const hooks: ShellSessionHooks = { + hasMore: false, + onLoadMore: noop, + onDeleted: (session, remaining) => { deleted.push({ id: session.id, remaining: remaining.map((item) => item.id) }); }, + isSaved: () => true, + preview: () => false, + }; + let actions: ReturnType | null = null; + function Probe({ list, shellHooks }: { list: SessionListContextValue; shellHooks: ShellSessionHooks | null }) { + const value = useShellSessionActions(list, shellHooks); + useEffect(() => { actions = value; }); + return null; + } + try { + await harness.render(); + assert.ok(actions); + await harness.update(() => actions?.controls?.onDelete("session-1")); + assert.equal(useSessionStore.getState().pendingSessionDeletion?.id, "session-1", "delete asks first"); + assert.deepEqual(requests, [], "nothing is sent before the confirmation"); + await harness.update(() => actions?.confirmDeletion()); + assert.equal(useSessionStore.getState().pendingSessionDeletion, null); + assert.deepEqual(deleted, [{ id: "session-1", remaining: ["session-2"] }]); + assert.deepEqual(requests, ["DELETE /api/sessions/session-1"]); + await harness.idle(); + assert.deepEqual(useSessionStore.getState().sessions.map((item) => item.id), ["session-1", "session-2"], "rolled back"); + assert.deepEqual(harness.errors, []); + } finally { + globalThis.fetch = originalFetch; + useSessionStore.setState({ sessions: [], pendingSessionDeletion: null }); + await harness.close(); + } +}); + +test("signed out, the shell offers no session controls", async () => { + const harness = createClientLifecycleHarness(); + let controls: unknown = "unset"; + function Probe() { + const value = useShellSessionActions({ ...fakeList(), account: null, signedOut: true }, null); + useEffect(() => { controls = value.controls; }); + return null; + } + try { + await harness.render(); + assert.equal(controls, null); + } finally { + await harness.close(); + } +}); diff --git a/tests/test_session_management_entrypoints.py b/tests/test_session_management_entrypoints.py index c3afd889..427fa50e 100644 --- a/tests/test_session_management_entrypoints.py +++ b/tests/test_session_management_entrypoints.py @@ -16,6 +16,9 @@ HOME_SURFACE_FILES = ( ) OPTIONAL_HOME_HOOKS = ( _FRONTEND_SRC / "hooks" / "use-session-management.ts", + # issue #116:会话行的重命名、删除、收藏、转发、排序和删除确认框搬进共用外壳。 + _FRONTEND_SRC / "hooks" / "use-shell-session-actions.ts", + _FRONTEND_SRC / "components" / "session-delete-dialog.tsx", _FRONTEND_SRC / "hooks" / "use-consultation-run.ts", _FRONTEND_SRC / "hooks" / "use-profile-onboarding.ts", _FRONTEND_SRC / "hooks" / "use-rectification-surface.ts", diff --git a/tests/test_supabase_user_data_contract.py b/tests/test_supabase_user_data_contract.py index 63633b2b..b8478c14 100644 --- a/tests/test_supabase_user_data_contract.py +++ b/tests/test_supabase_user_data_contract.py @@ -57,6 +57,9 @@ HOME_SURFACE_FILES = ( OPTIONAL_HOME_HOOKS = ( _FRONTEND_SRC / "hooks" / "use-session-management.ts", + # issue #116:会话行的重命名、删除、收藏、转发、排序和删除确认框搬进共用外壳。 + _FRONTEND_SRC / "hooks" / "use-shell-session-actions.ts", + _FRONTEND_SRC / "components" / "session-delete-dialog.tsx", _FRONTEND_SRC / "hooks" / "use-consultation-run.ts", _FRONTEND_SRC / "hooks" / "use-profile-onboarding.ts", _FRONTEND_SRC / "hooks" / "use-rectification-surface.ts",