feat(web): 控制台 UI 批次——用量卡片精简+详情弹框、项目列表封顶 6、评审弹框增强、live 输出流、标题栏置顶

- 全局 AGENT 卡片精简为 2×2 概览(近 7 天窗口 + 真实运行数),删按项目列表,加「详情」弹框(按天/周/月柱状图 + 分项目下钻表);- 项目列表展开态封顶 6,多余折叠;- plan/exec 评审弹框:markdown 折叠卡片 + 点文件看 diff;- live 输出流提取 tool_use/tool_result;- Agent/审批闸/任务树/已归档标题栏滚动置顶。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-29 17:42:39 +08:00
parent 5198435ba0
commit 1c62059424
8 changed files with 414 additions and 77 deletions
+16 -13
View File
@@ -79,15 +79,25 @@ export function buildTaskTree(flat) {
// ── 审批闸卡片 ─────────────────────────────────────────
export function adaptApproval(tk, projName) {
const gate = tk.status.replace('_review', ''); // plan | spec | exec
const r = tk.result || {};
let doc;
if (gate === 'plan') doc = tk.plan;
else if (gate === 'spec') doc = tk.spec;
else doc = (tk.result && (tk.result.diffSummary || JSON.stringify(tk.result, null, 2))) || tk.operations;
return {
else doc = r.summary || r.diffSummary || tk.operations; // exec:优先 code review 报告(做了什么/怎么做/测试/逐条 review)
const out = {
id: tk.id, gate, taskId: tk.id, title: tk.title,
meta: `${projName || ''} · ${(tk.complexity || '').toUpperCase()}`,
doc: doc || '(无可预览内容)',
};
// 结果评审:挂上 双复审结论 + 安全报告 + 提交 + diff 体量,供评审弹层结构化展示
if (gate === 'exec' && tk.result) {
out.review = {
verdict: r.verdict || null, securityVerdict: r.securityVerdict || null,
securitySummary: r.securitySummary || null,
commits: r.commits || [], diffSummary: r.diffSummary || null, branch: r.branch || null,
};
}
return out;
}
// ── 事件流 ─────────────────────────────────────────────
@@ -144,20 +154,13 @@ export function deriveGlobal(projects, agentsResp) {
};
}
// 跨项目 agent 概览。token/成本来自 /api/usage 的 cost 明细(按当期窗口聚合)。
// 跨项目 agent 概览(常驻卡片 2×2 概览)。token/成本/运行来自 /api/usage 的 cost 明细(近 7 天窗口聚合)。
// 按项目下钻已移至「详情」弹框(独立走 /api/usage/detail),此处不再构建 byProject。
export function deriveAgentSummary(projects, agentsResp, cost) {
const byPid = new Map((cost?.byProject || []).map((c) => [c.projectId, c]));
const tokensTotal = (cost?.byProject || []).reduce((sum, c) => sum + (c.tokens || 0), 0);
return {
tokensWeek: tokensTotal, runsWeek: 0, costWeek: cost?.total || 0, activeNow: agentsResp?.totalActive ?? 0,
byProject: projects.map((p) => {
const c = byPid.get(p.id);
return {
id: p.id, name: p.name, hue: p.hue,
active: p.agents || 0, runs: 0,
tokens: c?.tokens || 0, cost: c?.costUsd || 0,
};
}),
tokensWeek: tokensTotal, runsWeek: cost?.runs || 0, costWeek: cost?.total || 0,
activeNow: agentsResp?.totalActive ?? 0,
};
}
+2 -1
View File
@@ -24,7 +24,8 @@ export const api = {
projectEvents: (pid) => http('GET', `/api/projects/${pid}/events`),
agents: () => http('GET', '/api/agents'),
approvals: () => http('GET', '/api/approvals'),
usage: () => http('GET', '/api/usage'),
usage: () => http('GET', '/api/usage?period=week'),
usageDetail: (g) => http('GET', `/api/usage/detail?granularity=${g}`),
syncProject: (pid) => http('POST', `/api/projects/${pid}/sync`),
decide: (taskId, action, reason, merge) =>
http('POST', `/api/tasks/${taskId}/decide`, { action, reason, merge }),
+52 -15
View File
@@ -48,19 +48,47 @@ function NewProjectModal({ t, lang, onCreate, onClose, defaultAutonomy }) {
);
}
// SDK 流式消息 → 一行可读文本(助手文本 / 工具调用 / 终态 / maestro 元事件
function summarizeMessage(m) {
if (!m || !m.type) return '';
// 工具参数 → 可读摘要(命令 / 文件 / 模式…
function toolArg(input) {
if (!input || typeof input !== 'object') return '';
return input.command || input.file_path || input.path || input.pattern || input.url || input.prompt
|| (Object.keys(input).length ? JSON.stringify(input) : '');
}
const trunc = (s, n) => { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n) + '…' : s; };
// SDK 流式消息 → 结构化块(助手文本 / 工具调用含参数 / 工具结果预览 / 终态 / 元事件)
function messageBlocks(m) {
if (!m || !m.type) return [];
const content = m.message && m.message.content;
const out = [];
if (m.type === 'assistant' && Array.isArray(content)) {
return content.map((c) => (c.type === 'text' ? c.text : c.type === 'tool_use' ? `${c.name}` : '')).filter(Boolean).join(' ');
for (const c of content) {
if (c.type === 'text' && c.text && c.text.trim()) out.push({ kind: 'text', text: c.text.trim() });
else if (c.type === 'tool_use') out.push({ kind: 'tool', name: c.name, arg: trunc(toolArg(c.input), 220) });
}
} else if (m.type === 'user' && Array.isArray(content)) {
for (const c of content) {
if (c.type === 'tool_result') {
const cnt = typeof c.content === 'string' ? c.content
: Array.isArray(c.content) ? c.content.map((x) => (x && x.text) || '').join('') : '';
out.push({ kind: 'result', text: trunc((cnt || '').trim(), 400), isError: c.is_error });
}
}
} else if (m.type === 'result') out.push({ kind: 'meta', text: '■ ' + (m.subtype || 'done') });
else if (typeof m.type === 'string' && m.type.startsWith('maestro.')) out.push({ kind: 'meta', text: '· ' + m.type });
return out;
}
// 单块渲染:文本 / 工具调用(青) / 结果预览(暗、缩进) / 元事件
function StreamLine({ b }) {
if (b.kind === 'tool') {
return <div style={{ padding: '3px 0' }}><span style={{ color: 'var(--cyan)', fontWeight: 600 }}> {b.name}</span>{b.arg ? <span style={{ color: 'var(--muted)' }}> {b.arg}</span> : null}</div>;
}
if (m.type === 'user' && Array.isArray(content)) {
return content.some((c) => c.type === 'tool_result') ? '↩ tool result' : '';
if (b.kind === 'result') {
return <div style={{ padding: '1px 0 5px 16px', color: b.isError ? 'var(--red)' : 'var(--faint)', fontSize: 11.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}> {b.text || '(空)'}</div>;
}
if (m.type === 'result') return `${m.subtype || 'done'}`;
if (typeof m.type === 'string' && m.type.startsWith('maestro.')) return `· ${m.type}`;
return '';
if (b.kind === 'meta') return <div style={{ padding: '3px 0', color: 'var(--green)' }}>{b.text}</div>;
return <div style={{ padding: '3px 0', color: 'var(--ink)' }}>{b.text}</div>;
}
// 实时流模态:EventSource 跟随某 run 的 transcript,逐条渲染 agent 输出
@@ -72,9 +100,9 @@ function StreamModal({ agent, onClose }) {
const es = new EventSource(`/api/tasks/${agent.taskId}/stream`);
es.addEventListener('open', () => setStatus('streaming'));
es.onmessage = (e) => {
let text = '';
try { text = summarizeMessage(JSON.parse(e.data)); } catch { text = ''; }
if (text) setLines((ls) => [...ls.slice(-400), text]);
let bs = [];
try { bs = messageBlocks(JSON.parse(e.data)); } catch { bs = []; }
if (bs.length) setLines((ls) => [...ls, ...bs].slice(-500));
};
es.addEventListener('end', () => { setStatus('done'); es.close(); });
es.onerror = () => { setStatus('disconnected'); es.close(); };
@@ -95,7 +123,7 @@ function StreamModal({ agent, onClose }) {
<button onClick={onClose} style={{ fontFamily: 'var(--mono)', fontSize: 13, background: 'transparent', color: 'var(--muted)', border: 'none', cursor: 'pointer' }}></button>
</div>
<div ref={boxRef} style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', fontFamily: 'var(--mono)', fontSize: 12.5, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--ink)' }}>
{lines.length ? lines.map((l, i) => <div key={i} style={{ paddingBottom: 2 }}>{l}</div>)
{lines.length ? lines.map((b, i) => <StreamLine key={i} b={b} />)
: <div style={{ color: 'var(--faint)' }}>等待输出</div>}
</div>
</div>
@@ -275,7 +303,16 @@ export function App() {
const REVIEW = new Set(['plan_review', 'spec_review', 'exec_review']);
const gates = approvalsRaw
.filter((a) => a.projectId === currentId && REVIEW.has(a.status))
.map((a) => adaptApproval(a, projNameOf(a.projectId)));
.map((a) => {
const g = adaptApproval(a, projNameOf(a.projectId));
// 拆解评审:挂上实际拆出的子任务(标题/复杂度/优先级/依赖/状态),供评审弹层结构化展示
if (g.gate === 'plan') {
g.subtasks = tasksRaw
.filter((tk) => tk.parentId === a.id)
.map((tk) => ({ id: tk.id, title: tk.title, complexity: tk.complexity, priority: tk.priority, deps: tk.deps || [], status: tk.status }));
}
return g;
});
const agents = adaptActiveAgents(agentsResp, currentId);
const events = eventsRaw.map(adaptEvent);
const quota = adaptQuota(usage);
@@ -366,7 +403,7 @@ export function App() {
collapsed={collapsed} onToggleCollapse={toggleCollapse}
onSelect={setCurrentId} onNewProject={() => setShowNewProject(true)} onReorder={reorderProjects}
settings={settings} onSaveSettings={saveSettings} />
<main style={{ overflowY: 'auto', padding: '0 22px 60px' }}>
<main style={{ overflowY: 'auto', minHeight: 0, padding: '0 22px 60px' }}>
{project ? <window.MaestroKitTopbar project={project} t={t} theme={theme}
onToggleTheme={() => setTheme(theme === 'light' ? 'dark' : 'light')}
lang={lang} onSelectLang={setLang}
+1 -1
View File
@@ -103,7 +103,7 @@ function ArchiveSection({ items, t, onOpen }) {
const slice = items.slice((cur - 1) * size, cur * size);
return (
<section style={{ opacity: .82 }}>
<window.MaestroKitSectionHead icon="archive" title={t.archive} count={items.length} accent="var(--faint)" folded={folded} onToggleFold={toggleFold} t={t} />
<window.MaestroKitSectionHead icon="archive" title={t.archive} count={items.length} accent="var(--faint)" folded={folded} onToggleFold={toggleFold} t={t} sticky />
{!folded ? (
<React.Fragment>
<div>{slice.map((it) => <ArchiveRow key={it.id} item={it} t={t} onOpen={onOpen} />)}</div>
+170 -3
View File
@@ -1,6 +1,108 @@
// ── 报告渲染:把 markdown(## 章节 + - 列表 + **粗体** + `代码`)渲染成可折叠卡片 ──
function mdInline(text) {
const nodes = []; const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
let last = 0; let m; let key = 0;
while ((m = re.exec(text))) {
if (m.index > last) nodes.push(text.slice(last, m.index));
const tok = m[0];
if (tok.startsWith('**')) nodes.push(<b key={key++} style={{ color: 'var(--ink)' }}>{tok.slice(2, -2)}</b>);
else nodes.push(<code key={key++} style={{ fontFamily: 'var(--mono)', fontSize: '.92em', color: 'var(--cyan)', background: 'var(--panel-2)', borderRadius: 3, padding: '1px 5px' }}>{tok.slice(1, -1)}</code>);
last = m.index + tok.length;
}
if (last < text.length) nodes.push(text.slice(last));
return nodes;
}
function mdBody(body) {
const out = []; let key = 0;
for (const raw of body.split('\n')) {
const t = raw.trim();
if (!t) { out.push(<div key={key++} style={{ height: 6 }} />); continue; }
if (/^[-*]\s/.test(t)) {
out.push(<div key={key++} style={{ display: 'flex', gap: 8, padding: '2px 0 2px 2px' }}><span style={{ flex: 'none', color: 'var(--green)' }}>·</span><span>{mdInline(t.replace(/^[-*]\s/, ''))}</span></div>);
} else if (/^#{1,4}\s/.test(t)) {
out.push(<div key={key++} style={{ fontWeight: 700, color: 'var(--muted)', margin: '6px 0 2px' }}>{mdInline(t.replace(/^#{1,4}\s/, ''))}</div>);
} else {
out.push(<div key={key++} style={{ padding: '1px 0' }}>{mdInline(t)}</div>);
}
}
return out;
}
function mdSections(md) {
if (!md) return [];
const out = []; const lines = md.split('\n');
let cur = null;
for (const line of lines) {
const h = line.match(/^##\s+(.+)$/);
if (h) { if (cur) out.push(cur); cur = { title: h[1].trim(), body: '' }; }
else if (cur) { cur.body += (cur.body ? '\n' : '') + line; }
else { if (line.trim()) { cur = { title: '', body: line }; } } // 首章节前的前言
}
if (cur) out.push(cur);
return out;
}
function ReportSectionCard({ title, body, t, defaultOpen }) {
const [open, setOpen] = React.useState(defaultOpen);
return (
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 6, overflow: 'hidden' }}>
<div onClick={() => setOpen((o) => !o)} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '9px 12px', cursor: 'pointer', background: 'var(--panel-2)', userSelect: 'none' }}>
<span style={{ flex: 'none', color: 'var(--green)', fontSize: 9, transition: 'transform .12s', transform: open ? 'none' : 'rotate(-90deg)' }}></span>
<span style={{ flex: 1, fontSize: 12.5, fontWeight: 700, color: 'var(--ink)', letterSpacing: '.02em' }}>{title || '说明'}</span>
</div>
{open ? <div style={{ padding: '11px 14px', fontSize: 12.5, lineHeight: 1.7, color: 'var(--ink)', wordBreak: 'break-word' }}>{mdBody(body)}</div> : null}
</div>
);
}
function ReportCards({ md, t }) {
const secs = mdSections(md);
if (!secs.length) return null;
return <div style={{ display: 'grid', gap: 8 }}>{secs.map((s, i) => <ReportSectionCard key={i} title={s.title} body={s.body} t={t} defaultOpen />)}</div>;
}
// 单文件 diff 渲染(+/- 行着色)
function DiffBody({ diff }) {
return (
<pre style={{ margin: 0, padding: '10px 12px', fontSize: 11.5, lineHeight: 1.5, overflowX: 'auto', background: 'var(--bg-deep)', fontFamily: 'var(--mono)' }}>
{diff.split('\n').map((ln, i) => {
const c = (ln.startsWith('+') && !ln.startsWith('+++')) ? 'var(--green)'
: (ln.startsWith('-') && !ln.startsWith('---')) ? 'var(--red)'
: ln.startsWith('@@') ? 'var(--cyan)'
: (ln.startsWith('diff --git') || ln.startsWith('index ') || ln.startsWith('+++') || ln.startsWith('---')) ? 'var(--faint)'
: 'var(--muted)';
return <div key={i} style={{ color: c, whiteSpace: 'pre' }}>{ln || ' '}</div>;
})}
</pre>
);
}
// 改动文件列表:点击文件展开其 diff(按需向 daemon 取)
function FileDiffs({ taskId, t }) {
const [files, setFiles] = React.useState(null);
const [openPath, setOpenPath] = React.useState(null);
React.useEffect(() => {
let alive = true;
fetch('/api/tasks/' + taskId + '/diff').then((r) => r.json()).then((d) => { if (alive) setFiles(d.files || []); }).catch(() => { if (alive) setFiles([]); });
return () => { alive = false; };
}, [taskId]);
if (!files) return <div style={{ fontSize: 11, color: 'var(--faint)', padding: '4px 2px' }}> diff</div>;
if (!files.length) return null;
return (
<div style={{ display: 'grid', gap: 6, marginTop: 9 }}>
{files.map((f) => (
<div key={f.path} style={{ border: '1px solid var(--line-soft)', borderRadius: 5, overflow: 'hidden' }}>
<div onClick={() => setOpenPath((p) => (p === f.path ? null : f.path))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 11px', cursor: 'pointer', background: 'var(--panel-2)' }}>
<span style={{ flex: 'none', color: 'var(--green)', fontSize: 9, transform: openPath === f.path ? 'none' : 'rotate(-90deg)' }}></span>
<span style={{ flex: 1, fontSize: 12, color: 'var(--cyan)', wordBreak: 'break-all' }}>{f.path}</span>
<span style={{ flex: 'none', fontSize: 10, color: 'var(--faint)' }}>{t.clickToView || ''}</span>
</div>
{openPath === f.path ? <DiffBody diff={f.diff} /> : null}
</div>
))}
</div>
);
}
// 全屏预览:读完整方案 + 就地裁决
function GatePreview({ item, t, onDecide, onClose }) {
const { ComplexityBadge, Button, Textarea } = window.MaestroDesignSystem_a6a290;
const { ComplexityBadge, StatusChip, Button, Textarea } = window.MaestroDesignSystem_a6a290;
const [rejecting, setRejecting] = React.useState(false);
const [reason, setReason] = React.useState('');
React.useEffect(() => {
@@ -9,6 +111,26 @@ function GatePreview({ item, t, onDecide, onClose }) {
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
if (!item) return null;
// 拆解子任务序号映射(依赖用 #序号 引用)
const subIdx = {};
(item.subtasks || []).forEach((s, i) => { subIdx[s.id] = i + 1; });
const rv = item.review;
const vBadge = (label, v) => {
const ok = v === 'approve', bad = v === 'reject';
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11.5, fontWeight: 700, padding: '4px 11px', borderRadius: 5,
color: ok ? 'var(--green)' : bad ? 'var(--red)' : 'var(--faint)',
border: '1px solid ' + (ok ? 'var(--green-dim)' : bad ? 'var(--red-dim)' : 'var(--line)'),
background: ok ? 'rgba(95,221,125,.08)' : bad ? 'rgba(255,93,93,.08)' : 'transparent',
}}>
<span style={{ opacity: .75, fontWeight: 600 }}>{label}</span>
<span>{ok ? '✓ ' + t.vApprove : bad ? '✗ ' + t.vReject : t.vNone}</span>
</span>
);
};
const secLabel = (txt) => <div style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--muted)', letterSpacing: '.18em', margin: '18px 0 8px' }}>{txt}</div>;
const reportBox = (txt) => <div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 4, padding: '12px 16px', fontSize: 12.5, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--ink)' }}>{txt}</div>;
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 1100, display: 'grid', placeItems: 'center' }}>
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(4,6,5,.86)', backdropFilter: 'blur(3px)' }}></div>
@@ -21,8 +143,53 @@ function GatePreview({ item, t, onDecide, onClose }) {
<button onClick={onClose} title={t.closeTip} style={{ fontFamily: 'var(--mono)', fontSize: 13, background: 'transparent', color: 'var(--muted)', border: 'none', cursor: 'pointer', padding: '4px 8px' }}></button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px 28px', fontFamily: 'var(--mono)' }}>
{item.gate === 'exec' && rv ? (
<div style={{ display: 'flex', gap: 10, marginBottom: 14, flexWrap: 'wrap' }}>
{vBadge(t.lblCodeReview, rv.verdict)}
{vBadge(t.lblSecurity, rv.securityVerdict)}
</div>
) : null}
{item.docLabel ? <div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 6 }}>{item.docLabel}</div> : null}
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', borderRadius: 4, padding: '12px 16px', fontSize: 13.5, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--ink)' }}>{item.doc}</div>
{/^##\s/m.test(item.doc || '')
? <ReportCards md={item.doc} t={t} />
: <div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', borderRadius: 4, padding: '12px 16px', fontSize: 13.5, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--ink)' }}>{item.doc}</div>}
{item.gate === 'exec' && rv && rv.securitySummary ? (
<React.Fragment>{secLabel(t.lblSecurity)}<ReportCards md={rv.securitySummary} t={t} /></React.Fragment>
) : null}
{item.gate === 'exec' && rv && (rv.commits.length || rv.diffSummary) ? (
<React.Fragment>
{secLabel(t.lblChanges + (rv.branch ? ' · ' + rv.branch : ''))}
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 4, padding: '12px 16px', fontSize: 12.5, lineHeight: 1.7 }}>
{rv.commits.map((c) => <div key={c} style={{ color: 'var(--green)' }}>· {c}</div>)}
{rv.diffSummary ? <div style={{ marginTop: rv.commits.length ? 9 : 0, color: 'var(--faint)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{rv.diffSummary}</div> : null}
</div>
<FileDiffs taskId={item.taskId} t={t} />
</React.Fragment>
) : null}
{item.gate === 'plan' && item.subtasks && item.subtasks.length ? (
<div style={{ marginTop: 20 }}>
<div style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 11 }}>{t.gateSubtasks} · {item.subtasks.length}</div>
<div style={{ display: 'grid', gap: 9 }}>
{item.subtasks.map((s, i) => {
const deps = (s.deps || []).map((d) => (subIdx[d] ? '#' + subIdx[d] : null)).filter(Boolean);
return (
<div key={s.id} style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 6, padding: '11px 13px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: '50%', background: 'var(--panel-2)', color: 'var(--muted)', fontSize: 11, fontWeight: 700, display: 'grid', placeItems: 'center' }}>{i + 1}</span>
<ComplexityBadge complexity={s.complexity} />
<span style={{ flex: 'none', fontSize: 10, fontWeight: 700, color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 3, padding: '1px 6px' }}>P{s.priority}</span>
<span style={{ flex: 1, fontSize: 13.5, fontWeight: 600, color: 'var(--ink)', minWidth: 120 }}>{s.title}</span>
{StatusChip ? <StatusChip status={s.status} label={t.status[s.status]} /> : null}
</div>
<div style={{ marginTop: 7, paddingLeft: 31, fontSize: 11.5, color: 'var(--faint)' }}>
{t.dependsOn}: {deps.length ? <span style={{ color: 'var(--amber)', fontWeight: 600 }}>{deps.join('、')}</span> : t.noDeps}
</div>
</div>
);
})}
</div>
</div>
) : null}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', padding: '12px 16px', borderTop: '1px solid var(--line)', background: 'var(--panel-2)' }}>
{rejecting ? (
@@ -60,7 +227,7 @@ function GateSection({ approvals, onDecide, t }) {
if (approvals.length === 0) return null;
return (
<section>
<window.MaestroKitSectionHead icon="gate" title={t.gateSection} count={approvals.length} accent="var(--violet)" folded={sectionFolded} onToggleFold={toggleSectionFold} t={t} />
<window.MaestroKitSectionHead icon="gate" title={t.gateSection} count={approvals.length} accent="var(--violet)" folded={sectionFolded} onToggleFold={toggleSectionFold} t={t} sticky />
{!sectionFolded ? <GateStripe /> : null}
{!sectionFolded ? approvals.map((a) => {
const isFolded = folded.has(a.id);
+157 -43
View File
@@ -231,14 +231,13 @@ function fmtTokens(n) {
return String(n);
}
// 全局 AGENT 卡片(内联常驻;按项目默认 3 个、多余折叠、按最近活跃排序)
function AgentCard({ global, summary, t, collapsed }) {
const [showAll, setShowAll] = React.useState(false);
function fmtUsd(n) { return '$' + (n || 0).toFixed(2); }
function shortModel(m) { return (m || 'unknown').replace(/^claude-/, ''); }
// 全局 AGENT 卡片(内联常驻;2×2 概览 = token/花费/运行/活跃,近 7 天窗口;明细走「详情」弹框)
function AgentCard({ global, summary, t, collapsed, projects }) {
const [showDetail, setShowDetail] = React.useState(false);
if (collapsed) return null;
// 「最近活跃」排序:运行中优先,其次本周 token 用量(无活跃时间戳时的代理)
const projs = [...summary.byProject].sort((a, b) => (b.active - a.active) || (b.tokens - a.tokens));
const maxTok = Math.max(...projs.map((p) => p.tokens), 1);
const shown = showAll ? projs : projs.slice(0, 3);
return (
<div style={{
margin: '10px 12px 4px', border: '1px solid var(--line)', borderRadius: 'var(--radius-md,6px)',
@@ -248,8 +247,8 @@ function AgentCard({ global, summary, t, collapsed }) {
<span style={{ color: 'var(--cyan)', display: 'inline-flex' }}><IcoAgents /></span>{t.gAgents.toUpperCase()}
<span style={{ marginLeft: 'auto', fontWeight: 400, letterSpacing: '.04em', color: 'var(--faint)' }}>{t.apWeek}</span>
</div>
{/* 总结 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1, background: 'var(--line-soft)', border: '1px solid var(--line-soft)', borderRadius: 4, overflow: 'hidden', marginBottom: 11 }}>
{/* 概览 2×2 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1, background: 'var(--line-soft)', border: '1px solid var(--line-soft)', borderRadius: 4, overflow: 'hidden', marginBottom: 10 }}>
{[
[fmtTokens(summary.tokensWeek), t.apTokens, 'var(--cyan)'],
['$' + summary.costWeek.toFixed(1), t.apCost, 'var(--green)'],
@@ -262,40 +261,140 @@ function AgentCard({ global, summary, t, collapsed }) {
</div>
))}
</div>
{/* 按项目(默认 3,多余折叠,按活跃排序)*/}
<div style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '.18em', color: 'var(--faint)', marginBottom: 7 }}>{t.apPerProj}</div>
<div style={{ display: 'grid', gap: 8 }}>
{shown.map((p) => (
<div key={p.id}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 3 }}>
<span style={{ flex: 'none', width: 16, height: 16, borderRadius: 4, display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, color: '#0a0d0b', background: 'hsl(' + p.hue + ' 45% 60%)' }}>{p.name[0].toUpperCase()}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
{p.active > 0 ? (
<span style={{ flex: 'none', display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 9.5, color: 'var(--cyan)', border: '1px solid var(--cyan-dim)', borderRadius: 3, padding: '0 5px' }}>
<span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--cyan)', boxShadow: '0 0 6px var(--cyan)', animation: 'maestro-pulse .9s infinite' }}></span>{p.active}
</span>
) : <span style={{ flex: 'none', fontSize: 9.5, color: 'var(--faint)' }}>{t.apIdle}</span>}
<span style={{ marginLeft: 'auto', flex: 'none', fontSize: 11, fontWeight: 600, color: 'var(--cyan)' }}>{fmtTokens(p.tokens)}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingLeft: 24 }}>
<span style={{ flex: 1, height: 4, background: 'var(--panel-2)', borderRadius: 2, overflow: 'hidden' }}>
<span style={{ display: 'block', height: '100%', width: (p.tokens / maxTok * 100) + '%', background: 'var(--cyan)', boxShadow: '0 0 6px rgba(89,200,216,.5)' }}></span>
</span>
<span style={{ flex: 'none', fontSize: 10, color: 'var(--faint)' }}>{p.runs} {t.apRuns}</span>
</div>
{/* 详情按钮 → 弹框 */}
<button type="button" onClick={() => setShowDetail(true)} style={{
width: '100%', background: 'transparent', border: '1px dashed var(--line)', color: 'var(--faint)',
borderRadius: 4, padding: '5px 0', fontSize: 10.5, fontFamily: 'var(--mono)', cursor: 'pointer', letterSpacing: '.08em',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--cyan)'; e.currentTarget.style.borderColor = 'var(--cyan-dim)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--faint)'; e.currentTarget.style.borderColor = 'var(--line)'; }}>
{t.apDetail}
</button>
{showDetail ? <AgentDetailModal projects={projects} t={t} onClose={() => setShowDetail(false)} /> : null}
</div>
);
}
// 用量详情弹框:按天/按月时间序列柱状图 + 分项目下钻表(token/花费/任务数/模型)。直连 /api/usage/detail。
function AgentDetailModal({ projects, t, onClose }) {
const [gran, setGran] = React.useState('day');
const [data, setData] = React.useState(null);
const [err, setErr] = React.useState(false);
React.useEffect(() => {
let alive = true;
setData(null); setErr(false);
fetch('/api/usage/detail?granularity=' + gran)
.then((r) => r.ok ? r.json() : Promise.reject(new Error('http')))
.then((d) => { if (alive) setData(d); })
.catch(() => { if (alive) setErr(true); });
return () => { alive = false; };
}, [gran]);
const nameOf = new Map((projects || []).map((p) => [p.id, p]));
const series = data?.series || [];
const maxCost = Math.max(...series.map((s) => s.costUsd), 1e-9);
const tot = data?.total || { costUsd: 0, tokens: 0, runs: 0, tasks: 0 };
const emptyBox = (msg) => <div style={{ padding: '34px 0', textAlign: 'center', color: 'var(--faint)', fontSize: 12 }}>{msg}</div>;
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 2000, display: 'grid', placeItems: 'center' }}>
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(4,6,5,.86)', backdropFilter: 'blur(3px)' }}></div>
<div style={{
position: 'relative', width: 'min(700px, 92vw)', maxHeight: '86vh', overflowY: 'auto',
background: 'var(--bg-deep)', border: '1px solid var(--line)', borderRadius: 'var(--radius-md,6px)',
boxShadow: '0 18px 50px rgba(0,0,0,.7)', padding: '18px 20px', animation: 'maestro-rise .14s ease both',
}}>
{/* 标题 + 段切换 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<span style={{ color: 'var(--cyan)', display: 'inline-flex' }}><IcoAgents /></span>
<span style={{ fontSize: 14, fontWeight: 700, letterSpacing: '.04em', color: 'var(--ink)' }}>{t.apDetailTitle}</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 1, background: 'var(--line-soft)', border: '1px solid var(--line-soft)', borderRadius: 4, overflow: 'hidden' }}>
{[['day', t.apByDay], ['week', t.apByWeek], ['month', t.apByMonth]].map(([g, label]) => (
<button key={g} type="button" onClick={() => setGran(g)} style={{
background: gran === g ? 'var(--cyan-dim)' : 'var(--bg-deep)', color: gran === g ? 'var(--cyan)' : 'var(--muted)',
border: 'none', padding: '4px 13px', fontSize: 11, fontFamily: 'var(--mono)', cursor: 'pointer',
}}>{label}</button>
))}
</div>
))}
</div>
{err ? emptyBox(t.apEmpty) : !data ? emptyBox('…') : (
<React.Fragment>
{/* total 概览 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 1, background: 'var(--line-soft)', border: '1px solid var(--line-soft)', borderRadius: 4, overflow: 'hidden', marginBottom: 14 }}>
{[
[fmtUsd(tot.costUsd), t.apCost, 'var(--green)'],
[fmtTokens(tot.tokens), t.apTokens, 'var(--cyan)'],
[tot.runs, t.apRuns, 'var(--ink)'],
[tot.tasks, t.apColTasks, 'var(--ink)'],
].map(([v, k, c], i) => (
<div key={i} style={{ background: 'var(--bg-deep)', padding: '8px 10px' }}>
<div style={{ fontSize: 15, fontWeight: 700, color: c }}>{v}</div>
<div style={{ fontSize: 9.5, color: 'var(--muted)', letterSpacing: '.06em', marginTop: 1 }}>{k}</div>
</div>
))}
</div>
{/* 时间序列柱状图(按花费成柱,hover 看 token */}
{series.length ? (
<React.Fragment>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 3, height: 110, padding: '0 2px 4px', borderBottom: '1px solid var(--line-soft)' }}>
{series.map((s) => (
<div key={s.bucket} title={s.bucket + ' · ' + fmtUsd(s.costUsd) + ' · ' + fmtTokens(s.tokens) + ' ' + t.apTokens + ' · ' + s.runs + ' ' + t.apRuns}
style={{ flex: 1, minWidth: 2, height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
<div style={{ height: Math.max(2, s.costUsd / maxCost * 100) + '%', background: 'var(--cyan)', borderRadius: '2px 2px 0 0', boxShadow: '0 0 6px rgba(89,200,216,.5)' }}></div>
</div>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 9, color: 'var(--faint)', fontFamily: 'var(--mono)', margin: '4px 0 16px' }}>
<span>{series[0].bucket}</span><span>{series[series.length - 1].bucket}</span>
</div>
</React.Fragment>
) : <div style={{ marginBottom: 16 }}>{emptyBox(t.apEmpty)}</div>}
{/* 分项目下钻表 */}
<div style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '.18em', color: 'var(--faint)', marginBottom: 8 }}>{t.apPerProj}</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11.5 }}>
<thead>
<tr style={{ color: 'var(--muted)', fontSize: 9.5, letterSpacing: '.06em' }}>
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'left' }}>{t.apColProject}</th>
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'right' }}>{t.apTokens}</th>
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'right' }}>{t.apCost}</th>
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'right' }}>{t.apColTasks}</th>
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'left' }}>{t.apColModels}</th>
</tr>
</thead>
<tbody>
{data.byProject.length ? data.byProject.map((p) => {
const pr = nameOf.get(p.projectId);
const nm = pr?.name || p.projectId;
const hue = pr?.hue ?? 200;
return (
<tr key={p.projectId} style={{ borderTop: '1px solid var(--line-soft)' }}>
<td style={{ padding: '6px' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
<span style={{ flex: 'none', width: 16, height: 16, borderRadius: 4, display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, color: '#0a0d0b', background: 'hsl(' + hue + ' 45% 60%)' }}>{nm[0].toUpperCase()}</span>
<span style={{ color: 'var(--ink)', fontWeight: 600 }}>{nm}</span>
</span>
</td>
<td style={{ padding: '6px', textAlign: 'right', color: 'var(--cyan)', fontWeight: 600 }}>{fmtTokens(p.tokens)}</td>
<td style={{ padding: '6px', textAlign: 'right', color: 'var(--green)', fontWeight: 600 }}>{fmtUsd(p.costUsd)}</td>
<td style={{ padding: '6px', textAlign: 'right', color: 'var(--ink)' }}>{p.tasks}</td>
<td style={{ padding: '6px' }}>
<span style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
{p.models.map((m) => (
<span key={m.model} title={shortModel(m.model) + ' · ' + fmtUsd(m.costUsd) + ' · ' + fmtTokens(m.tokens)} style={{ fontSize: 9, color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 3, padding: '0 5px', whiteSpace: 'nowrap' }}>{shortModel(m.model)}</span>
))}
</span>
</td>
</tr>
);
}) : <tr><td colSpan={5}>{emptyBox(t.apEmpty)}</td></tr>}
</tbody>
</table>
</React.Fragment>
)}
</div>
{projs.length > 3 ? (
<button type="button" onClick={() => setShowAll((s) => !s)} style={{
marginTop: 9, width: '100%', background: 'transparent', border: '1px dashed var(--line)', color: 'var(--faint)',
borderRadius: 4, padding: '4px 0', fontSize: 10.5, fontFamily: 'var(--mono)', cursor: 'pointer', letterSpacing: '.05em',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.borderColor = 'var(--muted)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--faint)'; e.currentTarget.style.borderColor = 'var(--line)'; }}>
{showAll ? t.apCollapse : t.apMore.replace('{n}', projs.length - 3)}
</button>
) : null}
</div>
);
}
@@ -371,6 +470,10 @@ function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onTog
const { Button } = window.MaestroDesignSystem_a6a290;
const [dragId, setDragId] = React.useState(null);
const [overId, setOverId] = React.useState(null);
const [showAllProjects, setShowAllProjects] = React.useState(false);
const PROJ_LIMIT = 6;
// 展开态下最多显示 6 个,多余折叠(折叠侧栏 rail 态显示全部小圆点,不截断)
const shownProjects = (collapsed || showAllProjects) ? projects : projects.slice(0, PROJ_LIMIT);
const onDrop = (targetId) => {
if (dragId && dragId !== targetId && onReorder) {
const ids = projects.map((x) => x.id);
@@ -402,7 +505,7 @@ function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onTog
</div>
) : <div style={{ height: 12 }}></div>}
<ul style={{ listStyle: 'none', flex: 1, margin: 0, padding: 0 }}>
{projects.map((p) => {
{shownProjects.map((p) => {
const active = p.id === currentId;
const meta = projStateMeta(p.state, t);
return (
@@ -453,9 +556,20 @@ function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onTog
);
})}
</ul>
{!collapsed && projects.length > PROJ_LIMIT ? (
<button type="button" onClick={() => setShowAllProjects((s) => !s)} style={{
margin: '2px 14px 8px', width: 'calc(100% - 28px)', background: 'transparent',
border: '1px dashed var(--line)', color: 'var(--faint)', borderRadius: 4, padding: '5px 0',
fontSize: 10.5, fontFamily: 'var(--mono)', cursor: 'pointer', letterSpacing: '.05em',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.borderColor = 'var(--muted)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--faint)'; e.currentTarget.style.borderColor = 'var(--line)'; }}>
{showAllProjects ? t.apCollapse : t.apMore.replace('{n}', projects.length - PROJ_LIMIT)}
</button>
) : null}
{/* ── 全局 AGENT 卡片(常驻)+ 用户(全局设置已并入用户菜单)── */}
<div style={{ borderTop: '1px solid var(--line-soft)' }}>
<AgentCard global={global} summary={summary} t={t} collapsed={collapsed} />
<AgentCard global={global} summary={summary} t={t} collapsed={collapsed} projects={projects} />
</div>
<UserRow user={user} t={t} collapsed={collapsed} settings={settings} onSaveSettings={onSaveSettings} />
<div style={{ borderTop: '1px solid var(--line-soft)', padding: collapsed ? '10px 0' : '10px 12px', fontSize: 11, color: 'var(--muted)', display: 'flex', alignItems: 'center', gap: 7, justifyContent: collapsed ? 'center' : 'flex-start' }}>
+1 -1
View File
@@ -42,7 +42,7 @@ function KitSectionHead({ icon, title, count, accent = 'var(--green)', folded, o
display: 'flex', alignItems: 'center', gap: 9,
fontFamily: 'var(--mono)', fontSize: 11, fontWeight: 600, letterSpacing: '.2em',
color: 'var(--muted)', textTransform: 'uppercase', padding: '16px 0 10px',
...(sticky ? { position: 'sticky', top: 0, zIndex: 5, background: 'linear-gradient(var(--bg) 78%, transparent)' } : {}),
...(sticky ? { position: 'sticky', top: 0, zIndex: 20, background: 'var(--bg)', boxShadow: '0 1px 0 var(--line-soft), 0 6px 10px -8px rgba(0,0,0,.6)' } : {}),
}}>
<span style={{ color: accent, flex: 'none', display: 'inline-flex', alignItems: 'center' }}><KitIcon name={icon} /></span>
<span style={{ whiteSpace: 'nowrap' }}>{title}</span>
+15
View File
@@ -24,6 +24,7 @@ window.MAESTRO_I18N = {
projPendingTip: '{n} 项待你审批',
gAgents: '全局 Agent', gAgentsDetail: '{p}/{P} 个项目运行 · {a} 个 agent',
apActive: '运行', apRuns: '次运行', apTokens: 'token', apWeek: '本周', apCost: '花费', apTotal: '所有项目', apPerProj: '按项目', apIdle: '空闲', apMore: '展开其余 {n} 个', apCollapse: '收起',
apDetail: '详情', apDetailTitle: '用量详情', apByDay: '按天', apByWeek: '按周', apByMonth: '按月', apColProject: '项目', apColTasks: '任务数', apColModels: '模型', apEmpty: '暂无用量数据',
gConfig: '全局配置', gConfigDetail: '并发上限 {n} · daemon {v}',
gUserPlan: '订阅', gUserSignOut: '退出登录', gUserSettings: '账户设置',
gGlobalSettings: '全局设置', gSettingsHint: '以下为新建项目的默认值(按用户保存)', gMaxRetries: '最大重试', gTimeoutMin: '超时(分钟)', gAutoPlan: '拆解自动放行', gAutoExec: '执行自动放行', gBudget: '预算 $(空=不限)', gBudgetPeriod: '预算周期', gPeriodDay: '按天', gPeriodMonth: '按月',
@@ -48,6 +49,8 @@ window.MAESTRO_I18N = {
gatePassed: ' 通过',
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: '结果评审' },
gateSubtasks: '拆解出的子任务', noDeps: '无依赖', dependsOn: '依赖',
lblCodeReview: '代码复审', lblSecurity: '安全审计', lblChanges: '改动', vApprove: '通过', vReject: '驳回', vNone: '无结论',
eventTypes: { 'task.created': '任务创建', 'task.updated': '任务更新', 'status.changed': '状态变更', 'approval.requested': '请求审批', 'approval.granted': '审批通过', 'approval.rejected': '审批驳回', 'run.started': '运行开始', 'run.finished': '运行结束', 'project.synced': 'todo 同步' },
},
en: {
@@ -71,6 +74,7 @@ window.MAESTRO_I18N = {
projPendingTip: '{n} awaiting your review',
gAgents: 'Agents', gAgentsDetail: '{p}/{P} projects · {a} agents',
apActive: 'active', apRuns: 'runs', apTokens: 'tokens', apWeek: 'this week', apCost: 'cost', apTotal: 'All projects', apPerProj: 'BY PROJECT', apIdle: 'idle', apMore: '+{n} more', apCollapse: 'Collapse',
apDetail: 'Details', apDetailTitle: 'Usage detail', apByDay: 'By day', apByWeek: 'By week', apByMonth: 'By month', apColProject: 'Project', apColTasks: 'Tasks', apColModels: 'Models', apEmpty: 'No usage data yet',
gConfig: 'Settings', gConfigDetail: 'Max concurrency {n} · daemon {v}',
gUserPlan: 'Plan', gUserSignOut: 'Sign out', gUserSettings: 'Account settings',
gGlobalSettings: 'Global settings', gSettingsHint: 'Defaults applied to new projects (saved per user)', gMaxRetries: 'Max retries', gTimeoutMin: 'Timeout (min)', gAutoPlan: 'Auto-approve plan', gAutoExec: 'Auto-approve exec', gBudget: 'Budget $ (empty=∞)', gBudgetPeriod: 'Budget period', gPeriodDay: 'Daily', gPeriodMonth: 'Monthly',
@@ -95,6 +99,8 @@ window.MAESTRO_I18N = {
gatePassed: ' approved',
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' },
gateSubtasks: 'Decomposed subtasks', noDeps: 'no deps', dependsOn: 'deps',
lblCodeReview: 'Code review', lblSecurity: 'Security', lblChanges: 'Changes', vApprove: 'Approve', vReject: 'Reject', vNone: 'No verdict',
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' },
},
es: {
@@ -118,6 +124,7 @@ window.MAESTRO_I18N = {
projPendingTip: '{n} esperan tu revisión',
gAgents: 'Agents globales', gAgentsDetail: '{p}/{P} proyectos · {a} agents',
apActive: 'activos', apRuns: 'ejecuciones', apTokens: 'tokens', apWeek: 'esta semana', apCost: 'coste', apTotal: 'Todos los proyectos', apPerProj: 'POR PROYECTO', apIdle: 'inactivo', apMore: '+{n} más', apCollapse: 'Contraer',
apDetail: 'Detalles', apDetailTitle: 'Detalle de uso', apByDay: 'Por día', apByWeek: 'Por semana', apByMonth: 'Por mes', apColProject: 'Proyecto', apColTasks: 'Tareas', apColModels: 'Modelos', apEmpty: 'Sin datos de uso',
gConfig: 'Config global', gConfigDetail: 'Concurrencia máx {n} · daemon {v}',
gUserPlan: 'Plan', gUserSignOut: 'Cerrar sesión', gUserSettings: 'Ajustes de cuenta',
gGlobalSettings: 'Configuración global', gSettingsHint: 'Valores por defecto para proyectos nuevos (por usuario)', gMaxRetries: 'Reintentos máx.', gTimeoutMin: 'Tiempo límite (min)', gAutoPlan: 'Auto-aprobar plan', gAutoExec: 'Auto-aprobar ejec.', gBudget: 'Presupuesto $ (vacío=∞)', gBudgetPeriod: 'Periodo', gPeriodDay: 'Diario', gPeriodMonth: 'Mensual',
@@ -142,6 +149,8 @@ window.MAESTRO_I18N = {
gatePassed: ' aprobada',
status: { init: 'Nueva', analyzing: 'Analizando', plan_review: 'Revisión de plan', decomposed: 'Descompuesta', speccing: 'Redactando spec', spec_review: 'Revisión de spec', ready: 'Lista', blocked: 'Bloqueada', queued: 'En cola', executing: 'Ejecutando', exec_review: 'Revisar y fusionar', failed: 'Fallida', needs_attention: 'Requiere atención', done: 'Hecha', paused: 'Pausada', cancelled: 'Cancelada' },
gates: { plan: 'REVISIÓN DE PLAN', spec: 'REVISIÓN DE SPEC', exec: 'REVISIÓN DE RESULTADO' },
gateSubtasks: 'Subtareas', noDeps: 'sin deps', dependsOn: 'deps',
lblCodeReview: 'Revisión', lblSecurity: 'Seguridad', lblChanges: 'Cambios', vApprove: 'Aprobar', vReject: 'Rechazar', vNone: 'Sin veredicto',
eventTypes: { 'task.created': 'Tarea creada', 'task.updated': 'Tarea actualizada', 'status.changed': 'Cambio de estado', 'approval.requested': 'Aprobación solicitada', 'approval.granted': 'Aprobación concedida', 'approval.rejected': 'Aprobación rechazada', 'run.started': 'Run iniciado', 'run.finished': 'Run terminado', 'project.synced': 'Todo sincronizado' },
},
ja: {
@@ -165,6 +174,7 @@ window.MAESTRO_I18N = {
projPendingTip: '{n} 件があなたの承認待ち',
gAgents: 'グローバル Agent', gAgentsDetail: '{p}/{P} プロジェクト · {a} 個の agent',
apActive: '実行中', apRuns: '回実行', apTokens: 'token', apWeek: '今週', apCost: 'コスト', apTotal: '全プロジェクト', apPerProj: 'プロジェクト別', apIdle: 'アイドル', apMore: '他 {n} 件', apCollapse: '折りたたむ',
apDetail: '詳細', apDetailTitle: '使用量の詳細', apByDay: '日別', apByWeek: '週別', apByMonth: '月別', apColProject: 'プロジェクト', apColTasks: 'タスク数', apColModels: 'モデル', apEmpty: '使用量データなし',
gConfig: 'グローバル設定', gConfigDetail: '並列上限 {n} · daemon {v}',
gUserPlan: 'プラン', gUserSignOut: 'ログアウト', gUserSettings: 'アカウント設定',
gGlobalSettings: 'グローバル設定', gSettingsHint: '新規プロジェクトの既定値(ユーザー単位で保存)', gMaxRetries: '最大リトライ', gTimeoutMin: 'タイムアウト(分)', gAutoPlan: '分解を自動承認', gAutoExec: '実行を自動承認', gBudget: '予算 $(空=無制限)', gBudgetPeriod: '予算期間', gPeriodDay: '日次', gPeriodMonth: '月次',
@@ -189,6 +199,8 @@ window.MAESTRO_I18N = {
gatePassed: ' 承認',
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: '結果レビュー' },
gateSubtasks: '分解されたサブタスク', noDeps: '依存なし', dependsOn: '依存',
lblCodeReview: 'コードレビュー', lblSecurity: 'セキュリティ', lblChanges: '変更', vApprove: '承認', vReject: '却下', vNone: '判定なし',
eventTypes: { 'task.created': 'タスク作成', 'task.updated': 'タスク更新', 'status.changed': 'ステータス変更', 'approval.requested': '承認リクエスト', 'approval.granted': '承認', 'approval.rejected': '却下', 'run.started': 'run 開始', 'run.finished': 'run 終了', 'project.synced': 'todo 同期' },
},
fr: {
@@ -212,6 +224,7 @@ window.MAESTRO_I18N = {
projPendingTip: '{n} en attente de votre revue',
gAgents: 'Agents globaux', gAgentsDetail: '{p}/{P} projets · {a} agents',
apActive: 'actifs', apRuns: 'exécutions', apTokens: 'tokens', apWeek: 'cette semaine', apCost: 'coût', apTotal: 'Tous les projets', apPerProj: 'PAR PROJET', apIdle: 'inactif', apMore: '+{n} de plus', apCollapse: 'Réduire',
apDetail: 'Détails', apDetailTitle: "Détail d'usage", apByDay: 'Par jour', apByWeek: 'Par semaine', apByMonth: 'Par mois', apColProject: 'Projet', apColTasks: 'Tâches', apColModels: 'Modèles', apEmpty: "Aucune donnée d'usage",
gConfig: 'Config globale', gConfigDetail: 'Concurrence max {n} · daemon {v}',
gUserPlan: 'Forfait', gUserSignOut: 'Se déconnecter', gUserSettings: 'Paramètres du compte',
gGlobalSettings: 'Paramètres globaux', gSettingsHint: 'Valeurs par défaut des nouveaux projets (par utilisateur)', gMaxRetries: 'Essais max', gTimeoutMin: 'Délai (min)', gAutoPlan: 'Auto-approuver plan', gAutoExec: 'Auto-approuver exéc.', gBudget: 'Budget $ (vide=∞)', gBudgetPeriod: 'Période', gPeriodDay: 'Quotidien', gPeriodMonth: 'Mensuel',
@@ -236,6 +249,8 @@ window.MAESTRO_I18N = {
gatePassed: ' approuvée',
status: { init: 'Nouvelle', analyzing: 'Analyse', plan_review: 'Revue du plan', decomposed: 'Décomposée', speccing: 'Rédaction spec', spec_review: 'Revue de spec', ready: 'Prête', blocked: 'Bloquée', queued: 'En file', executing: 'En exécution', exec_review: 'Revue & fusion', failed: 'Échouée', needs_attention: 'À traiter', done: 'Terminée', paused: 'En pause', cancelled: 'Annulée' },
gates: { plan: 'REVUE DU PLAN', spec: 'REVUE DE SPEC', exec: 'REVUE DU RÉSULTAT' },
gateSubtasks: 'Sous-tâches', noDeps: 'sans deps', dependsOn: 'deps',
lblCodeReview: 'Revue', lblSecurity: 'Sécurité', lblChanges: 'Modifs', vApprove: 'Approuver', vReject: 'Rejeter', vNone: 'Sans verdict',
eventTypes: { 'task.created': 'Tâche créée', 'task.updated': 'Tâche mise à jour', 'status.changed': "Changement d'état", 'approval.requested': 'Approbation demandée', 'approval.granted': 'Approbation accordée', 'approval.rejected': 'Approbation rejetée', 'run.started': 'Run démarré', 'run.finished': 'Run terminé', 'project.synced': 'Todo synchronisé' },
},
};