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:
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user