2025-12-07 19:32:53 +02:00
|
|
|
import React from 'react';
|
2026-01-03 13:39:59 +02:00
|
|
|
import type { Part } from '@opencode-ai/sdk/v2';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
import UserTextPart from './parts/UserTextPart';
|
|
|
|
|
import ToolPart from './parts/ToolPart';
|
|
|
|
|
import ProgressiveGroup from './parts/ProgressiveGroup';
|
|
|
|
|
import { MessageFilesDisplay } from '../FileAttachment';
|
2026-01-03 13:39:59 +02:00
|
|
|
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
2025-12-07 19:32:53 +02:00
|
|
|
import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types';
|
|
|
|
|
import type { TurnGroupingContext } from '../hooks/useTurnGrouping';
|
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
import { isEmptyTextPart, extractTextContent } from './partUtils';
|
|
|
|
|
import { FadeInOnReveal } from './FadeInOnReveal';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
2025-12-09 00:40:43 +02:00
|
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
2026-01-14 01:23:24 +02:00
|
|
|
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine } from '@remixicon/react';
|
2026-01-01 14:59:51 +02:00
|
|
|
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
2025-12-07 19:32:53 +02:00
|
|
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
2025-12-09 00:40:43 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
|
|
|
|
import { useMessageStore } from '@/stores/messageStore';
|
2025-12-09 00:40:43 +02:00
|
|
|
import { useSessionStore } from '@/stores/useSessionStore';
|
2026-01-01 14:59:51 +02:00
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
|
|
|
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
|
|
|
|
import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta';
|
2025-12-09 00:40:43 +02:00
|
|
|
|
2026-01-14 01:23:24 +02:00
|
|
|
const formatTurnDuration = (durationMs: number): string => {
|
|
|
|
|
const totalSeconds = durationMs / 1000;
|
|
|
|
|
if (totalSeconds < 60) {
|
|
|
|
|
return `${totalSeconds.toFixed(1)}s`;
|
|
|
|
|
}
|
|
|
|
|
const minutes = Math.floor(totalSeconds / 60);
|
|
|
|
|
const seconds = Math.round(totalSeconds % 60);
|
|
|
|
|
return `${minutes}m ${seconds}s`;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-17 03:13:37 +02:00
|
|
|
const ACTIVITY_STANDALONE_TOOL_NAMES = new Set<string>(['task']);
|
|
|
|
|
|
|
|
|
|
const isActivityStandaloneTool = (toolName: unknown): boolean => {
|
|
|
|
|
return typeof toolName === 'string' && ACTIVITY_STANDALONE_TOOL_NAMES.has(toolName.toLowerCase());
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
interface MessageBodyProps {
|
|
|
|
|
messageId: string;
|
|
|
|
|
parts: Part[];
|
|
|
|
|
isUser: boolean;
|
|
|
|
|
isMessageCompleted: boolean;
|
2025-12-27 15:51:28 +02:00
|
|
|
messageFinish?: string;
|
2026-01-14 01:23:24 +02:00
|
|
|
messageCompletedAt?: number;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
syntaxTheme: { [key: string]: React.CSSProperties };
|
|
|
|
|
|
|
|
|
|
isMobile: boolean;
|
|
|
|
|
hasTouchInput?: boolean;
|
|
|
|
|
copiedCode: string | null;
|
|
|
|
|
onCopyCode: (code: string) => void;
|
|
|
|
|
expandedTools: Set<string>;
|
|
|
|
|
onToggleTool: (toolId: string) => void;
|
|
|
|
|
onShowPopup: (content: ToolPopupContent) => void;
|
|
|
|
|
streamPhase: StreamPhase;
|
|
|
|
|
allowAnimation: boolean;
|
|
|
|
|
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
|
|
|
|
|
|
|
|
|
|
shouldShowHeader?: boolean;
|
|
|
|
|
hasTextContent?: boolean;
|
|
|
|
|
onCopyMessage?: () => void;
|
|
|
|
|
copiedMessage?: boolean;
|
|
|
|
|
onAuxiliaryContentComplete?: () => void;
|
|
|
|
|
showReasoningTraces?: boolean;
|
|
|
|
|
agentMention?: AgentMentionInfo;
|
|
|
|
|
turnGroupingContext?: TurnGroupingContext;
|
2025-12-21 00:47:43 +02:00
|
|
|
onRevert?: () => void;
|
2026-01-02 18:33:35 -05:00
|
|
|
onFork?: () => void;
|
2026-01-01 14:25:46 +02:00
|
|
|
errorMessage?: string;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const UserMessageBody: React.FC<{
|
|
|
|
|
messageId: string;
|
|
|
|
|
parts: Part[];
|
|
|
|
|
isMobile: boolean;
|
|
|
|
|
hasTouchInput?: boolean;
|
|
|
|
|
hasTextContent?: boolean;
|
|
|
|
|
onCopyMessage?: () => void;
|
|
|
|
|
copiedMessage?: boolean;
|
|
|
|
|
onShowPopup: (content: ToolPopupContent) => void;
|
|
|
|
|
agentMention?: AgentMentionInfo;
|
2025-12-21 00:47:43 +02:00
|
|
|
onRevert?: () => void;
|
2026-01-02 18:33:35 -05:00
|
|
|
onFork?: () => void;
|
|
|
|
|
}> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork }) => {
|
2025-12-07 19:32:53 +02:00
|
|
|
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
|
|
|
|
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
|
|
|
|
|
|
|
|
|
const textParts = React.useMemo(() => {
|
|
|
|
|
return parts.filter((part) => {
|
|
|
|
|
if (part.type !== 'text') return false;
|
|
|
|
|
return !isEmptyTextPart(part);
|
|
|
|
|
});
|
|
|
|
|
}, [parts]);
|
|
|
|
|
|
|
|
|
|
const mentionToken = agentMention?.token;
|
|
|
|
|
let mentionInjected = false;
|
|
|
|
|
|
|
|
|
|
const canCopyMessage = Boolean(onCopyMessage);
|
|
|
|
|
const isMessageCopied = Boolean(copiedMessage);
|
|
|
|
|
const isTouchContext = Boolean(hasTouchInput ?? isMobile);
|
|
|
|
|
const hasCopyableText = Boolean(hasTextContent);
|
|
|
|
|
|
|
|
|
|
const clearCopyHintTimeout = React.useCallback(() => {
|
|
|
|
|
if (copyHintTimeoutRef.current !== null && typeof window !== 'undefined') {
|
|
|
|
|
window.clearTimeout(copyHintTimeoutRef.current);
|
|
|
|
|
copyHintTimeoutRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const revealCopyHint = React.useCallback(() => {
|
|
|
|
|
if (!isTouchContext || !canCopyMessage || !hasCopyableText || typeof window === 'undefined') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearCopyHintTimeout();
|
|
|
|
|
setCopyHintVisible(true);
|
|
|
|
|
copyHintTimeoutRef.current = window.setTimeout(() => {
|
|
|
|
|
setCopyHintVisible(false);
|
|
|
|
|
copyHintTimeoutRef.current = null;
|
|
|
|
|
}, 1800);
|
|
|
|
|
}, [canCopyMessage, clearCopyHintTimeout, hasCopyableText, isTouchContext]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!hasCopyableText) {
|
|
|
|
|
setCopyHintVisible(false);
|
|
|
|
|
clearCopyHintTimeout();
|
|
|
|
|
}
|
|
|
|
|
}, [clearCopyHintTimeout, hasCopyableText]);
|
|
|
|
|
|
|
|
|
|
const handleCopyButtonClick = React.useCallback(
|
|
|
|
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
|
|
|
if (!onCopyMessage || !hasCopyableText) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
onCopyMessage();
|
|
|
|
|
|
|
|
|
|
if (isTouchContext) {
|
|
|
|
|
revealCopyHint();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[hasCopyableText, isTouchContext, onCopyMessage, revealCopyHint]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className="relative w-full group/message"
|
|
|
|
|
style={{ contain: 'layout', transform: 'translateZ(0)' }}
|
|
|
|
|
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
|
|
|
|
>
|
2026-01-22 12:21:19 +02:00
|
|
|
<div className="leading-relaxed overflow-hidden text-foreground/90 text-base">
|
2025-12-20 23:44:53 +02:00
|
|
|
{textParts.map((part, index) => {
|
|
|
|
|
let mentionForPart: AgentMentionInfo | undefined;
|
|
|
|
|
if (agentMention && mentionToken && !mentionInjected) {
|
|
|
|
|
const candidateText = extractTextContent(part);
|
|
|
|
|
if (candidateText.includes(mentionToken)) {
|
|
|
|
|
mentionForPart = agentMention;
|
|
|
|
|
mentionInjected = true;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
2025-12-20 23:44:53 +02:00
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<FadeInOnReveal key={`user-text-${index}`}>
|
|
|
|
|
<UserTextPart
|
|
|
|
|
part={part}
|
|
|
|
|
messageId={messageId}
|
|
|
|
|
isMobile={isMobile}
|
|
|
|
|
agentMention={mentionForPart}
|
|
|
|
|
/>
|
|
|
|
|
</FadeInOnReveal>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
2025-12-20 23:44:53 +02:00
|
|
|
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
|
2026-01-02 18:33:35 -05:00
|
|
|
{(canCopyMessage && hasCopyableText) || onRevert || onFork ? (
|
2025-12-20 23:50:23 +02:00
|
|
|
<div className={cn(
|
2026-01-22 12:21:19 +02:00
|
|
|
"mt-1 flex items-center justify-end gap-2"
|
2025-12-20 23:50:23 +02:00
|
|
|
)}>
|
2026-01-02 19:54:16 +02:00
|
|
|
{onRevert && (
|
2025-12-21 00:47:43 +02:00
|
|
|
<Tooltip delayDuration={1000}>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
|
|
|
|
aria-label="Revert to this message"
|
|
|
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
onRevert();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<RiArrowGoBackLine className="h-3.5 w-3.5" />
|
|
|
|
|
</Button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
)}
|
2026-01-02 18:33:35 -05:00
|
|
|
{onFork && (
|
|
|
|
|
<Tooltip delayDuration={1000}>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
|
|
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
onFork();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<RiGitBranchLine className="h-3.5 w-3.5" />
|
|
|
|
|
</Button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
)}
|
2025-12-21 00:47:43 +02:00
|
|
|
{canCopyMessage && hasCopyableText && (
|
|
|
|
|
<Tooltip delayDuration={1000}>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
|
|
|
|
|
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
|
|
|
|
aria-label="Copy message text"
|
|
|
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
|
|
|
onClick={handleCopyButtonClick}
|
|
|
|
|
onFocus={() => setCopyHintVisible(true)}
|
|
|
|
|
onBlur={() => {
|
|
|
|
|
if (!isMessageCopied) {
|
|
|
|
|
setCopyHintVisible(false);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{isMessageCopied ? (
|
|
|
|
|
<RiCheckLine className="h-3.5 w-3.5 text-[color:var(--status-success)]" />
|
|
|
|
|
) : (
|
|
|
|
|
<RiFileCopyLine className="h-3.5 w-3.5" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent sideOffset={6}>Copy message</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
)}
|
2025-12-20 23:44:53 +02:00
|
|
|
</div>
|
2025-12-21 00:47:43 +02:00
|
|
|
) : null}
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|
|
|
|
messageId,
|
|
|
|
|
parts,
|
|
|
|
|
isMessageCompleted,
|
2025-12-27 15:51:28 +02:00
|
|
|
messageFinish,
|
2026-01-14 01:23:24 +02:00
|
|
|
messageCompletedAt,
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
syntaxTheme,
|
|
|
|
|
isMobile,
|
|
|
|
|
hasTouchInput,
|
|
|
|
|
expandedTools,
|
|
|
|
|
onToggleTool,
|
|
|
|
|
onShowPopup,
|
|
|
|
|
streamPhase: _streamPhase,
|
|
|
|
|
allowAnimation: _allowAnimation,
|
|
|
|
|
onContentChange,
|
|
|
|
|
hasTextContent = false,
|
|
|
|
|
onCopyMessage,
|
|
|
|
|
copiedMessage = false,
|
|
|
|
|
onAuxiliaryContentComplete,
|
|
|
|
|
showReasoningTraces = false,
|
|
|
|
|
turnGroupingContext,
|
2026-01-01 14:25:46 +02:00
|
|
|
errorMessage,
|
2025-12-07 19:32:53 +02:00
|
|
|
}) => {
|
|
|
|
|
|
|
|
|
|
void _allowAnimation;
|
|
|
|
|
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
|
|
|
|
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
|
|
|
|
|
|
|
|
|
const canCopyMessage = Boolean(onCopyMessage);
|
|
|
|
|
const isMessageCopied = Boolean(copiedMessage);
|
|
|
|
|
const isTouchContext = Boolean(hasTouchInput ?? isMobile);
|
|
|
|
|
const awaitingMessageCompletion = !isMessageCompleted;
|
|
|
|
|
|
|
|
|
|
const visibleParts = React.useMemo(() => {
|
|
|
|
|
return parts
|
|
|
|
|
.filter((part) => !isEmptyTextPart(part))
|
|
|
|
|
.filter((part) => {
|
|
|
|
|
const rawPart = part as Record<string, unknown>;
|
|
|
|
|
return rawPart.type !== 'compaction';
|
|
|
|
|
});
|
|
|
|
|
}, [parts]);
|
|
|
|
|
|
|
|
|
|
const toolParts = React.useMemo(() => {
|
|
|
|
|
return visibleParts.filter((part): part is ToolPartType => part.type === 'tool');
|
|
|
|
|
}, [visibleParts]);
|
|
|
|
|
|
|
|
|
|
const assistantTextParts = React.useMemo(() => {
|
|
|
|
|
return visibleParts.filter((part) => part.type === 'text');
|
|
|
|
|
}, [visibleParts]);
|
|
|
|
|
|
2025-12-09 00:40:43 +02:00
|
|
|
const createSessionFromAssistantMessage = useSessionStore((state) => state.createSessionFromAssistantMessage);
|
2026-01-01 14:59:51 +02:00
|
|
|
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
|
2025-12-09 00:40:43 +02:00
|
|
|
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
|
2025-12-27 15:51:28 +02:00
|
|
|
const hasStopFinish = messageFinish === 'stop';
|
2025-12-09 00:40:43 +02:00
|
|
|
|
2025-12-09 12:04:27 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const hasTools = toolParts.length > 0;
|
|
|
|
|
|
|
|
|
|
const hasPendingTools = React.useMemo(() => {
|
|
|
|
|
return toolParts.some((toolPart) => {
|
|
|
|
|
const state = (toolPart as Record<string, unknown>).state as Record<string, unknown> | undefined ?? {};
|
|
|
|
|
const status = state?.status;
|
|
|
|
|
return status === 'pending' || status === 'running' || status === 'started';
|
|
|
|
|
});
|
|
|
|
|
}, [toolParts]);
|
|
|
|
|
|
|
|
|
|
const isToolFinalized = React.useCallback((toolPart: ToolPartType) => {
|
|
|
|
|
const state = (toolPart as Record<string, unknown>).state as Record<string, unknown> | undefined ?? {};
|
|
|
|
|
const status = state?.status;
|
|
|
|
|
if (status === 'pending' || status === 'running' || status === 'started') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const time = state?.time as Record<string, unknown> | undefined ?? {};
|
|
|
|
|
const endTime = typeof time?.end === 'number' ? time.end : undefined;
|
|
|
|
|
const startTime = typeof time?.start === 'number' ? time.start : undefined;
|
|
|
|
|
if (typeof endTime !== 'number') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (typeof startTime === 'number' && endTime < startTime) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const allToolsFinalized = React.useMemo(() => {
|
|
|
|
|
if (toolParts.length === 0) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (hasPendingTools) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return toolParts.every((toolPart) => isToolFinalized(toolPart));
|
|
|
|
|
}, [toolParts, hasPendingTools, isToolFinalized]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const reasoningParts = React.useMemo(() => {
|
|
|
|
|
return visibleParts.filter((part) => part.type === 'reasoning');
|
|
|
|
|
}, [visibleParts]);
|
|
|
|
|
|
|
|
|
|
const reasoningComplete = React.useMemo(() => {
|
|
|
|
|
if (reasoningParts.length === 0) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return reasoningParts.every((part) => {
|
|
|
|
|
const time = (part as Record<string, unknown>).time as { end?: number } | undefined;
|
|
|
|
|
return typeof time?.end === 'number';
|
|
|
|
|
});
|
|
|
|
|
}, [reasoningParts]);
|
|
|
|
|
|
2025-12-27 15:51:28 +02:00
|
|
|
// Message is considered to have an "open step" if info.finish is not yet present
|
|
|
|
|
const hasOpenStep = typeof messageFinish !== 'string';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const shouldHoldForReasoning =
|
|
|
|
|
reasoningParts.length > 0 &&
|
|
|
|
|
hasTools &&
|
|
|
|
|
(hasPendingTools || hasOpenStep || !allToolsFinalized);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const shouldHoldTools = awaitingMessageCompletion
|
|
|
|
|
|| (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized));
|
|
|
|
|
const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning;
|
|
|
|
|
|
|
|
|
|
const hasAuxiliaryContent = hasTools || reasoningParts.length > 0;
|
|
|
|
|
const isTextlessAssistantMessage = assistantTextParts.length === 0;
|
|
|
|
|
const auxiliaryContentComplete = hasAuxiliaryContent && isTextlessAssistantMessage && !shouldHoldTools && !shouldHoldReasoning && allToolsFinalized && reasoningComplete;
|
|
|
|
|
const auxiliaryCompletionAnnouncedRef = React.useRef(false);
|
|
|
|
|
const soloReasoningScrollTriggeredRef = React.useRef(false);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
soloReasoningScrollTriggeredRef.current = false;
|
|
|
|
|
}, [messageId]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!auxiliaryContentComplete) {
|
|
|
|
|
auxiliaryCompletionAnnouncedRef.current = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (auxiliaryCompletionAnnouncedRef.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
auxiliaryCompletionAnnouncedRef.current = true;
|
|
|
|
|
onAuxiliaryContentComplete?.();
|
|
|
|
|
}, [auxiliaryContentComplete, onAuxiliaryContentComplete]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (awaitingMessageCompletion) {
|
|
|
|
|
soloReasoningScrollTriggeredRef.current = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (hasTools) {
|
|
|
|
|
soloReasoningScrollTriggeredRef.current = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (reasoningParts.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (shouldHoldReasoning || !reasoningComplete) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (soloReasoningScrollTriggeredRef.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
soloReasoningScrollTriggeredRef.current = true;
|
|
|
|
|
onContentChange?.('structural');
|
|
|
|
|
}, [awaitingMessageCompletion, hasTools, onContentChange, reasoningComplete, reasoningParts.length, shouldHoldReasoning]);
|
|
|
|
|
|
|
|
|
|
const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion;
|
|
|
|
|
|
|
|
|
|
const clearCopyHintTimeout = React.useCallback(() => {
|
|
|
|
|
if (copyHintTimeoutRef.current !== null && typeof window !== 'undefined') {
|
|
|
|
|
window.clearTimeout(copyHintTimeoutRef.current);
|
|
|
|
|
copyHintTimeoutRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const revealCopyHint = React.useCallback(() => {
|
|
|
|
|
if (!isTouchContext || !canCopyMessage || !hasCopyableText || typeof window === 'undefined') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearCopyHintTimeout();
|
|
|
|
|
setCopyHintVisible(true);
|
|
|
|
|
copyHintTimeoutRef.current = window.setTimeout(() => {
|
|
|
|
|
setCopyHintVisible(false);
|
|
|
|
|
copyHintTimeoutRef.current = null;
|
|
|
|
|
}, 1800);
|
|
|
|
|
}, [canCopyMessage, clearCopyHintTimeout, hasCopyableText, isTouchContext]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!hasCopyableText) {
|
|
|
|
|
setCopyHintVisible(false);
|
|
|
|
|
clearCopyHintTimeout();
|
|
|
|
|
}
|
|
|
|
|
}, [clearCopyHintTimeout, hasCopyableText]);
|
|
|
|
|
|
|
|
|
|
const handleCopyButtonClick = React.useCallback(
|
|
|
|
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
|
|
|
if (!onCopyMessage || !hasCopyableText) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
onCopyMessage();
|
|
|
|
|
|
|
|
|
|
if (isTouchContext) {
|
|
|
|
|
revealCopyHint();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[hasCopyableText, isTouchContext, onCopyMessage, revealCopyHint]
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-09 00:40:43 +02:00
|
|
|
const handleForkClick = React.useCallback(
|
|
|
|
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
if (!createSessionFromAssistantMessage) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
void createSessionFromAssistantMessage(messageId);
|
|
|
|
|
},
|
|
|
|
|
[createSessionFromAssistantMessage, messageId]
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-01 14:59:51 +02:00
|
|
|
const handleForkMultiRunClick = React.useCallback(
|
|
|
|
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
|
|
const assistantPlanText = flattenAssistantTextParts(assistantTextParts);
|
|
|
|
|
if (!assistantPlanText.trim()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const prefilledPrompt = `${MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT}\n\n${assistantPlanText}`;
|
|
|
|
|
openMultiRunLauncherWithPrompt(prefilledPrompt);
|
|
|
|
|
},
|
|
|
|
|
[assistantTextParts, openMultiRunLauncherWithPrompt]
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
clearCopyHintTimeout();
|
|
|
|
|
};
|
|
|
|
|
}, [clearCopyHintTimeout]);
|
|
|
|
|
|
|
|
|
|
const toolConnections = React.useMemo(() => {
|
|
|
|
|
const connections: Record<string, { hasPrev: boolean; hasNext: boolean }> = {};
|
|
|
|
|
const displayableTools = toolParts.filter((toolPart) => {
|
2025-12-17 03:13:37 +02:00
|
|
|
if (isActivityStandaloneTool(toolPart.tool)) {
|
2025-12-16 21:31:22 +02:00
|
|
|
return false;
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
if (shouldHoldTools) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return isToolFinalized(toolPart);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
displayableTools.forEach((toolPart, index) => {
|
|
|
|
|
connections[toolPart.id] = {
|
|
|
|
|
hasPrev: index > 0,
|
|
|
|
|
hasNext: index < displayableTools.length - 1,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return connections;
|
|
|
|
|
}, [toolParts, shouldHoldTools, isToolFinalized]);
|
|
|
|
|
|
|
|
|
|
const activityPartsForTurn = React.useMemo(() => {
|
|
|
|
|
return turnGroupingContext?.activityParts ?? [];
|
|
|
|
|
}, [turnGroupingContext]);
|
|
|
|
|
|
|
|
|
|
const activityPartsForMessage = React.useMemo(() => {
|
|
|
|
|
if (!turnGroupingContext) return [];
|
|
|
|
|
return activityPartsForTurn.filter((activity) => activity.messageId === messageId);
|
|
|
|
|
}, [activityPartsForTurn, messageId, turnGroupingContext]);
|
|
|
|
|
|
2025-12-24 03:27:51 +02:00
|
|
|
const activityGroupSegmentsForMessage = React.useMemo(() => {
|
|
|
|
|
if (!turnGroupingContext) return [];
|
|
|
|
|
return turnGroupingContext.activityGroupSegments.filter((segment) => segment.anchorMessageId === messageId);
|
|
|
|
|
}, [messageId, turnGroupingContext]);
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const activityPartsByPart = React.useMemo(() => {
|
|
|
|
|
const map = new Map<Part, (typeof activityPartsForMessage)[number]>();
|
|
|
|
|
activityPartsForMessage.forEach((activity) => {
|
|
|
|
|
map.set(activity.part, activity);
|
|
|
|
|
});
|
|
|
|
|
return map;
|
|
|
|
|
}, [activityPartsForMessage]);
|
|
|
|
|
|
2025-12-24 03:27:51 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const visibleActivityPartsForTurn = React.useMemo(() => {
|
|
|
|
|
if (!turnGroupingContext) return [];
|
2025-12-16 21:31:22 +02:00
|
|
|
|
|
|
|
|
const base = !showReasoningTraces
|
|
|
|
|
? activityPartsForTurn.filter((activity) => activity.kind === 'tool')
|
|
|
|
|
: activityPartsForTurn;
|
|
|
|
|
|
2025-12-17 03:13:37 +02:00
|
|
|
// Tools rendered standalone are excluded from Activity group.
|
2025-12-16 21:31:22 +02:00
|
|
|
return base.filter((activity) => {
|
|
|
|
|
if (activity.kind !== 'tool') {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
const toolName = (activity.part as ToolPartType).tool;
|
2025-12-17 03:13:37 +02:00
|
|
|
return !isActivityStandaloneTool(toolName);
|
2025-12-16 21:31:22 +02:00
|
|
|
});
|
2025-12-07 19:32:53 +02:00
|
|
|
}, [activityPartsForTurn, showReasoningTraces, turnGroupingContext]);
|
|
|
|
|
|
|
|
|
|
const [hasEverHadMultipleVisibleActivities, setHasEverHadMultipleVisibleActivities] = React.useState(false);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!turnGroupingContext) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-24 03:27:51 +02:00
|
|
|
|
|
|
|
|
const hasTaskSplitSegments = turnGroupingContext.activityGroupSegments.some(
|
|
|
|
|
(segment) => segment.afterToolPartId !== null
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (visibleActivityPartsForTurn.length > 1 || (hasTaskSplitSegments && visibleActivityPartsForTurn.length > 0)) {
|
2025-12-07 19:32:53 +02:00
|
|
|
setHasEverHadMultipleVisibleActivities(true);
|
|
|
|
|
}
|
|
|
|
|
}, [turnGroupingContext, visibleActivityPartsForTurn.length]);
|
|
|
|
|
|
|
|
|
|
const shouldShowActivityGroup = Boolean(turnGroupingContext && hasEverHadMultipleVisibleActivities);
|
|
|
|
|
|
2025-12-17 03:13:37 +02:00
|
|
|
const shouldRenderActivityGroup = Boolean(
|
|
|
|
|
turnGroupingContext &&
|
|
|
|
|
shouldShowActivityGroup &&
|
2025-12-24 03:27:51 +02:00
|
|
|
visibleActivityPartsForTurn.length > 0 &&
|
|
|
|
|
activityGroupSegmentsForMessage.length > 0
|
2025-12-17 03:13:37 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const standaloneToolParts = React.useMemo(() => {
|
|
|
|
|
return toolParts.filter((toolPart) => isActivityStandaloneTool(toolPart.tool));
|
|
|
|
|
}, [toolParts]);
|
|
|
|
|
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const renderedParts = React.useMemo(() => {
|
|
|
|
|
const rendered: React.ReactNode[] = [];
|
|
|
|
|
|
2025-12-24 03:27:51 +02:00
|
|
|
const renderActivitySegments = (afterToolPartId: string | null) => {
|
2025-12-17 03:13:37 +02:00
|
|
|
if (!turnGroupingContext || !shouldRenderActivityGroup) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-24 03:27:51 +02:00
|
|
|
|
|
|
|
|
activityGroupSegmentsForMessage
|
|
|
|
|
.filter((segment) => (segment.afterToolPartId ?? null) === afterToolPartId)
|
|
|
|
|
.forEach((segment) => {
|
|
|
|
|
const visibleSegmentParts = !showReasoningTraces
|
|
|
|
|
? segment.parts.filter((activity) => activity.kind === 'tool')
|
|
|
|
|
: segment.parts;
|
|
|
|
|
|
|
|
|
|
if (visibleSegmentParts.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rendered.push(
|
|
|
|
|
<ProgressiveGroup
|
|
|
|
|
key={`progressive-group-${segment.id}`}
|
|
|
|
|
parts={visibleSegmentParts}
|
|
|
|
|
isExpanded={turnGroupingContext.isGroupExpanded}
|
|
|
|
|
onToggle={turnGroupingContext.toggleGroup}
|
|
|
|
|
syntaxTheme={syntaxTheme}
|
|
|
|
|
isMobile={isMobile}
|
|
|
|
|
expandedTools={expandedTools}
|
|
|
|
|
onToggleTool={onToggleTool}
|
|
|
|
|
onShowPopup={onShowPopup}
|
|
|
|
|
onContentChange={onContentChange}
|
|
|
|
|
diffStats={turnGroupingContext.diffStats}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
});
|
2025-12-17 03:13:37 +02:00
|
|
|
};
|
|
|
|
|
|
2025-12-24 03:27:51 +02:00
|
|
|
// Activity groups and standalone tasks are interleaved in message order.
|
|
|
|
|
renderActivitySegments(null);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2025-12-17 03:13:37 +02:00
|
|
|
standaloneToolParts.forEach((standaloneToolPart) => {
|
2025-12-16 21:31:22 +02:00
|
|
|
rendered.push(
|
2025-12-17 03:13:37 +02:00
|
|
|
<FadeInOnReveal key={`standalone-tool-${standaloneToolPart.id}`}>
|
2025-12-16 21:31:22 +02:00
|
|
|
<ToolPart
|
2025-12-17 03:13:37 +02:00
|
|
|
part={standaloneToolPart}
|
|
|
|
|
isExpanded={expandedTools.has(standaloneToolPart.id)}
|
2025-12-16 21:31:22 +02:00
|
|
|
onToggle={onToggleTool}
|
|
|
|
|
syntaxTheme={syntaxTheme}
|
|
|
|
|
isMobile={isMobile}
|
|
|
|
|
onContentChange={onContentChange}
|
|
|
|
|
hasPrevTool={false}
|
|
|
|
|
hasNextTool={false}
|
|
|
|
|
/>
|
|
|
|
|
</FadeInOnReveal>
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-24 03:27:51 +02:00
|
|
|
renderActivitySegments(standaloneToolPart.id);
|
|
|
|
|
});
|
2025-12-17 03:13:37 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const partsWithTime: Array<{
|
|
|
|
|
part: Part;
|
|
|
|
|
index: number;
|
|
|
|
|
endTime: number | null;
|
|
|
|
|
element: React.ReactNode;
|
|
|
|
|
}> = [];
|
|
|
|
|
|
|
|
|
|
visibleParts.forEach((part, index) => {
|
|
|
|
|
const activity = activityPartsByPart.get(part);
|
|
|
|
|
if (!activity) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!turnGroupingContext) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let endTime: number | null = null;
|
|
|
|
|
let element: React.ReactNode | null = null;
|
|
|
|
|
|
|
|
|
|
if (!shouldShowActivityGroup) {
|
|
|
|
|
if (activity.kind === 'tool') {
|
|
|
|
|
const toolPart = part as ToolPartType;
|
2025-12-16 21:31:22 +02:00
|
|
|
|
2025-12-17 03:13:37 +02:00
|
|
|
if (isActivityStandaloneTool(toolPart.tool)) {
|
2025-12-16 21:31:22 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const toolState = (toolPart as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state;
|
|
|
|
|
const time = toolState?.time;
|
|
|
|
|
const isFinalized = isToolFinalized(toolPart);
|
|
|
|
|
const shouldShowTool = !shouldHoldTools && isFinalized;
|
|
|
|
|
|
|
|
|
|
if (!shouldShowTool) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const connection = toolConnections[toolPart.id];
|
|
|
|
|
|
|
|
|
|
const toolElement = (
|
|
|
|
|
<FadeInOnReveal key={`tool-${toolPart.id}`}>
|
|
|
|
|
<ToolPart
|
|
|
|
|
part={toolPart}
|
|
|
|
|
isExpanded={expandedTools.has(toolPart.id)}
|
|
|
|
|
onToggle={onToggleTool}
|
|
|
|
|
syntaxTheme={syntaxTheme}
|
|
|
|
|
isMobile={isMobile}
|
|
|
|
|
onContentChange={onContentChange}
|
|
|
|
|
hasPrevTool={connection?.hasPrev ?? false}
|
|
|
|
|
hasNextTool={connection?.hasNext ?? false}
|
|
|
|
|
/>
|
|
|
|
|
</FadeInOnReveal>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
element = toolElement;
|
|
|
|
|
endTime = isFinalized && typeof time?.end === 'number' ? time.end : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (element) {
|
|
|
|
|
partsWithTime.push({
|
|
|
|
|
part,
|
|
|
|
|
index,
|
|
|
|
|
endTime,
|
|
|
|
|
element,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
partsWithTime.sort((a, b) => {
|
|
|
|
|
if (a.endTime === null && b.endTime === null) {
|
|
|
|
|
return a.index - b.index;
|
|
|
|
|
}
|
|
|
|
|
if (a.endTime === null) {
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
if (b.endTime === null) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
return a.endTime - b.endTime;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
partsWithTime.forEach(({ element }) => {
|
|
|
|
|
rendered.push(element);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return rendered;
|
|
|
|
|
}, [
|
|
|
|
|
activityPartsByPart,
|
2025-12-24 03:27:51 +02:00
|
|
|
activityGroupSegmentsForMessage,
|
2025-12-07 19:32:53 +02:00
|
|
|
expandedTools,
|
|
|
|
|
isMobile,
|
|
|
|
|
isToolFinalized,
|
|
|
|
|
onContentChange,
|
|
|
|
|
onShowPopup,
|
|
|
|
|
onToggleTool,
|
|
|
|
|
shouldHoldTools,
|
|
|
|
|
shouldShowActivityGroup,
|
|
|
|
|
showReasoningTraces,
|
|
|
|
|
syntaxTheme,
|
|
|
|
|
toolConnections,
|
|
|
|
|
turnGroupingContext,
|
|
|
|
|
visibleParts,
|
2025-12-17 03:13:37 +02:00
|
|
|
standaloneToolParts,
|
|
|
|
|
shouldRenderActivityGroup,
|
2025-12-07 19:32:53 +02:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const userMessageId = turnGroupingContext?.turnId;
|
|
|
|
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
|
|
|
|
|
|
|
|
|
const rawSummaryBodyFromStore = useMessageStore((state) => {
|
|
|
|
|
if (!userMessageId || !currentSessionId) return undefined;
|
|
|
|
|
const sessionMessages = state.messages.get(currentSessionId);
|
|
|
|
|
if (!sessionMessages) return undefined;
|
|
|
|
|
const userMsg = sessionMessages.find((m) => m.info?.id === userMessageId);
|
|
|
|
|
if (!userMsg) return undefined;
|
|
|
|
|
const summary = (userMsg.info as { summary?: { body?: string | null | undefined } | null | undefined }).summary;
|
|
|
|
|
const body = summary?.body;
|
|
|
|
|
return typeof body === 'string' && body.trim().length > 0 ? body : undefined;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const summaryCandidate =
|
|
|
|
|
typeof turnGroupingContext?.summaryBody === 'string' && turnGroupingContext.summaryBody.trim().length > 0
|
|
|
|
|
? turnGroupingContext.summaryBody
|
|
|
|
|
: rawSummaryBodyFromStore;
|
|
|
|
|
|
|
|
|
|
const summaryBodyRef = React.useRef<string | undefined>(undefined);
|
|
|
|
|
if (summaryCandidate && summaryCandidate.trim().length > 0) {
|
|
|
|
|
summaryBodyRef.current = summaryCandidate;
|
|
|
|
|
}
|
|
|
|
|
const prevUserMessageId = React.useRef(userMessageId);
|
|
|
|
|
if (prevUserMessageId.current !== userMessageId) {
|
|
|
|
|
prevUserMessageId.current = userMessageId;
|
|
|
|
|
summaryBodyRef.current = undefined;
|
|
|
|
|
}
|
|
|
|
|
const summaryBody = summaryBodyRef.current;
|
|
|
|
|
|
|
|
|
|
const showSummaryBody =
|
2025-12-09 12:04:27 +02:00
|
|
|
turnGroupingContext?.isLastAssistantInTurn &&
|
2025-12-07 19:32:53 +02:00
|
|
|
summaryBody &&
|
|
|
|
|
summaryBody.trim().length > 0;
|
|
|
|
|
|
2026-01-01 14:25:46 +02:00
|
|
|
const showErrorMessage = Boolean(errorMessage);
|
|
|
|
|
|
|
|
|
|
const shouldShowFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage));
|
2025-12-09 12:04:27 +02:00
|
|
|
const [isSummaryHovered, setIsSummaryHovered] = React.useState(false);
|
|
|
|
|
|
2026-01-14 01:23:24 +02:00
|
|
|
const turnDurationText = React.useMemo(() => {
|
|
|
|
|
if (!isLastAssistantInTurn || !hasStopFinish) return undefined;
|
|
|
|
|
const userCreatedAt = turnGroupingContext?.userMessageCreatedAt;
|
|
|
|
|
if (typeof userCreatedAt !== 'number' || typeof messageCompletedAt !== 'number') return undefined;
|
|
|
|
|
if (messageCompletedAt <= userCreatedAt) return undefined;
|
|
|
|
|
return formatTurnDuration(messageCompletedAt - userCreatedAt);
|
|
|
|
|
}, [isLastAssistantInTurn, hasStopFinish, turnGroupingContext?.userMessageCreatedAt, messageCompletedAt]);
|
|
|
|
|
|
2025-12-09 12:04:27 +02:00
|
|
|
const footerButtons = (
|
|
|
|
|
<>
|
2026-01-01 14:59:51 +02:00
|
|
|
<Tooltip delayDuration={1000}>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="icon"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
|
|
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
|
|
|
onClick={handleForkClick}
|
|
|
|
|
>
|
|
|
|
|
<RiChatNewLine className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent sideOffset={6}>Start new session from this answer</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
<Tooltip delayDuration={1000}>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="icon"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
|
|
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
|
|
|
onClick={handleForkMultiRunClick}
|
|
|
|
|
>
|
|
|
|
|
<ArrowsMerge className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent sideOffset={6}>Start new multi-run from this answer</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
2025-12-09 12:04:27 +02:00
|
|
|
{onCopyMessage && (
|
|
|
|
|
<Tooltip delayDuration={1000}>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
|
|
|
|
|
className={cn(
|
|
|
|
|
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
2025-12-20 23:44:53 +02:00
|
|
|
!hasCopyableText && 'opacity-50'
|
2025-12-09 12:04:27 +02:00
|
|
|
)}
|
|
|
|
|
disabled={!hasCopyableText}
|
|
|
|
|
aria-label="Copy message text"
|
|
|
|
|
aria-hidden={!hasCopyableText}
|
|
|
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
|
|
|
onClick={handleCopyButtonClick}
|
|
|
|
|
onFocus={() => {
|
|
|
|
|
if (hasCopyableText) {
|
|
|
|
|
setCopyHintVisible(true);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onBlur={() => {
|
|
|
|
|
if (!isMessageCopied) {
|
|
|
|
|
setCopyHintVisible(false);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{isMessageCopied ? (
|
|
|
|
|
<RiCheckLine className="h-3.5 w-3.5 text-[color:var(--status-success)]" />
|
|
|
|
|
) : (
|
|
|
|
|
<RiFileCopyLine className="h-3.5 w-3.5" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent sideOffset={6}>Copy answer</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
2025-12-09 00:40:43 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'relative w-full group/message'
|
|
|
|
|
)}
|
|
|
|
|
style={{
|
|
|
|
|
contain: 'layout',
|
|
|
|
|
transform: 'translateZ(0)',
|
|
|
|
|
}}
|
|
|
|
|
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
|
|
|
|
>
|
2026-01-22 12:21:19 +02:00
|
|
|
<div>
|
2025-12-09 12:04:27 +02:00
|
|
|
<div
|
2026-01-22 12:21:19 +02:00
|
|
|
className="leading-relaxed overflow-hidden text-foreground/90 [&_p:last-child]:mb-0 [&_ul:last-child]:mb-0 [&_ol:last-child]:mb-0"
|
2025-12-09 12:04:27 +02:00
|
|
|
>
|
2025-12-07 19:32:53 +02:00
|
|
|
{renderedParts}
|
2026-01-01 14:25:46 +02:00
|
|
|
{showErrorMessage && (
|
|
|
|
|
<FadeInOnReveal key="assistant-error">
|
|
|
|
|
<div className="group/assistant-text relative break-words">
|
|
|
|
|
<SimpleMarkdownRenderer content={errorMessage ?? ''} />
|
|
|
|
|
</div>
|
|
|
|
|
</FadeInOnReveal>
|
|
|
|
|
)}
|
2025-12-07 19:32:53 +02:00
|
|
|
{showSummaryBody && (
|
|
|
|
|
<FadeInOnReveal key="summary-body">
|
2025-12-09 12:04:27 +02:00
|
|
|
<div
|
|
|
|
|
className="group/assistant-text relative break-words"
|
|
|
|
|
onMouseEnter={() => setIsSummaryHovered(true)}
|
|
|
|
|
onMouseLeave={() => setIsSummaryHovered(false)}
|
|
|
|
|
>
|
|
|
|
|
<SimpleMarkdownRenderer content={summaryBody} />
|
|
|
|
|
{shouldShowFooter && (
|
2026-01-14 01:23:24 +02:00
|
|
|
<div className="mt-2 mb-1 flex items-center justify-between gap-2">
|
|
|
|
|
{turnDurationText ? (
|
|
|
|
|
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
|
|
|
|
<RiHourglassLine className="h-3.5 w-3.5" />
|
|
|
|
|
{turnDurationText}
|
|
|
|
|
</span>
|
|
|
|
|
) : <span />}
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex items-center gap-2 opacity-0 pointer-events-none transition-opacity duration-150 focus-within:opacity-100 focus-within:pointer-events-auto",
|
|
|
|
|
isSummaryHovered && "opacity-100 pointer-events-auto",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{footerButtons}
|
|
|
|
|
</div>
|
2025-12-09 12:04:27 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
2025-12-09 00:40:43 +02:00
|
|
|
</div>
|
|
|
|
|
</FadeInOnReveal>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
|
2025-12-09 12:04:27 +02:00
|
|
|
{!showSummaryBody && shouldShowFooter && (
|
2026-01-14 01:23:24 +02:00
|
|
|
<div className="mt-2 mb-1 flex items-center justify-between gap-2">
|
|
|
|
|
{turnDurationText ? (
|
|
|
|
|
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
|
|
|
|
<RiHourglassLine className="h-3.5 w-3.5" />
|
|
|
|
|
{turnDurationText}
|
|
|
|
|
</span>
|
|
|
|
|
) : <span />}
|
|
|
|
|
<div className="flex items-center gap-2 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/message:opacity-100 group-hover/message:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto">
|
|
|
|
|
{footerButtons}
|
|
|
|
|
</div>
|
2025-12-09 00:40:43 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
2025-12-09 12:04:27 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const MessageBody: React.FC<MessageBodyProps> = ({ isUser, ...props }) => {
|
|
|
|
|
|
|
|
|
|
if (isUser) {
|
|
|
|
|
return (
|
|
|
|
|
<UserMessageBody
|
|
|
|
|
messageId={props.messageId}
|
|
|
|
|
parts={props.parts}
|
|
|
|
|
isMobile={props.isMobile}
|
|
|
|
|
hasTouchInput={props.hasTouchInput}
|
|
|
|
|
hasTextContent={props.hasTextContent}
|
|
|
|
|
onCopyMessage={props.onCopyMessage}
|
|
|
|
|
copiedMessage={props.copiedMessage}
|
|
|
|
|
onShowPopup={props.onShowPopup}
|
|
|
|
|
agentMention={props.agentMention}
|
2025-12-21 00:47:43 +02:00
|
|
|
onRevert={props.onRevert}
|
2026-01-02 18:33:35 -05:00
|
|
|
onFork={props.onFork}
|
2025-12-07 19:32:53 +02:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return <AssistantMessageBody {...props} />;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default React.memo(MessageBody);
|