@@ -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 ? (
@@ -214,6 +231,7 @@ function ConfigGuidance({
onSelectSuggestion: (prompt: string) => void;
onKindChange?: (kind: QuickChatSessionKind) => void;
}) {
+ const { t } = useTranslation();
return (
-
Configuration Chat
+ {t("common:configurationChat")}
-
- Ask an agent to manage workflows, agent profiles, and MCP configuration.
-
+
{t("configChat:dialogDescription")}
{onKindChange && (
- No agent profiles are available. Create one in Agent settings first.
-
+ {t("configChat:noAgentProfiles")}
)}
{hasProfiles && needsProfileSelection && }
diff --git a/apps/web/components/config-chat/use-config-chat.ts b/apps/web/components/config-chat/use-config-chat.ts
index 37dc4cdf76..e97ffa8383 100644
--- a/apps/web/components/config-chat/use-config-chat.ts
+++ b/apps/web/components/config-chat/use-config-chat.ts
@@ -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) {
diff --git a/apps/web/components/global-commands.tsx b/apps/web/components/global-commands.tsx
index 00ee83f0cb..fa0303e380 100644
--- a/apps/web/components/global-commands.tsx
+++ b/apps/web/components/global-commands.tsx
@@ -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: ,
keywords: searchKeywords(t, "common:commandConfigChatKeywords"),
diff --git a/apps/web/components/page-topbar.tsx b/apps/web/components/page-topbar.tsx
index f64f6189ae..27dae58231 100644
--- a/apps/web/components/page-topbar.tsx
+++ b/apps/web/components/page-topbar.tsx
@@ -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 (
-
+
{showBackLink && (
<>
diff --git a/apps/web/components/task/simple/OfficeSimplePane.tsx b/apps/web/components/task/simple/OfficeSimplePane.tsx
index 0d7904cd6f..e1f62c2d44 100644
--- a/apps/web/components/task/simple/OfficeSimplePane.tsx
+++ b/apps/web/components/task/simple/OfficeSimplePane.tsx
@@ -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 (
-
+
diff --git a/apps/web/components/task/task-top-bar-title.tsx b/apps/web/components/task/task-top-bar-title.tsx
index 6cf7b9db1a..aac00ce4fd 100644
--- a/apps/web/components/task/task-top-bar-title.tsx
+++ b/apps/web/components/task/task-top-bar-title.tsx
@@ -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(null);
@@ -92,7 +94,7 @@ export function TaskTopBarTitle({ taskId, taskTitle, isArchived }: TaskTopBarTit
}
return (
-
+
diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs
index 00f3318b6e..3296beb402 100644
--- a/apps/web/eslint.i18n.options.mjs
+++ b/apps/web/eslint.i18n.options.mjs
@@ -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}",
];
diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx
index 672798d064..a4b4a2485d 100644
--- a/apps/web/src/app-shell.tsx
+++ b/apps/web/src/app-shell.tsx
@@ -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`:
+ * `` 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 (
+
+ );
+}
+
export function AppShell({ children }: AppShellProps) {
return (
@@ -31,7 +53,7 @@ export function AppShell({ children }: AppShellProps) {
-
+
diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json
index 659c522d9e..f712a8a031 100644
--- a/apps/web/src/locales/en/common.json
+++ b/apps/web/src/locales/en/common.json
@@ -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",
diff --git a/apps/web/src/locales/en/configChat.json b/apps/web/src/locales/en/configChat.json
new file mode 100644
index 0000000000..040951eebc
--- /dev/null
+++ b/apps/web/src/locales/en/configChat.json
@@ -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"
+}
diff --git a/apps/web/src/locales/pseudo/common.json b/apps/web/src/locales/pseudo/common.json
index 594f304abd..8919c4f5a2 100644
--- a/apps/web/src/locales/pseudo/common.json
+++ b/apps/web/src/locales/pseudo/common.json
@@ -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": "Ŵōŕķśƥàćēś",
diff --git a/apps/web/src/locales/pseudo/configChat.json b/apps/web/src/locales/pseudo/configChat.json
new file mode 100644
index 0000000000..62b30d87ab
--- /dev/null
+++ b/apps/web/src/locales/pseudo/configChat.json
@@ -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": "Ũńķńōŵń ēŕŕōŕ"
+}
diff --git a/docs/i18n.md b/docs/i18n.md
index 6fbe643a8c..d648418c1a 100644
--- a/docs/i18n.md
+++ b/docs/i18n.md
@@ -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 `` 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 `` 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 — ``
+ 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.