559 lines
20 KiB
TypeScript
559 lines
20 KiB
TypeScript
/**
|
|
* GraphNodePopover — renders popover for graph nodes using project UI components.
|
|
* This stays in the renderer slice instead of the reusable package because it
|
|
* composes project-specific UI, selectors, and presentation helpers.
|
|
*/
|
|
|
|
import { Badge } from '@renderer/components/ui/badge';
|
|
import { Button } from '@renderer/components/ui/button';
|
|
import { agentAvatarUrl, buildMemberLaunchPresentation } from '@renderer/utils/memberHelpers';
|
|
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
|
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
|
|
|
|
import { isTaskInReviewCycle, resolveTaskReviewer } from '../../core/domain/taskGraphSemantics';
|
|
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
|
import { useGraphMemberPopoverContext } from '../hooks/useGraphMemberPopoverContext';
|
|
|
|
import { GraphTaskCard } from './GraphTaskCard';
|
|
|
|
import type { GraphNode } from '@claude-teams/agent-graph';
|
|
import type { TeamTaskWithKanban } from '@shared/types';
|
|
|
|
// ─── Tool name/preview formatters ───────────────────────────────────────────
|
|
|
|
/** Clean up tool names: "mcp__agent-teams__task_create" → "Task Create" */
|
|
function formatToolName(raw: string): string {
|
|
// Strip MCP prefixes (mcp__serverName__toolName → toolName)
|
|
const parts = raw.split('__');
|
|
const name = parts[parts.length - 1] ?? raw;
|
|
// snake_case → Title Case
|
|
return name.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
}
|
|
|
|
/** Clean up tool preview: strip raw JSON, extract meaningful part */
|
|
function formatToolPreview(preview: string | undefined): string | undefined {
|
|
if (!preview) return undefined;
|
|
// If it looks like raw JSON object, try to extract a readable field
|
|
if (preview.startsWith('{') || preview.startsWith('[')) {
|
|
try {
|
|
const parsed: unknown = JSON.parse(preview.length > 200 ? preview.slice(0, 200) : preview);
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
const previewRecord = parsed as Record<string, unknown>;
|
|
const candidates = [
|
|
previewRecord.subject,
|
|
previewRecord.name,
|
|
previewRecord.label,
|
|
previewRecord.file_path,
|
|
previewRecord.path,
|
|
previewRecord.query,
|
|
];
|
|
const firstText = candidates.find((value) => typeof value === 'string');
|
|
if (typeof firstText === 'string') {
|
|
return firstText;
|
|
}
|
|
}
|
|
} catch {
|
|
// Truncated JSON — extract first quoted value
|
|
const match = /"(?:subject|name|label|path|query)":\s*"([^"]{1,60})"/.exec(preview);
|
|
if (match) return match[1];
|
|
}
|
|
}
|
|
return preview.length > 50 ? preview.slice(0, 50) + '...' : preview;
|
|
}
|
|
|
|
interface GraphNodePopoverProps {
|
|
node: GraphNode;
|
|
teamName: string;
|
|
onClose: () => void;
|
|
onSendMessage?: (memberName: string) => void;
|
|
onOpenTaskDetail?: (taskId: string) => void;
|
|
onOpenMemberProfile?: (memberName: string) => void;
|
|
onCreateTask?: (owner: string) => void;
|
|
onStartTask?: (taskId: string) => void;
|
|
onCompleteTask?: (taskId: string) => void;
|
|
onApproveTask?: (taskId: string) => void;
|
|
onRequestReview?: (taskId: string) => void;
|
|
onRequestChanges?: (taskId: string) => void;
|
|
onCancelTask?: (taskId: string) => void;
|
|
onMoveBackToDone?: (taskId: string) => void;
|
|
onDeleteTask?: (taskId: string) => void;
|
|
}
|
|
|
|
export const GraphNodePopover = ({
|
|
node,
|
|
teamName,
|
|
onClose,
|
|
onSendMessage,
|
|
onOpenTaskDetail,
|
|
onOpenMemberProfile,
|
|
onCreateTask,
|
|
onStartTask,
|
|
onCompleteTask,
|
|
onApproveTask,
|
|
onRequestReview,
|
|
onRequestChanges,
|
|
onCancelTask,
|
|
onMoveBackToDone,
|
|
onDeleteTask,
|
|
}: GraphNodePopoverProps): React.JSX.Element => {
|
|
if (node.kind === 'member' || node.kind === 'lead') {
|
|
return (
|
|
<MemberPopoverContent
|
|
node={node}
|
|
onClose={onClose}
|
|
onSendMessage={onSendMessage}
|
|
onOpenProfile={onOpenMemberProfile}
|
|
onCreateTask={onCreateTask}
|
|
onOpenTask={onOpenTaskDetail}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (node.kind === 'task') {
|
|
if (node.isOverflowStack || node.domainRef.kind === 'task_overflow') {
|
|
return (
|
|
<OverflowPopoverContent
|
|
node={node}
|
|
teamName={teamName}
|
|
onClose={onClose}
|
|
onOpenTaskDetail={onOpenTaskDetail}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<GraphTaskCard
|
|
node={node}
|
|
teamName={teamName}
|
|
onClose={onClose}
|
|
onOpenDetail={onOpenTaskDetail}
|
|
onStartTask={onStartTask}
|
|
onCompleteTask={onCompleteTask}
|
|
onApproveTask={onApproveTask}
|
|
onRequestReview={onRequestReview}
|
|
onRequestChanges={onRequestChanges}
|
|
onCancelTask={onCancelTask}
|
|
onMoveBackToDone={onMoveBackToDone}
|
|
onDeleteTask={onDeleteTask}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Cross-team ghost node
|
|
if (node.kind === 'crossteam') {
|
|
const extTeamName =
|
|
node.domainRef.kind === 'crossteam' ? node.domainRef.externalTeamName : node.label;
|
|
return (
|
|
<div className="min-w-[180px] rounded-lg border border-purple-500/30 bg-[var(--color-surface-raised)] p-3 shadow-xl">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-purple-400">{'\u{2194}'}</span>
|
|
<span className="font-mono text-xs font-bold text-purple-300">{extTeamName}</span>
|
|
</div>
|
|
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">External team</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Process
|
|
return (
|
|
<div className="min-w-[180px] max-w-[260px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
|
|
<div className="font-mono text-xs font-bold text-[var(--color-text)]">{node.label}</div>
|
|
{node.processCommand && (
|
|
<div className="mt-1 truncate font-mono text-[10px] text-[var(--color-text-muted)]">
|
|
$ {node.processCommand}
|
|
</div>
|
|
)}
|
|
<div className="mt-2 space-y-0.5 text-[10px] text-[var(--color-text-muted)]">
|
|
{node.processRegisteredBy && (
|
|
<div>
|
|
Started by: <span className="text-[var(--color-text)]">{node.processRegisteredBy}</span>
|
|
</div>
|
|
)}
|
|
{node.processRegisteredAt && (
|
|
<div>At: {new Date(node.processRegisteredAt).toLocaleTimeString()}</div>
|
|
)}
|
|
{node.exceptionLabel && (
|
|
<Badge
|
|
variant="outline"
|
|
className={`px-1.5 py-0 text-[10px] ${
|
|
node.exceptionTone === 'error'
|
|
? 'border-red-500/30 text-red-400'
|
|
: 'border-amber-500/30 text-amber-400'
|
|
}`}
|
|
>
|
|
{node.exceptionLabel}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{node.processUrl && (
|
|
<a
|
|
href={node.processUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="mt-2 flex items-center gap-1 text-xs text-blue-400 hover:underline"
|
|
>
|
|
<ExternalLink size={12} /> Open URL
|
|
</a>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const OverflowPopoverContent = ({
|
|
node,
|
|
teamName,
|
|
onClose,
|
|
onOpenTaskDetail,
|
|
}: {
|
|
node: GraphNode;
|
|
teamName: string;
|
|
onClose: () => void;
|
|
onOpenTaskDetail?: (taskId: string) => void;
|
|
}): React.JSX.Element => {
|
|
const { teamData } = useGraphActivityContext(teamName);
|
|
const tasksById = new Map((teamData?.tasks ?? []).map((task) => [task.id, task]));
|
|
const hiddenTasks = (node.overflowTaskIds ?? [])
|
|
.map((taskId) => tasksById.get(taskId) ?? null)
|
|
.filter((task): task is TeamTaskWithKanban => task != null);
|
|
|
|
return (
|
|
<div className="min-w-[240px] max-w-[320px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-sm font-semibold text-[var(--color-text)]">Hidden tasks</div>
|
|
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
|
|
{node.overflowCount ?? hiddenTasks.length}
|
|
</Badge>
|
|
</div>
|
|
<div className="mt-2 max-h-[260px] space-y-1 overflow-y-auto pr-1">
|
|
{hiddenTasks.length === 0 ? (
|
|
<div className="text-xs text-[var(--color-text-muted)]">No hidden tasks available.</div>
|
|
) : (
|
|
hiddenTasks.map((task) => {
|
|
const reviewer = resolveTaskReviewer(task, teamData?.kanbanState.tasks[task.id]);
|
|
return (
|
|
<button
|
|
key={task.id}
|
|
type="button"
|
|
className="flex w-full items-start justify-between gap-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-secondary)] p-2 text-left transition-colors hover:border-[var(--color-border-emphasis)]"
|
|
onClick={() => {
|
|
onOpenTaskDetail?.(task.id);
|
|
onClose();
|
|
}}
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="font-mono text-[10px] text-[var(--color-text-muted)]">
|
|
{task.displayId ?? `#${task.id.slice(0, 6)}`}
|
|
</div>
|
|
<div className="truncate text-xs text-[var(--color-text)]">{task.subject}</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{task.owner && (
|
|
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
|
|
{task.owner}
|
|
</Badge>
|
|
)}
|
|
{isTaskInReviewCycle(task) && (
|
|
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
|
|
{reviewer ?? 'REV'}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Member Popover ─────────────────────────────────────────────────────────
|
|
|
|
const MemberPopoverContent = ({
|
|
node,
|
|
onClose,
|
|
onSendMessage,
|
|
onOpenProfile,
|
|
onCreateTask,
|
|
onOpenTask,
|
|
}: {
|
|
node: GraphNode;
|
|
onClose: () => void;
|
|
onSendMessage?: (name: string) => void;
|
|
onOpenProfile?: (name: string) => void;
|
|
onCreateTask?: (owner: string) => void;
|
|
onOpenTask?: (taskId: string) => void;
|
|
}): React.JSX.Element => {
|
|
const memberName =
|
|
node.domainRef.kind === 'member' || node.domainRef.kind === 'lead'
|
|
? node.domainRef.memberName
|
|
: 'team-lead';
|
|
const teamName =
|
|
node.domainRef.kind === 'member' || node.domainRef.kind === 'lead'
|
|
? node.domainRef.teamName
|
|
: '';
|
|
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
|
|
const { teamData, spawnEntry, leadActivity, progress, memberSpawnSnapshot, memberSpawnStatuses } =
|
|
useGraphMemberPopoverContext(teamName, memberName);
|
|
const member = teamData?.members.find((candidate) => candidate.name === memberName) ?? null;
|
|
const provisioningPresentation =
|
|
teamData && teamName
|
|
? buildTeamProvisioningPresentation({
|
|
progress,
|
|
members: teamData.members,
|
|
memberSpawnStatuses,
|
|
memberSpawnSnapshot,
|
|
})
|
|
: null;
|
|
const launchPresentation = member
|
|
? buildMemberLaunchPresentation({
|
|
member,
|
|
spawnStatus: spawnEntry?.status,
|
|
spawnLaunchState: spawnEntry?.launchState,
|
|
spawnLivenessSource: spawnEntry?.livenessSource,
|
|
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
|
runtimeAdvisory: member.runtimeAdvisory,
|
|
isLaunchSettling: provisioningPresentation?.hasMembersStillJoining ?? false,
|
|
isTeamAlive: teamData?.isAlive,
|
|
isTeamProvisioning: provisioningPresentation?.isActive ?? false,
|
|
leadActivity: node.kind === 'lead' ? leadActivity : undefined,
|
|
})
|
|
: null;
|
|
const fallbackSpawnStatusLabel =
|
|
node.spawnStatus && node.spawnStatus !== 'online'
|
|
? node.spawnStatus === 'waiting'
|
|
? 'waiting to start'
|
|
: node.spawnStatus === 'spawning'
|
|
? 'starting'
|
|
: node.spawnStatus === 'error'
|
|
? 'failed'
|
|
: node.spawnStatus
|
|
: null;
|
|
const statusLabel =
|
|
launchPresentation?.launchStatusLabel ??
|
|
node.launchStatusLabel ??
|
|
launchPresentation?.presenceLabel ??
|
|
fallbackSpawnStatusLabel ??
|
|
(node.state === 'active'
|
|
? 'active'
|
|
: node.state === 'idle'
|
|
? 'idle'
|
|
: node.state === 'terminated'
|
|
? 'offline'
|
|
: node.state === 'tool_calling'
|
|
? 'running tool'
|
|
: node.state);
|
|
const statusDotClass =
|
|
launchPresentation?.dotClass ??
|
|
(node.spawnStatus === 'spawning'
|
|
? 'bg-amber-400'
|
|
: node.spawnStatus === 'waiting'
|
|
? 'bg-zinc-400 animate-pulse'
|
|
: node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling'
|
|
? 'bg-emerald-400'
|
|
: node.state === 'idle'
|
|
? 'bg-zinc-400'
|
|
: node.state === 'error'
|
|
? 'bg-red-400'
|
|
: 'bg-zinc-600');
|
|
const showExceptionBadge = node.exceptionLabel && node.exceptionLabel !== statusLabel;
|
|
|
|
return (
|
|
<div className="min-w-[200px] max-w-[280px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
|
|
{/* Header: avatar + name */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative shrink-0">
|
|
<img
|
|
src={avatarSrc}
|
|
alt={memberName}
|
|
className="size-10 rounded-full border border-[var(--color-border)]"
|
|
/>
|
|
<div
|
|
className={`absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-[var(--color-surface-raised)] ${statusDotClass}`}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div
|
|
className="truncate text-sm font-semibold text-[var(--color-text)]"
|
|
style={{ color: node.color }}
|
|
>
|
|
{node.label.split(' · ')[0]}
|
|
</div>
|
|
{node.role && (
|
|
<div className="truncate text-xs text-[var(--color-text-muted)]">{node.role}</div>
|
|
)}
|
|
{node.runtimeLabel && (
|
|
<div className="truncate text-[11px] text-[var(--color-text-muted)]">
|
|
{node.runtimeLabel}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status badges */}
|
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
|
|
{statusLabel}
|
|
</Badge>
|
|
{node.kind === 'lead' && (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-blue-500/30 px-1.5 py-0 text-[10px] text-blue-400"
|
|
>
|
|
Lead
|
|
</Badge>
|
|
)}
|
|
{(launchPresentation?.spawnBadgeLabel ?? fallbackSpawnStatusLabel) &&
|
|
(launchPresentation?.spawnBadgeLabel ?? fallbackSpawnStatusLabel) !== statusLabel && (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-amber-500/30 px-1.5 py-0 text-[10px] text-amber-400"
|
|
>
|
|
{launchPresentation?.spawnBadgeLabel ?? fallbackSpawnStatusLabel}
|
|
</Badge>
|
|
)}
|
|
{showExceptionBadge && (
|
|
<Badge
|
|
variant="outline"
|
|
className={`px-1.5 py-0 text-[10px] ${
|
|
node.exceptionTone === 'error'
|
|
? 'border-red-500/30 text-red-400'
|
|
: 'border-amber-500/30 text-amber-400'
|
|
}`}
|
|
>
|
|
{node.exceptionLabel}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* Context usage stays hidden for now because lead context telemetry is still incomplete. */}
|
|
|
|
{/* Current task indicator — reuses same pattern as MemberCard */}
|
|
{node.currentTaskId && node.currentTaskSubject && (
|
|
<div className="mt-2 flex items-center gap-1.5 text-[10px]">
|
|
<Loader2
|
|
className="size-3 shrink-0 animate-spin"
|
|
style={{ color: node.color ?? '#66ccff' }}
|
|
/>
|
|
<span className="shrink-0 text-[var(--color-text-muted)]">working on</span>
|
|
<button
|
|
type="button"
|
|
className="min-w-0 truncate rounded px-1.5 py-0.5 font-medium text-[var(--color-text)] transition-opacity hover:opacity-90"
|
|
style={{ border: `1px solid ${node.color ?? '#66ccff'}40` }}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onOpenTask?.(node.currentTaskId!);
|
|
onClose();
|
|
}}
|
|
>
|
|
{node.currentTaskSubject.length > 30
|
|
? `${node.currentTaskSubject.slice(0, 30)}…`
|
|
: node.currentTaskSubject}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{node.activeTool && (
|
|
<div className="mt-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-secondary)] px-2 py-1.5 text-[10px]">
|
|
<div className="flex items-center gap-1.5">
|
|
<Loader2
|
|
className={`size-3 shrink-0 ${node.activeTool.state === 'running' ? 'animate-spin' : ''}`}
|
|
style={{
|
|
color:
|
|
node.activeTool.state === 'error'
|
|
? '#ef4444'
|
|
: node.activeTool.state === 'complete'
|
|
? '#22c55e'
|
|
: (node.color ?? '#66ccff'),
|
|
}}
|
|
/>
|
|
<span className="font-medium text-[var(--color-text)]">
|
|
{node.activeTool.state === 'running'
|
|
? 'Running tool'
|
|
: node.activeTool.state === 'error'
|
|
? 'Tool failed'
|
|
: 'Tool finished'}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 font-mono text-[var(--color-text-muted)]">
|
|
{node.activeTool.preview
|
|
? `${node.activeTool.name}: ${node.activeTool.preview}`
|
|
: node.activeTool.name}
|
|
</div>
|
|
{node.activeTool.resultPreview && node.activeTool.state !== 'running' && (
|
|
<div className="mt-1 text-[var(--color-text-muted)]">
|
|
{node.activeTool.resultPreview}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{node.recentTools && node.recentTools.length > 0 && (
|
|
<div className="mt-2">
|
|
<div className="mb-1 text-[10px] font-medium text-[var(--color-text-muted)]">
|
|
Recent tools
|
|
</div>
|
|
<div className="space-y-1">
|
|
{node.recentTools.slice(0, 5).map((tool) => {
|
|
const shortName = formatToolName(tool.name);
|
|
const shortPreview = formatToolPreview(tool.preview);
|
|
return (
|
|
<div
|
|
key={`${tool.name}:${tool.finishedAt}:${tool.startedAt}`}
|
|
className="flex items-center gap-1.5 rounded border border-[var(--color-border)] bg-[var(--color-surface-secondary)] px-2 py-1 text-[10px]"
|
|
>
|
|
<span
|
|
className="size-1.5 shrink-0 rounded-full"
|
|
style={{ background: tool.state === 'error' ? '#ef4444' : '#22c55e' }}
|
|
/>
|
|
<span className="font-mono font-medium text-[var(--color-text)]">
|
|
{shortName}
|
|
</span>
|
|
{shortPreview && (
|
|
<span className="truncate text-[var(--color-text-muted)]">{shortPreview}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 gap-1 px-2 text-xs"
|
|
onClick={() => {
|
|
onSendMessage?.(memberName);
|
|
onClose();
|
|
}}
|
|
>
|
|
<MessageSquare size={12} /> Message
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 gap-1 px-2 text-xs"
|
|
onClick={() => {
|
|
onOpenProfile?.(memberName);
|
|
onClose();
|
|
}}
|
|
>
|
|
<User size={12} /> Profile
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 gap-1 px-2 text-xs"
|
|
onClick={() => {
|
|
onCreateTask?.(memberName);
|
|
onClose();
|
|
}}
|
|
>
|
|
<Plus size={12} /> Task
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|