feat: add activity group anchor message handling and improve standalone tool rendering logic

This commit is contained in:
Bohdan Triapitsyn
2025-12-17 03:13:37 +02:00
parent 38fb154ffe
commit 788868f259
2 changed files with 114 additions and 18 deletions
@@ -43,6 +43,10 @@ export interface TurnGroupingContext {
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,10 +67,17 @@ interface TurnActivityInfo {
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;
const ACTIVITY_STANDALONE_TOOL_NAMES = new Set<string>(['task']);
const isActivityStandaloneTool = (toolName: unknown): boolean => {
return typeof toolName === 'string' && ACTIVITY_STANDALONE_TOOL_NAMES.has(toolName.toLowerCase());
};
export const detectTurns = (messages: ChatMessageEntry[]): Turn[] => { export const detectTurns = (messages: ChatMessageEntry[]): Turn[] => {
const result: Turn[] = []; const result: Turn[] = [];
let currentTurn: Turn | null = null; 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<string, number>();
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 { return {
activityParts, activityParts,
hasTools, hasTools,
hasReasoning, hasReasoning,
summaryBody, summaryBody,
diffStats, diffStats,
activityGroupAnchorMessageId,
}; };
}; };
@@ -383,6 +418,7 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
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,
@@ -94,6 +94,12 @@ const useMigrationTimer = (
return { isAnimating }; return { isAnimating };
}; };
const ACTIVITY_STANDALONE_TOOL_NAMES = new Set<string>(['task']);
const isActivityStandaloneTool = (toolName: unknown): boolean => {
return typeof toolName === 'string' && ACTIVITY_STANDALONE_TOOL_NAMES.has(toolName.toLowerCase());
};
interface MessageBodyProps { interface MessageBodyProps {
messageId: string; messageId: string;
parts: Part[]; parts: Part[];
@@ -541,7 +547,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const toolConnections = React.useMemo(() => { const toolConnections = React.useMemo(() => {
const connections: Record<string, { hasPrev: boolean; hasNext: boolean }> = {}; const connections: Record<string, { hasPrev: boolean; hasNext: boolean }> = {};
const displayableTools = toolParts.filter((toolPart) => { const displayableTools = toolParts.filter((toolPart) => {
if (toolPart.tool === 'task') { if (isActivityStandaloneTool(toolPart.tool)) {
return false; return false;
} }
if (shouldHoldTools) { if (shouldHoldTools) {
@@ -584,13 +590,13 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
? activityPartsForTurn.filter((activity) => activity.kind === 'tool') ? activityPartsForTurn.filter((activity) => activity.kind === 'tool')
: activityPartsForTurn; : 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) => { return base.filter((activity) => {
if (activity.kind !== 'tool') { if (activity.kind !== 'tool') {
return true; return true;
} }
const toolName = (activity.part as ToolPartType).tool; const toolName = (activity.part as ToolPartType).tool;
return !(typeof toolName === 'string' && toolName.toLowerCase() === 'task'); return !isActivityStandaloneTool(toolName);
}); });
}, [activityPartsForTurn, showReasoningTraces, turnGroupingContext]); }, [activityPartsForTurn, showReasoningTraces, turnGroupingContext]);
@@ -629,7 +635,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
if (activity.kind === 'tool') { if (activity.kind === 'tool') {
const toolPart = part as ToolPartType; const toolPart = part as ToolPartType;
if (toolPart.tool === 'task') { if (isActivityStandaloneTool(toolPart.tool)) {
return; return;
} }
if (shouldHoldTools) return; if (shouldHoldTools) return;
@@ -671,15 +677,59 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const { isAnimating: isMessageAnimating } = useMigrationTimer(turnGroupingContext, previewableActivityPartIds); 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<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[] = [];
if ( const pushActivityGroup = () => {
turnGroupingContext && if (!turnGroupingContext || !shouldRenderActivityGroup) {
turnGroupingContext.isFirstAssistantInTurn && return;
shouldShowActivityGroup && }
visibleActivityPartsForTurn.length > 0
) {
rendered.push( rendered.push(
<ProgressiveGroup <ProgressiveGroup
key="progressive-group" key="progressive-group"
@@ -697,16 +747,19 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
diffStats={turnGroupingContext.diffStats} diffStats={turnGroupingContext.diffStats}
/> />
); );
};
if (!shouldPlaceActivityAfterStandaloneTools) {
pushActivityGroup();
} }
// Task tool: show immediately and update progressively from metadata. // Standalone tools: rendered outside Activity group
const taskTools = toolParts.filter((toolPart) => toolPart.tool === 'task'); standaloneToolParts.forEach((standaloneToolPart) => {
taskTools.forEach((taskPart) => {
rendered.push( rendered.push(
<FadeInOnReveal key={`task-${taskPart.id}`}> <FadeInOnReveal key={`standalone-tool-${standaloneToolPart.id}`}>
<ToolPart <ToolPart
part={taskPart} part={standaloneToolPart}
isExpanded={expandedTools.has(taskPart.id)} isExpanded={expandedTools.has(standaloneToolPart.id)}
onToggle={onToggleTool} onToggle={onToggleTool}
syntaxTheme={syntaxTheme} syntaxTheme={syntaxTheme}
isMobile={isMobile} isMobile={isMobile}
@@ -718,6 +771,10 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
); );
}); });
if (shouldPlaceActivityAfterStandaloneTools) {
pushActivityGroup();
}
const partsWithTime: Array<{ const partsWithTime: Array<{
part: Part; part: Part;
index: number; index: number;
@@ -742,7 +799,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
if (activity.kind === 'tool') { if (activity.kind === 'tool') {
const toolPart = part as ToolPartType; const toolPart = part as ToolPartType;
if (toolPart.tool === 'task') { if (isActivityStandaloneTool(toolPart.tool)) {
return; return;
} }
@@ -805,7 +862,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
case 'tool': { case 'tool': {
const toolPart = part as ToolPartType; const toolPart = part as ToolPartType;
if (toolPart.tool === 'task') { if (isActivityStandaloneTool(toolPart.tool)) {
break; break;
} }
@@ -973,6 +1030,9 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
visibleActivityPartsForTurn, visibleActivityPartsForTurn,
visibleParts, visibleParts,
toolParts, toolParts,
standaloneToolParts,
shouldRenderActivityGroup,
shouldPlaceActivityAfterStandaloneTools,
]); ]);
const userMessageId = turnGroupingContext?.turnId; const userMessageId = turnGroupingContext?.turnId;