feat(i18n): localize the app sidebar, settings nav tree and status bar (#2214)

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-08-03 18:02:58 +02:00
committed by GitHub
parent c95d44193c
commit 2d653c962d
39 changed files with 822 additions and 256 deletions
@@ -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<ConnectionIssueSeverity, "none">;
}) {
const details = connectionIssueDetails(severity);
const details = useConnectionIssueCopy(severity);
if (!details) return null;
const { label, description } = details;
return (
<Tooltip>
@@ -116,7 +119,7 @@ function SidebarConnectionWarning({
<span
className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
role="status"
aria-label={details.description}
aria-label={description}
tabIndex={0}
data-testid="sidebar-connection-warning"
data-connection-severity={severity}
@@ -129,10 +132,10 @@ function SidebarConnectionWarning({
)}
aria-hidden="true"
/>
<span className="sr-only">{details.label}</span>
<span className="sr-only">{label}</span>
</span>
</TooltipTrigger>
<TooltipContent side={collapsed ? "right" : "top"}>{details.description}</TooltipContent>
<TooltipContent side={collapsed ? "right" : "top"}>{description}</TooltipContent>
</Tooltip>
);
}
@@ -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
>
<FooterIconButton
icon={IconSettings}
label={settingsMode ? "Close settings" : "Settings"}
label={settingsMode ? t("sidebar:closeSettings") : t("common:settings")}
collapsed={collapsed}
onClick={enterSettings}
active={settingsMode}
@@ -229,14 +233,14 @@ export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebar
/>
<FooterIconButton
icon={IconChartBar}
label="Stats"
label={t("sidebar:stats")}
collapsed={collapsed}
onClick={() => router.push("/stats")}
testId="sidebar-stats-button"
/>
<FooterIconButton
icon={IconStethoscope}
label="Improve Kandev"
label={t("sidebar:improveKandev")}
collapsed={collapsed}
onClick={() => setImproveOpen(true)}
testId="sidebar-improve-kandev-button"
@@ -244,7 +248,7 @@ export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebar
{releaseNotes.showTopbarButton && (
<FooterIconButton
icon={IconSparkles}
label="What's new"
label={t("sidebar:whatsNew")}
collapsed={collapsed}
onClick={releaseNotes.openDialog}
badge={releaseNotes.hasUnseen}
@@ -254,7 +258,7 @@ export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebar
{officeEnabled && (
<FooterIconButton
icon={activeIsOffice ? IconLayoutKanban : IconBuildings}
label={activeIsOffice ? "Kanban" : "Office"}
label={activeIsOffice ? t("sidebar:kanban") : t("sidebar:office")}
collapsed={collapsed}
onClick={() => {
if (!activeIsOffice) rememberLastKanbanWorkspace(activeWorkspace);
@@ -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
<TooltipTrigger asChild>
<Link
href={homeHref}
aria-label="Kandev home"
aria-label={t("sidebar:kandevHome")}
className="flex h-7 w-7 items-center justify-center rounded-md text-foreground/80 hover:bg-muted/60 cursor-pointer"
>
<span className="text-base font-bold tracking-tight">K</span>
@@ -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")}
>
<IconLayoutSidebarLeftExpand className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Expand sidebar</TooltipContent>
<TooltipContent side="right">{t("sidebar:expandSidebar")}</TooltipContent>
</Tooltip>
</div>
);
@@ -69,7 +71,7 @@ export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHead
>
<Link
href={homeHref}
aria-label="Kandev home"
aria-label={t("sidebar:kandevHome")}
className={cn(
"shrink-0 cursor-pointer text-sm font-semibold tracking-tight",
"text-foreground hover:text-foreground/80 transition-colors",
@@ -88,12 +90,12 @@ export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHead
size="icon"
className={COLLAPSE_BUTTON_CLASS}
onClick={onToggleCollapse}
aria-label="Collapse sidebar"
aria-label={t("sidebar:collapseSidebar")}
>
<IconLayoutSidebarLeftCollapse className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Collapse sidebar</TooltipContent>
<TooltipContent side="top">{t("sidebar:collapseSidebar")}</TooltipContent>
</Tooltip>
</div>
);
@@ -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)
<div className="relative">
<AppSidebarNavItem
icon={IconSquarePlus}
label="New Task"
label={t("sidebar:newTask")}
onClick={() => setOpen(true)}
collapsed={collapsed}
disabled={!workspaceId}
@@ -123,7 +125,7 @@ export function AppSidebarNewTaskItem({ collapsed }: AppSidebarNewTaskItemProps)
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-1 sidebar-fade-in">
<RowActionButton
icon={IconMessageCircle}
label="Quick Chat"
label={t("sidebar:quickChat")}
testId="sidebar-quick-chat-shortcut"
onClick={handleOpenQuickChat}
/>
@@ -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) {
<div className="flex flex-col gap-0.5">
<AppSidebarNavItem
icon={IconHome}
label="Home"
label={t("sidebar:home")}
href={inOffice ? "/office" : linkToTaskOverview({ workspaceId: workspaceId ?? undefined })}
collapsed={collapsed}
exactMatch
@@ -30,7 +32,7 @@ export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) {
{inOffice && (
<AppSidebarNavItem
icon={IconInbox}
label="Inbox"
label={t("sidebar:inbox")}
href="/office/inbox"
badge={inboxCount}
collapsed={collapsed}
@@ -39,7 +41,7 @@ export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) {
{workspaceId && collapsed && (
<AppSidebarNavItem
icon={IconMessageCircle}
label="Quick Chat"
label={t("sidebar:quickChat")}
onClick={handleOpenQuickChat}
collapsed={collapsed}
/>
@@ -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 (
<button
type="button"
aria-label="Resize sidebar"
aria-label={t("sidebar:resizeSidebar")}
onMouseDown={onMouseDown}
tabIndex={-1}
className="absolute -right-px top-0 z-10 h-full w-1 translate-x-1/2 cursor-ew-resize bg-transparent transition-colors hover:bg-primary active:bg-primary"
@@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import { usePathname } from "@/lib/routing/client-router";
import { IconSettings, IconChevronLeft } from "@tabler/icons-react";
import { useAppStore } from "@/components/state-provider";
@@ -16,6 +17,7 @@ import { SettingsTree } from "./sections/settings/settings-tree";
* group is currently expanded.
*/
export function AppSidebarSettingsMode() {
const { t } = useTranslation();
const pathname = usePathname();
const toggleSettingsMode = useAppStore((s) => s.toggleAppSidebarSettingsMode);
@@ -27,13 +29,15 @@ export function AppSidebarSettingsMode() {
<button
type="button"
onClick={toggleSettingsMode}
aria-label="Close settings"
aria-label={t("sidebar:closeSettings")}
data-testid="app-sidebar-settings-mode-close"
className="group/close flex items-center gap-1.5 px-2 h-7 shrink-0 rounded-md text-foreground/70 hover:bg-muted/60 hover:text-foreground cursor-pointer transition-colors"
>
<IconSettings className="h-3.5 w-3.5 group-hover/close:hidden" />
<IconChevronLeft className="h-3.5 w-3.5 hidden group-hover/close:block" />
<span className="text-[11px] font-semibold uppercase tracking-wider">Settings</span>
<span className="text-[11px] font-semibold uppercase tracking-wider">
{t("common:settings")}
</span>
</button>
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5">
<SettingsTree pathname={pathname} />
@@ -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 (
<button
ref={ref}
type="button"
data-testid="sidebar-workspace-trigger"
aria-label="Switch workspace"
aria-label={t("sidebar:switchWorkspace")}
className={cn(
"group/ws flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-md border border-border/70 bg-background px-2.5 text-sm font-medium text-foreground shadow-sm cursor-pointer transition-colors hover:border-border hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
className,
@@ -82,8 +84,9 @@ function workspaceType(workspace: WorkspaceItem | undefined): WorkspaceType {
return workspace?.office_workflow_id ? "office" : "kanban";
}
function workspaceTypeLabel(type: WorkspaceType) {
return type === "office" ? "Office" : "Kanban";
/** Returns a catalog key, not copy: this is a plain function with no hook. */
function workspaceTypeLabelKey(type: WorkspaceType) {
return type === "office" ? "sidebar:office" : "sidebar:kanban";
}
function WorkspaceTypeIcon({ type, className }: { type: WorkspaceType; className: string }) {
@@ -141,8 +144,9 @@ function WorkspaceList({
WorkspacePickerContentProps,
"workspaces" | "activeId" | "itemTestIdPrefix" | "onWorkspaceSelect"
>) {
const { t } = useTranslation();
if (workspaces.length === 0) {
return <DropdownMenuItem disabled>No workspaces</DropdownMenuItem>;
return <DropdownMenuItem disabled>{t("sidebar:noWorkspaces")}</DropdownMenuItem>;
}
return workspaces.map((ws) => {
@@ -157,7 +161,7 @@ function WorkspaceList({
<WorkspaceTypeIcon type={type} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{ws.name}</span>
<span className="shrink-0 rounded border border-border/60 px-1.5 py-0.5 text-[10px] font-medium leading-none text-muted-foreground">
{workspaceTypeLabel(type)}
{t(workspaceTypeLabelKey(type))}
</span>
{ws.id === activeId && <IconCheck className="h-3.5 w-3.5 shrink-0" />}
</DropdownMenuItem>
@@ -169,6 +173,7 @@ function WorkspaceCreateItems({
officeEnabled,
onNavigate,
}: Pick<WorkspacePickerContentProps, "officeEnabled" | "onNavigate">) {
const { t } = useTranslation();
if (!officeEnabled) {
return (
<DropdownMenuItem
@@ -176,7 +181,7 @@ function WorkspaceCreateItems({
onSelect={() => onNavigate("/settings/workspace")}
>
<IconPlus className="h-3.5 w-3.5" />
<span>Add workspace</span>
<span>{t("sidebar:addWorkspace")}</span>
</DropdownMenuItem>
);
}
@@ -188,14 +193,14 @@ function WorkspaceCreateItems({
onSelect={() => onNavigate("/settings/workspace")}
>
<IconLayoutKanban className="h-3.5 w-3.5" />
<span>New kanban workspace</span>
<span>{t("sidebar:newKanbanWorkspace")}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer gap-2"
onSelect={() => onNavigate("/office/setup?mode=new")}
>
<IconBriefcase className="h-3.5 w-3.5" />
<span>New office workspace</span>
<span>{t("sidebar:newOfficeWorkspace")}</span>
</DropdownMenuItem>
</>
);
@@ -211,6 +216,7 @@ export function AppSidebarWorkspacePicker({
modal = true,
onActionComplete,
}: WorkspacePickerProps = {}) {
const { t } = useTranslation();
const router = useRouter();
const officeEnabled = useFeature("office");
const workspaces = useAppStore((s) => s.workspaces);
@@ -220,7 +226,7 @@ export function AppSidebarWorkspacePicker({
const activeWorkspace = workspaces.items.find((w) => w.id === workspaces.activeId);
const activeId = activeWorkspace?.id ?? null;
const activeName = activeWorkspace?.name ?? "Workspace";
const activeName = activeWorkspace?.name ?? t("sidebar:workspaceFallback");
const handleSelect = useCallback(
(workspace: WorkspaceItem) => {
@@ -1,6 +1,7 @@
"use client";
import { IconLogout } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@kandev/ui/avatar";
import {
DropdownMenu,
@@ -40,6 +41,7 @@ export function CurrentUserChip({
collapsed: boolean;
className?: string;
}) {
const { t } = useTranslation();
const user = useAppStore((s) => s.auth.user);
if (!user) return null;
@@ -70,7 +72,7 @@ export function CurrentUserChip({
className="cursor-pointer"
>
<IconLogout className="h-4 w-4" />
Log out
{t("sidebar:logOut")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -276,3 +276,30 @@ describe("AgentsSection request sequencing", () => {
expect(state.setOfficeAgentProfiles).toHaveBeenCalledWith([agentFromThirdRequest]);
});
});
describe("AgentsSection error badge", () => {
const renderWithFailedRuns = (failedRuns: number) => {
state.office.agentProfiles = [
createAgentProfile({ id: "agent-1", workspace: defaultWorkspaceId, name: "Alfa" }),
];
state.office.inboxItems = Array.from({ length: failedRuns }, () => ({
type: "agent_run_failed",
payload: { agent_profile_id: agentProfileId("agent-1") },
}));
render(<AgentsSection collapsed={false} />);
};
// The badge used to concatenate an English "s" at the call site, which is
// untranslatable — it must go through a `count` plural instead.
it("uses the singular form for one failed run", () => {
renderWithFailedRuns(1);
expect(screen.getByText("1 error")).toBeTruthy();
});
it("uses the plural form for several failed runs", () => {
renderWithFailedRuns(3);
expect(screen.getByText("3 errors")).toBeTruthy();
});
});
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import Link from "@/components/routing/app-link";
import { usePathname, useRouter } from "@/lib/routing/client-router";
import { IconPlus, IconRobot, IconSitemap } from "@tabler/icons-react";
@@ -47,6 +48,7 @@ function isCurrentWorkspaceResponse(
}
function AgentsSectionHeaderAction({ router }: { router: { push: (path: string) => void } }) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-0.5">
<Tooltip>
@@ -56,14 +58,14 @@ function AgentsSectionHeaderAction({ router }: { router: { push: (path: string)
variant="ghost"
size="icon"
className="h-5 w-5 cursor-pointer"
aria-label="Agent topology"
aria-label={t("sidebar:agentTopology")}
>
<Link href="/office/workspace/org">
<IconSitemap className="h-3 w-3 text-muted-foreground/60" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Agent topology</TooltipContent>
<TooltipContent>{t("sidebar:agentTopology")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -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")}
>
<IconPlus className="h-3 w-3 text-muted-foreground/60" />
</Button>
</TooltipTrigger>
<TooltipContent>Add agent</TooltipContent>
<TooltipContent>{t("sidebar:addAgent")}</TooltipContent>
</Tooltip>
</div>
);
}
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 (
<AppSidebarSection
id={APP_SIDEBAR_SECTION_IDS.agents}
label="Agents"
label={t("common:agents")}
collapsed={collapsed}
icon={IconRobot}
headerAction={<AgentsSectionHeaderAction router={router} />}
@@ -157,7 +160,7 @@ export function AgentsSection({ collapsed }: AgentsSectionProps) {
defaultExpanded
>
{visibleAgents.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">No agents yet</p>
<p className="px-3 py-2 text-xs text-muted-foreground">{t("sidebar:noAgentsYet")}</p>
) : (
visibleAgents.map((agent) => <AgentRow key={agent.id} agent={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")}
</span>
) : null}
{!isAutoPaused && errorCount > 0 ? (
<span className="rounded-full bg-red-500/15 text-red-600 dark:text-red-400 px-1.5 py-0.5 text-[10px] font-medium">
{errorCount} error{errorCount === 1 ? "" : "s"}
{t("sidebar:runErrors", { count: errorCount })}
</span>
) : null}
{liveCount > 0 && <LiveAgentIndicator count={liveCount} />}
@@ -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 (
<AppSidebarSection
id={APP_SIDEBAR_SECTION_IDS.integrations}
label="Integrations"
label={t("common:integrations")}
collapsed={collapsed}
icon={IconPlugConnected}
headerAction={links.length > 0 ? <IntegrationHeaderShortcuts links={links} /> : undefined}
@@ -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(<OfficeNavigationSection collapsed={false} />);
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();
});
});
@@ -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") && (
<AppSidebarSection
id={APP_SIDEBAR_SECTION_IDS.officeWork}
label="Work"
label={t("sidebar:work")}
collapsed={collapsed}
icon={IconCircleDot}
defaultExpanded
@@ -55,7 +59,7 @@ export function OfficeNavigationSection({
<AppSidebarNavItem
key={item.href}
icon={item.icon}
label={item.label}
label={t(item.labelKey)}
href={item.href}
badge={getWorkBadge(item.href, taskCount, routineCount)}
collapsed={collapsed}
@@ -66,7 +70,7 @@ export function OfficeNavigationSection({
{(section === "all" || section === "office") && (
<AppSidebarSection
id={APP_SIDEBAR_SECTION_IDS.officeWorkspace}
label="Office"
label={t("sidebar:office")}
collapsed={collapsed}
icon={IconSettings}
defaultExpanded
@@ -75,7 +79,7 @@ export function OfficeNavigationSection({
<AppSidebarNavItem
key={item.href}
icon={item.icon}
label={item.label}
label={t(item.labelKey)}
href={item.href}
badge={getWorkspaceBadge(item.href, skillCount)}
badgeVariant={item.href === "/office/workspace/skills" ? "muted" : "primary"}
@@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import { useRouter } from "@/lib/routing/client-router";
import { IconBoxMultiple, IconPlus } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
@@ -16,6 +17,7 @@ type ProjectsSectionProps = {
};
export function ProjectsSection({ collapsed }: ProjectsSectionProps) {
const { t } = useTranslation();
const router = useRouter();
const inOffice = useInOffice();
const projects = useAppStore((s) => 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")}
>
<IconPlus className="h-3 w-3 text-muted-foreground/60" />
</Button>
</TooltipTrigger>
<TooltipContent>Add project</TooltipContent>
<TooltipContent>{t("sidebar:addProject")}</TooltipContent>
</Tooltip>
);
return (
<AppSidebarSection
id={APP_SIDEBAR_SECTION_IDS.projects}
label="Projects"
label={t("sidebar:projects")}
collapsed={collapsed}
icon={IconBoxMultiple}
headerAction={headerAction}
@@ -51,7 +53,7 @@ export function ProjectsSection({ collapsed }: ProjectsSectionProps) {
defaultExpanded
>
{activeProjects.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">No projects yet</p>
<p className="px-3 py-2 text-xs text-muted-foreground">{t("sidebar:noProjectsYet")}</p>
) : (
activeProjects.map((project) => {
const taskCount = project.taskCounts?.total ?? 0;
@@ -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 (
<SettingsGroup
label="Account"
label={t("sidebar:account")}
icon={IconUserCircle}
href={DEFAULT_HREF}
isActive={pathname.startsWith(ROOT_HREF)}
expanded={expanded}
onToggle={onToggle}
>
{ITEMS.map(({ href, label, icon }) => (
{ITEMS.map(({ href, labelKey, icon }) => (
<SettingsLeaf
key={href}
href={href}
label={label}
label={t(labelKey)}
icon={icon}
isActive={pathname === href}
depth={1}
@@ -1,6 +1,7 @@
"use client";
import { IconCpu } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAppStore } from "@/components/state-provider";
import { getExecutorIcon } from "@/lib/executor-icons";
import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives";
@@ -14,6 +15,7 @@ type ExecutorsGroupProps = {
};
export function ExecutorsGroup({ pathname, expanded, onToggle }: ExecutorsGroupProps) {
const { t } = useTranslation();
const executors = useAppStore((s) => 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 (
<SettingsGroup
label="Executors"
label={t("common:executors")}
icon={IconCpu}
href={ROOT_HREF}
isActive={pathname === ROOT_HREF}
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { t } from "@/lib/i18n";
import sidebarCatalog from "@/src/locales/en/sidebar.json";
/**
* Byte-for-byte pin on the settings nav's English copy.
*
* Externalizing a string can silently REWRITE it: point a label at an existing
* key whose wording has drifted and lint, `i18n:check`, the ratchet and the unit
* suite all still pass — the string is externalized, the key exists, the
* catalogs are in sync. Nothing downstream compares the rendered sentence to
* what users saw before.
*
* Nav labels are the sharpest case, because the same word usually exists twice:
* once here, and once as the route's own page title in
* `components/settings/settings-layout-client.tsx`. These assertions are the
* diff, kept.
*/
const NAV_LABELS: Array<[key: string, english: string]> = [
["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([]);
});
});
@@ -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({
<button
type="button"
onClick={toggle}
aria-label={expanded ? `Collapse ${label}` : `Expand ${label}`}
aria-label={
expanded ? t("sidebar:collapseGroup", { label }) : t("sidebar:expandGroup", { label })
}
aria-expanded={expanded}
className={cn(
"shrink-0 flex h-5 w-5 items-center justify-center text-muted-foreground/60 hover:text-foreground/80 cursor-pointer transition-colors",
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
IconKey,
IconMessageCircle,
@@ -58,6 +59,7 @@ export function settingsOpenGroupIdForPath(pathname: string): string {
* footer gear is active, as the full-height sidebar takeover.
*/
export function SettingsTree({ pathname }: { pathname: string }) {
const { t } = useTranslation();
const authEnabled = useFeature("auth");
const authMode = useAppStore((s) => s.auth.mode);
const showAccountGroup = authEnabled && authMode === "enabled";
@@ -83,19 +85,19 @@ export function SettingsTree({ pathname }: { pathname: string }) {
<AgentsGroup pathname={pathname} {...groupProps("agents")} />
<SettingsLeaf
href={PROMPTS_HREF}
label="Prompts"
label={t("common:prompts")}
icon={IconMessageCircle}
isActive={pathname === PROMPTS_HREF}
/>
<SettingsLeaf
href={VOICE_MODE_HREF}
label="Voice Mode"
label={t("settings:voiceMode")}
icon={IconMicrophone}
isActive={pathname === VOICE_MODE_HREF}
/>
<SettingsLeaf
href={UTILITY_HREF}
label="Utility Agents"
label={t("settings:utilityAgents")}
icon={IconWand}
isActive={pathname === UTILITY_HREF}
/>
@@ -103,20 +105,20 @@ export function SettingsTree({ pathname }: { pathname: string }) {
{/* Editors lives under General (see GeneralGroup) — no duplicate top-level leaf. */}
<SettingsLeaf
href={SECRETS_HREF}
label="Secrets"
label={t("settings:secrets")}
icon={IconKey}
isActive={pathname === SECRETS_HREF}
/>
<SettingsLeaf
href={EXT_MCP_HREF}
label="External MCP"
label={t("common:externalMcp")}
icon={IconPlugConnected}
isActive={pathname === EXT_MCP_HREF}
/>
<PluginSlot name="settings-nav" />
<SettingsLeaf
href={PLUGINS_HREF}
label="Plugins"
label={t("common:plugins")}
icon={IconPuzzle}
isActive={pathname === PLUGINS_HREF}
/>
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState, type ComponentType } from "react";
import { useTranslation } from "react-i18next";
import {
IconArrowsShuffle,
IconBolt,
@@ -39,17 +40,25 @@ const INTEGRATIONS: Array<{ slug: string; label: string; icon: IntegrationIcon }
{ slug: "slack", label: "Slack", icon: IconBrandSlack },
];
const ACTIVE_WORKSPACE_LABEL = (
<span className="shrink-0 rounded-full border border-primary/35 bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
Active
</span>
);
// Rendered as components, not module-scope JSX constants: `t()` must resolve at
// render so a locale switch reaches them (a module-scope value freezes at boot).
function ActiveWorkspaceBadge() {
const { t } = useTranslation();
return (
<span className="shrink-0 rounded-full border border-primary/35 bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
{t("sidebar:activeWorkspaceBadge")}
</span>
);
}
const ENABLED_LABEL = (
<span className="shrink-0 rounded border border-emerald-500/30 bg-emerald-500/10 px-1 py-0.5 text-[9px] font-medium leading-none text-emerald-600 dark:text-emerald-400">
Enabled
</span>
);
function EnabledBadge() {
const { t } = useTranslation();
return (
<span className="shrink-0 rounded border border-emerald-500/30 bg-emerald-500/10 px-1 py-0.5 text-[9px] font-medium leading-none text-emerald-600 dark:text-emerald-400">
{t("sidebar:enabledBadge")}
</span>
);
}
function WorkspaceIntegrationItems({
workspaceId,
@@ -84,7 +93,7 @@ function WorkspaceIntegrationItems({
key={href}
href={href}
label={label}
labelSuffix={enabled.has(slug) ? ENABLED_LABEL : undefined}
labelSuffix={enabled.has(slug) ? <EnabledBadge /> : undefined}
icon={icon}
isActive={pathname === href}
depth={3}
@@ -122,6 +131,7 @@ function activeWorkspaceFirst<T extends { id: string }>(workspaces: T[], activeI
}
export function WorkspacesGroup({ pathname, expanded, onToggle }: WorkspacesGroupProps) {
const { t } = useTranslation();
const workspaces = useAppStore((s) => s.workspaces.items);
const storeActiveWorkspaceId = useAppStore((s) => s.workspaces.activeId);
const routeWorkspaceId =
@@ -143,7 +153,7 @@ export function WorkspacesGroup({ pathname, expanded, onToggle }: WorkspacesGrou
return (
<SettingsGroup
label="Workspaces"
label={t("common:workspaces")}
icon={IconFolder}
href={ROOT_HREF}
isActive={pathname === ROOT_HREF}
@@ -163,7 +173,7 @@ export function WorkspacesGroup({ pathname, expanded, onToggle }: WorkspacesGrou
<SettingsGroup
key={workspace.id}
label={workspace.name}
labelSuffix={workspaceIsActive ? ACTIVE_WORKSPACE_LABEL : undefined}
labelSuffix={workspaceIsActive ? <ActiveWorkspaceBadge /> : undefined}
href={workspacePath}
isActive={pathname === workspacePath}
expanded={expandedWorkspaceId === workspace.id}
@@ -172,20 +182,20 @@ export function WorkspacesGroup({ pathname, expanded, onToggle }: WorkspacesGrou
>
<SettingsLeaf
href={repositoriesPath}
label="Repositories"
label={t("sidebar:repositories")}
icon={IconGitBranch}
isActive={pathname === repositoriesPath}
depth={2}
/>
<SettingsLeaf
href={workflowsPath}
label="Workflows"
label={t("workflows:workflows")}
icon={IconArrowsShuffle}
isActive={pathname === workflowsPath}
depth={2}
/>
<SettingsGroup
label="Integrations"
label={t("common:integrations")}
icon={IconPlugConnected}
href={integrationsPath}
isActive={pathname === integrationsPath}
@@ -200,7 +210,7 @@ export function WorkspacesGroup({ pathname, expanded, onToggle }: WorkspacesGrou
</SettingsGroup>
<SettingsLeaf
href={automationsPath}
label="Automations"
label={t("common:automations")}
icon={IconBolt}
isActive={pathname === automationsPath}
depth={2}
@@ -1,6 +1,7 @@
"use client";
import { IconCircleDot } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAppStore } from "@/components/state-provider";
import { TaskSessionSidebar } from "@/components/task/task-session-sidebar";
import { APP_SIDEBAR_SECTION_IDS } from "../app-sidebar-constants";
@@ -12,13 +13,14 @@ type TasksSectionProps = {
};
export function TasksSection({ collapsed }: TasksSectionProps) {
const { t } = useTranslation();
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const workflowId = useAppStore((s) => s.kanban.workflowId);
return (
<AppSidebarSection
id={APP_SIDEBAR_SECTION_IDS.tasks}
label="Tasks"
label={t("sidebar:tasks")}
collapsed={collapsed}
icon={IconCircleDot}
headerAction={<TasksViewPicker />}
@@ -1,6 +1,7 @@
"use client";
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { IconChevronDown, IconAdjustments, IconCheck, IconPlus } from "@tabler/icons-react";
import {
DropdownMenu,
@@ -10,6 +11,7 @@ import {
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { useAppStore } from "@/components/state-provider";
import { MAX_SIDEBAR_VIEWS } from "@/lib/state/slices/ui/sidebar-view-builtins";
import { SidebarFilterPopover } from "@/components/task/sidebar-filter/sidebar-filter-popover";
import { useSidebarViewPopover } from "@/components/task/sidebar-filter/use-sidebar-view-popover";
import { cn } from "@/lib/utils";
@@ -20,6 +22,7 @@ const TRIGGER_BUTTON_CLASS = cn(
);
export function TasksViewPicker() {
const { t } = useTranslation();
const views = useAppStore((s) => s.sidebarViews.views);
const activeViewId = useAppStore((s) => s.sidebarViews.activeViewId);
const draft = useAppStore((s) => s.sidebarViews.draft);
@@ -39,7 +42,7 @@ export function TasksViewPicker() {
[views, activeViewId],
);
const hasDraft = !!draft && draft.baseViewId === activeViewId;
const activeLabel = activeView?.name ?? "All";
const activeLabel = activeView?.name ?? t("sidebar:viewAll");
return (
<div className="flex items-center gap-0.5">
@@ -49,7 +52,7 @@ export function TasksViewPicker() {
type="button"
data-testid="tasks-view-picker"
className={cn(TRIGGER_BUTTON_CLASS, "max-w-[120px] gap-0.5")}
aria-label={`View: ${activeLabel}`}
aria-label={t("sidebar:viewLabel", { name: activeLabel })}
>
<span className="truncate">{activeLabel}</span>
<IconChevronDown className="h-3 w-3 shrink-0 opacity-70" />
@@ -101,14 +104,14 @@ export function TasksViewPicker() {
<button
type="button"
data-testid="sidebar-filter-gear"
aria-label="Filters and sort"
aria-label={t("sidebar:filtersAndSort")}
className={cn(TRIGGER_BUTTON_CLASS, "relative h-5 w-5 px-0")}
>
<IconAdjustments className="h-3.5 w-3.5" />
{hasDraft && (
<span
data-testid="sidebar-filter-gear-indicator"
aria-label="Unsaved filter changes"
aria-label={t("sidebar:unsavedFilterChanges")}
className="absolute right-0.5 top-0.5 h-1 w-1 rounded-full bg-amber-500"
/>
)}
@@ -128,20 +131,30 @@ function NewViewMenuItem({
hasDraft: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
return (
<DropdownMenuItem
disabled={!!disabledReason}
onSelect={onSelect}
data-testid="sidebar-new-view"
aria-label={disabledReason ? `New view unavailable. ${disabledReason}` : "New view"}
aria-label={
// `disabledReason` is still English: it comes from
// components/task/sidebar-filter/use-sidebar-view-popover.ts, which that
// directory's migration owns.
disabledReason
? t("sidebar:newViewUnavailable", { reason: disabledReason })
: t("sidebar:newView")
}
title={disabledReason ?? undefined}
className="cursor-pointer gap-2"
>
<IconPlus className="h-3.5 w-3.5" />
<span>New view</span>
<span>{t("sidebar:newView")}</span>
{disabledReason && (
<span className="ml-auto text-[10px] text-muted-foreground" aria-hidden="true">
{hasDraft ? "Save/discard first" : "50 max"}
{hasDraft
? t("sidebar:saveDiscardFirst")
: t("sidebar:maxViews", { count: MAX_SIDEBAR_VIEWS })}
</span>
)}
</DropdownMenuItem>
@@ -1,6 +1,7 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@kandev/ui/lib/utils";
import { useAppStatusItems, type AppStatusItem } from "./app-status-items";
import { useAppStatusBarDrag } from "./use-app-status-bar-drag";
@@ -25,6 +26,7 @@ export function AppStatusBar({
activeSessionId,
density,
}: AppStatusBarProps) {
const { t } = useTranslation();
const context = useMemo(
() => ({ pathname, activeWorkspaceId, activeTaskId, activeSessionId }),
[pathname, activeWorkspaceId, activeTaskId, activeSessionId],
@@ -42,7 +44,7 @@ export function AppStatusBar({
drag.draggingId && "cursor-grabbing",
)}
data-testid="app-status-bar"
aria-label="Application status"
aria-label={t("sidebar:applicationStatus")}
>
<StatusItemGroup side="left" testId="app-status-bar-left-plugins">
{projected.left.map((item) => (
@@ -1,6 +1,7 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { IconActivity } from "@tabler/icons-react";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
import { cn } from "@kandev/ui/lib/utils";
@@ -26,6 +27,7 @@ export function AppStatusDrawer({
onOpenChange,
connectionOnly = false,
}: AppStatusDrawerProps) {
const { t } = useTranslation();
const context = useMemo(
() => ({ pathname, activeWorkspaceId, activeTaskId, activeSessionId }),
[pathname, activeWorkspaceId, activeTaskId, activeSessionId],
@@ -62,10 +64,10 @@ export function AppStatusDrawer({
</div>
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Workspace
{t("common:workspace")}
</p>
<DrawerTitle className="text-base font-semibold tracking-[-0.015em] text-balance">
Status
{t("common:status")}
</DrawerTitle>
</div>
</div>
@@ -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"
>
<section className="space-y-2" aria-label="Application status items">
<section className="space-y-2" aria-label={t("sidebar:applicationStatusItems")}>
{orderedItems.map((item) => (
<DrawerStatusItem key={item.id} item={item} open={open} />
))}
@@ -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<ComponentProps<typeof Button>, "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 (
<Button
{...buttonProps}
@@ -1,6 +1,17 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { connectionIssueDetails, connectionStatusDetails } from "./connection-status-item";
import { t } from "@/lib/i18n";
import {
connectionIssueDetails,
connectionStatusDetails,
useConnectionIssueCopy,
} from "./connection-status-item";
/**
* These helpers return catalog keys rather than copy (they are plain functions
* with no hook available), so the assertions resolve through `t` — that covers
* the key AND its catalog entry, which is what a reader actually gets.
*/
describe("connectionStatusDetails", () => {
it.each([
["connected", "Connected", "Connected to Kandev"],
@@ -9,12 +20,17 @@ describe("connectionStatusDetails", () => {
["disconnected", "Offline", "Connection unavailable"],
["error", "Connection error", "Connection error: socket closed"],
] as const)("maps %s to accessible connection details", (status, label, description) => {
expect(
connectionStatusDetails(status, status === "error" ? "socket closed" : null),
).toMatchObject({
label,
description,
});
const details = connectionStatusDetails(status, status === "error" ? "socket closed" : null);
expect(t(details.labelKey)).toBe(label);
expect(t(details.descriptionKey, { ...details.descriptionValues })).toBe(description);
});
it("falls back to the undetailed message when the transport reports no error", () => {
const details = connectionStatusDetails("error", null);
expect(details.descriptionValues).toBeUndefined();
expect(t(details.descriptionKey)).toBe("Connection error");
});
it("uses a small green dot for an active connection", () => {
@@ -38,6 +54,28 @@ describe("connectionStatusDetails", () => {
"bg-destructive",
],
] as const)("maps %s to a problem-only warning", (severity, label, description, dotClass) => {
expect(connectionIssueDetails(severity)).toMatchObject({ label, description, dotClass });
const details = connectionIssueDetails(severity);
expect(t(details.labelKey)).toBe(label);
expect(t(details.descriptionKey)).toBe(description);
expect(details.dotClass).toBe(dotClass);
});
});
describe("useConnectionIssueCopy", () => {
it("returns nothing while the connection is healthy", () => {
const { result } = renderHook(() => useConnectionIssueCopy("none"));
expect(result.current).toBeNull();
});
it("resolves label and description for an active issue", () => {
const { result } = renderHook(() => useConnectionIssueCopy("lost"));
expect(result.current).toMatchObject({
label: "Connection lost",
description: "Connection lost for at least 10 seconds. Live updates may be stale.",
dotClass: "bg-destructive",
});
});
});
@@ -1,20 +1,31 @@
"use client";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@kandev/ui/lib/utils";
import { useAppStore } from "@/components/state-provider";
import type { ConnectionIssueSeverity, ConnectionStatus } from "@/lib/types/connection";
/**
* Copy is returned as catalog keys, not resolved strings: these are plain
* functions (no hook available) and every consumer is a component, so resolving
* at render keeps a locale switch working. Resolving here with the module-level
* `t` would work today but reads as module-scope copy the next reader must
* re-verify.
*/
export type ConnectionStatusDetails = {
label: string;
description: string;
labelKey: string;
descriptionKey: string;
descriptionValues?: Record<string, string>;
dotClass: string;
animate: boolean;
};
export type ConnectionIssueDetails = {
label: string;
description: string;
labelKey: string;
descriptionKey: string;
/** Always absent for issue details; present so the two shapes stay unifiable. */
descriptionValues?: Record<string, string>;
dotClass: string;
animate: boolean;
};
@@ -26,36 +37,39 @@ export function connectionStatusDetails(
switch (status) {
case "connected":
return {
label: "Connected",
description: "Connected to Kandev",
labelKey: "sidebar:connected",
descriptionKey: "sidebar:connectedToKandev",
dotClass: "bg-success",
animate: false,
};
case "connecting":
return {
label: "Connecting",
description: "Connecting to Kandev",
labelKey: "sidebar:connecting",
descriptionKey: "sidebar:connectingToKandev",
dotClass: "bg-muted-foreground",
animate: true,
};
case "reconnecting":
return {
label: "Reconnecting",
description: "Reconnecting to Kandev",
labelKey: "sidebar:reconnecting",
descriptionKey: "sidebar:reconnectingToKandev",
dotClass: "bg-amber-500",
animate: true,
};
case "error":
return {
label: "Connection error",
description: error ? `Connection error: ${error}` : "Connection error",
labelKey: "sidebar:connectionError",
// The interpolated `error` is a transport/API diagnostic and stays
// English by design — see docs/i18n.md on interpolated values.
descriptionKey: error ? "sidebar:connectionErrorDetail" : "sidebar:connectionError",
descriptionValues: error ? { error } : undefined,
dotClass: "bg-destructive",
animate: false,
};
case "disconnected":
return {
label: "Offline",
description: "Connection unavailable",
labelKey: "sidebar:offline",
descriptionKey: "sidebar:connectionUnavailable",
dotClass: "bg-muted-foreground/50",
animate: false,
};
@@ -68,22 +82,39 @@ export function connectionIssueDetails(
switch (severity) {
case "unstable":
return {
label: "Connection unstable",
description: "Connection unstable. Reconnecting to Kandev.",
labelKey: "sidebar:connectionUnstable",
descriptionKey: "sidebar:connectionUnstableDescription",
dotClass: "bg-amber-500",
animate: false,
};
case "lost":
return {
label: "Connection lost",
description: "Connection lost for at least 10 seconds. Live updates may be stale.",
labelKey: "sidebar:connectionLost",
descriptionKey: "sidebar:connectionLostDescription",
dotClass: "bg-destructive",
animate: false,
};
}
}
/**
* Resolved connection-issue copy for the surfaces that only need the sentence
* (mobile header, menu sheet, bottom nav, sidebar fallback). Keeps `t` out of
* four unrelated components and returns null while the connection is healthy.
*/
export function useConnectionIssueCopy(severity: ConnectionIssueSeverity) {
const { t } = useTranslation();
if (severity === "none") return null;
const details = connectionIssueDetails(severity);
return {
...details,
label: t(details.labelKey),
description: t(details.descriptionKey),
};
}
export function ConnectionStatusItem({ presentation }: { presentation: "bar" | "mobile-drawer" }) {
const { t } = useTranslation();
const status = useAppStore((state) => state.connection.status);
const error = useAppStore((state) => state.connection.error);
const severity = useAppStore((state) => state.connection.issueSeverity);
@@ -92,6 +123,7 @@ export function ConnectionStatusItem({ presentation }: { presentation: "bar" | "
const details = issueActive
? connectionIssueDetails(severity)
: connectionStatusDetails(status, error);
const description = t(details.descriptionKey, { ...details.descriptionValues });
return (
<Tooltip>
@@ -102,7 +134,7 @@ export function ConnectionStatusItem({ presentation }: { presentation: "bar" | "
presentation === "bar" ? "h-full w-5 justify-center" : "min-h-11 gap-3 px-1 text-sm",
)}
role="status"
aria-label={details.description}
aria-label={description}
tabIndex={presentation === "bar" ? 0 : undefined}
data-testid="app-status-connection"
data-connection-severity={issueActive ? severity : undefined}
@@ -112,18 +144,18 @@ export function ConnectionStatusItem({ presentation }: { presentation: "bar" | "
aria-hidden="true"
/>
{presentation === "bar" ? (
<span className="sr-only">{details.label}</span>
<span className="sr-only">{t(details.labelKey)}</span>
) : (
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Connection
{t("sidebar:connection")}
</span>
<span className="text-sm font-medium text-foreground">{details.description}</span>
<span className="text-sm font-medium text-foreground">{description}</span>
</span>
)}
</span>
</TooltipTrigger>
<TooltipContent>{details.description}</TooltipContent>
<TooltipContent>{description}</TooltipContent>
</Tooltip>
);
}
+65 -61
View File
@@ -1,6 +1,8 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useRouter } from "@/lib/routing/client-router";
import { useTheme } from "@/components/theme/app-theme";
import {
@@ -29,91 +31,97 @@ import type { CommandItem } from "@/lib/commands/types";
type PushFn = ReturnType<typeof useRouter>["push"];
function buildNavigationCommands(push: PushFn): CommandItem[] {
// Catalog keys, not copy — safe at module scope (no `t()` call here). The
// palette groups by this resolved value, so every producer must use these.
const GROUP_NAVIGATION = "common:commandGroupNavigation";
const GROUP_SETTINGS = "common:commandGroupSettings";
const GROUP_ACTIONS = "common:commandGroupActions";
/**
* Search keywords are stored as one comma-separated catalog value so a
* translator can localize the whole set in one entry. They are matched, never
* displayed; the palette itself selects commands by `id` (see
* `command-panel-footer.tsx`), so no behavior keys off this copy.
*/
function searchKeywords(t: TFunction, key: string): string[] {
return t(key)
.split(",")
.map((keyword) => keyword.trim())
.filter(Boolean);
}
function buildNavigationCommands(push: PushFn, t: TFunction): CommandItem[] {
return [
{
id: "nav-home",
label: "Go to Home",
group: "Navigation",
label: t("common:commandGoToHome"),
group: t(GROUP_NAVIGATION),
icon: <IconHome className="size-3.5" />,
keywords: ["home", "kanban", "board"],
keywords: searchKeywords(t, "common:commandGoToHomeKeywords"),
action: () => push(linkToTaskOverview()),
},
{
id: "nav-tasks",
label: "Go to All Tasks",
group: "Navigation",
label: t("common:commandGoToAllTasks"),
group: t(GROUP_NAVIGATION),
icon: <IconList className="size-3.5" />,
keywords: ["tasks", "list", "all"],
keywords: searchKeywords(t, "common:commandGoToAllTasksKeywords"),
action: () => push("/tasks"),
},
{
id: "nav-settings",
label: "Go to Settings",
group: "Navigation",
label: t("common:commandGoToSettings"),
group: t(GROUP_NAVIGATION),
icon: <IconSettings className="size-3.5" />,
keywords: ["settings", "preferences", "config", "general settings"],
keywords: searchKeywords(t, "common:commandGoToSettingsKeywords"),
action: () => push("/settings/general"),
},
{
id: "nav-stats",
label: "Go to Stats",
group: "Navigation",
label: t("common:commandGoToStats"),
group: t(GROUP_NAVIGATION),
icon: <IconChartBar className="size-3.5" />,
keywords: ["stats", "statistics", "analytics", "metrics"],
keywords: searchKeywords(t, "common:commandGoToStatsKeywords"),
action: () => push("/stats"),
},
{
id: "nav-github",
label: "Go to GitHub Dashboard",
group: "Navigation",
label: t("common:commandGoToGitHubDashboard"),
group: t(GROUP_NAVIGATION),
icon: <IconBrandGithub className="size-3.5" />,
keywords: ["github", "dashboard", "pr", "pull request", "code review", "issues", "review"],
keywords: searchKeywords(t, "common:commandGoToGitHubDashboardKeywords"),
action: () => push("/github"),
},
{
id: "settings-agents",
label: "Agents Settings",
group: "Settings",
label: t("common:commandAgentsSettings"),
group: t(GROUP_SETTINGS),
icon: <IconRobot className="size-3.5" />,
keywords: ["agents", "agent settings", "agent profiles", "installed agents", "claude"],
keywords: searchKeywords(t, "common:commandAgentsSettingsKeywords"),
action: () => push("/settings/agents"),
},
{
id: "settings-executors",
label: "Executors Settings",
group: "Settings",
label: t("common:commandExecutorsSettings"),
group: t(GROUP_SETTINGS),
icon: <IconCpu className="size-3.5" />,
keywords: [
"executors",
"executor profiles",
"execution environment",
"environment variables",
"runtime",
"compute",
],
keywords: searchKeywords(t, "common:commandExecutorsSettingsKeywords"),
action: () => push("/settings/executors"),
},
{
id: "settings-workspace",
label: "Workspace Settings",
group: "Settings",
label: t("common:commandWorkspaceSettings"),
group: t(GROUP_SETTINGS),
icon: <IconFolder className="size-3.5" />,
keywords: ["workspace", "workspaces"],
keywords: searchKeywords(t, "common:commandWorkspaceSettingsKeywords"),
action: () => push("/settings/workspace"),
},
{
id: "settings-prompts",
label: "Prompts Settings",
group: "Settings",
label: t("common:commandPromptsSettings"),
group: t(GROUP_SETTINGS),
icon: <IconMessageCircle className="size-3.5" />,
keywords: [
"prompts",
"prompt settings",
"custom prompts",
"prompt snippets",
"prompt templates",
],
keywords: searchKeywords(t, "common:commandPromptsSettingsKeywords"),
action: () => push("/settings/prompts"),
},
];
@@ -122,20 +130,22 @@ function buildNavigationCommands(push: PushFn): CommandItem[] {
function buildThemeCommand(
resolvedTheme: string | undefined,
setTheme: (theme: string) => void,
t: TFunction,
): CommandItem {
const isDark = resolvedTheme === "dark";
const destinationTheme = isDark ? "light" : "dark";
return {
id: "pref-theme",
label: isDark ? "Switch to Light Mode" : "Switch to Dark Mode",
group: "Preferences",
label: isDark ? t("common:commandSwitchToLightMode") : t("common:commandSwitchToDarkMode"),
group: t("common:commandGroupPreferences"),
icon: isDark ? <IconSun className="size-3.5" /> : <IconMoon className="size-3.5" />,
keywords: ["theme", "color theme", "appearance"],
keywords: searchKeywords(t, "common:commandThemeKeywords"),
action: () => setTheme(destinationTheme),
};
}
export function GlobalCommands() {
const { t } = useTranslation();
const router = useRouter();
const { resolvedTheme, setTheme } = useTheme();
const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId);
@@ -148,42 +158,36 @@ export function GlobalCommands() {
const quickChatCommand: CommandItem = useMemo(
() => ({
id: "quick-chat",
label: "Quick Chat",
group: "Actions",
label: t("common:commandQuickChat"),
group: t(GROUP_ACTIONS),
icon: <IconMessageCircle className="size-3.5" />,
keywords: ["quick chat", "new quick chat", "quick question", "ask agent"],
keywords: searchKeywords(t, "common:commandQuickChatKeywords"),
shortcut: quickChatShortcut,
action: handleOpenQuickChat,
}),
[handleOpenQuickChat, quickChatShortcut],
[handleOpenQuickChat, quickChatShortcut, t],
);
const configChatCommand: CommandItem = useMemo(
() => ({
id: "config-chat",
label: "Configuration Chat",
group: "Actions",
label: t("common:commandConfigurationChat"),
group: t(GROUP_ACTIONS),
icon: <IconSparkles className="size-3.5" />,
keywords: [
"config chat",
"config mode",
"configure kandev",
"workflow configuration",
"mcp configuration",
],
keywords: searchKeywords(t, "common:commandConfigChatKeywords"),
action: handleOpenConfigChat,
}),
[handleOpenConfigChat],
[handleOpenConfigChat, t],
);
const commands = useMemo<CommandItem[]>(
() => [
...buildNavigationCommands(router.push),
buildThemeCommand(resolvedTheme, setTheme),
...buildNavigationCommands(router.push, t),
buildThemeCommand(resolvedTheme, setTheme, t),
quickChatCommand,
configChatCommand,
],
[router.push, resolvedTheme, setTheme, quickChatCommand, configChatCommand],
[router.push, resolvedTheme, setTheme, quickChatCommand, configChatCommand, t],
);
useRegisterCommands(commands);
@@ -10,7 +10,7 @@ import { MobileMenuSheet } from "./mobile-menu-sheet";
import type { TasksListDisplayOptions } from "./mobile-menu-task-list-options";
import { useAppStore } from "@/components/state-provider";
import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider";
import { connectionIssueDetails } from "@/components/app-status-bar/connection-status-item";
import { useConnectionIssueCopy } from "@/components/app-status-bar/connection-status-item";
import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher";
import { workspaceHomeHref } from "@/components/app-sidebar/app-sidebar-workspace-navigation";
import { cn } from "@/lib/utils";
@@ -61,7 +61,7 @@ function MobileHeaderActions({
setMenuOpen: (open: boolean) => void;
}) {
const { issueSeverity } = useAppStatusDrawer();
const issueDetails = issueSeverity === "none" ? null : connectionIssueDetails(issueSeverity);
const issueDetails = useConnectionIssueCopy(issueSeverity);
return (
<>
@@ -32,7 +32,7 @@ import type { Repository, Task } from "@/lib/types/http";
import type { WorkflowsState } from "@/lib/state/slices";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider";
import { connectionIssueDetails } from "@/components/app-status-bar/connection-status-item";
import { useConnectionIssueCopy } from "@/components/app-status-bar/connection-status-item";
import { ImproveKandevDialog } from "@/components/improve-kandev-dialog";
type MobileMenuSheetProps = {
open: boolean;
@@ -292,7 +292,7 @@ function MobileUtilityActions({
onOpenChange: (open: boolean) => void;
}) {
const { enabled: statusDrawerEnabled, issueSeverity, openStatusDrawer } = useAppStatusDrawer();
const issueDetails = issueSeverity === "none" ? null : connectionIssueDetails(issueSeverity);
const issueDetails = useConnectionIssueCopy(issueSeverity);
const closeSheet = () => onOpenChange(false);
const openHealth = () => {
closeSheet();
@@ -14,7 +14,7 @@ import { cn } from "@/lib/utils";
import { Badge } from "@kandev/ui/badge";
import type { MobileSessionPanel } from "@/lib/state/slices/ui/types";
import type { ConnectionIssueSeverity } from "@/lib/types/connection";
import { connectionIssueDetails } from "@/components/app-status-bar/connection-status-item";
import { useConnectionIssueCopy } from "@/components/app-status-bar/connection-status-item";
type SessionMobileBottomNavProps = {
activePanel: MobileSessionPanel;
@@ -132,9 +132,7 @@ function MobileNavButton({
activePanel: MobileSessionPanel;
onPanelChange: (panel: MobileSessionPanel) => void;
}) {
const issueDetails = item.connectionIssueSeverity
? connectionIssueDetails(item.connectionIssueSeverity)
: null;
const issueDetails = useConnectionIssueCopy(item.connectionIssueSeverity ?? "none");
return (
<button
@@ -9,6 +9,7 @@ import {
type KeyboardEvent as ReactKeyboardEvent,
type MutableRefObject,
} from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "@/lib/routing/client-router";
import { useAppStore } from "@/components/state-provider";
import { useCommandPanelOpen } from "@/lib/commands/command-registry";
@@ -416,12 +417,17 @@ function useSwitcherActions({
}
function useRecentTaskSwitcherCommand(shortcut: KeyboardShortcut, openSwitcher: () => void) {
// `group` is the command palette's Map key AND its rendered heading, so it has
// to be translated in lockstep with every other producer or the heading splits
// in two under a non-English locale. `label`/`keywords` below stay English:
// they belong to the components/task migration, not this one.
const { t } = useTranslation();
const commands = useMemo(
() => [
{
id: "open-recent-task-switcher",
label: "Open Recent Task Switcher",
group: "Navigation",
group: t("common:commandGroupNavigation"),
shortcut,
keywords: [
"recent",
@@ -435,7 +441,7 @@ function useRecentTaskSwitcherCommand(shortcut: KeyboardShortcut, openSwitcher:
action: openSwitcher,
},
],
[openSwitcher, shortcut],
[openSwitcher, shortcut, t],
);
useRegisterCommands(commands);
}
+3 -1
View File
@@ -4,8 +4,10 @@ import { useTheme } from "@/components/theme/app-theme";
import { IconMoon, IconSun } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
export function ThemeToggle() {
const { t } = useTranslation();
const { theme, setTheme } = useTheme();
const mounted = useSyncExternalStore(
() => () => {},
@@ -25,7 +27,7 @@ export function ThemeToggle() {
className="h-9 w-9 p-0"
>
{theme === "dark" ? <IconSun className="h-4 w-4" /> : <IconMoon className="h-4 w-4" />}
<span className="sr-only">Toggle theme</span>
<span className="sr-only">{t("sidebar:toggleTheme")}</span>
</Button>
);
}
+17 -15
View File
@@ -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 <relative>") 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.
];
/**
+40
View File
@@ -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",
];
+48 -19
View File
@@ -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"
}
+76
View File
@@ -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"
}
+46 -17
View File
@@ -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": "Ŵōŕķśƥàćēś"
}
+76
View File
@@ -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": "Ŵōŕķśƥàćē"
}