feat(swarm): implement Swarm View remake with Operations, Archetypes, and Templates

This commit includes the new SwarmWorkspace with its 3 sub-tabs, the LeftPanel mission picker, and the comprehensive Operations Command Dashboard featuring the live interactive DAG telemetry and task assignment prep flow.
This commit is contained in:
zenchantlive 2026-02-20 22:19:38 -08:00
parent 409a7e7256
commit dfaf523029
74 changed files with 11066 additions and 2046 deletions

View file

@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { listAgents } from '../../../../lib/agent-registry';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const projectRoot = searchParams.get('projectRoot');
if (!projectRoot) {
return NextResponse.json({ ok: false, error: 'projectRoot is required' }, { status: 400 });
}
const result = await listAgents({}, { projectRoot });
if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error?.message }, { status: 500 });
}
return NextResponse.json({ ok: true, data: result.data });
}

View file

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { readInteractionsViaBd } from '../../../../../lib/read-interactions';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
): Promise<NextResponse> {
const { id } = await params;
const projectRoot = request.nextUrl.searchParams.get('projectRoot');
if (!projectRoot) {
return NextResponse.json(
{ ok: false, error: { message: 'projectRoot is required' } },
{ status: 400 }
);
}
try {
const comments = await readInteractionsViaBd(projectRoot, id);
return NextResponse.json({ ok: true, comments });
} catch (error) {
console.error('[API] Failed to fetch comments:', error);
return NextResponse.json(
{ ok: false, error: { message: 'Failed to fetch comments' } },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../../lib/bridge';
export const dynamic = 'force-dynamic';
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { searchParams } = new URL(request.url);
const projectRoot = searchParams.get('projectRoot');
if (!projectRoot) {
return NextResponse.json({ ok: false, error: 'projectRoot is required' }, { status: 400 });
}
try {
const result = await runBdCommand({
projectRoot,
args: ['swarm', 'status', id, '--json'],
});
if (!result.success) {
return NextResponse.json({ ok: false, error: result.error }, { status: 500 });
}
const topology = JSON.parse(result.stdout);
return NextResponse.json({ ok: true, data: topology });
} catch (e) {
console.error(`Failed to fetch topology for ${id}:`, e);
return NextResponse.json({ ok: false, error: 'Internal server error' }, { status: 500 });
}
}

View file

@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { joinSwarm, leaveSwarm } from '../../../../lib/swarm-molecules';
export async function POST(request: Request) {
try {
const body = await request.json();
const { projectRoot, agentId, missionId, action } = body;
if (!projectRoot || !agentId || !missionId || !action) {
return NextResponse.json(
{ ok: false, error: 'Missing required fields' },
{ status: 400 }
);
}
let result;
if (action === 'join') {
result = await joinSwarm({ agent: agentId, epicId: missionId }, { projectRoot });
} else if (action === 'leave') {
result = await leaveSwarm({ agent: agentId, projectRoot });
} else {
return NextResponse.json({ ok: false, error: 'Invalid action' }, { status: 400 });
}
if (!result.ok) {
return NextResponse.json(
{ ok: false, error: result.error?.message },
{ status: 500 }
);
}
return NextResponse.json({ ok: true, data: result.data });
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : 'Unknown error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const projectRoot = searchParams.get('projectRoot');
const id = searchParams.get('id');
if (!projectRoot || !id) {
return NextResponse.json({ ok: false, error: 'projectRoot and id are required' }, { status: 400 });
}
// 1. Get the mission/epic bead itself
const headResult = await runBdCommand({
projectRoot,
args: ['show', id, '--json'],
});
// 2. Get children
const childrenResult = await runBdCommand({
projectRoot,
args: ['list', '--parent', id, '--json'],
});
if (!headResult.success) {
return NextResponse.json({ ok: false, error: 'Failed to fetch mission head' }, { status: 500 });
}
try {
const head = JSON.parse(headResult.stdout);
let children = [];
if (childrenResult.success && childrenResult.stdout.trim()) {
children = JSON.parse(childrenResult.stdout);
}
const headObj = Array.isArray(head) ? head[0] : head;
// Transform for graph view (if needed, or just return raw issues and let UI handle it)
// The WorkflowGraph component expects BeadIssue[]
const nodes = [headObj, ...children];
return NextResponse.json({ ok: true, data: { nodes } });
} catch (e) {
return NextResponse.json({ ok: false, error: 'Failed to parse graph data' }, { status: 500 });
}
}

View file

@ -0,0 +1,88 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
import { listAgents, type AgentRecord } from '../../../../lib/agent-registry';
export const dynamic = 'force-dynamic';
interface SwarmTopology {
completed: { id: string; title: string }[];
active: { id: string; title: string; assignee?: string }[];
ready: { id: string; title: string }[];
blocked: { id: string; title: string; blocked_by: string[] }[];
}
interface Mission {
id: string;
title: string;
description?: string;
status: 'planning' | 'active' | 'blocked' | 'completed';
stats: {
total: number;
done: number;
blocked: number;
active: number;
};
topology?: SwarmTopology;
agents: AgentRecord[];
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const projectRoot = searchParams.get('projectRoot');
if (!projectRoot) {
return NextResponse.json({ ok: false, error: 'projectRoot is required' }, { status: 400 });
}
// 1. Fetch Swarms (Molecules)
const swarmResult = await runBdCommand({
projectRoot,
args: ['swarm', 'list', '--json'],
});
if (!swarmResult.success) {
console.warn('Swarm list failed, returning empty:', swarmResult.error);
return NextResponse.json({ ok: true, data: { missions: [] } });
}
// 2. Fetch All Agents
const agentResult = await listAgents({}, { projectRoot });
const allAgents = agentResult.ok ? agentResult.data! : [];
try {
const rawData = JSON.parse(swarmResult.stdout);
const rawSwarms = rawData.swarms || [];
// 3. Transform & Merge
const missions: Mission[] = rawSwarms
.filter((s: any) => !s.title.startsWith('Agent:'))
.map((s: any) => {
const assignedAgents = allAgents.filter(a => a.swarm_id === s.epic_id || a.swarm_id === s.id);
// Map status
let status: Mission['status'] = 'planning';
if (s.status === 'closed') status = 'completed';
else if (assignedAgents.length > 0) status = 'active';
else status = 'planning';
return {
id: s.id,
title: s.title,
description: s.description || s.epic_description || '',
status,
stats: {
total: s.total_issues || 0,
done: s.completed_issues || 0,
active: s.active_issues || 0,
blocked: 0
},
agents: assignedAgents
};
});
return NextResponse.json({ ok: true, data: { missions } });
} catch (e) {
console.error('Mission list parsing error:', e);
return NextResponse.json({ ok: false, error: 'Failed to parse mission data' }, { status: 500 });
}
}

View file

@ -0,0 +1,70 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
interface CloseSwarmPayload {
projectRoot: string;
swarmId: string;
reason?: string;
}
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'Invalid JSON body' } },
{ status: 400 },
);
}
if (!body || typeof body !== 'object') {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'Payload must be a JSON object' } },
{ status: 400 },
);
}
const data = body as Record<string, unknown>;
if (typeof data.projectRoot !== 'string' || !data.projectRoot.trim()) {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'projectRoot is required' } },
{ status: 400 },
);
}
if (typeof data.swarmId !== 'string' || !data.swarmId.trim()) {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'swarmId is required' } },
{ status: 400 },
);
}
const payload: CloseSwarmPayload = {
projectRoot: data.projectRoot.trim(),
swarmId: data.swarmId.trim(),
reason: typeof data.reason === 'string' ? data.reason.trim() : undefined,
};
const args = ['close', payload.swarmId, '--json'];
if (payload.reason) {
args.push('--reason', payload.reason);
}
const result = await runBdCommand({
projectRoot: payload.projectRoot,
args,
});
if (!result.success) {
return NextResponse.json(
{ ok: false, error: { classification: result.classification ?? 'unknown', message: result.error ?? result.stderr } },
{ status: 400 },
);
}
return NextResponse.json({ ok: true, swarmId: payload.swarmId });
}

View file

@ -0,0 +1,78 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
interface CreateSwarmPayload {
projectRoot: string;
epicId: string;
coordinator?: string;
}
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'Invalid JSON body' } },
{ status: 400 },
);
}
if (!body || typeof body !== 'object') {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'Payload must be a JSON object' } },
{ status: 400 },
);
}
const data = body as Record<string, unknown>;
if (typeof data.projectRoot !== 'string' || !data.projectRoot.trim()) {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'projectRoot is required' } },
{ status: 400 },
);
}
if (typeof data.epicId !== 'string' || !data.epicId.trim()) {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'epicId is required' } },
{ status: 400 },
);
}
const payload: CreateSwarmPayload = {
projectRoot: data.projectRoot.trim(),
epicId: data.epicId.trim(),
coordinator: typeof data.coordinator === 'string' ? data.coordinator.trim() : undefined,
};
const args = ['swarm', 'create', payload.epicId, '--json'];
if (payload.coordinator) {
args.push('--coordinator', payload.coordinator);
}
const result = await runBdCommand({
projectRoot: payload.projectRoot,
args,
});
if (!result.success) {
return NextResponse.json(
{ ok: false, error: { classification: result.classification ?? 'unknown', message: result.error ?? result.stderr } },
{ status: 400 },
);
}
try {
const swarm = JSON.parse(result.stdout);
return NextResponse.json({ ok: true, swarm });
} catch {
return NextResponse.json(
{ ok: false, error: { classification: 'unknown', message: 'Failed to parse swarm create output' } },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const projectRoot = searchParams.get('projectRoot');
if (!projectRoot) {
return NextResponse.json({ ok: false, error: 'projectRoot is required' }, { status: 400 });
}
// bd formula list --json
const result = await runBdCommand({
projectRoot,
args: ['formula', 'list', '--json', '--allow-stale'],
});
if (!result.success) {
return NextResponse.json({ ok: false, error: result.error }, { status: 500 });
}
try {
// If output is empty or not JSON array, handle gracefully
const json = JSON.parse(result.stdout || '[]');
return NextResponse.json({ ok: true, data: json });
} catch (e) {
return NextResponse.json({ ok: false, error: 'Failed to parse formulas' }, { status: 500 });
}
}

View file

@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const projectRoot = searchParams.get('projectRoot');
const epicId = searchParams.get('epic');
if (!projectRoot || !epicId) {
return NextResponse.json({ ok: false, error: 'projectRoot and epic are required' }, { status: 400 });
}
// 1. Get the epic itself
const epicResult = await runBdCommand({
projectRoot,
args: ['show', epicId, '--json'],
});
// 2. Get children
const childrenResult = await runBdCommand({
projectRoot,
args: ['list', '--parent', epicId, '--json'],
});
if (!epicResult.success) {
return NextResponse.json({ ok: false, error: 'Failed to fetch epic' }, { status: 500 });
}
try {
const epic = JSON.parse(epicResult.stdout);
// Handle list returning empty or error gracefully
let children = [];
if (childrenResult.success && childrenResult.stdout.trim()) {
children = JSON.parse(childrenResult.stdout);
// bd list returns array, bd show returns object (or array of 1)
}
const epicObj = Array.isArray(epic) ? epic[0] : epic;
const issues = [epicObj, ...children];
return NextResponse.json({ ok: true, data: issues });
} catch (e) {
return NextResponse.json({ ok: false, error: 'Failed to parse graph data' }, { status: 500 });
}
}

View file

