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 { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||||
import MessageHeader from './message/MessageHeader';
|
|
||||||
import MessageBody from './message/MessageBody';
|
import MessageBody from './message/MessageBody';
|
||||||
import type { AgentMentionInfo } from './message/types';
|
import type { AgentMentionInfo } from './message/types';
|
||||||
import type { StreamPhase, ToolPopupContent } from './message/types';
|
import type { StreamPhase, ToolPopupContent } from './message/types';
|
||||||
import { deriveMessageRole } from './message/messageRole';
|
import { deriveMessageRole } from './message/messageRole';
|
||||||
import { filterVisibleParts, normalizeParts } from './message/partUtils';
|
import { filterVisibleParts, normalizeParts } from './message/partUtils';
|
||||||
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
||||||
|
import { isHiddenUserMessage } from './message/hiddenUserMessage';
|
||||||
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
||||||
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
|
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
|
||||||
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
|
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
|
||||||
@@ -593,6 +593,16 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
const hasTurnGrouping = Boolean(turnGroupingContext);
|
const hasTurnGrouping = Boolean(turnGroupingContext);
|
||||||
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
|
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(() => {
|
const isFollowedByAssistant = React.useMemo(() => {
|
||||||
if (isUser) return false;
|
if (isUser) return false;
|
||||||
if (hasTurnGrouping) {
|
if (hasTurnGrouping) {
|
||||||
@@ -1006,7 +1016,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistantTopPaddingClass = !isUser && shouldShowHeader
|
const assistantTopPaddingClass = !isUser && shouldShowHeader && !previousIsHiddenUserMessage
|
||||||
? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0')
|
? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0')
|
||||||
: 'pt-0';
|
: 'pt-0';
|
||||||
const userMessageRadius = 'var(--radius-xl)';
|
const userMessageRadius = 'var(--radius-xl)';
|
||||||
@@ -1017,7 +1027,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'group w-full',
|
'group w-full',
|
||||||
isUser ? (isMobile ? 'pt-2' : 'pt-6') : assistantTopPaddingClass,
|
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}`}
|
id={`message-${message.info.id}`}
|
||||||
data-message-id={message.info.id}
|
data-message-id={message.info.id}
|
||||||
@@ -1121,17 +1131,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{shouldShowHeader && (
|
|
||||||
<MessageHeader
|
|
||||||
isUser={isUser}
|
|
||||||
providerID={headerProviderID}
|
|
||||||
agentName={headerAgentName}
|
|
||||||
modelName={headerModelName}
|
|
||||||
variant={headerVariant}
|
|
||||||
isDarkTheme={isDarkTheme}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MessageBody
|
<MessageBody
|
||||||
sessionId={message.info.sessionID}
|
sessionId={message.info.sessionID}
|
||||||
messageId={message.info.id}
|
messageId={message.info.id}
|
||||||
@@ -1166,6 +1165,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
errorMessage={assistantErrorText}
|
errorMessage={assistantErrorText}
|
||||||
errorVariant={assistantErrorVariant}
|
errorVariant={assistantErrorVariant}
|
||||||
reviewTransferDirection={reviewTransferDirection}
|
reviewTransferDirection={reviewTransferDirection}
|
||||||
|
footerProviderID={headerProviderID}
|
||||||
|
footerModelName={headerModelName}
|
||||||
|
footerAgentName={headerAgentName}
|
||||||
|
footerVariant={headerVariant}
|
||||||
|
isDarkTheme={isDarkTheme}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
|
|||||||
import { buildLiveStreamingEntry } from './lib/turns/streamingTailEntry';
|
import { buildLiveStreamingEntry } from './lib/turns/streamingTailEntry';
|
||||||
import { getNormalizedMessageForDisplay, hasCompactionPart } from './lib/messageDisplayNormalization';
|
import { getNormalizedMessageForDisplay, hasCompactionPart } from './lib/messageDisplayNormalization';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||||
|
import { isHiddenUserMessage } from './message/hiddenUserMessage';
|
||||||
import { FadeInDisabledProvider } from './message/FadeInOnReveal';
|
import { FadeInDisabledProvider } from './message/FadeInOnReveal';
|
||||||
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
|
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
|
||||||
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
|
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
|
||||||
@@ -385,7 +387,7 @@ type RenderEntry =
|
|||||||
previousMessage?: ChatMessageEntry;
|
previousMessage?: ChatMessageEntry;
|
||||||
nextMessage?: 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 };
|
type TurnUiState = { isExpanded: boolean };
|
||||||
|
|
||||||
@@ -469,6 +471,7 @@ MessageRow.displayName = 'MessageRow';
|
|||||||
interface TurnBlockProps {
|
interface TurnBlockProps {
|
||||||
turn: TurnRecord;
|
turn: TurnRecord;
|
||||||
isLastTurn: boolean;
|
isLastTurn: boolean;
|
||||||
|
nextEntryFirstMessage?: ChatMessageEntry;
|
||||||
sessionIsWorking: boolean;
|
sessionIsWorking: boolean;
|
||||||
defaultActivityExpanded: boolean;
|
defaultActivityExpanded: boolean;
|
||||||
turnUiStates: Map<string, TurnUiState>;
|
turnUiStates: Map<string, TurnUiState>;
|
||||||
@@ -488,6 +491,7 @@ interface TurnBlockProps {
|
|||||||
const TurnBlock = React.memo(({
|
const TurnBlock = React.memo(({
|
||||||
turn,
|
turn,
|
||||||
isLastTurn,
|
isLastTurn,
|
||||||
|
nextEntryFirstMessage,
|
||||||
sessionIsWorking,
|
sessionIsWorking,
|
||||||
defaultActivityExpanded,
|
defaultActivityExpanded,
|
||||||
turnUiStates,
|
turnUiStates,
|
||||||
@@ -503,6 +507,11 @@ const TurnBlock = React.memo(({
|
|||||||
activeStreamingPhase,
|
activeStreamingPhase,
|
||||||
reviewTransferDirection,
|
reviewTransferDirection,
|
||||||
}: TurnBlockProps) => {
|
}: 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 turnUiState = turnUiStates.get(turn.turnId) ?? { isExpanded: defaultActivityExpanded };
|
||||||
const handleToggleTurnGroup = React.useCallback(() => {
|
const handleToggleTurnGroup = React.useCallback(() => {
|
||||||
onToggleTurnGroup(turn.turnId);
|
onToggleTurnGroup(turn.turnId);
|
||||||
@@ -682,7 +691,7 @@ const TurnBlock = React.memo(({
|
|||||||
: (typeof messageIndex === 'number' && messageIndex > 0
|
: (typeof messageIndex === 'number' && messageIndex > 0
|
||||||
? messageOrder.ordered[messageIndex - 1]
|
? messageOrder.ordered[messageIndex - 1]
|
||||||
: undefined));
|
: undefined));
|
||||||
const nextMessage = undefined;
|
const nextMessage = isAssistantMessage && isLastAssistant ? nextEntryFirstMessage : undefined;
|
||||||
|
|
||||||
const turnGroupingContext = isAssistantMessage
|
const turnGroupingContext = isAssistantMessage
|
||||||
? {
|
? {
|
||||||
@@ -735,6 +744,7 @@ const TurnBlock = React.memo(({
|
|||||||
[
|
[
|
||||||
getAnimationHandlers,
|
getAnimationHandlers,
|
||||||
isLastTurn,
|
isLastTurn,
|
||||||
|
nextEntryFirstMessage,
|
||||||
messageOrder.lookup,
|
messageOrder.lookup,
|
||||||
messageOrder.ordered,
|
messageOrder.ordered,
|
||||||
onMessageContentChange,
|
onMessageContentChange,
|
||||||
@@ -772,7 +782,11 @@ const TurnBlock = React.memo(({
|
|||||||
}, [turn, visibleAssistantMessages]);
|
}, [turn, visibleAssistantMessages]);
|
||||||
|
|
||||||
return (
|
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
|
<TurnBlock
|
||||||
turn={entry.turn}
|
turn={entry.turn}
|
||||||
isLastTurn={entry.isLastTurn}
|
isLastTurn={entry.isLastTurn}
|
||||||
|
nextEntryFirstMessage={entry.nextEntryFirstMessage}
|
||||||
sessionIsWorking={sessionIsWorking}
|
sessionIsWorking={sessionIsWorking}
|
||||||
defaultActivityExpanded={defaultActivityExpanded}
|
defaultActivityExpanded={defaultActivityExpanded}
|
||||||
turnUiStates={turnUiStates}
|
turnUiStates={turnUiStates}
|
||||||
@@ -1204,12 +1219,14 @@ const StreamingTailContent: React.FC<{
|
|||||||
reviewTransferDirection,
|
reviewTransferDirection,
|
||||||
}) => {
|
}) => {
|
||||||
const liveParts = useSessionParts(activeStreamingMessageId ?? '', directory);
|
const liveParts = useSessionParts(activeStreamingMessageId ?? '', directory);
|
||||||
|
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
||||||
const liveEntry = React.useMemo(() => buildLiveStreamingEntry(entry, {
|
const liveEntry = React.useMemo(() => buildLiveStreamingEntry(entry, {
|
||||||
activeStreamingMessageId,
|
activeStreamingMessageId,
|
||||||
liveParts,
|
liveParts,
|
||||||
showTextJustificationActivity: chatRenderMode === 'sorted',
|
showTextJustificationActivity: chatRenderMode === 'sorted',
|
||||||
showTurnChangedFiles,
|
showTurnChangedFiles,
|
||||||
}), [activeStreamingMessageId, chatRenderMode, entry, liveParts, showTurnChangedFiles]);
|
mergeHiddenUserTurns: { planModeEnabled },
|
||||||
|
}), [activeStreamingMessageId, chatRenderMode, entry, liveParts, showTurnChangedFiles, planModeEnabled]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageListEntry
|
<MessageListEntry
|
||||||
@@ -1358,10 +1375,12 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
});
|
});
|
||||||
}), [baseDisplayMessages, retryOverlay]);
|
}), [baseDisplayMessages, retryOverlay]);
|
||||||
|
|
||||||
|
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
||||||
const { projection, staticTurns, streamingTurn } = useTurnRecords(displayMessages, {
|
const { projection, staticTurns, streamingTurn } = useTurnRecords(displayMessages, {
|
||||||
sessionKey,
|
sessionKey,
|
||||||
showTextJustificationActivity: chatRenderMode === 'sorted',
|
showTextJustificationActivity: chatRenderMode === 'sorted',
|
||||||
showTurnChangedFiles,
|
showTurnChangedFiles,
|
||||||
|
planModeEnabled,
|
||||||
});
|
});
|
||||||
const hasUngroupedStaticEntries = projection.ungroupedMessageIds.size > 0;
|
const hasUngroupedStaticEntries = projection.ungroupedMessageIds.size > 0;
|
||||||
const staticEntryMessages = hasUngroupedStaticEntries ? displayMessages : EMPTY_STATIC_ENTRY_MESSAGES;
|
const staticEntryMessages = hasUngroupedStaticEntries ? displayMessages : EMPTY_STATIC_ENTRY_MESSAGES;
|
||||||
@@ -1439,7 +1458,29 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
streamPerfCount('ui.message_list.render.streaming');
|
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
|
// All surfaces virtualize with @tanstack/react-virtual (see the engine
|
||||||
// note at the top of the file). An unvirtualized list is kept only for
|
// note at the top of the file). An unvirtualized list is kept only for
|
||||||
// tiny histories where windowing overhead is not worth it.
|
// tiny histories where windowing overhead is not worth it.
|
||||||
@@ -1745,7 +1786,9 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
}
|
}
|
||||||
const container = resolveScrollContainer();
|
const container = resolveScrollContainer();
|
||||||
if (!container) return;
|
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;
|
sessionKey?: string;
|
||||||
showTextJustificationActivity: boolean;
|
showTextJustificationActivity: boolean;
|
||||||
showTurnChangedFiles: boolean;
|
showTurnChangedFiles: boolean;
|
||||||
|
planModeEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnRecordsResult {
|
export interface TurnRecordsResult {
|
||||||
@@ -26,15 +27,18 @@ export const useTurnRecords = (
|
|||||||
const previousSessionKeyRef = React.useRef<string | undefined>(options.sessionKey);
|
const previousSessionKeyRef = React.useRef<string | undefined>(options.sessionKey);
|
||||||
const previousShowTextJustificationActivityRef = React.useRef(options.showTextJustificationActivity);
|
const previousShowTextJustificationActivityRef = React.useRef(options.showTextJustificationActivity);
|
||||||
const previousShowTurnChangedFilesRef = React.useRef(options.showTurnChangedFiles);
|
const previousShowTurnChangedFilesRef = React.useRef(options.showTurnChangedFiles);
|
||||||
|
const previousPlanModeEnabledRef = React.useRef(options.planModeEnabled);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
previousSessionKeyRef.current !== options.sessionKey
|
previousSessionKeyRef.current !== options.sessionKey
|
||||||
|| previousShowTextJustificationActivityRef.current !== options.showTextJustificationActivity
|
|| previousShowTextJustificationActivityRef.current !== options.showTextJustificationActivity
|
||||||
|| previousShowTurnChangedFilesRef.current !== options.showTurnChangedFiles
|
|| previousShowTurnChangedFilesRef.current !== options.showTurnChangedFiles
|
||||||
|
|| previousPlanModeEnabledRef.current !== options.planModeEnabled
|
||||||
) {
|
) {
|
||||||
previousSessionKeyRef.current = options.sessionKey;
|
previousSessionKeyRef.current = options.sessionKey;
|
||||||
previousShowTextJustificationActivityRef.current = options.showTextJustificationActivity;
|
previousShowTextJustificationActivityRef.current = options.showTextJustificationActivity;
|
||||||
previousShowTurnChangedFilesRef.current = options.showTurnChangedFiles;
|
previousShowTurnChangedFilesRef.current = options.showTurnChangedFiles;
|
||||||
|
previousPlanModeEnabledRef.current = options.planModeEnabled;
|
||||||
previousProjectionRef.current = null;
|
previousProjectionRef.current = null;
|
||||||
staticTurnsRef.current = [];
|
staticTurnsRef.current = [];
|
||||||
streamingTurnRef.current = undefined;
|
streamingTurnRef.current = undefined;
|
||||||
@@ -44,15 +48,17 @@ export const useTurnRecords = (
|
|||||||
previousProjectionRef.current = null;
|
previousProjectionRef.current = null;
|
||||||
staticTurnsRef.current = [];
|
staticTurnsRef.current = [];
|
||||||
streamingTurnRef.current = undefined;
|
streamingTurnRef.current = undefined;
|
||||||
}, [options.sessionKey, options.showTextJustificationActivity, options.showTurnChangedFiles]);
|
}, [options.sessionKey, options.showTextJustificationActivity, options.showTurnChangedFiles, options.planModeEnabled]);
|
||||||
|
|
||||||
const projection = React.useMemo(() => {
|
const projection = React.useMemo(() => {
|
||||||
const sessionKey = options.sessionKey ?? '';
|
const sessionKey = options.sessionKey ?? '';
|
||||||
|
const mergeKey = options.planModeEnabled ? 'merge:plan' : 'merge';
|
||||||
const cached = getCachedProjection(
|
const cached = getCachedProjection(
|
||||||
sessionKey,
|
sessionKey,
|
||||||
messages,
|
messages,
|
||||||
options.showTextJustificationActivity,
|
options.showTextJustificationActivity,
|
||||||
options.showTurnChangedFiles,
|
options.showTurnChangedFiles,
|
||||||
|
mergeKey,
|
||||||
);
|
);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
previousProjectionRef.current = cached;
|
previousProjectionRef.current = cached;
|
||||||
@@ -64,6 +70,7 @@ export const useTurnRecords = (
|
|||||||
previousProjection: previousProjectionRef.current,
|
previousProjection: previousProjectionRef.current,
|
||||||
showTextJustificationActivity: options.showTextJustificationActivity,
|
showTextJustificationActivity: options.showTextJustificationActivity,
|
||||||
showTurnChangedFiles: options.showTurnChangedFiles,
|
showTurnChangedFiles: options.showTurnChangedFiles,
|
||||||
|
mergeHiddenUserTurns: { planModeEnabled: options.planModeEnabled },
|
||||||
});
|
});
|
||||||
previousProjectionRef.current = nextProjection;
|
previousProjectionRef.current = nextProjection;
|
||||||
|
|
||||||
@@ -72,12 +79,13 @@ export const useTurnRecords = (
|
|||||||
messages,
|
messages,
|
||||||
options.showTextJustificationActivity,
|
options.showTextJustificationActivity,
|
||||||
options.showTurnChangedFiles,
|
options.showTurnChangedFiles,
|
||||||
|
mergeKey,
|
||||||
);
|
);
|
||||||
setCachedProjection(cacheKey, nextProjection);
|
setCachedProjection(cacheKey, nextProjection);
|
||||||
|
|
||||||
return nextProjection;
|
return nextProjection;
|
||||||
});
|
});
|
||||||
}, [messages, options.showTextJustificationActivity, options.showTurnChangedFiles, options.sessionKey]);
|
}, [messages, options.showTextJustificationActivity, options.showTurnChangedFiles, options.sessionKey, options.planModeEnabled]);
|
||||||
|
|
||||||
const staticTurns = React.useMemo(() => {
|
const staticTurns = React.useMemo(() => {
|
||||||
const nextStatic = projection.turns.length <= 1
|
const nextStatic = projection.turns.length <= 1
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ const getMessageFinish = (message: ChatMessageEntry): string | undefined => {
|
|||||||
return typeof finish === 'string' ? finish : undefined;
|
return typeof finish === 'string' ? finish : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isCompactionSummaryMessage = (message: ChatMessageEntry): boolean => {
|
||||||
|
return (message.info as { summary?: unknown }).summary === true;
|
||||||
|
};
|
||||||
|
|
||||||
const buildTurnPartRecord = (
|
const buildTurnPartRecord = (
|
||||||
turnId: string,
|
turnId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
@@ -93,6 +97,7 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
|
|||||||
input.assistantMessages.forEach((message) => {
|
input.assistantMessages.forEach((message) => {
|
||||||
const finish = getMessageFinish(message);
|
const finish = getMessageFinish(message);
|
||||||
const messageHasTool = message.parts.some((part) => part.type === 'tool');
|
const messageHasTool = message.parts.some((part) => part.type === 'tool');
|
||||||
|
const messageIsCompactionSummary = isCompactionSummaryMessage(message);
|
||||||
|
|
||||||
message.parts.forEach((part, partIndex) => {
|
message.parts.forEach((part, partIndex) => {
|
||||||
const isTool = part.type === 'tool';
|
const isTool = part.type === 'tool';
|
||||||
@@ -132,8 +137,13 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
|
|||||||
input.showTextJustificationActivity
|
input.showTextJustificationActivity
|
||||||
&& part.type === 'text'
|
&& part.type === 'text'
|
||||||
&& text
|
&& text
|
||||||
&& !isConfirmedSummaryText
|
&& (
|
||||||
&& (messageHasTool || (typeof finish === 'string' && finish !== 'stop'))
|
messageIsCompactionSummary
|
||||||
|
|| (
|
||||||
|
!isConfirmedSummaryText
|
||||||
|
&& (messageHasTool || (typeof finish === 'string' && finish !== 'stop'))
|
||||||
|
)
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
kind = 'justification';
|
kind = 'justification';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,4 +139,86 @@ describe('projectTurnRecords', () => {
|
|||||||
expect(next.turns).toBe(initial.turns);
|
expect(next.turns).toBe(initial.turns);
|
||||||
expect(next.turns[0]).toBe(initial.turns[0]);
|
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 { projectTurnActivity } from './projectTurnActivity';
|
||||||
import { projectTurnIndexes } from './projectTurnIndexes';
|
import { projectTurnIndexes } from './projectTurnIndexes';
|
||||||
import { projectTurnChangedFiles, projectTurnDiffStats, projectTurnSummary } from './projectTurnSummary';
|
import { projectTurnChangedFiles, projectTurnDiffStats, projectTurnSummary } from './projectTurnSummary';
|
||||||
@@ -84,12 +85,19 @@ interface ProjectTurnRecordsOptions {
|
|||||||
previousProjection?: TurnProjectionResult | null;
|
previousProjection?: TurnProjectionResult | null;
|
||||||
showTextJustificationActivity: boolean;
|
showTextJustificationActivity: boolean;
|
||||||
showTurnChangedFiles: 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 = {
|
const DEFAULT_OPTIONS: ProjectTurnRecordsOptions = {
|
||||||
previousProjection: null,
|
previousProjection: null,
|
||||||
showTextJustificationActivity: false,
|
showTextJustificationActivity: false,
|
||||||
showTurnChangedFiles: false,
|
showTurnChangedFiles: false,
|
||||||
|
mergeHiddenUserTurns: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const areSameMessageRefs = (left: ChatMessageEntry[], right: ChatMessageEntry[]): boolean => {
|
const areSameMessageRefs = (left: ChatMessageEntry[], right: ChatMessageEntry[]): boolean => {
|
||||||
@@ -191,12 +199,26 @@ export const projectTurnRecords = (
|
|||||||
const turnByUserId = new Map<string, TurnRecord>();
|
const turnByUserId = new Map<string, TurnRecord>();
|
||||||
const groupedMessageIds = new Set<string>();
|
const groupedMessageIds = new Set<string>();
|
||||||
|
|
||||||
|
const mergeHiddenUserTurns = effectiveOptions.mergeHiddenUserTurns;
|
||||||
|
|
||||||
messages.forEach((message, index) => {
|
messages.forEach((message, index) => {
|
||||||
const role = resolveMessageRole(message);
|
const role = resolveMessageRole(message);
|
||||||
if (role !== 'user') {
|
if (role !== 'user') {
|
||||||
return;
|
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 turnId = message.info.id;
|
||||||
const turn: TurnRecord = {
|
const turn: TurnRecord = {
|
||||||
turnId,
|
turnId,
|
||||||
|
|||||||
@@ -23,10 +23,15 @@ const getTextFromPart = (part: unknown): string | undefined => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isCompactionSummaryMessage = (message: ChatMessageEntry): boolean => {
|
||||||
|
return (message.info as { summary?: unknown }).summary === true;
|
||||||
|
};
|
||||||
|
|
||||||
export const projectTurnSummary = (assistantMessages: ChatMessageEntry[]): TurnSummaryRecord => {
|
export const projectTurnSummary = (assistantMessages: ChatMessageEntry[]): TurnSummaryRecord => {
|
||||||
for (let messageIndex = assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
for (let messageIndex = assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||||
const assistantMessage = assistantMessages[messageIndex];
|
const assistantMessage = assistantMessages[messageIndex];
|
||||||
if (!assistantMessage) continue;
|
if (!assistantMessage) continue;
|
||||||
|
if (isCompactionSummaryMessage(assistantMessage)) continue;
|
||||||
|
|
||||||
const finish = (assistantMessage.info as { finish?: string | null }).finish;
|
const finish = (assistantMessage.info as { finish?: string | null }).finish;
|
||||||
if (finish !== 'stop') continue;
|
if (finish !== 'stop') continue;
|
||||||
@@ -49,6 +54,7 @@ export const projectTurnSummary = (assistantMessages: ChatMessageEntry[]): TurnS
|
|||||||
for (let messageIndex = assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
for (let messageIndex = assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||||
const assistantMessage = assistantMessages[messageIndex];
|
const assistantMessage = assistantMessages[messageIndex];
|
||||||
if (!assistantMessage) continue;
|
if (!assistantMessage) continue;
|
||||||
|
if (isCompactionSummaryMessage(assistantMessage)) continue;
|
||||||
|
|
||||||
for (let partIndex = assistantMessage.parts.length - 1; partIndex >= 0; partIndex -= 1) {
|
for (let partIndex = assistantMessage.parts.length - 1; partIndex >= 0; partIndex -= 1) {
|
||||||
const part = assistantMessage.parts[partIndex];
|
const part = assistantMessage.parts[partIndex];
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type BuildLiveStreamingEntryOptions = {
|
|||||||
liveParts: Part[];
|
liveParts: Part[];
|
||||||
showTextJustificationActivity: boolean;
|
showTextJustificationActivity: boolean;
|
||||||
showTurnChangedFiles: boolean;
|
showTurnChangedFiles: boolean;
|
||||||
|
mergeHiddenUserTurns?: { planModeEnabled: boolean };
|
||||||
};
|
};
|
||||||
|
|
||||||
const withLiveParts = (
|
const withLiveParts = (
|
||||||
@@ -69,9 +70,21 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
|
|||||||
return entry;
|
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,
|
showTextJustificationActivity: options.showTextJustificationActivity,
|
||||||
showTurnChangedFiles: options.showTurnChangedFiles,
|
showTurnChangedFiles: options.showTurnChangedFiles,
|
||||||
|
mergeHiddenUserTurns: options.mergeHiddenUserTurns,
|
||||||
});
|
});
|
||||||
const turn = projection.turns[0] ?? {
|
const turn = projection.turns[0] ?? {
|
||||||
...entry.turn,
|
...entry.turn,
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ describe('turnProjectionCache', () => {
|
|||||||
test('keeps the cache key stable for unchanged message and part references', () => {
|
test('keeps the cache key stable for unchanged message and part references', () => {
|
||||||
const messages = [createEntry('hello')];
|
const messages = [createEntry('hello')];
|
||||||
|
|
||||||
const first = buildProjectionCacheKey('session_1', messages, false, false);
|
const first = buildProjectionCacheKey('session_1', messages, false, false, 'merge');
|
||||||
const second = buildProjectionCacheKey('session_1', messages, false, false);
|
const second = buildProjectionCacheKey('session_1', messages, false, false, 'merge');
|
||||||
|
|
||||||
expect(second).toBe(first);
|
expect(second).toBe(first);
|
||||||
});
|
});
|
||||||
@@ -27,8 +27,8 @@ describe('turnProjectionCache', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const beforeKey = buildProjectionCacheKey('session_1', before, false, false);
|
const beforeKey = buildProjectionCacheKey('session_1', before, false, false, 'merge');
|
||||||
const afterKey = buildProjectionCacheKey('session_1', after, false, false);
|
const afterKey = buildProjectionCacheKey('session_1', after, false, false, 'merge');
|
||||||
|
|
||||||
expect(afterKey).not.toBe(beforeKey);
|
expect(afterKey).not.toBe(beforeKey);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const buildProjectionCacheKey = (
|
|||||||
messages: ChatMessageEntry[],
|
messages: ChatMessageEntry[],
|
||||||
showTextJustificationActivity: boolean,
|
showTextJustificationActivity: boolean,
|
||||||
showTurnChangedFiles: boolean,
|
showTurnChangedFiles: boolean,
|
||||||
|
mergeHiddenUserTurnsKey: string,
|
||||||
): string => {
|
): string => {
|
||||||
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||||
const lastMessageId = lastMessage?.info?.id ?? '';
|
const lastMessageId = lastMessage?.info?.id ?? '';
|
||||||
@@ -51,6 +52,7 @@ export const buildProjectionCacheKey = (
|
|||||||
buildMessagesVersionSignature(messages),
|
buildMessagesVersionSignature(messages),
|
||||||
showTextJustificationActivity ? '1' : '0',
|
showTextJustificationActivity ? '1' : '0',
|
||||||
showTurnChangedFiles ? '1' : '0',
|
showTurnChangedFiles ? '1' : '0',
|
||||||
|
mergeHiddenUserTurnsKey,
|
||||||
].join('|');
|
].join('|');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,8 +61,9 @@ export const getCachedProjection = (
|
|||||||
messages: ChatMessageEntry[],
|
messages: ChatMessageEntry[],
|
||||||
showTextJustificationActivity: boolean,
|
showTextJustificationActivity: boolean,
|
||||||
showTurnChangedFiles: boolean,
|
showTurnChangedFiles: boolean,
|
||||||
|
mergeHiddenUserTurnsKey: string,
|
||||||
): TurnProjectionResult | undefined => {
|
): TurnProjectionResult | undefined => {
|
||||||
const key = buildProjectionCacheKey(sessionKey, messages, showTextJustificationActivity, showTurnChangedFiles);
|
const key = buildProjectionCacheKey(sessionKey, messages, showTextJustificationActivity, showTurnChangedFiles, mergeHiddenUserTurnsKey);
|
||||||
const cached = projectionCache.get(key);
|
const cached = projectionCache.get(key);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
// LRU re-order: move hit to the end (most recent) so it survives
|
// 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 { TurnChangedFilesDropdown } from '../TurnChangedFilesDropdown';
|
||||||
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
||||||
import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types';
|
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 { cn } from '@/lib/utils';
|
||||||
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
|
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
|
||||||
import { isEmptyTextPart, extractTextContent } from './partUtils';
|
import { isEmptyTextPart, extractTextContent } from './partUtils';
|
||||||
@@ -55,6 +55,8 @@ import {
|
|||||||
sendReviewFeedbackToOriginal,
|
sendReviewFeedbackToOriginal,
|
||||||
} from '@/lib/reviewFlow';
|
} from '@/lib/reviewFlow';
|
||||||
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
|
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)' };
|
const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' };
|
||||||
@@ -437,6 +439,11 @@ interface MessageBodyProps {
|
|||||||
contextPinned?: boolean;
|
contextPinned?: boolean;
|
||||||
contextPinPending?: boolean;
|
contextPinPending?: boolean;
|
||||||
onToggleContextPin?: () => void;
|
onToggleContextPin?: () => void;
|
||||||
|
footerProviderID?: string | null;
|
||||||
|
footerModelName?: string;
|
||||||
|
footerAgentName?: string;
|
||||||
|
footerVariant?: string;
|
||||||
|
isDarkTheme?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOOL_REVEAL_CACHE_MAX = 200;
|
const TOOL_REVEAL_CACHE_MAX = 200;
|
||||||
@@ -1089,6 +1096,11 @@ const AssistantMessageBody = React.memo(({
|
|||||||
contextPinned,
|
contextPinned,
|
||||||
contextPinPending,
|
contextPinPending,
|
||||||
onToggleContextPin,
|
onToggleContextPin,
|
||||||
|
footerProviderID,
|
||||||
|
footerModelName,
|
||||||
|
footerAgentName,
|
||||||
|
footerVariant,
|
||||||
|
isDarkTheme = false,
|
||||||
}: Omit<MessageBodyProps, 'isUser'>) => {
|
}: Omit<MessageBodyProps, 'isUser'>) => {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const chatSurfaceMode = useChatSurfaceMode();
|
const chatSurfaceMode = useChatSurfaceMode();
|
||||||
@@ -1104,6 +1116,7 @@ const AssistantMessageBody = React.memo(({
|
|||||||
|
|
||||||
const isTouchContext = Boolean(hasTouchInput ?? isMobile);
|
const isTouchContext = Boolean(hasTouchInput ?? isMobile);
|
||||||
const alwaysShowMessageActions = Boolean(alwaysShowActions ?? isMobile);
|
const alwaysShowMessageActions = Boolean(alwaysShowActions ?? isMobile);
|
||||||
|
const { src: footerLogoSrc, onError: handleFooterLogoError, hasLogo: footerHasLogo } = useProviderLogo(footerProviderID ?? null);
|
||||||
const awaitingMessageCompletion = !isMessageCompleted;
|
const awaitingMessageCompletion = !isMessageCompleted;
|
||||||
const animateActivityRows = awaitingMessageCompletion || Boolean(turnGroupingContext?.isWorking);
|
const animateActivityRows = awaitingMessageCompletion || Boolean(turnGroupingContext?.isWorking);
|
||||||
|
|
||||||
@@ -1747,38 +1760,78 @@ const AssistantMessageBody = React.memo(({
|
|||||||
const renderedParts = React.useMemo(() => {
|
const renderedParts = React.useMemo(() => {
|
||||||
const rendered: React.ReactNode[] = [];
|
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) {
|
if (shouldRenderActivityGroup && toggleActivityGroup) {
|
||||||
activityGroupSegmentsForMessage.forEach((segment) => {
|
activityGroupSegmentsForMessage.forEach((segment) => {
|
||||||
const visibleSegmentParts = showReasoningTraces
|
if (segment.afterToolPartId && localToolPartIds.has(segment.afterToolPartId)) {
|
||||||
? segment.parts
|
const list = segmentsAfterLocalTool.get(segment.afterToolPartId) ?? [];
|
||||||
: segment.parts.filter((activity) => activity.kind !== 'reasoning');
|
list.push(segment);
|
||||||
if (visibleSegmentParts.length === 0) {
|
segmentsAfterLocalTool.set(segment.afterToolPartId, list);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rendered.push(
|
const block = renderSegmentBlock(segment);
|
||||||
<div key={`progressive-group-${segment.id}`} className="mb-3">
|
if (block) {
|
||||||
<TurnActivity
|
rendered.push(block);
|
||||||
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 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.
|
// Flat rendering: iterate parts in natural order.
|
||||||
// Group consecutive static tools (read, grep, glob, etc.) into compact rows.
|
// Group consecutive static tools (read, grep, glob, etc.) into compact rows.
|
||||||
// Expandable tools (bash, edit, task) get individual rows.
|
// Expandable tools (bash, edit, task) get individual rows.
|
||||||
@@ -1864,19 +1917,23 @@ const AssistantMessageBody = React.memo(({
|
|||||||
if (part.type === 'tool') {
|
if (part.type === 'tool') {
|
||||||
const toolPart = part as ToolPartType;
|
const toolPart = part as ToolPartType;
|
||||||
const toolName = toolPart.tool?.toLowerCase() ?? '';
|
const toolName = toolPart.tool?.toLowerCase() ?? '';
|
||||||
|
const toolPartId = toolPart.id ?? `${messageId}-part-${i}-${part.type}`;
|
||||||
|
|
||||||
if (isSortedRenderMode && !isActivityOwnerMessage) {
|
if (isSortedRenderMode && !isActivityOwnerMessage) {
|
||||||
|
flushSegmentsAfterTool(toolPartId);
|
||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activity = activityByPart.get(part);
|
const activity = activityByPart.get(part);
|
||||||
if (activity?.kind === 'tool' && !isStandaloneTool(toolName)) {
|
if (activity?.kind === 'tool' && !isStandaloneTool(toolName)) {
|
||||||
|
flushSegmentsAfterTool(toolPartId);
|
||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldShowTool(toolPart)) {
|
if (!shouldShowTool(toolPart)) {
|
||||||
|
flushSegmentsAfterTool(toolPartId);
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1899,6 +1956,7 @@ const AssistantMessageBody = React.memo(({
|
|||||||
</ToolRevealOnMount>
|
</ToolRevealOnMount>
|
||||||
</FadeInOnReveal>
|
</FadeInOnReveal>
|
||||||
);
|
);
|
||||||
|
flushSegmentsAfterTool(toolPartId);
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1924,6 +1982,7 @@ const AssistantMessageBody = React.memo(({
|
|||||||
</ToolRevealOnMount>
|
</ToolRevealOnMount>
|
||||||
</FadeInOnReveal>
|
</FadeInOnReveal>
|
||||||
);
|
);
|
||||||
|
flushSegmentsAfterTool(toolPartId);
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1932,6 +1991,17 @@ const AssistantMessageBody = React.memo(({
|
|||||||
i++;
|
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;
|
return rendered;
|
||||||
}, [
|
}, [
|
||||||
activityByPart,
|
activityByPart,
|
||||||
@@ -2164,13 +2234,46 @@ const AssistantMessageBody = React.memo(({
|
|||||||
)}
|
)}
|
||||||
{shouldShowTurnFooter && (
|
{shouldShowTurnFooter && (
|
||||||
<div
|
<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}
|
style={MESSAGE_FOOTER_CONTAINER_STYLE}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5" data-message-action-group="true">
|
<div className="flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1 text-sm text-muted-foreground/60">
|
||||||
{messageActionButtons}
|
{footerModelName ? (
|
||||||
{finalTurnActionButtons}
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
</div>
|
{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 ? (
|
{turnDurationText ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -2205,6 +2308,19 @@ const AssistantMessageBody = React.memo(({
|
|||||||
isInteractive={turnGroupingContext?.isLatestTurn === true}
|
isInteractive={turnGroupingContext?.isLatestTurn === true}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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>
|
</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