-
-
-
-
+
+
+
{t('typing.thinking')}
+
+
+
+
+
-
+
);
}
diff --git a/src/pages/Chat/message-parts/AssistMessagePart.tsx b/src/pages/Chat/message-parts/AssistMessagePart.tsx
new file mode 100644
index 0000000..2170230
--- /dev/null
+++ b/src/pages/Chat/message-parts/AssistMessagePart.tsx
@@ -0,0 +1,361 @@
+/**
+ * Assistant Message Part
+ * Renders assistant text with Markdown, streaming cursor, word-by-word fade-in,
+ * and hover action buttons (copy / regenerate).
+ */
+import {
+ useState,
+ useCallback,
+ memo,
+ useMemo,
+ type PropsWithChildren,
+ type ReactNode,
+} from 'react';
+import { Copy, Check, RefreshCw, Sparkles, AlertTriangle, File } from 'lucide-react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import { useTranslation } from 'react-i18next';
+import { Button } from '@/components/ui/button';
+import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
+import { cn } from '@/lib/utils';
+import type { RawMessage, AttachedFileMeta } from '@/stores/chat';
+import { extractText, extractImages, formatTimestamp } from '../message-utils';
+import { WordByWordFadeIn } from './WordByWordFadeIn';
+
+// ── File helpers ─────────────────────────────────────────────────
+
+function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
+}
+
+function FileCard({ file }: { file: AttachedFileMeta }) {
+ return (
+
+
+
+
{file.fileName}
+
+ {file.fileSize > 0 ? formatFileSize(file.fileSize) : 'File'}
+
+
+
+ );
+}
+
+// ── Streaming Markdown components ────────────────────────────────
+
+/**
+ * Build markdown renderer components that optionally wrap text children
+ * in WordByWordFadeIn when streaming.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function makeStreamingComponents(isStreaming: boolean): any {
+ // When not streaming, use default rendering (no fade-in wrappers)
+ if (!isStreaming) {
+ return {
+ code({
+ className,
+ children,
+ ...props
+ }: {
+ className?: string;
+ children?: ReactNode;
+ [key: string]: unknown;
+ }) {
+ const match = /language-(\w+)/.exec(className || '');
+ const isInline = !match && !className;
+ if (isInline) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+
+ {children}
+
+
+ );
+ },
+ a({ href, children }: { href?: string; children?: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+ },
+ };
+ }
+
+ // Streaming mode: wrap text in WordByWordFadeIn
+ const Wrap = ({ children }: PropsWithChildren) =>
{children};
+
+ return {
+ p({ children }: { children?: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+ },
+ li({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) {
+ return (
+
+ {children}
+
+ );
+ },
+ strong({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) {
+ return (
+
+ {children}
+
+ );
+ },
+ h1({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) {
+ return (
+
+ {children}
+
+ );
+ },
+ h2({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) {
+ return (
+
+ {children}
+
+ );
+ },
+ h3({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) {
+ return (
+
+ {children}
+
+ );
+ },
+ code({
+ className,
+ children,
+ ...props
+ }: {
+ className?: string;
+ children?: ReactNode;
+ [key: string]: unknown;
+ }) {
+ const match = /language-(\w+)/.exec(className || '');
+ const isInline = !match && !className;
+ if (isInline) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+
+ {children}
+
+
+ );
+ },
+ a({ href, children }: { href?: string; children?: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+ },
+ };
+}
+
+// ── Props ────────────────────────────────────────────────────────
+
+export interface AssistMessagePartProps {
+ message: RawMessage;
+ isStreaming?: boolean;
+ streamingTools?: Array<{
+ id?: string;
+ toolCallId?: string;
+ name: string;
+ status: 'running' | 'completed' | 'error';
+ durationMs?: number;
+ summary?: string;
+ }>;
+ onRegenerate?: () => void;
+}
+
+// ── Component ────────────────────────────────────────────────────
+
+export const AssistMessagePart = memo(
+ function AssistMessagePart({
+ message,
+ isStreaming = false,
+ onRegenerate,
+ }: AssistMessagePartProps) {
+ const { t } = useTranslation('chat');
+ const text = extractText(message);
+ const hasText = text.trim().length > 0;
+ const images = extractImages(message);
+ const attachedFiles = message._attachedFiles || [];
+ const isErrorResponse = message.stopReason === 'error' || !!message.errorMessage;
+
+ const [copied, setCopied] = useState(false);
+
+ const copyContent = useCallback(() => {
+ navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }, [text]);
+
+
+ const mdComponents = useMemo(() => makeStreamingComponents(isStreaming), [isStreaming]);
+
+ return (
+
+ {/* Avatar */}
+
+
+
+
+ {/* Content */}
+
+ {/* Error response from LLM provider */}
+ {isErrorResponse && !hasText && (
+
+
+
+
+ {message.errorMessage || 'An error occurred while generating the response.'}
+
+
+
+ )}
+
+ {/* Main text bubble with Markdown */}
+ {hasText && (
+
+
+
+ {text}
+
+ {isStreaming && (
+
+ )}
+
+
+ {/* Footer: timestamp */}
+ {!isStreaming && message.timestamp && (
+
+
+ {formatTimestamp(message.timestamp)}
+
+
+ )}
+
+ )}
+
+ {/* Images — assistant (below text) */}
+ {images.length > 0 && (
+
+ {images.map((img, i) => (
+

+ ))}
+
+ )}
+
+ {/* File attachments — assistant (below text) */}
+ {attachedFiles.length > 0 && (
+
+ {attachedFiles.map((file, i) => {
+ const isImage = file.mimeType.startsWith('image/');
+ if (isImage && images.length > 0) return null;
+ if (isImage && file.preview) {
+ return (
+

+ );
+ }
+ if (isImage && !file.preview) {
+ return (
+
+
+
+ );
+ }
+ return
;
+ })}
+
+ )}
+
+ {/* Action buttons — visible on hover */}
+ {hasText && !isStreaming && (
+
+
+
+
+
+ {t('message.copy')}
+
+ {onRegenerate && (
+
+
+
+
+ {t('message.regenerate')}
+
+ )}
+
+ )}
+
+
+ );
+ },
+ (prev, next) => {
+ if (prev.message.id !== next.message.id) return false;
+ if (prev.message.content !== next.message.content) return false;
+ if (prev.message.timestamp !== next.message.timestamp) return false;
+ if (prev.message.stopReason !== next.message.stopReason) return false;
+ if (prev.message.errorMessage !== next.message.errorMessage) return false;
+ if (prev.message._attachedFiles !== next.message._attachedFiles) return false;
+ if (prev.isStreaming !== next.isStreaming) return false;
+ if (prev.onRegenerate !== next.onRegenerate) return false;
+ return true;
+ }
+);
+AssistMessagePart.displayName = 'AssistMessagePart';
diff --git a/src/pages/Chat/message-parts/ReasoningPart.tsx b/src/pages/Chat/message-parts/ReasoningPart.tsx
new file mode 100644
index 0000000..51f8f9e
--- /dev/null
+++ b/src/pages/Chat/message-parts/ReasoningPart.tsx
@@ -0,0 +1,92 @@
+/**
+ * Reasoning / Thinking Part
+ * Collapsible block showing the model's chain-of-thought.
+ * Uses framer-motion for smooth expand/collapse animation.
+ */
+import { memo, useState, useEffect } from 'react';
+import { ChevronDown, ChevronRight } from 'lucide-react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import { AnimatePresence, motion } from 'framer-motion';
+import { useTranslation } from 'react-i18next';
+
+const variants = {
+ collapsed: {
+ height: 0,
+ opacity: 0,
+ marginTop: 0,
+ marginBottom: 0,
+ },
+ expanded: {
+ height: 'auto',
+ opacity: 1,
+ marginTop: '0.5rem',
+ marginBottom: '0.25rem',
+ },
+};
+
+export interface ReasoningPartProps {
+ content: string;
+ isThinking?: boolean;
+}
+
+export const ReasoningPart = memo(
+ function ReasoningPart({ content, isThinking = false }: ReasoningPartProps) {
+ const { t } = useTranslation('chat');
+ const [expanded, setExpanded] = useState(isThinking);
+
+ // Auto-collapse when thinking finishes
+ useEffect(() => {
+ if (!isThinking && expanded) {
+ setExpanded(false);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isThinking]);
+
+ return (
+
+
+
+ {expanded && (
+
+
+ {content}
+
+
+ )}
+
+
+ );
+ },
+ (prev, next) => {
+ if (prev.content !== next.content) return false;
+ if (prev.isThinking !== next.isThinking) return false;
+ return true;
+ }
+);
+ReasoningPart.displayName = 'ReasoningPart';
diff --git a/src/pages/Chat/message-parts/ToolMessagePart.tsx b/src/pages/Chat/message-parts/ToolMessagePart.tsx
new file mode 100644
index 0000000..46ef780
--- /dev/null
+++ b/src/pages/Chat/message-parts/ToolMessagePart.tsx
@@ -0,0 +1,172 @@
+/**
+ * Tool Message Part
+ * Renders tool status bars (running/completed/error) and tool-use cards
+ * with expandable request/response details.
+ *
+ * When expanded, delegates to a specialized renderer (WebSearch, CodeExecutor,
+ * Browser, or Default) based on the tool name — providing rich visualisation
+ * instead of raw JSON.
+ */
+import { useState, memo, type ReactNode } from 'react';
+import {
+ ChevronDown,
+ ChevronRight,
+ Search,
+ Globe,
+ Monitor,
+ Wrench,
+ Loader2,
+ CheckCircle2,
+ XCircle,
+ Code2,
+} from 'lucide-react';
+import { cn } from '@/lib/utils';
+import {
+ WebSearchRenderer,
+ CodeExecutorRenderer,
+ BrowserRenderer,
+ DefaultRenderer,
+} from '../tool-renderers';
+
+// ── Shared tool helpers ──────────────────────────────────────────
+
+function formatDuration(durationMs?: number): string | null {
+ if (!durationMs || !Number.isFinite(durationMs)) return null;
+ if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
+ return `${(durationMs / 1000).toFixed(1)}s`;
+}
+
+function renderToolIcon(name: string, className: string): ReactNode {
+ const n = name.toLowerCase();
+ if (n.includes('search')) return
;
+ if (n.includes('fetch')) return
;
+ if (n.includes('browser') || n.includes('navigate') || n.includes('playwright'))
+ return
;
+ if (n.includes('code') || n.includes('execute') || n.includes('interpreter'))
+ return
;
+ return
;
+}
+
+function getToolLabel(name: string): string {
+ const n = name.toLowerCase();
+ if (n === 'web_search') return 'Web Search';
+ if (n === 'web_fetch') return 'Web Fetch';
+ if (n === 'browser') return 'Browser';
+ return name;
+}
+
+// ── Tool Status Bar ──────────────────────────────────────────────
+
+export interface ToolStatusItem {
+ id?: string;
+ toolCallId?: string;
+ name: string;
+ status: 'running' | 'completed' | 'error';
+ durationMs?: number;
+ summary?: string;
+}
+
+export const ToolStatusBar = memo(function ToolStatusBar({ tools }: { tools: ToolStatusItem[] }) {
+ return (
+
+
+ {tools.map((tool) => {
+ const duration = formatDuration(tool.durationMs);
+ const label = getToolLabel(tool.name);
+ return (
+
+ {/* Status indicator */}
+ {tool.status === 'running' ? (
+
+ ) : tool.status === 'error' ? (
+
+ ) : (
+
+ )}
+
+ {/* Tool icon + name */}
+
+ {renderToolIcon(tool.name, 'h-3 w-3')}
+ {label}
+
+
+ {/* Duration */}
+ {duration && {duration}}
+
+ {/* Summary */}
+ {tool.summary && (
+ {tool.summary}
+ )}
+
+ );
+ })}
+
+
+ );
+});
+ToolStatusBar.displayName = 'ToolStatusBar';
+
+// ── Tool Card ────────────────────────────────────────────────────
+
+export interface ToolCardProps {
+ name: string;
+ input: unknown;
+ /** Tool execution output/result — displayed via specialised renderer */
+ output?: unknown;
+}
+
+export const ToolCard = memo(function ToolCard({ name, input, output }: ToolCardProps) {
+ const [expanded, setExpanded] = useState(false);
+ const label = getToolLabel(name);
+
+ // Select renderer inline to avoid "component created during render" lint error
+ const n = name.toLowerCase();
+ const isSearch = n.includes('search');
+ const isCode =
+ n.includes('code') ||
+ n.includes('execute') ||
+ n.includes('run_code') ||
+ n.includes('interpreter');
+ const isBrowser = n.includes('browser') || n.includes('navigate') || n.includes('playwright');
+
+ return (
+
+
+ {expanded && (
+
+ {isSearch ? (
+
+ ) : isCode ? (
+
+ ) : isBrowser ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ );
+});
+ToolCard.displayName = 'ToolCard';
diff --git a/src/pages/Chat/message-parts/UserMessagePart.tsx b/src/pages/Chat/message-parts/UserMessagePart.tsx
new file mode 100644
index 0000000..0879cbc
--- /dev/null
+++ b/src/pages/Chat/message-parts/UserMessagePart.tsx
@@ -0,0 +1,281 @@
+/**
+ * User Message Part
+ * Renders user text bubble with long-text truncation, inline edit mode,
+ * and hover action buttons (copy / edit / delete).
+ */
+import { useState, useCallback, useRef, useEffect, memo } from 'react';
+import { Copy, Check, Pencil, Trash2, ChevronDown, ChevronUp, User, File } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Button } from '@/components/ui/button';
+import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
+import type { RawMessage, AttachedFileMeta } from '@/stores/chat';
+import { extractText, extractImages, formatTimestamp } from '../message-utils';
+
+const MAX_TEXT_LENGTH = 1000;
+
+// ── File helpers (moved from ChatMessage) ───────────────────────
+
+function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
+}
+
+function FileIcon({ className }: { mimeType: string; className?: string }) {
+ return
;
+}
+
+function FileCard({ file }: { file: AttachedFileMeta }) {
+ return (
+
+
+
+
{file.fileName}
+
+ {file.fileSize > 0 ? formatFileSize(file.fileSize) : 'File'}
+
+
+
+ );
+}
+
+// ── Props ────────────────────────────────────────────────────────
+
+export interface UserMessagePartProps {
+ message: RawMessage;
+ onEdit?: (messageId: string, newText: string) => void;
+ onDelete?: (messageId: string) => void;
+}
+
+// ── Component ────────────────────────────────────────────────────
+
+export const UserMessagePart = memo(
+ function UserMessagePart({ message, onEdit, onDelete }: UserMessagePartProps) {
+ const { t } = useTranslation('chat');
+ const text = extractText(message);
+ const images = extractImages(message);
+ const attachedFiles = message._attachedFiles || [];
+
+ const [copied, setCopied] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+ const [mode, setMode] = useState<'view' | 'edit'>('view');
+ const [editText, setEditText] = useState(text);
+ const textareaRef = useRef
(null);
+
+ const isLongText = text.length > MAX_TEXT_LENGTH;
+ const displayText = expanded || !isLongText ? text : text.slice(0, MAX_TEXT_LENGTH) + '...';
+
+ const copyContent = useCallback(() => {
+ navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }, [text]);
+
+ const handleEdit = () => {
+ setEditText(text);
+ setMode('edit');
+ };
+
+ const handleSaveEdit = () => {
+ if (onEdit && message.id && editText.trim()) {
+ onEdit(message.id, editText.trim());
+ }
+ setMode('view');
+ };
+
+ const handleCancelEdit = () => {
+ setMode('view');
+ setEditText(text);
+ };
+
+ const handleDelete = useCallback(() => {
+ if (onDelete && message.id) {
+ onDelete(message.id);
+ }
+ }, [onDelete, message.id]);
+
+ // Auto-focus textarea in edit mode
+ useEffect(() => {
+ if (mode === 'edit' && textareaRef.current) {
+ textareaRef.current.focus();
+ textareaRef.current.setSelectionRange(editText.length, editText.length);
+ }
+ }, [mode, editText.length]);
+
+ // Auto-resize textarea
+ useEffect(() => {
+ if (mode === 'edit' && textareaRef.current) {
+ const ta = textareaRef.current;
+ ta.style.height = 'auto';
+ ta.style.height = `${ta.scrollHeight}px`;
+ }
+ }, [mode, editText]);
+
+ return (
+
+ {/* Avatar */}
+
+
+
+
+ {/* Content */}
+
+ {/* Images from content blocks */}
+ {images.length > 0 && (
+
+ {images.map((img, i) => (
+
+

+
+ ))}
+
+ )}
+
+ {/* File attachments */}
+ {attachedFiles.length > 0 && (
+
+ {attachedFiles.map((file, i) => {
+ const isImage = file.mimeType.startsWith('image/');
+ if (isImage && images.length > 0) return null;
+ if (isImage) {
+ return (
+
+ {file.preview ? (
+

+ ) : (
+
+
+
+ )}
+
+ );
+ }
+ return
;
+ })}
+
+ )}
+
+ {/* Text bubble or edit mode */}
+ {mode === 'edit' ? (
+
+ ) : (
+ text.trim().length > 0 && (
+
+ {/* Gradient fade for long truncated text */}
+ {isLongText && !expanded && (
+
+ )}
+
{displayText}
+ {isLongText && (
+
+ )}
+
+ )
+ )}
+
+ {/* Action buttons — visible on hover */}
+ {mode === 'view' && text.trim().length > 0 && (
+
+
+
+
+
+ {t('message.copy')}
+
+
+
+
+
+ {t('message.edit')}
+
+ {onDelete && message.id && (
+
+
+
+
+
+ {t('message.delete')}
+
+
+ )}
+
+ )}
+
+ {/* Timestamp on hover */}
+ {message.timestamp && (
+
+ {formatTimestamp(message.timestamp)}
+
+ )}
+
+
+ );
+ },
+ (prev, next) => {
+ if (prev.message.id !== next.message.id) return false;
+ if (prev.message.content !== next.message.content) return false;
+ if (prev.message.timestamp !== next.message.timestamp) return false;
+ if (prev.message._attachedFiles !== next.message._attachedFiles) return false;
+ if (prev.onEdit !== next.onEdit) return false;
+ if (prev.onDelete !== next.onDelete) return false;
+ return true;
+ }
+);
+UserMessagePart.displayName = 'UserMessagePart';
diff --git a/src/pages/Chat/message-parts/WordByWordFadeIn.tsx b/src/pages/Chat/message-parts/WordByWordFadeIn.tsx
new file mode 100644
index 0000000..d31a114
--- /dev/null
+++ b/src/pages/Chat/message-parts/WordByWordFadeIn.tsx
@@ -0,0 +1,43 @@
+/**
+ * Word-by-word fade-in animation for streaming markdown content.
+ * Inspired by better-chatbot's markdown.tsx FadeIn / WordByWordFadeIn.
+ * Uses CSS animations (Tailwind animate-in + fade-in) to avoid heavy framer-motion overhead per word.
+ */
+import { memo, type PropsWithChildren, type ReactNode } from 'react';
+
+const FadeIn = memo(function FadeIn({ children }: PropsWithChildren) {
+ return (
+
+ {children}{' '}
+
+ );
+});
+
+/**
+ * Splits children into words and wraps each in a fade-in span.
+ * Non-string children (e.g. JSX elements) are passed through as-is.
+ * Should ONLY be used when streaming — static content should render plain.
+ */
+export const WordByWordFadeIn = memo(function WordByWordFadeIn({ children }: PropsWithChildren) {
+ const flat: ReactNode[] = Array.isArray(children) ? children : [children];
+ const result: ReactNode[] = [];
+
+ for (let i = 0; i < flat.length; i++) {
+ const child = flat[i];
+ if (typeof child === 'string') {
+ const words = child.split(' ');
+ for (let j = 0; j < words.length; j++) {
+ if (words[j]) {
+ result.push({words[j]});
+ }
+ }
+ } else {
+ result.push(child);
+ }
+ }
+
+ return <>{result}>;
+});
diff --git a/src/pages/Chat/message-parts/index.ts b/src/pages/Chat/message-parts/index.ts
new file mode 100644
index 0000000..8496f81
--- /dev/null
+++ b/src/pages/Chat/message-parts/index.ts
@@ -0,0 +1,27 @@
+/**
+ * Message Parts — Modular message component exports
+ *
+ * This directory contains the refactored message part components,
+ * split from the monolithic ChatMessage.tsx.
+ *
+ * Structure:
+ * - WordByWordFadeIn.tsx: Streaming word-level fade-in animation
+ * - UserMessagePart.tsx: User message with edit/copy/delete actions, long-text truncation
+ * - AssistMessagePart.tsx: Assistant message with Markdown, streaming cursor, copy/regenerate
+ * - ReasoningPart.tsx: Collapsible thinking/reasoning block with framer-motion animation
+ * - ToolMessagePart.tsx: Tool status bar + tool card components
+ */
+
+export { UserMessagePart } from './UserMessagePart';
+export type { UserMessagePartProps } from './UserMessagePart';
+
+export { AssistMessagePart } from './AssistMessagePart';
+export type { AssistMessagePartProps } from './AssistMessagePart';
+
+export { ReasoningPart } from './ReasoningPart';
+export type { ReasoningPartProps } from './ReasoningPart';
+
+export { ToolStatusBar, ToolCard } from './ToolMessagePart';
+export type { ToolStatusItem, ToolCardProps } from './ToolMessagePart';
+
+export { WordByWordFadeIn } from './WordByWordFadeIn';
diff --git a/src/pages/Chat/tool-renderers/BrowserRenderer.tsx b/src/pages/Chat/tool-renderers/BrowserRenderer.tsx
new file mode 100644
index 0000000..09d53d2
--- /dev/null
+++ b/src/pages/Chat/tool-renderers/BrowserRenderer.tsx
@@ -0,0 +1,217 @@
+/**
+ * Browser Tool Renderer
+ * Renders browser operation results with URL, status badge, and action summary.
+ * Triggered when tool name contains "browser".
+ */
+import { useState, useMemo, memo } from 'react';
+import {
+ Monitor,
+ ExternalLink,
+ CheckCircle2,
+ XCircle,
+ Loader2,
+ MousePointer2,
+ Eye,
+ Type,
+} from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { useTranslation } from 'react-i18next';
+import type { ToolRendererProps } from './index';
+
+/** Extract URL from input or output */
+function extractUrl(data: unknown): string {
+ if (!data || typeof data !== 'object') return '';
+ const obj = data as Record;
+ if (typeof obj.url === 'string') return obj.url;
+ if (typeof obj.href === 'string') return obj.href;
+ if (typeof obj.link === 'string') return obj.link;
+ if (typeof obj.page_url === 'string') return obj.page_url;
+ return '';
+}
+
+/** Detect browser action type from input */
+function extractAction(input: unknown): string {
+ if (!input || typeof input !== 'object') return 'browse';
+ const obj = input as Record;
+ if (typeof obj.action === 'string') return obj.action.toLowerCase();
+ if (typeof obj.command === 'string') return obj.command.toLowerCase();
+ if (typeof obj.type === 'string') return obj.type.toLowerCase();
+ return 'browse';
+}
+
+/** Get appropriate icon for browser action */
+function ActionIcon({ action, className }: { action: string; className: string }) {
+ if (action.includes('click') || action.includes('press')) {
+ return ;
+ }
+ if (action.includes('type') || action.includes('fill') || action.includes('input')) {
+ return ;
+ }
+ if (action.includes('screenshot') || action.includes('observe') || action.includes('look')) {
+ return ;
+ }
+ return ;
+}
+
+/** Extract status from output */
+function extractStatus(output: unknown): 'success' | 'error' | 'running' {
+ if (!output) return 'running';
+ if (typeof output === 'object') {
+ const obj = output as Record;
+ if (obj.error || obj.isError === true) return 'error';
+ if (typeof obj.status === 'string') {
+ const s = obj.status.toLowerCase();
+ if (s === 'error' || s === 'failed') return 'error';
+ }
+ }
+ return 'success';
+}
+
+/** Extract page title from output */
+function extractTitle(output: unknown): string {
+ if (!output || typeof output !== 'object') return '';
+ const obj = output as Record;
+ if (typeof obj.title === 'string') return obj.title;
+ if (typeof obj.pageTitle === 'string') return obj.pageTitle;
+ return '';
+}
+
+export const BrowserRenderer = memo(function BrowserRenderer({
+ input,
+ output,
+}: ToolRendererProps) {
+ const { t } = useTranslation('chat');
+ const [activeTab, setActiveTab] = useState<'summary' | 'input' | 'output'>(
+ output != null ? 'summary' : 'input'
+ );
+
+ const url = useMemo(() => extractUrl(input) || extractUrl(output), [input, output]);
+ const action = useMemo(() => extractAction(input), [input]);
+ const status = useMemo(() => extractStatus(output), [output]);
+ const pageTitle = useMemo(() => extractTitle(output), [output]);
+
+ const domain = useMemo(() => {
+ if (!url) return '';
+ try {
+ return new URL(url).hostname.replace(/^www\./, '');
+ } catch {
+ return url;
+ }
+ }, [url]);
+
+ const actionLabel = useMemo(() => {
+ if (action.includes('click')) return t('tool.browserClick', 'Click');
+ if (action.includes('type') || action.includes('fill')) return t('tool.browserType', 'Type');
+ if (action.includes('navigate') || action.includes('goto')) return t('tool.browserNavigate', 'Navigate');
+ if (action.includes('screenshot')) return t('tool.browserScreenshot', 'Screenshot');
+ if (action.includes('scroll')) return t('tool.browserScroll', 'Scroll');
+ if (action.includes('observe') || action.includes('look')) return t('tool.browserObserve', 'Observe');
+ return t('tool.browserBrowse', 'Browse');
+ }, [action, t]);
+
+ return (
+
+ {/* Header card with URL + status */}
+
+
+
+
+
+ {actionLabel}
+ {/* Status badge */}
+ {status === 'success' && (
+
+ )}
+ {status === 'error' && }
+ {status === 'running' && (
+
+ )}
+
+
+ {/* URL */}
+ {url && (
+
+ )}
+
+ {/* Page title */}
+ {pageTitle && (
+
{pageTitle}
+ )}
+
+
+
+ {/* Tabs */}
+
+ {output != null && (
+
+ )}
+
+ {output != null && (
+
+ )}
+
+
+ {/* Content */}
+ {activeTab === 'input' && (
+
+ {typeof input === 'string' ? input : JSON.stringify(input, null, 2)}
+
+ )}
+ {activeTab === 'output' && output != null && (
+
+ {typeof output === 'string' ? output : JSON.stringify(output, null, 2)}
+
+ )}
+ {activeTab === 'summary' && output != null && (
+
+ {typeof output === 'string' ? (
+
{output}
+ ) : (
+
{JSON.stringify(output, null, 2)}
+ )}
+
+ )}
+
+ );
+});
+BrowserRenderer.displayName = 'BrowserRenderer';
diff --git a/src/pages/Chat/tool-renderers/CodeExecutorRenderer.tsx b/src/pages/Chat/tool-renderers/CodeExecutorRenderer.tsx
new file mode 100644
index 0000000..0531a16
--- /dev/null
+++ b/src/pages/Chat/tool-renderers/CodeExecutorRenderer.tsx
@@ -0,0 +1,204 @@
+/**
+ * Code Executor Tool Renderer
+ * Renders code execution results with syntax-highlighted code block and output area.
+ * Triggered when tool name contains "code" or "execute".
+ */
+import { useState, useMemo, memo, useCallback } from 'react';
+import { Copy, Check, AlertTriangle, ChevronRight, Terminal, Code2 } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { useTranslation } from 'react-i18next';
+import type { ToolRendererProps } from './index';
+
+/** Try to extract code string from tool input */
+function extractCode(input: unknown): string {
+ if (typeof input === 'string') return input;
+ if (!input || typeof input !== 'object') return '';
+ const obj = input as Record;
+ if (typeof obj.code === 'string') return obj.code;
+ if (typeof obj.script === 'string') return obj.script;
+ if (typeof obj.source === 'string') return obj.source;
+ if (typeof obj.content === 'string') return obj.content;
+ return JSON.stringify(input, null, 2);
+}
+
+/** Try to extract language from tool input or name */
+function extractLanguage(input: unknown, toolName: string): string {
+ if (input && typeof input === 'object') {
+ const obj = input as Record;
+ if (typeof obj.language === 'string') return obj.language.toLowerCase();
+ if (typeof obj.lang === 'string') return obj.lang.toLowerCase();
+ if (typeof obj.type === 'string') return obj.type.toLowerCase();
+ }
+ const n = toolName.toLowerCase();
+ if (n.includes('python') || n.includes('py')) return 'python';
+ if (n.includes('javascript') || n.includes('js')) return 'javascript';
+ if (n.includes('typescript') || n.includes('ts')) return 'typescript';
+ if (n.includes('bash') || n.includes('shell') || n.includes('sh')) return 'bash';
+ return 'code';
+}
+
+/** Extract output logs or result text from tool output */
+function extractOutput(output: unknown): { logs: string[]; error?: string } {
+ if (!output) return { logs: [] };
+ if (typeof output === 'string') return { logs: [output] };
+
+ const obj = output as Record;
+ const logs: string[] = [];
+ let error: string | undefined;
+
+ // Error
+ if (typeof obj.error === 'string' && obj.error) {
+ error = obj.error;
+ }
+
+ // Logs array
+ if (Array.isArray(obj.logs)) {
+ for (const log of obj.logs) {
+ if (typeof log === 'string') {
+ logs.push(log);
+ } else if (log && typeof log === 'object') {
+ const entry = log as Record;
+ if (Array.isArray(entry.args)) {
+ const text = (entry.args as Array>)
+ .map((a) => (typeof a.value === 'string' ? a.value : JSON.stringify(a.value ?? a)))
+ .join(' ');
+ logs.push(text);
+ } else if (typeof entry.message === 'string') {
+ logs.push(entry.message);
+ }
+ }
+ }
+ }
+
+ // Result / output as string
+ if (typeof obj.result === 'string' && obj.result) logs.push(obj.result);
+ if (typeof obj.output === 'string' && obj.output) logs.push(obj.output);
+ if (typeof obj.stdout === 'string' && obj.stdout) logs.push(obj.stdout);
+ if (typeof obj.stderr === 'string' && obj.stderr) {
+ error = error || obj.stderr;
+ }
+
+ // If nothing extracted, try to stringify the entire output
+ if (logs.length === 0 && !error && typeof output === 'object') {
+ const str = JSON.stringify(output, null, 2);
+ if (str !== '{}' && str !== '[]') logs.push(str);
+ }
+
+ return { logs, error };
+}
+
+export const CodeExecutorRenderer = memo(function CodeExecutorRenderer({
+ input,
+ output,
+}: ToolRendererProps) {
+ const { t } = useTranslation('chat');
+ const [activeTab, setActiveTab] = useState<'code' | 'output'>(
+ output != null ? 'output' : 'code'
+ );
+ const [copied, setCopied] = useState(false);
+
+ const code = useMemo(() => extractCode(input), [input]);
+ const language = useMemo(() => extractLanguage(input, ''), [input]);
+ const { logs, error } = useMemo(() => extractOutput(output), [output]);
+
+ const handleCopyCode = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ // clipboard may not be available
+ }
+ }, [code]);
+
+ const langLabel = language === 'javascript' ? 'JS' : language === 'python' ? 'PY' : language.toUpperCase();
+
+ return (
+
+ {/* Header with language badge */}
+
+
+
+ {output != null && (
+
+ )}
+
+
+
+
+ {langLabel}
+
+
+
+
+
+ {/* Content */}
+ {activeTab === 'code' ? (
+
+ {code || t('tool.noData', 'No data')}
+
+ ) : (
+
+ {/* Logs */}
+ {logs.length > 0 && (
+
+ {logs.map((log, i) => (
+
+
+ {log}
+
+ ))}
+
+ )}
+
+ {/* Error */}
+ {error && (
+
+ )}
+
+ {/* No output */}
+ {logs.length === 0 && !error && (
+
+ {t('tool.noOutput', 'No output')}
+
+ )}
+
+ )}
+
+ );
+});
+CodeExecutorRenderer.displayName = 'CodeExecutorRenderer';
diff --git a/src/pages/Chat/tool-renderers/DefaultRenderer.tsx b/src/pages/Chat/tool-renderers/DefaultRenderer.tsx
new file mode 100644
index 0000000..5b9538b
--- /dev/null
+++ b/src/pages/Chat/tool-renderers/DefaultRenderer.tsx
@@ -0,0 +1,85 @@
+/**
+ * Default Tool Renderer
+ * Enhanced JSON view with formatted output, Copy button, and syntax highlighting.
+ * Used as fallback when no specialized renderer matches the tool name.
+ */
+import { useState, memo, useCallback } from 'react';
+import { Copy, Check } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { useTranslation } from 'react-i18next';
+import type { ToolRendererProps } from './index';
+
+export const DefaultRenderer = memo(function DefaultRenderer({
+ input,
+ output,
+}: ToolRendererProps) {
+ const { t } = useTranslation('chat');
+ const [activeTab, setActiveTab] = useState<'input' | 'output'>(output != null ? 'output' : 'input');
+ const [copied, setCopied] = useState(false);
+
+ const currentData = activeTab === 'input' ? input : output;
+ const formatted =
+ typeof currentData === 'string' ? currentData : JSON.stringify(currentData, null, 2);
+
+ const handleCopy = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(formatted ?? '');
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ // clipboard may not be available
+ }
+ }, [formatted]);
+
+ return (
+
+ {/* Tab bar + copy button */}
+
+
+
+ {output != null && (
+
+ )}
+
+
+
+
+
+ {/* Content */}
+
+ {formatted || t('tool.noData', 'No data')}
+
+
+ );
+});
+DefaultRenderer.displayName = 'DefaultRenderer';
diff --git a/src/pages/Chat/tool-renderers/WebSearchRenderer.tsx b/src/pages/Chat/tool-renderers/WebSearchRenderer.tsx
new file mode 100644
index 0000000..3be1450
--- /dev/null
+++ b/src/pages/Chat/tool-renderers/WebSearchRenderer.tsx
@@ -0,0 +1,214 @@
+/**
+ * Web Search Tool Renderer
+ * Renders search results as a clean list with titles, URLs, and descriptions.
+ * Triggered when tool name contains "search".
+ */
+import { useState, useMemo, memo } from 'react';
+import { ExternalLink, Globe, Search, AlertTriangle } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { useTranslation } from 'react-i18next';
+import type { ToolRendererProps } from './index';
+
+/** A generic search result shape — tolerant of different provider formats */
+interface SearchResult {
+ title?: string;
+ url?: string;
+ link?: string;
+ href?: string;
+ description?: string;
+ snippet?: string;
+ text?: string;
+ content?: string;
+}
+
+/** Try to extract an array of search results from tool output */
+function extractResults(data: unknown): SearchResult[] {
+ if (!data || typeof data !== 'object') return [];
+
+ // Direct array of results
+ if (Array.isArray(data)) return data as SearchResult[];
+
+ const obj = data as Record;
+
+ // Common wrapper fields
+ if (Array.isArray(obj.results)) return obj.results as SearchResult[];
+ if (Array.isArray(obj.items)) return obj.items as SearchResult[];
+ if (Array.isArray(obj.data)) return obj.data as SearchResult[];
+ if (Array.isArray(obj.organic)) return obj.organic as SearchResult[];
+ if (Array.isArray(obj.web)) return obj.web as SearchResult[];
+
+ return [];
+}
+
+function getUrl(result: SearchResult): string {
+ return result.url || result.link || result.href || '';
+}
+
+function getDescription(result: SearchResult): string {
+ return result.description || result.snippet || result.text || result.content || '';
+}
+
+function getDomain(url: string): string {
+ try {
+ return new URL(url).hostname.replace(/^www\./, '');
+ } catch {
+ return url;
+ }
+}
+
+export const WebSearchRenderer = memo(function WebSearchRenderer({
+ name,
+ input,
+ output,
+}: ToolRendererProps) {
+ const { t } = useTranslation('chat');
+ const [activeTab, setActiveTab] = useState<'results' | 'input'>(
+ output != null ? 'results' : 'input'
+ );
+
+ const query = useMemo(() => {
+ if (!input || typeof input !== 'object') return '';
+ const inp = input as Record;
+ return (
+ (typeof inp.query === 'string' ? inp.query : '') ||
+ (typeof inp.q === 'string' ? inp.q : '') ||
+ (typeof inp.search === 'string' ? inp.search : '') ||
+ ''
+ );
+ }, [input]);
+
+ const results = useMemo(() => extractResults(output), [output]);
+
+ const isError = useMemo(() => {
+ if (!output || typeof output !== 'object') return false;
+ const obj = output as Record;
+ return obj.isError === true || obj.error != null;
+ }, [output]);
+
+ const errorMessage = useMemo(() => {
+ if (!output || typeof output !== 'object') return '';
+ const obj = output as Record;
+ return typeof obj.error === 'string' ? obj.error : '';
+ }, [output]);
+
+ // No output yet — show input
+ if (output == null) {
+ return (
+
+
+
+
+ {t('tool.searchingFor', 'Searching for')}: “{query}”
+
+
+
+ {typeof input === 'string' ? input : JSON.stringify(input, null, 2)}
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ {t('tool.searchResults', 'Search Results')}
+
+ {query && (
+
+ “{query}”
+
+ )}
+
+
+ {/* Tabs */}
+
+
+
+
+
+ {/* Content */}
+ {activeTab === 'input' ? (
+
+ {typeof input === 'string' ? input : JSON.stringify(input, null, 2)}
+
+ ) : isError ? (
+
+
+
{errorMessage || t('tool.searchError', 'Search failed')}
+
+ ) : results.length === 0 ? (
+
+ {t('tool.noResults', 'No results found')}
+
+ ) : (
+
+ {results.map((result, i) => {
+ const url = getUrl(result);
+ const desc = getDescription(result);
+ return (
+
+ {/* Title + link */}
+
+
+ {/* Domain */}
+ {url && (
+
+ {getDomain(url)}
+
+ )}
+
+ {/* Description */}
+ {desc && (
+
+ {desc}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+});
+WebSearchRenderer.displayName = 'WebSearchRenderer';
diff --git a/src/pages/Chat/tool-renderers/index.ts b/src/pages/Chat/tool-renderers/index.ts
new file mode 100644
index 0000000..da20e39
--- /dev/null
+++ b/src/pages/Chat/tool-renderers/index.ts
@@ -0,0 +1,39 @@
+/**
+ * Tool Renderer Router
+ * Selects the appropriate specialized renderer based on tool name.
+ * Falls back to DefaultRenderer for unknown tool types.
+ */
+import type { ComponentType } from 'react';
+import { WebSearchRenderer } from './WebSearchRenderer';
+import { CodeExecutorRenderer } from './CodeExecutorRenderer';
+import { BrowserRenderer } from './BrowserRenderer';
+import { DefaultRenderer } from './DefaultRenderer';
+
+/** Props shared by all tool renderers */
+export interface ToolRendererProps {
+ /** Tool name */
+ name: string;
+ /** Tool call input (arguments sent to the tool) */
+ input: unknown;
+ /** Tool execution output / result (undefined if not yet completed) */
+ output?: unknown;
+}
+
+/** Select the best renderer component based on tool name */
+export function getToolRenderer(toolName: string): ComponentType {
+ const n = toolName.toLowerCase();
+
+ if (n.includes('search')) {
+ return WebSearchRenderer;
+ }
+ if (n.includes('code') || n.includes('execute') || n.includes('run_code') || n.includes('interpreter')) {
+ return CodeExecutorRenderer;
+ }
+ if (n.includes('browser') || n.includes('navigate') || n.includes('playwright')) {
+ return BrowserRenderer;
+ }
+
+ return DefaultRenderer;
+}
+
+export { WebSearchRenderer, CodeExecutorRenderer, BrowserRenderer, DefaultRenderer };