@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { joinSwarm } from '../../../../lib/swarm-molecules';
export async function POST(request: Request) {
try {
const body = await request.json();
const { projectRoot, agentId, swarmId } = body;
if (!projectRoot || !agentId || !swarmId) {
return NextResponse.json(
{ ok: false, error: 'Missing required fields' },
{ status: 400 }
);
}
const result = await joinSwarm(
{ agent: agentId, epicId: swarmId },
{ projectRoot }
);
if (!result.ok) {
return NextResponse.json(
{ ok: false, error: result.error?.message },
{ status: 500 }
);
}
return NextResponse.json({ ok: true, data: result.data });
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : 'Unknown error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
export async function POST(request: Request) {
try {
const body = await request.json();
const { projectRoot, title, proto } = body;
if (!projectRoot || !title || !proto) {
return NextResponse.json(
{ ok: false, error: 'Missing required fields: projectRoot, title, proto' },
{ status: 400 }
);
}
// bd mol pour "Title" --proto <proto> --json
const args = ['mol', 'pour', title, '--proto', proto, '--json'];
// Safety: Ensure proto doesn't contain shell injection
if (!/^[a-zA-Z0-9_\-.]+$/.test(proto)) {
return NextResponse.json({ ok: false, error: 'Invalid proto name' }, { status: 400 });
}
const result = await runBdCommand({
projectRoot,
args,
});
if (!result.success) {
return NextResponse.json({ ok: false, error: result.error || result.stderr }, { status: 500 });
}
const data = JSON.parse(result.stdout);
return NextResponse.json({ ok: true, data });
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : 'Unknown error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { leaveSwarm } from '../../../../lib/swarm-molecules';
export async function POST(request: Request) {
try {
const body = await request.json();
const { projectRoot, agentId } = body;
if (!projectRoot || !agentId) {
return NextResponse.json(
{ ok: false, error: 'Missing required fields' },
{ status: 400 }
);
}
const result = await leaveSwarm(
{ agent: agentId },
{ projectRoot }
);
if (!result.ok) {
return NextResponse.json(
{ ok: false, error: result.error?.message },
{ status: 500 }
);
}
return NextResponse.json({ ok: true, data: result.data });
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : 'Unknown error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
export async function GET(request: Request): Promise<Response> {
const { searchParams } = new URL(request.url);
const projectRoot = searchParams.get('projectRoot');
if (!projectRoot) {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'projectRoot is required' } },
{ status: 400 },
);
}
const result = await runBdCommand({
projectRoot,
args: ['swarm', 'list', '--json'],
});
if (!result.success) {
return NextResponse.json(
{ ok: false, error: { classification: result.classification ?? 'unknown', message: result.error ?? result.stderr } },
{ status: 400 },
);
}
try {
const rawData = JSON.parse(result.stdout);
// Filter out items that look like agents (start with "Agent:" or have gt:agent style IDs if discernible)
// Real swarms/molecules usually don't start with "Agent:".
const swarms = (rawData.swarms || []).filter((s: any) =>
!s.title.startsWith('Agent: ') &&
!s.title.startsWith('Agent:')
);
return NextResponse.json({ ok: true, data: { swarms } });
} catch {
return NextResponse.json(
{ ok: false, error: { classification: 'unknown', message: 'Failed to parse swarm list output' } },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
export async function POST(request: Request) {
try {
const { beadId, archetypeId } = await request.json();
if (!beadId || !archetypeId) {
return NextResponse.json({ error: 'Missing beadId or archetypeId' }, { status: 400 });
}
// Use bd CLI to add the archetype label. We leave it 'open' because Prep just assigns the agent.
const cmd = `bd label add ${beadId} "agent:${archetypeId}"`;
const { stdout, stderr } = await execAsync(cmd);
if (stderr && !stderr.includes('Warning')) {
console.error('bd edit stderr:', stderr);
}
return NextResponse.json({ success: true, message: `Prepped ${beadId} for ${archetypeId}`, output: stdout });
} catch (error: any) {
console.error('Prep task failed:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View file

@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
export async function GET(request: Request): Promise<Response> {
const { searchParams } = new URL(request.url);
const projectRoot = searchParams.get('projectRoot');
const epicId = searchParams.get('epic');
if (!projectRoot) {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'projectRoot is required' } },
{ status: 400 },
);
}
if (!epicId) {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'epic is required' } },
{ status: 400 },
);
}
const result = await runBdCommand({
projectRoot,
args: ['swarm', 'status', epicId, '--json'],
});
if (!result.success) {
return NextResponse.json(
{ ok: false, error: { classification: result.classification ?? 'unknown', message: result.error ?? result.stderr } },
{ status: 400 },
);
}
try {
const data = JSON.parse(result.stdout);
return NextResponse.json({ ok: true, data });
} catch {
return NextResponse.json(
{ ok: false, error: { classification: 'unknown', message: 'Failed to parse swarm status output' } },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';
import { getTemplates } from '../../../../lib/server/beads-fs';
export async function GET() {
const data = await getTemplates();
return NextResponse.json(data);
}

View file

@ -3,45 +3,64 @@
@tailwind utilities;
:root {
/* ========== EARTHY-DARK DESIGN SYSTEM TOKENS (PRD v2.0) ========== */
/* Backgrounds */
--color-bg-base: #2D2D2D;
--color-bg-card: #363636;
--color-bg-input: #404040;
/* ========== VISUAL-TRUTH UI TOKEN CONTRACT (bb-vt.1.1) ========== */
--ui-bg-app: #070d16;
--ui-bg-shell: #0c1420;
--ui-bg-panel: #111c2a;
--ui-bg-card: #1a2431;
/* Accents */
--color-accent-green: #7CB97A;
--color-accent-amber: #D4A574;
--color-accent-teal: #5BA8A0;
--ui-border-soft: rgba(153, 171, 190, 0.2);
--ui-border-strong: rgba(153, 171, 190, 0.34);
/* Text */
--color-text-primary: #FFFFFF;
--color-text-secondary: #B8B8B8;
--color-text-muted: #888888;
--color-text-on-primary: #1A1A1A;
--ui-text-primary: #e8edf5;
--ui-text-muted: #8f9caf;
/* Borders */
--color-border-default: #4A4A4A;
--color-border-subtle: #3A3A3A;
--ui-accent-ready: #35d98f;
--ui-accent-blocked: #ff4c72;
--ui-accent-warning: #ffb24a;
--ui-accent-info: #35c9ff;
--ui-accent-action-green: #35d98f;
--ui-accent-action-red: #ff4c72;
--ui-font-sans: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
--ui-font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
--ui-tracking-tight: -0.011em;
--ui-numeric-tabular: tabular-nums;
/* ========== LEGACY-COMPATIBLE MAPPINGS ========== */
--color-bg-base: var(--ui-bg-app);
--color-bg-card: var(--ui-bg-shell);
--color-bg-input: var(--ui-bg-panel);
--color-accent-green: var(--ui-accent-ready);
--color-accent-amber: var(--ui-accent-warning);
--color-accent-teal: var(--ui-accent-info);
--color-text-primary: var(--ui-text-primary);
--color-text-secondary: #c4cfdb;
--color-text-muted: var(--ui-text-muted);
--color-text-on-primary: #10161d;
--color-border-default: var(--ui-border-strong);
--color-border-subtle: var(--ui-border-soft);
/* Status colors */
--status-open: #5BA8A0;
--status-ready: #5BA8A0;
--status-in-progress: #7CB97A;
--status-progress: #7CB97A;
--status-blocked: #D4A574;
--status-blocked-earthy: #D4A574;
--status-closed: #888888;
--status-closed-earthy: #888888;
--status-deferred: #888888;
--status-open: var(--ui-accent-info);
--status-ready: var(--ui-accent-ready);
--status-in-progress: var(--ui-accent-warning);
--status-progress: var(--ui-accent-warning);
--status-blocked: var(--ui-accent-blocked);
--status-blocked-earthy: var(--ui-accent-blocked);
--status-closed: #7f8b98;
--status-closed-earthy: #7f8b98;
--status-deferred: #7f8b98;
/* Liveness colors */
--liveness-active: #7CB97A;
--liveness-stale: #D4A574;
--liveness-stuck: #C97A7A;
--liveness-dead: #C97A7A;
--liveness-idle: #888888;
--liveness-active: var(--ui-accent-ready);
--liveness-stale: var(--ui-accent-warning);
--liveness-stuck: var(--ui-accent-action-red);
--liveness-dead: var(--ui-accent-action-red);
--liveness-idle: #7f8b98;
/* Agent Role Colors */
--agent-role-ui: #6B9BD2;
@ -77,8 +96,8 @@
--shadow-soft-xl: 0 20px 40px -10px rgba(0, 0, 0, 0.4);
/* ========== TYPOGRAPHY ========== */
--font-ui-stack: 'Segoe UI', system-ui, -apple-system, sans-serif;
--font-mono-stack: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
--font-ui-stack: var(--ui-font-sans);
--font-mono-stack: var(--ui-font-mono);
--font-size-h1: 2rem;
--font-size-h2: 1.5rem;
@ -110,7 +129,17 @@
/* ========== LAYOUT ========== */
--sidebar-left-width: 13.75rem;
--sidebar-right-width: 17.5rem;
--topbar-height: 3rem;
--topbar-height: 3.75rem;
--glass-base: linear-gradient(
180deg,
color-mix(in srgb, var(--ui-bg-card) 72%, black),
color-mix(in srgb, var(--ui-bg-panel) 78%, black)
);
--edge-top: color-mix(in srgb, var(--ui-border-strong) 80%, white 20%);
--edge-bottom: color-mix(in srgb, var(--ui-border-soft) 75%, black 25%);
--elevation-ambient: 0 20px 40px -16px rgba(0, 0, 0, 0.78);
--elevation-tight: 0 10px 24px -12px rgba(0, 0, 0, 0.7);
/* ========== LEGACY COMPATIBILITY TOKENS ========== */
/* For existing components that reference these */
@ -134,11 +163,10 @@ body {
}
body {
/* Earthy-dark base from PRD (replaces Aero Chrome) */
background-color: var(--color-bg-base);
color: var(--color-text-secondary);
font-family: var(--font-ui-stack);
letter-spacing: -0.011em;
background-color: var(--ui-bg-app);
color: var(--ui-text-primary);
font-family: var(--ui-font-sans);
letter-spacing: var(--ui-tracking-tight);
position: relative;
isolation: isolate;
}
@ -266,24 +294,52 @@ body {
.ui-select option,
.ui-option {
background-color: #10141d;
color: #e2e8f0;
background-color: var(--ui-bg-panel);
color: var(--ui-text-primary);
}
.ui-text {
font-family: var(--font-ui-stack);
font-family: var(--ui-font-sans);
font-weight: 500;
letter-spacing: -0.01em;
letter-spacing: var(--ui-tracking-tight);
line-height: 1.35;
}
.system-data {
font-family: var(--font-mono-stack);
font-variant-numeric: tabular-nums;
font-family: var(--ui-font-mono);
font-variant-numeric: var(--ui-numeric-tabular);
font-weight: 450;
letter-spacing: 0.015em;
}
.ui-shell-app {
background: var(--ui-bg-app);
color: var(--ui-text-primary);
font-family: var(--ui-font-sans);
letter-spacing: var(--ui-tracking-tight);
}
.ui-shell-topbar {
background: linear-gradient(180deg, color-mix(in srgb, var(--ui-bg-panel) 92%, black), var(--ui-bg-shell));
border-bottom: 1px solid color-mix(in srgb, var(--ui-accent-info) 22%, var(--ui-border-soft));
box-shadow: 0 10px 24px -20px rgba(0, 0, 0, 0.9);
}
.ui-shell-middle {
background: linear-gradient(180deg, color-mix(in srgb, var(--ui-bg-app) 74%, black), color-mix(in srgb, var(--ui-bg-app) 90%, black));
border-left: 1px solid color-mix(in srgb, var(--ui-accent-info) 20%, var(--ui-border-soft));
border-right: 1px solid color-mix(in srgb, var(--ui-accent-info) 20%, var(--ui-border-soft));
}
.ui-shell-panel {
background: linear-gradient(180deg, color-mix(in srgb, var(--ui-bg-shell) 86%, black), color-mix(in srgb, var(--ui-bg-panel) 84%, black));
border-left: 1px solid color-mix(in srgb, var(--ui-accent-info) 30%, var(--ui-border-soft));
}
.ui-tabular-nums {
font-variant-numeric: var(--ui-numeric-tabular);
}
.workflow-graph-legend {
backdrop-filter: blur(12px);

View file

@ -1,78 +1,5 @@
import { SessionsPage } from '../../components/sessions/sessions-page';
import type { SwarmGroup } from '../../components/sessions/sessions-header';
import { readIssuesForScope } from '../../lib/aggregate-read';
import { resolveProjectScope } from '../../lib/project-scope';
import { listProjects } from '../../lib/registry';
import { listAgents } from '../../lib/agent-registry';
import { getSwarmMembers } from '../../lib/swarm-molecules';
import { redirect } from 'next/navigation';
export const dynamic = 'force-dynamic';
interface PageProps {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}
export default async function Page({ searchParams }: PageProps) {
const params = (await searchParams) ?? {};
const requestedProjectKey = typeof params.project === 'string' ? params.project : null;
const requestedMode = typeof params.mode === 'string' ? params.mode : null;
const registryProjects = await listProjects();
const agentsResult = await listAgents({});
const agents = agentsResult.data ?? [];
const scope = resolveProjectScope({
currentProjectRoot: process.cwd(),
registryProjects,
requestedProjectKey,
requestedMode,
});
const issues = await readIssuesForScope({
mode: scope.mode,
selected: scope.selected,
scopeOptions: scope.options,
preferBd: true,
});
const epics = issues.filter(i => i.issue_type === 'epic');
const epicsWithSwarm = epics.filter(
i => (i.labels || []).some(l => l.startsWith('swarm:'))
);
const swarmGroups: SwarmGroup[] = [];
const assignedAgentIds = new Set<string>();
for (const epic of epicsWithSwarm) {
const swarmLabel = epic.labels?.find(l => l.startsWith('swarm:'));
if (!swarmLabel) continue;
const swarmId = swarmLabel.replace('swarm:', '');
const memberIds = await getSwarmMembers({ swarmId }, { projectRoot: scope.selected.root });
const members = agents.filter(a => memberIds.includes(a.agent_id));
members.forEach(a => assignedAgentIds.add(a.agent_id));
if (members.length > 0) {
swarmGroups.push({
swarmId,
swarmLabel: epic.id,
members,
});
}
}
const unassignedAgents = agents.filter(a => !assignedAgentIds.has(a.agent_id));
return (
<SessionsPage
issues={issues}
agents={agents}
projectRoot={scope.selected.root}
projectScopeKey={scope.selected.key}
projectScopeOptions={scope.options}
projectScopeMode={scope.mode}
swarmGroups={swarmGroups}
unassignedAgents={unassignedAgents}
/>
);
export default function SessionsRedirectPage() {
redirect('/?view=social');
}

View file

@ -110,25 +110,25 @@ function formatRelativeTime(timestamp: string): string {
function getAgentTone(status: AgentStatus): AgentTone {
const tones: Record<AgentStatus, AgentTone> = {
active: {
cardClass: 'bg-[radial-gradient(circle_at_86%_18%,rgba(124,185,122,0.28),transparent_58%),rgba(45,64,47,0.74)]',
cardClass: 'bg-[#173126]',
labelClass: 'text-[#7CB97A]',
ringClass: 'ring-[#7CB97A]/45',
glowClass: 'bg-[#7CB97A]/30',
},
stale: {
cardClass: 'bg-[radial-gradient(circle_at_86%_18%,rgba(212,165,116,0.28),transparent_58%),rgba(73,61,46,0.74)]',
cardClass: 'bg-[#322817]',
labelClass: 'text-[#D4A574]',
ringClass: 'ring-[#D4A574]/45',
glowClass: 'bg-[#D4A574]/30',
},
stuck: {
cardClass: 'bg-[radial-gradient(circle_at_86%_18%,rgba(201,122,122,0.28),transparent_58%),rgba(74,52,54,0.76)]',
cardClass: 'bg-[#341a1f]',
labelClass: 'text-[#C97A7A]',
ringClass: 'ring-[#C97A7A]/45',
glowClass: 'bg-[#C97A7A]/30',
},
dead: {
cardClass: 'bg-[radial-gradient(circle_at_86%_18%,rgba(136,104,112,0.26),transparent_58%),rgba(60,55,60,0.74)]',
cardClass: 'bg-[#2b232b]',
labelClass: 'text-[#A78A94]',
ringClass: 'ring-[#A78A94]/40',
glowClass: 'bg-[#A78A94]/25',
@ -146,84 +146,84 @@ function getEventTone(kind: string): EventTone {
label: 'Created',
labelClass: 'text-[#7CB97A]',
dotClass: 'bg-[#7CB97A]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(124,185,122,0.26),transparent_55%),rgba(42,62,44,0.68)]',
cardClass: 'bg-[#182f25]',
idClass: 'text-[#9ACB98]',
},
opened: {
label: 'Opened',
labelClass: 'text-[#7CB97A]',
dotClass: 'bg-[#7CB97A]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(124,185,122,0.26),transparent_55%),rgba(42,62,44,0.68)]',
cardClass: 'bg-[#182f25]',
idClass: 'text-[#9ACB98]',
},
closed: {
label: 'Closed',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(212,165,116,0.28),transparent_55%),rgba(66,56,44,0.7)]',
cardClass: 'bg-[#332716]',
idClass: 'text-[#DAB891]',
},
reopened: {
label: 'Reopened',
labelClass: 'text-[#5B95E8]',
dotClass: 'bg-[#5B95E8]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(91,149,232,0.3),transparent_55%),rgba(42,51,66,0.7)]',
cardClass: 'bg-[#1b2b43]',
idClass: 'text-[#8DB4EF]',
},
status_changed: {
label: 'Status changed',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(212,165,116,0.24),transparent_55%),rgba(63,54,44,0.68)]',
cardClass: 'bg-[#2f2518]',
idClass: 'text-[#DAB891]',
},
priority_changed: {
label: 'Priority changed',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(212,165,116,0.24),transparent_55%),rgba(63,54,44,0.68)]',
cardClass: 'bg-[#2f2518]',
idClass: 'text-[#DAB891]',
},
assignee_changed: {
label: 'Assigned',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(212,165,116,0.24),transparent_55%),rgba(63,54,44,0.68)]',
cardClass: 'bg-[#2f2518]',
idClass: 'text-[#DAB891]',
},
dependency_added: {
label: 'Dependency added',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(212,165,116,0.24),transparent_55%),rgba(63,54,44,0.68)]',
cardClass: 'bg-[#2f2518]',
idClass: 'text-[#DAB891]',
},
dependency_removed: {
label: 'Dependency removed',
labelClass: 'text-[#C97A7A]',
dotClass: 'bg-[#C97A7A]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(201,122,122,0.24),transparent_55%),rgba(65,47,50,0.7)]',
cardClass: 'bg-[#321b21]',
idClass: 'text-[#D9A9A9]',
},
heartbeat: {
label: 'Heartbeat',
labelClass: 'text-[#5BA8A0]',
dotClass: 'bg-[#5BA8A0]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(91,168,160,0.26),transparent_55%),rgba(42,58,60,0.7)]',
cardClass: 'bg-[#173034]',
idClass: 'text-[#8BC9C1]',
},
commented: {
label: 'Commented',
labelClass: 'text-[#5BA8A0]',
dotClass: 'bg-[#5BA8A0]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(91,168,160,0.26),transparent_55%),rgba(42,58,60,0.7)]',
cardClass: 'bg-[#173034]',
idClass: 'text-[#8BC9C1]',
},
comment_added: {
label: 'Commented',
labelClass: 'text-[#5BA8A0]',
dotClass: 'bg-[#5BA8A0]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(91,168,160,0.26),transparent_55%),rgba(42,58,60,0.7)]',
cardClass: 'bg-[#173034]',
idClass: 'text-[#8BC9C1]',
},
};
@ -233,7 +233,7 @@ function getEventTone(kind: string): EventTone {
label: normalized.replace(/_/g, ' '),
labelClass: 'text-[#5BA8A0]',
dotClass: 'bg-[#5BA8A0]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(91,168,160,0.24),transparent_55%),rgba(42,58,60,0.68)]',
cardClass: 'bg-[#173034]',
idClass: 'text-[#8BC9C1]',
}
);
@ -335,9 +335,9 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
}
return (
<div className="flex flex-col h-full bg-[radial-gradient(circle_at_8%_5%,rgba(91,168,160,0.16),transparent_30%),radial-gradient(circle_at_94%_88%,rgba(212,165,116,0.14),transparent_34%),rgba(26,26,28,0.96)] backdrop-blur-xl">
<div className="flex flex-col h-full bg-[#070f19] backdrop-blur-xl">
{/* AGENT ROSTER SECTION */}
<div className="flex-shrink-0 p-4 bg-black/10 shadow-[0_16px_24px_-24px_rgba(0,0,0,0.9)]">
<div className="flex-shrink-0 p-4 bg-[#0b1625] shadow-[0_16px_24px_-24px_rgba(0,0,0,0.9)]">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse shadow-[0_0_8px_#10b981]" />
@ -354,7 +354,7 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
<div className="grid grid-cols-1 gap-2">
{agentRoster.map(agent => (
<div key={agent.beadId} className={cn(
'group flex items-center gap-3 p-2 rounded-xl transition-all duration-300 shadow-[0_12px_22px_-14px_rgba(0,0,0,0.85)]',
'group flex items-center gap-3 p-2 rounded-xl transition-all duration-300 shadow-[0_14px_24px_-14px_rgba(0,0,0,0.92)]',
getAgentTone(agent.status).cardClass,
)}>
<div className="relative">
@ -412,7 +412,7 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
return (
<div key={activity.id} className="group relative">
<div className={cn(
"p-3 rounded-xl transition-all duration-300 shadow-[0_12px_22px_-14px_rgba(0,0,0,0.88)]",
"p-3 rounded-xl transition-all duration-300 shadow-[0_14px_24px_-14px_rgba(0,0,0,0.94)]",
eventTone.cardClass
)}>
<div className="flex items-center gap-2 mb-1.5">

View file

@ -22,13 +22,17 @@ export function GraphView({
hideClosed = false,
}: GraphViewProps) {
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between border-b border-white/5 px-4 py-2 bg-white/[0.02]">
<div className="flex items-center gap-1">
<div className="flex h-full flex-col bg-[var(--ui-bg-app)]">
<div className="flex items-center justify-between border-b border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] px-4 py-2.5">
<div className="flex items-center gap-3">
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">
Graph View
</p>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => onGraphTabChange('flow')}
className={`rounded-lg px-3 py-1.5 text-xs font-bold uppercase tracking-wider transition-all ${
className={`rounded-lg px-3 py-1.5 text-xs font-bold uppercase tracking-wider transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ui-bg-shell)] ${
graphTab === 'flow'
? 'bg-sky-400/10 text-sky-200 shadow-[0_2px_8px_rgba(56,189,248,0.1)]'
: 'text-text-muted/60 hover:text-text-body hover:bg-white/[0.04]'
@ -39,7 +43,7 @@ export function GraphView({
<button
type="button"
onClick={() => onGraphTabChange('overview')}
className={`rounded-lg px-3 py-1.5 text-xs font-bold uppercase tracking-wider transition-all ${
className={`rounded-lg px-3 py-1.5 text-xs font-bold uppercase tracking-wider transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ui-bg-shell)] ${
graphTab === 'overview'
? 'bg-sky-400/10 text-sky-200 shadow-[0_2px_8px_rgba(56,189,248,0.1)]'
: 'text-text-muted/60 hover:text-text-body hover:bg-white/[0.04]'
@ -48,11 +52,12 @@ export function GraphView({
Overview
</button>
</div>
</div>
<span className="text-[10px] text-text-muted/50">
{beads.length} beads
</span>
</div>
<div className="flex-1 min-h-0">
<div className="min-h-0 flex-1">
<WorkflowGraph
beads={beads}
selectedId={selectedId}

View file

@ -0,0 +1,148 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Users, AlertTriangle, Activity, CheckCircle2, Circle } from 'lucide-react';
import { AgentAvatar } from '../shared/agent-avatar';
import type { AgentRecord } from '../../lib/agent-registry';
import { SwarmGraph } from './swarm-graph';
import { useSwarmTopology } from '../../hooks/use-swarm-topology';
export interface MissionCardProps {
id: string;
projectRoot: string;
title: string;
description?: string;
status: 'planning' | 'active' | 'blocked' | 'completed';
stats: {
total: number;
done: number;
blocked: number;
};
agents: AgentRecord[];
onDeploy: () => void;
onClick: () => void;
}
const STATUS_CONFIG = {
planning: {
color: 'text-blue-400',
border: 'border-blue-500/30',
bg: 'bg-blue-500/5',
label: 'PLANNING',
icon: Circle
},
active: {
color: 'text-emerald-400',
border: 'border-emerald-500/30',
bg: 'bg-emerald-500/5',
label: 'ACTIVE',
icon: Activity
},
blocked: {
color: 'text-rose-400',
border: 'border-rose-500/30',
bg: 'bg-rose-500/5',
label: 'BLOCKED',
icon: AlertTriangle
},
completed: {
color: 'text-slate-400',
border: 'border-slate-500/30',
bg: 'bg-slate-500/5',
label: 'COMPLETE',
icon: CheckCircle2
},
};
export function MissionCard({ id, projectRoot, title, description, status, stats, agents, onDeploy, onClick }: MissionCardProps) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.planning;
const StatusIcon = config.icon;
const { topology, isLoading } = useSwarmTopology(projectRoot, id);
const isUnstaffed = agents.length === 0;
const isWorking = agents.some(a => a.status === 'working');
const showPulse = status === 'active' || isWorking;
return (
<Card
onClick={onClick}
className="group relative flex flex-col h-[320px] cursor-pointer overflow-hidden rounded-2xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-card)] hover:border-[var(--ui-accent-info)] hover:shadow-xl hover:shadow-black/20 transition-all duration-300"
>
{/* Decorative Top Glow */}
<div className={cn("absolute top-0 left-0 right-0 h-1 opacity-50 group-hover:opacity-100 transition-opacity", config.bg.replace('/5', '/40'))} />
{/* HEADER */}
<div className="p-5 flex flex-col gap-3 min-h-0">
<div className="flex justify-between items-start">
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] tracking-wider text-slate-500">{id}</span>
<Badge variant="outline" className={cn("text-[9px] px-2 py-0.5 border h-5 flex items-center gap-1.5", config.color, config.border, config.bg)}>
<StatusIcon className="h-3 w-3" />
{config.label}
</Badge>
</div>
{showPulse && (
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</span>
</div>
)}
</div>
<div className="space-y-2">
<h3 className="font-bold text-lg text-slate-100 leading-tight group-hover:text-white transition-colors line-clamp-2">
{title}
</h3>
<p className="text-xs text-slate-400 line-clamp-2 leading-relaxed">
{description || "No mission brief available."}
</p>
</div>
</div>
{/* GRAPH VISUALIZATION */}
<div className="px-5 py-2 flex-1 flex flex-col justify-end">
<SwarmGraph topology={topology} isLoading={isLoading} />
</div>
{/* FOOTER: SQUAD */}
<div className="px-5 py-3 border-t border-white/5 flex items-center justify-between bg-[var(--ui-bg-shell)] mt-auto">
<div className="flex items-center gap-3">
<div className="flex -space-x-2">
{agents.slice(0, 4).map(agent => (
<div key={agent.agent_id} className="ring-2 ring-[var(--ui-bg-shell)] rounded-full transition-transform hover:scale-110 z-0 hover:z-10 relative" title={`${agent.display_name} (${agent.role})`}>
<AgentAvatar name={agent.display_name} status={agent.status as any} size="sm" />
</div>
))}
{agents.length === 0 && (
<div className="h-7 w-7 rounded-full bg-slate-800 border border-slate-700 border-dashed flex items-center justify-center text-slate-600">
<Users className="h-3 w-3" />
</div>
)}
</div>
{agents.length === 0 && (
<span className="text-[10px] font-medium text-slate-500 uppercase tracking-wide">Unstaffed</span>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={(e) => { e.stopPropagation(); onDeploy(); }}
className={cn(
"h-7 px-3 text-[10px] font-bold uppercase tracking-wider border transition-all",
isUnstaffed
? "border-blue-500/20 text-blue-400 bg-blue-500/5 hover:bg-blue-500/10 hover:border-blue-500/40"
: "border-slate-700 text-slate-400 hover:text-white hover:bg-white/5 hover:border-slate-500"
)}
>
{isUnstaffed ? 'Deploy' : 'Manage'}
</Button>
</div>
</Card>
);
}

View file

@ -0,0 +1,141 @@
'use client';
import { useState } from 'react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Loader2, Map, MessageSquare, Users, X, Activity } from 'lucide-react';
import { WorkflowGraph } from '../shared/workflow-graph';
import { AgentAvatar } from '../shared/agent-avatar';
import { useMissionGraph } from '../../hooks/use-mission-graph';
import type { AgentRecord } from '../../lib/agent-registry';
interface MissionInspectorProps {
missionId: string;
missionTitle: string; // Passed in or fetched? Better to pass in for instant header
projectRoot: string;
assignedAgents: AgentRecord[];
onClose: () => void;
onAssign: (agentId: string, action: 'join' | 'leave') => void;
}
export function MissionInspector({
missionId,
missionTitle,
projectRoot,
assignedAgents,
onClose,
onAssign
}: MissionInspectorProps) {
const { nodes, isLoading: isGraphLoading } = useMissionGraph(projectRoot, missionId);
const [activeTab, setActiveTab] = useState('map');
return (
<div className="flex flex-col h-full bg-[#08111d] border-l border-slate-800 text-slate-200">
{/* Header */}
<div className="flex items-start justify-between p-4 border-b border-slate-800 bg-[#0d1621]">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 font-mono text-[10px]">
{missionId}
</Badge>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-bold">Active Operation</span>
</div>
<h2 className="text-sm font-semibold text-white line-clamp-2">{missionTitle}</h2>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6 text-slate-500 hover:text-white" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0">
<div className="px-4 pt-2 bg-[#0d1621] border-b border-slate-800">
<TabsList className="bg-transparent p-0 h-auto gap-4">
<TabsTrigger
value="map"
className="rounded-none border-b-2 border-transparent px-2 py-2 text-[10px] uppercase tracking-wider data-[state=active]:border-emerald-500 data-[state=active]:bg-transparent data-[state=active]:text-emerald-400"
>
<Map className="h-3 w-3 mr-1.5" />
Map
</TabsTrigger>
<TabsTrigger
value="comms"
className="rounded-none border-b-2 border-transparent px-2 py-2 text-[10px] uppercase tracking-wider data-[state=active]:border-emerald-500 data-[state=active]:bg-transparent data-[state=active]:text-emerald-400"
>
<MessageSquare className="h-3 w-3 mr-1.5" />
Comms
</TabsTrigger>
<TabsTrigger
value="squad"
className="rounded-none border-b-2 border-transparent px-2 py-2 text-[10px] uppercase tracking-wider data-[state=active]:border-emerald-500 data-[state=active]:bg-transparent data-[state=active]:text-emerald-400"
>
<Users className="h-3 w-3 mr-1.5" />
Squad <span className="ml-1 text-slate-500">{assignedAgents.length}</span>
</TabsTrigger>
</TabsList>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden relative">
<TabsContent value="map" className="h-full m-0 p-0 data-[state=active]:flex flex-col">
{isGraphLoading ? (
<div className="flex h-full items-center justify-center"><Loader2 className="h-6 w-6 animate-spin text-slate-600" /></div>
) : (
<div className="flex-1 relative bg-slate-950">
<WorkflowGraph beads={nodes} selectedId={undefined} hideClosed={false} className="h-full w-full border-0 rounded-none" />
<div className="absolute bottom-4 right-4 pointer-events-none">
<Badge variant="outline" className="bg-black/50 border-white/10 backdrop-blur text-xs">
{nodes.length} Nodes
</Badge>
</div>
</div>
)}
</TabsContent>
<TabsContent value="comms" className="h-full m-0 p-4 overflow-y-auto">
<div className="flex flex-col items-center justify-center h-full text-slate-500 space-y-2 opacity-60">
<Activity className="h-8 w-8" />
<p className="text-xs">Secure Uplink Offline</p>
<p className="text-[10px] italic">Inter-agent communication feed coming in Phase 3.2</p>
</div>
</TabsContent>
<TabsContent value="squad" className="h-full m-0">
<ScrollArea className="h-full">
<div className="p-4 space-y-3">
{assignedAgents.length === 0 ? (
<div className="text-center py-8 text-slate-500 text-xs">
No agents deployed.
</div>
) : (
assignedAgents.map(agent => (
<div key={agent.agent_id} className="flex items-center justify-between p-3 rounded-lg bg-slate-800/40 border border-slate-800">
<div className="flex items-center gap-3">
<AgentAvatar name={agent.display_name} status={agent.status as any} />
<div>
<p className="text-sm font-medium text-slate-200">{agent.display_name}</p>
<div className="flex items-center gap-2 text-[10px] text-slate-500">
<span className="uppercase font-bold tracking-wider">{agent.role}</span>
<span></span>
<span className="font-mono">{agent.status}</span>
</div>
</div>
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs text-rose-400 hover:text-rose-300 hover:bg-rose-950/30" onClick={() => onAssign(agent.agent_id, 'leave')}>
Dismiss
</Button>
</div>
))
)}
</div>
</ScrollArea>
</TabsContent>
</div>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,107 @@
'use client';
import { useMemo } from 'react';
import { motion } from 'framer-motion';
import type { SwarmTopologyData } from '../../hooks/use-swarm-topology';
interface SwarmGraphProps {
topology: SwarmTopologyData | null;
isLoading: boolean;
}
export function SwarmGraph({ topology, isLoading }: SwarmGraphProps) {
const nodes = useMemo(() => {
if (!topology) return [];
// Simple layout strategy: Clusters
// Done: Left side
// Active: Center
// Ready: Right
// Blocked: Bottom Right
const output: React.ReactNode[] = [];
const scale = 0.5;
// 1. Completed (Green Cluster)
topology.completed.forEach((item, i) => {
const col = i % 5;
const row = Math.floor(i / 5);
output.push(
<circle
key={`done-${item.id}`}
cx={20 + (col * 8)}
cy={20 + (row * 8)}
r={2.5}
fill="#34d399"
opacity={0.5}
/>
);
});
// 2. Active (Pulsing Center)
topology.active.forEach((item, i) => {
const cx = 140 + (i * 20);
const cy = 30 + (i % 2) * 10;
output.push(
<g key={`active-${item.id}`}>
<circle cx={cx} cy={cy} r={6} fill="#10b981" className="animate-pulse" />
<circle cx={cx} cy={cy} r={3} fill="#ecfdf5" />
</g>
);
});
// 3. Ready (White Pipeline)
topology.ready.forEach((item, i) => {
const cx = 220 + (i * 10);
const cy = 30;
output.push(
<circle key={`ready-${item.id}`} cx={cx} cy={cy} r={3} fill="#94a3b8" />
);
});
// 4. Blocked (Red Hazard)
topology.blocked.forEach((item, i) => {
const cx = 220 + (i * 10);
const cy = 50;
output.push(
<circle key={`blocked-${item.id}`} cx={cx} cy={cy} r={3} fill="#f43f5e" />
);
});
return output;
}, [topology]);
if (isLoading) {
return (
<div className="h-16 w-full flex items-center justify-center bg-black/20 rounded-lg animate-pulse">
<span className="text-[10px] text-slate-600 font-mono">SCANNING TOPOLOGY...</span>
</div>
);
}
if (!topology || (topology.completed.length === 0 && topology.active.length === 0 && topology.ready.length === 0)) {
return (
<div className="h-16 w-full flex items-center justify-center bg-black/20 rounded-lg border border-dashed border-slate-800">
<span className="text-[10px] text-slate-600 font-mono">EMPTY SIGNAL</span>
</div>
);
}
return (
<div className="h-16 w-full bg-black/40 rounded-lg border border-white/5 overflow-hidden relative">
<svg width="100%" height="100%" viewBox="0 0 300 64" preserveAspectRatio="xMidYMid meet">
{/* Connection Lines (Abstract) */}
<path d="M 60 30 L 130 30" stroke="#334155" strokeWidth="1" strokeDasharray="4 4" />
<path d="M 180 30 L 210 30" stroke="#334155" strokeWidth="1" strokeDasharray="4 4" />
{nodes}
{/* Labels */}
<text x="30" y="55" fontSize="8" fill="#475569" textAnchor="middle" fontFamily="monospace">DONE</text>
<text x="150" y="55" fontSize="8" fill="#10b981" textAnchor="middle" fontFamily="monospace" fontWeight="bold">ACTIVE</text>
<text x="240" y="15" fontSize="8" fill="#94a3b8" textAnchor="middle" fontFamily="monospace">READY</text>
<text x="240" y="60" fontSize="8" fill="#f43f5e" textAnchor="middle" fontFamily="monospace">BLOCKED</text>
</svg>
</div>
);
}

View file

@ -0,0 +1,255 @@
'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import { AgentAvatar } from '../shared/agent-avatar';
import { useAgentPool } from '../../hooks/use-agent-pool';
import { Loader2, Plus, Minus, ShieldCheck, Search, Users, ChevronLeft, Save } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import type { AgentRecord } from '../../lib/agent-registry';
interface TeamManagerDialogProps {
isOpen: boolean;
onClose: () => void;
missionId: string;
missionTitle: string;
projectRoot: string;
assignedAgents: AgentRecord[];
onAssign: (agentId: string, action: 'join' | 'leave') => Promise<void>;
}
export function TeamManagerDialog({
isOpen,
onClose,
missionId,
missionTitle,
projectRoot,
assignedAgents,
onAssign
}: TeamManagerDialogProps) {
const { agents, isLoading, refresh } = useAgentPool(projectRoot);
const [search, setSearch] = useState('');
const [pendingAction, setPendingAction] = useState<string | null>(null);
// Creation Mode State
const [isCreating, setIsCreating] = useState(false);
const [newName, setNewName] = useState('');
const [newRole, setNewRole] = useState('');
const [newInstructions, setNewInstructions] = useState('');
const [isSaving, setIsSaving] = useState(false);
const assignedIds = new Set(assignedAgents.map(a => a.agent_id));
const availableAgents = agents.filter(a =>
!assignedIds.has(a.agent_id) &&
(a.display_name.toLowerCase().includes(search.toLowerCase()) ||
a.role.toLowerCase().includes(search.toLowerCase()))
);
const handleAction = async (agentId: string, action: 'join' | 'leave') => {
setPendingAction(agentId);
try {
await onAssign(agentId, action);
} finally {
setPendingAction(null);
}
};
const handleCreateAgent = async () => {
if (!newName || !newRole) return;
setIsSaving(true);
try {
const res = await fetch('/api/agent/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectRoot, name: newName, role: newRole, instructions: newInstructions })
});
if (res.ok) {
await refresh();
setIsCreating(false);
setNewName('');
setNewRole('');
setNewInstructions('');
}
} catch (e) {
console.error(e);
} finally {
setIsSaving(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl bg-[#08111d] border-slate-800 text-slate-200">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-emerald-500" />
Manage Mission Squad
<Badge variant="outline" className="ml-2 border-slate-700 text-slate-400 font-mono font-normal">
{missionId}
</Badge>
</DialogTitle>
<p className="text-sm text-slate-400">{missionTitle}</p>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 h-[450px] mt-4">
{/* Left: Available Pool / Creation Form */}
<div className="flex flex-col gap-2 rounded-lg border border-slate-800 bg-[#0d1621] p-3 transition-all relative overflow-hidden">
{isCreating ? (
// CREATION FORM
<div className="flex flex-col h-full animate-in slide-in-from-left-4 fade-in duration-200">
<div className="flex items-center justify-between mb-4">
<Button variant="ghost" size="sm" onClick={() => setIsCreating(false)} className="-ml-2 text-slate-400 hover:text-white">
<ChevronLeft className="h-4 w-4 mr-1" /> Back
</Button>
<h4 className="text-xs font-bold uppercase tracking-wider text-emerald-500">Draft New Agent</h4>
</div>
<div className="space-y-4 flex-1">
<div className="space-y-1">
<label className="text-[10px] uppercase text-slate-500 font-semibold">Codename</label>
<Input
placeholder="e.g. Data Miner"
className="bg-slate-900 border-slate-700"
value={newName}
onChange={e => setNewName(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase text-slate-500 font-semibold">Role</label>
<Input
placeholder="e.g. data-engineer"
className="bg-slate-900 border-slate-700 font-mono text-xs"
value={newRole}
onChange={e => setNewRole(e.target.value)}
/>
</div>
<div className="space-y-1 flex-1 flex flex-col">
<label className="text-[10px] uppercase text-slate-500 font-semibold">Directives / Instructions</label>
<Textarea
placeholder="Primary directive: Extract data from..."
className="bg-slate-900 border-slate-700 flex-1 resize-none text-xs leading-relaxed"
value={newInstructions}
onChange={e => setNewInstructions(e.target.value)}
/>
</div>
</div>
<Button
onClick={handleCreateAgent}
disabled={!newName || !newRole || isSaving}
className="mt-4 bg-emerald-600 hover:bg-emerald-500 text-white w-full"
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <><Save className="h-4 w-4 mr-2" /> Recruit Agent</>}
</Button>
</div>
) : (
// LIST VIEW
<>
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500">Available Resources</h4>
<Badge variant="secondary" className="bg-slate-800 text-slate-400">{availableAgents.length}</Badge>
</div>
<div className="flex gap-2 mb-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-3 w-3 text-slate-500" />
<Input
placeholder="Search agents..."
className="h-8 pl-7 bg-slate-900 border-slate-700 text-xs"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button
size="icon"
variant="outline"
className="h-8 w-8 border-slate-700 border-dashed text-slate-400 hover:text-emerald-400 hover:border-emerald-500/50"
onClick={() => setIsCreating(true)}
title="Draft New Agent"
>
<Plus className="h-4 w-4" />
</Button>
</div>
<ScrollArea className="flex-1 pr-3">
{isLoading ? (
<div className="flex justify-center p-4"><Loader2 className="h-5 w-5 animate-spin text-slate-500" /></div>
) : (
<div className="space-y-2">
{availableAgents.map(agent => (
<div key={agent.agent_id} className="flex items-center justify-between p-2 rounded hover:bg-white/5 transition-colors group">
<div className="flex items-center gap-2">
<AgentAvatar name={agent.display_name} status={agent.status as any} size="sm" />
<div>
<p className="text-xs font-medium text-slate-200">{agent.display_name}</p>
<p className="text-[10px] text-slate-500 uppercase">{agent.role}</p>
</div>
</div>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 opacity-0 group-hover:opacity-100 hover:bg-emerald-500/20 hover:text-emerald-400"
onClick={() => handleAction(agent.agent_id, 'join')}
disabled={!!pendingAction}
>
{pendingAction === agent.agent_id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
</Button>
</div>
))}
</div>
)}
</ScrollArea>
</>
)}
</div>
{/* Right: Assigned Squad */}
<div className="flex flex-col gap-2 rounded-lg border border-emerald-900/30 bg-emerald-950/10 p-3">
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-bold uppercase tracking-wider text-emerald-500">Deployed Squad</h4>
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400">{assignedAgents.length}</Badge>
</div>
<ScrollArea className="flex-1 pr-3">
<div className="space-y-2">
{assignedAgents.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-slate-500 text-xs italic border border-dashed border-emerald-900/30 rounded bg-emerald-950/20">
<Users className="h-8 w-8 mb-2 opacity-20" />
No agents assigned
</div>
) : (
assignedAgents.map(agent => (
<div key={agent.agent_id} className="flex items-center justify-between p-2 rounded bg-emerald-500/5 border border-emerald-500/10">
<div className="flex items-center gap-2">
<AgentAvatar name={agent.display_name} status={agent.status as any} size="sm" />
<div>
<p className="text-xs font-medium text-emerald-100">{agent.display_name}</p>
<p className="text-[10px] text-emerald-500/70 uppercase">{agent.role}</p>
</div>
</div>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 hover:bg-rose-500/20 hover:text-rose-400"
onClick={() => handleAction(agent.agent_id, 'leave')}
disabled={!!pendingAction}
>
{pendingAction === agent.agent_id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Minus className="h-3 w-3" />}
</Button>
</div>
))
)}
</div>
</ScrollArea>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} className="border-slate-700 text-slate-300">Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -1,320 +1,442 @@
'use client';
import { useState, useMemo } from 'react';
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Folder, FolderOpen, Star } from 'lucide-react';
import type { BeadIssue } from '../../lib/types';
import { useResponsive } from '../../hooks/use-responsive';
import { cn } from '../../lib/utils';
import { useUrlState, type ViewType } from '../../hooks/use-url-state';
export type LeftPanelStatusFilter = 'all' | 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
export type LeftPanelPriorityFilter = 'all' | 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
export type LeftPanelPresetFilter = 'all' | 'active' | 'blocked_agents';
export interface LeftPanelFilters {
query: string;
status: LeftPanelStatusFilter;
priority: LeftPanelPriorityFilter;
preset: LeftPanelPresetFilter;
hideClosed: boolean;
}
export interface LeftPanelProps {
issues: BeadIssue[];
selectedEpicId?: string | null;
onEpicSelect?: (epicId: string | null) => void;
filters: LeftPanelFilters;
onFiltersChange: (filters: LeftPanelFilters) => void;
}
interface EpicNode {
interface EpicEntry {
epic: BeadIssue;
children: BeadIssue[];
stats: {
total: number;
closed: number;
in_progress: number;
blocked: number;
ready: number;
lastActivity: number;
};
status: 'blocked' | 'in_progress' | 'ready' | 'done' | 'empty';
blockedCount: number;
activeCount: number;
readyCount: number;
deferredCount: number;
doneCount: number;
agentBlockedCount: number;
latestTimestamp: string;
}
function buildEpicTree(issues: BeadIssue[]): EpicNode[] {
const epics = issues.filter(issue => issue.issue_type === 'epic');
const epicMap = new Map<string, EpicNode>();
function mapStatus(task: BeadIssue): LeftPanelStatusFilter {
if (task.status === 'open') return 'ready';
if (task.status === 'in_progress') return 'in_progress';
if (task.status === 'blocked') return 'blocked';
if (task.status === 'closed' || task.status === 'tombstone') return 'done';
return 'deferred';
}
for (const epic of epics) {
epicMap.set(epic.id, {
epic,
children: [],
stats: { total: 0, closed: 0, in_progress: 0, blocked: 0, ready: 0, lastActivity: new Date(epic.updated_at).getTime() },
status: 'empty'
});
function mapPriority(task: BeadIssue): LeftPanelPriorityFilter {
if (task.priority <= 0) return 'P0';
if (task.priority === 1) return 'P1';
if (task.priority === 2) return 'P2';
if (task.priority === 3) return 'P3';
return 'P4';
}
function formatRelative(timestamp: string): string {
const then = new Date(timestamp);
const now = new Date();
const diffMinutes = Math.floor((now.getTime() - then.getTime()) / 60000);
if (diffMinutes < 1) return 'just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
function buildEntries(issues: BeadIssue[]): EpicEntry[] {
const epics = issues.filter((issue) => issue.issue_type === 'epic');
const tasks = issues.filter((issue) => issue.issue_type !== 'epic');
const taskById = new Map(tasks.map((task) => [task.id, task] as const));
const incomingBlockers = new Map<string, string[]>();
for (const task of tasks) {
incomingBlockers.set(task.id, []);
}
for (const issue of issues) {
if (issue.issue_type === 'epic') continue;
const parentDep = issue.dependencies.find(dep => dep.type === 'parent');
if (parentDep && epicMap.has(parentDep.target)) {
const node = epicMap.get(parentDep.target)!;
node.children.push(issue);
node.stats.total++;
if (issue.status === 'closed') node.stats.closed++;
else if (issue.status === 'blocked') node.stats.blocked++;
else if (issue.status === 'in_progress') node.stats.in_progress++;
else node.stats.ready++; // open/ready
const issueTime = new Date(issue.updated_at).getTime();
if (issueTime > node.stats.lastActivity) node.stats.lastActivity = issueTime;
for (const task of tasks) {
for (const dependency of task.dependencies) {
if (dependency.type !== 'blocks') continue;
if (!taskById.has(dependency.target)) continue;
const current = incomingBlockers.get(dependency.target) ?? [];
current.push(task.id);
incomingBlockers.set(dependency.target, current);
}
}
// Determine Aggregate Status
for (const node of epicMap.values()) {
if (node.stats.blocked > 0) node.status = 'blocked';
else if (node.stats.in_progress > 0) node.status = 'in_progress';
else if (node.stats.ready > 0) node.status = 'ready';
else if (node.stats.total > 0 && node.stats.closed === node.stats.total) node.status = 'done';
else node.status = 'empty';
}
return Array.from(epicMap.values()).sort((a, b) => {
// Sort by status priority (Blocked > In Progress > Ready > Done) then Recency
const statusScore = { blocked: 4, in_progress: 3, ready: 2, done: 1, empty: 0 };
const scoreDiff = statusScore[b.status] - statusScore[a.status];
if (scoreDiff !== 0) return scoreDiff;
return b.stats.lastActivity - a.stats.lastActivity;
});
}
function StatusIndicator({ status }: { status: string }) {
const styles = {
blocked: 'bg-[#C97A7A] shadow-[0_0_8px_rgba(201,122,122,0.45)]',
in_progress: 'bg-[#D4A574] shadow-[0_0_8px_rgba(212,165,116,0.45)]',
ready: 'bg-[#7CB97A] shadow-[0_0_8px_rgba(124,185,122,0.45)]',
done: 'bg-[var(--status-closed)]',
empty: 'bg-white/10',
}[status] || 'bg-slate-500';
return <div className={cn("w-1.5 h-1.5 rounded-full shrink-0", styles)} />;
}
export function LeftPanel({
issues,
selectedEpicId,
onEpicSelect,
}: LeftPanelProps) {
const [expandedEpics, setExpandedEpics] = useState<Set<string>>(new Set());
const { isDesktop, isTablet } = useResponsive();
const epicTree = useMemo(() => buildEpicTree(issues), [issues]);
const featuredEpics = useMemo(() => epicTree.slice(0, 2), [epicTree]);
const standardEpics = useMemo(() => epicTree.slice(2, 6), [epicTree]);
const compactEpics = useMemo(() => epicTree.slice(6), [epicTree]);
const toggleEpic = (epicId: string) => {
setExpandedEpics(prev => {
const next = new Set(prev);
if (next.has(epicId)) {
next.delete(epicId);
} else {
next.add(epicId);
}
return next;
const isEffectivelyBlocked = (task: BeadIssue): boolean => {
if (task.status === 'blocked') return true;
if (task.status === 'closed' || task.status === 'tombstone') return false;
const blockers = incomingBlockers.get(task.id) ?? [];
return blockers.some((blockerId) => {
const blocker = taskById.get(blockerId);
return blocker ? blocker.status !== 'closed' && blocker.status !== 'tombstone' : false;
});
};
const handleEpicClick = (epicId: string) => {
onEpicSelect?.(epicId === selectedEpicId ? null : epicId);
toggleEpic(epicId);
};
return epics
.map((epic) => {
const children = tasks
.filter((task) => task.dependencies.some((dep) => dep.type === 'parent' && dep.target === epic.id))
.sort((a, b) => a.id.localeCompare(b.id));
const blockedCount = children.filter((task) => isEffectivelyBlocked(task)).length;
const activeCount = children.filter((task) => task.status === 'in_progress').length;
const readyCount = children.filter((task) => task.status === 'open' && !isEffectivelyBlocked(task)).length;
const deferredCount = children.filter((task) => task.status === 'deferred').length;
const doneCount = children.filter((task) => task.status === 'closed' || task.status === 'tombstone').length;
const agentBlockedCount = children.filter(
(task) =>
isEffectivelyBlocked(task) &&
(Boolean(task.assignee) ||
task.labels.some((label) => label === 'gt:agent' || label.startsWith('agent:') || label.startsWith('gt:agent:'))),
).length;
const latestTimestamp = [epic.updated_at, ...children.map((child) => child.updated_at)]
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())[0] ?? epic.updated_at;
return {
epic,
children,
blockedCount,
activeCount,
readyCount,
deferredCount,
doneCount,
agentBlockedCount,
latestTimestamp,
};
})
.sort((a, b) => a.epic.id.localeCompare(b.epic.id));
}
if (isTablet) {
return (
<div className="flex w-16 flex-col items-center gap-2 overflow-y-auto bg-[var(--color-bg-card)]/96 py-4 shadow-[10px_0_28px_-16px_rgba(0,0,0,0.82)]">
{epicTree.map(({ epic, status }) => (
<button
key={epic.id}
onClick={() => handleEpicClick(epic.id)}
className={cn(
'w-10 h-10 rounded-xl flex items-center justify-center text-xs font-bold transition-all duration-200 ring-1',
selectedEpicId === epic.id
? 'bg-[var(--color-bg-input)] ring-white/30 text-white'
: 'ring-transparent text-[var(--color-text-muted)] hover:bg-white/5',
status === 'blocked' && 'ring-[#C97A7A]/50',
status === 'in_progress' && 'ring-[#D4A574]/50'
)}
>
{epic.id.slice(0, 2).toUpperCase()}
</button>
))}
</div>
);
function statusDot(status: BeadIssue['status']): string {
if (status === 'blocked') return 'bg-[var(--ui-accent-blocked)]';
if (status === 'in_progress') return 'bg-[var(--ui-accent-warning)]';
if (status === 'closed') return 'bg-[var(--ui-text-muted)]';
return 'bg-[var(--ui-accent-ready)]';
}
function rowTone(entry: EpicEntry): string {
if (entry.blockedCount > 0) {
return '#22111a';
}
if (entry.activeCount > 0) {
return '#221a11';
}
if (entry.readyCount > 0) {
return '#0f221c';
}
return '#111f2b';
}
function isTaskMatch(task: BeadIssue, filters: LeftPanelFilters): boolean {
if (filters.hideClosed && (task.status === 'closed' || task.status === 'tombstone')) return false;
const normalizedQuery = filters.query.trim().toLowerCase();
if (normalizedQuery.length > 0) {
const searchable = `${task.id} ${task.title} ${task.labels.join(' ')}`.toLowerCase();
if (!searchable.includes(normalizedQuery)) return false;
}
if (filters.status !== 'all' && mapStatus(task) !== filters.status) return false;
if (filters.priority !== 'all' && mapPriority(task) !== filters.priority) return false;
if (filters.preset === 'active' && task.status !== 'in_progress') return false;
if (
filters.preset === 'blocked_agents' &&
!(
task.status === 'blocked' &&
(Boolean(task.assignee) || task.labels.some((label) => label === 'gt:agent' || label.startsWith('agent:')))
)
) {
return false;
}
return true;
}
export function LeftPanel({ issues, selectedEpicId, onEpicSelect, filters, onFiltersChange }: LeftPanelProps) {
const { view, setView } = useUrlState();
const entries = useMemo(() => buildEntries(issues), [issues]);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const hasActiveFilters =
filters.query.trim().length > 0 ||
filters.status !== 'all' ||
filters.priority !== 'all' ||
filters.preset !== 'all' ||
filters.hideClosed;
const views: Array<{ id: ViewType; label: string }> = [
{ id: 'social', label: 'Social' },
{ id: 'graph', label: 'Graph' },
{ id: 'swarm', label: 'Swarm' },
];
return (
<div
className={cn(
'flex flex-col h-full overflow-hidden transition-all duration-300',
!isDesktop && 'hidden lg:flex'
)}
style={{ width: '20rem' }}
data-testid="left-panel"
>
<div className="flex h-full flex-col bg-[radial-gradient(circle_at_4%_14%,rgba(212,165,116,0.38),transparent_44%),radial-gradient(circle_at_96%_86%,rgba(91,168,160,0.34),transparent_40%),linear-gradient(165deg,rgba(49,49,62,0.97),rgba(37,40,54,0.98))] shadow-[14px_0_34px_-18px_rgba(0,0,0,0.86)]">
{/* Header */}
<div className="flex items-center justify-between bg-[linear-gradient(90deg,rgba(212,165,116,0.16),rgba(91,168,160,0.12))] p-5 shadow-[0_12px_22px_-18px_rgba(0,0,0,0.9)]">
<span className="text-xs font-bold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Workstreams</span>
<div className="flex gap-2 text-[10px] font-mono text-[var(--color-text-muted)]/60">
<span>{epicTree.length} ACTIVE</span>
<aside className="flex h-full flex-col bg-[var(--ui-bg-shell)] shadow-[inset_-1px_0_0_rgba(0,0,0,0.55),24px_0_40px_-34px_rgba(0,0,0,0.98)]" data-testid="left-panel">
<div className="px-4 py-3 shadow-[0_14px_24px_-20px_rgba(0,0,0,0.92)]">
<div className="mb-3 flex items-center gap-1 rounded-xl bg-[#101c2b] p-1 shadow-[0_12px_24px_-18px_rgba(0,0,0,0.88)]">
{views.map((item) => {
const active = view === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => setView(item.id)}
className={cn(
'flex-1 rounded-lg px-2 py-1 text-xs font-semibold uppercase tracking-[0.12em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]',
active
? 'bg-[#183149] text-[var(--ui-text-primary)]'
: 'text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)]',
)}
>
{item.label}
</button>
);
})}
</div>
<div className="space-y-2 rounded-xl bg-[#101a27] p-2.5 shadow-[0_16px_26px_-20px_rgba(0,0,0,0.92)]">
<div className="grid grid-cols-1 gap-2">
<input
value={filters.query}
onChange={(event) => onFiltersChange({ ...filters, query: event.target.value })}
className="ui-field rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
placeholder="Filter Tasks…"
aria-label="Filter tasks"
autoComplete="off"
/>
<div className="grid grid-cols-2 gap-2">
<select
value={filters.status}
onChange={(event) => onFiltersChange({ ...filters, status: event.target.value as LeftPanelStatusFilter })}
className="ui-field ui-select rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
aria-label="Status filter"
>
<option className="ui-option" value="all">All Status</option>
<option className="ui-option" value="ready">Ready</option>
<option className="ui-option" value="in_progress">In Progress</option>
<option className="ui-option" value="blocked">Blocked</option>
<option className="ui-option" value="deferred">Deferred</option>
<option className="ui-option" value="done">Done</option>
</select>
<select
value={filters.priority}
onChange={(event) => onFiltersChange({ ...filters, priority: event.target.value as LeftPanelPriorityFilter })}
className="ui-field ui-select rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
aria-label="Priority filter"
>
<option className="ui-option" value="all">All Priority</option>
<option className="ui-option" value="P0">P0</option>
<option className="ui-option" value="P1">P1</option>
<option className="ui-option" value="P2">P2</option>
<option className="ui-option" value="P3">P3</option>
<option className="ui-option" value="P4">P4</option>
</select>
</div>
</div>
<div className="flex gap-1">
<button
type="button"
onClick={() => onFiltersChange({ ...filters, preset: filters.preset === 'active' ? 'all' : 'active' })}
className={cn(
'flex-1 rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] shadow-[0_8px_18px_-16px_rgba(0,0,0,0.9)] transition-colors',
filters.preset === 'active'
? 'bg-[#2f2618] text-[var(--ui-accent-warning)]'
: 'bg-[#0f1824] text-[var(--ui-text-muted)]',
)}
aria-pressed={filters.preset === 'active'}
>
Active
</button>
<button
type="button"
onClick={() => onFiltersChange({ ...filters, preset: filters.preset === 'blocked_agents' ? 'all' : 'blocked_agents' })}
className={cn(
'flex-1 rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] shadow-[0_8px_18px_-16px_rgba(0,0,0,0.9)] transition-colors',
filters.preset === 'blocked_agents'
? 'bg-[#2f1621] text-[var(--ui-accent-blocked)]'
: 'bg-[#0f1824] text-[var(--ui-text-muted)]',
)}
aria-pressed={filters.preset === 'blocked_agents'}
>
Agent Blocked
</button>
</div>
<button
type="button"
onClick={() => onFiltersChange({ ...filters, hideClosed: !filters.hideClosed })}
className={cn(
'w-full rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] shadow-[0_8px_18px_-16px_rgba(0,0,0,0.9)] transition-colors',
filters.hideClosed
? 'bg-[#1d2b1a] text-[var(--ui-accent-ready)]'
: 'bg-[#0f1824] text-[var(--ui-text-muted)]',
)}
aria-pressed={filters.hideClosed}
>
Hide Closed
</button>
</div>
<p className="mt-2 font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--ui-text-muted)]">Navigation / Epics</p>
</div>
<div className="flex-1 overflow-y-auto px-3 py-3 custom-scrollbar">
{entries.map((entry) => {
const {
epic,
children,
blockedCount,
activeCount,
readyCount,
deferredCount,
doneCount,
agentBlockedCount,
latestTimestamp,
} = entry;
const matchedChildren = children.filter((task) => isTaskMatch(task, filters));
const total = children.length;
const donePercent = total > 0 ? Math.round((doneCount / total) * 100) : 0;
const readyPercent = total > 0 ? Math.round((readyCount / total) * 100) : 0;
const activePercent = total > 0 ? Math.round((activeCount / total) * 100) : 0;
const blockedPercent = total > 0 ? Math.round((blockedCount / total) * 100) : 0;
const isExpanded = expanded[epic.id] ?? false;
const isSelected = selectedEpicId === epic.id;
const laneColor = blockedCount > 0 ? 'var(--ui-accent-blocked)' : activeCount > 0 ? 'var(--ui-accent-warning)' : 'var(--ui-accent-ready)';
const rowBackground = rowTone(entry);
if (matchedChildren.length === 0 && hasActiveFilters && !isSelected) {
return null;
}
return (
<div key={epic.id} className="mb-2">
<div
className={cn(
'rounded-2xl px-3 py-3 transition-colors shadow-[0_18px_28px_-22px_rgba(0,0,0,0.96)]',
isSelected
? 'text-[var(--ui-text-primary)] ring-1 ring-[rgba(143,156,175,0.45)]'
: 'text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)]',
)}
style={{
boxShadow: `inset 3px 0 0 ${laneColor}, 0 18px 30px -24px rgba(0,0,0,0.95)`,
background: rowBackground,
}}
>
<div className="mb-1.5 flex items-start gap-2">
<button
type="button"
onClick={() => setExpanded((current) => ({ ...current, [epic.id]: !isExpanded }))}
className="mt-0.5 inline-flex h-4 w-4 items-center justify-center rounded text-[var(--ui-text-muted)] transition-colors hover:bg-white/5 hover:text-[var(--ui-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]"
aria-label={isExpanded ? `Collapse ${epic.title}` : `Expand ${epic.title}`}
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronDown className="h-3.5 w-3.5" aria-hidden="true" /> : <ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />}
</button>
<button
type="button"
onClick={() => onEpicSelect?.(isSelected ? null : epic.id)}
className="min-w-0 flex-1 text-left"
>
<div className="flex min-w-0 items-center gap-1.5">
{isExpanded ? <FolderOpen className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" /> : <Folder className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />}
<p className="truncate text-[15px] font-semibold leading-tight text-[var(--ui-text-primary)]">{epic.title}</p>
</div>
<p className="mt-0.5 truncate font-mono text-[11px] text-[var(--ui-text-muted)]">{epic.id}</p>
</button>
<button
type="button"
onClick={() => onEpicSelect?.(epic.id)}
className="inline-flex h-5 w-5 items-center justify-center rounded bg-[#0e1823] text-[var(--ui-text-muted)] shadow-[0_10px_16px_-12px_rgba(0,0,0,0.9)] transition-colors hover:text-[var(--ui-text-primary)]"
aria-label={`Focus ${epic.title}`}
>
<Star className="h-3 w-3" aria-hidden="true" />
</button>
</div>
<div className="flex items-center gap-3 text-[11px]">
<p><span className="text-[var(--ui-text-primary)]">{total}</span> tasks</p>
<p><span className="text-[var(--ui-accent-warning)]">{activeCount}</span> active</p>
<p><span className="text-[var(--ui-accent-blocked)]">{agentBlockedCount}</span> ag-blocked</p>
<p className="ml-auto text-[var(--ui-text-muted)]">{formatRelative(latestTimestamp)}</p>
</div>
<div className="mt-2">
<div className="h-1.5 overflow-hidden rounded-full bg-[#0a111a]">
<div className="flex h-full w-full">
<div style={{ width: `${readyPercent}%`, background: 'var(--ui-accent-ready)' }} />
<div style={{ width: `${activePercent}%`, background: 'var(--ui-accent-warning)' }} />
<div style={{ width: `${blockedPercent}%`, background: 'var(--ui-accent-blocked)' }} />
<div style={{ width: `${Math.max(0, 100 - readyPercent - activePercent - blockedPercent)}%`, background: 'var(--ui-text-muted)' }} />
</div>
</div>
<div className="mt-1 flex items-center justify-between text-[10px] text-[var(--ui-text-muted)]">
<span>{donePercent}% done</span>
<span><span className="text-[var(--ui-accent-ready)]">{readyCount}</span> ready</span>
</div>
</div>
{deferredCount + doneCount + blockedCount > 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-[10px] text-[var(--ui-text-muted)]">
{blockedCount > 0 ? <span>{blockedCount} blocked</span> : null}
{deferredCount > 0 ? <span>{deferredCount} deferred</span> : null}
{doneCount > 0 ? <span>{doneCount} done</span> : null}
</div>
) : null}
</div>
{isExpanded ? (
<div className="ml-8 mt-1 space-y-1 pl-3">
{matchedChildren.slice(0, 7).map((task) => (
<button
key={task.id}
type="button"
onClick={() => onEpicSelect?.(epic.id)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-[var(--ui-text-muted)] transition-colors hover:bg-[#112133] hover:text-[var(--ui-text-primary)]"
>
<span className={cn('h-1.5 w-1.5 rounded-full', statusDot(task.status))} />
<span className="min-w-0 flex-1 truncate">{task.title}</span>
<span className="font-mono text-[10px] text-[var(--ui-text-muted)]">{task.id}</span>
</button>
))}
{matchedChildren.length > 7 ? (
<p className="px-1.5 py-0.5 text-[10px] text-[var(--ui-text-muted)]">+ {matchedChildren.length - 7} more</p>
) : null}
</div>
) : null}
</div>
);
})}
</div>
<footer className="border-t border-[var(--ui-border-soft)] px-4 py-3">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-[linear-gradient(135deg,#9cb6bf,#f1dcc6)]" />
<div>
<p className="text-sm font-semibold text-[var(--ui-text-primary)]">Alex Chen</p>
<p className="text-xs text-[var(--ui-text-muted)]">Lead Ops</p>
</div>
</div>
{/* Tree */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-3 space-y-4">
{[
{ label: 'Featured', items: featuredEpics, tier: 'featured' as const },
{ label: 'Active', items: standardEpics, tier: 'standard' as const },
{ label: 'Queue', items: compactEpics, tier: 'compact' as const },
].map((section) => (
<div key={section.label} className={cn(section.items.length === 0 && 'hidden')}>
<p className="mb-2 px-1 text-[10px] font-bold uppercase tracking-[0.16em] text-[#97A0AF]/75">
{section.label}
</p>
<div className={cn(section.tier === 'compact' ? 'space-y-1.5' : 'space-y-2.5')}>
{section.items.map(({ epic, children, stats, status }) => {
const isExpanded = expandedEpics.has(epic.id);
const isSelected = selectedEpicId === epic.id;
const statusStyle = {
blocked:
'bg-[radial-gradient(circle_at_100%_50%,rgba(201,122,122,0.3),transparent_58%),rgba(92,58,58,0.8)] hover:bg-[radial-gradient(circle_at_100%_50%,rgba(201,122,122,0.38),transparent_58%),rgba(106,64,64,0.85)]',
in_progress:
'bg-[radial-gradient(circle_at_100%_50%,rgba(212,165,116,0.34),transparent_58%),rgba(92,70,45,0.82)] hover:bg-[radial-gradient(circle_at_100%_50%,rgba(212,165,116,0.44),transparent_58%),rgba(108,82,51,0.88)]',
ready:
'bg-[radial-gradient(circle_at_100%_50%,rgba(124,185,122,0.34),transparent_60%),rgba(54,84,55,0.84)] hover:bg-[radial-gradient(circle_at_100%_50%,rgba(124,185,122,0.44),transparent_60%),rgba(61,95,61,0.9)]',
done:
'bg-[radial-gradient(circle_at_100%_50%,rgba(91,168,160,0.3),transparent_58%),rgba(52,72,77,0.78)] hover:bg-[radial-gradient(circle_at_100%_50%,rgba(91,168,160,0.38),transparent_58%),rgba(59,82,87,0.84)]',
empty:
'bg-[radial-gradient(circle_at_100%_50%,rgba(74,104,130,0.2),transparent_58%),rgba(44,49,65,0.76)] hover:bg-[radial-gradient(circle_at_100%_50%,rgba(74,104,130,0.28),transparent_58%),rgba(49,56,74,0.82)]',
}[status];
if (section.tier === 'compact') {
return (
<button
key={epic.id}
type="button"
onClick={() => onEpicSelect?.(epic.id === selectedEpicId ? null : epic.id)}
className={cn(
'w-full rounded-lg px-2.5 py-2 text-left transition-all duration-200',
'flex items-center justify-between gap-2',
statusStyle,
isSelected
? 'shadow-[0_14px_22px_-14px_rgba(0,0,0,0.88),0_0_0_1px_rgba(255,255,255,0.08)_inset]'
: 'shadow-[0_8px_16px_-12px_rgba(0,0,0,0.82)]',
)}
>
<div className="min-w-0">
<p className="truncate font-mono text-[10px] text-[#C7D0DF]/70">{epic.id}</p>
<p className="truncate text-xs font-semibold text-white/90">{epic.title}</p>
</div>
<div className="shrink-0 text-right">
<p className="text-[10px] font-mono text-[#C7D0DF]/70">{stats.total}</p>
<StatusIndicator status={status} />
</div>
</button>
);
}
const isFeatured = section.tier === 'featured';
const cardPadding = isFeatured ? 'p-4' : 'p-3';
const titleClass = isFeatured ? 'text-base' : 'text-sm';
const activeStyle = isSelected
? 'shadow-[0_24px_34px_-16px_rgba(0,0,0,0.9),0_0_0_1px_rgba(255,255,255,0.08)_inset] scale-[1.01]'
: 'shadow-[0_10px_20px_-14px_rgba(0,0,0,0.85)]';
return (
<div key={epic.id} className="group">
<button
type="button"
onClick={() => handleEpicClick(epic.id)}
className={cn(
'w-full flex flex-col rounded-xl text-left transition-all duration-300 relative overflow-hidden',
cardPadding,
statusStyle,
activeStyle,
)}
>
<div
className={cn(
'absolute left-0 top-0 bottom-0 w-1.5',
status === 'blocked'
? 'bg-[#C97A7A]'
: status === 'in_progress'
? 'bg-[#D4A574]'
: status === 'ready'
? 'bg-[#7CB97A]'
: 'bg-[#5BA8A0]',
)}
/>
<div className="pl-3 w-full">
<div className="flex items-center justify-between w-full mb-1">
<span className="text-[10px] font-mono text-text-muted/70 tracking-tight">{epic.id}</span>
{stats.blocked > 0 && (
<span className="rounded bg-[color:rgba(201,122,122,0.24)] px-1.5 text-[9px] font-bold text-[#F0C9C9]">
{stats.blocked} BLOCKED
</span>
)}
</div>
<div className={cn('truncate font-bold text-white/90 mb-2 leading-snug', titleClass)}>
{epic.title}
</div>
<div className="flex h-1.5 w-full items-center gap-1 overflow-hidden rounded-full bg-black/20">
<div style={{ width: `${(stats.closed / (stats.total || 1)) * 100}%` }} className="h-full bg-[#5BA8A0]/75" />
<div style={{ width: `${(stats.in_progress / (stats.total || 1)) * 100}%` }} className="h-full bg-[#D4A574]" />
<div style={{ width: `${(stats.blocked / (stats.total || 1)) * 100}%` }} className="h-full bg-[#C97A7A]" />
<div style={{ width: `${(stats.ready / (stats.total || 1)) * 100}%` }} className="h-full bg-[#7CB97A]" />
</div>
<div className="flex justify-between mt-1.5 text-[9px] font-mono text-text-muted/50">
<span>{Math.round(((stats.closed + stats.in_progress) / (stats.total || 1)) * 100)}% Done</span>
<span>{stats.total} Tasks</span>
</div>
</div>
</button>
{isExpanded && children.length > 0 && (
<div className="ml-4 mt-2 space-y-1 pl-3">
{children.slice(0, 5).map((child) => (
<div
key={child.id}
className="group/child flex cursor-pointer items-center justify-between rounded px-2 py-1.5 transition-colors hover:bg-[rgba(212,165,116,0.16)]"
>
<span className="text-[10px] font-mono text-text-muted/60">{child.id}</span>
<div className="flex items-center gap-2">
<span className="text-[10px] text-text-muted/60 truncate max-w-[80px]">{child.title}</span>
<StatusIndicator status={child.status} />
</div>
</div>
))}
{children.length > 5 && (
<div className="px-2 py-1 text-[9px] text-text-muted/30 italic">+{children.length - 5} more</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
))}
</div>
{/* Footer */}
<div className="bg-black/18 p-4 shadow-[0_-10px_22px_-18px_rgba(0,0,0,0.82)]">
<label className="group flex cursor-pointer items-center gap-3 rounded px-2 py-1 transition-colors hover:bg-white/5">
<div className={`h-3 w-3 rounded-full ${selectedEpicId === null ? 'bg-[var(--status-ready)] shadow-[0_0_8px_rgba(124,185,122,0.7)]' : 'bg-white/25'}`} />
<span className={cn(
"text-xs font-medium transition-colors",
selectedEpicId === null ? "text-[#9BD2CB]" : "text-[var(--color-text-muted)] group-hover:text-[var(--color-text-secondary)]"
)}>
Global Scope
</span>
</label>
</div>
</div>
</div>
</footer>
</aside>
);
}

View file

@ -13,24 +13,22 @@ export interface RightPanelProps {
export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPanelProps) {
const { isMobile, isDesktop } = useResponsive();
const { panel, togglePanel } = useUrlState();
const { rightPanel, toggleRightPanel } = useUrlState();
const isOpen = externalIsOpen ?? (panel === 'open');
const isOpen = externalIsOpen ?? (rightPanel === 'open');
// Calculate width based on content (Standard 17rem vs Chat Mode ~26rem)
// If rail is present, we are in "Chat Mode" (Wide Panel + Rail)
// If no rail, we are in "Activity Mode" (Standard Panel)
const panelWidth = isOpen ? (rail ? '26rem' : '17rem') : '0';
const panelWidth = isOpen ? '20.75rem' : '0';
if (isDesktop) {
return (
<div
className="overflow-hidden transition-all duration-300 flex"
className="ui-shell-panel flex overflow-hidden transition-all duration-300"
style={{
width: panelWidth,
background:
'radial-gradient(circle_at_12%_8%,rgba(91,168,160,0.22),transparent_34%),radial-gradient(circle_at_88%_84%,rgba(212,165,116,0.2),transparent_30%),linear-gradient(180deg,rgba(50,50,58,0.98),rgba(40,42,54,0.98))',
boxShadow: isOpen ? '-24px 0 44px -26px rgba(0,0,0,0.85)' : 'none',
boxShadow: isOpen ? '-24px 0 40px -26px rgba(0,0,0,0.95), inset 1px 0 0 rgba(91,168,160,0.22)' : 'none',
}}
data-testid="right-panel-desktop"
>
@ -38,7 +36,12 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
<>
{/* Main Content (Chat or Activity) */}
<div className="flex-1 min-w-0 h-full overflow-hidden flex flex-col">
<div className="flex-1 overflow-y-auto custom-scrollbar p-0">
<div className="border-l border-[color-mix(in_srgb,var(--ui-accent-info)_36%,var(--ui-border-soft))] bg-[linear-gradient(180deg,color-mix(in_srgb,var(--ui-bg-shell)_96%,black),color-mix(in_srgb,var(--ui-bg-panel)_90%,black))]">
<div className="px-3 py-2 border-b border-[var(--ui-border-soft)]">
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">Agent Pool Monitor</p>
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-0 bg-[#08111d]">
{/* Remove default padding to allow edge-to-edge chat */}
{children || <span>Right Panel</span>}
</div>
@ -46,7 +49,13 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
{/* Side Rail (Mini Activity - Only if provided) */}
{rail && (
<div className="w-12 h-full flex-shrink-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.24),rgba(0,0,0,0.36))] shadow-[-10px_0_20px_-18px_rgba(0,0,0,0.9)]">
<div
className="h-full w-10 flex-shrink-0 shadow-[-10px_0_20px_-18px_rgba(0,0,0,0.9)]"
style={{
background: 'var(--ui-bg-shell)',
borderLeft: '1px solid var(--ui-border-soft)',
}}
>
{rail}
</div>
)}
@ -61,32 +70,46 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
}
const handleBackdropClick = () => {
togglePanel();
toggleRightPanel();
};
const handleCloseClick = () => {
togglePanel();
toggleRightPanel();
};
if (isMobile) {
return (
<div
className="fixed inset-0 z-50"
style={{ backgroundColor: 'var(--color-bg-card)' }}
style={{
backgroundColor: 'var(--ui-bg-panel)',
paddingTop: 'env(safe-area-inset-top)',
paddingBottom: 'env(safe-area-inset-bottom)',
overscrollBehavior: 'contain',
touchAction: 'manipulation',
WebkitTapHighlightColor: 'rgba(0,0,0,0.08)',
}}
data-testid="right-panel-mobile"
>
<div className="flex justify-end p-4">
<div className="flex justify-end px-4 py-3">
<button
onClick={handleCloseClick}
className="p-2 rounded-md hover:bg-white/10"
style={{ color: 'var(--color-text-secondary)' }}
style={{ color: 'var(--ui-text-muted)' }}
data-testid="right-panel-close"
aria-label="Close panel"
>
<X size={24} />
</button>
</div>
<div className="p-4 overflow-y-auto" style={{ height: 'calc(100% - 4rem)', color: 'var(--color-text-secondary)' }}>
<div
className="overflow-y-auto px-4 pb-4"
style={{
height: 'calc(100% - 4rem)',
color: 'var(--ui-text-primary)',
overscrollBehavior: 'contain',
}}
>
{children || <span>Right Panel</span>}
</div>
</div>
@ -106,7 +129,8 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
style={{
width: '17rem',
background:
'radial-gradient(circle_at_12%_8%,rgba(91,168,160,0.22),transparent_34%),radial-gradient(circle_at_88%_84%,rgba(212,165,116,0.2),transparent_30%),linear-gradient(180deg,rgba(50,50,58,0.98),rgba(40,42,54,0.98))',
'linear-gradient(180deg, color-mix(in srgb, var(--ui-bg-panel) 86%, black), var(--ui-bg-panel))',
borderLeft: '1px solid var(--ui-border-soft)',
boxShadow: '-24px 0 44px -26px rgba(0,0,0,0.85)',
}}
data-testid="right-panel-tablet"
@ -115,14 +139,14 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
<button
onClick={handleCloseClick}
className="p-2 rounded-md hover:bg-white/10"
style={{ color: 'var(--color-text-secondary)' }}
style={{ color: 'var(--ui-text-muted)' }}
data-testid="right-panel-close"
aria-label="Close panel"
>
<X size={24} />
</button>
</div>
<div className="p-4" style={{ color: 'var(--color-text-secondary)' }}>
<div className="p-4" style={{ color: 'var(--ui-text-primary)' }}>
{children || <span>Right Panel</span>}
</div>
</div>

View file

@ -12,6 +12,7 @@ import { buildEditableIssueDraft, buildIssueUpdatePayload, validateEditableIssue
import type { UpdateMutationPayload } from '../../lib/mutations';
import type { BeadIssue } from '../../lib/types';
import { ThreadView, type ThreadItem } from './thread-view';
import { useResponsive } from '../../hooks/use-responsive';
interface ThreadDrawerProps {
isOpen: boolean;
@ -20,34 +21,20 @@ interface ThreadDrawerProps {
id: string;
items?: ThreadItem[];
embedded?: boolean;
takeover?: boolean;
issue?: BeadIssue | null;
projectRoot?: string;
onIssueUpdated?: (issueId: string) => Promise<void> | void;
}
const SAMPLE_ITEMS: ThreadItem[] = [
{
id: '1',
type: 'comment',
author: 'sarah.lee',
content: 'Pushed a first pass for the left rail hierarchy. Need readability check on status chips.',
timestamp: new Date(Date.now() - 6 * 60 * 1000),
},
{
id: '2',
type: 'status_change',
from: 'open',
to: 'in_progress',
timestamp: new Date(Date.now() - 31 * 60 * 1000),
},
{
id: '3',
type: 'protocol_event',
event: 'HANDOFF',
content: 'Swarm integrator picked up follow-up work.',
timestamp: new Date(Date.now() - 55 * 60 * 1000),
},
];
interface CommentFromApi {
id: string;
bead_id: string;
actor: string;
kind: 'comment';
text: string;
timestamp: string;
}
const STATUS_OPTIONS: EditableIssueDraft['status'][] = ['open', 'in_progress', 'blocked', 'deferred', 'closed'];
const PRIORITY_OPTIONS = [0, 1, 2, 3, 4] as const;
@ -65,6 +52,19 @@ async function postIssueUpdate(body: UpdateMutationPayload): Promise<void> {
}
}
async function postComment(projectRoot: string, id: string, text: string): Promise<void> {
const response = await fetch('/api/beads/comment', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ projectRoot, id, text }),
});
const payload = (await response.json()) as { ok: boolean; error?: { message?: string } };
if (!response.ok || !payload.ok) {
throw new Error(payload.error?.message ?? 'Comment failed');
}
}
function saveStateTone(state: 'ready' | 'saving' | 'saved' | 'error'): string {
if (state === 'saving') return 'border-[#5BA8A0]/50 bg-[#5BA8A0]/20 text-[#D6EEEA]';
if (state === 'saved') return 'border-[#7CB97A]/50 bg-[#7CB97A]/20 text-[#D4ECD2]';
@ -77,18 +77,48 @@ export function ThreadDrawer({
onClose,
title,
id,
items = SAMPLE_ITEMS,
items: externalItems,
embedded = false,
takeover = false,
issue,
projectRoot,
onIssueUpdated,
}: ThreadDrawerProps) {
const { isMobile } = useResponsive();
const [comment, setComment] = useState('');
const [commentState, setCommentState] = useState<'ready' | 'sending' | 'sent' | 'error'>('ready');
const [editMode, setEditMode] = useState(false);
const [draft, setDraft] = useState<EditableIssueDraft | null>(issue ? buildEditableIssueDraft(issue) : null);
const [fieldErrors, setFieldErrors] = useState<EditableIssueFieldErrors>({});
const [saveError, setSaveError] = useState<string | null>(null);
const [saveState, setSaveState] = useState<'ready' | 'saving' | 'saved' | 'error'>('ready');
const [comments, setComments] = useState<CommentFromApi[]>([]);
const [commentsLoading, setCommentsLoading] = useState(false);
// Fetch comments when drawer opens
useEffect(() => {
if (!isOpen || !id || !projectRoot) {
setComments([]);
return;
}
const fetchComments = async () => {
setCommentsLoading(true);
try {
const response = await fetch(`/api/beads/${id}/comments?projectRoot=${encodeURIComponent(projectRoot)}`);
const payload = (await response.json()) as { ok: boolean; comments?: CommentFromApi[] };
if (payload.ok && payload.comments) {
setComments(payload.comments);
}
} catch (error) {
console.error('Failed to fetch comments:', error);
} finally {
setCommentsLoading(false);
}
};
fetchComments();
}, [isOpen, id, projectRoot]);
useEffect(() => {
if (!issue) {
@ -109,15 +139,32 @@ export function ThreadDrawer({
const canEdit = Boolean(issue && projectRoot && draft);
// Convert comments to ThreadItems
const threadItems: ThreadItem[] = useMemo(() => {
const items: ThreadItem[] = comments.map(c => ({
id: c.id,
type: 'comment' as const,
author: c.actor,
content: c.text,
timestamp: new Date(c.timestamp),
}));
// Merge with any external items if provided
if (externalItems) {
items.push(...externalItems);
}
// Sort by timestamp descending
return items.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
}, [comments, externalItems]);
const participants = useMemo(() => {
const names = new Set<string>();
for (const item of items) {
for (const item of threadItems) {
if (item.author && item.author.trim()) {
names.add(item.author.trim());
}
}
return Array.from(names).slice(0, 4);
}, [items]);
}, [threadItems]);
const handleSave = async () => {
if (!issue || !projectRoot || !draft) {
@ -155,216 +202,301 @@ export function ThreadDrawer({
}
};
const handleCommentSubmit = async () => {
if (!projectRoot || !id || !comment.trim()) {
return;
}
setCommentState('sending');
try {
await postComment(projectRoot, id, comment.trim());
setComment('');
setCommentState('sent');
// Refresh comments
const response = await fetch(`/api/beads/${id}/comments?projectRoot=${encodeURIComponent(projectRoot)}`);
const payload = (await response.json()) as { ok: boolean; comments?: CommentFromApi[] };
if (payload.ok && payload.comments) {
setComments(payload.comments);
}
await onIssueUpdated?.(id);
setTimeout(() => setCommentState('ready'), 900);
} catch (error) {
console.error('Comment failed:', error);
setCommentState('error');
setTimeout(() => setCommentState('ready'), 2000);
}
};
if (!isOpen) {
return null;
}
return (
<div
className="flex h-full flex-col"
style={{
const frameShellClass = takeover
? 'mx-auto flex h-full w-full max-w-[1120px] flex-col overflow-hidden rounded-xl border border-[var(--ui-border-soft)] bg-[linear-gradient(180deg,color-mix(in_srgb,var(--ui-bg-card)_92%,black),color-mix(in_srgb,var(--ui-bg-shell)_88%,black))] shadow-[0_34px_60px_-40px_rgba(0,0,0,0.84)]'
: 'flex h-full flex-col';
const frameShellStyle = takeover
? undefined
: {
width: embedded ? '100%' : '26rem',
background: 'linear-gradient(180deg, #353535, #2E2E2E)',
background: 'linear-gradient(180deg, var(--ui-bg-card), var(--ui-bg-shell))',
borderLeft: embedded ? 'none' : '1px solid var(--color-border-default)',
boxShadow: embedded ? 'none' : '-20px 0 48px rgba(0,0,0,0.45)',
}}
overscrollBehavior: 'contain' as const,
};
const conversationSection = (
<section className="rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] p-3 shadow-[0_12px_28px_-22px_rgba(0,0,0,0.7)]">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-[var(--ui-text-primary)]">
<MessageSquareText className="h-4 w-4 text-[var(--ui-accent-info)]" />
Conversation
</div>
<div className="flex items-center gap-1">
{participants.map((name) => (
<span key={name} className="inline-flex h-6 items-center rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 text-[11px] text-[var(--ui-text-muted)]">
{name}
</span>
))}
</div>
</div>
<ThreadView items={threadItems} variant={takeover ? 'chat' : 'stack'} currentUser="you" />
</section>
);
const summarySection = (
<section className="rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] p-3 shadow-[0_14px_30px_-24px_rgba(0,0,0,0.75)]">
<div className="mb-3 flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-[var(--ui-text-primary)]">Task Summary</p>
<Badge className={`rounded-full border px-2 py-0.5 text-[11px] ${saveStateTone(saveState)}`}>
{saveState}
</Badge>
</div>
{!issue ? (
<p className="text-sm text-[var(--ui-text-muted)]">No task details available for this thread context.</p>
) : !editMode ? (
<div className="space-y-2 text-sm">
<p className="font-semibold text-[var(--ui-text-primary)]">{issue.title}</p>
<p className="text-[var(--ui-text-muted)]">{issue.description ?? 'No description provided.'}</p>
<div className="flex flex-wrap gap-2">
<Badge className="rounded-full border border-[var(--ui-accent-info)]/40 bg-[var(--ui-accent-info)]/20 text-[#d9f5ff]">{issue.status}</Badge>
<Badge className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] text-[var(--ui-text-muted)]">P{issue.priority}</Badge>
<Badge className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] text-[var(--ui-text-muted)]">{issue.issue_type}</Badge>
{issue.assignee ? <Badge className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] text-[var(--ui-text-muted)]">@{issue.assignee}</Badge> : null}
</div>
<div className="pt-1">
<Button
onClick={() => setEditMode(true)}
disabled={!canEdit}
className="h-8 rounded-full bg-[var(--ui-accent-action-green)] px-4 text-[#081f12] hover:bg-[color-mix(in_srgb,var(--ui-accent-action-green)_86%,white)] disabled:opacity-40"
>
<Edit3 className="mr-2 h-3.5 w-3.5" /> Edit task
</Button>
</div>
</div>
) : (
<div className="space-y-2.5">
<label className="block text-xs text-[#9F9F9F]">
Title
<Input
value={draft?.title ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, title: event.target.value } : current))}
className="mt-1 border-[#4A4A4A] bg-[#3B3B3B] text-white"
/>
</label>
{fieldErrors.title ? <p className="text-xs text-[#EAA7A0]">{fieldErrors.title}</p> : null}
<label className="block text-xs text-[#9F9F9F]">
Description
<textarea
value={draft?.description ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, description: event.target.value } : current))}
className="mt-1 min-h-20 w-full rounded-md border border-[#4A4A4A] bg-[#3B3B3B] px-3 py-2 text-sm text-white outline-none ring-offset-0 placeholder:text-[#808080] focus:border-[#5BA8A0]"
/>
</label>
<div className="grid gap-2 sm:grid-cols-2">
<label className="block text-xs text-[#9F9F9F]">
Assignee
<Input
value={draft?.assignee ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, assignee: event.target.value } : current))}
className="mt-1 border-[#4A4A4A] bg-[#3B3B3B] text-white"
/>
</label>
<label className="block text-xs text-[#9F9F9F]">
Issue type
<Input
value={draft?.issueType ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, issueType: event.target.value } : current))}
className="mt-1 border-[#4A4A4A] bg-[#3B3B3B] text-white"
/>
</label>
</div>
<label className="block text-xs text-[#9F9F9F]">
Labels
<Input
value={draft?.labelsInput ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, labelsInput: event.target.value } : current))}
className="mt-1 border-[#4A4A4A] bg-[#3B3B3B] text-white"
/>
</label>
{fieldErrors.labelsInput ? <p className="text-xs text-[#EAA7A0]">{fieldErrors.labelsInput}</p> : null}
<div>
<p className="mb-1 text-xs text-[#9F9F9F]">Status</p>
<div className="flex flex-wrap gap-2">
{STATUS_OPTIONS.map((status) => (
<button
key={status}
type="button"
onClick={() => setDraft((current) => (current ? { ...current, status } : current))}
className={`rounded-full border px-2 py-1 text-xs ${draft?.status === status ? 'border-[#5BA8A0] bg-[#5BA8A0]/20 text-[#D7ECE9]' : 'border-[#4A4A4A] bg-[#3A3A3A] text-[#B8B8B8]'}`}
>
{status}
</button>
))}
</div>
</div>
<div>
<p className="mb-1 text-xs text-[#9F9F9F]">Priority</p>
<div className="flex flex-wrap gap-2">
{PRIORITY_OPTIONS.map((priority) => (
<button
key={priority}
type="button"
onClick={() => setDraft((current) => (current ? { ...current, priority } : current))}
className={`rounded-full border px-2 py-1 text-xs ${draft?.priority === priority ? 'border-[#D4A574] bg-[#D4A574]/20 text-[#EBD7BD]' : 'border-[#4A4A4A] bg-[#3A3A3A] text-[#B8B8B8]'}`}
>
P{priority}
</button>
))}
</div>
</div>
{saveError ? <p className="text-xs text-[#EAA7A0]">{saveError}</p> : null}
<div className="flex justify-end gap-2 pt-1">
<Button
variant="outline"
onClick={() => setEditMode(false)}
className="h-8 rounded-full border-[#4A4A4A] bg-[#3B3B3B] px-4 text-[#C0C0C0] hover:bg-[#444444]"
>
Cancel
</Button>
<Button
onClick={() => void handleSave()}
className="h-8 rounded-full bg-[#7CB97A] px-4 text-[#1A1A1A] hover:bg-[#8ECC8C]"
>
Save
</Button>
</div>
</div>
)}
</section>
);
return (
<div
className={takeover ? 'h-full p-4 md:p-6' : 'h-full'}
style={
isMobile
? {
paddingTop: takeover ? 'max(1rem, env(safe-area-inset-top))' : undefined,
paddingBottom: takeover ? 'max(1rem, env(safe-area-inset-bottom))' : undefined,
}
: undefined
}
>
<header className="border-b border-[#4A4A4A] bg-[#363636]/90 px-4 py-3">
<div className={frameShellClass} style={frameShellStyle}>
<header className="border-b border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[#8F8F8F]">Open Thread</p>
<h2 className="truncate text-lg font-semibold text-white" title={title}>{title}</h2>
<p className="text-xs text-[#A5A5A5]">{id} · {items.length} events</p>
<div className="mb-1 flex items-center gap-2">
<p className="font-mono text-xs font-semibold text-[var(--ui-accent-info)]">#{id}</p>
<span className="rounded-full border border-[var(--ui-accent-ready)]/45 bg-[var(--ui-accent-ready)]/20 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] text-[#d8ffe8]">
In Progress
</span>
</div>
<h2 className="truncate text-[40px] font-semibold leading-[1.12] tracking-[-0.02em] text-[var(--ui-text-primary)]" title={title}>{title}</h2>
<p className="mt-1 text-xs text-[var(--ui-text-muted)]">{threadItems.length} events</p>
</div>
<Button
onClick={onClose}
variant="ghost"
className="h-8 w-8 rounded-full p-0 text-[#B8B8B8] hover:bg-white/10 hover:text-white"
className="h-8 w-8 rounded-full p-0 text-[var(--ui-text-muted)] hover:bg-white/10 hover:text-[var(--ui-text-primary)]"
aria-label="Close thread"
>
<X className="h-4 w-4" />
</Button>
</div>
</header>
</header>
<ScrollArea className="flex-1">
<div className="space-y-3 p-4">
<section className="rounded-xl border border-[#4A4A4A] bg-[#303030] p-3 shadow-[0_12px_28px_-22px_rgba(0,0,0,0.7)]">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-[#DCDCDC]">
<MessageSquareText className="h-4 w-4 text-[#5BA8A0]" />
Conversation
</div>
<div className="flex items-center gap-1">
{participants.map((name) => (
<span key={name} className="inline-flex h-6 items-center rounded-full border border-white/10 bg-white/5 px-2 text-[11px] text-[#CFCFCF]">
{name}
</span>
))}
</div>
</div>
<ThreadView items={items} />
</section>
<section className="rounded-xl border border-[#4A4A4A] bg-[#303030] p-3 shadow-[0_14px_30px_-24px_rgba(0,0,0,0.75)]">
<div className="mb-3 flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-white">Task summary</p>
<Badge className={`rounded-full border px-2 py-0.5 text-[11px] ${saveStateTone(saveState)}`}>
{saveState}
</Badge>
</div>
{!issue ? (
<p className="text-sm text-[#9E9E9E]">No task details available for this thread context.</p>
) : !editMode ? (
<div className="space-y-2 text-sm">
<p className="font-semibold text-[#F4F4F4]">{issue.title}</p>
<p className="text-[#B8B8B8]">{issue.description ?? 'No description provided.'}</p>
<div className="flex flex-wrap gap-2">
<Badge className="rounded-full border border-[#5BA8A0]/40 bg-[#5BA8A0]/20 text-[#CFE7E3]">{issue.status}</Badge>
<Badge className="rounded-full border border-white/10 bg-white/5 text-[#CFCFCF]">P{issue.priority}</Badge>
<Badge className="rounded-full border border-white/10 bg-white/5 text-[#CFCFCF]">{issue.issue_type}</Badge>
{issue.assignee ? <Badge className="rounded-full border border-white/10 bg-white/5 text-[#CFCFCF]">@{issue.assignee}</Badge> : null}
</div>
<div className="pt-1">
<Button
onClick={() => setEditMode(true)}
disabled={!canEdit}
className="h-8 rounded-full bg-[#7CB97A] px-4 text-[#1A1A1A] hover:bg-[#8FCC8D] disabled:opacity-40"
>
<Edit3 className="mr-2 h-3.5 w-3.5" /> Edit task
</Button>
</div>
</div>
<ScrollArea className="flex-1">
<div className="space-y-3 p-4">
{takeover ? (
<>
{summarySection}
{conversationSection}
</>
) : (
<div className="space-y-2.5">
<label className="block text-xs text-[#9F9F9F]">
Title
<Input
value={draft?.title ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, title: event.target.value } : current))}
className="mt-1 border-[#4A4A4A] bg-[#3B3B3B] text-white"
/>
</label>
{fieldErrors.title ? <p className="text-xs text-[#EAA7A0]">{fieldErrors.title}</p> : null}
<label className="block text-xs text-[#9F9F9F]">
Description
<textarea
value={draft?.description ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, description: event.target.value } : current))}
className="mt-1 min-h-20 w-full rounded-md border border-[#4A4A4A] bg-[#3B3B3B] px-3 py-2 text-sm text-white outline-none ring-offset-0 placeholder:text-[#808080] focus:border-[#5BA8A0]"
/>
</label>
<div className="grid gap-2 sm:grid-cols-2">
<label className="block text-xs text-[#9F9F9F]">
Assignee
<Input
value={draft?.assignee ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, assignee: event.target.value } : current))}
className="mt-1 border-[#4A4A4A] bg-[#3B3B3B] text-white"
/>
</label>
<label className="block text-xs text-[#9F9F9F]">
Issue type
<Input
value={draft?.issueType ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, issueType: event.target.value } : current))}
className="mt-1 border-[#4A4A4A] bg-[#3B3B3B] text-white"
/>
</label>
</div>
<label className="block text-xs text-[#9F9F9F]">
Labels
<Input
value={draft?.labelsInput ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, labelsInput: event.target.value } : current))}
className="mt-1 border-[#4A4A4A] bg-[#3B3B3B] text-white"
/>
</label>
{fieldErrors.labelsInput ? <p className="text-xs text-[#EAA7A0]">{fieldErrors.labelsInput}</p> : null}
<div>
<p className="mb-1 text-xs text-[#9F9F9F]">Status</p>
<div className="flex flex-wrap gap-2">
{STATUS_OPTIONS.map((status) => (
<button
key={status}
type="button"
onClick={() => setDraft((current) => (current ? { ...current, status } : current))}
className={`rounded-full border px-2 py-1 text-xs ${draft?.status === status ? 'border-[#5BA8A0] bg-[#5BA8A0]/20 text-[#D7ECE9]' : 'border-[#4A4A4A] bg-[#3A3A3A] text-[#B8B8B8]'}`}
>
{status}
</button>
))}
</div>
</div>
<div>
<p className="mb-1 text-xs text-[#9F9F9F]">Priority</p>
<div className="flex flex-wrap gap-2">
{PRIORITY_OPTIONS.map((priority) => (
<button
key={priority}
type="button"
onClick={() => setDraft((current) => (current ? { ...current, priority } : current))}
className={`rounded-full border px-2 py-1 text-xs ${draft?.priority === priority ? 'border-[#D4A574] bg-[#D4A574]/20 text-[#EBD7BD]' : 'border-[#4A4A4A] bg-[#3A3A3A] text-[#B8B8B8]'}`}
>
P{priority}
</button>
))}
</div>
</div>
{saveError ? <p className="text-xs text-[#EAA7A0]">{saveError}</p> : null}
<div className="flex justify-end gap-2 pt-1">
<Button
variant="outline"
onClick={() => setEditMode(false)}
className="h-8 rounded-full border-[#4A4A4A] bg-[#3B3B3B] px-4 text-[#C0C0C0] hover:bg-[#444444]"
>
Cancel
</Button>
<Button
onClick={() => void handleSave()}
className="h-8 rounded-full bg-[#7CB97A] px-4 text-[#1A1A1A] hover:bg-[#8ECC8C]"
>
Save
</Button>
</div>
</div>
<>
{conversationSection}
{summarySection}
</>
)}
</section>
</div>
</ScrollArea>
</div>
</ScrollArea>
<footer className="border-t border-[#4A4A4A] bg-[#2F2F2F] p-3">
<div className="flex items-center gap-2 rounded-xl border border-[#4A4A4A] bg-[#3A3A3A] p-1">
<Input
value={comment}
onChange={(event) => setComment(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && comment.trim()) {
event.preventDefault();
setComment('');
}
}}
placeholder="Reply to thread..."
className="border-0 bg-transparent text-white placeholder:text-[#888888]"
/>
<Button
type="button"
className="h-8 rounded-full bg-[#5BA8A0] px-3 text-[#1A1A1A] hover:bg-[#6AB8AF]"
onClick={() => setComment('')}
disabled={!comment.trim()}
>
<Send className="h-3.5 w-3.5" />
</Button>
</div>
</footer>
<footer
className="border-t border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] p-3"
style={
isMobile
? {
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))',
position: 'sticky',
bottom: 0,
zIndex: 10,
}
: undefined
}
>
<div className="flex items-center gap-2 rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] p-1">
<Input
value={comment}
onChange={(event) => setComment(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && comment.trim()) {
event.preventDefault();
void handleCommentSubmit();
}
}}
placeholder="Type a message to neighbors..."
className="border-0 bg-transparent text-[var(--ui-text-primary)] placeholder:text-[var(--ui-text-muted)]"
autoComplete="off"
disabled={commentState === 'sending'}
/>
<Button
type="button"
className="h-8 rounded-full bg-[var(--ui-accent-action-green)] px-3 text-[#082012] hover:bg-[color-mix(in_srgb,var(--ui-accent-action-green)_86%,white)] disabled:opacity-50"
onClick={() => void handleCommentSubmit()}
disabled={!comment.trim() || commentState === 'sending'}
>
<Send className="h-3.5 w-3.5" />
</Button>
</div>
{commentState === 'error' && (
<p className="mt-1 text-xs text-[#EAA7A0]">Failed to send comment</p>
)}
</footer>
</div>
{takeover ? (
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_8%_10%,rgba(91,168,160,0.14),transparent_32%),radial-gradient(circle_at_92%_88%,rgba(212,165,116,0.16),transparent_30%)]" />
) : null}
</div>
);
}

View file

@ -19,6 +19,8 @@ export interface ThreadItem {
interface ThreadViewProps {
items: ThreadItem[];
variant?: 'stack' | 'chat';
currentUser?: string;
onAddComment?: (text: string) => void;
}
@ -72,28 +74,40 @@ function getProtocolLabel(event?: string): string {
}
}
function CommentItem({ item }: { item: ThreadItem }) {
function CommentItem({ item, isSelf }: { item: ThreadItem; isSelf: boolean }) {
return (
<div className="flex gap-3 py-3">
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarImage src={undefined} alt={item.author} />
<AvatarFallback className="bg-surface-muted text-text-body text-xs font-semibold">
{item.author ? getInitials(item.author) : '??'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-text-primary text-sm font-medium">
{item.author || 'Unknown'}
</span>
<span className="text-text-muted text-xs">
{formatRelativeTime(item.timestamp)}
</span>
<div className={cn('flex gap-3 py-3', isSelf && 'justify-end')}>
{!isSelf ? (
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarImage src={undefined} alt={item.author} />
<AvatarFallback className="bg-surface-muted text-text-body text-xs font-semibold">
{item.author ? getInitials(item.author) : '??'}
</AvatarFallback>
</Avatar>
) : null}
<div className={cn('min-w-0 max-w-[80%]', isSelf && 'items-end')}>
<div className={cn('mb-1 flex items-center gap-2', isSelf && 'justify-end')}>
<span className="text-text-primary text-sm font-semibold">{item.author || 'Unknown'}</span>
<span className="font-mono text-[11px] text-text-muted">{formatRelativeTime(item.timestamp)}</span>
</div>
<p className="text-text-secondary text-sm whitespace-pre-wrap break-words">
<p
className={cn(
'whitespace-pre-wrap break-words rounded-xl px-3 py-2 text-base leading-relaxed',
isSelf
? 'bg-[color-mix(in_srgb,var(--ui-accent-ready)_24%,var(--ui-bg-panel))] text-[var(--ui-text-primary)]'
: 'bg-[color-mix(in_srgb,var(--ui-bg-panel)_88%,black)] text-text-secondary',
)}
>
{item.content}
</p>
</div>
{isSelf ? (
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarFallback className="bg-[color-mix(in_srgb,var(--ui-accent-ready)_40%,var(--ui-bg-panel))] text-text-body text-xs font-semibold">
{item.author ? getInitials(item.author) : 'ME'}
</AvatarFallback>
</Avatar>
) : null}
</div>
);
}
@ -133,7 +147,9 @@ function ProtocolEventItem({ item }: { item: ThreadItem }) {
);
}
export function ThreadView({ items, onAddComment }: ThreadViewProps) {
export function ThreadView({ items, variant = 'stack', currentUser = 'you', onAddComment }: ThreadViewProps) {
void onAddComment;
return (
<div className="space-y-1">
{items.length === 0 ? (
@ -143,7 +159,13 @@ export function ThreadView({ items, onAddComment }: ThreadViewProps) {
{items.map((item) => {
switch (item.type) {
case 'comment':
return <CommentItem key={item.id} item={item} />;
return (
<CommentItem
key={item.id}
item={item}
isSelf={variant === 'chat' && (item.author ?? '').trim().toLowerCase() === currentUser.toLowerCase()}
/>
);
case 'status_change':
return <StatusChangeItem key={item.id} item={item} />;
case 'protocol_event':

View file

@ -1,124 +1,148 @@
'use client';
import { ReactNode } from 'react';
import { useUrlState, ViewType } from '../../hooks/use-url-state';
import { LayoutGrid, Lock, Plus, Sidebar, SidebarClose } from 'lucide-react';
import { useUrlState } from '../../hooks/use-url-state';
import { useResponsive } from '../../hooks/use-responsive';
export interface TopBarProps {
onCreateTask?: () => Promise<void> | void;
isCreatingTask?: boolean;
taskActionMessage?: string;
children?: ReactNode;
totalTasks?: number;
criticalAlerts?: number;
idleCount?: number;
busyCount?: number;
}
export function TopBar({ children }: TopBarProps) {
const { view, setView, togglePanel } = useUrlState();
const { isDesktop } = useResponsive();
interface MetricTileProps {
label: string;
value: number;
accent?: 'ready' | 'blocked' | 'info' | 'warning';
}
const tabs: { id: ViewType; label: string }[] = [
{ id: 'social', label: 'Social' },
{ id: 'graph', label: 'Graph' },
{ id: 'swarm', label: 'Swarm' },
];
const showHamburger = !isDesktop;
function MetricTile({ label, value, accent = 'info' }: MetricTileProps) {
const accentColor =
accent === 'ready'
? 'var(--ui-accent-ready)'
: accent === 'blocked'
? 'var(--ui-accent-blocked)'
: accent === 'warning'
? 'var(--ui-accent-warning)'
: 'var(--ui-accent-info)';
return (
<header
className="h-12 flex items-center justify-between px-4"
style={{
background:
'radial-gradient(circle_at_10%_50%,rgba(212,165,116,0.14),transparent_30%),radial-gradient(circle_at_90%_40%,rgba(91,168,160,0.14),transparent_30%),var(--color-bg-card)',
boxShadow: '0 14px 22px -20px rgba(0,0,0,0.85)',
}}
data-testid="top-bar"
>
<div className="flex items-center gap-2">
{showHamburger && (
<button
onClick={togglePanel}
className="p-2 transition-colors hover:text-[var(--color-text-primary)]"
style={{ color: 'var(--color-text-secondary)' }}
aria-label="Open menu"
data-testid="hamburger-button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
)}
<nav className="flex items-center gap-1" role="tablist">
{tabs.map((tab) => {
const isActive = view === tab.id;
return (
<button
key={tab.id}
onClick={() => setView(tab.id)}
role="tab"
aria-selected={isActive}
className={`px-4 py-2 text-sm transition-colors rounded-md ${
isActive
? 'font-bold shadow-[inset_0_-2px_0_var(--color-accent-green),0_10px_18px_-14px_rgba(0,0,0,0.8)] bg-white/[0.03]'
: 'font-normal hover:text-[var(--color-text-primary)]'
}`}
style={{
color: isActive ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
}}
data-testid={`tab-${tab.id}`}
>
{tab.label}
</button>
);
})}
</nav>
<div className="hidden items-center gap-2 rounded-md border border-[var(--ui-border-soft)] bg-[color-mix(in_srgb,var(--ui-bg-panel)_84%,black)] px-2.5 py-1 text-xs md:inline-flex">
<p className="font-mono text-[10px] uppercase tracking-[0.13em] text-[var(--ui-text-muted)]">{label}</p>
<p className="font-mono text-sm leading-none text-[var(--ui-text-primary)]">{value}</p>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: accentColor }} />
</div>
);
}
export function TopBar({
onCreateTask,
isCreatingTask = false,
taskActionMessage,
children,
totalTasks = 0,
criticalAlerts = 0,
idleCount = 0,
busyCount = 0,
}: TopBarProps) {
const { leftPanel, toggleLeftPanel, rightPanel, toggleRightPanel, blockedOnly, toggleBlockedOnly } = useUrlState();
const { isDesktop } = useResponsive();
return (
<header className="ui-shell-topbar flex h-[var(--topbar-height)] items-center justify-between border-b border-[var(--ui-border-soft)]" data-testid="top-bar">
<div className="flex min-w-0 items-center">
<button
type="button"
onClick={toggleLeftPanel}
className="ml-3 mr-2 inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--ui-text-muted)] transition-colors hover:bg-white/5 hover:text-[var(--ui-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]"
aria-label={leftPanel === 'open' ? 'Collapse Sidebar' : 'Expand Sidebar'}
aria-pressed={leftPanel === 'open'}
data-testid="hamburger-button"
>
{leftPanel === 'open' ? <SidebarClose className="h-4 w-4" aria-hidden="true" /> : <Sidebar className="h-4 w-4" aria-hidden="true" />}
</button>
<div className="mr-3 flex min-w-[210px] items-center gap-2 border-r border-[var(--ui-border-soft)] px-2 py-2">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-[color-mix(in_srgb,var(--ui-accent-ready)_24%,var(--ui-bg-panel))] text-[var(--ui-accent-ready)]">
<LayoutGrid className="h-5 w-5" aria-hidden="true" />
</div>
<div>
<p className="text-sm font-semibold uppercase tracking-[0.04em] text-[var(--ui-text-primary)]">Command Grid</p>
<p className="font-mono text-[10px] text-[var(--ui-text-muted)]">v2.4.0-stable</p>
</div>
</div>
<div className="hidden items-center gap-2 pl-2 md:flex">
<MetricTile label="Total" value={totalTasks} accent="ready" />
<MetricTile label="Blocked" value={criticalAlerts} accent="blocked" />
<MetricTile label="Busy" value={busyCount} accent="warning" />
<MetricTile label="Idle" value={idleCount} accent="info" />
</div>
</div>
<div className="flex items-center gap-3">
{children || (
<div className="mr-3 flex items-center gap-2">
{children ?? (
<>
<input
type="text"
placeholder="Filter..."
className="px-3 py-1.5 text-sm rounded focus:outline-none"
style={{
backgroundColor: 'var(--color-bg-input)',
color: 'var(--color-text-primary)',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.08), 0 8px 14px -12px rgba(0,0,0,0.85)',
}}
data-testid="filter-input"
/>
<button
className="p-2 transition-colors hover:text-[var(--color-text-primary)]"
style={{ color: 'var(--color-text-secondary)' }}
aria-label="Settings"
data-testid="settings-button"
type="button"
onClick={toggleBlockedOnly}
aria-pressed={blockedOnly}
className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.11em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]"
style={{
borderColor: blockedOnly
? 'color-mix(in srgb, var(--ui-accent-blocked) 78%, transparent)'
: 'var(--ui-border-soft)',
backgroundColor: blockedOnly
? 'color-mix(in srgb, var(--ui-accent-blocked) 20%, var(--ui-bg-panel))'
: 'color-mix(in srgb, var(--ui-bg-panel) 88%, black)',
color: blockedOnly ? '#ffd4dd' : 'var(--ui-text-primary)',
}}
data-testid="blocked-items-button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<Lock className="h-3.5 w-3.5" aria-hidden="true" />
Blocked Items
<span className="rounded-full bg-[color-mix(in_srgb,var(--ui-accent-blocked)_84%,black)] px-1.5 py-0.5 font-mono text-[10px] text-[#fff0f3]">
{criticalAlerts}
</span>
</button>
<button
type="button"
onClick={() => {
void onCreateTask?.();
}}
disabled={isCreatingTask}
className="inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_srgb,var(--ui-accent-ready)_80%,black)] bg-[var(--ui-accent-action-green)] px-4 py-2 text-xs font-semibold uppercase tracking-[0.11em] text-[#072514] transition-colors hover:bg-[color-mix(in_srgb,var(--ui-accent-action-green)_84%,white)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)] disabled:opacity-60"
data-testid="new-task-button"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
{isCreatingTask ? 'Creating…' : 'New Task'}
</button>
</>
)}
{isDesktop ? (
<button
type="button"
onClick={toggleRightPanel}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--ui-text-muted)] transition-colors hover:bg-white/5 hover:text-[var(--ui-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]"
aria-label={rightPanel === 'open' ? 'Collapse Right Sidebar' : 'Expand Right Sidebar'}
aria-pressed={rightPanel === 'open'}
data-testid="settings-button"
>
<Sidebar className="h-4 w-4" aria-hidden="true" />
</button>
) : null}
<span className="sr-only" aria-live="polite">
{taskActionMessage ?? ''}
</span>
</div>
</header>
);

View file

@ -115,6 +115,7 @@ export function UnifiedShell({
return (
<SwarmWorkspace
selectedMissionId={swarmId ?? undefined}
issues={filteredIssues}
/>
);
}

View file

@ -1,4 +1,4 @@
import type { MouseEventHandler } from 'react';
import type { KeyboardEvent, MouseEventHandler } from 'react';
import { Activity, Clock3, GitBranch, Link2, MessageCircle, Orbit } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
@ -20,67 +20,90 @@ interface SocialCardProps {
dependencyCount?: number;
commentCount?: number;
unreadCount?: number;
blockedByDetails?: Array<{ id: string; title: string; epic?: string }>;
unblocksDetails?: Array<{ id: string; title: string; epic?: string }>;
}
type StatusTone = {
accent: string;
glow: string;
badgeClass: string;
surface: string;
accentChip: string;
};
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, onClick?: MouseEventHandler<HTMLDivElement>) {
if (!onClick) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onClick(event as unknown as Parameters<MouseEventHandler<HTMLDivElement>>[0]);
}
const STATUS_TONES: Record<SocialCardData['status'], StatusTone> = {
ready: {
accent: '#7CB97A',
glow: 'rgba(124,185,122,0.26)',
badgeClass: 'bg-[#7CB97A]/26 text-[#DCEED8] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
surface:
'radial-gradient(circle at 80% 78%, rgba(124,185,122,0.46), transparent 76%), radial-gradient(circle at 8% 6%, rgba(124,185,122,0.26), transparent 68%), linear-gradient(145deg, rgba(45,78,45,0.99), rgba(35,62,35,0.99))',
accentChip: 'bg-[#7CB97A]/18 text-[#D2E4CE] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
},
in_progress: {
accent: '#D4A574',
glow: 'rgba(212,165,116,0.28)',
badgeClass: 'bg-[#D4A574]/28 text-[#EED9C1] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
surface:
'radial-gradient(circle at 80% 78%, rgba(212,165,116,0.48), transparent 76%), radial-gradient(circle at 8% 6%, rgba(212,165,116,0.28), transparent 68%), linear-gradient(145deg, rgba(86,64,40,0.99), rgba(68,49,30,0.99))',
accentChip: 'bg-[#D4A574]/20 text-[#E0C6A7] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
},
blocked: {
accent: '#C97A7A',
glow: 'rgba(201,122,122,0.26)',
badgeClass: 'bg-[#C97A7A]/28 text-[#EDD3D3] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
surface:
'radial-gradient(circle at 80% 78%, rgba(201,122,122,0.46), transparent 76%), radial-gradient(circle at 8% 6%, rgba(201,122,122,0.27), transparent 68%), linear-gradient(145deg, rgba(76,46,46,0.99), rgba(60,36,36,0.99))',
accentChip: 'bg-[#C97A7A]/18 text-[#E1C0C0] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
},
closed: {
accent: 'var(--status-closed)',
glow: 'rgba(136,136,136,0.16)',
badgeClass: 'bg-[#888888]/26 text-[#CECECE] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
surface:
'radial-gradient(circle at 80% 78%, rgba(136,136,136,0.32), transparent 76%), radial-gradient(circle at 8% 6%, rgba(136,136,136,0.16), transparent 68%), linear-gradient(145deg, rgba(56,56,56,0.99), rgba(44,44,44,0.99))',
accentChip: 'bg-[#888888]/16 text-[#BEBEBE] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
},
};
function renderDependencyPreview(ids: string[], toneClass: string, label: string) {
if (ids.length === 0) {
return null;
function statusVisual(status: SocialCardData['status']) {
if (status === 'blocked') {
return {
border: 'color-mix(in srgb, var(--ui-accent-blocked) 50%, var(--ui-border-soft))',
cardBg:
'linear-gradient(160deg, color-mix(in srgb, var(--ui-accent-blocked) 20%, #1a0f15), color-mix(in srgb, var(--ui-bg-shell) 92%, black))',
badgeBg: 'color-mix(in srgb, var(--ui-accent-blocked) 24%, transparent)',
badgeText: '#ffd5df',
chipText: 'Blocked',
};
}
if (status === 'in_progress') {
return {
border: 'color-mix(in srgb, var(--ui-accent-warning) 50%, var(--ui-border-soft))',
cardBg:
'linear-gradient(160deg, color-mix(in srgb, var(--ui-accent-warning) 16%, #1a1510), color-mix(in srgb, var(--ui-bg-shell) 92%, black))',
badgeBg: 'color-mix(in srgb, var(--ui-accent-warning) 24%, transparent)',
badgeText: '#ffe5c7',
chipText: 'Active',
};
}
if (status === 'ready') {
return {
border: 'color-mix(in srgb, var(--ui-accent-ready) 50%, var(--ui-border-soft))',
cardBg:
'linear-gradient(160deg, color-mix(in srgb, var(--ui-accent-ready) 16%, #101a15), color-mix(in srgb, var(--ui-bg-shell) 92%, black))',
badgeBg: 'color-mix(in srgb, var(--ui-accent-ready) 24%, transparent)',
badgeText: '#d6ffe7',
chipText: 'Ready',
};
}
return {
border: 'var(--ui-border-strong)',
cardBg: 'linear-gradient(160deg, color-mix(in srgb, var(--ui-bg-card) 95%, black), color-mix(in srgb, var(--ui-bg-shell) 90%, black))',
badgeBg: 'color-mix(in srgb, var(--ui-border-strong) 24%, transparent)',
badgeText: 'var(--ui-text-muted)',
chipText: 'Closed',
};
}
function dependencyPanel(
title: string,
color: string,
details: Array<{ id: string; title: string; epic?: string }>,
) {
if (details.length === 0) return null;
return (
<div className="min-w-0 rounded-lg bg-black/20 px-2 py-1.5 shadow-[0_10px_18px_-14px_rgba(0,0,0,0.85)]">
<p className={cn('mb-1 text-[10px] font-semibold uppercase tracking-[0.12em]', toneClass)}>{label}</p>
<div className="flex flex-wrap gap-1">
{ids.slice(0, 2).map((id) => (
<span key={id} className="rounded-md bg-white/10 px-1.5 py-0.5 font-mono text-[10px] text-[#DCDCDC] shadow-[0_8px_12px_-12px_rgba(0,0,0,0.88)]">
{id}
</span>
<div className="rounded-md border border-[var(--ui-border-soft)] bg-[color-mix(in_srgb,var(--ui-bg-panel)_82%,black)] px-2.5 py-2">
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.12em]" style={{ color }}>
{title}
</p>
<div className="space-y-1.5">
{details.slice(0, 1).map((item) => (
<div
key={`${title}-${item.id}`}
className="rounded border border-[var(--ui-border-soft)] bg-[color-mix(in_srgb,var(--ui-bg-card)_88%,black)] px-2 py-1.5"
>
<div className="mb-0.5 flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--ui-accent-info)]" />
<span className="font-mono text-[10px] text-[var(--ui-text-muted)]">{item.id}</span>
</div>
<p className="line-clamp-1 text-xs text-[var(--ui-text-primary)]">{item.title}</p>
{item.epic ? (
<p className="line-clamp-1 text-[10px] text-[var(--ui-accent-info)]"> {item.epic}</p>
) : null}
</div>
))}
{ids.length > 2 ? <span className="text-[10px] text-[#8E8E8E]">+{ids.length - 2}</span> : null}
</div>
{details.length > 1 ? <p className="mt-1 text-[11px] text-[var(--ui-text-muted)]">+{details.length - 1} more</p> : null}
</div>
);
}
@ -98,131 +121,116 @@ export function SocialCard({
dependencyCount,
commentCount,
unreadCount = 0,
blockedByDetails = [],
unblocksDetails = [],
}: SocialCardProps) {
const tone = STATUS_TONES[data.status];
const status = statusVisual(data.status);
return (
<div
onClick={onClick}
onKeyDown={(event) => handleCardKeyDown(event, onClick)}
role="button"
tabIndex={0}
aria-label={`Open ${data.title}`}
className={cn(
'group relative flex h-full min-h-[18rem] cursor-pointer flex-col rounded-2xl px-4 py-4 text-left transition-all duration-200 ease-out',
'hover:-translate-y-0.5',
selected && 'translate-y-[-2px]',
'group relative flex min-h-[290px] cursor-pointer flex-col rounded-[14px] border px-3.5 py-3 text-left transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]',
className,
)}
style={{
background: tone.surface,
background: status.cardBg,
borderColor: selected ? status.border : 'var(--ui-border-soft)',
boxShadow: selected
? `0 24px 50px -18px ${tone.glow}, 0 10px 24px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.12)`
: `0 12px 24px -20px ${tone.glow}, 0 6px 14px rgba(0,0,0,0.38), inset 0 1px 0 rgba(255,255,255,0.06)`,
? '0 24px 40px -26px rgba(0,0,0,0.85), 0 0 0 1px color-mix(in srgb, var(--ui-border-strong) 66%, transparent)'
: '0 12px 26px -24px rgba(0,0,0,0.82)',
}}
>
<div className="absolute inset-x-0 top-0 h-[4px]" style={{ backgroundColor: tone.accent }} />
<div
className="pointer-events-none absolute right-3 top-3 h-10 w-10 rounded-full blur-xl"
style={{ backgroundColor: tone.glow }}
/>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-[11px] text-[#A8D0CB]">{data.id}</span>
<Badge className="rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em]" style={{ backgroundColor: status.badgeBg, color: status.badgeText }}>
{status.chipText}
</Badge>
<span className="font-mono text-[11px] text-[var(--ui-accent-info)]">{data.priority}</span>
<span className="truncate font-mono text-[11px] text-[var(--ui-text-muted)]">{data.id}</span>
{unreadCount > 0 ? (
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-[#E24A3A] px-1 text-[10px] font-semibold text-white">
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--ui-accent-action-red)] px-1 text-[10px] font-semibold text-white">
{unreadCount}
</span>
) : null}
</div>
<div className="flex items-center gap-2">
<Badge className={cn('rounded-full px-2 py-0.5 text-[10px] font-semibold', tone.badgeClass)}>
{data.status.replace('_', ' ')}
</Badge>
<Badge className="rounded-full bg-black/25 px-2 py-0.5 font-mono text-[10px] text-[#D0D0D0] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]">
{data.priority}
</Badge>
</div>
</div>
<h3 className="line-clamp-2 text-[1.7rem] font-semibold leading-[1.1] tracking-[-0.02em] text-white">
{data.title}
</h3>
<p className="mt-2 line-clamp-2 min-h-[2.6rem] text-sm leading-relaxed text-[#B8B8B8]">
<h3 className="line-clamp-2 text-[27px] font-semibold leading-[1.13] tracking-[-0.01em] text-[var(--ui-text-primary)]">{data.title}</h3>
<p className="mt-1.5 line-clamp-3 min-h-[56px] text-[13px] leading-relaxed text-[var(--ui-text-muted)]">
{description || 'No summary provided yet.'}
</p>
<div className="mt-2 flex flex-wrap gap-2">
<span className="rounded-full bg-[#D4A574]/28 px-2 py-0.5 text-[10px] font-semibold text-[#F5DFC2] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.82)]">
{data.blocks.length} blocking
</span>
<span className="rounded-full bg-[#E57373]/24 px-2 py-0.5 text-[10px] font-semibold text-[#F3C2C2] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.82)]">
{data.unblocks.length} blocked by
</span>
<div className="mt-2 flex flex-col gap-2">
{dependencyPanel('Blocked By', 'var(--ui-accent-blocked)', blockedByDetails)}
{dependencyPanel('Unblocks', 'var(--ui-accent-ready)', unblocksDetails)}
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{renderDependencyPreview(data.unblocks, 'text-[#D4A574]', 'Blocked By')}
{renderDependencyPreview(data.blocks, 'text-[#5BA8A0]', 'Unblocks')}
<div className="mt-2 flex items-center gap-2">
{data.agents.slice(0, 3).map((agent) => (
<AgentAvatar
key={`${data.id}-${agent.name}`}
name={agent.name}
status={agent.status as AgentStatus}
role={agent.role}
size="sm"
/>
))}
{data.agents.length === 0 ? <span className="text-xs text-[var(--ui-text-muted)]">No crew</span> : null}
</div>
<div className="mt-auto flex items-end justify-between gap-3 pt-4">
<div className="space-y-1.5 text-xs text-[#9A9A9A]">
<p className="inline-flex items-center gap-1.5"><Clock3 className="h-3.5 w-3.5" />{updatedLabel}</p>
<div className="flex items-center gap-3">
<p className="inline-flex items-center gap-1"><Link2 className="h-3.5 w-3.5" />{dependencyCount ?? data.blocks.length + data.unblocks.length}</p>
<p className="inline-flex items-center gap-1"><MessageCircle className="h-3.5 w-3.5" />{commentCount ?? 0}</p>
<div className="mt-auto border-t border-[var(--ui-border-soft)] pt-1.5">
<div className="mb-1.5 flex items-center justify-between text-xs text-[var(--ui-text-muted)]">
<span className="inline-flex items-center gap-1"><Clock3 className="h-3.5 w-3.5" aria-hidden="true" />{updatedLabel}</span>
<span className="font-mono text-[11px] text-[var(--ui-accent-ready)]">stage active</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-xs text-[var(--ui-text-muted)]">
<span className="inline-flex items-center gap-1"><Link2 className="h-3.5 w-3.5" aria-hidden="true" />{dependencyCount ?? data.blocks.length + data.unblocks.length}</span>
<span className="inline-flex items-center gap-1"><MessageCircle className="h-3.5 w-3.5" aria-hidden="true" />{commentCount ?? 0}</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onJumpToGraph?.(data.id);
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] text-[var(--ui-accent-info)] transition-colors hover:bg-white/5"
aria-label="Open in graph"
>
<GitBranch className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onJumpToActivity?.(data.id);
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] text-[var(--ui-accent-warning)] transition-colors hover:bg-white/5"
aria-label="Open in activity"
>
<Orbit className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onOpenThread?.();
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] text-[var(--ui-accent-ready)] transition-colors hover:bg-white/5"
aria-label="Open thread"
>
<Activity className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</div>
<div className="flex items-center -space-x-2">
{data.agents.slice(0, 4).map((agent) => (
<div key={`${data.id}-${agent.name}`} className="rounded-full ring-2 ring-[#2C2C2C]">
<AgentAvatar
name={agent.name}
status={agent.status as AgentStatus}
role={agent.role}
size="sm"
/>
</div>
))}
{data.agents.length === 0 ? <span className="text-xs text-[#808080]">No crew</span> : null}
</div>
</div>
<div className="mt-3 flex items-center justify-end gap-1 pt-2 shadow-[inset_0_10px_12px_-14px_rgba(0,0,0,0.88)]">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onJumpToGraph?.(data.id);
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[#5BA8A0]/24 text-[#AFE2DC] shadow-[0_10px_16px_-12px_rgba(0,0,0,0.8)] transition-colors hover:bg-[#5BA8A0]/36"
title="Jump to graph view"
>
<GitBranch className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onJumpToActivity?.(data.id);
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[#D4A574]/24 text-[#E8D0B3] shadow-[0_10px_16px_-12px_rgba(0,0,0,0.8)] transition-colors hover:bg-[#D4A574]/36"
title="Jump to activity view"
>
<Orbit className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onOpenThread?.();
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[#7CB97A]/24 text-[#D2EACF] shadow-[0_10px_16px_-12px_rgba(0,0,0,0.8)] transition-colors hover:bg-[#7CB97A]/36"
title="Open thread"
>
<Activity className="h-3.5 w-3.5" />
</button>
</div>
</div>
);

View file

@ -1,7 +1,6 @@
'use client';
import { useMemo } from 'react';
import { Clock3, Layers2, Sparkles, TriangleAlert } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import type { BeadIssue } from '../../lib/types';
@ -14,6 +13,33 @@ interface SocialPageProps {
selectedId?: string;
onSelect: (id: string) => void;
projectScopeOptions?: ProjectScopeOption[];
blockedOnly?: boolean;
}
type SectionKey = 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
const SECTION_LABEL: Record<SectionKey, string> = {
ready: 'Ready',
in_progress: 'In Progress',
blocked: 'Blocked',
deferred: 'Deferred',
done: 'Done',
};
const SECTION_COLOR: Record<SectionKey, string> = {
ready: 'var(--ui-accent-ready)',
in_progress: 'var(--ui-accent-warning)',
blocked: 'var(--ui-accent-blocked)',
deferred: 'var(--ui-accent-info)',
done: 'var(--ui-text-muted)',
};
function bucketForStatus(status: string): SectionKey {
if (status === 'ready') return 'ready';
if (status === 'in_progress') return 'in_progress';
if (status === 'blocked') return 'blocked';
if (status === 'closed') return 'done';
return 'deferred';
}
function formatRelative(timestamp: string): string {
@ -31,16 +57,13 @@ function formatRelative(timestamp: string): string {
return `${diffDays}d ago`;
}
const STATUS_SCORE: Record<string, number> = {
blocked: 5,
in_progress: 4,
ready: 3,
open: 3,
deferred: 2,
closed: 1,
};
export function SocialPage({ issues, selectedId, onSelect, projectScopeOptions = [] }: SocialPageProps) {
export function SocialPage({
issues,
selectedId,
onSelect,
projectScopeOptions = [],
blockedOnly = false,
}: SocialPageProps) {
const router = useRouter();
const searchParams = useSearchParams();
const cards = useMemo(() => buildSocialCards(issues), [issues]);
@ -48,11 +71,8 @@ export function SocialPage({ issues, selectedId, onSelect, projectScopeOptions =
const navigateWithParams = (updates: Record<string, string | null>) => {
const next = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (!value) {
next.delete(key);
} else {
next.set(key, value);
}
if (!value) next.delete(key);
else next.set(key, value);
}
const query = next.toString();
router.push(query ? `/?${query}` : '/', { scroll: false });
@ -60,124 +80,194 @@ export function SocialPage({ issues, selectedId, onSelect, projectScopeOptions =
const issueById = useMemo(() => {
const map = new Map<string, BeadIssue>();
for (const issue of issues) map.set(issue.id, issue);
return map;
}, [issues]);
const epicTitleById = useMemo(() => {
const map = new Map<string, string>();
for (const issue of issues) {
map.set(issue.id, issue);
if (issue.issue_type === 'epic') {
map.set(issue.id, issue.title);
}
}
return map;
}, [issues]);
const orderedCards = useMemo(() => {
return [...cards].sort((a, b) => {
const scoreDiff = (STATUS_SCORE[b.status] ?? 0) - (STATUS_SCORE[a.status] ?? 0);
if (scoreDiff !== 0) {
return scoreDiff;
}
return b.lastActivity.getTime() - a.lastActivity.getTime();
const toDependencyDetails = (ids: string[]) =>
ids.map((id) => {
const depIssue = issueById.get(id);
const parentEpicId = depIssue?.dependencies.find((dep) => dep.type === 'parent')?.target;
return {
id,
title: depIssue?.title ?? id,
epic: parentEpicId ? epicTitleById.get(parentEpicId) : undefined,
};
});
}, [cards]);
const selectedCard = useMemo(
() => orderedCards.find((card) => card.id === selectedId) ?? null,
[orderedCards, selectedId],
const orderedCards = useMemo(
() => [...cards].sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime()),
[cards],
);
const selectedIssue = selectedCard ? issueById.get(selectedCard.id) ?? null : null;
const visibleCards = useMemo(
() => (blockedOnly ? orderedCards.filter((card) => card.status === 'blocked') : orderedCards),
[blockedOnly, orderedCards],
);
const metrics = useMemo(() => {
const blocked = cards.filter((card) => card.status === 'blocked').length;
const active = cards.filter((card) => card.status === 'in_progress').length;
const ready = cards.filter((card) => card.status === 'ready').length;
const urgent = cards.filter((card) => card.priority === 'P0').length;
const grouped = useMemo(() => {
const map: Record<SectionKey, typeof visibleCards> = {
ready: [],
in_progress: [],
blocked: [],
deferred: [],
done: [],
};
return { blocked, active, ready, urgent };
}, [cards]);
for (const card of visibleCards) {
map[bucketForStatus(card.status)].push(card);
}
return map;
}, [visibleCards]);
const [expandedSections, setExpandedSections] = useState<Record<SectionKey, boolean>>({
ready: false,
in_progress: false,
blocked: false,
deferred: false,
done: false,
});
const [collapsedSections, setCollapsedSections] = useState<Record<SectionKey, boolean>>({
ready: false,
in_progress: false,
blocked: false,
deferred: true,
done: true,
});
return (
<div className="relative h-full overflow-y-auto bg-[#2D2D2D] custom-scrollbar">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_10%_12%,rgba(90,70,50,0.42),transparent_34%),radial-gradient(circle_at_88%_82%,rgba(35,72,77,0.34),transparent_36%)]" />
<div className="relative mx-auto flex max-w-[1450px] flex-col gap-4 p-5">
<section className="rounded-2xl bg-[linear-gradient(160deg,rgba(57,57,66,0.95),rgba(46,49,60,0.95))] p-4 shadow-[0_24px_40px_-26px_rgba(0,0,0,0.82),inset_0_1px_0_rgba(255,255,255,0.05)]">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[#8B8B8B]">Social Stream</p>
<h2 className="mt-1 text-3xl font-semibold tracking-tight text-white">Task Activity Command Feed</h2>
<p className="mt-1 text-sm text-[#B8B8B8]">Two-column live task stream with inline thread context.</p>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="rounded-full bg-[#404856] px-3 py-1 text-[#D8D8D8] shadow-[0_10px_18px_-14px_rgba(0,0,0,0.8)]">{projectScopeOptions.length} scopes</div>
<div className="rounded-full bg-[#404856] px-3 py-1 text-[#D8D8D8] shadow-[0_10px_18px_-14px_rgba(0,0,0,0.8)]">{cards.length} tasks</div>
</div>
<div className="h-full overflow-y-auto bg-[var(--ui-bg-app)] custom-scrollbar">
<div className="mx-auto w-full max-w-[1280px] px-4 pb-8 pt-4 xl:px-6">
<div className="mb-4 flex items-center justify-between gap-3 border-b border-[var(--ui-border-soft)] pb-3">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">Social Stream</p>
<p className="text-sm text-[var(--ui-text-primary)]">Task Activity Command Feed</p>
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-4">
<div className="rounded-xl bg-[#7CB97A]/24 px-3 py-2 text-xs font-semibold text-[#DDF0DA] shadow-[0_12px_20px_-16px_rgba(0,0,0,0.82)]">{metrics.ready} ready</div>
<div className="rounded-xl bg-[#D4A574]/24 px-3 py-2 text-xs font-semibold text-[#F0DEC8] shadow-[0_12px_20px_-16px_rgba(0,0,0,0.82)]">{metrics.active} in progress</div>
<div className="rounded-xl bg-[#C97A7A]/24 px-3 py-2 text-xs font-semibold text-[#F3D2D2] shadow-[0_12px_20px_-16px_rgba(0,0,0,0.82)]">{metrics.blocked} blocked</div>
<div className="rounded-xl bg-[#E24A3A]/24 px-3 py-2 text-xs font-semibold text-[#F7CBC6] shadow-[0_12px_20px_-16px_rgba(0,0,0,0.82)]">{metrics.urgent} P0</div>
<div className="flex items-center gap-2 text-[11px]">
<span className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[var(--ui-text-muted)]">
{projectScopeOptions.length} scopes
</span>
<span className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[var(--ui-text-muted)]">
{visibleCards.length} tasks
</span>
</div>
</section>
{selectedCard && selectedIssue ? (
<section className="rounded-2xl bg-[radial-gradient(circle_at_100%_50%,rgba(91,168,160,0.2),transparent_45%),rgba(54,57,66,0.94)] p-3 shadow-[0_16px_30px_-18px_rgba(0,0,0,0.72),inset_0_1px_0_rgba(255,255,255,0.04)]">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-[#DDEDEC]">
<Sparkles className="h-4 w-4 text-[#5BA8A0]" />
<p className="text-sm font-semibold">Focused thread context</p>
</div>
<p className="text-xs text-[#8B8B8B]">{selectedCard.id}</p>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_auto_auto_auto]">
<p className="line-clamp-2 text-sm text-[#D8D8D8]">{selectedIssue.description ?? selectedIssue.title}</p>
<p className="inline-flex items-center gap-1 text-xs text-[#9E9E9E]"><Clock3 className="h-3.5 w-3.5" />{formatRelative(selectedIssue.updated_at)}</p>
<p className="inline-flex items-center gap-1 text-xs text-[#9E9E9E]"><Layers2 className="h-3.5 w-3.5" />{selectedIssue.dependencies.length} deps</p>
{selectedIssue.status === 'blocked' ? (
<p className="inline-flex items-center gap-1 text-xs text-[#E1BC8F]"><TriangleAlert className="h-3.5 w-3.5" />Needs unblock</p>
) : (
<p className="text-xs text-[#7CB97A]">Healthy flow</p>
)}
</div>
</section>
) : null}
<section className="grid grid-cols-1 gap-4 pb-6 xl:grid-cols-2">
{orderedCards.map((card) => {
const issue = issueById.get(card.id);
const commentCount = typeof issue?.metadata?.commentCount === 'number' ? issue.metadata.commentCount : 0;
const unreadCount = typeof issue?.metadata?.unreadCount === 'number' ? issue.metadata.unreadCount : 0;
const description = issue?.description ?? undefined;
</div>
<section className="space-y-6">
{(Object.keys(SECTION_LABEL) as SectionKey[]).map((key) => {
const cardsForSection = grouped[key];
return (
<SocialCard
key={card.id}
data={card}
selected={selectedId === card.id}
onClick={() => onSelect(card.id)}
onJumpToGraph={(id) => {
navigateWithParams({
view: 'graph',
task: id,
swarm: null,
panel: 'open',
drawer: 'closed',
});
}}
onJumpToActivity={(id) => {
navigateWithParams({
view: 'activity',
task: id,
panel: 'open',
drawer: 'closed',
});
}}
onOpenThread={() => onSelect(card.id)}
description={description ?? undefined}
updatedLabel={issue ? formatRelative(issue.updated_at) : 'just now'}
dependencyCount={issue?.dependencies.length ?? card.blocks.length + card.unblocks.length}
commentCount={commentCount}
unreadCount={unreadCount}
/>
<div key={key}>
<div className="mb-3 flex items-center gap-2 border-b border-[var(--ui-border-soft)] pb-2">
<p className="font-mono text-xs uppercase tracking-[0.14em]" style={{ color: SECTION_COLOR[key] }}>
{SECTION_LABEL[key]}
</p>
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-1.5 text-[10px] text-[var(--ui-text-primary)]">
{cardsForSection.length}
</span>
{(key === 'deferred' || key === 'done') ? (
<button
type="button"
onClick={() =>
setCollapsedSections((current) => ({ ...current, [key]: !current[key] }))
}
className="ml-auto rounded border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--ui-text-muted)] transition-colors hover:text-[var(--ui-text-primary)]"
>
{collapsedSections[key] ? 'Expand' : 'Minimize'}
</button>
) : null}
</div>
{collapsedSections[key] ? (
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
{cardsForSection.length === 0
? `No tasks in ${SECTION_LABEL[key].toLowerCase()}.`
: `${cardsForSection.length} tasks hidden.`}
</p>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{(expandedSections[key] ? cardsForSection : cardsForSection.slice(0, 3)).map((card) => {
const issue = issueById.get(card.id);
const commentCount = typeof issue?.metadata?.commentCount === 'number' ? issue.metadata.commentCount : 0;
const unreadCount = typeof issue?.metadata?.unreadCount === 'number' ? issue.metadata.unreadCount : 0;
const dependencyCount = issue?.dependencies.length ?? card.blocks.length + card.unblocks.length;
return (
<SocialCard
key={card.id}
data={card}
selected={selectedId === card.id}
onClick={() => onSelect(card.id)}
onJumpToGraph={(id) =>
navigateWithParams({
view: 'graph',
task: id,
swarm: null,
right: 'open',
panel: 'open',
drawer: 'closed',
})
}
onJumpToActivity={(id) =>
navigateWithParams({
view: 'activity',
task: id,
right: 'open',
panel: 'open',
drawer: 'closed',
})
}
onOpenThread={() => onSelect(card.id)}
description={issue?.description ?? undefined}
updatedLabel={issue ? formatRelative(issue.updated_at) : 'just now'}
dependencyCount={dependencyCount}
commentCount={commentCount}
unreadCount={unreadCount}
blockedByDetails={toDependencyDetails(card.unblocks)}
unblocksDetails={toDependencyDetails(card.blocks)}
/>
);
})}
{cardsForSection.length === 0 ? (
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
No tasks in this lane.
</p>
) : null}
</div>
)}
{!collapsedSections[key] && cardsForSection.length > 3 ? (
<div className="mt-2 flex justify-end">
<button
type="button"
onClick={() =>
setExpandedSections((current) => ({ ...current, [key]: !current[key] }))
}
className="rounded-md border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2.5 py-1.5 text-xs font-semibold uppercase tracking-[0.1em] text-[var(--ui-text-muted)] transition-colors hover:text-[var(--ui-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]"
>
{expandedSections[key] ? 'Show Less' : `Show ${cardsForSection.length - 3} More`}
</button>
</div>
) : null}
</div>
);
})}
</section>
{visibleCards.length === 0 ? (
<p className="mt-5 px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
No blocked tasks right now.
</p>
) : null}
</div>
</div>
);

View file

@ -0,0 +1,110 @@
import React from 'react';
import { X, Save, ShieldAlert } from 'lucide-react';
import type { AgentArchetype } from '../../lib/types-swarm';
interface ArchetypeInspectorProps {
archetype: AgentArchetype;
onClose: () => void;
}
export function ArchetypeInspector({ archetype, onClose }: ArchetypeInspectorProps) {
if (!archetype) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="flex flex-col h-[85vh] w-full max-w-2xl overflow-hidden rounded-xl border border-[var(--ui-border-soft)] bg-[#0f1824] shadow-2xl animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e]">
<div className="flex items-center gap-3">
<div
className="h-10 w-10 rounded-lg flex items-center justify-center font-bold text-lg border"
style={{ backgroundColor: `${archetype.color}15`, color: archetype.color, borderColor: `${archetype.color}30` }}
>
{archetype.name.charAt(0)}
</div>
<div>
<h2 className="text-lg font-bold text-[var(--ui-text-primary)] leading-tight">{archetype.name}</h2>
<p className="font-mono uppercase tracking-wider text-[10px] text-[var(--ui-text-muted)] mt-0.5">{archetype.id}</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-full p-2 text-[var(--ui-text-muted)] hover:bg-white/5 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body Content */}
<div className="flex-1 overflow-y-auto p-5 space-y-6 custom-scrollbar">
{/* ReadOnly Warning if builtin */}
{archetype.isBuiltIn && (
<div className="flex items-start gap-3 bg-[var(--ui-accent-warning)]/10 border border-[var(--ui-accent-warning)]/20 p-3 rounded-lg text-[var(--ui-accent-warning)]">
<ShieldAlert className="w-5 h-5 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<span className="font-semibold">Built-in Archetype.</span> This is a core system role. You cannot delete it, but you can override its system prompt.
</div>
</div>
)}
{/* Metadata Section */}
<div className="space-y-4">
<div>
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-1.5 block">Description</label>
<input
type="text"
defaultValue={archetype.description}
readOnly
className="w-full bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 text-sm text-[var(--ui-text-primary)] focus:outline-none focus:border-[var(--ui-accent-info)] focus:ring-1 focus:ring-[var(--ui-accent-info)]"
/>
</div>
<div>
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-1.5 block">Capabilities</label>
<div className="flex flex-wrap gap-2">
{archetype.capabilities.map((cap, idx) => (
<span key={idx} className="px-2 py-1 rounded-md bg-white/5 text-[11px] uppercase font-semibold text-[var(--ui-text-muted)] border border-white/10">
{cap}
</span>
))}
</div>
</div>
</div>
<div className="border-t border-[var(--ui-border-soft)] pt-6">
<div className="flex flex-col h-[300px]">
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-1.5 flex items-center justify-between">
<span>System Prompt</span>
<span className="text-[10px] text-emerald-400 normal-case tracking-normal">Syntax: Markdown</span>
</label>
<textarea
defaultValue={archetype.systemPrompt}
readOnly
className="flex-1 w-full bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md p-4 text-sm text-[var(--ui-text-primary)] font-mono resize-none focus:outline-none focus:border-[var(--ui-accent-info)] custom-scrollbar leading-relaxed"
/>
</div>
</div>
</div>
{/* Footer Controls */}
<div className="border-t border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e] flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-semibold text-[var(--ui-text-primary)] hover:bg-white/5 rounded-md transition-colors"
>
Cancel
</button>
<button
disabled
className="flex items-center gap-2 px-4 py-2 text-sm font-bold bg-[var(--ui-accent-info)] hover:bg-[var(--ui-accent-info)]/90 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" />
Save Changes
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,31 @@
"use client";
import { Loader2, CheckCircle2 } from "lucide-react";
export type Phase = 'planning' | 'deployment' | 'execution' | 'debrief';
export function ConvoyStepper({ activePhase }: { activePhase: Phase }) {
const phases: Phase[] = ['planning', 'deployment', 'execution', 'debrief'];
return (
<div className="flex flex-wrap items-center gap-4 bg-muted/50 p-4 rounded-lg my-4">
{phases.map((p, i) => {
const isActive = activePhase === p;
const isPast = phases.indexOf(activePhase) > i;
return (
<div
key={p}
className={`flex items-center gap-2 ${isActive ? 'text-primary' : isPast ? 'text-muted-foreground' : 'text-muted-foreground/50'
}`}
>
{isActive && <Loader2 className="w-4 h-4 animate-spin" />}
{isPast && <CheckCircle2 className="w-4 h-4" />}
{!isActive && !isPast && <div className="w-4 h-4 rounded-full border border-current" />}
<span className="font-mono text-sm uppercase">{p}</span>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,172 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Loader2, Plus, Rocket } from 'lucide-react';
interface LaunchSwarmDialogProps {
projectRoot: string;
onSuccess?: () => void;
}
interface Formula {
name: string;
description?: string;
}
export function LaunchSwarmDialog({ projectRoot, onSuccess }: LaunchSwarmDialogProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [formulas, setFormulas] = useState<Formula[]>([]);
const [selectedFormula, setSelectedFormula] = useState<string>('');
const [title, setTitle] = useState('');
const [error, setError] = useState<string | null>(null);
const fetchFormulas = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/swarm/formulas?projectRoot=${encodeURIComponent(projectRoot)}`);
const json = await res.json();
if (json.ok) {
setFormulas(json.data);
} else {
setError(json.error);
}
} catch (e) {
setError('Failed to fetch formulas');
} finally {
setLoading(false);
}
};
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen);
if (isOpen && formulas.length === 0) {
fetchFormulas();
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title || !selectedFormula) return;
setLoading(true);
setError(null);
try {
const res = await fetch('/api/swarm/launch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectRoot,
title,
proto: selectedFormula,
}),
});
const json = await res.json();
if (json.ok) {
setOpen(false);
setTitle('');
setSelectedFormula('');
onSuccess?.();
} else {
setError(json.error);
}
} catch (e) {
setError('Failed to launch swarm');
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 border-emerald-500/20 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 hover:text-emerald-300"
>
<Rocket className="h-4 w-4" />
Launch Swarm
</Button>
</DialogTrigger>
<DialogContent className="bg-[#08111d] border-slate-800 text-slate-200 sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Launch New Swarm</DialogTitle>
<DialogDescription className="text-slate-400">
Instantiate a new molecule from a template proto.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="proto" className="text-slate-300">Formula Template</Label>
<Select value={selectedFormula} onValueChange={setSelectedFormula} disabled={loading}>
<SelectTrigger className="bg-slate-900 border-slate-700 text-slate-200">
<SelectValue placeholder="Select a proto..." />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
{formulas.length === 0 && !loading && (
<div className="p-2 text-xs text-slate-500 text-center">No formulas found</div>
)}
{formulas.map((f) => (
<SelectItem key={f.name} value={f.name} className="focus:bg-slate-700 focus:text-slate-100">
{f.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="title" className="text-slate-300">Swarm Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="bg-slate-900 border-slate-700 text-slate-200"
placeholder="e.g. Release v1.2"
disabled={loading}
/>
</div>
{error && (
<div className="text-xs text-rose-400 bg-rose-950/20 p-2 rounded border border-rose-900/30">
{error}
</div>
)}
</form>
<DialogFooter>
<Button
type="submit"
onClick={handleSubmit}
disabled={loading || !title || !selectedFormula}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Rocket className="mr-2 h-4 w-4" />}
Launch
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,210 @@
import React, { useEffect, useMemo } from 'react';
import {
Background,
MarkerType,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
Handle,
type Edge,
type Node,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import dagre from 'dagre';
import type { BeadIssue } from '../../lib/types';
import type { AgentArchetype } from '../../lib/types-swarm';
// Custom Node for the Agent DAG
interface AgentNodeData extends Record<string, unknown> {
title: string;
status: string;
assignee: string | null;
archetype?: AgentArchetype;
isSelected?: boolean;
}
function AgentNodeCard({ data }: { data: AgentNodeData }) {
const isDone = data.status === 'closed';
const isInProgress = data.status === 'in_progress';
const isBlocked = data.status === 'blocked';
const statusColor = isDone ? 'text-emerald-400' : isBlocked ? 'text-rose-400' : isInProgress ? 'text-amber-400' : 'text-slate-400';
let borderColor = isDone ? 'border-emerald-500/30' : isBlocked ? 'border-rose-500/30' : isInProgress ? 'border-amber-500/30' : 'border-slate-500/30';
let containerClasses = `w-[260px] rounded-xl border bg-[#0a111a] p-3 shadow-xl transition-all duration-500 ${borderColor}`;
if (isInProgress) {
containerClasses += ' shadow-[0_0_20px_rgba(251,191,36,0.15)] ring-1 ring-amber-500/30';
}
if (data.isSelected) {
containerClasses = `w-[260px] rounded-xl border bg-[#0a111a] p-3 shadow-[0_0_25px_rgba(56,189,248,0.2)] transition-all duration-300 border-[var(--ui-accent-info)] ring-2 ring-[var(--ui-accent-info)]/50`;
}
const bgStr = data.archetype ? `${data.archetype.color}15` : '#ffffff05';
const colorStr = data.archetype ? data.archetype.color : '#888';
return (
<div className={containerClasses}>
<div className="flex items-start gap-3">
<div
className={`h-10 w-10 flex-shrink-0 rounded-lg flex items-center justify-center font-bold text-lg border relative ${isInProgress ? 'animate-pulse duration-1000' : ''}`}
style={{ backgroundColor: bgStr, color: colorStr, borderColor: `${colorStr}40` }}
>
{data.assignee ? data.assignee.charAt(0).toUpperCase() : '?'}
<div className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-[#0a111a] bg-current ${statusColor} ${isInProgress ? 'animate-ping' : ''}`} style={{ animationDuration: '2s' }} />
<div className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-[#0a111a] bg-current ${statusColor}`} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-mono text-[var(--ui-text-muted)] uppercase tracking-wider mb-0.5 truncate flex items-center justify-between">
<span>{data.assignee || 'Unassigned'}</span>
{isInProgress && <span className="text-amber-500 animate-pulse text-[8px] tracking-widest">WORKING...</span>}
</div>
<div className="text-sm font-semibold text-[var(--ui-text-primary)] leading-tight line-clamp-2">
{data.title}
</div>
{data.archetype && (
<div className="text-[9px] text-[var(--ui-text-muted)] mt-1 truncate">
{data.archetype.name}
</div>
)}
</div>
</div>
{/* React Flow handles */}
<Handle type="target" position={Position.Left} className="w-4 h-4 rounded-full bg-slate-800 border-2 border-slate-600 !left-[-8px] opacity-0" />
<Handle type="source" position={Position.Right} className="w-4 h-4 rounded-full bg-slate-800 border-2 border-slate-600 !right-[-8px] opacity-0" />
</div>
);
}
const nodeTypes = {
agentNode: AgentNodeCard,
};
const layoutDagre = (nodes: Node<AgentNodeData>[], edges: Edge[]): Node<AgentNodeData>[] => {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 60 });
for (const node of nodes) {
dagreGraph.setNode(node.id, { width: 260, height: 110 });
}
for (const edge of edges) {
dagreGraph.setEdge(edge.source, edge.target);
}
dagre.layout(dagreGraph);
return nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
const newNode = { ...node };
if (nodeWithPosition) {
newNode.targetPosition = Position.Left;
newNode.sourcePosition = Position.Right;
newNode.position = {
x: nodeWithPosition.x - 260 / 2,
y: nodeWithPosition.y - 110 / 2,
};
}
return newNode;
});
};
function SpecializedAgentDagInner({ beads, archetypes, selectedId, onSelect }: { beads: BeadIssue[], archetypes: AgentArchetype[], selectedId?: string | null, onSelect?: (id: string) => void }) {
const { fitView } = useReactFlow();
const handleNodeClick = React.useCallback(
(_: React.MouseEvent, node: Node) => {
onSelect?.(node.id);
},
[onSelect],
);
const flowModel = useMemo(() => {
// Find visible beads (hide tombstone)
const visibleBeads = beads.filter(b => b.status !== 'tombstone');
const baseNodes: Node<AgentNodeData>[] = visibleBeads.map((issue) => {
const assigneeStr = issue.assignee?.toLowerCase() || '';
const matchedArchetype = archetypes.find(a =>
assigneeStr.includes(a.id.toLowerCase()) ||
assigneeStr.includes(a.name.toLowerCase())
);
return {
id: issue.id,
type: 'agentNode',
data: {
title: issue.title,
status: issue.status,
assignee: issue.assignee,
archetype: matchedArchetype,
isSelected: issue.id === selectedId
},
position: { x: 0, y: 0 },
sourcePosition: Position.Right,
targetPosition: Position.Left,
};
});
const graphEdges: Edge[] = [];
const beadIds = new Set(visibleBeads.map(b => b.id));
visibleBeads.forEach(issue => {
issue.dependencies.forEach(dep => {
if (dep.type === 'blocks' && beadIds.has(dep.target)) {
// issue depends on dep.target (issue is blocked by dep.target)
// Edge should flow from blocker to blocked
graphEdges.push({
id: `e-${dep.target}-${issue.id}`,
source: dep.target,
target: issue.id,
type: 'smoothstep',
animated: issue.status === 'in_progress' || issue.status === 'closed',
style: { stroke: '#475569', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#475569' }
});
}
});
});
console.log('SpecializedAgentDag generated nodes:', baseNodes.length, 'edges:', graphEdges.length);
return {
nodes: layoutDagre(baseNodes, graphEdges),
edges: graphEdges,
};
}, [beads, archetypes, selectedId]);
useEffect(() => {
const timeout = setTimeout(() => {
fitView({ padding: 0.3, duration: 300 });
}, 100);
return () => clearTimeout(timeout);
}, [fitView, flowModel.nodes.length]);
return (
<ReactFlow
nodes={flowModel.nodes}
edges={flowModel.edges}
nodeTypes={nodeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={true}
onNodeClick={handleNodeClick}
fitView
>
<Background gap={24} size={1} color="rgba(255,255,255,0.02)" />
</ReactFlow>
);
}
export function SpecializedAgentDag(props: { beads: BeadIssue[], archetypes: AgentArchetype[], selectedId?: string | null, onSelect?: (id: string) => void }) {
return (
<ReactFlowProvider>
<SpecializedAgentDagInner {...props} />
</ReactFlowProvider>
);
}

View file

@ -1,47 +1,13 @@
'use client';
import type { SwarmCard as SwarmCardType, AgentRoster } from '../../lib/swarm-cards';
import type { SwarmCardData } from '../../lib/swarm-api';
import { Card } from '../../../components/ui/card';
import { Badge } from '../../../components/ui/badge';
import { AgentAvatar } from '../shared/agent-avatar';
import { cn } from '../../lib/utils';
import { Plus, Menu, Diamond, Waves, AlertTriangle } from 'lucide-react';
import { CheckCircle2, PlayCircle, Clock, AlertCircle } from 'lucide-react';
interface SwarmCardProps {
card: SwarmCardType;
onExpand?: () => void;
onMenu?: () => void;
onGraph?: () => void;
onTimeline?: () => void;
}
function formatTimeAgo(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays}d ago`;
if (diffHours > 0) return `${diffHours}h ago`;
if (diffMins > 0) return `${diffMins}m ago`;
return 'just now';
}
const HEALTH_COLORS: Record<string, string> = {
active: 'text-emerald-400',
stale: 'text-amber-400',
stuck: 'text-rose-400',
dead: 'text-red-500',
};
function AgentRosterRow({ agent }: { agent: AgentRoster }) {
return (
<div className="flex items-center gap-2 text-xs text-slate-400">
<span className="font-mono text-slate-500">{agent.name}:</span>
<span className="truncate">{agent.currentTask || 'idle'}</span>
</div>
);
card: SwarmCardData;
}
function ProgressBar({ progress }: { progress: number }) {
@ -50,118 +16,70 @@ function ProgressBar({ progress }: { progress: number }) {
return (
<div className="flex items-center gap-2">
<div className="flex-1 font-mono text-xs">
<div className="flex-1 font-mono text-xs text-slate-300">
{'█'.repeat(filled)}
{'░'.repeat(empty)}
</div>
<span className="text-xs text-slate-400">{progress}% done</span>
<span className="text-xs text-slate-400">{progress}%</span>
</div>
);
}
function AttentionList({ items }: { items: string[] }) {
if (items.length === 0) return null;
const STATUS_COLORS: Record<string, string> = {
open: 'text-emerald-400 border-emerald-400/30',
closed: 'text-slate-400 border-slate-400/30',
in_progress: 'text-amber-400 border-amber-400/30',
};
export function SwarmCard({ card }: SwarmCardProps) {
return (
<div className="space-y-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-amber-400">
ATTENTION:
</span>
{items.slice(0, 3).map((item, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs text-amber-200/80">
<AlertTriangle className="h-3 w-3 text-amber-400" />
<span className="truncate">{item}</span>
</div>
))}
</div>
);
}
export function SwarmCard({ card, onExpand, onMenu, onGraph, onTimeline }: SwarmCardProps) {
const activeAgents = card.agents.filter((a) => a.status === 'active');
const otherAgents = card.agents.filter((a) => a.status !== 'active');
return (
<Card className="rounded-xl border border-white/[0.06] bg-[#363636] px-3.5 py-3 shadow-[0_18px_38px_-18px_rgba(0,0,0,0.82),0_6px_18px_-10px_rgba(0,0,0,0.72),inset_0_1px_0_rgba(255,255,255,0.06)] transition duration-200 hover:shadow-[0_24px_52px_-16px_rgba(0,0,0,0.9),0_10px_26px_-10px_rgba(0,0,0,0.78)]">
<Card className="rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-card)] px-3.5 py-3 shadow-[0_18px_38px_-18px_rgba(0,0,0,0.82),0_6px_18px_-10px_rgba(0,0,0,0.72),inset_0_1px_0_rgba(255,255,255,0.06)] transition-shadow duration-200 hover:shadow-[0_24px_52px_-16px_rgba(0,0,0,0.9),0_10px_26px_-10px_rgba(0,0,0,0.78)]">
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold text-slate-200">
{card.swarmId}
</span>
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0 border-slate-600', HEALTH_COLORS[card.health])}
>
{card.health}
</Badge>
</div>
<span className="text-sm text-slate-400 line-clamp-1">{card.title}</span>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold text-slate-200">
{card.swarmId}
</span>
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', STATUS_COLORS[card.status] ?? 'text-slate-400 border-slate-400/30')}
>
{card.status}
</Badge>
</div>
<button
onClick={onExpand}
className="p-1 rounded hover:bg-white/5 transition-colors"
aria-label="Expand"
>
<Plus className="h-4 w-4 text-slate-500" />
</button>
<span className="text-sm text-slate-400 line-clamp-1">{card.title}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500">
AGENTS:
</span>
<div className="flex items-center gap-1 -space-x-1">
{activeAgents.slice(0, 4).map((agent) => (
<AgentAvatar key={agent.name} name={agent.name} status={agent.status} size="sm" />
))}
{otherAgents.slice(0, 2).map((agent) => (
<AgentAvatar key={agent.name} name={agent.name} status={agent.status} size="sm" />
))}
{card.agents.length > 6 && (
<span className="text-xs text-slate-500 ml-2">+{card.agents.length - 6}</span>
)}
<ProgressBar progress={card.progressPercent} />
<div className="text-xs text-slate-500">
Epic: <span className="font-mono text-slate-400">{card.epicId}</span>
</div>
<div className="grid grid-cols-4 gap-2 text-xs">
<div className="flex items-center gap-1 text-emerald-400">
<CheckCircle2 className="h-3 w-3" />
<span>{card.completedIssues}</span>
</div>
<div className="flex items-center gap-1 text-amber-400">
<PlayCircle className="h-3 w-3" />
<span>{card.activeIssues}</span>
</div>
<div className="flex items-center gap-1 text-blue-400">
<Clock className="h-3 w-3" />
<span>{card.readyIssues}</span>
</div>
<div className="flex items-center gap-1 text-rose-400">
<AlertCircle className="h-3 w-3" />
<span>{card.blockedIssues}</span>
</div>
</div>
{card.agents.filter((a) => a.currentTask).slice(0, 2).map((agent) => (
<AgentRosterRow key={agent.name} agent={agent} />
))}
<AttentionList items={card.attentionItems} />
<ProgressBar progress={card.progress} />
{card.lastActivity && (
<div className="text-xs text-slate-500 italic truncate">
Last activity {formatTimeAgo(card.lastActivity)}
{card.coordinator && (
<div className="text-xs text-slate-500">
Coordinator: <span className="text-slate-400">{card.coordinator}</span>
</div>
)}
<div className="flex items-center justify-end gap-1 pt-1 border-t border-white/[0.04]">
<button
onClick={onMenu}
className="p-1.5 rounded hover:bg-white/5 transition-colors"
aria-label="Menu"
>
<Menu className="h-3.5 w-3.5 text-slate-500" />
</button>
<button
onClick={onGraph}
className="p-1.5 rounded hover:bg-white/5 transition-colors"
aria-label="Graph view"
>
<Diamond className="h-3.5 w-3.5 text-slate-500" />
</button>
<button
onClick={onTimeline}
className="p-1.5 rounded hover:bg-white/5 transition-colors"
aria-label="Timeline view"
>
<Waves className="h-3.5 w-3.5 text-slate-500" />
</button>
</div>
</div>
</Card>
);

View file

@ -0,0 +1,146 @@
'use client';
import type { SwarmCardData } from '../../lib/swarm-api';
import { Card } from '../../../components/ui/card';
import { Badge } from '../../../components/ui/badge';
import { Button } from '@/components/ui/button';
import { cn } from '../../lib/utils';
import { CheckCircle2, PlayCircle, Clock, AlertCircle, UserPlus, UserMinus, Activity } from 'lucide-react';
import { AgentAvatar } from '../shared/agent-avatar';
import { useAgentPool } from '../../hooks/use-agent-pool';
interface SwarmControlCardProps {
card: SwarmCardData;
projectRoot: string;
onJoin?: () => void;
onLeave?: () => void;
isJoining?: boolean;
}
function MiniGraph({ progress }: { progress: number }) {
// A simple visual indicator of progress complexity (mocked for now, but implies graph structure)
return (
<div className="flex h-8 items-end gap-0.5 opacity-50">
{[...Array(10)].map((_, i) => {
const height = Math.max(20, Math.random() * 80);
const active = (i * 10) < progress;
return (
<div
key={i}
className={cn("w-1 rounded-t-sm transition-all", active ? "bg-emerald-500" : "bg-slate-700")}
style={{ height: `${active ? height : 20}%` }}
/>
)
})}
</div>
);
}
const STATUS_COLORS: Record<string, string> = {
open: 'text-emerald-400 border-emerald-400/30',
closed: 'text-slate-400 border-slate-400/30',
in_progress: 'text-amber-400 border-amber-400/30',
};
export function SwarmControlCard({ card, projectRoot, onJoin, onLeave, isJoining }: SwarmControlCardProps) {
const { getAgentsBySwarm } = useAgentPool(projectRoot);
const agents = getAgentsBySwarm(card.swarmId);
return (
<Card className="group relative overflow-hidden rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-card)] p-0 shadow-lg transition-all hover:border-[var(--ui-accent-info)] hover:shadow-xl">
{/* Background Decoration */}
<div className="absolute right-0 top-0 h-32 w-32 -translate-y-16 translate-x-16 rounded-full bg-emerald-500/5 blur-3xl transition-opacity group-hover:opacity-20" />
<div className="flex flex-col h-full p-4 space-y-4">
{/* Header */}
<div className="flex items-start justify-between">
<div className="space-y-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs font-bold text-emerald-500">
{card.swarmId}
</span>
<Badge
variant="outline"
className={cn('text-[9px] px-1.5 py-0 uppercase', STATUS_COLORS[card.status] ?? 'text-slate-400 border-slate-400/30')}
>
{card.status}
</Badge>
</div>
<h4 className="text-sm font-semibold text-slate-200 line-clamp-1 group-hover:text-white transition-colors">
{card.title}
</h4>
</div>
<Activity className="h-4 w-4 text-slate-600 group-hover:text-emerald-400 transition-colors" />
</div>
{/* Visualizer */}
<div className="rounded-lg bg-black/20 p-2">
<div className="flex justify-between items-end mb-1">
<span className="text-[10px] text-slate-500 font-mono">ACTIVITY</span>
<span className="text-[10px] text-emerald-400 font-mono">{card.progressPercent}%</span>
</div>
<MiniGraph progress={card.progressPercent} />
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-2 text-xs border-t border-white/5 pt-3">
<div className="flex flex-col items-center gap-1 text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5" />
<span className="font-mono text-[10px]">{card.completedIssues}</span>
</div>
<div className="flex flex-col items-center gap-1 text-amber-400">
<PlayCircle className="h-3.5 w-3.5" />
<span className="font-mono text-[10px]">{card.activeIssues}</span>
</div>
<div className="flex flex-col items-center gap-1 text-blue-400">
<Clock className="h-3.5 w-3.5" />
<span className="font-mono text-[10px]">{card.readyIssues}</span>
</div>
<div className="flex flex-col items-center gap-1 text-rose-400">
<AlertCircle className="h-3.5 w-3.5" />
<span className="font-mono text-[10px]">{card.blockedIssues}</span>
</div>
</div>
{/* Agent Roster & Actions */}
<div className="flex items-center justify-between mt-auto pt-2">
<div className="flex -space-x-2">
{agents.slice(0, 3).map(agent => (
<div key={agent.agent_id} className="ring-2 ring-[var(--ui-bg-card)] rounded-full z-10">
<AgentAvatar
name={agent.display_name}
status={agent.status as any}
size="sm"
/>
</div>
))}
{agents.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-800 text-[10px] font-bold text-slate-400 ring-2 ring-[var(--ui-bg-card)] z-0">
+{agents.length - 3}
</div>
)}
{agents.length === 0 && (
<span className="text-[10px] text-slate-500 italic pl-1">No agents</span>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-[10px] gap-1 border-emerald-500/20 hover:bg-emerald-500/10 hover:text-emerald-400"
onClick={(e) => {
e.stopPropagation();
onJoin?.();
}}
disabled={isJoining}
>
<UserPlus className="h-3 w-3" />
Join
</Button>
</div>
</div>
</div>
</Card>
);
}

View file

@ -1,201 +1,179 @@
'use client';
import type { SwarmCard as SwarmCardType } from '../../lib/swarm-cards';
import { useEffect, useState } from 'react';
import type { SwarmCardData, SwarmStatusFromApi } from '../../lib/swarm-api';
import { Badge } from '../../../components/ui/badge';
import { AgentAvatar } from '../shared/agent-avatar';
import { cn } from '../../lib/utils';
import { AlertTriangle, Clock, Users } from 'lucide-react';
import { CheckCircle2, PlayCircle, Clock, AlertCircle, Loader2 } from 'lucide-react';
interface SwarmDetailProps {
card: SwarmCardType;
}
const HEALTH_COLORS: Record<string, string> = {
active: 'border-emerald-500/50 text-emerald-400',
stale: 'border-amber-500/50 text-amber-400',
stuck: 'border-rose-500/50 text-rose-400',
dead: 'border-red-600/50 text-red-500',
};
const STATUS_GLOW: Record<string, string> = {
active: 'shadow-[0_0_8px_rgba(52,211,153,0.5)]',
stale: 'shadow-[0_0_8px_rgba(251,191,36,0.4)]',
stuck: 'shadow-[0_0_8px_rgba(244,63,94,0.5)]',
dead: 'shadow-[0_0_8px_rgba(220,38,38,0.6)]',
};
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
if (diffHours > 0) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffMins > 0) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
return 'just now';
swarmId: string;
projectRoot: string;
}
function ProgressBar({ progress }: { progress: number }) {
const filled = Math.round(progress / 10);
const empty = 10 - filled;
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span style={{ color: 'var(--color-text-muted)' }}>Progress</span>
<span className="font-mono" style={{ color: 'var(--color-text-secondary)' }}>
{progress}%
</span>
<span className="text-slate-400">Progress</span>
<span className="font-mono text-slate-300">{progress}%</span>
</div>
<div
className="h-1.5 rounded-full overflow-hidden"
style={{ backgroundColor: 'var(--color-bg-elevated)' }}
>
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${progress}%`,
backgroundColor:
progress >= 80
? 'var(--color-success)'
: progress >= 50
? 'var(--color-warning)'
: 'var(--color-error)',
}}
/>
<div className="flex items-center gap-2">
<div className="flex-1 font-mono text-xs text-slate-300">
{'█'.repeat(filled)}
{'░'.repeat(empty)}
</div>
</div>
</div>
);
}
function AgentRosterSection({ agents }: { agents: SwarmCardType['agents'] }) {
const active = agents.filter((a) => a.status === 'active').length;
const stale = agents.filter((a) => a.status === 'stale').length;
const stuck = agents.filter((a) => a.status === 'stuck').length;
const dead = agents.filter((a) => a.status === 'dead').length;
export function SwarmDetail({ swarmId, projectRoot }: SwarmDetailProps) {
const [status, setStatus] = useState<SwarmStatusFromApi | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchStatus() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
`/api/swarm/status?projectRoot=${encodeURIComponent(projectRoot)}&epic=${encodeURIComponent(swarmId)}`
);
const payload = await response.json();
if (payload.ok && payload.data) {
setStatus(payload.data);
} else {
setError(payload.error?.message || 'Failed to load swarm status');
}
} catch (e) {
setError('Failed to fetch swarm status');
} finally {
setIsLoading(false);
}
}
fetchStatus();
}, [swarmId, projectRoot]);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-slate-400">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Loading swarm...
</div>
);
}
if (error) {
return (
<div className="py-8 text-center text-rose-400">
{error}
</div>
);
}
if (!status) {
return (
<div className="py-8 text-center text-slate-400">
No swarm data found
</div>
);
}
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" style={{ color: 'var(--color-text-muted)' }} />
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-muted)' }}>
Agents ({agents.length})
</span>
<div className="space-y-4 p-4">
{/* Header */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold text-slate-200">
{swarmId}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-emerald-400 border-emerald-400/30">
swarm
</Badge>
</div>
<h3 className="text-sm font-medium text-slate-200 line-clamp-2">
{status.epic_title}
</h3>
</div>
<div className="flex flex-wrap gap-1.5">
{agents.map((agent) => (
<div
key={agent.name}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md border',
STATUS_GLOW[agent.status]
)}
style={{ backgroundColor: 'var(--color-bg-elevated)' }}
>
<AgentAvatar name={agent.name} status={agent.status} size="sm" />
<span className="text-xs" style={{ color: 'var(--color-text-primary)' }}>
{agent.name}
</span>
{/* Progress */}
<ProgressBar progress={status.progress_percent} />
{/* Stats Grid */}
<div className="grid grid-cols-4 gap-2 text-xs">
<div className="flex items-center gap-1 text-emerald-400">
<CheckCircle2 className="h-3 w-3" />
<span>{status.completed.length} done</span>
</div>
<div className="flex items-center gap-1 text-amber-400">
<PlayCircle className="h-3 w-3" />
<span>{status.active_count} active</span>
</div>
<div className="flex items-center gap-1 text-blue-400">
<Clock className="h-3 w-3" />
<span>{status.ready_count} ready</span>
</div>
<div className="flex items-center gap-1 text-rose-400">
<AlertCircle className="h-3 w-3" />
<span>{status.blocked_count} blocked</span>
</div>
</div>
{/* Active Tasks */}
{status.active.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-slate-400">
Active ({status.active.length})
</h4>
<div className="space-y-1">
{status.active.map((task) => (
<div key={task.id} className="p-2 rounded-md bg-amber-500/10 border border-amber-500/20">
<span className="font-mono text-[10px] text-amber-300">{task.id}</span>
<p className="text-xs text-slate-300 line-clamp-1">{task.title}</p>
</div>
))}
</div>
</div>
)}
{/* Ready Tasks */}
{status.ready.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-slate-400">
Ready to Pick Up ({status.ready.length})
</h4>
<div className="space-y-1">
{status.ready.map((task) => (
<div key={task.id} className="p-2 rounded-md bg-blue-500/10 border border-blue-500/20">
<span className="font-mono text-[10px] text-blue-300">{task.id}</span>
<p className="text-xs text-slate-300 line-clamp-1">{task.title}</p>
</div>
))}
</div>
</div>
)}
{/* Blocked Tasks */}
{status.blocked.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-slate-400">
Blocked ({status.blocked.length})
</h4>
<div className="space-y-1">
{status.blocked.map((task) => (
<div key={task.id} className="p-2 rounded-md bg-rose-500/10 border border-rose-500/20">
<span className="font-mono text-[10px] text-rose-300">{task.id}</span>
<p className="text-xs text-slate-300 line-clamp-1">{task.title}</p>
</div>
))}
</div>
))}
</div>
{(active > 0 || stale > 0 || stuck > 0 || dead > 0) && (
<div className="flex gap-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
{active > 0 && <span className="text-emerald-400">{active} active</span>}
{stale > 0 && <span className="text-amber-400">{stale} stale</span>}
{stuck > 0 && <span className="text-rose-400">{stuck} stuck</span>}
{dead > 0 && <span className="text-red-500">{dead} dead</span>}
</div>
)}
</div>
);
}
function AttentionSection({ items }: { items: string[] }) {
if (items.length === 0) return null;
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 text-amber-400" />
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-muted)' }}>
Attention ({items.length})
</span>
</div>
<div className="space-y-1.5">
{items.map((item, i) => (
<div
key={i}
className="flex items-start gap-1.5 p-2 rounded-md"
style={{ backgroundColor: 'var(--color-bg-elevated)' }}
>
<AlertTriangle className="h-3 w-3 text-amber-400 mt-0.5 flex-shrink-0" />
<span className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
{item}
</span>
</div>
))}
</div>
</div>
);
}
function LastActivitySection({ date }: { date: Date }) {
return (
<div className="flex items-center gap-1.5 text-xs" style={{ color: 'var(--color-text-muted)' }}>
<Clock className="h-3.5 w-3.5" />
<span>Last activity {formatRelativeTime(date)}</span>
</div>
);
}
function ThreadSection() {
return (
<div className="space-y-2">
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-muted)' }}>
Thread
</span>
<p className="text-text-muted text-sm italic">
Thread drawer coming (bb-ui2.31)
</p>
</div>
);
}
export function SwarmDetail({ card }: SwarmDetailProps) {
return (
<div className="space-y-4">
{/* Header */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold" style={{ color: 'var(--color-text-primary)' }}>
{card.swarmId}
</span>
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', HEALTH_COLORS[card.health])}
>
{card.health}
</Badge>
</div>
<h3 className="text-sm font-medium line-clamp-2" style={{ color: 'var(--color-text-primary)' }}>
{card.title}
</h3>
</div>
{/* Progress */}
<ProgressBar progress={card.progress} />
{/* Agent Roster */}
<AgentRosterSection agents={card.agents} />
{/* Attention Items */}
<AttentionSection items={card.attentionItems} />
{/* Last Activity */}
<LastActivitySection date={card.lastActivity} />
{/* Thread */}
<ThreadSection />
</div>
);
}

View file

@ -0,0 +1,191 @@
'use client';
import { useEffect, useState } from 'react';
import type { SwarmCardData, SwarmStatusFromApi } from '../../lib/swarm-api';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { CheckCircle2, PlayCircle, Clock, AlertCircle, Loader2, Users } from 'lucide-react';
import { AgentAvatar } from '../shared/agent-avatar';
import { useAgentPool } from '../../hooks/use-agent-pool';
interface SwarmInspectorProps {
swarmId: string;
projectRoot: string;
onClose?: () => void;
}
function ProgressBar({ progress }: { progress: number }) {
const filled = Math.round(progress / 10);
const empty = 10 - filled;
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-slate-400">Progress</span>
<span className="font-mono text-slate-300">{progress}%</span>
</div>
<div className="flex items-center gap-1 font-mono text-xs text-slate-300 tracking-widest">
<span className="text-emerald-400">{'█'.repeat(filled)}</span>
<span className="text-slate-700">{'░'.repeat(empty)}</span>
</div>
</div>
);
}
export function SwarmInspector({ swarmId, projectRoot }: SwarmInspectorProps) {
const [status, setStatus] = useState<SwarmStatusFromApi | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { agents, getAgentsBySwarm } = useAgentPool(projectRoot);
useEffect(() => {
async function fetchStatus() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
`/api/swarm/status?projectRoot=${encodeURIComponent(projectRoot)}&epic=${encodeURIComponent(swarmId)}`
);
const payload = await response.json();
if (payload.ok && payload.data) {
setStatus(payload.data);
} else {
setError(payload.error?.message || 'Failed to load swarm status');
}
} catch (e) {
setError('Failed to fetch swarm status');
} finally {
setIsLoading(false);
}
}
fetchStatus();
}, [swarmId, projectRoot]);
const assignedAgents = getAgentsBySwarm(swarmId);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-slate-400">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Loading...
</div>
);
}
if (error || !status) {
return (
<div className="p-4 text-center text-rose-400">
{error || 'No data found'}
</div>
);
}
return (
<div className="flex flex-col h-full bg-[#08111d] text-slate-200">
{/* Header */}
<div className="p-4 border-b border-[var(--ui-border-soft)] bg-[#0d1621]">
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline" className="font-mono text-[10px] text-emerald-400 border-emerald-400/30 px-1.5">
{swarmId}
</Badge>
<span className="text-[10px] uppercase tracking-wider text-slate-500">Active Operation</span>
</div>
<h3 className="text-sm font-semibold leading-snug line-clamp-2 mb-3">
{status.epic_title}
</h3>
<ProgressBar progress={status.progress_percent} />
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Agent Roster */}
<section>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-bold uppercase tracking-widest text-slate-500 flex items-center gap-2">
<Users className="h-3 w-3" />
Assigned Agents
</h4>
<span className="text-[10px] bg-slate-800 px-1.5 py-0.5 rounded text-slate-400">
{assignedAgents.length}
</span>
</div>
{assignedAgents.length === 0 ? (
<div className="text-xs text-slate-500 italic p-3 border border-dashed border-slate-800 rounded-lg text-center">
No agents currently assigned.
<br/>
<span className="text-[10px]">Use "Join" on the main card.</span>
</div>
) : (
<div className="space-y-2">
{assignedAgents.map(agent => (
<div key={agent.agent_id} className="flex items-center gap-3 p-2 rounded-lg bg-slate-800/50 border border-slate-800">
<AgentAvatar
name={agent.display_name}
status={agent.status as any}
size="sm"
/>
<div>
<p className="text-xs font-medium text-slate-300">{agent.display_name}</p>
<p className="text-[10px] text-slate-500 font-mono">{agent.status}</p>
</div>
</div>
))}
</div>
)}
</section>
{/* Task Stats */}
<section className="grid grid-cols-2 gap-2">
<div className="p-2 rounded bg-[#0f1824] border border-slate-800">
<div className="flex items-center gap-2 mb-1 text-emerald-400">
<CheckCircle2 className="h-3 w-3" />
<span className="text-[10px] font-bold uppercase">Done</span>
</div>
<span className="text-lg font-mono">{status.completed.length}</span>
</div>
<div className="p-2 rounded bg-[#0f1824] border border-slate-800">
<div className="flex items-center gap-2 mb-1 text-amber-400">
<PlayCircle className="h-3 w-3" />
<span className="text-[10px] font-bold uppercase">Active</span>
</div>
<span className="text-lg font-mono">{status.active_count}</span>
</div>
<div className="p-2 rounded bg-[#0f1824] border border-slate-800">
<div className="flex items-center gap-2 mb-1 text-blue-400">
<Clock className="h-3 w-3" />
<span className="text-[10px] font-bold uppercase">Ready</span>
</div>
<span className="text-lg font-mono">{status.ready_count}</span>
</div>
<div className="p-2 rounded bg-[#0f1824] border border-slate-800">
<div className="flex items-center gap-2 mb-1 text-rose-400">
<AlertCircle className="h-3 w-3" />
<span className="text-[10px] font-bold uppercase">Blocked</span>
</div>
<span className="text-lg font-mono">{status.blocked_count}</span>
</div>
</section>
{/* Active Tasks List */}
{status.active.length > 0 && (
<section>
<h4 className="text-xs font-bold uppercase tracking-widest text-slate-500 mb-3">
Currently Executing
</h4>
<div className="space-y-2">
{status.active.map((task) => (
<div key={task.id} className="p-3 rounded-lg bg-amber-950/20 border border-amber-900/30">
<div className="flex items-center justify-between mb-1">
<span className="font-mono text-[10px] text-amber-500">{task.id}</span>
<Badge variant="outline" className="text-[9px] h-4 border-amber-800 text-amber-500">IN PROGRESS</Badge>
</div>
<p className="text-xs text-slate-300 line-clamp-2">{task.title}</p>
</div>
))}
</div>
</section>
)}
</div>
</div>
);
}

View file

@ -1,10 +1,11 @@
'use client';
import { useMemo, useState } from 'react';
import type { BeadIssue } from '../../lib/types';
import type { SwarmCard as SwarmCardType } from '../../lib/swarm-cards';
import { buildSwarmCards } from '../../lib/swarm-cards';
import { SwarmCard } from './swarm-card';
import { useMemo, useState, useCallback, useEffect, useRef } from 'react';
import { useMissionList, type MissionData } from '../../hooks/use-mission-list';
import { MissionCard } from '../mission/mission-card';
import { TeamManagerDialog } from '../mission/team-manager-dialog';
import { MissionInspector } from '../mission/mission-inspector';
import { LaunchSwarmDialog } from './launch-dialog';
import {
DropdownMenu,
DropdownMenuTrigger,
@ -14,7 +15,8 @@ import {
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { ArrowUpDown, ChevronDown } from 'lucide-react';
import { ArrowUpDown, ChevronDown, Loader2, Rocket, LayoutGrid, Users, Shield } from 'lucide-react';
import { useAgentPool } from '../../hooks/use-agent-pool';
type SortOption = 'health' | 'activity' | 'progress' | 'name';
@ -25,76 +27,157 @@ const SORT_LABELS: Record<SortOption, string> = {
name: 'Name',
};
const INITIAL_LIMIT = 16; // 4x4 grid
const HEALTH_ORDER: Record<string, number> = {
stuck: 0,
stale: 1,
dead: 2,
active: 3,
};
function sortCards(cards: SwarmCardType[], sortBy: SortOption): SwarmCardType[] {
const sorted = [...cards];
const INITIAL_LIMIT = 16;
function sortMissions(missions: MissionData[], sortBy: SortOption): MissionData[] {
const sorted = [...missions];
switch (sortBy) {
case 'health':
return sorted.sort((a, b) => {
const orderA = HEALTH_ORDER[a.health] ?? 4;
const orderB = HEALTH_ORDER[b.health] ?? 4;
return orderA - orderB;
});
case 'activity':
return sorted.sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime());
case 'progress':
return sorted.sort((a, b) => b.progress - a.progress);
return sorted.sort((a, b) => (b.stats.done / (b.stats.total || 1)) - (a.stats.done / (a.stats.total || 1)));
case 'activity':
return sorted; // Need last_activity in API to sort real activity
case 'health':
return sorted.sort((a, b) => b.stats.blocked - a.stats.blocked); // Most blocked first
case 'name':
return sorted.sort((a, b) => a.swarmId.localeCompare(b.swarmId));
return sorted.sort((a, b) => a.title.localeCompare(b.title));
default:
return sorted;
}
}
interface SwarmPageProps {
issues: BeadIssue[];
projectRoot: string;
selectedId?: string;
onSelect: (id: string) => void;
setRightPanel?: (content: React.ReactNode | null) => void;
}
export function SwarmPage({ issues, selectedId, onSelect }: SwarmPageProps) {
export function SwarmPage({ projectRoot, selectedId, onSelect, setRightPanel }: SwarmPageProps) {
const [sortBy, setSortBy] = useState<SortOption>('health');
const [expanded, setExpanded] = useState(false);
const [manageTeamId, setManageTeamId] = useState<string | null>(null);
const cards = useMemo(() => buildSwarmCards(issues), [issues]);
const sortedCards = useMemo(() => sortCards(cards, sortBy), [cards, sortBy]);
const visibleCards = expanded ? sortedCards : sortedCards.slice(0, INITIAL_LIMIT);
const hasMore = sortedCards.length > INITIAL_LIMIT;
// Refs to break dependency loops
const onSelectRef = useRef(onSelect);
useEffect(() => { onSelectRef.current = onSelect; }, [onSelect]);
const { missions, isLoading, error, refresh: refreshMissions } = useMissionList(projectRoot);
const { agents, refresh: refreshAgents } = useAgentPool(projectRoot);
const sortedMissions = useMemo(() => sortMissions(missions, sortBy), [missions, sortBy]);
const visibleMissions = expanded ? sortedMissions : sortedMissions.slice(0, INITIAL_LIMIT);
const hasMore = sortedMissions.length > INITIAL_LIMIT;
const busyAgents = agents.filter(a => a.status === 'working').length;
// Handle Team Manager Actions
const handleAssign = useCallback(async (agentId: string, action: 'join' | 'leave') => {
// If called from inspector, we use selectedId. If called from dialog, we use manageTeamId.
const targetMissionId = manageTeamId || selectedId;
if (!targetMissionId) return;
const endpoint = action === 'join' ? '/api/mission/assign' : '/api/mission/assign';
await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectRoot,
missionId: targetMissionId,
agentId,
action
}),
});
await Promise.all([refreshMissions(), refreshAgents()]);
}, [manageTeamId, selectedId, projectRoot, refreshMissions, refreshAgents]);
const activeMissionForInspector = missions.find(m => m.id === selectedId);
const activeMission = missions.find(m => m.id === manageTeamId);
// Sync right panel on selectedId change
useEffect(() => {
if (selectedId && setRightPanel && activeMissionForInspector) {
setRightPanel(
<MissionInspector
missionId={selectedId}
missionTitle={activeMissionForInspector.title}
projectRoot={projectRoot}
assignedAgents={activeMissionForInspector.agents}
onClose={() => onSelectRef.current('')}
onAssign={(agentId, action) => handleAssign(agentId, action)}
/>
);
} else if (!selectedId && setRightPanel) {
setRightPanel(null);
}
}, [selectedId, projectRoot, setRightPanel, activeMissionForInspector, handleAssign]); // Removed onSelect from deps
return (
<div className="p-4">
<div className="flex items-center justify-between mb-4" style={{ maxWidth: '1200px', margin: '0 auto' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--color-text-primary)' }}>
Swarm View
</h2>
<div className="h-full overflow-y-auto bg-[var(--ui-bg-app)] px-4 py-4 md:px-6 custom-scrollbar">
{/* Dashboard Stats */}
<div className="mx-auto mb-6 grid w-full max-w-[1200px] grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex items-center gap-4 rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] p-4 shadow-sm">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-500/10 text-indigo-500">
<Shield className="h-5 w-5" />
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Active Missions</p>
<p className="text-xl font-mono text-slate-200">{missions.length}</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] p-4 shadow-sm">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-500">
<Users className="h-5 w-5" />
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Agent Fleet</p>
<p className="text-xl font-mono text-slate-200">{agents.length}</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] p-4 shadow-sm border-l-4 border-l-emerald-500">
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Operational Load</p>
<div className="flex items-center gap-2">
<span className="text-xl font-mono text-slate-200">{busyAgents}/{agents.length}</span>
<span className="text-[10px] text-slate-500">engaged</span>
</div>
</div>
</div>
</div>
{/* Toolbar */}
<div className="mx-auto mb-4 flex w-full max-w-[1200px] items-center justify-between gap-3 rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] px-3 py-2 shadow-sm">
<div className="flex items-center gap-3">
<div className="min-w-0">
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">Command</p>
<h2 className="text-base font-semibold text-[var(--ui-text-primary)]">
Mission Control
</h2>
</div>
<div className="h-8 w-px bg-white/5 mx-2" />
<LaunchSwarmDialog projectRoot={projectRoot} onSuccess={refreshMissions} />
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 border-white/10 bg-white/5 hover:bg-white/10"
className="gap-2 border-white/10 bg-white/5 text-[var(--ui-text-primary)] hover:bg-white/10"
>
<ArrowUpDown className="h-4 w-4" />
{SORT_LABELS[sortBy]}
<ArrowUpDown className="h-4 w-4 text-slate-500" aria-hidden="true" />
<span className="text-xs uppercase tracking-wider font-bold">{SORT_LABELS[sortBy]}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuContent align="end" className="w-40 bg-[#0d1621] border-slate-800 text-slate-300">
<DropdownMenuLabel className="text-[10px] uppercase tracking-widest text-slate-500">Sort Missions</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-white/5" />
{(Object.keys(SORT_LABELS) as SortOption[]).map((option) => (
<DropdownMenuItem
key={option}
onClick={() => setSortBy(option)}
className={sortBy === option ? 'bg-accent/50' : ''}
className={sortBy === option ? 'bg-indigo-500/10 text-indigo-400' : 'focus:bg-white/5 focus:text-white'}
>
{SORT_LABELS[option]}
</DropdownMenuItem>
@ -103,47 +186,64 @@ export function SwarmPage({ issues, selectedId, onSelect }: SwarmPageProps) {
</DropdownMenu>
</div>
<div
className="grid gap-4"
style={{
gridTemplateColumns: 'repeat(4, 1fr)',
maxWidth: '1200px',
margin: '0 auto',
}}
>
{visibleCards.map((card) => (
<div
key={card.swarmId}
onClick={() => onSelect(card.swarmId)}
className={`cursor-pointer rounded-xl transition-all ${
selectedId === card.swarmId
? 'ring-2 ring-[var(--color-accent-amber)]'
: 'hover:ring-1 hover:ring-white/10'
}`}
>
<SwarmCard card={card} />
</div>
{/* Grid */}
<div className="mx-auto grid w-full max-w-[1200px] grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{visibleMissions.map((mission) => (
<MissionCard
key={mission.id}
id={mission.id}
projectRoot={projectRoot}
title={mission.title}
description={mission.description}
status={mission.status as any}
stats={mission.stats}
agents={mission.agents}
onClick={() => onSelect(mission.id)}
onDeploy={() => setManageTeamId(mission.id)}
/>
))}
</div>
{hasMore && (
<div className="flex justify-center mt-4">
<div className="mt-8 flex justify-center pb-12">
<Button
variant="outline"
onClick={() => setExpanded(true)}
className="gap-2 border-white/10 bg-white/5 hover:bg-white/10"
className="gap-2 border-white/10 bg-white/5 text-[var(--ui-text-primary)] hover:bg-white/10"
>
Show {sortedCards.length - INITIAL_LIMIT} more
<ChevronDown className="h-4 w-4" />
Show All Missions
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
)}
{sortedCards.length === 0 && (
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
No swarms found. Add agents with <code className="px-1 py-0.5 rounded bg-white/5">gt:agent</code> and <code className="px-1 py-0.5 rounded bg-white/5">swarm:*</code> labels.
{isLoading && (
<div className="py-24 flex flex-col items-center justify-center text-[var(--color-text-muted)]">
<Loader2 className="h-8 w-8 animate-spin mb-4 text-indigo-500" />
<p className="text-sm font-mono uppercase tracking-widest animate-pulse">Establishing Uplink...</p>
</div>
)}
{!isLoading && !error && missions.length === 0 && (
<div className="py-24 flex flex-col items-center justify-center text-[var(--color-text-muted)]">
<Rocket className="h-12 w-12 mb-4 opacity-20" />
<p className="text-sm mb-4">No active missions. Launch one to begin.</p>
<LaunchSwarmDialog projectRoot={projectRoot} onSuccess={refreshMissions} />
</div>
)}
{/* Dialogs */}
{activeMission && (
<TeamManagerDialog
isOpen={!!manageTeamId}
onClose={() => setManageTeamId(null)}
missionId={activeMission.id}
missionTitle={activeMission.title}
projectRoot={projectRoot}
assignedAgents={activeMission.agents}
onAssign={handleAssign}
/>
)}
</div>
);
}
}

View file

@ -1,26 +1,141 @@
"use client";
import React, { useState } from 'react';
import { SwarmLiveDag } from './swarm-live-dag';
import { ConvoyStepper } from './convoy-stepper';
import { TelemetryGrid } from './telemetry-grid';
import { ConvoyStepper, type Phase } from './convoy-stepper';
import { Network, Blocks, FileCode2, Info } from 'lucide-react';
import { cn } from '../../lib/utils';
import type { BeadIssue } from '../../lib/types';
import { useArchetypes } from '../../hooks/use-archetypes';
import { useTemplates } from '../../hooks/use-templates';
import { ArchetypeInspector } from './archetype-inspector';
import { TemplateInspector } from './template-inspector';
export function SwarmWorkspace({ selectedMissionId }: { selectedMissionId?: string }) {
export function SwarmWorkspace({ selectedMissionId, issues = [] }: { selectedMissionId?: string, issues?: BeadIssue[] }) {
const [activeTab, setActiveTab] = useState<'operations' | 'archetypes' | 'templates'>('operations');
// Inspector State
const [inspectingArchetypeId, setInspectingArchetypeId] = useState<string | null>(null);
const [inspectingTemplateId, setInspectingTemplateId] = useState<string | null>(null);
const { archetypes, isLoading: archetypesLoading } = useArchetypes();
const { templates, isLoading: templatesLoading } = useTemplates();
// Simulation State
const [isSimulating, setIsSimulating] = useState(false);
const [simPhase, setSimPhase] = useState<Phase>('planning');
const [simBeads, setSimBeads] = useState<BeadIssue[]>([]);
const handleSummon = () => {
setIsSimulating(true);
setSimPhase('planning');
setSimBeads([]);
// Mock Flow: Planning -> Graph Generation -> Deployment -> Execution
setTimeout(() => {
setSimPhase('deployment'); // Skipping Graph Generation for simplicity here
// Generate some fake beads
const mockBeads: BeadIssue[] = [
{
id: 'b-mock-1',
title: 'Analyze DB Schema',
status: 'closed',
assignee: 'Alice (Architect)',
owner: null,
description: null,
issue_type: 'task',
priority: 1,
labels: [],
dependencies: [{ type: 'parent', target: selectedMissionId || 'epic' }],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
closed_at: null, close_reason: null, closed_by_session: null, created_by: null, due_at: null, estimated_minutes: null, external_ref: null, metadata: {}
},
{
id: 'b-mock-2',
title: 'Implement API Routes',
status: 'in_progress',
assignee: 'Bob (Backend)',
owner: null,
description: null,
issue_type: 'task',
priority: 1,
labels: [],
dependencies: [
{ type: 'parent', target: selectedMissionId || 'epic' },
{ type: 'blocks', target: 'b-mock-1' } // Bob waits for Alice
],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
closed_at: null, close_reason: null, closed_by_session: null, created_by: null, due_at: null, estimated_minutes: null, external_ref: null, metadata: {}
},
{
id: 'b-mock-3',
title: 'Build UI Components',
status: 'blocked',
assignee: 'Charlie (Frontend)',
owner: null,
description: null,
issue_type: 'task',
priority: 1,
labels: [],
dependencies: [
{ type: 'parent', target: selectedMissionId || 'epic' },
{ type: 'blocks', target: 'b-mock-2' } // Charlie waits for Bob
],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
closed_at: null, close_reason: null, closed_by_session: null, created_by: null, due_at: null, estimated_minutes: null, external_ref: null, metadata: {}
}
];
setTimeout(() => {
setSimBeads(mockBeads);
setSimPhase('execution');
}, 1000);
}, 1500);
};
const displayBeads = isSimulating ? simBeads : issues;
const renderTabContent = () => {
switch (activeTab) {
case 'operations':
return selectedMissionId
? (
<div className="flex flex-col h-full gap-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
<ConvoyStepper activePhase="execution" />
<div className="flex-1 min-h-0 bg-[#0f1824]/50 rounded-xl border border-[var(--ui-border-soft)] p-2 shadow-inner">
<SwarmLiveDag epicId={selectedMissionId} />
? (() => {
const epic = issues.find(i => i.id === selectedMissionId);
let epicPhase: Phase = 'planning';
if (epic?.status === 'in_progress') epicPhase = 'execution';
if (epic?.status === 'closed' || epic?.status === 'tombstone') epicPhase = 'debrief';
return (
<div className="flex flex-col h-full gap-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex items-center justify-between">
<ConvoyStepper activePhase={isSimulating ? simPhase : epicPhase} />
<div className="flex gap-2">
<button
onClick={() => setIsSimulating(false)}
className="px-3 py-1.5 text-xs font-semibold bg-rose-500/10 text-rose-500 hover:bg-rose-500/20 rounded-md transition-colors"
>
Halt Swarm
</button>
<button
onClick={handleSummon}
disabled={isSimulating && simPhase !== 'debrief'}
className="px-3 py-1.5 text-xs font-bold bg-[var(--ui-accent-info)] text-white hover:bg-[var(--ui-accent-info)]/90 shadow shadow-[var(--ui-accent-info)]/20 rounded-md transition-colors disabled:opacity-50"
>
Summon Polecats
</button>
</div>
</div>
<div className="flex-1 min-h-0">
<TelemetryGrid epicId={selectedMissionId} issues={displayBeads} archetypes={archetypes} />
</div>
</div>
</div>
)
)
})()
: (
<div className="flex flex-col items-center justify-center h-full text-center space-y-4 animate-in fade-in duration-700">
<div className="p-4 bg-[var(--ui-accent-info)]/10 rounded-full">
@ -36,36 +151,122 @@ export function SwarmWorkspace({ selectedMissionId }: { selectedMissionId?: stri
);
case 'archetypes':
return (
<div className="p-6 bg-[#0f1824]/30 rounded-xl border border-[var(--ui-border-soft)] h-full animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="p-6 bg-[#0f1824]/30 rounded-xl border border-[var(--ui-border-soft)] h-full animate-in fade-in slide-in-from-bottom-4 duration-500 overflow-y-auto custom-scrollbar">
<h3 className="text-xl font-bold text-[var(--ui-text-primary)] mb-2">Agent Archetypes</h3>
<p className="text-[var(--ui-text-muted)] text-sm mb-6">Manage the base roles and system prompts available to your swarms.</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Placeholder Cards */}
{[1, 2, 3].map(i => (
<div key={i} className="bg-[#111f2b] p-4 rounded-lg border border-[var(--ui-border-soft)] hover:border-[var(--ui-accent-info)]/50 transition-colors">
<div className="h-10 w-10 rounded-lg bg-[var(--ui-accent-info)]/20 mb-3" />
<div className="h-4 w-24 bg-white/10 rounded mb-2" />
<div className="h-3 w-3/4 bg-white/5 rounded" />
{archetypesLoading ? (
[1, 2, 3].map(i => (
<div key={i} className="bg-[#111f2b] p-4 rounded-lg border border-[var(--ui-border-soft)] animate-pulse">
<div className="h-10 w-10 rounded-lg bg-[var(--ui-accent-info)]/20 mb-3" />
<div className="h-4 w-24 bg-white/10 rounded mb-2" />
<div className="h-3 w-3/4 bg-white/5 rounded" />
</div>
))
) : archetypes.length === 0 ? (
<div className="col-span-full text-center text-[var(--ui-text-muted)] text-sm py-8 border border-dashed border-white/10 rounded-lg">
No archetypes found. Create one in the `.beads/archetypes/` directory.
</div>
))}
) : (
archetypes.map(arc => (
<button
key={arc.id}
onClick={() => setInspectingArchetypeId(arc.id)}
className="bg-[#111f2b] p-4 rounded-xl border border-[var(--ui-border-soft)] hover:border-[var(--ui-accent-info)]/50 focus:outline-none focus:ring-2 focus:ring-[var(--ui-accent-info)] transition-colors shadow-[0_18px_28px_-22px_rgba(0,0,0,0.96)] flex flex-col text-left w-full h-full"
>
<div className="flex items-center gap-3 mb-3">
<div className="h-10 w-10 flex-shrink-0 rounded-lg flex items-center justify-center font-bold text-lg border" style={{ backgroundColor: `${arc.color}15`, color: arc.color, borderColor: `${arc.color}30` }}>
{arc.name.charAt(0)}
</div>
<div className="truncate">
<div className="font-semibold text-[15px] text-[var(--ui-text-primary)] truncate">{arc.name}</div>
<div className="text-[10px] text-[var(--ui-text-muted)] font-mono uppercase tracking-wider truncate">{arc.id}</div>
</div>
</div>
<div className="text-xs text-[var(--ui-text-muted)] line-clamp-2 mb-4 flex-1">
{arc.description}
</div>
<div className="flex flex-wrap gap-1 mt-auto">
{arc.capabilities.slice(0, 3).map((cap, idx) => (
<span key={idx} className="px-1.5 py-0.5 rounded bg-white/5 text-[9px] uppercase font-semibold text-[var(--ui-text-muted)] border border-white/10">
{cap}
</span>
))}
{arc.capabilities.length > 3 && (
<span className="px-1.5 py-0.5 rounded bg-white/5 text-[9px] uppercase font-semibold text-[var(--ui-text-muted)] border border-white/10">
+{arc.capabilities.length - 3}
</span>
)}
</div>
</button>
))
)}
</div>
</div>
);
case 'templates':
return (
<div className="p-6 bg-[#0f1824]/30 rounded-xl border border-[var(--ui-border-soft)] h-full animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="p-6 bg-[#0f1824]/30 rounded-xl border border-[var(--ui-border-soft)] h-full animate-in fade-in slide-in-from-bottom-4 duration-500 overflow-y-auto custom-scrollbar">
<h3 className="text-xl font-bold text-[var(--ui-text-primary)] mb-2">Swarm Templates</h3>
<p className="text-[var(--ui-text-muted)] text-sm mb-6">Define predefined teams and formulas for rapid mission deployment.</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2].map(i => (
<div key={i} className="bg-[#111f2b] p-5 rounded-lg border border-[var(--ui-border-soft)] flex items-center gap-4 hover:border-amber-500/50 transition-colors">
<div className="h-12 w-12 rounded-full bg-amber-500/20" />
<div>
<div className="h-4 w-32 bg-white/10 rounded mb-2" />
<div className="h-3 w-48 bg-white/5 rounded" />
{templatesLoading ? (
[1, 2].map(i => (
<div key={i} className="bg-[#111f2b] p-5 rounded-xl border border-[var(--ui-border-soft)] flex items-center gap-4 animate-pulse">
<div className="h-12 w-12 rounded-full bg-amber-500/20" />
<div className="flex-1">
<div className="h-4 w-32 bg-white/10 rounded mb-2" />
<div className="h-3 w-48 bg-white/5 rounded" />
</div>
</div>
))
) : templates.length === 0 ? (
<div className="col-span-full text-center text-[var(--ui-text-muted)] text-sm py-8 border border-dashed border-white/10 rounded-lg">
No templates found. Create one in the `.beads/templates/` directory.
</div>
))}
) : (
templates.map(tpl => (
<button
key={tpl.id}
onClick={() => setInspectingTemplateId(tpl.id)}
className="bg-[#111f2b] p-5 rounded-xl border border-[var(--ui-border-soft)] flex flex-col gap-4 hover:border-amber-500/50 focus:outline-none focus:ring-2 focus:ring-amber-500/50 transition-colors shadow-[0_18px_28px_-22px_rgba(0,0,0,0.96)] text-left w-full"
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3 w-full pr-2">
<div className="h-10 w-10 flex-shrink-0 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-500 font-bold">
{tpl.team.reduce((acc, curr) => acc + curr.count, 0)}
</div>
<div className="truncate">
<div className="font-semibold text-[15px] text-[var(--ui-text-primary)] truncate">{tpl.name}</div>
<div className="text-[10px] text-[var(--ui-text-muted)] font-mono uppercase tracking-wider truncate">{tpl.id}</div>
</div>
</div>
{tpl.isBuiltIn && (
<span className="flex-shrink-0 px-2 py-0.5 rounded-full bg-white/5 text-[9px] uppercase font-bold text-[var(--ui-text-muted)] border border-white/10">Default</span>
)}
</div>
<div className="text-xs text-[var(--ui-text-muted)] line-clamp-2">
{tpl.description}
</div>
<div className="mt-auto pt-3 border-t border-[var(--ui-border-soft)] w-full">
<div className="text-[10px] uppercase font-bold text-[var(--ui-text-muted)] tracking-wider mb-2">Team Composition</div>
<div className="flex flex-wrap gap-2">
{tpl.team.map((member, idx) => {
const arch = archetypes.find(a => a.id === member.archetypeId);
return (
<div key={idx} className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-[#0f1824] border border-[var(--ui-border-soft)]">
<div className="h-4 w-4 rounded text-[9px] flex items-center justify-center font-bold" style={{ backgroundColor: `${arch?.color || '#888'}20`, color: arch?.color || '#888' }}>
{arch?.name.charAt(0) || '?'}
</div>
<span className="text-[11px] text-[var(--ui-text-primary)] font-medium">{member.count}x {arch?.name || member.archetypeId}</span>
</div>
);
})}
</div>
</div>
</button>
))
)}
</div>
</div>
);
@ -121,6 +322,22 @@ export function SwarmWorkspace({ selectedMissionId }: { selectedMissionId?: stri
{renderTabContent()}
</div>
</main>
{/* Popups */}
{inspectingArchetypeId && (
<ArchetypeInspector
archetype={archetypes.find(a => a.id === inspectingArchetypeId)!}
onClose={() => setInspectingArchetypeId(null)}
/>
)}
{inspectingTemplateId && (
<TemplateInspector
template={templates.find(t => t.id === inspectingTemplateId)!}
archetypes={archetypes}
onClose={() => setInspectingTemplateId(null)}
/>
)}
</div>
);
}

View file

@ -0,0 +1,217 @@
"use client";
import React, { useState } from 'react';
import dynamic from 'next/dynamic';
import { Loader2, AlertCircle, Bot, Zap } from 'lucide-react';
import type { BeadIssue } from '../../lib/types';
import type { AgentArchetype } from '../../lib/types-swarm';
const SpecializedAgentDagLazy = dynamic(
() => import('./specialized-agent-dag').then((m) => m.SpecializedAgentDag),
{
ssr: false,
loading: () => (
<div className="flex items-center justify-center p-8 w-full h-full min-h-[200px]">
<Loader2 className="animate-spin text-muted-foreground" />
</div>
),
}
);
interface TelemetryGridProps {
epicId: string;
issues: BeadIssue[];
archetypes: AgentArchetype[];
}
export function TelemetryGrid({ epicId, issues, archetypes }: TelemetryGridProps) {
const [selectedBeadId, setSelectedBeadId] = useState<string | null>(null);
const [isPrepping, setIsPrepping] = useState(false);
const [prepSuccess, setPrepSuccess] = useState(false);
const [selectedArchetypeForPrep, setSelectedArchetypeForPrep] = useState<string>('');
// 1. Filter beads for this epic
const beads = issues.filter(issue => {
if (issue.issue_type === 'epic') return false; // don't include epic itself in DAG
const parent = issue.dependencies.find(d => d.type === 'parent');
return parent?.target === epicId;
});
// 2. Compute "Attention Feed" (Blocked beads)
const blockedBeads = beads.filter(b => b.status === 'blocked');
// 3. Compute "Active Roster" (Unique assignees working on in_progress beads)
const activeAssignees = new Set<string>();
const rosterEntries: { assignee: string, currentTask: string, archetype?: AgentArchetype }[] = [];
beads.forEach(b => {
if (b.status === 'in_progress' && b.assignee && !activeAssignees.has(b.assignee)) {
activeAssignees.add(b.assignee);
const assigneeStr = b.assignee.toLowerCase();
const matchedArchetype = archetypes.find(a =>
assigneeStr.includes(a.id.toLowerCase()) ||
assigneeStr.includes(a.name.toLowerCase())
);
rosterEntries.push({
assignee: b.assignee,
currentTask: b.title,
archetype: matchedArchetype
});
}
});
const selectedBead = selectedBeadId ? beads.find(b => b.id === selectedBeadId) : null;
const handlePrepTask = async () => {
if (!selectedBead || !selectedArchetypeForPrep) return;
setIsPrepping(true);
setPrepSuccess(false);
try {
const res = await fetch('/api/swarm/prep', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
beadId: selectedBead.id,
archetypeId: selectedArchetypeForPrep
})
});
if (!res.ok) throw new Error('Prep failed');
setPrepSuccess(true);
setTimeout(() => setPrepSuccess(false), 3000);
// Note: The shell's useIssues typically polls or relies on SWR to update.
// In a real app we'd call mutate() here.
} catch (e) {
console.error(e);
} finally {
setIsPrepping(false);
}
};
return (
<div className="flex flex-col lg:flex-row gap-4 h-full animate-in fade-in duration-500">
{/* Left/Top: Specialized DAG */}
<div className="flex-[2] min-h-[400px] lg:min-h-0 bg-[#0f1824]/50 rounded-xl border border-[var(--ui-border-soft)] shadow-inner relative overflow-hidden flex flex-col">
<div className="absolute top-3 left-3 z-10 px-3 py-1.5 bg-background/80 backdrop-blur rounded-md border border-[var(--ui-border-soft)] flex items-center gap-2 shadow-sm pointer-events-none">
<Bot className="w-4 h-4 text-[var(--ui-accent-info)]" />
<span className="text-xs font-semibold tracking-wide uppercase text-[var(--ui-text-primary)]">Agent Flow</span>
</div>
<div className="flex-1 w-full h-full">
<SpecializedAgentDagLazy
beads={beads}
archetypes={archetypes}
selectedId={selectedBeadId}
onSelect={setSelectedBeadId}
/>
</div>
</div>
{/* Right/Bottom: Feeds */}
<div className="flex-1 flex flex-col gap-4 min-w-[300px]">
{/* Task Assignment Panel (Shows if a node is selected) */}
{selectedBead && (
<div className="flex-none bg-[#111f2b] rounded-xl border border-[var(--ui-accent-info)]/30 flex flex-col overflow-hidden shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)] ring-1 ring-[var(--ui-accent-info)]/10">
<div className="px-4 py-3 border-b border-[var(--ui-border-soft)] bg-[#14202e] flex items-center gap-2">
<Zap className="w-4 h-4 text-[var(--ui-accent-info)]" />
<h3 className="font-semibold text-sm text-[var(--ui-text-primary)]">Task Assignment</h3>
</div>
<div className="p-4 space-y-4">
<div>
<div className="text-[10px] font-mono text-[var(--ui-text-muted)] uppercase tracking-wider mb-1">{selectedBead.id}</div>
<div className="text-sm font-semibold text-[var(--ui-text-primary)] leading-snug">{selectedBead.title}</div>
<div className="text-xs text-[var(--ui-text-muted)] mt-1">Status: <span className="font-semibold uppercase">{selectedBead.status}</span></div>
</div>
{(selectedBead.status === 'open' || selectedBead.status === 'blocked') ? (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-[var(--ui-text-muted)] mb-1.5 block">Assign Agent Archetype</label>
<select
value={selectedArchetypeForPrep}
onChange={(e) => setSelectedArchetypeForPrep(e.target.value)}
className="w-full bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 text-sm text-[var(--ui-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--ui-accent-info)]"
>
<option value="" disabled>Select archetype...</option>
{archetypes.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
<button
onClick={handlePrepTask}
disabled={!selectedArchetypeForPrep || isPrepping || prepSuccess}
className={`w-full py-2 text-white text-sm font-bold rounded-md disabled:opacity-50 transition-colors flex items-center justify-center ${prepSuccess ? 'bg-emerald-500' : 'bg-[var(--ui-accent-info)] hover:bg-[var(--ui-accent-info)]/90'}`}
>
{isPrepping ? <Loader2 className="w-4 h-4 animate-spin" /> : prepSuccess ? 'Prep Successful!' : 'Prep Task for Swarm'}
</button>
</div>
) : (
<div className="text-xs text-amber-500 bg-amber-500/10 p-2 rounded border border-amber-500/20">
Task is {selectedBead.status.replace('_', ' ')}. Only open or blocked tasks can be prepped.
</div>
)}
</div>
</div>
)}
{/* Priority Attention */}
<div className="flex-1 bg-[#111f2b] rounded-xl border border-[var(--ui-border-soft)] flex flex-col overflow-hidden shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]">
<div className="px-4 py-3 border-b border-[var(--ui-border-soft)] bg-[#14202e] flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-rose-500" />
<h3 className="font-semibold text-sm text-[var(--ui-text-primary)]">Priority Attention</h3>
<span className="ml-auto bg-rose-500/10 text-rose-500 text-[10px] font-bold px-2 py-0.5 rounded-full">{blockedBeads.length} Blocked</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2 custom-scrollbar">
{blockedBeads.length === 0 ? (
<div className="text-center text-xs text-[var(--ui-text-muted)] mt-6">
All clear. No blocked tasks.
</div>
) : (
blockedBeads.map(b => (
<div key={b.id} className="p-3 bg-rose-500/5 border border-rose-500/20 rounded-lg">
<div className="text-xs font-mono text-rose-500 mb-1">{b.id}</div>
<div className="text-sm text-[var(--ui-text-primary)] font-medium leading-tight">{b.title}</div>
</div>
))
)}
</div>
</div>
{/* Active Roster */}
<div className="flex-1 bg-[#111f2b] rounded-xl border border-[var(--ui-border-soft)] flex flex-col overflow-hidden shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]">
<div className="px-4 py-3 border-b border-[var(--ui-border-soft)] bg-[#14202e] flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<h3 className="font-semibold text-sm text-[var(--ui-text-primary)]">Active Roster</h3>
<span className="ml-auto text-[10px] uppercase font-bold text-[var(--ui-text-muted)] tracking-wider">{rosterEntries.length} Deployed</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2 custom-scrollbar">
{rosterEntries.length === 0 ? (
<div className="text-center text-xs text-[var(--ui-text-muted)] mt-6">
No agents currently active.
</div>
) : (
rosterEntries.map((r, i) => (
<div key={i} className="flex gap-3 p-3 bg-[#0a111a] border border-white/5 rounded-lg items-center">
<div
className="h-8 w-8 rounded flex-shrink-0 flex items-center justify-center font-bold text-sm border"
style={{ backgroundColor: `${r.archetype?.color || '#888'}15`, color: r.archetype?.color || '#888', borderColor: `${r.archetype?.color || '#888'}30` }}
>
{r.assignee.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-bold text-[var(--ui-text-primary)] truncate">{r.assignee}</div>
<div className="text-[10px] text-[var(--ui-text-muted)] truncate">{r.currentTask}</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,138 @@
import React from 'react';
import { X, Save, Edit, Link, Network } from 'lucide-react';
import type { SwarmTemplate, AgentArchetype } from '../../lib/types-swarm';
interface TemplateInspectorProps {
template: SwarmTemplate;
archetypes: AgentArchetype[];
onClose: () => void;
}
export function TemplateInspector({ template, archetypes, onClose }: TemplateInspectorProps) {
if (!template) return null;
const totalAgents = template.team.reduce((acc, curr) => acc + curr.count, 0);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="flex flex-col h-[75vh] w-full max-w-2xl overflow-hidden rounded-xl border border-[var(--ui-border-soft)] bg-[#0f1824] shadow-2xl animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e]">
<div className="flex items-center gap-4">
<div className="h-10 w-10 flex-shrink-0 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-500 font-bold text-lg">
{totalAgents}
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-bold text-[var(--ui-text-primary)] leading-tight">{template.name}</h2>
{template.isBuiltIn && (
<span className="px-1.5 py-0.5 rounded-full bg-white/10 text-[9px] uppercase font-bold text-[var(--ui-text-muted)] border border-white/10">Built-in</span>
)}
</div>
<p className="font-mono uppercase tracking-wider text-[10px] text-[var(--ui-text-muted)] mt-0.5">{template.id}</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-full p-2 text-[var(--ui-text-muted)] hover:bg-white/5 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body Content */}
<div className="flex-1 overflow-y-auto p-5 space-y-6 custom-scrollbar">
{/* Metadata Section */}
<div>
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-1.5 block">Purpose / Description</label>
<textarea
defaultValue={template.description}
readOnly
rows={2}
className="w-full bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 text-sm text-[var(--ui-text-primary)] focus:outline-none focus:border-[var(--ui-accent-info)] focus:ring-1 focus:ring-[var(--ui-accent-info)] resize-none"
/>
</div>
{/* Team Composition Builder */}
<div className="border-t border-[var(--ui-border-soft)] pt-5">
<div className="flex items-center justify-between mb-4">
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider flex items-center gap-2">
<Network className="w-4 h-4 text-emerald-500" />
Roster Composition
</label>
<button className="text-[11px] font-semibold text-[var(--ui-accent-info)] hover:text-white bg-[var(--ui-accent-info)]/10 px-2 py-1 rounded transition-colors disabled:opacity-50">
+ Add Member
</button>
</div>
<div className="space-y-2">
{template.team.map((member, idx) => {
const arch = archetypes.find(a => a.id === member.archetypeId);
return (
<div key={idx} className="flex items-center gap-3 bg-[#111f2b] border border-[var(--ui-border-soft)] p-3 rounded-lg">
<div className="h-8 w-8 rounded text-sm flex items-center justify-center font-bold" style={{ backgroundColor: `${arch?.color || '#888'}20`, color: arch?.color || '#888' }}>
{arch?.name.charAt(0) || '?'}
</div>
<div className="flex-1">
<div className="font-semibold text-sm text-[var(--ui-text-primary)]">{arch?.name || member.archetypeId}</div>
<div className="text-[11px] text-[var(--ui-text-muted)]">{arch?.description || 'Unknown Archetype'}</div>
</div>
<div className="flex items-center gap-2 bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md p-1">
<span className="text-xs font-mono text-[var(--ui-text-muted)] px-2">Count:</span>
<input
type="number"
defaultValue={member.count}
readOnly
className="w-12 bg-transparent text-sm font-bold text-center text-[var(--ui-text-primary)] focus:outline-none"
/>
</div>
</div>
);
})}
</div>
</div>
{/* Advanced: Proto-formula */}
<div className="border-t border-[var(--ui-border-soft)] pt-5">
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-2 flex items-center gap-2">
<Link className="w-4 h-4 text-amber-500" />
MOL Proto-Formula (Optional)
</label>
<div className="flex items-center gap-3">
<input
type="text"
defaultValue={template.protoFormula || ''}
placeholder="e.g. 'release' or 'bugfix'"
readOnly
className="flex-1 bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 font-mono text-sm text-amber-500 focus:outline-none focus:border-amber-500/50 focus:ring-1 focus:ring-amber-500/50"
/>
<div className="text-[11px] text-[var(--ui-text-muted)] max-w-[200px] leading-tight">
Specifies a Gastown Formula to execute (`bd mol pour`) when launching this swarm.
</div>
</div>
</div>
</div>
{/* Footer Controls */}
<div className="border-t border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e] flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-semibold text-[var(--ui-text-primary)] hover:bg-white/5 rounded-md transition-colors"
>
Close
</button>
<button
disabled
className="flex items-center gap-2 px-4 py-2 text-sm font-bold bg-[var(--ui-accent-info)] hover:bg-[var(--ui-accent-info)]/90 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" />
Save Template
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,61 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import type { AgentRecord } from '../lib/agent-registry';
interface UseAgentPoolResult {
agents: AgentRecord[];
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
getAgentsBySwarm: (swarmId: string) => AgentRecord[];
}
async function fetchAgents(projectRoot: string): Promise<AgentRecord[]> {
try {
const response = await fetch(`/api/agents/list?projectRoot=${encodeURIComponent(projectRoot)}`);
const payload = await response.json();
if (!response.ok || !payload.ok) {
console.error('Agent fetch failed:', payload.error);
return [];
}
return (payload.data || []) as AgentRecord[];
} catch (err) {
console.error('Agent fetch error:', err);
return [];
}
}
export function useAgentPool(projectRoot: string): UseAgentPoolResult {
const [agents, setAgents] = useState<AgentRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
// Only set loading on first fetch
if (agents.length === 0) setIsLoading(true);
setError(null);
try {
const data = await fetchAgents(projectRoot);
setAgents(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load agent pool');
} finally {
setIsLoading(false);
}
}, [projectRoot, agents.length]);
useEffect(() => {
refresh();
const interval = setInterval(refresh, 10000);
return () => clearInterval(interval);
}, [refresh]);
const getAgentsBySwarm = useCallback((swarmId: string) => {
return agents.filter(agent => agent.swarm_id === swarmId);
}, [agents]);
return { agents, isLoading, error, refresh, getAgentsBySwarm };
}

View file

@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
import type { AgentArchetype } from '../lib/types-swarm';
export function useArchetypes() {
const [data, setData] = useState<AgentArchetype[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
async function fetchArchetypes() {
try {
const res = await fetch('/api/swarm/archetypes');
if (!res.ok) {
throw new Error('Failed to fetch archetypes');
}
const json = await res.json();
setData(json);
} catch (err: any) {
setError(err);
} finally {
setIsLoading(false);
}
}
fetchArchetypes();
}, []);
return { archetypes: data, isLoading, error };
}

View file

@ -0,0 +1,42 @@
'use client';
import { useEffect, useState } from 'react';
import type { BeadIssue } from '../lib/types';
interface UseMissionGraphResult {
nodes: BeadIssue[];
isLoading: boolean;
error: string | null;
}
export function useMissionGraph(projectRoot: string, missionId: string): UseMissionGraphResult {
const [nodes, setNodes] = useState<BeadIssue[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchGraph() {
if (!missionId) return;
setIsLoading(true);
setError(null);
try {
const response = await fetch(
`/api/mission/graph?projectRoot=${encodeURIComponent(projectRoot)}&id=${encodeURIComponent(missionId)}`
);
const payload = await response.json();
if (payload.ok && payload.data) {
setNodes(payload.data.nodes);
} else {
setError(payload.error || 'Failed to load graph');
}
} catch (e) {
setError('Failed to fetch mission graph');
} finally {
setIsLoading(false);
}
}
fetchGraph();
}, [projectRoot, missionId]);
return { nodes, isLoading, error };
}

View file

@ -0,0 +1,66 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import type { AgentRecord } from '../lib/agent-registry';
export interface MissionData {
id: string;
title: string;
description?: string;
status: 'planning' | 'active' | 'blocked' | 'completed';
stats: {
total: number;
done: number;
blocked: number;
active: number;
};
agents: AgentRecord[];
}
interface UseMissionListResult {
missions: MissionData[];
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
async function fetchMissions(projectRoot: string): Promise<MissionData[]> {
try {
const response = await fetch(`/api/mission/list?projectRoot=${encodeURIComponent(projectRoot)}`);
const payload = await response.json();
if (!response.ok || !payload.ok) {
throw new Error(payload.error?.message || 'Failed to fetch missions');
}
return payload.data.missions || [];
} catch (err) {
console.error('Mission fetch error:', err);
throw err;
}
}
export function useMissionList(projectRoot: string): UseMissionListResult {
const [missions, setMissions] = useState<MissionData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await fetchMissions(projectRoot);
setMissions(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load missions');
} finally {
setIsLoading(false);
}
}, [projectRoot]);
useEffect(() => {
refresh();
}, [refresh]);
return { missions, isLoading, error, refresh };
}

View file

@ -0,0 +1,86 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import type { SwarmCardData, SwarmListResponse, SwarmStatusFromApi } from '../lib/swarm-api';
import { apiSwarmToCardData, type SwarmFromApi } from '../lib/swarm-api';
interface UseSwarmListResult {
swarms: SwarmCardData[];
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
async function fetchSwarmList(projectRoot: string): Promise<SwarmCardData[]> {
const response = await fetch(`/api/swarm/list?projectRoot=${encodeURIComponent(projectRoot)}`, {
cache: 'no-store',
});
const payload = (await response.json()) as { ok: boolean; data?: SwarmListResponse; error?: { message?: string } };
if (!response.ok || !payload.ok || !payload.data) {
throw new Error(payload.error?.message ?? 'Failed to fetch swarms');
}
return payload.data.swarms.map((s: SwarmFromApi) => apiSwarmToCardData(s));
}
async function fetchSwarmStatus(projectRoot: string, epicId: string): Promise<SwarmStatusFromApi | null> {
try {
const response = await fetch(
`/api/swarm/status?projectRoot=${encodeURIComponent(projectRoot)}&epic=${encodeURIComponent(epicId)}`,
{ cache: 'no-store' }
);
const payload = (await response.json()) as { ok: boolean; data?: SwarmStatusFromApi; error?: { message?: string } };
if (!response.ok || !payload.ok || !payload.data) {
return null;
}
return payload.data;
} catch {
return null;
}
}
export function useSwarmList(projectRoot: string): UseSwarmListResult {
const [swarms, setSwarms] = useState<SwarmCardData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const swarmList = await fetchSwarmList(projectRoot);
const swarmsWithStatus = await Promise.all(
swarmList.map(async (swarm) => {
const status = await fetchSwarmStatus(projectRoot, swarm.epicId);
if (status) {
return {
...swarm,
readyIssues: status.ready_count,
blockedIssues: status.blocked_count,
};
}
return swarm;
})
);
setSwarms(swarmsWithStatus);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch swarms');
} finally {
setIsLoading(false);
}
}, [projectRoot]);
useEffect(() => {
refresh();
}, [refresh]);
return { swarms, isLoading, error, refresh };
}

View file

@ -0,0 +1,50 @@
'use client';
import { useState, useEffect } from 'react';
export interface SwarmTopologyData {
completed: { id: string; title: string; assignee?: string }[];
active: { id: string; title: string; assignee?: string }[];
ready: { id: string; title: string }[];
blocked: { id: string; title: string; blocked_by: string[] }[];
progress_percent: number;
}
export function useSwarmTopology(projectRoot: string, swarmId: string) {
const [topology, setTopology] = useState<SwarmTopologyData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
async function fetchTopology() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/mission/${swarmId}/topology?projectRoot=${encodeURIComponent(projectRoot)}`);
const result = await response.json();
if (mounted) {
if (result.ok) {
setTopology(result.data);
} else {
setError(result.error);
}
}
} catch (err) {
if (mounted) setError('Failed to load topology');
} finally {
if (mounted) setIsLoading(false);
}
}
if (projectRoot && swarmId) {
fetchTopology();
}
return () => { mounted = false; };
}, [projectRoot, swarmId]);
return { topology, isLoading, error };
}

View file

@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
import type { SwarmTemplate } from '../lib/types-swarm';
export function useTemplates() {
const [data, setData] = useState<SwarmTemplate[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
async function fetchTemplates() {
try {
const res = await fetch('/api/swarm/templates');
if (!res.ok) {
throw new Error('Failed to fetch templates');
}
const json = await res.json();
setData(json);
} catch (err: any) {
setError(err);
} finally {
setIsLoading(false);
}
}
fetchTemplates();
}, []);
return { templates: data, isLoading, error };
}

View file

@ -1,6 +1,6 @@
'use client';
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
export type ViewType = 'social' | 'graph' | 'swarm' | 'activity';
@ -12,13 +12,22 @@ export interface UrlState {
view: ViewType;
setView: (v: ViewType) => void;
taskId: string | null;
setTaskId: (id: string | null) => void;
setTaskId: (id: string | null, openDrawer?: boolean) => void;
swarmId: string | null;
setSwarmId: (id: string | null) => void;
setSwarmId: (id: string | null, openDrawer?: boolean) => void;
agentId: string | null;
setAgentId: (id: string | null) => void;
epicId: string | null;
setEpicId: (id: string | null) => void;
leftPanel: PanelState;
setLeftPanel: (state: PanelState) => void;
toggleLeftPanel: () => void;
rightPanel: PanelState;
setRightPanel: (state: PanelState) => void;
toggleRightPanel: () => void;
blockedOnly: boolean;
setBlockedOnly: (enabled: boolean) => void;
toggleBlockedOnly: () => void;
panel: PanelState;
togglePanel: () => void;
drawer: DrawerState;
@ -29,7 +38,8 @@ export interface UrlState {
}
const DEFAULT_VIEW: ViewType = 'social';
const DEFAULT_PANEL: PanelState = 'open';
const DEFAULT_LEFT_PANEL: PanelState = 'open';
const DEFAULT_RIGHT_PANEL: PanelState = 'open';
const DEFAULT_DRAWER: DrawerState = 'closed';
const DEFAULT_GRAPH_TAB: GraphTabType = 'flow';
@ -38,12 +48,51 @@ const VALID_PANELS: PanelState[] = ['open', 'closed'];
const VALID_DRAWERS: DrawerState[] = ['open', 'closed'];
const VALID_GRAPH_TABS: GraphTabType[] = ['flow', 'overview'];
export function parseUrlState(searchParams: URLSearchParams): {
const PANEL_STORAGE_KEYS = {
left: 'bb.ui.leftPanel',
right: 'bb.ui.rightPanel',
} as const;
interface PanelDefaults {
leftPanel: PanelState;
rightPanel: PanelState;
}
function parsePanelValue(value: string | null): PanelState | null {
if (!value || !VALID_PANELS.includes(value as PanelState)) {
return null;
}
return value as PanelState;
}
function readStoredPanelState(key: string, fallback: PanelState): PanelState {
if (typeof window === 'undefined') {
return fallback;
}
const value = window.localStorage.getItem(key);
return parsePanelValue(value) ?? fallback;
}
function isBlockedEnabled(value: string | null): boolean {
return value === '1' || value === 'true';
}
export function parseUrlState(
searchParams: URLSearchParams,
defaults: PanelDefaults = {
leftPanel: DEFAULT_LEFT_PANEL,
rightPanel: DEFAULT_RIGHT_PANEL,
}
): {
view: ViewType;
taskId: string | null;
swarmId: string | null;
agentId: string | null;
epicId: string | null;
leftPanel: PanelState;
rightPanel: PanelState;
blockedOnly: boolean;
panel: PanelState;
drawer: DrawerState;
graphTab: GraphTabType;
@ -58,10 +107,15 @@ export function parseUrlState(searchParams: URLSearchParams): {
const agentId = searchParams.get('agent');
const epicId = searchParams.get('epic');
const panelParam = searchParams.get('panel');
const panel: PanelState = panelParam && VALID_PANELS.includes(panelParam as PanelState)
? (panelParam as PanelState)
: DEFAULT_PANEL;
const leftPanelFromUrl = parsePanelValue(searchParams.get('left'));
const rightPanelFromUrl = parsePanelValue(searchParams.get('right'));
const legacyPanel = parsePanelValue(searchParams.get('panel'));
const leftPanel = leftPanelFromUrl ?? defaults.leftPanel;
const rightPanel = rightPanelFromUrl ?? legacyPanel ?? defaults.rightPanel;
const panel = rightPanel;
const blockedOnly = isBlockedEnabled(searchParams.get('blocked'));
const drawerParam = searchParams.get('drawer');
const drawer: DrawerState = drawerParam && VALID_DRAWERS.includes(drawerParam as DrawerState)
@ -73,7 +127,7 @@ export function parseUrlState(searchParams: URLSearchParams): {
? (graphTabParam as GraphTabType)
: DEFAULT_GRAPH_TAB;
return { view, taskId, swarmId, agentId, epicId, panel, drawer, graphTab };
return { view, taskId, swarmId, agentId, epicId, leftPanel, rightPanel, blockedOnly, panel, drawer, graphTab };
}
export function buildUrlParams(
@ -97,8 +151,27 @@ export function buildUrlParams(
export function useUrlState(): UrlState {
const searchParams = useSearchParams();
const router = useRouter();
const [panelDefaults, setPanelDefaults] = useState<PanelDefaults>({
leftPanel: DEFAULT_LEFT_PANEL,
rightPanel: DEFAULT_RIGHT_PANEL,
});
const state = useMemo(() => parseUrlState(searchParams), [searchParams]);
useEffect(() => {
setPanelDefaults({
leftPanel: readStoredPanelState(PANEL_STORAGE_KEYS.left, DEFAULT_LEFT_PANEL),
rightPanel: readStoredPanelState(PANEL_STORAGE_KEYS.right, DEFAULT_RIGHT_PANEL),
});
}, []);
const state = useMemo(() => parseUrlState(searchParams, panelDefaults), [searchParams, panelDefaults]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(PANEL_STORAGE_KEYS.left, state.leftPanel);
window.localStorage.setItem(PANEL_STORAGE_KEYS.right, state.rightPanel);
}, [state.leftPanel, state.rightPanel]);
const updateUrl = useCallback((updates: Record<string, string | null>) => {
const newUrl = buildUrlParams(searchParams, updates);
@ -109,26 +182,55 @@ export function useUrlState(): UrlState {
updateUrl({ view: v });
}, [updateUrl]);
const setTaskId = useCallback((id: string | null) => {
updateUrl({ task: id, panel: id ? 'open' : null });
const setLeftPanel = useCallback((next: PanelState) => {
updateUrl({ left: next });
}, [updateUrl]);
const setSwarmId = useCallback((id: string | null) => {
updateUrl({ swarm: id, panel: id ? 'open' : null });
const toggleLeftPanel = useCallback(() => {
setLeftPanel(state.leftPanel === 'open' ? 'closed' : 'open');
}, [setLeftPanel, state.leftPanel]);
const setRightPanel = useCallback((next: PanelState) => {
// Keep legacy `panel` in sync while migrating to explicit `right`.
updateUrl({ right: next, panel: next });
}, [updateUrl]);
const toggleRightPanel = useCallback(() => {
setRightPanel(state.rightPanel === 'open' ? 'closed' : 'open');
}, [setRightPanel, state.rightPanel]);
const setBlockedOnly = useCallback((enabled: boolean) => {
updateUrl({ blocked: enabled ? '1' : null });
}, [updateUrl]);
const toggleBlockedOnly = useCallback(() => {
setBlockedOnly(!state.blockedOnly);
}, [setBlockedOnly, state.blockedOnly]);
const setTaskId = useCallback((id: string | null, openDrawer?: boolean) => {
const right = id ? 'open' : null;
const drawer = openDrawer ? 'open' : null;
// Clear swarm when setting task
updateUrl({ task: id, swarm: null, right, panel: right, drawer });
}, [updateUrl]);
const setSwarmId = useCallback((id: string | null, openDrawer?: boolean) => {
const right = id ? 'open' : null;
const drawer = openDrawer ? 'open' : null;
// Clear task when setting swarm
updateUrl({ swarm: id, task: null, right, panel: right, drawer });
}, [updateUrl]);
const setAgentId = useCallback((id: string | null) => {
updateUrl({ agent: id, panel: id ? 'open' : null });
const right = id ? 'open' : null;
updateUrl({ agent: id, right, panel: right });
}, [updateUrl]);
const setEpicId = useCallback((id: string | null) => {
updateUrl({ epic: id });
}, [updateUrl]);
const togglePanel = useCallback(() => {
const newPanel = state.panel === 'open' ? 'closed' : 'open';
updateUrl({ panel: newPanel });
}, [state.panel, updateUrl]);
const togglePanel = toggleRightPanel;
const setDrawer = useCallback((state: DrawerState) => {
updateUrl({ drawer: state });
@ -139,7 +241,7 @@ export function useUrlState(): UrlState {
}, [updateUrl]);
const clearSelection = useCallback(() => {
updateUrl({ task: null, swarm: null, epic: null, panel: 'closed', drawer: 'closed' });
updateUrl({ task: null, swarm: null, epic: null, right: 'closed', panel: 'closed', drawer: 'closed' });
}, [updateUrl]);
return {
@ -153,7 +255,16 @@ export function useUrlState(): UrlState {
setAgentId,
epicId: state.epicId,
setEpicId,
panel: state.panel,
leftPanel: state.leftPanel,
setLeftPanel,
toggleLeftPanel,
rightPanel: state.rightPanel,
setRightPanel,
toggleRightPanel,
blockedOnly: state.blockedOnly,
setBlockedOnly,
toggleBlockedOnly,
panel: state.rightPanel,
togglePanel,
drawer: state.drawer,
setDrawer,

View file

@ -31,6 +31,8 @@ export interface AgentRecord {
version: number;
rig?: string;
role_type?: string;
swarm_id?: string;
current_task?: string;
}
export interface RegisterAgentInput {
@ -179,11 +181,22 @@ function validateRole(value: string): AgentCommandError | null {
function mapBdAgentToRecord(bdAgent: any): AgentRecord {
// Extract role from labels if role_type is not set
let role = bdAgent.role_type || 'agent';
if (role === 'agent' && Array.isArray(bdAgent.labels)) {
let swarmId: string | undefined;
let currentTask: string | undefined;
if (Array.isArray(bdAgent.labels)) {
const roleLabel = bdAgent.labels.find((l: string) => l.startsWith('role:'));
if (roleLabel) {
role = roleLabel.split(':')[1];
}
const swarmLabel = bdAgent.labels.find((l: string) => l.startsWith('swarm:'));
if (swarmLabel) {
swarmId = swarmLabel.split(':')[1];
}
const workingLabel = bdAgent.labels.find((l: string) => l.startsWith('working:'));
if (workingLabel) {
currentTask = workingLabel.split(':')[1];
}
}
let rig = bdAgent.rig;
@ -204,6 +217,8 @@ function mapBdAgentToRecord(bdAgent: any): AgentRecord {
version: 1,
rig,
role_type: bdAgent.role_type,
swarm_id: swarmId,
current_task: currentTask,
};
return record;
}

View file

@ -102,10 +102,16 @@ export async function runBdCommand(
const shellCommand = buildShellCommand(command, args);
const mingwBin = 'C:\\msys64\\mingw64\\bin';
const existingPath = deps.env.Path ?? deps.env.PATH ?? '';
const enhancedPath = existingPath.includes('mingw64')
? existingPath
: `${mingwBin};${existingPath}`;
const { stdout, stderr } = await deps.exec(shellCommand, {
cwd,
timeout: timeoutMs,
env: deps.env,
env: { ...deps.env, Path: enhancedPath, PATH: enhancedPath },
});
return {

View file

@ -1,15 +1,115 @@
import fs from 'fs/promises';
import path from 'path';
import { AgentArchetype } from '../types-swarm';
import { AgentArchetype, SwarmTemplate } from '../types-swarm';
const ARCHE_DIR = path.join(process.cwd(), '.beads', 'archetypes');
const TEMPLATE_DIR = path.join(process.cwd(), '.beads', 'templates');
const SEED_ARCHETYPES: AgentArchetype[] = [
{
id: 'architect',
name: 'System Architect',
description: 'Designs complex system structures and writes detailed implementation plans.',
systemPrompt: 'You are a staff-level software architect focused on high-level system design.',
capabilities: ['planning', 'design_docs', 'arch_review'],
color: '#3b82f6',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isBuiltIn: true
},
{
id: 'coder',
name: 'Implementation Engineer',
description: 'Translates plans into precise, type-safe, and tested code.',
systemPrompt: 'You are a senior software engineer focused on execution and clean code.',
capabilities: ['coding', 'refactoring', 'testing'],
color: '#10b981',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isBuiltIn: true
}
];
export async function getArchetypes(): Promise<AgentArchetype[]> {
try {
await fs.mkdir(ARCHE_DIR, { recursive: true });
// Minimal mock for now to pass test
return [];
const files = await fs.readdir(ARCHE_DIR);
if (files.filter(f => f.endsWith('.json')).length === 0) {
// Seed defaults
for (const arch of SEED_ARCHETYPES) {
await fs.writeFile(path.join(ARCHE_DIR, `${arch.id}.json`), JSON.stringify(arch, null, 2));
}
return SEED_ARCHETYPES;
}
const archetypes: AgentArchetype[] = [];
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
const content = await fs.readFile(path.join(ARCHE_DIR, file), 'utf-8');
const parsed = JSON.parse(content);
archetypes.push({
...parsed,
id: file.replace('.json', '')
});
} catch (err) {
console.error(`Failed to parse archetype file: ${file}`, err);
}
}
return archetypes;
} catch (e) {
console.error('Error in getArchetypes:', e);
return [];
}
}
const SEED_TEMPLATES: SwarmTemplate[] = [
{
id: 'standard-app',
name: 'Standard Application Swarm',
description: 'A balanced team of an Architect and two Coders for standard feature development.',
team: [
{ archetypeId: 'architect', count: 1 },
{ archetypeId: 'coder', count: 2 }
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isBuiltIn: true
}
];
export async function getTemplates(): Promise<SwarmTemplate[]> {
try {
await fs.mkdir(TEMPLATE_DIR, { recursive: true });
const files = await fs.readdir(TEMPLATE_DIR);
if (files.filter(f => f.endsWith('.json')).length === 0) {
for (const tpl of SEED_TEMPLATES) {
await fs.writeFile(path.join(TEMPLATE_DIR, `${tpl.id}.json`), JSON.stringify(tpl, null, 2));
}
return SEED_TEMPLATES;
}
const templates: SwarmTemplate[] = [];
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
const content = await fs.readFile(path.join(TEMPLATE_DIR, file), 'utf-8');
const parsed = JSON.parse(content);
templates.push({
...parsed,
id: file.replace('.json', '')
});
} catch (err) {
console.error(`Failed to parse template file: ${file}`, err);
}
}
return templates;
} catch (e) {
console.error('Error in getTemplates:', e);
return [];
}
}

View file

@ -74,20 +74,21 @@ function extractAgents(bead: BeadIssue): AgentInfo[] {
}
export function buildSocialCards(beads: BeadIssue[]): SocialCard[] {
const taskBeads = beads.filter((bead) => bead.issue_type !== 'epic');
const beadMap = new Map<string, BeadIssue>();
for (const bead of beads) {
for (const bead of taskBeads) {
beadMap.set(bead.id, bead);
}
const blocksIncoming = new Map<string, string[]>();
const blocksOutgoing = new Map<string, string[]>();
for (const bead of beads) {
for (const bead of taskBeads) {
blocksIncoming.set(bead.id, []);
blocksOutgoing.set(bead.id, []);
}
for (const bead of beads) {
for (const bead of taskBeads) {
for (const dep of bead.dependencies) {
if (dep.type === 'blocks' && beadMap.has(dep.target)) {
const outgoing = blocksOutgoing.get(bead.id) ?? [];
@ -101,15 +102,30 @@ export function buildSocialCards(beads: BeadIssue[]): SocialCard[] {
}
}
return beads.map((bead) => ({
id: bead.id,
title: bead.title,
status: mapStatus(bead.status),
blocks: blocksOutgoing.get(bead.id) ?? [], // what I block (amber)
unblocks: blocksIncoming.get(bead.id) ?? [], // what blocks me (rose)
agents: extractAgents(bead),
lastActivity: new Date(bead.updated_at),
priority: mapPriority(bead.priority),
}));
}
return taskBeads.map((bead) => {
const explicitStatus = mapStatus(bead.status);
const incomingBlockers = blocksIncoming.get(bead.id) ?? [];
const hasUnresolvedIncomingBlockers = incomingBlockers.some((blockerId) => {
const blocker = beadMap.get(blockerId);
return blocker ? blocker.status !== 'closed' && blocker.status !== 'tombstone' : false;
});
const effectiveStatus: SocialCardStatus =
explicitStatus === 'closed' || explicitStatus === 'in_progress' || explicitStatus === 'blocked'
? explicitStatus
: hasUnresolvedIncomingBlockers
? 'blocked'
: explicitStatus;
return {
id: bead.id,
title: bead.title,
status: effectiveStatus,
blocks: blocksOutgoing.get(bead.id) ?? [], // what I block (amber)
unblocks: incomingBlockers, // what blocks me (rose)
agents: extractAgents(bead),
lastActivity: new Date(bead.updated_at),
priority: mapPriority(bead.priority),
};
});
}

64
src/lib/swarm-api.ts Normal file
View file

@ -0,0 +1,64 @@
export interface SwarmFromApi {
id: string;
title: string;
epic_id: string;
epic_title: string;
status: string;
coordinator: string;
total_issues: number;
completed_issues: number;
active_issues: number;
progress_percent: number;
}
export interface SwarmListResponse {
swarms: SwarmFromApi[];
}
export interface SwarmStatusFromApi {
epic_id: string;
epic_title: string;
total_issues: number;
completed: Array<{ id: string; title: string; status: string }>;
active: Array<{ id: string; title: string; status: string }>;
ready: Array<{ id: string; title: string; status: string }>;
blocked: Array<{ id: string; title: string; status: string }>;
progress_percent: number;
active_count: number;
ready_count: number;
blocked_count: number;
}
export interface SwarmCardData {
swarmId: string;
title: string;
epicId: string;
epicTitle: string;
status: string;
coordinator: string;
totalIssues: number;
completedIssues: number;
activeIssues: number;
readyIssues: number;
blockedIssues: number;
progressPercent: number;
agents: import('./agent-registry').AgentRecord[];
}
export function apiSwarmToCardData(swarm: SwarmFromApi, status?: SwarmStatusFromApi): SwarmCardData {
return {
swarmId: swarm.id,
title: swarm.title,
epicId: swarm.epic_id,
epicTitle: swarm.epic_title,
status: swarm.status,
coordinator: swarm.coordinator,
totalIssues: swarm.total_issues,
completedIssues: swarm.completed_issues,
activeIssues: swarm.active_issues,
readyIssues: status?.ready_count ?? 0,
blockedIssues: status?.blocked_count ?? 0,
progressPercent: swarm.progress_percent,
agents: [], // Populated separately via agent-registry
};
}

View file

@ -1,6 +1,6 @@
import { interpolate, useCurrentFrame, useVideoConfig, AbsoluteFill, Sequence, Img, staticFile, spring } from 'remotion';
import React from 'react';
import { loadFont } from "@remotion/google-fonts/Inter";
import { loadFont } from '@remotion/google-fonts/inter';
import { Background } from './components/Background';
import { TerminalScene } from './components/TerminalScene';
import { TimelineScene } from './components/TimelineScene';