refactor: reorganize chat view components and types

This commit is contained in:
Haileyesus
2026-02-11 18:43:40 +03:00
parent 7f45540dbe
commit 189b1533b2
18 changed files with 38 additions and 38 deletions

View File

@@ -2,14 +2,14 @@ import React, { useCallback, useEffect, useRef } from 'react';
import QuickSettingsPanel from '../../QuickSettingsPanel';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useTranslation } from 'react-i18next';
import ChatMessagesPane from './ChatMessagesPane';
import ChatComposer from './ChatComposer';
import type { ChatInterfaceProps } from '../types';
import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer';
import type { ChatInterfaceProps } from '../types/types';
import { useChatProviderState } from '../hooks/useChatProviderState';
import { useChatSessionState } from '../hooks/useChatSessionState';
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
import { useChatComposerState } from '../hooks/useChatComposerState';
import type { Provider } from '../types';
import type { Provider } from '../types/types';
type PendingViewSession = {
sessionId: string | null;

View File

@@ -1,7 +1,7 @@
import CommandMenu from '../../CommandMenu';
import ClaudeStatus from '../../ClaudeStatus';
import { MicButton } from '../../MicButton.jsx';
import ImageAttachment from '../input/ImageAttachment';
import CommandMenu from '../../../CommandMenu';
import ClaudeStatus from '../../../ClaudeStatus';
import { MicButton } from '../../../MicButton.jsx';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import ChatInputControls from './ChatInputControls';
import { useTranslation } from 'react-i18next';
@@ -17,7 +17,7 @@ import type {
SetStateAction,
TouchEvent,
} from 'react';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../types';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
interface MentionableFile {
name: string;

View File

@@ -1,10 +1,10 @@
import { useTranslation } from 'react-i18next';
import type { Dispatch, RefObject, SetStateAction } from 'react';
import SessionProviderLogo from '../../SessionProviderLogo';
import MessageComponent from '../messages/MessageComponent';
import SessionProviderLogo from '../../../SessionProviderLogo';
import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
import type { ChatMessage, Provider } from '../types';
import type { Project, ProjectSession } from '../../../types/app';
import type { ChatMessage, Provider } from '../../types/types';
import type { Project, ProjectSession } from '../../../../types/app';
interface ChatMessagesPaneProps {
scrollContainerRef: RefObject<HTMLDivElement>;

View File

@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react';
interface ImageAttachmentProps {
file: File;
onRemove: () => void;
uploadProgress?: number;
error?: string;
}
const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachmentProps) => {
const [preview, setPreview] = useState<string | undefined>(undefined);
useEffect(() => {
const url = URL.createObjectURL(file);
setPreview(url);
return () => URL.revokeObjectURL(url);
}, [file]);
return (
<div className="relative group">
<img src={preview} alt={file.name} className="w-20 h-20 object-cover rounded" />
{uploadProgress !== undefined && uploadProgress < 100 && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<div className="text-white text-xs">{uploadProgress}%</div>
</div>
)}
{error && (
<div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
)}
<button
onClick={onRemove}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
};
export default ImageAttachment;

View File

@@ -0,0 +1,188 @@
import React, { useMemo, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTranslation } from 'react-i18next';
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
type MarkdownProps = {
children: React.ReactNode;
className?: string;
};
type CodeBlockProps = {
node?: any;
inline?: boolean;
className?: string;
children?: React.ReactNode;
};
const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {
const { t } = useTranslation('chat');
const [copied, setCopied] = useState(false);
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(raw);
const inlineDetected = inline || (node && node.type === 'inlineCode');
const shouldInline = inlineDetected || !looksMultiline;
if (shouldInline) {
return (
<code
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${
className || ''
}`}
{...props}
>
{children}
</code>
);
}
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : 'text';
const textToCopy = raw;
const handleCopy = () => {
const doSet = () => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
try {
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(textToCopy).then(doSet).catch(() => {
const ta = document.createElement('textarea');
ta.value = textToCopy;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
} catch {}
document.body.removeChild(ta);
doSet();
});
} else {
const ta = document.createElement('textarea');
ta.value = textToCopy;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
} catch {}
document.body.removeChild(ta);
doSet();
}
} catch {}
};
return (
<div className="relative group my-2">
{language && language !== 'text' && (
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">{language}</div>
)}
<button
type="button"
onClick={handleCopy}
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 focus:opacity-100 active:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
>
{copied ? (
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
{t('codeBlock.copied')}
</span>
) : (
<span className="flex items-center gap-1">
<svg
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg>
{t('codeBlock.copy')}
</span>
)}
</button>
<SyntaxHighlighter
language={language}
style={oneDark}
customStyle={{
margin: 0,
borderRadius: '0.5rem',
fontSize: '0.875rem',
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
}}
codeTagProps={{
style: {
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
},
}}
>
{raw}
</SyntaxHighlighter>
</div>
);
};
const markdownComponents = {
code: CodeBlock,
blockquote: ({ children }: { children?: React.ReactNode }) => (
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2">
{children}
</blockquote>
),
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">
{children}
</a>
),
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
table: ({ children }: { children?: React.ReactNode }) => (
<div className="overflow-x-auto my-2">
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table>
</div>
),
thead: ({ children }: { children?: React.ReactNode }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>,
th: ({ children }: { children?: React.ReactNode }) => (
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th>
),
td: ({ children }: { children?: React.ReactNode }) => (
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td>
),
};
export function Markdown({ children, className }: MarkdownProps) {
const content = normalizeInlineCodeFences(String(children ?? ''));
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
const rehypePlugins = useMemo(() => [rehypeKatex], []);
return (
<div className={className}>
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents as any}>
{content}
</ReactMarkdown>
</div>
);
}

View File

@@ -0,0 +1,430 @@
// @ts-nocheck
import React, { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../SessionProviderLogo';
import type { ChatMessage, Provider } from '../../types/types';
import { Markdown } from './Markdown';
import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools';
type DiffLine = {
type: string;
content: string;
lineNum: number;
};
interface MessageComponentProps {
message: ChatMessage;
index: number;
prevMessage: ChatMessage | null;
createDiff: (oldStr: string, newStr: string) => DiffLine[];
onFileOpen?: (filePath: string, diffInfo?: any) => void;
onShowSettings?: () => void;
onGrantToolPermission?: (suggestion: any) => any;
autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject?: Project | null;
provider: Provider | string;
}
const MessageComponent = memo(({ message, index, 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 = React.useRef(null);
const [isExpanded, setIsExpanded] = React.useState(false);
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
const [permissionGrantState, setPermissionGrantState] = React.useState('idle');
React.useEffect(() => {
setPermissionGrantState('idle');
}, [permissionSuggestion?.entry, message.toolId]);
React.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('details');
details.forEach(detail => {
detail.open = true;
});
}
});
},
{ threshold: 0.1 }
);
observer.observe(node);
return () => {
observer.unobserve(node);
};
}, [autoExpandTools, isExpanded, message.isToolUse]);
const selectedProvider = useMemo(() => localStorage.getItem('selected-provider') || 'claude', []);
const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]);
return (
<div
ref={messageRef}
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 items-end space-x-0 sm:space-x-3 w-full sm:w-auto sm:max-w-[85%] md:max-w-md lg:max-w-lg xl:max-w-xl">
<div className="bg-blue-600 text-white rounded-2xl rounded-br-md px-3 sm:px-4 py-2 shadow-sm flex-1 sm:flex-initial">
<div className="text-sm whitespace-pre-wrap break-words">
{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="rounded-lg max-w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => window.open(img.data, '_blank')}
/>
))}
</div>
)}
<div className="text-xs text-blue-100 mt-1 text-right">
{formattedTime}
</div>
</div>
{!isGrouped && (
<div className="hidden sm:flex w-8 h-8 bg-blue-600 rounded-full items-center justify-center text-white text-sm flex-shrink-0">
U
</div>
)}
</div>
) : (
/* Claude/Error/Tool messages on the left */
<div className="w-full">
{!isGrouped && (
<div className="flex items-center space-x-3 mb-2">
{message.type === 'error' ? (
<div className="w-8 h-8 bg-red-600 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0">
!
</div>
) : message.type === 'tool' ? (
<div className="w-8 h-8 bg-gray-600 dark:bg-gray-700 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0">
🔧
</div>
) : (
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1">
<SessionProviderLogo provider={selectedProvider} className="w-full h-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') : (selectedProvider === 'cursor' ? t('messageTypes.cursor') : selectedProvider === 'codex' ? t('messageTypes.codex') : 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">{message.displayText}</Markdown>
</div>
</div>
{message.toolInput && (
<ToolRenderer
toolName={message.toolName}
toolInput={message.toolInput}
toolResult={message.toolResult}
toolId={message.toolId}
mode="input"
onFileOpen={onFileOpen}
createDiff={createDiff}
selectedProject={selectedProject}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
onShowSettings={onShowSettings}
rawToolInput={message.toolInput}
/>
)}
{/* Tool Result Section */}
{message.toolResult && !shouldHideToolResult(message.toolName, message.toolResult) && (
message.toolResult.isError ? (
// Error results - red error box with content
<div
id={`tool-result-${message.toolId}`}
className="relative mt-2 p-3 rounded border scroll-mt-4 bg-red-50/50 dark:bg-red-950/10 border-red-200/60 dark:border-red-800/40"
>
<div className="relative flex items-center gap-1.5 mb-2">
<svg className="w-4 h-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">Error</span>
</div>
<div className="relative text-sm text-red-900 dark:text-red-100">
<Markdown className="prose prose-sm max-w-none prose-red dark:prose-invert">
{String(message.toolResult.content || '')}
</Markdown>
{permissionSuggestion && (
<div className="mt-4 border-t border-red-200/60 dark:border-red-800/60 pt-3">
<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 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${
permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? 'bg-green-100 dark:bg-green-900/30 border-green-300/70 dark:border-green-800/60 text-green-800 dark:text-green-200 cursor-default'
: 'bg-white/80 dark:bg-gray-900/40 border-red-300/70 dark:border-red-800/60 text-red-700 dark:text-red-200 hover:bg-white dark:hover:bg-gray-900/70'
}`}
>
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? 'Permission added'
: `Grant permission for ${permissionSuggestion.toolName}`}
</button>
{onShowSettings && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
className="text-xs text-red-700 dark:text-red-200 underline hover:text-red-800 dark:hover:text-red-100"
>
Open settings
</button>
)}
</div>
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
Adds <span className="font-mono">{permissionSuggestion.entry}</span> to Allowed Tools.
</div>
{permissionGrantState === 'error' && (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
Unable to update permissions. Please try again.
</div>
)}
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
Permission saved. Retry the request to use the tool.
</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}
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="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-5 h-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="font-semibold text-amber-900 dark:text-amber-100 text-base mb-3">
Interactive Prompt
</h4>
{(() => {
const lines = message.content.split('\n').filter(line => line.trim());
const questionLine = lines.find(line => line.includes('?')) || lines[0] || '';
const options = [];
// 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="text-sm text-amber-800 dark:text-amber-200 mb-4">
{questionLine}
</p>
{/* Option buttons */}
<div className="space-y-2 mb-4">
{options.map((option) => (
<button
key={option.number}
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${
option.isSelected
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md'
: 'bg-white dark:bg-gray-800 text-amber-900 dark:text-amber-100 border-amber-300 dark:border-amber-700'
} cursor-not-allowed opacity-75`}
disabled
>
<div className="flex items-center gap-3">
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
option.isSelected
? 'bg-white/20'
: 'bg-amber-100 dark:bg-amber-800/50'
}`}>
{option.number}
</span>
<span className="text-sm sm:text-base font-medium flex-1">
{option.text}
</span>
{option.isSelected && (
<span className="text-lg"></span>
)}
</div>
</button>
))}
</div>
<div className="bg-amber-100 dark:bg-amber-800/30 rounded-lg p-3">
<p className="text-amber-900 dark:text-amber-100 text-sm font-medium mb-1">
Waiting for your response in the CLI
</p>
<p className="text-amber-800 dark:text-amber-200 text-xs">
Please select an option in your terminal where Claude is running.
</p>
</div>
</>
);
})()}
</div>
</div>
</div>
) : message.isThinking ? (
/* Thinking messages - collapsible by default */
<div className="text-sm text-gray-700 dark:text-gray-300">
<details className="group">
<summary className="cursor-pointer text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 font-medium flex items-center gap-2">
<svg className="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span>💭 Thinking...</span>
</summary>
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 text-sm">
<Markdown className="prose prose-sm max-w-none dark:prose-invert prose-gray">
{message.content}
</Markdown>
</div>
</details>
</div>
) : (
<div className="text-sm text-gray-700 dark:text-gray-300">
{/* Thinking accordion for reasoning */}
{showThinking && message.reasoning && (
<details className="mb-3">
<summary className="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 font-medium">
💭 Thinking...
</summary>
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 italic text-gray-600 dark:text-gray-400 text-sm">
<div className="whitespace-pre-wrap">
{message.reasoning}
</div>
</div>
</details>
)}
{(() => {
const content = formatUsageLimitText(String(message.content || ''));
// 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="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
<svg className="w-4 h-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">JSON Response</span>
</div>
<div className="bg-gray-800 dark:bg-gray-900 border border-gray-600/30 dark:border-gray-700 rounded-lg overflow-hidden">
<pre className="p-4 overflow-x-auto">
<code className="text-gray-100 dark:text-gray-200 text-sm font-mono block whitespace-pre">
{formatted}
</code>
</pre>
</div>
</div>
);
} catch (e) {
// Not valid JSON, fall through to normal rendering
}
}
// Normal rendering for non-JSON content
return message.type === 'assistant' ? (
<Markdown className="prose prose-sm max-w-none dark:prose-invert prose-gray">
{content}
</Markdown>
) : (
<div className="whitespace-pre-wrap">
{content}
</div>
);
})()}
</div>
)}
{!isGrouped && (
<div className="text-[11px] text-gray-400 dark:text-gray-500 mt-1">
{formattedTime}
</div>
)}
</div>
</div>
)}
</div>
);
});
export default MessageComponent;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import type { PendingPermissionRequest } from '../types';
import { buildClaudeToolPermissionEntry, formatToolInputForDisplay } from '../utils/chatPermissions';
import { getClaudeSettings } from '../utils/chatStorage';
import type { PendingPermissionRequest } from '../../types/types';
import { buildClaudeToolPermissionEntry, formatToolInputForDisplay } from '../../utils/chatPermissions';
import { getClaudeSettings } from '../../utils/chatStorage';
interface PermissionRequestsBannerProps {
pendingPermissionRequests: PendingPermissionRequest[];

View File

@@ -1,10 +1,10 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../SessionProviderLogo';
import NextTaskBanner from '../../NextTaskBanner.jsx';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../../../shared/modelConstants';
import type { Provider } from '../types';
import type { ProjectSession } from '../../../types/app';
import SessionProviderLogo from '../../../SessionProviderLogo';
import NextTaskBanner from '../../../NextTaskBanner.jsx';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../../../../shared/modelConstants';
import type { Provider } from '../../types/types';
import type { ProjectSession } from '../../../../types/app';
interface ProviderSelectionEmptyStateProps {
selectedSession: ProjectSession | null;