Skip to content

Commit 4f8f3d3

Browse files
committed
feat(shell): wire three pages to column specs
1 parent fd83abb commit 4f8f3d3

8 files changed

Lines changed: 321 additions & 7 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context AI 侧栏列接线守卫(批 3 T9 / 规格 §6.2「AI 对话侧栏:接入列基础设施」+ M3)。
4+
*
5+
* Why 用**真执行器 + 真注册表规格**(不是给组件塞假 props):本文件要证的正是
6+
* 「`useColumnLayout("chat-sidebar", columnSpec("chat-sidebar"))` 的结果真的走到了 DOM」
7+
* —— 假 props 只能证明组件认这个 prop,证不了接线(同 `notes/NotesReadingColumn.outline.test.tsx` 口径)。
8+
*
9+
* 判据(每条都配了能红的变异体,见 task-9-report §变异体实测):
10+
* ① 未记忆时渲染宽度 = 注册表默认 260(接线前本组件写死 240 ⇒ 红);
11+
* ② localStorage 记的列宽 → 渲染宽度(写死宽度 ⇒ 记忆无效 ⇒ 红);
12+
* ③ 窄窗(< `BREAKPOINTS.twoCol`)自动折叠 → 26px 窄条;**点窄条走 expand() 能展开**
13+
* (J1-3 死局形态:只翻 setManualFolded 则自动折叠下点了不展开 ⇒ 红);
14+
* ④ 手动折叠记忆 + 页面不注入任何 prop 时仍然自足(缺省不回落 240)。
15+
*
16+
* ⚠️ 仪器边界:jsdom **不排版** —— 只判 `style.width` 与元素有无,**不判像素**(像素归 T14 探针);
17+
* 且组件测试底座全局 `environment: "node"` ⇒ 首行必须是指令行(本行之上不得有注释)。
18+
* 本仓未装 jest-dom / user-event ⇒ 断言用原生 API、交互用 `fireEvent`;无 `globals`
19+
* ⇒ 必须显式 `afterEach(cleanup)`(先例 `NoteColorPicker.test.tsx:11`)。
20+
*/
21+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
23+
import { useColumnLayout } from "../hooks/useColumnLayout";
24+
import { columnSpec } from "../shell/columnRegistry";
25+
import ChatSidebar from "./ChatSidebar";
26+
import type { ChatSidebarProps } from "./ChatSidebar";
27+
28+
const KEY = "chat-sidebar";
29+
const base: Omit<ChatSidebarProps, "width" | "folded" | "onExpand"> = {
30+
sessions: [], tasks: [], activeChatId: null, activeTaskId: null,
31+
onSelectChat: vi.fn(), onSelectTask: vi.fn(), onNewChat: vi.fn(),
32+
onRenameChat: vi.fn(), onDeleteChat: vi.fn(),
33+
sessionTitles: new Map<number, string>(), noteTitles: new Map<number, string>(),
34+
};
35+
36+
/** 页面形态的宿主:真 hook + 真注册表规格(= `ChatPage.tsx` 的那一行 + props 注入) */
37+
function Harness() {
38+
const chatCol = useColumnLayout(KEY, columnSpec(KEY));
39+
return <ChatSidebar {...base} width={chatCol.width} folded={chatCol.folded} onExpand={chatCol.expand} />;
40+
}
41+
42+
/** 展开态的根元素宽度(折叠态根元素是 ColumnBar 窄条,无 width 样式) */
43+
const rootWidth = (el: HTMLElement | null) => el?.style.width ?? "";
44+
45+
beforeEach(() => {
46+
// jsdom 默认视口恰为 1024 < twoCol(1100) ⇒ 不显式声明会让每一列都静默变成折叠态
47+
// (批 3 T5 实测;1280 = 规格 §1 决策 17 的默认窗宽)。断言一条不改,改的只是测试环境。
48+
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1280 });
49+
window.localStorage.clear();
50+
});
51+
afterEach(() => {
52+
cleanup();
53+
window.localStorage.clear();
54+
// 与 `SelectionActionMenu.test.tsx` 同款:视口改动必须复原,否则同文件后续用例跑在错的窗口下
55+
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024 });
56+
});
57+
58+
describe("ChatSidebar 列接线(规格 §6.2)", () => {
59+
it("① 未记忆时 = 注册表默认(260,接线前写死 240)", () => {
60+
expect(columnSpec(KEY).default).toBe(260); // 规格 §6.2 锚点(注册表被改错时先在这里红)
61+
const { container } = render(<Harness />);
62+
expect(rootWidth(container.firstElementChild as HTMLElement)).toBe("260px");
63+
expect(screen.queryByTestId("column-bar")).toBeNull();
64+
});
65+
66+
it("② localStorage 记忆的列宽走到 DOM(写死宽度则记忆无效)", () => {
67+
window.localStorage.setItem(`layout:col-width:${KEY}`, "300");
68+
const { container } = render(<Harness />);
69+
expect(rootWidth(container.firstElementChild as HTMLElement)).toBe("300px");
70+
});
71+
72+
it("③ 窄窗自动折叠渲染窄条;点窄条走 expand() 能展开", () => {
73+
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024 });
74+
const { container } = render(<Harness />);
75+
expect(screen.getByTestId("column-bar")).toBeTruthy();
76+
expect(container.firstElementChild?.getAttribute("data-testid")).toBe("column-bar");
77+
fireEvent.click(screen.getByTestId("column-bar"));
78+
expect(screen.queryByTestId("column-bar")).toBeNull();
79+
expect(rootWidth(container.firstElementChild as HTMLElement)).toBe("260px");
80+
});
81+
82+
it("④ 手动折叠记忆生效;且页面零注入时组件仍自足(不回落 240)", () => {
83+
window.localStorage.setItem(`layout:col-fold:${KEY}`, "1");
84+
const folded = render(<Harness />);
85+
expect(screen.getByTestId("column-bar")).toBeTruthy();
86+
folded.unmount();
87+
const bare = render(<ChatSidebar {...base} />);
88+
expect(rootWidth(bare.container.firstElementChild as HTMLElement)).toBe(
89+
`${columnSpec(KEY).default}px`,
90+
);
91+
});
92+
});

