Files
maestro/web/app.js
T
wangjia 7884182d2b feat: 编排器跨项目全局并发上限闸(tsk_erQHNYdn6i9g)
新增用户级设置项 globalConcurrency(settings 表,独立于新建项目默认值
来源 concurrency),缺省 0 = 不限,向后兼容无需 DB 迁移。

- store: UserSettings 加 globalConcurrency 字段 + 默认 0;新增
  globalInflightCount()(跨所有项目 started run 总数,与 inflightTaskIds 同口径)
- orchestrator.claimTick: 叠加全局闸,与 per-project 闸串联(都过才领);
  轮初查一次全局在途、轮内手动 ++,达上限即 return 跨所有项目停止领取
- api: PUT /api/settings 支持 globalConcurrency(空/null 归一为 0)
- web: 侧栏「全局设置」入口 + 模态,编辑/回显/校验(≥0 整数)
- test: 新增全局闸达上限跨项目阻止 / 未达上限正常领取 / 边界 / 轮初短路 用例

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:11:42 +08:00

2370 lines
117 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';
// ══════════════════════════════════════════════════════════════
// i18n · zh / en 双语(任务内容/项目名/转录等用户数据不翻译)
// ══════════════════════════════════════════════════════════════
const I18N = {
zh: {
logoSub: '多项目任务调度台',
projects: '项目', newProject: '+ 新建',
connecting: '连接中…', liveConn: '实时连接',
disconnected: '已断开 · {n}s 后重连',
badgeGate: '待审批', badgeReady: '待执行', badgeRun: '执行中', badgeBlocked: '被阻塞', badgeTotal: '总量',
badgeGateTip: '等待你裁决的审批闸',
badgeReadyTip: '可执行(依赖已满足)+ 排队中',
badgeRunTip: 'agent 正在执行',
badgeBlockedTip: '被依赖阻塞(依赖完成后自动放行)',
badgeTotalTip: '未完成任务总量(不含 done / 已拆解容器 / 取消)',
sync: '同步 todo', config: '配置', configTip: '项目配置(并发 / 工作模式 / todo 同步)',
maxConcurrency: '最大并发', workMode: '工作模式', current: '当前',
save: '保存配置', collapse: '收起',
projLogo: 'Logo', logoPh: '图片 URL 或仓库内相对路径;留空=自动',
verifyCmd: '校验命令', verifyPh: 'npm test · go test ./...(可空)',
model: '项目 Model', modelPh: 'claude-opus-4-5(可空,留空用全局默认)',
cfgGates: '硬闸 / 自动放行', cfgChecks: '分项检查(lint / typecheck / build',
cfgLint: 'lint 命令', cfgTypecheck: 'typecheck 命令', cfgBuild: 'build 命令', cfgCmdPh: '留空=跳过此项',
cfgAutoPlan: '自动放行 plan(全 easy 子任务跳过 plan_review',
cfgAutoExec: '自动放行 exec(双复审 approve 后自动合并)',
cfgAgentRules: '项目 Agent 规范(注入执行/复审 prompt', cfgAgentRulesPh: '项目专属执行约定、风格、禁忌……(可空)',
cfgBudget: '成本预算', cfgBudgetPh: 'USD 上限(可空=不限)', cfgBudgetDay: '按天', cfgBudgetMonth: '按月',
cfgModels: '角色模型(留空=默认 opus-4.8;可填模型 id 或 {"easy":..,"hard":..} JSON',
cfgMdlExecutor: 'executor', cfgMdlPlanner: 'planner', cfgMdlReviewer: 'reviewer', cfgMdlConflict: 'conflict',
cfgModelsBad: 'models 角色「{role}」JSON 格式不对',
autonomy: { manual: 'manual · 手动', 'auto-easy': 'auto-easy · 自动执行 Easy', 'auto-approved': 'auto-approved · 自动执行已批准' },
autonomyShort: { manual: '手动', 'auto-easy': '自动 · Easy', 'auto-approved': '自动 · 已批准' },
agentSection: 'Agent 执行', agentEmpty: '无 agent 在执行', running: 'RUNNING', since: '开始',
usageUnavail: '额度信息不可用', quota: '额度', quotaWeek: '周', quotaReset: '{t} 后重置',
gateSection: '审批闸 · 等待裁决', gatePreview: '⛶ 预览',
attnLabel: '需人工确认', attnReason: '失败原因', attnNoReason: '失败原因未记录',
attnRequeue: '↺ 重新排队', attnCancel: '✕ 取消任务',
gateAcceptMerge: '✓ 通过并合并', gateAccept: '✓ 接受', gateOnlyPass: '仅通过',
gateMergeNote: '通过 = 自动把任务分支合并到 {branch}(仅本地合并,不 push',
gateReject: '✗ 拒绝', rejectPlaceholder: '驳回意见(必填)——说明问题与改进方向',
confirmReject: '确认驳回',
taskSection: '任务树', newTask: '+ 新建任务',
titleLabel: '标题', titlePlaceholder: '要做什么',
complexity: '复杂度', cplxAuto: 'AUTO',
parentTask: '父任务', topLevel: '(顶层)', priority: '优先级',
p0: 'P0 · 高', p1: 'P1 · 中', p2: 'P2 · 低',
create: '创建任务', cancel: '取消',
searchPh: '搜索标题…', clearFilter: '✕ 清除筛选', matchCount: '{n} 项命中 / {total}',
events: '事件流', noEvents: '暂无事件',
sideEmpty: '暂无项目,点「+ 新建」登记第一个仓库',
noProject: '未选择项目', projActive: '活跃', projPaused: '暂停',
cfgCurrent: '当前:并发 {c} · 模式 {m}',
syncMeta: '{t}同步', noTodoTip: '未发现 todo.json — 约定路径 <repo>/todo/todo.json',
syncTip: '从 <repo>/todo/todo.json 同步任务',
archiveSection: '已归档', archiveTip: '点击查看完整详情',
archiveSubs: '含 {n} 子任务', archiveModalTitle: '归档详情',
archiveDelete: '🗑 删除归档', closeTip: '关闭(Esc',
pagePrev: ' 上一页', pageNext: '下一页 ', pageOf: '第 {a} / {b} 页', perPage: '每页',
attrsLabel: '属性', runsLabel: '执行历史({n} 次 run',
resultLabel: '执行结果', approvalsLabel: '审批记录', timelineLabel: '状态流转时间线',
branch: '分支', accepted: '接受', rejected: '驳回', approver: '审批人',
runKind: { executor: '执行', reviewer: 'code review', security: '安全审计', planner: '规划' },
runSt: { succeeded: '成功', failed: '失败', started: '进行中', cancelled: '取消' },
planDocLabel: 'PLAN · 分析与拆解', specDocLabel: 'SPEC · 改动方案', opsDocLabel: 'OPERATIONS · 执行的操作',
planDetailLabel: 'PLAN · 分析拆解', specDetailLabel: 'SPEC · 方案', opsDetailLabel: 'OPERATIONS · 操作',
resultDetailLabel: 'RESULT · 执行结果', diffLabel: 'DIFF 摘要',
mergeStatus: '合并状态',
mergeFailedBanner: '⚠️ 自动合并失败(冲突)→ 已建最高优先级补救任务',
mergeRemNote: '补救任务解决冲突并合并后,本任务将自动标记完成,无需手动「仅通过」。',
reviewLabel: 'CODE REVIEW', securityLabel: '安全审计',
verdictApprove: '建议通过', verdictReject: '建议拒绝', verdictNone: '未复审',
pendingDoc: '待 Claude Code 产出(经 MCP 写入并提交评审)',
approvalsHistLabel: '审批历史',
depsLabel: 'DEPS · 依赖(全部完成才可执行)',
depEmpty: '暂无依赖', depEdit: '✎ 编辑',
depsEditLabel: 'DEPS · 编辑依赖(勾选当前项目其它任务;已排除自身与会成环者)',
depNoOthers: '本项目暂无其它可选任务', saveDeps: '保存依赖({n}',
noOutput: '暂无产出与历史 —— 状态:{st}',
foldExpand: '展开({n} 字符)',
transcriptView: '▶ 查看转录', transcriptHide: '▼ 收起转录',
transcriptLoading: '加载中…', transcriptEmpty: '(无可展示的转录内容)',
transcriptBadgeA: 'assistant', transcriptBadgeR: '结论',
transcriptBadgeW: '模型回退', transcriptBadgeT: '工具调用',
transcriptBadgeTR: '工具结果', transcriptBadgeTRErr: '工具结果 · 报错',
transcriptTrunc: '…(已截断)', fullRead: '全屏阅读',
treeNoProject: '先在左侧新建或选择一个项目',
treeEmpty: '还没有任务 —— 点上方「+ 新建任务」开始',
treeAllDone: '全部任务已完成归档(见页面底部归档区)',
treeNoMatch: '没有任务命中当前筛选 —— 放宽条件或「清除筛选」',
depsWait: '等依赖 {n}',
metricsSection: '指标 · 健康度与成本', metricsTasks: '· {n} 任务',
metricDur: '平均执行时长', metricDurSub: 'P50 {p50} · P95 {p95} · n={n}', metricDurNone: '暂无已结束执行',
metricSucc: '执行成功率', metricSuccSub: '{ok}/{total} 成功 · 失败 {fail}', metricSuccNone: '暂无执行',
metricReview: '复审通过率', metricReviewSub: '复审 {ra}/{rt} · 安全 {sr} ({sa}/{st2})',
metricRetry: '重试率', metricRetrySub: '重试 {r} 次 · 需人工 {n}',
metricCost: '额度消耗', metricCostSub: '执行 {dur} · {est}', metricEstTag: '估算',
metricModelHead: '按模型分布', metricEst: '(时长×档位 粗估)',
fgroups: { todo: '待办', doing: '进行中', container: '容器', gate: '待审批', bad: '异常', hold: '挂起' },
status: { init: '新建', analyzing: '分析拆解中', plan_review: '待确认拆解', decomposed: '已拆解', speccing: '写方案中', spec_review: '待确认方案', ready: '可执行', blocked: '被阻塞', queued: '排队中', executing: '执行中', exec_review: '待审/合', failed: '失败', needs_attention: '需人工', done: '完成', paused: '暂停', cancelled: '取消' },
gates: { plan: '拆解评审', spec: '方案评审', exec: '结果评审' },
eventTypes: { 'task.created': '任务创建', 'task.updated': '任务更新', 'status.changed': '状态变更', 'approval.requested': '请求审批', 'approval.granted': '审批通过', 'approval.rejected': '审批驳回', 'run.started': '运行开始', 'run.finished': '运行结束', 'project.synced': 'todo 同步', 'merge.failed': '合并失败', 'merge.remediated': '补救完成 → 完成' },
tlAuto: { 'deps-met': '依赖满足自动放行', 'deps-reconcile': '启动对账', 'deps-unmet': '依赖未满足落位', 'children-done': '子任务全完成自动收口', 'import-done': '导入历史完成', 'daemon-restart': '重启恢复', 'merge-remediated': '补救完成自动收口' },
tlOrchestrator: '编排器', tlImporter: '导入器',
tlApproveGranted: '审批通过({gate}', tlApproveRejected: '审批驳回({gate}{reason}',
tlRunStarted: 'run 开始({kind}', tlRunFinished: 'run 结束({st}',
tlMergeFailed: '合并失败{files}{task}', tlMergeOk: '补救完成 → 完成{task}',
tlMergeFailFiles: '(冲突文件:{files}', tlMergeTask: ' → 补救任务「{title}」',
tlMergeOkTask: '(补救任务「{title}」已合并)',
evStatusChange: '{name}{from} → {to}', evProjectReg: '项目「{name}」已登记',
evTaskCreated: '{title}{cplx}', evMergeFail: '{name}:合并失败{files} → 补救任务',
evMergeOk: '{name}:补救完成 → 完成', evTaskUpdated: '{name} · 字段 {field}',
evSynced: '新建 {created} · 推进 done {done} · 跳过 {skipped}',
conflictFiles: '(冲突文件:{files}',
toastCreated: '任务已创建', toastAccepted: '已接受',
toastAcceptedNoMerge: '已通过(未合并,分支保留)',
toastRejected: '已驳回,任务退回返工',
toastSaved: '配置已保存',
toastSynced: '同步完成:新建 {created} · 推进 done {done} · 跳过 {skipped}',
toastWsSynced: 'todo 同步:新建 {created} · 推进 done {done} · 跳过 {skipped}',
toastCancelled: '任务已取消', toastRequeued: '任务已重新排队', toastDeleted: '任务已删除',
toastDeletedWithSubs: '任务已删除(含 {n} 子孙)',
toastArchiveDeleted: '归档任务已删除', toastArchiveDeletedSubs: '归档任务已删除(含 {n} 子孙)',
toastDepsUpdated: '依赖已更新({n} 项,按依赖重算 ready/blocked',
toastCplxSet: '复杂度已改为 {c}(状态按新复杂度重置)',
toastProjectCreated: '项目「{name}」已创建',
toastTaskNoProject: '请先选择项目', toastTitleRequired: '标题必填',
toastNameRequired: '名称与仓库路径必填',
toastTaskNotFound: '任务不在当前项目',
toastConcInt: '最大并发必须是 ≥1 的整数',
globalSettings: '全局设置', globalConc: '全局并发上限',
globalConcHint: '跨所有项目的在途任务总数上限;0 = 不限。',
globalConcInt: '全局并发上限必须是 ≥0 的整数(0 = 不限)',
toastSettingsSaved: '全局设置已保存',
toastRejectRequired: '驳回意见不能为空',
reorderSaved: '项目顺序已保存',
cancelConfirm: '确定取消任务「{title}」{hint}?\n取消后任务进入归档,不影响子任务执行。',
cancelHint: '(含 {n} 个直接子任务,子任务保持原状)',
deleteConfirm: '彻底删除「{title}」{hint}?\n此操作不可恢复,所有相关数据(执行历史、审批记录)将一并删除。',
deleteHint: '及其 {n} 个子孙任务',
cancelDetailMsg: '确定取消任务「{title}」?(将进入归档,子任务保持原状)',
deleteDetailMsg: '彻底删除「{title}」{hint}?此操作不可恢复!',
deleteDetailHint: '(含 {n} 个直接子任务,及其全部子孙)',
cancelBtn: '取消任务', cancelConfirmBtn: '确认取消', cancelAbortBtn: '算了',
deleteBtn: '🗑 删除', deleteBtnHint: '🗑 删除(含 {n} 子任务)',
deleteConfirmBtn: '确认删除', deleteAbortBtn: '算了',
archiveDeleteConfirm: '彻底删除「{title}」{hint}?\n此操作不可恢复,所有相关数据将一并删除。',
archiveDeleteHint: '及其 {n} 个子孙任务',
lang: '中文', langTip: '切换语言 / Switch language',
scheduleScore: '调度 score 优先', models: '模型',
},
en: {
logoSub: 'MULTI-PROJECT ORCHESTRATOR',
projects: 'PROJECTS', newProject: '+ New',
connecting: 'Connecting…', liveConn: 'Live',
disconnected: 'Disconnected · retry in {n}s',
badgeGate: 'pending review', badgeReady: 'to run', badgeRun: 'running', badgeBlocked: 'blocked', badgeTotal: 'total',
badgeGateTip: 'Approval gates awaiting your decision',
badgeReadyTip: 'Ready (deps met) + queued',
badgeRunTip: 'Agents currently executing',
badgeBlockedTip: 'Blocked (auto-released when deps complete)',
badgeTotalTip: 'Open tasks (excl. done / decomposed containers / cancelled)',
sync: 'Sync todo', config: 'Config', configTip: 'Project config (concurrency / autonomy / todo sync)',
maxConcurrency: 'Max concurrency', workMode: 'Autonomy', current: 'Current',
save: 'Save config', collapse: 'Close',
projLogo: 'Logo', logoPh: 'Image URL or repo-relative path; empty = auto',
verifyCmd: 'Verify command', verifyPh: 'npm test · go test ./... (optional)',
model: 'Project Model', modelPh: 'claude-opus-4-5 (optional, empty = global default)',
cfgGates: 'Gates / auto-approve', cfgChecks: 'Per-step checks (lint / typecheck / build)',
cfgLint: 'lint command', cfgTypecheck: 'typecheck command', cfgBuild: 'build command', cfgCmdPh: 'empty = skip',
cfgAutoPlan: 'Auto-approve plan (skip plan_review when all-easy)',
cfgAutoExec: 'Auto-approve exec (auto-merge after dual review approves)',
cfgAgentRules: 'Project agent rules (injected into exec/review prompts)', cfgAgentRulesPh: 'Project-specific conventions, style, taboos… (optional)',
cfgBudget: 'Cost budget', cfgBudgetPh: 'USD cap (empty = unlimited)', cfgBudgetDay: 'per day', cfgBudgetMonth: 'per month',
cfgModels: 'Per-role models (empty = default opus-4.8; model id or {"easy":..,"hard":..} JSON)',
cfgMdlExecutor: 'executor', cfgMdlPlanner: 'planner', cfgMdlReviewer: 'reviewer', cfgMdlConflict: 'conflict',
cfgModelsBad: 'models role "{role}" has invalid JSON',
autonomy: { manual: 'manual · manual', 'auto-easy': 'auto-easy · Auto Easy', 'auto-approved': 'auto-approved · Auto approved' },
autonomyShort: { manual: 'Manual', 'auto-easy': 'Auto · Easy', 'auto-approved': 'Auto · Approved' },
agentSection: 'Agent runs', agentEmpty: 'No agents running', running: 'RUNNING', since: '',
usageUnavail: 'Usage info unavailable', quota: 'Quota', quotaWeek: 'week', quotaReset: 'resets in {t}',
gateSection: 'Approval gates · awaiting decision', gatePreview: '⛶ Preview',
attnLabel: 'Needs attention', attnReason: 'Failure reason', attnNoReason: 'No failure reason recorded',
attnRequeue: '↺ Re-queue', attnCancel: '✕ Cancel task',
gateAcceptMerge: '✓ Accept & merge', gateAccept: '✓ Accept', gateOnlyPass: 'Pass only',
gateMergeNote: 'Accept = merge task branch into {branch} (local only, no push)',
gateReject: '✗ Reject', rejectPlaceholder: 'Rejection feedback (required) — describe issues',
confirmReject: 'Confirm reject',
taskSection: 'Task tree', newTask: '+ New task',
titleLabel: 'Title', titlePlaceholder: 'What needs doing',
complexity: 'Complexity', cplxAuto: 'AUTO',
parentTask: 'Parent', topLevel: '(top level)', priority: 'Priority',
p0: 'P0 · high', p1: 'P1 · medium', p2: 'P2 · low',
create: 'Create task', cancel: 'Cancel',
searchPh: 'Search titles…', clearFilter: '✕ Clear filters', matchCount: '{n} / {total} matches',
events: 'Events', noEvents: 'No events yet',
sideEmpty: 'No projects — click "+ New" to register a repo',
noProject: 'No project selected', projActive: 'Active', projPaused: 'Paused',
cfgCurrent: 'Current: concurrency {c} · mode {m}',
syncMeta: 'synced {t}', noTodoTip: 'todo.json not found — expected at <repo>/todo/todo.json',
syncTip: 'Sync from <repo>/todo/todo.json',
archiveSection: 'Archive', archiveTip: 'Click for full detail',
archiveSubs: '{n} subtasks', archiveModalTitle: 'Archive detail',
archiveDelete: '🗑 Delete', closeTip: 'Close (Esc)',
pagePrev: ' Prev', pageNext: 'Next ', pageOf: 'Page {a} / {b}', perPage: 'Per page',
attrsLabel: 'ATTRIBUTES', runsLabel: 'RUN HISTORY ({n} runs)',
resultLabel: 'RESULT', approvalsLabel: 'APPROVALS', timelineLabel: 'STATUS TIMELINE',
branch: 'Branch', accepted: 'accepted', rejected: 'rejected', approver: 'Approver',
runKind: { executor: 'execute', reviewer: 'code review', security: 'security audit', planner: 'plan' },
runSt: { succeeded: 'succeeded', failed: 'failed', started: 'in progress', cancelled: 'cancelled' },
planDocLabel: 'PLAN · Analysis & breakdown', specDocLabel: 'SPEC · Change spec', opsDocLabel: 'OPERATIONS · Planned ops',
planDetailLabel: 'PLAN · Analysis', specDetailLabel: 'SPEC · Proposal', opsDetailLabel: 'OPERATIONS · Actions',
resultDetailLabel: 'RESULT · Execution result', diffLabel: 'DIFF SUMMARY',
mergeStatus: 'Merge status',
mergeFailedBanner: '⚠️ Auto-merge failed (conflict) → remediation task created',
mergeRemNote: 'Once the remediation task resolves conflicts and merges, this task will be auto-completed.',
reviewLabel: 'CODE REVIEW', securityLabel: 'SECURITY AUDIT',
verdictApprove: 'Recommend approve', verdictReject: 'Recommend reject', verdictNone: 'Not reviewed',
pendingDoc: 'Awaiting Claude Code output (written via MCP, then submitted for review)',
approvalsHistLabel: 'Approval history',
depsLabel: 'DEPS (all must complete to run)',
depEmpty: 'No dependencies', depEdit: '✎ Edit',
depsEditLabel: 'DEPS · Edit dependencies (select other tasks; self and cycle candidates excluded)',
depNoOthers: 'No other tasks available in this project', saveDeps: 'Save deps ({n})',
noOutput: 'No output yet — status: {st}',
foldExpand: 'Expand ({n} chars)',
transcriptView: '▶ View transcript', transcriptHide: '▼ Hide transcript',
transcriptLoading: 'Loading…', transcriptEmpty: '(No transcript content)',
transcriptBadgeA: 'assistant', transcriptBadgeR: 'result',
transcriptBadgeW: 'model fallback', transcriptBadgeT: 'tool call',
transcriptBadgeTR: 'tool result', transcriptBadgeTRErr: 'tool result · error',
transcriptTrunc: '…(truncated)', fullRead: 'Read fullscreen',
treeNoProject: 'Create or select a project on the left',
treeEmpty: 'No tasks yet — click "+ New task" above',
treeAllDone: 'All tasks archived (see archive section below)',
treeNoMatch: 'No tasks match current filters — relax filters or "Clear filters"',
depsWait: 'deps {n}',
metricsSection: 'Metrics · Health & cost', metricsTasks: '· {n} tasks',
metricDur: 'Avg run time', metricDurSub: 'P50 {p50} · P95 {p95} · n={n}', metricDurNone: 'No completed runs yet',
metricSucc: 'Success rate', metricSuccSub: '{ok}/{total} succeeded · {fail} failed', metricSuccNone: 'No runs yet',
metricReview: 'Review pass rate', metricReviewSub: 'Review {ra}/{rt} · Security {sr} ({sa}/{st2})',
metricRetry: 'Retry rate', metricRetrySub: '{r} retries · {n} needs attention',
metricCost: 'Cost units', metricCostSub: 'Run time {dur} · {est}', metricEstTag: 'estimate',
metricModelHead: 'By model', metricEst: '(time × tier estimate)',
fgroups: { todo: 'To do', doing: 'In progress', container: 'Container', gate: 'Pending review', bad: 'Issues', hold: 'On hold' },
status: { init: 'New', analyzing: 'Analyzing', plan_review: 'Plan review', decomposed: 'Decomposed', speccing: 'Drafting spec', spec_review: 'Spec review', ready: 'Ready', blocked: 'Blocked', queued: 'Queued', executing: 'Executing', exec_review: 'Review & merge', failed: 'Failed', needs_attention: 'Needs attention', done: 'Done', paused: 'Paused', cancelled: 'Cancelled' },
gates: { plan: 'PLAN REVIEW', spec: 'SPEC REVIEW', exec: 'EXEC REVIEW' },
eventTypes: { 'task.created': 'Task created', 'task.updated': 'Task updated', 'status.changed': 'Status changed', 'approval.requested': 'Approval requested', 'approval.granted': 'Approval granted', 'approval.rejected': 'Approval rejected', 'run.started': 'Run started', 'run.finished': 'Run finished', 'project.synced': 'Todo synced', 'merge.failed': 'Merge failed', 'merge.remediated': 'Remediation done → complete' },
tlAuto: { 'deps-met': 'deps met, auto-released', 'deps-reconcile': 'reconcile started', 'deps-unmet': 'deps unmet, blocked', 'children-done': 'all children done, auto-closed', 'import-done': 'import history done', 'daemon-restart': 'restored on restart', 'merge-remediated': 'remediation done, auto-closed' },
tlOrchestrator: 'orchestrator', tlImporter: 'importer',
tlApproveGranted: 'Approved ({gate})', tlApproveRejected: 'Rejected ({gate}){reason}',
tlRunStarted: 'Run started ({kind})', tlRunFinished: 'Run finished ({st})',
tlMergeFailed: 'Merge failed{files}{task}', tlMergeOk: 'Remediation done → complete{task}',
tlMergeFailFiles: ' (conflicts: {files})', tlMergeTask: ' → remediation: "{title}"',
tlMergeOkTask: ' (remediation "{title}" merged)',
evStatusChange: '{name}: {from} → {to}', evProjectReg: 'Project "{name}" registered',
evTaskCreated: '{title} ({cplx})', evMergeFail: '{name}: merge failed{files} → remediation',
evMergeOk: '{name}: remediation done → complete', evTaskUpdated: '{name} · field {field}',
evSynced: 'Created {created} · advanced done {done} · skipped {skipped}',
conflictFiles: ' (conflicts: {files})',
toastCreated: 'Task created', toastAccepted: 'Accepted',
toastAcceptedNoMerge: 'Accepted (no merge, branch kept)',
toastRejected: 'Rejected — task sent back',
toastSaved: 'Config saved',
toastSynced: 'Synced: {created} created · {done} advanced · {skipped} skipped',
toastWsSynced: 'Todo sync: {created} created · {done} advanced · {skipped} skipped',
toastCancelled: 'Task cancelled', toastRequeued: 'Task re-queued', toastDeleted: 'Task deleted',
toastDeletedWithSubs: 'Task deleted ({n} descendants)',
toastArchiveDeleted: 'Archive deleted', toastArchiveDeletedSubs: 'Archive deleted ({n} descendants)',
toastDepsUpdated: 'Dependencies updated ({n} items, ready/blocked recalculated)',
toastCplxSet: 'Complexity set to {c} (status reset per new complexity)',
toastProjectCreated: 'Project "{name}" created',
toastTaskNoProject: 'Select a project first', toastTitleRequired: 'Title is required',
toastNameRequired: 'Name and repo path are required',
toastTaskNotFound: 'Task not found in current project',
toastConcInt: 'Max concurrency must be an integer ≥1',
globalSettings: 'Settings', globalConc: 'Global concurrency cap',
globalConcHint: 'Max in-flight tasks across all projects; 0 = unlimited.',
globalConcInt: 'Global concurrency cap must be an integer ≥0 (0 = unlimited)',
toastSettingsSaved: 'Settings saved',
toastRejectRequired: 'Rejection feedback is required',
reorderSaved: 'Project order saved',
cancelConfirm: 'Cancel task "{title}"{hint}?\nThis moves the task to archive; subtasks are unaffected.',
cancelHint: ' (includes {n} direct subtasks)',
deleteConfirm: 'Permanently delete "{title}"{hint}?\nThis cannot be undone — all data (runs, approvals) will be removed.',
deleteHint: ' and {n} descendants',
cancelDetailMsg: 'Cancel task "{title}"? (moves to archive; subtasks unaffected)',
deleteDetailMsg: 'Permanently delete "{title}"{hint}? Cannot be undone!',
deleteDetailHint: ' (includes {n} direct subtasks and all descendants)',
cancelBtn: 'Cancel task', cancelConfirmBtn: 'Confirm cancel', cancelAbortBtn: 'Never mind',
deleteBtn: '🗑 Delete', deleteBtnHint: '🗑 Delete ({n} subtasks)',
deleteConfirmBtn: 'Confirm delete', deleteAbortBtn: 'Never mind',
archiveDeleteConfirm: 'Permanently delete "{title}"{hint}?\nThis cannot be undone — all data will be removed.',
archiveDeleteHint: ' and {n} descendants',
lang: 'EN', langTip: '切换语言 / Switch language',
scheduleScore: 'score-first scheduling', models: 'Models',
},
};
// ── i18n 状态与核心函数 ──
const i18nState = { lang: localStorage.getItem('maestro_lang') || 'zh' };
/** 取翻译字符串,支持 {varName} 插值 */
function t(key, vars) {
const d = I18N[i18nState.lang] || I18N.zh;
let val = d[key];
if (val == null) val = I18N.zh[key];
if (val == null) return key;
if (vars && typeof val === 'string') {
val = val.replace(/\{(\w+)\}/g, (_, k) => vars[k] != null ? String(vars[k]) : '{' + k + '}');
}
return val;
}
/** 取嵌套翻译(如 status.init、fgroups.todo */
function tSub(groupKey, subKey, fallback) {
const d = I18N[i18nState.lang] || I18N.zh;
const grp = d[groupKey];
if (grp && grp[subKey] != null) return grp[subKey];
const fb = I18N.zh[groupKey];
return (fb && fb[subKey] != null) ? fb[subKey] : (fallback || subKey);
}
/** 切换语言并刷新全部 UI */
function setLang(lang) {
if (!I18N[lang]) return;
i18nState.lang = lang;
localStorage.setItem('maestro_lang', lang);
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
applyStaticI18n();
renderAll();
}
/** 更新 HTML 中的 data-i18n / data-i18n-title / data-i18n-ph 静态元素 */
function applyStaticI18n() {
document.querySelectorAll('[data-i18n]').forEach((el) => {
el.textContent = t(el.dataset.i18n);
});
document.querySelectorAll('[data-i18n-title]').forEach((el) => {
el.title = t(el.dataset.i18nTitle);
});
document.querySelectorAll('[data-i18n-ph]').forEach((el) => {
el.placeholder = t(el.dataset.i18nPh);
});
document.documentElement.lang = i18nState.lang === 'zh' ? 'zh-CN' : 'en';
const lb = document.getElementById('langBtn');
if (lb) lb.textContent = t('lang');
}
// ── 状态/闸/事件标签(读当前语言) ──
function statusLabel(st) { return tSub('status', st) || st; }
function gateLabel(g) { return tSub('gates', g) || g; }
function autonomyLabel(a) { return tSub('autonomy', a) || a; }
function autonomyShort(a) { return tSub('autonomyShort', a) || a; }
function eventLabel(tp) { return tSub('eventTypes', tp) || tp; }
function fgroupLabel(key) { return tSub('fgroups', key) || key; }
function runKindLabel(k) { return tSub('runKind', k) || k; }
function runStLabel(s) { return tSub('runSt', s) || s; }
// ── SVG 图标集(v1.1 双色调,24×242px 圆头;currentColor=基础描边,--icon-accent=信号色) ──
const ICONS = {
task: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="2.5"/><polyline points="8 12 11 15 16 9" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
project: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 3h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg>`,
gate: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3L22 21L2 21Z"/><line x1="12" y1="10" x2="12" y2="14" stroke="var(--icon-accent,#b88ef5)"/><line x1="12" y1="17.5" x2="12" y2="17.51" stroke="var(--icon-accent,#b88ef5)"/></svg>`,
ready: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 4L19 12L7 20Z" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
running: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3" fill="var(--icon-accent,#59c8d8)" stroke="none"/></svg>`,
sync: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
config: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="12" y2="6"/><circle cx="15" cy="6" r="2.5" stroke="var(--icon-accent,#5fdd7d)"/><line x1="18" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="5.5" y2="12"/><circle cx="9" cy="12" r="2.5" stroke="var(--icon-accent,#5fdd7d)"/><line x1="12.5" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="12.5" y2="18"/><circle cx="16" cy="18" r="2.5" stroke="var(--icon-accent,#5fdd7d)"/><line x1="19.5" y1="18" x2="21" y2="18"/></svg>`,
search: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><line x1="15.5" y1="15.5" x2="21" y2="21" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
git: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
deps: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><line x1="10" y1="10" x2="14" y2="14" stroke="var(--icon-accent,#f0b429)"/></svg>`,
reason: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="5 4 5 14 18 14"/><polyline points="13 9 18 14 13 19" stroke="var(--icon-accent,#f0b429)"/></svg>`,
event: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="2 12 7 12 10 5 14 19 17 12 22 12" stroke="var(--icon-accent,#59c8d8)"/></svg>`,
flow: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="12" x2="19" y2="12"/><polyline points="13 6 19 12 13 18" stroke="var(--icon-accent,#59c8d8)"/></svg>`,
add: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="4" x2="12" y2="20" stroke="var(--icon-accent,#5fdd7d)"/><line x1="4" y1="12" x2="20" y2="12" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
close: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="5" x2="19" y2="19"/><line x1="19" y1="5" x2="5" y2="19"/></svg>`,
};
/** 返回指定图标的内联 SVG HTML(name 不存在时返回空串) */
function icon(name) { return ICONS[name] || ''; }
// ── 模型常量(与 src/model/status.ts / complexity.ts 对齐) ──
// STATUS_LABEL 保留为 zh 默认,运行时通过 statusLabel() 读当前语言
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' };
// GATE_LABEL 保留 zh 默认,运行时通过 gateLabel() 读当前语言
const GATE_LABEL = { plan: '拆解评审', spec: '方案评审', exec: '结果评审' };
const CPLX_LABEL = { hard: 'HARD', medium: 'MED', easy: 'EASY' };
// EVENT_LABEL 保留 zh 默认,运行时通过 eventLabel() 读当前语言
const EVENT_LABEL = {
'task.created': '任务创建', 'task.updated': '任务更新', 'status.changed': '状态变更',
'approval.requested': '请求审批', 'approval.granted': '审批通过', 'approval.rejected': '审批驳回',
'run.started': '运行开始', 'run.finished': '运行结束', 'project.synced': 'todo 同步',
'merge.failed': '合并失败', 'merge.remediated': '补救完成 → 完成',
};
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',
'merge.failed': 'ev-rejected', 'merge.remediated': 'ev-approve',
};
// AUTONOMY_LABEL 保留 zh 默认,运行时通过 autonomyShort() 读当前语言
const AUTONOMY_LABEL = {
manual: '手动', 'auto-easy': '自动执行 Easy', 'auto-approved': '自动执行已批准',
};
// 任务树筛选:使用 i18n key 作为组名(渲染时通过 fgroupLabel() 翻译)
const FILTER_GROUPS = [
['todo', ['init', 'ready', 'blocked']],
['doing', ['analyzing', 'speccing', 'queued', 'executing']],
['container', ['decomposed']],
['gate', ['plan_review', 'spec_review', 'exec_review']],
['bad', ['failed', 'needs_attention']],
['hold', ['paused']],
];
// 归档态:不进任务树,沉到页面底部的归档区(时间倒序分页)
const ARCHIVED = new Set(['done', 'cancelled']);
// 允许编辑 complexity / deps 的状态(与 store.ts 的 COMPLEXITY_EDITABLE 对齐:尚未进入执行链路)
const STRUCT_EDITABLE = new Set(['init', 'analyzing', 'speccing', 'ready', 'plan_review', 'spec_review', 'blocked']);
// ── 全局状态 ──
const S = {
projects: [],
currentProjectId: null,
tasks: [],
approvals: [],
events: [], // 新→旧
collapsed: new Set(),
expanded: new Set(),
rejectOpen: new Set(),
filter: { cplx: new Set(), status: new Set(), statusGroups: new Set(), kw: '' },// 任务树筛选(组选与单选分离)
matchCount: 0,
agents: null, // GET /api/agents 结果(404 时为 null
metrics: null, // GET /api/metrics 结果(失败时为 null
cplxMenuFor: null, // 复杂度下拉打开的任务 id
syncReqAt: 0, // 本端发起 sync 的时间(避免 WS 重复 toast)
previewId: null, // 全局预览中的任务 id
previewReject: false, // 预览层内驳回意见框是否展开
archive: { page: 1, size: 20 }, // 归档区分页
confirmCancel: null, // 取消确认中的任务 id
confirmDelete: null, // 删除确认中的任务 id
depsEditFor: null, // 正在编辑依赖的任务 id
depsEditSel: null, // 编辑中已选依赖 id 集合(Set)
};
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);
const zh = i18nState.lang === 'zh';
if (s < 60) return zh ? '刚刚' : 'just now';
const m = Math.floor(s / 60);
if (m < 60) return zh ? `${m} 分钟前` : `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return zh ? `${h} 小时前` : `${h}h ago`;
return zh ? `${Math.floor(h / 24)} 天前` : `${Math.floor(h / 24)}d ago`;
}
/** 模型名缩写显示:claude-fable-5 → fable-5 */
function shortModel(m) {
return String(m || '—').replace(/^claude-/, '');
}
/** 毫秒 → 紧凑时长("45s" / "1m 23s" / "2h 5m");无效 → '—' */
function fmtDur(ms) {
const n = Number(ms);
if (!Number.isFinite(n) || n <= 0) return '—';
const s = Math.round(n / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return s % 60 ? `${m}m ${s % 60}s` : `${m}m`;
const h = Math.floor(m / 60);
return m % 60 ? `${h}h ${m % 60}m` : `${h}h`;
}
/** 0-1 比率 → 百分比文本;null/无效 → '—' */
function fmtRate(rate) {
return rate == null || !Number.isFinite(Number(rate)) ? '—' : `${Math.round(Number(rate) * 100)}%`;
}
/** fmtRel 的未来版:到 iso 还有多久("3h 33m" / "42m" / "<1m";已过/无效 → '' */
function fmtUntil(iso) {
if (!iso) return '';
const ms = new Date(iso).getTime() - Date.now();
if (!Number.isFinite(ms) || ms <= 0) return '';
const m = Math.ceil(ms / 60000);
if (m < 1) return '<1m';
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return m % 60 ? `${h}h ${m % 60}m` : `${h}h`;
const d = Math.floor(h / 24);
return h % 24 ? `${d}d ${h % 24}h` : `${d}d`;
}
/** 字符进度条:8 格 █/░ */
function usageBar(pct) {
const filled = Math.round(Math.min(100, Math.max(0, pct)) / 100 * 8);
return '█'.repeat(filled) + '░'.repeat(8 - filled);
}
/** 单个额度窗口(5h / 周)的展示段;>80% 琥珀、>95% 红 */
function usageSeg(label, w) {
if (!w || !Number.isFinite(Number(w.percent))) {
return `<span class="usage-seg"><span class="usage-label">${label}</span> <span class="usage-na">—</span></span>`;
}
const pct = Number(w.percent);
const lvl = pct > 95 ? ' crit' : pct > 80 ? ' warn' : '';
const until = fmtUntil(w.resetsAt);
return `<span class="usage-seg${lvl}"><span class="usage-label">${label}</span> ` +
`<span class="usage-bar">${usageBar(pct)}</span> ${pct}%` +
`${until ? `<span class="usage-reset">${t('quotaReset', { t: until })}</span>` : ''}</span>`;
}
/** Agent 面板顶部的订阅额度行(执行 agent 烧的就是这个池子);usage=null → 灰字降级 */
function usageLine(usage) {
if (!usage || (!usage.session && !usage.weekly)) {
return `<div class="agent-usage unavail">${t('usageUnavail')}</div>`;
}
return `<div class="agent-usage">${t('quota')} ${usageSeg('5h', usage.session)} · ${usageSeg(t('quotaWeek'), usage.weekly)}</div>`;
}
// ── 轻量 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; }
}
// 指标聚合(当前项目);失败静默降级为 null(面板隐藏)
async function loadMetrics() {
const pid = S.currentProjectId;
if (!pid) { S.metrics = null; return; }
try { S.metrics = await api(`/api/metrics?project=${encodeURIComponent(pid)}`); }
catch { S.metrics = null; }
}
async function refresh() {
try {
await Promise.all([loadProjectData(), loadMetrics()]);
renderAll();
} catch (e) { toast(e.message); }
}
async function fullRefresh() {
try {
await loadProjects();
await Promise.all([loadProjectData(), loadAgents(), loadMetrics()]);
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();
applyStaticI18n();
renderSidebar();
renderTopbar();
renderAgents();
renderGates();
renderTree();
renderFilterBar();
renderEvents();
renderParentOptions();
renderMetrics();
renderArchive();
renderPreview();
restoreDrafts(snap);
}
// ── 渲染:侧栏 ──
function renderSidebar() {
const ul = $('#projectList');
if (!S.projects.length) {
ul.innerHTML = `<li class="p-empty">${esc(t('sideEmpty'))}</li>`;
return;
}
ul.innerHTML = S.projects.map((p) => {
const ch = (p.name || '?').trim().charAt(0).toUpperCase();
const hue = [...(p.name || '')].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 360, 7);
return `
<li class="${p.id === S.currentProjectId ? 'active' : ''}" data-action="select-project" data-id="${esc(p.id)}" draggable="true">
<span class="p-logo-wrap">
<span class="p-logo-fb" style="--h:${hue}">${esc(ch)}</span>
<img class="p-logo" src="/api/projects/${esc(p.id)}/logo?v=${esc(p.logo || '')}" alt="" onload="this.classList.add('ok')" onerror="this.remove()">
</span>
<span class="p-meta">
<span class="p-name">${esc(p.name)}</span>
<span class="p-path">${esc(p.repoPath)}</span>
</span>
</li>`;
}).join('');
}
function renderTopbar() {
const p = S.projects.find((x) => x.id === S.currentProjectId);
$('#projTitle').textContent = p ? p.name : t('noProject');
$('#projMeta').textContent = p
? `${p.repoPath} · ${t('branch')} ${p.defaultBranch} · ${p.status === 'active' ? t('projActive') : t('projPaused')} · ${S.tasks.length}`
: '';
// 徽章组(全部不含已完成:done / 已拆解容器 / 取消):
// 待审批 / 待执行(ready 叶子依赖满足 + 排队中) / 执行中 / 被阻塞 / 总量
$('#badgeGroup').hidden = !p;
const FINISHED = new Set(['done', 'decomposed', 'cancelled']);
const alive = S.tasks.filter((x) => !FINISHED.has(x.status));
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 = alive.filter((x) =>
(x.status === 'ready' && !hasKids.has(x.id) &&
(x.deps || []).every((d) => byId.get(d)?.status === 'done')) ||
x.status === 'queued',
).length;
const nRun = alive.filter((x) => x.status === 'executing').length;
const nBlocked = alive.filter((x) => x.status === 'blocked').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);
setBdg('#bdgBlocked', nBlocked);
setBdg('#bdgTotal', alive.length);
// badge 标签(label + title 通过 data-i18n / data-i18n-title 已静态绑定,这里刷新一次)
applyStaticI18n();
// 同步 todo / 项目配置入口
$('#topActions').hidden = !p;
if (p) {
const sb = $('#btnSync');
const noTodo = p.hasTodoJson === false; // 字段未交付(undefined)时不禁用,错误由 toast 兜底
sb.disabled = noTodo;
sb.title = noTodo ? t('noTodoTip') : t('syncTip');
$('#syncMeta').textContent = p.lastSyncAt ? t('syncMeta', { t: fmtRel(p.lastSyncAt) }) : '';
$('#cfgCurrent').textContent = t('cfgCurrent', { c: p.concurrency ?? '—', m: autonomyShort(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}` : '';
const usage = usageLine(a ? a.usage : null);
if (!total) {
body.innerHTML = `${usage}<div class="agent-empty">${t('agentEmpty')}</div>`;
return;
}
const groups = (a.agents || []).filter((g) => g.active && g.active.length);
const sinceLabel = t('since');
body.innerHTML = `
${usage}
<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(autonomyShort(g.autonomy) || g.autonomy || '—')}</span>
<span class="agent-proj-meta" title="${t('scheduleScore')}">${t('scheduleScore')}</span>
${g.models ? `<span class="agent-proj-meta">${t('models')} E:${esc(shortModel(g.models.easy))} M:${esc(shortModel(g.models.medium))} H:${esc(shortModel(g.models.hard))}</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)}${sinceLabel}</span>
</div>`).join('')}
</div>`).join('')}
</div>
</div>`;
}
// ── 渲染:审批闸 ──
function gateDocs(task) {
const gate = GATE_OF[task.status];
const blocks = [];
const emptyNote = i18nState.lang === 'zh' ? '(未填写)' : '(empty)';
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">${emptyNote}</pre>`));
if (gate === 'plan') doc(t('planDocLabel'), task.plan);
if (gate === 'spec') doc(t('specDocLabel'), task.spec);
if (gate === 'exec') {
if (task.result) {
const r = task.result;
if (r.mergeTaskId) { // 合并失败:已建补救任务在解冲突,原任务停在审核闸
const rem = S.tasks.find((x) => x.id === r.mergeTaskId);
const remDone = rem && (rem.status === 'done' || rem.status === 'cancelled');
blocks.push(`<div class="gate-doc-label">${t('mergeStatus')}</div>
<div class="doc merge-failed-banner">${t('mergeFailedBanner')}
${rem ? `${esc(rem.title)}」(${statusLabel(rem.status)}` : `${esc(r.mergeTaskId)}`}
${remDone ? '' : `<br>${t('mergeRemNote')}`}</div>`);
}
blocks.push(reviewReportBlocks(r)); // CODE REVIEW + 安全审计 两节(各带 verdict 徽章),置于 DIFF 摘要上方
blocks.push(`<div class="gate-doc-label">${t('resultDetailLabel')}</div>
<dl class="result-kv">
${r.branch ? `<dt>${icon('git')} ${t('branch')}</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">${t('diffLabel')}</div><pre class="doc">${esc(r.diffSummary)}</pre>`);
} else {
const noResult = i18nState.lang === 'zh' ? '(无执行结果记录)' : '(no result recorded)';
blocks.push(`<pre class="doc empty">${noResult}</pre>`);
}
if (task.operations) doc(t('opsDocLabel'), task.operations);
else if (task.spec) doc(t('specDocLabel'), task.spec);
}
return blocks.join('');
}
function renderGates() {
const sec = $('#gateSection');
if (!S.approvals.length) { sec.hidden = true; return; }
sec.hidden = false;
const proj = S.projects.find((x) => x.id === S.currentProjectId);
const defBranch = proj ? proj.defaultBranch : 'main';
// Update gate section head text
const gateHead = sec.querySelector('.gate-head');
if (gateHead) {
const headMark = gateHead.querySelector('.head-mark');
const ico = gateHead.querySelector('.icon');
gateHead.innerHTML = '';
if (headMark) gateHead.appendChild(headMark);
if (ico) gateHead.appendChild(ico);
gateHead.appendChild(document.createTextNode(' ' + t('gateSection')));
}
const gateApprovals = S.approvals.filter((ap) => ap.status !== 'needs_attention');
const attnTasks = S.approvals.filter((ap) => ap.status === 'needs_attention');
const gateCards = gateApprovals.map((ap) => {
const gate = GATE_OF[ap.status];
const rejOpen = S.rejectOpen.has(ap.id);
const mergeNote = gate === 'exec' ? `<span class="proj-meta">${t('gateMergeNote', { branch: esc(defBranch) })}</span>` : '';
const onlyPassBtn = gate === 'exec'
? `<button class="btn btn-ghost btn-xs" data-action="gate-accept" data-merge="false" data-id="${esc(ap.id)}" title="${t('gateOnlyPass')}">${t('gateOnlyPass')}</button>`
: '';
return `
<div class="gate-card">
<div class="gate-card-head">
<span class="gate-kind">${gateLabel(gate)}</span>
<span class="gate-title">${esc(ap.title)}</span>
${cplxBadge(ap.complexity)}
${statusChip(ap.status)}
<button class="btn btn-ghost btn-xs gate-preview-btn" data-action="preview-open" data-id="${esc(ap.id)}" title="${t('fullRead')}">${t('gatePreview')}</button>
</div>
<div class="gate-body">${gateDocs(ap)}</div>
<div class="gate-actions">
<button class="btn btn-accept" data-action="gate-accept" data-id="${esc(ap.id)}">${gate === 'exec' ? t('gateAcceptMerge') : t('gateAccept')}</button>${onlyPassBtn}
<button class="btn btn-reject" data-action="gate-reject-toggle" data-id="${esc(ap.id)}">${t('gateReject')}</button>
${mergeNote}
</div>
${rejOpen ? `
<div class="reject-form">
<textarea id="rej-${esc(ap.id)}" placeholder="${t('rejectPlaceholder')}"></textarea>
<button class="btn btn-reject" data-action="gate-reject-confirm" data-id="${esc(ap.id)}">${t('confirmReject')}</button>
<button class="btn" data-action="gate-reject-toggle" data-id="${esc(ap.id)}">${t('collapse')}</button>
</div>` : ''}
</div>`;
});
const attnCards = attnTasks.map((ap) => {
const reason = ap.lastRunError || t('attnNoReason');
return `
<div class="gate-card gate-card-attn">
<div class="gate-card-head">
<span class="gate-kind gate-kind-attn">${t('attnLabel')}</span>
<span class="gate-title">${esc(ap.title)}</span>
${cplxBadge(ap.complexity)}
${statusChip(ap.status)}
</div>
<div class="gate-body">
<div class="gate-doc-label">${t('attnReason')}</div>
<pre class="doc attn-reason">${esc(reason)}</pre>
</div>
<div class="gate-actions">
<button class="btn btn-accept" data-action="task-requeue" data-id="${esc(ap.id)}">${t('attnRequeue')}</button>
<button class="btn btn-reject" data-action="task-cancel" data-id="${esc(ap.id)}">${t('attnCancel')}</button>
<span class="proj-meta">id ${esc(ap.id)} · ${fmtTime(ap.updatedAt)}</span>
</div>
</div>`;
});
$('#gateList').innerHTML = [...attnCards, ...gateCards].join('');
}
// ── 渲染:指标面板(数字卡 + 按模型额度分布) ──
function metricCard(label, value, sub, cls) {
return `<div class="metric-card${cls ? ' ' + cls : ''}">
<div class="metric-val">${value}</div>
<div class="metric-label">${esc(label)}</div>
${sub ? `<div class="metric-sub">${sub}</div>` : ''}
</div>`;
}
function renderMetrics() {
const sec = $('#metricsSection');
const m = S.metrics;
// 无项目或无任何任务数据 → 隐藏面板(不打扰空项目)
if (!m || !S.currentProjectId || (m.taskCount === 0 && m.runs.total === 0 && m.duration.count === 0)) {
sec.hidden = true;
return;
}
sec.hidden = false;
$('#metricsScope').textContent = t('metricsTasks', { n: m.taskCount });
const d = m.duration;
const r = m.runs;
const rv = m.review;
const rt = m.retry;
// 成功率配色:≥80% 绿、≥50% 琥珀、其余红
const rateCls = (rate) => {
if (rate == null) return '';
const v = Number(rate);
return v >= 0.8 ? 'ok' : v >= 0.5 ? 'warn' : 'crit';
};
const cards = [
metricCard(t('metricDur'), fmtDur(d.avgMs),
d.count ? t('metricDurSub', { p50: fmtDur(d.p50Ms), p95: fmtDur(d.p95Ms), n: d.count }) : t('metricDurNone')),
metricCard(t('metricSucc'), fmtRate(r.successRate),
r.total ? t('metricSuccSub', { ok: r.succeeded, total: r.total, fail: r.failed }) : t('metricSuccNone'),
rateCls(r.successRate)),
metricCard(t('metricReview'), fmtRate(rv.reviewRate),
t('metricReviewSub', { ra: rv.reviewApprove, rt: rv.reviewTotal, sr: fmtRate(rv.securityRate), sa: rv.securityApprove, st2: rv.securityTotal }),
rateCls(rv.reviewRate)),
metricCard(t('metricRetry'), fmtRate(rt.retryRate),
t('metricRetrySub', { r: rt.retries, n: rt.needsAttention }), rt.needsAttention > 0 ? 'warn' : ''),
];
// 按模型额度分布(估算)
const totalUnits = (m.byModel || []).reduce((a, x) => a + Number(x.estCostUnits || 0), 0);
const totalDur = (m.byModel || []).reduce((a, x) => a + Number(x.durationMs || 0), 0);
cards.push(metricCard(t('metricCost'), `${totalUnits.toFixed(1)}<span class="metric-unit">u</span>`,
t('metricCostSub', { dur: fmtDur(totalDur), est: `<span class="metric-est">${t('metricEstTag')}</span>` }), 'est'));
const dist = (m.byModel || []).length
? `<div class="metric-models">
<div class="metric-models-head">${t('metricModelHead')} <span class="metric-est">${t('metricEst')}</span></div>
${m.byModel.map((x) => {
const w = totalUnits > 0 ? Math.round(Number(x.estCostUnits) / totalUnits * 100) : 0;
return `<div class="metric-model-row">
<span class="metric-model-name">${esc(shortModel(x.model))}</span>
<span class="metric-model-bar"><span class="metric-model-fill" style="width:${w}%"></span></span>
<span class="metric-model-num">${Number(x.estCostUnits).toFixed(1)}u · ${x.runs} run · ${fmtDur(x.durationMs)}</span>
</div>`;
}).join('')}
</div>`
: '';
// Update section head
const metSec = $('#metricsSection');
const metHead = metSec && metSec.querySelector('.sec-head');
if (metHead) metHead.innerHTML = `<span class="head-mark cyan">▍</span>${t('metricsSection')}<span id="metricsScope" class="metrics-scope"> ${t('metricsTasks', { n: m.taskCount })}</span>`;
$('#metricsBody').innerHTML = `<div class="metric-cards">${cards.join('')}</div>${dist}`;
}
// ── 渲染:归档区(done/取消,updatedAt 倒序,分页) ──
function renderArchive() {
const sec = $('#archiveSection');
if (!S.currentProjectId) { sec.hidden = true; return; }
// 只列深度 1 的归档树根,行内标注子任务数
const m = childrenMap(S.tasks);
const arch = archivedIdSet();
const subCount = (t) => {
let n = 0;
const stack = [...(m.get(t.id) || [])];
while (stack.length) { const x = stack.pop(); n++; for (const c of m.get(x.id) || []) stack.push(c); }
return n;
};
const items = (m.get('__root__') || [])
.filter((t) => arch.has(t.id))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
sec.hidden = items.length === 0;
if (sec.hidden) return;
const a = S.archive;
const pages = Math.max(1, Math.ceil(items.length / a.size));
if (a.page > pages) a.page = pages;
const slice = items.slice((a.page - 1) * a.size, a.page * a.size);
// Update archive section head
const archSec = $('#archiveSection');
const archHead = archSec && archSec.querySelector('.sec-head');
if (archHead) archHead.innerHTML = `<span class="head-mark">▣</span>${t('archiveSection')} · <span id="archiveCount">${items.length}</span>`;
$('#archiveList').innerHTML = slice.map((task) => {
const n = subCount(task);
return `
<div class="archive-row" data-action="archive-detail" data-id="${esc(task.id)}" title="${t('archiveTip')}">
<span class="t-title">${esc(task.title)}<span class="t-id">${esc(task.id.slice(-6))}</span></span>
${n ? `<span class="archive-subs">${t('archiveSubs', { n })}</span>` : ''}
<span class="spacer"></span>
${cplxBadge(task.complexity)}
${statusChip(task.status)}
<span class="archive-time" title="${esc(task.updatedAt)}">${fmtRel(task.updatedAt)}</span>
</div>`;
}).join('');
// 底部:翻页 + 每页条数 chip 组(替代原生下拉)
const sizeChips = [10, 20, 50, 100].map((n) =>
`<button type="button" class="fchip ${a.size === n ? 'on' : ''}" data-action="archive-size" data-size="${n}">${n}</button>`,
).join('');
$('#archivePager').innerHTML = `
${pages > 1 ? `
<button class="btn btn-xs" data-action="archive-prev" ${a.page <= 1 ? 'disabled' : ''}>${t('pagePrev')}</button>
<span class="archive-page">${t('pageOf', { a: a.page, b: pages })}</span>
<button class="btn btn-xs" data-action="archive-next" ${a.page >= pages ? 'disabled' : ''}>${t('pageNext')}</button>` : ''}
<span class="archive-size-chips"><span class="archive-size-label">${t('perPage')}</span>${sizeChips}</span>`;
}
// ── 归档详情对话框(只读:全属性/执行历史/审批/状态流转时间线) ──
function fmtFull(iso) {
try { return new Date(iso).toLocaleString('zh-CN', { hour12: false }); } catch { return iso || '—'; }
}
function fmtDuration(startIso, endIso) {
if (!startIso || !endIso) return '—';
const ms = new Date(endIso) - new Date(startIso);
if (!Number.isFinite(ms) || ms < 0) return '—';
const s = Math.round(ms / 1000);
const zh = i18nState.lang === 'zh';
if (s < 60) return zh ? `${s}` : `${s}s`;
return zh ? `${Math.floor(s / 60)}${s % 60}` : `${Math.floor(s / 60)}m ${s % 60}s`;
}
/** 事件 → 时间线条目(人或事:by/auto/actor/原因) */
function tlActor(p) {
const who = [];
if (p.by) who.push(p.by === 'orchestrator' ? t('tlOrchestrator') : p.by);
if (p.actor) who.push(p.actor === 'importer' ? t('tlImporter') : p.actor);
if (p.auto) who.push(tSub('tlAuto', p.auto, p.auto));
return who.join(' · ');
}
async function openArchiveDetail(id) {
try {
const [task, runs, events] = await Promise.all([
api(`/api/tasks/${id}`), api(`/api/tasks/${id}/runs`), api(`/api/tasks/${id}/events`),
]);
renderArchiveModal(task, runs, events);
} catch (e) { toast(e.message); }
}
function renderArchiveModal(task, runs, events) {
const root = $('#archiveModalRoot');
const byId = new Map(S.tasks.map((x) => [x.id, x]));
const parent = task.parentId ? byId.get(task.parentId) : null;
const r = task.result;
const depSep = i18nState.lang === 'zh' ? '、' : ', ';
const kv = (dt, dd) => `<dt>${dt}</dt><dd>${dd}</dd>`;
const attrs = `<dl class="result-kv">
${kv('id', esc(task.id))}
${kv(t('complexity'), `${CPLX_LABEL[task.complexity] || esc(task.complexity)}`)}
${kv(t('priority'), `P${esc(String(task.priority))}`)}
${kv(t('status', {}), statusLabel(task.status))}
${kv('depth', esc(String(task.depth)))}
${parent ? kv(t('parentTask'), esc(parent.title)) : ''}
${task.deps && task.deps.length ? kv('deps', task.deps.map((d) => esc(byId.get(d)?.title || d)).join(depSep)) : ''}
${kv(i18nState.lang === 'zh' ? '创建时间' : 'Created', fmtFull(task.createdAt))}
${kv(i18nState.lang === 'zh' ? '最后更新' : 'Updated', fmtFull(task.updatedAt))}
</dl>`;
const docs = [['plan', t('planDetailLabel')], ['spec', t('specDetailLabel')], ['operations', t('opsDetailLabel')]]
.filter(([f]) => task[f])
.map(([f, label]) => `<div class="gate-doc-label">${label}</div><div class="doc md">${mdToHtml(task[f])}</div>`)
.join('');
const runRows = runs.length ? `<div class="gate-doc-label">${t('runsLabel', { n: runs.length })}</div>
<div class="run-list">${runs.map((x) => `
<div class="run-item">
<div class="run-head">
<span class="run-kind">${runKindLabel(x.kind)}</span>
<span class="run-st run-st-${esc(x.status)}">${runStLabel(x.status)}</span>
<span class="run-time">${fmtFull(x.startedAt)}${x.endedAt ? fmtFull(x.endedAt) : '—'} · ${fmtDuration(x.startedAt, x.endedAt)}</span>
<span class="spacer"></span>
${x.transcriptRef ? `<button class="btn btn-xs btn-ghost" data-action="run-transcript" data-id="${esc(x.id)}">${t('transcriptView')}</button>` : ''}
</div>
${x.error ? `<div class="run-err">${i18nState.lang === 'zh' ? '错误:' : 'Error: '}${esc(x.error)}</div>` : ''}
${x.transcriptRef ? `<div class="transcript" id="transcript-${esc(x.id)}" hidden></div>` : ''}
</div>`).join('')}</div>` : '';
const resultBlock = r ? `${reviewReportBlocks(r)}
<div class="gate-doc-label">${t('resultLabel')}</div>
<dl class="result-kv">
${r.branch ? kv(`${icon('git')} ${t('branch')}`, esc(r.branch)) : ''}
${r.worktree ? kv('worktree', esc(r.worktree)) : ''}
${r.prUrl ? kv('PR', esc(r.prUrl)) : ''}
${r.commits && r.commits.length ? kv('commits', r.commits.map(esc).join('<br>')) : ''}
</dl>
${r.diffSummary ? `<pre class="doc">${esc(r.diffSummary)}</pre>` : ''}` : '';
const approvalRows = task.approvals && task.approvals.length ? `<div class="gate-doc-label">${t('approvalsLabel')}</div>
<dl class="result-kv">${task.approvals.map((a) => kv(
`${gateLabel(a.gate)} · ${a.action === 'accept' ? t('accepted') : t('rejected')}`,
`${t('approver')} ${esc(a.actor)} · ${fmtFull(a.at)}${a.reason ? `<br>${icon('reason')} ${esc(a.reason)}` : ''}`,
)).join('')}</dl>` : '';
const timeline = events.length ? `<div class="gate-doc-label">${t('timelineLabel')}</div>
<div class="tl">${events.map((e) => {
const p = e.payload || {};
let text = eventLabel(e.type);
if (e.type === 'status.changed') text = `${statusLabel(p.from)} ${statusLabel(p.to)}`;
if (e.type === 'approval.granted') text = t('tlApproveGranted', { gate: gateLabel(p.gate) });
if (e.type === 'approval.rejected') text = t('tlApproveRejected', { gate: gateLabel(p.gate), reason: p.reason ? `${p.reason}` : '' });
if (e.type === 'run.started') text = t('tlRunStarted', { kind: runKindLabel(p.kind) || p.kind || '' });
if (e.type === 'run.finished') text = t('tlRunFinished', { st: runStLabel(p.status) || p.status || '' });
if (e.type === 'merge.failed') {
const files = (p.conflictFiles && p.conflictFiles.length)
? t('tlMergeFailFiles', { files: p.conflictFiles.join(depSep) }) : '';
const remT = p.remediationTaskId ? (byId.get(p.remediationTaskId)?.title || p.remediationTaskId) : '';
text = t('tlMergeFailed', { files, task: remT ? t('tlMergeTask', { title: remT }) : '' });
}
if (e.type === 'merge.remediated') {
const remT = p.remediationTaskId ? (byId.get(p.remediationTaskId)?.title || p.remediationTaskId) : '';
text = t('tlMergeOk', { task: remT ? t('tlMergeOkTask', { title: remT }) : '' });
}
const who = tlActor(p);
return `<div class="tl-item">
<span class="tl-time">${fmtFull(e.at)}</span>
<span class="tl-text">${esc(text)}</span>
${who ? `<span class="tl-who">${esc(who)}</span>` : ''}
</div>`;
}).join('')}</div>` : '';
root.hidden = false;
root.innerHTML = `
<div class="preview-mask" data-action="archive-modal-close"></div>
<div class="preview-panel">
<div class="preview-head">
<span class="gate-kind">${t('archiveModalTitle')}</span>
<span class="preview-title">${esc(task.title)}</span>
${cplxBadge(task.complexity)}
${statusChip(task.status)}
<span class="spacer"></span>
<button class="btn btn-ghost" data-action="archive-modal-close" title="${t('closeTip')}">${icon('close')}</button>
</div>
<div class="preview-body">
${attrs}${docs}${runRows}${resultBlock}${approvalRows}${timeline}
</div>
<div class="preview-foot archive-modal-actions">
<button class="btn btn-xs btn-reject" data-action="archive-delete" data-id="${esc(task.id)}">${t('archiveDelete')}</button>
<button class="btn btn-ghost" data-action="archive-modal-close">${t('collapse')}</button>
</div>
</div>`;
}
// 过长内容折叠为 <details>,短内容直接 <pre>
function foldable(text, truncated, threshold = 400) {
const body = esc(text) + (truncated ? esc('\n' + t('transcriptTrunc')) : '');
if ((text || '').length <= threshold) return `<pre class="t-pre">${body}</pre>`;
return `<details class="t-fold"><summary>${t('foldExpand', { n: (text || '').length })}</summary><pre class="t-pre">${body}</pre></details>`;
}
// 结构化转录消息流 → HTML(按 type 分类;文本/结论复用 mdToHtml,工具调用/结果折叠)
function renderTranscript(messages) {
if (!messages || !messages.length) return `<div class="transcript-empty">${t('transcriptEmpty')}</div>`;
const trunc = `<div class="t-trunc">${t('transcriptTrunc')}</div>`;
return `<div class="t-stream">${messages.map((m) => {
if (m.type === 'text') {
return `<div class="t-msg t-text"><span class="t-badge t-badge-a">${t('transcriptBadgeA')}</span>
<div class="doc md">${mdToHtml(m.text || '')}</div>${m.truncated ? trunc : ''}</div>`;
}
if (m.type === 'result') {
return `<div class="t-msg t-result"><span class="t-badge t-badge-r">${t('transcriptBadgeR')}</span>
<div class="doc md">${mdToHtml(m.text || '')}</div>${m.truncated ? trunc : ''}</div>`;
}
if (m.type === 'fallback') {
return `<div class="t-msg t-fallback"><span class="t-badge t-badge-w">${t('transcriptBadgeW')}</span>
<span class="t-fallback-txt">${esc(m.from)}${esc(m.to)}</span></div>`;
}
if (m.type === 'tool_use') {
return `<div class="t-msg t-tool"><span class="t-badge t-badge-t">${t('transcriptBadgeT')}</span>
<span class="t-tool-name">${esc(m.name)}</span>${m.input ? foldable(m.input, m.truncated) : ''}</div>`;
}
if (m.type === 'tool_result') {
return `<div class="t-msg t-tool-result${m.isError ? ' is-err' : ''}">
<span class="t-badge t-badge-tr">${m.isError ? t('transcriptBadgeTRErr') : t('transcriptBadgeTR')}</span>${foldable(m.text || '', m.truncated)}</div>`;
}
return '';
}).join('')}</div>`;
}
// ── 渲染:全局预览(全屏读方案 + 就地裁决) ──
function renderPreview() {
const root = $('#previewRoot');
if (!S.previewId) { root.hidden = true; root.innerHTML = ''; return; }
const task = S.approvals.find((x) => x.id === S.previewId) || S.tasks.find((x) => x.id === S.previewId);
// 任务已离开闸(被裁决/状态变化)→ 自动关闭
if (!task || !GATE_OF[task.status]) { S.previewId = null; S.previewReject = false; root.hidden = true; root.innerHTML = ''; return; }
const gate = GATE_OF[task.status];
const pp = S.projects.find((x) => x.id === S.currentProjectId);
const defBranch = pp ? pp.defaultBranch : 'main';
const onlyPassBtn = gate === 'exec'
? `<button class="btn btn-ghost btn-xs" data-action="gate-accept" data-merge="false" data-id="${esc(task.id)}" title="${t('gateOnlyPass')}">${t('gateOnlyPass')}</button>`
: '';
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">${gateLabel(gate)}</span>
<span class="preview-title">${esc(task.title)}</span>
${cplxBadge(task.complexity)}
${statusChip(task.status)}
<span class="spacer"></span>
<button class="btn btn-ghost" data-action="preview-close" title="${t('closeTip')}">${icon('close')}</button>
</div>
<div class="preview-body">${gateDocs(task)}</div>
<div class="preview-foot">
<button class="btn btn-accept" data-action="gate-accept" data-id="${esc(task.id)}">${gate === 'exec' ? t('gateAcceptMerge') : t('gateAccept')}</button>${onlyPassBtn}
<button class="btn btn-reject" data-action="preview-reject-toggle" data-id="${esc(task.id)}">${t('gateReject')}</button>
${gate === 'exec' ? `<span class="proj-meta">${t('gateMergeNote', { branch: esc(defBranch) })}</span>` : ''}
<span class="proj-meta">id ${esc(task.id)} · ${fmtTime(task.updatedAt)}</span>
${S.previewReject ? `
<div class="reject-form preview-reject">
<textarea id="prev-rej-${esc(task.id)}" placeholder="${t('rejectPlaceholder')}"></textarea>
<button class="btn btn-reject" data-action="gate-reject-confirm" data-id="${esc(task.id)}" data-ta="prev-rej-${esc(task.id)}">${t('confirmReject')}</button>
<button class="btn" data-action="preview-reject-toggle" data-id="${esc(task.id)}">${t('collapse')}</button>
</div>` : ''}
</div>
</div>`;
if (S.previewReject) {
const ta = document.getElementById(`prev-rej-${task.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'}">${statusLabel(st)}</span>`;
}
// 复审结论徽章:approve=建议通过(绿) / reject=建议拒绝(红) / 其他=未复审(灰)
function verdictBadge(v) {
if (v === 'approve') return `<span class="verdict verdict-approve">${t('verdictApprove')}</span>`;
if (v === 'reject') return `<span class="verdict verdict-reject">${t('verdictReject')}</span>`;
return `<span class="verdict verdict-none">${t('verdictNone')}</span>`;
}
// 复审报告区块:「CODE REVIEW」「安全审计」两节,各自 verdict 徽章(通过绿/拒绝红/未复审灰)
function reviewReportBlocks(r) {
if (!r) return '';
const noReview = i18nState.lang === 'zh' ? '(无复审记录)' : '(no review record)';
const sec = (label, summary, verdict) => {
const head = `<div class="gate-doc-label">${label} ${verdictBadge(verdict)}</div>`;
if (summary) return `${head}<div class="doc md review-summary">${mdToHtml(summary)}</div>`;
return `${head}<pre class="doc empty">${noReview}</pre>`;
};
return sec(t('reviewLabel'), r.summary, r.verdict) + sec(t('securityLabel'), r.securitySummary, r.securityVerdict);
}
function childrenMap(list = S.tasks) {
const m = new Map();
for (const t of list) {
const key = t.parentId || '__root__';
if (!m.has(key)) m.set(key, []);
m.get(key).push(t);
}
return m;
}
/**
* 归档单位 = 深度 1 任务整树:自身与全部子孙都 done/取消 才算「完成」。
* 返回归档任务 id 集合(含整树)。子任务先完成但兄弟未完时整树留在任务树里展示进度。
*/
function archivedIdSet() {
const m = childrenMap(S.tasks);
const subtreeDone = (t) => ARCHIVED.has(t.status) && (m.get(t.id) || []).every(subtreeDone);
const ids = new Set();
for (const r of m.get('__root__') || []) {
if (!subtreeDone(r)) continue;
const stack = [r];
while (stack.length) {
const x = stack.pop();
ids.add(x.id);
for (const c of m.get(x.id) || []) stack.push(c);
}
}
return ids;
}
/** 任务树里显示的任务(已归档整树沉到底部归档区) */
function aliveTasks() {
const arch = archivedIdSet();
return S.tasks.filter((t) => !arch.has(t.id));
}
// ── 筛选 ──
function filterActive() {
const f = S.filter;
return f.cplx.size > 0 || f.status.size > 0 || f.statusGroups.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.statusGroups.size) {
const inStatus = f.status.has(t.status);
const inGroup = [...f.statusGroups].some((gi) => FILTER_GROUPS[gi]?.[1].includes(t.status));
if (!inStatus && !inGroup) 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 alive = aliveTasks();
const byId = new Map(alive.map((t) => [t.id, t]));
const visible = new Set();
const matched = new Set();
for (const t of alive) {
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(([key, sts], gi) => {
// 组选与单选分离:组 chip 只反映组选择,成员 chip 只反映单选;单状态组只渲染状态 chip 本身
const members = sts.map((s) =>
`<button type="button" class="fchip fst ${f.status.has(s) ? 'on' : ''}" data-action="filter-status" data-st="${s}">${statusLabel(s)}</button>`,
).join('');
if (sts.length === 1) return `<span class="fgroup">${members}</span>`;
const gCls = f.statusGroups.has(gi) ? 'on' : '';
const groupName = fgroupLabel(key);
const groupTip = i18nState.lang === 'zh' ? '按整组筛选' : 'Filter by group';
return `<span class="fgroup">
<button type="button" class="fchip fgroup-chip ${gCls}" data-action="filter-group" data-gi="${gi}" title="${groupTip}">${groupName}</button>
${members}
</span>`;
}).join('');
const active = filterActive();
$('#filterClear').hidden = !active;
$('#filterCount').textContent = active ? t('matchCount', { n: S.matchCount, total: S.tasks.length }) : '';
}
function renderTree() {
const root = $('#taskTree');
if (!S.currentProjectId) {
root.innerHTML = `<div class="tree-empty"><b>NO PROJECT</b>${t('treeNoProject')}</div>`;
return;
}
if (!S.tasks.length) {
root.innerHTML = `<div class="tree-empty"><b>EMPTY</b>${t('treeEmpty')}</div>`;
return;
}
const alive = aliveTasks();
if (!alive.length) {
root.innerHTML = `<div class="tree-empty"><b>ALL DONE</b>${t('treeAllDone')}</div>`;
return;
}
const m = childrenMap(alive);
const vis = computeVisible();
if (vis && !vis.visible.size) {
root.innerHTML = `<div class="tree-empty"><b>NO MATCH</b>${t('treeNoMatch')}</div>`;
return;
}
const byId = new Map(S.tasks.map((x) => [x.id, x]));
const i18n = t; // t will be shadowed by the task parameter inside renderNode
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(String(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 '';
const depSep2 = i18nState.lang === 'zh' ? '、' : ', ';
return `<span class="t-deps-wait" title="${unmet.map((d) => esc(byId.get(d)?.title || d)).join(depSep2)}">${icon('deps')} ${i18n('depsWait', { n: unmet.length })}</span>`;
})()}
<span class="t-prio ${t.priority === 0 ? 'hot' : ''}">P${t.priority}</span>
${cplxBadge(t.complexity, t.id)}
${statusChip(t.status)}
${!ARCHIVED.has(t.status) ? `<button class="btn btn-ghost btn-xs task-row-cancel" data-action="row-cancel" data-id="${esc(t.id)}" title="取消任务(进入归档)">${icon('close')}</button>` : ''}
<button class="btn btn-ghost btn-xs task-row-delete" data-action="row-delete" data-id="${esc(t.id)}" title="彻底删除任务(不可恢复)">🗑</button>
</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(task) {
if (task.status === 'analyzing') return { field: 'plan', label: t('planDetailLabel'), next: 'plan_review' };
if (task.status === 'speccing') return { field: 'spec', label: t('specDetailLabel'), next: 'spec_review' };
if (task.status === 'ready') return { field: 'operations', label: t('opsDetailLabel'), next: null };
return null;
}
function renderDetail(task) {
const parts = [];
const w = writableField(task);
// 产出全部只读展示:写产出与提交评审由 Claude Code 经 MCP 完成,看板只留用户动作(审批/配置/同步)
const docs = [['plan', t('planDetailLabel')], ['spec', t('specDetailLabel')], ['operations', t('opsDetailLabel')]];
for (const [f, label] of docs) {
if (!task[f]) continue;
parts.push(`<div><div class="detail-label">${label}</div><div class="doc md">${mdToHtml(task[f])}</div></div>`);
}
// 当前阶段应产出但还没有内容 → 占位提示
if (w && !task[w.field]) {
parts.push(`<div><div class="detail-label">${w.label}</div><div class="doc doc-pending">${t('pendingDoc')}</div></div>`);
}
// result
if (task.result) {
const r = task.result;
parts.push(`
<div>${reviewReportBlocks(r)}
<div class="detail-label">${t('resultDetailLabel')}</div>
<dl class="result-kv">
<dt>${icon('git')} ${t('branch')}</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 (task.approvals && task.approvals.length) {
parts.push(`
<div><div class="detail-label">${t('approvalsHistLabel')}</div>
${task.approvals.map((a) => `
<div class="approval-item">
<span class="ap-act-${a.action}">${a.action === 'accept' ? `${t('accepted')}` : `${t('rejected')}`}</span>
<span class="ap-gate">${gateLabel(a.gate)} · ${esc(a.actor)}</span>
<span class="ap-time">${fmtTime(a.at)}</span>
${a.reason ? `<span class="ap-reason">${icon('reason')}<span>${esc(a.reason)}</span></span>` : ''}
</div>`).join('')}
</div>`);
}
// 依赖列表:标题 + 状态 chip,未完成的高亮(全部 done 本任务才可被领取);可编辑状态支持增删
const depsEditable = STRUCT_EDITABLE.has(task.status);
if (S.depsEditFor === task.id) {
parts.push(depsEditorBlock(task));
} else if ((task.deps && task.deps.length) || depsEditable) {
const depGotoTip = i18nState.lang === 'zh' ? '点击定位到该任务' : 'Click to locate task';
const items = (task.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="${depGotoTip}">
<span class="dep-mark">${ok ? '✓' : icon('deps')}</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('') || `<div class="dep-empty">${t('depEmpty')}</div>`;
const depEditTip = i18nState.lang === 'zh' ? '增删本任务的依赖' : 'Edit task dependencies';
const editBtn = depsEditable
? `<button class="btn btn-xs deps-edit-btn" data-action="deps-edit" data-id="${esc(task.id)}" title="${depEditTip}">${t('depEdit')}</button>`
: '';
parts.push(`<div><div class="detail-label">${t('depsLabel')}${editBtn}</div><div class="dep-list">${items}</div></div>`);
}
if (!parts.length) parts.push(`<div class="proj-meta">${t('noOutput', { st: statusLabel(task.status) })}</div>`);
const depLabelMeta = i18nState.lang === 'zh' ? '深度' : 'depth';
parts.push(`<div class="proj-meta">id ${esc(task.id)} · ${depLabelMeta} ${task.depth} · ${fmtTime(task.createdAt)} · ${fmtTime(task.updatedAt)}</div>`);
// ── 取消 / 删除操作区 ──
const m = childrenMap(S.tasks);
const childCount = (m.get(task.id) || []).length;
const cancelTip = i18nState.lang === 'zh' ? '取消任务(进入 cancelled 状态,计入归档)' : 'Cancel task (enters cancelled, archived)';
const deleteTip = i18nState.lang === 'zh' ? '彻底删除任务(不可恢复)' : 'Permanently delete task (cannot be undone)';
const cancelSection = !ARCHIVED.has(task.status) ? (() => {
if (S.confirmCancel === task.id) {
const msg = t('cancelDetailMsg', { title: task.title });
return `<div class="detail-action-confirm">
<span class="detail-confirm-msg">${esc(msg)}</span>
<button class="btn btn-xs btn-reject" data-action="task-cancel-confirm" data-id="${esc(task.id)}">${t('cancelConfirmBtn')}</button>
<button class="btn btn-xs" data-action="task-cancel-abort" data-id="${esc(task.id)}">${t('cancelAbortBtn')}</button>
</div>`;
}
return `<button class="btn btn-xs btn-reject" data-action="task-cancel" data-id="${esc(task.id)}" title="${cancelTip}">${icon('close')} ${t('cancelBtn')}</button>`;
})() : '';
const deleteSection = (() => {
if (S.confirmDelete === task.id) {
const hint = childCount ? t('deleteDetailHint', { n: childCount }) : '';
const msg = t('deleteDetailMsg', { title: task.title, hint });
return `<div class="detail-action-confirm detail-action-confirm-del">
<span class="detail-confirm-msg">${esc(msg)}</span>
<button class="btn btn-xs btn-reject" data-action="task-delete-confirm" data-id="${esc(task.id)}">${t('deleteConfirmBtn')}</button>
<button class="btn btn-xs" data-action="task-delete-abort" data-id="${esc(task.id)}">${t('deleteAbortBtn')}</button>
</div>`;
}
return childCount
? `<button class="btn btn-xs" data-action="task-delete" data-id="${esc(task.id)}" title="${deleteTip}">${t('deleteBtnHint', { n: childCount })}</button>`
: `<button class="btn btn-xs" data-action="task-delete" data-id="${esc(task.id)}" title="${deleteTip}">${t('deleteBtn')}</button>`;
})();
if (cancelSection || deleteSection) {
parts.push(`<div class="detail-actions">${cancelSection}${deleteSection}</div>`);
}
return `<div class="task-detail"><div class="detail-grid">${parts.join('')}</div></div>`;
}
/** 选某任务为 task 的依赖会成环的候选集合:所有(传递)依赖 task 的任务 id */
function depsWouldCycle(task) {
const byId = new Map(S.tasks.map((x) => [x.id, x]));
const reaches = (id, target, seen) => {
const node = byId.get(id);
if (!node) return false;
for (const d of (node.deps || [])) {
if (d === target) return true;
if (!seen.has(d)) { seen.add(d); if (reaches(d, target, seen)) return true; }
}
return false;
};
const bad = new Set();
for (const x of S.tasks) {
if (x.id === task.id) continue;
if (reaches(x.id, task.id, new Set())) bad.add(x.id);
}
return bad;
}
/** 依赖编辑器:多选当前项目其它任务(排除自己与会成环者),保存走 PATCH deps */
function depsEditorBlock(task) {
const sel = S.depsEditSel || new Set();
const bad = depsWouldCycle(task);
const candidates = S.tasks
.filter((x) => x.id !== task.id && !ARCHIVED.has(x.status) && !bad.has(x.id))
.sort((a, b) => (a.depth - b.depth) || a.title.localeCompare(b.title));
const rows = candidates.map((x) => {
const on = sel.has(x.id);
return `<div class="dep-pick ${on ? 'on' : ''}" data-action="deps-edit-toggle" data-id="${esc(task.id)}" data-dep="${esc(x.id)}">
<span class="dep-check">${on ? '☑' : '☐'}</span>
${statusChip(x.status)}
<span class="dep-title">${esc(x.title)}</span>
<span class="t-id">${esc(x.id.slice(-6))}</span>
</div>`;
}).join('') || `<div class="dep-empty">${t('depNoOthers')}</div>`;
return `<div><div class="detail-label">${t('depsEditLabel')}</div>
<div class="dep-list dep-pick-list">${rows}</div>
<div class="deps-edit-foot">
<button class="btn btn-xs btn-accept" data-action="deps-edit-save" data-id="${esc(task.id)}">${t('saveDeps', { n: sel.size })}</button>
<button class="btn btn-xs" data-action="deps-edit-cancel" data-id="${esc(task.id)}">${t('cancel')}</button>
</div></div>`;
}
// ── 渲染:事件流 ──
function eventDetail(e) {
const task = S.tasks.find((x) => x.id === e.taskId);
const name = task ? task.title : (e.taskId ? e.taskId.slice(-6) : '');
const p = e.payload || {};
const depSep = i18nState.lang === 'zh' ? '、' : ', ';
if (e.type === 'status.changed') {
const sep = i18nState.lang === 'zh' ? '' : ': ';
return `${name}${sep}${statusLabel(p.from)}${statusLabel(p.to)}`;
}
if (e.type === 'approval.granted' || e.type === 'approval.rejected') {
const r = p.reason ? `${p.reason}` : '';
return `${name} · ${gateLabel(p.gate)}${r}`;
}
if (e.type === 'task.created') {
if (p.kind === 'project') {
return i18nState.lang === 'zh' ? `项目「${p.name || ''}」已登记` : `Project "${p.name || ''}" registered`;
}
return `${p.title || name}${CPLX_LABEL[p.complexity] || ''}`;
}
if (e.type === 'merge.failed') {
const files = (p.conflictFiles && p.conflictFiles.length)
? (i18nState.lang === 'zh' ? `(冲突文件:${p.conflictFiles.join(depSep)}` : ` (conflicts: ${p.conflictFiles.join(', ')})`) : '';
return i18nState.lang === 'zh' ? `${name}:合并失败${files} → 补救任务` : `${name}: merge failed${files} → remediation`;
}
if (e.type === 'merge.remediated') {
return i18nState.lang === 'zh' ? `${name}:补救完成 → 完成` : `${name}: remediation done → complete`;
}
if (e.type === 'task.updated') {
return i18nState.lang === 'zh' ? `${name} · 字段 ${p.field || ''}` : `${name} · field ${p.field || ''}`;
}
if (e.type === 'project.synced') {
return i18nState.lang === 'zh'
? `新建 ${p.created ?? 0} · 推进 done ${p.doneAdvanced ?? 0} · 跳过 ${p.skipped ?? 0}`
: `Created ${p.created ?? 0} · advanced ${p.doneAdvanced ?? 0} · skipped ${p.skipped ?? 0}`;
}
if (e.type === 'run.started' || e.type === 'run.finished') {
return `${name} · ${runKindLabel(p.kind) || p.kind || ''} ${p.status || ''}`;
}
return name;
}
function renderEvents() {
const ul = $('#eventList');
if (!S.events.length) {
ul.innerHTML = `<li class="ev-empty">${t('noEvents')}</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">${icon('event')}${eventLabel(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="">${t('topLevel')}</option>` +
S.tasks.map((task) => `<option value="${esc(task.id)}">${esc('· '.repeat(Math.max(0, (task.depth || 1) - 1)) + task.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; S.confirmCancel = null; S.confirmDelete = 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 'open-settings':
api('/api/settings').then((s) => {
$('#setGlobalConc').value = s.globalConcurrency ?? 0;
$('#settingsRoot').hidden = false;
$('#setGlobalConc').focus();
}).catch((e) => toast(e.message));
break;
case 'close-settings':
$('#settingsRoot').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;
if (S.expanded.has(id)) {
S.expanded.delete(id);
// 折叠时清除行内确认状态
if (S.confirmCancel === id) S.confirmCancel = null;
if (S.confirmDelete === id) S.confirmDelete = null;
} else {
S.expanded.add(id);
}
renderTree();
break;
}
case 'gate-accept': {
const noMerge = el.dataset.merge === 'false';
act(() => api(`/api/tasks/${id}/decide`, {
method: 'POST', body: JSON.stringify(noMerge ? { action: 'accept', merge: false } : { action: 'accept' }),
}), noMerge ? t('toastAcceptedNoMerge') : t('toastAccepted'));
break;
}
case 'task-requeue':
act(() => api(`/api/tasks/${id}/requeue`, { method: 'POST' }), t('toastRequeued'));
break;
case 'task-cancel':
act(() => api(`/api/tasks/${id}/transition`, {
method: 'POST', body: JSON.stringify({ status: 'cancelled' }),
}), t('toastCancelled'));
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(t('toastRejectRequired')); if (ta) ta.focus(); return; }
S.rejectOpen.delete(id);
act(() => api(`/api/tasks/${id}/decide`, {
method: 'POST', body: JSON.stringify({ action: 'reject', reason }),
}), t('toastRejected'));
break;
}
case 'goto-task': {
const target = S.tasks.find((x) => x.id === id);
if (!target) { toast(t('toastTaskNotFound')); break; }
// 被筛选隐藏时清除筛选,保证可见
const vis = computeVisible();
if (vis && !vis.visible.has(id)) {
S.filter.cplx.clear(); S.filter.status.clear(); S.filter.statusGroups.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 'archive-detail':
openArchiveDetail(id);
break;
case 'run-transcript': {
ev.stopPropagation();
const box = document.getElementById(`transcript-${id}`);
if (!box) break;
if (!box.hidden) { box.hidden = true; el.textContent = t('transcriptView'); break; }
box.hidden = false;
el.textContent = t('transcriptHide');
if (!box.dataset.loaded) {
box.innerHTML = `<div class="transcript-loading">${t('transcriptLoading')}</div>`;
api(`/api/runs/${id}/transcript`)
.then((data) => { box.dataset.loaded = '1'; box.innerHTML = renderTranscript(data.messages); })
.catch((e) => { box.innerHTML = `<div class="transcript-err">${esc(e.message)}</div>`; });
}
break;
}
case 'archive-modal-close':
$('#archiveModalRoot').hidden = true;
$('#archiveModalRoot').innerHTML = '';
break;
case 'archive-delete': {
const archTask = S.tasks.find((x) => x.id === id);
const mDel = childrenMap(S.tasks);
let descCount = 0;
const countDesc = (tid) => { for (const c of mDel.get(tid) || []) { descCount++; countDesc(c.id); } };
countDesc(id);
const title = archTask ? archTask.title : id;
const hint = descCount ? t('archiveDeleteHint', { n: descCount }) : '';
if (!confirm(t('archiveDeleteConfirm', { title, hint }))) break;
$('#archiveModalRoot').hidden = true;
$('#archiveModalRoot').innerHTML = '';
act(async () => {
await api(`/api/tasks/${id}`, { method: 'DELETE' });
S.expanded.delete(id);
}, descCount ? t('toastArchiveDeletedSubs', { n: descCount }) : t('toastArchiveDeleted'));
break;
}
case 'archive-size':
S.archive.size = Number(el.dataset.size) || 20;
S.archive.page = 1;
renderArchive();
break;
case 'archive-prev':
S.archive.page = Math.max(1, S.archive.page - 1);
renderArchive();
break;
case 'archive-next':
S.archive.page += 1;
renderArchive();
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 }),
}), t('toastCplxSet', { c: CPLX_LABEL[c] }));
break;
}
// ── 依赖编辑 ──
case 'deps-edit': {
const t = S.tasks.find((x) => x.id === id);
S.depsEditFor = id;
S.depsEditSel = new Set((t && t.deps) || []);
renderTree();
break;
}
case 'deps-edit-toggle': {
const dep = el.dataset.dep;
if (!S.depsEditSel) S.depsEditSel = new Set();
S.depsEditSel.has(dep) ? S.depsEditSel.delete(dep) : S.depsEditSel.add(dep);
renderTree();
break;
}
case 'deps-edit-cancel':
S.depsEditFor = null; S.depsEditSel = null;
renderTree();
break;
case 'deps-edit-save': {
const deps = [...(S.depsEditSel || new Set())];
S.depsEditFor = null; S.depsEditSel = null;
act(() => api(`/api/tasks/${id}`, {
method: 'PATCH', body: JSON.stringify({ deps }),
}), t('toastDepsUpdated', { n: deps.length }));
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 gi = Number(el.dataset.gi);
if (!FILTER_GROUPS[gi]) break;
S.filter.statusGroups.has(gi) ? S.filter.statusGroups.delete(gi) : S.filter.statusGroups.add(gi);
renderTree(); renderFilterBar();
break;
}
case 'filter-clear':
S.filter.cplx.clear();
S.filter.status.clear();
S.filter.statusGroups.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(t('toastSynced', { created: r.created ?? 0, done: r.doneAdvanced ?? 0, skipped: 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';
$('#cfgLogo').value = p.logo ?? '';
$('#cfgVerifyCmd').value = p.verifyCmd ?? '';
$('#cfgModel').value = p.model ?? '';
// 分项检查(checks 是 JSON 文本)
let checks = {};
try { checks = p.checks ? JSON.parse(p.checks) : {}; } catch { checks = {}; }
$('#cfgLint').value = checks.lint ?? '';
$('#cfgTypecheck').value = checks.typecheck ?? '';
$('#cfgBuild').value = checks.build ?? '';
// 自动放行
$('#cfgAutoPlan').checked = !!p.autoApprovePlan;
$('#cfgAutoExec').checked = !!p.autoApproveExec;
// agent 规范 + 预算
$('#cfgAgentRules').value = p.agentRules ?? '';
$('#cfgBudget').value = p.budgetUsd == null ? '' : p.budgetUsd;
$('#cfgBudgetPeriod').value = p.budgetPeriod === 'day' ? 'day' : 'month';
// 角色模型(字符串原样显示;对象形式显示 JSON)
const m = p.models || {};
for (const [role, el] of [['executor', '#cfgMdlExecutor'], ['planner', '#cfgMdlPlanner'], ['reviewer', '#cfgMdlReviewer'], ['conflict', '#cfgMdlConflict']]) {
const v = m[role];
$(el).value = v == null ? '' : (typeof v === 'string' ? v : JSON.stringify(v));
}
}
}
break;
}
case 'save-config': {
if (!S.currentProjectId) break;
const cc = Number($('#cfgConcurrency').value);
if (!Number.isInteger(cc) || cc < 1) { toast(t('toastConcInt')); break; }
const autonomy = $('#cfgAutonomy').value;
const logo = $('#cfgLogo').value.trim();
const verifyCmd = $('#cfgVerifyCmd').value.trim() || null;
const model = $('#cfgModel').value.trim() || null;
// 分项检查 → checks JSON(全空=null
const checksObj = {};
for (const [k, el] of [['lint', '#cfgLint'], ['typecheck', '#cfgTypecheck'], ['build', '#cfgBuild']]) {
const v = $(el).value.trim(); if (v) checksObj[k] = v;
}
const checks = Object.keys(checksObj).length ? JSON.stringify(checksObj) : null;
// 角色模型 → models(字符串或 {..} JSON;全空=null
const modelsObj = {};
let modelsBad = null;
for (const [role, el] of [['executor', '#cfgMdlExecutor'], ['planner', '#cfgMdlPlanner'], ['reviewer', '#cfgMdlReviewer'], ['conflict', '#cfgMdlConflict']]) {
const v = $(el).value.trim(); if (!v) continue;
if (v[0] === '{') { try { modelsObj[role] = JSON.parse(v); } catch { modelsBad = role; } }
else modelsObj[role] = v;
}
if (modelsBad) { toast(t('cfgModelsBad', { role: modelsBad })); break; }
const models = Object.keys(modelsObj).length ? modelsObj : null;
const agentRules = $('#cfgAgentRules').value.trim() || null;
const budgetRaw = $('#cfgBudget').value.trim();
const budgetUsd = budgetRaw === '' ? null : Number(budgetRaw);
if (budgetUsd !== null && (!Number.isFinite(budgetUsd) || budgetUsd < 0)) { toast(t('cfgModelsBad', { role: 'budget' })); break; }
const budgetPeriod = $('#cfgBudgetPeriod').value === 'day' ? 'day' : 'month';
const autoApprovePlan = $('#cfgAutoPlan').checked;
const autoApproveExec = $('#cfgAutoExec').checked;
act(async () => {
await api(`/api/projects/${S.currentProjectId}`, {
method: 'PATCH', body: JSON.stringify({ concurrency: cc, autonomy, logo: logo || null, verifyCmd, model, checks, models, agentRules, budgetUsd, budgetPeriod, autoApprovePlan, autoApproveExec }),
});
await loadProjects(); // 刷新当前值显示 + logo
renderSidebar();
$('#configPanel').hidden = true; // 保存成功自动收起
}, t('toastSaved'));
break;
}
// ── 任务行:取消(带确认) ──
case 'row-cancel': {
ev.stopPropagation();
const cancelTask = S.tasks.find((x) => x.id === id);
if (!cancelTask) break;
const mCancel = childrenMap(S.tasks);
const cancelChildCount = (mCancel.get(id) || []).length;
const cancelHintStr = cancelChildCount ? t('cancelHint', { n: cancelChildCount }) : '';
if (!confirm(t('cancelConfirm', { title: cancelTask.title, hint: cancelHintStr }))) break;
act(() => api(`/api/tasks/${id}/cancel`, { method: 'POST', body: '{}' }), t('toastCancelled'));
break;
}
// ── 任务行:删除(带级联确认) ──
case 'row-delete': {
ev.stopPropagation();
const delTask = S.tasks.find((x) => x.id === id);
const mRowDel = childrenMap(S.tasks);
// 递归统计子孙数
let descCount = 0;
const countDescR = (tid) => { for (const c of mRowDel.get(tid) || []) { descCount++; countDescR(c.id); } };
countDescR(id);
const delTitle = delTask ? delTask.title : id;
const delHintStr = descCount ? t('deleteHint', { n: descCount }) : '';
if (!confirm(t('deleteConfirm', { title: delTitle, hint: delHintStr }))) break;
act(async () => {
await api(`/api/tasks/${id}`, { method: 'DELETE' });
S.expanded.delete(id);
S.collapsed.delete(id);
}, descCount ? t('toastDeletedWithSubs', { n: descCount }) : t('toastDeleted'));
break;
}
// ── 详情:取消 — 显示行内确认 ──
case 'task-cancel': {
ev.stopPropagation();
S.confirmCancel = id;
S.confirmDelete = null;
renderTree();
break;
}
case 'task-cancel-abort': {
ev.stopPropagation();
S.confirmCancel = null;
renderTree();
break;
}
case 'task-cancel-confirm': {
ev.stopPropagation();
S.confirmCancel = null;
act(() => api(`/api/tasks/${id}/cancel`, { method: 'POST', body: '{}' }), t('toastCancelled'));
break;
}
// ── 详情:删除 — 显示行内确认 ──
case 'task-delete': {
ev.stopPropagation();
S.confirmDelete = id;
S.confirmCancel = null;
renderTree();
break;
}
case 'task-delete-abort': {
ev.stopPropagation();
S.confirmDelete = null;
renderTree();
break;
}
case 'task-delete-confirm': {
ev.stopPropagation();
const delId = id;
S.confirmDelete = null;
act(async () => {
await api(`/api/tasks/${delId}`, { method: 'DELETE' });
S.expanded.delete(delId);
S.collapsed.delete(delId);
}, t('toastDeleted'));
break;
}
case 'toggle-lang': {
setLang(i18nState.lang === 'zh' ? 'en' : 'zh');
break;
}
}
});
// 阻止详情区域内点击冒泡触发折叠
document.addEventListener('click', (ev) => {
if (ev.target.closest('.task-detail') && !ev.target.closest('[data-action]')) {
ev.stopPropagation();
}
}, true);
// ── 项目列表拖动排序 ──
let dragId = null;
$('#projectList').addEventListener('dragstart', (ev) => {
const li = ev.target.closest('li[data-id]');
if (!li) return;
dragId = li.dataset.id;
li.classList.add('dragging');
ev.dataTransfer.effectAllowed = 'move';
});
$('#projectList').addEventListener('dragend', (ev) => {
const li = ev.target.closest('li');
if (li) li.classList.remove('dragging');
$('#projectList').querySelectorAll('.drop-over').forEach((x) => x.classList.remove('drop-over'));
dragId = null;
});
$('#projectList').addEventListener('dragover', (ev) => {
ev.preventDefault();
const li = ev.target.closest('li[data-id]');
$('#projectList').querySelectorAll('.drop-over').forEach((x) => x.classList.remove('drop-over'));
if (li && li.dataset.id !== dragId) li.classList.add('drop-over');
});
$('#projectList').addEventListener('drop', (ev) => {
ev.preventDefault();
const li = ev.target.closest('li[data-id]');
if (!li || !dragId || li.dataset.id === dragId) return;
const ids = S.projects.map((p) => p.id);
const from = ids.indexOf(dragId);
const to = ids.indexOf(li.dataset.id);
if (from < 0 || to < 0) return;
ids.splice(to, 0, ids.splice(from, 1)[0]); // 插到目标位置
S.projects.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); // 乐观重排
renderSidebar();
act(async () => {
await api('/api/projects/reorder', { method: 'POST', body: JSON.stringify({ order: ids }) });
await loadProjects();
renderSidebar();
}, t('reorderSaved'));
});
// ── 表单提交 ──
$('#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(t('toastNameRequired')); 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();
}, t('toastProjectCreated', { name }));
});
$('#settingsForm').addEventListener('submit', (ev) => {
ev.preventDefault();
const n = Number($('#setGlobalConc').value);
if (!Number.isInteger(n) || n < 0) { toast(t('globalConcInt')); return; }
act(async () => {
await api('/api/settings', { method: 'PUT', body: JSON.stringify({ globalConcurrency: n }) });
$('#settingsRoot').hidden = true;
}, t('toastSettingsSaved'));
});
$('#newTaskPanel').addEventListener('submit', (ev) => {
ev.preventDefault();
const f = ev.target;
if (!S.currentProjectId) { toast(t('toastTaskNoProject')); return; }
const title = f.title.value.trim();
if (!title) { toast(t('toastTitleRequired')); 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 = '';
}, t('toastCreated'));
});
// 关键字筛选(轻防抖)
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') {
const am = $('#archiveModalRoot');
if (!am.hidden) { am.hidden = true; am.innerHTML = ''; return; }
if (S.previewId) { S.previewId = null; S.previewReject = false; renderPreview(); return; }
if (!$('#settingsRoot').hidden) { $('#settingsRoot').hidden = true; 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, t('liveConn'));
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(t('toastWsSynced', { created: p.created ?? 0, done: p.doneAdvanced ?? 0, skipped: 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, t('disconnected', { n: Math.round(delay / 1000) }));
setTimeout(connectWs, delay);
};
ws.onclose = retry;
ws.onerror = () => { try { ws.close(); } catch { /* noop */ } };
}
// 相对时间(agent 开始时间 / 上次同步)定期重绘
setInterval(() => { renderAgents(); renderTopbar(); }, 30000);
// ── 启动 ──
// 初始化 i18n(从 localStorage 恢复语言,更新静态元素)
applyStaticI18n();
// 更新连接状态文字(此时还未连接)
$('#wsText').textContent = t('connecting');
fullRefresh().then(connectWs);