import React, { useEffect, useMemo, useState } from 'react'; import ReactMarkdown, { type Components } from 'react-markdown'; import { api } from '@renderer/api'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useStore } from '@renderer/store'; import { createLogger } from '@shared/utils/logger'; import { format } from 'date-fns'; import { User } from 'lucide-react'; import remarkGfm from 'remark-gfm'; import { useShallow } from 'zustand/react/shallow'; import { CopyButton } from '../common/CopyButton'; import { createSearchContext, highlightSearchInChildren, type SearchContext, } from './searchHighlightUtils'; import type { UserGroup } from '@renderer/types/groups'; const logger = createLogger('Component:UserChatGroup'); // Pattern for @paths only (file references) const PATH_PATTERN = /@([^\s,)}\]]+)/g; 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 pathPattern = /@[^\s,)}\]]+/g; const parts: React.ReactNode[] = []; let lastIndex = 0; let match; pathPattern.lastIndex = 0; while ((match = pathPattern.exec(text)) !== null) { if (match.index > lastIndex) { parts.push(text.slice(lastIndex, match.index)); } const fullMatch = match[0]; const isValid = validatedPaths[fullMatch] === true; if (isValid) { parts.push( {fullMatch} ); } else { parts.push(fullMatch); } lastIndex = match.index + fullMatch.length; } 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; }); } /** * 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 ): 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 a: ({ href, children }) => ( {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 }) => (
        {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>({}); // 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 search state for highlighting const { searchQuery, searchMatches, currentSearchIndex } = useStore( useShallow((s) => ({ searchQuery: s.searchQuery, searchMatches: s.searchMatches, currentSearchIndex: s.currentSearchIndex, })) ); const hasImages = content.images.length > 0; // Use rawText to preserve /commands inline const textContent = content.rawText ?? content.text ?? ''; const isLongContent = textContent.length > 500; // Extract @path mentions from text const pathMentions = useMemo(() => { if (!textContent) return []; const result: { value: string; raw: string }[] = []; const pathPattern = new RegExp(PATH_PATTERN.source, PATH_PATTERN.flags); let match; while ((match = pathPattern.exec(textContent)) !== null) { result.push({ value: match[1], raw: match[0] }); } return result; }, [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(results); } } 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), [effectiveValidatedPaths] ); // 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) : 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; // Determine display text const displayText = isLongContent && !isExpanded ? textContent.slice(0, 500) + '...' : textContent; return (
    {/* Header - right aligned with improved hierarchy */}
    {format(timestamp, 'h:mm:ss a')} You
    {/* Content - polished bubble with subtle depth */} {textContent && (
    {displayText}
    {isLongContent && ( )}
    )} {/* Images indicator */} {hasImages && (
    {content.images.length} image{content.images.length > 1 ? 's' : ''} attached
    )}
    ); }; export const UserChatGroup = React.memo(UserChatGroupInner);