agent-ecosystem/src/renderer/components/chat/items/TeammateMessageItem.tsx
iliya 1f4c550ed3 refactor: remove deprecated electron.vite.config and update dependencies
- Deleted the obsolete electron.vite.config file to streamline the project structure.
- Updated package.json to remove the "agent-teams-controller" dependency and added "@radix-ui/react-hover-card".
- Enhanced the pnpm-lock.yaml to reflect the updated dependencies.
- Modified CrossTeamService and TeamProvisioningService to accommodate new color properties in team configurations.
- Improved message handling in various components to support mention links and member hover cards.
2026-03-09 23:19:37 +02:00

250 lines
8.5 KiB
TypeScript

import React, { useMemo } from 'react';
import {
CARD_BG,
CARD_BORDER_STYLE,
CARD_HEADER_BG,
CARD_ICON_MUTED,
CARD_TEXT_LIGHT,
} from '@renderer/constants/cssVariables';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
import { formatTokensCompact } from '@renderer/utils/formatters';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { linkifyMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { format } from 'date-fns';
import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react';
import { MarkdownViewer } from '../viewers/MarkdownViewer';
import type { TeammateMessage } from '@renderer/types/groups';
// =============================================================================
// Types
// =============================================================================
interface TeammateMessageItemProps {
teammateMessage: TeammateMessage;
onClick: () => void;
isExpanded: boolean;
/** Callback to spotlight the reply link: pass toolId on hover, null on leave */
onReplyHover?: (toolId: string | null) => void;
/** Additional classes for highlighting (e.g., error deep linking) */
highlightClasses?: string;
/** Inline styles for highlighting (used by custom hex colors) */
highlightStyle?: React.CSSProperties;
}
// =============================================================================
// Resend Detection
// =============================================================================
const RESEND_PATTERNS = [
/\bresend/i,
/\bre-send/i,
/\bsent\b.{0,20}\bearlier/i,
/\balready\s+sent/i,
/\bsent\s+in\s+my\s+previous/i,
];
function isResendMessage(message: TeammateMessage): boolean {
// Check summary first (cheaper)
if (RESEND_PATTERNS.some((p) => p.test(message.summary))) return true;
// Check first 300 chars of content
const contentSnippet = message.content.slice(0, 300);
return RESEND_PATTERNS.some((p) => p.test(contentSnippet));
}
// =============================================================================
// Component
// =============================================================================
/**
* TeammateMessageItem - Card component for teammate messages.
*
* Visual distinction from SubagentItem:
* - Left color accent border (3px)
* - "Message" type label after name badge
* - No metrics pill, no duration, no model info
*
* Operational noise (idle/shutdown/terminated) renders as minimal inline text.
*/
export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
teammateMessage,
onClick,
isExpanded,
onReplyHover,
highlightClasses = '',
highlightStyle,
}) => {
const colors = getTeamColorSet(teammateMessage.color);
const { isLight } = useTheme();
// Get team members for @mention highlighting
const members = useStore((s) => s.selectedTeamData?.members);
const memberColorMap = useMemo(
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
[members]
);
// Detect operational noise
const noiseLabel = useMemo(
() => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId),
[teammateMessage.content, teammateMessage.teammateId]
);
// Detect resent/duplicate messages
const isResend = useMemo(() => isResendMessage(teammateMessage), [teammateMessage]);
const plainSummary = useMemo(
() => extractMarkdownPlainText(teammateMessage.summary),
[teammateMessage.summary]
);
const plainReplyToSummary = useMemo(
() =>
teammateMessage.replyToSummary
? extractMarkdownPlainText(teammateMessage.replyToSummary)
: undefined,
[teammateMessage.replyToSummary]
);
const displayContent = useMemo(() => {
const stripped = stripAgentBlocks(teammateMessage.content);
return linkifyMentionsInMarkdown(stripped, memberColorMap);
}, [teammateMessage.content, memberColorMap]);
// Noise: minimal inline row (no card, no expand)
if (noiseLabel) {
return (
<div className="flex items-center gap-2 px-3 py-1" style={{ opacity: 0.45 }}>
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: colors.border }} />
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
{teammateMessage.teammateId}
</span>
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
{noiseLabel}
</span>
</div>
);
}
const truncatedSummary =
plainSummary.length > 80 ? plainSummary.slice(0, 80) + '...' : plainSummary;
return (
<div
className={`overflow-hidden rounded-md transition-all duration-300 ${highlightClasses}`}
style={{
backgroundColor: CARD_BG,
border: CARD_BORDER_STYLE,
borderLeft: `3px solid ${colors.border}`,
opacity: isResend ? 0.6 : undefined,
...highlightStyle,
}}
>
{/* Header */}
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors"
style={{
backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent',
borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none',
}}
>
<ChevronRight
className={`size-3.5 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
style={{ color: CARD_ICON_MUTED }}
/>
{/* Message icon — distinguishes from SubagentItem's Bot/dot icon */}
<MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} />
{/* Teammate name badge */}
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={{
backgroundColor: getThemedBadge(colors, isLight),
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
>
{teammateMessage.teammateId}
</span>
{/* "Message" type label — parallels SubagentItem's model info */}
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
Message
</span>
{/* Reply indicator — shows which SendMessage triggered this response */}
{plainReplyToSummary && (
<span
role="presentation"
className="flex cursor-default items-center gap-1 text-[10px]"
style={{ color: CARD_ICON_MUTED }}
onMouseEnter={() => onReplyHover?.(teammateMessage.replyToToolId ?? null)}
onMouseLeave={() => onReplyHover?.(null)}
>
<CornerDownLeft className="size-2.5" />
<span className="truncate" style={{ maxWidth: '180px' }}>
{plainReplyToSummary}
</span>
</span>
)}
{/* Resend badge — marks duplicate/resent messages */}
{isResend && (
<span
className="flex items-center gap-0.5 text-[10px]"
style={{ color: CARD_ICON_MUTED }}
>
<RefreshCw className="size-2.5" />
Resent
</span>
)}
{/* Summary */}
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
{truncatedSummary || 'Teammate message'}
</span>
{/* Context impact — tokens injected into main session */}
{teammateMessage.tokenCount != null && teammateMessage.tokenCount > 0 && (
<span
className="shrink-0 font-mono text-[11px] tabular-nums"
style={{ color: CARD_ICON_MUTED }}
>
~{formatTokensCompact(teammateMessage.tokenCount)} tokens
</span>
)}
{/* Timestamp — rightmost info element */}
<span
className="shrink-0 font-mono text-[11px] tabular-nums"
style={{ color: CARD_ICON_MUTED }}
>
{format(teammateMessage.timestamp, 'HH:mm:ss')}
</span>
</div>
{/* Expanded content */}
{isExpanded && (
<div className="p-3">
<MarkdownViewer content={displayContent} copyable />
</div>
)}
</div>
);
};