Skip to content

Commit a885429

Browse files
committed
fix(chat): wire sidebar resize handle
1 parent 477bf60 commit a885429

4 files changed

Lines changed: 123 additions & 33 deletions

File tree

‎app/src/components/ChatSidebar.test.tsx‎

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,28 @@
1313
* (J1-3 死局形态:只翻 setManualFolded 则自动折叠下点了不展开 ⇒ 红);
1414
* ④ 手动折叠记忆 + 页面不注入任何 prop 时仍然自足(缺省不回落 240)。
1515
*
16+
* ⑤⑥⑦(2026-09-12 · T9 评审 I-1 修正 / 控制方裁决 e):**拖拽手柄**。此前页面只
17+
* 散传宽度/折叠态/展开回调 ⇒ `resizeBy` 到不了 UI,注册表承诺的 200..320 是
18+
* 规格 §6.2 逐字点名的「假可调」;现改为列状态**整体**注入 + 手柄在本组件内渲染:
19+
* ⑤ 手柄在(`data-testid="column-resizer"`,见 `ColumnResizer.tsx:60`);
20+
* ⑥ **拖拽真的改宽**(pointer 增量 → `resizeBy` → DOM `style.width`,含 max 夹取);
21+
* ⑦ 折叠态下手柄仍在(与 `notes/NotesGroupsColumn` 同款:手柄在折叠三元之外);
22+
* ⑧ 宽度与 `resizeBy` 都取自**注入的那个对象**(防「注入了却被忽略」的假接线)。
23+
*
1624
* ⚠️ 仪器边界:jsdom **不排版** —— 只判 `style.width` 与元素有无,**不判像素**(像素归 T14 探针);
1725
* 且组件测试底座全局 `environment: "node"` ⇒ 首行必须是指令行(本行之上不得有注释)。
1826
* 本仓未装 jest-dom / user-event ⇒ 断言用原生 API、交互用 `fireEvent`;无 `globals`
1927
* ⇒ 必须显式 `afterEach(cleanup)`(先例 `NoteColorPicker.test.tsx:11`)。
2028
*/
2129
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2230
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
23-
import { useColumnLayout } from "../hooks/useColumnLayout";
31+
import { useColumnLayout, type ColumnLayout } from "../hooks/useColumnLayout";
2432
import { columnSpec } from "../shell/columnRegistry";
2533
import ChatSidebar from "./ChatSidebar";
2634
import type { ChatSidebarProps } from "./ChatSidebar";
2735

