fix(i18n): close the lint-invisible gaps the guard structurally cannot see (#2470)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-10 00:21:39 +02:00
committed by GitHub
parent 84ce139b35
commit fb63a88cd1
134 changed files with 2161 additions and 404 deletions
@@ -7,6 +7,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { getTask } from "@/lib/api/domains/office-extended-api";
import type { OfficeTask } from "@/lib/state/slices/office/types";
import { StatusIcon } from "@/app/office/tasks/status-icon";
import { normalizeTaskStatus } from "@/app/office/tasks/normalize-status";
import { PRIORITY_LABEL_KEYS, STATUS_LABEL_KEYS } from "@/app/office/lib/label-keys";
import { useTranslation } from "react-i18next";
type TasksTouchedProps = {
@@ -136,10 +138,16 @@ function TaskTableRow({ row }: { row: RowState }) {
<TableCell>
<span className="inline-flex items-center gap-1.5 text-xs">
<StatusIcon status={task.status} className="h-3.5 w-3.5" />
{formatStatus(task.status)}
{/* `status.replace(/_/g, " ")` used to render the wire identifier as
pseudo-English ("in progress"), which no catalog can fix.
`STATUS_LABEL_KEYS` is the same map the board, filters and status
icon already resolve through. */}
{t(STATUS_LABEL_KEYS[normalizeTaskStatus(task.status)])}
</span>
</TableCell>
<TableCell className="text-xs capitalize">{task.priority || "—"}</TableCell>
<TableCell className="text-xs">
{task.priority ? t(PRIORITY_LABEL_KEYS[task.priority] ?? "office:none") : "—"}
</TableCell>
</TableRow>
);
}
@@ -148,10 +156,6 @@ function shortId(id: string): string {
return id.length > 8 ? id.slice(0, 8) : id;
}
function formatStatus(status: string): string {
return status.replace(/_/g, " ");
}
/**
* Loads each task row in parallel via the per-task API. Kept inline in
* this file because it's only used here; the office store is task-tree
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { t } from "@/lib/i18n";
import { BUDGET_ACTION_LABEL_KEYS, BUDGET_PERIOD_LABEL_KEYS } from "./label-keys";
/**
* Both maps back a `?? raw wire value` fallback at the call site rather than a
* `?? someOtherKey` one. An earlier version defaulted an unmapped
* `actionOnExceed` to "notify only", which reports the mildest possible
* behaviour for an action the server may treat as blocking — a wrong label is
* worse here than an unfamiliar identifier.
*/
describe("budget label maps", () => {
it("covers every wire value the BudgetPolicy type allows", () => {
expect(Object.keys(BUDGET_ACTION_LABEL_KEYS).sort()).toEqual([
"block_new_tasks",
"notify_only",
"pause_agent",
]);
expect(Object.keys(BUDGET_PERIOD_LABEL_KEYS).sort()).toEqual(["monthly", "total"]);
});
it("resolves every mapped key to real copy", () => {
for (const key of [
...Object.values(BUDGET_ACTION_LABEL_KEYS),
...Object.values(BUDGET_PERIOD_LABEL_KEYS),
]) {
const value = t(key);
expect(value).not.toBe(key);
expect(value).not.toContain(":");
}
});
it("has no entry for an unknown action, so the caller falls back to the raw value", () => {
expect(BUDGET_ACTION_LABEL_KEYS["freeze_workspace"]).toBeUndefined();
expect(BUDGET_PERIOD_LABEL_KEYS["weekly"]).toBeUndefined();
});
});
+17
View File
@@ -68,3 +68,20 @@ export const CONCURRENCY_POLICY_LABEL_KEYS: Record<string, string> = {
coalesce_if_active: "office:coalesceIfActive",
always_create: "office:alwaysCreate",
};
/**
* Budget overrun actions. The costs card rendered the wire value with
* `actionOnExceed.replace(/_/g, " ")`, which is pseudo-English: a translator
* with a complete catalog still saw "block new tasks" spelled out of an
* identifier.
*/
export const BUDGET_PERIOD_LABEL_KEYS: Record<string, string> = {
monthly: "office:budgetPeriodMonthly",
total: "office:budgetPeriodTotal",
};
export const BUDGET_ACTION_LABEL_KEYS: Record<string, string> = {
notify_only: "office:budgetActionNotifyOnly",
pause_agent: "office:budgetActionPauseAgent",
block_new_tasks: "office:budgetActionBlockNewTasks",
};
@@ -6,6 +6,7 @@ import { Button } from "@kandev/ui/button";
import { IconTrash } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import type { BudgetPolicy } from "@/lib/state/slices/office/types";
import { BUDGET_ACTION_LABEL_KEYS, BUDGET_PERIOD_LABEL_KEYS } from "@/app/office/lib/label-keys";
import { cn, formatDollars } from "@/lib/utils";
import { useTranslation } from "react-i18next";
// Module-level `t`, resolved at call time: `getBudgetStatus` is a plain helper
@@ -95,12 +96,24 @@ export function BudgetPolicyCard({ policy, spentSubcents = 0, onDelete }: Props)
<span>{t("office:remainingAmount", { amount: formatDollars(remaining) })}</span>
</div>
<div className="flex gap-2 text-xs text-muted-foreground mt-1">
{/* `period` and `actionOnExceed` are wire enums; only the field
labels are copy, so the values are interpolated as-is. */}
<span>{t("office:periodValue", { period: policy.period })}</span>
{/* Both enums resolve through the shared label maps. An UNMAPPED
value falls back to the raw wire value rather than to a mapped
label: showing "notify only" for an action the server does not
actually treat that way is worse than showing its identifier. */}
<span>
{t("office:periodValue", {
period: BUDGET_PERIOD_LABEL_KEYS[policy.period]
? t(BUDGET_PERIOD_LABEL_KEYS[policy.period])
: policy.period,
})}
</span>
<span>{t("office:alertPercent", { percent: policy.alertThresholdPct })}</span>
<span>
{t("office:actionValue", { action: policy.actionOnExceed.replace(/_/g, " ") })}
{t("office:actionValue", {
action: BUDGET_ACTION_LABEL_KEYS[policy.actionOnExceed]
? t(BUDGET_ACTION_LABEL_KEYS[policy.actionOnExceed])
: policy.actionOnExceed,
})}
</span>
</div>
</div>
@@ -1,6 +1,7 @@
"use client";
import Link from "@/components/routing/app-link";
import { getExecutorLabel } from "@/lib/executor-icons";
import { AgentStatusDot } from "../../agents/components/agent-status-dot";
import type { OrgTreeNode } from "./org-tree-layout";
import { CARD_W } from "./org-tree-layout";
@@ -66,7 +67,7 @@ export function OrgNodeCard({ node }: OrgNodeCardProps) {
)}
{agent.executorPreference?.type && (
<p className="text-[10px] uppercase tracking-wide text-muted-foreground/70 truncate mt-1">
{agent.executorPreference.type.replace(/_/g, " ")}
{getExecutorLabel(agent.executorPreference.type)}
</p>
)}
</div>
+13 -6
View File
@@ -16,6 +16,10 @@ import { MobileSearchBar } from "@/components/kanban/mobile-search-bar";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import type { Task, Workspace, Workflow, Repository } from "@/lib/types/http";
import { useToast } from "@/components/toast-provider";
// Module-level `t`: every use below is inside a callback or a plain helper
// (`errorDescription`), so it resolves at invocation rather than at import —
// the case `apps/web/CLAUDE.md` sanctions. None of it renders as JSX.
import { t } from "@/lib/i18n";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings";
import { useDebounce } from "@/hooks/use-debounce";
@@ -131,7 +135,7 @@ function useTaskOperations({
if (!shouldCommit()) return;
if (!silent) {
toast({
title: "Failed to load tasks",
title: t("tasks:failedToLoadTasks"),
description: errorDescription(err),
variant: "error",
});
@@ -164,7 +168,7 @@ function useTaskOperations({
}
function errorDescription(err: unknown): string {
return err instanceof Error ? err.message : "Unknown error";
return err instanceof Error ? err.message : t("common:unknownError");
}
function useTaskMutations(fetchTasks: () => void) {
@@ -175,11 +179,14 @@ function useTaskMutations(fetchTasks: () => void) {
async (taskId: string, opts?: { cascade?: boolean }) => {
try {
await archiveTask(taskId, opts);
toast({ title: "Task archived", description: "The task has been archived successfully." });
toast({
title: t("tasks:taskArchived"),
description: t("tasks:taskArchivedDescription"),
});
fetchTasks();
} catch (err) {
toast({
title: "Failed to archive task",
title: t("tasks:failedToArchiveTask"),
description: errorDescription(err),
variant: "error",
});
@@ -196,7 +203,7 @@ function useTaskMutations(fetchTasks: () => void) {
fetchTasks();
} catch (err) {
toast({
title: "Failed to unarchive task",
title: t("tasks:failedToUnarchiveTask"),
description: errorDescription(err),
variant: "error",
});
@@ -213,7 +220,7 @@ function useTaskMutations(fetchTasks: () => void) {
fetchTasks();
} catch (err) {
toast({
title: "Failed to delete task",
title: t("tasks:failedToDeleteTask"),
description: errorDescription(err),
variant: "error",
});
@@ -15,6 +15,7 @@ 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";
import { sidebarViewName } from "@/lib/state/slices/ui/sidebar-view-builtins";
const TRIGGER_BUTTON_CLASS = cn(
"flex h-5 items-center justify-center rounded-sm px-1.5 cursor-pointer",
@@ -42,7 +43,7 @@ export function TasksViewPicker() {
[views, activeViewId],
);
const hasDraft = !!draft && draft.baseViewId === activeViewId;
const activeLabel = activeView?.name ?? t("sidebar:viewAll");
const activeLabel = activeView ? sidebarViewName(activeView, t) : t("sidebar:viewAll");
return (
<div className="flex items-center gap-0.5">
@@ -81,7 +82,7 @@ export function TasksViewPicker() {
className="cursor-pointer gap-2 text-xs"
>
<IconCheck className={cn("h-3.5 w-3.5", isActive ? "opacity-100" : "opacity-0")} />
<span className="truncate">{view.name}</span>
<span className="truncate">{sidebarViewName(view, t)}</span>
</DropdownMenuItem>
);
})}
@@ -2,6 +2,7 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "@/lib/toast/sonner";
import { t } from "@/lib/i18n";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync";
import type { AppStatusBarOrderState } from "@/lib/state/slices/settings/types";
@@ -49,7 +50,7 @@ export function useAppStatusBarOrder<T extends AppStatusItemDescriptor>(activeIt
.catch(() => {
if (latestRevision.current !== revision) return;
setOptimisticOrder(null);
toast.error("Could not save status bar order");
toast.error(t("task:couldNotSaveStatusBarOrder"));
});
},
[activeItems, order, store],
@@ -141,7 +141,7 @@ export function AzureDevOpsTaskLauncher({
const onSuccess = async (task: Task) => {
if (payload?.kind === "work-item" && workspaceId) {
if (!payload.item.project) {
toast.error("Failed to link Azure DevOps work item: project is missing.");
toast.error(t("azuredevops:failedToLinkWorkItemNoProject"));
} else {
try {
const linked = await associateAzureDevOpsWorkItem(workspaceId, task.id, {
@@ -152,7 +152,7 @@ export function AzureDevOpsTaskLauncher({
setTaskWorkItem(task.id, linked);
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : "Failed to link Azure DevOps work item.",
error instanceof Error ? error.message : t("azuredevops:failedToLinkWorkItem"),
);
}
}
@@ -167,7 +167,7 @@ export function AzureDevOpsTaskLauncher({
setTaskPullRequest(task.id, linked);
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : "Failed to link Azure DevOps pull request.",
error instanceof Error ? error.message : t("azuredevops:failedToLinkPullRequest"),
);
}
}
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useSyncExternalStore } from "react";
import { t } from "@/lib/i18n";
import { requestPRReviewers } from "@/lib/api/domains/github-review-api";
import type { PRReview, RequestedReviewer, TaskPR } from "@/lib/types/github";
@@ -208,8 +209,8 @@ export function usePRScopedReviewRequest(
async (reviewer: string) => {
if (!workspaceId || !identity) {
toast({
title: "Failed to re-request review",
description: "Select a workspace before requesting review.",
title: t("github:failedToReRequestReview"),
description: t("github:selectWorkspaceBeforeRequestingReview"),
variant: "error",
});
return;
@@ -228,14 +229,17 @@ export function usePRScopedReviewRequest(
} catch (error) {
finishRequest(identity, reviewer, operationId, false);
toast({
title: "Failed to re-request review",
description: error instanceof Error ? error.message : "An error occurred",
title: t("github:failedToReRequestReview"),
description: error instanceof Error ? error.message : t("common:anErrorOccurred"),
variant: "error",
});
return;
}
finishRequest(identity, reviewer, operationId, true);
toast({ description: `Review re-requested from ${reviewer}`, variant: "success" });
toast({
description: t("github:reviewReRequestedFrom", { reviewer }),
variant: "success",
});
try {
refresh();
} catch {
+35 -1
View File
@@ -4,11 +4,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
routerPush: vi.fn(),
onViewModeChange: vi.fn(),
commands: [] as Array<{ id: string; action?: () => void }>,
commands: [] as Array<{
id: string;
label?: string;
group?: string;
keywords?: string[];
action?: () => void;
}>,
activeWorkspaceId: "workspace-1" as string | null,
activeWorkflowId: "workflow-1" as string | null,
}));
// `t` echoes the key it is given, so copy that never reached the catalog comes
// back as plain English and the copy test below names it. Asserting the English
// itself cannot distinguish a catalog hit from an inlined literal.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => `«${key}»` }),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
@@ -72,4 +85,25 @@ describe("HomepageCommands", () => {
commandAction("view-list")();
expect(mocks.routerPush).toHaveBeenCalledWith("/tasks?workspace=workspace-1");
});
it("resolves every registered label and group through the catalog", () => {
render(<HomepageCommands onCreateTask={vi.fn()} />);
const inlined = mocks.commands.flatMap((command) =>
[
["label", command.label],
["group", command.group],
// Search keywords are matched, never displayed, but a non-English user
// types in their own language — leaving them English makes the command
// unfindable. They arrive as one comma-separated catalog value.
["keywords", command.keywords?.join(",")],
]
.filter(([, value]) => typeof value === "string" && !value.startsWith("«"))
.map(([field, value]) => `${command.id}.${field} = ${value}`),
);
expect(inlined).toEqual([]);
expect(mocks.commands.length).toBeGreaterThan(0);
expect(mocks.commands.every((command) => (command.keywords?.length ?? 0) > 0)).toBe(true);
});
});
+16 -12
View File
@@ -1,8 +1,10 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "@/lib/routing/client-router";
import { IconLayoutKanban, IconGitBranch, IconList, IconPlus } from "@tabler/icons-react";
import { searchKeywords } from "@/lib/commands/search-keywords";
import { useRegisterCommands } from "@/hooks/use-register-commands";
import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings";
import { linkToTaskOverview, linkToTasks } from "@/lib/links";
@@ -16,6 +18,7 @@ type HomepageCommandsProps = {
};
export function HomepageCommands({ onCreateTask }: HomepageCommandsProps) {
const { t } = useTranslation();
const router = useRouter();
const { onViewModeChange } = useKanbanDisplaySettings();
const { isMobile } = useResponsiveBreakpoint();
@@ -33,20 +36,20 @@ export function HomepageCommands({ onCreateTask }: HomepageCommandsProps) {
const items: CommandItem[] = [
{
id: "task-create",
label: "Create New Task",
group: "Tasks",
label: t("common:commandCreateNewTask"),
group: t("common:commandGroupTasks"),
icon: <IconPlus className="size-3.5" />,
shortcut: newTaskShortcut,
keywords: ["new", "create", "task", "add"],
keywords: searchKeywords(t, "common:commandCreateNewTaskKeywords"),
action: onCreateTask,
priority: 0,
},
{
id: "view-kanban",
label: "Switch to Kanban View",
group: "View",
label: t("common:commandSwitchToKanbanView"),
group: t("common:commandGroupView"),
icon: <IconLayoutKanban className="size-3.5" />,
keywords: ["kanban", "board", "view"],
keywords: searchKeywords(t, "common:commandSwitchToKanbanViewKeywords"),
priority: 0,
action: () => {
router.push(taskOverviewHref);
@@ -58,10 +61,10 @@ export function HomepageCommands({ onCreateTask }: HomepageCommandsProps) {
if (!isMobile) {
items.push({
id: "view-pipeline",
label: "Switch to Pipeline View",
group: "View",
label: t("common:commandSwitchToPipelineView"),
group: t("common:commandGroupView"),
icon: <IconGitBranch className="size-3.5" />,
keywords: ["pipeline", "graph", "view"],
keywords: searchKeywords(t, "common:commandSwitchToPipelineViewKeywords"),
priority: 0,
action: () => {
router.push(taskOverviewHref);
@@ -72,10 +75,10 @@ export function HomepageCommands({ onCreateTask }: HomepageCommandsProps) {
items.push({
id: "view-list",
label: "Switch to List View",
group: "View",
label: t("common:commandSwitchToListView"),
group: t("common:commandGroupView"),
icon: <IconList className="size-3.5" />,
keywords: ["list", "table", "view"],
keywords: searchKeywords(t, "common:commandSwitchToListViewKeywords"),
priority: 0,
action: () => router.push(tasksHref),
});
@@ -89,6 +92,7 @@ export function HomepageCommands({ onCreateTask }: HomepageCommandsProps) {
isMobile,
taskOverviewHref,
tasksHref,
t,
]);
useRegisterCommands(commands);
@@ -61,11 +61,22 @@ afterEach(() => {
statusDrawerState.issueSeverity = "none";
});
function renderHeader(title: string, workspaceId?: string, onSearchChange?: () => void) {
/**
* `currentPage` — not the title text — decides whether this is the Home header.
* The component used to derive that from `title === "Home"`, which was true only
* in English, so these tests pass the discriminant explicitly now.
*/
function renderHeader(
title: string,
workspaceId?: string,
onSearchChange?: () => void,
currentPage: "kanban" | "tasks" = "kanban",
) {
return render(
<StateProvider>
<KanbanHeaderMobile
title={title}
currentPage={currentPage}
workspaceId={workspaceId}
onSearchChange={onSearchChange}
workspaceLabel="/root/kandev"
@@ -87,7 +98,7 @@ describe("KanbanHeaderMobile", () => {
});
it("renders page title and workspace label for non-Home pages", () => {
renderHeader("Tasks");
renderHeader("Tasks", undefined, undefined, "tasks");
const leftActions = screen.getByTestId(LEFT_ACTIONS_TEST_ID);
expect(leftActions.textContent).toContain("Tasks");
@@ -164,7 +164,7 @@ export function KanbanHeaderMobile({
const setSearchOpen = useAppStore((state) => state.setMobileKanbanSearchOpen);
const handleOpenQuickChat = useQuickChatLauncher(workspaceId);
const handleOpenQuickTerminal = useQuickTerminalLauncher(workspaceId);
const isHome = title === "Home";
const isHome = currentPage !== "tasks";
const toggleSearch = () => {
const next = !isSearchOpen;
+24 -8
View File
@@ -14,6 +14,7 @@ import {
IconTimeline,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { PageTopbar } from "@/components/page-topbar";
import { KanbanDisplayDropdown } from "../kanban-display-dropdown";
import { usePluginTaskFilters } from "@/hooks/use-plugin-task-filters";
@@ -64,13 +65,27 @@ const DESKTOP_HEADER_NARROW_PX = 800;
function getWorkspaceLabel(
workspaces: Array<{ id: string; name: string }>,
activeWorkspaceId: string | null,
t: TFunction,
): string {
if (!activeWorkspaceId) return "All workspaces";
return workspaces.find((workspace) => workspace.id === activeWorkspaceId)?.name ?? "Workspace";
if (!activeWorkspaceId) return t("kanban:allWorkspaces");
return (
workspaces.find((workspace) => workspace.id === activeWorkspaceId)?.name ??
t("common:workspace")
);
}
function getHeaderTitle(currentPage: string): string {
return currentPage === "tasks" ? "Tasks" : "Home";
/**
* The header title is display copy, so it must not double as the "is this the
* home page?" flag — `title === "Home"` was true only in English and silently
* picked the wrong branch in every other locale. `currentPage` is the
* discriminant; the title is derived from it.
*/
function isHomePage(currentPage: string): boolean {
return currentPage !== "tasks";
}
function getHeaderTitle(currentPage: string, t: TFunction): string {
return isHomePage(currentPage) ? t("sidebar:home") : t("sidebar:tasks");
}
function toHeaderHealthProps(health: ReturnType<typeof useSystemHealthIndicator>) {
@@ -206,7 +221,7 @@ function TabletHeader({
hideTitle?: boolean;
}) {
const { t } = useTranslation();
const isHome = title === "Home";
const isHome = isHomePage(currentPage);
const pluginTaskFilters = usePluginTaskFilters();
return (
@@ -305,7 +320,7 @@ function DesktopHeader({
className={`${isNarrow ? "w-44" : "w-72 xl:w-80"} [&_input]:h-8`}
/>
) : null;
const isHome = title === "Home";
const isHome = isHomePage(currentPage);
const centerSearch =
searchInput && !isNarrow ? <div data-testid="kanban-header-search">{searchInput}</div> : null;
const actionsSearch = isNarrow ? searchInput : null;
@@ -381,6 +396,7 @@ export function KanbanHeader({
isSearchLoading = false,
tasksListOptions,
}: KanbanHeaderProps) {
const { t } = useTranslation();
const { isMobile, isTablet } = useResponsiveBreakpoint();
const isMenuOpen = useAppStore((state) => state.mobileKanban.isMenuOpen);
const setMenuOpen = useAppStore((state) => state.setMobileKanbanMenuOpen);
@@ -391,8 +407,8 @@ export function KanbanHeader({
const healthIndicator = useSystemHealthIndicator();
const toggleValue = currentPage === "tasks" ? "list" : effectiveTaskListingView;
const handleViewChange = useHeaderView(currentPage, workspaceId, workflowId, onViewModeChange);
const title = getHeaderTitle(currentPage);
const workspaceLabel = getWorkspaceLabel(workspaces, activeWorkspaceId);
const title = getHeaderTitle(currentPage, t);
const workspaceLabel = getWorkspaceLabel(workspaces, activeWorkspaceId, t);
const healthProps = toHeaderHealthProps(healthIndicator);
const sharedSearch = { searchQuery, onSearchChange, isSearchLoading };
@@ -212,7 +212,7 @@ export function MobileColumnTabs({
workflowNavigation?.workflows.find(
(workflow) => workflow.id === workflowNavigation.activeWorkflowId,
) ?? workflowNavigation?.workflows[0];
const stepLabel = activeStep?.title ?? "No steps configured";
const stepLabel = activeStep?.title ?? t("task:noStepsConfigured");
const selectStep = (index: number) => {
onColumnChange(index);
@@ -2,6 +2,7 @@
import { memo, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { IconCheck, IconChevronDown, IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
@@ -155,6 +156,7 @@ function triggerDetails(
currentModel: string | null,
modelConfig: SelectConfigOption | undefined,
extraConfigOptions: SelectConfigOption[],
t: TFunction,
): TriggerDetail[] {
let modelValue = "";
if (modelConfig) {
@@ -166,7 +168,7 @@ function triggerDetails(
? [
{
id: modelConfig?.id || MODEL_CONFIG_CATEGORY,
name: modelConfig?.name || "Model",
name: modelConfig?.name || t("common:model"),
value: modelValue,
},
]
@@ -531,8 +533,8 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({
onModelChange,
onConfigChange,
disabled,
placeholder = "Select model...",
ariaLabel = "Model settings",
placeholder,
ariaLabel,
variant = "field",
popoverSide = "bottom",
triggerClassName: customTriggerClassName,
@@ -557,7 +559,7 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({
);
const details =
triggerSummary === "changed"
? triggerDetails(modelOptions, currentModel, modelConfig, extraConfigOptions)
? triggerDetails(modelOptions, currentModel, modelConfig, extraConfigOptions, t)
: undefined;
// The (fallback) marker is a live WS signal, not replayed on reload. When
// it is active, explain on the trigger that the note is transient so users
@@ -583,11 +585,11 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({
return (
<Popover open={open} onOpenChange={onOpenChange}>
<ModelConfigSelectorTrigger
ariaLabel={ariaLabel}
ariaLabel={ariaLabel ?? t("agents:modelSettings")}
details={details}
disabled={disabled}
label={label}
placeholder={placeholder}
placeholder={placeholder ?? t("agents:selectModel")}
triggerClassName={customTriggerClassName}
triggerTitle={triggerTitle}
variant={variant}
+2 -1
View File
@@ -28,6 +28,7 @@ import {
splitReviewFileKey as splitFileKey,
suppressAvailableGitlinkFiles,
} from "./types";
import { t } from "@/lib/i18n";
/**
* Multi-repo dedup: keying ReviewFile entries by `path` only collapses
@@ -312,7 +313,7 @@ function useReviewDialogHandlers(opts: ReviewDialogHandlerOptions) {
else
toast({
title: "Discard failed",
description: result.error || "An error occurred",
description: result.error || t("common:anErrorOccurred"),
variant: "error",
});
} catch (e) {
@@ -21,7 +21,7 @@ export function RepoGroupHeader({
const { t } = useTranslation();
// The empty-name group ("uncategorised" — no repository_name on its files)
// gets a generic label so the user understands what they're looking at.
const label = name || "Other changes";
const label = name || t("task:otherChanges");
return (
<div
className="sticky top-0 z-20 flex items-center gap-2 px-4 py-1.5 bg-muted/40 backdrop-blur-sm border-y border-border/60 text-xs font-medium text-foreground"
+98 -1
View File
@@ -1,12 +1,39 @@
import { describe, expect, it, vi } from "vitest";
import type { TFunction } from "i18next";
import { t as translate } from "@/lib/i18n";
import type { CommandItem } from "@/lib/commands/types";
import { buildTaskCommands } from "./session-commands";
import {
buildGitCommands,
buildPanelCommands,
buildSessionCommands,
buildTaskCommands,
buildWorkspaceCommands,
} from "./session-commands";
const t = translate as unknown as TFunction;
const ARCHIVE_COMMAND_ID = "task-archive";
/**
* A `t` that echoes the key it was asked for. Any label or group that did NOT
* go through the catalog comes back as plain English instead, which is what the
* "every … resolves through the catalog" tests below detect. A palette entry
* whose copy is inlined renders correct English and passes lint, so an assertion
* on the English itself cannot tell the two apart.
*/
const markerT = ((key: string) => `«${key}»`) as unknown as TFunction;
const unresolved = (commands: CommandItem[]) =>
commands.flatMap((cmd) =>
[
["label", cmd.label],
["group", cmd.group],
["inputPlaceholder", cmd.inputPlaceholder],
]
.filter(([, value]) => typeof value === "string" && !value.startsWith("«"))
.map(([field, value]) => `${cmd.id}.${field} = ${value}`),
);
function build(overrides: Partial<Parameters<typeof buildTaskCommands>[0]> = {}) {
return buildTaskCommands({
activeTaskId: "task-A",
@@ -58,3 +85,73 @@ describe("buildTaskCommands", () => {
expect(build({ activeTaskId: null })).toEqual([]);
});
});
describe("session palette copy", () => {
const gitOptions = {
git: { push: vi.fn(), pull: vi.fn(), rebase: vi.fn(), merge: vi.fn() },
baseBranch: "origin/main",
openCommitDialog: vi.fn(),
openPRDialog: vi.fn(),
runGitWithFeedback: vi.fn(),
};
it("resolves every session, git, workspace and panel entry through the catalog", () => {
const commands = [
...buildSessionCommands(true, vi.fn(), markerT),
...buildGitCommands({
...gitOptions,
git: gitOptions.git as unknown as Parameters<typeof buildGitCommands>[0]["git"],
t: markerT,
}),
...buildWorkspaceCommands("session-A", markerT),
...buildPanelCommands(
{
addBrowser: vi.fn(),
addTerminal: vi.fn(),
addPlan: vi.fn(),
addChanges: vi.fn(),
} as unknown as Parameters<typeof buildPanelCommands>[0],
false,
markerT,
),
...buildTaskCommands({
activeTaskId: "task-A",
isTaskArchived: false,
t: markerT,
openNewAgent: vi.fn(),
openSubtask: vi.fn(),
requestArchive: vi.fn(),
}),
];
expect(unresolved(commands)).toEqual([]);
});
it("labels the git operation toast with the same catalog entry as the palette row", () => {
const runGitWithFeedback = vi.fn();
const push = buildGitCommands({
...gitOptions,
git: gitOptions.git as unknown as Parameters<typeof buildGitCommands>[0]["git"],
runGitWithFeedback,
t: markerT,
}).find((cmd) => cmd.id === "git-push");
push?.action?.();
// The second argument is the operation name `useGitWithFeedback` composes
// its toast titles around; it used to be a hardcoded "Push".
expect(runGitWithFeedback).toHaveBeenCalledWith(expect.any(Function), push?.label);
});
it("keeps every group of a builder identical so the palette does not split it", () => {
const groups = new Set(
buildGitCommands({
...gitOptions,
git: gitOptions.git as unknown as Parameters<typeof buildGitCommands>[0]["git"],
t: markerT,
}).map((cmd) => cmd.group),
);
expect(groups.size).toBe(1);
});
});
+98 -71
View File
@@ -22,7 +22,7 @@ import {
} from "@tabler/icons-react";
import { useRegisterCommands } from "@/hooks/use-register-commands";
import { useGitOperations } from "@/hooks/use-git-operations";
import { useGitWithFeedback } from "@/hooks/use-git-with-feedback";
import { gitOperationLabel, useGitWithFeedback } from "@/hooks/use-git-with-feedback";
import { usePanelActions } from "@/hooks/use-panel-actions";
import { useVcsDialogs } from "@/components/vcs/vcs-dialogs";
import { useAppStore } from "@/components/state-provider";
@@ -53,98 +53,124 @@ type GitRunFn = (
type GitOps = ReturnType<typeof useGitOperations>;
type PanelActions = ReturnType<typeof usePanelActions>;
function buildSessionCommands(
// Catalog keys, not copy — safe at module scope because nothing calls `t()`
// here. The palette groups by the RESOLVED value, so every producer of a group
// must go through these constants; `global-commands.tsx` does the same.
const GROUP_AGENT = "common:commandGroupAgent";
const GROUP_GIT = "common:commandGroupGit";
const GROUP_WORKSPACE = "common:commandGroupWorkspace";
const GROUP_PANELS = "common:commandGroupPanels";
export function buildSessionCommands(
isAgentRunning: boolean | undefined,
cancelTurn: () => void,
t: TFunction,
): CommandItem[] {
const items: CommandItem[] = [];
if (isAgentRunning)
items.push({
id: "session-cancel",
label: "Cancel Turn",
group: "Agent",
label: t("common:commandCancelTurn"),
group: t(GROUP_AGENT),
icon: <IconPlayerStop className="size-3.5" />,
keywords: ["cancel", "stop", "turn", "cancel agent", "stop agent", "interrupt agent"],
keywords: searchKeywords(t, "common:commandCancelTurnKeywords"),
action: cancelTurn,
});
return items;
}
function buildGitCommands(
git: GitOps,
baseBranch: string | undefined,
openCommitDialog: () => void,
openPRDialog: () => void,
runGitWithFeedback: GitRunFn,
): CommandItem[] {
type GitCommandOptions = {
git: GitOps;
baseBranch: string | undefined;
openCommitDialog: () => void;
openPRDialog: () => void;
runGitWithFeedback: GitRunFn;
t: TFunction;
};
export function buildGitCommands({
git,
baseBranch,
openCommitDialog,
openPRDialog,
runGitWithFeedback,
t,
}: GitCommandOptions): CommandItem[] {
const group = t(GROUP_GIT);
return [
{
id: "git-commit",
label: "Commit Changes",
group: "Git",
label: t("common:commandCommitChanges"),
group,
icon: <IconGitCommit className="size-3.5" />,
keywords: ["commit", "git", "save changes", "git commit"],
keywords: searchKeywords(t, "common:commandCommitChangesKeywords"),
action: openCommitDialog,
},
{
id: "git-push",
label: "Push",
group: "Git",
label: t("common:gitOpPush"),
group,
icon: <IconArrowUp className="size-3.5" />,
keywords: ["push", "git", "push changes", "push to remote", "upload changes"],
action: () => runGitWithFeedback(() => git.push(), "Push"),
keywords: searchKeywords(t, "common:commandPushKeywords"),
action: () => runGitWithFeedback(() => git.push(), gitOperationLabel(t, "common:gitOpPush")),
},
{
id: "git-pull",
label: "Pull",
group: "Git",
label: t("common:gitOpPull"),
group,
icon: <IconArrowDown className="size-3.5" />,
keywords: ["pull", "git", "pull changes", "download changes"],
action: () => runGitWithFeedback(() => git.pull(), "Pull"),
keywords: searchKeywords(t, "common:commandPullKeywords"),
action: () => runGitWithFeedback(() => git.pull(), gitOperationLabel(t, "common:gitOpPull")),
},
{
id: "git-create-pr",
label: "Create PR",
group: "Git",
label: t("common:commandCreatePr"),
group,
icon: <IconGitPullRequest className="size-3.5" />,
keywords: ["pull request", "pr", "open pull request", "submit pull request", "git"],
keywords: searchKeywords(t, "common:commandCreatePrKeywords"),
action: openPRDialog,
},
{
id: "git-rebase",
label: "Rebase",
group: "Git",
label: t("common:gitOpRebase"),
group,
icon: <IconGitBranch className="size-3.5" />,
keywords: ["rebase", "git", "branch"],
keywords: searchKeywords(t, "common:commandRebaseKeywords"),
action: () => {
const t = baseBranch?.replace(/^origin\//, "") || "main";
return runGitWithFeedback(() => git.rebase(t), "Rebase");
const target = baseBranch?.replace(/^origin\//, "") || "main";
return runGitWithFeedback(
() => git.rebase(target),
gitOperationLabel(t, "common:gitOpRebase"),
);
},
},
{
id: "git-merge",
label: "Merge",
group: "Git",
label: t("common:gitOpMerge"),
group,
icon: <IconGitMerge className="size-3.5" />,
keywords: ["merge", "git", "branch"],
keywords: searchKeywords(t, "common:commandMergeKeywords"),
action: () => {
const t = baseBranch?.replace(/^origin\//, "") || "main";
return runGitWithFeedback(() => git.merge(t), "Merge");
const target = baseBranch?.replace(/^origin\//, "") || "main";
return runGitWithFeedback(
() => git.merge(target),
gitOperationLabel(t, "common:gitOpMerge"),
);
},
},
];
}
function buildWorkspaceCommands(sessionId: string): CommandItem[] {
export function buildWorkspaceCommands(sessionId: string, t: TFunction): CommandItem[] {
return [
{
id: "workspace-create-file",
label: "Create File",
group: "Workspace",
label: t("common:commandCreateFile"),
group: t(GROUP_WORKSPACE),
icon: <IconFilePlus className="size-3.5" />,
keywords: ["create", "new", "file", "add"],
keywords: searchKeywords(t, "common:commandCreateFileKeywords"),
enterMode: "input",
inputPlaceholder: "File path relative to workspace root...",
inputPlaceholder: t("common:commandCreateFilePlaceholder"),
onInputSubmit: async (path) => {
const client = getWebSocketClient();
if (!client) return;
@@ -183,18 +209,18 @@ export function buildTaskCommands({
const items: CommandItem[] = [
{
id: "agent-new",
label: "New Agent",
group: "Agent",
label: t("common:commandNewAgent"),
group: t(GROUP_AGENT),
icon: <IconMessagePlus className="size-3.5" />,
keywords: ["new", "agent", "session", "start agent", "new session"],
keywords: searchKeywords(t, "common:commandNewAgentKeywords"),
action: openNewAgent,
},
{
id: "subtask-create",
label: "Create Subtask",
label: t("common:commandCreateSubtask"),
group: t("common:commandGroupTasks"),
icon: <IconSubtask className="size-3.5" />,
keywords: ["subtask", "create", "new subtask", "new sub-task", "child task"],
keywords: searchKeywords(t, "common:commandCreateSubtaskKeywords"),
action: openSubtask,
},
];
@@ -213,51 +239,45 @@ export function buildTaskCommands({
return items;
}
function buildPanelCommands(
export function buildPanelCommands(
panels: PanelActions,
isPassthrough: boolean | undefined,
t: TFunction,
): CommandItem[] {
const group = t(GROUP_PANELS);
const items: CommandItem[] = [
{
id: "panel-browser",
label: "Add Browser Panel",
group: "Panels",
label: t("common:commandAddBrowserPanel"),
group,
icon: <IconBrowser className="size-3.5" />,
keywords: ["browser", "preview", "web", "open browser preview", "web preview", "app preview"],
keywords: searchKeywords(t, "common:commandAddBrowserPanelKeywords"),
action: () => panels.addBrowser(),
},
{
id: "panel-terminal",
label: "Add Terminal Panel",
group: "Panels",
label: t("common:commandAddTerminalPanel"),
group,
icon: <IconTerminal2 className="size-3.5" />,
keywords: ["terminal", "shell", "console", "new terminal", "open terminal", "command line"],
keywords: searchKeywords(t, "common:commandAddTerminalPanelKeywords"),
action: () => panels.addTerminal(),
},
];
if (!isPassthrough)
items.push({
id: "panel-plan",
label: "Add Plan Panel",
group: "Panels",
label: t("common:commandAddPlanPanel"),
group,
icon: <IconFileText className="size-3.5" />,
keywords: ["plan", "document", "task plan", "implementation plan", "plan details"],
keywords: searchKeywords(t, "common:commandAddPlanPanelKeywords"),
action: () => panels.addPlan(),
});
items.push({
id: "panel-changes",
label: "Add Changes Panel",
group: "Panels",
label: t("common:commandAddChangesPanel"),
group,
icon: <IconFileDiff className="size-3.5" />,
keywords: [
"changes",
"diff",
"git changes",
"changed files",
"source control",
"git diff",
"review changes",
],
keywords: searchKeywords(t, "common:commandAddChangesPanelKeywords"),
action: () => panels.addChanges(),
});
return items;
@@ -364,12 +384,19 @@ export function SessionCommands({
const commands = useMemo<CommandItem[]>(() => {
const sessionScoped = sessionId
? [
...buildSessionCommands(isAgentRunning, cancelTurn),
...buildSessionCommands(isAgentRunning, cancelTurn, t),
...(hasWorktree
? buildGitCommands(git, baseBranch, openCommitDialog, openPRDialog, runGitWithFeedback)
? buildGitCommands({
git,
baseBranch,
openCommitDialog,
openPRDialog,
runGitWithFeedback,
t,
})
: []),
...(hasWorktree ? buildWorkspaceCommands(sessionId) : []),
...buildPanelCommands(panels, isPassthrough),
...(hasWorktree ? buildWorkspaceCommands(sessionId, t) : []),
...buildPanelCommands(panels, isPassthrough, t),
]
: [];
const items = [
@@ -6,6 +6,7 @@ import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@/lib/utils";
import {
builtInLayoutProfileName,
BUILT_IN_LAYOUT_PROFILES,
getBuiltInLayoutOverride,
getBuiltInLayoutOverrideSourceId,
@@ -109,7 +110,7 @@ function BuiltInProfileList({
>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex min-w-0 flex-wrap items-center justify-between gap-1">
<span className="text-sm font-medium">{profile.name}</span>
<span className="text-sm font-medium">{builtInLayoutProfileName(profile, t)}</span>
<span className="flex flex-wrap justify-end gap-1">
<Badge variant="outline">{t("settings:builtIn")}</Badge>
{override && <Badge variant="secondary">{t("settings:customized")}</Badge>}
@@ -118,7 +119,9 @@ function BuiltInProfileList({
)}
</span>
</span>
<span className="text-xs text-muted-foreground">{profile.description}</span>
<span className="text-xs text-muted-foreground">
{profile.descriptionKey ? t(profile.descriptionKey) : profile.description}
</span>
</span>
</button>
);
@@ -15,6 +15,7 @@ import {
resetBuiltInLayoutOverride,
resolveEffectiveDefaultLayout,
setDefaultLayoutProfile,
builtInLayoutProfileName,
upsertBuiltInLayoutOverride,
validateReusableLayout,
} from "@/lib/layout/layout-profiles";
@@ -151,8 +152,9 @@ function updateBuiltInDefault(drafts: Drafts, selected: SelectedState) {
}
function useProfileActions(drafts: Drafts, selected: SelectedState) {
const selectedName =
selected.selectedBuiltIn?.name ?? selected.selectedCustom?.name ?? t("settings:layout");
const selectedName = selected.selectedBuiltIn
? builtInLayoutProfileName(selected.selectedBuiltIn, t)
: (selected.selectedCustom?.name ?? t("settings:layout"));
const duplicate = () =>
attempt(drafts.setError, () => {
const id = createLayoutProfileId();
@@ -1,3 +1,4 @@
import { t } from "@/lib/i18n";
export type GitCredentialDisplay = {
source: string;
detail: string;
@@ -57,7 +58,7 @@ export function getGitCredentialDisplay(
case "executor":
return {
source: "Executor credentials",
detail: actor || "Credentials available in the selected executor",
detail: actor || t("task:credentialsAvailableInExecutor"),
transport: transportLabels[transport] ?? "executor Git and gh credentials",
};
default:
@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it } from "vitest";
import i18n from "i18next";
import type { PerRepoOperationResult } from "@/hooks/domains/session/use-session-git";
import { describePerRepo } from "./changes-panel-hooks";
/**
* The multi-repo git toast. Its three branches were English template literals
* (`${operationName} partially succeeded`, `Failed in N repos — …`) and are now
* catalog messages with five interpolation variables between them.
*
* A misspelled variable — `repo` for `repos`, `count` for `succeeded` — makes
* i18next leave the placeholder in the rendered string rather than throw, so it
* ships a toast reading "succeeded in {{count}} repos" and nothing else catches
* it. Every assertion below therefore checks for a leftover `{{`.
*/
afterEach(async () => {
await i18n.changeLanguage("en");
});
const repo = (name: string, success: boolean, error?: string): PerRepoOperationResult =>
({ repository_name: name, success, error }) as PerRepoOperationResult;
const noPlaceholders = (result: { title: string; description: string }) => {
expect(result.title).not.toContain("{{");
expect(result.description).not.toContain("{{");
};
describe("describePerRepo", () => {
it("reports every repo succeeding", () => {
const result = describePerRepo([repo("api", true), repo("web", true)], "Push");
expect(result.variant).toBe("success");
expect(result.title).toBe("Push successful");
expect(result.description).toBe("Push succeeded in 2 repos: api, web");
noPlaceholders(result);
});
it("reports every repo failing, with each repo's error", () => {
const result = describePerRepo(
[repo("api", false, "no upstream"), repo("web", false, "conflict")],
"Rebase",
);
expect(result.variant).toBe("error");
expect(result.title).toBe("Rebase failed");
expect(result.description).toBe("Failed in 2 repos — api: no upstream; web: conflict");
noPlaceholders(result);
});
it("surfaces a partial success as an error but names what did succeed", () => {
const result = describePerRepo([repo("api", true), repo("web", false, "conflict")], "Merge");
expect(result.variant).toBe("error");
expect(result.title).toBe("Merge partially succeeded");
expect(result.description).toBe(
"Merge succeeded in 1 of 2 repos (api); failed in web: conflict",
);
noPlaceholders(result);
});
it("falls back to a generic error when a repo reports no message", () => {
const result = describePerRepo([repo("api", false)], "Pull");
expect(result.description).toBe("Failed in 1 repo — api: Unknown error");
noPlaceholders(result);
});
it("uses the singular form for a single failing repo", () => {
// `_one` vs `_other` is the reason `count` is passed at all; English differs
// only in "repo"/"repos", but a locale with more forms needs the number.
const one = describePerRepo([repo("api", false, "boom")], "Push");
const two = describePerRepo([repo("api", false, "boom"), repo("web", false, "boom")], "Push");
expect(one.description).toContain("1 repo —");
expect(two.description).toContain("2 repos —");
});
it("keeps every branch resolving after a locale switch", async () => {
await i18n.changeLanguage("pseudo");
for (const result of [
describePerRepo([repo("api", true)], "Push"),
describePerRepo([repo("api", false, "boom")], "Push"),
describePerRepo([repo("api", true), repo("web", false, "boom")], "Push"),
]) {
noPlaceholders(result);
// The repo names are data and stay ASCII; the frame around them must not.
expect(result.title).toMatch(/[^\x20-\x7E]/);
}
});
});
+65 -31
View File
@@ -1,6 +1,8 @@
"use client";
import { useState, useCallback } from "react";
import { t } from "@/lib/i18n";
import { gitOperationLabel } from "@/hooks/use-git-with-feedback";
import type { useToast } from "@/components/toast-provider";
import type { SessionGit, PerRepoOperationResult } from "@/hooks/domains/session/use-session-git";
@@ -37,43 +39,59 @@ type GitOperationFn = (op: () => Promise<GitOperationResultLike>, name: string)
* present, summarise per-repo successes/failures instead of returning the
* raw output (which was just the last repo's text and hid partial-success).
*/
function describePerRepo(
export function describePerRepo(
perRepo: PerRepoOperationResult[],
operationName: string,
): { title: string; description: string; variant: "success" | "error" } {
const succeeded = perRepo.filter((r) => r.success);
const failed = perRepo.filter((r) => !r.success);
const succeededNames = succeeded.map((r) => r.repository_name).join(", ");
const failedSummary = failed
.map((r) => `${r.repository_name}: ${r.error || "unknown error"}`)
const repos = succeeded.map((r) => r.repository_name).join(", ");
const summary = failed
.map((r) =>
t("common:gitOperationRepoFailure", {
repo: r.repository_name,
error: r.error || t("common:unknownError"),
}),
)
.join("; ");
if (failed.length === 0) {
return {
title: `${operationName} successful`,
description: `${operationName} succeeded in ${succeeded.length} repos: ${succeededNames}`,
title: t("common:gitOperationSucceeded", { operation: operationName }),
description: t("common:gitOperationSucceededInRepos", {
count: succeeded.length,
operation: operationName,
repos,
}),
variant: "success",
};
}
if (succeeded.length === 0) {
return {
title: `${operationName} failed`,
description: `Failed in ${failed.length} repos — ${failedSummary}`,
title: t("common:gitOperationFailed", { operation: operationName }),
description: t("common:gitOperationFailedInRepos", { count: failed.length, summary }),
variant: "error",
};
}
// Partial success: surface as error so the user notices, but include the
// succeeded list in the description so they don't retry the whole op.
//
// `gitOperationPartialDescription` carries `_one` as well as `_other` because
// i18next requires both, but `_one` is unreachable here by construction: this
// branch needs at least one success AND one failure, so `count` is never 1.
// Translators can treat the singular as a formality.
return {
title: `${operationName} partially succeeded`,
description: `${operationName} succeeded in ${succeeded.length} of ${perRepo.length} repos (${succeededNames}); failed in ${failedSummary}`,
title: t("common:gitOperationPartiallySucceeded", { operation: operationName }),
description: t("common:gitOperationPartialDescription", {
count: perRepo.length,
operation: operationName,
succeeded: succeeded.length,
repos,
summary,
}),
variant: "error",
};
}
function labelWithRepo(label: string, repo: string | undefined): string {
return repo ? `${label} (${repo})` : label;
}
export function useChangesGitHandlers(
gitOps: GitOps,
toast: Toast,
@@ -92,15 +110,18 @@ export function useChangesGitHandlers(
return;
}
const variant = result.success ? "success" : "error";
const title = result.success ? `${operationName} successful` : `${operationName} failed`;
const title = result.success
? t("common:gitOperationSucceeded", { operation: operationName })
: t("common:gitOperationFailed", { operation: operationName });
const description = result.success
? result.output.slice(0, 200) || `${operationName} completed`
: result.error || "An error occurred";
? result.output.slice(0, 200) ||
t("common:gitOperationCompleted", { operation: operationName })
: result.error || t("common:anErrorOccurred");
toast({ title, description, variant });
} catch (e) {
toast({
title: `${operationName} failed`,
description: e instanceof Error ? e.message : "An unexpected error occurred",
title: t("common:gitOperationFailed", { operation: operationName }),
description: e instanceof Error ? e.message : t("common:anUnexpectedErrorOccurred"),
variant: "error",
});
}
@@ -110,27 +131,39 @@ export function useChangesGitHandlers(
const handlePull = useCallback(
(repo?: string) => {
handleGitOperation(() => gitOps.pull(false, repo), labelWithRepo("Pull", repo));
handleGitOperation(
() => gitOps.pull(false, repo),
gitOperationLabel(t, "common:gitOpPull", repo),
);
},
[handleGitOperation, gitOps],
);
const handleRebase = useCallback(
(repo?: string) => {
const targetBranch = baseBranch?.replace(/^origin\//, "") || "main";
handleGitOperation(() => gitOps.rebase(targetBranch, repo), labelWithRepo("Rebase", repo));
handleGitOperation(
() => gitOps.rebase(targetBranch, repo),
gitOperationLabel(t, "common:gitOpRebase", repo),
);
},
[handleGitOperation, gitOps, baseBranch],
);
const handleMerge = useCallback(
(repo?: string) => {
const targetBranch = baseBranch?.replace(/^origin\//, "") || "main";
handleGitOperation(() => gitOps.merge(targetBranch, repo), labelWithRepo("Merge", repo));
handleGitOperation(
() => gitOps.merge(targetBranch, repo),
gitOperationLabel(t, "common:gitOpMerge", repo),
);
},
[handleGitOperation, gitOps, baseBranch],
);
const handlePush = useCallback(
(repo?: string) => {
handleGitOperation(() => gitOps.push(undefined, repo), labelWithRepo("Push", repo));
handleGitOperation(
() => gitOps.push(undefined, repo),
gitOperationLabel(t, "common:gitOpPush", repo),
);
},
[handleGitOperation, gitOps],
);
@@ -138,14 +171,14 @@ export function useChangesGitHandlers(
(repo?: string) => {
handleGitOperation(
() => gitOps.push({ force: true }, repo),
labelWithRepo("Force push", repo),
gitOperationLabel(t, "common:gitOpForcePush", repo),
);
},
[handleGitOperation, gitOps],
);
const handleRevertCommit = useCallback(
(sha: string, repo?: string) => {
handleGitOperation(() => gitOps.revertCommit(sha, repo), "Revert commit");
handleGitOperation(() => gitOps.revertCommit(sha, repo), t("common:gitOpRevertCommit"));
},
[handleGitOperation, gitOps],
);
@@ -192,14 +225,14 @@ function useChangesDiscardAmendHandlers(
const result = await gitOps.discard(paths, repoToDiscard);
if (!result.success)
toast({
title: "Failed to discard changes",
description: result.error || "An unknown error occurred",
title: t("task:failedToDiscardChanges"),
description: result.error || t("common:anUnknownErrorOccurred"),
variant: "error",
});
} catch (error) {
toast({
title: "Failed to discard changes",
description: error instanceof Error ? error.message : "An unknown error occurred",
title: t("task:failedToDiscardChanges"),
description: error instanceof Error ? error.message : t("common:anUnknownErrorOccurred"),
variant: "error",
});
} finally {
@@ -229,7 +262,7 @@ function useChangesDiscardAmendHandlers(
setAmendDialogOpen(false);
await handleGitOperation(
() => gitOps.commit(amendMessage.trim(), false, true, amendRepo),
"Amend commit",
t("common:gitOpAmendCommit"),
);
setAmendMessage("");
setAmendRepo(undefined);
@@ -270,7 +303,8 @@ function useChangesResetHandlers(gitOps: GitOps, handleGitOperation: GitOperatio
async (mode: "soft" | "hard") => {
if (!resetCommitSha) return;
setResetDialogOpen(false);
const operationName = mode === "hard" ? "Hard reset" : "Soft reset";
const operationName =
mode === "hard" ? t("common:gitOpHardReset") : t("common:gitOpSoftReset");
await handleGitOperation(() => gitOps.reset(resetCommitSha, mode, resetRepo), operationName);
setResetCommitSha(null);
setResetRepo(undefined);
@@ -47,8 +47,9 @@ export function RepoGroupItem({
displayName?: string;
disabled?: boolean;
}) {
const { t } = useTranslation();
const stop = (e: React.MouseEvent) => e.stopPropagation();
const label = displayName || group.repositoryName || "Repository";
const label = displayName || group.repositoryName || t("common:repository");
return (
<li data-testid="changes-repo-group" data-repository-name={group.repositoryName || ""}>
<div className="flex items-center justify-between gap-2 px-1 py-0.5">
@@ -246,8 +247,9 @@ export function CommitsRepoGroup({
// Each repo has its own "latest unpushed commit" — revert/amend in this
// group must target THIS repo's newest, not the merged-list newest.
const firstUnpushedInGroup = groupCommits.findIndex((c) => c.pushed !== true);
const { t } = useTranslation();
const stop = (e: React.MouseEvent) => e.stopPropagation();
const label = displayName || repositoryName || "Repository";
const label = displayName || repositoryName || t("common:repository");
// Bug 10 acknowledged trade-off: `existingPrUrl` is sourced from a
// workspace-scoped `prByRepo` map keyed only by "" today — the kandev task
// model has one PR per task, not one PR per repo. As a result every per-
@@ -54,7 +54,7 @@ export function PermissionRequestMessage({ comment }: PermissionRequestMessagePr
});
const statusBadge = getPermissionStatusBadge(permissionStatus);
const titleText = comment.content || "Permission Required";
const titleText = comment.content || t("task:permissionRequired");
const detailSummary = summarizePermissionAction(permissionMetadata?.action_details, titleText);
return (
@@ -134,7 +134,7 @@ function parseStatusMetadata(comment: Message) {
const progress =
typeof metadata?.progress === "number" ? Math.min(Math.max(metadata.progress, 0), 100) : null;
const statusLine = metadata?.stage || metadata?.status;
const message = metadata?.message || comment.content || statusLine || "Status update";
const message = metadata?.message || comment.content || statusLine || t("task:statusUpdate");
const isError = comment.type === "error" || metadata?.variant === "error";
const isWarning = metadata?.variant === "warning" || metadata?.cancelled === true;
return { metadata, progress, statusLine, message, isError, isWarning };
@@ -20,6 +20,7 @@ import { PermissionActionRow } from "./permission-action-row";
import { ExpandableRow } from "./expandable-row";
import { useExpandState } from "./use-expand-state";
import { parsePermission, usePermissionResponseHandlers } from "./use-permission-handlers";
import { t } from "@/lib/i18n";
const TOOL_ICON_RULES: Array<{ keywords: string[]; Icon: typeof IconCode }> = [
{ keywords: ["edit", "replace", "write", "save"], Icon: IconEdit },
@@ -191,7 +192,7 @@ export const ToolCallMessage = memo(function ToolCallMessage({
const { isExpanded, handleToggle } = useExpandState(status, autoExpanded);
const metadata = comment.metadata as ToolCallMetadata | undefined;
const rawTitle = metadata?.title ?? comment.content ?? "Tool call";
const rawTitle = metadata?.title ?? comment.content ?? t("task:toolCall");
const title = transformPathsInText(prettifyToolTitle(rawTitle), worktreePath);
const formattedOutput = hasOutput && !inlineOutput ? formatToolOutput(output) : null;
@@ -277,13 +277,14 @@ export const ToolSubagentMessage = memo(function ToolSubagentMessage({
onOpenFile,
renderChild,
}: ToolSubagentMessageProps) {
const { t } = useTranslation();
const metadata = comment.metadata as ToolCallMetadata | undefined;
const childCount = childMessages.length;
const { subagentTask, isActive, resultText, prompt, hasExpandableContent } =
deriveSubagentDisplay(metadata, childCount, isContainingTurnActive);
const resultSummary = firstResultLine(resultText);
const description = subagentTask?.description || comment.content || "Subagent";
const subagentType = subagentTask?.subagent_type || "Task";
const description = subagentTask?.description || comment.content || t("task:subagent");
const subagentType = subagentTask?.subagent_type || t("common:task");
// Track manual override state - null means "use auto behavior"
const [manualExpandState, setManualExpandState] = useState<boolean | null>(null);
@@ -89,7 +89,7 @@ function HitAuthor({
</span>
);
}
const label = agentLabel?.trim() || "Agent";
const label = agentLabel?.trim() || t("common:agent");
return (
<span className="text-[0.6875rem] uppercase tracking-wide font-medium text-muted-foreground inline-flex items-center gap-1.5 min-w-0">
{agentName && <AgentLogo agentName={agentName} size={12} className="shrink-0" />}
@@ -1,5 +1,6 @@
import type { MentionItem } from "@/hooks/use-inline-mention";
import type { AppState } from "@/lib/state/store";
import { t } from "@/lib/i18n";
type TaskLike = AppState["kanban"]["tasks"][number];
@@ -57,8 +58,8 @@ export function buildTaskMentionItems(
const addTask = (task: TaskLike, workflowId: string) => {
if (task.id === currentTaskId || seen.has(task.id)) return;
seen.add(task.id);
const workflowName = workflowNameById.get(workflowId) ?? "Workflow";
const stepTitle = stepTitleById.get(task.workflowStepId) ?? "Step";
const workflowName = workflowNameById.get(workflowId) ?? t("common:workflow");
const stepTitle = stepTitleById.get(task.workflowStepId) ?? t("common:step");
items.push(toMentionItem(task, workflowId, workflowName, stepTitle));
};
@@ -3,6 +3,7 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { NodeViewWrapper, ReactNodeViewRenderer, type ReactNodeViewProps } from "@tiptap/react";
import { IconHash } from "@tabler/icons-react";
import { t } from "@/lib/i18n";
export const EntityReferenceNode = Node.create({
name: "entityReference",
@@ -54,7 +55,7 @@ function EntityReferenceChipView({ node }: ReactNodeViewProps) {
kind?: string | null;
id?: string | null;
};
const label = key || title || id || "Reference";
const label = key || title || id || t("common:reference");
const source = [provider, kind].filter(Boolean).join(" ");
const description = source ? `${source}: ${title || label}` : title || label;
return (
@@ -156,7 +156,7 @@ export function LeftHeaderActions(props: IDockviewHeaderActionsProps) {
if (!environmentId) return;
try {
const result = await createUserShell(environmentId, { scriptId });
const title = result.label ?? "Script";
const title = result.label ?? t("common:script");
addTerminalPanel(result.terminalId, group.id, environmentId, undefined, title);
} catch (error) {
console.error("Failed to run script:", error);
@@ -491,7 +491,7 @@ function TerminalScriptsDropdown({
rightBottomGroupId ?? undefined,
environmentId,
undefined,
result.label ?? "Script",
result.label ?? t("common:script"),
);
} catch (error) {
console.error("Failed to run script:", error);
@@ -574,7 +574,7 @@ function TerminalDevPreviewButton({
}
try {
const shell = await createUserShell(environmentId, { taskId: taskID ?? undefined });
const title = shell.displayName ?? shell.label ?? "Terminal";
const title = shell.displayName ?? shell.label ?? t("common:terminal");
addTerminalPanel(
shell.terminalId,
rightBottomGroupId ?? undefined,
@@ -155,7 +155,7 @@ function useWatermarkHandlers(
groupId,
environmentId,
undefined,
result.label ?? "Script",
result.label ?? t("common:script"),
);
} catch (error) {
console.error("Failed to run script:", error);
+17 -7
View File
@@ -19,6 +19,7 @@ import { useOpenSessionFolder } from "@/hooks/use-open-session-folder";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
import { useToast } from "@/components/toast-provider";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useMultiSelect } from "@/hooks/use-multi-select";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useContextFilesStore } from "@/lib/state/context-files-store";
@@ -156,7 +157,11 @@ type MoveFilesParams = {
onRenameFile: (oldPath: string, newPath: string) => Promise<boolean>;
};
function executeMoveFiles(params: MoveFilesParams, toast: ReturnType<typeof useToast>["toast"]) {
function executeMoveFiles(
params: MoveFilesParams,
toast: ReturnType<typeof useToast>["toast"],
t: TFunction,
) {
const { sources, targetPath, treeState, setSelectedPaths, onRenameFile } = params;
const snapshot = treeState.tree;
@@ -172,8 +177,8 @@ function executeMoveFiles(params: MoveFilesParams, toast: ReturnType<typeof useT
if (results.some((ok) => !ok)) {
treeState.setTree(snapshot);
toast({
title: "Move failed",
description: "Some files could not be moved",
title: t("task:moveFailed"),
description: t("task:moveFailedFiles"),
variant: "error",
});
}
@@ -181,8 +186,8 @@ function executeMoveFiles(params: MoveFilesParams, toast: ReturnType<typeof useT
.catch(() => {
treeState.setTree(snapshot);
toast({
title: "Move failed",
description: "An error occurred while moving files",
title: t("task:moveFailed"),
description: t("task:moveFailedUnexpected"),
variant: "error",
});
});
@@ -194,6 +199,7 @@ function useDragAndDrop(
setSelectedPaths: (paths: Set<string>) => void,
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>,
) {
const { t } = useTranslation();
const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false);
const [dragOverPath, setDragOverPath] = useState<string | null>(null);
@@ -257,9 +263,13 @@ function useDragAndDrop(
if (!onRenameFile) return;
const sources = dragPathsRef.current;
if (sources.length === 0 || isDropInvalid(sources, targetPath)) return;
executeMoveFiles({ sources, targetPath, treeState, setSelectedPaths, onRenameFile }, toast);
executeMoveFiles(
{ sources, targetPath, treeState, setSelectedPaths, onRenameFile },
toast,
t,
);
},
[onRenameFile, treeState, setSelectedPaths, toast],
[onRenameFile, treeState, setSelectedPaths, t, toast],
);
return {
+1 -1
View File
@@ -170,7 +170,7 @@ export const ModeSelector = memo(function ModeSelector({
}
const currentMode = modeState.availableModes.find((m) => m.id === modeState.currentModeId);
const displayName = currentMode?.name || modeState.currentModeId || "Mode";
const displayName = currentMode?.name || modeState.currentModeId || t("common:mode");
return (
<DropdownMenu open={dropdownOpen} onOpenChange={handleDropdownOpenChange}>
@@ -49,6 +49,7 @@ function agentProfileDisplayLabel(profile: AgentProfileOption): string {
}
function useNewSessionDialogState(taskId: string) {
const { t } = useTranslation();
const resolvedWorkspaceId = useAppStore((state) =>
resolveComposerWorkspaceId({
sessionId: null,
@@ -61,8 +62,8 @@ function useNewSessionDialogState(taskId: string) {
}),
);
const taskTitle = useAppStore((state) => {
const task = state.kanban.tasks.find((t: { id: string }) => t.id === taskId);
return task?.title ?? "Task";
const task = state.kanban.tasks.find((entry: { id: string }) => entry.id === taskId);
return task?.title ?? t("common:task");
});
const agentProfiles = useAppStore((state) => state.agentProfiles.items);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
@@ -10,6 +10,7 @@ import {
type MutableRefObject,
} from "react";
import { useTranslation } from "react-i18next";
import { searchKeywords } from "@/lib/commands/search-keywords";
import { useRouter } from "@/lib/routing/client-router";
import { useAppStore } from "@/components/state-provider";
import { useCommandPanelOpen } from "@/lib/commands/command-registry";
@@ -419,25 +420,16 @@ 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.
// in two under a non-English locale.
const { t } = useTranslation();
const commands = useMemo(
() => [
{
id: "open-recent-task-switcher",
label: "Open Recent Task Switcher",
label: t("common:commandOpenRecentTaskSwitcher"),
group: t("common:commandGroupNavigation"),
shortcut,
keywords: [
"recent",
"task",
"switcher",
"history",
"recent tasks",
"switch task",
"switch recent tasks",
],
keywords: searchKeywords(t, "common:commandOpenRecentTaskSwitcherKeywords"),
action: openSwitcher,
},
],
@@ -13,6 +13,7 @@ import { SidebarViewChips } from "./sidebar-view-chips";
import { SidebarFilterPopover } from "./sidebar-filter-popover";
import { useSidebarViewPopover } from "./use-sidebar-view-popover";
import { useTranslation } from "react-i18next";
import { sidebarViewName } from "@/lib/state/slices/ui/sidebar-view-builtins";
// `group` is the command-palette bucket shared with `components/session-commands.tsx`;
// it is grouping taxonomy rather than this surface's copy, so it migrates with
@@ -36,9 +37,9 @@ function useSidebarCommands(
for (const view of views) {
list.push({
id: `sidebar-switch-view-${view.id}`,
label: t("task:switchSidebarView", { name: view.name }),
label: t("task:switchSidebarView", { name: sidebarViewName(view, t) }),
group: "Sidebar",
keywords: ["view", "switch", "sidebar", view.name.toLowerCase()],
keywords: ["view", "switch", "sidebar", sidebarViewName(view, t).toLowerCase()],
action: () => setActiveView(view.id),
});
}
@@ -15,6 +15,8 @@ import { CSS } from "@dnd-kit/utilities";
import { useAppStore } from "@/components/state-provider";
import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types";
import { cn } from "@/lib/utils";
import { sidebarViewName } from "@/lib/state/slices/ui/sidebar-view-builtins";
import { useTranslation } from "react-i18next";
const DRAG_ACTIVATION_DISTANCE = 8;
const TOUCH_DRAG_DELAY_MS = 250;
@@ -80,6 +82,7 @@ function SidebarViewChip({
active: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: view.id,
});
@@ -150,10 +153,10 @@ function SidebarViewChip({
: "border-transparent text-muted-foreground hover:text-foreground",
isDragging && "z-50 cursor-grabbing",
)}
title={view.name}
title={sidebarViewName(view, t)}
onClick={handleClick}
>
<span className="block max-w-[120px] truncate">{view.name}</span>
<span className="block max-w-[120px] truncate">{sidebarViewName(view, t)}</span>
</button>
);
}
@@ -5,6 +5,7 @@ import { Input } from "@kandev/ui/input";
import { Button } from "@kandev/ui/button";
import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types";
import { useTranslation } from "react-i18next";
import { sidebarViewName } from "@/lib/state/slices/ui/sidebar-view-builtins";
type HeaderMode = "view" | "rename" | "saveAs";
@@ -115,7 +116,7 @@ function NameDisplay({
return (
<>
<span className="font-medium" data-testid="sidebar-filter-active-view-name">
{activeView?.name ?? "—"}
{activeView ? sidebarViewName(activeView, t) : "—"}
</span>
{hasDraft && (
<span
@@ -12,6 +12,7 @@ import {
import { calculateHash, generateUnifiedDiff } from "@/lib/utils/file-diff";
import { requestFileContent, updateFileContent, deleteFile } from "@/lib/ws/workspace-files";
import { useToast } from "@/components/toast-provider";
import { t } from "@/lib/i18n";
import { getFileTabKey } from "./task-center-panel-file-tabs";
import { lspClientManager } from "@/lib/lsp/lsp-client-manager";
@@ -235,16 +236,15 @@ export function useFileSaveDelete({
);
} else {
toast({
title: "Save failed",
description: response.error || "Failed to save file",
title: t("editors:saveFailed"),
description: response.error || t("editors:failedToSaveFile"),
variant: "error",
});
}
} catch (error) {
toast({
title: "Save failed",
description:
error instanceof Error ? error.message : "An error occurred while saving the file",
title: t("editors:saveFailed"),
description: error instanceof Error ? error.message : t("editors:errorWhileSavingFile"),
variant: "error",
});
} finally {
@@ -268,16 +268,15 @@ export function useFileSaveDelete({
handleCloseFileTab(getFileTabKey({ path, repo }));
} else {
toast({
title: "Delete failed",
description: response.error || "Failed to delete file",
title: t("editors:deleteFailed"),
description: response.error || t("editors:failedToDeleteFile"),
variant: "error",
});
}
} catch (error) {
toast({
title: "Delete failed",
description:
error instanceof Error ? error.message : "An error occurred while deleting the file",
title: t("editors:deleteFailed"),
description: error instanceof Error ? error.message : t("editors:errorWhileDeletingFile"),
variant: "error",
});
}
@@ -21,6 +21,7 @@ import { useAppStore } from "@/components/state-provider";
import { PlanRevisionPreviewDialog } from "./task-plan-preview-dialog";
import { PlanRevisionDiffDialog } from "./task-plan-diff-dialog";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
type ComparePair = [string | null, string | null];
@@ -46,7 +47,8 @@ type TaskPlanRevisionsProps = {
/** Label rendered in the UI for any user-authored revision. Display-only —
* the backend still stamps its own author_name on the row. */
const USER_DISPLAY_NAME = "You";
/** Catalog key, not copy — resolving at module scope freezes the locale. */
const USER_DISPLAY_NAME_KEY = "common:you";
export function TaskPlanRevisions(props: TaskPlanRevisionsProps) {
const [open, setOpen] = useState(false);
@@ -267,7 +269,7 @@ function RevisionsDialogStack({
// Remount on revision change so loader state resets without setState-in-effect.
key={`preview-${previewRevision?.id ?? "none"}`}
revision={previewRevision}
authorLabel={previewRevision ? authorLabel(previewRevision) : ""}
authorLabel={previewRevision ? authorLabel(previewRevision, t) : ""}
loadContent={loadRevisionContent}
onClose={() => setPreviewRevision(null)}
onRestore={() => {
@@ -307,9 +309,9 @@ function RevisionsDialogStack({
/** Display label for an author. User revisions always render as "You";
* agent revisions use the stored author_name (the agent profile display name). */
function authorLabel(revision: TaskPlanRevision): string {
if (revision.author_kind === "user") return USER_DISPLAY_NAME;
return revision.author_name || "Agent";
function authorLabel(revision: TaskPlanRevision, t: TFunction): string {
if (revision.author_kind === "user") return t(USER_DISPLAY_NAME_KEY);
return revision.author_name || t("common:agent");
}
/** Resolve the active session's agent backend name (e.g. "claude", "codex") so
@@ -370,6 +372,7 @@ function RevisionAuthor({
revision: TaskPlanRevision;
agentName: string | null;
}) {
const { t } = useTranslation();
if (revision.author_kind === "agent") {
return (
<>
@@ -384,7 +387,7 @@ function RevisionAuthor({
<>
<IconUser className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-foreground truncate" data-testid="plan-revision-author">
{USER_DISPLAY_NAME}
{t(USER_DISPLAY_NAME_KEY)}
</span>
</>
);
@@ -1,12 +1,13 @@
import type { TaskSwitcherItem } from "./task-switcher";
import type { useArchivedTaskState } from "./task-archived-context";
import { t } from "@/lib/i18n";
export function buildArchivedSidebarItem(
s: ReturnType<typeof useArchivedTaskState>,
): TaskSwitcherItem {
return {
id: s.archivedTaskId!,
title: s.archivedTaskTitle ?? "Archived task",
title: s.archivedTaskTitle ?? t("task:archivedTask"),
state: undefined,
sessionState: undefined,
description: undefined,
+2 -1
View File
@@ -138,13 +138,14 @@ function useTerminalTabClose({
}
export function TerminalTab(props: IDockviewPanelHeaderProps) {
const { t } = useTranslation();
const { terminalId, taskID: stampedTaskID, environmentId: stampedEnv } = extractParams(props);
const activeTaskID = useAppStore((s) => s.tasks?.activeTaskId ?? null);
const taskID = stampedTaskID ?? activeTaskID ?? null;
const { shell, isOrdinary, seq, showBadge, displayName, closable } = useTerminalTabState(
stampedEnv,
terminalId,
props.api.title ?? "Terminal",
props.api.title ?? t("common:terminal"),
);
// DockviewDefaultTab reads the title from `api.title` directly and
@@ -3,6 +3,7 @@
import { useState, useCallback } from "react";
import { startTunnel, stopTunnel } from "@/lib/api/domains/port-api";
import { toast } from "@/lib/toast/sonner";
import { t } from "@/lib/i18n";
export function useTunnelActions(
sessionId: string,
@@ -16,10 +17,12 @@ export function useTunnelActions(
try {
const tunnelPort = await startTunnel(sessionId, port, requestedPort);
setActiveTunnels((prev) => new Map(prev).set(port, tunnelPort));
toast.success(`Tunnel started on port ${tunnelPort}`);
toast.success(t("task:tunnelStartedOnPort", { port: tunnelPort }));
} catch (err) {
toast.error(
`Failed to start tunnel: ${err instanceof Error ? err.message : "unknown error"}`,
t("task:failedToStartTunnel", {
error: err instanceof Error ? err.message : t("task:unknownError"),
}),
);
} finally {
setPendingTunnels((prev) => {
@@ -42,10 +45,12 @@ export function useTunnelActions(
next.delete(port);
return next;
});
toast.success("Tunnel stopped");
toast.success(t("task:tunnelStopped"));
} catch (err) {
toast.error(
`Failed to stop tunnel: ${err instanceof Error ? err.message : "unknown error"}`,
t("task:failedToStopTunnel", {
error: err instanceof Error ? err.message : t("task:unknownError"),
}),
);
} finally {
setPendingTunnels((prev) => {
+17 -19
View File
@@ -28,7 +28,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@kandev/ui/lib/utils";
import { useSessionGit } from "@/hooks/domains/session/use-session-git";
import { useGitWithFeedback } from "@/hooks/use-git-with-feedback";
import { gitOperationLabel, useGitWithFeedback } from "@/hooks/use-git-with-feedback";
import { useVcsDialogs } from "@/components/vcs/vcs-dialogs";
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name";
@@ -310,47 +310,45 @@ type VcsSplitButtonProps = {
className?: string;
};
// The `label` values below stay English on purpose. They are operation *names*
// consumed by `useGitWithFeedback`, which concatenates them into its own English
// toast titles (`${operationName} failed`). `hooks/use-git-with-feedback.ts` is
// not migrated yet, so translating only this half would ship mixed-language
// toasts. They move when that hook's messages do.
function useGitActions(git: ReturnType<typeof useSessionGit>, baseBranch?: string) {
const { t } = useTranslation();
const gitWithFeedback = useGitWithFeedback();
const handlePull = useCallback(
(repo?: string) => {
const label = repo ? `Pull (${repo})` : "Pull";
gitWithFeedback(() => git.pull(false, repo), label);
gitWithFeedback(() => git.pull(false, repo), gitOperationLabel(t, "common:gitOpPull", repo));
},
[gitWithFeedback, git],
[gitWithFeedback, git, t],
);
const handlePush = useCallback(
(force = false, repo?: string) => {
const baseLabel = force ? "Force Push" : "Push";
const label = repo ? `${baseLabel} (${repo})` : baseLabel;
gitWithFeedback(() => git.push({ force }, repo), label);
const operationKey = force ? "common:gitOpForcePush" : "common:gitOpPush";
gitWithFeedback(() => git.push({ force }, repo), gitOperationLabel(t, operationKey, repo));
},
[gitWithFeedback, git],
[gitWithFeedback, git, t],
);
const handleRebase = useCallback(
(repo?: string) => {
const targetBranch = baseBranch?.replace(/^origin\//, "") || "main";
const label = repo ? `Rebase (${repo})` : "Rebase";
gitWithFeedback(() => git.rebase(targetBranch, repo), label);
gitWithFeedback(
() => git.rebase(targetBranch, repo),
gitOperationLabel(t, "common:gitOpRebase", repo),
);
},
[gitWithFeedback, git, baseBranch],
[gitWithFeedback, git, baseBranch, t],
);
const handleMerge = useCallback(
(repo?: string) => {
const targetBranch = baseBranch?.replace(/^origin\//, "") || "main";
const label = repo ? `Merge (${repo})` : "Merge";
gitWithFeedback(() => git.merge(targetBranch, repo), label);
gitWithFeedback(
() => git.merge(targetBranch, repo),
gitOperationLabel(t, "common:gitOpMerge", repo),
);
},
[gitWithFeedback, git, baseBranch],
[gitWithFeedback, git, baseBranch, t],
);
return { handlePull, handlePush, handleRebase, handleMerge };
+4 -7
View File
@@ -29,7 +29,7 @@ import {
useGitOperations,
} from "@/hooks/use-git-operations";
import { useAppStore } from "@/components/state-provider";
import { useGitWithFeedback } from "@/hooks/use-git-with-feedback";
import { gitOperationLabel, useGitWithFeedback } from "@/hooks/use-git-with-feedback";
import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator";
import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured";
import { useToast } from "@/components/toast-provider";
@@ -451,6 +451,7 @@ function useVcsDialogsState(
taskTitle: string | undefined,
baseBranch: string | undefined,
) {
const { t } = useTranslation();
const cs = useCommitDialogState();
const ps = usePRDialogState();
const { toast } = useToast();
@@ -477,16 +478,12 @@ function useVcsDialogsState(
const title = cs.message.trim();
const body = cs.body.trim();
const fullMessage = body ? `${title}\n\n${body}` : title;
// Deliberately English: `useGitWithFeedback` composes this into
// "<label>...", "<label> successful" and "<label> failed" itself, and that
// hook is not migrated. Translating only the label would produce a
// half-translated toast.
const label = cs.repo ? `Commit (${cs.repo})` : "Commit";
const label = gitOperationLabel(t, "common:gitOpCommit", cs.repo);
await gitWithFeedback(() => commit(fullMessage, cs.stageAll, false, cs.repo), label);
cs.setMessage("");
cs.setBody("");
cs.setRepo(undefined);
}, [cs, gitWithFeedback, commit]);
}, [cs, gitWithFeedback, commit, t]);
const handleCreatePR = useCreatePRHandler(
ps,
baseBranch,
+29 -28
View File
@@ -195,34 +195,18 @@ const SCREENS: Screen[] = [
// run between `cc6eb4dd5` and this commit. Re-check these against
// `BUILT_IN_LAYOUT_PROFILES` whenever a built-in name or description moves.
//
// Both groups are display strings that are also PERSISTED, so translating
// them in place would write locale-dependent values into a user's saved
// layouts and leave them there after a locale switch:
// - Built-in profile names/descriptions (lib/layout/layout-profiles.ts).
// `upsertBuiltInLayoutOverride` copies `builtIn.name` into the saved
// record the first time a built-in is customized.
// - Dockview panel titles (lib/state/layout-manager/constants.ts), which
// `toSerializedDockview` writes into the stored layout JSON. That path
// is already in `EXCLUDED` in scripts/externalize-strings.mjs.
// Localizing either needs a key/persisted-value split in those modules.
allow: [
"Default",
"Plan Mode",
"Preview Mode",
"VS Code",
"Agent with Files, Changes, and Terminal",
"Agent and Plan side by side",
"Agent and Browser side by side",
"Agent and VS Code side by side",
"Agent",
"Plan",
"Changes",
"Files",
"Browser",
"Terminal",
"PR Details",
"Merge Request",
],
// Both groups WERE display strings that are also persisted, and both have
// had the key/persisted-value split this note used to ask for:
// - Built-in profile names/descriptions (lib/layout/layout-profiles.ts)
// carry `nameKey`/`descriptionKey`; `name` stays canonical English
// because `upsertBuiltInLayoutOverride` copies it into the saved record.
// - Dockview panel titles (lib/state/layout-manager/constants.ts) carry
// `titleKey`; `toSerializedDockview` writes the canonical English
// `title` into the stored layout JSON, and `normalizePanel` re-resolves
// the display title on restore. `panel-titles.test.ts` covers both
// directions.
// Only the two product names are still expected to render as English.
allow: ["VS Code", "Agent and VS Code side by side"],
},
{
name: "settings — keyboard shortcuts",
@@ -298,6 +282,23 @@ const SCREENS: Screen[] = [
// `test-base` state and belongs with whoever owns that fixture — not an
// allowlist entry and not a renamed screen, either of which would keep the
// false claim alive in a new form.
// NOT YET: "kanban board" (`/`, anchor `[data-testid=kanban-board]`). Probed
// under pseudo against this fixture. The board's own chrome is clean, and the
// one real finding it surfaced — the built-in sidebar view's "All tasks",
// which `sidebar-view-builtins.ts` persists as a user-editable name — is fixed
// and now resolves through `sidebar:viewAllTasks`.
//
// What is left is entirely the fixture's own records: the workflow name
// ("E2E Workflow") and its four step names ("Backlog", "In Progress",
// "Review", "Done"). Those are user data on exactly the footing as the
// workspace and executor-profile names described above, so this entry needs
// the same change — `findUnlocalizedText` learning to skip user data — and not
// an `allow` list of fixture values, which would fix this fixture and leave
// every developer instance red under different workflow names.
//
// `/tasks` was probed with it and cannot be added for a different reason:
// `[data-testid=tasks-list]` never renders in this fixture, so there is no
// anchor that proves the route mounted. It needs a seeded task list first.
{
name: "office — inbox",
url: "/office/inbox",
+90
View File
@@ -1824,4 +1824,94 @@ export const i18nGuardFiles = [
// chips, routing cards and activity rows all resolve through, so the same
// vocabulary cannot drift apart across surfaces.
"app/office/**/*.{ts,tsx}",
// The command palette's two un-migrated producers. `global-commands.tsx` was
// already listed and already resolved everything through the catalog, so ⌘K
// rendered a translated Navigation group above English Git/Panels rows; these
// two are what made the same list half-English. Their labels are `label:`
// object properties rather than JSX, which is why the guard reported them
// clean the whole time — see the note on `mode: "jsx-only"` above.
"components/homepage-commands.tsx",
"components/session-commands.tsx",
// `use-git-with-feedback.ts` composed its own English around the operation
// name ("Push failed"), which is why `vcs-split-button.tsx`, `vcs-dialogs.tsx`
// and the palette all passed it a deliberately English label. It interpolates
// catalog messages now, so those three no longer have to.
"hooks/use-git-with-feedback.ts",
// Toast and feedback copy. None of it is JSX — it is `title:`/`description:`
// object properties and `toast.error("…")` arguments — so `mode: "jsx-only"`
// never inspected any of these files and they read as clean for the whole
// migration. Listing them records that they are done; only the pseudo-locale
// can prove they stay that way.
//
// `changes-panel-hooks.ts` carried the multi-repo half of the same
// concatenated-English problem as `use-git-with-feedback.ts`
// (`${operationName} partially succeeded`), including two per-repo counts that
// now use `count` with `_one`/`_other` instead of a bare "repos".
//
// `unarchive-feedback.ts` inflected its own sentence
// (`${plural ? "Branches" : "Branch"} … no longer ${plural ? "exist" : "exists"}`),
// which is the inline-plural shape docs/i18n.md rules out; the plural rule
// lives in the catalog now. `check-inline-plurals.mjs` does not see this
// form — it is two independent ternaries, not a `+ "s"`.
"app/tasks/tasks-page-client.tsx",
"components/azure-devops/azure-devops-task-launcher.tsx",
"components/github/use-pr-scoped-review-request.ts",
"components/task/changes-panel-hooks.ts",
"components/task/task-center-panel-restoration.ts",
"components/task/use-tunnel-actions.ts",
"hooks/domains/comments/use-markdown-preview-comments.ts",
"hooks/domains/session/use-task-environment.ts",
"hooks/use-file-save-delete.ts",
"hooks/use-utility-agent-generator.ts",
"lib/tasks/unarchive-feedback.ts",
// Dockview panel titles. These are display copy that is ALSO persisted —
// `toSerializedDockview` writes the title into the stored layout JSON — so
// they now carry a `titleKey` beside a canonical English `title`, the split
// the note on the layouts screen in `e2e/tests/i18n/pseudo-coverage.spec.ts`
// said they needed. `constants.ts` deliberately still holds English `title:`
// values; they are storage, not copy, and `panel-titles.test.ts` asserts the
// two stay apart.
"lib/state/dockview-panel-actions.ts",
"lib/state/layout-manager/constants.ts",
"lib/state/layout-manager/serializer.ts",
// Built-in layout profile names and descriptions, the other half of the same
// problem: `upsertBuiltInLayoutOverride` copies `name` into a saved override,
// so `name`/`description` stay canonical English and the settings list renders
// `nameKey`/`descriptionKey`.
"lib/layout/layout-profiles.ts",
// English `??` / `||` fallbacks — "Agent", "Terminal", "Repository",
// "Untitled task", "An error occurred". They render whenever the real value
// is absent, which is a normal path rather than an edge case, and none of
// them is a JSX literal, so the guard never saw them. Values that are
// PERSISTED or sent to an agent verbatim ("New Repository", the quick-chat
// session name, `use-plan-actions`' default prompt) are deliberately still
// English and stay off this list.
"components/review/review-diff-list-groups.tsx",
"components/review/review-dialog.tsx",
"components/task/changes-git-credential-display.ts",
"components/task/task-session-sidebar-archived-item.ts",
"hooks/domains/session/use-session-resumption.ts",
"hooks/domains/session/use-terminals.ts",
"hooks/domains/session/use-user-shells.ts",
"hooks/use-editor-keybinds.ts",
"hooks/use-summarize-session.ts",
"hooks/use-update-available-toast.ts",
"lib/agent-runtime-update.ts",
"lib/api/domains/plan-api.ts",
"lib/capability-warning.ts",
"lib/github-auth.ts",
"lib/recent-tasks.ts",
"lib/state/slices/comments/format.ts",
"lib/utils/file-diff.ts",
// The built-in sidebar view's name, the third persisted-and-displayed string
// in this change. `SidebarView.name` is user-editable and synced, so the
// built-in keeps a canonical English `name` and every surface resolves
// `sidebarViewName()` instead. Found by the pseudo oracle, not by lint: it is
// a `.ts` constant, which `mode: "jsx-only"` never inspects.
"lib/state/slices/ui/sidebar-view-builtins.ts",
// The last English row in the palette. Its `group` was already translated —
// it has to be, since the palette groups by the resolved value — and a
// comment deferred the `label`/`keywords` to "the components/task migration".
// Both are palette copy and belong with the rest of it.
"components/task/recent-task-switcher-hooks.ts",
];
@@ -9,6 +9,7 @@ import {
type RefObject,
type SetStateAction,
} from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "@/components/toast-provider";
import { useCommentsStore, type DiffComment } from "@/lib/state/slices/comments";
import {
@@ -169,6 +170,7 @@ function useMarkdownCommentSubmitters({
textSelection: MarkdownPreviewSelection | null;
clearTextSelection: () => void;
}) {
const { t } = useTranslation();
const addComment = useCommentsStore((s) => s.addComment);
const { runComment } = useRunComment({ sessionId: sessionId ?? null, taskId: taskId ?? null });
const { toast } = useToast();
@@ -193,24 +195,27 @@ function useMarkdownCommentSubmitters({
[addComment, clearTextSelection, path, repositoryId, sessionId, textSelection],
);
// Same `editors:` copy as the CodeMirror and Monaco comment flows in
// `components/editors/**`, which say exactly this. The markdown preview is
// the third surface of the same feature and was the one left in English.
const submitComment = useCallback(
(text: string) => {
const comment = createComment(text);
if (!comment) return;
toast({
title: "Comment added",
description: "Your comment will be sent with your next message.",
title: t("editors:commentAdded"),
description: t("editors:commentWillBeSentWithNextMessage"),
});
},
[createComment, toast],
[createComment, t, toast],
);
const submitAndRunComment = useCallback(
async (text: string) => {
if (!canRunComment) {
toast({
title: "Failed to send comment",
description: "Open a task session before sending to the agent.",
title: t("editors:failedToSendComment"),
description: t("editors:openTaskSessionBeforeSending"),
variant: "error",
});
return;
@@ -220,18 +225,18 @@ function useMarkdownCommentSubmitters({
try {
const { queued } = await runComment(comment);
toast({
title: "Comment sent",
description: queued ? "Queued for the agent." : "Sent to the agent.",
title: t("editors:commentSent"),
description: queued ? t("editors:queuedForTheAgent") : t("editors:sentToTheAgent"),
});
} catch {
toast({
title: "Failed to send comment",
description: "Please try again.",
title: t("editors:failedToSendComment"),
description: t("editors:pleaseTryAgain"),
variant: "error",
});
}
},
[canRunComment, createComment, runComment, toast],
[canRunComment, createComment, runComment, t, toast],
);
return { submitComment, submitAndRunComment: canRunComment ? submitAndRunComment : undefined };
@@ -278,6 +283,7 @@ function useVisibleMarkdownCommentActions({
}: {
setCommentView: Dispatch<SetStateAction<MarkdownCommentView>>;
}) {
const { t } = useTranslation();
const removeComment = useCommentsStore((s) => s.removeComment);
const updateComment = useCommentsStore((s) => s.updateComment);
const { toast } = useToast();
@@ -289,9 +295,9 @@ function useVisibleMarkdownCommentActions({
const nextComments = view.comments.filter((comment) => comment.id !== commentId);
return nextComments.length > 0 ? { ...view, comments: nextComments } : null;
});
toast({ title: "Comment deleted" });
toast({ title: t("editors:commentDeleted") });
},
[removeComment, setCommentView, toast],
[removeComment, setCommentView, t, toast],
);
const updateVisibleComment = useCallback(
(commentId: string, text: string) => {
@@ -305,9 +311,9 @@ function useVisibleMarkdownCommentActions({
),
};
});
toast({ title: "Comment updated" });
toast({ title: t("editors:commentUpdated") });
},
[setCommentView, toast, updateComment],
[setCommentView, t, toast, updateComment],
);
return { removeVisibleComment, updateVisibleComment };
@@ -13,6 +13,7 @@ import {
type TaskId,
type TaskSessionState,
} from "@/lib/types/http";
import { t } from "@/lib/i18n";
export type SessionStatus = {
session_id: string;
@@ -95,7 +96,7 @@ function applyResumeResponse(
return true;
}
setters.setResumptionState("error");
setters.setError(resp.error ?? "Failed to resume session");
setters.setError(resp.error ?? t("task:failedToResumeSession"));
return false;
}
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "@/lib/toast/sonner";
import { t } from "@/lib/i18n";
import {
fetchTaskEnvironmentLive,
@@ -101,15 +102,15 @@ export function useTaskEnvironment(taskId: string | null | undefined, active: bo
setIsResetting(true);
try {
await resetTaskEnvironment(taskId, { push_branch: pushBranch });
toast.success("Environment reset");
toast.success(t("task:environmentReset"));
lastStatusRef.current = getEnvironmentStatusSnapshot(null, null);
setEnv(null);
setContainer(null);
setSsh(null);
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
toast.error(`Reset failed: ${msg}`);
const msg = err instanceof Error ? err.message : t("task:unknownError");
toast.error(t("task:environmentResetFailed", { error: msg }));
return false;
} finally {
setIsResetting(false);
@@ -132,10 +133,10 @@ function maybeNotifyEnvironmentStatus(
) {
if (!prev || prev.key === next.key) return;
if (prev.tone === "running" && next.tone !== "running") {
toast.error("Executor environment stopped", {
description: `Current state: ${next.label}`,
toast.error(t("task:executorEnvironmentStopped"), {
description: t("task:environmentStateCurrent", { state: next.label }),
});
} else if (prev.tone !== "running" && next.tone === "running") {
toast.success("Executor environment running");
toast.success(t("task:executorEnvironmentRunning"));
}
}
@@ -23,6 +23,7 @@ import type { UserShellInfo } from "@/lib/state/slices/session-runtime/types";
import type { RepositoryScript } from "@/lib/types/http";
import type { Dispatch, SetStateAction, MouseEvent } from "react";
import type { PreviewStage } from "@/lib/state/slices";
import { t } from "@/lib/i18n";
export type { Terminal, TerminalType };
@@ -168,7 +169,7 @@ function useAddTerminal({
const newTerm: Terminal = {
id: result.terminalId,
type: "shell",
label: result.displayName ?? result.label ?? "Terminal",
label: result.displayName ?? result.label ?? t("common:terminal"),
closable: true,
kind: ordinary ? "ordinary" : result.kind,
seq: result.seq,
@@ -440,7 +441,7 @@ function useTerminalActions({
const newTerm: Terminal = {
id: result.terminalId,
type: "script",
label: result.label ?? script.name ?? "Script",
label: result.label ?? script.name ?? t("common:script"),
closable: true,
kind: "script",
};
@@ -7,6 +7,7 @@ import type {
UserShellState,
UserShellPTYStatus,
} from "@/lib/state/slices";
import { t } from "@/lib/i18n";
interface UseUserShellsReturn {
shells: UserShellInfo[];
@@ -179,7 +180,7 @@ function mapListItemToShell(s: ListResponseItem): UserShellInfo {
ptyStatus: s.pty_status,
processId: s.process_id,
running: s.running ?? s.pty_status === "running",
label: s.label || s.display_name || "Terminal",
label: s.label || s.display_name || t("common:terminal"),
closable: s.closable ?? true,
initialCommand: s.initial_command,
};
+2 -1
View File
@@ -8,6 +8,7 @@ import { createUserShell } from "@/lib/api/domains/user-shell-api";
import { matchesShortcut } from "@/lib/keyboard/utils";
import { getShortcut, type StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides";
import type { DockviewApi } from "dockview-react";
import { t } from "@/lib/i18n";
function handleTabNavigation(e: KeyboardEvent, api: DockviewApi) {
const activePanel = api.activePanel;
@@ -66,7 +67,7 @@ function handleTerminalToggle(
createUserShell(environmentId, { taskId: taskID ?? undefined })
.then((result) => {
const title = result.displayName ?? result.label ?? "Terminal";
const title = result.displayName ?? result.label ?? t("common:terminal");
useDockviewStore
.getState()
.addTerminalPanel(result.terminalId, undefined, environmentId, taskID ?? undefined, title);
+11 -12
View File
@@ -8,6 +8,7 @@ import { generateUnifiedDiff, calculateHash } from "@/lib/utils/file-diff";
import type { useToast } from "@/components/toast-provider";
import { buildRepoScopedItemId, PREVIEW_FILE_EDITOR_ID } from "@/lib/state/dockview-panel-actions";
import { lspClientManager } from "@/lib/lsp/lsp-client-manager";
import { t } from "@/lib/i18n";
/** Read openFiles from the store without subscribing to changes. */
function getOpenFiles() {
@@ -96,23 +97,22 @@ async function performSaveFile(path: string, repo: string | undefined, params: S
if (stillClean) updatePanelAfterSave(file.path, file.name, file.repo);
if (response.resolution === "overwritten") {
params.toast({
title: "File saved (overwritten)",
description: "The file was modified externally. Your version was saved.",
title: t("editors:fileSavedOverwritten"),
description: t("editors:fileSavedOverwrittenDescription"),
variant: "default",
});
}
} else {
params.toast({
title: "Save failed",
description: response.error || "Failed to save file",
title: t("editors:saveFailed"),
description: response.error || t("editors:failedToSaveFile"),
variant: "error",
});
}
} catch (error) {
params.toast({
title: "Save failed",
description:
error instanceof Error ? error.message : "An error occurred while saving the file",
title: t("editors:saveFailed"),
description: error instanceof Error ? error.message : t("editors:errorWhileSavingFile"),
variant: "error",
});
} finally {
@@ -143,17 +143,16 @@ export function useSaveDeleteActions(params: SaveDeleteParams) {
const response = await deleteFile(client, currentSessionId, path, fileRepo);
if (!response.success) {
toast({
title: "Delete failed",
description: response.error || "Failed to delete file",
title: t("editors:deleteFailed"),
description: response.error || t("editors:failedToDeleteFile"),
variant: "error",
});
return;
}
} catch (error) {
toast({
title: "Delete failed",
description:
error instanceof Error ? error.message : "An error occurred while deleting the file",
title: t("editors:deleteFailed"),
description: error instanceof Error ? error.message : t("editors:errorWhileDeletingFile"),
variant: "error",
});
return;
+35 -8
View File
@@ -1,44 +1,71 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "@/components/toast-provider";
type GitOperationResult = { success: boolean; output: string; error?: string };
/**
* Resolve an operation name for `useGitWithFeedback`, optionally scoped to one
* repository in a multi-repo task. The parenthesised repo form is a catalog
* entry rather than a template literal so a locale can move or re-punctuate it.
*/
export function gitOperationLabel(
// Deliberately not `TFunction`: callers include plain helpers that use the
// module-level `t` from `@/lib/i18n`, whose signature is narrower.
translate: (key: string, values?: Record<string, unknown>) => string,
operationKey: string,
repo?: string,
): string {
const operation = translate(operationKey);
return repo ? translate("common:gitOperationScoped", { operation, repo }) : operation;
}
/**
* Wraps a git operation with toast feedback (loading → success/error).
*
* `operationName` is the ALREADY-TRANSLATED name of the operation ("Push",
* "Rebase (kandev)"), which this hook interpolates into its own catalog
* messages. Callers resolve it through `gitOperationLabel` below rather than
* passing a literal: the three call sites used to pass English on purpose,
* because this hook concatenated English around it and translating only one
* half would have shipped a mixed-language toast.
*/
export function useGitWithFeedback() {
const { t } = useTranslation();
const { toast, updateToast } = useToast();
const run = useCallback(
async (operation: () => Promise<GitOperationResult>, operationName: string) => {
const toastId = toast({
title: `${operationName}...`,
title: t("common:gitOperationRunning", { operation: operationName }),
variant: "loading",
});
try {
const result = await operation();
if (result.success) {
updateToast(toastId, {
title: `${operationName} successful`,
description: result.output.slice(0, 200) || `${operationName} completed`,
title: t("common:gitOperationSucceeded", { operation: operationName }),
description:
result.output.slice(0, 200) ||
t("common:gitOperationCompleted", { operation: operationName }),
variant: "success",
});
} else {
updateToast(toastId, {
title: `${operationName} failed`,
description: result.error || "An error occurred",
title: t("common:gitOperationFailed", { operation: operationName }),
description: result.error || t("common:anErrorOccurred"),
variant: "error",
});
}
} catch (e) {
updateToast(toastId, {
title: `${operationName} failed`,
description: e instanceof Error ? e.message : "An unexpected error occurred",
title: t("common:gitOperationFailed", { operation: operationName }),
description: e instanceof Error ? e.message : t("common:anUnexpectedErrorOccurred"),
variant: "error",
});
}
},
[toast, updateToast],
[t, toast, updateToast],
);
return run;
+2 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
import { executeUtilityPrompt } from "@/lib/api/domains/utility-api";
import { listTaskSessionMessages } from "@/lib/api/domains/session-api";
import type { Message } from "@/lib/types/http";
import { t } from "@/lib/i18n";
export type SummarizeSessionResult = {
summary: string | null;
@@ -39,7 +40,7 @@ export function useSummarizeSession() {
conversation_history: transcript,
});
if (!result.success) {
return { summary: null, error: result.error || "Summarize utility returned no result" };
return { summary: null, error: result.error || t("task:summarizeReturnedNoResult") };
}
return { summary: result.response ?? null };
} catch (error) {
+2 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef } from "react";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { nativeNotifications } from "@/lib/desktop/native-notification-client";
import { t } from "@/lib/i18n";
export function useUpdateAvailableToast() {
const notification = useAppStore((s) => s.updateAvailableNotification);
@@ -19,7 +20,7 @@ export function useUpdateAvailableToast() {
}
shownRef.current.add(notification.version);
const title = notification.title || "Kandev update available";
const title = notification.title || t("task:kandevUpdateAvailable");
const body =
notification.body ||
`Kandev ${notification.version} is available. Open Settings > System > Updates to review it.`;
@@ -1,6 +1,7 @@
"use client";
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { executeUtilityPrompt, type ExecutePromptRequest } from "@/lib/api/domains/utility-api";
import { useToast } from "@/components/toast-provider";
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
@@ -52,6 +53,7 @@ export function useUtilityAgentGenerator({
taskDescription,
}: UseUtilityAgentGeneratorOptions) {
const [generating, setGenerating] = useState<Set<GeneratorType>>(new Set());
const { t } = useTranslation();
const { toast } = useToast();
const gitStatus = useSessionGitStatus(sessionId);
@@ -103,8 +105,8 @@ export function useUtilityAgentGenerator({
// Other generators need git context from an active session.
if (!sessionId && type !== ENHANCE_PROMPT) {
toast({
title: "No active session",
description: "Start a session first to use AI generation",
title: t("task:noActiveSession"),
description: t("task:noActiveSessionDescription"),
variant: "error",
});
return;
@@ -115,8 +117,8 @@ export function useUtilityAgentGenerator({
const resp = await executeUtilityPrompt(buildRequest(type, options));
if (!resp.success || !resp.response) {
toast({
title: "Generation failed",
description: resp.error || "Failed to generate content",
title: t("task:generationFailed"),
description: resp.error || t("task:failedToGenerateContent"),
variant: "error",
});
return;
@@ -128,15 +130,15 @@ export function useUtilityAgentGenerator({
});
} catch (error) {
toast({
title: "Generation failed",
description: error instanceof Error ? error.message : "Unknown error",
title: t("task:generationFailed"),
description: error instanceof Error ? error.message : t("task:unknownError"),
variant: "error",
});
} finally {
clearType(type);
}
},
[sessionId, buildRequest, clearType, toast],
[sessionId, buildRequest, clearType, t, toast],
);
return useGeneratorCallbacks(generate, generating);
+56 -2
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import i18n from "i18next";
import type { AgentUpdateJob, AgentUpdatePreview } from "@/lib/api";
import { canApproveAgentRuntimeUpdate, resolveRuntimeVersionPair } from "./agent-runtime-update";
@@ -27,12 +28,18 @@ describe("resolveRuntimeVersionPair", () => {
preview(),
job({ current_version: "0.63.0", target_version: "0.63.0" }),
),
).toEqual({ currentVersion: "0.63.0", targetVersion: "0.63.0", versionsMatch: true });
).toEqual({
currentVersion: "0.63.0",
hasCurrentVersion: true,
targetVersion: "0.63.0",
versionsMatch: true,
});
});
it("falls back to preview versions before a job exists", () => {
expect(resolveRuntimeVersionPair(preview())).toEqual({
currentVersion: "0.62.0",
hasCurrentVersion: true,
targetVersion: "0.63.0",
versionsMatch: false,
});
@@ -69,3 +76,50 @@ describe("canApproveAgentRuntimeUpdate", () => {
expect(canApproveAgentRuntimeUpdate(state)).toBe(expected);
});
});
/**
* The missing-version predicate used to be `currentVersion !== "Unknown"`, a
* comparison against the English placeholder. Once `currentVersion` became
* localized, that test was true in every other locale, so a missing version read
* as known and the approval gate opened. These pin the state to a flag and the
* string to display only.
*/
describe("missing current version under a non-English locale", () => {
afterEach(async () => {
await i18n.changeLanguage("en");
});
const missing = {
preview: preview({ current_version: "" }),
previewError: null,
loading: false,
updateInFlight: false,
starting: false,
installInFlight: false,
};
it.each(["en", "pt-pt", "zh-cn", "pseudo"])("stays un-approvable in %s", async (locale) => {
await i18n.changeLanguage(locale);
const pair = resolveRuntimeVersionPair(preview({ current_version: "" }));
expect(pair.hasCurrentVersion).toBe(false);
expect(canApproveAgentRuntimeUpdate(missing)).toBe(false);
});
it("still shows a localized placeholder to the user", async () => {
await i18n.changeLanguage("pseudo");
const pair = resolveRuntimeVersionPair(preview({ current_version: "" }));
expect(pair.currentVersion).not.toBe("Unknown");
expect(pair.currentVersion).toMatch(/[^\x20-\x7E]/);
});
it("does not treat a localized placeholder as matching a target version", async () => {
await i18n.changeLanguage("pt-pt");
const pair = resolveRuntimeVersionPair(
preview({ current_version: "", target_version: "0.63.0" }),
);
expect(pair.versionsMatch).toBe(false);
});
});
+20 -9
View File
@@ -1,7 +1,18 @@
import type { AgentUpdateJob, AgentUpdatePreview } from "@/lib/api";
import { t } from "@/lib/i18n";
export type RuntimeVersionPair = {
/** Display copy — localized, and therefore never compared. */
currentVersion: string;
/**
* Whether a real version was reported. The predicate this replaced was
* `currentVersion !== "Unknown"`, which compared against the English
* placeholder; once `currentVersion` became localized that test was true in
* every other locale, so a missing version read as known and the update
* gate opened. docs/i18n.md calls this out under "Do not translate" — the
* flag is the state, the string is only for the user.
*/
hasCurrentVersion: boolean;
targetVersion?: string;
versionsMatch: boolean;
};
@@ -10,12 +21,11 @@ export function resolveRuntimeVersionPair(
preview: AgentUpdatePreview | null,
job?: AgentUpdateJob,
): RuntimeVersionPair {
const currentVersion = job?.current_version || preview?.current_version || "Unknown";
const reported = job?.current_version || preview?.current_version;
const currentVersion = reported || t("common:unknown");
const targetVersion = job?.target_version || preview?.target_version;
const versionsMatch = Boolean(
targetVersion && currentVersion !== "Unknown" && currentVersion === targetVersion,
);
return { currentVersion, targetVersion, versionsMatch };
const versionsMatch = Boolean(targetVersion && reported && reported === targetVersion);
return { currentVersion, hasCurrentVersion: Boolean(reported), targetVersion, versionsMatch };
}
export function canApproveAgentRuntimeUpdate({
@@ -35,11 +45,12 @@ export function canApproveAgentRuntimeUpdate({
starting: boolean;
installInFlight: boolean;
}): boolean {
const { currentVersion, targetVersion } = resolveRuntimeVersionPair(preview, job);
const { currentVersion, hasCurrentVersion, targetVersion } = resolveRuntimeVersionPair(
preview,
job,
);
return (
Boolean(
preview && currentVersion !== "Unknown" && targetVersion && currentVersion !== targetVersion,
) &&
Boolean(preview && hasCurrentVersion && targetVersion && currentVersion !== targetVersion) &&
!previewError &&
!loading &&
!updateInFlight &&
+3
View File
@@ -35,6 +35,9 @@ export async function createTaskPlan(
}
const response = await client.request("task.plan.create", {
task_id: taskId,
// Canonical English on purpose: the backend persists this into
// `task_plans.title` / `task_plan_revisions.title`, so a localized default
// would freeze whichever locale created the plan into the stored record.
title: title || "Plan",
content,
created_by: "user",
+4 -3
View File
@@ -1,5 +1,6 @@
import { IconAlertTriangle, IconLock } from "@tabler/icons-react";
import type { CapabilityStatus } from "@/lib/types/http";
import { t } from "@/lib/i18n";
export type CapabilityWarning = {
Icon: typeof IconLock;
@@ -16,19 +17,19 @@ export function getCapabilityWarning(
return {
Icon: IconLock,
color: "text-amber-600 dark:text-amber-400",
title: error || "Authentication required",
title: error || t("agents:authenticationRequired"),
};
case "not_installed":
return {
Icon: IconAlertTriangle,
color: "text-muted-foreground",
title: error || "Agent CLI not installed",
title: error || t("task:agentCliNotInstalled"),
};
case "failed":
return {
Icon: IconAlertTriangle,
color: "text-red-600 dark:text-red-400",
title: error || "Agent probe failed",
title: error || t("task:agentProbeFailed"),
};
default:
return null;
+2 -1
View File
@@ -1,4 +1,5 @@
import type { GitHubStatus } from "@/lib/types/github";
import { t } from "@/lib/i18n";
export function hasGitHubPersonalActor(status: GitHubStatus | null): boolean {
const actor = status?.effective_personal_actor;
@@ -18,7 +19,7 @@ export function getGitHubPersonalIdentityState(status: GitHubStatus): GitHubPers
active && principal?.source === "github_app_user" && status.personal?.status === "active",
);
let actor = "Unavailable";
if (active) actor = principal?.login ?? "Unavailable";
if (active) actor = principal?.login ?? t("common:unavailable");
else if (status.automation?.source === "github_app_installation") actor = "Not connected";
return { active, actor, personalOAuthActive };
}
+35
View File
@@ -16,34 +16,69 @@ export type BuiltInLayoutProfileId = Exclude<BuiltInPreset, "compact">;
export type BuiltInLayoutProfileDescriptor = {
id: BuiltInLayoutProfileId;
/** Canonical English; persisted into a saved override. Not for display. */
name: string;
/** Catalog key the settings list renders. Absent for product names. */
nameKey?: string;
description: string;
descriptionKey?: string;
};
/**
* Display name for a built-in profile, in the active locale.
*
* `descriptor.name` is the canonical English value `upsertBuiltInLayoutOverride`
* persists; every surface that shows the name to a user goes through here so the
* two cannot drift.
*/
export function builtInLayoutProfileName(
descriptor: Pick<BuiltInLayoutProfileDescriptor, "name" | "nameKey">,
translate: (key: string) => string,
): string {
return descriptor.nameKey ? translate(descriptor.nameKey) : descriptor.name;
}
export type BuiltInLayoutProfile = BuiltInLayoutProfileDescriptor & {
layout: LayoutState;
};
/**
* `name` is BOTH display copy and persisted data: `upsertBuiltInLayoutOverride`
* copies it into the saved record the first time a built-in is customized. It
* therefore stays canonical English, and `nameKey`/`descriptionKey` are what the
* settings list renders — the same split the dockview panel registry uses. A
* translated `name` here would write the current locale into a user's saved
* layouts and leave it there after a switch.
*
* "VS Code" is a product name and so has no `nameKey`.
*/
export const BUILT_IN_LAYOUT_PROFILES: readonly BuiltInLayoutProfileDescriptor[] = [
{
id: "default",
name: "Default",
nameKey: "settings:layoutProfileDefault",
description: "Agent with Files, Changes, and Terminal",
descriptionKey: "settings:layoutProfileDefaultDescription",
},
{
id: "plan",
name: "Plan Mode",
nameKey: "settings:layoutProfilePlanMode",
description: "Agent and Plan side by side",
descriptionKey: "settings:layoutProfilePlanModeDescription",
},
{
id: "preview",
name: "Preview Mode",
nameKey: "settings:layoutProfilePreviewMode",
description: "Agent and Browser side by side",
descriptionKey: "settings:layoutProfilePreviewModeDescription",
},
{
id: "vscode",
name: "VS Code",
description: "Agent and VS Code side by side",
descriptionKey: "settings:layoutProfileVsCodeDescription",
},
];
+2 -1
View File
@@ -1,4 +1,5 @@
import type { TaskSessionState, TaskState } from "@/lib/types/http";
import { t } from "@/lib/i18n";
export const RECENT_TASKS_STORAGE_KEY = "kandev.recentTasks.v1";
export const RECENT_TASKS_CHANGED_EVENT = "kandev:recent-tasks-changed";
@@ -33,7 +34,7 @@ function normalizeEntry(value: unknown): RecentTaskEntry | null {
if (!isRecord(value)) return null;
const taskId = optionalString(value.taskId);
if (!taskId) return null;
const title = optionalString(value.title) ?? "Untitled task";
const title = optionalString(value.title) ?? t("common:untitledTask");
const visitedAt = optionalString(value.visitedAt) ?? new Date(0).toISOString();
return {
+15 -13
View File
@@ -2,6 +2,8 @@ import type { DockviewApi, DockviewGroupPanel } from "dockview-react";
import type { CommitDetailTarget } from "@/components/task/changes-diff-target";
import { t } from "@/lib/i18n";
import { focusOrAddPanel } from "./dockview-layout-builders";
import { TERMINAL_DEFAULT_ID } from "./layout-manager/constants";
import { panelTitle } from "./layout-manager/panel-title";
import {
parsePluginPanelId,
pluginPanelId,
@@ -365,7 +367,7 @@ function buildFileDiffAction(get: StoreGet) {
api,
type: "file-diff",
itemId,
title: `Diff [${getFileName(path)}]`,
title: t("task:panelFileDiff", { file: getFileName(path) }),
params: {
kind: "file",
path,
@@ -439,7 +441,7 @@ export function buildPanelActions(set: StoreSet, get: StoreGet) {
id: "chat",
component: "chat",
tabComponent: "permanentTab",
title: "Agent",
title: panelTitle("chat"),
position: { referenceGroup: centerGroupId },
});
},
@@ -449,7 +451,7 @@ export function buildPanelActions(set: StoreSet, get: StoreGet) {
addSimplePanel(api, groupId ?? rightTopGroupId, {
id: "changes",
component: "changes",
title: "Changes",
title: panelTitle("changes"),
tabComponent: "changesTab",
});
},
@@ -459,7 +461,7 @@ export function buildPanelActions(set: StoreSet, get: StoreGet) {
addSimplePanel(api, groupId ?? rightTopGroupId, {
id: "files",
component: "files",
title: "Files",
title: panelTitle("files"),
});
},
addDiffViewerPanel: (path?: string, content?: string, groupId?: string) => {
@@ -469,7 +471,7 @@ export function buildPanelActions(set: StoreSet, get: StoreGet) {
addSimplePanel(api, groupId ?? centerGroupId, {
id: "diff-viewer",
component: "diff-viewer",
title: "Diff Viewer",
title: t("task:panelDiffViewer"),
params: { kind: "all" },
});
},
@@ -483,7 +485,7 @@ export function buildPanelActions(set: StoreSet, get: StoreGet) {
addSimplePanel(api, groupId ?? centerGroupId, {
id: browserId,
component: "browser",
title: t("task:browser"),
title: panelTitle("browser"),
params: { url: url ?? "" },
});
},
@@ -549,7 +551,7 @@ function buildReviewPanelActions(get: StoreGet) {
focusOrAddPanel(api, {
id,
component: "pr-detail",
title: prKey ? "Pull Request" : "PR Details",
title: prKey ? t("task:panelPullRequest") : panelTitle("pr-detail"),
position: { referenceGroup: targetGroupId },
params: prKey ? { prKey } : undefined,
});
@@ -572,7 +574,7 @@ function buildReviewPanelActions(get: StoreGet) {
focusOrAddPanel(api, {
id,
component: "mr-detail",
title: "Merge Request",
title: panelTitle("mr-detail"),
position: { referenceGroup: canonical?.group.id ?? centerGroupId },
params: { mrKey },
});
@@ -588,7 +590,7 @@ export function buildExtraPanelActions(get: StoreGet) {
focusOrAddPanel(api, {
id: "vscode",
component: "vscode",
title: "VS Code",
title: panelTitle("vscode"),
position: { referenceGroup: centerGroupId },
});
},
@@ -603,7 +605,7 @@ export function buildExtraPanelActions(get: StoreGet) {
focusOrAddPanel(api, {
id: "vscode",
component: "vscode",
title: "VS Code",
title: panelTitle("vscode"),
position: { referenceGroup: centerGroupId },
});
},
@@ -613,7 +615,7 @@ export function buildExtraPanelActions(get: StoreGet) {
addSidePanel(
api,
centerGroupId,
{ id: "plan", component: "plan", title: "Plan", tabComponent: "planTab" },
{ id: "plan", component: "plan", title: panelTitle("plan"), tabComponent: "planTab" },
opts,
);
},
@@ -646,7 +648,7 @@ export function buildExtraPanelActions(get: StoreGet) {
addSidePanel(
api,
centerGroupId,
{ id: "todos", component: "todos", title: t("common:todos") },
{ id: "todos", component: "todos", title: panelTitle("todos") },
opts,
);
},
@@ -673,7 +675,7 @@ export function buildExtraPanelActions(get: StoreGet) {
// when there's more than one ordinary terminal in the task and
// exposes a context menu for rename / park / destroy.
tabComponent: "terminalTab",
title: title ?? "Terminal",
title: title ?? panelTitle(TERMINAL_DEFAULT_ID),
params: { terminalId: id, environmentId, taskID },
});
},
+87 -12
View File
@@ -64,13 +64,48 @@ export const STRUCTURAL_COMPONENTS = new Set([
"plugin-panel",
]);
/** Default panel configurations for known panels. */
export const PANEL_REGISTRY: Record<string, Omit<LayoutPanel, "id">> = {
chat: { component: "chat", title: "Agent", tabComponent: "permanentTab" },
plan: { component: "plan", title: "Plan", tabComponent: "planTab" },
changes: { component: "changes", title: "Changes", tabComponent: "changesTab" },
files: { component: "files", title: "Files" },
browser: { component: "browser", title: "Browser", params: { url: "" } },
/**
* Default panel configurations for known panels.
*
* `title` and `titleKey` are deliberately two different things:
*
* - `title` is the CANONICAL, always-English value. `toSerializedDockview`
* writes it into the stored layout JSON, so it has to be locale-independent
* — otherwise the locale a layout happened to be saved in is frozen into it
* and survives every later switch.
* - `titleKey` is what the user reads. `panelTitle()` resolves it whenever a
* panel is built or a saved layout is restored, so the tab always shows the
* ACTIVE locale regardless of which one wrote the layout.
*
* This is the key/persisted-value split docs/i18n.md prescribes for a display
* string that is also stored. Before it, two of these titles were translated at
* creation (`t("task:browser")`, `t("common:todos")`) and the rest were not, so
* the tab bar was half-translated AND persisted whichever locale created it.
*
* `vscode` has no `titleKey` on purpose: "VS Code" is a product name, and
* `panelTitle()` falls back to `title` when a key is absent.
*/
export const PANEL_REGISTRY: Record<string, Omit<LayoutPanel, "id"> & { titleKey?: string }> = {
chat: {
component: "chat",
title: "Agent",
titleKey: "task:panelAgent",
tabComponent: "permanentTab",
},
plan: { component: "plan", title: "Plan", titleKey: "task:panelPlan", tabComponent: "planTab" },
changes: {
component: "changes",
title: "Changes",
titleKey: "task:panelChanges",
tabComponent: "changesTab",
},
files: { component: "files", title: "Files", titleKey: "task:panelFiles" },
browser: {
component: "browser",
title: "Browser",
titleKey: "task:browser",
params: { url: "" },
},
vscode: { component: "vscode", title: "VS Code" },
[TERMINAL_DEFAULT_ID]: {
component: "terminal",
@@ -81,18 +116,58 @@ export const PANEL_REGISTRY: Record<string, Omit<LayoutPanel, "id">> = {
// session-page mount so the badge logic has a seq to display.
tabComponent: "terminalTab",
title: "Terminal",
titleKey: "task:panelTerminal",
params: { terminalId: "shell-default" },
},
"pr-detail": { component: "pr-detail", title: "PR Details" },
"mr-detail": { component: "mr-detail", title: "Merge Request" },
todos: { component: "todos", title: "Todos" },
"pr-detail": { component: "pr-detail", title: "PR Details", titleKey: "task:panelPrDetails" },
"mr-detail": {
component: "mr-detail",
title: "Merge Request",
titleKey: "task:panelMergeRequest",
},
todos: { component: "todos", title: "Todos", titleKey: "common:todos" },
};
/** Create a LayoutPanel from the registry by ID. */
/**
* `panelTitle()` — the locale-resolved counterpart of `canonicalPanelTitle()` —
* lives in `./panel-title.ts`. It needs `@/lib/i18n`, and this module must stay
* loadable from plain Node because e2e specs import constants from it; see that
* file's comment.
*/
/**
* Registry entry for a panel.
*
* Falls back to the COMPONENT when the id is not an exact key, because several
* panel families mint dynamic ids from their component — `browser:<url>`,
* `pr-detail|<key>`, `mr-detail|<key>`. Matching on the id alone left those
* outside the canonical/localized split entirely: they were created with a
* localized title, captured verbatim into the saved layout, and then could not
* be re-localized on restore, so the layout showed whichever locale created it.
*/
export function panelRegistryEntry(
id: string,
component?: string,
): (typeof PANEL_REGISTRY)[string] | undefined {
return PANEL_REGISTRY[id] ?? (component ? PANEL_REGISTRY[component] : undefined);
}
/** The canonical English title stored in a saved layout, if the panel is known. */
export function canonicalPanelTitle(id: string, component?: string): string | undefined {
return panelRegistryEntry(id, component)?.title;
}
/**
* Create a LayoutPanel from the registry by ID.
*
* The title is canonical English: a `LayoutPanel` is the shape that gets
* persisted, and `toSerializedDockview` localizes it when it reaches dockview.
*/
export function panel(id: string): LayoutPanel {
const config = PANEL_REGISTRY[id];
if (!config) throw new Error(`Unknown panel: ${id}`);
return { id, ...config };
const { titleKey: _titleKey, ...rest } = config;
return { id, ...rest };
}
/** Generate panel config for a session tab. */
@@ -0,0 +1,30 @@
import { t } from "@/lib/i18n";
import { panelRegistryEntry } from "./constants";
/**
* The display title for a registry panel, in the active locale.
*
* Deliberately NOT in `constants.ts`. That module is imported by e2e specs
* (`e2e/tests/layout/pane-resize-right.spec.ts` takes `TERMINAL_DEFAULT_ID` from
* it), and Playwright loads spec files through Node rather than Vite. Pulling
* `@/lib/i18n` into that graph makes the whole file fail to load with
* `SyntaxError: Cannot use 'import.meta' outside a module` — and one unloadable
* spec aborts the entire run, so it takes every shard down with it, not just
* the layout tests. Keeping the i18n dependency in a separate module leaves
* `constants.ts` importable from plain Node.
*
* Resolved at call time, never at module load — the registry stores the key.
* Unknown ids (file diffs, plugin panels, per-session chat tabs) carry their own
* title and are returned unchanged.
*
* This is the RENDER direction only. `LayoutState` — what gets saved — always
* holds the canonical English title, so the localized form exists solely inside
* dockview. `toSerializedDockview` localizes on the way in and
* `panelFromDockviewPanel` canonicalizes on the way back out.
*/
export function panelTitle(id: string, fallback?: string, component?: string): string {
const config = panelRegistryEntry(id, component);
if (!config) return fallback ?? id;
return config.titleKey ? t(config.titleKey) : config.title;
}
@@ -0,0 +1,113 @@
import { afterEach, describe, expect, it } from "vitest";
import i18n from "i18next";
import { canonicalPanelTitle, panel, PANEL_REGISTRY } from "./constants";
import { panelTitle } from "./panel-title";
import { toSerializedDockview } from "./serializer";
import type { LayoutState } from "./types";
/**
* Panel titles are BOTH display copy and persisted data: `toSerializedDockview`
* writes them into the stored layout JSON. That is why the registry keeps the
* two apart — `title` is canonical English for storage, `titleKey` resolves at
* render for the tab bar.
*
* Before the split, two titles were translated at creation and the rest were
* not, so a saved layout captured whichever locale created it and kept showing
* it after a switch.
*/
afterEach(async () => {
await i18n.changeLanguage("en");
});
function layoutWith(panelIds: string[]): LayoutState {
return {
columns: [{ id: "col", groups: [{ id: "group", panels: panelIds.map(panel) }] }],
} as unknown as LayoutState;
}
describe("panelTitle", () => {
it("resolves a registry panel through the catalog", async () => {
expect(panelTitle("changes")).toBe("Changes");
await i18n.changeLanguage("pseudo");
const pseudo = panelTitle("changes");
expect(pseudo).not.toBe("Changes");
expect(pseudo).toMatch(/[^\x20-\x7E]/);
});
it("falls back to the given title for a panel the registry does not know", () => {
expect(panelTitle("diff:src/app.ts", "Diff [app.ts]")).toBe("Diff [app.ts]");
});
const BROWSER_PANEL_ID = "browser:https://example.com";
it("resolves a dynamic id through its component", async () => {
// `browser:<url>`, `pr-detail|<key>` and `mr-detail|<key>` are minted per
// instance, so an exact-id lookup misses them. Without the component
// fallback they were created localized, captured verbatim into the saved
// layout, and could not be re-localized on restore — the layout then showed
// whichever locale had created it.
expect(panelTitle(BROWSER_PANEL_ID, undefined, "browser")).toBe("Browser");
expect(canonicalPanelTitle(BROWSER_PANEL_ID, "browser")).toBe("Browser");
expect(canonicalPanelTitle("mr-detail|kandev!7", "mr-detail")).toBe("Merge Request");
await i18n.changeLanguage("pseudo");
expect(panelTitle(BROWSER_PANEL_ID, undefined, "browser")).not.toBe("Browser");
// Storage stays canonical whatever the locale.
expect(canonicalPanelTitle(BROWSER_PANEL_ID, "browser")).toBe("Browser");
});
it("leaves a product name untranslated", async () => {
await i18n.changeLanguage("pseudo");
expect(panelTitle("vscode")).toBe("VS Code");
});
it("gives every registry panel a title that is not the raw key", () => {
const leaked = Object.keys(PANEL_REGISTRY).filter((id) => panelTitle(id).includes(":"));
expect(leaked).toEqual([]);
});
});
describe("layout round trip", () => {
const titlesOf = (state: LayoutState) => {
const serialized = toSerializedDockview(state, 1000, 800, new Map());
return (serialized as unknown as { panels: Record<string, { title: string }> }).panels;
};
it("localizes on the way into dockview", async () => {
// `toSerializedDockview` feeds `api.fromJSON`, so this is what the tab bar
// and the layout editor's preview render — it must follow the locale. The
// first version of this split had the two directions the wrong way round,
// which the pseudo-coverage oracle caught as English tab titles on the
// layouts screen while every unit test passed.
await i18n.changeLanguage("pseudo");
const stored = titlesOf(layoutWith(["chat", "changes", "browser"]));
for (const title of ["chat", "changes", "browser"].map((id) => stored[id].title)) {
expect(title).toMatch(/[^\x20-\x7E]/);
}
});
it("keeps a stored LayoutState in canonical English", () => {
// `panel()` builds the shape that gets persisted, so it must NOT localize —
// otherwise the locale that created a layout is frozen into it.
const built = layoutWith(["chat", "changes", "browser"]).columns[0].groups[0].panels;
expect(built.map((p) => p.title)).toEqual(["Agent", "Changes", "Browser"]);
});
it("builds the same canonical LayoutState in any locale", async () => {
const english = layoutWith(["chat", "changes", "browser"]);
await i18n.changeLanguage("pseudo");
const pseudo = layoutWith(["chat", "changes", "browser"]);
expect(pseudo).toEqual(english);
});
it("keeps the canonical title for every registry panel", () => {
for (const id of Object.keys(PANEL_REGISTRY)) {
expect(canonicalPanelTitle(id)).toBe(PANEL_REGISTRY[id].title);
}
});
});
@@ -1,7 +1,17 @@
import type { DockviewApi, SerializedDockview } from "dockview-react";
import type { LayoutState, LayoutColumn, LayoutGroup, LayoutPanel, LayoutNode } from "./types";
import { computeColumnWidths, computeGroupHeights } from "./sizing";
import { SIDEBAR_LOCK, KNOWN_PANEL_IDS, STRUCTURAL_COMPONENTS } from "./constants";
import {
SIDEBAR_LOCK,
KNOWN_PANEL_IDS,
STRUCTURAL_COMPONENTS,
TERMINAL_DEFAULT_ID,
canonicalPanelTitle,
} from "./constants";
import { panelTitle } from "./panel-title";
/** Canonical English, because `normalizePanel` produces a stored LayoutState. */
const TERMINAL_CANONICAL_TITLE = canonicalPanelTitle(TERMINAL_DEFAULT_ID) ?? "Terminal";
// Dockview serialized grid node types (internal format)
type SerializedLeafNode = {
@@ -137,7 +147,12 @@ function serializePanels(state: LayoutState): Record<
panels[panel.id] = {
id: panel.id,
contentComponent: panel.component,
title: panel.title,
// RENDER direction: this feeds `api.fromJSON`, so the tab the user
// reads is resolved here, in the active locale. `LayoutState` itself
// keeps the canonical English title — see `panelFromDockviewPanel`,
// which canonicalizes again on capture so a translated title can
// never reach storage.
title: panelTitle(panel.id, panel.title, panel.component),
...(panel.tabComponent ? { tabComponent: panel.tabComponent } : {}),
...(panel.params ? { params: panel.params } : {}),
};
@@ -184,7 +199,9 @@ function panelFromDockviewPanel(panel: any): LayoutPanel {
return {
id: panel.id,
component: panel.view?.contentComponent ?? panel.id,
title: panel.title ?? panel.id,
// CAPTURE direction: dockview holds the localized title, and this result is
// what gets persisted, so a registry panel goes back to canonical English.
title: canonicalPanelTitle(panel.id, panel.view?.contentComponent) ?? panel.title ?? panel.id,
...(panel.view?.tabComponent ? { tabComponent: panel.view.tabComponent } : {}),
...(panel.params ? { params: panel.params as Record<string, unknown> } : {}),
};
@@ -331,7 +348,7 @@ function normalizePanel(
// terminals (shell-<uuid>) — preserve id + params so the live PTY
// and store record match on restore. Coerce presentation in case
// the saved layout predates the custom tab + plain title.
return { ...p, tabComponent: "terminalTab", title: "Terminal" };
return { ...p, tabComponent: "terminalTab", title: TERMINAL_CANONICAL_TITLE };
}
// Legacy ephemeral terminal id (e.g. `terminal-saved-1` from older
// layouts) — mint a fresh id so it reconnects to a new PTY.
@@ -341,16 +358,20 @@ function normalizePanel(
id,
params: { terminalId: id },
tabComponent: "terminalTab",
title: "Terminal",
title: TERMINAL_CANONICAL_TITLE,
};
}
if (KNOWN_PANEL_IDS.has(p.id)) return p;
// A layout saved before this split (or by an older build) can carry a
// translated title; normalise it back to canonical English so the render
// direction is the only place a locale is applied.
if (KNOWN_PANEL_IDS.has(p.id))
return { ...p, title: canonicalPanelTitle(p.id, p.component) ?? p.title };
if (p.component === "browser") {
const url = (p.params?.url as string) ?? "";
const id = url ? `browser:${url}` : `browser-saved-${++counters.browser}`;
return { ...p, id, params: { url } };
return { ...p, id, params: { url }, title: canonicalPanelTitle(id, p.component) ?? p.title };
}
return p;
}
+2 -1
View File
@@ -13,6 +13,7 @@ import {
isPRFeedbackComment,
isWalkthroughComment,
} from "./types";
import { t } from "@/lib/i18n";
/**
* Format diff review comments as human-readable markdown for sending to agent.
@@ -109,7 +110,7 @@ export function formatWalkthroughCommentsAsMarkdown(comments: WalkthroughComment
for (const c of comments) {
const lineRange = c.startLine === c.endLine ? `${c.startLine}` : `${c.startLine}-${c.endLine}`;
const repoPrefix = c.repo ? `${c.repo}/` : "";
const title = c.walkthroughTitle || "Walkthrough";
const title = c.walkthroughTitle || t("common:walkthrough");
lines.push(`**${title} · Step ${c.stepIndex + 1} / ${c.stepCount}**`);
lines.push(`**${repoPrefix}${c.filePath}:${lineRange}**`);
@@ -14,6 +14,32 @@ export function createDefaultSidebarView(id: string, name: string): SidebarView
};
}
/**
* Catalog key for the built-in view's name.
*
* `SidebarView.name` is persisted and user-editable (the view manager renames
* it in place), so the built-in's name stays canonical English in state and the
* surfaces that display it resolve this key instead — the same key/persisted-
* value split the layout profiles and dockview panel titles use. Translating
* `name` here would write the current locale into the user's synced views.
*/
export const DEFAULT_VIEW_NAME_KEY = "sidebar:viewAllTasks";
export const DEFAULT_VIEW: SidebarView = createDefaultSidebarView(DEFAULT_VIEW_ID, "All tasks");
/**
* Display name for a view.
*
* Only the built-in that still carries its canonical name resolves through the
* catalog. The built-in is renameable like any other view, so keying off the id
* alone would show the translated default over a name the user chose.
*/
export function sidebarViewName(
view: Pick<SidebarView, "id" | "name">,
translate: (key: string) => string,
): string {
const isUnrenamedBuiltIn = view.id === DEFAULT_VIEW_ID && view.name === DEFAULT_VIEW.name;
return isUnrenamedBuiltIn ? translate(DEFAULT_VIEW_NAME_KEY) : view.name;
}
export const DEFAULT_ACTIVE_VIEW_ID = DEFAULT_VIEW_ID;
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_VIEW, createDefaultSidebarView, sidebarViewName } from "./sidebar-view-builtins";
const translate = (key: string) => `«${key}»`;
/**
* `SidebarView.name` is persisted AND user-editable, so the built-in's display
* name resolves through the catalog while the stored value stays canonical
* English. Keying only off the id would have shown the translated default over
* a name the user typed.
*/
describe("sidebarViewName", () => {
it("localizes the built-in view while it keeps its canonical name", () => {
expect(sidebarViewName(DEFAULT_VIEW, translate)).toBe("«sidebar:viewAllTasks»");
});
it("keeps a renamed built-in view showing the user's name", () => {
const renamed = { ...DEFAULT_VIEW, name: "My stuff" };
expect(sidebarViewName(renamed, translate)).toBe("My stuff");
});
it("never localizes a user-created view", () => {
const custom = createDefaultSidebarView("view-abc", "Blocked work");
expect(sidebarViewName(custom, translate)).toBe("Blocked work");
});
it("keeps the stored default name canonical English", () => {
// What sync writes to the server; translating it would freeze a locale in.
expect(DEFAULT_VIEW.name).toBe("All tasks");
});
});
@@ -39,6 +39,15 @@ describe("unarchiveToastPayload", () => {
expect(payload.description).not.toContain("feat/ok");
});
it("uses the singular form for exactly one unrecoverable branch", () => {
const payload = unarchiveToastPayload(
response([{ task_id: "t1", repository_id: "r1", branch: "feat/gone", status: "missing" }]),
);
// Singular and plural now come from `_one`/`_other` rather than from two
// ternaries at the call site, so both forms have to keep resolving.
expect(payload.description).toContain("Branch feat/gone no longer exists");
});
it("pluralizes when multiple branches are unrecoverable", () => {
const payload = unarchiveToastPayload(
response([
+14 -6
View File
@@ -1,4 +1,5 @@
import type { UnarchiveTaskResponse } from "@/lib/api/domains/kanban-api";
import { t } from "@/lib/i18n";
export type UnarchiveToastPayload = {
title: string;
@@ -10,19 +11,26 @@ export type UnarchiveToastPayload = {
// branch no longer exists anywhere (never pushed, local copy deleted by
// archive), the prior work is unrecoverable and the user must be told the
// next session starts fresh.
//
// The missing-branch sentence used to inflect itself
// (`${plural ? "Branches" : "Branch"} … no longer ${plural ? "exist" : "exists"}`),
// which puts the plural rule at the call site and cannot be translated into a
// language whose rules differ from English. `count` plus `_one`/`_other` moves
// it into the catalog, where each locale supplies as many forms as it needs.
export function unarchiveToastPayload(result: UnarchiveTaskResponse): UnarchiveToastPayload {
const missing = (result.recovery ?? []).filter((r) => r.status === "missing");
if (missing.length > 0) {
const branches = missing.map((r) => r.branch).join(", ");
const plural = missing.length > 1;
return {
title: "Task unarchived",
description: `${plural ? "Branches" : "Branch"} ${branches} no longer ${plural ? "exist" : "exists"} locally or on the remote — the next session starts fresh from the base branch.`,
title: t("tasks:taskUnarchived"),
description: t("tasks:unarchivedBranchesMissing", {
count: missing.length,
branches: missing.map((r) => r.branch).join(", "),
}),
};
}
return {
title: "Task unarchived",
description: "The task has been restored.",
title: t("tasks:taskUnarchived"),
description: t("tasks:unarchivedTaskRestored"),
variant: "success",
};
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { createTwoFilesPatch } from "diff";
import { djb2Hash } from "./hash";
import { t } from "@/lib/i18n";
/**
* Generate a unified diff between original and modified content
@@ -65,5 +66,5 @@ export function formatDiffStats(additions: number, deletions: number): string {
if (deletions > 0) {
parts.push(`-${deletions}`);
}
return parts.join(" ") || "No changes";
return parts.join(" ") || t("task:noChanges");
}
+2
View File
@@ -99,6 +99,7 @@
"failedToFetchCapabilities": "Failed to fetch capabilities",
"modelOptionsAuthenticationRequired": "Sign in to this agent to load model options.",
"modelOptionsResolutionFailed": "Model options could not be loaded.",
"modelSettings": "Model settings",
"resolvingModelOptions": "Loading model options…",
"retryModelOptions": "Retry",
"failedToLoadCommandPreview": "Failed to load command preview",
@@ -186,6 +187,7 @@
"searchModes": "Search modes...",
"selectAModel": "Select a model...",
"selectAModelShort": "Select a model",
"selectModel": "Select model...",
"sessionEnded": "Session ended.",
"sessionEndedWithCode": "Session ended (exit {{code}}).",
"sessionUnavailable": "Session is no longer available.",
+3
View File
@@ -65,6 +65,9 @@
"enabled": "Enabled",
"enterValidOrganizationUrl": "Enter a valid organization URL to open its token settings.",
"executorProfileId": "Executor profile ID",
"failedToLinkPullRequest": "Failed to link Azure DevOps pull request.",
"failedToLinkWorkItem": "Failed to link Azure DevOps work item.",
"failedToLinkWorkItemNoProject": "Failed to link Azure DevOps work item: project is missing.",
"failedToLoadConfig": "Failed to load Azure DevOps config: {{error}}",
"failedToLoadQuickActions": "Failed to load quick actions.",
"failedToSaveDefaultQueries": "Failed to save default queries",
+78
View File
@@ -4,6 +4,7 @@
"activeSubagents_other": "{{count}} subagents running",
"activeTasks": "Active Tasks",
"aDirectLocalExecutorProfileIsRequired": "A direct local executor profile is required to create and use an empty repository.",
"agent": "Agent",
"agentDashboard": "agent dashboard",
"agentRun": "agent run",
"agentRuns": "agent runs",
@@ -13,6 +14,9 @@
"andMoreFiles_one": "+{{count}} more",
"andMoreFiles_other": "+{{count}} more",
"anEnhancedPromptIsAvailable": "An enhanced prompt is available.",
"anErrorOccurred": "An error occurred",
"anUnexpectedErrorOccurred": "An unexpected error occurred",
"anUnknownErrorOccurred": "An unknown error occurred",
"apply": "Apply",
"autoApproveAllPermissions": "Auto-approve all permissions",
"automations": "Automations",
@@ -36,9 +40,30 @@
"cliPassthrough": "CLI passthrough",
"close": "Close",
"closeEsc": "Close (Esc)",
"commandAddBrowserPanel": "Add Browser Panel",
"commandAddBrowserPanelKeywords": "browser, preview, web, open browser preview, web preview, app preview",
"commandAddChangesPanel": "Add Changes Panel",
"commandAddChangesPanelKeywords": "changes, diff, git changes, changed files, source control, git diff, review changes",
"commandAddPlanPanel": "Add Plan Panel",
"commandAddPlanPanelKeywords": "plan, document, task plan, implementation plan, plan details",
"commandAddTerminalPanel": "Add Terminal Panel",
"commandAddTerminalPanelKeywords": "terminal, shell, console, new terminal, open terminal, command line",
"commandAgentsSettingsKeywords": "agents, agent settings, agent profiles, installed agents, claude",
"commandArchiveTaskKeywords": "archive, archive task, archive current task, close task, finish task, put away",
"commandCancelTurn": "Cancel Turn",
"commandCancelTurnKeywords": "cancel, stop, turn, cancel agent, stop agent, interrupt agent",
"commandCommitChanges": "Commit Changes",
"commandCommitChangesKeywords": "commit, git, save changes, git commit",
"commandConfigChatKeywords": "config chat, config mode, configure kandev, workflow configuration, mcp configuration",
"commandCreateFile": "Create File",
"commandCreateFileKeywords": "create, new, file, add",
"commandCreateFilePlaceholder": "File path relative to workspace root...",
"commandCreateNewTask": "Create New Task",
"commandCreateNewTaskKeywords": "new, create, task, add",
"commandCreatePr": "Create PR",
"commandCreatePrKeywords": "pull request, pr, open pull request, submit pull request, git",
"commandCreateSubtask": "Create Subtask",
"commandCreateSubtaskKeywords": "subtask, create, new subtask, new sub-task, child task",
"commandExecutorsSettingsKeywords": "executors, executor profiles, execution environment, environment variables, runtime, compute",
"commandGoToAllTasks": "Go to All Tasks",
"commandGoToAllTasksKeywords": "tasks, list, all",
@@ -51,11 +76,21 @@
"commandGoToStats": "Go to Stats",
"commandGoToStatsKeywords": "stats, statistics, analytics, metrics",
"commandGroupActions": "Actions",
"commandGroupAgent": "Agent",
"commandGroupCommands": "Commands",
"commandGroupGit": "Git",
"commandGroupNavigation": "Navigation",
"commandGroupPanels": "Panels",
"commandGroupPreferences": "Preferences",
"commandGroupSettings": "Settings",
"commandGroupTasks": "Tasks",
"commandGroupView": "View",
"commandGroupWorkspace": "Workspace",
"commandMergeKeywords": "merge, git, branch",
"commandNewAgent": "New Agent",
"commandNewAgentKeywords": "new, agent, session, start agent, new session",
"commandOpenRecentTaskSwitcher": "Open Recent Task Switcher",
"commandOpenRecentTaskSwitcherKeywords": "recent, task, switcher, history, recent tasks, switch task, switch recent tasks",
"commandPaletteMode": "Command palette mode",
"commandPreviewAddTerminalPanel": "Add Terminal Panel",
"commandPreviewCommitChanges": "Commit Changes",
@@ -63,10 +98,19 @@
"commandPreviewPull": "Pull",
"commandPreviewSearchTasks": "Search Tasks",
"commandPromptsSettingsKeywords": "prompts, prompt settings, custom prompts, prompt snippets, prompt templates",
"commandPullKeywords": "pull, git, pull changes, download changes",
"commandPushKeywords": "push, git, push changes, push to remote, upload changes",
"commandQuickChat": "Quick Chat",
"commandQuickChatKeywords": "quick chat, new quick chat, quick question, ask agent",
"commandRebaseKeywords": "rebase, git, branch",
"commandSwitchToDarkMode": "Switch to Dark Mode",
"commandSwitchToKanbanView": "Switch to Kanban View",
"commandSwitchToKanbanViewKeywords": "kanban, board, view",
"commandSwitchToLightMode": "Switch to Light Mode",
"commandSwitchToListView": "Switch to List View",
"commandSwitchToListViewKeywords": "list, table, view",
"commandSwitchToPipelineView": "Switch to Pipeline View",
"commandSwitchToPipelineViewKeywords": "pipeline, graph, view",
"commandThemeKeywords": "theme, color theme, appearance",
"commandWorkspaceSettingsKeywords": "workspace, workspaces",
"commitsAndPushesToTheCurrent": "Commits and pushes to the current branch.",
@@ -126,6 +170,29 @@
"forwardStdinStdoutStraightToThe": "Forward stdin/stdout straight to the CLI subprocess. Disables ACP.",
"getStarted": "Get Started",
"githubCliNotAuthenticated": "GitHub CLI not authenticated",
"gitOpAmendCommit": "Amend commit",
"gitOpCommit": "Commit",
"gitOperationFailedInRepos_one": "Failed in {{count}} repo — {{summary}}",
"gitOperationFailedInRepos_other": "Failed in {{count}} repos — {{summary}}",
"gitOperationPartialDescription_one": "{{operation}} succeeded in {{succeeded}} of {{count}} repo ({{repos}}); failed in {{summary}}",
"gitOperationPartialDescription_other": "{{operation}} succeeded in {{succeeded}} of {{count}} repos ({{repos}}); failed in {{summary}}",
"gitOperationPartiallySucceeded": "{{operation}} partially succeeded",
"gitOperationRepoFailure": "{{repo}}: {{error}}",
"gitOperationSucceededInRepos_one": "{{operation}} succeeded in {{count}} repo: {{repos}}",
"gitOperationSucceededInRepos_other": "{{operation}} succeeded in {{count}} repos: {{repos}}",
"gitOpForcePush": "Force Push",
"gitOpHardReset": "Hard reset",
"gitOpMerge": "Merge",
"gitOpPull": "Pull",
"gitOpPush": "Push",
"gitOpRebase": "Rebase",
"gitOperationCompleted": "{{operation}} completed",
"gitOperationFailed": "{{operation}} failed",
"gitOperationRunning": "{{operation}}...",
"gitOperationScoped": "{{operation}} ({{repo}})",
"gitOperationSucceeded": "{{operation}} successful",
"gitOpRevertCommit": "Revert commit",
"gitOpSoftReset": "Soft reset",
"gitScopeSkipped": "Skipped {{scope}} because a child repository operation failed ({{failedScopes}}).",
"goToFirstPage": "Go to first page",
"goToLastPage": "Go to last page",
@@ -234,6 +301,7 @@
"probing": "Probing",
"profileName": "Profile name",
"prompts": "Prompts",
"reference": "Reference",
"refreshAriaLabel": "Refresh {{label}}",
"refreshBranchesGitFetch": "Refresh branches (git fetch)",
"refreshingLabel": "Refreshing {{label}}...",
@@ -275,6 +343,7 @@
"scopeFiles": "Files",
"scopeTitleWithShortcut": "{{label}} ({{shortcut}})",
"scratchWorkspace": "scratch workspace",
"script": "Script",
"searchEllipsis": "Search…",
"searchFailedEditTheQueryOr": "Search failed. Edit the query or reopen search to retry.",
"searchForFiles": "Search for files...",
@@ -302,9 +371,12 @@
"skip": "Skip",
"stagesAndCommitsChangesUsingConventional": "Stages and commits changes using Conventional Commits.",
"status": "Status",
"step": "Step",
"subtaskTitle": "Subtask title",
"switchMode": "Switch mode",
"system": "System",
"task": "Task",
"terminal": "Terminal",
"todos": "Todos",
"taskLoading": "Loading task...",
"tasks": "Tasks",
@@ -333,19 +405,25 @@
"typeACommand": "Type a command...",
"typeToSearchFiles": "Type to search files...",
"typeToSearchTaskContents": "Type to search task contents…",
"unavailable": "Unavailable",
"unknown": "Unknown",
"unknownError": "Unknown error",
"unknownPanel": "Unknown panel: {{component}}",
"unsavedChanges": "Unsaved changes",
"untitledTask": "Untitled task",
"usefulCommandsAgentSkills": "Useful commands & agent skills",
"useScratchInstead": "Use scratch instead",
"useThisFolder": "Use this folder",
"viewFullReleaseOnGithub": "View full release on GitHub",
"waitsForCiAndAutomatedReviews": "Waits for CI and automated reviews, then fixes failures and addresses comments.",
"walkthrough": "Walkthrough",
"whatSNew": "What's New",
"whenItsDoneTheAgentOpensAPr": "When it's done, the agent opens a pull request to <2>{{repo}}</2> for the maintainers to review, saving them time and shipping the improvement to everyone.",
"workflow": "Workflow",
"workflowsControlTheStepsAutomationAnd": "Workflows control the steps, automation, and agent behavior for your tasks. You can add more workflows from Settings.",
"workspace": "Workspace",
"workspaces": "Workspaces",
"you": "You",
"youCanUseTheNavigationOr": "You can use the navigation or reload to try again.",
"yourAgentImplementsItInThe": "Your agent implements it in the kandev repository, with tests",
"youVerifyAndTestTheChange": "You verify and test the change in a second kandev instance"
+9
View File
@@ -19,10 +19,17 @@
"copyLine": "Copy line {{lineNumber}}",
"copyPath": "Copy path",
"default": "default",
"deleteFailed": "Delete failed",
"deleteFile": "Delete file",
"disableWordWrap": "Disable word wrap",
"enableWordWrap": "Enable word wrap",
"errorWhileDeletingFile": "An error occurred while deleting the file",
"errorWhileSavingFile": "An error occurred while saving the file",
"failedToDeleteFile": "Failed to delete file",
"failedToSaveFile": "Failed to save file",
"failedToSendComment": "Failed to send comment",
"fileSavedOverwritten": "File saved (overwritten)",
"fileSavedOverwrittenDescription": "The file was modified externally. Your version was saved.",
"foldUnchangedLines": "Fold unchanged lines",
"hideDiffIndicators": "Hide diff indicators",
"highlight": "Highlight",
@@ -35,6 +42,7 @@
"noDiffAvailable": "No diff available",
"noEditorsConfigured": "No editors configured",
"openFolder": "Open folder",
"openTaskSessionBeforeSending": "Open a task session before sending to the agent.",
"openWith": "Open with...",
"pasteLink": "Paste link...",
"pleaseTryAgain": "Please try again.",
@@ -43,6 +51,7 @@
"reload": "Reload",
"revertAllChanges": "Revert all changes",
"revertChanges": "Revert changes",
"saveFailed": "Save failed",
"saving": "Saving...",
"sentToTheAgent": "Sent to the agent.",
"showAllLines": "Show all lines",
+3
View File
@@ -180,6 +180,7 @@
"failedToDeleteReviewWatch": "Failed to delete review watch",
"failedToLoadGithubWorkspaceSettings": "Failed to load GitHub workspace settings",
"failedToMerge": "Failed to merge",
"failedToReRequestReview": "Failed to re-request review",
"failedToResetIssueWatch": "Failed to reset issue watch",
"failedToResetReviewWatch": "Failed to reset review watch",
"failedToSaveDefaultQueries": "Failed to save default queries",
@@ -445,6 +446,7 @@
"reviewCount_other": "{{count}} reviews",
"reviewFollowUp": "Review follow-up",
"reviewPermissions": "Review permissions",
"reviewReRequestedFrom": "Review re-requested from {{reviewer}}",
"reviewWatch": "review watch",
"reviewWatchCreated": "Review watch created",
"reviewWatchDeleted": "Review watch deleted",
@@ -468,6 +470,7 @@
"selectWorkflow": "Select workflow",
"selectWorkspace": "Select workspace",
"selectedRepositories": "Selected repositories",
"selectWorkspaceBeforeRequestingReview": "Select a workspace before requesting review.",
"setTheseExactUrlsCreateAClientSecret": "Set these exact URLs, create a client secret and webhook secret, and download a private key. This one-time setup expires {{expiresAt}}.",
"showGithubApiLimits": "Show GitHub API limits",
"showLess": "Show less",
-1
View File
@@ -316,7 +316,6 @@
"mrAutomationExplainAutoFixRounds": "Explain auto-fix rounds",
"mrAutomationAutoFixPromptSaveFailed": "Failed to save auto-fix prompt.",
"mrAutomationAutoFixPromptResetFailed": "Failed to reset auto-fix prompt.",
"mrPopoverOpenDetail": "Open merge request detail",
"mrPopoverOpenInGitlab": "Open !{{mriid}} in GitLab",
"mrPopoverUnlink": "Unlink !{{mriid}}",
"mrPopoverPassRate": "Pass rate",
+1
View File
@@ -4,6 +4,7 @@
"allRepositories": "All repositories",
"allWorkflows": "All workflows",
"allWorkflowsTitleCase": "All Workflows",
"allWorkspaces": "All workspaces",
"approvalRequired": "Approval Required",
"archive": "Archive",
"archiveCount_one": "Archive {{count}}",
+5
View File
@@ -101,10 +101,15 @@
"botTokenChatId": "{\"bot_token\": \"...\", \"chat_id\": \"...\"}",
"branch": "Branch",
"browseSkillsSh": "Browse skills.sh",
"budgetActionBlockNewTasks": "block new tasks",
"budgetActionNotifyOnly": "notify only",
"budgetActionPauseAgent": "pause agent",
"budgetAlert": "Budget Alert",
"budgetAmount": "Budget: {{amount}}",
"budgetCapConcurrencyAndExecutionEnvironment": "Budget cap, concurrency, and execution environment.",
"budgetLimitsConcurrencyAndExecutionEnvironment": "Budget limits, concurrency, and execution environment.",
"budgetPeriodMonthly": "monthly",
"budgetPeriodTotal": "total",
"budgetPolicies": "Budget Policies",
"budgetPolicyCreated": "Budget policy created",
"budgetPolicyDeleted": "Budget policy deleted",
+7
View File
@@ -347,10 +347,17 @@
"layoutEditorForProfile": "{{name}} editor",
"layoutProfile": "layout profile",
"layoutProfileCopyName": "{{name}} copy",
"layoutProfileDefault": "Default",
"layoutProfileDefaultDescription": "Agent with Files, Changes, and Terminal",
"layoutProfileName": "Layout profile name",
"layoutProfileNamesMustNotBe": "Layout profile names must not be empty",
"layoutProfilePlanMode": "Plan Mode",
"layoutProfilePlanModeDescription": "Agent and Plan side by side",
"layoutProfilePreviewMode": "Preview Mode",
"layoutProfilePreviewModeDescription": "Agent and Browser side by side",
"layoutProfiles": "Layout profiles",
"layoutProfilesWereNotSaved": "Layout profiles were not saved",
"layoutProfileVsCodeDescription": "Agent and VS Code side by side",
"layouts": "Layouts",
"ligatures": "Ligatures",
"light": "Light",
+1
View File
@@ -101,6 +101,7 @@
"toggleTheme": "Toggle theme",
"unsavedFilterChanges": "Unsaved filter changes",
"viewAll": "All",
"viewAllTasks": "All tasks",
"viewLabel": "View: {{name}}",
"whatsNew": "What's new",
"work": "Work",
+40
View File
@@ -1,5 +1,8 @@
{
"agentCliNotInstalled": "Agent CLI not installed",
"agentProbeFailed": "Agent probe failed",
"aPullRequestAlreadyExistsFor": "A pull request already exists for this task",
"archivedTask": "Archived task",
"aSharedDocumentForYouAnd": "A shared document for you and the agent to collaborate on the approach",
"aboutContextWindowSource": "About context window source",
"activeBadge": "ACTIVE",
@@ -269,6 +272,7 @@
"couldNotAddSources": "Could not add sources. Your entries are still here to retry.",
"couldNotLoadRepositories": "Could not load repositories: {{message}}",
"couldNotResolveRemoteRepository": "Could not resolve remote repository: {{message}}",
"couldNotSaveStatusBarOrder": "Could not save status bar order",
"couldnTStartASession": "Couldn't start a session",
"create": "Create",
"createANewBranch": "Create a new branch",
@@ -290,6 +294,7 @@
"creatingEllipsis": "Creating...",
"creatingFrom": "Creating {{shortName}} from <4>{{displayBranch}}</4>",
"creatingFromTo": "Creating {{shortName}} from <4>{{displayBranch}}</4> to <7>{{baseBranch}}</7>",
"credentialsAvailableInExecutor": "Credentials available in the selected executor",
"ctrl": "Ctrl",
"current": "current",
"current2": "Current",
@@ -401,14 +406,19 @@
"environmentPrepared": "Environment prepared",
"environmentPreparedOnFreshSandbox": "Environment prepared on a fresh sandbox",
"environmentPreparedWithWarnings": "Environment prepared with warnings",
"environmentReset": "Environment reset",
"environmentResetFailed": "Reset failed: {{error}}",
"environmentSetupFailed": "Environment setup failed",
"environmentSetupFinishedWithErrors": "Environment setup finished with errors",
"environmentStateCurrent": "Current state: {{state}}",
"error": "Error",
"errorData": "Error Data",
"eventCount_one": "{{count}} event",
"eventCount_other": "{{count}} events",
"executor2": "Executor",
"executorEnvironmentIsUnavailable": "Executor environment is unavailable.",
"executorEnvironmentRunning": "Executor environment running",
"executorEnvironmentStopped": "Executor environment stopped",
"executorHintDocker": "A Docker container will be created from the selected base branch and checked out on a task branch.",
"executorHintLocal": "The agent will run directly on the repository.",
"executorHintWorktreeMulti": "A git worktree will be created for each repository in a parent folder. The agent runs in that parent folder so it can see every worktree side by side.",
@@ -439,6 +449,8 @@
"failedToChangeModel": "Failed to change model",
"failedToDeleteDocument": "Failed to delete document",
"failedToDeleteLayout": "Failed to delete layout",
"failedToDiscardChanges": "Failed to discard changes",
"failedToGenerateContent": "Failed to generate content",
"failedToLinkGithubIssue": "Failed to link GitHub issue.",
"failedToLinkGithubPullRequest": "Failed to link GitHub pull request.",
"failedToLinkTarget": "Failed to link {{target}}.",
@@ -454,13 +466,16 @@
"failedToRenameItem": "Failed to rename item",
"failedToRenameTask": "Failed to rename task",
"failedToRestorePlan": "Failed to restore plan",
"failedToResumeSession": "Failed to resume session",
"failedToRevokeShare": "Failed to revoke share.",
"failedToSaveDocument": "Failed to save document",
"failedToSaveLayout": "Failed to save layout",
"failedToSendComment": "Failed to send comment",
"failedToSendComments": "Failed to send comments",
"failedToSendMessage": "Failed to send message",
"failedToStartTunnel": "Failed to start tunnel: {{error}}",
"failedToStartVsCode": "Failed to start VS Code",
"failedToStopTunnel": "Failed to stop tunnel: {{error}}",
"failedToUnarchiveTask": "Failed to unarchive task",
"failedToUnlinkGithubIssue": "Failed to unlink GitHub issue.",
"failedToUpload": "Failed to upload {{name}}",
@@ -519,6 +534,7 @@
"fullImpactDetails": "Full impact details",
"fullSize": "Full size {{alt}}",
"generatingSummary": "Generating summary...",
"generationFailed": "Generation failed",
"gitActions": "Git actions",
"gitPollMode": "Git poll: {{mode}} ({{label}})",
"githubIssue": "GitHub Issue",
@@ -599,6 +615,7 @@
"kandevListWorkspaces": "Kandev: List Workspaces",
"kandevMessageTask": "Kandev: Message Task",
"kandevMoveTask": "Kandev: Move Task",
"kandevUpdateAvailable": "Kandev update available",
"kandevUpdateTask": "Kandev: Update Task",
"kandevUpdateTaskPlan": "Kandev: Update Task Plan",
"kandevWriteTaskDocument": "Kandev: Write Task Document",
@@ -703,6 +720,9 @@
"moreNotShown_one": "+ {{count}} more not shown",
"moreNotShown_other": "+ {{count}} more not shown",
"moreToolbarActions": "More toolbar actions",
"moveFailed": "Move failed",
"moveFailedFiles": "Some files could not be moved",
"moveFailedUnexpected": "An error occurred while moving files",
"moveHeadToThisCommitKeep": "Move HEAD to this commit. Keep all changes staged.",
"moveHere": "Move here",
"moveTaskToTheNextWorkflow": "Move task to the next workflow step",
@@ -733,6 +753,7 @@
"nextMessage": "Next message",
"nextQuestion": "Next question",
"noActiveSession": "No active session",
"noActiveSessionDescription": "Start a session first to use AI generation",
"noActiveTask": "No active task",
"noActivityYet": "No activity yet",
"noAgentFound": "No agent found.",
@@ -796,6 +817,7 @@
"noSavedLayouts": "No saved layouts",
"noSessionsYetLaunchOneTo": "No sessions yet. Launch one to get started.",
"noSteps": "No steps",
"noStepsConfigured": "No steps configured",
"noStepsFound": "No steps found.",
"noSubIssues": "No sub-issues",
"noTaskSelected": "No task selected",
@@ -836,9 +858,20 @@
"openWorkspaceSettings": "Open workspace settings",
"option": "option {{index}}",
"orTypeACustomAnswer": "Or type a custom answer…",
"otherChanges": "Other changes",
"output": "Output",
"outputTokens": "Output tokens",
"outputTruncated": "Output truncated",
"panelAgent": "Agent",
"panelChanges": "Changes",
"panelDiffViewer": "Diff Viewer",
"panelFileDiff": "Diff [{{file}}]",
"panelFiles": "Files",
"panelMergeRequest": "Merge Request",
"panelPlan": "Plan",
"panelPrDetails": "PR Details",
"panelPullRequest": "Pull Request",
"panelTerminal": "Terminal",
"parent": "Parent",
"parkedCount": "Parked ({{parked}})",
"parkedTerminals": "Parked terminals",
@@ -850,6 +883,7 @@
"pendingReviewCommentsHelp_one": "{{count}} pending review comment. Click to expand the list — you can edit each comment, click the file path to jump to the source, or remove a comment with the trash icon.",
"pendingReviewCommentsHelp_other": "{{count}} pending review comments. Click to expand the list — you can edit each comment, click the file path to jump to the source, or remove a comment with the trash icon.",
"permissionRequested": "Permission requested",
"permissionRequired": "Permission Required",
"pickADifferentRepoLocalFolder": "Pick a different repo, local folder, or remote URL",
"pickAStartingFolderOptional": "pick a starting folder (optional)",
"pickOrPasteARepo": "Pick or paste a repo",
@@ -1219,6 +1253,7 @@
"statusInProgress": "In Progress",
"statusInReview": "In Review",
"statusTodo": "Todo",
"statusUpdate": "Status update",
"stepCount_one": "{{count}} step",
"stepCount_other": "{{count}} steps",
"stepFieldLabel": "step",
@@ -1233,6 +1268,7 @@
"stopVoiceInput": "Stop voice input",
"stoppedWithAnError": "stopped with an error",
"stopping": "Stopping…",
"subagent": "Subagent",
"subIssues": "Sub-issues",
"subTasks": "Sub-tasks",
"submit": "Submit",
@@ -1244,6 +1280,7 @@
"subtasksStayActiveUnlessYouTick": "Subtasks stay active unless you tick this. They may still be in progress.",
"successful": "{{operationName}} successful",
"summarize": "Summarize",
"summarizeReturnedNoResult": "Summarize utility returned no result",
"summarizeSession": "Summarize session",
"summarizeSessionNamed": "Summarize {{label}}",
"summarizing": "Summarizing...",
@@ -1337,6 +1374,7 @@
"tokenCount_other": "{{formatted}} tokens",
"tokens": "tokens",
"tool": "tool",
"toolCall": "Tool call",
"toolCallCount_one": "{{count}} tool call",
"toolCallCount_other": "{{count}} tool calls",
"toolCount_one": "{{count}} tool",
@@ -1353,6 +1391,8 @@
"truncatedParen": "(truncated)",
"tryAgain": "Try again",
"tunnel": "Tunnel",
"tunnelStartedOnPort": "Tunnel started on port {{port}}",
"tunnelStopped": "Tunnel stopped",
"turnForDuration": "for {{duration}}",
"turnMessageCount_one": "· {{count}} message",
"turnMessageCount_other": "· {{count}} messages",
+10
View File
@@ -10,6 +10,10 @@
"columnWorkflow": "Workflow",
"delete": "Delete",
"deleteTask": "Delete task",
"failedToArchiveTask": "Failed to archive task",
"failedToDeleteTask": "Failed to delete task",
"failedToLoadTasks": "Failed to load tasks",
"failedToUnarchiveTask": "Failed to unarchive task",
"goToFirstPage": "Go to first page",
"goToLastPage": "Go to last page",
"goToNextPage": "Go to next page",
@@ -39,6 +43,12 @@
"sortTitleZA": "Title Z-A",
"sortUpdatedNewest": "Updated newest",
"sortUpdatedOldest": "Updated oldest",
"taskArchived": "Task archived",
"taskArchivedDescription": "The task has been archived successfully.",
"taskUnarchived": "Task unarchived",
"unarchive": "Unarchive",
"unarchivedBranchesMissing_one": "Branch {{branches}} no longer exists locally or on the remote — the next session starts fresh from the base branch.",
"unarchivedBranchesMissing_other": "Branches {{branches}} no longer exist locally or on the remote — the next session starts fresh from the base branch.",
"unarchivedTaskRestored": "The task has been restored.",
"unarchiveTask": "Unarchive task"
}
+3 -1
View File
@@ -55,7 +55,7 @@
"conflictTierMappingsTitle": "Ŵōŕķśƥàćē ţĩēŕ ḿàƥƥĩńĝś:",
"conflictWatchersTitle": "Ŵàţćĥēŕś (ŵĩĺĺ ƀē ďĩśàƀĺēď):",
"conflictUtilityAgentsTitle": "Ũţĩĺĩţŷ àĝēńţś (ŵĩĺĺ ķēēƥ śţàĺē ƀĩńďĩńĝś):",
"disableProfileUtilityWarning": "Ũţĩĺĩţŷ àĝēńţś ũśē ţĥĩś ƥŕōƒĩĺē. Ďĩśàƀĺĩńĝ ĩţ śţōƥś ńēŵ ũţĩĺĩţŷ ćàĺĺś ũńţĩĺ ēàćĥ ƀĩńďĩńĝ ĩś ƒĩχēď.",
"disableProfileUtilityWarning": "Ũţĩĺĩţŷ àĝēńţś ũśē ţĥĩś ƥŕōƒĩĺē. Ďĩśàƀĺĩńĝ ĩţ ŵĩĺĺ śţōƥ ńēŵ ũţĩĺĩţŷ ćàĺĺś ũńţĩĺ ēàćĥ ƀĩńďĩńĝ ĩś ŕēƥàĩŕēď.",
"disableProfileAnyway": "Ďĩśàƀĺē ƥŕōƒĩĺē",
"copied": "Ćōƥĩēď",
"copyInstallCommand": "Ćōƥŷ ĩńśţàĺĺ ćōḿḿàńď",
@@ -99,6 +99,7 @@
"failedToFetchCapabilities": "Ƒàĩĺēď ţō ƒēţćĥ ćàƥàƀĩĺĩţĩēś",
"modelOptionsAuthenticationRequired": "Śĩĝń ĩń ţō ţĥĩś àĝēńţ ţō ĺōàď ḿōďēĺ ōƥţĩōńś.",
"modelOptionsResolutionFailed": "Ḿōďēĺ ōƥţĩōńś ćōũĺď ńōţ ƀē ĺōàďēď.",
"modelSettings": "Ḿōďēĺ śēţţĩńĝś",
"resolvingModelOptions": "Ĺōàďĩńĝ ḿōďēĺ ōƥţĩōńś…",
"retryModelOptions": "Ŕēţŕŷ",
"failedToLoadCommandPreview": "Ƒàĩĺēď ţō ĺōàď ćōḿḿàńď ƥŕēvĩēŵ",
@@ -186,6 +187,7 @@
"searchModes": "Śēàŕćĥ ḿōďēś...",
"selectAModel": "Śēĺēćţ à ḿōďēĺ...",
"selectAModelShort": "Śēĺēćţ à ḿōďēĺ",
"selectModel": "Śēĺēćţ ḿōďēĺ...",
"sessionEnded": "Śēśśĩōń ēńďēď.",
"sessionEndedWithCode": "Śēśśĩōń ēńďēď (ēxĩţ {{code}}).",
"sessionUnavailable": "Śēśśĩōń ĩś ńō ĺōńĝēŕ àvàĩĺàƀĺē.",
@@ -65,6 +65,9 @@
"enabled": "Ēńàƀĺēď",
"enterValidOrganizationUrl": "Ēńţēŕ à vàĺĩď ōŕĝàńĩźàţĩōń ŨŔĹ ţō ōƥēń ĩţś ţōķēń śēţţĩńĝś.",
"executorProfileId": "Ēxēćũţōŕ ƥŕōƒĩĺē ĨĎ",
"failedToLinkPullRequest": "Ƒàĩĺēď ţō ĺĩńķ Àźũŕē ĎēvŌƥś ƥũĺĺ ŕēqũēśţ.",
"failedToLinkWorkItem": "Ƒàĩĺēď ţō ĺĩńķ Àźũŕē ĎēvŌƥś ŵōŕķ ĩţēḿ.",
"failedToLinkWorkItemNoProject": "Ƒàĩĺēď ţō ĺĩńķ Àźũŕē ĎēvŌƥś ŵōŕķ ĩţēḿ: ƥŕōĵēćţ ĩś ḿĩśśĩńĝ.",
"failedToLoadConfig": "Ƒàĩĺēď ţō ĺōàď Àźũŕē ĎēvŌƥś ćōńƒĩĝ: {{error}}",
"failedToLoadQuickActions": "Ƒàĩĺēď ţō ĺōàď qũĩćķ àćţĩōńś.",
"failedToSaveDefaultQueries": "Ƒàĩĺēď ţō śàvē ďēƒàũĺţ qũēŕĩēś",
+78
View File
@@ -4,6 +4,7 @@
"activeSubagents_other": "{{count}} śũƀàĝēńţś ŕũńńĩńĝ",
"activeTasks": "Àćţĩvē Ţàśķś",
"aDirectLocalExecutorProfileIsRequired": "À ďĩŕēćţ ĺōćàĺ ēxēćũţōŕ ƥŕōƒĩĺē ĩś ŕēqũĩŕēď ţō ćŕēàţē àńď ũśē àń ēḿƥţŷ ŕēƥōśĩţōŕŷ.",
"agent": "Àĝēńţ",
"agentDashboard": "àĝēńţ ďàśĥƀōàŕď",
"agentRun": "àĝēńţ ŕũń",
"agentRuns": "àĝēńţ ŕũńś",
@@ -13,6 +14,9 @@
"andMoreFiles_one": "+{{count}} ḿōŕē",
"andMoreFiles_other": "+{{count}} ḿōŕē",
"anEnhancedPromptIsAvailable": "Àń ēńĥàńćēď ƥŕōḿƥţ ĩś àvàĩĺàƀĺē.",
"anErrorOccurred": "Àń ēŕŕōŕ ōććũŕŕēď",
"anUnexpectedErrorOccurred": "Àń ũńēxƥēćţēď ēŕŕōŕ ōććũŕŕēď",
"anUnknownErrorOccurred": "Àń ũńķńōŵń ēŕŕōŕ ōććũŕŕēď",
"apply": "Àƥƥĺŷ",
"autoApproveAllPermissions": "Àũţō-àƥƥŕōvē àĺĺ ƥēŕḿĩśśĩōńś",
"automations": "Àũţōḿàţĩōńś",
@@ -36,9 +40,30 @@
"cliPassthrough": "ĆĹĨ ƥàśśţĥŕōũĝĥ",
"close": "Ćĺōśē",
"closeEsc": "Ćĺōśē (Ēść)",
"commandAddBrowserPanel": "Àďď Ɓŕōŵśēŕ Ƥàńēĺ",
"commandAddBrowserPanelKeywords": "ƀŕōŵśēŕ, ƥŕēvĩēŵ, ŵēƀ, ōƥēń ƀŕōŵśēŕ ƥŕēvĩēŵ, ŵēƀ ƥŕēvĩēŵ, àƥƥ ƥŕēvĩēŵ",
"commandAddChangesPanel": "Àďď Ćĥàńĝēś Ƥàńēĺ",
"commandAddChangesPanelKeywords": "ćĥàńĝēś, ďĩƒƒ, ĝĩţ ćĥàńĝēś, ćĥàńĝēď ƒĩĺēś, śōũŕćē ćōńţŕōĺ, ĝĩţ ďĩƒƒ, ŕēvĩēŵ ćĥàńĝēś",
"commandAddPlanPanel": "Àďď Ƥĺàń Ƥàńēĺ",
"commandAddPlanPanelKeywords": "ƥĺàń, ďōćũḿēńţ, ţàśķ ƥĺàń, ĩḿƥĺēḿēńţàţĩōń ƥĺàń, ƥĺàń ďēţàĩĺś",
"commandAddTerminalPanel": "Àďď Ţēŕḿĩńàĺ Ƥàńēĺ",
"commandAddTerminalPanelKeywords": "ţēŕḿĩńàĺ, śĥēĺĺ, ćōńśōĺē, ńēŵ ţēŕḿĩńàĺ, ōƥēń ţēŕḿĩńàĺ, ćōḿḿàńď ĺĩńē",
"commandAgentsSettingsKeywords": "àĝēńţś, àĝēńţ śēţţĩńĝś, àĝēńţ ƥŕōƒĩĺēś, ĩńśţàĺĺēď àĝēńţś, ćĺàũďē",
"commandArchiveTaskKeywords": "àŕćĥĩvē, àŕćĥĩvē ţàśķ, àŕćĥĩvē ćũŕŕēńţ ţàśķ, ćĺōśē ţàśķ, ƒĩńĩśĥ ţàśķ, ƥũţ àŵàŷ",
"commandCancelTurn": "Ćàńćēĺ Ţũŕń",
"commandCancelTurnKeywords": "ćàńćēĺ, śţōƥ, ţũŕń, ćàńćēĺ àĝēńţ, śţōƥ àĝēńţ, ĩńţēŕŕũƥţ àĝēńţ",
"commandCommitChanges": "Ćōḿḿĩţ Ćĥàńĝēś",
"commandCommitChangesKeywords": "ćōḿḿĩţ, ĝĩţ, śàvē ćĥàńĝēś, ĝĩţ ćōḿḿĩţ",
"commandConfigChatKeywords": "ćōńƒĩĝ ćĥàţ, ćōńƒĩĝ ḿōďē, ćōńƒĩĝũŕē ķàńďēv, ŵōŕķƒĺōŵ ćōńƒĩĝũŕàţĩōń, ḿćƥ ćōńƒĩĝũŕàţĩōń",
"commandCreateFile": "Ćŕēàţē Ƒĩĺē",
"commandCreateFileKeywords": "ćŕēàţē, ńēŵ, ƒĩĺē, àďď",
"commandCreateFilePlaceholder": "Ƒĩĺē ƥàţĥ ŕēĺàţĩvē ţō ŵōŕķśƥàćē ŕōōţ...",
"commandCreateNewTask": "Ćŕēàţē Ńēŵ Ţàśķ",
"commandCreateNewTaskKeywords": "ńēŵ, ćŕēàţē, ţàśķ, àďď",
"commandCreatePr": "Ćŕēàţē ƤŔ",
"commandCreatePrKeywords": "ƥũĺĺ ŕēqũēśţ, ƥŕ, ōƥēń ƥũĺĺ ŕēqũēśţ, śũƀḿĩţ ƥũĺĺ ŕēqũēśţ, ĝĩţ",
"commandCreateSubtask": "Ćŕēàţē Śũƀţàśķ",
"commandCreateSubtaskKeywords": "śũƀţàśķ, ćŕēàţē, ńēŵ śũƀţàśķ, ńēŵ śũƀ-ţàśķ, ćĥĩĺď ţàśķ",
"commandExecutorsSettingsKeywords": "ēxēćũţōŕś, ēxēćũţōŕ ƥŕōƒĩĺēś, ēxēćũţĩōń ēńvĩŕōńḿēńţ, ēńvĩŕōńḿēńţ vàŕĩàƀĺēś, ŕũńţĩḿē, ćōḿƥũţē",
"commandGoToAllTasks": "Ĝō ţō Àĺĺ Ţàśķś",
"commandGoToAllTasksKeywords": "ţàśķś, ĺĩśţ, àĺĺ",
@@ -51,11 +76,21 @@
"commandGoToStats": "Ĝō ţō Śţàţś",
"commandGoToStatsKeywords": "śţàţś, śţàţĩśţĩćś, àńàĺŷţĩćś, ḿēţŕĩćś",
"commandGroupActions": "Àćţĩōńś",
"commandGroupAgent": "Àĝēńţ",
"commandGroupCommands": "Ćōḿḿàńďś",
"commandGroupGit": "Ĝĩţ",
"commandGroupNavigation": "Ńàvĩĝàţĩōń",
"commandGroupPanels": "Ƥàńēĺś",
"commandGroupPreferences": "Ƥŕēƒēŕēńćēś",
"commandGroupSettings": "Śēţţĩńĝś",
"commandGroupTasks": "Ţàśķś",
"commandGroupView": "Vĩēŵ",
"commandGroupWorkspace": "Ŵōŕķśƥàćē",
"commandMergeKeywords": "ḿēŕĝē, ĝĩţ, ƀŕàńćĥ",
"commandNewAgent": "Ńēŵ Àĝēńţ",
"commandNewAgentKeywords": "ńēŵ, àĝēńţ, śēśśĩōń, śţàŕţ àĝēńţ, ńēŵ śēśśĩōń",
"commandOpenRecentTaskSwitcher": "Ōƥēń Ŕēćēńţ Ţàśķ Śŵĩţćĥēŕ",
"commandOpenRecentTaskSwitcherKeywords": "ŕēćēńţ, ţàśķ, śŵĩţćĥēŕ, ĥĩśţōŕŷ, ŕēćēńţ ţàśķś, śŵĩţćĥ ţàśķ, śŵĩţćĥ ŕēćēńţ ţàśķś",
"commandPaletteMode": "Ćōḿḿàńď ƥàĺēţţē ḿōďē",
"commandPreviewAddTerminalPanel": "Àďď Ţēŕḿĩńàĺ Ƥàńēĺ",
"commandPreviewCommitChanges": "Ćōḿḿĩţ Ćĥàńĝēś",
@@ -63,10 +98,19 @@
"commandPreviewPull": "Ƥũĺĺ",
"commandPreviewSearchTasks": "Śēàŕćĥ Ţàśķś",
"commandPromptsSettingsKeywords": "ƥŕōḿƥţś, ƥŕōḿƥţ śēţţĩńĝś, ćũśţōḿ ƥŕōḿƥţś, ƥŕōḿƥţ śńĩƥƥēţś, ƥŕōḿƥţ ţēḿƥĺàţēś",
"commandPullKeywords": "ƥũĺĺ, ĝĩţ, ƥũĺĺ ćĥàńĝēś, ďōŵńĺōàď ćĥàńĝēś",
"commandPushKeywords": "ƥũśĥ, ĝĩţ, ƥũśĥ ćĥàńĝēś, ƥũśĥ ţō ŕēḿōţē, ũƥĺōàď ćĥàńĝēś",
"commandQuickChat": "Qũĩćķ Ćĥàţ",
"commandQuickChatKeywords": "qũĩćķ ćĥàţ, ńēŵ qũĩćķ ćĥàţ, qũĩćķ qũēśţĩōń, àśķ àĝēńţ",
"commandRebaseKeywords": "ŕēƀàśē, ĝĩţ, ƀŕàńćĥ",
"commandSwitchToDarkMode": "Śŵĩţćĥ ţō Ďàŕķ Ḿōďē",
"commandSwitchToKanbanView": "Śŵĩţćĥ ţō Ķàńƀàń Vĩēŵ",
"commandSwitchToKanbanViewKeywords": "ķàńƀàń, ƀōàŕď, vĩēŵ",
"commandSwitchToLightMode": "Śŵĩţćĥ ţō Ĺĩĝĥţ Ḿōďē",
"commandSwitchToListView": "Śŵĩţćĥ ţō Ĺĩśţ Vĩēŵ",
"commandSwitchToListViewKeywords": "ĺĩśţ, ţàƀĺē, vĩēŵ",
"commandSwitchToPipelineView": "Śŵĩţćĥ ţō Ƥĩƥēĺĩńē Vĩēŵ",
"commandSwitchToPipelineViewKeywords": "ƥĩƥēĺĩńē, ĝŕàƥĥ, vĩēŵ",
"commandThemeKeywords": "ţĥēḿē, ćōĺōŕ ţĥēḿē, àƥƥēàŕàńćē",
"commandWorkspaceSettingsKeywords": "ŵōŕķśƥàćē, ŵōŕķśƥàćēś",
"commitsAndPushesToTheCurrent": "Ćōḿḿĩţś àńď ƥũśĥēś ţō ţĥē ćũŕŕēńţ ƀŕàńćĥ.",
@@ -126,6 +170,29 @@
"forwardStdinStdoutStraightToThe": "Ƒōŕŵàŕď śţďĩń/śţďōũţ śţŕàĩĝĥţ ţō ţĥē ĆĹĨ śũƀƥŕōćēśś. Ďĩśàƀĺēś ÀĆƤ.",
"getStarted": "Ĝēţ Śţàŕţēď",
"githubCliNotAuthenticated": "ĜĩţĤũƀ ĆĹĨ ńōţ àũţĥēńţĩćàţēď",
"gitOpAmendCommit": "Àḿēńď ćōḿḿĩţ",
"gitOpCommit": "Ćōḿḿĩţ",
"gitOperationFailedInRepos_one": "Ƒàĩĺēď ĩń {{count}} ŕēƥō — {{summary}}",
"gitOperationFailedInRepos_other": "Ƒàĩĺēď ĩń {{count}} ŕēƥōś — {{summary}}",
"gitOperationPartialDescription_one": "{{operation}} śũććēēďēď ĩń {{succeeded}} ōƒ {{count}} ŕēƥō ({{repos}}); ƒàĩĺēď ĩń {{summary}}",
"gitOperationPartialDescription_other": "{{operation}} śũććēēďēď ĩń {{succeeded}} ōƒ {{count}} ŕēƥōś ({{repos}}); ƒàĩĺēď ĩń {{summary}}",
"gitOperationPartiallySucceeded": "{{operation}} ƥàŕţĩàĺĺŷ śũććēēďēď",
"gitOperationRepoFailure": "{{repo}}: {{error}}",
"gitOperationSucceededInRepos_one": "{{operation}} śũććēēďēď ĩń {{count}} ŕēƥō: {{repos}}",
"gitOperationSucceededInRepos_other": "{{operation}} śũććēēďēď ĩń {{count}} ŕēƥōś: {{repos}}",
"gitOpForcePush": "Ƒōŕćē Ƥũśĥ",
"gitOpHardReset": "Ĥàŕď ŕēśēţ",
"gitOpMerge": "Ḿēŕĝē",
"gitOpPull": "Ƥũĺĺ",
"gitOpPush": "Ƥũśĥ",
"gitOpRebase": "Ŕēƀàśē",
"gitOperationCompleted": "{{operation}} ćōḿƥĺēţēď",
"gitOperationFailed": "{{operation}} ƒàĩĺēď",
"gitOperationRunning": "{{operation}}...",
"gitOperationScoped": "{{operation}} ({{repo}})",
"gitOperationSucceeded": "{{operation}} śũććēśśƒũĺ",
"gitOpRevertCommit": "Ŕēvēŕţ ćōḿḿĩţ",
"gitOpSoftReset": "Śōƒţ ŕēśēţ",
"gitScopeSkipped": "Śķĩƥƥēď {{scope}} ƀēćàũśē à ćĥĩĺď ŕēƥōśĩţōŕŷ ōƥēŕàţĩōń ƒàĩĺēď ({{failedScopes}}).",
"goToFirstPage": "Ĝō ţō ƒĩŕśţ ƥàĝē",
"goToLastPage": "Ĝō ţō ĺàśţ ƥàĝē",
@@ -234,6 +301,7 @@
"probing": "Ƥŕōƀĩńĝ",
"profileName": "Ƥŕōƒĩĺē ńàḿē",
"prompts": "Ƥŕōḿƥţś",
"reference": "Ŕēƒēŕēńćē",
"refreshAriaLabel": "Ŕēƒŕēśĥ {{label}}",
"refreshBranchesGitFetch": "Ŕēƒŕēśĥ ƀŕàńćĥēś (ĝĩţ ƒēţćĥ)",
"refreshingLabel": "Ŕēƒŕēśĥĩńĝ {{label}}...",
@@ -275,6 +343,7 @@
"scopeFiles": "Ƒĩĺēś",
"scopeTitleWithShortcut": "{{label}} ({{shortcut}})",
"scratchWorkspace": "śćŕàţćĥ ŵōŕķśƥàćē",
"script": "Śćŕĩƥţ",
"searchEllipsis": "Śēàŕćĥ…",
"searchFailedEditTheQueryOr": "Śēàŕćĥ ƒàĩĺēď. Ēďĩţ ţĥē qũēŕŷ ōŕ ŕēōƥēń śēàŕćĥ ţō ŕēţŕŷ.",
"searchForFiles": "Śēàŕćĥ ƒōŕ ƒĩĺēś...",
@@ -302,9 +371,12 @@
"skip": "Śķĩƥ",
"stagesAndCommitsChangesUsingConventional": "Śţàĝēś àńď ćōḿḿĩţś ćĥàńĝēś ũśĩńĝ Ćōńvēńţĩōńàĺ Ćōḿḿĩţś.",
"status": "Śţàţũś",
"step": "Śţēƥ",
"subtaskTitle": "Śũƀţàśķ ţĩţĺē",
"switchMode": "Śŵĩţćĥ ḿōďē",
"system": "Śŷśţēḿ",
"task": "Ţàśķ",
"terminal": "Ţēŕḿĩńàĺ",
"todos": "Ţōďōś",
"taskLoading": "Ĺōàďĩńĝ ţàśķ...",
"tasks": "Ţàśķś",
@@ -333,19 +405,25 @@
"typeACommand": "Ţŷƥē à ćōḿḿàńď...",
"typeToSearchFiles": "Ţŷƥē ţō śēàŕćĥ ƒĩĺēś...",
"typeToSearchTaskContents": "Ţŷƥē ţō śēàŕćĥ ţàśķ ćōńţēńţś…",
"unavailable": "Ũńàvàĩĺàƀĺē",
"unknown": "Ũńķńōŵń",
"unknownError": "Ũńķńōŵń ēŕŕōŕ",
"unknownPanel": "Ũńķńōŵń ƥàńēĺ: {{component}}",
"unsavedChanges": "Ũńśàvēď ćĥàńĝēś",
"untitledTask": "Ũńţĩţĺēď ţàśķ",
"usefulCommandsAgentSkills": "Ũśēƒũĺ ćōḿḿàńďś & àĝēńţ śķĩĺĺś",
"useScratchInstead": "Ũśē śćŕàţćĥ ĩńśţēàď",
"useThisFolder": "Ũśē ţĥĩś ƒōĺďēŕ",
"viewFullReleaseOnGithub": "Vĩēŵ ƒũĺĺ ŕēĺēàśē ōń ĜĩţĤũƀ",
"waitsForCiAndAutomatedReviews": "Ŵàĩţś ƒōŕ ĆĨ àńď àũţōḿàţēď ŕēvĩēŵś, ţĥēń ƒĩxēś ƒàĩĺũŕēś àńď àďďŕēśśēś ćōḿḿēńţś.",
"walkthrough": "Ŵàĺķţĥŕōũĝĥ",
"whatSNew": "Ŵĥàţ'ś Ńēŵ",
"whenItsDoneTheAgentOpensAPr": "Ŵĥēń ĩţ'ś ďōńē, ţĥē àĝēńţ ōƥēńś à ƥũĺĺ ŕēqũēśţ ţō <2>{{repo}}</2> ƒōŕ ţĥē ḿàĩńţàĩńēŕś ţō ŕēvĩēŵ, śàvĩńĝ ţĥēḿ ţĩḿē àńď śĥĩƥƥĩńĝ ţĥē ĩḿƥŕōvēḿēńţ ţō ēvēŕŷōńē.",
"workflow": "Ŵōŕķƒĺōŵ",
"workflowsControlTheStepsAutomationAnd": "Ŵōŕķƒĺōŵś ćōńţŕōĺ ţĥē śţēƥś, àũţōḿàţĩōń, àńď àĝēńţ ƀēĥàvĩōŕ ƒōŕ ŷōũŕ ţàśķś. Ŷōũ ćàń àďď ḿōŕē ŵōŕķƒĺōŵś ƒŕōḿ Śēţţĩńĝś.",
"workspace": "Ŵōŕķśƥàćē",
"workspaces": "Ŵōŕķśƥàćēś",
"you": "Ŷōũ",
"youCanUseTheNavigationOr": "Ŷōũ ćàń ũśē ţĥē ńàvĩĝàţĩōń ōŕ ŕēĺōàď ţō ţŕŷ àĝàĩń.",
"yourAgentImplementsItInThe": "Ŷōũŕ àĝēńţ ĩḿƥĺēḿēńţś ĩţ ĩń ţĥē ķàńďēv ŕēƥōśĩţōŕŷ, ŵĩţĥ ţēśţś",
"youVerifyAndTestTheChange": "Ŷōũ vēŕĩƒŷ àńď ţēśţ ţĥē ćĥàńĝē ĩń à śēćōńď ķàńďēv ĩńśţàńćē"
+9
View File
@@ -19,10 +19,17 @@
"copyLine": "Ćōƥŷ ĺĩńē {{lineNumber}}",
"copyPath": "Ćōƥŷ ƥàţĥ",
"default": "ďēƒàũĺţ",
"deleteFailed": "Ďēĺēţē ƒàĩĺēď",
"deleteFile": "Ďēĺēţē ƒĩĺē",
"disableWordWrap": "Ďĩśàƀĺē ŵōŕď ŵŕàƥ",
"enableWordWrap": "Ēńàƀĺē ŵōŕď ŵŕàƥ",
"errorWhileDeletingFile": "Àń ēŕŕōŕ ōććũŕŕēď ŵĥĩĺē ďēĺēţĩńĝ ţĥē ƒĩĺē",
"errorWhileSavingFile": "Àń ēŕŕōŕ ōććũŕŕēď ŵĥĩĺē śàvĩńĝ ţĥē ƒĩĺē",
"failedToDeleteFile": "Ƒàĩĺēď ţō ďēĺēţē ƒĩĺē",
"failedToSaveFile": "Ƒàĩĺēď ţō śàvē ƒĩĺē",
"failedToSendComment": "Ƒàĩĺēď ţō śēńď ćōḿḿēńţ",
"fileSavedOverwritten": "Ƒĩĺē śàvēď (ōvēŕŵŕĩţţēń)",
"fileSavedOverwrittenDescription": "Ţĥē ƒĩĺē ŵàś ḿōďĩƒĩēď ēxţēŕńàĺĺŷ. Ŷōũŕ vēŕśĩōń ŵàś śàvēď.",
"foldUnchangedLines": "Ƒōĺď ũńćĥàńĝēď ĺĩńēś",
"hideDiffIndicators": "Ĥĩďē ďĩƒƒ ĩńďĩćàţōŕś",
"highlight": "Ĥĩĝĥĺĩĝĥţ",
@@ -35,6 +42,7 @@
"noDiffAvailable": "Ńō ďĩƒƒ àvàĩĺàƀĺē",
"noEditorsConfigured": "Ńō ēďĩţōŕś ćōńƒĩĝũŕēď",
"openFolder": "Ōƥēń ƒōĺďēŕ",
"openTaskSessionBeforeSending": "Ōƥēń à ţàśķ śēśśĩōń ƀēƒōŕē śēńďĩńĝ ţō ţĥē àĝēńţ.",
"openWith": "Ōƥēń ŵĩţĥ...",
"pasteLink": "Ƥàśţē ĺĩńķ...",
"pleaseTryAgain": "Ƥĺēàśē ţŕŷ àĝàĩń.",
@@ -43,6 +51,7 @@
"reload": "Ŕēĺōàď",
"revertAllChanges": "Ŕēvēŕţ àĺĺ ćĥàńĝēś",
"revertChanges": "Ŕēvēŕţ ćĥàńĝēś",
"saveFailed": "Śàvē ƒàĩĺēď",
"saving": "Śàvĩńĝ...",
"sentToTheAgent": "Śēńţ ţō ţĥē àĝēńţ.",
"showAllLines": "Śĥōŵ àĺĺ ĺĩńēś",
+3
View File
@@ -180,6 +180,7 @@
"failedToDeleteReviewWatch": "Ƒàĩĺēď ţō ďēĺēţē ŕēvĩēŵ ŵàţćĥ",
"failedToLoadGithubWorkspaceSettings": "Ƒàĩĺēď ţō ĺōàď ĜĩţĤũƀ ŵōŕķśƥàćē śēţţĩńĝś",
"failedToMerge": "Ƒàĩĺēď ţō ḿēŕĝē",
"failedToReRequestReview": "Ƒàĩĺēď ţō ŕē-ŕēqũēśţ ŕēvĩēŵ",
"failedToResetIssueWatch": "Ƒàĩĺēď ţō ŕēśēţ ĩśśũē ŵàţćĥ",
"failedToResetReviewWatch": "Ƒàĩĺēď ţō ŕēśēţ ŕēvĩēŵ ŵàţćĥ",
"failedToSaveDefaultQueries": "Ƒàĩĺēď ţō śàvē ďēƒàũĺţ qũēŕĩēś",
@@ -445,6 +446,7 @@
"reviewCount_other": "{{count}} ŕēvĩēŵś",
"reviewFollowUp": "Ŕēvĩēŵ ƒōĺĺōŵ-ũƥ",
"reviewPermissions": "Ŕēvĩēŵ ƥēŕḿĩśśĩōńś",
"reviewReRequestedFrom": "Ŕēvĩēŵ ŕē-ŕēqũēśţēď ƒŕōḿ {{reviewer}}",
"reviewWatch": "ŕēvĩēŵ ŵàţćĥ",
"reviewWatchCreated": "Ŕēvĩēŵ ŵàţćĥ ćŕēàţēď",
"reviewWatchDeleted": "Ŕēvĩēŵ ŵàţćĥ ďēĺēţēď",
@@ -468,6 +470,7 @@
"selectWorkflow": "Śēĺēćţ ŵōŕķƒĺōŵ",
"selectWorkspace": "Śēĺēćţ ŵōŕķśƥàćē",
"selectedRepositories": "Śēĺēćţēď ŕēƥōśĩţōŕĩēś",
"selectWorkspaceBeforeRequestingReview": "Śēĺēćţ à ŵōŕķśƥàćē ƀēƒōŕē ŕēqũēśţĩńĝ ŕēvĩēŵ.",
"setTheseExactUrlsCreateAClientSecret": "Śēţ ţĥēśē ēxàćţ ŨŔĹś, ćŕēàţē à ćĺĩēńţ śēćŕēţ àńď ŵēƀĥōōķ śēćŕēţ, àńď ďōŵńĺōàď à ƥŕĩvàţē ķēŷ. Ţĥĩś ōńē-ţĩḿē śēţũƥ ēxƥĩŕēś {{expiresAt}}.",
"showGithubApiLimits": "Śĥōŵ ĜĩţĤũƀ ÀƤĨ ĺĩḿĩţś",
"showLess": "Śĥōŵ ĺēśś",
-1
View File
@@ -316,7 +316,6 @@
"mrAutomationExplainAutoFixRounds": "Ēxƥĺàĩń àũţō-ƒĩx ŕōũńďś",
"mrAutomationAutoFixPromptSaveFailed": "Ƒàĩĺēď ţō śàvē àũţō-ƒĩx ƥŕōḿƥţ.",
"mrAutomationAutoFixPromptResetFailed": "Ƒàĩĺēď ţō ŕēśēţ àũţō-ƒĩx ƥŕōḿƥţ.",
"mrPopoverOpenDetail": "Ōƥēń ḿēŕĝē ŕēqũēśţ ďēţàĩĺ",
"mrPopoverOpenInGitlab": "Ōƥēń !{{mriid}} ĩń ĜĩţĹàƀ",
"mrPopoverUnlink": "Ũńĺĩńķ !{{mriid}}",
"mrPopoverPassRate": "Ƥàśś ŕàţē",
+1
View File
@@ -4,6 +4,7 @@
"allRepositories": "Àĺĺ ŕēƥōśĩţōŕĩēś",
"allWorkflows": "Àĺĺ ŵōŕķƒĺōŵś",
"allWorkflowsTitleCase": "Àĺĺ Ŵōŕķƒĺōŵś",
"allWorkspaces": "Àĺĺ ŵōŕķśƥàćēś",
"approvalRequired": "Àƥƥŕōvàĺ Ŕēqũĩŕēď",
"archive": "Àŕćĥĩvē",
"archiveCount_one": "Àŕćĥĩvē {{count}}",

Some files were not shown because too many files have changed in this diff Show More