fix: turn grouping and activity rendering refactor

This commit is contained in:
Bohdan Triapitsyn
2025-12-24 03:27:51 +02:00
parent 5a0a13b71c
commit b850a04e21
4 changed files with 214 additions and 96 deletions
@@ -434,6 +434,9 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return freshnessDetector.shouldAnimateMessage(message.info, currentSessionId || message.info.sessionID); return freshnessDetector.shouldAnimateMessage(message.info, currentSessionId || message.info.sessionID);
}, [message.info, currentSessionId, isUser]); }, [message.info, currentSessionId, isUser]);
// Track if this message should show header to prevent flickering
const shouldShowHeaderRef = React.useRef(false);
const previousRole = React.useMemo(() => { const previousRole = React.useMemo(() => {
if (!previousMessage) return null; if (!previousMessage) return null;
return deriveMessageRole(previousMessage.info); return deriveMessageRole(previousMessage.info);
@@ -444,12 +447,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return deriveMessageRole(nextMessage.info); return deriveMessageRole(nextMessage.info);
}, [nextMessage]); }, [nextMessage]);
const shouldShowHeader = React.useMemo(() => {
if (isUser) return true;
if (!previousRole) return true;
return previousRole.isUser;
}, [isUser, previousRole]);
const isFollowedByAssistant = React.useMemo(() => { const isFollowedByAssistant = React.useMemo(() => {
if (isUser) return false; if (isUser) return false;
if (!nextRole) return false; if (!nextRole) return false;
@@ -466,6 +463,43 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return isStreamingMessage ? 'streaming' : 'completed'; return isStreamingMessage ? 'streaming' : 'completed';
}, [isMessageCompleted, lifecyclePhase, isStreamingMessage]); }, [isMessageCompleted, lifecyclePhase, isStreamingMessage]);
const shouldShowHeader = React.useMemo(() => {
if (isUser) return true;
// Use turn grouping context if available for more precise control
const headerMessageId = turnGroupingContext?.headerMessageId;
if (headerMessageId) {
// For turn grouping: only show header for the first assistant message in the turn
const isFirstAssistantInTurn = message.info.id === headerMessageId;
if (isFirstAssistantInTurn) {
// For completed messages, always show header (historical messages)
if (streamPhase === 'completed') {
return true;
}
// For streaming messages: show header when streaming starts and keep it visible
const isCurrentlyStreaming = streamPhase === 'streaming' || streamPhase === 'cooldown';
const hasStartedStreaming = shouldShowHeaderRef.current;
// Update the ref when streaming starts
if (isCurrentlyStreaming && !hasStartedStreaming) {
shouldShowHeaderRef.current = true;
}
// Show header if streaming has started or is currently active
return hasStartedStreaming || isCurrentlyStreaming;
}
// For non-first assistant messages, don't show header
return false;
}
// Fallback to original logic when turn grouping is not available
if (!previousRole) return true;
return previousRole.isUser;
}, [isUser, previousRole, turnGroupingContext, streamPhase, message.info]);
const handleCopyCode = React.useCallback((code: string) => { const handleCopyCode = React.useCallback((code: string) => {
navigator.clipboard.writeText(code); navigator.clipboard.writeText(code);
setCopiedCode(code); setCopiedCode(code);
+15 -1
View File
@@ -49,6 +49,7 @@ interface StatusRowProps {
// Working state // Working state
isWorking: boolean; isWorking: boolean;
statusText: string | null; statusText: string | null;
isGenericStatus?: boolean;
isWaitingForPermission?: boolean; isWaitingForPermission?: boolean;
wasAborted?: boolean; wasAborted?: boolean;
abortActive?: boolean; abortActive?: boolean;
@@ -64,6 +65,7 @@ interface StatusRowProps {
export const StatusRow: React.FC<StatusRowProps> = ({ export const StatusRow: React.FC<StatusRowProps> = ({
isWorking, isWorking,
statusText, statusText,
isGenericStatus,
isWaitingForPermission, isWaitingForPermission,
wasAborted, wasAborted,
abortActive, abortActive,
@@ -122,7 +124,17 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const hasTodos = visibleTodos.length > 0; const hasTodos = visibleTodos.length > 0;
// Original logic from ChatInput // Original logic from ChatInput
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive); const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
const hasContent = isWorking || hasTodos || showAbortStatus;
// Track if placeholder is showing result (done/aborted) to keep StatusRow mounted
const [placeholderShowingResult, setPlaceholderShowingResult] = React.useState(false);
// Keep StatusRow rendered while:
// - isWorking (active session)
// - isComplete (showing "Done" result)
// - wasAborted (showing "Aborted" result)
// - placeholderShowingResult (placeholder still displaying result)
// - hasTodos or showAbortStatus
const hasContent = isWorking || isComplete || wasAborted || placeholderShowingResult || hasTodos || showAbortStatus;
// Close popover when clicking outside // Close popover when clicking outside
const popoverRef = React.useRef<HTMLDivElement>(null); const popoverRef = React.useRef<HTMLDivElement>(null);
@@ -201,10 +213,12 @@ export const StatusRow: React.FC<StatusRowProps> = ({
<WorkingPlaceholder <WorkingPlaceholder
key={currentSessionId ?? "no-session"} key={currentSessionId ?? "no-session"}
statusText={statusText} statusText={statusText}
isGenericStatus={isGenericStatus}
isWaitingForPermission={isWaitingForPermission} isWaitingForPermission={isWaitingForPermission}
wasAborted={wasAborted} wasAborted={wasAborted}
completionId={completionId ?? null} completionId={completionId ?? null}
isComplete={isComplete} isComplete={isComplete}
onResultVisibilityChange={setPlaceholderShowingResult}
/> />
) : null} ) : null}
</div> </div>
@@ -31,6 +31,13 @@ interface TurnDiffStats {
files: number; files: number;
} }
export interface TurnActivityGroup {
id: string;
anchorMessageId: string;
afterToolPartId: string | null;
parts: TurnActivityPart[];
}
export interface TurnGroupingContext { export interface TurnGroupingContext {
turnId: string; turnId: string;
isFirstAssistantInTurn: boolean; isFirstAssistantInTurn: boolean;
@@ -39,14 +46,12 @@ export interface TurnGroupingContext {
summaryBody?: string; summaryBody?: string;
activityParts: TurnActivityPart[]; activityParts: TurnActivityPart[];
activityGroupSegments: TurnActivityGroup[];
headerMessageId?: string;
hasTools: boolean; hasTools: boolean;
hasReasoning: boolean; hasReasoning: boolean;
diffStats?: TurnDiffStats; diffStats?: TurnDiffStats;
// Message that should render the Activity group for this turn.
// Chosen as the first assistant message where the turn reaches 2+ activities.
activityGroupAnchorMessageId?: string;
isWorking: boolean; isWorking: boolean;
isGroupExpanded: boolean; isGroupExpanded: boolean;
@@ -63,11 +68,11 @@ interface TurnUiState {
interface TurnActivityInfo { interface TurnActivityInfo {
activityParts: TurnActivityPart[]; activityParts: TurnActivityPart[];
activityGroupSegments: TurnActivityGroup[];
hasTools: boolean; hasTools: boolean;
hasReasoning: boolean; hasReasoning: boolean;
summaryBody?: string; summaryBody?: string;
diffStats?: TurnDiffStats; diffStats?: TurnDiffStats;
activityGroupAnchorMessageId?: string;
} }
const ENABLE_TEXT_JUSTIFICATION_ACTIVITY = false; const ENABLE_TEXT_JUSTIFICATION_ACTIVITY = false;
@@ -266,36 +271,112 @@ const getTurnActivityInfo = (turn: Turn): TurnActivityInfo => {
}); });
}); });
// Pick the first assistant message where the turn reaches 2+ activities. const activityGroupSegments: TurnActivityGroup[] = [];
// Excludes standalone tools (rendered outside Activity group).
const activityCountByMessage = new Map<string, number>(); const activityByPart = new WeakMap<Part, TurnActivityPart>();
activityParts.forEach((activity) => { activityParts.forEach((activity) => {
if (activity.kind === 'tool') { activityByPart.set(activity.part, activity);
const toolName = (activity.part as { tool?: unknown }).tool;
if (isActivityStandaloneTool(toolName)) {
return;
}
}
activityCountByMessage.set(activity.messageId, (activityCountByMessage.get(activity.messageId) ?? 0) + 1);
}); });
let activityGroupAnchorMessageId: string | undefined; const taskMessageById = new Map<string, string>();
let cumulative = 0; const taskOrder: string[] = [];
for (const msg of turn.assistantMessages) { const partsByAfterTool = new Map<string | null, TurnActivityPart[]>();
cumulative += activityCountByMessage.get(msg.info.id) ?? 0;
if (cumulative >= 2) { let currentAfterToolPartId: string | null = null;
activityGroupAnchorMessageId = msg.info.id;
break; turn.assistantMessages.forEach((msg) => {
const messageId = msg.info.id;
msg.parts.forEach((part) => {
if (part.type === 'tool') {
const toolName = (part as { tool?: unknown }).tool;
if (isActivityStandaloneTool(toolName)) {
const toolPartId = typeof part.id === 'string' && part.id.trim().length > 0
? part.id
: `${messageId}-task-${taskOrder.length + 1}`;
if (!taskMessageById.has(toolPartId)) {
taskMessageById.set(toolPartId, messageId);
taskOrder.push(toolPartId);
}
currentAfterToolPartId = toolPartId;
return;
}
}
const activity = activityByPart.get(part);
if (!activity) {
return;
}
if (activity.kind === 'tool') {
const toolName = (activity.part as { tool?: unknown }).tool;
if (isActivityStandaloneTool(toolName)) {
return;
}
}
const list = partsByAfterTool.get(currentAfterToolPartId) ?? [];
list.push(activity);
partsByAfterTool.set(currentAfterToolPartId, list);
});
});
const pickAnchorForStartSegment = (segmentParts: TurnActivityPart[]): string | undefined => {
if (segmentParts.length === 0) return undefined;
const countByMessage = new Map<string, number>();
segmentParts.forEach((activity) => {
countByMessage.set(activity.messageId, (countByMessage.get(activity.messageId) ?? 0) + 1);
});
let firstWithAny: string | undefined;
let cumulative = 0;
for (const msg of turn.assistantMessages) {
const count = countByMessage.get(msg.info.id) ?? 0;
if (count > 0 && !firstWithAny) {
firstWithAny = msg.info.id;
}
cumulative += count;
if (cumulative >= 2) {
return msg.info.id;
}
} }
} return firstWithAny;
};
const orderedKeys: Array<string | null> = [null, ...taskOrder];
orderedKeys.forEach((afterToolPartId) => {
const segmentParts = partsByAfterTool.get(afterToolPartId) ?? [];
if (segmentParts.length === 0) {
return;
}
const anchorMessageId = afterToolPartId === null
? pickAnchorForStartSegment(segmentParts)
: taskMessageById.get(afterToolPartId);
if (!anchorMessageId) {
return;
}
activityGroupSegments.push({
id: `${turn.turnId}:${anchorMessageId}:${afterToolPartId ?? 'start'}`,
anchorMessageId,
afterToolPartId,
parts: segmentParts,
});
});
return { return {
activityParts, activityParts,
activityGroupSegments,
hasTools, hasTools,
hasReasoning, hasReasoning,
summaryBody, summaryBody,
diffStats, diffStats,
activityGroupAnchorMessageId,
}; };
}; };
@@ -396,6 +477,7 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
const activityInfo = turnActivityInfo.get(turn.turnId); const activityInfo = turnActivityInfo.get(turn.turnId);
const activityParts = activityInfo?.activityParts ?? []; const activityParts = activityInfo?.activityParts ?? [];
const activityGroupSegments = activityInfo?.activityGroupSegments ?? [];
const hasTools = Boolean(activityInfo?.hasTools); const hasTools = Boolean(activityInfo?.hasTools);
const hasReasoning = Boolean(activityInfo?.hasReasoning); const hasReasoning = Boolean(activityInfo?.hasReasoning);
const summaryBody = activityInfo?.summaryBody; const summaryBody = activityInfo?.summaryBody;
@@ -405,6 +487,7 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
const isFirstAssistantInTurn = messageId === firstAssistantId; const isFirstAssistantInTurn = messageId === firstAssistantId;
const lastAssistantId = turn.assistantMessages[turn.assistantMessages.length - 1]?.info.id; const lastAssistantId = turn.assistantMessages[turn.assistantMessages.length - 1]?.info.id;
const isLastAssistantInTurn = messageId === lastAssistantId; const isLastAssistantInTurn = messageId === lastAssistantId;
const headerMessageId = firstAssistantId;
const uiState = getOrCreateTurnState(turn.turnId); const uiState = getOrCreateTurnState(turn.turnId);
const isTurnWorking = sessionIsWorking && lastTurnId === turn.turnId; const isTurnWorking = sessionIsWorking && lastTurnId === turn.turnId;
@@ -415,10 +498,11 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
isLastAssistantInTurn, isLastAssistantInTurn,
summaryBody, summaryBody,
activityParts, activityParts,
activityGroupSegments,
headerMessageId,
hasTools, hasTools,
hasReasoning, hasReasoning,
diffStats, diffStats,
activityGroupAnchorMessageId: activityInfo?.activityGroupAnchorMessageId,
isWorking: isTurnWorking, isWorking: isTurnWorking,
isGroupExpanded: uiState.isExpanded, isGroupExpanded: uiState.isExpanded,
previewedPartIds: uiState.previewedPartIds, previewedPartIds: uiState.previewedPartIds,
@@ -597,6 +597,11 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
return activityPartsForTurn.filter((activity) => activity.messageId === messageId); return activityPartsForTurn.filter((activity) => activity.messageId === messageId);
}, [activityPartsForTurn, messageId, turnGroupingContext]); }, [activityPartsForTurn, messageId, turnGroupingContext]);
const activityGroupSegmentsForMessage = React.useMemo(() => {
if (!turnGroupingContext) return [];
return turnGroupingContext.activityGroupSegments.filter((segment) => segment.anchorMessageId === messageId);
}, [messageId, turnGroupingContext]);
const activityPartsByPart = React.useMemo(() => { const activityPartsByPart = React.useMemo(() => {
const map = new Map<Part, (typeof activityPartsForMessage)[number]>(); const map = new Map<Part, (typeof activityPartsForMessage)[number]>();
activityPartsForMessage.forEach((activity) => { activityPartsForMessage.forEach((activity) => {
@@ -605,6 +610,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
return map; return map;
}, [activityPartsForMessage]); }, [activityPartsForMessage]);
const visibleActivityPartsForTurn = React.useMemo(() => { const visibleActivityPartsForTurn = React.useMemo(() => {
if (!turnGroupingContext) return []; if (!turnGroupingContext) return [];
@@ -628,7 +634,12 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
if (!turnGroupingContext) { if (!turnGroupingContext) {
return; return;
} }
if (visibleActivityPartsForTurn.length > 1) {
const hasTaskSplitSegments = turnGroupingContext.activityGroupSegments.some(
(segment) => segment.afterToolPartId !== null
);
if (visibleActivityPartsForTurn.length > 1 || (hasTaskSplitSegments && visibleActivityPartsForTurn.length > 0)) {
setHasEverHadMultipleVisibleActivities(true); setHasEverHadMultipleVisibleActivities(true);
} }
}, [turnGroupingContext, visibleActivityPartsForTurn.length]); }, [turnGroupingContext, visibleActivityPartsForTurn.length]);
@@ -701,81 +712,58 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const shouldRenderActivityGroup = Boolean( const shouldRenderActivityGroup = Boolean(
turnGroupingContext && turnGroupingContext &&
turnGroupingContext.activityGroupAnchorMessageId === messageId &&
shouldShowActivityGroup && shouldShowActivityGroup &&
visibleActivityPartsForTurn.length > 0 visibleActivityPartsForTurn.length > 0 &&
activityGroupSegmentsForMessage.length > 0
); );
const standaloneToolParts = React.useMemo(() => { const standaloneToolParts = React.useMemo(() => {
return toolParts.filter((toolPart) => isActivityStandaloneTool(toolPart.tool)); return toolParts.filter((toolPart) => isActivityStandaloneTool(toolPart.tool));
}, [toolParts]); }, [toolParts]);
const isActivityGroupVisibleNow = React.useMemo(() => {
if (!turnGroupingContext || !shouldRenderActivityGroup) {
return false;
}
if (!turnGroupingContext.isWorking) {
return true;
}
const previewed = turnGroupingContext.previewedPartIds;
return visibleActivityPartsForTurn.some((activity) => previewed.has(activity.id));
}, [shouldRenderActivityGroup, turnGroupingContext, visibleActivityPartsForTurn]);
const standaloneToolsFirstVisibleAtRef = React.useRef<number | null>(null);
const activityGroupFirstVisibleAtRef = React.useRef<number | null>(null);
React.useEffect(() => {
standaloneToolsFirstVisibleAtRef.current = null;
activityGroupFirstVisibleAtRef.current = null;
}, [messageId]);
const now = Date.now();
if (standaloneToolParts.length > 0 && standaloneToolsFirstVisibleAtRef.current === null) {
standaloneToolsFirstVisibleAtRef.current = now;
}
if (isActivityGroupVisibleNow && activityGroupFirstVisibleAtRef.current === null) {
activityGroupFirstVisibleAtRef.current = now;
}
const shouldPlaceActivityAfterStandaloneTools = Boolean(
standaloneToolParts.length > 0 &&
isActivityGroupVisibleNow &&
typeof standaloneToolsFirstVisibleAtRef.current === 'number' &&
typeof activityGroupFirstVisibleAtRef.current === 'number' &&
activityGroupFirstVisibleAtRef.current > standaloneToolsFirstVisibleAtRef.current
);
const renderedParts = React.useMemo(() => { const renderedParts = React.useMemo(() => {
const rendered: React.ReactNode[] = []; const rendered: React.ReactNode[] = [];
const pushActivityGroup = () => { const renderActivitySegments = (afterToolPartId: string | null) => {
if (!turnGroupingContext || !shouldRenderActivityGroup) { if (!turnGroupingContext || !shouldRenderActivityGroup) {
return; return;
} }
rendered.push(
<ProgressiveGroup activityGroupSegmentsForMessage
key="progressive-group" .filter((segment) => (segment.afterToolPartId ?? null) === afterToolPartId)
parts={visibleActivityPartsForTurn} .forEach((segment) => {
isExpanded={turnGroupingContext.isGroupExpanded} const visibleSegmentParts = !showReasoningTraces
onToggle={turnGroupingContext.toggleGroup} ? segment.parts.filter((activity) => activity.kind === 'tool')
syntaxTheme={syntaxTheme} : segment.parts;
isMobile={isMobile}
expandedTools={expandedTools} if (visibleSegmentParts.length === 0) {
onToggleTool={onToggleTool} return;
onShowPopup={onShowPopup} }
onContentChange={onContentChange}
isWorking={turnGroupingContext.isWorking} rendered.push(
previewedPartIds={turnGroupingContext.previewedPartIds} <ProgressiveGroup
diffStats={turnGroupingContext.diffStats} key={`progressive-group-${segment.id}`}
/> parts={visibleSegmentParts}
); isExpanded={turnGroupingContext.isGroupExpanded}
onToggle={turnGroupingContext.toggleGroup}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
expandedTools={expandedTools}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
onContentChange={onContentChange}
isWorking={turnGroupingContext.isWorking}
previewedPartIds={turnGroupingContext.previewedPartIds}
diffStats={turnGroupingContext.diffStats}
/>
);
});
}; };
if (!shouldPlaceActivityAfterStandaloneTools) { // Activity groups and standalone tasks are interleaved in message order.
pushActivityGroup(); renderActivitySegments(null);
}
// Standalone tools: rendered outside Activity group
standaloneToolParts.forEach((standaloneToolPart) => { standaloneToolParts.forEach((standaloneToolPart) => {
rendered.push( rendered.push(
<FadeInOnReveal key={`standalone-tool-${standaloneToolPart.id}`}> <FadeInOnReveal key={`standalone-tool-${standaloneToolPart.id}`}>
@@ -791,11 +779,9 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
/> />
</FadeInOnReveal> </FadeInOnReveal>
); );
});
if (shouldPlaceActivityAfterStandaloneTools) { renderActivitySegments(standaloneToolPart.id);
pushActivityGroup(); });
}
const partsWithTime: Array<{ const partsWithTime: Array<{
part: Part; part: Part;
@@ -1026,6 +1012,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
return rendered; return rendered;
}, [ }, [
activityPartsByPart, activityPartsByPart,
activityGroupSegmentsForMessage,
copiedCode, copiedCode,
copiedMessage, copiedMessage,
expandedTools, expandedTools,
@@ -1054,7 +1041,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
toolParts, toolParts,
standaloneToolParts, standaloneToolParts,
shouldRenderActivityGroup, shouldRenderActivityGroup,
shouldPlaceActivityAfterStandaloneTools,
]); ]);
const userMessageId = turnGroupingContext?.turnId; const userMessageId = turnGroupingContext?.turnId;