Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions apps/web/src/shell/sidebar-rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,64 @@ describe("buildSidebarRows", () => {
expect(rows).toEqual([{ kind: "workbench", workbench: freshDm }]);
});

test("every workbench deliberately created against the same agent definition keeps its own row (CL-6459)", () => {
const created = ["ch_new_1", "ch_new_2", "ch_new_3"].map((id, index) =>
workbench({
id,
kind: "chat",
title: "New Workbench",
definitionId: "wfd_myra",
participants: [{ address: "myra@acme.localhost", handle: "myra" }],
lastActivityAt: `2026-01-0${index + 1}T00:00:00.000Z`,
}),
);

const rows = buildSidebarRows([], created);

expect(rows.map((row) => row.workbench.id)).toEqual([
"ch_new_3",
"ch_new_2",
"ch_new_1",
]);
});

test("a stale cross-tenant sibling drops without taking the live definition's deliberate workbenches with it", () => {
const staleAncestorDm = workbench({
id: "ch_myra_ancestor",
kind: "chat",
title: "Myra",
definitionId: "wfd_myra_ancestor",
participants: [{ address: "myra@acme.localhost", handle: "myra" }],
lastActivityAt: "2026-01-01T00:00:00.000Z",
});
const homeDm = workbench({
id: "ch_myra_home",
kind: "chat",
title: "Myra",
definitionId: "wfd_myra_leaf",
participants: [{ address: "myra@acme.localhost", handle: "myra" }],
lastActivityAt: "2026-01-04T00:00:00.000Z",
});
const createdWorkbench = workbench({
id: "ch_myra_new",
kind: "chat",
title: "New Workbench",
definitionId: "wfd_myra_leaf",
participants: [{ address: "myra@acme.localhost", handle: "myra" }],
lastActivityAt: "2026-01-05T00:00:00.000Z",
});

const rows = buildSidebarRows(
[],
[staleAncestorDm, homeDm, createdWorkbench],
);

expect(rows.map((row) => row.workbench.id)).toEqual([
"ch_myra_new",
"ch_myra_home",
]);
});

test("two distinct agents whose slugs humanize to the same title never collapse into one row (CL-6413)", () => {
const researchAnalystHyphen = workbench({
id: "ch_research_analyst_hyphen",
Expand Down
70 changes: 42 additions & 28 deletions apps/web/src/shell/sidebar-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,49 +31,63 @@ function agentIdentityKey(chat: Workbench): string {
return agentParticipant?.handle ?? chat.title;
}

type AgentChat = Workbench & { readonly definitionId: string };

/** A group workbench never carries a `definitionId`; every agent DM does. */
function isAgentChat(chat: Workbench): chat is AgentChat {
return chat.definitionId !== null && chat.definitionId !== undefined;
}

function activityOf(chat: Workbench): number {
return chat.lastActivityAt ? Date.parse(chat.lastActivityAt) : 0;
}

/**
* Collapse agent-DM chats down to one row per agent identity (CL-6271).
* Drop agent-DM chats minted against a superseded sibling definition of
* the same agent (CL-6271), keeping every chat that belongs to the
* live one (CL-6459).
*
* Shadowing already picks a single nearest definition per name in
* `listVisibleAgentDefinitions`, but a DM chat is minted against a
* specific definition id, so a caller who has separately DM'd the same
* named agent (e.g. "Myra") launched from more than one ancestor tenant
* ends up with one durable `Workbench` row per instance — the dedupe
* there never runs again once a chat exists. Group every agent-DM chat
* (identified by `definitionId` being set — a group workbench never
* carries one) by `agentIdentityKey` and keep only the most recently
* active row per group; the rest are stale siblings of an identity the
* caller only ever needs to see once.
* there never runs again once a chat exists.
*
* `definitionId` is the discriminator that separates those stale
* cross-tenant siblings from workbenches a person deliberately created:
* "+ New Workbench" always mints against the bench's own currently
* resolved definition (`instant-agent-create.ts`), so N deliberate
* creations share one definition id and each keeps its row, while an
* ancestor tenant's leftover DM carries a different one. Per agent
* identity, the most recently active chat names the live definition;
* chats under any other definition are the stale siblings.
*/
function dedupeAgentChatsByTitle(chats: readonly Workbench[]): Workbench[] {
const groupChats = chats.filter(
(chat) => chat.definitionId === null || chat.definitionId === undefined,
);
const agentChats = chats.filter(
(chat) => chat.definitionId !== null && chat.definitionId !== undefined,
);
function dropSupersededAgentChats(chats: readonly Workbench[]): Workbench[] {
const groupChats = chats.filter((chat) => !isAgentChat(chat));
const agentChats = chats.filter(isAgentChat);

const newestByIdentity = new Map<string, Workbench>();
const liveDefinitionByIdentity = new Map<string, AgentChat>();
for (const chat of agentChats) {
const key = agentIdentityKey(chat);
const current = newestByIdentity.get(key);
const chatActivity = chat.lastActivityAt
? Date.parse(chat.lastActivityAt)
: 0;
const currentActivity = current?.lastActivityAt
? Date.parse(current.lastActivityAt)
: 0;
if (current === undefined || chatActivity > currentActivity) {
newestByIdentity.set(key, chat);
const current = liveDefinitionByIdentity.get(key);
if (current === undefined || activityOf(chat) > activityOf(current)) {
liveDefinitionByIdentity.set(key, chat);
}
}

return [...groupChats, ...newestByIdentity.values()];
return [
...groupChats,
...agentChats.filter(
(chat) =>
chat.definitionId ===
liveDefinitionByIdentity.get(agentIdentityKey(chat))?.definitionId,
),
];
}

function recencyOf(row: SidebarRow): number {
return row.workbench.lastActivityAt
? Date.parse(row.workbench.lastActivityAt)
: 0;
return activityOf(row.workbench);
}

function isPinned(row: SidebarRow): boolean {
Expand All @@ -90,7 +104,7 @@ export function buildSidebarRows(
workbenches: readonly Workbench[],
chats: readonly Workbench[],
): readonly SidebarRow[] {
const dedupedChats = dedupeAgentChatsByTitle(chats);
const dedupedChats = dropSupersededAgentChats(chats);
const rows: SidebarRow[] = [
...workbenches.map(
(workbench) => ({ kind: "workbench", workbench }) as const,
Expand Down
Loading