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
@@ -134,6 +134,7 @@ const baseChatFields = {
has_unread: false,
client_type: "ui",
last_error: null,
children: [],
} as const;
// ---------------------------------------------------------------------------
@@ -58,6 +58,7 @@ const buildChat = (overrides: Partial<TypesGen.Chat> = {}): TypesGen.Chat => ({
has_unread: false,
client_type: "ui",
last_error: null,
children: [],
...overrides,
});
+73 -46
View File
@@ -10,6 +10,7 @@ import { toast } from "sonner";
import { API, watchChats } from "#/api/api";
import { getErrorMessage } from "#/api/errors";
import {
appendChildToParentInCache,
archiveChat,
cancelChatListRefetches,
chatDiffContentsKey,
@@ -23,9 +24,11 @@ import {
prependToInfiniteChatsCache,
readInfiniteChatsCache,
regenerateChatTitle,
removeChildFromParentInCache,
reorderPinnedChat,
unarchiveChat,
unpinChat,
updateChildInParentCache,
updateInfiniteChatsCache,
} from "#/api/queries/chats";
import { workspaceById } from "#/api/queries/workspaces";
@@ -502,19 +505,22 @@ const AgentsPage: FC = () => {
}
if (chatEvent.kind === "deleted") {
// Drop the chat from the flat root list (root or
// cascade via root_chat_id) and from any parent's
// embedded children (individual child archive).
updateInfiniteChatsCache(queryClient, (chats) =>
chats.filter(
(c) =>
c.id !== updatedChat.id && c.root_chat_id !== updatedChat.id,
),
);
removeChildFromParentInCache(queryClient, updatedChat.id);
queryClient.removeQueries({
queryKey: chatKey(updatedChat.id),
exact: true,
});
return;
}
if (chatEvent.kind === "diff_status_change") {
// Only refetch the diff file contents — the chat's
// diff_status field is already written into the
@@ -560,59 +566,80 @@ const AgentsPage: FC = () => {
// page, so a naive prepend would duplicate the
// chat into every loaded page.
if (chatEvent.kind === "created") {
prependToInfiniteChatsCache(queryClient, updatedChat);
if (updatedChat.parent_chat_id) {
// Child chat: append to its parent's children
// array. If the parent is not in any loaded
// page, the child is silently dropped.
appendChildToParentInCache(
queryClient,
updatedChat,
updatedChat.parent_chat_id,
);
} else {
prependToInfiniteChatsCache(queryClient, updatedChat);
}
} else {
// Build a field updater shared between root and
// child cache update paths.
const applyFields = (c: TypesGen.Chat): TypesGen.Chat => {
const nextStatus = isStatusEvent ? updatedChat.status : c.status;
const nextTitle = isTitleEvent ? updatedChat.title : c.title;
const nextDiffStatus = isDiffStatusEvent
? updatedChat.diff_status
: c.diff_status;
const nextWorkspaceId =
updatedChat.workspace_id ?? c.workspace_id;
const nextBuildId = updatedChat.build_id ?? c.build_id;
const nextUpdatedAt =
c.updated_at > updatedChat.updated_at
? c.updated_at
: updatedChat.updated_at;
// The server's pubsub path does not compute
// has_unread (it always sends false). For
// status_change events on non-active chats,
// optimistically mark as unread since the
// assistant produced new output.
const nextHasUnread =
isStatusEvent && updatedChat.id !== activeChatIDRef.current
? true
: c.has_unread;
if (
nextStatus === c.status &&
nextTitle === c.title &&
diffStatusEqual(nextDiffStatus, c.diff_status) &&
nextWorkspaceId === c.workspace_id &&
nextBuildId === c.build_id &&
nextHasUnread === c.has_unread
) {
return c;
}
return {
...c,
status: nextStatus,
title: nextTitle,
diff_status: nextDiffStatus,
workspace_id: nextWorkspaceId,
build_id: nextBuildId,
updated_at: nextUpdatedAt,
has_unread: nextHasUnread,
};
};
// Try root-level update first.
updateInfiniteChatsCache(queryClient, (chats) => {
let didUpdate = false;
const nextChats = chats.map((c) => {
if (c.id !== updatedChat.id) return c;
const nextStatus = isStatusEvent
? updatedChat.status
: c.status;
const nextTitle = isTitleEvent ? updatedChat.title : c.title;
const nextDiffStatus = isDiffStatusEvent
? updatedChat.diff_status
: c.diff_status;
const nextWorkspaceId =
updatedChat.workspace_id ?? c.workspace_id;
const nextBuildId = updatedChat.build_id ?? c.build_id;
const nextUpdatedAt =
c.updated_at > updatedChat.updated_at
? c.updated_at
: updatedChat.updated_at;
// The server's pubsub path does not compute
// has_unread (it always sends false). For
// status_change events on non-active chats,
// optimistically mark as unread since the
// assistant produced new output.
const nextHasUnread =
isStatusEvent && updatedChat.id !== activeChatIDRef.current
? true
: c.has_unread;
if (
nextStatus === c.status &&
nextTitle === c.title &&
diffStatusEqual(nextDiffStatus, c.diff_status) &&
nextWorkspaceId === c.workspace_id &&
nextBuildId === c.build_id &&
nextHasUnread === c.has_unread
) {
return c;
}
didUpdate = true;
return {
...c,
status: nextStatus,
title: nextTitle,
diff_status: nextDiffStatus,
workspace_id: nextWorkspaceId,
build_id: nextBuildId,
updated_at: nextUpdatedAt,
has_unread: nextHasUnread,
};
const result = applyFields(c);
if (result !== c) didUpdate = true;
return result;
});
return didUpdate ? nextChats : chats;
});
// Also update inside parent's children array
// in case the event targets a child chat.
updateChildInParentCache(queryClient, applyFields, updatedChat.id);
}
queryClient.setQueryData<TypesGen.Chat | undefined>(
chatKey(updatedChat.id),
@@ -143,6 +143,7 @@ const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
has_unread: false,
client_type: "ui",
last_error: null,
children: [],
...overrides,
});
@@ -219,6 +219,7 @@ const makeChat = (chatID: string): TypesGen.Chat => ({
has_unread: false,
client_type: "ui",
last_error: null,
children: [],
});
const makeMessage = (
@@ -63,6 +63,7 @@ export const WithParentChat: Story = {
pin_order: 0,
has_unread: false,
client_type: "ui",
children: [],
},
},
};
@@ -54,6 +54,7 @@ const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
has_unread: false,
client_type: "ui",
last_error: null,
children: [],
...overrides,
});
@@ -110,13 +111,18 @@ type Story = StoryObj<typeof AgentsSidebar>;
export const RunningDelegatedChat: Story = {
args: {
chats: [
buildChat({ id: "root-1", title: "Root agent" }),
buildChat({
id: "child-running",
title: "Running child",
status: "running",
parent_chat_id: "root-1",
root_chat_id: "root-1",
id: "root-1",
title: "Root agent",
children: [
buildChat({
id: "child-running",
title: "Running child",
status: "running",
parent_chat_id: "root-1",
root_chat_id: "root-1",
}),
],
}),
],
},
@@ -140,13 +146,18 @@ export const RunningDelegatedChat: Story = {
export const PendingDelegatedChat: Story = {
args: {
chats: [
buildChat({ id: "root-pending", title: "Root agent" }),
buildChat({
id: "child-pending",
title: "Pending child",
status: "pending",
parent_chat_id: "root-pending",
root_chat_id: "root-pending",
id: "root-pending",
title: "Root agent",
children: [
buildChat({
id: "child-pending",
title: "Pending child",
status: "pending",
parent_chat_id: "root-pending",
root_chat_id: "root-pending",
}),
],
}),
],
},
@@ -170,12 +181,17 @@ export const PendingDelegatedChat: Story = {
export const ExpandCollapse: Story = {
args: {
chats: [
buildChat({ id: "root-2", title: "Root for collapse" }),
buildChat({
id: "child-collapse",
title: "Nested child",
parent_chat_id: "root-2",
root_chat_id: "root-2",
id: "root-2",
title: "Root for collapse",
children: [
buildChat({
id: "child-collapse",
title: "Nested child",
parent_chat_id: "root-2",
root_chat_id: "root-2",
}),
],
}),
],
},
@@ -212,12 +228,14 @@ export const RunningChatPreservesSpinner: Story = {
id: "root-running",
title: "Running root agent",
status: "running",
}),
buildChat({
id: "child-of-running",
title: "Child of running",
parent_chat_id: "root-running",
root_chat_id: "root-running",
children: [
buildChat({
id: "child-of-running",
title: "Child of running",
parent_chat_id: "root-running",
root_chat_id: "root-running",
}),
],
}),
],
},
@@ -257,13 +275,15 @@ export const IdleParentWithRunningChild: Story = {
id: "idle-parent",
title: "Idle parent agent",
status: "waiting",
}),
buildChat({
id: "running-child",
title: "Running sub-agent",
status: "running",
parent_chat_id: "idle-parent",
root_chat_id: "idle-parent",
children: [
buildChat({
id: "running-child",
title: "Running sub-agent",
status: "running",
parent_chat_id: "idle-parent",
root_chat_id: "idle-parent",
}),
],
}),
],
},
@@ -289,6 +309,10 @@ export const IdleParentWithRunningChild: Story = {
};
export const ActiveChatAncestryExpanded: Story = {
// This story uses the flat-sibling shape (parent_chat_id on each
// entry) rather than embedded children. It intentionally
// exercises the defensive fallback loop in buildChatTree for
// stale cache data from before root-only pagination landed.
args: {
chats: [
buildChat({ id: "root-active", title: "Active root" }),
@@ -332,6 +356,57 @@ export const ActiveChatAncestryExpanded: Story = {
},
};
export const MixedCacheDoesNotDuplicateChild: Story = {
// Simulates the rollout window where a stale cache entry for a child
// chat still appears as a flat sibling in the paginated list while
// the same child is also embedded under its parent. Without the
// guard in buildChatTree (`if (!parentById.has(chat.id))` around
// setting the parent link to undefined), the flat entry would
// overwrite the embedded parent link and the defensive fallback
// would re-add the child to its parent's children list, producing
// a React duplicate-key warning and double-render.
args: {
chats: [
buildChat({
id: "mixed-root",
title: "Mixed root",
children: [
buildChat({
id: "mixed-child",
title: "Mixed child",
parent_chat_id: "mixed-root",
root_chat_id: "mixed-root",
}),
],
}),
// Stale flat entry for the same child still present in the
// cache. It must not cause a duplicate render.
buildChat({
id: "mixed-child",
title: "Mixed child",
parent_chat_id: "mixed-root",
root_chat_id: "mixed-root",
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: {
path: "/agents/mixed-child",
pathParams: { agentId: "mixed-child" },
},
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Mixed root")).toBeInTheDocument();
await waitFor(() => {
expect(canvas.getAllByText("Mixed child")).toHaveLength(1);
});
},
};
// Use a fixed offset so the value always falls in the "Today" bucket
// without embedding a literal date that drifts across calendar days.
const recentTimestamp = new Date(Date.now() - 60_000).toISOString();
@@ -67,6 +67,7 @@ const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
last_error: null,
mcp_server_ids: [],
labels: {},
children: [],
...overrides,
});
@@ -157,6 +157,7 @@ const statusConfig = {
type ChatTree = {
readonly rootIds: readonly string[];
readonly chatById: ReadonlyMap<string, Chat>;
readonly childrenById: ReadonlyMap<string, readonly string[]>;
readonly parentById: ReadonlyMap<string, string | undefined>;
};
@@ -263,45 +264,54 @@ const getParentChatID = (chat: Chat): string | undefined => {
return asNonEmptyString(chat.parent_chat_id);
};
const getRootChatID = (chat: Chat): string | undefined => {
return asNonEmptyString(chat.root_chat_id);
};
const buildChatTree = (chats: readonly Chat[]): ChatTree => {
const orderById = new Map<string, number>();
const chatById = new Map<string, Chat>();
const parentById = new Map<string, string | undefined>();
const childrenById = new Map<string, string[]>();
for (const [index, chat] of chats.entries()) {
orderById.set(chat.id, index);
// The paginated list now contains only root chats. Children
// are embedded in each root's `children` field.
for (const chat of chats) {
chatById.set(chat.id, chat);
childrenById.set(chat.id, []);
}
for (const chat of chats) {
let parentID = getParentChatID(chat);
if (!parentID || parentID === chat.id || !chatById.has(parentID)) {
parentID = undefined;
// Guard against stale cache entries: if a flat child
// entry appears in `chats` after its embedded parent has
// already set its parent link, do not overwrite the link
// with `undefined`. Without this, the defensive fallback
// below re-adds the child to its parent's list, producing
// a duplicate key in React rendering.
if (!parentById.has(chat.id)) {
parentById.set(chat.id, undefined);
}
if (!parentID) {
const rootID = getRootChatID(chat);
if (rootID && rootID !== chat.id && chatById.has(rootID)) {
parentID = rootID;
if (chat.children) {
for (const child of chat.children) {
chatById.set(child.id, child);
parentById.set(child.id, chat.id);
childrenById.get(chat.id)?.push(child.id);
// Children cannot have their own children (depth
// capped at 1), but initialize the map entry for
// uniform lookup.
childrenById.set(child.id, []);
}
}
parentById.set(chat.id, parentID);
if (parentID) {
childrenById.get(parentID)?.push(chat.id);
}
}
for (const children of childrenById.values()) {
children.sort((leftID, rightID) => {
return (orderById.get(leftID) ?? 0) - (orderById.get(rightID) ?? 0);
});
// Defensive fallback for cached data during rollout: if any
// chat has a parent_chat_id that points to a chat in the list
// but was not embedded, build the link. This handles stale
// cache entries from before the backend change.
for (const chat of chats) {
const parentID = getParentChatID(chat);
if (
parentID &&
parentID !== chat.id &&
chatById.has(parentID) &&
!parentById.get(chat.id)
) {
parentById.set(chat.id, parentID);
childrenById.get(parentID)?.push(chat.id);
}
}
const rootIds = chats
@@ -310,6 +320,7 @@ const buildChatTree = (chats: readonly Chat[]): ChatTree => {
return {
rootIds,
chatById,
childrenById,
parentById,
};
@@ -325,10 +336,17 @@ const collectVisibleChatIDs = ({
readonly tree: ChatTree;
}): Set<string> => {
if (!search) {
return new Set(chats.map((chat) => chat.id));
const allIDs = new Set(chats.map((chat) => chat.id));
for (const chat of chats) {
for (const child of chat.children ?? []) {
allIDs.add(child.id);
}
}
return allIDs;
}
const matchedChatIDs = chats
const allChats = chats.flatMap((chat) => [chat, ...(chat.children ?? [])]);
const matchedChatIDs = allChats
.filter((chat) => chat.title.toLowerCase().includes(search))
.map((chat) => chat.id);
if (matchedChatIDs.length === 0) {
@@ -780,7 +798,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
const [expandedById, setExpandedById] = useState<Record<string, boolean>>({});
const chatTree = buildChatTree(chats);
const chatById = new Map(chats.map((chat) => [chat.id, chat] as const));
const chatById = chatTree.chatById;
const visibleChatIDs = collectVisibleChatIDs({
chats,
search: normalizedSearch,