agent-ecosystem/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx
2026-05-02 00:12:02 +03:00

517 lines
18 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { ACTIVITY_LANE } from '@claude-teams/agent-graph';
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';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import {
buildInlineActivityEntries,
getGraphLeadMemberName,
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';
import type {
MemberActivityFilter,
MemberDetailTab,
} from '@renderer/components/team/members/memberDetailTypes';
import type { ResolvedTeamMember } from '@shared/types/team';
const ACTIVITY_SHELL_HEIGHT =
ACTIVITY_LANE.headerHeight +
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
ACTIVITY_LANE.overflowHeight;
interface GraphActivityHudProps {
teamName: string;
nodes: GraphNode[];
getActivityWorldRect?: (ownerNodeId: string) => {
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
} | null;
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;
enabled?: boolean;
onOpenTaskDetail?: (taskId: string) => void;
onOpenMemberProfile?: (
memberName: string,
options?: {
initialTab?: MemberDetailTab;
initialActivityFilter?: MemberActivityFilter;
}
) => void;
}
export const GraphActivityHud = ({
teamName,
nodes,
getActivityWorldRect = () => null,
getCameraZoom = () => 1,
worldToScreen,
getNodeWorldPosition = () => null,
getViewportSize,
focusNodeIds,
enabled = true,
onOpenTaskDetail,
onOpenMemberProfile,
}: GraphActivityHudProps): React.JSX.Element | null => {
const worldLayerRef = useRef<HTMLDivElement | null>(null);
const shellRefs = useRef(new Map<string, HTMLDivElement | null>());
const connectorRefs = useRef(new Map<string, SVGSVGElement | null>());
const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>());
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
const { teamData, teams } = useGraphActivityContext(teamName);
const teamSnapshot = teamData;
const members = teamData?.members ?? [];
const messages = teamData?.messageFeed ?? [];
const ownerNodes = useMemo(
() =>
nodes.filter(
(node): node is GraphNode & { kind: 'lead' | 'member' } =>
node.kind === 'lead' || node.kind === 'member'
),
[nodes]
);
const leadNodeId = ownerNodes.find((node) => node.kind === 'lead')?.id ?? `lead:${teamName}`;
const leadName = teamSnapshot
? getGraphLeadMemberName({ members }, teamName)
: `${teamName}-lead`;
const ownerNodeIds = useMemo(() => new Set(ownerNodes.map((node) => node.id)), [ownerNodes]);
const entryMapByOwnerNodeId = useMemo(() => {
if (!teamSnapshot) {
return new Map<string, InlineActivityEntry[]>();
}
return buildInlineActivityEntries({
data: {
members,
tasks: teamSnapshot.tasks,
messages,
},
teamName,
leadId: leadNodeId,
leadName,
ownerNodeIds,
});
}, [leadName, leadNodeId, members, messages, ownerNodeIds, teamName, teamSnapshot]);
const messageContext = useMemo(() => buildMessageContext(members), [members]);
const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams);
const { readSet } = useTeamMessagesRead(teamName);
useEffect(() => {
setExpandedItem(null);
}, [teamName]);
const visibleLanes = useMemo(() => {
return ownerNodes
.map((node) => {
const graphItems = node.activityItems ?? [];
const overflowCount = node.activityOverflowCount ?? 0;
const visibleCount = Math.max(0, graphItems.length - overflowCount);
const visibleGraphItems = graphItems.slice(0, visibleCount);
const entriesById = new Map(
(entryMapByOwnerNodeId.get(node.id) ?? []).map(
(entry) => [entry.graphItem.id, entry] as const
)
);
const entries = visibleGraphItems
.map((item) => entriesById.get(item.id))
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
return {
node,
entries,
overflowCount,
};
})
.filter(
(lane) => lane.node.kind === 'member' || lane.entries.length > 0 || lane.overflowCount > 0
);
}, [entryMapByOwnerNodeId, ownerNodes]);
useLayoutEffect(() => {
if (!enabled || visibleLanes.length === 0) {
for (const shell of shellRefs.current.values()) {
if (shell) {
shell.style.opacity = '0';
}
}
for (const connector of connectorRefs.current.values()) {
if (connector) {
connector.style.opacity = '0';
}
}
return;
}
let frameId = 0;
const updatePositions = (): void => {
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 measurableLanes: {
lane: (typeof visibleLanes)[number];
shell: HTMLDivElement;
connector: SVGSVGElement | null;
connectorPath: SVGPathElement | null;
laneRect: NonNullable<ReturnType<typeof getActivityWorldRect>>;
nodeWorld: { x: number; y: number };
}[] = [];
for (const lane of visibleLanes) {
const shell = shellRefs.current.get(lane.node.id);
if (!shell) {
continue;
}
const connector = connectorRefs.current.get(lane.node.id) ?? null;
const connectorPath = connectorPathRefs.current.get(lane.node.id) ?? null;
const laneRect = getActivityWorldRect(lane.node.id);
const nodeWorld = getNodeWorldPosition(lane.node.id);
if (!laneRect || !nodeWorld || !worldToScreen) {
shell.style.opacity = '0';
if (connector) {
connector.style.opacity = '0';
}
continue;
}
const zoom = Math.max(getCameraZoom(), 0.001);
const screenTopLeft = worldToScreen(laneRect.left, laneRect.top);
const widthScreen = Math.max(1, laneRect.width * zoom);
const heightScreen = Math.max(1, laneRect.height * zoom);
const viewport = getViewportSize?.();
const laneVisible =
!viewport ||
(screenTopLeft.x + widthScreen > -80 &&
screenTopLeft.x < viewport.width + 80 &&
screenTopLeft.y + heightScreen > -80 &&
screenTopLeft.y < viewport.height + 80);
if (!laneVisible) {
shell.style.opacity = '0';
if (connector) {
connector.style.opacity = '0';
}
continue;
}
measurableLanes.push({
lane,
shell,
connector,
connectorPath,
laneRect,
nodeWorld,
});
}
for (const entry of measurableLanes) {
const { lane, shell, connector, connectorPath, laneRect, nodeWorld } = entry;
const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1;
shell.style.opacity = String(baseOpacity);
shell.style.left = `${Math.round(laneRect.left)}px`;
shell.style.top = `${Math.round(laneRect.top)}px`;
shell.style.transform = '';
if (connector && connectorPath) {
const endX = laneRect.left + laneRect.width / 2;
const endY = laneRect.top >= nodeWorld.y ? laneRect.top + 10 : laneRect.bottom - 10;
const startX = nodeWorld.x;
const startY = nodeWorld.y - 18;
const minX = Math.min(startX, endX);
const minY = Math.min(startY, endY);
const connectorWidth = Math.max(1, Math.abs(endX - startX));
const connectorHeight = Math.max(1, Math.abs(endY - startY));
const localStartX = startX - minX;
const localStartY = startY - minY;
const localEndX = endX - minX;
const localEndY = endY - minY;
const dx = localEndX - localStartX;
const curve = Math.max(28, Math.abs(dx) * 0.35);
const c1x = localStartX + Math.sign(dx || 1) * curve;
const c1y = localStartY;
const c2x = localEndX - Math.sign(dx || 1) * curve;
const c2y = localEndY;
connector.style.opacity = String(baseOpacity);
connector.style.left = `${Math.round(minX)}px`;
connector.style.top = `${Math.round(minY)}px`;
connector.setAttribute('width', String(Math.ceil(connectorWidth)));
connector.setAttribute('height', String(Math.ceil(connectorHeight)));
connector.setAttribute(
'viewBox',
`0 0 ${Math.ceil(connectorWidth)} ${Math.ceil(connectorHeight)}`
);
connectorPath.setAttribute(
'd',
`M ${localStartX.toFixed(1)} ${localStartY.toFixed(1)} C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${localEndX.toFixed(1)} ${localEndY.toFixed(1)}`
);
}
}
frameId = window.requestAnimationFrame(updatePositions);
};
updatePositions();
return () => {
window.cancelAnimationFrame(frameId);
};
}, [
enabled,
focusNodeIds,
getActivityWorldRect,
getCameraZoom,
getNodeWorldPosition,
getViewportSize,
worldToScreen,
visibleLanes,
]);
const handleMessageClick = useCallback((item: TimelineItem) => {
setExpandedItem(item);
}, []);
const handleMessageKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>, item: TimelineItem): void => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleMessageClick(item);
}
},
[handleMessageClick]
);
const handleMemberClick = useCallback(
(member: ResolvedTeamMember) => {
onOpenMemberProfile?.(member.name);
},
[onOpenMemberProfile]
);
const handleOpenOwnerActivity = useCallback(
(node: GraphNode & { kind: 'lead' | 'member' }) => {
if (node.domainRef.kind !== 'lead' && node.domainRef.kind !== 'member') {
return;
}
onOpenMemberProfile?.(node.domainRef.memberName, {
initialTab: 'activity',
initialActivityFilter: 'all',
});
},
[onOpenMemberProfile]
);
const forwardWheelToGraph = useCallback((event: WheelEvent, shell: HTMLDivElement) => {
const graphRoot = shell.closest('.team-graph-view');
const canvas = graphRoot?.querySelector('canvas');
if (!(canvas instanceof HTMLCanvasElement)) {
return;
}
if (event.cancelable) {
event.preventDefault();
}
canvas.dispatchEvent(
new WheelEvent('wheel', {
deltaX: event.deltaX,
deltaY: event.deltaY,
deltaMode: event.deltaMode,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
metaKey: event.metaKey,
bubbles: true,
cancelable: true,
})
);
}, []);
useEffect(() => {
if (!enabled) {
return;
}
const listeners: { shell: HTMLDivElement; handler: (event: WheelEvent) => void }[] = [];
for (const lane of visibleLanes) {
const shell = shellRefs.current.get(lane.node.id);
if (!shell) {
continue;
}
const handler = (event: WheelEvent): void => forwardWheelToGraph(event, shell);
shell.addEventListener('wheel', handler, { passive: false });
listeners.push({ shell, handler });
}
return () => {
for (const { shell, handler } of listeners) {
shell.removeEventListener('wheel', handler);
}
};
}, [enabled, forwardWheelToGraph, visibleLanes]);
const renderLaneEntry = useCallback(
(entry: InlineActivityEntry, index: number): React.JSX.Element => {
const messageKey = toMessageKey(entry.message);
const timelineItem: TimelineItem = {
type: 'message',
message: entry.message,
};
const isUnread = !entry.message.read && !readSet.has(messageKey);
return (
<div
key={entry.graphItem.id}
className="h-[72px] min-h-[72px] min-w-0 max-w-full cursor-pointer overflow-hidden"
role="button"
tabIndex={0}
onClick={() => handleMessageClick(timelineItem)}
onKeyDown={(event) => handleMessageKeyDown(event, timelineItem)}
>
<GraphActivityCard
message={entry.message}
teamName={teamName}
messageContext={messageContext}
teamNames={teamNames}
teamColorByName={teamColorByName}
isUnread={isUnread}
zebraShade={index % 2 === 1}
onClick={() => handleMessageClick(timelineItem)}
onOpenTaskDetail={onOpenTaskDetail}
onOpenMemberProfile={onOpenMemberProfile}
/>
</div>
);
},
[
handleMessageClick,
handleMessageKeyDown,
messageContext,
onOpenMemberProfile,
onOpenTaskDetail,
readSet,
teamColorByName,
teamName,
teamNames,
]
);
if (!enabled || !teamSnapshot || visibleLanes.length === 0) {
return null;
}
return (
<>
<div
ref={worldLayerRef}
className="pointer-events-none absolute left-0 top-0 z-[8] origin-top-left select-none"
>
{visibleLanes.map((lane) => (
<div key={lane.node.id}>
{(() => {
const laneRect = getActivityWorldRect(lane.node.id);
const laneWidth = laneRect?.width ?? ACTIVITY_LANE.width;
const laneHeight = laneRect?.height ?? ACTIVITY_SHELL_HEIGHT;
return (
<>
<svg
ref={(element) => {
connectorRefs.current.set(lane.node.id, element);
}}
className="pointer-events-none absolute z-[9] overflow-visible opacity-0"
>
<path
ref={(element) => {
connectorPathRefs.current.set(lane.node.id, element);
}}
d=""
fill="none"
stroke="rgba(148, 163, 184, 0.3)"
strokeWidth="1.25"
strokeLinecap="round"
strokeDasharray="3 4"
/>
</svg>
<div
ref={(element) => {
shellRefs.current.set(lane.node.id, element);
}}
className="pointer-events-auto absolute z-10 origin-top-left select-none opacity-0"
style={{
width: `${laneWidth}px`,
maxWidth: `${laneWidth}px`,
height: `${laneHeight}px`,
}}
onDragStart={(event) => {
event.preventDefault();
}}
>
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
Activity
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
{lane.entries.length === 0 && lane.overflowCount === 0 ? (
<div className="flex h-[72px] min-h-[72px] items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-[11px] text-slate-400/60">
No recent activity
</div>
) : null}
{lane.entries.map(renderLaneEntry)}
{lane.overflowCount > 0 ? (
<button
type="button"
className="h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
onClick={() => handleOpenOwnerActivity(lane.node)}
>
+{lane.overflowCount} more
</button>
) : null}
</div>
</div>
</div>
</>
);
})()}
</div>
))}
</div>
<MessageExpandDialog
expandedItem={expandedItem}
open={expandedItem !== null}
onOpenChange={(open) => {
if (!open) {
setExpandedItem(null);
}
}}
teamName={teamName}
members={members}
onMemberClick={handleMemberClick}
onTaskIdClick={onOpenTaskDetail}
teamNames={teamNames}
teamColorByName={teamColorByName}
/>
</>
);
};