diff --git a/Agent.md b/Agent.md index a2b64be1..28422d31 100644 --- a/Agent.md +++ b/Agent.md @@ -120,7 +120,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` Python: `uv run pytest tests/ -v` (1099) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`; renderer React suite: `cd emrg/gui/renderer && npm run typecheck && npm test` (129 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer) + `npm run build` → `renderer/dist/` +GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`; renderer React suite: `cd emrg/gui/renderer && npm run typecheck && npm test` (151 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer + 12 sidebar + 10 Sidebar) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径) diff --git a/emrg/gui/renderer/src/components/Shell.tsx b/emrg/gui/renderer/src/components/Shell.tsx index 83b80819..b145ed4e 100644 --- a/emrg/gui/renderer/src/components/Shell.tsx +++ b/emrg/gui/renderer/src/components/Shell.tsx @@ -1,9 +1,11 @@ import { useI18n } from "../lib/i18n"; +import { Sidebar } from "./Sidebar"; /** - * Shell — React 骨架占位布局(Batch 0)。 + * Shell — React 骨架占位布局(Batch 0 + Batch 3 Sidebar 挂载)。 * 设计文档 §5 Batch 0 项 4:错误边界/横幅/菜单等零风险组件先就位。 - * 后续批次逐个替换为真实组件:Sidebar / TranscriptView / ResultPanel / Dialogs。 + * Batch 3:左侧挂载 (当前空数据,Batch 5 接线 daemon 事件)。 + * 后续批次逐个替换为真实组件:TranscriptView / ResultPanel / Dialogs。 */ export function Shell() { const { t } = useI18n(); @@ -15,9 +17,14 @@ export function Shell() { {t("shell.batch0Notice")} -
-

{t("shell.placeholder")}

-
+
+ +
+

{t("shell.placeholder")}

+
+
); } diff --git a/emrg/gui/renderer/src/components/Sidebar.test.tsx b/emrg/gui/renderer/src/components/Sidebar.test.tsx new file mode 100644 index 00000000..e5db758a --- /dev/null +++ b/emrg/gui/renderer/src/components/Sidebar.test.tsx @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Sidebar } from "./Sidebar"; +import { I18nProvider } from "../lib/i18n"; +import type { OpenSessionEntry, SessionInfo } from "../lib/sidebar"; + +/** + * Sidebar.test.tsx — Sidebar React 组件测试(Batch 3)。 + * 镜像 vanilla sidebar.js renderOpenSessions 行为:label 显隐、条目格式、 + * 点击切换、右键菜单、激活高亮。类名与 vanilla CSS 一致(Batch 5 复用)。 + */ + +const sessions: OpenSessionEntry[] = [ + { sid: "s1", title: "聊天 A", projectName: "emrg", lastActive: "2026-08-26T09:00:00Z" }, + { sid: "s2", projectName: "aitokenpool", lastActive: "2026-08-26T08:00:00Z" }, +]; + +const known: SessionInfo[] = [ + { session_id: "s1", title: "聊天 A" }, + { session_id: "s2", title: "本地会话 B" }, +]; + +function setup(props: Partial[0]> = {}) { + const utils = render( + + + , + ); + return { ...utils }; +} + +describe("Sidebar", () => { + it("有打开会话 → 显示分组 label(sidebar.openSessions)与条目", () => { + setup(); + expect(screen.getByTestId("open-sessions-label")).toHaveTextContent("打开的会话"); + expect(screen.getByTestId("open-sessions-label")).not.toHaveAttribute("hidden"); + expect(screen.getAllByTestId("open-session-item")).toHaveLength(2); + }); + + it("无打开会话 → label hidden + 空 nav(vanilla 行为)", () => { + setup({ openSessions: [] }); + expect(screen.getByTestId("open-sessions-label")).toHaveAttribute("hidden"); + expect(screen.queryAllByTestId("open-session-item")).toHaveLength(0); + }); + + it("条目格式:有标题 → project/title;无标题 → project/sid", () => { + setup(); + const items = screen.getAllByTestId("open-session-item"); + expect(items[0].querySelector(".conv-title")).toHaveTextContent("emrg/聊天 A"); + // s2 无 entry.title → resolveEntryTitle 回退本地 known 标题 + expect(items[1].querySelector(".conv-title")).toHaveTextContent("aitokenpool/本地会话 B"); + }); + + it("无标题且本地未知 → project/sid 降级格式", () => { + setup({ knownSessions: [] }); + const items = screen.getAllByTestId("open-session-item"); + expect(items[0].querySelector(".conv-title")).toHaveTextContent("emrg/聊天 A"); + expect(items[1].querySelector(".conv-title")).toHaveTextContent("aitokenpool/s2"); + }); + + it("点击条目 → onSelect(sid)", async () => { + const onSelect = vi.fn(); + setup({ onSelect }); + await userEvent.click(screen.getAllByTestId("open-session-item")[0]); + expect(onSelect).toHaveBeenCalledWith("s1"); + }); + + it("右键条目 → onContextMenu(entry, event) + preventDefault", async () => { + const onContextMenu = vi.fn(); + setup({ onContextMenu }); + const item = screen.getAllByTestId("open-session-item")[0]; + await userEvent.pointer({ keys: "[MouseRight]", target: item }); + expect(onContextMenu).toHaveBeenCalledTimes(1); + expect(onContextMenu.mock.calls[0][0]).toMatchObject({ sid: "s1" }); + expect(onContextMenu.mock.calls[0][1].defaultPrevented).toBe(true); + }); + + it("activeSid 匹配条目带 .active 高亮", () => { + setup({ activeSid: "s1" }); + const items = screen.getAllByTestId("open-session-item"); + expect(items[0]).toHaveClass("active"); + expect(items[1]).not.toHaveClass("active"); + }); + + it("条目 data-sid 属性(vanilla dataset.sid 对应)", () => { + setup(); + expect(screen.getAllByTestId("open-session-item")[1]).toHaveAttribute("data-sid", "s2"); + }); + + it("排序:lastActive 倒序(新在前)", () => { + setup(); + const items = screen.getAllByTestId("open-session-item"); + expect(items[0]).toHaveAttribute("data-sid", "s1"); // 09:00 > 08:00 + expect(items[1]).toHaveAttribute("data-sid", "s2"); + }); + + it("注入 labelFn 可覆盖默认格式(测试隔离依赖)", () => { + setup({ labelFn: (_p, _t, sid) => `L:${sid}` }); + const items = screen.getAllByTestId("open-session-item"); + expect(items[0].querySelector(".conv-title")).toHaveTextContent("L:s1"); + }); +}); diff --git a/emrg/gui/renderer/src/components/Sidebar.tsx b/emrg/gui/renderer/src/components/Sidebar.tsx new file mode 100644 index 00000000..8502773c --- /dev/null +++ b/emrg/gui/renderer/src/components/Sidebar.tsx @@ -0,0 +1,84 @@ +import { useI18n } from "../lib/i18n"; +import { + isActive, + resolveEntryTitle, + sessionLabel, + sortOpenSessions, + type OpenSessionEntry, + type SessionInfo, +} from "../lib/sidebar"; + +/** + * Sidebar — Batch 3: 对话列表(跨项目打开会话区,P4 slice 2)。 + * 从 vanilla `js/sidebar.js` renderOpenSessions/highlight 迁移为 React 组件。 + * + * - 数据注入(不直接碰 daemon/IPC,Batch 5 接线);类名与 vanilla CSS 一致 + * (.conv-item/.conv-title/.open-sessions),Batch 5 CSS 直接复用。 + * - 点击 → onSelect(sid);右键 → onContextMenu(entry, event)(调用方弹菜单)。 + * - activeSid 匹配条目加 .active(对应 vanilla highlight 逻辑)。 + */ +export interface SidebarProps { + openSessions: OpenSessionEntry[]; + knownSessions?: SessionInfo[]; + activeSid?: string | null; + onSelect?: (sid: string) => void; + onContextMenu?: (entry: OpenSessionEntry, event: React.MouseEvent) => void; + /** 可注入的标题格式化函数(测试用;默认 sessionLabel) */ + labelFn?: (project: string, title: string, sid: string) => string; +} + +export function Sidebar({ + openSessions, + knownSessions = [], + activeSid = null, + onSelect, + onContextMenu, + labelFn = sessionLabel, +}: SidebarProps) { + const { t } = useI18n(); + const entries = sortOpenSessions(openSessions); + + if (!entries.length) { + // vanilla:无打开会话 → label hidden + 空 nav + return ( +
+ + ); + } + + return ( +
+
+ {t("sidebar.openSessions")} +
+ +
+ ); +} diff --git a/emrg/gui/renderer/src/lib/sidebar.test.ts b/emrg/gui/renderer/src/lib/sidebar.test.ts new file mode 100644 index 00000000..bb2f861d --- /dev/null +++ b/emrg/gui/renderer/src/lib/sidebar.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + isActive, + resolveEntryTitle, + sessionLabel, + sortOpenSessions, + type OpenSessionEntry, + type SessionInfo, +} from "./sidebar"; + +/** + * sidebar.test.ts — Sidebar 纯逻辑测试(Batch 3,镜像 vanilla sidebar.js 行为)。 + */ + +describe("sessionLabel", () => { + it("有标题 → project/title 格式", () => { + expect(sessionLabel("emrg", "我的会话", "s1")).toBe("emrg/我的会话"); + }); + + it("无标题 → project/sid 格式(不降级为标题,rant 2026-08-20T22:04:57)", () => { + expect(sessionLabel("emrg", "", "s1")).toBe("emrg/s1"); + }); + + it("project 为空时仍输出 /sid 或 /title 分隔(保持与 vanilla 一致)", () => { + expect(sessionLabel("", "", "s1")).toBe("/s1"); + }); +}); + +describe("resolveEntryTitle", () => { + const known: SessionInfo[] = [ + { session_id: "s1", title: "本地标题" }, + { session_id: "s2" }, + ]; + + it("entry.title 优先(跨项目自带标题覆盖本地)", () => { + expect(resolveEntryTitle({ sid: "s1", title: "跨项目标题" }, known)).toBe("跨项目标题"); + }); + + it("无 entry.title → 回退本地已知会话标题", () => { + expect(resolveEntryTitle({ sid: "s1" }, known)).toBe("本地标题"); + }); + + it("两者皆无 → 空串", () => { + expect(resolveEntryTitle({ sid: "s2" }, known)).toBe(""); + expect(resolveEntryTitle({ sid: "nope" }, known)).toBe(""); + }); + + it("knownSessions 为空数组 → 空串", () => { + expect(resolveEntryTitle({ sid: "s1", title: "跨项目标题" }, [])).toBe("跨项目标题"); + expect(resolveEntryTitle({ sid: "s1" }, [])).toBe(""); + }); +}); + +describe("sortOpenSessions", () => { + const mk = (sid: string, lastActive?: string): OpenSessionEntry => ({ sid, lastActive }); + + it("lastActive 倒序(最新在前)", () => { + const list = [ + mk("a", "2026-08-26T08:00:00Z"), + mk("b", "2026-08-26T09:00:00Z"), + mk("c", "2026-08-25T10:00:00Z"), + ]; + expect(sortOpenSessions(list).map((e) => e.sid)).toEqual(["b", "a", "c"]); + }); + + it("无时间戳 → 排最后;不修改入参数组", () => { + const list = [mk("a", "2026-08-26T08:00:00Z"), mk("b"), mk("c", "2026-08-25T10:00:00Z")]; + const sorted = sortOpenSessions(list); + expect(sorted.map((e) => e.sid)).toEqual(["a", "c", "b"]); + expect(list.map((e) => e.sid)).toEqual(["a", "b", "c"]); // 入参未变 + }); + + it("非法时间戳按 NaN 处理(排后),空数组幂等", () => { + expect(sortOpenSessions([mk("a", "not-a-date"), mk("b", "2026-08-26T08:00:00Z")]).map((e) => e.sid)).toEqual([ + "b", + "a", + ]); + expect(sortOpenSessions([])).toEqual([]); + }); +}); + +describe("isActive", () => { + it("相等 → true;不等 → false", () => { + expect(isActive("s1", "s1")).toBe(true); + expect(isActive("s1", "s2")).toBe(false); + }); + + it("null/undefined 安全", () => { + expect(isActive(null, "s1")).toBe(false); + expect(isActive("s1", null)).toBe(false); + expect(isActive(undefined, undefined)).toBe(false); + }); +}); diff --git a/emrg/gui/renderer/src/lib/sidebar.ts b/emrg/gui/renderer/src/lib/sidebar.ts new file mode 100644 index 00000000..97e3160b --- /dev/null +++ b/emrg/gui/renderer/src/lib/sidebar.ts @@ -0,0 +1,59 @@ +/** + * sidebar.ts — Batch 3: Sidebar 纯逻辑(从 vanilla `js/sidebar.js` + `js/app.js` 提取)。 + * + * 对话列表 = 跨项目打开会话区(P4 slice 2):条目 = 项目名/会话标题, + * lastActive 倒序(main 已排序,此处防御性稳定排序兜底)。 + * 仅显示标题,不显示 session ID / 消息数(去黑话,sidebar.js 头注)。 + */ + +export interface OpenSessionEntry { + sid: string; + /** 跨项目标题(entry 自带,优先) */ + title?: string; + /** 项目名(空 → 归入当前项目) */ + projectName?: string; + /** ISO 时间戳,用于排序 */ + lastActive?: string; +} + +export interface SessionInfo { + session_id: string; + title?: string; + [k: string]: unknown; +} + +/** 会话条目统一格式:有标题显示 project/title,无标题显示 project/id(rant 2026-08-20T22:04:57)。 */ +export function sessionLabel(project: string, title: string, sid: string): string { + return title ? `${project}/${title}` : `${project}/${sid}`; +} + +/** + * 标题解析:entry.title 优先(跨项目自带),否则回退本地已知会话标题; + * 两者皆无 → 空串(此时 label 降级为 project/sid)。 + * (sidebar.js renderOpenSessions: `entry.title || cur.title || ""`) + */ +export function resolveEntryTitle(entry: OpenSessionEntry, knownSessions: SessionInfo[]): string { + if (entry.title) return entry.title; + const cur = knownSessions.find((s) => s.session_id === entry.sid); + return (cur && cur.title) || ""; +} + +/** + * 打开会话排序:lastActive 倒序(最新在前)。无时间戳 → 视为最早(排后)。 + * 返回新数组(不修改入参)。main 已排序时结果不变(幂等)。 + */ +export function sortOpenSessions(entries: OpenSessionEntry[]): OpenSessionEntry[] { + return [...entries].sort((a, b) => { + const ta = a.lastActive ? Date.parse(a.lastActive) : 0; + const tb = b.lastActive ? Date.parse(b.lastActive) : 0; + if (Number.isNaN(ta) && Number.isNaN(tb)) return 0; + if (Number.isNaN(ta)) return 1; + if (Number.isNaN(tb)) return -1; + return tb - ta; + }); +} + +/** 当前会话高亮判定(sidebar.js highlight: `item.dataset.sid === sid`)。 */ +export function isActive(sid: string | null | undefined, activeSid: string | null | undefined): boolean { + return !!sid && sid === activeSid; +}