feat(agent-graph): add activity visibility toggle

This commit is contained in:
777genius 2026-04-20 20:28:57 +03:00
parent aefd2e93ac
commit a76404fec7
7 changed files with 150 additions and 4 deletions

View file

@ -32,6 +32,7 @@ export interface GraphConfigPort {
}; };
// ─── Filters (show/hide node kinds) ──────────────────────────────────── // ─── Filters (show/hide node kinds) ────────────────────────────────────
showActivity?: boolean;
showTasks?: boolean; showTasks?: boolean;
showProcesses?: boolean; showProcesses?: boolean;
showCompletedTasks?: boolean; showCompletedTasks?: boolean;

View file

@ -6,6 +6,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import * as Tooltip from '@radix-ui/react-tooltip'; import * as Tooltip from '@radix-ui/react-tooltip';
import { import {
Activity,
Columns3, Columns3,
Expand, Expand,
Settings2, Settings2,
@ -26,6 +27,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
export interface GraphFilterState { export interface GraphFilterState {
showActivity: boolean;
showTasks: boolean; showTasks: boolean;
showProcesses: boolean; showProcesses: boolean;
showEdges: boolean; showEdges: boolean;
@ -219,6 +221,13 @@ export function GraphControls({
border: '1px solid rgba(100, 200, 255, 0.12)', border: '1px solid rgba(100, 200, 255, 0.12)',
}} }}
> >
<ToolbarToggle
active={filters.showActivity}
onClick={() => toggle('showActivity')}
icon={<Activity size={13} />}
label="Activity"
block
/>
<ToolbarToggle <ToolbarToggle
active={filters.showTasks} active={filters.showTasks}
onClick={() => toggle('showTasks')} onClick={() => toggle('showTasks')}

View file

@ -70,6 +70,7 @@ export interface GraphViewProps {
onSelectNode: (nodeId: string) => void; onSelectNode: (nodeId: string) => void;
}) => React.ReactNode; }) => React.ReactNode;
renderHud?: (props: { renderHud?: (props: {
filters: GraphFilterState;
getLaunchAnchorScreenPlacement: ( getLaunchAnchorScreenPlacement: (
leadNodeId: string, leadNodeId: string,
) => { x: number; y: number; scale: number; visible: boolean } | null; ) => { x: number; y: number; scale: number; visible: boolean } | null;
@ -112,6 +113,7 @@ export function GraphView({
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null); const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [interactionLocked, setInteractionLocked] = useState(false); const [interactionLocked, setInteractionLocked] = useState(false);
const [filters, setFilters] = useState<GraphFilterState>({ const [filters, setFilters] = useState<GraphFilterState>({
showActivity: config?.showActivity ?? true,
showTasks: config?.showTasks ?? true, showTasks: config?.showTasks ?? true,
showProcesses: config?.showProcesses ?? true, showProcesses: config?.showProcesses ?? true,
showEdges: true, showEdges: true,
@ -1016,6 +1018,7 @@ export function GraphView({
{renderHud ? ( {renderHud ? (
<div className="pointer-events-none absolute inset-0 z-[5] overflow-hidden"> <div className="pointer-events-none absolute inset-0 z-[5] overflow-hidden">
{renderHud({ {renderHud({
filters,
getLaunchAnchorScreenPlacement, getLaunchAnchorScreenPlacement,
getActivityWorldRect, getActivityWorldRect,
getTransientHandoffSnapshot, getTransientHandoffSnapshot,

View file

@ -152,7 +152,7 @@ export const TeamGraphOverlay = ({
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
focusEdgeIds?: ReadonlySet<string> | null; focusEdgeIds?: ReadonlySet<string> | null;
}; };
const { getViewportSize, focusNodeIds } = extraHudProps; const { getViewportSize, focusNodeIds, filters } = extraHudProps;
return ( return (
<> <>
@ -174,6 +174,7 @@ export const TeamGraphOverlay = ({
getNodeWorldPosition={extraHudProps.getNodeWorldPosition} getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
getViewportSize={getViewportSize} getViewportSize={getViewportSize}
focusNodeIds={focusNodeIds} focusNodeIds={focusNodeIds}
enabled={filters?.showActivity ?? true}
onOpenTaskDetail={onOpenTaskDetail} onOpenTaskDetail={onOpenTaskDetail}
onOpenMemberProfile={onOpenMemberProfile} onOpenMemberProfile={onOpenMemberProfile}
/> />

View file

@ -176,7 +176,7 @@ export const TeamGraphTab = ({
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
focusEdgeIds?: ReadonlySet<string> | null; focusEdgeIds?: ReadonlySet<string> | null;
}; };
const { getViewportSize, focusNodeIds } = extraHudProps; const { getViewportSize, focusNodeIds, filters } = extraHudProps;
return ( return (
<> <>
@ -199,7 +199,7 @@ export const TeamGraphTab = ({
getNodeWorldPosition={extraHudProps.getNodeWorldPosition} getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
getViewportSize={getViewportSize} getViewportSize={getViewportSize}
focusNodeIds={focusNodeIds} focusNodeIds={focusNodeIds}
enabled={isActive} enabled={isActive && (filters?.showActivity ?? true)}
onOpenTaskDetail={dispatchOpenTask} onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile} onOpenMemberProfile={dispatchOpenProfile}
/> />

View file

@ -36,6 +36,7 @@ describe('GraphControls', () => {
root.render( root.render(
React.createElement(GraphControls, { React.createElement(GraphControls, {
filters: { filters: {
showActivity: true,
showTasks: true, showTasks: true,
showProcesses: true, showProcesses: true,
showEdges: true, showEdges: true,
@ -88,6 +89,7 @@ describe('GraphControls', () => {
root.render( root.render(
React.createElement(GraphControls, { React.createElement(GraphControls, {
filters: { filters: {
showActivity: true,
showTasks: true, showTasks: true,
showProcesses: true, showProcesses: true,
showEdges: true, showEdges: true,
@ -112,4 +114,62 @@ describe('GraphControls', () => {
await Promise.resolve(); await Promise.resolve();
}); });
}); });
it('toggles activity visibility from graph settings', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onFiltersChange = vi.fn();
await act(async () => {
root.render(
React.createElement(GraphControls, {
filters: {
showActivity: true,
showTasks: true,
showProcesses: true,
showEdges: true,
paused: false,
},
onFiltersChange,
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onZoomToFit: vi.fn(),
teamName: 'demo-team',
})
);
await Promise.resolve();
});
const settingsButton = host.querySelector('button[aria-label="Graph settings"]');
expect(settingsButton).not.toBeNull();
await act(async () => {
settingsButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
const activityButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Activity')
);
expect(activityButton).not.toBeUndefined();
await act(async () => {
activityButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(onFiltersChange).toHaveBeenCalledWith({
showActivity: false,
showTasks: true,
showProcesses: true,
showEdges: true,
paused: false,
});
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
}); });

View file

@ -31,6 +31,7 @@ const hoisted = vi.hoisted(() => ({
time: 0, time: 0,
}, },
clearTransientOwnerPositions: vi.fn(), clearTransientOwnerPositions: vi.fn(),
graphControlsProps: null as null | Record<string, unknown>,
})); }));
vi.mock('../../../../packages/agent-graph/src/hooks/useGraphCamera', () => ({ vi.mock('../../../../packages/agent-graph/src/hooks/useGraphCamera', () => ({
@ -69,7 +70,10 @@ vi.mock('../../../../packages/agent-graph/src/hooks/useGraphSimulation', () => (
})); }));
vi.mock('../../../../packages/agent-graph/src/ui/GraphControls', () => ({ vi.mock('../../../../packages/agent-graph/src/ui/GraphControls', () => ({
GraphControls: () => null, GraphControls: (props: Record<string, unknown>) => {
hoisted.graphControlsProps = props;
return null;
},
})); }));
vi.mock('../../../../packages/agent-graph/src/ui/GraphOverlay', () => ({ vi.mock('../../../../packages/agent-graph/src/ui/GraphOverlay', () => ({
@ -95,6 +99,7 @@ describe('GraphView pan interactions', () => {
hoisted.interaction.isDragging.current = false; hoisted.interaction.isDragging.current = false;
hoisted.simulationState.nodes = []; hoisted.simulationState.nodes = [];
hoisted.simulationState.edges = []; hoisted.simulationState.edges = [];
hoisted.graphControlsProps = null;
vi.stubGlobal( vi.stubGlobal(
'ResizeObserver', 'ResizeObserver',
class { class {
@ -397,4 +402,71 @@ describe('GraphView pan interactions', () => {
expect(hoisted.interaction.handleMouseUp).toHaveBeenCalledTimes(1); expect(hoisted.interaction.handleMouseUp).toHaveBeenCalledTimes(1);
expect(hoisted.clearTransientOwnerPositions).toHaveBeenCalledTimes(1); expect(hoisted.clearTransientOwnerPositions).toHaveBeenCalledTimes(1);
}); });
it('passes activity filter state to renderHud and updates it through graph controls', async () => {
const renderHud = vi.fn(() => null);
await act(async () => {
root.render(
React.createElement(GraphView, {
data: {
teamName: 'demo-team',
nodes: [],
edges: [],
particles: [],
},
config: {
animationEnabled: false,
showActivity: false,
},
renderHud,
})
);
await Promise.resolve();
});
expect(renderHud).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: expect.objectContaining({
showActivity: false,
}),
})
);
const controlsProps = hoisted.graphControlsProps as
| {
filters: {
showActivity: boolean;
showTasks: boolean;
showProcesses: boolean;
showEdges: boolean;
paused: boolean;
};
onFiltersChange: (filters: {
showActivity: boolean;
showTasks: boolean;
showProcesses: boolean;
showEdges: boolean;
paused: boolean;
}) => void;
}
| null;
expect(controlsProps).not.toBeNull();
await act(async () => {
controlsProps?.onFiltersChange({
...controlsProps!.filters,
showActivity: true,
});
await Promise.resolve();
});
expect(renderHud).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: expect.objectContaining({
showActivity: true,
}),
})
);
});
}); });