From 963672d97ea33902a8795c4279886ccaf2f60e86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Fiere?= Date: Sat, 1 Aug 2026 18:20:31 +0200 Subject: [PATCH] feat(web): collapse linked PRs into submenu in task add-panel menu (#2111) --- .../task/dockview-add-panel-items.test.tsx | 170 ++++++++++++++++++ .../task/dockview-add-panel-items.tsx | 72 ++++++-- .../web/e2e/tests/pr/pr-multi-popover.spec.ts | 9 +- docs/plans/add-panel-pr-submenu/plan.md | 123 +++++++++++++ .../task-01-frontend-submenu.md | 45 +++++ .../plans/add-panel-pr-submenu/task-02-e2e.md | 43 +++++ docs/specs/ui/add-panel-pr-submenu.md | 67 +++++++ 7 files changed, 511 insertions(+), 18 deletions(-) create mode 100644 apps/web/components/task/dockview-add-panel-items.test.tsx create mode 100644 docs/plans/add-panel-pr-submenu/plan.md create mode 100644 docs/plans/add-panel-pr-submenu/task-01-frontend-submenu.md create mode 100644 docs/plans/add-panel-pr-submenu/task-02-e2e.md create mode 100644 docs/specs/ui/add-panel-pr-submenu.md diff --git a/apps/web/components/task/dockview-add-panel-items.test.tsx b/apps/web/components/task/dockview-add-panel-items.test.tsx new file mode 100644 index 0000000000..80511d0f93 --- /dev/null +++ b/apps/web/components/task/dockview-add-panel-items.test.tsx @@ -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 = {}) { + const fullState: AddPanelMenuState = { + taskId: null, + isPassthrough: false, + hasChanges: false, + hasFiles: false, + prs: [], + mrs: [], + ...state, + }; + return render( + + open + + {}} + onAddTerminal={() => {}} + onRunScript={() => {}} + onRunDevScript={() => {}} + /> + + , + ); +} + +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"); + }); +}); diff --git a/apps/web/components/task/dockview-add-panel-items.tsx b/apps/web/components/task/dockview-add-panel-items.tsx index 43ac7625af..776c9d1fc9 100644 --- a/apps/web/components/task/dockview-add-panel-items.tsx +++ b/apps/web/components/task/dockview-add-panel-items.tsx @@ -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 ( + onOpenPR(pr)} + className={MENU_ITEM_CLASS} + data-testid={`add-panel-pr-item-${prIdentitySlug(pr)}`} + > + + {prPanelLabel(pr.pr_number)} + + ); + } + return ( + + + + Pull requests + + + {prs.map((pr) => ( + onOpenPR(pr)} + className={MENU_ITEM_CLASS} + data-testid={`add-panel-pr-item-${prIdentitySlug(pr)}`} + > + + {`${prPanelLabel(pr.pr_number)} — ${pr.owner}/${pr.repo}`} + + ))} + + + ); +} + export function AddPanelMenuItems({ groupId, state, @@ -95,19 +149,7 @@ export function AddPanelMenuItems({ Files )} - {state.prs.map((pr) => ( - addPRPanel(prTaskKey(pr))} - className={MENU_ITEM_CLASS} - data-testid={`add-panel-pr-item-${prIdentitySlug(pr)}`} - > - - {state.prs.length > 1 - ? `${prPanelLabel(pr.pr_number)} — ${pr.repo}` - : prPanelLabel(pr.pr_number)} - - ))} + addPRPanel(prTaskKey(pr))} /> {state.mrs.map((mr) => ( { // 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 }); }); diff --git a/docs/plans/add-panel-pr-submenu/plan.md b/docs/plans/add-panel-pr-submenu/plan.md new file mode 100644 index 0000000000..f3fa33097c --- /dev/null +++ b/docs/plans/add-panel-pr-submenu/plan.md @@ -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 + `` labeled **Pull requests** with + `IconGitPullRequest`, testid `add-panel-pr-submenu`, and a + `` 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 + ` 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.) diff --git a/docs/plans/add-panel-pr-submenu/task-01-frontend-submenu.md b/docs/plans/add-panel-pr-submenu/task-01-frontend-submenu.md new file mode 100644 index 0000000000..458bb653f5 --- /dev/null +++ b/docs/plans/add-panel-pr-submenu/task-01-frontend-submenu.md @@ -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---` 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. diff --git a/docs/plans/add-panel-pr-submenu/task-02-e2e.md b/docs/plans/add-panel-pr-submenu/task-02-e2e.md new file mode 100644 index 0000000000..0aa1bab512 --- /dev/null +++ b/docs/plans/add-panel-pr-submenu/task-02-e2e.md @@ -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`. diff --git a/docs/specs/ui/add-panel-pr-submenu.md b/docs/specs/ui/add-panel-pr-submenu.md new file mode 100644 index 0000000000..378a9a3711 --- /dev/null +++ b/docs/specs/ui/add-panel-pr-submenu.md @@ -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)