diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 9b2fb77a..c77ebaf6 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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`。 + diff --git a/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md b/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md new file mode 100644 index 00000000..b09a4e19 --- /dev/null +++ b/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md @@ -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 快速门留到本单全部改完再跑。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 284f5700..ebde06c3 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -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 | diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 3d87eac8..c34f4f08 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index e65c8a19..31b02b5e 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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); } diff --git a/frontend/src/components/chat-chart-frame.tsx b/frontend/src/components/chat-chart-frame.tsx index 98efb645..7d84a0b8 100644 --- a/frontend/src/components/chat-chart-frame.tsx +++ b/frontend/src/components/chat-chart-frame.tsx @@ -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 =
{children}
; - if (!Split || !desktop) { - return
{column}
; - } return (
- - {column} - + {column} + {Split && desktop ? ( + + ) : null}
); } diff --git a/frontend/src/components/chat-chart-split.tsx b/frontend/src/components/chat-chart-split.tsx index 9aeac45a..0c5e24c3 100644 --- a/frontend/src/components/chat-chart-split.tsx +++ b/frontend/src/components/chat-chart-split.tsx @@ -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(null); @@ -44,11 +42,9 @@ export function ChatChartSplit({ if (pixels >= CHAT_CHART_WIDTH_MIN) onWidth(Math.round(pixels)); } + if (!open) return null; return ( - - - {children} - + 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(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 ( -
- {sidebar} - {children} -
- ); - } - const expandedWidth = hydrated ? width : viewport === "tablet" ? SIDEBAR_WIDTH_TABLET : SIDEBAR_WIDTH_DEFAULT; const pixelWidth = open ? expandedWidth : SIDEBAR_WIDTH_COLLAPSED; - if (!Resizable) { - return ( -
- {sidebar} - {children} -
- ); - } + useEffect(() => { + hydrate(); + }, [hydrate]); - function onLayoutChanged(_layout: Record, 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) { + 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 ( - - +
{sidebar} - - - - {children} - - + {desktop ? ( +
+
{children}
+ ); } diff --git a/frontend/tests/class-name-definition-contract.test.ts b/frontend/tests/class-name-definition-contract.test.ts index 0921d4a7..8cef55de 100644 --- a/frontend/tests/class-name-definition-contract.test.ts +++ b/frontend/tests/class-name-definition-contract.test.ts @@ -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", diff --git a/frontend/tests/shell-frame-stable-mount.test.tsx b/frontend/tests/shell-frame-stable-mount.test.tsx new file mode 100644 index 00000000..e68262ef --- /dev/null +++ b/frontend/tests/shell-frame-stable-mount.test.tsx @@ -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 probe; +} + +function listenForResize(win: Window) { + const buckets = new Map>(); + win.addEventListener = ((type: string, listener: EventListener) => { + const set = buckets.get(type) ?? new Set(); + 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( + + }> + + + + + , + ); + 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(); + } +}); diff --git a/frontend/tests/sidebar-contract.test.ts b/frontend/tests/sidebar-contract.test.ts index d54218c2..63568640 100644 --- a/frontend/tests/sidebar-contract.test.ts +++ b/frontend/tests/sidebar-contract.test.ts @@ -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/); });