import { Fragment, useMemo } 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 { 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 { useStore } from '@renderer/store'; import { getMessageTypeLabel, getStructuredMessageSummary, parseMessageReply, parseStructuredAgentMessage, } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; 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, RefreshCw, Reply } from 'lucide-react'; import { isManagedCollapseState } from './collapseState'; import { ReplyQuoteBlock } from './ReplyQuoteBlock'; import type { ActivityCollapseState } from './collapseState'; 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 }: { teamName: string }): React.JSX.Element { return ( {teamName} ); } interface ActivityItemProps { message: InboxMessage; teamName: string; localMemberNames?: Set; 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; 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; /** Explicit collapse state for timeline-controlled collapsed mode. */ collapseState?: ActivityCollapseState; } 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 // --------------------------------------------------------------------------- /** Convert `#` in plain text to markdown links with task:// protocol. */ export function linkifyTaskIdsInMarkdown(text: string): string { return text.replace(/#([A-Za-z0-9-]+)\b/g, '[#$1](task://$1)'); } /** 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 = ({ message, teamName, localMemberNames, memberRole, memberColor, recipientColor, isUnread, memberColorMap, onMemberNameClick, onCreateTask, onReply, onTaskIdClick, onRestartTeam, zebraShade, collapseState, }: ActivityItemProps): React.JSX.Element => { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const { isLight } = useTheme(); const formattedRole = formatAgentRole(memberRole); const teams = useStore((s) => s.teams); const teamNames = useMemo( () => teams.filter((t) => !t.deletedAt).map((t) => t.teamName), [teams] ); 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); // 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 = isManagedCollapseState(collapseState); const isExpanded = isManaged ? !collapseState.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); if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) result = linkifyAllMentionsInMarkdown(result, memberColorMap ?? new Map(), teamNames); return result; }, [strippedText, memberColorMap, teamNames, systemLabel]); 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 = isManaged ? collapseState.canToggle : false; const showChevron = isHeaderClickable; const isUserSent = message.source === 'user_sent' || isCrossTeamSent; const isSystemMessage = message.from === 'system'; const onManagedToggle = isManaged ? collapseState.onToggle : undefined; const handleHeaderToggle = isHeaderClickable ? (): void => { onManagedToggle?.(); } : undefined; 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} {/* 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}
); };