mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
fix(agents): prevent updated_at regression causing sidebar group flicker (#22672)
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
import { API } from "api/api";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { QueryClient } from "react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { archiveChat, chatKey, chatsKey, unarchiveChat } from "./chats";
|
||||
|
||||
vi.mock("api/api", () => ({
|
||||
API: {
|
||||
archiveChat: vi.fn(),
|
||||
unarchiveChat: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const makeChat = (
|
||||
id: string,
|
||||
overrides?: Partial<TypesGen.Chat>,
|
||||
): TypesGen.Chat => ({
|
||||
id,
|
||||
owner_id: "owner-1",
|
||||
last_model_config_id: "model-1",
|
||||
title: `Chat ${id}`,
|
||||
status: "running",
|
||||
created_at: "2025-01-01T00:00:00.000Z",
|
||||
updated_at: "2025-01-01T00:00:00.000Z",
|
||||
archived: false,
|
||||
last_error: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeChatWithMessages = (
|
||||
chatId: string,
|
||||
overrides?: Partial<TypesGen.Chat>,
|
||||
): TypesGen.ChatWithMessages => ({
|
||||
chat: makeChat(chatId, overrides),
|
||||
messages: [],
|
||||
queued_messages: [],
|
||||
});
|
||||
|
||||
const createTestQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnWindowFocus: false,
|
||||
networkMode: "offlineFirst",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("archiveChat optimistic update", () => {
|
||||
it("optimistically sets archived to true in the chats list", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
const initialChats = [makeChat(chatId), makeChat("chat-2")];
|
||||
queryClient.setQueryData(chatsKey, initialChats);
|
||||
|
||||
vi.mocked(API.archiveChat).mockResolvedValue();
|
||||
|
||||
const mutation = archiveChat(queryClient);
|
||||
await mutation.onMutate(chatId);
|
||||
|
||||
const updatedChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
|
||||
expect(updatedChats).toHaveLength(2);
|
||||
expect(updatedChats?.find((c) => c.id === chatId)?.archived).toBe(true);
|
||||
// Other chats are unchanged.
|
||||
expect(updatedChats?.find((c) => c.id === "chat-2")?.archived).toBe(false);
|
||||
});
|
||||
|
||||
it("optimistically sets archived to true in the individual chat cache", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
queryClient.setQueryData(chatsKey, [makeChat(chatId)]);
|
||||
queryClient.setQueryData(chatKey(chatId), makeChatWithMessages(chatId));
|
||||
|
||||
vi.mocked(API.archiveChat).mockResolvedValue();
|
||||
|
||||
const mutation = archiveChat(queryClient);
|
||||
await mutation.onMutate(chatId);
|
||||
|
||||
const cachedChat = queryClient.getQueryData<TypesGen.ChatWithMessages>(
|
||||
chatKey(chatId),
|
||||
);
|
||||
expect(cachedChat?.chat.archived).toBe(true);
|
||||
});
|
||||
|
||||
it("rolls back the chats list on error", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
const initialChats = [makeChat(chatId)];
|
||||
queryClient.setQueryData(chatsKey, initialChats);
|
||||
queryClient.setQueryData(chatKey(chatId), makeChatWithMessages(chatId));
|
||||
|
||||
const mutation = archiveChat(queryClient);
|
||||
const context = await mutation.onMutate(chatId);
|
||||
|
||||
// Verify the optimistic update took effect.
|
||||
expect(
|
||||
queryClient.getQueryData<TypesGen.Chat[]>(chatsKey)?.[0].archived,
|
||||
).toBe(true);
|
||||
|
||||
// Simulate an error — the onError handler should restore original
|
||||
// data.
|
||||
mutation.onError(new Error("server error"), chatId, context);
|
||||
|
||||
const rolledBack = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
|
||||
expect(rolledBack?.[0].archived).toBe(false);
|
||||
});
|
||||
|
||||
it("rolls back the individual chat cache on error", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
queryClient.setQueryData(chatsKey, [makeChat(chatId)]);
|
||||
queryClient.setQueryData(chatKey(chatId), makeChatWithMessages(chatId));
|
||||
|
||||
const mutation = archiveChat(queryClient);
|
||||
const context = await mutation.onMutate(chatId);
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<TypesGen.ChatWithMessages>(chatKey(chatId))?.chat
|
||||
.archived,
|
||||
).toBe(true);
|
||||
|
||||
mutation.onError(new Error("server error"), chatId, context);
|
||||
|
||||
const rolledBack = queryClient.getQueryData<TypesGen.ChatWithMessages>(
|
||||
chatKey(chatId),
|
||||
);
|
||||
expect(rolledBack?.chat.archived).toBe(false);
|
||||
});
|
||||
|
||||
it("handles error rollback gracefully when context is undefined", () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
queryClient.setQueryData(chatsKey, [makeChat(chatId, { archived: true })]);
|
||||
|
||||
const mutation = archiveChat(queryClient);
|
||||
|
||||
// Calling onError with undefined context should not throw.
|
||||
expect(() => {
|
||||
mutation.onError(new Error("fail"), chatId, undefined);
|
||||
}).not.toThrow();
|
||||
|
||||
// Data should remain unchanged since there was nothing to roll
|
||||
// back to.
|
||||
expect(
|
||||
queryClient.getQueryData<TypesGen.Chat[]>(chatsKey)?.[0].archived,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("handles onMutate when no individual chat cache exists", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
queryClient.setQueryData(chatsKey, [makeChat(chatId)]);
|
||||
// Deliberately do NOT set chatKey(chatId) data.
|
||||
|
||||
const mutation = archiveChat(queryClient);
|
||||
const context = await mutation.onMutate(chatId);
|
||||
|
||||
// The list should still be optimistically updated.
|
||||
expect(
|
||||
queryClient.getQueryData<TypesGen.Chat[]>(chatsKey)?.[0].archived,
|
||||
).toBe(true);
|
||||
// previousChat should be undefined.
|
||||
expect(context?.previousChat).toBeUndefined();
|
||||
});
|
||||
|
||||
it("invalidates queries on settled regardless of outcome", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
||||
|
||||
const mutation = archiveChat(queryClient);
|
||||
await mutation.onSettled(undefined, undefined, chatId);
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: chatsKey,
|
||||
});
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: chatKey(chatId),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("unarchiveChat optimistic update", () => {
|
||||
it("optimistically sets archived to false in the chats list", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
queryClient.setQueryData(chatsKey, [makeChat(chatId, { archived: true })]);
|
||||
|
||||
const mutation = unarchiveChat(queryClient);
|
||||
await mutation.onMutate(chatId);
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<TypesGen.Chat[]>(chatsKey)?.[0].archived,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("optimistically sets archived to false in the individual chat cache", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
queryClient.setQueryData(chatsKey, [makeChat(chatId, { archived: true })]);
|
||||
queryClient.setQueryData(
|
||||
chatKey(chatId),
|
||||
makeChatWithMessages(chatId, { archived: true }),
|
||||
);
|
||||
|
||||
const mutation = unarchiveChat(queryClient);
|
||||
await mutation.onMutate(chatId);
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<TypesGen.ChatWithMessages>(chatKey(chatId))?.chat
|
||||
.archived,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rolls back both caches on error", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
queryClient.setQueryData(chatsKey, [makeChat(chatId, { archived: true })]);
|
||||
queryClient.setQueryData(
|
||||
chatKey(chatId),
|
||||
makeChatWithMessages(chatId, { archived: true }),
|
||||
);
|
||||
|
||||
const mutation = unarchiveChat(queryClient);
|
||||
const context = await mutation.onMutate(chatId);
|
||||
|
||||
// Verify optimistic update.
|
||||
expect(
|
||||
queryClient.getQueryData<TypesGen.Chat[]>(chatsKey)?.[0].archived,
|
||||
).toBe(false);
|
||||
expect(
|
||||
queryClient.getQueryData<TypesGen.ChatWithMessages>(chatKey(chatId))?.chat
|
||||
.archived,
|
||||
).toBe(false);
|
||||
|
||||
// Roll back.
|
||||
mutation.onError(new Error("server error"), chatId, context);
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<TypesGen.Chat[]>(chatsKey)?.[0].archived,
|
||||
).toBe(true);
|
||||
expect(
|
||||
queryClient.getQueryData<TypesGen.ChatWithMessages>(chatKey(chatId))?.chat
|
||||
.archived,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("invalidates queries on settled", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
||||
|
||||
const mutation = unarchiveChat(queryClient);
|
||||
await mutation.onSettled(undefined, undefined, chatId);
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: chatsKey,
|
||||
});
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: chatKey(chatId),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, render, renderHook, waitFor } from "@testing-library/react";
|
||||
import { watchChat } from "api/api";
|
||||
import { chatKey } from "api/queries/chats";
|
||||
import { chatKey, chatsKey } from "api/queries/chats";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "react-query";
|
||||
@@ -2029,3 +2029,484 @@ describe("useChatStore", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSidebarChat via stream events", () => {
|
||||
it("updates sidebar chat status on status stream event", async () => {
|
||||
immediateAnimationFrame();
|
||||
|
||||
const chatID = "chat-sidebar-status";
|
||||
const mockSocket = createMockSocket();
|
||||
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnWindowFocus: false,
|
||||
networkMode: "offlineFirst",
|
||||
},
|
||||
},
|
||||
});
|
||||
const initialChat = makeChat(chatID);
|
||||
// Seed the chats list so updateSidebarChat can find it.
|
||||
queryClient.setQueryData(chatsKey, [initialChat]);
|
||||
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const setChatErrorReason = vi.fn();
|
||||
const clearChatErrorReason = vi.fn();
|
||||
|
||||
renderHook(
|
||||
() => {
|
||||
const { store } = useChatStore({
|
||||
chatID,
|
||||
chatMessages: [],
|
||||
chatRecord: initialChat,
|
||||
chatData: {
|
||||
chat: initialChat,
|
||||
messages: [],
|
||||
queued_messages: [],
|
||||
},
|
||||
chatQueuedMessages: [],
|
||||
setChatErrorReason,
|
||||
clearChatErrorReason,
|
||||
});
|
||||
return { chatStatus: useChatSelector(store, selectChatStatus) };
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
mockSocket.emitData({
|
||||
type: "status",
|
||||
chat_id: chatID,
|
||||
status: { status: "completed" },
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const sidebarChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
|
||||
expect(sidebarChats?.[0].status).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not change sidebar updated_at on message stream event", async () => {
|
||||
immediateAnimationFrame();
|
||||
|
||||
const chatID = "chat-sidebar-message";
|
||||
const mockSocket = createMockSocket();
|
||||
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnWindowFocus: false,
|
||||
networkMode: "offlineFirst",
|
||||
},
|
||||
},
|
||||
});
|
||||
const initialChat = makeChat(chatID);
|
||||
queryClient.setQueryData(chatsKey, [initialChat]);
|
||||
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const setChatErrorReason = vi.fn();
|
||||
const clearChatErrorReason = vi.fn();
|
||||
|
||||
renderHook(
|
||||
() => {
|
||||
const { store } = useChatStore({
|
||||
chatID,
|
||||
chatMessages: [],
|
||||
chatRecord: initialChat,
|
||||
chatData: {
|
||||
chat: initialChat,
|
||||
messages: [],
|
||||
queued_messages: [],
|
||||
},
|
||||
chatQueuedMessages: [],
|
||||
setChatErrorReason,
|
||||
clearChatErrorReason,
|
||||
});
|
||||
return {
|
||||
orderedIDs: useChatSelector(store, selectOrderedMessageIDs),
|
||||
};
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
|
||||
});
|
||||
|
||||
const messageTimestamp = "2025-06-15T12:00:00.000Z";
|
||||
act(() => {
|
||||
mockSocket.emitData({
|
||||
type: "message",
|
||||
chat_id: chatID,
|
||||
message: {
|
||||
...makeMessage(chatID, 42, "assistant", "hello"),
|
||||
created_at: messageTimestamp,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// The per-chat WebSocket does not write updated_at — only the
|
||||
// global chat-list WebSocket delivers the authoritative server
|
||||
// timestamp. Verify it stays at the original value.
|
||||
await waitFor(() => {
|
||||
const sidebarChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
|
||||
expect(sidebarChats?.[0].updated_at).toBe(initialChat.updated_at);
|
||||
});
|
||||
});
|
||||
|
||||
it("updates sidebar chat status to error on error stream event", async () => {
|
||||
immediateAnimationFrame();
|
||||
|
||||
const chatID = "chat-sidebar-error";
|
||||
const mockSocket = createMockSocket();
|
||||
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnWindowFocus: false,
|
||||
networkMode: "offlineFirst",
|
||||
},
|
||||
},
|
||||
});
|
||||
const initialChat = makeChat(chatID);
|
||||
queryClient.setQueryData(chatsKey, [initialChat]);
|
||||
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const setChatErrorReason = vi.fn();
|
||||
const clearChatErrorReason = vi.fn();
|
||||
|
||||
renderHook(
|
||||
() => {
|
||||
const { store } = useChatStore({
|
||||
chatID,
|
||||
chatMessages: [],
|
||||
chatRecord: initialChat,
|
||||
chatData: {
|
||||
chat: initialChat,
|
||||
messages: [],
|
||||
queued_messages: [],
|
||||
},
|
||||
chatQueuedMessages: [],
|
||||
setChatErrorReason,
|
||||
clearChatErrorReason,
|
||||
});
|
||||
return { chatStatus: useChatSelector(store, selectChatStatus) };
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
mockSocket.emitData({
|
||||
type: "error",
|
||||
chat_id: chatID,
|
||||
error: { message: "something went wrong" },
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const sidebarChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
|
||||
expect(sidebarChats?.[0].status).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not update sidebar for a different chatID", async () => {
|
||||
immediateAnimationFrame();
|
||||
|
||||
const chatID = "chat-active";
|
||||
const otherChatID = "chat-other";
|
||||
const mockSocket = createMockSocket();
|
||||
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnWindowFocus: false,
|
||||
networkMode: "offlineFirst",
|
||||
},
|
||||
},
|
||||
});
|
||||
const activeChat = makeChat(chatID);
|
||||
const otherChat = makeChat(otherChatID);
|
||||
queryClient.setQueryData(chatsKey, [activeChat, otherChat]);
|
||||
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const setChatErrorReason = vi.fn();
|
||||
const clearChatErrorReason = vi.fn();
|
||||
|
||||
renderHook(
|
||||
() => {
|
||||
useChatStore({
|
||||
chatID,
|
||||
chatMessages: [],
|
||||
chatRecord: activeChat,
|
||||
chatData: {
|
||||
chat: activeChat,
|
||||
messages: [],
|
||||
queued_messages: [],
|
||||
},
|
||||
chatQueuedMessages: [],
|
||||
setChatErrorReason,
|
||||
clearChatErrorReason,
|
||||
});
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
|
||||
});
|
||||
|
||||
// Emit a status event for the *active* chat.
|
||||
act(() => {
|
||||
mockSocket.emitData({
|
||||
type: "status",
|
||||
chat_id: chatID,
|
||||
status: { status: "completed" },
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const sidebarChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
|
||||
expect(sidebarChats?.find((c) => c.id === chatID)?.status).toBe(
|
||||
"completed",
|
||||
);
|
||||
});
|
||||
|
||||
// The other chat should remain unchanged.
|
||||
const sidebarChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
|
||||
expect(sidebarChats?.find((c) => c.id === otherChatID)?.status).toBe(
|
||||
"running",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not regress updated_at on message events", async () => {
|
||||
immediateAnimationFrame();
|
||||
|
||||
const chatID = "chat-no-regress-msg";
|
||||
const mockSocket = createMockSocket();
|
||||
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnWindowFocus: false,
|
||||
networkMode: "offlineFirst",
|
||||
},
|
||||
},
|
||||
});
|
||||
const futureTimestamp = "2099-01-01T00:00:00.000Z";
|
||||
const initialChat = { ...makeChat(chatID), updated_at: futureTimestamp };
|
||||
queryClient.setQueryData(chatsKey, [initialChat]);
|
||||
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const setChatErrorReason = vi.fn();
|
||||
const clearChatErrorReason = vi.fn();
|
||||
|
||||
renderHook(
|
||||
() => {
|
||||
const { store } = useChatStore({
|
||||
chatID,
|
||||
chatMessages: [],
|
||||
chatRecord: initialChat,
|
||||
chatData: {
|
||||
chat: initialChat,
|
||||
messages: [],
|
||||
queued_messages: [],
|
||||
},
|
||||
chatQueuedMessages: [],
|
||||
setChatErrorReason,
|
||||
clearChatErrorReason,
|
||||
});
|
||||
return {
|
||||
orderedIDs: useChatSelector(store, selectOrderedMessageIDs),
|
||||
};
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
|
||||
});
|
||||
|
||||
// The per-chat WS no longer writes updated_at, so any
|
||||
// message event should leave it untouched.
|
||||
act(() => {
|
||||
mockSocket.emitData({
|
||||
type: "message",
|
||||
chat_id: chatID,
|
||||
message: {
|
||||
...makeMessage(chatID, 99, "assistant", "old message"),
|
||||
created_at: "2020-01-01T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const sidebarChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
|
||||
expect(sidebarChats?.[0].updated_at).toBe(futureTimestamp);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not change updated_at on status events", async () => {
|
||||
immediateAnimationFrame();
|
||||
|
||||
const chatID = "chat-no-regress-status";
|
||||
const mockSocket = createMockSocket();
|
||||
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnWindowFocus: false,
|
||||
networkMode: "offlineFirst",
|
||||
},
|
||||
},
|
||||
});
|
||||
const initialChat = makeChat(chatID);
|
||||
queryClient.setQueryData(chatsKey, [initialChat]);
|
||||
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const setChatErrorReason = vi.fn();
|
||||
const clearChatErrorReason = vi.fn();
|
||||
|
||||
renderHook(
|
||||
() => {
|
||||
const { store } = useChatStore({
|
||||
chatID,
|
||||
chatMessages: [],
|
||||
chatRecord: initialChat,
|
||||
chatData: {
|
||||
chat: initialChat,
|
||||
messages: [],
|
||||
queued_messages: [],
|
||||
},
|
||||
chatQueuedMessages: [],
|
||||
setChatErrorReason,
|
||||
clearChatErrorReason,
|
||||
});
|
||||
return { chatStatus: useChatSelector(store, selectChatStatus) };
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
mockSocket.emitData({
|
||||
type: "status",
|
||||
chat_id: chatID,
|
||||
status: { status: "completed" },
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const sidebarChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
|
||||
// Status should update, but updated_at must stay untouched.
|
||||
expect(sidebarChats?.[0].status).toBe("completed");
|
||||
expect(sidebarChats?.[0].updated_at).toBe(initialChat.updated_at);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not change updated_at on error events", async () => {
|
||||
immediateAnimationFrame();
|
||||
|
||||
const chatID = "chat-no-regress-error";
|
||||
const mockSocket = createMockSocket();
|
||||
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnWindowFocus: false,
|
||||
networkMode: "offlineFirst",
|
||||
},
|
||||
},
|
||||
});
|
||||
const initialChat = makeChat(chatID);
|
||||
queryClient.setQueryData(chatsKey, [initialChat]);
|
||||
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const setChatErrorReason = vi.fn();
|
||||
const clearChatErrorReason = vi.fn();
|
||||
|
||||
renderHook(
|
||||
() => {
|
||||
const { store } = useChatStore({
|
||||
chatID,
|
||||
chatMessages: [],
|
||||
chatRecord: initialChat,
|
||||
chatData: {
|
||||
chat: initialChat,
|
||||
messages: [],
|
||||
queued_messages: [],
|
||||
},
|
||||
chatQueuedMessages: [],
|
||||
setChatErrorReason,
|
||||
clearChatErrorReason,
|
||||
});
|
||||
return { chatStatus: useChatSelector(store, selectChatStatus) };
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
mockSocket.emitData({
|
||||
type: "error",
|
||||
chat_id: chatID,
|
||||
error: { message: "something broke" },
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const sidebarChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
|
||||
expect(sidebarChats?.[0].status).toBe("error");
|
||||
expect(sidebarChats?.[0].updated_at).toBe(initialChat.updated_at);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -636,10 +636,11 @@ export const useChatStore = (
|
||||
if (changed) {
|
||||
scheduleStreamReset();
|
||||
}
|
||||
updateSidebarChat((chat) => ({
|
||||
...chat,
|
||||
updated_at: message.created_at ?? new Date().toISOString(),
|
||||
}));
|
||||
// Do not update updated_at here. The global
|
||||
// chat-list WebSocket delivers the authoritative
|
||||
// server timestamp; fabricating a client-side
|
||||
// value causes the chat to flicker between time
|
||||
// groups when the two sources race.
|
||||
continue;
|
||||
}
|
||||
case "queue_update":
|
||||
@@ -679,9 +680,7 @@ export const useChatStore = (
|
||||
updateSidebarChat((chat) => ({
|
||||
...chat,
|
||||
status: nextStatus,
|
||||
updated_at: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
continue;
|
||||
}
|
||||
case "error": {
|
||||
@@ -695,7 +694,6 @@ export const useChatStore = (
|
||||
updateSidebarChat((chat) => ({
|
||||
...chat,
|
||||
status: "error",
|
||||
updated_at: new Date().toISOString(),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -449,7 +449,10 @@ const AgentsPage: FC = () => {
|
||||
...c,
|
||||
status: updatedChat.status,
|
||||
title: updatedChat.title,
|
||||
updated_at: updatedChat.updated_at,
|
||||
updated_at:
|
||||
c.updated_at > updatedChat.updated_at
|
||||
? c.updated_at
|
||||
: updatedChat.updated_at,
|
||||
}
|
||||
: c,
|
||||
);
|
||||
@@ -472,7 +475,10 @@ const AgentsPage: FC = () => {
|
||||
...previousChat.chat,
|
||||
status: updatedChat.status,
|
||||
title: updatedChat.title,
|
||||
updated_at: updatedChat.updated_at,
|
||||
updated_at:
|
||||
previousChat.chat.updated_at > updatedChat.updated_at
|
||||
? previousChat.chat.updated_at
|
||||
: updatedChat.updated_at,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
import { NavLink, useParams } from "react-router";
|
||||
import { cn } from "utils/cn";
|
||||
import { shortRelativeTime } from "utils/time";
|
||||
import { getTimeGroup, TIME_GROUPS } from "./timeGroups";
|
||||
|
||||
interface AgentsSidebarProps {
|
||||
chats: readonly Chat[];
|
||||
@@ -88,24 +89,6 @@ type ChatTree = {
|
||||
readonly parentById: ReadonlyMap<string, string | undefined>;
|
||||
};
|
||||
|
||||
const TIME_GROUPS = ["Today", "Yesterday", "This Week", "Older"] as const;
|
||||
type TimeGroup = (typeof TIME_GROUPS)[number];
|
||||
|
||||
function getTimeGroup(dateStr: string): TimeGroup {
|
||||
const now = new Date();
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const weekAgo = new Date(today);
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
|
||||
if (date >= today) return "Today";
|
||||
if (date >= yesterday) return "Yesterday";
|
||||
if (date >= weekAgo) return "This Week";
|
||||
return "Older";
|
||||
}
|
||||
|
||||
const getStatusConfig = (status: ChatStatus) => {
|
||||
return statusConfig[status] ?? statusConfig.completed;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getTimeGroup, TIME_GROUPS } from "./timeGroups";
|
||||
|
||||
describe("getTimeGroup", () => {
|
||||
beforeEach(() => {
|
||||
// Pin "now" to 2025-07-15T14:30:00.000Z (a Tuesday) so all
|
||||
// assertions are deterministic.
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-07-15T14:30:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("exports the expected group labels in order", () => {
|
||||
expect(TIME_GROUPS).toEqual(["Today", "Yesterday", "This Week", "Older"]);
|
||||
});
|
||||
|
||||
it('returns "Today" for a date later today', () => {
|
||||
expect(getTimeGroup("2025-07-15T18:00:00.000Z")).toBe("Today");
|
||||
});
|
||||
|
||||
it('returns "Today" for a date at start of today (midnight)', () => {
|
||||
expect(getTimeGroup("2025-07-15T00:00:00.000Z")).toBe("Today");
|
||||
});
|
||||
|
||||
it('returns "Today" for a date earlier today', () => {
|
||||
expect(getTimeGroup("2025-07-15T08:00:00.000Z")).toBe("Today");
|
||||
});
|
||||
|
||||
it('returns "Yesterday" for a date in the previous calendar day', () => {
|
||||
expect(getTimeGroup("2025-07-14T10:00:00.000Z")).toBe("Yesterday");
|
||||
});
|
||||
|
||||
it('returns "Yesterday" for exactly midnight of yesterday', () => {
|
||||
expect(getTimeGroup("2025-07-14T00:00:00.000Z")).toBe("Yesterday");
|
||||
});
|
||||
|
||||
it('returns "This Week" for a date 2 days ago', () => {
|
||||
expect(getTimeGroup("2025-07-13T12:00:00.000Z")).toBe("This Week");
|
||||
});
|
||||
|
||||
it('returns "This Week" for a date 6 days ago', () => {
|
||||
expect(getTimeGroup("2025-07-09T12:00:00.000Z")).toBe("This Week");
|
||||
});
|
||||
|
||||
it('returns "This Week" for exactly 7 days ago at midnight', () => {
|
||||
// weekAgo = today - 7 days = 2025-07-08T00:00:00 local.
|
||||
// A date at exactly that boundary should be "This Week"
|
||||
// because the comparison is date >= weekAgo.
|
||||
expect(getTimeGroup("2025-07-08T00:00:00.000Z")).toBe("This Week");
|
||||
});
|
||||
|
||||
it('returns "Older" for a date 8 days ago', () => {
|
||||
expect(getTimeGroup("2025-07-07T23:59:59.000Z")).toBe("Older");
|
||||
});
|
||||
|
||||
it('returns "Older" for a date far in the past', () => {
|
||||
expect(getTimeGroup("2024-01-01T00:00:00.000Z")).toBe("Older");
|
||||
});
|
||||
|
||||
it('returns "Today" for a date in the future', () => {
|
||||
expect(getTimeGroup("2025-07-20T00:00:00.000Z")).toBe("Today");
|
||||
});
|
||||
|
||||
describe("midnight boundary edge cases", () => {
|
||||
it('classifies 1 second before midnight as "Yesterday"', () => {
|
||||
// One second before today's midnight in UTC.
|
||||
expect(getTimeGroup("2025-07-14T23:59:59.000Z")).toBe("Yesterday");
|
||||
});
|
||||
|
||||
it('classifies exactly midnight as "Today"', () => {
|
||||
expect(getTimeGroup("2025-07-15T00:00:00.000Z")).toBe("Today");
|
||||
});
|
||||
|
||||
it('classifies 1 second after midnight as "Today"', () => {
|
||||
expect(getTimeGroup("2025-07-15T00:00:01.000Z")).toBe("Today");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Time-based grouping utility used by the sidebar to categorize
|
||||
* chats into "Today", "Yesterday", "This Week", and "Older".
|
||||
*/
|
||||
export const TIME_GROUPS = [
|
||||
"Today",
|
||||
"Yesterday",
|
||||
"This Week",
|
||||
"Older",
|
||||
] as const;
|
||||
type TimeGroup = (typeof TIME_GROUPS)[number];
|
||||
|
||||
export function getTimeGroup(dateStr: string): TimeGroup {
|
||||
const now = new Date();
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const weekAgo = new Date(today);
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
|
||||
if (date >= today) return "Today";
|
||||
if (date >= yesterday) return "Yesterday";
|
||||
if (date >= weekAgo) return "This Week";
|
||||
return "Older";
|
||||
}
|
||||
Reference in New Issue
Block a user