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:
Mathias Fredriksson
2026-04-20 13:19:59 +03:00
committed by GitHub
parent df429b7f60
commit fc2493780f
30 changed files with 1514 additions and 225 deletions
+179
View File
@@ -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);
});
});
+88
View File
@@ -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,
+8
View File
@@ -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