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
4 changes: 2 additions & 2 deletions Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request
- Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand)
- Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端"
- Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout)
- Unit tests `npm test` (234: 44 daemon_client + 19 conn-manager + 22 app-commands + 112 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- Unit tests `npm test` (235: 44 daemon_client + 19 conn-manager + 22 app-commands + 113 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- **Scheduled tasks** — Task generalization + CRUD (rant 2026-08-12T18:23:15, #709/#710/#711)
- Task handler generalized: `TaskHandler` (renamed from `EvolutionHandler`), repo-configured self-heal for any project, template lookup builtin → `~/.emrg/task-templates/<name>.md` → fallback
- Daemon commands: `task_create/update/delete` + `task_template_create/list/update/delete` (tasks stored in `~/.emrg/tasks.yml`, custom type templates in `~/.emrg/task-templates/`)
Expand Down Expand Up @@ -119,7 +119,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (777) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (234: 44 daemon_client + 19 conn-manager + 22 app-commands + 112 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
GUI: `cd emrg/gui && npm test` (235: 44 daemon_client + 19 conn-manager + 22 app-commands + 113 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
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 路径不受影响)

Expand Down
9 changes: 8 additions & 1 deletion emrg/gui/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,14 @@
</section>
<section class="side-panel hidden" id="panel-projects" data-panel-body="projects">
<div class="side-panel-title" data-i18n="nav.projectsPanel">项目</div>
<div class="side-panel-body" data-i18n="nav.placeholder">面板内容将在后续版本填充</div>
<div class="side-panel-body">
<!-- rant 14:10:14 P5:项目面板(复用 listProjects/listProjectSessions/registerProject/removeProject IPC) -->
<div class="hint" style="margin-bottom:6px;" data-i18n="projects.hint">查看会话 / 添加项目 / 删除(保留磁盘数据)</div>
<div id="project-list" class="task-list"></div>
<div style="display:flex;gap:8px;margin-top:8px;">
<button type="button" id="project-add-btn" class="btn btn-ghost" style="flex:1;" data-i18n="projects.add">+ 添加项目</button>
</div>
</div>
</section>
<section class="side-panel hidden" id="panel-tasks" data-panel-body="tasks">
<div class="side-panel-title" data-i18n="nav.tasksPanel">任务</div>
Expand Down
28 changes: 27 additions & 1 deletion emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,16 @@ const App = (() => {
}
}

// 项目面板(rant 14:10:14 P5:打开即加载项目列表)
async function openProjectsPanel() {
if (state.activePanel !== "projects") switchPanel("projects");
try {
await Dialogs.renderProjectList();
} catch (e) {
Chat.addSystemMessage(_t("projects.addFailed", { msg: e.message }));
}
}

async function doTrigger(name) {
const n = String(name || "").trim();
if (!n) return;
Expand Down Expand Up @@ -1487,12 +1497,14 @@ const App = (() => {
for (const p of SIDE_PANELS) {
$(`nav-${p}`)?.addEventListener("click", () => {
switchPanel(p);
// P2:面板打开时加载对应数据(settings 走 showSettings 刷新全部;tasks 走任务加载
// P2:面板打开时加载对应数据(settings 走 showSettings 刷新全部;tasks/projects 走各自加载
if (p === "settings" && state.activePanel === "settings") {
loadEvolutionSummary();
Dialogs.showSettings();
} else if (p === "tasks" && state.activePanel === "tasks") {
openTasksPanel();
} else if (p === "projects" && state.activePanel === "projects") {
openProjectsPanel();
}
});
}
Expand Down Expand Up @@ -1537,6 +1549,19 @@ const App = (() => {
document.querySelectorAll("#settings-tabs .panel-tab").forEach((tab) => {
tab.addEventListener("click", () => switchSettingsTab(tab.dataset.settingsTab));
});
// rant 14:10:14 P5:项目面板「添加项目」→ 选目录 → 注册
$("project-add-btn")?.addEventListener("click", async () => {
try {
const res = await window.emrg.pickProjectDir();
if (res && res.path) {
await window.emrg.registerProject({ path: res.path });
Chat.addSystemMessage(_t("projects.added", { path: res.path }));
await Dialogs.renderProjectList();
}
} catch (e) {
Chat.addSystemMessage(_t("projects.addFailed", { msg: e.message }));
}
});

// 设置/首启对话框:Enter 提交(与重命名/模型表单一致的交互)
const enterToSave = (fn) => (e) => {
Expand Down Expand Up @@ -1695,6 +1720,7 @@ const App = (() => {
switchPanel, // rant 14:10:14 P1:侧边栏面板切换(导出供测试)
switchSettingsTab, // rant 14:10:14 P2:设置面板 tab 切换(导出供测试)
openTasksPanel, // rant 14:10:14 P3:任务面板打开 + 加载(导出供测试)
openProjectsPanel, // rant 14:10:14 P5:项目面板打开 + 加载(导出供测试)
refreshSessions,
showConvMenu,
handleEvent,
Expand Down
93 changes: 91 additions & 2 deletions emrg/gui/renderer/js/dialogs.js
Original file line number Diff line number Diff line change
Expand Up @@ -959,7 +959,7 @@ const Dialogs = (() => {
return p && (p.name === "emrg" || p.name === "emrg-task");
}

function confirmDeleteProject(p) {
function confirmDeleteProject(p, onDone) {
if (isProtectedProject(p)) {
showConfirm(_t("deleteProject.title"), _t("deleteProject.protectedBody", { name: p.name }), { okText: _t("dlg.gotIt"), danger: false });
return;
Expand All @@ -982,7 +982,8 @@ const Dialogs = (() => {
if (remaining.length > 0) await App.switchSession(remaining[0].sid, { silent: true });
else await App.newSession();
}
showOpenSessionDialog(); // 刷新项目列表
if (typeof onDone === "function") onDone();
else showOpenSessionDialog(); // 刷新项目列表
} catch (e) {
Chat.addSystemMessage(_t("deleteProject.failed", { msg: e.message }));
}
Expand Down Expand Up @@ -1038,6 +1039,92 @@ const Dialogs = (() => {
});
}

// ── 项目面板(rant 14:10:14 P5:侧边栏项目入口,复用项目 IPC) ──
/** 项目面板:列表(名称/路径/auto_evolve 徽标/最近活跃)+ 查看会话 + 删除 + 添加。 */
async function renderProjectList() {
const list = $("project-list");
if (!list) return;
list.innerHTML = ""; // 显式清空(测试沙箱 innerHTML 非空赋值不清 children;真实 DOM 语义一致)
list.innerHTML = `<div class="help-row"><span class="help-hint">${_t("dlg.loading")}</span></div>`;
try {
// auto_evolve 徽标:tasks.yml 中有 config.project === 项目名的任务(含 evolution 类型)
const [projects, tasks] = await Promise.all([
window.emrg.listProjects(),
window.emrg.listTasks().catch(() => []),
]);
list.innerHTML = "";
if (!projects || projects.length === 0) {
list.innerHTML = `<div class="task-empty">${_t("projects.empty")}</div>`;
return;
}
const evolveProjects = new Set(
(tasks || []).filter((t) => t && t.config && t.config.project).map((t) => t.config.project)
);
projects.forEach((p) => {
const row = el("div", { class: "task-row" });
row.appendChild(el("span", { class: "task-name" }, p.name || p.path || "?"));
if (evolveProjects.has(p.name)) {
row.appendChild(el("span", { class: "task-badge" }, `⚡ ${_t("projects.autoEvolve")}`));
}
const hints = [];
if (p.path) hints.push(p.path);
const act = relTime(p.latest_session_at);
if (act) hints.push(act);
row.appendChild(el("span", { class: "task-hint" }, hints.join(" · ")));
const actions = el("span", { class: "task-actions" });
// 查看会话(复用 listProjectSessions)
const sessBtn = el("button", { type: "button", class: "model-action-btn", title: _t("projects.viewSessions") }, _t("projects.viewSessions"));
sessBtn.addEventListener("click", () => showProjectSessionsInPanel(p));
actions.appendChild(sessBtn);
// 删除(受保护守卫 + 确认;删除后刷新面板)
const delBtn = el("button", { type: "button", class: "model-action-btn danger", title: _t("deleteProject.delete") }, _t("deleteProject.delete"));
delBtn.addEventListener("click", () => confirmDeleteProject(p, () => renderProjectList()));
actions.appendChild(delBtn);
row.appendChild(actions);
list.appendChild(row);
});
} catch (e) {
list.innerHTML = `<div class="help-row"><span class="help-hint">${_t("projects.addFailed", { msg: e.message })}</span></div>`;
}
}

/** 项目面板内嵌:该项目会话列表(created_at 倒序)→ 点击切换会话(带项目 cwd)。 */
async function showProjectSessionsInPanel(project) {
const list = $("project-list");
if (!list) return;
list.innerHTML = ""; // 显式清空(测试沙箱 innerHTML 非空赋值不清 children;真实 DOM 语义一致)
list.innerHTML = `<div class="help-row"><span class="help-hint">${_t("dlg.loading")}</span></div>`;
// 返回按钮 + 标题
const title = el("div", { class: "task-row" });
const backBtn = el("button", { type: "button", class: "model-action-btn", style: "flex-shrink:0;" }, _t("projects.back"));
backBtn.addEventListener("click", () => renderProjectList());
title.appendChild(backBtn);
title.appendChild(el("span", { class: "task-name" }, _t("projects.sessionsOf", { project: project.name || project.path || "" })));
list.appendChild(title);
try {
const frame = await window.emrg.listProjectSessions({ projectPath: project.path });
const sessions = frame.sessions || [];
if (sessions.length === 0) {
list.appendChild(el("div", { class: "task-empty" }, _t("projects.noSessions")));
return;
}
sessions.forEach((s) => {
const row = el("div", { class: "task-row", style: "cursor:pointer;" });
row.appendChild(el("span", { class: "task-name" }, s.title || _t("app.unnamed")));
if (s.session_id === App.state.sessionId) row.appendChild(el("span", { class: "task-badge" }, _t("projects.current")));
const hint = el("span", { class: "task-hint" }, s.session_id || "");
row.appendChild(hint);
row.addEventListener("click", async () => {
// 带项目路径切换(resume 用该项目 cwd,非全局 projectDir)
await App.switchSession(s.session_id, { projectPath: project.path });
});
list.appendChild(row);
});
} catch (e) {
list.appendChild(el("div", { class: "task-empty" }, _t("openSession.loadFailed", { msg: e.message })));
}
}

// ── P5 slice 2:新建会话对话框(选已有项目 → 新建;或新建项目 → 新建) ──
async function showNewSessionDialog() {
const list = $("new-session-list");
Expand Down Expand Up @@ -1123,6 +1210,8 @@ const Dialogs = (() => {
initTaskManagement, // rant 18:23:15 P3:定时任务/自定义类型管理初始化
loadTaskMeta, // rant 18:23:15 P3:任务/类型元数据加载(面板打开/测试复用)
renderTaskList, // rant 18:23:15 P3:任务列表渲染(测试/刷新复用)
renderProjectList, // rant 14:10:14 P5:项目面板列表渲染(测试/刷新复用)
showProjectSessionsInPanel, // rant 14:10:14 P5:项目面板内嵌会话列表(测试复用)
saveTaskForm, // rant 18:23:15 P3:任务表单提交(测试复用)
saveTemplateForm, // rant 18:23:15 P3:类型表单提交(测试复用)
showRename,
Expand Down
28 changes: 28 additions & 0 deletions emrg/gui/renderer/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ const I18N = (() => {
"nav.settingsPanel": "设置",
"nav.placeholder": "面板内容将在后续版本填充",

// 项目面板(rant 14:10:14 P5)
"projects.hint": "查看会话 / 添加项目 / 删除(保留磁盘数据)",
"projects.add": "+ 添加项目",
"projects.empty": "暂无项目(点击「添加项目」注册工作目录)",
"projects.added": "项目已添加:{path}",
"projects.addFailed": "添加项目失败:{msg}",
"projects.sessions": "会话",
"projects.autoEvolve": "演化",
"projects.viewSessions": "查看会话",
"projects.noSessions": "该项目暂无会话",
"projects.sessionsOf": "{project} 的会话",
"projects.back": "← 返回项目列表",
"projects.current": "当前",

// 空状态欢迎屏
"empty.hello": "你好,我是 EMRG",
"empty.sub": "你的 AI 工作伙伴。问我任何事——写东西、整理文件、规划任务,都可以。",
Expand Down Expand Up @@ -440,6 +454,20 @@ const I18N = (() => {
"nav.settingsPanel": "Settings",
"nav.placeholder": "Panel content arrives in a later stage",

// Projects panel (rant 14:10:14 P5)
"projects.hint": "View sessions / add / remove (disk data kept)",
"projects.add": "+ Add project",
"projects.empty": "No projects yet (click “Add project” to register a working directory)",
"projects.added": "Project added: {path}",
"projects.addFailed": "Failed to add project: {msg}",
"projects.sessions": "Sessions",
"projects.autoEvolve": "Evolve",
"projects.viewSessions": "Sessions",
"projects.noSessions": "No sessions for this project yet",
"projects.sessionsOf": "Sessions of {project}",
"projects.back": "← Back to project list",
"projects.current": "Current",

// Empty state
"empty.hello": "Hi, I'm EMRG",
"empty.sub": "Your AI work partner. Ask me anything — write, organize files, plan tasks.",
Expand Down
57 changes: 57 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ const ELEMENT_IDS = [
"conn-banner", "empty-state", "model-switcher", "model-switcher-label", "brand-star", "new-chat-btn", "open-chat-btn",
"side-nav", "nav-sessions", "nav-projects", "nav-tasks", "nav-rants", "nav-settings",
"panel-sessions", "panel-projects", "panel-tasks", "panel-rants", "panel-settings",
"project-list", "project-add-btn",
"settings-tabs", "settings-tab-model", "settings-tab-workdir", "settings-tab-github", "settings-tab-appearance", "settings-tab-language", "settings-tab-about",
"settings-body-model", "settings-body-workdir", "settings-body-github", "settings-body-appearance", "settings-body-language", "settings-body-about",
"settings-cancel", "settings-save", "set-api-key", "set-base-url", "set-project-dir", "set-model", "pick-dir-btn", "theme-options", "welcome-dialog", "welcome-api-key", "welcome-base-url",
Expand Down Expand Up @@ -2726,3 +2727,59 @@ test("rant 14:10:14 P2:设置面板 tab 切换(6 tab 独立显隐 + 高亮
await tick();
assert.strictEqual(visible("settings-body-about"), true, "非法 tab 名应保持当前 tab");
});

test("rant 14:10:14 P5:项目面板列表(auto_evolve 徽标 + 最近活跃)+ 查看会话 + 删除刷新", async () => {
let removed = null;
const { ctx, els } = makeSandbox({
listProjects: async () => [
{ name: "emrg", path: "/p/emrg", latest_session_at: "2026-08-13T07:00:00+08:00" },
{ name: "docs", path: "/p/docs", latest_session_at: null },
],
listTasks: async () => [
{ name: "emrg-task", type: "evolution", config: { project: "emrg" }, interval: 1800, enabled: true },
// docs 无任何任务 → 无 auto_evolve 徽标(负态)
],
listProjectSessions: async ({ projectPath }) => ({
sessions: projectPath === "/p/docs"
? [{ session_id: "s-docs", title: "文档会话" }]
: [],
}),
removeProject: async (payload) => { removed = payload; return { ok: true, removed: true, closed: [] }; },
});
await tick();
await vm.runInContext("App.openProjectsPanel()", ctx);
await tick();
const list = els["project-list"];
// 2 行项目 + auto_evolve 徽标只出现在 emrg 行(有 evolution 任务)
const rows = list.children.filter((c) => c.className.includes("task-row"));
assert.strictEqual(rows.length, 2, `项目应渲染 2 行,实际 ${rows.length}`);
const names = rows.map((r) => r.querySelector(".task-name") ? r.querySelector(".task-name").textContent : "");
assert.deepStrictEqual(names, ["emrg", "docs"]);
const badges = rows.map((r) => r.querySelectorAll(".task-badge").length);
assert.strictEqual(badges[0], 1, "emrg(有 evolution 任务)应显示 auto_evolve 徽标");
assert.strictEqual(badges[1], 0, "docs(无 evolution 任务)不应显示徽标");
// 查看会话 → 点击 docs 行「会话」→ 内嵌会话列表
const docsRow = rows[1];
const sessBtns = docsRow.querySelectorAll(".model-action-btn");
assert.strictEqual(sessBtns.length, 2, "每行应有 查看会话 + 删除 两个操作");
sessBtns[0].click();
await tick();
const rows2 = list.children.filter((c) => c.className.includes("task-row"));
assert.ok(rows2.length >= 2, "会话视图应含 返回行 + 会话行");
const names2 = rows2.map((r) => r.querySelector(".task-name") ? r.querySelector(".task-name").textContent : "");
assert.ok(names2.some((n) => n.includes("文档会话")), `会话行应显示会话标题,实际 ${names2.join(",")}`);
// 返回 → 列表
const backBtn = rows2[0].querySelector(".model-action-btn");
backBtn.click();
await tick();
assert.strictEqual(list.children.filter((c) => c.className.includes("task-row")).length, 2, "返回后回到项目列表");
// 删除 → confirmDeleteProject(非受保护 docs)→ removeProject + 刷新
const docsRow2 = list.children.filter((c) => c.className.includes("task-row"))[1];
const delBtns = docsRow2.querySelectorAll(".model-action-btn");
delBtns[1].click();
assert.strictEqual(els["confirm-dialog"].open, true, "删除应弹确认框");
await vm.runInContext("EMRG_Dialogs.confirmOk()", ctx);
await tick();
assert.ok(removed, "removeProject 应被调用");
assert.strictEqual(removed.name, "docs");
});
Loading