364 lines
13 KiB
TypeScript
364 lines
13 KiB
TypeScript
import React, { act } from 'react';
|
||
import { createRoot } from 'react-dom/client';
|
||
import { describe, expect, it } from 'vitest';
|
||
import { afterEach, beforeEach, vi } from 'vitest';
|
||
|
||
vi.mock('@renderer/components/team/MemberBadge', () => ({
|
||
MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name),
|
||
}));
|
||
vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({
|
||
CompactMarkdownPreview: ({ content, className }: { content: string; className?: string }) =>
|
||
React.createElement('div', { className }, content),
|
||
}));
|
||
vi.mock('@renderer/components/ui/tooltip', () => ({
|
||
TooltipProvider: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
|
||
Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
|
||
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
||
React.createElement(React.Fragment, null, children),
|
||
TooltipContent: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
|
||
}));
|
||
vi.mock('../../../../../src/renderer/components/team/activity/AnimatedHeightReveal', () => ({
|
||
ENTRY_REVEAL_ANIMATION_MS: 220,
|
||
ENTRY_REVEAL_EASING: 'ease',
|
||
AnimatedHeightReveal: ({ children }: { children: React.ReactNode }) =>
|
||
React.createElement(React.Fragment, null, children),
|
||
}));
|
||
vi.mock('../../../../../src/renderer/components/team/activity/ThoughtBodyContent', () => ({
|
||
ThoughtBodyContent: ({ thought }: { thought: { text: string } }) =>
|
||
React.createElement('div', null, thought.text),
|
||
}));
|
||
vi.mock('@renderer/utils/memberHelpers', () => ({
|
||
agentAvatarUrl: () => '/avatar.png',
|
||
}));
|
||
|
||
import {
|
||
groupTimelineItems,
|
||
isLeadThought,
|
||
LeadThoughtsGroupRow,
|
||
} from '../../../../../src/renderer/components/team/activity/LeadThoughtsGroup';
|
||
|
||
import type { InboxMessage } from '../../../../../src/shared/types';
|
||
|
||
function makeLeadSessionMsg(text: string, overrides?: Partial<InboxMessage>): InboxMessage {
|
||
return {
|
||
from: 'team-lead',
|
||
text,
|
||
timestamp: '2026-03-28T18:30:00.000Z',
|
||
read: true,
|
||
source: 'lead_session',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe('LeadThoughtsGroup', () => {
|
||
beforeEach(() => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
vi.stubGlobal(
|
||
'IntersectionObserver',
|
||
class {
|
||
observe() {}
|
||
disconnect() {}
|
||
}
|
||
);
|
||
vi.stubGlobal(
|
||
'ResizeObserver',
|
||
class {
|
||
observe() {}
|
||
disconnect() {}
|
||
}
|
||
);
|
||
});
|
||
|
||
afterEach(() => {
|
||
document.body.innerHTML = '';
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('does not classify slash command results as lead thoughts', () => {
|
||
const resultMessage: InboxMessage = {
|
||
from: 'team-lead',
|
||
text: 'Total cost: $1.05',
|
||
timestamp: '2026-03-27T22:06:00.000Z',
|
||
read: true,
|
||
source: 'lead_session',
|
||
messageKind: 'slash_command_result',
|
||
commandOutput: {
|
||
stream: 'stdout',
|
||
commandLabel: '/cost',
|
||
},
|
||
};
|
||
|
||
expect(isLeadThought(resultMessage)).toBe(false);
|
||
expect(groupTimelineItems([resultMessage])).toEqual([
|
||
{
|
||
type: 'message',
|
||
message: resultMessage,
|
||
},
|
||
]);
|
||
});
|
||
|
||
describe('teammate-message noise filtering', () => {
|
||
it('excludes closed <teammate-message> blocks with idle_notification from timeline', () => {
|
||
const noise = makeLeadSessionMsg(
|
||
'<teammate-message teammate_id="tom" color="blue"> {"type":"idle_notification","from":"tom","timestamp":"2026-03-28T18:30:49.102Z","idleReason":"available"}</teammate-message>'
|
||
);
|
||
expect(isLeadThought(noise)).toBe(false);
|
||
expect(groupTimelineItems([noise])).toEqual([]);
|
||
});
|
||
|
||
it('excludes unclosed <teammate-message> blocks with idle_notification from timeline', () => {
|
||
const noise = makeLeadSessionMsg(
|
||
'<teammate-message teammate_id="tom" color="blue"> {"type":"idle_notification","from":"tom","timestamp":"2026-03-28T18:30:49.102Z","idleReason":"available"}'
|
||
);
|
||
expect(isLeadThought(noise)).toBe(false);
|
||
expect(groupTimelineItems([noise])).toEqual([]);
|
||
});
|
||
|
||
it('excludes <teammate-message> blocks with shutdown_request from timeline', () => {
|
||
const noise = makeLeadSessionMsg(
|
||
'<teammate-message teammate_id="bob" color="green"> {"type":"shutdown_request"}</teammate-message>'
|
||
);
|
||
expect(isLeadThought(noise)).toBe(false);
|
||
expect(groupTimelineItems([noise])).toEqual([]);
|
||
});
|
||
|
||
it('excludes raw idle_notification JSON from timeline', () => {
|
||
const noise = makeLeadSessionMsg(
|
||
'{"type":"idle_notification","from":"alice","idleReason":"available"}'
|
||
);
|
||
expect(isLeadThought(noise)).toBe(false);
|
||
expect(groupTimelineItems([noise])).toEqual([]);
|
||
});
|
||
|
||
it('does not exclude noise messages with a recipient (captured SendMessage)', () => {
|
||
const sendMsg = makeLeadSessionMsg(
|
||
'{"type":"idle_notification","from":"tom","idleReason":"available"}',
|
||
{ to: 'alice' }
|
||
);
|
||
// Has a recipient, so isLeadThought returns false (line 61), but isLeadSessionNoise
|
||
// also returns false because `to` is non-empty — message should appear in timeline.
|
||
expect(groupTimelineItems([sendMsg])).toEqual([
|
||
{ type: 'message', message: sendMsg },
|
||
]);
|
||
});
|
||
|
||
it('does not exclude non-lead noise messages from timeline', () => {
|
||
const inboxMsg: InboxMessage = {
|
||
from: 'tom',
|
||
text: '{"type":"idle_notification","from":"tom","idleReason":"available"}',
|
||
timestamp: '2026-03-28T18:30:00.000Z',
|
||
read: true,
|
||
// No source — regular inbox message
|
||
};
|
||
expect(groupTimelineItems([inboxMsg])).toEqual([
|
||
{ type: 'message', message: inboxMsg },
|
||
]);
|
||
});
|
||
|
||
it('keeps regular lead thoughts alongside noise', () => {
|
||
const thought = makeLeadSessionMsg('Team is ready. Distributing tasks...');
|
||
const noise = makeLeadSessionMsg(
|
||
'<teammate-message teammate_id="tom" color="blue"> {"type":"idle_notification","from":"tom","idleReason":"available"}</teammate-message>'
|
||
);
|
||
const thought2 = makeLeadSessionMsg('Assigned task #1 to bob.');
|
||
|
||
const items = groupTimelineItems([thought, noise, thought2]);
|
||
// Noise is excluded; both thoughts should be grouped
|
||
expect(items.length).toBe(1);
|
||
expect(items[0].type).toBe('lead-thoughts');
|
||
if (items[0].type === 'lead-thoughts') {
|
||
expect(items[0].group.thoughts).toHaveLength(2);
|
||
expect(items[0].group.thoughts[0].text).toBe(thought.text);
|
||
expect(items[0].group.thoughts[1].text).toBe(thought2.text);
|
||
}
|
||
});
|
||
});
|
||
|
||
it('uses a two-line clamped preview in compact header mode', async () => {
|
||
const host = document.createElement('div');
|
||
document.body.appendChild(host);
|
||
const root = createRoot(host);
|
||
const preview =
|
||
'Это длинный preview текста для lead thoughts, который должен занимать до двух строк в compact header, а не одну.';
|
||
|
||
const thought = makeLeadSessionMsg(preview, {
|
||
messageId: 'thought-1',
|
||
leadSessionId: 'lead-session-1',
|
||
});
|
||
|
||
await act(async () => {
|
||
root.render(
|
||
React.createElement(LeadThoughtsGroupRow, {
|
||
group: { type: 'lead-thoughts', thoughts: [thought] },
|
||
collapseMode: 'managed',
|
||
isCollapsed: true,
|
||
canToggleCollapse: true,
|
||
compactHeader: true,
|
||
})
|
||
);
|
||
await Promise.resolve();
|
||
});
|
||
|
||
const previewNode = host.querySelector('.line-clamp-2');
|
||
expect(previewNode).not.toBeNull();
|
||
expect(previewNode?.textContent).toBe(preview);
|
||
expect(previewNode?.getAttribute('title')).toBeNull();
|
||
expect(previewNode?.className).toContain('line-clamp-2');
|
||
expect(previewNode?.className).toContain('w-full');
|
||
expect(previewNode?.className).toContain('max-w-full');
|
||
expect(previewNode?.className).not.toContain('min-h-8');
|
||
expect(previewNode?.className).not.toContain('truncate');
|
||
|
||
await act(async () => {
|
||
root.unmount();
|
||
await Promise.resolve();
|
||
});
|
||
});
|
||
|
||
it('uses the normalized full thought text instead of only the first line in compact header mode', async () => {
|
||
const host = document.createElement('div');
|
||
document.body.appendChild(host);
|
||
const root = createRoot(host);
|
||
const firstLine = 'Собрать единый remediation plan.';
|
||
const secondLine = 'Проверить remaining edge cases по graph и messages.';
|
||
const preview = `${firstLine} ${secondLine}`;
|
||
|
||
const thought = makeLeadSessionMsg(`${firstLine}\n${secondLine}`, {
|
||
messageId: 'thought-2',
|
||
leadSessionId: 'lead-session-2',
|
||
});
|
||
|
||
await act(async () => {
|
||
root.render(
|
||
React.createElement(LeadThoughtsGroupRow, {
|
||
group: { type: 'lead-thoughts', thoughts: [thought] },
|
||
collapseMode: 'managed',
|
||
isCollapsed: true,
|
||
canToggleCollapse: true,
|
||
compactHeader: true,
|
||
})
|
||
);
|
||
await Promise.resolve();
|
||
});
|
||
|
||
const previewNode = host.querySelector('.line-clamp-2');
|
||
expect(previewNode).not.toBeNull();
|
||
expect(previewNode?.textContent).toBe(preview);
|
||
|
||
await act(async () => {
|
||
root.unmount();
|
||
await Promise.resolve();
|
||
});
|
||
});
|
||
|
||
it('strips info_for_agent blocks from compact thoughts preview', async () => {
|
||
const host = document.createElement('div');
|
||
document.body.appendChild(host);
|
||
const root = createRoot(host);
|
||
const visibleText = 'Собрать единый remediation plan.';
|
||
|
||
const thought = makeLeadSessionMsg(
|
||
`${visibleText}\n<info_for_agent>\ninternal note\n</info_for_agent>`,
|
||
{
|
||
messageId: 'thought-3',
|
||
leadSessionId: 'lead-session-3',
|
||
}
|
||
);
|
||
|
||
await act(async () => {
|
||
root.render(
|
||
React.createElement(LeadThoughtsGroupRow, {
|
||
group: { type: 'lead-thoughts', thoughts: [thought] },
|
||
collapseMode: 'managed',
|
||
isCollapsed: true,
|
||
canToggleCollapse: true,
|
||
compactHeader: true,
|
||
})
|
||
);
|
||
await Promise.resolve();
|
||
});
|
||
|
||
const previewNode = host.querySelector('.line-clamp-2');
|
||
expect(previewNode).not.toBeNull();
|
||
expect(previewNode?.textContent).toBe(visibleText);
|
||
expect(previewNode?.textContent).not.toContain('info_for_agent');
|
||
expect(previewNode?.textContent).not.toContain('internal note');
|
||
|
||
await act(async () => {
|
||
root.unmount();
|
||
await Promise.resolve();
|
||
});
|
||
});
|
||
|
||
it('uses a two-line preview in collapsed wide mode for thought groups', async () => {
|
||
const host = document.createElement('div');
|
||
document.body.appendChild(host);
|
||
const root = createRoot(host);
|
||
const preview =
|
||
'Делегировал alice финальную общую сводку и remediation plan по всем findings команды.';
|
||
|
||
const thought = makeLeadSessionMsg(preview, {
|
||
messageId: 'thought-4',
|
||
leadSessionId: 'lead-session-4',
|
||
});
|
||
|
||
await act(async () => {
|
||
root.render(
|
||
React.createElement(LeadThoughtsGroupRow, {
|
||
group: { type: 'lead-thoughts', thoughts: [thought] },
|
||
collapseMode: 'managed',
|
||
isCollapsed: true,
|
||
canToggleCollapse: true,
|
||
compactHeader: false,
|
||
})
|
||
);
|
||
await Promise.resolve();
|
||
});
|
||
|
||
const previewNode = host.querySelector('.line-clamp-2');
|
||
expect(previewNode).not.toBeNull();
|
||
expect(previewNode?.textContent).toBe(preview);
|
||
|
||
await act(async () => {
|
||
root.unmount();
|
||
await Promise.resolve();
|
||
});
|
||
});
|
||
|
||
it('reuses the expanded thought markdown preprocessing for compact preview', async () => {
|
||
const host = document.createElement('div');
|
||
document.body.appendChild(host);
|
||
const root = createRoot(host);
|
||
const thought = makeLeadSessionMsg('**Важно** проверить #task123 и ping @alice', {
|
||
messageId: 'thought-4',
|
||
leadSessionId: 'lead-session-4',
|
||
taskRefs: [{ taskId: 'task123', displayId: '#task123', teamName: 'my-team' }],
|
||
});
|
||
|
||
await act(async () => {
|
||
root.render(
|
||
React.createElement(LeadThoughtsGroupRow, {
|
||
group: { type: 'lead-thoughts', thoughts: [thought] },
|
||
collapseMode: 'managed',
|
||
isCollapsed: true,
|
||
canToggleCollapse: true,
|
||
compactHeader: true,
|
||
memberColorMap: new Map([['alice', 'blue']]),
|
||
teamNames: ['my-team'],
|
||
})
|
||
);
|
||
await Promise.resolve();
|
||
});
|
||
|
||
const previewNode = host.querySelector('.line-clamp-2');
|
||
expect(previewNode).not.toBeNull();
|
||
expect(previewNode?.textContent).toContain('**Важно**');
|
||
expect(previewNode?.textContent).toContain('[#task123](task://task123)');
|
||
expect(previewNode?.textContent).toContain('mention://blue/alice');
|
||
|
||
await act(async () => {
|
||
root.unmount();
|
||
await Promise.resolve();
|
||
});
|
||
});
|
||
});
|