From ad8cddabcd33a56efcee694f9957f716c8ef9a5a Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 17:13:57 +0300 Subject: [PATCH] feat(agent-graph): center transient handoff cards --- .../src/canvas/draw-handoff-cards.ts | 28 ++- packages/agent-graph/src/index.ts | 2 + packages/agent-graph/src/ui/GraphCanvas.tsx | 19 +- packages/agent-graph/src/ui/GraphView.tsx | 19 ++ .../agent-graph/src/ui/transientHandoffs.ts | 46 ++++- .../renderer/ui/GraphActivityCard.tsx | 96 ++++++++++ .../renderer/ui/GraphActivityHud.tsx | 62 +----- .../renderer/ui/GraphTransientHandoffHud.tsx | 176 ++++++++++++++++++ .../renderer/ui/TeamGraphOverlay.tsx | 18 ++ .../agent-graph/renderer/ui/TeamGraphTab.tsx | 19 ++ .../ui/buildTransientHandoffMessage.ts | 70 +++++++ .../buildTransientHandoffMessage.test.ts | 61 ++++++ 12 files changed, 536 insertions(+), 80 deletions(-) create mode 100644 src/features/agent-graph/renderer/ui/GraphActivityCard.tsx create mode 100644 src/features/agent-graph/renderer/ui/GraphTransientHandoffHud.tsx create mode 100644 src/features/agent-graph/renderer/ui/buildTransientHandoffMessage.ts create mode 100644 test/renderer/features/agent-graph/buildTransientHandoffMessage.test.ts diff --git a/packages/agent-graph/src/canvas/draw-handoff-cards.ts b/packages/agent-graph/src/canvas/draw-handoff-cards.ts index 098e439f..a0e62860 100644 --- a/packages/agent-graph/src/canvas/draw-handoff-cards.ts +++ b/packages/agent-graph/src/canvas/draw-handoff-cards.ts @@ -3,7 +3,10 @@ import { HANDOFF_CARD, NODE, TASK_PILL, MIN_VISIBLE_OPACITY } from '../constants import type { CameraTransform } from '../hooks/useGraphCamera'; import { getHandoffAnchorTarget } from '../layout/launchAnchor'; import type { GraphNode } from '../ports/types'; -import type { TransientHandoffCard } from '../ui/transientHandoffs'; +import { + getTransientHandoffCardAlpha, + type TransientHandoffCard, +} from '../ui/transientHandoffs'; import { truncateText } from './draw-misc'; import { hexWithAlpha, measureTextCached } from './render-cache'; @@ -20,24 +23,24 @@ export function drawHandoffCards( const { cards, nodeMap, time, camera, viewport } = params; if (cards.length === 0) return; - const stackIndexByDestination = new Map(); + const stackIndexByAnchor = new Map(); let drawnCount = 0; for (const card of cards) { if (drawnCount >= HANDOFF_CARD.maxVisible) break; - const destinationNode = nodeMap.get(card.destinationNodeId); - if (!destinationNode || destinationNode.x == null || destinationNode.y == null) continue; + const anchorNode = nodeMap.get(card.anchorNodeId); + if (!anchorNode || anchorNode.x == null || anchorNode.y == null) continue; - const alpha = getCardAlpha(card, time); + const alpha = getTransientHandoffCardAlpha(card, time); if (alpha <= MIN_VISIBLE_OPACITY) continue; const previewLines = buildPreviewLines(ctx, card.preview); const height = HANDOFF_CARD.baseHeight + previewLines.length * HANDOFF_CARD.previewLineHeight; - const stackIndex = stackIndexByDestination.get(card.destinationNodeId) ?? 0; - stackIndexByDestination.set(card.destinationNodeId, stackIndex + 1); + const stackIndex = stackIndexByAnchor.get(card.anchorNodeId) ?? 0; + stackIndexByAnchor.set(card.anchorNodeId, stackIndex + 1); const position = getCardPosition({ - node: destinationNode, + node: anchorNode, camera, viewport, height, @@ -59,15 +62,6 @@ export function drawHandoffCards( } } -function getCardAlpha(card: TransientHandoffCard, time: number): number { - const fadeIn = Math.min(1, (time - card.activatedAt) / HANDOFF_CARD.fadeInSeconds); - const fadeOutRemaining = card.expiresAt - time; - const fadeOut = fadeOutRemaining <= HANDOFF_CARD.fadeOutSeconds - ? Math.max(0, fadeOutRemaining / HANDOFF_CARD.fadeOutSeconds) - : 1; - return Math.max(0, Math.min(1, fadeIn * fadeOut)); -} - function getCardPosition(params: { node: GraphNode; camera: CameraTransform; diff --git a/packages/agent-graph/src/index.ts b/packages/agent-graph/src/index.ts index 6eda9d4d..cd74c996 100644 --- a/packages/agent-graph/src/index.ts +++ b/packages/agent-graph/src/index.ts @@ -10,6 +10,8 @@ export { GraphView } from './ui/GraphView'; export type { GraphViewProps } from './ui/GraphView'; export { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from './layout/activityLane'; +export { getTransientHandoffCardAlpha } from './ui/transientHandoffs'; +export type { TransientHandoffCard } from './ui/transientHandoffs'; // ─── Port Interfaces (for adapters in host project) ───────────────────────── export type { GraphDataPort } from './ports/GraphDataPort'; diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index 516e3a5f..c4f13588 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -34,6 +34,7 @@ import { import { createTransientHandoffState, selectRenderableTransientHandoffCards, + type TransientHandoffCard, updateTransientHandoffState, } from './transientHandoffs'; import type { CameraTransform } from '../hooks/useGraphCamera'; @@ -70,6 +71,14 @@ export interface GraphCanvasHandle { draw: (state: GraphDrawState) => void; /** Get the canvas element for coordinate transforms */ getCanvas: () => HTMLCanvasElement | null; + /** Read current transient handoff cards for DOM HUD rendering */ + getTransientHandoffSnapshot: (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => { + cards: TransientHandoffCard[]; + time: number; + }; } export interface GraphCanvasProps { @@ -163,6 +172,7 @@ export const GraphCanvas = forwardRef(funct const activeParticleEdgesCache = useRef(new Set()); const handoffStateRef = useRef(createTransientHandoffState()); const lastTeamNameRef = useRef(null); + const lastDrawTimeRef = useRef(0); // Imperative draw function — called from RAF, NOT from React render useImperativeHandle( @@ -181,6 +191,7 @@ export const GraphCanvas = forwardRef(funct if (w === 0 || h === 0) return; try { + lastDrawTimeRef.current = state.time; if (lastTeamNameRef.current !== state.teamName) { handoffStateRef.current = createTransientHandoffState(); lastTeamNameRef.current = state.teamName; @@ -309,9 +320,7 @@ export const GraphCanvas = forwardRef(funct focusNodeIds: state.focusNodeIds, focusEdgeIds: prioritizedEdgeIds ?? state.focusEdgeIds, } - ).filter( - (card) => card.destinationKind !== 'lead' && card.destinationKind !== 'member' - ); + ).filter((card) => card.anchorKind !== 'lead' && card.anchorKind !== 'member'); drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds); // 2c. Visible nodes only (back to front: process → task → member/lead) @@ -419,6 +428,10 @@ export const GraphCanvas = forwardRef(funct } }, getCanvas: () => canvasRef.current, + getTransientHandoffSnapshot: (options) => ({ + cards: selectRenderableTransientHandoffCards(handoffStateRef.current, options), + time: lastDrawTimeRef.current, + }), }), [showHexGrid, showStarField, bloomIntensity] ); diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index c20ed598..c9f1d744 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -22,6 +22,7 @@ import { GraphControls, type GraphFilterState } from './GraphControls'; import { GraphOverlay } from './GraphOverlay'; import { GraphEdgeOverlay } from './GraphEdgeOverlay'; import { buildFocusState } from './buildFocusState'; +import type { TransientHandoffCard } from './transientHandoffs'; import { useGraphSimulation } from '../hooks/useGraphSimulation'; import { useGraphCamera } from '../hooks/useGraphCamera'; import { useGraphInteraction } from '../hooks/useGraphInteraction'; @@ -73,11 +74,16 @@ export interface GraphViewProps { leadNodeId: string, ) => { x: number; y: number; scale: number; visible: boolean } | null; getActivityWorldRect: (ownerNodeId: string) => StableRect | null; + getTransientHandoffSnapshot: (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => { cards: TransientHandoffCard[]; time: number }; getCameraZoom: () => number; worldToScreen: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null; getViewportSize: () => { width: number; height: number }; focusNodeIds: ReadonlySet | null; + focusEdgeIds: ReadonlySet | null; }) => React.ReactNode; } @@ -250,6 +256,17 @@ export function GraphView({ (ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId), [] ); + const getTransientHandoffSnapshot = useCallback( + (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => + canvasHandle.current?.getTransientHandoffSnapshot(options) ?? { + cards: [], + time: 0, + }, + [] + ); const getNodeWorldPosition = useCallback((nodeId: string) => { const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId); if (node?.x == null || node?.y == null) { @@ -946,11 +963,13 @@ export function GraphView({ {renderHud({ getLaunchAnchorScreenPlacement, getActivityWorldRect, + getTransientHandoffSnapshot, getCameraZoom, worldToScreen: camera.worldToScreen, getNodeWorldPosition, getViewportSize, focusNodeIds: focusState.focusNodeIds, + focusEdgeIds: focusState.focusEdgeIds, })} ) : null} diff --git a/packages/agent-graph/src/ui/transientHandoffs.ts b/packages/agent-graph/src/ui/transientHandoffs.ts index 78465e0f..54dbff98 100644 --- a/packages/agent-graph/src/ui/transientHandoffs.ts +++ b/packages/agent-graph/src/ui/transientHandoffs.ts @@ -8,12 +8,16 @@ export interface TransientHandoffCard { edgeId: string; sourceNodeId: string; destinationNodeId: string; + anchorNodeId: string; + anchorKind: GraphNode['kind']; sourceLabel: string; destinationLabel: string; destinationKind: GraphNode['kind']; kind: HandoffParticleKind; color: string; preview?: string; + relatedTaskId?: string; + relatedTaskDisplayId?: string; count: number; activatedAt: number; updatedAt: number; @@ -70,6 +74,12 @@ export function updateTransientHandoffState( const sourceNode = nodeMap.get(sourceNodeId); const destinationNode = nodeMap.get(destinationNodeId); if (!sourceNode || !destinationNode) continue; + const anchorNode = + destinationNode.kind === 'lead' || destinationNode.kind === 'member' + ? destinationNode + : sourceNode.kind === 'lead' || sourceNode.kind === 'member' + ? sourceNode + : destinationNode; const previewText = normalizePreviewText(particle.preview ?? particle.label); if (particle.kind === 'inbox_message' && isLowSignalInboxPreview(previewText)) { @@ -86,12 +96,16 @@ export function updateTransientHandoffState( edgeId: edge.id, sourceNodeId, destinationNodeId, + anchorNodeId: anchorNode.id, + anchorKind: anchorNode.kind, sourceLabel: sourceNode.label, destinationLabel: destinationNode.label, destinationKind: destinationNode.kind, kind: particle.kind, color: particle.color, preview: previewText ?? existing?.preview, + relatedTaskId: edge.sourceTaskIds?.[0] ?? edge.targetTaskIds?.[0], + relatedTaskDisplayId: buildTaskDisplayId(edge.sourceTaskIds?.[0] ?? edge.targetTaskIds?.[0]), count: nextCount, activatedAt: existing?.activatedAt ?? time, updatedAt: time, @@ -112,19 +126,19 @@ export function selectRenderableTransientHandoffCards( const focusEdgeIds = options?.focusEdgeIds ?? null; const hasFocus = (focusNodeIds?.size ?? 0) > 0 || (focusEdgeIds?.size ?? 0) > 0; - const byDestination = new Map(); + const byAnchor = new Map(); for (const card of state.cardsByKey.values()) { if (hasFocus && !isCardInFocus(card, focusNodeIds, focusEdgeIds)) continue; - const destinationCards = byDestination.get(card.destinationNodeId); - if (destinationCards) { - destinationCards.push(card); + const anchorCards = byAnchor.get(card.anchorNodeId); + if (anchorCards) { + anchorCards.push(card); } else { - byDestination.set(card.destinationNodeId, [card]); + byAnchor.set(card.anchorNodeId, [card]); } } const selected: TransientHandoffCard[] = []; - for (const cards of byDestination.values()) { + for (const cards of byAnchor.values()) { cards.sort((a, b) => b.updatedAt - a.updatedAt); selected.push(...cards.slice(0, HANDOFF_CARD.maxPerDestination)); } @@ -145,10 +159,21 @@ function isCardInFocus( return ( !!focusEdgeIds?.has(card.edgeId) || !!focusNodeIds?.has(card.sourceNodeId) || - !!focusNodeIds?.has(card.destinationNodeId) + !!focusNodeIds?.has(card.destinationNodeId) || + !!focusNodeIds?.has(card.anchorNodeId) ); } +export function getTransientHandoffCardAlpha(card: TransientHandoffCard, time: number): number { + const fadeIn = Math.min(1, (time - card.activatedAt) / HANDOFF_CARD.fadeInSeconds); + const fadeOutRemaining = card.expiresAt - time; + const fadeOut = + fadeOutRemaining <= HANDOFF_CARD.fadeOutSeconds + ? Math.max(0, fadeOutRemaining / HANDOFF_CARD.fadeOutSeconds) + : 1; + return Math.max(0, Math.min(1, fadeIn * fadeOut)); +} + function normalizePreviewText(text: string | undefined): string | undefined { if (!text) return undefined; const normalized = text @@ -161,3 +186,10 @@ function normalizePreviewText(text: string | undefined): string | undefined { function isLowSignalInboxPreview(preview: string | undefined): boolean { return preview === 'idle'; } + +function buildTaskDisplayId(taskId: string | undefined): string | undefined { + if (!taskId) { + return undefined; + } + return taskId.slice(0, 8); +} diff --git a/src/features/agent-graph/renderer/ui/GraphActivityCard.tsx b/src/features/agent-graph/renderer/ui/GraphActivityCard.tsx new file mode 100644 index 00000000..e75f5696 --- /dev/null +++ b/src/features/agent-graph/renderer/ui/GraphActivityCard.tsx @@ -0,0 +1,96 @@ +import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; +import { + resolveMessageRenderProps, + type MessageContext, +} from '@renderer/components/team/activity/activityMessageContext'; + +import type { + MemberActivityFilter, + MemberDetailTab, +} from '@renderer/components/team/members/memberDetailTypes'; +import type { InboxMessage } from '@shared/types'; + +interface GraphActivityCardProps { + message: InboxMessage; + teamName: string; + messageContext: MessageContext; + teamNames: string[]; + teamColorByName: ReadonlyMap; + isUnread?: boolean; + zebraShade?: boolean; + className?: string; + onClick?: () => void; + onOpenTaskDetail?: (taskId: string) => void; + onOpenMemberProfile?: ( + memberName: string, + options?: { + initialTab?: MemberDetailTab; + initialActivityFilter?: MemberActivityFilter; + } + ) => void; +} + +export const GraphActivityCard = ({ + message, + teamName, + messageContext, + teamNames, + teamColorByName, + isUnread = false, + zebraShade = false, + className, + onClick, + onOpenTaskDetail, + onOpenMemberProfile, +}: GraphActivityCardProps): React.JSX.Element => { + const renderProps = resolveMessageRenderProps(message, messageContext); + const interactive = Boolean(onClick); + + return ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onClick?.(); + } + } + : undefined + } + onDragStart={(event) => { + event.preventDefault(); + }} + > + onOpenMemberProfile?.(memberName)} + onTaskIdClick={onOpenTaskDetail} + zebraShade={zebraShade} + teamNames={teamNames} + teamColorByName={teamColorByName} + /> +
+ ); +}; diff --git a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index 7a5d4eda..1e26cf33 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -1,11 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ACTIVITY_LANE } from '@claude-teams/agent-graph'; -import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; -import { - buildMessageContext, - resolveMessageRenderProps, -} from '@renderer/components/team/activity/activityMessageContext'; +import { buildMessageContext } from '@renderer/components/team/activity/activityMessageContext'; import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog'; import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; @@ -17,6 +13,7 @@ import { type InlineActivityEntry, } from '../../core/domain/buildInlineActivityEntries'; import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; +import { GraphActivityCard } from './GraphActivityCard'; import type { GraphNode } from '@claude-teams/agent-graph'; import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup'; @@ -279,38 +276,10 @@ export const GraphActivityHud = ({ visibleLanes, ]); - const expandedItemsByKey = useMemo(() => { - const items = new Map(); - for (const lane of visibleLanes) { - for (const entry of lane.entries) { - const key = toMessageKey(entry.message); - items.set(key, { type: 'message', message: entry.message }); - } - } - return items; - }, [visibleLanes]); - - const handleExpandItem = useCallback( - (key: string) => { - const next = expandedItemsByKey.get(key); - if (next) { - setExpandedItem(next); - } - }, - [expandedItemsByKey] - ); - const handleMessageClick = useCallback((item: TimelineItem) => { setExpandedItem(item); }, []); - const handleMemberNameClick = useCallback( - (memberName: string) => { - onOpenMemberProfile?.(memberName); - }, - [onOpenMemberProfile] - ); - const handleMemberClick = useCallback( (member: ResolvedTeamMember) => { onOpenMemberProfile?.(member.name); @@ -444,10 +413,6 @@ export const GraphActivityHud = ({ ) : null} {lane.entries.map((entry, index) => { const messageKey = toMessageKey(entry.message); - const renderProps = resolveMessageRenderProps( - entry.message, - messageContext - ); const timelineItem: TimelineItem = { type: 'message', message: entry.message, @@ -468,26 +433,17 @@ export const GraphActivityHud = ({ } }} > - handleMessageClick(timelineItem)} + onOpenTaskDetail={onOpenTaskDetail} + onOpenMemberProfile={onOpenMemberProfile} /> ); diff --git a/src/features/agent-graph/renderer/ui/GraphTransientHandoffHud.tsx b/src/features/agent-graph/renderer/ui/GraphTransientHandoffHud.tsx new file mode 100644 index 00000000..72406bbe --- /dev/null +++ b/src/features/agent-graph/renderer/ui/GraphTransientHandoffHud.tsx @@ -0,0 +1,176 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +import { + ACTIVITY_LANE, + getTransientHandoffCardAlpha, + type TransientHandoffCard, +} from '@claude-teams/agent-graph'; +import { buildMessageContext } from '@renderer/components/team/activity/activityMessageContext'; +import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; + +import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; + +import { buildTransientHandoffMessage } from './buildTransientHandoffMessage'; +import { GraphActivityCard } from './GraphActivityCard'; + +interface GraphTransientHandoffHudProps { + teamName: string; + getTransientHandoffSnapshot?: (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => { cards: TransientHandoffCard[]; time: number }; + getCameraZoom?: () => number; + worldToScreen?: (x: number, y: number) => { x: number; y: number }; + getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; + focusNodeIds: ReadonlySet | null; + focusEdgeIds: ReadonlySet | null; + enabled?: boolean; +} + +const CARD_WIDTH = ACTIVITY_LANE.width; +const CARD_HEIGHT = 72; +const STACK_GAP = 10; + +export const GraphTransientHandoffHud = ({ + teamName, + getTransientHandoffSnapshot = () => ({ cards: [], time: 0 }), + getCameraZoom = () => 1, + worldToScreen, + getNodeWorldPosition = () => null, + focusNodeIds, + focusEdgeIds, + enabled = true, +}: GraphTransientHandoffHudProps): React.JSX.Element | null => { + const worldLayerRef = useRef(null); + const shellRefs = useRef(new Map()); + const signatureRef = useRef(''); + const [cards, setCards] = useState([]); + const { teamData, teams } = useGraphActivityContext(teamName); + const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]); + const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams); + + useEffect(() => { + signatureRef.current = ''; + setCards([]); + }, [teamName]); + + useLayoutEffect(() => { + if (!enabled) { + setCards([]); + return; + } + + let frameId = 0; + const tick = (): void => { + const snapshot = getTransientHandoffSnapshot({ + focusNodeIds, + focusEdgeIds, + }); + const nextCards = snapshot.cards.filter( + (card) => card.anchorKind === 'lead' || card.anchorKind === 'member' + ); + const nextSignature = nextCards + .map((card) => `${card.key}:${card.count}:${card.updatedAt}:${card.anchorNodeId}`) + .join('|'); + if (nextSignature !== signatureRef.current) { + signatureRef.current = nextSignature; + setCards(nextCards); + } + + const worldLayer = worldLayerRef.current; + if (worldLayer && worldToScreen) { + const origin = worldToScreen(0, 0); + const zoom = Math.max(getCameraZoom(), 0.001); + worldLayer.style.transform = `translate(${Math.round(origin.x)}px, ${Math.round(origin.y)}px) scale(${zoom.toFixed(3)})`; + } + + const stackIndexByAnchor = new Map(); + for (const card of nextCards) { + const shell = shellRefs.current.get(card.key); + if (!shell) { + continue; + } + + const nodeWorld = getNodeWorldPosition(card.anchorNodeId); + const alpha = getTransientHandoffCardAlpha(card, snapshot.time); + if (!nodeWorld || !worldToScreen || alpha <= 0.001) { + shell.style.opacity = '0'; + continue; + } + + const stackIndex = stackIndexByAnchor.get(card.anchorNodeId) ?? 0; + stackIndexByAnchor.set(card.anchorNodeId, stackIndex + 1); + const lift = stackIndex * (CARD_HEIGHT * 0.34 + STACK_GAP); + const scale = 0.94 + alpha * 0.06; + + shell.style.left = `${Math.round(nodeWorld.x)}px`; + shell.style.top = `${Math.round(nodeWorld.y)}px`; + shell.style.opacity = String(alpha); + shell.style.transform = `translate(-50%, calc(-50% - ${lift.toFixed(1)}px)) scale(${scale.toFixed(3)})`; + } + + frameId = window.requestAnimationFrame(tick); + }; + + tick(); + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [ + enabled, + focusEdgeIds, + focusNodeIds, + getCameraZoom, + getNodeWorldPosition, + getTransientHandoffSnapshot, + worldToScreen, + ]); + + const handoffMessages = useMemo( + () => + cards.map((card, index) => ({ + card, + message: buildTransientHandoffMessage(teamName, card), + zebraShade: index % 2 === 1, + })), + [cards, teamName] + ); + + if (!enabled || !teamData || cards.length === 0) { + return null; + } + + return ( +
+ {handoffMessages.map(({ card, message, zebraShade }) => ( +
{ + shellRefs.current.set(card.key, element); + }} + className="pointer-events-none absolute z-[9] origin-center opacity-0 transition-opacity duration-150 ease-out" + style={{ + width: `${CARD_WIDTH}px`, + maxWidth: `${CARD_WIDTH}px`, + }} + onDragStart={(event) => { + event.preventDefault(); + }} + > + +
+ ))} +
+ ); +}; diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index c5e87d8c..33948aba 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -18,6 +18,7 @@ import { GraphActivityHud } from './GraphActivityHud'; import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; import { GraphNodePopover } from './GraphNodePopover'; import { GraphProvisioningHud } from './GraphProvisioningHud'; +import { GraphTransientHandoffHud } from './GraphTransientHandoffHud'; import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; import type { @@ -141,13 +142,30 @@ export const TeamGraphOverlay = ({ height: number; } | null; getCameraZoom?: () => number; + getTransientHandoffSnapshot?: (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => { + cards: import('@claude-teams/agent-graph').TransientHandoffCard[]; + time: number; + }; worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; + focusEdgeIds?: ReadonlySet | null; }; const { getViewportSize, focusNodeIds } = extraHudProps; return ( <> + number; + getTransientHandoffSnapshot?: (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => { + cards: import('@claude-teams/agent-graph').TransientHandoffCard[]; + time: number; + }; worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; + focusEdgeIds?: ReadonlySet | null; }; const { getViewportSize, focusNodeIds } = extraHudProps; return ( <> + ${card.destinationLabel}`; +} + +function buildText(card: TransientHandoffCard): string { + const preview = card.preview?.trim(); + switch (card.kind) { + case 'task_assign': { + const taskLabel = card.relatedTaskDisplayId ?? card.relatedTaskId ?? 'task'; + return `New task assigned to you: ${taskLabel}${preview ? ` - ${preview}` : ''}`; + } + case 'task_comment': + return preview ?? `${card.sourceLabel} added a comment`; + case 'review_request': + return preview ?? `Review requested by ${card.sourceLabel}`; + case 'review_response': + return preview ?? `Review response from ${card.sourceLabel}`; + case 'inbox_message': + default: + return preview ?? `${card.sourceLabel} -> ${card.destinationLabel}`; + } +} + +export function buildTransientHandoffMessage( + teamName: string, + card: TransientHandoffCard +): InboxMessage { + const messageKind = card.kind === 'task_comment' ? 'task_comment_notification' : 'default'; + const taskRefs = buildTaskRefs(teamName, card); + + return { + from: card.sourceLabel, + to: card.destinationLabel, + text: buildText(card), + timestamp: new Date(card.updatedAt * 1000).toISOString(), + read: true, + summary: buildSummary(card), + color: card.color, + messageId: `graph-handoff:${card.key}`, + source: 'inbox', + messageKind, + taskRefs, + }; +} diff --git a/test/renderer/features/agent-graph/buildTransientHandoffMessage.test.ts b/test/renderer/features/agent-graph/buildTransientHandoffMessage.test.ts new file mode 100644 index 00000000..cbc0b5a7 --- /dev/null +++ b/test/renderer/features/agent-graph/buildTransientHandoffMessage.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTransientHandoffMessage } from '../../../../src/features/agent-graph/renderer/ui/buildTransientHandoffMessage'; + +import type { TransientHandoffCard } from '@claude-teams/agent-graph'; + +function buildCard(overrides: Partial = {}): TransientHandoffCard { + return { + key: 'edge-1:fwd:task_comment', + edgeId: 'edge-1', + sourceNodeId: 'member:bob', + destinationNodeId: 'task:abc', + anchorNodeId: 'member:bob', + anchorKind: 'member', + sourceLabel: 'bob', + destinationLabel: 'abc12345', + destinationKind: 'task', + kind: 'task_comment', + color: '#22c55e', + preview: 'Dependency resolved', + relatedTaskId: 'abc12345def67890', + relatedTaskDisplayId: 'abc12345', + count: 1, + activatedAt: 10, + updatedAt: 11, + expiresAt: 15, + ...overrides, + }; +} + +describe('buildTransientHandoffMessage', () => { + it('builds task comment notifications with task refs', () => { + const message = buildTransientHandoffMessage('signal-ops-2', buildCard()); + + expect(message.messageKind).toBe('task_comment_notification'); + expect(message.from).toBe('bob'); + expect(message.taskRefs).toEqual([ + { + taskId: 'abc12345def67890', + displayId: 'abc12345', + teamName: 'signal-ops-2', + }, + ]); + }); + + it('builds task assign text that ActivityItem recognizes as task badge', () => { + const message = buildTransientHandoffMessage( + 'signal-ops-2', + buildCard({ + key: 'edge-2:fwd:task_assign', + kind: 'task_assign', + destinationNodeId: 'member:bob', + destinationKind: 'member', + destinationLabel: 'bob', + }) + ); + + expect(message.messageKind).toBe('default'); + expect(message.text.startsWith('New task assigned to you:')).toBe(true); + }); +});