From fe6b3ac7151c32956a5bb62fe2ea044f723062a4 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 21:08:14 +0000 Subject: [PATCH] fix(site/AgentsPage): suppress unread dot while a chat is streaming The sidebar was painting a per-chat unread indicator at the same time the row spinner was running. That combination implies the agent is waiting on user action, but during running/pending the chat is mid-turn and the user has nothing to act on. The dot also flickered (appear, then vanish) as intermediate status_change events raced the authoritative server value from the next list refetch. Two coordinated fixes: - mergeWatchedChatSummary: do not flip has_unread to true on a status_change event when the new status is running or pending. Existing has_unread is still preserved (so a chat that was already unread before the new turn started keeps its dot until the user opens it). Terminal-ish transitions (waiting / completed / requires_action / error) still mark inactive chats unread. - AgentsSidebar row: gate the visible unread indicator and sr-only unread label on !isStreaming, so any cached has_unread=true is hidden while the row is mid-turn. Defends against the case where the cache carried has_unread=true from before the current turn. Tests cover the four new branches in mergeWatchedChatSummary. --- site/src/api/queries/chats.test.ts | 72 +++++++++++++++++++ site/src/api/queries/chats.ts | 13 +++- .../components/Sidebar/AgentsSidebar.tsx | 4 +- 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 74f9c5acc5aad..ba207bb17688c 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -2301,6 +2301,78 @@ describe("mergeWatchedChatSummary", () => { }).has_unread, ).toBe(false); }); + + it("does not mark inactive chats unread when transitioning to running", () => { + const cachedChat = makeChat("chat-1", { + has_unread: false, + updated_at: "2025-01-01T00:00:00.000Z", + }); + const watchedChat = makeChat("chat-1", { + status: "running", + updated_at: "2025-01-01T00:05:00.000Z", + }); + + expect( + mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "status_change", + activeChatId: "chat-2", + }).has_unread, + ).toBe(false); + }); + + it("does not mark inactive chats unread when transitioning to pending", () => { + const cachedChat = makeChat("chat-1", { + has_unread: false, + updated_at: "2025-01-01T00:00:00.000Z", + }); + const watchedChat = makeChat("chat-1", { + status: "pending", + updated_at: "2025-01-01T00:05:00.000Z", + }); + + expect( + mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "status_change", + activeChatId: "chat-2", + }).has_unread, + ).toBe(false); + }); + + it("preserves existing has_unread when transitioning to a streaming status", () => { + const cachedChat = makeChat("chat-1", { + has_unread: true, + updated_at: "2025-01-01T00:00:00.000Z", + }); + const watchedChat = makeChat("chat-1", { + status: "running", + updated_at: "2025-01-01T00:05:00.000Z", + }); + + expect( + mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "status_change", + activeChatId: "chat-2", + }).has_unread, + ).toBe(true); + }); + + it("marks inactive chats unread when transitioning to waiting", () => { + const cachedChat = makeChat("chat-1", { + has_unread: false, + updated_at: "2025-01-01T00:00:00.000Z", + }); + const watchedChat = makeChat("chat-1", { + status: "waiting", + updated_at: "2025-01-01T00:05:00.000Z", + }); + + expect( + mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "status_change", + activeChatId: "chat-2", + }).has_unread, + ).toBe(true); + }); }); describe("mergeWatchedChatIntoCaches", () => { diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 740e3ac1dcd43..f1e7d444f8252 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -293,8 +293,19 @@ export const mergeWatchedChatSummary = ( isFreshEnough || isSummaryEvent ? watchedChat.last_turn_summary : cachedChat.last_turn_summary; + // Only flip has_unread to true on a status_change when the new status is + // not actively streaming. While a chat is `running` or `pending` the user + // has nothing to act on (the spinner already conveys progress), and an + // unread dot in that state is just noise. Intermediate status_change + // events during a turn would also race the authoritative server value, + // producing a visible flicker where the dot appears then vanishes. + const isStreamingStatus = + watchedChat.status === "running" || watchedChat.status === "pending"; const nextHasUnread = - isFreshEnough && isStatusEvent && watchedChat.id !== activeChatId + isFreshEnough && + isStatusEvent && + watchedChat.id !== activeChatId && + !isStreamingStatus ? true : cachedChat.has_unread; const nextUpdatedAt = diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx index ba1ad6e14dcfd..37eefe2f81b46 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx @@ -714,7 +714,7 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => { > {chat.title} - {chat.has_unread && !isActiveChat && ( + {chat.has_unread && !isActiveChat && !isStreaming && ( (unread) )} {isRegeneratingThisChat && ( @@ -762,7 +762,7 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => { ) : ( <> - {chat.has_unread && !isActiveChat ? ( + {chat.has_unread && !isActiveChat && !isStreaming ? (