feat(web): collapse linked PRs into submenu in task add-panel menu (#2111)

This commit is contained in:
Clément Fiere
2026-08-01 18:20:31 +02:00
committed by GitHub
parent 1a1b4725f7
commit 963672d97e
7 changed files with 511 additions and 18 deletions
@@ -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 });
});
+123
View File
@@ -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`.
+67
View File
@@ -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)