17 KiB
spec, created, status
| spec | created | status |
|---|---|---|
| docs/specs/integrations/requirements/gitlab-mr-task-list-badges.md | 2026-08-13 | draft |
Implementation Plan: GitLab MR Badge on the Sidebar and Tasks-List Rows
Amendment: The later rich task title preview plan extends this plan. It owns rich badge content and shared hydration behavior.
Overview
MRTaskIcon ships today with exactly one call site (the Kanban card). This plan
adds it to the two remaining task-row surfaces: the app sidebar row
(components/task/task-item.tsx) and the /tasks rich row
(app/tasks/rich-task-list-row.tsx), and gives /tasks its own unconditional MR
hydration call so its own row has data to render. Frontend only: no backend, no
API, no store slice, no new component, no new user-facing copy.
The three production edits are on disjoint files and can land in any order; the three E2E specs follow them. The order below is dependency order, not risk order: each surface is independently observable, so the product is working after every task.
Backend
None. The spec introduces no persistent state, no API, no WS action, and no
change to TaskStatusSummary. The GitLab MR rows the badge reads are already
cached at taskMRs.byWorkspaceId[workspaceId][taskId].
Frontend
1. App sidebar row — apps/web/components/task/task-item.tsx
The local TaskPRIcon component (currently at ~line 314) is two chained early
returns:
const hasStorePR = useAppStore((s) => !!taskId && (s.taskPRs.byTaskId[taskId]?.length ?? 0) > 0);
if (hasStorePR) return <PRTaskIcon taskId={taskId!} />;
if (!prInfo) return null;
// ... prInfo fallback badge
Both returns exit the component, so anything appended after them is
unreachable for a task that has a PR and for a task that has neither a PR nor
prInfo — the GitLab-only task this feature exists to serve.
Restructure so the PR branch and the MR branch are siblings. Rename the component to reflect that it now renders both contribution badges, and have it return a fragment:
function TaskContributionIcons({ taskId, prInfo }: { taskId?: string; prInfo?: PrInfo }) {
const hasStorePR = useAppStore((s) => !!taskId && (s.taskPRs.byTaskId[taskId]?.length ?? 0) > 0);
return (
<>
<TaskPRBadge taskId={taskId} prInfo={prInfo} hasStorePR={hasStorePR} />
{taskId ? <MRTaskIcon taskId={taskId} /> : null}
</>
);
}
Three rules the restructure must hold, each with its own AC:
- The MR branch needs its own
taskIdtruthiness guard.hasStorePRis not usable: it isfalseboth whentaskIdis absent and whentaskIdis present with no PRs.<MRTaskIcon taskId={taskId!} />(the non-null-assertion shape the PR branch beside it uses) would invoke the component withundefined, add auseTaskMRssubscription, and violate AC5 and AC18 while still rendering nothing. The guard must prevent the call, not just the output. (AC5, AC18) - The call site inside
TaskItemContent's title row keeps its position: the MR badge lands immediately after the PR badge and beforeIssueTaskIcon. Full row order: title, autopilot, pinned, PR, MR, issue, agent-error, remote-cloud, archived chip. (AC2, AC22) - The sidebar hydrates nothing. No file under
components/task/orcomponents/app-sidebar/may importuseWorkspaceMRs. (AC7)
task-item.tsx measures 591 effective lines against the 600-line
max-lines limit (skipBlankLines, skipComments, --max-warnings 0). The
restructure above fits inline with margin; the one-new-file allowance in the
spec's Constraints is a fallback, not the expected shape.
2. /tasks rich row — apps/web/app/tasks/rich-task-list-row.tsx
In PrimaryTaskLine:
- Rename the prop
showPullRequest: booleantoshowContributions: boolean. It is file-local (declared and passed only within this file:truefromRichTaskContentat ~line 129,falsefromTaskListRowPrimaryContent's compact path at ~line 174). One flag gates both badges; no second flag, no per-provider toggle, and no default value. NoshowPullRequestidentifier may remain in the file. (AC10, AC11) - Add
<MRTaskIcon taskId={task.id} />inside the existing propagation-stopping<span>that already wrapsPRTaskIcon, after it. Not a second wrapper: one wrapper, two children, so the badges cannot drift apart on interaction behaviour. (AC9) - That wrapper carries no
classNametoday because it has only ever had one child. Two children would render with no separation at all. Give itinline-flex items-center gap-1— the samegap-1the Kanban card'skanban-card-title-rowand the sidebar title row already use. Apply it unconditionally, so it adds no element and reserves no space in the no-MR case. (AC23, and this is why AC23 and AC6 are not in tension) - Do not add
shrink-0(both badge roots already carry it) and do not touch the row's existinggap-2.
3. /tasks hydration — apps/web/app/tasks/tasks-page-client.tsx
The page already owns its GitHub hydration, gated on the same setting that gates the badge (line 529):
useWorkspacePRs(showTaskDetails ? s.activeWorkspaceId : null);
Mirroring that expression for GitLab is a cross-surface data-loss bug. The
two hooks do not have the same null behaviour: useWorkspacePRs(null) clears a
local ref and touches no store state, while useWorkspaceMRs(null) calls
resetTaskMRs() with no argument, which assigns taskMRs.byWorkspaceId = {}
— every workspace, wiped. AppSidebar is mounted in the root layout and is
therefore on screen on /tasks, so the gated form would blank the sidebar's MR
badges for the whole time a user sits on /tasks with task details off.
Add, unconditionally:
import { useWorkspaceMRs } from "@/hooks/domains/gitlab/use-task-mr";
// ...
useWorkspaceMRs(s.activeWorkspaceId);
matching kanban-board.tsx:321's existing unconditional call rather than
useWorkspacePRs's gated one. (AC12)
Passing null because s.activeWorkspaceId is itself null is permitted and
is not the forbidden case: with no active workspace useTaskMRs returns
EMPTY_MRS for every task, so the clear takes nothing away. (AC21)
Check max-lines on this file before assuming the edit is free. It measures
597 effective lines of 600 — the tightest of the three, despite being the
smaller file by raw count (642 vs 660). The edit above is exactly two effective
lines, landing it at 599. That fits, but with one line to spare. If the actual
eslint run disagrees, the remedy is an extraction into a sibling under
app/tasks/ (the spec's Constraints permit exactly one new file for this
purpose), not an eslint-disable: the file carries none today.
API client
None.
State
None. Reads go through the existing useTaskMRs(taskId)
(hooks/domains/gitlab/use-task-mr.ts:60), which resolves workspaces.activeId
itself and returns the module-level, referentially stable EMPTY_MRS when there
is nothing to show. No new selector may be introduced, and nothing added by this
feature may return a freshly allocated array or object — a fresh [] causes an
infinite re-render loop, as EMPTY_MRS' own comment records. (AC18)
apps/web/components/gitlab/mr-task-icon.tsx and everything under
apps/web/components/github/ are consumed exactly as they ship and are not
modified. (AC14, AC15)
Tests
Unit scenarios are U1 to U11 per the spec. Note the spec's numbering rule:
cite scenarios by prefixed id, never by bare number.
| # | What | File | How |
|---|---|---|---|
| U1 | Sidebar, PR and MR together, PR first in DOM order (AC2) | components/task/task-item.test.tsx |
Render TaskItem under StateProvider with initialState seeding taskPRs.byTaskId.t1, taskMRs.byWorkspaceId.ws1.t1, workspaces.activeId = "ws1"; assert both testids present and compareDocumentPosition |
| U2 | Sidebar, MR only, no PR and no prInfo (AC3) |
same | The case the early return makes impossible today |
| U3 | Sidebar, prInfo fallback plus MR, PR first (AC4) |
same | prInfo={{ number: 7, state: "Open" }}, no store PR |
| U4 | Sidebar, no taskId (AC5) |
same | Two clauses, both required. (a) no mr-task-icon-* element; (b) MRTaskIcon not invoked at all |
| U5 | Sidebar, no MRs, no mr-task-icon-* element (AC6) |
same | PR only |
| U6 | /tasks row, contributions shown: both badges inside the single wrapper, PR first, wrapper carries inline-flex items-center gap-1 (AC8, AC9, AC23) |
app/tasks/rich-task-list-row.test.tsx (new) |
Render the exported TaskListRowPrimaryContent with showTaskDetails true under StateProvider, following tasks-list-view.test.tsx's no-mock shape |
| U7 | /tasks row, contributions hidden: neither badge (AC10) |
same | Same store, compact path |
| U8 | useWorkspaceMRs(null) wipes every workspace (AC13) |
hooks/domains/gitlab/use-task-mr.test.ts |
Seed byWorkspaceId["ws-a"] and ["ws-b"], render the hook with null, assert both gone. Pins the existing behaviour AC12 exists to route around |
| U9 | TasksPageClient calls useWorkspaceMRs with the workspace id, not null, while tasksListShowDetails is false (AC12) |
app/tasks/tasks-page-client.mr-hydration.test.tsx (new) |
Mock @/hooks/domains/gitlab/use-task-mr with vi.fn(), assert the argument. See the risk note in task-03 |
| U10 | Sidebar, MR before the issue badge, run with a store PR present so it covers the full PR-MR-issue order (AC22) | components/task/task-item.test.tsx |
issueInfo={{ url, number: 42 }} plus a seeded PR and MR |
| U11 | MR seeded but workspaces.activeId = null: no badge (AC21) |
same | Observes AC21's second clause, which no other scenario reaches |
U4 clause (b) needs a vi.fn()-backed component mock, and no in-repo
precedent exists — write it fresh. components/kanban-card-content.test.tsx is
the precedent for module-level interception only: it replaces the module with
a plain stub (MRTaskIcon: () => <span data-testid="mr-task-icon" />), and a stub
records no calls, so clause (b) cannot be asserted against it. Copy that file's
vi.mock placement and factory shape; do not copy its stub body. The required
shape is:
vi.mock("@/components/gitlab/mr-task-icon", () => ({ MRTaskIcon: vi.fn(() => null) }));
// ...
expect(MRTaskIcon).not.toHaveBeenCalled();
Clause (b) is the load-bearing one: an unguarded <MRTaskIcon taskId={taskId!} />
would pass clause (a) while breaking AC5.
E2E Tests
Required: apps/web/** is touched and none of the files are on the Testing
step's exemption allowlist.
All GitLab scenarios seed through the GitLab mock provider and a task created
with no agent, for the reason mr-task-card-badge.spec.ts's seedBoardTask
comment records: createTaskWithAgent auto-starts a session, the start step
carries on_turn_complete: move_to_step review, and the card leaves the column
mid-assertion.
Seed helpers are copied, not imported. nextMRIID, seedMR, linkMR,
ensureGitLabConfigured and seedBoardTask are file-local and unexported in
mr-task-card-badge.spec.ts. Each new spec defines its own local copies.
mr-task-card-badge.spec.ts is not edited to export them, which is what keeps
AC16 ("passes unmodified") literally true, and it matches the in-repo precedent:
mobile-mr-task-card-badge.spec.ts already writes its own seedBoardTaskWithMR.
Shared constants are different: import GITLAB_HOST and GITLAB_PROJECT
from e2e/helpers/gitlab.ts.
Lint consequence the builder must respect. The spec corrects an earlier draft
here, and the correct mechanism is narrower than it looks. In
apps/web/eslint.config.mjs, sonarjs/no-identical-functions sits in the global
block (no files, no ignores), so it is live on e2e/** — the
e2e/** entry in an ignores array belongs to the i18n guard block, and the
files: ["e2e/**/*.ts"] override disables max-lines and
sonarjs/no-duplicate-string but not no-identical-functions. It cannot fire
across files, because ESLint sees one file at a time. So: copy each helper
across files freely; never write two copies of the same helper inside one
file, because pnpm --filter @kandev/web lint runs eslint --max-warnings 0.
The mock GitHub PR for E3, E6 and E7 comes from the existing mock-GitHub API
client methods: apiClient.mockGitHubReset(), apiClient.mockGitHubSetUser(),
then apiClient.mockGitHubAssociateTaskPR({ task_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, state }) — state is
optional and is passed explicitly. mockGitHubAddPRs() is the wrong call and
must not be used: it seeds the mock provider's PR catalogue and creates no
task-to-PR association, so taskPRs.byTaskId[taskId] stays empty, PRTaskIcon
renders nothing, and the PR-before-MR assertion can never run. The reference is
mr-task-card-badge.spec.ts's own "AC30/AC37" test; reading it does not modify
it.
Every mr-task-icon-* accessor must be container-scoped and may not use
.first(). One task id can be emitted by two mounted rows on one route (sidebar
plus board, or sidebar plus /tasks row). Scope to app-sidebar,
tasks-list-row, or task-card-<id>. (AC19)
apps/web/e2e/tests/gitlab/mr-sidebar-badge.spec.ts (new):
- E1 — On
/(the board mounts the hydration owner), a task with one linked open MR: insideapp-sidebar, row located by[data-task-row-id="<taskId>"],mr-task-icon-<taskId>visible withdata-mr-count="1"anddata-mr-state="open". (AC1, AC19) - E2 — Same route, task with no linked MR: inside
app-sidebar,mr-task-icon-<taskId>has count 0. (AC6) - E3 — Task with both a mock GitHub PR and a linked MR: inside the sidebar
row, both visible, PR precedes MR by
compareDocumentPosition. (AC2)
apps/web/e2e/tests/gitlab/mr-tasks-list-badge.spec.ts (new):
- E4 — Navigate to
/tasks, enable "Show task details" through thedisplay-buttonmenu (the pattern intask-listing-view-preferences.spec.ts:getByTestId("display-button").click()thengetByText("Show task details", { exact: true }).click()), locate thetasks-list-rowby title (the row carries no task-id attribute; onlytasks-list-row-titleidentifies it), assertmr-task-icon-<taskId>inside that row. (AC8, AC19) - E5 — Same task, details off: inside the row, both
pr-task-icon-<taskId>andmr-task-icon-<taskId>have count 0. (AC10) - E6 — Task with both: inside the row, PR precedes MR. (AC9)
apps/web/e2e/tests/gitlab/mobile-mr-tasks-list-badge.spec.ts (new):
- E7 — Mobile viewport, details on, task with both badges: the MR badge is
visible and
assertNoDocumentHorizontalOverflowpasses. Mirrorsmobile-mr-task-card-badge.spec.ts. (AC20)
All seven scenarios run on / or /tasks. Neither route mounts
TaskPageContent, so none is exposed to the two accepted hydration holes the
spec records (an archived task at /t/:taskId, and
use-external-vcs-file-link.ts's already-shipped gated useWorkspaceMRs(null)).
The builder must not attempt to work around either hole from the three production
files in scope; in particular the sidebar must not gain its own useWorkspaceMRs
call, which AC7 forbids.
Page object. Extend e2e/pages/app-sidebar-page.ts (already anchored at
app-sidebar) with a row-and-badge accessor. Do not add a parallel sidebar page
object; e2e/pages/sidebar-tasks-page.ts is the shape to follow.
Verification Results
Pending. On completion, synchronize this section with each task's ## Results:
record exact commands and outcomes/counts, generated artifact paths, and
cleanup/teardown evidence.
Implementation Waves And Parallel Candidates
The three production tasks touch disjoint files and share no schema, migration, generated contract, lockfile, or package-wide config, so they are genuine parallel candidates. The default is sequential execution in the primary conversation. Waves do not authorize subagents; only the user may ask for them after selecting the implementation model.
Wave 1 (parallel candidates — user authorization required):
- [ ] [task-01-sidebar-mr-badge](task-01-sidebar-mr-badge.md)
- [ ] [task-02-tasks-row-mr-badge](task-02-tasks-row-mr-badge.md)
- [ ] [task-03-tasks-page-mr-hydration](task-03-tasks-page-mr-hydration.md)
Wave 2:
- [ ] [task-04-e2e-sidebar-badge](task-04-e2e-sidebar-badge.md) (needs 01)
- [ ] [task-05-e2e-tasks-list-badge](task-05-e2e-tasks-list-badge.md) (needs 02, 03)
Before any task, in a fresh worktree: cd apps && pnpm install --frozen-lockfile.
apps/node_modules/ is not shared across worktrees, and without it vitest,
eslint and the commit hooks all fail.
Open Questions
None. The spec settles badge ordering (PR then MR, DOM order, all three
surfaces), the /tasks flag question (one flag, renamed showContributions),
coarse-pointer behaviour (the Radix tooltip is inherited unchanged), and the
sidebar prInfo question (deliberately GitHub-only; an MR analogue is a backend
projection change and is out of scope).