chore: checkpoint before DAG views UX overhaul
This commit is contained in:
parent
5695125a75
commit
a03def1ca1
125 changed files with 40711 additions and 581 deletions
|
|
@ -1,5 +1,7 @@
|
|||
import { activityEventBus } from '../../../lib/realtime';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const projectRoot = url.searchParams.get('projectRoot') || undefined;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { readIssuesFromDisk } from '../../../../../lib/read-issues';
|
|||
import { activityEventBus } from '../../../../../lib/realtime';
|
||||
import { getAgentMetrics } from '../../../../../lib/agent-sessions';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ agentId: string }> }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { listAgents } from '../../../../lib/agent-registry';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectRoot = searchParams.get('projectRoot');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { readInteractionsViaBd } from '../../../../../lib/read-interactions';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { NextResponse } from 'next/server';
|
|||
|
||||
import { readIssuesFromDisk } from '../../../../lib/read-issues';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd();
|
||||
|
|
|
|||
|
|
@ -21,11 +21,15 @@ async function readLastTouchedVersion(filePath: string): Promise<number | null>
|
|||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const projectRootSearchParam = url.searchParams.get('projectRoot');
|
||||
const projectRoot = canonicalizeWindowsPath(projectRootSearchParam || process.cwd());
|
||||
|
||||
console.log(`[SSE /api/events] Connection request - raw param: "${projectRootSearchParam}", canonicalized: "${projectRoot}"`);
|
||||
|
||||
try {
|
||||
getIssuesWatchManager().startWatch(projectRoot);
|
||||
} catch (error) {
|
||||
|
|
@ -41,7 +45,7 @@ export async function GET(request: Request): Promise<Response> {
|
|||
);
|
||||
}
|
||||
|
||||
let cleanup = () => {};
|
||||
let cleanup = () => { };
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
|
|
@ -55,15 +59,20 @@ export async function GET(request: Request): Promise<Response> {
|
|||
|
||||
write(SSE_CONNECTED_FRAME);
|
||||
|
||||
console.log(`[SSE /api/events] Subscribing to event bus with projectRoot: ${projectRoot}`);
|
||||
const unsubscribeIssues = issuesEventBus.subscribe(
|
||||
(event) => {
|
||||
console.log('[SSE /api/events] Received ISSUES event from bus:', event.kind, 'projectRoot:', event.projectRoot);
|
||||
write(toSseFrame(event));
|
||||
},
|
||||
{ projectRoot },
|
||||
);
|
||||
|
||||
console.log(`[SSE /api/events] Subscriber count after subscribe: ${issuesEventBus.getSubscriberCount()}`);
|
||||
|
||||
const unsubscribeActivity = activityEventBus.subscribe(
|
||||
(event) => {
|
||||
console.log('[SSE /api/events] Received ACTIVITY event from bus');
|
||||
write(toActivitySseFrame(event));
|
||||
},
|
||||
{ projectRoot },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { runBdCommand } from '../../../../lib/bridge';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectRoot = searchParams.get('projectRoot');
|
||||
|
|
@ -23,24 +25,24 @@ export async function GET(request: Request) {
|
|||
});
|
||||
|
||||
if (!headResult.success) {
|
||||
return NextResponse.json({ ok: false, error: 'Failed to fetch mission head' }, { status: 500 });
|
||||
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);
|
||||
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) {
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: 'Failed to parse graph data' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
53
src/app/api/swarm/archetypes/[id]/route.ts
Normal file
53
src/app/api/swarm/archetypes/[id]/route.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { saveArchetype, deleteArchetype } from '../../../../../lib/server/beads-fs';
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
// Validation
|
||||
if (!body.name || !body.systemPrompt) {
|
||||
return NextResponse.json(
|
||||
{ error: 'name and systemPrompt are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const archetype = await saveArchetype({
|
||||
id,
|
||||
name: body.name,
|
||||
description: body.description || '',
|
||||
systemPrompt: body.systemPrompt,
|
||||
capabilities: body.capabilities || [],
|
||||
color: body.color || '#3b82f6',
|
||||
createdAt: body.createdAt,
|
||||
isBuiltIn: body.isBuiltIn
|
||||
});
|
||||
|
||||
return NextResponse.json(archetype);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to update archetype' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
await deleteArchetype(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to delete archetype';
|
||||
const status = message.includes('built-in') ? 403 : 404;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,38 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getArchetypes } from '../../../../lib/server/beads-fs';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getArchetypes, saveArchetype } from '../../../../lib/server/beads-fs';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const data = await getArchetypes();
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validation
|
||||
if (!body.name || !body.systemPrompt) {
|
||||
return NextResponse.json(
|
||||
{ error: 'name and systemPrompt are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const archetype = await saveArchetype({
|
||||
name: body.name,
|
||||
description: body.description || '',
|
||||
systemPrompt: body.systemPrompt,
|
||||
capabilities: body.capabilities || [],
|
||||
color: body.color || '#3b82f6'
|
||||
});
|
||||
|
||||
return NextResponse.json(archetype, { status: 201 });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to create archetype' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { runBdCommand } from '../../../../lib/bridge';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectRoot = searchParams.get('projectRoot');
|
||||
|
|
@ -23,7 +25,7 @@ export async function GET(request: Request) {
|
|||
// 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) {
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: 'Failed to parse formulas' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { runBdCommand } from '../../../../lib/bridge';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectRoot = searchParams.get('projectRoot');
|
||||
|
|
@ -23,7 +25,7 @@ export async function GET(request: Request) {
|
|||
});
|
||||
|
||||
if (!epicResult.success) {
|
||||
return NextResponse.json({ ok: false, error: 'Failed to fetch epic' }, { status: 500 });
|
||||
return NextResponse.json({ ok: false, error: 'Failed to fetch epic' }, { status: 500 });
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -31,15 +33,15 @@ export async function GET(request: Request) {
|
|||
// 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)
|
||||
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) {
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: 'Failed to parse graph data' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { NextResponse } from 'next/server';
|
|||
|
||||
import { runBdCommand } from '../../../../lib/bridge';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectRoot = searchParams.get('projectRoot');
|
||||
|
|
@ -29,11 +31,11 @@ export async function GET(request: Request): Promise<Response> {
|
|||
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: ') &&
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { NextResponse } from 'next/server';
|
|||
|
||||
import { runBdCommand } from '../../../../lib/bridge';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectRoot = searchParams.get('projectRoot');
|
||||
|
|
|
|||
59
src/app/api/swarm/templates/[id]/route.ts
Normal file
59
src/app/api/swarm/templates/[id]/route.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { saveTemplate, deleteTemplate } from '../../../../../lib/server/beads-fs';
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
// Validation
|
||||
if (!body.name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'name is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!body.team || !Array.isArray(body.team) || body.team.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'team must be a non-empty array' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const template = await saveTemplate({
|
||||
id,
|
||||
name: body.name,
|
||||
description: body.description || '',
|
||||
team: body.team,
|
||||
protoFormula: body.protoFormula,
|
||||
createdAt: body.createdAt,
|
||||
isBuiltIn: body.isBuiltIn
|
||||
});
|
||||
|
||||
return NextResponse.json(template);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to update template' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
await deleteTemplate(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to delete template';
|
||||
const status = message.includes('built-in') ? 403 : 404;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,44 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getTemplates } from '../../../../lib/server/beads-fs';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getTemplates, saveTemplate } from '../../../../lib/server/beads-fs';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const data = await getTemplates();
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validation
|
||||
if (!body.name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'name is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!body.team || !Array.isArray(body.team) || body.team.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'team must be a non-empty array' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const template = await saveTemplate({
|
||||
name: body.name,
|
||||
description: body.description || '',
|
||||
team: body.team,
|
||||
protoFormula: body.protoFormula
|
||||
});
|
||||
|
||||
return NextResponse.json(template, { status: 201 });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to create template' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ export default function MockupPage() {
|
|||
setDraftLabels(selectedTask.labels.join(", "))
|
||||
setDraftBlockedReason(selectedTask.blockedReason)
|
||||
setThreadEditMode(false)
|
||||
}, [selectedTask?.id])
|
||||
}, [selectedTask, selectedTask?.id])
|
||||
|
||||
const saveTaskChanges = () => {
|
||||
if (!selectedTask) return
|
||||
|
|
@ -202,26 +202,26 @@ export default function MockupPage() {
|
|||
epic.id !== selectedEpicId
|
||||
? epic
|
||||
: {
|
||||
...epic,
|
||||
tasks: epic.tasks.map((task) =>
|
||||
task.id !== selectedTask.id
|
||||
? task
|
||||
: {
|
||||
...task,
|
||||
title: draftTitle,
|
||||
description: draftDescription,
|
||||
status: draftStatus,
|
||||
priority: draftPriority,
|
||||
issueType: draftIssueType,
|
||||
assignee: draftAssignee,
|
||||
owner: draftOwner,
|
||||
labels: nextLabels,
|
||||
blockedReason: draftBlockedReason,
|
||||
updatedAgo: "now",
|
||||
blockedByCount: draftStatus === "blocked" ? Math.max(task.blockedByCount, 1) : 0,
|
||||
}
|
||||
),
|
||||
}
|
||||
...epic,
|
||||
tasks: epic.tasks.map((task) =>
|
||||
task.id !== selectedTask.id
|
||||
? task
|
||||
: {
|
||||
...task,
|
||||
title: draftTitle,
|
||||
description: draftDescription,
|
||||
status: draftStatus,
|
||||
priority: draftPriority,
|
||||
issueType: draftIssueType,
|
||||
assignee: draftAssignee,
|
||||
owner: draftOwner,
|
||||
labels: nextLabels,
|
||||
blockedReason: draftBlockedReason,
|
||||
updatedAgo: "now",
|
||||
blockedByCount: draftStatus === "blocked" ? Math.max(task.blockedByCount, 1) : 0,
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
setSavePulse(true)
|
||||
|
|
@ -250,29 +250,29 @@ export default function MockupPage() {
|
|||
<Card className={panelClass} style={{ backgroundColor: palette.surface, borderColor: palette.border }}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{leftMode === "tasks" ? (
|
||||
<Button variant="ghost" className="h-8 px-2" onClick={() => setLeftMode("epics")}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to epics
|
||||
</Button>
|
||||
) : (
|
||||
<CardTitle className="text-lg">Epics</CardTitle>
|
||||
)}
|
||||
<Badge className="rounded-full" style={{ backgroundColor: palette.mutedBg, color: palette.textSecondary }}>{selectedEpic.openCount} open</Badge>
|
||||
{leftMode === "tasks" ? (
|
||||
<Button variant="ghost" className="h-8 px-2" onClick={() => setLeftMode("epics")}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to epics
|
||||
</Button>
|
||||
) : (
|
||||
<CardTitle className="text-lg">Epics</CardTitle>
|
||||
)}
|
||||
<Badge className="rounded-full" style={{ backgroundColor: palette.mutedBg, color: palette.textSecondary }}>{selectedEpic.openCount} open</Badge>
|
||||
</div>
|
||||
<CardDescription style={{ color: palette.textSecondary }}>Select an epic, then choose a task.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={leftMode === "epics" ? "Search epics" : "Search tasks"}
|
||||
className="mb-3"
|
||||
style={{ backgroundColor: palette.mutedBg, borderColor: palette.border }}
|
||||
/>
|
||||
<ScrollArea className="h-[520px] pr-2">
|
||||
<div className="space-y-2">
|
||||
{leftMode === "epics"
|
||||
? epics
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={leftMode === "epics" ? "Search epics" : "Search tasks"}
|
||||
className="mb-3"
|
||||
style={{ backgroundColor: palette.mutedBg, borderColor: palette.border }}
|
||||
/>
|
||||
<ScrollArea className="h-[520px] pr-2">
|
||||
<div className="space-y-2">
|
||||
{leftMode === "epics"
|
||||
? epics
|
||||
.filter((epic) => epic.name.toLowerCase().includes(query.toLowerCase()))
|
||||
.map((epic) => (
|
||||
<button
|
||||
|
|
@ -291,7 +291,7 @@ export default function MockupPage() {
|
|||
<p className="mt-1 text-xs" style={{ color: palette.textSecondary }}>{epic.id}</p>
|
||||
</button>
|
||||
))
|
||||
: filteredTasks.map((task) => (
|
||||
: filteredTasks.map((task) => (
|
||||
<button
|
||||
key={task.id}
|
||||
type="button"
|
||||
|
|
@ -299,11 +299,10 @@ export default function MockupPage() {
|
|||
setSelectedTaskId(task.id)
|
||||
closeThread()
|
||||
}}
|
||||
className={`${subPanelClass} w-full p-3 text-left transition duration-200 ${
|
||||
selectedTask?.id === task.id
|
||||
className={`${subPanelClass} w-full p-3 text-left transition duration-200 ${selectedTask?.id === task.id
|
||||
? "shadow-[0_12px_26px_rgba(0,0,0,0.4)]"
|
||||
: "hover:-translate-y-[1px] hover:shadow-[0_10px_22px_rgba(0,0,0,0.33)]"
|
||||
}`}
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: selectedTask?.id === task.id ? palette.mutedBg : palette.surface,
|
||||
borderColor: selectedTask?.id === task.id ? palette.primary : palette.border,
|
||||
|
|
@ -318,68 +317,68 @@ export default function MockupPage() {
|
|||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={panelClass} style={{ backgroundColor: palette.surface, borderColor: palette.border }}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{selectedEpic.name}</CardTitle>
|
||||
<CardDescription style={{ color: palette.textSecondary }}>Task cards + thread context</CardDescription>
|
||||
</div>
|
||||
<Button className="h-8 rounded-full px-4 text-white" style={{ backgroundColor: palette.primary }}>New update</Button>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{selectedEpic.name}</CardTitle>
|
||||
<CardDescription style={{ color: palette.textSecondary }}>Task cards + thread context</CardDescription>
|
||||
</div>
|
||||
<Button className="h-8 rounded-full px-4 text-white" style={{ backgroundColor: palette.primary }}>New update</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<ScrollArea className="h-[430px] pr-2">
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{filteredTasks.map((task) => (
|
||||
<button
|
||||
key={task.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedTaskId(task.id)}
|
||||
className={`rounded-xl border p-4 text-left transition duration-200 hover:-translate-y-[1px] hover:shadow-[0_14px_28px_rgba(0,0,0,0.35)] ${statusClasses(task.status)}`}
|
||||
style={{ borderColor: selectedTask?.id === task.id ? palette.primary : palette.border }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold" style={{ color: palette.eggplant }}>{task.id}</span>
|
||||
<Badge className={`rounded-full px-2 py-0.5 text-[11px] ${statusBadge(task.status)}`}>{task.status.replace("_", " ")}</Badge>
|
||||
</div>
|
||||
<p className="mt-3 text-[1.7rem] font-semibold leading-[1.15]">{task.title}</p>
|
||||
<p className="mt-2 line-clamp-2 text-sm" style={{ color: palette.textSecondary }}>{task.description}</p>
|
||||
<div className="mt-4 flex items-center gap-3 text-xs" style={{ color: palette.textSecondary }}>
|
||||
<span className="inline-flex items-center gap-1"><Clock3 className="h-3.5 w-3.5" />{task.updatedAgo}</span>
|
||||
<span className="inline-flex items-center gap-1"><Link2 className="h-3.5 w-3.5" />{task.dependencyCount}</span>
|
||||
<span className="inline-flex items-center gap-1"><MessageCircle className="h-3.5 w-3.5" />{task.commentCount}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<Separator className="my-4" />
|
||||
<div className={`${subPanelClass} p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]`} style={{ borderColor: palette.border, backgroundColor: palette.mutedBg }}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">Conversation: {selectedTask?.id}</p>
|
||||
<Button variant="ghost" className="h-7 px-2" style={{ color: palette.secondary }} onClick={() => setThreadOpen(true)}>
|
||||
Open thread <ArrowUpRight className="ml-1 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border px-3 py-2 text-sm" style={{ borderColor: "#5A5D6A", backgroundColor: "#2A2B37" }}>
|
||||
<span className="font-semibold" style={{ color: palette.success }}>alex.chen</span>
|
||||
<span className="mx-1 text-xs" style={{ color: "#8F92A3" }}>2m</span>
|
||||
<span style={{ color: palette.textSecondary }}>Need confirmation that detail strip stays sticky while card grid scrolls.</span>
|
||||
<ScrollArea className="h-[430px] pr-2">
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{filteredTasks.map((task) => (
|
||||
<button
|
||||
key={task.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedTaskId(task.id)}
|
||||
className={`rounded-xl border p-4 text-left transition duration-200 hover:-translate-y-[1px] hover:shadow-[0_14px_28px_rgba(0,0,0,0.35)] ${statusClasses(task.status)}`}
|
||||
style={{ borderColor: selectedTask?.id === task.id ? palette.primary : palette.border }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold" style={{ color: palette.eggplant }}>{task.id}</span>
|
||||
<Badge className={`rounded-full px-2 py-0.5 text-[11px] ${statusBadge(task.status)}`}>{task.status.replace("_", " ")}</Badge>
|
||||
</div>
|
||||
<p className="mt-3 text-[1.7rem] font-semibold leading-[1.15]">{task.title}</p>
|
||||
<p className="mt-2 line-clamp-2 text-sm" style={{ color: palette.textSecondary }}>{task.description}</p>
|
||||
<div className="mt-4 flex items-center gap-3 text-xs" style={{ color: palette.textSecondary }}>
|
||||
<span className="inline-flex items-center gap-1"><Clock3 className="h-3.5 w-3.5" />{task.updatedAgo}</span>
|
||||
<span className="inline-flex items-center gap-1"><Link2 className="h-3.5 w-3.5" />{task.dependencyCount}</span>
|
||||
<span className="inline-flex items-center gap-1"><MessageCircle className="h-3.5 w-3.5" />{task.commentCount}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-lg border px-3 py-2 text-sm" style={{ borderColor: "#5A5D6A", backgroundColor: "#2A2B37" }}>
|
||||
<span className="font-semibold" style={{ color: palette.secondary }}>sarah.lee</span>
|
||||
<span className="mx-1 text-xs" style={{ color: "#8F92A3" }}>1m</span>
|
||||
<span style={{ color: palette.textSecondary }}>Approved if right rail remains visible at 1280px breakpoint.</span>
|
||||
</ScrollArea>
|
||||
<Separator className="my-4" />
|
||||
<div className={`${subPanelClass} p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]`} style={{ borderColor: palette.border, backgroundColor: palette.mutedBg }}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">Conversation: {selectedTask?.id}</p>
|
||||
<Button variant="ghost" className="h-7 px-2" style={{ color: palette.secondary }} onClick={() => setThreadOpen(true)}>
|
||||
Open thread <ArrowUpRight className="ml-1 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border px-3 py-2 text-sm" style={{ borderColor: "#5A5D6A", backgroundColor: "#2A2B37" }}>
|
||||
<span className="font-semibold" style={{ color: palette.success }}>alex.chen</span>
|
||||
<span className="mx-1 text-xs" style={{ color: "#8F92A3" }}>2m</span>
|
||||
<span style={{ color: palette.textSecondary }}>Need confirmation that detail strip stays sticky while card grid scrolls.</span>
|
||||
</div>
|
||||
<div className="rounded-lg border px-3 py-2 text-sm" style={{ borderColor: "#5A5D6A", backgroundColor: "#2A2B37" }}>
|
||||
<span className="font-semibold" style={{ color: palette.secondary }}>sarah.lee</span>
|
||||
<span className="mx-1 text-xs" style={{ color: "#8F92A3" }}>1m</span>
|
||||
<span style={{ color: palette.textSecondary }}>Approved if right rail remains visible at 1280px breakpoint.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -389,27 +388,27 @@ export default function MockupPage() {
|
|||
<CardDescription style={{ color: palette.textSecondary }}>Persistent awareness while working tasks.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<div className={`${subPanelClass} p-3 shadow-[0_8px_20px_rgba(0,0,0,0.25)]`} style={{ borderColor: palette.border, backgroundColor: palette.mutedBg }}>
|
||||
<p className="mb-2 text-sm font-semibold">Live Agents</p>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="flex items-center justify-between"><span>swarm-view-integrator</span><span style={{ color: palette.success }}>online</span></p>
|
||||
<p className="flex items-center justify-between"><span>social-view-integrator</span><span style={{ color: palette.warning }}>away</span></p>
|
||||
<p className="flex items-center justify-between"><span>graph-integrator</span><span style={{ color: palette.info }}>busy</span></p>
|
||||
<div className={`${subPanelClass} p-3 shadow-[0_8px_20px_rgba(0,0,0,0.25)]`} style={{ borderColor: palette.border, backgroundColor: palette.mutedBg }}>
|
||||
<p className="mb-2 text-sm font-semibold">Live Agents</p>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="flex items-center justify-between"><span>swarm-view-integrator</span><span style={{ color: palette.success }}>online</span></p>
|
||||
<p className="flex items-center justify-between"><span>social-view-integrator</span><span style={{ color: palette.warning }}>away</span></p>
|
||||
<p className="flex items-center justify-between"><span>graph-integrator</span><span style={{ color: palette.info }}>busy</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${subPanelClass} p-3 shadow-[0_8px_20px_rgba(0,0,0,0.25)]`} style={{ borderColor: palette.border, backgroundColor: palette.mutedBg }}>
|
||||
<p className="mb-2 text-sm font-semibold">Recent Activity</p>
|
||||
<div className="space-y-1 text-xs" style={{ color: palette.textSecondary }}>
|
||||
<p>5m · bb-z6s moved to in progress</p>
|
||||
<p>11m · bb-atf received 2 comments</p>
|
||||
<p>18m · bb-3ha marked closed</p>
|
||||
<p>33m · bb-nuy dependency changed</p>
|
||||
<div className={`${subPanelClass} p-3 shadow-[0_8px_20px_rgba(0,0,0,0.25)]`} style={{ borderColor: palette.border, backgroundColor: palette.mutedBg }}>
|
||||
<p className="mb-2 text-sm font-semibold">Recent Activity</p>
|
||||
<div className="space-y-1 text-xs" style={{ color: palette.textSecondary }}>
|
||||
<p>5m · bb-z6s moved to in progress</p>
|
||||
<p>11m · bb-atf received 2 comments</p>
|
||||
<p>18m · bb-3ha marked closed</p>
|
||||
<p>33m · bb-nuy dependency changed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${subPanelClass} p-3 shadow-[0_8px_20px_rgba(0,0,0,0.22)]`} style={{ borderColor: "#6A4E2F", backgroundColor: "#3A332B" }}>
|
||||
<p className="mb-2 text-sm font-semibold">Attention</p>
|
||||
<p className="flex items-center gap-2 text-sm" style={{ color: "#F2C684" }}><TriangleAlert className="h-4 w-4" /> 2 blocked tasks in selected epic</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${subPanelClass} p-3 shadow-[0_8px_20px_rgba(0,0,0,0.22)]`} style={{ borderColor: "#6A4E2F", backgroundColor: "#3A332B" }}>
|
||||
<p className="mb-2 text-sm font-semibold">Attention</p>
|
||||
<p className="flex items-center gap-2 text-sm" style={{ color: "#F2C684" }}><TriangleAlert className="h-4 w-4" /> 2 blocked tasks in selected epic</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ interface AgentRosterEntry {
|
|||
interface ActivityPanelProps {
|
||||
issues: BeadIssue[];
|
||||
collapsed?: boolean;
|
||||
projectRoot: string;
|
||||
}
|
||||
|
||||
const AGENT_LABEL = 'gt:agent';
|
||||
|
|
@ -243,7 +244,7 @@ function getInitials(name: string): string {
|
|||
return name.split(/[-_\s]/).map(p => p[0]).join('').toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps) {
|
||||
export function ActivityPanel({ issues, collapsed = false, projectRoot }: ActivityPanelProps) {
|
||||
const [activities, setActivities] = useState<ActivityEvent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
|
|
@ -270,13 +271,16 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
|
|||
|
||||
// Subscribe to real-time activity
|
||||
useEffect(() => {
|
||||
const source = new EventSource('/api/events');
|
||||
console.log('[ActivityPanel] Connecting to SSE for:', projectRoot);
|
||||
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
|
||||
|
||||
const onActivity = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data?.event) {
|
||||
setActivities(prev => [data.event, ...prev].slice(0, 50));
|
||||
console.log('[ActivityPanel] Received activity event:', data);
|
||||
// data IS the activity event directly (not wrapped in { event: ... })
|
||||
if (data?.beadId) {
|
||||
setActivities(prev => [data, ...prev].slice(0, 50));
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
|
|
@ -286,10 +290,11 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
|
|||
source.addEventListener('activity', onActivity as EventListener);
|
||||
|
||||
return () => {
|
||||
console.log('[ActivityPanel] Closing SSE connection');
|
||||
source.removeEventListener('activity', onActivity as EventListener);
|
||||
source.close();
|
||||
};
|
||||
}, []);
|
||||
}, [projectRoot]);
|
||||
|
||||
const activeAgents = agentRoster.filter(a => a.status === 'active').length;
|
||||
if (collapsed) {
|
||||
|
|
|
|||
|
|
@ -27,47 +27,47 @@ export interface MissionCardProps {
|
|||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
planning: {
|
||||
color: 'text-blue-400',
|
||||
border: 'border-blue-500/30',
|
||||
bg: 'bg-blue-500/5',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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) {
|
||||
export function MissionCard({ id, projectRoot, title, description, status, 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
|
||||
<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"
|
||||
>
|
||||
|
|
@ -106,7 +106,7 @@ export function MissionCard({ id, projectRoot, title, description, status, stats
|
|||
|
||||
{/* GRAPH VISUALIZATION */}
|
||||
<div className="px-5 py-2 flex-1 flex flex-col justify-end">
|
||||
<SwarmGraph topology={topology} isLoading={isLoading} />
|
||||
<SwarmGraph topology={topology} isLoading={isLoading} />
|
||||
</div>
|
||||
|
||||
{/* FOOTER: SQUAD */}
|
||||
|
|
@ -129,14 +129,14 @@ export function MissionCard({ id, projectRoot, title, description, status, stats
|
|||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
<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"
|
||||
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"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { SwarmTopologyData } from '../../hooks/use-swarm-topology';
|
||||
|
||||
interface SwarmGraphProps {
|
||||
|
|
@ -12,27 +11,26 @@ interface SwarmGraphProps {
|
|||
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
|
||||
<circle
|
||||
key={`done-${item.id}`}
|
||||
cx={20 + (col * 8)}
|
||||
cy={20 + (row * 8)}
|
||||
r={2.5}
|
||||
fill="#34d399"
|
||||
cx={20 + (col * 8)}
|
||||
cy={20 + (row * 8)}
|
||||
r={2.5}
|
||||
fill="#34d399"
|
||||
opacity={0.5}
|
||||
/>
|
||||
);
|
||||
|
|
@ -40,32 +38,32 @@ export function SwarmGraph({ topology, isLoading }: SwarmGraphProps) {
|
|||
|
||||
// 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>
|
||||
);
|
||||
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" />
|
||||
);
|
||||
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" />
|
||||
);
|
||||
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;
|
||||
|
|
@ -80,11 +78,11 @@ export function SwarmGraph({ topology, isLoading }: SwarmGraphProps) {
|
|||
}
|
||||
|
||||
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 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 (
|
||||
|
|
@ -93,9 +91,9 @@ export function SwarmGraph({ topology, isLoading }: SwarmGraphProps) {
|
|||
{/* 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>
|
||||
|
|
|
|||
|
|
@ -93,8 +93,6 @@ export function ThreadDrawer({
|
|||
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) {
|
||||
|
|
@ -103,7 +101,6 @@ export function ThreadDrawer({
|
|||
}
|
||||
|
||||
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[] };
|
||||
|
|
@ -112,8 +109,6 @@ export function ThreadDrawer({
|
|||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch comments:', error);
|
||||
} finally {
|
||||
setCommentsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -239,12 +234,12 @@ export function ThreadDrawer({
|
|||
const frameShellStyle = takeover
|
||||
? undefined
|
||||
: {
|
||||
width: embedded ? '100%' : '26rem',
|
||||
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,
|
||||
};
|
||||
width: embedded ? '100%' : '26rem',
|
||||
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)]">
|
||||
|
|
@ -406,34 +401,34 @@ export function ThreadDrawer({
|
|||
style={
|
||||
isMobile
|
||||
? {
|
||||
paddingTop: takeover ? 'max(1rem, env(safe-area-inset-top))' : undefined,
|
||||
paddingBottom: takeover ? 'max(1rem, env(safe-area-inset-bottom))' : undefined,
|
||||
}
|
||||
paddingTop: takeover ? 'max(1rem, env(safe-area-inset-top))' : undefined,
|
||||
paddingBottom: takeover ? 'max(1rem, env(safe-area-inset-bottom))' : undefined,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<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">
|
||||
<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 className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
<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>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
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>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
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>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
|
|
@ -457,11 +452,11 @@ export function ThreadDrawer({
|
|||
style={
|
||||
isMobile
|
||||
? {
|
||||
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))',
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: 10,
|
||||
}
|
||||
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))',
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: 10,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import type { ProjectScopeOption } from '../../lib/project-scope';
|
||||
|
|
@ -17,6 +17,7 @@ import { SwarmMissionPicker } from '../swarm/swarm-mission-picker';
|
|||
import { buildSocialCards } from '../../lib/social-cards';
|
||||
import { ActivityPanel } from '../activity/activity-panel';
|
||||
import { useSwarmList } from '../../hooks/use-swarm-list';
|
||||
import { useBeadsSubscription } from '../../hooks/use-beads-subscription';
|
||||
|
||||
export interface UnifiedShellProps {
|
||||
issues: BeadIssue[];
|
||||
|
|
@ -27,13 +28,16 @@ export interface UnifiedShellProps {
|
|||
}
|
||||
|
||||
export function UnifiedShell({
|
||||
issues,
|
||||
issues: initialIssues,
|
||||
projectRoot,
|
||||
projectScopeOptions,
|
||||
}: UnifiedShellProps) {
|
||||
const router = useRouter();
|
||||
const { view, taskId, setTaskId, swarmId, setSwarmId, graphTab, setGraphTab, panel, drawer, setDrawer, epicId, setEpicId } = useUrlState();
|
||||
|
||||
// Subscribe to SSE for real-time updates on ALL views
|
||||
const { issues, refresh } = useBeadsSubscription(initialIssues, projectRoot);
|
||||
|
||||
const [filters, setFilters] = useState<LeftPanelFilters>({
|
||||
query: '',
|
||||
status: 'all',
|
||||
|
|
@ -116,6 +120,7 @@ export function UnifiedShell({
|
|||
<SwarmWorkspace
|
||||
selectedMissionId={swarmId ?? undefined}
|
||||
issues={filteredIssues}
|
||||
projectRoot={projectRoot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -157,7 +162,7 @@ export function UnifiedShell({
|
|||
|
||||
{/* RIGHT PANEL: Activity or Custom */}
|
||||
<RightPanel isOpen={panel === 'open'}>
|
||||
{customRightPanel || <ActivityPanel issues={issues} />}
|
||||
{customRightPanel || <ActivityPanel issues={issues} projectRoot={projectRoot} />}
|
||||
</RightPanel>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,108 +1,262 @@
|
|||
import React from 'react';
|
||||
import { X, Save, ShieldAlert } from 'lucide-react';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Save, ShieldAlert, Trash2, Plus } from 'lucide-react';
|
||||
import type { AgentArchetype } from '../../lib/types-swarm';
|
||||
|
||||
interface ArchetypeInspectorProps {
|
||||
archetype: AgentArchetype;
|
||||
archetype?: AgentArchetype;
|
||||
onClose: () => void;
|
||||
onSave: (data: Partial<AgentArchetype>) => Promise<void>;
|
||||
onDelete?: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ArchetypeInspector({ archetype, onClose }: ArchetypeInspectorProps) {
|
||||
if (!archetype) return null;
|
||||
export function ArchetypeInspector({ archetype, onClose, onSave, onDelete }: ArchetypeInspectorProps) {
|
||||
const isNew = !archetype;
|
||||
|
||||
const [name, setName] = useState(archetype?.name || '');
|
||||
const [description, setDescription] = useState(archetype?.description || '');
|
||||
const [systemPrompt, setSystemPrompt] = useState(archetype?.systemPrompt || '');
|
||||
const [capabilities, setCapabilities] = useState<string[]>(archetype?.capabilities || []);
|
||||
const [color, setColor] = useState(archetype?.color || '#3b82f6');
|
||||
const [newCapability, setNewCapability] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (archetype) {
|
||||
setName(archetype.name);
|
||||
setDescription(archetype.description);
|
||||
setSystemPrompt(archetype.systemPrompt);
|
||||
setCapabilities(archetype.capabilities);
|
||||
setColor(archetype.color);
|
||||
}
|
||||
}, [archetype]);
|
||||
|
||||
const handleAddCapability = () => {
|
||||
if (newCapability.trim()) {
|
||||
setCapabilities([...capabilities, newCapability.trim().toLowerCase()]);
|
||||
setNewCapability('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCapability = (index: number) => {
|
||||
setCapabilities(capabilities.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim() || !systemPrompt.trim()) {
|
||||
setError('Name and System Prompt are required');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onSave({
|
||||
id: archetype?.id,
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
systemPrompt: systemPrompt.trim(),
|
||||
capabilities,
|
||||
color,
|
||||
isBuiltIn: archetype?.isBuiltIn
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!archetype || !onDelete) return;
|
||||
|
||||
if (!confirm(`Delete archetype "${archetype.name}"? This cannot be undone.`)) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onDelete(archetype.id);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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` }}
|
||||
style={{ backgroundColor: `${color}15`, color: color, borderColor: `${color}30` }}
|
||||
>
|
||||
{archetype.name.charAt(0)}
|
||||
{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>
|
||||
<h2 className="text-lg font-bold text-[var(--ui-text-primary)] leading-tight">
|
||||
{isNew ? 'New Archetype' : name || 'Edit Archetype'}
|
||||
</h2>
|
||||
{!isNew && (
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
{error && (
|
||||
<div className="mx-5 mt-4 p-3 bg-rose-500/10 border border-rose-500/20 rounded-lg text-rose-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{archetype?.isBuiltIn && (
|
||||
<div className="mx-5 mt-4 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.
|
||||
</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>
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-6 custom-scrollbar">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-1.5 block">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., System Architect"
|
||||
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">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this archetype's role"
|
||||
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">Color</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-10 h-10 rounded cursor-pointer border border-[var(--ui-border-soft)]"
|
||||
/>
|
||||
<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)]"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="flex-1 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>
|
||||
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-1.5 block">System Prompt *</label>
|
||||
<textarea
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
placeholder="You are an expert software engineer..."
|
||||
rows={6}
|
||||
className="w-full bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 text-sm text-[var(--ui-text-primary)] font-mono resize-y focus:outline-none focus:border-[var(--ui-accent-info)] focus:ring-1 focus:ring-[var(--ui-accent-info)] custom-scrollbar"
|
||||
/>
|
||||
</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 gap-2 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newCapability}
|
||||
onChange={(e) => setNewCapability(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddCapability())}
|
||||
placeholder="e.g., execute_code"
|
||||
className="flex-1 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)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCapability}
|
||||
disabled={!newCapability.trim()}
|
||||
className="px-3 py-2 bg-[var(--ui-border-soft)] hover:bg-[var(--ui-border-hover)] text-white rounded-md transition-colors disabled:opacity-50 flex items-center gap-1 text-sm font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{capabilities.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{capabilities.map((cap, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 bg-[#14202e] border border-[var(--ui-border-soft)] px-2.5 py-1 rounded-full text-xs text-[var(--ui-text-primary)] isolate">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: color }}></div>
|
||||
<span>{cap}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveCapability(i)}
|
||||
className="ml-1 text-[var(--ui-text-muted)] hover:text-rose-400 transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--ui-text-muted)] italic py-2">No specific capabilities defined.</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 className="border-t border-[var(--ui-border-soft)] bg-[#0A111A] p-4 flex items-center justify-between flex-shrink-0 rounded-b-xl">
|
||||
<div>
|
||||
{!isNew && !archetype?.isBuiltIn && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="px-4 py-2 border border-rose-500/20 text-rose-400 hover:bg-rose-500/10 rounded-md text-sm font-medium transition-colors flex items-center gap-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--ui-text-muted)] hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="px-5 py-2 bg-[var(--ui-accent-info)] hover:bg-[var(--ui-accent-info)]/90 text-white rounded-md text-sm font-medium transition-colors flex items-center gap-2 shadow-lg shadow-blue-500/20 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{isSaving ? 'Saving...' : (isNew ? 'Create Archetype' : 'Save Changes')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Loader2, Plus, Rocket } from 'lucide-react';
|
||||
import { Loader2, Rocket } from 'lucide-react';
|
||||
|
||||
interface LaunchSwarmDialogProps {
|
||||
projectRoot: string;
|
||||
|
|
@ -51,7 +51,7 @@ export function LaunchSwarmDialog({ projectRoot, onSuccess }: LaunchSwarmDialogP
|
|||
} else {
|
||||
setError(json.error);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setError('Failed to fetch formulas');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -82,7 +82,7 @@ export function LaunchSwarmDialog({ projectRoot, onSuccess }: LaunchSwarmDialogP
|
|||
proto: selectedFormula,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
const json = await res.json();
|
||||
if (json.ok) {
|
||||
setOpen(false);
|
||||
|
|
@ -92,7 +92,7 @@ export function LaunchSwarmDialog({ projectRoot, onSuccess }: LaunchSwarmDialogP
|
|||
} else {
|
||||
setError(json.error);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setError('Failed to launch swarm');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -102,9 +102,9 @@ export function LaunchSwarmDialog({ projectRoot, onSuccess }: LaunchSwarmDialogP
|
|||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<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" />
|
||||
|
|
@ -127,7 +127,7 @@ export function LaunchSwarmDialog({ projectRoot, onSuccess }: LaunchSwarmDialogP
|
|||
</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>
|
||||
<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">
|
||||
|
|
@ -148,7 +148,7 @@ export function LaunchSwarmDialog({ projectRoot, onSuccess }: LaunchSwarmDialogP
|
|||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-rose-400 bg-rose-950/20 p-2 rounded border border-rose-900/30">
|
||||
{error}
|
||||
|
|
@ -156,9 +156,9 @@ export function LaunchSwarmDialog({ projectRoot, onSuccess }: LaunchSwarmDialogP
|
|||
)}
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !title || !selectedFormula}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ 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 { CheckCircle2, PlayCircle, Clock, AlertCircle, UserPlus, Activity } from 'lucide-react';
|
||||
import { AgentAvatar } from '../shared/agent-avatar';
|
||||
import { useAgentPool } from '../../hooks/use-agent-pool';
|
||||
|
||||
|
|
@ -13,7 +13,6 @@ interface SwarmControlCardProps {
|
|||
card: SwarmCardData;
|
||||
projectRoot: string;
|
||||
onJoin?: () => void;
|
||||
onLeave?: () => void;
|
||||
isJoining?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -21,17 +20,17 @@ 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}
|
||||
{[...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>
|
||||
);
|
||||
}
|
||||
|
|
@ -42,14 +41,14 @@ const STATUS_COLORS: Record<string, string> = {
|
|||
in_progress: 'text-amber-400 border-amber-400/30',
|
||||
};
|
||||
|
||||
export function SwarmControlCard({ card, projectRoot, onJoin, onLeave, isJoining }: SwarmControlCardProps) {
|
||||
export function SwarmControlCard({ card, projectRoot, onJoin, 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" />
|
||||
{/* 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 */}
|
||||
|
|
@ -75,11 +74,11 @@ export function SwarmControlCard({ card, projectRoot, onJoin, onLeave, isJoining
|
|||
|
||||
{/* 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 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 */}
|
||||
|
|
@ -107,10 +106,10 @@ export function SwarmControlCard({ card, projectRoot, onJoin, onLeave, isJoining
|
|||
<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"
|
||||
<AgentAvatar
|
||||
name={agent.display_name}
|
||||
status={agent.status as any}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -120,24 +119,24 @@ export function SwarmControlCard({ card, projectRoot, onJoin, onLeave, isJoining
|
|||
</div>
|
||||
)}
|
||||
{agents.length === 0 && (
|
||||
<span className="text-[10px] text-slate-500 italic pl-1">No agents</span>
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { SwarmCardData, SwarmStatusFromApi } from '../../lib/swarm-api';
|
||||
import type { 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';
|
||||
|
|
@ -36,7 +35,7 @@ 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);
|
||||
const { getAgentsBySwarm } = useAgentPool(projectRoot);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStatus() {
|
||||
|
|
@ -52,7 +51,7 @@ export function SwarmInspector({ swarmId, projectRoot }: SwarmInspectorProps) {
|
|||
} else {
|
||||
setError(payload.error?.message || 'Failed to load swarm status');
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setError('Failed to fetch swarm status');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
|
@ -100,7 +99,7 @@ export function SwarmInspector({ swarmId, projectRoot }: SwarmInspectorProps) {
|
|||
{/* 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">
|
||||
<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>
|
||||
|
|
@ -108,21 +107,21 @@ export function SwarmInspector({ swarmId, projectRoot }: SwarmInspectorProps) {
|
|||
{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="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"
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -11,15 +11,15 @@ import { useTemplates } from '../../hooks/use-templates';
|
|||
import { ArchetypeInspector } from './archetype-inspector';
|
||||
import { TemplateInspector } from './template-inspector';
|
||||
|
||||
export function SwarmWorkspace({ selectedMissionId, issues = [] }: { selectedMissionId?: string, issues?: BeadIssue[] }) {
|
||||
export function SwarmWorkspace({ selectedMissionId, issues = [], projectRoot }: { selectedMissionId?: string, issues?: BeadIssue[], projectRoot: string }) {
|
||||
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();
|
||||
const { archetypes, isLoading: archetypesLoading, saveArchetype, deleteArchetype } = useArchetypes(projectRoot);
|
||||
const { templates, isLoading: templatesLoading, saveTemplate, deleteTemplate } = useTemplates(projectRoot);
|
||||
|
||||
// Simulation State
|
||||
const [isSimulating, setIsSimulating] = useState(false);
|
||||
|
|
@ -152,7 +152,15 @@ export function SwarmWorkspace({ selectedMissionId, issues = [] }: { selectedMis
|
|||
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 overflow-y-auto custom-scrollbar">
|
||||
<h3 className="text-xl font-bold text-[var(--ui-text-primary)] mb-2">Agent Archetypes</h3>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xl font-bold text-[var(--ui-text-primary)]">Agent Archetypes</h3>
|
||||
<button
|
||||
onClick={() => setInspectingArchetypeId('')}
|
||||
className="px-3 py-1.5 bg-[var(--ui-accent-info)] hover:bg-[var(--ui-accent-info)]/90 text-white rounded-md text-xs font-semibold shadow-md transition-colors"
|
||||
>
|
||||
+ Create Archetype
|
||||
</button>
|
||||
</div>
|
||||
<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">
|
||||
{archetypesLoading ? (
|
||||
|
|
@ -207,7 +215,15 @@ export function SwarmWorkspace({ selectedMissionId, issues = [] }: { selectedMis
|
|||
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 overflow-y-auto custom-scrollbar">
|
||||
<h3 className="text-xl font-bold text-[var(--ui-text-primary)] mb-2">Swarm Templates</h3>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xl font-bold text-[var(--ui-text-primary)]">Swarm Templates</h3>
|
||||
<button
|
||||
onClick={() => setInspectingTemplateId('')}
|
||||
className="px-3 py-1.5 bg-[var(--ui-accent-info)] hover:bg-[var(--ui-accent-info)]/90 text-white rounded-md text-xs font-semibold shadow-md transition-colors"
|
||||
>
|
||||
+ Create Template
|
||||
</button>
|
||||
</div>
|
||||
<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">
|
||||
{templatesLoading ? (
|
||||
|
|
@ -324,18 +340,22 @@ export function SwarmWorkspace({ selectedMissionId, issues = [] }: { selectedMis
|
|||
</main>
|
||||
|
||||
{/* Popups */}
|
||||
{inspectingArchetypeId && (
|
||||
{inspectingArchetypeId !== null && (
|
||||
<ArchetypeInspector
|
||||
archetype={archetypes.find(a => a.id === inspectingArchetypeId)!}
|
||||
archetype={archetypes.find(a => a.id === inspectingArchetypeId)}
|
||||
onClose={() => setInspectingArchetypeId(null)}
|
||||
onSave={saveArchetype}
|
||||
onDelete={deleteArchetype}
|
||||
/>
|
||||
)}
|
||||
|
||||
{inspectingTemplateId && (
|
||||
{inspectingTemplateId !== null && (
|
||||
<TemplateInspector
|
||||
template={templates.find(t => t.id === inspectingTemplateId)!}
|
||||
template={templates.find(t => t.id === inspectingTemplateId)}
|
||||
archetypes={archetypes}
|
||||
onClose={() => setInspectingTemplateId(null)}
|
||||
onSave={saveTemplate}
|
||||
onDelete={deleteTemplate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,110 @@
|
|||
import React from 'react';
|
||||
import { X, Save, Edit, Link, Network } from 'lucide-react';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Save, Trash2, Plus, Network, ShieldAlert } from 'lucide-react';
|
||||
import type { SwarmTemplate, AgentArchetype } from '../../lib/types-swarm';
|
||||
|
||||
interface TemplateInspectorProps {
|
||||
template: SwarmTemplate;
|
||||
template?: SwarmTemplate;
|
||||
archetypes: AgentArchetype[];
|
||||
onClose: () => void;
|
||||
onSave: (data: Partial<SwarmTemplate>) => Promise<void>;
|
||||
onDelete?: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function TemplateInspector({ template, archetypes, onClose }: TemplateInspectorProps) {
|
||||
if (!template) return null;
|
||||
export function TemplateInspector({ template, archetypes, onClose, onSave, onDelete }: TemplateInspectorProps) {
|
||||
const isNew = !template;
|
||||
|
||||
const totalAgents = template.team.reduce((acc, curr) => acc + curr.count, 0);
|
||||
const [name, setName] = useState(template?.name || '');
|
||||
const [description, setDescription] = useState(template?.description || '');
|
||||
const [team, setTeam] = useState<{ archetypeId: string; count: number }[]>(template?.team || []);
|
||||
const [protoFormula, setProtoFormula] = useState(template?.protoFormula || '');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
setName(template.name);
|
||||
setDescription(template.description);
|
||||
setTeam(template.team);
|
||||
setProtoFormula(template.protoFormula || '');
|
||||
}
|
||||
}, [template]);
|
||||
|
||||
const updateTeamMember = (index: number, field: 'archetypeId' | 'count', value: string | number) => {
|
||||
const newTeam = [...team];
|
||||
if (field === 'count') {
|
||||
newTeam[index] = { ...newTeam[index], count: Math.max(1, Number(value)) };
|
||||
} else {
|
||||
newTeam[index] = { ...newTeam[index], archetypeId: value as string };
|
||||
}
|
||||
setTeam(newTeam);
|
||||
};
|
||||
|
||||
const addTeamMember = () => {
|
||||
const firstAvailableArchetype = archetypes[0]?.id || '';
|
||||
setTeam([...team, { archetypeId: firstAvailableArchetype, count: 1 }]);
|
||||
};
|
||||
|
||||
const removeTeamMember = (index: number) => {
|
||||
setTeam(team.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
setError('Name is required');
|
||||
return;
|
||||
}
|
||||
if (team.length === 0) {
|
||||
setError('At least one team member is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onSave({
|
||||
id: template?.id,
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
team,
|
||||
protoFormula: protoFormula.trim() || undefined,
|
||||
isBuiltIn: template?.isBuiltIn
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!template || !onDelete) return;
|
||||
|
||||
if (!confirm(`Delete template "${template.name}"? This cannot be undone.`)) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onDelete(template.id);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalAgents = 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">
|
||||
<div className="flex flex-col h-[80vh] 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">
|
||||
|
|
@ -25,113 +112,159 @@ export function TemplateInspector({ template, archetypes, onClose }: TemplateIns
|
|||
</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 && (
|
||||
<h2 className="text-lg font-bold text-[var(--ui-text-primary)] leading-tight">
|
||||
{isNew ? 'New Template' : name || 'Edit Template'}
|
||||
</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>
|
||||
{!isNew && (
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
{error && (
|
||||
<div className="mx-5 mt-4 p-3 bg-rose-500/10 border border-rose-500/20 rounded-lg text-rose-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata Section */}
|
||||
{template?.isBuiltIn && (
|
||||
<div className="mx-5 mt-4 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 Template.</span> This is a core system template. You cannot delete it.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-6 custom-scrollbar">
|
||||
<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"
|
||||
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-1.5 block">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Standard Application Swarm"
|
||||
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">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Describe the purpose of this swarm template..."
|
||||
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)] 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
|
||||
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider block">Team Composition *</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTeamMember}
|
||||
className="px-2 py-1 bg-[var(--ui-border-soft)] hover:bg-[var(--ui-border-hover)] text-white rounded transition-colors flex items-center gap-1 text-xs font-medium"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> 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 className="space-y-3">
|
||||
{team.map((member, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<select
|
||||
value={member.archetypeId}
|
||||
onChange={(e) => updateTeamMember(index, 'archetypeId', e.target.value)}
|
||||
className="flex-1 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)]"
|
||||
>
|
||||
{archetypes.map(a => (
|
||||
<option key={a.id} value={a.id}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={member.count}
|
||||
onChange={(e) => updateTeamMember(index, 'count', e.target.value)}
|
||||
className="w-20 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)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTeamMember(index)}
|
||||
className="p-2 text-[var(--ui-text-muted)] hover:text-rose-400 hover:bg-white/5 rounded-md transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{team.length === 0 && (
|
||||
<div className="text-sm text-[var(--ui-text-muted)] italic py-4 text-center border border-dashed border-[var(--ui-border-soft)] rounded-md">
|
||||
No agents assigned. Add a member to build your team.
|
||||
</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)
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-1.5 block flex items-center gap-1.5">
|
||||
<Network className="w-3 h-3" /> 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>
|
||||
<textarea
|
||||
value={protoFormula}
|
||||
onChange={(e) => setProtoFormula(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Optional default interaction rules or steps..."
|
||||
className="w-full bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 text-sm font-mono text-[var(--ui-text-primary)] focus:outline-none focus:border-[var(--ui-accent-info)] resize-y custom-scrollbar"
|
||||
/>
|
||||
</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 className="border-t border-[var(--ui-border-soft)] bg-[#0A111A] p-4 flex items-center justify-between flex-shrink-0 rounded-b-xl">
|
||||
<div>
|
||||
{!isNew && !template?.isBuiltIn && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="px-4 py-2 border border-rose-500/20 text-rose-400 hover:bg-rose-500/10 rounded-md text-sm font-medium transition-colors flex items-center gap-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--ui-text-muted)] hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="px-5 py-2 bg-[var(--ui-accent-info)] hover:bg-[var(--ui-accent-info)]/90 text-white rounded-md text-sm font-medium transition-colors flex items-center gap-2 shadow-lg shadow-blue-500/20 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{isSaving ? 'Saving...' : (isNew ? 'Create Template' : 'Save Changes')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@ interface UseAgentPoolResult {
|
|||
|
||||
async function fetchAgents(projectRoot: string): Promise<AgentRecord[]> {
|
||||
try {
|
||||
const response = await fetch(`/api/agents/list?projectRoot=${encodeURIComponent(projectRoot)}`);
|
||||
const response = await fetch(`/api/agents/list?projectRoot=${encodeURIComponent(projectRoot)}`, { cache: 'no-store' });
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,29 +1,92 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { AgentArchetype } from '../lib/types-swarm';
|
||||
|
||||
export function useArchetypes() {
|
||||
export function useArchetypes(projectRoot: string) {
|
||||
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);
|
||||
const fetchArchetypes = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await fetch('/api/swarm/archetypes', { cache: 'no-store' });
|
||||
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 };
|
||||
const saveArchetype = async (archetype: Partial<AgentArchetype>) => {
|
||||
const isNew = !archetype.id;
|
||||
const method = isNew ? 'POST' : 'PUT';
|
||||
const url = isNew ? '/api/swarm/archetypes' : `/api/swarm/archetypes/${archetype.id}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(archetype)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => null);
|
||||
throw new Error(errorData?.error || 'Failed to save archetype');
|
||||
}
|
||||
|
||||
// Small delay to ensure file system sync, then refresh
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
await fetchArchetypes();
|
||||
};
|
||||
|
||||
const deleteArchetype = async (id: string) => {
|
||||
const res = await fetch(`/api/swarm/archetypes/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => null);
|
||||
throw new Error(errorData?.error || 'Failed to delete archetype');
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
await fetchArchetypes();
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchArchetypes();
|
||||
}, [fetchArchetypes]);
|
||||
|
||||
// Subscribe to SSE for real-time updates
|
||||
useEffect(() => {
|
||||
console.log('[useArchetypes] Connecting to SSE for:', projectRoot);
|
||||
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
|
||||
|
||||
const onIssues = () => {
|
||||
console.log('[useArchetypes] SSE event received, refreshing...');
|
||||
void fetchArchetypes();
|
||||
};
|
||||
|
||||
source.addEventListener('issues', onIssues as EventListener);
|
||||
|
||||
return () => {
|
||||
console.log('[useArchetypes] Closing SSE connection');
|
||||
source.removeEventListener('issues', onIssues as EventListener);
|
||||
source.close();
|
||||
};
|
||||
}, [projectRoot, fetchArchetypes]);
|
||||
|
||||
return {
|
||||
archetypes: data,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchArchetypes,
|
||||
saveArchetype,
|
||||
deleteArchetype
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ interface FetchResponse {
|
|||
}
|
||||
|
||||
async function fetchIssues(projectRoot: string): Promise<BeadIssue[]> {
|
||||
const response = await fetch(`/api/beads/read?projectRoot=${encodeURIComponent(projectRoot)}`, {
|
||||
const timestamp = Date.now();
|
||||
const response = await fetch(`/api/beads/read?projectRoot=${encodeURIComponent(projectRoot)}&_t=${timestamp}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = (await response.json()) as FetchResponse;
|
||||
|
|
@ -67,15 +68,15 @@ export function useBeadsSubscription(
|
|||
useEffect(() => {
|
||||
console.log('[SSE] Connecting to event source for:', projectRoot);
|
||||
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
|
||||
|
||||
|
||||
source.onopen = () => {
|
||||
console.log('[SSE] Connection opened');
|
||||
};
|
||||
|
||||
|
||||
source.onerror = (err) => {
|
||||
console.error('[SSE] Connection error:', err);
|
||||
};
|
||||
|
||||
|
||||
const onIssues = (event: MessageEvent) => {
|
||||
console.log('🚨 SSE ISSUES RECEIVED:', event.data);
|
||||
onUpdate?.('issues');
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ export function useMissionGraph(projectRoot: string, missionId: string): UseMiss
|
|||
setError(null);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/mission/graph?projectRoot=${encodeURIComponent(projectRoot)}&id=${encodeURIComponent(missionId)}`
|
||||
`/api/mission/graph?projectRoot=${encodeURIComponent(projectRoot)}&id=${encodeURIComponent(missionId)}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
const payload = await response.json();
|
||||
if (payload.ok && payload.data) {
|
||||
|
|
@ -29,7 +30,7 @@ export function useMissionGraph(projectRoot: string, missionId: string): UseMiss
|
|||
} else {
|
||||
setError(payload.error || 'Failed to load graph');
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
setError('Failed to fetch mission graph');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
|
|
|||
|
|
@ -26,13 +26,13 @@ interface UseMissionListResult {
|
|||
|
||||
async function fetchMissions(projectRoot: string): Promise<MissionData[]> {
|
||||
try {
|
||||
const response = await fetch(`/api/mission/list?projectRoot=${encodeURIComponent(projectRoot)}`);
|
||||
const response = await fetch(`/api/mission/list?projectRoot=${encodeURIComponent(projectRoot)}`, { cache: 'no-store' });
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ export function useSwarmTopology(projectRoot: string, swarmId: string) {
|
|||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/mission/${swarmId}/topology?projectRoot=${encodeURIComponent(projectRoot)}`);
|
||||
const response = await fetch(`/api/mission/${swarmId}/topology?projectRoot=${encodeURIComponent(projectRoot)}`, { cache: 'no-store' });
|
||||
const result = await response.json();
|
||||
|
||||
|
||||
if (mounted) {
|
||||
if (result.ok) {
|
||||
setTopology(result.data);
|
||||
|
|
@ -32,7 +32,7 @@ export function useSwarmTopology(projectRoot: string, swarmId: string) {
|
|||
setError(result.error);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
if (mounted) setError('Failed to load topology');
|
||||
} finally {
|
||||
if (mounted) setIsLoading(false);
|
||||
|
|
|
|||
|
|
@ -1,29 +1,91 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { SwarmTemplate } from '../lib/types-swarm';
|
||||
|
||||
export function useTemplates() {
|
||||
export function useTemplates(projectRoot: string) {
|
||||
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);
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await fetch('/api/swarm/templates', { cache: 'no-store' });
|
||||
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 };
|
||||
const saveTemplate = async (template: Partial<SwarmTemplate>) => {
|
||||
const isNew = !template.id;
|
||||
const method = isNew ? 'POST' : 'PUT';
|
||||
const url = isNew ? '/api/swarm/templates' : `/api/swarm/templates/${template.id}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(template)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => null);
|
||||
throw new Error(errorData?.error || 'Failed to save template');
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
await fetchTemplates();
|
||||
};
|
||||
|
||||
const deleteTemplate = async (id: string) => {
|
||||
const res = await fetch(`/api/swarm/templates/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => null);
|
||||
throw new Error(errorData?.error || 'Failed to delete template');
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
await fetchTemplates();
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
}, [fetchTemplates]);
|
||||
|
||||
// Subscribe to SSE for real-time updates
|
||||
useEffect(() => {
|
||||
console.log('[useTemplates] Connecting to SSE for:', projectRoot);
|
||||
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
|
||||
|
||||
const onIssues = () => {
|
||||
console.log('[useTemplates] SSE event received, refreshing...');
|
||||
void fetchTemplates();
|
||||
};
|
||||
|
||||
source.addEventListener('issues', onIssues as EventListener);
|
||||
|
||||
return () => {
|
||||
console.log('[useTemplates] Closing SSE connection');
|
||||
source.removeEventListener('issues', onIssues as EventListener);
|
||||
source.close();
|
||||
};
|
||||
}, [projectRoot, fetchTemplates]);
|
||||
|
||||
return {
|
||||
templates: data,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchTemplates,
|
||||
saveTemplate,
|
||||
deleteTemplate
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { runBdCommand, type RunBdCommandResult } from './bridge';
|
||||
import { issuesEventBus } from './realtime';
|
||||
|
||||
export type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment';
|
||||
export type MutationStatus = 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed';
|
||||
|
|
@ -298,6 +299,9 @@ export async function executeMutation(
|
|||
};
|
||||
}
|
||||
|
||||
// Emit event to notify SSE clients of the change
|
||||
issuesEventBus.emit(payload.projectRoot, undefined, 'changed');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
operation,
|
||||
|
|
|
|||
|
|
@ -38,9 +38,9 @@ export class IssuesEventBus {
|
|||
private nextSubscriberId = 1;
|
||||
|
||||
emit(projectRoot: string, changedPath?: string, kind: IssuesChangeKind = 'changed'): IssuesChangedEvent {
|
||||
console.log(`[IssuesBus] Emitting event: ${kind} for ${projectRoot} (${changedPath})`);
|
||||
const canonicalProjectRoot = canonicalizeWindowsPath(projectRoot);
|
||||
const projectKey = windowsPathKey(canonicalProjectRoot);
|
||||
console.log(`[IssuesBus] Emitting event: ${kind} for ${projectKey} (path: ${changedPath}, subscribers: ${this.subscribers.size})`);
|
||||
const event: IssuesChangedEvent = {
|
||||
id: this.nextEventId,
|
||||
projectRoot: canonicalProjectRoot,
|
||||
|
|
@ -50,11 +50,14 @@ export class IssuesEventBus {
|
|||
};
|
||||
this.nextEventId += 1;
|
||||
|
||||
let delivered = 0;
|
||||
for (const subscriber of this.subscribers.values()) {
|
||||
if (!subscriber.projectKey || subscriber.projectKey === projectKey) {
|
||||
subscriber.listener(event);
|
||||
delivered++;
|
||||
}
|
||||
}
|
||||
console.log(`[IssuesBus] Delivered to ${delivered} subscribers`);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,84 @@ import { AgentArchetype, SwarmTemplate } from '../types-swarm';
|
|||
const ARCHE_DIR = path.join(process.cwd(), '.beads', 'archetypes');
|
||||
const TEMPLATE_DIR = path.join(process.cwd(), '.beads', 'templates');
|
||||
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
export type SaveArchetypeInput = Partial<AgentArchetype> & {
|
||||
name: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
capabilities: string[];
|
||||
color: string;
|
||||
};
|
||||
|
||||
export async function saveArchetype(input: SaveArchetypeInput): Promise<AgentArchetype> {
|
||||
await fs.mkdir(ARCHE_DIR, { recursive: true });
|
||||
|
||||
const id = input.id || slugify(input.name);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
let isBuiltIn = input.isBuiltIn ?? false;
|
||||
let createdAt = input.createdAt || now;
|
||||
|
||||
try {
|
||||
const existingContent = await fs.readFile(path.join(ARCHE_DIR, `${id}.json`), 'utf-8');
|
||||
const existing = JSON.parse(existingContent);
|
||||
if (existing.isBuiltIn) {
|
||||
isBuiltIn = true; // Protect built-in status
|
||||
}
|
||||
if (existing.createdAt) {
|
||||
createdAt = existing.createdAt;
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist, which is fine
|
||||
}
|
||||
|
||||
const archetype: AgentArchetype = {
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
systemPrompt: input.systemPrompt,
|
||||
capabilities: input.capabilities,
|
||||
color: input.color,
|
||||
createdAt,
|
||||
updatedAt: now,
|
||||
isBuiltIn
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(ARCHE_DIR, `${id}.json`),
|
||||
JSON.stringify(archetype, null, 2)
|
||||
);
|
||||
|
||||
return archetype;
|
||||
}
|
||||
|
||||
export async function deleteArchetype(id: string): Promise<void> {
|
||||
const filePath = path.join(ARCHE_DIR, `${id}.json`);
|
||||
|
||||
let archetype: AgentArchetype;
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
archetype = JSON.parse(content);
|
||||
} catch {
|
||||
throw new Error(`Archetype not found: ${id}`);
|
||||
}
|
||||
|
||||
if (archetype.isBuiltIn) {
|
||||
throw new Error(`Cannot delete built-in archetype: ${id}`);
|
||||
}
|
||||
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
|
||||
const SEED_ARCHETYPES: AgentArchetype[] = [
|
||||
{
|
||||
id: 'architect',
|
||||
|
|
@ -113,3 +191,77 @@ export async function getTemplates(): Promise<SwarmTemplate[]> {
|
|||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export type SaveTemplateInput = Partial<SwarmTemplate> & {
|
||||
name: string;
|
||||
description: string;
|
||||
team: { archetypeId: string; count: number }[];
|
||||
};
|
||||
|
||||
export async function saveTemplate(input: SaveTemplateInput): Promise<SwarmTemplate> {
|
||||
await fs.mkdir(TEMPLATE_DIR, { recursive: true });
|
||||
|
||||
const archetypes = await getArchetypes();
|
||||
const validArchetypeIds = new Set(archetypes.map(a => a.id));
|
||||
|
||||
for (const member of input.team) {
|
||||
if (!validArchetypeIds.has(member.archetypeId)) {
|
||||
throw new Error(`Invalid archetype ID in team: ${member.archetypeId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const id = input.id || slugify(input.name);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
let isBuiltIn = input.isBuiltIn ?? false;
|
||||
let createdAt = input.createdAt || now;
|
||||
|
||||
try {
|
||||
const existingContent = await fs.readFile(path.join(TEMPLATE_DIR, `${id}.json`), 'utf-8');
|
||||
const existing = JSON.parse(existingContent);
|
||||
if (existing.isBuiltIn) {
|
||||
isBuiltIn = true; // Protect built-in status
|
||||
}
|
||||
if (existing.createdAt) {
|
||||
createdAt = existing.createdAt;
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist, which is fine
|
||||
}
|
||||
|
||||
const template: SwarmTemplate = {
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
team: input.team,
|
||||
protoFormula: input.protoFormula,
|
||||
createdAt,
|
||||
updatedAt: now,
|
||||
isBuiltIn
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(TEMPLATE_DIR, `${id}.json`),
|
||||
JSON.stringify(template, null, 2)
|
||||
);
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
export async function deleteTemplate(id: string): Promise<void> {
|
||||
const filePath = path.join(TEMPLATE_DIR, `${id}.json`);
|
||||
|
||||
let template: SwarmTemplate;
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
template = JSON.parse(content);
|
||||
} catch {
|
||||
throw new Error(`Template not found: ${id}`);
|
||||
}
|
||||
|
||||
if (template.isBuiltIn) {
|
||||
throw new Error(`Cannot delete built-in template: ${id}`);
|
||||
}
|
||||
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,10 +69,10 @@ export function diffSnapshots(
|
|||
|
||||
// 4. Collection Changes (Labels)
|
||||
if (!areArraysEqual(prev.labels, curr.labels)) {
|
||||
events.push(createEvent('labels_changed', curr, now, {
|
||||
field: 'labels',
|
||||
from: prev.labels.join(','),
|
||||
to: curr.labels.join(',')
|
||||
events.push(createEvent('labels_changed', curr, now, {
|
||||
field: 'labels',
|
||||
from: prev.labels.join(','),
|
||||
to: curr.labels.join(',')
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -82,6 +82,16 @@ export function diffSnapshots(
|
|||
});
|
||||
});
|
||||
|
||||
// 6. Detect Deleted Issues
|
||||
if (previous) {
|
||||
const currMap = new Set(current.map(c => c.id));
|
||||
previous.forEach(prev => {
|
||||
if (!currMap.has(prev.id)) {
|
||||
events.push(createEvent('deleted' as any, prev, now)); // Force cast as 'deleted' may not be in ActivityEventKind type
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +135,7 @@ function diffDependencies(
|
|||
curr: BeadDependency[]
|
||||
): { kind: 'dependency_added' | 'dependency_removed', target: string }[] {
|
||||
const changes: { kind: 'dependency_added' | 'dependency_removed', target: string }[] = [];
|
||||
|
||||
|
||||
const prevTargets = new Set(prev.map(d => d.target));
|
||||
const currTargets = new Set(curr.map(d => d.target));
|
||||
|
||||
|
|
|
|||
|
|
@ -41,29 +41,39 @@ export class IssuesWatchManager {
|
|||
}>;
|
||||
|
||||
constructor(options: WatchManagerOptions = {}) {
|
||||
const debounceMs = options.debounceMs ?? 150;
|
||||
const debounceMs = options.debounceMs ?? 450;
|
||||
this.eventBus = options.eventBus ?? issuesEventBus;
|
||||
this.activityBus = options.activityBus ?? activityEventBus;
|
||||
this.coalescer = new ProjectEventCoalescer(debounceMs, async ({ projectRoot, payload }) => {
|
||||
console.log(`[Watcher] Processing event for ${projectRoot}: ${payload.kind} (${payload.changedPath})`);
|
||||
|
||||
|
||||
// 1. Emit basic file change event
|
||||
// If it's just last-touched or a DB file change, we treat it as telemetry
|
||||
// If it's just last-touched or a DB file change, we treat it as telemetry initially
|
||||
const changedPath = payload.changedPath || '';
|
||||
const isIssuesJsonl = changedPath.endsWith('issues.jsonl') || changedPath.endsWith('issues.jsonl.new');
|
||||
const isLastTouched = changedPath.includes('last-touched');
|
||||
const isDbPulse = changedPath.includes('beads.db');
|
||||
const isArchetype = changedPath.includes('.beads') && changedPath.includes('archetypes');
|
||||
const isTemplate = changedPath.includes('.beads') && changedPath.includes('templates');
|
||||
|
||||
const kind = (isLastTouched || isDbPulse) && !isIssuesJsonl ? 'telemetry' : payload.kind;
|
||||
this.eventBus.emit(projectRoot, payload.changedPath, kind);
|
||||
const isBaseTelemetry = (isLastTouched || isDbPulse) && !isIssuesJsonl && !isArchetype && !isTemplate;
|
||||
|
||||
console.log(`[Watcher] Base Telemetry Emit -> ${isBaseTelemetry ? 'telemetry' : payload.kind}`);
|
||||
this.eventBus.emit(projectRoot, payload.changedPath, isBaseTelemetry ? 'telemetry' : payload.kind);
|
||||
|
||||
// 2. Perform snapshot diffing if issues.jsonl changed
|
||||
const isBeadsDb = changedPath.includes('beads.db') || isLastTouched;
|
||||
const isGlobalMessages = changedPath.includes('.beadboard') && changedPath.includes('messages');
|
||||
|
||||
|
||||
if (isIssuesJsonl || isBeadsDb) {
|
||||
console.log(`[Watcher] Issues changed. Syncing activity for ${projectRoot}...`);
|
||||
await this.syncActivity(projectRoot);
|
||||
const hadMutations = await this.syncActivity(projectRoot);
|
||||
|
||||
// If it was just a telemetry pulse, but we discovered actual structural changes, emit an issues event to refresh UI
|
||||
if (hadMutations && isBaseTelemetry) {
|
||||
console.log(`[Watcher] Structural changes found in telemetry pulse. Upgrading to issues event.`);
|
||||
this.eventBus.emit(projectRoot, payload.changedPath, payload.kind);
|
||||
}
|
||||
} else if (isGlobalMessages) {
|
||||
console.log(`[Watcher] Global agent messages changed. Triggering refresh for ${projectRoot}.`);
|
||||
// No need to syncActivity (diff issues) if only messages changed,
|
||||
|
|
@ -72,35 +82,45 @@ export class IssuesWatchManager {
|
|||
});
|
||||
}
|
||||
|
||||
private async syncActivity(projectRoot: string): Promise<void> {
|
||||
private async syncActivity(projectRoot: string): Promise<boolean> {
|
||||
const projectKey = windowsPathKey(projectRoot);
|
||||
const previous = this.snapshots.get(projectKey) ?? null;
|
||||
|
||||
|
||||
try {
|
||||
const current = await readIssuesFromDisk({ projectRoot, preferBd: true, skipAgentFilter: true });
|
||||
const events = diffSnapshots(previous, current);
|
||||
|
||||
|
||||
console.log(`[Watcher] syncActivity for ${projectRoot}: generated ${events.length} events (prev: ${previous?.length ?? 0}, current: ${current.length})`);
|
||||
|
||||
this.snapshots.set(projectKey, current);
|
||||
|
||||
|
||||
events.forEach(event => {
|
||||
this.activityBus.emit(event);
|
||||
});
|
||||
|
||||
return events.length > 0;
|
||||
} catch (error) {
|
||||
console.error(`[Watcher] Failed to sync activity for ${projectRoot}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async startWatch(projectRoot: string): Promise<void> {
|
||||
const projectKey = windowsPathKey(projectRoot);
|
||||
if (this.registrations.has(projectKey)) {
|
||||
console.log(`[Watcher] Already watching: ${projectKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Watcher] Starting watch for: ${projectRoot} (key: ${projectKey})`);
|
||||
|
||||
// Pre-populate snapshot to avoid "all created" burst on first change
|
||||
try {
|
||||
const initial = await readIssuesFromDisk({ projectRoot, preferBd: true, skipAgentFilter: true });
|
||||
this.snapshots.set(projectKey, initial);
|
||||
} catch {
|
||||
console.log(`[Watcher] Initial snapshot: ${initial.length} issues`);
|
||||
} catch (err) {
|
||||
console.log(`[Watcher] Initial snapshot failed:`, err);
|
||||
// Ignore initial read failure, will retry on first change
|
||||
}
|
||||
|
||||
|
|
@ -109,9 +129,15 @@ export class IssuesWatchManager {
|
|||
watchedPaths.push(path.join(projectRoot, '.beads', 'beads.db-wal'));
|
||||
watchedPaths.push(path.join(projectRoot, '.beads', 'last-touched'));
|
||||
|
||||
// Watch archetypes and templates directories for real-time updates
|
||||
watchedPaths.push(path.join(projectRoot, '.beads', 'archetypes'));
|
||||
watchedPaths.push(path.join(projectRoot, '.beads', 'templates'));
|
||||
|
||||
// Add global agent messages to enable cross-project communication real-time updates
|
||||
watchedPaths.push(getGlobalAgentMessagesPath());
|
||||
|
||||
console.log(`[Watcher] Watching paths:`, watchedPaths);
|
||||
|
||||
const watcher = chokidar.watch(watchedPaths, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
|
|
@ -121,6 +147,7 @@ export class IssuesWatchManager {
|
|||
});
|
||||
|
||||
const onFileEvent = (eventName: FileEventName, changedPath: string) => {
|
||||
console.log(`[Watcher] File event: ${eventName} on ${changedPath}`);
|
||||
const kind: IssuesChangeKind = eventName === 'unlink' ? 'renamed' : 'changed';
|
||||
this.queueCoalescedEvent(projectRoot, changedPath, kind);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue