- 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
209 lines
6.3 KiB
TypeScript
209 lines
6.3 KiB
TypeScript
import type { Card, Column, DocPage } from '../../lib/kanbanApi';
|
||
import type { CustomPage } from '../../lib/pagesApi';
|
||
import { ReferenceLinks } from './ReferenceLinks';
|
||
import type { UseOrchestrator } from './useOrchestrator';
|
||
|
||
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;
|
||
onOpen: () => void;
|
||
}
|
||
|
||
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={{
|
||
...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={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: 'center', gap: '6px', marginBottom: 'var(--sp-2)' }}>
|
||
<span style={badgeStyle}>{card.id}</span>
|
||
<span style={categoryStyle}>{card.category}</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={titleStyle}>{card.title}</h4>
|
||
<p style={descStyle}>{card.description}</p>
|
||
|
||
{/* References (compact) */}
|
||
<div style={{ marginTop: 'var(--sp-2)', marginBottom: 'var(--sp-1)' }}>
|
||
<ReferenceLinks references={card.references} pages={pages} customPages={customPages} compact />
|
||
</div>
|
||
|
||
{/* 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>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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)',
|
||
fontSize: '0.65rem',
|
||
color: 'var(--muted)',
|
||
background: 'var(--surface-raised)',
|
||
padding: '2px 6px',
|
||
borderRadius: 'var(--radius-sm)',
|
||
};
|
||
|
||
const categoryStyle: React.CSSProperties = {
|
||
fontSize: '0.7rem',
|
||
color: 'var(--fg-dim)',
|
||
background: 'rgba(100,100,100,0.1)',
|
||
padding: '2px 6px',
|
||
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)',
|
||
padding: '4px 8px',
|
||
background: 'rgba(240,160,48,0.08)',
|
||
borderRadius: 'var(--radius-sm)',
|
||
borderLeft: '2px solid var(--accent)',
|
||
};
|
||
|
||
const tagStyle: React.CSSProperties = {
|
||
fontSize: '0.65rem',
|
||
padding: '2px 6px',
|
||
background: 'rgba(100,150,255,0.1)',
|
||
border: '1px solid rgba(100,150,255,0.3)',
|
||
borderRadius: 'var(--radius-sm)',
|
||
};
|