Files
claudecodeui/src/components/chat/view/subcomponents/MessageComponent.tsx

473 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type {
ChatMessage,
ClaudePermissionSuggestion,
PermissionGrantResult,
Provider,
} from '../../types/types';
import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools';
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
import { Markdown } from './Markdown';
import MessageCopyControl from './MessageCopyControl';
type DiffLine = {
type: string;
content: string;
lineNum: number;
};
type MessageComponentProps = {
message: ChatMessage;
prevMessage: ChatMessage | null;
createDiff: (oldStr: string, newStr: string) => DiffLine[];
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject?: Project | null;
provider: Provider | string;
};
type InteractiveOption = {
number: string;
text: string;
isSelected: boolean;
};
type PermissionGrantState = 'idle' | 'granted' | 'error';
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const { t } = useTranslation('chat');
const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') ||
(prevMessage.type === 'user') ||
(prevMessage.type === 'tool') ||
(prevMessage.type === 'error'));
const messageRef = useRef<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
const userCopyContent = String(message.content || '');
const formattedMessageContent = useMemo(
() => formatUsageLimitText(String(message.content || '')),
[message.content]
);
const assistantCopyContent = message.isToolUse
? String(message.displayText || message.content || '')
: formattedMessageContent;
const isCommandOrFileEditToolResponse = Boolean(
message.isToolUse && COPY_HIDDEN_TOOL_NAMES.has(String(message.toolName || ''))
);
const shouldShowUserCopyControl = message.type === 'user' && userCopyContent.trim().length > 0;
const shouldShowAssistantCopyControl = message.type === 'assistant' &&
assistantCopyContent.trim().length > 0 &&
!isCommandOrFileEditToolResponse &&
!message.isThinking;
useEffect(() => {
setPermissionGrantState('idle');
}, [permissionSuggestion?.entry, message.toolId]);
useEffect(() => {
const node = messageRef.current;
if (!autoExpandTools || !node || !message.isToolUse) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isExpanded) {
setIsExpanded(true);
const details = node.querySelectorAll<HTMLDetailsElement>('details');
details.forEach((detail) => {
detail.open = true;
});
}
});
},
{ threshold: 0.1 }
);
observer.observe(node);
return () => {
observer.unobserve(node);
};
}, [autoExpandTools, isExpanded, message.isToolUse]);
const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]);
const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking);
if (shouldHideThinkingMessage) {
return null;
}
return (
<div
ref={messageRef}
data-message-timestamp={message.timestamp || undefined}
className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`}
>
{message.type === 'user' ? (
/* User message bubble on the right */
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
<div className="whitespace-pre-wrap break-words text-sm">
{message.content}
</div>
{message.images && message.images.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-2">
{message.images.map((img, idx) => (
<img
key={img.name || idx}
src={img.data}
alt={img.name}
className="h-auto max-w-full cursor-pointer rounded-lg transition-opacity hover:opacity-90"
onClick={() => window.open(img.data, '_blank')}
/>
))}
</div>
)}
<div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
{shouldShowUserCopyControl && (
<MessageCopyControl content={userCopyContent} messageType="user" />
)}
<span>{formattedTime}</span>
</div>
</div>
{!isGrouped && (
<div className="hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-sm text-white sm:flex">
U
</div>
)}
</div>
) : message.isTaskNotification ? (
/* Compact task notification on the left */
<div className="w-full">
<div className="flex items-center gap-2 py-0.5">
<span className={`inline-block h-1.5 w-1.5 flex-shrink-0 rounded-full ${message.taskStatus === 'completed' ? 'bg-green-400 dark:bg-green-500' : 'bg-amber-400 dark:bg-amber-500'}`} />
<span className="text-xs text-gray-500 dark:text-gray-400">{message.content}</span>
</div>
</div>
) : (
/* Claude/Error/Tool messages on the left */
<div className="w-full">
{!isGrouped && (
<div className="mb-2 flex items-center space-x-3">
{message.type === 'error' ? (
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-red-600 text-sm text-white">
!
</div>
) : message.type === 'tool' ? (
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-gray-600 text-sm text-white dark:bg-gray-700">
🔧
</div>
) : (
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-white">
<SessionProviderLogo provider={provider} className="h-full w-full" />
</div>
)}
<div className="text-sm font-medium text-gray-900 dark:text-white">
{message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : provider === 'gemini' ? t('messageTypes.gemini') : t('messageTypes.claude'))}
</div>
</div>
)}
<div className="w-full">
{message.isToolUse ? (
<>
<div className="flex flex-col">
<div className="flex flex-col">
<Markdown className="prose prose-sm max-w-none dark:prose-invert">
{String(message.displayText || '')}
</Markdown>
</div>
</div>
{message.toolInput && (
<ToolRenderer
toolName={message.toolName || 'UnknownTool'}
toolInput={message.toolInput}
toolResult={message.toolResult}
toolId={message.toolId}
mode="input"
onFileOpen={onFileOpen}
createDiff={createDiff}
selectedProject={selectedProject}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
isSubagentContainer={message.isSubagentContainer}
subagentState={message.subagentState}
/>
)}
{/* Tool Result Section */}
{message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
message.toolResult.isError ? (
// Error results - red error box with content
<div
id={`tool-result-${message.toolId}`}
className="relative mt-2 scroll-mt-4 rounded border border-red-200/60 bg-red-50/50 p-3 dark:border-red-800/40 dark:bg-red-950/10"
>
<div className="relative mb-2 flex items-center gap-1.5">
<svg className="h-4 w-4 text-red-500 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span>
</div>
<div className="relative text-sm text-red-900 dark:text-red-100">
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
{String(message.toolResult.content || '')}
</Markdown>
{permissionSuggestion && (
<div className="mt-4 border-t border-red-200/60 pt-3 dark:border-red-800/60">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => {
if (!onGrantToolPermission) return;
const result = onGrantToolPermission(permissionSuggestion);
if (result?.success) {
setPermissionGrantState('granted');
} else {
setPermissionGrantState('error');
}
}}
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? 'cursor-default border-green-300/70 bg-green-100 text-green-800 dark:border-green-800/60 dark:bg-green-900/30 dark:text-green-200'
: 'border-red-300/70 bg-white/80 text-red-700 hover:bg-white dark:border-red-800/60 dark:bg-gray-900/40 dark:text-red-200 dark:hover:bg-gray-900/70'
}`}
>
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? t('permissions.added')
: t('permissions.grant', { tool: permissionSuggestion.toolName })}
</button>
{onShowSettings && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
className="text-xs text-red-700 underline hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
>
{t('permissions.openSettings')}
</button>
)}
</div>
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
{t('permissions.addTo', { entry: permissionSuggestion.entry })}
</div>
{permissionGrantState === 'error' && (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
{t('permissions.error')}
</div>
)}
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
{t('permissions.retry')}
</div>
)}
</div>
)}
</div>
</div>
) : (
// Non-error results - route through ToolRenderer (single source of truth)
<div id={`tool-result-${message.toolId}`} className="scroll-mt-4">
<ToolRenderer
toolName={message.toolName || 'UnknownTool'}
toolInput={message.toolInput}
toolResult={message.toolResult}
toolId={message.toolId}
mode="result"
onFileOpen={onFileOpen}
createDiff={createDiff}
selectedProject={selectedProject}
autoExpandTools={autoExpandTools}
/>
</div>
)
)}
</>
) : message.isInteractivePrompt ? (
// Special handling for interactive prompts
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-amber-500">
<svg className="h-5 w-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="flex-1">
<h4 className="mb-3 text-base font-semibold text-amber-900 dark:text-amber-100">
{t('interactive.title')}
</h4>
{(() => {
const lines = (message.content || '').split('\n').filter((line) => line.trim());
const questionLine = lines.find((line) => line.includes('?')) || lines[0] || '';
const options: InteractiveOption[] = [];
// Parse the menu options
lines.forEach((line) => {
// Match lines like " 1. Yes" or " 2. No"
const optionMatch = line.match(/[\s]*(\d+)\.\s+(.+)/);
if (optionMatch) {
const isSelected = line.includes('');
options.push({
number: optionMatch[1],
text: optionMatch[2].trim(),
isSelected
});
}
});
return (
<>
<p className="mb-4 text-sm text-amber-800 dark:text-amber-200">
{questionLine}
</p>
{/* Option buttons */}
<div className="mb-4 space-y-2">
{options.map((option) => (
<button
key={option.number}
className={`w-full rounded-lg border-2 px-4 py-3 text-left transition-all ${option.isSelected
? 'border-amber-600 bg-amber-600 text-white shadow-md dark:border-amber-700 dark:bg-amber-700'
: 'border-amber-300 bg-white text-amber-900 dark:border-amber-700 dark:bg-gray-800 dark:text-amber-100'
} cursor-not-allowed opacity-75`}
disabled
>
<div className="flex items-center gap-3">
<span className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-sm font-bold ${option.isSelected
? 'bg-white/20'
: 'bg-amber-100 dark:bg-amber-800/50'
}`}>
{option.number}
</span>
<span className="flex-1 text-sm font-medium sm:text-base">
{option.text}
</span>
{option.isSelected && (
<span className="text-lg"></span>
)}
</div>
</button>
))}
</div>
<div className="rounded-lg bg-amber-100 p-3 dark:bg-amber-800/30">
<p className="mb-1 text-sm font-medium text-amber-900 dark:text-amber-100">
{t('interactive.waiting')}
</p>
<p className="text-xs text-amber-800 dark:text-amber-200">
{t('interactive.instruction')}
</p>
</div>
</>
);
})()}
</div>
</div>
</div>
) : message.isThinking ? (
/* Thinking messages — Reasoning component (ai-elements pattern) */
<Reasoning defaultOpen={false}>
<ReasoningTrigger />
<ReasoningContent>
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
{message.content}
</Markdown>
<div className="mt-3 flex items-center text-[11px]">
<MessageCopyControl content={String(message.content || '')} messageType="assistant" />
</div>
</ReasoningContent>
</Reasoning>
) : (
<div className="text-sm text-gray-700 dark:text-gray-300">
{/* Reasoning accordion */}
{showThinking && message.reasoning && (
<Reasoning className="mb-3" defaultOpen={false}>
<ReasoningTrigger />
<ReasoningContent>
<div className="whitespace-pre-wrap">
{message.reasoning}
</div>
</ReasoningContent>
</Reasoning>
)}
{(() => {
const content = formattedMessageContent;
// Detect if content is pure JSON (starts with { or [)
const trimmedContent = content.trim();
if ((trimmedContent.startsWith('{') || trimmedContent.startsWith('[')) &&
(trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) {
try {
const parsed = JSON.parse(trimmedContent);
const formatted = JSON.stringify(parsed, null, 2);
return (
<div className="my-2">
<div className="mb-2 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span className="font-medium">{t('json.response')}</span>
</div>
<div className="overflow-hidden rounded-lg border border-gray-600/30 bg-gray-800 dark:border-gray-700 dark:bg-gray-900">
<pre className="overflow-x-auto p-4">
<code className="block whitespace-pre font-mono text-sm text-gray-100 dark:text-gray-200">
{formatted}
</code>
</pre>
</div>
</div>
);
} catch {
// Not valid JSON, fall through to normal rendering
}
}
// Normal rendering for non-JSON content
return message.type === 'assistant' ? (
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
{content}
</Markdown>
) : (
<div className="whitespace-pre-wrap">
{content}
</div>
);
})()}
</div>
)}
{(shouldShowAssistantCopyControl || !isGrouped) && (
<div className="mt-1 flex w-full items-center gap-2 text-[11px] text-gray-400 dark:text-gray-500">
{shouldShowAssistantCopyControl && (
<MessageCopyControl content={assistantCopyContent} messageType="assistant" />
)}
{!isGrouped && <span>{formattedTime}</span>}
</div>
)}
</div>
</div>
)}
</div>
);
});
export default MessageComponent;