import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'; import { api } from '@renderer/api'; import { MemberHoverCard } from '@renderer/components/team/members/MemberHoverCard'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { extractFileReferenceTokens } from '@renderer/utils/groupTransformer'; import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { parseTaskNotifications } from '@shared/utils/contentSanitizer'; import { createLogger } from '@shared/utils/logger'; import { format } from 'date-fns'; import { CheckCircle, ChevronDown, ChevronUp, Circle, FileText, User, XCircle } from 'lucide-react'; import remarkGfm from 'remark-gfm'; import { useShallow } from 'zustand/react/shallow'; import { CopyButton } from '../common/CopyButton'; import { extractTextFromReactNode } from './markdownCopyUtils'; import { createSearchContext, EMPTY_SEARCH_MATCHES, highlightSearchInChildren, type SearchContext, } from './searchHighlightUtils'; import type { UserGroup } from '@renderer/types/groups'; const logger = createLogger('Component:UserChatGroup'); interface UserChatGroupProps { userGroup: UserGroup; } /** * Recursively walks React children and replaces text nodes containing @path * references with styled spans using validated path state. */ // eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types function highlightTextNode(text: string, validatedPaths: Record): React.ReactNode { const pathReferences = extractFileReferenceTokens(text); const parts: React.ReactNode[] = []; let lastIndex = 0; for (const reference of pathReferences) { if (reference.startIndex > lastIndex) { parts.push(text.slice(lastIndex, reference.startIndex)); } const fullMatch = reference.raw; const isValid = validatedPaths[fullMatch] === true; if (isValid) { parts.push( {fullMatch} ); } else { parts.push(fullMatch); } lastIndex = reference.endIndex; } if (lastIndex < text.length) { parts.push(text.slice(lastIndex)); } if (parts.length === 0) return text; if (parts.length === 1) return parts[0]; return parts; } // eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types function highlightPaths( children: React.ReactNode, validatedPaths: Record ): React.ReactNode { // eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types return React.Children.map(children, (child): React.ReactNode => { if (typeof child === 'string') { return highlightTextNode(child, validatedPaths); } if (React.isValidElement<{ children?: React.ReactNode }>(child) && child.props.children) { return React.cloneElement( child, undefined, highlightPaths(child.props.children, validatedPaths) ); } return child; }); } /** * Custom URL transform that preserves mention:// protocol. * react-markdown strips non-standard protocols by default. */ function allowMentionProtocol(url: string): string { if (url.startsWith('mention://')) return url; return defaultUrlTransform(url); } /** * Creates markdown components for user bubble rendering. * Uses chat-user CSS variables for consistent styling and wraps * text-bearing elements through highlightPaths for @path tag injection * and optional search term highlighting. */ function createUserMarkdownComponents( validatedPaths: Record, searchCtx: SearchContext | null, isLight = false ): Components { const userTextColor = 'var(--chat-user-text)'; // Compose path highlighting with optional search highlighting // eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types const hl = (children: React.ReactNode): React.ReactNode => { const withPaths = highlightPaths(children, validatedPaths); return searchCtx ? highlightSearchInChildren(withPaths, searchCtx) : withPaths; }; return { h1: ({ children }) => (

