fix(site/src/pages/AgentsPage): refetch the open chat on a project change

A move keeps the chat's updated_at, so the watch merge takes project_id
from each project_change payload in arrival order. The server publishes
after it releases the root lock, so two quick moves can reach another
tab in reverse order, and the open chat then kept the older project in
its top bar and Move menu. The list caches refetched on project_change;
the chat entity did not.

The watch handler now refetches the open chat on project_change. Each
event first cancels an entity refetch in flight, and the server
publishes after commit, so the refetch after the last event reads the
committed project. A first fetch still in flight restarts, because
invalidation would reuse a request that may predate the move.

The archive tests and the new move tests share one mock of the watch
socket.
This commit is contained in:
Michael Suchacz
2026-10-15 19:51:23 +00:00
parent 4d4bceaa99
commit 1ac9da0de8
2 changed files with 93 additions and 22 deletions
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import {
type InfiniteData,
InfiniteQueryObserver,
type QueryClient,
useQuery,
} from "react-query";
import { useOutletContext, useParams } from "react-router";
@@ -65,6 +66,30 @@ const renderLayout = (route = "/agents") =>
extraRoutes: [{ path: "/workspaces", element: null }],
});
const mockChatWatchSocket = () => {
let chatWatchServer: MockWebSocketServer | undefined;
vi.spyOn(apiModule, "watchChats").mockImplementation(
() =>
new OneWayWebSocket<ChatWatchEvent>({
apiRoute: "/api/v2/chats/watch",
websocketInit: (url, protocol) => {
const [socket, server] = createMockWebSocket(url, protocol);
chatWatchServer = server;
return socket;
},
}),
);
return (event: ChatWatchEvent) => {
if (!chatWatchServer)
throw new Error("Chat watch connection was not opened");
act(() =>
chatWatchServer?.publishMessage(
new MessageEvent("message", { data: JSON.stringify(event) }),
),
);
};
};
beforeEach(() => {
vi.spyOn(API, "getWorkspaceQuota").mockResolvedValue(MockWorkspaceQuota);
vi.spyOn(API.experimental, "getChatModels").mockResolvedValue([]);
@@ -440,7 +465,7 @@ describe("AgentsPageLayout archive and delete", () => {
parent_chat_id: chat.id,
root_chat_id: chat.id,
};
let chatWatchServer: MockWebSocketServer | undefined;
let publishChatWatchEvent: (event: ChatWatchEvent) => void;
beforeEach(() => {
vi.spyOn(API.experimental, "getChats").mockResolvedValue([chat, otherChat]);
@@ -464,18 +489,7 @@ describe("AgentsPageLayout archive and delete", () => {
vi.spyOn(API, "deleteWorkspace").mockResolvedValue(
MockWorkspaceBuildDelete,
);
chatWatchServer = undefined;
vi.spyOn(apiModule, "watchChats").mockImplementation(
() =>
new OneWayWebSocket<ChatWatchEvent>({
apiRoute: "/api/v2/chats/watch",
websocketInit: (url, protocol) => {
const [socket, server] = createMockWebSocket(url, protocol);
chatWatchServer = server;
return socket;
},
}),
);
publishChatWatchEvent = mockChatWatchSocket();
});
it.each(["sidebar", "top bar"] as const)(
@@ -716,17 +730,10 @@ describe("AgentsPageLayout archive and delete", () => {
// Archiving emits this event before the workspace deletion finishes,
// removing the sidebar row that started the mutation.
vi.mocked(API.experimental.getChats).mockResolvedValue([otherChat]);
if (!chatWatchServer)
throw new Error("Chat watch connection was not opened");
const event: ChatWatchEvent = {
publishChatWatchEvent({
kind: "deleted",
chat: { ...chat, archived: true },
};
act(() =>
chatWatchServer?.publishMessage(
new MessageEvent("message", { data: JSON.stringify(event) }),
),
);
});
await waitFor(() =>
expect(
queryClient
@@ -828,3 +835,59 @@ describe("AgentsPageLayout archive and delete", () => {
},
);
});
describe("AgentsPageLayout project moves", () => {
const mockChatInProjectA: Chat = { ...MockChat, project_id: "project-a" };
const mockChatInProjectB: Chat = { ...MockChat, project_id: "project-b" };
const mockChatInProjectC: Chat = { ...MockChat, project_id: "project-c" };
let publishChatWatchEvent: (event: ChatWatchEvent) => void;
beforeEach(() => {
vi.spyOn(API.experimental, "getChats").mockResolvedValue([
mockChatInProjectA,
]);
vi.spyOn(API.experimental, "getChat").mockResolvedValue(mockChatInProjectA);
publishChatWatchEvent = mockChatWatchSocket();
});
const commitMove = (movedChat: Chat) => {
vi.mocked(API.experimental.getChats).mockResolvedValue([movedChat]);
vi.mocked(API.experimental.getChat).mockResolvedValue(movedChat);
};
const expectOpenChatProject = (queryClient: QueryClient, projectId: string) =>
waitFor(() =>
expect(
queryClient.getQueryData(chatEntityKey(MockChat.id)),
).toMatchObject({ project_id: projectId }),
);
it("ends on the committed project when move events arrive out of order", async () => {
const { queryClient } = renderLayout(`/agents/${MockChat.id}`);
await expectOpenChatProject(queryClient, "project-a");
// Two quick moves committed project-b and then project-c, and this tab
// receives their events in reverse order.
commitMove(mockChatInProjectC);
publishChatWatchEvent({ kind: "project_change", chat: mockChatInProjectC });
publishChatWatchEvent({ kind: "project_change", chat: mockChatInProjectB });
await expectOpenChatProject(queryClient, "project-c");
});
it("ends on the moved project when a move overtakes the open chat's first fetch", async () => {
const firstFetch = createDeferred<Chat>();
vi.mocked(API.experimental.getChat).mockReturnValueOnce(firstFetch.promise);
const { queryClient } = renderLayout(`/agents/${MockChat.id}`);
await waitFor(() =>
expect(API.experimental.getChat).toHaveBeenCalledTimes(1),
);
commitMove(mockChatInProjectB);
publishChatWatchEvent({ kind: "project_change", chat: mockChatInProjectB });
// The first fetch read the chat before the move committed.
await act(async () => firstFetch.resolve(mockChatInProjectA));
await expectOpenChatProject(queryClient, "project-b");
});
});
@@ -34,6 +34,7 @@ import {
mergeWatchedChatIntoCaches,
prependToInfiniteChatsCache,
proposeChatTitle,
resetUnloadedChatEntity,
shouldInvalidateChatSearches,
shouldInvalidateChatsByWorkspace,
updateChatTitle,
@@ -432,6 +433,13 @@ const AgentsPageLayout: React.FC = () => {
// merely marked stale.
void invalidateChatEntity(queryClient, updatedChat.id);
}
if (chatEvent.kind === "project_change") {
// Moves keep updated_at, so the merge above keeps whichever
// project_change arrives last. Refetch the committed project,
// and restart a first fetch that may predate the move.
void resetUnloadedChatEntity(queryClient, updatedChat.id);
void invalidateChatEntity(queryClient, updatedChat.id);
}
}
});
return ws;