feat(console): 任务树筛选/折叠/统一标题 + 侧栏全局Agent卡片/拖拽排序/全局设置弹框/记忆项目 + 状态点真实化

任务树:四维多选下拉筛选(状态/复杂度/优先级/执行者)、已选置顶、新建任务按钮移右上角(0任务也能建)。
四块主区(Agent/审批闸/任务树/归档)统一标题组件(图标+名称+数量徽标)+ 折叠按钮(localStorage 记忆)。
侧栏:全局 Agent 弹层改常驻卡片(项目默认3+折叠+按活跃排序)、顶部项目拖拽排序、全局设置移入用户菜单弹框(保存/取消,落库)、记忆当前项目。
adaptProject 用后端 summary 的 running/attention 判状态点颜色(青/琥珀/灰)+ 紫色关注数徽标;agent 标题不再越界;i18n 5 语言补全。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-29 11:30:32 +08:00
parent 43fa1f7dbb
commit 8765e61498
9 changed files with 430 additions and 194 deletions
+4 -4
View File
@@ -19,13 +19,13 @@ export function adaptProject(p) {
const s = p.summary || {};
let state = 'idle';
if (p.status === 'paused') state = 'paused';
else if (s.executing > 0) state = 'running';
else if (s.blocked > 0) state = 'blocked';
else if (s.alive > 0) state = 'running';
else if (s.running > 0) state = 'running'; // agent 在跑(executor/planner)→ 青点
else if (s.blocked > 0) state = 'blocked'; // 被依赖阻塞 → 琥珀点
// 其余 idle(灰点);待审批/需人工数走下面 pending 紫徽标
return {
id: p.id, name: p.name, path: shortPath(p.repoPath), branch: p.defaultBranch,
autonomy: p.autonomy, concurrency: p.concurrency, hue: hueFor(p.name),
state, pending: s.pending || 0, agents: s.executing || 0,
state, pending: s.attention || 0, agents: s.executing || 0,
// 透传给 ConfigPanel 用的原始字段
model: p.model, verifyCmd: p.verifyCmd, maxRetries: p.maxRetries, repoPath: p.repoPath,
logo: p.logo, budgetUsd: p.budgetUsd ?? null, budgetPeriod: p.budgetPeriod ?? 'month',
+3
View File
@@ -30,7 +30,10 @@ export const api = {
http('POST', `/api/tasks/${taskId}/decide`, { action, reason, merge }),
createTask: (pid, body) => http('POST', `/api/projects/${pid}/tasks`, body),
patchProject: (pid, body) => http('PATCH', `/api/projects/${pid}`, body),
reorderProjects: (order) => http('POST', '/api/projects/reorder', { order }),
createProject: (body) => http('POST', '/api/projects', body),
getSettings: () => http('GET', '/api/settings'),
putSettings: (data) => http('PUT', '/api/settings', data),
takeover: (taskId) => http('POST', `/api/tasks/${taskId}/takeover`),
patchTask: (taskId, body) => http('PATCH', `/api/tasks/${taskId}`, body),
cancelTask: (taskId) => http('POST', `/api/tasks/${taskId}/cancel`),
+33 -6
View File
@@ -12,7 +12,7 @@ const { Toast } = window.MaestroDesignSystem_a6a290;
const USER = { name: 'local', handle: '@local', plan: 'Claude Code', initial: 'L', hue: 200 };
// 新建项目模态(design 无现成 surface,用 DS 组件就地拼装)。标签 zh/en 自含。
function NewProjectModal({ t, lang, onCreate, onClose }) {
function NewProjectModal({ t, lang, onCreate, onClose, defaultAutonomy }) {
const { Button, Input, Select } = window.MaestroDesignSystem_a6a290;
const L = lang === 'zh'
? { title: '新建项目', name: '项目名', repo: '仓库路径', branch: '默认分支', mode: '工作模式', namePh: 'my-app', repoPh: '/Users/you/code/my-app', create: '创建' }
@@ -20,7 +20,7 @@ function NewProjectModal({ t, lang, onCreate, onClose }) {
const [name, setName] = React.useState('');
const [repoPath, setRepoPath] = React.useState('');
const [defaultBranch, setDefaultBranch] = React.useState('main');
const [autonomy, setAutonomy] = React.useState('manual');
const [autonomy, setAutonomy] = React.useState(defaultAutonomy || 'manual');
const submit = (e) => {
e.preventDefault();
if (!name.trim() || !repoPath.trim()) return;
@@ -133,6 +133,7 @@ export function App() {
return I18N[saved] ? saved : 'zh';
});
const t = I18N[lang];
const { Button } = window.MaestroDesignSystem_a6a290;
// ── 真实数据 state ──────────────────────────────────
const [projectsRaw, setProjectsRaw] = React.useState([]);
@@ -141,11 +142,14 @@ export function App() {
const [eventsRaw, setEventsRaw] = React.useState([]);
const [agentsResp, setAgentsResp] = React.useState({ totalActive: 0, agents: [] });
const [usage, setUsage] = React.useState(null);
const [currentId, setCurrentId] = React.useState(null);
const [settings, setSettings] = React.useState(null);
const [currentId, setCurrentId] = React.useState(() => localStorage.getItem('maestro-kit-project') || null);
React.useEffect(() => { if (currentId) localStorage.setItem('maestro-kit-project', currentId); }, [currentId]);
const [wsState, setWsState] = React.useState('closed');
// ── UI state(沿用原型)─────────────────────────────
const [showConfig, setShowConfig] = React.useState(false);
const [showNew, setShowNew] = React.useState(false);
const [archiveItem, setArchiveItem] = React.useState(null);
const [streamAgent, setStreamAgent] = React.useState(null);
const [takeoverInfo, setTakeoverInfo] = React.useState(null);
@@ -210,7 +214,9 @@ export function App() {
try {
const [pj, ag, us, ap] = await Promise.all([api.listProjects(), api.agents(), api.usage(), api.approvals()]);
setProjectsRaw(pj); setAgentsResp(ag); setUsage(us); setApprovalsRaw(ap);
setCurrentId((cur) => cur || (pj[0] && pj[0].id) || null);
// settings 端点可能未部署到旧 daemon:独立拉取,失败则降级为默认(不阻断主加载)
api.getSettings().then(setSettings).catch(() => setSettings(null));
setCurrentId((cur) => (cur && pj.some((p) => p.id === cur) ? cur : (pj[0] && pj[0].id) || null));
} catch (e) { toast('warn', '加载失败:' + e.message); }
}, []);
@@ -223,6 +229,18 @@ export function App() {
} catch (e) { toast('warn', '加载任务失败:' + e.message); }
}, []);
// 左侧栏项目拖拽排序:乐观重排 + 落库(reorder 写 sortOrder
const reorderProjects = async (order) => {
setProjectsRaw((prev) => order.map((id) => prev.find((p) => p.id === id)).filter(Boolean));
try { await api.reorderProjects(order); } catch (e) { toast('warn', '排序保存失败:' + e.message); loadGlobal(); }
};
// 用户级全局默认配置:弹框保存 → 落库
const saveSettings = async (data) => {
try { const saved = await api.putSettings(data); setSettings(saved); toast('ok', t.toastSaved); }
catch (e) { toast('warn', '保存失败:' + e.message); }
};
React.useEffect(() => { loadGlobal(); }, [loadGlobal]);
React.useEffect(() => { loadProject(currentId); }, [currentId, loadProject]);
@@ -346,7 +364,8 @@ export function App() {
<window.MaestroKitSidebar projects={projects} currentId={currentId} t={t}
global={global} user={USER} summary={agentSummary} onGlobalConfig={() => toast('warn', t.toastModal)}
collapsed={collapsed} onToggleCollapse={toggleCollapse}
onSelect={setCurrentId} onNewProject={() => setShowNewProject(true)} />
onSelect={setCurrentId} onNewProject={() => setShowNewProject(true)} onReorder={reorderProjects}
settings={settings} onSaveSettings={saveSettings} />
<main style={{ overflowY: 'auto', padding: '0 22px 60px' }}>
{project ? <window.MaestroKitTopbar project={project} t={t} theme={theme}
onToggleTheme={() => setTheme(theme === 'light' ? 'dark' : 'light')}
@@ -354,6 +373,14 @@ export function App() {
counts={counts}
onSync={onSync}
onToggleConfig={() => setShowConfig(!showConfig)} /> : null}
{project ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 14 }}>
<Button variant="solid" onClick={() => setShowNew((s) => !s)}>{t.newTask}</Button>
</div>
) : null}
{showNew && project ? <window.MaestroKitNewTaskPanel tasks={tasks} t={t}
onCancel={() => setShowNew(false)}
onCreate={(payload) => { setShowNew(false); createTask(payload); }} /> : null}
{showConfig && project ? <window.MaestroKitConfigPanel project={project} t={t} lang={lang}
onSave={saveConfig}
onSync={onSync}
@@ -372,7 +399,7 @@ export function App() {
</main>
<window.MaestroKitEventPanel events={events} collapsed={evCollapsed} onToggleCollapse={toggleEvents} t={t} />
{archiveItem ? <window.MaestroKitArchiveModal item={archiveItem} t={t} onClose={() => setArchiveItem(null)} /> : null}
{showNewProject ? <NewProjectModal t={t} lang={lang} onCreate={createProject} onClose={() => setShowNewProject(false)} /> : null}
{showNewProject ? <NewProjectModal t={t} lang={lang} defaultAutonomy={settings?.autonomy} onCreate={createProject} onClose={() => setShowNewProject(false)} /> : null}
{streamAgent ? <StreamModal agent={streamAgent} onClose={() => setStreamAgent(null)} /> : null}
{takeoverInfo ? <TakeoverModal info={takeoverInfo} onClose={() => setTakeoverInfo(null)} /> : null}
<div style={{ position: 'fixed', bottom: 18, left: '50%', transform: 'translateX(-50%)', zIndex: 1200, display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
+10 -6
View File
@@ -95,17 +95,21 @@ function ArchivePager({ page, pages, size, setPage, setSize, t }) {
function ArchiveSection({ items, t, onOpen }) {
const [page, setPage] = React.useState(1);
const [size, setSize] = React.useState(20);
const [folded, setFolded] = React.useState(() => localStorage.getItem('maestro-kit-fold-archive') === '1');
const toggleFold = () => setFolded((f) => { localStorage.setItem('maestro-kit-fold-archive', f ? '0' : '1'); return !f; });
if (!items.length) return null;
const pages = Math.max(1, Math.ceil(items.length / size));
const cur = Math.min(page, pages);
const slice = items.slice((cur - 1) * size, cur * size);
return (
<section style={{ marginTop: 28, opacity: .82 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontFamily: 'var(--mono)', fontSize: 11, fontWeight: 600, letterSpacing: '.22em', color: 'var(--muted)', textTransform: 'uppercase', padding: '18px 0 10px' }}>
<span style={{ color: 'var(--faint)' }}></span>{t.archive} · {items.length}
</div>
<div>{slice.map((it) => <ArchiveRow key={it.id} item={it} t={t} onOpen={onOpen} />)}</div>
<ArchivePager page={cur} pages={pages} size={size} setPage={setPage} setSize={(n) => { setSize(n); setPage(1); }} t={t} />
<section style={{ opacity: .82 }}>
<window.MaestroKitSectionHead icon="archive" title={t.archive} count={items.length} accent="var(--faint)" folded={folded} onToggleFold={toggleFold} t={t} />
{!folded ? (
<React.Fragment>
<div>{slice.map((it) => <ArchiveRow key={it.id} item={it} t={t} onOpen={onOpen} />)}</div>
<ArchivePager page={cur} pages={pages} size={size} setPage={setPage} setSize={(n) => { setSize(n); setPage(1); }} t={t} />
</React.Fragment>
) : null}
</section>
);
}
+8 -6
View File
@@ -47,20 +47,22 @@ function GatePreview({ item, t, onDecide, onClose }) {
// 审批闸区:斜纹条 + GateCard 列表(accept/reject + 驳回必填理由)
function GateSection({ approvals, onDecide, t }) {
const { GateCard, GateStripe, Button, Textarea, SectionHead } = window.MaestroDesignSystem_a6a290;
const { GateCard, GateStripe, Button, Textarea } = window.MaestroDesignSystem_a6a290;
const [rejecting, setRejecting] = React.useState(null);
const [reason, setReason] = React.useState('');
const [preview, setPreview] = React.useState(null);
const [sectionFolded, setSectionFolded] = React.useState(() => localStorage.getItem('maestro-kit-fold-gate') === '1');
const toggleSectionFold = () => setSectionFolded((f) => { localStorage.setItem('maestro-kit-fold-gate', f ? '0' : '1'); return !f; });
const [folded, setFolded] = React.useState(() => new Set());
const toggleFold = (id) => setFolded((prev) => {
const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next;
});
if (approvals.length === 0) return null;
return (
<section style={{ marginTop: 18 }}>
<GateStripe />
<SectionHead mark="violet" title={t.gateSection} style={{ paddingTop: 12, color: 'var(--violet)' }} />
{approvals.map((a) => {
<section>
<window.MaestroKitSectionHead icon="gate" title={t.gateSection} count={approvals.length} accent="var(--violet)" folded={sectionFolded} onToggleFold={toggleSectionFold} t={t} />
{!sectionFolded ? <GateStripe /> : null}
{!sectionFolded ? approvals.map((a) => {
const isFolded = folded.has(a.id);
return (
<div key={a.id} style={{ marginBottom: 12 }}>
@@ -93,7 +95,7 @@ function GateSection({ approvals, onDecide, t }) {
</GateCard>
</div>
);
})}
}) : null}
<GatePreview item={preview} t={t} onDecide={onDecide} onClose={() => setPreview(null)} />
</section>
);
+170 -84
View File
@@ -87,8 +87,84 @@ const sIco = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke
function IcoAgents() { return <svg {...sIco}><circle cx="12" cy="12" r="8" /><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none" /></svg>; }
function IcoGlobalCfg() { return <svg {...sIco}><circle cx="12" cy="12" r="9" /><line x1="3" y1="12" x2="21" y2="12" /><path d="M12 3a14 14 0 0 0 0 18a14 14 0 0 0 0-18" /></svg>; }
function UserRow({ user, t, collapsed }) {
// 全局设置弹框:用户级默认配置(新建项目套用)。保存 / 取消,落库经 onSave。
function SettingsModal({ settings, onSave, onClose, t }) {
const { Button, Input, Select } = window.MaestroDesignSystem_a6a290;
const s = settings || {};
const [autonomy, setAutonomy] = React.useState(s.autonomy || 'manual');
const [concurrency, setConcurrency] = React.useState(String(s.concurrency ?? 1));
const [maxRetries, setMaxRetries] = React.useState(String(s.maxRetries ?? 2));
const [timeoutMin, setTimeoutMin] = React.useState(String(Math.round((s.timeoutMs ?? 1800000) / 60000)));
const [autoPlan, setAutoPlan] = React.useState(!!s.autoApprovePlan);
const [autoExec, setAutoExec] = React.useState(!!s.autoApproveExec);
const [budgetUsd, setBudgetUsd] = React.useState(s.budgetUsd != null ? String(s.budgetUsd) : '');
const [budgetPeriod, setBudgetPeriod] = React.useState(s.budgetPeriod || 'month');
const [model, setModel] = React.useState(s.model || '');
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const submit = () => {
onSave({
autonomy, concurrency: Number(concurrency) || 1, maxRetries: Number(maxRetries) || 0,
timeoutMs: Math.max(1, Number(timeoutMin) || 30) * 60000,
autoApprovePlan: autoPlan, autoApproveExec: autoExec,
budgetUsd: budgetUsd.trim() === '' ? null : Number(budgetUsd),
budgetPeriod, model: model.trim() === '' ? null : model.trim(),
});
onClose();
};
const Toggle = ({ on, set }) => (
<button type="button" onClick={() => set(!on)} style={{
alignSelf: 'flex-start', fontFamily: 'var(--mono)', fontSize: 11, fontWeight: 700, letterSpacing: '.08em',
background: on ? 'rgba(95,221,125,.1)' : 'var(--bg-deep)', color: on ? 'var(--green)' : 'var(--faint)',
border: '1px solid ' + (on ? 'var(--green-dim)' : 'var(--line)'), borderRadius: 'var(--radius-sm,4px)',
padding: '6px 14px', cursor: 'pointer', transition: 'all .1s',
}}>{on ? 'ON' : 'OFF'}</button>
);
const field = (label, node) => (
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--muted)', letterSpacing: '.05em' }}>
<span>{label}</span>{node}
</label>
);
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: 460, maxWidth: '92vw', maxHeight: '88vh', 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: 8, fontSize: 12, fontWeight: 700, letterSpacing: '.16em', color: 'var(--muted)', textTransform: 'uppercase', marginBottom: 3 }}>
<span style={{ color: 'var(--green)' }}></span>{t.gGlobalSettings}
</div>
<div style={{ fontSize: 11, color: 'var(--faint)', marginBottom: 15 }}>{t.gSettingsHint}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 13 }}>
{field(t.workMode, <Select value={autonomy} onChange={setAutonomy} options={Object.entries(t.autonomy).map(([value, label]) => ({ value, label }))} />)}
{field(t.maxConcurrency, <Input type="number" value={concurrency} onChange={setConcurrency} style={{ width: '100%' }} />)}
{field(t.gMaxRetries, <Input type="number" value={maxRetries} onChange={setMaxRetries} style={{ width: '100%' }} />)}
{field(t.gTimeoutMin, <Input type="number" value={timeoutMin} onChange={setTimeoutMin} style={{ width: '100%' }} />)}
{field(t.gAutoPlan, <Toggle on={autoPlan} set={setAutoPlan} />)}
{field(t.gAutoExec, <Toggle on={autoExec} set={setAutoExec} />)}
{field(t.gBudget, <Input type="number" placeholder="∞" value={budgetUsd} onChange={setBudgetUsd} style={{ width: '100%' }} />)}
{field(t.gBudgetPeriod, <Select value={budgetPeriod} onChange={setBudgetPeriod} options={[{ value: 'day', label: t.gPeriodDay }, { value: 'month', label: t.gPeriodMonth }]} />)}
</div>
<div style={{ marginTop: 13 }}>
{field(t.model, <Input placeholder={t.modelPh} value={model} onChange={setModel} style={{ width: '100%' }} />)}
</div>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 18 }}>
<Button onClick={onClose}>{t.cancel}</Button>
<Button variant="solid" onClick={submit}>{t.save}</Button>
</div>
</div>
</div>
);
}
function UserRow({ user, t, collapsed, settings, onSaveSettings }) {
const [open, setOpen] = React.useState(false);
const [showSettings, setShowSettings] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
if (!open) return;
@@ -133,13 +209,18 @@ function UserRow({ user, t, collapsed }) {
<div style={{ fontSize: 12, fontWeight: 600 }}>{user.name} <span style={{ color: 'var(--faint)', fontWeight: 400 }}>{user.handle}</span></div>
<div style={{ fontSize: 10.5, color: 'var(--green)', marginTop: 2 }}>{t.gUserPlan} · {user.plan}</div>
</div>
{[t.gUserSettings, t.gUserSignOut].map((label, i) => (
<div key={label} style={{ padding: '6px 10px', fontSize: 12, color: i === 1 ? 'var(--red)' : 'var(--ink)', cursor: 'pointer', borderRadius: 3 }}
{[
{ label: t.gGlobalSettings, onClick: () => { setShowSettings(true); setOpen(false); }, red: false },
{ label: t.gUserSettings, onClick: null, red: false },
{ label: t.gUserSignOut, onClick: null, red: true },
].map((it) => (
<div key={it.label} onClick={it.onClick || undefined} style={{ padding: '6px 10px', fontSize: 12, color: it.red ? 'var(--red)' : 'var(--ink)', cursor: 'pointer', borderRadius: 3 }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--panel-2)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>{label}</div>
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>{it.label}</div>
))}
</div>
) : null}
{showSettings ? <SettingsModal settings={settings} onSave={onSaveSettings} onClose={() => setShowSettings(false)} t={t} /> : null}
</div>
);
}
@@ -150,83 +231,70 @@ function fmtTokens(n) {
return String(n);
}
function AgentsRow({ global, summary, t, collapsed }) {
const [open, setOpen] = React.useState(false);
const [pos, setPos] = React.useState(null);
const ref = React.useRef(null);
const rowRef = React.useRef(null);
React.useEffect(() => {
if (!open) return;
const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target) && rowRef.current && !rowRef.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
const toggle = () => {
if (!open && rowRef.current) {
const r = rowRef.current.getBoundingClientRect();
const W = 286;
const left = collapsed ? r.right + 6 : Math.min(r.left, window.innerWidth - W - 8);
setPos({ left, bottom: window.innerHeight - r.top + 6, width: W });
}
setOpen((o) => !o);
};
const maxTok = Math.max(...summary.byProject.map((p) => p.tokens), 1);
const detail = t.gAgentsDetail.replace('{p}', global.runningProjects).replace('{P}', global.projectCount).replace('{a}', global.runningAgents);
// 全局 AGENT 卡片(内联常驻;按项目默认 3 个、多余折叠、按最近活跃排序)
function AgentCard({ global, summary, t, collapsed }) {
const [showAll, setShowAll] = 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={{ position: 'relative' }}>
<div ref={rowRef}>
<SideRow collapsed={collapsed} icon={<IcoAgents />} accent="var(--cyan)" onClick={toggle}
label={t.gAgents} detail={detail} title={t.gAgents + ' · ' + detail} />
<div style={{
margin: '10px 12px 4px', border: '1px solid var(--line)', borderRadius: 'var(--radius-md,6px)',
background: 'var(--bg-deep)', padding: '11px 12px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 10.5, fontWeight: 700, letterSpacing: '.18em', color: 'var(--muted)', marginBottom: 10 }}>
<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>
{open && pos ? (
<div ref={ref} style={{
position: 'fixed', left: pos.left, bottom: pos.bottom, width: pos.width, zIndex: 1000,
background: 'var(--bg-deep)', border: '1px solid var(--line)', borderRadius: 'var(--radius-md,6px)',
boxShadow: '0 12px 34px rgba(0,0,0,.7)', padding: '12px 14px', animation: 'maestro-rise .12s ease both',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 10.5, fontWeight: 700, letterSpacing: '.18em', color: 'var(--muted)', marginBottom: 10 }}>
<span style={{ color: 'var(--cyan)' }}></span>{t.gAgents.toUpperCase()}
<span style={{ marginLeft: 'auto', fontWeight: 400, letterSpacing: '.04em', color: 'var(--faint)' }}>{t.apWeek}</span>
{/* 总结 */}
<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 }}>
{[
[fmtTokens(summary.tokensWeek), t.apTokens, 'var(--cyan)'],
['$' + summary.costWeek.toFixed(1), t.apCost, 'var(--green)'],
[summary.runsWeek, t.apRuns, 'var(--ink)'],
[summary.activeNow + ' / ' + global.maxAgents, t.apActive, summary.activeNow ? 'var(--cyan)' : 'var(--faint)'],
].map(([v, k, c], i) => (
<div key={i} style={{ background: 'var(--bg-deep)', padding: '7px 9px' }}>
<div style={{ fontSize: 15, fontWeight: 700, color: c, letterSpacing: '.02em' }}>{v}</div>
<div style={{ fontSize: 9.5, color: 'var(--muted)', letterSpacing: '.06em', marginTop: 1 }}>{k}</div>
</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: 12 }}>
{[
[fmtTokens(summary.tokensWeek), t.apTokens, 'var(--cyan)'],
['$' + summary.costWeek.toFixed(1), t.apCost, 'var(--green)'],
[summary.runsWeek, t.apRuns, 'var(--ink)'],
[summary.activeNow + ' / ' + global.maxAgents, t.apActive, summary.activeNow ? 'var(--cyan)' : 'var(--faint)'],
].map(([v, k, c], i) => (
<div key={i} style={{ background: 'var(--bg-deep)', padding: '8px 10px' }}>
<div style={{ fontSize: 16, fontWeight: 700, color: c, letterSpacing: '.02em' }}>{v}</div>
<div style={{ fontSize: 10, color: 'var(--muted)', letterSpacing: '.06em', marginTop: 1 }}>{k}</div>
</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>
</div>
{/* 按项目 */}
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '.18em', color: 'var(--faint)', marginBottom: 7 }}>{t.apPerProj}</div>
<div style={{ display: 'grid', gap: 9 }}>
{summary.byProject.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>
</div>
))}
</div>
</div>
))}
</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>
);
@@ -299,8 +367,19 @@ function GlobalConfigRow({ global, t, collapsed }) {
);
}
function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onToggleCollapse, t, global, user, summary, onGlobalConfig }) {
function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onToggleCollapse, t, global, user, summary, onGlobalConfig, onReorder, settings, onSaveSettings }) {
const { Button } = window.MaestroDesignSystem_a6a290;
const [dragId, setDragId] = React.useState(null);
const [overId, setOverId] = React.useState(null);
const onDrop = (targetId) => {
if (dragId && dragId !== targetId && onReorder) {
const ids = projects.map((x) => x.id);
const from = ids.indexOf(dragId);
const to = ids.indexOf(targetId);
if (from > -1 && to > -1) { ids.splice(to, 0, ids.splice(from, 1)[0]); onReorder(ids); }
}
setDragId(null); setOverId(null);
};
return (
<aside style={{ background: 'var(--bg-deep)', borderRight: '1px solid var(--line)', display: 'flex', flexDirection: 'column', overflowY: 'auto', overflowX: 'hidden' }}>
<div style={{ padding: collapsed ? '16px 0 12px' : '20px 16px 14px', borderBottom: '1px solid var(--line-soft)', display: 'flex', flexDirection: 'column', alignItems: collapsed ? 'center' : 'flex-start' }}>
@@ -328,16 +407,24 @@ function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onTog
const meta = projStateMeta(p.state, t);
return (
<li key={p.id} onClick={() => onSelect(p.id)} title={p.name + ' · ' + meta.label + (p.pending ? ' · ' + t.projPendingTip.replace('{n}', p.pending) : '')}
draggable={!collapsed}
onDragStart={(e) => { setDragId(p.id); e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => { if (dragId) { e.preventDefault(); if (p.id !== dragId && overId !== p.id) setOverId(p.id); } }}
onDragLeave={() => setOverId((o) => (o === p.id ? null : o))}
onDrop={(e) => { e.preventDefault(); onDrop(p.id); }}
onDragEnd={() => { setDragId(null); setOverId(null); }}
style={{
position: 'relative',
padding: collapsed ? '10px 0' : '9px 14px 9px 12px', cursor: 'pointer',
padding: collapsed ? '10px 0' : '9px 14px 9px 12px', cursor: dragId ? 'grabbing' : 'pointer',
display: 'flex', alignItems: 'center', gap: 9,
justifyContent: collapsed ? 'center' : 'flex-start',
borderLeft: '2px solid ' + (active ? 'var(--green)' : 'transparent'),
background: active ? 'var(--panel-2)' : 'transparent',
color: active ? 'var(--green)' : 'var(--muted)',
opacity: dragId === p.id ? 0.4 : 1,
boxShadow: overId === p.id ? 'inset 0 2px 0 var(--green)' : 'none',
}}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = 'var(--panel)'; }}
onMouseEnter={(e) => { if (!active && !dragId) e.currentTarget.style.background = 'var(--panel)'; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = 'transparent'; }}>
<ProjectLogo project={p} size={collapsed ? 26 : 24} />
{!collapsed ? (
@@ -366,12 +453,11 @@ function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onTog
);
})}
</ul>
{/* ── 底部:全局 agent · 全局配置 · 用户 ── */}
{/* ── 全局 AGENT 卡片(常驻)+ 用户(全局设置已并入用户菜单)── */}
<div style={{ borderTop: '1px solid var(--line-soft)' }}>
<AgentsRow global={global} summary={summary} t={t} collapsed={collapsed} />
<GlobalConfigRow global={global} t={t} collapsed={collapsed} />
<AgentCard global={global} summary={summary} t={t} collapsed={collapsed} />
</div>
<UserRow user={user} t={t} collapsed={collapsed} />
<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' }}>
{!collapsed ? (
<React.Fragment>
+175 -75
View File
@@ -1,12 +1,63 @@
// 任务树:筛选栏(搜索+复杂度+状态分组)+ 折叠层级 + 复杂度可点换档 + 依赖可视化跳转 + 展开详情;新建任务表单
const KIT_FILTER_GROUPS = [
['todo', ['init', 'ready', 'blocked']],
['doing', ['analyzing', 'speccing', 'queued', 'executing', 'decomposed']],
['gate', ['plan_review', 'spec_review', 'exec_review']],
['bad', ['failed', 'needs_attention']],
['hold', ['paused', 'cancelled']],
['done', ['done']],
// 状态全序(下拉选项顺序:待办 → 进行 → 容器 → 审批 → 异常 → 挂起 → 终态)
const KIT_STATUS_ORDER = [
'init', 'ready', 'blocked',
'analyzing', 'speccing', 'queued', 'executing', 'decomposed',
'plan_review', 'spec_review', 'exec_review',
'failed', 'needs_attention',
'paused', 'done', 'cancelled',
];
const KIT_CPLX_OPTS = [{ value: 'hard', label: 'HARD' }, { value: 'medium', label: 'MED' }, { value: 'easy', label: 'EASY' }];
const KIT_PRIO_OPTS = [{ value: 'P0', label: 'P0' }, { value: 'P1', label: 'P1' }, { value: 'P2', label: 'P2' }];
// 主显示区各区块标题通用折叠按钮(▾ 展开 / ▸ 折叠);暴露到 window 供 Agent/审批闸/归档复用
function KitFoldBtn({ folded, onClick, t }) {
return (
<button type="button" onClick={onClick} title={folded ? (t.gateExpand || '展开') : (t.gateCollapse || '折叠')} style={{
background: 'transparent', border: '1px solid var(--line)', color: 'var(--muted)', cursor: 'pointer',
fontSize: 10, fontFamily: 'var(--mono)', lineHeight: 1, padding: '3px 8px', borderRadius: 'var(--radius-sm,4px)',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--green)'; e.currentTarget.style.borderColor = 'var(--green-dim)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.borderColor = 'var(--line)'; }}>
{folded ? '▸' : '▾'}
</button>
);
}
window.MaestroKitFoldBtn = KitFoldBtn;
// 区块图标集(24 网格 / stroke currentColor,继承标题 accent 色)
function KitIcon({ name }) {
const p = { width: 15, height: 15, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', style: { flex: 'none', display: 'block' } };
if (name === 'agent') return <svg {...p}><polyline points="3 12 7 12 10 5 14 19 17 12 21 12" /></svg>;
if (name === 'gate') return <svg {...p}><path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6z" /><path d="M9 12l2 2 4-4" /></svg>;
if (name === 'tasks') return <svg {...p}><path d="M9 6h11M9 12h11M9 18h11" /><path d="M4.5 6h.01M4.5 12h.01M4.5 18h.01" /></svg>;
if (name === 'archive') return <svg {...p}><rect x="3" y="4" width="18" height="4" rx="1" /><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8" /><path d="M10 12h4" /></svg>;
return null;
}
// 统一区块标题:图标 + 名称 + 数量徽标 + 折叠按钮(四块主显示区共用,视觉一致)
function KitSectionHead({ icon, title, count, accent = 'var(--green)', folded, onToggleFold, t, sticky }) {
return (
<div style={{
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)' } : {}),
}}>
<span style={{ color: accent, flex: 'none', display: 'inline-flex', alignItems: 'center' }}><KitIcon name={icon} /></span>
<span style={{ whiteSpace: 'nowrap' }}>{title}</span>
{count != null ? (
<span style={{
flex: 'none', fontFamily: 'var(--mono)', fontSize: 10, fontWeight: 700, letterSpacing: '.02em',
color: accent, background: 'var(--panel-2)', borderRadius: 10, padding: '1px 7px', lineHeight: '15px',
minWidth: 10, textAlign: 'center',
}}>{count}</span>
) : null}
<span style={{ marginLeft: 'auto', flex: 'none' }}><KitFoldBtn folded={folded} onClick={onToggleFold} t={t} /></span>
</div>
);
}
window.MaestroKitSectionHead = KitSectionHead;
function kitFlatten(tasks, map = new Map()) {
for (const tk of tasks) { map.set(tk.id, tk); if (tk.children) kitFlatten(tk.children, map); }
@@ -53,49 +104,94 @@ function CplxPicker({ task, cplx, onChange }) {
);
}
function FilterBar({ t, kw, setKw, cplxSet, toggleCplx, statusSet, toggleGroup, matchCount, filtering, onClear }) {
const { Button } = window.MaestroDesignSystem_a6a290;
const chip = (on, color, dim, label, onClick) => (
<button key={label} onClick={onClick} style={{
fontFamily: 'var(--mono)', fontSize: 10, fontWeight: 700, letterSpacing: '.1em',
background: on ? 'rgba(95,221,125,.08)' : 'transparent',
color: on ? color : 'var(--faint)',
border: '1px solid ' + (on ? dim : 'var(--line)'),
borderRadius: 3, padding: '2px 8px', cursor: 'pointer', transition: 'all .1s', whiteSpace: 'nowrap',
}}>{label}</button>
);
// 多选下拉:触发器显示「标签 ·N」,菜单内勾选切换(不随单击关闭);点外 / Esc 关闭
function KitMultiSelect({ label, options, selected, onToggle, onClear, accent = 'var(--green)' }) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
if (!open) return;
const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };
}, [open]);
const n = selected.size;
// 已选项统一排到菜单最前(打开时按「选中优先」重排,保持各自原相对顺序)
const ordered = open
? [...options.filter((o) => selected.has(o.value)), ...options.filter((o) => !selected.has(o.value))]
: options;
return (
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 6, padding: '8px 10px', marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 7 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<input value={kw} onChange={(e) => setKw(e.target.value)} placeholder={t.searchPh} autoComplete="off" style={{
fontFamily: 'var(--mono)', fontSize: 12, width: 190, padding: '4px 9px',
background: 'var(--bg-deep)', color: 'var(--ink)', border: '1px solid var(--line)',
borderRadius: 'var(--radius-sm, 4px)', outline: 'none',
}} />
<span style={{ display: 'inline-flex', gap: 6 }}>
{chip(cplxSet.has('hard'), 'var(--red)', 'var(--red-dim)', 'HARD', () => toggleCplx('hard'))}
{chip(cplxSet.has('medium'), 'var(--amber)', 'var(--amber-dim)', 'MED', () => toggleCplx('medium'))}
{chip(cplxSet.has('easy'), 'var(--green)', 'var(--green-dim)', 'EASY', () => toggleCplx('easy'))}
<span ref={ref} style={{ position: 'relative', display: 'inline-flex', flex: 'none' }}>
<button type="button" onClick={() => setOpen(!open)} style={{
fontFamily: 'var(--mono)', fontSize: 11.5, letterSpacing: '.03em',
background: n ? 'rgba(95,221,125,.08)' : 'var(--bg-deep)',
color: n ? accent : 'var(--muted)',
border: '1px solid ' + (n ? 'var(--green-dim)' : 'var(--line)'),
borderRadius: 'var(--radius-sm, 4px)', padding: '5px 9px', cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap', transition: 'all .1s',
}}>
<span>{label}</span>
{n ? <span style={{
fontSize: 9.5, fontWeight: 700, background: accent, color: 'var(--bg-deep)',
borderRadius: 8, padding: '0 5px', lineHeight: '14px', minWidth: 8, textAlign: 'center',
}}>{n}</span> : null}
<span style={{ fontSize: 8, color: 'var(--faint)', transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .12s' }}></span>
</button>
{open ? (
<span style={{
position: 'absolute', top: 'calc(100% + 5px)', left: 0, zIndex: 80, minWidth: 152,
display: 'grid', gap: 1, background: 'var(--bg-deep)', border: '1px solid var(--line)',
borderRadius: 'var(--radius-md, 6px)', padding: 4, boxShadow: '0 10px 30px rgba(0,0,0,.65)',
animation: 'maestro-rise .12s ease both', maxHeight: 320, overflowY: 'auto',
}}>
{n ? (
<button type="button" onClick={onClear} style={{
fontFamily: 'var(--mono)', fontSize: 11, textAlign: 'left', background: 'transparent',
color: 'var(--faint)', border: 'none', borderRadius: 4, padding: '5px 8px', cursor: 'pointer',
marginBottom: 2, borderBottom: '1px solid var(--line)',
}}> 清空</button>
) : null}
{ordered.map((o) => {
const on = selected.has(o.value);
return (
<button type="button" key={o.value} onClick={() => onToggle(o.value)} style={{
fontFamily: 'var(--mono)', fontSize: 12, textAlign: 'left',
background: on ? 'var(--panel-2)' : 'transparent',
color: on ? accent : 'var(--ink)', fontWeight: on ? 700 : 400,
border: 'none', borderRadius: 4, padding: '6px 9px 6px 7px', cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 7, whiteSpace: 'nowrap',
}}
onMouseEnter={(e) => { if (!on) e.currentTarget.style.background = 'var(--panel)'; }}
onMouseLeave={(e) => { if (!on) e.currentTarget.style.background = 'transparent'; }}>
<span style={{ flex: 'none', width: 10, color: accent, fontSize: 10 }}>{on ? '✓' : ''}</span>{o.label}
</button>
);
})}
</span>
<span style={{ flex: 1 }}></span>
{filtering ? <span style={{ fontSize: 11, color: 'var(--amber)', letterSpacing: '.08em' }}>{t.matchCount.replace('{n}', matchCount)}</span> : null}
{filtering ? <Button variant="ghost" size="xs" onClick={onClear}>{t.clearFilter}</Button> : null}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
{KIT_FILTER_GROUPS.map(([g, sts]) => {
const on = sts.every((s) => statusSet.has(s));
const part = !on && sts.some((s) => statusSet.has(s));
return (
<button key={g} onClick={() => toggleGroup(sts)} style={{
fontFamily: 'var(--mono)', fontSize: 10.5, letterSpacing: '.04em',
background: 'transparent',
color: on ? 'var(--green)' : part ? 'var(--amber)' : 'var(--muted)',
border: '1px dashed ' + (on ? 'var(--green-dim)' : part ? 'var(--amber-dim)' : 'var(--line-soft)'),
borderRadius: 3, padding: '2px 9px', cursor: 'pointer', whiteSpace: 'nowrap',
}}>{t.fgroups[g]}</button>
);
})}
</div>
) : null}
</span>
);
}
function FilterBar({ t, kw, setKw, sets, toggle, clearOne, matchCount, filtering, onClear }) {
const { Button } = window.MaestroDesignSystem_a6a290;
const statusOpts = KIT_STATUS_ORDER.map((s) => ({ value: s, label: t.status[s] }));
const assigneeOpts = [{ value: 'agent', label: t.assignees.agent }, { value: 'human', label: t.assignees.human }];
return (
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 6, padding: '8px 10px', marginBottom: 12, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<input value={kw} onChange={(e) => setKw(e.target.value)} placeholder={t.searchPh} autoComplete="off" style={{
fontFamily: 'var(--mono)', fontSize: 12, width: 180, padding: '5px 9px',
background: 'var(--bg-deep)', color: 'var(--ink)', border: '1px solid var(--line)',
borderRadius: 'var(--radius-sm, 4px)', outline: 'none',
}} />
<KitMultiSelect label={t.fStatus} options={statusOpts} selected={sets.status} onToggle={(v) => toggle('status', v)} onClear={() => clearOne('status')} />
<KitMultiSelect label={t.complexity} options={KIT_CPLX_OPTS} selected={sets.cplx} onToggle={(v) => toggle('cplx', v)} onClear={() => clearOne('cplx')} accent="var(--amber)" />
<KitMultiSelect label={t.priority} options={KIT_PRIO_OPTS} selected={sets.prio} onToggle={(v) => toggle('prio', v)} onClear={() => clearOne('prio')} accent="var(--red)" />
<KitMultiSelect label={t.fAssignee} options={assigneeOpts} selected={sets.assignee} onToggle={(v) => toggle('assignee', v)} onClear={() => clearOne('assignee')} accent="var(--violet)" />
<span style={{ flex: 1 }}></span>
{filtering ? <span style={{ fontSize: 11, color: 'var(--amber)', letterSpacing: '.08em' }}>{t.matchCount.replace('{n}', matchCount)}</span> : null}
{filtering ? <Button variant="ghost" size="xs" onClick={onClear}>{t.clearFilter}</Button> : null}
</div>
);
}
@@ -311,13 +407,15 @@ function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
}
function TaskSection({ tasks, onToast, onCreate, onTakeover, taskOps, t }) {
const { Button, SectionHead } = window.MaestroDesignSystem_a6a290;
const [expandedId, setExpandedId] = React.useState(null);
const [openIds, setOpenIds] = React.useState(() => new Set(['t1']));
const [showNew, setShowNew] = React.useState(false);
const [folded, setFolded] = React.useState(() => localStorage.getItem('maestro-kit-fold-tasks') === '1');
const toggleFold = () => setFolded((f) => { localStorage.setItem('maestro-kit-fold-tasks', f ? '0' : '1'); return !f; });
const [kw, setKw] = React.useState('');
const [cplxSet, setCplxSet] = React.useState(() => new Set());
const [statusSet, setStatusSet] = React.useState(() => new Set());
const [cplxSet, setCplxSet] = React.useState(() => new Set());
const [prioSet, setPrioSet] = React.useState(() => new Set());
const [assigneeSet, setAssigneeSet] = React.useState(() => new Set());
const [cplxOverride, setCplxOverride] = React.useState({});
const [flashId, setFlashId] = React.useState(null);
const byId = React.useMemo(() => kitFlatten(tasks), [tasks]);
@@ -329,25 +427,25 @@ function TaskSection({ tasks, onToast, onCreate, onTakeover, taskOps, t }) {
const toggleOpen = (id) => setOpenIds((prev) => {
const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next;
});
const toggleCplx = (v) => setCplxSet((prev) => {
// 四维多选筛选:status / cplx / prio / assignee
const setters = { status: setStatusSet, cplx: setCplxSet, prio: setPrioSet, assignee: setAssigneeSet };
const sets = { status: statusSet, cplx: cplxSet, prio: prioSet, assignee: assigneeSet };
const toggle = (key, v) => setters[key]((prev) => {
const next = new Set(prev); next.has(v) ? next.delete(v) : next.add(v); return next;
});
const toggleGroup = (sts) => setStatusSet((prev) => {
const next = new Set(prev);
const allOn = sts.every((s) => next.has(s));
for (const s of sts) allOn ? next.delete(s) : next.add(s);
return next;
});
const filtering = kw.trim() !== '' || cplxSet.size > 0 || statusSet.size > 0;
const clearFilters = () => { setKw(''); setCplxSet(new Set()); setStatusSet(new Set()); };
const clearOne = (key) => setters[key](new Set());
const filtering = kw.trim() !== '' || statusSet.size > 0 || cplxSet.size > 0 || prioSet.size > 0 || assigneeSet.size > 0;
const clearFilters = () => { setKw(''); setStatusSet(new Set()); setCplxSet(new Set()); setPrioSet(new Set()); setAssigneeSet(new Set()); };
// 筛选:自身命中 → 显示;祖先链作为上下文淡显;命中节点的父级自动展开
const { visibleSet, matchCount } = React.useMemo(() => {
if (!filtering) return { visibleSet: null, matchCount: 0 };
const matches = (tk) =>
(kw.trim() === '' || tk.title.toLowerCase().includes(kw.trim().toLowerCase())) &&
(statusSet.size === 0 || statusSet.has(tk.status)) &&
(cplxSet.size === 0 || cplxSet.has(cplxOverride[tk.id] || tk.cplx)) &&
(statusSet.size === 0 || statusSet.has(tk.status));
(prioSet.size === 0 || prioSet.has(tk.prio)) &&
(assigneeSet.size === 0 || assigneeSet.has((tk._raw && tk._raw.assignee) || ''));
const vis = new Set(); const ctx = new Set(); let count = 0;
const walk = (tk) => {
let childHit = false;
@@ -360,7 +458,7 @@ function TaskSection({ tasks, onToast, onCreate, onTakeover, taskOps, t }) {
for (const tk of tasks) walk(tk);
const set = new Set(vis); set.ctx = ctx;
return { visibleSet: set, matchCount: count };
}, [filtering, kw, cplxSet, statusSet, tasks, cplxOverride]);
}, [filtering, kw, statusSet, cplxSet, prioSet, assigneeSet, tasks, cplxOverride]);
// 依赖跳转:展开所有祖先 + flash 定位
const onJump = (id) => {
@@ -384,20 +482,22 @@ function TaskSection({ tasks, onToast, onCreate, onTakeover, taskOps, t }) {
const roots = visibleSet ? tasks.filter((tk) => visibleSet.has(tk.id)) : tasks;
return (
<section>
<SectionHead title={t.taskSection} sticky action={<Button variant="ghost" size="xs" onClick={() => setShowNew(!showNew)}>{t.newTask}</Button>} />
{showNew ? <NewTaskPanel tasks={tasks} t={t} onCancel={() => setShowNew(false)}
onCreate={(payload) => { setShowNew(false); onCreate ? onCreate(payload) : onToast('ok', t.toastCreated); }} /> : null}
<FilterBar t={t} kw={kw} setKw={setKw} cplxSet={cplxSet} toggleCplx={toggleCplx}
statusSet={statusSet} toggleGroup={toggleGroup} matchCount={matchCount} filtering={filtering} onClear={clearFilters} />
<div>
{roots.map((tk) => (
<TaskRow key={tk.id} task={tk} depth={0} ctx={visibleSet ? visibleSet.ctx.has(tk.id) : false}
expandedId={expandedId} setExpandedId={setExpandedId} openIds={openIds} toggleOpen={toggleOpen} t={t}
byId={byId} cplxOf={cplxOf} onChangeCplx={onChangeCplx} flashId={flashId} onJump={onJump} visibleSet={visibleSet} onTakeover={onTakeover} taskOps={taskOps} />
))}
</div>
<KitSectionHead icon="tasks" title={t.taskSection} count={byId.size} accent="var(--green)" folded={folded} onToggleFold={toggleFold} t={t} sticky />
{!folded ? (
<React.Fragment>
<FilterBar t={t} kw={kw} setKw={setKw} sets={sets} toggle={toggle} clearOne={clearOne}
matchCount={matchCount} filtering={filtering} onClear={clearFilters} />
<div>
{roots.map((tk) => (
<TaskRow key={tk.id} task={tk} depth={0} ctx={visibleSet ? visibleSet.ctx.has(tk.id) : false}
expandedId={expandedId} setExpandedId={setExpandedId} openIds={openIds} toggleOpen={toggleOpen} t={t}
byId={byId} cplxOf={cplxOf} onChangeCplx={onChangeCplx} flashId={flashId} onJump={onJump} visibleSet={visibleSet} onTakeover={onTakeover} taskOps={taskOps} />
))}
</div>
</React.Fragment>
) : null}
</section>
);
}
Object.assign(window, { MaestroKitTaskSection: TaskSection });
Object.assign(window, { MaestroKitTaskSection: TaskSection, MaestroKitNewTaskPanel: NewTaskPanel });
+7 -3
View File
@@ -155,10 +155,13 @@ function ConfigPanel({ project, onSave, onClose, onSync, t, lang }) {
}
function AgentSection({ agents, quota, t, onOpenStream }) {
const { SectionHead, QuotaMeter } = window.MaestroDesignSystem_a6a290;
const { QuotaMeter } = window.MaestroDesignSystem_a6a290;
const [folded, setFolded] = React.useState(() => localStorage.getItem('maestro-kit-fold-agent') === '1');
const toggleFold = () => setFolded((f) => { localStorage.setItem('maestro-kit-fold-agent', f ? '0' : '1'); return !f; });
return (
<section>
<SectionHead mark="cyan" title={<span>{t.agentSection}<span style={{ color: 'var(--cyan)', letterSpacing: '.08em', marginLeft: 8 }}>{agents.length > 0 ? agents.length : ''}</span></span>} sticky />
<window.MaestroKitSectionHead icon="agent" title={t.agentSection} count={agents.length} accent="var(--cyan)" folded={folded} onToggleFold={toggleFold} t={t} sticky />
{!folded ? (<React.Fragment>
{quota ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 22, flexWrap: 'wrap', fontFamily: 'var(--mono)', margin: '0 0 12px' }}>
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--muted)', letterSpacing: '.08em' }}>{t.quota}</span>
@@ -182,7 +185,7 @@ function AgentSection({ agents, quota, t, onOpenStream }) {
onMouseEnter={(e) => { if (onOpenStream && a.taskId) e.currentTarget.style.background = 'var(--panel-2)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
<span style={{ flex: 'none', width: 6, height: 6, borderRadius: '50%', background: 'var(--cyan)', boxShadow: '0 0 8px var(--cyan)', animation: 'maestro-pulse .9s infinite' }}></span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.title}</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.title}</span>
<span style={{ flex: 'none', fontSize: 10, letterSpacing: '.1em', color: 'var(--cyan)', border: '1px solid var(--cyan-dim)', padding: '0 6px', borderRadius: 3 }}>{a.kind}</span>
<span style={{ flex: 'none', marginLeft: 'auto', fontSize: 10.5, color: 'var(--faint)' }}>{a.meta} · {a.time} {t.since}</span>
</div>
@@ -190,6 +193,7 @@ function AgentSection({ agents, quota, t, onOpenStream }) {
</div>
</div>
)}
</React.Fragment>) : null}
</section>
);
}
+20 -10
View File
@@ -23,16 +23,18 @@ window.MAESTRO_I18N = {
projRunning: '运行中', projPaused: '暂停', projBlocked: '阻塞', projIdle: '空闲',
projPendingTip: '{n} 项待你审批',
gAgents: '全局 Agent', gAgentsDetail: '{p}/{P} 个项目运行 · {a} 个 agent',
apActive: '运行', apRuns: '次运行', apTokens: 'token', apWeek: '本周', apCost: '花费', apTotal: '所有项目', apPerProj: '按项目', apIdle: '空闲',
apActive: '运行', apRuns: '次运行', apTokens: 'token', apWeek: '本周', apCost: '花费', apTotal: '所有项目', apPerProj: '按项目', apIdle: '空闲', apMore: '展开其余 {n} 个', apCollapse: '收起',
gConfig: '全局配置', gConfigDetail: '并发上限 {n} · daemon {v}',
gUserPlan: '订阅', gUserSignOut: '退出登录', gUserSettings: '账户设置',
gGlobalSettings: '全局设置', gSettingsHint: '以下为新建项目的默认值(按用户保存)', gMaxRetries: '最大重试', gTimeoutMin: '超时(分钟)', gAutoPlan: '拆解自动放行', gAutoExec: '执行自动放行', gBudget: '预算 $(空=不限)', gBudgetPeriod: '预算周期', gPeriodDay: '按天', gPeriodMonth: '按月',
gateExpand: '展开', gateCollapse: '折叠', gateDblTip: '双击全屏阅读',
quota: '额度', quotaWeek: '周', quotaReset: '{t} 后重置',
projLogo: '项目 Logo', logoPh: '图片 URL 或仓库内相对路径;留空=自动', verifyCmd: '校验命令', verifyPh: 'npm test · go test ./...(可空)', model: '模型', modelPh: 'claude-opus-4-5(可空)',
searchPh: '⌕ 搜索标题…', clearFilter: '✕ 清除筛选', matchCount: '{n} 项匹配',
fgroups: { todo: '待办', doing: '进行中', gate: '待审批', bad: '异常', hold: '挂起', done: '完成' },
fStatus: '状态', fAssignee: '执行者', assignees: { agent: '智能体', human: '人工' },
fullRead: '全屏阅读', depsLabel: 'DEPS · 依赖(全部完成才可执行)', depDone: '完成', depWait: '等待',
gateSection: '审批闸 · 等待裁决', accept: '✓ 通过', reject: '✕ 驳回', confirmReject: '确认驳回',
gateSection: '审批闸', accept: '✓ 通过', reject: '✕ 驳回', confirmReject: '确认驳回',
rejectPlaceholder: '改进意见(必填)',
taskSection: '任务树', newTask: '+ 新建任务', titleLabel: '标题', titlePlaceholder: '要做什么',
complexity: '复杂度', cplxAuto: '智能', parentTask: '父任务', topLevel: '(顶层)', priority: '优先级',
@@ -68,16 +70,18 @@ window.MAESTRO_I18N = {
projRunning: 'Running', projPaused: 'Paused', projBlocked: 'Blocked', projIdle: 'Idle',
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',
apActive: 'active', apRuns: 'runs', apTokens: 'tokens', apWeek: 'this week', apCost: 'cost', apTotal: 'All projects', apPerProj: 'BY PROJECT', apIdle: 'idle', apMore: '+{n} more', apCollapse: 'Collapse',
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',
gateExpand: 'Expand', gateCollapse: 'Collapse', gateDblTip: 'Double-click for fullscreen',
quota: 'Quota', quotaWeek: 'week', quotaReset: 'resets in {t}',
projLogo: 'Project logo', logoPh: 'Image URL or repo-relative path; empty = auto', verifyCmd: 'Verify command', verifyPh: 'npm test · go test ./... (optional)', model: 'Model', modelPh: 'claude-opus-4-5 (optional)',
searchPh: '⌕ Search titles…', clearFilter: '✕ Clear filters', matchCount: '{n} matches',
fgroups: { todo: 'To do', doing: 'In progress', gate: 'Pending review', bad: 'Issues', hold: 'On hold', done: 'Done' },
fStatus: 'Status', fAssignee: 'Assignee', assignees: { agent: 'Agent', human: 'Human' },
fullRead: 'Read fullscreen', depsLabel: 'DEPS (all must complete to run)', depDone: 'done', depWait: 'waiting',
gateSection: 'Approval gates · awaiting decision', accept: '✓ Accept', reject: '✕ Reject', confirmReject: 'Confirm reject',
gateSection: 'Approval gates', accept: '✓ Accept', reject: '✕ Reject', confirmReject: 'Confirm reject',
rejectPlaceholder: 'Improvement feedback (required)',
taskSection: 'Task tree', newTask: '+ New task', titleLabel: 'Title', titlePlaceholder: 'What needs doing',
complexity: 'Complexity', cplxAuto: 'AUTO', parentTask: 'Parent', topLevel: '(top level)', priority: 'Priority',
@@ -113,16 +117,18 @@ window.MAESTRO_I18N = {
projRunning: 'En curso', projPaused: 'Pausado', projBlocked: 'Bloqueado', projIdle: 'Inactivo',
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',
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',
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',
gateExpand: 'Expandir', gateCollapse: 'Plegar', gateDblTip: 'Doble clic para pantalla completa',
quota: 'Cuota', quotaWeek: 'semana', quotaReset: 'reinicio en {t}',
projLogo: 'Logo del proyecto', logoPh: 'URL o ruta relativa; vacío = auto', verifyCmd: 'Comando de verificación', verifyPh: 'npm test · go test ./... (opcional)', model: 'Modelo', modelPh: 'claude-opus-4-5 (opcional)',
searchPh: '⌕ Buscar títulos…', clearFilter: '✕ Limpiar filtros', matchCount: '{n} coincidencias',
fgroups: { todo: 'Pendientes', doing: 'En curso', gate: 'Por revisar', bad: 'Incidencias', hold: 'En pausa', done: 'Hechas' },
fStatus: 'Estado', fAssignee: 'Ejecutor', assignees: { agent: 'Agente', human: 'Humano' },
fullRead: 'Pantalla completa', depsLabel: 'DEPS (todas deben completarse)', depDone: 'hecha', depWait: 'esperando',
gateSection: 'Puertas de aprobación · pendientes', accept: '✓ Aceptar', reject: '✕ Rechazar', confirmReject: 'Confirmar rechazo',
gateSection: 'Puertas de aprobación', accept: '✓ Aceptar', reject: '✕ Rechazar', confirmReject: 'Confirmar rechazo',
rejectPlaceholder: 'Comentario de mejora (obligatorio)',
taskSection: 'Árbol de tareas', newTask: '+ Nueva tarea', titleLabel: 'Título', titlePlaceholder: 'Qué hay que hacer',
complexity: 'Complejidad', cplxAuto: 'AUTO', parentTask: 'Padre', topLevel: '(raíz)', priority: 'Prioridad',
@@ -158,16 +164,18 @@ window.MAESTRO_I18N = {
projRunning: '実行中', projPaused: '一時停止', projBlocked: 'ブロック', projIdle: 'アイドル',
projPendingTip: '{n} 件があなたの承認待ち',
gAgents: 'グローバル Agent', gAgentsDetail: '{p}/{P} プロジェクト · {a} 個の agent',
apActive: '実行中', apRuns: '回実行', apTokens: 'token', apWeek: '今週', apCost: 'コスト', apTotal: '全プロジェクト', apPerProj: 'プロジェクト別', apIdle: 'アイドル',
apActive: '実行中', apRuns: '回実行', apTokens: 'token', apWeek: '今週', apCost: 'コスト', apTotal: '全プロジェクト', apPerProj: 'プロジェクト別', apIdle: 'アイドル', apMore: '他 {n} 件', apCollapse: '折りたたむ',
gConfig: 'グローバル設定', gConfigDetail: '並列上限 {n} · daemon {v}',
gUserPlan: 'プラン', gUserSignOut: 'ログアウト', gUserSettings: 'アカウント設定',
gGlobalSettings: 'グローバル設定', gSettingsHint: '新規プロジェクトの既定値(ユーザー単位で保存)', gMaxRetries: '最大リトライ', gTimeoutMin: 'タイムアウト(分)', gAutoPlan: '分解を自動承認', gAutoExec: '実行を自動承認', gBudget: '予算 $(空=無制限)', gBudgetPeriod: '予算期間', gPeriodDay: '日次', gPeriodMonth: '月次',
gateExpand: '展開', gateCollapse: '折り畳む', gateDblTip: 'ダブルクリックで全画面',
quota: 'クォータ', quotaWeek: '週', quotaReset: '{t} 後にリセット',
projLogo: 'プロジェクトロゴ', logoPh: '画像 URL またはリポジトリ相対パス;空=自動', verifyCmd: '検証コマンド', verifyPh: 'npm test · go test ./...(任意)', model: 'モデル', modelPh: 'claude-opus-4-5(任意)',
searchPh: '⌕ タイトルを検索…', clearFilter: '✕ フィルタをクリア', matchCount: '{n} 件一致',
fgroups: { todo: '未着手', doing: '進行中', gate: '承認待ち', bad: '異常', hold: '保留', done: '完了' },
fStatus: 'ステータス', fAssignee: '担当', assignees: { agent: 'エージェント', human: '人手' },
fullRead: '全画面で読む', depsLabel: 'DEPS · 依存(全完了で実行可)', depDone: '完了', depWait: '待機',
gateSection: '承認ゲート · 裁定待ち', accept: '✓ 承認', reject: '✕ 却下', confirmReject: '却下を確定',
gateSection: '承認ゲート', accept: '✓ 承認', reject: '✕ 却下', confirmReject: '却下を確定',
rejectPlaceholder: '改善フィードバック(必須)',
taskSection: 'タスクツリー', newTask: '+ 新規タスク', titleLabel: 'タイトル', titlePlaceholder: '何をしますか',
complexity: '複雑度', cplxAuto: '智能', parentTask: '親タスク', topLevel: '(トップ)', priority: '優先度',
@@ -203,16 +211,18 @@ window.MAESTRO_I18N = {
projRunning: 'En cours', projPaused: 'En pause', projBlocked: 'Bloqué', projIdle: 'Inactif',
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',
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',
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',
gateExpand: 'Déplier', gateCollapse: 'Replier', gateDblTip: 'Double-clic pour le plein écran',
quota: 'Quota', quotaWeek: 'semaine', quotaReset: 'reset dans {t}',
projLogo: 'Logo du projet', logoPh: 'URL ou chemin relatif ; vide = auto', verifyCmd: 'Commande de vérification', verifyPh: 'npm test · go test ./... (optionnel)', model: 'Modèle', modelPh: 'claude-opus-4-5 (optionnel)',
searchPh: '⌕ Rechercher…', clearFilter: '✕ Effacer les filtres', matchCount: '{n} résultats',
fgroups: { todo: 'À faire', doing: 'En cours', gate: 'À approuver', bad: 'Anomalies', hold: 'En attente', done: 'Terminées' },
fStatus: 'Statut', fAssignee: 'Exécutant', assignees: { agent: 'Agent', human: 'Humain' },
fullRead: 'Plein écran', depsLabel: 'DEPS (toutes requises)', depDone: 'terminée', depWait: 'en attente',
gateSection: "Portes d'approbation · en attente", accept: '✓ Accepter', reject: '✕ Rejeter', confirmReject: 'Confirmer le rejet',
gateSection: "Portes d'approbation", accept: '✓ Accepter', reject: '✕ Rejeter', confirmReject: 'Confirmer le rejet',
rejectPlaceholder: "Retour d'amélioration (obligatoire)",
taskSection: 'Arbre des tâches', newTask: '+ Nouvelle tâche', titleLabel: 'Titre', titlePlaceholder: 'Que faut-il faire',
complexity: 'Complexité', cplxAuto: 'AUTO', parentTask: 'Parent', topLevel: '(racine)', priority: 'Priorité',