feat: hydrate persisted PR details on task disclosure (#3055)

This commit is contained in:
Carlos Florêncio
2026-08-26 15:54:59 +00:00
committed by GitHub
parent 49dd944bd3
commit a6896bca9a
68 changed files with 1764 additions and 130 deletions
@@ -323,9 +323,9 @@ func (h *Hub) BroadcastToWorkspace(workspaceID string, msg *ws.Message) {
// BroadcastToWorkspaceOrDrop is the fail-closed sibling of
// BroadcastToWorkspace: when per-user auth is enforced it DROPS the message
// unless the workspace is non-empty AND resolves to a known owner — it never
// falls back to a global broadcast. Every Office notification is
// workspace-scoped, so an office payload whose workspace could not be
// resolved must never cross user boundaries. With auth disabled (single
// falls back to a global broadcast. Every notification using this path is
// workspace-scoped, so a payload whose workspace could not be resolved must
// never cross user boundaries. With auth disabled (single
// user) it degrades to a plain global broadcast, preserving today's
// behavior.
func (h *Hub) BroadcastToWorkspaceOrDrop(workspaceID string, msg *ws.Message) {
@@ -334,7 +334,7 @@ func (h *Hub) BroadcastToWorkspaceOrDrop(workspaceID string, msg *ws.Message) {
return
}
if workspaceID == "" {
return // fail closed: no unattributed office fan-out under auth
return // fail closed: no unattributed workspace fan-out under auth
}
resolver := h.authPolicy.WorkspaceOwner
if resolver == nil {
@@ -288,7 +288,8 @@ func (b *TaskEventBroadcaster) routeBroadcast(
// session page after task creation.
b.hub.BroadcastToWorkspace(workspaceID, msg)
return nil
case ws.ActionGitHubTaskCIOptionsUpdated, ws.ActionGitLabTaskMRUpdated, ws.ActionGitLabTaskMRAutomationUpdated:
case ws.ActionGitHubTaskPRUpdated, ws.ActionGitHubTaskPRDeleted,
ws.ActionGitHubTaskCIOptionsUpdated, ws.ActionGitLabTaskMRUpdated, ws.ActionGitLabTaskMRAutomationUpdated:
// These payloads carry per-task PR/MR automation and lifecycle state. Fail closed
// (drop, don't fall back to a global broadcast) when workspace
// resolution came back empty and auth is enforced — an unattributed
@@ -270,6 +270,64 @@ func TestTaskEventBroadcaster_DropsUnscopedGitHubCIOptionsWhenAuthIsEnforced(t *
}
}
func TestTaskEventBroadcaster_ScopesTypedGitHubTaskPRUpdateToOwningWorkspace(t *testing.T) {
hub := newTestHub(t)
hub.setAuthPolicy(AuthPolicy{
Enforced: func() bool { return true },
WorkspaceOwner: func(_ context.Context, workspaceID string) (string, error) {
if workspaceID == "workspace-a" {
return "user-a", nil
}
return "", errors.New("unknown workspace")
},
})
clientA := newTestClient("client-a")
clientA.identity = authn.Identity{UserID: "user-a", Role: authn.RoleMember}
clientB := newTestClient("client-b")
clientB.identity = authn.Identity{UserID: "user-b", Role: authn.RoleMember}
registerTestClient(hub, clientA)
registerTestClient(hub, clientB)
payload := &githubsvc.TaskPR{
ID: "association-a",
WorkspaceID: "workspace-a",
TaskID: "task-a",
}
broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()}
msg := bus.NewEvent(events.GitHubTaskPRUpdated, "test", payload)
require.NoError(t, broadcaster.broadcastEvent(context.Background(), msg, ws.ActionGitHubTaskPRUpdated))
if !clientReceived(clientA) {
t.Fatal("owner of workspace-a did not receive task PR update")
}
if clientReceived(clientB) {
t.Fatal("task PR update crossed the workspace boundary")
}
select {
case leaked := <-hub.broadcast:
t.Fatalf("task PR update used the global broadcast path: %s", leaked.Action)
default:
}
}
func TestTaskEventBroadcaster_DropsUnscopedGitHubTaskPRUpdateWhenAuthIsEnforced(t *testing.T) {
hub := newTestHub(t)
hub.setAuthPolicy(AuthPolicy{Enforced: func() bool { return true }})
broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()}
payload := &githubsvc.TaskPR{TaskID: "task-without-workspace"}
require.NoError(t, broadcaster.broadcastEvent(
context.Background(),
bus.NewEvent(events.GitHubTaskPRUpdated, "test", payload),
ws.ActionGitHubTaskPRUpdated,
))
select {
case leaked := <-hub.broadcast:
t.Fatalf("unscoped task PR update was globally broadcast: %s", leaked.Action)
default:
}
}
func TestTaskEventBroadcaster_ScopesGitHubCIOptionsToOwningWorkspace(t *testing.T) {
hub := newTestHub(t)
hub.setAuthPolicy(AuthPolicy{
+5
View File
@@ -576,6 +576,11 @@ type TaskPR struct {
AutoMergeObservedAt *time.Time `json:"auto_merge_observed_at" db:"auto_merge_observed_at"`
}
// GetWorkspaceID lets workspace-scoped notification handlers route the typed
// in-process task PR event to the owning workspace instead of broadcasting it
// as an unattributed instance-wide update.
func (p TaskPR) GetWorkspaceID() string { return p.WorkspaceID }
// TaskCIOptions stores task-level PR automation preferences.
//
// The five automation switches below (AutoFixEnabled, AutoMergeEnabled,
@@ -27,6 +27,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "id",
workspace_id: "ws1",
task_id: "task-1",
owner: "o",
repo: "r",
@@ -22,6 +22,7 @@ function renderWithStore(ui: ReactNode) {
function makeTaskPR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "pr-1",
workspace_id: "workspace-1",
task_id: "task-1",
owner: "o",
repo: "r",
@@ -103,6 +103,7 @@ function makeOptions(overrides: Partial<TaskCIAutomationOptions> = {}): TaskCIAu
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "id",
workspace_id: "workspace-1",
task_id: "task-1",
owner: "o",
repo: "r",
@@ -41,6 +41,7 @@ const UPDATING_LABEL = "Updating…";
function makePR(): TaskPR {
return {
id: "id",
workspace_id: "workspace-1",
task_id: "task-1",
owner: "acme",
repo: "app",
@@ -5,6 +5,7 @@ import type { CheckRun, TaskPR } from "@/lib/types/github";
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "id",
workspace_id: "workspace-1",
task_id: "task",
owner: "o",
repo: "r",
@@ -99,6 +99,7 @@ function PRCIPopoverHeader({
<ChangeRequestPopoverHeader
number={pr.pr_number}
title={title}
author={pr.author_login}
url={pr.pr_url}
onOpenReview={onOpenDetailPanel}
openDetailsLabel={t("github:openDetails", { title: `#${pr.pr_number} ${title}` })}
@@ -50,6 +50,7 @@ afterEach(() => {
function makeTaskPR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "id",
workspace_id: "workspace-1",
task_id: "task",
owner: "o",
repo: "r",
@@ -24,6 +24,7 @@ const reviewMocks = vi.hoisted(() => ({ requestReviewers: vi.fn(), submitReview:
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";
const WORKSPACE_ID = "workspace-1";
vi.mock("@/hooks/domains/github/use-pr-feedback", () => ({
usePRFeedback: () => ({
@@ -72,6 +73,7 @@ afterEach(async () => {
function makeTaskPR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "id",
workspace_id: WORKSPACE_ID,
task_id: "task",
owner: "o",
repo: "r",
@@ -141,7 +143,7 @@ function ReviewRequestHarness({
onReady,
requestedReviewers = [],
reviews = [],
workspaceId = "workspace-1",
workspaceId = WORKSPACE_ID,
}: {
onReady: (reviewRequest: ReturnType<typeof usePRScopedReviewRequest>) => void;
requestedReviewers?: { login: string; type: "user" }[];
@@ -171,7 +173,7 @@ function dismissedReview(id = 1): PRReview {
}
const TEST_INITIAL_STATE = {
workspaces: { activeId: "workspace-1" },
workspaces: { activeId: WORKSPACE_ID },
} as unknown as Partial<AppState>;
function prDetailTree(taskPR = makeTaskPR()) {
@@ -305,7 +307,7 @@ describe("PRDetailContent deferred re-request", () => {
"r",
1,
["octocat"],
"workspace-1",
WORKSPACE_ID,
);
expect(screen.queryByTestId(RE_REQUEST_BUTTON)).toBeNull();
});
@@ -172,7 +172,10 @@ describe("PRDetailPanelComponent — task switch on the singleton legacy panel",
await act(async () => {
getStoreApi().setState((prev) => ({
...prev,
taskPRs: { byTaskId: { ...prev.taskPRs.byTaskId, "task-2": [pr2] } },
taskPRs: {
...prev.taskPRs,
byTaskId: { ...prev.taskPRs.byTaskId, "task-2": [pr2] },
},
tasks: { ...prev.tasks, activeTaskId: "task-2", activeSessionId: "session-2" },
}));
});
@@ -13,6 +13,7 @@ const QUEUE_STATUS_TEST_ID = "pr-merge-queue-status";
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "id",
workspace_id: "workspace-1",
task_id: "task-1",
owner: "acme",
repo: "demo",
@@ -15,6 +15,7 @@ const CONFLICT_BANNER_TEST_ID = "pr-conflict-banner";
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "id",
workspace_id: "workspace-1",
task_id: "task-1",
owner: "o",
repo: "r",
@@ -64,6 +64,7 @@ function renderWithStore(initialState: Partial<AppState>, ui: ReactNode) {
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "pr-id",
workspace_id: "workspace-1",
task_id: "task-1",
owner: "acme",
repo: "demo",
@@ -3,6 +3,7 @@ import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github";
export function makeTestPR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "pr-id",
workspace_id: "workspace-1",
task_id: "task-1",
owner: "acme",
repo: "demo",
@@ -259,6 +259,7 @@ describe("PRStatusChip mobile branch", () => {
expect(document.querySelector("[data-testid='pr-topbar-popover-inner']")).not.toBeNull();
expect(document.querySelector("[data-testid='pr-status-chip-drawer-close']")).not.toBeNull();
expect(screen.getByTestId("pr-popover-title").textContent).toBe("#42 Test PR");
expect(screen.getByTestId("pr-popover-author").textContent).toBe("by alice");
expect(drawer?.textContent).not.toContain("Open PR details");
});
@@ -76,6 +76,7 @@ function renderWithStore(initialState: Partial<AppState>, ui: ReactNode) {
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "pr-id",
workspace_id: "workspace-1",
task_id: "task-1",
owner: "acme",
repo: "demo",
@@ -6,6 +6,7 @@ import { derivePRTaskStatusSummary } from "./pr-task-status-summary";
function draftPR(): TaskPR {
return {
id: "id",
workspace_id: "workspace-1",
task_id: "task",
owner: "o",
repo: "r",
@@ -1,12 +1,21 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import { cleanup, render } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { StateProvider } from "@/components/state-provider";
import { TaskContributionIcons } from "@/components/task/task-contribution-icons";
import { PRTaskIcon } from "./pr-task-icon";
import type { AppState } from "@/lib/state/store";
import type { TaskPR } from "@/lib/types/github";
const listTaskPRsMock = vi.hoisted(() => vi.fn());
const TASK_ID = "task-1";
const WORKSPACE_ID = "workspace-1";
vi.mock("@/lib/api/domains/github-api", () => ({
listTaskPRs: listTaskPRsMock,
}));
function renderWithStore(initialState: Partial<AppState> | undefined, ui: ReactNode) {
return render(
<StateProvider initialState={initialState}>
@@ -18,7 +27,8 @@ function renderWithStore(initialState: Partial<AppState> | undefined, ui: ReactN
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "id",
task_id: "task-1",
workspace_id: WORKSPACE_ID,
task_id: TASK_ID,
owner: "o",
repo: "r",
pr_number: 1,
@@ -48,18 +58,22 @@ function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
};
}
beforeEach(() => {
listTaskPRsMock.mockReset().mockReturnValue(new Promise(() => {}));
});
afterEach(() => cleanup());
describe("PRTaskIcon corrupted store entry", () => {
// Regression: an upstream payload (partial hydration, WS reorder, etc.) once
// landed in taskPRs.byTaskId["task-1"] as a non-array truthy value. The
// landed in taskPRs.byTaskId[TASK_ID] as a non-array truthy value. The
// length-based guards then fell through into MultiPRIcon, where for-of
// threw `prs is not iterable`. PRTaskIcon must bail rather than crash.
it("renders nothing when byTaskId[taskId] is a non-array object", () => {
const { container } = renderWithStore(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ taskPRs: { byTaskId: { "task-1": {} as any } } } as Partial<AppState>,
<PRTaskIcon taskId="task-1" />,
{ taskPRs: { byTaskId: { [TASK_ID]: {} as any } } } as Partial<AppState>,
<PRTaskIcon taskId={TASK_ID} />,
);
expect(container.firstChild).toBeNull();
});
@@ -71,10 +85,10 @@ describe("PRTaskIcon corrupted store entry", () => {
it("renders an icon when byTaskId[taskId] is a valid array of one PR", () => {
const { container } = renderWithStore(
{ taskPRs: { byTaskId: { "task-1": [makePR()] } } },
<PRTaskIcon taskId="task-1" />,
{ taskPRs: { byTaskId: { [TASK_ID]: [makePR()] } } },
<PRTaskIcon taskId={TASK_ID} />,
);
expect(container.querySelector('[data-testid="pr-task-icon-task-1"]')).not.toBeNull();
expect(container.querySelector(`[data-testid="pr-task-icon-${TASK_ID}"]`)).not.toBeNull();
});
it("renders the multi-PR icon when byTaskId[taskId] has multiple PRs", () => {
@@ -82,17 +96,84 @@ describe("PRTaskIcon corrupted store entry", () => {
{
taskPRs: {
byTaskId: {
"task-1": [
[TASK_ID]: [
makePR({ id: "a", repository_id: "repo-a", pr_number: 1 }),
makePR({ id: "b", repository_id: "repo-b", pr_number: 2 }),
],
},
},
},
<PRTaskIcon taskId="task-1" />,
<PRTaskIcon taskId={TASK_ID} />,
);
const icon = container.querySelector('[data-testid="pr-task-icon-task-1"]');
const icon = container.querySelector(`[data-testid="pr-task-icon-${TASK_ID}"]`);
expect(icon).not.toBeNull();
expect(icon?.getAttribute("data-pr-count")).toBe("2");
expect(icon?.getAttribute("data-pr-ready-to-merge")).toBe("false");
});
it("opens a loading disclosure for a compact PR projection", () => {
renderWithStore(
{ workspaces: { items: [], activeId: WORKSPACE_ID } },
<TaskContributionIcons
taskId={TASK_ID}
prInfo={{ number: 7, state: "open", aggregateState: "pending" }}
/>,
);
const icon = screen.getByTestId(`pr-task-icon-${TASK_ID}`);
expect(icon.getAttribute("role")).toBe("img");
fireEvent.pointerEnter(icon, { pointerType: "mouse" });
expect(screen.getAllByTestId("pr-task-tooltip-loading").length).toBeGreaterThan(0);
});
it("opens a loading disclosure when a compact PR projection receives keyboard focus", () => {
renderWithStore(
{ workspaces: { items: [], activeId: WORKSPACE_ID } },
<TaskContributionIcons
taskId={TASK_ID}
prInfo={{ number: 7, state: "open", aggregateState: "pending" }}
/>,
);
const icon = screen.getByTestId(`pr-task-icon-${TASK_ID}`);
const matches = vi.spyOn(icon, "matches").mockReturnValue(true);
fireEvent.focus(icon);
matches.mockRestore();
expect(screen.getAllByTestId("pr-task-tooltip-loading").length).toBeGreaterThan(0);
});
it("keeps keyboard focus and the open tooltip when hydration completes", async () => {
let resolveResponse!: (value: { task_prs: Record<string, TaskPR[]> }) => void;
const response = new Promise<{ task_prs: Record<string, TaskPR[]> }>((resolve) => {
resolveResponse = resolve;
});
listTaskPRsMock.mockReturnValue(response);
renderWithStore(
{ workspaces: { items: [], activeId: WORKSPACE_ID } },
<TaskContributionIcons
taskId={TASK_ID}
prInfo={{ number: 7, state: "open", aggregateState: "pending" }}
/>,
);
const icon = screen.getByTestId(`pr-task-icon-${TASK_ID}`);
const matches = vi.spyOn(icon, "matches").mockReturnValue(true);
icon.focus();
matches.mockRestore();
await waitFor(() =>
expect(screen.getAllByTestId("pr-task-tooltip-loading").length).toBeGreaterThan(0),
);
await act(async () => {
resolveResponse({ task_prs: { [TASK_ID]: [makePR()] } });
await response;
});
await waitFor(() =>
expect(screen.getAllByTestId("pr-task-status-summary").length).toBeGreaterThan(0),
);
expect(document.activeElement).toBe(screen.getByTestId(`pr-task-icon-${TASK_ID}`));
});
});
@@ -13,8 +13,8 @@ import {
} from "./pr-task-icon";
import type { TaskPR } from "@/lib/types/github";
const SKY_400 = "text-sky-400";
const RED_500 = "text-red-500";
const SKY_400 = "text-sky-400",
RED_500 = "text-red-500";
const YELLOW_500 = "text-yellow-500";
const EMERALD_400 = "text-emerald-400";
const GREEN_500 = "text-green-500";
@@ -51,7 +51,7 @@ function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
closed_at: null,
last_synced_at: null,
updated_at: "",
...overrides,
...{ workspace_id: "workspace-1", ...overrides },
};
}
+122 -51
View File
@@ -11,6 +11,11 @@ import {
CHANGE_REQUEST_STATUS_RANK,
getChangeRequestAggregateStatusColor,
} from "@/components/integrations/change-request-task-status-color";
import {
getTaskPRsForCurrentWorkspace,
useTaskPRTooltipHydration,
type TaskPRTooltipHydrationStatus,
} from "@/hooks/domains/github/use-task-pr-tooltip-hydration";
import type { TaskPR } from "@/lib/types/github";
import { derivePRTaskStatusSummary, PRTaskStatusSummary } from "./pr-task-status-summary";
@@ -233,40 +238,114 @@ export function pickDefaultPR(prs: TaskPR[]): TaskPR | null {
return best;
}
export function PRTaskIcon({ taskId }: { taskId: string }) {
const prs = useAppStore((state) => state.taskPRs.byTaskId[taskId] ?? null);
export type TaskPRInfo = {
number: number;
state: string;
aggregateState?: string;
};
export function PRTaskIcon({ taskId, prInfo }: { taskId: string; prInfo?: TaskPRInfo }) {
const prs = useAppStore((state) => getTaskPRsForCurrentWorkspace(state, taskId));
const hydration = useTaskPRTooltipHydration(taskId);
const fullPRs = Array.isArray(prs) && prs.length > 0 ? prs : [];
// Defensive: an upstream payload may briefly seed byTaskId[taskId] with a
// non-array value (e.g. an empty object from a partial hydration). Bail
// instead of falling through into MultiPRIcon, where for-of throws.
if (!Array.isArray(prs) || prs.length === 0) return null;
if (prs.length === 1) return <SinglePRIcon taskId={taskId} pr={prs[0]} />;
return <MultiPRIcon taskId={taskId} prs={prs} />;
// instead of falling through into a full-data summary, where for-of throws.
if (fullPRs.length === 0 && !prInfo) return null;
return <PRTaskIconView taskId={taskId} prInfo={prInfo} prs={fullPRs} hydration={hydration} />;
}
function SinglePRIcon({ taskId, pr }: { taskId: string; pr: TaskPR }) {
type TaskPRIconPresentation = {
hasFullData: boolean;
singlePR: TaskPR | null;
readyToMerge: boolean;
allReadyToMerge: boolean;
summaries: ReturnType<typeof derivePRTaskStatusSummary>[];
iconColor: string;
displayState: string | undefined;
displayCount: number;
};
function getTaskPRIconPresentation(prs: TaskPR[], prInfo?: TaskPRInfo): TaskPRIconPresentation {
const hasFullData = prs.length > 0;
const singlePR = prs.length === 1 ? prs[0] : null;
const readyToMerge = singlePR ? isPRReadyToMerge(singlePR) : false;
return {
hasFullData,
singlePR,
readyToMerge,
allReadyToMerge: areAllOpenPRsReadyToMerge(prs),
summaries: prs.map((pr) => derivePRTaskStatusSummary(pr, isPRReadyToMerge(pr))),
iconColor: getTaskPRIconColor(prs, prInfo),
displayState: singlePR?.state ?? (hasFullData ? undefined : prInfo?.state),
displayCount: hasFullData ? prs.length : 1,
};
}
function PRTaskIconView({
taskId,
prInfo,
prs,
hydration,
}: {
taskId: string;
prInfo?: TaskPRInfo;
prs: TaskPR[];
hydration: ReturnType<typeof useTaskPRTooltipHydration>;
}) {
const { t } = useTranslation();
const tooltip = useChangeRequestTaskTooltipState();
const readyToMerge = isPRReadyToMerge(pr);
const summary = derivePRTaskStatusSummary(pr, readyToMerge);
const hasFullData = prs.length > 0;
const tooltip = useChangeRequestTaskTooltipState(
!hasFullData && prInfo
? () => {
void hydration.hydrate();
}
: undefined,
);
const {
singlePR,
readyToMerge,
allReadyToMerge,
summaries,
iconColor,
displayState,
displayCount,
} = getTaskPRIconPresentation(prs, prInfo);
const ariaLabel =
prs.length > 1
? t("github:pullRequestStatuses", { count: prs.length })
: t("github:pullRequestStatus", { number: singlePR?.pr_number ?? prInfo?.number });
return (
<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={readyToMerge ? "true" : "false"}
data-pr-state={displayState}
data-pr-count={displayCount}
data-pr-ready-to-merge={
hasFullData ? String(prs.length === 1 ? readyToMerge : allReadyToMerge) : undefined
}
role="img"
tabIndex={0}
aria-label={t("github:pullRequestStatus", { number: pr.pr_number })}
aria-label={ariaLabel}
onPointerEnter={tooltip.onPointerEnter}
onPointerLeave={tooltip.onPointerLeave}
onFocus={tooltip.onFocus}
onBlur={tooltip.onBlur}
className={cn("inline-flex items-center shrink-0", getPRStatusColor(pr))}
className={cn(
"inline-flex items-center shrink-0",
prs.length > 1 && "gap-0.5",
iconColor,
)}
>
<IconGitPullRequest aria-hidden="true" className="h-3.5 w-3.5" />
{prs.length > 1 ? (
<span className="text-[9px] font-semibold leading-none tabular-nums">{prs.length}</span>
) : null}
</span>
</TooltipTrigger>
<TooltipContent
@@ -274,45 +353,37 @@ function SinglePRIcon({ taskId, pr }: { taskId: string; pr: TaskPR }) {
onEscapeKeyDown={tooltip.onEscapeKeyDown}
className="w-80 max-w-[calc(100vw-1rem)] p-3"
>
<PRTaskStatusSummary summaries={[summary]} />
{hasFullData ? (
<PRTaskStatusSummary summaries={summaries} />
) : (
<CompactPRTooltipContent status={hydration.status} />
)}
</TooltipContent>
</Tooltip>
);
}
function MultiPRIcon({ taskId, prs }: { taskId: string; prs: TaskPR[] }) {
const { t } = useTranslation();
const tooltip = useChangeRequestTaskTooltipState();
const aggregateColor = aggregatePRStatusColor(prs);
const allReady = areAllOpenPRsReadyToMerge(prs);
const summaries = prs.map((pr) => derivePRTaskStatusSummary(pr, isPRReadyToMerge(pr)));
return (
<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 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
sideOffset={6}
onEscapeKeyDown={tooltip.onEscapeKeyDown}
className="w-80 max-w-[calc(100vw-1rem)] p-3"
>
<PRTaskStatusSummary summaries={summaries} />
</TooltipContent>
</Tooltip>
);
function getTaskPRIconColor(prs: TaskPR[], prInfo?: TaskPRInfo): string {
if (prs.length === 1) return getPRStatusColor(prs[0]);
if (prs.length > 1) return aggregatePRStatusColor(prs);
return getPRAggregateStatusColor(prInfo?.aggregateState ?? prInfo?.state);
}
function CompactPRTooltipContent({ status }: { status: TaskPRTooltipHydrationStatus }) {
const { t } = useTranslation();
if (status === "loading" || status === "idle") {
return (
<span data-testid="pr-task-tooltip-loading" className="text-sm text-muted-foreground">
{t("github:taskPrDetailsLoading")}
</span>
);
}
if (status === "unavailable") {
return (
<span data-testid="pr-task-tooltip-unavailable" className="text-sm text-muted-foreground">
{t("github:taskPrDetailsUnavailable")}
</span>
);
}
return null;
}
@@ -5,6 +5,7 @@ import { derivePRTaskStatusSummary } from "./pr-task-status-summary";
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "pr-id",
workspace_id: "workspace-1",
task_id: "task-id",
owner: "kdlbs",
repo: "kandev",
@@ -48,6 +49,7 @@ describe("structured PR task status summary", () => {
expect(summary).toEqual({
number: 2966,
title: "Make pull request status easier to scan",
author: "octocat",
rows: [
{ kind: "review", status: "approved", tone: "success" },
{ kind: "ci", status: "passed", tone: "success" },
@@ -133,6 +135,12 @@ describe("structured PR task status summary", () => {
expect(summary.rows).toEqual([{ kind: "merge", status: "draft", tone: "muted" }]);
});
it("omits an empty author identity", () => {
const summary = derivePRTaskStatusSummary(makePR({ author_login: " " }), false);
expect(summary).not.toHaveProperty("author");
});
});
describe("queued PR task status summaries", () => {
@@ -100,7 +100,13 @@ export function derivePRTaskStatusSummary(
deriveMergeRow(pr, readyToMerge),
].filter((row): row is PRTaskSummaryRow => row !== null);
return { number: pr.pr_number, title: pr.pr_title, rows };
const author = pr.author_login.trim();
return {
number: pr.pr_number,
title: pr.pr_title,
...(author ? { author } : {}),
rows,
};
}
export function PRTaskStatusSummary({ summaries }: { summaries: PRTaskStatusSummaryData[] }) {
@@ -95,6 +95,7 @@ function ChangeRequestPopoverHeaderActions({
export function ChangeRequestPopoverHeader({
number,
title,
author,
url,
onOpenReview,
onUnlink,
@@ -104,6 +105,7 @@ export function ChangeRequestPopoverHeader({
}: {
number: number | string;
title: string;
author?: string;
url?: string;
onOpenReview?: () => void;
onUnlink?: (signal: AbortSignal) => void | Promise<void>;
@@ -121,6 +123,7 @@ export function ChangeRequestPopoverHeader({
[],
);
const displayTitle = `#${number} ${title || t("integrations:untitledPullRequest")}`;
const displayAuthor = author?.trim();
const unlink = async () => {
if (!onUnlink || unlinking) return;
unlinkController.current?.abort();
@@ -141,26 +144,36 @@ export function ChangeRequestPopoverHeader({
data-testid="pr-popover-header"
className="flex items-center justify-between gap-2 border-b border-border/50 pb-2"
>
{onOpenReview ? (
<button
type="button"
data-testid="pr-popover-title"
className="min-w-0 cursor-pointer truncate text-left text-sm font-medium hover:underline"
title={displayTitle}
aria-label={openDetailsLabel ?? t("integrations:openDetails", { title: displayTitle })}
onClick={onOpenReview}
>
{displayTitle}
</button>
) : (
<span
data-testid="pr-popover-title"
className="min-w-0 truncate text-sm font-medium"
title={displayTitle}
>
{displayTitle}
</span>
)}
<div className="min-w-0 flex-1">
{onOpenReview ? (
<button
type="button"
data-testid="pr-popover-title"
className="block min-w-0 max-w-full cursor-pointer truncate text-left text-sm font-medium hover:underline"
title={displayTitle}
aria-label={openDetailsLabel ?? t("integrations:openDetails", { title: displayTitle })}
onClick={onOpenReview}
>
{displayTitle}
</button>
) : (
<span
data-testid="pr-popover-title"
className="block min-w-0 max-w-full truncate text-sm font-medium"
title={displayTitle}
>
{displayTitle}
</span>
)}
{displayAuthor ? (
<div
data-testid="pr-popover-author"
className="mt-0.5 truncate text-[11px] text-muted-foreground"
>
{t("task:byAuthor", { author: displayAuthor })}
</div>
) : null}
</div>
<ChangeRequestPopoverHeaderActions
number={number}
url={url}
@@ -48,4 +48,21 @@ describe("ChangeRequestTaskStatusSummary layout", () => {
expect(label.className).toContain("min-w-0");
expect(label.className).toContain("[overflow-wrap:anywhere]");
});
it("renders the author below the title and omits a blank identity", () => {
const { rerender } = render(
<ChangeRequestTaskStatusSummary
summaries={[{ number: 8, title: "Author summary", author: "octocat", rows: [] }]}
/>,
);
expect(screen.getByTestId("pr-task-status-title-author").textContent).toBe("by octocat");
rerender(
<ChangeRequestTaskStatusSummary
summaries={[{ number: 8, title: "Author summary", author: " ", rows: [] }]}
/>,
);
expect(screen.queryByTestId("pr-task-status-title-author")).toBeNull();
});
});
@@ -64,6 +64,7 @@ export type ChangeRequestTaskSummaryRow = {
export type ChangeRequestTaskStatusSummaryData = {
number: number | string;
title: string;
author?: string;
rows: ChangeRequestTaskSummaryRow[];
};
@@ -253,6 +254,14 @@ export function ChangeRequestTaskStatusSummary({
>
{summary.title}
</div>
{summary.author?.trim() ? (
<div
data-testid={`${presentation.titleTestId}-author`}
className="mt-0.5 text-[11px] leading-snug text-muted-foreground"
>
{t("task:byAuthor", { author: summary.author.trim() })}
</div>
) : null}
</div>
</div>
{summary.rows.length > 0 && (
@@ -92,6 +92,7 @@ const TEST_TIMESTAMP = "2026-07-31T00:00:00Z";
function makePR(id: string, number: number, repo = "kandev"): TaskPR {
return {
id,
workspace_id: "workspace-1",
task_id: "task-1",
owner: "acme",
repo,
@@ -3,7 +3,6 @@
import { IconGitPullRequest } from "@tabler/icons-react";
import { getPRAggregateStatusColor, PRTaskIcon } from "@/components/github/pr-task-icon";
import { MRTaskIcon } from "@/components/gitlab/mr-task-icon";
import { useAppStore } from "@/components/state-provider";
import { cn } from "@/lib/utils";
/** Shows PR icon from store (real data) or from prInfo prop (prototype/mock). */
@@ -14,8 +13,7 @@ function TaskPRIcon({
taskId?: string;
prInfo?: { number: number; state: string; aggregateState?: string };
}) {
const hasStorePR = useAppStore((s) => !!taskId && (s.taskPRs.byTaskId[taskId]?.length ?? 0) > 0);
if (hasStorePR) return <PRTaskIcon taskId={taskId!} />;
if (taskId) return <PRTaskIcon taskId={taskId} prInfo={prInfo} />;
if (!prInfo) return null;
const color = getPRAggregateStatusColor(prInfo.aggregateState ?? prInfo.state);
return (
@@ -31,9 +29,8 @@ function TaskPRIcon({
/**
* PR badge (store or prInfo fallback) and MR badge as siblings, PR first. The
* MR branch needs its own taskId guard: hasStorePR inside TaskPRIcon is false
* both when taskId is absent and when it's present with no PRs, so it can't
* gate the MRTaskIcon call.
* MR branch needs its own taskId guard because MRTaskIcon reads task-scoped
* store data and cannot render without a task identity.
*/
export function TaskContributionIcons({
taskId,
@@ -47,6 +47,7 @@ function renderTaskItem(
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "id",
workspace_id: "workspace-1",
task_id: "t1",
owner: "o",
repo: "r",
@@ -647,6 +648,7 @@ describe("TaskItem contribution badges", () => {
const prIcon = screen.getByTestId(PR_ICON_TEST_ID);
const mrIcon = screen.getByTestId(MR_ICON_TEST_ID);
expect(prIcon.getAttribute("role")).toBe("img");
expect(prIcon.compareDocumentPosition(mrIcon) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
@@ -6,6 +6,7 @@ import type { TaskMR } from "@/lib/types/gitlab";
function makePR(overrides: Partial<TaskPR>): TaskPR {
return {
id: "pr-1",
workspace_id: "workspace-1",
task_id: "task-1",
owner: "kdlbs",
repo: "kandev",
@@ -35,6 +35,7 @@ import { TaskPRShortcut } from "./task-pr-shortcut";
function makePR(id: string, number: number): TaskPR {
return {
id,
workspace_id: "workspace-1",
task_id: "task-1",
owner: "acme",
repo: "kandev",
@@ -25,6 +25,7 @@ function makeTask(overrides: Partial<KanbanState["tasks"][number]>): KanbanState
function makePR(overrides: Pick<TaskPR, "id" | "task_id">): TaskPR {
return {
workspace_id: "workspace-1",
owner: "o",
repo: "r",
pr_number: 1,
+6 -1
View File
@@ -1783,7 +1783,12 @@ export class ApiClient {
checks_passing?: number;
unresolved_review_threads?: number;
}): Promise<void> {
await this.request("POST", "/api/v1/github/mock/task-prs", data);
const workspaceId = data.workspace_id?.trim() || (await this.activeWorkspaceId());
await this.request(
"POST",
"/api/v1/github/mock/task-prs",
workspaceId ? { ...data, workspace_id: workspaceId } : data,
);
}
async associateGitHubTaskPR(data: {
@@ -0,0 +1,109 @@
import { expect } from "@playwright/test";
import { test } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
const NAVIGATION_TITLE = "PR summary navigation";
const TARGET_TITLE = "Inactive PR summary target";
test.describe("inactive task PR summary hydration", () => {
test("loads full PR details on hover and reuses the settled task cache", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
test.setTimeout(90_000);
const stepOptions = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
};
const navigationTask = await apiClient.seedTask(
seedData.workspaceId,
NAVIGATION_TITLE,
stepOptions,
);
const targetTask = await apiClient.seedTask(seedData.workspaceId, TARGET_TITLE, {
...stepOptions,
state: "IN_PROGRESS",
});
await apiClient.seedTaskSession(navigationTask.task_id, {
state: "WAITING_FOR_INPUT",
agentProfileId: seedData.agentProfileId,
});
await apiClient.seedTaskSession(targetTask.task_id, {
state: "WAITING_FOR_INPUT",
agentProfileId: seedData.agentProfileId,
});
await apiClient.mockGitHubAssociateTaskPR({
workspace_id: seedData.workspaceId,
task_id: targetTask.task_id,
owner: "kandev-e2e",
repo: "sidebar-summary-fixtures",
pr_number: 44,
pr_url: "https://github.test/kandev-e2e/sidebar-summary-fixtures/pull/44",
pr_title: "Hydrate the inactive task summary",
head_branch: "feature/sidebar-summary",
base_branch: "main",
author_login: "persisted-author",
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "clean",
required_reviews: 1,
review_count: 1,
checks_total: 1,
checks_passing: 1,
});
let taskDetailRequests = 0;
testPage.on("request", (request) => {
if (
request.url().includes("/api/v1/github/task-prs?") &&
request.url().includes(`task_ids=${targetTask.task_id}`)
) {
taskDetailRequests += 1;
}
});
await testPage.goto(`/t/${navigationTask.task_id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const targetRow = session.sidebarTaskItem(TARGET_TITLE);
await expect(targetRow).toBeVisible({ timeout: 15_000 });
const icon = targetRow.getByTestId(`pr-task-icon-${targetTask.task_id}`);
await expect(icon).toBeVisible({ timeout: 15_000 });
await expect(icon).toHaveAttribute("data-pr-state", "Open");
await expect(icon).toHaveAttribute("data-pr-count", "1");
await expect(icon).not.toHaveAttribute("data-pr-ready-to-merge");
expect(taskDetailRequests).toBe(0);
await icon.hover();
await expect.poll(() => taskDetailRequests).toBe(1);
const summary = testPage
.locator(
'[data-slot="tooltip-content"]:not([data-state="closed"]) > [data-testid="pr-task-status-summary"]',
)
.first();
await expect(summary).toBeVisible();
await expect(summary.getByTestId("pr-task-status-number")).toHaveText("PR #44");
await expect(summary.getByTestId("pr-task-status-title")).toHaveText(
"Hydrate the inactive task summary",
);
await expect(summary.getByTestId("pr-task-status-title-author")).toHaveText(
"by persisted-author",
);
await prCapture.screenshot("desktop-pr-sidebar-hover-hydration", {
caption: "Desktop task sidebar PR summary with persisted author identity",
});
await testPage.mouse.move(0, 0);
await expect(summary).toBeHidden();
await icon.hover();
await expect(summary).toBeVisible();
await expect.poll(() => taskDetailRequests).toBe(1);
});
});
@@ -9,6 +9,7 @@ test.describe("Mobile task status summary", () => {
testPage,
apiClient,
seedData,
prCapture,
}) => {
test.setTimeout(90_000);
@@ -116,5 +117,14 @@ test.describe("Mobile task status summary", () => {
// 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}$`));
await session.waitForLoad();
await expect(session.prStatusChip()).toBeVisible({ timeout: 15_000 });
await session.tapPRStatusChip();
const author = session.prStatusChipDrawer().getByTestId("pr-popover-author");
await expect(author).toBeInViewport({ ratio: 1 });
await expect(author).toHaveText("by e2e");
await prCapture.screenshot("mobile-task-status-summary-author", {
caption: "Mobile PR status drawer with the linked author identity",
});
});
});
@@ -21,6 +21,7 @@ function deferred<T>() {
function makePR(): TaskPR {
return {
id: "id",
workspace_id: "workspace-1",
task_id: "task-1",
owner: "acme",
repo: "app",
@@ -16,6 +16,7 @@ afterEach(() => cleanup());
function makeTaskPR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "pr",
workspace_id: "workspace-1",
task_id: "task-1",
owner: "kdlbs",
repo: "kandev",
@@ -24,6 +24,7 @@ const SIBLING_BRANCH = "feat/second";
const NOW = "2026-07-22T00:00:00Z";
const selectedPR: TaskPR = {
id: "pr-2",
workspace_id: WORKSPACE_ID,
task_id: TASK_ID,
repository_id: REPOSITORY_ID,
owner: "acme",
@@ -37,6 +37,7 @@ afterEach(() => {
function taskPR(): TaskPR {
return {
id: "pr-1",
workspace_id: "ws-1",
task_id: "task-1",
owner: "acme",
repo: "site",
@@ -26,6 +26,7 @@ function wrapper({ children }: { children: ReactNode }) {
function makeTaskPR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "pr-1",
workspace_id: "workspace-1",
task_id: "task-1",
owner: "acme",
repo: "widget",
@@ -0,0 +1,303 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
import type { AppState } from "@/lib/state/store";
import type { TaskPR } from "@/lib/types/github";
const listTaskPRsMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/domains/github-api", () => ({
listTaskPRs: listTaskPRsMock,
}));
import { useTaskPRTooltipHydration } from "./use-task-pr-tooltip-hydration";
const WORKSPACE_A = "workspace-a";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "pr-1",
workspace_id: WORKSPACE_A,
task_id: "task-1",
repository_id: "repository-1",
owner: "acme",
repo: "demo",
pr_number: 7,
pr_url: "https://github.com/acme/demo/pull/7",
pr_title: "Hydrate this pull request",
head_branch: "feature/hydration",
base_branch: "main",
author_login: "octocat",
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "clean",
review_count: 1,
pending_review_count: 0,
required_reviews: 1,
comment_count: 0,
unresolved_review_threads: 0,
checks_total: 1,
checks_passing: 1,
additions: 1,
deletions: 0,
created_at: "",
merged_at: null,
closed_at: null,
last_synced_at: null,
updated_at: "",
...overrides,
};
}
function createStateWrapper(initialState: Partial<AppState> = {}) {
return function StateTestWrapper({ children }: { children: ReactNode }) {
return (
<StateProvider
initialState={{
workspaces: { items: [], activeId: WORKSPACE_A },
...initialState,
}}
>
{children}
</StateProvider>
);
};
}
function useHydrationWithStore(taskId: string) {
return {
hydration: useTaskPRTooltipHydration(taskId),
store: useAppStoreApi(),
};
}
beforeEach(() => {
listTaskPRsMock.mockReset();
});
afterEach(() => {
cleanup();
});
describe("useTaskPRTooltipHydration request coordination", () => {
it("deduplicates concurrent requests for the same task and store", async () => {
const response = deferred<{ task_prs: Record<string, TaskPR[]> }>();
listTaskPRsMock.mockReturnValue(response.promise);
const { result } = renderHook(
() => {
const first = useTaskPRTooltipHydration("task-1");
const second = useTaskPRTooltipHydration("task-1");
return { first, second, store: useAppStoreApi() };
},
{ wrapper: createStateWrapper() },
);
expect(listTaskPRsMock).not.toHaveBeenCalled();
let firstRequest!: Promise<unknown>;
await act(async () => {
firstRequest = result.current.first.hydrate();
void result.current.second.hydrate();
});
expect(listTaskPRsMock).toHaveBeenCalledTimes(1);
expect(listTaskPRsMock).toHaveBeenCalledWith(["task-1"], { cache: "no-store" });
const pr = makePR();
await act(async () => {
response.resolve({ task_prs: { "task-1": [pr] } });
await firstRequest;
});
await waitFor(() =>
expect(result.current.store.getState().taskPRs.byTaskId["task-1"]).toEqual([pr]),
);
});
it("uses the settled store entry without making another request", async () => {
const pr = makePR();
listTaskPRsMock.mockResolvedValue({ task_prs: { "task-1": [pr] } });
const { result } = renderHook(() => useHydrationWithStore("task-1"), {
wrapper: createStateWrapper(),
});
await act(async () => {
await result.current.hydration.hydrate();
await result.current.hydration.hydrate();
});
expect(listTaskPRsMock).toHaveBeenCalledTimes(1);
expect(result.current.store.getState().taskPRs.byTaskId["task-1"]).toEqual([pr]);
});
});
describe("useTaskPRTooltipHydration context guards", () => {
it("keeps a WebSocket entry when the older HTTP response resolves later", async () => {
const response = deferred<{ task_prs: Record<string, TaskPR[]> }>();
listTaskPRsMock.mockReturnValue(response.promise);
const { result } = renderHook(() => useHydrationWithStore("task-1"), {
wrapper: createStateWrapper(),
});
const websocketPR = makePR({ pr_title: "Fresh WebSocket title", author_login: "fresh-user" });
const httpPR = makePR({ pr_title: "Stale HTTP title", author_login: "stale-user" });
let request!: Promise<unknown>;
await act(async () => {
request = result.current.hydration.hydrate();
result.current.store.getState().setTaskPR("task-1", websocketPR);
});
await act(async () => {
response.resolve({ task_prs: { "task-1": [httpPR] } });
await request;
});
expect(result.current.store.getState().taskPRs.byTaskId["task-1"]).toEqual([websocketPR]);
});
it("does not apply a response after the active workspace changes", async () => {
const response = deferred<{ task_prs: Record<string, TaskPR[]> }>();
listTaskPRsMock.mockReturnValue(response.promise);
const { result } = renderHook(() => useHydrationWithStore("task-1"), {
wrapper: createStateWrapper(),
});
const request = result.current.hydration.hydrate();
await act(async () => {
result.current.store.getState().setActiveWorkspace("workspace-b");
response.resolve({ task_prs: { "task-1": [makePR()] } });
await request;
});
expect(result.current.store.getState().taskPRs.byTaskId["task-1"]).toBeUndefined();
});
it("does not apply a response after the task id changes", async () => {
const response = deferred<{ task_prs: Record<string, TaskPR[]> }>();
listTaskPRsMock.mockReturnValue(response.promise);
const { result, rerender } = renderHook(
({ taskId }: { taskId: string }) => useHydrationWithStore(taskId),
{
initialProps: { taskId: "task-1" },
wrapper: createStateWrapper(),
},
);
const request = result.current.hydration.hydrate();
rerender({ taskId: "task-2" });
await act(async () => {
response.resolve({ task_prs: { "task-1": [makePR()] } });
await request;
});
expect(result.current.store.getState().taskPRs.byTaskId["task-1"]).toBeUndefined();
});
it("does not reuse task PRs left behind after a workspace switch", async () => {
listTaskPRsMock.mockResolvedValue({ task_prs: { "task-1": [] } });
const { result } = renderHook(() => useHydrationWithStore("task-1"), {
wrapper: createStateWrapper({ taskPRs: { byTaskId: { "task-1": [makePR()] } } }),
});
act(() => {
result.current.store.getState().resetKanbanWorkspaceContext();
result.current.store.getState().setActiveWorkspace("workspace-b");
});
await act(async () => {
await result.current.hydration.hydrate();
});
expect(listTaskPRsMock).toHaveBeenCalledWith(["task-1"], { cache: "no-store" });
});
it("does not reuse task PRs after the workspace context generation changes", async () => {
listTaskPRsMock.mockResolvedValue({ task_prs: { "task-1": [] } });
const { result } = renderHook(() => useHydrationWithStore("task-1"), {
wrapper: createStateWrapper({ taskPRs: { byTaskId: { "task-1": [makePR()] } } }),
});
act(() => {
result.current.store.getState().resetKanbanWorkspaceContext();
});
await act(async () => {
await result.current.hydration.hydrate();
});
expect(listTaskPRsMock).toHaveBeenCalledWith(["task-1"], { cache: "no-store" });
});
});
describe("useTaskPRTooltipHydration retry states", () => {
it("shows unavailable for an empty response and retries the next disclosure", async () => {
const pr = makePR();
listTaskPRsMock
.mockResolvedValueOnce({ task_prs: { "task-1": [] } })
.mockResolvedValueOnce({ task_prs: { "task-1": [pr] } });
const { result } = renderHook(() => useHydrationWithStore("task-1"), {
wrapper: createStateWrapper(),
});
await act(async () => {
await result.current.hydration.hydrate();
});
expect(result.current.hydration.status).toBe("unavailable");
await act(async () => {
await result.current.hydration.hydrate();
});
expect(listTaskPRsMock).toHaveBeenCalledTimes(2);
expect(result.current.store.getState().taskPRs.byTaskId["task-1"]).toEqual([pr]);
});
it("shows unavailable after a failed response and retries the next disclosure", async () => {
const pr = makePR();
listTaskPRsMock
.mockRejectedValueOnce(new Error("request failed"))
.mockResolvedValueOnce({ task_prs: { "task-1": [pr] } });
const { result } = renderHook(() => useHydrationWithStore("task-1"), {
wrapper: createStateWrapper(),
});
await act(async () => {
await result.current.hydration.hydrate();
});
expect(result.current.hydration.status).toBe("unavailable");
await act(async () => {
await result.current.hydration.hydrate();
});
expect(listTaskPRsMock).toHaveBeenCalledTimes(2);
expect(result.current.store.getState().taskPRs.byTaskId["task-1"]).toEqual([pr]);
});
it("does not resurrect an association deleted while the request was pending", async () => {
const response = deferred<{ task_prs: Record<string, TaskPR[]> }>();
listTaskPRsMock.mockReturnValue(response.promise);
const { result } = renderHook(() => useHydrationWithStore("task-1"), {
wrapper: createStateWrapper(),
});
const request = result.current.hydration.hydrate();
act(() => {
result.current.store.getState().removeTaskPR("task-1", "pr-1");
});
await act(async () => {
response.resolve({ task_prs: { "task-1": [makePR()] } });
await request;
});
expect(result.current.store.getState().taskPRs.byTaskId["task-1"]).toBeUndefined();
});
});
@@ -0,0 +1,166 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { StoreApi } from "zustand";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { listTaskPRs } from "@/lib/api/domains/github-api";
import type { AppState } from "@/lib/state/store";
import { isCurrentWorkspaceContext } from "@/lib/state/workspace-context";
import type { TaskPRScope } from "@/lib/state/slices/github/types";
import type { TaskPR } from "@/lib/types/github";
export type TaskPRTooltipHydrationStatus = "idle" | "loading" | "unavailable";
type TaskPRRequestRegistry = Map<string, Promise<TaskPR[]>>;
const requestRegistryByStore = new WeakMap<StoreApi<AppState>, TaskPRRequestRegistry>();
function hasTaskPRs(value: unknown): value is TaskPR[] {
return Array.isArray(value) && value.length > 0;
}
function requestKey(
workspaceId: string,
workspaceContextGeneration: number,
taskId: string,
): string {
// The NUL delimiter is only an in-memory scope-key boundary; API IDs are
// opaque stable values and are not expected to contain it.
return `${workspaceId}\u0000${workspaceContextGeneration}\u0000${taskId}`;
}
function getRequestRegistry(store: StoreApi<AppState>): TaskPRRequestRegistry {
const existing = requestRegistryByStore.get(store);
if (existing) return existing;
const registry: TaskPRRequestRegistry = new Map();
requestRegistryByStore.set(store, registry);
return registry;
}
function isSamePR(left: TaskPR, right: TaskPR): boolean {
if (left.id && right.id && left.id === right.id) return true;
return (
(left.repository_id ?? "") === (right.repository_id ?? "") && left.pr_number === right.pr_number
);
}
/** Merge only missing identities so a newer WebSocket row remains authoritative. */
function getTaskPRsForScope(state: AppState, taskId: string, scope: TaskPRScope): TaskPR[] | null {
if (
state.taskPRs.workspaceId === scope.workspaceId &&
state.taskPRs.workspaceContextGeneration === scope.workspaceContextGeneration &&
hasTaskPRs(state.taskPRs.byTaskId[taskId])
) {
return state.taskPRs.byTaskId[taskId];
}
return null;
}
export function getTaskPRsForCurrentWorkspace(state: AppState, taskId: string): TaskPR[] | null {
return getTaskPRsForScope(state, taskId, {
workspaceId: state.workspaces.activeId,
workspaceContextGeneration: state.workspaceContextGeneration,
});
}
function mergeMissingTaskPRs(
store: StoreApi<AppState>,
taskId: string,
prs: TaskPR[],
scope: TaskPRScope,
): void {
for (const pr of prs) {
const taskPRs = store.getState().taskPRs;
if (taskPRs.deletedAssociationIdsByTaskId?.[taskId]?.[pr.id]) continue;
const current = taskPRs.byTaskId[taskId];
const currentPRs = Array.isArray(current) ? current : [];
if (currentPRs.some((candidate) => isSamePR(candidate, pr))) continue;
store.getState().setTaskPR(taskId, pr, scope);
}
}
function requestTaskPRs(
store: StoreApi<AppState>,
scope: TaskPRScope & { workspaceId: string },
taskId: string,
): Promise<TaskPR[]> {
const registry = getRequestRegistry(store);
const key = requestKey(scope.workspaceId, scope.workspaceContextGeneration, taskId);
const existing = registry.get(key);
if (existing) return existing;
const request = listTaskPRs([taskId], { cache: "no-store" }).then(
(response) => response.task_prs?.[taskId] ?? [],
);
registry.set(key, request);
request.then(
() => {
if (registry.get(key) === request) registry.delete(key);
},
() => {
if (registry.get(key) === request) registry.delete(key);
},
);
return request;
}
export function useTaskPRTooltipHydration(taskId: string): {
status: TaskPRTooltipHydrationStatus;
hydrate: () => Promise<TaskPR[]>;
} {
const store = useAppStoreApi();
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const workspaceContextGeneration = useAppStore((state) => state.workspaceContextGeneration);
const [status, setStatus] = useState<TaskPRTooltipHydrationStatus>("idle");
const scopeKey = `${workspaceId ?? ""}\u0000${workspaceContextGeneration}\u0000${taskId}`;
const scopeRef = useRef({ key: scopeKey, generation: 0 });
if (scopeRef.current.key !== scopeKey) {
scopeRef.current = {
key: scopeKey,
generation: scopeRef.current.generation + 1,
};
}
useEffect(() => {
setStatus("idle");
}, [scopeKey]);
const hydrate = useCallback(() => {
const generation = scopeRef.current.generation;
const scope = workspaceId ? { workspaceId, workspaceContextGeneration } : null;
const isCurrentScope = () =>
scopeRef.current.generation === generation &&
scope !== null &&
isCurrentWorkspaceContext(
store.getState(),
scope.workspaceId,
scope.workspaceContextGeneration,
);
if (!scope) {
if (scopeRef.current.generation === generation) setStatus("unavailable");
return Promise.resolve([]);
}
const current = store.getState();
const cached = getTaskPRsForScope(current, taskId, scope);
if (cached) {
if (isCurrentScope()) setStatus("idle");
return Promise.resolve(cached);
}
if (isCurrentScope()) setStatus("loading");
return requestTaskPRs(store, scope, taskId).then(
(prs) => {
if (isCurrentScope()) {
mergeMissingTaskPRs(store, taskId, prs, scope);
setStatus(getTaskPRsForScope(store.getState(), taskId, scope) ? "idle" : "unavailable");
}
return prs;
},
() => {
if (isCurrentScope()) setStatus("unavailable");
return [];
},
);
}, [store, taskId, workspaceContextGeneration, workspaceId]);
return { status, hydrate };
}
+17 -8
View File
@@ -4,11 +4,13 @@ import { useEffect, useCallback, useRef, useState } from "react";
import { deleteTaskPR, listWorkspaceTaskPRs } from "@/lib/api/domains/github-api";
import { getWebSocketClient } from "@/lib/ws/connection";
import { useAppStore } from "@/components/state-provider";
import { getTaskPRsForCurrentWorkspace } from "./use-task-pr-tooltip-hydration";
import type { TaskPR } from "@/lib/types/github";
/** Fetch all PR associations for a workspace. */
export function useWorkspacePRs(workspaceId: string | null) {
const setTaskPRs = useAppStore((state) => state.setTaskPRs);
const workspaceContextGeneration = useAppStore((state) => state.workspaceContextGeneration);
const fetchedRef = useRef<string | null>(null);
const requestRef = useRef(0);
@@ -25,14 +27,17 @@ export function useWorkspacePRs(workspaceId: string | null) {
listWorkspaceTaskPRs(workspaceId, { cache: "no-store" })
.then((response) => {
if (requestRef.current !== requestId) return;
setTaskPRs(response?.task_prs ?? {});
setTaskPRs(response?.task_prs ?? {}, {
workspaceId,
workspaceContextGeneration,
});
})
.catch(() => {
if (requestRef.current === requestId) {
fetchedRef.current = null; // allow retry on failure
}
});
}, [workspaceId, setTaskPRs]);
}, [workspaceContextGeneration, setTaskPRs, workspaceId]);
}
const SYNC_RETRY_DELAY = 5_000; // 5 seconds
@@ -73,11 +78,14 @@ type SyncResponse = { prs?: TaskPR[]; permanent?: boolean } | TaskPR | null | un
/** Fetch a single task's PR associations, with on-demand sync via WS. */
export function useTaskPR(taskId: string | null) {
const prs = useAppStore((state) => (taskId ? (state.taskPRs.byTaskId[taskId] ?? null) : null));
const prs = useAppStore((state) =>
taskId ? getTaskPRsForCurrentWorkspace(state, taskId) : null,
);
const pr = getPrimaryTaskPR(prs ?? undefined);
const setTaskPR = useAppStore((state) => state.setTaskPR);
const removeTaskPR = useAppStore((state) => state.removeTaskPR);
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const workspaceContextGeneration = useAppStore((state) => state.workspaceContextGeneration);
const connectionStatus = useAppStore((state) => state.connection.status);
const retryRef = useRef(0);
const permanentRef = useRef(false);
@@ -133,8 +141,9 @@ export function useTaskPR(taskId: string | null) {
const list = normalizeSyncResponse(result);
setLoadedTaskId(requestedTaskId);
if (list.length === 0) return;
const scope = { workspaceId, workspaceContextGeneration };
for (const pr of list) {
if (pr.task_id) setTaskPR(requestedTaskId, pr);
if (pr.task_id) setTaskPR(requestedTaskId, pr, scope);
}
retryRef.current = 0;
})
@@ -146,15 +155,15 @@ export function useTaskPR(taskId: string | null) {
setLoadedTaskId(requestedTaskId);
}
});
}, [taskId, setTaskPR]);
}, [taskId, setTaskPR, workspaceContextGeneration, workspaceId]);
const unlink = useCallback(
async (associationId: string) => {
if (!taskId || !workspaceId) throw new Error("No active workspace is selected.");
await deleteTaskPR(associationId, workspaceId);
removeTaskPR(taskId, associationId);
removeTaskPR(taskId, associationId, { workspaceId, workspaceContextGeneration });
},
[removeTaskPR, taskId, workspaceId],
[removeTaskPR, taskId, workspaceContextGeneration, workspaceId],
);
// Reset retry/permanent state when taskId changes. Bumping requestRef
@@ -240,6 +249,6 @@ export function useActiveTaskPR(): TaskPR | null {
return useAppStore((s) => {
const taskId = s.tasks.activeTaskId;
if (!taskId) return null;
return getPrimaryTaskPR(s.taskPRs.byTaskId[taskId]);
return getPrimaryTaskPR(getTaskPRsForCurrentWorkspace(s, taskId) ?? undefined);
});
}
@@ -12,6 +12,7 @@ const CURRENT_BRANCH = "feature/current";
function taskPR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "pr-2",
workspace_id: "workspace-1",
task_id: "task-1",
repository_id: "repo-frontend",
owner: "acme",
@@ -66,6 +66,7 @@ const baseStatus: Omit<GitStatusEntry, "repository_name" | "head_commit" | "remo
function taskPR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "pr-current",
workspace_id: "workspace-1",
task_id: "task-1",
repository_id: "repo-frontend",
owner: "acme",
@@ -66,6 +66,7 @@ function repository(overrides: Partial<Repository> = {}): Repository {
function githubPR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "pr-link-1",
workspace_id: WORKSPACE_ID,
task_id: TASK_ID,
repository_id: GITHUB_REPOSITORY_ID,
owner: "acme",
+17 -1
View File
@@ -264,6 +264,23 @@ function mergeGitHubState(initialState: HydrationState) {
...defaultState.githubAppRegistrations,
...initialState.githubAppRegistrations,
},
taskPRs: mergeTaskPRState(initialState),
};
}
function mergeTaskPRState(initialState: HydrationState) {
const incoming = initialState.taskPRs;
return {
...defaultState.taskPRs,
...incoming,
byTaskId: { ...defaultState.taskPRs.byTaskId, ...incoming?.byTaskId },
deletedAssociationIdsByTaskId: {
...defaultState.taskPRs.deletedAssociationIdsByTaskId,
...incoming?.deletedAssociationIdsByTaskId,
},
workspaceId: incoming?.workspaceId ?? initialState.workspaces?.activeId ?? null,
workspaceContextGeneration:
incoming?.workspaceContextGeneration ?? initialState.workspaceContextGeneration ?? 0,
};
}
@@ -369,7 +386,6 @@ export function mergeInitialState(initialState?: HydrationState): DefaultState {
...initialState.embeddedVscodeSupport,
},
...mergeGitHubState(initialState),
taskPRs: { ...defaultState.taskPRs, ...initialState.taskPRs },
taskIssues: { ...defaultState.taskIssues, ...initialState.taskIssues },
pendingPrUrlByTaskId: {
...defaultState.pendingPrUrlByTaskId,
+9 -1
View File
@@ -612,7 +612,15 @@ function hydrateGitHub(draft: Draft<AppState>, state: HydrationState): void {
if (state.githubAppRegistrations) {
mergeWithLoading(draft.githubAppRegistrations, state.githubAppRegistrations);
}
if (state.taskPRs) deepMerge(draft.taskPRs, state.taskPRs);
if (state.taskPRs) {
deepMerge(draft.taskPRs, state.taskPRs);
// Older boot payloads contain only byTaskId. Stamp those records with the
// workspace context that was hydrated alongside them so a later workspace
// switch cannot treat them as current data.
draft.taskPRs.workspaceId = state.taskPRs.workspaceId ?? draft.workspaces.activeId;
draft.taskPRs.workspaceContextGeneration =
state.taskPRs.workspaceContextGeneration ?? draft.workspaceContextGeneration;
}
if (state.azureDevOpsTaskPullRequests) {
deepMerge(draft.azureDevOpsTaskPullRequests, state.azureDevOpsTaskPullRequests);
}
@@ -13,6 +13,7 @@ import type {
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "id",
workspace_id: "workspace-1",
task_id: "task-1",
owner: "o",
repo: "r",
@@ -1,10 +1,15 @@
import type { StateCreator } from "zustand";
import type { GitHubSlice, GitHubSliceState } from "./types";
import type { GitHubSlice, GitHubSliceState, TaskPRScope } from "./types";
export const defaultGitHubState: GitHubSliceState = {
githubStatus: { byWorkspaceId: {} },
githubAppRegistrations: { byWorkspaceId: {} },
taskPRs: { byTaskId: {} },
taskPRs: {
byTaskId: {},
workspaceId: null,
workspaceContextGeneration: 0,
deletedAssociationIdsByTaskId: {},
},
taskIssues: { workspaceId: null, byTaskId: {} },
pendingPrUrlByTaskId: { byTaskId: {} },
prWatches: { items: [], loaded: false, loading: false },
@@ -120,6 +125,30 @@ function clearPendingForTaskPR(
}
}
function applyTaskPRScope(draft: GitHubSlice, scope?: TaskPRScope): void {
if (!scope) return;
const changed =
draft.taskPRs.workspaceId !== scope.workspaceId ||
draft.taskPRs.workspaceContextGeneration !== scope.workspaceContextGeneration;
if (changed) {
draft.taskPRs.byTaskId = {};
draft.taskPRs.deletedAssociationIdsByTaskId = {};
}
draft.taskPRs.workspaceId = scope.workspaceId;
draft.taskPRs.workspaceContextGeneration = scope.workspaceContextGeneration;
}
function clearTaskPRDeletionTombstone(draft: GitHubSlice, taskId: string, associationId: string) {
const deletedByTask = draft.taskPRs.deletedAssociationIdsByTaskId;
if (!deletedByTask) return;
const deleted = deletedByTask[taskId];
if (!deleted) return;
delete deleted[associationId];
if (Object.keys(deleted).length === 0) {
delete deletedByTask[taskId];
}
}
function createTaskPRActions(
set: ImmerSet,
): Pick<
@@ -132,12 +161,19 @@ function createTaskPRActions(
| "upsertTaskIssue"
> {
return {
setTaskPRs: (prs) =>
setTaskPRs: (prs, scope) =>
set((draft) => {
applyTaskPRScope(draft, scope);
draft.taskPRs.byTaskId = prs;
draft.taskPRs.deletedAssociationIdsByTaskId = {};
}),
removeTaskPR: (taskId, associationId) =>
removeTaskPR: (taskId, associationId, scope) =>
set((draft) => {
applyTaskPRScope(draft, scope);
const deletedByTask = (draft.taskPRs.deletedAssociationIdsByTaskId ??= {});
const deleted = deletedByTask[taskId] ?? {};
deleted[associationId] = true;
deletedByTask[taskId] = deleted;
const current = draft.taskPRs.byTaskId[taskId];
if (!Array.isArray(current)) return;
const remaining = current.filter((pr) => pr.id !== associationId);
@@ -155,8 +191,10 @@ function createTaskPRActions(
draft.taskIssues.workspaceId = workspaceId;
draft.taskIssues.byTaskId[issue.task_id] = issue;
}),
setTaskPR: (taskId, pr) =>
setTaskPR: (taskId, pr, scope) =>
set((draft) => {
applyTaskPRScope(draft, scope);
clearTaskPRDeletionTombstone(draft, taskId, pr.id);
// Upsert by (repository_id, pr_number) so multi-branch tasks can
// hold N PRs on the same repo as siblings. Keying on
// repository_id alone collapses every PR for that repo onto one
+13 -3
View File
@@ -33,9 +33,19 @@ export type GitHubAppRegistrationsState = {
byWorkspaceId: Record<string, GitHubAppRegistrationsEntry>;
};
export type TaskPRScope = {
workspaceId: string | null;
workspaceContextGeneration: number;
};
export type TaskPRsState = {
/** Each task may have multiple PRs (one per repository for multi-repo tasks). */
byTaskId: Record<string, TaskPR[]>;
/** Scope metadata is optional for backward-compatible boot payloads. */
workspaceId?: string | null;
workspaceContextGeneration?: number;
/** Association tombstones prevent stale HTTP responses from resurrecting a deletion. */
deletedAssociationIdsByTaskId?: Record<string, Record<string, true>>;
};
export type TaskIssuesState = {
@@ -116,11 +126,11 @@ export type GitHubSliceActions = {
) => void;
setGitHubAppRegistrationsLoading: (workspaceId: string, loading: boolean) => void;
resetGitHubAppRegistrations: (workspaceId: string) => void;
setTaskPRs: (prs: Record<string, TaskPR[]>) => void;
removeTaskPR: (taskId: string, associationId: string) => void;
setTaskPRs: (prs: Record<string, TaskPR[]>, scope?: TaskPRScope) => void;
removeTaskPR: (taskId: string, associationId: string, scope?: TaskPRScope) => void;
setTaskIssues: (workspaceId: string, issues: Record<string, TaskIssueLink>) => void;
upsertTaskIssue: (workspaceId: string, issue: TaskIssueLink) => void;
setTaskPR: (taskId: string, pr: TaskPR) => void;
setTaskPR: (taskId: string, pr: TaskPR, scope?: TaskPRScope) => void;
setPendingPrUrlForTask: (taskId: string, repoKey: string, prUrl: string) => void;
setPRWatches: (watches: PRWatch[]) => void;
setPRWatchesLoading: (loading: boolean) => void;
+1
View File
@@ -229,6 +229,7 @@ export type MergeQueueState =
export type TaskPR = {
id: string;
workspace_id: string;
task_id: string;
/** ID of the task repository this PR belongs to. Empty for legacy single-repo
* tasks persisted before multi-repo support. */
+49
View File
@@ -19,6 +19,20 @@ const DEFAULT_CI_AUTO_FIX_PROMPT = "Default prompt";
const TASK_ID = "task-1";
const INITIAL_UPDATED_AT = "2026-08-11T10:00:00Z";
const NEWER_UPDATED_AT = "2026-08-11T10:01:00Z";
const ACTIVE_WORKSPACE_ID = "workspace-b";
const FOREIGN_WORKSPACE_ID = "workspace-a";
const EXISTING_ASSOCIATION_ID = "association-b";
function seedTaskPRScope(store: ReturnType<typeof createAppStore>) {
store.getState().setActiveWorkspace(ACTIVE_WORKSPACE_ID);
store.getState().setTaskPRs(
{ [TASK_ID]: [{ id: EXISTING_ASSOCIATION_ID, task_id: TASK_ID } as never] },
{
workspaceId: ACTIVE_WORKSPACE_ID,
workspaceContextGeneration: store.getState().workspaceContextGeneration,
},
);
}
describe("registerGitHubHandlers CI options", () => {
it("does not let a delayed CI-options event replace newer state", () => {
@@ -96,6 +110,41 @@ describe("registerGitHubHandlers CI options", () => {
});
describe("registerGitHubHandlers", () => {
it("ignores a task PR update owned by another workspace", () => {
const store = createAppStore();
seedTaskPRScope(store);
const handler = registerGitHubHandlers(store)["github.task_pr.updated"]!;
handler({
payload: {
id: "association-a",
task_id: TASK_ID,
workspace_id: FOREIGN_WORKSPACE_ID,
},
} as Parameters<typeof handler>[0]);
expect(store.getState().taskPRs.byTaskId[TASK_ID]?.map((pr) => pr.id)).toEqual([
EXISTING_ASSOCIATION_ID,
]);
});
it("ignores an unattributed task PR update", () => {
const store = createAppStore();
seedTaskPRScope(store);
const handler = registerGitHubHandlers(store)["github.task_pr.updated"]!;
handler({
payload: {
id: "association-without-workspace",
task_id: TASK_ID,
},
} as Parameters<typeof handler>[0]);
expect(store.getState().taskPRs.byTaskId[TASK_ID]?.map((pr) => pr.id)).toEqual([
EXISTING_ASSOCIATION_ID,
]);
});
it("removes the detached PR association without touching sibling PRs", () => {
const store = createAppStore();
const first = {
+16 -4
View File
@@ -12,14 +12,26 @@ export function registerGitHubHandlers(store: StoreApi<AppState>): WsHandlers {
return {
"github.task_pr.updated": (message) => {
const pr = message.payload as TaskPR;
if (pr.task_id) {
store.getState().setTaskPR(pr.task_id, pr);
}
if (!pr.task_id || !pr.workspace_id) return;
const state = store.getState();
if (!state.workspaces.activeId || state.workspaces.activeId !== pr.workspace_id) return;
store.getState().setTaskPR(pr.task_id, pr, {
workspaceId: pr.workspace_id,
workspaceContextGeneration: state.workspaceContextGeneration,
});
},
"github.task_pr.deleted": (message) => {
const deleted = message.payload as TaskPRDeletedEvent;
if (deleted.task_id && deleted.association_id) {
store.getState().removeTaskPR(deleted.task_id, deleted.association_id);
const state = store.getState();
if (state.workspaces.activeId && state.workspaces.activeId !== deleted.workspace_id) return;
const scope = state.workspaces.activeId
? {
workspaceId: state.workspaces.activeId,
workspaceContextGeneration: state.workspaceContextGeneration,
}
: undefined;
store.getState().removeTaskPR(deleted.task_id, deleted.association_id, scope);
}
},
"github.task_ci_options.updated": (message) => {
+2
View File
@@ -497,6 +497,8 @@
"taskAutoFixPrompt": "Task auto-fix prompt",
"taskGitAccess": "Task Git access",
"taskLabelled": "Task: {{title}}",
"taskPrDetailsLoading": "Loading pull request details...",
"taskPrDetailsUnavailable": "Pull request details unavailable",
"taskPrompt": "Task Prompt",
"tasks": "Tasks",
"tasksCreatedByThisWatcherLand": "Tasks created by this watcher land in the selected workspace.",
+2
View File
@@ -497,6 +497,8 @@
"taskAutoFixPrompt": "Ţàśķ àũţō-ƒĩx ƥŕōḿƥţ",
"taskGitAccess": "Ţàśķ Ĝĩţ àććēśś",
"taskLabelled": "Ţàśķ: {{title}}",
"taskPrDetailsLoading": "Ĺōàďĩńĝ ƥũĺĺ ŕēqũēśţ ďēţàĩĺś...",
"taskPrDetailsUnavailable": "Ƥũĺĺ ŕēqũēśţ ďēţàĩĺś ũńàvàĩĺàƀĺē",
"taskPrompt": "Ţàśķ Ƥŕōḿƥţ",
"tasks": "Ţàśķś",
"tasksCreatedByThisWatcherLand": "Ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ ĺàńď ĩń ţĥē śēĺēćţēď ŵōŕķśƥàćē.",
+2
View File
@@ -478,6 +478,8 @@
"taskAutoFixPrompt": "Prompt de correção automática da tarefa",
"taskGitAccess": "Acesso Git das tarefas",
"taskLabelled": "Tarefa: {{title}}",
"taskPrDetailsLoading": "A carregar os detalhes do pull request...",
"taskPrDetailsUnavailable": "Detalhes do pull request indisponíveis",
"taskPrompt": "Prompt da tarefa",
"tasks": "Tarefas",
"tasksCreatedByThisWatcherLand": "As tarefas criadas por esta vigilância vão para a área de trabalho selecionada.",
+2
View File
@@ -497,6 +497,8 @@
"taskAutoFixPrompt": "任务自动修复提示词",
"taskGitAccess": "任务 Git 访问",
"taskLabelled": "任务:{{title}}",
"taskPrDetailsLoading": "正在加载拉取请求详情...",
"taskPrDetailsUnavailable": "拉取请求详情不可用",
"taskPrompt": "任务提示词",
"tasks": "任务",
"tasksCreatedByThisWatcherLand": "此监控创建的任务会进入所选工作区。",
+2
View File
@@ -497,6 +497,8 @@
"taskAutoFixPrompt": "任務自動修復提示詞",
"taskGitAccess": "任務 Git 存取",
"taskLabelled": "任務:{{title}}",
"taskPrDetailsLoading": "正在載入拉取請求詳情...",
"taskPrDetailsUnavailable": "拉取請求詳情不可用",
"taskPrompt": "任務提示詞",
"tasks": "任務",
"tasksCreatedByThisWatcherLand": "此監控建立的任務會進入所選工作區。",
+2
View File
@@ -497,6 +497,8 @@
"taskAutoFixPrompt": "任務自動修復提示詞",
"taskGitAccess": "任務 Git 存取",
"taskLabelled": "任務:{{title}}",
"taskPrDetailsLoading": "正在載入提取要求詳細資料...",
"taskPrDetailsUnavailable": "提取要求詳細資料不可用",
"taskPrompt": "任務提示詞",
"tasks": "任務",
"tasksCreatedByThisWatcherLand": "此監控建立的任務會進入所選工作區。",
@@ -0,0 +1,180 @@
---
created: 2026-08-26
status: complete
requirements:
- REQ-UI-PR-TASK-STATUS-SUMMARY-001
system_design:
- ../../specs/ui/system-design/pr-task-status-summary.md
legacy_specs: []
---
# Implementation Plan: PR Task Status Hover Hydration
## Overview
Make every GitHub task PR indicator disclose details consistently. First add a
task-scoped hydration path for compact indicators. Then add the author identity
to the shared summary and the existing mobile drawer.
This order makes the data available before the presentation and end-to-end
work depends on it.
## Scope
### In scope
- Open a tooltip for compact GitHub PR indicators on mouse hover and keyboard
focus.
- Load persisted full PR records for only the disclosed task.
- Deduplicate concurrent loads, reuse store data, guard HTTP and WebSocket
races, and permit retry after an error.
- Preserve workspace ownership for live TaskPR updates. The browser must reject
missing or foreign workspace IDs, and typed backend events must route by their
owning workspace without falling back to a global broadcast.
- Show the GitHub author login in the task summary.
- Show the same author login in the existing coarse-pointer PR-status drawer.
- Add focused component, desktop Playwright, and mobile Playwright coverage.
### Out of scope
- Upstream GitHub refreshes from the task-row indicator.
- Workspace-wide PR loading for the sidebar.
- Changes to task-summary persistence or projection fields.
- New task-row touch targets, drawers, navigation, or provider associations.
- Author support for GitLab or registered provider summaries.
## Technical approach
### Task-scoped disclosure hydration
Add a hook under `hooks/domains/github` that uses `listTaskPRs([taskId])` when
`taskPRs.byTaskId[taskId]` is empty for the active workspace context. Scope the
in-flight registry to the Zustand store and key it by workspace, workspace
context generation, and task. Preserve deletion tombstones so a late response
cannot restore an association removed while it was pending.
Update `PRTaskIcon` and `TaskContributionIcons` so compact and full data use one
tooltip trigger. Compact content shows localized loading or unavailable text.
Successful data updates replace the content without unmounting the trigger or
closing the tooltip.
Before response application, compare the active workspace and current store
records. Preserve matching records that arrived from WebSocket events. Add only
missing PR identities from the HTTP result.
### Workspace-scoped live PR updates
Treat `TaskPR.workspace_id` as the authoritative owner of a live update. The
frontend handler applies an update only when that ID is present and matches the
active workspace, preserving the current scoped cache for missing or foreign
payloads. The backend exposes `TaskPR.WorkspaceID` to the notification
broadcaster and routes typed PR updates and detachments through the fail-closed
workspace path when authentication is enforced, so an unattributed event is
dropped instead of broadcast globally.
### Author presentation
Add optional author data to `ChangeRequestTaskStatusSummaryData`. Derive the
value from `TaskPR.author_login` and show localized `task:byAuthor` copy below
the title.
Extend `ChangeRequestPopoverHeader` with an optional author value. Pass the
GitHub author from `PRCIPopover` so desktop status content and the existing
mobile drawer use the same text.
### Localization
Add localized loading and unavailable copy to the GitHub namespace in English,
Portuguese, Simplified Chinese, Hong Kong Chinese, and Taiwan Chinese. Reuse
the existing localized `task:byAuthor` key for author presentation.
## Tests
| Acceptance criteria | Evidence |
| --- | --- |
| `AC-UI-PR-TASK-STATUS-SUMMARY-001.13` to `.16` | A new hydration-hook test covers task scope, one in-flight request, cache reuse, workspace and task-context changes, deletion during an HTTP request, empty/error retry, and HTTP/WS ordering. Render tests cover mouse and keyboard disclosure states, including focus continuity. |
| `AC-UI-PR-TASK-STATUS-SUMMARY-001.17` | Summary derivation and shared render tests cover a present and missing author login. |
| Existing `.1` to `.12` | Existing PR icon and shared summary suites remain green. |
| `AC-UI-PR-TASK-STATUS-SUMMARY-001.18` | Existing status-chip component tests and mobile Playwright coverage prove drawer author visibility and unchanged task-row navigation. |
| `AC-UI-PR-TASK-STATUS-SUMMARY-001.19` | `github.test.ts` proves missing and foreign live updates leave the active scoped cache unchanged; `task_notifications_test.go` proves typed events reach only the owning workspace and unattributed events are dropped when auth is enforced; the singleton panel test preserves taskPR scope metadata during task switching. |
## E2E tests
- `apps/web/e2e/tests/pr/pr-sidebar-hover-hydration.spec.ts` uses the `chromium`
project. It opens an unrelated task, hovers an inactive task PR indicator,
observes the task-scoped request, and checks the author and structured rows.
- `apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts` uses the
`mobile-chrome` project. It taps the row, opens the current PR-status drawer,
checks the author, and checks for horizontal overflow.
## Mobile design contract
- **Desktop outcome:** Hover or keyboard focus opens and hydrates the PR task
summary.
- **Mobile entry:** The task row remains the entry point. The user opens the
task and then uses the existing PR-status chip.
- **Exemplars:** `session-task-switcher-sheet.tsx` owns task navigation.
`pr-status-chip.tsx` owns the coarse-pointer drawer.
- **Hierarchy:** The task row stays primary. The PR author is secondary content
below the title in the existing drawer.
- **Presentation:** Reuse the inset PR-status drawer. Do not add another drawer
or a compact icon hit target.
- **Scroll and geometry:** Keep the drawer's existing internal scroll owner,
safe-area behavior, and viewport containment.
- **Shared state:** Desktop and mobile read the same `TaskPR.author_login`.
- **Proof:** The mobile Playwright scenario checks navigation, drawer content,
and horizontal containment.
## Work orders
- [x] [Task 01: Hydrate PR details on disclosure](task-01-hydrate-pr-details-on-disclosure.md)
- [x] [Task 02: Show PR author identity](task-02-show-pr-author-identity.md)
Task 02 depends on Task 01. The work orders are sequential and do not authorize
subagents.
## Verification results
- Focused web unit suite: 163 tests passed across 10 files after the review
fixup, including the workspace-context generation regression test.
- Web typecheck passed.
- Web i18n check and ratchet passed.
- Full web lint passed.
- Desktop Playwright hydration scenario passed, including task-scoped request
deduplication, structured details, author identity, and cache reuse.
- Mobile Chrome Playwright scenario passed, including task navigation, the
existing PR drawer, author identity, and horizontal containment.
- Web E2E build passed.
- Fresh desktop and mobile captures were generated, inspected, compressed,
and validated through `apps/web/.pr-assets/manifest.json`.
- The repository-wide E2E sleep-policy lint remains baseline-red with 188
unrelated errors. Both changed E2E files pass the same lint directly.
- PR review fixup coverage also verifies task-id and workspace-context races,
deletion tombstones, and keyboard focus continuity during hydration.
- Workspace-routing review fixup on 2026-08-26 added the `workspace_id` frontend
contract, missing/foreign event guards, typed backend workspace extraction, and
fail-closed notification tests. The focused frontend suite passed 95 tests
across 6 files and the backend gateway/GitHub suites passed 2,176 tests. The
full frontend Vitest run was stopped after the requested 15-minute local
window without a failure result or terminal summary; the PR checks remain the
authoritative broad-suite validation.
## Documentation impact
No public documentation change is required. This package updates the durable UI
requirement and adds its system design.
## Risks
- A late HTTP result can replace newer WebSocket data unless current store
identities win during response application.
- One hook instance per row can produce duplicate requests unless the in-flight
registry is scoped above each component.
- A request that starts on pointer entry can finish after pointer leave. The
result must remain cached without reopening the tooltip.
- Radix can keep hidden tooltip portals mounted. E2E locators must target the
active tooltip content.
- Adding author text can increase summary height and can wrap in translated
layouts. Desktop and mobile tests must retain viewport containment.
- A missing or mismatched workspace ID must never be replaced with the active
workspace or reach a global notification broadcast.
@@ -0,0 +1,115 @@
---
id: "01-hydrate-pr-details-on-disclosure"
title: "Hydrate PR details on disclosure"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-PR-TASK-STATUS-SUMMARY-001
acceptance_criteria:
- AC-UI-PR-TASK-STATUS-SUMMARY-001.1
- AC-UI-PR-TASK-STATUS-SUMMARY-001.13
- AC-UI-PR-TASK-STATUS-SUMMARY-001.14
- AC-UI-PR-TASK-STATUS-SUMMARY-001.15
- AC-UI-PR-TASK-STATUS-SUMMARY-001.16
system_design:
- ../../specs/ui/system-design/pr-task-status-summary.md
---
# Task 01: Hydrate PR Details on Disclosure
## Summary
Give compact GitHub PR indicators the same tooltip trigger as full indicators.
Load stored full PR records for only the disclosed task and keep the result in
the existing store.
## In scope
- Add a task-scoped hydration hook with store-scoped request deduplication.
- Guard workspace and workspace-context changes, preserve matching WebSocket
store records, and honor deletion tombstones.
- Treat `TaskPR.workspace_id` as authoritative for live updates. Ignore missing
or foreign browser payloads and route typed backend events through the
fail-closed workspace broadcaster when authentication is enforced.
- Render compact loading and unavailable tooltip content.
- Start the load on mouse pointer entry or visible keyboard focus.
- Add localized loading and unavailable copy in all required locales.
## Out of scope
- Author presentation.
- Upstream GitHub refreshes.
- Workspace-wide PR hydration.
- Backend, persistence, or task-summary changes.
## Acceptance
- A compact indicator opens immediately and becomes a full summary after one
task-scoped request.
- Concurrent disclosure requests share one request, and loaded store data
prevents another request.
- Empty, failed, stale-workspace, and WebSocket-before-HTTP paths preserve safe
state and permit a later retry.
- A missing or mismatched workspace ID in a live TaskPR update leaves the active
scoped cache unchanged, and an unattributed typed backend event cannot fan out
across workspace owners when authentication is enforced.
## Verification
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps && pnpm --filter @kandev/web exec vitest run hooks/domains/github/use-task-pr-tooltip-hydration.test.tsx components/github/pr-task-icon.render.test.tsx components/task/task-item.test.tsx)
(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/hooks/domains/github/use-task-pr-tooltip-hydration.ts`
- `apps/web/hooks/domains/github/use-task-pr-tooltip-hydration.test.tsx`
- `apps/web/components/github/pr-task-icon.tsx`
- `apps/web/components/github/pr-task-icon.render.test.tsx`
- `apps/web/components/task/task-contribution-icons.tsx`
- `apps/web/components/task/task-item.test.tsx`
- `apps/web/src/locales/*/github.json`
## Dependencies
None.
## Risks
- HTTP hydration and WebSocket updates can arrive in either order.
- Local component state alone cannot deduplicate several mounted task surfaces.
- Pointer leave must not cancel useful store hydration.
## Parallelism
`sequential`
## Inputs
- Requirement criteria `.1` and `.13` to `.16`.
- The disclosure data flow and failure sections in the system design.
- `RegisteredChangeRequestTaskIcon` as the on-open refresh pattern.
## Results
- Added task-scoped compact PR hydration with store-scoped request
deduplication, settled-cache reuse, workspace race protection, WebSocket
precedence, and retryable empty or failed states.
- Added localized loading and unavailable tooltip states in all required
locales.
- Added pointer and keyboard disclosure coverage plus focused hydration tests.
- Added task-id and workspace-context generation race coverage, plus protection
against resurrecting an association deleted while an HTTP request is pending.
- Focused unit tests, typecheck, i18n checks, ratchet, full web lint, desktop
E2E, mobile E2E, and the E2E build passed.
- The 2026-08-26 review fixup added frontend missing/foreign workspace event
coverage, backend typed-event routing and fail-closed coverage, and preserved
taskPR scope metadata in the singleton task-switch test. The focused frontend
suite passed 95 tests across 6 files and the backend gateway/GitHub suites
passed 2,176 tests.
@@ -0,0 +1,105 @@
---
id: "02-show-pr-author-identity"
title: "Show PR author identity"
status: done
wave: 2
depends_on:
- "01-hydrate-pr-details-on-disclosure"
plan: "plan.md"
requirements:
- REQ-UI-PR-TASK-STATUS-SUMMARY-001
acceptance_criteria:
- AC-UI-PR-TASK-STATUS-SUMMARY-001.2
- AC-UI-PR-TASK-STATUS-SUMMARY-001.7
- AC-UI-PR-TASK-STATUS-SUMMARY-001.8
- AC-UI-PR-TASK-STATUS-SUMMARY-001.17
- AC-UI-PR-TASK-STATUS-SUMMARY-001.18
system_design:
- ../../specs/ui/system-design/pr-task-status-summary.md
---
# Task 02: Show PR Author Identity
## Summary
Show the GitHub author login below the PR title in the task summary. Show the
same identity in the existing coarse-pointer PR-status drawer and prove both
user flows end to end.
## In scope
- Add optional author data to the shared task-summary presentation.
- Derive the author from `TaskPR.author_login` and omit empty values.
- Add optional author content to the shared change-request popover header.
- Pass the GitHub author into `PRCIPopover` for desktop and drawer content.
- Add desktop and mobile Playwright coverage.
## Out of scope
- GitLab and registered-provider author derivation.
- New mobile navigation or touch controls.
- Provider refresh, association, or persistence behavior.
## Acceptance
- The hydrated task tooltip shows the non-empty GitHub author below each PR
title and omits an empty author line.
- The existing mobile PR-status drawer shows the same author without changing
task-row tap behavior.
- Desktop and mobile surfaces remain inside their viewports and preserve the
existing structured status rows.
## Verification
```bash
(cd apps && pnpm --filter @kandev/web exec vitest run components/github/pr-task-status-summary.test.ts components/integrations/change-request-task-status-summary.test.tsx components/github/pr-ci-popover.test.ts components/github/pr-status-chip.test.tsx)
(cd apps/web && pnpm e2e:run tests/pr/pr-sidebar-hover-hydration.spec.ts)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-task-status-summary.spec.ts)
(cd apps/web && pnpm run typecheck)
```
## Files likely touched
- `apps/web/components/integrations/change-request-task-status-summary.tsx`
- `apps/web/components/integrations/change-request-task-status-summary.test.tsx`
- `apps/web/components/integrations/change-request-ci-anatomy.tsx`
- `apps/web/components/github/pr-task-status-summary.tsx`
- `apps/web/components/github/pr-task-status-summary.test.ts`
- `apps/web/components/github/pr-ci-popover.tsx`
- `apps/web/components/github/pr-ci-popover.test.ts`
- `apps/web/components/github/pr-status-chip.test.tsx`
- `apps/web/e2e/tests/pr/pr-sidebar-hover-hydration.spec.ts`
- `apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts`
## Dependencies
- Task 01 must provide the compact-to-full disclosure path.
## Risks
- Shared optional author markup must not change providers that omit the value.
- Longer author identities can wrap and increase overlay height.
- Hidden Radix portals can make unscoped browser assertions ambiguous.
## Parallelism
`sequential`
## Inputs
- Requirement criteria `.2`, `.7`, `.8`, `.17`, and `.18`.
- Author presentation and mobile behavior in the system design.
- Existing task-summary, CI popover, status-chip drawer, and mobile sidebar
tests.
## Results
- Added optional GitHub author presentation below task-summary and CI-popover
titles, while omitting blank identities.
- Reused the existing mobile PR-status drawer and preserved task-row
navigation.
- Added summary, popover, drawer, desktop E2E, and mobile E2E coverage.
- Kept one mounted tooltip trigger across compact-to-full hydration so keyboard
focus and an open disclosure survive the store update.
- Focused unit tests, typecheck, full web lint, desktop E2E, mobile E2E, and the
E2E build passed.
+1
View File
@@ -160,6 +160,7 @@ contract that other capabilities can reuse.
- [Entity Reference Composer](system-design/entity-reference-composer.md)
- [Mobile Task Chrome](system-design/mobile-task-chrome.md)
- [Persistent status motion](system-design/persistent-status-motion.md)
- [PR Task Status Summary](system-design/pr-task-status-summary.md)
- [Prompt History Panel](system-design/prompt-history-panel.md)
- [Quick Chat and Terminal Tabs](system-design/quick-terminal.md)
- [Task Confirmation Warning Hierarchy](system-design/confirmation-warning-hierarchy.md)
@@ -2,6 +2,7 @@
status: active
system: ui
created: 2026-08-06
updated: 2026-08-26
owners:
- kandev
---
@@ -9,7 +10,9 @@ owners:
## Overview
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.
Task pull-request indicators show a compact structured summary of each linked
pull request. The indicator must also work when a task row initially has only
the bounded task-status projection and not the full pull-request record.
## Requirements
@@ -25,12 +28,19 @@ Task pull-request indicators currently flatten the PR title, review state, CI st
- **AC-UI-PR-TASK-STATUS-SUMMARY-001.4:** 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.
- **AC-UI-PR-TASK-STATUS-SUMMARY-001.5:** 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.
- **AC-UI-PR-TASK-STATUS-SUMMARY-001.6:** 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.
- **AC-UI-PR-TASK-STATUS-SUMMARY-001.7:** 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.
- **AC-UI-PR-TASK-STATUS-SUMMARY-001.7:** The disclosure uses localized copy, readable line height, restrained semantic colors, and a viewport-contained width. Opening it does not navigate, mutate PR state, refresh the provider, or change task-row activation.
- **AC-UI-PR-TASK-STATUS-SUMMARY-001.8:** The shared task PR indicator uses the same summary in the desktop sidebar, Kanban cards, and rich task-list rows.
- **AC-UI-PR-TASK-STATUS-SUMMARY-001.9:** All status entries in one summary share label, icon, and status-text columns so Review, CI, merge, and terminal values start at the same horizontal position.
- **AC-UI-PR-TASK-STATUS-SUMMARY-001.10:** Secondary detail, including merge-queue position and estimated merge time, starts under the status text instead of under the label or icon.
- **AC-UI-PR-TASK-STATUS-SUMMARY-001.11:** Shared columns adapt to translated labels and wrapped values without a fixed label width that clips content.
- **AC-UI-PR-TASK-STATUS-SUMMARY-001.12:** GitHub, GitLab, and registered change-request providers that use the shared summary component receive the same row alignment without changing provider-specific state derivation or copy.
- **AC-UI-PR-TASK-STATUS-SUMMARY-001.13:** When hover or keyboard focus opens a GitHub PR indicator with only compact task-summary data, the disclosure shall appear. The system shall load the stored PR records for that task.
- **AC-UI-PR-TASK-STATUS-SUMMARY-001.14:** While the task-scoped load is pending, the disclosure shall show a localized loading state. On success, it shall replace that state with the structured summary without another user action.
- **AC-UI-PR-TASK-STATUS-SUMMARY-001.15:** Repeated or concurrent disclosure requests for the same task shall share one in-flight load. Loaded store data shall prevent later disclosure requests from starting another load.
- **AC-UI-PR-TASK-STATUS-SUMMARY-001.16:** When the task-scoped load fails or returns no PR records, the disclosure shall show a localized unavailable state. A later disclosure shall retry the load.
- **AC-UI-PR-TASK-STATUS-SUMMARY-001.17:** Each GitHub PR summary shall show the non-empty PR author login below the PR title. A missing author login shall not create an empty label.
- **AC-UI-PR-TASK-STATUS-SUMMARY-001.18:** On a coarse pointer, the task row shall remain the primary touch target. After task navigation, the existing PR-status drawer shall show the same author login.
- **AC-UI-PR-TASK-STATUS-SUMMARY-001.19:** TaskPR API and WebSocket payloads shall carry the owning workspace ID. The backend shall route typed PR events by that ID, and the frontend shall ignore missing or mismatched updates before changing the active workspace cache.
## Migrated source detail
@@ -68,8 +78,12 @@ do not align, and secondary merge-queue text begins under the icon instead of th
- 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.
viewport-contained width. It remains informational and does not mutate provider state.
- If the browser has only compact task-summary data, hover or keyboard focus loads the stored PR
records for that task. The disclosure shows loading and unavailable states without closing.
- Repeated requests for the same task share one in-flight load. Loaded task-store data prevents
another load.
- Each GitHub PR entry shows its non-empty author login below the title.
- The shared task PR indicator uses the same summary in the desktop sidebar, Kanban cards, and rich
task-list rows.
- GitHub, GitLab, and registered change-request providers that use the shared summary component get
@@ -103,13 +117,22 @@ do not align, and secondary merge-queue text begins under the icon instead of th
**THEN** that detail starts under **Awaiting checks** and wraps within the status column.
- **GIVEN** translated row labels have different lengths, **WHEN** the summary renders, **THEN** the
longest available label defines the shared label column and no label or status is clipped.
- **GIVEN** a task row has only compact PR status, **WHEN** a fine pointer hovers the PR indicator
or keyboard focus reaches it, **THEN** a loading disclosure opens and then shows the full stored
PR summary without another user action.
- **GIVEN** two mounted surfaces request the same task PR details, **WHEN** their requests overlap,
**THEN** they share one in-flight load and preserve newer store data that arrives first.
- **GIVEN** a stored PR has an author login, **WHEN** the task summary or mobile PR-status drawer
opens, **THEN** the author login appears below the PR title.
- **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 polling, API contracts, persistence, or task-to-change-request associations.
- Changing provider polling, persistence, or task-to-change-request associations.
- Refreshing GitHub when the task-row disclosure opens.
- Loading all workspace PR records to satisfy one task-row disclosure.
- Adding check-run lists, reviewer lists, comments, merge controls, or other full PR-detail content
to the task indicator summary.
- Changing GitHub, GitLab, or registered-provider status derivation, icon precedence, or copy.
@@ -121,3 +144,5 @@ do not align, and secondary merge-queue text begins under the icon instead of th
[PR task status summary plan](../../../plans/pr-task-status-summary/plan.md)
[Sidebar task row presentation refinement](../../../plans/sidebar-task-row-presentation/plan.md)
[PR task status hover hydration](../../../plans/pr-task-status-hover-hydration/plan.md)
@@ -0,0 +1,160 @@
---
status: draft
system: ui
requirements:
- REQ-UI-PR-TASK-STATUS-SUMMARY-001
---
# PR Task Status Summary System Design
## Purpose and boundaries
The UI system owns the shared task-indicator disclosure. The disclosure is used
by the sidebar, Kanban cards, and rich task lists.
The task status projection remains the bounded source for inactive task rows.
The GitHub integration remains the source for stored `TaskPR` records. This
design does not add full PR records to `TaskStatusSummary`.
## Requirement mapping
| Requirement | Design sections |
| --- | --- |
| `REQ-UI-PR-TASK-STATUS-SUMMARY-001` | [Disclosure data flow](#disclosure-data-flow), [Author presentation](#author-presentation), [Mobile behavior](#mobile-behavior), [Failure and recovery](#failure-and-recovery) |
## Current data split
`TaskStatusSummary.pull_request` gives every task row compact PR data. The
frontend maps this data to `prInfo`, which contains the PR number, state, and
aggregate state.
`taskPRs.byTaskId` contains full `TaskPR` records. These records contain the
title, `author_login`, review state, CI state, and merge state. The cache also
records the workspace and workspace-context generation that owns its rows, so
task-detail surfaces cannot reuse records from an earlier context. Some routes
load all workspace records, while task-detail surfaces load records for the
active task.
The current fallback indicator uses `prInfo` without a tooltip. The full-data
indicator uses `PRTaskIcon` with `PRTaskStatusSummary`. This split causes the
inconsistent behavior.
## Components and responsibilities
- `TaskContributionIcons` passes compact `prInfo` to the GitHub task indicator.
- `PRTaskIcon` owns one visual trigger for compact, loading, unavailable, and
full-data states.
- A task-scoped hydration hook reads the current workspace-scoped
`taskPRs.byTaskId` entry and uses `listTaskPRs([taskId])` only when full data
is absent.
- `useChangeRequestTaskTooltipState` opens on a mouse pointer or visible
keyboard focus. Its `onOpen` callback starts hydration.
- `PRTaskStatusSummary` derives GitHub presentation data from each `TaskPR`.
- `ChangeRequestTaskStatusSummary` renders the shared summary structure and an
optional author identity.
- `PRCIPopover` and `PRStatusChipDrawer` provide the existing detailed touch
path after the user opens a task.
## Data and contracts
The design uses the existing `GET /api/v1/github/task-prs?task_ids=<id>`
endpoint. This endpoint returns persisted task associations. It does not start
the workspace-wide stale-record refresh.
The frontend store remains the cache. A successful load adds each returned
`TaskPR` through the existing store action. The client-only cache metadata
tracks workspace context and association deletion tombstones; the
implementation adds no database field, task-summary field, or public API.
Every `TaskPR` API and WebSocket payload includes its owning `workspace_id`.
The backend exposes that identity to the WebSocket broadcaster for typed
in-process events and routes PR updates and detachments through the
fail-closed workspace path when authentication is enforced. The frontend
applies an update only when its workspace ID matches the active workspace;
missing or mismatched updates are ignored before the cache changes.
`ChangeRequestTaskStatusSummaryData` gains an optional `author` value. GitHub
sets this value from `TaskPR.author_login`. Other providers can omit it.
## Disclosure data flow
1. The task row renders the compact PR indicator from `prInfo`.
2. A mouse pointer enters the indicator, or keyboard focus becomes visible.
3. The tooltip opens immediately and shows the PR identity with a loading state.
4. The hydration hook checks the current workspace-scoped
`taskPRs.byTaskId` entry again. It stops if full data is available.
5. The hook acquires one in-flight request for the active store, workspace,
workspace-context generation, and task. Other mounted indicators reuse that
request.
6. The response is ignored after a workspace or task context change. Existing
store records win over matching response records, and deletion tombstones
prevent a late response from resurrecting an association. New response
identities can fill missing multi-PR siblings.
7. The full store data replaces the loading content while the tooltip remains
open.
The in-flight registry is scoped to the Zustand store instance. It removes a
request after settlement. The store is the only settled cache.
## Author presentation
The structured task summary shows `task:byAuthor` below the PR title when
`author_login` is non-empty. The author line uses normal text, so its meaning
does not depend on color or an icon.
The GitHub CI popover header receives the same optional author. This header is
shared by the desktop status popover and the coarse-pointer PR-status drawer.
## Failure and recovery
The tooltip does not show a toast for a passive disclosure error. It shows a
localized unavailable state and keeps the compact PR identity visible.
An error or empty response removes the in-flight entry. A later hover or focus
starts another request. Pointer leave does not cancel a request because the
result remains useful to other task surfaces.
HTTP responses cannot replace a matching store record that arrived through a
newer WebSocket path. A workspace or workspace-context change prevents response
application, and a deletion tombstone prevents a late HTTP row from restoring a
removed association.
## Accessibility
The compact fallback and full indicator use the same focusable semantic
trigger. The trigger remains mounted while compact content changes to the full
summary, so keyboard focus and an open tooltip survive hydration. Keyboard
focus starts the same load as mouse hover. Escape dismisses the tooltip, and
later focus can open it again.
Loading and unavailable content uses localized text. The author identity is
part of the visible summary and the accessible content.
## Mobile behavior
The compact task-row indicator remains passive on coarse pointers. The task row
keeps its existing primary navigation action and touch geometry.
After navigation, the existing `PRStatusChipDrawer` is the author-detail entry
point. The drawer keeps its current safe-area handling and internal scroll
owner. Its PR content shows the author below the title.
The closest shipped mobile surfaces are
`session-task-switcher-sheet.tsx` for task navigation and
`pr-status-chip.tsx` for the coarse-pointer drawer.
## Tests
Unit and component tests cover request deduplication, cache reuse, workspace
and task-context changes, WebSocket-before-HTTP races, deletion tombstones,
retry behavior, keyboard-focus continuity, and author omission.
Desktop Playwright coverage starts on an unrelated task detail route. It opens
an inactive task indicator that initially has only compact summary data.
Mobile Playwright coverage taps the inactive task row, opens the existing
PR-status drawer, and checks the author identity and page containment.
## Related designs
- [Bounded Task Status Delivery](../../platform/system-design/bounded-task-status-delivery.md)