feat: hydrate persisted PR details on task disclosure (#3055)
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -497,6 +497,8 @@
|
||||
"taskAutoFixPrompt": "Ţàśķ àũţō-ƒĩx ƥŕōḿƥţ",
|
||||
"taskGitAccess": "Ţàśķ Ĝĩţ àććēśś",
|
||||
"taskLabelled": "Ţàśķ: {{title}}",
|
||||
"taskPrDetailsLoading": "Ĺōàďĩńĝ ƥũĺĺ ŕēqũēśţ ďēţàĩĺś...",
|
||||
"taskPrDetailsUnavailable": "Ƥũĺĺ ŕēqũēśţ ďēţàĩĺś ũńàvàĩĺàƀĺē",
|
||||
"taskPrompt": "Ţàśķ Ƥŕōḿƥţ",
|
||||
"tasks": "Ţàśķś",
|
||||
"tasksCreatedByThisWatcherLand": "Ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ ĺàńď ĩń ţĥē śēĺēćţēď ŵōŕķśƥàćē.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -497,6 +497,8 @@
|
||||
"taskAutoFixPrompt": "任务自动修复提示词",
|
||||
"taskGitAccess": "任务 Git 访问",
|
||||
"taskLabelled": "任务:{{title}}",
|
||||
"taskPrDetailsLoading": "正在加载拉取请求详情...",
|
||||
"taskPrDetailsUnavailable": "拉取请求详情不可用",
|
||||
"taskPrompt": "任务提示词",
|
||||
"tasks": "任务",
|
||||
"tasksCreatedByThisWatcherLand": "此监控创建的任务会进入所选工作区。",
|
||||
|
||||
@@ -497,6 +497,8 @@
|
||||
"taskAutoFixPrompt": "任務自動修復提示詞",
|
||||
"taskGitAccess": "任務 Git 存取",
|
||||
"taskLabelled": "任務:{{title}}",
|
||||
"taskPrDetailsLoading": "正在載入拉取請求詳情...",
|
||||
"taskPrDetailsUnavailable": "拉取請求詳情不可用",
|
||||
"taskPrompt": "任務提示詞",
|
||||
"tasks": "任務",
|
||||
"tasksCreatedByThisWatcherLand": "此監控建立的任務會進入所選工作區。",
|
||||
|
||||
@@ -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.
|
||||
+115
@@ -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.
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user