feat: merge hidden-user turns and move message metadata to turn footer
- Remove assistant message headers; show provider icon, model, agent, thinking variant, duration and time in the turn footer (metadata left, hover-revealed actions right) - Merge turns started by hidden user messages (subagent nudges) into the previous turn so Activity, footer and spacing stay continuous - Treat compaction summary text (info.summary) as justification activity in sorted mode and skip it when picking the turn summary - Interleave activity segments with standalone tool rows so Agent Task sits chronologically between activity sections
This commit is contained in:
@@ -9,7 +9,7 @@ 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';
|
||||
@@ -55,6 +55,8 @@ import {
|
||||
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)' };
|
||||
@@ -437,6 +439,11 @@ interface MessageBodyProps {
|
||||
contextPinned?: boolean;
|
||||
contextPinPending?: boolean;
|
||||
onToggleContextPin?: () => void;
|
||||
footerProviderID?: string | null;
|
||||
footerModelName?: string;
|
||||
footerAgentName?: string;
|
||||
footerVariant?: string;
|
||||
isDarkTheme?: boolean;
|
||||
}
|
||||
|
||||
const TOOL_REVEAL_CACHE_MAX = 200;
|
||||
@@ -1089,6 +1096,11 @@ const AssistantMessageBody = React.memo(({
|
||||
contextPinned,
|
||||
contextPinPending,
|
||||
onToggleContextPin,
|
||||
footerProviderID,
|
||||
footerModelName,
|
||||
footerAgentName,
|
||||
footerVariant,
|
||||
isDarkTheme = false,
|
||||
}: Omit<MessageBodyProps, 'isUser'>) => {
|
||||
const { t, locale } = useI18n();
|
||||
const chatSurfaceMode = useChatSurfaceMode();
|
||||
@@ -1104,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);
|
||||
|
||||
@@ -1747,38 +1760,78 @@ 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.
|
||||
@@ -1864,19 +1917,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' && !isStandaloneTool(toolName)) {
|
||||
flushSegmentsAfterTool(toolPartId);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!shouldShowTool(toolPart)) {
|
||||
flushSegmentsAfterTool(toolPartId);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -1899,6 +1956,7 @@ const AssistantMessageBody = React.memo(({
|
||||
</ToolRevealOnMount>
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
flushSegmentsAfterTool(toolPartId);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -1924,6 +1982,7 @@ const AssistantMessageBody = React.memo(({
|
||||
</ToolRevealOnMount>
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
flushSegmentsAfterTool(toolPartId);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -1932,6 +1991,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,
|
||||
@@ -2164,13 +2234,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-between 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>
|
||||
@@ -2205,6 +2308,19 @@ const AssistantMessageBody = React.memo(({
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getAgentColor } from '@/lib/agentColors';
|
||||
import { useProviderLogo } from '@/hooks/useProviderLogo';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
|
||||
interface MessageHeaderProps {
|
||||
isUser: boolean;
|
||||
providerID: string | null;
|
||||
agentName: string | undefined;
|
||||
modelName: string | undefined;
|
||||
variant?: string;
|
||||
isDarkTheme: boolean;
|
||||
}
|
||||
|
||||
const MessageHeader: React.FC<MessageHeaderProps> = ({ isUser, providerID, agentName, modelName, variant, isDarkTheme }) => {
|
||||
const { src: logoSrc, onError: handleLogoError, hasLogo } = useProviderLogo(providerID);
|
||||
|
||||
return (
|
||||
<div className={cn('mb-2')}>
|
||||
<div className={cn('flex items-center justify-between gap-2')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-shrink-0">
|
||||
{isUser ? (
|
||||
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<Icon name="user-3" className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center">
|
||||
{hasLogo && logoSrc ? (
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt={`${providerID} logo`}
|
||||
className="h-4 w-4"
|
||||
style={{
|
||||
filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)',
|
||||
}}
|
||||
onError={handleLogoError}
|
||||
/>
|
||||
) : (
|
||||
<Icon name="brain-ai-3" className="h-4 w-4"
|
||||
style={{ color: `var(${getAgentColor(agentName).var})` }}/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3
|
||||
className={cn(
|
||||
'font-bold typography-ui-header tracking-tight leading-none',
|
||||
isUser ? 'text-primary' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{isUser ? 'You' : (modelName || 'Assistant')}
|
||||
</h3>
|
||||
{!isUser && agentName && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-1.5 py-0 rounded cursor-default',
|
||||
'agent-badge typography-meta',
|
||||
'hover:bg-[rgb(from_var(--agent-color-bg)_r_g_b_/_0.1)] hover:border-[rgb(from_var(--agent-color)_r_g_b_/_0.2)]',
|
||||
getAgentColor(agentName).class
|
||||
)}
|
||||
>
|
||||
<Icon name="ai-agent" className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="font-medium">{agentName}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isUser && variant && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-1.5 py-0 rounded cursor-default',
|
||||
'agent-badge typography-meta',
|
||||
'hover:bg-[rgb(from_var(--agent-color-bg)_r_g_b_/_0.1)] hover:border-[rgb(from_var(--agent-color)_r_g_b_/_0.2)]',
|
||||
variant === 'Default' ? undefined : 'agent-info'
|
||||
)}
|
||||
style={
|
||||
variant === 'Default'
|
||||
? ({
|
||||
'--agent-color': 'var(--muted-foreground)',
|
||||
'--agent-color-bg': 'var(--muted-foreground)',
|
||||
} as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Icon name="brain-ai-3" className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="font-medium">{variant.length > 0 ? variant[0].toLowerCase() + variant.slice(1) : variant}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(MessageHeader);
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { deriveMessageRole } from './messageRole';
|
||||
import { filterVisibleParts, normalizeParts } from './partUtils';
|
||||
import { normalizeUserDisplayParts } from './normalizeUserDisplayParts';
|
||||
|
||||
/**
|
||||
* A user message is hidden when none of its parts survive display
|
||||
* normalization (e.g. synthetic subagent-completion nudges). Turns separated
|
||||
* only by such messages should render as one continuous flow.
|
||||
*/
|
||||
// Streaming recomputes turn projections often; cache by parts reference so
|
||||
// unchanged messages resolve without re-running display normalization.
|
||||
const hiddenByPartsPlanMode = new WeakMap<Part[], boolean>();
|
||||
const hiddenByPartsNoPlanMode = new WeakMap<Part[], boolean>();
|
||||
|
||||
export const isHiddenUserMessage = (
|
||||
entry: { info: Message; parts: Part[] } | null | undefined,
|
||||
options: { planModeEnabled: boolean }
|
||||
): boolean => {
|
||||
if (!entry) return false;
|
||||
if (!deriveMessageRole(entry.info).isUser) return false;
|
||||
|
||||
const cache = options.planModeEnabled ? hiddenByPartsPlanMode : hiddenByPartsNoPlanMode;
|
||||
const cached = cache.get(entry.parts);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const parts = normalizeUserDisplayParts(normalizeParts(entry.parts), { planModeEnabled: options.planModeEnabled });
|
||||
const hidden = filterVisibleParts(parts, { includeReasoning: true }).length === 0;
|
||||
cache.set(entry.parts, hidden);
|
||||
return hidden;
|
||||
};
|
||||
Reference in New Issue
Block a user