- Added stable slot layout support in various components, enhancing the layout and interaction of nodes. - Updated TypeScript configuration to include new paths for the agent-graph package. - Refactored layout logic in activity lanes and kanban to accommodate stable slot assignments. - Enhanced GraphView and GraphControls to support sidebar visibility toggling and owner slot drop handling. - Introduced new types for layout management in GraphDataPort and related files. - Updated README to include stable slot layout documentation.
343 lines
9.7 KiB
TypeScript
343 lines
9.7 KiB
TypeScript
import React, { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { ACTIVITY_ANCHOR_LAYOUT } from '@claude-teams/agent-graph';
|
|
import { GraphActivityHud } from '@features/agent-graph/renderer/ui/GraphActivityHud';
|
|
|
|
import type { GraphNode } from '@claude-teams/agent-graph';
|
|
import type { InboxMessage } from '@shared/types/team';
|
|
|
|
const teamState = {
|
|
selectedTeamName: 'demo-team',
|
|
selectedTeamData: {
|
|
members: [
|
|
{ name: 'team-lead', agentType: 'team-lead' },
|
|
{ name: 'jack', agentType: 'developer' },
|
|
],
|
|
tasks: [],
|
|
messages: [],
|
|
},
|
|
teamDataCacheByName: new Map<
|
|
string,
|
|
{ members: Record<string, unknown>[]; tasks: unknown[]; messages: unknown[] }
|
|
>([
|
|
[
|
|
'demo-team',
|
|
{
|
|
members: [
|
|
{ name: 'team-lead', agentType: 'team-lead' },
|
|
{ name: 'jack', agentType: 'developer' },
|
|
],
|
|
tasks: [],
|
|
messages: [],
|
|
},
|
|
],
|
|
]),
|
|
teams: [],
|
|
};
|
|
|
|
const buildInlineActivityEntries = vi.fn();
|
|
const originalOffsetWidthDescriptor = Object.getOwnPropertyDescriptor(
|
|
HTMLElement.prototype,
|
|
'offsetWidth'
|
|
);
|
|
const originalOffsetHeightDescriptor = Object.getOwnPropertyDescriptor(
|
|
HTMLElement.prototype,
|
|
'offsetHeight'
|
|
);
|
|
|
|
vi.mock('@renderer/store', () => ({
|
|
useStore: (selector: (state: typeof teamState) => unknown) => selector(teamState),
|
|
}));
|
|
|
|
vi.mock('@renderer/store/slices/teamSlice', () => ({
|
|
selectTeamDataForName: (_state: typeof teamState, teamName: string) =>
|
|
teamState.teamDataCacheByName.get(teamName) ??
|
|
(teamState.selectedTeamName === teamName ? teamState.selectedTeamData : null),
|
|
}));
|
|
|
|
vi.mock('zustand/react/shallow', () => ({
|
|
useShallow: (selector: unknown) => selector,
|
|
}));
|
|
|
|
vi.mock('@renderer/hooks/useTeamMessagesRead', () => ({
|
|
useTeamMessagesRead: () => ({
|
|
readSet: new Set<string>(),
|
|
markRead: vi.fn(),
|
|
markAllRead: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@renderer/hooks/useStableTeamMentionMeta', () => ({
|
|
useStableTeamMentionMeta: () => ({
|
|
teamNames: [],
|
|
teamColorByName: new Map(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/activity/ActivityItem', () => ({
|
|
ActivityItem: ({ message }: { message: InboxMessage }) =>
|
|
React.createElement('div', { 'data-testid': 'activity-item' }, message.summary ?? message.text),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/activity/MessageExpandDialog', () => ({
|
|
MessageExpandDialog: () => null,
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/activity/activityMessageContext', () => ({
|
|
buildMessageContext: () => ({
|
|
colorMap: new Map(),
|
|
localMemberNames: new Set<string>(),
|
|
memberInfo: new Map(),
|
|
}),
|
|
resolveMessageRenderProps: () => ({}),
|
|
}));
|
|
|
|
vi.mock('@features/agent-graph/core/domain/buildInlineActivityEntries', () => ({
|
|
buildInlineActivityEntries: (...args: unknown[]) => buildInlineActivityEntries(...args),
|
|
getGraphLeadMemberName: () => 'team-lead',
|
|
}));
|
|
|
|
describe('GraphActivityHud', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
buildInlineActivityEntries.mockReset();
|
|
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
|
|
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
|
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
|
configurable: true,
|
|
get() {
|
|
return 296;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
|
|
configurable: true,
|
|
get() {
|
|
return 220;
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
vi.unstubAllGlobals();
|
|
if (originalOffsetWidthDescriptor) {
|
|
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidthDescriptor);
|
|
} else {
|
|
delete (HTMLElement.prototype as { offsetWidth?: number }).offsetWidth;
|
|
}
|
|
if (originalOffsetHeightDescriptor) {
|
|
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeightDescriptor);
|
|
} else {
|
|
delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight;
|
|
}
|
|
});
|
|
|
|
it('opens the member profile on the Activity tab when +N more is clicked', async () => {
|
|
const visibleMessages: InboxMessage[] = [
|
|
{
|
|
from: 'team-lead',
|
|
to: 'jack',
|
|
text: 'First',
|
|
summary: 'First',
|
|
timestamp: '2026-04-13T13:34:00.000Z',
|
|
read: false,
|
|
messageId: 'msg-1',
|
|
},
|
|
{
|
|
from: 'team-lead',
|
|
to: 'jack',
|
|
text: 'Second',
|
|
summary: 'Second',
|
|
timestamp: '2026-04-13T13:35:00.000Z',
|
|
read: false,
|
|
messageId: 'msg-2',
|
|
},
|
|
{
|
|
from: 'team-lead',
|
|
to: 'jack',
|
|
text: 'Third',
|
|
summary: 'Third',
|
|
timestamp: '2026-04-13T13:36:00.000Z',
|
|
read: false,
|
|
messageId: 'msg-3',
|
|
},
|
|
];
|
|
buildInlineActivityEntries.mockReturnValue(
|
|
new Map([
|
|
[
|
|
'member:demo-team:jack',
|
|
visibleMessages.map((message, index) => ({
|
|
ownerNodeId: 'member:demo-team:jack',
|
|
graphItem: {
|
|
id: `item-${index + 1}`,
|
|
kind: 'inbox_message',
|
|
timestamp: message.timestamp,
|
|
title: message.summary ?? '',
|
|
},
|
|
message,
|
|
})),
|
|
],
|
|
])
|
|
);
|
|
|
|
const node: GraphNode = {
|
|
id: 'member:demo-team:jack',
|
|
kind: 'member',
|
|
label: 'jack',
|
|
state: 'active',
|
|
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'jack' },
|
|
activityItems: [
|
|
{
|
|
id: 'item-1',
|
|
kind: 'inbox_message',
|
|
timestamp: '2026-04-13T13:36:00.000Z',
|
|
title: 'Third',
|
|
},
|
|
{
|
|
id: 'item-2',
|
|
kind: 'inbox_message',
|
|
timestamp: '2026-04-13T13:35:00.000Z',
|
|
title: 'Second',
|
|
},
|
|
{
|
|
id: 'item-3',
|
|
kind: 'inbox_message',
|
|
timestamp: '2026-04-13T13:34:00.000Z',
|
|
title: 'First',
|
|
},
|
|
{
|
|
id: 'item-4',
|
|
kind: 'inbox_message',
|
|
timestamp: '2026-04-13T13:33:00.000Z',
|
|
title: 'Older hidden',
|
|
},
|
|
],
|
|
activityOverflowCount: 1,
|
|
};
|
|
|
|
const onOpenMemberProfile = vi.fn();
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(GraphActivityHud, {
|
|
teamName: 'demo-team',
|
|
nodes: [node],
|
|
getActivityAnchorScreenPlacement: () => ({ x: 40, y: 80, scale: 1, visible: true }),
|
|
focusNodeIds: null,
|
|
onOpenMemberProfile,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const moreButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
|
button.textContent?.includes('+1 more')
|
|
);
|
|
expect(moreButton).not.toBeUndefined();
|
|
|
|
await act(async () => {
|
|
moreButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(onOpenMemberProfile).toHaveBeenCalledWith('jack', {
|
|
initialTab: 'activity',
|
|
initialActivityFilter: 'all',
|
|
});
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('keeps the activity lane above the owner label area when packed anchor drifts too low', async () => {
|
|
const message: InboxMessage = {
|
|
from: 'team-lead',
|
|
to: 'jack',
|
|
text: 'Latest log',
|
|
summary: 'Latest log',
|
|
timestamp: '2026-04-13T13:36:00.000Z',
|
|
read: false,
|
|
messageId: 'msg-latest',
|
|
};
|
|
buildInlineActivityEntries.mockReturnValue(
|
|
new Map([
|
|
[
|
|
'member:demo-team:jack',
|
|
[
|
|
{
|
|
ownerNodeId: 'member:demo-team:jack',
|
|
graphItem: {
|
|
id: 'item-1',
|
|
kind: 'inbox_message',
|
|
timestamp: message.timestamp,
|
|
title: message.summary ?? '',
|
|
},
|
|
message,
|
|
},
|
|
],
|
|
],
|
|
])
|
|
);
|
|
|
|
const node: GraphNode = {
|
|
id: 'member:demo-team:jack',
|
|
kind: 'member',
|
|
label: 'jack',
|
|
state: 'active',
|
|
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'jack' },
|
|
activityItems: [
|
|
{
|
|
id: 'item-1',
|
|
kind: 'inbox_message',
|
|
timestamp: message.timestamp,
|
|
title: 'Latest log',
|
|
},
|
|
],
|
|
activityOverflowCount: 0,
|
|
};
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const nodeWorld = { x: 320, y: 300 };
|
|
const packedAnchor = { x: 120, y: 260 };
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(GraphActivityHud, {
|
|
teamName: 'demo-team',
|
|
nodes: [node],
|
|
getActivityAnchorScreenPlacement: () => ({ x: 40, y: 80, scale: 1, visible: true }),
|
|
getActivityAnchorWorldPosition: () => packedAnchor,
|
|
getNodeWorldPosition: () => nodeWorld,
|
|
getNodeScreenPosition: () => ({ x: 400, y: 300, visible: true }),
|
|
getViewportSize: () => ({ width: 1200, height: 800 }),
|
|
worldToScreen: (x: number, y: number) => ({ x, y }),
|
|
focusNodeIds: null,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const shell = host.querySelector('.z-10');
|
|
expect(shell).not.toBeNull();
|
|
const expectedTop =
|
|
nodeWorld.y +
|
|
ACTIVITY_ANCHOR_LAYOUT.memberOffsetY +
|
|
ACTIVITY_ANCHOR_LAYOUT.reservedHeight -
|
|
220;
|
|
expect((shell as HTMLDivElement).style.top).toBe(`${Math.round(expectedTop)}px`);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
});
|