From 23084b460b2ceb0348c7144617a8c66ae2ec296a Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:54:06 +0200 Subject: [PATCH] fix(web): + Starten for empty projects instead of fallback bot (#92) * fix(web): show Starten for empty projects instead of fallback bot When a project space had no group conversations, the sidebar fell back to listing 1:1 bots (e.g. Chief from onboarding) under the project name. Bootstrap also redirected /app to the first bot, making it look like an existing conversation. Only show project group chats in Projekte, render + Starten for empty projects, and open the existing create flow (group or bot) on click. Co-authored-by: cynarAI * fix(web): keep empty-project Starten view and route flows correctly Rebased onto main. Guard the Hauptthread auto-redirect when the current space is an empty project so the main Starten CTA stays visible. Split project start into create-bot vs create-group flows (main vs sidebar) and feed home-space bots into the group form when starting from a project. Co-authored-by: cynarAI * fix(web): surface empty-project Starten on Projekte pillar Route bare /app in empty projects to the Projekte view, keep /app on stale bot/group URLs, and show the create-group side panel without an active thread. Co-authored-by: cynarAI * fix(e2e): scope RPC to selected space and tighten Starten specs Pass x-rakazo-space-id from Playwright RPC helpers so archive/list hit the active project space. Trim the group-create Starten test to the side panel flow and navigate to /app after archiving the last bot. Co-authored-by: cynarAI * style(e2e): format empty-project-starten spec Co-authored-by: cynarAI * fix(e2e): remove project bots so empty spaces stay deletable Archived bots still count as space content and hide the delete-space menu. Use bots/remove in spaces.spec when the test needs a deletable empty project space. Co-authored-by: cynarAI --------- Co-authored-by: Cursor Agent Co-authored-by: cynarAI --- apps/web/e2e/empty-project-starten.spec.ts | 73 +++++++++ apps/web/e2e/helpers.ts | 6 +- apps/web/e2e/spaces.spec.ts | 47 +++--- apps/web/src/lib/project-empty-state.test.ts | 50 ++++++ apps/web/src/lib/project-empty-state.ts | 26 ++++ apps/web/src/pages/Shell.tsx | 154 ++++++++++++++++--- 6 files changed, 306 insertions(+), 50 deletions(-) create mode 100644 apps/web/e2e/empty-project-starten.spec.ts create mode 100644 apps/web/src/lib/project-empty-state.test.ts create mode 100644 apps/web/src/lib/project-empty-state.ts diff --git a/apps/web/e2e/empty-project-starten.spec.ts b/apps/web/e2e/empty-project-starten.spec.ts new file mode 100644 index 0000000..3e5d102 --- /dev/null +++ b/apps/web/e2e/empty-project-starten.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from "@playwright/test"; +import { completeOnboarding, rpc, signup, unterhaltungenOnly } from "./helpers"; + +test("empty project shows Starten instead of a fallback bot", async ({ page }) => { + const stamp = Date.now(); + await signup(page, `empty-project-${stamp}@rakazo.test`, "password12", "Empty Project"); + await completeOnboarding(page); + await page.goto("/app"); + await page.waitForURL(/\/app\/[^/]+$/); + + const projekte = page.locator(".sidebar-projekte"); + await projekte.getByTestId("create-project-trigger").click(); + const dialog = page.getByRole("dialog", { name: "New project" }); + await dialog.getByLabel("Name").fill("Launch"); + await dialog.getByRole("button", { name: "Create project", exact: true }).click(); + await page.waitForURL(/\/app(?:\/)?$/); + + await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0); + await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible(); + await expect(page.getByTestId("project-start-main")).toBeVisible(); + + await page.getByTestId("project-start-main").click(); + await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create"); +}); + +test("Starten in an onboarded project opens the group create flow", async ({ page }) => { + const stamp = Date.now(); + await signup(page, `project-start-${stamp}@rakazo.test`, "password12", "Project Start"); + await completeOnboarding(page); + + const projekte = page.locator(".sidebar-projekte"); + await projekte.getByTestId("create-project-trigger").click(); + const dialog = page.getByRole("dialog", { name: "New project" }); + await dialog.getByLabel("Name").fill("Website"); + await dialog.getByRole("button", { name: "Create project", exact: true }).click(); + await page.waitForURL(/\/app(?:\/)?$/); + await page.goto("/onboarding"); + await completeOnboarding(page); + + await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); + await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0); + + await projekte.getByRole("button", { name: "Starten", exact: true }).click(); + await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create-group"); + await expect(page.getByText("New group", { exact: true })).toBeVisible(); + await expect( + page.getByTestId("side-panel").getByRole("button", { name: /^Chief/ }), + ).toBeVisible(); +}); + +test("archiving the only bot keeps Starten visible in the project sidebar", async ({ page }) => { + const stamp = Date.now(); + await signup(page, `project-archive-${stamp}@rakazo.test`, "password12", "Project Archive"); + await completeOnboarding(page); + + const projekte = page.locator(".sidebar-projekte"); + await projekte.getByTestId("create-project-trigger").click(); + const dialog = page.getByRole("dialog", { name: "New project" }); + await dialog.getByLabel("Name").fill("Side"); + await dialog.getByRole("button", { name: "Create project", exact: true }).click(); + await page.waitForURL(/\/app(?:\/)?$/); + await page.goto("/onboarding"); + await completeOnboarding(page); + + const [chief] = await rpc>(page, "bots/list", {}); + expect(chief?.name).toMatch(/^Chief/); + await rpc(page, "bots/archive", { botId: chief!.id }); + + await page.goto("/app"); + await page.waitForURL(/\/app(?:\/)?$/); + await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible(); + await expect(page.getByTestId("project-start-main")).toBeVisible(); +}); diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index 1e0bd68..31a5c9b 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -23,7 +23,11 @@ export function activeBotId(page: Page) { } export async function rpc(page: Page, procedure: string, body: unknown): Promise { - const response = await page.request.post(`/rpc/${procedure}`, { data: { json: body } }); + const spaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id")); + const response = await page.request.post(`/rpc/${procedure}`, { + data: { json: body }, + headers: spaceId ? { "x-rakazo-space-id": spaceId } : undefined, + }); const parsed = (await response.json()) as { json?: T; error?: { message?: string } }; if (!response.ok() || parsed.error) { throw new Error(`${procedure} ${response.status()}: ${parsed.error?.message ?? "failed"}`); diff --git a/apps/web/e2e/spaces.spec.ts b/apps/web/e2e/spaces.spec.ts index c30855a..d2746f3 100644 --- a/apps/web/e2e/spaces.spec.ts +++ b/apps/web/e2e/spaces.spec.ts @@ -41,7 +41,8 @@ test("Projekte + opens new project dialog instead of group form", async ({ page await expect(unterhaltungenOnly(page).getByText("Customer project", { exact: true })).toHaveCount( 0, ); - await expect(page.locator("main").getByRole("button", { name: "Create an Oo" })).toBeVisible(); + await expect(page.getByTestId("project-start-main")).toBeVisible(); + await expect(page.getByTestId("project-start-main")).toHaveText(/Starten/); }); test("spaces stay invisible by default and chat creation requires approval", async ({ @@ -93,16 +94,18 @@ test("spaces stay invisible by default and chat creation requires approval", asy const supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group"); const supportSpaceId = supportSpaceGroup?.match(/^project-space:([^:]+)/)?.[1]; expect(supportSpaceId).toBeTruthy(); - await supportSpace.getByRole("button", { name: "Open Customer support" }).click(); - await page.waitForURL(/\/onboarding/); + await supportSpace.getByRole("button", { name: "Select Customer support" }).click(); + await page.waitForURL(/\/app(?:\/)?$/); await expect .poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))) .toBe(supportSpaceId); + await page.goto("/onboarding"); await completeOnboarding(page); await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible(); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); - await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(1); + await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0); + await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible(); await captureScreenshot(page, testInfo, "spaces-sidebar"); await page.evaluate((id) => { @@ -136,21 +139,19 @@ test("a new space auto-completes onboarding and can be deleted from its menu", a const projekte = page.locator(".sidebar-projekte"); await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible(); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); - await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(1); + await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0); + await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible(); await captureScreenshot(page, testInfo, "new-space-after-onboarding"); // Spaces can only be deleted when empty — remove auto-created Chief first. - const temporaryChief = projekte.getByRole("button", { name: /^Chief/ }); - await temporaryChief.click({ button: "right" }); - await page.getByRole("menuitem", { name: "Delete" }).click(); - const deleteBotDialog = page.getByRole("alertdialog", { name: /Delete Chief/ }); - await expect(deleteBotDialog).toBeVisible(); - await deleteBotDialog.getByRole("button", { name: "Delete", exact: true }).click(); - await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0); - await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); + const [temporaryChief] = await rpc>(page, "bots/list", {}); + expect(temporaryChief).toBeTruthy(); + await rpc(page, "bots/remove", { botId: temporaryChief!.id, deleteMemories: false }); + await page.goto("/app"); + await page.waitForURL(/\/app(?:\/)?$/); // The emptied space stays selectable instead of trapping the user. - await expect(projekte.getByRole("button", { name: "Open Temporary" }).first()).toBeVisible(); + await expect(projekte.getByRole("button", { name: "Select Temporary" }).first()).toBeVisible(); // Deletion is discoverable through the visible Space actions menu. await projectSpace(projekte, "Temporary") @@ -196,20 +197,14 @@ test("deleting the last bot in a space stays in the app after first use", async // Delete the only bot in the new space: the app must stay put instead of // forcing onboarding again. - const sideBot = projekte.getByRole("button", { name: /^Chief/ }); - await sideBot.click({ button: "right" }); - await page.getByRole("menuitem", { name: "Delete" }).click(); - const deleteDialog = page.getByRole("alertdialog", { name: /Delete Chief/ }); - await expect(deleteDialog).toBeVisible(); - await deleteDialog.getByRole("button", { name: "Delete", exact: true }).click(); - await expect(sideBot).toHaveCount(0); + const [sideChief] = await rpc>(page, "bots/list", {}); + expect(sideChief).toBeTruthy(); + await rpc(page, "bots/remove", { botId: sideChief!.id, deleteMemories: false }); await expect(page).not.toHaveURL(/\/onboarding/); - await page.reload(); + await page.goto("/app"); await expect(page).not.toHaveURL(/\/onboarding/); - await expect(projekte.getByRole("button", { name: "Open Side" }).first()).toBeVisible(); - await expect( - page.locator("main").getByRole("button", { name: "Create new Bot", exact: true }), - ).toBeVisible(); + await expect(projekte.getByRole("button", { name: "Select Side" }).first()).toBeVisible(); + await expect(page.getByTestId("project-start-main")).toBeVisible(); await projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click(); await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible(); diff --git a/apps/web/src/lib/project-empty-state.test.ts b/apps/web/src/lib/project-empty-state.test.ts new file mode 100644 index 0000000..c079987 --- /dev/null +++ b/apps/web/src/lib/project-empty-state.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + isEmptyProjectSpace, + isHauptthreadGroup, + projectGroupChatsInSpace, +} from "./project-empty-state.js"; + +describe("isHauptthreadGroup", () => { + it("recognizes Hauptthread and Team Chat", () => { + expect(isHauptthreadGroup({ id: "1", name: "Hauptthread" })).toBe(true); + expect(isHauptthreadGroup({ id: "2", name: "Team Chat" })).toBe(true); + expect(isHauptthreadGroup({ id: "3", name: "Launch" })).toBe(false); + }); +}); + +describe("projectGroupChatsInSpace", () => { + it("returns project groups in a space without Hauptthread", () => { + const groups = [ + { id: "g1", name: "Launch", spaceId: "space-a" }, + { id: "g2", name: "Hauptthread", spaceId: "space-a" }, + { id: "g3", name: "Ops", spaceId: "space-b" }, + ]; + expect(projectGroupChatsInSpace(groups, "space-a").map((group) => group.id)).toEqual(["g1"]); + }); +}); + +describe("isEmptyProjectSpace", () => { + const spaces = [ + { id: "home", isDefault: true }, + { id: "project", isDefault: false }, + ]; + + it("is false for the home space", () => { + expect(isEmptyProjectSpace("home", spaces, [], "home")).toBe(false); + }); + + it("is true for a project space without group chats", () => { + expect(isEmptyProjectSpace("project", spaces, [], "home")).toBe(true); + }); + + it("is false when the project space already has a group chat", () => { + const groups = [{ id: "g1", name: "Launch", spaceId: "project" }]; + expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(false); + }); + + it("ignores Hauptthread when deciding emptiness", () => { + const groups = [{ id: "g1", name: "Hauptthread", spaceId: "project" }]; + expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(true); + }); +}); diff --git a/apps/web/src/lib/project-empty-state.ts b/apps/web/src/lib/project-empty-state.ts new file mode 100644 index 0000000..3bd8132 --- /dev/null +++ b/apps/web/src/lib/project-empty-state.ts @@ -0,0 +1,26 @@ +import type { Group, Space } from "@rakazo/contracts"; + +export function isHauptthreadGroup(group: Pick): boolean { + return group.name === "Hauptthread" || group.name === "Team Chat"; +} + +/** Project-scoped group chats in a space (excludes Hauptthread). */ +export function projectGroupChatsInSpace( + groups: readonly Pick[], + spaceId: string, +): readonly Pick[] { + return groups.filter((group) => group.spaceId === spaceId && !isHauptthreadGroup(group)); +} + +/** Non-default project space with no project group conversations yet. */ +export function isEmptyProjectSpace( + spaceId: string | null | undefined, + spaces: readonly Pick[], + groups: readonly Pick[], + homeSpaceId: string | undefined, +): boolean { + if (!spaceId || !homeSpaceId || spaceId === homeSpaceId) return false; + const space = spaces.find((item) => item.id === spaceId); + if (!space || space.isDefault) return false; + return projectGroupChatsInSpace(groups, spaceId).length === 0; +} diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index bc6f6ab..ee2d117 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -214,6 +214,7 @@ import { revokePendingAttachmentPreviews, } from "../lib/pending-attachments"; import { markAfterPaint, markOnce } from "../lib/performance"; +import { isEmptyProjectSpace } from "../lib/project-empty-state"; import { readProjectSidebarOrder, writeProjectSidebarOrder } from "../lib/project-sidebar-order"; import { quoteDraftForSelection } from "../lib/quote-selection"; import { clearSpaceSelection, rpc, selectedSpaceId, selectSpace } from "../lib/rpc"; @@ -851,7 +852,15 @@ export function ShellPage() { const inGroup = Boolean(groupId); const projectSessionKey = searchParams.get("s"); const voiceEntry = searchParams.get("voice") === "1"; - const active = inGroup ? undefined : (bots.find((b) => b.id === botId) ?? bots[0]); + const currentSpaceId = selectedSpaceId() ?? bootstrapMe?.spaceId; + const homeSpaceId = spaces.find((space) => space.isDefault)?.id ?? bootstrapMe?.spaceId; + const emptyProjectSpace = useMemo( + () => isEmptyProjectSpace(currentSpaceId, spaces, groups, homeSpaceId), + [currentSpaceId, groups, homeSpaceId, spaces], + ); + const active = inGroup + ? undefined + : (bots.find((b) => b.id === botId) ?? (emptyProjectSpace && !botId ? undefined : bots[0])); const activeGroup = groups.find((group) => group.id === groupId); const activeProjectSection = activeGroup?.sectionId ? botSections.find((section) => section.id === activeGroup.sectionId) @@ -1039,13 +1048,28 @@ export function ShellPage() { return; } const currentGroupId = routeGroupId.current; + const currentBotId = routeBotId.current; + const refreshHomeSpaceId = + navigation.spaces.find((space) => space.isDefault)?.id ?? selectedSpaceId() ?? undefined; + const refreshSpaceId = selectedSpaceId() ?? refreshHomeSpaceId; + const emptyProject = isEmptyProjectSpace( + refreshSpaceId, + navigation.spaces, + groupList, + refreshHomeSpaceId, + ); + if (emptyProject) { + if (currentGroupId || (currentBotId && !list.some((bot) => bot.id === currentBotId))) { + navigate("/app", { replace: true }); + } + return; + } if (currentGroupId) { if (!groupList.some((group) => group.id === currentGroupId)) { navigate(firstThreadRoute(list, groupList), { replace: true }); } return; } - const currentBotId = routeBotId.current; if (!currentBotId || !list.some((bot) => bot.id === currentBotId)) { navigate(firstThreadRoute(list, groupList), { replace: true }); } @@ -1268,6 +1292,21 @@ export function ShellPage() { navigate("/onboarding", { replace: true }); return; } + const bootstrapHomeSpaceId = + bootstrap.spaces.find((space) => space.isDefault)?.id ?? bootstrap.me.spaceId; + const bootstrapSpaceId = selectedSpaceId() ?? bootstrap.me.spaceId; + const emptyProject = isEmptyProjectSpace( + bootstrapSpaceId, + bootstrap.spaces, + groupList, + bootstrapHomeSpaceId, + ); + if (emptyProject) { + if (groupId || (botId && !bootstrap.bots.some((bot) => bot.id === botId))) { + navigate("/app", { replace: true }); + } + return; + } if (groupId) { if (!groupList.some((group) => group.id === groupId)) { navigate(firstThreadRoute(bootstrap.bots, groupList), { replace: true }); @@ -1663,8 +1702,6 @@ export function ShellPage() { [groups], ); - const homeSpaceId = spaces.find((space) => space.isDefault)?.id ?? bootstrapMe?.spaceId; - const homeSpaceBots = useMemo(() => { if (!homeSpaceId) return bots; const home = spaces.find((space) => space.id === homeSpaceId); @@ -1710,10 +1747,7 @@ export function ShellPage() { ) : [], ); - const botsInSpace = sidebarGroups.flatMap((group) => - group.spaceId === space.id ? group.bots.filter((item) => item.kind === "bot") : [], - ); - const projectBots = dedupeSidebarBots(groupsInSpace.length > 0 ? groupsInSpace : botsInSpace); + const projectBots = dedupeSidebarBots(groupsInSpace); entries.push({ key: projectBots.length > 0 ? `project-space:${space.id}` : `project-space:${space.id}:empty`, @@ -1806,6 +1840,14 @@ export function ShellPage() { [bootstrapMe?.spaceId, navigate], ); + const openProjectStart = useCallback( + (spaceId: string, flow: "create" | "group") => { + setActiveNavPillar("projekte"); + openSpaceChat(spaceId, `/app?project=start&flow=${flow}`); + }, + [openSpaceChat], + ); + const openConversationBot = useCallback( (botId: string) => { setConversationPickerOpen(false); @@ -2031,6 +2073,42 @@ export function ShellPage() { void jumpToMessage({ groupId, messageId: projectSessionKey }); }, [groupId, inGroup, projectSessionKey]); + useEffect(() => { + if (!botId && !groupId && emptyProjectSpace && initialBotsLoaded) { + setActiveNavPillar("projekte"); + } + }, [botId, emptyProjectSpace, groupId, initialBotsLoaded]); + + useEffect(() => { + if (searchParams.get("project") !== "start" || !emptyProjectSpace || !initialBotsLoaded) { + return; + } + setActiveNavPillar("projekte"); + const flow = searchParams.get("flow"); + setPanel( + flow === "create" + ? "create" + : flow === "group" && homeSpaceBots.length > 0 + ? "create-group" + : flow === "group" + ? "create" + : bots.length === 0 + ? "create" + : "create-group", + ); + const next = new URLSearchParams(searchParams); + next.delete("project"); + next.delete("flow"); + setSearchParams(next, { replace: true }); + }, [ + bots.length, + emptyProjectSpace, + homeSpaceBots.length, + initialBotsLoaded, + searchParams, + setSearchParams, + ]); + const activeSnapshot = inGroup ? snapshot?.groupId === groupId ? snapshot @@ -3213,7 +3291,7 @@ export function ShellPage() { ) : null; useEffect(() => { - if (!active && !activeGroup && initialBotsLoaded) { + if (!active && !activeGroup && initialBotsLoaded && !emptyProjectSpace) { const hauptthread = groups.find((g) => g.name === "Hauptthread" || g.name === "Team Chat"); if (hauptthread) { navigate(`/app/g/${hauptthread.id}`); @@ -3227,7 +3305,7 @@ export function ShellPage() { .catch(console.error); } } - }, [active, activeGroup, initialBotsLoaded, groups, bots, navigate]); + }, [active, activeGroup, emptyProjectSpace, initialBotsLoaded, groups, bots, navigate]); const userName = session.data?.user.name ?? t`You`; const initials = userName @@ -3842,13 +3920,7 @@ export function ShellPage() { className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg pl-8 py-1 text-ui-label font-semibold tracking-wider uppercase text-muted-foreground/60 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring" onClick={() => { if (group.emptySpaceId) { - const space = spaces.find( - (item) => item.id === group.emptySpaceId, - ); - openSpaceChat( - group.emptySpaceId, - space?.hasContent ? "/app" : "/onboarding", - ); + openSpaceChat(group.emptySpaceId, "/app"); return; } toggleSidebarSection(group.key); @@ -3888,7 +3960,7 @@ export function ShellPage() { aria-expanded={group.emptySpaceId ? undefined : !collapsed} aria-label={ group.emptySpaceId - ? t`Open ${group.title}` + ? t`Select ${group.title}` : collapsed ? t`Expand ${group.title}` : t`Collapse ${group.title}` @@ -3931,6 +4003,20 @@ export function ShellPage() { ) : null} ) : null} + {!collapsed && group.emptySpaceId ? ( +
+ +
+ ) : null} {!collapsed && group.bots.map((item) => ( + + ) : !active && !activeGroup && initialBotsLoaded ? (