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
+18 -14
View File
@@ -14,13 +14,13 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { cn } from '@/lib/utils';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
import MessageHeader from './message/MessageHeader';
import MessageBody from './message/MessageBody';
import type { AgentMentionInfo } from './message/types';
import type { StreamPhase, ToolPopupContent } from './message/types';
import { deriveMessageRole } from './message/messageRole';
import { filterVisibleParts, normalizeParts } from './message/partUtils';
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
import { isHiddenUserMessage } from './message/hiddenUserMessage';
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
@@ -593,6 +593,16 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const hasTurnGrouping = Boolean(turnGroupingContext);
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
const previousIsHiddenUserMessage = React.useMemo(
() => !isUser && isHiddenUserMessage(previousMessage, { planModeEnabled }),
[isUser, planModeEnabled, previousMessage]
);
const nextIsHiddenUserMessage = React.useMemo(
() => !isUser && isHiddenUserMessage(nextMessage, { planModeEnabled }),
[isUser, planModeEnabled, nextMessage]
);
const isFollowedByAssistant = React.useMemo(() => {
if (isUser) return false;
if (hasTurnGrouping) {
@@ -1006,7 +1016,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return null;
}
const assistantTopPaddingClass = !isUser && shouldShowHeader
const assistantTopPaddingClass = !isUser && shouldShowHeader && !previousIsHiddenUserMessage
? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0')
: 'pt-0';
const userMessageRadius = 'var(--radius-xl)';
@@ -1017,7 +1027,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
className={cn(
'group w-full',
isUser ? (isMobile ? 'pt-2' : 'pt-6') : assistantTopPaddingClass,
isUser ? 'pb-0' : isFollowedByAssistant ? 'pb-0' : 'pb-8'
isUser ? 'pb-0' : (isFollowedByAssistant || nextIsHiddenUserMessage) ? 'pb-0' : 'pb-8'
)}
id={`message-${message.info.id}`}
data-message-id={message.info.id}
@@ -1121,17 +1131,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
)
) : (
<div className="relative">
{shouldShowHeader && (
<MessageHeader
isUser={isUser}
providerID={headerProviderID}
agentName={headerAgentName}
modelName={headerModelName}
variant={headerVariant}
isDarkTheme={isDarkTheme}
/>
)}
<MessageBody
sessionId={message.info.sessionID}
messageId={message.info.id}
@@ -1166,6 +1165,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
errorMessage={assistantErrorText}
errorVariant={assistantErrorVariant}
reviewTransferDirection={reviewTransferDirection}
footerProviderID={headerProviderID}
footerModelName={headerModelName}
footerAgentName={headerAgentName}
footerVariant={headerVariant}
isDarkTheme={isDarkTheme}
/>
</div>
@@ -12,6 +12,8 @@ import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
import { buildLiveStreamingEntry } from './lib/turns/streamingTailEntry';
import { getNormalizedMessageForDisplay, hasCompactionPart } from './lib/messageDisplayNormalization';
import { useUIStore } from '@/stores/useUIStore';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { isHiddenUserMessage } from './message/hiddenUserMessage';
import { FadeInDisabledProvider } from './message/FadeInOnReveal';
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
@@ -385,7 +387,7 @@ type RenderEntry =
previousMessage?: ChatMessageEntry;
nextMessage?: ChatMessageEntry;
}
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean; nextEntryFirstMessage?: ChatMessageEntry };
type TurnUiState = { isExpanded: boolean };
@@ -469,6 +471,7 @@ MessageRow.displayName = 'MessageRow';
interface TurnBlockProps {
turn: TurnRecord;
isLastTurn: boolean;
nextEntryFirstMessage?: ChatMessageEntry;
sessionIsWorking: boolean;
defaultActivityExpanded: boolean;
turnUiStates: Map<string, TurnUiState>;
@@ -488,6 +491,7 @@ interface TurnBlockProps {
const TurnBlock = React.memo(({
turn,
isLastTurn,
nextEntryFirstMessage,
sessionIsWorking,
defaultActivityExpanded,
turnUiStates,
@@ -503,6 +507,11 @@ const TurnBlock = React.memo(({
activeStreamingPhase,
reviewTransferDirection,
}: TurnBlockProps) => {
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
const userMessageHidden = React.useMemo(
() => isHiddenUserMessage(turn.userMessage, { planModeEnabled }),
[planModeEnabled, turn.userMessage]
);
const turnUiState = turnUiStates.get(turn.turnId) ?? { isExpanded: defaultActivityExpanded };
const handleToggleTurnGroup = React.useCallback(() => {
onToggleTurnGroup(turn.turnId);
@@ -682,7 +691,7 @@ const TurnBlock = React.memo(({
: (typeof messageIndex === 'number' && messageIndex > 0
? messageOrder.ordered[messageIndex - 1]
: undefined));
const nextMessage = undefined;
const nextMessage = isAssistantMessage && isLastAssistant ? nextEntryFirstMessage : undefined;
const turnGroupingContext = isAssistantMessage
? {
@@ -735,6 +744,7 @@ const TurnBlock = React.memo(({
[
getAnimationHandlers,
isLastTurn,
nextEntryFirstMessage,
messageOrder.lookup,
messageOrder.ordered,
onMessageContentChange,
@@ -772,7 +782,11 @@ const TurnBlock = React.memo(({
}, [turn, visibleAssistantMessages]);
return (
<TurnItem turn={renderableTurn} stickyUserHeader={stickyUserHeader} renderMessage={renderMessage} />
<TurnItem
turn={renderableTurn}
stickyUserHeader={stickyUserHeader && !userMessageHidden}
renderMessage={renderMessage}
/>
);
});
@@ -893,6 +907,7 @@ const MessageListEntry = React.memo(({
<TurnBlock
turn={entry.turn}
isLastTurn={entry.isLastTurn}
nextEntryFirstMessage={entry.nextEntryFirstMessage}
sessionIsWorking={sessionIsWorking}
defaultActivityExpanded={defaultActivityExpanded}
turnUiStates={turnUiStates}
@@ -1204,12 +1219,14 @@ const StreamingTailContent: React.FC<{
reviewTransferDirection,
}) => {
const liveParts = useSessionParts(activeStreamingMessageId ?? '', directory);
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
const liveEntry = React.useMemo(() => buildLiveStreamingEntry(entry, {
activeStreamingMessageId,
liveParts,
showTextJustificationActivity: chatRenderMode === 'sorted',
showTurnChangedFiles,
}), [activeStreamingMessageId, chatRenderMode, entry, liveParts, showTurnChangedFiles]);
mergeHiddenUserTurns: { planModeEnabled },
}), [activeStreamingMessageId, chatRenderMode, entry, liveParts, showTurnChangedFiles, planModeEnabled]);
return (
<MessageListEntry
@@ -1358,10 +1375,12 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
});
}), [baseDisplayMessages, retryOverlay]);
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
const { projection, staticTurns, streamingTurn } = useTurnRecords(displayMessages, {
sessionKey,
showTextJustificationActivity: chatRenderMode === 'sorted',
showTurnChangedFiles,
planModeEnabled,
});
const hasUngroupedStaticEntries = projection.ungroupedMessageIds.size > 0;
const staticEntryMessages = hasUngroupedStaticEntries ? displayMessages : EMPTY_STATIC_ENTRY_MESSAGES;
@@ -1439,7 +1458,29 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
streamPerfCount('ui.message_list.render.streaming');
}
const historyEntries = staticRenderEntries;
// Depend on the trailing entry's first message (stable while its assistant
// streams), not the trailing entry itself, so streaming updates do not
// recreate every static entry and re-render every turn block.
const trailingEntryFirstMessage = trailingStreamingEntry
? (trailingStreamingEntry.kind === 'turn' ? trailingStreamingEntry.turn.userMessage : trailingStreamingEntry.message)
: undefined;
const historyEntries = React.useMemo<RenderEntry[]>(() => {
return staticRenderEntries.map((entry, index) => {
if (entry.kind !== 'turn') {
return entry;
}
const nextEntryFirstMessage = index < staticRenderEntries.length - 1
? (() => {
const nextEntry = staticRenderEntries[index + 1];
return nextEntry.kind === 'turn' ? nextEntry.turn.userMessage : nextEntry.message;
})()
: trailingEntryFirstMessage;
if (!nextEntryFirstMessage) {
return entry;
}
return { ...entry, nextEntryFirstMessage };
});
}, [staticRenderEntries, trailingEntryFirstMessage]);
// All surfaces virtualize with @tanstack/react-virtual (see the engine
// note at the top of the file). An unvirtualized list is kept only for
// tiny histories where windowing overhead is not worth it.
@@ -1745,7 +1786,9 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
}
const container = resolveScrollContainer();
if (!container) return;
container.scrollTop = container.scrollHeight;
// Overshoot so the browser clamps to the exact fractional
// maximum (scrollHeight is integer-rounded) — see useChatAutoFollow.
container.scrollTop = container.scrollHeight + 4096;
},
};
@@ -8,6 +8,7 @@ interface UseTurnRecordsOptions {
sessionKey?: string;
showTextJustificationActivity: boolean;
showTurnChangedFiles: boolean;
planModeEnabled: boolean;
}
export interface TurnRecordsResult {
@@ -26,15 +27,18 @@ export const useTurnRecords = (
const previousSessionKeyRef = React.useRef<string | undefined>(options.sessionKey);
const previousShowTextJustificationActivityRef = React.useRef(options.showTextJustificationActivity);
const previousShowTurnChangedFilesRef = React.useRef(options.showTurnChangedFiles);
const previousPlanModeEnabledRef = React.useRef(options.planModeEnabled);
if (
previousSessionKeyRef.current !== options.sessionKey
|| previousShowTextJustificationActivityRef.current !== options.showTextJustificationActivity
|| previousShowTurnChangedFilesRef.current !== options.showTurnChangedFiles
|| previousPlanModeEnabledRef.current !== options.planModeEnabled
) {
previousSessionKeyRef.current = options.sessionKey;
previousShowTextJustificationActivityRef.current = options.showTextJustificationActivity;
previousShowTurnChangedFilesRef.current = options.showTurnChangedFiles;
previousPlanModeEnabledRef.current = options.planModeEnabled;
previousProjectionRef.current = null;
staticTurnsRef.current = [];
streamingTurnRef.current = undefined;
@@ -44,15 +48,17 @@ export const useTurnRecords = (
previousProjectionRef.current = null;
staticTurnsRef.current = [];
streamingTurnRef.current = undefined;
}, [options.sessionKey, options.showTextJustificationActivity, options.showTurnChangedFiles]);
}, [options.sessionKey, options.showTextJustificationActivity, options.showTurnChangedFiles, options.planModeEnabled]);
const projection = React.useMemo(() => {
const sessionKey = options.sessionKey ?? '';
const mergeKey = options.planModeEnabled ? 'merge:plan' : 'merge';
const cached = getCachedProjection(
sessionKey,
messages,
options.showTextJustificationActivity,
options.showTurnChangedFiles,
mergeKey,
);
if (cached) {
previousProjectionRef.current = cached;
@@ -64,6 +70,7 @@ export const useTurnRecords = (
previousProjection: previousProjectionRef.current,
showTextJustificationActivity: options.showTextJustificationActivity,
showTurnChangedFiles: options.showTurnChangedFiles,
mergeHiddenUserTurns: { planModeEnabled: options.planModeEnabled },
});
previousProjectionRef.current = nextProjection;
@@ -72,12 +79,13 @@ export const useTurnRecords = (
messages,
options.showTextJustificationActivity,
options.showTurnChangedFiles,
mergeKey,
);
setCachedProjection(cacheKey, nextProjection);
return nextProjection;
});
}, [messages, options.showTextJustificationActivity, options.showTurnChangedFiles, options.sessionKey]);
}, [messages, options.showTextJustificationActivity, options.showTurnChangedFiles, options.sessionKey, options.planModeEnabled]);
const staticTurns = React.useMemo(() => {
const nextStatic = projection.turns.length <= 1
@@ -36,6 +36,10 @@ const getMessageFinish = (message: ChatMessageEntry): string | undefined => {
return typeof finish === 'string' ? finish : undefined;
};
const isCompactionSummaryMessage = (message: ChatMessageEntry): boolean => {
return (message.info as { summary?: unknown }).summary === true;
};
const buildTurnPartRecord = (
turnId: string,
messageId: string,
@@ -93,6 +97,7 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
input.assistantMessages.forEach((message) => {
const finish = getMessageFinish(message);
const messageHasTool = message.parts.some((part) => part.type === 'tool');
const messageIsCompactionSummary = isCompactionSummaryMessage(message);
message.parts.forEach((part, partIndex) => {
const isTool = part.type === 'tool';
@@ -132,8 +137,13 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
input.showTextJustificationActivity
&& part.type === 'text'
&& text
&& !isConfirmedSummaryText
&& (messageHasTool || (typeof finish === 'string' && finish !== 'stop'))
&& (
messageIsCompactionSummary
|| (
!isConfirmedSummaryText
&& (messageHasTool || (typeof finish === 'string' && finish !== 'stop'))
)
)
) {
kind = 'justification';
}
@@ -139,4 +139,86 @@ describe('projectTurnRecords', () => {
expect(next.turns).toBe(initial.turns);
expect(next.turns[0]).toBe(initial.turns[0]);
});
test('merges turns started by hidden user messages when merging is enabled', () => {
const user1 = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 });
user1.parts = [{ id: 'p1', type: 'text', text: 'visible prompt' } as Part];
const assistant1 = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 });
const hiddenUser = createMessageEntry({ id: 'u2', role: 'user', createdAt: 3 });
const assistant2 = createMessageEntry({ id: 'a2', role: 'assistant', parentID: 'u2', createdAt: 4 });
const projection = projectTurnRecords([user1, assistant1, hiddenUser, assistant2], {
mergeHiddenUserTurns: { planModeEnabled: false },
});
expect(projection.turns).toHaveLength(1);
expect(projection.turns[0]?.turnId).toBe('u1');
expect(projection.turns[0]?.assistantMessageIds).toEqual(['a1', 'a2']);
expect(projection.ungroupedMessageIds.has('u2')).toBe(false);
});
test('keeps hidden user messages as separate turns when merging is disabled', () => {
const user1 = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 });
const assistant1 = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 });
const hiddenUser = createMessageEntry({ id: 'u2', role: 'user', createdAt: 3 });
const assistant2 = createMessageEntry({ id: 'a2', role: 'assistant', parentID: 'u2', createdAt: 4 });
const projection = projectTurnRecords([user1, assistant1, hiddenUser, assistant2]);
expect(projection.turns).toHaveLength(2);
expect(projection.turns[1]?.turnId).toBe('u2');
});
test('does not merge a hidden user message when there is no previous turn', () => {
const hiddenUser = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 });
const assistant = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 });
const projection = projectTurnRecords([hiddenUser, assistant], {
mergeHiddenUserTurns: { planModeEnabled: false },
});
expect(projection.turns).toHaveLength(1);
expect(projection.turns[0]?.turnId).toBe('u1');
expect(projection.turns[0]?.assistantMessageIds).toEqual(['a1']);
});
test('chains merges across consecutive hidden user messages', () => {
const user1 = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 });
user1.parts = [{ id: 'p1', type: 'text', text: 'visible prompt' } as Part];
const assistant1 = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 });
const hidden1 = createMessageEntry({ id: 'u2', role: 'user', createdAt: 3 });
const assistant2 = createMessageEntry({ id: 'a2', role: 'assistant', parentID: 'u2', createdAt: 4 });
const hidden2 = createMessageEntry({ id: 'u3', role: 'user', createdAt: 5 });
const assistant3 = createMessageEntry({ id: 'a3', role: 'assistant', parentID: 'u3', createdAt: 6 });
const projection = projectTurnRecords([user1, assistant1, hidden1, assistant2, hidden2, assistant3], {
mergeHiddenUserTurns: { planModeEnabled: false },
});
expect(projection.turns).toHaveLength(1);
expect(projection.turns[0]?.assistantMessageIds).toEqual(['a1', 'a2', 'a3']);
});
test('treats compaction summary text as justification activity in sorted mode', () => {
const user = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 });
user.parts = [{ id: 'p1', type: 'text', text: 'prompt' } as Part];
const compaction = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 });
(compaction.info as { summary?: boolean; finish?: string }).summary = true;
(compaction.info as { summary?: boolean; finish?: string }).finish = 'stop';
compaction.parts = [{ id: 'cp1', type: 'text', text: 'compacted context summary' } as Part];
const assistant = createMessageEntry({ id: 'a2', role: 'assistant', parentID: 'u1', createdAt: 3 });
(assistant.info as { finish?: string }).finish = 'stop';
assistant.parts = [{ id: 'ap1', type: 'text', text: 'final answer' } as Part];
const projection = projectTurnRecords([user, compaction, assistant], {
showTextJustificationActivity: true,
});
const turn = projection.turns[0];
expect(turn?.summaryText).toBe('final answer');
const compactionActivity = turn?.activityParts.find((activity) => activity.messageId === 'a1');
expect(compactionActivity?.kind).toBe('justification');
const finalActivity = turn?.activityParts.find((activity) => activity.messageId === 'a2');
expect(finalActivity).toBe(undefined);
});
});
@@ -1,3 +1,4 @@
import { isHiddenUserMessage } from '../../message/hiddenUserMessage';
import { projectTurnActivity } from './projectTurnActivity';
import { projectTurnIndexes } from './projectTurnIndexes';
import { projectTurnChangedFiles, projectTurnDiffStats, projectTurnSummary } from './projectTurnSummary';
@@ -84,12 +85,19 @@ interface ProjectTurnRecordsOptions {
previousProjection?: TurnProjectionResult | null;
showTextJustificationActivity: boolean;
showTurnChangedFiles: boolean;
/**
* When set, a turn whose user message is hidden (no visible display parts,
* e.g. synthetic subagent-completion nudges) is merged into the previous
* turn instead of starting a new one.
*/
mergeHiddenUserTurns?: { planModeEnabled: boolean };
}
const DEFAULT_OPTIONS: ProjectTurnRecordsOptions = {
previousProjection: null,
showTextJustificationActivity: false,
showTurnChangedFiles: false,
mergeHiddenUserTurns: undefined,
};
const areSameMessageRefs = (left: ChatMessageEntry[], right: ChatMessageEntry[]): boolean => {
@@ -191,12 +199,26 @@ export const projectTurnRecords = (
const turnByUserId = new Map<string, TurnRecord>();
const groupedMessageIds = new Set<string>();
const mergeHiddenUserTurns = effectiveOptions.mergeHiddenUserTurns;
messages.forEach((message, index) => {
const role = resolveMessageRole(message);
if (role !== 'user') {
return;
}
const previousTurn = turns[turns.length - 1];
if (
mergeHiddenUserTurns
&& previousTurn
&& isHiddenUserMessage(message, { planModeEnabled: mergeHiddenUserTurns.planModeEnabled })
) {
turnByUserId.set(message.info.id, previousTurn);
previousTurn.messages.push(createTurnMessageRecord(message, index));
groupedMessageIds.add(message.info.id);
return;
}
const turnId = message.info.id;
const turn: TurnRecord = {
turnId,
@@ -23,10 +23,15 @@ const getTextFromPart = (part: unknown): string | undefined => {
return undefined;
};
const isCompactionSummaryMessage = (message: ChatMessageEntry): boolean => {
return (message.info as { summary?: unknown }).summary === true;
};
export const projectTurnSummary = (assistantMessages: ChatMessageEntry[]): TurnSummaryRecord => {
for (let messageIndex = assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const assistantMessage = assistantMessages[messageIndex];
if (!assistantMessage) continue;
if (isCompactionSummaryMessage(assistantMessage)) continue;
const finish = (assistantMessage.info as { finish?: string | null }).finish;
if (finish !== 'stop') continue;
@@ -49,6 +54,7 @@ export const projectTurnSummary = (assistantMessages: ChatMessageEntry[]): TurnS
for (let messageIndex = assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const assistantMessage = assistantMessages[messageIndex];
if (!assistantMessage) continue;
if (isCompactionSummaryMessage(assistantMessage)) continue;
for (let partIndex = assistantMessage.parts.length - 1; partIndex >= 0; partIndex -= 1) {
const part = assistantMessage.parts[partIndex];
@@ -19,6 +19,7 @@ type BuildLiveStreamingEntryOptions = {
liveParts: Part[];
showTextJustificationActivity: boolean;
showTurnChangedFiles: boolean;
mergeHiddenUserTurns?: { planModeEnabled: boolean };
};
const withLiveParts = (
@@ -69,9 +70,21 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
return entry;
}
const projection = projectTurnRecords([entry.turn.userMessage, ...assistantMessages], {
// Re-project from the turn's full ordered message records (not just
// userMessage + assistants) so hidden user messages merged into this turn
// keep parenting their assistant replies.
const liveMessageById = new Map(assistantMessages.map((message) => [message.info.id, message]));
const sourceMessages = entry.turn.messages.length > 0
? entry.turn.messages
.slice()
.sort((left, right) => left.order - right.order)
.map((record) => liveMessageById.get(record.messageId) ?? record.message)
: [entry.turn.userMessage, ...assistantMessages];
const projection = projectTurnRecords(sourceMessages, {
showTextJustificationActivity: options.showTextJustificationActivity,
showTurnChangedFiles: options.showTurnChangedFiles,
mergeHiddenUserTurns: options.mergeHiddenUserTurns,
});
const turn = projection.turns[0] ?? {
...entry.turn,
@@ -12,8 +12,8 @@ describe('turnProjectionCache', () => {
test('keeps the cache key stable for unchanged message and part references', () => {
const messages = [createEntry('hello')];
const first = buildProjectionCacheKey('session_1', messages, false, false);
const second = buildProjectionCacheKey('session_1', messages, false, false);
const first = buildProjectionCacheKey('session_1', messages, false, false, 'merge');
const second = buildProjectionCacheKey('session_1', messages, false, false, 'merge');
expect(second).toBe(first);
});
@@ -27,8 +27,8 @@ describe('turnProjectionCache', () => {
},
];
const beforeKey = buildProjectionCacheKey('session_1', before, false, false);
const afterKey = buildProjectionCacheKey('session_1', after, false, false);
const beforeKey = buildProjectionCacheKey('session_1', before, false, false, 'merge');
const afterKey = buildProjectionCacheKey('session_1', after, false, false, 'merge');
expect(afterKey).not.toBe(beforeKey);
});
@@ -39,6 +39,7 @@ export const buildProjectionCacheKey = (
messages: ChatMessageEntry[],
showTextJustificationActivity: boolean,
showTurnChangedFiles: boolean,
mergeHiddenUserTurnsKey: string,
): string => {
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined;
const lastMessageId = lastMessage?.info?.id ?? '';
@@ -51,6 +52,7 @@ export const buildProjectionCacheKey = (
buildMessagesVersionSignature(messages),
showTextJustificationActivity ? '1' : '0',
showTurnChangedFiles ? '1' : '0',
mergeHiddenUserTurnsKey,
].join('|');
};
@@ -59,8 +61,9 @@ export const getCachedProjection = (
messages: ChatMessageEntry[],
showTextJustificationActivity: boolean,
showTurnChangedFiles: boolean,
mergeHiddenUserTurnsKey: string,
): TurnProjectionResult | undefined => {
const key = buildProjectionCacheKey(sessionKey, messages, showTextJustificationActivity, showTurnChangedFiles);
const key = buildProjectionCacheKey(sessionKey, messages, showTextJustificationActivity, showTurnChangedFiles, mergeHiddenUserTurnsKey);
const cached = projectionCache.get(key);
if (cached) {
// LRU re-order: move hit to the end (most recent) so it survives
@@ -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;
};