diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index e172af8b..d586eea9 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -8,7 +8,12 @@ */ import { getUnreadCount } from '@renderer/services/commentReadStorage'; -import { agentAvatarUrl, getMemberRuntimeAdvisoryLabel } from '@renderer/utils/memberHelpers'; +import { + agentAvatarUrl, + buildMemberLaunchPresentation, + getMemberRuntimeAdvisoryLabel, +} from '@renderer/utils/memberHelpers'; +import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary'; import { stripCrossTeamPrefix } from '@shared/constants/crossTeam'; import { @@ -19,6 +24,10 @@ import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isLeadMember } from '@shared/utils/leadDetection'; import { collapseOverflowStacksWithMeta } from '../utils/collapseOverflowStacks'; +import { + buildInlineActivityEntries, + getGraphLeadMemberName, +} from '../utils/buildInlineActivityEntries'; import { isTaskBlocked, isTaskInReviewCycle, @@ -27,6 +36,7 @@ import { import type { GraphDataPort, + GraphActivityItem, GraphEdge, GraphNode, GraphNodeState, @@ -37,6 +47,8 @@ import type { InboxMessage, LeadActivityState, MemberSpawnStatusEntry, + MemberSpawnStatusesSnapshot, + TeamProvisioningProgress, TeamData, } from '@shared/types/team'; import type { LeadContextUsage } from '@shared/types/team'; @@ -76,7 +88,9 @@ export class TeamGraphAdapter { activeTools?: Record>, finishedVisible?: Record>, toolHistory?: Record, - commentReadState?: Record + commentReadState?: Record, + provisioningProgress?: TeamProvisioningProgress | null, + memberSpawnSnapshot?: MemberSpawnStatusesSnapshot ): GraphDataPort { if (teamData?.teamName !== teamName) { return TeamGraphAdapter.#emptyResult(teamName); @@ -101,6 +115,14 @@ export class TeamGraphAdapter { const leadId = `lead:${teamName}`; const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName); + const provisioningPresentation = buildTeamProvisioningPresentation({ + progress: provisioningProgress, + members: teamData.members, + memberSpawnStatuses: spawnStatuses, + memberSpawnSnapshot, + }); + const isTeamProvisioning = provisioningPresentation?.isActive ?? false; + const isLaunchSettling = provisioningPresentation?.hasMembersStillJoining ?? false; this.#buildLeadNode( nodes, @@ -113,7 +135,8 @@ export class TeamGraphAdapter { leadContext, activeTools, finishedVisible, - toolHistory + toolHistory, + isTeamProvisioning ); this.#buildMemberNodes( nodes, @@ -125,10 +148,13 @@ export class TeamGraphAdapter { pendingApprovalAgents, activeTools, finishedVisible, - toolHistory + toolHistory, + isTeamProvisioning, + isLaunchSettling ); this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState); this.#buildProcessNodes(nodes, edges, teamData, teamName); + this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName); this.#buildMessageParticles( particles, nodes, @@ -166,7 +192,7 @@ export class TeamGraphAdapter { // ─── Private: node builders ────────────────────────────────────────────── static #getLeadMemberName(data: TeamData, teamName: string): string { - return data.members.find((member) => isLeadMember(member))?.name ?? `${teamName}-lead`; + return getGraphLeadMemberName(data, teamName); } static #isBeforeParticleCutoff(timestamp: string | undefined, cutoffMs: number | null): boolean { @@ -209,7 +235,8 @@ export class TeamGraphAdapter { leadContext?: LeadContextUsage, activeTools?: Record>, finishedVisible?: Record>, - toolHistory?: Record + toolHistory?: Record, + isTeamProvisioning = false ): void { const percent = leadContext?.percent; const leadMember = data.members.find((member) => member.name === leadName); @@ -220,6 +247,20 @@ export class TeamGraphAdapter { const hasRunningTool = Object.keys(activeTools?.[leadName] ?? {}).length > 0; const pendingApproval = pendingApprovalAgents?.has(leadName) || pendingApprovalAgents?.has('lead') || false; + const leadLaunchPresentation = leadMember + ? buildMemberLaunchPresentation({ + member: leadMember, + spawnStatus: undefined, + spawnLaunchState: undefined, + spawnLivenessSource: undefined, + spawnRuntimeAlive: undefined, + runtimeAdvisory: leadMember.runtimeAdvisory, + isLaunchSettling: false, + isTeamAlive: data.isAlive, + isTeamProvisioning, + leadActivity, + }) + : null; const leadState = leadActivity === 'offline' ? 'terminated' @@ -245,6 +286,7 @@ export class TeamGraphAdapter { leadMember?.model, leadMember?.effort ), + launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined, contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined, avatarUrl: agentAvatarUrl(leadName, 64), pendingApproval, @@ -286,7 +328,9 @@ export class TeamGraphAdapter { pendingApprovalAgents?: Set, activeTools?: Record>, finishedVisible?: Record>, - toolHistory?: Record + toolHistory?: Record, + isTeamProvisioning = false, + isLaunchSettling = false ): void { for (const member of data.members) { if (member.removedAt) continue; @@ -305,6 +349,17 @@ export class TeamGraphAdapter { spawn, pendingApprovalAgents?.has(member.name) ?? false ); + const launchPresentation = buildMemberLaunchPresentation({ + member, + spawnStatus: spawn?.status, + spawnLaunchState: spawn?.launchState, + spawnLivenessSource: spawn?.livenessSource, + spawnRuntimeAlive: spawn?.runtimeAlive, + runtimeAdvisory: member.runtimeAdvisory, + isLaunchSettling, + isTeamAlive: data.isAlive, + isTeamProvisioning, + }); nodes.push({ id: memberId, @@ -321,6 +376,7 @@ export class TeamGraphAdapter { member.effort ), spawnStatus: spawn?.status, + launchVisualState: launchPresentation.launchVisualState ?? undefined, avatarUrl: agentAvatarUrl(member.name, 64), currentTaskId: member.currentTaskId ?? undefined, currentTaskSubject: member.currentTaskId @@ -579,6 +635,44 @@ export class TeamGraphAdapter { } } + #attachActivityFeeds( + nodes: GraphNode[], + data: TeamData, + teamName: string, + leadId: string, + leadName: string + ): void { + const ownerNodeIds = new Set(); + + for (const node of nodes) { + if (node.kind !== 'lead' && node.kind !== 'member') { + continue; + } + ownerNodeIds.add(node.id); + node.activityItems = []; + node.activityOverflowCount = 0; + } + + const entriesByOwnerNodeId = buildInlineActivityEntries({ + data, + teamName, + leadId, + leadName, + ownerNodeIds, + }); + + for (const node of nodes) { + if (node.kind !== 'lead' && node.kind !== 'member') { + continue; + } + const activityItems = (entriesByOwnerNodeId.get(node.id) ?? []).map( + (entry) => entry.graphItem + ); + node.activityItems = activityItems; + node.activityOverflowCount = Math.max(0, activityItems.length - 3); + } + } + #buildMessageParticles( particles: GraphParticle[], nodes: GraphNode[], diff --git a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts index 0d250c6d..2b160983 100644 --- a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts @@ -7,7 +7,10 @@ import { useMemo, useRef, useSyncExternalStore } from 'react'; import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; -import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; +import { + getCurrentProvisioningProgressForTeam, + selectTeamDataForName, +} from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; import { TeamGraphAdapter } from './TeamGraphAdapter'; @@ -26,6 +29,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { activeTools, finishedVisible, toolHistory, + provisioningProgress, + memberSpawnSnapshot, } = useStore( useShallow((s) => ({ teamData: selectTeamDataForName(s, teamName), @@ -36,6 +41,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { activeTools: teamName ? s.activeToolsByTeam[teamName] : undefined, finishedVisible: teamName ? s.finishedVisibleByTeam[teamName] : undefined, toolHistory: teamName ? s.toolHistoryByTeam[teamName] : undefined, + provisioningProgress: teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null, + memberSpawnSnapshot: teamName ? s.memberSpawnSnapshotsByTeam[teamName] : undefined, })) ); @@ -63,7 +70,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { activeTools, finishedVisible, toolHistory, - commentReadState + commentReadState, + provisioningProgress, + memberSpawnSnapshot ), [ teamData, @@ -76,6 +85,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { finishedVisible, toolHistory, commentReadState, + provisioningProgress, + memberSpawnSnapshot, ] ); } diff --git a/src/renderer/features/agent-graph/ui/GraphActivityHud.tsx b/src/renderer/features/agent-graph/ui/GraphActivityHud.tsx new file mode 100644 index 00000000..4c6ef638 --- /dev/null +++ b/src/renderer/features/agent-graph/ui/GraphActivityHud.tsx @@ -0,0 +1,278 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; +import { + buildMessageContext, + resolveMessageRenderProps, +} from '@renderer/components/team/activity/activityMessageContext'; +import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog'; +import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; +import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; +import { useStore } from '@renderer/store'; +import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; +import { toMessageKey } from '@renderer/utils/teamMessageKey'; +import { useShallow } from 'zustand/react/shallow'; + +import { + buildInlineActivityEntries, + getGraphLeadMemberName, + type InlineActivityEntry, +} from '../utils/buildInlineActivityEntries'; + +import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup'; +import type { GraphNode } from '@claude-teams/agent-graph'; +import type { ResolvedTeamMember } from '@shared/types/team'; + +interface GraphActivityHudProps { + teamName: string; + nodes: GraphNode[]; + getActivityAnchorScreenPlacement: ( + ownerNodeId: string + ) => { x: number; y: number; scale: number; visible: boolean } | null; + focusNodeIds: ReadonlySet | null; + enabled?: boolean; + onOpenTaskDetail?: (taskId: string) => void; + onOpenMemberProfile?: (memberName: string) => void; +} + +export function GraphActivityHud({ + teamName, + nodes, + getActivityAnchorScreenPlacement, + focusNodeIds, + enabled = true, + onOpenTaskDetail, + onOpenMemberProfile, +}: GraphActivityHudProps): React.JSX.Element | null { + const shellRefs = useRef(new Map()); + const [expandedItem, setExpandedItem] = useState(null); + const { teamData, teams } = useStore( + useShallow((state) => ({ + teamData: selectTeamDataForName(state, teamName), + teams: state.teams, + })) + ); + + const ownerNodes = useMemo( + () => + nodes.filter( + (node): node is GraphNode & { kind: 'lead' | 'member' } => + node.kind === 'lead' || node.kind === 'member' + ), + [nodes] + ); + const leadNodeId = ownerNodes.find((node) => node.kind === 'lead')?.id ?? `lead:${teamName}`; + const leadName = teamData ? getGraphLeadMemberName(teamData, teamName) : `${teamName}-lead`; + const ownerNodeIds = useMemo(() => new Set(ownerNodes.map((node) => node.id)), [ownerNodes]); + const entryMapByOwnerNodeId = useMemo(() => { + if (!teamData) { + return new Map(); + } + return buildInlineActivityEntries({ + data: teamData, + teamName, + leadId: leadNodeId, + leadName, + ownerNodeIds, + }); + }, [leadName, leadNodeId, ownerNodeIds, teamData, teamName]); + const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]); + const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams); + const { readSet } = useTeamMessagesRead(teamName); + + useEffect(() => { + setExpandedItem(null); + }, [teamName]); + + const visibleLanes = useMemo(() => { + return ownerNodes + .map((node) => { + const graphItems = node.activityItems ?? []; + const overflowCount = node.activityOverflowCount ?? 0; + const visibleCount = Math.max(0, graphItems.length - overflowCount); + const visibleGraphItems = graphItems.slice(0, visibleCount); + const entriesById = new Map( + (entryMapByOwnerNodeId.get(node.id) ?? []).map( + (entry) => [entry.graphItem.id, entry] as const + ) + ); + const entries = visibleGraphItems + .map((item) => entriesById.get(item.id)) + .filter((entry): entry is NonNullable => Boolean(entry)); + + return { + node, + entries, + overflowCount, + }; + }) + .filter((lane) => lane.entries.length > 0 || lane.overflowCount > 0); + }, [entryMapByOwnerNodeId, ownerNodes]); + + useLayoutEffect(() => { + if (!enabled || visibleLanes.length === 0) { + for (const shell of shellRefs.current.values()) { + if (shell) { + shell.style.opacity = '0'; + } + } + return; + } + + let frameId = 0; + const updatePositions = (): void => { + for (const lane of visibleLanes) { + const shell = shellRefs.current.get(lane.node.id); + if (!shell) { + continue; + } + + const placement = getActivityAnchorScreenPlacement(lane.node.id); + if (!placement || !placement.visible) { + shell.style.opacity = '0'; + continue; + } + + const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1; + shell.style.opacity = String(baseOpacity); + shell.style.transform = `translate(${Math.round(placement.x)}px, ${Math.round(placement.y)}px) scale(${placement.scale.toFixed(3)})`; + } + + frameId = window.requestAnimationFrame(updatePositions); + }; + + updatePositions(); + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [enabled, focusNodeIds, getActivityAnchorScreenPlacement, visibleLanes]); + + const expandedItemsByKey = useMemo(() => { + const items = new Map(); + for (const lane of visibleLanes) { + for (const entry of lane.entries) { + const key = toMessageKey(entry.message); + items.set(key, { type: 'message', message: entry.message }); + } + } + return items; + }, [visibleLanes]); + + const handleExpandItem = useCallback( + (key: string) => { + const next = expandedItemsByKey.get(key); + if (next) { + setExpandedItem(next); + } + }, + [expandedItemsByKey] + ); + + const handleMessageClick = useCallback((item: TimelineItem) => { + setExpandedItem(item); + }, []); + + const handleMemberNameClick = useCallback( + (memberName: string) => { + onOpenMemberProfile?.(memberName); + }, + [onOpenMemberProfile] + ); + + const handleMemberClick = useCallback( + (member: ResolvedTeamMember) => { + onOpenMemberProfile?.(member.name); + }, + [onOpenMemberProfile] + ); + + if (!enabled || !teamData || visibleLanes.length === 0) { + return null; + } + + return ( + <> + {visibleLanes.map((lane) => ( +
{ + shellRefs.current.set(lane.node.id, element); + }} + className="pointer-events-auto absolute z-10 w-[296px] origin-top-left opacity-0" + > +
+ Activity +
+
+ {lane.entries.map((entry, index) => { + const messageKey = toMessageKey(entry.message); + const renderProps = resolveMessageRenderProps(entry.message, messageContext); + const timelineItem: TimelineItem = { type: 'message', message: entry.message }; + const isUnread = !entry.message.read && !readSet.has(messageKey); + + return ( +
handleMessageClick(timelineItem)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleMessageClick(timelineItem); + } + }} + > + +
+ ); + })} + + {lane.overflowCount > 0 ? ( +
+ +{lane.overflowCount} more +
+ ) : null} +
+
+ ))} + + { + if (!open) { + setExpandedItem(null); + } + }} + teamName={teamName} + members={teamData.members} + onMemberClick={handleMemberClick} + onTaskIdClick={onOpenTaskDetail} + teamNames={teamNames} + teamColorByName={teamColorByName} + /> + + ); +} diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx index e6450ea2..746d8565 100644 --- a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -7,12 +7,17 @@ import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { useStore } from '@renderer/store'; -import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; -import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; +import { + getCurrentProvisioningProgressForTeam, + selectTeamDataForName, +} from '@renderer/store/slices/teamSlice'; +import { agentAvatarUrl, buildMemberLaunchPresentation } from '@renderer/utils/memberHelpers'; +import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; -import { GraphTaskCard } from './GraphTaskCard'; import { isTaskInReviewCycle, resolveTaskReviewer } from '../utils/taskGraphSemantics'; +import { GraphTaskCard } from './GraphTaskCard'; import type { GraphNode } from '@claude-teams/agent-graph'; import type { TeamTaskWithKanban } from '@shared/types'; @@ -48,13 +53,6 @@ function formatToolPreview(preview: string | undefined): string | undefined { return preview.length > 50 ? preview.slice(0, 50) + '...' : preview; } -function getSpawnStatusBadgeLabel(spawnStatus: GraphNode['spawnStatus']): string { - if (spawnStatus === 'waiting' || spawnStatus === 'spawning') { - return 'starting'; - } - return spawnStatus ?? ''; -} - interface GraphNodePopoverProps { node: GraphNode; teamName: string; @@ -281,26 +279,78 @@ const MemberPopoverContent = ({ 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 } = + useStore( + useShallow((state) => ({ + teamData: teamName ? selectTeamDataForName(state, teamName) : null, + spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined, + leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined, + progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null, + memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined, + memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined, + })) + ); + 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' || node.spawnStatus === 'spawning' + ? 'starting' + : node.spawnStatus + : null; const statusLabel = - node.state === 'active' - ? 'Active' + launchPresentation?.presenceLabel ?? + fallbackSpawnStatusLabel ?? + (node.state === 'active' + ? 'active' : node.state === 'idle' - ? 'Idle' + ? 'idle' : node.state === 'terminated' - ? 'Offline' + ? 'offline' : node.state === 'tool_calling' - ? 'Running tool' - : node.state; - - const statusDotColor = - 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'; + ? '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 (
@@ -313,7 +363,7 @@ const MemberPopoverContent = ({ className="size-10 rounded-full border border-[var(--color-border)]" />
@@ -347,15 +397,16 @@ const MemberPopoverContent = ({ Lead )} - {node.spawnStatus && node.spawnStatus !== 'online' && ( - - {getSpawnStatusBadgeLabel(node.spawnStatus)} - - )} - {node.exceptionLabel && ( + {(launchPresentation?.spawnBadgeLabel ?? fallbackSpawnStatusLabel) && + (launchPresentation?.spawnBadgeLabel ?? fallbackSpawnStatusLabel) !== statusLabel && ( + + {launchPresentation?.spawnBadgeLabel ?? fallbackSpawnStatusLabel} + + )} + {showExceptionBadge && ( ({ key: step.key, label: step.label })); +const HUD_STEPPER_STYLE: CSSProperties = { + ['--stepper-done' as string]: '#22c55e', + ['--stepper-done-glow' as string]: 'rgba(34, 197, 94, 0.24)', + ['--stepper-current' as string]: '#22c55e', + ['--stepper-current-ring' as string]: 'rgba(34, 197, 94, 0.18)', + ['--stepper-pending' as string]: 'rgba(148, 163, 184, 0.08)', + ['--stepper-pending-text' as string]: '#cbd5e1', + ['--stepper-pending-border' as string]: 'rgba(148, 163, 184, 0.2)', + ['--stepper-line' as string]: 'rgba(148, 163, 184, 0.14)', + ['--stepper-line-done' as string]: '#22c55e', + ['--stepper-label' as string]: '#94a3b8', + ['--stepper-label-active' as string]: '#e2e8f0', + ['--stepper-error' as string]: '#ef4444', + ['--stepper-error-glow' as string]: 'rgba(239, 68, 68, 0.22)', + ['--stepper-label-error' as string]: '#fca5a5', +}; + +function shouldRenderLaunchHud(presentation: TeamProvisioningPresentation | null): boolean { + return presentation != null; +} + +function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): { + border: string; + badge: string; + icon: React.ReactNode; + iconClassName: string; +} { + switch (tone) { + case 'error': + return { + border: 'border-red-400/35 bg-[rgba(26,10,16,0.92)]', + badge: 'border-red-500/30 text-red-300', + icon: , + iconClassName: 'text-red-400', + }; + case 'warning': + return { + border: 'border-amber-400/35 bg-[rgba(31,18,8,0.92)]', + badge: 'border-amber-500/30 text-amber-200', + icon: , + iconClassName: 'text-amber-400', + }; + case 'success': + return { + border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.92)]', + badge: 'border-emerald-500/30 text-emerald-200', + icon: , + iconClassName: 'text-emerald-400', + }; + default: + return { + border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.92)]', + badge: 'border-cyan-500/20 text-cyan-200', + icon: , + iconClassName: 'text-cyan-300', + }; + } +} + +export interface GraphProvisioningHudProps { + teamName: string; + leadNodeId: string | null; + getLaunchAnchorScreenPlacement: ( + leadNodeId: string + ) => { x: number; y: number; scale: number; visible: boolean } | null; + enabled?: boolean; +} + +export function GraphProvisioningHud({ + teamName, + leadNodeId, + getLaunchAnchorScreenPlacement, + enabled = true, +}: GraphProvisioningHudProps): React.JSX.Element | null { + const { presentation, runInstanceKey } = useTeamProvisioningPresentation(teamName); + const shellRef = useRef(null); + const lastActiveStepRef = useRef(-1); + const [detailsOpen, setDetailsOpen] = useState(false); + const [dismissed, setDismissed] = useState(false); + const shouldRender = + enabled && shouldRenderLaunchHud(presentation) && !dismissed && Boolean(leadNodeId); + const tone = presentation ? getToneClasses(presentation.compactTone) : null; + const errorStepIndex = presentation?.isFailed + ? lastActiveStepRef.current >= 0 + ? lastActiveStepRef.current + : 0 + : undefined; + + useEffect(() => { + setDetailsOpen(false); + setDismissed(false); + lastActiveStepRef.current = -1; + }, [runInstanceKey, teamName]); + + useEffect(() => { + if (!shouldRender || !leadNodeId) { + setDetailsOpen(false); + } + }, [leadNodeId, shouldRender]); + + useEffect(() => { + if (presentation && !presentation.isFailed && presentation.currentStepIndex >= 0) { + lastActiveStepRef.current = presentation.currentStepIndex; + } + }, [presentation]); + + useLayoutEffect(() => { + if (!shouldRender || !leadNodeId) { + return; + } + let frameId = 0; + const updatePosition = (): void => { + const shell = shellRef.current; + if (!shell) { + frameId = window.requestAnimationFrame(updatePosition); + return; + } + const placement = getLaunchAnchorScreenPlacement(leadNodeId); + if (!placement) { + shell.style.opacity = '0'; + frameId = window.requestAnimationFrame(updatePosition); + return; + } + + if (!placement.visible) { + shell.style.opacity = '0'; + frameId = window.requestAnimationFrame(updatePosition); + return; + } + + shell.style.opacity = '1'; + shell.style.transform = `translate(${Math.round(placement.x)}px, ${Math.round(placement.y)}px) scale(${placement.scale.toFixed(3)})`; + frameId = window.requestAnimationFrame(updatePosition); + }; + + updatePosition(); + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [getLaunchAnchorScreenPlacement, leadNodeId, shouldRender]); + + const compactLabel = useMemo(() => { + if (!presentation?.compactDetail) { + return null; + } + return presentation.compactDetail.length > 88 + ? `${presentation.compactDetail.slice(0, 88)}...` + : presentation.compactDetail; + }, [presentation?.compactDetail]); + + if (!shouldRender || !presentation || !tone) { + return null; + } + + return ( +
+
+
+
+
+ {tone.icon} +
+ {presentation.compactTitle} +
+ + {presentation.isFailed + ? 'Issue' + : presentation.hasMembersStillJoining + ? 'Joining' + : presentation.isActive + ? 'Live' + : 'Ready'} + +
+ {compactLabel ? ( +
{compactLabel}
+ ) : null} +
+
+ + +
+
+ + +
+ + + + + Launch details + + Detailed team launch progress, live output and CLI logs. + + +
+ +
+
+
+
+ ); +} diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx index 2a897eeb..31ca16e12 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -11,7 +11,9 @@ import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHo import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; +import { GraphActivityHud } from './GraphActivityHud'; import { GraphNodePopover } from './GraphNodePopover'; +import { GraphProvisioningHud } from './GraphProvisioningHud'; import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; @@ -33,6 +35,10 @@ export const TeamGraphOverlay = ({ onOpenMemberProfile, }: TeamGraphOverlayProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); + const leadNodeId = useMemo( + () => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null, + [graphData.nodes] + ); // Task action dispatchers (same pattern as TeamGraphTab) const dispatchTaskAction = useCallback( @@ -85,6 +91,27 @@ export const TeamGraphOverlay = ({ onRequestClose={onClose} onRequestPinAsTab={onPinAsTab} className="team-graph-view min-w-0 flex-1" + renderHud={({ + getLaunchAnchorScreenPlacement, + getActivityAnchorScreenPlacement, + focusNodeIds, + }) => ( + <> + + + + )} renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose: closeEdge, onSelectNode }) => ( { const graphData = useTeamGraphAdapter(teamName); + const leadNodeId = useMemo( + () => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null, + [graphData.nodes] + ); const [fullscreen, setFullscreen] = useState(false); // Typed event dispatchers (DRY — used in both events + renderOverlay) @@ -117,6 +123,29 @@ export const TeamGraphTab = ({ className="team-graph-view size-full" suspendAnimation={!isActive} onRequestFullscreen={() => setFullscreen(true)} + renderHud={({ + getLaunchAnchorScreenPlacement, + getActivityAnchorScreenPlacement, + focusNodeIds, + }) => ( + <> + + + + )} renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose, onSelectNode }) => ( ; +} + +export function getGraphLeadMemberName(data: TeamData, teamName: string): string { + return data.members.find((member) => isLeadMember(member))?.name ?? `${teamName}-lead`; +} + +export function buildInlineActivityEntries({ + data, + teamName, + leadId, + leadName, + ownerNodeIds, +}: BuildInlineActivityEntriesArgs): Map { + const entriesByOwnerNodeId = new Map(); + + const appendEntry = (entry: InlineActivityEntry): void => { + const targetOwnerNodeId = ownerNodeIds.has(entry.ownerNodeId) ? entry.ownerNodeId : leadId; + const ownerEntries = entriesByOwnerNodeId.get(targetOwnerNodeId); + if (ownerEntries) { + ownerEntries.push(entry); + } else { + entriesByOwnerNodeId.set(targetOwnerNodeId, [entry]); + } + }; + + for (const ownerNodeId of ownerNodeIds) { + entriesByOwnerNodeId.set(ownerNodeId, []); + } + + const orderedMessages = [...data.messages].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + for (const message of orderedMessages) { + if (message.summary?.startsWith('Comment on ')) { + continue; + } + + const idleLabel = getIdleGraphLabel(message.text ?? ''); + if (idleLabel === 'idle') { + continue; + } + if (!idleLabel && isInboxNoiseMessage(message.text ?? '')) { + continue; + } + + const ownerNodeId = resolveMessageOwnerNodeId({ + message, + teamName, + leadId, + leadName, + ownerNodeIds, + }); + if (!ownerNodeId) { + continue; + } + + const crossTeamPreview = + message.source === 'cross_team' || message.source === 'cross_team_sent' + ? (message.summary ?? stripCrossTeamPrefix(message.text ?? '')).replace( + /^\[cross-team\]\s*/i, + '' + ) + : undefined; + const previewSource = + message.source === 'cross_team' || message.source === 'cross_team_sent' + ? crossTeamPreview + : (message.summary ?? message.text); + const graphItem: GraphActivityItem = { + id: `activity:msg:${teamName}:${getActivityMessageKey(message)}`, + kind: 'inbox_message', + timestamp: message.timestamp, + title: buildActivityMessageTitle(message, leadName), + preview: idleLabel ?? buildActivityPreview(previewSource), + authorLabel: buildParticipantLabel(message.from, leadName), + }; + + appendEntry({ + ownerNodeId, + graphItem, + message, + }); + } + + const orderedComments = [...collectTaskComments(data.tasks)].sort((a, b) => + a.comment.createdAt.localeCompare(b.comment.createdAt) + ); + for (const item of orderedComments) { + const ownerNodeId = resolveCommentOwnerNodeId({ + taskOwner: item.task.owner, + author: item.comment.author, + teamName, + leadId, + leadName, + ownerNodeIds, + }); + if (!ownerNodeId) { + continue; + } + + const taskLabel = item.task.displayId ?? `#${item.task.id.slice(0, 6)}`; + const preview = buildActivityPreview(item.comment.text); + const graphItem: GraphActivityItem = { + id: `activity:comment:${teamName}:${item.task.id}:${item.comment.id}`, + kind: 'task_comment', + timestamp: item.comment.createdAt, + title: `${taskLabel} ${item.task.subject}`.trim(), + preview, + taskId: item.task.id, + taskDisplayId: item.task.displayId ?? undefined, + authorLabel: item.comment.author, + }; + + appendEntry({ + ownerNodeId, + graphItem, + message: buildCommentActivityMessage({ + teamName, + leadName, + task: item.task, + comment: item.comment, + }), + }); + } + + for (const [ownerNodeId, entries] of entriesByOwnerNodeId) { + entriesByOwnerNodeId.set( + ownerNodeId, + entries.sort((a, b) => b.graphItem.timestamp.localeCompare(a.graphItem.timestamp)) + ); + } + + return entriesByOwnerNodeId; +} + +function collectTaskComments( + tasks: readonly TeamTaskWithKanban[] +): Array<{ task: TeamTaskWithKanban; comment: TaskComment }> { + const items: Array<{ task: TeamTaskWithKanban; comment: TaskComment }> = []; + for (const task of tasks) { + for (const comment of task.comments ?? []) { + items.push({ task, comment }); + } + } + return items; +} + +function resolveMessageOwnerNodeId(args: { + message: InboxMessage; + teamName: string; + leadId: string; + leadName: string; + ownerNodeIds: ReadonlySet; +}): string | null { + const { message, teamName, leadId, leadName, ownerNodeIds } = args; + if (message.source === 'cross_team' || message.source === 'cross_team_sent') { + return leadId; + } + + const fromId = resolveParticipantId(message.from ?? '', teamName, leadId, leadName); + const toId = message.to ? resolveParticipantId(message.to, teamName, leadId, leadName) : leadId; + + if (toId !== leadId && ownerNodeIds.has(toId)) { + return toId; + } + if (fromId !== leadId && ownerNodeIds.has(fromId)) { + return fromId; + } + return ownerNodeIds.has(leadId) ? leadId : null; +} + +function resolveCommentOwnerNodeId(args: { + taskOwner: string | undefined; + author: string; + teamName: string; + leadId: string; + leadName: string; + ownerNodeIds: ReadonlySet; +}): string | null { + const { taskOwner, author, teamName, leadId, leadName, ownerNodeIds } = args; + if (taskOwner) { + const ownerId = resolveParticipantId(taskOwner, teamName, leadId, leadName); + if (ownerNodeIds.has(ownerId)) { + return ownerId; + } + } + + const authorId = resolveParticipantId(author, teamName, leadId, leadName); + if (ownerNodeIds.has(authorId)) { + return authorId; + } + return ownerNodeIds.has(leadId) ? leadId : null; +} + +function buildActivityMessageTitle(message: InboxMessage, leadName: string): string { + if (message.source === 'cross_team' || message.source === 'cross_team_sent') { + const externalTeam = extractExternalTeamName(message.from ?? '') ?? 'external'; + return message.source === 'cross_team_sent' + ? `${leadName} -> ${externalTeam}` + : `${externalTeam} -> ${leadName}`; + } + + const fromLabel = buildParticipantLabel(message.from, leadName); + const toLabel = buildParticipantLabel(message.to ?? leadName, leadName); + return `${fromLabel} -> ${toLabel}`; +} + +function buildCommentActivityMessage(args: { + teamName: string; + leadName: string; + task: TeamTaskWithKanban; + comment: TaskComment; +}): InboxMessage { + const { teamName, leadName, task, comment } = args; + const taskDisplayId = task.displayId ?? `#${task.id.slice(0, 6)}`; + const summaryPreview = buildActivityPreview(comment.text, 90) ?? task.subject; + const summary = `${taskDisplayId} ${summaryPreview}`.trim(); + const recipient = task.owner && task.owner !== comment.author ? task.owner : leadName; + + return { + from: comment.author, + to: recipient, + text: comment.text, + timestamp: comment.createdAt, + read: true, + summary, + messageId: `graph-activity-comment:${teamName}:${task.id}:${comment.id}`, + messageKind: 'task_comment_notification', + source: 'inbox', + taskRefs: buildTaskRefs(teamName, task), + attachments: mapCommentAttachments(comment.attachments), + }; +} + +function buildTaskRefs(teamName: string, task: TeamTaskWithKanban): TaskRef[] | undefined { + const displayId = task.displayId ?? `#${task.id.slice(0, 6)}`; + return [ + { + taskId: task.id, + displayId, + teamName, + }, + ]; +} + +function mapCommentAttachments( + attachments: TaskAttachmentMeta[] | undefined +): AttachmentMeta[] | undefined { + if (!attachments || attachments.length === 0) { + return undefined; + } + return attachments.map((attachment) => ({ + id: attachment.id, + filename: attachment.filename, + mimeType: attachment.mimeType, + size: attachment.size, + filePath: attachment.filePath ?? undefined, + })); +} + +function buildActivityPreview(text: string | undefined, max = 180): string | undefined { + const normalized = normalizeActivityText(text); + if (!normalized) { + return undefined; + } + return normalized.length > max + ? `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}…` + : normalized; +} + +function normalizeActivityText(text: string | undefined): string | undefined { + let normalized = text?.replace(/\s+/g, ' ').trim(); + if (!normalized) { + return normalized; + } + normalized = normalized.replace(/#[a-f0-9]{6,}\s*/gi, '').trim(); + normalized = normalized.replace(/\|/g, ' - '); + return normalized; +} + +function getActivityMessageKey(message: InboxMessage): string { + if (message.messageId && message.messageId.trim().length > 0) { + return message.messageId; + } + return [ + message.timestamp, + message.from ?? '', + message.to ?? '', + message.summary ?? '', + message.text ?? '', + ].join('\u0000'); +} + +function resolveParticipantId( + name: string, + teamName: string, + leadId: string, + leadName?: string +): string { + const normalized = name.trim().toLowerCase(); + if (normalized === 'user' || normalized === 'team-lead') { + return leadId; + } + if (normalized === leadName?.trim().toLowerCase()) { + return leadId; + } + return `member:${teamName}:${name}`; +} + +function buildParticipantLabel(name: string | undefined, leadName: string): string { + if (!name) { + return leadName; + } + const normalized = name.trim().toLowerCase(); + if ( + normalized === 'user' || + normalized === 'team-lead' || + normalized === leadName.trim().toLowerCase() + ) { + return leadName; + } + + const dotIndex = name.indexOf('.'); + if (dotIndex > 0 && dotIndex < name.length - 1) { + return name.slice(dotIndex + 1); + } + + return name; +} + +function extractExternalTeamName(from: string): string | null { + const dotIndex = from.indexOf('.'); + if (dotIndex <= 0) { + return null; + } + return from.slice(0, dotIndex); +} diff --git a/test/renderer/features/agent-graph/GraphNodePopover.test.ts b/test/renderer/features/agent-graph/GraphNodePopover.test.ts index 384d24ed..9a85ee15 100644 --- a/test/renderer/features/agent-graph/GraphNodePopover.test.ts +++ b/test/renderer/features/agent-graph/GraphNodePopover.test.ts @@ -136,6 +136,72 @@ describe('GraphNodePopover spawn badge labels', () => { }); }); + it('reuses launch-aware presence semantics from cached team data', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + useStore.setState({ + teamDataCacheByName: { + 'northstar-core': { + teamName: 'northstar-core', + config: { name: 'Northstar', members: [], projectPath: '/repo' }, + members: [ + { + name: 'alice', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + agentType: 'reviewer', + providerId: 'codex', + }, + ], + tasks: [], + messages: [], + kanbanState: { teamName: 'northstar-core', reviewers: [], tasks: {} }, + processes: [], + isAlive: true, + }, + }, + memberSpawnStatusesByTeam: { + 'northstar-core': { + alice: { + status: 'online', + launchState: 'runtime_pending_bootstrap', + livenessSource: 'process', + runtimeAlive: true, + }, + }, + }, + memberSpawnSnapshotsByTeam: {}, + currentProvisioningRunIdByTeam: {}, + provisioningRuns: {}, + leadActivityByTeam: {}, + } as never); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(GraphNodePopover, { + node: makeMemberNode('online'), + teamName: 'northstar-core', + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('online'); + expect(host.textContent).not.toContain('Idle'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('renders overflow stack contents instead of the task card and opens task detail from the list', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); useStore.setState({ diff --git a/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts b/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts new file mode 100644 index 00000000..e9f830a0 --- /dev/null +++ b/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts @@ -0,0 +1,183 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const hookState = { + presentation: null as + | { + isActive: boolean; + isFailed: boolean; + hasMembersStillJoining: boolean; + failedSpawnCount: number; + compactTone: 'default' | 'warning' | 'error' | 'success'; + compactTitle: string; + compactDetail?: string | null; + currentStepIndex: number; + progress: { runId: string }; + } + | null, + runInstanceKey: 'team:run-1:2026-04-13T10:00:00.000Z', +}; + +vi.mock('@renderer/components/team/useTeamProvisioningPresentation', () => ({ + useTeamProvisioningPresentation: () => hookState, +})); + +vi.mock('@renderer/components/ui/badge', () => ({ + Badge: ({ children }: { children: React.ReactNode }) => React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/team/StepProgressBar', () => ({ + StepProgressBar: () => React.createElement('div', { 'data-testid': 'stepper' }, 'stepper'), +})); + +vi.mock('@renderer/components/team/TeamProvisioningPanel', () => ({ + TeamProvisioningPanel: ({ + defaultLogsOpen, + }: { + defaultLogsOpen?: boolean; + }) => + React.createElement( + 'div', + { 'data-testid': 'panel', 'data-default-logs-open': defaultLogsOpen ? 'true' : 'false' }, + 'provisioning-panel' + ), +})); + +import { GraphProvisioningHud } from '@renderer/features/agent-graph/ui/GraphProvisioningHud'; + +const placement = { x: 120, y: 80, scale: 1, visible: true }; + +describe('GraphProvisioningHud', () => { + afterEach(() => { + document.body.innerHTML = ''; + hookState.presentation = null; + hookState.runInstanceKey = 'team:run-1:2026-04-13T10:00:00.000Z'; + }); + + it('keeps successful ready launch summary visible until dismissed', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + hookState.presentation = { + isActive: false, + isFailed: false, + hasMembersStillJoining: false, + failedSpawnCount: 0, + compactTone: 'success', + compactTitle: 'Team launched', + compactDetail: 'All 3 teammates joined', + currentStepIndex: 4, + progress: { runId: 'run-1' }, + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(GraphProvisioningHud, { + teamName: 'northstar-core', + leadNodeId: 'lead:northstar-core', + getLaunchAnchorScreenPlacement: () => placement, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Team launched'); + expect(host.textContent).toContain('All 3 teammates joined'); + expect(host.querySelector('[data-testid="stepper"]')).not.toBeNull(); + expect(document.body.textContent).not.toContain('provisioning-panel'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('opens launch details in a separate dialog when the stepper is clicked', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + hookState.presentation = { + isActive: false, + isFailed: false, + hasMembersStillJoining: false, + failedSpawnCount: 0, + compactTone: 'success', + compactTitle: 'Team launched', + compactDetail: 'All 3 teammates joined', + currentStepIndex: 4, + progress: { runId: 'run-3' }, + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(GraphProvisioningHud, { + teamName: 'northstar-core', + leadNodeId: 'lead:northstar-core', + getLaunchAnchorScreenPlacement: () => placement, + }) + ); + await Promise.resolve(); + }); + + const openButton = host.querySelector('button[aria-label="Open full launch details"]'); + expect(openButton).not.toBeNull(); + + await act(async () => { + openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(document.body.textContent).toContain('provisioning-panel'); + expect(document.body.querySelector('[data-testid="panel"]')?.getAttribute('data-default-logs-open')).toBe( + 'true' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('does not render or animate when disabled for an inactive graph tab', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + hookState.presentation = { + isActive: true, + isFailed: false, + hasMembersStillJoining: false, + failedSpawnCount: 0, + compactTone: 'default', + compactTitle: 'Launching team', + compactDetail: 'Waiting for members', + currentStepIndex: 1, + progress: { runId: 'run-2' }, + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(GraphProvisioningHud, { + teamName: 'northstar-core', + leadNodeId: 'lead:northstar-core', + getLaunchAnchorScreenPlacement: () => placement, + enabled: false, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toBe(''); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index e454d4e6..10f1771e 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -465,6 +465,125 @@ describe('TeamGraphAdapter particles', () => { ]); }); + it('builds member activity feeds from inbox messages in newest-first order', () => { + const adapter = TeamGraphAdapter.create(); + + const graph = adapter.adapt( + createBaseTeamData({ + messages: [ + { + from: 'alice', + to: 'team-lead', + text: 'First update', + timestamp: '2026-03-28T19:00:01.000Z', + read: false, + messageId: 'msg-1', + }, + { + from: 'team-lead', + to: 'alice', + text: 'Second update', + timestamp: '2026-03-28T19:00:02.000Z', + read: false, + messageId: 'msg-2', + }, + ], + }), + 'my-team' + ); + + expect(findNode(graph, 'member:my-team:alice')?.activityItems).toEqual([ + expect.objectContaining({ + id: 'activity:msg:my-team:msg-2', + title: 'team-lead -> alice', + preview: 'Second update', + }), + expect.objectContaining({ + id: 'activity:msg:my-team:msg-1', + title: 'alice -> team-lead', + preview: 'First update', + }), + ]); + }); + + it('routes task comment activity to the task owner and keeps task detail metadata', () => { + const adapter = TeamGraphAdapter.create(); + + const graph = adapter.adapt( + createBaseTeamData({ + tasks: [ + { + id: 'task-comments', + displayId: '#8', + subject: 'Review API notes', + owner: 'bob', + status: 'in_progress', + comments: [ + { + id: 'comment-1', + author: 'alice', + text: 'Please check the final API notes before merge', + createdAt: '2026-03-28T19:00:02.000Z', + type: 'regular', + }, + ], + reviewState: 'none', + } as TeamTaskWithKanban, + ], + }), + 'my-team' + ); + + expect(findNode(graph, 'member:my-team:bob')?.activityItems).toEqual([ + expect.objectContaining({ + id: 'activity:comment:my-team:task-comments:comment-1', + kind: 'task_comment', + title: '#8 Review API notes', + preview: 'Please check the final API notes before merge', + taskId: 'task-comments', + taskDisplayId: '#8', + authorLabel: 'alice', + }), + ]); + }); + + it('skips noisy idle inbox rows in the activity feed while keeping cross-team traffic on the lead lane', () => { + const adapter = TeamGraphAdapter.create(); + + const graph = adapter.adapt( + createBaseTeamData({ + messages: [ + { + from: 'alice', + to: 'team-lead', + text: JSON.stringify({ type: 'idle_notification' }), + timestamp: '2026-03-28T19:00:01.000Z', + read: true, + messageId: 'idle-generic', + }, + { + from: 'team-b.alex', + text: '[cross-team] Need status update', + timestamp: '2026-03-28T19:00:02.000Z', + read: false, + messageId: 'cross-team-1', + source: 'cross_team', + }, + ], + }), + 'my-team' + ); + + expect(findNode(graph, 'member:my-team:alice')?.activityItems).toEqual([]); + expect(findNode(graph, 'lead:my-team')?.activityItems).toEqual([ + expect.objectContaining({ + id: 'activity:msg:my-team:cross-team-1', + title: 'team-b -> team-lead', + preview: 'Need status update', + }), + ]); + }); + it('creates inbox particles for all unseen messages, not only the newest 20', () => { const adapter = TeamGraphAdapter.create(); adapter.adapt(createBaseTeamData(), 'my-team'); @@ -484,6 +603,88 @@ describe('TeamGraphAdapter particles', () => { expect(graph.particles.every((particle) => particle.kind === 'inbox_message')).toBe(true); }); + it('derives graph launch visuals from shared provisioning semantics', () => { + const adapter = TeamGraphAdapter.create(); + const graph = adapter.adapt( + createBaseTeamData(), + 'my-team', + { + alice: { + status: 'online', + launchState: 'runtime_pending_bootstrap', + livenessSource: 'process', + runtimeAlive: true, + updatedAt: '2026-03-28T19:00:01.000Z', + }, + }, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { + runId: 'run-1', + teamName: 'my-team', + state: 'finalizing', + startedAt: '2026-03-28T19:00:00.000Z', + message: 'Waiting for bootstrap contact', + pid: 1234, + configReady: true, + } as never + ); + + expect(findNode(graph, 'member:my-team:alice')?.launchVisualState).toBe('runtime_pending'); + }); + + it('keeps confirmed teammates in settling visuals while launch is still joining', () => { + const adapter = TeamGraphAdapter.create(); + const graph = adapter.adapt( + createBaseTeamData(), + 'my-team', + { + alice: { + status: 'online', + launchState: 'confirmed_alive', + livenessSource: 'heartbeat', + runtimeAlive: true, + updatedAt: '2026-03-28T19:00:01.000Z', + }, + }, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { + runId: 'run-1', + teamName: 'my-team', + state: 'ready', + startedAt: '2026-03-28T19:00:00.000Z', + message: 'Finishing launch', + pid: 1234, + configReady: true, + } as never, + { + runId: 'run-1', + expectedMembers: ['alice', 'bob'], + statuses: {}, + summary: { + confirmedCount: 1, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + source: 'merged', + } as never + ); + + expect(findNode(graph, 'member:my-team:alice')?.launchVisualState).toBe('settling'); + }); + it('scopes inbox particle ids by team name to avoid cross-team collisions', () => { const adapter = TeamGraphAdapter.create(); adapter.adapt(createBaseTeamData({ teamName: 'team-a' }), 'team-a'); diff --git a/test/renderer/features/agent-graph/activityLane.test.ts b/test/renderer/features/agent-graph/activityLane.test.ts new file mode 100644 index 00000000..6a4c6b60 --- /dev/null +++ b/test/renderer/features/agent-graph/activityLane.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; + +import { + ACTIVITY_LANE, + findActivityItemAt, + getActivityAnchorScreenPlacement, + getActivityAnchorTarget, + getActivityLaneBounds, + getVisibleActivityWindow, +} from '../../../../packages/agent-graph/src/layout/activityLane'; + +import type { GraphActivityItem, GraphNode } from '@claude-teams/agent-graph'; + +function createItems(count: number): GraphActivityItem[] { + return Array.from({ length: count }, (_, index) => ({ + id: `item-${index + 1}`, + kind: 'inbox_message', + timestamp: `2026-04-13T12:00:0${index}Z`, + title: `Item ${index + 1}`, + })); +} + +describe('activity lane helpers', () => { + it('keeps the newest visible window in newest-first order', () => { + const window = getVisibleActivityWindow(createItems(6)); + + expect(window.items.map((item) => item.id)).toEqual(['item-1', 'item-2', 'item-3']); + expect(window.overflowCount).toBe(3); + }); + + it('places the lead lane to the left and member lane to the right', () => { + const leadTarget = getActivityAnchorTarget({ nodeX: 100, nodeY: 80, nodeKind: 'lead' }); + const memberTarget = getActivityAnchorTarget({ nodeX: 100, nodeY: 80, nodeKind: 'member' }); + const memberLeftOfLeadTarget = getActivityAnchorTarget({ + nodeX: 80, + nodeY: 80, + nodeKind: 'member', + leadX: 100, + }); + + expect(leadTarget.x).toBeLessThan(100); + expect(memberTarget.x).toBeGreaterThan(100); + expect(memberLeftOfLeadTarget.x).toBeLessThan(80); + expect(leadTarget.y).toBeLessThan(80); + expect(memberTarget.y).toBeLessThan(80); + }); + + it('hits visible activity pills in the owner lane', () => { + const node: GraphNode = { + id: 'member:team:alice', + kind: 'member', + label: 'alice', + state: 'active', + x: 100, + y: 80, + activityItems: createItems(3), + domainRef: { kind: 'member', teamName: 'team', memberName: 'alice' }, + }; + + const anchor = getActivityAnchorTarget({ nodeX: 100, nodeY: 80, nodeKind: 'member' }); + const bounds = getActivityLaneBounds(anchor.x, anchor.y); + const hit = findActivityItemAt( + bounds.left + ACTIVITY_LANE.width / 2, + bounds.top + ACTIVITY_LANE.headerHeight + ACTIVITY_LANE.itemHeight / 2, + [node] + ); + + expect(hit?.ownerNodeId).toBe(node.id); + expect(hit?.item.id).toBe('item-1'); + }); + + it('keeps activity lane at its world-space position instead of clamping to the viewport', () => { + const placement = getActivityAnchorScreenPlacement({ + anchorX: 40, + anchorY: 60, + cameraX: 0, + cameraY: 0, + zoom: 1, + viewportWidth: 800, + viewportHeight: 600, + }); + + expect(placement.x).toBe(40 - ACTIVITY_LANE.width / 2); + expect(placement.y).toBe(60 - (ACTIVITY_LANE.headerHeight + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + ACTIVITY_LANE.overflowHeight) / 2); + expect(placement.visible).toBe(true); + }); + + it('stays visible when only part of the lane is inside the viewport', () => { + const placement = getActivityAnchorScreenPlacement({ + anchorX: -40, + anchorY: 40, + cameraX: 0, + cameraY: 0, + zoom: 1, + viewportWidth: 800, + viewportHeight: 600, + }); + + expect(placement.x).toBeLessThan(0); + expect(placement.visible).toBe(true); + }); +}); diff --git a/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts b/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts new file mode 100644 index 00000000..9c8c56b8 --- /dev/null +++ b/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildInlineActivityEntries, + getGraphLeadMemberName, +} from '@renderer/features/agent-graph/utils/buildInlineActivityEntries'; + +import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team'; + +function createBaseTeamData( + overrides?: Partial & { + tasks?: TeamTaskWithKanban[]; + messages?: InboxMessage[]; + } +): TeamData { + return { + teamName: 'my-team', + config: { + name: 'My Team', + members: [{ name: 'team-lead' }, { name: 'alice' }, { name: 'bob' }], + projectPath: '/repo', + }, + members: [ + { + name: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + agentType: 'team-lead', + }, + { + name: 'alice', + status: 'active', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'bob', + status: 'active', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + }, + ], + tasks: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + isAlive: true, + ...overrides, + }; +} + +describe('buildInlineActivityEntries', () => { + it('keeps original inbox messages for member lanes and preserves route metadata', () => { + const data = createBaseTeamData({ + messages: [ + { + from: 'team-lead', + to: 'alice', + text: 'New task assigned', + timestamp: '2026-03-28T19:00:01.000Z', + read: false, + messageId: 'msg-1', + }, + ], + }); + const entries = buildInlineActivityEntries({ + data, + teamName: 'my-team', + leadId: 'lead:my-team', + leadName: getGraphLeadMemberName(data, 'my-team'), + ownerNodeIds: new Set(['lead:my-team', 'member:my-team:alice', 'member:my-team:bob']), + }); + + const aliceEntries = entries.get('member:my-team:alice') ?? []; + expect(aliceEntries).toHaveLength(1); + expect(aliceEntries[0]?.graphItem).toEqual( + expect.objectContaining({ + id: 'activity:msg:my-team:msg-1', + title: 'team-lead -> alice', + preview: 'New task assigned', + }) + ); + expect(aliceEntries[0]?.message).toMatchObject({ + from: 'team-lead', + to: 'alice', + messageId: 'msg-1', + }); + }); + + it('builds synthetic comment messages that open with full task context and route owner-self comments to lead', () => { + const data = createBaseTeamData({ + tasks: [ + { + id: 'task-1', + displayId: '#8fdd6803', + subject: 'Review contributor notes', + owner: 'jack', + status: 'in_progress', + comments: [ + { + id: 'comment-1', + author: 'jack', + text: 'Короткий отчет по contributor pass', + createdAt: '2026-03-28T19:00:02.000Z', + type: 'regular', + }, + ], + reviewState: 'none', + } as unknown as TeamTaskWithKanban, + ], + members: [ + { + name: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + agentType: 'team-lead', + }, + { + name: 'jack', + status: 'active', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + }, + ], + }); + + const entries = buildInlineActivityEntries({ + data, + teamName: 'my-team', + leadId: 'lead:my-team', + leadName: getGraphLeadMemberName(data, 'my-team'), + ownerNodeIds: new Set(['lead:my-team', 'member:my-team:jack']), + }); + + const jackEntries = entries.get('member:my-team:jack') ?? []; + expect(jackEntries).toHaveLength(1); + expect(jackEntries[0]?.graphItem).toEqual( + expect.objectContaining({ + id: 'activity:comment:my-team:task-1:comment-1', + kind: 'task_comment', + title: '#8fdd6803 Review contributor notes', + preview: 'Короткий отчет по contributor pass', + }) + ); + expect(jackEntries[0]?.message).toMatchObject({ + from: 'jack', + to: 'team-lead', + summary: '#8fdd6803 Короткий отчет по contributor pass', + messageKind: 'task_comment_notification', + taskRefs: [{ taskId: 'task-1', displayId: '#8fdd6803', teamName: 'my-team' }], + }); + }); +}); diff --git a/test/renderer/features/agent-graph/kanbanLayout.test.ts b/test/renderer/features/agent-graph/kanbanLayout.test.ts new file mode 100644 index 00000000..28a39e4d --- /dev/null +++ b/test/renderer/features/agent-graph/kanbanLayout.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; + +import { TASK_PILL } from '../../../../packages/agent-graph/src/constants/canvas-constants'; +import { + KanbanLayoutEngine, + getOwnerKanbanBaseX, +} from '../../../../packages/agent-graph/src/layout/kanbanLayout'; +import { + getActivityAnchorTarget, + getActivityLaneBounds, +} from '../../../../packages/agent-graph/src/layout/activityLane'; + +import type { GraphNode } from '@claude-teams/agent-graph'; + +function createMemberNode(id: string, x: number, y: number, memberName: string): GraphNode { + return { + id, + kind: 'member', + label: memberName, + state: 'active', + x, + y, + domainRef: { kind: 'member', teamName: 'team', memberName }, + }; +} + +function createLeadNode(x: number, y: number): GraphNode { + return { + id: 'lead:team', + kind: 'lead', + label: 'team lead', + state: 'active', + x, + y, + domainRef: { kind: 'lead', teamName: 'team', memberName: 'lead' }, + }; +} + +function createTaskNode( + id: string, + ownerId: string, + status: NonNullable +): GraphNode { + return { + id, + kind: 'task', + label: id, + state: 'active', + ownerId, + taskStatus: status, + reviewState: 'none', + domainRef: { kind: 'task', teamName: 'team', taskId: id }, + }; +} + +describe('kanban layout activity-lane avoidance', () => { + it('anchors right-side member kanban columns to the left of the owner', () => { + const baseX = getOwnerKanbanBaseX({ + ownerX: 220, + ownerKind: 'member', + activeColumnCount: 3, + columnWidth: 180, + leadX: 0, + }); + + expect(baseX).toBe(220 - 2 * 180); + }); + + it('anchors left-side member kanban columns to the right of the owner', () => { + const baseX = getOwnerKanbanBaseX({ + ownerX: -220, + ownerKind: 'member', + activeColumnCount: 3, + columnWidth: 180, + leadX: 0, + }); + + expect(baseX).toBe(-220); + }); + + it('keeps member task pills out of the reserved right-side activity lane', () => { + const lead = createLeadNode(0, 0); + const member = createMemberNode('member:jack', 220, 40, 'jack'); + const tasks = [ + createTaskNode('task:todo', member.id, 'pending'), + createTaskNode('task:wip', member.id, 'in_progress'), + createTaskNode('task:done', member.id, 'completed'), + ]; + + KanbanLayoutEngine.layout([lead, member, ...tasks]); + + const anchor = getActivityAnchorTarget({ + nodeX: member.x ?? 0, + nodeY: member.y ?? 0, + nodeKind: 'member', + leadX: lead.x ?? null, + }); + const laneBounds = getActivityLaneBounds(anchor.x, anchor.y); + const rightmostTaskEdge = Math.max(...tasks.map((task) => (task.x ?? 0) + TASK_PILL.width / 2)); + + expect(rightmostTaskEdge).toBeLessThan(laneBounds.left); + }); + + it('keeps member task pills out of the reserved left-side activity lane', () => { + const lead = createLeadNode(0, 0); + const member = createMemberNode('member:alice', -220, 40, 'alice'); + const tasks = [ + createTaskNode('task:todo', member.id, 'pending'), + createTaskNode('task:wip', member.id, 'in_progress'), + createTaskNode('task:done', member.id, 'completed'), + ]; + + KanbanLayoutEngine.layout([lead, member, ...tasks]); + + const anchor = getActivityAnchorTarget({ + nodeX: member.x ?? 0, + nodeY: member.y ?? 0, + nodeKind: 'member', + leadX: lead.x ?? null, + }); + const laneBounds = getActivityLaneBounds(anchor.x, anchor.y); + const leftmostTaskEdge = Math.min(...tasks.map((task) => (task.x ?? 0) - TASK_PILL.width / 2)); + + expect(leftmostTaskEdge).toBeGreaterThan(laneBounds.right); + }); +}); diff --git a/test/renderer/features/agent-graph/launchAnchor.test.ts b/test/renderer/features/agent-graph/launchAnchor.test.ts new file mode 100644 index 00000000..0baf3749 --- /dev/null +++ b/test/renderer/features/agent-graph/launchAnchor.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { + HANDOFF_ANCHOR_LAYOUT, + LAUNCH_ANCHOR_LAYOUT, + getHandoffAnchorTarget, + getLaunchAnchorBounds, + getLaunchAnchorTarget, + getLaunchAnchorScreenPlacement, + getLaunchHudScale, +} from '../../../../packages/agent-graph/src/layout/launchAnchor'; + +describe('launchAnchor layout helpers', () => { + it('clamps HUD scale to the supported zoom range', () => { + expect(getLaunchHudScale(0.25)).toBeCloseTo(0.25); + expect(getLaunchHudScale(0.92)).toBeCloseTo(0.92); + expect(getLaunchHudScale(1.8)).toBe(LAUNCH_ANCHOR_LAYOUT.maxScale); + }); + + it('returns compact HUD bounds centered around the anchor', () => { + const bounds = getLaunchAnchorBounds(240, 40); + + expect(bounds).toEqual({ + left: 72, + top: -26, + right: 408, + bottom: 106, + }); + }); + + it('places the launch slot above and to the right of the lead', () => { + const target = getLaunchAnchorTarget(100, 50); + + expect(target.x).toBeGreaterThan(100 + LAUNCH_ANCHOR_LAYOUT.compactWidth / 2 - 8); + expect(target.y).toBeLessThan(50); + }); + + it('places handoff slots above-right for members and above-left for the lead', () => { + const leadTarget = getHandoffAnchorTarget({ nodeX: 100, nodeY: 80, nodeKind: 'lead' }); + const memberTarget = getHandoffAnchorTarget({ nodeX: 100, nodeY: 80, nodeKind: 'member' }); + + expect(leadTarget.x).toBeLessThan(100); + expect(memberTarget.x).toBeGreaterThan(100); + expect(leadTarget.y).toBeLessThan(80 - HANDOFF_ANCHOR_LAYOUT.reservedHeight / 4); + expect(memberTarget.y).toBeLessThan(80 - HANDOFF_ANCHOR_LAYOUT.reservedHeight / 4); + }); + + it('clamps screen placement into the viewport while preserving visibility state', () => { + const placement = getLaunchAnchorScreenPlacement({ + anchorX: 520, + anchorY: -30, + cameraX: 0, + cameraY: 0, + zoom: 1, + viewportWidth: 480, + viewportHeight: 320, + }); + + expect(placement.scale).toBe(1); + expect(placement.x).toBeGreaterThanOrEqual(LAUNCH_ANCHOR_LAYOUT.viewportPadding); + expect(placement.y).toBe(LAUNCH_ANCHOR_LAYOUT.viewportPadding); + expect(placement.visible).toBe(true); + }); + + it('marks the anchor as not visible when it is well outside the viewport', () => { + const placement = getLaunchAnchorScreenPlacement({ + anchorX: 1200, + anchorY: 900, + cameraX: 0, + cameraY: 0, + zoom: 1, + viewportWidth: 480, + viewportHeight: 320, + }); + + expect(placement.visible).toBe(false); + expect(placement.x).toBeGreaterThanOrEqual(LAUNCH_ANCHOR_LAYOUT.viewportPadding); + expect(placement.y).toBeGreaterThanOrEqual(LAUNCH_ANCHOR_LAYOUT.viewportPadding); + }); +}); diff --git a/test/renderer/features/agent-graph/useGraphCamera.test.ts b/test/renderer/features/agent-graph/useGraphCamera.test.ts new file mode 100644 index 00000000..b4b518d5 --- /dev/null +++ b/test/renderer/features/agent-graph/useGraphCamera.test.ts @@ -0,0 +1,74 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useGraphCamera, type UseGraphCameraResult } from '../../../../packages/agent-graph/src/hooks/useGraphCamera'; + +import type { GraphNode } from '@claude-teams/agent-graph'; + +let capturedCamera: UseGraphCameraResult | null = null; + +function CameraHarness(): React.JSX.Element | null { + capturedCamera = useGraphCamera(); + return null; +} + +describe('useGraphCamera zoomToFit', () => { + afterEach(() => { + capturedCamera = null; + document.body.innerHTML = ''; + }); + + it('accounts for extra world bounds when fitting the graph', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CameraHarness)); + await Promise.resolve(); + }); + + const node: GraphNode = { + id: 'lead:team-a', + kind: 'lead', + label: 'team-a', + state: 'active', + x: 0, + y: 0, + domainRef: { kind: 'lead', teamName: 'team-a', memberName: 'lead' }, + }; + + capturedCamera?.zoomToFit([node], 800, 600); + const zoomWithoutExtra = capturedCamera?.transformRef.current.zoom ?? 0; + + capturedCamera?.zoomToFit([node], 800, 600, [ + { + left: 80, + top: -50, + right: 420, + bottom: 120, + }, + ]); + + const transform = capturedCamera?.transformRef.current; + expect(transform).not.toBeNull(); + expect((transform?.zoom ?? 0)).toBeLessThan(zoomWithoutExtra); + + const right = 420 * (transform?.zoom ?? 0) + (transform?.x ?? 0); + const bottom = 120 * (transform?.zoom ?? 0) + (transform?.y ?? 0); + const left = 80 * (transform?.zoom ?? 0) + (transform?.x ?? 0); + const top = -50 * (transform?.zoom ?? 0) + (transform?.y ?? 0); + + expect(left).toBeGreaterThanOrEqual(0); + expect(top).toBeGreaterThanOrEqual(0); + expect(right).toBeLessThanOrEqual(800); + expect(bottom).toBeLessThanOrEqual(600); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts new file mode 100644 index 00000000..7e344691 --- /dev/null +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -0,0 +1,86 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useGraphSimulation, type UseGraphSimulationResult } from '../../../../packages/agent-graph/src/hooks/useGraphSimulation'; +import { getLaunchAnchorTarget } from '../../../../packages/agent-graph/src/layout/launchAnchor'; + +import type { GraphNode } from '@claude-teams/agent-graph'; + +let capturedSimulation: UseGraphSimulationResult | null = null; + +function SimulationHarness(): React.JSX.Element | null { + capturedSimulation = useGraphSimulation(); + return null; +} + +describe('useGraphSimulation launch anchor', () => { + afterEach(() => { + capturedSimulation = null; + document.body.innerHTML = ''; + }); + + it('keeps the launch anchor aligned when the lead is dragged after settling', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(SimulationHarness)); + await Promise.resolve(); + }); + + const lead: GraphNode = { + id: 'lead:team-a', + kind: 'lead', + label: 'team-a', + state: 'active', + domainRef: { kind: 'lead', teamName: 'team-a', memberName: 'lead' }, + }; + + const member: GraphNode = { + id: 'member:team-a:alice', + kind: 'member', + label: 'alice', + state: 'active', + domainRef: { kind: 'member', teamName: 'team-a', memberName: 'alice' }, + }; + + await act(async () => { + capturedSimulation?.updateData( + [lead, member], + [ + { + id: 'edge:lead:alice', + source: lead.id, + target: member.id, + type: 'parent-child', + }, + ], + [] + ); + capturedSimulation?.tick(0); + await Promise.resolve(); + }); + + expect(capturedSimulation?.getLaunchAnchorWorldPosition(lead.id)).not.toBeNull(); + expect(capturedSimulation?.getExtraWorldBounds()).toHaveLength(3); + + await act(async () => { + capturedSimulation?.setNodePosition(lead.id, 140, 60); + capturedSimulation?.tick(0); + await Promise.resolve(); + }); + + expect(capturedSimulation?.getLaunchAnchorWorldPosition(lead.id)).toEqual( + getLaunchAnchorTarget(140, 60) + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +});