diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index c06abb42fd..e65e37942f 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -38,6 +38,7 @@ import { AlertDialogAction, } from "@kandev/ui/alert-dialog"; import { IconAlertTriangle } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; function useWorkflowSelection({ store, @@ -572,20 +573,21 @@ function ApprovalWarningDialog({ setMoveError: (error: MoveTaskError | null) => void; handleGoToTask: () => void; }) { + const { t } = useTranslation(); return ( !open && setMoveError(null)}> - Approval Required + {t("kanban:approvalRequired")} {moveError?.message} - Dismiss + {t("kanban:dismiss")} {moveError?.taskId && ( - Go to Task + {t("kanban:goToTask")} )} diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx index 3675ba576c..e10b6ba243 100644 --- a/apps/web/components/kanban-card-content.tsx +++ b/apps/web/components/kanban-card-content.tsx @@ -189,6 +189,7 @@ export function KanbanCardBody({ } function KanbanCardRelationship({ task }: { task: Task }) { + const { t } = useTranslation(); const parentTitle = useAppStore((s) => { if (!task.parentTaskId) return null; return s.kanban.tasks.find((t) => t.id === task.parentTaskId)?.title ?? null; @@ -204,13 +205,14 @@ function KanbanCardRelationship({ task }: { task: Task }) { className="mt-1 flex min-w-0 items-center gap-1 text-[10px] text-muted-foreground" > - Subtask of + {t("kanban:subtaskOf")} {relationshipTitle} ); } function KanbanCardBadges({ task }: { task: Task }) { + const { t } = useTranslation(); const showRow = hasCardBadges(task); if (!showRow) return null; @@ -221,20 +223,26 @@ function KanbanCardBadges({ task }: { task: Task }) { - Queued for {task.queuedForStepTitle ?? "next capacity"} + {t("kanban:queuedForStep", { + step: task.queuedForStepTitle ?? t("kanban:nextCapacity"), + })} )} {task.sessionCount && task.sessionCount > 1 && ( - {task.sessionCount} sessions + {t("kanban:sessionCount", { count: task.sessionCount })} )} {task.reviewStatus === "pending" && task.state !== "IN_PROGRESS" && (
- Approval Required + {t("kanban:approvalRequired")}
)} {task.reviewStatus === "changes_requested" && ( @@ -242,7 +250,7 @@ function KanbanCardBadges({ task }: { task: Task }) { variant="outline" className="border-amber-500 text-amber-600 bg-amber-50 dark:bg-amber-950/50 text-xs h-5" > - Changes Requested + {t("kanban:changesRequested")} )} @@ -448,6 +456,7 @@ type KanbanCardMenuProps = KanbanCardActionProps & { }; function KanbanCardMenu(props: KanbanCardMenuProps) { + const { t } = useTranslation(); const { effectiveMenuOpen, setMenuOpen, isDeleting, isArchiving } = props; const { menuEntries } = props; const isProcessing = isDeleting || isArchiving; @@ -466,7 +475,7 @@ function KanbanCardMenu(props: KanbanCardMenuProps) { className="text-muted-foreground hover:text-foreground hover:bg-muted rounded-sm p-1 -m-1 transition-colors cursor-pointer" onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} - aria-label="More options" + aria-label={t("kanban:moreOptions")} > @@ -489,6 +498,7 @@ function KanbanCardCheckbox({ isSelected?: boolean; onCheckboxClick: (e: React.MouseEvent) => void; }) { + const { t } = useTranslation(); return (
diff --git a/apps/web/components/kanban-card-menu-items.tsx b/apps/web/components/kanban-card-menu-items.tsx index c9897afd1c..c620aaacb4 100644 --- a/apps/web/components/kanban-card-menu-items.tsx +++ b/apps/web/components/kanban-card-menu-items.tsx @@ -40,6 +40,8 @@ import { import { cn } from "@/lib/utils"; import type { PluginTaskMenuContext } from "@/lib/plugins/types"; import { buildEditMenuEntry } from "./kanban-card-edit-submenu"; +import { useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; type ItemEntry = { kind: "item"; @@ -114,14 +116,17 @@ function resolvePluginMenuContext(context?: PluginTaskMenuContext): PluginTaskMe } function StepBadges({ step, isCurrent }: { step: TaskMoveStep; isCurrent: boolean }) { + const { t } = useTranslation(); const hasAutoStart = stepHasAutoStart(step); if (!isCurrent && !hasAutoStart) return null; return ( - {isCurrent && Current} + {isCurrent && ( + {t("kanban:current")} + )} {hasAutoStart && ( - Auto-start + {t("kanban:autoStart")} )} ); @@ -164,7 +169,7 @@ function buildMoveToCurrentWorkflowSubmenu({ key: "move-to", testId: "task-context-move-to", icon: , - label: "Move to", + label: t("kanban:moveTo"), disabled, className: "w-48", children: steps.map((step) => buildStepEntry(step, currentStepId, onMoveToStep)), @@ -191,7 +196,7 @@ function buildWorkflowTargetEntry({ label: {workflow.name}, trailing: ( - No steps + {t("kanban:noSteps")} ), }; @@ -230,7 +235,7 @@ function buildSendToWorkflowSubmenu({ key: "send-to-workflow", testId: "task-context-send-to-workflow", icon: , - label: "Send to workflow", + label: t("kanban:sendToWorkflow"), disabled, className: "w-56", children: targets.map((workflow) => @@ -257,7 +262,7 @@ function buildGitLabMergeRequestLinkEntry({ key: "link-gitlab-merge-request", testId: "task-context-link-gitlab-merge-request", icon: , - label: "GitLab Merge Request", + label: t("kanban:gitlabMergeRequest"), disabled, onSelect: onLinkMergeRequest, }; @@ -297,7 +302,7 @@ function buildLinkSubmenu({ key: "link-github-pull-request", testId: "task-context-link-github-pull-request", icon: , - label: "GitHub Pull Request", + label: t("kanban:githubPullRequest"), disabled, onSelect: onLinkPullRequest, }); @@ -308,7 +313,7 @@ function buildLinkSubmenu({ key: "link-github-issue", testId: "task-context-link-github-issue", icon: , - label: "GitHub Issue", + label: t("kanban:githubIssue"), disabled, onSelect: onLinkIssue, }); @@ -321,7 +326,7 @@ function buildLinkSubmenu({ key: "link-jira-ticket", testId: "task-context-link-jira-ticket", icon: , - label: "Jira Ticket", + label: t("kanban:jiraTicket"), disabled, onSelect: onLinkJiraTicket, }); @@ -332,7 +337,7 @@ function buildLinkSubmenu({ key: "link-linear-issue", testId: "task-context-link-linear-issue", icon: , - label: "Linear Issue", + label: t("kanban:linearIssue"), disabled, onSelect: onLinkLinearIssue, }); @@ -343,7 +348,7 @@ function buildLinkSubmenu({ key: "link-sentry-issue", testId: "task-context-link-sentry-issue", icon: , - label: "Sentry Issue", + label: t("kanban:sentryIssue"), disabled, onSelect: onLinkSentryIssue, }); @@ -353,7 +358,7 @@ function buildLinkSubmenu({ key: "link", testId: "task-context-link", icon: , - label: "Link", + label: t("kanban:link"), disabled, className: "w-56", children, @@ -431,7 +436,7 @@ export function buildKanbanCardMenuEntries({ ) : ( ), - label: "Archive", + label: t("kanban:archive"), disabled: isProcessing || !onArchive, onSelect: onArchive, }); @@ -448,7 +453,7 @@ export function buildKanbanCardMenuEntries({ ) : ( ), - label: "Delete", + label: t("kanban:delete"), destructive: true, disabled: isProcessing || !onDelete, onSelect: onDelete, @@ -475,7 +480,7 @@ function buildDetachEntry({ ) : ( ), - label: "Detach from parent", + label: t("kanban:detachFromParent"), disabled: isProcessing, onSelect: onDetach, }; diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx index 13043c67e1..95c651d793 100644 --- a/apps/web/components/kanban-column.tsx +++ b/apps/web/components/kanban-column.tsx @@ -14,6 +14,7 @@ import { useAppStore } from "@/components/state-provider"; import type { KanbanExternalLinkAvailability } from "./kanban-external-link-availability"; import type { Repository } from "@/lib/types/http"; import { countAdmittedTasks, formatWipCount, isOverWipLimit } from "@/lib/kanban/wip-limit"; +import { useTranslation } from "react-i18next"; export interface WorkflowStep { id: string; @@ -50,6 +51,36 @@ interface KanbanColumnProps { externalLinkAvailability: KanbanExternalLinkAvailability; } +function ColumnHeader({ step, tasks }: { step: WorkflowStep; tasks: Task[] }) { + const { t } = useTranslation(); + const admittedTaskCount = countAdmittedTasks(tasks); + const overWipLimit = isOverWipLimit(admittedTaskCount, step.wip_limit); + const wipCountLabel = formatWipCount(admittedTaskCount, step.wip_limit); + + return ( +
+
+
+

{step.title}

+ + {wipCountLabel} + +
+
+ ); +} + export function KanbanColumn({ step, tasks, @@ -86,9 +117,6 @@ export function KanbanColumn({ // Ordered ids of the cards rendered in this column — the source of truth for // shift-click range selection (matches exactly what the user sees). const columnTaskIds = useMemo(() => tasks.map((t) => t.id), [tasks]); - const admittedTaskCount = countAdmittedTasks(tasks); - const overWipLimit = isOverWipLimit(admittedTaskCount, step.wip_limit); - const wipCountLabel = formatWipCount(admittedTaskCount, step.wip_limit); return (
{/* Column Header */} - {!hideHeader && ( -
-
-
-

{step.title}

- - {wipCountLabel} - -
-
- )} + {!hideHeader && } {/* Tasks */}
diff --git a/apps/web/components/kanban-display-dropdown.tsx b/apps/web/components/kanban-display-dropdown.tsx index 9a800e9343..3404e05c36 100644 --- a/apps/web/components/kanban-display-dropdown.tsx +++ b/apps/web/components/kanban-display-dropdown.tsx @@ -15,21 +15,14 @@ import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; import type { Repository } from "@/lib/types/http"; import type { WorkflowsState } from "@/lib/state/slices"; import type { ComponentProps } from "react"; +import { useTranslation } from "react-i18next"; +import { getRepositoryPlaceholderKey } from "@/lib/kanban/repository-placeholder"; type KanbanDisplayDropdownProps = { triggerSize?: ComponentProps["size"]; currentPage?: "kanban" | "tasks"; }; -function getRepositoryPlaceholder( - repositoriesLoading: boolean, - repositoriesEmpty: boolean, -): string { - if (repositoriesLoading) return "Loading repositories..."; - if (repositoriesEmpty) return "No repositories"; - return "Select repository"; -} - function WorkflowSection({ activeWorkflowId, workflows, @@ -39,18 +32,19 @@ function WorkflowSection({ workflows: WorkflowsState["items"]; onWorkflowChange: (id: string | null) => void; }) { + const { t } = useTranslation(); return (
- Workflow + {t("kanban:workflow")} onRepositoryChange(value as string | "all")} @@ -83,11 +80,13 @@ function RepositorySection({ > - All repositories + {t("kanban:allRepositories")} {repositories.map((repo: Repository) => ( {repo.name} @@ -103,6 +102,7 @@ export function KanbanDisplayDropdown({ triggerSize = "icon", currentPage = "kanban", }: KanbanDisplayDropdownProps) { + const { t } = useTranslation(); const { workflows, activeWorkflowId, @@ -148,7 +148,9 @@ export function KanbanDisplayDropdown({ />
- Preview Panel + + {t("kanban:previewPanel")} +

- When enabled, clicking a task opens the preview panel. When disabled, clicking - navigates directly to the session. + {t("kanban:whenEnabledClickingATaskOpens")}

{currentPage === "tasks" && ( <>
- List rows + + {t("kanban:listRows")} +

- Add repository, pull request, session, parent, and review context to List rows. + {t("kanban:addRepositoryPullRequestSessionParent")}

diff --git a/apps/web/components/kanban-with-preview.tsx b/apps/web/components/kanban-with-preview.tsx index ca0572cd36..66c1c1dac6 100644 --- a/apps/web/components/kanban-with-preview.tsx +++ b/apps/web/components/kanban-with-preview.tsx @@ -26,6 +26,7 @@ import { useEnsureTaskSession, type UseEnsureTaskSessionResult, } from "@/hooks/domains/session/use-ensure-task-session"; +import { useTranslation } from "react-i18next"; type KanbanWithPreviewProps = { initialTaskId?: string; @@ -428,6 +429,7 @@ function FloatingPreviewLayout({ onSessionChange, onResizeMouseDown, }: PreviewLayoutProps) { + const { t } = useTranslation(); return ( <>
@@ -440,7 +442,7 @@ function FloatingPreviewLayout({
) { + const { t } = useTranslation(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showArchiveConfirm, setShowArchiveConfirm] = useState(false); @@ -160,7 +162,7 @@ function TaskActions({ className="cursor-pointer" > - Archive task + {t("kanban:archiveTask")} )} - Delete task + {t("kanban:deleteTask")} @@ -206,6 +208,7 @@ function TaskButton({ isSelected?: boolean; onClick: () => void; }) { + const { t } = useTranslation(); const hasAction = needsAction(task); const sessionCount = task.sessionCount ?? 0; return ( @@ -237,7 +240,7 @@ function TaskButton({ )} {sessionCount > 0 && ( - {sessionCount} {sessionCount === 1 ? "session" : "sessions"} + {t("kanban:sessionCount", { count: sessionCount })} )}
@@ -273,6 +276,7 @@ export function Graph2TaskPipeline({ onToggleSelect, isMultiSelectMode, }: Graph2TaskPipelineProps) { + const { t } = useTranslation(); const currentStepIndex = useMemo( () => steps.findIndex((s) => s.id === task.workflowStepId), [steps, task.workflowStepId], @@ -307,7 +311,7 @@ export function Graph2TaskPipeline({ >
diff --git a/apps/web/components/kanban/kanban-header-mobile.tsx b/apps/web/components/kanban/kanban-header-mobile.tsx index 174d625c99..35841c868f 100644 --- a/apps/web/components/kanban/kanban-header-mobile.tsx +++ b/apps/web/components/kanban/kanban-header-mobile.tsx @@ -14,6 +14,7 @@ import { useConnectionIssueCopy } from "@/components/app-status-bar/connection-s import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; import { workspaceHomeHref } from "@/components/app-sidebar/app-sidebar-workspace-navigation"; import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; type KanbanHeaderMobileProps = { workspaceId?: string; @@ -30,10 +31,11 @@ type KanbanHeaderMobileProps = { }; function MobileBrandLink({ workspaceId }: Pick) { + const { t } = useTranslation(); return ( Kandev @@ -60,6 +62,7 @@ function MobileHeaderActions({ toggleSearch: () => void; setMenuOpen: (open: boolean) => void; }) { + const { t } = useTranslation(); const { issueSeverity } = useAppStatusDrawer(); const issueDetails = useConnectionIssueCopy(issueSeverity); @@ -77,7 +80,7 @@ function MobileHeaderActions({ size="icon-lg" onClick={handleOpenQuickChat} className="cursor-pointer" - aria-label="Quick Chat" + aria-label={t("common:commandQuickChat")} data-testid="mobile-quick-chat-button" > @@ -90,7 +93,7 @@ function MobileHeaderActions({ onClick={toggleSearch} className="cursor-pointer" aria-pressed={isSearchOpen} - aria-label="Search tasks" + aria-label={t("kanban:searchTasks")} data-testid="mobile-search-toggle" > @@ -105,7 +108,11 @@ function MobileHeaderActions({ issueSeverity === "lost" && "text-destructive", issueSeverity === "unstable" && "text-amber-500", )} - aria-label={issueDetails ? `${issueDetails.description} Open menu` : "Open menu"} + aria-label={ + issueDetails + ? t("kanban:openMenuWithStatus", { description: issueDetails.description }) + : t("kanban:openMenu") + } data-connection-severity={issueSeverity === "none" ? undefined : issueSeverity} > diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index 0ee6158ea7..bb655066fd 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -30,6 +30,7 @@ import { useSystemHealthIndicator } from "@/hooks/use-system-health-indicator"; import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; import { TopbarMetrics } from "@/components/system-metrics/topbar-metrics"; import type { ComponentProps, RefObject } from "react"; +import { useTranslation } from "react-i18next"; type KanbanHeaderProps = { workspaceId?: string; @@ -44,13 +45,14 @@ type KanbanHeaderProps = { type ViewToggleItem = { value: string; icon: typeof IconLayoutKanban; - label: string; + /** Resolved at render — a `t()` here would freeze at the boot locale. */ + labelKey: string; }; const VIEW_TOGGLE_ITEMS: ViewToggleItem[] = [ - { value: "kanban", icon: IconLayoutKanban, label: "Kanban" }, - { value: "pipeline", icon: IconTimeline, label: "Pipeline" }, - { value: "list", icon: IconList, label: "List" }, + { value: "kanban", icon: IconLayoutKanban, labelKey: "kanban:kanban" }, + { value: "pipeline", icon: IconTimeline, labelKey: "kanban:pipeline" }, + { value: "list", icon: IconList, labelKey: "kanban:list" }, ]; const WORKBENCH_TOPBAR_CLASSNAME = "h-12 border-b-0 px-3 py-2"; @@ -92,6 +94,7 @@ function ViewToggleGroup({ className?: string; itemClassName?: string; }) { + const { t } = useTranslation(); return ( - {VIEW_TOGGLE_ITEMS.map(({ value, icon: Icon, label }) => ( + {VIEW_TOGGLE_ITEMS.map(({ value, icon: Icon, labelKey }) => ( - {label} + {t(labelKey)} ))} @@ -167,6 +170,7 @@ function TabletHeader({ onOpenHealthDialog: () => void; hideTitle?: boolean; }) { + const { t } = useTranslation(); const isHome = title === "Home"; const handleOpenQuickChat = useQuickChatLauncher(workspaceId); @@ -184,7 +188,7 @@ function TabletHeader({ @@ -201,7 +205,7 @@ function TabletHeader({ size="icon-lg" onClick={handleOpenQuickChat} className="cursor-pointer" - aria-label="Quick Chat" + aria-label={t("common:commandQuickChat")} data-testid="tablet-quick-chat-button" > @@ -223,7 +227,7 @@ function TabletHeader({ className="cursor-pointer" > - Open menu + {t("kanban:openMenu")} } @@ -258,13 +262,14 @@ function DesktopHeader({ onOpenHealthDialog: () => void; hideTitle?: boolean; }) { + const { t } = useTranslation(); const headerRef = useRef(null); const isNarrow = useIsHeaderNarrow(headerRef); const searchInput = onSearchChange ? ( diff --git a/apps/web/components/kanban/mobile-column-tabs.tsx b/apps/web/components/kanban/mobile-column-tabs.tsx index 103be75c3a..23c4999958 100644 --- a/apps/web/components/kanban/mobile-column-tabs.tsx +++ b/apps/web/components/kanban/mobile-column-tabs.tsx @@ -22,6 +22,7 @@ import type { WorkflowStep } from "../kanban-column"; import type { MobileWorkflowNavigation } from "@/lib/kanban/view-registry"; import { formatWipCount, isOverWipLimit } from "@/lib/kanban/wip-limit"; import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; type MobileColumnTabsProps = { steps: WorkflowStep[]; @@ -32,6 +33,7 @@ type MobileColumnTabsProps = { }; function StepCount({ step, count }: { step: WorkflowStep; count: number }) { + const { t } = useTranslation(); const overWipLimit = isOverWipLimit(count, step.wip_limit); const label = formatWipCount(count, step.wip_limit); @@ -42,7 +44,11 @@ function StepCount({ step, count }: { step: WorkflowStep; count: number }) { "h-5 shrink-0 px-1.5 text-xs tabular-nums", overWipLimit && "border-amber-500/50 bg-amber-500/15 text-amber-700 dark:text-amber-300", )} - aria-label={overWipLimit ? `${label} tasks, over WIP limit` : `${label} tasks`} + aria-label={ + overWipLimit + ? t("kanban:tasksOverWipLimit", { label }) + : t("kanban:wipTaskCount", { label }) + } > {label} @@ -65,13 +71,14 @@ function WorkflowOptions({ navigation: MobileWorkflowNavigation; onSelect: (workflowId: string) => void; }) { + const { t } = useTranslation(); return (

- Workflow + {t("kanban:workflow")}

{navigation.workflows.map((workflow) => { const isActive = workflow.id === navigation.activeWorkflowId; @@ -114,6 +121,7 @@ function StepOptions({ onSelect: (index: number) => void; separated: boolean; }) { + const { t } = useTranslation(); return (
- Step + {t("kanban:step")} {steps.length === 0 && ( -

No steps configured.

+

{t("kanban:noStepsConfigured")}

)} {steps.map((step, index) => { const isActive = index === activeIndex; @@ -165,12 +173,13 @@ function NavigatorDrawerContent({ onSelectStep: (index: number) => void; onSelectWorkflow: (workflowId: string) => void; }) { + const { t } = useTranslation(); return ( - Board navigator + {t("kanban:boardNavigator")} - Choose workflow and step shown on board. + {t("kanban:chooseWorkflowAndStepShownOn")}
@@ -196,6 +205,7 @@ export function MobileColumnTabs({ onColumnChange, workflowNavigation, }: MobileColumnTabsProps) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const activeStep = steps[activeIndex] ?? steps[0]; const activeWorkflow = @@ -222,7 +232,7 @@ export function MobileColumnTabs({ className="h-11 w-11 cursor-pointer rounded-xl transition-[background-color,color,border-color,transform] duration-150 ease-out active:scale-[0.96]" disabled={!activeStep || activeIndex === 0} onClick={() => onColumnChange(activeIndex - 1)} - aria-label="Previous step" + aria-label={t("kanban:previousStep")} > @@ -235,8 +245,8 @@ export function MobileColumnTabs({ data-testid="mobile-board-navigator" aria-label={ activeWorkflow - ? `${activeWorkflow.name}, ${stepLabel}. Choose workflow or step.` - : `${stepLabel}. Choose step.` + ? t("kanban:chooseWorkflowOrStep", { name: activeWorkflow.name, stepLabel }) + : t("kanban:chooseStep", { stepLabel }) } > @@ -269,7 +279,7 @@ export function MobileColumnTabs({ className="h-11 w-11 cursor-pointer rounded-xl transition-[background-color,color,border-color,transform] duration-150 ease-out active:scale-[0.96]" disabled={!activeStep || activeIndex === steps.length - 1} onClick={() => onColumnChange(activeIndex + 1)} - aria-label="Next step" + aria-label={t("kanban:nextStep")} > diff --git a/apps/web/components/kanban/mobile-drop-targets.tsx b/apps/web/components/kanban/mobile-drop-targets.tsx index 45e4134917..c1eb6962d8 100644 --- a/apps/web/components/kanban/mobile-drop-targets.tsx +++ b/apps/web/components/kanban/mobile-drop-targets.tsx @@ -3,6 +3,7 @@ import { useDroppable } from "@dnd-kit/core"; import { cn } from "@/lib/utils"; import type { WorkflowStep } from "../kanban-column"; +import { useTranslation } from "react-i18next"; type MobileDropTargetProps = { step: WorkflowStep; @@ -39,6 +40,7 @@ type MobileDropTargetsProps = { }; export function MobileDropTargets({ steps, currentStepId, isDragging }: MobileDropTargetsProps) { + const { t } = useTranslation(); if (!isDragging) return null; return ( @@ -49,7 +51,7 @@ export function MobileDropTargets({ steps, currentStepId, isDragging }: MobileDr ))}

- Drop on a column to move task + {t("kanban:dropOnAColumnToMove")}

); diff --git a/apps/web/components/kanban/mobile-fab.tsx b/apps/web/components/kanban/mobile-fab.tsx index 2f26e2cb50..a8efdf916a 100644 --- a/apps/web/components/kanban/mobile-fab.tsx +++ b/apps/web/components/kanban/mobile-fab.tsx @@ -3,6 +3,7 @@ import { Button } from "@kandev/ui/button"; import { IconPlus } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; type MobileFabProps = { onClick: () => void; @@ -10,6 +11,7 @@ type MobileFabProps = { }; export function MobileFab({ onClick, isDragging = false }: MobileFabProps) { + const { t } = useTranslation(); return ( ); } diff --git a/apps/web/components/kanban/mobile-menu-sheet.tsx b/apps/web/components/kanban/mobile-menu-sheet.tsx index b49f007f96..1813051a96 100644 --- a/apps/web/components/kanban/mobile-menu-sheet.tsx +++ b/apps/web/components/kanban/mobile-menu-sheet.tsx @@ -3,24 +3,13 @@ import { type ReactNode, type RefObject, useRef, useState } from "react"; import { useRouter } from "@/lib/routing/client-router"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer"; -import { Button } from "@kandev/ui/button"; import { Checkbox } from "@kandev/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group"; -import { - IconAlertTriangle, - IconActivity, - IconLayoutKanban, - IconList, - IconStethoscope, - IconTimeline, -} from "@tabler/icons-react"; +import { IconLayoutKanban, IconList, IconTimeline } from "@tabler/icons-react"; import { AppSidebarWorkspacePicker } from "@/components/app-sidebar/app-sidebar-workspace-picker"; import { MobileIntegrationsSection } from "@/components/integrations/integrations-menu"; -import { DestinationRows } from "@/components/navigation/destination-rows"; import { MobilePluginNavSection } from "@/components/plugins/mobile-plugin-nav-section"; -import { useStaticDestinations } from "@/hooks/use-app-destinations"; -import { MOBILE_MENU_UTILITY_SECTIONS } from "@/lib/navigation/surface-policy"; import { TaskSearchInput } from "./task-search-input"; import { MobileTasksListOptions, @@ -32,9 +21,18 @@ import { cn } from "@/lib/utils"; import type { Repository, Task } from "@/lib/types/http"; import type { WorkflowsState } from "@/lib/state/slices"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; -import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider"; -import { useConnectionIssueCopy } from "@/components/app-status-bar/connection-status-item"; import { ImproveKandevDialog } from "@/components/improve-kandev-dialog"; +import { useTranslation } from "react-i18next"; +import { getRepositoryPlaceholderKey } from "@/lib/kanban/repository-placeholder"; +import { MobileUtilityActions } from "./mobile-menu-utility-actions"; +import { + mobileControlClass, + mobileControlIconClass, + mobileFieldClass, + mobileFieldLabelClass, + mobileSectionClass, + mobileSectionTitleClass, +} from "./mobile-menu-styles"; type MobileMenuSheetProps = { open: boolean; onOpenChange: (open: boolean) => void; @@ -48,19 +46,6 @@ type MobileMenuSheetProps = { onOpenHealthDialog: () => void; }; -const mobileSectionClass = "space-y-2"; -const mobileSectionTitleClass = "text-sm font-medium"; -const mobileFieldClass = "space-y-1.5"; -const mobileFieldLabelClass = "text-xs font-medium text-muted-foreground"; -const mobileControlClass = "h-10 w-full px-3 text-sm"; -const mobileControlIconClass = "h-4 w-4 shrink-0"; - -function getRepositoryPlaceholder(loading: boolean, empty: boolean): string { - if (loading) return "Loading repositories..."; - if (empty) return "No repositories"; - return "Select repository"; -} - type MobileDisplayOptionsProps = { activeWorkflowId: string | null; workflows: WorkflowsState["items"]; @@ -96,20 +81,21 @@ function MobileDisplaySelects({ | "showTaskDetails" | "tasksListOptions" >) { + const { t } = useTranslation(); return ( <> {showWorkflow && (
- + onRepositoryChange(value as string | "all")} @@ -129,11 +115,13 @@ function MobileDisplaySelects({ > - All repositories + {t("kanban:allRepositories")} {repositories.map((repo: Repository) => ( {repo.name} @@ -147,6 +135,7 @@ function MobileDisplaySelects({ } function MobileDisplayOptions(props: MobileDisplayOptionsProps) { + const { t } = useTranslation(); const { enablePreviewOnClick, onTogglePreviewOnClick, @@ -158,10 +147,10 @@ function MobileDisplayOptions(props: MobileDisplayOptionsProps) { } = props; return (
- +
- +
{showTaskDetails && (
- +

- Add repository, pull request, session, parent, and review context to List rows. + {t("kanban:addRepositoryPullRequestSessionParent")}

)} @@ -201,15 +190,16 @@ function MobileSearchSection({ onSearchChange?: (query: string) => void; isSearchLoading: boolean; }) { + const { t } = useTranslation(); if (!onSearchChange) return null; return (
- + @@ -218,9 +208,10 @@ function MobileSearchSection({ } function MobileWorkspaceSection({ onOpenChange }: { onOpenChange: (open: boolean) => void }) { + const { t } = useTranslation(); return (
- + onOpenChange(false)} @@ -243,9 +234,10 @@ function MobileViewSection({ onViewChange: (value: string) => void; showPipeline: boolean; }) { + const { t } = useTranslation(); return (
- + - Kanban + {t("kanban:kanban")} {showPipeline && ( - Pipeline + {t("kanban:pipeline")} )} - List + {t("kanban:list")}
); } -function MobileUtilityActions({ - showHealthIndicator, - onOpenHealthDialog, - onOpenImproveKandev, - onOpenChange, -}: { - showHealthIndicator: boolean; - onOpenHealthDialog: () => void; - onOpenImproveKandev: () => void; - onOpenChange: (open: boolean) => void; -}) { - const { enabled: statusDrawerEnabled, issueSeverity, openStatusDrawer } = useAppStatusDrawer(); - const issueDetails = useConnectionIssueCopy(issueSeverity); - // Stats, Settings, and anything else added to the manifest's insight/utility - // sections. Before the manifest, this block hardcoded a Settings link and - // Stats had no phone entry point at all. - const destinations = useStaticDestinations("mobileMenu", MOBILE_MENU_UTILITY_SECTIONS); - const closeSheet = () => onOpenChange(false); - const openHealth = () => { - closeSheet(); - onOpenHealthDialog(); - }; - const openStatus = () => { - closeSheet(); - requestAnimationFrame(openStatusDrawer); - }; - - return ( -
-
Utilities
- {statusDrawerEnabled && ( - - )} - - - {showHealthIndicator && ( - - )} -
- ); -} - function ResponsiveMenuSurface({ isMobile, open, @@ -380,6 +288,7 @@ function ResponsiveMenuSurface({ onOpenAutoFocus: (event: Event) => void; children: ReactNode; }) { + const { t } = useTranslation(); if (isMobile) { return ( @@ -394,7 +303,7 @@ function ResponsiveMenuSurface({ className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-background shadow-2xl shadow-black/20" > - Menu + {t("kanban:menu")}
{children} @@ -415,7 +324,7 @@ function ResponsiveMenuSurface({ className="w-full overflow-y-auto outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:max-w-sm" > - Menu + {t("kanban:menu")} {children} diff --git a/apps/web/components/kanban/mobile-menu-styles.ts b/apps/web/components/kanban/mobile-menu-styles.ts new file mode 100644 index 0000000000..1623883167 --- /dev/null +++ b/apps/web/components/kanban/mobile-menu-styles.ts @@ -0,0 +1,10 @@ +/** + * Shared Tailwind class strings for the mobile menu sheet and the sections + * extracted out of it, so the sheet and its children stay visually identical. + */ +export const mobileSectionClass = "space-y-2"; +export const mobileSectionTitleClass = "text-sm font-medium"; +export const mobileFieldClass = "space-y-1.5"; +export const mobileFieldLabelClass = "text-xs font-medium text-muted-foreground"; +export const mobileControlClass = "h-10 w-full px-3 text-sm"; +export const mobileControlIconClass = "h-4 w-4 shrink-0"; diff --git a/apps/web/components/kanban/mobile-menu-task-list-options.tsx b/apps/web/components/kanban/mobile-menu-task-list-options.tsx index 58ffc57eb8..882671d872 100644 --- a/apps/web/components/kanban/mobile-menu-task-list-options.tsx +++ b/apps/web/components/kanban/mobile-menu-task-list-options.tsx @@ -8,6 +8,7 @@ import { type TasksListGroup, type TasksListSort, } from "@/lib/tasks/tasks-list-options"; +import { useTranslation } from "react-i18next"; export type TasksListDisplayOptions = { showArchived: boolean; @@ -22,14 +23,15 @@ const fieldClass = "space-y-1.5"; const fieldLabelClass = "text-xs font-medium text-muted-foreground"; export function MobileTasksListOptions({ options }: { options: TasksListDisplayOptions }) { + const { t } = useTranslation(); return (
- -

These options affect this task list only.

+ +

{t("kanban:theseOptionsAffectThisTaskList")}

-

- Choose how tasks are ordered in this list. -

+

{t("kanban:chooseHowTasksAreOrderedIn")}

- Group tasks into sections by state, workflow, repository, or none. + {t("kanban:groupTasksIntoSectionsByState")}

diff --git a/apps/web/components/kanban/mobile-menu-utility-actions.tsx b/apps/web/components/kanban/mobile-menu-utility-actions.tsx new file mode 100644 index 0000000000..8ba12f0bec --- /dev/null +++ b/apps/web/components/kanban/mobile-menu-utility-actions.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { IconActivity, IconAlertTriangle, IconStethoscope } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { useTranslation } from "react-i18next"; +import { DestinationRows } from "@/components/navigation/destination-rows"; +import { useStaticDestinations } from "@/hooks/use-app-destinations"; +import { MOBILE_MENU_UTILITY_SECTIONS } from "@/lib/navigation/surface-policy"; +import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider"; +import { useConnectionIssueCopy } from "@/components/app-status-bar/connection-status-item"; +import { cn } from "@/lib/utils"; +import { + mobileControlClass, + mobileControlIconClass, + mobileSectionTitleClass, +} from "./mobile-menu-styles"; + +export function MobileUtilityActions({ + showHealthIndicator, + onOpenHealthDialog, + onOpenImproveKandev, + onOpenChange, +}: { + showHealthIndicator: boolean; + onOpenHealthDialog: () => void; + onOpenImproveKandev: () => void; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useTranslation(); + const { enabled: statusDrawerEnabled, issueSeverity, openStatusDrawer } = useAppStatusDrawer(); + const issueDetails = useConnectionIssueCopy(issueSeverity); + // Stats, Settings, and anything else added to the manifest's insight/utility + // sections. Before the manifest, this block hardcoded a Settings link and + // Stats had no phone entry point at all. + const destinations = useStaticDestinations("mobileMenu", MOBILE_MENU_UTILITY_SECTIONS); + const closeSheet = () => onOpenChange(false); + const openHealth = () => { + closeSheet(); + onOpenHealthDialog(); + }; + const openStatus = () => { + closeSheet(); + requestAnimationFrame(openStatusDrawer); + }; + + return ( +
+
{t("kanban:utilities")}
+ {statusDrawerEnabled && ( + + )} + + + {showHealthIndicator && ( + + )} +
+ ); +} diff --git a/apps/web/components/kanban/mobile-search-bar.tsx b/apps/web/components/kanban/mobile-search-bar.tsx index 225aac229a..991ae6f53c 100644 --- a/apps/web/components/kanban/mobile-search-bar.tsx +++ b/apps/web/components/kanban/mobile-search-bar.tsx @@ -1,6 +1,7 @@ "use client"; import { TaskSearchInput } from "./task-search-input"; +import { useTranslation } from "react-i18next"; type MobileSearchBarProps = { searchQuery: string; @@ -8,12 +9,13 @@ type MobileSearchBarProps = { }; export function MobileSearchBar({ searchQuery, onSearchChange }: MobileSearchBarProps) { + const { t } = useTranslation(); return (
diff --git a/apps/web/components/kanban/swimlane-graph-content.tsx b/apps/web/components/kanban/swimlane-graph-content.tsx index cf4ecf87c8..e8fae3dff2 100644 --- a/apps/web/components/kanban/swimlane-graph-content.tsx +++ b/apps/web/components/kanban/swimlane-graph-content.tsx @@ -25,6 +25,7 @@ import type { MoveTaskError } from "@/hooks/use-drag-and-drop"; import type { KanbanState } from "@/lib/state/slices/kanban/types"; import { useTaskPendingInput } from "@/hooks/use-task-pending-input"; import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order"; +import { useTranslation } from "react-i18next"; export type SwimlaneGraphContentProps = { workflowId: string; @@ -295,6 +296,7 @@ export function SwimlaneGraphContent({ onPreviewTask, onMoveError, }: SwimlaneGraphContentProps) { + const { t } = useTranslation(); const { sensors, clampVertical, @@ -309,7 +311,7 @@ export function SwimlaneGraphContent({ if (tasks.length === 0) { return (
-
No tasks
+
{t("kanban:noTasks")}
); } diff --git a/apps/web/components/kanban/swimlane-graph2-content.tsx b/apps/web/components/kanban/swimlane-graph2-content.tsx index 743f420d95..9c2b6ddb7a 100644 --- a/apps/web/components/kanban/swimlane-graph2-content.tsx +++ b/apps/web/components/kanban/swimlane-graph2-content.tsx @@ -7,6 +7,7 @@ import { ORPHAN_STEP, ORPHAN_STEP_ID, remapOrphanTasks } from "./swimlane-kanban import type { ViewContentProps } from "@/lib/kanban/view-registry"; import type { Task } from "@/components/kanban-card"; import type { WorkflowStep } from "@/components/kanban-column"; +import { useTranslation } from "react-i18next"; export function getGraph2DisplayState( tasks: Task[], @@ -35,6 +36,7 @@ export function SwimlaneGraph2Content({ onToggleSelect, isMultiSelectMode, }: ViewContentProps) { + const { t } = useTranslation(); const { moveTask } = useSwimlaneMove(workflowId, { onMoveError, }); @@ -67,7 +69,7 @@ export function SwimlaneGraph2Content({ if (displayTasks.length === 0) { return (
-
No tasks
+
{t("kanban:noTasks")}
); } diff --git a/apps/web/components/kanban/swimlane-header.tsx b/apps/web/components/kanban/swimlane-header.tsx index 766b50c3fa..a8d735248f 100644 --- a/apps/web/components/kanban/swimlane-header.tsx +++ b/apps/web/components/kanban/swimlane-header.tsx @@ -5,6 +5,7 @@ import { Badge } from "@kandev/ui/badge"; import { Checkbox } from "@kandev/ui/checkbox"; import { IconChevronRight, IconGripVertical } from "@tabler/icons-react"; import { cn } from "@kandev/ui/lib/utils"; +import { useTranslation } from "react-i18next"; export type SwimlaneHeaderProps = { workflowName: string; @@ -25,6 +26,7 @@ export function SwimlaneHeader({ onToggleMultiSelect, isMultiSelectMode, }: SwimlaneHeaderProps) { + const { t } = useTranslation(); return (
- Multi-select + {t("kanban:multiSelect")}
)}
diff --git a/apps/web/components/kanban/swimlane-kanban-content.tsx b/apps/web/components/kanban/swimlane-kanban-content.tsx index 1b6ed16ae3..3daa930294 100644 --- a/apps/web/components/kanban/swimlane-kanban-content.tsx +++ b/apps/web/components/kanban/swimlane-kanban-content.tsx @@ -32,6 +32,7 @@ import { type KanbanExternalLinkAvailability, useKanbanExternalLinkAvailability, } from "@/components/kanban-external-link-availability"; +import { useTranslation } from "react-i18next"; /** * Sentinel step ID used to collect tasks whose workflow_step_id no longer @@ -277,6 +278,7 @@ function MobileKanbanLayout({ activeTask: Task | null; mobileWorkflowNavigation?: MobileWorkflowNavigation; }) { + const { t } = useTranslation(); const taskCounts = useMemo(() => { const counts: Record = {}; for (const step of steps) { @@ -306,7 +308,7 @@ function MobileKanbanLayout({ className="mx-4 my-3 flex flex-1 items-center justify-center rounded-xl border border-dashed border-border/70 px-6 text-center text-sm text-muted-foreground" data-testid="mobile-kanban-no-steps" > - No steps configured. Choose another workflow or add steps in Settings. + {t("kanban:noStepsConfiguredChooseAnotherWorkflow")}
) : ( ; @@ -52,6 +53,7 @@ function BulkArchiveDialog({ isProcessing: boolean; onConfirm: (opts: { cascade: boolean }) => void; }) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); return ( @@ -65,7 +67,7 @@ function BulkArchiveDialog({ data-testid="bulk-archive-button" > - Archive {count} + {t("kanban:archiveCount", { count })} void; }) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); return ( @@ -108,7 +111,7 @@ function BulkDeleteDialog({ data-testid="bulk-delete-button" > - Delete {count} + {t("kanban:deleteCount", { count })} [...selectedIds], [selectedIds]); const executorTypes = useBulkExecutorTypes(taskIds); @@ -161,10 +165,10 @@ export function TaskMultiSelectToolbar({ variant="outline" className="cursor-pointer gap-1.5" disabled={isProcessing || !canMove} - title={!canMove ? "Cannot move tasks from different workflows" : undefined} + title={!canMove ? t("kanban:cannotMoveTasksFromDifferentWorkflows") : undefined} data-testid="bulk-move-button" > - Move to + {t("kanban:moveTo")} @@ -206,7 +210,7 @@ export function TaskMultiSelectToolbar({ className="cursor-pointer ml-1" onClick={onClearSelection} disabled={isProcessing} - aria-label="Clear selection" + aria-label={t("kanban:clearSelection")} data-testid="bulk-clear-selection" > diff --git a/apps/web/components/review/review-comments-overview.tsx b/apps/web/components/review/review-comments-overview.tsx index e09b8c3a67..352cbdf6cc 100644 --- a/apps/web/components/review/review-comments-overview.tsx +++ b/apps/web/components/review/review-comments-overview.tsx @@ -3,6 +3,7 @@ import { IconMessage } from "@tabler/icons-react"; import type { DiffComment } from "@/lib/diff/types"; import { formatLineRange } from "@/lib/diff"; +import { useTranslation } from "react-i18next"; type FileGroup = { key: string; filePath: string; comments: DiffComment[] }; @@ -51,13 +52,14 @@ function fileName(filePath: string): string { * inside the "Fix Comments" hover popover on the review top bar. */ export function ReviewCommentsOverview({ comments }: { comments: DiffComment[] }) { + const { t } = useTranslation(); const groups = groupCommentsByFile(comments); const total = comments.length; if (total === 0) { return (
- No comments to fix yet. + {t("review:noCommentsToFixYet")}
); } @@ -67,10 +69,10 @@ export function ReviewCommentsOverview({ comments }: { comments: DiffComment[] }
- {total} pending review comment{total !== 1 ? "s" : ""} + {t("review:pendingCommentCount", { count: total })} - across {groups.length} file{groups.length !== 1 ? "s" : ""} + {t("review:acrossFileCount", { count: groups.length })}
@@ -103,7 +105,7 @@ export function ReviewCommentsOverview({ comments }: { comments: DiffComment[] }
{formatLineRange(comment.startLine, comment.endLine)} - · {comment.side === "additions" ? "new" : "old"} + · {comment.side === "additions" ? t("review:new") : t("review:old")}

diff --git a/apps/web/components/review/review-dialog-pr-state.tsx b/apps/web/components/review/review-dialog-pr-state.tsx index 20a197b282..78bb4c7913 100644 --- a/apps/web/components/review/review-dialog-pr-state.tsx +++ b/apps/web/components/review/review-dialog-pr-state.tsx @@ -5,6 +5,7 @@ import { IconLoader2, IconRefresh } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import type { TaskPR } from "@/lib/types/github"; import type { ReviewFile } from "./types"; +import { useTranslation } from "react-i18next"; type AutoCloseReviewDialogInput = { open: boolean; @@ -129,12 +130,16 @@ export function ReviewPRDiffBoundary({ onRetry, children, }: ReviewPRDiffBoundaryProps) { + const { t } = useTranslation(); if (selectedPR && loading) { return (

- Loading {selectedPR.repo} #{selectedPR.pr_number} changes… + {t("review:loadingPRChanges", { + repo: selectedPR.repo, + prNumber: selectedPR.pr_number, + })}
); @@ -146,7 +151,7 @@ export function ReviewPRDiffBoundary({ {onRetry && ( )}
diff --git a/apps/web/components/review/review-dialog-surface.tsx b/apps/web/components/review/review-dialog-surface.tsx index 2435cb230c..2ad11dcc9d 100644 --- a/apps/web/components/review/review-dialog-surface.tsx +++ b/apps/web/components/review/review-dialog-surface.tsx @@ -9,6 +9,7 @@ import { ReviewDiffList } from "./review-diff-list"; import { ReviewFileTree } from "./review-file-tree"; import { ReviewPRDiffBoundary, shouldBlockReviewForPR } from "./review-dialog-pr-state"; import { ReviewTopBar } from "./review-top-bar"; +import { useTranslation } from "react-i18next"; type ReviewDialogSurfaceProps = { open: boolean; @@ -44,6 +45,7 @@ function ReviewDialogDiffContent({ | "onRetryPRDiff" | "state" >) { + const { t } = useTranslation(); const blockReviewForPR = shouldBlockReviewForPR(state.allFiles); return ( ) : (
- {state.filter.trim() ? "No files match the filter" : "No changes to review"} + {state.filter.trim() ? t("review:noFilesMatchTheFilter") : t("review:noChangesToReview")}
)}
@@ -77,6 +79,7 @@ function ReviewDialogDiffContent({ } export function ReviewDialogSurface(props: ReviewDialogSurfaceProps) { + const { t } = useTranslation(); const { open, onOpenChange, sessionId, state } = props; const splitRowRef = useRef(null); const sidebar = useReviewSidebarResize(splitRowRef, open, state.reviewSourceKey); @@ -100,7 +103,7 @@ export function ReviewDialogSurface(props: ReviewDialogSurfaceProps) { showCloseButton={false} overlayClassName="bg-black/40" > - Review Changes + {t("review:reviewChangesTitle")} diff --git a/apps/web/components/review/review-diff-header.tsx b/apps/web/components/review/review-diff-header.tsx index 3d98a98ab1..8a712f6ff9 100644 --- a/apps/web/components/review/review-diff-header.tsx +++ b/apps/web/components/review/review-diff-header.tsx @@ -8,6 +8,7 @@ import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { cn } from "@/lib/utils"; import type { ReviewFile } from "./types"; import { FileDiffToolbar } from "./review-diff-toolbar"; +import { useTranslation } from "react-i18next"; export type ReviewExternalLinkContext = { baseBranchByRepo: Record; @@ -60,6 +61,7 @@ function ReviewDiffStats({ file, compact = false }: { file: ReviewFile; compact? } function StaleIndicator({ compact = false }: { compact?: boolean }) { + const { t } = useTranslation(); return ( - changed + {t("review:staleChanged")} ); } @@ -166,6 +168,7 @@ function MobileHeaderIdentity({ onToggleCollapse, toolbar, }: ResponsiveHeaderIdentityProps) { + const { t } = useTranslation(); return (
@@ -200,6 +207,7 @@ function DesktopHeaderIdentity({ onToggleCollapse, toolbar, }: ResponsiveHeaderIdentityProps) { + const { t } = useTranslation(); return ( <>
@@ -211,7 +219,11 @@ function DesktopHeaderIdentity({ diff --git a/apps/web/components/review/review-findings-overview.tsx b/apps/web/components/review/review-findings-overview.tsx index 5e0fc20103..b6df64cfc6 100644 --- a/apps/web/components/review/review-findings-overview.tsx +++ b/apps/web/components/review/review-findings-overview.tsx @@ -5,6 +5,7 @@ import { ReviewFindingSeverityBadge } from "@/components/diff/review-finding-sev import { findingFileKey, sortFindings } from "@/lib/review/findings"; import { formatLineRange } from "@/lib/diff"; import type { TaskReviewFinding } from "@/lib/types/review"; +import { useTranslation } from "react-i18next"; type FindingFileGroup = { key: string; filePath: string; findings: TaskReviewFinding[] }; @@ -60,12 +61,15 @@ export function ReviewFindingsOverview({ findings: TaskReviewFinding[]; onNavigate: (finding: TaskReviewFinding) => void; }) { + const { t } = useTranslation(); const groups = groupOpenFindingsByFile(findings); const total = groups.reduce((sum, g) => sum + g.findings.length, 0); if (total === 0) { return ( -
No open findings.
+
+ {t("review:noOpenFindings")} +
); } @@ -74,10 +78,10 @@ export function ReviewFindingsOverview({
- {total} open finding{total !== 1 ? "s" : ""} + {t("review:openFindingCount", { count: total })} - across {groups.length} file{groups.length !== 1 ? "s" : ""} + {t("review:acrossFileCount", { count: groups.length })}
diff --git a/apps/web/components/review/review-fix-comments-button.tsx b/apps/web/components/review/review-fix-comments-button.tsx index d39e91ed98..dd559d0009 100644 --- a/apps/web/components/review/review-fix-comments-button.tsx +++ b/apps/web/components/review/review-fix-comments-button.tsx @@ -7,6 +7,7 @@ import { Popover, PopoverAnchor, PopoverContent } from "@kandev/ui/popover"; import type { DiffComment } from "@/lib/diff/types"; import { useHoverPopover } from "@/hooks/domains/github/use-hover-popover"; import { ReviewCommentsOverview } from "./review-comments-overview"; +import { useTranslation } from "react-i18next"; const COMMENTS_HOVER_OPEN_DELAY_MS = 150; const COMMENTS_HOVER_CLOSE_DELAY_MS = 150; @@ -30,6 +31,7 @@ export function FixCommentsButton({ getPendingComments, onFixComments, }: FixCommentsButtonProps) { + const { t } = useTranslation(); const { open, onOpenChange, onTriggerEnter, onTriggerLeave, onContentEnter, onContentLeave } = useHoverPopover({ openDelayMs: COMMENTS_HOVER_OPEN_DELAY_MS, @@ -67,7 +69,7 @@ export function FixCommentsButton({ data-testid="review-fix-comments-button" > - Fix Comments + {t("review:fixComments")} {commentCount} diff --git a/apps/web/components/review/review-markdown-diff-preview-content.tsx b/apps/web/components/review/review-markdown-diff-preview-content.tsx index ce4420f851..b20b171c7a 100644 --- a/apps/web/components/review/review-markdown-diff-preview-content.tsx +++ b/apps/web/components/review/review-markdown-diff-preview-content.tsx @@ -1,12 +1,14 @@ import { MarkdownPreviewRenderer } from "@/components/task/markdown-preview-content"; import type { ReviewMarkdownPreview } from "./review-markdown-diff-preview"; +import { useTranslation } from "react-i18next"; export function ReviewMarkdownDiffPreviewContent({ preview }: { preview: ReviewMarkdownPreview }) { + const { t } = useTranslation(); return (
{preview.isPartial && (

- Showing changed Markdown fragments. Unchanged content is not included in this diff. + {t("review:markdownPreviewPartialNotice")}

)}
@@ -17,7 +19,7 @@ export function ReviewMarkdownDiffPreviewContent({ preview }: { preview: ReviewM > {preview.fragments.length > 1 && (

- Changed section {index + 1} + {t("review:changedSection", { index: index + 1 })}

)} diff --git a/apps/web/components/review/review-pr-selector.tsx b/apps/web/components/review/review-pr-selector.tsx index a45fd3f0f2..c9d0f76c9c 100644 --- a/apps/web/components/review/review-pr-selector.tsx +++ b/apps/web/components/review/review-pr-selector.tsx @@ -15,6 +15,7 @@ import { cn } from "@kandev/ui/lib/utils"; import { prIdentitySlug, prTaskKey } from "@/components/github/pr-utils"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import type { TaskPR } from "@/lib/types/github"; +import { useTranslation } from "react-i18next"; type ReviewPRSelectorProps = { prs: TaskPR[]; @@ -35,6 +36,7 @@ export function ReviewPRSelector({ compact = false, testIdPrefix = "review-pr-selector", }: ReviewPRSelectorProps) { + const { t } = useTranslation(); const { isMobile, isFinePointer } = useResponsiveBreakpoint(); if (prs.length < 2 || !selectedPR) return null; @@ -53,7 +55,10 @@ export function ReviewPRSelector({ variant="outline" data-testid={`${testIdPrefix}-trigger`} data-pr-number={selectedPR.pr_number} - aria-label={`Review pull request ${selectedPR.repo} #${selectedPR.pr_number}`} + aria-label={t("review:reviewPullRequestFor", { + repo: selectedPR.repo, + prNumber: selectedPR.pr_number, + })} className={cn( "max-w-full cursor-pointer gap-1.5 px-2 transition-transform active:scale-[0.96]", triggerHeight, @@ -76,7 +81,7 @@ export function ReviewPRSelector({ data-testid={`${testIdPrefix}-menu`} className="max-h-[calc(100dvh-1rem)] w-80 max-w-[calc(100vw-1rem)] overflow-y-auto overscroll-contain sm:max-h-80" > - Review pull request + {t("review:reviewPullRequest")} - No inference-capable agent is configured for review.{" "} - - Settings → Utility Agents - + + No inference-capable agent is configured for review.{" "} + +

); } if (code === "review_no_changes") { return (

- No changes to review yet. + {t("review:noChangesToReviewYet")}

); } @@ -85,6 +88,7 @@ function RunTrigger({ compact: boolean; onStart: () => void; }) { + const { t } = useTranslation(); return ( @@ -94,7 +98,7 @@ function RunTrigger({ className="cursor-pointer gap-1 px-2" onClick={onStart} disabled={disabled} - aria-label="Review these changes with an agent" + aria-label={t("review:reviewTheseChangesWithAnAgent")} data-testid="review-run-changes" > {busy ? ( @@ -102,11 +106,11 @@ function RunTrigger({ ) : ( )} - {!compact && Review changes} + {!compact && {t("review:reviewChanges")}} - {busy ? "Review in progress" : "Review these changes with an agent"} + {busy ? t("review:reviewInProgress") : t("review:reviewTheseChangesWithAnAgent")} ); @@ -120,12 +124,13 @@ function RunTrigger({ function resolveNotice( notice: { code: string; message: string } | null, activeRun: TaskReviewRun | null, + t: TFunction, ): { code: string; message: string } | null { if (notice) return notice; if (activeRun?.status !== "failed") return null; return { code: activeRun.error_code, - message: activeRun.error_message || "The review failed", + message: activeRun.error_message || t("review:theReviewFailed"), }; } @@ -135,9 +140,10 @@ export function ReviewRunButton({ activeRun, compact = false, }: ReviewRunButtonProps) { + const { t } = useTranslation(); const { notice, starting, start, clearNotice } = useRunReview(taskId, sessionId); const running = isRunActive(activeRun); - const shown = resolveNotice(notice, activeRun); + const shown = resolveNotice(notice, activeRun, t); const handleCancel = useCallback(async () => { if (!activeRun) return; @@ -165,7 +171,7 @@ export function ReviewRunButton({ data-testid="review-cancel-run" > - Cancel + {t("common:cancel")} )} diff --git a/apps/web/components/review/review-top-bar.tsx b/apps/web/components/review/review-top-bar.tsx index 9fcd2d4e03..0d5f1b2d5a 100644 --- a/apps/web/components/review/review-top-bar.tsx +++ b/apps/web/components/review/review-top-bar.tsx @@ -30,6 +30,7 @@ import { ReviewRunButton } from "./review-run-button"; import { ReviewFindingsButton } from "./review-findings-button"; import { ReviewPRSelector } from "./review-pr-selector"; import type { TaskPR } from "@/lib/types/github"; +import { useTranslation } from "react-i18next"; type ReviewTopBarProps = { sessionId: string; @@ -73,6 +74,7 @@ type ReviewSettingsMenuProps = { }; function ReviewSettingsMenu({ reviewAutoMarkOnScroll, onToggleAutoMark }: ReviewSettingsMenuProps) { + const { t } = useTranslation(); return ( @@ -89,7 +91,7 @@ function ReviewSettingsMenu({ reviewAutoMarkOnScroll, onToggleAutoMark }: Review }} > - Auto-mark reviewed on scroll + {t("review:autoMarkReviewedOnScroll")} @@ -99,6 +101,7 @@ function ReviewSettingsMenu({ reviewAutoMarkOnScroll, onToggleAutoMark }: Review type ReviewProgressProps = { reviewedCount: number; totalCount: number }; function ReviewProgress({ reviewedCount, totalCount }: ReviewProgressProps) { + const { t } = useTranslation(); const progressPercent = totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0; return (
@@ -109,7 +112,7 @@ function ReviewProgress({ reviewedCount, totalCount }: ReviewProgressProps) { />
- {reviewedCount} of {totalCount} files reviewed + {t("review:filesReviewed", { reviewedCount, totalCount })}
); @@ -121,6 +124,7 @@ function ReviewDisplayControls({ onToggleWordWrap, onToggleSplitView, }: Pick) { + const { t } = useTranslation(); return ( <> @@ -134,7 +138,7 @@ function ReviewDisplayControls({ - Toggle word wrap + {t("review:toggleWordWrap")} @@ -152,7 +156,7 @@ function ReviewDisplayControls({ - {splitView ? "Switch to unified view" : "Switch to split view"} + {splitView ? t("review:switchToUnifiedView") : t("review:switchToSplitView")} @@ -166,8 +170,11 @@ function ReviewWalkthroughButton({ onRequestWalkthrough: (() => void) | undefined; disabled?: boolean; }) { + const { t } = useTranslation(); if (!onRequestWalkthrough) return null; - const tooltip = disabled ? "Loading changed files..." : "Walk me through these changes"; + const tooltip = disabled + ? t("review:loadingChangedFiles") + : t("review:walkMeThroughTheseChanges"); return ( @@ -180,7 +187,7 @@ function ReviewWalkthroughButton({ size="sm" variant="ghost" className="px-2 cursor-pointer" - aria-label="Walk me through these review changes" + aria-label={t("review:walkMeThroughTheseReviewChanges")} data-testid="review-request-walkthrough" disabled={disabled} onClick={onRequestWalkthrough} @@ -216,6 +223,7 @@ export const ReviewTopBar = memo(function ReviewTopBar({ onSelectPR, prDiffLoading, }: ReviewTopBarProps) { + const { t } = useTranslation(); const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const { activeRun, findings } = useTaskReview(activeTaskId); const reviewRunning = isRunActive(activeRun); @@ -279,7 +287,7 @@ export const ReviewTopBar = memo(function ReviewTopBar({ variant="ghost" className="px-2 cursor-pointer" onClick={onClose} - aria-label="Close review" + aria-label={t("review:closeReview")} > diff --git a/apps/web/components/review/types.ts b/apps/web/components/review/types.ts index df8e4cde5c..c1558292f5 100644 --- a/apps/web/components/review/types.ts +++ b/apps/web/components/review/types.ts @@ -1,4 +1,5 @@ import { djb2Hash } from "@/lib/utils/hash"; +import { t } from "@/lib/i18n"; import type { FileChangeStatus } from "@/lib/utils/file-change-status"; export type ReviewFile = { @@ -98,15 +99,15 @@ export function splitReviewFileKey(key: string): { repositoryName: string; path: export function diffSkipReasonLabel(reason?: string): string { switch (reason) { case "too_large": - return "File too large to diff (>10 MB)"; + return t("review:diffSkipTooLarge"); case "binary": - return "Binary file — not diffable"; + return t("review:diffSkipBinary"); case "truncated": - return "Diff truncated (>256 KB)"; + return t("review:diffSkipTruncated"); case "budget_exceeded": - return "Diff skipped — too many changed files"; + return t("review:diffSkipBudgetExceeded"); default: - return "Loading diff..."; + return t("review:diffLoading"); } } @@ -134,17 +135,17 @@ export function reviewDiffUnavailableLabel(file: ReviewFile): string { if (file.diff_skip_reason) return diffSkipReasonLabel(file.diff_skip_reason); switch (file.status) { case "added": - return "Added file has no textual diff"; + return t("review:noTextualDiffAdded"); case "untracked": - return "Untracked file has no textual diff"; + return t("review:noTextualDiffUntracked"); case "deleted": - return "Deleted file has no textual diff"; + return t("review:noTextualDiffDeleted"); case "renamed": return file.old_path - ? `Moved from ${file.old_path}; no textual changes` - : "File moved; no textual changes"; + ? t("review:noTextualDiffMovedFrom", { oldPath: file.old_path }) + : t("review:noTextualDiffMoved"); case "modified": - return "No textual diff available"; + return t("review:noTextualDiffModified"); default: { const exhaustiveStatus: never = file.status; return exhaustiveStatus; diff --git a/apps/web/components/review/walkthrough-overlay.tsx b/apps/web/components/review/walkthrough-overlay.tsx index 23d6f7e2fa..dd4aef0372 100644 --- a/apps/web/components/review/walkthrough-overlay.tsx +++ b/apps/web/components/review/walkthrough-overlay.tsx @@ -19,6 +19,7 @@ import { WalkthroughFloatingWindow } from "@/components/diff/walkthrough-floatin import { clearOpenWalkthroughTaskId, setOpenWalkthroughTaskId } from "@/lib/walkthrough-open-state"; import { cn } from "@kandev/ui/lib/utils"; import type { TaskWalkthrough } from "@/lib/types/http"; +import { useTranslation } from "react-i18next"; type WalkthroughOverlayProps = { /** The task whose walkthrough launcher should be shown. */ @@ -45,6 +46,7 @@ function WalkthroughLauncher({ onToggle, stepCount, }: WalkthroughLauncherProps) { + const { t } = useTranslation(); return (