feat(web): complete German, English sources and catalogs that cannot go stale (#111)

* fix(web): use English Lingui sources for German UI text

Several web surfaces hard-coded German (the project card, roster and
activity status labels, the sidebar "Heute" label) or used German text as
the Lingui source message (sidebar sections, project timeline, bot picker,
voice entry, group stage, copy and read-aloud buttons). English users saw
German, and because production builds keep only message ids, none of these
strings could be translated per locale.

Convert them to English source messages, keeping the meaning:
- ProjectCreatedCard: Lingui Trans/Plural instead of literals.
- agent-activity: labels go through i18n._ with literal ids so the module
  stays importable from plain unit tests; the extractor still collects them.
- Shell, ProjectSessionTimeline, bot-picker, StageVoiceEntry, OoStage:
  English sources, reusing existing messages where one already fit
  ("just now", "{n}m ago", "Search", "No bots match", "Set up voice",
  "Group conversation", "Transcript").
- The conversation picker matches its create entry against the translated
  label instead of a fixed German string.

The German wording returns through the de catalog in the next commit.
Unit and E2E tests now assert the English labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* feat(web): keep Lingui catalogs complete and in sync

The committed catalogs were last extracted weeks ago: about 330 source
messages were missing from every locale, and production builds keep only
message ids, so those strings rendered as hash ids (sidebar sections,
privacy settings, profile, timeline, ...). German also had 226 empty
entries, and every extraction rewrote the files because references carried
line numbers.

- lingui.config.ts: po references without line numbers, and a stable sort
  that breaks Lingui's ordering ties (explicit ids, equal text) by id, so
  the output no longer depends on file-system order.
- Re-extract all catalogs with --clean (drops 25-44 obsolete entries per
  locale; no existing translation changed).
- Translate every German entry (du form, consistent with the existing
  catalog). Other locales keep empty entries and fall back to English.
- i18n-catalog-sync.test.ts extracts into a copy of the catalogs and fails
  when the committed files differ or a German entry is empty, so stale
  catalogs fail the unit test job (offline, about 5 s).

Adds @lingui/format-po (already installed through @lingui/cli) as a direct
dev dependency to configure the formatter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* test(web): find the sidebar Today button by its English label

The shell contrast test added on main looks up the sidebar button by the
old German source text, which this branch replaces with "Today".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* chore(web): extract catalogs and translate the in-app browser strings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* test: look up the home main conversation in the scheduling journey

Since every project has its own main conversation, the journey that
schedules for a specialist found the user's main conversation with
findFirst over all of them. With the Lab project's conversation also
matching, it picked either one, so the check failed about one run in
three. The lookup now names the home space, where the specialist joined
and the routine is created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-04 10:04:21 +02:00 committed by GitHub
parent ece3638ce7
commit 0c3e3f3d29
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 22311 additions and 12470 deletions

View File

@ -253,7 +253,7 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true }); await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true });
}); });
await sidebar.getByRole("button", { name: "Heute", exact: true }).click(); await sidebar.getByRole("button", { name: "Today", exact: true }).click();
const heute = page.getByTestId("heute-brief"); const heute = page.getByTestId("heute-brief");
await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible(); await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible();
await inEachTheme(page, async (theme) => { await inEachTheme(page, async (theme) => {

View File

@ -6,7 +6,7 @@ test("Heute shows native brief instead of Mission Control iframe", async ({ page
await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute"); await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute");
await completeOnboarding(page); await completeOnboarding(page);
await page.getByRole("button", { name: "Heute", exact: true }).click(); await page.getByRole("button", { name: "Today", exact: true }).click();
await expect(page).toHaveURL(/\/app\/[^/]+$/); await expect(page).toHaveURL(/\/app\/[^/]+$/);
const brief = page.getByTestId("heute-brief"); const brief = page.getByTestId("heute-brief");

View File

@ -30,7 +30,7 @@ test("a new project opens straight into the conversation with its own main agent
name: "Chief, main conversation", name: "Chief, main conversation",
}); });
await expect(projectEntry).toBeVisible({ timeout: 30_000 }); await expect(projectEntry).toBeVisible({ timeout: 30_000 });
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toHaveCount(0); await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
const groupId = new URL(page.url()).pathname.split("/").at(-1)!; const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
const snapshot = await rpc<{ leadBotId?: string }>(page, "threads/get", { groupId }); const snapshot = await rpc<{ leadBotId?: string }>(page, "threads/get", { groupId });
expect(snapshot.leadBotId).toBeTruthy(); expect(snapshot.leadBotId).toBeTruthy();

View File

@ -21,20 +21,19 @@ test("sidebar has expected navigation items and no integrations", async ({ page
// Actual sidebar structure (verified by snapshot): // Actual sidebar structure (verified by snapshot):
// - button "Activity" (top action) // - button "Activity" (top action)
// - textbox "Search" (search input) // - textbox "Search" (search input)
// - button "Heute" (today) // - button "Today"
// - text "Sprich mit deinen Oo's" (conversations section header) // - text "Talk to your Oos" (conversations section header)
// - NO "Integration(en)" button or text // - NO "Integration(en)" button or text
// Search: it's a textbox, not a button // Search: it's a textbox, not a button
const searchInput = sidebar.locator('input[placeholder="Search"], textbox[name="Search"]'); const searchInput = sidebar.locator('input[placeholder="Search"], textbox[name="Search"]');
await expect(searchInput).toBeVisible({ timeout: 5000 }); await expect(searchInput).toBeVisible({ timeout: 5000 });
// "Heute" (Today) const todayBtn = sidebar.getByRole("button", { name: "Today", exact: true });
const todayBtn = sidebar.getByRole("button", { name: "Heute", exact: true });
await expect(todayBtn).toBeVisible({ timeout: 5000 }); await expect(todayBtn).toBeVisible({ timeout: 5000 });
// "Sprich mit deinen Oo's" is plain text, not a button // "Talk to your Oos" is plain text, not a button
const conversationsText = sidebar.getByText("Sprich mit deinen Oo's"); const conversationsText = sidebar.getByText("Talk to your Oos");
await expect(conversationsText).toBeVisible({ timeout: 5000 }); await expect(conversationsText).toBeVisible({ timeout: 5000 });
// No "Integration(en)" button // No "Integration(en)" button

View File

@ -41,8 +41,8 @@ test("app loads without React hook errors", async ({ page }) => {
bodyText.includes("Sign in") || bodyText.includes("Sign in") ||
bodyText.includes("Anmelden") || bodyText.includes("Anmelden") ||
bodyText.includes("EvOo") || bodyText.includes("EvOo") ||
bodyText.includes("Projekte") || bodyText.includes("Projects") ||
bodyText.includes("Heute") || bodyText.includes("Today") ||
bodyText.includes("Team"); bodyText.includes("Team");
expect(hasContent).toBe(true); expect(hasContent).toBe(true);

View File

@ -16,7 +16,7 @@ function projectSpace(projekte: Locator, name: string) {
}); });
} }
test("Projekte + opens new project dialog instead of group form", async ({ page }, testInfo) => { test("Projects + opens new project dialog instead of group form", async ({ page }, testInfo) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create"); await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create");
await completeOnboarding(page); await completeOnboarding(page);
@ -135,7 +135,7 @@ test("spaces stay invisible by default and chat creation requires approval", asy
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible(); await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toHaveCount(0); await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
await captureScreenshot(page, testInfo, "spaces-sidebar"); await captureScreenshot(page, testInfo, "spaces-sidebar");
await page.evaluate((id) => { await page.evaluate((id) => {
@ -171,7 +171,7 @@ test("a new space opens its main conversation and can be deleted from its menu",
await expect( await expect(
projectSpace(projekte, "Temporary").getByRole("button", { name: "Chief, main conversation" }), projectSpace(projekte, "Temporary").getByRole("button", { name: "Chief, main conversation" }),
).toBeVisible(); ).toBeVisible();
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toHaveCount(0); await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
expect(await rpc<Array<{ kind: string }>>(page, "bots/list", {})).toEqual([ expect(await rpc<Array<{ kind: string }>>(page, "bots/list", {})).toEqual([
expect.objectContaining({ kind: "main" }), expect.objectContaining({ kind: "main" }),
]); ]);

View File

@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { completeOnboarding, openNewBot, signup, unterhaltungenOnly } from "./helpers"; import { completeOnboarding, openNewBot, signup, unterhaltungenOnly } from "./helpers";
test("Unterhaltungen and Projekte stay separate; + opens bot picker", async ({ page }) => { test("conversations and projects stay separate; + opens bot picker", async ({ page }) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `unterhaltungen-${stamp}@rakazo.test`, "password12", "Unterhaltungen UX"); await signup(page, `unterhaltungen-${stamp}@rakazo.test`, "password12", "Unterhaltungen UX");
await completeOnboarding(page); await completeOnboarding(page);
@ -11,8 +11,8 @@ test("Unterhaltungen and Projekte stay separate; + opens bot picker", async ({ p
const unterhaltungen = page.locator(".sidebar-unterhaltungen"); const unterhaltungen = page.locator(".sidebar-unterhaltungen");
const projekte = page.locator(".sidebar-projekte"); const projekte = page.locator(".sidebar-projekte");
await expect(unterhaltungen.getByText("Sprich mit deinen Oo's", { exact: true })).toBeVisible(); await expect(unterhaltungen.getByText("Talk to your Oos", { exact: true })).toBeVisible();
await expect(projekte.getByText("Projekte", { exact: true })).toBeVisible(); await expect(projekte.getByText("Projects", { exact: true })).toBeVisible();
await expect(unterhaltungen.getByRole("button", { name: /^Chief/ })).toBeVisible(); await expect(unterhaltungen.getByRole("button", { name: /^Chief/ })).toBeVisible();
await unterhaltungen.getByTestId("conversation-picker-trigger").click(); await unterhaltungen.getByTestId("conversation-picker-trigger").click();

View File

@ -1,4 +1,28 @@
import type { CatalogFormatter, CatalogType } from "@lingui/conf";
import { defineConfig } from "@lingui/conf"; import { defineConfig } from "@lingui/conf";
import { formatter } from "@lingui/format-po";
// File references without line numbers, so catalogs change only when messages change.
const po = formatter({ lineNumbers: false });
const collator = new Intl.Collator("en-US");
// Lingui orders entries by source text but leaves ties (explicit ids, equal text) in the order the
// files were found, which differs between file systems. Break ties by id so output is stable.
function sortCatalog(catalog: CatalogType): CatalogType {
return Object.fromEntries(
Object.entries(catalog).sort(
([aId, a], [bId, b]) =>
collator.compare(a.message ?? aId, b.message ?? bId) ||
collator.compare(a.context ?? "", b.context ?? "") ||
(aId < bId ? -1 : aId > bId ? 1 : 0),
),
);
}
const stablePo: CatalogFormatter = {
...po,
serialize: (catalog, ctx) => po.serialize(sortCatalog(catalog), ctx),
};
export default defineConfig({ export default defineConfig({
sourceLocale: "en", sourceLocale: "en",
@ -10,5 +34,6 @@ export default defineConfig({
exclude: ["**/locales/**", "**/*.test.*"], exclude: ["**/locales/**", "**/*.test.*"],
}, },
], ],
format: stablePo,
compileNamespace: "es", compileNamespace: "es",
}); });

View File

@ -36,6 +36,7 @@
"@lingui/babel-plugin-lingui-macro": "6.6.0", "@lingui/babel-plugin-lingui-macro": "6.6.0",
"@lingui/cli": "6.6.0", "@lingui/cli": "6.6.0",
"@lingui/conf": "6.6.0", "@lingui/conf": "6.6.0",
"@lingui/format-po": "6.6.0",
"@lingui/vite-plugin": "6.6.0", "@lingui/vite-plugin": "6.6.0",
"@playwright/test": "1.63.0", "@playwright/test": "1.63.0",
"@rolldown/plugin-babel": "0.2.4", "@rolldown/plugin-babel": "0.2.4",

View File

@ -1,3 +1,4 @@
import { Plural, Trans } from "@lingui/react/macro";
import { FolderKanban } from "lucide-react"; import { FolderKanban } from "lucide-react";
export interface ProjectCreatedCardProps { export interface ProjectCreatedCardProps {
@ -22,17 +23,19 @@ export function ProjectCreatedCard({ name, memberCount, onClick }: ProjectCreate
</span> </span>
<div className="truncate text-ui-body font-semibold text-foreground">{name}</div> <div className="truncate text-ui-body font-semibold text-foreground">{name}</div>
<span className="rounded-md bg-muted px-1.5 py-0.5 text-ui-label font-medium uppercase tracking-wider text-muted-foreground"> <span className="rounded-md bg-muted px-1.5 py-0.5 text-ui-label font-medium uppercase tracking-wider text-muted-foreground">
Projekt <Trans>Project</Trans>
</span> </span>
</div> </div>
<span className="shrink-0 rounded-full bg-success/15 px-2.5 py-0.5 text-ui-body-sm font-medium text-success"> <span className="shrink-0 rounded-full bg-success/15 px-2.5 py-0.5 text-ui-body-sm font-medium text-success">
Erstellt <Trans>Created</Trans>
</span> </span>
</div> </div>
<p className="mt-2.5 text-ui-body-sm leading-relaxed text-muted-foreground"> <p className="mt-2.5 text-ui-body-sm leading-relaxed text-muted-foreground">
{memberCount} Agent{memberCount === 1 ? "" : "en"} eingeladen <Plural value={memberCount} one="# agent invited" other="# agents invited" />
</p> </p>
<div className="mt-2.5 text-ui-body-sm font-medium text-primary">Projekt öffnen →</div> <div className="mt-2.5 text-ui-body-sm font-medium text-primary">
<Trans>Open project →</Trans>
</div>
</button> </button>
</div> </div>
); );

View File

@ -421,7 +421,7 @@ export function OoGroupStage({
if (sparkLabel && groupSpark?.kind === "peer-reply") return sparkLabel; if (sparkLabel && groupSpark?.kind === "peer-reply") return sparkLabel;
if (awarenessLabel) return awarenessLabel; if (awarenessLabel) return awarenessLabel;
if (sparkLabel) return sparkLabel; if (sparkLabel) return sparkLabel;
if (!speaker) return t`Gruppenunterhaltung`; if (!speaker) return t`Group conversation`;
if (running && workingBots.length > 1) { if (running && workingBots.length > 1) {
return t`${workingBots.length} Oos are working — ${speaker.name} has the floor`; return t`${workingBots.length} Oos are working — ${speaker.name} has the floor`;
} }
@ -495,7 +495,7 @@ export function OoGroupStage({
<h2 className="mt-1.5 truncate text-ui-title font-semibold tracking-tight text-foreground"> <h2 className="mt-1.5 truncate text-ui-title font-semibold tracking-tight text-foreground">
{groupName ?? t`Group`} {groupName ?? t`Group`}
</h2> </h2>
<p className="truncate text-ui-body-sm text-muted-foreground">{t`Gruppenunterhaltung`}</p> <p className="truncate text-ui-body-sm text-muted-foreground">{t`Group conversation`}</p>
</div> </div>
{awarenessLabel && awarenessEvent && speaker ? ( {awarenessLabel && awarenessEvent && speaker ? (

View File

@ -21,8 +21,8 @@ export function StageVoiceEntry({
variant="outline" variant="outline"
onClick={onStartVoice} onClick={onStartVoice}
data-testid="stage-voice-primary" data-testid="stage-voice-primary"
aria-label={voiceReady ? t`Sprechen starten` : t`Sprache einrichten`} aria-label={voiceReady ? t`Start talking` : t`Set up voice`}
title={voiceReady ? t`Sprechen starten` : t`Sprache einrichten`} title={voiceReady ? t`Start talking` : t`Set up voice`}
className={`tactile-press h-control-lg w-full max-w-sm gap-2.5 rounded-full px-6 text-ui-body font-semibold transition-all ${ className={`tactile-press h-control-lg w-full max-w-sm gap-2.5 rounded-full px-6 text-ui-body font-semibold transition-all ${
voiceReady voiceReady
? "relative overflow-hidden border-border bg-foreground/5 text-foreground hover:bg-foreground/10" ? "relative overflow-hidden border-border bg-foreground/5 text-foreground hover:bg-foreground/10"
@ -33,10 +33,10 @@ export function StageVoiceEntry({
<VoiceAuroraRim botColor={botColor} className="inset-x-[12%] h-0.5 blur-[3px]" /> <VoiceAuroraRim botColor={botColor} className="inset-x-[12%] h-0.5 blur-[3px]" />
) : null} ) : null}
<Mic strokeWidth={2} className="size-icon-lg shrink-0" aria-hidden="true" /> <Mic strokeWidth={2} className="size-icon-lg shrink-0" aria-hidden="true" />
{voiceReady ? <Trans>Sprechen starten</Trans> : <Trans>Sprache einrichten</Trans>} {voiceReady ? <Trans>Start talking</Trans> : <Trans>Set up voice</Trans>}
</Button> </Button>
<p className="text-center text-ui-caption text-muted-foreground"> <p className="text-center text-ui-caption text-muted-foreground">
<Trans>Primär per Stimme — Transkript unten</Trans> <Trans>Voice first — transcript below</Trans>
</p> </p>
</div> </div>
); );

View File

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest"; import { i18n } from "@lingui/core";
import { beforeAll, describe, expect, it } from "vitest";
import { import {
isDoingStatus, isDoingStatus,
isNeedsYouStatus, isNeedsYouStatus,
@ -9,6 +10,11 @@ import {
} from "./agent-activity"; } from "./agent-activity";
describe("agent-activity", () => { describe("agent-activity", () => {
beforeAll(() => {
i18n.load("en", {});
i18n.activate("en");
});
it("classifies needs-you and doing statuses", () => { it("classifies needs-you and doing statuses", () => {
expect(isNeedsYouStatus("waiting_input")).toBe(true); expect(isNeedsYouStatus("waiting_input")).toBe(true);
expect(isNeedsYouStatus("waiting_takeover")).toBe(true); expect(isNeedsYouStatus("waiting_takeover")).toBe(true);
@ -17,16 +23,16 @@ describe("agent-activity", () => {
expect(isDoingStatus("idle")).toBe(false); expect(isDoingStatus("idle")).toBe(false);
}); });
it("returns German doing verbs instead of raw status", () => { it("returns doing verbs instead of raw status", () => {
expect(rosterDoingVerb("waiting_input")).toBe("Braucht deine Antwort"); expect(rosterDoingVerb("waiting_input")).toBe("Needs your reply");
expect(rosterDoingVerb("running")).toBe("Arbeitet…"); expect(rosterDoingVerb("running")).toBe("Working…");
expect(rosterDoingVerb("idle")).toBeNull(); expect(rosterDoingVerb("idle")).toBeNull();
}); });
it("builds roster subtitle with verb priority", () => { it("builds roster subtitle with verb priority", () => {
expect(rosterActivitySubtitle("running", "Some preview")).toBe("Arbeitet…"); expect(rosterActivitySubtitle("running", "Some preview")).toBe("Working…");
expect(rosterActivitySubtitle("idle", "Hello there")).toBe("Hello there"); expect(rosterActivitySubtitle("idle", "Hello there")).toBe("Hello there");
expect(rosterActivitySubtitle("idle", "")).toBe("Bereit"); expect(rosterActivitySubtitle("idle", "")).toBe("Ready");
}); });
it("maps status dot tones", () => { it("maps status dot tones", () => {
@ -36,7 +42,7 @@ describe("agent-activity", () => {
}); });
it("returns needs-you chip label only for waiting states", () => { it("returns needs-you chip label only for waiting states", () => {
expect(needsYouChipLabel("waiting_input")).toBe("Braucht dich"); expect(needsYouChipLabel("waiting_input")).toBe("Needs you");
expect(needsYouChipLabel("running")).toBeNull(); expect(needsYouChipLabel("running")).toBeNull();
}); });
}); });

View File

@ -1,5 +1,9 @@
import { i18n } from "@lingui/core";
import type { RunActivityRow } from "@rakazo/contracts"; import type { RunActivityRow } from "@rakazo/contracts";
// Labels use `i18n._` with literal ids instead of the Lingui macro so this module stays
// importable from plain unit tests; the extractor still collects them.
/** Run statuses where the agent is blocked on the user. */ /** Run statuses where the agent is blocked on the user. */
export function isNeedsYouStatus(status: string | undefined): boolean { export function isNeedsYouStatus(status: string | undefined): boolean {
return status === "waiting_input" || status === "waiting_takeover"; return status === "waiting_input" || status === "waiting_takeover";
@ -18,19 +22,19 @@ export function isFailedRoutine(run: RunActivityRow): boolean {
return run.trigger === "routine" && run.status === "failed"; return run.trigger === "routine" && run.status === "failed";
} }
/** German doing-verb for roster / activity surfaces — never raw status strings. */ /** Doing-verb for roster / activity surfaces — never raw status strings. */
export function rosterDoingVerb(status: string | undefined): string | null { export function rosterDoingVerb(status: string | undefined): string | null {
switch (status) { switch (status) {
case "queued": case "queued":
return "Wartet an…"; return i18n._("Queued…");
case "leased": case "leased":
return "Startet…"; return i18n._("Starting…");
case "running": case "running":
return "Arbeitet…"; return i18n._("Working…");
case "waiting_input": case "waiting_input":
return "Braucht deine Antwort"; return i18n._("Needs your reply");
case "waiting_takeover": case "waiting_takeover":
return "Braucht dich"; return i18n._("Needs you");
default: default:
return null; return null;
} }
@ -39,27 +43,27 @@ export function rosterDoingVerb(status: string | undefined): string | null {
/** Short chip label for needs-you states. */ /** Short chip label for needs-you states. */
export function needsYouChipLabel(status: string | undefined): string | null { export function needsYouChipLabel(status: string | undefined): string | null {
if (!isNeedsYouStatus(status)) return null; if (!isNeedsYouStatus(status)) return null;
return status === "waiting_takeover" ? "Braucht dich" : "Braucht dich"; return i18n._("Needs you");
} }
export function runActivityLabel(status: RunActivityRow["status"]): string { export function runActivityLabel(status: RunActivityRow["status"]): string {
switch (status) { switch (status) {
case "queued": case "queued":
return "Wartet an…"; return i18n._("Queued…");
case "leased": case "leased":
return "Startet…"; return i18n._("Starting…");
case "running": case "running":
return "Arbeitet…"; return i18n._("Working…");
case "waiting_input": case "waiting_input":
return "Braucht deine Antwort"; return i18n._("Needs your reply");
case "waiting_takeover": case "waiting_takeover":
return "Braucht dich"; return i18n._("Needs you");
case "completed": case "completed":
return "Erledigt"; return i18n._("Done");
case "failed": case "failed":
return "Fehlgeschlagen"; return i18n._("Failed");
case "cancelled": case "cancelled":
return "Abgebrochen"; return i18n._("Cancelled");
default: default:
return status; return status;
} }
@ -69,7 +73,7 @@ export function runActivityLabel(status: RunActivityRow["status"]): string {
export function rosterActivitySubtitle( export function rosterActivitySubtitle(
status: string | undefined, status: string | undefined,
preview: string | undefined, preview: string | undefined,
idleFallback = "Bereit", idleFallback = i18n._("Ready"),
): string { ): string {
const verb = rosterDoingVerb(status); const verb = rosterDoingVerb(status);
if (verb) return verb; if (verb) return verb;

View File

@ -0,0 +1,68 @@
import { cpSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AllCatalogsType } from "@lingui/cli/api";
import { getCatalogs } from "@lingui/cli/api";
import { getConfig } from "@lingui/conf";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
const webRoot = fileURLToPath(new URL("../..", import.meta.url));
const localesDir = path.join(webRoot, "src", "locales");
const config = getConfig({ cwd: webRoot, configPath: path.join(webRoot, "lingui.config.ts") });
const extractHint = "Run `pnpm --filter @rakazo/web intl:extract` and commit the catalogs.";
/** Runs `lingui extract --clean` against a copy of the committed catalogs. */
async function extractIntoCopy(dir: string): Promise<AllCatalogsType> {
cpSync(localesDir, dir, { recursive: true });
const catalogs = await getCatalogs({
...config,
catalogs: config.catalogs.map((catalog) => ({
...catalog,
path: path.join(dir, "{locale}", "messages"),
// Absolute, because the CLI resolves relative paths from apps/web and tests run from the root.
include: [path.join(webRoot, "src")],
exclude: catalog.exclude ?? [],
})),
});
expect(catalogs).toHaveLength(1);
const result = await catalogs[0]!.make({
clean: true,
overwrite: false,
orderBy: config.orderBy,
});
if (!result) throw new Error("Message extraction failed");
return result;
}
describe("lingui catalogs stay in sync with the source", () => {
let dir: string;
let extracted: AllCatalogsType;
beforeAll(async () => {
dir = mkdtempSync(path.join(tmpdir(), "evoo-locales-"));
extracted = await extractIntoCopy(dir);
}, 120_000);
afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});
it("matches a fresh extraction for every locale", () => {
const stale = config.locales.filter((locale) => {
const file = path.join(locale, "messages.po");
return (
readFileSync(path.join(dir, file), "utf8") !==
readFileSync(path.join(localesDir, file), "utf8")
);
});
expect(stale, extractHint).toEqual([]);
});
it("translates every German message", () => {
const untranslated = Object.entries(extracted.de ?? {})
.filter(([, entry]) => !entry.translation)
.map(([id, entry]) => entry.message ?? id);
expect(untranslated).toEqual([]);
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -15,11 +15,11 @@ function goalStatusTone(status: ProjectGoalStatus): string {
function goalStatusLabel(status: ProjectGoalStatus): string { function goalStatusLabel(status: ProjectGoalStatus): string {
switch (status) { switch (status) {
case "open": case "open":
return t`Offen`; return t`To do`;
case "doing": case "doing":
return t`In Arbeit`; return t`In progress`;
case "done": case "done":
return t`Erledigt`; return t`Done`;
default: { default: {
const _exhaustive: never = status; const _exhaustive: never = status;
return _exhaustive; return _exhaustive;
@ -62,12 +62,12 @@ function sessionStatusLabel(status: ProjectSession["status"]): string {
function formatRelativeTime(iso: string): string { function formatRelativeTime(iso: string): string {
const delta = Date.now() - new Date(iso).getTime(); const delta = Date.now() - new Date(iso).getTime();
const minutes = Math.round(delta / 60_000); const minutes = Math.round(delta / 60_000);
if (minutes < 1) return t`gerade eben`; if (minutes < 1) return t`just now`;
if (minutes < 60) return t`vor ${minutes} Min.`; if (minutes < 60) return t`${minutes}m ago`;
const hours = Math.round(minutes / 60); const hours = Math.round(minutes / 60);
if (hours < 24) return t`vor ${hours} Std.`; if (hours < 24) return t`${hours}h ago`;
const days = Math.round(hours / 24); const days = Math.round(hours / 24);
return t`vor ${days} T.`; return t`${days}d ago`;
} }
type ProjectSessionTimelineProps = { type ProjectSessionTimelineProps = {
@ -165,13 +165,12 @@ export function ProjectSessionTimeline({
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<div className="border-b border-border/60 px-4 py-4 md:px-8 md:py-5 lg:px-10"> <div className="border-b border-border/60 px-4 py-4 md:px-8 md:py-5 lg:px-10">
<p className="text-ui-label font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-ui-label font-medium uppercase tracking-wide text-muted-foreground">
<Trans>Projekt</Trans> <Trans>Project</Trans>
</p> </p>
<h2 className="text-ui-body font-semibold tracking-tight text-foreground">{sectionName}</h2> <h2 className="text-ui-body font-semibold tracking-tight text-foreground">{sectionName}</h2>
<p className="mt-1 text-ui-body-sm text-muted-foreground"> <p className="mt-1 text-ui-body-sm text-muted-foreground">
<Trans> <Trans>
Goals für dieses Projekt — unabhängig von Chat-Nachrichten. Unterhaltungen findest du Goals for this project, separate from chat messages. Conversations are below.
weiter unten.
</Trans> </Trans>
</p> </p>
</div> </div>
@ -187,18 +186,18 @@ export function ProjectSessionTimeline({
<Trans>Goals</Trans> <Trans>Goals</Trans>
</h3> </h3>
<span className="rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-ui-label font-medium text-primary"> <span className="rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-ui-label font-medium text-primary">
Goal <Trans>Goal</Trans>
</span> </span>
</div> </div>
{loadingGoals ? ( {loadingGoals ? (
<p className="text-ui-body-sm text-muted-foreground"> <p className="text-ui-body-sm text-muted-foreground">
<Trans>Goals werden geladen…</Trans> <Trans>Loading goals…</Trans>
</p> </p>
) : goals.length === 0 ? ( ) : goals.length === 0 ? (
<div className="rounded-[16px] border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center"> <div className="rounded-[16px] border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center">
<p className="text-ui-body-sm text-muted-foreground"> <p className="text-ui-body-sm text-muted-foreground">
<Trans>Noch keine Goals in diesem Projekt.</Trans> <Trans>No goals in this project yet.</Trans>
</p> </p>
</div> </div>
) : ( ) : (
@ -217,7 +216,7 @@ export function ProjectSessionTimeline({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-primary/30 bg-primary/10 px-2 py-0.5 text-ui-label font-semibold uppercase tracking-wide text-primary"> <span className="rounded-full border border-primary/30 bg-primary/10 px-2 py-0.5 text-ui-label font-semibold uppercase tracking-wide text-primary">
Goal <Trans>Goal</Trans>
</span> </span>
<p className="truncate text-ui-body font-medium text-foreground"> <p className="truncate text-ui-body font-medium text-foreground">
{goal.title} {goal.title}
@ -252,7 +251,7 @@ export function ProjectSessionTimeline({
onClick={() => onStartVoiceFirst(linkedGroupId)} onClick={() => onStartVoiceFirst(linkedGroupId)}
className="rounded-lg border border-emerald-500/40 bg-emerald-500/15 px-3 py-1.5 text-ui-label font-semibold text-emerald-200 transition-colors hover:bg-emerald-500/25" className="rounded-lg border border-emerald-500/40 bg-emerald-500/15 px-3 py-1.5 text-ui-label font-semibold text-emerald-200 transition-colors hover:bg-emerald-500/25"
> >
<Trans>Voice First starten</Trans> <Trans>Start Voice First</Trans>
</button> </button>
) : null} ) : null}
<button <button
@ -260,7 +259,7 @@ export function ProjectSessionTimeline({
onClick={() => onOpenGroupChat(linkedGroupId)} onClick={() => onOpenGroupChat(linkedGroupId)}
className="rounded-lg border border-border bg-background px-3 py-1.5 text-ui-label font-medium text-foreground transition-colors hover:bg-accent" className="rounded-lg border border-border bg-background px-3 py-1.5 text-ui-label font-medium text-foreground transition-colors hover:bg-accent"
> >
<Trans>Unterhaltung öffnen</Trans> <Trans>Open conversation</Trans>
</button> </button>
</div> </div>
</article> </article>
@ -274,7 +273,7 @@ export function ProjectSessionTimeline({
type="text" type="text"
value={newGoalTitle} value={newGoalTitle}
onChange={(event) => setNewGoalTitle(event.target.value)} onChange={(event) => setNewGoalTitle(event.target.value)}
placeholder={t`Neues Goal…`} placeholder={t`New goal…`}
className="min-w-0 flex-1 rounded-xl border border-border bg-background px-3 py-2 text-ui-body-sm text-foreground" className="min-w-0 flex-1 rounded-xl border border-border bg-background px-3 py-2 text-ui-body-sm text-foreground"
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter") void handleCreateGoal(); if (event.key === "Enter") void handleCreateGoal();
@ -286,7 +285,7 @@ export function ProjectSessionTimeline({
onClick={() => void handleCreateGoal()} onClick={() => void handleCreateGoal()}
className="rounded-xl border border-border bg-background px-4 py-2 text-ui-body-sm font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50" className="rounded-xl border border-border bg-background px-4 py-2 text-ui-body-sm font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50"
> >
<Trans>Goal anlegen</Trans> <Trans>Add goal</Trans>
</button> </button>
</div> </div>
</section> </section>
@ -297,26 +296,26 @@ export function ProjectSessionTimeline({
id="project-chats-heading" id="project-chats-heading"
className="text-ui-body-sm font-semibold text-foreground" className="text-ui-body-sm font-semibold text-foreground"
> >
<Trans>Unterhaltungen</Trans> <Trans>Conversations</Trans>
</h3> </h3>
<span className="text-ui-label text-muted-foreground">{groupName}</span> <span className="text-ui-label text-muted-foreground">{groupName}</span>
</div> </div>
{loadingSessions ? ( {loadingSessions ? (
<p className="text-ui-body-sm text-muted-foreground"> <p className="text-ui-body-sm text-muted-foreground">
<Trans>Unterhaltungen werden geladen…</Trans> <Trans>Loading conversations…</Trans>
</p> </p>
) : sessions.length === 0 ? ( ) : sessions.length === 0 ? (
<div className="rounded-[16px] border border-border/70 bg-card/50 px-4 py-6 text-center"> <div className="rounded-[16px] border border-border/70 bg-card/50 px-4 py-6 text-center">
<p className="text-ui-body-sm text-muted-foreground"> <p className="text-ui-body-sm text-muted-foreground">
<Trans>Noch keine Unterhaltung in diesem Projekt-Chat.</Trans> <Trans>No conversations in this project chat yet.</Trans>
</p> </p>
<button <button
type="button" type="button"
onClick={onStartSession} onClick={onStartSession}
className="mt-4 rounded-xl border border-border bg-background px-4 py-2 text-ui-body-sm font-medium text-foreground transition-colors hover:bg-accent" className="mt-4 rounded-xl border border-border bg-background px-4 py-2 text-ui-body-sm font-medium text-foreground transition-colors hover:bg-accent"
> >
<Trans>Chat starten</Trans> <Trans>Start chat</Trans>
</button> </button>
</div> </div>
) : ( ) : (
@ -371,7 +370,7 @@ export function ProjectSessionTimeline({
onClick={() => setSessionsExpanded(true)} onClick={() => setSessionsExpanded(true)}
className="self-start px-1 py-1 text-ui-body-sm font-medium text-primary hover:underline" className="self-start px-1 py-1 text-ui-body-sm font-medium text-primary hover:underline"
> >
<Trans>Mehr Unterhaltungen… ({hiddenSessionCount})</Trans> <Trans>More conversations ({hiddenSessionCount})</Trans>
</button> </button>
) : null} ) : null}
<button <button
@ -379,7 +378,7 @@ export function ProjectSessionTimeline({
onClick={onStartSession} onClick={onStartSession}
className="mt-1 self-start rounded-lg border border-border/70 px-3 py-1.5 text-ui-label font-medium text-foreground transition-colors hover:bg-accent" className="mt-1 self-start rounded-lg border border-border/70 px-3 py-1.5 text-ui-label font-medium text-foreground transition-colors hover:bg-accent"
> >
<Trans>Neue Unterhaltung</Trans> <Trans>New conversation</Trans>
</button> </button>
</div> </div>
)} )}

View File

@ -3585,7 +3585,7 @@ export function ShellPage() {
<button <button
type="button" type="button"
aria-label={ aria-label={
heuteActionCount > 0 ? t`Heute, ${heuteActionCount} need you` : t`Heute` heuteActionCount > 0 ? t`Today, ${heuteActionCount} need you` : t`Today`
} }
onClick={() => { onClick={() => {
ooSound.play("interact"); ooSound.play("interact");
@ -3606,18 +3606,18 @@ export function ShellPage() {
) : null} ) : null}
</div> </div>
<div className="font-semibold text-foreground/90 tracking-tight text-ui-body"> <div className="font-semibold text-foreground/90 tracking-tight text-ui-body">
Heute <Trans>Today</Trans>
</div> </div>
</button> </button>
</div> </div>
<div className="sidebar-unterhaltungen"> <div className="sidebar-unterhaltungen">
<div className="flex items-center justify-between pt-4 pb-2 px-2.5"> <div className="flex items-center justify-between pt-4 pb-2 px-2.5">
<div className="rk-shell-section-label">{t`Sprich mit deinen Oo's`}</div> <div className="rk-shell-section-label">{t`Talk to your Oos`}</div>
<div className="flex items-center"> <div className="flex items-center">
<Popover open={conversationPickerOpen} onOpenChange={setConversationPickerOpen}> <Popover open={conversationPickerOpen} onOpenChange={setConversationPickerOpen}>
<PopoverTrigger <PopoverTrigger
type="button" type="button"
aria-label={t`Neue Unterhaltung`} aria-label={t`New conversation`}
data-testid="conversation-picker-trigger" data-testid="conversation-picker-trigger"
className="rk-sidebar-muted hover:text-foreground/90 transition-colors" className="rk-sidebar-muted hover:text-foreground/90 transition-colors"
> >
@ -3661,7 +3661,7 @@ export function ShellPage() {
) : null} ) : null}
{conversationSidebarGroups.length === 0 && !mainConversationEntry ? ( {conversationSidebarGroups.length === 0 && !mainConversationEntry ? (
<div className="p-4 text-center text-ui-label rk-sidebar-muted"> <div className="p-4 text-center text-ui-label rk-sidebar-muted">
<p>{t`Noch keine Unterhaltung.`}</p> <p>{t`No conversations yet.`}</p>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -3669,7 +3669,7 @@ export function ShellPage() {
onClick={() => setConversationPickerOpen(true)} onClick={() => setConversationPickerOpen(true)}
> >
<Plus className="mr-1 size-icon-sm" /> <Plus className="mr-1 size-icon-sm" />
{t`Neue Unterhaltung`} {t`New conversation`}
</Button> </Button>
</div> </div>
) : null} ) : null}
@ -3917,8 +3917,8 @@ export function ShellPage() {
className="mt-1 w-full flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-ui-label font-medium rk-sidebar-muted hover:bg-sidebar-accent hover:text-foreground transition-colors" className="mt-1 w-full flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-ui-label font-medium rk-sidebar-muted hover:bg-sidebar-accent hover:text-foreground transition-colors"
> >
{expandedTeamGroups[group.key] {expandedTeamGroups[group.key]
? "Weniger anzeigen" ? t`Show less`
: `Alle ${group.bots.length} anzeigen...`} : t`Show all ${group.bots.length}`}
</button> </button>
)} )}
</div> </div>
@ -3928,7 +3928,7 @@ export function ShellPage() {
{/* PROJEKTE SECTION */} {/* PROJEKTE SECTION */}
<div className="flex items-center justify-between pt-4 pb-1 mt-3 border-t border-sidebar-border px-2.5"> <div className="flex items-center justify-between pt-4 pb-1 mt-3 border-t border-sidebar-border px-2.5">
<div> <div>
<div className="rk-shell-section-label">{t`Projekte`}</div> <div className="rk-shell-section-label">{t`Projects`}</div>
<p className="mt-0.5 px-0.5 text-ui-caption rk-sidebar-muted"> <p className="mt-0.5 px-0.5 text-ui-caption rk-sidebar-muted">
{t`Threads by topic · folders`} {t`Threads by topic · folders`}
</p> </p>
@ -4080,7 +4080,7 @@ export function ShellPage() {
onClick={() => void startProject(group.spaceId)} onClick={() => void startProject(group.spaceId)}
> >
<Plus className="mr-1 size-icon-sm" /> <Plus className="mr-1 size-icon-sm" />
{t`Starten`} {t`Start`}
</Button> </Button>
</div> </div>
) : null} ) : null}
@ -4503,21 +4503,19 @@ export function ShellPage() {
> >
{inGroup {inGroup
? (activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`) ? (activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`)
: (active?.name ?? t`Unterhaltung wählen`)} : (active?.name ?? t`Choose a conversation`)}
</span> </span>
{!inGroup && active && (composerRunning || active.status !== "idle") ? ( {!inGroup && active && (composerRunning || active.status !== "idle") ? (
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug"> <span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
{composerRunning || active.status === "running" {composerRunning || active.status === "running" ? t`Busy` : active.status}
? t`Beschäftigt`
: active.status}
</span> </span>
) : inGroup && projectSessionKey ? ( ) : inGroup && projectSessionKey ? (
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug"> <span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
{t`Gruppenunterhaltung`} {t`Group conversation`}
</span> </span>
) : inGroup && activeGroup?.kind !== "main" ? ( ) : inGroup && activeGroup?.kind !== "main" ? (
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug"> <span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
{t`Projekt · Sessions & Ordner`} {t`Project · sessions & folders`}
</span> </span>
) : null} ) : null}
</div> </div>
@ -4562,7 +4560,7 @@ export function ShellPage() {
onClick={() => currentSpaceId && void startProject(currentSpaceId)} onClick={() => currentSpaceId && void startProject(currentSpaceId)}
> >
<Plus aria-hidden="true" className="size-icon-md" /> <Plus aria-hidden="true" className="size-icon-md" />
{t`Starten`} {t`Start`}
</Button> </Button>
</div> </div>
) : !active && !activeGroup && initialBotsLoaded ? ( ) : !active && !activeGroup && initialBotsLoaded ? (
@ -4575,7 +4573,7 @@ export function ShellPage() {
) : showProjectTimeline && bots.length > 0 ? ( ) : showProjectTimeline && bots.length > 0 ? (
<ProjectSessionTimeline <ProjectSessionTimeline
sectionId={activeGroup!.sectionId!} sectionId={activeGroup!.sectionId!}
sectionName={activeProjectSection?.name ?? activeGroup?.name ?? t`Projekt`} sectionName={activeProjectSection?.name ?? activeGroup?.name ?? t`Project`}
groupId={groupId!} groupId={groupId!}
groupName={activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`} groupName={activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`}
onOpenSession={openProjectSession} onOpenSession={openProjectSession}
@ -4586,7 +4584,7 @@ export function ShellPage() {
) : showProjectTimeline && bots.length === 0 ? ( ) : showProjectTimeline && bots.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-12"> <div className="flex flex-1 flex-col items-center justify-center gap-4 py-12">
<p className="text-ui-body text-muted-foreground"> <p className="text-ui-body text-muted-foreground">
<Trans>Erstelle zuerst einen Bot, um Projekte starten zu können.</Trans> <Trans>Create a bot first to start a project.</Trans>
</p> </p>
<Button onClick={() => setShowCreateBotModal(true)}> <Button onClick={() => setShowCreateBotModal(true)}>
<Plus aria-hidden="true" className="size-icon-md" /> <Plus aria-hidden="true" className="size-icon-md" />
@ -6058,11 +6056,7 @@ const Transcript = memo(function Transcript({
> >
{encounterMode ? ( {encounterMode ? (
<p className="mx-auto w-full max-w-2xl px-1 text-ui-label font-medium uppercase tracking-wider text-muted-foreground/80"> <p className="mx-auto w-full max-w-2xl px-1 text-ui-label font-medium uppercase tracking-wider text-muted-foreground/80">
{voiceEncounterActive ? ( {voiceEncounterActive ? <Trans>Transcript</Trans> : <Trans>Conversation record</Trans>}
<Trans>Transkript — sekundär zur Stimme</Trans>
) : (
<Trans>Conversation record</Trans>
)}
</p> </p>
) : null} ) : null}
{olderCursor != null ? ( {olderCursor != null ? (
@ -7796,10 +7790,10 @@ const MessageView = memo(function MessageView({
} }
}} }}
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors cursor-pointer text-ui-body-sm font-medium" className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors cursor-pointer text-ui-body-sm font-medium"
title={t`Antwort kopieren`} title={t`Copy reply`}
> >
<Copy size={13.5} /> <Copy size={13.5} />
<span>{t`Kopieren`}</span> <span>{t`Copy`}</span>
</button> </button>
{voiceReady ? ( {voiceReady ? (
<button <button
@ -7809,7 +7803,7 @@ const MessageView = memo(function MessageView({
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors cursor-pointer text-ui-body-sm font-medium" className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors cursor-pointer text-ui-body-sm font-medium"
> >
<Volume2 size={13.5} /> <Volume2 size={13.5} />
<span>{speaking ? t`Stop` : t`Vorlesen`}</span> <span>{speaking ? t`Stop` : t`Read aloud`}</span>
</button> </button>
) : null} ) : null}
</div> </div>

View File

@ -12,7 +12,7 @@ import {
import { Info, Lock, Plus, Users } from "lucide-react"; import { Info, Lock, Plus, Users } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
/** 1:1-Unterhaltung: bestehenden Bot öffnen oder neuen Bot anlegen. */ /** 1:1 conversation: open an existing bot or create a new one. */
export function ConversationBotPicker({ export function ConversationBotPicker({
bots, bots,
onCreateBot, onCreateBot,
@ -31,29 +31,31 @@ export function ConversationBotPicker({
(bot) => bot.name.toLowerCase().includes(needle) || bot.title.toLowerCase().includes(needle), (bot) => bot.name.toLowerCase().includes(needle) || bot.title.toLowerCase().includes(needle),
); );
}, [bots, needle]); }, [bots, needle]);
const createLabel = t`Create new Bot`;
const createText = createLabel.toLowerCase();
const showCreate = const showCreate =
!needle || !needle ||
"neuen bot erstellen".includes(needle) || createText.includes(needle) ||
needle.split(/\s+/).every((part) => "neuen bot erstellen".includes(part)); needle.split(/\s+/).every((part) => createText.includes(part));
return ( return (
<div data-testid="conversation-bot-picker" className="w-[min(320px,calc(100vw-2rem))]"> <div data-testid="conversation-bot-picker" className="w-[min(320px,calc(100vw-2rem))]">
<label className="flex items-center gap-2 border-b border-border px-3 py-2"> <label className="flex items-center gap-2 border-b border-border px-3 py-2">
<span className="shrink-0 text-ui-body-sm text-muted-foreground"> <span className="shrink-0 text-ui-body-sm text-muted-foreground">
<Trans>Mit:</Trans> <Trans>With:</Trans>
</span> </span>
<input <input
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder={t`Suchen`} placeholder={t`Search`}
aria-label={t`Suchen`} aria-label={t`Search`}
className="min-w-0 flex-1 bg-transparent text-ui-body text-foreground outline-none placeholder:text-muted-foreground" className="min-w-0 flex-1 bg-transparent text-ui-body text-foreground outline-none placeholder:text-muted-foreground"
/> />
</label> </label>
<Command shouldFilter={false} className="rounded-none border-0 bg-transparent p-0"> <Command shouldFilter={false} className="rounded-none border-0 bg-transparent p-0">
<CommandList className="max-h-72 p-1"> <CommandList className="max-h-72 p-1">
<CommandEmpty> <CommandEmpty>
<Trans>Kein Bot gefunden</Trans> <Trans>No bots match</Trans>
</CommandEmpty> </CommandEmpty>
<CommandGroup> <CommandGroup>
{showCreate ? ( {showCreate ? (
@ -64,7 +66,7 @@ export function ConversationBotPicker({
className="gap-2" className="gap-2"
> >
<Plus strokeWidth={1.8} aria-hidden="true" className="size-icon-md" /> <Plus strokeWidth={1.8} aria-hidden="true" className="size-icon-md" />
<Trans>Neuen Bot erstellen</Trans> {createLabel}
</CommandItem> </CommandItem>
) : null} ) : null}
{matched.map((bot) => ( {matched.map((bot) => (

3
pnpm-lock.yaml generated
View File

@ -319,6 +319,9 @@ importers:
'@lingui/conf': '@lingui/conf':
specifier: 6.6.0 specifier: 6.6.0
version: 6.6.0 version: 6.6.0
'@lingui/format-po':
specifier: 6.6.0
version: 6.6.0
'@lingui/vite-plugin': '@lingui/vite-plugin':
specifier: 6.6.0 specifier: 6.6.0
version: 6.6.0(@babel/core@7.29.7)(@lingui/babel-plugin-lingui-macro@6.6.0(@babel/core@7.29.7)(@babel/types@7.29.8))(@rolldown/plugin-babel@0.2.4(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(rolldown@1.2.8)(vite@8.3.0(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.0)))(esbuild@0.28.2)(rolldown@1.2.8)(vite@8.3.0(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.0)) version: 6.6.0(@babel/core@7.29.7)(@lingui/babel-plugin-lingui-macro@6.6.0(@babel/core@7.29.7)(@babel/types@7.29.8))(@rolldown/plugin-babel@0.2.4(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(rolldown@1.2.8)(vite@8.3.0(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.0)))(esbuild@0.28.2)(rolldown@1.2.8)(vite@8.3.0(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.0))