From 8e718ee58cd0ef51cf23457c9c5c70225657fca8 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 19 Jul 2026 22:58:22 +0300 Subject: [PATCH] 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 --- .../ui/src/components/chat/ChatMessage.tsx | 32 ++-- .../ui/src/components/chat/MessageList.tsx | 55 +++++- .../components/chat/hooks/useTurnRecords.ts | 12 +- .../chat/lib/turns/projectTurnActivity.ts | 14 +- .../chat/lib/turns/projectTurnRecords.test.ts | 82 ++++++++ .../chat/lib/turns/projectTurnRecords.ts | 22 +++ .../chat/lib/turns/projectTurnSummary.ts | 6 + .../chat/lib/turns/streamingTailEntry.ts | 15 +- .../lib/turns/turnProjectionCache.test.ts | 8 +- .../chat/lib/turns/turnProjectionCache.ts | 5 +- .../components/chat/message/MessageBody.tsx | 178 +++++++++++++++--- .../components/chat/message/MessageHeader.tsx | 97 ---------- .../chat/message/hiddenUserMessage.ts | 34 ++++ 13 files changed, 402 insertions(+), 158 deletions(-) delete mode 100644 packages/ui/src/components/chat/message/MessageHeader.tsx create mode 100644 packages/ui/src/components/chat/message/hiddenUserMessage.ts diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index b3a577a2..ae5bbc1f 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ ) ) : (
- {shouldShowHeader && ( - - )} - = ({ errorMessage={assistantErrorText} errorVariant={assistantErrorVariant} reviewTransferDirection={reviewTransferDirection} + footerProviderID={headerProviderID} + footerModelName={headerModelName} + footerAgentName={headerAgentName} + footerVariant={headerVariant} + isDarkTheme={isDarkTheme} />
diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 79057501..6e43b76d 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -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; @@ -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 ( - + ); }); @@ -893,6 +907,7 @@ const MessageListEntry = React.memo(({ { 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 ( (({ }); }), [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(({ 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(() => { + 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(({ } 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; }, }; diff --git a/packages/ui/src/components/chat/hooks/useTurnRecords.ts b/packages/ui/src/components/chat/hooks/useTurnRecords.ts index a7b94938..8228ccc1 100644 --- a/packages/ui/src/components/chat/hooks/useTurnRecords.ts +++ b/packages/ui/src/components/chat/hooks/useTurnRecords.ts @@ -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(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 diff --git a/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts b/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts index 416efc67..86921e31 100644 --- a/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts +++ b/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts @@ -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'; } diff --git a/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts index 6eb32053..f7d9f22b 100644 --- a/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts +++ b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts @@ -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); + }); }); diff --git a/packages/ui/src/components/chat/lib/turns/projectTurnRecords.ts b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.ts index bdff1b45..c7232e51 100644 --- a/packages/ui/src/components/chat/lib/turns/projectTurnRecords.ts +++ b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.ts @@ -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(); const groupedMessageIds = new Set(); + 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, diff --git a/packages/ui/src/components/chat/lib/turns/projectTurnSummary.ts b/packages/ui/src/components/chat/lib/turns/projectTurnSummary.ts index b1117728..9a473c63 100644 --- a/packages/ui/src/components/chat/lib/turns/projectTurnSummary.ts +++ b/packages/ui/src/components/chat/lib/turns/projectTurnSummary.ts @@ -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]; diff --git a/packages/ui/src/components/chat/lib/turns/streamingTailEntry.ts b/packages/ui/src/components/chat/lib/turns/streamingTailEntry.ts index b1564452..2bf7dd2a 100644 --- a/packages/ui/src/components/chat/lib/turns/streamingTailEntry.ts +++ b/packages/ui/src/components/chat/lib/turns/streamingTailEntry.ts @@ -19,6 +19,7 @@ type BuildLiveStreamingEntryOptions = { liveParts: Part[]; showTextJustificationActivity: boolean; showTurnChangedFiles: boolean; + mergeHiddenUserTurns?: { planModeEnabled: boolean }; }; const withLiveParts = ( @@ -69,9 +70,21 @@ export const buildLiveStreamingEntry = ( 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, diff --git a/packages/ui/src/components/chat/lib/turns/turnProjectionCache.test.ts b/packages/ui/src/components/chat/lib/turns/turnProjectionCache.test.ts index 47471a9a..2f11bcaa 100644 --- a/packages/ui/src/components/chat/lib/turns/turnProjectionCache.test.ts +++ b/packages/ui/src/components/chat/lib/turns/turnProjectionCache.test.ts @@ -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); }); diff --git a/packages/ui/src/components/chat/lib/turns/turnProjectionCache.ts b/packages/ui/src/components/chat/lib/turns/turnProjectionCache.ts index 39c4c1ac..91b493c1 100644 --- a/packages/ui/src/components/chat/lib/turns/turnProjectionCache.ts +++ b/packages/ui/src/components/chat/lib/turns/turnProjectionCache.ts @@ -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 diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index e985e64f..1d6558e2 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -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) => { 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 ( +
+ +
+ ); + }; + + // 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(); + visibleParts.forEach((part, partIndex) => { + if (part.type === 'tool') { + localToolPartIds.add(part.id ?? `${messageId}-part-${partIndex}-${part.type}`); + } + }); + const segmentsAfterLocalTool = new Map(); 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( -
- -
- ); + 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(({ ); + flushSegmentsAfterTool(toolPartId); i++; continue; } @@ -1924,6 +1982,7 @@ const AssistantMessageBody = React.memo(({ ); + 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 && (
-
- {messageActionButtons} - {finalTurnActionButtons} -
+
+ {footerModelName ? ( + + {footerHasLogo && footerLogoSrc ? ( + + ) : ( + + )} + {footerModelName} + + ) : null} + {footerVariant && !['default', 'none'].includes(footerVariant.toLowerCase()) ? ( + + + + {footerVariant[0].toLowerCase() + footerVariant.slice(1)} + + + ) : null} + {footerAgentName ? ( + + + {footerAgentName} + + ) : null} {turnDurationText ? ( @@ -2205,6 +2308,19 @@ const AssistantMessageBody = React.memo(({ isInteractive={turnGroupingContext?.isLatestTurn === true} /> ) : null} +
+
+ {messageActionButtons} + {finalTurnActionButtons} +
)} diff --git a/packages/ui/src/components/chat/message/MessageHeader.tsx b/packages/ui/src/components/chat/message/MessageHeader.tsx deleted file mode 100644 index aedd2e63..00000000 --- a/packages/ui/src/components/chat/message/MessageHeader.tsx +++ /dev/null @@ -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 = ({ isUser, providerID, agentName, modelName, variant, isDarkTheme }) => { - const { src: logoSrc, onError: handleLogoError, hasLogo } = useProviderLogo(providerID); - - return ( -
-
-
-
- {isUser ? ( -
- -
- ) : ( -
- {hasLogo && logoSrc ? ( - {`${providerID} - ) : ( - - )} -
- )} -
-
-

- {isUser ? 'You' : (modelName || 'Assistant')} -

- {!isUser && agentName && ( -
- - {agentName} -
- )} - {!isUser && variant && ( -
- - {variant.length > 0 ? variant[0].toLowerCase() + variant.slice(1) : variant} -
- )} -
-
-
-
- ); -}; - -export default React.memo(MessageHeader); diff --git a/packages/ui/src/components/chat/message/hiddenUserMessage.ts b/packages/ui/src/components/chat/message/hiddenUserMessage.ts new file mode 100644 index 00000000..2fc300e9 --- /dev/null +++ b/packages/ui/src/components/chat/message/hiddenUserMessage.ts @@ -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(); +const hiddenByPartsNoPlanMode = new WeakMap(); + +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; +};