From bb1074aece0ae1b87243aa0a7672564314640d59 Mon Sep 17 00:00:00 2001 From: Ryan Cartwright Date: Wed, 11 Nov 2026 14:12:14 +1100 Subject: [PATCH] fix(web): refetch a workspace's lists when its event stream resets --- packages/web/src/lib/thread-events.ts | 59 ++++++++++++-------- packages/web/src/screens/agent-chat.test.tsx | 29 ++++++++++ packages/web/src/shell/shell.test.tsx | 16 ++++++ 3 files changed, 81 insertions(+), 23 deletions(-) diff --git a/packages/web/src/lib/thread-events.ts b/packages/web/src/lib/thread-events.ts index 3bbb75ae..b7942f6b 100644 --- a/packages/web/src/lib/thread-events.ts +++ b/packages/web/src/lib/thread-events.ts @@ -330,29 +330,42 @@ async function applyWorkspaceEvent( return; } const update = parsed.success; - if (update.type === "conversation.thread_changed") { - await Promise.all([ - queries.invalidateQueries({ queryKey: ["chat-messages", update.conversationId] }), - queries.invalidateQueries({ queryKey: ["chat-history", update.conversationId] }), - // A pod's chat links the threads started from it, with how many replies each has. - queries.invalidateQueries({ - queryKey: ["chat-messages"], - predicate: (query) => linksThread(query.state.data, update.conversationId), - }), - // A new message moves its conversation up the list, changes its preview, and may leave it unread. - refreshAfterThreadChange(queries, workspaceId), - ]); - } else if (update.type === "thread.changed") { - await Promise.all([ - queries.invalidateQueries({ queryKey: ["chat-history"] }), - // A reply, or its bot starting or stopping, changes how the thread's line in a chat reads. - queries.invalidateQueries({ - queryKey: ["chat-messages"], - predicate: (query) => linksThread(query.state.data, update.threadId), - }), - // A message, a reply, or an approval asked for may change how a chat stands in the lists. - refreshAfterThreadChange(queries, workspaceId), - ]); + switch (update.type) { + case "conversation.thread_changed": + await Promise.all([ + queries.invalidateQueries({ queryKey: ["chat-messages", update.conversationId] }), + queries.invalidateQueries({ queryKey: ["chat-history", update.conversationId] }), + // A pod's chat links the threads started from it, with how many replies each has. + queries.invalidateQueries({ + queryKey: ["chat-messages"], + predicate: (query) => linksThread(query.state.data, update.conversationId), + }), + // A new message moves its conversation up the list, changes its preview, and may leave it unread. + refreshAfterThreadChange(queries, workspaceId), + ]); + return; + case "thread.changed": + await Promise.all([ + queries.invalidateQueries({ queryKey: ["chat-history"] }), + // A reply, or its bot starting or stopping, changes how the thread's line in a chat reads. + queries.invalidateQueries({ + queryKey: ["chat-messages"], + predicate: (query) => linksThread(query.state.data, update.threadId), + }), + // A message, a reply, or an approval asked for may change how a chat stands in the lists. + refreshAfterThreadChange(queries, workspaceId), + ]); + return; + case "reset": + // Any number of the events above may have been missed, so everything they keep fresh is fetched again. + await Promise.all([ + queries.invalidateQueries({ queryKey: ["chat-messages"] }), + queries.invalidateQueries({ queryKey: ["chat-history"] }), + refreshAfterThreadChange(queries, workspaceId), + ]); + return; + default: + update satisfies never; } } diff --git a/packages/web/src/screens/agent-chat.test.tsx b/packages/web/src/screens/agent-chat.test.tsx index 8657ca8d..29e7ba63 100644 --- a/packages/web/src/screens/agent-chat.test.tsx +++ b/packages/web/src/screens/agent-chat.test.tsx @@ -647,6 +647,35 @@ describe("ongoing agent Chat", () => { expect(composer.textContent).toBe(""); }); + it("fetches the Chat again when its workspace's events start over", async () => { + const updates = controlledEventStream(); + client.events.workspace.mockReturnValue(updates.stream); + mount(`/suga/pods/suga-team/agents/${linear.handle}`); + await screen.findByRole("log", { name: "Chat messages" }); + await waitFor(() => expect(client.events.workspace).toHaveBeenCalled()); + + const missed = { + ...agentMessage, + id: "0199a3a0-0000-7000-8000-0000000000fe", + content: "Sent while the stream was behind", + parts: [{ type: "text" as const, text: "Sent while the stream was behind" }], + createdAt: "2026-09-18T09:25:00.000Z", + }; + client.api.chats.messages.mockReturnValue( + Effect.succeed({ + items: [ + { kind: "message", message: mainMessage }, + { kind: "message", message: agentMessage }, + { kind: "message", message: missed }, + ], + nextCursor: null, + }), + ); + updates.emit(streamEvent("reset")); + + await screen.findByText("Sent while the stream was behind"); + }); + it("keeps following the latest message while the agent works, and when it answers", async () => { const updates = controlledEventStream(); client.events.thread.mockReturnValue(updates.stream); diff --git a/packages/web/src/shell/shell.test.tsx b/packages/web/src/shell/shell.test.tsx index 7669fd39..89b62549 100644 --- a/packages/web/src/shell/shell.test.tsx +++ b/packages/web/src/shell/shell.test.tsx @@ -2628,4 +2628,20 @@ describe("the app's badge", () => { await waitFor(() => expect(document.title).toBe("Sugabots | Suga Workspace")); expect(clearAppBadge).toHaveBeenCalled(); }); + + it("counts again what is waiting when the workspace's events start over", async () => { + pageTitle(); + const updates = controlledEventStream(); + client.events.workspace.mockReturnValue(updates.stream); + client.api.chats.approvals.mockReturnValue( + Effect.succeed({ waiting: [waitingApproval], answered: [] }), + ); + mount(linearPage); + await waitFor(() => expect(document.title).toBe("(1) Sugabots | Suga Workspace")); + + client.api.chats.approvals.mockReturnValue(Effect.succeed({ waiting: [], answered: [] })); + updates.emit(streamEvent("reset")); + + await waitFor(() => expect(document.title).toBe("Sugabots | Suga Workspace")); + }); });