Files
maestro/web/app.js
T
wangjia fa472a06a7 feat: 同步引擎+Agent配置+依赖自动落位+看板大改(预览/md渲染/筛选/徽章组)
后端:
- src/sync/todo-sync.ts: todo.json 单向同步引擎(导入=首次同步,幂等,source_ref 映射,
  subs 复杂度修正为 easy,旧侧 done 历史事实优先 forceDone)
- 依赖自动落位:ready 意图按 deps 落位 blocked,依赖全 done 自动放行,
  daemon 启动 reconcileDeps 对账,手动绕过会弹回
- 新 API: PATCH projects/:id(autonomy/concurrency)、POST :id/sync、GET /api/agents、
  PATCH tasks/:id(title/priority/complexity 重置)
- daemon 定时同步(MAESTRO_SYNC_INTERVAL 默认 300s) + project.synced 事件
- priority 语义翻转: P0 最高/P1 默认/P2 最低,取值限 0..2,排序/映射/MCP/CLI 全跟进
- 静态服务发 no-cache 头(修浏览器吃旧 CSS/JS)
- schema 迁移: tasks.source_ref / projects.last_sync_at(ensureColumn 平滑升级旧库)

看板:
- 全屏预览模式(94vh 读完整方案+就地裁决,Esc/遮罩/裁决自动关闭)
- 产出 markdown 渲染为 HTML(零依赖渲染器,转义优先)
- 任务树筛选(复杂度/状态分组/关键字)+ 顶栏徽章组(待审批/可执行/执行中,hover 展开)
- 依赖可视化:详情 DEPS 区块 + 行内⛓等依赖 + 锚点跳转定位
- 按钮收敛:提交评审/编辑产出移除(CC 经 MCP 操作),界面只留用户动作
- Agent 执行面板 + 项目配置(并发/工作模式)+ 同步按钮

测试:21 个全过(新增 sync 幂等/迁移/patch/依赖落位/对账幂等)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:58:28 +08:00

