From 788868f259950a3bb70744e493d9770b36ae96ad Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 17 Dec 2025 03:13:37 +0200 Subject: [PATCH] feat: add activity group anchor message handling and improve standalone tool rendering logic --- .../components/chat/hooks/useTurnGrouping.ts | 36 +++++++ .../components/chat/message/MessageBody.tsx | 96 +++++++++++++++---- 2 files changed, 114 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/components/chat/hooks/useTurnGrouping.ts b/packages/ui/src/components/chat/hooks/useTurnGrouping.ts index 01ac6794..aa8a9e1a 100644 --- a/packages/ui/src/components/chat/hooks/useTurnGrouping.ts +++ b/packages/ui/src/components/chat/hooks/useTurnGrouping.ts @@ -43,6 +43,10 @@ export interface TurnGroupingContext { hasReasoning: boolean; 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; isGroupExpanded: boolean; @@ -63,10 +67,17 @@ interface TurnActivityInfo { hasReasoning: boolean; summaryBody?: string; diffStats?: TurnDiffStats; + activityGroupAnchorMessageId?: string; } const ENABLE_TEXT_JUSTIFICATION_ACTIVITY = false; +const ACTIVITY_STANDALONE_TOOL_NAMES = new Set(['task']); + +const isActivityStandaloneTool = (toolName: unknown): boolean => { + return typeof toolName === 'string' && ACTIVITY_STANDALONE_TOOL_NAMES.has(toolName.toLowerCase()); +}; + export const detectTurns = (messages: ChatMessageEntry[]): Turn[] => { const result: Turn[] = []; let currentTurn: Turn | null = null; @@ -255,12 +266,36 @@ const getTurnActivityInfo = (turn: Turn): TurnActivityInfo => { }); }); + // Pick the first assistant message where the turn reaches 2+ activities. + // Excludes standalone tools (rendered outside Activity group). + const activityCountByMessage = new Map(); + activityParts.forEach((activity) => { + if (activity.kind === 'tool') { + 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; + let cumulative = 0; + for (const msg of turn.assistantMessages) { + cumulative += activityCountByMessage.get(msg.info.id) ?? 0; + if (cumulative >= 2) { + activityGroupAnchorMessageId = msg.info.id; + break; + } + } + return { activityParts, hasTools, hasReasoning, summaryBody, diffStats, + activityGroupAnchorMessageId, }; }; @@ -383,6 +418,7 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe hasTools, hasReasoning, diffStats, + activityGroupAnchorMessageId: activityInfo?.activityGroupAnchorMessageId, isWorking: isTurnWorking, isGroupExpanded: uiState.isExpanded, previewedPartIds: uiState.previewedPartIds, diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 5eb67ad3..faa5fe89 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -94,6 +94,12 @@ const useMigrationTimer = ( return { isAnimating }; }; +const ACTIVITY_STANDALONE_TOOL_NAMES = new Set(['task']); + +const isActivityStandaloneTool = (toolName: unknown): boolean => { + return typeof toolName === 'string' && ACTIVITY_STANDALONE_TOOL_NAMES.has(toolName.toLowerCase()); +}; + interface MessageBodyProps { messageId: string; parts: Part[]; @@ -541,7 +547,7 @@ const AssistantMessageBody: React.FC> = ({ const toolConnections = React.useMemo(() => { const connections: Record = {}; const displayableTools = toolParts.filter((toolPart) => { - if (toolPart.tool === 'task') { + if (isActivityStandaloneTool(toolPart.tool)) { return false; } if (shouldHoldTools) { @@ -584,13 +590,13 @@ const AssistantMessageBody: React.FC> = ({ ? activityPartsForTurn.filter((activity) => activity.kind === 'tool') : activityPartsForTurn; - // Task tool gets its own progressive card (not part of Activity group). + // Tools rendered standalone are excluded from Activity group. return base.filter((activity) => { if (activity.kind !== 'tool') { return true; } const toolName = (activity.part as ToolPartType).tool; - return !(typeof toolName === 'string' && toolName.toLowerCase() === 'task'); + return !isActivityStandaloneTool(toolName); }); }, [activityPartsForTurn, showReasoningTraces, turnGroupingContext]); @@ -629,7 +635,7 @@ const AssistantMessageBody: React.FC> = ({ if (activity.kind === 'tool') { const toolPart = part as ToolPartType; - if (toolPart.tool === 'task') { + if (isActivityStandaloneTool(toolPart.tool)) { return; } if (shouldHoldTools) return; @@ -671,15 +677,59 @@ const AssistantMessageBody: React.FC> = ({ const { isAnimating: isMessageAnimating } = useMigrationTimer(turnGroupingContext, previewableActivityPartIds); + const shouldRenderActivityGroup = Boolean( + turnGroupingContext && + turnGroupingContext.activityGroupAnchorMessageId === messageId && + shouldShowActivityGroup && + visibleActivityPartsForTurn.length > 0 + ); + + const standaloneToolParts = React.useMemo(() => { + return toolParts.filter((toolPart) => isActivityStandaloneTool(toolPart.tool)); + }, [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(null); + const activityGroupFirstVisibleAtRef = React.useRef(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 rendered: React.ReactNode[] = []; - if ( - turnGroupingContext && - turnGroupingContext.isFirstAssistantInTurn && - shouldShowActivityGroup && - visibleActivityPartsForTurn.length > 0 - ) { + const pushActivityGroup = () => { + if (!turnGroupingContext || !shouldRenderActivityGroup) { + return; + } rendered.push( > = ({ diffStats={turnGroupingContext.diffStats} /> ); + }; + + if (!shouldPlaceActivityAfterStandaloneTools) { + pushActivityGroup(); } - // Task tool: show immediately and update progressively from metadata. - const taskTools = toolParts.filter((toolPart) => toolPart.tool === 'task'); - taskTools.forEach((taskPart) => { + // Standalone tools: rendered outside Activity group + standaloneToolParts.forEach((standaloneToolPart) => { rendered.push( - + > = ({ ); }); + if (shouldPlaceActivityAfterStandaloneTools) { + pushActivityGroup(); + } + const partsWithTime: Array<{ part: Part; index: number; @@ -742,7 +799,7 @@ const AssistantMessageBody: React.FC> = ({ if (activity.kind === 'tool') { const toolPart = part as ToolPartType; - if (toolPart.tool === 'task') { + if (isActivityStandaloneTool(toolPart.tool)) { return; } @@ -805,7 +862,7 @@ const AssistantMessageBody: React.FC> = ({ case 'tool': { const toolPart = part as ToolPartType; - if (toolPart.tool === 'task') { + if (isActivityStandaloneTool(toolPart.tool)) { break; } @@ -973,6 +1030,9 @@ const AssistantMessageBody: React.FC> = ({ visibleActivityPartsForTurn, visibleParts, toolParts, + standaloneToolParts, + shouldRenderActivityGroup, + shouldPlaceActivityAfterStandaloneTools, ]); const userMessageId = turnGroupingContext?.turnId;