‎app/src/components/ChatSidebar.tsx‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import type { AiTaskRecord, ChatSession } from "../types";
99
// 2026-09-09 批 1:任务标题统一按类别解析(taskRefLabel——会话级/笔记级
1010
// 精修 ref_id 语义不同;enrich 恒笔记级),侧栏与对话页/dock 同口径
1111
import { taskRefLabel } from "../utils/entityLabel";
12+
// 批 3(规格 §6.2「AI 对话侧栏:接入列基础设施」):列宽不再写死 240——
13+
// 规格住在 shell/columnRegistry,运行时由页面里的 useColumnLayout 执行
14+
import { columnSpec } from "../shell/columnRegistry";
15+
import ColumnBar from "./ColumnBar";
1216

1317
/** 任务类型标签(refine/enrich → 中文 + 图标;模块内消费——审查修复:原
1418
* export 无外部消费方,收窄为非导出) */
@@ -17,7 +21,19 @@ const OP_LABEL: Record<string, string> = {
1721
enrich: "📚 补充",
1822
};
1923

20-
interface Props {
24+
/**
25+
* 批 3(规格 §6.2):本列已接入列基础设施。两个新 prop 均为可选 ⇒ 组件在
26+
* 没有页面注入时行为自足(宽度回落到注册表默认值),但**折叠态必须注入
27+
* onExpand**——否则 26px 窄条点了不展开(正是 J1-3 那类交互死局的形态)。
28+
*/
29+
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;
2137
sessions: ChatSession[];
2238
tasks: AiTaskRecord[];
2339
/** 当前选中(chat 段会话 id / task 段任务 id) */
@@ -41,12 +57,18 @@ function fmtTime(unix: number): string {
4157
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
4258
}
4359

44-
export default function ChatSidebar(props: Props) {
60+
export default function ChatSidebar(props: ChatSidebarProps) {
4561
const {
62+
width = columnSpec("chat-sidebar").default,
63+
folded = false,
64+
onExpand,
4665
sessions, tasks, activeChatId, activeTaskId,
4766
onSelectChat, onSelectTask, onNewChat, onNewKbChat, onRenameChat, onDeleteChat,
4867
sessionTitles, noteTitles,
4968
} = props;
69+
// 批 3:折叠态与其它列同款——整列换成 ColumnBar 窄条(26px,点击走 expand())。
70+
// 顶层仍是**恰好一个**元素(不是 Fragment):根 flex 的子元素数不变 ⇒ 不改 flex 分配。
71+
if (folded) return <ColumnBar icon="💬" title="对话" onClick={() => onExpand?.()} />;
5072
const itemBase: React.CSSProperties = {
5173
padding: "6px 8px",
5274
borderRadius: 6,
@@ -62,7 +84,7 @@ export default function ChatSidebar(props: Props) {
6284
color: "#374151",
6385
};
6486
return (
65-
<div style={{ width: 240, flexShrink: 0, borderRight: "1px solid #e5e7eb", display: "flex", flexDirection: "column", minHeight: 0 }}>
87+
<div style={{ width, flexShrink: 0, borderRight: "1px solid #e5e7eb", display: "flex", flexDirection: "column", minHeight: 0 }}>
6688
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 8px 4px" }}>
6789
<span style={{ fontSize: 12, fontWeight: 600, color: "#6b7280" }}>💬 对话</span>
6890
<div style={{ display: "flex", gap: 2 }}>

‎app/src/pages/ChatPage.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import ChatMessageList from "../components/ChatMessageList";
1919
import ChatComposer from "../components/ChatComposer";
2020
import TaskConversationView from "../components/TaskConversationView";
2121
import useChatStream from "../hooks/useChatStream";
22+
// 批 3(规格 §6.2):AI 侧栏列——规格取自注册表,运行时与宽度记忆由 hook 执行
23+
import { useColumnLayout } from "../hooks/useColumnLayout";
24+
import { columnSpec } from "../shell/columnRegistry";
2225
// v0.16.1:对话「另存为笔记」——双入口(顶栏整段 / AI 消息级)+ 转写纯函数
2326
import ChatSaveNoteDialog from "../components/ChatSaveNoteDialog";
2427
import { buildConversationMarkdown } from "../utils/chatTranscript";
@@ -99,6 +102,8 @@ export default function ChatPage(props: Props) {
99102
// F3(审查竞态):消息装载序号——并发装载只认最新(快速切会话时旧会话的
100103
// 异步返回不得覆盖新会话消息;与 selectChat 的同步 set 无关,纯防错序)
101104
const loadSeq = useRef(0);
105+
// 批 3(规格 §6.2):AI 侧栏列——宽度记忆/窄窗自动折叠由 hook 执行(本页不建规格)
106+
const chatCol = useColumnLayout("chat-sidebar", columnSpec("chat-sidebar"));
102107

103108
const loadMessages = useCallback(async (sessionId: number) => {
104109
const my = ++loadSeq.current;
@@ -402,6 +407,7 @@ export default function ChatPage(props: Props) {
402407
return (
403408
<div style={{ height: "100%", display: "flex", minHeight: 0 }}>
404409
<ChatSidebar
410+
width={chatCol.width} folded={chatCol.folded} onExpand={chatCol.expand}
405411
sessions={sessions}
406412
tasks={tasks}
407413
activeChatId={activeChatId}

‎app/src/pages/GoalsPage.test.tsx‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context 目标左列接线守卫(批 3 T9 / 规格 §6.2「🎯 目标 左列:接入列基础设施;
4+
* 默认 380 → **320**」+ M4)。
5+
*
6+
* Why 用**真执行器 + 真注册表规格**(不是复刻一个假列):本文件要证的正是
7+
* 「`useColumnLayout("goals-left", columnSpec("goals-left"))` 的结果真的走到了 DOM」,
8+
* 且默认值确实从 380 变成规格的 320(`NotesReadingColumn.outline.test.tsx` 同款口径)。
9+
*
10+
* 判据(每条都配了能红的变异体,见 task-9-report §变异体实测):
11+
* ① 未记忆时左列宽 = 注册表默认 320(接线前写死 380 ⇒ 红);
12+
* ② 窄窗(< `BREAKPOINTS.twoCol`)自动折叠 → 26px 窄条;点窄条走 expand() 能展开;
13+
* ③ 拖拽手柄(`column-resizer`)在列上 —— §6.2 要求本列「接入列基础设施」而不是只换个数。
14+
*
15+
* ⚠️ 仪器边界:jsdom 不排版 ⇒ 只判 `style.width` 与元素有无,**不判像素**(归 T14 探针);
16+
* 全部 IPC 走 mock(不连真实数据 —— 测试隔离纪律);视口在 afterEach 复原。
17+
*/
18+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
20+
import { columnSpec } from "../shell/columnRegistry";
21+
22+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
23+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
24+
25+
import GoalsPage from "./GoalsPage";
26+
27+
/** 页面根 → 行容器(`flex:1` 那一层)→ 左列(折叠态是 column-bar 窄条) */
28+
const leftColumn = (root: Element | null) =>
29+
(root?.children[1]?.firstElementChild ?? null) as HTMLElement | null;
30+
31+
beforeEach(() => {
32+
// jsdom 默认视口恰为 1024 < twoCol(1100) ⇒ 不显式声明会让左列静默变折叠态
33+
// (批 3 T5 实测;1280 = 规格 §1 决策 17 的默认窗宽)。断言不改,改的只是测试环境。
34+
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1280 });
35+
window.localStorage.clear();
36+
invokeMock.mockReset();
37+
invokeMock.mockResolvedValue([]); // list_goals / list_note_groups / list_goal_graduations 全空
38+
});
39+
afterEach(() => {
40+
cleanup();
41+
window.localStorage.clear();
42+
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024 });
43+
});
44+
45+
describe("GoalsPage 左列接线(规格 §6.2)", () => {
46+
it("① 未记忆时 = 注册表默认(320,接线前写死 380)+ 拖拽手柄在列上", () => {
47+
expect(columnSpec("goals-left").default).toBe(320); // 规格锚点:注册表被改错先在这里红
48+
const { container } = render(<GoalsPage />);
49+
expect(leftColumn(container.firstElementChild)?.style.width).toBe("320px");
50+
expect(screen.getByTestId("column-resizer")).toBeTruthy();
51+
});
52+
53+
it("② localStorage 记忆的列宽走到 DOM", () => {
54+
window.localStorage.setItem("layout:col-width:goals-left", "400");
55+
const { container } = render(<GoalsPage />);
56+
expect(leftColumn(container.firstElementChild)?.style.width).toBe("400px");
57+
});
58+
59+
it("③ 窄窗自动折叠渲染窄条;点窄条走 expand() 能展开(J1-3 死局形态)", () => {
60+
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024 });
61+
const { container } = render(<GoalsPage />);
62+
expect(leftColumn(container.firstElementChild)?.getAttribute("data-testid")).toBe("column-bar");
63+
fireEvent.click(screen.getByTestId("column-bar"));
64+
expect(leftColumn(container.firstElementChild)?.style.width).toBe("320px");
65+
});
66+
});

‎app/src/pages/GoalsPage.tsx‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
import { useCallback, useEffect, useState } from "react";
1010
import { invoke } from "@tauri-apps/api/core";
1111
import { useDbRefresh } from "../hooks/useDbRefresh";
12+
// 批 3(规格 §6.2「目标 左列:接入列基础设施;默认 380 → 320」):本页不再自建列宽——
13+
// 规格取自注册表,运行时/宽度记忆由 hook 执行(默认 320,可拖 240..420,窄窗自动折叠)
14+
import { useColumnLayout } from "../hooks/useColumnLayout";
15+
import { columnSpec } from "../shell/columnRegistry";
16+
import ColumnResizer from "../components/ColumnResizer";
17+
import ColumnBar from "../components/ColumnBar";
1218
import type { GoalCardView, GraduationReport } from "../types/goals";
1319
import GoalCard from "../components/GoalCard";
1420
import GoalDetail from "../components/GoalDetail";
@@ -27,6 +33,8 @@ export default function GoalsPage() {
2733
// v0.18.1:毕业档案(快照永久保留——目标删除后仍可读;REQ-255/256)
2834
const [archives, setArchives] = useState<GraduationReport[]>([]);
2935
const [archiveOpen, setArchiveOpen] = useState<number | null>(null);
36+
// 批 3(规格 §6.2):目标左列——今日硬编码 380,注册表口径改为默认 320(M4)
37+
const goalsCol = useColumnLayout("goals-left", columnSpec("goals-left"));
3038

3139
const load = useCallback(async () => {
3240
try {
@@ -77,7 +85,12 @@ export default function GoalsPage() {
7785
</div>
7886

7987
<div style={{ flex: 1, minHeight: 0, display: "flex" }}>
80-
<div style={{ width: 380, overflow: "auto", padding: 12, borderRight: "1px solid #e5e7eb", boxSizing: "border-box", flexShrink: 0 }}>
88+
{/* 批 3:折叠态与其它列同款(26px 窄条,点击走 expand()——不是 setManualFolded,
89+
否则窄窗自动折叠下点了不展开) */}
90+
{goalsCol.folded ? (
91+
<ColumnBar icon="🎯" title="目标列表" onClick={goalsCol.expand} />
92+
) : (
93+
<div style={{ width: goalsCol.width, overflow: "auto", padding: 12, borderRight: "1px solid #e5e7eb", boxSizing: "border-box", flexShrink: 0 }}>
8194
{err && <p style={{ fontSize: 12, color: "#dc2626" }}>{err}</p>}
8295
{loaded && cards.length === 0 && (
8396
<div style={{ padding: 24, textAlign: "center" }}>
@@ -122,6 +135,8 @@ export default function GoalsPage() {
122135
</div>
123136
)}
124137
</div>
138+
)}
139+
<ColumnResizer onResize={goalsCol.resizeBy} onReset={goalsCol.resetWidth} />
125140
<div style={{ flex: 1, minWidth: 0 }}>
126141
{selectedId != null ? (
127142
<GoalDetail

‎app/src/pages/SettingsPage.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ import AsrConfusionPanel from "../components/AsrConfusionPanel";
3333
import ProofreadToggle from "../components/ProofreadToggle";
3434
// v0.20.4(REQ-304):web 扩展收件服务面板
3535
import WebInboxPanel from "../components/WebInboxPanel";
36+
// 批 3(规格 §6.2「设置 主列:现 720 左对齐 → 改居中(860)」):阅读舒适宽取自注册表。
37+
// ⚠️ 本列**不走** useColumnLayout——注册表它的 min=max=0(不参与拖拽),
38+
// clamp(860, 0, 0) 会得到 0 ⇒ 只取规格的 default 值
39+
import { columnSpec } from "../shell/columnRegistry";
3640

3741
const panel: React.CSSProperties = { border: "1px solid #e5e7eb", borderRadius: 8, padding: 12 };
3842
const groupTitle: React.CSSProperties = {
@@ -56,7 +60,7 @@ interface Props {
5660
export default function SettingsPage({ active = true }: Props) {
5761
return (
5862
<div style={{ height: "calc(100vh - var(--ed-nav-h))", overflowY: "auto" }}>
59-
<div style={{ maxWidth: 720, padding: "12px 16px 24px" }}>
63+
<div style={{ maxWidth: columnSpec("settings-main").default, margin: "0 auto", padding: "12px 16px 24px" }}>
6064
<div style={{ fontSize: 13, color: "#6b7280", marginBottom: 4 }}>
6165
应用配置与管理。改动即时生效,课堂助手下次使用时读取最新值。
6266
</div>

0 commit comments

Comments
 (0)