From e17da2f648a086f289eefc67f0382fdef7d58822 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 23 Apr 2026 20:31:46 +0700 Subject: [PATCH] fix(site): fix action bar hidden after null-returning assistant messages (#24566) closes CODAGT-121 When an invisible assistant message (e.g. a `provider_executed` tool-result with no text content) appeared between a visible assistant message and the next user message, the visible assistant's action bar was incorrectly hidden. The chain logic computed `isLastInChain` from the raw `parsedMessages` array, which includes entries that `ChatMessageItem` returns `null` for. Extract a shared `isTimelineMessageVisible` helper that encodes the three null-return conditions (`provider_executed` tool-result-only, all-provider-executed parts, metadata-only) in one place. Use it both to guard the early return in `ChatMessageItem` and to skip invisible entries when computing `isLastInChain`, so chain boundaries are based on the next *rendered* message. --- .../ConversationTimeline.stories.tsx | 62 +++++++++++++++++++ .../ChatConversation/ConversationTimeline.tsx | 32 ++++++++-- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index d5c9c8bc46..65dd1b0f3c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1607,3 +1607,65 @@ export const NoRenderableContentFallbackSpacing: Story = { ).toBeInTheDocument(); }, }; + +/** + * Regression: action bar must appear on the last *visible* assistant + * message even when invisible assistant messages (provider-executed + * tool-result-only) follow it before the next user turn. + */ +export const AssistantActionBarAfterHiddenMessages: Story = { + args: { + ...defaultArgs, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "user", + content: [{ type: "text", text: "Help me refactor" }], + }, + { + ...baseMessage, + id: 2, + role: "assistant", + content: [ + { type: "text", text: "Here is the **refactored** version." }, + ], + }, + { + ...baseMessage, + id: 3, + role: "assistant", + content: [ + { + type: "tool-result", + tool_call_id: "provider-tool-1", + result: { output: "done" }, + provider_executed: true, + }, + ], + }, + { + ...baseMessage, + id: 4, + role: "user", + content: [{ type: "text", text: "Thanks!" }], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Force the hover-reveal action bars visible using stable test IDs. + for (const el of canvasElement.querySelectorAll( + '[data-testid="message-actions"]', + )) { + if (el instanceof HTMLElement) { + el.style.opacity = "1"; + } + } + // 2 user messages + 1 visible assistant = 3 action bars. + // The invisible provider-executed tool-result message (id=3) + // must not prevent the assistant (id=2) from showing its bar. + const actions = canvas.getAllByTestId("message-actions"); + expect(actions).toHaveLength(3); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index b0d8f4adc7..a4d4a00d0c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -821,6 +821,28 @@ const StickyUserMessage = memo<{ }, ); +function computeLastInChainFlags( + parsedMessages: readonly ParsedMessageEntry[], +): boolean[] { + const flags = new Array(parsedMessages.length).fill(false); + let nextVisibleIsUser = true; // no next visible => treat as chain end + for (let i = parsedMessages.length - 1; i >= 0; i--) { + const entry = parsedMessages[i]; + const { shouldHide } = deriveMessageDisplayState({ + message: entry.message, + parsed: entry.parsed, + hideActions: false, + }); + if (entry.message.role !== "user") { + flags[i] = nextVisibleIsUser; + } + if (!shouldHide) { + nextVisibleIsUser = entry.message.role === "user"; + } + } + return flags; +} + interface ConversationTimelineProps { parsedMessages: readonly ParsedMessageEntry[]; subagentTitles: Map; @@ -854,6 +876,8 @@ export const ConversationTimeline = memo( mcpServers, showDesktopPreviews, }) => { + const lastInChainFlags = computeLastInChainFlags(parsedMessages); + if (parsedMessages.length === 0) { return null; } @@ -929,10 +953,10 @@ export const ConversationTimeline = memo( /> ); } - // Hide actions on assistant messages that are not - // the last in a consecutive assistant chain. - const next = parsedMessages[msgIdx + 1]; - const isLastInChain = !next || next.message.role === "user"; + // Hide actions on assistant messages that are not the + // last in a consecutive assistant chain. Flags are + // precomputed in a single reverse pass above. + const isLastInChain = lastInChainFlags[msgIdx]; return (