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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "Ŵōŕķśƥàćēś"
|
||||
}
|
||||
|
||||
@@ -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": "Ŵōŕķśƥàćē"
|
||||
}
|
||||
Reference in New Issue
Block a user