Sidebar session menu and drag on every page (issue #116)
Independent Staging Quality Gate / validate (push) Failing after 15m9s
Independent Staging Quality Gate / publish (push) Skipped

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:
Jesse_Chen
2026-10-28 01:52:47 +08:00
co-authored by Claude Opus 5.5
parent 36fd4b2c83
commit 0f0fea325e
23 changed files with 619 additions and 244 deletions
+7
View File
@@ -1,5 +1,12 @@
# 印度占星 Skill 更新日志
## 2026-10-28 — 每个页面的侧栏会话都能「…」和拖动
- 在星盘、星历、我的报告、星盘档案页,侧栏的会话也有「…」菜单(收藏、重命名、转发、删除),也能拖动排序,跟首页一样。
- 删除照旧先弹确认;没删成会退回原样并提示。在哪个页面改的,回到首页列表都一致。
- 在这些页面点一条会话,仍回到首页打开它。
- Skill 版本不变(BUG-1467,issue #116)。
## 2026-10-27 — 星盘页按签过的原型改回来
- 盘面按原型画:每一宫写星座符号和名字,下一行写宫号(上升宫写「上升 1」);星按庙旺绿、落陷红着色,逆行写 R。
+23 -1
View File
@@ -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` 曾被其他会话清理腾盘,重建后验收;不影响结果。
+1
View File
@@ -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
View File
@@ -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)
+15
View File
@@ -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 />
+9 -40
View File
@@ -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" />
+20 -14
View File
@@ -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(() => () => {
+9 -101
View File
@@ -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");
}
+17 -1
View File
@@ -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));
}
+10
View File
@@ -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,
+7 -2
View File
@@ -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");
+15 -16
View File
@@ -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;
+3
View File
@@ -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",
+4 -1
View File
@@ -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",