Compare commits

..

No commits in common. "490e2fefe856ffc2eec466cd9dcf5fcfabb40e7f" and "a9a251b80ad01fa5ffcb4844cb412828ad5a5ab9" have entirely different histories.

9 changed files with 42 additions and 127 deletions

View File

@ -307,10 +307,6 @@ 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

@ -5,15 +5,12 @@ 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 page.getByRole("button", { name: "Continue", exact: true }).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 page.getByTestId("onboarding-name-step").getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /Skip/i }).click();
await page.getByRole("button", { name: "Skip", exact: true }).click();
await expect(page).toHaveURL(/\/app\/g\/[^/]+/, { timeout: 30_000 });
await expect(
page.getByTestId("bot-settings-trigger").getByText("Luna", { exact: true }),
@ -28,8 +25,8 @@ test("German UI keeps Deutsch on language skip label", async ({ page }) => {
});
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 page.getByRole("button", { name: "Continue", exact: true }).click();
await page.getByRole("button", { name: "Skip", exact: true }).click();
await expect(
page.getByRole("heading", { name: /Wähle deine Sprache|Choose your language/i }),
).toBeVisible();
@ -43,16 +40,15 @@ test("German voice step lists German voices after choosing Deutsch", async ({ pa
});
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: "Continue", exact: true }).click();
await page.getByRole("button", { name: "Skip", exact: true }).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");
await expect(page.getByRole("button", { name: "Eva", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Alloy", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Eva", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
});
test("main conversation does not show a group subtitle for one Oo", async ({ page }) => {

View File

@ -87,14 +87,10 @@ 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();
await page.getByRole("button", { name: "Skip", exact: true }).click();
}
/**
@ -117,15 +113,11 @@ export async function completeOnboarding(
);
}
const design = page.getByRole("heading", { name: /Design your Oo|Gestalte dein Oo/i });
const design = page.getByRole("heading", { name: /Design your 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 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 expectedOoName = options.ooName ?? "Chief";
const chief = page.getByText(expectedOoName).first();
@ -135,30 +127,30 @@ export async function completeOnboarding(
await expect(entry.first()).toBeVisible({ timeout: 20_000 });
if (await design.isVisible()) {
await page.getByRole("button", { name: onboardingContinueButton }).click();
await page.getByRole("button", { name: "Continue", exact: true }).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();
await naming.getByRole("button", { name: "Continue", exact: true }).click();
} else {
await page.getByRole("button", { name: onboardingSkipButton }).click();
await page.getByRole("button", { name: "Skip", exact: true }).click();
}
await expect(language).toBeVisible();
await page.getByRole("button", { name: onboardingLanguageSkipButton }).click();
await page.getByRole("button", { name: /Skip/i }).click();
await expect(voice).toBeVisible();
await page.getByRole("button", { name: onboardingSkipButton }).click();
await page.getByRole("button", { name: "Skip", exact: true }).click();
}
if (await visible(integrations, 8_000)) {
if (testInfo) await captureScreenshot(page, testInfo, "02-connect-apps");
await page.getByRole("button", { name: onboardingSkipButton }).click();
await page.getByRole("button", { name: "Skip", exact: true }).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: onboardingSkipButton });
.getByRole("button", { name: "Skip", exact: true });
await expect(async () => {
if (await skipScan.isVisible()) await skipScan.click();
expect(page.url()).toMatch(/\/app\/g\/[^/]+$/);
@ -194,17 +186,15 @@ export async function signup(
) {
await expect(async () => {
await page.goto("/sign-up", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: /Create your evOo|Dein evOo erstellen/i }),
).toBeVisible({
await expect(page.getByRole("heading", { name: "Create your evOo" })).toBeVisible({
timeout: 5_000,
});
}).toPass({ timeout: 45_000 });
if (testInfo) await captureScreenshot(page, testInfo, "01-sign-up");
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();
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();
}
export async function captureScreenshot(page: Page, testInfo: TestInfo, name: string) {
@ -299,7 +289,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$|^Nachricht$/i })).toBeVisible();
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
}
/** Create a named bot via RPC for test setup (skips the + picker). */

View File

@ -84,21 +84,13 @@ 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({ waitUntil: "domcontentloaded" });
await page.reload();
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.
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;
await page.getByTestId("heute-rhythm").click();
const panel = page.getByTestId("heute-rhythm-panel");
await expect(panel).toBeVisible();
const morning = panel.getByTestId("heute-rhythm-morning");

View File

@ -1,12 +1,9 @@
import { Trans } from "@lingui/react/macro";
import type { VoiceInfo } from "@rakazo/contracts";
import { 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 { defaultOnboardingVoiceId, offlineOnboardingVoices } from "../../lib/onboarding-voices";
import { rpc } from "../../lib/rpc";
import type { UiLocale } from "../../lib/ui-locale";
@ -42,7 +39,7 @@ 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 ordered = onboardingVoicesFromList(listed, uiLocale);
const ordered = voicesForLocale(listed, uiLocale).slice(0, 6);
setVoiceOptions(ordered);
const nextId = defaultOnboardingVoiceId(uiLocale, ordered);
if (nextId) onSelectedVoiceIdChange(nextId);
@ -92,7 +89,6 @@ 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

@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
defaultOnboardingVoiceId,
offlineOnboardingVoices,
onboardingVoicesFromList,
} from "./onboarding-voices.js";
import { defaultOnboardingVoiceId, offlineOnboardingVoices } from "./onboarding-voices.js";
describe("offlineOnboardingVoices", () => {
it("offers German voices for de", () => {
@ -22,18 +18,6 @@ describe("offlineOnboardingVoices", () => {
});
});
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

@ -13,41 +13,11 @@ const ENGLISH_ONBOARDING_VOICES: VoiceInfo[] = [
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

@ -273,11 +273,6 @@ export function ShellPage() {
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";
@ -625,11 +620,8 @@ 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);
@ -764,7 +756,7 @@ export function ShellPage() {
return;
}
if (!currentBotId || !list.some((bot) => bot.id === currentBotId)) {
if (holdingHeuteRoute()) return;
if (routeOnHeute.current) return;
navigate(firstThreadRoute(list, groupList, inProject), { replace: true });
}
} finally {
@ -1012,11 +1004,9 @@ 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 });

View File

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