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 { useShallow } from 'zustand/react/shallow'; import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting'; import { formatTokensCompact } from '@renderer/utils/formatters'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { linkifyAllMentionsInMarkdown } 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 = ({ teammateMessage, onClick, isExpanded, onReplyHover, highlightClasses = '', highlightStyle, }) => { const colors = getTeamColorSet(teammateMessage.color); const { isLight } = useTheme(); // Get team members for @mention highlighting const members = useStore(useShallow((s) => s.selectedTeamData?.members)); const memberColorMap = useMemo( () => (members ? buildMemberColorMap(members) : new Map()), [members] ); // Get team names for @team linkification const teams = useStore(useShallow((s) => s.teams)); const teamNames = useMemo( () => teams.filter((t) => !t.deletedAt).map((t) => t.teamName), [teams] ); // 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 linkifyAllMentionsInMarkdown(stripped, memberColorMap, teamNames); }, [teammateMessage.content, memberColorMap, teamNames]); // Noise: minimal inline row (no card, no expand) if (noiseLabel) { return (
{teammateMessage.teammateId} {noiseLabel}
); } const truncatedSummary = plainSummary.length > 80 ? plainSummary.slice(0, 80) + '...' : plainSummary; return (
{/* Header */}
{ 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', }} > {/* Message icon — distinguishes from SubagentItem's Bot/dot icon */} {/* Teammate name badge */} {teammateMessage.teammateId} {/* "Message" type label — parallels SubagentItem's model info */} Message {/* Reply indicator — shows which SendMessage triggered this response */} {plainReplyToSummary && ( onReplyHover?.(teammateMessage.replyToToolId ?? null)} onMouseLeave={() => onReplyHover?.(null)} > {plainReplyToSummary} )} {/* Resend badge — marks duplicate/resent messages */} {isResend && ( Resent )} {/* Summary */} {truncatedSummary || 'Teammate message'} {/* Context impact — tokens injected into main session */} {teammateMessage.tokenCount != null && teammateMessage.tokenCount > 0 && ( ~{formatTokensCompact(teammateMessage.tokenCount)} tokens )} {/* Timestamp — rightmost info element */} {format(teammateMessage.timestamp, 'HH:mm:ss')}
{/* Expanded content */} {isExpanded && (
)}
); };