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 { 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 { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary';
|
||||||
import { stripCrossTeamPrefix } from '@shared/constants/crossTeam';
|
import { stripCrossTeamPrefix } from '@shared/constants/crossTeam';
|
||||||
import {
|
import {
|
||||||
|
|
@ -19,6 +24,10 @@ import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
import { collapseOverflowStacksWithMeta } from '../utils/collapseOverflowStacks';
|
import { collapseOverflowStacksWithMeta } from '../utils/collapseOverflowStacks';
|
||||||
|
import {
|
||||||
|
buildInlineActivityEntries,
|
||||||
|
getGraphLeadMemberName,
|
||||||
|
} from '../utils/buildInlineActivityEntries';
|
||||||
import {
|
import {
|
||||||
isTaskBlocked,
|
isTaskBlocked,
|
||||||
isTaskInReviewCycle,
|
isTaskInReviewCycle,
|
||||||
|
|
@ -27,6 +36,7 @@ import {
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
GraphDataPort,
|
GraphDataPort,
|
||||||
|
GraphActivityItem,
|
||||||
GraphEdge,
|
GraphEdge,
|
||||||
GraphNode,
|
GraphNode,
|
||||||
GraphNodeState,
|
GraphNodeState,
|
||||||
|
|
@ -37,6 +47,8 @@ import type {
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
|
MemberSpawnStatusesSnapshot,
|
||||||
|
TeamProvisioningProgress,
|
||||||
TeamData,
|
TeamData,
|
||||||
} from '@shared/types/team';
|
} from '@shared/types/team';
|
||||||
import type { LeadContextUsage } from '@shared/types/team';
|
import type { LeadContextUsage } from '@shared/types/team';
|
||||||
|
|
@ -76,7 +88,9 @@ export class TeamGraphAdapter {
|
||||||
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
||||||
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
|
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
|
||||||
toolHistory?: Record<string, ActiveToolCall[]>,
|
toolHistory?: Record<string, ActiveToolCall[]>,
|
||||||
commentReadState?: Record<string, unknown>
|
commentReadState?: Record<string, unknown>,
|
||||||
|
provisioningProgress?: TeamProvisioningProgress | null,
|
||||||
|
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot
|
||||||
): GraphDataPort {
|
): GraphDataPort {
|
||||||
if (teamData?.teamName !== teamName) {
|
if (teamData?.teamName !== teamName) {
|
||||||
return TeamGraphAdapter.#emptyResult(teamName);
|
return TeamGraphAdapter.#emptyResult(teamName);
|
||||||
|
|
@ -101,6 +115,14 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
const leadId = `lead:${teamName}`;
|
const leadId = `lead:${teamName}`;
|
||||||
const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, 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(
|
this.#buildLeadNode(
|
||||||
nodes,
|
nodes,
|
||||||
|
|
@ -113,7 +135,8 @@ export class TeamGraphAdapter {
|
||||||
leadContext,
|
leadContext,
|
||||||
activeTools,
|
activeTools,
|
||||||
finishedVisible,
|
finishedVisible,
|
||||||
toolHistory
|
toolHistory,
|
||||||
|
isTeamProvisioning
|
||||||
);
|
);
|
||||||
this.#buildMemberNodes(
|
this.#buildMemberNodes(
|
||||||
nodes,
|
nodes,
|
||||||
|
|
@ -125,10 +148,13 @@ export class TeamGraphAdapter {
|
||||||
pendingApprovalAgents,
|
pendingApprovalAgents,
|
||||||
activeTools,
|
activeTools,
|
||||||
finishedVisible,
|
finishedVisible,
|
||||||
toolHistory
|
toolHistory,
|
||||||
|
isTeamProvisioning,
|
||||||
|
isLaunchSettling
|
||||||
);
|
);
|
||||||
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState);
|
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState);
|
||||||
this.#buildProcessNodes(nodes, edges, teamData, teamName);
|
this.#buildProcessNodes(nodes, edges, teamData, teamName);
|
||||||
|
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
|
||||||
this.#buildMessageParticles(
|
this.#buildMessageParticles(
|
||||||
particles,
|
particles,
|
||||||
nodes,
|
nodes,
|
||||||
|
|
@ -166,7 +192,7 @@ export class TeamGraphAdapter {
|
||||||
// ─── Private: node builders ──────────────────────────────────────────────
|
// ─── Private: node builders ──────────────────────────────────────────────
|
||||||
|
|
||||||
static #getLeadMemberName(data: TeamData, teamName: string): string {
|
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 {
|
static #isBeforeParticleCutoff(timestamp: string | undefined, cutoffMs: number | null): boolean {
|
||||||
|
|
@ -209,7 +235,8 @@ export class TeamGraphAdapter {
|
||||||
leadContext?: LeadContextUsage,
|
leadContext?: LeadContextUsage,
|
||||||
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
||||||
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
|
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
|
||||||
toolHistory?: Record<string, ActiveToolCall[]>
|
toolHistory?: Record<string, ActiveToolCall[]>,
|
||||||
|
isTeamProvisioning = false
|
||||||
): void {
|
): void {
|
||||||
const percent = leadContext?.percent;
|
const percent = leadContext?.percent;
|
||||||
const leadMember = data.members.find((member) => member.name === leadName);
|
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 hasRunningTool = Object.keys(activeTools?.[leadName] ?? {}).length > 0;
|
||||||
const pendingApproval =
|
const pendingApproval =
|
||||||
pendingApprovalAgents?.has(leadName) || pendingApprovalAgents?.has('lead') || false;
|
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 =
|
const leadState =
|
||||||
leadActivity === 'offline'
|
leadActivity === 'offline'
|
||||||
? 'terminated'
|
? 'terminated'
|
||||||
|
|
@ -245,6 +286,7 @@ export class TeamGraphAdapter {
|
||||||
leadMember?.model,
|
leadMember?.model,
|
||||||
leadMember?.effort
|
leadMember?.effort
|
||||||
),
|
),
|
||||||
|
launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined,
|
||||||
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
|
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
|
||||||
avatarUrl: agentAvatarUrl(leadName, 64),
|
avatarUrl: agentAvatarUrl(leadName, 64),
|
||||||
pendingApproval,
|
pendingApproval,
|
||||||
|
|
@ -286,7 +328,9 @@ export class TeamGraphAdapter {
|
||||||
pendingApprovalAgents?: Set<string>,
|
pendingApprovalAgents?: Set<string>,
|
||||||
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
||||||
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
|
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
|
||||||
toolHistory?: Record<string, ActiveToolCall[]>
|
toolHistory?: Record<string, ActiveToolCall[]>,
|
||||||
|
isTeamProvisioning = false,
|
||||||
|
isLaunchSettling = false
|
||||||
): void {
|
): void {
|
||||||
for (const member of data.members) {
|
for (const member of data.members) {
|
||||||
if (member.removedAt) continue;
|
if (member.removedAt) continue;
|
||||||
|
|
@ -305,6 +349,17 @@ export class TeamGraphAdapter {
|
||||||
spawn,
|
spawn,
|
||||||
pendingApprovalAgents?.has(member.name) ?? false
|
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({
|
nodes.push({
|
||||||
id: memberId,
|
id: memberId,
|
||||||
|
|
@ -321,6 +376,7 @@ export class TeamGraphAdapter {
|
||||||
member.effort
|
member.effort
|
||||||
),
|
),
|
||||||
spawnStatus: spawn?.status,
|
spawnStatus: spawn?.status,
|
||||||
|
launchVisualState: launchPresentation.launchVisualState ?? undefined,
|
||||||
avatarUrl: agentAvatarUrl(member.name, 64),
|
avatarUrl: agentAvatarUrl(member.name, 64),
|
||||||
currentTaskId: member.currentTaskId ?? undefined,
|
currentTaskId: member.currentTaskId ?? undefined,
|
||||||
currentTaskSubject: member.currentTaskId
|
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(
|
#buildMessageParticles(
|
||||||
particles: GraphParticle[],
|
particles: GraphParticle[],
|
||||||
nodes: GraphNode[],
|
nodes: GraphNode[],
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ import { useMemo, useRef, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
|
import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
|
||||||
import { useStore } from '@renderer/store';
|
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 { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { TeamGraphAdapter } from './TeamGraphAdapter';
|
import { TeamGraphAdapter } from './TeamGraphAdapter';
|
||||||
|
|
@ -26,6 +29,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
activeTools,
|
activeTools,
|
||||||
finishedVisible,
|
finishedVisible,
|
||||||
toolHistory,
|
toolHistory,
|
||||||
|
provisioningProgress,
|
||||||
|
memberSpawnSnapshot,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
teamData: selectTeamDataForName(s, teamName),
|
teamData: selectTeamDataForName(s, teamName),
|
||||||
|
|
@ -36,6 +41,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
activeTools: teamName ? s.activeToolsByTeam[teamName] : undefined,
|
activeTools: teamName ? s.activeToolsByTeam[teamName] : undefined,
|
||||||
finishedVisible: teamName ? s.finishedVisibleByTeam[teamName] : undefined,
|
finishedVisible: teamName ? s.finishedVisibleByTeam[teamName] : undefined,
|
||||||
toolHistory: teamName ? s.toolHistoryByTeam[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,
|
activeTools,
|
||||||
finishedVisible,
|
finishedVisible,
|
||||||
toolHistory,
|
toolHistory,
|
||||||
commentReadState
|
commentReadState,
|
||||||
|
provisioningProgress,
|
||||||
|
memberSpawnSnapshot
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
teamData,
|
teamData,
|
||||||
|
|
@ -76,6 +85,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
finishedVisible,
|
finishedVisible,
|
||||||
toolHistory,
|
toolHistory,
|
||||||
commentReadState,
|
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 { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
import {
|
||||||
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
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 { 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 { isTaskInReviewCycle, resolveTaskReviewer } from '../utils/taskGraphSemantics';
|
||||||
|
import { GraphTaskCard } from './GraphTaskCard';
|
||||||
|
|
||||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||||
import type { TeamTaskWithKanban } from '@shared/types';
|
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;
|
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 {
|
interface GraphNodePopoverProps {
|
||||||
node: GraphNode;
|
node: GraphNode;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -281,26 +279,78 @@ const MemberPopoverContent = ({
|
||||||
node.domainRef.kind === 'member' || node.domainRef.kind === 'lead'
|
node.domainRef.kind === 'member' || node.domainRef.kind === 'lead'
|
||||||
? node.domainRef.memberName
|
? node.domainRef.memberName
|
||||||
: 'team-lead';
|
: 'team-lead';
|
||||||
|
const teamName =
|
||||||
|
node.domainRef.kind === 'member' || node.domainRef.kind === 'lead'
|
||||||
|
? node.domainRef.teamName
|
||||||
|
: '';
|
||||||
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
|
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 =
|
const statusLabel =
|
||||||
node.state === 'active'
|
launchPresentation?.presenceLabel ??
|
||||||
? 'Active'
|
fallbackSpawnStatusLabel ??
|
||||||
|
(node.state === 'active'
|
||||||
|
? 'active'
|
||||||
: node.state === 'idle'
|
: node.state === 'idle'
|
||||||
? 'Idle'
|
? 'idle'
|
||||||
: node.state === 'terminated'
|
: node.state === 'terminated'
|
||||||
? 'Offline'
|
? 'offline'
|
||||||
: node.state === 'tool_calling'
|
: node.state === 'tool_calling'
|
||||||
? 'Running tool'
|
? 'running tool'
|
||||||
: node.state;
|
: node.state);
|
||||||
|
const statusDotClass =
|
||||||
const statusDotColor =
|
launchPresentation?.dotClass ??
|
||||||
node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling'
|
(node.spawnStatus === 'spawning'
|
||||||
? 'bg-emerald-400'
|
? 'bg-amber-400'
|
||||||
: node.state === 'idle'
|
: node.spawnStatus === 'waiting'
|
||||||
? 'bg-zinc-400'
|
? 'bg-zinc-400 animate-pulse'
|
||||||
: node.state === 'error'
|
: node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling'
|
||||||
? 'bg-red-400'
|
? 'bg-emerald-400'
|
||||||
: 'bg-zinc-600';
|
: node.state === 'idle'
|
||||||
|
? 'bg-zinc-400'
|
||||||
|
: node.state === 'error'
|
||||||
|
? 'bg-red-400'
|
||||||
|
: 'bg-zinc-600');
|
||||||
|
const showExceptionBadge = node.exceptionLabel && node.exceptionLabel !== statusLabel;
|
||||||
|
|
||||||
return (
|
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">
|
<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)]"
|
className="size-10 rounded-full border border-[var(--color-border)]"
|
||||||
/>
|
/>
|
||||||
<div
|
<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>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|
@ -347,15 +397,16 @@ const MemberPopoverContent = ({
|
||||||
Lead
|
Lead
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{node.spawnStatus && node.spawnStatus !== 'online' && (
|
{(launchPresentation?.spawnBadgeLabel ?? fallbackSpawnStatusLabel) &&
|
||||||
<Badge
|
(launchPresentation?.spawnBadgeLabel ?? fallbackSpawnStatusLabel) !== statusLabel && (
|
||||||
variant="outline"
|
<Badge
|
||||||
className="border-amber-500/30 px-1.5 py-0 text-[10px] text-amber-400"
|
variant="outline"
|
||||||
>
|
className="border-amber-500/30 px-1.5 py-0 text-[10px] text-amber-400"
|
||||||
{getSpawnStatusBadgeLabel(node.spawnStatus)}
|
>
|
||||||
</Badge>
|
{launchPresentation?.spawnBadgeLabel ?? fallbackSpawnStatusLabel}
|
||||||
)}
|
</Badge>
|
||||||
{node.exceptionLabel && (
|
)}
|
||||||
|
{showExceptionBadge && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`px-1.5 py-0 text-[10px] ${
|
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 { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
|
||||||
|
|
||||||
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
||||||
|
import { GraphActivityHud } from './GraphActivityHud';
|
||||||
import { GraphNodePopover } from './GraphNodePopover';
|
import { GraphNodePopover } from './GraphNodePopover';
|
||||||
|
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||||
|
|
||||||
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
||||||
|
|
||||||
|
|
@ -33,6 +35,10 @@ export const TeamGraphOverlay = ({
|
||||||
onOpenMemberProfile,
|
onOpenMemberProfile,
|
||||||
}: TeamGraphOverlayProps): React.JSX.Element => {
|
}: TeamGraphOverlayProps): React.JSX.Element => {
|
||||||
const graphData = useTeamGraphAdapter(teamName);
|
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)
|
// Task action dispatchers (same pattern as TeamGraphTab)
|
||||||
const dispatchTaskAction = useCallback(
|
const dispatchTaskAction = useCallback(
|
||||||
|
|
@ -85,6 +91,27 @@ export const TeamGraphOverlay = ({
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
onRequestPinAsTab={onPinAsTab}
|
onRequestPinAsTab={onPinAsTab}
|
||||||
className="team-graph-view min-w-0 flex-1"
|
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 }) => (
|
renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose: closeEdge, onSelectNode }) => (
|
||||||
<GraphBlockingEdgePopover
|
<GraphBlockingEdgePopover
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHo
|
||||||
import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
|
import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
|
||||||
|
|
||||||
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
||||||
|
import { GraphActivityHud } from './GraphActivityHud';
|
||||||
import { GraphNodePopover } from './GraphNodePopover';
|
import { GraphNodePopover } from './GraphNodePopover';
|
||||||
|
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||||
|
|
||||||
import type { GraphDomainRef, GraphEventPort, GraphNode } from '@claude-teams/agent-graph';
|
import type { GraphDomainRef, GraphEventPort, GraphNode } from '@claude-teams/agent-graph';
|
||||||
|
|
||||||
|
|
@ -31,6 +33,10 @@ export const TeamGraphTab = ({
|
||||||
isPaneFocused = false,
|
isPaneFocused = false,
|
||||||
}: TeamGraphTabProps): React.JSX.Element => {
|
}: TeamGraphTabProps): React.JSX.Element => {
|
||||||
const graphData = useTeamGraphAdapter(teamName);
|
const graphData = useTeamGraphAdapter(teamName);
|
||||||
|
const leadNodeId = useMemo(
|
||||||
|
() => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null,
|
||||||
|
[graphData.nodes]
|
||||||
|
);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
|
||||||
// Typed event dispatchers (DRY — used in both events + renderOverlay)
|
// Typed event dispatchers (DRY — used in both events + renderOverlay)
|
||||||
|
|
@ -117,6 +123,29 @@ export const TeamGraphTab = ({
|
||||||
className="team-graph-view size-full"
|
className="team-graph-view size-full"
|
||||||
suspendAnimation={!isActive}
|
suspendAnimation={!isActive}
|
||||||
onRequestFullscreen={() => setFullscreen(true)}
|
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 }) => (
|
renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose, onSelectNode }) => (
|
||||||
<GraphBlockingEdgePopover
|
<GraphBlockingEdgePopover
|
||||||
teamName={teamName}
|
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 () => {
|
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);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
useStore.setState({
|
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', () => {
|
it('creates inbox particles for all unseen messages, not only the newest 20', () => {
|
||||||
const adapter = TeamGraphAdapter.create();
|
const adapter = TeamGraphAdapter.create();
|
||||||
adapter.adapt(createBaseTeamData(), 'my-team');
|
adapter.adapt(createBaseTeamData(), 'my-team');
|
||||||
|
|
@ -484,6 +603,88 @@ describe('TeamGraphAdapter particles', () => {
|
||||||
expect(graph.particles.every((particle) => particle.kind === 'inbox_message')).toBe(true);
|
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', () => {
|
it('scopes inbox particle ids by team name to avoid cross-team collisions', () => {
|
||||||
const adapter = TeamGraphAdapter.create();
|
const adapter = TeamGraphAdapter.create();
|
||||||
adapter.adapt(createBaseTeamData({ teamName: 'team-a' }), 'team-a');
|
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