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
@@ -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;
},
};