feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
// ChatPane — mirrors ui/src/components/chat/* with user/ask/result/progress bubbles
|
||||
function Bubble({ role, children, footer }) {
|
||||
const isUser = role === 'user';
|
||||
const style = {
|
||||
maxWidth: '85%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 16,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.55,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
};
|
||||
if (isUser) {
|
||||
Object.assign(style, { background: '#2563eb', color: '#fff', borderBottomRightRadius: 4, alignSelf: 'flex-end' });
|
||||
} else if (role === 'ask') {
|
||||
Object.assign(style, { background: '#fef9c3', color: '#854d0e', border: '1px solid #fde68a', borderBottomLeftRadius: 4 });
|
||||
} else if (role === 'result') {
|
||||
Object.assign(style, { background: '#ecfdf5', color: '#065f46', border: '1px solid #a7f3d0', borderBottomLeftRadius: 4 });
|
||||
} else {
|
||||
Object.assign(style, { background: '#fff', color: '#0f172a', border: '1px solid #e2e8f0', borderBottomLeftRadius: 4 });
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: isUser ? 'flex-end' : 'flex-start', gap: 4 }}>
|
||||
<div style={style}>{children}</div>
|
||||
{footer && <div style={{ fontSize: 10, color: '#94a3b8' }}>{footer}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressBubble({ text }) {
|
||||
return (
|
||||
<div style={{
|
||||
alignSelf: 'flex-start', background: '#f1f5f9', color: '#475569',
|
||||
padding: '8px 12px', borderRadius: 12, fontSize: 12,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
<Spinner />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatHeader({ task, onOpenDetail, detailOpen }) {
|
||||
return (
|
||||
<div style={{
|
||||
flexShrink: 0, padding: '12px 16px', borderBottom: '1px solid #e2e8f0', background: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
||||
}}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>
|
||||
TASK #{task.id}
|
||||
</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{task.title}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<StatusBadge status={task.status} />
|
||||
<button onClick={onOpenDetail} style={{
|
||||
padding: '6px 10px', borderRadius: 8, border: '1px solid #e2e8f0',
|
||||
background: detailOpen ? '#eff6ff' : '#fff',
|
||||
color: detailOpen ? '#1d4ed8' : '#475569',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>詳細</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Composer({ onSend }) {
|
||||
const [text, setText] = React.useState('');
|
||||
const send = () => { if (!text.trim()) return; onSend(text.trim()); setText(''); };
|
||||
return (
|
||||
<div style={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', background: '#fff', padding: 12 }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-end', gap: 8, background: '#f8fafc',
|
||||
border: '1px solid #e2e8f0', borderRadius: 12, padding: 8,
|
||||
}}>
|
||||
<button style={{ padding: 6, background: 'transparent', border: 'none', color: '#94a3b8', cursor: 'pointer' }}>
|
||||
<IconAttach width={16} height={16} />
|
||||
</button>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); send(); } }}
|
||||
rows={2}
|
||||
placeholder="メッセージを入力 (⌘+Enter で送信)"
|
||||
style={{
|
||||
flex: 1, resize: 'none', border: 'none', outline: 'none', background: 'transparent',
|
||||
fontFamily: 'inherit', fontSize: 13, color: '#0f172a', lineHeight: 1.5, minHeight: 32,
|
||||
}}
|
||||
/>
|
||||
<button onClick={send} style={{
|
||||
padding: '6px 14px', background: '#2563eb', color: '#fff', borderRadius: 8,
|
||||
fontSize: 12, fontWeight: 700, border: 'none', cursor: text.trim() ? 'pointer' : 'not-allowed',
|
||||
opacity: text.trim() ? 1 : 0.5, fontFamily: 'inherit',
|
||||
}}>送信</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#94a3b8', paddingLeft: 4 }}>エージェントは常に /brainstorm → /plan → /implement のパイプラインで動作します。</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatPane({ task, messages, onSend, onOpenDetail, detailOpen }) {
|
||||
const scrollRef = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}, [messages.length, task.id]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#f8fafc' }}>
|
||||
<ChatHeader task={task} onOpenDetail={onOpenDetail} detailOpen={detailOpen} />
|
||||
<div ref={scrollRef} style={{
|
||||
flex: 1, overflowY: 'auto', padding: '16px 20px',
|
||||
display: 'flex', flexDirection: 'column', gap: 12, minHeight: 0,
|
||||
}}>
|
||||
{messages.map((m, i) => (
|
||||
m.role === 'progress'
|
||||
? <ProgressBubble key={i} text={m.content} />
|
||||
: <Bubble key={i} role={m.role} footer={m.footer}>{m.content}</Bubble>
|
||||
))}
|
||||
</div>
|
||||
<Composer onSend={onSend} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.ChatPane = ChatPane;
|
||||
@@ -0,0 +1,134 @@
|
||||
// DetailPanel — tabs: overview, progress (activity + log surface)
|
||||
function Tabs({ tab, onTab }) {
|
||||
const items = [
|
||||
{ id: 'overview', label: '概要' },
|
||||
{ id: 'progress', label: '進捗' },
|
||||
{ id: 'subtasks', label: 'サブタスク' },
|
||||
];
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid #e2e8f0', background: '#fff' }}>
|
||||
{items.map(it => (
|
||||
<button key={it.id} onClick={() => onTab(it.id)} style={{
|
||||
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
|
||||
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: tab === it.id ? '#eff6ff' : 'transparent',
|
||||
color: tab === it.id ? '#1d4ed8' : '#64748b',
|
||||
}}>{it.label}</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({ task }) {
|
||||
const Row = ({ label, value }) => (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, padding: '8px 0', borderBottom: '1px solid #f1f5f9', fontSize: 12 }}>
|
||||
<span style={{ color: '#64748b' }}>{label}</span>
|
||||
<span style={{ color: '#0f172a', fontWeight: 600, textAlign: 'right' }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ padding: 16, overflowY: 'auto', fontSize: 13, color: '#0f172a' }}>
|
||||
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>DESCRIPTION</div>
|
||||
<div style={{ marginTop: 6, color: '#334155', lineHeight: 1.6, fontSize: 13 }}>{task.body}</div>
|
||||
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<StatChip label="試行" value={`${task.attempts}/3`} />
|
||||
<StatChip label="ピース" value={task.piece} color="#2563eb" />
|
||||
<StatChip label="ワーカー" value={task.worker || '—'} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Row label="リポジトリ" value={<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 11 }}>{task.repo}</span>} />
|
||||
<Row label="ブランチ" value={<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 11 }}>{task.branch}</span>} />
|
||||
<Row label="作成日時" value={new Date(task.createdAt).toLocaleString('ja-JP')} />
|
||||
<Row label="更新日時" value={relativeTime(task.updatedAt)} />
|
||||
<Row label="担当" value={task.assignee} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
|
||||
<button style={{
|
||||
padding: '8px 14px', borderRadius: 10, border: '1px solid #e2e8f0',
|
||||
background: '#fff', color: '#475569', fontSize: 12, fontWeight: 600,
|
||||
cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
|
||||
}}>再試行</button>
|
||||
<button style={{
|
||||
padding: '8px 14px', borderRadius: 10, border: '1px solid #fecaca',
|
||||
background: '#fff', color: '#b91c1c', fontSize: 12, fontWeight: 600,
|
||||
cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
|
||||
}}>キャンセル</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressTab({ task }) {
|
||||
const events = task.events || [];
|
||||
return (
|
||||
<div style={{ padding: 16, overflowY: 'auto' }}>
|
||||
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em', marginBottom: 8 }}>ACTIVITY</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 20 }}>
|
||||
{events.map((e, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 10, fontSize: 12 }}>
|
||||
<div style={{ flexShrink: 0, marginTop: 3 }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: 9999, background: e.kind === 'error' ? '#dc2626' : e.kind === 'ok' ? '#16a34a' : '#3b82f6' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: '#0f172a', fontWeight: 600 }}>{e.label}</div>
|
||||
<div style={{ color: '#64748b', fontSize: 11 }}>{e.meta}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#94a3b8', fontFamily: 'IBM Plex Mono, monospace' }}>{e.time}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em', marginBottom: 6 }}>ACTIVITY.LOG</div>
|
||||
<div style={{
|
||||
background: '#0f172a', color: '#e2e8f0',
|
||||
fontFamily: 'IBM Plex Mono, monospace', fontSize: 11, lineHeight: 1.6,
|
||||
padding: 12, borderRadius: 8, whiteSpace: 'pre', overflowX: 'auto',
|
||||
}}>
|
||||
{`[10:42:18] ` + String.fromCharCode(9432) + ` starting worker for task #` + task.id + `
|
||||
[10:42:19] ` + String.fromCharCode(9432) + ` branch: ` + task.branch + `
|
||||
[10:42:21] ` + String.fromCharCode(9432) + ` piece: ` + task.piece + `
|
||||
[10:42:22] ` + String.fromCharCode(9655) + ` /brainstorm
|
||||
[10:43:04] ` + String.fromCharCode(10003) + ` /plan (12 steps)
|
||||
[10:43:05] ` + String.fromCharCode(9655) + ` /implement
|
||||
[10:44:58] ` + String.fromCharCode(10003) + ` tests passed`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailPanel({ task, onClose }) {
|
||||
const [tab, setTab] = React.useState('overview');
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#f8fafc', borderLeft: '1px solid #e2e8f0' }}>
|
||||
<div style={{
|
||||
flexShrink: 0, padding: '12px 16px', borderBottom: '1px solid #e2e8f0', background: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
|
||||
}}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>DETAIL</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>#{task.id} {task.title}</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{
|
||||
width: 28, height: 28, borderRadius: 8, border: '1px solid #e2e8f0',
|
||||
background: '#fff', color: '#64748b', cursor: 'pointer', display: 'inline-flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}><IconClose width={12} height={12} /></button>
|
||||
</div>
|
||||
<Tabs tab={tab} onTab={setTab} />
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
{tab === 'overview' && <OverviewTab task={task} />}
|
||||
{tab === 'progress' && <ProgressTab task={task} />}
|
||||
{tab === 'subtasks' && (
|
||||
<div style={{ padding: 16, fontSize: 13, color: '#64748b' }}>
|
||||
サブタスクはこのタスクにありません。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.DetailPanel = DetailPanel;
|
||||
@@ -0,0 +1,86 @@
|
||||
// Shared small primitives for the admin UI kit.
|
||||
// Status tone + tiny SVG icons + labels matching the codebase.
|
||||
|
||||
const STATUS_LABELS = {
|
||||
queued: 'Inbox', running: 'Running', waiting_human: 'Waiting',
|
||||
waiting_subtasks: 'Subtasks', retry: 'Retry', succeeded: 'Done',
|
||||
failed: 'Failed', cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
const STATUS_TONE = {
|
||||
running: { bg: '#dcfce7', fg: '#166534' },
|
||||
waiting_human: { bg: '#fef9c3', fg: '#854d0e' },
|
||||
waiting_subtasks: { bg: '#e0e7ff', fg: '#3730a3' },
|
||||
failed: { bg: '#fee2e2', fg: '#b91c1c' },
|
||||
succeeded: { bg: '#dbeafe', fg: '#1e40af' },
|
||||
retry: { bg: '#fef3c7', fg: '#92400e' },
|
||||
queued: { bg: '#e2e8f0', fg: '#475569' },
|
||||
cancelled: { bg: '#e2e8f0', fg: '#475569' },
|
||||
};
|
||||
|
||||
function StatusBadge({ status, small }) {
|
||||
const tone = STATUS_TONE[status] || STATUS_TONE.queued;
|
||||
const label = STATUS_LABELS[status] || status;
|
||||
const style = {
|
||||
background: tone.bg, color: tone.fg,
|
||||
fontSize: small ? 10 : 11, fontWeight: 700,
|
||||
padding: small ? '1px 8px' : '2px 10px', borderRadius: 9999,
|
||||
display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap',
|
||||
};
|
||||
return <span style={style}>{label}</span>;
|
||||
}
|
||||
|
||||
function StatChip({ label, value, color }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
|
||||
padding: '8px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||
minWidth: 0, flex: '1 1 0', minWidth: 80,
|
||||
}}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', letterSpacing: '.06em', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>{label}</div>
|
||||
<div style={{
|
||||
fontSize: 15, fontWeight: 800, color: color || '#0f172a', marginTop: 2,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return <div style={{
|
||||
width: 16, height: 16, border: '2px solid #e2e8f0', borderTopColor: '#2563eb',
|
||||
borderRadius: '9999px', animation: 'ao-spin 1s linear infinite', display: 'inline-block',
|
||||
}} />;
|
||||
}
|
||||
|
||||
function PulseDot() {
|
||||
return <span style={{
|
||||
display: 'inline-block', width: 8, height: 8, background: '#3b82f6',
|
||||
borderRadius: 9999, animation: 'ao-pulse 1.2s ease-in-out infinite',
|
||||
}} />;
|
||||
}
|
||||
|
||||
function IconSearch(props) {
|
||||
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>;
|
||||
}
|
||||
function IconAttach(props) {
|
||||
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>;
|
||||
}
|
||||
function IconClose(props) {
|
||||
return <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...props}><path d="M4 4l8 8M12 4l-8 8"/></svg>;
|
||||
}
|
||||
|
||||
function relativeTime(ms) {
|
||||
const mins = Math.floor((Date.now() - ms) / 60000);
|
||||
if (mins < 1) return 'たった今';
|
||||
if (mins < 60) return `${mins}分前`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}時間前`;
|
||||
return `${Math.floor(hrs / 24)}日前`;
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
STATUS_LABELS, STATUS_TONE,
|
||||
StatusBadge, StatChip, Spinner, PulseDot,
|
||||
IconSearch, IconAttach, IconClose, relativeTime,
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
# Admin Dashboard UI Kit
|
||||
|
||||
High-fidelity recreation of the Agent Orchestrator admin UI (`ui/` in the codebase).
|
||||
|
||||
- `index.html` — interactive 3-column dashboard: task list, chat, detail panel. Click a task to open chat; open detail to see Overview / Progress tabs.
|
||||
- `TopBar.jsx` — top navigation with wordmark, section nav, status counts, primary CTA
|
||||
- `TaskList.jsx` — FilterBar + LocalTaskListItem
|
||||
- `ChatPane.jsx` — header + messages + composer with user / ask / result / progress bubbles
|
||||
- `DetailPanel.jsx` — tabbed detail with Overview + Progress (activity timeline + log surface)
|
||||
- `Primitives.jsx` — StatusBadge, StatChip, tiny SVG icons, spinner, pulse dot
|
||||
|
||||
Cosmetic recreation — no real API. Data is inline sample Japanese task data.
|
||||
@@ -0,0 +1,92 @@
|
||||
// TaskList — FilterBar + LocalTaskListItem recreation
|
||||
function FilterBar({ status, onStatus, search, onSearch, sort, onSort, counts, total }) {
|
||||
const columns = ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retry', 'succeeded', 'failed', 'cancelled'];
|
||||
const chipStyle = (active) => ({
|
||||
flexShrink: 0, padding: '6px 10px', borderRadius: 9999,
|
||||
fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap', cursor: 'pointer',
|
||||
border: '1px solid ' + (active ? '#2563eb' : '#e2e8f0'),
|
||||
background: active ? '#eff6ff' : '#fff',
|
||||
color: active ? '#1d4ed8' : '#64748b',
|
||||
fontFamily: 'inherit',
|
||||
});
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingBottom: 12, borderBottom: '1px solid #e2e8f0' }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0',
|
||||
borderRadius: 12, padding: '6px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||
}}>
|
||||
<IconSearch width={14} height={14} style={{ color: '#94a3b8', flexShrink: 0 }} />
|
||||
<input value={search} onChange={(e) => onSearch(e.target.value)} placeholder="検索..."
|
||||
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: '#0f172a', minWidth: 0 }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
|
||||
<button style={chipStyle(status === 'all')} onClick={() => onStatus('all')}>
|
||||
All <span style={{ color: '#94a3b8', marginLeft: 2 }}>{total}</span>
|
||||
</button>
|
||||
{columns.map(s => (
|
||||
<button key={s} style={chipStyle(status === s)} onClick={() => onStatus(s)}>
|
||||
{STATUS_LABELS[s]} <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts[s] || 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select value={sort} onChange={(e) => onSort(e.target.value)} style={{
|
||||
padding: '6px 10px', fontSize: 12, background: '#fff', border: '1px solid #e2e8f0',
|
||||
borderRadius: 8, color: '#334155', outline: 'none', fontFamily: 'inherit',
|
||||
}}>
|
||||
<option value="updated">新しい順</option>
|
||||
<option value="status">ステータス順</option>
|
||||
<option value="title">タイトル順</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskItem({ task, active, onClick }) {
|
||||
return (
|
||||
<button onClick={onClick} style={{
|
||||
width: '100%', textAlign: 'left', padding: '10px 12px', borderRadius: 12,
|
||||
border: '1px solid ' + (active ? '#3b82f6' : '#e2e8f0'),
|
||||
background: active ? '#eff6ff' : '#fff',
|
||||
cursor: 'pointer', transition: 'background .15s', fontFamily: 'inherit',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
#{task.id} {task.title}
|
||||
</div>
|
||||
<StatusBadge status={task.status} small />
|
||||
</div>
|
||||
<div style={{ marginTop: 2, fontSize: 11, color: '#64748b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{task.body.length > 60 ? task.body.slice(0, 60) + '…' : task.body}
|
||||
</div>
|
||||
<div style={{ marginTop: 2, fontSize: 10, color: '#94a3b8' }}>{relativeTime(task.updatedAt)}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskList({ tasks, activeId, onSelect, filters, setFilters }) {
|
||||
const counts = {};
|
||||
for (const s of ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retry', 'succeeded', 'failed', 'cancelled']) {
|
||||
counts[s] = tasks.filter(t => t.status === s).length;
|
||||
}
|
||||
const filtered = tasks
|
||||
.filter(t => filters.status === 'all' || t.status === filters.status)
|
||||
.filter(t => !filters.search || (t.title + t.body).toLowerCase().includes(filters.search.toLowerCase()))
|
||||
.sort((a, b) => filters.sort === 'title' ? a.title.localeCompare(b.title) : b.updatedAt - a.updatedAt);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<FilterBar
|
||||
status={filters.status} onStatus={(s) => setFilters(f => ({ ...f, status: s }))}
|
||||
search={filters.search} onSearch={(q) => setFilters(f => ({ ...f, search: q }))}
|
||||
sort={filters.sort} onSort={(s) => setFilters(f => ({ ...f, sort: s }))}
|
||||
counts={counts} total={tasks.length}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
||||
{filtered.map(t => <TaskItem key={t.id} task={t} active={activeId === t.id} onClick={() => onSelect(t.id)} />)}
|
||||
{filtered.length === 0 && <div style={{ fontSize: 13, color: '#64748b', padding: '12px 8px' }}>スレッドがありません</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.TaskList = TaskList;
|
||||
@@ -0,0 +1,62 @@
|
||||
// TopBar — mirrors ui/src/components/layout/TopBar.tsx
|
||||
function TopBar({ page, onNavigate, counts, onOpenCreate, user }) {
|
||||
const navItem = (id, label) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => onNavigate(id)}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 500,
|
||||
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: page === id ? '#2563eb' : 'transparent',
|
||||
color: page === id ? '#fff' : '#64748b',
|
||||
transition: 'background .15s',
|
||||
}}
|
||||
>{label}</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
flexShrink: 0, background: '#fff', borderBottom: '1px solid #e2e8f0',
|
||||
padding: '12px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<img src="../../assets/logo.svg" width="22" height="22" alt="" />
|
||||
<span style={{
|
||||
fontFamily: 'IBM Plex Mono, monospace', fontSize: 11, fontWeight: 700,
|
||||
color: '#2563eb', textTransform: 'uppercase', letterSpacing: '.16em',
|
||||
}}>Agent Orchestrator</span>
|
||||
<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 10, color: '#94a3b8' }}>v1.14.0</span>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{navItem('tasks', 'Tasks')}
|
||||
{navItem('schedules', 'Schedules')}
|
||||
{navItem('settings', 'Settings')}
|
||||
{navItem('users', 'Users')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ fontSize: 11, color: '#64748b', display: 'flex', gap: 6 }}>
|
||||
<span><b style={{ color: '#334155' }}>{counts.total}</b> 件</span>
|
||||
<span><b style={{ color: '#16a34a' }}>{counts.running}</b> 実行中</span>
|
||||
<span><b style={{ color: '#d97706' }}>{counts.waiting}</b> 待機</span>
|
||||
{counts.failed > 0 && <span><b style={{ color: '#dc2626' }}>{counts.failed}</b> 失敗</span>}
|
||||
</div>
|
||||
{user && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: 9999, background: '#dbeafe', color: '#1d4ed8',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700,
|
||||
}}>{user.name.charAt(0)}</div>
|
||||
<span style={{ fontSize: 12, color: '#475569' }}>{user.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={onOpenCreate} style={{
|
||||
padding: '8px 16px', background: '#2563eb', color: '#fff', borderRadius: 12,
|
||||
fontSize: 13, fontWeight: 700, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>新しい依頼</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.TopBar = TopBar;
|
||||
@@ -0,0 +1,231 @@
|
||||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Agent Orchestrator — Admin</title>
|
||||
<link rel="stylesheet" href="../../colors_and_type.css">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-app, #f8fafc);
|
||||
color: var(--fg1, #0f172a);
|
||||
font-size: 13px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
@keyframes ao-spin { from { transform: rotate(0) } to { transform: rotate(360deg) } }
|
||||
@keyframes ao-pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.35 } }
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: #cbd5e1; border: 2px solid #f8fafc; border-radius: 10px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="https://unpkg.com/[email protected]/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/[email protected]/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script type="text/babel" src="./Primitives.jsx"></script>
|
||||
<script type="text/babel" src="./TopBar.jsx"></script>
|
||||
<script type="text/babel" src="./TaskList.jsx"></script>
|
||||
<script type="text/babel" src="./ChatPane.jsx"></script>
|
||||
<script type="text/babel" src="./DetailPanel.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
const MIN = 60 * 1000;
|
||||
const H = 60 * MIN;
|
||||
const now = Date.now();
|
||||
|
||||
const SAMPLE_TASKS = [
|
||||
{
|
||||
id: 412, title: 'Xの朝のAIダイジェスト生成',
|
||||
body: '毎朝 7:00 JST にフォロー中のAI関連アカウントの過去24hをサマリし、DMで送信する。Twitter CLIを使用し、1スレッドにまとめること。',
|
||||
status: 'running', piece: 'x-ai-digest', worker: 'worker-03', attempts: 1,
|
||||
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/412-morning-digest',
|
||||
createdAt: now - 2*H, updatedAt: now - 4*MIN,
|
||||
events: [
|
||||
{ kind: 'info', label: '/brainstorm 完了', meta: '12個のアイデアを生成', time: '10:42' },
|
||||
{ kind: 'info', label: '/plan 完了', meta: '12ステップ · 推定 4分', time: '10:43' },
|
||||
{ kind: 'info', label: '/implement 実行中', meta: 'ステップ 8 / 12', time: '10:45' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 411, title: 'Brave Search の CAPTCHA 回避',
|
||||
body: 'noVNC経由でBraveに繰り返しCAPTCHAが発生。ユーザーの介入が必要。',
|
||||
status: 'waiting_human', piece: 'general', worker: 'worker-01', attempts: 2,
|
||||
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/411-brave-captcha',
|
||||
createdAt: now - 3*H, updatedAt: now - 18*MIN,
|
||||
events: [
|
||||
{ kind: 'info', label: '/brainstorm 完了', meta: '', time: '08:10' },
|
||||
{ kind: 'error', label: 'ASK が発行されました', meta: 'CAPTCHAの解決を依頼', time: '08:22' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 410, title: 'GitHub Issue #284 の対応',
|
||||
body: 'scheduler.ts のタイムアウト処理リファクタ。worker-manager.test.ts を更新。',
|
||||
status: 'succeeded', piece: 'general', worker: 'worker-02', attempts: 1,
|
||||
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/410-sched-timeout',
|
||||
createdAt: now - 8*H, updatedAt: now - 2*H,
|
||||
events: [
|
||||
{ kind: 'info', label: '/plan 完了', meta: '7ステップ', time: '02:11' },
|
||||
{ kind: 'ok', label: 'PR 作成', meta: '#284 テスト通過', time: '04:08' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 409, title: 'ブレスト: 社内AIエージェント活用事例',
|
||||
body: '営業部向け、週次の活用アイデアを10件ブレストし、優先順位をつけて提出。',
|
||||
status: 'queued', piece: 'brainstorming', worker: null, attempts: 0,
|
||||
assignee: '@tomoko', repo: 'gitea:corp/ops', branch: '—',
|
||||
createdAt: now - 30*MIN, updatedAt: now - 25*MIN,
|
||||
events: [],
|
||||
},
|
||||
{
|
||||
id: 408, title: '四半期データの集計とグラフ化',
|
||||
body: 'Q3のSNSエンゲージメントを集計し、CSVとPNGで出力。',
|
||||
status: 'waiting_subtasks', piece: 'data-process', worker: 'worker-05', attempts: 1,
|
||||
assignee: '@kenta', repo: 'gitea:corp/analytics', branch: 'task/408-q3-roundup',
|
||||
createdAt: now - 5*H, updatedAt: now - 45*MIN,
|
||||
events: [
|
||||
{ kind: 'info', label: 'サブタスク3件を発行', meta: '#408-1, #408-2, #408-3', time: '09:30' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 407, title: '競合サービスのリサーチ',
|
||||
body: 'エージェント型ワーカー系SaaSを3社分析し、比較表を作成する。',
|
||||
status: 'failed', piece: 'research', worker: 'worker-04', attempts: 3,
|
||||
assignee: '@tomoko', repo: 'gitea:corp/research', branch: 'task/407-competitors',
|
||||
createdAt: now - 26*H, updatedAt: now - 10*H,
|
||||
events: [
|
||||
{ kind: 'error', label: 'タイムアウト', meta: '3回連続で失敗', time: 'yesterday' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 406, title: 'ゲーム実況の告知ツイート生成',
|
||||
body: '今夜のストリーム用の告知ツイートを3案作成。ハッシュタグ付き。',
|
||||
status: 'retry', piece: 'game-tweet-generator', worker: 'worker-06', attempts: 2,
|
||||
assignee: '@daichi', repo: 'gitea:daichi/stream', branch: 'task/406-tweet-gen',
|
||||
createdAt: now - 40*MIN, updatedAt: now - 8*MIN,
|
||||
events: [
|
||||
{ kind: 'error', label: 'レート制限', meta: '60秒後に再試行', time: '10:35' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 405, title: '経費申請書のOCRと仕分け',
|
||||
body: '添付PDFをOCRし、勘定科目ごとに仕分け。',
|
||||
status: 'cancelled', piece: 'office-process', worker: null, attempts: 1,
|
||||
assignee: '@kenta', repo: 'gitea:corp/ops', branch: '—',
|
||||
createdAt: now - 48*H, updatedAt: now - 20*H,
|
||||
events: [],
|
||||
},
|
||||
];
|
||||
|
||||
const INITIAL_MESSAGES = {
|
||||
412: [
|
||||
{ role: 'user', content: '毎朝7:00にAI関連のXアカウントの過去24hをまとめて、DMで送ってほしい。1スレッドで。', footer: '10:41 · @daichi' },
|
||||
{ role: 'assistant', content: '了解。x-ai-digest ピースを使用します。対象アカウント、まとめる観点、文字数制限を確認させてください。' },
|
||||
{ role: 'ask', content: '❓ 以下を確認させてください:\n\n1. 対象アカウントリストはこのリポジトリの accounts.txt で良いですか?\n2. 1ツイートあたりの上限文字数は280でOK?\n3. 日本語メインの要約で良いですか?' },
|
||||
{ role: 'user', content: '1. OK\n2. OK\n3. 日本語で。でも原文が英語なら簡潔な英語の引用も残して。' },
|
||||
{ role: 'progress', content: '/implement — ステップ 8 / 12 (Twitter CLIでタイムライン取得中)' },
|
||||
],
|
||||
411: [
|
||||
{ role: 'user', content: 'Brave Searchで検索結果が取れない。何度もCAPTCHAが出てるっぽい。' },
|
||||
{ role: 'progress', content: 'noVNCセッションを開いて確認中...' },
|
||||
{ role: 'ask', content: '❓ CAPTCHAの解決が必要です。noVNCで手動で解決していただけますか?\n\nsession: https://novnc.internal/412\n\n解決後 `/resume 411` と返信してください。' },
|
||||
],
|
||||
410: [
|
||||
{ role: 'user', content: 'Issue #284 の対応お願い。scheduler.tsのタイムアウト処理が不安定。' },
|
||||
{ role: 'assistant', content: '了解。/brainstorm から始めます。' },
|
||||
{ role: 'result', content: '✅ 完了しました。\n\n- PR: gitea:daichi/agent-orchestrator#291\n- 変更: scheduler.ts, worker-manager.test.ts, worker.test.ts\n- テスト: 42 passed\n\nレビューお願いします。' },
|
||||
],
|
||||
409: [
|
||||
{ role: 'user', content: '営業部向けに今週のエージェント活用ネタを10個ブレストしてほしい。' },
|
||||
{ role: 'assistant', content: '了解。キューに入りました。ワーカーの空きが出次第処理します。' },
|
||||
],
|
||||
408: [
|
||||
{ role: 'user', content: 'Q3のSNSエンゲージメントまとめて、折れ線グラフと棒グラフのPNGで。' },
|
||||
{ role: 'progress', content: 'サブタスク3件の完了を待っています (#408-1, #408-2, #408-3)' },
|
||||
],
|
||||
407: [
|
||||
{ role: 'user', content: '競合3社のリサーチと比較表を作成。' },
|
||||
{ role: 'assistant', content: '3回試行しましたが、外部サイトの読み込みタイムアウトが続いています。' },
|
||||
],
|
||||
406: [
|
||||
{ role: 'user', content: '今夜のストリーム告知を3案、ハッシュタグ付きで。' },
|
||||
{ role: 'progress', content: 'レート制限中 — 60秒後に再試行します' },
|
||||
],
|
||||
405: [
|
||||
{ role: 'user', content: '経費PDFのOCRと仕分け。' },
|
||||
{ role: 'assistant', content: 'キャンセルされました。' },
|
||||
],
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [tasks, setTasks] = React.useState(SAMPLE_TASKS);
|
||||
const [activeId, setActiveId] = React.useState(412);
|
||||
const [detailOpen, setDetailOpen] = React.useState(true);
|
||||
const [messages, setMessages] = React.useState(INITIAL_MESSAGES);
|
||||
const [filters, setFilters] = React.useState({ status: 'all', search: '', sort: 'updated' });
|
||||
const [page, setPage] = React.useState('tasks');
|
||||
|
||||
const active = tasks.find(t => t.id === activeId) || tasks[0];
|
||||
|
||||
const counts = {
|
||||
total: tasks.length,
|
||||
running: tasks.filter(t => t.status === 'running').length,
|
||||
waiting: tasks.filter(t => t.status === 'waiting_human' || t.status === 'waiting_subtasks').length,
|
||||
failed: tasks.filter(t => t.status === 'failed').length,
|
||||
};
|
||||
|
||||
const onSend = (text) => {
|
||||
setMessages(m => ({
|
||||
...m,
|
||||
[activeId]: [...(m[activeId] || []), { role: 'user', content: text, footer: 'たった今 · @daichi' }],
|
||||
}));
|
||||
// fake echo after small delay
|
||||
setTimeout(() => {
|
||||
setMessages(m => ({
|
||||
...m,
|
||||
[activeId]: [...(m[activeId] || []), { role: 'progress', content: 'エージェントが応答を生成中...' }],
|
||||
}));
|
||||
}, 400);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
||||
<TopBar
|
||||
page={page} onNavigate={setPage}
|
||||
counts={counts}
|
||||
onOpenCreate={() => alert('新しい依頼 (モック)')}
|
||||
user={{ name: 'Daichi' }}
|
||||
/>
|
||||
<div style={{
|
||||
flex: 1, minHeight: 0, display: 'grid',
|
||||
gridTemplateColumns: detailOpen ? '320px 1fr 380px' : '320px 1fr',
|
||||
background: '#f1f5f9', gap: 1,
|
||||
}}>
|
||||
<div style={{ background: '#fff', padding: 12, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<TaskList
|
||||
tasks={tasks} activeId={activeId} onSelect={setActiveId}
|
||||
filters={filters} setFilters={setFilters}
|
||||
/>
|
||||
</div>
|
||||
<ChatPane
|
||||
task={active}
|
||||
messages={messages[active.id] || []}
|
||||
onSend={onSend}
|
||||
onOpenDetail={() => setDetailOpen(v => !v)}
|
||||
detailOpen={detailOpen}
|
||||
/>
|
||||
{detailOpen && <DetailPanel task={active} onClose={() => setDetailOpen(false)} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user