Show tools and reasoning live in sorted mode Keep ambiguous text deferred until final output Avoid duplicate reasoning outside Activity
1550 lines
64 KiB
TypeScript
1550 lines
64 KiB
TypeScript
import React from 'react';
|
|
import type { Part } from '@opencode-ai/sdk/v2';
|
|
|
|
import UserTextPart from './parts/UserTextPart';
|
|
import ToolPart from './parts/ToolPart';
|
|
import AssistantTextPart from './parts/AssistantTextPart';
|
|
import ReasoningPart from './parts/ReasoningPart';
|
|
import { MessageFilesDisplay } from '../FileAttachment';
|
|
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
|
import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types';
|
|
import type { TurnGroupingContext } from '../lib/turns/types';
|
|
import { cn } from '@/lib/utils';
|
|
import { isEmptyTextPart, extractTextContent } from './partUtils';
|
|
import { FadeInOnReveal } from './FadeInOnReveal';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiVolumeUpLine, RiStopLine, RiImageDownloadLine, RiLoader4Line, RiErrorWarningLine } from '@remixicon/react';
|
|
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
|
|
|
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
|
import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta';
|
|
import { useMessageTTS } from '@/hooks/useMessageTTS';
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
import { TextSelectionMenu } from './TextSelectionMenu';
|
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
|
import { toPng } from 'html-to-image';
|
|
import { toast } from '@/components/ui';
|
|
import { formatTimestampForDisplay } from './timeFormat';
|
|
import { ToolRevealOnMount } from './parts/ToolRevealOnMount';
|
|
import { StaticToolRow } from './parts/ProgressiveGroup';
|
|
import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils';
|
|
import TurnActivity from '../components/TurnActivity';
|
|
|
|
type SubtaskPartLike = Part & {
|
|
type: 'subtask';
|
|
description?: unknown;
|
|
command?: unknown;
|
|
agent?: unknown;
|
|
prompt?: unknown;
|
|
taskSessionID?: unknown;
|
|
model?: {
|
|
providerID?: unknown;
|
|
modelID?: unknown;
|
|
};
|
|
};
|
|
|
|
type ShellActionPartLike = Part & {
|
|
type: 'text';
|
|
shellAction?: {
|
|
command?: unknown;
|
|
output?: unknown;
|
|
status?: unknown;
|
|
};
|
|
};
|
|
|
|
const isSubtaskPart = (part: Part): part is SubtaskPartLike => {
|
|
return part.type === 'subtask';
|
|
};
|
|
|
|
const isShellActionPart = (part: Part): part is ShellActionPartLike => {
|
|
const textPart = part as unknown as { type?: unknown; shellAction?: unknown };
|
|
return textPart.type === 'text' && typeof textPart.shellAction === 'object' && textPart.shellAction !== null;
|
|
};
|
|
|
|
const normalizeSubtaskModel = (model: SubtaskPartLike['model']): string | null => {
|
|
if (!model || typeof model !== 'object') return null;
|
|
const providerID = typeof model.providerID === 'string' ? model.providerID.trim() : '';
|
|
const modelID = typeof model.modelID === 'string' ? model.modelID.trim() : '';
|
|
if (!providerID || !modelID) return null;
|
|
return `${providerID}/${modelID}`;
|
|
};
|
|
|
|
const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
|
|
const [expanded, setExpanded] = React.useState(false);
|
|
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
|
|
|
const description = typeof part.description === 'string' ? part.description.trim() : '';
|
|
const command = typeof part.command === 'string' ? part.command.trim() : '';
|
|
const agent = typeof part.agent === 'string' ? part.agent.trim() : '';
|
|
const prompt = typeof part.prompt === 'string' ? part.prompt.trim() : '';
|
|
const taskSessionID = typeof part.taskSessionID === 'string' ? part.taskSessionID.trim() : '';
|
|
const model = normalizeSubtaskModel(part.model);
|
|
|
|
return (
|
|
<div className="mt-2">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="typography-meta font-semibold text-foreground">Delegated task</span>
|
|
{command ? (
|
|
<span className="inline-flex h-5 items-center rounded px-1.5 text-[11px] leading-none bg-foreground/5 text-muted-foreground">
|
|
/{command}
|
|
</span>
|
|
) : null}
|
|
{agent ? (
|
|
<span className="inline-flex h-5 items-center rounded px-1.5 text-[11px] leading-none bg-foreground/5 text-muted-foreground">
|
|
@{agent}
|
|
</span>
|
|
) : null}
|
|
{model ? (
|
|
<span className="inline-flex h-5 items-center rounded px-1.5 text-[11px] leading-none bg-foreground/5 text-muted-foreground">
|
|
{model}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{description ? (
|
|
<div className="typography-ui-label text-foreground/90 mt-1.5">
|
|
{description}
|
|
</div>
|
|
) : null}
|
|
|
|
{prompt ? (
|
|
<div className="mt-2 border-t border-border/60 pt-1.5">
|
|
<button
|
|
type="button"
|
|
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
|
|
onClick={() => setExpanded((value) => !value)}
|
|
>
|
|
{expanded ? 'Hide prompt' : 'Show prompt'}
|
|
</button>
|
|
{expanded ? (
|
|
<pre className="typography-meta mt-1.5 overflow-x-auto whitespace-pre-wrap break-words text-foreground/85">
|
|
{prompt}
|
|
</pre>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{taskSessionID ? (
|
|
<div className="mt-1.5">
|
|
<button
|
|
type="button"
|
|
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
|
|
onClick={() => {
|
|
void setCurrentSession(taskSessionID);
|
|
}}
|
|
>
|
|
Open subtask session
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part }) => {
|
|
const [expanded, setExpanded] = React.useState(false);
|
|
const [copiedOutput, setCopiedOutput] = React.useState(false);
|
|
const copiedResetTimeoutRef = React.useRef<number | null>(null);
|
|
|
|
const command = typeof part.shellAction?.command === 'string' ? part.shellAction.command.trim() : '';
|
|
const output = typeof part.shellAction?.output === 'string' ? part.shellAction.output : '';
|
|
const status = typeof part.shellAction?.status === 'string' ? part.shellAction.status.trim().toLowerCase() : '';
|
|
const hasOutput = output.trim().length > 0;
|
|
|
|
const clearCopiedResetTimeout = React.useCallback(() => {
|
|
if (copiedResetTimeoutRef.current !== null && typeof window !== 'undefined') {
|
|
window.clearTimeout(copiedResetTimeoutRef.current);
|
|
copiedResetTimeoutRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
clearCopiedResetTimeout();
|
|
};
|
|
}, [clearCopiedResetTimeout]);
|
|
|
|
const copyOutputToClipboard = React.useCallback(async () => {
|
|
if (!hasOutput) return;
|
|
|
|
const result = await copyTextToClipboard(output);
|
|
if (!result.ok) return;
|
|
|
|
clearCopiedResetTimeout();
|
|
setCopiedOutput(true);
|
|
if (typeof window !== 'undefined') {
|
|
copiedResetTimeoutRef.current = window.setTimeout(() => {
|
|
setCopiedOutput(false);
|
|
copiedResetTimeoutRef.current = null;
|
|
}, 2000);
|
|
}
|
|
}, [clearCopiedResetTimeout, hasOutput, output]);
|
|
|
|
return (
|
|
<div className="mt-2">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="typography-meta font-semibold text-foreground">Shell command</span>
|
|
{status ? (
|
|
<span className={cn(
|
|
'inline-flex h-5 items-center rounded px-1.5 text-[11px] leading-none',
|
|
status === 'error'
|
|
? 'bg-[var(--status-error-background)] text-[var(--status-error)]'
|
|
: 'bg-foreground/5 text-muted-foreground'
|
|
)}>
|
|
{status}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{command ? (
|
|
<pre className="typography-meta mt-1.5 overflow-x-auto whitespace-pre-wrap break-words text-foreground/90 font-mono">
|
|
{command}
|
|
</pre>
|
|
) : null}
|
|
|
|
{hasOutput ? (
|
|
<div className="mt-2 border-t border-border/60 pt-1.5">
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<button
|
|
type="button"
|
|
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
|
|
onClick={() => setExpanded((value) => !value)}
|
|
>
|
|
{expanded ? 'Hide output' : 'Show output'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={() => {
|
|
void copyOutputToClipboard();
|
|
}}
|
|
aria-label={copiedOutput ? 'Copied' : 'Copy output'}
|
|
title={copiedOutput ? 'Copied' : 'Copy output'}
|
|
>
|
|
{copiedOutput ? <RiCheckLine className="h-3.5 w-3.5" /> : <RiFileCopyLine className="h-3.5 w-3.5" />}
|
|
</button>
|
|
</div>
|
|
{expanded ? (
|
|
<pre className="typography-meta mt-1.5 max-h-56 overflow-auto whitespace-pre-wrap break-words text-foreground/85 font-mono">
|
|
{output}
|
|
</pre>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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`;
|
|
};
|
|
|
|
|
|
|
|
interface MessageBodyProps {
|
|
sessionId?: string;
|
|
messageId: string;
|
|
parts: Part[];
|
|
isUser: boolean;
|
|
isMessageCompleted: boolean;
|
|
messageFinish?: string;
|
|
messageCompletedAt?: number;
|
|
messageCreatedAt?: number;
|
|
|
|
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;
|
|
onRevert?: () => void;
|
|
onFork?: () => void;
|
|
errorMessage?: string;
|
|
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
|
|
stickyUserHeaderEnabled?: boolean;
|
|
}
|
|
|
|
const TOOL_REVEAL_CACHE_MAX = 200;
|
|
const revealedToolIdsByMessage = new Map<string, Set<string>>();
|
|
|
|
const readRevealedToolIds = (messageId: string): Set<string> => {
|
|
const cached = revealedToolIdsByMessage.get(messageId);
|
|
return cached ? new Set(cached) : new Set<string>();
|
|
};
|
|
|
|
const writeRevealedToolIds = (messageId: string, value: Set<string>): void => {
|
|
if (revealedToolIdsByMessage.size >= TOOL_REVEAL_CACHE_MAX && !revealedToolIdsByMessage.has(messageId)) {
|
|
const oldest = revealedToolIdsByMessage.keys().next().value;
|
|
if (oldest) {
|
|
revealedToolIdsByMessage.delete(oldest);
|
|
}
|
|
}
|
|
revealedToolIdsByMessage.set(messageId, new Set(value));
|
|
};
|
|
|
|
const UserMessageBody: React.FC<{
|
|
messageId: string;
|
|
parts: Part[];
|
|
isMobile: boolean;
|
|
hasTouchInput?: boolean;
|
|
hasTextContent?: boolean;
|
|
onCopyMessage?: () => void;
|
|
copiedMessage?: boolean;
|
|
onShowPopup: (content: ToolPopupContent) => void;
|
|
agentMention?: AgentMentionInfo;
|
|
onRevert?: () => void;
|
|
onFork?: () => void;
|
|
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
|
|
stickyUserHeaderEnabled?: boolean;
|
|
}> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }) => {
|
|
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
|
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
|
|
|
const userContentParts = React.useMemo(() => {
|
|
return parts.filter((part) => {
|
|
if (part.type === 'text') {
|
|
return !isEmptyTextPart(part);
|
|
}
|
|
if (isSubtaskPart(part)) {
|
|
return true;
|
|
}
|
|
if (isShellActionPart(part)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
}, [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 showUserContent = userActionsMode !== 'external-actions';
|
|
const showUserActions = userActionsMode !== 'external-content';
|
|
const useStickyScrollableUserContent = stickyUserHeaderEnabled && userActionsMode === 'inline';
|
|
|
|
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]
|
|
);
|
|
|
|
const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || onFork) && showUserActions ? (
|
|
<div className={cn(
|
|
'group/user-actions',
|
|
isMobile
|
|
? userActionsMode === 'inline'
|
|
? 'flex items-center justify-end pt-2 pb-3'
|
|
: stickyUserHeaderEnabled
|
|
? 'flex h-9 items-start justify-end pt-0'
|
|
: 'flex h-11 items-start justify-end pt-0'
|
|
: userActionsMode === 'inline'
|
|
? 'absolute top-full left-0 right-0 z-10 pt-5'
|
|
: 'flex h-8 items-start justify-end pt-2'
|
|
)}>
|
|
<div
|
|
className={cn(
|
|
'flex items-center justify-end gap-1',
|
|
isMobile
|
|
? userActionsMode === 'inline'
|
|
? 'translate-x-5'
|
|
: 'translate-x-0'
|
|
: userActionsMode === 'inline'
|
|
? 'translate-x-5'
|
|
: 'translate-x-0',
|
|
isMobile
|
|
? 'pointer-events-auto opacity-100'
|
|
: 'pointer-events-none opacity-0 transition-opacity duration-150 group-hover/message:pointer-events-auto group-hover/message:opacity-100 group-hover/user-actions:pointer-events-auto group-hover/user-actions:opacity-100 group-hover/user-shell:pointer-events-auto group-hover/user-shell:opacity-100'
|
|
)}
|
|
>
|
|
{onRevert && (
|
|
<Tooltip delayDuration={1000}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 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 w-3" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
{onFork && (
|
|
<Tooltip delayDuration={1000}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 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="Fork from this message"
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onFork();
|
|
}}
|
|
>
|
|
<RiGitBranchLine className="h-3 w-3" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
{canCopyMessage && hasCopyableText && (
|
|
<Tooltip delayDuration={1000}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
|
|
className="h-6 w-6 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 w-3 text-[color:var(--status-success)]" />
|
|
) : (
|
|
<RiFileCopyLine className="h-3 w-3" />
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent sideOffset={6}>Copy message</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null;
|
|
|
|
if (!showUserContent) {
|
|
return <>{actionsBlock}</>;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="relative w-full group/message"
|
|
style={{ contain: 'layout', transform: 'translateZ(0)' }}
|
|
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'leading-relaxed text-foreground/90 text-base overflow-x-hidden',
|
|
useStickyScrollableUserContent
|
|
? 'overflow-y-auto overscroll-contain scrollbar-none'
|
|
: 'overflow-y-hidden'
|
|
)}
|
|
style={useStickyScrollableUserContent ? { maxHeight: 'calc(var(--chat-scroll-height, 100dvh) * 0.4)' } : undefined}
|
|
>
|
|
{userContentParts.map((part, index) => {
|
|
if (isSubtaskPart(part)) {
|
|
return (
|
|
<React.Fragment key={part.id ?? `user-subtask-${index}`}>
|
|
<UserSubtaskPart part={part} />
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
|
|
if (isShellActionPart(part)) {
|
|
return (
|
|
<React.Fragment key={part.id ?? `user-shell-${index}`}>
|
|
<UserShellActionPart part={part} />
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
|
|
let mentionForPart: AgentMentionInfo | undefined;
|
|
if (agentMention && mentionToken && !mentionInjected) {
|
|
const candidateText = extractTextContent(part);
|
|
if (candidateText.includes(mentionToken)) {
|
|
mentionForPart = agentMention;
|
|
mentionInjected = true;
|
|
}
|
|
}
|
|
return (
|
|
<React.Fragment key={part.id ?? `user-text-${index}`}>
|
|
<UserTextPart
|
|
part={part}
|
|
messageId={messageId}
|
|
isMobile={isMobile}
|
|
agentMention={mentionForPart}
|
|
/>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} compact />
|
|
{actionsBlock}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|
sessionId,
|
|
messageId,
|
|
parts,
|
|
isMessageCompleted,
|
|
messageFinish,
|
|
messageCompletedAt,
|
|
messageCreatedAt,
|
|
|
|
syntaxTheme,
|
|
isMobile,
|
|
hasTouchInput,
|
|
expandedTools,
|
|
onToggleTool,
|
|
onShowPopup,
|
|
streamPhase: _streamPhase,
|
|
allowAnimation: _allowAnimation,
|
|
onContentChange,
|
|
hasTextContent = false,
|
|
onCopyMessage,
|
|
copiedMessage = false,
|
|
onAuxiliaryContentComplete,
|
|
showReasoningTraces = false,
|
|
turnGroupingContext,
|
|
errorMessage,
|
|
}) => {
|
|
const streamPhase = _streamPhase;
|
|
void _allowAnimation;
|
|
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
|
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
|
const messageContentRef = React.useRef<HTMLDivElement>(null);
|
|
const toolRevealReadyRef = React.useRef(false);
|
|
|
|
React.useEffect(() => {
|
|
toolRevealReadyRef.current = true;
|
|
}, []);
|
|
|
|
const canCopyMessage = Boolean(onCopyMessage);
|
|
const isMessageCopied = Boolean(copiedMessage);
|
|
const isTouchContext = Boolean(hasTouchInput ?? isMobile);
|
|
const awaitingMessageCompletion = !isMessageCompleted;
|
|
const animateActivityRows = awaitingMessageCompletion || Boolean(turnGroupingContext?.isWorking);
|
|
|
|
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 toolRevealStateRef = React.useRef<{
|
|
messageId: string;
|
|
hasCommitted: boolean;
|
|
persistedToolIds: Set<string>;
|
|
animatedToolIds: Set<string>;
|
|
}>({
|
|
messageId,
|
|
hasCommitted: false,
|
|
persistedToolIds: readRevealedToolIds(messageId),
|
|
animatedToolIds: new Set<string>(),
|
|
});
|
|
|
|
if (toolRevealStateRef.current.messageId !== messageId) {
|
|
toolRevealStateRef.current = {
|
|
messageId,
|
|
hasCommitted: false,
|
|
persistedToolIds: readRevealedToolIds(messageId),
|
|
animatedToolIds: new Set<string>(),
|
|
};
|
|
}
|
|
|
|
const currentToolIds = React.useMemo(() => {
|
|
const ids = new Set<string>();
|
|
|
|
for (const toolPart of toolParts) {
|
|
ids.add(toolPart.id);
|
|
}
|
|
|
|
const activitySegments = turnGroupingContext?.activityGroupSegments;
|
|
if (Array.isArray(activitySegments)) {
|
|
for (const segment of activitySegments) {
|
|
if (segment.anchorMessageId !== messageId) {
|
|
continue;
|
|
}
|
|
for (const activity of segment.parts) {
|
|
if (activity.kind !== 'tool') {
|
|
continue;
|
|
}
|
|
const toolId = (activity.part as { id?: unknown }).id;
|
|
if (typeof toolId === 'string' && toolId.length > 0) {
|
|
ids.add(toolId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return Array.from(ids);
|
|
}, [messageId, toolParts, turnGroupingContext?.activityGroupSegments]);
|
|
const shouldAnimateNewToolMount = Boolean(turnGroupingContext?.isWorking && toolRevealReadyRef.current);
|
|
const persistedToolIds = toolRevealStateRef.current.persistedToolIds;
|
|
const animatedToolIds = toolRevealStateRef.current.animatedToolIds;
|
|
|
|
if (shouldAnimateNewToolMount && toolRevealStateRef.current.hasCommitted) {
|
|
for (const toolId of currentToolIds) {
|
|
if (!persistedToolIds.has(toolId)) {
|
|
animatedToolIds.add(toolId);
|
|
}
|
|
}
|
|
}
|
|
|
|
const animatedToolIdsKey = Array.from(animatedToolIds).join('\u0000');
|
|
const animatedToolIdsLookup = React.useMemo(
|
|
() => new Set(animatedToolIdsKey ? animatedToolIdsKey.split('\u0000') : []),
|
|
[animatedToolIdsKey]
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
const nextPersistedToolIds = new Set(toolRevealStateRef.current.persistedToolIds);
|
|
for (const toolId of currentToolIds) {
|
|
nextPersistedToolIds.add(toolId);
|
|
}
|
|
toolRevealStateRef.current.persistedToolIds = nextPersistedToolIds;
|
|
toolRevealStateRef.current.hasCommitted = true;
|
|
writeRevealedToolIds(messageId, nextPersistedToolIds);
|
|
}, [currentToolIds, messageId]);
|
|
|
|
const assistantTextParts = React.useMemo(() => {
|
|
return visibleParts.filter((part) => part.type === 'text');
|
|
}, [visibleParts]);
|
|
|
|
const createSessionFromAssistantMessage = useSessionUIStore((state) => state.createSessionFromAssistantMessage);
|
|
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
|
|
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
|
const isSortedRenderMode = chatRenderMode === 'sorted';
|
|
const collapsedPreviewCount = 7;
|
|
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
|
|
const hasStopFinish = messageFinish === 'stop';
|
|
|
|
// TTS for message playback
|
|
const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS();
|
|
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
|
|
const voiceProvider = useConfigStore((state) => state.voiceProvider);
|
|
|
|
const readAloudTooltip = React.useMemo(() => {
|
|
if (isTTSPlaying) {
|
|
return 'Stop speaking';
|
|
}
|
|
const providerLabel = voiceProvider === 'browser' ? 'Browser' : voiceProvider === 'openai' ? 'OpenAI' : voiceProvider === 'openai-compatible' ? 'Custom' : 'Say';
|
|
return `Read aloud (${providerLabel} voice)`;
|
|
}, [isTTSPlaying, voiceProvider]);
|
|
|
|
|
|
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 isActiveTool = React.useCallback((toolPart: ToolPartType): boolean => {
|
|
const state = (toolPart as Record<string, unknown>).state as Record<string, unknown> | undefined ?? {};
|
|
const status = state?.status;
|
|
return status === 'pending' || status === 'running' || status === 'started';
|
|
}, []);
|
|
|
|
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 shouldShowTool = React.useCallback((toolPart: ToolPartType): boolean => {
|
|
return isActiveTool(toolPart) || isToolFinalized(toolPart);
|
|
}, [isActiveTool, isToolFinalized]);
|
|
|
|
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]);
|
|
|
|
// Message is considered to have an "open step" if info.finish is not yet present
|
|
const hasOpenStep = typeof messageFinish !== 'string';
|
|
|
|
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]
|
|
);
|
|
|
|
const handleForkClick = React.useCallback(
|
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
if (!createSessionFromAssistantMessage) {
|
|
return;
|
|
}
|
|
void createSessionFromAssistantMessage(messageId);
|
|
},
|
|
[createSessionFromAssistantMessage, messageId]
|
|
);
|
|
|
|
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]
|
|
);
|
|
|
|
const handleTTSClick = React.useCallback(
|
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
|
|
if (isTTSPlaying) {
|
|
stopTTS();
|
|
return;
|
|
}
|
|
|
|
const messageText = flattenAssistantTextParts(assistantTextParts);
|
|
if (messageText.trim()) {
|
|
void playTTS(messageText);
|
|
}
|
|
},
|
|
[assistantTextParts, isTTSPlaying, playTTS, stopTTS]
|
|
);
|
|
|
|
const [isSharing, setIsSharing] = React.useState(false);
|
|
|
|
const handleShareImage = React.useCallback(
|
|
async (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
|
|
if (!messageContentRef.current || isSharing) return;
|
|
|
|
setIsSharing(true);
|
|
let wrapper: HTMLDivElement | null = null;
|
|
try {
|
|
const originalElement = messageContentRef.current;
|
|
const computedStyle = window.getComputedStyle(originalElement);
|
|
const rootStyle = window.getComputedStyle(document.documentElement);
|
|
const resolvedBackgroundColor =
|
|
rootStyle.getPropertyValue('--surface-background').trim() ||
|
|
computedStyle.backgroundColor ||
|
|
window.getComputedStyle(document.body).backgroundColor;
|
|
const paddingSize = 24;
|
|
|
|
wrapper = document.createElement('div');
|
|
wrapper.style.cssText = `
|
|
padding: ${paddingSize}px;
|
|
background-color: ${resolvedBackgroundColor};
|
|
display: inline-block;
|
|
`;
|
|
|
|
const clone = originalElement.cloneNode(true) as HTMLElement;
|
|
clone.style.cssText = `
|
|
${computedStyle.cssText}
|
|
transform: none;
|
|
contain: none;
|
|
`;
|
|
|
|
const timestampElements = clone.querySelectorAll<HTMLElement>('[aria-label^="Message time:"]');
|
|
const footerRowsAdjusted = new Set<HTMLElement>();
|
|
timestampElements.forEach((element) => {
|
|
const label = element.getAttribute('aria-label');
|
|
const timestamp = label?.replace('Message time:', '').trim();
|
|
if (!timestamp || element.textContent?.includes(timestamp)) {
|
|
return;
|
|
}
|
|
|
|
const timestampText = document.createElement('span');
|
|
timestampText.style.marginLeft = '4px';
|
|
timestampText.textContent = timestamp;
|
|
element.appendChild(timestampText);
|
|
|
|
const metaGroup = element.parentElement;
|
|
const footerRow = metaGroup?.parentElement as HTMLElement | null;
|
|
const actionsGroup = footerRow?.firstElementChild as HTMLElement | null;
|
|
if (!footerRow || !actionsGroup || actionsGroup === metaGroup || footerRowsAdjusted.has(footerRow)) {
|
|
return;
|
|
}
|
|
|
|
actionsGroup.style.display = 'none';
|
|
footerRow.style.justifyContent = 'flex-start';
|
|
footerRowsAdjusted.add(footerRow);
|
|
});
|
|
|
|
wrapper.appendChild(clone);
|
|
document.body.appendChild(wrapper);
|
|
|
|
const dataUrl = await toPng(wrapper, {
|
|
quality: 1,
|
|
pixelRatio: 2,
|
|
backgroundColor: resolvedBackgroundColor,
|
|
});
|
|
|
|
const fileName = `message-${messageId}.png`;
|
|
|
|
if (isVSCodeRuntime()) {
|
|
const response = await fetch('/api/vscode/save-image', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName, dataUrl }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to save image in VS Code');
|
|
}
|
|
|
|
const payload = await response.json() as { saved?: boolean; canceled?: boolean; error?: string };
|
|
if (payload.saved !== true) {
|
|
if (payload.canceled) {
|
|
return;
|
|
}
|
|
throw new Error(payload.error || 'Failed to save image in VS Code');
|
|
}
|
|
} else {
|
|
const link = document.createElement('a');
|
|
link.download = fileName;
|
|
link.href = dataUrl;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}
|
|
|
|
toast.success('Image saved');
|
|
} catch (error) {
|
|
console.error('Failed to generate image:', error);
|
|
toast.error('Failed to generate image');
|
|
} finally {
|
|
if (wrapper && wrapper.parentNode) {
|
|
wrapper.parentNode.removeChild(wrapper);
|
|
}
|
|
setIsSharing(false);
|
|
}
|
|
},
|
|
[messageId, isSharing]
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
clearCopyHintTimeout();
|
|
};
|
|
}, [clearCopyHintTimeout]);
|
|
|
|
const activityPartsForTurn = React.useMemo(() => {
|
|
const all = turnGroupingContext?.activityParts;
|
|
if (!isSortedRenderMode || !all) {
|
|
return [];
|
|
}
|
|
return all;
|
|
}, [isSortedRenderMode, turnGroupingContext?.activityParts]);
|
|
|
|
const activityGroupSegmentsForMessage = React.useMemo(() => {
|
|
const all = turnGroupingContext?.activityGroupSegments;
|
|
if (!isSortedRenderMode || !all) {
|
|
return [];
|
|
}
|
|
return all.filter((segment) => segment.anchorMessageId === messageId);
|
|
}, [isSortedRenderMode, messageId, turnGroupingContext?.activityGroupSegments]);
|
|
|
|
const hasAnchoredActivitySegments = activityGroupSegmentsForMessage.length > 0;
|
|
|
|
const activityByPart = React.useMemo(() => {
|
|
const byRef = new Map<Part, (typeof activityPartsForTurn)[number]>();
|
|
const byId = new Map<string, (typeof activityPartsForTurn)[number]>();
|
|
activityPartsForTurn.forEach((activity) => {
|
|
byRef.set(activity.part, activity);
|
|
const partId = (activity.part as { id?: unknown }).id;
|
|
if (typeof partId === 'string' && partId.length > 0) {
|
|
byId.set(partId, activity);
|
|
}
|
|
});
|
|
|
|
return {
|
|
get: (part: Part) => {
|
|
const direct = byRef.get(part);
|
|
if (direct) {
|
|
return direct;
|
|
}
|
|
const partId = (part as { id?: unknown }).id;
|
|
if (typeof partId === 'string' && partId.length > 0) {
|
|
return byId.get(partId);
|
|
}
|
|
return undefined;
|
|
},
|
|
};
|
|
}, [activityPartsForTurn]);
|
|
|
|
const toggleActivityGroup = turnGroupingContext?.toggleGroup;
|
|
const isActivityOwnerMessage = !isSortedRenderMode
|
|
|| !turnGroupingContext?.activityOwnerMessageId
|
|
|| turnGroupingContext.activityOwnerMessageId === messageId
|
|
|| hasAnchoredActivitySegments;
|
|
|
|
const shouldRenderActivityGroup = isSortedRenderMode
|
|
&& isActivityOwnerMessage
|
|
&& hasAnchoredActivitySegments
|
|
&& Boolean(toggleActivityGroup);
|
|
|
|
const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish;
|
|
|
|
|
|
const renderedParts = React.useMemo(() => {
|
|
const rendered: React.ReactNode[] = [];
|
|
|
|
if (shouldRenderActivityGroup && toggleActivityGroup) {
|
|
activityGroupSegmentsForMessage.forEach((segment) => {
|
|
const visibleSegmentParts = showReasoningTraces
|
|
? segment.parts
|
|
: segment.parts.filter((activity) => activity.kind !== 'reasoning');
|
|
if (visibleSegmentParts.length === 0) {
|
|
return;
|
|
}
|
|
rendered.push(
|
|
<div key={`progressive-group-${segment.id}`} className="mb-3">
|
|
<TurnActivity
|
|
parts={visibleSegmentParts}
|
|
isExpanded={turnGroupingContext.isGroupExpanded === true}
|
|
collapsedPreviewCount={collapsedPreviewCount}
|
|
onToggle={toggleActivityGroup}
|
|
syntaxTheme={syntaxTheme}
|
|
isMobile={isMobile}
|
|
expandedTools={expandedTools}
|
|
onToggleTool={onToggleTool}
|
|
onShowPopup={onShowPopup}
|
|
onContentChange={onContentChange}
|
|
streamPhase={streamPhase}
|
|
showHeader={true}
|
|
animateRows={animateActivityRows}
|
|
animatedToolIds={animatedToolIdsLookup}
|
|
diffStats={turnGroupingContext.diffStats}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
}
|
|
|
|
// Flat rendering: iterate parts in natural order.
|
|
// Group consecutive static tools (read, grep, glob, etc.) into compact rows.
|
|
// Expandable tools (bash, edit, task) get individual rows.
|
|
// Text and reasoning render inline at their natural position.
|
|
let i = 0;
|
|
while (i < visibleParts.length) {
|
|
const part = visibleParts[i];
|
|
|
|
if (part.type === 'text') {
|
|
const activity = activityByPart.get(part);
|
|
if (shouldDeferSortedInlineText) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (activity?.kind === 'justification') {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
rendered.push(
|
|
<AssistantTextPart
|
|
key={`assistant-text-${messageId}-${i}`}
|
|
part={part}
|
|
sessionId={sessionId}
|
|
messageId={messageId}
|
|
streamPhase={streamPhase}
|
|
chatRenderMode={chatRenderMode}
|
|
onContentChange={onContentChange}
|
|
/>
|
|
);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (part.type === 'reasoning') {
|
|
const activity = activityByPart.get(part);
|
|
if (activity?.kind === 'reasoning') {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (showReasoningTraces) {
|
|
if (isSortedRenderMode) {
|
|
rendered.push(
|
|
<ReasoningPart
|
|
key={`reasoning-${messageId}-${i}`}
|
|
part={part}
|
|
messageId={messageId}
|
|
onContentChange={onContentChange}
|
|
/>
|
|
);
|
|
} else {
|
|
rendered.push(
|
|
<AssistantTextPart
|
|
key={`reasoning-${messageId}-${i}`}
|
|
part={part}
|
|
sessionId={sessionId}
|
|
messageId={messageId}
|
|
streamPhase={streamPhase}
|
|
chatRenderMode={chatRenderMode}
|
|
onContentChange={onContentChange}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (part.type === 'tool') {
|
|
const toolPart = part as ToolPartType;
|
|
const toolName = toolPart.tool?.toLowerCase() ?? '';
|
|
|
|
if (isSortedRenderMode && !isActivityOwnerMessage) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
const activity = activityByPart.get(part);
|
|
if (activity?.kind === 'tool' && (shouldRenderActivityGroup || !isStandaloneTool(toolName))) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (!shouldShowTool(toolPart)) {
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Expandable tools: bash, edit, write, task, question — individual rows
|
|
if (isExpandableTool(toolName)) {
|
|
rendered.push(
|
|
<FadeInOnReveal key={`tool-${toolPart.id}`}>
|
|
<ToolRevealOnMount animate={animatedToolIdsLookup.has(toolPart.id)} wipe>
|
|
<ToolPart
|
|
part={toolPart}
|
|
isExpanded={expandedTools.has(toolPart.id)}
|
|
onToggle={onToggleTool}
|
|
syntaxTheme={syntaxTheme}
|
|
isMobile={isMobile}
|
|
onContentChange={onContentChange}
|
|
onShowPopup={onShowPopup}
|
|
animateTailText={animatedToolIdsLookup.has(toolPart.id)}
|
|
/>
|
|
</ToolRevealOnMount>
|
|
</FadeInOnReveal>
|
|
);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Static tools: one row per tool call (no grouping)
|
|
rendered.push(
|
|
<FadeInOnReveal key={`static-tools-${toolPart.id}`}>
|
|
<ToolRevealOnMount animate={animatedToolIdsLookup.has(toolPart.id)} wipe>
|
|
<StaticToolRow
|
|
toolName={toolName}
|
|
activities={[
|
|
{
|
|
id: toolPart.id,
|
|
turnId: '',
|
|
messageId,
|
|
partIndex: 0,
|
|
part: toolPart,
|
|
kind: 'tool' as const,
|
|
},
|
|
]}
|
|
animateTailText={animatedToolIdsLookup.has(toolPart.id)}
|
|
/>
|
|
</ToolRevealOnMount>
|
|
</FadeInOnReveal>
|
|
);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Unknown part type — skip
|
|
i++;
|
|
}
|
|
|
|
return rendered;
|
|
}, [
|
|
activityByPart,
|
|
activityGroupSegmentsForMessage,
|
|
animatedToolIdsLookup,
|
|
animateActivityRows,
|
|
chatRenderMode,
|
|
collapsedPreviewCount,
|
|
expandedTools,
|
|
isMobile,
|
|
isActivityOwnerMessage,
|
|
isSortedRenderMode,
|
|
messageId,
|
|
sessionId,
|
|
onContentChange,
|
|
onShowPopup,
|
|
onToggleTool,
|
|
shouldRenderActivityGroup,
|
|
shouldShowTool,
|
|
streamPhase,
|
|
showReasoningTraces,
|
|
shouldDeferSortedInlineText,
|
|
syntaxTheme,
|
|
toggleActivityGroup,
|
|
turnGroupingContext,
|
|
visibleParts,
|
|
]);
|
|
|
|
// With flat rendering, no collapsed summary is needed — text renders inline.
|
|
|
|
const showErrorMessage = Boolean(errorMessage);
|
|
|
|
const shouldShowFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage));
|
|
|
|
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]);
|
|
|
|
const footerTimestamp = React.useMemo(() => {
|
|
const timestamp = typeof messageCompletedAt === 'number' && messageCompletedAt > 0
|
|
? messageCompletedAt
|
|
: (typeof messageCreatedAt === 'number' && messageCreatedAt > 0 ? messageCreatedAt : null);
|
|
if (timestamp === null) return null;
|
|
|
|
const formatted = formatTimestampForDisplay(timestamp);
|
|
return formatted.length > 0 ? formatted : null;
|
|
}, [messageCompletedAt, messageCreatedAt]);
|
|
|
|
const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1';
|
|
|
|
const footerButtons = (
|
|
<>
|
|
{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',
|
|
!hasCopyableText && 'opacity-50'
|
|
)}
|
|
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>
|
|
)}
|
|
<Tooltip delayDuration={1000}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
disabled={isSharing || !hasCopyableText}
|
|
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',
|
|
(!hasCopyableText || isSharing) && 'opacity-50'
|
|
)}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onClick={handleShareImage}
|
|
>
|
|
{isSharing ? (
|
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<RiImageDownloadLine className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent sideOffset={6}>{isSharing ? 'Saving image...' : 'Save as image'}</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={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>
|
|
|
|
{showMessageTTSButtons && hasCopyableText && (
|
|
<Tooltip delayDuration={1000}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn(
|
|
'h-8 w-8 bg-transparent hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
|
isTTSPlaying ? 'text-green-500' : 'text-muted-foreground hover:text-foreground'
|
|
)}
|
|
aria-label={isTTSPlaying ? 'Stop speaking' : 'Read aloud'}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onClick={handleTTSClick}
|
|
>
|
|
{isTTSPlaying ? (
|
|
<RiStopLine className="h-3.5 w-3.5" />
|
|
) : (
|
|
<RiVolumeUpLine className="h-3.5 w-3.5" />
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent sideOffset={6}>{readAloudTooltip}</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
|
|
<div
|
|
ref={messageContentRef}
|
|
className={cn(
|
|
'relative w-full group/message'
|
|
)}
|
|
style={{
|
|
contain: 'layout',
|
|
transform: 'translateZ(0)',
|
|
}}
|
|
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
|
>
|
|
<TextSelectionMenu containerRef={messageContentRef} />
|
|
<div>
|
|
<div
|
|
className="message-content-text leading-relaxed overflow-hidden text-foreground/90 [&_p:last-child]:mb-0 [&_ul:last-child]:mb-0 [&_ol:last-child]:mb-0"
|
|
>
|
|
{renderedParts}
|
|
{showErrorMessage && (
|
|
<FadeInOnReveal key="assistant-error">
|
|
<div className="group/assistant-text relative mt-3 p-3 rounded-lg border bg-[var(--status-error-background)] border-[var(--status-error-border)] break-all max-w-full">
|
|
<div className="flex items-start gap-2">
|
|
<RiErrorWarningLine className="h-4 w-4 shrink-0 mt-0.5 text-[var(--status-error)]" />
|
|
<div className="break-all min-w-0 flex-1">
|
|
<SimpleMarkdownRenderer content={errorMessage ?? ''} onShowPopup={onShowPopup} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</FadeInOnReveal>
|
|
)}
|
|
</div>
|
|
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
|
|
{shouldShowFooter && (
|
|
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
{footerButtons}
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
{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>
|
|
) : null}
|
|
{footerTimestamp ? (
|
|
<span
|
|
className={footerTimestampClassName}
|
|
aria-label={`Message time: ${footerTimestamp}`}
|
|
>
|
|
<RiTimeLine className="h-3.5 w-3.5" />
|
|
{footerTimestamp}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</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}
|
|
onRevert={props.onRevert}
|
|
onFork={props.onFork}
|
|
userActionsMode={props.userActionsMode}
|
|
stickyUserHeaderEnabled={props.stickyUserHeaderEnabled}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return <AssistantMessageBody {...props} />;
|
|
};
|
|
|
|
export default MessageBody;
|