diff --git a/apps/web/e2e/unterhaltungen-sidebar.spec.ts b/apps/web/e2e/unterhaltungen-sidebar.spec.ts index ec03c7f..b3a6713 100644 --- a/apps/web/e2e/unterhaltungen-sidebar.spec.ts +++ b/apps/web/e2e/unterhaltungen-sidebar.spec.ts @@ -39,6 +39,25 @@ test("conversations and projects stay separate; + opens bot picker", async ({ pa await page.waitForURL(/\/app/); await expect(projekte.getByText("Side project", { exact: true })).toBeVisible(); await expect(unterhaltungenOnly(page).getByText("Side project", { exact: true })).toHaveCount(0); + + // The main agent leads the picker, even after other bots exist. + await unterhaltungen.getByTestId("conversation-picker-trigger").click(); + await expect( + page + .getByTestId("conversation-bot-picker") + .locator("[data-testid^='conversation-picker-bot-']") + .first(), + ).toHaveText("Chief"); + await page.keyboard.press("Escape"); + + // Creating a project that already exists opens it instead of listing it twice. + await projekte.getByTestId("create-project-trigger").click(); + const again = page.getByRole("dialog", { name: "New project" }); + await again.getByLabel("Name").fill("side project"); + await again.getByRole("button", { name: "Create project", exact: true }).click(); + await expect(again).toBeHidden(); + await expect(projekte.getByText("Side project", { exact: true })).toHaveCount(1); + await expect(projekte.getByText("side project", { exact: true })).toHaveCount(0); }); test("chat header shows avatar once and composer uses generic placeholder", async ({ page }) => { diff --git a/apps/web/src/pages/shell/bot-picker.tsx b/apps/web/src/pages/shell/bot-picker.tsx index 925f7a7..f227b13 100644 --- a/apps/web/src/pages/shell/bot-picker.tsx +++ b/apps/web/src/pages/shell/bot-picker.tsx @@ -26,9 +26,15 @@ export function ConversationBotPicker({ const [query, setQuery] = useState(""); const needle = query.trim().toLowerCase(); const matched = useMemo(() => { - if (!needle) return bots; - return bots.filter( - (bot) => bot.name.toLowerCase().includes(needle) || bot.title.toLowerCase().includes(needle), + const found = needle + ? bots.filter( + (bot) => + bot.name.toLowerCase().includes(needle) || bot.title.toLowerCase().includes(needle), + ) + : bots; + // The main agent is the usual person to talk to, so it leads the list. + return [...found].sort( + (left, right) => Number(right.kind === "main") - Number(left.kind === "main"), ); }, [bots, needle]); const createLabel = t`Create new Bot`; diff --git a/packages/db/src/main-agent.postgres.test.ts b/packages/db/src/main-agent.postgres.test.ts index 74fb82d..7ab9b65 100644 --- a/packages/db/src/main-agent.postgres.test.ts +++ b/packages/db/src/main-agent.postgres.test.ts @@ -80,6 +80,28 @@ describePostgres("main agent and main conversation (PostgreSQL)", () => { expect(await findMainBotId(prisma, userId)).toBe(bots[0]?.id); }); + it("opens an existing project instead of creating a second one with the same name", async () => { + const { userId, homeSpaceId } = await newUser(); + const first = await createSpaceForMember(prisma, { + currentSpaceId: homeSpaceId, + userId, + name: "evOo", + }); + const again = await createSpaceForMember(prisma, { + currentSpaceId: homeSpaceId, + userId, + name: " EVOO ", + }); + expect(again).toEqual(first); + const home = await prisma.space.findUniqueOrThrow({ where: { id: homeSpaceId } }); + const named = await createSpaceForMember(prisma, { + currentSpaceId: homeSpaceId, + userId, + name: home.name, + }); + expect(named.id).not.toBe(homeSpaceId); + }); + it("gives every project its own main bot and main conversation next to the home one", async () => { const { userId, homeSpaceId } = await newUser(); const home = await ensureMainConversation(prisma, userId); diff --git a/packages/db/src/spaces.ts b/packages/db/src/spaces.ts index e1c4ac0..32f039b 100644 --- a/packages/db/src/spaces.ts +++ b/packages/db/src/spaces.ts @@ -202,7 +202,7 @@ export async function createSpaceForMember( const spaceMembershipId = randomUUID(); const createdAt = new Date(); - await withTransactionRetry(() => + const space = await withTransactionRetry(() => prisma.$transaction( async (tx) => { const currentMembership = await tx.spaceMember.findUnique({ @@ -215,6 +215,19 @@ export async function createSpaceForMember( select: { organizationId: true }, }); if (!currentMembership) throw new IsolationError(); + // Asking for a project that already exists opens it instead of adding a second one. + const existing = await tx.space.findFirst({ + where: { + organizationId: currentMembership.organizationId, + name: { equals: name, mode: "insensitive" }, + isDefault: false, + deletingAt: null, + memberships: { some: { userId: input.userId } }, + }, + select: { id: true, name: true }, + orderBy: { createdAt: "asc" }, + }); + if (existing) return existing; const count = await tx.spaceMember.count({ where: { userId: input.userId, @@ -241,12 +254,13 @@ export async function createSpaceForMember( userId: input.userId, createdAt, }); + return { id: spaceId, name }; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ), ); - return { id: spaceId, name }; + return space; } type EmptySpaceDeleteInput = {