import { Fragment, memo, useCallback, useMemo } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { CopyButton } from '@renderer/components/common/CopyButton'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; 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 { CARD_BG, CARD_BG_ZEBRA, CARD_BORDER_STYLE, CARD_ICON_MUTED, CARD_TEXT_LIGHT, } from '@renderer/constants/cssVariables'; import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { getMessageTypeLabel, getStructuredMessageSummary, parseMessageReply, parseStructuredAgentMessage, } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { cn } from '@renderer/lib/utils'; import { areInboxMessagesEquivalentForRender, areStringArraysEqual, areStringMapsEqual, } from '@renderer/utils/messageRenderEquality'; import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, parseCrossTeamPrefix, stripCrossTeamPrefix, } from '@shared/constants/crossTeam'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { AlertTriangle, ChevronRight, ListPlus, Maximize2, RefreshCw, Reply } from 'lucide-react'; import { ReplyQuoteBlock } from './ReplyQuoteBlock'; import type { TeamColorSet } from '@renderer/constants/teamColors'; import type { InboxMessage } from '@shared/types'; type StructuredMessage = Record; function parseQualifiedRecipient( value: string | undefined ): { teamName: string; memberName: string } | null { if (typeof value !== 'string') return null; const trimmed = value.trim(); const dot = trimmed.indexOf('.'); if (dot <= 0 || dot === trimmed.length - 1) return null; return { teamName: trimmed.slice(0, dot), memberName: trimmed.slice(dot + 1), }; } function parseCrossTeamPseudoRecipient(value: string | undefined): string | null { if (typeof value !== 'string') return null; const trimmed = value.trim(); if (!trimmed.startsWith('cross-team:')) return null; const teamName = trimmed.slice('cross-team:'.length).trim(); return teamName.length > 0 ? teamName : null; } export function isQualifiedExternalRecipient( value: string | undefined, teamName: string, localMemberNames?: Set ): boolean { const recipient = parseQualifiedRecipient(value); if (!recipient) return false; if (recipient.teamName === teamName) return false; return !localMemberNames?.has(value?.trim() ?? ''); } export function getCrossTeamSentTarget( value: string | undefined, teamName: string, localMemberNames?: Set ): string | null { const pseudoTarget = parseCrossTeamPseudoRecipient(value); if (pseudoTarget) return pseudoTarget; const recipient = parseQualifiedRecipient(value); if (!recipient) return null; if (recipient.teamName === teamName) return null; if (localMemberNames?.has(value?.trim() ?? '')) return null; return recipient.teamName; } export function getCrossTeamSentMemberName(value: string | undefined): string | null { return parseQualifiedRecipient(value)?.memberName ?? null; } function CrossTeamTeamBadge({ teamName, onClick, }: { teamName: string; onClick?: (teamName: string) => void; }): React.JSX.Element { if (onClick) { return ( ); } return ( {teamName} ); } interface ActivityItemProps { message: InboxMessage; teamName: string; localMemberNames?: Set; teamNames?: string[]; memberRole?: string; memberColor?: string; recipientColor?: string; /** When true, show a blue unread dot. */ isUnread?: boolean; /** Map of member name → color name for @mention badge rendering. */ memberColorMap?: Map; /** Team color mapping for team:// links rendered inside markdown. */ teamColorByName?: ReadonlyMap; /** Opens a team tab from cross-team badges or team:// links. */ onTeamClick?: (teamName: string) => void; 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; /** Called when the user clicks "Restart team" on an auth error message. */ onRestartTeam?: () => void; /** When true, apply a subtle lighter background for zebra-striped lists. */ zebraShade?: boolean; /** Collapsed-mode primitives stabilized by ActivityTimeline. */ collapseMode?: 'default' | 'managed'; isCollapsed?: boolean; canToggleCollapse?: boolean; collapseToggleKey?: string; onToggleCollapse?: (key: string) => void; /** Compact header mode for narrow message lists. */ compactHeader?: boolean; /** Callback to expand this item into a fullscreen dialog. */ onExpand?: (key: string) => void; /** Stable key for expand identification. */ expandItemKey?: string; } function areMessagesEquivalentForActivityItem(prev: InboxMessage, next: InboxMessage): boolean { return areInboxMessagesEquivalentForRender(prev, next); } function getStringField(obj: StructuredMessage, key: string): string | null { const value = obj[key]; return typeof value === 'string' && value.trim() !== '' ? value : null; } /** Check if a message renders as a compact noise row (idle, shutdown, etc.). */ export function isNoiseMessage(text: string): boolean { const parsed = parseStructuredAgentMessage(text); return parsed !== null && getNoiseLabel(parsed) !== 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 ${formatTaskDisplayLabel({ id: String(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 historical system/automated messages that should be collapsed by default. // These patterns are kept only for legacy compatibility with old inbox/session rows; // new runtime behavior must not depend on exact legacy wording. // --------------------------------------------------------------------------- const SYSTEM_MESSAGE_PATTERNS: { pattern: RegExp; label: string }[] = [ { pattern: /^New task assigned to you:/, label: 'Task assignment' }, { pattern: /^Task #[A-Za-z0-9-]+\s+approved/, label: 'Task approved' }, { pattern: /^Task #[A-Za-z0-9-]+\s+needs fixes/, label: 'Review changes requested' }, ]; export function getSystemMessageLabel(text: string): string | null { for (const { pattern, label } of SYSTEM_MESSAGE_PATTERNS) { if (pattern.test(text)) return label; } return null; } /** Labels to highlight in task assignment / review messages (bold in markdown). */ const TASK_MESSAGE_LABELS = [ 'New task assigned to you:', 'Description:', 'Task approved', 'Task needs fixes', 'Review changes requested', 'Changes requested:', 'Comments:', 'Reviewer:', 'Related:', 'Blocked by:', 'Blocks:', ]; /** Make known structural labels bold in system/task messages. */ function highlightSystemLabels(text: string, isSystem: boolean): string { if (!isSystem) return text; let result = text; for (const label of TASK_MESSAGE_LABELS) { // Escape any regex-special chars in the label, match at line start or after newline const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); result = result.replace(new RegExp(`(^|\\n)(${escaped})`, 'g'), '$1**$2**'); } return result; } /** Detect authentication/authorization errors that may be resolved by restarting. */ const AUTH_ERROR_PATTERNS = [ /OAuth token has expired/i, /API Error:\s*401/i, /authentication_error/i, /Failed to authenticate/i, /invalid.*api.key/i, /unauthorized/i, ]; // --------------------------------------------------------------------------- // Full message card — left colored border, name badge, collapsible content // --------------------------------------------------------------------------- /** Render `#` in plain text as clickable inline elements with TaskTooltip. */ function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] { return text.split(/(#[A-Za-z0-9-]+\b)/g).map((part, i) => { const match = /^#([A-Za-z0-9-]+)$/.exec(part); if (!match) return {part}; const taskId = match[1]; return ( ); }); } export const ActivityItem = memo( function ActivityItem({ message, teamName, localMemberNames, teamNames = [], memberRole, memberColor, recipientColor, isUnread, memberColorMap, teamColorByName, onTeamClick, onMemberNameClick, onCreateTask, onReply, onTaskIdClick, onRestartTeam, zebraShade, collapseMode = 'default', isCollapsed = false, canToggleCollapse = false, collapseToggleKey, onToggleCollapse, compactHeader = false, onExpand, expandItemKey, }: ActivityItemProps): React.JSX.Element { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const { isLight } = useTheme(); // Hide role when it matches the sender name (avoids "lead" badge + "Team Lead" text duplication) const formattedRole = memberRole && memberRole !== message.from ? formatAgentRole(memberRole) : null; const timestamp = useMemo(() => { if (Number.isNaN(Date.parse(message.timestamp))) return message.timestamp; const date = new Date(message.timestamp); const now = new Date(); const isToday = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate(); return isToday ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : date.toLocaleString(); }, [message.timestamp]); 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); // Highlight messages containing API errors const isApiError = message.text.includes('API Error'); // Detect auth errors that may be resolved by restarting the team const isAuthError = isApiError && AUTH_ERROR_PATTERNS.some((p) => p.test(message.text)); // Never collapse rate limit messages as noise — they must be visible const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null; const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null; const isManaged = collapseMode === 'managed'; const isExpanded = isManaged ? !isCollapsed : true; const parsedCrossTeamPrefix = useMemo(() => parseCrossTeamPrefix(message.text), [message.text]); const qualifiedRecipient = useMemo(() => parseQualifiedRecipient(message.to), [message.to]); const crossTeamSentTarget = useMemo( () => getCrossTeamSentTarget(message.to, teamName, localMemberNames), [message.to, teamName, localMemberNames] ); const crossTeamSentMemberName = useMemo( () => getCrossTeamSentMemberName(message.to), [message.to] ); const isCrossTeam = message.source === CROSS_TEAM_SOURCE || parsedCrossTeamPrefix !== null; const isCrossTeamSent = message.source === CROSS_TEAM_SENT_SOURCE || crossTeamSentTarget !== null; const isCrossTeamAny = isCrossTeam || isCrossTeamSent; const crossTeamOrigin = useMemo(() => { if (!isCrossTeam) return null; const fromValue = parsedCrossTeamPrefix?.from ?? message.from; const dot = fromValue.indexOf('.'); if (dot <= 0 || dot === fromValue.length - 1) return null; return { teamName: fromValue.substring(0, dot), memberName: fromValue.substring(dot + 1), }; }, [isCrossTeam, message.from, parsedCrossTeamPrefix]); const crossTeamTarget = useMemo(() => { if (!isCrossTeamSent) return null; if (crossTeamSentTarget) return crossTeamSentTarget; if (qualifiedRecipient) return qualifiedRecipient.teamName; if (!message.to) return null; const dot = message.to.indexOf('.'); if (dot <= 0) return message.to; return message.to.substring(0, dot); }, [crossTeamSentTarget, isCrossTeamSent, message.to, qualifiedRecipient]); const senderName = crossTeamOrigin ? crossTeamOrigin.memberName : message.from; const senderColor = crossTeamOrigin ? undefined : (memberColor ?? message.color); const senderHideAvatar = message.from === 'user' || message.from === 'system' || crossTeamOrigin?.memberName === 'user'; // Strip agent-only blocks + normalize escape sequences (before linkification) const strippedText = useMemo(() => { if (structured) return null; let stripped = stripAgentBlocks(message.text).trim(); if (!stripped) return null; // All content was agent-only blocks → show summary instead // Strip cross-team metadata tag (e.g. `\n`) // — kept in stored text for CLI agents / durable artifacts. if (isCrossTeamAny) { stripped = stripCrossTeamPrefix(stripped); } // Normalize literal \n from historical CLI-produced text to real newlines return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); }, [structured, message.text, isCrossTeamAny]); // Parse reply BEFORE linkification — linkifyAllMentionsInMarkdown transforms @name // into markdown links which breaks the reply regex matcher const parsedReply = useMemo( () => (strippedText ? parseMessageReply(strippedText) : null), [strippedText] ); // Linkify task IDs (always, for TaskTooltip) + @mentions for display const displayText = useMemo(() => { if (!strippedText) return null; let result = highlightSystemLabels(strippedText, !!systemLabel); result = linkifyTaskIdsInMarkdown(result, message.taskRefs); if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) result = linkifyAllMentionsInMarkdown(result, memberColorMap ?? new Map(), teamNames); return result; }, [strippedText, memberColorMap, teamNames, systemLabel]); const crossTeamPreview = useMemo(() => { if (!isCrossTeamAny || !strippedText) return ''; const oneLine = strippedText.replace(/\n+/g, ' ').trim(); if (!oneLine) return ''; return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine; }, [isCrossTeamAny, strippedText]); const rawSummary = useMemo(() => { if (crossTeamPreview) return crossTeamPreview; const s = message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; if (s) return s; // Fallback: use the beginning of message text as preview for plain-text messages const plain = stripAgentBlocks(message.text).trim(); if (!plain) return ''; const oneLine = plain.replace(/\n+/g, ' '); return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine; }, [crossTeamPreview, message.summary, structured, message.text]); 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 = useCallback((): 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); }, [ autoSummary, message.from, message.summary, message.text, onCreateTask, structured, timestamp, ]); const isHeaderClickable = isManaged && canToggleCollapse; const showChevron = isHeaderClickable && !compactHeader; const isUserSent = message.source === 'user_sent' || isCrossTeamSent; const isSystemMessage = message.from === 'system'; const handleHeaderToggle = useCallback(() => { if (isHeaderClickable && collapseToggleKey) { onToggleCollapse?.(collapseToggleKey); } }, [collapseToggleKey, isHeaderClickable, onToggleCollapse]); return (
{/* Header — div with role=button (cannot use )} {/* Content — collapsed for system messages, expanded for others */} {isExpanded ? (
{structured ? (
{autoSummary && autoSummary !== messageType ? (

{autoSummary}

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

{summaryText}

) : null} {/* Auth error recovery action */} {isAuthError && onRestartTeam ? (

Authentication failed. Restarting the team will refresh the session and may resolve this issue. If the problem persists, check your API credentials or try again later.

) : null} {message.attachments?.length && message.messageId ? ( ) : null}
) : null}
); }, (prev, next) => prev.teamName === next.teamName && prev.localMemberNames === next.localMemberNames && prev.memberRole === next.memberRole && prev.memberColor === next.memberColor && prev.recipientColor === next.recipientColor && prev.isUnread === next.isUnread && prev.memberColorMap === next.memberColorMap && areStringArraysEqual(prev.teamNames, next.teamNames) && areStringMapsEqual(prev.teamColorByName, next.teamColorByName) && prev.onTeamClick === next.onTeamClick && prev.onMemberNameClick === next.onMemberNameClick && prev.onCreateTask === next.onCreateTask && prev.onReply === next.onReply && prev.onTaskIdClick === next.onTaskIdClick && prev.onRestartTeam === next.onRestartTeam && prev.zebraShade === next.zebraShade && prev.collapseMode === next.collapseMode && prev.isCollapsed === next.isCollapsed && prev.canToggleCollapse === next.canToggleCollapse && prev.collapseToggleKey === next.collapseToggleKey && prev.onToggleCollapse === next.onToggleCollapse && prev.compactHeader === next.compactHeader && prev.onExpand === next.onExpand && prev.expandItemKey === next.expandItemKey && areMessagesEquivalentForActivityItem(prev.message, next.message) );