fix(chat): choose the chart person before the first message

This commit is contained in:
jesse-ux
2026-09-22 12:57:04 +08:00
parent 6d06fca0d8
commit 2a3013f224
22 changed files with 973 additions and 27 deletions
+4 -1
View File
@@ -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.
+20
View File
@@ -106,6 +106,26 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 2019 入职 | 2019 年前后,你有没有换过工作?A. 明确发生且时间吻合 | 2019 年前后,你有没有换过工作? | 不得把选项字面写进题干。 |
| 2023 感情区分 | 你有没有一段认真开始或结束的关系? | 工作这块记下了。2023 年前后,有没有一段认真开始或结束的关系? | 题干必须写出服务端给的年份;漏写年份用户看不见期间。 |
## 聊天顶栏的当前星盘
顶栏人名是入口,不是资料编辑。看见的字是当前这个人的名字。读屏名称是「当前星盘:」加上这个名字。不要写「我的星盘」或「分析对象」。
| 位置 | 文案 |
|---|---|
| 弹出层标题 | 当前星盘 |
| 本人 | 本人 |
| 空会话里点另一个人 | 直接换成这个人,不另写一句确认 |
| 已经说过话,点另一个人 | 新建会话并使用此人物 |
| 回答还没结束 | 这段回答结束后再换 |
| 列表还在读 | 人物列表还在读 |
| 列表没读出来 | 人物列表暂时读不出来 |
| 重试 | 重试 |
| 资料不完整 | 这份资料还不完整 |
| 资料已删除 | 资料已删除 · {原来的名字} |
| 删除后不能再发 | 这份资料已经删除,这段对话只能看。 |
不写「正在加载」。不因为列表失败就改绑到本人。
## 侧栏与会话标题
历史分组标签只用这五个词,空组不出现:今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早。不要写成「七日内」「更早之前」。
+63 -5
View File
@@ -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 ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
+29 -5
View File
@@ -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;
+98 -12
View File
@@ -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;
+13 -4
View File
@@ -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: "半年内换工作时机",
+250
View File
@@ -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"]) {