First-run onboarding UX: naming, locale, voices, Heute route, mobile drawer (#171)

* Fix first-run onboarding UX: naming, locale, voices, Heute route, mobile drawer

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix biome import order and formatting

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix biome formatting on OoStage and E2E specs

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix E2E specs for onboarding name step and Heute reload route

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix German onboarding heading matcher in E2E

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix German signup E2E, Escape menu stacking, and Heute rhythm click

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Make onboarding E2E helpers locale-aware for German UI

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix completeOnboarding language skip for Skip · locale label

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Use language-step Skip · locale button in completeOnboarding

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix German E2E: Nachricht composer, voice loading wait, Heute rhythm

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Stabilize Heute reload URL and German voice E2E assertions

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix Heute bootstrap redirect and German onboarding voices

When Kokoro lists no locale-matched voices, onboarding now uses offline
presets instead of showing English-only options. Shell bootstrap skips
bot redirects while the user is on /app/heute.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Relax German voice E2E name matching for voice labels

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Harden Heute route and German voice onboarding E2E

Rebased onto main with Expo SDK 57 typecheck fixes. Voice options expose
stable test ids; Shell keeps /app/heute across bootstrap and redirects
legacy /app/heute bot URLs. Heute coach E2E asserts URL after reload.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Format Shell after Heute route helper

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
cynarAI 2026-10-06 20:15:36 +02:00 committed by GitHub
parent ba89574c83
commit 0e1036b2ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 770 additions and 72 deletions

View File

@ -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" });

View File

@ -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();
});
});

View File

@ -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). */

View File

@ -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");

View File

@ -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.

View File

@ -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();

View File

@ -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();
});

View File

@ -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();

View File

@ -120,6 +120,10 @@ function SessionApp() {
}
/>
<Route path="/app" element={user ? <ShellPage /> : <Navigate to="/sign-in" replace />} />
<Route
path="/app/heute"
element={user ? <ShellPage /> : <Navigate to="/sign-in" replace />}
/>
<Route
path="/app/g/:groupId"
element={user ? <ShellPage /> : <Navigate to="/sign-in" replace />}

View File

@ -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 (
<div className="mx-auto max-w-xl" data-testid="onboarding-name-step">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
<Trans>Name your Oo</Trans>
</h2>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Something short and yours — or keep the default and rename later.</Trans>
</p>
<div className="mt-8 flex flex-col items-center gap-4 sm:flex-row sm:items-start">
<BotAvatar
color={config.avatarValue}
size={120}
identity="onboarding-name-preview"
pose={config.pose as EvooPose}
interactive
className="shrink-0"
/>
<div className="w-full min-w-0 flex-1">
<label htmlFor={inputId} className="sr-only">
<Trans>Oo name</Trans>
</label>
<input
id={inputId}
data-testid="onboarding-oo-name-input"
type="text"
maxLength={48}
autoComplete="off"
autoCapitalize="words"
placeholder={DEFAULT_FIRST_OO_NAME}
value={draft}
onChange={(event) => 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"
/>
<p className="mt-2 text-xs text-muted-foreground">
{t`Press Enter or Continue — skip keeps ${DEFAULT_FIRST_OO_NAME}.`}
</p>
</div>
</div>
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={onBack}>
<Trans>Back</Trans>
</Button>
<div className="flex gap-2">
<Button
variant="ghost"
onClick={() => {
onNameChange(DEFAULT_FIRST_OO_NAME);
onContinue();
}}
>
<Trans>Skip</Trans>
</Button>
<Button onClick={commitAndContinue}>
<Trans>Continue</Trans>
</Button>
</div>
</div>
</div>
);
}

View File

