* fix: preserve Markdown blocks when copying messages * fix: preserve blank lines when copying code --------- Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
1020 lines
44 KiB
TypeScript
1020 lines
44 KiB
TypeScript
import React from 'react';
|
|
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
|
|
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useContextStore } from '@/stores/contextStore';
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { useSelectionStore } from '@/sync/selection-store';
|
|
import { useDeviceInfo } from '@/lib/device';
|
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
|
import { cn } from '@/lib/utils';
|
|
import { useChatSurfaceMode } from './useChatSurfaceMode';
|
|
|
|
import MessageBody from './message/MessageBody';
|
|
import type { AgentMentionInfo } from './message/types';
|
|
import type { StreamPhase, ToolPopupContent } from './message/types';
|
|
import { deriveMessageRole } from './message/messageRole';
|
|
import { filterVisibleParts, normalizeParts } from './message/partUtils';
|
|
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
|
import { isHiddenUserMessage } from './message/hiddenUserMessage';
|
|
import { flattenAssistantTextParts, flattenUserTextParts } from '@/lib/messages/messageText';
|
|
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
|
|
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
|
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
|
import type { TurnGroupingContext } from './lib/turns/types';
|
|
import { copyMarkdownToClipboard, copyTextToClipboard } from '@/lib/clipboard';
|
|
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
|
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
|
import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual } from './message/renderCompare';
|
|
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
|
|
import { toast } from 'sonner';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
|
import { getContextObligatoryMessages } from '@/lib/contextObligatoryMessages';
|
|
import { setContextObligatoryMessage } from '@/sync/session-actions';
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
|
import { focusChatInput } from './composer/editor/dom';
|
|
|
|
const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog'));
|
|
|
|
const EXPANDED_TOOLS_CACHE_MAX = 4000;
|
|
const expandedToolsStateCache = new Map<string, Set<string>>();
|
|
const collapsedToolsStateCache = new Map<string, Set<string>>();
|
|
|
|
const BASH_TOOL_NAMES = new Set(['bash', 'shell', 'cmd', 'terminal']);
|
|
const EDIT_TOOL_NAMES = new Set([
|
|
'apply_patch',
|
|
'edit',
|
|
'write',
|
|
'multiedit',
|
|
'str_replace',
|
|
'str_replace_based_edit_tool',
|
|
'create',
|
|
'file_write',
|
|
]);
|
|
|
|
const normalizeToolName = (toolName: unknown): string => {
|
|
if (typeof toolName !== 'string') return '';
|
|
const trimmed = toolName.trim().toLowerCase();
|
|
if (!trimmed) return '';
|
|
const withoutIndex = trimmed.replace(/:\d+$/, '');
|
|
if (!withoutIndex.includes('.')) {
|
|
return withoutIndex;
|
|
}
|
|
const parts = withoutIndex.split('.').filter(Boolean);
|
|
return parts[parts.length - 1] ?? withoutIndex;
|
|
};
|
|
|
|
const readExpandedToolsCache = (messageId: string): Set<string> => {
|
|
const cached = expandedToolsStateCache.get(messageId);
|
|
return cached ? new Set(cached) : new Set();
|
|
};
|
|
|
|
const writeExpandedToolsCache = (messageId: string, value: Set<string>): void => {
|
|
if (expandedToolsStateCache.size >= EXPANDED_TOOLS_CACHE_MAX && !expandedToolsStateCache.has(messageId)) {
|
|
const oldest = expandedToolsStateCache.keys().next().value;
|
|
if (typeof oldest === 'string') {
|
|
expandedToolsStateCache.delete(oldest);
|
|
}
|
|
}
|
|
expandedToolsStateCache.set(messageId, new Set(value));
|
|
};
|
|
|
|
const readCollapsedToolsCache = (messageId: string): Set<string> => {
|
|
const cached = collapsedToolsStateCache.get(messageId);
|
|
return cached ? new Set(cached) : new Set();
|
|
};
|
|
|
|
const writeCollapsedToolsCache = (messageId: string, value: Set<string>): void => {
|
|
if (collapsedToolsStateCache.size >= EXPANDED_TOOLS_CACHE_MAX && !collapsedToolsStateCache.has(messageId)) {
|
|
const oldest = collapsedToolsStateCache.keys().next().value;
|
|
if (typeof oldest === 'string') {
|
|
collapsedToolsStateCache.delete(oldest);
|
|
}
|
|
}
|
|
collapsedToolsStateCache.set(messageId, new Set(value));
|
|
};
|
|
|
|
function useStickyDisplayValue<T>(value: T | null | undefined): T | null | undefined {
|
|
const [stickyValue, setStickyValue] = React.useState<T | null | undefined>(value);
|
|
|
|
React.useEffect(() => {
|
|
if (value !== undefined && value !== null) {
|
|
setStickyValue(value);
|
|
}
|
|
}, [value]);
|
|
|
|
return value ?? stickyValue;
|
|
}
|
|
|
|
const getMessageInfoProp = (info: unknown, key: string): unknown => {
|
|
if (typeof info === 'object' && info !== null) {
|
|
return (info as Record<string, unknown>)[key];
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
interface ChatMessageProps {
|
|
message: {
|
|
info: Message;
|
|
parts: Part[];
|
|
};
|
|
previousMessage?: {
|
|
info: Message;
|
|
parts: Part[];
|
|
};
|
|
nextMessage?: {
|
|
info: Message;
|
|
parts: Part[];
|
|
};
|
|
scrollToBottom?: () => void;
|
|
turnGroupingContext?: TurnGroupingContext;
|
|
assistantHeaderMessageId?: string;
|
|
isInActiveTurn?: boolean;
|
|
activeStreamingPhase?: StreamPhase | null;
|
|
animateUserOnMount?: boolean;
|
|
onUserAnimationConsumed?: (messageId: string) => void;
|
|
reviewTransferDirection?: ReviewTransferDirection | null;
|
|
}
|
|
|
|
const ChatMessage: React.FC<ChatMessageProps> = ({
|
|
message,
|
|
previousMessage,
|
|
nextMessage,
|
|
turnGroupingContext,
|
|
assistantHeaderMessageId,
|
|
isInActiveTurn = false,
|
|
activeStreamingPhase = null,
|
|
animateUserOnMount = false,
|
|
onUserAnimationConsumed,
|
|
reviewTransferDirection = null,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const { isMobile, isTablet, hasTouchInput } = useDeviceInfo();
|
|
const alwaysShowMessageActions = isMobile || isTablet;
|
|
const canPinIntoContext = !isVSCodeRuntime();
|
|
const { currentTheme } = useThemeSystem();
|
|
const messageContainerRef = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession);
|
|
const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection);
|
|
|
|
streamPerfCount('ui.chat_message.render');
|
|
if (isInActiveTurn) {
|
|
streamPerfCount('ui.chat_message.render.streaming');
|
|
}
|
|
|
|
const providers = useConfigStore((state) => state.providers);
|
|
const { showReasoningTraces, stickyUserHeader, chatRenderMode, showExpandedBashTools, showExpandedEditTools } = useUIStore(
|
|
useShallow((state) => ({
|
|
showReasoningTraces: state.showReasoningTraces,
|
|
stickyUserHeader: state.stickyUserHeader,
|
|
chatRenderMode: state.chatRenderMode,
|
|
showExpandedBashTools: state.showExpandedBashTools,
|
|
showExpandedEditTools: state.showExpandedEditTools,
|
|
}))
|
|
);
|
|
|
|
const [copiedCode, setCopiedCode] = React.useState<string | null>(null);
|
|
const [copiedMessage, setCopiedMessage] = React.useState(false);
|
|
const [expandedTools, setExpandedTools] = React.useState<Set<string>>(() => readExpandedToolsCache(message.info.id));
|
|
const [collapsedTools, setCollapsedTools] = React.useState<Set<string>>(() => readCollapsedToolsCache(message.info.id));
|
|
const [popupContent, setPopupContent] = React.useState<ToolPopupContent>({
|
|
open: false,
|
|
title: '',
|
|
content: '',
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
setExpandedTools(readExpandedToolsCache(message.info.id));
|
|
setCollapsedTools(readCollapsedToolsCache(message.info.id));
|
|
}, [message.info.id]);
|
|
|
|
|
|
|
|
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
|
|
const isUser = messageRole.isUser;
|
|
const chatSurfaceMode = useChatSurfaceMode();
|
|
const useExternalUserActionsRow = isUser && (isMobile || !stickyUserHeader);
|
|
const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow;
|
|
|
|
const sessionId = message.info.sessionID;
|
|
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
|
|
|
// Keep non-active-turn rows detached from context-store churn.
|
|
const { currentContextAgent, savedSessionAgentSelection } = useContextStore(
|
|
useShallow((state) => ({
|
|
currentContextAgent: isInActiveTurn && sessionId ? state.currentAgentContext.get(sessionId) : undefined,
|
|
savedSessionAgentSelection: isInActiveTurn && sessionId ? state.sessionAgentSelections.get(sessionId) : undefined,
|
|
}))
|
|
);
|
|
|
|
const normalizedParts = React.useMemo(() => {
|
|
const safeParts = normalizeParts(message.parts);
|
|
if (!isUser) {
|
|
return safeParts;
|
|
}
|
|
|
|
return normalizeUserDisplayParts(safeParts, { planModeEnabled });
|
|
}, [isUser, message.parts, planModeEnabled]);
|
|
|
|
const previousUserMetadata = React.useMemo(() => {
|
|
if (isUser || !previousMessage) {
|
|
return null;
|
|
}
|
|
|
|
const clientRole = getMessageInfoProp(previousMessage.info, 'clientRole');
|
|
const role = getMessageInfoProp(previousMessage.info, 'role');
|
|
const previousRole = typeof clientRole === 'string' ? clientRole : (typeof role === 'string' ? role : undefined);
|
|
if (previousRole !== 'user') {
|
|
return null;
|
|
}
|
|
|
|
const mode = getMessageInfoProp(previousMessage.info, 'mode');
|
|
const agent = getMessageInfoProp(previousMessage.info, 'agent');
|
|
const providerID = getMessageInfoProp(previousMessage.info, 'providerID');
|
|
const modelID = getMessageInfoProp(previousMessage.info, 'modelID');
|
|
const variant = getMessageInfoProp(previousMessage.info, 'variant');
|
|
const resolvedAgent =
|
|
typeof mode === 'string' && mode.trim().length > 0
|
|
? mode
|
|
: (typeof agent === 'string' && agent.trim().length > 0 ? agent : undefined);
|
|
const resolvedProvider = typeof providerID === 'string' && providerID.trim().length > 0 ? providerID : undefined;
|
|
const resolvedModel = typeof modelID === 'string' && modelID.trim().length > 0 ? modelID : undefined;
|
|
const resolvedVariant = typeof variant === 'string' && variant.trim().length > 0 ? variant : undefined;
|
|
|
|
if (!resolvedAgent && !resolvedProvider && !resolvedModel && !resolvedVariant) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
agentName: resolvedAgent,
|
|
providerId: resolvedProvider,
|
|
modelId: resolvedModel,
|
|
variant: resolvedVariant,
|
|
};
|
|
}, [isUser, previousMessage]);
|
|
|
|
const previousIsModeSwitchMessage = React.useMemo(() => {
|
|
if (!planModeEnabled) return false;
|
|
if (isUser || !previousMessage) return false;
|
|
const parts = Array.isArray(previousMessage.parts) ? previousMessage.parts : [];
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const part = parts[i] as unknown as { type?: string; text?: string; synthetic?: boolean };
|
|
if (part?.type !== 'text') continue;
|
|
if (part?.synthetic !== true) continue;
|
|
const text = typeof part.text === 'string' ? part.text.trim() : '';
|
|
if (text.startsWith('User has requested to enter plan mode') || text.startsWith('The plan at ')) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}, [isUser, planModeEnabled, previousMessage]);
|
|
|
|
const agentName = React.useMemo(() => {
|
|
if (isUser) return undefined;
|
|
|
|
// While the assistant message is streaming, if the immediately previous user message is a
|
|
// synthetic mode switch, trust that mode for the badge.
|
|
const timeInfo = message.info.time as { completed?: number } | undefined;
|
|
const isCompleted = typeof timeInfo?.completed === 'number' && timeInfo.completed > 0;
|
|
if (!isCompleted && previousIsModeSwitchMessage && previousUserMetadata?.agentName) {
|
|
return previousUserMetadata.agentName;
|
|
}
|
|
|
|
const messageMode = getMessageInfoProp(message.info, 'mode');
|
|
if (typeof messageMode === 'string' && messageMode.trim().length > 0) {
|
|
return messageMode;
|
|
}
|
|
|
|
const messageAgent = getMessageInfoProp(message.info, 'agent');
|
|
if (typeof messageAgent === 'string' && messageAgent.trim().length > 0) {
|
|
return messageAgent;
|
|
}
|
|
|
|
if (previousUserMetadata?.agentName) {
|
|
return previousUserMetadata.agentName;
|
|
}
|
|
|
|
if (!sessionId) {
|
|
return undefined;
|
|
}
|
|
|
|
if (currentContextAgent) {
|
|
return currentContextAgent;
|
|
}
|
|
|
|
return savedSessionAgentSelection ?? undefined;
|
|
}, [isUser, message.info, previousIsModeSwitchMessage, previousUserMetadata, sessionId, currentContextAgent, savedSessionAgentSelection]);
|
|
|
|
const messageProviderID = !isUser ? getMessageInfoProp(message.info, 'providerID') : null;
|
|
const messageModelID = !isUser ? getMessageInfoProp(message.info, 'modelID') : null;
|
|
|
|
const contextModelSelection = React.useMemo(() => {
|
|
if (isUser || !sessionId) return null;
|
|
|
|
if (previousUserMetadata?.providerId && previousUserMetadata?.modelId) {
|
|
return {
|
|
providerId: previousUserMetadata.providerId,
|
|
modelId: previousUserMetadata.modelId,
|
|
};
|
|
}
|
|
|
|
if (agentName) {
|
|
const agentSelection = getAgentModelForSession(sessionId, agentName);
|
|
if (agentSelection?.providerId && agentSelection?.modelId) {
|
|
return agentSelection;
|
|
}
|
|
}
|
|
|
|
const sessionSelection = getSessionModelSelection(sessionId);
|
|
if (sessionSelection?.providerId && sessionSelection?.modelId) {
|
|
return sessionSelection;
|
|
}
|
|
|
|
return null;
|
|
}, [isUser, sessionId, agentName, previousUserMetadata, getAgentModelForSession, getSessionModelSelection]);
|
|
|
|
const providerID = React.useMemo(() => {
|
|
if (isUser) return null;
|
|
if (typeof messageProviderID === 'string' && messageProviderID.trim().length > 0) {
|
|
return messageProviderID;
|
|
}
|
|
return contextModelSelection?.providerId ?? null;
|
|
}, [isUser, messageProviderID, contextModelSelection]);
|
|
|
|
const modelID = React.useMemo(() => {
|
|
if (isUser) return null;
|
|
if (typeof messageModelID === 'string' && messageModelID.trim().length > 0) {
|
|
return messageModelID;
|
|
}
|
|
return contextModelSelection?.modelId ?? null;
|
|
}, [isUser, messageModelID, contextModelSelection]);
|
|
|
|
const modelName = React.useMemo(() => {
|
|
if (isUser) return undefined;
|
|
|
|
const provider = providerID && providers.length > 0
|
|
? providers.find((p) => p.id === providerID)
|
|
: undefined;
|
|
return getProviderModelDisplayName(provider, modelID) || undefined;
|
|
}, [isUser, providerID, modelID, providers]);
|
|
|
|
const modelHasVariants = React.useMemo(() => {
|
|
if (isUser) return false;
|
|
if (!providerID || !modelID) return false;
|
|
|
|
const provider = providers.find((p) => p.id === providerID);
|
|
if (!provider?.models || !Array.isArray(provider.models)) {
|
|
return false;
|
|
}
|
|
|
|
const model = provider.models.find((m: Record<string, unknown>) => (m as Record<string, unknown>).id === modelID) as
|
|
| { variants?: Record<string, unknown> }
|
|
| undefined;
|
|
|
|
const variants = model?.variants;
|
|
return Boolean(variants && Object.keys(variants).length > 0);
|
|
}, [isUser, modelID, providerID, providers]);
|
|
|
|
const displayAgentName = useStickyDisplayValue<string>(agentName);
|
|
const displayProviderIDValue = useStickyDisplayValue<string>(providerID ?? undefined);
|
|
const displayModelName = useStickyDisplayValue<string>(modelName);
|
|
|
|
const headerAgentName = displayAgentName ?? undefined;
|
|
const headerProviderID = displayProviderIDValue ?? null;
|
|
const headerModelName = displayModelName ?? undefined;
|
|
|
|
const messageCompletedAt = React.useMemo(() => {
|
|
const timeInfo = message.info.time as { completed?: number } | undefined;
|
|
return typeof timeInfo?.completed === 'number' ? timeInfo.completed : null;
|
|
}, [message.info.time]);
|
|
|
|
const messageCreatedAt = React.useMemo(() => {
|
|
const timeInfo = message.info.time as { created?: number } | undefined;
|
|
return typeof timeInfo?.created === 'number' ? timeInfo.created : null;
|
|
}, [message.info.time]);
|
|
const isPinnedIntoContext = useGlobalSessionsStore((state) => {
|
|
const session = state.activeSessions.find((candidate) => candidate.id === sessionId)
|
|
?? state.archivedSessions.find((candidate) => candidate.id === sessionId);
|
|
return getContextObligatoryMessages(session).some((entry) => entry.id === message.info.id);
|
|
});
|
|
const [pinPending, setPinPending] = React.useState(false);
|
|
const handleToggleContextPin = React.useCallback(async () => {
|
|
if (!sessionId || !messageCreatedAt || pinPending) return;
|
|
setPinPending(true);
|
|
try {
|
|
const directory = useSessionUIStore.getState().getDirectoryForSession(sessionId);
|
|
await setContextObligatoryMessage(sessionId, directory, {
|
|
id: message.info.id,
|
|
createdAt: messageCreatedAt,
|
|
role: isUser ? 'user' : 'assistant',
|
|
}, !isPinnedIntoContext);
|
|
// Return focus to the composer so the user can keep typing right
|
|
// after adding the message to context (matches the refocus pattern
|
|
// used by the model/agent selectors).
|
|
requestAnimationFrame(focusChatInput);
|
|
} catch (error) {
|
|
console.error('[chat-message] failed to update context pin', error);
|
|
toast.error(t('chat.messageBody.actions.contextPinFailed'));
|
|
} finally {
|
|
setPinPending(false);
|
|
}
|
|
}, [isPinnedIntoContext, isUser, message.info.id, messageCreatedAt, pinPending, sessionId, t]);
|
|
|
|
const isMessageCompleted = React.useMemo(() => {
|
|
if (isUser) return true;
|
|
return Boolean(messageCompletedAt && messageCompletedAt > 0);
|
|
}, [isUser, messageCompletedAt]);
|
|
|
|
const messageFinish = React.useMemo(() => {
|
|
const finish = (message.info as { finish?: string }).finish;
|
|
return typeof finish === 'string' ? finish : undefined;
|
|
}, [message.info]);
|
|
|
|
const visibleParts = React.useMemo(
|
|
() =>
|
|
filterVisibleParts(normalizedParts, {
|
|
includeReasoning: showReasoningTraces,
|
|
}),
|
|
[normalizedParts, showReasoningTraces]
|
|
);
|
|
|
|
const displayParts = React.useMemo(() => {
|
|
if (isUser) {
|
|
return visibleParts;
|
|
}
|
|
|
|
if (!isMessageCompleted && chatRenderMode === 'sorted') {
|
|
return [];
|
|
}
|
|
|
|
return visibleParts;
|
|
}, [chatRenderMode, isMessageCompleted, isUser, visibleParts]);
|
|
|
|
|
|
const toolParts = React.useMemo(() => {
|
|
if (isUser) {
|
|
return [];
|
|
}
|
|
const filtered = visibleParts.filter((part) => part.type === 'tool');
|
|
return filtered;
|
|
}, [isUser, visibleParts]);
|
|
|
|
const turnActivityToolParts = React.useMemo(() => {
|
|
if (isUser) {
|
|
return [] as Part[];
|
|
}
|
|
const records = turnGroupingContext?.activityParts ?? [];
|
|
return records
|
|
.filter((record) => record.kind === 'tool')
|
|
.map((record) => record.part)
|
|
.filter((part): part is Part => part.type === 'tool');
|
|
}, [isUser, turnGroupingContext?.activityParts]);
|
|
|
|
const defaultOpenToolIds = React.useMemo(() => {
|
|
if (!showExpandedBashTools && !showExpandedEditTools) {
|
|
return new Set<string>();
|
|
}
|
|
|
|
const next = new Set<string>();
|
|
for (const part of [...toolParts, ...turnActivityToolParts]) {
|
|
const toolId = typeof part?.id === 'string' ? part.id : '';
|
|
if (!toolId) continue;
|
|
const toolName = normalizeToolName((part as { tool?: string }).tool);
|
|
if (!toolName) continue;
|
|
|
|
if (showExpandedBashTools && BASH_TOOL_NAMES.has(toolName)) {
|
|
next.add(toolId);
|
|
continue;
|
|
}
|
|
if (showExpandedEditTools && EDIT_TOOL_NAMES.has(toolName)) {
|
|
next.add(toolId);
|
|
}
|
|
}
|
|
|
|
return next;
|
|
}, [showExpandedBashTools, showExpandedEditTools, toolParts, turnActivityToolParts]);
|
|
|
|
const effectiveExpandedTools = React.useMemo(() => {
|
|
if (defaultOpenToolIds.size === 0 && collapsedTools.size === 0) {
|
|
return expandedTools;
|
|
}
|
|
|
|
const next = new Set(expandedTools);
|
|
defaultOpenToolIds.forEach((toolId) => {
|
|
if (!collapsedTools.has(toolId)) {
|
|
next.add(toolId);
|
|
}
|
|
});
|
|
collapsedTools.forEach((toolId) => {
|
|
next.delete(toolId);
|
|
});
|
|
return next;
|
|
}, [collapsedTools, defaultOpenToolIds, expandedTools]);
|
|
|
|
const agentMention = React.useMemo(() => {
|
|
if (!isUser) {
|
|
return undefined;
|
|
}
|
|
const mentionPart = normalizedParts.find((part) => part.type === 'agent');
|
|
if (!mentionPart) {
|
|
return undefined;
|
|
}
|
|
const partWithName = mentionPart as { name?: string; source?: { value?: string } };
|
|
const name = typeof partWithName.name === 'string' ? partWithName.name : undefined;
|
|
if (!name) {
|
|
return undefined;
|
|
}
|
|
const rawValue = partWithName.source && typeof partWithName.source.value === 'string' && partWithName.source.value.trim().length > 0
|
|
? partWithName.source.value
|
|
: `@${name}`;
|
|
return { name, token: rawValue } satisfies AgentMentionInfo;
|
|
}, [isUser, normalizedParts]);
|
|
|
|
const shouldHideUserMessage = isUser && displayParts.length === 0;
|
|
|
|
const themeVariant = currentTheme?.metadata.variant;
|
|
const isDarkTheme = React.useMemo(() => {
|
|
if (themeVariant) {
|
|
return themeVariant === 'dark';
|
|
}
|
|
if (typeof document !== 'undefined') {
|
|
return document.documentElement.classList.contains('dark');
|
|
}
|
|
return false;
|
|
}, [themeVariant]);
|
|
|
|
const shouldAnimateMessage = React.useMemo(() => {
|
|
if (isUser) return false;
|
|
const freshnessDetector = MessageFreshnessDetector.getInstance();
|
|
return freshnessDetector.shouldAnimateMessage(message.info, message.info.sessionID);
|
|
}, [message.info, isUser]);
|
|
|
|
const [hasStartedStreamingHeader, setHasStartedStreamingHeader] = React.useState(false);
|
|
|
|
const nextRole = React.useMemo(() => {
|
|
if (!nextMessage) return null;
|
|
return deriveMessageRole(nextMessage.info);
|
|
}, [nextMessage]);
|
|
|
|
const hasTurnGrouping = Boolean(turnGroupingContext);
|
|
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
|
|
|
|
const previousIsHiddenUserMessage = React.useMemo(
|
|
() => !isUser && isHiddenUserMessage(previousMessage, { planModeEnabled }),
|
|
[isUser, planModeEnabled, previousMessage]
|
|
);
|
|
|
|
const nextIsHiddenUserMessage = React.useMemo(
|
|
() => !isUser && isHiddenUserMessage(nextMessage, { planModeEnabled }),
|
|
[isUser, planModeEnabled, nextMessage]
|
|
);
|
|
|
|
const isFollowedByAssistant = React.useMemo(() => {
|
|
if (isUser) return false;
|
|
if (hasTurnGrouping) {
|
|
return !isLastAssistantInTurn;
|
|
}
|
|
if (!nextRole) return false;
|
|
return !nextRole.isUser && nextRole.role === 'assistant';
|
|
}, [hasTurnGrouping, isLastAssistantInTurn, isUser, nextRole]);
|
|
|
|
const streamPhase: StreamPhase = React.useMemo(() => {
|
|
if (isMessageCompleted) {
|
|
return 'completed';
|
|
}
|
|
if (isInActiveTurn) {
|
|
return activeStreamingPhase ?? 'streaming';
|
|
}
|
|
return 'completed';
|
|
}, [activeStreamingPhase, isInActiveTurn, isMessageCompleted]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isUser || !animateUserOnMount) {
|
|
return;
|
|
}
|
|
onUserAnimationConsumed?.(message.info.id);
|
|
}, [animateUserOnMount, isUser, message.info.id, onUserAnimationConsumed]);
|
|
|
|
React.useEffect(() => {
|
|
setHasStartedStreamingHeader(false);
|
|
}, [message.info.id]);
|
|
|
|
React.useEffect(() => {
|
|
const headerMessageId = assistantHeaderMessageId ?? turnGroupingContext?.headerMessageId;
|
|
if (isUser || !headerMessageId || headerMessageId !== message.info.id) {
|
|
return;
|
|
}
|
|
|
|
const isCurrentlyStreaming = streamPhase === 'streaming' || streamPhase === 'cooldown';
|
|
if (isCurrentlyStreaming) {
|
|
setHasStartedStreamingHeader(true);
|
|
}
|
|
}, [assistantHeaderMessageId, isUser, message.info.id, streamPhase, turnGroupingContext?.headerMessageId]);
|
|
|
|
const shouldShowHeader = React.useMemo(() => {
|
|
if (isUser) return true;
|
|
|
|
// Use turn grouping context if available for more precise control
|
|
const headerMessageId = assistantHeaderMessageId ?? turnGroupingContext?.headerMessageId;
|
|
if (headerMessageId) {
|
|
// For turn grouping: only show header for the first assistant message in the turn
|
|
const isFirstAssistantInTurn = message.info.id === headerMessageId;
|
|
|
|
if (isFirstAssistantInTurn) {
|
|
// For completed messages, always show header (historical messages)
|
|
if (streamPhase === 'completed') {
|
|
return true;
|
|
}
|
|
|
|
// For streaming messages: show header when streaming starts and keep it visible
|
|
const isCurrentlyStreaming = streamPhase === 'streaming' || streamPhase === 'cooldown';
|
|
return hasStartedStreamingHeader || isCurrentlyStreaming;
|
|
}
|
|
|
|
// For non-first assistant messages, don't show header
|
|
return false;
|
|
}
|
|
|
|
// Ungrouped fallback path: always show assistant header.
|
|
return true;
|
|
}, [assistantHeaderMessageId, hasStartedStreamingHeader, isUser, turnGroupingContext, streamPhase, message.info.id]);
|
|
|
|
const handleCopyCode = React.useCallback((code: string) => {
|
|
void copyTextToClipboard(code).then((result) => {
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
setCopiedCode(code);
|
|
setTimeout(() => setCopiedCode(null), 2000);
|
|
});
|
|
}, []);
|
|
|
|
const headerVariantRaw = !isUser ? (turnGroupingContext?.userMessageVariant ?? previousUserMetadata?.variant) : undefined;
|
|
|
|
const headerVariant = !isUser && modelHasVariants ? (headerVariantRaw ?? 'Default') : undefined;
|
|
|
|
// Summary body removed — flat rendering means text is always inline.
|
|
|
|
const assistantError = React.useMemo(() => {
|
|
if (isUser) {
|
|
return undefined;
|
|
}
|
|
const errorInfo = (message.info as { error?: unknown } | undefined)?.error as
|
|
| { data?: { message?: unknown }; message?: unknown; name?: unknown }
|
|
| undefined;
|
|
if (!errorInfo) {
|
|
return undefined;
|
|
}
|
|
const dataMessage = typeof errorInfo.data?.message === 'string' ? errorInfo.data.message : undefined;
|
|
const errorMessage = typeof errorInfo.message === 'string' ? errorInfo.message : undefined;
|
|
const errorName = typeof errorInfo.name === 'string' ? errorInfo.name : undefined;
|
|
const detail = dataMessage || errorMessage || errorName;
|
|
if (!detail) {
|
|
return undefined;
|
|
}
|
|
if (errorName === 'SessionRetry') {
|
|
return {
|
|
text: `Opencode failed to send a message. Retry attempt info: ${detail}`,
|
|
};
|
|
}
|
|
if (isLikelyProviderAuthFailure(detail)) {
|
|
return {
|
|
text: PROVIDER_AUTH_FAILURE_MESSAGE,
|
|
};
|
|
}
|
|
if (detail.trim().toLowerCase() === 'aborted') {
|
|
return {
|
|
text: 'The running turn was stopped before OpenCode could send the next message.',
|
|
};
|
|
}
|
|
return {
|
|
text: `Opencode failed to send message with error: ${detail}`,
|
|
};
|
|
}, [isUser, message.info]);
|
|
|
|
const assistantErrorText = assistantError?.text;
|
|
|
|
const messageTextContent = React.useMemo(() => {
|
|
if (isUser) {
|
|
return flattenUserTextParts(displayParts);
|
|
}
|
|
|
|
if (assistantErrorText && assistantErrorText.trim().length > 0) {
|
|
return assistantErrorText;
|
|
}
|
|
|
|
return flattenAssistantTextParts(displayParts);
|
|
}, [assistantErrorText, displayParts, isUser]);
|
|
|
|
const hasTextContent = messageTextContent.length > 0;
|
|
|
|
const handleCopyMessage = React.useCallback(async () => {
|
|
let result;
|
|
if (isUser) {
|
|
result = await copyTextToClipboard(messageTextContent);
|
|
} else {
|
|
const { renderMarkdownSync } = await import('./markdown/markdownCore');
|
|
result = await copyMarkdownToClipboard(messageTextContent, renderMarkdownSync(messageTextContent));
|
|
}
|
|
if (!result.ok) {
|
|
return false;
|
|
}
|
|
if (isUser) {
|
|
setCopiedMessage(true);
|
|
setTimeout(() => setCopiedMessage(false), 2000);
|
|
}
|
|
return true;
|
|
}, [isUser, messageTextContent]);
|
|
|
|
const handleRevert = React.useCallback(() => {
|
|
if (!sessionId || !message.info.id) return;
|
|
useSessionUIStore.getState().revertToMessage(sessionId, message.info.id);
|
|
}, [sessionId, message.info.id]);
|
|
|
|
// NEW: Fork handler
|
|
const handleFork = React.useCallback(() => {
|
|
if (!sessionId || !message.info.id) return;
|
|
useSessionUIStore.getState().forkFromMessage(sessionId, message.info.id);
|
|
}, [sessionId, message.info.id]);
|
|
|
|
const handleToggleTool = React.useCallback((toolId: string) => {
|
|
const isDefaultOpen = defaultOpenToolIds.has(toolId);
|
|
const isCurrentlyExpanded = effectiveExpandedTools.has(toolId);
|
|
|
|
if (isDefaultOpen) {
|
|
setCollapsedTools((prev) => {
|
|
const next = new Set(prev);
|
|
if (isCurrentlyExpanded) {
|
|
next.add(toolId);
|
|
} else {
|
|
next.delete(toolId);
|
|
}
|
|
writeCollapsedToolsCache(message.info.id, next);
|
|
return next;
|
|
});
|
|
|
|
if (!isCurrentlyExpanded) {
|
|
setExpandedTools((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(toolId);
|
|
writeExpandedToolsCache(message.info.id, next);
|
|
return next;
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
setExpandedTools((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(toolId)) {
|
|
next.delete(toolId);
|
|
} else {
|
|
next.add(toolId);
|
|
}
|
|
writeExpandedToolsCache(message.info.id, next);
|
|
return next;
|
|
});
|
|
|
|
setCollapsedTools((prev) => {
|
|
if (!prev.has(toolId)) {
|
|
return prev;
|
|
}
|
|
const next = new Set(prev);
|
|
next.delete(toolId);
|
|
writeCollapsedToolsCache(message.info.id, next);
|
|
return next;
|
|
});
|
|
}, [defaultOpenToolIds, effectiveExpandedTools, message.info.id]);
|
|
|
|
const hasEverStreamedRef = React.useRef(false);
|
|
|
|
React.useEffect(() => {
|
|
hasEverStreamedRef.current = false;
|
|
}, [message.info.id]);
|
|
|
|
const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
|
|
|
|
const handleShowPopup = React.useCallback((content: ToolPopupContent) => {
|
|
|
|
if (content.image || content.mermaid) {
|
|
setPopupContent(content);
|
|
setImagePreviewOpen(true);
|
|
}
|
|
}, [setImagePreviewOpen]);
|
|
|
|
const handlePopupChange = React.useCallback((open: boolean) => {
|
|
setPopupContent((prev) => ({ ...prev, open }));
|
|
setImagePreviewOpen(open);
|
|
}, [setImagePreviewOpen]);
|
|
|
|
const isAnimationSettled = Boolean(getMessageInfoProp(message.info, 'animationSettled'));
|
|
const isStreamingPhase = streamPhase === 'streaming' || streamPhase === 'cooldown';
|
|
|
|
if (isStreamingPhase) {
|
|
hasEverStreamedRef.current = true;
|
|
}
|
|
|
|
const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase && !hasEverStreamedRef.current;
|
|
|
|
if (shouldHideUserMessage) {
|
|
return null;
|
|
}
|
|
|
|
const assistantTopPaddingClass = !isUser && shouldShowHeader && !previousIsHiddenUserMessage
|
|
? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0')
|
|
: 'pt-0';
|
|
const userMessageRadius = 'var(--radius-xl)';
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={cn(
|
|
'group w-full',
|
|
isUser ? (isMobile ? 'pt-2' : 'pt-4') : assistantTopPaddingClass,
|
|
isUser ? 'pb-0' : (isFollowedByAssistant || nextIsHiddenUserMessage) ? 'pb-0' : 'pb-2'
|
|
)}
|
|
id={`message-${message.info.id}`}
|
|
data-message-id={message.info.id}
|
|
ref={messageContainerRef}
|
|
>
|
|
<div className="chat-message-column relative">
|
|
{isUser ? (
|
|
displayParts.length === 0 ? null : (
|
|
<FadeInOnReveal
|
|
forceAnimation
|
|
skipAnimation={!animateUserOnMount}
|
|
ignoreContextDisabled
|
|
respectReducedMotion
|
|
>
|
|
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
|
|
{/* peek: the action row under the bubble is suppressed, so
|
|
reserve its gap to the next message here, OUTSIDE the
|
|
bubble background. */}
|
|
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined, chatSurfaceMode === 'peek' ? 'pb-3' : undefined)}>
|
|
<div
|
|
style={{
|
|
backgroundColor: 'var(--chat-user-message-bg)',
|
|
borderRadius: userMessageRadius,
|
|
borderBottomRightRadius: 'var(--radius-sm)',
|
|
}}
|
|
className="px-5 py-3 shadow-none border border-primary/5"
|
|
>
|
|
<MessageBody
|
|
messageId={message.info.id}
|
|
parts={displayParts}
|
|
isUser={isUser}
|
|
isMessageCompleted={isMessageCompleted}
|
|
messageFinish={messageFinish}
|
|
messageCreatedAt={messageCreatedAt ?? undefined}
|
|
isMobile={isMobile}
|
|
alwaysShowActions={alwaysShowMessageActions}
|
|
hasTouchInput={hasTouchInput}
|
|
copiedCode={copiedCode}
|
|
onCopyCode={handleCopyCode}
|
|
expandedTools={expandedTools}
|
|
onToggleTool={handleToggleTool}
|
|
onShowPopup={handleShowPopup}
|
|
streamPhase={streamPhase}
|
|
allowAnimation={allowAnimation}
|
|
shouldShowHeader={false}
|
|
hasTextContent={hasTextContent}
|
|
onCopyMessage={handleCopyMessage}
|
|
copiedMessage={copiedMessage}
|
|
showReasoningTraces={showReasoningTraces}
|
|
agentMention={agentMention}
|
|
onRevert={handleRevert}
|
|
onFork={isUser ? handleFork : undefined}
|
|
contextPinned={isPinnedIntoContext}
|
|
contextPinPending={pinPending}
|
|
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
|
|
errorMessage={assistantErrorText}
|
|
userActionsMode={useExternalUserActionsRow ? 'external-content' : 'inline'}
|
|
stickyUserHeaderEnabled={stickyUserHeader}
|
|
/>
|
|
</div>
|
|
{useExternalUserActionsRow ? (
|
|
<MessageBody
|
|
messageId={message.info.id}
|
|
parts={displayParts}
|
|
isUser={isUser}
|
|
isMessageCompleted={isMessageCompleted}
|
|
messageFinish={messageFinish}
|
|
messageCreatedAt={messageCreatedAt ?? undefined}
|
|
isMobile={isMobile}
|
|
alwaysShowActions={alwaysShowMessageActions}
|
|
hasTouchInput={hasTouchInput}
|
|
copiedCode={copiedCode}
|
|
onCopyCode={handleCopyCode}
|
|
expandedTools={expandedTools}
|
|
onToggleTool={handleToggleTool}
|
|
onShowPopup={handleShowPopup}
|
|
streamPhase={streamPhase}
|
|
allowAnimation={allowAnimation}
|
|
shouldShowHeader={false}
|
|
hasTextContent={hasTextContent}
|
|
onCopyMessage={handleCopyMessage}
|
|
copiedMessage={copiedMessage}
|
|
showReasoningTraces={showReasoningTraces}
|
|
agentMention={agentMention}
|
|
onRevert={handleRevert}
|
|
onFork={isUser ? handleFork : undefined}
|
|
contextPinned={isPinnedIntoContext}
|
|
contextPinPending={pinPending}
|
|
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
|
|
errorMessage={assistantErrorText}
|
|
userActionsMode="external-actions"
|
|
stickyUserHeaderEnabled={stickyUserHeader}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</FadeInOnReveal>
|
|
)
|
|
) : (
|
|
<div className="relative">
|
|
<MessageBody
|
|
sessionId={message.info.sessionID}
|
|
messageId={message.info.id}
|
|
parts={visibleParts}
|
|
isUser={isUser}
|
|
isMessageCompleted={isMessageCompleted}
|
|
messageFinish={messageFinish}
|
|
messageCompletedAt={messageCompletedAt ?? undefined}
|
|
messageCreatedAt={messageCreatedAt ?? undefined}
|
|
contextPinned={isPinnedIntoContext}
|
|
contextPinPending={pinPending}
|
|
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
|
|
isMobile={isMobile}
|
|
alwaysShowActions={alwaysShowMessageActions}
|
|
hasTouchInput={hasTouchInput}
|
|
copiedCode={copiedCode}
|
|
onCopyCode={handleCopyCode}
|
|
expandedTools={effectiveExpandedTools}
|
|
onToggleTool={handleToggleTool}
|
|
onShowPopup={handleShowPopup}
|
|
streamPhase={streamPhase}
|
|
allowAnimation={allowAnimation}
|
|
shouldShowHeader={shouldShowHeader}
|
|
hasTextContent={hasTextContent}
|
|
onCopyMessage={handleCopyMessage}
|
|
copiedMessage={copiedMessage}
|
|
showReasoningTraces={showReasoningTraces}
|
|
agentMention={agentMention}
|
|
turnGroupingContext={turnGroupingContext}
|
|
errorMessage={assistantErrorText}
|
|
reviewTransferDirection={reviewTransferDirection}
|
|
footerProviderID={headerProviderID}
|
|
footerModelName={headerModelName}
|
|
footerAgentName={headerAgentName}
|
|
footerVariant={headerVariant}
|
|
isDarkTheme={isDarkTheme}
|
|
/>
|
|
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<React.Suspense fallback={null}>
|
|
<ToolOutputDialog
|
|
popup={popupContent}
|
|
onOpenChange={handlePopupChange}
|
|
isMobile={isMobile}
|
|
/>
|
|
</React.Suspense>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default React.memo(ChatMessage, (prev, next) => {
|
|
return areRenderRelevantMessagesEqual(
|
|
{ info: prev.message.info, parts: prev.message.parts },
|
|
{ info: next.message.info, parts: next.message.parts }
|
|
)
|
|
&& areOptionalRenderRelevantMessagesEqual(
|
|
prev.previousMessage ? { info: prev.previousMessage.info, parts: prev.previousMessage.parts } : undefined,
|
|
next.previousMessage ? { info: next.previousMessage.info, parts: next.previousMessage.parts } : undefined
|
|
)
|
|
&& areOptionalRenderRelevantMessagesEqual(
|
|
prev.nextMessage ? { info: prev.nextMessage.info, parts: prev.nextMessage.parts } : undefined,
|
|
next.nextMessage ? { info: next.nextMessage.info, parts: next.nextMessage.parts } : undefined
|
|
)
|
|
&& prev.isInActiveTurn === next.isInActiveTurn
|
|
&& prev.activeStreamingPhase === next.activeStreamingPhase
|
|
&& prev.reviewTransferDirection === next.reviewTransferDirection
|
|
&& prev.assistantHeaderMessageId === next.assistantHeaderMessageId
|
|
&& prev.animateUserOnMount === next.animateUserOnMount
|
|
&& prev.onUserAnimationConsumed === next.onUserAnimationConsumed
|
|
&& areRelevantTurnGroupingContextsEqual(
|
|
prev.turnGroupingContext,
|
|
next.turnGroupingContext,
|
|
prev.message.info.id,
|
|
deriveMessageRole(prev.message.info).isUser
|
|
);
|
|
});
|