fix(chat): choose the chart person before the first message
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# BLOCKED
|
||||
|
||||
## BUG-1000:聊天人物选择缺少受控登录态(2026-09-22)
|
||||
|
||||
- 本轮没有登录态,也没有 Chrome。顶栏点击、焦点回收和跨设备刷新不能写成通过。
|
||||
- 清单:`docs/testing/chat-subject-picker-20260922.md`。定向测试与类型检查见 `docs/tasks/PROGRESS-chat-subject-picker-20260922.md`。未推送,未部署。
|
||||
|
||||
## BUG-982/983 日期锚点:未部署与受控真人缺口(2026-09-20)
|
||||
|
||||
本轮本地实现不等于 staging 交付;未 commit/push,未部署。缺本轮受控登录态,不能把服务端动作、真实 PostgreSQL 或 SSR 当作浏览器通过。真人须按 `docs/testing/rectification-cross-midnight-fix-20260920.md` 的日期锚点补充清单走查。D4 数据库授权已解除原禁止新迁移停点,但不授权修改旧迁移、历史日期猜补、IANA/DST 修复或阈值。
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-22 — 开口前可以选定聊天的人物
|
||||
|
||||
普通聊天顶栏的人名可以点开。还没发消息时能换成另一位人物;发过之后要换人就新建一段对话。本人是默认,而且看得见。对方资料删了,旧对话还能看,不能再发。Skill 版本不变。
|
||||
|
||||
## 2026-09-22 — 更新出生资料后,星盘跟的是同一份时间
|
||||
|
||||
保存出生资料后,星盘不再沿用刚才那张盘。读不到资料、已采用的时间缺时区、时区对不上、盘算不出来,页面上分开说明,不再只剩一句没有内容。Skill 版本不变。
|
||||
|
||||
@@ -13263,3 +13263,19 @@
|
||||
- 相关记录:BUG-011、BUG-058、BUG-188
|
||||
- 复发自:无
|
||||
- 修复版本:本分支 `fix(report): project ordinary reports onto public prose only`(未推送,未部署)
|
||||
|
||||
## BUG-1000 | 普通聊天顶栏不能在开口前选择人物
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-22
|
||||
- 最近更新:2026-09-22
|
||||
- 影响面:普通聊天顶栏的人物入口;空会话的待发送绑定;已有消息会话的换人路径
|
||||
- 用户现象:服务端已经能按会话绑定人物,但顶栏仍是一段不能点的名字。新对话沿用账户里上次选中的星盘,开口前看不见、也换不了人。开口之后没有「新建会话并使用此人物」这条路。
|
||||
- 触发条件:打开普通聊天,想在第一条消息前换成另一位本人名下的人物;或在已经说过话的会话里点另一个人。
|
||||
- 根因:BUG-997 只补了服务端真值。`startNewChat` 仍把 `activeChartId` 抄进新会话,顶栏 `.chat-header-chart` 仍是静态 `span`,没有选择器。历史会话打开时还会把该人物写回账户级 `activeChartId`。
|
||||
- 修复:普通咨询顶栏改为「当前星盘」按钮。本人是看得见的默认。空会话选择只更新该会话的绑定,已落库的空会话 PATCH 只提交 id 和角色,姓名由服务端填写。已有消息的会话不改绑,只提供新建会话。人物列表失败、未读完或不完整时不写入绑定。资料已删除的旧会话仍可阅读,发送阻断,不退回本人。报告、每日星语和生时校正不挂这个选择器。
|
||||
- 验证:见 `docs/tasks/PROGRESS-chat-subject-picker-20260922.md`。没有登录态,浏览器点击未验收,见 `docs/testing/chat-subject-picker-20260922.md`。
|
||||
- 防复发:新普通会话的人物 id 固定为本人,不读 `activeChartId`。打开历史会话不得改写该会话绑定,也不得把绑定抄回 `activeChartId`。有消息的会话不得 PATCH 人物。列表未就绪时不得创建绑定。
|
||||
- 相关记录:BUG-997
|
||||
- 复发自:无
|
||||
- 修复版本:本分支 `fix(chat): choose the chart person before the first message`(未推送,未部署)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# 进度 · 开始聊天选择人物(2026-09-22)
|
||||
|
||||
基线:`6d06fca0d878403dc1fab646a20c5b8ef7447fd5`(开工时 `origin/staging`)。分支 `codex/chat-subject-picker-20260922`。未推送,未部署。
|
||||
|
||||
## 做了什么
|
||||
|
||||
- 普通咨询顶栏的静态人名改成 `ChatProfilePicker`。读屏名称是「当前星盘:人物名」,看见的字只有当前人物名。
|
||||
- 新对话默认本人,不读 `activeChartId`。空会话里点另一个人,只改这一段还没开口的绑定。还没落库的空会话把 id 留在本地,第一条消息的 `POST /api/sessions` 带上它,姓名和角色由服务端填写。已经落库、仍没有消息的空会话只 `PATCH` id 和角色,不提交客户端姓名,再读回服务端姓名。
|
||||
- 已经有消息的会话不改绑。点另一个人只有「新建会话并使用此人物」。回答还在进行时这个动作不执行。
|
||||
- 打开历史会话不再把该人物写回账户级 `activeChartId`。深链和刷新仍只用库里的绑定。
|
||||
- 人物列表加载中或失败时不创建绑定,也不把缺列表说成「资料已删除」。资料确认已删除后,旧会话还能看,发送被挡住,不退回本人。资料改名后,选择器显示资料里的现名,不改写已锁定会话的快照。
|
||||
- 报告、每日星语入口和生时校正不挂这个选择器。校正顶栏仍是原来的静态 chip。
|
||||
|
||||
## 断言变更
|
||||
|
||||
没有改既有断言的期望值,也没有删测试名。`chart-library-session`、`session-open-preserves-identity`、`chat-session-write`、`chat-session-authority` 各加了一条,原断言仍在。
|
||||
|
||||
| 测试 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| 无 | — | — | 本轮只加断言,没有把旧期望改松 |
|
||||
|
||||
## 验证
|
||||
|
||||
- `tsc --noEmit`:通过。
|
||||
- `npm run lint`:0 error。既有 warning 未改。
|
||||
- `npx tsx --test` 定向:`chat-profile-picker` 10、`chart-library-session` 6、`session-open-preserves-identity` 5、`chat-session-write` 17、`chat-session-authority` 10、`home-shell-growth-contract` 4,加上同批的 `chat-session-url`、`composer-isolation-contract`、`consultation-subject-binding`。合计 79 pass / 0 fail;收尾再跑前六个文件 52 pass / 0 fail。
|
||||
- 未跑全量 `npm test`,未跑 `next build`。
|
||||
- `Home()`:`useState` 34(上限 36),`useRef` 37(上限 37),都没有新增调用。`page.tsx` 1844 行,低于 1846 + 150 的粗护栏。
|
||||
- 没有登录态。浏览器点击未验收,见 `docs/testing/chat-subject-picker-20260922.md` 和 `BLOCKED.md`。
|
||||
|
||||
## 没动
|
||||
|
||||
- `starter-home.tsx`、starter-entry CSS、`consult/route.ts`、`docs/tasks/README.md`。
|
||||
- 没有改表,没有改服务端 resolver 的失败即关闭语义。
|
||||
@@ -0,0 +1,14 @@
|
||||
# 聊天人物选择:浏览器点击未在本轮验收
|
||||
|
||||
本轮只在工作树里跑了类型检查、lint 和定向 node:test。没有登录态,也没有打开浏览器。
|
||||
|
||||
下面这些不能写成通过。需要一位已登录的人,用合成人物(不要用真实出生资料)在普通聊天里看:
|
||||
|
||||
- [ ] 新对话顶栏能看见本人名字。点开后有本人,也有自己添加的其他人。Enter、Space 和点击都能开合;Esc 和点外面会关上,焦点回到人名。
|
||||
- [ ] 还没发消息时换成另一个人,再发出去。刷新和把链接换到另一台设备,这段对话仍是那个人,不是账户里上次选的星盘。
|
||||
- [ ] 已经说过话的会话里点另一个人,只出现「新建会话并使用此人物」。旧会话的历史不变。回答还在生成时,这个动作要等回答结束。
|
||||
- [ ] 人物列表读不出来时,不换成一个没点过的人。
|
||||
- [ ] 删掉对方资料后,旧会话还能看,不能再发,也不会变成本人。
|
||||
- [ ] 报告、每日星语、生时校正上没有这个人物按钮。
|
||||
|
||||
示例只用虚构人物。不要把真实姓名、出生时间或邮箱写进记录。
|
||||
+4
-1
@@ -346,7 +346,10 @@ The birth-time rectification session is the consultation transcript plus a house
|
||||
### Chat header
|
||||
|
||||
- One line, 46px on desktop. Sidebar trigger, session title, and the credit pill. It was 68px and carried a second line reading `分析对象:{盘名}`.
|
||||
- The chart a session analyses is now a quiet `--color-ink-tertiary` chip beside the title (`.chat-header-chart`), and it drops out below 768px rather than wrapping the header to two rows. "分析对象" was internal vocabulary; the chart name alone carries the same fact.
|
||||
- Ordinary consultation: the person is a quiet button beside the title (`.chat-profile-picker-trigger`, still `.chat-header-chart`). The accessible name is `当前星盘:{人物名}`; the visible text is only the current person's name. Click, Enter, and Space open a popover. `aria-expanded` and `aria-controls` point at that popover. Escape, an outside click, and a successful choice close it and return focus to the button. The button and each row are at least 44px. The popover uses the elevated canvas, warm hairline, and the same 120ms opacity-plus-4px motion as the model selector; reduced motion drops the translation.
|
||||
- Before the first message the list selects a person and updates that empty session's binding. Self is the visible default. After the first message the current person stays selected; another person offers only `新建会话并使用此人物` and does not rebind the old session. That action waits until the current request ends.
|
||||
- Loading reads `人物列表还在读`. Failure reads `人物列表暂时读不出来` with `重试`. An incomplete row reads `这份资料还不完整` and cannot be chosen. A deleted person reads `资料已删除 · {名字}`; the old session stays readable and sending is blocked. None of these states use a spinner.
|
||||
- Rectification, reports, and daily horoscope do not get this control. Their quiet note, when present, remains the static `.chat-header-chart` span and still drops out below 768px. The consultation button stays on narrow screens and ellipsizes so the header remains one line.
|
||||
- With the composer strip gone as well, the two pieces of permanent chrome went from 68 + 148 to 46 + 124 px.
|
||||
- On viewports ≤767px the header row is 52px plus the safe-area inset. The credit pill and the rectification 「当前盘面」 chip share one size: 40px visual height, 14px tabular type, the same radius and padding, no 64px min-width. The 44px touch target is the transparent `::before` ring.
|
||||
- Window-level scroll is reset by `ViewportScrollLock` after the on-screen keyboard closes, so the header cannot be left above the screen.
|
||||
|
||||
@@ -106,6 +106,26 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
| 2019 入职 | 2019 年前后,你有没有换过工作?A. 明确发生且时间吻合 | 2019 年前后,你有没有换过工作? | 不得把选项字面写进题干。 |
|
||||
| 2023 感情区分 | 你有没有一段认真开始或结束的关系? | 工作这块记下了。2023 年前后,有没有一段认真开始或结束的关系? | 题干必须写出服务端给的年份;漏写年份用户看不见期间。 |
|
||||
|
||||
## 聊天顶栏的当前星盘
|
||||
|
||||
顶栏人名是入口,不是资料编辑。看见的字是当前这个人的名字。读屏名称是「当前星盘:」加上这个名字。不要写「我的星盘」或「分析对象」。
|
||||
|
||||
| 位置 | 文案 |
|
||||
|---|---|
|
||||
| 弹出层标题 | 当前星盘 |
|
||||
| 本人 | 本人 |
|
||||
| 空会话里点另一个人 | 直接换成这个人,不另写一句确认 |
|
||||
| 已经说过话,点另一个人 | 新建会话并使用此人物 |
|
||||
| 回答还没结束 | 这段回答结束后再换 |
|
||||
| 列表还在读 | 人物列表还在读 |
|
||||
| 列表没读出来 | 人物列表暂时读不出来 |
|
||||
| 重试 | 重试 |
|
||||
| 资料不完整 | 这份资料还不完整 |
|
||||
| 资料已删除 | 资料已删除 · {原来的名字} |
|
||||
| 删除后不能再发 | 这份资料已经删除,这段对话只能看。 |
|
||||
|
||||
不写「正在加载」。不因为列表失败就改绑到本人。
|
||||
|
||||
## 侧栏与会话标题
|
||||
|
||||
历史分组标签只用这五个词,空组不出现:今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早。不要写成「七日内」「更早之前」。
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
type ChatMessageFeedback,
|
||||
} from "@/components/chat-message-actions";
|
||||
import { ChatTranscript, type ChatTranscriptActions } from "@/components/chat-transcript";
|
||||
import { ChatProfilePicker } from "@/components/chat-profile-picker";
|
||||
import { ModelSelector } from "@/components/model-selector";
|
||||
import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall";
|
||||
import { ChatComposer } from "@/components/chat-composer";
|
||||
@@ -174,6 +175,8 @@ import {
|
||||
type StreamingReply,
|
||||
type Theme,
|
||||
} from "@/lib/home-types";
|
||||
import { chatSubjectSendBlocked } from "@/lib/chat-profile-picker-model";
|
||||
import { useChatProfileCatalog } from "@/hooks/use-chat-profile-catalog";
|
||||
import {
|
||||
birthQuestion,
|
||||
chartSnapshotForSession,
|
||||
@@ -454,6 +457,8 @@ export default function Home() {
|
||||
togglePinnedSession,
|
||||
shareSession,
|
||||
startNewChat,
|
||||
bindEmptyChatSubject,
|
||||
openChatBoundToProfile,
|
||||
selectSession, selectSessionModel, loadMoreSessions,
|
||||
} = useSessionManagement({
|
||||
account, accountId, activeChartId, activeSession, activeSessionId, activeSessionIdRef,
|
||||
@@ -563,6 +568,36 @@ export default function Home() {
|
||||
queueMicrotask(() => setActiveChartId(storedChartId));
|
||||
}, [accountId, hydrated]);
|
||||
|
||||
const {
|
||||
status: chatProfileStatus,
|
||||
markReady: markChatProfilesReady,
|
||||
markFailed: markChatProfilesFailed,
|
||||
beginReload: beginChatProfileReload,
|
||||
} = useChatProfileCatalog(accountId);
|
||||
const subjectDeleted = chatSubjectSendBlocked({
|
||||
session: activeSession,
|
||||
status: chatProfileStatus,
|
||||
library: chartLibrary,
|
||||
});
|
||||
|
||||
function reloadChatProfiles() {
|
||||
if (!accountId) return;
|
||||
const requested = accountId;
|
||||
const profileForLibrary: Profile = activeChartId === "self" ? profile : account ? readProfile(account.profile) : profile;
|
||||
beginChatProfileReload();
|
||||
void fetchCloudChartLibrary()
|
||||
.then((cloudLibrary) => {
|
||||
if (chartLibraryLoadedAccount.current !== requested) return;
|
||||
setChartLibrary(chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart));
|
||||
markChatProfilesReady(requested);
|
||||
})
|
||||
.catch(() => {
|
||||
if (chartLibraryLoadedAccount.current !== requested) return;
|
||||
markChatProfilesFailed(requested);
|
||||
setComposerNotice("星盘库暂时无法读取,请重试。");
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const branch = chartLibrarySessionBranch(accountId, chartLibraryLoadedAccount.current);
|
||||
if (branch === "clear" || !accountId) {
|
||||
@@ -582,17 +617,26 @@ export default function Home() {
|
||||
clearSynastryHistory();
|
||||
void fetchCloudChartLibrary()
|
||||
.then((cloudLibrary) => {
|
||||
if (chartLibraryLoadedAccount.current !== accountId) return;
|
||||
setChartLibrary(chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart));
|
||||
markChatProfilesReady(accountId);
|
||||
})
|
||||
.catch(() => {
|
||||
if (chartLibraryLoadedAccount.current !== accountId) return;
|
||||
markChatProfilesFailed(accountId);
|
||||
setComposerNotice("星盘库暂时无法读取,请重试。", {
|
||||
label: "重试",
|
||||
onClick: () => {
|
||||
beginChatProfileReload();
|
||||
void fetchCloudChartLibrary()
|
||||
.then((cloudLibrary) => {
|
||||
if (chartLibraryLoadedAccount.current !== accountId) return;
|
||||
setChartLibrary(chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart));
|
||||
markChatProfilesReady(accountId);
|
||||
})
|
||||
.catch(() => {
|
||||
if (chartLibraryLoadedAccount.current !== accountId) return;
|
||||
markChatProfilesFailed(accountId);
|
||||
setComposerNotice("星盘库暂时无法读取,请重试。");
|
||||
});
|
||||
},
|
||||
@@ -618,7 +662,7 @@ export default function Home() {
|
||||
});
|
||||
}
|
||||
setChartLibrary((current) => upsertSelfChart(current, profileForLibrary));
|
||||
}, [account, accountId, activeChartId, applyCloudSynastryHistory, clearSynastryHistory, profile]);
|
||||
}, [account, accountId, activeChartId, applyCloudSynastryHistory, beginChatProfileReload, clearSynastryHistory, markChatProfilesFailed, markChatProfilesReady, profile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !accountId || chartLibrary.length === 0) return;
|
||||
@@ -747,6 +791,7 @@ export default function Home() {
|
||||
pendingConsultation,
|
||||
pendingSessionId,
|
||||
profile,
|
||||
subjectDeleted,
|
||||
sessions,
|
||||
setAccount,
|
||||
setActiveSessionId,
|
||||
@@ -1426,6 +1471,7 @@ export default function Home() {
|
||||
if (onboardingStep === "name" && presetMessageFinished) void saveOnboardingName();
|
||||
return;
|
||||
}
|
||||
if (subjectDeleted) return;
|
||||
if (isLoading || cancellationPending) return void enqueueQueuedDraft(composerDraftSnapshot());
|
||||
void send(composerDraftSnapshot(), draftTheme.current ?? undefined, draftEntrypoint.current);
|
||||
}
|
||||
@@ -1566,7 +1612,19 @@ export default function Home() {
|
||||
<SidebarTrigger placement="inset" />
|
||||
<div className="chat-header-title">
|
||||
<strong>{activeSession?.title || "新对话"}</strong>
|
||||
{activeSession ? <span className="chat-header-chart">{sessionChartLabel(activeSession, chartLibrary)}</span> : null}
|
||||
{activeSession?.sessionType === "consultation" ? (
|
||||
<ChatProfilePicker
|
||||
session={activeSession}
|
||||
library={chartLibrary}
|
||||
status={chatProfileStatus}
|
||||
requestInFlight={Boolean(pendingSessionId) || cancellationPending}
|
||||
onBindEmpty={bindEmptyChatSubject}
|
||||
onOpenNew={openChatBoundToProfile}
|
||||
onReload={reloadChatProfiles}
|
||||
/>
|
||||
) : activeSession ? (
|
||||
<span className="chat-header-chart">{sessionChartLabel(activeSession, chartLibrary)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="chat-header-actions">
|
||||
<div className="chat-header-rectification" data-rectification-header-slot="" ref={setRectificationHeaderSlot} />
|
||||
@@ -1711,11 +1769,11 @@ export default function Home() {
|
||||
<ChatComposer
|
||||
inputRef={composerInput}
|
||||
inputLabel={!profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
|
||||
placeholder={ordinaryComposerPlaceholder({ locked: rectificationComposerLocked, hasAccount: Boolean(account), profileComplete, onboardingName: onboardingStep === "name", presetMessageFinished, creditsEmpty: Boolean(account && account.credits === 0 && !account.activeSubscription) })}
|
||||
placeholder={ordinaryComposerPlaceholder({ locked: rectificationComposerLocked, hasAccount: Boolean(account), profileComplete, onboardingName: onboardingStep === "name", presetMessageFinished, creditsEmpty: Boolean(account && account.credits === 0 && !account.activeSubscription), subjectDeleted })}
|
||||
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
|
||||
inputDisabled={sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
|
||||
inputDisabled={subjectDeleted || sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
|
||||
submitLabel={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"}
|
||||
submitBlocked={Boolean(pendingSessionId) || Boolean(queuedDraft) || sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
|
||||
submitBlocked={subjectDeleted || Boolean(pendingSessionId) || Boolean(queuedDraft) || sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
|
||||
stopVisible={isLoading}
|
||||
stopLabel={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
|
||||
stopTitle={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
|
||||
|
||||
@@ -642,6 +642,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.chat-header > div { min-width: 0; flex: 1; }
|
||||
.chat-header { padding: 0 var(--space-3); }
|
||||
.chat-header-chart { display: none; }
|
||||
button.chat-profile-picker-trigger { display: inline-flex; }
|
||||
.onboarding-card { padding: 16px; }
|
||||
|
||||
.message-assistant .message-content { max-width: 100%; }
|
||||
@@ -678,7 +679,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
width: 8px !important;
|
||||
height: 8px !important;
|
||||
}
|
||||
.model-selector-popup[data-starting-style], .model-selector-popup[data-ending-style], .session-actions[data-starting-style], .session-actions[data-ending-style], .account-menu-popup[data-starting-style], .account-menu-popup[data-ending-style], [role="tooltip"][data-starting-style], [role="tooltip"][data-ending-style] { transform: none; }
|
||||
.model-selector-popup[data-starting-style], .model-selector-popup[data-ending-style], .chat-profile-picker-popup[data-starting-style], .chat-profile-picker-popup[data-ending-style], .session-actions[data-starting-style], .session-actions[data-ending-style], .account-menu-popup[data-starting-style], .account-menu-popup[data-ending-style], [role="tooltip"][data-starting-style], [role="tooltip"][data-ending-style] { transform: none; }
|
||||
.auth-step { transform: none; transition: opacity 80ms linear !important; }
|
||||
@starting-style { .auth-step { opacity: 0; transform: none; } }
|
||||
}
|
||||
@@ -906,12 +907,33 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.chat-panel.is-rectification { grid-template-rows: 46px minmax(0, 1fr); }
|
||||
.chat-header { z-index: 2; min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: 0 var(--space-4); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); text-align: left; }
|
||||
.chat-header > div { min-width: 0; }
|
||||
/* One line, 46px tall. The former second line read `分析对象:…`; the chart it
|
||||
analyses is now a quiet chip beside the title, and drops out on narrow
|
||||
viewports rather than wrapping the header to two rows. */
|
||||
.chat-header-title { min-width: 0; display: flex; align-items: baseline; gap: var(--space-3); }
|
||||
/* One line, 46px tall. The chart chip stays beside the title and ellipsizes
|
||||
instead of wrapping the header. Narrow viewports hide the static note; the
|
||||
ordinary-chat person button stays, because it is the only person entry. */
|
||||
.chat-header-title { min-width: 0; display: flex; align-items: center; gap: var(--space-3); }
|
||||
.chat-header strong { min-width: 0; overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-size: var(--type-body-md); font-weight: 500; letter-spacing: -.2px; }
|
||||
.chat-header-chart { flex: 0 1 auto; min-width: 0; overflow: hidden; color: var(--color-ink-tertiary); font-size: var(--type-caption); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chat-profile-picker-trigger { max-width: 46%; min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; margin: 0; padding: 0 var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-tertiary); cursor: pointer; font: inherit; font-size: var(--type-caption); line-height: 1.35; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out; }
|
||||
.chat-profile-picker-trigger:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 1px; }
|
||||
.chat-profile-picker-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chat-profile-picker-positioner { z-index: 30; outline: 0; }
|
||||
.chat-profile-picker-popup { width: max-content; min-width: 220px; max-width: min(320px, calc(100vw - var(--space-6))); border: 1px solid var(--color-border); padding: var(--space-3); border-radius: var(--radius-lg); background: var(--color-canvas); box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); }
|
||||
.chat-profile-picker-popup[data-starting-style], .chat-profile-picker-popup[data-ending-style] { opacity: 0; transform: translateY(calc(var(--space-1) * -1)); }
|
||||
.chat-profile-picker-title { margin: 0; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-sm); font-weight: 500; }
|
||||
.chat-profile-picker-status { margin: var(--space-2) 0 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.45; }
|
||||
.chat-profile-picker-status p { margin: 0; }
|
||||
.chat-profile-picker-retry { min-height: 44px; margin-top: var(--space-2); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-action); cursor: pointer; font: inherit; font-size: var(--type-caption); }
|
||||
.chat-profile-picker-options { display: grid; gap: var(--space-1); margin: var(--space-2) 0 0; }
|
||||
.chat-profile-picker-option { width: 100%; min-height: 44px; display: grid; grid-template-columns: minmax(0, 1fr) 20px; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3); border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; text-align: left; font: inherit; transition: border-color 120ms ease-out, background-color 120ms ease-out, color 120ms ease-out; }
|
||||
.chat-profile-picker-option[aria-disabled="true"] { cursor: default; color: var(--color-ink-tertiary); }
|
||||
.chat-profile-picker-option[data-selected] { border-color: var(--color-border); background: var(--color-selected); color: var(--color-ink); }
|
||||
.chat-profile-picker-option:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 1px; }
|
||||
.chat-profile-picker-option:disabled { cursor: default; }
|
||||
.chat-profile-picker-copy { min-width: 0; display: grid; gap: 2px; }
|
||||
.chat-profile-picker-copy b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: inherit; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; }
|
||||
.chat-profile-picker-copy small { color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.35; }
|
||||
.chat-profile-picker-check { width: 18px; height: 18px; opacity: 0; color: var(--color-action); }
|
||||
.chat-profile-picker-option[data-selected] .chat-profile-picker-check { opacity: 1; }
|
||||
.chat-header-actions { min-width: max-content; display: flex; flex: 0 0 auto; align-items: center; gap: var(--space-2); white-space: nowrap; }
|
||||
.chat-header-actions > * { flex: 0 0 auto; }
|
||||
/* The page header is 46px tall, so its actions are 32px — the button primitives
|
||||
@@ -1981,6 +2003,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.composer button:not(:disabled):hover, .button-primary:not(:disabled):hover { background: var(--color-action-hover); }
|
||||
.model-selector-trigger:not(:disabled):hover { background: var(--color-canvas-muted); color: var(--color-ink); }
|
||||
.model-selector-option:hover { background: var(--color-canvas-soft); color: var(--color-ink); }
|
||||
.chat-profile-picker-trigger:hover { background: var(--color-canvas-muted); color: var(--color-ink); }
|
||||
.chat-profile-picker-option:not(:disabled):not([aria-disabled="true"]):hover { background: var(--color-canvas-soft); color: var(--color-ink); }
|
||||
.button-secondary:not(:disabled):hover { background: var(--color-canvas-muted); }
|
||||
.danger-primary:not(:disabled):hover { background: color-mix(in srgb, var(--color-danger) 88%, var(--color-ink)); }
|
||||
.birth-time-source-option:hover, .birth-time-answer-list button:not(:disabled):hover { border-color: var(--color-action); }
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { Popover } from "@base-ui/react/popover";
|
||||
import { Check } from "lucide-react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
chatProfileChoice,
|
||||
chatProfileDisplayName,
|
||||
chatProfilePickerRows,
|
||||
chatProfileSelectedId,
|
||||
chatSessionSubjectLocked,
|
||||
type ChatProfileCatalogStatus,
|
||||
type ChatProfilePickerRow,
|
||||
} from "@/lib/chat-profile-picker-model";
|
||||
import type { ChartLibraryRecord, ChatSession } from "@/lib/home-types";
|
||||
|
||||
export type ChatProfilePickerProps = {
|
||||
readonly session: ChatSession;
|
||||
readonly library: readonly ChartLibraryRecord[];
|
||||
readonly status: ChatProfileCatalogStatus;
|
||||
readonly requestInFlight: boolean;
|
||||
readonly onBindEmpty: (chartProfileId: string) => Promise<boolean>;
|
||||
readonly onOpenNew: (chartProfileId: string) => Promise<boolean>;
|
||||
readonly onReload: () => void;
|
||||
};
|
||||
|
||||
export function ChatProfilePicker({
|
||||
session,
|
||||
library,
|
||||
status,
|
||||
requestInFlight,
|
||||
onBindEmpty,
|
||||
onOpenNew,
|
||||
onReload,
|
||||
}: ChatProfilePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const listId = useId();
|
||||
const label = chatProfileDisplayName({ session, status, library });
|
||||
const selectedId = chatProfileSelectedId(session);
|
||||
const rows = chatProfilePickerRows({ status, library, selectedId });
|
||||
const locked = chatSessionSubjectLocked(session);
|
||||
const deleted = status === "ready" && Boolean(selectedId) && selectedId !== "self" && !rows.some((row) => row.id === selectedId);
|
||||
|
||||
function closePicker() {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
|
||||
async function choose(row: ChatProfilePickerRow) {
|
||||
if (!row.complete || pendingId) return;
|
||||
const choice = chatProfileChoice({
|
||||
session,
|
||||
chartProfileId: row.id,
|
||||
requestInFlight,
|
||||
libraryReady: status === "ready",
|
||||
selectable: row.complete,
|
||||
});
|
||||
if (choice.action === "ignore") {
|
||||
if (row.selected) closePicker();
|
||||
return;
|
||||
}
|
||||
setPendingId(row.id);
|
||||
try {
|
||||
const ok = choice.action === "bind"
|
||||
? await onBindEmpty(choice.chartProfileId)
|
||||
: await onOpenNew(choice.chartProfileId);
|
||||
if (ok) closePicker();
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) triggerRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger
|
||||
ref={triggerRef}
|
||||
className="chat-header-chart chat-profile-picker-trigger"
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-label={`当前星盘:${label}`}
|
||||
>
|
||||
<span className="chat-profile-picker-name">{label}</span>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Positioner className="chat-profile-picker-positioner" side="bottom" align="start" sideOffset={8} collisionPadding={12}>
|
||||
<Popover.Popup
|
||||
id={listId}
|
||||
className="chat-profile-picker-popup"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
closePicker();
|
||||
}}
|
||||
>
|
||||
<Popover.Title className="chat-profile-picker-title">当前星盘</Popover.Title>
|
||||
{status === "loading" ? <p className="chat-profile-picker-status" role="status">人物列表还在读</p> : null}
|
||||
{status === "failed" ? (
|
||||
<div className="chat-profile-picker-status" role="status">
|
||||
<p>人物列表暂时读不出来</p>
|
||||
<button className="chat-profile-picker-retry" type="button" onClick={onReload}>重试</button>
|
||||
</div>
|
||||
) : null}
|
||||
{deleted ? <p className="chat-profile-picker-status">这份资料已经删除,这段对话只能看。</p> : null}
|
||||
{rows.length > 0 ? (
|
||||
<div className="chat-profile-picker-options" role="group" aria-label="当前星盘">
|
||||
{rows.map((row) => (
|
||||
<ProfileRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
locked={locked}
|
||||
requestInFlight={requestInFlight}
|
||||
pending={pendingId === row.id}
|
||||
onChoose={() => void choose(row)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Popover.Popup>
|
||||
</Popover.Positioner>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileRow({
|
||||
row,
|
||||
locked,
|
||||
requestInFlight,
|
||||
pending,
|
||||
onChoose,
|
||||
}: {
|
||||
readonly row: ChatProfilePickerRow;
|
||||
readonly locked: boolean;
|
||||
readonly requestInFlight: boolean;
|
||||
readonly pending: boolean;
|
||||
readonly onChoose: () => void;
|
||||
}) {
|
||||
if (!row.complete) {
|
||||
return (
|
||||
<div className="chat-profile-picker-option" aria-disabled="true">
|
||||
<span className="chat-profile-picker-copy">
|
||||
<b>{row.name}</b>
|
||||
<small>{row.detail}</small>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (locked && !row.selected) {
|
||||
return (
|
||||
<button
|
||||
className="chat-profile-picker-option"
|
||||
type="button"
|
||||
disabled={requestInFlight || pending}
|
||||
aria-label={`${row.name},新建会话并使用此人物`}
|
||||
onClick={onChoose}
|
||||
>
|
||||
<span className="chat-profile-picker-copy">
|
||||
<b>{row.name}</b>
|
||||
<small>{requestInFlight ? "这段回答结束后再换" : "新建会话并使用此人物"}</small>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="chat-profile-picker-option"
|
||||
type="button"
|
||||
aria-pressed={row.selected}
|
||||
data-selected={row.selected ? "" : undefined}
|
||||
disabled={pending}
|
||||
onClick={onChoose}
|
||||
>
|
||||
<span className="chat-profile-picker-copy">
|
||||
<b>{row.name}</b>
|
||||
<small>{row.detail}</small>
|
||||
</span>
|
||||
<Check className="chat-profile-picker-check" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { ChatProfileCatalogStatus } from "@/lib/chat-profile-picker-model";
|
||||
|
||||
/**
|
||||
* Load state for the ordinary-chat person list.
|
||||
* The page effect still owns the chart-library fetch; this hook only records
|
||||
* whether that read is in flight, ready, or failed. A failure stays failed
|
||||
* until an explicit retry, so a self-only fallback cannot look like deletion.
|
||||
*/
|
||||
export function useChatProfileCatalog(accountId: string | undefined) {
|
||||
const accountRef = useRef(accountId);
|
||||
useEffect(() => {
|
||||
accountRef.current = accountId;
|
||||
}, [accountId]);
|
||||
const [trackedAccount, setTrackedAccount] = useState(accountId);
|
||||
const [status, setStatus] = useState<ChatProfileCatalogStatus>(accountId ? "loading" : "idle");
|
||||
if (trackedAccount !== accountId) {
|
||||
setTrackedAccount(accountId);
|
||||
setStatus(accountId ? "loading" : "idle");
|
||||
}
|
||||
|
||||
const markReady = useCallback((forAccountId: string) => {
|
||||
if (accountRef.current !== forAccountId) return;
|
||||
setStatus("ready");
|
||||
}, []);
|
||||
|
||||
const markFailed = useCallback((forAccountId: string) => {
|
||||
if (accountRef.current !== forAccountId) return;
|
||||
setStatus("failed");
|
||||
}, []);
|
||||
|
||||
const beginReload = useCallback(() => {
|
||||
if (!accountRef.current) return;
|
||||
setStatus("loading");
|
||||
}, []);
|
||||
|
||||
return { status, markReady, markFailed, beginReload };
|
||||
}
|
||||
@@ -117,6 +117,7 @@ export type ConsultationRunParams = {
|
||||
pendingConsultation: MutableRefObject<PendingConsultation | null>;
|
||||
pendingSessionId: string | null;
|
||||
profile: Profile;
|
||||
subjectDeleted: boolean;
|
||||
sessions: ChatSession[];
|
||||
setAccount: Dispatch<SetStateAction<Account | null>>;
|
||||
setActiveSessionId: Dispatch<SetStateAction<string>>;
|
||||
@@ -175,6 +176,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
pendingConsultation,
|
||||
pendingSessionId,
|
||||
profile,
|
||||
subjectDeleted,
|
||||
sessions,
|
||||
setAccount,
|
||||
setActiveSessionId,
|
||||
@@ -513,6 +515,10 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
: null);
|
||||
const resumeRequestId = options.resumeRequestId;
|
||||
const resuming = Boolean(resumeRequestId);
|
||||
if (!resuming && subjectDeleted) {
|
||||
setComposerNotice("这份资料已经删除,这段对话只能看。");
|
||||
return false;
|
||||
}
|
||||
const liveSession = targetSessionId
|
||||
? sessions.find((session) => session.id === targetSessionId)
|
||||
: activeSession;
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
type BirthTimeConsultationConsentState,
|
||||
} from "@/lib/birth-time-consultation-consent";
|
||||
import {
|
||||
activeChartStorageKey,
|
||||
createSession,
|
||||
fetchSessionDetail,
|
||||
fetchSessions,
|
||||
@@ -34,6 +33,12 @@ import {
|
||||
isUnsavedEmptyConsultation,
|
||||
replaceUnsavedEmptyConsultations,
|
||||
} from "@/lib/session-list-filter";
|
||||
import {
|
||||
chatSessionSubjectLocked,
|
||||
emptySessionBindingWrite,
|
||||
NEW_CHAT_SUBJECT_ID,
|
||||
} from "@/lib/chat-profile-picker-model";
|
||||
import { isConsultationSubjectProfileComplete } from "@/lib/consultation-subject-resolver";
|
||||
import { chartSnapshotForSession } from "@/lib/home-profile";
|
||||
import { beginSessionPageLoad, mergeSessionPage } from "@/lib/session-groups";
|
||||
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
|
||||
@@ -91,8 +96,6 @@ export type SessionManagementParams = {
|
||||
export function useSessionManagement(params: SessionManagementParams) {
|
||||
const {
|
||||
account,
|
||||
accountId,
|
||||
activeChartId,
|
||||
activeSession,
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
@@ -111,7 +114,6 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
sessionSelectionSource,
|
||||
sessions,
|
||||
sessionsRef,
|
||||
setActiveChartId,
|
||||
setActiveSessionId,
|
||||
setBirthTimeConsultationConsent,
|
||||
setDraft,
|
||||
@@ -297,7 +299,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
if (!account || !modelCatalog || creatingSession) return null;
|
||||
const nextSession = {
|
||||
...createSession(modelCatalog.defaultModelId),
|
||||
...chartSnapshotForSession(activeChartId, chartLibrary, profile),
|
||||
...chartSnapshotForSession(NEW_CHAT_SUBJECT_ID, chartLibrary, profile),
|
||||
};
|
||||
for (const session of sessions) {
|
||||
if (isUnsavedEmptyConsultation(session)) pendingCreateById.current.delete(session.id);
|
||||
@@ -330,13 +332,8 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
setComposerNotice("");
|
||||
setRectificationError("");
|
||||
setRectificationErrorSessionId(null);
|
||||
if (nextSession?.chartProfileId) {
|
||||
const boundChart = chartLibrary.find((record) => record.id === nextSession.chartProfileId);
|
||||
if (boundChart && boundChart.id !== activeChartId && accountId) {
|
||||
setActiveChartId(boundChart.id);
|
||||
localStorage.setItem(activeChartStorageKey(accountId), boundChart.id);
|
||||
}
|
||||
}
|
||||
// Opening a stored session keeps that session's binding. It does not
|
||||
// follow or rewrite the account-level active chart.
|
||||
if (nextSession?.sessionType === "birth_time_rectification") {
|
||||
setRectificationError("");
|
||||
if (deferredRectificationSwitch) {
|
||||
@@ -490,6 +487,93 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function selectableChartRecord(chartProfileId: string) {
|
||||
const record = chartProfileId === "self"
|
||||
? chartLibrary.find((item) => item.id === "self" || item.role === "self")
|
||||
: chartLibrary.find((item) => item.id === chartProfileId && item.role === "other");
|
||||
if (!record || !isConsultationSubjectProfileComplete(record.profile)) return null;
|
||||
return record;
|
||||
}
|
||||
|
||||
async function bindEmptyChatSubject(chartProfileId: string): Promise<boolean> {
|
||||
const session = sessionsRef.current.find((item) => item.id === activeSession?.id) ?? activeSession;
|
||||
if (!session || chatSessionSubjectLocked(session)) return false;
|
||||
const record = selectableChartRecord(chartProfileId);
|
||||
if (!record) return false;
|
||||
const requestedId = record.role === "self" ? "self" : record.id;
|
||||
if (session.chartProfileId === requestedId && session.chartProfileRole === record.role) return true;
|
||||
if (cloudCreatedIds.current.has(session.id)) {
|
||||
const previousRole = session.chartProfileRole === "self" || session.chartProfileRole === "other"
|
||||
? session.chartProfileRole
|
||||
: null;
|
||||
try {
|
||||
await writeChatSession(session.id, emptySessionBindingWrite(requestedId, record.role), "update");
|
||||
const detailed = await fetchSessionDetail(session.id, modelCatalog);
|
||||
if (detailed?.chartProfileId !== requestedId || detailed.chartProfileRole !== record.role) {
|
||||
throw new Error("人物没有绑上");
|
||||
}
|
||||
updateSession(session.id, (current) => {
|
||||
if (chatSessionSubjectLocked(current)) return current;
|
||||
return {
|
||||
...current,
|
||||
chartProfileId: detailed.chartProfileId,
|
||||
chartProfileName: detailed.chartProfileName,
|
||||
chartProfileRole: detailed.chartProfileRole,
|
||||
};
|
||||
});
|
||||
return true;
|
||||
} catch (caught) {
|
||||
if (previousRole) {
|
||||
try {
|
||||
await writeChatSession(
|
||||
session.id,
|
||||
emptySessionBindingWrite(session.chartProfileId ?? "self", previousRole),
|
||||
"update",
|
||||
);
|
||||
} catch {
|
||||
setComposerNotice("人物没有绑上");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setComposerNotice(caught instanceof Error && caught.message !== "人物没有绑上"
|
||||
? caught.message
|
||||
: "人物没有绑上");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const snapshot = chartSnapshotForSession(requestedId, chartLibrary, profile);
|
||||
updateSession(session.id, (current) => {
|
||||
if (chatSessionSubjectLocked(current)) return current;
|
||||
return { ...current, ...snapshot };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function openChatBoundToProfile(chartProfileId: string): Promise<boolean> {
|
||||
if (!account || !modelCatalog || creatingSession) return false;
|
||||
if (pendingSessionId || cancellationPending) return false;
|
||||
const record = selectableChartRecord(chartProfileId);
|
||||
if (!record) return false;
|
||||
const requestedId = record.role === "self" ? "self" : record.id;
|
||||
const nextSession = {
|
||||
...createSession(modelCatalog.defaultModelId),
|
||||
...chartSnapshotForSession(requestedId, chartLibrary, profile),
|
||||
};
|
||||
for (const existing of sessions) {
|
||||
if (isUnsavedEmptyConsultation(existing)) pendingCreateById.current.delete(existing.id);
|
||||
}
|
||||
pendingCreateById.current.set(nextSession.id, {});
|
||||
setSessions((current) => replaceUnsavedEmptyConsultations(current, nextSession));
|
||||
setActiveSessionId(nextSession.id);
|
||||
setDraft("");
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
setComposerNotice("");
|
||||
setRequestError(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
visibleSessions,
|
||||
sessionsCursor,
|
||||
@@ -508,6 +592,8 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
togglePinnedSession,
|
||||
shareSession,
|
||||
startNewChat,
|
||||
bindEmptyChatSubject,
|
||||
openChatBoundToProfile,
|
||||
selectSession,
|
||||
selectSessionModel,
|
||||
loadMoreSessions,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { isConsultationSubjectProfileComplete } from "@/lib/consultation-subject-resolver";
|
||||
import { chartRelationshipLabel, sessionChartLabel } from "@/lib/home-profile";
|
||||
import type { ChartLibraryRecord, ChatSession } from "@/lib/home-types";
|
||||
|
||||
export type ChatProfileCatalogStatus = "idle" | "loading" | "ready" | "failed";
|
||||
|
||||
/** New ordinary chats show self. They do not inherit the account's active chart. */
|
||||
export const NEW_CHAT_SUBJECT_ID = "self" as const;
|
||||
|
||||
export type ChatProfileChoice =
|
||||
| { readonly action: "bind"; readonly chartProfileId: string }
|
||||
| { readonly action: "open-new"; readonly chartProfileId: string }
|
||||
| { readonly action: "ignore" };
|
||||
|
||||
export type ChatProfilePickerRow = {
|
||||
readonly id: string;
|
||||
readonly role: "self" | "other";
|
||||
readonly name: string;
|
||||
readonly detail: string;
|
||||
readonly complete: boolean;
|
||||
readonly selected: boolean;
|
||||
};
|
||||
|
||||
type SubjectSession = Pick<ChatSession, "chartProfileId" | "chartProfileName" | "chartProfileRole" | "sessionType" | "messagesHydrated" | "messages">;
|
||||
|
||||
export function chatSessionSubjectLocked(session: {
|
||||
sessionType: string;
|
||||
messagesHydrated: boolean;
|
||||
messages: readonly unknown[];
|
||||
}): boolean {
|
||||
if (session.sessionType !== "consultation") return true;
|
||||
if (!session.messagesHydrated) return true;
|
||||
return session.messages.length > 0;
|
||||
}
|
||||
|
||||
export function chatProfileSelectedId(session: Pick<ChatSession, "chartProfileId" | "chartProfileRole">): string {
|
||||
if (session.chartProfileRole === "other" || (session.chartProfileId && session.chartProfileId !== "self")) {
|
||||
return session.chartProfileId ?? "";
|
||||
}
|
||||
return "self";
|
||||
}
|
||||
|
||||
export function chatProfileDisplayName(input: {
|
||||
session: Pick<ChatSession, "chartProfileId" | "chartProfileName" | "chartProfileRole">;
|
||||
status: ChatProfileCatalogStatus;
|
||||
library: readonly ChartLibraryRecord[];
|
||||
}): string {
|
||||
if (input.status !== "ready") {
|
||||
const snapshot = input.session.chartProfileName?.trim();
|
||||
if (snapshot) return snapshot;
|
||||
if (!input.session.chartProfileId || input.session.chartProfileId === "self" || input.session.chartProfileRole !== "other") {
|
||||
return "我";
|
||||
}
|
||||
return "未命名资料";
|
||||
}
|
||||
return sessionChartLabel(input.session, input.library);
|
||||
}
|
||||
|
||||
export function chatSubjectSendBlocked(input: {
|
||||
session: Pick<ChatSession, "sessionType" | "chartProfileId" | "chartProfileRole"> | undefined;
|
||||
status: ChatProfileCatalogStatus;
|
||||
library: readonly ChartLibraryRecord[];
|
||||
}): boolean {
|
||||
if (!input.session || input.session.sessionType !== "consultation") return false;
|
||||
if (input.status !== "ready") return false;
|
||||
const id = input.session.chartProfileId;
|
||||
if (!id || id === "self" || input.session.chartProfileRole === "self") return false;
|
||||
return !input.library.some((record) => record.id === id && record.role === "other");
|
||||
}
|
||||
|
||||
function rowName(record: ChartLibraryRecord): string {
|
||||
return record.profile.name.trim() || (record.role === "self" ? "我" : "未命名资料");
|
||||
}
|
||||
|
||||
export function chatProfilePickerRows(input: {
|
||||
status: ChatProfileCatalogStatus;
|
||||
library: readonly ChartLibraryRecord[];
|
||||
selectedId: string;
|
||||
}): ChatProfilePickerRow[] {
|
||||
if (input.status !== "ready") return [];
|
||||
const self = input.library.find((record) => record.role === "self" || record.id === "self");
|
||||
const others = input.library.filter((record) => record.role === "other" && record.id !== "self");
|
||||
const rows: ChatProfilePickerRow[] = [];
|
||||
if (self) rows.push(toRow(self, input.selectedId));
|
||||
else {
|
||||
rows.push({
|
||||
id: "self",
|
||||
role: "self",
|
||||
name: "我",
|
||||
detail: "这份资料还不完整",
|
||||
complete: false,
|
||||
selected: input.selectedId === "self",
|
||||
});
|
||||
}
|
||||
for (const record of others) rows.push(toRow(record, input.selectedId));
|
||||
return rows;
|
||||
}
|
||||
|
||||
function toRow(record: ChartLibraryRecord, selectedId: string): ChatProfilePickerRow {
|
||||
const complete = isConsultationSubjectProfileComplete(record.profile);
|
||||
const id = record.role === "self" ? "self" : record.id;
|
||||
return {
|
||||
id,
|
||||
role: record.role,
|
||||
name: rowName(record),
|
||||
detail: complete
|
||||
? (record.role === "self" ? "本人" : chartRelationshipLabel(record.relationship))
|
||||
: "这份资料还不完整",
|
||||
complete,
|
||||
selected: id === selectedId,
|
||||
};
|
||||
}
|
||||
|
||||
export function chatProfileChoice(input: {
|
||||
session: Pick<SubjectSession, "sessionType" | "messagesHydrated" | "messages" | "chartProfileId">;
|
||||
chartProfileId: string;
|
||||
requestInFlight: boolean;
|
||||
libraryReady: boolean;
|
||||
selectable: boolean;
|
||||
}): ChatProfileChoice {
|
||||
if (!input.libraryReady || !input.selectable || !input.chartProfileId) return { action: "ignore" };
|
||||
if (input.session.sessionType !== "consultation") return { action: "ignore" };
|
||||
if (chatSessionSubjectLocked(input.session)) {
|
||||
if (input.chartProfileId === (input.session.chartProfileId || "self")) return { action: "ignore" };
|
||||
if (input.requestInFlight) return { action: "ignore" };
|
||||
return { action: "open-new", chartProfileId: input.chartProfileId };
|
||||
}
|
||||
if (input.chartProfileId === input.session.chartProfileId) return { action: "ignore" };
|
||||
return { action: "bind", chartProfileId: input.chartProfileId };
|
||||
}
|
||||
|
||||
/** Empty-session PATCH body. The server fills the name; the client does not send one. */
|
||||
export function emptySessionBindingWrite(chartProfileId: string, role: "self" | "other"): {
|
||||
chart_profile_id: string;
|
||||
chart_profile_role: "self" | "other";
|
||||
} {
|
||||
return {
|
||||
chart_profile_id: role === "self" ? "self" : chartProfileId,
|
||||
chart_profile_role: role,
|
||||
};
|
||||
}
|
||||
@@ -149,6 +149,12 @@ function otherProfileComplete(row: Record<string, unknown>): boolean {
|
||||
return Boolean(name && birthDate && source && status && latitude && longitude && label && (timezoneOffset || timezoneId));
|
||||
}
|
||||
|
||||
/** Same completeness gate the resolver uses before it will bind a person. */
|
||||
export function isConsultationSubjectProfileComplete(profile: unknown): boolean {
|
||||
const row = chartProfileToConsultationRow(profile);
|
||||
return Boolean(row && otherProfileComplete(row));
|
||||
}
|
||||
|
||||
export function consultationSubjectBindingFromSession(row: {
|
||||
chart_profile_id?: unknown;
|
||||
chart_profile_name?: unknown;
|
||||
|
||||
@@ -97,11 +97,20 @@ export function chartSnapshotForSession(
|
||||
return { chartProfileId: chartId || null, chartProfileName: "未命名资料", chartProfileRole: chartId ? "other" : null };
|
||||
}
|
||||
|
||||
export function sessionChartLabel(session: ChatSession, library: readonly ChartLibraryRecord[]) {
|
||||
export function sessionChartLabel(
|
||||
session: Pick<ChatSession, "chartProfileId" | "chartProfileName" | "chartProfileRole">,
|
||||
library: readonly ChartLibraryRecord[],
|
||||
) {
|
||||
if (!session.chartProfileId) return "未关联资料";
|
||||
const current = session.chartProfileId === "self" || library.some((record) => record.id === session.chartProfileId);
|
||||
const name = session.chartProfileName?.trim() || (session.chartProfileRole === "self" ? "我" : "未命名资料");
|
||||
return current ? name : `资料已删除 · ${name}`;
|
||||
const record = session.chartProfileId === "self"
|
||||
? library.find((item) => item.id === "self" || item.role === "self")
|
||||
: library.find((item) => item.id === session.chartProfileId && item.role === "other");
|
||||
if (record) {
|
||||
return record.profile.name.trim() || (record.role === "self" ? "我" : "未命名资料");
|
||||
}
|
||||
if (session.chartProfileId === "self") return session.chartProfileName?.trim() || "我";
|
||||
const name = session.chartProfileName?.trim() || "未命名资料";
|
||||
return `资料已删除 · ${name}`;
|
||||
}
|
||||
|
||||
export function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) {
|
||||
|
||||
@@ -61,8 +61,10 @@ export function ordinaryComposerPlaceholder(input: {
|
||||
onboardingName: boolean;
|
||||
presetMessageFinished: boolean;
|
||||
creditsEmpty: boolean;
|
||||
subjectDeleted?: boolean;
|
||||
}): string {
|
||||
if (input.locked) return RECTIFICATION_OPENING_COMPOSER_PLACEHOLDER;
|
||||
if (input.subjectDeleted) return "这份资料已经删除,这段对话只能看";
|
||||
if (!input.hasAccount) return "正在读取账户…";
|
||||
if (!input.profileComplete) {
|
||||
if (!input.onboardingName) return "请先完成上方资料";
|
||||
|
||||
@@ -101,6 +101,15 @@ test("sidebar titles drop the chart-name prefix and only subtitle others", () =>
|
||||
profile: { ...emptyProfile, name: "对方" },
|
||||
}];
|
||||
assert.equal(sessionSidebarSubtitle(other, liveOtherLibrary), "9月14日 12:14 · 对方");
|
||||
const renamedLibrary: ChartLibraryRecord[] = [{
|
||||
...selfRecord,
|
||||
id: "other-1",
|
||||
role: "other",
|
||||
relationship: "partner",
|
||||
profile: { ...emptyProfile, name: "新名字" },
|
||||
}];
|
||||
assert.equal(sessionSidebarSubtitle(other, renamedLibrary), "9月14日 12:14 · 新名字");
|
||||
assert.equal(other.chartProfileName, "对方");
|
||||
assert.deepEqual(toSidebarSessionRow(other, liveOtherLibrary), {
|
||||
id: other.id,
|
||||
title: "半年内换工作时机",
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
NEW_CHAT_SUBJECT_ID,
|
||||
chatProfileChoice,
|
||||
chatProfileDisplayName,
|
||||
chatProfilePickerRows,
|
||||
chatProfileSelectedId,
|
||||
chatSubjectSendBlocked,
|
||||
emptySessionBindingWrite,
|
||||
} from "../src/lib/chat-profile-picker-model.ts";
|
||||
import { emptyProfile, type ChartLibraryRecord, type ChatSession, type Profile } from "../src/lib/home-types.ts";
|
||||
|
||||
const pickerSource = readFileSync(new URL("../src/components/chat-profile-picker.tsx", import.meta.url), "utf8");
|
||||
const hookSource = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8");
|
||||
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const starterSource = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8");
|
||||
|
||||
function sourceBetween(source: string, start: string, end: string): string {
|
||||
const from = source.indexOf(start);
|
||||
const to = source.indexOf(end, from + start.length);
|
||||
assert.notEqual(from, -1, start);
|
||||
assert.notEqual(to, -1, end);
|
||||
return source.slice(from, to);
|
||||
}
|
||||
|
||||
function person(name: string, complete = true): Profile {
|
||||
return {
|
||||
...emptyProfile,
|
||||
name,
|
||||
date: complete ? "1990-01-02" : "",
|
||||
birthTimeSource: complete ? "family_exact" : "",
|
||||
birthTimeStatus: complete ? "confirmed" : "",
|
||||
time: complete ? "08:30" : "",
|
||||
birthPlaceLabel: complete ? "示例城市" : "",
|
||||
latitude: complete ? 31.2 : null,
|
||||
longitude: complete ? 121.5 : null,
|
||||
timezoneId: complete ? "Asia/Shanghai" : "",
|
||||
timezoneOffset: complete ? 8 : null,
|
||||
};
|
||||
}
|
||||
|
||||
function record(id: string, role: "self" | "other", name: string, complete = true): ChartLibraryRecord {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
relationship: role === "self" ? "self" : "friend",
|
||||
updatedAt: 1,
|
||||
profile: person(name, complete),
|
||||
};
|
||||
}
|
||||
|
||||
function session(patch: Partial<ChatSession>): ChatSession {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
title: "新对话",
|
||||
theme: "general",
|
||||
modelId: "m",
|
||||
messages: [],
|
||||
updatedAt: 1,
|
||||
sessionType: "consultation",
|
||||
rectificationCaseId: null,
|
||||
chartProfileId: "self",
|
||||
chartProfileName: "本人",
|
||||
chartProfileRole: "self",
|
||||
pinned: false,
|
||||
archivedAt: null,
|
||||
messagesHydrated: true,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
const self = record("self", "self", "本人");
|
||||
const other = record("other-1", "other", "对方");
|
||||
const incomplete = record("other-2", "other", "未写完", false);
|
||||
|
||||
test("the picker lists self and owned others, with self selected by default", () => {
|
||||
assert.equal(NEW_CHAT_SUBJECT_ID, "self");
|
||||
assert.equal(chatProfileSelectedId(session({ chartProfileId: null, chartProfileRole: null, chartProfileName: null })), "self");
|
||||
const rows = chatProfilePickerRows({ status: "ready", library: [self, other, incomplete], selectedId: "self" });
|
||||
assert.deepEqual(rows.map((row) => row.id), ["self", "other-1", "other-2"]);
|
||||
assert.equal(rows[0]?.selected, true);
|
||||
assert.equal(rows[0]?.detail, "本人");
|
||||
assert.equal(rows[1]?.detail, "朋友");
|
||||
assert.equal(rows[2]?.complete, false);
|
||||
assert.equal(rows[2]?.detail, "这份资料还不完整");
|
||||
});
|
||||
|
||||
test("an empty consultation binds the chosen person and does not open another session", () => {
|
||||
const empty = session({ messages: [] });
|
||||
assert.deepEqual(chatProfileChoice({
|
||||
session: empty,
|
||||
chartProfileId: "other-1",
|
||||
requestInFlight: false,
|
||||
libraryReady: true,
|
||||
selectable: true,
|
||||
}), { action: "bind", chartProfileId: "other-1" });
|
||||
assert.deepEqual(chatProfileChoice({
|
||||
session: empty,
|
||||
chartProfileId: "self",
|
||||
requestInFlight: false,
|
||||
libraryReady: true,
|
||||
selectable: true,
|
||||
}), { action: "ignore" });
|
||||
});
|
||||
|
||||
test("a session with messages offers a new session and does not rebind", () => {
|
||||
const locked = session({
|
||||
chartProfileId: "self",
|
||||
messages: [{ role: "user", text: "想看事业" }],
|
||||
});
|
||||
assert.deepEqual(chatProfileChoice({
|
||||
session: locked,
|
||||
chartProfileId: "other-1",
|
||||
requestInFlight: false,
|
||||
libraryReady: true,
|
||||
selectable: true,
|
||||
}), { action: "open-new", chartProfileId: "other-1" });
|
||||
assert.deepEqual(chatProfileChoice({
|
||||
session: locked,
|
||||
chartProfileId: "self",
|
||||
requestInFlight: false,
|
||||
libraryReady: true,
|
||||
selectable: true,
|
||||
}), { action: "ignore" });
|
||||
assert.deepEqual(chatProfileChoice({
|
||||
session: locked,
|
||||
chartProfileId: "other-1",
|
||||
requestInFlight: true,
|
||||
libraryReady: true,
|
||||
selectable: true,
|
||||
}), { action: "ignore" });
|
||||
});
|
||||
|
||||
test("a library that is still loading or failed cannot create a binding", () => {
|
||||
assert.deepEqual(chatProfilePickerRows({ status: "failed", library: [self, other], selectedId: "self" }), []);
|
||||
assert.deepEqual(chatProfilePickerRows({ status: "loading", library: [self, other], selectedId: "other-1" }), []);
|
||||
assert.deepEqual(chatProfileChoice({
|
||||
session: session({}),
|
||||
chartProfileId: "other-1",
|
||||
requestInFlight: false,
|
||||
libraryReady: false,
|
||||
selectable: true,
|
||||
}), { action: "ignore" });
|
||||
assert.deepEqual(chatProfileChoice({
|
||||
session: session({}),
|
||||
chartProfileId: "other-2",
|
||||
requestInFlight: false,
|
||||
libraryReady: true,
|
||||
selectable: false,
|
||||
}), { action: "ignore" });
|
||||
});
|
||||
|
||||
test("a deleted person stays visible and blocks sending without falling back to self", () => {
|
||||
const deleted = session({
|
||||
chartProfileId: "other-1",
|
||||
chartProfileName: "对方",
|
||||
chartProfileRole: "other",
|
||||
messages: [{ role: "user", text: "想看事业" }],
|
||||
});
|
||||
assert.equal(chatProfileDisplayName({ session: deleted, status: "loading", library: [self] }), "对方");
|
||||
assert.equal(chatProfileDisplayName({ session: deleted, status: "failed", library: [self] }), "对方");
|
||||
assert.equal(chatSubjectSendBlocked({ session: deleted, status: "loading", library: [self] }), false);
|
||||
assert.equal(chatSubjectSendBlocked({ session: deleted, status: "failed", library: [self] }), false);
|
||||
assert.equal(chatProfileDisplayName({ session: deleted, status: "ready", library: [self] }), "资料已删除 · 对方");
|
||||
assert.equal(chatSubjectSendBlocked({ session: deleted, status: "ready", library: [self] }), true);
|
||||
assert.equal(chatProfileSelectedId(deleted), "other-1");
|
||||
assert.notEqual(chatProfileSelectedId(deleted), NEW_CHAT_SUBJECT_ID);
|
||||
});
|
||||
|
||||
test("a renamed living profile shows the current name and leaves the stored snapshot alone", () => {
|
||||
const stored = session({
|
||||
chartProfileId: "other-1",
|
||||
chartProfileName: "对方",
|
||||
chartProfileRole: "other",
|
||||
});
|
||||
const renamed = record("other-1", "other", "新名字");
|
||||
assert.equal(chatProfileDisplayName({ session: stored, status: "ready", library: [self, renamed] }), "新名字");
|
||||
assert.equal(stored.chartProfileName, "对方");
|
||||
});
|
||||
|
||||
test("opening a stored session keeps its subject instead of the active chart", () => {
|
||||
const opened = session({
|
||||
chartProfileId: "other-1",
|
||||
chartProfileName: "对方",
|
||||
chartProfileRole: "other",
|
||||
});
|
||||
const activeChartId = "self";
|
||||
assert.equal(chatProfileSelectedId(opened), "other-1");
|
||||
assert.notEqual(chatProfileSelectedId(opened), activeChartId);
|
||||
const selectSession = sourceBetween(hookSource, "function selectSession(", "function activateFallbackSession(");
|
||||
assert.doesNotMatch(selectSession, /setActiveChartId/);
|
||||
assert.doesNotMatch(selectSession, /chartProfileId\s*:/);
|
||||
assert.doesNotMatch(selectSession, /activeChartId/);
|
||||
});
|
||||
|
||||
test("the empty-session write sends an id and role, not a client-forged name", () => {
|
||||
const write = emptySessionBindingWrite("other-1", "other");
|
||||
assert.deepEqual(write, { chart_profile_id: "other-1", chart_profile_role: "other" });
|
||||
assert.equal("chart_profile_name" in write, false);
|
||||
assert.deepEqual(emptySessionBindingWrite("forged", "self"), {
|
||||
chart_profile_id: "self",
|
||||
chart_profile_role: "self",
|
||||
});
|
||||
const bind = sourceBetween(hookSource, "async function bindEmptyChatSubject(", "async function openChatBoundToProfile(");
|
||||
assert.match(bind, /chatSessionSubjectLocked/);
|
||||
assert.match(bind, /emptySessionBindingWrite/);
|
||||
assert.doesNotMatch(bind, /chart_profile_name/);
|
||||
assert.doesNotMatch(bind, /setActiveChartId/);
|
||||
});
|
||||
|
||||
test("a new session for another person does not patch the old one or send during a request", () => {
|
||||
const open = sourceBetween(hookSource, "async function openChatBoundToProfile(", "\n return {");
|
||||
assert.doesNotMatch(open, /writeChatSession\(/);
|
||||
assert.match(open, /pendingSessionId \|\| cancellationPending/);
|
||||
assert.match(open, /chartSnapshotForSession\(requestedId/);
|
||||
const startNewChat = sourceBetween(hookSource, "async function startNewChat(", "function selectSession(");
|
||||
assert.match(startNewChat, /NEW_CHAT_SUBJECT_ID/);
|
||||
assert.doesNotMatch(startNewChat, /activeChartId/);
|
||||
assert.match(startNewChat, /setDraft\(""\)/);
|
||||
assert.doesNotMatch(startNewChat, /persistSession\(/);
|
||||
assert.doesNotMatch(startNewChat, /writeSessionUrl\(nextSession\.id, "push"\)/);
|
||||
});
|
||||
|
||||
test("the header control is a button with popover keyboard, focus return, and a 44px target", () => {
|
||||
assert.match(pickerSource, /type="button"/);
|
||||
assert.match(pickerSource, /aria-expanded=\{open\}/);
|
||||
assert.match(pickerSource, /aria-controls=\{listId\}/);
|
||||
assert.match(pickerSource, /aria-label=\{`当前星盘:\$\{label\}`\}/);
|
||||
assert.match(pickerSource, /event\.key !== "Escape"/);
|
||||
assert.match(pickerSource, /triggerRef\.current\?\.focus\(\)/);
|
||||
assert.match(pickerSource, /Popover\.Root/);
|
||||
assert.match(pickerSource, /新建会话并使用此人物/);
|
||||
assert.match(pickerSource, /这段回答结束后再换/);
|
||||
assert.match(pickerSource, /人物列表还在读/);
|
||||
assert.match(pickerSource, /人物列表暂时读不出来/);
|
||||
assert.doesNotMatch(pickerSource, /正在加载/);
|
||||
assert.match(styles, /\.chat-profile-picker-trigger\s*\{[^}]*min-height:\s*44px/);
|
||||
assert.match(styles, /\.chat-profile-picker-option\s*\{[^}]*min-height:\s*44px/);
|
||||
assert.match(styles, /\.chat-profile-picker-popup\s*\{[^}]*transition:\s*opacity 120ms ease-out/);
|
||||
assert.match(styles, /\.chat-profile-picker-popup\[data-starting-style\][\s\S]*transform:\s*none/);
|
||||
assert.match(pageSource, /<ChatProfilePicker/);
|
||||
assert.match(pageSource, /activeSession\?\.sessionType === "consultation"/);
|
||||
assert.match(pageSource, /className="chat-header-chart"/);
|
||||
assert.match(pageSource, /sessionChartLabel\(activeSession, chartLibrary\)/);
|
||||
assert.doesNotMatch(starterSource, /ChatProfilePicker/);
|
||||
});
|
||||
@@ -133,6 +133,18 @@ test("pin and archive flags are session metadata, not localStorage", () => {
|
||||
assert.doesNotMatch(page, /writeSynastryHistory\(/);
|
||||
});
|
||||
|
||||
test("an in-progress chat is not rebound when another person is chosen", () => {
|
||||
const sessions = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8");
|
||||
const open = sessions.slice(
|
||||
sessions.indexOf("async function openChatBoundToProfile("),
|
||||
sessions.indexOf("\n return {", sessions.indexOf("async function openChatBoundToProfile(")),
|
||||
);
|
||||
assert.doesNotMatch(open, /writeChatSession\(/);
|
||||
assert.match(open, /pendingSessionId \|\| cancellationPending/);
|
||||
assert.match(itemRoute, /authoritativeSessionBinding/);
|
||||
assert.match(listRoute, /authoritativeSessionBinding/);
|
||||
});
|
||||
|
||||
test("session list GET pages by cursor and returns pinned on the first page", () => {
|
||||
assert.match(listRoute, /clampSessionLimit/);
|
||||
assert.match(listRoute, /parseSessionCursor/);
|
||||
|
||||
@@ -295,6 +295,9 @@ test("session API owns create and update while answer UI keeps sync failures out
|
||||
assert.match(contract, /\): z\.ZodType<Output> \{/);
|
||||
assert.match(page, /chartSnapshotForSession/);
|
||||
assert.match(page, /chart_profile_name: session\.chartProfileName/);
|
||||
assert.match(page, /emptySessionBindingWrite/);
|
||||
const bind = page.slice(page.indexOf("async function bindEmptyChatSubject("), page.indexOf("async function openChatBoundToProfile("));
|
||||
assert.doesNotMatch(bind, /chart_profile_name/);
|
||||
// 原值 `分析对象:{...}` 副标题 / 新值 `.chat-header-chart` 静默 chip
|
||||
// / 原因:顶栏从 68px 收到 46px 单行;「分析对象」是内部术语,标签去掉只留盘名。
|
||||
// 会话与星盘资料的绑定关系本身没变,下面两条仍然锁住它。
|
||||
|
||||
@@ -90,6 +90,17 @@ test("openRectificationCase inherits identity from existing before minting", ()
|
||||
assert.match(open, /void persistSession\(merged\)/);
|
||||
});
|
||||
|
||||
test("opening a stored chat does not retarget its person or the account chart", () => {
|
||||
const sessions = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8");
|
||||
const selectSession = sessions.slice(
|
||||
sessions.indexOf("function selectSession("),
|
||||
sessions.indexOf("function activateFallbackSession("),
|
||||
);
|
||||
assert.doesNotMatch(selectSession, /setActiveChartId/);
|
||||
assert.doesNotMatch(selectSession, /chartProfileId\s*:/);
|
||||
assert.doesNotMatch(selectSession, /localStorage\.setItem\(activeChartStorageKey/);
|
||||
});
|
||||
|
||||
test("ChatSession literals for an existing id cannot stamp updatedAt with the wall clock", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const directory of ["hooks", "lib"]) {
|
||||
|
||||
Reference in New Issue
Block a user