import { memo, useCallback, useMemo } from 'react' import type { MouseEvent as ReactMouseEvent } from 'react' import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { MessageActionBar, type MessageBranchAction } from './MessageActionBar' import { InlineImageGallery } from './InlineImageGallery' import { InlineVideoGallery } from './InlineVideoGallery' import { AssistantOutputTargetCard } from './AssistantOutputTargetCard' import { handlePreviewLink } from '../../lib/handlePreviewLink' import { getServerBaseUrl } from '../../lib/desktopRuntime' import { getDesktopHost } from '../../lib/desktopHost' import { extractAssistantOutputTargets } from '../../lib/assistantOutputTargets' import { useBrowserPanelStore } from '../../stores/browserPanelStore' import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' import { useTranslation } from '../../i18n' type Props = { content: string isStreaming?: boolean branchAction?: MessageBranchAction sessionId?: string timestamp?: number } const MAX_CARDS = 3 export const AssistantMessage = memo(function AssistantMessage({ content, isStreaming, branchAction, sessionId, timestamp }: Props) { const t = useTranslation() const workDir = useWorkspacePanelStore((s) => (sessionId ? s.statusBySession[sessionId]?.workDir : undefined)) const handleLinkClick = useCallback( (href: string, event: ReactMouseEvent): boolean => { if (!sessionId) return false const handled = handlePreviewLink(href, { sessionId, serverBaseUrl: getServerBaseUrl(), openBrowser: (id, url) => useBrowserPanelStore.getState().open(id, url), openFilePreview: (id, path) => { void useWorkspacePanelStore.getState().openPreview(id, path, 'file') }, openExternal: (url) => { void getDesktopHost().shell.open(url) .catch(() => window.open(url, '_blank')) }, }) if (handled) event.preventDefault() return handled }, [sessionId], ) const outputTargets = useMemo( () => isStreaming || !sessionId ? [] : // Image/video targets render inline (InlineImageGallery/InlineVideoGallery); never also as a card. extractAssistantOutputTargets(content, { workDir }).filter( (target) => target.kind !== 'image' && target.kind !== 'video', ), [content, isStreaming, sessionId, workDir], ) if (!content.trim()) return null const documentLayout = shouldUseDocumentLayout(content) return (
{!isStreaming && } {!isStreaming && } {isStreaming && ( )}
{!isStreaming && sessionId && outputTargets.length > 0 && (
{outputTargets.slice(0, MAX_CARDS).map((target) => ( ))} {outputTargets.length > MAX_CARDS && (
{t('assistantOutputs.moreOutputs', { count: String(outputTargets.length - MAX_CARDS) })}
)}
)}
) }) function shouldUseDocumentLayout(content: string) { const normalized = content.trim() if (!normalized) return false if (/```/.test(normalized)) return true if (/^\s{0,3}(#{1,6}\s|[-*+]\s|\d+\.\s|>\s|\|.+\|)/m.test(normalized)) return true const paragraphs = normalized .split(/\n\s*\n/) .map((chunk) => chunk.trim()) .filter(Boolean) return paragraphs.length >= 2 || normalized.split('\n').filter((line) => line.trim()).length >= 8 }