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.
This commit is contained in:
Jaayden Halko
2026-04-23 14:31:46 +01:00
committed by GitHub
parent ca14aa37c4
commit e17da2f648
2 changed files with 90 additions and 4 deletions
@@ -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);
},
};
@@ -821,6 +821,28 @@ const StickyUserMessage = memo<{
},
);
function computeLastInChainFlags(
parsedMessages: readonly ParsedMessageEntry[],
): boolean[] {
const flags = new Array<boolean>(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<string, string>;
@@ -854,6 +876,8 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
mcpServers,
showDesktopPreviews,
}) => {
const lastInChainFlags = computeLastInChainFlags(parsedMessages);
if (parsedMessages.length === 0) {
return null;
}
@@ -929,10 +953,10 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
/>
);
}
// 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 (
<ChatMessageItem
key={message.id}