feat(agent-graph): enhance team graph
This commit is contained in:
parent
674c1d8b13
commit
7d47cbaded
17 changed files with 2238 additions and 44 deletions
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
278
src/renderer/features/agent-graph/ui/GraphActivityHud.tsx
Normal file
278
src/renderer/features/agent-graph/ui/GraphActivityHud.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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] ${
|
||||
|
|
|
|||
259
src/renderer/features/agent-graph/ui/GraphProvisioningHud.tsx
Normal file
259
src/renderer/features/agent-graph/ui/GraphProvisioningHud.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
183
test/renderer/features/agent-graph/GraphProvisioningHud.test.ts
Normal file
183
test/renderer/features/agent-graph/GraphProvisioningHud.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
102
test/renderer/features/agent-graph/activityLane.test.ts
Normal file
102
test/renderer/features/agent-graph/activityLane.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
126
test/renderer/features/agent-graph/kanbanLayout.test.ts
Normal file
126
test/renderer/features/agent-graph/kanbanLayout.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
80
test/renderer/features/agent-graph/launchAnchor.test.ts
Normal file
80
test/renderer/features/agent-graph/launchAnchor.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
74
test/renderer/features/agent-graph/useGraphCamera.test.ts
Normal file
74
test/renderer/features/agent-graph/useGraphCamera.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue