// 真实数据版 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 }) {
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('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 (
);
}
// SDK 流式消息 → 一行可读文本(助手文本 / 工具调用 / 终态 / maestro 元事件)
function summarizeMessage(m) {
if (!m || !m.type) return '';
const content = m.message && m.message.content;
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(' ');
}
if (m.type === 'user' && Array.isArray(content)) {
return content.some((c) => c.type === 'tool_result') ? '↩ tool result' : '';
}
if (m.type === 'result') return `■ ${m.subtype || 'done'}`;
if (typeof m.type === 'string' && m.type.startsWith('maestro.')) return `· ${m.type}`;
return '';
}
// 实时流模态: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 text = '';
try { text = summarizeMessage(JSON.parse(e.data)); } catch { text = ''; }
if (text) setLines((ls) => [...ls.slice(-400), text]);
};
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((l, i) =>
{l}
)
:
等待输出…
}
);
}
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];
// ── 真实数据 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 [currentId, setCurrentId] = React.useState(null);
const [wsState, setWsState] = React.useState('closed');
// ── UI state(沿用原型)─────────────────────────────
const [showConfig, setShowConfig] = React.useState(false);
const [archiveItem, setArchiveItem] = React.useState(null);
const [streamAgent, setStreamAgent] = 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);
setCurrentId((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); }
}, []);
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);
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)));
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 { await api.uploadAttachments(task.id, files); toast('ok', `附件已上传 · ${files.length} 个`); }
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 [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)} />
{project ? setTheme(theme === 'light' ? 'dark' : 'light')}
lang={lang} onSelectLang={setLang}
counts={counts}
onSync={onSync}
onToggleConfig={() => setShowConfig(!showConfig)} /> : null}
{showConfig && project ? setShowConfig(false)} /> : null}
{tasks.length ? (
) : (
{t.empty}
{t.emptyTasks}
)}
{archiveItem ? setArchiveItem(null)} /> : null}
{showNewProject ? setShowNewProject(false)} /> : null}
{streamAgent ? setStreamAgent(null)} /> : null}
{toasts.map((x) => {x.text})}
);
}
export function mount() {
createRoot(document.getElementById('root')).render();
}