feat(web): improve task PR status hover (#2393)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import { getPRStatusColor, getPRTooltip } from "./pr-task-icon";
|
||||
import { getPRStatusColor } from "./pr-task-icon";
|
||||
import { derivePRTaskStatusSummary } from "./pr-task-status-summary";
|
||||
|
||||
function draftPR(): TaskPR {
|
||||
return {
|
||||
@@ -40,8 +41,8 @@ describe("draft PR task status", () => {
|
||||
});
|
||||
|
||||
it("identifies the draft without claiming it is ready to merge", () => {
|
||||
const tooltip = getPRTooltip(draftPR());
|
||||
expect(tooltip).toContain("Draft");
|
||||
expect(tooltip).not.toContain("Ready to merge");
|
||||
const summary = derivePRTaskStatusSummary(draftPR(), false);
|
||||
expect(summary.rows.at(-1)).toEqual({ kind: "merge", status: "draft", tone: "muted" });
|
||||
expect(summary.rows.some((row) => row.status === "ready")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
getPRAggregateStatusColor,
|
||||
areAllOpenPRsReadyToMerge,
|
||||
getPRStatusColor,
|
||||
getPRTooltip,
|
||||
isPRAwaitingReview,
|
||||
isPRReadyToMerge,
|
||||
isPRWaitingOnBranchProtection,
|
||||
@@ -657,33 +656,3 @@ describe("pickDefaultPR", () => {
|
||||
expect(pickDefaultPR([merged, closed])?.id).toBe("merged");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPRTooltip", () => {
|
||||
it("includes 'Ready to merge' when ready", () => {
|
||||
const pr = makePR({
|
||||
state: "open",
|
||||
review_state: "approved",
|
||||
checks_state: "success",
|
||||
mergeable_state: "clean",
|
||||
});
|
||||
expect(getPRTooltip(pr)).toContain("Ready to merge");
|
||||
});
|
||||
|
||||
it("includes 'Mergeable: blocked' when blocked", () => {
|
||||
const pr = makePR({
|
||||
state: "open",
|
||||
review_state: "approved",
|
||||
checks_state: "success",
|
||||
mergeable_state: "blocked",
|
||||
});
|
||||
expect(getPRTooltip(pr)).toContain("Mergeable: blocked");
|
||||
expect(getPRTooltip(pr)).not.toContain("Ready to merge");
|
||||
});
|
||||
|
||||
it("omits mergeable when state is empty or unknown", () => {
|
||||
const empty = makePR({ state: "open", mergeable_state: "" });
|
||||
const unknown = makePR({ state: "open", mergeable_state: "unknown" });
|
||||
expect(getPRTooltip(empty)).not.toContain("Mergeable:");
|
||||
expect(getPRTooltip(unknown)).not.toContain("Mergeable:");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
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 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";
|
||||
@@ -166,21 +169,6 @@ export function getPRStatusColor(pr: TaskPR): string {
|
||||
return MUTED_FOREGROUND;
|
||||
}
|
||||
|
||||
export function getPRTooltip(pr: TaskPR): string {
|
||||
const parts = [`PR #${pr.pr_number}: ${pr.pr_title}`];
|
||||
if (pr.state !== "open") parts.push(`State: ${pr.state}`);
|
||||
if (pr.review_state) parts.push(`Review: ${pr.review_state}`);
|
||||
if (pr.checks_state) parts.push(`CI: ${pr.checks_state}`);
|
||||
if (isPRDraft(pr)) {
|
||||
parts.push("Draft");
|
||||
} else if (isPRReadyToMerge(pr)) {
|
||||
parts.push("Ready to merge");
|
||||
} else if (pr.mergeable_state && pr.mergeable_state !== "unknown" && pr.state === "open") {
|
||||
parts.push(`Mergeable: ${pr.mergeable_state}`);
|
||||
}
|
||||
return parts.join(" | ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the most attention-worthy color across N PRs. For multi-repo tasks one
|
||||
* red PR should dominate the visual even if the others are green. Terminal
|
||||
@@ -256,52 +244,106 @@ 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 readyToMerge = isPRReadyToMerge(pr);
|
||||
const summary = derivePRTaskStatusSummary(pr, readyToMerge);
|
||||
return (
|
||||
<Tooltip>
|
||||
<Tooltip open={tooltip.open}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-testid={`pr-task-icon-${taskId}`}
|
||||
data-pr-state={pr.state}
|
||||
data-pr-count="1"
|
||||
data-pr-ready-to-merge={isPRReadyToMerge(pr) ? "true" : "false"}
|
||||
data-pr-ready-to-merge={readyToMerge ? "true" : "false"}
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
aria-label={t("github:pullRequestStatus", { number: pr.pr_number })}
|
||||
onPointerEnter={tooltip.onPointerEnter}
|
||||
onPointerLeave={tooltip.onPointerLeave}
|
||||
onFocus={tooltip.onFocus}
|
||||
onBlur={tooltip.onBlur}
|
||||
className={cn("inline-flex items-center shrink-0", getPRStatusColor(pr))}
|
||||
>
|
||||
<IconGitPullRequest className="h-3.5 w-3.5" />
|
||||
<IconGitPullRequest aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{getPRTooltip(pr)}</TooltipContent>
|
||||
<TooltipContent
|
||||
sideOffset={6}
|
||||
onEscapeKeyDown={tooltip.onEscapeKeyDown}
|
||||
className="w-80 max-w-[calc(100vw-1rem)] p-3"
|
||||
>
|
||||
<PRTaskStatusSummary summaries={[summary]} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiPRIcon({ taskId, prs }: { taskId: string; prs: TaskPR[] }) {
|
||||
const { t } = useTranslation();
|
||||
const tooltip = usePRTaskTooltipState();
|
||||
const aggregateColor = aggregatePRStatusColor(prs);
|
||||
const allReady = areAllOpenPRsReadyToMerge(prs);
|
||||
const summaries = prs.map((pr) => derivePRTaskStatusSummary(pr, isPRReadyToMerge(pr)));
|
||||
return (
|
||||
<Tooltip>
|
||||
<Tooltip open={tooltip.open}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-testid={`pr-task-icon-${taskId}`}
|
||||
data-pr-count={prs.length}
|
||||
data-pr-ready-to-merge={allReady ? "true" : "false"}
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
aria-label={t("github:pullRequestStatuses", { count: prs.length })}
|
||||
onPointerEnter={tooltip.onPointerEnter}
|
||||
onPointerLeave={tooltip.onPointerLeave}
|
||||
onFocus={tooltip.onFocus}
|
||||
onBlur={tooltip.onBlur}
|
||||
className={cn("inline-flex items-center gap-0.5 shrink-0", aggregateColor)}
|
||||
>
|
||||
<IconGitPullRequest className="h-3.5 w-3.5" />
|
||||
<span className="text-[9px] font-semibold leading-none">{prs.length}</span>
|
||||
<IconGitPullRequest aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
<span className="text-[9px] font-semibold leading-none tabular-nums">{prs.length}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
{prs.map((pr) => (
|
||||
<div key={pr.id} className="flex items-center gap-2">
|
||||
<span className={cn("inline-flex shrink-0", getPRStatusColor(pr))}>
|
||||
<IconGitPullRequest className="h-3 w-3" />
|
||||
</span>
|
||||
<span>{getPRTooltip(pr)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<TooltipContent
|
||||
sideOffset={6}
|
||||
onEscapeKeyDown={tooltip.onEscapeKeyDown}
|
||||
className="w-80 max-w-[calc(100vw-1rem)] p-3"
|
||||
>
|
||||
<PRTaskStatusSummary summaries={summaries} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import { derivePRTaskStatusSummary } from "./pr-task-status-summary";
|
||||
|
||||
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
|
||||
return {
|
||||
id: "pr-id",
|
||||
task_id: "task-id",
|
||||
owner: "kdlbs",
|
||||
repo: "kandev",
|
||||
pr_number: 2966,
|
||||
pr_url: "https://github.com/kdlbs/kandev/pull/2966",
|
||||
pr_title: "Make pull request status easier to scan",
|
||||
head_branch: "feat/readable-pr-summary",
|
||||
base_branch: "main",
|
||||
author_login: "octocat",
|
||||
state: "open",
|
||||
review_state: "",
|
||||
checks_state: "",
|
||||
mergeable_state: "",
|
||||
review_count: 0,
|
||||
pending_review_count: 0,
|
||||
comment_count: 0,
|
||||
unresolved_review_threads: 0,
|
||||
checks_total: 0,
|
||||
checks_passing: 0,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
created_at: "",
|
||||
merged_at: null,
|
||||
closed_at: null,
|
||||
last_synced_at: null,
|
||||
updated_at: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("structured PR task status summary", () => {
|
||||
it("derives separate review, CI, and ready-to-merge rows", () => {
|
||||
const pr = makePR({
|
||||
review_state: "approved",
|
||||
checks_state: "success",
|
||||
mergeable_state: "clean",
|
||||
});
|
||||
|
||||
const summary = derivePRTaskStatusSummary(pr, true);
|
||||
|
||||
expect(summary).toEqual({
|
||||
number: 2966,
|
||||
title: "Make pull request status easier to scan",
|
||||
rows: [
|
||||
{ kind: "review", status: "approved", tone: "success" },
|
||||
{ kind: "ci", status: "passed", tone: "success" },
|
||||
{ kind: "merge", status: "ready", tone: "success" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves unknown provider values while omitting missing rows", () => {
|
||||
const summary = derivePRTaskStatusSummary(
|
||||
makePR({
|
||||
review_state: "queued_for_review" as unknown as TaskPR["review_state"],
|
||||
checks_state: "",
|
||||
mergeable_state: "unknown",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
expect(summary.rows).toEqual([
|
||||
{
|
||||
kind: "review",
|
||||
status: "raw",
|
||||
tone: "muted",
|
||||
rawValue: "queued_for_review",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses a terminal state row and omits mergeability for merged PRs", () => {
|
||||
const summary = derivePRTaskStatusSummary(
|
||||
makePR({
|
||||
state: "merged",
|
||||
review_state: "approved",
|
||||
checks_state: "success",
|
||||
mergeable_state: "clean",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
expect(summary.rows).toEqual([
|
||||
{ kind: "state", status: "merged", tone: "merged" },
|
||||
{ kind: "review", status: "approved", tone: "success" },
|
||||
{ kind: "ci", status: "passed", tone: "success" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows blocked as a wait instead of claiming the PR is ready", () => {
|
||||
const summary = derivePRTaskStatusSummary(
|
||||
makePR({
|
||||
review_state: "approved",
|
||||
checks_state: "success",
|
||||
mergeable_state: "blocked",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
expect(summary.rows.at(-1)).toEqual({
|
||||
kind: "merge",
|
||||
status: "blocked",
|
||||
tone: "muted",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps requested changes, failed CI, and conflicts to danger rows", () => {
|
||||
const summary = derivePRTaskStatusSummary(
|
||||
makePR({
|
||||
review_state: "changes_requested",
|
||||
checks_state: "failure",
|
||||
mergeable_state: "dirty",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
expect(summary.rows).toEqual([
|
||||
{ kind: "review", status: "changes_requested", tone: "danger" },
|
||||
{ kind: "ci", status: "failed", tone: "danger" },
|
||||
{ kind: "merge", status: "conflicts", tone: "danger" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps draft precedence over a supplied ready state", () => {
|
||||
const summary = derivePRTaskStatusSummary(makePR({ mergeable_state: "draft" }), true);
|
||||
|
||||
expect(summary.rows).toEqual([{ kind: "merge", status: "draft", tone: "muted" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
"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";
|
||||
|
||||
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[];
|
||||
};
|
||||
|
||||
function rawRow(kind: PRTaskSummaryRowKind, rawValue: string): PRTaskSummaryRow {
|
||||
return { kind, status: "raw", tone: "muted", rawValue };
|
||||
}
|
||||
|
||||
function deriveStateRow(state: string): PRTaskSummaryRow | null {
|
||||
if (!state || state === "open") return null;
|
||||
if (state === "merged") return { kind: "state", status: "merged", tone: "merged" };
|
||||
if (state === "closed") return { kind: "state", status: "closed", tone: "danger" };
|
||||
return rawRow("state", state);
|
||||
}
|
||||
|
||||
function deriveReviewRow(reviewState: string): PRTaskSummaryRow | null {
|
||||
if (!reviewState) return null;
|
||||
if (reviewState === "approved") {
|
||||
return { kind: "review", status: "approved", tone: "success" };
|
||||
}
|
||||
if (reviewState === "changes_requested") {
|
||||
return { kind: "review", status: "changes_requested", tone: "danger" };
|
||||
}
|
||||
if (reviewState === "pending") {
|
||||
return { kind: "review", status: "pending_review", tone: "info" };
|
||||
}
|
||||
return rawRow("review", reviewState);
|
||||
}
|
||||
|
||||
function deriveCIRow(checksState: string): PRTaskSummaryRow | null {
|
||||
if (!checksState) return null;
|
||||
if (checksState === "success") return { kind: "ci", status: "passed", tone: "success" };
|
||||
if (checksState === "failure") return { kind: "ci", status: "failed", tone: "danger" };
|
||||
if (checksState === "pending") {
|
||||
return { kind: "ci", status: "in_progress", tone: "warning" };
|
||||
}
|
||||
return rawRow("ci", checksState);
|
||||
}
|
||||
|
||||
function deriveMergeRow(pr: TaskPR, readyToMerge: boolean): PRTaskSummaryRow | null {
|
||||
if (pr.state !== "open") return null;
|
||||
if (pr.mergeable_state === "draft") {
|
||||
return { kind: "merge", status: "draft", tone: "muted" };
|
||||
}
|
||||
if (readyToMerge) return { kind: "merge", status: "ready", tone: "success" };
|
||||
if (!pr.mergeable_state || pr.mergeable_state === "unknown") return null;
|
||||
if (pr.mergeable_state === "dirty") {
|
||||
return { kind: "merge", status: "conflicts", tone: "danger" };
|
||||
}
|
||||
if (pr.mergeable_state === "behind") {
|
||||
return { kind: "merge", status: "behind", tone: "warning" };
|
||||
}
|
||||
if (pr.mergeable_state === "blocked") {
|
||||
return { kind: "merge", status: "blocked", tone: "muted" };
|
||||
}
|
||||
if (pr.mergeable_state === "clean") {
|
||||
return { kind: "merge", status: "mergeable", tone: "muted" };
|
||||
}
|
||||
return rawRow("merge", pr.mergeable_state);
|
||||
}
|
||||
|
||||
export function derivePRTaskStatusSummary(
|
||||
pr: TaskPR,
|
||||
readyToMerge: boolean,
|
||||
): PRTaskStatusSummaryData {
|
||||
const rows = [
|
||||
deriveStateRow(pr.state),
|
||||
deriveReviewRow(pr.review_state),
|
||||
deriveCIRow(pr.checks_state),
|
||||
deriveMergeRow(pr, readyToMerge),
|
||||
].filter((row): row is PRTaskSummaryRow => row !== null);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,14 @@ async function seedBadgeTest(
|
||||
|
||||
type TaskPR = NonNullable<Awaited<ReturnType<ApiClient["getTaskPR"]>>>;
|
||||
|
||||
function visibleTaskPRSummary(page: Page) {
|
||||
return page
|
||||
.locator(
|
||||
'[data-slot="tooltip-content"]:not([data-state="closed"]) > [data-testid="pr-task-status-summary"]',
|
||||
)
|
||||
.first();
|
||||
}
|
||||
|
||||
async function waitForTaskPRFields(
|
||||
apiClient: ApiClient,
|
||||
taskId: string,
|
||||
@@ -346,4 +354,123 @@ test.describe("PR status badge", () => {
|
||||
await expect(icon).not.toHaveClass(/text-emerald-400/);
|
||||
await expect(icon).not.toHaveClass(/text-green-500/);
|
||||
});
|
||||
|
||||
test("renders readable task PR summary", async ({ testPage, apiClient, seedData, prCapture }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const taskTitle = "Readable PR Summary Task";
|
||||
const prTitle =
|
||||
"Improve pull request status readability with a clear title and separate review, CI, and merge rows";
|
||||
const { task } = await seedBadgeTest(
|
||||
apiClient,
|
||||
seedData.workspaceId,
|
||||
seedData.agentProfileId,
|
||||
seedData.repositoryId,
|
||||
taskTitle,
|
||||
);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await apiClient.mockGitHubAssociateTaskPR({
|
||||
task_id: task.id,
|
||||
owner: "testorg",
|
||||
repo: "testrepo",
|
||||
pr_number: 2966,
|
||||
pr_url: "https://github.com/testorg/testrepo/pull/2966",
|
||||
pr_title: prTitle,
|
||||
head_branch: "feat/readable-pr-summary",
|
||||
base_branch: "main",
|
||||
author_login: "test-user",
|
||||
state: "open",
|
||||
review_state: "approved",
|
||||
checks_state: "success",
|
||||
mergeable_state: "clean",
|
||||
});
|
||||
await waitForTaskPRFields(apiClient, task.id, {
|
||||
state: "open",
|
||||
review_state: "approved",
|
||||
checks_state: "success",
|
||||
mergeable_state: "clean",
|
||||
});
|
||||
|
||||
const taskRow = testPage.getByTestId("sidebar-task-item").filter({ hasText: taskTitle });
|
||||
await expect(taskRow).toBeVisible({ timeout: 15_000 });
|
||||
const icon = taskRow.getByTestId(`pr-task-icon-${task.id}`);
|
||||
await expect(icon).toHaveAttribute("data-pr-ready-to-merge", "true");
|
||||
await icon.hover();
|
||||
|
||||
const summary = visibleTaskPRSummary(testPage);
|
||||
await expect(summary).toBeVisible();
|
||||
await expect(summary.getByTestId("pr-task-status-number")).toHaveText("PR #2966");
|
||||
const title = summary.getByTestId("pr-task-status-title");
|
||||
await expect(title).toHaveText(prTitle);
|
||||
|
||||
const reviewRow = summary.getByTestId("pr-task-status-review");
|
||||
await expect(reviewRow).toContainText("Review");
|
||||
await expect(reviewRow).toContainText("Approved");
|
||||
const ciRow = summary.getByTestId("pr-task-status-ci");
|
||||
await expect(ciRow).toContainText("CI");
|
||||
await expect(ciRow).toContainText("Passed");
|
||||
const mergeRow = summary.getByTestId("pr-task-status-merge");
|
||||
await expect(mergeRow).toContainText("Merge");
|
||||
await expect(mergeRow).toContainText("Ready to merge");
|
||||
await prCapture.screenshot("readable-task-pr-summary", {
|
||||
caption: "Structured pull request summary in the task sidebar",
|
||||
});
|
||||
|
||||
const [summaryBox, titleBox] = await Promise.all([summary.boundingBox(), title.boundingBox()]);
|
||||
expect(summaryBox).not.toBeNull();
|
||||
expect(titleBox).not.toBeNull();
|
||||
expect(titleBox!.height).toBeGreaterThan(24);
|
||||
const viewport = testPage.viewportSize();
|
||||
expect(viewport).not.toBeNull();
|
||||
expect(summaryBox!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(summaryBox!.y).toBeGreaterThanOrEqual(0);
|
||||
expect(summaryBox!.x + summaryBox!.width).toBeLessThanOrEqual(viewport!.width);
|
||||
expect(summaryBox!.y + summaryBox!.height).toBeLessThanOrEqual(viewport!.height);
|
||||
expect(
|
||||
await testPage.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await taskRow.focus();
|
||||
await testPage.keyboard.press("Tab");
|
||||
await expect(icon).toBeFocused();
|
||||
const focusedSummary = visibleTaskPRSummary(testPage);
|
||||
await expect(focusedSummary).toBeVisible();
|
||||
await expect(focusedSummary.getByTestId("pr-task-status-title")).toHaveText(prTitle);
|
||||
|
||||
await testPage.mouse.move(viewport!.width - 1, viewport!.height - 1);
|
||||
await expect(focusedSummary).toBeVisible();
|
||||
await testPage.keyboard.press("Escape");
|
||||
await expect(focusedSummary).toBeHidden();
|
||||
await apiClient.mockGitHubAssociateTaskPR({
|
||||
workspace_id: seedData.workspaceId,
|
||||
task_id: task.id,
|
||||
owner: "testorg",
|
||||
repo: "api",
|
||||
pr_number: 2967,
|
||||
pr_url: "https://github.com/testorg/api/pull/2967",
|
||||
pr_title: "Resolve the failing API checks",
|
||||
head_branch: "fix/api-checks",
|
||||
base_branch: "main",
|
||||
author_login: "test-user",
|
||||
state: "open",
|
||||
review_state: "changes_requested",
|
||||
checks_state: "failure",
|
||||
mergeable_state: "dirty",
|
||||
});
|
||||
await expect(icon).toHaveAttribute("data-pr-count", "2", { timeout: 15_000 });
|
||||
await icon.hover();
|
||||
|
||||
const multiSummary = visibleTaskPRSummary(testPage);
|
||||
const entries = multiSummary.getByTestId("pr-task-status-entry");
|
||||
await expect(entries).toHaveCount(2);
|
||||
await expect(entries.nth(0).getByTestId("pr-task-status-number")).toHaveText("PR #2966");
|
||||
await expect(entries.nth(1).getByTestId("pr-task-status-number")).toHaveText("PR #2967");
|
||||
await expect(entries.nth(1).getByTestId("pr-task-status-title")).toHaveText(
|
||||
"Resolve the failing API checks",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,5 +94,9 @@ test.describe("Mobile task status summary", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
expect(viewport!.width).toBeGreaterThan(0);
|
||||
|
||||
// The passive PR icon must not steal the row's native touch target.
|
||||
await targetRow.tap();
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${targetTask.task_id}$`));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client secret",
|
||||
"closePrStatus": "Close PR status",
|
||||
"closed": "Closed",
|
||||
"closedAgo": "closed {{time}}",
|
||||
"commentCount_one": "{{count}} comment",
|
||||
"commentCount_other": "{{count}} comments",
|
||||
@@ -285,6 +286,7 @@
|
||||
"managedWorkspaceCredentials": "Managed workspace credentials",
|
||||
"me": "Me",
|
||||
"meMyTeams": "Me & my teams",
|
||||
"mergeable": "Mergeable",
|
||||
"mergeConflictsWith": "Merge conflicts with",
|
||||
"mergeMethodDefault": "Merge PR",
|
||||
"mergeMethodMergeCommit": "Create a merge commit",
|
||||
@@ -381,6 +383,11 @@
|
||||
"prApproved": "PR approved",
|
||||
"prClosedWithoutMerging": "PR closed without merging",
|
||||
"prMerged": "PR merged",
|
||||
"prTaskStatusCi": "CI",
|
||||
"prTaskStatusMerge": "Merge",
|
||||
"prTaskStatusNumber": "PR #{{number}}",
|
||||
"prTaskStatusReview": "Review",
|
||||
"prTaskStatusState": "State",
|
||||
"prepareAppOnGithub": "Prepare App on GitHub",
|
||||
"presetIconBug": "Bug",
|
||||
"presetIconCheck": "Check",
|
||||
@@ -406,6 +413,9 @@
|
||||
"pullRequestCiStatusReviewsAnd": "Pull request CI status, reviews, and checks summary.",
|
||||
"pullRequestCount_one": "{{count}} pull request",
|
||||
"pullRequestCount_other": "{{count}} pull requests",
|
||||
"pullRequestStatus": "Pull request #{{number}} status",
|
||||
"pullRequestStatuses_one": "{{count}} pull request status",
|
||||
"pullRequestStatuses_other": "{{count}} pull request statuses",
|
||||
"pullRequests": "Pull requests",
|
||||
"pullRequestsAndIssuesAcrossYour": "Pull requests and issues across your repos.",
|
||||
"pullRequestsLinkedToTask_one": "{{count}} pull request linked to this task — open one",
|
||||
@@ -419,6 +429,7 @@
|
||||
"rateLimitUnitPoints": "points",
|
||||
"rateLimitUnitRequests": "requests",
|
||||
"rateLimitUnknown": "unknown",
|
||||
"readyToMerge": "Ready to merge",
|
||||
"reRequestReview": "Re-request review",
|
||||
"reRequestReviewFrom": "Re-request review from {{author}}",
|
||||
"reconnectGithub": "Reconnect GitHub",
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"clientId": "Ćĺĩēńţ ĨĎ",
|
||||
"clientSecret": "Ćĺĩēńţ śēćŕēţ",
|
||||
"closePrStatus": "Ćĺōśē ƤŔ śţàţũś",
|
||||
"closed": "Ćĺōśēď",
|
||||
"closedAgo": "ćĺōśēď {{time}}",
|
||||
"commentCount_one": "{{count}} ćōḿḿēńţ",
|
||||
"commentCount_other": "{{count}} ćōḿḿēńţś",
|
||||
@@ -285,6 +286,7 @@
|
||||
"managedWorkspaceCredentials": "Ḿàńàĝēď ŵōŕķśƥàćē ćŕēďēńţĩàĺś",
|
||||
"me": "Ḿē",
|
||||
"meMyTeams": "Ḿē & ḿŷ ţēàḿś",
|
||||
"mergeable": "Ḿēŕĝēàƀĺē",
|
||||
"mergeConflictsWith": "Ḿēŕĝē ćōńƒĺĩćţś ŵĩţĥ",
|
||||
"mergeMethodDefault": "Ḿēŕĝē ƤŔ",
|
||||
"mergeMethodMergeCommit": "Ćŕēàţē à ḿēŕĝē ćōḿḿĩţ",
|
||||
@@ -381,6 +383,11 @@
|
||||
"prApproved": "ƤŔ àƥƥŕōvēď",
|
||||
"prClosedWithoutMerging": "ƤŔ ćĺōśēď ŵĩţĥōũţ ḿēŕĝĩńĝ",
|
||||
"prMerged": "ƤŔ ḿēŕĝēď",
|
||||
"prTaskStatusCi": "ĆĨ",
|
||||
"prTaskStatusMerge": "Ḿēŕĝē",
|
||||
"prTaskStatusNumber": "ƤŔ #{{number}}",
|
||||
"prTaskStatusReview": "Ŕēvĩēŵ",
|
||||
"prTaskStatusState": "Śţàţē",
|
||||
"prepareAppOnGithub": "Ƥŕēƥàŕē Àƥƥ ōń ĜĩţĤũƀ",
|
||||
"presetIconBug": "Ɓũĝ",
|
||||
"presetIconCheck": "Ćĥēćķ",
|
||||
@@ -406,6 +413,9 @@
|
||||
"pullRequestCiStatusReviewsAnd": "Ƥũĺĺ ŕēqũēśţ ĆĨ śţàţũś, ŕēvĩēŵś, àńď ćĥēćķś śũḿḿàŕŷ.",
|
||||
"pullRequestCount_one": "{{count}} ƥũĺĺ ŕēqũēśţ",
|
||||
"pullRequestCount_other": "{{count}} ƥũĺĺ ŕēqũēśţś",
|
||||
"pullRequestStatus": "Ƥũĺĺ ŕēqũēśţ #{{number}} śţàţũś",
|
||||
"pullRequestStatuses_one": "{{count}} ƥũĺĺ ŕēqũēśţ śţàţũś",
|
||||
"pullRequestStatuses_other": "{{count}} ƥũĺĺ ŕēqũēśţ śţàţũśēś",
|
||||
"pullRequests": "Ƥũĺĺ ŕēqũēśţś",
|
||||
"pullRequestsAndIssuesAcrossYour": "Ƥũĺĺ ŕēqũēśţś àńď ĩśśũēś àćŕōśś ŷōũŕ ŕēƥōś.",
|
||||
"pullRequestsLinkedToTask_one": "{{count}} ƥũĺĺ ŕēqũēśţ ĺĩńķēď ţō ţĥĩś ţàśķ — ōƥēń ōńē",
|
||||
@@ -419,6 +429,7 @@
|
||||
"rateLimitUnitPoints": "ƥōĩńţś",
|
||||
"rateLimitUnitRequests": "ŕēqũēśţś",
|
||||
"rateLimitUnknown": "ũńķńōŵń",
|
||||
"readyToMerge": "Ŕēàďŷ ţō ḿēŕĝē",
|
||||
"reRequestReview": "Ŕē-ŕēqũēśţ ŕēvĩēŵ",
|
||||
"reRequestReviewFrom": "Ŕē-ŕēqũēśţ ŕēvĩēŵ ƒŕōḿ {{author}}",
|
||||
"reconnectGithub": "Ŕēćōńńēćţ ĜĩţĤũƀ",
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
spec: docs/specs/ui/pr-task-status-summary.md
|
||||
created: 2026-08-06
|
||||
status: implemented
|
||||
---
|
||||
|
||||
# Implementation Plan: PR Task Status Summary
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the shared task PR icon's pipe-delimited tooltip body with a small, localized summary
|
||||
component. The component will keep existing status derivation and icon attributes authoritative,
|
||||
then present each PR as a header plus separate review, CI, and merge/state rows. Focused component
|
||||
tests and desktop/mobile Playwright coverage will prove readability without changing task-row
|
||||
behavior or introducing a new touch surface.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Structured PR summary
|
||||
|
||||
- Add `apps/web/components/github/pr-task-status-summary.tsx` as the presentation boundary for
|
||||
tooltip content. Its pure derivation accepts each `TaskPR` plus the already-derived
|
||||
ready-to-merge boolean, builds only rows backed by available source fields, and feeds one grouped
|
||||
rendered entry per PR.
|
||||
- Separate `#<number>` and the PR title in a compact header. Use a fixed preferred width with a
|
||||
viewport-safe maximum, `text-pretty`, `break-words`, and a readable `leading-snug` treatment so a
|
||||
long title wraps without turning the disclosure into one dense paragraph.
|
||||
- Render review, CI, and merge/terminal state in a two-column description layout. Each row has a
|
||||
translated category label, humanized known status, Tabler semantic icon, and restrained status
|
||||
tone. Reuse the status-copy and icon language established by
|
||||
`apps/web/components/github/my-github/pr-status-badges.tsx`; preserve unknown non-empty values as
|
||||
provider data rather than translating or omitting them.
|
||||
- Use the existing `TooltipContent` surface, shadow, collision handling, and arrow. Use dividers
|
||||
only to separate multiple PR entries; do not add nested cards, animation, or interactive content.
|
||||
- Add stable selectors for the summary, each PR entry, title, and status rows so tests can assert
|
||||
hierarchy rather than matching one concatenated string.
|
||||
|
||||
### Shared task PR icon integration
|
||||
|
||||
- In `apps/web/components/github/pr-task-icon.tsx`, replace both single- and multi-PR tooltip bodies
|
||||
with `PRTaskStatusSummary`. Pass `isPRReadyToMerge(pr)` into the presentation component rather
|
||||
than reimplementing merge readiness.
|
||||
- Remove `getPRTooltip` and its string-concatenation tests. Keep `getPRStatusColor`,
|
||||
`aggregatePRStatusColor`, `areAllOpenPRsReadyToMerge`, `pickDefaultPR`, and all existing
|
||||
`data-pr-*` attributes unchanged.
|
||||
- Give the informational trigger a localized accessible name and keyboard focus path while
|
||||
preserving its passive behavior and the containing task row's existing activation semantics.
|
||||
The shared integration intentionally updates sidebar, Kanban-card, and rich-list uses together.
|
||||
|
||||
### Localization
|
||||
|
||||
- Add only missing summary labels and known-state values to
|
||||
`apps/web/src/locales/en/github.json`, resolving them with `useTranslation()` during render.
|
||||
- Regenerate `apps/web/src/locales/pseudo/github.json`; do not call `t()` at module scope or
|
||||
translate raw provider fallback values.
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** available fields become separate review, CI, and merge/state rows; ready-to-merge uses
|
||||
the supplied strict result; missing fields produce no blank rows; unknown values remain visible.
|
||||
**File:** `apps/web/components/github/pr-task-status-summary.test.ts`.
|
||||
**How:** exercise the exported pure summary derivation with table-driven single-PR states for
|
||||
approved/success/ready, failure and conflicts, draft/terminal cases, missing values, and an
|
||||
unknown provider value. Rendered hierarchy, wrapping, icons, and selectors remain Playwright
|
||||
assertions rather than component-test implementation details.
|
||||
- **What:** the shared icon still guards malformed store entries, retains single/multi attributes,
|
||||
and mounts one grouped summary entry per linked PR.
|
||||
**Files:** `apps/web/components/github/pr-task-icon.render.test.tsx` and
|
||||
`apps/web/components/github/pr-task-icon.test.ts`.
|
||||
**How:** preserve the existing malformed-store and attribute cases, and replace the obsolete
|
||||
`getPRTooltip` string cases while leaving status-derivation coverage intact.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** a ready PR with a long title exposes separate **Review — Approved**, **CI —
|
||||
Passed**, and **Merge — Ready to merge** rows from the sidebar icon.
|
||||
**File:** `apps/web/e2e/tests/pr/pr-status-badge.spec.ts`.
|
||||
**What to verify:** locate the icon inside the named sidebar task row, hover it, scope through the
|
||||
open Tooltip portal, assert the structured selectors and text, and confirm the summary and
|
||||
document remain within the viewport. Keep existing ready-to-merge attribute assertions.
|
||||
- **Scenario:** a linked-PR row in the phone task-switcher remains a passive indicator inside the
|
||||
existing primary task-navigation row.
|
||||
**File:** `apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts`.
|
||||
**What to verify:** retain the PR-state and horizontal-overflow assertions, tap the row, and
|
||||
confirm normal task navigation without a new task-row overlay.
|
||||
|
||||
## Mobile Parity
|
||||
|
||||
- **Desktop outcome:** scan PR identity, review, CI, and merge state directly from the task icon.
|
||||
- **Mobile entry point:** the task row remains the primary tap target; after navigation, the
|
||||
existing `PRStatusChipDrawer` remains the detailed touch surface.
|
||||
- **Nearest shipped exemplars:** `session-task-switcher-sheet.tsx` supplies task-row navigation and
|
||||
scroll ownership; `pr-status-chip.tsx` supplies the existing coarse-pointer PR detail drawer.
|
||||
- **Presentation choice and rationale:** this is a content-only desktop tooltip refinement. A new
|
||||
adjacent drawer trigger would need a 44 px hitbox and would compete with the dense row's primary
|
||||
navigation for supplemental information already available in the task view.
|
||||
- **Geometry and state:** the mobile drawer keeps its existing single internal scroll owner,
|
||||
safe-area behavior, task data, selection, and row action logic. No responsive preference or
|
||||
domain state is duplicated.
|
||||
- **Proof:** the focused `mobile-chrome` task-status-summary scenario verifies the shared icon,
|
||||
row navigation, and absence of document horizontal overflow.
|
||||
|
||||
## Verification Results
|
||||
|
||||
Completed on 2026-08-07.
|
||||
|
||||
- RED unit proof: the new summary assertion failed against the old pipe-delimited string, receiving
|
||||
`PR #2966: ... | Review: approved | CI: success | Ready to merge` instead of structured rows.
|
||||
- RED browser proof: the focused Chromium test failed because `pr-task-status-summary` did not
|
||||
exist in the old tooltip.
|
||||
- Focused frontend suite: 4 files and 78 tests passed, covering existing icon semantics plus ready,
|
||||
failure/conflict, blocked, draft, terminal, missing, and unknown summary states.
|
||||
- Desktop Playwright: 1 Chromium scenario passed for hover hierarchy, long-title wrapping,
|
||||
viewport containment, keyboard focus, and two-PR grouping.
|
||||
- Mobile Playwright: 1 `mobile-chrome` scenario passed for passive-icon row navigation and
|
||||
document overflow containment.
|
||||
- `pnpm run typecheck`, targeted ESLint, `i18n:check`, and `i18n:ratchet` passed. The i18n check
|
||||
retained 918 advisory pre-existing `zh-cn` catalog parity issues and confirmed the pseudo locale
|
||||
is synchronized.
|
||||
- A gated PR screenshot was captured and visually inspected at
|
||||
`apps/web/.pr-assets/pr-status-badge--readable-task-pr-summary.png`; the following normal E2E run
|
||||
cleaned the ignored `.pr-assets` directory before successful runner teardown.
|
||||
|
||||
No blockers or residual in-scope risks remain.
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
Wave 1 (sequential):
|
||||
|
||||
- [x] [Task 01: Build readable PR task summary](task-01-readable-pr-task-summary.md)
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm install --frozen-lockfile
|
||||
cd apps && pnpm --filter @kandev/web test -- --run \
|
||||
components/github/pr-task-icon.test.ts \
|
||||
components/github/pr-task-icon-draft.test.ts \
|
||||
components/github/pr-task-icon.render.test.tsx \
|
||||
components/github/pr-task-status-summary.test.ts
|
||||
cd apps/web && pnpm e2e:run tests/pr/pr-status-badge.spec.ts \
|
||||
-- --grep "renders readable task PR summary"
|
||||
cd apps/web && pnpm e2e:run --project mobile-chrome \
|
||||
tests/task/mobile-task-status-summary.spec.ts
|
||||
cd apps/web && pnpm run typecheck
|
||||
cd apps && pnpm --filter @kandev/web run i18n:check
|
||||
cd apps && pnpm --filter @kandev/web run i18n:ratchet
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
- `PRTaskIcon` is shared by sidebar, Kanban, and rich task-list surfaces. The summary must improve
|
||||
all three without changing click propagation or the compact trigger geometry.
|
||||
- Radix can render both an accessibility tooltip copy and a visible portal. Browser assertions
|
||||
must start at the open `[data-slot="tooltip-content"]` portal to avoid strict-locator ambiguity.
|
||||
- Humanizing provider values must not create a second merge-readiness rule. Only
|
||||
`isPRReadyToMerge` may produce **Ready to merge**, and unknown non-empty values must survive.
|
||||
- Multiple linked PRs increase tooltip height. Keep each entry compact and avoid adding full CI or
|
||||
review detail that belongs in the existing PR status popover/drawer.
|
||||
|
||||
## Documentation Impact
|
||||
|
||||
No public documentation change is required. This work changes an in-product informational
|
||||
disclosure, not a command, setting, workflow, API, or public term. No ADR is warranted because it
|
||||
adds no architectural boundary or persistent contract.
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
id: "01-readable-pr-task-summary"
|
||||
title: "Build readable PR task summary"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/pr-task-status-summary.md"
|
||||
---
|
||||
|
||||
# Task 01: Build Readable PR Task Summary
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Fine-pointer hover and keyboard focus show each linked PR as a distinct header plus labelled,
|
||||
icon-supported review, CI, and merge/state rows; long titles wrap and missing rows stay absent.
|
||||
- Existing icon color, multi-PR count, merge-readiness logic, `data-pr-*` attributes, task-row
|
||||
activation, and coarse-pointer behavior remain unchanged; all new copy is localized.
|
||||
- Focused component tests, desktop hover E2E, mobile task-row E2E, typecheck, and i18n checks pass.
|
||||
|
||||
## TDD Sequence
|
||||
|
||||
1. RED: add the summary component cases for ready, failure/conflict, draft/terminal, missing,
|
||||
unknown, long-title, and multi-PR states through the pure summary derivation; update icon tests
|
||||
and observe the new behavior expectations fail against the current pipe-delimited tooltip.
|
||||
2. RED: extend the desktop PR badge spec with the sidebar hover hierarchy/geometry assertions and
|
||||
the mobile task-status spec with row navigation; run both focused specs before production
|
||||
changes.
|
||||
3. GREEN: add `PRTaskStatusSummary`, wire both icon variants to it, add accessible trigger copy,
|
||||
and add English catalog keys. Reuse existing readiness helpers and status tones.
|
||||
4. REFACTOR: remove `getPRTooltip`, keep the presentation component focused, generate the pseudo
|
||||
locale, inspect desktop and phone rendered results, then run every exact verification command.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm install --frozen-lockfile
|
||||
cd apps && pnpm --filter @kandev/web test -- --run \
|
||||
components/github/pr-task-icon.test.ts \
|
||||
components/github/pr-task-icon-draft.test.ts \
|
||||
components/github/pr-task-icon.render.test.tsx \
|
||||
components/github/pr-task-status-summary.test.ts
|
||||
cd apps/web && pnpm e2e:run tests/pr/pr-status-badge.spec.ts \
|
||||
-- --grep "renders readable task PR summary"
|
||||
cd apps/web && pnpm e2e:run --project mobile-chrome \
|
||||
tests/task/mobile-task-status-summary.spec.ts
|
||||
cd apps/web && pnpm run typecheck
|
||||
cd apps && pnpm --filter @kandev/web run i18n:check
|
||||
cd apps && pnpm --filter @kandev/web run i18n:ratchet
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/github/pr-task-icon.tsx`
|
||||
- `apps/web/components/github/pr-task-icon-draft.test.ts`
|
||||
- `apps/web/components/github/pr-task-icon.test.ts`
|
||||
- `apps/web/components/github/pr-task-icon.render.test.tsx`
|
||||
- `apps/web/components/github/pr-task-status-summary.tsx`
|
||||
- `apps/web/components/github/pr-task-status-summary.test.ts`
|
||||
- `apps/web/src/locales/en/github.json`
|
||||
- `apps/web/src/locales/pseudo/github.json`
|
||||
- `apps/web/e2e/tests/pr/pr-status-badge.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts`
|
||||
- `docs/specs/ui/pr-task-status-summary.md`
|
||||
- `docs/specs/INDEX.md`
|
||||
- `docs/plans/pr-task-status-summary/plan.md`
|
||||
- `docs/plans/pr-task-status-summary/task-01-readable-pr-task-summary.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None. Current `TaskPR` projections and readiness/color helpers contain every required field and
|
||||
remain authoritative.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential` — summary presentation, shared icon wiring, locale keys, and focused tests describe
|
||||
one small vertical slice and overlap on the same component contract.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `docs/specs/ui/pr-task-status-summary.md`, especially status-preservation, multi-PR, fallback,
|
||||
and mobile scenarios.
|
||||
- `docs/plans/pr-task-status-summary/plan.md`.
|
||||
- Existing derivation and trigger attributes in `apps/web/components/github/pr-task-icon.tsx`.
|
||||
- Status icon/copy precedent in
|
||||
`apps/web/components/github/my-github/pr-status-badges.tsx`.
|
||||
- Tooltip portal selector guidance in `.agents/skills/e2e/SKILL.md`.
|
||||
- Content-only mobile guidance and exemplars in `.agents/skills/mobile-parity/SKILL.md`.
|
||||
- Typography and surface rules in `/home/zeval/.agents/skills/make-interfaces-feel-better/`.
|
||||
|
||||
## Risks
|
||||
|
||||
- Never infer **Ready to merge** from aggregate check counts or raw `mergeable_state`; use the
|
||||
existing strict helper result passed by `PRTaskIcon`.
|
||||
- Keep unknown provider values as data and resolve known catalog keys only during render.
|
||||
- Scope E2E assertions to the visible open Tooltip portal because Radix may mount a second
|
||||
accessibility copy.
|
||||
- Keep the compact indicator passive and preserve parent row click/keyboard behavior on every
|
||||
shared surface.
|
||||
|
||||
## Results
|
||||
|
||||
RED evidence:
|
||||
|
||||
- `pnpm --filter @kandev/web test -- --run
|
||||
components/github/pr-task-status-summary.test.ts` failed as expected: the old implementation
|
||||
returned one pipe-delimited string instead of `{ number, title, rows }`.
|
||||
- `pnpm e2e:run tests/pr/pr-status-badge.spec.ts -- --grep "renders readable task PR summary"`
|
||||
failed as expected because the old tooltip had no structured summary selector.
|
||||
|
||||
GREEN evidence:
|
||||
|
||||
- `pnpm install --frozen-lockfile` completed from `apps/` without changing the lockfile.
|
||||
- The focused Vitest command above, expanded to include `pr-task-icon-draft.test.ts`, passed 78 tests
|
||||
across 4 files. A final summary-only run passed 6 tests after adding explicit failure/conflict
|
||||
coverage.
|
||||
- The desktop E2E command passed 1 Chromium test. It proves hover and keyboard focus, readable
|
||||
labelled rows, long-title wrapping, viewport containment, and two linked PR groups.
|
||||
- The mobile E2E command passed 1 `mobile-chrome` test. `.tap()` on the task row navigated normally
|
||||
and the document remained free of horizontal overflow.
|
||||
- `pnpm run typecheck`, the task's targeted ESLint invocation, `i18n:check`, and `i18n:ratchet` all
|
||||
passed. Pseudo-locale generation produced 30 namespaces / 5,740 messages; the 918 reported
|
||||
`zh-cn` parity issues are advisory and pre-existing.
|
||||
- `CAPTURE_PR_ASSETS=1` produced
|
||||
`apps/web/.pr-assets/pr-status-badge--readable-task-pr-summary.png` for visual inspection. The
|
||||
subsequent standard E2E cleanup removed the ignored capture and every E2E run completed teardown.
|
||||
|
||||
Files changed:
|
||||
|
||||
- `apps/web/components/github/pr-task-icon.tsx`
|
||||
- `apps/web/components/github/pr-task-icon-draft.test.ts`
|
||||
- `apps/web/components/github/pr-task-icon.test.ts`
|
||||
- `apps/web/components/github/pr-task-status-summary.tsx`
|
||||
- `apps/web/components/github/pr-task-status-summary.test.ts`
|
||||
- `apps/web/src/locales/en/github.json`
|
||||
- `apps/web/src/locales/pseudo/github.json`
|
||||
- `apps/web/e2e/tests/pr/pr-status-badge.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts`
|
||||
- This spec, plan, task, and `docs/specs/INDEX.md`.
|
||||
|
||||
No blockers or residual in-scope risks remain. Existing ready-to-merge, aggregate color, task-row
|
||||
activation, and coarse-pointer detail ownership are preserved.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report implementation outcome, files changed, status semantics preserved, localization keys,
|
||||
desktop/mobile rendered evidence, exact tests and counts, blockers or risks, and synchronized
|
||||
task/plan status. Present visual changes in a concise **Before / After** table.
|
||||
@@ -154,6 +154,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
|
||||
|---|---|
|
||||
| [ci-pr-automation](ui/ci-pr-automation.md) | building |
|
||||
| [github-pr-review-actions](ui/github-pr-review-actions.md) | shipped |
|
||||
| [pr-task-status-summary](ui/pr-task-status-summary.md) | shipped |
|
||||
| [comment-markdown](ui/comment-markdown.md) | shipped |
|
||||
| [resizable-markdown-tables](ui/resizable-markdown-tables.md) | building |
|
||||
| [transcript-auto-scroll](ui/transcript-auto-scroll.md) | building |
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
status: shipped
|
||||
created: 2026-08-06
|
||||
owner: kandev
|
||||
---
|
||||
|
||||
# PR Task Status Summary
|
||||
|
||||
## Why
|
||||
|
||||
Task pull-request indicators currently flatten the PR title, review state, CI state, and
|
||||
mergeability into one pipe-delimited sentence. Long titles and several adjacent status values
|
||||
make the hover disclosure slow to scan during a brief pointer interaction.
|
||||
|
||||
## What
|
||||
|
||||
- Hovering a task's PR indicator with a fine pointer, or focusing it with the keyboard, shows a
|
||||
compact structured summary instead of a pipe-delimited sentence.
|
||||
- Each linked PR has a distinct entry with its PR number and title in a visual header. Long titles
|
||||
wrap within the summary rather than widening it beyond the viewport.
|
||||
- Review, CI, and merge or terminal state appear as separate labelled rows when their source data
|
||||
is available. Each row combines readable text with a semantic icon and color; no meaning depends
|
||||
on color or icon alone.
|
||||
- Known GitHub states use concise user-facing copy such as **Approved**, **Passed**, **In
|
||||
progress**, **Changes requested**, **Conflicts**, and **Ready to merge**. An unrecognized
|
||||
non-empty provider value remains visible instead of being dropped.
|
||||
- Ready-to-merge copy uses the existing strict `isPRReadyToMerge` rule. Draft, terminal,
|
||||
review, check, mergeability, aggregate icon color, and multi-PR attention precedence do not
|
||||
change.
|
||||
- A task with several linked PRs shows one consistently structured entry per PR, separated clearly,
|
||||
while retaining the existing aggregate icon color, PR count, and ready-to-merge attributes.
|
||||
- The disclosure uses localized copy, readable line height, restrained semantic colors, and a
|
||||
viewport-contained width. It remains informational: opening it does not navigate, mutate PR
|
||||
state, fetch new detail, or change task-row activation.
|
||||
- The shared task PR indicator uses the same summary in the desktop sidebar, Kanban cards, and rich
|
||||
task-list rows.
|
||||
- On coarse-pointer phone and tablet layouts, task rows keep their existing primary tap behavior
|
||||
and passive PR indicator. Detailed touch interaction remains available through the existing PR
|
||||
status drawer after opening the task, so this visual refinement adds no hover-only required
|
||||
action or competing compact touch target.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** an open PR with an approval, successful CI, and clean mergeability, **WHEN** the user
|
||||
hovers or focuses its task PR indicator, **THEN** the summary separates the PR number and title
|
||||
from labelled **Review — Approved**, **CI — Passed**, and **Merge — Ready to merge** rows.
|
||||
- **GIVEN** a PR with changes requested, failing CI, a merge conflict, a blocked state, or a
|
||||
behind-base state, **WHEN** its summary opens, **THEN** each available condition appears in its
|
||||
own readable row with matching semantic text and icon without changing the indicator's existing
|
||||
attention color.
|
||||
- **GIVEN** a long PR title, **WHEN** its summary opens near a viewport edge, **THEN** the title
|
||||
wraps inside the collision-aware summary and the disclosure causes no document-level horizontal
|
||||
overflow.
|
||||
- **GIVEN** a pointer-hovered PR indicator also has keyboard focus, **WHEN** the pointer leaves,
|
||||
**THEN** the summary remains visible until focus leaves or the user dismisses it with Escape.
|
||||
- **GIVEN** several PRs linked to one task, **WHEN** the task PR summary opens, **THEN** every PR is
|
||||
identifiable by number and title and its available status rows are visually grouped beneath it.
|
||||
- **GIVEN** a non-empty provider status Kandev does not recognize, **WHEN** the summary opens,
|
||||
**THEN** that value remains visible as fallback text; absent status fields do not produce empty
|
||||
rows.
|
||||
- **GIVEN** a phone task-switcher drawer, **WHEN** a linked-PR task row renders and the user taps
|
||||
the row, **THEN** the existing task navigation and PR indicator remain usable without horizontal
|
||||
overflow or a new hover-dependent interaction.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing GitHub status derivation, merge readiness, polling, API contracts, persistence, or
|
||||
task-to-PR associations.
|
||||
- Adding check-run lists, reviewer lists, comments, merge controls, or other full PR-detail content
|
||||
to the task indicator summary.
|
||||
- Changing GitLab merge-request or Azure DevOps pull-request indicators.
|
||||
- Adding a new phone/tablet task-row drawer or turning the compact PR indicator into a separate
|
||||
touch action.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
[PR task status summary plan](../../plans/pr-task-status-summary/plan.md)
|
||||
Reference in New Issue
Block a user