feat(i18n): localize Configuration Chat and the last shared aria-labels (#2223)

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-08-04 00:08:04 +02:00
committed by GitHub
parent bb71233826
commit 2b69d35fcf
16 changed files with 337 additions and 46 deletions
+10
View File
@@ -4,6 +4,16 @@ import { Slot } from "radix-ui";
import { cn } from "./lib/utils";
import { IconChevronRight, IconDots } from "@tabler/icons-react";
/**
* `aria-label` is an English default, not a fixed string: `{...props}` spreads
* AFTER it, so any consumer can pass a translated `aria-label` and win.
*
* Do NOT import an i18n runtime here. `@kandev/ui` must stay usable standalone
* and must not depend on the app's i18next instance, so localization happens at
* the call site — see docs/i18n.md ("How it's wired") and the app's three
* consumers (`page-topbar`, `task-top-bar-title`, `OfficeSimplePane`), which all
* pass `aria-label={t("common:breadcrumb")}`.
*/
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav aria-label="breadcrumb" data-slot="breadcrumb" className={cn(className)} {...props} />
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import { t } from "@/lib/i18n";
/**
* Byte-for-byte English for every string this migration externalized, as the
* Configuration Chat surfaces rendered them before it.
*
* An i18n migration must not change copy, and nothing else in the toolchain can
* tell you that it did: `i18n:check` proves a key resolves and that `en` and
* `pseudo` agree, lint proves nothing is hardcoded, and the pseudo-coverage
* oracle proves nothing is left in English. All four are green when a key
* resolves to a *different* sentence than the one it replaced — see
* docs/i18n.md, "An existing key is not automatically the right key".
*
* Two entries here are the ones that hazard actually applies to:
*
* - `common:configurationChat` replaced `common:commandConfigurationChat`,
* which held the byte-identical "Configuration Chat" for the command
* palette. Folding them avoids a twin that could drift; this pins that the
* fold did not move the palette's label.
* - `common:cancel` is reused for the setup footer's Cancel button, which
* rendered a bare literal "Cancel" before.
*/
const CONFIG_CHAT_COPY: Array<[key: string, english: string]> = [
// The feature name, rendered by the FAB, the panel header, the dialog
// heading and the command palette. One key, four surfaces.
["common:configurationChat", "Configuration Chat"],
["common:cancel", "Cancel"],
// The panel chrome.
["configChat:openInQuickChat", "Open in Quick Chat"],
["configChat:closePanel", "Close configuration chat"],
["configChat:tagline", "Configure Kandev with natural language"],
// The setup surface, shared by the floating panel and the quick-chat dialog.
["configChat:agentProfileHeading", "Configuration agent profile"],
[
"configChat:agentProfileHelp",
"Choose the agent with access to configuration tools. This becomes the workspace default.",
],
["configChat:tryAsking", "Try asking"],
["configChat:promptHeading", "What would you like to configure?"],
["configChat:promptPlaceholder", "Ask anything about your configuration..."],
["configChat:startChat", "Start chat"],
["configChat:startingChat", "Starting chat..."],
["configChat:startChatAria", "Start configuration chat"],
[
"configChat:dialogDescription",
"Ask an agent to manage workflows, agent profiles, and MCP configuration.",
],
[
"configChat:noAgentProfiles",
"No agent profiles are available. Create one in Agent settings first.",
],
// Owned by `use-config-chat.ts`, which holds no JSX — lint reported the file
// as clean while both of these reached the user.
[
"configChat:profileUnavailable",
"The selected agent profile is not available yet. Try again shortly.",
],
["configChat:unknownError", "Unknown error"],
];
/**
* `SUGGESTION_PROMPTS` was a SCREAMING_CASE array, which
* `i18next/no-literal-string` skips entirely — four sentences the guard never
* reported. They are display copy the user then SENDS as a prompt, so they are
* translated; pinning them keeps that intentional.
*/
const SUGGESTION_COPY: Array<[key: string, english: string]> = [
["configChat:suggestionAddReviewStep", "Add a 'Code Review' step to my workflow"],
["configChat:suggestionCreateProfile", "Create a new agent profile with auto-approve enabled"],
["configChat:suggestionShowWorkflow", "Show me the current workflow configuration"],
["configChat:suggestionUpdateMcp", "Update the MCP servers for the default agent profile"],
];
/**
* `@kandev/ui` primitives must not import the app's i18n runtime, so their
* English defaults are overridden at the call site. These two are the labels
* the pseudo-coverage oracle reported on every migrated screen.
*/
const SHARED_CHROME_COPY: Array<[key: string, english: string]> = [
// `Breadcrumb`'s own `aria-label="breadcrumb"` default, lowercase as shadcn
// ships it. Overridden by page-topbar, task-top-bar-title and OfficeSimplePane.
["common:breadcrumb", "breadcrumb"],
// sonner's `containerAriaLabel`, which defaults to "Notifications". Kept
// separate from `settings:notifications` (the settings PAGE name, per
// SEGMENT_LABEL_KEYS) on purpose: renaming that page must not rename the
// toast region a screen reader announces.
["common:toastRegionLabel", "Notifications"],
];
describe("Configuration Chat English copy", () => {
it.each([...CONFIG_CHAT_COPY, ...SUGGESTION_COPY, ...SHARED_CHROME_COPY])(
"%s renders its pre-migration English",
(key, english) => {
expect(t(key)).toBe(english);
},
);
it("does not leave a twin of the feature name behind", () => {
// A missing key echoes itself, so this asserts the old palette-only key is
// really gone rather than quietly resolving to the same sentence.
expect(t("common:commandConfigurationChat")).toBe("commandConfigurationChat");
});
it("gives the toast region the same English as the settings page name, from its own key", () => {
// Note this asserts EQUALITY, and a failure is not necessarily a bug.
//
// The two are separate keys with different owners: `settings:notifications`
// is the settings PAGE name (per SEGMENT_LABEL_KEYS), while
// `common:toastRegionLabel` names the global toast region a screen reader
// announces. They happen to render the same word today.
//
// So a red here means only "they have diverged". Decide which one you meant
// to reword, change that one, and update this expectation — do NOT make the
// other follow, which is the coupling the two keys exist to prevent.
expect(t("common:toastRegionLabel")).toBe(t("settings:notifications"));
});
});
@@ -1,6 +1,7 @@
"use client";
import { memo, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useShallow } from "zustand/react/shallow";
import { IconArrowsMaximize, IconSparkles, IconX } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
@@ -24,11 +25,12 @@ function useConfigChatPanelStore() {
}
function PanelHeader({ onExpand, onClose }: { onExpand: () => void; onClose: () => void }) {
const { t } = useTranslation();
return (
<header className="flex h-12 shrink-0 items-center justify-between border-b bg-muted/30 pl-3">
<div className="flex min-w-0 items-center gap-2">
<IconSparkles className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium">Configuration Chat</span>
<span className="truncate text-sm font-medium">{t("common:configurationChat")}</span>
</div>
<div className="flex items-center">
<Tooltip>
@@ -38,19 +40,19 @@ function PanelHeader({ onExpand, onClose }: { onExpand: () => void; onClose: ()
variant="ghost"
className="h-11 w-11 cursor-pointer rounded-none"
onClick={onExpand}
aria-label="Open in Quick Chat"
aria-label={t("configChat:openInQuickChat")}
>
<IconArrowsMaximize className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Open in Quick Chat</TooltipContent>
<TooltipContent>{t("configChat:openInQuickChat")}</TooltipContent>
</Tooltip>
<Button
size="icon"
variant="ghost"
className="h-11 w-11 cursor-pointer rounded-none"
onClick={onClose}
aria-label="Close configuration chat"
aria-label={t("configChat:closePanel")}
>
<IconX className="h-4 w-4" />
</Button>
@@ -133,6 +135,7 @@ export const ConfigChatPanel = memo(function ConfigChatPanel({
setFloatingActionsHost,
}: ConfigChatPanelProps) {
const panel = useConfigChatPanelController(workspaceId);
const { t } = useTranslation();
return (
<Popover open={panel.isOpen} onOpenChange={panel.handleOpenChange}>
@@ -147,15 +150,15 @@ export const ConfigChatPanel = memo(function ConfigChatPanel({
"fixed bottom-[calc(1.5rem+var(--app-status-bar-height))] right-6 z-50 h-12 w-12 cursor-pointer rounded-full shadow-lg",
panel.isOpen && "pointer-events-none opacity-0",
)}
aria-label="Configuration Chat"
aria-label={t("common:configurationChat")}
>
<IconSparkles className="h-6 w-6" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="left">
<p className="font-medium">Configuration Chat</p>
<p className="text-xs text-muted-foreground">Configure Kandev with natural language</p>
<p className="font-medium">{t("common:configurationChat")}</p>
<p className="text-xs text-muted-foreground">{t("configChat:tagline")}</p>
</TooltipContent>
</Tooltip>
<PopoverContent
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconLoader2, IconMessageCircle, IconSend2, IconSparkles } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Textarea } from "@kandev/ui/textarea";
@@ -8,11 +9,22 @@ import { useAppStore } from "@/components/state-provider";
import type { QuickChatSessionKind } from "@/lib/state/slices/ui/types";
import { ConfigurationChatToggle } from "@/components/quick-chat/configuration-chat-toggle";
const SUGGESTION_PROMPTS = [
"Add a 'Code Review' step to my workflow",
"Create a new agent profile with auto-approve enabled",
"Show me the current workflow configuration",
"Update the MCP servers for the default agent profile",
/**
* Catalog KEYS, not copy. Resolving these at module scope would freeze them at
* the boot locale (and hide them from the pseudo-locale, which only ever sees
* text that was never translated) — see docs/i18n.md. `Suggestions` resolves
* each one at render.
*
* These are display copy that the user then SENDS: picking one fills the
* composer, so the agent receives whatever locale the user is reading in. That
* is the intent — a French user should be able to send a French prompt — and
* none of these values is compared or persisted.
*/
const SUGGESTION_PROMPT_KEYS = [
"configChat:suggestionAddReviewStep",
"configChat:suggestionCreateProfile",
"configChat:suggestionShowWorkflow",
"configChat:suggestionUpdateMcp",
];
type ConfigChatSetupBaseProps = {
@@ -31,15 +43,14 @@ type ConfigChatSetupProps = ConfigChatSetupBaseProps &
function ProfileSelector({ onSelect }: { onSelect: (id: string) => void }) {
const profiles = useAppStore((state) => state.agentProfiles.items ?? []);
const { t } = useTranslation();
return (
<section className="space-y-3" aria-labelledby="config-chat-agent-label">
<div>
<h3 id="config-chat-agent-label" className="text-sm font-medium">
Configuration agent profile
{t("configChat:agentProfileHeading")}
</h3>
<p className="text-xs text-muted-foreground">
Choose the agent with access to configuration tools. This becomes the workspace default.
</p>
<p className="text-xs text-muted-foreground">{t("configChat:agentProfileHelp")}</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{profiles.map((profile) => (
@@ -66,20 +77,24 @@ function ProfileSelector({ onSelect }: { onSelect: (id: string) => void }) {
}
function Suggestions({ onSelect }: { onSelect: (prompt: string) => void }) {
const { t } = useTranslation();
return (
<section className="space-y-2" aria-labelledby="config-chat-suggestions-label">
<h3 id="config-chat-suggestions-label" className="text-xs font-medium text-muted-foreground">
Try asking
{t("configChat:tryAsking")}
</h3>
<div className="grid gap-2 sm:grid-cols-2">
{SUGGESTION_PROMPTS.map((prompt) => (
{/* Keyed by the CATALOG KEY, not the resolved prompt: a React key should
be a stable identity, and keying on translated copy remounts every
button on a locale switch. */}
{SUGGESTION_PROMPT_KEYS.map((key) => (
<button
key={prompt}
key={key}
type="button"
onClick={() => onSelect(prompt)}
onClick={() => onSelect(t(key))}
className="min-h-11 rounded-md border px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:bg-accent/50 hover:text-foreground"
>
{prompt}
{t(key)}
</button>
))}
</div>
@@ -100,20 +115,21 @@ function ConfigChatFooter({
startDisabled: boolean;
isStarting: boolean;
}) {
const { t } = useTranslation();
return (
<footer className="flex shrink-0 items-center justify-end gap-2 border-t bg-popover px-4 py-3 sm:px-8">
<Button variant="outline" onClick={onCancel} disabled={disabled} className="cursor-pointer">
Cancel
{t("common:cancel")}
</Button>
<Button
onClick={onStart}
disabled={startDisabled}
className="min-w-28 cursor-pointer"
aria-label="Start configuration chat"
aria-label={t("configChat:startChatAria")}
data-dialog-default-action
>
{isStarting ? <IconLoader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
{isStarting ? "Starting chat..." : "Start chat"}
{isStarting ? t("configChat:startingChat") : t("configChat:startChat")}
</Button>
</footer>
);
@@ -142,6 +158,7 @@ function ConfigPrompt({
onChange,
onSubmit,
}: ConfigPromptProps) {
const { t } = useTranslation();
return (
<div
className="shrink-0 border-t bg-popover px-4 py-4 sm:px-8"
@@ -152,7 +169,7 @@ function ConfigPrompt({
aria-labelledby="config-chat-prompt-label"
>
<h3 id="config-chat-prompt-label" className="text-sm font-medium">
What would you like to configure?
{t("configChat:promptHeading")}
</h3>
<div className="flex items-end gap-2">
<Textarea
@@ -171,7 +188,7 @@ function ConfigPrompt({
onSubmit();
}
}}
placeholder="Ask anything about your configuration..."
placeholder={t("configChat:promptPlaceholder")}
disabled={disabled}
className="min-h-20 max-h-32 resize-y"
/>
@@ -181,7 +198,7 @@ function ConfigPrompt({
onClick={onSubmit}
disabled={!canSubmit}
className="h-11 w-11 shrink-0 cursor-pointer"
aria-label="Start configuration chat"
aria-label={t("configChat:startChatAria")}
>
{isStarting ? (
<IconLoader2 className="h-4 w-4 animate-spin" aria-hidden />
@@ -214,6 +231,7 @@ function ConfigGuidance({
onSelectSuggestion: (prompt: string) => void;
onKindChange?: (kind: QuickChatSessionKind) => void;
}) {
const { t } = useTranslation();
return (
<div
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-8 sm:py-8"
@@ -225,11 +243,9 @@ function ConfigGuidance({
<header className="space-y-2">
<div className="flex items-center gap-2">
<IconSparkles className="h-5 w-5 text-primary" aria-hidden />
<h2 className="text-lg font-semibold">Configuration Chat</h2>
<h2 className="text-lg font-semibold">{t("common:configurationChat")}</h2>
</div>
<p className="text-sm text-muted-foreground">
Ask an agent to manage workflows, agent profiles, and MCP configuration.
</p>
<p className="text-sm text-muted-foreground">{t("configChat:dialogDescription")}</p>
</header>
{onKindChange && (
<ConfigurationChatToggle
@@ -242,9 +258,7 @@ function ConfigGuidance({
)}
{!hasProfiles && (
<p className="text-sm text-muted-foreground">
No agent profiles are available. Create one in Agent settings first.
</p>
<p className="text-sm text-muted-foreground">{t("configChat:noAgentProfiles")}</p>
)}
{hasProfiles && needsProfileSelection && <ProfileSelector onSelect={onSelectProfile} />}
@@ -3,6 +3,7 @@
import { useCallback, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { t } from "@/lib/i18n";
import { updateWorkspaceAction } from "@/app/actions/workspaces";
import { startConfigChat } from "@/lib/api/domains/workspace-api";
import { getQuickChatSetupSessionId } from "@/lib/state/slices/ui/quick-chat-session";
@@ -105,6 +106,12 @@ function registerStartedSession({
// The config-chat endpoint has no title field, so name the tab from the
// opening prompt and save it to the task — otherwise the label would live
// only in this browser and be lost on the next resync.
//
// "Config Chat" is deliberately NOT translated: `persistQuickChatRename`
// writes it to the task title, so translating it would store a
// locale-dependent value that then renders unchanged after a locale switch,
// on surfaces this directory does not own. Same call as the built-in layout
// profile names and the seeded workflow step names.
const derivedName = prompt.slice(0, 40) || "Config Chat";
store.renameQuickChatSession(response.session_id, derivedName);
void persistQuickChatRename(response.session_id, response.task_id, derivedName).catch(
@@ -161,7 +168,7 @@ export function useConfigChat(workspaceId: string) {
.getState()
.agentProfiles.items.find((item) => item.id === agentProfileId);
if (!profile) {
setError("The selected agent profile is not available yet. Try again shortly.");
setError(t("configChat:profileUnavailable"));
return undefined;
}
if (activeWorkspaceStarts.has(workspaceId)) return undefined;
@@ -202,7 +209,10 @@ export function useConfigChat(workspaceId: string) {
return response.session_id;
} catch (err) {
if (latestRequestId.current !== requestId) return undefined;
setError(err instanceof Error ? err.message : "Unknown error");
// `err.message` is an API/network diagnostic and stays English by
// design (docs/i18n.md, "the interpolated-value limit"); the FALLBACK
// for a payload-less throw is our copy and is translated.
setError(err instanceof Error ? err.message : t("configChat:unknownError"));
return undefined;
} finally {
if (activeWorkspaceStarts.get(workspaceId) === workspaceStart) {
+1 -1
View File
@@ -171,7 +171,7 @@ export function GlobalCommands() {
const configChatCommand: CommandItem = useMemo(
() => ({
id: "config-chat",
label: t("common:commandConfigurationChat"),
label: t("common:configurationChat"),
group: t(GROUP_ACTIONS),
icon: <IconSparkles className="size-3.5" />,
keywords: searchKeywords(t, "common:commandConfigChatKeywords"),
+3 -1
View File
@@ -1,6 +1,7 @@
import Link from "@/components/routing/app-link";
import { forwardRef } from "react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { IconArrowLeft, IconHome } from "@tabler/icons-react";
import {
Breadcrumb,
@@ -92,6 +93,7 @@ function TopbarBreadcrumb({
icon?: ReactNode;
showPhoneHome: boolean;
}) {
const { t } = useTranslation();
// The Home prefix is redundant on desktop, where the AppSidebar always shows
// a Home nav item. Only render the back link when a page sets a non-root
// backHref (e.g. a true ancestor route within a section).
@@ -101,7 +103,7 @@ function TopbarBreadcrumb({
// users with no way back. Keep the home crumb phone-only.
const showHomeCrumb = !showBackLink && showPhoneHome;
return (
<Breadcrumb className="relative z-10 min-w-0">
<Breadcrumb className="relative z-10 min-w-0" aria-label={t("common:breadcrumb")}>
<BreadcrumbList className="flex-nowrap text-sm">
{showBackLink && (
<>
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import Link from "@/components/routing/app-link";
import {
IconCopy,
@@ -98,8 +99,9 @@ function commentsReadOnly(task: Task, sessions: TaskSession[]): boolean {
}
function TaskBreadcrumb({ task }: { task: Task }) {
const { t } = useTranslation();
return (
<Breadcrumb>
<Breadcrumb aria-label={t("common:breadcrumb")}>
<BreadcrumbList className="text-sm">
<BreadcrumbItem>
<BreadcrumbLink asChild>
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from "@kandev/ui/breadcrumb";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { Input } from "@kandev/ui/input";
@@ -15,6 +16,7 @@ type TaskTopBarTitleProps = {
export function TaskTopBarTitle({ taskId, taskTitle, isArchived }: TaskTopBarTitleProps) {
const { renameTaskById } = useTaskActions();
const { t } = useTranslation();
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState("");
const titleRef = useRef<HTMLSpanElement>(null);
@@ -92,7 +94,7 @@ export function TaskTopBarTitle({ taskId, taskTitle, isArchived }: TaskTopBarTit
}
return (
<Breadcrumb className="min-w-0 max-w-full">
<Breadcrumb className="min-w-0 max-w-full" aria-label={t("common:breadcrumb")}>
<BreadcrumbList className="min-w-0 max-w-full flex-nowrap text-sm">
<BreadcrumbItem className="min-w-0 max-w-full">
<Tooltip>
+37
View File
@@ -1091,4 +1091,41 @@ export const i18nGuardFiles = [
"components/settings/utility-sections.tsx",
"components/settings/voice-mode-settings.tsx",
"lib/settings/external-mcp-tools.ts",
// Configuration Chat. `ConfigChatProvider` mounts the FAB on EVERY /settings
// route, so its `aria-label="Configuration Chat"` was the last text finding
// the pseudo-coverage oracle reported across the whole migrated surface.
//
// `use-config-chat.ts` holds no JSX, so `mode: "jsx-only"` never inspects it;
// the entry records that it was read by eye. The count from
// `pnpm run lint:i18n` reported it as 0 while it owned two strings that reach
// the user — the `setError` for a missing agent profile, and the fallback for
// a throw with no `message`. It imports the module-level `t` rather than the
// hook so each resolves when `startSession` runs, keeping `t` out of the
// callback's dependency array.
//
// `SUGGESTION_PROMPT_KEYS` in `config-chat-setup.tsx` was `SUGGESTION_PROMPTS`,
// four sentences the guard skipped entirely because the identifier is
// SCREAMING_CASE. They now travel as catalog keys and resolve at render.
//
// Copy lives in a new `configChat` namespace, except the feature NAME
// ("Configuration Chat"), which the command palette also renders and which is
// therefore `common:configurationChat` — `common:commandConfigurationChat`
// held the byte-identical string and was folded into it rather than left as a
// twin free to drift.
//
// Deliberately left in English: the `"Config Chat"` session-name fallback in
// `use-config-chat.ts`. `persistQuickChatRename` writes it to the task title,
// so translating it would persist a locale-dependent value — the same call as
// the built-in layout profile names and the seeded workflow step names.
//
// NOT listed, and not migrated here:
// - `components/settings/config-chat-agent-section.tsx`, the Configuration
// Chat card on Settings → Utility Agents. #2218 migrates it with the page
// that owns it, into `settings:configChatAgent*`.
// - `components/quick-chat/**`. `quick-chat-modal.tsx` renders
// `ConfigChatSetup` in its `presentation="dialog"` form and calls
// `useConfigChat`, so it inherits everything migrated here, but its own
// chrome — and `configuration-chat-toggle.tsx`, which `ConfigChatSetup`
// renders — is still English and belongs to the quick-chat migration.
"components/config-chat/*.{ts,tsx}",
];
+23 -1
View File
@@ -1,3 +1,5 @@
import { useTranslation } from "react-i18next";
import { AppSidebar } from "@/components/app-sidebar/app-sidebar";
import { AppStatusSurfaceProvider } from "@/components/app-status-bar/app-status-surface-provider";
import { CommandPanel } from "@/components/command-panel";
@@ -24,6 +26,26 @@ type AppShellProps = {
children: React.ReactNode;
};
/**
* sonner names its toast region `Notifications alt+T` by default — copy a
* screen-reader user hears on every screen, and the last un-migrated
* `aria-label` the pseudo-coverage oracle reported.
*
* Deliberately its own component rather than a `t()` call in `AppShell`:
* `<I18nProvider>` is mounted BY `AppShell`, so `AppShell`'s own render runs
* outside the provider and would not re-render on a locale switch.
*/
function AppToaster() {
const { t } = useTranslation();
return (
<SonnerToaster
richColors
position="top-right"
containerAriaLabel={t("common:toastRegionLabel")}
/>
);
}
export function AppShell({ children }: AppShellProps) {
return (
<I18nProvider>
@@ -31,7 +53,7 @@ export function AppShell({ children }: AppShellProps) {
<DiffWorkerPoolProvider>
<TooltipProvider>
<ToastProvider>
<SonnerToaster richColors position="top-right" />
<AppToaster />
<SessionFailureToastBridge />
<TaskDeletedToastBridge />
<UpdateAvailableToastBridge />
+3 -1
View File
@@ -7,8 +7,8 @@
"changelog": "Changelog",
"commandAgentsSettings": "Agents Settings",
"commandAgentsSettingsKeywords": "agents, agent settings, agent profiles, installed agents, claude",
"breadcrumb": "breadcrumb",
"commandConfigChatKeywords": "config chat, config mode, configure kandev, workflow configuration, mcp configuration",
"commandConfigurationChat": "Configuration Chat",
"commandExecutorsSettings": "Executors Settings",
"commandExecutorsSettingsKeywords": "executors, executor profiles, execution environment, environment variables, runtime, compute",
"commandGoToAllTasks": "Go to All Tasks",
@@ -34,6 +34,7 @@
"commandThemeKeywords": "theme, color theme, appearance",
"commandWorkspaceSettings": "Workspace Settings",
"commandWorkspaceSettingsKeywords": "workspace, workspaces",
"configurationChat": "Configuration Chat",
"contextCompactionCountHelp": "Kandev infers this count from observed context usage drops. Missing samples or provider resets can make it approximate.",
"contextCompactions": "Compactions",
"createAWorkspaceToConfigureThisIntegration": "Create a workspace to configure this integration.",
@@ -77,6 +78,7 @@
"taskUnavailable": "Task unavailable",
"taskUnavailableDescription": "We could not load this task. It may have been deleted or you may not have access.",
"title": "Title",
"toastRegionLabel": "Notifications",
"unsavedChanges": "Unsaved changes",
"workspace": "Workspace",
"workspaces": "Workspaces",
+21
View File
@@ -0,0 +1,21 @@
{
"agentProfileHeading": "Configuration agent profile",
"agentProfileHelp": "Choose the agent with access to configuration tools. This becomes the workspace default.",
"closePanel": "Close configuration chat",
"dialogDescription": "Ask an agent to manage workflows, agent profiles, and MCP configuration.",
"noAgentProfiles": "No agent profiles are available. Create one in Agent settings first.",
"openInQuickChat": "Open in Quick Chat",
"profileUnavailable": "The selected agent profile is not available yet. Try again shortly.",
"promptHeading": "What would you like to configure?",
"promptPlaceholder": "Ask anything about your configuration...",
"startChat": "Start chat",
"startChatAria": "Start configuration chat",
"startingChat": "Starting chat...",
"suggestionAddReviewStep": "Add a 'Code Review' step to my workflow",
"suggestionCreateProfile": "Create a new agent profile with auto-approve enabled",
"suggestionShowWorkflow": "Show me the current workflow configuration",
"suggestionUpdateMcp": "Update the MCP servers for the default agent profile",
"tagline": "Configure Kandev with natural language",
"tryAsking": "Try asking",
"unknownError": "Unknown error"
}
+3 -1
View File
@@ -7,8 +7,8 @@
"changelog": "Ćĥàńĝēĺōĝ",
"commandAgentsSettings": "Àĝēńţś Śēţţĩńĝś",
"commandAgentsSettingsKeywords": "àĝēńţś, àĝēńţ śēţţĩńĝś, àĝēńţ ƥŕōƒĩĺēś, ĩńśţàĺĺēď àĝēńţś, ćĺàũďē",
"breadcrumb": "ƀŕēàďćŕũḿƀ",
"commandConfigChatKeywords": "ćōńƒĩĝ ćĥàţ, ćōńƒĩĝ ḿōďē, ćōńƒĩĝũŕē ķàńďēv, ŵōŕķƒĺōŵ ćōńƒĩĝũŕàţĩōń, ḿćƥ ćōńƒĩĝũŕàţĩōń",
"commandConfigurationChat": "Ćōńƒĩĝũŕàţĩōń Ćĥàţ",
"commandExecutorsSettings": "Ēxēćũţōŕś Śēţţĩńĝś",
"commandExecutorsSettingsKeywords": "ēxēćũţōŕś, ēxēćũţōŕ ƥŕōƒĩĺēś, ēxēćũţĩōń ēńvĩŕōńḿēńţ, ēńvĩŕōńḿēńţ vàŕĩàƀĺēś, ŕũńţĩḿē, ćōḿƥũţē",
"commandGoToAllTasks": "Ĝō ţō Àĺĺ Ţàśķś",
@@ -34,6 +34,7 @@
"commandThemeKeywords": "ţĥēḿē, ćōĺōŕ ţĥēḿē, àƥƥēàŕàńćē",
"commandWorkspaceSettings": "Ŵōŕķśƥàćē Śēţţĩńĝś",
"commandWorkspaceSettingsKeywords": "ŵōŕķśƥàćē, ŵōŕķśƥàćēś",
"configurationChat": "Ćōńƒĩĝũŕàţĩōń Ćĥàţ",
"contextCompactionCountHelp": "Ķàńďēv ĩńƒēŕś ţĥĩś ćōũńţ ƒŕōḿ ōƀśēŕvēď ćōńţēxţ ũśàĝē ďŕōƥś. Ḿĩśśĩńĝ śàḿƥĺēś ōŕ ƥŕōvĩďēŕ ŕēśēţś ćàń ḿàķē ĩţ àƥƥŕōxĩḿàţē.",
"contextCompactions": "Ćōḿƥàćţĩōńś",
"createAWorkspaceToConfigureThisIntegration": "Ćŕēàţē à ŵōŕķśƥàćē ţō ćōńƒĩĝũŕē ţĥĩś ĩńţēĝŕàţĩōń.",
@@ -77,6 +78,7 @@
"taskUnavailable": "Ţàśķ ũńàvàĩĺàƀĺē",
"taskUnavailableDescription": "Ŵē ćōũĺď ńōţ ĺōàď ţĥĩś ţàśķ. Ĩţ ḿàŷ ĥàvē ƀēēń ďēĺēţēď ōŕ ŷōũ ḿàŷ ńōţ ĥàvē àććēśś.",
"title": "Ţĩţĺē",
"toastRegionLabel": "Ńōţĩƒĩćàţĩōńś",
"unsavedChanges": "Ũńśàvēď ćĥàńĝēś",
"workspace": "Ŵōŕķśƥàćē",
"workspaces": "Ŵōŕķśƥàćēś",
@@ -0,0 +1,21 @@
{
"agentProfileHeading": "Ćōńƒĩĝũŕàţĩōń àĝēńţ ƥŕōƒĩĺē",
"agentProfileHelp": "Ćĥōōśē ţĥē àĝēńţ ŵĩţĥ àććēśś ţō ćōńƒĩĝũŕàţĩōń ţōōĺś. Ţĥĩś ƀēćōḿēś ţĥē ŵōŕķśƥàćē ďēƒàũĺţ.",
"closePanel": "Ćĺōśē ćōńƒĩĝũŕàţĩōń ćĥàţ",
"dialogDescription": "Àśķ àń àĝēńţ ţō ḿàńàĝē ŵōŕķƒĺōŵś, àĝēńţ ƥŕōƒĩĺēś, àńď ḾĆƤ ćōńƒĩĝũŕàţĩōń.",
"noAgentProfiles": "Ńō àĝēńţ ƥŕōƒĩĺēś àŕē àvàĩĺàƀĺē. Ćŕēàţē ōńē ĩń Àĝēńţ śēţţĩńĝś ƒĩŕśţ.",
"openInQuickChat": "Ōƥēń ĩń Qũĩćķ Ćĥàţ",
"profileUnavailable": "Ţĥē śēĺēćţēď àĝēńţ ƥŕōƒĩĺē ĩś ńōţ àvàĩĺàƀĺē ŷēţ. Ţŕŷ àĝàĩń śĥōŕţĺŷ.",
"promptHeading": "Ŵĥàţ ŵōũĺď ŷōũ ĺĩķē ţō ćōńƒĩĝũŕē?",
"promptPlaceholder": "Àśķ àńŷţĥĩńĝ àƀōũţ ŷōũŕ ćōńƒĩĝũŕàţĩōń...",
"startChat": "Śţàŕţ ćĥàţ",
"startChatAria": "Śţàŕţ ćōńƒĩĝũŕàţĩōń ćĥàţ",
"startingChat": "Śţàŕţĩńĝ ćĥàţ...",
"suggestionAddReviewStep": "Àďď à 'Ćōďē Ŕēvĩēŵ' śţēƥ ţō ḿŷ ŵōŕķƒĺōŵ",
"suggestionCreateProfile": "Ćŕēàţē à ńēŵ àĝēńţ ƥŕōƒĩĺē ŵĩţĥ àũţō-àƥƥŕōvē ēńàƀĺēď",
"suggestionShowWorkflow": "Śĥōŵ ḿē ţĥē ćũŕŕēńţ ŵōŕķƒĺōŵ ćōńƒĩĝũŕàţĩōń",
"suggestionUpdateMcp": "Ũƥďàţē ţĥē ḾĆƤ śēŕvēŕś ƒōŕ ţĥē ďēƒàũĺţ àĝēńţ ƥŕōƒĩĺē",
"tagline": "Ćōńƒĩĝũŕē Ķàńďēv ŵĩţĥ ńàţũŕàĺ ĺàńĝũàĝē",
"tryAsking": "Ţŕŷ àśķĩńĝ",
"unknownError": "Ũńķńōŵń ēŕŕōŕ"
}
+28 -3
View File
@@ -349,9 +349,34 @@ completeness check: anything still rendering plain English under it was missed.
- **Locale source of truth**: the `kandev_locale` cookie. The Go shell
(`apps/backend/internal/webapp/shell.go`) reads it to set `<html lang>` and
seed the boot payload, so first paint matches with no flash.
- **Shared UI**: `@kandev/ui` primitives still carry their own hardcoded
accessibility labels ("Close", "Toggle Sidebar"). Localizing them needs a
strings-provider seam in the package; not done yet.
- **Shared UI**: `@kandev/ui` primitives carry their own hardcoded accessibility
labels ("Close", "Toggle Sidebar", "Loading", "Go to next page"). The package
must not import the app's i18n runtime — it has to stay usable standalone — so
these are localized at the **call site**, not in the package. Check the shape
before inventing a seam, because the two halves differ:
- **A label in an ATTRIBUTE needs nothing.** Every one of them sits on an
element that spreads `{...props}` *after* it, so the overridable string prop
the spec asks for already exists and the English literal is only its default.
`Breadcrumb`'s `aria-label="breadcrumb"` was this shape: the fix was
`aria-label={t("common:breadcrumb")}` at its three consumers (`page-topbar`,
`task-top-bar-title`, `OfficeSimplePane`) plus a comment in the package
recording the contract. A `label?: string` prop would have duplicated a prop
React already gives you. Same shape, still un-overridden: `SidebarRail`'s
`aria-label`/`title`, `Spinner`'s `aria-label="Loading"`, and `Pagination`'s
three.
- **A label that is a TEXT CHILD does need a seam**, because a consumer cannot
reach it through `{...props}`: the `<span className="sr-only">` in
`DialogContent`, `SidebarTrigger` and `BreadcrumbEllipsis` ("Close", "Toggle
Sidebar", "More"), and `PaginationPrevious`/`PaginationNext`'s visible
"Previous"/"Next". Each wants an optional label prop defaulting to today's
text. Open — the dialog "Close" is the one a pseudo walk keeps surfacing,
because every integration settings page renders it.
A third-party primitive is the same call: sonner names its toast region
`Notifications alt+T` and accepts `containerAriaLabel`, which `AppToaster` in
`src/app-shell.tsx` passes. That has to be its own component — `<I18nProvider>`
is mounted *by* `AppShell`, so a `t()` call in `AppShell`'s own render sits
outside the provider and would not re-render on a locale switch.
- **Tests**: `vitest.setup.ts` calls `initI18nForTests()`. react-i18next falls
back to the default instance when no provider is present, so no render wrapper
is needed and every test's render tree is unchanged.