feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
// 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: '#f1f5f9', color: '#0f172a', 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, disabled }) {
|
||||
const [text, setText] = React.useState('');
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [error, setError] = React.useState(null);
|
||||
const send = async () => {
|
||||
if (!text.trim() || disabled || sending) return;
|
||||
setError(null); setSending(true);
|
||||
try {
|
||||
await Promise.resolve(onSend(text.trim()));
|
||||
setText('');
|
||||
} catch (e) {
|
||||
setError(e?.message || '送信に失敗しました');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div style={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', background: '#fff', padding: 12 }}>
|
||||
{error && (
|
||||
<div style={{
|
||||
marginBottom: 8, padding: '8px 10px', background: '#fef2f2', border: '1px solid #fecaca',
|
||||
color: '#b91c1c', borderRadius: 8, fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
|
||||
}}>
|
||||
<span>⚠ {error}</span>
|
||||
<button onClick={send} style={{
|
||||
padding: '2px 8px', borderRadius: 6, border: '1px solid #fecaca',
|
||||
background: '#fff', color: '#b91c1c', fontSize: 11, fontWeight: 700,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>再送信</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-end', gap: 8, background: '#f8fafc',
|
||||
border: '1px solid #e2e8f0', borderRadius: 12, padding: 8,
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
}}>
|
||||
<button style={{ padding: 6, background: 'transparent', border: 'none', color: '#94a3b8', cursor: 'pointer' }}>
|
||||
<IconAttach width={16} height={16} />
|
||||
</button>
|
||||
<textarea
|
||||
value={text}
|
||||
disabled={disabled || sending}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); send(); } }}
|
||||
rows={2}
|
||||
placeholder={disabled ? '送信できません' : 'メッセージを入力 (⌘+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} disabled={disabled || sending || !text.trim()} style={{
|
||||
padding: '6px 14px', background: '#2563eb', color: '#fff', borderRadius: 8,
|
||||
fontSize: 12, fontWeight: 700, border: 'none',
|
||||
cursor: (disabled || sending || !text.trim()) ? 'not-allowed' : 'pointer',
|
||||
opacity: (disabled || sending || !text.trim()) ? 0.5 : 1, fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
{sending && <Spinner />}
|
||||
{sending ? '送信中' : '送信'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#94a3b8', paddingLeft: 4 }}>エージェントは常に /brainstorm → /plan → /implement のパイプラインで動作します。</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatPane({ task, messages, onSend, onOpenDetail, detailOpen, loading, onOpenCreate }) {
|
||||
const scrollRef = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}, [messages.length, task && task.id]);
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#fff', justifyContent: 'center' }}>
|
||||
<EmptyState
|
||||
icon={<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>}
|
||||
title="タスクを選択してください"
|
||||
hint="左のリストから会話を開くか、新しい依頼を作成できます。"
|
||||
action={onOpenCreate && (
|
||||
<button onClick={onOpenCreate} style={{
|
||||
padding: '8px 14px', borderRadius: 10, fontSize: 12, fontWeight: 700,
|
||||
background: '#2563eb', color: '#fff', border: 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
新しい依頼
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#fff' }}>
|
||||
<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,
|
||||
}}>
|
||||
{loading && (
|
||||
<>
|
||||
<div style={{ alignSelf: 'flex-end', width: '60%' }}><SkeletonLine height={40} style={{ borderRadius: 16 }} /></div>
|
||||
<div style={{ alignSelf: 'flex-start', width: '70%' }}><SkeletonLine height={56} style={{ borderRadius: 16 }} /></div>
|
||||
<div style={{ alignSelf: 'flex-start', width: '40%' }}><SkeletonLine height={32} style={{ borderRadius: 12 }} /></div>
|
||||
</>
|
||||
)}
|
||||
{!loading && messages.length === 0 && (
|
||||
<EmptyState
|
||||
compact
|
||||
title="まだメッセージがありません"
|
||||
hint="下の入力欄から依頼の詳細を送信してください。エージェントが /brainstorm から開始します。"
|
||||
/>
|
||||
)}
|
||||
{!loading && 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} disabled={task.status === 'cancelled' || task.status === 'succeeded'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.ChatPane = ChatPane;
|
||||
Reference in New Issue
Block a user