桌面加载和打开本命盘不再把整页卸掉再挂上(BUG-1444)。

侧栏和对话列始终留在原来的容器里。分栏到达、窗口跨过手机断点,子组件只挂载一次。

检查:在 frontend 下跑 shell-frame-stable-mount、sidebar-contract、chat-chart-panel-state、class-name-definition-contract。
结果:52 通过,0 失败,退出码 0。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-26 22:56:23 +08:00
co-authored by Grok (agent loop)
parent 2e690f654c
commit 38097ca82f
11 changed files with 180 additions and 128 deletions
+16
View File
@@ -19626,3 +19626,19 @@
- 复发自:无
- 修复版本:分支 `codex/full-data-parity-3-20261022`。
## BUG-1444 | 桌面加载后整页被卸掉再挂上
- 状态:resolved
- 首次发现:2026-10-26
- 最近更新:2026-10-26
- 影响面:桌面外壳和对话页旁边的本命盘。手机抽屉的开关方式没改。
- 用户现象:桌面打开网站,或把窗口从手机宽度拉到桌面,整页会再闪一次,正在生成的回答可能丢掉。第一次打开旁边的本命盘时,对话列也会重新挂上,输入到一半的内容和滚动位置会丢。
- 触发条件:可拖分栏的代码还没到时,外壳是普通容器;代码到达后改成另一套面板组件,把整页包进去。本命盘同理,对话列被挪进分栏组件里。窗口跨过手机断点时再来一次。
- 根因:两种状态下,页面内容的祖先元素类型变了。React 会卸载再重挂,启动效果跑两次。
- 修复:侧栏和主区始终留在各自的容器里。拖宽用侧栏容器里的把手,夹在 200 到 360 之间,拖到比 200 更窄就收成 64。不再另载一套分栏库包住整页。本命盘作为对话列的兄弟,第一次打开后加在旁边,不挪走对话列。不用 `key` 掩盖重挂。
- 验证:`frontend/tests/shell-frame-stable-mount.test.tsx` 在分栏到达前后、以及视口 500 与 1440 之间,侧栏和对话列都是挂载 1 次、卸载 0 次。同一批还跑了 `sidebar-contract.test.ts`、`chat-chart-panel-state.test.ts`、`class-name-definition-contract.test.ts`,合计 52 通过、0 失败、退出码 0。
- 防复发:上面的挂载计数测试。再把页面包进后到达的面板里,计数会从 1 变成 2。
- 相关记录:issue #111 验收 U1。#105、#106。
- 复发自:无
- 修复版本:分支 `codex/ui-kit-onboarding-fix-20261026`。
@@ -0,0 +1,15 @@
# 进度:组件库与启程事后修复(2026-10-26)
分支 `codex/ui-kit-onboarding-fix-20261026`。只推这个分支。验收通过前不合进测试环境。承载 issue #111。BUG 从 1444 起。
## U1 · 桌面加载不再把整页卸掉再挂上(BUG-1444)
侧栏和主区始终在各自的容器里。拖宽用侧栏里的把手,宽 200–360,默认 288,拖到比 200 更窄收成 64。本命盘加在对话列旁边,不把对话列挪进后到达的分栏里。
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 挂载与相关合同 | `node node_modules/tsx/dist/cli.mjs --test tests/shell-frame-stable-mount.test.tsx tests/sidebar-contract.test.ts tests/chat-chart-panel-state.test.ts tests/class-name-definition-contract.test.ts` | 52 通过,0 失败,退出码 0 |
断言三栏(`sidebar-contract.test.ts`「selects desktop and tablet shell widths」):原值是网格列分出 288 / 240 / 64,并检查 `minSize={SIDEBAR_WIDTH_MIN}`。新值检查横向 flex、`shell-sidebar-slot` / `shell-main-slot`,以及宽度上下限常量。原因是可拖面板不再包住整页,页面在分栏到达或跨断点时不再卸载。
全量 `npm test`、`npm run build`、`npm run test:db`、Python 快速门留到本单全部改完再跑。
+1 -1
View File
@@ -40,7 +40,7 @@
| `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` |
| `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `codex/chart-table-span-20261023` |
| `TASK-ui-kit-migration-20261023.md` | `PROGRESS-ui-kit-0-base-20261023.md`;`PROGRESS-ui-kit-1-shell-20261023.md`;`PROGRESS-ui-kit-2-chat-20261023.md`;`PROGRESS-ui-kit-3-chart-20261023.md`;`PROGRESS-ui-kit-4-people-20261023.md`;`PROGRESS-ui-kit-5-pages-20261023.md`;`PROGRESS-ui-kit-6-rectification-20261023.md`;`PROGRESS-ui-kit-7-cleanup-20261023.md` | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | K0–K7 已在测试环境,待验收 | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG |
| `TASK-ui-kit-onboarding-fix-20261026.md` | — | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 待执行(#111 承载) | `codex/ui-kit-onboarding-fix-20261026` |
| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1 已提交) | `codex/ui-kit-onboarding-fix-20261026` |
| —(门禁修复,无新任务书) | `PROGRESS-birth-journey-contract-20261025.md` | **测试分支过不了门禁,网站停在完整数据版(二)**:BUG-1439 出生时间合同;BUG-1440 测试画布;BUG-1441 侧栏流式检查等第一次补画结束再数帧。页面不改 | 已合入 staging,等门禁 | `codex/gate-birth-journey-contract-20261025` |
| `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md`;`PROGRESS-onboarding-2-intake-20261022.md`;`PROGRESS-onboarding-4-motion-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022`。(二)T3+T4 记 BUG-1431,在 `codex/onboarding-2-intake-20261022`。(四)T7 在 `codex/onboarding-4-motion-20261022` | (一)(二)(三)已在 staging;(四)本分支待验收 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U |
| `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd |
+2 -2
View File
@@ -1267,13 +1267,13 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
## 外壳(2026-10-24)
桌面侧栏在可拖分栏里,宽 200–360px,默认 288。拖到最左收成 64px 窄轨;收起不把 64 写成宽度。宽度记在本机 `sidebar_width`。手机用左侧抽屉,宽 `min(86vw, 320px)`。当前页高亮用已有的 `layoutId` 滑动。翻页用已有的页面进入 280ms、离开 150ms,方向跟着侧栏顺序。顶栏点数是描边按钮:硬币图标加数字,44px;「点」只在可读名称里。数字变少时往下滚,变多时往上滚,360ms;系统要求减少动态时立刻换数字。启程清单胶囊仍是 32px,仍在点数左边。账户按钮只显示头像和名字。设置仍是三个分区。会话只在收藏组内、或最近一组内拖动排序;收藏整组仍在最前。翻页、高亮滑动、拖宽和拖动排序用的库在页面出来后再载入,不进首屏。
桌面侧栏宽 200–360px,默认 288,拖右侧把手调整。拖到比 200 更窄就收成 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)不显示这个按钮,也不因为藏起来就把「开着」写成关掉。面板里是当前人物的本命盘,复用星盘页的绘制,不写骨架、不写「正在加载」。可拖分栏和星盘绘制都在第一次打开后面才载入,不进首屏。
桌面顶栏、点数右边,是打开或收起本命盘的图标按钮,提示「打开本命盘」或「收起本命盘」。面板宽 280–560px,默认 360。拖到最窄可以收起。开着和宽度记在本机 `chat_chart_panel_open`、`chat_chart_panel_width`。手机(视口还没定,或小于 768)不显示这个按钮,也不因为藏起来就把「开着」写成关掉。面板里是当前人物的本命盘,复用星盘页的绘制,不写骨架、不写「正在加载」。对话列始终留在原处。本命盘在第一次打开后载入,加在对话列旁边,不把对话列卸掉再挂上。星盘绘制不进首屏。
## 新用户启程(2026-10-25)
+7 -4
View File
@@ -823,11 +823,13 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
.group\/sidebar-provider[data-viewport] { height: 100vh; min-height: 0; overflow: hidden; }
.chat-app { width: 100%; height: 100%; min-height: 0; overflow: clip; display: flex; flex-direction: column; background: var(--color-canvas); }
.shell-frame { display: flex; flex: 1 1 auto; flex-direction: row; min-width: 0; min-height: 0; }
.shell-frame[data-shell-resize="pending"] > :first-child { flex: 0 0 var(--shell-sidebar-width, 288px); width: var(--shell-sidebar-width, 288px); min-width: 0; }
.shell-sidebar-slot { position: relative; flex: 0 0 var(--shell-sidebar-width, 288px); width: var(--shell-sidebar-width, 288px); min-width: 0; min-height: 0; }
.shell-frame[data-shell-resize="mobile"] > .shell-sidebar-slot { flex: 0 0 auto; width: auto; }
.shell-main-slot { display: flex; flex: 1 1 auto; flex-direction: column; min-width: 0; min-height: 0; }
.shell-inset, .shell-page { min-width: 0; min-height: 0; }
.shell-page { height: 100%; }
.shell-page-presence { position: relative; height: 100%; min-width: 0; min-height: 0; }
.shell-resize-handle { flex: 0 0 6px; }
.shell-resize-handle { position: absolute; top: 0; right: -3px; z-index: 2; width: 6px; height: 100%; padding: 0; border: 0; background: transparent; cursor: col-resize; }
.sidebar-nav-current { position: absolute; inset: 0; z-index: 0; border-radius: var(--radius-md); background: var(--sidebar-accent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 22%, transparent); pointer-events: none; }
.sidebar { position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; border-right: 1px solid var(--sidebar-border); color: var(--sidebar-foreground); padding: var(--space-5) var(--space-3) 0; background: var(--sidebar-background); backdrop-filter: saturate(130%) blur(20px); }
[data-sidebar="header"] { flex: 0 0 auto; padding-bottom: var(--space-3); }
@@ -1016,8 +1018,9 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
.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-chart-frame { grid-row: 1 / -1; grid-column: 1; min-width: 0; min-height: 0; height: 100%; display: flex; flex-direction: row; }
.chat-chart-frame > .chat-column { flex: 1 1 auto; }
.chat-chart-split { height: 100%; min-width: 0; min-height: 0; flex: 0 0 auto; }
.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); }
+4 -7
View File
@@ -15,7 +15,6 @@ type SplitComponent = (props: {
subjectId: string;
onOpenChange: (open: boolean) => void;
onWidth: (width: number) => void;
children: ReactNode;
}) => ReactNode;
export function ChatChartToggle() {
@@ -78,14 +77,12 @@ export function ChatChartFrame({ children }: { children: ReactNode }) {
}, [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>
{column}
{Split && desktop ? (
<Split open={open} width={width} subjectId={subjectId} onOpenChange={setOpen} onWidth={setWidth} />
) : null}
</div>
);
}
+3 -7
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { useEffect, useRef } from "react";
import type { PanelImperativeHandle } from "react-resizable-panels";
import { ChatNatalChart } from "@/components/chat-natal-chart";
@@ -13,14 +13,12 @@ export function ChatChartSplit({
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);
@@ -44,11 +42,9 @@ export function ChatChartSplit({
if (pixels >= CHAT_CHART_WIDTH_MIN) onWidth(Math.round(pixels));
}
if (!open) return null;
return (
<ResizablePanelGroup orientation="horizontal" className="chat-chart-split" onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="chat-column" defaultSize="100%">
{children}
</ResizablePanel>
<ResizablePanelGroup orientation="horizontal" className="chat-chart-split" style={{ width }} onLayoutChanged={onLayoutChanged}>
<ResizableHandle withHandle className="chat-chart-resize-handle" />
<ResizablePanel
id="chat-natal"
+44 -102
View File
@@ -1,7 +1,6 @@
"use client";
import { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
import type { PanelImperativeHandle } from "react-resizable-panels";
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { useSidebar } from "@/components/ui/sidebar";
import {
@@ -13,124 +12,67 @@ import {
} from "@/lib/sidebar-state";
import { useUiStore } from "@/stores/ui-store";
type ResizableModule = typeof import("@/components/ui/resizable");
let panels: ResizableModule | null = null;
let started = false;
const listeners = new Set<() => void>();
function notify() {
for (const listener of listeners) listener();
}
function subscribe(listener: () => void) {
listeners.add(listener);
if (!started) {
started = true;
void import("@/components/ui/resizable").then((mod) => {
panels = mod;
notify();
}).catch(() => {
started = false;
});
}
return () => listeners.delete(listener);
}
function useResizablePanels(enabled: boolean): ResizableModule | null {
return useSyncExternalStore(
(listener) => (enabled ? subscribe(listener) : () => {}),
() => (enabled ? panels : null),
() => null,
);
}
export function ShellFrame({ sidebar, children }: { sidebar: ReactNode; children: ReactNode }) {
const { isMobile, open, setOpen, viewport, ready } = useSidebar();
const width = useUiStore((state) => state.sidebarWidth);
const hydrated = useUiStore((state) => state.sidebarWidthHydrated);
const hydrate = useUiStore((state) => state.hydrateSidebarWidth);
const setSidebarWidth = useUiStore((state) => state.setSidebarWidth);
const dragStop = useRef<(() => void) | null>(null);
const desktop = ready && !isMobile;
const Resizable = useResizablePanels(desktop && hydrated);
const panelRef = useRef<PanelImperativeHandle | null>(null);
useEffect(() => {
hydrate();
}, [hydrate]);
useEffect(() => {
const panel = panelRef.current;
if (!panel || !Resizable) return;
if (open && panel.isCollapsed()) panel.expand();
if (!open && !panel.isCollapsed()) panel.collapse();
}, [open, Resizable]);
if (!desktop) {
return (
<div className="shell-frame" data-shell-resize="mobile">
{sidebar}
{children}
</div>
);
}
const expandedWidth = hydrated
? width
: viewport === "tablet" ? SIDEBAR_WIDTH_TABLET : SIDEBAR_WIDTH_DEFAULT;
const pixelWidth = open ? expandedWidth : SIDEBAR_WIDTH_COLLAPSED;
if (!Resizable) {
return (
<div
className="shell-frame"
data-shell-resize="pending"
style={{ ["--shell-sidebar-width" as string]: `${pixelWidth}px` }}
>
{sidebar}
{children}
</div>
);
}
useEffect(() => {
hydrate();
}, [hydrate]);
function onLayoutChanged(_layout: Record<string, number>, meta: { isUserInteraction: boolean }) {
if (!meta.isUserInteraction) return;
const panel = panelRef.current;
if (!panel) return;
const pixels = panel.getSize().inPixels;
if (panel.isCollapsed() || pixels < 120) {
setOpen(false);
return;
}
setOpen(true);
setSidebarWidth(Math.round(pixels));
useEffect(() => () => dragStop.current?.(), []);
function onResizeStart(event: ReactPointerEvent<HTMLButtonElement>) {
if (event.button !== 0) return;
event.preventDefault();
const startX = event.clientX;
const startWidth = open ? expandedWidth : SIDEBAR_WIDTH_COLLAPSED;
const move = (ev: PointerEvent) => {
const next = startWidth + ev.clientX - startX;
if (next < SIDEBAR_WIDTH_MIN) {
setOpen(false);
return;
}
setOpen(true);
setSidebarWidth(Math.min(SIDEBAR_WIDTH_MAX, Math.round(next)));
};
const stop = () => {
dragStop.current = null;
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", stop);
};
dragStop.current = stop;
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", stop);
}
return (
<Resizable.ResizablePanelGroup
orientation="horizontal"
<div
className="shell-frame"
data-shell-resize="live"
onLayoutChanged={onLayoutChanged}
data-shell-resize={desktop ? "live" : "mobile"}
style={desktop ? { ["--shell-sidebar-width" as string]: `${pixelWidth}px` } : undefined}
>
<Resizable.ResizablePanel
id="app-sidebar"
className="shell-sidebar-panel"
panelRef={panelRef}
collapsible
collapsedSize={SIDEBAR_WIDTH_COLLAPSED}
collapsedThreshold={SIDEBAR_WIDTH_MIN - SIDEBAR_WIDTH_COLLAPSED}
minSize={SIDEBAR_WIDTH_MIN}
maxSize={SIDEBAR_WIDTH_MAX}
defaultSize={pixelWidth}
groupResizeBehavior="preserve-pixel-size"
>
<div className="shell-sidebar-slot">
{sidebar}
</Resizable.ResizablePanel>
<Resizable.ResizableHandle className="shell-resize-handle" />
<Resizable.ResizablePanel id="app-main" defaultSize="100%">
{children}
</Resizable.ResizablePanel>
</Resizable.ResizablePanelGroup>
{desktop ? (
<button
type="button"
className="shell-resize-handle"
aria-label="调整侧栏宽度"
onPointerDown={onResizeStart}
/>
) : null}
</div>
<div className="shell-main-slot">{children}</div>
</div>
);
}
@@ -61,7 +61,6 @@ const knownUnstyled = new Set([
// 折叠内容、分栏、侧栏导航和手机侧栏上,类名只是钩子。
"chart-page-dasha-panel",
"chat-chart-resize-handle",
"shell-sidebar-panel",
"sidebar-nav-item",
"sidebar-sheet",
"is-live",
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import test from "node:test";
import { useEffect } from "react";
import { ChatChartFrame } from "../src/components/chat-chart-frame.tsx";
import { ShellFrame } from "../src/components/shell-frame.tsx";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
import { useUiStore } from "../src/stores/ui-store.ts";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
function Probe({ record }: { record: { mount: number; unmount: number } }) {
useEffect(() => {
record.mount += 1;
return () => {
record.unmount += 1;
};
}, [record]);
return <span>probe</span>;
}
function listenForResize(win: Window) {
const buckets = new Map<string, Set<EventListener>>();
win.addEventListener = ((type: string, listener: EventListener) => {
const set = buckets.get(type) ?? new Set<EventListener>();
set.add(listener);
buckets.set(type, set);
}) as Window["addEventListener"];
win.removeEventListener = ((type: string, listener: EventListener) => {
buckets.get(type)?.delete(listener);
}) as Window["removeEventListener"];
win.dispatchEvent = ((event: Event) => {
for (const listener of buckets.get(event.type) ?? []) listener(event);
return true;
}) as Window["dispatchEvent"];
}
test("the shell and the chat column stay mounted when the desktop split arrives", async () => {
const sidebar = { mount: 0, unmount: 0 };
const column = { mount: 0, unmount: 0 };
const harness = createClientLifecycleHarness();
listenForResize(globalThis.window);
const win = globalThis.window as Window & { innerWidth: number };
try {
await harness.render(
<SidebarProvider>
<ShellFrame sidebar={<Probe record={sidebar} />}>
<ChatChartFrame>
<Probe record={column} />
</ChatChartFrame>
</ShellFrame>
</SidebarProvider>,
);
await harness.idle();
assert.equal(sidebar.mount, 1, "sidebar mounted once before the split");
assert.equal(sidebar.unmount, 0, "sidebar unmounted before the split");
assert.equal(column.mount, 1, "chat column mounted once before the split");
assert.equal(column.unmount, 0, "chat column unmounted before the split");
await harness.update(() => {
win.innerWidth = 500;
win.dispatchEvent(new Event("resize"));
});
await harness.idle();
await harness.update(() => {
win.innerWidth = 1440;
win.dispatchEvent(new Event("resize"));
});
await harness.idle();
await harness.update(() => {
useUiStore.getState().setChatChartOpen(true);
});
for (let attempt = 0; attempt < 8; attempt += 1) await harness.idle();
assert.equal(sidebar.mount, 1, "sidebar mounted once across the breakpoint");
assert.equal(sidebar.unmount, 0, "sidebar unmounted across the breakpoint");
assert.equal(column.mount, 1, "chat column mounted once when the chart split arrived");
assert.equal(column.unmount, 0, "chat column unmounted when the chart split arrived");
assert.equal(harness.errors.length, 0, harness.errors.map(String).join("\n"));
} finally {
await harness.close();
}
});
+6 -4
View File
@@ -446,12 +446,14 @@ test("maps the sidebar semantic aliases and responsive dimensions", () => {
test("selects desktop and tablet shell widths from provider data", () => {
assert.match(globalStyles, /\.group\\\/sidebar-provider\[data-viewport\][^{]*\{[^}]*height:\s*100dvh/);
// 原值:`.chat-app` 用网格列分出 288 / 240 / 64。
// 新值:`.chat-app` 是纵向 flex,`.shell-frame` 是横向 flex;宽度由可拖面板记住,收起是 64。
// 原因:K1 桌面侧栏放进可拖宽面板,不再用网格列写死三档宽度。
// 新值:`.chat-app` 是纵向 flex,`.shell-frame` 是横向 flex;侧栏宽度夹在最小与最大之间,收起是 64。
// 原因:可拖面板不再包住整页。侧栏和主区始终是原来的容器,分栏到达或跨过断点都不会把页面卸掉再挂上。
assert.match(globalStyles, /\.chat-app\s*\{[^}]*display:\s*flex[^}]*flex-direction:\s*column/);
assert.match(globalStyles, /\.shell-frame\s*\{[^}]*display:\s*flex[^}]*flex-direction:\s*row/);
assert.match(readProjectFile("src/components/shell-frame.tsx"), /minSize=\{SIDEBAR_WIDTH_MIN\}/);
assert.match(readProjectFile("src/components/shell-frame.tsx"), /maxSize=\{SIDEBAR_WIDTH_MAX\}/);
assert.match(readProjectFile("src/components/shell-frame.tsx"), /SIDEBAR_WIDTH_MIN/);
assert.match(readProjectFile("src/components/shell-frame.tsx"), /SIDEBAR_WIDTH_MAX/);
assert.match(readProjectFile("src/components/shell-frame.tsx"), /shell-sidebar-slot/);
assert.match(readProjectFile("src/components/shell-frame.tsx"), /shell-main-slot/);
assert.match(readProjectFile("src/lib/sidebar-state.ts"), /SIDEBAR_WIDTH_COLLAPSED = 64/);
assert.doesNotMatch(globalStyles, /transition:[^;}]*\b(?:width|grid-template-columns)\b/);
});