feat(agent-graph): enhance team graph

This commit is contained in:
777genius 2026-04-13 16:18:14 +03:00
parent 674c1d8b13
commit 7d47cbaded
17 changed files with 2238 additions and 44 deletions

View file

@ -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<string, Record<string, ActiveToolCall>>,
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
toolHistory?: Record<string, ActiveToolCall[]>,
commentReadState?: Record<string, unknown>
commentReadState?: Record<string, unknown>,
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<string, Record<string, ActiveToolCall>>,
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
toolHistory?: Record<string, ActiveToolCall[]>
toolHistory?: Record<string, ActiveToolCall[]>,
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<string>,
activeTools?: Record<string, Record<string, ActiveToolCall>>,
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
toolHistory?: Record<string, ActiveToolCall[]>
toolHistory?: Record<string, ActiveToolCall[]>,
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<string>();
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[],

View file

@ -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,
]
);
}

View file

@ -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<string> | 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<string, HTMLDivElement | null>());
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(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<string, InlineActivityEntry[]>();
}
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<typeof entry> => 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<string, TimelineItem>();
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) => (
<div
key={lane.node.id}
ref={(element) => {
shellRefs.current.set(lane.node.id, element);
}}
className="pointer-events-auto absolute z-10 w-[296px] origin-top-left opacity-0"
>
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
Activity
</div>
<div className="space-y-2">
{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 (
<div
key={entry.graphItem.id}
className="cursor-pointer"
role="button"
tabIndex={0}
onClick={() => handleMessageClick(timelineItem)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleMessageClick(timelineItem);
}
}}
>
<ActivityItem
message={entry.message}
teamName={teamName}
compactHeader
collapseMode="managed"
isCollapsed
canToggleCollapse={false}
isUnread={isUnread}
expandItemKey={messageKey}
onExpand={handleExpandItem}
memberRole={renderProps.memberRole}
memberColor={renderProps.memberColor}
recipientColor={renderProps.recipientColor}
memberColorMap={messageContext.colorMap}
localMemberNames={messageContext.localMemberNames}
onMemberNameClick={handleMemberNameClick}
onTaskIdClick={onOpenTaskDetail}
zebraShade={index % 2 === 1}
teamNames={teamNames}
teamColorByName={teamColorByName}
/>
</div>
);
})}
{lane.overflowCount > 0 ? (
<div className="rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300">
+{lane.overflowCount} more
</div>
) : null}
</div>
</div>
))}
<MessageExpandDialog
expandedItem={expandedItem}
open={expandedItem !== null}
onOpenChange={(open) => {
if (!open) {
setExpandedItem(null);
}
}}
teamName={teamName}
members={teamData.members}
onMemberClick={handleMemberClick}
onTaskIdClick={onOpenTaskDetail}
teamNames={teamNames}
teamColorByName={teamColorByName}
/>
</>
);
}

View file

