From c9bcf79ca70a8a4635d12aa832d13bbe21bf1901 Mon Sep 17 00:00:00 2001 From: Nelson Pires Date: Mon, 2 Mar 2026 23:00:46 -0300 Subject: [PATCH] fix(chat): show in-flight tools immediately and keep collapsed activity live (#563) * fix(chat): render active tools before completion * fix(chat): preserve running activity in collapsed view * fix(chat): live-update duration for active tools * fix: show live tool activity without timer resets Show active tool rows before completion Keep live duration stable during streaming updates --------- Co-authored-by: Bohdan Triapitsyn --- .../components/chat/message/MessageBody.tsx | 44 +++++++++++-------- .../chat/message/parts/ProgressiveGroup.tsx | 31 ++++++++++++- .../chat/message/parts/ToolPart.tsx | 25 +++++------ 3 files changed, 65 insertions(+), 35 deletions(-) diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 59523d5d..c1c3520f 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -617,6 +617,12 @@ const AssistantMessageBody: React.FC> = ({ }); }, [toolParts]); + const isActiveTool = React.useCallback((toolPart: ToolPartType): boolean => { + const state = (toolPart as Record).state as Record | undefined ?? {}; + const status = state?.status; + return status === 'pending' || status === 'running' || status === 'started'; + }, []); + const isToolFinalized = React.useCallback((toolPart: ToolPartType) => { const state = (toolPart as Record).state as Record | undefined ?? {}; const status = state?.status; @@ -635,6 +641,10 @@ const AssistantMessageBody: React.FC> = ({ return true; }, []); + const shouldShowTool = React.useCallback((toolPart: ToolPartType): boolean => { + return isActiveTool(toolPart) || isToolFinalized(toolPart); + }, [isActiveTool, isToolFinalized]); + const allToolsFinalized = React.useMemo(() => { if (toolParts.length === 0) { return true; @@ -881,10 +891,7 @@ const AssistantMessageBody: React.FC> = ({ if (isActivityStandaloneTool(toolPart.tool)) { return false; } - if (shouldHoldTools) { - return false; - } - return isToolFinalized(toolPart); + return shouldShowTool(toolPart); }); displayableTools.forEach((toolPart, index) => { @@ -895,7 +902,7 @@ const AssistantMessageBody: React.FC> = ({ }); return connections; - }, [toolParts, shouldHoldTools, isToolFinalized]); + }, [toolParts, shouldShowTool]); const activityPartsForTurn = React.useMemo(() => { return turnGroupingContext?.activityParts ?? []; @@ -1063,22 +1070,21 @@ const AssistantMessageBody: React.FC> = ({ let endTime: number | null = null; let element: React.ReactNode | null = null; - if (!shouldShowActivityGroup) { - if (activity.kind === 'tool') { - const toolPart = part as ToolPartType; + if (!shouldShowActivityGroup) { + if (activity.kind === 'tool') { + const toolPart = part as ToolPartType; - if (isActivityStandaloneTool(toolPart.tool)) { - return; - } + if (isActivityStandaloneTool(toolPart.tool)) { + return; + } - const toolState = (toolPart as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state; - const time = toolState?.time; - const isFinalized = isToolFinalized(toolPart); - const shouldShowTool = !shouldHoldTools && isFinalized; + const toolState = (toolPart as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state; + const time = toolState?.time; + const isFinalized = isToolFinalized(toolPart); - if (!shouldShowTool) { - return; - } + if (!shouldShowTool(toolPart)) { + return; + } const connection = toolConnections[toolPart.id]; @@ -1158,7 +1164,7 @@ const AssistantMessageBody: React.FC> = ({ onContentChange, onShowPopup, onToggleTool, - shouldHoldTools, + shouldShowTool, shouldShowActivityGroup, showReasoningTraces, syntaxTheme, diff --git a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx index a2eb5af8..8a0f49f2 100644 --- a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx +++ b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx @@ -107,9 +107,36 @@ const ProgressiveGroup: React.FC = ({ const toolConnections = getToolConnections(displayParts); - // For collapsed state: show last N items + // For collapsed state: show last N items, but ensure at least one in-flight item is visible if exists const visibleCollapsedParts = React.useMemo(() => { - return displayParts.slice(-MAX_VISIBLE_COLLAPSED); + const defaultVisible = displayParts.slice(-MAX_VISIBLE_COLLAPSED); + + const hasVisibleActive = defaultVisible.some((p) => p.endedAt === undefined); + if (hasVisibleActive) { + return defaultVisible; + } + + const activeParts = displayParts.filter((p) => p.endedAt === undefined); + if (activeParts.length === 0) { + return defaultVisible; + } + + const newestActive = activeParts[activeParts.length - 1]; + const visibleIds = new Set(defaultVisible.map((p) => p.id)); + + if (visibleIds.has(newestActive.id)) { + return defaultVisible; + } + + const replacementIndex = 0; + const result = [...defaultVisible]; + result[replacementIndex] = newestActive; + + return result.sort((a, b) => { + const aIndex = displayParts.findIndex((p) => p.id === a.id); + const bIndex = displayParts.findIndex((p) => p.id === b.id); + return aIndex - bIndex; + }); }, [displayParts]); // Set of part IDs that were visible in collapsed state diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 630b921f..020446b9 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -1432,8 +1432,9 @@ const ToolPart: React.FC = ({ const isTaskTool = part.tool.toLowerCase() === 'task'; - const isFinalized = state.status === 'completed' || state.status === 'error'; - const isActive = state.status === 'running' || state.status === 'pending'; + const status = state.status as string | undefined; + const isFinalized = status === 'completed' || status === 'error'; + const isActive = status === 'running' || status === 'pending' || status === 'started'; const isError = state.status === 'error'; @@ -1454,18 +1455,14 @@ const ToolPart: React.FC = ({ const input = stateWithData.input; const time = stateWithData.time; - const [pinnedTaskTime, setPinnedTaskTime] = React.useState<{ start?: number; end?: number }>({}); + const [pinnedTime, setPinnedTime] = React.useState<{ start?: number; end?: number }>({}); React.useEffect(() => { - setPinnedTaskTime({}); + setPinnedTime({}); }, [part.id]); React.useEffect(() => { - if (!isTaskTool) { - return; - } - - setPinnedTaskTime((prev) => { + setPinnedTime((prev) => { const next = { ...prev }; let changed = false; @@ -1481,10 +1478,10 @@ const ToolPart: React.FC = ({ return changed ? next : prev; }); - }, [isTaskTool, time?.end, time?.start]); + }, [time?.end, time?.start]); - const effectiveTimeStart = isTaskTool ? (pinnedTaskTime.start ?? time?.start) : time?.start; - const effectiveTimeEnd = isTaskTool ? (pinnedTaskTime.end ?? time?.end) : time?.end; + const effectiveTimeStart = pinnedTime.start ?? time?.start; + const effectiveTimeEnd = pinnedTime.end ?? time?.end; const endedTimestampText = React.useMemo(() => { if (typeof effectiveTimeEnd !== 'number' || !Number.isFinite(effectiveTimeEnd)) { @@ -1670,7 +1667,7 @@ const ToolPart: React.FC = ({ handleMainClick(event); }; - if (!isFinalized && !isTaskTool) { + if (!isFinalized && !isActive && !isTaskTool) { return null; } @@ -1757,7 +1754,7 @@ const ToolPart: React.FC = ({ {!isMobile && endedTimestampText ? (