fix: orchestrator button + Pi SDK session error

- Move leftSidebarMode from URL state to local useState in unified-shell,
    avoiding force-dynamic router round-trip that made the button appear broken                                           - Replace fileURLToPath(new URL(..., import.meta.url)) with process.cwd()
    in bb-pi-bootstrap.ts — import.meta.url is a webpack:// URL in Next.js,
    causing cross-realm TypeError when passed to Node.js fileURLToPath()
This commit is contained in:
zenchantlive 2026-03-24 15:39:19 -07:00
parent 643fa299dd
commit d335e5bf71
98 changed files with 17851 additions and 944 deletions

View file

@ -24,24 +24,24 @@ export type EventTone = {
idClass: string;
};
interface AgentRosterEntry {
name: string;
status: AgentStatus;
lastSeen: string | null;
beadId: string;
}
interface CoordMessage {
message_id: string;
bead_id: string;
from_agent: string;
to_agent: string;
category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO';
subject: string;
state: 'unread' | 'read' | 'acked';
created_at: string;
acked_at: string | null;
}
interface AgentRosterEntry {
name: string;
status: AgentStatus;
lastSeen: string | null;
beadId: string;
}
interface CoordMessage {
message_id: string;
bead_id: string;
from_agent: string;
to_agent: string;
category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO';
subject: string;
state: 'unread' | 'read' | 'acked';
created_at: string;
acked_at: string | null;
}
interface ActivityPanelProps {
issues: BeadIssue[];
@ -51,6 +51,21 @@ interface ActivityPanelProps {
const AGENT_LABEL = 'gt:agent';
function mergeUniqueActivities(existing: ActivityEvent[], incoming: ActivityEvent[]): ActivityEvent[] {
const seen = new Set<string>();
const merged: ActivityEvent[] = [];
for (const event of [...incoming, ...existing]) {
if (seen.has(event.id)) continue;
seen.add(event.id);
merged.push(event);
}
return merged
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 50);
}
// Determine agent status based on last activity
function deriveAgentStatus(lastSeenAt: string | null): AgentStatus {
if (!lastSeenAt) return 'dead';
@ -152,23 +167,23 @@ function getAgentTone(status: AgentStatus): AgentTone {
}
// reopened=blue, closed=amber, created/opened=green, others semantic
export function getEventTone(kind: string): EventTone {
const normalized = kind.toLowerCase();
const byKind: Record<string, EventTone> = {
coord_send: {
label: 'Coord Send',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[var(--status-in-progress)]',
idClass: 'text-[#DAB891]',
},
coord_ack: {
label: 'Coord Ack',
labelClass: 'text-[#7CB97A]',
dotClass: 'bg-[#7CB97A]',
cardClass: 'bg-[var(--status-ready)]',
idClass: 'text-[#9ACB98]',
},
export function getEventTone(kind: string): EventTone {
const normalized = kind.toLowerCase();
const byKind: Record<string, EventTone> = {
coord_send: {
label: 'Coord Send',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[var(--status-in-progress)]',
idClass: 'text-[#DAB891]',
},
coord_ack: {
label: 'Coord Ack',
labelClass: 'text-[#7CB97A]',
dotClass: 'bg-[#7CB97A]',
cardClass: 'bg-[var(--status-ready)]',
idClass: 'text-[#9ACB98]',
},
created: {
label: 'Created',
labelClass: 'text-[#7CB97A]',
@ -270,95 +285,95 @@ export function getInitials(name: string): string {
return name.split(/[-_\s]/).map(p => p[0]).join('').toUpperCase().slice(0, 2);
}
export function ActivityPanel({ issues, collapsed = false, projectRoot }: ActivityPanelProps) {
const [activities, setActivities] = useState<ActivityEvent[]>([]);
const [coordActivities, setCoordActivities] = useState<ActivityEvent[]>([]);
const [reservationByAgent, setReservationByAgent] = useState<Record<string, string | undefined>>({});
const [isLoading, setIsLoading] = useState(true);
export function ActivityPanel({ issues, collapsed = false, projectRoot }: ActivityPanelProps) {
const [activities, setActivities] = useState<ActivityEvent[]>([]);
const [coordActivities, setCoordActivities] = useState<ActivityEvent[]>([]);
const [reservationByAgent, setReservationByAgent] = useState<Record<string, string | undefined>>({});
const [isLoading, setIsLoading] = useState(true);
const agentRoster = useMemo(() => buildAgentRoster(issues), [issues]);
// Fetch activity history
useEffect(() => {
async function fetchActivity() {
// Fetch activity history
useEffect(() => {
async function fetchActivity() {
try {
const response = await fetch('/api/activity');
if (response.ok) {
const data = await response.json();
setActivities(data.slice(0, 50)); // Limit to 50 events
}
} catch (error) {
console.error('[ActivityPanel] Failed to fetch activity:', error);
} finally {
setIsLoading(false);
}
}
fetchActivity();
}, []);
useEffect(() => {
const fetchCoordination = async () => {
if (agentRoster.length === 0) {
setCoordActivities([]);
setReservationByAgent({});
return;
}
// Use batch endpoints to reduce API calls from 2N to 2
const agentNames = agentRoster.map(a => a.name).join(',');
const [mailResponse, reservationsResponse] = await Promise.all([
fetch(`/api/agents/mail/batch?agents=${encodeURIComponent(agentNames)}&limit=15`),
fetch(`/api/agents/reservations/batch?agents=${encodeURIComponent(agentNames)}`),
]);
const mailPayload = await mailResponse.json().catch(() => ({ ok: false, data: [] }));
const reservationsPayload = await reservationsResponse.json().catch(() => ({ ok: false, data: [] }));
// Collect all messages from all agents
const uniqueMessages = new Map<string, CoordMessage>();
if (mailPayload.ok && mailPayload.data) {
for (const entry of mailPayload.data) {
for (const message of (entry.messages ?? [])) {
uniqueMessages.set(message.message_id, message);
}
}
}
const mapped = [...uniqueMessages.values()]
.map((message) => ({
id: `coord-${message.message_id}`,
kind: (message.state === 'acked' ? 'coord_ack' : 'coord_send') as ActivityEvent['kind'],
beadId: message.bead_id,
beadTitle: `${message.category}: ${message.subject}`,
timestamp: message.state === 'acked' && message.acked_at ? message.acked_at : message.created_at,
actor: message.state === 'acked' ? message.to_agent : message.from_agent,
projectId: projectRoot,
projectName: 'beadboard',
payload: {},
}))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 25);
// Build reservation map
const reservationMap: Record<string, string | undefined> = {};
if (reservationsPayload.ok && reservationsPayload.data) {
for (const entry of reservationsPayload.data) {
reservationMap[entry.agent] = entry.scope;
}
}
setCoordActivities(mapped);
setReservationByAgent(reservationMap);
};
void fetchCoordination();
const timer = setInterval(() => {
void fetchCoordination();
}, 15000);
return () => clearInterval(timer);
}, [agentRoster, projectRoot]);
setActivities((prev) => mergeUniqueActivities(prev, data));
}
} catch (error) {
console.error('[ActivityPanel] Failed to fetch activity:', error);
} finally {
setIsLoading(false);
}
}
fetchActivity();
}, []);
useEffect(() => {
const fetchCoordination = async () => {
if (agentRoster.length === 0) {
setCoordActivities([]);
setReservationByAgent({});
return;
}
// Use batch endpoints to reduce API calls from 2N to 2
const agentNames = agentRoster.map(a => a.name).join(',');
const [mailResponse, reservationsResponse] = await Promise.all([
fetch(`/api/agents/mail/batch?agents=${encodeURIComponent(agentNames)}&limit=15`),
fetch(`/api/agents/reservations/batch?agents=${encodeURIComponent(agentNames)}`),
]);
const mailPayload = await mailResponse.json().catch(() => ({ ok: false, data: [] }));
const reservationsPayload = await reservationsResponse.json().catch(() => ({ ok: false, data: [] }));
// Collect all messages from all agents
const uniqueMessages = new Map<string, CoordMessage>();
if (mailPayload.ok && mailPayload.data) {
for (const entry of mailPayload.data) {
for (const message of (entry.messages ?? [])) {
uniqueMessages.set(message.message_id, message);
}
}
}
const mapped = [...uniqueMessages.values()]
.map((message) => ({
id: `coord-${message.message_id}`,
kind: (message.state === 'acked' ? 'coord_ack' : 'coord_send') as ActivityEvent['kind'],
beadId: message.bead_id,
beadTitle: `${message.category}: ${message.subject}`,
timestamp: message.state === 'acked' && message.acked_at ? message.acked_at : message.created_at,
actor: message.state === 'acked' ? message.to_agent : message.from_agent,
projectId: projectRoot,
projectName: 'beadboard',
payload: {},
}))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 25);
// Build reservation map
const reservationMap: Record<string, string | undefined> = {};
if (reservationsPayload.ok && reservationsPayload.data) {
for (const entry of reservationsPayload.data) {
reservationMap[entry.agent] = entry.scope;
}
}
setCoordActivities(mapped);
setReservationByAgent(reservationMap);
};
void fetchCoordination();
const timer = setInterval(() => {
void fetchCoordination();
}, 15000);
return () => clearInterval(timer);
}, [agentRoster, projectRoot]);
// Subscribe to real-time activity
useEffect(() => {
@ -371,7 +386,7 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
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));
setActivities(prev => mergeUniqueActivities(prev, [data]));
}
} catch (e) {
// Ignore parse errors
@ -387,13 +402,11 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
};
}, [projectRoot]);
const activeAgents = agentRoster.filter(a => a.status === 'active').length;
const mergedActivities = useMemo(
() => [...coordActivities, ...activities]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 50),
[activities, coordActivities],
);
const activeAgents = agentRoster.filter(a => a.status === 'active').length;
const mergedActivities = useMemo(
() => mergeUniqueActivities(coordActivities, activities),
[activities, coordActivities],
);
if (collapsed) {
return (
<div className="flex flex-col items-center gap-6 py-6 h-full bg-[var(--surface-secondary)]">
@ -425,7 +438,7 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
{/* Activity Pulses */}
<div className="flex flex-col gap-2 opacity-40">
{mergedActivities.slice(0, 8).map((act) => (
{mergedActivities.slice(0, 8).map((act) => (
<div key={act.id} className={cn(
"w-1 h-1 rounded-full",
getEventTone(act.kind).dotClass
@ -473,20 +486,20 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
<div className="flex flex-col flex-1 min-w-0">
<span className="text-xs font-semibold text-text-primary group-hover:text-white transition-colors">{agent.name}</span>
<div className="flex items-center gap-1.5">
<span className={cn(
"text-[9px] uppercase tracking-wider font-bold",
getAgentTone(agent.status).labelClass
)}>
{agent.status}
</span>
{reservationByAgent[agent.name] ? (
<span className="max-w-[140px] truncate rounded border border-cyan-500/30 bg-cyan-500/10 px-1 py-0.5 text-[9px] text-cyan-200" title={reservationByAgent[agent.name]}>
{reservationByAgent[agent.name]}
</span>
) : null}
<span className="text-[9px] text-text-muted/40 font-mono">
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'N/A'}
</span>
<span className={cn(
"text-[9px] uppercase tracking-wider font-bold",
getAgentTone(agent.status).labelClass
)}>
{agent.status}
</span>
{reservationByAgent[agent.name] ? (
<span className="max-w-[140px] truncate rounded border border-cyan-500/30 bg-cyan-500/10 px-1 py-0.5 text-[9px] text-cyan-200" title={reservationByAgent[agent.name]}>
{reservationByAgent[agent.name]}
</span>
) : null}
<span className="text-[9px] text-text-muted/40 font-mono">
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'N/A'}
</span>
</div>
</div>
</div>
@ -508,13 +521,13 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
<div className="w-4 h-4 border-2 border-teal-500 border-t-transparent rounded-full animate-spin" />
<span className="text-[10px] font-mono text-text-muted">SYNCING...</span>
</div>
) : mergedActivities.length === 0 ? (
) : mergedActivities.length === 0 ? (
<div className="p-10 text-center opacity-30">
<p className="text-[10px] font-mono">VOID_STREAM_NULL</p>
</div>
) : (
<div className="p-3 space-y-3">
{mergedActivities.map((activity) => {
{mergedActivities.map((activity) => {
const eventTone = getEventTone(activity.kind);
return (
<div key={activity.id} className="group relative">

View file

@ -7,6 +7,7 @@ import { ActivityPanel } from './activity-panel';
import { SwarmCommandFeed } from './swarm-command-feed';
import { ThreadDrawer } from '../shared/thread-drawer';
import { MissionInspector } from '../mission/mission-inspector';
import { AgentStatusPanel } from '../agents/agent-status-panel';
import { useSwarmList } from '../../hooks/use-swarm-list';
import { useUrlState } from '../../hooks/use-url-state';
@ -58,6 +59,10 @@ export function ContextualRightPanel({ epicId, taskId, swarmId, issues, projectR
</button>
</div>
)}
{/* Agent Status for active epic */}
<div className="shrink-0 border-b border-[var(--border-subtle)] p-3">
<AgentStatusPanel projectRoot={projectRoot} />
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<SwarmCommandFeed
epicId={epicId}
@ -116,13 +121,21 @@ function SwarmIdBranch({ swarmId, projectRoot }: { swarmId: string; projectRoot:
const assignedAgents = swarm?.agents ?? [];
return (
<MissionInspector
missionId={swarmId}
missionTitle={missionTitle}
projectRoot={projectRoot}
assignedAgents={assignedAgents}
onClose={() => setSwarmId(null)}
onAssign={async () => {}}
/>
<div className="flex h-full flex-col overflow-hidden">
{/* Agent Status for active swarm */}
<div className="shrink-0 border-b border-[var(--border-subtle)] p-3">
<AgentStatusPanel projectRoot={projectRoot} />
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<MissionInspector
missionId={swarmId}
missionTitle={missionTitle}
projectRoot={projectRoot}
assignedAgents={assignedAgents}
onClose={() => setSwarmId(null)}
onAssign={async () => {}}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,83 @@
// src/components/agents/agent-action-row.tsx
'use client';
import { AgentAssignButton } from './agent-assign-button';
import { AgentSpawnButton } from './agent-spawn-button';
import { useAgentStatus, useSpawnAgent } from './hooks';
import type { AgentArchetype } from '../../lib/types-swarm';
export interface AgentActionRowProps {
beadId: string;
beadStatus: string;
agents: AgentArchetype[];
projectRoot: string;
currentAgentTypeId?: string;
onAgentAssigned?: (agentTypeId: string) => void;
onAgentSpawned?: (workerId: string, displayName: string) => void;
size?: 'sm' | 'md';
}
export function AgentActionRow({
beadId,
beadStatus,
agents,
projectRoot,
currentAgentTypeId,
onAgentAssigned,
onAgentSpawned,
size = 'sm',
}: AgentActionRowProps) {
const { workerStatus, workerDisplayName, workerError } = useAgentStatus(beadId);
const { spawn, isSpawning } = useSpawnAgent(projectRoot);
const handleAssign = async (agentTypeId: string) => {
// Call API to assign agent type to bead
try {
const response = await fetch('/api/beads/assign-agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ beadId, agentTypeId }),
});
if (response.ok && onAgentAssigned) {
onAgentAssigned(agentTypeId);
}
} catch (error) {
console.error('Failed to assign agent:', error);
}
};
const handleSpawn = async () => {
if (!currentAgentTypeId) return;
const result = await spawn(beadId, currentAgentTypeId);
if (result.success && onAgentSpawned) {
onAgentSpawned(result.workerId!, result.displayName!);
}
};
// Don't show for closed beads
if (beadStatus === 'closed') {
return null;
}
return (
<div className="flex items-center gap-1.5">
<AgentAssignButton
beadId={beadId}
agents={agents}
currentAgentTypeId={currentAgentTypeId}
onAssign={handleAssign}
size={size}
/>
<AgentSpawnButton
beadId={beadId}
agentTypeId={currentAgentTypeId}
workerStatus={isSpawning ? 'spawning' : workerStatus}
workerDisplayName={workerDisplayName}
workerError={workerError}
onSpawn={handleSpawn}
size={size}
/>
</div>
);
}

View file

@ -0,0 +1,79 @@
// src/components/agents/agent-assign-button.tsx
'use client';
import { useState } from 'react';
import { UserPlus } from 'lucide-react';
import { AgentPickerPopup } from './agent-picker-popup';
import type { AgentArchetype } from '../../lib/types-swarm';
export interface AgentAssignButtonProps {
beadId: string;
agents: AgentArchetype[];
currentAgentTypeId?: string;
onAssign: (agentTypeId: string) => void;
size?: 'sm' | 'md';
disabled?: boolean;
}
export function AgentAssignButton({
beadId,
agents,
currentAgentTypeId,
onAssign,
size = 'sm',
disabled = false,
}: AgentAssignButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const sizeClasses = size === 'sm'
? 'h-6 w-6'
: 'h-7 w-7';
const iconSize = size === 'sm'
? 'w-3 h-3'
: 'w-3.5 h-3.5';
const isAssigned = !!currentAgentTypeId;
const assignedAgent = agents.find(a => a.id === currentAgentTypeId);
const bgColor = isAssigned && assignedAgent
? `${assignedAgent.color}30`
: 'var(--surface-tertiary)';
const iconColor = isAssigned && assignedAgent
? assignedAgent.color
: 'var(--text-tertiary)';
return (
<div className="relative">
<button
type="button"
onClick={() => !disabled && setIsOpen(true)}
disabled={disabled}
className={`inline-flex ${sizeClasses} items-center justify-center rounded-md border transition-colors ${
disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:opacity-80'
}`}
style={{
backgroundColor: bgColor,
borderColor: isAssigned && assignedAgent
? `${assignedAgent.color}50`
: 'var(--border-subtle)',
}}
title={isAssigned ? `Assigned: ${assignedAgent?.name}` : 'Assign agent'}
>
<UserPlus className={iconSize} style={{ color: iconColor }} />
</button>
<AgentPickerPopup
isOpen={isOpen}
onClose={() => setIsOpen(false)}
agents={agents}
selectedAgentId={currentAgentTypeId}
onSelect={(agentId) => {
onAssign(agentId);
setIsOpen(false);
}}
/>
</div>
);
}

View file

@ -0,0 +1,120 @@
// src/components/agents/agent-picker-popup.tsx
'use client';
import { useEffect, useRef } from 'react';
import { Rocket, Brain, Wrench, Search, CheckCircle, FlaskConical, Upload } from 'lucide-react';
import type { AgentArchetype } from '../../lib/types-swarm';
export interface AgentPickerPopupProps {
isOpen: boolean;
onClose: () => void;
agents: AgentArchetype[];
selectedAgentId?: string;
onSelect: (agentId: string) => void;
onSpawn?: (agentId: string) => void;
position?: { x: number; y: number };
}
const AGENT_ICONS: Record<string, React.ReactNode> = {
architect: <Brain className="w-4 h-4" />,
engineer: <Wrench className="w-4 h-4" />,
investigator: <Search className="w-4 h-4" />,
reviewer: <CheckCircle className="w-4 h-4" />,
tester: <FlaskConical className="w-4 h-4" />,
shipper: <Upload className="w-4 h-4" />,
};
export function AgentPickerPopup({
isOpen,
onClose,
agents,
selectedAgentId,
onSelect,
onSpawn,
position,
}: AgentPickerPopupProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, onClose]);
if (!isOpen) return null;
const style = position
? { position: 'absolute' as const, left: position.x, top: position.y + 8 }
: {};
return (
<div
ref={ref}
style={style}
className="z-50 min-w-[180px] rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-elevated)] p-1 shadow-lg"
>
{/* Orchestrator option */}
<button
onClick={() => {
onSelect('orchestrator');
onClose();
}}
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
selectedAgentId === 'orchestrator'
? 'bg-[var(--accent-info)]/20 text-[var(--accent-info)]'
: 'text-[var(--text-primary)] hover:bg-[var(--surface-hover)]'
}`}
>
<Rocket className="w-4 h-4" />
<span className="font-medium">Orchestrator</span>
<span className="ml-auto text-xs text-[var(--text-tertiary)]">auto</span>
</button>
<div className="my-1 border-t border-[var(--border-subtle)]" />
{/* Agent types */}
{agents.map((agent) => (
<button
key={agent.id}
onClick={() => {
onSelect(agent.id);
onClose();
}}
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
selectedAgentId === agent.id
? 'bg-[var(--accent-info)]/20 text-[var(--accent-info)]'
: 'text-[var(--text-primary)] hover:bg-[var(--surface-hover)]'
}`}
>
<span style={{ color: agent.color }}>
{AGENT_ICONS[agent.id] || <Wrench className="w-4 h-4" />}
</span>
<span>{agent.name}</span>
</button>
))}
{/* Spawn button */}
{onSpawn && selectedAgentId && (
<>
<div className="my-1 border-t border-[var(--border-subtle)]" />
<button
onClick={() => {
onSpawn(selectedAgentId);
onClose();
}}
className="flex w-full items-center justify-center gap-2 rounded-md bg-emerald-500/20 px-3 py-2 text-sm font-medium text-emerald-400 transition-colors hover:bg-emerald-500/30"
>
<Rocket className="w-4 h-4" />
Spawn {agents.find(a => a.id === selectedAgentId)?.name || 'Agent'}
</button>
</>
)}
</div>
);
}

View file

@ -0,0 +1,132 @@
// src/components/agents/agent-spawn-button.tsx
'use client';
import { Rocket, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import type { WorkerStatus } from './hooks/use-agent-status';
export interface AgentSpawnButtonProps {
beadId: string;
agentTypeId?: string;
workerStatus: WorkerStatus;
workerDisplayName?: string;
workerError?: string;
onSpawn: () => void;
size?: 'sm' | 'md';
disabled?: boolean;
}
const STATUS_CONFIG: Record<WorkerStatus, {
icon: React.ReactNode;
color: string;
bgColor: string;
borderColor: string;
title: string;
pulsing?: boolean;
}> = {
idle: {
icon: <Rocket className="w-3 h-3" />,
color: '#6b7280',
bgColor: 'rgba(107, 114, 128, 0.1)',
borderColor: 'rgba(107, 114, 128, 0.3)',
title: 'No agent assigned',
},
spawning: {
icon: <Loader2 className="w-3 h-3 animate-spin" />,
color: '#3b82f6',
bgColor: 'rgba(59, 130, 246, 0.1)',
borderColor: 'rgba(59, 130, 246, 0.3)',
title: 'Spawning...',
pulsing: true,
},
working: {
icon: <Rocket className="w-3 h-3" />,
color: '#22c55e',
bgColor: 'rgba(34, 197, 94, 0.1)',
borderColor: 'rgba(34, 197, 94, 0.3)',
title: 'Working',
pulsing: true,
},
blocked: {
icon: <AlertCircle className="w-3 h-3" />,
color: '#ef4444',
bgColor: 'rgba(239, 68, 68, 0.1)',
borderColor: 'rgba(239, 68, 68, 0.3)',
title: 'Blocked',
},
completed: {
icon: <CheckCircle className="w-3 h-3" />,
color: '#22c55e',
bgColor: 'rgba(34, 197, 94, 0.1)',
borderColor: 'rgba(34, 197, 94, 0.3)',
title: 'Completed',
},
failed: {
icon: <AlertCircle className="w-3 h-3" />,
color: '#ef4444',
bgColor: 'rgba(239, 68, 68, 0.1)',
borderColor: 'rgba(239, 68, 68, 0.3)',
title: 'Failed',
},
};
export function AgentSpawnButton({
beadId,
agentTypeId,
workerStatus,
workerDisplayName,
workerError,
onSpawn,
size = 'sm',
disabled = false,
}: AgentSpawnButtonProps) {
const config = STATUS_CONFIG[workerStatus];
const sizeClasses = size === 'sm' ? 'h-6 w-6' : 'h-7 w-7';
// No agent assigned - don't show button
if (!agentTypeId && workerStatus === 'idle') {
return null;
}
const canSpawn = workerStatus === 'idle' && agentTypeId;
const showTooltip = workerStatus === 'working' || workerStatus === 'blocked' || workerStatus === 'completed';
return (
<div className="relative group">
<button
type="button"
onClick={() => canSpawn && !disabled && onSpawn()}
disabled={disabled || !canSpawn}
className={`inline-flex ${sizeClasses} items-center justify-center rounded-md border transition-colors ${
disabled || !canSpawn ? 'cursor-default' : 'hover:opacity-80'
} ${config.pulsing ? 'animate-pulse' : ''}`}
style={{
backgroundColor: config.bgColor,
borderColor: config.borderColor,
color: config.color,
}}
title={workerDisplayName ? `${config.title}: ${workerDisplayName}` : config.title}
>
{config.icon}
</button>
{/* Tooltip for active workers */}
{showTooltip && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-50">
<div className="rounded-md bg-[var(--surface-elevated)] border border-[var(--border-subtle)] px-3 py-2 shadow-lg min-w-[160px]">
<p className="text-xs font-medium text-[var(--text-primary)]">
{workerDisplayName || 'Agent'}
</p>
<p className="text-[10px] text-[var(--text-tertiary)] capitalize">
{workerStatus}
</p>
{workerError && (
<p className="text-[10px] text-red-400 mt-1 truncate">
{workerError}
</p>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,158 @@
'use client';
import { useEffect, useState } from 'react';
import type { AgentInstance, AgentStatus } from '../../lib/agent-instance';
import { Activity, CheckCircle, XCircle, Loader2, Users } from 'lucide-react';
interface AgentStatusPanelProps {
projectRoot: string;
}
export function AgentStatusPanel({ projectRoot }: AgentStatusPanelProps) {
const [status, setStatus] = useState<AgentStatus | null>(null);
const [history, setHistory] = useState<AgentInstance[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Poll for agent status updates
const poll = async () => {
try {
const [statusRes, historyRes] = await Promise.all([
fetch(`/api/runtime/agents?projectRoot=${encodeURIComponent(projectRoot)}`),
fetch(`/api/runtime/agents/history?projectRoot=${encodeURIComponent(projectRoot)}`),
]);
const statusData = await statusRes.json();
const historyData = await historyRes.json();
if (statusData.ok) setStatus(statusData.status);
if (historyData.ok) setHistory(historyData.instances);
} catch (error) {
console.error('Failed to fetch agent status:', error);
} finally {
setLoading(false);
}
};
poll();
const interval = setInterval(poll, 2000);
return () => clearInterval(interval);
}, [projectRoot]);
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-[var(--text-tertiary)]" />
</div>
);
}
return (
<div className="space-y-4">
{/* Active Agents Section */}
<div>
<h3 className="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider mb-2 flex items-center gap-2">
<Activity className="w-3.5 h-3.5" />
Active Agents ({status?.totalActive || 0})
</h3>
{!status || status.instances.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)] italic py-2">
No active agents. Spawn an agent to work on a task.
</p>
) : (
<div className="space-y-2">
{status.instances.map(instance => (
<AgentInstanceCard key={instance.id} instance={instance} />
))}
</div>
)}
</div>
{/* Recent Completions Section */}
{history.length > 0 && (
<div>
<h3 className="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider mb-2 flex items-center gap-2">
<Users className="w-3.5 h-3.5" />
Recent Completions
</h3>
<div className="space-y-1 max-h-40 overflow-auto">
{history.slice(0, 10).map(instance => (
<AgentHistoryItem key={instance.id} instance={instance} />
))}
</div>
</div>
)}
{/* Summary by Type */}
{status && Object.keys(status.byType).length > 0 && (
<div className="pt-3 border-t border-[var(--border-subtle)]">
<div className="text-[10px] font-mono text-[var(--text-tertiary)] uppercase tracking-wider mb-2">
By Type
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(status.byType).map(([type, count]) => (
<div
key={type}
className="px-2 py-1 rounded bg-[var(--surface-quaternary)] text-xs"
>
<span className="font-medium capitalize">{type}</span>
<span className="text-[var(--text-tertiary)] ml-1">{count}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
function AgentInstanceCard({ instance }: { instance: AgentInstance }) {
const statusConfig = {
spawning: { color: 'bg-yellow-500', icon: Loader2, animate: true },
working: { color: 'bg-cyan-500', icon: Activity, animate: false },
idle: { color: 'bg-gray-500', icon: Activity, animate: false },
completed: { color: 'bg-green-500', icon: CheckCircle, animate: false },
failed: { color: 'bg-red-500', icon: XCircle, animate: false },
};
const config = statusConfig[instance.status];
const Icon = config.icon;
return (
<div className="flex items-center gap-3 p-2 rounded bg-[var(--surface-quaternary)] border border-[var(--border-subtle)]">
<div className={`w-2 h-2 rounded-full ${config.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{instance.displayName}</span>
<span className="text-[10px] text-[var(--text-tertiary)] uppercase">
{instance.status}
</span>
</div>
{instance.currentBeadId && (
<div className="text-xs text-[var(--text-tertiary)] truncate">
{instance.currentBeadId}
</div>
)}
</div>
<Icon className={`w-4 h-4 text-[var(--text-tertiary)] ${config.animate ? 'animate-spin' : ''}`} />
</div>
);
}
function AgentHistoryItem({ instance }: { instance: AgentInstance }) {
const isSuccess = instance.status === 'completed';
return (
<div className="flex items-center gap-2 text-xs p-1.5 rounded bg-[var(--surface-tertiary)]">
{isSuccess ? (
<CheckCircle className="w-3 h-3 text-green-400" />
) : (
<XCircle className="w-3 h-3 text-red-400" />
)}
<span className="font-medium">{instance.displayName}</span>
{instance.currentBeadId && (
<span className="text-[var(--text-tertiary)]"> {instance.currentBeadId}</span>
)}
</div>
);
}

View file

@ -0,0 +1,3 @@
// src/components/agents/hooks/index.ts
export { useAgentStatus, type AgentStatus, type WorkerStatus } from './use-agent-status';
export { useSpawnAgent, type SpawnResult } from './use-spawn-agent';

View file

@ -0,0 +1,68 @@
// src/components/agents/hooks/use-agent-status.ts
import { useState, useEffect, useRef } from 'react';
export type WorkerStatus = 'idle' | 'spawning' | 'working' | 'blocked' | 'completed' | 'failed';
export interface AgentStatus {
agentTypeId?: string;
workerStatus: WorkerStatus;
workerDisplayName?: string;
workerError?: string;
isLoading: boolean;
}
const POLL_INTERVAL_MS = 5000;
export function useAgentStatus(beadId: string): AgentStatus {
const [status, setStatus] = useState<AgentStatus>({
workerStatus: 'idle',
isLoading: true,
});
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const fetchStatus = async () => {
if (!beadId) return;
try {
const response = await fetch(`/api/runtime/worker-status?beadId=${encodeURIComponent(beadId)}`);
if (!response.ok) {
// If API returns 404 or error, no worker exists yet
setStatus({ workerStatus: 'idle', isLoading: false });
return;
}
const data = await response.json();
setStatus({
workerStatus: data.status || 'idle',
workerDisplayName: data.displayName,
workerError: data.error,
agentTypeId: data.agentTypeId,
isLoading: false,
});
} catch (error) {
console.error('Failed to fetch worker status:', error);
setStatus({ workerStatus: 'idle', isLoading: false });
}
};
useEffect(() => {
if (!beadId) {
setStatus({ workerStatus: 'idle', isLoading: false });
return;
}
// Initial fetch
fetchStatus();
// Set up polling
intervalRef.current = setInterval(fetchStatus, POLL_INTERVAL_MS);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [beadId]);
return status;
}

View file

@ -0,0 +1,41 @@
// src/components/agents/hooks/use-spawn-agent.ts
import { useState } from 'react';
export interface SpawnResult {
success: boolean;
workerId?: string;
displayName?: string;
error?: string;
}
export function useSpawnAgent(projectRoot: string) {
const [isSpawning, setIsSpawning] = useState(false);
const spawn = async (beadId: string, agentTypeId: string): Promise<SpawnResult> => {
setIsSpawning(true);
try {
const response = await fetch('/api/runtime/spawn', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectRoot, beadId, agentTypeId }),
});
const data = await response.json();
if (!data.ok) {
return { success: false, error: data.error };
}
return {
success: true,
workerId: data.workerId,
displayName: data.displayName,
};
} catch (error) {
return { success: false, error: String(error) };
} finally {
setIsSpawning(false);
}
};
return { spawn, isSpawning };
}

View file

@ -0,0 +1,6 @@
// src/components/agents/index.ts
export { AgentActionRow, type AgentActionRowProps } from './agent-action-row';
export { AgentAssignButton, type AgentAssignButtonProps } from './agent-assign-button';
export { AgentSpawnButton, type AgentSpawnButtonProps } from './agent-spawn-button';
export { AgentPickerPopup, type AgentPickerPopupProps } from './agent-picker-popup';
export * from './hooks';

View file

@ -2,9 +2,9 @@
import React, { useState, useMemo } from 'react';
import { Zap, Users, FileCode2, Loader2, UserPlus, Clock, AlertCircle, ChevronDown, ChevronRight, Blocks, Layers } from 'lucide-react';
import { ArchetypeInspector } from '../swarm/archetype-inspector';
import { AgentInspector } from '../swarm/agent-inspector';
import { TemplateInspector } from '../swarm/template-inspector';
import { ArchetypePicker } from '../swarm/archetype-picker';
import { AgentPicker } from '../swarm/agent-picker';
import { TemplatePicker } from '../swarm/template-picker';
import { useArchetypes } from '../../hooks/use-archetypes';
import { useTemplates } from '../../hooks/use-templates';
@ -219,8 +219,8 @@ export function AssignmentPanel({ selectedIssue, projectRoot, issues, epicId, on
});
};
const getArchetypeCountInTeam = (template: SwarmTemplate, archetypeId: string): number => {
return template.team.filter(member => member.archetypeId === archetypeId).length;
const getArchetypeCountInTeam = (template: SwarmTemplate, agentTypeId: string): number => {
return template.team.filter(member => member.agentTypeId === agentTypeId).length;
};
const renderTaskItem = (issue: BeadIssue, showAssignButton: boolean = false, archetypeBadges: AgentArchetype[] = []) => (
@ -290,7 +290,7 @@ export function AssignmentPanel({ selectedIssue, projectRoot, issues, epicId, on
</button>
</div>
<ArchetypePicker
<AgentPicker
archetypes={archetypes}
isOpen={showArchetypeList}
onClose={() => setShowArchetypeList(false)}
@ -361,12 +361,12 @@ export function AssignmentPanel({ selectedIssue, projectRoot, issues, epicId, on
<div>
<div className="text-[10px] font-mono text-[var(--text-tertiary)] uppercase tracking-wider mb-2">Team Roster</div>
<div className="space-y-1">
{Array.from(new Set(epicTemplate.team.map(m => m.archetypeId))).map(archetypeId => {
const archetype = archetypes.find((a: AgentArchetype) => a.id === archetypeId);
const count = getArchetypeCountInTeam(epicTemplate, archetypeId);
{Array.from(new Set(epicTemplate.team.map(m => m.agentTypeId))).map(agentTypeId => {
const archetype = archetypes.find((a: AgentArchetype) => a.id === agentTypeId);
const count = getArchetypeCountInTeam(epicTemplate, agentTypeId);
if (!archetype) return null;
return (
<div key={archetypeId} className="flex items-center gap-2 text-xs">
<div key={agentTypeId} className="flex items-center gap-2 text-xs">
<div
className="h-4 w-4 rounded flex items-center justify-center text-[10px] font-bold"
style={{
@ -557,7 +557,7 @@ export function AssignmentPanel({ selectedIssue, projectRoot, issues, epicId, on
</div>
{inspectingArchetypeId !== null && (
<ArchetypeInspector
<AgentInspector
archetype={archetypes.find((a: AgentArchetype) => a.id === inspectingArchetypeId)}
onClose={() => setInspectingArchetypeId(null)}
onSave={saveArchetype}

View file

@ -0,0 +1,113 @@
'use client';
import { useState } from 'react';
import { CheckCircle, ArrowRight, Play, MessageSquare, GitBranch, Zap } from 'lucide-react';
interface OnboardingWizardProps {
hasProjects: boolean;
piInstalled: boolean;
hasAuth: boolean;
}
export function OnboardingWizard({ hasProjects }: OnboardingWizardProps) {
const [isLoading, setIsLoading] = useState(false);
const handleSkipToApp = () => {
window.location.href = '/?onboarded=true';
};
return (
<div className="min-h-screen bg-[var(--surface-primary)] flex items-center justify-center p-4">
<div className="max-w-2xl w-full">
<div className="bg-[var(--surface-elevated)] rounded-xl border border-[var(--border-subtle)] p-8">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-[var(--text-primary)] mb-2">
Welcome to BeadBoard
</h1>
<p className="text-[var(--text-secondary)]">
Multi-agent swarm coordination for dependency-constrained work
</p>
</div>
{/* Features */}
<div className="grid gap-4 mb-8">
<div className="flex items-start gap-3 p-4 bg-[var(--surface-quaternary)] rounded-lg">
<div className="p-2 rounded-lg bg-cyan-500/10">
<MessageSquare size={20} className="text-cyan-400" />
</div>
<div>
<h3 className="font-semibold text-[var(--text-primary)]">Orchestrator Chat</h3>
<p className="text-sm text-[var(--text-secondary)]">
Left panel has a built-in AI orchestrator. Just send a message to get started.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-[var(--surface-quaternary)] rounded-lg">
<div className="p-2 rounded-lg bg-cyan-500/10">
<Zap size={20} className="text-cyan-400" />
</div>
<div>
<h3 className="font-semibold text-[var(--text-primary)]">Spawn Workers</h3>
<p className="text-sm text-[var(--text-secondary)]">
Tell the orchestrator to spawn workers for parallel task execution.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-[var(--surface-quaternary)] rounded-lg">
<div className="p-2 rounded-lg bg-cyan-500/10">
<GitBranch size={20} className="text-cyan-400" />
</div>
<div>
<h3 className="font-semibold text-[var(--text-primary)]">Task Graphs</h3>
<p className="text-sm text-[var(--text-secondary)]">
Visualize dependencies and coordinate work across your project.
</p>
</div>
</div>
</div>
{/* Quick Start */}
<div className="bg-emerald-500/10 border border-emerald-500/30 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-emerald-400 mb-2 flex items-center gap-2">
<CheckCircle size={16} /> Everything is ready
</h3>
<p className="text-sm text-[var(--text-secondary)]">
The agent runtime will install automatically when you send your first message.
No manual setup required!
</p>
</div>
{/* Project hint if needed */}
{!hasProjects && (
<div className="bg-[var(--surface-quaternary)] rounded-lg p-4 mb-6">
<p className="text-sm text-[var(--text-secondary)] mb-2">
<strong>Tip:</strong> Add a project to coordinate work:
</p>
<code className="block bg-black/30 rounded p-2 text-cyan-300 font-mono text-sm">
bb project add /path/to/your/project
</code>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-center gap-3">
<button
onClick={handleSkipToApp}
disabled={isLoading}
className="inline-flex items-center gap-2 px-6 py-3 bg-cyan-500 hover:bg-cyan-400 text-black font-semibold rounded-lg transition-colors disabled:opacity-50"
>
<Play size={16} /> Start Using BeadBoard
</button>
</div>
<p className="text-center text-[var(--text-tertiary)] text-xs mt-4">
You can also run <code className="bg-black/30 px-1 rounded">bb --help</code> in terminal for CLI commands
</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,404 @@
'use client';
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Folder, FolderOpen, Pencil, Rocket, Star } from 'lucide-react';
import type { RuntimeInstance } from '../../lib/embedded-runtime';
import type { BeadIssue } from '../../lib/types';
import { cn } from '../../lib/utils';
import { useUrlState, type LeftPanelFilters, type LeftPanelStatusFilter, type LeftPanelPriorityFilter, type LeftPanelPresetFilter, type LeftSidebarMode, type ViewType } from '../../hooks/use-url-state';
export type { LeftPanelFilters } from '../../hooks/use-url-state';
import { OrchestratorPanel } from './orchestrator-panel';
interface EpicEntry {
epic: BeadIssue;
children: BeadIssue[];
blockedCount: number;
activeCount: number;
readyCount: number;
deferredCount: number;
doneCount: number;
agentBlockedCount: number;
latestTimestamp: string;
}
export interface LeftPanelProps {
issues: BeadIssue[];
selectedEpicId?: string | null;
onEpicSelect?: (epicId: string | null) => void;
onEpicEdit?: (epicId: string) => void;
filters: LeftPanelFilters;
onFiltersChange: (filters: LeftPanelFilters) => void;
onAssignMode?: (epicId: string) => void;
sidebarMode?: LeftSidebarMode;
onSidebarModeChange?: (mode: LeftSidebarMode) => void;
orchestrator?: RuntimeInstance;
orchestratorThread?: import('../../lib/orchestrator-chat').OrchestratorChatMessage[];
projectRoot?: string;
}
function mapStatus(task: BeadIssue): LeftPanelStatusFilter {
if (task.status === 'open') return 'ready';
if (task.status === 'in_progress') return 'in_progress';
if (task.status === 'blocked') return 'blocked';
if (task.status === 'deferred') return 'deferred';
if (task.status === 'closed' || task.status === 'tombstone') return 'done';
return 'all';
}
const views = [
{ id: 'social', label: 'Social' },
{ id: 'graph', label: 'Graph' },
] as const;
function isTaskMatch(task: BeadIssue, filters: LeftPanelFilters): boolean {
if (filters.query.trim()) {
const query = filters.query.toLowerCase();
if (!task.title.toLowerCase().includes(query) && !task.id.toLowerCase().includes(query)) {
return false;
}
}
if (filters.status !== 'all') {
if (mapStatus(task) !== filters.status) return false;
}
if (filters.priority !== 'all') {
const priorityMap: Record<number, string> = { 0: 'P0', 1: 'P1', 2: 'P2', 3: 'P3', 4: 'P4' };
if (priorityMap[task.priority] !== filters.priority) return false;
}
if (filters.preset === 'active') {
if (task.status !== 'open' && task.status !== 'in_progress') return false;
}
if (filters.preset === 'blocked_agents') {
if (!task.labels.includes('gt:agent') && !task.labels.includes('agent:blocked')) return false;
}
if (filters.hideClosed) {
if (task.status === 'closed' || task.status === 'tombstone') return false;
}
return true;
}
function rowTone(entry: EpicEntry): string {
const { epic } = entry;
if (epic.status === 'closed') return 'bg-[var(--surface-tertiary)]';
return 'bg-[var(--surface-quaternary)]';
}
function shouldHideEpicEntry(params: {
epicStatus: BeadIssue['status'];
matchedChildrenCount: number;
totalChildrenCount: number;
isSelected: boolean;
filters: LeftPanelFilters;
}): boolean {
const { epicStatus, matchedChildrenCount, totalChildrenCount, isSelected, filters } = params;
const hasTaskFilters =
filters.query.trim().length > 0 ||
filters.status !== 'all' ||
filters.priority !== 'all' ||
filters.preset !== 'all';
const epicClosed = epicStatus === 'closed' || epicStatus === 'tombstone';
const noVisibleChildren = matchedChildrenCount === 0 && totalChildrenCount > 0;
const hiddenByTaskFilters = hasTaskFilters && noVisibleChildren;
const hiddenByHideClosed = filters.hideClosed && noVisibleChildren;
const hiddenByEpicClosed = filters.hideClosed && epicClosed;
if (hiddenByEpicClosed) {
return true;
}
return !isSelected && (hiddenByTaskFilters || hiddenByHideClosed);
}
export function LeftPanel({
issues,
selectedEpicId,
filters,
onFiltersChange,
sidebarMode = 'epics',
onSidebarModeChange,
orchestrator,
orchestratorThread,
projectRoot,
onEpicSelect,
}: LeftPanelProps) {
const urlState = useUrlState();
const { view, setView } = urlState;
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const entries = useMemo(() => {
const epicMap = new Map<string, EpicEntry>();
const childrenMap = new Map<string, BeadIssue[]>();
for (const issue of issues) {
if (issue.labels.includes('gt:agent')) continue;
const parentEdge = issue.dependencies.find((dep) => dep.type === 'parent');
if (parentEdge) {
const children = childrenMap.get(parentEdge.target) ?? [];
children.push(issue);
childrenMap.set(parentEdge.target, children);
} else if (issue.issue_type === 'epic') {
epicMap.set(issue.id, {
epic: issue,
children: [],
blockedCount: 0,
activeCount: 0,
readyCount: 0,
deferredCount: 0,
doneCount: 0,
agentBlockedCount: 0,
latestTimestamp: issue.updated_at ?? issue.created_at ?? '',
});
}
}
for (const entry of epicMap.values()) {
entry.children = childrenMap.get(entry.epic.id) ?? [];
entry.blockedCount = entry.children.filter((t) => t.status === 'blocked').length;
entry.activeCount = entry.children.filter((t) => t.status === 'in_progress').length;
entry.readyCount = entry.children.filter((t) => t.status === 'open').length;
entry.deferredCount = entry.children.filter((t) => t.status === 'deferred').length;
entry.doneCount = entry.children.filter((t) => t.status === 'closed' || t.status === 'tombstone').length;
entry.agentBlockedCount = entry.children.filter((t) => t.labels.includes('agent:blocked')).length;
}
return Array.from(epicMap.values())
.filter((entry) => !shouldHideEpicEntry({
epicStatus: entry.epic.status,
matchedChildrenCount: entry.children.length,
totalChildrenCount: entry.children.length,
isSelected: selectedEpicId === entry.epic.id,
filters,
}))
.sort((a, b) => b.latestTimestamp.localeCompare(a.latestTimestamp));
}, [issues, selectedEpicId, filters]);
const handleEpicClick = (epicId: string) => {
setExpanded((prev) => ({ ...prev, [epicId]: !prev[epicId] }));
onEpicSelect?.(epicId);
};
return (
<aside className="flex h-full min-h-0 overflow-hidden flex-col bg-[var(--surface-primary)] border-r border-[var(--border-strong)]" data-testid="left-panel">
{/* ORCHESTRATOR MODE: Only show mode switcher and chat */}
{sidebarMode === 'orchestrator' ? (
<>
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-subtle)]">
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-tertiary)]">Project Orchestrator</p>
<button
type="button"
onClick={() => onSidebarModeChange?.('epics')}
className="rounded-lg px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border border-[var(--border-subtle)] bg-[var(--surface-quaternary)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
title="Switch to Epics view"
>
Epics
</button>
</div>
<div className="flex-1 overflow-hidden">
{orchestrator ? (
<OrchestratorPanel
orchestrator={orchestrator}
thread={orchestratorThread ?? []}
projectRoot={projectRoot}
/>
) : (
<div className="flex h-full items-center justify-center px-4 py-3 text-sm text-[var(--text-tertiary)]">
Orchestrator not initialized. Run bd init to set up beads.
</div>
)}
</div>
</>
) : (
<>
{/* EPICS MODE: Show filters and epic list */}
<div className="px-4 py-3 border-b border-[var(--border-subtle)]">
<div className="mb-3 flex items-center gap-1 rounded-xl bg-[var(--surface-tertiary)] p-1 border border-[var(--border-strong)]">
{views.map((item) => {
const active = view === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => setView(item.id)}
className={cn(
'flex-1 rounded-lg px-2 py-1 text-xs font-semibold uppercase tracking-[0.12em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]',
active
? 'bg-[var(--accent-info)]/20 text-[var(--accent-info)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]',
)}
>
{item.label}
</button>
);
})}
</div>
<div className="space-y-2 rounded-xl bg-[var(--surface-quaternary)] p-2.5 border border-[var(--border-subtle)]">
<div className="grid grid-cols-1 gap-2">
<input
value={filters.query}
onChange={(event) => onFiltersChange({ ...filters, query: event.target.value })}
className="ui-field rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
placeholder="Filter Tasks…"
aria-label="Filter tasks"
autoComplete="off"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-2 gap-2">
<select
value={filters.status}
onChange={(event) => onFiltersChange({ ...filters, status: event.target.value as LeftPanelStatusFilter })}
className="ui-field ui-select rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
aria-label="Status filter"
>
<option className="ui-option" value="all">All Status</option>
<option className="ui-option" value="ready">Ready</option>
<option className="ui-option" value="in_progress">In Progress</option>
<option className="ui-option" value="blocked">Blocked</option>
<option className="ui-option" value="deferred">Deferred</option>
<option className="ui-option" value="done">Done</option>
</select>
<select
value={filters.priority}
onChange={(event) => onFiltersChange({ ...filters, priority: event.target.value as LeftPanelPriorityFilter })}
className="ui-field ui-select rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
aria-label="Priority filter"
>
<option className="ui-option" value="all">All Priority</option>
<option className="ui-option" value="P0">P0</option>
<option className="ui-option" value="P1">P1</option>
<option className="ui-option" value="P2">P2</option>
<option className="ui-option" value="P3">P3</option>
<option className="ui-option" value="P4">P4</option>
</select>
</div>
<div className="flex gap-1">
<button
type="button"
onClick={() => onFiltersChange({ ...filters, preset: filters.preset === 'active' ? 'all' : 'active' })}
className={cn(
'flex-1 rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border',
filters.preset === 'active'
? 'bg-[var(--accent-warning)]/15 border-[var(--accent-warning)]/40 text-[var(--accent-warning)]'
: 'bg-[var(--surface-quaternary)] border-[var(--border-subtle)] text-[var(--text-tertiary)]',
)}
aria-pressed={filters.preset === 'active'}
>
Active
</button>
<button
type="button"
onClick={() => onFiltersChange({ ...filters, preset: filters.preset === 'blocked_agents' ? 'all' : 'blocked_agents' })}
className={cn(
'flex-1 rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border',
filters.preset === 'blocked_agents'
? 'bg-[var(--accent-danger)]/15 border-[var(--accent-danger)]/40 text-[var(--accent-danger)]'
: 'bg-[var(--surface-quaternary)] border-[var(--border-subtle)] text-[var(--text-tertiary)]',
)}
aria-pressed={filters.preset === 'blocked_agents'}
>
Agent Blocked
</button>
</div>
</div>
<button
type="button"
onClick={() => onFiltersChange({ ...filters, hideClosed: !filters.hideClosed })}
className={cn(
'w-full rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border',
filters.hideClosed
? 'bg-[var(--accent-success)]/15 border-[var(--accent-success)]/40 text-[var(--accent-success)]'
: 'bg-[var(--surface-quaternary)] border-[var(--border-subtle)] text-[var(--text-tertiary)]',
)}
aria-pressed={filters.hideClosed}
>
Hide Closed
</button>
</div>
<div className="mt-2 flex items-center gap-1 rounded-xl bg-[var(--surface-tertiary)] p-1 border border-[var(--border-subtle)]">
<button
type="button"
onClick={() => onSidebarModeChange?.('orchestrator')}
className="flex-1 rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border bg-[var(--accent-info)]/15 border-[var(--accent-info)]/40 text-[var(--accent-info)]"
aria-pressed
>
Orchestrator
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 py-3 custom-scrollbar">
{entries.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No epics found.</p>
) : (
entries.map((entry) => {
const {
epic,
children,
blockedCount,
activeCount,
readyCount,
deferredCount,
doneCount,
agentBlockedCount,
latestTimestamp,
} = entry;
const matchedChildren = children.filter((task) => isTaskMatch(task, filters));
const total = children.length;
const isExpanded = expanded[epic.id] ?? false;
const isSelected = selectedEpicId === epic.id;
const rowBackground = rowTone(entry);
return (
<div key={epic.id} className="mb-2">
<div
className={cn(
'rounded-xl px-3 py-3 transition-colors border border-[var(--border-subtle)]',
isSelected
? 'border-[var(--accent-info)] bg-[var(--accent-info)]/10'
: rowBackground,
)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setExpanded((prev) => ({ ...prev, [epic.id]: !prev[epic.id] }))}
className="rounded p-1 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)]"
aria-label={isExpanded ? 'Collapse epic' : 'Expand epic'}
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<div className="flex min-w-0 items-center gap-1.5">
<FolderOpen className="h-3.5 w-3.5 text-[var(--accent-info)]" />
<span className="truncate text-sm font-semibold text-[var(--text-primary)]">{epic.title}</span>
{total > 0 ? (
<span className="shrink-0 rounded-full bg-[var(--surface-tertiary)] px-2 py-0.5 text-[10px] font-mono text-[var(--text-tertiary)]">
{matchedChildren.length}/{total}
</span>
) : null}
</div>
</div>
</div>
</div>
</div>
</div>
);
})
)}
</div>
</>
)}
</aside>
);
}

View file

@ -3,9 +3,11 @@
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Folder, FolderOpen, Pencil, Rocket, Star } from 'lucide-react';
import type { RuntimeConsoleEvent, RuntimeInstance } from '../../lib/embedded-runtime';
import type { BeadIssue } from '../../lib/types';
import { cn } from '../../lib/utils';
import { useUrlState, type ViewType } from '../../hooks/use-url-state';
import { useUrlState, type LeftSidebarMode, type ViewType } from '../../hooks/use-url-state';
import { OrchestratorPanel } from './orchestrator-panel';
export type LeftPanelStatusFilter = 'all' | 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
export type LeftPanelPriorityFilter = 'all' | 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
@ -27,9 +29,14 @@ export interface LeftPanelProps {
filters: LeftPanelFilters;
onFiltersChange: (filters: LeftPanelFilters) => void;
onAssignMode?: (epicId: string) => void;
sidebarMode?: LeftSidebarMode;
onSidebarModeChange?: (mode: LeftSidebarMode) => void;
orchestrator?: RuntimeInstance;
orchestratorThread?: RuntimeConsoleEvent[];
projectRoot?: string;
}
interface EpicEntry {
interface EpicEntry {
epic: BeadIssue;
children: BeadIssue[];
blockedCount: number;
@ -38,34 +45,34 @@ interface EpicEntry {
deferredCount: number;
doneCount: number;
agentBlockedCount: number;
latestTimestamp: string;
}
export function shouldHideEpicEntry(params: {
epicStatus: BeadIssue['status'];
matchedChildrenCount: number;
totalChildrenCount: number;
isSelected: boolean;
filters: LeftPanelFilters;
}): boolean {
const { epicStatus, matchedChildrenCount, totalChildrenCount, isSelected, filters } = params;
const hasTaskFilters =
filters.query.trim().length > 0 ||
filters.status !== 'all' ||
filters.priority !== 'all' ||
filters.preset !== 'all';
const epicClosed = epicStatus === 'closed' || epicStatus === 'tombstone';
const noVisibleChildren = matchedChildrenCount === 0 && totalChildrenCount > 0;
const hiddenByTaskFilters = hasTaskFilters && noVisibleChildren;
const hiddenByHideClosed = filters.hideClosed && noVisibleChildren;
const hiddenByEpicClosed = filters.hideClosed && epicClosed;
if (hiddenByEpicClosed) {
return true;
}
return !isSelected && (hiddenByTaskFilters || hiddenByHideClosed);
}
latestTimestamp: string;
}
export function shouldHideEpicEntry(params: {
epicStatus: BeadIssue['status'];
matchedChildrenCount: number;
totalChildrenCount: number;
isSelected: boolean;
filters: LeftPanelFilters;
}): boolean {
const { epicStatus, matchedChildrenCount, totalChildrenCount, isSelected, filters } = params;
const hasTaskFilters =
filters.query.trim().length > 0 ||
filters.status !== 'all' ||
filters.priority !== 'all' ||
filters.preset !== 'all';
const epicClosed = epicStatus === 'closed' || epicStatus === 'tombstone';
const noVisibleChildren = matchedChildrenCount === 0 && totalChildrenCount > 0;
const hiddenByTaskFilters = hasTaskFilters && noVisibleChildren;
const hiddenByHideClosed = filters.hideClosed && noVisibleChildren;
const hiddenByEpicClosed = filters.hideClosed && epicClosed;
if (hiddenByEpicClosed) {
return true;
}
return !isSelected && (hiddenByTaskFilters || hiddenByHideClosed);
}
function mapStatus(task: BeadIssue): LeftPanelStatusFilter {
if (task.status === 'open') return 'ready';
@ -200,12 +207,24 @@ function isTaskMatch(task: BeadIssue, filters: LeftPanelFilters): boolean {
return true;
}
export function LeftPanel({ issues, selectedEpicId, onEpicSelect, onEpicEdit, filters, onFiltersChange, onAssignMode }: LeftPanelProps) {
export function LeftPanel({
issues,
selectedEpicId,
onEpicSelect,
onEpicEdit,
filters,
onFiltersChange,
onAssignMode,
sidebarMode = 'epics',
onSidebarModeChange,
orchestrator,
orchestratorThread = [],
}: LeftPanelProps) {
const { view, setView } = useUrlState();
const entries = useMemo(() => buildEntries(issues), [issues]);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const views: Array<{ id: ViewType; label: string }> = [
const views: Array<{ id: ViewType; label: string }> = [
{ id: 'social', label: 'Social' },
{ id: 'graph', label: 'Graph' },
];
@ -316,11 +335,40 @@ export function LeftPanel({ issues, selectedEpicId, onEpicSelect, onEpicEdit, fi
</button>
</div>
<p className="mt-2 font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--text-tertiary)]">Navigation / Epics</p>
<div className="mt-2 flex items-center gap-1 rounded-xl bg-[var(--surface-tertiary)] p-1 border border-[var(--border-subtle)]">
{([
{ id: 'epics', label: 'Epics' },
{ id: 'orchestrator', label: 'Orchestrator' },
] as Array<{ id: LeftSidebarMode; label: string }>).map((item) => {
const active = sidebarMode === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => onSidebarModeChange?.(item.id)}
className={cn(
'flex-1 rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border',
active
? 'bg-[var(--accent-info)]/15 border-[var(--accent-info)]/40 text-[var(--accent-info)]'
: 'bg-[var(--surface-quaternary)] border-[var(--border-subtle)] text-[var(--text-tertiary)]',
)}
aria-pressed={active}
>
{item.label}
</button>
);
})}
</div>
<p className="mt-2 font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--text-tertiary)]">
{sidebarMode === 'orchestrator' ? 'Project Orchestrator' : 'Navigation / Epics'}
</p>
</div>
<div className="flex-1 overflow-y-auto px-3 py-3 custom-scrollbar">
{entries.map((entry) => {
{sidebarMode === 'orchestrator' ? (
orchestrator ? <OrchestratorPanel orchestrator={orchestrator} thread={orchestratorThread} /> : null
) : entries.map((entry) => {
const {
epic,
children,
@ -343,15 +391,15 @@ export function LeftPanel({ issues, selectedEpicId, onEpicSelect, onEpicEdit, fi
const laneColor = blockedCount > 0 ? 'var(--accent-danger)' : activeCount > 0 ? 'var(--accent-warning)' : 'var(--accent-success)';
const rowBackground = rowTone(entry);
if (shouldHideEpicEntry({
epicStatus: epic.status,
matchedChildrenCount: matchedChildren.length,
totalChildrenCount: total,
isSelected,
filters,
})) {
return null;
}
if (shouldHideEpicEntry({
epicStatus: epic.status,
matchedChildrenCount: matchedChildren.length,
totalChildrenCount: total,
isSelected,
filters,
})) {
return null;
}
return (
<div key={epic.id} className="mb-2">

View file

@ -0,0 +1,118 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Send } from 'lucide-react';
import type { RuntimeInstance } from '../../lib/embedded-runtime';
import type { OrchestratorChatMessage } from '../../lib/orchestrator-chat';
export interface OrchestratorPanelProps {
orchestrator: RuntimeInstance;
thread: OrchestratorChatMessage[];
projectRoot?: string;
}
export function OrchestratorPanel({ orchestrator, thread, projectRoot }: OrchestratorPanelProps) {
const [input, setInput] = useState('');
const [submitting, setSubmitting] = useState(false);
const [optimisticMessages, setOptimisticMessages] = useState<OrchestratorChatMessage[]>([]);
useEffect(() => {
setOptimisticMessages((current) =>
current.filter((pending) => !thread.some((message) => message.role === 'user' && message.text === pending.text))
);
}, [thread]);
const visibleThread = useMemo(() => [...thread, ...optimisticMessages], [thread, optimisticMessages]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || submitting || !projectRoot) return;
setSubmitting(true);
const text = input.trim();
setInput('');
setOptimisticMessages((current) => [
...current,
{
id: `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
role: 'user',
text,
timestamp: new Date().toISOString(),
},
]);
try {
await fetch('/api/runtime/prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectRoot, text })
});
} finally {
setSubmitting(false);
}
};
return (
<div className="flex h-full min-h-0 flex-col" data-testid="orchestrator-panel">
<div className="border-b border-[var(--border-subtle)] px-4 py-3">
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-tertiary)]">Main Orchestrator</p>
<div className="mt-2 flex items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-[var(--text-primary)]">{orchestrator.label}</p>
<p className="text-xs text-[var(--text-secondary)]">Long-lived project control plane for Pi launches</p>
</div>
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-1 text-[10px] uppercase tracking-[0.12em] text-cyan-200">
{orchestrator.status}
</span>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3 custom-scrollbar">
<div className="space-y-3">
{visibleThread.map((message) => (
<div
key={message.id}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={message.role === 'user'
? 'max-w-[85%] rounded-2xl rounded-br-md bg-cyan-500/15 px-3 py-2 text-sm text-cyan-50 border border-cyan-500/25'
: 'max-w-[85%] rounded-2xl rounded-bl-md bg-[var(--surface-quaternary)] px-3 py-2 text-sm text-[var(--text-primary)] border border-[var(--border-subtle)]'
}
>
<p className="whitespace-pre-wrap leading-relaxed">{message.text}</p>
</div>
</div>
))}
{visibleThread.length === 0 ? (
<p className="rounded-lg border border-dashed border-[var(--border-subtle)] bg-[var(--surface-quaternary)] px-3 py-4 text-sm text-[var(--text-tertiary)]">
No orchestrator messages yet.
</p>
) : null}
</div>
</div>
{projectRoot && (
<div className="border-t border-[var(--border-subtle)] p-3">
<form onSubmit={handleSubmit} className="relative flex items-center">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask the orchestrator..."
className="w-full rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-3 py-2 pr-10 text-sm placeholder-[var(--text-tertiary)] focus:border-[var(--brand-primary)] focus:outline-none"
disabled={submitting}
/>
<button
type="submit"
disabled={!input.trim() || submitting}
className="absolute right-2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] disabled:opacity-50"
>
<Send size={16} />
</button>
</form>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,101 @@
'use client';
import { useState } from 'react';
import { ChevronDown, ChevronUp, TerminalSquare } from 'lucide-react';
import { cn } from '../../lib/utils';
import type { RuntimeConsoleEvent } from '../../lib/embedded-runtime';
export interface RuntimeConsoleProps {
events: RuntimeConsoleEvent[];
daemonStatus?: string | null;
}
function statusTone(status?: RuntimeConsoleEvent['status']): string {
if (status === 'failed' || status === 'blocked') return 'text-red-300';
if (status === 'completed') return 'text-emerald-300';
if (status === 'planning' || status === 'launching') return 'text-amber-300';
return 'text-cyan-200';
}
function isWorkerEvent(event: RuntimeConsoleEvent): boolean {
return (
event.kind === 'worker.spawned' ||
event.kind === 'worker.updated' ||
event.kind === 'worker.completed' ||
event.kind === 'worker.failed'
);
}
function formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
return Number.isNaN(date.getTime()) ? timestamp : date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export function RuntimeConsole({ events, daemonStatus }: RuntimeConsoleProps) {
const [isMinimized, setIsMinimized] = useState(false);
return (
<section
className="border-t border-[var(--border-strong)] bg-[var(--surface-elevated)]"
data-testid="runtime-console"
>
<div className="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-2">
<div className="flex items-center gap-4">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-tertiary)]">Runtime Console</p>
<p className="text-xs text-[var(--text-secondary)]">Live orchestrator and worker telemetry</p>
</div>
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-2.5 py-1 text-[10px] uppercase tracking-[0.12em] text-[var(--text-tertiary)]">
<TerminalSquare className="h-3.5 w-3.5" aria-hidden="true" />
{daemonStatus ? `daemon ${daemonStatus} · ` : ''}{events.length} event{events.length === 1 ? '' : 's'}
</div>
</div>
<button
type="button"
onClick={() => setIsMinimized(!isMinimized)}
className="rounded p-1 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)]"
title={isMinimized ? 'Expand console' : 'Minimize console'}
aria-label={isMinimized ? 'Expand console' : 'Minimize console'}
>
{isMinimized ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
{!isMinimized && (
<div className="grid max-h-44 gap-2 overflow-y-auto px-4 py-3 custom-scrollbar">
{events.map((event) => (
<article
key={event.id}
className="rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-quaternary)] px-3 py-2"
>
<div className="mb-1 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
{isWorkerEvent(event) && (
<span className="inline-flex items-center gap-1 rounded-full bg-indigo-500/10 px-1.5 py-0.5 text-[9px] font-mono uppercase tracking-wider text-indigo-300" title="Worker Agent Event">
Worker
</span>
)}
<span className={cn('font-mono text-[10px] uppercase tracking-[0.12em]', statusTone(event.status))}>
{event.kind}
</span>
{event.actorLabel ? (
<span className="truncate text-[11px] text-[var(--text-tertiary)]">{event.actorLabel}</span>
) : null}
</div>
<span className="shrink-0 font-mono text-[10px] text-[var(--text-tertiary)]">{formatTimestamp(event.timestamp)}</span>
</div>
<p className="text-sm font-medium text-[var(--text-primary)]">{event.title}</p>
<p className="mt-1 text-xs leading-relaxed text-[var(--text-secondary)]">{event.detail}</p>
</article>
))}
{events.length === 0 ? (
<p className="rounded-lg border border-dashed border-[var(--border-subtle)] bg-[var(--surface-quaternary)] px-3 py-4 text-sm text-[var(--text-tertiary)]">
No runtime events yet.
</p>
) : null}
</div>
)}
</section>
);
}

View file

@ -3,27 +3,30 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { X } from 'lucide-react';
import { useRouter } from 'next/navigation';
import type { BeadIssue } from '../../lib/types';
import type { ProjectScopeOption } from '../../lib/project-scope';
import type { BeadIssue } from '../../lib/types';
import type { ProjectScopeOption } from '../../lib/project-scope';
import { buildLaunchRequest, createLaunchConsoleEvents, createOrchestratorInstance, type RuntimeConsoleEvent, type RuntimeStatus } from '../../lib/embedded-runtime';
import { TopBar } from './top-bar';
import { LeftPanel, type LeftPanelFilters } from './left-panel';
import { LeftPanel, type LeftPanelFilters } from './left-panel-new';
import { RightPanel } from './right-panel';
import { MobileNav } from './mobile-nav';
import { ThreadDrawer } from './thread-drawer';
import { ResizeHandle } from './resize-handle';
import { useUrlState } from '../../hooks/use-url-state';
import { RuntimeConsole } from './runtime-console';
import { useUrlState, type LeftSidebarMode } from '../../hooks/use-url-state';
import { usePanelResize } from '../../hooks/use-panel-resize';
import { SmartDag } from '../graph/smart-dag';
import { SocialPage } from '../social/social-page';
import { buildSocialCards } from '../../lib/social-cards';
import { ContextualRightPanel } from '../activity/contextual-right-panel';
import { AssignmentPanel } from '../graph/assignment-panel';
import { TelemetryStrip } from './telemetry-strip';
import { useSwarmList } from '../../hooks/use-swarm-list';
import { useBeadsSubscription } from '../../hooks/use-beads-subscription';
import { useBdHealth } from '../../hooks/use-bd-health';
import { BlockedTriageModal } from './blocked-triage-modal';
import { deriveBlockedIds } from '../../lib/kanban';
import { TelemetryStrip } from './telemetry-strip';
import { useSwarmList } from '../../hooks/use-swarm-list';
import { useBeadsSubscription } from '../../hooks/use-beads-subscription';
import { useBdHealth } from '../../hooks/use-bd-health';
import { BlockedTriageModal } from './blocked-triage-modal';
import { deriveBlockedIds } from '../../lib/kanban';
import { projectOrchestratorChat } from '../../lib/orchestrator-chat';
export interface UnifiedShellProps {
issues: BeadIssue[];
@ -33,13 +36,27 @@ export interface UnifiedShellProps {
projectScopeMode: 'single' | 'aggregate';
}
function mergeUniqueRuntimeEvents(existing: RuntimeConsoleEvent[], incoming: RuntimeConsoleEvent[]): RuntimeConsoleEvent[] {
const seen = new Set<string>();
const merged: RuntimeConsoleEvent[] = [];
for (const event of [...incoming, ...existing]) {
if (seen.has(event.id)) continue;
seen.add(event.id);
merged.push(event);
}
return merged.slice(0, 40);
}
export function UnifiedShell({
issues: initialIssues,
projectRoot,
projectScopeOptions,
}: UnifiedShellProps) {
const router = useRouter();
const { view, taskId, setTaskId, swarmId, graphTab, panel, drawer, setDrawer, epicId, setEpicId, blockedOnly } = useUrlState();
const { view, taskId, setTaskId, swarmId, graphTab, drawer, setDrawer, epicId, setEpicId, blockedOnly } = useUrlState();
const [leftSidebarMode, setLeftSidebarMode] = useState<LeftSidebarMode>('epics');
// Subscribe to SSE for real-time updates on ALL views
const { issues } = useBeadsSubscription(initialIssues, projectRoot);
@ -66,29 +83,32 @@ export function UnifiedShell({
}, []);
const [customRightPanel, setCustomRightPanel] = useState<React.ReactNode | null>(null);
const [orchestrator, setOrchestrator] = useState(() => createOrchestratorInstance(projectRoot));
const [runtimeEvents, setRuntimeEvents] = useState<RuntimeConsoleEvent[]>([]);
const [daemonLifecycle, setDaemonLifecycle] = useState<{ status: RuntimeStatus | 'stopped' | 'starting' | 'stopping' | 'failed' } | null>(null);
// Assign mode state for graph view
const [assignMode, setAssignMode] = useState(false);
const [selectedAssignIssue, setSelectedAssignIssue] = useState<BeadIssue | null>(null);
// Remember last non-telemetry state for minimize button
const [lastTaskId, setLastTaskId] = useState<string | null>(null);
const [lastAssignMode, setLastAssignMode] = useState(false);
// Blocked triage modal state
const [blockedTriageOpen, setBlockedTriageOpen] = useState(false);
const handleOpenBlockedTriage = useCallback(() => setBlockedTriageOpen(true), []);
const handleCloseBlockedTriage = useCallback(() => setBlockedTriageOpen(false), []);
// Remember last non-telemetry state for minimize button
const [lastTaskId, setLastTaskId] = useState<string | null>(null);
const [lastAssignMode, setLastAssignMode] = useState(false);
const socialCards = useMemo(() => buildSocialCards(issues), [issues]);
const blockedIds = useMemo(() => deriveBlockedIds(issues), [issues]);
const blockedCount = useMemo(() => {
return issues.filter(i => i.status === 'blocked' || blockedIds.has(i.id)).length;
}, [issues, blockedIds]);
const { swarms: swarmCards } = useSwarmList(projectRoot);
const bdHealth = useBdHealth(projectRoot);
const selectedSocialCard = taskId ? socialCards.find(c => c.id === taskId) : null;
// Blocked triage modal state
const [blockedTriageOpen, setBlockedTriageOpen] = useState(false);
const handleOpenBlockedTriage = useCallback(() => setBlockedTriageOpen(true), []);
const handleCloseBlockedTriage = useCallback(() => setBlockedTriageOpen(false), []);
const socialCards = useMemo(() => buildSocialCards(issues), [issues]);
const blockedIds = useMemo(() => deriveBlockedIds(issues), [issues]);
const blockedCount = useMemo(() => {
return issues.filter(i => i.status === 'blocked' || blockedIds.has(i.id)).length;
}, [issues, blockedIds]);
const { swarms: swarmCards } = useSwarmList(projectRoot);
const bdHealth = useBdHealth(projectRoot);
const selectedSocialCard = taskId ? socialCards.find(c => c.id === taskId) : null;
const selectedSwarmCard = swarmId ? swarmCards.find(c => c.swarmId === swarmId) : null;
const selectedIssue = taskId ? issues.find((issue) => issue.id === taskId) ?? null : null;
@ -131,6 +151,116 @@ export function UnifiedShell({
setAssignMode(true);
}, [setTaskId]);
useEffect(() => {
let cancelled = false;
async function bootstrapRuntime() {
try {
const [statusResponse, orchestratorResponse, eventsResponse] = await Promise.all([
fetch('/api/runtime/status'),
fetch('/api/runtime/orchestrator', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ projectRoot }),
}),
fetch(`/api/runtime/events?projectRoot=${encodeURIComponent(projectRoot)}`),
]);
const statusPayload = await statusResponse.json().catch(() => null);
const orchestratorPayload = await orchestratorResponse.json().catch(() => null);
const eventsPayload = await eventsResponse.json().catch(() => null);
if (cancelled) {
return;
}
if (statusResponse.ok && statusPayload?.lifecycle) {
setDaemonLifecycle(statusPayload.lifecycle);
}
if (orchestratorResponse.ok && orchestratorPayload?.ok && orchestratorPayload.data) {
setOrchestrator(orchestratorPayload.data);
}
if (orchestratorPayload?.lifecycle) {
setDaemonLifecycle(orchestratorPayload.lifecycle);
}
if (eventsResponse.ok && eventsPayload?.ok && Array.isArray(eventsPayload.data)) {
setRuntimeEvents((current) => mergeUniqueRuntimeEvents(current, eventsPayload.data));
}
} catch {
// Runtime bootstrap is best-effort during early integration.
}
}
void bootstrapRuntime();
return () => {
cancelled = true;
};
}, [projectRoot]);
useEffect(() => {
// daemon lifecycle and runtime events should come from the daemon stream, not local shell ownership
const source = new EventSource(`/api/runtime/stream?projectRoot=${encodeURIComponent(projectRoot)}`);
const onRuntime = (event: MessageEvent) => {
try {
const payload = JSON.parse(event.data) as RuntimeConsoleEvent;
setRuntimeEvents((current) => mergeUniqueRuntimeEvents(current, [payload]));
} catch {
// Ignore malformed runtime frames.
}
};
source.addEventListener('runtime', onRuntime as EventListener);
return () => {
source.removeEventListener('runtime', onRuntime as EventListener);
source.close();
};
}, [projectRoot]);
const handleAskOrchestrator = useCallback(async (issueId: string) => {
const issue = issues.find((entry) => entry.id === issueId);
if (!issue) {
return;
}
const optimisticRequest = buildLaunchRequest({
issue,
origin: 'social',
projectRoot,
swarmId,
});
const optimisticEvents = createLaunchConsoleEvents(optimisticRequest);
setLeftSidebarMode('orchestrator');
setRuntimeEvents((current) => mergeUniqueRuntimeEvents(current, optimisticEvents));
try {
const response = await fetch('/api/runtime/launch', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
projectRoot,
taskId: issueId,
origin: 'social',
swarmId,
}),
});
const payload = await response.json().catch(() => null);
if (response.ok && payload?.ok) {
if (payload.lifecycle) {
setDaemonLifecycle(payload.lifecycle);
}
if (payload.data?.orchestrator) {
setOrchestrator(payload.data.orchestrator);
}
if (Array.isArray(payload.data?.events)) {
setRuntimeEvents((current) => mergeUniqueRuntimeEvents(current, payload.data.events));
}
}
} catch {
// Keep optimistic console events visible; bridge hardening comes in later phases.
}
}, [issues, projectRoot, setLeftSidebarMode, swarmId]);
// Minimize: restore last clicked thing (task or assign mode)
const handleMinimize = useCallback(() => {
if (lastTaskId) {
@ -156,23 +286,23 @@ export function UnifiedShell({
// Chat Mode Logic: If a card is selected (drawer='open'), we show Chat popup
const isChatOpen = drawer === 'open' && (!!taskId || !!swarmId || !!epicId);
const selectedEpic = epicId ? issues.find((issue) => issue.id === epicId && issue.issue_type === 'epic') ?? null : null;
const drawerTitle = selectedSocialCard?.title || selectedSwarmCard?.title || selectedEpic?.title || '';
const drawerId = taskId || swarmId || epicId || '';
const selectedItem = selectedEpic ?? selectedIssue;
useEffect(() => {
if (!filters.hideClosed || !epicId) {
return;
}
const epic = issues.find((issue) => issue.id === epicId && issue.issue_type === 'epic');
if (!epic) {
return;
}
if (epic.status === 'closed' || epic.status === 'tombstone') {
setEpicId(null);
}
}, [filters.hideClosed, epicId, issues, setEpicId]);
const selectedEpic = epicId ? issues.find((issue) => issue.id === epicId && issue.issue_type === 'epic') ?? null : null;
const drawerTitle = selectedSocialCard?.title || selectedSwarmCard?.title || selectedEpic?.title || '';
const drawerId = taskId || swarmId || epicId || '';
const selectedItem = selectedEpic ?? selectedIssue;
useEffect(() => {
if (!filters.hideClosed || !epicId) {
return;
}
const epic = issues.find((issue) => issue.id === epicId && issue.issue_type === 'epic');
if (!epic) {
return;
}
if (epic.status === 'closed' || epic.status === 'tombstone') {
setEpicId(null);
}
}, [filters.hideClosed, epicId, issues, setEpicId]);
// Panel resize hook
const { leftWidth, rightWidth, handleLeftResize, handleRightResize } = usePanelResize();
@ -214,6 +344,7 @@ export function UnifiedShell({
projectRoot={projectRoot}
swarmId={swarmId ?? undefined}
onRocketClick={handleSocialRocket}
onAskOrchestrator={handleAskOrchestrator}
/>
);
}
@ -262,16 +393,16 @@ export function UnifiedShell({
return (
<div className="flex flex-col h-screen bg-[var(--surface-backdrop)]" data-testid="unified-shell">
{/* TOP BAR: 3rem fixed */}
<TopBar
totalTasks={issues.filter(i => i.issue_type !== 'epic').length}
criticalAlerts={blockedCount}
busyCount={issues.filter(i => i.status === 'in_progress').length}
idleCount={0}
actor={actor}
onActorChange={handleActorChange}
onLaunchSwarm={() => { setTaskId(null); setAssignMode(true); }}
onOpenBlockedTriage={handleOpenBlockedTriage}
/>
<TopBar
totalTasks={issues.filter(i => i.issue_type !== 'epic').length}
criticalAlerts={blockedCount}
busyCount={issues.filter(i => i.status === 'in_progress').length}
idleCount={0}
actor={actor}
onActorChange={handleActorChange}
onLaunchSwarm={() => { setTaskId(null); setAssignMode(true); }}
onOpenBlockedTriage={handleOpenBlockedTriage}
/>
{!bdHealth.loading && !bdHealth.healthy ? (
<div className="border-b border-amber-500/35 bg-amber-500/12 px-4 py-2 text-xs text-amber-100">
<span className="font-semibold">BD setup issue:</span> {bdHealth.message}
@ -293,6 +424,11 @@ export function UnifiedShell({
filters={filters}
onFiltersChange={setFilters}
onAssignMode={(epicId) => { setEpicId(epicId); setTaskId(null); setAssignMode(true); }}
sidebarMode={leftSidebarMode}
onSidebarModeChange={setLeftSidebarMode}
orchestrator={orchestrator}
orchestratorThread={projectOrchestratorChat(runtimeEvents)}
projectRoot={projectRoot}
/>
</div>
@ -344,20 +480,22 @@ export function UnifiedShell({
</div>
) : null}
{/* MOBILE NAV: Bottom tab bar */}
<MobileNav />
{/* BLOCKED TRIAGE MODAL */}
<BlockedTriageModal
isOpen={blockedTriageOpen}
onClose={handleCloseBlockedTriage}
issues={issues}
projectRoot={projectRoot}
onSelectTask={(taskId) => {
setTaskId(taskId);
handleCloseBlockedTriage();
}}
/>
</div>
<RuntimeConsole events={runtimeEvents} daemonStatus={daemonLifecycle?.status ?? null} />
{/* MOBILE NAV: Bottom tab bar */}
<MobileNav />
{/* BLOCKED TRIAGE MODAL */}
<BlockedTriageModal
isOpen={blockedTriageOpen}
onClose={handleCloseBlockedTriage}
issues={issues}
projectRoot={projectRoot}
onSelectTask={(taskId) => {
setTaskId(taskId);
handleCloseBlockedTriage();
}}
/>
</div>
);
}

View file

@ -1,33 +1,36 @@
import { useState } from 'react';
import type { KeyboardEvent, MouseEventHandler } from 'react';
import { Clock3, GitBranch, Link2, MessageCircle, MessageSquare, Rocket, UserPlus } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { SocialCard as SocialCardData, AgentStatus } from '../../lib/social-cards';
import { AgentAvatar } from '../shared/agent-avatar';
import { useArchetypePicker } from '../../hooks/use-archetype-picker';
import type { AgentArchetype } from '../../lib/types-swarm';
import { Clock3, GitBranch, Link2, MessageCircle, MessageSquare, UserPlus } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { SocialCard as SocialCardData, AgentStatus } from '../../lib/social-cards';
import { AgentAvatar } from '../shared/agent-avatar';
import { AgentActionRow } from '../agents';
import { useArchetypePicker } from '../../hooks/use-archetype-picker';
import type { AgentArchetype } from '../../lib/types-swarm';
interface SocialCardProps {
data: SocialCardData;
className?: string;
selected?: boolean;
onClick?: MouseEventHandler<HTMLDivElement>;
onJumpToGraph?: (id: string) => void;
onJumpToActivity?: (id: string) => void;
onOpenThread?: () => void;
description?: string;
updatedLabel?: string;
dependencyCount?: number;
commentCount?: number;
unreadCount?: number;
blockedByDetails?: Array<{ id: string; title: string; epic?: string }>;
unblocksDetails?: Array<{ id: string; title: string; epic?: string }>;
archetypes?: AgentArchetype[];
swarmId?: string;
data: SocialCardData;
className?: string;
selected?: boolean;
onClick?: MouseEventHandler<HTMLDivElement>;
onJumpToGraph?: (id: string) => void;
onJumpToActivity?: (id: string) => void;
onOpenThread?: () => void;
description?: string;
updatedLabel?: string;
dependencyCount?: number;
commentCount?: number;
unreadCount?: number;
blockedByDetails?: Array<{ id: string; title: string; epic?: string }>;
unblocksDetails?: Array<{ id: string; title: string; epic?: string }>;
archetypes?: AgentArchetype[];
projectRoot?: string;
swarmId?: string;
onLaunchSwarm?: () => void;
onAskOrchestrator?: () => void;
agentUnreadByName?: Record<string, number>;
agentMessagesByName?: Record<string, Array<{
message_id: string;
@ -41,82 +44,82 @@ interface SocialCardProps {
agentReservationsByName?: Record<string, string | undefined>;
onAckMessage?: (agent: string, messageId: string) => Promise<void> | void;
}
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, onClick?: MouseEventHandler<HTMLDivElement>) {
if (!onClick) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onClick(event as unknown as Parameters<MouseEventHandler<HTMLDivElement>>[0]);
}
function statusVisual(status: SocialCardData['status']) {
if (status === 'blocked') {
return {
border: 'var(--accent-danger)',
badgeBg: 'var(--status-blocked)',
badgeText: '#ffd5df',
chipText: 'Blocked',
};
}
if (status === 'in_progress') {
return {
border: 'var(--accent-warning)',
badgeBg: 'var(--status-in-progress)',
badgeText: '#ffe5c7',
chipText: 'Active',
};
}
if (status === 'ready') {
return {
border: 'var(--accent-success)',
badgeBg: 'var(--status-ready)',
badgeText: '#d6ffe7',
chipText: 'Ready',
};
}
return {
border: 'var(--border-default)',
badgeBg: 'var(--status-closed)',
badgeText: 'var(--text-tertiary)',
chipText: 'Closed',
};
}
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, onClick?: MouseEventHandler<HTMLDivElement>) {
if (!onClick) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onClick(event as unknown as Parameters<MouseEventHandler<HTMLDivElement>>[0]);
}
function statusVisual(status: SocialCardData['status']) {
if (status === 'blocked') {
return {
border: 'var(--accent-danger)',
badgeBg: 'var(--status-blocked)',
badgeText: '#ffd5df',
chipText: 'Blocked',
};
}
if (status === 'in_progress') {
return {
border: 'var(--accent-warning)',
badgeBg: 'var(--status-in-progress)',
badgeText: '#ffe5c7',
chipText: 'Active',
};
}
if (status === 'ready') {
return {
border: 'var(--accent-success)',
badgeBg: 'var(--status-ready)',
badgeText: '#d6ffe7',
chipText: 'Ready',
};
}
return {
border: 'var(--border-default)',
badgeBg: 'var(--status-closed)',
badgeText: 'var(--text-tertiary)',
chipText: 'Closed',
};
}
function dependencyPanel(
title: string,
color: string,
details: Array<{ id: string; title: string; epic?: string }>,
) {
if (details.length === 0) return null;
return (
<div className="rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-2.5 py-2">
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.12em]" style={{ color }}>
{title}
</p>
<div className="space-y-1.5">
{details.slice(0, 1).map((item) => (
<div
key={`${title}-${item.id}`}
className="rounded border border-[var(--border-subtle)] bg-[var(--surface-quaternary)] px-2 py-1.5"
>
<div className="mb-0.5 flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--accent-info)]" />
<span className="font-mono text-[10px] text-[var(--text-tertiary)]">{item.id}</span>
</div>
<p className="line-clamp-1 text-xs text-[var(--text-primary)]">{item.title}</p>
{item.epic ? (
<p className="line-clamp-1 text-[10px] text-[var(--accent-info)]"> {item.epic}</p>
) : null}
</div>
))}
</div>
{details.length > 1 ? <p className="mt-1 text-[11px] text-[var(--text-tertiary)]">+{details.length - 1} more</p> : null}
</div>
);
title: string,
color: string,
details: Array<{ id: string; title: string; epic?: string }>,
) {
if (details.length === 0) return null;
return (
<div className="rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-2.5 py-2">
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.12em]" style={{ color }}>
{title}
</p>
<div className="space-y-1.5">
{details.slice(0, 1).map((item) => (
<div
key={`${title}-${item.id}`}
className="rounded border border-[var(--border-subtle)] bg-[var(--surface-quaternary)] px-2 py-1.5"
>
<div className="mb-0.5 flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--accent-info)]" />
<span className="font-mono text-[10px] text-[var(--text-tertiary)]">{item.id}</span>
</div>
<p className="line-clamp-1 text-xs text-[var(--text-primary)]">{item.title}</p>
{item.epic ? (
<p className="line-clamp-1 text-[10px] text-[var(--accent-info)]"> {item.epic}</p>
) : null}
</div>
))}
</div>
{details.length > 1 ? <p className="mt-1 text-[11px] text-[var(--text-tertiary)]">+{details.length - 1} more</p> : null}
</div>
);
}
function categoryBadgeClass(category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO'): string {
@ -127,23 +130,24 @@ function categoryBadgeClass(category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO
}
export function SocialCard({
data,
className,
selected = false,
onClick,
onJumpToGraph,
onJumpToActivity,
onOpenThread,
description,
updatedLabel = 'just now',
dependencyCount,
commentCount,
unreadCount = 0,
blockedByDetails = [],
unblocksDetails = [],
data,
className,
selected = false,
onClick,
onJumpToGraph,
onJumpToActivity,
description,
updatedLabel = 'just now',
dependencyCount,
commentCount,
unreadCount = 0,
blockedByDetails = [],
unblocksDetails = [],
archetypes = [],
projectRoot,
swarmId,
onLaunchSwarm,
onAskOrchestrator,
agentUnreadByName = {},
agentMessagesByName = {},
agentReservationsByName = {},
@ -155,52 +159,52 @@ export function SocialCard({
const isSwarmHighlighted = swarmId && data.id.includes(swarmId);
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
const [ackingMessageId, setAckingMessageId] = useState<string | null>(null);
return (
<div
onClick={onClick}
onKeyDown={(event) => handleCardKeyDown(event, onClick)}
role="button"
tabIndex={0}
aria-label={`Open ${data.title}`}
className={cn(
'group relative flex min-h-[290px] cursor-pointer flex-col rounded-[14px] border px-3.5 py-3 text-left transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]',
isSwarmHighlighted && 'ring-2 ring-blue-500',
className,
)}
style={{
background: 'var(--surface-quaternary)',
borderColor: selected ? status.border : 'var(--border-default)',
boxShadow: selected
? `0 0 0 2px ${status.border}, 0 20px 40px -20px rgba(0,0,0,0.6)`
: '0 4px 12px -6px rgba(0,0,0,0.4)',
}}
>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Badge className="rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em]" style={{ backgroundColor: status.badgeBg, color: status.badgeText }}>
{status.chipText}
</Badge>
<span className="font-mono text-[11px] text-[var(--accent-info)]">{data.priority}</span>
<span className="truncate font-mono text-[11px] text-[var(--text-tertiary)]">{data.id}</span>
{unreadCount > 0 ? (
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-danger)] px-1 text-[10px] font-semibold text-[var(--text-inverse)]">
{unreadCount}
</span>
) : null}
</div>
</div>
<h3 className="line-clamp-2 text-[27px] font-semibold leading-[1.13] tracking-[-0.01em] text-[var(--text-primary)]">{data.title}</h3>
<p className="mt-1.5 line-clamp-3 min-h-[56px] text-[13px] leading-relaxed text-[var(--text-tertiary)]">
{description || 'No summary provided yet.'}
</p>
<div className="mt-2 flex flex-col gap-2">
{dependencyPanel('Blocked By', 'var(--accent-danger)', blockedByDetails)}
{dependencyPanel('Unblocks', 'var(--accent-success)', unblocksDetails)}
</div>
return (
<div
onClick={onClick}
onKeyDown={(event) => handleCardKeyDown(event, onClick)}
role="button"
tabIndex={0}
aria-label={`Open ${data.title}`}
className={cn(
'group relative flex min-h-[290px] cursor-pointer flex-col rounded-[14px] border px-3.5 py-3 text-left transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]',
isSwarmHighlighted && 'ring-2 ring-blue-500',
className,
)}
style={{
background: 'var(--surface-quaternary)',
borderColor: selected ? status.border : 'var(--border-default)',
boxShadow: selected
? `0 0 0 2px ${status.border}, 0 20px 40px -20px rgba(0,0,0,0.6)`
: '0 4px 12px -6px rgba(0,0,0,0.4)',
}}
>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Badge className="rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em]" style={{ backgroundColor: status.badgeBg, color: status.badgeText }}>
{status.chipText}
</Badge>
<span className="font-mono text-[11px] text-[var(--accent-info)]">{data.priority}</span>
<span className="truncate font-mono text-[11px] text-[var(--text-tertiary)]">{data.id}</span>
{unreadCount > 0 ? (
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-danger)] px-1 text-[10px] font-semibold text-[var(--text-inverse)]">
{unreadCount}
</span>
) : null}
</div>
</div>
<h3 className="line-clamp-2 text-[27px] font-semibold leading-[1.13] tracking-[-0.01em] text-[var(--text-primary)]">{data.title}</h3>
<p className="mt-1.5 line-clamp-3 min-h-[56px] text-[13px] leading-relaxed text-[var(--text-tertiary)]">
{description || 'No summary provided yet.'}
</p>
<div className="mt-2 flex flex-col gap-2">
{dependencyPanel('Blocked By', 'var(--accent-danger)', blockedByDetails)}
{dependencyPanel('Unblocks', 'var(--accent-success)', unblocksDetails)}
</div>
<div className="mt-2 flex items-center gap-2">
{data.agents.slice(0, 3).map((agent) => {
const unreadCount = agentUnreadByName[agent.name] ?? 0;
@ -286,87 +290,98 @@ export function SocialCard({
) : null}
</div>
) : null}
{showAssign && (
<div className="mt-2 flex gap-2 items-center overflow-hidden" onClick={(e) => e.stopPropagation()}>
<select
value={selectedArchetype ?? ''}
onChange={(e) => setSelectedArchetype(e.target.value || null)}
className="min-w-0 flex-1 text-xs border border-[var(--border-subtle)] rounded-md px-2 py-1.5 bg-[var(--surface-input)] text-[var(--text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--accent-info)]"
>
<option value="" disabled>Select agent role...</option>
{archetypes.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
<button
onClick={async (e) => {
e.stopPropagation();
await handleAssign(data.id);
}}
disabled={!selectedArchetype || isAssigning || assignSuccess}
className={`flex-shrink-0 px-2.5 py-1.5 text-xs font-semibold rounded-md transition-colors disabled:opacity-50 flex items-center gap-1 ${assignSuccess ? 'bg-[var(--accent-success)] text-white' : 'bg-[var(--accent-info)] text-white hover:bg-[var(--accent-info)]/90'}`}
>
<UserPlus className="w-3 h-3" />
{isAssigning ? '...' : assignSuccess ? '✓' : 'Assign'}
</button>
</div>
)}
<div className="mt-auto border-t border-[var(--border-subtle)] pt-1.5">
<div className="mb-1.5 flex items-center justify-between text-xs text-[var(--text-tertiary)]">
<span className="inline-flex items-center gap-1"><Clock3 className="h-3.5 w-3.5" aria-hidden="true" />{updatedLabel}</span>
<span className="font-mono text-[11px] text-[var(--accent-success)]">stage active</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-xs text-[var(--text-tertiary)]">
<span className="inline-flex items-center gap-1"><Link2 className="h-3.5 w-3.5" aria-hidden="true" />{dependencyCount ?? data.blocks.length + data.unblocks.length}</span>
<span className="inline-flex items-center gap-1"><MessageCircle className="h-3.5 w-3.5" aria-hidden="true" />{commentCount ?? 0}</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onJumpToGraph?.(data.id);
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] text-[var(--accent-info)] transition-colors hover:bg-[var(--alpha-white-low)]"
aria-label="View dependency graph"
title="View dependency graph"
>
<GitBranch className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onJumpToActivity?.(data.id);
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] text-[var(--accent-success)] transition-colors hover:bg-[var(--alpha-white-low)]"
aria-label="View details"
title="View details"
>
<MessageSquare className="h-3.5 w-3.5" aria-hidden="true" />
</button>
{onLaunchSwarm ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onLaunchSwarm();
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-emerald-500/20 bg-emerald-500/10 text-emerald-400 transition-colors hover:bg-emerald-500/20"
aria-label="Launch Swarm"
title="Launch Swarm"
>
<Rocket className="h-3.5 w-3.5" aria-hidden="true" />
</button>
) : null}
</div>
</div>
</div>
</div>
);
}
{showAssign && (
<div className="mt-2 flex gap-2 items-center overflow-hidden" onClick={(e) => e.stopPropagation()}>
<select
value={selectedArchetype ?? ''}
onChange={(e) => setSelectedArchetype(e.target.value || null)}
className="min-w-0 flex-1 text-xs border border-[var(--border-subtle)] rounded-md px-2 py-1.5 bg-[var(--surface-input)] text-[var(--text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--accent-info)]"
>
<option value="" disabled>Select agent role...</option>
{archetypes.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
<button
onClick={async (e) => {
e.stopPropagation();
await handleAssign(data.id);
}}
disabled={!selectedArchetype || isAssigning || assignSuccess}
className={`flex-shrink-0 px-2.5 py-1.5 text-xs font-semibold rounded-md transition-colors disabled:opacity-50 flex items-center gap-1 ${assignSuccess ? 'bg-[var(--accent-success)] text-white' : 'bg-[var(--accent-info)] text-white hover:bg-[var(--accent-info)]/90'}`}
>
<UserPlus className="w-3 h-3" />
{isAssigning ? '...' : assignSuccess ? '✓' : 'Assign'}
</button>
</div>
)}
<div className="mt-auto border-t border-[var(--border-subtle)] pt-1.5">
<div className="mb-1.5 flex items-center justify-between text-xs text-[var(--text-tertiary)]">
<span className="inline-flex items-center gap-1"><Clock3 className="h-3.5 w-3.5" aria-hidden="true" />{updatedLabel}</span>
<span className="font-mono text-[11px] text-[var(--accent-success)]">stage active</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-xs text-[var(--text-tertiary)]">
<span className="inline-flex items-center gap-1"><Link2 className="h-3.5 w-3.5" aria-hidden="true" />{dependencyCount ?? data.blocks.length + data.unblocks.length}</span>
<span className="inline-flex items-center gap-1"><MessageCircle className="h-3.5 w-3.5" aria-hidden="true" />{commentCount ?? 0}</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onJumpToGraph?.(data.id);
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] text-[var(--accent-info)] transition-colors hover:bg-[var(--alpha-white-low)]"
aria-label="View dependency graph"
title="View dependency graph"
>
<GitBranch className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onJumpToActivity?.(data.id);
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] text-[var(--accent-success)] transition-colors hover:bg-[var(--alpha-white-low)]"
aria-label="View details"
title="View details"
>
<MessageSquare className="h-3.5 w-3.5" aria-hidden="true" />
</button>
{onAskOrchestrator ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onAskOrchestrator();
}}
className="inline-flex items-center gap-1 rounded-md border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-cyan-200 transition-colors hover:bg-cyan-500/20"
aria-label="Ask orchestrator"
title="Ask Orchestrator"
>
<MessageSquare className="h-3.5 w-3.5" aria-hidden="true" />
Ask
</button>
) : null}
{projectRoot && archetypes.length > 0 ? (
<AgentActionRow
beadId={data.id}
beadStatus={data.status}
agents={archetypes}
projectRoot={projectRoot}
currentAgentTypeId={data.agentTypeId}
size="sm"
/>
) : null}
</div>
</div>
</div>
</div>
);
}

View file

@ -2,22 +2,23 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import type { BeadIssue } from '../../lib/types';
import type { ProjectScopeOption } from '../../lib/project-scope';
import { buildSocialCards } from '../../lib/social-cards';
import { SocialCard } from './social-card';
import { useArchetypes } from '../../hooks/use-archetypes';
import type { BeadIssue } from '../../lib/types';
import type { ProjectScopeOption } from '../../lib/project-scope';
import { buildSocialCards } from '../../lib/social-cards';
import { SocialCard } from './social-card';
import { useArchetypes } from '../../hooks/use-archetypes';
interface SocialPageProps {
issues: BeadIssue[];
selectedId?: string;
onSelect: (id: string) => void;
projectScopeOptions?: ProjectScopeOption[];
blockedOnly?: boolean;
projectRoot: string;
swarmId?: string;
onRocketClick?: () => void;
issues: BeadIssue[];
selectedId?: string;
onSelect: (id: string) => void;
projectScopeOptions?: ProjectScopeOption[];
blockedOnly?: boolean;
projectRoot: string;
swarmId?: string;
onRocketClick?: () => void;
onAskOrchestrator?: (issueId: string) => void;
}
interface CoordMessage {
@ -30,137 +31,138 @@ interface CoordMessage {
state: 'unread' | 'read' | 'acked';
requires_ack: boolean;
}
type SectionKey = 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
const SECTION_LABEL: Record<SectionKey, string> = {
ready: 'Ready',
in_progress: 'In Progress',
blocked: 'Blocked',
deferred: 'Deferred',
done: 'Done',
};
const SECTION_COLOR: Record<SectionKey, string> = {
ready: 'var(--ui-accent-ready)',
in_progress: 'var(--ui-accent-warning)',
blocked: 'var(--ui-accent-blocked)',
deferred: 'var(--ui-accent-info)',
done: 'var(--ui-text-muted)',
};
function bucketForStatus(status: string): SectionKey {
if (status === 'ready') return 'ready';
if (status === 'in_progress') return 'in_progress';
if (status === 'blocked') return 'blocked';
if (status === 'closed') return 'done';
return 'deferred';
}
function formatRelative(timestamp: string): string {
const then = new Date(timestamp);
const now = new Date();
const diffMins = Math.floor((now.getTime() - then.getTime()) / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
type SectionKey = 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
const SECTION_LABEL: Record<SectionKey, string> = {
ready: 'Ready',
in_progress: 'In Progress',
blocked: 'Blocked',
deferred: 'Deferred',
done: 'Done',
};
const SECTION_COLOR: Record<SectionKey, string> = {
ready: 'var(--ui-accent-ready)',
in_progress: 'var(--ui-accent-warning)',
blocked: 'var(--ui-accent-blocked)',
deferred: 'var(--ui-accent-info)',
done: 'var(--ui-text-muted)',
};
function bucketForStatus(status: string): SectionKey {
if (status === 'ready') return 'ready';
if (status === 'in_progress') return 'in_progress';
if (status === 'blocked') return 'blocked';
if (status === 'closed') return 'done';
return 'deferred';
}
function formatRelative(timestamp: string): string {
const then = new Date(timestamp);
const now = new Date();
const diffMins = Math.floor((now.getTime() - then.getTime()) / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
export function SocialPage({
issues,
selectedId,
onSelect,
projectScopeOptions = [],
blockedOnly = false,
projectRoot,
swarmId,
onRocketClick,
}: SocialPageProps) {
const router = useRouter();
const searchParams = useSearchParams();
const cards = useMemo(() => buildSocialCards(issues), [issues]);
const { archetypes } = useArchetypes(projectRoot);
const navigateWithParams = (updates: Record<string, string | null>) => {
const next = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (!value) next.delete(key);
else next.set(key, value);
}
const query = next.toString();
router.push(query ? `/?${query}` : '/', { scroll: false });
};
const issueById = useMemo(() => {
const map = new Map<string, BeadIssue>();
for (const issue of issues) map.set(issue.id, issue);
return map;
}, [issues]);
const epicTitleById = useMemo(() => {
const map = new Map<string, string>();
for (const issue of issues) {
if (issue.issue_type === 'epic') {
map.set(issue.id, issue.title);
}
}
return map;
}, [issues]);
const toDependencyDetails = (ids: string[]) =>
ids.map((id) => {
const depIssue = issueById.get(id);
const parentEpicId = depIssue?.dependencies.find((dep) => dep.type === 'parent')?.target;
return {
id,
title: depIssue?.title ?? id,
epic: parentEpicId ? epicTitleById.get(parentEpicId) : undefined,
};
});
const orderedCards = useMemo(
() => [...cards].sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime()),
[cards],
);
const visibleCards = useMemo(
() => (blockedOnly ? orderedCards.filter((card) => card.status === 'blocked') : orderedCards),
[blockedOnly, orderedCards],
);
const grouped = useMemo(() => {
const map: Record<SectionKey, typeof visibleCards> = {
ready: [],
in_progress: [],
blocked: [],
deferred: [],
done: [],
};
for (const card of visibleCards) {
map[bucketForStatus(card.status)].push(card);
}
return map;
}, [visibleCards]);
issues,
selectedId,
onSelect,
projectScopeOptions = [],
blockedOnly = false,
projectRoot,
swarmId,
onRocketClick,
onAskOrchestrator,
}: SocialPageProps) {
const router = useRouter();
const searchParams = useSearchParams();
const cards = useMemo(() => buildSocialCards(issues), [issues]);
const { archetypes } = useArchetypes(projectRoot);
const navigateWithParams = (updates: Record<string, string | null>) => {
const next = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (!value) next.delete(key);
else next.set(key, value);
}
const query = next.toString();
router.push(query ? `/?${query}` : '/', { scroll: false });
};
const issueById = useMemo(() => {
const map = new Map<string, BeadIssue>();
for (const issue of issues) map.set(issue.id, issue);
return map;
}, [issues]);
const epicTitleById = useMemo(() => {
const map = new Map<string, string>();
for (const issue of issues) {
if (issue.issue_type === 'epic') {
map.set(issue.id, issue.title);
}
}
return map;
}, [issues]);
const toDependencyDetails = (ids: string[]) =>
ids.map((id) => {
const depIssue = issueById.get(id);
const parentEpicId = depIssue?.dependencies.find((dep) => dep.type === 'parent')?.target;
return {
id,
title: depIssue?.title ?? id,
epic: parentEpicId ? epicTitleById.get(parentEpicId) : undefined,
};
});
const orderedCards = useMemo(
() => [...cards].sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime()),
[cards],
);
const visibleCards = useMemo(
() => (blockedOnly ? orderedCards.filter((card) => card.status === 'blocked') : orderedCards),
[blockedOnly, orderedCards],
);
const grouped = useMemo(() => {
const map: Record<SectionKey, typeof visibleCards> = {
ready: [],
in_progress: [],
blocked: [],
deferred: [],
done: [],
};
for (const card of visibleCards) {
map[bucketForStatus(card.status)].push(card);
}
return map;
}, [visibleCards]);
const [expandedSections, setExpandedSections] = useState<Record<SectionKey, boolean>>({
ready: false,
in_progress: false,
blocked: false,
deferred: false,
done: false,
});
ready: false,
in_progress: false,
blocked: false,
deferred: false,
done: false,
});
const [collapsedSections, setCollapsedSections] = useState<Record<SectionKey, boolean>>({
ready: false,
in_progress: false,
blocked: false,
deferred: true,
done: true,
ready: false,
in_progress: false,
blocked: false,
deferred: true,
done: true,
});
const [agentMessagesByName, setAgentMessagesByName] = useState<Record<string, CoordMessage[]>>({});
const [agentUnreadByName, setAgentUnreadByName] = useState<Record<string, number>>({});
@ -243,100 +245,102 @@ export function SocialPage({
});
await refreshCoordination();
};
return (
<div className="h-full overflow-y-auto bg-[var(--ui-bg-app)] custom-scrollbar">
<div className="mx-auto w-full max-w-[1280px] px-4 pb-8 pt-4 xl:px-6">
<div className="mb-4 flex items-center justify-between gap-3 border-b border-[var(--ui-border-soft)] pb-3">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">Social Stream</p>
<p className="text-sm text-[var(--ui-text-primary)]">Task Activity Command Feed</p>
</div>
<div className="flex items-center gap-2 text-[11px]">
<span className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[var(--ui-text-muted)]">
{projectScopeOptions.length} scopes
</span>
<span className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[var(--ui-text-muted)]">
{visibleCards.length} tasks
</span>
</div>
</div>
<section className="space-y-6">
{(Object.keys(SECTION_LABEL) as SectionKey[]).map((key) => {
const cardsForSection = grouped[key];
return (
<div key={key}>
<div className="mb-3 flex items-center gap-2 border-b border-[var(--ui-border-soft)] pb-2">
<p className="font-mono text-xs uppercase tracking-[0.14em]" style={{ color: SECTION_COLOR[key] }}>
{SECTION_LABEL[key]}
</p>
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-1.5 text-[10px] text-[var(--ui-text-primary)]">
{cardsForSection.length}
</span>
{(key === 'deferred' || key === 'done') ? (
<button
type="button"
onClick={() =>
setCollapsedSections((current) => ({ ...current, [key]: !current[key] }))
}
className="ml-auto rounded border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--ui-text-muted)] transition-colors hover:text-[var(--ui-text-primary)]"
>
{collapsedSections[key] ? 'Expand' : 'Minimize'}
</button>
) : null}
</div>
{collapsedSections[key] ? (
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
{cardsForSection.length === 0
? `No tasks in ${SECTION_LABEL[key].toLowerCase()}.`
: `${cardsForSection.length} tasks hidden.`}
</p>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{(expandedSections[key] ? cardsForSection : cardsForSection.slice(0, 3)).map((card) => {
const issue = issueById.get(card.id);
const commentCount = typeof issue?.metadata?.commentCount === 'number' ? issue.metadata.commentCount : 0;
const unreadCount = typeof issue?.metadata?.unreadCount === 'number' ? issue.metadata.unreadCount : 0;
const dependencyCount = issue?.dependencies.length ?? card.blocks.length + card.unblocks.length;
return (
return (
<div className="h-full overflow-y-auto bg-[var(--ui-bg-app)] custom-scrollbar">
<div className="mx-auto w-full max-w-[1280px] px-4 pb-8 pt-4 xl:px-6">
<div className="mb-4 flex items-center justify-between gap-3 border-b border-[var(--ui-border-soft)] pb-3">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">Social Stream</p>
<p className="text-sm text-[var(--ui-text-primary)]">Task Activity Command Feed</p>
</div>
<div className="flex items-center gap-2 text-[11px]">
<span className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[var(--ui-text-muted)]">
{projectScopeOptions.length} scopes
</span>
<span className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[var(--ui-text-muted)]">
{visibleCards.length} tasks
</span>
</div>
</div>
<section className="space-y-6">
{(Object.keys(SECTION_LABEL) as SectionKey[]).map((key) => {
const cardsForSection = grouped[key];
return (
<div key={key}>
<div className="mb-3 flex items-center gap-2 border-b border-[var(--ui-border-soft)] pb-2">
<p className="font-mono text-xs uppercase tracking-[0.14em]" style={{ color: SECTION_COLOR[key] }}>
{SECTION_LABEL[key]}
</p>
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-1.5 text-[10px] text-[var(--ui-text-primary)]">
{cardsForSection.length}
</span>
{(key === 'deferred' || key === 'done') ? (
<button
type="button"
onClick={() =>
setCollapsedSections((current) => ({ ...current, [key]: !current[key] }))
}
className="ml-auto rounded border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--ui-text-muted)] transition-colors hover:text-[var(--ui-text-primary)]"
>
{collapsedSections[key] ? 'Expand' : 'Minimize'}
</button>
) : null}
</div>
{collapsedSections[key] ? (
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
{cardsForSection.length === 0
? `No tasks in ${SECTION_LABEL[key].toLowerCase()}.`
: `${cardsForSection.length} tasks hidden.`}
</p>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{(expandedSections[key] ? cardsForSection : cardsForSection.slice(0, 3)).map((card) => {
const issue = issueById.get(card.id);
const commentCount = typeof issue?.metadata?.commentCount === 'number' ? issue.metadata.commentCount : 0;
const unreadCount = typeof issue?.metadata?.unreadCount === 'number' ? issue.metadata.unreadCount : 0;
const dependencyCount = issue?.dependencies.length ?? card.blocks.length + card.unblocks.length;
return (
<SocialCard
key={card.id}
data={card}
selected={selectedId === card.id}
onClick={() => onSelect(card.id)}
onJumpToGraph={(id) =>
navigateWithParams({
view: 'graph',
graphTab: 'flow',
task: id,
swarm: null,
right: 'open',
panel: 'open',
drawer: 'closed',
})
}
onJumpToActivity={(id) =>
navigateWithParams({
task: id,
right: 'open',
panel: 'open',
drawer: 'closed',
})
}
onOpenThread={() => onSelect(card.id)}
description={issue?.description ?? undefined}
updatedLabel={issue ? formatRelative(issue.updated_at) : 'just now'}
dependencyCount={dependencyCount}
commentCount={commentCount}
unreadCount={unreadCount}
blockedByDetails={toDependencyDetails(card.unblocks)}
unblocksDetails={toDependencyDetails(card.blocks)}
archetypes={archetypes}
swarmId={swarmId}
key={card.id}
data={card}
selected={selectedId === card.id}
onClick={() => onSelect(card.id)}
onJumpToGraph={(id) =>
navigateWithParams({
view: 'graph',
graphTab: 'flow',
task: id,
swarm: null,
right: 'open',
panel: 'open',
drawer: 'closed',
})
}
onJumpToActivity={(id) =>
navigateWithParams({
task: id,
right: 'open',
panel: 'open',
drawer: 'closed',
})
}
onOpenThread={() => onSelect(card.id)}
description={issue?.description ?? undefined}
updatedLabel={issue ? formatRelative(issue.updated_at) : 'just now'}
dependencyCount={dependencyCount}
commentCount={commentCount}
unreadCount={unreadCount}
blockedByDetails={toDependencyDetails(card.unblocks)}
unblocksDetails={toDependencyDetails(card.blocks)}
archetypes={archetypes}
projectRoot={projectRoot}
swarmId={swarmId}
onLaunchSwarm={onRocketClick}
onAskOrchestrator={() => onAskOrchestrator?.(card.id)}
agentUnreadByName={agentUnreadByName}
agentMessagesByName={agentMessagesByName}
agentReservationsByName={agentReservationsByName}
@ -344,38 +348,38 @@ export function SocialPage({
/>
);
})}
{cardsForSection.length === 0 ? (
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
No tasks in this lane.
</p>
) : null}
</div>
)}
{!collapsedSections[key] && cardsForSection.length > 3 ? (
<div className="mt-2 flex justify-end">
<button
type="button"
onClick={() =>
setExpandedSections((current) => ({ ...current, [key]: !current[key] }))
}
className="rounded-md border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2.5 py-1.5 text-xs font-semibold uppercase tracking-[0.1em] text-[var(--ui-text-muted)] transition-colors hover:text-[var(--ui-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]"
>
{expandedSections[key] ? 'Show Less' : `Show ${cardsForSection.length - 3} More`}
</button>
</div>
) : null}
</div>
);
})}
</section>
{visibleCards.length === 0 ? (
<p className="mt-5 px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
No blocked tasks right now.
</p>
) : null}
</div>
</div>
);
}
{cardsForSection.length === 0 ? (
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
No tasks in this lane.
</p>
) : null}
</div>
)}
{!collapsedSections[key] && cardsForSection.length > 3 ? (
<div className="mt-2 flex justify-end">
<button
type="button"
onClick={() =>
setExpandedSections((current) => ({ ...current, [key]: !current[key] }))
}
className="rounded-md border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2.5 py-1.5 text-xs font-semibold uppercase tracking-[0.1em] text-[var(--ui-text-muted)] transition-colors hover:text-[var(--ui-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]"
>
{expandedSections[key] ? 'Show Less' : `Show ${cardsForSection.length - 3} More`}
</button>
</div>
) : null}
</div>
);
})}
</section>
{visibleCards.length === 0 ? (
<p className="mt-5 px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
No blocked tasks right now.
</p>
) : null}
</div>
</div>
);
}

View file

@ -0,0 +1,484 @@
"use client";
import React, { useState, useEffect, useMemo } from 'react';
import { X, Save, ShieldAlert, Trash2, Plus, Copy, Palette, Smile } from 'lucide-react';
import type { AgentArchetype } from '../../lib/types-swarm';
const COLOR_PRESETS = [
'#3b82f6', '#2563eb', '#1d4ed8', '#0ea5e9', '#06b6d4',
'#10b981', '#059669', '#22c55e', '#84cc16', '#a3e635',
'#8b5cf6', '#7c3aed', '#a855f7', '#c084fc', '#e879f9',
'#ef4444', '#dc2626', '#f97316', '#fb923c', '#fbbf24',
'#ec4899', '#db2777', '#f472b6', '#f9a8d4', '#fda4af',
'#6366f1', '#64748b', '#78716c', '#57534e', '#1e293b',
];
const EMOJI_PRESETS = [
'🏗️', '⚙️', '🔍', '🧪', '🚀', '🤖', '👨‍💻', '👩‍💻', '🧙‍♂️', '🧙‍♀️',
'🔧', '📝', '🎯', '⚡', '🛡️', '📊', '🗂️', '💡', '🔮', '🧩',
'⭐', '🔥', '💎', '🚦', '🎪', '🎨', '🎭', '🃏', '👑', '🏆',
'🦅', '🐺', '🦁', '🐻', '🦊', '🐙', '🐝', '🦋', '🌿', '🌊',
];
const SUGGESTED_CAPABILITIES = [
'coding', 'testing', 'debugging', 'refactoring', 'documentation',
'code_review', 'system_design', 'architecture', 'planning', 'analysis',
'research', 'investigation', 'deployment', 'ci_cd', 'monitoring',
'security', 'performance', 'optimization', 'integration', 'migration',
'data_analysis', 'automation', 'scripting', 'api_design', 'database',
'frontend', 'backend', 'devops', 'qa', 'mentoring',
];
interface AgentInspectorProps {
archetype?: AgentArchetype;
onClose: () => void;
onSave: (data: Partial<AgentArchetype>) => Promise<void>;
onDelete?: (id: string) => Promise<void>;
onClone?: (archetype: AgentArchetype) => Promise<void>;
}
export function AgentInspector({ archetype, onClose, onSave, onDelete, onClone }: AgentInspectorProps) {
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 [icon, setIcon] = useState(archetype?.icon || '');
const [newCapability, setNewCapability] = useState('');
const [capabilityFilter, setCapabilityFilter] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isCloning, setIsCloning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [showColorPicker, setShowColorPicker] = useState(false);
const [showCapabilityDropdown, setShowCapabilityDropdown] = useState(false);
useEffect(() => {
if (archetype) {
setName(archetype.name);
setDescription(archetype.description);
setSystemPrompt(archetype.systemPrompt);
setCapabilities(archetype.capabilities);
setColor(archetype.color);
setIcon(archetype.icon || '');
}
}, [archetype]);
const filteredSuggestions = useMemo(() => {
return SUGGESTED_CAPABILITIES.filter(
cap =>
cap.includes(capabilityFilter.toLowerCase()) &&
!capabilities.includes(cap)
).slice(0, 6);
}, [capabilityFilter, capabilities]);
const handleAddCapability = (cap?: string) => {
const toAdd = cap || newCapability.trim();
if (toAdd && !capabilities.includes(toAdd.toLowerCase())) {
setCapabilities([...capabilities, toAdd.toLowerCase()]);
setNewCapability('');
setCapabilityFilter('');
setShowCapabilityDropdown(false);
}
};
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,
icon: icon || undefined,
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);
}
};
const handleClone = async () => {
if (!archetype || !onClone) return;
setIsCloning(true);
setError(null);
try {
await onClone(archetype);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to clone');
} finally {
setIsCloning(false);
}
};
const displayChar = icon || name.charAt(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-[90vh] w-full max-w-3xl overflow-hidden rounded-xl border border-[var(--ui-border-soft)] bg-[#0f1824] shadow-2xl animate-in zoom-in-95 duration-200">
<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-12 w-12 rounded-xl flex items-center justify-center text-xl font-bold border-2 transition-all duration-200"
style={{
backgroundColor: `${color}20`,
color: color,
borderColor: `${color}50`
}}
>
{displayChar}
</div>
<div>
<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">
<X className="w-5 h-5" />
</button>
</div>
{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 flex items-center gap-2">
<ShieldAlert className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--ui-text-secondary)] mb-1.5">Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-primary)] placeholder:text-[var(--ui-text-muted)] focus:outline-none focus:ring-2 focus:ring-blue-500/50"
placeholder="e.g., Code Reviewer"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--ui-text-secondary)] mb-1.5">Description</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-primary)] placeholder:text-[var(--ui-text-muted)] focus:outline-none focus:ring-2 focus:ring-blue-500/50"
placeholder="Brief description"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[var(--ui-text-secondary)] mb-1.5">System Prompt *</label>
<textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
rows={6}
className="w-full px-3 py-2 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-primary)] placeholder:text-[var(--ui-text-muted)] focus:outline-none focus:ring-2 focus:ring-blue-500/50 font-mono text-sm resize-none"
placeholder="You are a helpful assistant that..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="flex items-center gap-2 text-sm font-medium text-[var(--ui-text-secondary)] mb-1.5">
<Palette className="w-4 h-4" />
Color
</label>
<div className="flex items-center gap-2 mb-2">
<div
className="h-8 w-8 rounded-lg border-2 border-white/20"
style={{ backgroundColor: color }}
/>
<input
type="text"
value={color}
onChange={(e) => setColor(e.target.value)}
className="flex-1 px-3 py-1.5 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-primary)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
<button
type="button"
onClick={() => setShowColorPicker(!showColorPicker)}
className="px-3 py-1.5 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-secondary)] hover:bg-white/5 text-sm"
>
{showColorPicker ? 'Hide' : 'Pick'}
</button>
</div>
{showColorPicker && (
<div className="grid grid-cols-10 gap-1.5 p-2 bg-[var(--ui-bg-soft)] rounded-lg border border-[var(--ui-border-soft)]">
{COLOR_PRESETS.map((presetColor) => (
<button
key={presetColor}
type="button"
onClick={() => {
setColor(presetColor);
setShowColorPicker(false);
}}
className={`h-6 w-6 rounded-md border-2 transition-all hover:scale-110 ${color === presetColor ? 'border-white ring-2 ring-white/30' : 'border-transparent'}`}
style={{ backgroundColor: presetColor }}
title={presetColor}
/>
))}
</div>
)}
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-[var(--ui-text-secondary)] mb-1.5">
<Smile className="w-4 h-4" />
Icon / Emoji
</label>
<div className="flex items-center gap-2 mb-2">
<div
className="h-8 w-8 rounded-lg flex items-center justify-center text-lg border border-[var(--ui-border-soft)] bg-[var(--ui-bg-soft)]"
style={{ color }}
>
{icon || '?'}
</div>
<input
type="text"
value={icon}
onChange={(e) => setIcon(e.target.value)}
className="flex-1 px-3 py-1.5 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
placeholder="Emoji or leave empty"
/>
<button
type="button"
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
className="px-3 py-1.5 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-secondary)] hover:bg-white/5 text-sm"
>
{showEmojiPicker ? 'Hide' : 'Pick'}
</button>
</div>
{showEmojiPicker && (
<div className="grid grid-cols-10 gap-1.5 p-2 bg-[var(--ui-bg-soft)] rounded-lg border border-[var(--ui-border-soft)]">
{EMOJI_PRESETS.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
setIcon(emoji);
setShowEmojiPicker(false);
}}
className={`h-6 w-6 rounded-md flex items-center justify-center text-base transition-all hover:scale-110 hover:bg-white/10 ${icon === emoji ? 'bg-white/20 ring-2 ring-white/30' : ''}`}
>
{emoji}
</button>
))}
</div>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-[var(--ui-text-secondary)] mb-1.5">Capabilities</label>
<div className="flex flex-wrap gap-2 mb-2">
{capabilities.map((cap, index) => (
<span
key={cap}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-secondary)]"
>
{cap}
<button
type="button"
onClick={() => handleRemoveCapability(index)}
className="text-[var(--ui-text-muted)] hover:text-rose-400 transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<div className="relative">
<div className="flex gap-2">
<input
type="text"
value={newCapability}
onChange={(e) => {
setNewCapability(e.target.value);
setCapabilityFilter(e.target.value);
setShowCapabilityDropdown(true);
}}
onFocus={() => setShowCapabilityDropdown(true)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddCapability();
}
}}
className="flex-1 px-3 py-2 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-primary)] placeholder:text-[var(--ui-text-muted)] focus:outline-none focus:ring-2 focus:ring-blue-500/50 text-sm"
placeholder="Add capability..."
/>
<button
type="button"
onClick={() => handleAddCapability()}
disabled={!newCapability.trim()}
className="px-3 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
{showCapabilityDropdown && filteredSuggestions.length > 0 && (
<div className="absolute z-10 mt-1 w-full bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] rounded-lg shadow-lg overflow-hidden">
{filteredSuggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
onClick={() => handleAddCapability(suggestion)}
className="w-full px-3 py-2 text-left text-sm text-[var(--ui-text-primary)] hover:bg-white/5 transition-colors"
>
{suggestion}
</button>
))}
</div>
)}
</div>
</div>
<div className="border-t border-[var(--ui-border-soft)] pt-4">
<h3 className="text-sm font-medium text-[var(--ui-text-secondary)] mb-3">Live Preview</h3>
<div className="p-4 rounded-xl bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)]">
<div className="flex items-center gap-3 mb-3">
<div
className="h-10 w-10 rounded-xl flex items-center justify-center text-lg font-bold border-2"
style={{
backgroundColor: `${color}20`,
color: color,
borderColor: `${color}50`
}}
>
{displayChar}
</div>
<div>
<div className="font-semibold text-[var(--ui-text-primary)]">
{name || 'Archetype Name'}
</div>
<div className="text-xs text-[var(--ui-text-muted)]">
{description || 'No description'}
</div>
</div>
</div>
{capabilities.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{capabilities.slice(0, 5).map((cap) => (
<span
key={cap}
className="px-2 py-0.5 rounded-full text-[10px] font-medium"
style={{
backgroundColor: `${color}20`,
color: color
}}
>
{cap}
</span>
))}
{capabilities.length > 5 && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium text-[var(--ui-text-muted)]">
+{capabilities.length - 5} more
</span>
)}
</div>
)}
{systemPrompt && (
<div className="mt-3 p-2 rounded-lg bg-black/20 text-xs text-[var(--ui-text-muted)] font-mono line-clamp-2">
{systemPrompt}
</div>
)}
</div>
</div>
</div>
<div className="flex items-center justify-between border-t border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e]">
<div className="flex items-center gap-2">
{!isNew && onDelete && (
<button
onClick={handleDelete}
disabled={isDeleting || archetype?.isBuiltIn}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-rose-400 hover:bg-rose-500/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Trash2 className="w-4 h-4" />
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
)}
{!isNew && onClone && (
<button
onClick={handleClone}
disabled={isCloning}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-[var(--ui-text-secondary)] hover:bg-white/5 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Copy className="w-4 h-4" />
{isCloning ? 'Cloning...' : 'Clone'}
</button>
)}
</div>
<div className="flex items-center gap-3">
{archetype?.isBuiltIn && (
<span className="text-xs text-amber-400 bg-amber-500/10 px-2 py-1 rounded">
Built-in archetype
</span>
)}
<button
onClick={onClose}
className="px-4 py-2 rounded-lg text-[var(--ui-text-secondary)] hover:bg-white/5 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isSaving || !name.trim() || !systemPrompt.trim()}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Save className="w-4 h-4" />
{isSaving ? 'Saving...' : (isNew ? 'Create' : 'Save')}
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,131 @@
"use client";
import React from 'react';
import { X, Blocks, Check, Pencil, Plus } from 'lucide-react';
import type { AgentArchetype } from '../../lib/types-swarm';
import { getArchetypeDisplayChar } from '../../lib/utils';
interface AgentPickerProps {
archetypes: AgentArchetype[];
isOpen: boolean;
onClose: () => void;
onSelect: (archetype: AgentArchetype) => void;
onEdit: (archetypeId: string) => void;
onCreateNew: () => void;
}
export function AgentPicker({
archetypes,
isOpen,
onClose,
onSelect,
onEdit,
onCreateNew
}: AgentPickerProps) {
if (!isOpen) return null;
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
const handleSelect = (archetype: AgentArchetype) => {
onSelect(archetype);
onClose();
};
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"
onClick={handleBackdropClick}
>
<div className="flex flex-col w-full max-w-[800px] max-h-[85vh] overflow-hidden rounded-xl border border-[var(--ui-border-soft)] bg-[#0f1824] shadow-2xl animate-in zoom-in-95 duration-200">
<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">
<Blocks className="w-5 h-5 text-[var(--ui-text-secondary)]" />
<h2 className="text-lg font-bold text-[var(--ui-text-primary)]">
Select Archetype
</h2>
</div>
<button
onClick={onClose}
className="rounded-full p-2 text-[var(--ui-text-muted)] hover:bg-white/5 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
{archetypes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-[var(--ui-text-muted)]">
<Blocks className="w-12 h-12 mb-3 opacity-50" />
<p className="text-sm">No archetypes available</p>
<p className="text-xs mt-1">Create one to get started</p>
</div>
) : (
<div className="grid grid-cols-2 gap-4">
{archetypes.map((archetype) => {
const displayChar = getArchetypeDisplayChar(archetype);
return (
<div
key={archetype.id}
className="group relative flex flex-col p-4 rounded-xl bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] hover:border-[var(--ui-border)] hover:bg-[#111f2b] transition-all duration-200"
>
<div className="flex items-start gap-3 mb-2">
<div
className="h-10 w-10 rounded-xl flex items-center justify-center text-lg font-bold border-2 flex-shrink-0"
style={{
backgroundColor: `${archetype.color}20`,
color: archetype.color,
borderColor: `${archetype.color}50`
}}
>
{displayChar}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-[var(--ui-text-primary)] text-sm truncate">
{archetype.name}
</h3>
<p className="text-xs text-[var(--ui-text-muted)] line-clamp-4 mt-0.5">
{archetype.description || 'No description'}
</p>
</div>
</div>
<div className="flex items-center gap-2 mt-auto pt-2 border-t border-[var(--ui-border-soft)]">
<button
onClick={() => handleSelect(archetype)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-600 text-white text-xs font-medium hover:bg-blue-500 transition-colors"
>
<Check className="w-3.5 h-3.5" />
Select
</button>
<button
onClick={() => onEdit(archetype.id)}
className="p-1.5 rounded-lg text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] hover:bg-white/5 transition-colors"
title="Edit archetype"
>
<Pencil className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
)}
</div>
<div className="border-t border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e]">
<button
onClick={onCreateNew}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-secondary)] hover:bg-[#111f2b] hover:text-[var(--ui-text-primary)] hover:border-[var(--ui-border)] transition-colors"
>
<Plus className="w-4 h-4" />
Create New Archetype
</button>
</div>
</div>
</div>
);
}

View file

@ -8,7 +8,7 @@ import { cn, getArchetypeDisplayChar, getTemplateDisplayChar, getTemplateColor }
import type { BeadIssue } from '../../lib/types';
import { useArchetypes } from '../../hooks/use-archetypes';
import { useTemplates } from '../../hooks/use-templates';
import { ArchetypeInspector } from './archetype-inspector';
import { AgentInspector } from './agent-inspector';
import { TemplateInspector } from './template-inspector';
export function SwarmWorkspace({ selectedMissionId, issues = [], projectRoot }: { selectedMissionId?: string, issues?: BeadIssue[], projectRoot: string }) {
@ -276,13 +276,13 @@ export function SwarmWorkspace({ selectedMissionId, issues = [], projectRoot }:
<div className="text-[10px] uppercase font-bold text-[var(--ui-text-muted)] tracking-wider mb-2">Team Composition</div>
<div className="flex flex-wrap gap-2">
{tpl.team.map((member, idx) => {
const arch = archetypes.find(a => a.id === member.archetypeId);
const arch = archetypes.find(a => a.id === member.agentTypeId);
return (
<div key={idx} className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-[#0f1824] border border-[var(--ui-border-soft)]">
<div className="h-4 w-4 rounded text-[9px] flex items-center justify-center font-bold" style={{ backgroundColor: `${arch?.color || '#888'}20`, color: arch?.color || '#888' }}>
{arch ? getArchetypeDisplayChar(arch) : '?'}
</div>
<span className="text-[11px] text-[var(--ui-text-primary)] font-medium">{member.count}x {arch?.name || member.archetypeId}</span>
<span className="text-[11px] text-[var(--ui-text-primary)] font-medium">{member.count}x {arch?.name || member.agentTypeId}</span>
</div>
);
})}
@ -350,7 +350,7 @@ export function SwarmWorkspace({ selectedMissionId, issues = [], projectRoot }:
{/* Popups */}
{inspectingArchetypeId !== null && (
<ArchetypeInspector
<AgentInspector
archetype={archetypes.find(a => a.id === inspectingArchetypeId)}
onClose={() => setInspectingArchetypeId(null)}
onSave={saveArchetype}

View file

@ -34,7 +34,10 @@ export function TemplateInspector({ template, archetypes, onClose, onSave, onDel
const [name, setName] = useState(template?.name || '');
const [description, setDescription] = useState(template?.description || '');
const [team, setTeam] = useState<{ archetypeId: string; count: number }[]>(template?.team || []);
// Use agentTypeId internally, normalize from template.team
const [team, setTeam] = useState<{ agentTypeId: string; count: number }[]>(
template?.team?.map(m => ({ agentTypeId: m.agentTypeId, count: m.count })) || []
);
const [protoFormula, setProtoFormula] = useState(template?.protoFormula || '');
const [color, setColor] = useState(template?.color || '#f59e0b');
const [icon, setIcon] = useState(template?.icon || '');
@ -49,26 +52,26 @@ export function TemplateInspector({ template, archetypes, onClose, onSave, onDel
if (template) {
setName(template.name);
setDescription(template.description);
setTeam(template.team);
setTeam(template.team?.map(m => ({ agentTypeId: m.agentTypeId, count: m.count })) || []);
setProtoFormula(template.protoFormula || '');
setColor(template.color || '#f59e0b');
setIcon(template.icon || '');
}
}, [template]);
const updateTeamMember = (index: number, field: 'archetypeId' | 'count', value: string | number) => {
const updateTeamMember = (index: number, field: 'agentTypeId' | '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 };
newTeam[index] = { ...newTeam[index], agentTypeId: value as string };
}
setTeam(newTeam);
};
const addTeamMember = () => {
const firstAvailableArchetype = archetypes[0]?.id || '';
setTeam([...team, { archetypeId: firstAvailableArchetype, count: 1 }]);
const firstAvailableAgentType = archetypes[0]?.id || '';
setTeam([...team, { agentTypeId: firstAvailableAgentType, count: 1 }]);
};
const removeTeamMember = (index: number) => {
@ -328,8 +331,8 @@ export function TemplateInspector({ template, archetypes, onClose, onSave, onDel
{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)}
value={member.agentTypeId}
onChange={(e) => updateTeamMember(index, 'agentTypeId', 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 => (
@ -403,11 +406,11 @@ export function TemplateInspector({ template, archetypes, onClose, onSave, onDel
key={idx}
className="px-2 py-0.5 rounded-full text-[10px] font-medium"
style={{
backgroundColor: `${getArchetypeColor(member.archetypeId)}20`,
color: getArchetypeColor(member.archetypeId)
backgroundColor: `${getArchetypeColor(member.agentTypeId)}20`,
color: getArchetypeColor(member.agentTypeId)
}}
>
{getArchetypeName(member.archetypeId)} ×{member.count}
{getArchetypeName(member.agentTypeId)} ×{member.count}
</span>
))}
</div>