@ -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 (
<div className="min-w-[200px] max-w-[280px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
@ -313,7 +363,7 @@ const MemberPopoverContent = ({
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)] ${statusDotColor}`}
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">
@ -347,15 +397,16 @@ const MemberPopoverContent = ({
Lead
</Badge>
)}
{node.spawnStatus && node.spawnStatus !== 'online' && (
<Badge
variant="outline"
className="border-amber-500/30 px-1.5 py-0 text-[10px] text-amber-400"
>
{getSpawnStatusBadgeLabel(node.spawnStatus)}
</Badge>
)}
{node.exceptionLabel && (
{(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] ${

View file

@ -0,0 +1,259 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { TeamProvisioningPanel } from '@renderer/components/team/TeamProvisioningPanel';
import { StepProgressBar } from '@renderer/components/team/StepProgressBar';
import { useTeamProvisioningPresentation } from '@renderer/components/team/useTeamProvisioningPresentation';
import { DISPLAY_STEPS } from '@renderer/components/team/provisioningSteps';
import { cn } from '@renderer/lib/utils';
import { AlertTriangle, CheckCircle2, ExternalLink, Loader2, X } from 'lucide-react';
import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import type { CSSProperties } from 'react';
const MINI_STEPS = DISPLAY_STEPS.map((step) => ({ 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: <AlertTriangle size={13} />,
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: <AlertTriangle size={13} />,
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: <CheckCircle2 size={13} />,
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: <Loader2 size={13} className="animate-spin" />,
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<HTMLDivElement>(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 (
<div
ref={shellRef}
className="pointer-events-auto absolute z-10 w-[336px] origin-top-left opacity-0 transition-opacity"
>
<div
className={cn(
'rounded-xl border px-3 py-3 text-slate-100 shadow-[0_18px_48px_rgba(5,5,16,0.38)] backdrop-blur-xl',
tone.border
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={cn('shrink-0', tone.iconClassName)}>{tone.icon}</span>
<div className="truncate text-sm font-semibold text-slate-50">
{presentation.compactTitle}
</div>
<Badge variant="outline" className={cn('px-1.5 py-0 text-[10px]', tone.badge)}>
{presentation.isFailed
? 'Issue'
: presentation.hasMembersStillJoining
? 'Joining'
: presentation.isActive
? 'Live'
: 'Ready'}
</Badge>
</div>
{compactLabel ? (
<div className="mt-1 text-[11px] leading-5 text-slate-300">{compactLabel}</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
className="inline-flex size-7 items-center justify-center rounded-md border border-white/10 bg-white/5 text-slate-400 transition-colors hover:bg-white/10 hover:text-slate-100"
onClick={() => setDetailsOpen(true)}
aria-label="Open launch details"
>
<ExternalLink size={14} />
</button>
<button
type="button"
className="inline-flex size-7 items-center justify-center rounded-md border border-white/10 bg-white/5 text-slate-400 transition-colors hover:bg-white/10 hover:text-slate-100"
onClick={() => setDismissed(true)}
aria-label="Dismiss launch overlay"
>
<X size={14} />
</button>
</div>
</div>
<button
type="button"
className="border-cyan-300/12 mt-3 w-full rounded-xl border bg-[rgba(4,10,20,0.58)] px-3 py-3 text-left transition-colors hover:bg-[rgba(8,18,32,0.76)]"
style={HUD_STEPPER_STYLE}
onClick={() => setDetailsOpen(true)}
aria-label="Open full launch details"
>
<StepProgressBar
steps={MINI_STEPS}
currentIndex={presentation.currentStepIndex}
errorIndex={errorStepIndex}
className="w-full"
/>
</button>
</div>
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
<DialogContent className="w-[min(1120px,92vw)] max-w-5xl p-0">
<DialogHeader className="sr-only">
<DialogTitle>Launch details</DialogTitle>
<DialogDescription>
Detailed team launch progress, live output and CLI logs.
</DialogDescription>
</DialogHeader>
<div className="max-h-[85vh] overflow-y-auto p-4">
<TeamProvisioningPanel teamName={teamName} surface="flat" defaultLogsOpen />
</div>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -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,
}) => (
<>
<GraphActivityHud
teamName={teamName}
nodes={graphData.nodes}
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement}
focusNodeIds={focusNodeIds}
onOpenTaskDetail={onOpenTaskDetail}
onOpenMemberProfile={onOpenMemberProfile}
/>
<GraphProvisioningHud
teamName={teamName}
leadNodeId={leadNodeId}
getLaunchAnchorScreenPlacement={getLaunchAnchorScreenPlacement}
/>
</>
)}
renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose: closeEdge, onSelectNode }) => (
<GraphBlockingEdgePopover
teamName={teamName}

View file

@ -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, GraphNode } from '@claude-teams/agent-graph';
@ -31,6 +33,10 @@ export const TeamGraphTab = ({
isPaneFocused = false,
}: TeamGraphTabProps): React.JSX.Element => {
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,
}) => (
<>
<GraphActivityHud
teamName={teamName}
nodes={graphData.nodes}
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement}
focusNodeIds={focusNodeIds}
enabled={isActive}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile}
/>
<GraphProvisioningHud
teamName={teamName}
leadNodeId={leadNodeId}
getLaunchAnchorScreenPlacement={getLaunchAnchorScreenPlacement}
enabled={isActive}
/>
</>
)}
renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose, onSelectNode }) => (
<GraphBlockingEdgePopover
teamName={teamName}

View file

@ -0,0 +1,362 @@
import { stripCrossTeamPrefix } from '@shared/constants/crossTeam';
import { getIdleGraphLabel } from '@shared/utils/idleNotificationSemantics';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { isLeadMember } from '@shared/utils/leadDetection';
import type { GraphActivityItem } from '@claude-teams/agent-graph';
import type {
AttachmentMeta,
InboxMessage,
TaskAttachmentMeta,
TaskComment,
TaskRef,
TeamData,
TeamTaskWithKanban,
} from '@shared/types/team';
export interface InlineActivityEntry {
ownerNodeId: string;
graphItem: GraphActivityItem;
message: InboxMessage;
}
export interface BuildInlineActivityEntriesArgs {
data: TeamData;
teamName: string;
leadId: string;
leadName: string;
ownerNodeIds: ReadonlySet<string>;
}
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<string, InlineActivityEntry[]> {
const entriesByOwnerNodeId = new Map<string, InlineActivityEntry[]>();
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>;
}): 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>;
}): 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);
}

View file

@ -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({

View file

@ -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();
});
});
});

View file

@ -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');

View file

@ -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);
});
});

View file

@ -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<TeamData> & {
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' }],
});
});
});

View file

@ -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['taskStatus']>
): 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);
});
});

View file

@ -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);
});
});

View file

@ -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();
});
});
});

View file

@ -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();
});
});
});