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:
parent
643fa299dd
commit
d335e5bf71
98 changed files with 17851 additions and 944 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
83
src/components/agents/agent-action-row.tsx
Normal file
83
src/components/agents/agent-action-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
src/components/agents/agent-assign-button.tsx
Normal file
79
src/components/agents/agent-assign-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
src/components/agents/agent-picker-popup.tsx
Normal file
120
src/components/agents/agent-picker-popup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
src/components/agents/agent-spawn-button.tsx
Normal file
132
src/components/agents/agent-spawn-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
src/components/agents/agent-status-panel.tsx
Normal file
158
src/components/agents/agent-status-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/components/agents/hooks/index.ts
Normal file
3
src/components/agents/hooks/index.ts
Normal 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';
|
||||
68
src/components/agents/hooks/use-agent-status.ts
Normal file
68
src/components/agents/hooks/use-agent-status.ts
Normal 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;
|
||||
}
|
||||
41
src/components/agents/hooks/use-spawn-agent.ts
Normal file
41
src/components/agents/hooks/use-spawn-agent.ts
Normal 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 };
|
||||
}
|
||||
6
src/components/agents/index.ts
Normal file
6
src/components/agents/index.ts
Normal 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';
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
113
src/components/onboarding/onboarding-wizard.tsx
Normal file
113
src/components/onboarding/onboarding-wizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
404
src/components/shared/left-panel-new.tsx
Normal file
404
src/components/shared/left-panel-new.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
118
src/components/shared/orchestrator-panel.tsx
Normal file
118
src/components/shared/orchestrator-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/components/shared/runtime-console.tsx
Normal file
101
src/components/shared/runtime-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
484
src/components/swarm/agent-inspector.tsx
Normal file
484
src/components/swarm/agent-inspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src/components/swarm/agent-picker.tsx
Normal file
131
src/components/swarm/agent-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue