diff --git a/apps/web/e2e/bot-organization.spec.ts b/apps/web/e2e/bot-organization.spec.ts index c32a4cd..d9176bf 100644 --- a/apps/web/e2e/bot-organization.spec.ts +++ b/apps/web/e2e/bot-organization.spec.ts @@ -307,6 +307,10 @@ test("group chats share every context-menu action", async ({ page }, testInfo) = await group.click({ button: "right" }); await captureScreenshot(page, testInfo, "group-context-menu-mobile"); await page.keyboard.press("Escape"); + if ((await page.getByRole("menu").count()) > 0) { + await page.keyboard.press("Escape"); + } + await expect(page.getByRole("menu")).toHaveCount(0); await page.setViewportSize({ width: 1280, height: 800 }); await group.click({ button: "right" }); diff --git a/apps/web/e2e/first-run-onboarding-ux.spec.ts b/apps/web/e2e/first-run-onboarding-ux.spec.ts new file mode 100644 index 0000000..b0e2306 --- /dev/null +++ b/apps/web/e2e/first-run-onboarding-ux.spec.ts @@ -0,0 +1,87 @@ +import { expect, test } from "@playwright/test"; +import { completeOnboarding, rpc, signup } from "./helpers"; + +test("onboarding names the first Oo and shows it in chat", async ({ page }) => { + const stamp = Date.now(); + await signup(page, `name-oo-${stamp}@rakazo.test`, "password12", "Namer"); + await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); + await page.getByRole("button", { name: /^(Continue|Weiter)$/i }).click(); + await expect(page.getByTestId("onboarding-name-step")).toBeVisible(); + await page.getByTestId("onboarding-oo-name-input").fill("Luna"); + await page + .getByTestId("onboarding-name-step") + .getByRole("button", { name: /^(Continue|Weiter)$/i }) + .click(); + await page.getByRole("button", { name: /^(Skip|Überspringen ·)/i }).click(); + await page.getByRole("button", { name: /^(Skip|Überspringen)$/i }).click(); + await expect(page).toHaveURL(/\/app\/g\/[^/]+/, { timeout: 30_000 }); + await expect( + page.getByTestId("bot-settings-trigger").getByText("Luna", { exact: true }), + ).toBeVisible(); + expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Luna"); +}); + +test("German UI keeps Deutsch on language skip label", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "language", { value: "de-DE", configurable: true }); + localStorage.setItem("rakazo.uiLocale", "de"); + }); + const stamp = Date.now(); + await signup(page, `de-skip-${stamp}@rakazo.test`, "password12", "Deutsch"); + await page.getByRole("button", { name: /^(Continue|Weiter)$/i }).click(); + await page.getByRole("button", { name: /^(Skip|Überspringen)$/i }).click(); + await expect( + page.getByRole("heading", { name: /Wähle deine Sprache|Choose your language/i }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: /Überspringen · Deutsch/i })).toBeVisible(); +}); + +test("German voice step lists German voices after choosing Deutsch", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "language", { value: "de-DE", configurable: true }); + localStorage.setItem("rakazo.uiLocale", "de"); + }); + const stamp = Date.now(); + await signup(page, `de-voice-${stamp}@rakazo.test`, "password12", "Stimme"); + await page.getByRole("button", { name: /^(Continue|Weiter)$/i }).click(); + await page.getByRole("button", { name: /^(Skip|Überspringen)$/i }).click(); + await page.getByRole("button", { name: "Deutsch", exact: true }).click(); + await expect(page.getByText(/Loading voices|Stimmen werden geladen/i)).toHaveCount(0, { + timeout: 30_000, + }); + await expect(page.getByTestId("onboarding-voice-df_eva")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("onboarding-voice-dm_bernd")).toBeVisible(); + await expect(page.getByTestId("onboarding-voice-af_alloy")).toHaveCount(0); + await expect(page.getByTestId("onboarding-voice-df_eva")).toHaveAttribute("aria-pressed", "true"); +}); + +test("main conversation does not show a group subtitle for one Oo", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "language", { value: "de-DE", configurable: true }); + localStorage.setItem("rakazo.uiLocale", "de"); + }); + const stamp = Date.now(); + await signup(page, `main-1-1-${stamp}@rakazo.test`, "password12", "Solo"); + await completeOnboarding(page, undefined, { mainConversation: true }); + await expect(page.getByTestId("bot-settings-trigger")).not.toContainText("Gruppenunterhaltung"); + await expect(page.getByTestId("oo-group-stage")).toContainText(/1:1/i); +}); + +test.describe("mobile shell", () => { + test.use({ + hasTouch: true, + isMobile: true, + viewport: { width: 390, height: 844 }, + }); + + test("Escape closes the mobile navigation drawer", async ({ page }) => { + const stamp = Date.now(); + await signup(page, `mobile-esc-${stamp}@rakazo.test`, "password12", "Escape"); + await completeOnboarding(page); + await page.getByRole("button", { name: "Open navigation" }).click(); + await expect(page.getByRole("button", { name: "Close navigation" })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("button", { name: "Close navigation" })).toHaveCount(0); + await expect(page.getByTestId("open-navigation")).toBeFocused(); + }); +}); diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index 04661ec..b3e4036 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -87,6 +87,16 @@ async function visible(locator: Locator, timeout: number) { return locator.isVisible({ timeout }).catch(() => false); } +const onboardingContinueButton = /^(Continue|Weiter)$/i; +const onboardingSkipButton = /^(Skip|Überspringen)$/i; +const onboardingLanguageSkipButton = /^(Skip|Überspringen) ·/i; + +/** After the look picker, skip the optional Oo naming step (keeps the default name). */ +export async function skipOnboardingNameStep(page: Page) { + await expect(page.getByTestId("onboarding-name-step")).toBeVisible(); + await page.getByRole("button", { name: onboardingSkipButton }).click(); +} + /** * Walks the first-run wizard, which lands in the main conversation with the main bot's first * onboarding question pending (the composer works regardless). Unless `mainConversation` is @@ -95,23 +105,29 @@ async function visible(locator: Locator, timeout: number) { export async function completeOnboarding( page: Page, testInfo?: TestInfo, - options: { mainConversation?: boolean } = {}, + options: { mainConversation?: boolean; ooName?: string } = {}, ) { await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 }); if (page.url().includes("/onboarding")) { await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute( "data-onboarding-step", - /^(emoji|language|voice|integrations|scan|bot)$/, + /^(emoji|name|language|voice|integrations|scan|bot)$/, { timeout: 30_000 }, ); } - const design = page.getByRole("heading", { name: /Design your Oo/i }); - const language = page.getByRole("heading", { name: /Choose your language/i }); - const voice = page.getByRole("heading", { name: /Pick a voice/i }); - const integrations = page.getByRole("heading", { name: "Server integrations", exact: true }); - const chief = page.getByText("Chief").first(); + const design = page.getByRole("heading", { name: /Design your Oo|Gestalte dein Oo/i }); + const naming = page.getByTestId("onboarding-name-step"); + const language = page.getByRole("heading", { + name: /Choose your language|Wähle deine Sprache/i, + }); + const voice = page.getByRole("heading", { name: /Pick a voice|Wähle eine Stimme/i }); + const integrations = page.getByRole("heading", { + name: /Server integrations|Server-Integrationen/i, + }); + const expectedOoName = options.ooName ?? "Chief"; + const chief = page.getByText(expectedOoName).first(); // Scripted CI has needsModel false, so a fresh owner starts on integrations. // A model-gated run still opens the look picker first. @@ -119,23 +135,30 @@ export async function completeOnboarding( await expect(entry.first()).toBeVisible({ timeout: 20_000 }); if (await design.isVisible()) { - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: onboardingContinueButton }).click(); + await expect(naming).toBeVisible(); + if (options.ooName) { + await page.getByTestId("onboarding-oo-name-input").fill(options.ooName); + await naming.getByRole("button", { name: onboardingContinueButton }).click(); + } else { + await page.getByRole("button", { name: onboardingSkipButton }).click(); + } await expect(language).toBeVisible(); - await page.getByRole("button", { name: /Skip/i }).click(); + await page.getByRole("button", { name: onboardingLanguageSkipButton }).click(); await expect(voice).toBeVisible(); - await page.getByRole("button", { name: "Skip", exact: true }).click(); + await page.getByRole("button", { name: onboardingSkipButton }).click(); } if (await visible(integrations, 8_000)) { if (testInfo) await captureScreenshot(page, testInfo, "02-connect-apps"); - await page.getByRole("button", { name: "Skip", exact: true }).click(); + await page.getByRole("button", { name: onboardingSkipButton }).click(); } // The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding // then lands in the main conversation. const skipScan = page .locator('[data-onboarding-step="scan"]') - .getByRole("button", { name: "Skip", exact: true }); + .getByRole("button", { name: onboardingSkipButton }); await expect(async () => { if (await skipScan.isVisible()) await skipScan.click(); expect(page.url()).toMatch(/\/app\/g\/[^/]+$/); @@ -171,15 +194,17 @@ export async function signup( ) { await expect(async () => { await page.goto("/sign-up", { waitUntil: "domcontentloaded" }); - await expect(page.getByRole("heading", { name: "Create your evOo" })).toBeVisible({ + await expect( + page.getByRole("heading", { name: /Create your evOo|Dein evOo erstellen/i }), + ).toBeVisible({ timeout: 5_000, }); }).toPass({ timeout: 45_000 }); if (testInfo) await captureScreenshot(page, testInfo, "01-sign-up"); - await page.getByPlaceholder("Your name").fill(name); - await page.getByPlaceholder("Your email address").fill(email); - await page.getByPlaceholder("Password").fill(password); - await page.getByRole("button", { name: "Create account" }).click(); + await page.getByPlaceholder(/Your name|Dein Name/i).fill(name); + await page.getByPlaceholder(/Your email address|Deine E-Mail-Adresse/i).fill(email); + await page.getByPlaceholder(/^Password$|^Passwort$/i).fill(password); + await page.getByRole("button", { name: /Create account|Konto erstellen/i }).click(); } export async function captureScreenshot(page: Page, testInfo: TestInfo, name: string) { @@ -274,7 +299,7 @@ export async function waitForChatComposer(page: Page) { await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", { timeout: 30_000, }); - await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); + await expect(page.getByRole("combobox", { name: /^Message$|^Nachricht$/i })).toBeVisible(); } /** Create a named bot via RPC for test setup (skips the + picker). */ diff --git a/apps/web/e2e/heute-brief.spec.ts b/apps/web/e2e/heute-brief.spec.ts index 293dcc9..4caac78 100644 --- a/apps/web/e2e/heute-brief.spec.ts +++ b/apps/web/e2e/heute-brief.spec.ts @@ -18,7 +18,10 @@ test("Heute shows native brief instead of Mission Control iframe", async ({ page await completeOnboarding(page); await openHeute(page); - await expect(page).toHaveURL(/\/app\/[^/]+$/); + await expect(page).toHaveURL(/\/app\/heute$/); + await page.reload(); + await expect(page.getByTestId("heute-brief")).toBeVisible(); + await expect(page).toHaveURL(/\/app\/heute$/); const brief = page.getByTestId("heute-brief"); await expect(brief).toBeVisible(); @@ -81,12 +84,21 @@ test("Heute shows the coach's brief, habits and goals, checks habits off and set await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("3 days"); await captureScreenshot(page, testInfo, "heute-coach"); - await page.reload(); - await openHeute(page); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page).toHaveURL(/\/app\/heute$/); + await expect(page.getByTestId("heute-brief")).toBeVisible(); await expect(page.getByTestId(`heute-habit-${habit.id}`).getByRole("checkbox")).toBeChecked(); // Check-in times live behind one compact control. - await page.getByTestId("heute-rhythm").click(); + const rhythm = page.getByTestId("heute-rhythm"); + await expect(page.getByTestId("heute-coach-brief")).toBeVisible(); + await rhythm.scrollIntoViewIfNeeded(); + await expect(rhythm).toBeVisible({ timeout: 30_000 }); + const rhythmLoaded = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/rhythm/get") && response.ok(), + ); + await rhythm.click(); + await rhythmLoaded; const panel = page.getByTestId("heute-rhythm-panel"); await expect(panel).toBeVisible(); const morning = panel.getByTestId("heute-rhythm-morning"); diff --git a/apps/web/e2e/onboarding-conversation.spec.ts b/apps/web/e2e/onboarding-conversation.spec.ts index 7809caa..c1d254b 100644 --- a/apps/web/e2e/onboarding-conversation.spec.ts +++ b/apps/web/e2e/onboarding-conversation.spec.ts @@ -11,7 +11,7 @@ test("focus choice suggests apps and preserves a completed connection", async ({ const stamp = Date.now(); await signup(page, `onboarding-${stamp}@rakazo.test`, "password12", "Robin"); // The main bot greets the user in the main conversation, where onboarding lands. - await completeOnboarding(page, undefined, { mainConversation: true }); + await completeOnboarding(page, undefined, { mainConversation: true, ooName: "Robin" }); await expect( page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."), @@ -31,12 +31,11 @@ test("focus choice suggests apps and preserves a completed connection", async ({ await captureScreenshot(page, testInfo, "choice-card-onboarding"); await page.getByTestId("focus-choice-day").click(); - // The focus step suggests apps but must not rename the bot: the name the - // user chose during creation ("Chief") is preserved. + // The focus step must not rename the bot: onboarding keeps the chosen name. await expect( - page.getByTestId("bot-settings-trigger").getByText("Chief", { exact: true }), + page.getByTestId("bot-settings-trigger").getByText("Robin", { exact: true }), ).toBeVisible(); - expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief"); + expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Robin"); await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); // The app cards speak for themselves; onboarding adds no text around them. await expect(page.getByText(/^Got it/)).toHaveCount(0); @@ -103,7 +102,7 @@ test("focus choice suggests apps and preserves a completed connection", async ({ test("choice refresh failures leave options available for retry", async ({ page }) => { await signup(page, `choice-refresh-${Date.now()}@rakazo.test`, "password12", "Choice Retry"); - await completeOnboarding(page, undefined, { mainConversation: true }); + await completeOnboarding(page, undefined, { mainConversation: true, ooName: "Robin" }); const choice = page.getByTestId("focus-choice-day"); await expect(choice).toBeEnabled(); // Keep the existing choice rendered while its save succeeds and navigation refresh fails. diff --git a/apps/web/e2e/onboarding-emoji-first.spec.ts b/apps/web/e2e/onboarding-emoji-first.spec.ts index 51e1bf8..9572b68 100644 --- a/apps/web/e2e/onboarding-emoji-first.spec.ts +++ b/apps/web/e2e/onboarding-emoji-first.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { captureScreenshot, signup } from "./helpers"; +import { captureScreenshot, signup, skipOnboardingNameStep } from "./helpers"; test.beforeEach(async ({ page }) => { await page.route("**/rpc/integrationSetup/get", (route) => @@ -49,11 +49,12 @@ test("emoji-first onboarding: shape + color selection with live preview", async if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview"); - // Click Continue → goes to the language step + // Click Continue → naming, then language. await page .getByRole("button", { name: /Continue/i }) .first() .click(); + await skipOnboardingNameStep(page); // Step 2: Language selection await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); @@ -96,6 +97,7 @@ test("onboarding: skip language defaults to English", async ({ page }) => { .getByRole("button", { name: /Continue/i }) .first() .click(); + await skipOnboardingNameStep(page); // Language step — click Skip, then skip voice. await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); diff --git a/apps/web/e2e/onboarding-oo-swipe.spec.ts b/apps/web/e2e/onboarding-oo-swipe.spec.ts index 0789ace..4b5f343 100644 --- a/apps/web/e2e/onboarding-oo-swipe.spec.ts +++ b/apps/web/e2e/onboarding-oo-swipe.spec.ts @@ -1,6 +1,6 @@ import type { Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; -import { captureScreenshot, signup } from "./helpers"; +import { captureScreenshot, signup, skipOnboardingNameStep } from "./helpers"; test.beforeEach(async ({ page }) => { await page.route("**/rpc/integrationSetup/get", (route) => @@ -77,5 +77,6 @@ test("mobile onboarding: category pick and swipe look previews", async ({ page } .getByRole("button", { name: /Continue/i }) .first() .click(); + await skipOnboardingNameStep(page); await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); }); diff --git a/apps/web/e2e/user-profile.spec.ts b/apps/web/e2e/user-profile.spec.ts index 8d5840e..21cf71a 100644 --- a/apps/web/e2e/user-profile.spec.ts +++ b/apps/web/e2e/user-profile.spec.ts @@ -4,7 +4,14 @@ * with its origin, confirms a pending one, and forgets one or all of them. */ import { expect, test } from "@playwright/test"; -import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers"; +import { + captureScreenshot, + openUserSettings, + rpc, + signup, + skipOnboardingNameStep, + waitForChatComposer, +} from "./helpers"; test.use({ timezoneId: "Europe/Vienna" }); @@ -34,6 +41,7 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async timeout: 30_000, }); await page.getByRole("button", { name: "Continue", exact: true }).click(); + await skipOnboardingNameStep(page); await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); await page.getByRole("button", { name: "English", exact: true }).click(); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7d13621..3b1e012 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -120,6 +120,10 @@ function SessionApp() { } /> : } /> + : } + /> : } diff --git a/apps/web/src/components/onboarding/OnboardingNameStep.tsx b/apps/web/src/components/onboarding/OnboardingNameStep.tsx new file mode 100644 index 0000000..c7d9c1e --- /dev/null +++ b/apps/web/src/components/onboarding/OnboardingNameStep.tsx @@ -0,0 +1,98 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { EvooPose } from "@rakazo/ui-web"; +import { BotAvatar, Button } from "@rakazo/ui-web"; +import { useId, useState } from "react"; +import type { OnboardingEmojiConfig } from "./OnboardingOoDesigner"; + +export const DEFAULT_FIRST_OO_NAME = "Chief"; + +type OnboardingNameStepProps = { + config: OnboardingEmojiConfig; + name: string; + onNameChange: (name: string) => void; + onBack: () => void; + onContinue: () => void; +}; + +export function OnboardingNameStep({ + config, + name, + onNameChange, + onBack, + onContinue, +}: OnboardingNameStepProps) { + const { t } = useLingui(); + const inputId = useId(); + const [draft, setDraft] = useState(name); + + function commitAndContinue() { + const trimmed = draft.trim() || DEFAULT_FIRST_OO_NAME; + onNameChange(trimmed); + onContinue(); + } + + return ( +
+

+ Name your Oo +

+

+ Something short and yours — or keep the default and rename later. +

+ +
+ +
+ + setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") commitAndContinue(); + }} + className="w-full rounded-2xl border-2 border-border bg-card px-4 py-3 text-lg font-semibold tracking-tight text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground/60 focus:border-primary" + /> +

+ {t`Press Enter or Continue — skip keeps ${DEFAULT_FIRST_OO_NAME}.`} +

+
+
+ +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/onboarding/OnboardingVoiceStep.tsx b/apps/web/src/components/onboarding/OnboardingVoiceStep.tsx index 91cd569..37d7704 100644 --- a/apps/web/src/components/onboarding/OnboardingVoiceStep.tsx +++ b/apps/web/src/components/onboarding/OnboardingVoiceStep.tsx @@ -1,15 +1,20 @@ import { Trans } from "@lingui/react/macro"; import type { VoiceInfo } from "@rakazo/contracts"; -import { defaultVoiceForLocale, voicesForLocale } from "@rakazo/core"; import { Button } from "@rakazo/ui-web"; import { useEffect, useRef, useState } from "react"; +import { + defaultOnboardingVoiceId, + offlineOnboardingVoices, + onboardingVoicesFromList, +} from "../../lib/onboarding-voices"; import { rpc } from "../../lib/rpc"; -import { readUiLocale } from "../../lib/ui-locale"; +import type { UiLocale } from "../../lib/ui-locale"; /** Must match packages/adapters KokoroVoiceProvider placeholder. */ export const KOKORO_LOCAL_TOKEN = "local-kokoro"; type OnboardingVoiceStepProps = { + uiLocale: UiLocale; selectedVoiceId: string; onSelectedVoiceIdChange: (voiceId: string) => void; onBack: () => void; @@ -18,6 +23,7 @@ type OnboardingVoiceStepProps = { }; export function OnboardingVoiceStep({ + uiLocale, selectedVoiceId, onSelectedVoiceIdChange, onBack, @@ -36,13 +42,17 @@ export function OnboardingVoiceStep({ await rpc.voice.connect({ provider: "kokoro", apiKey: KOKORO_LOCAL_TOKEN }); const listed = await rpc.voice.voices({ provider: "kokoro" }); if (cancelled) return; - const locale = readUiLocale(); - const ordered = voicesForLocale(listed, locale).slice(0, 6); + const ordered = onboardingVoicesFromList(listed, uiLocale); setVoiceOptions(ordered); - const defaultVoice = defaultVoiceForLocale(listed, locale); - if (defaultVoice) onSelectedVoiceIdChange(defaultVoice.id); + const nextId = defaultOnboardingVoiceId(uiLocale, ordered); + if (nextId) onSelectedVoiceIdChange(nextId); } catch { - if (!cancelled) setVoiceOptions([]); + if (!cancelled) { + const fallback = offlineOnboardingVoices(uiLocale); + setVoiceOptions(fallback); + const nextId = defaultOnboardingVoiceId(uiLocale, fallback); + if (nextId) onSelectedVoiceIdChange(nextId); + } } finally { if (!cancelled) setVoiceLoading(false); } @@ -54,7 +64,7 @@ export function OnboardingVoiceStep({ return () => { cancelled = true; }; - }, [onSelectedVoiceIdChange]); + }, [onSelectedVoiceIdChange, uiLocale]); async function skipVoice() { await previewTask.current; @@ -82,6 +92,7 @@ export function OnboardingVoiceStep({ - @@ -268,6 +297,7 @@ export function OnboardingPage() { {step === "voice" ? ( setStep("language")} diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index ddec27f..dd611fc 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -84,7 +84,7 @@ import { useState, } from "react"; import { createPortal } from "react-dom"; -import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { ArtifactPane } from "../components/ArtifactPane"; import { InAppBrowser } from "../components/browser/InAppBrowser"; import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress"; @@ -95,6 +95,7 @@ import { useWideLayout } from "../hooks/useWideLayout"; import { readActivityMode, writeActivityMode } from "../lib/activity-mode"; import { rosterActivitySubtitle } from "../lib/agent-activity"; import { agentPointerFeed } from "../lib/agent-pointer"; +import { HEUTE_APP_PATH, isHeuteAppPath } from "../lib/app-routes"; import type { ArtifactTarget } from "../lib/artifact-open"; import { attachmentMaxBytes, uploadAttachment } from "../lib/artifact-upload"; import { authClient } from "../lib/auth"; @@ -103,6 +104,7 @@ import { requestBrowserNotificationPermission } from "../lib/browser-notificatio import { loadComputerScreen } from "../lib/computer-screen"; import { scheduleFocusPrompt } from "../lib/focus-prompt"; import { formatBytes } from "../lib/format-bytes"; +import { isMultiOoGroupChat } from "../lib/group-chat"; import { isWorkingRunStatus, resolveActiveGroupSpeaker, @@ -267,14 +269,26 @@ import { SortableGroup } from "../components/SortableGroup"; export function ShellPage() { const { t } = useLingui(); const { botId, groupId } = useParams(); + const location = useLocation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); + const onHeuteRoute = isHeuteAppPath(location.pathname); + useEffect(() => { + if (botId === "heute") { + navigate(HEUTE_APP_PATH, { replace: true }); + } + }, [botId, navigate]); const [activeNavPillar, setActiveNavPillar] = useState(() => { + if (isHeuteAppPath(location.pathname)) return "heute"; if (groupId) return "projekte"; if (botId) return "team"; return "heute"; }); + useEffect(() => { + if (isHeuteAppPath(location.pathname)) setActiveNavPillar("heute"); + }, [location.pathname]); + useEffect(() => { if (botId) setActiveNavPillar("team"); }, [botId]); @@ -611,6 +625,11 @@ export function ShellPage() { const recordingSkill = activeTaughtSkills.find((skill) => skill.status === "recording") ?? null; const routeBotId = useRef(botId); routeBotId.current = botId; + const routePathname = useRef(location.pathname); + routePathname.current = location.pathname; + const routeOnHeute = useRef(onHeuteRoute); + routeOnHeute.current = onHeuteRoute; + const holdingHeuteRoute = () => routeOnHeute.current || isHeuteAppPath(routePathname.current); const routeGroupId = useRef(groupId); routeGroupId.current = groupId; const activeBotId = useRef(inGroup ? undefined : active?.id); @@ -745,6 +764,7 @@ export function ShellPage() { return; } if (!currentBotId || !list.some((bot) => bot.id === currentBotId)) { + if (holdingHeuteRoute()) return; navigate(firstThreadRoute(list, groupList, inProject), { replace: true }); } } finally { @@ -992,9 +1012,11 @@ export function ShellPage() { return; } if (inProject && !botId) { + if (holdingHeuteRoute()) return; navigate(firstThreadRoute(bootstrap.bots, groupList, true), { replace: true }); return; } + if (holdingHeuteRoute()) return; const selectedBotId = bootstrap.thread?.botId ?? bootstrap.bots[0]?.id; if (selectedBotId && selectedBotId !== botId) { navigate(`/app/${selectedBotId}`, { replace: true }); @@ -1580,6 +1602,9 @@ export function ShellPage() { : snapshot?.botId === active?.id ? snapshot : null; + const activeGroupMemberCount = + activeGroup?.members?.length ?? activeSnapshot?.members?.length ?? 0; + const multiOoInGroup = inGroup && isMultiOoGroupChat(activeGroupMemberCount); const activeReplyTarget = replyTarget && activeSnapshot?.messages.some((message) => message.id === replyTarget.id) ? replyTarget @@ -2796,6 +2821,7 @@ export function ShellPage() { ooSound.play("interact"); setActiveNavPillar("heute"); setMobileSidebarOpen(false); + navigate(HEUTE_APP_PATH); }} className={rosterRowClass(activeNavPillar === "heute")} > @@ -3213,10 +3239,17 @@ export function ShellPage() { {composerRunning || active.status === "running" ? t`Busy` : active.status} - ) : inGroup && projectSessionKey ? ( + ) : multiOoInGroup && projectSessionKey ? ( {t`Group conversation`} + ) : inGroup && + activeGroup?.kind === "main" && + activeGroupMemberCount <= 1 && + mainConversationEntry ? ( + + {t`1:1 with ${mainConversationEntry.bot.name}`} + ) : inGroup && activeGroup?.kind !== "main" ? ( {t`Project · sessions & folders`} diff --git a/apps/web/src/pages/shell/sidebar-toggle-buttons.tsx b/apps/web/src/pages/shell/sidebar-toggle-buttons.tsx index fdbd5f8..7186be5 100644 --- a/apps/web/src/pages/shell/sidebar-toggle-buttons.tsx +++ b/apps/web/src/pages/shell/sidebar-toggle-buttons.tsx @@ -20,6 +20,7 @@ export function SidebarToggleButtons({ {collapsed && desktopBridge() ? : null}