@ -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({
<button
key={voice.id}
type="button"
data-testid={`onboarding-voice-${voice.id}`}
onClick={() => onSelectedVoiceIdChange(voice.id)}
aria-pressed={selectedVoiceId === voice.id}
className={`rounded-xl border-2 px-3 py-3 text-left text-sm font-medium transition-all ${

View File

@ -12,6 +12,7 @@ import {
resolveBotPersona,
resolveTextStagePhase,
} from "../../lib/bot-persona";
import { isMultiOoGroupChat } from "../../lib/group-chat";
import {
extractGroupSpark,
type GroupStageMember,
@ -317,6 +318,7 @@ export function OoGroupStage({
[snapshot, speaker, enrichMember],
);
const roster = snapshot?.members ?? [];
const multiOo = isMultiOoGroupChat(roster.length);
const groupSpark = useMemo(
() => extractGroupSpark(snapshot, workingBots.length),
[snapshot, workingBots.length],
@ -405,7 +407,7 @@ export function OoGroupStage({
if (sparkLabel && groupSpark?.kind === "peer-reply") return sparkLabel;
if (awarenessLabel) return awarenessLabel;
if (sparkLabel) return sparkLabel;
if (!speaker) return t`Group conversation`;
if (!speaker) return multiOo ? t`Group conversation` : t`1:1 conversation`;
if (running && workingBots.length > 1) {
return t`${workingBots.length} Oos are working — ${speaker.name} has the floor`;
}
@ -429,8 +431,15 @@ export function OoGroupStage({
thinkingIndex,
thinkingLines,
workingBots.length,
multiOo,
]);
const headerSubtitle = multiOo
? t`Group conversation`
: speaker
? t`1:1 with ${speaker.name}`
: t`1:1 conversation`;
const energy = groupStageEnergy(phase, groupSpark, workingBots.length);
const phaseLabel = showEnsembleOnly
@ -454,7 +463,7 @@ export function OoGroupStage({
return (
<section
data-testid="oo-group-stage"
aria-label={t`Group conversation`}
aria-label={multiOo ? t`Group conversation` : t`1:1 conversation`}
className={`relative shrink-0 overflow-hidden border-b border-border/60 ${hasMessages ? "max-h-[28vh]" : ""}`}
>
<TextStageCanvas
@ -479,7 +488,7 @@ export function OoGroupStage({
<h2 className="mt-1.5 truncate text-ui-title font-semibold tracking-tight text-foreground">
{groupName ?? t`Group`}
</h2>
<p className="truncate text-ui-body-sm text-muted-foreground">{t`Group conversation`}</p>
<p className="truncate text-ui-body-sm text-muted-foreground">{headerSubtitle}</p>
</div>
{awarenessLabel && awarenessEvent && speaker ? (

View File

@ -0,0 +1,5 @@
export const HEUTE_APP_PATH = "/app/heute";
export function isHeuteAppPath(pathname: string): boolean {
return pathname === HEUTE_APP_PATH || pathname.endsWith(`${HEUTE_APP_PATH}`);
}

View File

@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { isMultiOoGroupChat } from "./group-chat.js";
describe("isMultiOoGroupChat", () => {
it("treats a single Oo as 1:1", () => {
expect(isMultiOoGroupChat(0)).toBe(false);
expect(isMultiOoGroupChat(1)).toBe(false);
});
it("treats two or more Oos as a group", () => {
expect(isMultiOoGroupChat(2)).toBe(true);
expect(isMultiOoGroupChat(5)).toBe(true);
});
});

View File

@ -0,0 +1,4 @@
/** True when the encounter has more than one Oo (group chat), not a 1:1 thread. */
export function isMultiOoGroupChat(memberCount: number): boolean {
return memberCount > 1;
}

View File

@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import {
defaultOnboardingVoiceId,
offlineOnboardingVoices,
onboardingVoicesFromList,
} from "./onboarding-voices.js";
describe("offlineOnboardingVoices", () => {
it("offers German voices for de", () => {
expect(offlineOnboardingVoices("de").map((voice) => voice.id)).toEqual([
"df_eva",
"dm_bernd",
"df_victoria",
"dm_martin",
]);
});
it("offers English voices for en", () => {
const ids = offlineOnboardingVoices("en").map((voice) => voice.id);
expect(ids).toContain("af_heart");
expect(ids).not.toContain("df_eva");
});
});
describe("onboardingVoicesFromList", () => {
it("uses offline German presets when the server only lists English voices", () => {
const englishOnly = [{ id: "af_alloy", label: "Alloy", description: "neutral" }];
expect(onboardingVoicesFromList(englishOnly, "de").map((voice) => voice.id)).toEqual([
"df_eva",
"dm_bernd",
"df_victoria",
"dm_martin",
]);
});
});
describe("defaultOnboardingVoiceId", () => {
it("prefers df_eva for German", () => {
expect(defaultOnboardingVoiceId("de", offlineOnboardingVoices("de"))).toBe("df_eva");
});
});

View File

@ -0,0 +1,53 @@
import type { VoiceInfo } from "@rakazo/contracts";
import { defaultVoiceForLocale, GERMAN_VOICE_PRESETS, voicesForLocale } from "@rakazo/core";
/** English presets when the local voice server is unreachable during onboarding. */
const ENGLISH_ONBOARDING_VOICES: VoiceInfo[] = [
{ id: "af_heart", label: "Heart", description: "warm" },
{ id: "af_bella", label: "Bella", description: "expressive" },
{ id: "af_sky", label: "Sky", description: "bright" },
{ id: "af_nova", label: "Nova", description: "energetic" },
{ id: "am_adam", label: "Adam", description: "clear" },
{ id: "am_michael", label: "Michael", description: "steady" },
];
const OFFLINE_CATALOG = [...GERMAN_VOICE_PRESETS, ...ENGLISH_ONBOARDING_VOICES];
const KOKORO_LANGUAGE_PREFIXES: Record<string, string> = {
de: "d",
en: "ab",
es: "e",
fr: "f",
hi: "h",
it: "i",
ja: "j",
pt: "p",
zh: "z",
};
function listedVoicesSpeakLocale(listed: VoiceInfo[], locale: string): boolean {
const language = locale.toLowerCase().split("-")[0] ?? "";
const prefixes = KOKORO_LANGUAGE_PREFIXES[language];
if (!prefixes) return listed.length > 0;
return listed.some((voice) => {
const prefix = /^([a-z])[fm]_/.exec(voice.id)?.[1];
return Boolean(prefix && prefixes.includes(prefix));
});
}
/** Locale-aware voice choices for onboarding when listing from the server fails. */
export function offlineOnboardingVoices(locale: string): VoiceInfo[] {
return voicesForLocale(OFFLINE_CATALOG, locale).slice(0, 6);
}
/** Prefer server voices that speak the UI language; otherwise use offline presets. */
export function onboardingVoicesFromList(listed: VoiceInfo[], locale: string): VoiceInfo[] {
if (listedVoicesSpeakLocale(listed, locale)) {
return voicesForLocale(listed, locale).slice(0, 6);
}
return offlineOnboardingVoices(locale);
}
export function defaultOnboardingVoiceId(locale: string, voices: VoiceInfo[]): string {
return defaultVoiceForLocale(voices, locale)?.id ?? "";
}

View File

@ -303,6 +303,13 @@ msgstr "1:1-Unterhaltung"
msgid "1:1 conversation with {title}"
msgstr "1:1-Unterhaltung mit {title}"
#. placeholder {0}: mainConversationEntry.bot.name
#. placeholder {0}: speaker.name
#: src/components/stage/OoStage.tsx
#: src/pages/Shell.tsx
msgid "1:1 with {0}"
msgstr "1:1 mit {0}"
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr "Über Gruppen"
@ -783,6 +790,7 @@ msgstr "Verfügbar, sobald die Routine gespeichert ist"
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -1423,6 +1431,7 @@ msgid "Context limit"
msgstr "Kontextlimit"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -3317,6 +3326,10 @@ msgstr "Gruppe benennen"
msgid "Name this routine"
msgstr "Routine benennen"
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Name your Oo"
msgstr "Gib deinem Oo einen Namen"
#: src/components/OoAtelier.tsx
msgid "Neck"
msgstr "Hals"
@ -3627,6 +3640,10 @@ msgstr "Nur leere Spaces können gelöscht werden. Lösche zuerst die Bots und G
msgid "Only file and folder names are read."
msgstr "Gelesen werden nur Datei- und Ordnernamen."
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr "Name des Oo"
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
@ -3833,6 +3850,10 @@ msgstr "Vorlieben"
msgid "Present"
msgstr "Anwesend"
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Press Enter or Continue — skip keeps {DEFAULT_FIRST_OO_NAME}."
msgstr "Enter oder Weiter — Überspringen behält Chief."
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
msgid "Preview {0}"
@ -4574,6 +4595,7 @@ msgid "Skills"
msgstr "Skills"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
@ -4581,9 +4603,10 @@ msgstr "Skills"
msgid "Skip"
msgstr "Überspringen"
#. placeholder {0}: UI_LOCALE_LABELS[activeUiLocale]
#: src/pages/Onboarding.tsx
msgid "Skip · English"
msgstr "Überspringen · Englisch"
msgid "Skip · {0}"
msgstr "Überspringen · {0}"
#: src/lib/localized-provider-hint.ts
msgid "Skip or deploy key"
@ -4614,6 +4637,10 @@ msgstr "Software-Update"
msgid "Something needs your attention."
msgstr "Etwas braucht deine Aufmerksamkeit."
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Something short and yours — or keep the default and rename later."
msgstr "Kurz und persönlich — oder Standard behalten und später umbenennen."
#: src/lib/voice-persona-copy.ts
msgid "Sorting priorities…"
msgstr "Sortiere die Prioritäten…"

View File

@ -303,6 +303,13 @@ msgstr "1:1 conversation"
msgid "1:1 conversation with {title}"
msgstr "1:1 conversation with {title}"
#. placeholder {0}: mainConversationEntry.bot.name
#. placeholder {0}: speaker.name
#: src/components/stage/OoStage.tsx
#: src/pages/Shell.tsx
msgid "1:1 with {0}"
msgstr "1:1 with {0}"
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr "About groups"
@ -783,6 +790,7 @@ msgstr "Available after the routine is saved"
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -1423,6 +1431,7 @@ msgid "Context limit"
msgstr "Context limit"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -3317,6 +3326,10 @@ msgstr "Name this group"
msgid "Name this routine"
msgstr "Name this routine"
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Name your Oo"
msgstr "Name your Oo"
#: src/components/OoAtelier.tsx
msgid "Neck"
msgstr "Neck"
@ -3627,6 +3640,10 @@ msgstr "Only empty spaces can be deleted. Delete its bots and groups first."
msgid "Only file and folder names are read."
msgstr "Only file and folder names are read."
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr "Oo name"
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
@ -3833,6 +3850,10 @@ msgstr "Preferences"
msgid "Present"
msgstr "Present"
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Press Enter or Continue — skip keeps {DEFAULT_FIRST_OO_NAME}."
msgstr "Press Enter or Continue — skip keeps {DEFAULT_FIRST_OO_NAME}."
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
msgid "Preview {0}"
@ -4574,6 +4595,7 @@ msgid "Skills"
msgstr "Skills"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
@ -4581,9 +4603,10 @@ msgstr "Skills"
msgid "Skip"
msgstr "Skip"
#. placeholder {0}: UI_LOCALE_LABELS[activeUiLocale]
#: src/pages/Onboarding.tsx
msgid "Skip · English"
msgstr "Skip · English"
msgid "Skip · {0}"
msgstr "Skip · {0}"
#: src/lib/localized-provider-hint.ts
msgid "Skip or deploy key"
@ -4614,6 +4637,10 @@ msgstr "Software update"
msgid "Something needs your attention."
msgstr "Something needs your attention."
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Something short and yours — or keep the default and rename later."
msgstr "Something short and yours — or keep the default and rename later."
#: src/lib/voice-persona-copy.ts
msgid "Sorting priorities…"
msgstr "Sorting priorities…"

View File

@ -303,6 +303,13 @@ msgstr ""
msgid "1:1 conversation with {title}"
msgstr ""
#. placeholder {0}: mainConversationEntry.bot.name
#. placeholder {0}: speaker.name
#: src/components/stage/OoStage.tsx
#: src/pages/Shell.tsx
msgid "1:1 with {0}"
msgstr ""
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr ""
@ -783,6 +790,7 @@ msgstr "Disponible después de guardar la rutina"
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -1423,6 +1431,7 @@ msgid "Context limit"
msgstr "Límite de contexto"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -3317,6 +3326,10 @@ msgstr "Nombra este grupo"
msgid "Name this routine"
msgstr "Nombra esta rutina"
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Name your Oo"
msgstr ""
#: src/components/OoAtelier.tsx
msgid "Neck"
msgstr ""
@ -3627,6 +3640,10 @@ msgstr ""
msgid "Only file and folder names are read."
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
@ -3833,6 +3850,10 @@ msgstr ""
msgid "Present"
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Press Enter or Continue — skip keeps {DEFAULT_FIRST_OO_NAME}."
msgstr ""
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
msgid "Preview {0}"
@ -4574,6 +4595,7 @@ msgid "Skills"
msgstr "Habilidades"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
@ -4581,8 +4603,9 @@ msgstr "Habilidades"
msgid "Skip"
msgstr "Omitir"
#. placeholder {0}: UI_LOCALE_LABELS[activeUiLocale]
#: src/pages/Onboarding.tsx
msgid "Skip · English"
msgid "Skip · {0}"
msgstr ""
#: src/lib/localized-provider-hint.ts
@ -4614,6 +4637,10 @@ msgstr "Actualización de software"
msgid "Something needs your attention."
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Something short and yours — or keep the default and rename later."
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Sorting priorities…"
msgstr ""

View File

@ -303,6 +303,13 @@ msgstr ""
msgid "1:1 conversation with {title}"
msgstr ""
#. placeholder {0}: mainConversationEntry.bot.name
#. placeholder {0}: speaker.name
#: src/components/stage/OoStage.tsx
#: src/pages/Shell.tsx
msgid "1:1 with {0}"
msgstr ""
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr ""
@ -783,6 +790,7 @@ msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -1423,6 +1431,7 @@ msgid "Context limit"
msgstr "कॉन्टेक्स्ट सीमा"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -3317,6 +3326,10 @@ msgstr "इस समूह को नाम दें"
msgid "Name this routine"
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Name your Oo"
msgstr ""
#: src/components/OoAtelier.tsx
msgid "Neck"
msgstr ""
@ -3627,6 +3640,10 @@ msgstr ""
msgid "Only file and folder names are read."
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
@ -3833,6 +3850,10 @@ msgstr ""
msgid "Present"
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Press Enter or Continue — skip keeps {DEFAULT_FIRST_OO_NAME}."
msgstr ""
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
msgid "Preview {0}"
@ -4574,6 +4595,7 @@ msgid "Skills"
msgstr "स्किल्स"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
@ -4581,8 +4603,9 @@ msgstr "स्किल्स"
msgid "Skip"
msgstr "छोड़ें"
#. placeholder {0}: UI_LOCALE_LABELS[activeUiLocale]
#: src/pages/Onboarding.tsx
msgid "Skip · English"
msgid "Skip · {0}"
msgstr ""
#: src/lib/localized-provider-hint.ts
@ -4614,6 +4637,10 @@ msgstr ""
msgid "Something needs your attention."
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Something short and yours — or keep the default and rename later."
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Sorting priorities…"
msgstr ""

View File

@ -303,6 +303,13 @@ msgstr ""
msgid "1:1 conversation with {title}"
msgstr ""
#. placeholder {0}: mainConversationEntry.bot.name
#. placeholder {0}: speaker.name
#: src/components/stage/OoStage.tsx
#: src/pages/Shell.tsx
msgid "1:1 with {0}"
msgstr ""
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr ""
@ -783,6 +790,7 @@ msgstr "루틴을 저장한 후 사용할 수 있습니다"
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -1423,6 +1431,7 @@ msgid "Context limit"
msgstr "컨텍스트 한도"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -3317,6 +3326,10 @@ msgstr "그룹 이름"
msgid "Name this routine"
msgstr "이 루틴의 이름을 정하세요"
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Name your Oo"
msgstr ""
#: src/components/OoAtelier.tsx
msgid "Neck"
msgstr ""
@ -3627,6 +3640,10 @@ msgstr ""
msgid "Only file and folder names are read."
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
@ -3833,6 +3850,10 @@ msgstr ""
msgid "Present"
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Press Enter or Continue — skip keeps {DEFAULT_FIRST_OO_NAME}."
msgstr ""
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
msgid "Preview {0}"
@ -4574,6 +4595,7 @@ msgid "Skills"
msgstr "스킬"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
@ -4581,8 +4603,9 @@ msgstr "스킬"
msgid "Skip"
msgstr "건너뛰기"
#. placeholder {0}: UI_LOCALE_LABELS[activeUiLocale]
#: src/pages/Onboarding.tsx
msgid "Skip · English"
msgid "Skip · {0}"
msgstr ""
#: src/lib/localized-provider-hint.ts
@ -4614,6 +4637,10 @@ msgstr "소프트웨어 업데이트"
msgid "Something needs your attention."
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Something short and yours — or keep the default and rename later."
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Sorting priorities…"
msgstr ""

View File

@ -303,6 +303,13 @@ msgstr ""
msgid "1:1 conversation with {title}"
msgstr ""
#. placeholder {0}: mainConversationEntry.bot.name
#. placeholder {0}: speaker.name
#: src/components/stage/OoStage.tsx
#: src/pages/Shell.tsx
msgid "1:1 with {0}"
msgstr ""
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr ""
@ -783,6 +790,7 @@ msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -1423,6 +1431,7 @@ msgid "Context limit"
msgstr "Limite de contexto"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -3317,6 +3326,10 @@ msgstr "Dar nome ao grupo"
msgid "Name this routine"
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Name your Oo"
msgstr ""
#: src/components/OoAtelier.tsx
msgid "Neck"
msgstr ""
@ -3627,6 +3640,10 @@ msgstr ""
msgid "Only file and folder names are read."
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
@ -3833,6 +3850,10 @@ msgstr ""
msgid "Present"
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Press Enter or Continue — skip keeps {DEFAULT_FIRST_OO_NAME}."
msgstr ""
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
msgid "Preview {0}"
@ -4574,6 +4595,7 @@ msgid "Skills"
msgstr "Habilidades"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
@ -4581,8 +4603,9 @@ msgstr "Habilidades"
msgid "Skip"
msgstr "Pular"
#. placeholder {0}: UI_LOCALE_LABELS[activeUiLocale]
#: src/pages/Onboarding.tsx
msgid "Skip · English"
msgid "Skip · {0}"
msgstr ""
#: src/lib/localized-provider-hint.ts
@ -4614,6 +4637,10 @@ msgstr ""
msgid "Something needs your attention."
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Something short and yours — or keep the default and rename later."
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Sorting priorities…"
msgstr ""

View File

@ -303,6 +303,13 @@ msgstr ""
msgid "1:1 conversation with {title}"
msgstr ""
#. placeholder {0}: mainConversationEntry.bot.name
#. placeholder {0}: speaker.name
#: src/components/stage/OoStage.tsx
#: src/pages/Shell.tsx
msgid "1:1 with {0}"
msgstr ""
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr "О группах"
@ -783,6 +790,7 @@ msgstr "Доступно после сохранения задачи"
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -1423,6 +1431,7 @@ msgid "Context limit"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -3317,6 +3326,10 @@ msgstr "Назовите эту группу"
msgid "Name this routine"
msgstr "Назовите эту задачу"
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Name your Oo"
msgstr ""
#: src/components/OoAtelier.tsx
msgid "Neck"
msgstr ""
@ -3627,6 +3640,10 @@ msgstr "Удалить можно только пустые пространст
msgid "Only file and folder names are read."
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
@ -3833,6 +3850,10 @@ msgstr ""
msgid "Present"
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Press Enter or Continue — skip keeps {DEFAULT_FIRST_OO_NAME}."
msgstr ""
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
msgid "Preview {0}"
@ -4574,6 +4595,7 @@ msgid "Skills"
msgstr "Навыки"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
@ -4581,8 +4603,9 @@ msgstr "Навыки"
msgid "Skip"
msgstr "Пропустить"
#. placeholder {0}: UI_LOCALE_LABELS[activeUiLocale]
#: src/pages/Onboarding.tsx
msgid "Skip · English"
msgid "Skip · {0}"
msgstr ""
#: src/lib/localized-provider-hint.ts
@ -4614,6 +4637,10 @@ msgstr "Обновление программного обеспечения"
msgid "Something needs your attention."
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Something short and yours — or keep the default and rename later."
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Sorting priorities…"
msgstr ""

View File

@ -303,6 +303,13 @@ msgstr ""
msgid "1:1 conversation with {title}"
msgstr ""
#. placeholder {0}: mainConversationEntry.bot.name
#. placeholder {0}: speaker.name
#: src/components/stage/OoStage.tsx
#: src/pages/Shell.tsx
msgid "1:1 with {0}"
msgstr ""
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr ""
@ -783,6 +790,7 @@ msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -1423,6 +1431,7 @@ msgid "Context limit"
msgstr "Bağlam sınırı"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -3317,6 +3326,10 @@ msgstr "Gruba bir ad ver"
msgid "Name this routine"
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Name your Oo"
msgstr ""
#: src/components/OoAtelier.tsx
msgid "Neck"
msgstr ""
@ -3627,6 +3640,10 @@ msgstr ""
msgid "Only file and folder names are read."
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
@ -3833,6 +3850,10 @@ msgstr ""
msgid "Present"
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Press Enter or Continue — skip keeps {DEFAULT_FIRST_OO_NAME}."
msgstr ""
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
msgid "Preview {0}"
@ -4574,6 +4595,7 @@ msgid "Skills"
msgstr "Beceriler"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
@ -4581,8 +4603,9 @@ msgstr "Beceriler"
msgid "Skip"
msgstr "Atla"
#. placeholder {0}: UI_LOCALE_LABELS[activeUiLocale]
#: src/pages/Onboarding.tsx
msgid "Skip · English"
msgid "Skip · {0}"
msgstr ""
#: src/lib/localized-provider-hint.ts
@ -4614,6 +4637,10 @@ msgstr ""
msgid "Something needs your attention."
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Something short and yours — or keep the default and rename later."
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Sorting priorities…"
msgstr ""

View File

@ -303,6 +303,13 @@ msgstr ""
msgid "1:1 conversation with {title}"
msgstr ""
#. placeholder {0}: mainConversationEntry.bot.name
#. placeholder {0}: speaker.name
#: src/components/stage/OoStage.tsx
#: src/pages/Shell.tsx
msgid "1:1 with {0}"
msgstr ""
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr ""
@ -783,6 +790,7 @@ msgstr "保存例行任务后可用"
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -1423,6 +1431,7 @@ msgid "Context limit"
msgstr "上下文限制"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -3317,6 +3326,10 @@ msgstr "给这个群组命名"
msgid "Name this routine"
msgstr "给这个例行任务命名"
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Name your Oo"
msgstr ""
#: src/components/OoAtelier.tsx
msgid "Neck"
msgstr ""
@ -3627,6 +3640,10 @@ msgstr ""
msgid "Only file and folder names are read."
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
@ -3833,6 +3850,10 @@ msgstr ""
msgid "Present"
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Press Enter or Continue — skip keeps {DEFAULT_FIRST_OO_NAME}."
msgstr ""
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
msgid "Preview {0}"
@ -4574,6 +4595,7 @@ msgid "Skills"
msgstr "技能"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
@ -4581,8 +4603,9 @@ msgstr "技能"
msgid "Skip"
msgstr "跳过"
#. placeholder {0}: UI_LOCALE_LABELS[activeUiLocale]
#: src/pages/Onboarding.tsx
msgid "Skip · English"
msgid "Skip · {0}"
msgstr ""
#: src/lib/localized-provider-hint.ts
@ -4614,6 +4637,10 @@ msgstr "软件更新"
msgid "Something needs your attention."
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Something short and yours — or keep the default and rename later."
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Sorting priorities…"
msgstr ""

View File

@ -5,6 +5,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { IntegrationSetup } from "../components/integrations/IntegrationSetup";
import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell";
import {
DEFAULT_FIRST_OO_NAME,
OnboardingNameStep,
} from "../components/onboarding/OnboardingNameStep";
import {
type OnboardingEmojiConfig,
OnboardingOoDesigner,
@ -23,7 +27,12 @@ import {
import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories";
import { onboardingProfileUpdate } from "../lib/onboarding-profile";
import { rpc, selectSpace } from "../lib/rpc";
import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale";
import {
persistUiLocale,
resolveUiLocale,
UI_LOCALE_LABELS,
type UiLocale,
} from "../lib/ui-locale";
import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest";
/** Selects the main conversation's space and returns its route. */
@ -44,8 +53,9 @@ export function OnboardingPage() {
const { t } = useLingui();
const navigate = useNavigate();
const [step, setStep] = useState<
"loading" | "emoji" | "language" | "voice" | "integrations" | "scan" | "bot"
"loading" | "emoji" | "name" | "language" | "voice" | "integrations" | "scan" | "bot"
>("emoji");
const [ooName, setOoName] = useState(DEFAULT_FIRST_OO_NAME);
const [selectedVoiceId, setSelectedVoiceId] = useState("");
const [voiceSkipped, setVoiceSkipped] = useState(false);
const [integrationSetup, setIntegrationSetup] = useState<IntegrationSetupState | null>(null);
@ -57,14 +67,9 @@ export function OnboardingPage() {
const [crewReady, setCrewReady] = useState(false);
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
const [currentLocale, setCurrentLocale] = useState<string>(() => {
try {
return localStorage.getItem("rakazo.uiLocale") ?? "en";
} catch {
return "en";
}
});
const [currentLocale, setCurrentLocale] = useState<UiLocale>(() => resolveUiLocale());
const [languageChoice, setLanguageChoice] = useState<UiLocale | null>(null);
const activeUiLocale = languageChoice ?? currentLocale;
useEffect(() => {
let cancelled = false;
@ -110,7 +115,7 @@ export function OnboardingPage() {
const discoveryPhase: OnboardingDiscoveryPhase =
step === "loading"
? "loading"
: step === "emoji" || step === "language" || step === "voice"
: step === "emoji" || step === "name" || step === "language" || step === "voice"
? "emoji"
: step === "integrations"
? "integrations"
@ -145,7 +150,10 @@ export function OnboardingPage() {
setError(null);
try {
const bot = await rpc.bots.ensureMain();
await rpc.bots.update({ botId: bot.id, color: emojiConfig.avatarValue }).catch(() => null);
const trimmedName = ooName.trim() || DEFAULT_FIRST_OO_NAME;
await rpc.bots
.update({ botId: bot.id, color: emojiConfig.avatarValue, name: trimmedName })
.catch(() => null);
setCrewReady(true);
await rpc.profile
@ -213,7 +221,11 @@ export function OnboardingPage() {
<OnboardingDiscoveryShell
items={discoveryItems}
compact={
step === "emoji" || step === "language" || step === "voice" || step === "integrations"
step === "emoji" ||
step === "name" ||
step === "language" ||
step === "voice" ||
step === "integrations"
}
>
{error && step !== "bot" ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
@ -222,6 +234,16 @@ export function OnboardingPage() {
<OnboardingOoDesigner
config={emojiConfig}
onChange={setEmojiConfig}
onContinue={() => setStep("name")}
/>
) : null}
{step === "name" ? (
<OnboardingNameStep
config={emojiConfig}
name={ooName}
onNameChange={setOoName}
onBack={() => setStep("emoji")}
onContinue={() => setStep("language")}
/>
) : null}
@ -241,7 +263,7 @@ export function OnboardingPage() {
type="button"
onClick={() => {
persistUiLocale(code as UiLocale);
setCurrentLocale(code);
setCurrentLocale(code as UiLocale);
setLanguageChoice(code as UiLocale);
setStep("voice");
}}
@ -256,11 +278,18 @@ export function OnboardingPage() {
))}
</div>
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
<Button variant="ghost" size="sm" onClick={() => setStep("name")}>
<Trans>Back</Trans>
</Button>
<Button onClick={() => setStep("voice")}>
<Trans>Skip · English</Trans>
<Button
onClick={() => {
const locale = resolveUiLocale();
setLanguageChoice(locale);
setCurrentLocale(locale);
setStep("voice");
}}
>
{t`Skip · ${UI_LOCALE_LABELS[activeUiLocale]}`}
</Button>
</div>
</div>
@ -268,6 +297,7 @@ export function OnboardingPage() {
{step === "voice" ? (
<OnboardingVoiceStep
uiLocale={activeUiLocale}
selectedVoiceId={selectedVoiceId}
onSelectedVoiceIdChange={setSelectedVoiceId}
onBack={() => setStep("language")}

View File

@ -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<NavPillar>(() => {
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<string | undefined>(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<string | undefined>(groupId);
routeGroupId.current = groupId;
const activeBotId = useRef<string | undefined>(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() {
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
{composerRunning || active.status === "running" ? t`Busy` : active.status}
</span>
) : inGroup && projectSessionKey ? (
) : multiOoInGroup && projectSessionKey ? (
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
{t`Group conversation`}
</span>
) : inGroup &&
activeGroup?.kind === "main" &&
activeGroupMemberCount <= 1 &&
mainConversationEntry ? (
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
{t`1:1 with ${mainConversationEntry.bot.name}`}
</span>
) : inGroup && activeGroup?.kind !== "main" ? (
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
{t`Project · sessions & folders`}

View File

@ -20,6 +20,7 @@ export function SidebarToggleButtons({
{collapsed && desktopBridge() ? <WindowChrome /> : null}
<button
type="button"
data-testid="open-navigation"
aria-label={t`Open navigation`}
onClick={onOpenNavigation}
className="app-no-drag grid size-control-lg shrink-0 place-items-center rounded-lg text-foreground/75 hover:bg-accent md:hidden"

View File

@ -17,6 +17,20 @@ export function useMobileSidebar() {
return () => desktop.removeEventListener("change", closeMobileSidebar);
}, []);
useEffect(() => {
if (!mobileSidebarOpen) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape" || window.matchMedia("(min-width: 768px)").matches) return;
event.preventDefault();
setMobileSidebarOpen(false);
queueMicrotask(() => {
document.querySelector<HTMLElement>('[data-testid="open-navigation"]')?.focus();
});
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [mobileSidebarOpen]);
function onMobileSidebarTouchStart(event: React.TouchEvent<HTMLDivElement>) {
if (
mobileSidebarOpen ||