mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user