feat(kanban): card detail modal and rich agent-run console
- RunEventList: grouped activity timeline. Assistant text becomes chat bubbles (auto-collapsing long messages); tool_start/tool_end pair into entries with spinners and expandable input/result blocks; bevy output rolls into a live console; relative timestamps on a left rail - AgentRunBar: redesigned as a mission console. Live stats header (elapsed time, tool count, events), animated status banner with sweep/glow while running, clearer action bar. All controls preserved (run/steer/stop, diff/merge/bevy) so the human-only merge/complete safety model holds - tailwind.css: vn-flow, vn-sweep, vn-dots, vn-spin keyframes - CardModal: full card overlay (orchestrator, references, tags, comments) - DiffModal: branch-diff review (commits, stat, capped patch) - useOrchestrator: background polling + bevy status sync + ref-counted SSE - KanbanCard: pulsing agent/bevy running badge on collapsed cards
This commit is contained in:
@@ -1,355 +1,163 @@
|
||||
import { useState } from 'react';
|
||||
import type { Card, Column, DocPage, ReferenceType } from '../../lib/kanbanApi';
|
||||
import type { Card, Column, DocPage } from '../../lib/kanbanApi';
|
||||
import type { CustomPage } from '../../lib/pagesApi';
|
||||
import { ReferenceLinks } from './ReferenceLinks';
|
||||
import { AgentRunBar } from './AgentRunBar';
|
||||
import type { UseOrchestrator } from './useOrchestrator';
|
||||
|
||||
const COLUMN_LABELS: Record<Column, string> = {
|
||||
done: 'Done',
|
||||
'in-progress': 'In Progress',
|
||||
todo: 'Todo',
|
||||
backlog: 'Backlog',
|
||||
const COLUMN_DOT: Record<Column, string> = {
|
||||
done: 'var(--green)',
|
||||
'in-progress': 'var(--accent)',
|
||||
todo: 'var(--red)',
|
||||
backlog: 'var(--muted)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact, click-to-open kanban card.
|
||||
*
|
||||
* The full detail (agent orchestration, comments, references, tags) lives in
|
||||
* the CardModal opened by `onOpen`. This card is a dense preview: status, id,
|
||||
* title, description, references, and a prominent live indicator while an agent
|
||||
* or Bevy playtest is running on the card's worktree.
|
||||
*/
|
||||
interface KanbanCardProps {
|
||||
card: Card;
|
||||
pages: DocPage[];
|
||||
customPages: CustomPage[];
|
||||
orch: UseOrchestrator;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onMove: (status: Column) => void;
|
||||
onAddComment: (text: string) => void;
|
||||
onDeleteComment: (commentId: string) => void;
|
||||
onAddTag: (tag: string) => void;
|
||||
onRemoveTag: (tag: string) => void;
|
||||
onAddReference: (ref: { label: string; type: ReferenceType; href: string }) => void;
|
||||
onRemoveReference: (href: string) => void;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
export function KanbanCard(props: KanbanCardProps) {
|
||||
const { card, pages, customPages, orch, expanded, onToggle } = props;
|
||||
const [commentDraft, setCommentDraft] = useState('');
|
||||
const [tagDraft, setTagDraft] = useState('');
|
||||
const [refDraft, setRefDraft] = useState('');
|
||||
const [customRefDraft, setCustomRefDraft] = useState('');
|
||||
|
||||
const hasComments = card.comments.length > 0;
|
||||
const hasTags = card.tags.length > 0;
|
||||
|
||||
// Doc pages not already referenced (for the add-reference picker).
|
||||
const availablePages = pages.filter(
|
||||
(p) => !card.references.some((r) => r.href === p.path),
|
||||
);
|
||||
|
||||
// Custom pages not already referenced (for the add-reference picker).
|
||||
const availableCustomPages = customPages.filter(
|
||||
(p) => !card.references.some((r) => r.href === `/docs/custom/${p.slug}`),
|
||||
);
|
||||
export function KanbanCard({ card, pages, customPages, orch, onOpen }: KanbanCardProps) {
|
||||
const agentRunning = orch.isRunning(card.id);
|
||||
const run = orch.runForCard(card.id);
|
||||
const bevyRunning = Boolean(run && orch.bevyIsRunning(run.id));
|
||||
const active = agentRunning || bevyRunning;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: `1px solid ${expanded ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: 'var(--sp-3)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
boxShadow: expanded ? '0 2px 8px rgba(0,0,0,0.1)' : 'none',
|
||||
...cardStyle,
|
||||
// An active card is unmistakable: accent glow + tinted background +
|
||||
// colored left rail so a running agent is visible at a glance, even
|
||||
// from across the board.
|
||||
...(active
|
||||
? {
|
||||
borderColor: 'var(--accent)',
|
||||
boxShadow: '0 0 0 1px var(--accent), 0 0 14px rgba(240,160,48,0.25)',
|
||||
background: 'rgba(240,160,48,0.04)',
|
||||
}
|
||||
: {}),
|
||||
borderLeft: `3px solid ${COLUMN_DOT[card.status]}`,
|
||||
}}
|
||||
onClick={onToggle}
|
||||
className="hover:shadow-sm"
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
title="Open card details"
|
||||
>
|
||||
{/* Header: id + category + counts */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--sp-2)', marginBottom: 'var(--sp-2)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: 'var(--sp-2)' }}>
|
||||
<span style={badgeStyle}>{card.id}</span>
|
||||
<span style={categoryStyle}>{card.category}</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: '0.7rem', color: 'var(--fg-dim)' }}>
|
||||
{orch.isRunning(card.id) && (
|
||||
<span style={{ color: 'var(--accent)', marginRight: '6px' }}>🤖 running</span>
|
||||
)}
|
||||
{hasComments && `💬 ${card.comments.length} `}
|
||||
{hasTags && `🏷️ ${card.tags.length} `}
|
||||
{card.references.length > 0 && `🔗 ${card.references.length}`}
|
||||
</span>
|
||||
{active && (
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
<RunningBadge agent={agentRunning} bevy={bevyRunning} />
|
||||
</span>
|
||||
)}
|
||||
{!active && (
|
||||
<span style={{ marginLeft: 'auto', fontSize: '0.7rem', color: 'var(--fg-dim)' }}>
|
||||
{card.comments.length > 0 && `💬 ${card.comments.length} `}
|
||||
{card.tags.length > 0 && `🏷️ ${card.tags.length} `}
|
||||
{card.references.length > 0 && `🔗 ${card.references.length}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h4 style={{ margin: '0 0 var(--sp-1) 0', fontSize: '0.9rem', fontWeight: 500, color: 'var(--fg)' }}>
|
||||
{card.title}
|
||||
</h4>
|
||||
<p style={{ margin: '0 0 var(--sp-1) 0', fontSize: '0.8rem', color: 'var(--fg-dim)', lineHeight: 1.4 }}>
|
||||
{card.description}
|
||||
</p>
|
||||
<p style={{ margin: '0 0 var(--sp-2) 0', fontSize: '0.75rem', color: 'var(--fg-dim)', lineHeight: 1.4, fontStyle: 'italic' }}>
|
||||
{card.details}
|
||||
</p>
|
||||
<h4 style={titleStyle}>{card.title}</h4>
|
||||
<p style={descStyle}>{card.description}</p>
|
||||
|
||||
{/* References (compact) */}
|
||||
<div style={{ marginBottom: 'var(--sp-2)' }}>
|
||||
<div style={{ marginTop: 'var(--sp-2)', marginBottom: 'var(--sp-1)' }}>
|
||||
<ReferenceLinks references={card.references} pages={pages} customPages={customPages} compact />
|
||||
</div>
|
||||
|
||||
{/* Footer: files + notes + tags */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sp-1)' }}>
|
||||
{card.files && (
|
||||
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
|
||||
📁 {card.files}
|
||||
</div>
|
||||
)}
|
||||
{card.notes && (
|
||||
<div style={noteStyle}>💡 {card.notes}</div>
|
||||
)}
|
||||
{hasTags && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{card.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onRemoveTag(tag);
|
||||
}}
|
||||
style={tagStyle}
|
||||
title="Click to remove tag"
|
||||
>
|
||||
🏷️ {tag} ×
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded section */}
|
||||
{expanded && (
|
||||
<div style={{ marginTop: 'var(--sp-3)', paddingTop: 'var(--sp-3)', borderTop: '1px solid var(--border)' }}>
|
||||
{/* Agent orchestrator */}
|
||||
<AgentRunBar card={card} orch={orch} />
|
||||
|
||||
{/* Move */}
|
||||
<div style={{ marginBottom: 'var(--sp-3)' }}>
|
||||
<label style={{ fontSize: '0.75rem', color: 'var(--fg-dim)', marginRight: '8px' }}>Move to:</label>
|
||||
<select
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => props.onMove(e.target.value as Column)}
|
||||
value={card.status}
|
||||
style={inputStyle}
|
||||
>
|
||||
{(Object.keys(COLUMN_LABELS) as Column[]).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COLUMN_LABELS[c]}
|
||||
</option>
|
||||
{/* Footer: files + notes + tags (read-only chips here) */}
|
||||
{(card.files || card.notes || card.tags.length > 0) && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginTop: 'var(--sp-1)' }}>
|
||||
{card.files && (
|
||||
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
|
||||
📁 {card.files}
|
||||
</div>
|
||||
)}
|
||||
{card.notes && <div style={noteStyle}>💡 {card.notes}</div>}
|
||||
{card.tags.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{card.tags.map((tag) => (
|
||||
<span key={tag} style={tagStyle}>
|
||||
🏷️ {tag}
|
||||
</span>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Add reference (doc page) */}
|
||||
{availablePages.length > 0 && (
|
||||
<div style={{ marginBottom: 'var(--sp-3)' }}>
|
||||
<label style={{ fontSize: '0.75rem', color: 'var(--fg-dim)', display: 'block', marginBottom: '4px' }}>
|
||||
Add doc reference:
|
||||
</label>
|
||||
<select
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
value={refDraft}
|
||||
onChange={(e) => setRefDraft(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">Select a doc page…</option>
|
||||
{availablePages.map((p) => (
|
||||
<option key={p.path} value={p.path}>
|
||||
{p.icon} {p.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{refDraft && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const page = pages.find((p) => p.path === refDraft);
|
||||
if (page) {
|
||||
props.onAddReference({ label: page.title, type: 'doc', href: page.path });
|
||||
setRefDraft('');
|
||||
}
|
||||
}}
|
||||
style={smallButtonStyle}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add reference (custom page) */}
|
||||
{availableCustomPages.length > 0 && (
|
||||
<div style={{ marginBottom: 'var(--sp-3)' }}>
|
||||
<label style={{ fontSize: '0.75rem', color: 'var(--purple)', display: 'block', marginBottom: '4px' }}>
|
||||
Add custom page reference:
|
||||
</label>
|
||||
<select
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
value={customRefDraft}
|
||||
onChange={(e) => setCustomRefDraft(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">Select a custom page…</option>
|
||||
{availableCustomPages.map((p) => (
|
||||
<option key={p.id} value={p.slug}>
|
||||
{p.icon} {p.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{customRefDraft && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const page = customPages.find((p) => p.slug === customRefDraft);
|
||||
if (page) {
|
||||
props.onAddReference({
|
||||
label: page.title,
|
||||
type: 'custom',
|
||||
href: `/docs/custom/${page.slug}`,
|
||||
});
|
||||
setCustomRefDraft('');
|
||||
}
|
||||
}}
|
||||
style={smallButtonStyle}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full reference list with removal */}
|
||||
<div style={{ marginBottom: 'var(--sp-3)' }}>
|
||||
<label style={{ fontSize: '0.75rem', color: 'var(--fg-dim)', display: 'block', marginBottom: '4px' }}>
|
||||
References
|
||||
</label>
|
||||
<ReferenceLinks
|
||||
references={card.references}
|
||||
pages={pages}
|
||||
customPages={customPages}
|
||||
onRemove={(href) => props.onRemoveReference(href)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add tag */}
|
||||
<div style={{ marginBottom: 'var(--sp-3)', display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add tag…"
|
||||
value={tagDraft}
|
||||
onChange={(e) => setTagDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
if (tagDraft.trim()) {
|
||||
props.onAddTag(tagDraft.trim());
|
||||
setTagDraft('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (tagDraft.trim()) {
|
||||
props.onAddTag(tagDraft.trim());
|
||||
setTagDraft('');
|
||||
}
|
||||
}}
|
||||
style={smallButtonStyle}
|
||||
>
|
||||
Tag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div>
|
||||
<h5 style={{ margin: '0 0 var(--sp-2) 0', fontSize: '0.85rem', color: 'var(--fg)' }}>Comments</h5>
|
||||
{hasComments ? (
|
||||
<div style={{ marginBottom: 'var(--sp-2)', maxHeight: '200px', overflowY: 'auto' }}>
|
||||
{card.comments.map((comment) => (
|
||||
<div key={comment.id} style={commentStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||
<span style={{ fontWeight: 500, color: 'var(--fg)' }}>{comment.author}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onDeleteComment(comment.id);
|
||||
}}
|
||||
title="Delete comment"
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.7rem',
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ color: 'var(--fg-dim)', lineHeight: 1.4, fontSize: '0.8rem' }}>{comment.text}</div>
|
||||
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', marginTop: '4px' }}>
|
||||
{new Date(comment.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--muted)', fontStyle: 'italic' }}>No comments yet</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a comment…"
|
||||
value={commentDraft}
|
||||
onChange={(e) => setCommentDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
if (commentDraft.trim()) {
|
||||
props.onAddComment(commentDraft.trim());
|
||||
setCommentDraft('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (commentDraft.trim()) {
|
||||
props.onAddComment(commentDraft.trim());
|
||||
setCommentDraft('');
|
||||
}
|
||||
}}
|
||||
style={primaryButtonStyle}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- shared styles ---------------------------------------------------------
|
||||
/** Live activity badge: pulses while an agent or Bevy run is in flight. */
|
||||
function RunningBadge({ agent, bevy }: { agent: boolean; bevy: boolean }) {
|
||||
const color = agent ? 'var(--accent)' : 'var(--purple)';
|
||||
const label = agent ? 'Agent' : 'Bevy';
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontSize: '0.62rem',
|
||||
fontWeight: 600,
|
||||
padding: '2px 7px',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
border: `1px solid ${color}`,
|
||||
background: `${color}1a`,
|
||||
color,
|
||||
whiteSpace: 'nowrap',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
<span style={pulseDotStyle(color)} />
|
||||
{label} running
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function pulseDotStyle(color: string): React.CSSProperties {
|
||||
return {
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
boxShadow: `0 0 6px ${color}`,
|
||||
animation: 'vn-pulse 1.1s ease-in-out infinite',
|
||||
};
|
||||
}
|
||||
|
||||
// --- styles ----------------------------------------------------------------
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: 'var(--sp-3)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
};
|
||||
|
||||
const badgeStyle: React.CSSProperties = {
|
||||
fontFamily: 'var(--font-mono)',
|
||||
@@ -368,6 +176,20 @@ const categoryStyle: React.CSSProperties = {
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
};
|
||||
|
||||
const titleStyle: React.CSSProperties = {
|
||||
margin: '0 0 var(--sp-1) 0',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 500,
|
||||
color: 'var(--fg)',
|
||||
};
|
||||
|
||||
const descStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--fg-dim)',
|
||||
lineHeight: 1.4,
|
||||
};
|
||||
|
||||
const noteStyle: React.CSSProperties = {
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--accent)',
|
||||
@@ -383,41 +205,4 @@ const tagStyle: React.CSSProperties = {
|
||||
background: 'rgba(100,150,255,0.1)',
|
||||
border: '1px solid rgba(100,150,255,0.3)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: '4px 8px',
|
||||
fontSize: '0.8rem',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--fg)',
|
||||
};
|
||||
|
||||
const smallButtonStyle: React.CSSProperties = {
|
||||
padding: '4px 12px',
|
||||
fontSize: '0.8rem',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--fg)',
|
||||
};
|
||||
|
||||
const primaryButtonStyle: React.CSSProperties = {
|
||||
padding: '8px 16px',
|
||||
fontSize: '0.8rem',
|
||||
background: 'var(--accent)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--surface)',
|
||||
};
|
||||
|
||||
const commentStyle: React.CSSProperties = {
|
||||
padding: '8px',
|
||||
marginBottom: '8px',
|
||||
background: 'var(--surface-raised)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user