Merge main and fix lazy image export loading

This commit is contained in:
Bohdan Triapitsyn
2026-08-02 20:17:37 +03:00
1656 changed files with 159739 additions and 72292 deletions
@@ -4,13 +4,14 @@ 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, { MergedReasoningPart } from './parts/ReasoningPart';
import ReasoningPart from './parts/ReasoningPart';
import { MessageFilesDisplay } from '../FileAttachment';
import { TurnChangedFilesDropdown } from '../TurnChangedFilesDropdown';
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types';
import type { TurnChangedFile, TurnGroupingContext } from '../lib/turns/types';
import type { TurnActivityGroup, TurnChangedFile, TurnGroupingContext } from '../lib/turns/types';
import { cn } from '@/lib/utils';
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
import { isEmptyTextPart, extractTextContent } from './partUtils';
import { FadeInOnReveal } from './FadeInOnReveal';
import { Button } from '@/components/ui/button';
@@ -52,6 +53,9 @@ import {
sendImplementationResponseToReviewer,
sendReviewFeedbackToOriginal,
} from '@/lib/reviewFlow';
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
import { useProviderLogo } from '@/hooks/useProviderLogo';
import { getAgentColor } from '@/lib/agentColors';
const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' };
@@ -64,37 +68,89 @@ const getDisplayFileName = (file: string): string => {
return segments.at(-1) ?? file;
};
const TurnChangedFilePills = React.memo(({ files }: { files?: TurnChangedFile[] }) => {
if (!files || files.length === 0) {
return null;
}
const TurnChangedFileChipContent = React.memo(({ file, interactive = false }: { file: TurnChangedFile; interactive?: boolean }) => (
<span
className={cn(
'inline-flex max-w-full items-center gap-1.5 rounded-lg border border-border/30 bg-muted/30 px-2 py-1 text-xs text-muted-foreground',
interactive && 'transition-colors hover:border-border/60 hover:bg-interactive-hover'
)}
style={{ lineHeight: 'round(1.35em, 1px)' }}
>
<FileTypeIcon filePath={file.file} className="h-3.5 w-3.5 flex-shrink-0" />
<span className="max-w-52 truncate text-foreground/80" title={file.file}>{getDisplayFileName(file.file)}</span>
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
<span style={{ color: 'var(--status-success)' }}>+{file.additions}</span>
<span className="text-muted-foreground/70">/</span>
<span style={{ color: 'var(--status-error)' }}>-{file.deletions}</span>
</span>
</span>
));
const TurnChangedFilePillButton = React.memo(({
file,
onOpen,
}: {
file: TurnChangedFile;
onOpen: (file: string) => void;
}) => {
const { t } = useI18n();
return (
<button
type="button"
className="inline-flex h-8 max-w-full cursor-pointer items-center rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
aria-label={t('chat.changedFiles.actions.openFileTitle', { path: file.file })}
title={file.file}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onOpen(file.file);
}}
>
<TurnChangedFileChipContent file={file} interactive />
</button>
);
});
const StaticTurnChangedFilePills = React.memo(({ files }: { files: TurnChangedFile[] }) => (
<>
{files.map((file) => (
<span key={file.file} className="inline-flex h-8 max-w-full items-center" title={file.file}>
<TurnChangedFileChipContent file={file} />
</span>
))}
</>
));
const InteractiveTurnChangedFilePills = React.memo(({ files }: { files: TurnChangedFile[] }) => {
const effectiveDirectory = useEffectiveDirectory();
const isMobile = useUIStore((state) => state.isMobile);
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
const openContextDiff = useUIStore((state) => state.openContextDiff);
const openLastTurnDiff = React.useCallback((file: string) => {
if (!isMobile && effectiveDirectory) {
openContextDiff(effectiveDirectory, file, false, 'turn');
return;
}
navigateToDiff(file, false, 'turn');
}, [effectiveDirectory, isMobile, navigateToDiff, openContextDiff]);
return (
<>
{files.map((file) => {
return (
<Tooltip key={file.file}>
<TooltipTrigger asChild>
<span className="inline-flex h-8 max-w-full items-center">
<span className="inline-flex max-w-full items-center gap-1.5 rounded-lg border border-border/30 bg-muted/30 px-2 py-1 text-xs leading-[1.35] text-muted-foreground">
<FileTypeIcon filePath={file.file} className="h-3.5 w-3.5 flex-shrink-0" />
<span className="max-w-52 truncate text-foreground/80" title={file.file}>{getDisplayFileName(file.file)}</span>
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
<span style={{ color: 'var(--status-success)' }}>+{file.additions}</span>
<span className="text-muted-foreground/70">/</span>
<span style={{ color: 'var(--status-error)' }}>-{file.deletions}</span>
</span>
</span>
</span>
</TooltipTrigger>
<TooltipContent>{file.file}</TooltipContent>
</Tooltip>
);
})}
{files.map((file) => (
<TurnChangedFilePillButton key={file.file} file={file} onOpen={openLastTurnDiff} />
))}
</>
);
});
const TurnChangedFilePills = React.memo(({ files, isInteractive }: { files?: TurnChangedFile[]; isInteractive: boolean }) => {
if (!files || files.length === 0) return null;
return isInteractive ? <InteractiveTurnChangedFilePills files={files} /> : <StaticTurnChangedFilePills files={files} />;
});
type SubtaskPartLike = Part & {
type: 'subtask';
description?: unknown;
@@ -200,7 +256,11 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
onClick={() => {
if (!effectiveDirectory) return;
if (isMobile || isVSCodeRuntime()) {
// In contexts with no ContextPanel (embedded
// session-chat iframe) or single-surface layouts
// (mobile, VS Code), navigate in place. Otherwise
// open a new side-panel tab.
if (isEmbeddedSessionChat() || isMobile || isVSCodeRuntime()) {
setCurrentSession(taskSessionID, effectiveDirectory);
return;
}
@@ -221,6 +281,8 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
);
};
const SHELL_CODE_TAG_STYLE: React.CSSProperties = { background: 'transparent', backgroundColor: 'transparent' };
const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part }) => {
const [expanded, setExpanded] = React.useState(false);
const [copiedOutput, setCopiedOutput] = React.useState(false);
@@ -278,9 +340,14 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
</div>
{command ? (
<pre className="typography-meta mt-1.5 overflow-x-auto whitespace-pre-wrap break-words text-foreground/90 font-mono">
{command}
</pre>
<div className="typography-meta mt-1.5 overflow-x-auto font-mono">
<WorkerHighlightedCode
language="bash"
code={command}
codeStyle={SHELL_CODE_TAG_STYLE}
wrap
/>
</div>
) : null}
{hasOutput ? (
@@ -306,9 +373,14 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
</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>
<div className="typography-meta mt-1.5 max-h-56 overflow-auto font-mono text-foreground/85">
<WorkerHighlightedCode
language="bash"
code={output}
codeStyle={SHELL_CODE_TAG_STYLE}
wrap
/>
</div>
) : null}
</div>
) : null}
@@ -364,6 +436,14 @@ interface MessageBodyProps {
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
stickyUserHeaderEnabled?: boolean;
reviewTransferDirection?: ReviewTransferDirection | null;
contextPinned?: boolean;
contextPinPending?: boolean;
onToggleContextPin?: () => void;
footerProviderID?: string | null;
footerModelName?: string;
footerAgentName?: string;
footerVariant?: string;
isDarkTheme?: boolean;
}
const TOOL_REVEAL_CACHE_MAX = 200;
@@ -384,9 +464,10 @@ const writeRevealedToolIds = (messageId: string, value: Set<string>): void => {
revealedToolIdsByMessage.set(messageId, new Set(value));
};
const UserMessageBody = React.memo(({ messageId, parts, isMobile, alwaysShowActions = isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }: {
const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobile, alwaysShowActions = isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, contextPinned, contextPinPending, onToggleContextPin, userActionsMode = 'inline', stickyUserHeaderEnabled = true }: {
messageId: string;
parts: Part[];
messageCreatedAt?: number | null;
isMobile: boolean;
alwaysShowActions?: boolean;
hasTouchInput?: boolean;
@@ -397,11 +478,15 @@ const UserMessageBody = React.memo(({ messageId, parts, isMobile, alwaysShowActi
agentMention?: AgentMentionInfo;
onRevert?: () => void;
onFork?: () => void;
contextPinned?: boolean;
contextPinPending?: boolean;
onToggleContextPin?: () => void;
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
stickyUserHeaderEnabled?: boolean;
}) => {
const { t } = useI18n();
const { locale, t } = useI18n();
const chatSurfaceMode = useChatSurfaceMode();
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
const copyHintTimeoutRef = React.useRef<number | null>(null);
@@ -476,7 +561,13 @@ const UserMessageBody = React.memo(({ messageId, parts, isMobile, alwaysShowActi
);
const effectiveOnFork = chatSurfaceMode === 'mini-chat' ? undefined : onFork;
const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork) && showUserActions ? (
const timestamp = React.useMemo(() => {
void locale;
if (typeof messageCreatedAt !== 'number' || messageCreatedAt <= 0) return null;
const formatted = formatTimestampForDisplay(messageCreatedAt, timeFormatPreference);
return formatted.length > 0 ? formatted : null;
}, [locale, messageCreatedAt, timeFormatPreference]);
const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? (
<div className={cn(
'group/user-actions',
isMobile
@@ -504,6 +595,20 @@ const UserMessageBody = React.memo(({ messageId, parts, isMobile, alwaysShowActi
: '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'
)}
>
{timestamp ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className="mr-1 flex items-center gap-1 text-sm tabular-nums text-muted-foreground/60"
aria-label={`Message time: ${timestamp}`}
>
<Icon name="time" className="h-3.5 w-3.5" />
<span className="message-footer__label">{timestamp}</span>
</span>
</TooltipTrigger>
<TooltipContent>{timestamp}</TooltipContent>
</Tooltip>
) : null}
{onRevert && (
<Tooltip>
<TooltipTrigger asChild>
@@ -546,6 +651,29 @@ const UserMessageBody = React.memo(({ messageId, parts, isMobile, alwaysShowActi
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.fork')}</TooltipContent>
</Tooltip>
)}
{onToggleContextPin && hasCopyableText && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
contextPinned ? 'text-[color:var(--status-info)]' : 'text-muted-foreground',
)}
disabled={contextPinPending}
aria-pressed={contextPinned}
aria-label={t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onToggleContextPin(); }}
>
<Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}</TooltipContent>
</Tooltip>
)}
{canCopyMessage && hasCopyableText && (
<Tooltip>
<TooltipTrigger asChild>
@@ -965,6 +1093,14 @@ const AssistantMessageBody = React.memo(({
errorMessage,
errorVariant = 'error',
reviewTransferDirection = null,
contextPinned,
contextPinPending,
onToggleContextPin,
footerProviderID,
footerModelName,
footerAgentName,
footerVariant,
isDarkTheme = false,
}: Omit<MessageBodyProps, 'isUser'>) => {
const { t, locale } = useI18n();
const chatSurfaceMode = useChatSurfaceMode();
@@ -980,6 +1116,7 @@ const AssistantMessageBody = React.memo(({
const isTouchContext = Boolean(hasTouchInput ?? isMobile);
const alwaysShowMessageActions = Boolean(alwaysShowActions ?? isMobile);
const { src: footerLogoSrc, onError: handleFooterLogoError, hasLogo: footerHasLogo } = useProviderLogo(footerProviderID ?? null);
const awaitingMessageCompletion = !isMessageCompleted;
const animateActivityRows = awaitingMessageCompletion || Boolean(turnGroupingContext?.isWorking);
@@ -1158,7 +1295,6 @@ const AssistantMessageBody = React.memo(({
const [isForkSubmitting, setIsForkSubmitting] = React.useState(false);
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
const collapsibleThinkingBlocks = useUIStore((state) => state.collapsibleThinkingBlocks);
const groupReasoningBlocks = useUIStore((state) => state.groupReasoningBlocks);
const showSplitAssistantMessageActions = useUIStore((state) => state.showSplitAssistantMessageActions);
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const vscodeApi = useRuntimeAPIs().vscode;
@@ -1395,6 +1531,9 @@ const AssistantMessageBody = React.memo(({
let wrapper: HTMLDivElement | null = null;
try {
// Load the exporter before attaching its temporary clone so a slow
// chunk request cannot leave export-only content in the page layout.
const { toPng } = await import('html-to-image');
const originalElement = sourceElement;
const computedStyle = window.getComputedStyle(originalElement);
const rootStyle = window.getComputedStyle(document.documentElement);
@@ -1405,6 +1544,7 @@ const AssistantMessageBody = React.memo(({
const paddingSize = 24;
wrapper = document.createElement('div');
wrapper.setAttribute('data-message-image-export', 'true');
wrapper.style.cssText = `
padding: ${paddingSize}px;
background-color: ${resolvedBackgroundColor};
@@ -1454,9 +1594,6 @@ const AssistantMessageBody = React.memo(({
wrapper.appendChild(clone);
document.body.appendChild(wrapper);
// Lazy-load html-to-image: only needed when the user exports a
// message as an image, so keep it out of the eager app shell.
const { toPng } = await import('html-to-image');
const dataUrl = await toPng(wrapper, {
quality: 1,
pixelRatio: 2,
@@ -1627,51 +1764,82 @@ const AssistantMessageBody = React.memo(({
const renderedParts = React.useMemo(() => {
const rendered: React.ReactNode[] = [];
const renderSegmentBlock = (segment: TurnActivityGroup): React.ReactNode | null => {
if (!shouldRenderActivityGroup || !toggleActivityGroup) {
return null;
}
const visibleSegmentParts = showReasoningTraces
? segment.parts
: segment.parts.filter((activity) => activity.kind !== 'reasoning');
if (visibleSegmentParts.length === 0) {
return null;
}
return (
<div key={`progressive-group-${segment.id}`} className="mb-3">
<TurnActivity
parts={visibleSegmentParts}
isExpanded={turnGroupingContext?.isGroupExpanded === true}
collapsedPreviewCount={collapsedPreviewCount}
onToggle={toggleActivityGroup}
isMobile={isMobile}
expandedTools={expandedTools}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
onContentChange={onContentChange}
streamPhase={effectiveStreamPhase}
showHeader={true}
animateRows={animateActivityRows}
animatedToolIds={animatedToolIdsLookup}
diffStats={turnGroupingContext?.diffStats}
renderJustificationActions={renderJustificationActions}
/>
</div>
);
};
// Segments that follow a standalone tool of THIS message render right
// after that tool's row so e.g. an Agent Task sits chronologically
// between the activity before it and the activity after it.
const localToolPartIds = new Set<string>();
visibleParts.forEach((part, partIndex) => {
if (part.type === 'tool') {
localToolPartIds.add(part.id ?? `${messageId}-part-${partIndex}-${part.type}`);
}
});
const segmentsAfterLocalTool = new Map<string, TurnActivityGroup[]>();
if (shouldRenderActivityGroup && toggleActivityGroup) {
activityGroupSegmentsForMessage.forEach((segment) => {
const visibleSegmentParts = showReasoningTraces
? segment.parts
: segment.parts.filter((activity) => activity.kind !== 'reasoning');
if (visibleSegmentParts.length === 0) {
if (segment.afterToolPartId && localToolPartIds.has(segment.afterToolPartId)) {
const list = segmentsAfterLocalTool.get(segment.afterToolPartId) ?? [];
list.push(segment);
segmentsAfterLocalTool.set(segment.afterToolPartId, list);
return;
}
rendered.push(
<div key={`progressive-group-${segment.id}`} className="mb-3">
<TurnActivity
parts={visibleSegmentParts}
isExpanded={turnGroupingContext.isGroupExpanded === true}
collapsedPreviewCount={collapsedPreviewCount}
onToggle={toggleActivityGroup}
isMobile={isMobile}
expandedTools={expandedTools}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
onContentChange={onContentChange}
streamPhase={effectiveStreamPhase}
showHeader={true}
animateRows={animateActivityRows}
animatedToolIds={animatedToolIdsLookup}
diffStats={turnGroupingContext.diffStats}
renderJustificationActions={renderJustificationActions}
/>
</div>
);
const block = renderSegmentBlock(segment);
if (block) {
rendered.push(block);
}
});
}
const flushSegmentsAfterTool = (toolPartId: string) => {
const segments = segmentsAfterLocalTool.get(toolPartId);
if (!segments) {
return;
}
segmentsAfterLocalTool.delete(toolPartId);
segments.forEach((segment) => {
const block = renderSegmentBlock(segment);
if (block) {
rendered.push(block);
}
});
};
// 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 renders inline at its natural position.
// Reasoning: all reasoning parts for this message are merged into ONE block
// at the position of the first reasoning part (VSCode Copilot pattern).
const flatReasoningParts = visibleParts.filter((p) => {
if (p.type !== 'reasoning') return false;
const a = activityByPart.get(p);
return a?.kind !== 'reasoning';
});
let reasoningMergeRendered = false;
let i = 0;
while (i < visibleParts.length) {
const part = visibleParts[i];
@@ -1733,20 +1901,6 @@ const AssistantMessageBody = React.memo(({
onShowPopup={onShowPopup}
/>
);
} else if (groupReasoningBlocks) {
// Merged mode (VSCode pattern): one block for all reasoning parts.
if (!reasoningMergeRendered) {
reasoningMergeRendered = true;
rendered.push(
<MergedReasoningPart
key={`reasoning-merged-${messageId}`}
parts={flatReasoningParts}
messageId={messageId}
streamPhase={effectiveStreamPhase}
onContentChange={onContentChange}
/>
);
}
} else {
// Per-part mode: each reasoning block at its natural position.
rendered.push(
@@ -1767,19 +1921,23 @@ const AssistantMessageBody = React.memo(({
if (part.type === 'tool') {
const toolPart = part as ToolPartType;
const toolName = toolPart.tool?.toLowerCase() ?? '';
const toolPartId = toolPart.id ?? `${messageId}-part-${i}-${part.type}`;
if (isSortedRenderMode && !isActivityOwnerMessage) {
flushSegmentsAfterTool(toolPartId);
i += 1;
continue;
}
const activity = activityByPart.get(part);
if (activity?.kind === 'tool' && (shouldRenderActivityGroup || !isStandaloneTool(toolName))) {
if (activity?.kind === 'tool' && !isStandaloneTool(toolName)) {
flushSegmentsAfterTool(toolPartId);
i += 1;
continue;
}
if (!shouldShowTool(toolPart)) {
flushSegmentsAfterTool(toolPartId);
i++;
continue;
}
@@ -1802,6 +1960,7 @@ const AssistantMessageBody = React.memo(({
</ToolRevealOnMount>
</FadeInOnReveal>
);
flushSegmentsAfterTool(toolPartId);
i++;
continue;
}
@@ -1827,6 +1986,7 @@ const AssistantMessageBody = React.memo(({
</ToolRevealOnMount>
</FadeInOnReveal>
);
flushSegmentsAfterTool(toolPartId);
i++;
continue;
}
@@ -1835,6 +1995,17 @@ const AssistantMessageBody = React.memo(({
i++;
}
// Any segments whose anchor tool never got flushed (filtered parts,
// unexpected ordering) must still render rather than disappear.
segmentsAfterLocalTool.forEach((segments) => {
segments.forEach((segment) => {
const block = renderSegmentBlock(segment);
if (block) {
rendered.push(block);
}
});
});
return rendered;
}, [
activityByPart,
@@ -1844,7 +2015,6 @@ const AssistantMessageBody = React.memo(({
animateActivityRows,
chatRenderMode,
collapsibleThinkingBlocks,
groupReasoningBlocks,
collapsedPreviewCount,
expandedTools,
isMobile,
@@ -1939,6 +2109,29 @@ const AssistantMessageBody = React.memo(({
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.saveAsPlan')}</TooltipContent>
</Tooltip>
) : null}
{onToggleContextPin && hasCopyableText ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
'h-8 w-8 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
contextPinned ? 'text-[color:var(--status-info)]' : 'text-muted-foreground',
)}
disabled={contextPinPending}
aria-pressed={contextPinned}
aria-label={t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onToggleContextPin(); }}
>
<Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}</TooltipContent>
</Tooltip>
) : null}
{!isMiniChatSurface && !isReviewSessionView ? <Tooltip>
<TooltipTrigger asChild>
<Button
@@ -2045,13 +2238,46 @@ const AssistantMessageBody = React.memo(({
)}
{shouldShowTurnFooter && (
<div
className="mt-2 mb-1 flex flex-wrap items-center justify-start gap-1.5"
className="mt-2 mb-1 flex flex-wrap items-center justify-start gap-x-3 gap-y-1.5"
style={MESSAGE_FOOTER_CONTAINER_STYLE}
>
<div className="flex items-center gap-1.5" data-message-action-group="true">
{messageActionButtons}
{finalTurnActionButtons}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1 text-sm text-muted-foreground/60">
{footerModelName ? (
<span className="flex min-w-0 items-center gap-1.5">
{footerHasLogo && footerLogoSrc ? (
<img
src={footerLogoSrc}
alt=""
className="h-3.5 w-3.5 flex-shrink-0"
style={{
filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)',
}}
onError={handleFooterLogoError}
/>
) : (
<Icon
name="brain-ai-3"
className="h-3.5 w-3.5 flex-shrink-0"
style={{ color: `var(${getAgentColor(footerAgentName).var})` }}
/>
)}
<span className="truncate">{footerModelName}</span>
</span>
) : null}
{footerVariant && !['default', 'none'].includes(footerVariant.toLowerCase()) ? (
<span className="flex items-center gap-1">
<Icon name="brain-ai-3" className="h-3.5 w-3.5 flex-shrink-0" />
<span className="message-footer__label">
{footerVariant[0].toLowerCase() + footerVariant.slice(1)}
</span>
</span>
) : null}
{footerAgentName ? (
<span className="flex items-center gap-1">
<Icon name="ai-agent" className="h-3.5 w-3.5 flex-shrink-0" />
<span className="message-footer__label">{footerAgentName}</span>
</span>
) : null}
{turnDurationText ? (
<Tooltip>
<TooltipTrigger asChild>
@@ -2081,8 +2307,24 @@ const AssistantMessageBody = React.memo(({
<TurnChangedFilesDropdown activityParts={turnGroupingContext?.activityParts} />
) : null}
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
<TurnChangedFilePills files={turnGroupingContext?.changedFiles} />
<TurnChangedFilePills
files={turnGroupingContext?.changedFiles}
isInteractive={turnGroupingContext?.isLatestTurn === true}
/>
) : null}
</div>
<div
className={cn(
'flex items-center gap-1.5',
alwaysShowMessageActions || isTouchContext
? undefined
: 'pointer-events-none opacity-0 transition-opacity duration-150 focus-within:pointer-events-auto focus-within:opacity-100 group-hover/message:pointer-events-auto group-hover/message:opacity-100'
)}
data-message-action-group="true"
>
{messageActionButtons}
{finalTurnActionButtons}
</div>
</div>
)}
@@ -2098,6 +2340,7 @@ const MessageBody = React.memo(({ isUser, ...props }: MessageBodyProps) => {
<UserMessageBody
messageId={props.messageId}
parts={props.parts}
messageCreatedAt={props.messageCreatedAt}
isMobile={props.isMobile}
alwaysShowActions={props.alwaysShowActions}
hasTouchInput={props.hasTouchInput}
@@ -2108,6 +2351,9 @@ const MessageBody = React.memo(({ isUser, ...props }: MessageBodyProps) => {
agentMention={props.agentMention}
onRevert={props.onRevert}
onFork={props.onFork}
contextPinned={props.contextPinned}
contextPinPending={props.contextPinPending}
onToggleContextPin={props.onToggleContextPin}
userActionsMode={props.userActionsMode}
stickyUserHeaderEnabled={props.stickyUserHeaderEnabled}
/>