diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 429cbc27..86d75b5b 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -6,7 +6,12 @@ import { AttachmentDisplay } from '@renderer/components/team/attachments/Attachm import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@renderer/components/ui/tooltip'; import { CARD_BG, CARD_BG_ZEBRA, @@ -780,7 +785,7 @@ export const ActivityItem = memo( if (!isCrossTeamAny || !strippedText) return ''; const oneLine = strippedText.replace(/\n+/g, ' ').trim(); if (!oneLine) return ''; - return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine; + return oneLine; }, [isCrossTeamAny, strippedText]); const rawSummary = useMemo(() => { @@ -806,8 +811,7 @@ export const ActivityItem = memo( // Fallback: use the beginning of message text as preview for plain-text messages const plain = getSanitizedInboxMessageText(message).trim(); if (!plain) return ''; - const oneLine = plain.replace(/\n+/g, ' '); - return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine; + return plain.replace(/\n+/g, ' '); }, [ crossTeamPreview, isSlashCommandMessage, @@ -819,6 +823,39 @@ export const ActivityItem = memo( structured, ]); const summaryText = extractMarkdownPlainText(rawSummary); + const compactPreviewText = useMemo(() => { + if (idleSemantic?.hasPeerSummary && idleSemantic.peerSummary) { + return idleSemantic.peerSummary; + } + if (isSlashCommandResult && message.commandOutput) { + return message.summary || getCommandOutputSummary(message.text); + } + if (isSlashCommandMessage && slashCommandMeta) { + if (slashCommandMeta.args) { + const oneLine = slashCommandMeta.args.replace(/\n+/g, ' ').trim(); + return `${slashCommandMeta.command} ${oneLine}`; + } + return slashCommandMeta.command; + } + if (crossTeamPreview) return crossTeamPreview; + + const fullText = strippedText?.trim() ?? ''; + if (fullText) { + return extractMarkdownPlainText(fullText).replace(/\n+/g, ' ').trim(); + } + + return summaryText || rawSummary; + }, [ + crossTeamPreview, + idleSemantic, + isSlashCommandMessage, + isSlashCommandResult, + message, + message.commandOutput, + rawSummary, + slashCommandMeta, + summaryText, + ]); const commentTaskRef = message.messageKind === 'task_comment_notification' ? (message.taskRefs?.[0] ?? null) : null; const commentTaskDisplayId = @@ -1178,13 +1215,105 @@ export const ActivityItem = memo( )} -
- {summaryContent} + + + +
+ {compactPreviewText} +
+
+ + {compactPreviewText} + +
+
+
+ ) : !isExpanded ? ( +
+
+ {isUnread ? ( + + ) : null} + {showChevron ? ( + + ) : null} + {crossTeamOrigin ? ( + + ) : null} + {senderBadge} + {!compactHeader && formattedRole && !isSlashCommandResult ? ( + + {formattedRole} + + ) : null} + {messageTypeBadge} + {leadSourceBadge} + {statusBadge} + {recipientBadge} +
+ + {timestamp} + + {onExpand && expandItemKey && ( + + )} +
+ + + +
+ {compactPreviewText} +
+
+ + {compactPreviewText} + +
+
) : ( <> diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 6943a68b..31295a3b 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -10,7 +10,12 @@ import { } from 'react'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@renderer/components/ui/tooltip'; import { CARD_BG, CARD_BG_ZEBRA, @@ -39,6 +44,7 @@ import { } from './AnimatedHeightReveal'; import { ThoughtBodyContent } from './ThoughtBodyContent'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import type { InboxMessage, ToolCallMeta } from '@shared/types'; export interface LeadThoughtGroup { @@ -587,9 +593,11 @@ const LeadThoughtsGroupRowComponent = ({ // Try newest first (most relevant), then scan for any text for (const t of thoughts) { if (t.text && t.text.trim()) { - const plain = extractMarkdownPlainText(t.text); - const firstLine = plain.split('\n').find((l) => l.trim().length > 0) ?? ''; - return firstLine.trim(); + const plain = extractMarkdownPlainText(stripAgentBlocks(t.text)); + const normalized = plain.replace(/\n+/g, ' ').trim(); + if (normalized) { + return normalized; + } } } return null; @@ -830,13 +838,108 @@ const LeadThoughtsGroupRowComponent = ({ {compactPreviewText ? ( -
- {compactPreviewText} + + + +
+ {compactPreviewText} +
+
+ + {compactPreviewText} + +
+
+ ) : null} +
+ ) : !isBodyVisible ? ( +
+
+ {canToggleBodyVisibility && !compactHeader ? ( + + ) : null} + {!compactHeader ? ( +
+ + +
+ ) : null} + + + {thoughts.length} thoughts + +
+ + {timestampLabel} + + {onExpand && expandItemKey && ( + + )}
+
+ {compactPreviewText ? ( + + + +
+ {compactPreviewText} +
+
+ + {compactPreviewText} + +
+
) : null}
) : ( @@ -871,26 +974,7 @@ const LeadThoughtsGroupRowComponent = ({ {thoughts.length} thoughts - {!isBodyVisible && headerTextPreview ? ( - - - - {headerTextPreview} - - - {totalToolSummary ? ( - - - - ) : null} - - ) : totalToolSummary ? ( + {totalToolSummary ? ( diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx new file mode 100644 index 00000000..4867111d --- /dev/null +++ b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx @@ -0,0 +1,161 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@renderer/components/team/MemberBadge', () => ({ + MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name), +})); + +vi.mock('@renderer/components/team/UnreadCommentsBadge', () => ({ + UnreadCommentsBadge: () => null, +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + className, + onClick, + disabled, + 'aria-label': ariaLabel, + }: { + children: React.ReactNode; + className?: string; + onClick?: React.MouseEventHandler; + disabled?: boolean; + 'aria-label'?: string; + }) => + React.createElement( + 'button', + { className, onClick, disabled, 'aria-label': ariaLabel, type: 'button' }, + children + ), +})); + +vi.mock('@renderer/components/ui/popover', () => ({ + Popover: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + PopoverTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + PopoverContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + +vi.mock('@renderer/components/ui/tooltip', () => ({ + 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('@renderer/hooks/useTheme', () => ({ + useTheme: () => ({ isLight: false }), +})); + +vi.mock('@renderer/hooks/useUnreadCommentCount', () => ({ + useUnreadCommentCount: () => 0, +})); + +import { KanbanTaskCard } from './KanbanTaskCard'; + +import type { TeamTaskWithKanban } from '@shared/types/team'; + +const baseTask: TeamTaskWithKanban = { + id: 'task-1', + displayId: 'abcd1234', + subject: 'Implement safer onboarding flow', + owner: 'alice', + reviewer: '', + status: 'in_progress', + changePresence: 'unknown', + comments: [], + blockedBy: [], + blocks: [], + workIntervals: [], + historyEvents: [], + createdAt: '2026-04-18T10:00:00.000Z', + updatedAt: '2026-04-18T10:10:00.000Z', +} as unknown as TeamTaskWithKanban; + +const noop = (): void => undefined; + +describe('KanbanTaskCard change badge', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('does not render a No changes badge when changePresence is no_changes', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(KanbanTaskCard, { + task: { ...baseTask, changePresence: 'no_changes' }, + teamName: 'my-team', + columnId: 'in_progress', + hasReviewers: true, + compact: false, + taskMap: new Map(), + memberColorMap: new Map([['alice', 'blue']]), + onRequestReview: noop, + onApprove: noop, + onRequestChanges: noop, + onMoveBackToDone: noop, + onStartTask: noop, + onCompleteTask: noop, + onCancelTask: noop, + onViewChanges: noop, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).not.toContain('No changes'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('still renders the Changes action when changePresence is has_changes', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(KanbanTaskCard, { + task: { ...baseTask, changePresence: 'has_changes' }, + teamName: 'my-team', + columnId: 'in_progress', + hasReviewers: true, + compact: false, + taskMap: new Map(), + memberColorMap: new Map([['alice', 'blue']]), + onRequestReview: noop, + onApprove: noop, + onRequestChanges: noop, + onMoveBackToDone: noop, + onStartTask: noop, + onCompleteTask: noop, + onCancelTask: noop, + onViewChanges: noop, + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[aria-label="Changes"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 87933fb6..7c84488d 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -268,10 +268,6 @@ export const KanbanTaskCard = memo( onViewChanges!(task.id); }} /> - ) : canDisplay && task.changePresence === 'no_changes' ? ( - - No changes - ) : null} {onDeleteTask ? ( diff --git a/src/renderer/components/team/members/CurrentTaskIndicator.tsx b/src/renderer/components/team/members/CurrentTaskIndicator.tsx index e4df93f6..d168359a 100644 --- a/src/renderer/components/team/members/CurrentTaskIndicator.tsx +++ b/src/renderer/components/team/members/CurrentTaskIndicator.tsx @@ -6,7 +6,6 @@ import type { TeamTaskWithKanban } from '@shared/types'; interface CurrentTaskIndicatorProps { task: TeamTaskWithKanban; borderColor: string; - /** Max characters for the subject before truncating */ maxSubjectLength?: number; activityLabel?: string; onOpenTask?: () => void; @@ -19,21 +18,24 @@ interface CurrentTaskIndicatorProps { export const CurrentTaskIndicator = ({ task, borderColor, - maxSubjectLength = 36, + maxSubjectLength, activityLabel = 'working on', onOpenTask, }: CurrentTaskIndicatorProps): React.JSX.Element => { - const truncated = task.subject.length > maxSubjectLength; - const subjectText = truncated ? `${task.subject.slice(0, maxSubjectLength)}…` : task.subject; + const subjectText = + typeof maxSubjectLength === 'number' && + maxSubjectLength > 0 && + task.subject.length > maxSubjectLength + ? `${task.subject.slice(0, maxSubjectLength)}…` + : task.subject; return ( - <> +
{activityLabel} - +
); }; diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index bcf7ebc2..5ea2e0fe 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -1,6 +1,6 @@ import { Badge } from '@renderer/components/ui/badge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { getTeamColorSet, getThemedBadge, scaleColorAlpha } from '@renderer/constants/teamColors'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { @@ -101,6 +101,7 @@ export const MemberCard = ({ const completed = taskCounts?.completed ?? 0; const totalTasks = pending + inProgress + completed; const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0; + const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); const activityTask = currentTask ?? reviewTask ?? null; const activityTitle = currentTask ? `Current task: #${deriveTaskDisplayId(currentTask.id)}` @@ -120,18 +121,14 @@ export const MemberCard = ({ !showStartingBadge && spawnStatus !== 'error' && (Boolean(activityTask) || !isAwaitingReply); - const cardTint = scaleColorAlpha(getThemedBadge(colors, isLight), 0.5); return (
- {member.name} +
+ {member.name} +
-
+
{displayMemberName(member.name)} @@ -210,20 +215,16 @@ export const MemberCard = ({ style={{ backgroundColor: 'var(--skeleton-base)' }} />
- ) : runtimeSummary ? ( -
- {runtimeSummary} + ) : runtimeSummary || roleLabel ? ( +
+ {runtimeSummary ? {runtimeSummary} : null} + {runtimeSummary && roleLabel ? ( + + ) : null} + {roleLabel ? {roleLabel} : null}
) : null}
- {(() => { - const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); - return roleLabel ? ( - - {roleLabel} - - ) : null; - })()} {showStartingBadge ? ( ({ React.createElement(React.Fragment, null, children), })); 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), @@ -45,6 +47,186 @@ import { } from '@renderer/components/team/activity/ActivityItem'; import type { InboxMessage } from '@shared/types'; +describe('ActivityItem compact header preview', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('uses a two-line clamped preview in compact mode', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const summary = + 'Делегировал alice длинную задачу с заметно более длинным описанием, чтобы превью занимало больше одной строки в компактном режиме.'; + + const message: InboxMessage = { + from: 'team-lead', + text: summary, + summary, + timestamp: new Date('2026-04-18T16:30:00.000Z').toISOString(), + read: true, + source: 'lead_process', + }; + + await act(async () => { + root.render( + React.createElement(ActivityItem, { + message, + teamName: 'my-team', + compactHeader: true, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + collapseToggleKey: 'message-key', + }) + ); + await Promise.resolve(); + }); + + const preview = host.querySelector('.line-clamp-2'); + expect(preview).not.toBeNull(); + expect(preview?.textContent).toBe(summary); + expect(preview?.getAttribute('title')).toBeNull(); + expect(preview?.className).toContain('line-clamp-2'); + expect(preview?.className).toContain('w-full'); + expect(preview?.className).toContain('max-w-full'); + expect(preview?.className).not.toContain('min-h-8'); + expect(preview?.className).not.toContain('truncate'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('prefers full message text over a pre-truncated summary in compact mode', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const fullText = + 'Делегировал bob ещё один узкий шаг: собрать fix-batch с учётом landing P0 по render->generate и пройтись по оставшимся edge cases.'; + + const message: InboxMessage = { + from: 'team-lead', + text: fullText, + summary: 'Делегировал bob ещё один узкий шаг: собрать fix-batch с у...', + timestamp: new Date('2026-04-18T16:29:00.000Z').toISOString(), + read: true, + source: 'lead_process', + }; + + await act(async () => { + root.render( + React.createElement(ActivityItem, { + message, + teamName: 'my-team', + compactHeader: true, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + collapseToggleKey: 'message-key-full-text', + }) + ); + await Promise.resolve(); + }); + + const preview = host.querySelector('.line-clamp-2'); + expect(preview).not.toBeNull(); + expect(preview?.textContent).toBe(fullText); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('strips info_for_agent blocks from compact preview text', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + const visibleText = 'New task assigned to you: #3fd70e2 Собрать fix-batch'; + const message: InboxMessage = { + from: 'team-lead', + text: `${visibleText}\n\ninternal only\n`, + timestamp: new Date('2026-04-18T16:28:00.000Z').toISOString(), + read: true, + source: 'lead_process', + }; + + await act(async () => { + root.render( + React.createElement(ActivityItem, { + message, + teamName: 'my-team', + compactHeader: true, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + collapseToggleKey: 'message-key-strip-agent-block', + }) + ); + await Promise.resolve(); + }); + + const preview = host.querySelector('.line-clamp-2'); + expect(preview).not.toBeNull(); + expect(preview?.textContent).toBe(visibleText); + expect(preview?.textContent).not.toContain('info_for_agent'); + expect(preview?.textContent).not.toContain('internal only'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('uses a two-line preview in collapsed wide mode, not inline one-line summary', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const fullText = + 'Делегировал alice финальную общую сводку и remediation plan по всем findings команды.'; + + const message: InboxMessage = { + from: 'team-lead', + text: fullText, + timestamp: new Date('2026-04-18T16:30:00.000Z').toISOString(), + read: true, + source: 'lead_process', + }; + + await act(async () => { + root.render( + React.createElement(ActivityItem, { + message, + teamName: 'my-team', + compactHeader: false, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + collapseToggleKey: 'message-key-wide-collapsed', + }) + ); + await Promise.resolve(); + }); + + const preview = host.querySelector('.line-clamp-2'); + expect(preview).not.toBeNull(); + expect(preview?.textContent).toBe(fullText); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); + describe('ActivityItem slash command rendering', () => { afterEach(() => { document.body.innerHTML = ''; diff --git a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts index cdb504de..1dd46a44 100644 --- a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts +++ b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts @@ -1,8 +1,36 @@ +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/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'; @@ -19,6 +47,29 @@ function makeLeadSessionMsg(text: string, overrides?: Partial): In } 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', @@ -118,4 +169,155 @@ describe('LeadThoughtsGroup', () => { } }); }); + + 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\ninternal note\n`, + { + 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(); + }); + }); }); diff --git a/test/renderer/components/team/members/CurrentTaskIndicator.test.ts b/test/renderer/components/team/members/CurrentTaskIndicator.test.ts new file mode 100644 index 00000000..c538792f --- /dev/null +++ b/test/renderer/components/team/members/CurrentTaskIndicator.test.ts @@ -0,0 +1,77 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { CurrentTaskIndicator } from '@renderer/components/team/members/CurrentTaskIndicator'; + +import type { TeamTaskWithKanban } from '@shared/types'; + +const task: TeamTaskWithKanban = { + id: 'task-1', + displayId: '9d1915a7', + subject: 'Полный аудит актуальности документации и связанных onboarding заметок', + status: 'in_progress', +} as unknown as TeamTaskWithKanban; + +describe('CurrentTaskIndicator', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('uses all available width for the task pill without early subject truncation', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CurrentTaskIndicator, { + task, + borderColor: '#3b82f6', + }) + ); + await Promise.resolve(); + }); + + const wrapper = host.firstElementChild as HTMLElement | null; + const button = host.querySelector('button'); + + expect(wrapper?.className).toContain('flex-1'); + expect(button?.className).toContain('flex-1'); + expect(button?.className).toContain('text-left'); + expect(button?.textContent).toContain(task.subject); + expect(button?.style.border).toBe(''); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('still supports an explicit subject ceiling when a compact caller requests it', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CurrentTaskIndicator, { + task, + borderColor: '#3b82f6', + maxSubjectLength: 12, + }) + ); + await Promise.resolve(); + }); + + const button = host.querySelector('button'); + expect(button?.textContent).toContain('Полный аудит…'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/components/team/members/MemberCard.test.ts b/test/renderer/components/team/members/MemberCard.test.ts index 6fa3cd14..e0058c00 100644 --- a/test/renderer/components/team/members/MemberCard.test.ts +++ b/test/renderer/components/team/members/MemberCard.test.ts @@ -240,4 +240,38 @@ describe('MemberCard starting-state visuals', () => { await Promise.resolve(); }); }); + + it('shows member color on the avatar ring instead of a colored card rail', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + isTeamAlive: true, + isTeamProvisioning: false, + }) + ); + await Promise.resolve(); + }); + + const img = host.querySelector('img'); + const avatarRing = img?.parentElement; + const clickableCard = host.querySelector('[role="button"]') as HTMLElement | null; + + expect(avatarRing).not.toBeNull(); + expect(avatarRing?.style.borderColor).toBe('#3b82f6'); + expect(clickableCard?.style.borderLeft).toBe(''); + expect(clickableCard?.style.background).toBe(''); + expect(clickableCard?.className).not.toContain('px-'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); });