diff --git a/apps/web/app/office/agents/[id]/runs/components/tasks-touched.tsx b/apps/web/app/office/agents/[id]/runs/components/tasks-touched.tsx index 9bdb057d4f..b785cd32bf 100644 --- a/apps/web/app/office/agents/[id]/runs/components/tasks-touched.tsx +++ b/apps/web/app/office/agents/[id]/runs/components/tasks-touched.tsx @@ -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 }) { - {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)])} - {task.priority || "—"} + + {task.priority ? t(PRIORITY_LABEL_KEYS[task.priority] ?? "office:none") : "—"} + ); } @@ -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 diff --git a/apps/web/app/office/lib/budget-labels.test.ts b/apps/web/app/office/lib/budget-labels.test.ts new file mode 100644 index 0000000000..e2abec2281 --- /dev/null +++ b/apps/web/app/office/lib/budget-labels.test.ts @@ -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(); + }); +}); diff --git a/apps/web/app/office/lib/label-keys.ts b/apps/web/app/office/lib/label-keys.ts index 4bbd538e0e..d841a73874 100644 --- a/apps/web/app/office/lib/label-keys.ts +++ b/apps/web/app/office/lib/label-keys.ts @@ -68,3 +68,20 @@ export const CONCURRENCY_POLICY_LABEL_KEYS: Record = { 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 = { + monthly: "office:budgetPeriodMonthly", + total: "office:budgetPeriodTotal", +}; + +export const BUDGET_ACTION_LABEL_KEYS: Record = { + notify_only: "office:budgetActionNotifyOnly", + pause_agent: "office:budgetActionPauseAgent", + block_new_tasks: "office:budgetActionBlockNewTasks", +}; diff --git a/apps/web/app/office/workspace/costs/budget-policy-card.tsx b/apps/web/app/office/workspace/costs/budget-policy-card.tsx index fbf8a2cd5d..2ebb8ea35f 100644 --- a/apps/web/app/office/workspace/costs/budget-policy-card.tsx +++ b/apps/web/app/office/workspace/costs/budget-policy-card.tsx @@ -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) {t("office:remainingAmount", { amount: formatDollars(remaining) })}
- {/* `period` and `actionOnExceed` are wire enums; only the field - labels are copy, so the values are interpolated as-is. */} - {t("office:periodValue", { period: policy.period })} + {/* 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. */} + + {t("office:periodValue", { + period: BUDGET_PERIOD_LABEL_KEYS[policy.period] + ? t(BUDGET_PERIOD_LABEL_KEYS[policy.period]) + : policy.period, + })} + {t("office:alertPercent", { percent: policy.alertThresholdPct })} - {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, + })}
diff --git a/apps/web/app/office/workspace/org/org-node-card.tsx b/apps/web/app/office/workspace/org/org-node-card.tsx index f142f0d225..2d85a9e59e 100644 --- a/apps/web/app/office/workspace/org/org-node-card.tsx +++ b/apps/web/app/office/workspace/org/org-node-card.tsx @@ -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 && (

- {agent.executorPreference.type.replace(/_/g, " ")} + {getExecutorLabel(agent.executorPreference.type)}

)} diff --git a/apps/web/app/tasks/tasks-page-client.tsx b/apps/web/app/tasks/tasks-page-client.tsx index cc357ae225..7bed90bd04 100644 --- a/apps/web/app/tasks/tasks-page-client.tsx +++ b/apps/web/app/tasks/tasks-page-client.tsx @@ -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", }); diff --git a/apps/web/components/app-sidebar/sections/tasks-view-picker.tsx b/apps/web/components/app-sidebar/sections/tasks-view-picker.tsx index 401630d767..227fe930bd 100644 --- a/apps/web/components/app-sidebar/sections/tasks-view-picker.tsx +++ b/apps/web/components/app-sidebar/sections/tasks-view-picker.tsx @@ -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 (
@@ -81,7 +82,7 @@ export function TasksViewPicker() { className="cursor-pointer gap-2 text-xs" > - {view.name} + {sidebarViewName(view, t)} ); })} diff --git a/apps/web/components/app-status-bar/use-app-status-bar-order.ts b/apps/web/components/app-status-bar/use-app-status-bar-order.ts index 11337c61dc..961748f18c 100644 --- a/apps/web/components/app-status-bar/use-app-status-bar-order.ts +++ b/apps/web/components/app-status-bar/use-app-status-bar-order.ts @@ -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(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], diff --git a/apps/web/components/azure-devops/azure-devops-task-launcher.tsx b/apps/web/components/azure-devops/azure-devops-task-launcher.tsx index 595a464c87..2404c8c397 100644 --- a/apps/web/components/azure-devops/azure-devops-task-launcher.tsx +++ b/apps/web/components/azure-devops/azure-devops-task-launcher.tsx @@ -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"), ); } } diff --git a/apps/web/components/github/use-pr-scoped-review-request.ts b/apps/web/components/github/use-pr-scoped-review-request.ts index 795a6f64b7..091a6a94b5 100644 --- a/apps/web/components/github/use-pr-scoped-review-request.ts +++ b/apps/web/components/github/use-pr-scoped-review-request.ts @@ -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 { diff --git a/apps/web/components/homepage-commands.test.tsx b/apps/web/components/homepage-commands.test.tsx index 107caf64dd..f46a264b7e 100644 --- a/apps/web/components/homepage-commands.test.tsx +++ b/apps/web/components/homepage-commands.test.tsx @@ -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(); + + 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); + }); }); diff --git a/apps/web/components/homepage-commands.tsx b/apps/web/components/homepage-commands.tsx index e3ca33b29e..6634a117a5 100644 --- a/apps/web/components/homepage-commands.tsx +++ b/apps/web/components/homepage-commands.tsx @@ -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: , 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: , - 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: , - 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: , - 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); diff --git a/apps/web/components/kanban/kanban-header-mobile.test.tsx b/apps/web/components/kanban/kanban-header-mobile.test.tsx index 77f45998d3..cc5e7ca233 100644 --- a/apps/web/components/kanban/kanban-header-mobile.test.tsx +++ b/apps/web/components/kanban/kanban-header-mobile.test.tsx @@ -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( { }); 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"); diff --git a/apps/web/components/kanban/kanban-header-mobile.tsx b/apps/web/components/kanban/kanban-header-mobile.tsx index 8ee5a65ef2..e890c210b3 100644 --- a/apps/web/components/kanban/kanban-header-mobile.tsx +++ b/apps/web/components/kanban/kanban-header-mobile.tsx @@ -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; diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index ccf90e77c7..df22fdc08b 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -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) { @@ -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 ?
{searchInput}
: 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 }; diff --git a/apps/web/components/kanban/mobile-column-tabs.tsx b/apps/web/components/kanban/mobile-column-tabs.tsx index 23c4999958..6e869aef54 100644 --- a/apps/web/components/kanban/mobile-column-tabs.tsx +++ b/apps/web/components/kanban/mobile-column-tabs.tsx @@ -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); diff --git a/apps/web/components/model-config-selector.tsx b/apps/web/components/model-config-selector.tsx index 8ed81ad29b..ddef6869db 100644 --- a/apps/web/components/model-config-selector.tsx +++ b/apps/web/components/model-config-selector.tsx @@ -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 ( `«${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[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[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[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[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[0]["git"], + t: markerT, + }).map((cmd) => cmd.group), + ); + + expect(groups.size).toBe(1); + }); +}); diff --git a/apps/web/components/session-commands.tsx b/apps/web/components/session-commands.tsx index 007929e52a..5c6e8c34b1 100644 --- a/apps/web/components/session-commands.tsx +++ b/apps/web/components/session-commands.tsx @@ -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; type PanelActions = ReturnType; -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: , - 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: , - 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: , - 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: , - 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: , - 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: , - 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: , - 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: , - 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: , - 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: , - 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: , - 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: , - 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: , - 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: , - 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(() => { 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 = [ diff --git a/apps/web/components/settings/layouts/layout-profile-list.tsx b/apps/web/components/settings/layouts/layout-profile-list.tsx index 9f36533c67..18d9ca5977 100644 --- a/apps/web/components/settings/layouts/layout-profile-list.tsx +++ b/apps/web/components/settings/layouts/layout-profile-list.tsx @@ -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({ > - {profile.name} + {builtInLayoutProfileName(profile, t)} {t("settings:builtIn")} {override && {t("settings:customized")}} @@ -118,7 +119,9 @@ function BuiltInProfileList({ )} - {profile.description} + + {profile.descriptionKey ? t(profile.descriptionKey) : profile.description} + ); diff --git a/apps/web/components/settings/layouts/use-layout-settings.ts b/apps/web/components/settings/layouts/use-layout-settings.ts index baeb63328c..0be2da3d2e 100644 --- a/apps/web/components/settings/layouts/use-layout-settings.ts +++ b/apps/web/components/settings/layouts/use-layout-settings.ts @@ -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(); diff --git a/apps/web/components/task/changes-git-credential-display.ts b/apps/web/components/task/changes-git-credential-display.ts index 6f5728ae09..71d328fe82 100644 --- a/apps/web/components/task/changes-git-credential-display.ts +++ b/apps/web/components/task/changes-git-credential-display.ts @@ -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: diff --git a/apps/web/components/task/changes-panel-hooks.test.ts b/apps/web/components/task/changes-panel-hooks.test.ts new file mode 100644 index 0000000000..d6afd1c018 --- /dev/null +++ b/apps/web/components/task/changes-panel-hooks.test.ts @@ -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]/); + } + }); +}); diff --git a/apps/web/components/task/changes-panel-hooks.ts b/apps/web/components/task/changes-panel-hooks.ts index aee3f44a80..ee0b8aaa74 100644 --- a/apps/web/components/task/changes-panel-hooks.ts +++ b/apps/web/components/task/changes-panel-hooks.ts @@ -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, 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); diff --git a/apps/web/components/task/changes-panel-repo-groups.tsx b/apps/web/components/task/changes-panel-repo-groups.tsx index 41d8efc987..ae3f2e9196 100644 --- a/apps/web/components/task/changes-panel-repo-groups.tsx +++ b/apps/web/components/task/changes-panel-repo-groups.tsx @@ -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 (
  • @@ -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- diff --git a/apps/web/components/task/chat/messages/permission-request-message.tsx b/apps/web/components/task/chat/messages/permission-request-message.tsx index 75a761dc7a..bb76fd7d36 100644 --- a/apps/web/components/task/chat/messages/permission-request-message.tsx +++ b/apps/web/components/task/chat/messages/permission-request-message.tsx @@ -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 ( diff --git a/apps/web/components/task/chat/messages/status-message.tsx b/apps/web/components/task/chat/messages/status-message.tsx index 8d634f9c04..909a151ad7 100644 --- a/apps/web/components/task/chat/messages/status-message.tsx +++ b/apps/web/components/task/chat/messages/status-message.tsx @@ -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 }; diff --git a/apps/web/components/task/chat/messages/tool-call-message.tsx b/apps/web/components/task/chat/messages/tool-call-message.tsx index bac85ac5cd..8c87930eaa 100644 --- a/apps/web/components/task/chat/messages/tool-call-message.tsx +++ b/apps/web/components/task/chat/messages/tool-call-message.tsx @@ -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; diff --git a/apps/web/components/task/chat/messages/tool-subagent-message.tsx b/apps/web/components/task/chat/messages/tool-subagent-message.tsx index b6e2c02cd2..adc19f28e6 100644 --- a/apps/web/components/task/chat/messages/tool-subagent-message.tsx +++ b/apps/web/components/task/chat/messages/tool-subagent-message.tsx @@ -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(null); diff --git a/apps/web/components/task/chat/session-search-hits.tsx b/apps/web/components/task/chat/session-search-hits.tsx index eb1c4d4365..cd7af97150 100644 --- a/apps/web/components/task/chat/session-search-hits.tsx +++ b/apps/web/components/task/chat/session-search-hits.tsx @@ -89,7 +89,7 @@ function HitAuthor({ ); } - const label = agentLabel?.trim() || "Agent"; + const label = agentLabel?.trim() || t("common:agent"); return ( {agentName && } diff --git a/apps/web/components/task/chat/task-mention-items.ts b/apps/web/components/task/chat/task-mention-items.ts index f1916ba2ee..250a1ed3f4 100644 --- a/apps/web/components/task/chat/task-mention-items.ts +++ b/apps/web/components/task/chat/task-mention-items.ts @@ -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)); }; diff --git a/apps/web/components/task/chat/tiptap-entity-reference-extension.tsx b/apps/web/components/task/chat/tiptap-entity-reference-extension.tsx index 15259a5e2a..9e820077f4 100644 --- a/apps/web/components/task/chat/tiptap-entity-reference-extension.tsx +++ b/apps/web/components/task/chat/tiptap-entity-reference-extension.tsx @@ -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 ( diff --git a/apps/web/components/task/dockview-header-actions.tsx b/apps/web/components/task/dockview-header-actions.tsx index eae7bf16a1..aa382901f1 100644 --- a/apps/web/components/task/dockview-header-actions.tsx +++ b/apps/web/components/task/dockview-header-actions.tsx @@ -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, diff --git a/apps/web/components/task/dockview-watermark.tsx b/apps/web/components/task/dockview-watermark.tsx index 5795e54efc..2f0c92ac8f 100644 --- a/apps/web/components/task/dockview-watermark.tsx +++ b/apps/web/components/task/dockview-watermark.tsx @@ -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); diff --git a/apps/web/components/task/file-browser.tsx b/apps/web/components/task/file-browser.tsx index 6aad0609a1..e5eee7e518 100644 --- a/apps/web/components/task/file-browser.tsx +++ b/apps/web/components/task/file-browser.tsx @@ -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; }; -function executeMoveFiles(params: MoveFilesParams, toast: ReturnType["toast"]) { +function executeMoveFiles( + params: MoveFilesParams, + toast: ReturnType["toast"], + t: TFunction, +) { const { sources, targetPath, treeState, setSelectedPaths, onRenameFile } = params; const snapshot = treeState.tree; @@ -172,8 +177,8 @@ function executeMoveFiles(params: MoveFilesParams, toast: ReturnType !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 { 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) => void, onRenameFile?: (oldPath: string, newPath: string) => Promise, ) { + const { t } = useTranslation(); const { toast } = useToast(); const [isDragging, setIsDragging] = useState(false); const [dragOverPath, setDragOverPath] = useState(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 { diff --git a/apps/web/components/task/mode-selector.tsx b/apps/web/components/task/mode-selector.tsx index 26ab5d37fb..ba90a6cd71 100644 --- a/apps/web/components/task/mode-selector.tsx +++ b/apps/web/components/task/mode-selector.tsx @@ -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 ( diff --git a/apps/web/components/task/new-session-dialog.tsx b/apps/web/components/task/new-session-dialog.tsx index ab38d25532..2cb3aff5df 100644 --- a/apps/web/components/task/new-session-dialog.tsx +++ b/apps/web/components/task/new-session-dialog.tsx @@ -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); diff --git a/apps/web/components/task/recent-task-switcher-hooks.ts b/apps/web/components/task/recent-task-switcher-hooks.ts index 48c7fa32de..9de306705e 100644 --- a/apps/web/components/task/recent-task-switcher-hooks.ts +++ b/apps/web/components/task/recent-task-switcher-hooks.ts @@ -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, }, ], diff --git a/apps/web/components/task/sidebar-filter/sidebar-filter-bar.tsx b/apps/web/components/task/sidebar-filter/sidebar-filter-bar.tsx index 966f36c948..26b0b5aaf9 100644 --- a/apps/web/components/task/sidebar-filter/sidebar-filter-bar.tsx +++ b/apps/web/components/task/sidebar-filter/sidebar-filter-bar.tsx @@ -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), }); } diff --git a/apps/web/components/task/sidebar-filter/sidebar-view-chips.tsx b/apps/web/components/task/sidebar-filter/sidebar-view-chips.tsx index de8be7467d..90c4ed88f5 100644 --- a/apps/web/components/task/sidebar-filter/sidebar-view-chips.tsx +++ b/apps/web/components/task/sidebar-filter/sidebar-view-chips.tsx @@ -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} > - {view.name} + {sidebarViewName(view, t)} ); } diff --git a/apps/web/components/task/sidebar-filter/view-manager.tsx b/apps/web/components/task/sidebar-filter/view-manager.tsx index 0253bfb0b1..807c52a05c 100644 --- a/apps/web/components/task/sidebar-filter/view-manager.tsx +++ b/apps/web/components/task/sidebar-filter/view-manager.tsx @@ -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 ( <> - {activeView?.name ?? "—"} + {activeView ? sidebarViewName(activeView, t) : "—"} {hasDraft && ( 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({ <> - {USER_DISPLAY_NAME} + {t(USER_DISPLAY_NAME_KEY)} ); diff --git a/apps/web/components/task/task-session-sidebar-archived-item.ts b/apps/web/components/task/task-session-sidebar-archived-item.ts index 10388fd192..1170efac96 100644 --- a/apps/web/components/task/task-session-sidebar-archived-item.ts +++ b/apps/web/components/task/task-session-sidebar-archived-item.ts @@ -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, ): TaskSwitcherItem { return { id: s.archivedTaskId!, - title: s.archivedTaskTitle ?? "Archived task", + title: s.archivedTaskTitle ?? t("task:archivedTask"), state: undefined, sessionState: undefined, description: undefined, diff --git a/apps/web/components/task/terminal-tab.tsx b/apps/web/components/task/terminal-tab.tsx index 0ec82917fb..03aa822cee 100644 --- a/apps/web/components/task/terminal-tab.tsx +++ b/apps/web/components/task/terminal-tab.tsx @@ -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 diff --git a/apps/web/components/task/use-tunnel-actions.ts b/apps/web/components/task/use-tunnel-actions.ts index d085c4d479..ef238b91e4 100644 --- a/apps/web/components/task/use-tunnel-actions.ts +++ b/apps/web/components/task/use-tunnel-actions.ts @@ -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) => { diff --git a/apps/web/components/vcs-split-button.tsx b/apps/web/components/vcs-split-button.tsx index 95ebb8495f..a780aa5d7d 100644 --- a/apps/web/components/vcs-split-button.tsx +++ b/apps/web/components/vcs-split-button.tsx @@ -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, 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 }; diff --git a/apps/web/components/vcs/vcs-dialogs.tsx b/apps/web/components/vcs/vcs-dialogs.tsx index 8034138bcf..cf17b41d80 100644 --- a/apps/web/components/vcs/vcs-dialogs.tsx +++ b/apps/web/components/vcs/vcs-dialogs.tsx @@ -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 - // "