244 lines · 7.8 KB · TypeScript
RawDownload
import React from 'react';import ReactMarkdown from 'react-markdown'; import { useStore } from '@renderer/store';import { AlertTriangle, CheckCircle, FileCheck, XCircle } from 'lucide-react';import remarkGfm from 'remark-gfm';import { useShallow } from 'zustand/react/shallow'; import { CopyButton } from '../common/CopyButton';import { OngoingBanner } from '../common/OngoingIndicator'; import { createMarkdownComponents, markdownComponents } from './markdownComponents';import { createSearchContext } from './searchHighlightUtils'; import type { AIGroupLastOutput } from '@renderer/types/groups'; interface LastOutputDisplayProps {  lastOutput: AIGroupLastOutput | null;  aiGroupId: string;  /** Whether this is the last AI group in the conversation */  isLastGroup?: boolean;  /** Whether the session is ongoing (from sessions array, same source as sidebar) */  isSessionOngoing?: boolean;} /** * LastOutputDisplay shows the always-visible last text output OR last tool result. * This is what the user sees as "the answer" from the AI. * * Features: * - Shows text output with elegant prose styling * - Shows tool result with tool name and icon * - Handles error states for tool results * - Shows timestamp * - Expandable for long content */export const LastOutputDisplay = ({  lastOutput,  aiGroupId,  isLastGroup = false,  isSessionOngoing = false,}: Readonly<LastOutputDisplayProps>): React.JSX.Element | null => {  const { searchQuery, searchMatches, currentSearchIndex } = useStore(    useShallow((s) => ({      searchQuery: s.searchQuery,      searchMatches: s.searchMatches,      currentSearchIndex: s.currentSearchIndex,    }))  );  const isTextOutput = lastOutput?.type === 'text' && Boolean(lastOutput.text);   // Create search context (fresh each render so counter starts at 0)  const searchCtx =    searchQuery && isTextOutput      ? createSearchContext(searchQuery, aiGroupId, searchMatches, currentSearchIndex)      : null;   // Create markdown components with optional search highlighting  // 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 mdComponents = searchCtx ? createMarkdownComponents(searchCtx) : markdownComponents;   // Show ongoing banner if this is the last AI group and session is ongoing  // This uses the same source (sessions array) as the sidebar green dot for consistency  if (isLastGroup && isSessionOngoing) {    return <OngoingBanner />;  }   if (!lastOutput) {    return null;  }   const { type } = lastOutput;   // Render text output  if (type === 'text' && lastOutput.text) {    const textContent = lastOutput.text || '';     return (      <div        className="group relative overflow-hidden rounded-lg"        style={{          backgroundColor: 'var(--code-bg)',          border: '1px solid var(--code-border)',        }}      >        <CopyButton text={textContent} />         {/* Content - scrollable */}        <div className="max-h-96 overflow-y-auto px-4 py-3" data-search-content>          <ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>            {textContent}          </ReactMarkdown>        </div>      </div>    );  }   // Render tool result  if (type === 'tool_result' && lastOutput.toolResult) {    const isError = lastOutput.isError ?? false;    const Icon = isError ? XCircle : CheckCircle;     return (      <div        className="overflow-hidden rounded-lg"        style={{          backgroundColor: isError            ? 'var(--tool-result-error-bg)'            : 'var(--tool-result-success-bg)',          border: `1px solid ${isError ? 'var(--tool-result-error-border)' : 'var(--tool-result-success-border)'}`,        }}      >        {/* Header */}        <div          className="flex items-center gap-2 px-4 py-2"          style={{            borderBottom: `1px solid ${isError ? 'var(--tool-result-error-border)' : 'var(--tool-result-success-border)'}`,          }}        >          <Icon            className="size-4"            style={{              color: isError ? 'var(--tool-result-error-text)' : 'var(--tool-result-success-text)',            }}          />          {lastOutput.toolName && (            <code              className="rounded px-1.5 py-0.5 text-xs"              style={{                backgroundColor: 'var(--tag-bg)',                color: 'var(--tag-text)',                border: '1px solid var(--tag-border)',              }}            >              {lastOutput.toolName}            </code>          )}          {isError && (            <span              className="text-xs font-medium"              style={{ color: 'var(--tool-result-error-text)' }}            >              Error            </span>          )}        </div>         {/* Content */}        <div className="px-4 py-3">          <pre            className="max-h-96 overflow-y-auto whitespace-pre-wrap break-words font-mono text-sm"            style={{ color: 'var(--color-text)' }}          >            {lastOutput.toolResult}          </pre>        </div>      </div>    );  }   // Render interruption as a simple horizontal banner  if (type === 'interruption') {    return (      <div        className="flex w-full items-center justify-center gap-2 rounded-lg px-4 py-2"        style={{          backgroundColor: 'var(--warning-bg, rgba(245, 158, 11, 0.1))',          border: '1px solid var(--warning-border, rgba(245, 158, 11, 0.3))',        }}      >        <AlertTriangle          className="size-4 shrink-0"          style={{ color: 'var(--warning-text, #f59e0b)' }}        />        <span className="text-sm" style={{ color: 'var(--warning-text, #f59e0b)' }}>          Request interrupted by user        </span>      </div>    );  }   // Render plan_exit (ExitPlanMode) with plan content in markdown  if (type === 'plan_exit' && lastOutput.planContent) {    const planContent = lastOutput.planContent || '';    const planPreamble = lastOutput.planPreamble;     return (      <div className="space-y-3">        {/* Preamble text (e.g., "The plan is complete. Let me exit plan mode...") */}        {planPreamble && (          <div            className="overflow-hidden rounded-lg"            style={{              backgroundColor: 'var(--code-bg)',              border: '1px solid var(--code-border)',            }}          >            <div className="px-4 py-3">              <ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>                {planPreamble}              </ReactMarkdown>            </div>          </div>        )}         {/* Plan content block */}        <div          className="overflow-hidden rounded-lg"          style={{            backgroundColor: 'var(--plan-exit-bg)',            border: '1px solid var(--plan-exit-border)',          }}        >          {/* Header */}          <div            className="flex items-center justify-between px-4 py-2"            style={{              borderBottom: '1px solid var(--plan-exit-border)',              backgroundColor: 'var(--plan-exit-header-bg)',            }}          >            <div className="flex items-center gap-2">              <FileCheck className="size-4" style={{ color: 'var(--plan-exit-text)' }} />              <span className="text-sm font-medium" style={{ color: 'var(--plan-exit-text)' }}>                Plan Ready for Approval              </span>            </div>            <CopyButton text={planContent} inline />          </div>           {/* Plan content - scrollable */}          <div className="max-h-96 overflow-y-auto px-4 py-3">            <ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>              {planContent}            </ReactMarkdown>          </div>        </div>      </div>    );  }   return null;};