feat(web): Unterhaltungen-IA, Bot-Picker und schlanker Chat-Header (#71)
- Sidebar: Unterhaltungen (1:1-Bots im Home-Space) und Projekte (Projekt-Spaces + Gruppen) getrennt; Projekt-Spaces erscheinen nicht mehr unter Team/Unterhaltungen - + in Unterhaltungen öffnet Bot-Picker (bestehenden Bot öffnen oder neuen Bot erstellen) - Chat-Header: Bot-Avatar wieder sichtbar, Status nur bei Aktivität, kein doppeltes „1:1 conversation“ - Composer: generisches „Nachricht…“ statt Name dreifach - OoStage: kein doppelter Bot-Name bei laufendem Verlauf Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
2f296f60a1
commit
0474875142
@ -48,7 +48,7 @@ test("logout protects bot deep links and sign-in restores the session", async ({
|
||||
|
||||
await page.waitForURL(/\/app\/[^/]+$/);
|
||||
const protectedBotPath = new URL(page.url()).pathname;
|
||||
await expect(page.getByPlaceholder("Message Chief")).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: new RegExp(userName, "i") }).click();
|
||||
await expect(page.getByRole("button", { name: "Settings", exact: true })).toBeVisible();
|
||||
|
||||
@ -33,7 +33,7 @@ test("bot creation, editing, and deletion persist", async ({ page }, testInfo) =
|
||||
await form.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await createAborted;
|
||||
// Failed create keeps the form open on the current bot chat.
|
||||
await expect(page.getByPlaceholder("Message Chief")).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create");
|
||||
await expect(page.getByTestId("create-bot-error")).toBeVisible();
|
||||
expect(createFailed).toBe(true);
|
||||
@ -51,7 +51,7 @@ test("bot creation, editing, and deletion persist", async ({ page }, testInfo) =
|
||||
await route.abort("failed");
|
||||
});
|
||||
await createBotFromPicker(page);
|
||||
await expect(page.getByPlaceholder("Message New Bot")).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
await expect.poll(() => failedPostCreateRefresh).toBe(true);
|
||||
await page.unroute("**/rpc/spaces/list");
|
||||
await expect(botList.getByRole("button", { name: /^New Bot/ })).toBeVisible();
|
||||
@ -82,7 +82,7 @@ test("bot creation, editing, and deletion persist", async ({ page }, testInfo) =
|
||||
await expect(studio).toBeHidden();
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(botList.getByRole("button", { name: /^Researcher/ })).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Message Researcher")).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
|
||||
await page.locator("main").getByRole("button", { name: "Researcher", exact: true }).click();
|
||||
await expect(nameInput).toHaveValue("Researcher");
|
||||
@ -136,12 +136,12 @@ test("bot creation, editing, and deletion persist", async ({ page }, testInfo) =
|
||||
await descriptionInput.fill("Builds durable, source-backed research briefs.");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(botList.getByRole("button", { name: /^Atlas/ })).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Message Atlas")).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
await captureScreenshot(page, testInfo, "28-edited-bot-profile");
|
||||
|
||||
await page.reload();
|
||||
await expect(botList.getByRole("button", { name: /^Atlas/ })).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Message Atlas")).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
await page.locator("main").getByRole("button", { name: "Atlas", exact: true }).click();
|
||||
await expect(nameInput).toHaveValue("Atlas");
|
||||
await expect(titleInput).toHaveValue("Research lead");
|
||||
@ -157,7 +157,7 @@ test("bot creation, editing, and deletion persist", async ({ page }, testInfo) =
|
||||
name: new RegExp(`^${longUnbrokenName}`),
|
||||
});
|
||||
await expect(longNameBot).toBeVisible();
|
||||
await expect(page.getByPlaceholder(`Message ${longUnbrokenName}`)).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
|
||||
await longNameBot.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
@ -176,6 +176,6 @@ test("bot creation, editing, and deletion persist", async ({ page }, testInfo) =
|
||||
await page.waitForURL((url) => url.pathname !== deletedBotPath);
|
||||
await expect(botList.getByText(longUnbrokenName, { exact: true })).toHaveCount(0);
|
||||
await expect(botList.getByRole("button", { name: /^Chief/ })).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Message Chief")).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
await captureScreenshot(page, testInfo, "31-deleted-bot-fallback");
|
||||
});
|
||||
|
||||
@ -368,7 +368,7 @@ test("sign-in, spawn, and stop work in the shell", async ({ page }, testInfo) =>
|
||||
await expect(page.getByTestId("composer-steering-status")).toHaveCount(0);
|
||||
await expect(page.getByText("Messages sent now guide the next turn.")).toHaveCount(0);
|
||||
await expect(page.getByText(/^Steer /)).toHaveCount(0);
|
||||
await expect(composer).toHaveAttribute("placeholder", "Message Chief");
|
||||
await expect(composer).toHaveAttribute("placeholder", "Message…");
|
||||
await expect(page.getByRole("button", { name: "Send", exact: true })).toBeVisible();
|
||||
await composer.fill("Use the newer report and keep the answer short.");
|
||||
await page.keyboard.press("Tab");
|
||||
@ -386,7 +386,7 @@ test("sign-in, spawn, and stop work in the shell", async ({ page }, testInfo) =>
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(page.getByRole("button", { name: "Send", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
|
||||
await expect(composer).toHaveAttribute("placeholder", "Message Chief");
|
||||
await expect(composer).toHaveAttribute("placeholder", "Message…");
|
||||
await captureScreenshot(page, testInfo, "14-active-bot-work-mobile");
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
expect(browserErrors).toEqual([]);
|
||||
|
||||
@ -178,6 +178,6 @@ export async function createNamedBot(
|
||||
computerMode: options.computerMode ?? "team",
|
||||
});
|
||||
await page.goto(`/app/${bot.id}`);
|
||||
await expect(page.getByPlaceholder(`Message ${name}`)).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
return bot.id;
|
||||
}
|
||||
|
||||
@ -40,7 +40,7 @@ test("create opens form, then empty chat; picker lists bots; sidebar collapses",
|
||||
await form.locator("label:has-text('Name') input").fill("New Bot");
|
||||
await form.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await page.waitForURL(/\/app\/[^/]+$/);
|
||||
await expect(page.getByPlaceholder("Message New Bot")).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
|
||||
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
|
||||
await captureScreenshot(page, testInfo, "create-chat-sidepanel-closed");
|
||||
@ -118,7 +118,7 @@ test("later bot waits before showing the focus card; sending cancels it", async
|
||||
|
||||
await page.clock.install();
|
||||
await createBotFromPicker(page);
|
||||
await expect(page.getByPlaceholder("Message New Bot")).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
|
||||
|
||||
await page.clock.fastForward(9_000);
|
||||
@ -150,7 +150,7 @@ test("plus picker can create a Private computer bot", async ({ page }, testInfo)
|
||||
await page.waitForURL(/\/app\/[^/]+$/);
|
||||
|
||||
await createBotFromPicker(page, { computerMode: "dedicated" });
|
||||
await expect(page.getByPlaceholder("Message New Bot")).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
await captureScreenshot(page, testInfo, "create-private-computer-bot");
|
||||
|
||||
const botId = page.url().split("/").pop()!;
|
||||
@ -179,7 +179,7 @@ test("second bot from plus opens create form before persist", async ({ page }, t
|
||||
await form.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await create;
|
||||
await page.waitForURL(/\/app\/[^/]+$/);
|
||||
await expect(page.getByPlaceholder("Message Researcher")).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
|
||||
await captureScreenshot(page, testInfo, "second-bot-created");
|
||||
});
|
||||
|
||||
@ -27,7 +27,7 @@ test("focus choice suggests apps and preserves a completed connection", async ({
|
||||
// The focus step suggests apps but must not rename the bot: the name the
|
||||
// user chose during creation ("Chief") is preserved.
|
||||
await expect(page.locator("main").getByText("Chief", { exact: true })).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Message Chief")).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
await expect(page.getByText("Slack", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Gmail", { exact: true })).toBeVisible();
|
||||
const connectionCards = page.getByRole("group", { name: / connection$/ });
|
||||
|
||||
@ -21,9 +21,10 @@ test("Projekte + opens new project dialog instead of group form", async ({ page
|
||||
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
|
||||
await expect(page).not.toHaveURL(/\/onboarding/);
|
||||
await page.waitForURL(/\/app/);
|
||||
await expect(sidebar.getByText("Customer project", { exact: true })).toBeVisible({
|
||||
await expect(projekte.getByText("Customer project", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.locator(".sidebar-unterhaltungen").getByText("Customer project")).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator("main").getByRole("button", { name: "Create new Bot", exact: true }),
|
||||
).toBeVisible();
|
||||
@ -37,6 +38,8 @@ test("spaces stay invisible by default and chat creation requires approval", asy
|
||||
await completeOnboarding(page);
|
||||
|
||||
const sidebar = page.locator("aside").first();
|
||||
const personalSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
|
||||
expect(personalSpaceId).toBeTruthy();
|
||||
await expect(sidebar.getByText("Personal", { exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||
await captureScreenshot(page, testInfo, "single-space-sidebar");
|
||||
@ -48,7 +51,7 @@ test("spaces stay invisible by default and chat creation requires approval", asy
|
||||
await captureScreenshot(page, testInfo, "new-space-dialog");
|
||||
await dialog.getByRole("button", { name: "Cancel" }).click();
|
||||
|
||||
const composer = page.getByRole("combobox", { name: "Message Chief" });
|
||||
const composer = page.getByRole("combobox", { name: "Message" });
|
||||
await composer.fill("Create a space named Customer support");
|
||||
await composer.press("Enter");
|
||||
await expect(page.getByRole("button", { name: "Create space", exact: true })).toBeVisible({
|
||||
@ -61,16 +64,19 @@ test("spaces stay invisible by default and chat creation requires approval", asy
|
||||
await page.getByRole("button", { name: "Create space", exact: true }).click();
|
||||
await expect(page.getByText("Created", { exact: true })).toBeVisible();
|
||||
|
||||
await expect(sidebar.getByText("Personal", { exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByText("Customer support", { exact: true })).toBeVisible({
|
||||
const projekte = page.locator(".sidebar-projekte");
|
||||
const unterhaltungen = page.locator(".sidebar-unterhaltungen");
|
||||
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(unterhaltungen.getByText("Customer support", { exact: true })).toHaveCount(0);
|
||||
await expect(unterhaltungen.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||
|
||||
const supportSpace = sidebar
|
||||
.locator('[data-sidebar-group^="space:"]')
|
||||
.filter({ hasText: "Customer support" });
|
||||
const supportSpace = projekte.locator('[data-sidebar-group^="project-space:"]').filter({
|
||||
hasText: "Customer support",
|
||||
});
|
||||
const supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group");
|
||||
const supportSpaceId = supportSpaceGroup?.split(":")[1];
|
||||
const supportSpaceId = supportSpaceGroup?.split(":")[2];
|
||||
expect(supportSpaceId).toBeTruthy();
|
||||
await supportSpace.getByRole("button", { name: "Open Customer support" }).click();
|
||||
await page.waitForURL(/\/(onboarding|app)/);
|
||||
@ -79,23 +85,20 @@ test("spaces stay invisible by default and chat creation requires approval", asy
|
||||
.toBe(supportSpaceId);
|
||||
await completeOnboarding(page);
|
||||
|
||||
await expect(sidebar.getByText("Personal", { exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByText("Customer support", { exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(2);
|
||||
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
|
||||
await expect(unterhaltungen.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
||||
await captureScreenshot(page, testInfo, "spaces-sidebar");
|
||||
|
||||
const personalSpace = sidebar
|
||||
.locator('[data-sidebar-group^="space:"]')
|
||||
.filter({ hasText: "Personal" });
|
||||
const personalSpaceGroup = await personalSpace.getAttribute("data-sidebar-group");
|
||||
const personalSpaceId = personalSpaceGroup?.split(":")[1];
|
||||
expect(personalSpaceId).toBeTruthy();
|
||||
await personalSpace.getByRole("button", { name: /^Chief/ }).click();
|
||||
await page.evaluate((id) => {
|
||||
window.localStorage.setItem("rakazo:space-id", id!);
|
||||
}, personalSpaceId);
|
||||
await page.reload();
|
||||
await page.waitForURL(/\/app\/[^/]+$/);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
|
||||
.toBe(personalSpaceId);
|
||||
await expect(sidebar.getByText("Customer support", { exact: true })).toBeVisible();
|
||||
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("a new space auto-completes onboarding and can be deleted from its menu", async ({
|
||||
@ -114,27 +117,24 @@ test("a new space auto-completes onboarding and can be deleted from its menu", a
|
||||
// Model is already connected, so onboarding skips the form and lands in Chief.
|
||||
await completeOnboarding(page);
|
||||
await expect(page).toHaveURL(/\/app\//);
|
||||
await expect(sidebar.getByText("Temporary", { exact: true })).toBeVisible();
|
||||
const temporarySpace = sidebar
|
||||
.locator('[data-sidebar-group^="space:"]')
|
||||
.filter({ hasText: "Temporary" });
|
||||
await expect(temporarySpace.getByRole("button", { name: /^Chief/ })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(2);
|
||||
const projekte = page.locator(".sidebar-projekte");
|
||||
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||
await captureScreenshot(page, testInfo, "new-space-after-onboarding");
|
||||
|
||||
// Spaces can only be deleted when empty — remove auto-created Chief first.
|
||||
await temporarySpace.getByRole("button", { name: /^Chief/ }).click({ button: "right" });
|
||||
await sidebar.getByRole("button", { name: /^Chief/ }).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(temporarySpace.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
||||
|
||||
// The emptied space stays selectable instead of trapping the user.
|
||||
await expect(sidebar.getByRole("button", { name: "Open Temporary" })).toBeVisible();
|
||||
await expect(projekte.getByRole("button", { name: "Open Temporary" })).toBeVisible();
|
||||
|
||||
// Deletion is discoverable through the visible Space actions menu.
|
||||
await sidebar.getByRole("button", { name: "Actions for Temporary" }).click();
|
||||
await projekte.getByRole("button", { name: "Actions for Temporary" }).click();
|
||||
await captureScreenshot(page, testInfo, "space-actions-menu");
|
||||
await page.getByRole("menuitem", { name: "Delete space" }).click();
|
||||
const deleteSpaceDialog = page.getByRole("alertdialog", { name: "Delete Temporary?" });
|
||||
@ -169,14 +169,12 @@ test("deleting the last bot in a space stays in the app after first use", async
|
||||
await dialog.getByRole("button", { name: "Create space", exact: true }).click();
|
||||
await page.waitForURL(/\/(onboarding|app)/);
|
||||
await completeOnboarding(page);
|
||||
await expect(sidebar.getByText("Side", { exact: true })).toBeVisible();
|
||||
const projekte = page.locator(".sidebar-projekte");
|
||||
await expect(projekte.getByText("Side", { exact: true })).toBeVisible();
|
||||
|
||||
// Delete the only bot in the new space: the app must stay put instead of
|
||||
// forcing onboarding again.
|
||||
const sideBot = sidebar
|
||||
.locator('[data-sidebar-group^="space:"]')
|
||||
.filter({ hasText: "Side" })
|
||||
.getByRole("button", { name: /^Chief/ });
|
||||
const sideBot = sidebar.getByRole("button", { name: /^Chief/ });
|
||||
await sideBot.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
const deleteDialog = page.getByRole("alertdialog", { name: /Delete Chief/ });
|
||||
@ -186,12 +184,12 @@ test("deleting the last bot in a space stays in the app after first use", async
|
||||
await expect(page).not.toHaveURL(/\/onboarding/);
|
||||
await page.reload();
|
||||
await expect(page).not.toHaveURL(/\/onboarding/);
|
||||
await expect(sidebar.getByRole("button", { name: "Open Side" })).toBeVisible();
|
||||
await expect(projekte.getByRole("button", { name: "Open Side" })).toBeVisible();
|
||||
await expect(
|
||||
page.locator("main").getByRole("button", { name: "Create new Bot", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await sidebar.getByRole("button", { name: "Actions for Side" }).click();
|
||||
await projekte.getByRole("button", { name: "Actions for Side" }).click();
|
||||
await page.getByRole("menuitem", { name: "Delete space" }).click();
|
||||
await page
|
||||
.getByRole("alertdialog", { name: "Delete Side?" })
|
||||
|
||||
@ -264,7 +264,7 @@ async function openBot(page: Page, name: string) {
|
||||
.getByRole("complementary")
|
||||
.getByRole("button", { name: new RegExp(`^${name}`) })
|
||||
.click();
|
||||
await expect(page.getByPlaceholder(`Message ${name}`)).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function openComputerPanel(page: Page) {
|
||||
|
||||
59
apps/web/e2e/unterhaltungen-sidebar.spec.ts
Normal file
59
apps/web/e2e/unterhaltungen-sidebar.spec.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { completeOnboarding, openNewBot, rpc, signup } from "./helpers";
|
||||
|
||||
test("Unterhaltungen and Projekte stay separate; + opens bot picker", async ({ page }) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `unterhaltungen-${stamp}@rakazo.test`, "password12", "Unterhaltungen UX");
|
||||
await completeOnboarding(page);
|
||||
await page.goto("/app");
|
||||
await page.waitForURL(/\/app\/[^/]+$/);
|
||||
|
||||
const sidebar = page.locator("aside").first();
|
||||
const unterhaltungen = page.locator(".sidebar-unterhaltungen");
|
||||
const projekte = page.locator(".sidebar-projekte");
|
||||
|
||||
await expect(unterhaltungen.getByText("Unterhaltungen", { exact: true })).toBeVisible();
|
||||
await expect(projekte.getByText("Projekte", { exact: true })).toBeVisible();
|
||||
await expect(unterhaltungen.getByRole("button", { name: /^Chief/ })).toBeVisible();
|
||||
|
||||
await unterhaltungen.getByTestId("conversation-picker-trigger").click();
|
||||
const picker = page.getByTestId("conversation-bot-picker");
|
||||
await expect(picker).toBeVisible();
|
||||
await expect(picker.getByTestId("conversation-create-bot")).toBeVisible();
|
||||
await expect(picker.getByText("Chief", { exact: true })).toBeVisible();
|
||||
|
||||
await picker.getByTestId("conversation-create-bot").click();
|
||||
await expect(page.getByTestId("create-bot-form")).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await openNewBot(page);
|
||||
const form = page.getByTestId("create-bot-form");
|
||||
await form.locator("label:has-text('Name') input").fill("Researcher");
|
||||
await form.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await page.waitForURL(/\/app\/[^/]+$/);
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
|
||||
await projekte.getByRole("button", { name: "Create a project" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "New project" });
|
||||
await dialog.getByLabel("Name").fill("Side project");
|
||||
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
|
||||
await page.waitForURL(/\/app/);
|
||||
await expect(projekte.getByText("Side project", { exact: true })).toBeVisible();
|
||||
await expect(unterhaltungen.getByText("Side project", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("chat header shows avatar once and composer uses generic placeholder", async ({ page }) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `chat-header-${stamp}@rakazo.test`, "password12", "Chat Header");
|
||||
await completeOnboarding(page);
|
||||
await page.goto("/app");
|
||||
await page.waitForURL(/\/app\/[^/]+$/);
|
||||
|
||||
const header = page.getByTestId("bot-settings-trigger");
|
||||
await expect(header.locator(".rakazo-bot-avatar, [data-testid='bot-avatar']").first()).toBeVisible();
|
||||
await expect(header.getByText("Chief", { exact: true })).toBeVisible();
|
||||
await expect(header.getByText("1:1 conversation", { exact: true })).toHaveCount(0);
|
||||
|
||||
const composer = page.getByRole("combobox", { name: "Message" });
|
||||
await expect(composer).toHaveAttribute("placeholder", "Message…");
|
||||
});
|
||||
@ -170,8 +170,8 @@ export function OoStage({
|
||||
/>
|
||||
|
||||
<div className={`relative z-10 mx-auto flex w-full max-w-3xl flex-col items-center px-4 md:px-8 ${hasMessages ? "py-2 md:py-3" : "py-5 md:py-6"}`}>
|
||||
<div className="mb-3 flex w-full items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
{(hasMessages || phase !== "idle") && (
|
||||
<div className={`flex w-full justify-center ${hasMessages ? "mb-1" : "mb-3"}`}>
|
||||
<div
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-0.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground backdrop-blur-sm"
|
||||
style={
|
||||
@ -195,15 +195,20 @@ export function OoStage({
|
||||
/>
|
||||
<span>{phaseLabel}</span>
|
||||
</div>
|
||||
<h2 className="mt-1.5 truncate text-ui-title font-semibold tracking-tight text-foreground">
|
||||
</div>
|
||||
)}
|
||||
{!hasMessages ? (
|
||||
<div className="mb-3 text-center">
|
||||
<h2 className="truncate text-ui-title font-semibold tracking-tight text-foreground">
|
||||
{botName}
|
||||
</h2>
|
||||
<p className="truncate text-ui-body-sm text-muted-foreground">
|
||||
{t`1:1 conversation`}
|
||||
{persona.roleLine ? ` · ${persona.roleLine}` : ""}
|
||||
</p>
|
||||
{persona.roleLine ? (
|
||||
<p className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">
|
||||
{persona.roleLine}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{awarenessLabel && awarenessEvent ? (
|
||||
<div className="mb-3 w-full">
|
||||
|
||||
@ -232,7 +232,7 @@ import {
|
||||
import type { SettingsSection } from "./SettingsOverlay";
|
||||
import { SpaceSearchResults } from "./SpaceSearch";
|
||||
import { BotSettings, CreateBotForm } from "./shell/bot-panel";
|
||||
import { BotCreatePicker } from "./shell/bot-picker";
|
||||
import { BotCreatePicker, ConversationBotPicker } from "./shell/bot-picker";
|
||||
import { CommandPalette, isCommandPaletteHotkey } from "./shell/command-palette";
|
||||
import {
|
||||
ClearConversationDialog,
|
||||
@ -339,6 +339,14 @@ function readCollapsedSidebarSections(userId: string | null | undefined): Set<st
|
||||
|
||||
export type NavPillar = "heute" | "team" | "projekte";
|
||||
|
||||
function stripSpaceTitlePrefix(title: string | null, spaceName: string): string | null {
|
||||
if (!title) return null;
|
||||
const prefix = `${spaceName} · `;
|
||||
if (title.startsWith(prefix)) return title.slice(prefix.length);
|
||||
if (title === spaceName) return null;
|
||||
return title;
|
||||
}
|
||||
|
||||
export function ShellPage() {
|
||||
const { t } = useLingui();
|
||||
const { botId, groupId } = useParams();
|
||||
@ -571,6 +579,7 @@ export function ShellPage() {
|
||||
const mobileSidebarSwipeRef = useRef<{ startX: number; startY: number } | null>(null);
|
||||
const [draggedBotId, setDraggedBotId] = useState<string | null>(null);
|
||||
const [createMenuOpen, setCreateMenuOpen] = useState(false);
|
||||
const [conversationPickerOpen, setConversationPickerOpen] = useState(false);
|
||||
const [botsSidebarCollapsed, setBotsSidebarCollapsed] = useState(false);
|
||||
const focusPromptAbortRef = useRef<AbortController | null>(null);
|
||||
const focusPromptBotIdRef = useRef<string | null>(null);
|
||||
@ -1555,6 +1564,71 @@ export function ShellPage() {
|
||||
[groups],
|
||||
);
|
||||
|
||||
const homeSpaceId = bootstrapMe?.spaceId;
|
||||
|
||||
const homeSpaceBots = useMemo(() => {
|
||||
if (!homeSpaceId) return bots;
|
||||
const home = spaces.find((space) => space.id === homeSpaceId);
|
||||
return home?.bots ?? bots;
|
||||
}, [bots, homeSpaceId, spaces]);
|
||||
|
||||
const conversationSidebarGroups = useMemo(() => {
|
||||
if (!homeSpaceId) return [];
|
||||
return sidebarGroups
|
||||
.filter((group) => group.spaceId === homeSpaceId)
|
||||
.map((group) => ({
|
||||
...group,
|
||||
title: stripSpaceTitlePrefix(group.title, group.spaceName),
|
||||
showLock: false,
|
||||
bots: group.bots.filter((item) => item.kind === "bot"),
|
||||
}))
|
||||
.filter((group) => group.bots.length > 0);
|
||||
}, [homeSpaceId, sidebarGroups]);
|
||||
|
||||
const projectSidebarGroups = useMemo(() => {
|
||||
if (!homeSpaceId) return [];
|
||||
const entries: typeof sidebarGroups = [];
|
||||
|
||||
for (const space of spaces) {
|
||||
if (space.id === homeSpaceId) continue;
|
||||
const groupsInSpace = sidebarGroups.flatMap((group) =>
|
||||
group.spaceId === space.id
|
||||
? group.bots.filter(
|
||||
(item) => item.kind === "group" && item.chat.id !== hauptthreadGroup?.id,
|
||||
)
|
||||
: [],
|
||||
);
|
||||
entries.push({
|
||||
key: groupsInSpace.length > 0 ? `project-space:${space.id}` : `project-space:${space.id}:empty`,
|
||||
title: space.name,
|
||||
bots: groupsInSpace,
|
||||
sectionId: null,
|
||||
showLock: true,
|
||||
emptySpaceId: groupsInSpace.length > 0 ? undefined : space.id,
|
||||
spaceId: space.id,
|
||||
spaceName: space.name,
|
||||
canDeleteSpace: space.canDelete === true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of sidebarGroups) {
|
||||
if (group.spaceId !== homeSpaceId) continue;
|
||||
const groupChats = group.bots.filter(
|
||||
(item) => item.kind === "group" && item.chat.id !== hauptthreadGroup?.id,
|
||||
);
|
||||
if (groupChats.length === 0) continue;
|
||||
entries.push({
|
||||
...group,
|
||||
title: stripSpaceTitlePrefix(group.title, group.spaceName),
|
||||
showLock: false,
|
||||
emptySpaceId: undefined,
|
||||
bots: groupChats,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}, [hauptthreadGroup?.id, homeSpaceId, sidebarGroups, spaces]);
|
||||
|
||||
const openSpaceChat = useCallback(
|
||||
(spaceId: string, path: string) => {
|
||||
setMobileSidebarOpen(false);
|
||||
@ -1574,6 +1648,23 @@ export function ShellPage() {
|
||||
},
|
||||
[bootstrapMe?.spaceId, navigate],
|
||||
);
|
||||
|
||||
const openConversationBot = useCallback(
|
||||
(botId: string) => {
|
||||
setConversationPickerOpen(false);
|
||||
setMobileSidebarOpen(false);
|
||||
setActiveNavPillar("team");
|
||||
const bot =
|
||||
homeSpaceBots.find((item) => item.id === botId) ?? bots.find((item) => item.id === botId);
|
||||
if (bot) {
|
||||
openSpaceChat(bot.spaceId, `/app/${botId}`);
|
||||
return;
|
||||
}
|
||||
navigate(`/app/${botId}`);
|
||||
},
|
||||
[bots, homeSpaceBots, navigate, openSpaceChat],
|
||||
);
|
||||
|
||||
const flushBotOrder = useCallback(async () => {
|
||||
if (savingBotOrderRef.current) return;
|
||||
savingBotOrderRef.current = true;
|
||||
@ -3059,237 +3150,57 @@ export function ShellPage() {
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar-projekte">
|
||||
{/* PROJEKTE SECTION */}
|
||||
<div className="flex items-center justify-between pt-4 pb-1 mt-2 border-t border-white/5 px-2.5">
|
||||
<div>
|
||||
<div className="rk-shell-section-label">{t`Projekte`}</div>
|
||||
<p className="mt-0.5 px-0.5 text-ui-caption text-muted-foreground/70">
|
||||
{t`Threads by topic · folders`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Create a project`}
|
||||
onClick={() => openNewSpaceDialog("project")}
|
||||
className="text-muted-foreground/60 hover:text-foreground/90 transition-colors"
|
||||
>
|
||||
<Plus strokeWidth={2.5} className="size-icon-md" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{sidebarGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
bots: group.bots.filter(
|
||||
(b) => b.kind === "group" && b.chat.id !== hauptthreadGroup?.id,
|
||||
) as any[],
|
||||
}))
|
||||
.filter((group) => group.bots.length > 0 || group.emptySpaceId).length === 0 ? (
|
||||
<div className="p-4 text-center text-ui-label text-muted-foreground">
|
||||
<p>{t`No projects yet.`}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 text-ui-label"
|
||||
onClick={() => openNewSpaceDialog("project")}
|
||||
>
|
||||
<Plus className="mr-1 size-icon-sm" />
|
||||
{t`Create a project`}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{sidebarGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
bots: group.bots.filter(
|
||||
(b) => b.kind === "group" && b.chat.id !== hauptthreadGroup?.id,
|
||||
) as any[],
|
||||
}))
|
||||
.filter((group) => group.bots.length > 0 || group.emptySpaceId)
|
||||
.map((group) => {
|
||||
const collapsed =
|
||||
Boolean(group.title) && collapsedSidebarSections.has(group.key);
|
||||
return (
|
||||
<div key={`proj-${group.key}`} data-sidebar-group={group.key}>
|
||||
{group.title ? (
|
||||
<div className="flex items-center pt-3 pb-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2.5 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) {
|
||||
openSpaceChat(group.emptySpaceId, "/onboarding");
|
||||
return;
|
||||
}
|
||||
toggleSidebarSection(group.key);
|
||||
}}
|
||||
onContextMenu={
|
||||
group.sectionId
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
const sections =
|
||||
group.spaceId === bootstrapMe?.spaceId
|
||||
? botSections
|
||||
: (spaces.find((space) => space.id === group.spaceId)
|
||||
?.botSections ?? []);
|
||||
const section = sections.find(
|
||||
(item) => item.id === group.sectionId,
|
||||
);
|
||||
if (!section) return;
|
||||
sectionMenuAnchor.current = event.currentTarget;
|
||||
setSectionMenu({
|
||||
section,
|
||||
spaceId: group.spaceId,
|
||||
position: { x: event.clientX, y: event.clientY },
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
aria-expanded={group.emptySpaceId ? undefined : !collapsed}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5 truncate">
|
||||
{group.showLock ? (
|
||||
<Lock
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
className="size-icon-sm"
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{group.title}</span>
|
||||
</span>
|
||||
{group.emptySpaceId ? null : (
|
||||
<ChevronDown
|
||||
strokeWidth={1.8}
|
||||
className={`size-icon-md ${collapsed ? "-rotate-90 transition-transform" : "transition-transform"}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{!collapsed &&
|
||||
group.bots.map((item) => (
|
||||
<button
|
||||
key={`${item.kind}:${item.chat.id}`}
|
||||
type="button"
|
||||
aria-label={t`Group conversation ${item.chat.name}`}
|
||||
onClick={() => {
|
||||
openSpaceChat(item.chat.spaceId, `/app/g/${item.chat.id}`);
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
if (item.chat.spaceId !== bootstrapMe?.spaceId) return;
|
||||
event.preventDefault();
|
||||
botMenuAnchor.current = event.currentTarget;
|
||||
setBotMenu({
|
||||
kind: item.kind,
|
||||
id: item.chat.id,
|
||||
position: { x: event.clientX, y: event.clientY },
|
||||
});
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
|
||||
item.kind === "group" && inGroup && activeGroup?.id === item.chat.id
|
||||
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30"
|
||||
: "hover:bg-white/[0.05] border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<GroupAvatar
|
||||
members={
|
||||
item.chat.id === activeSnapshot?.groupId
|
||||
? (activeSnapshot?.members ?? item.chat.members)
|
||||
: item.chat.members
|
||||
}
|
||||
size={40}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||
<span
|
||||
dir="auto"
|
||||
className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${
|
||||
item.chat.unread
|
||||
? "font-bold text-white"
|
||||
: "font-semibold text-white/95"
|
||||
}`}
|
||||
>
|
||||
{item.chat.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<span className="text-ui-body-sm text-zinc-400 tabular-nums font-medium">
|
||||
{formatRosterTime(item.chat.updatedAt)}
|
||||
</span>
|
||||
{item.chat.unread ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2 w-2 rounded-full bg-emerald-400"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
dir="auto"
|
||||
className={`mt-0.5 truncate text-ui-body leading-snug ${
|
||||
item.chat.unread ? "font-medium text-white" : "text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{cleanRosterPreview(item.chat.preview) ||
|
||||
item.chat.members.map((member: any) => member.name).join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="sidebar-team">
|
||||
{/* TEAM SECTION */}
|
||||
<div className="sidebar-unterhaltungen">
|
||||
<div className="flex items-center justify-between pt-4 pb-1 px-2.5">
|
||||
<div>
|
||||
<div className="rk-shell-section-label">{t`Team`}</div>
|
||||
<div className="rk-shell-section-label">{t`Unterhaltungen`}</div>
|
||||
<p className="mt-0.5 px-0.5 text-ui-caption text-muted-foreground/70">
|
||||
{t`Your Oos — open a 1:1 conversation`}
|
||||
{t`1:1 mit deinen Oos`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Neues Teammitglied"
|
||||
onClick={() => setPanel("create")}
|
||||
className="text-muted-foreground/60 hover:text-foreground/90 transition-colors"
|
||||
>
|
||||
<Plus strokeWidth={2.5} className="size-icon-md" />
|
||||
</button>
|
||||
<Popover open={conversationPickerOpen} onOpenChange={setConversationPickerOpen}>
|
||||
<PopoverTrigger
|
||||
type="button"
|
||||
aria-label={t`Neue Unterhaltung`}
|
||||
data-testid="conversation-picker-trigger"
|
||||
className="text-muted-foreground/60 hover:text-foreground/90 transition-colors"
|
||||
>
|
||||
<Plus strokeWidth={2.5} className="size-icon-md" />
|
||||
</PopoverTrigger>
|
||||
{conversationPickerOpen ? (
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-auto gap-0 overflow-hidden p-0 data-closed:animate-none"
|
||||
>
|
||||
<ConversationBotPicker
|
||||
bots={homeSpaceBots}
|
||||
onCreateBot={() => {
|
||||
setConversationPickerOpen(false);
|
||||
setPanel("create");
|
||||
}}
|
||||
onOpenBot={openConversationBot}
|
||||
/>
|
||||
</PopoverContent>
|
||||
) : null}
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
{sidebarGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
bots: group.bots.filter((b) => b.kind === "bot") as any[],
|
||||
}))
|
||||
.filter((group) => group.bots.length > 0 || group.emptySpaceId).length === 0 ? (
|
||||
{conversationSidebarGroups.length === 0 ? (
|
||||
<div className="p-4 text-center text-ui-label text-muted-foreground">
|
||||
<p>{t`No Oos in your team yet.`}</p>
|
||||
<p>{t`Noch keine Unterhaltung.`}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 text-ui-label"
|
||||
onClick={() => setPanel("create")}
|
||||
onClick={() => setConversationPickerOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 size-icon-sm" />
|
||||
{t`Add an Oo`}
|
||||
{t`Neue Unterhaltung`}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{sidebarGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
bots: group.bots.filter((b) => b.kind === "bot") as any[],
|
||||
}))
|
||||
.filter((group) => group.bots.length > 0 || group.emptySpaceId)
|
||||
.map((group) => {
|
||||
{conversationSidebarGroups.map((group) => {
|
||||
const collapsed =
|
||||
Boolean(group.title) && collapsedSidebarSections.has(group.key);
|
||||
const groupBotIds = group.bots.flatMap((item) =>
|
||||
@ -3443,17 +3354,8 @@ export function ShellPage() {
|
||||
: t`Group conversation ${item.chat.name}`
|
||||
}
|
||||
onClick={() => {
|
||||
if (item.kind === "bot") {
|
||||
setActiveNavPillar("team");
|
||||
} else {
|
||||
setActiveNavPillar("projekte");
|
||||
}
|
||||
openSpaceChat(
|
||||
item.chat.spaceId,
|
||||
item.kind === "bot"
|
||||
? `/app/${item.chat.id}`
|
||||
: `/app/g/${item.chat.id}`,
|
||||
);
|
||||
setActiveNavPillar("team");
|
||||
openSpaceChat(item.chat.spaceId, `/app/${item.chat.id}`);
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
if (item.chat.spaceId !== bootstrapMe?.spaceId) return;
|
||||
@ -3585,6 +3487,214 @@ export function ShellPage() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="sidebar-projekte">
|
||||
{/* PROJEKTE SECTION */}
|
||||
<div className="flex items-center justify-between pt-4 pb-1 mt-2 border-t border-white/5 px-2.5">
|
||||
<div>
|
||||
<div className="rk-shell-section-label">{t`Projekte`}</div>
|
||||
<p className="mt-0.5 px-0.5 text-ui-caption text-muted-foreground/70">
|
||||
{t`Threads by topic · folders`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Create a project`}
|
||||
onClick={() => openNewSpaceDialog("project")}
|
||||
className="text-muted-foreground/60 hover:text-foreground/90 transition-colors"
|
||||
>
|
||||
<Plus strokeWidth={2.5} className="size-icon-md" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{projectSidebarGroups.length === 0 ? (
|
||||
<div className="p-4 text-center text-ui-label text-muted-foreground">
|
||||
<p>{t`No projects yet.`}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 text-ui-label"
|
||||
onClick={() => openNewSpaceDialog("project")}
|
||||
>
|
||||
<Plus className="mr-1 size-icon-sm" />
|
||||
{t`Create a project`}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{projectSidebarGroups.map((group) => {
|
||||
const collapsed =
|
||||
Boolean(group.title) && collapsedSidebarSections.has(group.key);
|
||||
return (
|
||||
<div key={`proj-${group.key}`} data-sidebar-group={group.key}>
|
||||
{group.title ? (
|
||||
<div className="flex items-center pt-3 pb-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2.5 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",
|
||||
);
|
||||
return;
|
||||
}
|
||||
toggleSidebarSection(group.key);
|
||||
}}
|
||||
onContextMenu={
|
||||
group.sectionId
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
const sections =
|
||||
group.spaceId === bootstrapMe?.spaceId
|
||||
? botSections
|
||||
: (spaces.find((space) => space.id === group.spaceId)
|
||||
?.botSections ?? []);
|
||||
const section = sections.find(
|
||||
(item) => item.id === group.sectionId,
|
||||
);
|
||||
if (!section) return;
|
||||
sectionMenuAnchor.current = event.currentTarget;
|
||||
setSectionMenu({
|
||||
section,
|
||||
spaceId: group.spaceId,
|
||||
position: { x: event.clientX, y: event.clientY },
|
||||
});
|
||||
}
|
||||
: group.canDeleteSpace
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
spaceMenuAnchor.current = event.currentTarget;
|
||||
setSpaceMenu({
|
||||
id: group.spaceId,
|
||||
position: { x: event.clientX, y: event.clientY },
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
aria-expanded={group.emptySpaceId ? undefined : !collapsed}
|
||||
aria-label={
|
||||
group.emptySpaceId
|
||||
? t`Open ${group.title}`
|
||||
: collapsed
|
||||
? t`Expand ${group.title}`
|
||||
: t`Collapse ${group.title}`
|
||||
}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5 truncate">
|
||||
{group.showLock ? (
|
||||
<Lock
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
className="size-icon-sm"
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{group.title}</span>
|
||||
</span>
|
||||
{group.emptySpaceId ? null : (
|
||||
<ChevronDown
|
||||
strokeWidth={1.8}
|
||||
className={`size-icon-md ${collapsed ? "-rotate-90 transition-transform" : "transition-transform"}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{group.canDeleteSpace ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t`Actions for ${group.spaceName}`}
|
||||
onClick={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
spaceMenuAnchor.current = event.currentTarget;
|
||||
setSpaceMenu({
|
||||
id: group.spaceId,
|
||||
position: { x: rect.left, y: rect.bottom },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal aria-hidden="true" className="size-icon-md" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!collapsed &&
|
||||
group.bots.map((item) => (
|
||||
<button
|
||||
key={`${item.kind}:${item.chat.id}`}
|
||||
type="button"
|
||||
aria-label={t`Group conversation ${item.chat.name}`}
|
||||
onClick={() => {
|
||||
setActiveNavPillar("projekte");
|
||||
openSpaceChat(item.chat.spaceId, `/app/g/${item.chat.id}`);
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
if (item.chat.spaceId !== bootstrapMe?.spaceId) return;
|
||||
event.preventDefault();
|
||||
botMenuAnchor.current = event.currentTarget;
|
||||
setBotMenu({
|
||||
kind: item.kind,
|
||||
id: item.chat.id,
|
||||
position: { x: event.clientX, y: event.clientY },
|
||||
});
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
|
||||
item.kind === "group" && inGroup && activeGroup?.id === item.chat.id
|
||||
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30"
|
||||
: "hover:bg-white/[0.05] border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<GroupAvatar
|
||||
members={
|
||||
item.chat.id === activeSnapshot?.groupId
|
||||
? (activeSnapshot?.members ?? item.chat.members)
|
||||
: item.chat.members
|
||||
}
|
||||
size={40}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||
<span
|
||||
dir="auto"
|
||||
className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${
|
||||
item.chat.unread
|
||||
? "font-bold text-white"
|
||||
: "font-semibold text-white/95"
|
||||
}`}
|
||||
>
|
||||
{item.chat.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<span className="text-ui-body-sm text-zinc-400 tabular-nums font-medium">
|
||||
{formatRosterTime(item.chat.updatedAt)}
|
||||
</span>
|
||||
{item.chat.unread ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2 w-2 rounded-full bg-emerald-400"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
dir="auto"
|
||||
className={`mt-0.5 truncate text-ui-body leading-snug ${
|
||||
item.chat.unread ? "font-medium text-white" : "text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{cleanRosterPreview(item.chat.preview) ||
|
||||
item.chat.members.map((member: any) => member.name).join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@ -3929,8 +4039,27 @@ export function ShellPage() {
|
||||
type="button"
|
||||
data-testid="bot-settings-trigger"
|
||||
onClick={() => setPanel(inGroup ? "group-settings" : "settings")}
|
||||
className="app-no-drag flex min-w-0 items-center"
|
||||
className="app-no-drag flex min-w-0 items-center gap-3"
|
||||
>
|
||||
{!inGroup && active ? (
|
||||
<BotAvatar
|
||||
color={active.color}
|
||||
identity={active.id}
|
||||
size={36}
|
||||
status={active.status}
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : inGroup ? (
|
||||
<GroupAvatar
|
||||
members={
|
||||
activeGroup?.members ??
|
||||
activeSnapshot?.members ??
|
||||
[]
|
||||
}
|
||||
size={36}
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
<div className="min-w-0 text-start flex flex-col justify-center">
|
||||
<span
|
||||
className="block truncate text-ui-body font-semibold text-foreground tracking-tight leading-snug"
|
||||
@ -3938,19 +4067,21 @@ export function ShellPage() {
|
||||
>
|
||||
{inGroup
|
||||
? (activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`)
|
||||
: (active?.name ?? t`Choose a conversation`)}
|
||||
: (active?.name ?? t`Unterhaltung wählen`)}
|
||||
</span>
|
||||
{!inGroup && active ? (
|
||||
{!inGroup && active && (composerRunning || active.status !== "idle") ? (
|
||||
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
|
||||
{active.title ? t`1:1 conversation · ${active.title}` : t`1:1 conversation`}
|
||||
{composerRunning || active.status === "running"
|
||||
? t`Beschäftigt`
|
||||
: active.status}
|
||||
</span>
|
||||
) : inGroup && projectSessionKey ? (
|
||||
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
|
||||
{t`Group conversation`}
|
||||
{t`Gruppenunterhaltung`}
|
||||
</span>
|
||||
) : inGroup ? (
|
||||
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
|
||||
{t`Project · sessions & folders`}
|
||||
{t`Projekt · Sessions & Ordner`}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@ -6317,14 +6448,8 @@ const Composer = memo(function Composer({
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
placeholder={
|
||||
showComposerPlaceholder
|
||||
? activeName
|
||||
? t`Frag ${activeName} was Schlaues…`
|
||||
: t`Frag mich was Schlaues – oder lass uns handeln…`
|
||||
: undefined
|
||||
}
|
||||
aria-label={activeName ? t`Message ${activeName}` : t`Message`}
|
||||
placeholder={showComposerPlaceholder ? t`Message…` : undefined}
|
||||
aria-label={t`Message`}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
|
||||
@ -12,6 +12,80 @@ import {
|
||||
import { Info, Lock, Plus, Users } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
/** 1:1-Unterhaltung: bestehenden Bot öffnen oder neuen Bot anlegen. */
|
||||
export function ConversationBotPicker({
|
||||
bots,
|
||||
onCreateBot,
|
||||
onOpenBot,
|
||||
}: {
|
||||
bots: Bot[];
|
||||
onCreateBot: () => void;
|
||||
onOpenBot: (botId: string) => void;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
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),
|
||||
);
|
||||
}, [bots, needle]);
|
||||
const showCreate =
|
||||
!needle ||
|
||||
"neuen bot erstellen".includes(needle) ||
|
||||
needle.split(/\s+/).every((part) => "neuen bot erstellen".includes(part));
|
||||
|
||||
return (
|
||||
<div data-testid="conversation-bot-picker" className="w-[min(320px,calc(100vw-2rem))]">
|
||||
<label className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<span className="shrink-0 text-ui-body-sm text-muted-foreground">
|
||||
<Trans>Mit:</Trans>
|
||||
</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t`Suchen`}
|
||||
aria-label={t`Suchen`}
|
||||
className="min-w-0 flex-1 bg-transparent text-ui-body text-foreground outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</label>
|
||||
<Command shouldFilter={false} className="rounded-none border-0 bg-transparent p-0">
|
||||
<CommandList className="max-h-72 p-1">
|
||||
<CommandEmpty>
|
||||
<Trans>Kein Bot gefunden</Trans>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{showCreate ? (
|
||||
<CommandItem
|
||||
value="create-new-bot"
|
||||
data-testid="conversation-create-bot"
|
||||
onSelect={() => onCreateBot()}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus strokeWidth={1.8} aria-hidden="true" className="size-icon-md" />
|
||||
<Trans>Neuen Bot erstellen</Trans>
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{matched.map((bot) => (
|
||||
<CommandItem
|
||||
key={bot.id}
|
||||
value={bot.id}
|
||||
data-testid={`conversation-picker-bot-${bot.id}`}
|
||||
onSelect={() => onOpenBot(bot.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
<BotAvatar color={bot.color} identity={bot.id} size={22} status={bot.status} />
|
||||
<span className="min-w-0 flex-1 truncate">{bot.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BotCreatePicker({
|
||||
bots,
|
||||
onCreateBot,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user