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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user