feat(plugins): add source-control extension contracts (#2117)
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
toTaskPreset,
|
||||
} from "./action-presets";
|
||||
import { IconEye, IconSparkles } from "@tabler/icons-react";
|
||||
import { iconForIntegrationPreset } from "@/components/integrations/integration-preset-icons";
|
||||
|
||||
describe("interpolatePromptTemplate", () => {
|
||||
it("replaces {{url}} and {{title}} placeholders", () => {
|
||||
@@ -47,6 +48,10 @@ describe("iconForPresetKey", () => {
|
||||
expect(iconForPresetKey("eye")).toBe(IconEye);
|
||||
});
|
||||
|
||||
it.each(["eye", "message", "tool"])("shares the host semantic %s glyph", (key) => {
|
||||
expect(iconForPresetKey(key)).toBe(iconForIntegrationPreset(key));
|
||||
});
|
||||
|
||||
it("falls back to sparkle for unknown or missing keys", () => {
|
||||
expect(iconForPresetKey(undefined)).toBe(IconSparkles);
|
||||
expect(iconForPresetKey("nonsense")).toBe(IconSparkles);
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import {
|
||||
IconEye,
|
||||
IconMessageDots,
|
||||
IconTool,
|
||||
IconCode,
|
||||
IconSearch,
|
||||
IconBug,
|
||||
IconSparkles,
|
||||
IconChecks,
|
||||
} from "@tabler/icons-react";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import { iconForIntegrationPreset } from "@/components/integrations/integration-preset-icons";
|
||||
import type {
|
||||
GitHubActionPreset,
|
||||
GitHubActionPresetIcon,
|
||||
@@ -24,23 +15,26 @@ export const PRESET_ICON_CHOICES: {
|
||||
icon: Icon;
|
||||
labelKey: string;
|
||||
}[] = [
|
||||
{ key: "eye", icon: IconEye, labelKey: "github:presetIconEye" },
|
||||
{ key: "message", icon: IconMessageDots, labelKey: "github:presetIconMessage" },
|
||||
{ key: "tool", icon: IconTool, labelKey: "github:presetIconTool" },
|
||||
{ key: "code", icon: IconCode, labelKey: "github:presetIconCode" },
|
||||
{ key: "search", icon: IconSearch, labelKey: "github:presetIconSearch" },
|
||||
{ key: "bug", icon: IconBug, labelKey: "github:presetIconBug" },
|
||||
{ key: "sparkle", icon: IconSparkles, labelKey: "github:presetIconSparkle" },
|
||||
{ key: "check", icon: IconChecks, labelKey: "github:presetIconCheck" },
|
||||
{ key: "eye", icon: iconForIntegrationPreset("eye"), labelKey: "github:presetIconEye" },
|
||||
{
|
||||
key: "message",
|
||||
icon: iconForIntegrationPreset("message"),
|
||||
labelKey: "github:presetIconMessage",
|
||||
},
|
||||
{ key: "tool", icon: iconForIntegrationPreset("tool"), labelKey: "github:presetIconTool" },
|
||||
{ key: "code", icon: iconForIntegrationPreset("code"), labelKey: "github:presetIconCode" },
|
||||
{ key: "search", icon: iconForIntegrationPreset("search"), labelKey: "github:presetIconSearch" },
|
||||
{ key: "bug", icon: iconForIntegrationPreset("bug"), labelKey: "github:presetIconBug" },
|
||||
{
|
||||
key: "sparkle",
|
||||
icon: iconForIntegrationPreset("sparkle"),
|
||||
labelKey: "github:presetIconSparkle",
|
||||
},
|
||||
{ key: "check", icon: iconForIntegrationPreset("check"), labelKey: "github:presetIconCheck" },
|
||||
];
|
||||
|
||||
const ICON_BY_KEY: Record<string, Icon> = Object.fromEntries(
|
||||
PRESET_ICON_CHOICES.map((choice) => [choice.key, choice.icon]),
|
||||
);
|
||||
|
||||
export function iconForPresetKey(key: string | undefined): Icon {
|
||||
if (!key) return IconSparkles;
|
||||
return ICON_BY_KEY[key] ?? IconSparkles;
|
||||
return iconForIntegrationPreset(key);
|
||||
}
|
||||
|
||||
// Interpolate `{{url}}` and `{{title}}` placeholders in a prompt template.
|
||||
|
||||
@@ -15,7 +15,7 @@ import { cn } from "@/lib/utils";
|
||||
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 { TaskRowIndicator } from "@/components/integrations/task-row-indicator";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type IssueListProps = {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
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";
|
||||
import { IntegrationListToolbar } from "@/components/integrations/integration-list-toolbar";
|
||||
import { RepoFilterCombobox } from "./repo-filter-combobox";
|
||||
|
||||
type ListToolbarProps = {
|
||||
title: string;
|
||||
@@ -23,40 +19,6 @@ type ListToolbarProps = {
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
function RefreshControls({
|
||||
loading,
|
||||
lastFetchedAt,
|
||||
onRefresh,
|
||||
showUpdatedPrefix,
|
||||
}: {
|
||||
loading: boolean;
|
||||
lastFetchedAt: Date | null;
|
||||
onRefresh: () => void;
|
||||
showUpdatedPrefix: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{lastFetchedAt && !loading && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{showUpdatedPrefix ? t("github:updated") : ""}
|
||||
{formatRelativeTime(lastFetchedAt.toISOString())}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 cursor-pointer"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
title={t("github:refresh")}
|
||||
>
|
||||
<IconRefresh className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListToolbar({
|
||||
title,
|
||||
count,
|
||||
@@ -72,67 +34,31 @@ export function ListToolbar({
|
||||
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">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex items-baseline gap-2 min-w-0 flex-1 md:flex-initial">
|
||||
<h2 className="text-sm font-semibold truncate" data-testid="github-list-toolbar-title">
|
||||
{title}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{loading ? "…" : count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<RefreshControls
|
||||
loading={loading}
|
||||
lastFetchedAt={lastFetchedAt}
|
||||
onRefresh={onRefresh}
|
||||
showUpdatedPrefix={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RepoFilterCombobox
|
||||
repoFilter={repoFilter}
|
||||
onRepoFilterChange={onRepoFilterChange}
|
||||
repoOptions={repoOptions}
|
||||
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"
|
||||
dropdownTestId="github-repo-filter-dropdown"
|
||||
/>
|
||||
<div className="w-full md:flex-1 md:min-w-[240px] relative">
|
||||
<Input
|
||||
value={customQuery}
|
||||
onChange={(e) => onCustomQueryChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onCommitCustomQuery();
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (dirty) onCommitCustomQuery();
|
||||
}}
|
||||
placeholder={t("github:customQueryPressEnterEG")}
|
||||
className="h-8 pr-20"
|
||||
<IntegrationListToolbar
|
||||
title={title}
|
||||
count={count}
|
||||
loading={loading}
|
||||
lastFetchedAt={lastFetchedAt}
|
||||
customQuery={customQuery}
|
||||
committedQuery={committedQuery}
|
||||
onCustomQueryChange={onCustomQueryChange}
|
||||
onCommitCustomQuery={onCommitCustomQuery}
|
||||
onRefresh={onRefresh}
|
||||
filter={
|
||||
<RepoFilterCombobox
|
||||
repoFilter={repoFilter}
|
||||
onRepoFilterChange={onRepoFilterChange}
|
||||
repoOptions={repoOptions}
|
||||
ariaLabel={t("github:filterGithubResultsByRepository")}
|
||||
triggerClassName="h-8 w-full border border-input bg-background px-2 py-1.5 text-xs/relaxed hover:bg-secondary/50 md:w-[220px]"
|
||||
className="md:min-w-[360px]"
|
||||
testId="github-repo-filter-trigger"
|
||||
dropdownTestId="github-repo-filter-dropdown"
|
||||
/>
|
||||
{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">
|
||||
{t("github:pressEnter")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-2 md:ml-auto">
|
||||
<RefreshControls
|
||||
loading={loading}
|
||||
lastFetchedAt={lastFetchedAt}
|
||||
onRefresh={onRefresh}
|
||||
showUpdatedPrefix
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
queryPlaceholder={t("github:customQueryPressEnterEG")}
|
||||
titleTestId="github-list-toolbar-title"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import Link from "@/components/routing/app-link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChangeRequestList, ChangeRequestRow } from "@/components/integrations/change-request-list";
|
||||
import {
|
||||
IconGitPullRequest,
|
||||
IconGitPullRequestClosed,
|
||||
IconGitMerge,
|
||||
IconPlus,
|
||||
IconChevronDown,
|
||||
} from "@tabler/icons-react";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
IntegrationIcon,
|
||||
type IntegrationIconName,
|
||||
} from "@/components/integrations/integration-icon";
|
||||
import { IntegrationStartTaskMenu } from "@/components/integrations/integration-start-task-menu";
|
||||
import { cn, formatRelativeTime } from "@/lib/utils";
|
||||
import type { GitHubPR, GitHubPRStatus, TaskPR } from "@/lib/types/github";
|
||||
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";
|
||||
import { prStatusKey, usePRStatuses } from "./use-pr-statuses";
|
||||
|
||||
type PRListProps = {
|
||||
workspaceId: string | null;
|
||||
@@ -35,20 +24,17 @@ type PRListProps = {
|
||||
prKeyToTasks?: Map<string, TaskPR[]>;
|
||||
};
|
||||
|
||||
// Prefer the enriched PR returned by the batched status endpoint — the search
|
||||
// API used to populate `items` does not include head/base branches, so the
|
||||
// launcher needs the enriched copy to pre-fill the task dialog correctly.
|
||||
export function pickPRForLaunch(pr: GitHubPR, status: GitHubPRStatus | null | undefined): GitHubPR {
|
||||
return status?.pr ?? pr;
|
||||
}
|
||||
|
||||
function prStateIcon(pr: GitHubPR): { Icon: Icon; className: string } {
|
||||
function prStateIcon(pr: GitHubPR): { name: IntegrationIconName; className: string } {
|
||||
if (pr.state === "merged")
|
||||
return { Icon: IconGitMerge, className: "text-purple-600 dark:text-purple-400" };
|
||||
return { name: "merged", className: "text-purple-600 dark:text-purple-400" };
|
||||
if (pr.state === "closed")
|
||||
return { Icon: IconGitPullRequestClosed, className: "text-red-600 dark:text-red-400" };
|
||||
if (pr.draft) return { Icon: IconGitPullRequest, className: "text-muted-foreground" };
|
||||
return { Icon: IconGitPullRequest, className: "text-emerald-600 dark:text-emerald-400" };
|
||||
return { name: "pull-request-closed", className: "text-red-600 dark:text-red-400" };
|
||||
if (pr.draft) return { name: "pull-request", className: "text-muted-foreground" };
|
||||
return { name: "pull-request", className: "text-emerald-600 dark:text-emerald-400" };
|
||||
}
|
||||
|
||||
function StartTaskMenu({
|
||||
@@ -61,42 +47,15 @@ function StartTaskMenu({
|
||||
onStartTask: PRListProps["onStartTask"];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const launch = (preset: TaskPreset) => onStartTask({ kind: "pr", pr, preset });
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1 cursor-pointer"
|
||||
data-testid="pr-start-task-trigger"
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
{t("github:task")}
|
||||
<IconChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{presets.map((p) => {
|
||||
const ItemIcon = p.icon;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={p.id}
|
||||
className="cursor-pointer gap-2 py-1.5"
|
||||
onSelect={() => launch(p)}
|
||||
data-testid="pr-start-task-preset"
|
||||
data-preset-id={p.id}
|
||||
>
|
||||
<ItemIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-medium leading-tight">{p.label}</span>
|
||||
<span className="text-[11px] text-muted-foreground leading-tight">{p.hint}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<IntegrationStartTaskMenu
|
||||
presets={presets}
|
||||
onSelect={(preset) => onStartTask({ kind: "pr", pr, preset })}
|
||||
triggerLabel={t("github:task")}
|
||||
triggerAriaLabel={t("github:task")}
|
||||
triggerTestId="pr-start-task-trigger"
|
||||
itemTestId="pr-start-task-preset"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,25 +72,15 @@ function PRRow({
|
||||
onStartTask: PRListProps["onStartTask"];
|
||||
tasks: TaskPR[] | undefined;
|
||||
}) {
|
||||
const { Icon: StateIcon, className: stateIconClass } = prStateIcon(pr);
|
||||
const { t } = useTranslation();
|
||||
const { name: stateIconName, className: stateIconClass } = prStateIcon(pr);
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-3 px-4 py-3 hover:bg-muted/40 transition-colors"
|
||||
data-testid="pr-row"
|
||||
data-pr-number={pr.number}
|
||||
>
|
||||
<StateIcon className={cn("h-4 w-4 mt-1 shrink-0", stateIconClass)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={pr.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-semibold hover:underline block truncate cursor-pointer"
|
||||
>
|
||||
{pr.title}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-0.5 text-xs text-muted-foreground">
|
||||
<ChangeRequestRow
|
||||
stateIcon={<IntegrationIcon name={stateIconName} className={cn("h-4 w-4", stateIconClass)} />}
|
||||
title={pr.title}
|
||||
href={pr.html_url}
|
||||
metadata={
|
||||
<>
|
||||
<span className="whitespace-nowrap">
|
||||
{pr.repo_owner}/{pr.repo_name}#{pr.number}
|
||||
</span>
|
||||
@@ -143,50 +92,26 @@ function PRRow({
|
||||
})}
|
||||
</span>
|
||||
<PRStatusBadges pr={pr} status={status} />
|
||||
<PRRowTaskIndicator tasks={tasks} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
</>
|
||||
}
|
||||
taskIndicator={<PRRowTaskIndicator tasks={tasks} />}
|
||||
action={
|
||||
<StartTaskMenu
|
||||
pr={pickPRForLaunch(pr, status)}
|
||||
presets={presets}
|
||||
onStartTask={onStartTask}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
testId="pr-row"
|
||||
dataAttributes={{ "data-pr-number": pr.number }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PRListBody({
|
||||
workspaceId,
|
||||
loading,
|
||||
error,
|
||||
items,
|
||||
presets,
|
||||
onStartTask,
|
||||
prKeyToTasks,
|
||||
}: PRListProps) {
|
||||
const { t } = useTranslation();
|
||||
function PRListBody({ workspaceId, items, presets, onStartTask, prKeyToTasks }: PRListProps) {
|
||||
const statuses = usePRStatuses(workspaceId, items);
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-10">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return <div className="text-center py-10 text-destructive text-sm">{error}</div>;
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 text-muted-foreground text-sm">
|
||||
{t("github:noPullRequestsMatchThisFilter")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="divide-y">
|
||||
<>
|
||||
{items.map((pr) => {
|
||||
const key = prStatusKey(pr.repo_owner, pr.repo_name, pr.number);
|
||||
return (
|
||||
@@ -200,14 +125,20 @@ function PRListBody({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PRList(props: PRListProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<ChangeRequestList
|
||||
loading={props.loading}
|
||||
error={props.error}
|
||||
emptyMessage={t("github:noPullRequestsMatchThisFilter")}
|
||||
isEmpty={props.items.length === 0}
|
||||
>
|
||||
<PRListBody {...props} />
|
||||
</div>
|
||||
</ChangeRequestList>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import { TaskRowIndicator } from "./task-row-indicator";
|
||||
import { TaskRowIndicator } from "@/components/integrations/task-row-indicator";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type PRRowTaskIndicatorProps = {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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__";
|
||||
import { IntegrationRepositoryFilter } from "@/components/integrations/integration-repository-filter";
|
||||
|
||||
type RepoFilterComboboxProps = {
|
||||
repoFilter: string;
|
||||
@@ -18,13 +14,6 @@ type RepoFilterComboboxProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function buildRepoFilterOptions(repoOptions: string[]): ComboboxOption[] {
|
||||
return [
|
||||
{ value: ALL_REPOS, label: t("github:allRepos"), keywords: ["all", "repositories", "repos"] },
|
||||
...repoOptions.map((repo) => ({ value: repo, label: repo, keywords: [repo] })),
|
||||
];
|
||||
}
|
||||
|
||||
export function RepoFilterCombobox({
|
||||
repoFilter,
|
||||
onRepoFilterChange,
|
||||
@@ -36,19 +25,13 @@ export function RepoFilterCombobox({
|
||||
className,
|
||||
}: RepoFilterComboboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const options = useMemo(() => buildRepoFilterOptions(repoOptions), [repoOptions]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
value={repoFilter || ALL_REPOS}
|
||||
onValueChange={(value) => {
|
||||
// Combobox signals reselecting the active option with an empty value.
|
||||
if (!value) return;
|
||||
onRepoFilterChange(value === ALL_REPOS ? "" : value);
|
||||
}}
|
||||
options={options}
|
||||
<IntegrationRepositoryFilter
|
||||
value={repoFilter}
|
||||
onValueChange={onRepoFilterChange}
|
||||
options={repoOptions.map((repo) => ({ value: repo, label: repo, keywords: [repo] }))}
|
||||
ariaLabel={ariaLabel}
|
||||
placeholder={t("github:allRepos")}
|
||||
allLabel={t("github:allRepos")}
|
||||
searchPlaceholder={t("github:filterRepositories")}
|
||||
emptyMessage={t("github:noRepositoriesFound")}
|
||||
triggerClassName={triggerClassName}
|
||||
|
||||
@@ -1,151 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { IconChecklist } from "@tabler/icons-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
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;
|
||||
taskId: string;
|
||||
fallbackTitle: string;
|
||||
};
|
||||
|
||||
type TaskRowIndicatorProps = {
|
||||
tasks: TaskRowLink[] | undefined;
|
||||
testIdPrefix: string;
|
||||
emptyLabel?: string;
|
||||
};
|
||||
|
||||
function useTaskStepTitle(workflowStepId: string | undefined): string | null {
|
||||
return useAppStore((state) => {
|
||||
if (!workflowStepId) return null;
|
||||
const findIn = (steps: KanbanState["steps"]) =>
|
||||
steps.find((step) => step.id === workflowStepId)?.title ?? null;
|
||||
const activeTitle = findIn(state.kanban.steps);
|
||||
if (activeTitle) return activeTitle;
|
||||
for (const snapshot of Object.values(state.kanbanMulti.snapshots)) {
|
||||
const title = findIn(snapshot.steps);
|
||||
if (title) return title;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function TaskTitle({ task }: { task: TaskRowLink }) {
|
||||
const taskData = useTaskById(task.taskId);
|
||||
const title = taskData?.title ?? task.fallbackTitle;
|
||||
return (
|
||||
<span className="truncate text-foreground/80" title={title}>
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const buttonClass = cn(
|
||||
"inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs",
|
||||
"hover:bg-muted/70 transition-colors cursor-pointer w-fit max-w-full",
|
||||
);
|
||||
const iconClass = "h-3 w-3 shrink-0 text-muted-foreground";
|
||||
|
||||
function SingleTaskButton({
|
||||
task,
|
||||
testIdPrefix,
|
||||
onNavigate,
|
||||
}: {
|
||||
task: TaskRowLink;
|
||||
testIdPrefix: string;
|
||||
onNavigate: (taskId: string) => void;
|
||||
}) {
|
||||
const taskData = useTaskById(task.taskId);
|
||||
const stepTitle = useTaskStepTitle(taskData?.workflowStepId);
|
||||
const title = taskData?.title ?? task.fallbackTitle;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${testIdPrefix}-single`}
|
||||
onClick={() => onNavigate(task.taskId)}
|
||||
className={buttonClass}
|
||||
>
|
||||
<IconChecklist className={iconClass} />
|
||||
<span className="truncate text-foreground/80">{title}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
<span>{t("github:taskLabelled", { title })}</span>
|
||||
{stepTitle ? (
|
||||
<span className="text-muted-foreground">{t("github:stepLabelled", { stepTitle })}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskRowIndicator({ tasks, testIdPrefix, emptyLabel }: TaskRowIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
const setActiveTask = useAppStore((state) => state.setActiveTask);
|
||||
const router = useRouter();
|
||||
const navigate = useCallback(
|
||||
(taskId: string) => {
|
||||
setActiveTask(taskId);
|
||||
router.push(linkToTask(taskId));
|
||||
},
|
||||
[router, setActiveTask],
|
||||
);
|
||||
|
||||
if (!tasks || tasks.length === 0) {
|
||||
return emptyLabel ? (
|
||||
<span
|
||||
data-testid={`${testIdPrefix}-empty`}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{emptyLabel}
|
||||
</span>
|
||||
) : null;
|
||||
}
|
||||
if (tasks.length === 1) {
|
||||
return <SingleTaskButton task={tasks[0]} testIdPrefix={testIdPrefix} onNavigate={navigate} />;
|
||||
}
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" data-testid={`${testIdPrefix}-multi`} className={buttonClass}>
|
||||
<IconChecklist className={iconClass} />
|
||||
<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>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
{tasks.map((task) => (
|
||||
<DropdownMenuItem
|
||||
key={task.id}
|
||||
className="cursor-pointer"
|
||||
onSelect={() => navigate(task.taskId)}
|
||||
>
|
||||
<TaskTitle task={task} />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
export {
|
||||
TaskRowIndicator,
|
||||
type TaskRowIndicatorProps,
|
||||
type TaskRowLink,
|
||||
} from "@/components/integrations/task-row-indicator";
|
||||
|
||||
@@ -1,25 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconCircleDot,
|
||||
IconCircleX,
|
||||
IconExternalLink,
|
||||
IconGitPullRequest,
|
||||
IconLoader2,
|
||||
IconMessageCircle,
|
||||
IconPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useCommentsStore } from "@/lib/state/slices/comments";
|
||||
import type { PRFeedbackComment } from "@/lib/state/slices/comments";
|
||||
import { useGitHubStatus } from "@/hooks/domains/github/use-github-status";
|
||||
import { usePRCIPopover } from "@/hooks/domains/github/use-pr-ci-popover";
|
||||
import { openExternalLink } from "@/lib/desktop/external-links";
|
||||
import {
|
||||
bucketCheck,
|
||||
bucketCheckCounts,
|
||||
@@ -31,7 +19,16 @@ 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";
|
||||
import {
|
||||
ChangeRequestCIPopoverFrame,
|
||||
ChangeRequestChecksSection,
|
||||
ChangeRequestCommentsRow,
|
||||
ChangeRequestPopoverFooter,
|
||||
ChangeRequestPopoverHeader,
|
||||
ChangeRequestReviewRow,
|
||||
CHANGE_REQUEST_CI_DESKTOP_SCROLL_CLASS,
|
||||
type ChangeRequestCheckRow,
|
||||
} from "@/components/integrations/change-request-ci-anatomy";
|
||||
|
||||
type CountsView = {
|
||||
passed: number;
|
||||
@@ -41,15 +38,20 @@ type CountsView = {
|
||||
|
||||
const CHECK_GROUP_ORDER: CheckBucket[] = ["passed", "in_progress", "failed"];
|
||||
|
||||
export const PR_CI_DESKTOP_POPOVER_SCROLL_CLASS =
|
||||
"max-h-[min(28rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain";
|
||||
|
||||
function countForBucket(counts: CountsView, kind: CheckBucket): number {
|
||||
if (kind === "passed") return counts.passed;
|
||||
if (kind === "in_progress") return counts.inProgress;
|
||||
return counts.failed;
|
||||
function normalizedCheckState(kind: CheckBucket): ChangeRequestCheckRow["state"] {
|
||||
if (kind === "passed") return "success";
|
||||
if (kind === "failed") return "failure";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function normalizedReviewState(pr: TaskPR): "approved" | "changes_requested" | "pending" {
|
||||
if (pr.review_state === "approved") return "approved";
|
||||
if (pr.review_state === "changes_requested") return "changes_requested";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export const PR_CI_DESKTOP_POPOVER_SCROLL_CLASS = CHANGE_REQUEST_CI_DESKTOP_SCROLL_CLASS;
|
||||
|
||||
export function deriveAggregateCounts(pr: TaskPR): CountsView {
|
||||
// Pre-load coarse split from aggregate fields; lazy PRFeedback fetch replaces it.
|
||||
const total = Math.max(0, pr.checks_total);
|
||||
@@ -84,39 +86,6 @@ export function hasNoChecksAtAll(
|
||||
);
|
||||
}
|
||||
|
||||
function PRPopoverTitle({
|
||||
title,
|
||||
onOpenDetailPanel,
|
||||
}: {
|
||||
title: string;
|
||||
onOpenDetailPanel?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const className = "min-w-0 truncate text-sm font-medium";
|
||||
if (!onOpenDetailPanel) {
|
||||
return (
|
||||
<span data-testid="pr-popover-title" className={className} title={title}>
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pr-popover-title"
|
||||
className={`${className} cursor-pointer text-left hover:underline`}
|
||||
title={title}
|
||||
aria-label={t("github:openDetails", { title })}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenDetailPanel();
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PRCIPopoverHeader({
|
||||
pr,
|
||||
onOpenDetailPanel,
|
||||
@@ -125,132 +94,19 @@ function PRCIPopoverHeader({
|
||||
onOpenDetailPanel?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const title = `#${pr.pr_number} ${pr.pr_title || t("github:untitledPr")}`;
|
||||
const title = pr.pr_title || t("github:untitledPr");
|
||||
return (
|
||||
<div
|
||||
data-testid="pr-popover-header"
|
||||
className="flex items-center justify-between gap-2 border-b border-border/50 pb-2"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<PRPopoverTitle title={title} onOpenDetailPanel={onOpenDetailPanel} />
|
||||
</div>
|
||||
<a
|
||||
data-testid="pr-popover-pr-link"
|
||||
href={pr.pr_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
aria-label={t("github:viewPullRequestOnGithub")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconGitPullRequest className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<ChangeRequestPopoverHeader
|
||||
number={pr.pr_number}
|
||||
title={title}
|
||||
url={pr.pr_url}
|
||||
onOpenReview={onOpenDetailPanel}
|
||||
openDetailsLabel={t("github:openDetails", { title: `#${pr.pr_number} ${title}` })}
|
||||
externalLabel={t("github:viewPullRequestOnGithub")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckGroupIcon({ kind }: { kind: CheckBucket }) {
|
||||
if (kind === "passed") return <IconCircleCheck className="h-3.5 w-3.5 text-emerald-500" />;
|
||||
if (kind === "in_progress")
|
||||
return <IconCircleDot className="h-3.5 w-3.5 text-yellow-500 animate-pulse" />;
|
||||
return <IconCircleX className="h-3.5 w-3.5 text-red-500" />;
|
||||
}
|
||||
|
||||
/** `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 { 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">
|
||||
<CheckGroupIcon kind={kind} />
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
</div>
|
||||
<span
|
||||
data-testid="pr-check-group-count"
|
||||
className="text-xs tabular-nums text-muted-foreground"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PRWorkflowRow({
|
||||
group,
|
||||
onAddAsContext,
|
||||
}: {
|
||||
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,
|
||||
// so an in_progress group never has failed jobs.)
|
||||
const badge =
|
||||
group.bucket === "in_progress"
|
||||
? `${group.inProgress} running`
|
||||
: `${group.passed}/${group.total} passed`;
|
||||
return (
|
||||
<div
|
||||
data-testid="pr-workflow-row"
|
||||
data-workflow={group.workflow}
|
||||
data-bucket={group.bucket}
|
||||
className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer"
|
||||
onClick={() => {
|
||||
if (group.htmlUrl) void openExternalLink(group.htmlUrl).catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium truncate min-w-0 flex-1" title={group.workflow}>
|
||||
{group.workflow}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">{badge}</span>
|
||||
{group.htmlUrl && (
|
||||
<Button
|
||||
data-testid="pr-workflow-open"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void openExternalLink(group.htmlUrl).catch(() => undefined);
|
||||
}}
|
||||
aria-label={t("github:openOnGithub", { workflow: group.workflow })}
|
||||
>
|
||||
<IconExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{group.bucket === "failed" && onAddAsContext && (
|
||||
<Button
|
||||
data-testid="pr-workflow-add-context"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddAsContext(buildWorkflowMessage(group));
|
||||
}}
|
||||
aria-label={t("github:addFailuresToChatContext", { workflow: group.workflow })}
|
||||
>
|
||||
<IconPlus className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] = [
|
||||
@@ -266,104 +122,6 @@ function buildWorkflowMessage(group: WorkflowGroup): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function PRCheckGroup({
|
||||
kind,
|
||||
count,
|
||||
workflows,
|
||||
isLoading,
|
||||
onAddAsContext,
|
||||
}: {
|
||||
kind: CheckBucket;
|
||||
count: number;
|
||||
workflows?: WorkflowGroup[];
|
||||
isLoading: boolean;
|
||||
onAddAsContext: ((message: string) => void) | null;
|
||||
}) {
|
||||
if (count <= 0 && (!workflows || workflows.length === 0)) return null;
|
||||
const showRows = kind !== "passed";
|
||||
const hasWorkflows = !!workflows && workflows.length > 0;
|
||||
return (
|
||||
<div data-testid="pr-check-group" data-kind={kind} className="flex flex-col">
|
||||
<CheckGroupHeader kind={kind} count={count} />
|
||||
{showRows && (
|
||||
<div className="flex flex-col pl-5">
|
||||
{hasWorkflows &&
|
||||
workflows!.map((g) => (
|
||||
<PRWorkflowRow
|
||||
key={`${g.workflow}-${g.bucket}`}
|
||||
group={g}
|
||||
onAddAsContext={kind === "failed" ? onAddAsContext : null}
|
||||
/>
|
||||
))}
|
||||
{!hasWorkflows && isLoading
|
||||
? Array.from({ length: Math.min(2, count || 1) }).map((_, i) => (
|
||||
<div
|
||||
key={`skel-${i}`}
|
||||
data-testid="pr-workflow-row-skeleton"
|
||||
className="h-5 my-0.5 rounded-sm bg-muted animate-pulse"
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacked progress bar mirroring TodoIndicator's bar but split into three
|
||||
* segments — green (passed), yellow (in_progress), red (failed) — each
|
||||
* sized proportionally to its count. The leading "pass rate" label gives
|
||||
* the same at-a-glance number TodoIndicator shows ("6/10 (60%)").
|
||||
*
|
||||
* Skipped/cancelled-but-not-failed jobs aren't counted toward the total
|
||||
* (they never enter any bucket), so the bar always reflects the actionable
|
||||
* 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;
|
||||
const passedPct = pct(counts.passed);
|
||||
const inProgressPct = pct(counts.inProgress);
|
||||
const failedPct = pct(counts.failed);
|
||||
const completePct = Math.round(passedPct);
|
||||
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">{t("github:passRate")}</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{counts.passed}/{total} ({completePct}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-muted/70">
|
||||
{passedPct > 0 && (
|
||||
<div
|
||||
data-segment="passed"
|
||||
className="h-full bg-green-500 transition-[width]"
|
||||
style={{ width: `${passedPct}%` }}
|
||||
/>
|
||||
)}
|
||||
{inProgressPct > 0 && (
|
||||
<div
|
||||
data-segment="in_progress"
|
||||
className="h-full bg-yellow-500 transition-[width]"
|
||||
style={{ width: `${inProgressPct}%` }}
|
||||
/>
|
||||
)}
|
||||
{failedPct > 0 && (
|
||||
<div
|
||||
data-segment="failed"
|
||||
className="h-full bg-red-500 transition-[width]"
|
||||
style={{ width: `${failedPct}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PRChecksSection({
|
||||
pr,
|
||||
feedback,
|
||||
@@ -401,34 +159,36 @@ function PRChecksSection({
|
||||
}, [feedback]);
|
||||
|
||||
const counts = precise ?? aggregateCounts;
|
||||
// "No checks at all": the lazy fetch has settled (not isFetching) and there
|
||||
// are no checks anywhere — neither in PRFeedback nor in the aggregate.
|
||||
if (hasNoChecksAtAll(pr, feedback, isFetching)) {
|
||||
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">
|
||||
{t("github:noChecksHaveStarted")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const rows: ChangeRequestCheckRow[] = byBucket
|
||||
? CHECK_GROUP_ORDER.flatMap((kind) =>
|
||||
byBucket[kind].map((group) => ({
|
||||
id: `${kind}:${group.workflow}`,
|
||||
label: group.workflow,
|
||||
state: normalizedCheckState(kind),
|
||||
detail:
|
||||
kind === "in_progress"
|
||||
? `${group.inProgress} running`
|
||||
: `${group.passed}/${group.total} passed`,
|
||||
...(group.htmlUrl ? { url: group.htmlUrl } : {}),
|
||||
...(kind === "failed" && onAddAsContext
|
||||
? { onAddAsContext: () => onAddAsContext(buildWorkflowMessage(group)) }
|
||||
: {}),
|
||||
})),
|
||||
)
|
||||
: [];
|
||||
return (
|
||||
<div data-testid="pr-checks-section" className="flex flex-col gap-1">
|
||||
<PRChecksProgressBar counts={counts} />
|
||||
{CHECK_GROUP_ORDER.map((kind) => {
|
||||
const value = countForBucket(counts, kind);
|
||||
return (
|
||||
<PRCheckGroup
|
||||
key={kind}
|
||||
kind={kind}
|
||||
count={value}
|
||||
workflows={byBucket?.[kind]}
|
||||
isLoading={isFetching && !byBucket}
|
||||
onAddAsContext={kind === "failed" ? onAddAsContext : null}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ChangeRequestChecksSection
|
||||
counts={{ passed: counts.passed, pending: counts.inProgress, failed: counts.failed }}
|
||||
rows={rows}
|
||||
loading={isFetching && !byBucket}
|
||||
emptyLabel={t("github:noChecksHaveStarted")}
|
||||
passRateLabel={t("github:passRate")}
|
||||
groupLabels={{
|
||||
success: t("github:checkBucketPassed"),
|
||||
pending: t("github:checkBucketInProgress"),
|
||||
failure: t("github:checkBucketFailed"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -437,63 +197,24 @@ function PRReviewRow({ pr }: { pr: TaskPR }) {
|
||||
const approved = pr.review_count;
|
||||
const requested = pr.pending_review_count;
|
||||
|
||||
let label: string;
|
||||
let icon: ReactNode;
|
||||
if (pr.review_state === "approved") {
|
||||
icon = <IconCheck className="h-3.5 w-3.5 text-emerald-500" />;
|
||||
label = "Approved";
|
||||
} else if (pr.review_state === "changes_requested") {
|
||||
icon = <IconCircleX className="h-3.5 w-3.5 text-red-500" />;
|
||||
label = "Changes requested";
|
||||
} else {
|
||||
// Default branch covers "no reviews yet", "review pending", and any
|
||||
// unknown state. Always rendered (no early-return) so a fresh PR
|
||||
// still surfaces its review status.
|
||||
icon = <IconCircleDot className="h-3.5 w-3.5 text-muted-foreground" />;
|
||||
label = "Awaiting review";
|
||||
}
|
||||
// Counts live on the right edge, mirroring the check-group rows.
|
||||
// Use "K / M" when a required-minimum is known, otherwise just the
|
||||
// approved count. The "(K requested)" suffix slides in when reviewers
|
||||
// have been pinged but haven't submitted a review.
|
||||
let countText: string;
|
||||
if (required != null) countText = `${approved} / ${required}`;
|
||||
else countText = `${approved}`;
|
||||
if (requested > 0) countText += ` · ${requested} requested`;
|
||||
return (
|
||||
<div
|
||||
data-testid="pr-review-row"
|
||||
className="flex items-center justify-between gap-2 px-1 py-1 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{icon}
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-muted-foreground tabular-nums">{countText}</span>
|
||||
</div>
|
||||
<ChangeRequestReviewRow
|
||||
state={normalizedReviewState(pr)}
|
||||
approved={approved}
|
||||
required={required ?? undefined}
|
||||
requested={requested}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PRCommentsRow({ pr }: { pr: TaskPR }) {
|
||||
const { t } = useTranslation();
|
||||
if (!pr.unresolved_review_threads || pr.unresolved_review_threads <= 0) return null;
|
||||
const count = pr.unresolved_review_threads ?? 0;
|
||||
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>{t("github:unresolvedComments", { count: pr.unresolved_review_threads })}</span>
|
||||
</div>
|
||||
<ChangeRequestCommentsRow count={count} label={t("github:unresolvedComments", { count })} />
|
||||
);
|
||||
}
|
||||
|
||||
function elapsedLabel(
|
||||
elapsed: number | null,
|
||||
t: (k: string, v?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
if (elapsed == null) return "";
|
||||
if (elapsed === 0) return t("github:updatedJustNow");
|
||||
return t("github:updatedAgo", { elapsed: formatElapsedShort(elapsed) });
|
||||
}
|
||||
|
||||
function PRPopoverFooter({
|
||||
lastUpdatedAt,
|
||||
isRefreshing,
|
||||
@@ -502,46 +223,17 @@ function PRPopoverFooter({
|
||||
isRefreshing: boolean;
|
||||
}) {
|
||||
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
|
||||
// is 0 ("just now").
|
||||
const [now, setNow] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (lastUpdatedAt == null) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, [lastUpdatedAt]);
|
||||
const elapsed =
|
||||
lastUpdatedAt == null
|
||||
? null
|
||||
: Math.max(0, Math.floor(((now ?? lastUpdatedAt) - lastUpdatedAt) / 1000));
|
||||
// The first open of a PR has no cache entry yet — that's the slowest fetch of
|
||||
// all, so the footer has to exist for the spinner even with nothing to date.
|
||||
if (lastUpdatedAt == null && !isRefreshing) return null;
|
||||
return (
|
||||
<div
|
||||
data-testid="pr-popover-footer"
|
||||
className="flex items-center justify-end border-t border-border/50 pt-1.5"
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<span
|
||||
data-testid="pr-popover-updating"
|
||||
role="status"
|
||||
className="flex items-center gap-1.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<IconLoader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
|
||||
{t("github:updatingStatus")}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
data-testid="pr-popover-updated-at"
|
||||
className="text-[10px] text-muted-foreground tabular-nums"
|
||||
>
|
||||
{elapsedLabel(elapsed, t)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChangeRequestPopoverFooter
|
||||
updatedAt={lastUpdatedAt ?? undefined}
|
||||
isRefreshing={isRefreshing}
|
||||
updatingLabel={t("github:updatingStatus")}
|
||||
formatElapsed={(seconds) =>
|
||||
seconds === 0
|
||||
? t("github:updatedJustNow")
|
||||
: t("github:updatedAgo", { elapsed: formatElapsedShort(seconds) })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -549,8 +241,7 @@ function formatElapsedShort(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h`;
|
||||
return `${Math.floor(minutes / 60)}h`;
|
||||
}
|
||||
|
||||
function ReconnectGitHubBlock() {
|
||||
@@ -595,11 +286,7 @@ export function PRCIPopover({
|
||||
const onAddAsContext = useAddCheckToContext(pr);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="pr-topbar-popover-inner"
|
||||
className="flex flex-col gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChangeRequestCIPopoverFrame>
|
||||
<PRCIPopoverHeader pr={pr} onOpenDetailPanel={onOpenDetailPanel} />
|
||||
{authLost ? (
|
||||
<ReconnectGitHubBlock />
|
||||
@@ -621,7 +308,7 @@ export function PRCIPopover({
|
||||
</>
|
||||
)}
|
||||
<PRPopoverFooter lastUpdatedAt={lastUpdatedAt} isRefreshing={isRefreshing} />
|
||||
</div>
|
||||
</ChangeRequestCIPopoverFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -653,7 +340,7 @@ function useAddCheckToContext(pr: TaskPR): ((message: string) => void) | null {
|
||||
addComment(comment);
|
||||
toast({ description: t("github:addedToChatContext") });
|
||||
},
|
||||
[sessionId, prNumber, addComment, toast],
|
||||
[sessionId, prNumber, addComment, toast, t],
|
||||
);
|
||||
return sessionId ? handler : null;
|
||||
}
|
||||
|
||||
@@ -14,14 +14,15 @@ import {
|
||||
usePRScopedReviewRequest,
|
||||
} from "./use-pr-scoped-review-request";
|
||||
import type { TaskPR, PRFeedback, GitHubPR, PRReview } from "@/lib/types/github";
|
||||
import { activateLocale, i18n } from "@/lib/i18n";
|
||||
|
||||
const feedbackMocks = vi.hoisted(() => ({
|
||||
refresh: vi.fn(),
|
||||
value: null as PRFeedback | null,
|
||||
}));
|
||||
const reviewMocks = vi.hoisted(() => ({ requestReviewers: vi.fn(), submitReview: vi.fn() }));
|
||||
const RE_REQUEST_BUTTON = "pr-rerequest-review-octocat";
|
||||
const PENDING_REVIEWER = "pr-pending-reviewer-octocat";
|
||||
const RE_REQUEST_BUTTON = "change-request-review-action-rerequest-review-octocat";
|
||||
const PENDING_REVIEWER = "change-request-pending-reviewer-octocat";
|
||||
const DISMISSED_REVIEWED_AT = "2026-01-01T00:00:00Z";
|
||||
|
||||
vi.mock("@/hooks/domains/github/use-pr-feedback", () => ({
|
||||
@@ -57,7 +58,7 @@ vi.mock("./pr-mergeability-notice", () => ({
|
||||
vi.mock("./pr-checks-section", () => ({ ChecksSection: () => null }));
|
||||
vi.mock("./pr-comments-section", () => ({ CommentsSection: () => null }));
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
feedbackMocks.refresh.mockReset();
|
||||
@@ -65,6 +66,7 @@ afterEach(() => {
|
||||
reviewMocks.requestReviewers.mockReset();
|
||||
reviewMocks.submitReview.mockReset();
|
||||
clearPRReviewRequestRegistryForTests();
|
||||
await activateLocale("en");
|
||||
});
|
||||
|
||||
function makeTaskPR(overrides: Partial<TaskPR> = {}): TaskPR {
|
||||
@@ -187,6 +189,29 @@ function renderPRDetail(taskPR = makeTaskPR()) {
|
||||
return render(prDetailTree(taskPR));
|
||||
}
|
||||
|
||||
describe("PRDetailContent shared presentation", () => {
|
||||
it("renders GitHub through the provider-neutral change request detail", () => {
|
||||
feedbackMocks.value = makeFeedback({ pr: { body: "Shared review body" } });
|
||||
|
||||
renderPRDetail();
|
||||
|
||||
expect(screen.getByTestId("change-request-detail")).not.toBeNull();
|
||||
expect(screen.getByText("Test PR")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("localizes review actions instead of embedding English in the detail model", async () => {
|
||||
await activateLocale("pseudo");
|
||||
feedbackMocks.value = makeFeedback({ reviews: [dismissedReview()] });
|
||||
|
||||
renderPRDetail();
|
||||
|
||||
expect(screen.getByTestId(RE_REQUEST_BUTTON).textContent).toBe(
|
||||
i18n.t("github:reRequestReview"),
|
||||
);
|
||||
expect(screen.getByTestId(RE_REQUEST_BUTTON).textContent).not.toBe("Re-request review");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldHideApproveButton", () => {
|
||||
it("hides when PR is closed", () => {
|
||||
expect(shouldHideApproveButton(makeTaskPR({ state: "closed" }), null, "bob")).toBe(true);
|
||||
@@ -328,7 +353,7 @@ describe("PRDetailContent optimistic review reconciliation", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId(PENDING_REVIEWER)).toBeNull();
|
||||
});
|
||||
expect(screen.getByTestId("pr-submitted-review-octocat")).not.toBeNull();
|
||||
expect(screen.getByTestId("change-request-submitted-review-octocat")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -355,7 +380,7 @@ describe("PRDetailContent same-timestamp review reconciliation", () => {
|
||||
view.rerender(prDetailTree());
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId(PENDING_REVIEWER)).toBeNull());
|
||||
expect(screen.getByTestId("pr-submitted-review-octocat")).not.toBeNull();
|
||||
expect(screen.getByTestId("change-request-submitted-review-octocat")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { setPanelTitle } from "@/lib/layout/panel-portal-manager";
|
||||
import {
|
||||
IconRefresh,
|
||||
IconPlus,
|
||||
IconMinus,
|
||||
IconGitMerge,
|
||||
IconCheck,
|
||||
IconLoader2,
|
||||
} from "@tabler/icons-react";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Separator } from "@kandev/ui/separator";
|
||||
import { ScrollArea } from "@kandev/ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import {
|
||||
ChangeRequestDetail,
|
||||
type ChangeRequestDetailModel,
|
||||
} from "@/components/integrations/change-request-detail";
|
||||
import { useActiveTaskPR, useTaskPR } from "@/hooks/domains/github/use-task-pr";
|
||||
import { prPanelLabel, prTaskKey } from "@/components/github/pr-utils";
|
||||
import { usePRFeedback } from "@/hooks/domains/github/use-pr-feedback";
|
||||
@@ -26,21 +20,9 @@ import type { PRFeedbackComment } from "@/lib/state/slices/comments";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { submitPRReview } from "@/lib/api/domains/github-pr-api";
|
||||
import type { TaskPR, PRFeedback } from "@/lib/types/github";
|
||||
import {
|
||||
formatTimeAgo,
|
||||
AuthorLink,
|
||||
getTimeAgoColor,
|
||||
CollapsibleSection,
|
||||
PRMarkdownBody,
|
||||
} from "./pr-shared";
|
||||
import { PRMergeButton } from "./pr-merge-button";
|
||||
import { PRMergeabilityNotice, buildConflictResolutionMessage } from "./pr-mergeability-notice";
|
||||
import { ReviewStateBadge } from "./pr-reviews-section";
|
||||
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 ---
|
||||
|
||||
@@ -63,9 +45,9 @@ export function PRDetailPanelComponent({ panelId, params }: PRDetailPanelProps)
|
||||
const pr = (params?.prKey ? prs.find((p) => prTaskKey(p) === params.prKey) : null) ?? activePR;
|
||||
|
||||
useEffect(() => {
|
||||
const title = pr ? prPanelLabel(pr.pr_number) : "Pull Request";
|
||||
const title = pr ? prPanelLabel(pr.pr_number) : t("task:pullRequest2");
|
||||
setPanelTitle(panelId, title);
|
||||
}, [pr, panelId]);
|
||||
}, [pr, panelId, t]);
|
||||
|
||||
if (!pr || !sessionId) {
|
||||
return (
|
||||
@@ -105,7 +87,7 @@ function useAddPRFeedbackAsContext(sessionId: string, prNumber: number) {
|
||||
addComment(comment);
|
||||
toast({ description: t("github:addedToChatContext") });
|
||||
},
|
||||
[sessionId, prNumber, addComment, toast],
|
||||
[sessionId, prNumber, addComment, toast, t],
|
||||
);
|
||||
|
||||
return { addAsContext };
|
||||
@@ -170,18 +152,6 @@ function derivePanelMetrics(taskPR: TaskPR, feedback: PRFeedback | null): PRPane
|
||||
};
|
||||
}
|
||||
|
||||
function DescriptionSection({ body }: { body: string }) {
|
||||
const { t } = useTranslation();
|
||||
if (!body) return null;
|
||||
return (
|
||||
<CollapsibleSection title={t("github:description")} count={1} defaultOpen={false}>
|
||||
<div className="px-2">
|
||||
<PRMarkdownBody body={body} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
|
||||
// GitHub logins are case-insensitive; normalize before comparing.
|
||||
// Fails closed when the current user is unknown — without that identity we
|
||||
// can't tell whether the viewer is the PR author, and GitHub rejects
|
||||
@@ -270,21 +240,140 @@ function ApproveButton({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 githubPerson(login: string, avatarUrl?: string, isBot?: boolean) {
|
||||
return {
|
||||
name: login,
|
||||
url: login ? `https://github.com/${login}` : undefined,
|
||||
avatarUrl: avatarUrl || undefined,
|
||||
isBot,
|
||||
};
|
||||
}
|
||||
|
||||
function mapGitHubIdentity(taskPR: TaskPR, feedback: PRFeedback | null) {
|
||||
const live = feedback?.pr;
|
||||
return {
|
||||
title: live?.title ?? taskPR.pr_title,
|
||||
url: live?.html_url || live?.url || taskPR.pr_url,
|
||||
state: live?.state ?? taskPR.state,
|
||||
draft: live?.draft,
|
||||
author: githubPerson(live?.author_login ?? taskPR.author_login),
|
||||
description: live?.body,
|
||||
};
|
||||
}
|
||||
|
||||
function mapGitHubDates(taskPR: TaskPR, feedback: PRFeedback | null) {
|
||||
const live = feedback?.pr;
|
||||
return {
|
||||
createdAt: live?.created_at ?? taskPR.created_at,
|
||||
mergedAt: live?.merged_at ?? taskPR.merged_at ?? undefined,
|
||||
closedAt: live?.closed_at ?? taskPR.closed_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mapGitHubBranches(taskPR: TaskPR, feedback: PRFeedback | null) {
|
||||
const live = feedback?.pr;
|
||||
return {
|
||||
sourceBranch: live?.head_branch ?? taskPR.head_branch,
|
||||
targetBranch: live?.base_branch ?? taskPR.base_branch,
|
||||
additions: live?.additions ?? taskPR.additions,
|
||||
deletions: live?.deletions ?? taskPR.deletions,
|
||||
};
|
||||
}
|
||||
|
||||
function mapGitHubReviews(
|
||||
taskPR: TaskPR,
|
||||
feedback: PRFeedback | null,
|
||||
requestingReviewers: string[],
|
||||
labels: { reRequestReview: string; requesting: string },
|
||||
) {
|
||||
const requesting = new Set(requestingReviewers.map((reviewer) => reviewer.toLowerCase()));
|
||||
const state = feedback?.pr.state ?? taskPR.state;
|
||||
return (feedback?.reviews ?? []).map((review) => ({
|
||||
id: String(review.id),
|
||||
author: githubPerson(review.author, review.author_avatar),
|
||||
state: review.state,
|
||||
body: review.body || undefined,
|
||||
createdAt: review.created_at,
|
||||
actions: shouldShowReRequestReviewAction(state, review.state)
|
||||
? [
|
||||
{
|
||||
id: "rerequest-review",
|
||||
label: labels.reRequestReview,
|
||||
pendingLabel: labels.requesting,
|
||||
tone: "secondary" as const,
|
||||
disabled: requesting.has(review.author.toLowerCase()),
|
||||
busy: requesting.has(review.author.toLowerCase()),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function mapGitHubChecks(feedback: PRFeedback | null) {
|
||||
return (feedback?.checks ?? []).map((check) => ({
|
||||
id: `${check.name}-${check.html_url}-${check.source}-${check.started_at ?? ""}`,
|
||||
name: check.name,
|
||||
state: check.status,
|
||||
conclusion: check.conclusion,
|
||||
url: check.html_url || undefined,
|
||||
output: check.output,
|
||||
startedAt: check.started_at ?? undefined,
|
||||
completedAt: check.completed_at ?? undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function mapGitHubComments(feedback: PRFeedback | null) {
|
||||
return (feedback?.comments ?? []).map((comment) => ({
|
||||
id: String(comment.id),
|
||||
parentId: comment.in_reply_to ? String(comment.in_reply_to) : undefined,
|
||||
author: githubPerson(comment.author, comment.author_avatar, comment.author_is_bot),
|
||||
body: comment.body,
|
||||
createdAt: comment.created_at,
|
||||
path: comment.path || undefined,
|
||||
line: comment.line || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function mapGitHubDetail(
|
||||
taskPR: TaskPR,
|
||||
feedback: PRFeedback | null,
|
||||
metrics: PRPanelMetrics,
|
||||
review: {
|
||||
requestedReviewers: { login: string; type: "user" | "team" }[];
|
||||
requestingReviewers: string[];
|
||||
labels: { reRequestReview: string; requesting: string };
|
||||
},
|
||||
): ChangeRequestDetailModel {
|
||||
return {
|
||||
providerId: "github",
|
||||
reviewKey: `${taskPR.owner}/${taskPR.repo}#${taskPR.pr_number}`,
|
||||
number: taskPR.pr_number,
|
||||
...mapGitHubIdentity(taskPR, feedback),
|
||||
...mapGitHubDates(taskPR, feedback),
|
||||
...mapGitHubBranches(taskPR, feedback),
|
||||
reviewState: metrics.reviewState,
|
||||
pendingReviewCount: metrics.pendingReviewCount,
|
||||
reviews: mapGitHubReviews(taskPR, feedback, review.requestingReviewers, review.labels),
|
||||
requestedReviewers: review.requestedReviewers.map((reviewer) => ({
|
||||
...githubPerson(reviewer.login),
|
||||
kind: reviewer.type,
|
||||
})),
|
||||
checks: mapGitHubChecks(feedback),
|
||||
comments: mapGitHubComments(feedback),
|
||||
lastSyncedAt: taskPR.last_synced_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function useConflictQueued(sessionId: string, prNumber: number): boolean {
|
||||
return useCommentsStore((s) =>
|
||||
s.pendingForChat.some((id) => {
|
||||
const c = s.byId[id];
|
||||
const comment = s.byId[id];
|
||||
return (
|
||||
!!c &&
|
||||
isPRFeedbackComment(c) &&
|
||||
c.feedbackType === "conflict" &&
|
||||
c.sessionId === sessionId &&
|
||||
c.prNumber === prNumber
|
||||
!!comment &&
|
||||
isPRFeedbackComment(comment) &&
|
||||
comment.feedbackType === "conflict" &&
|
||||
comment.sessionId === sessionId &&
|
||||
comment.prNumber === prNumber
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -325,240 +414,51 @@ export function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; session
|
||||
);
|
||||
}, [addAsContext, conflictQueued, taskPR.pr_number, taskPR.head_branch, taskPR.base_branch]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PRHeader
|
||||
workspaceId={workspaceId}
|
||||
taskPR={taskPR}
|
||||
feedback={feedback}
|
||||
metrics={metrics}
|
||||
loading={loading}
|
||||
onRefresh={refresh}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
conflictQueued={conflictQueued}
|
||||
/>
|
||||
<Separator />
|
||||
<ScrollArea className="flex-1 overflow-hidden">
|
||||
<div className="box-border w-0 min-w-full max-w-full overflow-x-hidden p-3 space-y-1">
|
||||
{loading && !feedback && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<IconLoader2 className="h-6 w-6 text-blue-500 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{feedback && (
|
||||
<>
|
||||
<DescriptionSection body={feedback.pr.body ?? ""} />
|
||||
<ReviewsSection
|
||||
reviews={feedback.reviews ?? []}
|
||||
requestedReviewers={reviewRequest.requestedReviewers}
|
||||
prUrl={taskPR.pr_url}
|
||||
reviewState={metrics.reviewState}
|
||||
pendingReviewCount={metrics.pendingReviewCount}
|
||||
onAddAsContext={(msg) => addAsContext("review", msg)}
|
||||
canReRequest={shouldShowReRequestReviewAction(feedback.pr.state, "DISMISSED")}
|
||||
requestingReviewers={reviewRequest.requestingReviewers}
|
||||
onReRequest={reviewRequest.reRequest}
|
||||
/>
|
||||
<ChecksSection
|
||||
checks={feedback.checks ?? []}
|
||||
onAddAsContext={(msg) => addAsContext("check", msg)}
|
||||
/>
|
||||
<CommentsSection
|
||||
comments={feedback.comments ?? []}
|
||||
prUrl={taskPR.pr_url}
|
||||
onAddAsContext={(msg) => addAsContext("comment", msg)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{taskPR.last_synced_at && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="px-3 py-2 text-[10px] text-muted-foreground text-center">
|
||||
{t("github:lastSynced")} {formatTimeAgo(taskPR.last_synced_at)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StateBadge({ state }: { state: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
open: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
draft: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
|
||||
merged: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
|
||||
closed: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||||
};
|
||||
return (
|
||||
<Badge variant="secondary" className={`text-[10px] px-1.5 py-0 ${styles[state] ?? ""}`}>
|
||||
{state}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderTitleRow({
|
||||
taskPR,
|
||||
loading,
|
||||
onRefresh,
|
||||
}: {
|
||||
taskPR: TaskPR;
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<a
|
||||
href={taskPR.pr_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium hover:underline truncate cursor-pointer min-w-0 flex-1"
|
||||
>
|
||||
{taskPR.pr_title}
|
||||
</a>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 cursor-pointer shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconRefresh className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<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">
|
||||
<Trans i18nKey="github:byAuthor">
|
||||
by <AuthorLink author={taskPR.author_login} />
|
||||
</Trans>
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className={getTimeAgoColor(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" />
|
||||
{t("github:mergedAgo", { time: formatTimeAgo(taskPR.merged_at) })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{taskPR.closed_at && !taskPR.merged_at && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{t("github:closedAgo", { time: formatTimeAgo(taskPR.closed_at) })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<IconPlus className="h-3 w-3 text-green-500" />
|
||||
{taskPR.additions}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<IconMinus className="h-3 w-3 text-red-500" />
|
||||
{taskPR.deletions}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{t("github:reviewCount", { count: metrics.reviewCount })}
|
||||
{metrics.pendingReviewCount > 0 && (
|
||||
<span className="text-yellow-600 dark:text-yellow-400">
|
||||
{" "}
|
||||
{t("github:pendingReviewCount", { count: metrics.pendingReviewCount })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{t("github:commentCount", { count: metrics.commentCount })}</span>
|
||||
{metrics.reviewState && <ReviewStateBadge state={metrics.reviewState} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PRHeader({
|
||||
workspaceId,
|
||||
taskPR,
|
||||
feedback,
|
||||
metrics,
|
||||
loading,
|
||||
onRefresh,
|
||||
onResolveConflicts,
|
||||
conflictQueued,
|
||||
}: {
|
||||
workspaceId: string | null;
|
||||
taskPR: TaskPR;
|
||||
feedback: PRFeedback | null;
|
||||
metrics: PRPanelMetrics;
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
onResolveConflicts: () => void;
|
||||
conflictQueued: boolean;
|
||||
}) {
|
||||
const detail = mapGitHubDetail(taskPR, feedback, metrics, {
|
||||
requestedReviewers: reviewRequest.requestedReviewers,
|
||||
requestingReviewers: reviewRequest.requestingReviewers,
|
||||
labels: {
|
||||
reRequestReview: t("github:reRequestReview"),
|
||||
requesting: t("github:requesting"),
|
||||
},
|
||||
});
|
||||
const liveState = feedback?.pr.state ?? taskPR.state;
|
||||
const isDraft = feedback?.pr.draft ?? false;
|
||||
const isMergeable = feedback?.pr.mergeable ?? true;
|
||||
// Prefer the live feedback state (refreshed by the panel's Refresh button);
|
||||
// fall back to the polled store value before feedback loads.
|
||||
const mergeableState = feedback?.pr.mergeable_state ?? taskPR.mergeable_state;
|
||||
|
||||
return (
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<HeaderTitleRow taskPR={taskPR} loading={loading} onRefresh={onRefresh} />
|
||||
</div>
|
||||
<ApproveButton
|
||||
workspaceId={workspaceId}
|
||||
taskPR={taskPR}
|
||||
feedback={feedback}
|
||||
onRefresh={onRefresh}
|
||||
<ChangeRequestDetail
|
||||
detail={detail}
|
||||
loading={loading}
|
||||
contentLoading={loading && !feedback}
|
||||
onRefresh={refresh}
|
||||
onAddContext={addAsContext}
|
||||
onAction={({ actionId, targetId }) => {
|
||||
if (actionId === "rerequest-review" && targetId) void reviewRequest.reRequest(targetId);
|
||||
}}
|
||||
headerActions={
|
||||
<>
|
||||
<ApproveButton
|
||||
workspaceId={workspaceId}
|
||||
taskPR={taskPR}
|
||||
feedback={feedback}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
<PRMergeButton taskPR={taskPR} onMerged={refresh} />
|
||||
</>
|
||||
}
|
||||
notice={
|
||||
<PRMergeabilityNotice
|
||||
state={mergeableState}
|
||||
mergeable={isMergeable}
|
||||
isDraft={isDraft}
|
||||
prState={liveState}
|
||||
baseBranch={taskPR.base_branch}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
resolveDisabled={conflictQueued}
|
||||
/>
|
||||
<PRMergeButton taskPR={taskPR} onMerged={onRefresh} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<StateBadge state={isDraft && liveState === "open" ? "draft" : liveState} />
|
||||
<span className="text-xs text-muted-foreground">#{taskPR.pr_number}</span>
|
||||
<code className="text-[10px] px-1 py-0.5 bg-muted rounded font-mono">
|
||||
{taskPR.head_branch}
|
||||
</code>
|
||||
<span className="text-muted-foreground mx-0.5">→</span>
|
||||
<code className="text-[10px] px-1 py-0.5 bg-muted rounded font-mono">
|
||||
{taskPR.base_branch}
|
||||
</code>
|
||||
</div>
|
||||
<PRMergeabilityNotice
|
||||
state={mergeableState}
|
||||
mergeable={isMergeable}
|
||||
isDraft={isDraft}
|
||||
prState={liveState}
|
||||
baseBranch={taskPR.base_branch}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
resolveDisabled={conflictQueued}
|
||||
/>
|
||||
<HeaderDateLine taskPR={taskPR} />
|
||||
<HeaderStatsLine taskPR={taskPR} metrics={metrics} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,10 +14,7 @@ import type { AppState } from "@/lib/state/store";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
|
||||
const AUTO_FIX_BADGE_TESTID = "pr-status-auto-fix-chip";
|
||||
|
||||
const testConstants = vi.hoisted(() => ({
|
||||
defaultCIFixPrompt: "Default CI fix prompt",
|
||||
}));
|
||||
const testConstants = vi.hoisted(() => ({ defaultCIFixPrompt: "Default CI fix prompt" }));
|
||||
|
||||
const responsiveMock = vi.hoisted(() => ({
|
||||
breakpoint: "desktop" as "mobile" | "tablet" | "compactDesktop" | "desktop",
|
||||
@@ -257,6 +254,7 @@ describe("PRStatusChip mobile branch", () => {
|
||||
|
||||
const drawer = document.querySelector(DRAWER_SELECTOR);
|
||||
expect(drawer).not.toBeNull();
|
||||
expect(drawer?.className).toContain("max-h-[80dvh]");
|
||||
// Inner popover body + close button render inside the drawer.
|
||||
expect(document.querySelector("[data-testid='pr-topbar-popover-inner']")).not.toBeNull();
|
||||
expect(document.querySelector("[data-testid='pr-status-chip-drawer-close']")).not.toBeNull();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IconCircleCheckFilled,
|
||||
IconCircleXFilled,
|
||||
@@ -10,17 +11,8 @@ import {
|
||||
IconPointFilled,
|
||||
IconAlertTriangleFilled,
|
||||
IconShield,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@kandev/ui/drawer";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Drawer } from "@kandev/ui/drawer";
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@kandev/ui/popover";
|
||||
import { useTaskPR } from "@/hooks/domains/github/use-task-pr";
|
||||
import { useHoverPopover } from "@/hooks/domains/github/use-hover-popover";
|
||||
@@ -39,6 +31,12 @@ import {
|
||||
pickDefaultPR,
|
||||
} from "@/components/github/pr-task-icon";
|
||||
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
|
||||
import {
|
||||
ChangeRequestStatusChip,
|
||||
ChangeRequestStatusChipHoverArea,
|
||||
ChangeRequestStatusDrawerContent,
|
||||
useChangeRequestStatusChipTriggerGuard,
|
||||
} from "@/components/integrations/change-request-status-chrome";
|
||||
import {
|
||||
AutomationFlagBadges,
|
||||
automationAriaSuffix,
|
||||
@@ -48,13 +46,12 @@ import {
|
||||
import type { AutomationFlags } from "@/components/github/pr-status-automation-badges";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const HOVER_OPEN_DELAY_MS = 150;
|
||||
const HOVER_CLOSE_DELAY_MS = 150;
|
||||
|
||||
// Terminal states (merged / closed) are omitted from CI status aggregation,
|
||||
// but remain in multi-PR surfaces so users can unlink old associations.
|
||||
// Terminal states (merged / closed) never reach here — PRStatusChip returns
|
||||
// null for them before rendering — so the chip status union omits them.
|
||||
type ChipStatus =
|
||||
| "passed"
|
||||
| "failed"
|
||||
@@ -82,8 +79,7 @@ type MultiChipProps = {
|
||||
};
|
||||
|
||||
function focusAfterCollapse(triggerRef?: TriggerRef) {
|
||||
if (!triggerRef) return;
|
||||
setTimeout(() => triggerRef.current?.focus(), 0);
|
||||
if (triggerRef) setTimeout(() => triggerRef.current?.focus(), 0);
|
||||
}
|
||||
|
||||
function chipStatus(pr: TaskPR): ChipStatus {
|
||||
@@ -134,9 +130,6 @@ export function aggregateChipStatus(prs: TaskPR[]): ChipStatus {
|
||||
return worst;
|
||||
}
|
||||
|
||||
const CHIP_BUTTON_CLASS =
|
||||
"cursor-pointer inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-md px-1 py-0.5 text-xs";
|
||||
|
||||
/**
|
||||
* Radix HoverCard treats the trigger as outside the content's bounding box, so
|
||||
* a click on the chip would auto-close the popover. This guard filters out
|
||||
@@ -144,20 +137,6 @@ const CHIP_BUTTON_CLASS =
|
||||
* via hover. Returns the trigger ref plus a memoised handler that reads the ref
|
||||
* lazily (inside the callback, never during render).
|
||||
*/
|
||||
function useChipTriggerGuard(externalRef?: TriggerRef) {
|
||||
const fallbackRef = useRef<HTMLButtonElement>(null);
|
||||
const ref = externalRef ?? fallbackRef;
|
||||
const onPointerDownOutside = useCallback(
|
||||
(e: { target: EventTarget | null; preventDefault: () => void }) => {
|
||||
if (ref.current && ref.current.contains(e.target as Node)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
return { ref, onPointerDownOutside };
|
||||
}
|
||||
|
||||
// Hover-bridge lifecycle for the chip's desktop popover. Delegates to the
|
||||
// shared hook so the chip and the top-bar PR button keep identical
|
||||
// trigger->content bridge behavior (the popover must survive the cursor
|
||||
@@ -184,10 +163,9 @@ function useChipPopoverInteractions() {
|
||||
* Mobile: tapping opens the same popover content inside a bottom-sheet Drawer
|
||||
* — hover is unreachable on touch devices.
|
||||
*
|
||||
* Returns null when the task has no PR yet, or when its only PR is terminal
|
||||
* (merged / closed). With multiple associations, terminal PRs stay in the
|
||||
* multi-PR surface so old links can still be removed while CI status continues
|
||||
* to reflect open PRs only.
|
||||
* Returns null when the task has no PR yet, or once the PR reaches a terminal
|
||||
* state (merged / closed) — the chat-input banner already conveys that, so the
|
||||
* CI chip would be redundant.
|
||||
*/
|
||||
export function PRStatusChip({ taskId }: { taskId: string | null }) {
|
||||
const workspaceId = useAppStore((state) => state.workspaces.activeId);
|
||||
@@ -196,9 +174,10 @@ export function PRStatusChip({ taskId }: { taskId: string | null }) {
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
// Defensive Array.isArray: a partial hydration can briefly seed the store
|
||||
// with a non-array value (same guard as PRTaskIcon).
|
||||
// Only open PRs are worth a CI chip — terminal PRs (merged/closed) are
|
||||
// already conveyed by the chat-input banner. With multiple PRs the chip
|
||||
// stays visible as long as at least one is still open.
|
||||
const allPRs = Array.isArray(prs) ? prs : [];
|
||||
// Terminal PRs are excluded from CI status and background warming, but are
|
||||
// kept in the multi-PR association list so old links remain unlinkable.
|
||||
const openPRs = allPRs.filter((p) => p.state !== "merged" && p.state !== "closed");
|
||||
// Subscribe at the chip level so the cache warms even when the top-bar PR
|
||||
// button isn't mounted (e.g. small viewport that hides it). Warm the PR the
|
||||
@@ -235,7 +214,6 @@ type ChipButtonAttrs = {
|
||||
"data-status": ChipStatus;
|
||||
"data-pr-ready-to-merge": "true" | "false";
|
||||
"aria-label": string;
|
||||
className: string;
|
||||
};
|
||||
|
||||
function chipButtonAttrs(
|
||||
@@ -254,7 +232,6 @@ function chipButtonAttrs(
|
||||
number: pr.pr_number,
|
||||
automation: automationAriaSuffix(automation, t),
|
||||
}),
|
||||
className: CHIP_BUTTON_CLASS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -267,32 +244,20 @@ function PRStatusChipInner(props: SingleChipProps) {
|
||||
function PRStatusChipHoverCard({ pr, automation, refreshTaskPR, triggerRef }: SingleChipProps) {
|
||||
const { t } = useTranslation();
|
||||
const status = chipStatus(pr);
|
||||
const { ref, onPointerDownOutside } = useChipTriggerGuard(triggerRef);
|
||||
const { ref, onPointerDownOutside } = useChangeRequestStatusChipTriggerGuard(triggerRef);
|
||||
const { open, onOpenChange, onTriggerEnter, onTriggerLeave, onContentEnter, onContentLeave } =
|
||||
useChipPopoverInteractions();
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<span
|
||||
className="inline-flex"
|
||||
onMouseOver={onTriggerEnter}
|
||||
onMouseEnter={onTriggerEnter}
|
||||
onMouseMove={onTriggerEnter}
|
||||
onPointerOver={onTriggerEnter}
|
||||
onPointerEnter={onTriggerEnter}
|
||||
onPointerMove={onTriggerEnter}
|
||||
onMouseLeave={onTriggerLeave}
|
||||
onPointerLeave={onTriggerLeave}
|
||||
onFocus={onTriggerEnter}
|
||||
onBlur={onTriggerLeave}
|
||||
>
|
||||
<ChangeRequestStatusChipHoverArea handlers={{ onTriggerEnter, onTriggerLeave }}>
|
||||
<PopoverAnchor asChild>
|
||||
<button ref={ref} type="button" {...chipButtonAttrs(pr, status, automation, t)}>
|
||||
<ChangeRequestStatusChip ref={ref} {...chipButtonAttrs(pr, status, automation, t)}>
|
||||
<IconChecklist className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
<ChipStatusGlyph status={status} />
|
||||
<AutomationFlagBadges automation={automation} />
|
||||
</button>
|
||||
</ChangeRequestStatusChip>
|
||||
</PopoverAnchor>
|
||||
</span>
|
||||
</ChangeRequestStatusChipHoverArea>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
@@ -321,7 +286,6 @@ type MultiChipButtonAttrs = {
|
||||
"data-pr-count": number;
|
||||
"data-status": ChipStatus;
|
||||
"aria-label": string;
|
||||
className: string;
|
||||
};
|
||||
|
||||
function multiChipButtonAttrs(
|
||||
@@ -338,7 +302,6 @@ function multiChipButtonAttrs(
|
||||
count: prs.length,
|
||||
automation: automationAriaSuffix(automation, t),
|
||||
}),
|
||||
className: CHIP_BUTTON_CLASS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -371,30 +334,18 @@ function PRStatusChipMultiHoverCard({
|
||||
}: MultiChipProps) {
|
||||
const { t } = useTranslation();
|
||||
const status = aggregateChipStatus(statusPrs ?? prs);
|
||||
const { ref, onPointerDownOutside } = useChipTriggerGuard(triggerRef);
|
||||
const { ref, onPointerDownOutside } = useChangeRequestStatusChipTriggerGuard(triggerRef);
|
||||
const { open, onOpenChange, onTriggerEnter, onTriggerLeave, onContentEnter, onContentLeave } =
|
||||
useChipPopoverInteractions();
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<span
|
||||
className="inline-flex"
|
||||
onMouseOver={onTriggerEnter}
|
||||
onMouseEnter={onTriggerEnter}
|
||||
onMouseMove={onTriggerEnter}
|
||||
onPointerOver={onTriggerEnter}
|
||||
onPointerEnter={onTriggerEnter}
|
||||
onPointerMove={onTriggerEnter}
|
||||
onMouseLeave={onTriggerLeave}
|
||||
onPointerLeave={onTriggerLeave}
|
||||
onFocus={onTriggerEnter}
|
||||
onBlur={onTriggerLeave}
|
||||
>
|
||||
<ChangeRequestStatusChipHoverArea handlers={{ onTriggerEnter, onTriggerLeave }}>
|
||||
<PopoverAnchor asChild>
|
||||
<button ref={ref} type="button" {...multiChipButtonAttrs(prs, status, automation, t)}>
|
||||
<ChangeRequestStatusChip ref={ref} {...multiChipButtonAttrs(prs, status, automation, t)}>
|
||||
<MultiChipGlyph prs={prs} status={status} automation={automation} />
|
||||
</button>
|
||||
</ChangeRequestStatusChip>
|
||||
</PopoverAnchor>
|
||||
</span>
|
||||
</ChangeRequestStatusChipHoverArea>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
@@ -431,46 +382,30 @@ function PRStatusChipMultiDrawer({
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<button
|
||||
<ChangeRequestStatusChip
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
{...multiChipButtonAttrs(prs, status, automation, t)}
|
||||
>
|
||||
<MultiChipGlyph prs={prs} status={status} automation={automation} />
|
||||
</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">
|
||||
{t("github:pullRequestCount", { count: prs.length })}
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="sr-only">
|
||||
{t("github:pullRequestCiStatusReviewsAnd")}
|
||||
</DrawerDescription>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
data-testid="pr-status-chip-drawer-close"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("github:closePrStatus")}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3" data-vaul-no-drag>
|
||||
<MultiPRCIPopover
|
||||
prs={prs}
|
||||
enabled={open}
|
||||
refreshTaskPR={refreshTaskPR}
|
||||
onRemovePR={onRemovePR}
|
||||
onCollapseFocus={() => focusAfterCollapse(triggerRef)}
|
||||
/>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</ChangeRequestStatusChip>
|
||||
<ChangeRequestStatusDrawerContent
|
||||
testId="pr-status-chip-drawer"
|
||||
closeTestId="pr-status-chip-drawer-close"
|
||||
title={t("github:pullRequestCount", { count: prs.length })}
|
||||
description={t("github:pullRequestCiStatusReviewsAnd")}
|
||||
closeLabel={t("github:closePrStatus")}
|
||||
>
|
||||
<MultiPRCIPopover
|
||||
prs={prs}
|
||||
enabled={open}
|
||||
refreshTaskPR={refreshTaskPR}
|
||||
onRemovePR={onRemovePR}
|
||||
onCollapseFocus={() => focusAfterCollapse(triggerRef)}
|
||||
/>
|
||||
</ChangeRequestStatusDrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -481,9 +416,8 @@ function PRStatusChipDrawer({ pr, automation, refreshTaskPR, triggerRef }: Singl
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<button
|
||||
<ChangeRequestStatusChip
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
@@ -492,32 +426,16 @@ function PRStatusChipDrawer({ pr, automation, refreshTaskPR, triggerRef }: Singl
|
||||
<IconChecklist className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
<ChipStatusGlyph status={status} />
|
||||
<AutomationFlagBadges automation={automation} />
|
||||
</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">
|
||||
{t("github:pr")}
|
||||
{pr.pr_number}
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="sr-only">
|
||||
{t("github:pullRequestCiStatusReviewsAnd")}
|
||||
</DrawerDescription>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
data-testid="pr-status-chip-drawer-close"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("github:closePrStatus")}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3" data-vaul-no-drag>
|
||||
<PRCIPopover pr={pr} enabled={open} refreshTaskPR={refreshTaskPR} />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</ChangeRequestStatusChip>
|
||||
<ChangeRequestStatusDrawerContent
|
||||
testId="pr-status-chip-drawer"
|
||||
closeTestId="pr-status-chip-drawer-close"
|
||||
title={`${t("github:pr")}${pr.pr_number}`}
|
||||
description={t("github:pullRequestCiStatusReviewsAnd")}
|
||||
closeLabel={t("github:closePrStatus")}
|
||||
>
|
||||
<PRCIPopover pr={pr} enabled={open} refreshTaskPR={refreshTaskPR} />
|
||||
</ChangeRequestStatusDrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,57 +2,34 @@
|
||||
|
||||
import { IconGitPullRequest } from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useState, type FocusEvent, type PointerEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useChangeRequestTaskTooltipState } from "@/components/integrations/use-change-request-task-tooltip-state";
|
||||
import {
|
||||
CHANGE_REQUEST_STATUS_COLORS,
|
||||
CHANGE_REQUEST_STATUS_RANK,
|
||||
getChangeRequestAggregateStatusColor,
|
||||
} from "@/components/integrations/change-request-task-status-color";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import { derivePRTaskStatusSummary, PRTaskStatusSummary } from "./pr-task-status-summary";
|
||||
|
||||
const MUTED_FOREGROUND = "text-muted-foreground";
|
||||
const PURPLE_500 = "text-purple-500";
|
||||
const RED_500 = "text-red-500";
|
||||
const YELLOW_500 = "text-yellow-500";
|
||||
const SKY_400 = "text-sky-400";
|
||||
const EMERALD_400 = "text-emerald-400";
|
||||
const GREEN_500 = "text-green-500";
|
||||
const MUTED_FOREGROUND = CHANGE_REQUEST_STATUS_COLORS.muted;
|
||||
const PURPLE_500 = CHANGE_REQUEST_STATUS_COLORS.merged;
|
||||
const RED_500 = CHANGE_REQUEST_STATUS_COLORS.danger;
|
||||
const YELLOW_500 = CHANGE_REQUEST_STATUS_COLORS.warning;
|
||||
const SKY_400 = CHANGE_REQUEST_STATUS_COLORS.review;
|
||||
const EMERALD_400 = CHANGE_REQUEST_STATUS_COLORS.ready;
|
||||
const GREEN_500 = CHANGE_REQUEST_STATUS_COLORS.passing;
|
||||
|
||||
/** Maps the task-level PR projection to the same visual language as live PRs. */
|
||||
export function getPRAggregateStatusColor(state: string | null | undefined): string {
|
||||
switch (state?.toLowerCase()) {
|
||||
case "merged":
|
||||
return PURPLE_500;
|
||||
case "closed":
|
||||
case "failure":
|
||||
return RED_500;
|
||||
case "pending":
|
||||
return YELLOW_500;
|
||||
case "awaiting_review":
|
||||
return SKY_400;
|
||||
case "ready":
|
||||
return EMERALD_400;
|
||||
case "passing":
|
||||
return GREEN_500;
|
||||
case "draft":
|
||||
case "blocked":
|
||||
case "neutral":
|
||||
case "open":
|
||||
default:
|
||||
return MUTED_FOREGROUND;
|
||||
}
|
||||
return getChangeRequestAggregateStatusColor(state);
|
||||
}
|
||||
|
||||
const STATUS_RANK: Record<string, number> = {
|
||||
// Higher = more attention-worthy. Drives the aggregated icon color when a
|
||||
// task has multiple PRs (we surface the worst state).
|
||||
[RED_500]: 5,
|
||||
[YELLOW_500]: 4,
|
||||
[SKY_400]: 3,
|
||||
[EMERALD_400]: 2,
|
||||
[GREEN_500]: 1,
|
||||
[PURPLE_500]: 0,
|
||||
[MUTED_FOREGROUND]: 0,
|
||||
};
|
||||
// Higher = more attention-worthy. Drives the aggregated icon color when a
|
||||
// task has multiple PRs (we surface the worst state).
|
||||
const STATUS_RANK = CHANGE_REQUEST_STATUS_RANK;
|
||||
|
||||
function hasExplicitPRChecksPassed(pr: TaskPR): boolean {
|
||||
return pr.checks_state === "success";
|
||||
@@ -180,7 +157,7 @@ export function aggregatePRStatusColor(prs: TaskPR[]): string {
|
||||
if (prs.length === 0) return MUTED_FOREGROUND;
|
||||
const open = prs.filter((p) => p.state === "open");
|
||||
const target = open.length > 0 ? open : prs;
|
||||
let bestColor = MUTED_FOREGROUND;
|
||||
let bestColor: string = MUTED_FOREGROUND;
|
||||
let bestRank = -1;
|
||||
for (const pr of target) {
|
||||
const color = getPRStatusColor(pr);
|
||||
@@ -244,41 +221,9 @@ export function PRTaskIcon({ taskId }: { taskId: string }) {
|
||||
return <MultiPRIcon taskId={taskId} prs={prs} />;
|
||||
}
|
||||
|
||||
function usePRTaskTooltipState() {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
return {
|
||||
open: !dismissed && (hovered || focused),
|
||||
onPointerEnter(event: PointerEvent<HTMLSpanElement>) {
|
||||
if (event.pointerType !== "mouse") return;
|
||||
setHovered(true);
|
||||
setDismissed(false);
|
||||
},
|
||||
onPointerLeave(event: PointerEvent<HTMLSpanElement>) {
|
||||
if (event.pointerType !== "mouse") return;
|
||||
setHovered(false);
|
||||
if (!focused) setDismissed(false);
|
||||
},
|
||||
onFocus(event: FocusEvent<HTMLSpanElement>) {
|
||||
if (!event.currentTarget.matches(":focus-visible")) return;
|
||||
setFocused(true);
|
||||
setDismissed(false);
|
||||
},
|
||||
onBlur() {
|
||||
setFocused(false);
|
||||
if (!hovered) setDismissed(false);
|
||||
},
|
||||
onEscapeKeyDown() {
|
||||
setDismissed(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function SinglePRIcon({ taskId, pr }: { taskId: string; pr: TaskPR }) {
|
||||
const { t } = useTranslation();
|
||||
const tooltip = usePRTaskTooltipState();
|
||||
const tooltip = useChangeRequestTaskTooltipState();
|
||||
const readyToMerge = isPRReadyToMerge(pr);
|
||||
const summary = derivePRTaskStatusSummary(pr, readyToMerge);
|
||||
return (
|
||||
@@ -314,7 +259,7 @@ function SinglePRIcon({ taskId, pr }: { taskId: string; pr: TaskPR }) {
|
||||
|
||||
function MultiPRIcon({ taskId, prs }: { taskId: string; prs: TaskPR[] }) {
|
||||
const { t } = useTranslation();
|
||||
const tooltip = usePRTaskTooltipState();
|
||||
const tooltip = useChangeRequestTaskTooltipState();
|
||||
const aggregateColor = aggregatePRStatusColor(prs);
|
||||
const allReady = areAllOpenPRsReadyToMerge(prs);
|
||||
const summaries = prs.map((pr) => derivePRTaskStatusSummary(pr, isPRReadyToMerge(pr)));
|
||||
|
||||
@@ -1,52 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconCircleDot,
|
||||
IconClockHour4,
|
||||
IconGitMerge,
|
||||
IconGitPullRequest,
|
||||
IconGitPullRequestDraft,
|
||||
IconX,
|
||||
type Icon as TablerIcon,
|
||||
} from "@tabler/icons-react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import {
|
||||
ChangeRequestTaskStatusSummary,
|
||||
type ChangeRequestTaskStatusSummaryData,
|
||||
type ChangeRequestTaskSummaryRow,
|
||||
type ChangeRequestTaskSummaryRowKind,
|
||||
type ChangeRequestTaskSummaryStatus,
|
||||
type ChangeRequestTaskSummaryTone,
|
||||
} from "@/components/integrations/change-request-task-status-summary";
|
||||
|
||||
export type PRTaskSummaryRowKind = "state" | "review" | "ci" | "merge";
|
||||
export type PRTaskSummaryTone = "success" | "danger" | "warning" | "info" | "merged" | "muted";
|
||||
export type PRTaskSummaryStatus =
|
||||
| "merged"
|
||||
| "closed"
|
||||
| "approved"
|
||||
| "changes_requested"
|
||||
| "pending_review"
|
||||
| "passed"
|
||||
| "failed"
|
||||
| "in_progress"
|
||||
| "draft"
|
||||
| "ready"
|
||||
| "conflicts"
|
||||
| "behind"
|
||||
| "blocked"
|
||||
| "mergeable"
|
||||
| "raw";
|
||||
|
||||
export type PRTaskSummaryRow = {
|
||||
kind: PRTaskSummaryRowKind;
|
||||
status: PRTaskSummaryStatus;
|
||||
tone: PRTaskSummaryTone;
|
||||
rawValue?: string;
|
||||
};
|
||||
|
||||
export type PRTaskStatusSummaryData = {
|
||||
number: number;
|
||||
title: string;
|
||||
rows: PRTaskSummaryRow[];
|
||||
};
|
||||
export type PRTaskSummaryRowKind = ChangeRequestTaskSummaryRowKind;
|
||||
export type PRTaskSummaryTone = ChangeRequestTaskSummaryTone;
|
||||
export type PRTaskSummaryStatus = ChangeRequestTaskSummaryStatus;
|
||||
export type PRTaskSummaryRow = ChangeRequestTaskSummaryRow;
|
||||
export type PRTaskStatusSummaryData = ChangeRequestTaskStatusSummaryData;
|
||||
|
||||
function rawRow(kind: PRTaskSummaryRowKind, rawValue: string): PRTaskSummaryRow {
|
||||
return { kind, status: "raw", tone: "muted", rawValue };
|
||||
@@ -119,123 +87,6 @@ export function derivePRTaskStatusSummary(
|
||||
return { number: pr.pr_number, title: pr.pr_title, rows };
|
||||
}
|
||||
|
||||
const ROW_LABEL_KEYS: Record<PRTaskSummaryRowKind, string> = {
|
||||
state: "github:prTaskStatusState",
|
||||
review: "github:prTaskStatusReview",
|
||||
ci: "github:prTaskStatusCi",
|
||||
merge: "github:prTaskStatusMerge",
|
||||
};
|
||||
|
||||
const TONE_CLASSES: Record<PRTaskSummaryTone, string> = {
|
||||
success: "text-emerald-600 dark:text-emerald-400",
|
||||
danger: "text-red-600 dark:text-red-400",
|
||||
warning: "text-amber-600 dark:text-amber-400",
|
||||
info: "text-sky-600 dark:text-sky-400",
|
||||
merged: "text-purple-600 dark:text-purple-400",
|
||||
muted: "text-muted-foreground",
|
||||
};
|
||||
|
||||
const STATUS_LABEL_KEYS: Record<Exclude<PRTaskSummaryStatus, "raw">, string> = {
|
||||
merged: "github:merged",
|
||||
closed: "github:closed",
|
||||
approved: "github:approved",
|
||||
changes_requested: "github:changesRequested",
|
||||
pending_review: "github:pendingReview",
|
||||
passed: "github:checkBucketPassed",
|
||||
failed: "github:checkBucketFailed",
|
||||
in_progress: "github:checkBucketInProgress",
|
||||
draft: "github:draft",
|
||||
ready: "github:readyToMerge",
|
||||
conflicts: "github:conflicts",
|
||||
behind: "github:behindBase",
|
||||
blocked: "github:blocked",
|
||||
mergeable: "github:mergeable",
|
||||
};
|
||||
|
||||
const STATUS_ICONS: Record<PRTaskSummaryStatus, TablerIcon> = {
|
||||
merged: IconGitMerge,
|
||||
closed: IconX,
|
||||
approved: IconCheck,
|
||||
changes_requested: IconX,
|
||||
pending_review: IconClockHour4,
|
||||
passed: IconCheck,
|
||||
failed: IconX,
|
||||
in_progress: IconClockHour4,
|
||||
draft: IconGitPullRequestDraft,
|
||||
ready: IconGitMerge,
|
||||
conflicts: IconAlertTriangle,
|
||||
behind: IconAlertTriangle,
|
||||
blocked: IconAlertTriangle,
|
||||
mergeable: IconGitMerge,
|
||||
raw: IconCircleDot,
|
||||
};
|
||||
|
||||
function getStatusText(row: PRTaskSummaryRow, t: TFunction): string {
|
||||
if (row.status === "raw") return row.rawValue ?? "";
|
||||
return t(STATUS_LABEL_KEYS[row.status]);
|
||||
}
|
||||
|
||||
function SummaryStatusIcon({ status }: { status: PRTaskSummaryStatus }) {
|
||||
const StatusIcon = STATUS_ICONS[status];
|
||||
return <StatusIcon aria-hidden="true" className="size-3.5 shrink-0" />;
|
||||
}
|
||||
|
||||
function SummaryRow({ row }: { row: PRTaskSummaryRow }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
data-testid={`pr-task-status-${row.kind}`}
|
||||
className="grid grid-cols-[min-content_minmax(0,1fr)] items-start gap-x-3"
|
||||
>
|
||||
<span className="text-muted-foreground">{t(ROW_LABEL_KEYS[row.kind])}</span>
|
||||
<span className={cn("flex min-w-0 items-center gap-1.5 font-medium", TONE_CLASSES[row.tone])}>
|
||||
<SummaryStatusIcon status={row.status} />
|
||||
<span className="min-w-0 break-words">{getStatusText(row, t)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PRTaskStatusSummary({ summaries }: { summaries: PRTaskStatusSummaryData[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div data-testid="pr-task-status-summary" className="w-full">
|
||||
{summaries.map((summary, index) => (
|
||||
<section
|
||||
key={`${summary.number}-${index}`}
|
||||
data-testid="pr-task-status-entry"
|
||||
aria-label={t("github:pullRequestStatus", { number: summary.number })}
|
||||
className={cn(index > 0 && "mt-3 border-t border-border/60 pt-3")}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<IconGitPullRequest
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
data-testid="pr-task-status-number"
|
||||
className="text-[11px] font-medium tabular-nums text-muted-foreground"
|
||||
>
|
||||
{t("github:prTaskStatusNumber", { number: summary.number })}
|
||||
</div>
|
||||
<div
|
||||
data-testid="pr-task-status-title"
|
||||
className="text-pretty break-words text-sm font-medium leading-snug text-foreground [overflow-wrap:anywhere]"
|
||||
>
|
||||
{summary.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{summary.rows.length > 0 && (
|
||||
<div className="mt-2.5 space-y-1.5 pl-6">
|
||||
{summary.rows.map((row) => (
|
||||
<SummaryRow key={row.kind} row={row} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return <ChangeRequestTaskStatusSummary summaries={summaries} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IconGitPullRequest,
|
||||
IconCheck,
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
IconChevronDown,
|
||||
IconAlertTriangle,
|
||||
} from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@kandev/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import {
|
||||
@@ -38,16 +38,18 @@ import { prIdentitySlug, prTaskKey } from "@/components/github/pr-utils";
|
||||
import { PR_CI_DESKTOP_POPOVER_SCROLL_CLASS, PRCIPopover } from "@/components/github/pr-ci-popover";
|
||||
import { MultiPRCIPopover } from "@/components/github/multi-pr-ci-popover";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import {
|
||||
ChangeRequestTopbarButton,
|
||||
ChangeRequestTopbarContent,
|
||||
} from "@/components/integrations/change-request-status-chrome";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const POPOVER_OPEN_DELAY_MS = 150;
|
||||
const POPOVER_CLOSE_DELAY_MS = 150;
|
||||
type TriggerRef = { current: HTMLButtonElement | null };
|
||||
|
||||
function focusAfterCollapse(triggerRef?: TriggerRef) {
|
||||
if (!triggerRef) return;
|
||||
setTimeout(() => triggerRef.current?.focus(), 0);
|
||||
if (triggerRef) setTimeout(() => triggerRef.current?.focus(), 0);
|
||||
}
|
||||
|
||||
// Badge for the hard merge blockers that must beat ready/awaiting-review:
|
||||
@@ -164,15 +166,12 @@ function PRSingleButton({
|
||||
// single subscription warms both.
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
<ChangeRequestTopbarButton
|
||||
ref={triggerRef}
|
||||
data-testid="pr-topbar-button"
|
||||
data-pr-number={pr.pr_number}
|
||||
data-pr-state={pr.state}
|
||||
data-pr-ready-to-merge={isPRReadyToMerge(pr) ? "true" : "false"}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="cursor-pointer gap-1.5 px-2"
|
||||
onMouseOver={onTriggerEnter}
|
||||
onMouseEnter={onTriggerEnter}
|
||||
onMouseMove={onTriggerEnter}
|
||||
@@ -188,10 +187,12 @@ function PRSingleButton({
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<IconGitPullRequest className={`h-4 w-4 ${getPRStatusColor(pr)}`} />
|
||||
<span className="text-xs font-medium">#{pr.pr_number}</span>
|
||||
<PRStatusIcon pr={pr} />
|
||||
</Button>
|
||||
<ChangeRequestTopbarContent
|
||||
label={`#${pr.pr_number}`}
|
||||
colorClassName={getPRStatusColor(pr)}
|
||||
statusIcon={<PRStatusIcon pr={pr} />}
|
||||
/>
|
||||
</ChangeRequestTopbarButton>
|
||||
);
|
||||
|
||||
if (usesTouchDrawer) {
|
||||
@@ -259,13 +260,10 @@ function PRMultiButton({
|
||||
// never stack.
|
||||
const triggerButton = (
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
<ChangeRequestTopbarButton
|
||||
ref={triggerRef}
|
||||
data-testid="pr-topbar-button"
|
||||
data-pr-count={prs.length}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="cursor-pointer gap-1.5 px-2"
|
||||
onMouseOver={menuOpen ? undefined : onTriggerEnter}
|
||||
onMouseEnter={menuOpen ? undefined : onTriggerEnter}
|
||||
onMouseMove={menuOpen ? undefined : onTriggerEnter}
|
||||
@@ -277,12 +275,12 @@ function PRMultiButton({
|
||||
onFocus={menuOpen ? undefined : onTriggerEnter}
|
||||
onBlur={onTriggerLeave}
|
||||
>
|
||||
<IconGitPullRequest className={`h-4 w-4 ${aggColor}`} />
|
||||
<span className="text-xs font-medium">
|
||||
{prs.length} {t("github:prs")}
|
||||
</span>
|
||||
<IconChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</Button>
|
||||
<ChangeRequestTopbarContent
|
||||
label={`${prs.length} ${t("github:prs")}`}
|
||||
colorClassName={aggColor}
|
||||
dropdown={<IconChevronDown className="h-3 w-3 text-muted-foreground" />}
|
||||
/>
|
||||
</ChangeRequestTopbarButton>
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user