From 58eee5a314f339fe99444fa68740bc5adad3de2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Wed, 5 Aug 2026 15:14:26 +0200 Subject: [PATCH] feat(i18n): localize the GitHub and GitLab task surfaces (#2283) Co-authored-by: Kandev Agent --- apps/web/app/github/github-page-client.tsx | 41 ++-- apps/web/app/gitlab/gitlab-page-client.tsx | 57 ++++-- .../integration-link-sentences.test.tsx | 59 ++++++ .../web/components/github/issue-task-icon.tsx | 9 +- .../components/github/multi-pr-ci-popover.tsx | 12 +- .../github/my-github/issue-list.tsx | 13 +- .../github/my-github/list-toolbar.tsx | 13 +- .../components/github/my-github/pr-list.tsx | 13 +- .../my-github/pr-row-task-indicator.tsx | 4 +- .../github/my-github/pr-status-badges.tsx | 34 ++-- .../github/my-github/presets-scope-bar.tsx | 9 +- .../github/my-github/presets-sidebar.tsx | 18 +- .../github/my-github/repo-filter-combobox.tsx | 11 +- .../github/my-github/save-preset-dialog.tsx | 25 +-- .../github/my-github/task-row-indicator.tsx | 11 +- .../components/github/pr-checks-section.tsx | 28 ++- .../github/pr-ci-automation-controls.tsx | 94 +++++----- apps/web/components/github/pr-ci-popover.tsx | 65 ++++--- .../components/github/pr-comments-section.tsx | 17 +- .../web/components/github/pr-detail-panel.tsx | 80 ++++---- .../web/components/github/pr-merge-button.tsx | 39 ++-- .../github/pr-mergeability-notice.tsx | 16 +- .../github/pr-mergeability-row.test.tsx | 12 +- .../components/github/pr-mergeability-row.tsx | 28 ++- .../components/github/pr-reviews-section.tsx | 20 +- apps/web/components/github/pr-shared.tsx | 12 +- apps/web/components/github/pr-status-chip.tsx | 25 ++- .../components/github/pr-topbar-button.tsx | 13 +- .../components/gitlab/mr-commits-section.tsx | 6 +- .../web/components/gitlab/mr-detail-panel.tsx | 105 ++++++----- .../gitlab/mr-discussions-section.tsx | 21 ++- .../components/gitlab/mr-files-section.tsx | 6 +- .../gitlab/mr-overview-section.test.ts | 30 +-- .../components/gitlab/mr-overview-section.tsx | 25 +-- .../gitlab/mr-reviewer-control.test.ts | 2 +- .../components/gitlab/mr-reviewer-control.tsx | 41 ++-- .../components/gitlab/mr-topbar-button.tsx | 26 ++- .../gitlab/my-gitlab/issue-list.tsx | 10 +- .../gitlab/my-gitlab/list-toolbar.tsx | 15 +- .../components/gitlab/my-gitlab/mr-list.tsx | 10 +- .../my-gitlab/mr-row-task-indicator.tsx | 4 +- .../gitlab/my-gitlab/presets-scope-bar.tsx | 9 +- .../gitlab/my-gitlab/presets-sidebar.tsx | 35 +++- .../gitlab/my-gitlab/save-preset-dialog.tsx | 21 ++- .../gitlab/my-gitlab/start-task-menu.tsx | 6 +- .../gitlab/subscription-toggle.test.ts | 5 +- .../components/gitlab/subscription-toggle.tsx | 31 ++- .../components/gitlab/task-mr-link-dialog.tsx | 22 ++- .../integrations/presets-scope-bar-base.tsx | 12 +- apps/web/eslint.i18n.options.mjs | 86 +++++++++ .../hooks/domains/gitlab/use-mr-actions.ts | 59 +++++- apps/web/src/locales/en/github.json | 176 +++++++++++++++++- apps/web/src/locales/en/gitlab.json | 155 +++++++++++++-- apps/web/src/locales/en/integrations.json | 6 + apps/web/src/locales/pseudo/github.json | 176 +++++++++++++++++- apps/web/src/locales/pseudo/gitlab.json | 155 +++++++++++++-- apps/web/src/locales/pseudo/integrations.json | 6 + 57 files changed, 1572 insertions(+), 467 deletions(-) create mode 100644 apps/web/components/github/integration-link-sentences.test.tsx create mode 100644 apps/web/src/locales/en/integrations.json create mode 100644 apps/web/src/locales/pseudo/integrations.json diff --git a/apps/web/app/github/github-page-client.tsx b/apps/web/app/github/github-page-client.tsx index 77d9d5ed47..daafb33406 100644 --- a/apps/web/app/github/github-page-client.tsx +++ b/apps/web/app/github/github-page-client.tsx @@ -48,6 +48,7 @@ import { import { useGitHubActionPresets } from "@/hooks/domains/github/use-github-action-presets"; import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; import { hasGitHubPersonalActor } from "@/lib/github-auth"; +import { Trans, useTranslation } from "react-i18next"; type GitHubPageClientProps = { workspaceId?: string; @@ -57,10 +58,11 @@ type GitHubPageClientProps = { }; function PageHeader({ onOpenMobileSidebar }: { onOpenMobileSidebar?: () => void }) { + const { t } = useTranslation(); return ( } actions={ onOpenMobileSidebar && ( @@ -70,7 +72,7 @@ function PageHeader({ onOpenMobileSidebar }: { onOpenMobileSidebar?: () => void onClick={onOpenMobileSidebar} className="md:hidden cursor-pointer" data-testid="github-mobile-menu-button" - aria-label="Open GitHub filters" + aria-label={t("github:openGithubFilters")} > @@ -87,6 +89,7 @@ function NotAuthenticatedNotice({ workspaceId?: string; personalRequired: boolean; }) { + const { t } = useTranslation(); const settingsHref = workspaceId ? `/settings/workspace/${workspaceId}/integrations/github` : "/settings/integrations/github"; @@ -94,24 +97,23 @@ function NotAuthenticatedNotice({ {personalRequired - ? "Connect your personal GitHub identity to see pull requests and issues assigned to you." - : "GitHub is not connected. Configure workspace automation with gh CLI or a personal access token."}{" "} - - Open GitHub settings - {" "} - to see your pull requests and issues. + ? t("github:connectYourPersonalGithubIdentityTo") + : t("github:githubIsNotConnectedConfigureWorkspace")}{" "} + + + to see your pull requests and issues. + ); } function NoWorkspaceNotice() { + const { t } = useTranslation(); return (
- - No workspace configured. Create a workspace first to start tasks from PRs/issues. - + {t("github:noWorkspaceConfiguredCreateAWorkspace")}
); @@ -126,14 +128,15 @@ function resolveTitle( saved: SavedPreset[], prPresets: PresetOption[], issuePresets: PresetOption[], + t: (key: string, values?: Record) => string, ): string { if (selection.source === "saved") { - return saved.find((p) => p.id === selection.id)?.label ?? "Saved query"; + return saved.find((p) => p.id === selection.id)?.label ?? t("github:savedQueryFallback"); } const presets = selection.kind === "pr" ? prPresets : issuePresets; return ( presets.find((p) => p.value === selection.id)?.label ?? - (selection.kind === "pr" ? "Pull requests" : "Issues") + (selection.kind === "pr" ? t("github:titlePullRequests") : t("github:titleIssues")) ); } @@ -325,6 +328,7 @@ function useSearchInteractionControls( } function useGitHubPageState(workspaceId: string | null, enabled: boolean) { + const { t } = useTranslation(); const { pr: resolvedPrPresets, issue: resolvedIssuePresets } = useResolvedQueryPresets(workspaceId); const { @@ -374,8 +378,8 @@ function useGitHubPageState(workspaceId: string | null, enabled: boolean) { repoFilter, ); const title = useMemo( - () => resolveTitle(selection, savedPresets, resolvedPrPresets, resolvedIssuePresets), - [selection, savedPresets, resolvedPrPresets, resolvedIssuePresets], + () => resolveTitle(selection, savedPresets, resolvedPrPresets, resolvedIssuePresets, t), + [selection, savedPresets, resolvedPrPresets, resolvedIssuePresets, t], ); const onSelect = useSidebarSelectionHandler({ @@ -492,6 +496,7 @@ export function GitHubPageClient({ steps, repositories, }: GitHubPageClientProps) { + const { t } = useTranslation(); const { status, loaded } = useGitHubStatus(workspaceId ?? null); const authed = hasGitHubUserIdentity(status); const [launchPayload, setLaunchPayload] = useState(null); @@ -525,7 +530,9 @@ export function GitHubPageClient({ return (
- {!loaded &&
Checking GitHub status…
} + {!loaded && ( +
{t("github:checkingGithubStatus")}
+ )} {loaded && !authed && (
- Filters + {t("github:filters")} void; }) { + const { t } = useTranslation(); return ( } actions={ onOpenMobileSidebar && ( @@ -66,7 +68,7 @@ function PageHeader({ onClick={onOpenMobileSidebar} className="h-11 w-11 md:hidden cursor-pointer" data-testid="gitlab-mobile-menu-button" - aria-label="Open GitLab filters" + aria-label={t("gitlab:openGitlabFilters")} > @@ -77,29 +79,37 @@ function PageHeader({ } function NotConnectedNotice({ reconnect }: { reconnect?: boolean }) { + const { t } = useTranslation(); return ( {reconnect - ? "GitLab credentials are configured, but authentication failed. Reconnect in " - : "GitLab is not connected. Configure GitLab authentication in "} - - Settings → GitLab - {" "} - to see your merge requests and issues. + ? t("gitlab:gitlabCredentialsAreConfiguredButAuthentication") + : t("gitlab:gitlabIsNotConnectedConfigureGitlab")} + + + to see your merge requests and issues. + ); } -function resolveTitle(selection: SidebarSelection, saved: SavedPreset[]): string { +function resolveTitle( + selection: SidebarSelection, + saved: SavedPreset[], + t: (key: string, values?: Record) => string, +): string { if (selection.source === "saved") { - return saved.find((p) => p.id === selection.id)?.label ?? "Saved query"; + return saved.find((p) => p.id === selection.id)?.label ?? t("gitlab:savedQueryFallback"); } const presets = selection.kind === "mr" ? MR_PRESETS : ISSUE_PRESETS; return ( presets.find((p) => p.value === selection.id)?.label ?? - (selection.kind === "mr" ? "Merge requests" : "Issues") + (selection.kind === "mr" ? t("gitlab:titleMergeRequests") : t("gitlab:titleIssues")) ); } @@ -172,12 +182,14 @@ function useProjectOptions( }, [knownProjects, projectFilter]); } +/** Lifted out of `useGitLabPageState`, which is at the 100-line function cap. */ +function initialSelection(): SidebarSelection { + return { kind: "mr", source: "preset", id: MR_PRESETS[0]?.value ?? "" }; +} + function useGitLabPageState(searchEnabled: boolean, workspaceId?: string) { - const [selection, setSelection] = useState(() => ({ - kind: "mr", - source: "preset", - id: MR_PRESETS[0]?.value ?? "", - })); + const { t } = useTranslation(); + const [selection, setSelection] = useState(initialSelection); const { draft: customQuery, committed: committedQuery, @@ -209,7 +221,10 @@ function useGitLabPageState(searchEnabled: boolean, workspaceId?: string) { search.rawItems, projectFilter, ); - const title = useMemo(() => resolveTitle(selection, savedPresets), [selection, savedPresets]); + const title = useMemo( + () => resolveTitle(selection, savedPresets, t), + [selection, savedPresets, t], + ); const onSelect = useCallback( (s: SidebarSelection) => { @@ -402,8 +417,11 @@ function GitLabPageBody({ onStartTask: (payload: GitLabLaunchPayload) => void; host: string; }) { + const { t } = useTranslation(); if (statusLoading) { - return
Checking GitLab status…
; + return ( +
{t("gitlab:checkingGitlabStatus")}
+ ); } if (!connected) { return ( @@ -447,6 +465,7 @@ function GitLabPageOverlays({ launchPayload: GitLabLaunchPayload | null; setLaunchPayload: (payload: GitLabLaunchPayload | null) => void; }) { + const { t } = useTranslation(); const onMobileSidebarSelect = (selection: Parameters[0]) => { state.onSelect(selection); setMobileSidebarOpen(false); @@ -464,7 +483,7 @@ function GitLabPageOverlays({ data-testid="gitlab-mobile-sidebar" > - Filters + {t("gitlab:filters")} to see your pull requests… + * + * with a message of `<0> to see your pull requests…`, the tag is EMPTY and + * the `` is self-closing, so the link renders with **no text at all** — + * a visibly broken control on a page whose only purpose is to send you to + * settings. `pnpm lint`, `i18n:check` and `check-trans-indices.mjs` were all + * green on it: the key resolved, the catalogs matched, the `<0>` landed on a + * real element child. Only looking at it showed the gap. + * + * The fix is that the link LABEL belongs inside the message, so a translator can + * move it and it can never render empty. These assert the reassembled sentence + * and that the anchor carries text. + */ +function renderNotice(i18nKey: string, href: string) { + return render( + + + , + ); +} + +describe("integration not-connected notices", () => { + it("renders the GitHub sentence with a non-empty settings link", () => { + const { container } = renderNotice( + "github:openSettingsToSeePrsAndIssues", + "/settings/integrations/github", + ); + + expect(container.textContent).toBe( + "Open GitHub settings to see your pull requests and issues.", + ); + const link = screen.getByRole("link"); + expect(link.textContent).toBe("Open GitHub settings"); + expect(link.getAttribute("href")).toBe("/settings/integrations/github"); + }); + + it("renders the GitLab sentence with a non-empty settings link", () => { + const { container } = renderNotice( + "gitlab:openSettingsToSeeMrsAndIssues", + "/settings/integrations/gitlab", + ); + + expect(container.textContent).toBe("Settings → GitLab to see your merge requests and issues."); + expect(screen.getByRole("link").textContent).toBe("Settings → GitLab"); + }); +}); diff --git a/apps/web/components/github/issue-task-icon.tsx b/apps/web/components/github/issue-task-icon.tsx index e4bea70dfc..e4cd675ff8 100644 --- a/apps/web/components/github/issue-task-icon.tsx +++ b/apps/web/components/github/issue-task-icon.tsx @@ -2,10 +2,12 @@ import { IconCircleDot } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useTranslation } from "react-i18next"; type IssueInfo = { url: string; number: number }; export function IssueTaskIcon({ issueInfo }: { issueInfo: IssueInfo }) { + const { t } = useTranslation(); return ( @@ -16,12 +18,15 @@ export function IssueTaskIcon({ issueInfo }: { issueInfo: IssueInfo }) { onClick={(e) => e.stopPropagation()} className="inline-flex items-center shrink-0 text-green-600 hover:text-green-500 cursor-pointer" data-testid="issue-task-icon" - aria-label={`Issue #${issueInfo.number}`} + aria-label={t("github:issue", { number: issueInfo.number })} > - Issue #{issueInfo.number} + + {t("github:issue2")} + {issueInfo.number} + ); } diff --git a/apps/web/components/github/multi-pr-ci-popover.tsx b/apps/web/components/github/multi-pr-ci-popover.tsx index c9daeada54..ad7756899e 100644 --- a/apps/web/components/github/multi-pr-ci-popover.tsx +++ b/apps/web/components/github/multi-pr-ci-popover.tsx @@ -10,6 +10,7 @@ import { usePRFeedbackBackgroundSync } from "@/hooks/domains/github/use-pr-ci-po import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import type { TaskPR } from "@/lib/types/github"; +import { useTranslation } from "react-i18next"; /** * Renders nothing — just keeps one PR's feedback cache warm while the popover @@ -53,6 +54,7 @@ function PRTab({ removing: boolean; onRemove?: (pr: TaskPR) => void; }) { + const { t } = useTranslation(); return (
@@ -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({ · - by {issue.author_login} · opened {formatRelativeTime(issue.created_at)} + {t("github:byAuthorOpenedAgo", { + author: issue.author_login, + time: formatRelativeTime(issue.created_at), + })} @@ -162,7 +169,7 @@ function IssueListBody({ if (items.length === 0) { return (
- No issues match this filter. + {t("github:noIssuesMatchThisFilter")}
); } diff --git a/apps/web/components/github/my-github/list-toolbar.tsx b/apps/web/components/github/my-github/list-toolbar.tsx index 282bec62b4..d473f057fe 100644 --- a/apps/web/components/github/my-github/list-toolbar.tsx +++ b/apps/web/components/github/my-github/list-toolbar.tsx @@ -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 && ( - {showUpdatedPrefix ? "Updated " : ""} + {showUpdatedPrefix ? t("github:updated") : ""} {formatRelativeTime(lastFetchedAt.toISOString())} )} @@ -47,7 +49,7 @@ function RefreshControls({ className="h-8 w-8 cursor-pointer" onClick={onRefresh} disabled={loading} - title="Refresh" + title={t("github:refresh")} > @@ -69,6 +71,7 @@ export function ListToolbar({ repoOptions, onRefresh, }: ListToolbarProps) { + const { t } = useTranslation(); const dirty = customQuery !== committedQuery; return (
@@ -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 && ( - Press Enter + {t("github:pressEnter")} )}
diff --git a/apps/web/components/github/my-github/pr-list.tsx b/apps/web/components/github/my-github/pr-list.tsx index 369244f0a3..f2a94bbcf1 100644 --- a/apps/web/components/github/my-github/pr-list.tsx +++ b/apps/web/components/github/my-github/pr-list.tsx @@ -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 ( @@ -70,7 +72,7 @@ function StartTaskMenu({ data-testid="pr-start-task-trigger" > - Task + {t("github:task")} @@ -112,6 +114,7 @@ function PRRow({ tasks: TaskPR[] | undefined; }) { const { Icon: StateIcon, className: stateIconClass } = prStateIcon(pr); + const { t } = useTranslation(); return (
· - by {pr.author_login} · opened {formatRelativeTime(pr.created_at)} + {t("github:byAuthorOpenedAgo", { + author: pr.author_login, + time: formatRelativeTime(pr.created_at), + })} @@ -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 (
- No pull requests match this filter. + {t("github:noPullRequestsMatchThisFilter")}
); } diff --git a/apps/web/components/github/my-github/pr-row-task-indicator.tsx b/apps/web/components/github/my-github/pr-row-task-indicator.tsx index ea215f0174..21a8e90cfc 100644 --- a/apps/web/components/github/my-github/pr-row-task-indicator.tsx +++ b/apps/web/components/github/my-github/pr-row-task-indicator.tsx @@ -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 ( ({ @@ -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")} /> ); } diff --git a/apps/web/components/github/my-github/pr-status-badges.tsx b/apps/web/components/github/my-github/pr-status-badges.tsx index bb3ff6cde3..9d26e9a9fe 100644 --- a/apps/web/components/github/my-github/pr-status-badges.tsx +++ b/apps/web/components/github/my-github/pr-status-badges.tsx @@ -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 ; + return ; if (state === "failure") - return ; - return ; + return ; + return ( + + ); } function ReviewChip({ @@ -55,16 +59,20 @@ function ReviewChip({ state: GitHubPRStatus["review_state"]; pending: number; }) { - if (state === "approved") return ; + const { t } = useTranslation(); + if (state === "approved") + return ; if (state === "changes_requested") - return ; + return ( + + ); if (pending > 0) return ( ); return null; @@ -77,15 +85,17 @@ function MergeableChip({ state: GitHubPRStatus["mergeable_state"]; prState: GitHubPR["state"]; }) { - if (prState === "merged") return ; + const { t } = useTranslation(); + if (prState === "merged") + return ; if (state === "draft") - return ; + return ; if (state === "dirty") - return ; + return ; if (state === "blocked") - return ; + return ; if (state === "behind") - return ; + return ; return null; } diff --git a/apps/web/components/github/my-github/presets-scope-bar.tsx b/apps/web/components/github/my-github/presets-scope-bar.tsx index 0e130987e7..39aa7757f1 100644 --- a/apps/web/components/github/my-github/presets-scope-bar.tsx +++ b/apps/web/components/github/my-github/presets-scope-bar.tsx @@ -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 ( ({ value, label: t(labelKey) }))} presetsByKind={(kind) => (kind === "pr" ? prPresets : issuePresets)} /> ); diff --git a/apps/web/components/github/my-github/presets-sidebar.tsx b/apps/web/components/github/my-github/presets-sidebar.tsx index fc183b1c0d..5ed4f0827f 100644 --- a/apps/web/components/github/my-github/presets-sidebar.tsx +++ b/apps/web/components/github/my-github/presets-sidebar.tsx @@ -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 (
{(["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")} ))}
@@ -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 ( <> - + {items.map((p) => ( void; }) { + const { t } = useTranslation(); return ( <> - + {saved.length === 0 && (
- No saved queries yet. + {t("github:noSavedQueriesYet")}
)} {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")} > @@ -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")} > - Save current query + {t("github:saveCurrentQuery")} ); diff --git a/apps/web/components/github/my-github/repo-filter-combobox.tsx b/apps/web/components/github/my-github/repo-filter-combobox.tsx index 28e1ed3f1e..f5f50894ac 100644 --- a/apps/web/components/github/my-github/repo-filter-combobox.tsx +++ b/apps/web/components/github/my-github/repo-filter-combobox.tsx @@ -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} diff --git a/apps/web/components/github/my-github/save-preset-dialog.tsx b/apps/web/components/github/my-github/save-preset-dialog.tsx index 193954a802..877ef2858a 100644 --- a/apps/web/components/github/my-github/save-preset-dialog.tsx +++ b/apps/web/components/github/my-github/save-preset-dialog.tsx @@ -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 ( <> - Save query + {t("github:saveQuery")} - 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"), + })}
setValue(e.target.value)} onFocus={(e) => e.target.select()} - placeholder="e.g. Needs my review" + placeholder={t("github:eGNeedsMyReview")} />
{customQuery && (
- Query + {t("github:query")} {customQuery} @@ -87,27 +90,27 @@ function SavePresetForm({ )}
- +

- This repository opens by default. You can change the filter after opening the query. + {t("github:thisRepositoryOpensByDefaultYou")}

diff --git a/apps/web/components/github/my-github/task-row-indicator.tsx b/apps/web/components/github/my-github/task-row-indicator.tsx index 46540d4606..ff7f5b08d7 100644 --- a/apps/web/components/github/my-github/task-row-indicator.tsx +++ b/apps/web/components/github/my-github/task-row-indicator.tsx @@ -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 ( @@ -87,8 +89,10 @@ function SingleTaskButton({
- Task: {title} - {stepTitle ? Step: {stepTitle} : null} + {t("github:taskLabelled", { title })} + {stepTitle ? ( + {t("github:stepLabelled", { stepTitle })} + ) : null}
@@ -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 - Watches this task's linked pull request during the 1 minute PR refresh loop. Auto-fix queues - task prompts for newly observed feedback, auto-merge waits for the PR to be ready, and the - notification switches wake the task's agent when the workspace's connected GitHub account is - requested for review or the PR reaches a terminal state. Review requests silently baseline - the account's current request state before notifying on a later request. + {t("github:watchesThisTaskSLinkedPull")} ); @@ -79,27 +77,29 @@ function CIAutomationPromptDialog({ onSave: () => void; onReset: () => void; }) { + const { t } = useTranslation(); const trimmed = prompt.trim(); return ( !next && onClose()}> - Auto-fix prompt + {t("github:autoFixPrompt")} - This prompt is used only for this task. Leave it blank to use the default prompt. Add{" "} - - {PR_FEEDBACK_PLACEHOLDER} - {" "} - when you want Kandev to include its PR feedback snapshot. + +
e.stopPropagation()} > - Edit default prompt + {t("github:editDefaultPrompt")}
@@ -124,14 +124,8 @@ function CIAutomationPromptDialog({ data-testid="ci-auto-fix-pr-feedback-help" className="rounded-md border border-border/70 bg-muted/30 p-3 text-xs leading-relaxed text-muted-foreground" > -

- The placeholder inserts the current PR identifier, new or changed failing checks with - GitHub job links, and new or changed review comments with file, line, and body text. -

-

- Omit the placeholder if you want the agent to pull or fetch the branch and inspect - GitHub itself instead of receiving Kandev's snapshot. -

+

{t("github:thePlaceholderInsertsTheCurrentPr")}

+

{t("github:omitThePlaceholderIfYouWant")}