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 type { CameraTransform } from '../hooks/useGraphCamera';
|
||||||
import { getHandoffAnchorTarget } from '../layout/launchAnchor';
|
import { getHandoffAnchorTarget } from '../layout/launchAnchor';
|
||||||
import type { GraphNode } from '../ports/types';
|
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 { truncateText } from './draw-misc';
|
||||||
import { hexWithAlpha, measureTextCached } from './render-cache';
|
import { hexWithAlpha, measureTextCached } from './render-cache';
|
||||||
|
|
||||||
|
|
@ -20,24 +23,24 @@ export function drawHandoffCards(
|
||||||
const { cards, nodeMap, time, camera, viewport } = params;
|
const { cards, nodeMap, time, camera, viewport } = params;
|
||||||
if (cards.length === 0) return;
|
if (cards.length === 0) return;
|
||||||
|
|
||||||
const stackIndexByDestination = new Map<string, number>();
|
const stackIndexByAnchor = new Map<string, number>();
|
||||||
let drawnCount = 0;
|
let drawnCount = 0;
|
||||||
|
|
||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
if (drawnCount >= HANDOFF_CARD.maxVisible) break;
|
if (drawnCount >= HANDOFF_CARD.maxVisible) break;
|
||||||
const destinationNode = nodeMap.get(card.destinationNodeId);
|
const anchorNode = nodeMap.get(card.anchorNodeId);
|
||||||
if (!destinationNode || destinationNode.x == null || destinationNode.y == null) continue;
|
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;
|
if (alpha <= MIN_VISIBLE_OPACITY) continue;
|
||||||
|
|
||||||
const previewLines = buildPreviewLines(ctx, card.preview);
|
const previewLines = buildPreviewLines(ctx, card.preview);
|
||||||
const height = HANDOFF_CARD.baseHeight + previewLines.length * HANDOFF_CARD.previewLineHeight;
|
const height = HANDOFF_CARD.baseHeight + previewLines.length * HANDOFF_CARD.previewLineHeight;
|
||||||
const stackIndex = stackIndexByDestination.get(card.destinationNodeId) ?? 0;
|
const stackIndex = stackIndexByAnchor.get(card.anchorNodeId) ?? 0;
|
||||||
stackIndexByDestination.set(card.destinationNodeId, stackIndex + 1);
|
stackIndexByAnchor.set(card.anchorNodeId, stackIndex + 1);
|
||||||
|
|
||||||
const position = getCardPosition({
|
const position = getCardPosition({
|
||||||
node: destinationNode,
|
node: anchorNode,
|
||||||
camera,
|
camera,
|
||||||
viewport,
|
viewport,
|
||||||
height,
|
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: {
|
function getCardPosition(params: {
|
||||||
node: GraphNode;
|
node: GraphNode;
|
||||||
camera: CameraTransform;
|
camera: CameraTransform;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
export { GraphView } from './ui/GraphView';
|
export { GraphView } from './ui/GraphView';
|
||||||
export type { GraphViewProps } from './ui/GraphView';
|
export type { GraphViewProps } from './ui/GraphView';
|
||||||
export { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from './layout/activityLane';
|
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) ─────────────────────────
|
// ─── Port Interfaces (for adapters in host project) ─────────────────────────
|
||||||
export type { GraphDataPort } from './ports/GraphDataPort';
|
export type { GraphDataPort } from './ports/GraphDataPort';
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
import {
|
import {
|
||||||
createTransientHandoffState,
|
createTransientHandoffState,
|
||||||
selectRenderableTransientHandoffCards,
|
selectRenderableTransientHandoffCards,
|
||||||
|
type TransientHandoffCard,
|
||||||
updateTransientHandoffState,
|
updateTransientHandoffState,
|
||||||
} from './transientHandoffs';
|
} from './transientHandoffs';
|
||||||
import type { CameraTransform } from '../hooks/useGraphCamera';
|
import type { CameraTransform } from '../hooks/useGraphCamera';
|
||||||
|
|
@ -70,6 +71,14 @@ export interface GraphCanvasHandle {
|
||||||
draw: (state: GraphDrawState) => void;
|
draw: (state: GraphDrawState) => void;
|
||||||
/** Get the canvas element for coordinate transforms */
|
/** Get the canvas element for coordinate transforms */
|
||||||
getCanvas: () => HTMLCanvasElement | null;
|
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 {
|
export interface GraphCanvasProps {
|
||||||
|
|
@ -163,6 +172,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
||||||
const activeParticleEdgesCache = useRef(new Set<string>());
|
const activeParticleEdgesCache = useRef(new Set<string>());
|
||||||
const handoffStateRef = useRef(createTransientHandoffState());
|
const handoffStateRef = useRef(createTransientHandoffState());
|
||||||
const lastTeamNameRef = useRef<string | null>(null);
|
const lastTeamNameRef = useRef<string | null>(null);
|
||||||
|
const lastDrawTimeRef = useRef(0);
|
||||||
|
|
||||||
// Imperative draw function — called from RAF, NOT from React render
|
// Imperative draw function — called from RAF, NOT from React render
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
|
|
@ -181,6 +191,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
||||||
if (w === 0 || h === 0) return;
|
if (w === 0 || h === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
lastDrawTimeRef.current = state.time;
|
||||||
if (lastTeamNameRef.current !== state.teamName) {
|
if (lastTeamNameRef.current !== state.teamName) {
|
||||||
handoffStateRef.current = createTransientHandoffState();
|
handoffStateRef.current = createTransientHandoffState();
|
||||||
lastTeamNameRef.current = state.teamName;
|
lastTeamNameRef.current = state.teamName;
|
||||||
|
|
@ -309,9 +320,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
||||||
focusNodeIds: state.focusNodeIds,
|
focusNodeIds: state.focusNodeIds,
|
||||||
focusEdgeIds: prioritizedEdgeIds ?? state.focusEdgeIds,
|
focusEdgeIds: prioritizedEdgeIds ?? state.focusEdgeIds,
|
||||||
}
|
}
|
||||||
).filter(
|
).filter((card) => card.anchorKind !== 'lead' && card.anchorKind !== 'member');
|
||||||
(card) => card.destinationKind !== 'lead' && card.destinationKind !== 'member'
|
|
||||||
);
|
|
||||||
drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds);
|
drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds);
|
||||||
|
|
||||||
// 2c. Visible nodes only (back to front: process → task → member/lead)
|
// 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,
|
getCanvas: () => canvasRef.current,
|
||||||
|
getTransientHandoffSnapshot: (options) => ({
|
||||||
|
cards: selectRenderableTransientHandoffCards(handoffStateRef.current, options),
|
||||||
|
time: lastDrawTimeRef.current,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
[showHexGrid, showStarField, bloomIntensity]
|
[showHexGrid, showStarField, bloomIntensity]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { GraphControls, type GraphFilterState } from './GraphControls';
|
||||||
import { GraphOverlay } from './GraphOverlay';
|
import { GraphOverlay } from './GraphOverlay';
|
||||||
import { GraphEdgeOverlay } from './GraphEdgeOverlay';
|
import { GraphEdgeOverlay } from './GraphEdgeOverlay';
|
||||||
import { buildFocusState } from './buildFocusState';
|
import { buildFocusState } from './buildFocusState';
|
||||||
|
import type { TransientHandoffCard } from './transientHandoffs';
|
||||||
import { useGraphSimulation } from '../hooks/useGraphSimulation';
|
import { useGraphSimulation } from '../hooks/useGraphSimulation';
|
||||||
import { useGraphCamera } from '../hooks/useGraphCamera';
|
import { useGraphCamera } from '../hooks/useGraphCamera';
|
||||||
import { useGraphInteraction } from '../hooks/useGraphInteraction';
|
import { useGraphInteraction } from '../hooks/useGraphInteraction';
|
||||||
|
|
@ -73,11 +74,16 @@ export interface GraphViewProps {
|
||||||
leadNodeId: string,
|
leadNodeId: string,
|
||||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
||||||
getActivityWorldRect: (ownerNodeId: string) => StableRect | null;
|
getActivityWorldRect: (ownerNodeId: string) => StableRect | null;
|
||||||
|
getTransientHandoffSnapshot: (options?: {
|
||||||
|
focusNodeIds?: ReadonlySet<string> | null;
|
||||||
|
focusEdgeIds?: ReadonlySet<string> | null;
|
||||||
|
}) => { cards: TransientHandoffCard[]; time: number };
|
||||||
getCameraZoom: () => number;
|
getCameraZoom: () => number;
|
||||||
worldToScreen: (x: number, y: number) => { x: number; y: number };
|
worldToScreen: (x: number, y: number) => { x: number; y: number };
|
||||||
getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null;
|
getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null;
|
||||||
getViewportSize: () => { width: number; height: number };
|
getViewportSize: () => { width: number; height: number };
|
||||||
focusNodeIds: ReadonlySet<string> | null;
|
focusNodeIds: ReadonlySet<string> | null;
|
||||||
|
focusEdgeIds: ReadonlySet<string> | null;
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,6 +256,17 @@ export function GraphView({
|
||||||
(ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId),
|
(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 getNodeWorldPosition = useCallback((nodeId: string) => {
|
||||||
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
|
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
|
||||||
if (node?.x == null || node?.y == null) {
|
if (node?.x == null || node?.y == null) {
|
||||||
|
|
@ -946,11 +963,13 @@ export function GraphView({
|
||||||
{renderHud({
|
{renderHud({
|
||||||
getLaunchAnchorScreenPlacement,
|
getLaunchAnchorScreenPlacement,
|
||||||
getActivityWorldRect,
|
getActivityWorldRect,
|
||||||
|
getTransientHandoffSnapshot,
|
||||||
getCameraZoom,
|
getCameraZoom,
|
||||||
worldToScreen: camera.worldToScreen,
|
worldToScreen: camera.worldToScreen,
|
||||||
getNodeWorldPosition,
|
getNodeWorldPosition,
|
||||||
getViewportSize,
|
getViewportSize,
|
||||||
focusNodeIds: focusState.focusNodeIds,
|
focusNodeIds: focusState.focusNodeIds,
|
||||||
|
focusEdgeIds: focusState.focusEdgeIds,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,16 @@ export interface TransientHandoffCard {
|
||||||
edgeId: string;
|
edgeId: string;
|
||||||
sourceNodeId: string;
|
sourceNodeId: string;
|
||||||
destinationNodeId: string;
|
destinationNodeId: string;
|
||||||
|
anchorNodeId: string;
|
||||||
|
anchorKind: GraphNode['kind'];
|
||||||
sourceLabel: string;
|
sourceLabel: string;
|
||||||
destinationLabel: string;
|
destinationLabel: string;
|
||||||
destinationKind: GraphNode['kind'];
|
destinationKind: GraphNode['kind'];
|
||||||
kind: HandoffParticleKind;
|
kind: HandoffParticleKind;
|
||||||
color: string;
|
color: string;
|
||||||
preview?: string;
|
preview?: string;
|
||||||
|
relatedTaskId?: string;
|
||||||
|
relatedTaskDisplayId?: string;
|
||||||
count: number;
|
count: number;
|
||||||
activatedAt: number;
|
activatedAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
|
|
@ -70,6 +74,12 @@ export function updateTransientHandoffState(
|
||||||
const sourceNode = nodeMap.get(sourceNodeId);
|
const sourceNode = nodeMap.get(sourceNodeId);
|
||||||
const destinationNode = nodeMap.get(destinationNodeId);
|
const destinationNode = nodeMap.get(destinationNodeId);
|
||||||
if (!sourceNode || !destinationNode) continue;
|
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);
|
const previewText = normalizePreviewText(particle.preview ?? particle.label);
|
||||||
if (particle.kind === 'inbox_message' && isLowSignalInboxPreview(previewText)) {
|
if (particle.kind === 'inbox_message' && isLowSignalInboxPreview(previewText)) {
|
||||||
|
|
@ -86,12 +96,16 @@ export function updateTransientHandoffState(
|
||||||
edgeId: edge.id,
|
edgeId: edge.id,
|
||||||
sourceNodeId,
|
sourceNodeId,
|
||||||
destinationNodeId,
|
destinationNodeId,
|
||||||
|
anchorNodeId: anchorNode.id,
|
||||||
|
anchorKind: anchorNode.kind,
|
||||||
sourceLabel: sourceNode.label,
|
sourceLabel: sourceNode.label,
|
||||||
destinationLabel: destinationNode.label,
|
destinationLabel: destinationNode.label,
|
||||||
destinationKind: destinationNode.kind,
|
destinationKind: destinationNode.kind,
|
||||||
kind: particle.kind,
|
kind: particle.kind,
|
||||||
color: particle.color,
|
color: particle.color,
|
||||||
preview: previewText ?? existing?.preview,
|
preview: previewText ?? existing?.preview,
|
||||||
|
relatedTaskId: edge.sourceTaskIds?.[0] ?? edge.targetTaskIds?.[0],
|
||||||
|
relatedTaskDisplayId: buildTaskDisplayId(edge.sourceTaskIds?.[0] ?? edge.targetTaskIds?.[0]),
|
||||||
count: nextCount,
|
count: nextCount,
|
||||||
activatedAt: existing?.activatedAt ?? time,
|
activatedAt: existing?.activatedAt ?? time,
|
||||||
updatedAt: time,
|
updatedAt: time,
|
||||||
|
|
@ -112,19 +126,19 @@ export function selectRenderableTransientHandoffCards(
|
||||||
const focusEdgeIds = options?.focusEdgeIds ?? null;
|
const focusEdgeIds = options?.focusEdgeIds ?? null;
|
||||||
const hasFocus = (focusNodeIds?.size ?? 0) > 0 || (focusEdgeIds?.size ?? 0) > 0;
|
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()) {
|
for (const card of state.cardsByKey.values()) {
|
||||||
if (hasFocus && !isCardInFocus(card, focusNodeIds, focusEdgeIds)) continue;
|
if (hasFocus && !isCardInFocus(card, focusNodeIds, focusEdgeIds)) continue;
|
||||||
const destinationCards = byDestination.get(card.destinationNodeId);
|
const anchorCards = byAnchor.get(card.anchorNodeId);
|
||||||
if (destinationCards) {
|
if (anchorCards) {
|
||||||
destinationCards.push(card);
|
anchorCards.push(card);
|
||||||
} else {
|
} else {
|
||||||
byDestination.set(card.destinationNodeId, [card]);
|
byAnchor.set(card.anchorNodeId, [card]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected: TransientHandoffCard[] = [];
|
const selected: TransientHandoffCard[] = [];
|
||||||
for (const cards of byDestination.values()) {
|
for (const cards of byAnchor.values()) {
|
||||||
cards.sort((a, b) => b.updatedAt - a.updatedAt);
|
cards.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
selected.push(...cards.slice(0, HANDOFF_CARD.maxPerDestination));
|
selected.push(...cards.slice(0, HANDOFF_CARD.maxPerDestination));
|
||||||
}
|
}
|
||||||
|
|
@ -145,10 +159,21 @@ function isCardInFocus(
|
||||||
return (
|
return (
|
||||||
!!focusEdgeIds?.has(card.edgeId) ||
|
!!focusEdgeIds?.has(card.edgeId) ||
|
||||||
!!focusNodeIds?.has(card.sourceNodeId) ||
|
!!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 {
|
function normalizePreviewText(text: string | undefined): string | undefined {
|
||||||
if (!text) return undefined;
|
if (!text) return undefined;
|
||||||
const normalized = text
|
const normalized = text
|
||||||
|
|
@ -161,3 +186,10 @@ function normalizePreviewText(text: string | undefined): string | undefined {
|
||||||
function isLowSignalInboxPreview(preview: string | undefined): boolean {
|
function isLowSignalInboxPreview(preview: string | undefined): boolean {
|
||||||
return preview === 'idle';
|
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 { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { ACTIVITY_LANE } from '@claude-teams/agent-graph';
|
import { ACTIVITY_LANE } from '@claude-teams/agent-graph';
|
||||||
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
|
import { buildMessageContext } from '@renderer/components/team/activity/activityMessageContext';
|
||||||
import {
|
|
||||||
buildMessageContext,
|
|
||||||
resolveMessageRenderProps,
|
|
||||||
} from '@renderer/components/team/activity/activityMessageContext';
|
|
||||||
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
|
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
|
||||||
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
|
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
|
||||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
|
|
@ -17,6 +13,7 @@ import {
|
||||||
type InlineActivityEntry,
|
type InlineActivityEntry,
|
||||||
} from '../../core/domain/buildInlineActivityEntries';
|
} from '../../core/domain/buildInlineActivityEntries';
|
||||||
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
||||||
|
import { GraphActivityCard } from './GraphActivityCard';
|
||||||
|
|
||||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||||
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
|
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
|
||||||
|
|
@ -279,38 +276,10 @@ export const GraphActivityHud = ({
|
||||||
visibleLanes,
|
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) => {
|
const handleMessageClick = useCallback((item: TimelineItem) => {
|
||||||
setExpandedItem(item);
|
setExpandedItem(item);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMemberNameClick = useCallback(
|
|
||||||
(memberName: string) => {
|
|
||||||
onOpenMemberProfile?.(memberName);
|
|
||||||
},
|
|
||||||
[onOpenMemberProfile]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMemberClick = useCallback(
|
const handleMemberClick = useCallback(
|
||||||
(member: ResolvedTeamMember) => {
|
(member: ResolvedTeamMember) => {
|
||||||
onOpenMemberProfile?.(member.name);
|
onOpenMemberProfile?.(member.name);
|
||||||
|
|
@ -444,10 +413,6 @@ export const GraphActivityHud = ({
|
||||||
) : null}
|
) : null}
|
||||||
{lane.entries.map((entry, index) => {
|
{lane.entries.map((entry, index) => {
|
||||||
const messageKey = toMessageKey(entry.message);
|
const messageKey = toMessageKey(entry.message);
|
||||||
const renderProps = resolveMessageRenderProps(
|
|
||||||
entry.message,
|
|
||||||
messageContext
|
|
||||||
);
|
|
||||||
const timelineItem: TimelineItem = {
|
const timelineItem: TimelineItem = {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
message: entry.message,
|
message: entry.message,
|
||||||
|
|
@ -468,26 +433,17 @@ export const GraphActivityHud = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ActivityItem
|
<GraphActivityCard
|
||||||
message={entry.message}
|
message={entry.message}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
compactHeader
|
messageContext={messageContext}
|
||||||
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}
|
teamNames={teamNames}
|
||||||
teamColorByName={teamColorByName}
|
teamColorByName={teamColorByName}
|
||||||
|
isUnread={isUnread}
|
||||||
|
zebraShade={index % 2 === 1}
|
||||||
|
onClick={() => handleMessageClick(timelineItem)}
|
||||||
|
onOpenTaskDetail={onOpenTaskDetail}
|
||||||
|
onOpenMemberProfile={onOpenMemberProfile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
||||||
import { GraphNodePopover } from './GraphNodePopover';
|
import { GraphNodePopover } from './GraphNodePopover';
|
||||||
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||||
|
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
|
||||||
|
|
||||||
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -141,13 +142,30 @@ export const TeamGraphOverlay = ({
|
||||||
height: number;
|
height: number;
|
||||||
} | null;
|
} | null;
|
||||||
getCameraZoom?: () => number;
|
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 };
|
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||||
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||||
|
focusEdgeIds?: ReadonlySet<string> | null;
|
||||||
};
|
};
|
||||||
const { getViewportSize, focusNodeIds } = extraHudProps;
|
const { getViewportSize, focusNodeIds } = extraHudProps;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<GraphTransientHandoffHud
|
||||||
|
teamName={teamName}
|
||||||
|
getTransientHandoffSnapshot={extraHudProps.getTransientHandoffSnapshot}
|
||||||
|
getCameraZoom={extraHudProps.getCameraZoom}
|
||||||
|
worldToScreen={extraHudProps.worldToScreen}
|
||||||
|
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
|
||||||
|
focusNodeIds={focusNodeIds}
|
||||||
|
focusEdgeIds={extraHudProps.focusEdgeIds ?? null}
|
||||||
|
/>
|
||||||
<GraphActivityHud
|
<GraphActivityHud
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
nodes={graphData.nodes}
|
nodes={graphData.nodes}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { GraphActivityHud } from './GraphActivityHud';
|
||||||
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
||||||
import { GraphNodePopover } from './GraphNodePopover';
|
import { GraphNodePopover } from './GraphNodePopover';
|
||||||
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||||
|
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
|
||||||
|
|
||||||
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -165,13 +166,31 @@ export const TeamGraphTab = ({
|
||||||
height: number;
|
height: number;
|
||||||
} | null;
|
} | null;
|
||||||
getCameraZoom?: () => number;
|
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 };
|
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||||
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||||
|
focusEdgeIds?: ReadonlySet<string> | null;
|
||||||
};
|
};
|
||||||
const { getViewportSize, focusNodeIds } = extraHudProps;
|
const { getViewportSize, focusNodeIds } = extraHudProps;
|
||||||
|
|
||||||
return (
|
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
|
<GraphActivityHud
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
nodes={graphData.nodes}
|
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