agent-ecosystem/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx
2026-04-16 22:52:56 +03:00

244 lines
8.7 KiB
TypeScript

/**
* TeamGraphTab — wraps GraphView for use as a dedicated tab.
* Provides Fullscreen button that opens the overlay.
*/
import { lazy, Suspense, useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { GraphView } from '@claude-teams/agent-graph';
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
import { GraphActivityHud } from './GraphActivityHud';
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
import { GraphNodePopover } from './GraphNodePopover';
import { GraphProvisioningHud } from './GraphProvisioningHud';
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
import type {
MemberActivityFilter,
MemberDetailTab,
} from '@renderer/components/team/members/memberDetailTypes';
const TeamGraphOverlay = lazy(() =>
import('./TeamGraphOverlay').then((m) => ({ default: m.TeamGraphOverlay }))
);
export interface TeamGraphTabProps {
teamName: string;
isActive?: boolean;
isPaneFocused?: boolean;
}
interface OpenProfileOptions {
initialTab?: MemberDetailTab;
initialActivityFilter?: MemberActivityFilter;
}
export const TeamGraphTab = ({
teamName,
isActive = true,
isPaneFocused = false,
}: TeamGraphTabProps): React.JSX.Element => {
const graphData = useTeamGraphAdapter(teamName);
const { openTeamPage, resetOwnerSlotAssignmentsToDefaults, commitOwnerSlotDrop } =
useTeamGraphSurfaceActions(teamName);
const [fullscreen, setFullscreen] = useState(false);
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
// Typed event dispatchers (DRY — used in both events + renderOverlay)
const dispatchOpenTask = useCallback(
(taskId: string) =>
window.dispatchEvent(new CustomEvent('graph:open-task', { detail: { teamName, taskId } })),
[teamName]
);
const dispatchSendMessage = useCallback(
(memberName: string) =>
window.dispatchEvent(
new CustomEvent('graph:send-message', { detail: { teamName, memberName } })
),
[teamName]
);
const dispatchOpenProfile = useCallback(
(memberName: string, options?: OpenProfileOptions) =>
window.dispatchEvent(
new CustomEvent('graph:open-profile', {
detail: { teamName, memberName, ...options },
})
),
[teamName]
);
const openCreateTask = useCallback(() => {
openCreateTaskDialog('');
}, [openCreateTaskDialog]);
useLayoutEffect(() => {
if (!isActive) {
return;
}
resetOwnerSlotAssignmentsToDefaults();
}, [isActive, resetOwnerSlotAssignmentsToDefaults]);
// Task action dispatchers
const dispatchTaskAction = useCallback(
(action: string) => (taskId: string) =>
window.dispatchEvent(new CustomEvent(`graph:${action}`, { detail: { teamName, taskId } })),
[teamName]
);
const dispatchStartTask = useMemo(() => dispatchTaskAction('start-task'), [dispatchTaskAction]);
const dispatchCompleteTask = useMemo(
() => dispatchTaskAction('complete-task'),
[dispatchTaskAction]
);
const dispatchApproveTask = useMemo(
() => dispatchTaskAction('approve-task'),
[dispatchTaskAction]
);
const dispatchRequestReview = useMemo(
() => dispatchTaskAction('request-review'),
[dispatchTaskAction]
);
const dispatchRequestChanges = useMemo(
() => dispatchTaskAction('request-changes'),
[dispatchTaskAction]
);
const dispatchCancelTask = useMemo(() => dispatchTaskAction('cancel-task'), [dispatchTaskAction]);
const dispatchMoveBackToDone = useMemo(
() => dispatchTaskAction('move-back-to-done'),
[dispatchTaskAction]
);
const dispatchDeleteTask = useMemo(() => dispatchTaskAction('delete-task'), [dispatchTaskAction]);
const events: GraphEventPort = {
onNodeDoubleClick: useCallback(
(ref: GraphDomainRef) => {
if (ref.kind === 'task') dispatchOpenTask(ref.taskId);
else if (ref.kind === 'member') dispatchOpenProfile(ref.memberName);
},
[dispatchOpenTask, dispatchOpenProfile]
),
onSendMessage: dispatchSendMessage,
onOpenTaskDetail: dispatchOpenTask,
onOpenMemberProfile: useCallback(
(memberName: string) => {
dispatchOpenProfile(memberName);
},
[dispatchOpenProfile]
),
};
return (
<div className="flex size-full overflow-hidden" style={{ background: '#050510' }}>
{sidebarVisible ? (
<TeamSidebarHost
teamName={teamName}
surface="graph-tab"
isActive={isActive}
isFocused={isPaneFocused}
/>
) : null}
<div className="min-w-0 flex-1">
<GraphView
data={graphData}
events={events}
className="team-graph-view size-full"
suspendAnimation={!isActive}
isSurfaceActive={isActive}
onRequestFullscreen={() => setFullscreen(true)}
onOpenTeamPage={openTeamPage}
onCreateTask={openCreateTask}
onToggleSidebar={toggleSidebarVisible}
isSidebarVisible={sidebarVisible}
renderTopToolbarContent={() => (
<GraphProvisioningHud teamName={teamName} enabled={isActive} />
)}
onOwnerSlotDrop={commitOwnerSlotDrop}
renderHud={(hudProps) => {
const extraHudProps = hudProps as typeof hudProps & {
getViewportSize?: () => { width: number; height: number };
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;
};
const { getViewportSize, focusNodeIds } = extraHudProps;
return (
<>
<GraphActivityHud
teamName={teamName}
nodes={graphData.nodes}
getActivityWorldRect={extraHudProps.getActivityWorldRect}
getCameraZoom={extraHudProps.getCameraZoom}
worldToScreen={extraHudProps.worldToScreen}
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
getViewportSize={getViewportSize}
focusNodeIds={focusNodeIds}
enabled={isActive}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile}
/>
</>
);
}}
renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose, onSelectNode }) => (
<GraphBlockingEdgePopover
teamName={teamName}
edge={edge}
sourceNode={sourceNode}
targetNode={targetNode}
onClose={onClose}
onSelectNode={onSelectNode}
onOpenTaskDetail={dispatchOpenTask}
/>
)}
renderOverlay={({ node, onClose }) => (
<GraphNodePopover
node={node}
teamName={teamName}
onClose={onClose}
onSendMessage={dispatchSendMessage}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile}
onCreateTask={openCreateTaskDialog}
onStartTask={dispatchStartTask}
onCompleteTask={dispatchCompleteTask}
onApproveTask={dispatchApproveTask}
onRequestReview={dispatchRequestReview}
onRequestChanges={dispatchRequestChanges}
onCancelTask={dispatchCancelTask}
onMoveBackToDone={dispatchMoveBackToDone}
onDeleteTask={dispatchDeleteTask}
/>
)}
/>
</div>
{createTaskDialog}
{fullscreen && (
<Suspense fallback={null}>
<TeamGraphOverlay
teamName={teamName}
onClose={() => setFullscreen(false)}
sidebarVisible={sidebarVisible}
onToggleSidebar={toggleSidebarVisible}
onSendMessage={dispatchSendMessage}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile}
/>
</Suspense>
)}
</div>
);
};