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:
parent
409a7e7256
commit
dfaf523029
74 changed files with 11066 additions and 2046 deletions
19
src/app/api/agents/list/route.ts
Normal file
19
src/app/api/agents/list/route.ts
Normal 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 });
|
||||
}
|
||||
28
src/app/api/beads/[id]/comments/route.ts
Normal file
28
src/app/api/beads/[id]/comments/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/app/api/mission/[id]/topology/route.ts
Normal file
31
src/app/api/mission/[id]/topology/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
40
src/app/api/mission/assign/route.ts
Normal file
40
src/app/api/mission/assign/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
46
src/app/api/mission/graph/route.ts
Normal file
46
src/app/api/mission/graph/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
88
src/app/api/mission/list/route.ts
Normal file
88
src/app/api/mission/list/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
70
src/app/api/swarm/close/route.ts
Normal file
70
src/app/api/swarm/close/route.ts
Normal 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 });
|
||||
}
|
||||
78
src/app/api/swarm/create/route.ts
Normal file
78
src/app/api/swarm/create/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
29
src/app/api/swarm/formulas/route.ts
Normal file
29
src/app/api/swarm/formulas/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
45
src/app/api/swarm/graph/route.ts
Normal file
45
src/app/api/swarm/graph/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
36
src/app/api/swarm/join/route.ts
Normal file
36
src/app/api/swarm/join/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/app/api/swarm/launch/route.ts
Normal file
42
src/app/api/swarm/launch/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
36
src/app/api/swarm/leave/route.ts
Normal file
36
src/app/api/swarm/leave/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
44
src/app/api/swarm/list/route.ts
Normal file
44
src/app/api/swarm/list/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
29
src/app/api/swarm/prep/route.ts
Normal file
29
src/app/api/swarm/prep/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
45
src/app/api/swarm/status/route.ts
Normal file
45
src/app/api/swarm/status/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
7
src/app/api/swarm/templates/route.ts
Normal file
7
src/app/api/swarm/templates/route.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
148
src/components/mission/mission-card.tsx
Normal file
148
src/components/mission/mission-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
src/components/mission/mission-inspector.tsx
Normal file
141
src/components/mission/mission-inspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
src/components/mission/swarm-graph.tsx
Normal file
107
src/components/mission/swarm-graph.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
255
src/components/mission/team-manager-dialog.tsx
Normal file
255
src/components/mission/team-manager-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ export function UnifiedShell({
|
|||
return (
|
||||
<SwarmWorkspace
|
||||
selectedMissionId={swarmId ?? undefined}
|
||||
issues={filteredIssues}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
110
src/components/swarm/archetype-inspector.tsx
Normal file
110
src/components/swarm/archetype-inspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/swarm/convoy-stepper.tsx
Normal file
31
src/components/swarm/convoy-stepper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
src/components/swarm/launch-dialog.tsx
Normal file
172
src/components/swarm/launch-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
src/components/swarm/specialized-agent-dag.tsx
Normal file
210
src/components/swarm/specialized-agent-dag.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
146
src/components/swarm/swarm-control-card.tsx
Normal file
146
src/components/swarm/swarm-control-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
191
src/components/swarm/swarm-inspector.tsx
Normal file
191
src/components/swarm/swarm-inspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
217
src/components/swarm/telemetry-grid.tsx
Normal file
217
src/components/swarm/telemetry-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
src/components/swarm/template-inspector.tsx
Normal file
138
src/components/swarm/template-inspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/hooks/use-agent-pool.ts
Normal file
61
src/hooks/use-agent-pool.ts
Normal 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 };
|
||||
}
|
||||
29
src/hooks/use-archetypes.ts
Normal file
29
src/hooks/use-archetypes.ts
Normal 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 };
|
||||
}
|
||||
42
src/hooks/use-mission-graph.ts
Normal file
42
src/hooks/use-mission-graph.ts
Normal 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 };
|
||||
}
|
||||
66
src/hooks/use-mission-list.ts
Normal file
66
src/hooks/use-mission-list.ts
Normal 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 };
|
||||
}
|
||||
86
src/hooks/use-swarm-list.ts
Normal file
86
src/hooks/use-swarm-list.ts
Normal 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 };
|
||||
}
|
||||
50
src/hooks/use-swarm-topology.ts
Normal file
50
src/hooks/use-swarm-topology.ts
Normal 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 };
|
||||
}
|
||||
29
src/hooks/use-templates.ts
Normal file
29
src/hooks/use-templates.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
64
src/lib/swarm-api.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue