Merge main and fix lazy image export loading
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user