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:
Bohdan Triapitsyn
2026-07-19 22:58:22 +03:00
parent 1014be696a
commit 8e718ee58c
13 changed files with 402 additions and 158 deletions
@@ -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;
};