feat(agent-graph): center transient handoff cards

This commit is contained in:
777genius 2026-04-18 17:13:57 +03:00
parent cb603aaf37
commit ad8cddabcd
12 changed files with 536 additions and 80 deletions

View file

@ -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<string, number>();
const stackIndexByAnchor = new Map<string, number>();
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;

View file

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

View file

@ -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<string> | null;
focusEdgeIds?: ReadonlySet<string> | null;
}) => {
cards: TransientHandoffCard[];
time: number;
};
}
export interface GraphCanvasProps {
@ -163,6 +172,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
const activeParticleEdgesCache = useRef(new Set<string>());
const handoffStateRef = useRef(createTransientHandoffState());
const lastTeamNameRef = useRef<string | null>(null);
const lastDrawTimeRef = useRef(0);
// Imperative draw function — called from RAF, NOT from React render
useImperativeHandle(
@ -181,6 +191,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(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<GraphCanvasHandle, GraphCanvasProps>(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<GraphCanvasHandle, GraphCanvasProps>(funct
}
},
getCanvas: () => canvasRef.current,
getTransientHandoffSnapshot: (options) => ({
cards: selectRenderableTransientHandoffCards(handoffStateRef.current, options),
time: lastDrawTimeRef.current,
}),
}),
[showHexGrid, showStarField, bloomIntensity]
);

View file

@ -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<string> | null;
focusEdgeIds?: ReadonlySet<string> | 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<string> | null;
focusEdgeIds: ReadonlySet<string> | null;
}) => React.ReactNode;
}
@ -250,6 +256,17 @@ export function GraphView({
(ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId),
[]
);
const getTransientHandoffSnapshot = useCallback(
(options?: {
focusNodeIds?: ReadonlySet<string> | null;
focusEdgeIds?: ReadonlySet<string> | 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,
})}
</div>
) : null}

View file

@ -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<string, TransientHandoffCard[]>();
const byAnchor = new Map<string, TransientHandoffCard[]>();
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);
}

View file

@ -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<string, string>;
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 (
<div
className={[
'h-[72px] min-h-[72px] min-w-0 max-w-full overflow-hidden',
interactive ? 'cursor-pointer' : '',
className ?? '',
]
.filter(Boolean)
.join(' ')}
role={interactive ? 'button' : undefined}
tabIndex={interactive ? 0 : undefined}
onClick={onClick}
onKeyDown={
interactive
? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick?.();
}
}
: undefined
}
onDragStart={(event) => {
event.preventDefault();
}}
>
<ActivityItem
message={message}
teamName={teamName}
compactHeader
collapseMode="managed"
isCollapsed
canToggleCollapse={false}
isUnread={isUnread}
memberRole={renderProps.memberRole}
memberColor={renderProps.memberColor}
recipientColor={renderProps.recipientColor}
memberColorMap={messageContext.colorMap}
localMemberNames={messageContext.localMemberNames}
onMemberNameClick={(memberName) => onOpenMemberProfile?.(memberName)}
onTaskIdClick={onOpenTaskDetail}
zebraShade={zebraShade}
teamNames={teamNames}
teamColorByName={teamColorByName}
/>
</div>
);
};

View file

@ -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<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);
@ -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 = ({
}
}}
>
<ActivityItem
<GraphActivityCard
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}
messageContext={messageContext}
teamNames={teamNames}
teamColorByName={teamColorByName}
isUnread={isUnread}
zebraShade={index % 2 === 1}
onClick={() => handleMessageClick(timelineItem)}
onOpenTaskDetail={onOpenTaskDetail}
onOpenMemberProfile={onOpenMemberProfile}
/>
</div>
);

View file

@ -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<string> | null;
focusEdgeIds?: ReadonlySet<string> | 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<string> | null;
focusEdgeIds: ReadonlySet<string> | 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<HTMLDivElement | null>(null);
const shellRefs = useRef(new Map<string, HTMLDivElement | null>());
const signatureRef = useRef('');
const [cards, setCards] = useState<TransientHandoffCard[]>([]);
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<string, number>();
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 (
<div
ref={worldLayerRef}
className="pointer-events-none absolute left-0 top-0 z-[9] origin-top-left select-none"
>
{handoffMessages.map(({ card, message, zebraShade }) => (
<div
key={card.key}
ref={(element) => {
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();
}}
>
<GraphActivityCard
message={message}
teamName={teamName}
messageContext={messageContext}
teamNames={teamNames}
teamColorByName={teamColorByName}
zebraShade={zebraShade}
className="pointer-events-none drop-shadow-[0_0_22px_rgba(94,234,212,0.12)]"
/>
</div>
))}
</div>
);
};

View file

@ -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<string> | null;
focusEdgeIds?: ReadonlySet<string> | 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<string> | null;
};
const { getViewportSize, focusNodeIds } = extraHudProps;
return (
<>
<GraphTransientHandoffHud
teamName={teamName}
getTransientHandoffSnapshot={extraHudProps.getTransientHandoffSnapshot}
getCameraZoom={extraHudProps.getCameraZoom}
worldToScreen={extraHudProps.worldToScreen}
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
focusNodeIds={focusNodeIds}
focusEdgeIds={extraHudProps.focusEdgeIds ?? null}
/>
<GraphActivityHud
teamName={teamName}
nodes={graphData.nodes}

View file

@ -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 {
@ -165,13 +166,31 @@ export const TeamGraphTab = ({
height: number;
} | null;
getCameraZoom?: () => number;
getTransientHandoffSnapshot?: (options?: {
focusNodeIds?: ReadonlySet<string> | null;
focusEdgeIds?: ReadonlySet<string> | 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<string> | null;
};
const { getViewportSize, focusNodeIds } = extraHudProps;
return (
<>
<GraphTransientHandoffHud
teamName={teamName}
getTransientHandoffSnapshot={extraHudProps.getTransientHandoffSnapshot}
getCameraZoom={extraHudProps.getCameraZoom}
worldToScreen={extraHudProps.worldToScreen}
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
focusNodeIds={focusNodeIds}
focusEdgeIds={extraHudProps.focusEdgeIds ?? null}
enabled={isActive}
/>
<GraphActivityHud
teamName={teamName}
nodes={graphData.nodes}

View file

@ -0,0 +1,70 @@
import type { TransientHandoffCard } from '@claude-teams/agent-graph';
import type { InboxMessage, TaskRef } from '@shared/types';
function buildTaskRefs(teamName: string, card: TransientHandoffCard): TaskRef[] | undefined {
if (!card.relatedTaskId) {
return undefined;
}
return [
{
taskId: card.relatedTaskId,
displayId: card.relatedTaskDisplayId ?? card.relatedTaskId.slice(0, 8),
teamName,
},
];
}
function buildSummary(card: TransientHandoffCard): string {
const preview = card.preview?.trim();
if (preview) {
return preview;
}
if (card.kind === 'task_assign' && card.relatedTaskDisplayId) {
return `Task ${card.relatedTaskDisplayId} assigned`;
}
if (card.kind === 'task_comment' && card.relatedTaskDisplayId) {
return `${card.relatedTaskDisplayId} updated`;
}
return `${card.sourceLabel} -> ${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,
};
}

View file

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