feat(agent-graph): center transient handoff cards
This commit is contained in:
parent
cb603aaf37
commit
ad8cddabcd
12 changed files with 536 additions and 80 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
96
src/features/agent-graph/renderer/ui/GraphActivityCard.tsx
Normal file
96
src/features/agent-graph/renderer/ui/GraphActivityCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue