feat(i18n): localize the GitHub and GitLab task surfaces (#2283)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-05 15:14:26 +02:00
committed by GitHub
parent 56350ffc2b
commit 58eee5a314
57 changed files with 1572 additions and 467 deletions
@@ -16,6 +16,7 @@ import { formatRelativeTime } from "@/lib/utils";
import type { GitHubIssue, TaskIssueLink } from "@/lib/types/github";
import type { LaunchPayload, TaskPreset } from "./quick-task-launcher";
import { TaskRowIndicator } from "./task-row-indicator";
import { useTranslation } from "react-i18next";
type IssueListProps = {
items: GitHubIssue[];
@@ -35,13 +36,14 @@ function StartTaskMenu({
presets: TaskPreset[];
onStartTask: IssueListProps["onStartTask"];
}) {
const { t } = useTranslation();
const launch = (preset: TaskPreset) => onStartTask({ kind: "issue", issue, preset });
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="h-7 gap-1 cursor-pointer">
<IconPlus className="h-3.5 w-3.5" />
Task
{t("github:task")}
<IconChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
@@ -94,6 +96,7 @@ function IssueRow({
onStartTask: IssueListProps["onStartTask"];
tasks: TaskIssueLink[] | undefined;
}) {
const { t } = useTranslation();
const StateIcon = issue.state === "open" ? IconCircle : IconCircleCheck;
const stateClass =
issue.state === "open"
@@ -121,7 +124,10 @@ function IssueRow({
</span>
<span>·</span>
<span className="whitespace-nowrap">
by {issue.author_login} · opened {formatRelativeTime(issue.created_at)}
{t("github:byAuthorOpenedAgo", {
author: issue.author_login,
time: formatRelativeTime(issue.created_at),
})}
</span>
<IssueLabels labels={issue.labels} />
<TaskRowIndicator
@@ -149,6 +155,7 @@ function IssueListBody({
onStartTask,
issueKeyToTasks,
}: IssueListProps) {
const { t } = useTranslation();
if (loading) {
return (
<div className="flex justify-center py-10">
@@ -162,7 +169,7 @@ function IssueListBody({
if (items.length === 0) {
return (
<div className="text-center py-10 text-muted-foreground text-sm">
No issues match this filter.
{t("github:noIssuesMatchThisFilter")}
</div>
);
}
@@ -6,6 +6,7 @@ import { Input } from "@kandev/ui/input";
import { cn } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/utils";
import { RepoFilterCombobox } from "./repo-filter-combobox";
import { useTranslation } from "react-i18next";
type ListToolbarProps = {
title: string;
@@ -33,11 +34,12 @@ function RefreshControls({
onRefresh: () => void;
showUpdatedPrefix: boolean;
}) {
const { t } = useTranslation();
return (
<>
{lastFetchedAt && !loading && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{showUpdatedPrefix ? "Updated " : ""}
{showUpdatedPrefix ? t("github:updated") : ""}
{formatRelativeTime(lastFetchedAt.toISOString())}
</span>
)}
@@ -47,7 +49,7 @@ function RefreshControls({
className="h-8 w-8 cursor-pointer"
onClick={onRefresh}
disabled={loading}
title="Refresh"
title={t("github:refresh")}
>
<IconRefresh className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
@@ -69,6 +71,7 @@ export function ListToolbar({
repoOptions,
onRefresh,
}: ListToolbarProps) {
const { t } = useTranslation();
const dirty = customQuery !== committedQuery;
return (
<div className="px-4 sm:px-6 py-2.5 border-b shrink-0 flex flex-col md:flex-row md:items-center md:flex-wrap gap-2 md:gap-3">
@@ -94,7 +97,7 @@ export function ListToolbar({
repoFilter={repoFilter}
onRepoFilterChange={onRepoFilterChange}
repoOptions={repoOptions}
ariaLabel="Filter GitHub results by repository"
ariaLabel={t("github:filterGithubResultsByRepository")}
triggerClassName="w-full md:w-[220px] h-8 border border-input bg-background hover:bg-secondary/50 px-2 py-1.5 text-xs/relaxed"
className="md:min-w-[360px]"
testId="github-repo-filter-trigger"
@@ -113,12 +116,12 @@ export function ListToolbar({
onBlur={() => {
if (dirty) onCommitCustomQuery();
}}
placeholder='Custom query — press Enter. e.g. "is:open review-requested:@me"'
placeholder={t("github:customQueryPressEnterEG")}
className="h-8 pr-20"
/>
{dirty && (
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-[10px] uppercase tracking-wider text-muted-foreground hidden sm:inline">
Press Enter
{t("github:pressEnter")}
</span>
)}
</div>
@@ -23,6 +23,7 @@ import type { LaunchPayload, TaskPreset } from "./quick-task-launcher";
import { PRStatusBadges } from "./pr-status-badges";
import { prStatusKey, usePRStatuses } from "./use-pr-statuses";
import { PRRowTaskIndicator } from "./pr-row-task-indicator";
import { useTranslation } from "react-i18next";
type PRListProps = {
workspaceId: string | null;
@@ -59,6 +60,7 @@ function StartTaskMenu({
presets: TaskPreset[];
onStartTask: PRListProps["onStartTask"];
}) {
const { t } = useTranslation();
const launch = (preset: TaskPreset) => onStartTask({ kind: "pr", pr, preset });
return (
<DropdownMenu>
@@ -70,7 +72,7 @@ function StartTaskMenu({
data-testid="pr-start-task-trigger"
>
<IconPlus className="h-3.5 w-3.5" />
Task
{t("github:task")}
<IconChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
@@ -112,6 +114,7 @@ function PRRow({
tasks: TaskPR[] | undefined;
}) {
const { Icon: StateIcon, className: stateIconClass } = prStateIcon(pr);
const { t } = useTranslation();
return (
<div
className="flex items-start gap-3 px-4 py-3 hover:bg-muted/40 transition-colors"
@@ -134,7 +137,10 @@ function PRRow({
</span>
<span>·</span>
<span className="whitespace-nowrap">
by {pr.author_login} · opened {formatRelativeTime(pr.created_at)}
{t("github:byAuthorOpenedAgo", {
author: pr.author_login,
time: formatRelativeTime(pr.created_at),
})}
</span>
<PRStatusBadges pr={pr} status={status} />
<PRRowTaskIndicator tasks={tasks} />
@@ -160,6 +166,7 @@ function PRListBody({
onStartTask,
prKeyToTasks,
}: PRListProps) {
const { t } = useTranslation();
const statuses = usePRStatuses(workspaceId, items);
if (loading) {
return (
@@ -174,7 +181,7 @@ function PRListBody({
if (items.length === 0) {
return (
<div className="text-center py-10 text-muted-foreground text-sm">
No pull requests match this filter.
{t("github:noPullRequestsMatchThisFilter")}
</div>
);
}
@@ -2,12 +2,14 @@
import type { TaskPR } from "@/lib/types/github";
import { TaskRowIndicator } from "./task-row-indicator";
import { useTranslation } from "react-i18next";
type PRRowTaskIndicatorProps = {
tasks: TaskPR[] | undefined;
};
export function PRRowTaskIndicator({ tasks }: PRRowTaskIndicatorProps) {
const { t } = useTranslation();
return (
<TaskRowIndicator
tasks={tasks?.map((task) => ({
@@ -16,7 +18,7 @@ export function PRRowTaskIndicator({ tasks }: PRRowTaskIndicatorProps) {
fallbackTitle: task.pr_title,
}))}
testIdPrefix="pr-row-task-indicator"
emptyLabel="No task created yet"
emptyLabel={t("github:noTaskCreatedYet")}
/>
);
}
@@ -11,6 +11,7 @@ import {
import type { Icon } from "@tabler/icons-react";
import type { GitHubPR, GitHubPRStatus } from "@/lib/types/github";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type StatusChipProps = {
Icon: Icon;
@@ -38,14 +39,17 @@ function StatusChip({ Icon, label, tone, title }: StatusChipProps) {
}
function ChecksChip({ status }: { status: GitHubPRStatus }) {
const { t } = useTranslation();
const { checks_state: state, checks_total: total, checks_passing: passing } = status;
if (!state) return null;
const label = total > 0 ? `${passing}/${total}` : "";
if (state === "success")
return <StatusChip Icon={IconCheck} label={label || "Checks passed"} tone="success" />;
return <StatusChip Icon={IconCheck} label={label || t("github:checksPassed")} tone="success" />;
if (state === "failure")
return <StatusChip Icon={IconX} label={label || "Checks failed"} tone="failure" />;
return <StatusChip Icon={IconClockHour4} label={label || "Checks running"} tone="pending" />;
return <StatusChip Icon={IconX} label={label || t("github:checksFailed")} tone="failure" />;
return (
<StatusChip Icon={IconClockHour4} label={label || t("github:checksRunning")} tone="pending" />
);
}
function ReviewChip({
@@ -55,16 +59,20 @@ function ReviewChip({
state: GitHubPRStatus["review_state"];
pending: number;
}) {
if (state === "approved") return <StatusChip Icon={IconCheck} label="Approved" tone="success" />;
const { t } = useTranslation();
if (state === "approved")
return <StatusChip Icon={IconCheck} label={t("github:approved")} tone="success" />;
if (state === "changes_requested")
return <StatusChip Icon={IconAlertTriangle} label="Changes requested" tone="failure" />;
return (
<StatusChip Icon={IconAlertTriangle} label={t("github:changesRequested")} tone="failure" />
);
if (pending > 0)
return (
<StatusChip
Icon={IconClockHour4}
label={`${pending} pending`}
label={t("github:pending", { pending })}
tone="pending"
title={`${pending} pending review(s)`}
title={t("github:pendingReviewS", { pending })}
/>
);
return null;
@@ -77,15 +85,17 @@ function MergeableChip({
state: GitHubPRStatus["mergeable_state"];
prState: GitHubPR["state"];
}) {
if (prState === "merged") return <StatusChip Icon={IconGitMerge} label="Merged" tone="success" />;
const { t } = useTranslation();
if (prState === "merged")
return <StatusChip Icon={IconGitMerge} label={t("github:merged")} tone="success" />;
if (state === "draft")
return <StatusChip Icon={IconGitPullRequestDraft} label="Draft" tone="neutral" />;
return <StatusChip Icon={IconGitPullRequestDraft} label={t("github:draft")} tone="neutral" />;
if (state === "dirty")
return <StatusChip Icon={IconAlertTriangle} label="Conflicts" tone="failure" />;
return <StatusChip Icon={IconAlertTriangle} label={t("github:conflicts")} tone="failure" />;
if (state === "blocked")
return <StatusChip Icon={IconAlertTriangle} label="Blocked" tone="pending" />;
return <StatusChip Icon={IconAlertTriangle} label={t("github:blocked")} tone="pending" />;
if (state === "behind")
return <StatusChip Icon={IconAlertTriangle} label="Behind base" tone="pending" />;
return <StatusChip Icon={IconAlertTriangle} label={t("github:behindBase")} tone="pending" />;
return null;
}
@@ -4,6 +4,7 @@ import { IntegrationScopeBar } from "@/components/integrations/presets-scope-bar
import { PR_PRESETS, ISSUE_PRESETS, type PresetOption } from "./search-bar";
import type { SavedPreset } from "./use-saved-presets";
import type { SidebarSelection } from "./presets-sidebar";
import { useTranslation } from "react-i18next";
type PresetsScopeBarProps = {
className?: string;
@@ -17,9 +18,10 @@ type PresetsScopeBarProps = {
issuePresets?: PresetOption[];
};
/** `value` is the persisted kind; only the catalog key is copy. */
const KINDS = [
{ value: "pr", label: "Pull requests" },
{ value: "issue", label: "Issues" },
{ value: "pr", labelKey: "github:titlePullRequests" },
{ value: "issue", labelKey: "github:titleIssues" },
] as const;
/**
@@ -32,12 +34,13 @@ export function PresetsScopeBar({
issuePresets = ISSUE_PRESETS,
...props
}: PresetsScopeBarProps) {
const { t } = useTranslation();
return (
<IntegrationScopeBar
{...props}
testId="github-presets-scope-bar"
savedMenuTestId="github-saved-queries-menu"
kinds={KINDS}
kinds={KINDS.map(({ value, labelKey }) => ({ value, label: t(labelKey) }))}
presetsByKind={(kind) => (kind === "pr" ? prPresets : issuePresets)}
/>
);
@@ -5,6 +5,7 @@ import type { Icon } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { PR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "./search-bar";
import type { SavedPreset } from "./use-saved-presets";
import { useTranslation } from "react-i18next";
export type SidebarSelection = {
kind: "pr" | "issue";
@@ -30,6 +31,7 @@ function KindToggle({
kind: "pr" | "issue";
onChange: (k: "pr" | "issue") => void;
}) {
const { t } = useTranslation();
return (
<div className="mx-2 mb-3 grid grid-cols-2 rounded-md border p-0.5 text-xs">
{(["pr", "issue"] as const).map((value) => (
@@ -44,7 +46,7 @@ function KindToggle({
: "text-muted-foreground hover:text-foreground",
)}
>
{value === "pr" ? "Pull requests" : "Issues"}
{value === "pr" ? t("github:pullRequests") : t("github:issues")}
</button>
))}
</div>
@@ -111,11 +113,12 @@ function PresetGroupList({
onSelect: (s: SidebarSelection) => void;
kind: "pr" | "issue";
}) {
const { t } = useTranslation();
const items = presets.filter((p) => p.group === group);
if (items.length === 0) return null;
return (
<>
<SectionHeader title={group === "inbox" ? "Inbox" : "Created"} />
<SectionHeader title={group === "inbox" ? t("github:inbox") : t("github:created")} />
{items.map((p) => (
<PresetItem
key={`${kind}-${p.value}`}
@@ -146,12 +149,13 @@ function SavedSection({
canSaveCurrent: boolean;
onSaveCurrent: () => void;
}) {
const { t } = useTranslation();
return (
<>
<SectionHeader title="Saved" />
<SectionHeader title={t("github:saved")} />
{saved.length === 0 && (
<div className="mx-2 px-2 py-1 text-xs text-muted-foreground/80 italic">
No saved queries yet.
{t("github:noSavedQueriesYet")}
</div>
)}
{saved.map((s) => (
@@ -169,7 +173,7 @@ function SavedSection({
onDelete(s.id);
}}
className="opacity-0 group-hover/item:opacity-100 transition-opacity text-muted-foreground hover:text-foreground cursor-pointer"
title="Delete saved query"
title={t("github:deleteSavedQuery")}
>
<IconX className="h-3.5 w-3.5" />
</button>
@@ -186,10 +190,10 @@ function SavedSection({
? "text-muted-foreground hover:bg-muted/50 hover:text-foreground cursor-pointer"
: "text-muted-foreground/50 cursor-not-allowed",
)}
title={canSaveCurrent ? "Save current query" : "Type a custom query first"}
title={canSaveCurrent ? t("github:saveCurrentQuery") : t("github:typeACustomQueryFirst")}
>
<IconDeviceFloppy className="h-4 w-4 shrink-0" />
<span>Save current query</span>
<span>{t("github:saveCurrentQuery")}</span>
</button>
</>
);
@@ -2,6 +2,8 @@
import { useMemo } from "react";
import { Combobox, type ComboboxOption } from "@/components/combobox";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
const ALL_REPOS = "__all__";
@@ -18,7 +20,7 @@ type RepoFilterComboboxProps = {
function buildRepoFilterOptions(repoOptions: string[]): ComboboxOption[] {
return [
{ value: ALL_REPOS, label: "All repos", keywords: ["all", "repositories", "repos"] },
{ value: ALL_REPOS, label: t("github:allRepos"), keywords: ["all", "repositories", "repos"] },
...repoOptions.map((repo) => ({ value: repo, label: repo, keywords: [repo] })),
];
}
@@ -33,6 +35,7 @@ export function RepoFilterCombobox({
triggerClassName,
className,
}: RepoFilterComboboxProps) {
const { t } = useTranslation();
const options = useMemo(() => buildRepoFilterOptions(repoOptions), [repoOptions]);
return (
@@ -45,9 +48,9 @@ export function RepoFilterCombobox({
}}
options={options}
ariaLabel={ariaLabel}
placeholder="All repos"
searchPlaceholder="Filter repositories..."
emptyMessage="No repositories found."
placeholder={t("github:allRepos")}
searchPlaceholder={t("github:filterRepositories")}
emptyMessage={t("github:noRepositoriesFound")}
triggerClassName={triggerClassName}
className={className}
testId={testId}
@@ -13,6 +13,7 @@ import { Input } from "@kandev/ui/input";
import { Button } from "@kandev/ui/button";
import { Label } from "@kandev/ui/label";
import { RepoFilterCombobox } from "./repo-filter-combobox";
import { useTranslation } from "react-i18next";
type SavePresetDialogProps = {
open: boolean;
@@ -42,6 +43,7 @@ function SavePresetForm({
onSave: (label: string, repoFilter: string) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const [value, setValue] = useState(suggestedLabel);
const [defaultRepoFilter, setDefaultRepoFilter] = useState(repoFilter);
const trimmed = value.trim();
@@ -56,16 +58,17 @@ function SavePresetForm({
return (
<>
<DialogHeader>
<DialogTitle>Save query</DialogTitle>
<DialogTitle>{t("github:saveQuery")}</DialogTitle>
<DialogDescription>
Save this {kind === "pr" ? "pull request" : "issue"} query to the sidebar for quick access
later.
{t("github:savePresetDescription", {
kind: kind === "pr" ? t("github:kindPullRequest") : t("github:kindIssue"),
})}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="preset-label" className="text-xs">
Name
{t("github:name")}
</Label>
<Input
id="preset-label"
@@ -73,13 +76,13 @@ function SavePresetForm({
value={value}
onChange={(e) => setValue(e.target.value)}
onFocus={(e) => e.target.select()}
placeholder="e.g. Needs my review"
placeholder={t("github:eGNeedsMyReview")}
/>
</div>
<div className="flex flex-col gap-1.5 text-xs">
{customQuery && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0 w-16">Query</span>
<span className="text-muted-foreground shrink-0 w-16">{t("github:query")}</span>
<code className="font-mono text-[11px] bg-muted rounded px-1.5 py-0.5 break-all">
{customQuery}
</code>
@@ -87,27 +90,27 @@ function SavePresetForm({
)}
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs">Default repository</Label>
<Label className="text-xs">{t("github:defaultRepository")}</Label>
<RepoFilterCombobox
repoFilter={defaultRepoFilter}
onRepoFilterChange={setDefaultRepoFilter}
repoOptions={repoOptions}
ariaLabel="Default repository"
ariaLabel={t("github:defaultRepository")}
triggerClassName="h-11 border border-input bg-background px-3 py-2 text-sm hover:bg-secondary/50 md:h-9 md:py-1.5"
testId="github-save-query-repo-trigger"
dropdownTestId="github-save-query-repo-dropdown"
/>
<p className="text-xs text-muted-foreground">
This repository opens by default. You can change the filter after opening the query.
{t("github:thisRepositoryOpensByDefaultYou")}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="cursor-pointer" onClick={onClose}>
Cancel
{t("common:cancel")}
</Button>
<Button className="cursor-pointer" disabled={!canSubmit} onClick={handleSubmit}>
Save
{t("common:save")}
</Button>
</DialogFooter>
</>
@@ -16,6 +16,7 @@ import { cn } from "@/lib/utils";
import { linkToTask } from "@/lib/links";
import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id";
import type { KanbanState } from "@/lib/state/slices";
import { useTranslation } from "react-i18next";
export type TaskRowLink = {
id: string;
@@ -72,6 +73,7 @@ function SingleTaskButton({
const taskData = useTaskById(task.taskId);
const stepTitle = useTaskStepTitle(taskData?.workflowStepId);
const title = taskData?.title ?? task.fallbackTitle;
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
@@ -87,8 +89,10 @@ function SingleTaskButton({
</TooltipTrigger>
<TooltipContent>
<div className="flex flex-col gap-0.5 text-xs">
<span>Task: {title}</span>
{stepTitle ? <span className="text-muted-foreground">Step: {stepTitle}</span> : null}
<span>{t("github:taskLabelled", { title })}</span>
{stepTitle ? (
<span className="text-muted-foreground">{t("github:stepLabelled", { stepTitle })}</span>
) : null}
</div>
</TooltipContent>
</Tooltip>
@@ -96,6 +100,7 @@ function SingleTaskButton({
}
export function TaskRowIndicator({ tasks, testIdPrefix, emptyLabel }: TaskRowIndicatorProps) {
const { t } = useTranslation();
const setActiveTask = useAppStore((state) => state.setActiveTask);
const router = useRouter();
const navigate = useCallback(
@@ -124,7 +129,7 @@ export function TaskRowIndicator({ tasks, testIdPrefix, emptyLabel }: TaskRowInd
<DropdownMenuTrigger asChild>
<button type="button" data-testid={`${testIdPrefix}-multi`} className={buttonClass}>
<IconChecklist className={iconClass} />
<span className="text-foreground/80">Tasks</span>
<span className="text-foreground/80">{t("github:tasks")}</span>
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px] shrink-0">
{tasks.length}
</Badge>