fix(site/src/pages/AgentsPage): clear retry banner on stream forward progress (#23653)

When a provider request fails and retries, the "Retrying request" banner
lingered in the UI after the retry succeeded. This happened because
`retryState` was only cleared on explicit `status` events (`running`,
`pending`, `waiting`), not when the stream resumed with `message_part`
or `message` events. Since the backend does not publish a
dedicated"retry resolved" event, the banner stayed visible for the
entire duration of the successful response.

Add `store.clearRetryState()` calls to the `message_part`, `message`,
and `status` event handlers so the banner disappears as soon as content
flows again.

Closes https://github.com/coder/coder/issues/23624
This commit is contained in:
Ethan
2026-03-26 17:41:13 +11:00
committed by GitHub
parent 21c2acbad5
commit ad3d934290
2 changed files with 89 additions and 0 deletions
@@ -1828,6 +1828,92 @@ describe("useChatStore", () => {
expect(result.current.retryState).toBeNull();
});
it("clears retryState when message_part arrives after retry", async () => {
immediateAnimationFrame();
const chatID = "chat-retry-message-part";
const mockSocket = createMockSocket();
mockWatchChatReturn(mockSocket);
const queryClient = createTestQueryClient();
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
const { result } = renderHook(
() => {
const { store } = useChatStore({
chatID,
chatMessages: [],
chatRecord: makeChat(chatID),
chatMessagesData: {
messages: [],
queued_messages: [],
has_more: false,
},
chatQueuedMessages: [],
setChatErrorReason,
clearChatErrorReason,
});
return {
retryState: useChatSelector(store, selectRetryState),
streamState: useChatSelector(store, selectStreamState),
};
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
});
act(() => {
mockSocket.emitData({
type: "retry",
chat_id: chatID,
retry: {
attempt: 1,
error: "rate limited",
kind: "rate_limit",
provider: "anthropic",
delay_ms: 3000,
retrying_at: "2025-01-01T00:00:30.000Z",
},
});
});
await waitFor(() => {
expect(result.current.retryState).toEqual({
attempt: 1,
error: "rate limited",
kind: "rate_limit",
provider: "anthropic",
delayMs: 3000,
retryingAt: "2025-01-01T00:00:30.000Z",
});
});
act(() => {
mockSocket.emitData({
type: "message_part",
chat_id: chatID,
message_part: {
role: "assistant",
part: { type: "text", text: "retry recovered" },
},
});
});
await waitFor(() => {
expect(result.current.retryState).toBeNull();
expect(result.current.streamState?.blocks).toEqual([
{ type: "response", text: "retry recovered" },
]);
});
});
it("routes status events for other chatIDs to subagent overrides", async () => {
immediateAnimationFrame();
@@ -946,6 +946,7 @@ export const useChatStore = (
}
const part = streamEvent.message_part?.part;
if (part) {
store.clearRetryState();
cancelScheduledStreamReset();
partsBuf.push(part);
}
@@ -962,6 +963,7 @@ export const useChatStore = (
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
continue;
}
store.clearRetryState();
pendingMessages.push(message);
if (
message.id !== undefined &&
@@ -997,6 +999,7 @@ export const useChatStore = (
continue;
}
store.clearRetryState();
store.setChatStatus(nextStatus);
if (nextStatus === "pending" || nextStatus === "waiting") {
store.clearStreamState();