{hl(children)}

), h2: ({ children }) => (

{hl(children)}

), h3: ({ children }) => (

{hl(children)}

), h4: ({ children }) => (

{hl(children)}

), h5: ({ children }) => (
{hl(children)}
), h6: ({ children }) => (
{hl(children)}
), p: ({ children }) => (

{hl(children)}

), // Inline elements — no hl(); parent block element's hl() descends here // mention:// links render as colored badges with MemberHoverCard a: ({ href, children }) => { if (href?.startsWith('mention://')) { const path = href.slice('mention://'.length); const slashIdx = path.indexOf('/'); let color = ''; let memberName = ''; try { color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : ''; memberName = slashIdx >= 0 ? decodeURIComponent(path.slice(slashIdx + 1)) : ''; } catch { // malformed percent-encoding } const colorSet = getTeamColorSet(color); const bg = getThemedBadge(colorSet, isLight); const badge = ( {children} ); if (memberName) { return ( {badge} ); } return badge; } return ( {children} ); }, strong: ({ children }) => ( {children} ), em: ({ children }) => ( {children} ), del: ({ children }) => ( {children} ), code: ({ className, children }) => { const hasLanguageClass = className?.includes('language-'); const content = typeof children === 'string' ? children : ''; const isMultiLine = content.includes('\n'); const isBlock = (hasLanguageClass ?? false) || isMultiLine; if (isBlock) { return ( {hl(children)} ); } // Inline code — no hl() return ( {children} ); }, pre: ({ children }) => { const codeText = extractTextFromReactNode(children).trim(); return (
          {codeText ?  : null}
          {children}
        
); }, blockquote: ({ children }) => (
{hl(children)}
), ul: ({ children }) => (
    {children}
), ol: ({ children }) => (
    {children}
), li: ({ children }) => (
  • {hl(children)}
  • ), table: ({ children }) => (
    {children}
    ), thead: ({ children }) => ( {children} ), th: ({ children }) => ( {hl(children)} ), td: ({ children }) => ( {hl(children)} ), hr: () =>
    , }; } /** * UserChatGroup displays a user's input message. * Features: * - Right-aligned bubble layout with subtle blue styling * - Header with user icon, label, and timestamp * - Markdown rendering with inline highlighted mentions (@paths) * - Copy button on hover * - Toggle for long content (>500 chars) * - Shows image count indicator */ const UserChatGroupInner = ({ userGroup }: Readonly): React.JSX.Element => { const { content, timestamp, id: groupId } = userGroup; const [isManuallyExpanded, setIsManuallyExpanded] = useState(false); const [validatedPaths, setValidatedPaths] = useState>({}); const { isLight } = useTheme(); // Get projectPath from per-tab session data, falling back to global state const { tabId } = useTabUI(); const projectPath = useStore((s) => { const td = tabId ? s.tabSessionData[tabId] : null; return (td?.sessionDetail ?? s.sessionDetail)?.session?.projectPath; }); // Get team members for @mention highlighting and team names for @team linkification const { members, teams } = useStore( useShallow((s) => ({ members: selectResolvedMembersForTeamName(s, s.selectedTeamName), teams: s.teams, })) ); const memberColorMap = useMemo( () => (members ? buildMemberColorMap(members) : new Map()), [members] ); const teamNames = useMemo( () => teams.filter((t) => !t.deletedAt).map((t) => t.teamName), [teams] ); // Get search state for highlighting — only re-render if THIS item has matches const { searchQuery, searchMatches, currentSearchIndex } = useStore( useShallow((s) => { const hasMatch = s.searchMatchItemIds.has(groupId); return { searchQuery: hasMatch ? s.searchQuery : '', searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, }; }) ); const hasImages = content.images.length > 0; // Use rawText to preserve /commands inline const textContent = content.rawText ?? content.text ?? ''; const stripped = useMemo(() => stripAgentBlocks(textContent), [textContent]); const isLongContent = stripped.length > 500; const taskNotifications = useMemo(() => { const rawContent = typeof userGroup.message.content === 'string' ? userGroup.message.content : Array.isArray(userGroup.message.content) ? userGroup.message.content .filter((block): block is { type: 'text'; text: string } => { return ( typeof block === 'object' && block !== null && 'type' in block && 'text' in block && (block as { type?: unknown }).type === 'text' && typeof (block as { text?: unknown }).text === 'string' ); }) .map((block) => block.text) .join('') : ''; return parseTaskNotifications(rawContent); }, [userGroup.message.content]); // Extract @path mentions from text const pathMentions = useMemo(() => { if (!textContent) return []; return extractFileReferenceTokens(textContent).map((reference) => ({ value: reference.path, raw: reference.raw, })); }, [textContent]); // Validate @path mentions via IPC useEffect(() => { if (pathMentions.length === 0 || !projectPath) return; let isCurrent = true; const validatePaths = async (): Promise => { try { const toValidate = pathMentions.map((m) => ({ type: 'path' as const, value: m.value })); const results = await api.validateMentions(toValidate, projectPath); if (isCurrent) { setValidatedPaths( Object.fromEntries( pathMentions.map((mention) => [mention.raw, results[`@${mention.value}`] === true]) ) ); } } catch (err) { logger.error('Path validation failed:', err); if (isCurrent) { setValidatedPaths({}); } } }; void validatePaths(); return () => { isCurrent = false; }; }, [textContent, projectPath, pathMentions]); const effectiveValidatedPaths = useMemo( () => (pathMentions.length === 0 || !projectPath ? {} : validatedPaths), [pathMentions.length, projectPath, validatedPaths] ); // Create search context (fresh each render so counter starts at 0) const searchCtx = searchQuery ? createSearchContext(searchQuery, groupId, searchMatches, currentSearchIndex) : null; // Base markdown components (no search) — safe to memoize const userMarkdownComponentsBase = useMemo( () => createUserMarkdownComponents(effectiveValidatedPaths, null, isLight), [effectiveValidatedPaths, isLight] ); // When search is active, create fresh each render (match counter is stateful and must start at 0) // useMemo would cache stale closures when parent re-renders without search deps changing const userMarkdownComponents = searchCtx ? createUserMarkdownComponents(effectiveValidatedPaths, searchCtx, isLight) : userMarkdownComponentsBase; // Auto-expand when search is active and this message has ANY matches. // Without this, the pre-counter searches full text but the renderer only // shows the first 500 chars — creating phantom matches. const shouldAutoExpand = useMemo(() => { if (!searchQuery || !isLongContent) return false; return searchMatches.some((m) => m.itemId === groupId); }, [searchQuery, isLongContent, searchMatches, groupId]); // Combined expansion state: manual toggle or auto-expand for search const isExpanded = isManuallyExpanded || shouldAutoExpand; const anchorRef = useRef(null); const handleCollapse = useCallback(() => { setIsManuallyExpanded(false); anchorRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }, []); // Determine display text const baseDisplayText = isLongContent && !isExpanded ? stripped.slice(0, 500) + '...' : stripped; // Pre-process: convert @memberName to mention:// markdown links const displayText = useMemo( () => linkifyAllMentionsInMarkdown(baseDisplayText, memberColorMap, teamNames), [baseDisplayText, memberColorMap, teamNames] ); return (
    {/* Header - right aligned with improved hierarchy */}
    {format(timestamp, 'h:mm:ss a')} You
    {/* Content - polished bubble with subtle depth (hide when only agent blocks) */} {stripped && (
    {displayText}
    {isLongContent && !isExpanded && ( )}
    )} {/* Sticky Show less — outside overflow-hidden bubble so sticky works */} {stripped && isLongContent && isExpanded ? (
    ) : null} {taskNotifications.length > 0 && taskNotifications.map((notification) => { const isCompleted = notification.status === 'completed'; const isFailed = notification.status === 'failed' || notification.status === 'error'; const StatusIcon = isFailed ? XCircle : isCompleted ? CheckCircle : Circle; const statusColor = isFailed ? 'var(--error-highlight-text, #ef4444)' : isCompleted ? 'var(--badge-success-text, #22c55e)' : 'var(--color-text-muted)'; const commandMatch = /"([^"]+)"/.exec(notification.summary); const commandName = commandMatch?.[1] ?? notification.summary.trim() ?? 'Background task'; const exitCodeMatch = /\(exit code (\d+)\)/.exec(notification.summary); const outputFileName = notification.outputFile ? (notification.outputFile.split(/[\\/]/).pop() ?? notification.outputFile) : null; return (
    {commandName || 'Background task'}
    {notification.status || 'unknown'} {exitCodeMatch?.[1] ? exit {exitCodeMatch[1]} : null} {outputFileName ? ( {outputFileName} ) : null}
    ); })} {/* Images indicator */} {hasImages && (
    {content.images.length} image{content.images.length > 1 ? 's' : ''} attached
    )}
    ); }; export const UserChatGroup = React.memo(UserChatGroupInner);