import { useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED, CARD_TEXT_LIGHT, } from '@renderer/constants/cssVariables'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getMessageTypeLabel, getStructuredMessageSummary, parseMessageReply, parseStructuredAgentMessage, } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { AlertTriangle, ChevronRight, ListPlus, Reply } from 'lucide-react'; import { ReplyQuoteBlock } from './ReplyQuoteBlock'; import type { TeamColorSet } from '@renderer/constants/teamColors'; import type { InboxMessage } from '@shared/types'; type StructuredMessage = Record; interface ActivityItemProps { message: InboxMessage; teamName: string; memberRole?: string; memberColor?: string; recipientColor?: string; /** When true, show a blue unread dot. */ isUnread?: boolean; onMemberNameClick?: (memberName: string) => void; onCreateTask?: (subject: string, description: string) => void; onReply?: (message: InboxMessage) => void; /** Called when a task ID link (e.g. #10) is clicked in message text. */ onTaskIdClick?: (taskId: string) => void; } function getStringField(obj: StructuredMessage, key: string): string | null { const value = obj[key]; return typeof value === 'string' && value.trim() !== '' ? value : null; } function getNoiseLabel(parsed: StructuredMessage): string | null { const type = getStringField(parsed, 'type'); if (type === 'idle_notification') { const reason = getStringField(parsed, 'idleReason'); return reason ? `Idle (${reason})` : 'Idle'; } if (type === 'shutdown_response') { return parsed.approve === true ? 'Shut down' : 'Rejected shutdown'; } if (type === 'shutdown_request') { return 'Shutdown requested'; } if (type === 'shutdown_approved' || type === 'teammate_terminated') { return type === 'shutdown_approved' ? 'Shutdown confirmed' : 'Terminated'; } if (type === 'task_completed') { const rawTaskId = parsed.taskId; const taskId = typeof rawTaskId === 'string' || typeof rawTaskId === 'number' ? rawTaskId : null; return taskId !== null ? `Completed task #${taskId}` : 'Completed a task'; } return null; } // --------------------------------------------------------------------------- // Compact noise row (idle, shutdown, terminated) — minimal dot + name + label // --------------------------------------------------------------------------- const NoiseRow = ({ name, label, colors, }: { name: string; label: string; colors: TeamColorSet; }): React.JSX.Element => (
{name} {label}
); // --------------------------------------------------------------------------- // Detect system/automated messages that should be collapsed by default. // These are generated by teamctl.js and contain tool instructions, not // human-written content, so showing them expanded adds visual noise. // --------------------------------------------------------------------------- const SYSTEM_MESSAGE_PATTERNS: { pattern: RegExp; label: string }[] = [ { pattern: /^New task assigned to you:/, label: 'Task assignment' }, { pattern: /^Task #\d+\s+approved/, label: 'Task approved' }, { pattern: /^Task #\d+\s+needs fixes/, label: 'Review changes requested' }, ]; function getSystemMessageLabel(text: string): string | null { for (const { pattern, label } of SYSTEM_MESSAGE_PATTERNS) { if (pattern.test(text)) return label; } return null; } // --------------------------------------------------------------------------- // Full message card — left colored border, name badge, collapsible content // --------------------------------------------------------------------------- /** Convert `#` in plain text to markdown links with task:// protocol. */ function linkifyTaskIdsInMarkdown(text: string): string { return text.replace(/#(\d+)/g, '[#$1](task://$1)'); } /** Render `#` in plain text as clickable inline elements. */ function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] { return text.split(/(#\d+)/g).map((part, i) => { const match = /^#(\d+)$/.exec(part); if (!match) return {part}; const taskId = match[1]; return ( ); }); } export const ActivityItem = ({ message, teamName, memberRole, memberColor, recipientColor, isUnread, onMemberNameClick, onCreateTask, onReply, onTaskIdClick, }: ActivityItemProps): React.JSX.Element => { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const formattedRole = formatAgentRole(memberRole); const timestamp = Number.isNaN(Date.parse(message.timestamp)) ? message.timestamp : new Date(message.timestamp).toLocaleString(); const structured = parseStructuredAgentMessage(message.text); // Only flag agent messages as rate-limited, not user's own quotes const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text); // Never collapse rate limit messages as noise — they must be visible const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null; // System/automated messages start collapsed (but not rate limits) const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null; const [isExpanded, setIsExpanded] = useState(!systemLabel); // Strip agent-only blocks from displayed text + linkify task IDs const displayText = useMemo(() => { if (structured) return null; const stripped = stripAgentBlocks(message.text).trim(); if (!stripped) return null; // All content was agent-only blocks → show summary instead return onTaskIdClick ? linkifyTaskIdsInMarkdown(stripped) : stripped; }, [structured, message.text, onTaskIdClick]); // Check if this is a reply message const parsedReply = useMemo( () => (displayText ? parseMessageReply(displayText) : null), [displayText] ); const rawSummary = message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]); // Noise messages: minimal inline row if (noiseLabel) { return ; } const messageType = structured && typeof structured.type === 'string' ? getMessageTypeLabel(structured.type) : null; const autoSummary = structured ? getStructuredMessageSummary(structured) : null; const handleCreateTask = (): void => { const subject = message.summary || autoSummary || `Task from ${message.from}`; const plainText = structured ? JSON.stringify(structured, null, 2) : stripAgentBlocks(message.text); const description = `From: ${message.from}\nAt: ${timestamp}\n\n${plainText}`.slice(0, 2000); onCreateTask?.(subject, description); }; const isHeaderClickable = Boolean(systemLabel); return (
{/* Header — div with role=button (cannot use Reply to message )} {onCreateTask && ( Create task from message )} {timestamp} {/* Content — collapsed for system messages, expanded for others */} {isExpanded ? (
{structured ? (
{autoSummary && autoSummary !== messageType ? (

{autoSummary}

) : null}
Raw JSON
                  {JSON.stringify(structured, null, 2)}
                
) : parsedReply ? ( ) : displayText ? ( { const link = (e.target as HTMLElement).closest( 'a[href^="task://"]' ); if (link) { e.preventDefault(); e.stopPropagation(); const taskId = link.getAttribute('href')?.replace('task://', ''); if (taskId) onTaskIdClick(taskId); } } : undefined } > ) : summaryText ? (

{summaryText}

) : null} {message.attachments?.length && message.messageId ? ( ) : null}
) : null}
); };