517 lines
18 KiB
TypeScript
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}
|
|
/>
|
|
</>
|
|
);
|
|
};
|