// 真实数据版 App:从本地 daemon 的 REST 拉取、订阅 WS 增量刷新,
// 通过 src/adapt.js 映射成各 surface 期望的形状(替代 window.MAESTRO_MOCK)。
import { createRoot } from 'react-dom/client';
import { api, subscribeEvents } from './api.js';
import {
adaptProject, buildTaskTree, adaptApproval, adaptEvent, adaptQuota,
deriveGlobal, deriveAgentSummary, adaptActiveAgents,
} from './adapt.js';
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, 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: '创建' }
: { title: 'New project', name: 'Name', repo: 'Repo path', branch: 'Default branch', mode: 'Mode', namePh: 'my-app', repoPh: '/Users/you/code/my-app', create: 'Create' };
const [name, setName] = React.useState('');
const [repoPath, setRepoPath] = React.useState('');
const [defaultBranch, setDefaultBranch] = React.useState('main');
const [autonomy, setAutonomy] = React.useState(defaultAutonomy || 'manual');
const submit = (e) => {
e.preventDefault();
if (!name.trim() || !repoPath.trim()) return;
onCreate({ name: name.trim(), repoPath: repoPath.trim(), defaultBranch: defaultBranch.trim() || 'main', autonomy });
};
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)) {
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 ⚙ {b.name}{b.arg ? {b.arg} : null}
;
}
if (b.kind === 'result') {
return ↩ {b.text || '(空)'}
;
}
if (b.kind === 'meta') return {b.text}
;
return {b.text}
;
}
// 实时流模态:EventSource 跟随某 run 的 transcript,逐条渲染 agent 输出
function StreamModal({ agent, onClose }) {
const [lines, setLines] = React.useState([]);
const [status, setStatus] = React.useState('connecting');
const boxRef = React.useRef(null);
React.useEffect(() => {
const es = new EventSource(`/api/tasks/${agent.taskId}/stream`);
es.addEventListener('open', () => setStatus('streaming'));
es.onmessage = (e) => {
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(); };
return () => es.close();
}, [agent.taskId]);
React.useEffect(() => { if (boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight; }, [lines]);
const dot = { connecting: 'var(--amber)', streaming: 'var(--cyan)', done: 'var(--green)', disconnected: 'var(--faint)' }[status];
return (
{agent.title}
{agent.kind}
实时输出 · {status}
{lines.length ? lines.map((b, i) =>
)
:
等待输出…
}
);
}
// 接管模态:展示 daemon 准备好的 worktree + 在自己终端起交互式 claude 的命令
function TakeoverModal({ info, onClose }) {
const [copied, setCopied] = React.useState(false);
const copy = () => { try { navigator.clipboard.writeText(info.console); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { /* 非安全上下文 */ } };
return (
⌨ 人工接管 · {info.title}
worktree 已就绪。在你自己的终端运行下面命令,起交互式 Claude Code 手动接手:
{info.console}
分支 {info.branch} · 完成后用 git 提交,可经审核区合并。
);
}
export function App() {
const I18N = window.MAESTRO_I18N;
const [lang, setLang] = React.useState(() => {
const saved = localStorage.getItem('maestro-kit-lang');
return I18N[saved] ? saved : 'zh';
});
const t = I18N[lang];
const { Button } = window.MaestroDesignSystem_a6a290;
// ── 真实数据 state ──────────────────────────────────
const [projectsRaw, setProjectsRaw] = React.useState([]);
const [tasksRaw, setTasksRaw] = React.useState([]);
const [approvalsRaw, setApprovalsRaw] = React.useState([]);
const [eventsRaw, setEventsRaw] = React.useState([]);
const [agentsResp, setAgentsResp] = React.useState({ totalActive: 0, agents: [] });
const [usage, setUsage] = 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);
const [toasts, setToasts] = React.useState([]);
const [theme, setTheme] = React.useState(() => localStorage.getItem('maestro-kit-theme') || 'dark');
React.useEffect(() => {
document.documentElement.dataset.theme = theme;
localStorage.setItem('maestro-kit-theme', theme);
}, [theme]);
React.useEffect(() => { localStorage.setItem('maestro-kit-lang', lang); }, [lang]);
const [collapsed, setCollapsed] = React.useState(() => localStorage.getItem('maestro-kit-sidebar') === 'collapsed');
const [evCollapsed, setEvCollapsed] = React.useState(() => localStorage.getItem('maestro-kit-events') === 'collapsed');
const toggleEvents = () => setEvCollapsed((c) => { localStorage.setItem('maestro-kit-events', c ? 'open' : 'collapsed'); return !c; });
const toggleCollapse = () => setCollapsed((c) => { localStorage.setItem('maestro-kit-sidebar', c ? 'open' : 'collapsed'); return !c; });
const SIDE_MIN = 200, SIDE_MAX = 420, EV_MIN = 240, EV_MAX = 520, CENTER_MIN = 480;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const sideMax = () => Math.min(SIDE_MAX, window.innerWidth - (evCollapsed ? 52 : evW) - CENTER_MIN);
const evMax = () => Math.min(EV_MAX, window.innerWidth - (collapsed ? 52 : sideW) - CENTER_MIN);
const [sideW, setSideW] = React.useState(() => clamp(Number(localStorage.getItem('maestro-kit-sidew')) || 232, SIDE_MIN, SIDE_MAX));
const [evW, setEvW] = React.useState(() => clamp(Number(localStorage.getItem('maestro-kit-evw')) || 320, EV_MIN, EV_MAX));
const [dragging, setDragging] = React.useState(null);
React.useEffect(() => { localStorage.setItem('maestro-kit-sidew', sideW); }, [sideW]);
React.useEffect(() => { localStorage.setItem('maestro-kit-evw', evW); }, [evW]);
const startDrag = (which) => (e) => {
e.preventDefault();
setDragging(which);
const startX = e.clientX;
const startW = which === 'side' ? sideW : evW;
const onMove = (ev) => {
if (which === 'side') setSideW(clamp(startW + (ev.clientX - startX), SIDE_MIN, Math.max(SIDE_MIN, sideMax())));
else setEvW(clamp(startW - (ev.clientX - startX), EV_MIN, Math.max(EV_MIN, evMax())));
};
const onUp = () => {
setDragging(null);
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
document.body.style.userSelect = '';
};
document.body.style.userSelect = 'none';
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
};
const Resizer = ({ which }) => (
);
// ── 数据加载 ────────────────────────────────────────
const toast = (kind, text) => {
const id = Date.now() + Math.random();
setToasts((list) => [...list, { id, kind, text }]);
setTimeout(() => setToasts((list) => list.filter((x) => x.id !== id)), 2600);
};
const loadGlobal = React.useCallback(async () => {
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);
// 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); }
}, []);
const loadProject = React.useCallback(async (pid) => {
if (!pid) return;
try {
const [tasks, events] = await Promise.all([api.projectTasks(pid), api.projectEvents(pid)]);
setTasksRaw(tasks);
setEventsRaw(events.slice(0, 60));
} 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]);
// WS:增量刷新。事件即时入流;任务/审批/agent 做轻量去抖刷新。
const refreshTimer = React.useRef(null);
React.useEffect(() => {
const unsub = subscribeEvents((evt) => {
if (!evt || !evt.type) return;
if (!evt.projectId || evt.projectId === currentId) {
setEventsRaw((list) => [evt, ...list].slice(0, 60));
}
if (evt.type === 'budget.exceeded') {
const pay = evt.payload || {};
toast('warn', `⚠ 项目超预算已暂停 · 已用 $${(pay.spend ?? 0).toFixed?.(2) ?? pay.spend} / $${pay.budget}`);
}
clearTimeout(refreshTimer.current);
refreshTimer.current = setTimeout(() => {
loadProject(currentId);
api.approvals().then(setApprovalsRaw).catch(() => {});
api.agents().then(setAgentsResp).catch(() => {});
api.listProjects().then(setProjectsRaw).catch(() => {});
}, 350);
}, setWsState);
return () => { unsub(); clearTimeout(refreshTimer.current); };
}, [currentId, loadProject]);
// ── 派生:映射成 surface 形状 ───────────────────────
const projects = projectsRaw.map(adaptProject);
const project = projects.find((p) => p.id === currentId) || projects[0];
const projNameOf = (pid) => (projectsRaw.find((p) => p.id === pid) || {}).name;
const { active: tasks, archived } = buildTaskTree(tasksRaw);
// 「需你处理」= 三个审批闸 + needs_attention(卡住待人工)。后者并入同区但操作不同。
const REVIEW = new Set(['plan_review', 'spec_review', 'exec_review']);
const gates = approvalsRaw
.filter((a) => a.projectId === currentId && (REVIEW.has(a.status) || a.status === 'needs_attention'))
.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, scopeFiles: tk.scopeFiles || [] }));
}
return g;
})
// 审批闸在前、需人工在后
.sort((x, y) => (x.gate === 'attention' ? 1 : 0) - (y.gate === 'attention' ? 1 : 0));
const agents = adaptActiveAgents(agentsResp, currentId);
const events = eventsRaw.map(adaptEvent);
const quota = adaptQuota(usage);
const global = deriveGlobal(projects, agentsResp);
const agentSummary = deriveAgentSummary(projects, agentsResp, usage?.cost);
const NONTERMINAL = (s) => !['done', 'cancelled', 'decomposed'].includes(s);
const counts = {
gate: gates.length,
ready: tasksRaw.filter((x) => x.projectId === currentId && (x.status === 'ready' || x.status === 'queued')).length,
run: tasksRaw.filter((x) => x.projectId === currentId && x.status === 'executing').length,
blocked: tasksRaw.filter((x) => x.projectId === currentId && x.status === 'blocked').length,
total: tasksRaw.filter((x) => x.projectId === currentId && NONTERMINAL(x.status)).length,
};
// ── 动作(真实写入)────────────────────────────────
const decide = async (a, action, reason) => {
setApprovalsRaw((list) => list.filter((x) => x.id !== a.id)); // 乐观移除
try {
await api.decide(a.id, action, reason);
toast(action === 'accept' ? 'ok' : 'warn', (action === 'accept' ? t.toastAccepted : t.toastRejected) + (action === 'accept' ? a.title : ''));
} catch (e) {
toast('warn', '裁决失败:' + e.message);
api.approvals().then(setApprovalsRaw).catch(() => {}); // 回滚
}
};
const onSync = async () => {
try { await api.syncProject(currentId); toast('ok', t.toastSynced); loadProject(currentId); loadGlobal(); }
catch (e) { toast('warn', '同步失败:' + e.message); }
};
const createTask = async (payload) => {
const { files, ...body } = payload;
try {
const task = await api.createTask(currentId, body);
if (files && files.length) {
try {
// 消费后端去重后的权威列表:新建任务原本无附件,stored 即本批去重后入库条数
const { attachments } = await api.uploadAttachments(task.id, files);
const stored = attachments.length;
const deduped = files.length - stored; // >0 表示有同内容文件被去重
toast('ok', t.attUploaded.replace('{n}', stored) + (deduped > 0 ? t.attDeduped.replace('{n}', deduped) : ''));
} catch (e) { toast('warn', e.message); }
}
toast('ok', t.toastCreated);
loadProject(currentId); loadGlobal();
} catch (e) { toast('warn', '创建失败:' + e.message); }
};
const saveConfig = async (patch) => {
try {
await api.patchProject(currentId, patch);
setShowConfig(false); toast('ok', t.toastSaved); loadGlobal();
} catch (e) { toast('warn', '保存失败:' + e.message); }
};
const takeover = async (task) => {
try {
const info = await api.takeover(task.id);
setTakeoverInfo({ ...info, title: task.title });
} catch (e) { toast('warn', '接管失败:' + e.message); }
};
// 任务生命周期操作(编辑落库 / 取消 / 重投 / 删除),统一带刷新
const taskOps = {
patch: async (taskId, body) => {
try { await api.patchTask(taskId, body); toast('ok', '已更新'); loadProject(currentId); }
catch (e) { toast('warn', '更新失败:' + e.message); }
},
cancel: async (taskId) => {
try { await api.cancelTask(taskId); toast('warn', '已取消'); loadProject(currentId); loadGlobal(); }
catch (e) { toast('warn', '取消失败:' + e.message); }
},
requeue: async (taskId) => {
try { await api.requeueTask(taskId); toast('ok', '已重投'); loadProject(currentId); loadGlobal(); }
catch (e) { toast('warn', '重投失败:' + e.message); }
},
del: async (taskId) => {
try { await api.deleteTask(taskId); toast('warn', '已删除'); loadProject(currentId); loadGlobal(); }
catch (e) { toast('warn', '删除失败:' + e.message); }
},
// 删除已建任务的单个附件(name = 磁盘文件名 basename(att.path)),成功后局部刷新
delAttachment: async (taskId, name) => {
try { await api.deleteAttachment(taskId, name); toast('ok', t.attDeleted); loadProject(currentId); }
catch (e) { toast('warn', t.attDeleteFailed + e.message); }
},
};
const [showNewProject, setShowNewProject] = React.useState(false);
const createProject = async (body) => {
try {
const p = await api.createProject(body);
setShowNewProject(false); toast('ok', '项目已创建 · ' + body.name);
await loadGlobal(); setCurrentId(p.id);
} catch (e) { toast('warn', '创建项目失败:' + e.message); }
};
return (
{!collapsed ?
: null}
{!evCollapsed ?
: null}
toast('warn', t.toastModal)}
collapsed={collapsed} onToggleCollapse={toggleCollapse}
onSelect={setCurrentId} onNewProject={() => setShowNewProject(true)} onReorder={reorderProjects}
settings={settings} onSaveSettings={saveSettings} />
{project ? setTheme(theme === 'light' ? 'dark' : 'light')}
lang={lang} onSelectLang={setLang}
counts={counts}
onSync={onSync}
onToggleConfig={() => setShowConfig(!showConfig)} /> : null}
{project ? (
) : null}
{showNew && project ? setShowNew(false)}
onCreate={(payload) => { setShowNew(false); createTask(payload); }} /> : null}
{showConfig && project ? setShowConfig(false)} /> : null}
{tasks.length ? (
) : (
{t.empty}
{t.emptyTasks}
)}
{archiveItem ? setArchiveItem(null)} /> : null}
{showNewProject ? setShowNewProject(false)} /> : null}
{streamAgent ? setStreamAgent(null)} /> : null}
{takeoverInfo ? setTakeoverInfo(null)} /> : null}
{toasts.map((x) => {x.text})}
);
}
export function mount() {
createRoot(document.getElementById('root')).render();
}