* Pick the voice from a carousel in the app language, local voice only Voice settings now list only the voices that speak the app language (German shows German voices, English shows English ones) as a horizontal carousel: one card per voice, a play button next to the name, tap to make it the active voice. Changing the app language moves the saved voice to that language. The hosted voice providers (ElevenLabs, OpenAI, Cartesia, Fish Audio) are removed; Kokoro on the server is the only voice. E2E runs against a stub Kokoro server so the carousel is covered. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57 * test(e2e): measure the workspace panel width after its transition settles Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57 --------- Co-authored-by: Claude <noreply@anthropic.com>
491 lines
15 KiB
TypeScript
491 lines
15 KiB
TypeScript
import { Trans, useLingui } from "@lingui/react/macro";
|
||
import { Button, Field, FieldLabel, Input, Toggle } from "@rakazo/ui-web";
|
||
import { ChevronDown } from "lucide-react";
|
||
import {
|
||
type KeyboardEvent as ReactKeyboardEvent,
|
||
type RefObject,
|
||
useEffect,
|
||
useId,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { Link } from "react-router-dom";
|
||
import { ApprovalRulesSettings } from "../components/ApprovalRulesSettings";
|
||
import { SuccessPop } from "../components/ai/primitives";
|
||
import { ComputersUnavailableHint } from "../components/ComputersUnavailableHint";
|
||
import { DesktopUpdateSection } from "../components/DesktopUpdates";
|
||
import { SoftwareUpdateSection } from "../components/SoftwareUpdateSection";
|
||
import { UserProfileSettings } from "../components/UserProfileSettings";
|
||
import { authClient } from "../lib/auth";
|
||
import { getActiveUiLocale, setUiLocale } from "../lib/i18n";
|
||
import {
|
||
type AppearancePreference,
|
||
getUiAppearancePreference,
|
||
setUiAppearance,
|
||
} from "../lib/ui-appearance";
|
||
import { UI_LOCALE_LABELS, UI_LOCALES, type UiLocale } from "../lib/ui-locale";
|
||
import { alignVoiceWithLocale } from "../lib/voice-locale";
|
||
|
||
export type SettingsGeneralProps = {
|
||
email?: string | null;
|
||
name: string;
|
||
messagingEnabled?: boolean;
|
||
onOpenMessaging?: () => void;
|
||
isDeploymentOwner?: boolean;
|
||
};
|
||
|
||
export function GeneralSettingsPanels({
|
||
email,
|
||
name,
|
||
messagingEnabled = false,
|
||
onOpenMessaging,
|
||
isDeploymentOwner = false,
|
||
}: SettingsGeneralProps) {
|
||
const [locale, setLocale] = useState<UiLocale>(() => getActiveUiLocale());
|
||
const localeRequestRef = useRef(0);
|
||
const [appearance, setAppearance] = useState<AppearancePreference>(() =>
|
||
getUiAppearancePreference(),
|
||
);
|
||
|
||
function chooseLocale(next: UiLocale) {
|
||
if (next === locale) return;
|
||
const requestId = ++localeRequestRef.current;
|
||
setLocale(next);
|
||
void setUiLocale(next).then((activated) => {
|
||
void alignVoiceWithLocale(activated).catch(() => undefined);
|
||
if (requestId !== localeRequestRef.current) return;
|
||
setLocale(activated);
|
||
});
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<section className="rounded-xl border border-border px-4 py-4">
|
||
<h3 className="text-ui-body font-medium text-foreground">
|
||
<Trans>Account</Trans>
|
||
</h3>
|
||
<p className="mt-3 text-ui-body text-foreground/75">{name}</p>
|
||
{email ? <p className="mt-1 text-ui-body-sm text-muted-foreground/70">{email}</p> : null}
|
||
</section>
|
||
|
||
<UserProfileSettings />
|
||
|
||
<ChangePasswordSection email={email} />
|
||
|
||
{messagingEnabled && onOpenMessaging ? (
|
||
<section className="rounded-xl border border-border px-4 py-4">
|
||
<h3 className="text-ui-body font-medium text-foreground">
|
||
<Trans>Messaging</Trans>
|
||
</h3>
|
||
<p className="mt-3 text-ui-body-sm text-muted-foreground/70">
|
||
<Trans>Chat apps, group channels, and agent connections.</Trans>
|
||
</p>
|
||
<Button variant="secondary" className="mt-3 rounded-full" onClick={onOpenMessaging}>
|
||
<Trans>Manage messaging settings</Trans>
|
||
</Button>
|
||
</section>
|
||
) : null}
|
||
|
||
<section className="rounded-xl border border-border px-4 py-4">
|
||
<h3 className="text-ui-body font-medium text-foreground">
|
||
<Trans>Appearance</Trans>
|
||
</h3>
|
||
<AppearancePicker
|
||
value={appearance}
|
||
onChange={(next) => {
|
||
setAppearance(next);
|
||
setUiAppearance(next);
|
||
}}
|
||
/>
|
||
</section>
|
||
|
||
<section className="rounded-xl border border-border px-4 py-4">
|
||
<h3 className="text-ui-body font-medium text-foreground">
|
||
<Trans>Language</Trans>
|
||
</h3>
|
||
<UiLocalePicker value={locale} onChange={chooseLocale} />
|
||
</section>
|
||
|
||
{isDeploymentOwner ? (
|
||
<Button variant="outline" render={<Link to="/integrations/setup" />}>
|
||
<Trans>Server integrations</Trans>
|
||
</Button>
|
||
) : null}
|
||
|
||
<details data-testid="advanced-settings" className="group rounded-xl border border-border">
|
||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-4 py-4 text-ui-body text-foreground/75">
|
||
<span>
|
||
<span className="block text-ui-body text-foreground">
|
||
<Trans>Advanced</Trans>
|
||
</span>
|
||
<span className="mt-1 block text-ui-body-sm text-muted-foreground/80">
|
||
<Trans>Optional controls most people never need</Trans>
|
||
</span>
|
||
</span>
|
||
<span aria-hidden="true" className="transition-transform group-open:rotate-90">
|
||
›
|
||
</span>
|
||
</summary>
|
||
<div className="border-t border-border px-4 pb-5">
|
||
<ApprovalRulesSettings />
|
||
</div>
|
||
</details>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function UsageSettingsPanel({
|
||
usage,
|
||
panelRef,
|
||
}: {
|
||
usage?: { runs: number; inputTokens: number; outputTokens: number } | null;
|
||
panelRef?: RefObject<HTMLDivElement | null>;
|
||
}) {
|
||
return (
|
||
<div
|
||
ref={panelRef}
|
||
tabIndex={-1}
|
||
data-testid="usage-settings"
|
||
className="rounded-xl border border-border px-4 py-4 outline-none"
|
||
>
|
||
<h3 className="text-ui-body font-medium text-foreground">
|
||
<Trans>Usage</Trans>
|
||
</h3>
|
||
{usage ? (
|
||
<p className="mt-3 text-ui-body text-foreground/75">
|
||
<Trans>
|
||
{usage.runs} runs · {usage.inputTokens + usage.outputTokens} tokens
|
||
</Trans>
|
||
</p>
|
||
) : null}
|
||
<p className={`text-ui-body-sm text-muted-foreground/80 ${usage ? "mt-2" : "mt-3"}`}>
|
||
<Trans>Model spend uses your provider keys.</Trans>
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ComputerSettingsPanel() {
|
||
return (
|
||
<div
|
||
data-testid="computers-setup-settings"
|
||
className="rounded-xl border border-border px-4 py-4"
|
||
>
|
||
<h3 className="text-ui-body font-medium text-foreground">
|
||
<Trans>Computers</Trans>
|
||
</h3>
|
||
<ComputersUnavailableHint className="mt-3 text-ui-body-sm leading-relaxed text-muted-foreground" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function UpdatesSettingsPanel({
|
||
isDeploymentOwner = false,
|
||
}: {
|
||
isDeploymentOwner?: boolean;
|
||
}) {
|
||
return (
|
||
<div className="space-y-5">
|
||
<DesktopUpdateSection />
|
||
<SoftwareUpdateSection isDeploymentOwner={isDeploymentOwner} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ChangePasswordSection({ email }: { email?: string | null }) {
|
||
const { t } = useLingui();
|
||
const [currentPassword, setCurrentPassword] = useState("");
|
||
const [newPassword, setNewPassword] = useState("");
|
||
const [confirmation, setConfirmation] = useState("");
|
||
const [pending, setPending] = useState(false);
|
||
const [saved, setSaved] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
async function changePassword() {
|
||
if (pending) return;
|
||
if (newPassword !== confirmation) {
|
||
setError(t`Passwords do not match`);
|
||
return;
|
||
}
|
||
setPending(true);
|
||
setSaved(false);
|
||
setError(null);
|
||
try {
|
||
const result = await authClient.changePassword({
|
||
currentPassword,
|
||
newPassword,
|
||
revokeOtherSessions: true,
|
||
});
|
||
if (result.error) {
|
||
setError(result.error.message ?? t`Could not change password`);
|
||
return;
|
||
}
|
||
setCurrentPassword("");
|
||
setNewPassword("");
|
||
setConfirmation("");
|
||
setSaved(true);
|
||
} catch {
|
||
setError(t`Could not reach the server`);
|
||
} finally {
|
||
setPending(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="rounded-xl border border-border px-4 py-4">
|
||
<h3 className="text-ui-body font-medium text-foreground">
|
||
<Trans>Password</Trans>
|
||
</h3>
|
||
<div className="mt-3 grid gap-3">
|
||
<input
|
||
type="text"
|
||
name="username"
|
||
autoComplete="username"
|
||
value={email ?? ""}
|
||
readOnly
|
||
tabIndex={-1}
|
||
aria-hidden="true"
|
||
className="sr-only"
|
||
/>
|
||
<SettingsPasswordInput
|
||
label={t`Current password`}
|
||
autoComplete="current-password"
|
||
value={currentPassword}
|
||
onChange={setCurrentPassword}
|
||
/>
|
||
<SettingsPasswordInput
|
||
label={t`New password`}
|
||
autoComplete="new-password"
|
||
value={newPassword}
|
||
onChange={setNewPassword}
|
||
/>
|
||
<SettingsPasswordInput
|
||
label={t`Confirm password`}
|
||
autoComplete="new-password"
|
||
value={confirmation}
|
||
onChange={setConfirmation}
|
||
/>
|
||
</div>
|
||
{error ? (
|
||
<p role="alert" className="mt-3 text-ui-body-sm text-destructive">
|
||
{error}
|
||
</p>
|
||
) : null}
|
||
<div className="mt-4 flex items-center gap-3">
|
||
<Button
|
||
className="rounded-full"
|
||
disabled={pending || currentPassword.length < 8 || newPassword.length < 8}
|
||
onClick={() => void changePassword()}
|
||
>
|
||
{pending ? <Trans>Changing…</Trans> : <Trans>Change password</Trans>}
|
||
</Button>
|
||
{saved ? <SuccessPop label={t`Password updated`} /> : null}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function SettingsPasswordInput({
|
||
label,
|
||
autoComplete,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
autoComplete: "current-password" | "new-password";
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
}) {
|
||
const id = useId();
|
||
return (
|
||
<Field>
|
||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||
<Input
|
||
id={id}
|
||
type="password"
|
||
autoComplete={autoComplete}
|
||
minLength={8}
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
/>
|
||
</Field>
|
||
);
|
||
}
|
||
|
||
function AppearancePicker({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: AppearancePreference;
|
||
onChange: (next: AppearancePreference) => void;
|
||
}) {
|
||
const { t } = useLingui();
|
||
const options: { value: AppearancePreference; label: string }[] = [
|
||
{ value: "system", label: t`System` },
|
||
{ value: "light", label: t`Light` },
|
||
{ value: "dark", label: t`Dark` },
|
||
];
|
||
|
||
return (
|
||
<fieldset
|
||
aria-label={t`Appearance`}
|
||
data-testid="ui-appearance-select"
|
||
className="mt-3 grid min-w-0 grid-cols-3 gap-1 rounded-lg bg-muted p-1"
|
||
>
|
||
{options.map((option) => (
|
||
<Toggle
|
||
key={option.value}
|
||
data-testid={`ui-appearance-${option.value}`}
|
||
pressed={option.value === value}
|
||
onPressedChange={() => onChange(option.value)}
|
||
className="text-ui-body-sm aria-pressed:bg-background aria-pressed:shadow-sm"
|
||
>
|
||
{option.label}
|
||
</Toggle>
|
||
))}
|
||
</fieldset>
|
||
);
|
||
}
|
||
|
||
function UiLocalePicker({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: UiLocale;
|
||
onChange: (locale: UiLocale) => void;
|
||
}) {
|
||
const { t } = useLingui();
|
||
const rootRef = useRef<HTMLDivElement>(null);
|
||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||
const listboxId = useId();
|
||
const selectedIndex = Math.max(0, UI_LOCALES.indexOf(value));
|
||
const [open, setOpen] = useState(false);
|
||
const [highlightedIndex, setHighlightedIndex] = useState(selectedIndex);
|
||
|
||
useEffect(() => {
|
||
setHighlightedIndex(selectedIndex);
|
||
setOpen(false);
|
||
}, [selectedIndex, value]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
optionRefs.current[highlightedIndex]?.focus();
|
||
}, [highlightedIndex, open]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
function closeOnOutsidePointer(event: PointerEvent) {
|
||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||
}
|
||
document.addEventListener("pointerdown", closeOnOutsidePointer);
|
||
return () => document.removeEventListener("pointerdown", closeOnOutsidePointer);
|
||
}, [open]);
|
||
|
||
function choose(index: number) {
|
||
const next = UI_LOCALES[index];
|
||
if (!next) return;
|
||
onChange(next);
|
||
setOpen(false);
|
||
triggerRef.current?.focus();
|
||
}
|
||
|
||
function moveHighlight(index: number) {
|
||
setHighlightedIndex((index + UI_LOCALES.length) % UI_LOCALES.length);
|
||
}
|
||
|
||
function onTriggerKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {
|
||
if (event.key === "Escape" && open) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setOpen(false);
|
||
return;
|
||
}
|
||
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
setOpen(true);
|
||
return;
|
||
}
|
||
if (event.key === "ArrowUp") {
|
||
event.preventDefault();
|
||
setOpen(true);
|
||
setHighlightedIndex(UI_LOCALES.length - 1);
|
||
}
|
||
}
|
||
|
||
function onOptionKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>, index: number) {
|
||
if (event.key === "ArrowDown") {
|
||
event.preventDefault();
|
||
moveHighlight(index + 1);
|
||
} else if (event.key === "ArrowUp") {
|
||
event.preventDefault();
|
||
moveHighlight(index - 1);
|
||
} else if (event.key === "Home") {
|
||
event.preventDefault();
|
||
setHighlightedIndex(0);
|
||
} else if (event.key === "End") {
|
||
event.preventDefault();
|
||
setHighlightedIndex(UI_LOCALES.length - 1);
|
||
} else if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
choose(index);
|
||
} else if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setOpen(false);
|
||
triggerRef.current?.focus();
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div ref={rootRef} className="relative mt-3">
|
||
<button
|
||
ref={triggerRef}
|
||
type="button"
|
||
role="combobox"
|
||
data-testid="ui-locale-select"
|
||
aria-label={t`Language`}
|
||
aria-controls={listboxId}
|
||
aria-expanded={open}
|
||
aria-haspopup="listbox"
|
||
className="flex h-9 w-full items-center justify-between rounded-lg border border-input bg-transparent px-3 text-start text-sm text-foreground outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50"
|
||
onClick={() => setOpen((current) => !current)}
|
||
onKeyDown={onTriggerKeyDown}
|
||
>
|
||
<span className="min-w-0 truncate">{UI_LOCALE_LABELS[value]}</span>
|
||
<span className="ml-3 shrink-0 text-muted-foreground" aria-hidden="true">
|
||
<ChevronDown strokeWidth={1.8} className="size-icon-md" />
|
||
</span>
|
||
</button>
|
||
{open ? (
|
||
<div
|
||
id={listboxId}
|
||
role="listbox"
|
||
aria-label={t`Language`}
|
||
className="rk-scroll absolute left-0 right-0 top-full z-20 mt-1 overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10"
|
||
>
|
||
{UI_LOCALES.map((code, index) => (
|
||
<button
|
||
key={code}
|
||
ref={(element) => {
|
||
optionRefs.current[index] = element;
|
||
}}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={code === value}
|
||
tabIndex={index === highlightedIndex ? 0 : -1}
|
||
className={`w-full rounded-md px-2 py-1.5 text-start text-sm outline-none hover:bg-accent focus-visible:bg-accent ${
|
||
code === value ? "bg-accent" : ""
|
||
}`}
|
||
onClick={() => choose(index)}
|
||
onKeyDown={(event) => onOptionKeyDown(event, index)}
|
||
>
|
||
{UI_LOCALE_LABELS[code]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|