feat(i18n): localize review dialog and kanban board (#2299)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"account": "Àććōũńţ",
|
||||
"archivedLoadFailed": "Àŕćĥĩvēď ţàśķś ćōũĺď ńōţ ƀē ĺōàďēď",
|
||||
"retry": "Ŕēţŕý",
|
||||
"retry": "Ŕēţŕŷ",
|
||||
"activeWorkspaceBadge": "Àćţĩvē",
|
||||
"activity": "Àćţĩvĩţŷ",
|
||||
"addAgent": "Àďď àĝēńţ",
|
||||
|
||||
Reference in New Issue
Block a user