Skip to content

Commit cbc1583

Browse files
committed
refactor(chat): extract launch menu from chat page
1 parent ebac9d7 commit cbc1583

6 files changed

Lines changed: 244 additions & 114 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context ChatLaunchMenu.test.tsx —— T13-b「拆件」提交的**行为等价性证据**(B7)。
4+
*
5+
* Why(这条判据为什么必须在拆件提交里):`ChatPage.tsx` 只有间接覆盖,而拆件动的是可交互的
6+
* JSX(开合状态 + 两处回调 + 三个 testid)。若拆件与迁移合并成一个提交,「行为等价」就只能靠
7+
* 读 diff 断言 —— B7 的裁决理由逐字就是这一点。故本文件在**拆件提交**里落地,并把拆件前的
8+
* 三条可观察行为(开合 / 选中 / 点空白收起)钉成判据。
9+
*
10+
* 五条判据:
11+
* ① 初始**收起**:面板与两个条目都不在树上(`queryByTestId` 为 null),只有触发钮;
12+
* ② 点触发钮 ⇒ 面板出现,且**两个条目都在**、`data-app-menu` 标记保留(拆件前逐字有);
13+
* ③ 点「AI 精修」⇒ `onLaunch("refine")` **恰好一次**,且面板**自行收起**;
14+
* ④ 点「AI 知识补充」⇒ `onLaunch("enrich")` **恰好一次**,且面板**自行收起**;
15+
* ⑤ 点透明点击层 ⇒ `onLaunch` **零次**(它不是选中),面板收起。
16+
*
17+
* 副作用:只挂 React 树(jsdom),不读 store、不发 IPC。边界:本仓未装 jest-dom ⇒ 只用原生 DOM API。
18+
*/
19+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
20+
import { afterEach, describe, expect, it, vi } from "vitest";
21+
import ChatLaunchMenu from "./ChatLaunchMenu";
22+
23+
afterEach(cleanup);
24+
25+
const OPEN = "task-launch-open";
26+
const MENU = "task-launch-menu";
27+
const REFINE = "task-launch-refine";
28+
const ENRICH = "task-launch-enrich";
29+
30+
describe("ChatLaunchMenu:拆件后的行为与拆件前逐条一致", () => {
31+
it("① 初始收起:只有触发钮在树上,面板与两个条目都不存在", () => {
32+
render(<ChatLaunchMenu onLaunch={() => undefined} />);
33+
expect(screen.getByTestId(OPEN).textContent).toBe("✨ 发起任务 ▾");
34+
expect(screen.queryByTestId(MENU)).toBeNull();
35+
expect(screen.queryByTestId(REFINE)).toBeNull();
36+
expect(screen.queryByTestId(ENRICH)).toBeNull();
37+
});
38+
39+
it("② 点触发钮 ⇒ 面板打开;两个条目都在;`data-app-menu` 标记保留", () => {
40+
render(<ChatLaunchMenu onLaunch={() => undefined} />);
41+
fireEvent.click(screen.getByTestId(OPEN));
42+
const menu = screen.getByTestId(MENU);
43+
expect(menu.getAttribute("data-app-menu")).toBe("");
44+
expect(screen.getByTestId(REFINE).textContent).toBe("✨ AI 精修(会话 → 精修成笔记)");
45+
expect(screen.getByTestId(ENRICH).textContent).toBe("📚 AI 知识补充(笔记 → 补外部知识)");
46+
});
47+
48+
it("③ 点「AI 精修」⇒ 回调恰一次 refine,且面板自行收起", () => {
49+
const onLaunch = vi.fn();
50+
render(<ChatLaunchMenu onLaunch={onLaunch} />);
51+
fireEvent.click(screen.getByTestId(OPEN));
52+
fireEvent.click(screen.getByTestId(REFINE));
53+
expect(onLaunch.mock.calls).toEqual([["refine"]]);
54+
expect(screen.queryByTestId(MENU)).toBeNull();
55+
});
56+
57+
it("④ 点「AI 知识补充」⇒ 回调恰一次 enrich,且面板自行收起", () => {
58+
const onLaunch = vi.fn();
59+
render(<ChatLaunchMenu onLaunch={onLaunch} />);
60+
fireEvent.click(screen.getByTestId(OPEN));
61+
fireEvent.click(screen.getByTestId(ENRICH));
62+
expect(onLaunch.mock.calls).toEqual([["enrich"]]);
63+
expect(screen.queryByTestId(MENU)).toBeNull();
64+
});
65+
66+
it("⑤ 点透明点击层 ⇒ 不触发选中,面板收起", () => {
67+
const onLaunch = vi.fn();
68+
const { container } = render(<ChatLaunchMenu onLaunch={onLaunch} />);
69+
fireEvent.click(screen.getByTestId(OPEN));
70+
// 点击层是根节点的**首个子节点的首个子节点**(`relative` 壳 → 触发钮 / 点击层 / 面板):
71+
// 用结构定位而不是类名 —— 拆件后没有类名,testid 也不该为点击层新造(原来就没有)。
72+
const scrim = container.firstElementChild?.children[1] as HTMLElement | undefined;
73+
expect(scrim, "点击层不在预期位置(结构变了,判据要跟着改)").toBeTruthy();
74+
expect(scrim?.getAttribute("data-testid")).toBeNull();
75+
fireEvent.click(scrim as HTMLElement);
76+
expect(onLaunch.mock.calls).toEqual([]);
77+
expect(screen.queryByTestId(MENU)).toBeNull();
78+
});
79+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* ChatLaunchMenu — 对话页工具条上的「发起任务」锚定菜单(v0.16.1 任务对话化)。
3+
*
4+
* @ai-context: Why 独立成件:它原先是 `pages/ChatPage.tsx` 内联的一段 JSX(含自己的
5+
* 开合状态、透明点击层与锚定面板)。`ChatPage.tsx` 当时 **599/600 行**
6+
* (本批的贴边文件之一),按控制方裁决 **B7「先拆件、再迁移」** 必须先把它
7+
* 摘出来,才允许动 `ChatPage.tsx` 的其它部分 —— 否则「拆件」与「迁移」会
8+
* 混在一个提交里,行为等价性无法用测试证明。
9+
* 本件是**纯展示 + 一个开合状态**:不读 store、不发 IPC、不写磁盘,
10+
* 唯一的对外语义是「用户选了 refine 还是 enrich」(`onLaunch`)。
11+
* @ai-context: 语义边界(与 `Modal` 的分工,ADR-033 §7):它是**锚定菜单**而不是对话框 ——
12+
* 没有对话框语义(无标题、无焦点陷阱、无 ESC 栈),所以按 B1 只把层级落
13+
* `zIndex("popover")`(200),**不迁 `<Modal>`**。
14+
* @ai-context: 层级:透明点击层与锚定面板**同档 200**(原为两个裸数字 30 / 31)。
15+
* 面板「在点击层之上」由 **DOM 序**保证(同档位下后者胜),不靠更大的数字 ——
16+
* 这正是六档标尺要消灭的「17 个各不相同的值」。行内 `style` 只承载**布局与层级**
17+
* (ADR-033 §4 允许),本件没有可被覆盖的原语语义(不是 `Button`/`Surface`/`Text`)。
18+
*/
19+
import { useState } from "react";
20+
import { zIndex } from "../../ui/zIndex";
21+
22+
export interface ChatLaunchMenuProps {
23+
/** 选中一个任务种类(父层据此打开发起对话框并清空目标预选) */
24+
onLaunch: (kind: "refine" | "enrich") => void;
25+
}
26+
27+
/**
28+
* 渲染发起任务的锚菜单。
29+
*
30+
* 开合状态**留在本件内部**(父层不需要知道菜单开没开):`ChatPage` 原来的
31+
* `launchMenuOpen` 只在「选中/点空白」两处被置 false,且这两处的另一动作分别由本回调和
32+
* 点击层自身完成 ⇒ 状态上行只会多一份跨层耦合,不改变任何可观察行为。
33+
*/
34+
export default function ChatLaunchMenu({ onLaunch }: ChatLaunchMenuProps) {
35+
const [open, setOpen] = useState(false);
36+
37+
return (
38+
<div style={{ position: "relative" }}>
39+
<button
40+
type="button"
41+
data-testid="task-launch-open"
42+
onClick={() => setOpen((v) => !v)}
43+
style={{ fontSize: 12, cursor: "pointer", padding: "3px 10px", borderRadius: 6, border: "1px solid #d1d5db", background: "#fff", color: "#374151" }}
44+
title="在对话中发起 AI 任务(也支持 '/refine' '/enrich')"
45+
>
46+
✨ 发起任务 ▾
47+
</button>
48+
{open && (
49+
<>
50+
<div onClick={() => setOpen(false)} style={{ position: "fixed", inset: 0, zIndex: zIndex("popover"), background: "transparent" }} />
51+
<div
52+
data-testid="task-launch-menu"
53+
data-app-menu=""
54+
style={{ position: "absolute", top: "100%", left: 0, zIndex: zIndex("popover"), background: "#fff", border: "1px solid #e5e7eb", borderRadius: 6, padding: 4, boxShadow: "0 4px 12px rgba(0,0,0,0.12)", minWidth: 180 }}
55+
>
56+
<button
57+
type="button"
58+
data-testid="task-launch-refine"
59+
style={{ display: "block", width: "100%", textAlign: "left", border: "none", background: "none", borderRadius: 6, padding: "6px 10px", fontSize: 12.5, cursor: "pointer", color: "#374151" }}
60+
onClick={() => { setOpen(false); onLaunch("refine"); }}
61+
>
62+
✨ AI 精修(会话 → 精修成笔记)
63+
</button>
64+
<button
65+
type="button"
66+
data-testid="task-launch-enrich"
67+
style={{ display: "block", width: "100%", textAlign: "left", border: "none", background: "none", borderRadius: 6, padding: "6px 10px", fontSize: 12.5, cursor: "pointer", color: "#374151" }}
68+
onClick={() => { setOpen(false); onLaunch("enrich"); }}
69+
>
70+
📚 AI 知识补充(笔记 → 补外部知识)
71+
</button>
72+
</div>
73+
</>
74+
)}
75+
</div>
76+
);
77+
}

‎app/src/pages/ChatPage.tsx‎

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import { buildConversationMarkdown } from "../utils/chatTranscript";
2828
// v0.16.1:任务对话化——对话内发起任务(按钮 + '/' 命令)+ 线程任务卡 + 追问预填
2929
import TaskLaunchDialog, { type LaunchTargetRow } from "../components/TaskLaunchDialog";
3030
import TaskThreadCard from "../components/TaskThreadCard";
31+
// 批 4 T13-b(B7):发起工具条拆件到 components/chat/(本页 599/600 贴边,动它必须先拆)
32+
import ChatLaunchMenu from "../components/chat/ChatLaunchMenu";
3133
import { buildTaskFollowUpPrompt } from "../utils/taskFollowUp";
3234
// 2026-09-09 批 1:任务标题统一按类别解析(taskRefLabel——笔记级精修
3335
// ref_id=笔记 id,仅按 opType 会错查会话标题表;enrich 恒笔记级)
@@ -88,7 +90,6 @@ export default function ChatPage(props: Props) {
8890
const [noteGroups, setNoteGroups] = useState<NoteGroup[]>([]);
8991
const [saveDialog, setSaveDialog] = useState<{ initialTitle: string; content: string } | null>(null);
9092
// v0.16.1:任务对话化——发起面板(工具条下拉)/ 启动对话框 / 目标清单
91-
const [launchMenuOpen, setLaunchMenuOpen] = useState(false);
9293
const [launchDialog, setLaunchDialog] = useState<"refine" | "enrich" | null>(null);
9394
const [launchTargetId, setLaunchTargetId] = useState<number | null>(null);
9495
const [sessionRows, setSessionRows] = useState<LaunchTargetRow[]>([]);
@@ -229,7 +230,6 @@ export default function ChatPage(props: Props) {
229230
const onTaskLaunched = useCallback((taskId: number) => {
230231
setLaunchDialog(null);
231232
setLaunchTargetId(null);
232-
setLaunchMenuOpen(false);
233233
void reloadTasks();
234234
// 启动后切到任务对话视图(进度/轨迹即时可见;聊天视图内有同款线程卡)
235235
void selectTask(taskId);
@@ -488,31 +488,10 @@ export default function ChatPage(props: Props) {
488488
{/* 内容区 */}
489489
{activeChatId !== null && activeSession && (
490490
<>
491-
{/* v0.16.1:任务对话化——发起工具条(按钮)/ '-' 命令同义 */}
491+
{/* v0.16.1:任务对话化——发起工具条(按钮)/ '/' 命令同义。
492+
批 4 T13-b:菜单(含透明点击层与锚定面板)已拆到 ChatLaunchMenu。 */}
492493
<div style={{ display: "flex", alignItems: "center", gap: 6, padding: "0 16px", flexShrink: 0 }}>
493-
<div style={{ position: "relative" }}>
494-
<button
495-
data-testid="task-launch-open"
496-
onClick={() => setLaunchMenuOpen((v) => !v)}
497-
style={{ fontSize: 12, cursor: "pointer", padding: "3px 10px", borderRadius: 6, border: "1px solid #d1d5db", background: "#fff", color: "#374151" }}
498-
title="在对话中发起 AI 任务(也支持 '/refine' '/enrich')"
499-
>
500-
✨ 发起任务 ▾
501-
</button>
502-
{launchMenuOpen && (
503-
<>
504-
<div onClick={() => setLaunchMenuOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 30, background: "transparent" }} />
505-
<div data-testid="task-launch-menu" data-app-menu="" style={{ position: "absolute", top: "100%", left: 0, zIndex: 31, background: "#fff", border: "1px solid #e5e7eb", borderRadius: 6, padding: 4, boxShadow: "0 4px 12px rgba(0,0,0,0.12)", minWidth: 180 }}>
506-
<button data-testid="task-launch-refine" style={{ display: "block", width: "100%", textAlign: "left", border: "none", background: "none", borderRadius: 6, padding: "6px 10px", fontSize: 12.5, cursor: "pointer", color: "#374151" }} onClick={() => { setLaunchDialog("refine"); setLaunchTargetId(null); setLaunchMenuOpen(false); }}>
507-
✨ AI 精修(会话 → 精修成笔记)
508-
</button>
509-
<button data-testid="task-launch-enrich" style={{ display: "block", width: "100%", textAlign: "left", border: "none", background: "none", borderRadius: 6, padding: "6px 10px", fontSize: 12.5, cursor: "pointer", color: "#374151" }} onClick={() => { setLaunchDialog("enrich"); setLaunchTargetId(null); setLaunchMenuOpen(false); }}>
510-
📚 AI 知识补充(笔记 → 补外部知识)
511-
</button>
512-
</div>
513-
</>
514-
)}
515-
</div>
494+
<ChatLaunchMenu onLaunch={(kind) => { setLaunchDialog(kind); setLaunchTargetId(null); }} />
516495
<span style={{ fontSize: 11, color: "#9ca3af" }}>试试 '/refine' '/enrich' 快捷命令</span>
517496
</div>
518497
{/* v0.16.1:线程任务卡(进行中实时 + 完成可追问);v0.17.0 精修

0 commit comments

Comments
 (0)