feat(kanban): persist board, reference pages, and run agents

Replace the localStorage kanban with the backend-backed board, add typed clients
and a React hook with optimistic updates. Cards can reference static doc pages
and user-created custom pages (new "custom" reference type with purple chips).

Add the agentic orchestrator UI: a per-card panel to launch `pi` runs, watch a
live tool/thought stream over SSE, steer mid-run, and stop — while the board
stays fully interactive. The board page wires the orchestrator and custom-pages
stores into every card.
This commit is contained in:
2026-06-16 15:44:29 -04:00
parent c24a6106bf
commit a3c72bb878
8 changed files with 1906 additions and 908 deletions

View File

@@ -0,0 +1,423 @@
import { useState } from 'react';
import type { Card, Column, DocPage, ReferenceType } 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',
};
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;
}
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}`),
);
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',
}}
onClick={onToggle}
className="hover:shadow-sm"
>
{/* Header: id + category + counts */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--sp-2)', 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>
</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>
{/* References (compact) */}
<div style={{ marginBottom: 'var(--sp-2)' }}>
<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>
))}
</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 ---------------------------------------------------------
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 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)',
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)',
};