Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 36 additions & 23 deletions packages/web/src/lib/thread-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
29 changes: 29 additions & 0 deletions packages/web/src/screens/agent-chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions packages/web/src/shell/shell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
});
});
Loading