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
@@ -0,0 +1,59 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { Trans } from "react-i18next";
import Link from "@/components/routing/app-link";
afterEach(cleanup);
/**
* The "not connected" notices on the GitHub and GitLab task pages are one
* sentence wrapped around a settings link. Migrating them created a failure that
* no gate catches: written as
*
* <Trans i18nKey="…"><Link href={…} /> to see your pull requests…</Trans>
*
* with a message of `<0></0> to see your pull requests…`, the tag is EMPTY and
* the `<Link />` 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(
<Trans i18nKey={i18nKey}>
<Link href={href} className="underline font-medium cursor-pointer" />
</Trans>,
);
}
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");
});
});
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
@@ -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 })}
>
<IconCircleDot className="h-3.5 w-3.5" />
</a>
</TooltipTrigger>
<TooltipContent>Issue #{issueInfo.number}</TooltipContent>
<TooltipContent>
{t("github:issue2")}
{issueInfo.number}
</TooltipContent>
</Tooltip>
);
}
@@ -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 (
<div className="flex shrink-0 items-center rounded-md border border-transparent">
<button
@@ -83,7 +85,7 @@ function PRTab({
<button
type="button"
data-testid={`pr-popover-remove-${prIdentitySlug(pr)}`}
aria-label={`Remove ${pr.repo} #${pr.pr_number} from task`}
aria-label={t("github:removeFromTask", { repo: pr.repo, prnumber: pr.pr_number })}
disabled={removing}
onClick={(event) => {
event.preventDefault();
@@ -124,6 +126,7 @@ export function MultiPRCIPopover({
onRemovePR?: (pr: TaskPR) => Promise<void>;
onCollapseFocus?: (remainingPR: TaskPR) => void;
}) {
const { t } = useTranslation();
// `overrideId` is only set when the user activates a tab. The displayed PR is
// derived: honour the override while it still exists, otherwise fall back to
// the worst-status PR. This keeps the selection valid as the list changes
@@ -160,8 +163,9 @@ export function MultiPRCIPopover({
}
} catch (error) {
toast({
title: "Failed to unlink pull request",
description: error instanceof Error ? error.message : "The pull request is still linked.",
title: t("github:failedToUnlinkPullRequest"),
description:
error instanceof Error ? error.message : t("github:thePullRequestIsStillLinked"),
variant: "error",
});
} finally {
@@ -190,7 +194,7 @@ export function MultiPRCIPopover({
))}
<div
role="tablist"
aria-label="Pull requests"
aria-label={t("github:pullRequests")}
data-testid="pr-multi-popover-tabs"
className="flex gap-1 overflow-x-auto border-b border-border/50 pb-2"
onKeyDown={onTablistKeyDown}
@@ -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>
@@ -2,6 +2,7 @@ import { useMemo, useState, useEffect } from "react";
import { IconCheck, IconX, IconClock } from "@tabler/icons-react";
import type { CheckRun } from "@/lib/types/github";
import { CollapsibleSection, AddToContextButton, formatDuration, formatElapsed } from "./pr-shared";
import { useTranslation } from "react-i18next";
function CheckStatusIcon({ check }: { check: CheckRun }) {
const value = check.conclusion || check.status;
@@ -49,6 +50,11 @@ function buildCheckMessage(check: CheckRun): string {
return parts.join("\n\n");
}
/**
* Agent-facing content, not UI copy: queued into the chat by `onAddAsContext`
* and sent to the agent verbatim, so it stays English — same call as the
* integration prompt templates.
*/
function buildAllFailedMessage(checks: CheckRun[]): string {
const failed = checks.filter(isFailedCheck);
const parts = [`### ${failed.length} CI Check${failed.length !== 1 ? "s" : ""} Failed`, ""];
@@ -62,14 +68,17 @@ function buildAllFailedMessage(checks: CheckRun[]): string {
return parts.join("\n");
}
function formatSectionSummary(checks: CheckRun[]): string {
function formatSectionSummary(
checks: CheckRun[],
t: (key: string, values?: Record<string, unknown>) => string,
): string {
const failed = checks.filter(isFailedCheck).length;
const passed = checks.filter((c) => c.conclusion === "success").length;
const pending = checks.length - failed - passed;
const parts: string[] = [];
if (failed > 0) parts.push(`${failed} failed`);
if (passed > 0) parts.push(`${passed} passed`);
if (pending > 0) parts.push(`${pending} pending`);
if (failed > 0) parts.push(t("github:checksFailedCount", { count: failed }));
if (passed > 0) parts.push(t("github:checksPassedCount", { count: passed }));
if (pending > 0) parts.push(t("github:checksPendingCount", { count: pending }));
return parts.join(", ");
}
@@ -80,6 +89,7 @@ export function ChecksSection({
checks: CheckRun[];
onAddAsContext: (message: string) => void;
}) {
const { t } = useTranslation();
const hasRunning = useMemo(() => checks.some((c) => c.started_at && !c.completed_at), [checks]);
const [, setTick] = useState(0);
@@ -89,18 +99,20 @@ export function ChecksSection({
return () => clearInterval(id);
}, [hasRunning]);
const summary = checks.length > 0 ? ` \u2014 ${formatSectionSummary(checks)}` : "";
const summary = checks.length > 0 ? ` \u2014 ${formatSectionSummary(checks, t)}` : "";
const hasFailed = checks.some(isFailedCheck);
return (
<CollapsibleSection
title={`CI Checks${summary}`}
title={t("github:ciChecks", { summary })}
count={checks.length}
defaultOpen
onAddAll={hasFailed ? () => onAddAsContext(buildAllFailedMessage(checks)) : undefined}
addAllLabel="Add all failed checks to chat context"
addAllLabel={t("github:addAllFailedChecksToChat")}
>
{checks.length === 0 && <p className="text-xs text-muted-foreground px-2 py-2">No checks</p>}
{checks.length === 0 && (
<p className="text-xs text-muted-foreground px-2 py-2">{t("github:noChecks")}</p>
)}
{checks.map((check) => {
const label = conclusionLabel(check);
const duration = checkDurationText(check);
@@ -27,10 +27,12 @@ import type {
TaskCIPRAutomationState,
TaskPR,
} from "@/lib/types/github";
import { Trans, useTranslation } from "react-i18next";
const PR_FEEDBACK_PLACEHOLDER = "{{pr.feedback}}";
function CIAutomationInfoButton() {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
@@ -39,17 +41,13 @@ function CIAutomationInfoButton() {
variant="ghost"
size="icon"
className="h-6 w-6 cursor-help text-muted-foreground hover:text-foreground"
aria-label="Explain CI automation options"
aria-label={t("github:explainCiAutomationOptions")}
>
<IconInfoCircle className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" align="end" className="max-w-[280px] text-xs leading-relaxed">
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")}
</TooltipContent>
</Tooltip>
);
@@ -79,27 +77,29 @@ function CIAutomationPromptDialog({
onSave: () => void;
onReset: () => void;
}) {
const { t } = useTranslation();
const trimmed = prompt.trim();
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Auto-fix prompt</DialogTitle>
<DialogTitle>{t("github:autoFixPrompt")}</DialogTitle>
<DialogDescription>
This prompt is used only for this task. Leave it blank to use the default prompt. Add{" "}
<code
data-testid="ci-auto-fix-pr-feedback-placeholder"
className="rounded bg-muted px-1 py-0.5 text-[11px]"
<Trans
i18nKey="github:autoFixPromptDescription"
values={{ placeholder: PR_FEEDBACK_PLACEHOLDER }}
>
{PR_FEEDBACK_PLACEHOLDER}
</code>{" "}
when you want Kandev to include its PR feedback snapshot.
<code
data-testid="ci-auto-fix-pr-feedback-placeholder"
className="rounded bg-muted px-1 py-0.5 text-[11px]"
/>
</Trans>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label htmlFor="task-ci-auto-fix-prompt" className="text-xs">
Task auto-fix prompt
{t("github:taskAutoFixPrompt")}
</Label>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button
@@ -109,14 +109,14 @@ function CIAutomationPromptDialog({
className="h-7 cursor-pointer px-2 text-xs"
onClick={() => onPromptChange(insertPRFeedbackPlaceholder(prompt))}
>
Insert PR feedback
{t("github:insertPrFeedback")}
</Button>
<a
href="/settings/prompts"
className="cursor-pointer text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
Edit default prompt
{t("github:editDefaultPrompt")}
</a>
</div>
</div>
@@ -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"
>
<p>
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.
</p>
<p className="mt-2">
Omit the placeholder if you want the agent to pull or fetch the branch and inspect
GitHub itself instead of receiving Kandev's snapshot.
</p>
<p>{t("github:thePlaceholderInsertsTheCurrentPr")}</p>
<p className="mt-2">{t("github:omitThePlaceholderIfYouWant")}</p>
</div>
<Textarea
id="task-ci-auto-fix-prompt"
@@ -143,17 +137,17 @@ function CIAutomationPromptDialog({
</div>
<DialogFooter>
<Button variant="ghost" className="cursor-pointer" disabled={saving} onClick={onClose}>
Cancel
{t("common:cancel")}
</Button>
<Button variant="outline" className="cursor-pointer" disabled={saving} onClick={onReset}>
Use default
{t("github:useDefault")}
</Button>
<Button
className="cursor-pointer"
disabled={saving || trimmed.length === 0}
onClick={onSave}
>
Save prompt
{t("github:savePrompt")}
</Button>
</DialogFooter>
</DialogContent>
@@ -210,6 +204,7 @@ function CIAutomationErrorRow({
loading: boolean;
onRetry: () => void;
}) {
const { t } = useTranslation();
return (
<div
role="alert"
@@ -225,7 +220,7 @@ function CIAutomationErrorRow({
onClick={onRetry}
>
<IconRefresh className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
Retry
{t("github:retry")}
</Button>
</div>
);
@@ -286,17 +281,15 @@ function CIAutoFixRoundHelpButton({
state: TaskCIPRAutomationState | undefined;
maxRounds: number | null | undefined;
}) {
const { t } = useTranslation();
const round = autoFixRoundForState(state, maxRounds);
return (
<CIAutomationHelpButton testId="ci-auto-fix-round-help" ariaLabel="Explain auto-fix rounds">
<CIAutomationHelpButton
testId="ci-auto-fix-round-help"
ariaLabel={t("github:explainAutoFixRounds")}
>
<span data-testid="ci-auto-fix-round-explanation">
Auto-fix has used {round.current} of {round.max} rounds for this PR. A round is counted when
Kandev sends or queues a CI auto-fix message. Kandev waits for all PR checks to finish
before starting a new CI auto-fix turn, so the agent gets the final failed checks and
current comments together. Updating an already queued auto-fix message does not use another
round. When this is at {round.max}/{round.max} and there is no pending auto-fix message left
to update, Kandev pauses auto-fix for this PR so it cannot loop forever. Disable and
re-enable auto-fix after manual review to start over.
{t("github:autoFixRoundExplanation", { current: round.current, max: round.max })}
</span>
</CIAutomationHelpButton>
);
@@ -313,6 +306,7 @@ function PRAgentPromptRows({
disabled: boolean;
patchOption: (patch: TaskCIAutomationPatch) => void;
}) {
const { t } = useTranslation();
const terminalHelpID = `task-pr-terminal-help-${taskId}`;
const terminalHelp = "Wake the agent when review work ends. Choose either or both outcomes.";
return (
@@ -328,7 +322,7 @@ function PRAgentPromptRows({
</span>
<CIAutomationRow
id={`task-pr-merged-prompt-${taskId}`}
label="PR merged"
label={t("github:prMerged")}
describedBy={terminalHelpID}
checked={Boolean(options?.prompt_on_merged)}
disabled={disabled}
@@ -336,7 +330,7 @@ function PRAgentPromptRows({
help={
<CIAutomationHelpButton
testId="ci-pr-terminal-help"
ariaLabel="Explain final PR state notifications"
ariaLabel={t("github:explainFinalPrStateNotifications")}
>
{terminalHelp}
</CIAutomationHelpButton>
@@ -344,7 +338,7 @@ function PRAgentPromptRows({
/>
<CIAutomationRow
id={`task-pr-closed-prompt-${taskId}`}
label="PR closed without merging"
label={t("github:prClosedWithoutMerging")}
describedBy={terminalHelpID}
checked={Boolean(options?.prompt_on_closed)}
disabled={disabled}
@@ -365,6 +359,7 @@ function ReviewFollowUpSection({
disabled: boolean;
patchOption: (patch: TaskCIAutomationPatch) => void;
}) {
const { t } = useTranslation();
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
const [open, setOpen] = useState(false);
const lifecycleEnabled = Boolean(
@@ -384,10 +379,10 @@ function ReviewFollowUpSection({
variant="ghost"
size="sm"
data-testid="ci-review-follow-up-trigger"
aria-label="Toggle review follow-up automation"
aria-label={t("github:toggleReviewFollowUpAutomation")}
className={`w-full cursor-pointer justify-between px-1 text-xs text-muted-foreground ${minHeight}`}
>
Review follow-up
{t("github:reviewFollowUp")}
<IconChevronDown
aria-hidden="true"
className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-180" : ""}`}
@@ -417,6 +412,7 @@ function ReviewRequestedPromptRow({
disabled: boolean;
patchOption: (patch: TaskCIAutomationPatch) => void;
}) {
const { t } = useTranslation();
const helpID = `task-pr-review-requested-prompt-${taskId}-description`;
const help = "Wake the agent for any new request, including re-review after changes.";
return (
@@ -426,7 +422,7 @@ function ReviewRequestedPromptRow({
</span>
<CIAutomationRow
id={`task-pr-review-requested-prompt-${taskId}`}
label="Your review is requested"
label={t("github:yourReviewIsRequested")}
describedBy={helpID}
checked={Boolean(options?.prompt_on_review_requested)}
disabled={disabled}
@@ -434,7 +430,7 @@ function ReviewRequestedPromptRow({
help={
<CIAutomationHelpButton
testId="ci-review-requested-help"
ariaLabel="Explain review request notifications"
ariaLabel={t("github:explainReviewRequestNotifications")}
>
{help}
</CIAutomationHelpButton>
@@ -451,9 +447,10 @@ function CIAutomationHeader({
disabled: boolean;
onEditPrompt: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between gap-2 px-1">
<div className="text-xs font-medium text-foreground">Automation</div>
<div className="text-xs font-medium text-foreground">{t("github:automation")}</div>
<div className="flex items-center gap-1">
<CIAutomationInfoButton />
<Button
@@ -461,7 +458,7 @@ function CIAutomationHeader({
variant="ghost"
size="icon"
className="h-6 w-6 cursor-pointer text-muted-foreground hover:text-foreground"
aria-label="Edit auto-fix prompt for this task"
aria-label={t("github:editAutoFixPromptForThis")}
disabled={disabled}
onClick={onEditPrompt}
>
@@ -485,11 +482,12 @@ function CIAutomationOptionRows({
patchOption: (patch: TaskCIAutomationPatch) => void;
automationState: TaskCIPRAutomationState | undefined;
}) {
const { t } = useTranslation();
return (
<>
<CIAutomationRow
id={`task-ci-auto-fix-${pr.task_id}`}
label="Auto-fix CI and address comments"
label={t("github:autoFixCiAndAddressComments")}
checked={Boolean(options?.auto_fix_enabled)}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ auto_fix_enabled: checked })}
@@ -504,7 +502,7 @@ function CIAutomationOptionRows({
/>
<CIAutomationRow
id={`task-ci-auto-merge-${pr.task_id}`}
label="Auto-merge when ready"
label={t("github:autoMergeWhenReady")}
checked={Boolean(options?.auto_merge_enabled)}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ auto_merge_enabled: checked })}
+41 -24
View File
@@ -30,6 +30,7 @@ import type { CheckRun, TaskPR } from "@/lib/types/github";
import { PRCIAutomationControls } from "./pr-ci-automation-controls";
import { PRMergeButton } from "./pr-merge-button";
import { PRMergeabilityRow } from "./pr-mergeability-row";
import { useTranslation } from "react-i18next";
type CountsView = {
passed: number;
@@ -89,6 +90,7 @@ function PRPopoverTitle({
title: string;
onOpenDetailPanel?: () => void;
}) {
const { t } = useTranslation();
const className = "min-w-0 truncate text-sm font-medium";
if (!onOpenDetailPanel) {
return (
@@ -103,7 +105,7 @@ function PRPopoverTitle({
data-testid="pr-popover-title"
className={`${className} cursor-pointer text-left hover:underline`}
title={title}
aria-label={`Open ${title} details`}
aria-label={t("github:openDetails", { title })}
onClick={(e) => {
e.stopPropagation();
onOpenDetailPanel();
@@ -121,7 +123,8 @@ function PRCIPopoverHeader({
pr: TaskPR;
onOpenDetailPanel?: () => void;
}) {
const title = `#${pr.pr_number} ${pr.pr_title || "Untitled PR"}`;
const { t } = useTranslation();
const title = `#${pr.pr_number} ${pr.pr_title || t("github:untitledPr")}`;
return (
<div
data-testid="pr-popover-header"
@@ -136,7 +139,7 @@ function PRCIPopoverHeader({
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer text-muted-foreground hover:text-foreground"
aria-label="View pull request on GitHub"
aria-label={t("github:viewPullRequestOnGithub")}
onClick={(e) => e.stopPropagation()}
>
<IconGitPullRequest className="h-3.5 w-3.5" />
@@ -152,14 +155,16 @@ function CheckGroupIcon({ kind }: { kind: CheckBucket }) {
return <IconCircleX className="h-3.5 w-3.5 text-red-500" />;
}
function bucketLabel(kind: CheckBucket): string {
if (kind === "passed") return "Passed";
if (kind === "in_progress") return "In progress";
return "Failed";
}
/** `kind` is the persisted CheckBucket; only the catalog key is copy. */
const BUCKET_LABEL_KEYS: Record<CheckBucket, string> = {
passed: "github:checkBucketPassed",
in_progress: "github:checkBucketInProgress",
failed: "github:checkBucketFailed",
};
function CheckGroupHeader({ kind, count }: { kind: CheckBucket; count: number }) {
const label = bucketLabel(kind);
const { t } = useTranslation();
const label = t(BUCKET_LABEL_KEYS[kind]);
return (
<div className="flex items-center justify-between gap-2 px-1 py-1">
<div className="flex items-center gap-1.5">
@@ -183,6 +188,7 @@ function PRWorkflowRow({
group: WorkflowGroup;
onAddAsContext: ((message: string) => void) | null;
}) {
const { t } = useTranslation();
// For an in-progress workflow, "0/1 ran" reads as "nothing finished"
// and confuses people: did it start? It's clearer to just report how
// many jobs are running. (Failed workflows go to a different bucket,
@@ -215,7 +221,7 @@ function PRWorkflowRow({
e.stopPropagation();
void openExternalLink(group.htmlUrl).catch(() => undefined);
}}
aria-label={`Open ${group.workflow} on GitHub`}
aria-label={t("github:openOnGithub", { workflow: group.workflow })}
>
<IconExternalLink className="h-3 w-3" />
</Button>
@@ -230,7 +236,7 @@ function PRWorkflowRow({
e.stopPropagation();
onAddAsContext(buildWorkflowMessage(group));
}}
aria-label={`Add ${group.workflow} failures to chat context`}
aria-label={t("github:addFailuresToChatContext", { workflow: group.workflow })}
>
<IconPlus className="h-3 w-3" />
</Button>
@@ -239,6 +245,11 @@ function PRWorkflowRow({
);
}
/**
* Agent-facing content, not UI copy: the returned string is queued into the chat
* by `addAsContext` and sent to the agent verbatim, so it stays English for the
* same reason the integration prompt templates do.
*/
function buildWorkflowMessage(group: WorkflowGroup): string {
const failed = group.jobs.filter((j) => bucketCheck(j) === "failed");
const lines: string[] = [
@@ -309,6 +320,7 @@ function PRCheckGroup({
* picture: how close are we to "all green".
*/
function PRChecksProgressBar({ counts }: { counts: CountsView }) {
const { t } = useTranslation();
const total = counts.passed + counts.inProgress + counts.failed;
if (total === 0) return null;
const pct = (n: number) => (n / total) * 100;
@@ -319,7 +331,7 @@ function PRChecksProgressBar({ counts }: { counts: CountsView }) {
return (
<div data-testid="pr-checks-progress" className="flex flex-col gap-1.5 px-1 pt-1 pb-1.5">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-foreground">Pass rate</span>
<span className="font-medium text-foreground">{t("github:passRate")}</span>
<span className="text-muted-foreground tabular-nums">
{counts.passed}/{total} ({completePct}%)
</span>
@@ -362,6 +374,7 @@ function PRChecksSection({
isFetching: boolean;
onAddAsContext: ((message: string) => void) | null;
}) {
const { t } = useTranslation();
const aggregateCounts = useMemo(() => deriveAggregateCounts(pr), [pr]);
const { precise, byBucket } = useMemo(() => {
@@ -393,7 +406,7 @@ function PRChecksSection({
return (
<div data-testid="pr-checks-section" className="flex flex-col">
<div data-testid="pr-checks-empty" className="px-1 py-2 text-xs text-muted-foreground">
No checks have started
{t("github:noChecksHaveStarted")}
</div>
</div>
);
@@ -461,25 +474,27 @@ function PRReviewRow({ pr }: { pr: TaskPR }) {
}
function PRCommentsRow({ pr }: { pr: TaskPR }) {
const { t } = useTranslation();
if (!pr.unresolved_review_threads || pr.unresolved_review_threads <= 0) return null;
return (
<div data-testid="pr-comments-row" className="flex items-center gap-1.5 px-1 py-1 text-xs">
<IconMessageCircle className="h-3.5 w-3.5 text-muted-foreground" />
<span>
{pr.unresolved_review_threads} unresolved comment
{pr.unresolved_review_threads === 1 ? "" : "s"}
</span>
<span>{t("github:unresolvedComments", { count: pr.unresolved_review_threads })}</span>
</div>
);
}
function elapsedLabel(elapsed: number | null): string {
function elapsedLabel(
elapsed: number | null,
t: (k: string, v?: Record<string, unknown>) => string,
): string {
if (elapsed == null) return "";
if (elapsed === 0) return "updated just now";
return `updated ${formatElapsedShort(elapsed)} ago`;
if (elapsed === 0) return t("github:updatedJustNow");
return t("github:updatedAgo", { elapsed: formatElapsedShort(elapsed) });
}
function PRPopoverFooter({ lastUpdatedAt }: { lastUpdatedAt: number | null }) {
const { t } = useTranslation();
// Capture a "now" snapshot only inside the setInterval callback (an event
// handler — no impurity in render, no setState during commit). When no
// tick has fired yet, fall back to lastUpdatedAt so the rendered elapsed
@@ -504,7 +519,7 @@ function PRPopoverFooter({ lastUpdatedAt }: { lastUpdatedAt: number | null }) {
data-testid="pr-popover-updated-at"
className="text-[10px] text-muted-foreground tabular-nums"
>
{elapsedLabel(elapsed)}
{elapsedLabel(elapsed, t)}
</span>
</div>
);
@@ -519,18 +534,19 @@ function formatElapsedShort(seconds: number): string {
}
function ReconnectGitHubBlock() {
const { t } = useTranslation();
return (
<div
data-testid="pr-popover-auth-error"
className="flex flex-col items-start gap-1 px-1 py-2 text-xs"
>
<span className="text-foreground">GitHub authentication lost.</span>
<span className="text-foreground">{t("github:githubAuthenticationLost")}</span>
<a
data-testid="pr-popover-reconnect-link"
href="/settings#github"
className="cursor-pointer text-primary hover:underline"
>
Reconnect GitHub
{t("github:reconnectGithub")}
</a>
</div>
);
@@ -591,6 +607,7 @@ export function PRCIPopover({
// --- Add-to-context wiring (mirrors pr-detail-panel.tsx for failed checks) ---
function useAddCheckToContext(pr: TaskPR): ((message: string) => void) | null {
const { t } = useTranslation();
const sessionId = useAppStore((s) => s.tasks.activeSessionId);
const addComment = useCommentsStore((s) => s.addComment);
const { toast } = useToast();
@@ -614,7 +631,7 @@ function useAddCheckToContext(pr: TaskPR): ((message: string) => void) | null {
content: message,
};
addComment(comment);
toast({ description: "Added to chat context" });
toast({ description: t("github:addedToChatContext") });
},
[sessionId, prNumber, addComment, toast],
);
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { IconChevronDown, IconChevronRight } from "@tabler/icons-react";
import type { PRComment } from "@/lib/types/github";
import { CollapsibleSection, AddToContextButton, FeedbackItemRow } from "./pr-shared";
import { useTranslation } from "react-i18next";
function buildCommentMessage(comment: PRComment, prUrl: string): string {
const location = comment.path
@@ -68,6 +69,7 @@ function buildThreads(comments: PRComment[]): CommentThread[] {
}
function CommentMetaBadge({ comment, isReply }: { comment: PRComment; isReply?: boolean }) {
const { t } = useTranslation();
if (comment.path) {
return (
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[180px]">
@@ -77,7 +79,7 @@ function CommentMetaBadge({ comment, isReply }: { comment: PRComment; isReply?:
);
}
if (!isReply) {
return <span className="text-[10px] text-muted-foreground">(general)</span>;
return <span className="text-[10px] text-muted-foreground">{t("github:general")}</span>;
}
return null;
}
@@ -115,6 +117,7 @@ function ThreadBlock({
prUrl: string;
onAddAsContext: (message: string) => void;
}) {
const { t } = useTranslation();
const hasReplies = thread.replies.length > 0;
return (
<div className="space-y-1.5">
@@ -125,7 +128,7 @@ function ThreadBlock({
{hasReplies && (
<AddToContextButton
onClick={() => onAddAsContext(buildThreadMessage(thread, prUrl))}
tooltip="Add whole thread to chat context"
tooltip={t("github:addWholeThreadToChatContext")}
/>
)}
</div>
@@ -151,6 +154,7 @@ export function CommentsSection({
prUrl: string;
onAddAsContext: (message: string) => void;
}) {
const { t } = useTranslation();
const [showBotComments, setShowBotComments] = useState(false);
const humanComments = useMemo(
() => comments.filter((comment) => !comment.author_is_bot),
@@ -165,14 +169,14 @@ export function CommentsSection({
return (
<CollapsibleSection
title="Comments"
title={t("github:comments")}
count={comments.length}
defaultOpen
onAddAll={() => onAddAsContext(buildAllCommentsMessage(comments, prUrl))}
addAllLabel="Add all comments to chat context"
addAllLabel={t("github:addAllCommentsToChatContext")}
>
{comments.length === 0 && (
<p className="text-xs text-muted-foreground px-2 py-2">No comments yet</p>
<p className="text-xs text-muted-foreground px-2 py-2">{t("github:noCommentsYet")}</p>
)}
{humanThreads.map((thread) => (
<ThreadBlock
@@ -193,7 +197,8 @@ export function CommentsSection({
) : (
<IconChevronRight className="h-3.5 w-3.5" />
)}
Bot comments ({botComments.length})
{t("github:botComments")}
{botComments.length})
</button>
)}
{showBotComments &&
+48 -32
View File
@@ -40,6 +40,7 @@ import { ChecksSection } from "./pr-checks-section";
import { ReviewsSection } from "./pr-reviews-section";
import { CommentsSection } from "./pr-comments-section";
import { usePRScopedReviewRequest } from "./use-pr-scoped-review-request";
import { Trans, useTranslation } from "react-i18next";
// --- Dockview panel wrapper ---
@@ -50,6 +51,7 @@ type PRDetailPanelProps = {
};
export function PRDetailPanelComponent({ panelId, params }: PRDetailPanelProps) {
const { t } = useTranslation();
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
const { prs } = useTaskPR(activeTaskId);
const activePR = useActiveTaskPR();
@@ -68,7 +70,7 @@ export function PRDetailPanelComponent({ panelId, params }: PRDetailPanelProps)
if (!pr || !sessionId) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No pull request linked to this session.
{t("github:noPullRequestLinkedToThis")}
</div>
);
}
@@ -83,6 +85,7 @@ export function PRDetailPanelComponent({ panelId, params }: PRDetailPanelProps)
// --- Add PR feedback as chat context ---
function useAddPRFeedbackAsContext(sessionId: string, prNumber: number) {
const { t } = useTranslation();
const { toast } = useToast();
const addComment = useCommentsStore((s) => s.addComment);
@@ -100,7 +103,7 @@ function useAddPRFeedbackAsContext(sessionId: string, prNumber: number) {
content,
};
addComment(comment);
toast({ description: "Added to chat context" });
toast({ description: t("github:addedToChatContext") });
},
[sessionId, prNumber, addComment, toast],
);
@@ -218,9 +221,10 @@ function derivePanelMetrics(taskPR: TaskPR, feedback: PRFeedback | null): PRPane
}
function DescriptionSection({ body }: { body: string }) {
const { t } = useTranslation();
if (!body) return null;
return (
<CollapsibleSection title="Description" count={1} defaultOpen={false}>
<CollapsibleSection title={t("github:description")} count={1} defaultOpen={false}>
<div className="px-2">
<PRMarkdownBody body={body} />
</div>
@@ -267,6 +271,7 @@ function ApproveButton({
feedback: PRFeedback | null;
onRefresh: () => void;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const [submitting, setSubmitting] = useState(false);
// Ensures status (and thus the authenticated username) is fetched even when
@@ -288,12 +293,12 @@ function ApproveButton({
{ owner: taskPR.owner, repo: taskPR.repo, number: taskPR.pr_number },
"APPROVE",
);
toast({ description: "PR approved", variant: "success" });
toast({ description: t("github:prApproved"), variant: "success" });
onRefresh();
} catch (e) {
toast({
title: "Failed to approve",
description: e instanceof Error ? e.message : "An error occurred",
title: t("github:failedToApprove"),
description: e instanceof Error ? e.message : t("github:anErrorOccurred"),
variant: "error",
});
} finally {
@@ -310,12 +315,33 @@ function ApproveButton({
disabled={submitting}
>
<IconCheck className="h-3.5 w-3.5" />
{submitting ? "Approving..." : `Approve as ${mutationActor}`}
{submitting ? t("github:approving") : t("github:approveAs", { mutationActor })}
</Button>
);
}
/**
* True once a conflict prompt for this PR is already queued — avoids piling up
* identical instructions if the user clicks "Resolve conflicts" again. Extracted
* from PRDetailContent, which is at the 100-line function cap.
*/
function useConflictQueued(sessionId: string, prNumber: number): boolean {
return useCommentsStore((s) =>
s.pendingForChat.some((id) => {
const c = s.byId[id];
return (
!!c &&
isPRFeedbackComment(c) &&
c.feedbackType === "conflict" &&
c.sessionId === sessionId &&
c.prNumber === prNumber
);
}),
);
}
export function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; sessionId: string }) {
const { t } = useTranslation();
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const { feedback, loading, refresh } = usePRFeedback(
workspaceId,
@@ -337,20 +363,7 @@ export function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; session
const metrics = derivePanelMetrics(taskPR, feedback);
// True once a conflict prompt for this PR is already queued — avoids piling
// up identical instructions if the user clicks "Resolve conflicts" again.
const conflictQueued = useCommentsStore((s) =>
s.pendingForChat.some((id) => {
const c = s.byId[id];
return (
!!c &&
isPRFeedbackComment(c) &&
c.feedbackType === "conflict" &&
c.sessionId === sessionId &&
c.prNumber === taskPR.pr_number
);
}),
);
const conflictQueued = useConflictQueued(sessionId, taskPR.pr_number);
const onResolveConflicts = useCallback(() => {
if (conflictQueued) return;
@@ -415,7 +428,7 @@ export function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; session
<>
<Separator />
<div className="px-3 py-2 text-[10px] text-muted-foreground text-center">
Last synced {formatTimeAgo(taskPR.last_synced_at)}
{t("github:lastSynced")} {formatTimeAgo(taskPR.last_synced_at)}
</div>
</>
)}
@@ -446,6 +459,7 @@ function HeaderTitleRow({
loading: boolean;
onRefresh: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-start justify-between gap-2">
<a
@@ -468,35 +482,38 @@ function HeaderTitleRow({
<IconRefresh className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
</Button>
</TooltipTrigger>
<TooltipContent>Refresh</TooltipContent>
<TooltipContent>{t("github:refresh")}</TooltipContent>
</Tooltip>
</div>
);
}
function HeaderDateLine({ taskPR }: { taskPR: TaskPR }) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground flex-wrap">
<span className="flex items-center gap-0.5">
by <AuthorLink author={taskPR.author_login} />
<Trans i18nKey="github:byAuthor">
by <AuthorLink author={taskPR.author_login} />
</Trans>
</span>
<span>&middot;</span>
<span className={getTimeAgoColor(taskPR.created_at)}>
opened {formatTimeAgo(taskPR.created_at)}
{t("github:openedAgo", { time: formatTimeAgo(taskPR.created_at) })}
</span>
{taskPR.merged_at && (
<>
<span>&middot;</span>
<span className="flex items-center gap-0.5">
<IconGitMerge className="h-3 w-3 text-purple-500" />
merged {formatTimeAgo(taskPR.merged_at)}
{t("github:mergedAgo", { time: formatTimeAgo(taskPR.merged_at) })}
</span>
</>
)}
{taskPR.closed_at && !taskPR.merged_at && (
<>
<span>&middot;</span>
<span>closed {formatTimeAgo(taskPR.closed_at)}</span>
<span>{t("github:closedAgo", { time: formatTimeAgo(taskPR.closed_at) })}</span>
</>
)}
</div>
@@ -504,6 +521,7 @@ function HeaderDateLine({ taskPR }: { taskPR: TaskPR }) {
}
function HeaderStatsLine({ taskPR, metrics }: { taskPR: TaskPR; metrics: PRPanelMetrics }) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
<span className="flex items-center gap-1">
@@ -516,18 +534,16 @@ function HeaderStatsLine({ taskPR, metrics }: { taskPR: TaskPR; metrics: PRPanel
</span>
<span>&middot;</span>
<span>
{metrics.reviewCount} review{metrics.reviewCount !== 1 ? "s" : ""}
{t("github:reviewCount", { count: metrics.reviewCount })}
{metrics.pendingReviewCount > 0 && (
<span className="text-yellow-600 dark:text-yellow-400">
{" "}
({metrics.pendingReviewCount} pending)
{t("github:pendingReviewCount", { count: metrics.pendingReviewCount })}
</span>
)}
</span>
<span>&middot;</span>
<span>
{metrics.commentCount} comment{metrics.commentCount !== 1 ? "s" : ""}
</span>
<span>{t("github:commentCount", { count: metrics.commentCount })}</span>
{metrics.reviewState && <ReviewStateBadge state={metrics.reviewState} />}
</div>
);
+22 -17
View File
@@ -17,6 +17,7 @@ import { mergePR } from "@/lib/api/domains/github-api";
import { getGitHubMutationActor } from "@/lib/github-auth";
import type { MergeMethod, TaskPR } from "@/lib/types/github";
import { isPRReadyToMerge } from "./pr-task-icon";
import { useTranslation } from "react-i18next";
function MutationActor({ actor }: { actor: string | null }) {
if (!actor) return null;
@@ -31,6 +32,7 @@ function CompactMergeButton({
extraMethods,
onPickMethod,
}: Omit<MergeButtonShellProps, "compact">) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-1.5">
<button
@@ -45,7 +47,7 @@ function CompactMergeButton({
</button>
{extraMethods.length > 0 && (
<div className="flex items-center justify-center gap-1 text-[11px] text-muted-foreground">
<span>or</span>
<span>{t("github:or")}</span>
{extraMethods.map((method) => (
<button
key={method}
@@ -58,7 +60,7 @@ function CompactMergeButton({
}}
className="cursor-pointer rounded px-1.5 py-0.5 font-medium hover:bg-muted hover:text-foreground disabled:cursor-default disabled:opacity-60"
>
{mergeShortLabel(method)}
{t(mergeShortLabelKey(method))}
</button>
))}
</div>
@@ -84,6 +86,7 @@ export function PRMergeButton({
onMerged?: () => void;
compact?: boolean;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const [merging, setMerging] = useState(false);
// After a successful merge we stay hidden until the store catches up to
@@ -120,12 +123,12 @@ export function PRMergeButton({
try {
await mergePR(workspaceId, taskPR.owner, taskPR.repo, taskPR.pr_number, method);
setMerged(true);
toast({ description: "PR merged", variant: "success" });
toast({ description: t("github:prMerged"), variant: "success" });
onMerged?.();
} catch (err) {
toast({
title: "Failed to merge",
description: err instanceof Error ? err.message : "An error occurred",
title: t("github:failedToMerge"),
description: err instanceof Error ? err.message : t("github:anErrorOccurred"),
variant: "error",
});
} finally {
@@ -141,7 +144,7 @@ export function PRMergeButton({
return (
<MergeButtonShell
compact={compact}
label={merging ? "Merging..." : mergeLabel(primary)}
label={merging ? t("github:merging") : t(mergeLabelKey(primary))}
disabled={merging}
actor={mutationActor}
onPrimaryClick={handlePrimary}
@@ -170,6 +173,7 @@ function MergeButtonShell({
extraMethods,
onPickMethod,
}: MergeButtonShellProps) {
const { t } = useTranslation();
// Compact (hover popover): a single full-width primary button plus quiet
// "or <method>" links for the alternates. Deliberately avoids a dropdown —
// its menu renders in a detached portal that closes the hover popover the
@@ -220,7 +224,7 @@ function MergeButtonShell({
<button
type="button"
data-testid="pr-merge-button-more"
aria-label="Choose merge method"
aria-label={t("github:chooseMergeMethod")}
disabled={disabled}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center rounded-r-md border-l border-green-700/40 bg-green-600 px-2 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500 disabled:opacity-60 cursor-pointer"
@@ -231,7 +235,7 @@ function MergeButtonShell({
<DropdownMenuContent align="end" className="w-auto">
{extraMethods.map((m) => (
<DropdownMenuItem key={m} onSelect={() => onPickMethod(m)}>
{mergeLabel(m)}
{t(mergeLabelKey(m))}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -242,29 +246,30 @@ function MergeButtonShell({
);
}
function mergeLabel(method?: MergeMethod): string {
/** `method` is GitHub's persisted merge method; only the label is copy. */
function mergeLabelKey(method?: MergeMethod): string {
switch (method) {
case "squash":
return "Squash and merge";
return "github:mergeMethodSquash";
case "rebase":
return "Rebase and merge";
return "github:mergeMethodRebase";
case "merge":
return "Create a merge commit";
return "github:mergeMethodMergeCommit";
default:
return "Merge PR";
return "github:mergeMethodDefault";
}
}
// Short form used by the compact "or <method>" alternates so the row fits the
// narrow popover without wrapping.
function mergeShortLabel(method: MergeMethod): string {
function mergeShortLabelKey(method: MergeMethod): string {
switch (method) {
case "squash":
return "Squash";
return "github:mergeMethodSquashShort";
case "rebase":
return "Rebase";
return "github:mergeMethodRebaseShort";
case "merge":
return "Merge commit";
return "github:mergeMethodMergeCommitShort";
}
}
@@ -4,6 +4,8 @@ import { IconAlertTriangle } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { cn } from "@/lib/utils";
import type { MergeableState } from "@/lib/types/github";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
// --- Pure descriptor (unit-tested) ---
@@ -30,9 +32,9 @@ export function describeMergeability({
case "dirty":
return { kind: "banner" };
case "blocked":
return { kind: "chip", label: "Blocked" };
return { kind: "chip", label: t("github:blocked") };
case "behind":
return { kind: "chip", label: "Behind base" };
return { kind: "chip", label: t("github:behindBase") };
case "clean":
case "unstable":
case "has_hooks":
@@ -75,6 +77,7 @@ function ConflictBanner({
resolveDisabled?: boolean;
popover?: boolean;
}) {
const { t } = useTranslation();
return (
<div
data-testid="pr-conflict-banner"
@@ -86,10 +89,10 @@ function ConflictBanner({
<IconAlertTriangle className="h-4 w-4 shrink-0 text-red-600 dark:text-red-400 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="text-xs font-semibold text-red-600 dark:text-red-400">
Merge conflicts with <code className="font-mono">{baseBranch}</code>
{t("github:mergeConflictsWith")} <code className="font-mono">{baseBranch}</code>
</div>
<p className="mt-0.5 text-[11px] leading-snug text-muted-foreground">
This branch can&apos;t be merged automatically. Resolve the conflicts before merging.
{t("github:thisBranchCanTBeMerged")}
</p>
{onResolveConflicts && (
<Button
@@ -100,7 +103,7 @@ function ConflictBanner({
onClick={onResolveConflicts}
disabled={resolveDisabled}
>
{resolveDisabled ? "Added to chat context" : "Resolve conflicts"}
{resolveDisabled ? t("github:addedToChatContext") : t("github:resolveConflicts")}
</Button>
)}
</div>
@@ -123,6 +126,7 @@ function MergeabilityChip({ label, popover }: { label: string; popover?: boolean
}
function NotMergeableText({ popover }: { popover?: boolean }) {
const { t } = useTranslation();
return (
<span
className={cn(
@@ -131,7 +135,7 @@ function NotMergeableText({ popover }: { popover?: boolean }) {
)}
>
<IconAlertTriangle className={popover ? "h-3.5 w-3.5" : "h-3 w-3"} />
Not mergeable
{t("github:notMergeable")}
</span>
);
}
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { t } from "@/lib/i18n";
import { cleanup, fireEvent, render } from "@testing-library/react";
import { StateProvider } from "@/components/state-provider";
import { ToastProvider } from "@/components/toast-provider";
@@ -155,19 +156,19 @@ describe("PRMergeabilityRow", () => {
describe("blockedReason", () => {
it("names the approval shortfall when required reviews aren't met", () => {
expect(blockedReason(makePR({ required_reviews: 2, review_count: 0 }))).toContain(
expect(blockedReason(makePR({ required_reviews: 2, review_count: 0 }), t)).toContain(
"2 more approvals",
);
expect(blockedReason(makePR({ required_reviews: 2, review_count: 1 }))).toContain(
expect(blockedReason(makePR({ required_reviews: 2, review_count: 1 }), t)).toContain(
"1 more approval",
);
});
it("points at a failing required check when CI is failure/pending", () => {
expect(blockedReason(makePR({ checks_state: "failure" })).toLowerCase()).toContain(
expect(blockedReason(makePR({ checks_state: "failure" }), t).toLowerCase()).toContain(
"required status check",
);
expect(blockedReason(makePR({ checks_state: "pending" })).toLowerCase()).toContain(
expect(blockedReason(makePR({ checks_state: "pending" }), t).toLowerCase()).toContain(
"required status check",
);
});
@@ -175,7 +176,7 @@ describe("blockedReason", () => {
it("does not claim a check failed when no CI is configured (empty checks_state)", () => {
// Regression: `checks_state !== "success"` also matched "" and falsely
// reported a status-check block on a code-owners/conversation gate.
const msg = blockedReason(makePR({ checks_state: "" })).toLowerCase();
const msg = blockedReason(makePR({ checks_state: "" }), t).toLowerCase();
expect(msg).not.toContain("required status check");
expect(msg).toContain("repository rules");
});
@@ -183,6 +184,7 @@ describe("blockedReason", () => {
it("falls back to a generic protection note for other rules", () => {
const msg = blockedReason(
makePR({ checks_state: "success", required_reviews: 1, review_count: 1 }),
t,
);
expect(msg.toLowerCase()).toContain("repository rules");
});
@@ -12,6 +12,7 @@ import {
import type { TaskPR } from "@/lib/types/github";
import { isPRAwaitingReview, isPRWaitingOnBranchProtection } from "./pr-task-icon";
import { PRMergeabilityNotice, buildConflictResolutionMessage } from "./pr-mergeability-notice";
import { useTranslation } from "react-i18next";
/**
* Wires the "Resolve conflicts" CTA to chat context, mirroring the PR detail
@@ -22,6 +23,7 @@ function useResolveConflicts(pr: TaskPR): {
onResolveConflicts: (() => void) | null;
conflictQueued: boolean;
} {
const { t } = useTranslation();
const sessionId = useAppStore((s) => s.tasks.activeSessionId);
const addComment = useCommentsStore((s) => s.addComment);
const { toast } = useToast();
@@ -59,7 +61,7 @@ function useResolveConflicts(pr: TaskPR): {
content,
};
addComment(comment);
toast({ description: "Added to chat context" });
toast({ description: t("github:addedToChatContext") });
}, [sessionId, conflictQueued, prNumber, headBranch, baseBranch, addComment, toast]);
return { onResolveConflicts: sessionId ? handler : null, conflictQueued };
@@ -74,6 +76,7 @@ function useResolveConflicts(pr: TaskPR): {
* mergeable" while GitHub recomputes).
*/
export function PRMergeabilityRow({ pr }: { pr: TaskPR }) {
const { t } = useTranslation();
const { onResolveConflicts, conflictQueued } = useResolveConflicts(pr);
// "blocked" gets a richer note than the bare chip: it explains *why* the
// merge is gated. But when the block is only an outstanding requested review,
@@ -84,9 +87,9 @@ export function PRMergeabilityRow({ pr }: { pr: TaskPR }) {
if (pr.state === "open" && pr.mergeable_state === "blocked") {
if (isPRAwaitingReview(pr)) return null;
if (isPRWaitingOnBranchProtection(pr)) {
return <BranchProtectionWaitNote reason={blockedReason(pr)} />;
return <BranchProtectionWaitNote reason={blockedReason(pr, t)} />;
}
return <BlockedNote reason={blockedReason(pr)} />;
return <BlockedNote reason={blockedReason(pr, t)} />;
}
return (
<PRMergeabilityNotice
@@ -108,21 +111,25 @@ export function PRMergeabilityRow({ pr }: { pr: TaskPR }) {
* fields we do have (required reviews, CI state) and fall back to a generic
* note for the rest (code owners, required conversations, etc.).
*/
export function blockedReason(pr: TaskPR): string {
export function blockedReason(
pr: TaskPR,
t: (key: string, values?: Record<string, unknown>) => string,
): string {
const required = pr.required_reviews ?? null;
if (required != null && pr.review_count < required) {
const missing = required - pr.review_count;
return `Needs ${missing} more approval${missing === 1 ? "" : "s"} before it can merge.`;
return t("github:blockedNeedsApprovals", { count: missing });
}
// Only "failure"/"pending" indicate an actual check problem; "" means no CI
// is configured (or it hasn't loaded), which isn't a status-check block.
if (pr.checks_state === "failure" || pr.checks_state === "pending") {
return "A required status check hasn't passed yet.";
return t("github:blockedStatusCheck");
}
return "Required reviews, code owners, or repository rules still need to clear.";
return t("github:blockedGeneric");
}
function BlockedNote({ reason }: { reason: string }) {
const { t } = useTranslation();
return (
<div data-testid="pr-blocked-note" className="flex items-start gap-1.5 px-1 py-1 text-xs">
<IconShield
@@ -131,7 +138,7 @@ function BlockedNote({ reason }: { reason: string }) {
/>
<div className="min-w-0">
<div className="font-medium text-amber-600 dark:text-amber-400">
Blocked by branch protection
{t("github:blockedByBranchProtection")}
</div>
<p className="text-[11px] leading-snug text-muted-foreground">{reason}</p>
</div>
@@ -140,6 +147,7 @@ function BlockedNote({ reason }: { reason: string }) {
}
function BranchProtectionWaitNote({ reason }: { reason: string }) {
const { t } = useTranslation();
return (
<div
data-testid="pr-branch-protection-wait-note"
@@ -147,7 +155,9 @@ function BranchProtectionWaitNote({ reason }: { reason: string }) {
>
<IconClock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="font-medium text-muted-foreground">Waiting on branch protection</div>
<div className="font-medium text-muted-foreground">
{t("github:waitingOnBranchProtection")}
</div>
<p className="text-[11px] leading-snug text-muted-foreground">{reason}</p>
</div>
</div>
@@ -3,6 +3,7 @@ import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import type { PRReview, RequestedReviewer } from "@/lib/types/github";
import { CollapsibleSection, FeedbackItemRow } from "./pr-shared";
import { useTranslation } from "react-i18next";
function ReviewStateIcon({ state }: { state: string }) {
if (state === "APPROVED") return <IconCheck className="h-3.5 w-3.5 text-green-500 shrink-0" />;
@@ -141,6 +142,7 @@ function SubmittedReviewRow({
isRequesting: boolean;
onReRequest?: (reviewer: string) => void;
}) {
const { t } = useTranslation();
const hasActionable = review.state === "CHANGES_REQUESTED" || !!review.body;
return (
<div data-testid={`pr-submitted-review-${normalizeLogin(review.author)}`}>
@@ -159,11 +161,11 @@ function SubmittedReviewRow({
variant="outline"
className="h-8 min-h-11 w-full max-w-full sm:w-auto sm:min-h-0 shrink-0 cursor-pointer px-2 text-[10px] [@media(pointer:coarse)]:min-h-11"
data-testid={`pr-rerequest-review-${normalizeLogin(review.author)}`}
aria-label={`Re-request review from ${review.author}`}
aria-label={t("github:reRequestReviewFrom", { author: review.author })}
onClick={() => onReRequest(review.author)}
disabled={isRequesting}
>
{isRequesting ? "Requesting..." : "Re-request review"}
{isRequesting ? t("github:requesting") : t("github:reRequestReview")}
</Button>
</div>
) : undefined
@@ -177,6 +179,7 @@ function SubmittedReviewRow({
}
function PendingReviewRow({ reviewer }: { reviewer: RequestedReviewer }) {
const { t } = useTranslation();
return (
<div
className="px-2.5 py-1.5 rounded-md border border-border bg-muted/30"
@@ -185,7 +188,9 @@ function PendingReviewRow({ reviewer }: { reviewer: RequestedReviewer }) {
<div className="flex items-center gap-2">
<IconClock className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
<span className="text-xs font-medium">{formatPendingReviewer(reviewer)}</span>
<span className="text-[10px] text-muted-foreground truncate">Pending review</span>
<span className="text-[10px] text-muted-foreground truncate">
{t("github:pendingReview")}
</span>
</div>
</div>
);
@@ -212,6 +217,7 @@ export function ReviewsSection({
requestingReviewers?: string[];
onReRequest?: (reviewer: string) => void;
}) {
const { t } = useTranslation();
const { reviews: reconciledReviews, pendingReviewers } = reconcileReviews(
reviews,
requestedReviewers,
@@ -227,14 +233,14 @@ export function ReviewsSection({
const subtitle = reviewState ? (
<div className="text-[10px] text-muted-foreground px-2 pb-1">
Overall: <ReviewStateBadge state={reviewState} />
{t("github:overallLabel")} <ReviewStateBadge state={reviewState} />
{pendingText && <span className="text-yellow-600 dark:text-yellow-400">{pendingText}</span>}
</div>
) : null;
return (
<CollapsibleSection
title={`Reviews${summary}`}
title={t("github:reviews", { summary })}
count={totalCount}
defaultOpen
subtitle={subtitle}
@@ -243,10 +249,10 @@ export function ReviewsSection({
? () => onAddAsContext(buildAllReviewsMessage(reconciledReviews, prUrl))
: undefined
}
addAllLabel="Add all reviews to chat context"
addAllLabel={t("github:addAllReviewsToChatContext")}
>
{reconciledReviews.length === 0 && pendingCount === 0 && (
<p className="text-xs text-muted-foreground px-2 py-2">No reviews yet</p>
<p className="text-xs text-muted-foreground px-2 py-2">{t("github:noReviewsYet")}</p>
)}
{reconciledReviews.map((review) => (
<SubmittedReviewRow
+8 -4
View File
@@ -6,6 +6,7 @@ import { IconMessagePlus, IconChevronDown, IconChevronRight } from "@tabler/icon
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
import { useTranslation } from "react-i18next";
export function formatTimeAgo(dateStr: string): string {
const date = new Date(dateStr);
@@ -92,6 +93,7 @@ export function CollapsibleSection({
addAllLabel?: string;
children: React.ReactNode;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(defaultOpen ?? true);
return (
@@ -120,10 +122,10 @@ export function CollapsibleSection({
onClick={onAddAll}
>
<IconMessagePlus className="h-3 w-3" />
Add all
{t("github:addAll")}
</Button>
</TooltipTrigger>
<TooltipContent>{addAllLabel ?? "Add all to chat context"}</TooltipContent>
<TooltipContent>{addAllLabel ?? t("github:addAllToChatContext")}</TooltipContent>
</Tooltip>
)}
</div>
@@ -140,6 +142,7 @@ export function AddToContextButton({
onClick: () => void;
tooltip?: string;
}) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
@@ -155,7 +158,7 @@ export function AddToContextButton({
<IconMessagePlus className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>{tooltip ?? "Add to chat context"}</TooltipContent>
<TooltipContent>{tooltip ?? t("github:addToChatContext")}</TooltipContent>
</Tooltip>
);
}
@@ -189,6 +192,7 @@ export function getTimeAgoColor(dateStr: string): string {
}
export function ExpandableBody({ body, className }: { body: string; className?: string }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
return (
@@ -204,7 +208,7 @@ export function ExpandableBody({ body, className }: { body: string; className?:
}}
className="text-[10px] text-blue-600 dark:text-blue-400 hover:underline mt-0.5 cursor-pointer"
>
{expanded ? "Show less" : "Show more"}
{expanded ? t("github:showLess") : t("github:showMore")}
</button>
</div>
);
+17 -8
View File
@@ -42,6 +42,7 @@ import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
import { autoFixRoundForState, findCIAutomationStateForPR } from "@/lib/github/ci-automation";
import type { AutoFixRoundInfo } from "@/lib/github/ci-automation";
import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github";
import { useTranslation } from "react-i18next";
const HOVER_OPEN_DELAY_MS = 150;
const HOVER_CLOSE_DELAY_MS = 150;
@@ -307,6 +308,7 @@ function chipButtonAttrs(
}
function AutomationFlagBadges({ automation }: { automation: AutomationFlags }) {
const { t } = useTranslation();
if (!automation.autoFix && !automation.autoMerge) return null;
const autoFixRound = automation.autoFixRound;
return (
@@ -322,7 +324,7 @@ function AutomationFlagBadges({ automation }: { automation: AutomationFlags }) {
: "bg-emerald-500/15 text-emerald-500"
}`}
>
Auto-fix {autoFixRound.current}/{autoFixRound.max}
{t("github:autoFix")} {autoFixRound.current}/{autoFixRound.max}
</span>
)}
{automation.autoMerge && (
@@ -330,7 +332,7 @@ function AutomationFlagBadges({ automation }: { automation: AutomationFlags }) {
data-testid="pr-status-auto-merge-chip"
className="rounded-sm bg-sky-500/15 px-1 py-0.5 text-[9px] font-medium leading-none text-sky-500"
>
Auto-merge
{t("github:autoMerge")}
</span>
)}
</>
@@ -499,6 +501,7 @@ function PRStatusChipMultiDrawer({
onRemovePR,
triggerRef,
}: MultiChipProps) {
const { t } = useTranslation();
const status = aggregateChipStatus(statusPrs ?? prs);
const [open, setOpen] = useState(false);
return (
@@ -515,16 +518,18 @@ function PRStatusChipMultiDrawer({
</button>
<DrawerContent data-testid="pr-status-chip-drawer" className="max-h-[80vh] flex flex-col">
<DrawerHeader className="flex flex-row items-center justify-between border-b py-2">
<DrawerTitle className="text-sm">{prs.length} pull requests</DrawerTitle>
<DrawerTitle className="text-sm">
{t("github:pullRequestCount", { count: prs.length })}
</DrawerTitle>
<DrawerDescription className="sr-only">
Pull request CI status, reviews, and checks summary.
{t("github:pullRequestCiStatusReviewsAnd")}
</DrawerDescription>
<DrawerClose asChild>
<Button
data-testid="pr-status-chip-drawer-close"
variant="ghost"
size="icon-sm"
aria-label="Close PR status"
aria-label={t("github:closePrStatus")}
className="cursor-pointer"
>
<IconX className="h-4 w-4" />
@@ -546,6 +551,7 @@ function PRStatusChipMultiDrawer({
}
function PRStatusChipDrawer({ pr, automation, refreshTaskPR, triggerRef }: SingleChipProps) {
const { t } = useTranslation();
const status = chipStatus(pr);
const [open, setOpen] = useState(false);
return (
@@ -564,16 +570,19 @@ function PRStatusChipDrawer({ pr, automation, refreshTaskPR, triggerRef }: Singl
</button>
<DrawerContent data-testid="pr-status-chip-drawer" className="max-h-[80vh] flex flex-col">
<DrawerHeader className="flex flex-row items-center justify-between border-b py-2">
<DrawerTitle className="text-sm">PR #{pr.pr_number}</DrawerTitle>
<DrawerTitle className="text-sm">
{t("github:pr")}
{pr.pr_number}
</DrawerTitle>
<DrawerDescription className="sr-only">
Pull request CI status, reviews, and checks summary.
{t("github:pullRequestCiStatusReviewsAnd")}
</DrawerDescription>
<DrawerClose asChild>
<Button
data-testid="pr-status-chip-drawer-close"
variant="ghost"
size="icon-sm"
aria-label="Close PR status"
aria-label={t("github:closePrStatus")}
className="cursor-pointer"
>
<IconX className="h-4 w-4" />
@@ -39,6 +39,7 @@ import { PR_CI_DESKTOP_POPOVER_SCROLL_CLASS, PRCIPopover } from "@/components/gi
import { MultiPRCIPopover } from "@/components/github/multi-pr-ci-popover";
import { useAppStore } from "@/components/state-provider";
import type { TaskPR } from "@/lib/types/github";
import { useTranslation } from "react-i18next";
const POPOVER_OPEN_DELAY_MS = 150;
const POPOVER_CLOSE_DELAY_MS = 150;
@@ -232,6 +233,7 @@ function PRMultiButton({
onRemovePR: (associationId: string) => Promise<void>;
triggerRef?: TriggerRef;
}) {
const { t } = useTranslation();
// Click still drives the dropdown (the explicit "jump to this PR's panel"
// affordance, and the only interaction on touch). Hover adds the aggregate
// CI popover with a tab per PR — desktop only, suppressed on touch where
@@ -276,7 +278,9 @@ function PRMultiButton({
onBlur={onTriggerLeave}
>
<IconGitPullRequest className={`h-4 w-4 ${aggColor}`} />
<span className="text-xs font-medium">{prs.length} PRs</span>
<span className="text-xs font-medium">
{prs.length} {t("github:prs")}
</span>
<IconChevronDown className="h-3 w-3 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
@@ -293,7 +297,9 @@ function PRMultiButton({
<TooltipTrigger asChild>
{usesTouchDrawer ? triggerButton : <PopoverAnchor asChild>{triggerButton}</PopoverAnchor>}
</TooltipTrigger>
<TooltipContent>{prs.length} pull requests linked to this task — open one</TooltipContent>
<TooltipContent>
{t("github:pullRequestsLinkedToTask", { count: prs.length })}
</TooltipContent>
</Tooltip>
<MultiPRMenuContent prs={prs} />
</DropdownMenu>
@@ -331,10 +337,11 @@ function PRMultiButton({
}
function MultiPRMenuContent({ prs }: { prs: TaskPR[] }) {
const { t } = useTranslation();
const addPRPanel = useDockviewStore((s) => s.addPRPanel);
return (
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuLabel className="text-xs">Pull requests</DropdownMenuLabel>
<DropdownMenuLabel className="text-xs">{t("github:pullRequests")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{prs.map((pr) => (
<DropdownMenuItem