2836
const KEY = "chat-sidebar";
29-
const base: Omit<ChatSidebarProps, "width" | "folded" | "onExpand"> = {
37+
const base: Omit<ChatSidebarProps, "col"> = {
3038
sessions: [], tasks: [], activeChatId: null, activeTaskId: null,
3139
onSelectChat: vi.fn(), onSelectTask: vi.fn(), onNewChat: vi.fn(),
3240
onRenameChat: vi.fn(), onDeleteChat: vi.fn(),
@@ -36,7 +44,7 @@ const base: Omit<ChatSidebarProps, "width" | "folded" | "onExpand"> = {
3644
/** 页面形态的宿主:真 hook + 真注册表规格(= `ChatPage.tsx` 的那一行 + props 注入) */
3745
function Harness() {
3846
const chatCol = useColumnLayout(KEY, columnSpec(KEY));
39-
return <ChatSidebar {...base} width={chatCol.width} folded={chatCol.folded} onExpand={chatCol.expand} />;
47+
return <ChatSidebar {...base} col={chatCol} />;
4048
}
4149

4250
/** 展开态的根元素宽度(折叠态根元素是 ColumnBar 窄条,无 width 样式) */
@@ -84,9 +92,61 @@ describe("ChatSidebar 列接线(规格 §6.2)", () => {
8492
const folded = render(<Harness />);
8593
expect(screen.getByTestId("column-bar")).toBeTruthy();
8694
folded.unmount();
95+
// 2026-09-12(I-1 修正):组件现在**自持**同 key 的列状态 ⇒ 上面写进去的手动折叠
96+
// 记忆会被兜底实例读回(渲染成窄条)。本条的判据是「宽度缺省来自注册表」,
97+
// 折记忆另由 ③⑦ 覆盖,故先清掉折叠记忆再验缺省宽度。
98+
window.localStorage.removeItem(`layout:col-fold:${KEY}`);
8799
const bare = render(<ChatSidebar {...base} />);
88100
expect(rootWidth(bare.container.firstElementChild as HTMLElement)).toBe(
89101
`${columnSpec(KEY).default}px`,
90102
);
91103
});
104+
105+
it("⑤ 拖拽手柄接在侧栏上(I-1「假可调」修正:注册表承诺的 200..320 可达)", () => {
106+
window.localStorage.setItem(`layout:col-width:${KEY}`, "300");
107+
render(<Harness />);
108+
// 有且仅有一个手柄(页面不再自己渲染第二个——列随组件走)
109+
const handle = screen.getByTestId("column-resizer");
110+
expect(handle.getAttribute("role")).toBe("separator");
111+
});
112+
113+
it("⑥ 拖手柄真的改宽(pointer 增量 → resizeBy → DOM 宽度;max 由 hook 夹取)", () => {
114+
const { container } = render(<Harness />);
115+
const handle = screen.getByTestId("column-resizer");
116+
expect(rootWidth(container.firstElementChild as HTMLElement)).toBe("260px");
117+
fireEvent.pointerDown(handle, { clientX: 100 });
118+
fireEvent.pointerMove(handle, { clientX: 140 }); // +40 ⇒ 300(增量语义:不是累计位移)
119+
fireEvent.pointerUp(handle);
120+
expect(rootWidth(container.firstElementChild as HTMLElement)).toBe("300px");
121+
// 注册表 max=320:再拖 +100 必须被 useColumnLayout 夹回 320(手柄旁路不了规格)
122+
fireEvent.pointerDown(handle, { clientX: 100 });
123+
fireEvent.pointerMove(handle, { clientX: 200 });
124+
fireEvent.pointerUp(handle);
125+
expect(rootWidth(container.firstElementChild as HTMLElement)).toBe("320px");
126+
});
127+
128+
it("⑦ 折叠态下手柄仍在(与 notes/NotesGroupsColumn 同款:手柄在折叠三元之外)", () => {
129+
window.localStorage.setItem(`layout:col-fold:${KEY}`, "1");
130+
render(<Harness />);
131+
expect(screen.getByTestId("column-bar")).toBeTruthy();
132+
expect(screen.getByTestId("column-resizer")).toBeTruthy();
133+
});
134+
135+
it("⑧ **注入的那个对象**就是真源(宽度与 resizeBy 都取自页面传的 col)", () => {
136+
// Why 造一个假对象(与 ①–⑦ 的「真 hook」口径互补):Harness 与页面的 hook 同 key、
137+
// 同规格 ⇒ 它分不清「用了注入对象」与「自己另开了一份状态」。若要防
138+
// `const col = ownCol`(页面注入了却被忽略)这类假接线,必须让注入值**可区分**。
139+
const resizeBy = vi.fn();
140+
const injected: ColumnLayout = {
141+
width: 205, folded: false, manuallyFolded: false,
142+
resizeBy, resetWidth: vi.fn(), setManualFolded: vi.fn(), expand: vi.fn(),
143+
};
144+
const { container } = render(<ChatSidebar {...base} col={injected} />);
145+
expect(rootWidth(container.firstElementChild as HTMLElement)).toBe("205px");
146+
const handle = screen.getByTestId("column-resizer");
147+
fireEvent.pointerDown(handle, { clientX: 10 });
148+
fireEvent.pointerMove(handle, { clientX: 30 });
149+
fireEvent.pointerUp(handle);
150+
expect(resizeBy.mock.calls.map((c) => c[0])).toEqual([20]);
151+
});
92152
});

‎app/src/components/ChatSidebar.tsx‎

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,22 @@
44
* @ai-context: 两段式:"💬 对话"(自由聊天会话 CRUD)+ "🤖 AI 任务"
55
* (refine/enrich 任务对话入口——只读轨迹视图数据源)。
66
* 全部数据由 ChatPage 加载后透传(本组件纯展示 + 事件回调)。
7+
* @ai-context: 批 3 · T9 评审 I-1:列状态**整体**注入(`col`)且拖拽手柄由本组件
8+
* 渲染。此前页面只散传宽度/折叠态/展开回调 ⇒ `resizeBy`/`resetWidth`
9+
* 在 UI 上不可达,注册表承诺的 200..320 成了规格 §6.2 逐字点名的
10+
* 「假可调」。形态与 `notes/NotesGroupsColumn` 同款:顶层 fragment、
11+
* 手柄在**折叠三元之外**(折叠态也渲染),且是页面根 flex 的直接子元素。
712
*/
813
import type { AiTaskRecord, ChatSession } from "../types";
914
// 2026-09-09 批 1:任务标题统一按类别解析(taskRefLabel——会话级/笔记级
1015
// 精修 ref_id 语义不同;enrich 恒笔记级),侧栏与对话页/dock 同口径
1116
import { taskRefLabel } from "../utils/entityLabel";
12-
// 批 3(规格 §6.2「AI 对话侧栏:接入列基础设施」):列宽不再写死 240——
17+
// 批 3(规格 §6.2「AI 对话侧栏:接入列基础设施」):列宽不再写死——
1318
// 规格住在 shell/columnRegistry,运行时由页面里的 useColumnLayout 执行
1419
import { columnSpec } from "../shell/columnRegistry";
20+
import { useColumnLayout, type ColumnLayout } from "../hooks/useColumnLayout";
1521
import ColumnBar from "./ColumnBar";
22+
import ColumnResizer from "./ColumnResizer";
1623

1724
/** 任务类型标签(refine/enrich → 中文 + 图标;模块内消费——审查修复:原
1825
* export 无外部消费方,收窄为非导出) */
@@ -22,18 +29,15 @@ const OP_LABEL: Record<string, string> = {
2229
};
2330

2431
/**
25-
* 批 3(规格 §6.2):本列已接入列基础设施。两个新 prop 均为可选 ⇒ 组件在
26-
* 没有页面注入时行为自足(宽度回落到注册表默认值),但**折叠态必须注入
27-
* onExpand**——否则 26px 窄条点了不展开(正是 J1-3 那类交互死局的形态)。
32+
* 批 3(规格 §6.2):本列已接入列基础设施;**T9 评审 I-1 修正**——列状态由页面
33+
* **整体**注入(`col`),三个散 prop(生效宽度 / 折叠态 / 展开回调)已收敛掉:
34+
* 散传时 `resizeBy` / `resetWidth` 到不了 UI ⇒ 注册表承诺的 200..320 不可达。
2835
*/
2936
export interface ChatSidebarProps {
30-
/** 生效列宽(`useColumnLayout("chat-sidebar", …).width`,已按 200..320 夹取);
31-
* 缺省 = 注册表默认 260(接线前本组件写死 240,见 columnRegistry 的 M3 注) */
32-
width?: number;
33-
/** 生效折叠态(自动折叠或手动折叠任一成立)——为真时整列收成 26px 窄条 */
34-
folded?: boolean;
35-
/** 点窄条展开(`useColumnLayout.expand`:同时清自动/手动折叠态) */
36-
onExpand?: () => void;
37+
/** 页面注入的列状态(`useColumnLayout("chat-sidebar", columnSpec("chat-sidebar"))`,
38+
* 宽度已按 200..320 夹取)。缺省 ⇒ 本组件按**同一注册表规格**自持一份(页面
39+
* 零注入时行为自足,缺省宽度仍是注册表默认值,不是接线前的写死值)。 */
40+
col?: ColumnLayout;
3741
sessions: ChatSession[];
3842
tasks: AiTaskRecord[];
3943
/** 当前选中(chat 段会话 id / task 段任务 id) */
@@ -59,16 +63,15 @@ function fmtTime(unix: number): string {
5963

6064
export default function ChatSidebar(props: ChatSidebarProps) {
6165
const {
62-
width = columnSpec("chat-sidebar").default,
63-
folded = false,
64-
onExpand,
66+
col: injectedCol,
6567
sessions, tasks, activeChatId, activeTaskId,
6668
onSelectChat, onSelectTask, onNewChat, onNewKbChat, onRenameChat, onDeleteChat,
6769
sessionTitles, noteTitles,
6870
} = props;
69-
// 批 3:折叠态与其它列同款——整列换成 ColumnBar 窄条(26px,点击走 expand())。
70-
// 顶层仍是**恰好一个**元素(不是 Fragment):根 flex 的子元素数不变 ⇒ 不改 flex 分配。
71-
if (folded) return <ColumnBar icon="💬" title="对话" onClick={() => onExpand?.()} />;
71+
// 单行写法是刻意的:`columnRegistry.test.ts` ⑤ 逐行捕获
72+
// `useColumnLayout("键", columnSpec("键"))`,换行写法会让那条判据静默空转。
73+
const ownCol = useColumnLayout("chat-sidebar", columnSpec("chat-sidebar"));
74+
const col = injectedCol ?? ownCol;
7275
const itemBase: React.CSSProperties = {
7376
padding: "6px 8px",
7477
borderRadius: 6,
@@ -83,8 +86,9 @@ export default function ChatSidebar(props: ChatSidebarProps) {
8386
textAlign: "left",
8487
color: "#374151",
8588
};
86-
return (
87-
<div style={{ width, flexShrink: 0, borderRight: "1px solid #e5e7eb", display: "flex", flexDirection: "column", minHeight: 0 }}>
89+
// 批 3:折叠态与其它列同款——整列换成 ColumnBar 窄条(26px,点击走 expand())
90+
const panel = (
91+
<div style={{ width: col.width, flexShrink: 0, borderRight: "1px solid #e5e7eb", display: "flex", flexDirection: "column", minHeight: 0 }}>
8892
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 8px 4px" }}>
8993
<span style={{ fontSize: 12, fontWeight: 600, color: "#6b7280" }}>💬 对话</span>
9094
<div style={{ display: "flex", gap: 2 }}>
@@ -180,4 +184,13 @@ export default function ChatSidebar(props: ChatSidebarProps) {
180184
</div>
181185
</div>
182186
);
187+
// T9 评审 I-1:手柄由本组件渲染,且在**折叠三元之外**(折叠态也在)——与
188+
// `notes/NotesGroupsColumn` 同款。顶层 fragment 的两个子元素都是页面根 flex 的
189+
// 直接子元素(多包一层会改变宽度分配;`ColumnResizer` 恒 5px / flexShrink 0)。
190+
return (
191+
<>
192+
{col.folded ? <ColumnBar icon="💬" title="对话" onClick={col.expand} /> : panel}
193+
<ColumnResizer onResize={col.resizeBy} onReset={col.resetWidth} />
194+
</>
195+
);
183196
}

‎app/src/pages/ChatPage.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ export default function ChatPage(props: Props) {
407407
return (
408408
<div style={{ height: "100%", display: "flex", minHeight: 0 }}>
409409
<ChatSidebar
410-
width={chatCol.width} folded={chatCol.folded} onExpand={chatCol.expand}
410+
col={chatCol}
411411
sessions={sessions}
412412
tasks={tasks}
413413
activeChatId={activeChatId}

‎app/src/shell/columnConsumption.test.ts‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,16 @@
1414
* ③ **扫描域自检**:四个文件都读得到且够长(防路径写错 ⇒ 0 命中 ⇒ 假绿),
1515
* 剥注释器与三个旧字面量正则各带正/负样本自检(不能失败的判据不算判据)。
1616
*
17+
* 2026-09-12(T9 评审 I-1 · 控制方裁决 e):① 与 ④ 按「列状态**整体**注入(`col`)+
18+
* 手柄在 `ChatSidebar` 内渲染」改写——旧断言逐字绑在散 prop 形态上,与裁决后的实现
19+
* 不相容(`ChatPage` 只有 1 行余量,散 prop 与对象不能并存)。改写**同强度不降**:
20+
* 新增 `onResize={col.resizeBy}`(手柄真接线)与「散 prop 不得复辟」「页面不得重复
21+
* 渲染手柄」三条判据,`it` 数 6 → 6 不变。
22+
*
1723
* ⚠️ 仪器局限(诚实边界):本文件**只看源码文本**——看不到「列实际渲染了多宽」。
18-
* `ChatSidebar` / `GoalsPage` / `SettingsPage` / `ChatPage` 今日**都没有组件测试**,
19-
* 故宽度与折叠的真实行为在本批**没有自动化覆盖**:像素证据归 T14 的探针,
24+
* `ChatPage` 今日**没有组件测试**(页面级接线只有本文件的文本判据),故宽度与折叠的
25+
* 真实行为在本批仍无自动化覆盖:像素证据归 T14 的探针;`ChatSidebar` 的行为层现在
26+
* 由 `components/ChatSidebar.test.tsx`(jsdom,⑤⑥⑦ = 手柄/拖拽改宽/折叠态手柄)承担。
2027
* 本文件跑在 vitest 全局 `node` 环境(纯文本判定,刻意不加 jsdom 头)。
2128
*/
2229
import { readFileSync } from "node:fs";
@@ -48,15 +55,20 @@ const LEGACY: readonly (readonly [string, RegExp])[] = [
4855
] as const;
4956

5057
describe("T9 · 未接入三处的列接线(规格 §6.2)", () => {
51-
it("① ChatSidebar 不再写死列宽,且接受 width/folded/onExpand", () => {
58+
it("① ChatSidebar 不再写死列宽;列状态整体注入 + 手柄在组件内(I-1 修正)", () => {
5259
const c = code("components/ChatSidebar.tsx");
5360
expect(/width:\s*240\b/.test(c), "ChatSidebar 代码里仍硬编码 240").toBe(false);
5461
expect(c.includes("export interface ChatSidebarProps")).toBe(true);
55-
expect(c.includes("width?: number")).toBe(true);
56-
expect(c.includes("folded?: boolean")).toBe(true);
62+
// 2026-09-12 T9 评审 I-1(控制方裁决 e):三个散 prop 收敛为**整对象** col
63+
expect(c.includes("col?: ColumnLayout"), "列状态未收敛为一个对象").toBe(true);
64+
expect(c.includes("col.folded"), "折叠态未取自列状态").toBe(true);
5765
expect(c.includes('columnSpec("chat-sidebar")'), "宽度缺省值未取注册表").toBe(true);
58-
// 折叠态必须整列换成窄条(与其它列同款),且是「早返回」而非藏在某分支里
59-
expect(/if\s*\(folded\)\s*return\s*<ColumnBar/.test(c), "折叠态未渲染 ColumnBar 窄条").toBe(true);
66+
// 「假可调」的机器判据:注册表承诺 200..320 ⇒ 必须真有一个手柄,且它**接到列状态上**
67+
// (只判「文件里出现 ColumnResizer」会被「渲染了但没接 onResize」的形态骗过)
68+
expect(c.includes("<ColumnResizer"), "侧栏没有拖拽手柄 ⇒ 注册表承诺的区间不可达").toBe(true);
69+
expect(c.includes("onResize={col.resizeBy}"), "手柄未接 resizeBy(拖了不改宽)").toBe(true);
70+
// 折叠态必须整列换成窄条(与其它列同款)
71+
expect(c.includes("<ColumnBar"), "折叠态未渲染 ColumnBar 窄条").toBe(true);
6072
});
6173

6274
it("② GoalsPage 不再写死 380,且可拖拽 + 可折叠", () => {
@@ -82,9 +94,14 @@ describe("T9 · 未接入三处的列接线(规格 §6.2)", () => {
8294
const c = code("pages/ChatPage.tsx");
8395
expect(c.includes('useColumnLayout("chat-sidebar", columnSpec("chat-sidebar"))'), "页面未从注册表取规格").toBe(true);
8496
const usage = c.slice(c.indexOf("<ChatSidebar"));
85-
expect(usage.includes("width={chatCol.width}"), "未把列宽注入 ChatSidebar").toBe(true);
86-
expect(usage.includes("folded={chatCol.folded}"), "未把折叠态注入 ChatSidebar").toBe(true);
87-
expect(usage.includes("onExpand={chatCol.expand}"), "未注入 expand(窄条点不开)").toBe(true);
97+
// 2026-09-12 T9 评审 I-1(控制方裁决 e):改为**一次对象传递**。散传 width/folded/
98+
// onExpand 时 resizeBy 到不了 UI(=§6.2 的「假可调」),故加**反向判据**防复辟。
99+
expect(usage.includes("col={chatCol}"), "未把列状态整体注入 ChatSidebar").toBe(true);
100+
for (const scattered of ["width={chatCol.width}", "folded={chatCol.folded}", "onExpand={chatCol.expand}"]) {
101+
expect(usage.includes(scattered), `散 prop ${scattered} 复辟(手柄拿不到 resizeBy)`).toBe(false);
102+
}
103+
// 手柄只有一处渲染(在 ChatSidebar 内)——页面不得再渲染第二个
104+
expect(c.includes("<ColumnResizer"), "手柄应由 ChatSidebar 渲染,页面不得重复渲染").toBe(false);
88105
});
89106

90107
it("⑤ 第二仪器(原始文本 · 刻意更严):旧字面量连注释里都不许残留", () => {

0 commit comments

Comments
 (0)