feat(i18n): localize the GitHub and GitLab task surfaces (#2283)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -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 })}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>·</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>·</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>·</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>·</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>·</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>
|
||||
);
|
||||
|
||||
@@ -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'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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user