feat(kanban): merge-all branches + floating running-agents dock

Two board-level additions:

Merge all reviewable agent branches into the main branch sequentially:
- worktrees.mergeBranches() merges candidates in order, advancing HEAD each
  time; refuses if the main tree is dirty, and on the first conflict aborts
  that merge (main left clean) and stops — prior merges stay, untried
  branches reported as unattempted.
- runManager.mergeAll() picks candidates (settled runs that own their
  worktree, branch present, not yet in HEAD; deduped, oldest-first) — so
  refinement runs (which inherit a worktree) are correctly excluded.
- POST /runs/merge-all; ⬇ Merge all button in the board header opens a
  results modal (per-branch ✓/⇢/⚠, conflict banner, inline git output).

Floating dock to view/open running agents (front-end only):
- RunningAgentsBar pins bottom-right (below the card modal), lists every
  running run with live elapsed times, and opens that card's modal on click;
  auto-hides when nothing runs, collapsible otherwise.
EOF && echo "" && git log --oneline -4
This commit is contained in:
2026-06-17 21:04:51 -04:00
parent 408bdb6dd7
commit d538ccdd4e
8 changed files with 636 additions and 1 deletions

View File

@@ -0,0 +1,170 @@
import { useEffect, useState } from 'react';
import type { BatchMergeResult } from '../../lib/orchestratorApi';
/**
* Results overlay for a "merge all" batch: shows each branch's outcome, which
* one (if any) conflicted and halted the batch, and which were left unattempted.
*
* A success means the merge landed; `alreadyMerged` is a no-op; `ok:false` is a
* conflict that stopped the run. Branches after the conflict are simply absent
* from `items` (the server didn't attempt them) — surfaced via the `haltedOn`
* banner so the operator knows to resolve and re-run.
*/
interface MergeAllModalProps {
result: BatchMergeResult | null;
busy: boolean;
onClose: () => void;
}
export function MergeAllModal({ result, busy, onClose }: MergeAllModalProps) {
// Close on Escape.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div style={overlayStyle} onClick={onClose}>
<div
className="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-2xl border border-border bg-surface p-6 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center gap-2">
<h2 className="m-0 text-lg text-fg-bright">Merge all agent branches</h2>
<button
className="ml-auto cursor-pointer rounded border border-border bg-transparent px-2 py-0.5 text-muted hover:text-fg"
onClick={onClose}
>
</button>
</div>
{busy && !result && (
<p className="m-0 text-[0.85rem] text-fg-dim">Merging branches sequentially</p>
)}
{result && (
<>
<p className="m-0 mb-3 text-[0.82rem] text-fg-dim">
Target branch: <code className="font-mono text-cyan">{result.target}</code>
{' · '}
{result.items.filter((i) => i.ok && !i.alreadyMerged).length} merged
{result.items.some((i) => i.alreadyMerged) &&
` · ${result.items.filter((i) => i.alreadyMerged).length} already in`}
{result.haltedOn && ' · halted on conflict'}
</p>
{result.aborted && result.reason && (
<div style={bannerStyle('var(--red)')}> {result.reason}</div>
)}
{result.haltedOn && (
<div style={bannerStyle('var(--red)')}>
Conflict merging <code style={monoStyle}>{result.haltedOn}</code> batch stopped,{' '}
{result.target} left clean. Resolve or drop that branch, then re-run.
</div>
)}
{!result.aborted && !result.haltedOn && result.items.length > 0 && (
<div style={bannerStyle('var(--green)')}>
All reviewable branches merged into {result.target}.
</div>
)}
{result.items.length === 0 && !result.aborted && (
<p className="m-0 text-[0.85rem] italic text-muted">
No branches needed merging everything is already in {result.target}.
</p>
)}
{result.items.length > 0 && (
<div className="flex flex-col gap-2">
{result.items.map((item) => {
const tone =
item.alreadyMerged ? 'var(--muted)' : item.ok ? 'var(--green)' : 'var(--red)';
return (
<div
key={item.runId}
style={{
border: `1px solid ${tone}33`,
background: `${tone}0f`,
borderRadius: 'var(--radius-sm)',
padding: '8px 10px',
}}
>
<div className="flex items-center gap-2">
<span style={{ color: tone, fontWeight: 600, fontSize: '0.8rem' }}>
{item.alreadyMerged ? '⇢' : item.ok ? '✓' : '⚠'}
</span>
<code style={monoStyle}>{item.branch}</code>
<span className="text-[0.7rem] text-muted">
{item.alreadyMerged ? 'already in target' : item.ok ? 'merged' : 'conflict'}
</span>
</div>
{!item.ok && item.output && (
<pre
className="m-1 mt-2 overflow-x-auto text-[0.66rem] text-fg-dim"
style={preStyle}
>
{item.output}
</pre>
)}
</div>
);
})}
</div>
)}
</>
)}
</div>
</div>
);
}
// --- styles ----------------------------------------------------------------
const overlayStyle: React.CSSProperties = {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(2px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 60,
padding: 'var(--sp-4)',
};
function bannerStyle(color: string): React.CSSProperties {
return {
padding: '8px 12px',
marginBottom: 'var(--sp-3)',
border: `1px solid ${color}33`,
background: `${color}0f`,
borderRadius: 'var(--radius-sm)',
borderLeft: `2px solid ${color}`,
color: 'var(--fg-dim)',
fontSize: '0.8rem',
lineHeight: 1.5,
};
}
const monoStyle: React.CSSProperties = {
fontFamily: 'var(--font-mono)',
fontSize: '0.74rem',
color: 'var(--fg)',
};
const preStyle: React.CSSProperties = {
background: 'var(--bg)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
padding: '6px 8px',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: '140px',
overflowY: 'auto',
};

View File

@@ -0,0 +1,279 @@
import { memo, useEffect, useState } from 'react';
import type { AgentRun } from '../../lib/orchestratorApi';
import type { Card } from '../../lib/kanbanApi';
/**
* Floating "active agents" dock for the implementation board.
*
* Pins to the bottom-right and lists every run currently in flight, each a
* one-click jump to that card's detail modal. Lives below the card modal
* (z-index 40 < 50): when a modal is open you're focused on one card; the dock
* is for the common case of scanning the board while agents work in the
* background. Reads only from the registry (`runs`) + the board's card map, so
* it needs no backend changes. Re-renders are isolated: a 1 s tick for live
* elapsed times lives inside this component, never the board page.
*/
interface RunningAgentsBarProps {
/** Full run list from the registry; only `running` entries are shown. */
runs: AgentRun[];
/** Card lookup for titles/categories (empty until the board loads — ids still show). */
cardById: Map<string, Card>;
/** Open a card's detail modal. */
onOpenCard: (cardId: string) => void;
}
/** Tick once a second while anything is active, for live elapsed times. */
function useNow(active: boolean): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!active) return;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [active]);
return now;
}
function fmtElapsed(startedAtIso: string | null, now: number): string {
if (!startedAtIso) return '';
const start = Date.parse(startedAtIso);
if (!Number.isFinite(start)) return '';
const s = Math.max(0, Math.floor((now - start) / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`;
const h = Math.floor(m / 60);
return `${h}h ${String(m % 60).padStart(2, '0')}m`;
}
/** Shorten `agent/u1-bec600e2` → `u1-bec600e2`. */
function shortBranch(branch: string): string {
const idx = branch.lastIndexOf('/');
return idx >= 0 ? branch.slice(idx + 1) : branch;
}
export const RunningAgentsBar = memo(function RunningAgentsBar({
runs,
cardById,
onOpenCard,
}: RunningAgentsBarProps) {
const active = runs.filter((r) => r.status === 'running');
const [collapsed, setCollapsed] = useState(false);
const [hovered, setHovered] = useState<string | null>(null);
const now = useNow(active.length > 0);
if (active.length === 0) return null;
const count = active.length;
return (
// Wrapper is non-interactive so the empty space around the dock never
// swallows board clicks; children opt back into pointer events.
<div style={{ ...wrapStyle, pointerEvents: 'none' }}>
{!collapsed && (
<div style={panelStyle}>
<div style={panelHeaderStyle}>
<span style={liveDotStyle} />
<span style={headerTextStyle}>
{count} agent{count === 1 ? '' : 's'} running
</span>
</div>
<div style={listStyle}>
{active.map((r) => {
const card = cardById.get(r.cardId);
const isHovered = hovered === r.id;
return (
<button
key={r.id}
type="button"
onClick={() => onOpenCard(r.cardId)}
onMouseEnter={() => setHovered(r.id)}
onMouseLeave={() => setHovered(null)}
style={{
...rowStyle,
background: isHovered ? 'var(--surface-raised)' : 'transparent',
borderColor: isHovered ? 'var(--accent)' : 'var(--border)',
}}
title={`Open ${card?.title ?? r.cardId}`}
>
<span style={rowDotStyle} />
<span style={{ minWidth: 0, flex: 1 }}>
<span style={titleStyle}>{card?.title ?? r.cardId}</span>
<span style={subStyle}>
<span style={idStyle}>{r.cardId}</span>
{r.branch && (
<span style={branchStyle}> {shortBranch(r.branch)}</span>
)}
</span>
</span>
<span style={elapsedStyle}>{fmtElapsed(r.startedAt, now)}</span>
<span style={{ ...openGlyphStyle, opacity: isHovered ? 1 : 0.4 }}></span>
</button>
);
})}
</div>
</div>
)}
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
style={toggleStyle}
title={collapsed ? 'Show running agents' : 'Hide running agents'}
>
<span style={liveDotStyle} />
<span>{count} running</span>
<span style={{ marginLeft: '2px', opacity: 0.7 }}>{collapsed ? '▲' : '▼'}</span>
</button>
</div>
);
});
// --- styles ----------------------------------------------------------------
const wrapStyle: React.CSSProperties = {
position: 'fixed',
right: 'var(--sp-4)',
bottom: 'var(--sp-4)',
zIndex: 40, // below the card modal (50) so an open modal stays focused
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: 'var(--sp-2)',
maxWidth: '320px',
};
const panelStyle: React.CSSProperties = {
pointerEvents: 'auto',
width: '300px',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
boxShadow: '0 8px 28px rgba(0,0,0,0.35)',
overflow: 'hidden',
};
const panelHeaderStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 10px',
borderBottom: '1px solid var(--border)',
background: 'var(--surface-raised)',
};
const headerTextStyle: React.CSSProperties = {
fontSize: '0.72rem',
fontWeight: 600,
color: 'var(--fg)',
letterSpacing: '0.02em',
};
const listStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
maxHeight: '264px',
overflowY: 'auto',
};
const rowStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: '8px',
width: '100%',
padding: '8px 10px',
textAlign: 'left',
cursor: 'pointer',
borderTop: 'none',
borderLeft: 'none',
borderRight: 'none',
borderBottom: '1px solid var(--border)',
borderRadius: 0,
transition: 'background 0.12s ease, border-color 0.12s ease',
};
const rowDotStyle: React.CSSProperties = {
width: '7px',
height: '7px',
borderRadius: '50%',
background: 'var(--accent)',
boxShadow: '0 0 6px var(--accent)',
animation: 'vn-pulse 1.1s ease-in-out infinite',
flexShrink: 0,
};
const titleStyle: React.CSSProperties = {
display: 'block',
fontSize: '0.8rem',
fontWeight: 500,
color: 'var(--fg)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
};
const subStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: '6px',
marginTop: '2px',
};
const idStyle: React.CSSProperties = {
fontFamily: 'var(--font-mono)',
fontSize: '0.62rem',
color: 'var(--muted)',
background: 'var(--bg)',
padding: '1px 5px',
borderRadius: 'var(--radius-sm)',
};
const branchStyle: React.CSSProperties = {
fontFamily: 'var(--font-mono)',
fontSize: '0.6rem',
color: 'var(--fg-dim)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
};
const elapsedStyle: React.CSSProperties = {
fontFamily: 'var(--font-mono)',
fontSize: '0.66rem',
color: 'var(--accent)',
flexShrink: 0,
fontVariantNumeric: 'tabular-nums',
};
const openGlyphStyle: React.CSSProperties = {
fontSize: '0.7rem',
color: 'var(--fg-dim)',
flexShrink: 0,
};
const toggleStyle: React.CSSProperties = {
pointerEvents: 'auto',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
fontSize: '0.72rem',
fontFamily: 'var(--font-mono)',
color: 'var(--accent)',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-pill)',
cursor: 'pointer',
boxShadow: '0 4px 14px rgba(0,0,0,0.3)',
whiteSpace: 'nowrap',
};
const liveDotStyle: React.CSSProperties = {
width: '8px',
height: '8px',
borderRadius: '50%',
background: 'var(--accent)',
boxShadow: '0 0 6px var(--accent)',
animation: 'vn-pulse 1.1s ease-in-out infinite',
flexShrink: 0,
};

View File

@@ -4,6 +4,7 @@ import {
type AgentRun,
type DiffResult,
type MergeResult,
type BatchMergeResult,
type RunStatus,
} from '../../lib/orchestratorApi';
@@ -49,6 +50,8 @@ export interface UseOrchestrator {
getDiff: (runId: string) => Promise<DiffResult>;
/** Merge a run's branch into the main worktree. */
mergeRun: (runId: string) => Promise<MergeResult>;
/** Merge all reviewable agent branches into the main branch sequentially. */
mergeAll: () => Promise<BatchMergeResult>;
/** Start a Bevy playtest in a run's worktree. */
startBevy: (runId: string) => Promise<void>;
/** Stop a run's Bevy playtest. */
@@ -196,6 +199,7 @@ export function useOrchestrator(): UseOrchestrator {
const getDiff = useCallback((runId: string) => orchestratorApi.getDiff(runId), []);
const mergeRun = useCallback((runId: string) => orchestratorApi.mergeRun(runId), []);
const mergeAll = useCallback(() => orchestratorApi.mergeAll(), []);
const startBevy = useCallback(async (runId: string) => {
await orchestratorApi.startBevy(runId);
@@ -260,6 +264,7 @@ export function useOrchestrator(): UseOrchestrator {
reflectBevy,
getDiff,
mergeRun,
mergeAll,
startBevy,
stopBevy,
bevyIsRunning,
@@ -281,6 +286,7 @@ export function useOrchestrator(): UseOrchestrator {
reflectBevy,
getDiff,
mergeRun,
mergeAll,
startBevy,
stopBevy,
bevyIsRunning,

View File

@@ -113,6 +113,25 @@ export interface MergeResult {
output: string;
}
/** One branch's outcome in a batch (merge-all) merge. */
export interface BatchMergeItem extends MergeResult {
/** The run this branch belonged to. */
runId: string;
}
/** Result of merging several branches sequentially into the main branch. */
export interface BatchMergeResult {
target: string;
/** One entry per candidate branch, in the order processed. */
items: BatchMergeItem[];
/** The branch that conflicted and stopped the batch, if any. */
haltedOn: string | null;
/** True if the main worktree was dirty and nothing was attempted. */
aborted: boolean;
/** Human-readable reason when aborted or halted. */
reason: string | null;
}
export const orchestratorApi = {
listRuns: (cardId?: string) =>
req<{ runs: AgentRun[] }>(`/runs${cardId ? `?cardId=${encodeURIComponent(cardId)}` : ''}`),
@@ -141,6 +160,9 @@ export const orchestratorApi = {
mergeRun: (id: string) =>
req<MergeResult>(`/runs/${id}/merge`, { method: 'POST' }),
/** Merge all reviewable agent branches into the main branch sequentially. */
mergeAll: () => req<BatchMergeResult>('/runs/merge-all', { method: 'POST' }),
bevyStatus: (id: string) => req<{ running: boolean }>(`/runs/${id}/bevy`),
startBevy: (id: string) => req<{ ok: boolean }>(`/runs/${id}/bevy`, { method: 'POST' }),

View File

@@ -3,9 +3,12 @@ import { Link } from 'react-router-dom';
import { useKanbanBoard } from '../../components/kanban/useKanbanBoard';
import { KanbanCard } from '../../components/kanban/KanbanCard';
import { CardModal } from '../../components/kanban/CardModal';
import { RunningAgentsBar } from '../../components/kanban/RunningAgentsBar';
import { MergeAllModal } from '../../components/kanban/MergeAllModal';
import { useOrchestrator } from '../../components/kanban/useOrchestrator';
import { useCustomPages } from '../../lib/customPagesStore';
import type { Card, Column } from '../../lib/kanbanApi';
import type { BatchMergeResult } from '../../lib/orchestratorApi';
const COLUMNS: { key: Column; title: string; color: string; bgColor: string }[] = [
{ key: 'done', title: 'DONE', color: 'var(--green)', bgColor: 'rgba(34,197,94,0.05)' },
@@ -46,11 +49,35 @@ export function KanbanBoardPage() {
const [openCardId, setOpenCardId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const [mergeAllResult, setMergeAllResult] = useState<BatchMergeResult | null>(null);
const [mergeAllBusy, setMergeAllBusy] = useState(false);
// Stable identity across renders — memoized cards receive a referentially
// stable `onOpen` so they don't re-render just because the parent did.
const openById = useCallback((id: string) => setOpenCardId(id), []);
/** Sequentially merge every reviewable agent branch into the main branch. */
const mergeAll = async () => {
setMergeAllBusy(true);
try {
const result = await orch.mergeAll();
setMergeAllResult(result);
// Branches that landed change the board's underlying git state; reload so
// diffs/merge state reflect reality.
await reload();
} catch (e) {
setMergeAllResult({
target: '?',
items: [],
haltedOn: null,
aborted: true,
reason: e instanceof Error ? e.message : 'merge-all failed',
});
} finally {
setMergeAllBusy(false);
}
};
// The card whose detail modal is open (null when closed).
const openCard = board?.cards.find((c) => c.id === openCardId) ?? null;
@@ -78,6 +105,14 @@ export function KanbanBoardPage() {
return groups;
}, [board]);
// Card-id index for the floating active-agents dock (titles/categories). Built
// from the board so it's stable between unrelated renders.
const cardById = useMemo(() => {
const m = new Map<string, Card>();
if (board) for (const c of board.cards) m.set(c.id, c);
return m;
}, [board]);
const counts = useMemo(() => {
const c = { done: 0, 'in-progress': 0, todo: 0, backlog: 0, total: 0 };
if (board) {
@@ -113,6 +148,15 @@ export function KanbanBoardPage() {
<button type="button" onClick={() => void reload()} style={headerButtonStyle}>
Reload
</button>
<button
type="button"
onClick={() => void mergeAll()}
disabled={mergeAllBusy}
title="Merge every reviewable agent branch into the main branch"
style={headerButtonStyle}
>
{mergeAllBusy ? 'Merging…' : '⬇ Merge all'}
</button>
</div>
</div>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.95rem', maxWidth: '720px' }}>
@@ -253,6 +297,18 @@ export function KanbanBoardPage() {
onRemoveReference={(href) => void removeReference(openCard.id, href)}
/>
)}
{/* Floating dock: jump to any agent currently running. */}
<RunningAgentsBar runs={orch.runs} cardById={cardById} onOpenCard={openById} />
{/* Batch-merge results overlay. */}
{mergeAllResult && (
<MergeAllModal
result={mergeAllResult}
busy={mergeAllBusy}
onClose={() => setMergeAllResult(null)}
/>
)}
</div>
);
}