Files
openchamber/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx
T
Bohdan Triapitsyn 9ea557d23e feat(markdown): open mermaid fullscreen preview on diagram click in chat
Thread onShowPopup from MessageBody through AssistantTextPart into the
markdown renderer so clicking a rendered mermaid diagram in assistant
messages opens the existing pan/zoom fullscreen preview dialog.
2026-06-15 18:23:59 +03:00

111 lines
4.2 KiB
TypeScript

import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
import { MarkdownRenderer } from '../../MarkdownRenderer';
import type { StreamPhase, ToolPopupContent } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
import { GeneratedJsonResultCard } from './GeneratedJsonResultCard';
import { parseGeneratedJsonResult } from './generatedJsonResult';
type PartWithText = Part & { text?: string; content?: string; value?: string; time?: { start?: number; end?: number } };
interface AssistantTextPartProps {
part: Part;
sessionId?: string;
messageId: string;
streamPhase: StreamPhase;
chatRenderMode?: 'sorted' | 'live';
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
onShowPopup?: (content: ToolPopupContent) => void;
}
const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
part,
messageId,
streamPhase,
chatRenderMode = 'live',
onShowPopup,
}) => {
// Use part directly from props — parent provides the latest version from the store.
// No store subscription here to avoid re-render cascade from unrelated delta events.
const partWithText = part as PartWithText;
const rawText = typeof partWithText.text === 'string' ? partWithText.text : '';
const contentText = typeof partWithText.content === 'string' ? partWithText.content : '';
const valueText = typeof partWithText.value === 'string' ? partWithText.value : '';
const textContent = [rawText, contentText, valueText].reduce((best, candidate) => {
return candidate.length > best.length ? candidate : best;
}, '');
const isStreamingPhase = streamPhase === 'streaming';
const isCooldownPhase = streamPhase === 'cooldown';
const isStreaming = chatRenderMode === 'live' && (isStreamingPhase || isCooldownPhase);
streamPerfCount('ui.assistant_text_part.render');
if (isStreaming) {
streamPerfCount('ui.assistant_text_part.render.streaming');
}
const throttledTextContent = useStreamingTextThrottle({
text: textContent,
isStreaming,
identityKey: `${messageId}:${part.id ?? 'text'}`,
});
const displayTextContent = resolveAssistantDisplayText({
textContent,
throttledTextContent,
isStreaming,
});
streamPerfObserve('ui.assistant_text_part.display_len', displayTextContent.length);
const time = partWithText.time;
const isFinalized = Boolean(time && typeof time.end !== 'undefined');
const isRenderableTextPart = part.type === 'text' || part.type === 'reasoning';
if (!isRenderableTextPart) {
return null;
}
if (!shouldRenderAssistantText({
displayTextContent,
isFinalized,
})) {
return null;
}
const generatedResult = !isStreaming && isFinalized ? parseGeneratedJsonResult(displayTextContent) : null;
if (generatedResult) {
return (
<div
className={`group/assistant-text relative break-words ${chatRenderMode === 'live' ? 'my-1' : ''}`}
key={part.id || `${messageId}-text`}
>
<GeneratedJsonResultCard result={generatedResult} />
</div>
);
}
return (
<div
className={`group/assistant-text relative break-words ${chatRenderMode === 'live' ? 'my-1' : ''}`}
key={part.id || `${messageId}-text`}
>
<MarkdownRenderer
content={displayTextContent}
part={part}
messageId={messageId}
isAnimated={false}
isStreaming={isStreaming}
disableStreamAnimation={chatRenderMode === 'sorted'}
variant={part.type === 'reasoning' ? 'reasoning' : 'assistant'}
enableFileReferences={isFinalized}
onShowPopup={onShowPopup}
/>
</div>
);
};
export default React.memo(AssistantTextPart);