261 lines
8.5 KiB
TypeScript
261 lines
8.5 KiB
TypeScript
import React from 'react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
|
|
import { useAppTranslation } from '@features/localization/renderer';
|
|
import { useStore } from '@renderer/store';
|
|
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
|
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, markdownComponentsWithCodeCopy } from './markdownComponents';
|
|
import { createSearchContext, EMPTY_SEARCH_MATCHES } 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 { t } = useAppTranslation('common');
|
|
// Only re-render if THIS AI group has search matches
|
|
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
|
|
useShallow((s) => {
|
|
const hasMatch = s.searchMatchItemIds.has(aiGroupId);
|
|
return {
|
|
searchQuery: hasMatch ? s.searchQuery : '',
|
|
searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES,
|
|
currentSearchIndex: hasMatch ? s.currentSearchIndex : -1,
|
|
};
|
|
})
|
|
);
|
|
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, { copyCodeBlocks: true })
|
|
: markdownComponentsWithCodeCopy;
|
|
|
|
// 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]}
|
|
rehypePlugins={REHYPE_PLUGINS}
|
|
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)' }}
|
|
>
|
|
{t('states.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)' }}>
|
|
{t('chat.lastOutput.requestInterrupted')}
|
|
</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)' }}>
|
|
{t('chat.lastOutput.planReadyForApproval')}
|
|
</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]}
|
|
rehypePlugins={REHYPE_PLUGINS}
|
|
components={mdComponents}
|
|
>
|
|
{planContent}
|
|
</ReactMarkdown>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|