feat(plugins): add source-control extension contracts (#2117)

This commit is contained in:
José Almeida
2026-08-13 08:29:29 +01:00
committed by GitHub
parent 064b852884
commit f218880ecb
530 changed files with 39054 additions and 7518 deletions
@@ -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"
/>
);
}
+46 -115
View File
@@ -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";
+84 -397
View File
@@ -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();
});
});
+178 -278
View File
@@ -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>&middot;</span>
<span className={getTimeAgoColor(taskPR.created_at)}>
{t("github:openedAgo", { time: formatTimeAgo(taskPR.created_at) })}
</span>
{taskPR.merged_at && (
<>
<span>&middot;</span>
<span className="flex items-center gap-0.5">
<IconGitMerge className="h-3 w-3 text-purple-500" />
{t("github:mergedAgo", { time: formatTimeAgo(taskPR.merged_at) })}
</span>
</>
)}
{taskPR.closed_at && !taskPR.merged_at && (
<>
<span>&middot;</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>&middot;</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>&middot;</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">&rarr;</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();
+56 -138
View File
@@ -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>
);
}
+20 -75
View File
@@ -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} />;
}
+20 -22
View File
@@ -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>
);