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` (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/<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` (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 路径不受影响)

Expand Down
22 changes: 22 additions & 0 deletions emrg/gui/renderer/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
4 changes: 4 additions & 0 deletions emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -709,6 +711,8 @@ const App = (() => {
setWorkspaceChrome("panel"); // 隐藏输入区 + 成果面板 + 空状态
} else {
// 点当前激活项(toggle 关闭)/ 点 💬 会话 → 回会话视图
// rant 10:36:39:从任务面板 toggle 关闭同样要停倒计时(activeView 即将离开 tasks)
if (state.activeView === "tasks") Dialogs.stopTaskCountdown?.();
showSessionsView();
}
}
Expand Down
59 changes: 59 additions & 0 deletions emrg/gui/renderer/js/dialogs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -636,6 +679,7 @@ const Dialogs = (() => {
async function renderTaskList() {
const list = $("task-list");
if (!list) return; // 元素缺失(测试桩)时忽略
stopTaskCountdown(); // 清旧计时器 + 旧倒计时引用(空列表/失败路径也不会泄漏)
list.innerHTML = "";
let tasks = [];
try {
Expand All @@ -648,14 +692,23 @@ const Dialogs = (() => {
list.innerHTML = `<div class="task-empty">${_t("settings.taskEmpty")}</div>`;
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 = [];
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 面板初始化
Expand Down
6 changes: 6 additions & 0 deletions emrg/gui/renderer/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}。",
Expand Down Expand Up @@ -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.",
Expand Down
95 changes: 95 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
Loading