From 39e598cd1dc7475c288a1d109ed511c69022187e Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 15 Aug 2026 10:45:33 +0800 Subject: [PATCH] emrg: GUI task status + next-run countdown (rant 2026-08-15T10:36:39) --- Agent.md | 4 +- emrg/gui/renderer/css/components.css | 22 +++++++ emrg/gui/renderer/js/app.js | 4 ++ emrg/gui/renderer/js/dialogs.js | 59 +++++++++++++++++ emrg/gui/renderer/js/i18n.js | 6 ++ emrg/gui/test/renderer.smoke.test.js | 95 ++++++++++++++++++++++++++++ 6 files changed, 188 insertions(+), 2 deletions(-) diff --git a/Agent.md b/Agent.md index 55e4d56..862a4b0 100644 --- a/Agent.md +++ b/Agent.md @@ -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` (250: 45 daemon_client + 19 conn-manager + 22 app-commands + 123 renderer smoke + 16 i18n + 7 integration + 3 commands + 6 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (253: 45 daemon_client + 19 conn-manager + 22 app-commands + 126 renderer smoke + 16 i18n + 7 integration + 3 commands + 6 build-config + 7 gui-state + 2 tool-group); 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/.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/`) @@ -119,7 +119,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (825) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (250: 45 daemon_client + 19 conn-manager + 22 app-commands + 123 renderer smoke + 16 i18n + 7 integration + 3 commands + 6 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (253: 45 daemon_client + 19 conn-manager + 22 app-commands + 126 renderer smoke + 16 i18n + 7 integration + 3 commands + 6 build-config + 7 gui-state + 2 tool-group) — 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 路径不受影响) diff --git a/emrg/gui/renderer/css/components.css b/emrg/gui/renderer/css/components.css index fd28a56..7b9af41 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -964,6 +964,28 @@ dialog::backdrop { margin-left: 4px; } +/* rant 10:36:39:任务状态徽标(待运行/待调度)+ 下次运行倒计时 */ +.task-pending-badge, +.task-idle-badge { + background: var(--bg-soft); + color: var(--text-3); + font-size: 10px; + padding: 1px 6px; + border-radius: 8px; + margin-left: 4px; +} +.task-pending-badge { + color: var(--accent); + background: var(--accent-soft, var(--bg-soft)); +} +.task-next-run { + font-size: var(--fs-aux); + color: var(--text-3); + flex-shrink: 0; + white-space: nowrap; + font-variant-numeric: tabular-nums; /* 倒计时走秒不抖动 */ +} + /* rant 09:17:45:提示词编辑器 Monaco 挂载容器(长提示词 300px 起步) */ .monaco-host { width: 100%; diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 390fca7..8051426 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -688,6 +688,8 @@ const App = (() => { function switchView(name) { if (!VIEWS.includes(name)) return; const isOpen = state.activeView === name; + // rant 10:36:39:离开任务视图 → 停倒计时(防泄漏;重开由 renderTaskList 重新启动) + if (state.activeView === "tasks" && name !== "tasks") Dialogs.stopTaskCountdown?.(); for (const p of VIEWS) { const btn = $(`nav-${p}`); if (btn) btn.classList.toggle("active", false); @@ -709,6 +711,8 @@ const App = (() => { setWorkspaceChrome("panel"); // 隐藏输入区 + 成果面板 + 空状态 } else { // 点当前激活项(toggle 关闭)/ 点 💬 会话 → 回会话视图 + // rant 10:36:39:从任务面板 toggle 关闭同样要停倒计时(activeView 即将离开 tasks) + if (state.activeView === "tasks") Dialogs.stopTaskCountdown?.(); showSessionsView(); } } diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js index a6e0622..dbaeeb8 100644 --- a/emrg/gui/renderer/js/dialogs.js +++ b/emrg/gui/renderer/js/dialogs.js @@ -619,6 +619,49 @@ const Dialogs = (() => { let taskProjects = []; // listProjects 结果 — 项目下拉仅已注册项目(决策点③) let editingTask = null; // 正在编辑的任务名(null = 新增;daemon 以 name 定位 → 名称不可改) let editingTemplate = null; // 正在编辑的自定义类型名(null = 新增;名称不可改) + // rant 2026-08-15T10:36:39:任务状态 + 下次运行倒计时 + // 倒计时以"渲染时快照的 deadline"为基准每秒递减(直接改 DOM 文本,不重渲染整行防闪烁/滚动丢失) + let taskCountdownTimer = null; // setInterval id(面板激活时启动,离开视图时清除防泄漏) + let taskCountdowns = []; // [{name, deadline, span}] — 每 1s 由 updateTaskCountdowns 更新 + + // 倒计时格式化:≤60s "43s";≤1h "1m23s";>1h "1h05m";负数/非数钳制为 0 + function formatCountdown(totalSeconds) { + const s = Math.max(0, Math.floor(Number(totalSeconds) || 0)); + if (s < 60) return `${s}s`; + if (s < 3600) { + const m = Math.floor(s / 60); + const sec = s % 60; + return `${m}m${String(sec).padStart(2, "0")}s`; + } + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + return `${h}h${String(m).padStart(2, "0")}m`; + } + + function updateTaskCountdowns() { + for (const e of taskCountdowns) { + const rem = Math.max(0, Math.ceil((e.deadline - Date.now()) / 1000)); + e.span.textContent = _t("app.taskNextRun", { n: formatCountdown(rem) }); + } + } + + function stopTaskCountdown() { + if (taskCountdownTimer !== null) { + clearInterval(taskCountdownTimer); + taskCountdownTimer = null; + } + taskCountdowns = []; + } + + function startTaskCountdown() { + // 幂等重启:只清旧计时器,不清 taskCountdowns(stopTaskCountdown 会清引用,供离开视图用) + if (taskCountdownTimer !== null) { + clearInterval(taskCountdownTimer); + taskCountdownTimer = null; + } + if (taskCountdowns.length === 0) return; + taskCountdownTimer = setInterval(updateTaskCountdowns, 1000); + } async function loadTaskMeta() { try { @@ -636,6 +679,7 @@ const Dialogs = (() => { async function renderTaskList() { const list = $("task-list"); if (!list) return; // 元素缺失(测试桩)时忽略 + stopTaskCountdown(); // 清旧计时器 + 旧倒计时引用(空列表/失败路径也不会泄漏) list.innerHTML = ""; let tasks = []; try { @@ -648,14 +692,23 @@ const Dialogs = (() => { list.innerHTML = `
${_t("settings.taskEmpty")}
`; return; } + const countdowns = []; // 本地收集 → 渲染完成后一次性挂载(startTaskCountdown 幂等重启) for (const t of tasks) { const row = el("div", { class: "task-row" }); row.appendChild(el("span", { class: "task-name" }, t.name || "?")); row.appendChild(el("span", { class: "task-badge" }, t.type || "evolution")); // rant 09:23:10:running 状态徽标(演化任务 60s 一轮几乎常驻 running,从源头减少误点) + // rant 10:36:39:等待中 → "待运行"淡色徽标 + 下次运行倒计时;无 next 且启用 → "待调度" if (t.running) { const runBadge = el("span", { class: "task-badge task-running-badge" }, _t("app.taskRunningBadge")); row.appendChild(runBadge); + } else if (t.next_run_in_seconds != null) { + row.appendChild(el("span", { class: "task-badge task-pending-badge" }, _t("app.taskPendingBadge"))); + const nextSpan = el("span", { class: "task-next-run" }, _t("app.taskNextRun", { n: formatCountdown(t.next_run_in_seconds) })); + row.appendChild(nextSpan); + countdowns.push({ name: t.name, deadline: Date.now() + Math.max(0, t.next_run_in_seconds) * 1000, span: nextSpan }); + } else if (t.enabled !== false) { + row.appendChild(el("span", { class: "task-badge task-idle-badge" }, _t("app.taskIdleBadge"))); } const cfg = (t.config && typeof t.config === "object") ? t.config : {}; const hints = []; @@ -713,6 +766,8 @@ const Dialogs = (() => { row.appendChild(actions); list.appendChild(row); } + taskCountdowns = countdowns; + startTaskCountdown(); // rant 10:36:39:面板激活即启动 1s 倒计时(离开视图由 app.js stopTaskCountdown) } async function openTaskForm(task = null) { @@ -1501,6 +1556,10 @@ const Dialogs = (() => { initTaskManagement, // rant 18:23:15 P3:定时任务/自定义类型管理初始化 loadTaskMeta, // rant 18:23:15 P3:任务/类型元数据加载(面板打开/测试复用) renderTaskList, // rant 18:23:15 P3:任务列表渲染(测试/刷新复用) + formatCountdown, // rant 10:36:39:倒计时格式化(≤60s "43s" / ≤1h "1m23s" / >1h "1h05m";测试复用) + updateTaskCountdowns, // rant 10:36:39:1s tick 更新倒计时文本(测试直接调用模拟走秒) + startTaskCountdown, // rant 10:36:39:启动 1s 倒计时(幂等;renderTaskList 自动调用) + stopTaskCountdown, // rant 10:36:39:停止并清引用(离开任务视图时 app.js 调用防泄漏) renderProjectList, // rant 14:10:14 P5:项目面板列表渲染(测试/刷新复用) showProjectSessionsInPanel, // rant 14:10:14 P5:项目面板内嵌会话列表(测试复用) initRantPanel, // rant 14:10:14 P4:rant 面板初始化 diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index 52987f8..f7c7913 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -400,6 +400,9 @@ const I18N = (() => { "app.rantFailed": "提交失败了:{msg}", "app.taskDisabled": "已停用", "app.taskInterval": "间隔 {n}s", + "app.taskNextRun": "下次运行 {n}", + "app.taskPendingBadge": "待运行", + "app.taskIdleBadge": "待调度", "app.tasksFailed": "加载任务失败:{msg}", "app.triggerFailed": "触发失败:{msg}", "app.triggered": "已触发任务 {n}。", @@ -817,6 +820,9 @@ const I18N = (() => { "app.rantFailed": "Submission failed: {msg}", "app.taskDisabled": "Disabled", "app.taskInterval": "every {n}s", + "app.taskNextRun": "next run in {n}", + "app.taskPendingBadge": "queued", + "app.taskIdleBadge": "not scheduled", "app.tasksFailed": "Failed to load tasks: {msg}", "app.triggerFailed": "Trigger failed: {msg}", "app.triggered": "Task {n} triggered.", diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 6a3b906..642b50a 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -170,6 +170,10 @@ function makeSandbox(overrides = {}) { console, setTimeout, clearTimeout, + // rant 10:36:39:任务倒计时 setInterval 桩(返回递增 id,永不触发 → 测试不泄漏真实定时器); + // 走秒由测试显式调用 EMRG_Dialogs.updateTaskCountdowns() 模拟 + setInterval: () => { win._intervalCount = (win._intervalCount || 0) + 1; return win._intervalCount; }, + clearInterval: () => { win._clearCount = (win._clearCount || 0) + 1; }, Date, Math, JSON, @@ -2706,6 +2710,97 @@ test("rant 2026-08-15T09:20:27/09:23:10:面板操作反馈走全局 toast( assert.strictEqual(els["toast"].classList.contains("toast-error"), true, "error → error toast"); }); +// ── rant 2026-08-15T10:36:39:任务状态 + 下次运行倒计时 ── +test("rant 10:36:39:formatCountdown 格式边界(0s/59s/60s/1h05m/负数钳制)", () => { + const { ctx } = makeSandbox(); + const fmt = (n) => vm.runInContext(`EMRG_Dialogs.formatCountdown(${JSON.stringify(n)})`, ctx); + assert.strictEqual(fmt(0), "0s"); + assert.strictEqual(fmt(43), "43s"); + assert.strictEqual(fmt(59), "59s"); + assert.strictEqual(fmt(60), "1m00s"); + assert.strictEqual(fmt(83), "1m23s"); + assert.strictEqual(fmt(3599), "59m59s"); + assert.strictEqual(fmt(3600), "1h00m"); + assert.strictEqual(fmt(3900), "1h05m"); + assert.strictEqual(fmt(-5), "0s", "负数钳制为 0"); + assert.strictEqual(fmt(1.9), "1s", "小数向下取整"); +}); + +test("rant 10:36:39:任务行状态展示 —— 运行中/待运行+倒计时/待调度/已停用 + 每秒递减", async () => { + const { ctx, win } = makeSandbox({ + listTasks: async () => [ + { name: "running-task", type: "evolution", running: true, interval: 60, next_run_in_seconds: null }, + { name: "waiting-task", type: "evolution", running: false, interval: 1800, next_run_in_seconds: 43 }, + { name: "idle-task", type: "custom", running: false, interval: 3600, next_run_in_seconds: null, enabled: true }, + { name: "disabled-task", type: "custom", running: false, interval: 3600, next_run_in_seconds: null, enabled: false }, + ], + listProjects: async () => [], + }); + let nowMs = 1_700_000_000_000; + win.Date = class extends Date { static now() { return nowMs; } }; + await tick(); + await vm.runInContext("App.openTasksPanel()", ctx); + await tick(); + const texts = vm.runInContext(`Array.from(document.getElementById("task-list").children).map((r) => { + const name = r.querySelector(".task-name") ? r.querySelector(".task-name").textContent : ""; + const badges = Array.from(r.querySelectorAll(".task-badge")).map((b) => b.textContent).join(","); + const next = r.querySelector(".task-next-run") ? r.querySelector(".task-next-run").textContent : ""; + const hint = r.querySelector(".task-hint") ? r.querySelector(".task-hint").textContent : ""; + return name + "|" + badges + "|" + next + "|" + hint; + })`, ctx); + assert.strictEqual(texts.length, 4, "4 任务各一行"); + assert.ok(texts[0].includes("运行中"), `running 徽标:${texts[0]}`); + assert.ok(texts[0].includes("运行中") && !texts[0].includes("下次运行"), "running 无倒计时"); + assert.ok(texts[1].includes("待运行") && texts[1].includes("下次运行 43s"), `等待任务:${texts[1]}`); + assert.ok(texts[2].includes("待调度") && !texts[2].includes("下次运行"), `空闲任务:${texts[2]}`); + assert.ok(texts[3].includes("已停用") && !texts[3].includes("待调度"), `禁用任务:${texts[3]}`); + // 每秒递减:快进 1s → updateTaskCountdowns(等效 setInterval tick) + nowMs += 1000; + await vm.runInContext("EMRG_Dialogs.updateTaskCountdowns()", ctx); + let nextRuns = vm.runInContext(`Array.from(document.getElementById("task-list").children).map((r) => { const s = r.querySelector(".task-next-run"); return s ? s.textContent : ""; }).filter(Boolean)`, ctx); + assert.deepStrictEqual(nextRuns, ["下次运行 42s"], "1s 后递减为 42s"); + // 快进到 deadline 之后 → 负数钳制为 0s(不显示负数) + nowMs += 50_000; + await vm.runInContext("EMRG_Dialogs.updateTaskCountdowns()", ctx); + nextRuns = vm.runInContext(`Array.from(document.getElementById("task-list").children).map((r) => { const s = r.querySelector(".task-next-run"); return s ? s.textContent : ""; }).filter(Boolean)`, ctx); + assert.deepStrictEqual(nextRuns, ["下次运行 0s"], "deadline 过后钳制为 0s"); +}); + +test("rant 10:36:39:倒计时生命周期 —— 渲染启动 interval、离开任务视图清理(无泄漏)", async () => { + const { ctx, win } = makeSandbox({ + listTasks: async () => [ + { name: "waiting-task", type: "evolution", running: false, interval: 1800, next_run_in_seconds: 43 }, + ], + listProjects: async () => [], + }); + await tick(); + const start = win._intervalCount || 0; + await vm.runInContext("App.openTasksPanel()", ctx); + await tick(); + assert.ok((win._intervalCount || 0) > start, "渲染含倒计时的任务 → 启动 setInterval"); + // 打开其他面板 → 停止倒计时(clearInterval 被调用) + const clearBefore = win._clearCount || 0; + await vm.runInContext("App.switchView('projects')", ctx); + assert.ok((win._clearCount || 0) > clearBefore, "离开任务视图 → clearInterval 防泄漏"); + // 重新打开 → 再次启动(renderTaskList 幂等重启) + const start2 = win._intervalCount || 0; + await vm.runInContext("App.openTasksPanel()", ctx); + await tick(); + assert.ok((win._intervalCount || 0) > start2, "重开任务面板 → 倒计时重新启动"); + // 无倒计时的任务(全 running)→ 不启动 interval + const { ctx: ctx2, win: win2 } = makeSandbox({ + listTasks: async () => [ + { name: "r1", type: "evolution", running: true, interval: 60, next_run_in_seconds: null }, + ], + listProjects: async () => [], + }); + await tick(); + const s2 = win2._intervalCount || 0; + await vm.runInContext("App.openTasksPanel()", ctx2); + await tick(); + assert.strictEqual(win2._intervalCount || 0, s2, "全 running 无倒计时 → 不启动 interval"); +}); + test("rant 2026-08-14T15:41:52:快速点击添加任务 —— 元数据未加载完也填充下拉 + 保存成功", async () => { let created = null; const { ctx, els } = makeSandbox({