1046 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ════════════════════════════════════════════════════════════
MAESTRO 调度台 · 前端逻辑(无框架,直连 daemon REST + WS
════════════════════════════════════════════════════════════ */
'use strict';
// ── 模型常量(与 src/model/status.ts / complexity.ts 对齐) ──
const STATUS_LABEL = {
init: '新建', analyzing: '分析拆解中', plan_review: '待确认拆解',
decomposed: '已拆解', speccing: '写方案中', spec_review: '待确认方案',
ready: '可执行', blocked: '被依赖阻塞', queued: '排队中',
executing: '执行中', exec_review: '待审/合', failed: '失败',
needs_attention: '需人工', done: '完成', paused: '暂停', cancelled: '取消',
};
const STATUS_GROUP = {
init: 'idle', analyzing: 'work', speccing: 'work',
plan_review: 'gate', spec_review: 'gate', exec_review: 'gate',
decomposed: 'container', ready: 'go', queued: 'go', executing: 'run',
blocked: 'hold', paused: 'hold', failed: 'bad', needs_attention: 'bad',
done: 'done', cancelled: 'dead',
};
const GATE_OF = { plan_review: 'plan', spec_review: 'spec', exec_review: 'exec' };
const GATE_LABEL = { plan: '拆解评审', spec: '方案评审', exec: '结果评审' };
const CPLX_LABEL = { hard: 'HARD', medium: 'MED', easy: 'EASY' };
const EVENT_LABEL = {
'task.created': '任务创建', 'task.updated': '任务更新', 'status.changed': '状态变更',
'approval.requested': '请求审批', 'approval.granted': '审批通过', 'approval.rejected': '审批驳回',
'run.started': '运行开始', 'run.finished': '运行结束', 'project.synced': 'todo 同步',
};
const EVENT_CLASS = {
'task.created': 'ev-created', 'task.updated': 'ev-updated', 'status.changed': 'ev-status',
'approval.requested': 'ev-gatewait', 'approval.granted': 'ev-approve',
'approval.rejected': 'ev-rejected', 'run.started': 'ev-run', 'run.finished': 'ev-run',
'project.synced': 'ev-created',
};
const AUTONOMY_LABEL = {
manual: '手动', 'auto-easy': '自动执行 Easy', 'auto-approved': '自动执行已批准',
};
// 任务树筛选:状态按状态机分组(组可整组开关,组内可单选)
const FILTER_GROUPS = [
['待办', ['init', 'ready', 'blocked']],
['进行中', ['analyzing', 'speccing', 'queued', 'executing', 'decomposed']],
['待审批', ['plan_review', 'spec_review', 'exec_review']],
['异常', ['failed', 'needs_attention']],
['挂起', ['paused', 'cancelled']],
['完成', ['done']],
];
// ── 全局状态 ──
const S = {
projects: [],
currentProjectId: null,
tasks: [],
approvals: [],
events: [], // 新→旧
collapsed: new Set(),
expanded: new Set(),
rejectOpen: new Set(),
filter: { cplx: new Set(), status: new Set(), kw: '' },// 任务树筛选(内存态)
matchCount: 0,
agents: null, // GET /api/agents 结果(404 时为 null
cplxMenuFor: null, // 复杂度下拉打开的任务 id
syncReqAt: 0, // 本端发起 sync 的时间(避免 WS 重复 toast)
previewId: null, // 全局预览中的任务 id
previewReject: false, // 预览层内驳回意见框是否展开
};
const $ = (sel) => document.querySelector(sel);
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
}
function fmtTime(iso) {
try { return new Date(iso).toLocaleTimeString('zh-CN', { hour12: false }); }
catch { return iso; }
}
function fmtRel(iso) {
if (!iso) return '';
const ms = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(ms)) return String(iso);
const s = Math.floor(ms / 1000);
if (s < 60) return '刚刚';
const m = Math.floor(s / 60);
if (m < 60) return `${m} 分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} 小时前`;
return `${Math.floor(h / 24)} 天前`;
}
// ── 轻量 Markdown 渲染(零依赖;先整体转义再做结构转换,杜绝注入) ──
function mdToHtml(src) {
const inline = (s) => s
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
const lines = esc(src).split('\n');
const out = [];
let i = 0;
let para = [];
let list = null; // {type:'ul'|'ol', items:[]}
const flushPara = () => { if (para.length) { out.push(`<p>${para.map(inline).join('<br>')}</p>`); para = []; } };
const flushList = () => {
if (list) {
out.push(`<${list.type}>${list.items.map((x) => `<li>${inline(x)}</li>`).join('')}</${list.type}>`);
list = null;
}
};
while (i < lines.length) {
const line = lines[i];
if (/^```/.test(line)) { // 围栏代码块
flushPara(); flushList();
const buf = []; i++;
while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; }
i++;
out.push(`<pre class="codeblock">${buf.join('\n')}</pre>`);
continue;
}
if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s\-:|]+\|\s*$/.test(lines[i + 1])) {
flushPara(); flushList(); // 表格
const cells = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => inline(c.trim()));
const head = cells(line);
i += 2;
const rows = [];
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(cells(lines[i])); i++; }
out.push(`<table><thead><tr>${head.map((h) => `<th>${h}</th>`).join('')}</tr></thead><tbody>${
rows.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`);
continue;
}
let m;
if ((m = line.match(/^(#{1,6})\s+(.*)$/))) { // 标题
flushPara(); flushList();
const lv = m[1].length;
out.push(`<div class="md-h md-h${lv}">${inline(m[2])}</div>`);
i++; continue;
}
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { flushPara(); flushList(); out.push('<hr>'); i++; continue; }
if ((m = line.match(/^\s*&gt;\s?(.*)$/))) { flushPara(); flushList(); out.push(`<blockquote>${inline(m[1])}</blockquote>`); i++; continue; }
if ((m = line.match(/^\s*[-*]\s+(.*)$/))) {
flushPara();
if (!list || list.type !== 'ul') { flushList(); list = { type: 'ul', items: [] }; }
list.items.push(m[1]); i++; continue;
}
if ((m = line.match(/^\s*\d+[.)]\s+(.*)$/))) {
flushPara();
if (!list || list.type !== 'ol') { flushList(); list = { type: 'ol', items: [] }; }
list.items.push(m[1]); i++; continue;
}
if (/^\s*$/.test(line)) { flushPara(); flushList(); i++; continue; }
para.push(line); i++;
}
flushPara(); flushList();
return out.join('\n');
}
// ── API ──
async function api(path, opts = {}) {
let res;
try {
res = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
} catch (e) {
throw new Error('无法连接 daemon' + e.message);
}
let data = null;
try { data = await res.json(); } catch { /* 非 JSON 响应 */ }
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
return data;
}
// ── Toast ──
function toast(msg, kind = 'err') {
const el = document.createElement('div');
el.className = `toast ${kind}`;
el.textContent = msg;
$('#toastRoot').appendChild(el);
setTimeout(() => { el.classList.add('out'); setTimeout(() => el.remove(), 350); }, 4200);
}
// ── 数据加载 ──
async function loadProjects() {
S.projects = await api('/api/projects');
if (!S.currentProjectId && S.projects.length) S.currentProjectId = S.projects[0].id;
if (S.currentProjectId && !S.projects.some((p) => p.id === S.currentProjectId)) {
S.currentProjectId = S.projects.length ? S.projects[0].id : null;
}
}
async function loadProjectData() {
const pid = S.currentProjectId;
if (!pid) { S.tasks = []; S.events = []; S.approvals = []; return; }
const [tasks, events, approvals] = await Promise.all([
api(`/api/projects/${pid}/tasks`),
api(`/api/projects/${pid}/events`),
api(`/api/approvals?projectId=${encodeURIComponent(pid)}`),
]);
S.tasks = tasks;
S.events = events.slice().reverse(); // 接口升序 → 展示新在前
S.approvals = approvals;
}
// agents 端点未就绪(404)时静默降级为空态,不打扰用户
async function loadAgents() {
try { S.agents = await api('/api/agents'); }
catch { S.agents = null; }
}
async function refresh() {
try {
await loadProjectData();
renderAll();
} catch (e) { toast(e.message); }
}
async function fullRefresh() {
try {
await loadProjects();
await Promise.all([loadProjectData(), loadAgents()]);
renderAll();
} catch (e) { toast(e.message); }
}
// ── 渲染:草稿保护(重绘前保存输入框内容与焦点) ──
function snapshotDrafts() {
const map = new Map();
document.querySelectorAll('textarea[id], input[id][type=text]').forEach((el) => {
if (el.value) map.set(el.id, el.value);
});
const focusId = document.activeElement && document.activeElement.id;
return { map, focusId };
}
function restoreDrafts(snap) {
snap.map.forEach((v, id) => {
const el = document.getElementById(id);
if (el && !el.value) el.value = v;
});
if (snap.focusId) {
const el = document.getElementById(snap.focusId);
if (el) { el.focus(); try { el.selectionStart = el.value.length; } catch { /* 非文本 */ } }
}
}
function renderAll() {
const snap = snapshotDrafts();
renderSidebar();
renderTopbar();
renderAgents();
renderGates();
renderTree();
renderFilterBar();
renderEvents();
renderParentOptions();
renderPreview();
restoreDrafts(snap);
}
// ── 渲染:侧栏 ──
function renderSidebar() {
const ul = $('#projectList');
if (!S.projects.length) {
ul.innerHTML = `<li class="p-empty">暂无项目<br>点「+ 新建」登记第一个仓库</li>`;
return;
}
ul.innerHTML = S.projects.map((p) => `
<li class="${p.id === S.currentProjectId ? 'active' : ''}" data-action="select-project" data-id="${esc(p.id)}">
<span class="p-name">${esc(p.name)}</span>
<span class="p-path">${esc(p.repoPath)}</span>
</li>`).join('');
}
function renderTopbar() {
const p = S.projects.find((x) => x.id === S.currentProjectId);
$('#projTitle').textContent = p ? p.name : '未选择项目';
$('#projMeta').textContent = p
? `${p.repoPath} · 分支 ${p.defaultBranch} · ${p.status === 'active' ? '活跃' : '暂停'} · 任务 ${S.tasks.length}`
: '';
// 徽章组:待审批 / 可执行(叶子+ready+依赖全 done,与编排器领取口径一致)/ 执行中
$('#badgeGroup').hidden = !p;
const hasKids = new Set(S.tasks.filter((x) => x.parentId).map((x) => x.parentId));
const byId = new Map(S.tasks.map((x) => [x.id, x]));
const nReady = S.tasks.filter((x) =>
x.status === 'ready' && !hasKids.has(x.id) &&
(x.deps || []).every((d) => byId.get(d)?.status === 'done'),
).length;
const nRun = S.tasks.filter((x) => x.status === 'executing').length;
const setBdg = (sel, n) => {
const el = $(sel);
el.querySelector('.bdg-n').textContent = n;
el.classList.toggle('zero', n === 0);
};
setBdg('#bdgGate', S.approvals.length);
setBdg('#bdgReady', nReady);
setBdg('#bdgRun', nRun);
// 同步 todo / 项目配置入口
$('#topActions').hidden = !p;
if (p) {
const sb = $('#btnSync');
const noTodo = p.hasTodoJson === false; // 字段未交付(undefined)时不禁用,错误由 toast 兜底
sb.disabled = noTodo;
sb.title = noTodo
? '未发现 todo.json —— 约定路径 <repo>/todo/todo.json'
: '从 <repo>/todo/todo.json 同步任务';
$('#syncMeta').textContent = p.lastSyncAt ? `${fmtRel(p.lastSyncAt)}同步` : '';
$('#cfgCurrent').textContent =
`当前:并发 ${p.concurrency ?? '—'} · 模式 ${AUTONOMY_LABEL[p.autonomy] || p.autonomy || '—'}`;
} else {
$('#configPanel').hidden = true;
}
}
// ── 渲染:Agent 执行面板 ──
function renderAgents() {
const body = $('#agentBody');
const a = S.agents;
const total = a && Number(a.totalActive) > 0 ? Number(a.totalActive) : 0;
$('#agentTotal').textContent = total ? `· ${total}` : '';
if (!total) {
body.innerHTML = `<div class="agent-empty">无 agent 在执行(自动执行将在 Phase 2 启用)</div>`;
return;
}
const groups = (a.agents || []).filter((g) => g.active && g.active.length);
body.innerHTML = `
<div class="agent-flex">
<div class="agent-big">${total}<span class="agent-big-sub">ACTIVE</span></div>
<div class="agent-groups">
${groups.map((g) => `
<div class="agent-proj">
<div class="agent-proj-head"><b>${esc(g.projectName)}</b>
<span class="agent-proj-meta">并发 ${esc(String(g.concurrency ?? '—'))} · ${esc(AUTONOMY_LABEL[g.autonomy] || g.autonomy || '—')}</span>
</div>
${g.active.map((r) => `
<div class="agent-run">
<span class="agent-run-dot"></span>
<span class="agent-run-title">${esc(r.taskTitle)}</span>
<span class="agent-run-kind">${esc(r.kind || '')}</span>
<span class="agent-run-time">${fmtRel(r.startedAt)}开始</span>
</div>`).join('')}
</div>`).join('')}
</div>
</div>`;
}
// ── 渲染:审批闸 ──
function gateDocs(t) {
const gate = GATE_OF[t.status];
const blocks = [];
const doc = (label, text) => blocks.push(
`<div class="gate-doc-label">${label}</div>` +
(text ? `<div class="doc md">${mdToHtml(text)}</div>` : `<pre class="doc empty">(未填写)</pre>`));
if (gate === 'plan') doc('PLAN · 分析与拆解', t.plan);
if (gate === 'spec') doc('SPEC · 改动方案', t.spec);
if (gate === 'exec') {
if (t.result) {
const r = t.result;
blocks.push(`<div class="gate-doc-label">RESULT · 执行结果</div>
<dl class="result-kv">
${r.branch ? `<dt>分支</dt><dd>${esc(r.branch)}</dd>` : ''}
${r.worktree ? `<dt>worktree</dt><dd>${esc(r.worktree)}</dd>` : ''}
${r.prUrl ? `<dt>PR</dt><dd><a href="${esc(r.prUrl)}" target="_blank">${esc(r.prUrl)}</a></dd>` : ''}
</dl>`);
if (r.diffSummary) blocks.push(`<div class="gate-doc-label">DIFF 摘要</div><pre class="doc">${esc(r.diffSummary)}</pre>`);
} else {
blocks.push(`<pre class="doc empty">(无执行结果记录)</pre>`);
}
if (t.operations) doc('OPERATIONS · 执行的操作', t.operations);
else if (t.spec) doc('SPEC · 改动方案', t.spec);
}
return blocks.join('');
}
function renderGates() {
const sec = $('#gateSection');
if (!S.approvals.length) { sec.hidden = true; return; }
sec.hidden = false;
$('#gateList').innerHTML = S.approvals.map((t) => {
const gate = GATE_OF[t.status];
const rejOpen = S.rejectOpen.has(t.id);
return `
<div class="gate-card">
<div class="gate-card-head">
<span class="gate-kind">${GATE_LABEL[gate]}</span>
<span class="gate-title">${esc(t.title)}</span>
${cplxBadge(t.complexity)}
${statusChip(t.status)}
<button class="btn btn-ghost btn-xs gate-preview-btn" data-action="preview-open" data-id="${esc(t.id)}" title="全屏预览方案">⛶ 预览</button>
</div>
<div class="gate-body">${gateDocs(t)}</div>
<div class="gate-actions">
<button class="btn btn-accept" data-action="gate-accept" data-id="${esc(t.id)}">✓ 接受</button>
<button class="btn btn-reject" data-action="gate-reject-toggle" data-id="${esc(t.id)}">✗ 拒绝</button>
${gate === 'exec' ? `<span class="proj-meta">接受 = 认可改动并标记完成(合并不自动执行)</span>` : ''}
</div>
${rejOpen ? `
<div class="reject-form">
<textarea id="rej-${esc(t.id)}" placeholder="驳回意见(必填)——说明问题与改进方向"></textarea>
<button class="btn btn-reject" data-action="gate-reject-confirm" data-id="${esc(t.id)}">确认驳回</button>
<button class="btn" data-action="gate-reject-toggle" data-id="${esc(t.id)}">收起</button>
</div>` : ''}
</div>`;
}).join('');
}
// ── 渲染:全局预览(全屏读方案 + 就地裁决) ──
function renderPreview() {
const root = $('#previewRoot');
if (!S.previewId) { root.hidden = true; root.innerHTML = ''; return; }
const t = S.approvals.find((x) => x.id === S.previewId) || S.tasks.find((x) => x.id === S.previewId);
// 任务已离开闸(被裁决/状态变化)→ 自动关闭
if (!t || !GATE_OF[t.status]) { S.previewId = null; S.previewReject = false; root.hidden = true; root.innerHTML = ''; return; }
const gate = GATE_OF[t.status];
root.hidden = false;
root.innerHTML = `
<div class="preview-mask" data-action="preview-close"></div>
<div class="preview-panel">
<div class="preview-head">
<span class="gate-kind">${GATE_LABEL[gate]}</span>
<span class="preview-title">${esc(t.title)}</span>
${cplxBadge(t.complexity)}
${statusChip(t.status)}
<span class="spacer"></span>
<button class="btn btn-ghost" data-action="preview-close" title="关闭(Esc">✕</button>
</div>
<div class="preview-body">${gateDocs(t)}</div>
<div class="preview-foot">
<button class="btn btn-accept" data-action="gate-accept" data-id="${esc(t.id)}">✓ 接受</button>
<button class="btn btn-reject" data-action="preview-reject-toggle" data-id="${esc(t.id)}">✗ 拒绝</button>
<span class="proj-meta">id ${esc(t.id)} · 更新 ${fmtTime(t.updatedAt)}</span>
${S.previewReject ? `
<div class="reject-form preview-reject">
<textarea id="prev-rej-${esc(t.id)}" placeholder="驳回意见(必填)——说明问题与改进方向"></textarea>
<button class="btn btn-reject" data-action="gate-reject-confirm" data-id="${esc(t.id)}" data-ta="prev-rej-${esc(t.id)}">确认驳回</button>
<button class="btn" data-action="preview-reject-toggle" data-id="${esc(t.id)}">收起</button>
</div>` : ''}
</div>
</div>`;
if (S.previewReject) {
const ta = document.getElementById(`prev-rej-${t.id}`);
if (ta) ta.focus();
}
}
// ── 渲染:任务树 ──
// taskId 传入时徽章可点击 → 弹出复杂度选择(审批闸卡片不传,保持只读)
function cplxBadge(c, taskId) {
const text = CPLX_LABEL[c] || esc(c);
if (!taskId) return `<span class="cplx cplx-${esc(c)}">${text}</span>`;
const open = S.cplxMenuFor === taskId;
const pop = open ? `<span class="cplx-pop">${['hard', 'medium', 'easy'].map((x) =>
`<button type="button" class="cplx cplx-${x} ${x === c ? 'cur' : ''}" data-action="cplx-set" data-id="${esc(taskId)}" data-cplx="${x}">${CPLX_LABEL[x]}</button>`,
).join('')}</span>` : '';
return `<span class="cplx-wrap">
<button type="button" class="cplx cplx-${esc(c)} cplx-btn" data-action="cplx-menu" data-id="${esc(taskId)}" title="点击修改复杂度(状态会按新复杂度重置)">${text}</button>
${pop}
</span>`;
}
function statusChip(st) {
return `<span class="chip chip-${STATUS_GROUP[st] || 'idle'}">${STATUS_LABEL[st] || esc(st)}</span>`;
}
function childrenMap() {
const m = new Map();
for (const t of S.tasks) {
const key = t.parentId || '__root__';
if (!m.has(key)) m.set(key, []);
m.get(key).push(t);
}
return m;
}
// ── 筛选 ──
function filterActive() {
const f = S.filter;
return f.cplx.size > 0 || f.status.size > 0 || f.kw.trim() !== '';
}
function matchTask(t) {
const f = S.filter;
if (f.cplx.size && !f.cplx.has(t.complexity)) return false;
if (f.status.size && !f.status.has(t.status)) return false;
const kw = f.kw.trim().toLowerCase();
if (kw && !String(t.title).toLowerCase().includes(kw)) return false;
return true;
}
// 命中节点 + 其全部祖先(保留父链以维持树形)
function computeVisible() {
S.matchCount = 0;
if (!filterActive()) return null;
const byId = new Map(S.tasks.map((t) => [t.id, t]));
const visible = new Set();
const matched = new Set();
for (const t of S.tasks) {
if (!matchTask(t)) continue;
matched.add(t.id);
let cur = t;
while (cur && !visible.has(cur.id)) {
visible.add(cur.id);
cur = cur.parentId ? byId.get(cur.parentId) : null;
}
}
S.matchCount = matched.size;
return { visible, matched };
}
function renderFilterBar() {
const bar = $('#filterBar');
bar.hidden = !S.currentProjectId;
if (bar.hidden) return;
const f = S.filter;
$('#filterCplx').innerHTML = ['hard', 'medium', 'easy'].map((c) =>
`<button type="button" class="fchip fc-${c} ${f.cplx.has(c) ? 'on' : ''}" data-action="filter-cplx" data-cplx="${c}">${CPLX_LABEL[c]}</button>`,
).join('');
$('#filterStatus').innerHTML = FILTER_GROUPS.map(([name, sts], gi) => {
const sel = sts.filter((s) => f.status.has(s)).length;
const gCls = sel === sts.length ? 'on' : (sel ? 'part' : '');
return `<span class="fgroup">
<button type="button" class="fchip fgroup-chip ${gCls}" data-action="filter-group" data-gi="${gi}">${name}</button>
${sts.map((s) => `<button type="button" class="fchip fst ${f.status.has(s) ? 'on' : ''}" data-action="filter-status" data-st="${s}">${STATUS_LABEL[s]}</button>`).join('')}
</span>`;
}).join('');
const active = filterActive();
$('#filterClear').hidden = !active;
$('#filterCount').textContent = active ? `命中 ${S.matchCount} / ${S.tasks.length}` : '';
}
function renderTree() {
const root = $('#taskTree');
if (!S.currentProjectId) {
root.innerHTML = `<div class="tree-empty"><b>NO PROJECT</b>先在左侧新建或选择一个项目</div>`;
return;
}
if (!S.tasks.length) {
root.innerHTML = `<div class="tree-empty"><b>EMPTY</b>还没有任务 —— 点上方「+ 新建任务」开始</div>`;
return;
}
const m = childrenMap();
const vis = computeVisible();
if (vis && !vis.visible.size) {
root.innerHTML = `<div class="tree-empty"><b>NO MATCH</b>没有任务命中当前筛选 —— 放宽条件或「清除筛选」</div>`;
return;
}
const byId = new Map(S.tasks.map((x) => [x.id, x]));
const renderNode = (t) => {
if (vis && !vis.visible.has(t.id)) return '';
const kids = m.get(t.id) || [];
const collapsed = !vis && S.collapsed.has(t.id); // 筛选时强制展开,保证命中可见
const expanded = S.expanded.has(t.id);
const isGate = !!GATE_OF[t.status];
const isCtx = vis && !vis.matched.has(t.id); // 仅作为父链保留的节点,弱化显示
const caret = kids.length
? `<span class="caret ${collapsed ? '' : 'open'}" data-action="toggle-collapse" data-id="${esc(t.id)}">▶</span>`
: `<span class="caret leaf">·</span>`;
return `
<div class="task-node depth-${t.depth}">
<div class="task-row ${isGate ? 'is-gate' : ''} ${expanded ? 'expanded' : ''} ${isCtx ? 'filter-ctx' : ''}" data-action="toggle-detail" data-id="${esc(t.id)}">
${caret}
<span class="t-title">${esc(t.title)}<span class="t-id">${esc(t.id.slice(-6))}</span></span>
<span class="spacer"></span>
${(() => {
// blocked(系统按依赖自动落位)→ 显示在等哪几条
if (t.status !== 'blocked' || !(t.deps || []).length) return '';
const unmet = t.deps.filter((d) => byId.get(d)?.status !== 'done');
if (!unmet.length) return '';
return `<span class="t-deps-wait" title="等待依赖完成:${unmet.map((d) => esc(byId.get(d)?.title || d)).join('、')}">⛓ 等依赖 ${unmet.length}</span>`;
})()}
<span class="t-prio ${t.priority === 0 ? 'hot' : ''}">P${t.priority}</span>
${cplxBadge(t.complexity, t.id)}
${statusChip(t.status)}
</div>
${expanded ? renderDetail(t) : ''}
${kids.length && !collapsed ? `<div class="task-children">${kids.map(renderNode).join('')}</div>` : ''}
</div>`;
};
root.innerHTML = (m.get('__root__') || []).map(renderNode).join('');
}
// ── 渲染:任务详情 ──
function writableField(t) {
if (t.status === 'analyzing') return { field: 'plan', label: '分析与拆解(plan', next: 'plan_review' };
if (t.status === 'speccing') return { field: 'spec', label: '改动方案(spec', next: 'spec_review' };
if (t.status === 'ready') return { field: 'operations', label: '将执行的操作(operations', next: null };
return null;
}
function renderDetail(t) {
const parts = [];
const w = writableField(t);
// 产出全部只读展示:写产出与提交评审由 Claude Code 经 MCP 完成,看板只留用户动作(审批/配置/同步)
const docs = [['plan', 'PLAN · 分析拆解'], ['spec', 'SPEC · 方案'], ['operations', 'OPERATIONS · 操作']];
for (const [f, label] of docs) {
if (!t[f]) continue;
parts.push(`<div><div class="detail-label">${label}</div><div class="doc md">${mdToHtml(t[f])}</div></div>`);
}
// 当前阶段应产出但还没有内容 → 占位提示
if (w && !t[w.field]) {
parts.push(`<div><div class="detail-label">${w.label}</div><div class="doc doc-pending">待 Claude Code 产出(经 MCP 写入并提交评审)</div></div>`);
}
// result
if (t.result) {
const r = t.result;
parts.push(`
<div><div class="detail-label">RESULT · 执行结果</div>
<dl class="result-kv">
<dt>分支</dt><dd>${esc(r.branch || '—')}</dd>
<dt>worktree</dt><dd>${esc(r.worktree || '—')}</dd>
${r.prUrl ? `<dt>PR</dt><dd>${esc(r.prUrl)}</dd>` : ''}
${r.commits && r.commits.length ? `<dt>commits</dt><dd>${r.commits.map(esc).join('<br>')}</dd>` : ''}
</dl>
${r.diffSummary ? `<pre class="doc" style="margin-top:6px">${esc(r.diffSummary)}</pre>` : ''}
</div>`);
}
// 审批历史
if (t.approvals && t.approvals.length) {
parts.push(`
<div><div class="detail-label">审批历史</div>
${t.approvals.map((a) => `
<div class="approval-item">
<span class="ap-act-${a.action}">${a.action === 'accept' ? '✓ 通过' : '✗ 驳回'}</span>
<span class="ap-gate">${GATE_LABEL[a.gate] || esc(a.gate)} · ${esc(a.actor)}</span>
<span class="ap-time">${fmtTime(a.at)}</span>
${a.reason ? `<span class="ap-reason">${esc(a.reason)}</span>` : ''}
</div>`).join('')}
</div>`);
}
// 依赖列表:标题 + 状态 chip,未完成的高亮(全部 done 本任务才可被领取)
if (t.deps && t.deps.length) {
const items = t.deps.map((d) => {
const dt = S.tasks.find((x) => x.id === d);
const ok = dt && dt.status === 'done';
return `<div class="dep-item ${ok ? 'ok' : 'wait'}" data-action="goto-task" data-id="${esc(d)}" title="点击定位到该任务">
<span class="dep-mark">${ok ? '✓' : '⛓'}</span>
${dt ? statusChip(dt.status) : ''}
<span class="dep-title">${esc(dt ? dt.title : d)}</span>
<span class="t-id">${esc(d.slice(-6))}</span>
<span class="dep-go">↧</span>
</div>`;
}).join('');
parts.push(`<div><div class="detail-label">DEPS · 依赖(全部完成才可执行)</div><div class="dep-list">${items}</div></div>`);
}
if (!parts.length) parts.push(`<div class="proj-meta">暂无产出与历史 —— 状态:${STATUS_LABEL[t.status]}</div>`);
parts.push(`<div class="proj-meta">id ${esc(t.id)} · 深度 ${t.depth} · 创建 ${fmtTime(t.createdAt)} · 更新 ${fmtTime(t.updatedAt)}</div>`);
return `<div class="task-detail"><div class="detail-grid">${parts.join('')}</div></div>`;
}
// ── 渲染:事件流 ──
function eventDetail(e) {
const t = S.tasks.find((x) => x.id === e.taskId);
const name = t ? t.title : (e.taskId ? e.taskId.slice(-6) : '');
const p = e.payload || {};
if (e.type === 'status.changed') {
return `${name}${STATUS_LABEL[p.from] || p.from}${STATUS_LABEL[p.to] || p.to}`;
}
if (e.type === 'approval.granted' || e.type === 'approval.rejected') {
const r = p.reason ? `${p.reason}` : '';
return `${name} · ${GATE_LABEL[p.gate] || p.gate}${r}`;
}
if (e.type === 'task.created') {
if (p.kind === 'project') return `项目「${p.name || ''}」已登记`;
return `${p.title || name}${CPLX_LABEL[p.complexity] || ''}`;
}
if (e.type === 'task.updated') return `${name} · 字段 ${p.field || ''}`;
if (e.type === 'project.synced') {
return `新建 ${p.created ?? 0} · 推进 done ${p.doneAdvanced ?? 0} · 跳过 ${p.skipped ?? 0}`;
}
if (e.type === 'run.started' || e.type === 'run.finished') {
return `${name} · ${p.kind || ''} ${p.status || ''}`;
}
return name;
}
function renderEvents() {
const ul = $('#eventList');
if (!S.events.length) {
ul.innerHTML = `<li class="ev-empty">暂无事件</li>`;
return;
}
ul.innerHTML = S.events.slice(0, 80).map((e) => `
<li class="${EVENT_CLASS[e.type] || ''}">
<div class="ev-head">
<span class="ev-type">${EVENT_LABEL[e.type] || esc(e.type)}</span>
<span class="ev-time">${fmtTime(e.at)}</span>
</div>
<div class="ev-detail">${eventDetail(e)}</div>
</li>`).join('');
}
// ── 渲染:父任务下拉 ──
function renderParentOptions() {
const sel = document.querySelector('#newTaskPanel select[name=parentId]');
const cur = sel.value;
sel.innerHTML = `<option value="">(顶层)</option>` +
S.tasks.map((t) => `<option value="${esc(t.id)}">${esc('· '.repeat(t.depth - 1) + t.title)}</option>`).join('');
sel.value = cur;
}
// ── 动作 ──
async function act(fn, okMsg) {
try {
await fn();
if (okMsg) toast(okMsg, 'ok');
await refresh();
} catch (e) { toast(e.message); }
}
document.addEventListener('click', (ev) => {
const el = ev.target.closest('[data-action]');
// 复杂度下拉:点击弹层外任意处关闭
if (S.cplxMenuFor && !ev.target.closest('.cplx-wrap')) {
S.cplxMenuFor = null;
renderTree();
}
if (!el) return;
const action = el.dataset.action;
const id = el.dataset.id;
switch (action) {
case 'select-project':
if (S.currentProjectId !== id) {
S.currentProjectId = id;
S.expanded.clear(); S.collapsed.clear(); S.rejectOpen.clear();
S.cplxMenuFor = null;
$('#configPanel').hidden = true;
refresh();
}
break;
case 'open-new-project':
$('#modalRoot').hidden = false;
document.querySelector('#newProjectForm input[name=name]').focus();
break;
case 'close-modal':
$('#modalRoot').hidden = true;
break;
case 'toggle-new-task': {
const p = $('#newTaskPanel');
p.hidden = !p.hidden;
if (!p.hidden) p.querySelector('input[name=title]').focus();
break;
}
case 'toggle-collapse':
ev.stopPropagation();
S.collapsed.has(id) ? S.collapsed.delete(id) : S.collapsed.add(id);
renderTree();
break;
case 'toggle-detail': {
// 点的是 caret 时由上面分支处理(stopPropagation);输入区内点击不折叠
if (ev.target.closest('.task-detail')) break;
S.expanded.has(id) ? S.expanded.delete(id) : S.expanded.add(id);
renderTree();
break;
}
case 'gate-accept':
act(() => api(`/api/tasks/${id}/decide`, {
method: 'POST', body: JSON.stringify({ action: 'accept' }),
}), '已接受');
break;
case 'gate-reject-toggle':
S.rejectOpen.has(id) ? S.rejectOpen.delete(id) : S.rejectOpen.add(id);
renderGates();
if (S.rejectOpen.has(id)) {
const ta = document.getElementById(`rej-${id}`);
if (ta) ta.focus();
}
break;
case 'preview-open':
S.previewId = id;
S.previewReject = false;
renderPreview();
break;
case 'preview-close':
S.previewId = null;
S.previewReject = false;
renderPreview();
break;
case 'preview-reject-toggle':
S.previewReject = !S.previewReject;
renderPreview();
break;
case 'gate-reject-confirm': {
const ta = document.getElementById(el.dataset.ta || `rej-${id}`);
const reason = ta ? ta.value.trim() : '';
if (!reason) { toast('驳回意见不能为空'); if (ta) ta.focus(); return; }
S.rejectOpen.delete(id);
act(() => api(`/api/tasks/${id}/decide`, {
method: 'POST', body: JSON.stringify({ action: 'reject', reason }),
}), '已驳回,任务退回返工');
break;
}
case 'goto-task': {
const target = S.tasks.find((x) => x.id === id);
if (!target) { toast('任务不在当前项目'); break; }
// 被筛选隐藏时清除筛选,保证可见
const vis = computeVisible();
if (vis && !vis.visible.has(id)) {
S.filter.cplx.clear(); S.filter.status.clear(); S.filter.kw = '';
renderFilterBar();
}
// 展开全部祖先 + 展开目标详情
let cur = target;
while (cur && cur.parentId) {
S.collapsed.delete(cur.parentId);
cur = S.tasks.find((x) => x.id === cur.parentId);
}
S.expanded.add(id);
renderTree();
const row = document.querySelector(`.task-row[data-id="${CSS.escape(id)}"]`);
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
row.classList.add('flash');
setTimeout(() => row.classList.remove('flash'), 1800);
}
break;
}
case 'cplx-menu':
S.cplxMenuFor = S.cplxMenuFor === id ? null : id;
renderTree();
break;
case 'cplx-set': {
const c = el.dataset.cplx;
S.cplxMenuFor = null;
act(() => api(`/api/tasks/${id}`, {
method: 'PATCH', body: JSON.stringify({ complexity: c }),
}), `复杂度已改为 ${CPLX_LABEL[c]}(状态按新复杂度重置)`);
break;
}
// ── 任务树筛选 ──
case 'filter-cplx': {
const c = el.dataset.cplx;
S.filter.cplx.has(c) ? S.filter.cplx.delete(c) : S.filter.cplx.add(c);
renderTree(); renderFilterBar();
break;
}
case 'filter-status': {
const st = el.dataset.st;
S.filter.status.has(st) ? S.filter.status.delete(st) : S.filter.status.add(st);
renderTree(); renderFilterBar();
break;
}
case 'filter-group': {
const g = FILTER_GROUPS[Number(el.dataset.gi)];
if (!g) break;
const sts = g[1];
const all = sts.every((s) => S.filter.status.has(s));
sts.forEach((s) => { all ? S.filter.status.delete(s) : S.filter.status.add(s); });
renderTree(); renderFilterBar();
break;
}
case 'filter-clear':
S.filter.cplx.clear();
S.filter.status.clear();
S.filter.kw = '';
$('#filterKw').value = '';
renderTree(); renderFilterBar();
break;
// ── 同步 todo ──
case 'sync-todo': {
if (!S.currentProjectId) break;
el.disabled = true;
S.syncReqAt = Date.now();
api(`/api/projects/${S.currentProjectId}/sync`, { method: 'POST', body: JSON.stringify({}) })
.then((r) => {
toast(`同步完成:新建 ${r.created ?? 0} · 推进 done ${r.doneAdvanced ?? 0} · 跳过 ${r.skipped ?? 0}`, 'ok');
(r.warnings || []).forEach((wmsg) => toast(String(wmsg), 'warn'));
return fullRefresh();
})
.catch((e) => { toast(e.message); el.disabled = false; });
break;
}
// ── Agent 配置 ──
case 'toggle-config': {
const panel = $('#configPanel');
panel.hidden = !panel.hidden;
if (!panel.hidden) {
const p = S.projects.find((x) => x.id === S.currentProjectId);
if (p) {
$('#cfgConcurrency').value = p.concurrency ?? 1;
$('#cfgAutonomy').value = AUTONOMY_LABEL[p.autonomy] ? p.autonomy : 'manual';
}
}
break;
}
case 'save-config': {
if (!S.currentProjectId) break;
const cc = Number($('#cfgConcurrency').value);
if (!Number.isInteger(cc) || cc < 1) { toast('最大并发必须是 ≥1 的整数'); break; }
const autonomy = $('#cfgAutonomy').value;
act(async () => {
await api(`/api/projects/${S.currentProjectId}`, {
method: 'PATCH', body: JSON.stringify({ concurrency: cc, autonomy }),
});
await loadProjects(); // 刷新当前值显示
}, '配置已保存');
break;
}
}
});
// 阻止详情区域内点击冒泡触发折叠
document.addEventListener('click', (ev) => {
if (ev.target.closest('.task-detail') && !ev.target.closest('[data-action]')) {
ev.stopPropagation();
}
}, true);
// ── 表单提交 ──
$('#newProjectForm').addEventListener('submit', (ev) => {
ev.preventDefault();
const f = ev.target;
const name = f.name.value.trim();
const repoPath = f.repoPath.value.trim();
if (!name || !repoPath) { toast('名称与仓库路径必填'); return; }
const body = { name, repoPath };
if (f.defaultBranch.value.trim()) body.defaultBranch = f.defaultBranch.value.trim();
if (f.verifyCmd.value.trim()) body.verifyCmd = f.verifyCmd.value.trim();
act(async () => {
const p = await api('/api/projects', { method: 'POST', body: JSON.stringify(body) });
S.currentProjectId = p.id;
f.reset();
$('#modalRoot').hidden = true;
await loadProjects();
}, `项目「${name}」已创建`);
});
$('#newTaskPanel').addEventListener('submit', (ev) => {
ev.preventDefault();
const f = ev.target;
if (!S.currentProjectId) { toast('请先选择项目'); return; }
const title = f.title.value.trim();
if (!title) { toast('标题必填'); return; }
const body = {
title,
complexity: f.complexity.value,
priority: Number(f.priority.value ?? 1),
};
if (f.parentId.value) body.parentId = f.parentId.value;
act(async () => {
await api(`/api/projects/${S.currentProjectId}/tasks`, {
method: 'POST', body: JSON.stringify(body),
});
f.title.value = '';
}, '任务已创建');
});
// 关键字筛选(轻防抖)
let kwTimer = null;
$('#filterKw').addEventListener('input', (ev) => {
S.filter.kw = ev.target.value;
clearTimeout(kwTimer);
kwTimer = setTimeout(() => { renderTree(); renderFilterBar(); }, 150);
});
// Esc 关模态
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') {
if (S.previewId) { S.previewId = null; S.previewReject = false; renderPreview(); return; }
$('#modalRoot').hidden = true;
}
});
// ── WebSocket 实时刷新(指数退避重连) ──
let wsAttempt = 0;
let refreshTimer = null;
function setWsState(on, text) {
$('#wsDot').className = `ws-dot ${on ? 'on' : 'off'}`;
$('#wsText').textContent = text;
}
function scheduleRefresh() {
if (refreshTimer) return;
refreshTimer = setTimeout(() => { refreshTimer = null; refresh(); }, 200);
}
function connectWs() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => {
wsAttempt = 0;
setWsState(true, '实时连接');
fullRefresh(); // 重连后补齐错过的状态
};
ws.onmessage = (msg) => {
let evt;
try { evt = JSON.parse(msg.data); } catch { return; }
if (evt.type === 'project.synced') {
// 本端刚发起的 sync 由 POST 响应负责 toast,避免重复
if (Date.now() - S.syncReqAt > 3000) {
const p = evt.payload || {};
toast(`todo 同步:新建 ${p.created ?? 0} · 推进 done ${p.doneAdvanced ?? 0} · 跳过 ${p.skipped ?? 0}`, 'ok');
}
fullRefresh();
return;
}
if (evt.type === 'run.started' || evt.type === 'run.finished') {
loadAgents().then(renderAgents).catch(() => {});
}
if (evt.projectId === S.currentProjectId) {
scheduleRefresh();
} else if (evt.type === 'task.created' && evt.payload && evt.payload.kind === 'project') {
loadProjects().then(renderSidebar).catch(() => {});
}
};
const retry = () => {
wsAttempt += 1;
const delay = Math.min(30000, 1000 * Math.pow(2, wsAttempt - 1));
setWsState(false, `已断开 · ${Math.round(delay / 1000)}s 后重连`);
setTimeout(connectWs, delay);
};
ws.onclose = retry;
ws.onerror = () => { try { ws.close(); } catch { /* noop */ } };
}
// 相对时间(agent 开始时间 / 上次同步)定期重绘
setInterval(() => { renderAgents(); renderTopbar(); }, 30000);
// ── 启动 ──
fullRefresh().then(connectWs);