feat(i18n): localize review dialog and kanban board (#2299)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-06 00:35:20 +02:00
committed by GitHub
parent 80e4d77bb1
commit d57ddfb9cb
46 changed files with 949 additions and 338 deletions
+5 -3
View File
@@ -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 (
<AlertDialog open={!!moveError} onOpenChange={(open) => !open && setMoveError(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<IconAlertTriangle className="h-5 w-5 text-amber-500" />
Approval Required
{t("kanban:approvalRequired")}
</AlertDialogTitle>
<AlertDialogDescription>{moveError?.message}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Dismiss</AlertDialogCancel>
<AlertDialogCancel>{t("kanban:dismiss")}</AlertDialogCancel>
{moveError?.taskId && (
<AlertDialogAction onClick={handleGoToTask}>Go to Task</AlertDialogAction>
<AlertDialogAction onClick={handleGoToTask}>{t("kanban:goToTask")}</AlertDialogAction>
)}
</AlertDialogFooter>
</AlertDialogContent>
+18 -8
View File
@@ -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"
>
<IconSubtask className="h-3 w-3 shrink-0" />
<span className="shrink-0 font-medium">Subtask of</span>
<span className="shrink-0 font-medium">{t("kanban:subtaskOf")}</span>
<span className="min-w-0 truncate">{relationshipTitle}</span>
</div>
);
}
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 }) {
<Badge
variant="secondary"
className="text-xs h-5"
title={`Queued for ${task.queuedForStepTitle ?? `workflow step ${task.queuedForStepId}`}`}
title={t("kanban:queuedForStep", {
step:
task.queuedForStepTitle ??
t("kanban:workflowStepFallback", { stepId: task.queuedForStepId }),
})}
>
Queued for {task.queuedForStepTitle ?? "next capacity"}
{t("kanban:queuedForStep", {
step: task.queuedForStepTitle ?? t("kanban:nextCapacity"),
})}
</Badge>
)}
{task.sessionCount && task.sessionCount > 1 && (
<Badge variant="secondary" className="text-xs h-5">
{task.sessionCount} sessions
{t("kanban:sessionCount", { count: task.sessionCount })}
</Badge>
)}
{task.reviewStatus === "pending" && task.state !== "IN_PROGRESS" && (
<div className="flex items-center gap-1 text-amber-700 dark:text-amber-600">
<IconAlertCircle className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium">Approval Required</span>
<span className="text-[10px] font-medium">{t("kanban:approvalRequired")}</span>
</div>
)}
{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")}
</Badge>
)}
</div>
@@ -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")}
>
<IconDots className="h-4 w-4" />
</button>
@@ -489,6 +498,7 @@ function KanbanCardCheckbox({
isSelected?: boolean;
onCheckboxClick: (e: React.MouseEvent) => void;
}) {
const { t } = useTranslation();
return (
<div
className="mt-0.5 shrink-0"
@@ -498,7 +508,7 @@ function KanbanCardCheckbox({
>
<Checkbox
checked={!!isSelected}
aria-label={`Select task ${taskTitle}`}
aria-label={t("kanban:selectTask", { title: taskTitle })}
className="cursor-pointer border-muted-foreground/50"
/>
</div>
+20 -15
View File
@@ -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 (
<span className="ml-auto flex items-center gap-1 text-[10px] text-muted-foreground">
{isCurrent && <span data-testid={`task-context-step-current-${step.id}`}>Current</span>}
{isCurrent && (
<span data-testid={`task-context-step-current-${step.id}`}>{t("kanban:current")}</span>
)}
{hasAutoStart && (
<span data-testid={`task-context-step-autostart-${step.id}`}>Auto-start</span>
<span data-testid={`task-context-step-autostart-${step.id}`}>{t("kanban:autoStart")}</span>
)}
</span>
);
@@ -164,7 +169,7 @@ function buildMoveToCurrentWorkflowSubmenu({
key: "move-to",
testId: "task-context-move-to",
icon: <IconArrowRight className="mr-2 h-4 w-4" />,
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: <span className="flex-1 truncate">{workflow.name}</span>,
trailing: (
<span data-testid="task-context-disabled-reason" className="ml-2 text-[10px]">
No steps
{t("kanban:noSteps")}
</span>
),
};
@@ -230,7 +235,7 @@ function buildSendToWorkflowSubmenu({
key: "send-to-workflow",
testId: "task-context-send-to-workflow",
icon: <IconLogicBuffer className="mr-2 h-4 w-4" />,
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: <IconBrandGitlab className="mr-2 h-4 w-4" />,
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: <IconGitPullRequest className="mr-2 h-4 w-4" />,
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: <IconCircleDot className="mr-2 h-4 w-4" />,
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: <IconTicket className="mr-2 h-4 w-4" />,
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: <IconCircleDot className="mr-2 h-4 w-4" />,
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: <IconBrandSentry className="mr-2 h-4 w-4" />,
label: "Sentry Issue",
label: t("kanban:sentryIssue"),
disabled,
onSelect: onLinkSentryIssue,
});
@@ -353,7 +358,7 @@ function buildLinkSubmenu({
key: "link",
testId: "task-context-link",
icon: <IconLink className="mr-2 h-4 w-4" />,
label: "Link",
label: t("kanban:link"),
disabled,
className: "w-56",
children,
@@ -431,7 +436,7 @@ export function buildKanbanCardMenuEntries({
) : (
<IconArchive className="mr-2 h-4 w-4" />
),
label: "Archive",
label: t("kanban:archive"),
disabled: isProcessing || !onArchive,
onSelect: onArchive,
});
@@ -448,7 +453,7 @@ export function buildKanbanCardMenuEntries({
) : (
<IconTrash className="mr-2 h-4 w-4" />
),
label: "Delete",
label: t("kanban:delete"),
destructive: true,
disabled: isProcessing || !onDelete,
onSelect: onDelete,
@@ -475,7 +480,7 @@ function buildDetachEntry({
) : (
<IconUnlink className="mr-2 h-4 w-4" />
),
label: "Detach from parent",
label: t("kanban:detachFromParent"),
disabled: isProcessing,
onSelect: onDetach,
};
+32 -23
View File
@@ -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 (
<div className="flex items-center justify-between pb-2 mb-3 px-1">
<div className="flex items-center gap-2">
<div className={cn("w-2 h-2 rounded-full", step.color)} />
<h2 className="font-semibold text-sm">{step.title}</h2>
<Badge
variant="secondary"
className={cn(
"text-xs tabular-nums",
overWipLimit &&
"border-amber-500/50 bg-amber-500/15 text-amber-700 dark:text-amber-300",
)}
aria-label={
overWipLimit ? t("kanban:tasksOverWipLimit", { label: wipCountLabel }) : undefined
}
title={overWipLimit ? t("kanban:overWipLimit") : undefined}
>
{wipCountLabel}
</Badge>
</div>
</div>
);
}
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 (
<div
@@ -101,26 +129,7 @@ export function KanbanColumn({
)}
>
{/* Column Header */}
{!hideHeader && (
<div className="flex items-center justify-between pb-2 mb-3 px-1">
<div className="flex items-center gap-2">
<div className={cn("w-2 h-2 rounded-full", step.color)} />
<h2 className="font-semibold text-sm">{step.title}</h2>
<Badge
variant="secondary"
className={cn(
"text-xs tabular-nums",
overWipLimit &&
"border-amber-500/50 bg-amber-500/15 text-amber-700 dark:text-amber-300",
)}
aria-label={overWipLimit ? `${wipCountLabel} tasks, over WIP limit` : undefined}
title={overWipLimit ? "Over WIP limit" : undefined}
>
{wipCountLabel}
</Badge>
</div>
</div>
)}
{!hideHeader && <ColumnHeader step={step} tasks={tasks} />}
{/* Tasks */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden space-y-2 px-1 pt-1">
+25 -22
View File
@@ -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<typeof Button>["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 (
<div className="space-y-1.5">
<DropdownMenuLabel className="px-0 text-foreground">Workflow</DropdownMenuLabel>
<DropdownMenuLabel className="px-0 text-foreground">{t("kanban:workflow")}</DropdownMenuLabel>
<Select
value={activeWorkflowId ?? "all"}
onValueChange={(value) => onWorkflowChange(value === "all" ? null : value)}
>
<SelectTrigger data-testid="display-workflow-filter" className="w-full border-border">
<SelectValue placeholder="Select workflow" />
<SelectValue placeholder={t("kanban:selectWorkflow")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Workflows</SelectItem>
<SelectItem value="all">{t("kanban:allWorkflowsTitleCase")}</SelectItem>
{workflows.map((workflow: WorkflowsState["items"][number]) => (
<SelectItem key={workflow.id} value={workflow.id}>
{workflow.name}
@@ -73,9 +67,12 @@ function RepositorySection({
repositoriesLoading: boolean;
onRepositoryChange: (value: string | "all") => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-1.5">
<DropdownMenuLabel className="px-0 text-foreground">Repository</DropdownMenuLabel>
<DropdownMenuLabel className="px-0 text-foreground">
{t("kanban:repository")}
</DropdownMenuLabel>
<Select
value={repositoryValue}
onValueChange={(value) => onRepositoryChange(value as string | "all")}
@@ -83,11 +80,13 @@ function RepositorySection({
>
<SelectTrigger data-testid="display-repository-filter" className="w-full border-border">
<SelectValue
placeholder={getRepositoryPlaceholder(repositoriesLoading, repositories.length === 0)}
placeholder={t(
getRepositoryPlaceholderKey(repositoriesLoading, repositories.length === 0),
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All repositories</SelectItem>
<SelectItem value="all">{t("kanban:allRepositories")}</SelectItem>
{repositories.map((repo: Repository) => (
<SelectItem key={repo.id} value={repo.id}>
{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({
/>
<DropdownMenuSeparator />
<div className="space-y-1.5">
<DropdownMenuLabel className="px-0 text-foreground">Preview Panel</DropdownMenuLabel>
<DropdownMenuLabel className="px-0 text-foreground">
{t("kanban:previewPanel")}
</DropdownMenuLabel>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={enablePreviewOnClick ?? false}
@@ -156,27 +158,28 @@ export function KanbanDisplayDropdown({
onTogglePreviewOnClick?.(!!checked);
}}
/>
<span className="text-sm text-foreground">Open preview on click</span>
<span className="text-sm text-foreground">{t("kanban:openPreviewOnClick")}</span>
</label>
<p className="text-xs text-muted-foreground pl-6">
When enabled, clicking a task opens the preview panel. When disabled, clicking
navigates directly to the session.
{t("kanban:whenEnabledClickingATaskOpens")}
</p>
</div>
{currentPage === "tasks" && (
<>
<DropdownMenuSeparator />
<div className="space-y-1.5">
<DropdownMenuLabel className="px-0 text-foreground">List rows</DropdownMenuLabel>
<DropdownMenuLabel className="px-0 text-foreground">
{t("kanban:listRows")}
</DropdownMenuLabel>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={tasksListShowDetails}
onCheckedChange={(checked) => onToggleTasksListShowDetails(checked === true)}
/>
<span className="text-sm text-foreground">Show task details</span>
<span className="text-sm text-foreground">{t("kanban:showTaskDetails")}</span>
</label>
<p className="pl-6 text-xs text-muted-foreground">
Add repository, pull request, session, parent, and review context to List rows.
{t("kanban:addRepositoryPullRequestSessionParent")}
</p>
</div>
</>
+3 -1
View File
@@ -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 (
<>
<div className="flex-1 overflow-hidden" style={{ width: `${kanbanWidth}px` }}>
@@ -440,7 +442,7 @@ function FloatingPreviewLayout({
<div
className="fixed inset-0 bg-black/30 z-30"
onClick={onClose}
aria-label="Close preview"
aria-label={t("kanban:closePreview")}
/>
<div
className="fixed top-0 right-0 bottom-[var(--app-status-bar-height)] z-40 flex bg-background shadow-2xl"
@@ -20,6 +20,7 @@ import { Graph2Connector } from "./graph2-connector";
import { isOrphanMoveTarget } from "./swimlane-kanban-content";
import type { Task } from "@/components/kanban-card";
import type { WorkflowStep } from "@/components/kanban-column";
import { useTranslation } from "react-i18next";
type ConnectorType = "past" | "transition" | "future";
@@ -138,6 +139,7 @@ function TaskActions({
Graph2TaskPipelineProps,
"task" | "onDeleteTask" | "onArchiveTask" | "isDeleting" | "isArchiving"
>) {
const { t } = useTranslation();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
@@ -160,7 +162,7 @@ function TaskActions({
className="cursor-pointer"
>
<IconArchive className="h-3.5 w-3.5 mr-2" />
Archive task
{t("kanban:archiveTask")}
</DropdownMenuItem>
)}
<DropdownMenuItem
@@ -169,7 +171,7 @@ function TaskActions({
className="text-destructive focus:text-destructive cursor-pointer"
>
<IconTrash className="h-3.5 w-3.5 mr-2" />
Delete task
{t("kanban:deleteTask")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -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 && (
<span className="text-[10px] text-muted-foreground/60">
{sessionCount} {sessionCount === 1 ? "session" : "sessions"}
{t("kanban:sessionCount", { count: sessionCount })}
</span>
)}
</div>
@@ -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({
>
<Checkbox
checked={!!isSelected}
aria-label={`Select task ${task.title}`}
aria-label={t("kanban:selectTask", { title: task.title })}
className="cursor-pointer border-muted-foreground/50"
/>
</div>
@@ -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<KanbanHeaderMobileProps, "workspaceId">) {
const { t } = useTranslation();
return (
<Link
href={workspaceHomeHref(workspaceId ? { id: workspaceId } : undefined)}
aria-label="Kandev home"
aria-label={t("kanban:kandevHome")}
className="relative z-10 shrink-0 cursor-pointer text-[15px] font-semibold leading-none transition-colors hover:text-foreground/80"
>
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"
>
<IconMessageCircle className="h-4 w-4" />
@@ -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"
>
<IconSearch className="h-4 w-4" />
@@ -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}
>
<IconMenu2 className="h-4 w-4" />
+15 -10
View File
@@ -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 (
<ToggleGroup
type="single"
@@ -101,7 +104,7 @@ function ViewToggleGroup({
size={size}
className={className}
>
{VIEW_TOGGLE_ITEMS.map(({ value, icon: Icon, label }) => (
{VIEW_TOGGLE_ITEMS.map(({ value, icon: Icon, labelKey }) => (
<ToggleGroupItem
key={value}
value={value}
@@ -114,7 +117,7 @@ function ViewToggleGroup({
<Icon className="h-4 w-4" />
</span>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
<TooltipContent>{t(labelKey)}</TooltipContent>
</Tooltip>
</ToggleGroupItem>
))}
@@ -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({
<TaskSearchInput
value={searchQuery}
onChange={onSearchChange}
placeholder="Search..."
placeholder={t("kanban:searchPlaceholder")}
isLoading={isSearchLoading}
className="hidden md:flex w-48 lg:w-56 [&_input]:h-8"
/>
@@ -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"
>
<IconMessageCircle className="h-4 w-4" />
@@ -223,7 +227,7 @@ function TabletHeader({
className="cursor-pointer"
>
<IconMenu2 className="h-4 w-4" />
<span className="sr-only">Open menu</span>
<span className="sr-only">{t("kanban:openMenu")}</span>
</Button>
</>
}
@@ -258,13 +262,14 @@ function DesktopHeader({
onOpenHealthDialog: () => void;
hideTitle?: boolean;
}) {
const { t } = useTranslation();
const headerRef = useRef<HTMLElement>(null);
const isNarrow = useIsHeaderNarrow(headerRef);
const searchInput = onSearchChange ? (
<TaskSearchInput
value={searchQuery}
onChange={onSearchChange}
placeholder="Search tasks..."
placeholder={t("kanban:searchTasksPlaceholder")}
isLoading={isSearchLoading}
className={`${isNarrow ? "w-44" : "w-72 xl:w-80"} [&_input]:h-8`}
/>
@@ -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}
</Badge>
@@ -65,13 +71,14 @@ function WorkflowOptions({
navigation: MobileWorkflowNavigation;
onSelect: (workflowId: string) => void;
}) {
const { t } = useTranslation();
return (
<section aria-labelledby="mobile-workflow-heading">
<h3
id="mobile-workflow-heading"
className="px-3 pb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Workflow
{t("kanban:workflow")}
</h3>
{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 (
<section
className={cn(separated && "mt-3 border-t border-border/70 pt-3")}
@@ -123,10 +131,10 @@ function StepOptions({
id="mobile-step-heading"
className="px-3 pb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Step
{t("kanban:step")}
</h3>
{steps.length === 0 && (
<p className="px-3 py-3 text-sm text-muted-foreground">No steps configured.</p>
<p className="px-3 py-3 text-sm text-muted-foreground">{t("kanban:noStepsConfigured")}</p>
)}
{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 (
<DrawerContent data-testid="mobile-board-navigator-drawer" className="max-h-[85dvh]">
<DrawerHeader className="pb-2 text-left">
<DrawerTitle className="text-balance">Board navigator</DrawerTitle>
<DrawerTitle className="text-balance">{t("kanban:boardNavigator")}</DrawerTitle>
<DrawerDescription className="text-pretty">
Choose workflow and step shown on board.
{t("kanban:chooseWorkflowAndStepShownOn")}
</DrawerDescription>
</DrawerHeader>
<div className="min-h-0 overflow-y-auto px-2 pb-[max(1rem,env(safe-area-inset-bottom))]">
@@ -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")}
>
<IconChevronLeft className="h-4 w-4" />
</Button>
@@ -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 })
}
>
<span className="flex min-w-0 items-center gap-2.5 text-left">
@@ -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")}
>
<IconChevronRight className="h-4 w-4" />
</Button>
@@ -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
))}
</div>
<p className="text-xs text-muted-foreground text-center mt-2">
Drop on a column to move task
{t("kanban:dropOnAColumnToMove")}
</p>
</div>
);
+3 -1
View File
@@ -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 (
<Button
onClick={onClick}
@@ -29,7 +31,7 @@ export function MobileFab({ onClick, isDragging = false }: MobileFabProps) {
}}
>
<IconPlus className="h-6 w-6" />
<span className="sr-only">Add task</span>
<span className="sr-only">{t("kanban:addTask")}</span>
</Button>
);
}
+41 -132
View File
@@ -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 && (
<div className={mobileFieldClass}>
<label className={mobileFieldLabelClass}>Workflow</label>
<label className={mobileFieldLabelClass}>{t("kanban:workflow")}</label>
<Select
value={activeWorkflowId ?? "all"}
onValueChange={(value) => onWorkflowChange(value === "all" ? null : value)}
>
<SelectTrigger className={mobileControlClass}>
<SelectValue placeholder="All workflows" />
<SelectValue placeholder={t("kanban:allWorkflows")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All workflows</SelectItem>
<SelectItem value="all">{t("kanban:allWorkflows")}</SelectItem>
{workflows.map((workflow: WorkflowsState["items"][number]) => (
<SelectItem key={workflow.id} value={workflow.id}>
{workflow.name}
@@ -121,7 +107,7 @@ function MobileDisplaySelects({
)}
<div className={mobileFieldClass}>
<label className={mobileFieldLabelClass}>Repository</label>
<label className={mobileFieldLabelClass}>{t("kanban:repository")}</label>
<Select
value={repositoryValue}
onValueChange={(value) => onRepositoryChange(value as string | "all")}
@@ -129,11 +115,13 @@ function MobileDisplaySelects({
>
<SelectTrigger className={mobileControlClass}>
<SelectValue
placeholder={getRepositoryPlaceholder(repositoriesLoading, repositories.length === 0)}
placeholder={t(
getRepositoryPlaceholderKey(repositoriesLoading, repositories.length === 0),
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All repositories</SelectItem>
<SelectItem value="all">{t("kanban:allRepositories")}</SelectItem>
{repositories.map((repo: Repository) => (
<SelectItem key={repo.id} value={repo.id}>
{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 (
<div className="space-y-4">
<label className={mobileSectionTitleClass}>Display Options</label>
<label className={mobileSectionTitleClass}>{t("kanban:displayOptions")}</label>
<MobileDisplaySelects {...selectProps} />
<div className={mobileFieldClass}>
<label className={mobileFieldLabelClass}>Preview Panel</label>
<label className={mobileFieldLabelClass}>{t("kanban:previewPanel")}</label>
<label className="flex h-10 cursor-pointer items-center gap-3 rounded-md px-0 text-sm font-medium">
<Checkbox
checked={enablePreviewOnClick ?? false}
@@ -169,21 +158,21 @@ function MobileDisplayOptions(props: MobileDisplayOptionsProps) {
onTogglePreviewOnClick?.(!!checked);
}}
/>
<span className="text-sm">Open preview on click</span>
<span className="text-sm">{t("kanban:openPreviewOnClick")}</span>
</label>
</div>
{showTaskDetails && (
<div className={mobileFieldClass}>
<label className={mobileFieldLabelClass}>List rows</label>
<label className={mobileFieldLabelClass}>{t("kanban:listRows")}</label>
<label className="flex min-h-11 cursor-pointer items-center gap-3 rounded-md px-0 text-sm font-medium">
<Checkbox
checked={tasksListShowDetails}
onCheckedChange={(checked) => onToggleTasksListShowDetails(checked === true)}
/>
<span>Show task details</span>
<span>{t("kanban:showTaskDetails")}</span>
</label>
<p className="pl-6 text-xs text-muted-foreground">
Add repository, pull request, session, parent, and review context to List rows.
{t("kanban:addRepositoryPullRequestSessionParent")}
</p>
</div>
)}
@@ -201,15 +190,16 @@ function MobileSearchSection({
onSearchChange?: (query: string) => void;
isSearchLoading: boolean;
}) {
const { t } = useTranslation();
if (!onSearchChange) return null;
return (
<div className={mobileSectionClass}>
<label className={mobileSectionTitleClass}>Search</label>
<label className={mobileSectionTitleClass}>{t("kanban:searchSection")}</label>
<TaskSearchInput
value={searchQuery}
onChange={onSearchChange}
placeholder="Search tasks..."
placeholder={t("kanban:searchTasksPlaceholder")}
isLoading={isSearchLoading}
className="w-full [&_[data-slot=input]]:h-10 [&_[data-slot=input]]:pl-9 [&_[data-slot=input]]:pr-9 [&_[data-slot=input]]:text-sm"
/>
@@ -218,9 +208,10 @@ function MobileSearchSection({
}
function MobileWorkspaceSection({ onOpenChange }: { onOpenChange: (open: boolean) => void }) {
const { t } = useTranslation();
return (
<div className={mobileSectionClass}>
<label className={mobileSectionTitleClass}>Workspace</label>
<label className={mobileSectionTitleClass}>{t("common:workspace")}</label>
<AppSidebarWorkspacePicker
modal={false}
onActionComplete={() => onOpenChange(false)}
@@ -243,9 +234,10 @@ function MobileViewSection({
onViewChange: (value: string) => void;
showPipeline: boolean;
}) {
const { t } = useTranslation();
return (
<div className={mobileSectionClass}>
<label className={mobileSectionTitleClass}>View</label>
<label className={mobileSectionTitleClass}>{t("kanban:view")}</label>
<ToggleGroup
type="single"
value={viewValue}
@@ -258,7 +250,7 @@ function MobileViewSection({
className="h-10 min-w-0 flex-1 cursor-pointer gap-2 text-sm data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
<IconLayoutKanban className={mobileControlIconClass} />
Kanban
{t("kanban:kanban")}
</ToggleGroupItem>
{showPipeline && (
<ToggleGroupItem
@@ -266,7 +258,7 @@ function MobileViewSection({
className="h-10 min-w-0 flex-1 cursor-pointer gap-2 text-sm data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
<IconTimeline className={mobileControlIconClass} />
Pipeline
{t("kanban:pipeline")}
</ToggleGroupItem>
)}
<ToggleGroupItem
@@ -274,97 +266,13 @@ function MobileViewSection({
className="h-10 min-w-0 flex-1 cursor-pointer gap-2 text-sm data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
<IconList className={mobileControlIconClass} />
List
{t("kanban:list")}
</ToggleGroupItem>
</ToggleGroup>
</div>
);
}
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 (
<div className="mt-auto flex flex-col gap-3 pt-4 border-t border-border">
<div className={mobileSectionTitleClass}>Utilities</div>
{statusDrawerEnabled && (
<Button
type="button"
variant="outline"
className={cn(
"relative h-11 w-full cursor-pointer justify-start gap-3 px-3 text-sm",
issueSeverity === "lost" && "border-destructive/40 text-destructive",
issueSeverity === "unstable" && "border-amber-500/40 text-amber-500",
)}
onClick={openStatus}
data-testid="mobile-home-status-button"
aria-label={issueDetails?.description}
data-connection-severity={issueSeverity === "none" ? undefined : issueSeverity}
>
<IconActivity className={mobileControlIconClass} />
Status
{issueDetails && (
<span
className={cn("ml-auto size-2 rounded-full", issueDetails.dotClass)}
aria-hidden="true"
/>
)}
</Button>
)}
<DestinationRows
destinations={destinations}
onNavigate={closeSheet}
className="gap-3 px-3 text-sm"
/>
<Button
type="button"
variant="outline"
className="h-11 w-full cursor-pointer justify-start gap-3 px-3 text-sm"
onClick={onOpenImproveKandev}
data-testid="mobile-improve-kandev-button"
>
<IconStethoscope className={mobileControlIconClass} />
Improve Kandev
</Button>
{showHealthIndicator && (
<Button
type="button"
variant="outline"
className={cn(mobileControlClass, "cursor-pointer justify-start gap-3")}
onClick={openHealth}
>
<IconAlertTriangle className={cn(mobileControlIconClass, "text-warning")} />
Health issues
</Button>
)}
</div>
);
}
function ResponsiveMenuSurface({
isMobile,
open,
@@ -380,6 +288,7 @@ function ResponsiveMenuSurface({
onOpenAutoFocus: (event: Event) => void;
children: ReactNode;
}) {
const { t } = useTranslation();
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
@@ -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"
>
<DrawerHeader className="shrink-0 border-b border-border/70 pb-3 text-left">
<DrawerTitle>Menu</DrawerTitle>
<DrawerTitle>{t("kanban:menu")}</DrawerTitle>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[env(safe-area-inset-bottom,0px)]">
{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"
>
<SheetHeader>
<SheetTitle>Menu</SheetTitle>
<SheetTitle>{t("kanban:menu")}</SheetTitle>
</SheetHeader>
{children}
</SheetContent>
@@ -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";
@@ -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 (
<div className={fieldClass}>
<label className={fieldLabelClass}>Task list</label>
<p className="text-xs text-muted-foreground">These options affect this task list only.</p>
<label className={fieldLabelClass}>{t("kanban:taskList")}</label>
<p className="text-xs text-muted-foreground">{t("kanban:theseOptionsAffectThisTaskList")}</p>
<div className="space-y-3">
<div className={fieldClass}>
<label id="mobile-tasks-list-sort-label" className={fieldLabelClass}>
Sort
{t("kanban:sort")}
</label>
<Select
value={options.sort}
@@ -51,13 +53,11 @@ export function MobileTasksListOptions({ options }: { options: TasksListDisplayO
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Choose how tasks are ordered in this list.
</p>
<p className="text-xs text-muted-foreground">{t("kanban:chooseHowTasksAreOrderedIn")}</p>
</div>
<div className={fieldClass}>
<label id="mobile-tasks-list-group-label" className={fieldLabelClass}>
Group
{t("kanban:group")}
</label>
<Select
value={options.group}
@@ -80,7 +80,7 @@ export function MobileTasksListOptions({ options }: { options: TasksListDisplayO
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Group tasks into sections by state, workflow, repository, or none.
{t("kanban:groupTasksIntoSectionsByState")}
</p>
</div>
<label className="flex min-h-11 cursor-pointer items-center gap-3 rounded-md px-0 text-sm font-medium">
@@ -89,7 +89,7 @@ export function MobileTasksListOptions({ options }: { options: TasksListDisplayO
onCheckedChange={(checked) => options.onShowArchivedChange(checked === true)}
data-testid="mobile-tasks-list-show-archived"
/>
<span>Show archived</span>
<span>{t("kanban:showArchived")}</span>
</label>
</div>
</div>
@@ -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 (
<div className="mt-auto flex flex-col gap-3 pt-4 border-t border-border">
<div className={mobileSectionTitleClass}>{t("kanban:utilities")}</div>
{statusDrawerEnabled && (
<Button
type="button"
variant="outline"
className={cn(
"relative h-11 w-full cursor-pointer justify-start gap-3 px-3 text-sm",
issueSeverity === "lost" && "border-destructive/40 text-destructive",
issueSeverity === "unstable" && "border-amber-500/40 text-amber-500",
)}
onClick={openStatus}
data-testid="mobile-home-status-button"
aria-label={issueDetails?.description}
data-connection-severity={issueSeverity === "none" ? undefined : issueSeverity}
>
<IconActivity className={mobileControlIconClass} />
{t("common:status")}
{issueDetails && (
<span
className={cn("ml-auto size-2 rounded-full", issueDetails.dotClass)}
aria-hidden="true"
/>
)}
</Button>
)}
<DestinationRows
destinations={destinations}
onNavigate={closeSheet}
className="gap-3 px-3 text-sm"
/>
<Button
type="button"
variant="outline"
className="h-11 w-full cursor-pointer justify-start gap-3 px-3 text-sm"
onClick={onOpenImproveKandev}
data-testid="mobile-improve-kandev-button"
>
<IconStethoscope className={mobileControlIconClass} />
{t("kanban:improveKandev")}
</Button>
{showHealthIndicator && (
<Button
type="button"
variant="outline"
className={cn(mobileControlClass, "cursor-pointer justify-start gap-3")}
onClick={openHealth}
>
<IconAlertTriangle className={cn(mobileControlIconClass, "text-warning")} />
{t("kanban:healthIssues")}
</Button>
)}
</div>
);
}
@@ -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 (
<div className="border-b border-border px-4 py-2" data-testid="mobile-search-bar">
<TaskSearchInput
value={searchQuery}
onChange={onSearchChange}
placeholder="Search tasks..."
placeholder={t("kanban:searchTasksPlaceholder")}
className="w-full"
autoFocus
/>
@@ -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 (
<div className="px-3 pb-3">
<div className="text-xs text-muted-foreground text-center py-4">No tasks</div>
<div className="text-xs text-muted-foreground text-center py-4">{t("kanban:noTasks")}</div>
</div>
);
}
@@ -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 (
<div className="px-3 pb-3">
<div className="text-xs text-muted-foreground text-center py-4">No tasks</div>
<div className="text-xs text-muted-foreground text-center py-4">{t("kanban:noTasks")}</div>
</div>
);
}
@@ -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 (
<div
className="grid grid-cols-[1fr_auto_1fr] items-center gap-1 py-1.5 w-full"
@@ -56,7 +58,7 @@ export function SwimlaneHeader({
tabIndex={-1}
aria-hidden
/>
Multi-select
{t("kanban:multiSelect")}
</div>
)}
<div className="flex-1 border-t border-dashed border-border/50" />
@@ -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<string, number> = {};
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")}
</div>
) : (
<SwipeableColumns
@@ -15,6 +15,7 @@ import {
} from "@kandev/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type { WorkflowStep } from "@/components/kanban-column";
import { useTranslation } from "react-i18next";
interface TaskMultiSelectToolbarProps {
selectedIds: Set<string>;
@@ -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"
>
<IconArchive className="h-4 w-4" />
Archive {count}
{t("kanban:archiveCount", { count })}
</Button>
<TaskArchiveConfirmDialog
open={open}
@@ -95,6 +97,7 @@ function BulkDeleteDialog({
isProcessing: boolean;
onConfirm: (opts: { cascade: boolean }) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
@@ -108,7 +111,7 @@ function BulkDeleteDialog({
data-testid="bulk-delete-button"
>
<IconTrash className="h-4 w-4" />
Delete {count}
{t("kanban:deleteCount", { count })}
</Button>
<TaskDeleteConfirmDialog
open={open}
@@ -135,6 +138,7 @@ export function TaskMultiSelectToolbar({
onBulkArchive,
onBulkMove,
}: TaskMultiSelectToolbarProps) {
const { t } = useTranslation();
const taskIds = useMemo(() => [...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")}
<IconChevronRight className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -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"
>
<IconX className="h-4 w-4" />
@@ -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 (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
No comments to fix yet.
{t("review:noCommentsToFixYet")}
</div>
);
}
@@ -67,10 +69,10 @@ export function ReviewCommentsOverview({ comments }: { comments: DiffComment[] }
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2">
<IconMessage className="h-4 w-4 shrink-0 text-blue-500" />
<span className="text-sm font-medium">
{total} pending review comment{total !== 1 ? "s" : ""}
{t("review:pendingCommentCount", { count: total })}
</span>
<span className="text-xs text-muted-foreground">
across {groups.length} file{groups.length !== 1 ? "s" : ""}
{t("review:acrossFileCount", { count: groups.length })}
</span>
</div>
@@ -103,7 +105,7 @@ export function ReviewCommentsOverview({ comments }: { comments: DiffComment[] }
<div className="mb-0.5 flex items-center gap-1 text-[10px] font-medium text-muted-foreground">
<span>{formatLineRange(comment.startLine, comment.endLine)}</span>
<span className="text-muted-foreground/60">
· {comment.side === "additions" ? "new" : "old"}
· {comment.side === "additions" ? t("review:new") : t("review:old")}
</span>
</div>
<p className="line-clamp-2 whitespace-pre-wrap text-xs leading-snug text-foreground/90">
@@ -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 (
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-sm text-muted-foreground">
<IconLoader2 className="h-5 w-5 animate-spin" />
<span>
Loading {selectedPR.repo} #{selectedPR.pr_number} changes…
{t("review:loadingPRChanges", {
repo: selectedPR.repo,
prNumber: selectedPR.pr_number,
})}
</span>
</div>
);
@@ -146,7 +151,7 @@ export function ReviewPRDiffBoundary({
{onRetry && (
<Button className="min-h-11" variant="outline" size="sm" onClick={onRetry}>
<IconRefresh className="h-4 w-4" />
Retry
{t("review:retry")}
</Button>
)}
</div>
@@ -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 (
<ReviewPRDiffBoundary
@@ -69,7 +71,7 @@ function ReviewDialogDiffContent({
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{state.filter.trim() ? "No files match the filter" : "No changes to review"}
{state.filter.trim() ? t("review:noFilesMatchTheFilter") : t("review:noChangesToReview")}
</div>
)}
</ReviewPRDiffBoundary>
@@ -77,6 +79,7 @@ function ReviewDialogDiffContent({
}
export function ReviewDialogSurface(props: ReviewDialogSurfaceProps) {
const { t } = useTranslation();
const { open, onOpenChange, sessionId, state } = props;
const splitRowRef = useRef<HTMLDivElement>(null);
const sidebar = useReviewSidebarResize(splitRowRef, open, state.reviewSourceKey);
@@ -100,7 +103,7 @@ export function ReviewDialogSurface(props: ReviewDialogSurfaceProps) {
showCloseButton={false}
overlayClassName="bg-black/40"
>
<DialogTitle className="sr-only">Review Changes</DialogTitle>
<DialogTitle className="sr-only">{t("review:reviewChangesTitle")}</DialogTitle>
<ReviewTopBar
sessionId={sessionId}
reviewedCount={state.reviewedFiles.size}
@@ -145,7 +148,7 @@ export function ReviewDialogSurface(props: ReviewDialogSurfaceProps) {
data-testid="review-dialog-sidebar-resize"
type="button"
tabIndex={-1}
aria-label="Resize file list"
aria-label={t("review:resizeFileList")}
className="group relative hidden w-1 flex-shrink-0 cursor-col-resize bg-border p-0 transition-colors hover:bg-primary md:block"
{...sidebar.resizeHandleProps}
>
@@ -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<string, string>;
@@ -60,6 +61,7 @@ function ReviewDiffStats({ file, compact = false }: { file: ReviewFile; compact?
}
function StaleIndicator({ compact = false }: { compact?: boolean }) {
const { t } = useTranslation();
return (
<span
className={cn(
@@ -68,7 +70,7 @@ function StaleIndicator({ compact = false }: { compact?: boolean }) {
)}
>
<IconAlertTriangle className="size-3.5" />
changed
{t("review:staleChanged")}
</span>
);
}
@@ -166,6 +168,7 @@ function MobileHeaderIdentity({
onToggleCollapse,
toolbar,
}: ResponsiveHeaderIdentityProps) {
const { t } = useTranslation();
return (
<div
data-testid="review-file-identity"
@@ -175,7 +178,11 @@ function MobileHeaderIdentity({
<button
type="button"
aria-expanded={!collapsed}
aria-label={`${collapsed ? "Expand" : "Collapse"} ${file.path}`}
aria-label={
collapsed
? t("review:expandFile", { path: file.path })
: t("review:collapseFile", { path: file.path })
}
onClick={onToggleCollapse}
className="flex min-h-11 min-w-0 flex-1 cursor-pointer items-center gap-1.5 text-left transition-colors duration-150 ease-out hover:text-foreground"
>
@@ -200,6 +207,7 @@ function DesktopHeaderIdentity({
onToggleCollapse,
toolbar,
}: ResponsiveHeaderIdentityProps) {
const { t } = useTranslation();
return (
<>
<div data-testid="review-file-identity" className="flex min-w-0 flex-1 items-center gap-2">
@@ -211,7 +219,11 @@ function DesktopHeaderIdentity({
<button
type="button"
aria-expanded={!collapsed}
aria-label={`${collapsed ? "Expand" : "Collapse"} ${file.path}`}
aria-label={
collapsed
? t("review:expandFile", { path: file.path })
: t("review:collapseFile", { path: file.path })
}
onClick={onToggleCollapse}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1.5 text-left hover:text-foreground"
>
@@ -24,6 +24,8 @@ import { extractReviewMarkdownPreview } from "./review-markdown-diff-preview";
import { ReviewMarkdownDiffPreviewContent } from "./review-markdown-diff-preview-content";
import { groupByRepositoryName } from "@/lib/group-by-repo";
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
const SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "]);
@@ -264,6 +266,7 @@ function useAutoMarkOnScroll({
}
function useCommentRunHandler(sessionId: string) {
const { t } = useTranslation();
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const { toast } = useToast();
const { runComment } = useRunComment({
@@ -275,14 +278,14 @@ function useCommentRunHandler(sessionId: string) {
try {
const { queued } = await runComment(comment);
toast({
title: "Comment sent",
description: queued ? "Queued for the agent." : "Sent to the agent.",
title: t("review:commentSent"),
description: queued ? t("review:queuedForTheAgent") : t("review:sentToTheAgent"),
});
} catch (err) {
console.error("Failed to run diff comment:", err);
toast({
title: "Failed to send comment",
description: "Please try again.",
title: t("review:failedToSendComment"),
description: t("review:pleaseTryAgain"),
variant: "error",
});
}
@@ -442,7 +445,7 @@ function renderDiffContent(opts: {
</DiffErrorBoundary>
{file.diff_skip_reason === "truncated" && (
<div className="py-1 text-center text-xs text-muted-foreground border-t">
Diff truncated — showing first 256 KB
{t("review:diffTruncated")}
</div>
)}
</>
@@ -36,6 +36,7 @@ import { useGlobalViewMode } from "@/hooks/use-global-view-mode";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { isMarkdownFile } from "@/lib/utils/file-types";
import { useTranslation } from "react-i18next";
const iconBtn = "h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100";
const iconBtnActive = "h-6 w-6 p-0 cursor-pointer bg-muted opacity-100";
@@ -118,11 +119,12 @@ function DiffDisplayControls({
onToggleViewMode,
onToggleWordWrap,
}: DiffDisplayControlsProps) {
const { t } = useTranslation();
return (
<>
<ToolbarIconBtn
onClick={onToggleExpandUnchanged}
tooltip={expandUnchanged ? "Collapse unchanged" : "Expand all"}
tooltip={expandUnchanged ? t("review:collapseUnchanged") : t("review:expandAll")}
active={expandUnchanged}
>
{expandUnchanged ? (
@@ -131,12 +133,20 @@ function DiffDisplayControls({
<IconFoldDown className="h-3.5 w-3.5" />
)}
</ToolbarIconBtn>
<ToolbarIconBtn onClick={onToggleWordWrap} tooltip="Toggle word wrap" active={wordWrap}>
<ToolbarIconBtn
onClick={onToggleWordWrap}
tooltip={t("review:toggleWordWrap")}
active={wordWrap}
>
<IconTextWrap className="h-3.5 w-3.5" />
</ToolbarIconBtn>
<ToolbarIconBtn
onClick={onToggleViewMode}
tooltip={globalViewMode === "split" ? "Switch to unified view" : "Switch to split view"}
tooltip={
globalViewMode === "split"
? t("review:switchToUnifiedView")
: t("review:switchToSplitView")
}
>
{globalViewMode === "split" ? (
<IconLayoutRows className="h-3.5 w-3.5" />
@@ -157,17 +167,18 @@ function MobileDiffViewMenuItems({
FileDiffToolbarProps,
"expandUnchanged" | "wordWrap" | "onToggleExpandUnchanged" | "onToggleWordWrap"
>) {
const { t } = useTranslation();
return (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>View</DropdownMenuLabel>
<DropdownMenuLabel>{t("review:view")}</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={expandUnchanged}
className={mobileMenuItem}
onCheckedChange={onToggleExpandUnchanged}
>
<IconFoldDown className={mobileMenuIcon} />
Expand unchanged lines
{t("review:expandUnchangedLines")}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={wordWrap}
@@ -175,13 +186,14 @@ function MobileDiffViewMenuItems({
onCheckedChange={onToggleWordWrap}
>
<IconTextWrap className={mobileMenuIcon} />
Wrap long lines
{t("review:wrapLongLines")}
</DropdownMenuCheckboxItem>
</>
);
}
function MobileFileActionsMenu(props: FileDiffToolbarProps) {
const { t } = useTranslation();
const {
diff,
filePath,
@@ -215,8 +227,8 @@ function MobileFileActionsMenu(props: FileDiffToolbarProps) {
type="button"
variant="ghost"
size="icon"
aria-label={`More actions for ${filePath}`}
title={`More actions for ${filePath}`}
aria-label={t("review:moreActionsFor", { filePath })}
title={t("review:moreActionsFor", { filePath })}
className="size-11 shrink-0 cursor-pointer text-muted-foreground transition-[scale,color,background-color] duration-150 ease-out active:scale-[0.96]"
onPointerDown={(event) => event.preventDefault()}
onClick={() => setOpen((previous) => !previous)}
@@ -226,7 +238,7 @@ function MobileFileActionsMenu(props: FileDiffToolbarProps) {
</DropdownMenuTrigger>
<DropdownMenuContent
data-testid="review-file-actions-menu"
aria-label={`Actions for ${filePath}`}
aria-label={t("review:actionsFor", { filePath })}
align="end"
className="w-64"
>
@@ -235,18 +247,18 @@ function MobileFileActionsMenu(props: FileDiffToolbarProps) {
</DropdownMenuLabel>
<DropdownMenuItem className={mobileMenuItem} onSelect={handleCopyDiff}>
<IconCopy className={mobileMenuIcon} />
Copy diff
{t("review:copyDiff")}
</DropdownMenuItem>
{onOpenFile && (
<DropdownMenuItem className={mobileMenuItem} onSelect={() => onOpenFile(filePath, repo)}>
<IconPencil className={mobileMenuIcon} />
Edit file
{t("review:editFile")}
</DropdownMenuItem>
)}
{onToggleMarkdownPreview && isMarkdownFile(filePath) && (
<DropdownMenuItem className={mobileMenuItem} onSelect={onToggleMarkdownPreview}>
<IconEye className={mobileMenuIcon} />
{markdownPreview ? "Show diff" : "Preview markdown"}
{markdownPreview ? t("review:showDiff") : t("review:previewMarkdown")}
</DropdownMenuItem>
)}
<ExternalVcsFileMenuItem
@@ -272,7 +284,7 @@ function MobileFileActionsMenu(props: FileDiffToolbarProps) {
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" className={mobileMenuItem} onSelect={onDiscard}>
<IconArrowBackUp className="size-4" />
Revert changes
{t("review:revertChanges")}
</DropdownMenuItem>
</>
)}
@@ -282,6 +294,7 @@ function MobileFileActionsMenu(props: FileDiffToolbarProps) {
}
function DesktopFileDiffToolbar(props: FileDiffToolbarProps) {
const { t } = useTranslation();
const {
diff,
filePath,
@@ -314,7 +327,7 @@ function DesktopFileDiffToolbar(props: FileDiffToolbarProps) {
return (
<div className="flex items-center gap-0.5">
<ToolbarIconBtn onClick={handleCopyDiff} tooltip="Copy diff">
<ToolbarIconBtn onClick={handleCopyDiff} tooltip={t("review:copyDiff")}>
<IconCopy className="h-3.5 w-3.5" />
</ToolbarIconBtn>
<ExternalVcsFileLink
@@ -340,19 +353,23 @@ function DesktopFileDiffToolbar(props: FileDiffToolbarProps) {
{onToggleMarkdownPreview && isMarkdownFile(filePath) && (
<ToolbarIconBtn
onClick={onToggleMarkdownPreview}
tooltip={markdownPreview ? "Show diff" : "Preview markdown"}
tooltip={markdownPreview ? t("review:showDiff") : t("review:previewMarkdown")}
>
<IconEye className="h-3.5 w-3.5" />
</ToolbarIconBtn>
)}
{onOpenFile && (
<ToolbarIconBtn onClick={() => onOpenFile(filePath, repo)} tooltip="Edit">
<ToolbarIconBtn onClick={() => onOpenFile(filePath, repo)} tooltip={t("common:edit")}>
<IconPencil className="h-3.5 w-3.5" />
</ToolbarIconBtn>
)}
<FileActionsDropdown filePath={filePath} sessionId={sessionId} size="xs" />
{source === "uncommitted" && (
<ToolbarIconBtn onClick={onDiscard} tooltip="Revert changes" className={iconBtnDestructive}>
<ToolbarIconBtn
onClick={onDiscard}
tooltip={t("review:revertChanges")}
className={iconBtnDestructive}
>
<IconArrowBackUp className="h-3.5 w-3.5" />
</ToolbarIconBtn>
)}
@@ -16,6 +16,7 @@ import { FileIcon } from "@/components/ui/file-icon";
import { useTree, type VisibleRow } from "@/hooks/use-tree";
import type { ReviewFile, FileTreeNode } from "./types";
import { buildFileTree, reviewFileKey } from "./types";
import { useTranslation } from "react-i18next";
type ReviewFileTreeProps = {
files: ReviewFile[];
@@ -86,6 +87,7 @@ interface ReviewFilterInputProps {
}
function ReviewFilterInput({ ref, value, onChange }: ReviewFilterInputProps) {
const { t } = useTranslation();
return (
<div className="px-2 py-2 shrink-0">
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/50 border border-border/50 focus-within:border-border focus-within:bg-muted/80 transition-colors">
@@ -94,7 +96,7 @@ function ReviewFilterInput({ ref, value, onChange }: ReviewFilterInputProps) {
ref={ref}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Filter changed files"
placeholder={t("review:filterChangedFiles")}
className="flex-1 bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground outline-none min-w-0"
/>
{value && (
@@ -8,6 +8,7 @@ import { openFindingCount } from "@/lib/review/findings";
import { navigateToFinding } from "@/lib/review/navigation";
import type { TaskReviewFinding } from "@/lib/types/review";
import { ReviewFindingsOverview } from "./review-findings-overview";
import { useTranslation } from "react-i18next";
export type ReviewFindingsButtonProps = {
findings: TaskReviewFinding[];
@@ -22,6 +23,7 @@ export type ReviewFindingsButtonProps = {
* there is nothing to navigate to.
*/
export function ReviewFindingsButton({ findings, onSelectFile }: ReviewFindingsButtonProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const count = openFindingCount(findings);
@@ -48,10 +50,10 @@ export function ReviewFindingsButton({ findings, onSelectFile }: ReviewFindingsB
size="sm"
variant="outline"
className="h-6 cursor-pointer gap-1 px-1.5 text-[10px]"
aria-label={`Go to ${count} review finding${count === 1 ? "" : "s"}`}
aria-label={t("review:goToFindings", { count })}
data-testid="review-open-count"
>
{count} finding{count === 1 ? "" : "s"}
{t("review:findingCount", { count })}
<IconChevronDown className="h-3 w-3" />
</Button>
</PopoverTrigger>
@@ -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 (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">No open findings.</div>
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
{t("review:noOpenFindings")}
</div>
);
}
@@ -74,10 +78,10 @@ export function ReviewFindingsOverview({
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2">
<IconSparkles className="h-4 w-4 shrink-0 text-primary" />
<span className="text-sm font-medium">
{total} open finding{total !== 1 ? "s" : ""}
{t("review:openFindingCount", { count: total })}
</span>
<span className="text-xs text-muted-foreground">
across {groups.length} file{groups.length !== 1 ? "s" : ""}
{t("review:acrossFileCount", { count: groups.length })}
</span>
</div>
@@ -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"
>
<IconMessageForward className="h-4 w-4" />
Fix Comments
{t("review:fixComments")}
<span className="ml-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
{commentCount}
</span>
@@ -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 (
<section className="px-4 py-6" data-testid="review-markdown-diff-preview">
{preview.isPartial && (
<p className="mb-5 text-sm text-muted-foreground">
Showing changed Markdown fragments. Unchanged content is not included in this diff.
{t("review:markdownPreviewPartialNotice")}
</p>
)}
<div className="markdown-body max-w-3xl">
@@ -17,7 +19,7 @@ export function ReviewMarkdownDiffPreviewContent({ preview }: { preview: ReviewM
>
{preview.fragments.length > 1 && (
<p className="mb-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Changed section {index + 1}
{t("review:changedSection", { index: index + 1 })}
</p>
)}
<MarkdownPreviewRenderer content={fragment} />
@@ -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"
>
<DropdownMenuLabel>Review pull request</DropdownMenuLabel>
<DropdownMenuLabel>{t("review:reviewPullRequest")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={selectedKey}
@@ -8,6 +8,8 @@ import Link from "@/components/routing/app-link";
import { cancelTaskReview, runTaskReview } from "@/lib/api/domains/review-api";
import { isRunActive } from "@/lib/review/findings";
import type { TaskReviewRun } from "@/lib/types/review";
import { Trans, useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
export type ReviewRunButtonProps = {
taskId: string | null | undefined;
@@ -23,20 +25,21 @@ export type ReviewRunButtonProps = {
* showing a dead-end error.
*/
function RunNotice({ code, message }: { code: string; message: string }) {
const { t } = useTranslation();
if (code === "review_agent_unavailable") {
return (
<p className="text-xs text-muted-foreground" data-testid="review-agent-unavailable">
No inference-capable agent is configured for review.{" "}
<Link href="/settings/utility-agents" className="cursor-pointer underline">
Settings → Utility Agents
</Link>
<Trans i18nKey="review:reviewAgentUnavailableNotice">
No inference-capable agent is configured for review.{" "}
<Link href="/settings/utility-agents" className="cursor-pointer underline" />
</Trans>
</p>
);
}
if (code === "review_no_changes") {
return (
<p className="text-xs text-muted-foreground" data-testid="review-no-changes">
No changes to review yet.
{t("review:noChangesToReviewYet")}
</p>
);
}
@@ -85,6 +88,7 @@ function RunTrigger({
compact: boolean;
onStart: () => void;
}) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
@@ -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({
) : (
<IconSparkles className="h-4 w-4" />
)}
{!compact && <span className="text-xs">Review changes</span>}
{!compact && <span className="text-xs">{t("review:reviewChanges")}</span>}
</Button>
</TooltipTrigger>
<TooltipContent>
{busy ? "Review in progress" : "Review these changes with an agent"}
{busy ? t("review:reviewInProgress") : t("review:reviewTheseChangesWithAnAgent")}
</TooltipContent>
</Tooltip>
);
@@ -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"
>
<IconX className="h-3.5 w-3.5" />
Cancel
{t("common:cancel")}
</Button>
)}
+15 -7
View File
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -89,7 +91,7 @@ function ReviewSettingsMenu({ reviewAutoMarkOnScroll, onToggleAutoMark }: Review
}}
>
<Checkbox checked={reviewAutoMarkOnScroll} className="pointer-events-none" />
<span className="text-sm flex-1">Auto-mark reviewed on scroll</span>
<span className="text-sm flex-1">{t("review:autoMarkReviewedOnScroll")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -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 (
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden">
@@ -109,7 +112,7 @@ function ReviewProgress({ reviewedCount, totalCount }: ReviewProgressProps) {
/>
</div>
<span className="text-xs text-muted-foreground truncate">
{reviewedCount} of {totalCount} files reviewed
{t("review:filesReviewed", { reviewedCount, totalCount })}
</span>
</div>
);
@@ -121,6 +124,7 @@ function ReviewDisplayControls({
onToggleWordWrap,
onToggleSplitView,
}: Pick<ReviewTopBarProps, "wordWrap" | "splitView" | "onToggleWordWrap" | "onToggleSplitView">) {
const { t } = useTranslation();
return (
<>
<Tooltip>
@@ -134,7 +138,7 @@ function ReviewDisplayControls({
<IconTextWrap className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Toggle word wrap</TooltipContent>
<TooltipContent>{t("review:toggleWordWrap")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -152,7 +156,7 @@ function ReviewDisplayControls({
</Button>
</TooltipTrigger>
<TooltipContent>
{splitView ? "Switch to unified view" : "Switch to split view"}
{splitView ? t("review:switchToUnifiedView") : t("review:switchToSplitView")}
</TooltipContent>
</Tooltip>
</>
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
@@ -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")}
>
<IconX className="h-4 w-4" />
</Button>
+12 -11
View File
@@ -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;
@@ -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 (
<div className="group fixed bottom-[calc(1.5rem+var(--app-status-bar-height))] right-6 z-[41]">
<button
@@ -60,10 +62,10 @@ function WalkthroughLauncher({
)}
>
<IconRoute className="size-4 text-primary" />
Walkthrough
{t("review:walkthrough")}
{hasUnseen ? (
<span
aria-label="New walkthrough"
aria-label={t("review:newWalkthrough")}
className="size-1.5 rounded-full bg-primary"
data-testid="walkthrough-unseen-dot"
/>
@@ -79,8 +81,8 @@ function WalkthroughLauncher({
</button>
<button
type="button"
aria-label="Discard walkthrough"
title="Discard walkthrough"
aria-label={t("review:discardWalkthrough")}
title={t("review:discardWalkthrough")}
data-testid="walkthrough-discard"
onClick={onDiscardClick}
className={cn(
@@ -111,25 +113,26 @@ function DiscardWalkthroughDialog({
onOpenChange,
open,
}: DiscardWalkthroughDialogProps) {
const { t } = useTranslation();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent data-testid="walkthrough-discard-dialog">
<AlertDialogHeader>
<AlertDialogTitle>Discard walkthrough?</AlertDialogTitle>
<AlertDialogTitle>{t("review:discardWalkthroughTitle")}</AlertDialogTitle>
<AlertDialogDescription>
This removes the saved walkthrough from this task. The agent can create a new one later.
{t("review:discardWalkthroughDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer" disabled={discarding}>
Cancel
{t("common:cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={discarding}
onClick={onConfirm}
>
Discard walkthrough
{t("review:discardWalkthrough")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -185,6 +188,7 @@ function useWalkthroughBackfill(params: {
* surface required).
*/
export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayProps) {
const { t } = useTranslation();
const walkthrough = useAppStore((s) => (taskId ? s.walkthroughs.byTaskId[taskId] : null));
const connectionStatus = useAppStore((s) => s.connection.status);
const activeStep = useAppStore((s) =>
@@ -237,10 +241,10 @@ export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayP
setOpenTaskId(null);
setWalkthrough(taskId, null);
setConfirmDiscardOpen(false);
toast({ title: "Walkthrough discarded", variant: "success" });
toast({ title: t("review:walkthroughDiscarded"), variant: "success" });
} catch (error) {
console.error("Failed to discard walkthrough:", error);
toast({ title: "Failed to discard walkthrough", variant: "error" });
toast({ title: t("review:failedToDiscardWalkthrough"), variant: "error" });
} finally {
setDiscarding(false);
}
+47
View File
@@ -1548,4 +1548,51 @@ export const i18nGuardFiles = [
"app/settings/agents/[[]agentId[]]/profiles/[[]profileId[]]/command-preview-card.tsx",
// Home route redirect shell.
"app/page-client.tsx",
// Review dialog: top bar, file tree, diff toolbar/header/list, walkthrough.
"components/review/review-comments-overview.tsx",
"components/review/review-dialog-pr-state.tsx",
"components/review/review-dialog-surface.tsx",
"components/review/review-diff-header.tsx",
"components/review/review-diff-list.tsx",
"components/review/review-diff-toolbar.tsx",
"components/review/review-file-tree.tsx",
"components/review/review-findings-button.tsx",
"components/review/review-findings-overview.tsx",
"components/review/review-fix-comments-button.tsx",
"components/review/review-markdown-diff-preview-content.tsx",
"components/review/review-pr-selector.tsx",
"components/review/review-run-button.tsx",
"components/review/review-top-bar.tsx",
"components/review/walkthrough-overlay.tsx",
// Plain-TS label helpers the review diff list renders — guard-blind, so they
// are listed to keep the entries above honest rather than because lint sees
// anything here.
"components/review/types.ts",
// Kanban board: swimlanes, graph pipeline, and the mobile board shell.
"components/kanban/graph2-task-pipeline.tsx",
"components/kanban/kanban-header-mobile.tsx",
"components/kanban/kanban-header.tsx",
"components/kanban/mobile-column-tabs.tsx",
"components/kanban/mobile-drop-targets.tsx",
"components/kanban/mobile-fab.tsx",
"components/kanban/mobile-menu-sheet.tsx",
// Extracted out of mobile-menu-sheet.tsx to stay under the 600-line limit.
"components/kanban/mobile-menu-styles.ts",
"components/kanban/mobile-menu-utility-actions.tsx",
"components/kanban/mobile-menu-task-list-options.tsx",
"components/kanban/mobile-search-bar.tsx",
"components/kanban/swimlane-graph-content.tsx",
"components/kanban/swimlane-graph2-content.tsx",
"components/kanban/swimlane-header.tsx",
"components/kanban/swimlane-kanban-content.tsx",
"components/kanban/task-multi-select-toolbar.tsx",
// Kanban board: loose card/column/dropdown components on the same namespace.
"components/kanban-board.tsx",
"components/kanban-card-content.tsx",
"components/kanban-card-menu-items.tsx",
"components/kanban-column.tsx",
"components/kanban-display-dropdown.tsx",
"components/kanban-with-preview.tsx",
// Shared by the desktop dropdown and the mobile sheet; returns catalog keys.
"lib/kanban/repository-placeholder.ts",
];
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { t } from "@/lib/i18n";
import { getRepositoryPlaceholderKey } from "./repository-placeholder";
describe("getRepositoryPlaceholderKey", () => {
it("prefers the loading state even when the list is still empty", () => {
expect(getRepositoryPlaceholderKey(true, true)).toBe("kanban:loadingRepositories");
expect(getRepositoryPlaceholderKey(true, false)).toBe("kanban:loadingRepositories");
});
it("reports an empty list once loading has finished", () => {
expect(getRepositoryPlaceholderKey(false, true)).toBe("kanban:noRepositories");
});
it("prompts for a selection when repositories are ready", () => {
expect(getRepositoryPlaceholderKey(false, false)).toBe("kanban:selectRepository");
});
it("returns keys that resolve in the English catalog", () => {
const cases: Array<[boolean, boolean, string]> = [
[true, true, "Loading repositories..."],
[false, true, "No repositories"],
[false, false, "Select repository"],
];
for (const [loading, empty, expected] of cases) {
expect(t(getRepositoryPlaceholderKey(loading, empty))).toBe(expected);
}
});
});
@@ -0,0 +1,14 @@
/**
* Placeholder shown by the repository `<Select>` on the kanban display
* dropdown (desktop) and the mobile menu sheet. Both surfaces render the same
* three states, so they share one mapping rather than two copies that drift.
*
* Returns a catalog KEY, not copy: `t()` must run at render, never here — a
* `t()` at module scope freezes at the boot locale and the pseudo-locale
* cannot detect it.
*/
export function getRepositoryPlaceholderKey(loading: boolean, empty: boolean): string {
if (loading) return "kanban:loadingRepositories";
if (empty) return "kanban:noRepositories";
return "kanban:selectRepository";
}
+92
View File
@@ -0,0 +1,92 @@
{
"addRepositoryPullRequestSessionParent": "Add repository, pull request, session, parent, and review context to List rows.",
"addTask": "Add task",
"allRepositories": "All repositories",
"allWorkflows": "All workflows",
"allWorkflowsTitleCase": "All Workflows",
"approvalRequired": "Approval Required",
"archive": "Archive",
"archiveCount_one": "Archive {{count}}",
"archiveCount_other": "Archive {{count}}",
"archiveTask": "Archive task",
"autoStart": "Auto-start",
"boardNavigator": "Board navigator",
"cannotMoveTasksFromDifferentWorkflows": "Cannot move tasks from different workflows",
"changesRequested": "Changes Requested",
"chooseHowTasksAreOrderedIn": "Choose how tasks are ordered in this list.",
"chooseStep": "{{stepLabel}}. Choose step.",
"chooseWorkflowAndStepShownOn": "Choose workflow and step shown on board.",
"chooseWorkflowOrStep": "{{name}}, {{stepLabel}}. Choose workflow or step.",
"clearSelection": "Clear selection",
"closePreview": "Close preview",
"current": "Current",
"delete": "Delete",
"deleteCount_one": "Delete {{count}}",
"deleteCount_other": "Delete {{count}}",
"deleteTask": "Delete task",
"detachFromParent": "Detach from parent",
"dismiss": "Dismiss",
"displayOptions": "Display Options",
"dropOnAColumnToMove": "Drop on a column to move task",
"githubIssue": "GitHub Issue",
"githubPullRequest": "GitHub Pull Request",
"gitlabMergeRequest": "GitLab Merge Request",
"goToTask": "Go to Task",
"group": "Group",
"groupTasksIntoSectionsByState": "Group tasks into sections by state, workflow, repository, or none.",
"healthIssues": "Health issues",
"improveKandev": "Improve Kandev",
"jiraTicket": "Jira Ticket",
"kanban": "Kanban",
"kandevHome": "Kandev home",
"linearIssue": "Linear Issue",
"link": "Link",
"list": "List",
"listRows": "List rows",
"loadingRepositories": "Loading repositories...",
"menu": "Menu",
"moreOptions": "More options",
"moveTo": "Move to",
"multiSelect": "Multi-select",
"nextCapacity": "next capacity",
"nextStep": "Next step",
"noRepositories": "No repositories",
"noSteps": "No steps",
"noStepsConfigured": "No steps configured.",
"noStepsConfiguredChooseAnotherWorkflow": "No steps configured. Choose another workflow or add steps in Settings.",
"noTasks": "No tasks",
"openMenu": "Open menu",
"openMenuWithStatus": "{{description}} Open menu",
"openPreviewOnClick": "Open preview on click",
"overWipLimit": "Over WIP limit",
"pipeline": "Pipeline",
"previewPanel": "Preview Panel",
"previousStep": "Previous step",
"queuedForStep": "Queued for {{step}}",
"repository": "Repository",
"searchPlaceholder": "Search...",
"searchSection": "Search",
"searchTasks": "Search tasks",
"searchTasksPlaceholder": "Search tasks...",
"selectRepository": "Select repository",
"selectTask": "Select task {{title}}",
"selectWorkflow": "Select workflow",
"sendToWorkflow": "Send to workflow",
"sentryIssue": "Sentry Issue",
"sessionCount_one": "{{count}} session",
"sessionCount_other": "{{count}} sessions",
"showArchived": "Show archived",
"showTaskDetails": "Show task details",
"sort": "Sort",
"step": "Step",
"subtaskOf": "Subtask of",
"taskList": "Task list",
"tasksOverWipLimit": "{{label}} tasks, over WIP limit",
"theseOptionsAffectThisTaskList": "These options affect this task list only.",
"utilities": "Utilities",
"view": "View",
"whenEnabledClickingATaskOpens": "When enabled, clicking a task opens the preview panel. When disabled, clicking navigates directly to the session.",
"wipTaskCount": "{{label}} tasks",
"workflow": "Workflow",
"workflowStepFallback": "workflow step {{stepId}}"
}
+82
View File
@@ -0,0 +1,82 @@
{
"acrossFileCount_one": "across {{count}} file",
"acrossFileCount_other": "across {{count}} files",
"actionsFor": "Actions for {{filePath}}",
"autoMarkReviewedOnScroll": "Auto-mark reviewed on scroll",
"changedSection": "Changed section {{index}}",
"closeReview": "Close review",
"collapseFile": "Collapse {{path}}",
"collapseUnchanged": "Collapse unchanged",
"commentSent": "Comment sent",
"copyDiff": "Copy diff",
"diffLoading": "Loading diff...",
"diffSkipBinary": "Binary file — not diffable",
"diffSkipBudgetExceeded": "Diff skipped — too many changed files",
"diffSkipTooLarge": "File too large to diff (>10 MB)",
"diffSkipTruncated": "Diff truncated (>256 KB)",
"diffTruncated": "Diff truncated — showing first 256 KB",
"discardWalkthrough": "Discard walkthrough",
"discardWalkthroughDescription": "This removes the saved walkthrough from this task. The agent can create a new one later.",
"discardWalkthroughTitle": "Discard walkthrough?",
"editFile": "Edit file",
"expandAll": "Expand all",
"expandFile": "Expand {{path}}",
"expandUnchangedLines": "Expand unchanged lines",
"failedToDiscardWalkthrough": "Failed to discard walkthrough",
"failedToSendComment": "Failed to send comment",
"filesReviewed": "{{reviewedCount}} of {{totalCount}} files reviewed",
"filterChangedFiles": "Filter changed files",
"findingCount_one": "{{count}} finding",
"findingCount_other": "{{count}} findings",
"fixComments": "Fix Comments",
"goToFindings_one": "Go to {{count}} review finding",
"goToFindings_other": "Go to {{count}} review findings",
"loadingChangedFiles": "Loading changed files...",
"loadingPRChanges": "Loading {{repo}} #{{prNumber}} changes…",
"markdownPreviewPartialNotice": "Showing changed Markdown fragments. Unchanged content is not included in this diff.",
"moreActionsFor": "More actions for {{filePath}}",
"new": "new",
"newWalkthrough": "New walkthrough",
"noChangesToReview": "No changes to review",
"noChangesToReviewYet": "No changes to review yet.",
"noCommentsToFixYet": "No comments to fix yet.",
"noFilesMatchTheFilter": "No files match the filter",
"noOpenFindings": "No open findings.",
"noTextualDiffAdded": "Added file has no textual diff",
"noTextualDiffDeleted": "Deleted file has no textual diff",
"noTextualDiffModified": "No textual diff available",
"noTextualDiffMoved": "File moved; no textual changes",
"noTextualDiffMovedFrom": "Moved from {{oldPath}}; no textual changes",
"noTextualDiffUntracked": "Untracked file has no textual diff",
"old": "old",
"openFindingCount_one": "{{count}} open finding",
"openFindingCount_other": "{{count}} open findings",
"pendingCommentCount_one": "{{count}} pending review comment",
"pendingCommentCount_other": "{{count}} pending review comments",
"pleaseTryAgain": "Please try again.",
"previewMarkdown": "Preview markdown",
"queuedForTheAgent": "Queued for the agent.",
"resizeFileList": "Resize file list",
"retry": "Retry",
"revertChanges": "Revert changes",
"reviewAgentUnavailableNotice": "No inference-capable agent is configured for review. <2>Settings → Utility Agents</2>",
"reviewChanges": "Review changes",
"reviewChangesTitle": "Review Changes",
"reviewInProgress": "Review in progress",
"reviewPullRequest": "Review pull request",
"reviewPullRequestFor": "Review pull request {{repo}} #{{prNumber}}",
"reviewTheseChangesWithAnAgent": "Review these changes with an agent",
"sentToTheAgent": "Sent to the agent.",
"showDiff": "Show diff",
"staleChanged": "changed",
"switchToSplitView": "Switch to split view",
"switchToUnifiedView": "Switch to unified view",
"theReviewFailed": "The review failed",
"toggleWordWrap": "Toggle word wrap",
"view": "View",
"walkMeThroughTheseChanges": "Walk me through these changes",
"walkMeThroughTheseReviewChanges": "Walk me through these review changes",
"walkthrough": "Walkthrough",
"walkthroughDiscarded": "Walkthrough discarded",
"wrapLongLines": "Wrap long lines"
}
+92
View File
@@ -0,0 +1,92 @@
{
"addRepositoryPullRequestSessionParent": "Àďď ŕēƥōśĩţōŕŷ, ƥũĺĺ ŕēqũēśţ, śēśśĩōń, ƥàŕēńţ, àńď ŕēvĩēŵ ćōńţēxţ ţō Ĺĩśţ ŕōŵś.",
"addTask": "Àďď ţàśķ",
"allRepositories": "Àĺĺ ŕēƥōśĩţōŕĩēś",
"allWorkflows": "Àĺĺ ŵōŕķƒĺōŵś",
"allWorkflowsTitleCase": "Àĺĺ Ŵōŕķƒĺōŵś",
"approvalRequired": "Àƥƥŕōvàĺ Ŕēqũĩŕēď",
"archive": "Àŕćĥĩvē",
"archiveCount_one": "Àŕćĥĩvē {{count}}",
"archiveCount_other": "Àŕćĥĩvē {{count}}",
"archiveTask": "Àŕćĥĩvē ţàśķ",
"autoStart": "Àũţō-śţàŕţ",
"boardNavigator": "Ɓōàŕď ńàvĩĝàţōŕ",
"cannotMoveTasksFromDifferentWorkflows": "Ćàńńōţ ḿōvē ţàśķś ƒŕōḿ ďĩƒƒēŕēńţ ŵōŕķƒĺōŵś",
"changesRequested": "Ćĥàńĝēś Ŕēqũēśţēď",
"chooseHowTasksAreOrderedIn": "Ćĥōōśē ĥōŵ ţàśķś àŕē ōŕďēŕēď ĩń ţĥĩś ĺĩśţ.",
"chooseStep": "{{stepLabel}}. Ćĥōōśē śţēƥ.",
"chooseWorkflowAndStepShownOn": "Ćĥōōśē ŵōŕķƒĺōŵ àńď śţēƥ śĥōŵń ōń ƀōàŕď.",
"chooseWorkflowOrStep": "{{name}}, {{stepLabel}}. Ćĥōōśē ŵōŕķƒĺōŵ ōŕ śţēƥ.",
"clearSelection": "Ćĺēàŕ śēĺēćţĩōń",
"closePreview": "Ćĺōśē ƥŕēvĩēŵ",
"current": "Ćũŕŕēńţ",
"delete": "Ďēĺēţē",
"deleteCount_one": "Ďēĺēţē {{count}}",
"deleteCount_other": "Ďēĺēţē {{count}}",
"deleteTask": "Ďēĺēţē ţàśķ",
"detachFromParent": "Ďēţàćĥ ƒŕōḿ ƥàŕēńţ",
"dismiss": "Ďĩśḿĩśś",
"displayOptions": "Ďĩśƥĺàŷ Ōƥţĩōńś",
"dropOnAColumnToMove": "Ďŕōƥ ōń à ćōĺũḿń ţō ḿōvē ţàśķ",
"githubIssue": "ĜĩţĤũƀ Ĩśśũē",
"githubPullRequest": "ĜĩţĤũƀ Ƥũĺĺ Ŕēqũēśţ",
"gitlabMergeRequest": "ĜĩţĹàƀ Ḿēŕĝē Ŕēqũēśţ",
"goToTask": "Ĝō ţō Ţàśķ",
"group": "Ĝŕōũƥ",
"groupTasksIntoSectionsByState": "Ĝŕōũƥ ţàśķś ĩńţō śēćţĩōńś ƀŷ śţàţē, ŵōŕķƒĺōŵ, ŕēƥōśĩţōŕŷ, ōŕ ńōńē.",
"healthIssues": "Ĥēàĺţĥ ĩśśũēś",
"improveKandev": "Ĩḿƥŕōvē Ķàńďēv",
"jiraTicket": "Ĵĩŕà Ţĩćķēţ",
"kanban": "Ķàńƀàń",
"kandevHome": "Ķàńďēv ĥōḿē",
"linearIssue": "Ĺĩńēàŕ Ĩśśũē",
"link": "Ĺĩńķ",
"list": "Ĺĩśţ",
"listRows": "Ĺĩśţ ŕōŵś",
"loadingRepositories": "Ĺōàďĩńĝ ŕēƥōśĩţōŕĩēś...",
"menu": "Ḿēńũ",
"moreOptions": "Ḿōŕē ōƥţĩōńś",
"moveTo": "Ḿōvē ţō",
"multiSelect": "Ḿũĺţĩ-śēĺēćţ",
"nextCapacity": "ńēxţ ćàƥàćĩţŷ",
"nextStep": "Ńēxţ śţēƥ",
"noRepositories": "Ńō ŕēƥōśĩţōŕĩēś",
"noSteps": "Ńō śţēƥś",
"noStepsConfigured": "Ńō śţēƥś ćōńƒĩĝũŕēď.",
"noStepsConfiguredChooseAnotherWorkflow": "Ńō śţēƥś ćōńƒĩĝũŕēď. Ćĥōōśē àńōţĥēŕ ŵōŕķƒĺōŵ ōŕ àďď śţēƥś ĩń Śēţţĩńĝś.",
"noTasks": "Ńō ţàśķś",
"openMenu": "Ōƥēń ḿēńũ",
"openMenuWithStatus": "{{description}} Ōƥēń ḿēńũ",
"openPreviewOnClick": "Ōƥēń ƥŕēvĩēŵ ōń ćĺĩćķ",
"overWipLimit": "Ōvēŕ ŴĨƤ ĺĩḿĩţ",
"pipeline": "Ƥĩƥēĺĩńē",
"previewPanel": "Ƥŕēvĩēŵ Ƥàńēĺ",
"previousStep": "Ƥŕēvĩōũś śţēƥ",
"queuedForStep": "Qũēũēď ƒōŕ {{step}}",
"repository": "Ŕēƥōśĩţōŕŷ",
"searchPlaceholder": "Śēàŕćĥ...",
"searchSection": "Śēàŕćĥ",
"searchTasks": "Śēàŕćĥ ţàśķś",
"searchTasksPlaceholder": "Śēàŕćĥ ţàśķś...",
"selectRepository": "Śēĺēćţ ŕēƥōśĩţōŕŷ",
"selectTask": "Śēĺēćţ ţàśķ {{title}}",
"selectWorkflow": "Śēĺēćţ ŵōŕķƒĺōŵ",
"sendToWorkflow": "Śēńď ţō ŵōŕķƒĺōŵ",
"sentryIssue": "Śēńţŕŷ Ĩśśũē",
"sessionCount_one": "{{count}} śēśśĩōń",
"sessionCount_other": "{{count}} śēśśĩōńś",
"showArchived": "Śĥōŵ àŕćĥĩvēď",
"showTaskDetails": "Śĥōŵ ţàśķ ďēţàĩĺś",
"sort": "Śōŕţ",
"step": "Śţēƥ",
"subtaskOf": "Śũƀţàśķ ōƒ",
"taskList": "Ţàśķ ĺĩśţ",
"tasksOverWipLimit": "{{label}} ţàśķś, ōvēŕ ŴĨƤ ĺĩḿĩţ",
"theseOptionsAffectThisTaskList": "Ţĥēśē ōƥţĩōńś àƒƒēćţ ţĥĩś ţàśķ ĺĩśţ ōńĺŷ.",
"utilities": "Ũţĩĺĩţĩēś",
"view": "Vĩēŵ",
"whenEnabledClickingATaskOpens": "Ŵĥēń ēńàƀĺēď, ćĺĩćķĩńĝ à ţàśķ ōƥēńś ţĥē ƥŕēvĩēŵ ƥàńēĺ. Ŵĥēń ďĩśàƀĺēď, ćĺĩćķĩńĝ ńàvĩĝàţēś ďĩŕēćţĺŷ ţō ţĥē śēśśĩōń.",
"wipTaskCount": "{{label}} ţàśķś",
"workflow": "Ŵōŕķƒĺōŵ",
"workflowStepFallback": "ŵōŕķƒĺōŵ śţēƥ {{stepId}}"
}
+82
View File
@@ -0,0 +1,82 @@
{
"acrossFileCount_one": "àćŕōśś {{count}} ƒĩĺē",
"acrossFileCount_other": "àćŕōśś {{count}} ƒĩĺēś",
"actionsFor": "Àćţĩōńś ƒōŕ {{filePath}}",
"autoMarkReviewedOnScroll": "Àũţō-ḿàŕķ ŕēvĩēŵēď ōń śćŕōĺĺ",
"changedSection": "Ćĥàńĝēď śēćţĩōń {{index}}",
"closeReview": "Ćĺōśē ŕēvĩēŵ",
"collapseFile": "Ćōĺĺàƥśē {{path}}",
"collapseUnchanged": "Ćōĺĺàƥśē ũńćĥàńĝēď",
"commentSent": "Ćōḿḿēńţ śēńţ",
"copyDiff": "Ćōƥŷ ďĩƒƒ",
"diffLoading": "Ĺōàďĩńĝ ďĩƒƒ...",
"diffSkipBinary": "Ɓĩńàŕŷ ƒĩĺē — ńōţ ďĩƒƒàƀĺē",
"diffSkipBudgetExceeded": "Ďĩƒƒ śķĩƥƥēď — ţōō ḿàńŷ ćĥàńĝēď ƒĩĺēś",
"diffSkipTooLarge": "Ƒĩĺē ţōō ĺàŕĝē ţō ďĩƒƒ (>10 ḾƁ)",
"diffSkipTruncated": "Ďĩƒƒ ţŕũńćàţēď (>256 ĶƁ)",
"diffTruncated": "Ďĩƒƒ ţŕũńćàţēď — śĥōŵĩńĝ ƒĩŕśţ 256 ĶƁ",
"discardWalkthrough": "Ďĩśćàŕď ŵàĺķţĥŕōũĝĥ",
"discardWalkthroughDescription": "Ţĥĩś ŕēḿōvēś ţĥē śàvēď ŵàĺķţĥŕōũĝĥ ƒŕōḿ ţĥĩś ţàśķ. Ţĥē àĝēńţ ćàń ćŕēàţē à ńēŵ ōńē ĺàţēŕ.",
"discardWalkthroughTitle": "Ďĩśćàŕď ŵàĺķţĥŕōũĝĥ?",
"editFile": "Ēďĩţ ƒĩĺē",
"expandAll": "Ēxƥàńď àĺĺ",
"expandFile": "Ēxƥàńď {{path}}",
"expandUnchangedLines": "Ēxƥàńď ũńćĥàńĝēď ĺĩńēś",
"failedToDiscardWalkthrough": "Ƒàĩĺēď ţō ďĩśćàŕď ŵàĺķţĥŕōũĝĥ",
"failedToSendComment": "Ƒàĩĺēď ţō śēńď ćōḿḿēńţ",
"filesReviewed": "{{reviewedCount}} ōƒ {{totalCount}} ƒĩĺēś ŕēvĩēŵēď",
"filterChangedFiles": "Ƒĩĺţēŕ ćĥàńĝēď ƒĩĺēś",
"findingCount_one": "{{count}} ƒĩńďĩńĝ",
"findingCount_other": "{{count}} ƒĩńďĩńĝś",
"fixComments": "Ƒĩx Ćōḿḿēńţś",
"goToFindings_one": "Ĝō ţō {{count}} ŕēvĩēŵ ƒĩńďĩńĝ",
"goToFindings_other": "Ĝō ţō {{count}} ŕēvĩēŵ ƒĩńďĩńĝś",
"loadingChangedFiles": "Ĺōàďĩńĝ ćĥàńĝēď ƒĩĺēś...",
"loadingPRChanges": "Ĺōàďĩńĝ {{repo}} #{{prNumber}} ćĥàńĝēś…",
"markdownPreviewPartialNotice": "Śĥōŵĩńĝ ćĥàńĝēď Ḿàŕķďōŵń ƒŕàĝḿēńţś. Ũńćĥàńĝēď ćōńţēńţ ĩś ńōţ ĩńćĺũďēď ĩń ţĥĩś ďĩƒƒ.",
"moreActionsFor": "Ḿōŕē àćţĩōńś ƒōŕ {{filePath}}",
"new": "ńēŵ",
"newWalkthrough": "Ńēŵ ŵàĺķţĥŕōũĝĥ",
"noChangesToReview": "Ńō ćĥàńĝēś ţō ŕēvĩēŵ",
"noChangesToReviewYet": "Ńō ćĥàńĝēś ţō ŕēvĩēŵ ŷēţ.",
"noCommentsToFixYet": "Ńō ćōḿḿēńţś ţō ƒĩx ŷēţ.",
"noFilesMatchTheFilter": "Ńō ƒĩĺēś ḿàţćĥ ţĥē ƒĩĺţēŕ",
"noOpenFindings": "Ńō ōƥēń ƒĩńďĩńĝś.",
"noTextualDiffAdded": "Àďďēď ƒĩĺē ĥàś ńō ţēxţũàĺ ďĩƒƒ",
"noTextualDiffDeleted": "Ďēĺēţēď ƒĩĺē ĥàś ńō ţēxţũàĺ ďĩƒƒ",
"noTextualDiffModified": "Ńō ţēxţũàĺ ďĩƒƒ àvàĩĺàƀĺē",
"noTextualDiffMoved": "Ƒĩĺē ḿōvēď; ńō ţēxţũàĺ ćĥàńĝēś",
"noTextualDiffMovedFrom": "Ḿōvēď ƒŕōḿ {{oldPath}}; ńō ţēxţũàĺ ćĥàńĝēś",
"noTextualDiffUntracked": "Ũńţŕàćķēď ƒĩĺē ĥàś ńō ţēxţũàĺ ďĩƒƒ",
"old": "ōĺď",
"openFindingCount_one": "{{count}} ōƥēń ƒĩńďĩńĝ",
"openFindingCount_other": "{{count}} ōƥēń ƒĩńďĩńĝś",
"pendingCommentCount_one": "{{count}} ƥēńďĩńĝ ŕēvĩēŵ ćōḿḿēńţ",
"pendingCommentCount_other": "{{count}} ƥēńďĩńĝ ŕēvĩēŵ ćōḿḿēńţś",
"pleaseTryAgain": "Ƥĺēàśē ţŕŷ àĝàĩń.",
"previewMarkdown": "Ƥŕēvĩēŵ ḿàŕķďōŵń",
"queuedForTheAgent": "Qũēũēď ƒōŕ ţĥē àĝēńţ.",
"resizeFileList": "Ŕēśĩźē ƒĩĺē ĺĩśţ",
"retry": "Ŕēţŕŷ",
"revertChanges": "Ŕēvēŕţ ćĥàńĝēś",
"reviewAgentUnavailableNotice": "Ńō ĩńƒēŕēńćē-ćàƥàƀĺē àĝēńţ ĩś ćōńƒĩĝũŕēď ƒōŕ ŕēvĩēŵ. <2>Śēţţĩńĝś → Ũţĩĺĩţŷ Àĝēńţś</2>",
"reviewChanges": "Ŕēvĩēŵ ćĥàńĝēś",
"reviewChangesTitle": "Ŕēvĩēŵ Ćĥàńĝēś",
"reviewInProgress": "Ŕēvĩēŵ ĩń ƥŕōĝŕēśś",
"reviewPullRequest": "Ŕēvĩēŵ ƥũĺĺ ŕēqũēśţ",
"reviewPullRequestFor": "Ŕēvĩēŵ ƥũĺĺ ŕēqũēśţ {{repo}} #{{prNumber}}",
"reviewTheseChangesWithAnAgent": "Ŕēvĩēŵ ţĥēśē ćĥàńĝēś ŵĩţĥ àń àĝēńţ",
"sentToTheAgent": "Śēńţ ţō ţĥē àĝēńţ.",
"showDiff": "Śĥōŵ ďĩƒƒ",
"staleChanged": "ćĥàńĝēď",
"switchToSplitView": "Śŵĩţćĥ ţō śƥĺĩţ vĩēŵ",
"switchToUnifiedView": "Śŵĩţćĥ ţō ũńĩƒĩēď vĩēŵ",
"theReviewFailed": "Ţĥē ŕēvĩēŵ ƒàĩĺēď",
"toggleWordWrap": "Ţōĝĝĺē ŵōŕď ŵŕàƥ",
"view": "Vĩēŵ",
"walkMeThroughTheseChanges": "Ŵàĺķ ḿē ţĥŕōũĝĥ ţĥēśē ćĥàńĝēś",
"walkMeThroughTheseReviewChanges": "Ŵàĺķ ḿē ţĥŕōũĝĥ ţĥēśē ŕēvĩēŵ ćĥàńĝēś",
"walkthrough": "Ŵàĺķţĥŕōũĝĥ",
"walkthroughDiscarded": "Ŵàĺķţĥŕōũĝĥ ďĩśćàŕďēď",
"wrapLongLines": "Ŵŕàƥ ĺōńĝ ĺĩńēś"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"account": "Àććōũńţ",
"archivedLoadFailed": "Àŕćĥĩvēď ţàśķś ćōũĺď ńōţ ƀē ĺōàďēď",
"retry": "Ŕēţŕý",
"retry": "Ŕēţŕŷ",
"activeWorkspaceBadge": "Àćţĩvē",
"activity": "Àćţĩvĩţŷ",
"addAgent": "Àďď àĝēńţ",