Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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+ 周期实证的恢复路径)
Expand Down
17 changes: 12 additions & 5 deletions emrg/gui/renderer/src/components/Shell.tsx
Original file line numberDiff line numberDiff line change
@@ -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:左侧挂载 <Sidebar>(当前空数据,Batch 5 接线 daemon 事件)。
* 后续批次逐个替换为真实组件:TranscriptView / ResultPanel / Dialogs。
*/
export function Shell() {
const { t } = useI18n();
Expand All@@ -15,9 +17,14 @@ export function Shell() {
{t("shell.batch0Notice")}
</span>
</header>
<main className="react-shell-main">
<p className="react-shell-placeholder">{t("shell.placeholder")}</p>
</main>
<div className="react-shell-body">
<aside className="react-shell-sidebar" data-testid="react-shell-sidebar">
<Sidebar openSessions={[]} />
</aside>
<main className="react-shell-main">
<p className="react-shell-placeholder">{t("shell.placeholder")}</p>
</main>
</div>
</div>
);
}
103 changes: 103 additions & 0 deletions emrg/gui/renderer/src/components/Sidebar.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<Parameters<typeof Sidebar>[0]> = {}) {
const utils = render(
<I18nProvider lang="zh">
<Sidebar openSessions={sessions} knownSessions={known} {...props} />
</I18nProvider>,
);
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");
});
});
84 changes: 84 additions & 0 deletions emrg/gui/renderer/src/components/Sidebar.tsx
Original file line numberDiff line numberDiff line change
@@ -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 (
<div className="react-sidebar" data-testid="sidebar">
<div
className="conv-group-label open-sessions-label"
data-testid="open-sessions-label"
hidden
/>
<nav id="open-sessions" className="open-sessions" data-testid="open-sessions" />
</div>
);
}

return (
<div className="react-sidebar" data-testid="sidebar">
<div className="conv-group-label open-sessions-label" data-testid="open-sessions-label">
{t("sidebar.openSessions")}
</div>
<nav id="open-sessions" className="open-sessions" data-testid="open-sessions">
{entries.map((entry) => {
const title = resolveEntryTitle(entry, knownSessions);
const label = labelFn(entry.projectName || "", title, entry.sid);
const active = isActive(entry.sid, activeSid);
return (
<div
key={entry.sid}
className={`conv-item open-session-item${active ? " active" : ""}`}
data-sid={entry.sid}
data-testid="open-session-item"
onClick={() => onSelect && onSelect(entry.sid)}
onContextMenu={(e) => {
e.preventDefault();
onContextMenu && onContextMenu(entry, e);
}}
>
<span className="conv-title">{label}</span>
</div>
);
})}
</nav>
</div>
);
}
93 changes: 93 additions & 0 deletions emrg/gui/renderer/src/lib/sidebar.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
59 changes: 59 additions & 0 deletions emrg/gui/renderer/src/lib/sidebar.ts
Original file line numberDiff line numberDiff line change
@@ -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;
}
Loading