mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: exclude subagent chats from sidebar pagination (#24404)
GetChats now returns only root chats (parent_chat_id IS NULL). A new GetChildChatsByParentIDs query fetches children for visible roots and embeds them in each parent's Children field. The singular getChat endpoint does the same. Archive invariant is one-way: parent archived implies child archived. Parent archive/unarchive cascades via root_chat_id. Individual child archive is permitted; child unarchive while the parent is archived is rejected atomically (row lock on child, re-read parent inside the transaction). Embedded children are filtered by the caller's archive state so individually-archived children stay hidden from active-parent views. Gitsync MarkStale uses GetChatsByWorkspaceIDs directly; MarkStaleParams.OwnerID removed (dead after the switch). Frontend: buildChatTree reads from the embedded children field, WebSocket handlers route child events into the parent's children array, and archiving a child strips it from the parent cache.
This commit is contained in:
@@ -4,6 +4,7 @@ import { API } from "#/api/api";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { buildOptimisticEditedMessage } from "./chatMessageEdits";
|
||||
import {
|
||||
appendChildToParentInCache,
|
||||
archiveChat,
|
||||
cancelChatListRefetches,
|
||||
chatCostSummary,
|
||||
@@ -23,10 +24,12 @@ import {
|
||||
pinChat,
|
||||
promoteChatQueuedMessage,
|
||||
regenerateChatTitle,
|
||||
removeChildFromParentInCache,
|
||||
reorderPinnedChat,
|
||||
unarchiveChat,
|
||||
unpinChat,
|
||||
updateChatPlanMode,
|
||||
updateChildInParentCache,
|
||||
updateInfiniteChatsCache,
|
||||
} from "./chats";
|
||||
|
||||
@@ -95,6 +98,7 @@ const makeChat = (
|
||||
has_unread: false,
|
||||
client_type: "ui",
|
||||
last_error: null,
|
||||
children: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -263,6 +267,29 @@ describe("archiveChat optimistic update", () => {
|
||||
expect(cachedChat?.archived).toBe(true);
|
||||
});
|
||||
|
||||
it("strips an individually-archived child from its parent's embedded children", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const child = makeChat("child-1", {
|
||||
parent_chat_id: "parent-1",
|
||||
root_chat_id: "parent-1",
|
||||
});
|
||||
const sibling = makeChat("child-2", {
|
||||
parent_chat_id: "parent-1",
|
||||
root_chat_id: "parent-1",
|
||||
});
|
||||
const parent = makeChat("parent-1", { children: [child, sibling] });
|
||||
seedInfiniteChats(queryClient, [parent]);
|
||||
|
||||
vi.mocked(API.experimental.updateChat).mockResolvedValue();
|
||||
|
||||
const mutation = archiveChat(queryClient);
|
||||
await mutation.onMutate("child-1");
|
||||
|
||||
const result = readInfiniteChats(queryClient);
|
||||
expect(result?.[0].children).toHaveLength(1);
|
||||
expect(result?.[0].children?.[0].id).toBe("child-2");
|
||||
});
|
||||
|
||||
it("rolls back the chats list on error by invalidating", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
@@ -1675,3 +1702,155 @@ describe("mutation onMutate cancels pagination fetches", () => {
|
||||
expect(chat?.archived).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendChildToParentInCache", () => {
|
||||
it("appends the child to the matching parent's children array", () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const parent = makeChat("parent-1");
|
||||
seedInfiniteChats(queryClient, [parent]);
|
||||
|
||||
const child = makeChat("child-1", {
|
||||
parent_chat_id: "parent-1",
|
||||
root_chat_id: "parent-1",
|
||||
});
|
||||
appendChildToParentInCache(queryClient, child, "parent-1");
|
||||
|
||||
const result = readInfiniteChats(queryClient);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result?.[0].children).toHaveLength(1);
|
||||
expect(result?.[0].children?.[0].id).toBe("child-1");
|
||||
});
|
||||
|
||||
it("silently drops the child when the parent is not in any page", () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const other = makeChat("other-root");
|
||||
seedInfiniteChats(queryClient, [other]);
|
||||
|
||||
const child = makeChat("orphan-child", {
|
||||
parent_chat_id: "missing-parent",
|
||||
root_chat_id: "missing-parent",
|
||||
});
|
||||
appendChildToParentInCache(queryClient, child, "missing-parent");
|
||||
|
||||
const result = readInfiniteChats(queryClient);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result?.[0].id).toBe("other-root");
|
||||
expect(result?.[0].children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not duplicate a child that already exists under the parent", () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const existingChild = makeChat("child-1", {
|
||||
parent_chat_id: "parent-1",
|
||||
root_chat_id: "parent-1",
|
||||
});
|
||||
const parent = makeChat("parent-1", { children: [existingChild] });
|
||||
seedInfiniteChats(queryClient, [parent]);
|
||||
|
||||
appendChildToParentInCache(queryClient, existingChild, "parent-1");
|
||||
|
||||
const result = readInfiniteChats(queryClient);
|
||||
expect(result?.[0].children).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateChildInParentCache", () => {
|
||||
it("applies the updater to a child nested under its parent", () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const child = makeChat("child-1", {
|
||||
parent_chat_id: "parent-1",
|
||||
root_chat_id: "parent-1",
|
||||
title: "Original title",
|
||||
});
|
||||
const parent = makeChat("parent-1", { children: [child] });
|
||||
seedInfiniteChats(queryClient, [parent]);
|
||||
|
||||
const found = updateChildInParentCache(
|
||||
queryClient,
|
||||
(c) => ({ ...c, title: "Updated title" }),
|
||||
"child-1",
|
||||
);
|
||||
expect(found).toBe(true);
|
||||
|
||||
const result = readInfiniteChats(queryClient);
|
||||
expect(result?.[0].children?.[0].title).toBe("Updated title");
|
||||
});
|
||||
|
||||
it("returns false when the child is not present under any parent", () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const parent = makeChat("parent-1");
|
||||
seedInfiniteChats(queryClient, [parent]);
|
||||
|
||||
const found = updateChildInParentCache(
|
||||
queryClient,
|
||||
(c) => ({ ...c, title: "Never applied" }),
|
||||
"missing-child",
|
||||
);
|
||||
expect(found).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the same reference when the updater returns the child unchanged", () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const child = makeChat("child-1", {
|
||||
parent_chat_id: "parent-1",
|
||||
root_chat_id: "parent-1",
|
||||
});
|
||||
const parent = makeChat("parent-1", { children: [child] });
|
||||
seedInfiniteChats(queryClient, [parent]);
|
||||
|
||||
const before = readInfiniteChats(queryClient)?.[0];
|
||||
const found = updateChildInParentCache(queryClient, (c) => c, "child-1");
|
||||
const after = readInfiniteChats(queryClient)?.[0];
|
||||
|
||||
expect(found).toBe(false);
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeChildFromParentInCache", () => {
|
||||
it("removes the child from its parent's children array", () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const child = makeChat("child-1", {
|
||||
parent_chat_id: "parent-1",
|
||||
root_chat_id: "parent-1",
|
||||
});
|
||||
const sibling = makeChat("child-2", {
|
||||
parent_chat_id: "parent-1",
|
||||
root_chat_id: "parent-1",
|
||||
});
|
||||
const parent = makeChat("parent-1", { children: [child, sibling] });
|
||||
seedInfiniteChats(queryClient, [parent]);
|
||||
|
||||
const found = removeChildFromParentInCache(queryClient, "child-1");
|
||||
expect(found).toBe(true);
|
||||
|
||||
const result = readInfiniteChats(queryClient);
|
||||
expect(result?.[0].children).toHaveLength(1);
|
||||
expect(result?.[0].children?.[0].id).toBe("child-2");
|
||||
});
|
||||
|
||||
it("returns false when no parent embeds the given child", () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const parent = makeChat("parent-1");
|
||||
seedInfiniteChats(queryClient, [parent]);
|
||||
|
||||
const found = removeChildFromParentInCache(queryClient, "missing-child");
|
||||
expect(found).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the parent reference when the child is not found", () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const child = makeChat("child-1", {
|
||||
parent_chat_id: "parent-1",
|
||||
root_chat_id: "parent-1",
|
||||
});
|
||||
const parent = makeChat("parent-1", { children: [child] });
|
||||
seedInfiniteChats(queryClient, [parent]);
|
||||
|
||||
const before = readInfiniteChats(queryClient)?.[0];
|
||||
removeChildFromParentInCache(queryClient, "missing-child");
|
||||
const after = readInfiniteChats(queryClient)?.[0];
|
||||
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,6 +102,90 @@ export const readInfiniteChatsCache = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Appends a child chat to its parent's `children` array across all
|
||||
* infinite chat query caches. If the parent is not in any loaded page,
|
||||
* the child is silently dropped (it will appear when the parent loads).
|
||||
*/
|
||||
export const appendChildToParentInCache = (
|
||||
queryClient: QueryClient,
|
||||
child: TypesGen.Chat,
|
||||
parentId: string,
|
||||
) => {
|
||||
updateInfiniteChatsCache(queryClient, (chats) => {
|
||||
let changed = false;
|
||||
const next = chats.map((c) => {
|
||||
if (c.id !== parentId) return c;
|
||||
// Avoid duplicates.
|
||||
if (c.children?.some((ch) => ch.id === child.id)) return c;
|
||||
changed = true;
|
||||
return { ...c, children: [...(c.children ?? []), child] };
|
||||
});
|
||||
return changed ? next : chats;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a child chat within its parent's `children` array across all
|
||||
* infinite chat query caches. Returns true if the child was found and
|
||||
* updated, false otherwise.
|
||||
*/
|
||||
export const updateChildInParentCache = (
|
||||
queryClient: QueryClient,
|
||||
updater: (child: TypesGen.Chat) => TypesGen.Chat,
|
||||
childId: string,
|
||||
) => {
|
||||
let found = false;
|
||||
updateInfiniteChatsCache(queryClient, (chats) => {
|
||||
let changed = false;
|
||||
const next = chats.map((c) => {
|
||||
if (!c.children?.length) return c;
|
||||
let childChanged = false;
|
||||
const nextChildren = c.children.map((ch) => {
|
||||
if (ch.id !== childId) return ch;
|
||||
const updated = updater(ch);
|
||||
if (updated !== ch) {
|
||||
childChanged = true;
|
||||
found = true;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
if (!childChanged) return c;
|
||||
changed = true;
|
||||
return { ...c, children: nextChildren };
|
||||
});
|
||||
return changed ? next : chats;
|
||||
});
|
||||
return found;
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a child chat from its parent's `children` array across all
|
||||
* infinite chat query caches. Returns true if the child was found and
|
||||
* removed, false otherwise. Used when a child is archived individually
|
||||
* (the sidebar hides children whose archive state differs from the
|
||||
* parent) and when a `deleted` pubsub event arrives for a child chat.
|
||||
*/
|
||||
export const removeChildFromParentInCache = (
|
||||
queryClient: QueryClient,
|
||||
childId: string,
|
||||
) => {
|
||||
let found = false;
|
||||
updateInfiniteChatsCache(queryClient, (chats) => {
|
||||
let changed = false;
|
||||
const next = chats.map((c) => {
|
||||
if (!c.children?.length) return c;
|
||||
const filtered = c.children.filter((ch) => ch.id !== childId);
|
||||
if (filtered.length === c.children.length) return c;
|
||||
found = true;
|
||||
changed = true;
|
||||
return { ...c, children: filtered };
|
||||
});
|
||||
return changed ? next : chats;
|
||||
});
|
||||
return found;
|
||||
};
|
||||
|
||||
const getNextOptimisticPinOrder = (queryClient: QueryClient): number => {
|
||||
let maxPinOrder = 0;
|
||||
const queries = queryClient.getQueriesData<
|
||||
@@ -309,11 +393,15 @@ export const archiveChat = (queryClient: QueryClient) => ({
|
||||
const previousChat = queryClient.getQueryData<TypesGen.Chat>(
|
||||
chatKey(chatId),
|
||||
);
|
||||
// Flip archived flag in the flat root list; strip the
|
||||
// chat from any parent's embedded children (individual
|
||||
// child archive).
|
||||
updateInfiniteChatsCache(queryClient, (chats) =>
|
||||
chats.map((chat) =>
|
||||
chat.id === chatId ? { ...chat, archived: true } : chat,
|
||||
),
|
||||
);
|
||||
removeChildFromParentInCache(queryClient, chatId);
|
||||
if (previousChat) {
|
||||
queryClient.setQueryData<TypesGen.Chat>(chatKey(chatId), {
|
||||
...previousChat,
|
||||
|
||||
Generated
+8
@@ -1261,6 +1261,14 @@ export interface Chat {
|
||||
readonly last_injected_context?: readonly ChatMessagePart[];
|
||||
readonly warnings?: readonly string[];
|
||||
readonly client_type: ChatClientType;
|
||||
/**
|
||||
* Children holds child (subagent) chats nested under this root
|
||||
* chat. Always initialized to an empty slice so the JSON field
|
||||
* is present as []. Child chats cannot create their own
|
||||
* subagents, so nesting depth is capped at 1 and this slice is
|
||||
* always empty for child chats.
|
||||
*/
|
||||
readonly children: readonly Chat[];
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
|
||||
Reference in New Issue
Block a user