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) ────────────────────────────────────
showActivity?: boolean;
showTasks?: boolean;
showProcesses?: boolean;
showCompletedTasks?: boolean;

View file

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

View file

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

View file

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

View file

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

View file

@ -36,6 +36,7 @@ describe('GraphControls', () => {
root.render(
React.createElement(GraphControls, {
filters: {
showActivity: true,
showTasks: true,
showProcesses: true,
showEdges: true,
@ -88,6 +89,7 @@ describe('GraphControls', () => {
root.render(
React.createElement(GraphControls, {
filters: {
showActivity: true,
showTasks: true,
showProcesses: true,
showEdges: true,
@ -112,4 +114,62 @@ describe('GraphControls', () => {
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,
},
clearTransientOwnerPositions: vi.fn(),
graphControlsProps: null as null | Record<string, unknown>,
}));
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', () => ({
GraphControls: () => null,
GraphControls: (props: Record<string, unknown>) => {
hoisted.graphControlsProps = props;
return null;
},
}));
vi.mock('../../../../packages/agent-graph/src/ui/GraphOverlay', () => ({
@ -95,6 +99,7 @@ describe('GraphView pan interactions', () => {
hoisted.interaction.isDragging.current = false;
hoisted.simulationState.nodes = [];
hoisted.simulationState.edges = [];
hoisted.graphControlsProps = null;
vi.stubGlobal(
'ResizeObserver',
class {
@ -397,4 +402,71 @@ describe('GraphView pan interactions', () => {
expect(hoisted.interaction.handleMouseUp).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,
}),
})
);
});
});