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
This commit is contained in:
parent
3d180dc2dd
commit
8ae07cdf11
@ -14,7 +14,7 @@ async function projectSidebarStart(projekte: Locator, name: string) {
|
||||
return projekte.getByTestId(`project-start-${spaceId}`);
|
||||
}
|
||||
|
||||
test("empty project shows Starten instead of a fallback bot", async ({ page }) => {
|
||||
test("empty project shows Start instead of a fallback bot", async ({ page }) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `empty-project-${stamp}@rakazo.test`, "password12", "Empty Project");
|
||||
await completeOnboarding(page);
|
||||
@ -29,14 +29,14 @@ test("empty project shows Starten instead of a fallback bot", async ({ page }) =
|
||||
await page.waitForURL(/\/app(?:\/)?$/);
|
||||
|
||||
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
||||
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
|
||||
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("project-start-main")).toBeVisible();
|
||||
|
||||
await page.getByTestId("project-start-main").click();
|
||||
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create");
|
||||
});
|
||||
|
||||
test("Starten in an onboarded project opens the group create flow", async ({ page }) => {
|
||||
test("Start in an onboarded project opens the group create flow", async ({ page }) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `project-start-${stamp}@rakazo.test`, "password12", "Project Start");
|
||||
await completeOnboarding(page);
|
||||
@ -70,7 +70,7 @@ test("Starten in an onboarded project opens the group create flow", async ({ pag
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("archiving the only bot keeps Starten visible in the project sidebar", async ({ page }) => {
|
||||
test("archiving the only bot keeps Start visible in the project sidebar", async ({ page }) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `project-archive-${stamp}@rakazo.test`, "password12", "Project Archive");
|
||||
await completeOnboarding(page);
|
||||
@ -93,6 +93,6 @@ test("archiving the only bot keeps Starten visible in the project sidebar", asyn
|
||||
|
||||
await page.goto("/app");
|
||||
await page.waitForURL(/\/app(?:\/)?$/);
|
||||
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
|
||||
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("project-start-main")).toBeVisible();
|
||||
});
|
||||
|
||||
@ -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 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\/[^/]+$/);
|
||||
|
||||
const brief = page.getByTestId("heute-brief");
|
||||
|
||||
@ -21,8 +21,8 @@ test("sidebar has expected navigation items and no integrations", async ({ page
|
||||
// Actual sidebar structure (verified by snapshot):
|
||||
// - button "Activity" (top action)
|
||||
// - textbox "Search" (search input)
|
||||
// - button "Heute" (today)
|
||||
// - text "Sprich mit deinen Oo's" (conversations section header)
|
||||
// - button "Today"
|
||||
// - text "Talk to your Oos" (conversations section header)
|
||||
// - status "Not configured" (trading bot URL unset)
|
||||
// - NO "Integration(en)" button or text
|
||||
|
||||
@ -30,12 +30,11 @@ test("sidebar has expected navigation items and no integrations", async ({ page
|
||||
const searchInput = sidebar.locator('input[placeholder="Search"], textbox[name="Search"]');
|
||||
await expect(searchInput).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// "Heute" (Today)
|
||||
const todayBtn = sidebar.getByRole("button", { name: "Heute", exact: true });
|
||||
const todayBtn = sidebar.getByRole("button", { name: "Today", exact: true });
|
||||
await expect(todayBtn).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// "Sprich mit deinen Oo's" is plain text, not a button
|
||||
const conversationsText = sidebar.getByText("Sprich mit deinen Oo's");
|
||||
// "Talk to your Oos" is plain text, not a button
|
||||
const conversationsText = sidebar.getByText("Talk to your Oos");
|
||||
await expect(conversationsText).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Trading mode comes from the bot API. With no URL configured the sidebar
|
||||
|
||||
@ -41,8 +41,8 @@ test("app loads without React hook errors", async ({ page }) => {
|
||||
bodyText.includes("Sign in") ||
|
||||
bodyText.includes("Anmelden") ||
|
||||
bodyText.includes("EvOo") ||
|
||||
bodyText.includes("Projekte") ||
|
||||
bodyText.includes("Heute") ||
|
||||
bodyText.includes("Projects") ||
|
||||
bodyText.includes("Today") ||
|
||||
bodyText.includes("Team");
|
||||
|
||||
expect(hasContent).toBe(true);
|
||||
|
||||
@ -15,7 +15,7 @@ function projectSpace(projekte: Locator, name: string) {
|
||||
});
|
||||
}
|
||||
|
||||
test("Projekte + opens new project dialog instead of group form", async ({ page }) => {
|
||||
test("Projects + opens new project dialog instead of group form", async ({ page }) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create");
|
||||
await completeOnboarding(page);
|
||||
@ -42,7 +42,7 @@ test("Projekte + opens new project dialog instead of group form", async ({ page
|
||||
0,
|
||||
);
|
||||
await expect(page.getByTestId("project-start-main")).toBeVisible();
|
||||
await expect(page.getByTestId("project-start-main")).toHaveText(/Starten/);
|
||||
await expect(page.getByTestId("project-start-main")).toHaveText(/Start/);
|
||||
});
|
||||
|
||||
test("spaces stay invisible by default and chat creation requires approval", async ({
|
||||
@ -104,7 +104,7 @@ test("spaces stay invisible by default and chat creation requires approval", asy
|
||||
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
|
||||
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
||||
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
|
||||
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toBeVisible();
|
||||
await captureScreenshot(page, testInfo, "spaces-sidebar");
|
||||
|
||||
await page.evaluate((id) => {
|
||||
@ -135,7 +135,7 @@ test("a new space opens empty and can be deleted from its menu", async ({ page }
|
||||
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
|
||||
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
||||
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
|
||||
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toBeVisible();
|
||||
expect(await rpc<unknown[]>(page, "bots/list", {})).toEqual([]);
|
||||
await captureScreenshot(page, testInfo, "new-space-empty");
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
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();
|
||||
await signup(page, `unterhaltungen-${stamp}@rakazo.test`, "password12", "Unterhaltungen UX");
|
||||
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 projekte = page.locator(".sidebar-projekte");
|
||||
|
||||
await expect(unterhaltungen.getByText("Sprich mit deinen Oo's", { exact: true })).toBeVisible();
|
||||
await expect(projekte.getByText("Projekte", { exact: true })).toBeVisible();
|
||||
await expect(unterhaltungen.getByText("Talk to your Oos", { exact: true })).toBeVisible();
|
||||
await expect(projekte.getByText("Projects", { exact: true })).toBeVisible();
|
||||
await expect(unterhaltungen.getByRole("button", { name: /^Chief/ })).toBeVisible();
|
||||
|
||||
await unterhaltungen.getByTestId("conversation-picker-trigger").click();
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { FolderKanban } from "lucide-react";
|
||||
|
||||
export interface ProjectCreatedCardProps {
|
||||
@ -22,17 +23,19 @@ export function ProjectCreatedCard({ name, memberCount, onClick }: ProjectCreate
|
||||
</span>
|
||||
<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">
|
||||
Projekt
|
||||
<Trans>Project</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -421,7 +421,7 @@ export function OoGroupStage({
|
||||
if (sparkLabel && groupSpark?.kind === "peer-reply") return sparkLabel;
|
||||
if (awarenessLabel) return awarenessLabel;
|
||||
if (sparkLabel) return sparkLabel;
|
||||
if (!speaker) return t`Gruppenunterhaltung`;
|
||||
if (!speaker) return t`Group conversation`;
|
||||
if (running && workingBots.length > 1) {
|
||||
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">
|
||||
{groupName ?? t`Group`}
|
||||
</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>
|
||||
|
||||
{awarenessLabel && awarenessEvent && speaker ? (
|
||||
|
||||
@ -21,8 +21,8 @@ export function StageVoiceEntry({
|
||||
variant="outline"
|
||||
onClick={onStartVoice}
|
||||
data-testid="stage-voice-primary"
|
||||
aria-label={voiceReady ? t`Sprechen starten` : t`Sprache einrichten`}
|
||||
title={voiceReady ? t`Sprechen starten` : t`Sprache einrichten`}
|
||||
aria-label={voiceReady ? t`Start talking` : t`Set up voice`}
|
||||
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 ${
|
||||
voiceReady
|
||||
? "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]" />
|
||||
) : null}
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
isDoingStatus,
|
||||
isNeedsYouStatus,
|
||||
@ -9,6 +10,11 @@ import {
|
||||
} from "./agent-activity";
|
||||
|
||||
describe("agent-activity", () => {
|
||||
beforeAll(() => {
|
||||
i18n.load("en", {});
|
||||
i18n.activate("en");
|
||||
});
|
||||
|
||||
it("classifies needs-you and doing statuses", () => {
|
||||
expect(isNeedsYouStatus("waiting_input")).toBe(true);
|
||||
expect(isNeedsYouStatus("waiting_takeover")).toBe(true);
|
||||
@ -17,16 +23,16 @@ describe("agent-activity", () => {
|
||||
expect(isDoingStatus("idle")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns German doing verbs instead of raw status", () => {
|
||||
expect(rosterDoingVerb("waiting_input")).toBe("Braucht deine Antwort");
|
||||
expect(rosterDoingVerb("running")).toBe("Arbeitet…");
|
||||
it("returns doing verbs instead of raw status", () => {
|
||||
expect(rosterDoingVerb("waiting_input")).toBe("Needs your reply");
|
||||
expect(rosterDoingVerb("running")).toBe("Working…");
|
||||
expect(rosterDoingVerb("idle")).toBeNull();
|
||||
});
|
||||
|
||||
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", "")).toBe("Bereit");
|
||||
expect(rosterActivitySubtitle("idle", "")).toBe("Ready");
|
||||
});
|
||||
|
||||
it("maps status dot tones", () => {
|
||||
@ -36,7 +42,7 @@ describe("agent-activity", () => {
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import { i18n } from "@lingui/core";
|
||||
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. */
|
||||
export function isNeedsYouStatus(status: string | undefined): boolean {
|
||||
return status === "waiting_input" || status === "waiting_takeover";
|
||||
@ -18,19 +22,19 @@ export function isFailedRoutine(run: RunActivityRow): boolean {
|
||||
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 {
|
||||
switch (status) {
|
||||
case "queued":
|
||||
return "Wartet an…";
|
||||
return i18n._("Queued…");
|
||||
case "leased":
|
||||
return "Startet…";
|
||||
return i18n._("Starting…");
|
||||
case "running":
|
||||
return "Arbeitet…";
|
||||
return i18n._("Working…");
|
||||
case "waiting_input":
|
||||
return "Braucht deine Antwort";
|
||||
return i18n._("Needs your reply");
|
||||
case "waiting_takeover":
|
||||
return "Braucht dich";
|
||||
return i18n._("Needs you");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@ -39,27 +43,27 @@ export function rosterDoingVerb(status: string | undefined): string | null {
|
||||
/** Short chip label for needs-you states. */
|
||||
export function needsYouChipLabel(status: string | undefined): string | 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 {
|
||||
switch (status) {
|
||||
case "queued":
|
||||
return "Wartet an…";
|
||||
return i18n._("Queued…");
|
||||
case "leased":
|
||||
return "Startet…";
|
||||
return i18n._("Starting…");
|
||||
case "running":
|
||||
return "Arbeitet…";
|
||||
return i18n._("Working…");
|
||||
case "waiting_input":
|
||||
return "Braucht deine Antwort";
|
||||
return i18n._("Needs your reply");
|
||||
case "waiting_takeover":
|
||||
return "Braucht dich";
|
||||
return i18n._("Needs you");
|
||||
case "completed":
|
||||
return "Erledigt";
|
||||
return i18n._("Done");
|
||||
case "failed":
|
||||
return "Fehlgeschlagen";
|
||||
return i18n._("Failed");
|
||||
case "cancelled":
|
||||
return "Abgebrochen";
|
||||
return i18n._("Cancelled");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
@ -69,7 +73,7 @@ export function runActivityLabel(status: RunActivityRow["status"]): string {
|
||||
export function rosterActivitySubtitle(
|
||||
status: string | undefined,
|
||||
preview: string | undefined,
|
||||
idleFallback = "Bereit",
|
||||
idleFallback = i18n._("Ready"),
|
||||
): string {
|
||||
const verb = rosterDoingVerb(status);
|
||||
if (verb) return verb;
|
||||
|
||||
@ -15,11 +15,11 @@ function goalStatusTone(status: ProjectGoalStatus): string {
|
||||
function goalStatusLabel(status: ProjectGoalStatus): string {
|
||||
switch (status) {
|
||||
case "open":
|
||||
return t`Offen`;
|
||||
return t`To do`;
|
||||
case "doing":
|
||||
return t`In Arbeit`;
|
||||
return t`In progress`;
|
||||
case "done":
|
||||
return t`Erledigt`;
|
||||
return t`Done`;
|
||||
default: {
|
||||
const _exhaustive: never = status;
|
||||
return _exhaustive;
|
||||
@ -62,12 +62,12 @@ function sessionStatusLabel(status: ProjectSession["status"]): string {
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const delta = Date.now() - new Date(iso).getTime();
|
||||
const minutes = Math.round(delta / 60_000);
|
||||
if (minutes < 1) return t`gerade eben`;
|
||||
if (minutes < 60) return t`vor ${minutes} Min.`;
|
||||
if (minutes < 1) return t`just now`;
|
||||
if (minutes < 60) return t`${minutes}m ago`;
|
||||
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);
|
||||
return t`vor ${days} T.`;
|
||||
return t`${days}d ago`;
|
||||
}
|
||||
|
||||
type ProjectSessionTimelineProps = {
|
||||
@ -165,13 +165,12 @@ export function ProjectSessionTimeline({
|
||||
<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">
|
||||
<p className="text-ui-label font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<Trans>Projekt</Trans>
|
||||
<Trans>Project</Trans>
|
||||
</p>
|
||||
<h2 className="text-ui-body font-semibold tracking-tight text-foreground">{sectionName}</h2>
|
||||
<p className="mt-1 text-ui-body-sm text-muted-foreground">
|
||||
<Trans>
|
||||
Goals für dieses Projekt — unabhängig von Chat-Nachrichten. Unterhaltungen findest du
|
||||
weiter unten.
|
||||
Goals for this project, separate from chat messages. Conversations are below.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
@ -187,18 +186,18 @@ export function ProjectSessionTimeline({
|
||||
<Trans>Goals</Trans>
|
||||
</h3>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{loadingGoals ? (
|
||||
<p className="text-ui-body-sm text-muted-foreground">
|
||||
<Trans>Goals werden geladen…</Trans>
|
||||
<Trans>Loading goals…</Trans>
|
||||
</p>
|
||||
) : goals.length === 0 ? (
|
||||
<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">
|
||||
<Trans>Noch keine Goals in diesem Projekt.</Trans>
|
||||
<Trans>No goals in this project yet.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@ -217,7 +216,7 @@ export function ProjectSessionTimeline({
|
||||
<div className="min-w-0 flex-1">
|
||||
<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">
|
||||
Goal
|
||||
<Trans>Goal</Trans>
|
||||
</span>
|
||||
<p className="truncate text-ui-body font-medium text-foreground">
|
||||
{goal.title}
|
||||
@ -252,7 +251,7 @@ export function ProjectSessionTimeline({
|
||||
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"
|
||||
>
|
||||
<Trans>Voice First starten</Trans>
|
||||
<Trans>Start Voice First</Trans>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
@ -260,7 +259,7 @@ export function ProjectSessionTimeline({
|
||||
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"
|
||||
>
|
||||
<Trans>Unterhaltung öffnen</Trans>
|
||||
<Trans>Open conversation</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
@ -274,7 +273,7 @@ export function ProjectSessionTimeline({
|
||||
type="text"
|
||||
value={newGoalTitle}
|
||||
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"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void handleCreateGoal();
|
||||
@ -286,7 +285,7 @@ export function ProjectSessionTimeline({
|
||||
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"
|
||||
>
|
||||
<Trans>Goal anlegen</Trans>
|
||||
<Trans>Add goal</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@ -297,26 +296,26 @@ export function ProjectSessionTimeline({
|
||||
id="project-chats-heading"
|
||||
className="text-ui-body-sm font-semibold text-foreground"
|
||||
>
|
||||
<Trans>Unterhaltungen</Trans>
|
||||
<Trans>Conversations</Trans>
|
||||
</h3>
|
||||
<span className="text-ui-label text-muted-foreground">{groupName}</span>
|
||||
</div>
|
||||
|
||||
{loadingSessions ? (
|
||||
<p className="text-ui-body-sm text-muted-foreground">
|
||||
<Trans>Unterhaltungen werden geladen…</Trans>
|
||||
<Trans>Loading conversations…</Trans>
|
||||
</p>
|
||||
) : sessions.length === 0 ? (
|
||||
<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">
|
||||
<Trans>Noch keine Unterhaltung in diesem Projekt-Chat.</Trans>
|
||||
<Trans>No conversations in this project chat yet.</Trans>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<Trans>Chat starten</Trans>
|
||||
<Trans>Start chat</Trans>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@ -371,7 +370,7 @@ export function ProjectSessionTimeline({
|
||||
onClick={() => setSessionsExpanded(true)}
|
||||
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>
|
||||
) : null}
|
||||
<button
|
||||
@ -379,7 +378,7 @@ export function ProjectSessionTimeline({
|
||||
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"
|
||||
>
|
||||
<Trans>Neue Unterhaltung</Trans>
|
||||
<Trans>New conversation</Trans>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -3558,7 +3558,7 @@ export function ShellPage() {
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
heuteActionCount > 0 ? t`Heute, ${heuteActionCount} need you` : t`Heute`
|
||||
heuteActionCount > 0 ? t`Today, ${heuteActionCount} need you` : t`Today`
|
||||
}
|
||||
onClick={() => {
|
||||
ooSound.play("interact");
|
||||
@ -3583,18 +3583,18 @@ export function ShellPage() {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="font-semibold text-foreground/90 tracking-tight text-ui-body">
|
||||
Heute
|
||||
<Trans>Today</Trans>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar-unterhaltungen">
|
||||
<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">
|
||||
<Popover open={conversationPickerOpen} onOpenChange={setConversationPickerOpen}>
|
||||
<PopoverTrigger
|
||||
type="button"
|
||||
aria-label={t`Neue Unterhaltung`}
|
||||
aria-label={t`New conversation`}
|
||||
data-testid="conversation-picker-trigger"
|
||||
className="text-muted-foreground/60 hover:text-foreground/90 transition-colors"
|
||||
>
|
||||
@ -3638,7 +3638,7 @@ export function ShellPage() {
|
||||
) : null}
|
||||
{conversationSidebarGroups.length === 0 && !mainConversationEntry ? (
|
||||
<div className="p-4 text-center text-ui-label text-muted-foreground">
|
||||
<p>{t`Noch keine Unterhaltung.`}</p>
|
||||
<p>{t`No conversations yet.`}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@ -3646,7 +3646,7 @@ export function ShellPage() {
|
||||
onClick={() => setConversationPickerOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 size-icon-sm" />
|
||||
{t`Neue Unterhaltung`}
|
||||
{t`New conversation`}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
@ -3896,8 +3896,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 text-muted-foreground hover:bg-sidebar-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
{expandedTeamGroups[group.key]
|
||||
? "Weniger anzeigen"
|
||||
: `Alle ${group.bots.length} anzeigen...`}
|
||||
? t`Show less`
|
||||
: t`Show all ${group.bots.length}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@ -3907,7 +3907,7 @@ export function ShellPage() {
|
||||
{/* PROJEKTE SECTION */}
|
||||
<div className="flex items-center justify-between pt-4 pb-1 mt-3 border-t border-white/5 px-2.5">
|
||||
<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 text-muted-foreground/70">
|
||||
{t`Threads by topic · folders`}
|
||||
</p>
|
||||
@ -4059,7 +4059,7 @@ export function ShellPage() {
|
||||
onClick={() => openProjectStart(group.spaceId, "group")}
|
||||
>
|
||||
<Plus className="mr-1 size-icon-sm" />
|
||||
{t`Starten`}
|
||||
{t`Start`}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
@ -4485,21 +4485,19 @@ export function ShellPage() {
|
||||
>
|
||||
{inGroup
|
||||
? (activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`)
|
||||
: (active?.name ?? t`Unterhaltung wählen`)}
|
||||
: (active?.name ?? t`Choose a conversation`)}
|
||||
</span>
|
||||
{!inGroup && active && (composerRunning || active.status !== "idle") ? (
|
||||
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
|
||||
{composerRunning || active.status === "running"
|
||||
? t`Beschäftigt`
|
||||
: active.status}
|
||||
{composerRunning || active.status === "running" ? t`Busy` : active.status}
|
||||
</span>
|
||||
) : inGroup && projectSessionKey ? (
|
||||
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
|
||||
{t`Gruppenunterhaltung`}
|
||||
{t`Group conversation`}
|
||||
</span>
|
||||
) : inGroup && activeGroup?.kind !== "main" ? (
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
@ -4533,7 +4531,7 @@ export function ShellPage() {
|
||||
onClick={() => currentSpaceId && openProjectStart(currentSpaceId, "create")}
|
||||
>
|
||||
<Plus aria-hidden="true" className="size-icon-md" />
|
||||
{t`Starten`}
|
||||
{t`Start`}
|
||||
</Button>
|
||||
</div>
|
||||
) : !active && !activeGroup && initialBotsLoaded ? (
|
||||
@ -4546,7 +4544,7 @@ export function ShellPage() {
|
||||
) : showProjectTimeline && bots.length > 0 ? (
|
||||
<ProjectSessionTimeline
|
||||
sectionId={activeGroup!.sectionId!}
|
||||
sectionName={activeProjectSection?.name ?? activeGroup?.name ?? t`Projekt`}
|
||||
sectionName={activeProjectSection?.name ?? activeGroup?.name ?? t`Project`}
|
||||
groupId={groupId!}
|
||||
groupName={activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`}
|
||||
onOpenSession={openProjectSession}
|
||||
@ -4557,7 +4555,7 @@ export function ShellPage() {
|
||||
) : showProjectTimeline && bots.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-12">
|
||||
<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>
|
||||
<Button onClick={() => setShowCreateBotModal(true)}>
|
||||
<Plus aria-hidden="true" className="size-icon-md" />
|
||||
@ -6059,11 +6057,7 @@ const Transcript = memo(function Transcript({
|
||||
>
|
||||
{encounterMode ? (
|
||||
<p className="mx-auto w-full max-w-2xl px-1 text-ui-label font-medium uppercase tracking-wider text-muted-foreground/80">
|
||||
{voiceEncounterActive ? (
|
||||
<Trans>Transkript — sekundär zur Stimme</Trans>
|
||||
) : (
|
||||
<Trans>Conversation record</Trans>
|
||||
)}
|
||||
{voiceEncounterActive ? <Trans>Transcript</Trans> : <Trans>Conversation record</Trans>}
|
||||
</p>
|
||||
) : null}
|
||||
{olderCursor != null ? (
|
||||
@ -7905,10 +7899,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"
|
||||
title={t`Antwort kopieren`}
|
||||
title={t`Copy reply`}
|
||||
>
|
||||
<Copy size={13.5} />
|
||||
<span>{t`Kopieren`}</span>
|
||||
<span>{t`Copy`}</span>
|
||||
</button>
|
||||
{voiceReady ? (
|
||||
<button
|
||||
@ -7918,7 +7912,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"
|
||||
>
|
||||
<Volume2 size={13.5} />
|
||||
<span>{speaking ? t`Stop` : t`Vorlesen`}</span>
|
||||
<span>{speaking ? t`Stop` : t`Read aloud`}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
import { Info, Lock, Plus, Users } from "lucide-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({
|
||||
bots,
|
||||
onCreateBot,
|
||||
@ -31,29 +31,31 @@ export function ConversationBotPicker({
|
||||
(bot) => bot.name.toLowerCase().includes(needle) || bot.title.toLowerCase().includes(needle),
|
||||
);
|
||||
}, [bots, needle]);
|
||||
const createLabel = t`Create new Bot`;
|
||||
const createText = createLabel.toLowerCase();
|
||||
const showCreate =
|
||||
!needle ||
|
||||
"neuen bot erstellen".includes(needle) ||
|
||||
needle.split(/\s+/).every((part) => "neuen bot erstellen".includes(part));
|
||||
createText.includes(needle) ||
|
||||
needle.split(/\s+/).every((part) => createText.includes(part));
|
||||
|
||||
return (
|
||||
<div data-testid="conversation-bot-picker" className="w-[min(320px,calc(100vw-2rem))]">
|
||||
<label className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<span className="shrink-0 text-ui-body-sm text-muted-foreground">
|
||||
<Trans>Mit:</Trans>
|
||||
<Trans>With:</Trans>
|
||||
</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t`Suchen`}
|
||||
aria-label={t`Suchen`}
|
||||
placeholder={t`Search`}
|
||||
aria-label={t`Search`}
|
||||
className="min-w-0 flex-1 bg-transparent text-ui-body text-foreground outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</label>
|
||||
<Command shouldFilter={false} className="rounded-none border-0 bg-transparent p-0">
|
||||
<CommandList className="max-h-72 p-1">
|
||||
<CommandEmpty>
|
||||
<Trans>Kein Bot gefunden</Trans>
|
||||
<Trans>No bots match</Trans>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{showCreate ? (
|
||||
@ -64,7 +66,7 @@ export function ConversationBotPicker({
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus strokeWidth={1.8} aria-hidden="true" className="size-icon-md" />
|
||||
<Trans>Neuen Bot erstellen</Trans>
|
||||
{createLabel}
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{matched.map((bot) => (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user