472 lines · 15.0 KB · TypeScript
RawDownload
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 typesfunction highlightTextNode(text: string, validatedPaths: Record<string, boolean>): 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(        <span          key={match.index}          style={{            backgroundColor: 'var(--chat-user-tag-bg)',            color: 'var(--chat-user-tag-text)',            padding: '0.125rem 0.375rem',            borderRadius: '0.25rem',            border: '1px solid var(--chat-user-tag-border)',            fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',            fontSize: '0.8125em',          }}        >          {fullMatch}        </span>      );    } 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 typesfunction highlightPaths(  children: React.ReactNode,  validatedPaths: Record<string, boolean>): 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<string, boolean>,  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 }) => (      <h1 className="mb-3 mt-6 text-lg font-semibold first:mt-0" style={{ color: userTextColor }}>        {hl(children)}      </h1>    ),    h2: ({ children }) => (      <h2 className="mb-2 mt-5 text-base font-semibold first:mt-0" style={{ color: userTextColor }}>        {hl(children)}      </h2>    ),    h3: ({ children }) => (      <h3 className="mb-2 mt-4 text-sm font-semibold first:mt-0" style={{ color: userTextColor }}>        {hl(children)}      </h3>    ),    h4: ({ children }) => (      <h4 className="mb-1.5 mt-3 text-sm font-semibold first:mt-0" style={{ color: userTextColor }}>        {hl(children)}      </h4>    ),    h5: ({ children }) => (      <h5 className="mb-1 mt-2 text-sm font-medium first:mt-0" style={{ color: userTextColor }}>        {hl(children)}      </h5>    ),    h6: ({ children }) => (      <h6 className="mb-1 mt-2 text-xs font-medium first:mt-0" style={{ color: userTextColor }}>        {hl(children)}      </h6>    ),     p: ({ children }) => (      <p        className="my-2 text-sm leading-relaxed first:mt-0 last:mb-0"        style={{ color: userTextColor }}      >        {hl(children)}      </p>    ),     // Inline elements — no hl(); parent block element's hl() descends here    a: ({ href, children }) => (      <a        href={href}        className="no-underline hover:underline"        style={{ color: 'var(--chat-user-tag-text)' }}        target="_blank"        rel="noopener noreferrer"      >        {children}      </a>    ),     strong: ({ children }) => (      <strong className="font-semibold" style={{ color: userTextColor }}>        {children}      </strong>    ),     em: ({ children }) => (      <em className="italic" style={{ color: userTextColor }}>        {children}      </em>    ),     del: ({ children }) => (      <del className="line-through" style={{ color: userTextColor }}>        {children}      </del>    ),     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 (          <code className="block font-mono text-xs" style={{ color: userTextColor }}>            {hl(children)}          </code>        );      }      // Inline code — no hl()      return (        <code          className="rounded px-1.5 py-0.5 font-mono text-xs"          style={{            backgroundColor: 'var(--chat-user-tag-bg)',            color: 'var(--chat-user-tag-text)',            border: '1px solid var(--chat-user-tag-border)',          }}        >          {children}        </code>      );    },     pre: ({ children }) => (      <pre        className="my-3 overflow-x-auto rounded-lg p-3 font-mono text-xs leading-relaxed"        style={{          backgroundColor: 'rgba(0, 0, 0, 0.15)',          border: '1px solid var(--chat-user-tag-border)',          color: userTextColor,        }}      >        {children}      </pre>    ),     blockquote: ({ children }) => (      <blockquote        className="my-3 border-l-4 pl-4 italic"        style={{          borderColor: 'var(--chat-user-tag-border)',          color: userTextColor,        }}      >        {hl(children)}      </blockquote>    ),     ul: ({ children }) => (      <ul className="my-2 list-disc space-y-1 pl-5" style={{ color: userTextColor }}>        {children}      </ul>    ),    ol: ({ children }) => (      <ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: userTextColor }}>        {children}      </ol>    ),    li: ({ children }) => (      <li className="text-sm" style={{ color: userTextColor }}>        {hl(children)}      </li>    ),     table: ({ children }) => (      <div className="my-3 overflow-x-auto">        <table          className="min-w-full border-collapse text-sm"          style={{ borderColor: 'var(--chat-user-tag-border)' }}        >          {children}        </table>      </div>    ),    thead: ({ children }) => (      <thead style={{ backgroundColor: 'rgba(0, 0, 0, 0.1)' }}>{children}</thead>    ),    th: ({ children }) => (      <th        className="px-3 py-2 text-left font-semibold"        style={{          border: '1px solid var(--chat-user-tag-border)',          color: userTextColor,        }}      >        {hl(children)}      </th>    ),    td: ({ children }) => (      <td        className="px-3 py-2"        style={{          border: '1px solid var(--chat-user-tag-border)',          color: userTextColor,        }}      >        {hl(children)}      </td>    ),     hr: () => <hr className="my-4" style={{ borderColor: 'var(--chat-user-tag-border)' }} />,  };} /** * 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<UserChatGroupProps>): React.JSX.Element => {  const { content, timestamp, id: groupId } = userGroup;  const [isManuallyExpanded, setIsManuallyExpanded] = useState(false);  const [validatedPaths, setValidatedPaths] = useState<Record<string, boolean>>({});   // 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<void> => {      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 (    <div className="flex justify-end">      <div className="max-w-[85%] space-y-2">        {/* Header - right aligned with improved hierarchy */}        <div className="flex items-center justify-end gap-1.5">          <span className="text-[10px]" style={{ color: 'var(--color-text-muted)' }}>            {format(timestamp, 'h:mm:ss a')}          </span>          <span className="text-xs font-semibold" style={{ color: 'var(--color-text-secondary)' }}>            You          </span>          <User className="size-3.5" style={{ color: 'var(--color-text-secondary)' }} />        </div>         {/* Content - polished bubble with subtle depth */}        {textContent && (          <div            className="group relative overflow-hidden rounded-2xl rounded-br-sm px-4 py-3"            style={{              backgroundColor: 'var(--chat-user-bg)',              border: '1px solid var(--chat-user-border)',              boxShadow: 'var(--chat-user-shadow)',            }}          >            <CopyButton text={textContent} bgColor="var(--chat-user-bg)" />             <div className="text-sm" style={{ color: 'var(--chat-user-text)' }} data-search-content>              <ReactMarkdown remarkPlugins={[remarkGfm]} components={userMarkdownComponents}>                {displayText}              </ReactMarkdown>            </div>            {isLongContent && (              <button                onClick={() => setIsManuallyExpanded(!isManuallyExpanded)}                className="mt-2 text-xs underline hover:opacity-80"                style={{ color: 'var(--color-text-muted)' }}              >                {isExpanded ? 'Show less' : 'Show more'}              </button>            )}          </div>        )}         {/* Images indicator */}        {hasImages && (          <div className="text-right text-xs" style={{ color: 'var(--color-text-muted)' }}>            {content.images.length} image{content.images.length > 1 ? 's' : ''} attached          </div>        )}      </div>    </div>  );}; export const UserChatGroup = React.memo(UserChatGroupInner);