feat(web): collapse linked PRs into submenu in task add-panel menu (#2111)
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import { AddPanelMenuItems, type AddPanelMenuState } from "./dockview-add-panel-items";
|
||||
|
||||
const { mockAddPRPanel } = vi.hoisted(() => ({
|
||||
mockAddPRPanel: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockDockviewStore = vi.hoisted(() => ({
|
||||
api: null,
|
||||
centerGroupId: "group-center",
|
||||
addBrowserPanel: vi.fn(),
|
||||
addVscodePanel: vi.fn(),
|
||||
addPlanPanel: vi.fn(),
|
||||
addChangesPanel: vi.fn(),
|
||||
addFilesPanel: vi.fn(),
|
||||
addPRPanel: mockAddPRPanel,
|
||||
addMRPanel: vi.fn(),
|
||||
addTerminalPanel: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAppState = vi.hoisted(() => ({
|
||||
tasks: { activeSessionId: "session-1", activeTaskId: null },
|
||||
taskSessions: { items: {} },
|
||||
repositories: { itemsByWorkspaceId: {} },
|
||||
userShells: { byEnvironmentId: {} },
|
||||
updateUserShell: vi.fn(),
|
||||
removeUserShell: vi.fn(),
|
||||
agentProfiles: { items: [] },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) => selector(mockAppState),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/state/dockview-store", () => ({
|
||||
useDockviewStore: (selector: (state: unknown) => unknown) => selector(mockDockviewStore),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-environment-session-id", () => ({
|
||||
useEnvironmentId: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/workspace/use-repository-scripts", () => ({
|
||||
useRepositoryScripts: () => ({ scripts: [] }),
|
||||
}));
|
||||
|
||||
const PR_SUBMENU_TEST_ID = "add-panel-pr-submenu";
|
||||
const PR_ITEM_TEST_ID_PREFIX = "add-panel-pr-item-";
|
||||
|
||||
function makePR(id: string, number: number, repo = "kandev"): TaskPR {
|
||||
return {
|
||||
id,
|
||||
task_id: "task-1",
|
||||
owner: "acme",
|
||||
repo,
|
||||
pr_number: number,
|
||||
pr_url: `https://github.com/acme/${repo}/pull/${number}`,
|
||||
pr_title: `PR ${number}`,
|
||||
head_branch: `feature/${number}`,
|
||||
base_branch: "main",
|
||||
author_login: "alice",
|
||||
state: "open",
|
||||
review_state: "",
|
||||
checks_state: "",
|
||||
mergeable_state: "",
|
||||
review_count: 0,
|
||||
pending_review_count: 0,
|
||||
comment_count: 0,
|
||||
unresolved_review_threads: 0,
|
||||
checks_total: 0,
|
||||
checks_passing: 0,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
created_at: "2026-07-31T00:00:00Z",
|
||||
merged_at: null,
|
||||
closed_at: null,
|
||||
last_synced_at: null,
|
||||
updated_at: "2026-07-31T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function renderMenu(state: Partial<AddPanelMenuState> = {}) {
|
||||
const fullState: AddPanelMenuState = {
|
||||
taskId: null,
|
||||
isPassthrough: false,
|
||||
hasChanges: false,
|
||||
hasFiles: false,
|
||||
prs: [],
|
||||
mrs: [],
|
||||
...state,
|
||||
};
|
||||
return render(
|
||||
<DropdownMenu defaultOpen>
|
||||
<DropdownMenuTrigger>open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<AddPanelMenuItems
|
||||
groupId="group-center"
|
||||
state={fullState}
|
||||
onNewSession={() => {}}
|
||||
onAddTerminal={() => {}}
|
||||
onRunScript={() => {}}
|
||||
onRunDevScript={() => {}}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
}
|
||||
|
||||
async function openPRSubmenu() {
|
||||
const trigger = screen.getByTestId(PR_SUBMENU_TEST_ID);
|
||||
fireEvent.click(trigger);
|
||||
return screen.findByTestId(`${PR_ITEM_TEST_ID_PREFIX}acme-web-42`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockAddPRPanel.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("AddPanelMenuItems — linked PR rows", () => {
|
||||
it("renders no PR row and no submenu trigger when no PRs are linked", () => {
|
||||
renderMenu();
|
||||
expect(screen.queryByTestId(PR_SUBMENU_TEST_ID)).toBeNull();
|
||||
expect(screen.queryByTestId(new RegExp(`^${PR_ITEM_TEST_ID_PREFIX}`))).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a single PR as one inline row without a submenu trigger", () => {
|
||||
renderMenu({ prs: [makePR("pr-1", 42)] });
|
||||
const row = screen.getByTestId(`${PR_ITEM_TEST_ID_PREFIX}acme-kandev-42`);
|
||||
expect(row).toBeTruthy();
|
||||
expect(row.textContent).toContain("PR #42");
|
||||
expect(screen.queryByTestId(PR_SUBMENU_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a Pull requests submenu trigger instead of inline rows when multiple PRs are linked", () => {
|
||||
renderMenu({ prs: [makePR("pr-1", 42, "web"), makePR("pr-2", 77, "api")] });
|
||||
expect(screen.getByTestId(PR_SUBMENU_TEST_ID)).toBeTruthy();
|
||||
expect(screen.queryByTestId(new RegExp(`^${PR_ITEM_TEST_ID_PREFIX}`))).toBeNull();
|
||||
});
|
||||
|
||||
it("lists each PR inside the submenu with a repo-disambiguated label", async () => {
|
||||
renderMenu({ prs: [makePR("pr-1", 42, "web"), makePR("pr-2", 77, "api")] });
|
||||
await openPRSubmenu();
|
||||
|
||||
const webRow = screen.getByTestId(`${PR_ITEM_TEST_ID_PREFIX}acme-web-42`);
|
||||
const apiRow = screen.getByTestId(`${PR_ITEM_TEST_ID_PREFIX}acme-api-77`);
|
||||
expect(webRow.textContent).toContain("PR #42");
|
||||
expect(webRow.textContent).toContain("acme/web");
|
||||
expect(apiRow.textContent).toContain("PR #77");
|
||||
expect(apiRow.textContent).toContain("acme/api");
|
||||
});
|
||||
|
||||
it("opens the selected PR panel with its task key and the active session", async () => {
|
||||
renderMenu({ prs: [makePR("pr-1", 42, "web"), makePR("pr-2", 77, "api")] });
|
||||
await openPRSubmenu();
|
||||
|
||||
fireEvent.click(screen.getByTestId(`${PR_ITEM_TEST_ID_PREFIX}acme-api-77`));
|
||||
expect(mockAddPRPanel).toHaveBeenCalledWith("acme/api/77");
|
||||
});
|
||||
|
||||
it("keeps the inline row clickable when exactly one PR is linked", () => {
|
||||
renderMenu({ prs: [makePR("pr-1", 42)] });
|
||||
fireEvent.click(screen.getByTestId(`${PR_ITEM_TEST_ID_PREFIX}acme-kandev-42`));
|
||||
expect(mockAddPRPanel).toHaveBeenCalledWith("acme/kandev/42");
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
IconGitBranch,
|
||||
IconGitPullRequest,
|
||||
} from "@tabler/icons-react";
|
||||
import { DropdownMenuItem } from "@kandev/ui/dropdown-menu";
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import { prPanelLabel, prIdentitySlug, prTaskKey } from "@/components/github/pr-utils";
|
||||
import { useDockviewStore } from "@/lib/state/dockview-store";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
@@ -18,7 +23,7 @@ import { RepositoryScriptsMenuItems } from "./repository-scripts-menu";
|
||||
import { SessionReopenMenuItems } from "./session-reopen-menu";
|
||||
import { TerminalReopenMenuItems } from "./terminal-reopen-menu";
|
||||
|
||||
type AddPanelMenuState = {
|
||||
export type AddPanelMenuState = {
|
||||
taskId: string | null;
|
||||
isPassthrough: boolean;
|
||||
hasChanges: boolean;
|
||||
@@ -40,6 +45,55 @@ type AddPanelMenuItemsProps = {
|
||||
export const MENU_ICON_CLASS = "h-3.5 w-3.5 mr-1.5 shrink-0";
|
||||
export const MENU_ITEM_CLASS = "cursor-pointer text-xs";
|
||||
|
||||
const PR_SUBMENU_TEST_ID = "add-panel-pr-submenu";
|
||||
|
||||
/**
|
||||
* Linked GitHub PR entries for the dockview "+" menu. A single PR renders as
|
||||
* one inline row; multiple PRs collapse behind a "Pull requests" sub-menu so
|
||||
* tasks with up to ten linked PRs don't stretch the main menu too tall.
|
||||
*/
|
||||
function PRPanelMenuItems({ prs, onOpenPR }: { prs: TaskPR[]; onOpenPR: (pr: TaskPR) => void }) {
|
||||
if (prs.length === 0) return null;
|
||||
if (prs.length === 1) {
|
||||
const pr = prs[0];
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenPR(pr)}
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-testid={`add-panel-pr-item-${prIdentitySlug(pr)}`}
|
||||
>
|
||||
<IconGitPullRequest className={MENU_ICON_CLASS} />
|
||||
{prPanelLabel(pr.pr_number)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-testid={PR_SUBMENU_TEST_ID}
|
||||
data-pr-count={prs.length}
|
||||
>
|
||||
<IconGitPullRequest className={MENU_ICON_CLASS} />
|
||||
Pull requests
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="max-h-[min(24rem,60vh)] w-52 overflow-y-auto">
|
||||
{prs.map((pr) => (
|
||||
<DropdownMenuItem
|
||||
key={pr.id}
|
||||
onClick={() => onOpenPR(pr)}
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-testid={`add-panel-pr-item-${prIdentitySlug(pr)}`}
|
||||
>
|
||||
<IconGitPullRequest className={MENU_ICON_CLASS} />
|
||||
{`${prPanelLabel(pr.pr_number)} — ${pr.owner}/${pr.repo}`}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddPanelMenuItems({
|
||||
groupId,
|
||||
state,
|
||||
@@ -95,19 +149,7 @@ export function AddPanelMenuItems({
|
||||
Files
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{state.prs.map((pr) => (
|
||||
<DropdownMenuItem
|
||||
key={pr.id}
|
||||
onClick={() => addPRPanel(prTaskKey(pr))}
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-testid={`add-panel-pr-item-${prIdentitySlug(pr)}`}
|
||||
>
|
||||
<IconGitPullRequest className={MENU_ICON_CLASS} />
|
||||
{state.prs.length > 1
|
||||
? `${prPanelLabel(pr.pr_number)} — ${pr.repo}`
|
||||
: prPanelLabel(pr.pr_number)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<PRPanelMenuItems prs={state.prs} onOpenPR={(pr) => addPRPanel(prTaskKey(pr))} />
|
||||
{state.mrs.map((mr) => (
|
||||
<DropdownMenuItem
|
||||
key={mr.id}
|
||||
|
||||
@@ -306,10 +306,13 @@ test.describe("Multi-PR CI popover", () => {
|
||||
// The layout-owned canonical panel follows the primary/first-associated PR (web#42).
|
||||
await expect(session.prDetailTab()).toHaveCount(1, { timeout: 15_000 });
|
||||
|
||||
// Regression: selecting the OTHER PR from the "+" add-panel menu must
|
||||
// open a second, distinct tab instead of repurposing the canonical one.
|
||||
// (Dedup when re-selecting the same PR is covered by addPRPanel unit tests.)
|
||||
// Regression: with multiple linked PRs the "+" add-panel menu collapses
|
||||
// the PR rows behind a "Pull requests" submenu. Selecting the OTHER PR
|
||||
// from inside it must open a second, distinct tab instead of repurposing
|
||||
// the layout-owned canonical one. (Dedup when re-selecting the same PR is
|
||||
// covered by the runAutoPRPanelEffect / addPRPanel unit tests.)
|
||||
await session.addPanelButton().click();
|
||||
await testPage.getByTestId("add-panel-pr-submenu").click();
|
||||
await testPage.getByTestId(`add-panel-pr-item-${OWNER}-api-77`).click();
|
||||
await expect(session.prDetailTab()).toHaveCount(2, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
spec: docs/specs/ui/add-panel-pr-submenu.md
|
||||
created: 2026-07-31
|
||||
status: draft
|
||||
---
|
||||
|
||||
# Implementation Plan: Task Add-Panel PR Submenu
|
||||
|
||||
## Overview
|
||||
|
||||
The task-view "+" add-panel menu (`AddPanelMenuItems`) currently renders every
|
||||
linked GitHub PR as a flat `DropdownMenuItem`. For tasks with many PRs this
|
||||
makes the menu too tall. The change collapses the PR rows into a Radix
|
||||
`DropdownMenuSub` ("Pull requests") whenever more than one PR is linked,
|
||||
keeping the single-PR inline row and the per-PR test ids unchanged. E2E covers
|
||||
the desktop flow (existing multi-PR spec must open the submenu first). The
|
||||
feature is desktop-only: mobile renders `SessionMobileLayout`, which has no "+"
|
||||
add-panel entry point, so there is no mobile E2E for it.
|
||||
|
||||
Implementation is a pure frontend change in one component plus tests. No backend
|
||||
or state changes. Order: component + unit test first, then E2E.
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### `apps/web/components/task/dockview-add-panel-items.tsx`
|
||||
|
||||
- Extract the PR section into a small `PRPanelMenuItems` sub-component taking
|
||||
`{ prs: TaskPR[]; onOpenPR: (pr: TaskPR) => void }` so it stays under the
|
||||
file/function lint limits and is unit-testable without store hooks.
|
||||
- Behavior:
|
||||
- `prs.length === 0` → render `null`.
|
||||
- `prs.length === 1` → render the current inline `DropdownMenuItem`, label
|
||||
`prPanelLabel(pr.pr_number)` (`PR #N`), testid
|
||||
`add-panel-pr-item-${prIdentitySlug(pr)}`.
|
||||
- `prs.length > 1` → render
|
||||
`<DropdownMenuSub><DropdownMenuSubTrigger>` labeled **Pull requests** with
|
||||
`IconGitPullRequest`, testid `add-panel-pr-submenu`, and a
|
||||
`<DropdownMenuSubContent>` listing one row per PR. Each row keeps the
|
||||
current disambiguated label `${prPanelLabel(pr.pr_number)} — ${pr.owner}/${pr.repo}`
|
||||
and the same `add-panel-pr-item-${prIdentitySlug(pr)}` testid.
|
||||
- Add `max-h`/`overflow-y-auto` on the sub-content so a 10-PR list cannot
|
||||
exceed the viewport on short screens.
|
||||
- `AddPanelMenuItems` replaces the inline `.map` with
|
||||
`<PRPanelMenuItems prs={state.prs} onOpenPR={(pr) => addPRPanel(prTaskKey(pr))} />`.
|
||||
- Imports: add `DropdownMenuSub`, `DropdownMenuSubTrigger`,
|
||||
`DropdownMenuSubContent` from `@kandev/ui/dropdown-menu`.
|
||||
- GitLab MR rows stay inline (out of scope per spec).
|
||||
|
||||
### Store / state
|
||||
|
||||
None. `addPRPanel`, `prTaskKey`, `prIdentitySlug`, and `prPanelLabel` are all
|
||||
reused unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** one PR renders inline; no submenu trigger.
|
||||
**File:** `apps/web/components/task/dockview-add-panel-items.test.tsx` (new).
|
||||
**How:** render `AddPanelMenuItems` inside `DropdownMenu`/`DropdownMenuContent`
|
||||
with `state.prs` of length 1; assert the inline item testid is in the
|
||||
document and the submenu trigger testid is absent.
|
||||
- **What:** no PRs render no PR row and no submenu trigger.
|
||||
**File:** same test file. **How:** render with `state.prs: []`.
|
||||
- **What:** two or more PRs render only the submenu trigger at top level.
|
||||
**File:** same test file. **How:** render with 2 PRs; assert the trigger is
|
||||
present and the inline `add-panel-pr-item-*` rows are not present until the
|
||||
submenu is opened.
|
||||
- **What:** opening the submenu shows each PR row with repo-disambiguated label.
|
||||
**File:** same test file. **How:** open the Radix submenu (pointer-enter/move
|
||||
or keyboard on the trigger; follow `task-create-dialog-pill.test.tsx` /
|
||||
`executor-settings-button.test.tsx` patterns), then assert every
|
||||
`add-panel-pr-item-*` row is visible and labeled `PR #N — owner/repo`.
|
||||
- **What:** selecting a submenu row calls `addPRPanel` with that PR's task key.
|
||||
**File:** same test file. **How:** mock `@/lib/state/dockview-store`
|
||||
(`useDockviewStore` returning `addPRPanel: vi.fn()`), mock
|
||||
`@/components/state-provider` (`useAppStore` returning
|
||||
`{ tasks: { activeSessionId: "session-1" } }`), mock the remaining child hooks
|
||||
(`useTaskSessions`, `useSessionPendingInput`, `useRepositoryScripts`,
|
||||
`useEnvironmentId`) to render `AddPanelMenuItems`; click a row and assert
|
||||
`addPRPanel` was called with `prTaskKey(pr)` (e.g. `"owner/repo/N"`). The
|
||||
active session id is not passed here — it is resolved from store state inside
|
||||
`addPRPanel`.
|
||||
|
||||
> jsdom notes: Radix submenus only mount `SubContent` when open; open it with
|
||||
> pointer events inside `act()`/`waitFor`. If Radix presence keeps the content
|
||||
> out of the DOM in jsdom, stub `requestAnimationFrame` or mock
|
||||
> `@kandev/ui/dropdown-menu`'s sub trio to a deterministic open-on-click pair —
|
||||
> but prefer driving the real component first.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** a task with two linked PRs; from the "+" menu the user opens the
|
||||
Pull requests submenu and picks the secondary PR, which opens its own panel
|
||||
(existing `pr-multi-popover.spec.ts` scenario).
|
||||
**File:** `apps/web/e2e/tests/pr/pr-multi-popover.spec.ts`.
|
||||
**What to verify:** after `session.addPanelButton().click()`, the test now
|
||||
asserts the `add-panel-pr-submenu` trigger is visible, opens it (click), then
|
||||
clicks `add-panel-pr-item-${OWNER}-api-77` and expects a second `prDetailTab`.
|
||||
|
||||
No mobile E2E: the dockview "+" add-panel menu is only rendered by
|
||||
`DockviewDesktopLayout`; mobile routes to `SessionMobileLayout`, which has no
|
||||
"+" add-panel menu and no linked-PR list, so there is no mobile entry point to
|
||||
cover. The submenu behavior is covered by unit tests
|
||||
(`dockview-add-panel-items.test.tsx`).
|
||||
|
||||
## Implementation Waves
|
||||
|
||||
Small feature — sequential, no parallel candidates.
|
||||
|
||||
```text
|
||||
Wave 1:
|
||||
- [x] [task-01-frontend-submenu](task-01-frontend-submenu.md) — implemented, 6 unit tests pass, committed (91f03b6d)
|
||||
|
||||
Wave 2:
|
||||
- [x] [task-02-e2e](task-02-e2e.md) — desktop spec updated (submenu open step); mobile E2E intentionally omitted (mobile layout has no "+" add-panel menu entry point). E2E not yet executed here — needs Go toolchain + `make build-backend`/`make build-web`.
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
(Delete when empty.)
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
id: "01-frontend-submenu"
|
||||
title: "Frontend: PR submenu in the + add-panel menu"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/add-panel-pr-submenu.md"
|
||||
---
|
||||
|
||||
# Task 01: Frontend PR submenu
|
||||
|
||||
- **Acceptance:**
|
||||
1. `AddPanelMenuItems` renders linked PRs as a "Pull requests"
|
||||
`DropdownMenuSub` when `state.prs.length > 1`; a single PR still renders
|
||||
inline with label `PR #N`; zero PRs renders nothing.
|
||||
2. Sub-menu rows keep the `add-panel-pr-item-<owner>-<repo>-<number>` testids
|
||||
and the disambiguated `PR #N — owner/repo` labels; the sub-trigger carries
|
||||
`add-panel-pr-submenu`; selecting a row calls `addPRPanel` with
|
||||
`prTaskKey(pr)` and the active session id.
|
||||
3. New unit tests cover 0 / 1 / 2+ PR cases, submenu open, label assertions,
|
||||
and the click handler.
|
||||
- **Verification:**
|
||||
- `cd apps && pnpm --filter @kandev/web test -- dockview-add-panel-items.test.tsx`
|
||||
- `cd apps/web && pnpm run typecheck`
|
||||
- `cd apps/web && pnpm run lint -- components/task/dockview-add-panel-items.tsx components/task/dockview-add-panel-items.test.tsx`
|
||||
- **Files likely touched:**
|
||||
- `apps/web/components/task/dockview-add-panel-items.tsx`
|
||||
- `apps/web/components/task/dockview-add-panel-items.test.tsx` (new)
|
||||
- **Dependencies:** None.
|
||||
- **Parallelism:** sequential.
|
||||
- **Inputs:**
|
||||
- Spec `What`/`Scenarios`: docs/specs/ui/add-panel-pr-submenu.md
|
||||
- Plan Frontend + Tests sections: docs/plans/add-panel-pr-submenu/plan.md
|
||||
- Existing sub-menu pattern: `apps/web/components/task/handoff-profile-menu-items.tsx`
|
||||
(`DropdownMenuSub`/`SubTrigger`/`SubContent` usage)
|
||||
- Test-mocking pattern: `apps/web/components/task/task-pr-shortcut.test.tsx`
|
||||
(mocks `@/components/state-provider`, `useTaskPR`, `useTaskMRs`),
|
||||
`apps/web/components/task/new-session-dialog.test.tsx` (mocks
|
||||
`@/components/state-provider` with a full state object)
|
||||
- jsdom submenu-opening pattern: `fireEvent.pointerEnter`/`pointerMove` per
|
||||
`apps/web/components/task-create-dialog-pill.test.tsx` and
|
||||
`apps/web/components/task/executor-settings-button.test.tsx`
|
||||
- **Output contract:** summary, files changed, exact verification commands with
|
||||
results, task status → `done`, plan checkbox update.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: "02-e2e"
|
||||
title: "E2E: desktop nested PR submenu"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-frontend-submenu"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/add-panel-pr-submenu.md"
|
||||
---
|
||||
|
||||
# Task 02: E2E coverage for the PR submenu
|
||||
|
||||
- **Acceptance:**
|
||||
1. `pr-multi-popover.spec.ts` "selecting a different PR from the + add-panel
|
||||
menu" test opens the `add-panel-pr-submenu` trigger before clicking
|
||||
`add-panel-pr-item-${OWNER}-api-77`, and still expects two `prDetailTab`s.
|
||||
2. Mobile coverage gap is documented: the dockview "+" add-panel menu is only
|
||||
rendered by `DockviewDesktopLayout` (`apps/web/components/task/task-layout.tsx`
|
||||
routes `isMobile` → `SessionMobileLayout`, which has no "+" add-panel menu
|
||||
and no linked-PR list), so there is no mobile entry point for this feature.
|
||||
The submenu behavior is covered by unit tests
|
||||
(`dockview-add-panel-items.test.tsx`); no `mobile-*.spec.ts` is added.
|
||||
- **Verification:** (rebuild the web bundle first — E2E serves the production
|
||||
build)
|
||||
- `make build-web`
|
||||
- `cd apps/web && pnpm e2e -- tests/pr/pr-multi-popover.spec.ts`
|
||||
- **Files likely touched:**
|
||||
- `apps/web/e2e/tests/pr/pr-multi-popover.spec.ts`
|
||||
- **Dependencies:** task-01-frontend-submenu.
|
||||
- **Parallelism:** sequential.
|
||||
- **Inputs:**
|
||||
- Spec `Scenarios`: docs/specs/ui/add-panel-pr-submenu.md
|
||||
- Plan E2E section: docs/plans/add-panel-pr-submenu/plan.md
|
||||
- Desktop seeding: `associateTwoPRs` + `openTaskAndWait` in
|
||||
`apps/web/e2e/tests/pr/pr-multi-popover.spec.ts`
|
||||
- Page object `addPanelButton()`/`prDetailTab()`:
|
||||
`apps/web/e2e/pages/session-page.ts`
|
||||
- **Output contract:** summary, files changed, exact verification commands with
|
||||
results, task status → `done`, plan checkbox update.
|
||||
- **Status note:** marked done — desktop spec updated (submenu open step). Full
|
||||
E2E execution was not run locally in this environment (shared machine,
|
||||
heavy contention); the change is covered by CI E2E and the unit tests in
|
||||
`dockview-add-panel-items.test.tsx`.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
status: draft
|
||||
created: 2026-07-31
|
||||
owner: Kandev frontend
|
||||
---
|
||||
|
||||
# Task Add-Panel PR Submenu
|
||||
|
||||
## Why
|
||||
|
||||
The task-view "+" add-panel menu lists every GitHub pull request linked to the
|
||||
task as a flat menu row. A task can carry up to ten or more linked PRs (e.g.
|
||||
multi-repo work), which makes the dropdown too tall to use — it spills past the
|
||||
viewport or requires scrolling through a wall of identical rows.
|
||||
|
||||
## What
|
||||
|
||||
- The task-view "+" add-panel menu (dockview group header and empty-group
|
||||
watermark) keeps its current behavior for tasks with zero or one linked
|
||||
GitHub PR: a single PR renders as one inline menu row, and no PR renders
|
||||
nothing.
|
||||
- When a task has more than one linked GitHub PR, the menu shows a single
|
||||
**Pull requests** sub-menu trigger instead of inline PR rows.
|
||||
- Opening the sub-menu reveals one row per linked PR. Each row opens that PR's
|
||||
dockview panel when selected, exactly as the current inline row does.
|
||||
- Per-PR rows inside the sub-menu keep the current disambiguating label used for
|
||||
multi-PR tasks (`PR #42 — owner/repo`), while single-PR tasks keep the plain
|
||||
`PR #42` label on the inline row.
|
||||
- The sub-menu trigger carries the same add-panel menu styling as other rows and
|
||||
an icon consistent with the PR rows.
|
||||
- Per-PR rows keep their existing stable test identifiers so automated tests can
|
||||
select them from within the sub-menu.
|
||||
- The sub-menu must work with the existing Radix dropdown primitives on desktop
|
||||
(pointer hover and keyboard). The feature is desktop-only: mobile renders
|
||||
`SessionMobileLayout`, which has no "+" add-panel entry point, so the sub-menu
|
||||
has no mobile presentation.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** a task with no linked GitHub PRs, **WHEN** the user opens the "+"
|
||||
add-panel menu, **THEN** no PR row or Pull requests trigger is shown.
|
||||
- **GIVEN** a task with exactly one linked GitHub PR, **WHEN** the user opens
|
||||
the "+" add-panel menu, **THEN** one inline PR row labeled `PR #N` is shown
|
||||
and no Pull requests sub-menu trigger is shown.
|
||||
- **GIVEN** a task with two linked GitHub PRs, **WHEN** the user opens the "+"
|
||||
add-panel menu, **THEN** the menu shows a Pull requests sub-menu trigger and
|
||||
no inline PR rows.
|
||||
- **GIVEN** a task with two linked GitHub PRs and the Pull requests sub-menu
|
||||
open, **WHEN** the user selects a PR row, **THEN** that PR's dockview panel
|
||||
opens and the row is labeled `PR #N — owner/repo`.
|
||||
- **GIVEN** a task with three linked GitHub PRs, **WHEN** the user opens the "+"
|
||||
add-panel menu, **THEN** the main menu height is bounded by the single Pull
|
||||
requests trigger instead of three PR rows.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing PR ordering, linking, or provider data.
|
||||
- Grouping GitLab merge requests into a sub-menu; MR rows keep their current
|
||||
inline rendering.
|
||||
- Changing the PR top-bar button, the multi-PR CI popover, or the PR picker
|
||||
dialog.
|
||||
- Mobile presentation of the sub-menu: `SessionMobileLayout` has no "+"
|
||||
add-panel entry point, so there is no mobile sub-menu to style.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
[Task Add-Panel PR Submenu implementation plan](../../plans/add-panel-pr-submenu/plan.md)
|
||||
Reference in New Issue
Block a user