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:
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}",
|
||||
];
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user