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