diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.tsx index 7d6c050b14..de4d05d01a 100644 --- a/apps/web/components/app-sidebar/app-sidebar-footer.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-footer.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { useRouter, usePathname } from "@/lib/routing/client-router"; import { IconBuildings, @@ -32,7 +33,7 @@ import { workspaceHomeHref, } from "./app-sidebar-workspace-navigation"; import { isSettingsRoute } from "./app-sidebar-route"; -import { connectionIssueDetails } from "../app-status-bar/connection-status-item"; +import { useConnectionIssueCopy } from "../app-status-bar/connection-status-item"; import type { ConnectionIssueSeverity } from "@/lib/types/connection"; type AppSidebarFooterProps = { @@ -108,7 +109,9 @@ function SidebarConnectionWarning({ collapsed: boolean; severity: Exclude; }) { - const details = connectionIssueDetails(severity); + const details = useConnectionIssueCopy(severity); + if (!details) return null; + const { label, description } = details; return ( @@ -116,7 +119,7 @@ function SidebarConnectionWarning({ - {details.description} + {description} ); } @@ -183,6 +186,7 @@ function SidebarFooterDialogs({ } export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebarFooterProps) { + const { t } = useTranslation(); const router = useRouter(); const pathname = usePathname(); const workspaces = useAppStore((s) => s.workspaces); @@ -221,7 +225,7 @@ export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebar > router.push("/stats")} testId="sidebar-stats-button" /> setImproveOpen(true)} testId="sidebar-improve-kandev-button" @@ -244,7 +248,7 @@ export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebar {releaseNotes.showTopbarButton && ( { if (!activeIsOffice) rememberLastKanbanWorkspace(activeWorkspace); diff --git a/apps/web/components/app-sidebar/app-sidebar-header.tsx b/apps/web/components/app-sidebar/app-sidebar-header.tsx index 4531a3e6ed..b4bd403d13 100644 --- a/apps/web/components/app-sidebar/app-sidebar-header.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-header.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "@/components/routing/app-link"; +import { useTranslation } from "react-i18next"; import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; @@ -17,6 +18,7 @@ type AppSidebarHeaderProps = { const COLLAPSE_BUTTON_CLASS = "h-7 w-7 shrink-0 cursor-pointer"; export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHeaderProps) { + const { t } = useTranslation(); const workspaces = useAppStore((s) => s.workspaces); const activeWorkspace = workspaces.items.find( (workspace) => workspace.id === workspaces.activeId, @@ -32,7 +34,7 @@ export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHead K @@ -47,12 +49,12 @@ export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHead size="icon" className={COLLAPSE_BUTTON_CLASS} onClick={onToggleCollapse} - aria-label="Expand sidebar" + aria-label={t("sidebar:expandSidebar")} > - Expand sidebar + {t("sidebar:expandSidebar")} ); @@ -69,7 +71,7 @@ export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHead > - Collapse sidebar + {t("sidebar:collapseSidebar")} ); diff --git a/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx b/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx index 80bff434bc..f676a5975e 100644 --- a/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react"; +import { useTranslation } from "react-i18next"; import dynamic from "@/lib/routing/client-dynamic"; import { useRouter } from "@/lib/routing/client-router"; import { IconMessageCircle, IconSquarePlus } from "@tabler/icons-react"; @@ -76,6 +77,7 @@ function RowActionButton({ icon: Icon, label, testId, onClick }: RowActionButton * global navigation item focused on creating top-level tasks and quick chats. */ export function AppSidebarNewTaskItem({ collapsed }: AppSidebarNewTaskItemProps) { + const { t } = useTranslation(); const router = useRouter(); const workspaceId = useAppStore((s) => s.workspaces.activeId); const workflowId = useAppStore((s) => s.kanban.workflowId); @@ -112,7 +114,7 @@ export function AppSidebarNewTaskItem({ collapsed }: AppSidebarNewTaskItemProps)
setOpen(true)} collapsed={collapsed} disabled={!workspaceId} @@ -123,7 +125,7 @@ export function AppSidebarNewTaskItem({ collapsed }: AppSidebarNewTaskItemProps)
diff --git a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx index b42a7fed8f..62cad9eae2 100644 --- a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx @@ -1,6 +1,7 @@ "use client"; import { IconHome, IconInbox, IconMessageCircle } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; import { useAppStore } from "@/components/state-provider"; import { useInOffice } from "@/hooks/use-in-office"; import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; @@ -13,6 +14,7 @@ type AppSidebarPrimaryNavProps = { }; export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) { + const { t } = useTranslation(); const workspaceId = useAppStore((s) => s.workspaces.activeId); const inboxCount = useAppStore((s) => s.office.inboxCount); const inOffice = useInOffice(); @@ -22,7 +24,7 @@ export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) {
diff --git a/apps/web/components/app-sidebar/app-sidebar-resize-handle.tsx b/apps/web/components/app-sidebar/app-sidebar-resize-handle.tsx index b8d26cb1b1..3ea6779f16 100644 --- a/apps/web/components/app-sidebar/app-sidebar-resize-handle.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-resize-handle.tsx @@ -1,5 +1,7 @@ "use client"; +import { useTranslation } from "react-i18next"; + type AppSidebarResizeHandleProps = { onMouseDown: (e: React.MouseEvent) => void; }; @@ -9,10 +11,11 @@ type AppSidebarResizeHandleProps = { * The full hit area highlights to match the dockview resize sashes. */ export function AppSidebarResizeHandle({ onMouseDown }: AppSidebarResizeHandleProps) { + const { t } = useTranslation(); return (
diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx index 8899721169..62eb0443cf 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx @@ -1,6 +1,7 @@ "use client"; import { forwardRef, useCallback, useState, type ComponentPropsWithoutRef } from "react"; +import { useTranslation } from "react-i18next"; import { useRouter } from "@/lib/routing/client-router"; import { IconBriefcase, @@ -38,12 +39,13 @@ const WorkspaceTrigger = forwardRef< HTMLButtonElement, ComponentPropsWithoutRef<"button"> & { activeName: string; chevronTestId: string } >(function WorkspaceTrigger({ activeName, chevronTestId, className, ...props }, ref) { + const { t } = useTranslation(); return ( - Agent topology + {t("sidebar:agentTopology")} @@ -71,19 +73,20 @@ function AgentsSectionHeaderAction({ router }: { router: { push: (path: string) variant="ghost" size="icon" className="h-5 w-5 cursor-pointer" - aria-label="Add agent" + aria-label={t("sidebar:addAgent")} onClick={() => router.push("/office/agents")} > - Add agent + {t("sidebar:addAgent")}
); } export function AgentsSection({ collapsed }: AgentsSectionProps) { + const { t } = useTranslation(); const router = useRouter(); const inOffice = useInOffice(); const store = useAppStoreApi(); @@ -149,7 +152,7 @@ export function AgentsSection({ collapsed }: AgentsSectionProps) { return ( } @@ -157,7 +160,7 @@ export function AgentsSection({ collapsed }: AgentsSectionProps) { defaultExpanded > {visibleAgents.length === 0 ? ( -

No agents yet

+

{t("sidebar:noAgentsYet")}

) : ( visibleAgents.map((agent) => ) )} @@ -166,6 +169,7 @@ export function AgentsSection({ collapsed }: AgentsSectionProps) { } function AgentRow({ agent }: { agent: AgentProfile }) { + const { t } = useTranslation(); const pathname = usePathname(); const href = `/office/agents/${agent.id}`; const isActive = pathname === href; @@ -196,12 +200,12 @@ function AgentRow({ agent }: { agent: AgentProfile }) { title={agent.pauseReason} className="rounded-full bg-red-500/15 text-red-600 dark:text-red-400 px-1.5 py-0.5 text-[10px] font-medium" > - paused + {t("sidebar:pausedBadge")} ) : null} {!isAutoPaused && errorCount > 0 ? ( - {errorCount} error{errorCount === 1 ? "" : "s"} + {t("sidebar:runErrors", { count: errorCount })} ) : null} {liveCount > 0 && } diff --git a/apps/web/components/app-sidebar/sections/integrations-section.tsx b/apps/web/components/app-sidebar/sections/integrations-section.tsx index b133a9d6fa..26a70af660 100644 --- a/apps/web/components/app-sidebar/sections/integrations-section.tsx +++ b/apps/web/components/app-sidebar/sections/integrations-section.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "@/components/routing/app-link"; +import { useTranslation } from "react-i18next"; import { usePathname } from "@/lib/routing/client-router"; import type { ComponentType } from "react"; import { @@ -91,6 +92,7 @@ function IntegrationRow({ href, label, icon: Icon, active, testId }: Integration } export function IntegrationsSection({ collapsed }: IntegrationsSectionProps) { + const { t } = useTranslation(); const pathname = usePathname(); const links = useConfiguredIntegrationLinks(); // Plugin-registered nav items that target this section @@ -104,7 +106,7 @@ export function IntegrationsSection({ collapsed }: IntegrationsSectionProps) { return ( 0 ? : undefined} diff --git a/apps/web/components/app-sidebar/sections/office-navigation-section.test.tsx b/apps/web/components/app-sidebar/sections/office-navigation-section.test.tsx index a8be7217bb..44c2a612d7 100644 --- a/apps/web/components/app-sidebar/sections/office-navigation-section.test.tsx +++ b/apps/web/components/app-sidebar/sections/office-navigation-section.test.tsx @@ -1,5 +1,7 @@ +import { act } from "react"; import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { activateLocale } from "@/lib/i18n"; let pathname = "/office"; @@ -90,3 +92,29 @@ describe("OfficeNavigationSection", () => { expect(badge.classList.contains("bg-primary")).toBe(false); }); }); + +/** + * `workItems` / `workspaceItems` are module-scope tables. A `t()` call written + * inside them resolves once at import and freezes at the boot locale — it still + * renders translated, so neither lint nor the pseudo-locale can see the bug. + * Storing the key and resolving at render is what this asserts. + */ +describe("OfficeNavigationSection locale switching", () => { + afterEach(async () => { + await act(async () => { + await activateLocale("en"); + }); + }); + + it("re-renders its module-scope nav labels when the locale changes", async () => { + render(); + expect(screen.getByRole("link", { name: /Preferences/i })).toBeTruthy(); + + await act(async () => { + await activateLocale("pseudo"); + }); + + expect(screen.queryByRole("link", { name: /^Preferences$/ })).toBeNull(); + expect(screen.getByRole("link", { name: /Ƥŕēƒēŕēńćēś/ })).toBeTruthy(); + }); +}); diff --git a/apps/web/components/app-sidebar/sections/office-navigation-section.tsx b/apps/web/components/app-sidebar/sections/office-navigation-section.tsx index cb39294953..be1840c1a0 100644 --- a/apps/web/components/app-sidebar/sections/office-navigation-section.tsx +++ b/apps/web/components/app-sidebar/sections/office-navigation-section.tsx @@ -9,6 +9,7 @@ import { IconRoute, IconSettings, } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; import { useAppStore } from "@/components/state-provider"; import { APP_SIDEBAR_SECTION_IDS } from "../app-sidebar-constants"; import { AppSidebarNavItem } from "../app-sidebar-nav-item"; @@ -19,23 +20,26 @@ type OfficeNavigationSectionProps = { section?: "all" | "work" | "office"; }; +// `labelKey`, not `label`: these tables are module scope, so a `t()` here would +// resolve once at import and freeze at the boot locale. Resolve at render below. const workItems = [ - { icon: IconCircleDot, label: "Tasks", href: "/office/tasks" }, - { icon: IconRepeat, label: "Routines", href: "/office/routines" }, + { icon: IconCircleDot, labelKey: "sidebar:tasks", href: "/office/tasks" }, + { icon: IconRepeat, labelKey: "sidebar:routines", href: "/office/routines" }, ] as const; const workspaceItems = [ - { icon: IconBoxMultiple, label: "Skills", href: "/office/workspace/skills" }, - { icon: IconCurrencyDollar, label: "Costs", href: "/office/workspace/costs" }, - { icon: IconHistory, label: "Activity", href: "/office/workspace/activity" }, - { icon: IconRoute, label: "Routing", href: "/office/workspace/routing" }, - { icon: IconSettings, label: "Preferences", href: "/office/workspace/settings" }, + { icon: IconBoxMultiple, labelKey: "sidebar:skills", href: "/office/workspace/skills" }, + { icon: IconCurrencyDollar, labelKey: "sidebar:costs", href: "/office/workspace/costs" }, + { icon: IconHistory, labelKey: "sidebar:activity", href: "/office/workspace/activity" }, + { icon: IconRoute, labelKey: "sidebar:routing", href: "/office/workspace/routing" }, + { icon: IconSettings, labelKey: "sidebar:preferences", href: "/office/workspace/settings" }, ] as const; export function OfficeNavigationSection({ collapsed, section = "all", }: OfficeNavigationSectionProps) { + const { t } = useTranslation(); const dashboard = useAppStore((s) => s.office.dashboard); const taskCount = dashboard?.task_count ?? 0; const routineCount = dashboard?.routine_count ?? 0; @@ -46,7 +50,7 @@ export function OfficeNavigationSection({ {(section === "all" || section === "work") && ( s.office.projects); @@ -30,20 +32,20 @@ export function ProjectsSection({ collapsed }: ProjectsSectionProps) { variant="ghost" size="icon" className="h-5 w-5 cursor-pointer" - aria-label="Add project" + aria-label={t("sidebar:addProject")} onClick={() => router.push("/office/projects")} > - Add project + {t("sidebar:addProject")} ); return ( {activeProjects.length === 0 ? ( -

No projects yet

+

{t("sidebar:noProjectsYet")}

) : ( activeProjects.map((project) => { const taskCount = project.taskCounts?.total ?? 0; diff --git a/apps/web/components/app-sidebar/sections/settings/account-group.tsx b/apps/web/components/app-sidebar/sections/settings/account-group.tsx index 4730ee68e5..7c3bc9aceb 100644 --- a/apps/web/components/app-sidebar/sections/settings/account-group.tsx +++ b/apps/web/components/app-sidebar/sections/settings/account-group.tsx @@ -1,14 +1,17 @@ "use client"; import { IconKey, IconShieldLock, IconUserCircle } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; const ROOT_HREF = "/settings/account"; const DEFAULT_HREF = `${ROOT_HREF}/security`; +// `labelKey`, not `label`: this table is module scope, so a `t()` here would +// freeze at the boot locale (and a SCREAMING_CASE literal is invisible to lint). const ITEMS = [ - { href: `${ROOT_HREF}/security`, label: "Profile & Password", icon: IconShieldLock }, - { href: `${ROOT_HREF}/tokens`, label: "API Tokens", icon: IconKey }, + { href: `${ROOT_HREF}/security`, labelKey: "sidebar:profileAndPassword", icon: IconShieldLock }, + { href: `${ROOT_HREF}/tokens`, labelKey: "sidebar:apiTokens", icon: IconKey }, ]; type AccountGroupProps = { @@ -18,20 +21,21 @@ type AccountGroupProps = { }; export function AccountGroup({ pathname, expanded, onToggle }: AccountGroupProps) { + const { t } = useTranslation(); return ( - {ITEMS.map(({ href, label, icon }) => ( + {ITEMS.map(({ href, labelKey, icon }) => ( s.executors.items); const allProfiles = executors.flatMap((executor) => (executor.profiles ?? []).map((profile) => ({ ...profile, executorType: executor.type })), @@ -21,7 +23,7 @@ export function ExecutorsGroup({ pathname, expanded, onToggle }: ExecutorsGroupP return ( = [ + ["common:prompts", "Prompts"], + ["settings:voiceMode", "Voice Mode"], + ["settings:utilityAgents", "Utility Agents"], + ["settings:secrets", "Secrets"], + ["common:externalMcp", "External MCP"], + ["common:plugins", "Plugins"], + ["common:workspaces", "Workspaces"], + ["sidebar:repositories", "Repositories"], + ["workflows:workflows", "Workflows"], + ["common:integrations", "Integrations"], + ["common:automations", "Automations"], + ["common:executors", "Executors"], + ["common:agents", "Agents"], + ["common:settings", "Settings"], + ["sidebar:account", "Account"], + ["sidebar:profileAndPassword", "Profile & Password"], + ["sidebar:apiTokens", "API Tokens"], +]; + +/** + * Labels that also exist as a route page title, keyed by + * `SEGMENT_LABEL_KEYS` in components/settings/settings-layout-client.tsx. The + * nav and the title are the same words for the same destination and the user + * sees both at once (nav on the left, title above), so they must resolve + * through ONE key. Two keys with identical English today is a translator's + * licence to make them disagree tomorrow. + * + * `sidebar:voiceMode` and `sidebar:utilityAgents` were exactly that mistake and + * were removed in favour of the existing `settings:` keys. + */ +const OWNED_BY_ANOTHER_NAMESPACE = [ + "Prompts", + "Voice Mode", + "Utility Agents", + "Secrets", + "External MCP", + "Plugins", + "Executors", + "Integrations", + "Automations", + "Workspaces", + "Agents", + "Settings", + "Workflows", +]; + +describe("settings nav copy", () => { + it.each(NAV_LABELS)("renders %s as its pinned English", (key, english) => { + expect(t(key)).toBe(english); + }); + + it("keeps every nav label non-empty and free of a leaking key", () => { + for (const [key] of NAV_LABELS) { + const value = t(key); + expect(value).not.toBe(""); + // A missing key echoes the key itself, which reads as copy in the UI. + expect(value).not.toBe(key); + expect(value).not.toContain(":"); + } + }); +}); + +describe("settings nav vs route title", () => { + it("does not re-declare a label another namespace already owns", () => { + const duplicates = Object.entries(sidebarCatalog) + .filter(([, value]) => OWNED_BY_ANOTHER_NAMESPACE.includes(value)) + .map(([key, value]) => `sidebar:${key} = ${JSON.stringify(value)}`); + + expect( + duplicates, + "These labels already have a key in common:/settings:/workflows: and are " + + "rendered as the route's page title from that key. Reuse it instead of " + + "adding a sidebar: twin, or the nav and the title can drift apart.", + ).toEqual([]); + }); +}); diff --git a/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx b/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx index 59c453e0e3..f661adab42 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx @@ -4,6 +4,7 @@ import Link from "@/components/routing/app-link"; import { IconChevronRight } from "@tabler/icons-react"; import type { Icon as TablerIcon } from "@tabler/icons-react"; import { useEffect, useState, type ComponentType, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { Collapsible, CollapsibleContent } from "@kandev/ui/collapsible"; import { cn } from "@/lib/utils"; import { SIDEBAR_ITEM_ACTIVE, SIDEBAR_ITEM_INACTIVE } from "../../app-sidebar-constants"; @@ -92,6 +93,7 @@ export function SettingsGroup({ expanded: controlledExpanded, onToggle, }: SettingsGroupProps) { + const { t } = useTranslation(); const [internalExpanded, setInternalExpanded] = useState(defaultExpanded); const isControlled = controlledExpanded !== undefined; const expanded = isControlled ? controlledExpanded : internalExpanded; @@ -148,7 +150,9 @@ export function SettingsGroup({

- Workspace + {t("common:workspace")}

- Status + {t("common:status")}
@@ -74,7 +76,7 @@ export function AppStatusDrawer({ data-testid="app-status-drawer-scroll-region" className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3" > -
+
{orderedItems.map((item) => ( ))} diff --git a/apps/web/components/app-status-bar/app-status-surface-provider.tsx b/apps/web/components/app-status-bar/app-status-surface-provider.tsx index be4a7a215c..4c5a23d640 100644 --- a/apps/web/components/app-status-bar/app-status-surface-provider.tsx +++ b/apps/web/components/app-status-bar/app-status-surface-provider.tsx @@ -10,6 +10,7 @@ import { type ReactNode, } from "react"; import { IconActivity } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { cn } from "@kandev/ui/lib/utils"; import { useAppStore } from "@/components/state-provider"; @@ -52,15 +53,20 @@ type AppStatusDrawerTriggerProps = Omit, "onClick" export function AppStatusDrawerTrigger({ className, children, - label = "Open status", + label, ...buttonProps }: AppStatusDrawerTriggerProps) { + const { t } = useTranslation(); const drawer = useContext(AppStatusDrawerContext); if (!drawer?.enabled) return null; const issueDetails = drawer.issueSeverity === "none" ? null : connectionIssueDetails(drawer.issueSeverity); const issueActive = issueDetails !== null; - const accessibleLabel = issueDetails?.description ?? label; + // The default lives in the body, not the destructuring pattern: parameter + // defaults evaluate before the component body, so `t` is not in scope there. + const accessibleLabel = issueDetails + ? t(issueDetails.descriptionKey) + : (label ?? t("sidebar:openStatus")); return ( ); } diff --git a/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts b/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts index 68d1a4dad3..473fde7e7e 100644 --- a/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts +++ b/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts @@ -136,17 +136,18 @@ const SCREENS: Array<{ name: string; url: string; allow?: string[] }> = [ // NOT YET: "settings — integrations github", "… gitlab", "… jira", // "… linear", "… sentry". Each page's // own copy is fully migrated (verified by running this oracle against it — - // every string the integration owns renders accented), but the route expands - // the Workspaces > Integrations branch of the settings nav, and - // `workspaces-group.tsx` / `settings-tree.tsx` are not migrated: "Workspaces", - // "Integrations", "Automations", "Executors", "Voice Mode", "Utility Agents", - // "External MCP", "Plugins", "System" and "Toggle theme" all still render plain - // English there. The /settings/general/* screens above pass because their - // expanded branch is `general-group.tsx`, which is migrated. Add these entries - // in the PR that migrates the settings nav — allowlisting those nav labels here - // would hide real misses instead. + // every string the integration owns renders accented). // - // Two shared components rendered by all five pages are also still English and + // The settings-nav half of this blocker is now GONE: `settings-tree.tsx`, + // `workspaces-group.tsx`, `executors-group.tsx`, `account-group.tsx` and + // `theme-toggle.tsx` are migrated, so "Workspaces", "Integrations", + // "Automations", "Executors", "Voice Mode", "Utility Agents", "External MCP", + // "Plugins" and "Toggle theme" render accented on every screen. Only "System" + // is left, from `sections/settings/system-group.tsx`, which the System-routes + // migration owns. + // + // What still blocks these five entries is the shared integration chrome, not + // the nav. Two components rendered by all five pages are still English and // would have to be migrated (or allowlisted, which is worse) first: // `components/integrations/drafted-integration-enabled-control.tsx` // ("Enabled"/"Disabled") and `components/watcher-repository-fields.tsx` @@ -159,13 +160,14 @@ const SCREENS: Array<{ name: string; url: string; allow?: string[] }> = [ // ("Authenticated", "· checked ") and `@kandev/ui`'s built-in dialog // "Close" label — both shared, both out of scope for a per-integration PR. // - // NOT YET, for the same reason: "settings — workspace workflows" + // NOT YET: "settings — workspace workflows" // (`/settings/workspace/:id/workflows`). The workflow editor's own copy is // fully migrated and was verified with this oracle against a live instance, - // but the route expands the same un-migrated Workspaces branch of the settings - // nav. It also renders the workspace's own name and its workflow/step names, - // which are user data — so the entry needs the nav migrated first, not an - // allowlist. + // and the Workspaces branch of the nav it expands is now migrated too. What + // remains is that the route renders the workspace's own name and its + // workflow/step names — user data, on the same footing as the fixture's + // "E2E Workspace". Adding the entry needs `findUnlocalizedText` to stop + // treating user data as eligible, not an allowlist entry per fixture name. ]; /** diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs index ab4ccef4c5..8ccae08886 100644 --- a/apps/web/eslint.i18n.options.mjs +++ b/apps/web/eslint.i18n.options.mjs @@ -813,4 +813,44 @@ export const i18nGuardFiles = [ "hooks/domains/system/use-desktop-updater.ts", "hooks/domains/system/use-kandev-restart.ts", "hooks/domains/system/use-self-update.ts", + // The app sidebar, the settings nav tree and the app status bar. These mount + // on EVERY screen, so their copy is what the pseudo-coverage spec sees wrapped + // around each settings page it walks — leaving them was why that oracle had + // never run green. + // + // Deliberately file globs, not `components/app-sidebar/**`. #2202 has landed, + // so `sections/settings/system-group.tsx` is migrated and already listed in + // the System block above — this directory is now covered in full, just by two + // entries rather than one. + // + // Do NOT tidy that into `components/app-sidebar/**`. + // `check-guard-allowlist.mjs` flags any entry that disappears while its path + // still exists, globs included, so swapping these seven for one broader glob + // reports seven removed entries and fails (verified by simulating it). A + // redundant broader glob *alongside* them would pass; removing them never + // does. + // + // `general-group.tsx` and `agents-group.tsx` are absent on purpose: earlier + // migrations already listed them further up, and a second copy here would be + // dead weight that reads as this PR's coverage. + // + // The four `.ts` entries hold no JSX, so `mode: "jsx-only"` never inspects + // them. They are listed because they were read and carry no copy (section + // ids, cookie names, drag geometry, a route predicate) — not because the rule + // can keep them that way. + "components/app-sidebar/*.{ts,tsx}", + "components/app-sidebar/sections/*.tsx", + "components/app-sidebar/sections/settings/account-group.tsx", + "components/app-sidebar/sections/settings/executors-group.tsx", + "components/app-sidebar/sections/settings/settings-nav-primitives.tsx", + "components/app-sidebar/sections/settings/settings-tree.tsx", + "components/app-sidebar/sections/settings/workspaces-group.tsx", + "components/app-status-bar/**/*.{ts,tsx}", + "components/theme-toggle.tsx", + // The command palette's own copy. `group` doubles as the palette's Map key and + // its rendered heading, so it must be translated in lockstep across every + // producer; `components/task/recent-task-switcher-hooks.ts` is the other one + // and had its `group` converted here, but the rest of that file belongs to the + // components/task migration and is not listed. + "components/global-commands.tsx", ]; diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json index cdd5cb921e..84235e0d31 100644 --- a/apps/web/src/locales/en/common.json +++ b/apps/web/src/locales/en/common.json @@ -1,13 +1,42 @@ { + "aboutContextCompactionCount": "About inferred compaction count", "agents": "Agents", "automations": "Automations", - "contextCompactions": "Compactions", - "contextCompactionCountHelp": "Kandev infers this count from observed context usage drops. Missing samples or provider resets can make it approximate.", - "aboutContextCompactionCount": "About inferred compaction count", "cancel": "Cancel", - "edit": "Edit", + "commandAgentsSettings": "Agents Settings", + "commandAgentsSettingsKeywords": "agents, agent settings, agent profiles, installed agents, claude", + "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", + "commandGoToAllTasksKeywords": "tasks, list, all", + "commandGoToGitHubDashboard": "Go to GitHub Dashboard", + "commandGoToGitHubDashboardKeywords": "github, dashboard, pr, pull request, code review, issues, review", + "commandGoToHome": "Go to Home", + "commandGoToHomeKeywords": "home, kanban, board", + "commandGoToSettings": "Go to Settings", + "commandGoToSettingsKeywords": "settings, preferences, config, general settings", + "commandGoToStats": "Go to Stats", + "commandGoToStatsKeywords": "stats, statistics, analytics, metrics", + "commandGroupActions": "Actions", + "commandGroupNavigation": "Navigation", + "commandGroupPreferences": "Preferences", + "commandGroupSettings": "Settings", + "commandPromptsSettings": "Prompts Settings", + "commandPromptsSettingsKeywords": "prompts, prompt settings, custom prompts, prompt snippets, prompt templates", + "commandQuickChat": "Quick Chat", + "commandQuickChatKeywords": "quick chat, new quick chat, quick question, ask agent", + "commandSwitchToDarkMode": "Switch to Dark Mode", + "commandSwitchToLightMode": "Switch to Light Mode", + "commandThemeKeywords": "theme, color theme, appearance", + "commandWorkspaceSettings": "Workspace Settings", + "commandWorkspaceSettingsKeywords": "workspace, workspaces", + "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.", "dAgo": "{{diffDay}}d ago", + "edit": "Edit", "executors": "Executors", "externalMcp": "External MCP", "hAgo": "{{diffHr}}h ago", @@ -16,32 +45,32 @@ "mAgo": "{{diffMin}}m ago", "plugins": "Plugins", "prDetails": "PR Details", + "presetIconBug": "Bug", + "presetIconCheck": "Check", + "presetIconCode": "Code", + "presetIconEye": "Eye", + "presetIconMessage": "Message", + "presetIconSearch": "Search", + "presetIconSparkle": "Sparkle", + "presetIconTool": "Tool", "prompts": "Prompts", "requestFailed": "Request failed", "reset": "Reset", - "resetting": "Resetting\u2026", - "resetWatchChecking": "Checking how many tasks would be deleted\u2026", + "resetWatchChecking": "Checking how many tasks would be deleted…", "resetWatchDeleteAllTasks": "This will delete every task previously created by the watch, including archived tasks. The watch's polling cursor is also cleared so the next check re-imports every currently-matching item. This cannot be undone.", "resetWatchDeleteTasks_one": "This will delete {{count}} task previously created by the watch, including archived tasks. The watch's polling cursor is also cleared so the next check re-imports every currently-matching item. This cannot be undone.", "resetWatchDeleteTasks_other": "This will delete {{count}} tasks previously created by the watch, including archived tasks. The watch's polling cursor is also cleared so the next check re-imports every currently-matching item. This cannot be undone.", - "resetWatchNoTasks": "No tasks were created by this watch yet \u2014 only the polling state will be cleared. The watch's polling cursor is also cleared so the next check re-imports every currently-matching item. This cannot be undone.", + "resetWatchNoTasks": "No tasks were created by this watch yet — only the polling state will be cleared. The watch's polling cursor is also cleared so the next check re-imports every currently-matching item. This cannot be undone.", "resetWatchPreviewFailedRequired": "Could not load the affected task count. Retry the preview before resetting this watch.", "resetWatchTitle": "Reset {{label}}?", + "resetting": "Resetting…", "retryPreview": "Retry preview", "settings": "Settings", "shell": "Shell", "status": "Status", - "system": "System", - "workspace": "Workspace", - "workspaces": "Workspaces", - "title": "Title", "subtaskTitle": "Subtask title", - "presetIconEye": "Eye", - "presetIconMessage": "Message", - "presetIconTool": "Tool", - "presetIconCode": "Code", - "presetIconSearch": "Search", - "presetIconBug": "Bug", - "presetIconSparkle": "Sparkle", - "presetIconCheck": "Check" + "system": "System", + "title": "Title", + "workspace": "Workspace", + "workspaces": "Workspaces" } diff --git a/apps/web/src/locales/en/sidebar.json b/apps/web/src/locales/en/sidebar.json new file mode 100644 index 0000000000..163c9e7051 --- /dev/null +++ b/apps/web/src/locales/en/sidebar.json @@ -0,0 +1,76 @@ +{ + "account": "Account", + "activeWorkspaceBadge": "Active", + "activity": "Activity", + "addAgent": "Add agent", + "addProject": "Add project", + "addWorkspace": "Add workspace", + "agentTopology": "Agent topology", + "apiTokens": "API Tokens", + "applicationStatus": "Application status", + "applicationStatusItems": "Application status items", + "closeSettings": "Close settings", + "collapseGroup": "Collapse {{label}}", + "collapseSidebar": "Collapse sidebar", + "connected": "Connected", + "connectedToKandev": "Connected to Kandev", + "connecting": "Connecting", + "connectingToKandev": "Connecting to Kandev", + "connection": "Connection", + "connectionError": "Connection error", + "connectionErrorDetail": "Connection error: {{error}}", + "connectionLost": "Connection lost", + "connectionLostDescription": "Connection lost for at least 10 seconds. Live updates may be stale.", + "connectionUnavailable": "Connection unavailable", + "connectionUnstable": "Connection unstable", + "connectionUnstableDescription": "Connection unstable. Reconnecting to Kandev.", + "costs": "Costs", + "enabledBadge": "Enabled", + "expandGroup": "Expand {{label}}", + "expandSidebar": "Expand sidebar", + "filtersAndSort": "Filters and sort", + "home": "Home", + "improveKandev": "Improve Kandev", + "inbox": "Inbox", + "kanban": "Kanban", + "kandevHome": "Kandev home", + "logOut": "Log out", + "maxViews_one": "{{count}} max", + "maxViews_other": "{{count}} max", + "newKanbanWorkspace": "New kanban workspace", + "newOfficeWorkspace": "New office workspace", + "newTask": "New Task", + "newView": "New view", + "newViewUnavailable": "New view unavailable. {{reason}}", + "noAgentsYet": "No agents yet", + "noProjectsYet": "No projects yet", + "noWorkspaces": "No workspaces", + "office": "Office", + "offline": "Offline", + "openStatus": "Open status", + "pausedBadge": "paused", + "preferences": "Preferences", + "profileAndPassword": "Profile & Password", + "projects": "Projects", + "quickChat": "Quick Chat", + "reconnecting": "Reconnecting", + "reconnectingToKandev": "Reconnecting to Kandev", + "repositories": "Repositories", + "resizeSidebar": "Resize sidebar", + "routines": "Routines", + "routing": "Routing", + "runErrors_one": "{{count}} error", + "runErrors_other": "{{count}} errors", + "saveDiscardFirst": "Save/discard first", + "skills": "Skills", + "stats": "Stats", + "switchWorkspace": "Switch workspace", + "tasks": "Tasks", + "toggleTheme": "Toggle theme", + "unsavedFilterChanges": "Unsaved filter changes", + "viewAll": "All", + "viewLabel": "View: {{name}}", + "whatsNew": "What's new", + "work": "Work", + "workspaceFallback": "Workspace" +} diff --git a/apps/web/src/locales/pseudo/common.json b/apps/web/src/locales/pseudo/common.json index 8777f659bb..0cb7a30fe4 100644 --- a/apps/web/src/locales/pseudo/common.json +++ b/apps/web/src/locales/pseudo/common.json @@ -1,13 +1,42 @@ { + "aboutContextCompactionCount": "Àƀōũţ ĩńƒēŕŕēď ćōḿƥàćţĩōń ćōũńţ", "agents": "Àĝēńţś", "automations": "Àũţōḿàţĩōńś", - "contextCompactions": "Ćōḿƥàćţĩōńś", - "contextCompactionCountHelp": "Ķàńďēv ĩńƒēŕś ţĥĩś ćōũńţ ƒŕōḿ ōƀśēŕvēď ćōńţēxţ ũśàĝē ďŕōƥś. Ḿĩśśĩńĝ śàḿƥĺēś ōŕ ƥŕōvĩďēŕ ŕēśēţś ćàń ḿàķē ĩţ àƥƥŕōxĩḿàţē.", - "aboutContextCompactionCount": "Àƀōũţ ĩńƒēŕŕēď ćōḿƥàćţĩōń ćōũńţ", "cancel": "Ćàńćēĺ", - "edit": "Ēďĩţ", + "commandAgentsSettings": "Àĝēńţś Śēţţĩńĝś", + "commandAgentsSettingsKeywords": "àĝēńţś, àĝēńţ śēţţĩńĝś, àĝēńţ ƥŕōƒĩĺēś, ĩńśţàĺĺēď àĝēńţś, ćĺàũďē", + "commandConfigChatKeywords": "ćōńƒĩĝ ćĥàţ, ćōńƒĩĝ ḿōďē, ćōńƒĩĝũŕē ķàńďēv, ŵōŕķƒĺōŵ ćōńƒĩĝũŕàţĩōń, ḿćƥ ćōńƒĩĝũŕàţĩōń", + "commandConfigurationChat": "Ćōńƒĩĝũŕàţĩōń Ćĥàţ", + "commandExecutorsSettings": "Ēxēćũţōŕś Śēţţĩńĝś", + "commandExecutorsSettingsKeywords": "ēxēćũţōŕś, ēxēćũţōŕ ƥŕōƒĩĺēś, ēxēćũţĩōń ēńvĩŕōńḿēńţ, ēńvĩŕōńḿēńţ vàŕĩàƀĺēś, ŕũńţĩḿē, ćōḿƥũţē", + "commandGoToAllTasks": "Ĝō ţō Àĺĺ Ţàśķś", + "commandGoToAllTasksKeywords": "ţàśķś, ĺĩśţ, àĺĺ", + "commandGoToGitHubDashboard": "Ĝō ţō ĜĩţĤũƀ Ďàśĥƀōàŕď", + "commandGoToGitHubDashboardKeywords": "ĝĩţĥũƀ, ďàśĥƀōàŕď, ƥŕ, ƥũĺĺ ŕēqũēśţ, ćōďē ŕēvĩēŵ, ĩśśũēś, ŕēvĩēŵ", + "commandGoToHome": "Ĝō ţō Ĥōḿē", + "commandGoToHomeKeywords": "ĥōḿē, ķàńƀàń, ƀōàŕď", + "commandGoToSettings": "Ĝō ţō Śēţţĩńĝś", + "commandGoToSettingsKeywords": "śēţţĩńĝś, ƥŕēƒēŕēńćēś, ćōńƒĩĝ, ĝēńēŕàĺ śēţţĩńĝś", + "commandGoToStats": "Ĝō ţō Śţàţś", + "commandGoToStatsKeywords": "śţàţś, śţàţĩśţĩćś, àńàĺŷţĩćś, ḿēţŕĩćś", + "commandGroupActions": "Àćţĩōńś", + "commandGroupNavigation": "Ńàvĩĝàţĩōń", + "commandGroupPreferences": "Ƥŕēƒēŕēńćēś", + "commandGroupSettings": "Śēţţĩńĝś", + "commandPromptsSettings": "Ƥŕōḿƥţś Śēţţĩńĝś", + "commandPromptsSettingsKeywords": "ƥŕōḿƥţś, ƥŕōḿƥţ śēţţĩńĝś, ćũśţōḿ ƥŕōḿƥţś, ƥŕōḿƥţ śńĩƥƥēţś, ƥŕōḿƥţ ţēḿƥĺàţēś", + "commandQuickChat": "Qũĩćķ Ćĥàţ", + "commandQuickChatKeywords": "qũĩćķ ćĥàţ, ńēŵ qũĩćķ ćĥàţ, qũĩćķ qũēśţĩōń, àśķ àĝēńţ", + "commandSwitchToDarkMode": "Śŵĩţćĥ ţō Ďàŕķ Ḿōďē", + "commandSwitchToLightMode": "Śŵĩţćĥ ţō Ĺĩĝĥţ Ḿōďē", + "commandThemeKeywords": "ţĥēḿē, ćōĺōŕ ţĥēḿē, àƥƥēàŕàńćē", + "commandWorkspaceSettings": "Ŵōŕķśƥàćē Śēţţĩńĝś", + "commandWorkspaceSettingsKeywords": "ŵōŕķśƥàćē, ŵōŕķśƥàćēś", + "contextCompactionCountHelp": "Ķàńďēv ĩńƒēŕś ţĥĩś ćōũńţ ƒŕōḿ ōƀśēŕvēď ćōńţēxţ ũśàĝē ďŕōƥś. Ḿĩśśĩńĝ śàḿƥĺēś ōŕ ƥŕōvĩďēŕ ŕēśēţś ćàń ḿàķē ĩţ àƥƥŕōxĩḿàţē.", + "contextCompactions": "Ćōḿƥàćţĩōńś", "createAWorkspaceToConfigureThisIntegration": "Ćŕēàţē à ŵōŕķśƥàćē ţō ćōńƒĩĝũŕē ţĥĩś ĩńţēĝŕàţĩōń.", "dAgo": "{{diffDay}}ď àĝō", + "edit": "Ēďĩţ", "executors": "Ēxēćũţōŕś", "externalMcp": "Ēxţēŕńàĺ ḾĆƤ", "hAgo": "{{diffHr}}ĥ àĝō", @@ -16,10 +45,17 @@ "mAgo": "{{diffMin}}ḿ àĝō", "plugins": "Ƥĺũĝĩńś", "prDetails": "ƤŔ Ďēţàĩĺś", + "presetIconBug": "Ɓũĝ", + "presetIconCheck": "Ćĥēćķ", + "presetIconCode": "Ćōďē", + "presetIconEye": "Ēŷē", + "presetIconMessage": "Ḿēśśàĝē", + "presetIconSearch": "Śēàŕćĥ", + "presetIconSparkle": "Śƥàŕķĺē", + "presetIconTool": "Ţōōĺ", "prompts": "Ƥŕōḿƥţś", "requestFailed": "Ŕēqũēśţ ƒàĩĺēď", "reset": "Ŕēśēţ", - "resetting": "Ŕēśēţţĩńĝ…", "resetWatchChecking": "Ćĥēćķĩńĝ ĥōŵ ḿàńŷ ţàśķś ŵōũĺď ƀē ďēĺēţēď…", "resetWatchDeleteAllTasks": "Ţĥĩś ŵĩĺĺ ďēĺēţē ēvēŕŷ ţàśķ ƥŕēvĩōũśĺŷ ćŕēàţēď ƀŷ ţĥē ŵàţćĥ, ĩńćĺũďĩńĝ àŕćĥĩvēď ţàśķś. Ţĥē ŵàţćĥ'ś ƥōĺĺĩńĝ ćũŕśōŕ ĩś àĺśō ćĺēàŕēď śō ţĥē ńēxţ ćĥēćķ ŕē-ĩḿƥōŕţś ēvēŕŷ ćũŕŕēńţĺŷ-ḿàţćĥĩńĝ ĩţēḿ. Ţĥĩś ćàńńōţ ƀē ũńďōńē.", "resetWatchDeleteTasks_one": "Ţĥĩś ŵĩĺĺ ďēĺēţē {{count}} ţàśķ ƥŕēvĩōũśĺŷ ćŕēàţēď ƀŷ ţĥē ŵàţćĥ, ĩńćĺũďĩńĝ àŕćĥĩvēď ţàśķś. Ţĥē ŵàţćĥ'ś ƥōĺĺĩńĝ ćũŕśōŕ ĩś àĺśō ćĺēàŕēď śō ţĥē ńēxţ ćĥēćķ ŕē-ĩḿƥōŕţś ēvēŕŷ ćũŕŕēńţĺŷ-ḿàţćĥĩńĝ ĩţēḿ. Ţĥĩś ćàńńōţ ƀē ũńďōńē.", @@ -27,21 +63,14 @@ "resetWatchNoTasks": "Ńō ţàśķś ŵēŕē ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥ ŷēţ — ōńĺŷ ţĥē ƥōĺĺĩńĝ śţàţē ŵĩĺĺ ƀē ćĺēàŕēď. Ţĥē ŵàţćĥ'ś ƥōĺĺĩńĝ ćũŕśōŕ ĩś àĺśō ćĺēàŕēď śō ţĥē ńēxţ ćĥēćķ ŕē-ĩḿƥōŕţś ēvēŕŷ ćũŕŕēńţĺŷ-ḿàţćĥĩńĝ ĩţēḿ. Ţĥĩś ćàńńōţ ƀē ũńďōńē.", "resetWatchPreviewFailedRequired": "Ćōũĺď ńōţ ĺōàď ţĥē àƒƒēćţēď ţàśķ ćōũńţ. Ŕēţŕŷ ţĥē ƥŕēvĩēŵ ƀēƒōŕē ŕēśēţţĩńĝ ţĥĩś ŵàţćĥ.", "resetWatchTitle": "Ŕēśēţ {{label}}?", + "resetting": "Ŕēśēţţĩńĝ…", "retryPreview": "Ŕēţŕŷ ƥŕēvĩēŵ", "settings": "Śēţţĩńĝś", "shell": "Śĥēĺĺ", "status": "Śţàţũś", - "system": "Śŷśţēḿ", - "workspace": "Ŵōŕķśƥàćē", - "workspaces": "Ŵōŕķśƥàćēś", - "title": "Ţĩţĺē", "subtaskTitle": "Śũƀţàśķ ţĩţĺē", - "presetIconEye": "Ēŷē", - "presetIconMessage": "Ḿēśśàĝē", - "presetIconTool": "Ţōōĺ", - "presetIconCode": "Ćōďē", - "presetIconSearch": "Śēàŕćĥ", - "presetIconBug": "Ɓũĝ", - "presetIconSparkle": "Śƥàŕķĺē", - "presetIconCheck": "Ćĥēćķ" + "system": "Śŷśţēḿ", + "title": "Ţĩţĺē", + "workspace": "Ŵōŕķśƥàćē", + "workspaces": "Ŵōŕķśƥàćēś" } diff --git a/apps/web/src/locales/pseudo/sidebar.json b/apps/web/src/locales/pseudo/sidebar.json new file mode 100644 index 0000000000..6532f5b8f2 --- /dev/null +++ b/apps/web/src/locales/pseudo/sidebar.json @@ -0,0 +1,76 @@ +{ + "account": "Àććōũńţ", + "activeWorkspaceBadge": "Àćţĩvē", + "activity": "Àćţĩvĩţŷ", + "addAgent": "Àďď àĝēńţ", + "addProject": "Àďď ƥŕōĵēćţ", + "addWorkspace": "Àďď ŵōŕķśƥàćē", + "agentTopology": "Àĝēńţ ţōƥōĺōĝŷ", + "apiTokens": "ÀƤĨ Ţōķēńś", + "applicationStatus": "Àƥƥĺĩćàţĩōń śţàţũś", + "applicationStatusItems": "Àƥƥĺĩćàţĩōń śţàţũś ĩţēḿś", + "closeSettings": "Ćĺōśē śēţţĩńĝś", + "collapseGroup": "Ćōĺĺàƥśē {{label}}", + "collapseSidebar": "Ćōĺĺàƥśē śĩďēƀàŕ", + "connected": "Ćōńńēćţēď", + "connectedToKandev": "Ćōńńēćţēď ţō Ķàńďēv", + "connecting": "Ćōńńēćţĩńĝ", + "connectingToKandev": "Ćōńńēćţĩńĝ ţō Ķàńďēv", + "connection": "Ćōńńēćţĩōń", + "connectionError": "Ćōńńēćţĩōń ēŕŕōŕ", + "connectionErrorDetail": "Ćōńńēćţĩōń ēŕŕōŕ: {{error}}", + "connectionLost": "Ćōńńēćţĩōń ĺōśţ", + "connectionLostDescription": "Ćōńńēćţĩōń ĺōśţ ƒōŕ àţ ĺēàśţ 10 śēćōńďś. Ĺĩvē ũƥďàţēś ḿàŷ ƀē śţàĺē.", + "connectionUnavailable": "Ćōńńēćţĩōń ũńàvàĩĺàƀĺē", + "connectionUnstable": "Ćōńńēćţĩōń ũńśţàƀĺē", + "connectionUnstableDescription": "Ćōńńēćţĩōń ũńśţàƀĺē. Ŕēćōńńēćţĩńĝ ţō Ķàńďēv.", + "costs": "Ćōśţś", + "enabledBadge": "Ēńàƀĺēď", + "expandGroup": "Ēxƥàńď {{label}}", + "expandSidebar": "Ēxƥàńď śĩďēƀàŕ", + "filtersAndSort": "Ƒĩĺţēŕś àńď śōŕţ", + "home": "Ĥōḿē", + "improveKandev": "Ĩḿƥŕōvē Ķàńďēv", + "inbox": "Ĩńƀōx", + "kanban": "Ķàńƀàń", + "kandevHome": "Ķàńďēv ĥōḿē", + "logOut": "Ĺōĝ ōũţ", + "maxViews_one": "{{count}} ḿàx", + "maxViews_other": "{{count}} ḿàx", + "newKanbanWorkspace": "Ńēŵ ķàńƀàń ŵōŕķśƥàćē", + "newOfficeWorkspace": "Ńēŵ ōƒƒĩćē ŵōŕķśƥàćē", + "newTask": "Ńēŵ Ţàśķ", + "newView": "Ńēŵ vĩēŵ", + "newViewUnavailable": "Ńēŵ vĩēŵ ũńàvàĩĺàƀĺē. {{reason}}", + "noAgentsYet": "Ńō àĝēńţś ŷēţ", + "noProjectsYet": "Ńō ƥŕōĵēćţś ŷēţ", + "noWorkspaces": "Ńō ŵōŕķśƥàćēś", + "office": "Ōƒƒĩćē", + "offline": "Ōƒƒĺĩńē", + "openStatus": "Ōƥēń śţàţũś", + "pausedBadge": "ƥàũśēď", + "preferences": "Ƥŕēƒēŕēńćēś", + "profileAndPassword": "Ƥŕōƒĩĺē & Ƥàśśŵōŕď", + "projects": "Ƥŕōĵēćţś", + "quickChat": "Qũĩćķ Ćĥàţ", + "reconnecting": "Ŕēćōńńēćţĩńĝ", + "reconnectingToKandev": "Ŕēćōńńēćţĩńĝ ţō Ķàńďēv", + "repositories": "Ŕēƥōśĩţōŕĩēś", + "resizeSidebar": "Ŕēśĩźē śĩďēƀàŕ", + "routines": "Ŕōũţĩńēś", + "routing": "Ŕōũţĩńĝ", + "runErrors_one": "{{count}} ēŕŕōŕ", + "runErrors_other": "{{count}} ēŕŕōŕś", + "saveDiscardFirst": "Śàvē/ďĩśćàŕď ƒĩŕśţ", + "skills": "Śķĩĺĺś", + "stats": "Śţàţś", + "switchWorkspace": "Śŵĩţćĥ ŵōŕķśƥàćē", + "tasks": "Ţàśķś", + "toggleTheme": "Ţōĝĝĺē ţĥēḿē", + "unsavedFilterChanges": "Ũńśàvēď ƒĩĺţēŕ ćĥàńĝēś", + "viewAll": "Àĺĺ", + "viewLabel": "Vĩēŵ: {{name}}", + "whatsNew": "Ŵĥàţ'ś ńēŵ", + "work": "Ŵōŕķ", + "workspaceFallback": "Ŵōŕķśƥàćē" +}