对话页换成组件库,桌面旁边可以打开本命盘(#106)。
Independent Staging Quality Gate / validate (push) Failing after 9m13s
Independent Staging Quality Gate / publish (push) Skipped

输入框里左边选模型、右边发送。回答下的赞、踩、复制、重新生成和追问改用组件库按钮。不满意原因和兑换窗改用对话框,句子不变。桌面顶栏可以打开当前人物的本命盘,能拖宽,刷新后还在;手机没有这个入口。当前会话和流式状态从 Home() 移进已有会话 store,useState 从 33 减到 25。流式、停止、排队、撤回、退点和寒暄免费没有改。

自测:tsc 退出码 0;lint 0 error、122 条警告;定向测试 275 通过、失败 0、取消 0;npm run build 退出码 0,/ 仍是 Static。首屏 gzip-9 639127,相对基线 634528 多 0.72%。隐私标记测试退出码 0。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-25 03:15:13 +08:00
co-authored by Grok (agent loop)
parent e4e240bf86
commit d25dfdbe1a
31 changed files with 921 additions and 249 deletions
+8
View File
@@ -1,5 +1,13 @@
# 印度占星 Skill 更新日志
## 2026-10-25 — 输入框里选模型,桌面旁边可以打开本命盘
- 输入框里左边选模型,右边是发送。各模型的价格只写在菜单里。
- 回答下面的赞、踩、复制、重新生成仍是原来的四个动作。「不满意原因」和兑换窗仍是原来的文案。
- 桌面对话顶栏可以打开当前人物的本命盘,能拖宽,也能收起。刷新后还在。手机上看不到这个入口。
- 发消息、停止、排队、撤回、退点和寒暄免费没有改。追问仍是原来的依次浮出。
- Skill 版本不变(BUG-1434)。
## 2026-10-25 — 建档、清单和天空第一次出现时会动一下
- 建档的句子和卡片、起步问题、追问、档案行,第一次出现时轻轻上浮。按下按钮、问题卡和关系标签时会轻微缩小。
+16
View File
@@ -19430,3 +19430,19 @@
- 复发自:无
- 修复版本:分支 `codex/ui-kit-1-shell-20261023`。
## BUG-1434 | 对话页换成组件库,旁边可以打开本命盘
- 状态:enhancement
- 首次发现:2026-10-25
- 最近更新:2026-10-25
- 影响面:首页对话输入框、模型选择、回答下的操作、追问、不满意原因、兑换窗、桌面对话旁边的本命盘。星盘页本身不在这次里。
- 用户现象:输入框里左边选模型,右边发送。价格只在模型菜单里。回答下面仍是赞、踩、复制、重新生成。桌面上可以在对话旁边打开当前人物的本命盘,能拖宽,刷新后还在。手机上看不到这个入口。
- 触发条件:打开首页对话。本命盘按钮在视口已判定为桌面(不小于 768)之后出现。打开后面板才去取本命盘。
- 根因:不是缺陷。组件库迁移的对话页这一张(任务书 K2,决策 D4、D5、D7、D8、D9)。
- 修复:输入框、模型菜单、回答操作、追问、不满意原因、兑换窗和跳到最新改用已有的组件库零件。当前会话、流式和反馈状态从 `Home()` 移进已有的会话 store,`Home()` 的 useState 从 33 减到 25,useRef 仍是 37。本命盘放在可拖分栏里,宽 280–560,默认 360,开着和宽度记在本机;绘制复用星盘页的图,第一次打开才载入。手机不显示入口,也不把「开着」写成关掉。流式逐字、停止、排队、撤回、退点和寒暄免费没有改。
- 验证:见 `docs/tasks/PROGRESS-ui-kit-2-chat-20261023.md`。没有浏览器登录态,不把发过一条对话或拖过本命盘写成已经在浏览器里做过。
- 防复发:`frontend/tests/chat-chart-panel-state.test.ts` 锁住本机记忆、宽度夹取、手机无入口,以及页面不把可拖分栏和星盘绘制打进同步包。对话回归锁住流式、停止、排队、撤回、退点和寒暄免费。增长合同锁住 `Home()` 的 hook 数不升。白名单测试锁住名单只减不增。
- 相关记录:issue #106。任务书 `docs/tasks/TASK-ui-kit-migration-20261023.md` 的 K2。前置 #105 的外壳、#99 的启程动效。
- 复发自:无
- 修复版本:分支 `codex/ui-kit-2-chat-20261023`。
@@ -0,0 +1,57 @@
# PROGRESS · 组件库 K2 对话页(2026-10-25)
工单 #106。分支 `codex/ui-kit-2-chat-20261023`。基线 `origin/staging` `e4e240bf863b49e75bf9c7e1ba54b40cb1265f64`(#105 新侧栏和 #99 启程动效已在这个提交里)。只做任务书 **K2 对话页**。K0、K1 已完成,K3 及之后没做。
## 结论
输入框里左边选模型,右边发送。各模型的价格只写在菜单里。回答下面仍是赞、踩、复制、重新生成。桌面上可以在对话旁边打开当前人物的本命盘,能拖宽,也能收起,刷新后还在。手机上看不到这个入口。发消息、停止、排队、撤回、退点和寒暄免费没有改。
## 做了什么
- 输入框改用组件库的组合输入。底边左边是模型菜单,只显示名字;「N 点」只在菜单项里。右边是圆形发送,和框的底边对齐。停止、排队召回和「内容由 AI 生成」仍在。模型菜单走外壳已经在用的延后载入:库还没到时,按钮上仍是当前模型名,点开还没有菜单。
- 赞、踩、复制、重新生成是图标按钮,各有提示。不满意原因和兑换窗改用对话框,句子不改。追问是描边小按钮,仍用启程进场依次浮出(260ms,间隔 64ms),没有另写时长。跳到最新和今日星语入口改用组件库按钮,原来的尺寸类还在。
- 当前会话、咨询阶段、停止中的标记、排队中的会话和请求、流式回答、赞踩和「已复制」从 `Home()` 移进已有的会话 store。消息列表本来就在会话列表里,跟着同一份 store。`Home()` 的 useState 从 33 减到 25。useRef 开工时是 37,做完仍是 37,没有新加。`page.tsx` 从 1419 行到 1450 行,粗护栏是 1567。`useSessionManagement` 仍在 `useRectificationSurface` 前面。
- 桌面顶栏、点数右边,是打开或收起本命盘的图标按钮,提示「打开本命盘」或「收起本命盘」。面板在可拖分栏里,宽 280–560,默认 360。开着和宽度记在本机 `chat_chart_panel_open`、`chat_chart_panel_width`。收起不把宽度写成 0。手机视口还没定、或小于 768 时不显示按钮,也不因为藏起来就把「开着」写成关掉。
- 面板里是当前人物的本命盘,复用星盘页的绘制。只取本命分盘这一层。缓存里的盘必须是同一个人,否则重新取。没有骨架,也没有「正在加载」。不标星盘页那一次「画出来」的标记。可拖分栏和星盘绘制都在第一次打开后面才载入。外层对话网格没有改,里面用一列重复原来的行高,次级页面不包这一列。
- 流式逐字仍在 `chat-message-row.tsx`,这个文件没有改。消息进场仍是已有的 Motion。扣点和落库没有改。
- 白名单去掉 9 个已经不再违规的文件,没有加长,也没有改测试里写死的基线名单。
- 没有改 `.gitea/**`,没有改生产部署,没有动数据库,没有给 `scripts/jyotish_api_server.py` 加方法。没有重做 #99 的动效时长。
## 断言改动(原值 / 新值 / 原因)
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| 输入框 | `<Textarea` 直接放在表单里,并带 `ref={inputRef}` | `<InputGroupTextarea`,仍绑定同一份草稿和同一个 ref | 输入框改用组合输入。聚焦和草稿订阅不变 |
| 跳到最新 | `<button` | `<Button`,仍是 `type="button"` | 改用组件库按钮。类名、焦点环和 44px 目标不变 |
| 今日星语 | `<button className="starter-entry"` | `<Button className="starter-entry"` | 改用组件库按钮。36px 胶囊和 44px 点按区仍由原来的类决定 |
| 兑换窗 | 自绘遮罩 `account-modal-overlay`,并用 `createPortal` | `Dialog`,内容类名仍是 `account-modal paywall-modal` | 改用组件库对话框。文案和这些类名留下 |
| 不满意原因 | 文件顶部静态引入,只在挂载容器里找文案 | 先建好测试文档再动态引入,容器和 `document.body` 都找 | 对话框在模块加载时才决定把内容挂到 body。测试文档必须先就位。提交、原因顺序和补充说明仍在 |
| 白名单 | 含首页和 8 个对话零件 | 这 9 个移出 | 它们已经改用组件库。名单只许变短 |
移出的 9 个文件:`page.tsx`、`chat-composer.tsx`、`chat-message-actions.tsx`、`conversation-follow-ups.tsx`、`jump-to-latest-button.tsx`、`model-selector.tsx`、`onboarding-redeem-paywall.tsx`、`reply-down-reason.tsx`、`starter-home.tsx`。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | 仓库根 `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
| 检查 | 在 `frontend` 下 `npm run lint` | 退出码 0。122 个警告,0 error |
| 定向测试 | 在 `frontend` 下 `node --import tsx --test` 下面列出的文件 | 275 条,通过 275,失败 0,取消 0 |
| 构建 | 在 `frontend` 下 `npm run build` | 退出码 0。编译 8.2s,TypeScript 17.3s。`/` 仍是 ○ Static。5 条既有 skill-package-registry 警告,没有改 |
| 首屏体积 | 首页 HTML 引用的 `/_next/static/**.js`,gzip-9 相加,不含 CSS | 基线 `e4e240bf`:36 个文件,缺失 0,634528 字节。本单:36 个文件,缺失 0,639127 字节,多 4599(+0.72%)。±2% 上限 647219 |
定向测试文件:`account-dialog-inert-20260918.test.ts`、`ai-generated-notice-20261014.test.ts`、`character-remaining-contract.test.ts`、`chat-chart-panel-state.test.ts`、`chat-composer-queue.test.ts`、`chat-message-actions.test.ts`、`chat-navigation-a11y-contract.test.ts`、`chat-stream-layout.test.ts`、`composer-isolation-contract.test.ts`、`consult-first-frame-and-pacing-20260928.test.tsx`、`consultation-entrypoint.test.ts`、`consultation-recovery.test.ts`、`consultation-smalltalk-ui.test.tsx`、`consultation-smalltalk.test.ts`、`home-shell-growth-contract.test.ts`、`home-streaming-render-split.test.ts`、`home-warm-return-lifecycle.test.tsx`、`membership-page.test.ts`、`onboarding-checklist-20261022.test.ts`、`rectification-agentic-entry.test.ts`、`rectification-grounding-regenerate-20260927.test.ts`、`reply-quality-20260930.test.tsx`、`session-conversation-layout.test.ts`、`session-list-lifecycle.test.tsx`、`starter-entry-soften.test.ts`、`stream-text-response.test.ts`、`ui-kit-whitelist.test.ts`。
`Home()` hook:开工 useState 33、useRef 37、`page.tsx` 1419 行。做完 useState 25、useRef 37、1450 行。上限是 useState 36、useRef 37、1567 行。
没有跑前端全量 `npm test`,没有跑 Python 快速门,没有跑 `test:db`。Python 入口和 `scripts/jyotish_api_server.py` 没有改。
## 偏离
- 测试用的假文档补了 `documentElement`、`dataset`、`style.setProperty`、`requestAnimationFrame` 和 `children`。组件库对话框要这些才能把内容挂出来。产品页面没有为测试改行为。
- 不满意原因的测试改成先建好文档再载入组件。同一份原因、补充说明和提交仍会发出去。
- 第一次构建把模型菜单静态打进首页脚本,gzip-9 是 655070(38 个文件,+20542,+3.24%,超出 ±2%)。模型菜单改走外壳已有的延后载入后再构建。延后载入后的首屏里,`dropdown-menu`、`chat-chart-split`、`chat-natal-chart`、`react-resizable-panels` 都是 0。`ResizablePanelGroup` 与 `VedicChartSvg` 的首屏命中数和基线一样(1 和 2);多出来的绘制和分栏只在未写进首页 HTML 的脚本里。库还没到时,模型按钮上仍是当前模型名。
## 环境缺口
- 没有登录态。没有在浏览器里发过一条对话,没有点过停止,没有拖过本命盘。本机记忆由测试读写 `chat_chart_panel_open` 和 `chat_chart_panel_width` 证明。可照做的步骤在 `docs/testing/ui-kit-migration-20261023.md` 的「对话」一节。
@@ -21,3 +21,11 @@
2. 顶栏点数是硬币图标加数字。读屏能听到「点」,眼睛看不到这个字。
3. 设置里仍是个人资料、账户与点数、通用设置。退出登录要再确认一次。反馈与投诉仍能打开。
4. 建完档的账户,右上角启程清单还在,点数按钮的左边。
## 对话
1. 输入框里,左边是模型名字,右边是圆形发送。点开模型,每一项旁边才有「N 点」。顶栏不再放模型选择。
2. 发出一条消息,可以停止。停止后该退的点仍退。寒暄不扣点。回答被撤下时仍不扣点。
3. 回答下面可以赞、踩、复制。点踩之后可以选原因,也可以什么都不选就提交。兑换窗的句子和以前一样。
4. 桌面上,点数右边有一个打开本命盘的按钮。打开后是当前人物的本命盘。拖右缘,宽度停在大约 280 到 560 像素。收起后再打开,宽度还在。刷新页面,开着和宽度都还在。
5. 把窗口改成大约 375 像素宽。顶栏没有打开本命盘的按钮。
+6
View File
@@ -4,6 +4,12 @@
桌面侧栏在可拖分栏里,宽 200–360px,默认 288。拖到最左收成 64px 窄轨;收起不把 64 写成宽度。宽度记在本机 `sidebar_width`。手机用左侧抽屉,宽 `min(86vw, 320px)`。当前页高亮用已有的 `layoutId` 滑动。翻页用已有的页面进入 280ms、离开 150ms,方向跟着侧栏顺序。顶栏点数是描边按钮:硬币图标加数字,44px;「点」只在可读名称里。数字变少时往下滚,变多时往上滚,360ms;系统要求减少动态时立刻换数字。启程清单胶囊仍是 32px,仍在点数左边。账户按钮只显示头像和名字。设置仍是三个分区。会话只在收藏组内、或最近一组内拖动排序;收藏整组仍在最前。翻页、高亮滑动、拖宽和拖动排序用的库在页面出来后再载入,不进首屏。
## 对话页(2026-10-25)
输入框仍是一个框。框底左边是模型菜单,只显示模型名;价格「N 点」只在菜单里。菜单用外壳已有的延后载入,不进首屏;库还没到时按钮上仍是模型名。右边是圆形发送,和框的底边对齐。回答下面赞、踩、复制、重新生成是 26px 的图标按钮,各有一句提示。不满意原因和兑换窗用组件库对话框,句子不改。追问是描边小按钮,仍按启程进场依次浮出(260ms,间隔 64ms),不另写一套时长。
桌面顶栏、点数右边,是打开或收起本命盘的图标按钮,提示「打开本命盘」或「收起本命盘」。面板在可拖分栏里,宽 280–560px,默认 360。拖到最窄可以收起。开着和宽度记在本机 `chat_chart_panel_open`、`chat_chart_panel_width`。手机(视口还没定,或小于 768)不显示这个按钮,也不因为藏起来就把「开着」写成关掉。面板里是当前人物的本命盘,复用星盘页的绘制,不写骨架、不写「正在加载」。可拖分栏和星盘绘制都在第一次打开后面才载入,不进首屏。
## 新用户启程(2026-10-25)
首页顶栏右侧、点数按钮左边,是一枚胶囊「启程 n/N」(`.onboarding-checklist-trigger`)。它沿用顶栏按钮的 32px 高度,圆角 999px,字和图标间距 6px。左边是 18×18 的进度环,描边按已完成的项画满。视口还没分出手机或桌面时,这枚胶囊不可打开。手机(侧栏视口小于 768)从底部抽屉拉出,带现有的下拉把手。平板和桌面用现有弹出层,靠右对齐,宽 `min(22rem, calc(100vw - 2rem))`,行是 `1.25rem minmax(0, 1fr) auto`,句子 `overflow-wrap: anywhere`。已完成的行用对勾,没有「去做」。底部是「先不看了」。
@@ -1,5 +1,4 @@
[
"src/app/(app)/page.tsx",
"src/app/error.tsx",
"src/app/global-error.tsx",
"src/components/ayanamsa-preference-field.tsx",
@@ -19,20 +18,14 @@
"src/components/chart-page/chart-type-picker.tsx",
"src/components/chart-page/chart-vedic-tab.tsx",
"src/components/chart-profile-form.tsx",
"src/components/chat-composer.tsx",
"src/components/chat-message-actions.tsx",
"src/components/consultation-run-timeline.tsx",
"src/components/conversation-follow-ups.tsx",
"src/components/email-otp-login.tsx",
"src/components/ephemeris/ephemeris-page.tsx",
"src/components/home-bootstrap-error.tsx",
"src/components/home-onboarding-shell.tsx",
"src/components/jump-to-latest-button.tsx",
"src/components/legal/legal-consent-gate.tsx",
"src/components/legal/login-consent.tsx",
"src/components/markdown-code-block.tsx",
"src/components/model-selector.tsx",
"src/components/onboarding-redeem-paywall.tsx",
"src/components/people/people-page.tsx",
"src/components/person-relation-field.tsx",
"src/components/personal-report/personal-report-center.tsx",
@@ -48,8 +41,6 @@
"src/components/rectification-composer-reopen.tsx",
"src/components/rectification-range-delivery.tsx",
"src/components/rectification-segment-delivery.tsx",
"src/components/reply-down-reason.tsx",
"src/components/starter-home.tsx",
"src/components/synastry/synastry-add-dialog.tsx",
"src/components/synastry/synastry-picker.tsx",
"src/components/synastry/synastry-report-view.tsx"
+47 -16
View File
@@ -29,12 +29,10 @@ import {
} from "@/lib/rectification-session-composer-guard";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
import { declaredBirthTime } from "@/lib/rectification-surface-state";
import {
toggleChatMessageFeedback,
type ChatMessageFeedback,
} from "@/components/chat-message-actions";
import { toggleChatMessageFeedback } from "@/components/chat-message-actions";
import { ChatTranscript, type ChatTranscriptActions } from "@/components/chat-transcript";
import { ChatProfilePicker } from "@/components/chat-profile-picker";
import { ChatChartFrame, ChatChartToggle } from "@/components/chat-chart-frame";
import { ModelSelector } from "@/components/model-selector";
import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall";
import { ChatComposer } from "@/components/chat-composer";
@@ -47,6 +45,8 @@ import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import { bindCurrentSubjectAccount, peekSubjectCatalog, readCurrentSubjectId, subscribeSubjectCatalog, useCurrentSubject, useCurrentSubjectProfile } from "@/lib/current-subject";
import { setOnboardingFinishedListener } from "@/lib/onboarding-intake";
import { useSessionStore } from "@/stores/session-store";
import { useShallow } from "zustand/react/shallow";
import { notifyOnboardingDailyOpened } from "@/lib/onboarding-signals";
import { personRelationLabel } from "@/lib/person-relation";
import { readStarterSignMap, readStarterSigns, subscribeStarterSigns } from "@/lib/starter-questions";
@@ -146,7 +146,6 @@ import {
type Profile,
type ReplyOutcome,
type RequestError,
type StreamingReply,
type Theme,
} from "@/lib/home-types";
import { chatSubjectSendBlocked } from "@/lib/chat-profile-picker-model";
@@ -225,17 +224,48 @@ export default function Home() {
const [dailyStarlanguage, setDailyStarlanguage] = useState<DailyStarlanguageState>(() => warmStart()?.daily ?? { kind: "pending" });
const [accountError, setAccountError] = useState("");
const [signingOut, setSigningOut] = useState(false);
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(() => warmStart()?.modelCatalog ?? null);
const [activeSessionId, setActiveSessionId] = useState(() => warmStart()?.landing.activeSessionId ?? "");
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(() => {
const warm = warmStart();
useSessionStore.getState().mountHomeConversation(warm?.landing.activeSessionId ?? "");
return warm?.modelCatalog ?? null;
});
const {
activeSessionId,
setActiveSessionId,
consultationPhase,
setConsultationPhase,
cancellationPending,
setCancellationPending,
pendingSessionId,
setPendingSessionId,
pendingRequestId,
setPendingRequestId,
streamingReply,
setStreamingReply,
messageFeedback,
setMessageFeedback,
copiedMessageKey,
setCopiedMessageKey,
} = useSessionStore(useShallow((state) => ({
activeSessionId: state.activeSessionId,
setActiveSessionId: state.setActiveSessionId,
consultationPhase: state.consultationPhase,
setConsultationPhase: state.setConsultationPhase,
cancellationPending: state.cancellationPending,
setCancellationPending: state.setCancellationPending,
pendingSessionId: state.pendingSessionId,
setPendingSessionId: state.setPendingSessionId,
pendingRequestId: state.pendingRequestId,
setPendingRequestId: state.setPendingRequestId,
streamingReply: state.streamingReply,
setStreamingReply: state.setStreamingReply,
messageFeedback: state.messageFeedback,
setMessageFeedback: state.setMessageFeedback,
copiedMessageKey: state.copiedMessageKey,
setCopiedMessageKey: state.setCopiedMessageKey,
})));
const draftTheme = useRef<Theme | null>(null);
const draftEntrypoint = useRef<ConsultationEntrypoint | null>(null);
const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null);
const [cancellationPending, setCancellationPending] = useState(false);
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
const [streamingReply, setStreamingReply] = useState<StreamingReply | null>(null);
const [messageFeedback, setMessageFeedback] = useState<Record<string, ChatMessageFeedback>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [replyOutcome, setReplyOutcome] = useState<ReplyOutcome | null>(null);
const [requestError, setRequestError] = useState<RequestError | null>(null);
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState<BirthTimeConsultationConsentState>(
@@ -1190,7 +1220,7 @@ export default function Home() {
};
return (
<>
<ChatChartFrame>
<DailyStarlanguageBinder
accountId={accountId}
bootstrapPhase={bootstrapPhase}
@@ -1256,6 +1286,7 @@ export default function Home() {
label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}
onClick={(event) => openAccountDialog("billing", { returnTarget: event.currentTarget, source: "credits" })}
/>
<ChatChartToggle />
</div>
</header>
@@ -1414,6 +1445,6 @@ export default function Home() {
}}
/>
)}
</>
</ChatChartFrame>
);
}
+12
View File
@@ -1022,6 +1022,15 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
it back. `clip` creates no scroll container, so it cannot happen again. */
.chat-panel { height: 100%; min-width: 0; min-height: 0; overflow: clip; display: grid; background: var(--color-canvas); grid-template-rows: 46px minmax(0, 1fr) auto; }
.chat-panel.is-rectification { grid-template-rows: 46px minmax(0, 1fr); }
/* Home spans the outer rows with one frame, then repeats the same templates
inside `.chat-column`. Secondary pages stay direct children of `.chat-panel`. */
.chat-chart-frame { grid-row: 1 / -1; grid-column: 1; min-width: 0; min-height: 0; height: 100%; }
.chat-chart-split { height: 100%; min-width: 0; min-height: 0; }
.chat-column { height: 100%; min-width: 0; min-height: 0; display: grid; grid-template-rows: 46px minmax(0, 1fr) auto; }
.chat-panel.is-rectification .chat-column { grid-template-rows: 46px minmax(0, 1fr); }
.chat-panel:has(.starter-balance) .chat-column { grid-template-rows: 46px minmax(0, 1fr) auto minmax(0, 1fr); }
.chat-natal-chart { height: 100%; min-width: 0; overflow: auto; padding: var(--space-3); }
.chat-header-actions .chat-chart-toggle { width: 32px; height: 32px; }
.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 chart chip stays beside the title and ellipsizes
@@ -2343,6 +2352,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.chart-birth-line { width: 100%; min-height: 44px; justify-content: space-between; }
.chat-panel.is-rectification { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr); }
.chat-panel:has(.starter-balance) { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto minmax(0, 1fr); }
.chat-column { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto; }
.chat-panel.is-rectification .chat-column { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr); }
.chat-panel:has(.starter-balance) .chat-column { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto minmax(0, 1fr); }
.chat-header { gap: var(--space-3); padding: max(0px, env(safe-area-inset-top)) var(--space-3) 0; }
.conversation.is-empty {
display: block;
@@ -0,0 +1,91 @@
"use client";
import { PanelRightClose, PanelRightOpen } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { useSidebar } from "@/components/ui/sidebar";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useCurrentSubject } from "@/lib/current-subject";
import { useUiStore } from "@/stores/ui-store";
type SplitComponent = (props: {
open: boolean;
width: number;
subjectId: string;
onOpenChange: (open: boolean) => void;
onWidth: (width: number) => void;
children: ReactNode;
}) => ReactNode;
export function ChatChartToggle() {
const { isMobile, ready } = useSidebar();
const open = useUiStore((state) => state.chatChartOpen);
const setOpen = useUiStore((state) => state.setChatChartOpen);
if (!ready || isMobile) return null;
const label = open ? "收起本命盘" : "打开本命盘";
const Icon = open ? PanelRightClose : PanelRightOpen;
return (
<Tooltip>
<TooltipTrigger
render={(
<Button
type="button"
variant="ghost"
size="icon-sm"
className="chat-chart-toggle"
aria-label={label}
aria-pressed={open}
onClick={() => setOpen(!open)}
>
<Icon aria-hidden="true" />
</Button>
)}
/>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
/**
* One cell spanning every row of `.chat-panel`. The inner `.chat-column`
* repeats today's row template so secondary pages can keep the outer grid.
* The split (and the chart drawing) load the first time the panel opens.
*/
export function ChatChartFrame({ children }: { children: ReactNode }) {
const { isMobile, ready } = useSidebar();
const subjectId = useCurrentSubject();
const open = useUiStore((state) => state.chatChartOpen);
const width = useUiStore((state) => state.chatChartWidth);
const hydrated = useUiStore((state) => state.chatChartHydrated);
const hydrate = useUiStore((state) => state.hydrateChatChart);
const setOpen = useUiStore((state) => state.setChatChartOpen);
const setWidth = useUiStore((state) => state.setChatChartWidth);
const [Split, setSplit] = useState<SplitComponent | null>(null);
const desktop = ready && !isMobile;
useEffect(() => {
hydrate();
}, [hydrate]);
useEffect(() => {
if (!desktop || !hydrated || !open || Split) return;
let cancelled = false;
void import("@/components/chat-chart-split").then((mod) => {
if (!cancelled) setSplit(() => mod.ChatChartSplit);
});
return () => { cancelled = true; };
}, [Split, desktop, hydrated, open]);
const column = <div className="chat-column">{children}</div>;
if (!Split || !desktop) {
return <div className="chat-chart-frame">{column}</div>;
}
return (
<div className="chat-chart-frame">
<Split open={open} width={width} subjectId={subjectId} onOpenChange={setOpen} onWidth={setWidth}>
{column}
</Split>
</div>
);
}
@@ -0,0 +1,67 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import type { PanelImperativeHandle } from "react-resizable-panels";
import { ChatNatalChart } from "@/components/chat-natal-chart";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { CHAT_CHART_WIDTH_MAX, CHAT_CHART_WIDTH_MIN } from "@/lib/chat-chart-panel-state";
export function ChatChartSplit({
open,
width,
subjectId,
onOpenChange,
onWidth,
children,
}: {
open: boolean;
width: number;
subjectId: string;
onOpenChange: (open: boolean) => void;
onWidth: (width: number) => void;
children: ReactNode;
}) {
const panelRef = useRef<PanelImperativeHandle | null>(null);
useEffect(() => {
const panel = panelRef.current;
if (!panel) return;
if (open && panel.isCollapsed()) panel.expand();
if (!open && !panel.isCollapsed()) panel.collapse();
}, [open]);
function onLayoutChanged(_layout: Record<string, number>, meta: { isUserInteraction: boolean }) {
if (!meta.isUserInteraction) return;
const panel = panelRef.current;
if (!panel) return;
if (panel.isCollapsed()) {
onOpenChange(false);
return;
}
onOpenChange(true);
const pixels = panel.getSize().inPixels;
if (pixels >= CHAT_CHART_WIDTH_MIN) onWidth(Math.round(pixels));
}
return (
<ResizablePanelGroup orientation="horizontal" className="chat-chart-split" onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="chat-column" defaultSize="100%">
{children}
</ResizablePanel>
<ResizableHandle withHandle className="chat-chart-resize-handle" />
<ResizablePanel
id="chat-natal"
panelRef={panelRef}
collapsible
collapsedSize={0}
minSize={CHAT_CHART_WIDTH_MIN}
maxSize={CHAT_CHART_WIDTH_MAX}
defaultSize={open ? width : 0}
groupResizeBehavior="preserve-pixel-size"
>
<ChatNatalChart subjectId={subjectId} />
</ResizablePanel>
</ResizablePanelGroup>
);
}
+46 -41
View File
@@ -4,7 +4,11 @@ import { useSyncExternalStore } from "react";
import type { ChangeEvent, FormEvent, KeyboardEvent, ReactNode, RefObject } from "react";
import { ArrowUp, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
InputGroup,
InputGroupAddon,
InputGroupTextarea,
} from "@/components/ui/input-group";
import { CharacterRemaining } from "@/components/character-remaining";
import { characterRemainingVisible } from "@/lib/character-remaining";
import {
@@ -47,8 +51,7 @@ type ChatComposerProps = {
readonly queued?: { text: string; onRecall: () => void };
/**
* Controls that belong to the composer itself (model picker, chart subject).
* They render on the composer's own bottom row; there is no longer a strip
* below the composer holding a permanent 44px of chrome.
* They sit on the left of the field's bottom row. The send button is on the right.
*/
readonly toolbar?: ReactNode;
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
@@ -91,48 +94,50 @@ export function ChatComposer({
<p className="composer-queue__label">{COMPOSER_QUEUE_LABEL}</p>
<p className="composer-queue__text">{queued.text}</p>
</div>
<button className="composer-queue__recall" type="button" onClick={queued.onRecall}>
<Button className="composer-queue__recall" type="button" variant="ghost" onClick={queued.onRecall}>
{COMPOSER_QUEUE_RECALL_LABEL}
</button>
</Button>
</div>
) : null}
<form className="composer" onSubmit={onSubmit}>
<Textarea
ref={inputRef}
aria-label={inputLabel}
aria-describedby={describedByIds}
placeholder={placeholder}
rows={1}
maxLength={maxLength}
disabled={inputDisabled}
value={draft}
onChange={onChange}
onKeyDown={onKeyDown}
/>
<div className="composer-row">
{toolbar ? <div className="composer-tools">{toolbar}</div> : null}
<div className="composer-row-end">
{showRemaining ? (
<CharacterRemaining id={remainingId} length={draft.length} maxLength={maxLength} />
) : null}
{stopVisible ? (
<Button
className="composer-stop"
aria-label={stopLabel}
title={stopTitle}
size="icon"
type="button"
onClick={onStop}
>
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label={submitLabel} disabled={!draft.trim() || submitBlocked} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
</div>
</div>
<InputGroup className="border-0 bg-transparent shadow-none ring-0">
<InputGroupTextarea
ref={inputRef}
aria-label={inputLabel}
aria-describedby={describedByIds}
placeholder={placeholder}
rows={1}
maxLength={maxLength}
disabled={inputDisabled}
value={draft}
onChange={onChange}
onKeyDown={onKeyDown}
/>
<InputGroupAddon align="block-end" className="composer-row">
{toolbar ? <div className="composer-tools">{toolbar}</div> : null}
<div className="composer-row-end">
{showRemaining ? (
<CharacterRemaining id={remainingId} length={draft.length} maxLength={maxLength} />
) : null}
{stopVisible ? (
<Button
className="composer-stop"
aria-label={stopLabel}
title={stopTitle}
size="icon"
type="button"
onClick={onStop}
>
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label={submitLabel} disabled={!draft.trim() || submitBlocked} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
</div>
</InputGroupAddon>
</InputGroup>
</form>
<p className="composer-ai-notice">{CHAT_AI_NOTICE_ZH}</p>
</>
@@ -1,7 +1,11 @@
"use client";
import type { ReactElement } from "react";
import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export type ChatMessageFeedback = "up" | "down";
export function toggleChatMessageFeedback(
@@ -11,6 +15,15 @@ export function toggleChatMessageFeedback(
return current === requested ? undefined : requested;
}
function ActionTip({ label, button }: { label: string; button: ReactElement }) {
return (
<Tooltip>
<TooltipTrigger render={button} />
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
export function ChatMessageActions({
feedback,
copied = false,
@@ -29,39 +42,62 @@ export function ChatMessageActions({
}>) {
return (
<div className="message-actions" aria-label="Agent 回答操作">
<button
aria-label="赞"
aria-pressed={feedback === "up"}
className={feedback === "up" ? "is-active" : ""}
title="赞"
type="button"
onClick={() => onFeedback("up")}
>
<ThumbsUp aria-hidden="true" />
</button>
<button
aria-label="踩"
aria-pressed={feedback === "down"}
className={feedback === "down" ? "is-active" : ""}
title="踩"
type="button"
onClick={() => onFeedback("down")}
>
<ThumbsDown aria-hidden="true" />
</button>
<button aria-label="复制回答" title="复制" type="button" onClick={onCopy}>
{copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
</button>
<ActionTip
label="赞"
button={(
<Button
aria-label="赞"
aria-pressed={feedback === "up"}
className={feedback === "up" ? "is-active" : ""}
size="icon-sm"
type="button"
variant="ghost"
onClick={() => onFeedback("up")}
>
<ThumbsUp aria-hidden="true" />
</Button>
)}
/>
<ActionTip
label="踩"
button={(
<Button
aria-label="踩"
aria-pressed={feedback === "down"}
className={feedback === "down" ? "is-active" : ""}
size="icon-sm"
type="button"
variant="ghost"
onClick={() => onFeedback("down")}
>
<ThumbsDown aria-hidden="true" />
</Button>
)}
/>
<ActionTip
label={copied ? "已复制" : "复制"}
button={(
<Button aria-label="复制回答" size="icon-sm" type="button" variant="ghost" onClick={onCopy}>
{copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
</Button>
)}
/>
{onRegenerate && (
<button
aria-label="重新生成回答"
disabled={!canRegenerate}
title={canRegenerate ? "重新生成" : "只能重新生成最近一条回答"}
type="button"
onClick={onRegenerate}
>
<RotateCcw aria-hidden="true" />
</button>
<ActionTip
label={canRegenerate ? "重新生成" : "只能重新生成最近一条回答"}
button={(
<Button
aria-label="重新生成回答"
disabled={!canRegenerate}
size="icon-sm"
type="button"
variant="ghost"
onClick={onRegenerate}
>
<RotateCcw aria-hidden="true" />
</Button>
)}
/>
)}
</div>
);
@@ -0,0 +1,59 @@
"use client";
import { useEffect, useState } from "react";
import { fetchChartView } from "@/lib/chart-view-client";
import type { ChartViewResponse, NorthIndianChart } from "@/lib/chart-view-contract";
import { peekChartPage } from "@/lib/secondary-page-data";
type ChartDrawing = typeof import("@/components/personal-report/vedic-chart-svg").VedicChartSvg;
function natalChart(view: ChartViewResponse | null, subjectId: string): NorthIndianChart | null {
if (!view || view.status !== "ok" || view.subjectId !== subjectId) return null;
const varga = view.vedic.vargas.find((item) => item.id === "D1") ?? view.vedic.vargas[0];
return varga?.chart ?? null;
}
/**
* Current person's D1, drawn with the chart page's SVG. Loaded only after the
* panel opens. No skeleton and no "正在加载": the region stays quiet until the
* drawing is ready. Do not mark `data-onboarding-draw` here; that key belongs
* to the chart page's first draw.
*/
export function ChatNatalChart({ subjectId }: { subjectId: string }) {
const peeked = peekChartPage();
const cached = peeked?.kind === "view" ? natalChart(peeked.view, subjectId) : null;
const [remote, setRemote] = useState<{ subjectId: string; chart: NorthIndianChart | null } | null>(null);
const [Drawing, setDrawing] = useState<ChartDrawing | null>(null);
const chart = remote?.subjectId === subjectId ? remote.chart ?? cached : cached;
useEffect(() => {
let cancelled = false;
void import("@/components/personal-report/vedic-chart-svg").then((mod) => {
if (!cancelled) setDrawing(() => mod.VedicChartSvg);
});
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (cached) return;
const controller = new AbortController();
void fetchChartView({ layers: ["varga"], signal: controller.signal, subjectId })
.then((result) => {
if (controller.signal.aborted) return;
setRemote({ subjectId, chart: natalChart(result.body, subjectId) });
})
.catch(() => undefined);
return () => controller.abort();
}, [cached, subjectId]);
return (
<div className="chat-natal-chart">
{chart && Drawing ? (
<Drawing chart={chart} ariaLabel="本命盘" />
) : (
<div role="img" aria-label="本命盘" />
)}
</div>
);
}
@@ -1,5 +1,7 @@
"use client";
import { Button } from "@/components/ui/button";
export function ConversationFollowUps({
questions,
disabled = false,
@@ -13,9 +15,11 @@ export function ConversationFollowUps({
return (
<div className="conversation-follow-ups" aria-label="可以继续问" data-onboarding-anchor="follow-up">
{questions.map((question, index) => (
<button
<Button
key={question}
className="conversation-follow-up"
variant="outline"
size="sm"
type="button"
disabled={disabled}
data-onboarding-press=""
@@ -24,7 +28,7 @@ export function ConversationFollowUps({
onClick={() => onSelect(question)}
>
{question}
</button>
</Button>
))}
</div>
);
@@ -2,6 +2,8 @@
import { ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* The "back to the newest turn" control both chat surfaces show once the
* reader has scrolled away from the bottom. It hangs off the composer's top
@@ -11,15 +13,16 @@ import { ArrowDown } from "lucide-react";
export function JumpToLatestButton({ onClick }: Readonly<{ onClick: () => void }>) {
return (
<div className="jump-to-latest">
<button
<Button
type="button"
variant="outline"
className="jump-to-latest__button"
aria-label="跳到最新"
onClick={onClick}
>
<ArrowDown aria-hidden="true" />
跳到最新
</button>
</Button>
</div>
);
}
+29 -58
View File
@@ -1,10 +1,15 @@
"use client";
import { Popover } from "@base-ui/react/popover";
import { Check, ChevronDown } from "lucide-react";
import { useId, useState } from "react";
import type { KeyboardEvent } from "react";
import { ChevronDown } from "lucide-react";
import type { PublicLanguageModel } from "@/lib/public-models";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu-lazy";
type ModelSelectorProps = {
readonly models: readonly PublicLanguageModel[];
@@ -19,69 +24,35 @@ export function ModelSelector({
disabled = false,
onSelect,
}: ModelSelectorProps) {
const [open, setOpen] = useState(false);
const groupName = useId();
const selectedModel = models.find((model) => model.id === selectedModelId);
const unavailable = models.length === 0;
function moveRadioSelection(event: KeyboardEvent<HTMLInputElement>, index: number) {
const step = event.key === "ArrowDown" || event.key === "ArrowRight"
? 1
: event.key === "ArrowUp" || event.key === "ArrowLeft"
? -1
: 0;
if (step === 0 || models.length < 2) return;
event.preventDefault();
const nextIndex = (index + step + models.length) % models.length;
const nextModel = models[nextIndex];
if (!nextModel) return;
onSelect(nextModel.id);
setOpen(false);
}
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger
<DropdownMenu>
<DropdownMenuTrigger
className="model-selector-trigger"
type="button"
disabled={disabled || unavailable}
aria-label={selectedModel ? `当前模型:${selectedModel.label},点击切换` : "模型暂不可用"}
>
<b>{selectedModel?.label ?? "暂不可用"}</b>
<ChevronDown aria-hidden="true" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Positioner className="model-selector-positioner" side="top" align="start" sideOffset={8} collisionPadding={12}>
<Popover.Popup className="model-selector-popup">
<Popover.Title className="model-selector-title">选择模型</Popover.Title>
<fieldset className="model-selector-options">
<legend className="sr-only">选择当前对话使用的模型</legend>
{models.map((model, index) => (
<label className="model-selector-option" data-selected={model.id === selectedModelId ? "" : undefined} key={model.id}>
<input
className="sr-only"
type="radio"
name={groupName}
value={model.id}
checked={model.id === selectedModelId}
onChange={() => onSelect(model.id)}
onClick={() => {
if (model.id === selectedModelId) onSelect(model.id);
setOpen(false);
}}
onKeyDown={(event) => moveRadioSelection(event, index)}
/>
<span className="model-selector-copy">
<b>{model.label}</b>
</span>
<Check className="model-selector-check" aria-hidden="true" />
</label>
))}
</fieldset>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
</DropdownMenuTrigger>
<DropdownMenuContent className="model-selector-popup" side="top" align="start" sideOffset={8}>
<DropdownMenuLabel className="model-selector-title">选择模型</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={selectedModelId}
onValueChange={(modelId) => onSelect(modelId)}
>
{models.map((model) => (
<DropdownMenuRadioItem className="model-selector-option" key={model.id} value={model.id}>
<span className="model-selector-copy">
<b>{model.label}</b>
<small>{model.creditCost} 点</small>
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -3,8 +3,13 @@
import { Gift, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { createPortal } from "react-dom";
import { keepFocusWithin } from "@/lib/focus-trap";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { notifyBalanceChanged, redeemErrorMessage } from "@/lib/membership";
import { notifyError, notifySuccess } from "@/lib/notify";
@@ -70,24 +75,18 @@ export function OnboardingRedeemPaywall({
}
}
const overlay = (
<div className="account-modal-overlay" onMouseDown={close}>
<section
return (
<Dialog open onOpenChange={(next) => { if (!next) close(); }}>
<DialogContent
className="account-modal paywall-modal"
role="dialog"
aria-modal="true"
showCloseButton={false}
aria-labelledby="onboarding-paywall-title"
onMouseDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Escape") close();
else keepFocusWithin(event.nativeEvent, event.currentTarget);
}}
>
<header className="account-modal-header">
<h2 id="onboarding-paywall-title">解锁完整咨询</h2>
<button className="dialog-close" aria-label="关闭" type="button" onClick={close} disabled={pending}>
<DialogTitle id="onboarding-paywall-title">解锁完整咨询</DialogTitle>
<Button className="dialog-close" aria-label="关闭" type="button" variant="ghost" size="icon-sm" onClick={close} disabled={pending}>
<X aria-hidden="true" />
</button>
</Button>
</header>
<div className="paywall-intro">
<span className="paywall-mark" aria-hidden="true"><Gift /></span>
@@ -100,7 +99,7 @@ export function OnboardingRedeemPaywall({
<form className="redeem-form account-redeem-form" onSubmit={redeem}>
<label htmlFor="onboarding-redeem-code">兑换码</label>
<div>
<input
<Input
id="onboarding-redeem-code"
ref={inputRef}
autoComplete="off"
@@ -111,22 +110,20 @@ export function OnboardingRedeemPaywall({
}}
placeholder="输入完整兑换码"
/>
<button className="button-primary" type="submit" disabled={!code.trim() || pending || unlocked}>
<Button className="button-primary" type="submit" disabled={!code.trim() || pending || unlocked}>
{pending ? "兑换中" : unlocked ? "已解锁" : "兑换并继续"}
</button>
</Button>
</div>
</form>
<div className="paywall-footer">
<span>{unlocked ? "点数已到账,回到输入框点发送就行。" : "还没有兑换码?"}</span>
{unlocked ? (
<button className="button-primary" type="button" onClick={close}>开始咨询</button>
<Button className="button-primary" type="button" onClick={close}>开始咨询</Button>
) : (
<button className="button-secondary" type="button" onClick={onOpenBilling}>查看套餐与会员</button>
<Button className="button-secondary" type="button" variant="outline" onClick={onOpenBilling}>查看套餐与会员</Button>
)}
</div>
</section>
</div>
</DialogContent>
</Dialog>
);
if (typeof document === "undefined") return overlay;
return createPortal(overlay, document.body);
}
+47 -38
View File
@@ -1,8 +1,15 @@
"use client";
import { useState } from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { Toggle } from "@/components/ui/toggle";
import { REPLY_QUALITY_NOTE_MAX, REPLY_QUALITY_REASON_LABELS, REPLY_QUALITY_REASONS } from "@/lib/reply-quality-labels";
import { notifyError, notifySuccess } from "@/lib/notify";
import { saveReplyQualityReason } from "@/lib/reply-ratings";
@@ -34,42 +41,44 @@ export function ReplyDownReason({ feedbackKey, onClose }: Readonly<{ feedbackKey
}
return (
<div className="reply-down-reason" role="group" aria-label="不满意原因">
<div className="reply-down-reason-head">
<span>哪里不满意?可以不选</span>
<button type="button" className="reply-down-reason-close" aria-label="关闭" onClick={onClose}><X aria-hidden="true" /></button>
</div>
<div className="reply-down-reason-chips">
{REASONS.map(([key, label]) => (
<button
key={key}
type="button"
aria-pressed={picked.has(key)}
onClick={() => setPicked((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
})}
>
{label}
</button>
))}
</div>
<input
className="reply-down-reason-note"
type="text"
value={note}
maxLength={REPLY_QUALITY_NOTE_MAX}
placeholder="补充一句(可不填)"
aria-label="补充说明"
onChange={(event) => setNote(event.target.value)}
/>
<div className="reply-down-reason-foot">
<span>会把这一轮对话发给我们排查</span>
<button type="button" className="reply-down-reason-send" disabled={state === "sending"} onClick={() => void submit()}>
{state === "failed" ? "再试一次" : "提交"}
</button>
</div>
</div>
<Dialog open onOpenChange={(next) => { if (!next) onClose(); }}>
<DialogContent className="reply-down-reason" showCloseButton={false} aria-label="不满意原因">
<DialogTitle>哪里不满意?可以不选</DialogTitle>
<div className="reply-down-reason-chips">
{REASONS.map(([key, label]) => (
<Toggle
key={key}
type="button"
variant="outline"
size="sm"
pressed={picked.has(key)}
aria-pressed={picked.has(key)}
onPressedChange={() => setPicked((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
})}
>
{label}
</Toggle>
))}
</div>
<input
className="reply-down-reason-note"
type="text"
value={note}
maxLength={REPLY_QUALITY_NOTE_MAX}
placeholder="补充一句(可不填)"
aria-label="补充说明"
onChange={(event) => setNote(event.target.value)}
/>
<div className="reply-down-reason-foot">
<DialogDescription>会把这一轮对话发给我们排查</DialogDescription>
<Button type="button" className="reply-down-reason-send" disabled={state === "sending"} onClick={() => void submit()}>
{state === "failed" ? "再试一次" : "提交"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+5 -2
View File
@@ -2,6 +2,8 @@
import { MoonStar } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* The empty state is split across two slots on purpose.
*
@@ -65,8 +67,9 @@ export function StarterEntries({
return (
<div className="starter-entries">
<div className="starter-entry-row">
<button
<Button
className="starter-entry"
variant="outline"
type="button"
aria-label={natalMinuteAvailable ? dailyStarlanguageQuestion : "查看今日运势"}
disabled={productEntrypointsDisabled}
@@ -74,7 +77,7 @@ export function StarterEntries({
>
<MoonStar aria-hidden="true" />
<span>{dailyLabel}</span>
</button>
</Button>
</div>
{!natalMinuteAvailable && (
<p className="starter-entry-boundary">出生时间不足以支持的判断我会说明,不会补造具体分钟。</p>
@@ -0,0 +1,75 @@
/**
* Desktop chat natal panel. localStorage, same reason as the sidebar width:
* `/` is static, so a cookie read on the server would drop that.
* The phone has no panel, and hiding it must not write `open` false.
*/
export const CHAT_CHART_OPEN_STORAGE_KEY = "chat_chart_panel_open";
export const CHAT_CHART_WIDTH_STORAGE_KEY = "chat_chart_panel_width";
export const CHAT_CHART_WIDTH_MIN = 280;
export const CHAT_CHART_WIDTH_MAX = 560;
export const CHAT_CHART_WIDTH_DEFAULT = 360;
export type ChatChartStorage = Pick<Storage, "getItem" | "setItem">;
export function chatChartStorage(): ChatChartStorage | null {
try {
if (typeof localStorage === "undefined") return null;
return localStorage;
} catch {
return null;
}
}
export function clampChatChartWidth(width: number): number {
if (!Number.isFinite(width)) return CHAT_CHART_WIDTH_DEFAULT;
return Math.min(CHAT_CHART_WIDTH_MAX, Math.max(CHAT_CHART_WIDTH_MIN, Math.round(width)));
}
export function readStoredChatChartOpen(
storage: ChatChartStorage | null = chatChartStorage(),
): boolean {
if (storage === null) return false;
try {
return storage.getItem(CHAT_CHART_OPEN_STORAGE_KEY) === "1";
} catch {
return false;
}
}
export function readStoredChatChartWidth(
storage: ChatChartStorage | null = chatChartStorage(),
): number {
if (storage === null) return CHAT_CHART_WIDTH_DEFAULT;
try {
const raw = storage.getItem(CHAT_CHART_WIDTH_STORAGE_KEY);
if (raw === null || raw === "") return CHAT_CHART_WIDTH_DEFAULT;
return clampChatChartWidth(Number(raw));
} catch {
return CHAT_CHART_WIDTH_DEFAULT;
}
}
export function writeStoredChatChartOpen(
open: boolean,
storage: ChatChartStorage | null = chatChartStorage(),
): void {
if (storage === null) return;
try {
storage.setItem(CHAT_CHART_OPEN_STORAGE_KEY, open ? "1" : "0");
} catch {
/* private mode */
}
}
export function writeStoredChatChartWidth(
width: number,
storage: ChatChartStorage | null = chatChartStorage(),
): void {
if (storage === null) return;
try {
storage.setItem(CHAT_CHART_WIDTH_STORAGE_KEY, String(clampChatChartWidth(width)));
} catch {
/* private mode */
}
}
+5 -1
View File
@@ -27,6 +27,7 @@ import { BOOT_READ_INIT, bootSessionsPath, takeBootSessionsRead, withBootFallbac
import { isListedSidebarSession } from "@/lib/session-list-filter";
import { toSidebarSessionRow } from "@/lib/session-sidebar-row";
import { mergeSubjectSessionRows } from "@/lib/subject-session-merge";
import { useSessionStore } from "@/stores/session-store";
export type SessionListBoot = {
readonly sessions: ChatSession[];
@@ -170,7 +171,10 @@ export async function loadSessionList(signal: AbortSignal, forceCatalog = false)
}
export function SessionListProvider({ children }: { children: ReactNode }) {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [listOwner] = useState(() => Symbol("session-list"));
useSessionStore.getState().claimSessionList(listOwner);
const sessions = useSessionStore((state) => state.sessions);
const setSessions = useSessionStore((state) => state.setSessions);
const [sessionsCursor, setSessionsCursor] = useState<string | null>(null);
const [account, setAccount] = useState<Account | null>(null);
const [signedOut, setSignedOut] = useState(false);
+74 -2
View File
@@ -1,12 +1,84 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import { create } from "zustand";
/** Skeleton. Conversation state moves here in a later issue, not in K0. */
import type { ChatMessageFeedback } from "@/components/chat-message-actions";
import type { ChatSession, StreamingReply } from "@/lib/home-types";
export type ConsultationPhase = "undo" | "streaming" | "recovering" | null;
function apply<T>(current: T, update: SetStateAction<T>): T {
return typeof update === "function" ? (update as (value: T) => T)(current) : update;
}
type SessionState = {
placeholder: true;
sessions: ChatSession[];
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
activeSessionId: string;
setActiveSessionId: Dispatch<SetStateAction<string>>;
consultationPhase: ConsultationPhase;
setConsultationPhase: Dispatch<SetStateAction<ConsultationPhase>>;
cancellationPending: boolean;
setCancellationPending: Dispatch<SetStateAction<boolean>>;
pendingSessionId: string | null;
setPendingSessionId: Dispatch<SetStateAction<string | null>>;
pendingRequestId: string | null;
setPendingRequestId: Dispatch<SetStateAction<string | null>>;
streamingReply: StreamingReply | null;
setStreamingReply: Dispatch<SetStateAction<StreamingReply | null>>;
messageFeedback: Record<string, ChatMessageFeedback>;
setMessageFeedback: Dispatch<SetStateAction<Record<string, ChatMessageFeedback>>>;
copiedMessageKey: string | null;
setCopiedMessageKey: Dispatch<SetStateAction<string | null>>;
warmSeeded: boolean;
/** Reset the fields Home() used to own, once per Home mount. Sessions stay. */
mountHomeConversation: (activeSessionId: string) => void;
/** Reset sessions and the conversation fields when a new list provider mounts. */
claimSessionList: (owner: symbol) => void;
};
export const useSessionStore = create<SessionState>(() => ({
const emptyConversation = {
activeSessionId: "",
consultationPhase: null as ConsultationPhase,
cancellationPending: false,
pendingSessionId: null as string | null,
pendingRequestId: null as string | null,
streamingReply: null as StreamingReply | null,
copiedMessageKey: null as string | null,
warmSeeded: false,
};
let claimedOwner: symbol | null = null;
export const useSessionStore = create<SessionState>((set) => ({
placeholder: true,
sessions: [],
...emptyConversation,
messageFeedback: {},
setSessions: (update) => set((state) => ({ sessions: apply(state.sessions, update) })),
setActiveSessionId: (update) => set((state) => ({ activeSessionId: apply(state.activeSessionId, update) })),
setConsultationPhase: (update) => set((state) => ({ consultationPhase: apply(state.consultationPhase, update) })),
setCancellationPending: (update) => set((state) => ({ cancellationPending: apply(state.cancellationPending, update) })),
setPendingSessionId: (update) => set((state) => ({ pendingSessionId: apply(state.pendingSessionId, update) })),
setPendingRequestId: (update) => set((state) => ({ pendingRequestId: apply(state.pendingRequestId, update) })),
setStreamingReply: (update) => set((state) => ({ streamingReply: apply(state.streamingReply, update) })),
setMessageFeedback: (update) => set((state) => ({ messageFeedback: apply(state.messageFeedback, update) })),
setCopiedMessageKey: (update) => set((state) => ({ copiedMessageKey: apply(state.copiedMessageKey, update) })),
mountHomeConversation: (activeSessionId) => set({
...emptyConversation,
activeSessionId,
messageFeedback: {},
warmSeeded: true,
}),
claimSessionList: (owner) => {
if (claimedOwner === owner) return;
claimedOwner = owner;
set({
sessions: [],
...emptyConversation,
messageFeedback: {},
});
},
}));
+34 -1
View File
@@ -2,19 +2,33 @@
import { create } from "zustand";
import {
CHAT_CHART_WIDTH_DEFAULT,
clampChatChartWidth,
readStoredChatChartOpen,
readStoredChatChartWidth,
writeStoredChatChartOpen,
writeStoredChatChartWidth,
} from "@/lib/chat-chart-panel-state";
import {
readStoredSidebarWidth,
SIDEBAR_WIDTH_DEFAULT,
writeStoredSidebarWidth,
} from "@/lib/sidebar-state";
/** Skeleton plus the desktop sidebar width. Home() does not read this store. */
/** Skeleton plus desktop sidebar width and the chat natal panel. */
type UiState = {
placeholder: true;
sidebarWidth: number;
sidebarWidthHydrated: boolean;
setSidebarWidth: (width: number) => void;
hydrateSidebarWidth: () => void;
chatChartOpen: boolean;
chatChartWidth: number;
chatChartHydrated: boolean;
setChatChartOpen: (open: boolean) => void;
setChatChartWidth: (width: number) => void;
hydrateChatChart: () => void;
};
export const useUiStore = create<UiState>((set) => ({
@@ -32,4 +46,23 @@ export const useUiStore = create<UiState>((set) => ({
sidebarWidthHydrated: true,
});
},
chatChartOpen: false,
chatChartWidth: CHAT_CHART_WIDTH_DEFAULT,
chatChartHydrated: false,
setChatChartOpen: (open) => {
writeStoredChatChartOpen(open);
set({ chatChartOpen: open, chatChartHydrated: true });
},
setChatChartWidth: (width) => {
const next = clampChatChartWidth(width);
writeStoredChatChartWidth(next);
set({ chatChartWidth: next, chatChartHydrated: true });
},
hydrateChatChart: () => {
set({
chatChartOpen: readStoredChatChartOpen(),
chatChartWidth: readStoredChatChartWidth(),
chatChartHydrated: true,
});
},
}));
@@ -66,7 +66,11 @@ test("the account dialog and onboarding paywall portal to document.body, outside
assert.match(overlay, /<AlertDialog open/);
assert.match(read("../src/components/ui/alert-dialog.tsx"), /AlertDialogPrimitive\.Portal/);
assert.match(read("../src/components/ui/dialog.tsx"), /DialogPrimitive\.Portal/);
assert.match(paywall, /createPortal\(overlay, document\.body\)/);
// 原值:兑换窗 `createPortal(overlay, document.body)`
// 新值:兑换窗用 Dialog,传送由 DialogPrimitive.Portal 完成
// 原因:K2 兑换窗改用组件库 Dialog,仍挂到 body,不留在会被 inert 的页面里
assert.match(paywall, /<Dialog open/);
assert.doesNotMatch(paywall, /createPortal\(/);
// 原值:renderToString 得到 role="dialog",且没有 inert
// 新值:注销走 AlertDialog,组件库根把角色写成 alert-dialog;页面源码不写 inert
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
CHAT_CHART_OPEN_STORAGE_KEY,
CHAT_CHART_WIDTH_DEFAULT,
CHAT_CHART_WIDTH_MAX,
CHAT_CHART_WIDTH_MIN,
CHAT_CHART_WIDTH_STORAGE_KEY,
clampChatChartWidth,
readStoredChatChartOpen,
readStoredChatChartWidth,
writeStoredChatChartOpen,
writeStoredChatChartWidth,
type ChatChartStorage,
} from "../src/lib/chat-chart-panel-state.ts";
function memoryStorage(seed: Record<string, string> = {}): ChatChartStorage & { values: Map<string, string> } {
const values = new Map(Object.entries(seed));
return {
values,
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => { values.set(key, String(value)); },
};
}
test("chat natal panel remembers open and width on this machine", () => {
const storage = memoryStorage();
assert.equal(readStoredChatChartOpen(storage), false);
assert.equal(readStoredChatChartWidth(storage), CHAT_CHART_WIDTH_DEFAULT);
writeStoredChatChartOpen(true, storage);
writeStoredChatChartWidth(480, storage);
assert.equal(storage.values.get(CHAT_CHART_OPEN_STORAGE_KEY), "1");
assert.equal(readStoredChatChartOpen(storage), true);
assert.equal(readStoredChatChartWidth(storage), 480);
writeStoredChatChartOpen(false, storage);
assert.equal(readStoredChatChartOpen(storage), false);
assert.equal(readStoredChatChartWidth(storage), 480);
});
test("chat natal panel width stays between 280 and 560", () => {
assert.equal(clampChatChartWidth(10), CHAT_CHART_WIDTH_MIN);
assert.equal(clampChatChartWidth(900), CHAT_CHART_WIDTH_MAX);
assert.equal(clampChatChartWidth(Number.NaN), CHAT_CHART_WIDTH_DEFAULT);
const storage = memoryStorage();
writeStoredChatChartWidth(12, storage);
assert.equal(storage.values.get(CHAT_CHART_WIDTH_STORAGE_KEY), String(CHAT_CHART_WIDTH_MIN));
assert.equal(readStoredChatChartWidth(memoryStorage({ [CHAT_CHART_WIDTH_STORAGE_KEY]: "nope" })), CHAT_CHART_WIDTH_DEFAULT);
});
test("the chat page loads the natal split only when the panel opens, and phones have no entry", () => {
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const frame = readFileSync(new URL("../src/components/chat-chart-frame.tsx", import.meta.url), "utf8");
const split = readFileSync(new URL("../src/components/chat-chart-split.tsx", import.meta.url), "utf8");
assert.match(page, /<ChatChartToggle \/>/);
assert.match(page, /<ChatChartFrame>/);
assert.doesNotMatch(page, /react-resizable-panels|vedic-chart-svg|chat-chart-split/);
assert.match(frame, /打开本命盘/);
assert.match(frame, /收起本命盘/);
assert.match(frame, /if \(!ready \|\| isMobile\) return null/);
assert.match(frame, /import\("@\/components\/chat-chart-split"\)/);
assert.match(split, /ResizablePanelGroup/);
assert.match(split, /collapsible/);
assert.doesNotMatch(split, /data-onboarding-draw/);
});
@@ -285,13 +285,16 @@ test("the jump-to-latest control stays keyboard reachable and labelled", () => {
assert.match(jumpControl, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
const jumpSource = readFileSync(new URL("../src/components/jump-to-latest-button.tsx", import.meta.url), "utf8");
const buttonTag = sourceBetween(jumpSource, "<button", ">");
// 原值:`<button`
// 新值:`<Button`,仍是 type="button"
// 原因:K2「跳到最新」改用组件库 Button,类名、焦点环和 44px 目标不变
const buttonTag = sourceBetween(jumpSource, "<Button", ">");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const buttonRule = styles.match(/\.jump-to-latest__button \{[^}]*\}/)?.[0] ?? "";
const wrapRule = styles.match(/\.jump-to-latest \{[^}]*\}/)?.[0] ?? "";
// Given: a real button in document order, never removed from the tab sequence.
assert.match(jumpSource, /<button\b/);
assert.match(jumpSource, /<Button\b/);
assert.match(buttonTag, /type="button"/);
assert.doesNotMatch(buttonTag, /tabIndex/);
assert.doesNotMatch(buttonTag, /aria-hidden/);
@@ -64,7 +64,10 @@ test("the textarea lives in an isolated composer that owns the draft subscriptio
assert.doesNotMatch(rectificationSource, /composer-draft|setComposerDraft|useComposerDraft/);
// Then: only the composer subtree reads the value that changes on every keystroke.
assert.match(composerSource, /<Textarea[\s\S]*?value=\{draft\}/);
// 原值:`<Textarea` 直接放在表单里
// 新值:`<InputGroupTextarea`,仍绑定同一份 draft
// 原因:K2 输入框改用 InputGroup,草稿订阅不变
assert.match(composerSource, /<InputGroupTextarea[\s\S]*?value=\{draft\}/);
assert.match(composerSource, /disabled=\{!draft\.trim\(\) \|\| submitBlocked\}/);
});
@@ -78,7 +81,10 @@ test("the composer keeps its Chinese input, focus and accessibility contract", (
// When: the page still focuses the same textarea element through its own ref.
assert.match(pageSource, /const composerInput = useRef<HTMLTextAreaElement>\(null\)/);
assert.match(pageSource, /inputRef=\{composerInput\}/);
assert.match(composerSource, /<Textarea\n\s*ref=\{inputRef\}/);
// 原值:`<Textarea ref={inputRef}`
// 新值:`<InputGroupTextarea ref={inputRef}`
// 原因:K2 输入框改用 InputGroup,页面仍用同一个 textarea ref 聚焦
assert.match(composerSource, /<InputGroupTextarea\n\s*ref=\{inputRef\}/);
// 原值 `composerInput.current?.focus()` / 新值 `focus({ preventScroll: true })`
// / 原因:裸 focus 会让浏览器滚动所有可滚动祖先,包括 overflow 的 `.chat-panel`——
// 线上实测把 46px 顶栏顶到了 y=-88 且无法滚回。焦点行为本身没变,只是不再连带滚动。
+4 -1
View File
@@ -64,10 +64,13 @@ test("the redeem paywall opens on the first send refused for credits, not when t
});
test("the onboarding paywall reuses the shared account dialog header", () => {
// 原值:自绘遮罩 `account-modal-overlay`
// 新值:`DialogContent` 使用 `account-modal paywall-modal`,标题仍在 `account-modal-header`
// 原因:K2 兑换窗改用组件库 Dialog,文案和这些类名留下
assert.match(onboardingPaywallSource, /<header className="account-modal-header">/);
assert.match(onboardingPaywallSource, /<\/header>/);
assert.doesNotMatch(onboardingPaywallSource, /className="dialog-header"/);
assert.match(onboardingPaywallSource, /className="account-modal-overlay"/);
assert.doesNotMatch(onboardingPaywallSource, /className="account-modal-overlay"/);
assert.match(onboardingPaywallSource, /className="account-modal paywall-modal"/);
assert.match(onboardingPaywallSource, /className="dialog-close"/);
});
@@ -9,7 +9,11 @@ class HostElement {
readonly nodeType = 1;
readonly namespaceURI = "http://www.w3.org/1999/xhtml";
readonly childNodes: HostElement[] = [];
readonly style = {};
readonly style = {
setProperty() {},
getPropertyValue() { return ""; },
removeProperty() { return ""; },
};
open = false;
get disabled() { return this.hasAttribute("disabled"); }
isConnected = true;
@@ -22,6 +26,8 @@ class HostElement {
scrollTop = 0;
scrollHeight = 0;
clientHeight = 0;
clientWidth = 1440;
offsetWidth = 1440;
readonly scrollCalls: { top?: number }[] = [];
scrollTo(options: { top?: number }) { this.scrollCalls.push(options); }
/** No layout here: every box is the origin. Components that position from rects still run. */
@@ -33,10 +39,17 @@ class HostElement {
if (wanted.every(name => classes.includes(name))) return this;
return this.parentNode?.closest(selector) ?? null;
}
get parentElement() { return this.parentNode; }
get children() { return this.childNodes.filter((child) => child.nodeType === 1); }
matches() { return false; }
querySelector() { return null; }
querySelectorAll() { return []; }
getRootNode() { return this.ownerDocument; }
contains(node: HostElement | null): boolean { return node === this || this.childNodes.some(child => child.contains(node)); }
get text(): string { return this.textContent + this.childNodes.map(child => child.text).join(""); }
get props(): Record<string, unknown> { return (this as unknown as Record<string, Record<string, unknown>>)[Object.keys(this).find(key => key.startsWith("__reactProps$")) ?? ""] ?? {}; }
readonly attributes = new Map<string, string>();
readonly dataset: Record<string, string> = {};
parentNode: HostElement | null = null;
textContent = "";
get nodeValue() { return this.textContent; }
@@ -68,9 +81,12 @@ class HostElement {
class HostDocument {
readonly nodeType = 9;
readonly body: HostElement;
readonly documentElement: HostElement;
activeElement: HostElement | null = null;
constructor() {
this.documentElement = new HostElement("HTML", this);
this.body = new HostElement("BODY", this);
this.documentElement.appendChild(this.body);
}
createTextNode(text: string) { const node = new HostElement("#text", this); node.textContent = text; return node; }
getElementById() { return null; }
@@ -85,6 +101,12 @@ export function createClientLifecycleHarness() {
const document = new HostDocument();
const frames = new Map<number, ReturnType<typeof setTimeout>>();
let frameId = 0;
const requestAnimationFrame = (callback: () => void) => {
const id = ++frameId;
frames.set(id, setTimeout(() => { frames.delete(id); callback(); }, 0));
return id;
};
const cancelAnimationFrame = (id: number) => { clearTimeout(frames.get(id)); frames.delete(id); };
const window = {
document,
innerWidth: 1440,
@@ -92,21 +114,19 @@ export function createClientLifecycleHarness() {
clearTimeout,
getComputedStyle: () => ({ direction: "ltr" }),
HTMLElement: HostElement,
Element: HostElement,
Node: HostElement,
HTMLIFrameElement: class {},
addEventListener() {},
removeEventListener() {},
requestAnimationFrame(callback: () => void) {
const id = ++frameId;
frames.set(id, setTimeout(() => { frames.delete(id); callback(); }, 0));
return id;
},
cancelAnimationFrame(id: number) { clearTimeout(frames.get(id)); frames.delete(id); },
requestAnimationFrame,
cancelAnimationFrame,
};
document.defaultView = window;
// tsx's ESM parser binds its runtime to global `self` when that binding
// exists. A partial window makes `cn` fail to parse. Next still calls
// `self.setTimeout`, so `self` is the real global, which already has it.
const globals = { window, self: globalThis, document, HTMLElement: HostElement, Element: HostElement, IS_REACT_ACT_ENVIRONMENT: true };
const globals = { window, self: globalThis, document, HTMLElement: HostElement, Element: HostElement, Node: HostElement, requestAnimationFrame, cancelAnimationFrame, IS_REACT_ACT_ENVIRONMENT: true };
const originals = Object.fromEntries(Object.keys(globals).map((key) => [
key, Object.getOwnPropertyDescriptor(globalThis, key),
]));
+12 -5
View File
@@ -4,7 +4,6 @@ import test from "node:test";
import React from "react";
import { toast } from "sonner";
import { ReplyDownReason } from "../src/components/reply-down-reason";
import { PRIVACY } from "../src/lib/legal-documents";
import { REPLY_QUALITY_RETENTION_DAYS, parseReplyQualityFilter } from "../src/lib/reply-quality-labels";
import { runReplyQualityRetentionTick } from "../src/lib/reply-quality-retention-core";
@@ -141,7 +140,13 @@ test("the privacy draft says what a 👎 keeps, why, for how long, and that 注
});
type Harness = ReturnType<typeof createClientLifecycleHarness>;
const find = (h: Harness, predicate: (node: ReturnType<Harness["elements"]>[number]) => boolean) => h.elements().find(predicate);
// 原值:文件顶部静态引入 ReplyDownReason,只在挂载容器里找文案
// 新值:先建好测试文档再动态引入,并同时查找 document.body
// 原因:组件库 Dialog 在模块加载时才决定把内容挂到 body;测试文档必须先就位
const loadReason = () => import("../src/components/reply-down-reason");
const visibleNodes = (h: Harness) => [...h.elements(), ...h.elements(h.document.body)];
const visibleText = (h: Harness) => `${h.container.text}\n${h.document.body.text}`;
const find = (h: Harness, predicate: (node: ReturnType<Harness["elements"]>[number]) => boolean) => visibleNodes(h).find(predicate);
test("不满意原因 is optional: submitting with nothing picked still thanks the user, after the 👎 save", async () => {
const calls: { url: string; body: Record<string, unknown> }[] = [];
@@ -158,10 +163,11 @@ test("不满意原因 is optional: submitting with nothing picked still thanks t
try {
const key = `${SESSION}:message-5`;
void persistReplyRating(key, "down", "第三答:注意作息。");
const { ReplyDownReason } = await loadReason();
await h.render(<ReplyDownReason feedbackKey={key} onClose={() => { closed += 1; }} />);
assert.ok(h.container.text.includes("哪里不满意?可以不选"));
assert.ok(h.container.text.includes("会把这一轮对话发给我们排查"));
assert.deepEqual(h.elements().filter((node) => node.getAttribute("aria-pressed") !== null).map((node) => node.text), ["答非所问", "内容不准", "太长太空", "语气不对", "其他"]);
assert.ok(visibleText(h).includes("哪里不满意?可以不选"));
assert.ok(visibleText(h).includes("会把这一轮对话发给我们排查"));
assert.deepEqual(visibleNodes(h).filter((node) => node.getAttribute("aria-pressed") !== null).map((node) => node.text), ["答非所问", "内容不准", "太长太空", "语气不对", "其他"]);
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "提交")!);
await h.idle();
assert.deepEqual(calls.map((call) => call.url), ["/api/reply-ratings", "/api/reply-ratings/reason"]);
@@ -188,6 +194,7 @@ test("picked reasons and the note are sent; an unsaved conversation sends nothin
}) as typeof fetch;
const h = createClientLifecycleHarness();
try {
const { ReplyDownReason } = await loadReason();
await h.render(<ReplyDownReason feedbackKey={`${SESSION}:message-3`} onClose={() => {}} />);
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "语气不对")!);
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "答非所问")!);
+5 -2
View File
@@ -13,8 +13,11 @@ function starterEntryBlock(): string {
return css.slice(start, end);
}
test("starter entries stay native buttons with a 36px pill and a 44px hit area", () => {
assert.match(starterHome, /<button[\s\S]*className="starter-entry"/);
test("starter entries stay a 36px pill with a 44px hit area", () => {
// 原值:`<button className="starter-entry"`
// 新值:`<Button className="starter-entry"`
// 原因:K2 今日星语改用组件库 Button,36px 胶囊和 44px 点按区仍由 `.starter-entry` 决定
assert.match(starterHome, /<Button[\s\S]*className="starter-entry"/);
// 原值: 2 新值: 1(生时校正 pill 删除) 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入
assert.equal(starterHome.match(/className="starter-entry"/g)?.length, 1);
assert.doesNotMatch(starterHome, /starter-hero|product-entrypoint|daily-starlanguage-card|birth-rectification-card/);