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:
@@ -15,7 +15,7 @@ import { newToken, REPO_ROOT } from './config.js';
|
|||||||
import { buildPrompt } from './prompt.js';
|
import { buildPrompt } from './prompt.js';
|
||||||
import { Runner, type PersistedEvent } from './runner.js';
|
import { Runner, type PersistedEvent } from './runner.js';
|
||||||
import { BevyProcess } from './bevy.js';
|
import { BevyProcess } from './bevy.js';
|
||||||
import { createWorktree, dirtySummary, headSha, isWorktreePresent, removeWorktree, type Worktree } from './worktrees.js';
|
import { createWorktree, dirtySummary, headSha, isWorktreePresent, isMergedIntoHead, mergeBranches, removeWorktree, type BatchMergeResult, type Worktree } from './worktrees.js';
|
||||||
|
|
||||||
export interface StartRunInput {
|
export interface StartRunInput {
|
||||||
cardId: string;
|
cardId: string;
|
||||||
@@ -267,6 +267,40 @@ class RunManager {
|
|||||||
return rows.map((r) => this.reconcile(hydrateRun(r)) as AgentRun);
|
return rows.map((r) => this.reconcile(hydrateRun(r)) as AgentRun);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge all reviewable agent branches into the main branch sequentially.
|
||||||
|
*
|
||||||
|
* Candidates are settled runs that own their worktree and have commits not
|
||||||
|
* yet in HEAD, oldest first (so merges land in roughly the order the runs
|
||||||
|
* happened). De-dups by branch and skips anything already merged. The actual
|
||||||
|
* sequential merge + conflict handling is in `worktrees.mergeBranches`.
|
||||||
|
*/
|
||||||
|
mergeAll(): BatchMergeResult {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM agent_runs
|
||||||
|
WHERE status IN ('completed','failed','stopped')
|
||||||
|
AND owns_worktree = 1
|
||||||
|
AND branch IS NOT NULL
|
||||||
|
ORDER BY created_at ASC`,
|
||||||
|
)
|
||||||
|
.all() as AgentRunRow[];
|
||||||
|
|
||||||
|
const seenBranch = new Set<string>();
|
||||||
|
const candidates: { branch: string; runId: string }[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const branch = row.branch!;
|
||||||
|
if (seenBranch.has(branch)) continue; // dedup (e.g. multiple runs same branch)
|
||||||
|
seenBranch.add(branch);
|
||||||
|
// Skip branches already fully in HEAD — nothing to merge, and including
|
||||||
|
// them would let an unrelated failure mask that they're done.
|
||||||
|
if (isMergedIntoHead(branch)) continue;
|
||||||
|
candidates.push({ branch, runId: row.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeBranches(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
/** Replayable event history for a run, optionally after a sequence number. */
|
/** Replayable event history for a run, optionally after a sequence number. */
|
||||||
events(id: string, sinceSeq = 0): AgentRunEvent[] {
|
events(id: string, sinceSeq = 0): AgentRunEvent[] {
|
||||||
const rows = db
|
const rows = db
|
||||||
|
|||||||
@@ -177,6 +177,71 @@ export interface MergeResult {
|
|||||||
output: string;
|
output: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Outcome of a single branch in a batch merge. */
|
||||||
|
export interface BatchMergeItem extends MergeResult {
|
||||||
|
/** The run this branch belonged to (for UI correlation). */
|
||||||
|
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. Branches after
|
||||||
|
* it were not attempted. */
|
||||||
|
haltedOn: string | null;
|
||||||
|
/** True if the main worktree was dirty and nothing was attempted. */
|
||||||
|
aborted: boolean;
|
||||||
|
/** Human-readable reason when `aborted` or `haltedOn`. */
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge multiple agent branches into the main worktree's checked-out branch,
|
||||||
|
* sequentially. Each successful merge advances HEAD, so later branches merge
|
||||||
|
* against the accumulated result.
|
||||||
|
*
|
||||||
|
* Safety: refuses outright if the main tree is dirty. On the first conflict,
|
||||||
|
* aborts that merge (leaving the main tree clean), records it as `haltedOn`,
|
||||||
|
* and stops — branches after it are NOT attempted, and prior successful merges
|
||||||
|
* stay in place. The caller should surface `haltedOn` + remaining branches so
|
||||||
|
* the operator can resolve or re-run after fixing the conflict.
|
||||||
|
*
|
||||||
|
* @param candidates branches to merge, in order, each tagged with its run id.
|
||||||
|
* Already-merged branches are reported as no-ops and skipped (never halt).
|
||||||
|
*/
|
||||||
|
export function mergeBranches(candidates: { branch: string; runId: string }[]): BatchMergeResult {
|
||||||
|
const target = currentBranch();
|
||||||
|
const dirty = mainDirtySummary();
|
||||||
|
if (dirty) {
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
items: [],
|
||||||
|
haltedOn: null,
|
||||||
|
aborted: true,
|
||||||
|
reason: `Cannot merge: ${dirty}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: BatchMergeItem[] = [];
|
||||||
|
let haltedOn: string | null = null;
|
||||||
|
let reason: string | null = null;
|
||||||
|
|
||||||
|
for (const { branch, runId } of candidates) {
|
||||||
|
const result = mergeBranch(branch);
|
||||||
|
items.push({ ...result, runId });
|
||||||
|
if (!result.ok) {
|
||||||
|
// A conflict aborts the batch: stop here, leaving earlier merges in place.
|
||||||
|
haltedOn = branch;
|
||||||
|
reason = `Conflict merging ${branch} — aborted, ${target} left clean.`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { target, items, haltedOn, aborted: false, reason };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge an agent branch into the main worktree's checked-out branch.
|
* Merge an agent branch into the main worktree's checked-out branch.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ orchestrator.post('/runs/:id/merge', (c) => {
|
|||||||
return c.json(mergeBranch(run.branch));
|
return c.json(mergeBranch(run.branch));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Merge all reviewable agent branches into the main branch sequentially. */
|
||||||
|
orchestrator.post('/runs/merge-all', (c) => c.json(runManager.mergeAll()));
|
||||||
|
|
||||||
/** Whether a Bevy playtest is running for a run. */
|
/** Whether a Bevy playtest is running for a run. */
|
||||||
orchestrator.get('/runs/:id/bevy', (c) => {
|
orchestrator.get('/runs/:id/bevy', (c) => {
|
||||||
const run = runManager.get(c.req.param('id'));
|
const run = runManager.get(c.req.param('id'));
|
||||||
|
|||||||
170
apps/docs/src/components/kanban/MergeAllModal.tsx
Normal file
170
apps/docs/src/components/kanban/MergeAllModal.tsx
Normal 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',
|
||||||
|
};
|
||||||
279
apps/docs/src/components/kanban/RunningAgentsBar.tsx
Normal file
279
apps/docs/src/components/kanban/RunningAgentsBar.tsx
Normal 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,
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type AgentRun,
|
type AgentRun,
|
||||||
type DiffResult,
|
type DiffResult,
|
||||||
type MergeResult,
|
type MergeResult,
|
||||||
|
type BatchMergeResult,
|
||||||
type RunStatus,
|
type RunStatus,
|
||||||
} from '../../lib/orchestratorApi';
|
} from '../../lib/orchestratorApi';
|
||||||
|
|
||||||
@@ -49,6 +50,8 @@ export interface UseOrchestrator {
|
|||||||
getDiff: (runId: string) => Promise<DiffResult>;
|
getDiff: (runId: string) => Promise<DiffResult>;
|
||||||
/** Merge a run's branch into the main worktree. */
|
/** Merge a run's branch into the main worktree. */
|
||||||
mergeRun: (runId: string) => Promise<MergeResult>;
|
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. */
|
/** Start a Bevy playtest in a run's worktree. */
|
||||||
startBevy: (runId: string) => Promise<void>;
|
startBevy: (runId: string) => Promise<void>;
|
||||||
/** Stop a run's Bevy playtest. */
|
/** Stop a run's Bevy playtest. */
|
||||||
@@ -196,6 +199,7 @@ export function useOrchestrator(): UseOrchestrator {
|
|||||||
|
|
||||||
const getDiff = useCallback((runId: string) => orchestratorApi.getDiff(runId), []);
|
const getDiff = useCallback((runId: string) => orchestratorApi.getDiff(runId), []);
|
||||||
const mergeRun = useCallback((runId: string) => orchestratorApi.mergeRun(runId), []);
|
const mergeRun = useCallback((runId: string) => orchestratorApi.mergeRun(runId), []);
|
||||||
|
const mergeAll = useCallback(() => orchestratorApi.mergeAll(), []);
|
||||||
|
|
||||||
const startBevy = useCallback(async (runId: string) => {
|
const startBevy = useCallback(async (runId: string) => {
|
||||||
await orchestratorApi.startBevy(runId);
|
await orchestratorApi.startBevy(runId);
|
||||||
@@ -260,6 +264,7 @@ export function useOrchestrator(): UseOrchestrator {
|
|||||||
reflectBevy,
|
reflectBevy,
|
||||||
getDiff,
|
getDiff,
|
||||||
mergeRun,
|
mergeRun,
|
||||||
|
mergeAll,
|
||||||
startBevy,
|
startBevy,
|
||||||
stopBevy,
|
stopBevy,
|
||||||
bevyIsRunning,
|
bevyIsRunning,
|
||||||
@@ -281,6 +286,7 @@ export function useOrchestrator(): UseOrchestrator {
|
|||||||
reflectBevy,
|
reflectBevy,
|
||||||
getDiff,
|
getDiff,
|
||||||
mergeRun,
|
mergeRun,
|
||||||
|
mergeAll,
|
||||||
startBevy,
|
startBevy,
|
||||||
stopBevy,
|
stopBevy,
|
||||||
bevyIsRunning,
|
bevyIsRunning,
|
||||||
|
|||||||
@@ -113,6 +113,25 @@ export interface MergeResult {
|
|||||||
output: string;
|
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 = {
|
export const orchestratorApi = {
|
||||||
listRuns: (cardId?: string) =>
|
listRuns: (cardId?: string) =>
|
||||||
req<{ runs: AgentRun[] }>(`/runs${cardId ? `?cardId=${encodeURIComponent(cardId)}` : ''}`),
|
req<{ runs: AgentRun[] }>(`/runs${cardId ? `?cardId=${encodeURIComponent(cardId)}` : ''}`),
|
||||||
@@ -141,6 +160,9 @@ export const orchestratorApi = {
|
|||||||
mergeRun: (id: string) =>
|
mergeRun: (id: string) =>
|
||||||
req<MergeResult>(`/runs/${id}/merge`, { method: 'POST' }),
|
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`),
|
bevyStatus: (id: string) => req<{ running: boolean }>(`/runs/${id}/bevy`),
|
||||||
|
|
||||||
startBevy: (id: string) => req<{ ok: boolean }>(`/runs/${id}/bevy`, { method: 'POST' }),
|
startBevy: (id: string) => req<{ ok: boolean }>(`/runs/${id}/bevy`, { method: 'POST' }),
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { Link } from 'react-router-dom';
|
|||||||
import { useKanbanBoard } from '../../components/kanban/useKanbanBoard';
|
import { useKanbanBoard } from '../../components/kanban/useKanbanBoard';
|
||||||
import { KanbanCard } from '../../components/kanban/KanbanCard';
|
import { KanbanCard } from '../../components/kanban/KanbanCard';
|
||||||
import { CardModal } from '../../components/kanban/CardModal';
|
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 { useOrchestrator } from '../../components/kanban/useOrchestrator';
|
||||||
import { useCustomPages } from '../../lib/customPagesStore';
|
import { useCustomPages } from '../../lib/customPagesStore';
|
||||||
import type { Card, Column } from '../../lib/kanbanApi';
|
import type { Card, Column } from '../../lib/kanbanApi';
|
||||||
|
import type { BatchMergeResult } from '../../lib/orchestratorApi';
|
||||||
|
|
||||||
const COLUMNS: { key: Column; title: string; color: string; bgColor: string }[] = [
|
const COLUMNS: { key: Column; title: string; color: string; bgColor: string }[] = [
|
||||||
{ key: 'done', title: 'DONE', color: 'var(--green)', bgColor: 'rgba(34,197,94,0.05)' },
|
{ 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 [openCardId, setOpenCardId] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [justSaved, setJustSaved] = 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 identity across renders — memoized cards receive a referentially
|
||||||
// stable `onOpen` so they don't re-render just because the parent did.
|
// stable `onOpen` so they don't re-render just because the parent did.
|
||||||
const openById = useCallback((id: string) => setOpenCardId(id), []);
|
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).
|
// The card whose detail modal is open (null when closed).
|
||||||
const openCard = board?.cards.find((c) => c.id === openCardId) ?? null;
|
const openCard = board?.cards.find((c) => c.id === openCardId) ?? null;
|
||||||
|
|
||||||
@@ -78,6 +105,14 @@ export function KanbanBoardPage() {
|
|||||||
return groups;
|
return groups;
|
||||||
}, [board]);
|
}, [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 counts = useMemo(() => {
|
||||||
const c = { done: 0, 'in-progress': 0, todo: 0, backlog: 0, total: 0 };
|
const c = { done: 0, 'in-progress': 0, todo: 0, backlog: 0, total: 0 };
|
||||||
if (board) {
|
if (board) {
|
||||||
@@ -113,6 +148,15 @@ export function KanbanBoardPage() {
|
|||||||
<button type="button" onClick={() => void reload()} style={headerButtonStyle}>
|
<button type="button" onClick={() => void reload()} style={headerButtonStyle}>
|
||||||
Reload
|
Reload
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.95rem', maxWidth: '720px' }}>
|
<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)}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user