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:
+16
-13
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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é' },
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user