fix(ui): finalize team activity and kanban polish
This commit is contained in:
parent
dac7b4f875
commit
fd0c936244
10 changed files with 945 additions and 77 deletions
|
|
@ -6,7 +6,12 @@ import { AttachmentDisplay } from '@renderer/components/team/attachments/Attachm
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
|
import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
|
||||||
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
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 {
|
import {
|
||||||
CARD_BG,
|
CARD_BG,
|
||||||
CARD_BG_ZEBRA,
|
CARD_BG_ZEBRA,
|
||||||
|
|
@ -780,7 +785,7 @@ export const ActivityItem = memo(
|
||||||
if (!isCrossTeamAny || !strippedText) return '';
|
if (!isCrossTeamAny || !strippedText) return '';
|
||||||
const oneLine = strippedText.replace(/\n+/g, ' ').trim();
|
const oneLine = strippedText.replace(/\n+/g, ' ').trim();
|
||||||
if (!oneLine) return '';
|
if (!oneLine) return '';
|
||||||
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
|
return oneLine;
|
||||||
}, [isCrossTeamAny, strippedText]);
|
}, [isCrossTeamAny, strippedText]);
|
||||||
|
|
||||||
const rawSummary = useMemo(() => {
|
const rawSummary = useMemo(() => {
|
||||||
|
|
@ -806,8 +811,7 @@ export const ActivityItem = memo(
|
||||||
// Fallback: use the beginning of message text as preview for plain-text messages
|
// Fallback: use the beginning of message text as preview for plain-text messages
|
||||||
const plain = getSanitizedInboxMessageText(message).trim();
|
const plain = getSanitizedInboxMessageText(message).trim();
|
||||||
if (!plain) return '';
|
if (!plain) return '';
|
||||||
const oneLine = plain.replace(/\n+/g, ' ');
|
return plain.replace(/\n+/g, ' ');
|
||||||
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
|
|
||||||
}, [
|
}, [
|
||||||
crossTeamPreview,
|
crossTeamPreview,
|
||||||
isSlashCommandMessage,
|
isSlashCommandMessage,
|
||||||
|
|
@ -819,6 +823,39 @@ export const ActivityItem = memo(
|
||||||
structured,
|
structured,
|
||||||
]);
|
]);
|
||||||
const summaryText = extractMarkdownPlainText(rawSummary);
|
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 =
|
const commentTaskRef =
|
||||||
message.messageKind === 'task_comment_notification' ? (message.taskRefs?.[0] ?? null) : null;
|
message.messageKind === 'task_comment_notification' ? (message.taskRefs?.[0] ?? null) : null;
|
||||||
const commentTaskDisplayId =
|
const commentTaskDisplayId =
|
||||||
|
|
@ -1178,13 +1215,105 @@ export const ActivityItem = memo(
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<TooltipProvider delayDuration={1000}>
|
||||||
className="mt-1 min-w-0 truncate text-[11px]"
|
<Tooltip>
|
||||||
style={{ color: CARD_TEXT_LIGHT }}
|
<TooltipTrigger asChild>
|
||||||
title={summaryText || rawSummary}
|
<div
|
||||||
>
|
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
|
||||||
{summaryContent}
|
style={{ color: CARD_TEXT_LIGHT }}
|
||||||
|
>
|
||||||
|
{compactPreviewText}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
className="max-w-sm whitespace-normal break-words"
|
||||||
|
>
|
||||||
|
{compactPreviewText}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
) : !isExpanded ? (
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{isUnread ? (
|
||||||
|
<span
|
||||||
|
className="size-2 shrink-0 rounded-full bg-blue-500"
|
||||||
|
title="Unread"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{showChevron ? (
|
||||||
|
<ChevronRight
|
||||||
|
className="size-3 shrink-0 transition-transform duration-150"
|
||||||
|
style={{
|
||||||
|
color: CARD_ICON_MUTED,
|
||||||
|
transform: isExpanded ? 'rotate(90deg)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{crossTeamOrigin ? (
|
||||||
|
<CrossTeamTeamBadge teamName={crossTeamOrigin.teamName} onClick={onTeamClick} />
|
||||||
|
) : null}
|
||||||
|
{senderBadge}
|
||||||
|
{!compactHeader && formattedRole && !isSlashCommandResult ? (
|
||||||
|
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
{formattedRole}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{messageTypeBadge}
|
||||||
|
{leadSourceBadge}
|
||||||
|
{statusBadge}
|
||||||
|
{recipientBadge}
|
||||||
|
<div className="relative ml-auto flex shrink-0 items-center">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
onExpand && expandItemKey
|
||||||
|
? 'text-[10px] transition-opacity group-hover:opacity-0'
|
||||||
|
: 'text-[10px]'
|
||||||
|
}
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
>
|
||||||
|
{timestamp}
|
||||||
|
</span>
|
||||||
|
{onExpand && expandItemKey && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Expand message"
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onExpand(expandItemKey);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Maximize2 size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<TooltipProvider delayDuration={1000}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
|
||||||
|
style={{ color: CARD_TEXT_LIGHT }}
|
||||||
|
>
|
||||||
|
{compactPreviewText}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
className="max-w-sm whitespace-normal break-words"
|
||||||
|
>
|
||||||
|
{compactPreviewText}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,12 @@ import {
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
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 {
|
import {
|
||||||
CARD_BG,
|
CARD_BG,
|
||||||
CARD_BG_ZEBRA,
|
CARD_BG_ZEBRA,
|
||||||
|
|
@ -39,6 +44,7 @@ import {
|
||||||
} from './AnimatedHeightReveal';
|
} from './AnimatedHeightReveal';
|
||||||
import { ThoughtBodyContent } from './ThoughtBodyContent';
|
import { ThoughtBodyContent } from './ThoughtBodyContent';
|
||||||
|
|
||||||
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import type { InboxMessage, ToolCallMeta } from '@shared/types';
|
import type { InboxMessage, ToolCallMeta } from '@shared/types';
|
||||||
|
|
||||||
export interface LeadThoughtGroup {
|
export interface LeadThoughtGroup {
|
||||||
|
|
@ -587,9 +593,11 @@ const LeadThoughtsGroupRowComponent = ({
|
||||||
// Try newest first (most relevant), then scan for any text
|
// Try newest first (most relevant), then scan for any text
|
||||||
for (const t of thoughts) {
|
for (const t of thoughts) {
|
||||||
if (t.text && t.text.trim()) {
|
if (t.text && t.text.trim()) {
|
||||||
const plain = extractMarkdownPlainText(t.text);
|
const plain = extractMarkdownPlainText(stripAgentBlocks(t.text));
|
||||||
const firstLine = plain.split('\n').find((l) => l.trim().length > 0) ?? '';
|
const normalized = plain.replace(/\n+/g, ' ').trim();
|
||||||
return firstLine.trim();
|
if (normalized) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -830,13 +838,108 @@ const LeadThoughtsGroupRowComponent = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{compactPreviewText ? (
|
{compactPreviewText ? (
|
||||||
<div
|
<TooltipProvider delayDuration={1000}>
|
||||||
className="mt-1 min-w-0 truncate text-[11px]"
|
<Tooltip>
|
||||||
style={{ color: headerTextPreview ? CARD_TEXT_LIGHT : CARD_ICON_MUTED }}
|
<TooltipTrigger asChild>
|
||||||
title={compactPreviewText}
|
<div
|
||||||
>
|
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
|
||||||
{compactPreviewText}
|
style={{ color: headerTextPreview ? CARD_TEXT_LIGHT : CARD_ICON_MUTED }}
|
||||||
|
>
|
||||||
|
{compactPreviewText}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
className="max-w-sm whitespace-normal break-words"
|
||||||
|
>
|
||||||
|
{compactPreviewText}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : !isBodyVisible ? (
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{canToggleBodyVisibility && !compactHeader ? (
|
||||||
|
<ChevronRight
|
||||||
|
className="size-3 shrink-0 transition-transform duration-150"
|
||||||
|
style={{
|
||||||
|
color: CARD_ICON_MUTED,
|
||||||
|
transform: isBodyVisible ? 'rotate(90deg)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!compactHeader ? (
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<img
|
||||||
|
src={agentAvatarUrl(leadName, 24)}
|
||||||
|
alt=""
|
||||||
|
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<LiveThoughtStatusBadge
|
||||||
|
canBeLive={canBeLive}
|
||||||
|
isTeamAlive={isTeamAlive}
|
||||||
|
leadActivity={leadActivity}
|
||||||
|
leadContextUpdatedAt={leadContextUpdatedAt}
|
||||||
|
newestTimestamp={newest.timestamp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<MemberBadge name={leadName} color={memberColor} hideAvatar />
|
||||||
|
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
{thoughts.length} thoughts
|
||||||
|
</span>
|
||||||
|
<div className="relative ml-auto flex shrink-0 items-center">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
onExpand && expandItemKey
|
||||||
|
? 'text-[10px] transition-opacity group-hover:opacity-0'
|
||||||
|
: 'text-[10px]'
|
||||||
|
}
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
>
|
||||||
|
{timestampLabel}
|
||||||
|
</span>
|
||||||
|
{onExpand && expandItemKey && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Expand thoughts"
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onExpand(expandItemKey);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Maximize2 size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{compactPreviewText ? (
|
||||||
|
<TooltipProvider delayDuration={1000}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
|
||||||
|
style={{ color: headerTextPreview ? CARD_TEXT_LIGHT : CARD_ICON_MUTED }}
|
||||||
|
>
|
||||||
|
{compactPreviewText}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
className="max-w-sm whitespace-normal break-words"
|
||||||
|
>
|
||||||
|
{compactPreviewText}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -871,26 +974,7 @@ const LeadThoughtsGroupRowComponent = ({
|
||||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
{thoughts.length} thoughts
|
{thoughts.length} thoughts
|
||||||
</span>
|
</span>
|
||||||
{!isBodyVisible && headerTextPreview ? (
|
{totalToolSummary ? (
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span
|
|
||||||
className="min-w-0 flex-1 cursor-default truncate text-[10px]"
|
|
||||||
style={{ color: CARD_TEXT_LIGHT }}
|
|
||||||
>
|
|
||||||
{headerTextPreview}
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
{totalToolSummary ? (
|
|
||||||
<TooltipContent side="bottom" className="max-w-[420px] font-mono text-[11px]">
|
|
||||||
<ToolSummaryTooltipContent
|
|
||||||
toolCalls={allToolCalls}
|
|
||||||
toolSummary={totalToolSummary}
|
|
||||||
/>
|
|
||||||
</TooltipContent>
|
|
||||||
) : null}
|
|
||||||
</Tooltip>
|
|
||||||
) : totalToolSummary ? (
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="cursor-default text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
<span className="cursor-default text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
|
|
||||||
161
src/renderer/components/team/kanban/KanbanTaskCard.test.tsx
Normal file
161
src/renderer/components/team/kanban/KanbanTaskCard.test.tsx
Normal file
|
|
@ -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<HTMLButtonElement>;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -268,10 +268,6 @@ export const KanbanTaskCard = memo(
|
||||||
onViewChanges!(task.id);
|
onViewChanges!(task.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : canDisplay && task.changePresence === 'no_changes' ? (
|
|
||||||
<span className="inline-flex h-6 shrink-0 items-center rounded-full border border-[var(--color-border)] px-2 text-[10px] text-[var(--color-text-muted)]">
|
|
||||||
No changes
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
|
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
|
||||||
{onDeleteTask ? (
|
{onDeleteTask ? (
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
interface CurrentTaskIndicatorProps {
|
interface CurrentTaskIndicatorProps {
|
||||||
task: TeamTaskWithKanban;
|
task: TeamTaskWithKanban;
|
||||||
borderColor: string;
|
borderColor: string;
|
||||||
/** Max characters for the subject before truncating */
|
|
||||||
maxSubjectLength?: number;
|
maxSubjectLength?: number;
|
||||||
activityLabel?: string;
|
activityLabel?: string;
|
||||||
onOpenTask?: () => void;
|
onOpenTask?: () => void;
|
||||||
|
|
@ -19,21 +18,24 @@ interface CurrentTaskIndicatorProps {
|
||||||
export const CurrentTaskIndicator = ({
|
export const CurrentTaskIndicator = ({
|
||||||
task,
|
task,
|
||||||
borderColor,
|
borderColor,
|
||||||
maxSubjectLength = 36,
|
maxSubjectLength,
|
||||||
activityLabel = 'working on',
|
activityLabel = 'working on',
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
}: CurrentTaskIndicatorProps): React.JSX.Element => {
|
}: CurrentTaskIndicatorProps): React.JSX.Element => {
|
||||||
const truncated = task.subject.length > maxSubjectLength;
|
const subjectText =
|
||||||
const subjectText = truncated ? `${task.subject.slice(0, maxSubjectLength)}…` : task.subject;
|
typeof maxSubjectLength === 'number' &&
|
||||||
|
maxSubjectLength > 0 &&
|
||||||
|
task.subject.length > maxSubjectLength
|
||||||
|
? `${task.subject.slice(0, maxSubjectLength)}…`
|
||||||
|
: task.subject;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||||
<Loader2 className="size-3 shrink-0 animate-spin" style={{ color: borderColor }} />
|
<Loader2 className="size-3 shrink-0 animate-spin" style={{ color: borderColor }} />
|
||||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
|
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="min-w-0 shrink truncate rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
className="min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-left text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||||
style={{ border: `1px solid ${borderColor}40` }}
|
|
||||||
title="Open task"
|
title="Open task"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -49,6 +51,6 @@ export const CurrentTaskIndicator = ({
|
||||||
>
|
>
|
||||||
{formatTaskDisplayLabel(task)} {subjectText}
|
{formatTaskDisplayLabel(task)} {subjectText}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
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 { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
|
|
@ -101,6 +101,7 @@ export const MemberCard = ({
|
||||||
const completed = taskCounts?.completed ?? 0;
|
const completed = taskCounts?.completed ?? 0;
|
||||||
const totalTasks = pending + inProgress + completed;
|
const totalTasks = pending + inProgress + completed;
|
||||||
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
|
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
|
||||||
|
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||||
const activityTask = currentTask ?? reviewTask ?? null;
|
const activityTask = currentTask ?? reviewTask ?? null;
|
||||||
const activityTitle = currentTask
|
const activityTitle = currentTask
|
||||||
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
||||||
|
|
@ -120,18 +121,14 @@ export const MemberCard = ({
|
||||||
!showStartingBadge &&
|
!showStartingBadge &&
|
||||||
spawnStatus !== 'error' &&
|
spawnStatus !== 'error' &&
|
||||||
(Boolean(activityTask) || !isAwaitingReply);
|
(Boolean(activityTask) || !isAwaitingReply);
|
||||||
const cardTint = scaleColorAlpha(getThemedBadge(colors, isLight), 0.5);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded transition-opacity duration-300 ${isRemoved ? 'opacity-50' : ''} ${spawnCardClass}`}
|
className={`rounded transition-opacity duration-300 ${isRemoved ? 'opacity-50' : ''} ${spawnCardClass}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="group relative cursor-pointer rounded px-2 py-1.5"
|
className="group relative cursor-pointer rounded py-1.5"
|
||||||
style={{
|
style={undefined}
|
||||||
borderLeft: `3px solid ${colors.border}`,
|
|
||||||
background: `linear-gradient(to right, ${cardTint}, transparent)`,
|
|
||||||
}}
|
|
||||||
title={activityTitle}
|
title={activityTitle}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -146,19 +143,27 @@ export const MemberCard = ({
|
||||||
<div className="pointer-events-none absolute inset-0 rounded transition-colors group-hover:bg-white/5" />
|
<div className="pointer-events-none absolute inset-0 rounded transition-colors group-hover:bg-white/5" />
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<img
|
<div
|
||||||
src={agentAvatarUrl(member.name)}
|
className="rounded-full border-2 p-[1px]"
|
||||||
alt={member.name}
|
style={{
|
||||||
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
|
borderColor: colors.border,
|
||||||
loading="lazy"
|
boxShadow: isLight ? 'none' : `0 0 0 1px ${colors.badge}`,
|
||||||
/>
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={agentAvatarUrl(member.name)}
|
||||||
|
alt={member.name}
|
||||||
|
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
|
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
|
||||||
aria-label={presenceLabel}
|
aria-label={presenceLabel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex min-w-0 items-center gap-1.5 truncate text-sm">
|
<div className="flex min-w-0 items-center gap-1.5 text-sm">
|
||||||
<span className="shrink-0 font-medium text-[var(--color-text)]">
|
<span className="shrink-0 font-medium text-[var(--color-text)]">
|
||||||
{displayMemberName(member.name)}
|
{displayMemberName(member.name)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -210,20 +215,16 @@ export const MemberCard = ({
|
||||||
style={{ backgroundColor: 'var(--skeleton-base)' }}
|
style={{ backgroundColor: 'var(--skeleton-base)' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : runtimeSummary ? (
|
) : runtimeSummary || roleLabel ? (
|
||||||
<div className="mt-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||||
{runtimeSummary}
|
{runtimeSummary ? <span className="min-w-0 truncate">{runtimeSummary}</span> : null}
|
||||||
|
{runtimeSummary && roleLabel ? (
|
||||||
|
<span className="shrink-0 opacity-60">•</span>
|
||||||
|
) : null}
|
||||||
|
{roleLabel ? <span className="shrink-0">{roleLabel}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
|
||||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
|
||||||
return roleLabel ? (
|
|
||||||
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] sm:inline">
|
|
||||||
{roleLabel}
|
|
||||||
</span>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
{showStartingBadge ? (
|
{showStartingBadge ? (
|
||||||
<span className="flex shrink-0 items-center gap-1">
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
<Loader2
|
<Loader2
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ vi.mock('@renderer/components/ui/ExpandableContent', () => ({
|
||||||
React.createElement(React.Fragment, null, children),
|
React.createElement(React.Fragment, null, children),
|
||||||
}));
|
}));
|
||||||
vi.mock('@renderer/components/ui/tooltip', () => ({
|
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),
|
Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
|
||||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
||||||
React.createElement(React.Fragment, null, children),
|
React.createElement(React.Fragment, null, children),
|
||||||
|
|
@ -45,6 +47,186 @@ import {
|
||||||
} from '@renderer/components/team/activity/ActivityItem';
|
} from '@renderer/components/team/activity/ActivityItem';
|
||||||
import type { InboxMessage } from '@shared/types';
|
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<info_for_agent>\ninternal only\n</info_for_agent>`,
|
||||||
|
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', () => {
|
describe('ActivityItem slash command rendering', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,36 @@
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
import { describe, expect, it } from 'vitest';
|
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 {
|
import {
|
||||||
groupTimelineItems,
|
groupTimelineItems,
|
||||||
isLeadThought,
|
isLeadThought,
|
||||||
|
LeadThoughtsGroupRow,
|
||||||
} from '../../../../../src/renderer/components/team/activity/LeadThoughtsGroup';
|
} from '../../../../../src/renderer/components/team/activity/LeadThoughtsGroup';
|
||||||
|
|
||||||
import type { InboxMessage } from '../../../../../src/shared/types';
|
import type { InboxMessage } from '../../../../../src/shared/types';
|
||||||
|
|
@ -19,6 +47,29 @@ function makeLeadSessionMsg(text: string, overrides?: Partial<InboxMessage>): In
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('LeadThoughtsGroup', () => {
|
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', () => {
|
it('does not classify slash command results as lead thoughts', () => {
|
||||||
const resultMessage: InboxMessage = {
|
const resultMessage: InboxMessage = {
|
||||||
from: 'team-lead',
|
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<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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -240,4 +240,38 @@ describe('MemberCard starting-state visuals', () => {
|
||||||
await Promise.resolve();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue