Sidebar session menu and drag on every page (issue #116)
Rename, delete with confirmation, pin, share and reorder moved from Home's session hook into the shared (app) shell, so /chart, /ephemeris, /reports and /people rows get the same menu and drag as /. Home only adds its paging cursor, what the open conversation does when deleted, and which local chats are unsaved. BUG-1467. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
36fd4b2c83
commit
0f0fea325e
@@ -1,5 +1,12 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-28 — 每个页面的侧栏会话都能「…」和拖动
|
||||
|
||||
- 在星盘、星历、我的报告、星盘档案页,侧栏的会话也有「…」菜单(收藏、重命名、转发、删除),也能拖动排序,跟首页一样。
|
||||
- 删除照旧先弹确认;没删成会退回原样并提示。在哪个页面改的,回到首页列表都一致。
|
||||
- 在这些页面点一条会话,仍回到首页打开它。
|
||||
- Skill 版本不变(BUG-1467,issue #116)。
|
||||
|
||||
## 2026-10-27 — 星盘页按签过的原型改回来
|
||||
|
||||
- 盘面按原型画:每一宫写星座符号和名字,下一行写宫号(上升宫写「上升 1」);星按庙旺绿、落陷红着色,逆行写 R。
|
||||
|
||||
+23
-1
@@ -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=<id>`。外壳兜底列表改为 `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`。
|
||||
|
||||
@@ -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=<id>`。
|
||||
- 外壳兜底列表按 `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` 曾被其他会话清理腾盘,重建后验收;不影响结果。
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 | 退出登录,打开星盘页 | 侧栏没有会话菜单,底部是「去登录」 |
|
||||
+1
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<SidebarProvider escapeBlocked={registration?.escapeBlocked ?? false}>
|
||||
@@ -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 }) {
|
||||
</PageSwitch>
|
||||
</SidebarInset>
|
||||
</ShellFrame>
|
||||
<SessionDeleteDialog
|
||||
session={sessionActions.pendingSessionDeletion}
|
||||
onCancel={sessionActions.cancelDeletion}
|
||||
onConfirm={sessionActions.confirmDeletion}
|
||||
/>
|
||||
<LegalConsentGate signedIn={Boolean(account) && !list.signedOut} />
|
||||
</main>
|
||||
<OnboardingHintLayer />
|
||||
|
||||
@@ -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<Theme | null>(null);
|
||||
const draftEntrypoint = useRef<ConsultationEntrypoint | null>(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}
|
||||
/>
|
||||
<SynastryDeepLink profile={profile} draft={synastryPanel.draftSynastryQuestionFromChart} />
|
||||
<AccountDialogDeepLink ready={hydrated && Boolean(account)} open={openAccountDialog} />
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{replyAnnouncement}</span>
|
||||
{pendingSessionDeletion ? (
|
||||
<AlertDialog open onOpenChange={(next) => { if (!next) setPendingSessionDeletion(null); }}>
|
||||
<AlertDialogContent className="session-delete-overlay" aria-label="确认删除聊天记录">
|
||||
<AlertDialogTitle>删除聊天记录?</AlertDialogTitle>
|
||||
<AlertDialogDescription>“{pendingSessionDeletion.title}”将被永久删除,无法恢复。</AlertDialogDescription>
|
||||
<div className="dialog-actions">
|
||||
<Button variant="outline" type="button" onClick={() => setPendingSessionDeletion(null)}>取消</Button>
|
||||
<Button variant="destructive" type="button" onClick={() => {
|
||||
const session = pendingSessionDeletion;
|
||||
setPendingSessionDeletion(null);
|
||||
void deleteSession(session);
|
||||
}}>确认删除</Button>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : null}
|
||||
<header className="chat-header">
|
||||
<span className="onboarding-menu-anchor">
|
||||
<SidebarTrigger placement="inset" />
|
||||
|
||||
@@ -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=<dialog>` (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=<id>`, and the account menu's dialog
|
||||
* entries become links to `/?account=<dialog>`
|
||||
* (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<HTMLButtonElement>;
|
||||
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=<id>`. `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<AppSidebarControls> = 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. */
|
||||
|
||||
@@ -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 (
|
||||
<AlertDialog open onOpenChange={(next) => { if (!next) onCancel(); }}>
|
||||
<AlertDialogContent className="session-delete-overlay" aria-label="确认删除聊天记录">
|
||||
<AlertDialogTitle>删除聊天记录?</AlertDialogTitle>
|
||||
<AlertDialogDescription>“{session.title}”将被永久删除,无法恢复。</AlertDialogDescription>
|
||||
<div className="dialog-actions">
|
||||
<Button variant="outline" type="button" onClick={onCancel}>取消</Button>
|
||||
<Button variant="destructive" type="button" onClick={onConfirm}>确认删除</Button>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -29,34 +29,26 @@ type HomeShellRegistrationParams = {
|
||||
rectificationOpeningSessionId: string | null;
|
||||
rectificationSurfaceOpen: boolean;
|
||||
registerShellControls: (registration: ShellRegistration | null) => void;
|
||||
renameSession: (session: ChatSession) => void | Promise<void>;
|
||||
selectSession: (sessionId: string) => void;
|
||||
sessionMenuId: string | null;
|
||||
sessions: readonly ChatSession[];
|
||||
sessionsCursor: string | null;
|
||||
setAccountMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setPendingSessionDeletion: Dispatch<SetStateAction<ChatSession | null>>;
|
||||
setSessionMenuId: Dispatch<SetStateAction<string | null>>;
|
||||
shareSession: (session: ChatSession) => void | Promise<void>;
|
||||
/** Home's part of a row deletion the shell runs (issue #116). */
|
||||
onSessionDeleted: (session: ChatSession, remaining: ChatSession[]) => void;
|
||||
isSessionSaved: (session: ChatSession) => boolean;
|
||||
uiPreview: RefObject<boolean>;
|
||||
startNewChat: () => void | Promise<unknown>;
|
||||
togglePinnedSession: (sessionId: string) => void;
|
||||
reorderSessions: (orderedIds: readonly string[]) => void;
|
||||
visibleSessions: readonly ChatSession[];
|
||||
};
|
||||
|
||||
type LatestShellActions = {
|
||||
sessions: readonly ChatSession[];
|
||||
loadMoreSessions: () => void;
|
||||
renameSession: (session: ChatSession) => void | Promise<void>;
|
||||
selectSession: (sessionId: string) => void;
|
||||
shareSession: (session: ChatSession) => void | Promise<void>;
|
||||
startNewChat: () => void | Promise<unknown>;
|
||||
togglePinnedSession: (sessionId: string) => void;
|
||||
reorderSessions: (orderedIds: readonly string[]) => void;
|
||||
openAccountDialog: (dialog: AccountDialogName, options?: { source?: string }) => void;
|
||||
setSessionMenuId: Dispatch<SetStateAction<string | null>>;
|
||||
setAccountMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setPendingSessionDeletion: Dispatch<SetStateAction<ChatSession | null>>;
|
||||
onSessionDeleted: (session: ChatSession, remaining: ChatSession[]) => void;
|
||||
isSessionSaved: (session: ChatSession) => boolean;
|
||||
uiPreview: RefObject<boolean>;
|
||||
};
|
||||
|
||||
type PublishedShellInputs = {
|
||||
@@ -72,7 +64,6 @@ type PublishedShellInputs = {
|
||||
pendingSessionId: string | null;
|
||||
cancellationPending: boolean;
|
||||
modelCatalog: PublicLanguageModelCatalog | null;
|
||||
sessionMenuId: string | null;
|
||||
sessionsCursor: string | null;
|
||||
accountTrigger: RefObject<HTMLButtonElement | null>;
|
||||
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(() => () => {
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [pendingSessionDeletion, setPendingSessionDeletion] = useState<ChatSession | null>(null);
|
||||
const [sessionDetailLoadingId, setSessionDetailLoadingId] = useState<string | null>(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<ChatSession | null> {
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ type SessionState = {
|
||||
copiedMessageKey: string | null;
|
||||
setCopiedMessageKey: Dispatch<SetStateAction<string | null>>;
|
||||
warmSeeded: boolean;
|
||||
/** The row whose 「…」 menu is open. The shell owns the menu on every page (issue #116). */
|
||||
sessionMenuId: string | null;
|
||||
setSessionMenuId: Dispatch<SetStateAction<string | null>>;
|
||||
/** The session waiting on the shell's delete confirmation. */
|
||||
pendingSessionDeletion: ChatSession | null;
|
||||
setPendingSessionDeletion: Dispatch<SetStateAction<ChatSession | null>>;
|
||||
/** 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<SessionState>((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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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\(/);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<SidebarProvider>
|
||||
<AppSidebar
|
||||
sessions={[row]}
|
||||
activeSessionId={null}
|
||||
account={null}
|
||||
sessionControls={withControls ? sessionControls : null}
|
||||
onOpenSession={noop}
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
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<typeof useShellSessionActions> | 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(<Probe list={fakeList()} shellHooks={hooks} />);
|
||||
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(<Probe />);
|
||||
assert.equal(controls, null);
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user