Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
50 KiB
status, created, owner
| status | created | owner |
|---|---|---|
| draft | 2026-08-12 | tbd |
GitLab MR Badge on the Sidebar and Tasks-List Rows
Why
A task whose work lives on GitHub shows a pull-request badge on all three
task-row surfaces: the Kanban card, the app sidebar's task list, and the
/tasks page rows. A task whose work lives on GitLab shows a merge-request
badge on exactly one of them, the Kanban card. On the other two a GitLab-only
task looks like a task with no remote contribution at all.
This is not a regression. docs/specs/gitlab-integration/spec.md scoped the MR
badge to the Kanban card and to nothing else, and the code matches that scope
exactly: MRTaskIcon has one call site,
apps/web/components/kanban-card-content.tsx:152. This spec adds the two
missing surfaces. It is a sibling of the shipped
gitlab-mr-status-chip card and belongs to the
same GitLab-parity family.
What
- A task with at least one linked GitLab merge request SHALL render the existing
MRTaskIconbadge on the app sidebar's task row and on the/taskspage's rich task row, in addition to the Kanban card it already renders on. - The badge SHALL be the existing
MRTaskIconcomponent, rendered unmodified. No new glyph, no new colour rule, no new status derivation, no newdata-testid, and no new user-facing copy are introduced by this feature. - On every surface that shows both, the pull-request badge SHALL precede the merge-request badge in DOM order.
- The
/taskspage SHALL become an owner of GitLab MR hydration for the active workspace, because it is the surface whose own row renders the badge. The sidebar SHALL NOT become one; see Hydration ownership. - No file under
apps/web/components/github/changes.apps/web/components/gitlab/mr-task-icon.tsxdoes not change either.PRTaskIcon's andMRTaskIcon's rendered output SHALL be identical before and after this feature.
Data model
No new persistent state, no new API, no new store slice, no new WS action.
The badge reads the TaskMR rows already cached at
taskMRs.byWorkspaceId[workspaceId][taskId]
(apps/web/lib/state/slices/gitlab/gitlab-slice.ts), typed at
apps/web/lib/types/gitlab.ts.
The two provider store shapes are not symmetric, and this asymmetry is the first implementation trap:
| GitHub | GitLab | |
|---|---|---|
| store path | taskPRs.byTaskId[taskId] |
taskMRs.byWorkspaceId[workspaceId][taskId] |
| accessor | direct useAppStore selector |
useTaskMRs(taskId) |
| workspace scoping | none | active workspace only |
useTaskMRs(taskId) (apps/web/hooks/domains/gitlab/use-task-mr.ts:60) resolves
workspaces.activeId itself and returns the module-level, referentially stable
EMPTY_MRS when there is nothing to show. A hand-rolled selector that returns a
fresh [] causes an infinite re-render loop; the constant's own comment records
this.
There is a second GitHub-only source the sidebar row already uses and which
has no GitLab counterpart: TaskItem's prInfo prop. It is not prototype-only
data. task-session-sidebar-item.ts:109 derives it from the bounded task status
projection TaskStatusSummary.pull_request
(apps/web/lib/types/task-status-summary.ts), which carries number, state,
aggregate_state and url and no merge_request field at all.
sidebar-mock-data.ts supplies the same shape for the sidebar prototype. A
GitLab analogue of that path is therefore a backend projection change, not a
frontend one; see Out of scope.
Surfaces and mount points
Exactly three existing production React files change, plus at most one new sibling component file if the badge-pair extraction reads better out-of-line. Constraints owns that allowance and states where the new file may live; this section is not a second, tighter fence. Line numbers drift; the anchors are the named symbols.
- App sidebar row —
apps/web/components/task/task-item.tsx, the localTaskPRIconcomponent and its single call site insideTaskItemContent's title row. /tasksrich row —apps/web/app/tasks/rich-task-list-row.tsx,PrimaryTaskLine./taskspage hydration —apps/web/app/tasks/tasks-page-client.tsx,TasksPageClient.
The Kanban card (kanban-card-content.tsx) is already correct and is the
reference shape. It does not change.
The sidebar early-return trap
TaskPRIcon today is:
const hasStorePR = useAppStore((s) => !!taskId && (s.taskPRs.byTaskId[taskId]?.length ?? 0) > 0);
if (hasStorePR) return <PRTaskIcon taskId={taskId!} />;
if (!prInfo) return null;
// ... summary-projection fallback badge
Both returns exit the component. An MRTaskIcon appended after them is
unreachable for any task that has a PR, and unreachable for any task that has
neither a PR nor prInfo — that is, for the GitLab-only task this feature
exists to serve. The component SHALL be restructured so the PR branch and the MR
branch are siblings, not alternatives.
taskId is optional on this component (taskId?: string), so hasStorePR is
not a usable guard for the MR branch: it is false both when there is no task id
and when there is a task id with no PRs. The MR branch needs its own taskId
truthiness guard.
Selection and ordering
- Badge order is fixed and named: pull request, then merge request. It is
DOM order within the row's title line, not visual order produced by CSS, and it
is identical on all three surfaces. The Kanban card already ships this order
(
PRTaskIconthenMRTaskIcon), andmr-task-card-badge.spec.tsalready asserts it there; the two new surfaces adopt it rather than inventing one. - Sidebar title row, full order: title, autopilot icon, pinned icon,
PR badge, MR badge, issue badge, agent-error icon, remote-cloud icon,
archived chip. The MR badge sits immediately after the PR badge and before
IssueTaskIcon: the two contribution badges group together, and the issue badge (a different entity class) follows them. /tasksrich row, full order: state icon, title, PR badge, MR badge, archived badge.- Which MR the badge represents, and the multi-MR count, are decided by
MRTaskIconand are unchanged: one MR rendersSingleMRIcon, two or more renderMultiMRIconwithaggregateMRStatusColorand a count. That includesaggregateMRStatusColor's first-in-array-order tie, which this feature freezes rather than touches (it is re-tuning the colour priority; see Out of scope). There is no per-surface selection rule and this spec SHALL NOT introduce one.
Hydration ownership
useWorkspaceMRs(workspaceId) is the only thing that puts MR rows in the store.
Its four existing call sites are kanban-board.tsx (unconditional on
workspaceState.activeId), mr-topbar-button.tsx, use-mr-key-to-tasks.ts and
use-external-vcs-file-link.ts. Neither the sidebar nor /tasks is among them.
Consolidating these behind one provider-level owner is an already-declared
out-of-scope card in the sibling spec, and this feature does not take it on.
/tasks becomes an owner. The gated form is forbidden.
TasksPageClient already owns its GitHub hydration, gated on the same setting
that gates the badge:
useWorkspacePRs(showTaskDetails ? s.activeWorkspaceId : null);
Mirroring that expression for GitLab is a cross-surface data-loss bug, and this is the second implementation trap. The two hooks do not have the same null behaviour:
useWorkspacePRs(null)clears a local ref and touches no store state.useWorkspaceMRs(null)callsresetTaskMRs()with no argument, andresetTaskMRswith no argument assignstaskMRs.byWorkspaceId = {}— every workspace, wiped.
Because AppSidebar is mounted in the root layout (app/layout.tsx) and is
therefore on screen on /tasks, the gated form would blank the sidebar's MR
badges (and any other MR consumer's data) for the entire time a user sits on
/tasks with task details switched off, and would keep re-wiping on every
render pass that re-runs the effect. The wipe is not confined to the page that
caused it.
The /tasks page SHALL therefore call useWorkspaceMRs(s.activeWorkspaceId)
unconditionally, matching kanban-board.tsx's existing unconditional call
rather than useWorkspacePRs's gated one. The cost is one GET per workspace
per effect run, which is not wasted even when task details are off: the sidebar
is mounted on that route and renders MR badges from the same store.
Note "per effect run", not "per mount": the app root renders inside
<StrictMode> (apps/web/src/main.tsx), and the hook's cleanup clears
fetchedRef, so React's development-only effect replay issues a second
GET on first mount. That is existing behaviour shared with all four current
call sites, it does not occur in a production build, and no acceptance criterion
here asserts a request count. It is stated so nobody writes a call-count
assertion against the wrong number.
s.activeWorkspaceId can itself be null, and the unconditional form then
passes null and clears the cache. That is correct and is not the case this
rule forbids: with no active workspace, useTaskMRs returns EMPTY_MRS for
every task and no surface can render an MR badge, so there is nothing for the
clear to take away. It is also exactly what kanban-board.tsx already does. The
forbidden form is different in kind — it passes null while a workspace is
active and other surfaces are displaying that workspace's MRs.
The sidebar does not become an owner
The sidebar SHALL read the store and hydrate nothing, exactly as its PR badge
does today. Consequently the sidebar MR badge is visible only when some other
mounted surface has hydrated the active workspace's MRs. That is an observation
about today's mounts, not a guarantee this spec pins, and the coverage is
not uniform across routes — see the two holes named below. No acceptance
criterion depends on an ambient hydration path: unit tests seed the store
directly, and the E2E scenarios below each name a route with a stated owner
(/ via kanban-board.tsx, /tasks via this feature).
This is a deliberate parity choice. The GitHub badge on the sidebar has the same property and the same zero owners; giving GitLab its own fifth call site would make the two providers diverge in the one place a consolidation card has to touch.
Two routes where the sidebar MR badge does not render, and why both are accepted
Both are pre-existing properties of code this feature does not touch. They are named here because a reader would otherwise assume the sidebar badge works everywhere the sidebar does, and because the second one is a live instance of the very pattern the previous section forbids.
Hole 1 — an archived task opened directly. MRTopbarButton is the hydration
owner on the task-detail routes, but on desktop it sits inside {!isArchived && (…)} in components/task/task-top-bar.tsx. An archived task opened at
/t/:taskId therefore mounts no MR hydration owner, and the sidebar shows no MR
badge for any task until the user navigates to a route that has one.
Hole 2 — a task with no GitLab repository, on the task-detail route.
useExternalVcsFileLinkHydration
(hooks/domains/workspace/use-external-vcs-file-link.ts) calls
useWorkspaceMRs(providers.has("gitlab") ? workspaceId : null);
and components/task/task-page-content.tsx invokes it unconditionally.
TaskPageContent is rendered by KanbanTaskShell for /t/:taskId and
/tasks/:id. For a task whose linked repositories carry no gitlab provider,
that expression passes null while a workspace is active and the sidebar is
displaying that workspace's MRs — which is exactly the shape "/tasks becomes an
owner" forbids, already shipped, on a sidebar-bearing route. It reaches
resetTaskMRs() with no argument and clears taskMRs.byWorkspaceId for every
workspace. MRTopbarButton on the same route calls useWorkspaceMRs(workspaceId)
and re-fetches, so the two race and the sidebar's MR badges are
nondeterministic on that route for a GitHub-only or repository-less task.
Both are accepted for this card, and no acceptance criterion is weakened by them. Three reasons, stated so the decision is not re-litigated at Build:
- Every AC that observes a badge is conditioned on the store already holding MRs — AC1 reads "where … the store holds at least one MR", not "wherever the task has an MR upstream". A wipe means the store does not hold them, so AC1 is satisfied vacuously rather than violated. The contract is about rendering what the store has, and that is what the builder must implement.
- The fix for Hole 2 is not a fix to this feature's code. It is either scoping
useWorkspaceMRs's reset to the workspace it last fetched, or removing the gate at that call site — both changes to a hook four other call sites depend on, including ones that rely on the full clear at sign-out. That is already an explicit Out-of-scope entry below, and this section now names the call site that makes it urgent. - Neither hole is reachable from any scenario in this spec. Every E2E scenario
(E1 to E7) runs on
/or/tasks, both of which have an unconditional owner and neither of which mountsTaskPageContent. The unit scenarios (U1 to U11) render components directly and mount no route at all.
The builder SHALL NOT attempt to work around either hole from the three
production files in scope — in particular, the sidebar SHALL NOT gain its own
useWorkspaceMRs call to compensate, which AC7 already forbids.
Nil, empty, and error behaviour
State each of these explicitly rather than by omission:
-
No linked MRs.
useTaskMRsreturnsEMPTY_MRS,MRTaskIconreturnsnull, and the row renders no MR element. The MR badge SHALL contribute no element of its own to the row in this case: no wrapper<span>, no placeholder, and no conditional spacing that exists only to reserve room for the absent badge. This is the observable form of the requirement, and AC6 is its test. (An earlier draft said "byte-identical layout", which named no baseline and no mechanism; no snapshot baseline is introduced by this feature.)This rule bounds what the MR badge adds when there are no MRs. It does not forbid fixed spacing on the badge container. The
/tasksbadge wrapper gains an unconditionalgapclass (specified under Responsive and coarse-pointer behaviour) so that two present badges are separated. That class is applied whether or not an MR badge renders, so in the no-MR case it changes nothing and adds no element. AC6 and AC23 are therefore not in tension, and a builder SHALL NOT read AC6 as a reason to leave two badges unseparated. AC23 requires that class unconditionally precisely so that satisfying it can never add an element in the no-MR case that AC6 forbids. -
No
taskId. The sidebar'sTaskItemacceptstaskId?: string. When it is absent the MR branch renders nothing and SHALL NOT callMRTaskIconwith an empty-string orundefinedid. -
No active workspace (
workspaces.activeId === null).useTaskMRsreturnsEMPTY_MRS; no badge, no error, no fetch. -
Task outside the active workspace.
taskMRsis workspace-keyed, so a row for a task belonging to another workspace renders no MR badge. This is inherited fromuseTaskMRsand is not corrected here. -
Corrupted store entry (a non-array value under
byWorkspaceId[ws][taskId], e.g. from a partial hydration).MRTaskIcon's existingArray.isArrayguard returnsnull. The new call sites SHALL NOT add a second, differently-shaped guard in front of it. -
Hydration fetch fails.
useWorkspaceMRsswallows the error and clears itsfetchedRefso a later workspace switch can retry. No badge, no toast, no error affordance, no retry loop is added by this feature. -
Workspace switch.
useWorkspaceMRscallsresetTaskMRs(workspaceId)for the incoming workspace before fetching, so between the switch and the response the new workspace's rows show no MR badge. That transient blank is existing behaviour and is accepted, not fixed. -
Task has a PR but no MR, or an MR but no PR. Exactly one badge renders. The row SHALL NOT reserve space for the absent one.
Boundaries and defaults
- Zero / one / two-or-more MRs. Zero renders nothing. One renders
SingleMRIconwithdata-mr-count="1"anddata-mr-state. Two or more renderMultiMRIconwithdata-mr-count="<n>", the aggregate colour and a numeric suffix, and nodata-mr-state. Inherited fromMRTaskIcon; no per-surface cap on the count and no overflow chip is introduced. - Archived tasks. The sidebar's archived rows and the
/taskslist with "Show archived" on both render through the same components, and the MR badge SHALL behave there exactly as the store-backed PR badge does today: present when the store holds MRs for the task, absent otherwise. Archival is not a suppression condition for either badge. (Note the asymmetry this inherits: the archived sidebar item builder setsprInfo: undefined, so the summary fallback PR badge is suppressed on archived rows while the store-backed badges are not. This feature neither copies nor corrects that.) - Terminal MRs. A task whose only MR is merged, closed or locked still shows
the badge, coloured by
getMRStatusColor. Unlike the status chip, the badge has no open-only filter, and this feature does not add one. - Rows with no
taskId—taskIdis optional onTaskItem(taskId?: string), and AC5 is grounded in that optionality, not in a specific caller. Stated precisely, because it is easy to assume otherwise: every production caller today passes ataskId.TaskItem's only production importer iscomponents/task/task-switcher-row.tsx, whose single mount passestaskId={task.id}. The sidebar's primary-nav and new-task rows renderAppSidebarNavItemandAppSidebarNewTaskItem, which are different components and do not renderTaskItemat all. So AC5 is a defensive contract on the optional prop rather than a rule about a live surface: it keeps the MR branch from being written in a way that breaks the moment an id-less caller appears, and U4 exercises it synthetically. Do not go looking for id-less rows in the sidebar; there are none, and their absence is not a missed surface. showContributionshas no default. It is a required prop onPrimaryTaskLine, passed explicitlytrueby the rich path andfalseby the compact path. It SHALL NOT be given a default value.
Concurrency and idempotency
- Two surfaces, one task, same route. The sidebar and the board (or the
/tasksrow) both mount a badge for the same task id. Both read the same store entry, so they always agree. Both emit the samedata-testid="mr-task-icon-<taskId>"; see Duplicate mounts below. - Two hydration owners mounted at once (board plus
/tasksis not reachable today, but sidebar-bearing routes each mount one andmr-topbar-buttoncan co-mount withuse-external-vcs-file-link). EachuseWorkspaceMRsinstance keeps its ownfetchedRef, so each issues its ownGET.setTaskMRsreplaces the whole per-workspace map, the request is idempotent, and the outcome converges to the last response regardless of arrival order. No coordination is added. - A store write while a row is mounted (
setTaskMRfrom a link, orremoveTaskMRfrom an unlink) re-renders every mounted badge for that task in the same commit. There is no per-surface cache and therefore no staleness window between surfaces. - Re-render cost. The badge adds exactly one
useTaskMRssubscription per rendered row. Rows for tasks with no MRs SHALL NOT re-render on unrelatedtaskMRswrites, which is what the stableEMPTY_MRSidentity buys. Any selector introduced by this feature SHALL NOT allocate a new array or object.
Responsive and coarse-pointer behaviour
MRTaskIconuses a plain RadixTooltipon both its single-MR and multi-MR variants. That disclosure behaviour is inherited unchanged: nouseTouchDrawervariant, nouseHoverPopoverwiring, no tap-to-open handling is added on the new surfaces. The badge already ships this exact behaviour on the Kanban card, including on a mobile viewport (e2e/tests/gitlab/mobile-mr-task-card-badge.spec.ts), and this feature changes neither the component nor the trade-off. Upgrading the badge's coarse-pointer disclosure is its own card; see Out of scope.- The badge SHALL NOT be gated on any Tailwind width class. It renders at every viewport width on both new surfaces, exactly as the PR badge beside it does.
- On a mobile viewport the added badge SHALL NOT introduce horizontal document
overflow on the
/taskslist. Both new badges sit inmin-w-0flex rows whose siblings already truncate. - The
/tasksrow is a click target. The PR badge is already wrapped in a<span>that stopsclickandpointerdownpropagation so hovering or tapping the badge cannot navigate the row. The MR badge SHALL sit inside that same wrapper rather than get a second copy of it, so the two badges cannot drift apart on interaction behaviour. - That wrapper carries no
classNameat all today, because it has only ever held a single child, so the row's owngap-2was the only separation it needed. Two children inside it would render with no separation whatsoever. The wrapper SHALL therefore be giveninline-flex items-center gap-1.gap-1is not a new number: it is what both surfaces that already render badges side by side use — the Kanban card'skanban-card-title-row(flex items-center gap-1 min-w-0, wherePRTaskIconandMRTaskIconare direct siblings) and the sidebar title row (flex items-center gap-1 min-w-0). Matching it keeps PR-to-MR separation identical on all three surfaces. Two things this deliberately does NOT do: it does not addshrink-0(bothPRTaskIconandMRTaskIconalready carryshrink-0on their own roots), and it does not alter the row's existinggap-2between the title and the badge wrapper. The class is unconditional, so it reserves no space for an absent badge; see Nil, empty, and error behaviour for why that satisfies AC6. AC23 is this rule's test. It is a separate criterion rather than a clause on AC9 because AC9 observes only which elements sit inside the wrapper and in what order, so it cannot fail on an unclassed wrapper. - The sidebar row's badges are not wrapped today and SHALL NOT become wrapped: clicking a sidebar row selects the task, which is the desired outcome from anywhere on the row including the badges.
Accessibility and duplicate mounts
MRTaskIconcarries noaria-labeland this feature adds none. Doing so would be new user-facing copy on a component whose Kanban mount would then disagree with its two new mounts, and the state is already named in the tooltip text (getMRTooltip), not conveyed by colour alone.MRTaskIcon'sdata-testidismr-task-icon-<taskId>, and the same id is now emitted by up to two mounted rows for one task on one route (sidebar plus board, or sidebar plus/tasksrow). Every Playwright accessor for it therefore SHALL be scoped to a container: the sidebar root (app-sidebar) or the specific row (tasks-list-row,task-card-<id>). No accessor may resolvemr-task-icon-*globally, and none may paper over the duplicate with.first(). The identical hazard already exists forpr-task-icon-*and is recorded ine2e/tests/pr/pr-status-badge.spec.ts.- The two badges SHALL remain separate elements with separate tooltips. They are not merged into a combined contribution badge.
Prop naming
PrimaryTaskLine's existing showPullRequest: boolean gates the badge slot on
the /tasks row. It is file-local (declared and passed only within
rich-task-list-row.tsx) and is passed true from the rich content path and
false from the compact path.
One flag SHALL gate both badges, and it SHALL be renamed to
showContributions. There is no second flag and no independent per-provider
toggle: a user who turns on "Show task details" is asking for the row's remote
contributions, not for GitHub's specifically. Leaving a flag named
showPullRequest in control of a merge-request badge is the kind of stale name
that the next reader has to disprove.
Internationalization
This feature introduces no user-facing string literal. MRTaskIcon renders
MR-derived data (getMRTooltip builds from mr_iid, mr_title, state,
approval_state, pipeline_state), which the component already documents as
non-translatable domain data with the same precedent as GitHub's getPRTooltip.
Consequences, stated so they are not re-derived:
apps/web/src/locales/en/*.jsonSHALL NOT gain a key.apps/web/eslint.i18n.options.mjsSHALL NOT gain an entry. Of the three touched files, two are already oni18nGuardFiles—app/tasks/rich-task-list-row.tsxandapp/tasks/tasks-page-client.tsx— soi18next/no-literal-stringis a whole-file error on both, not merely a changed-lines ratchet.components/task/task-item.tsxis not on the list, so only the ratchet's changed-lines judgement applies there. This feature adds no literal to any of the three, so the append-in-the-same-PR rule is not triggered on any of them. Adding an unrelated file to the list is a separate change and SHALL NOT be smuggled in here. (components/kanban-card-content.tsxis also on the list, but it is not one of the touched files — it does not change; see Surfaces and mount points.)pnpm run i18n:checkandpnpm run i18n:ratchetSHALL pass, and the ratchet's changed-lines judgement SHALL find nothing on the lines this feature touches.
Failure modes
| Failure | Observable behaviour | Contract |
|---|---|---|
| GitLab not configured for the workspace | listWorkspaceTaskMRs returns an empty map; no badge anywhere |
No error UI, no banner, no console noise |
| MR hydration request fails | No MR badge; PR badge unaffected | Row renders, retry only on a later workspace switch or remount |
| Store holds a non-array for the task | No MR badge | MRTaskIcon's existing guard; no crash |
| Task's only MR is unlinked while the row is mounted | Badge disappears in the same commit | removeTaskMR filters in place; no stale badge |
| Task's last MR merges | Badge stays, coloured purple (merged) |
Terminal MRs remain visible on the badge, unlike the status chip |
| Sidebar mounted on a route with no MR hydration owner | No MR badge for a GitLab-only task | Stated, accepted, not an AC (see Hydration ownership) |
Archived task opened at /t/:taskId (desktop) |
No MR badge in the sidebar, because MRTopbarButton is inside {!isArchived && …} and no owner mounts |
Hole 1: stated, accepted, not an AC; pre-existing, no file in scope changes it |
Task with no gitlab-provider repository opened at /t/:taskId or /tasks/:id |
Sidebar MR badges nondeterministic for every task: useExternalVcsFileLinkHydration passes null and wipes byWorkspaceId while MRTopbarButton re-fetches |
Hole 2: stated, accepted, not an AC. AC1 is conditioned on the store holding MRs, so it is satisfied vacuously. Fix belongs to the useWorkspaceMRs(null) reset-scoping card (Out of scope) |
Acceptance criteria
Sidebar row:
- AC1 — Where a sidebar task row has a
taskIdand the store holds at least one MR for that task in the active workspace, the row SHALL renderMRTaskIconfor that task id. - AC2 — Where a sidebar task row's task has both a store PR and at least one MR, the row SHALL render both badges, with the PR badge preceding the MR badge in DOM order.
- AC3 — Where a sidebar task row's task has an MR and no PR (neither a store
PR nor a
prInfosummary projection), the row SHALL render the MR badge. - AC4 — Where a sidebar task row's task has a
prInfosummary projection, no store PR, and at least one MR, the row SHALL render theprInfofallback PR badge followed by the MR badge. - AC5 — When a sidebar task row is rendered without a
taskId, the row SHALL render no MR badge and SHALL NOT invokeMRTaskIcon. - AC6 — Where a sidebar task row's task has no MRs, the row's rendered output
SHALL contain no element whose
data-testidstarts withmr-task-icon-. - AC7 — The sidebar SHALL NOT call
useWorkspaceMRs, and no file underapps/web/components/task/orapps/web/components/app-sidebar/SHALL import it.
/tasks rows:
- AC8 — Where the
/tasksrow is rendered with contributions shown and the store holds at least one MR for the task in the active workspace, the row SHALL renderMRTaskIconfor that task id. - AC9 — Where the
/tasksrow is rendered with contributions shown and the task has both a PR and an MR, both badges SHALL render, PR before MR in DOM order, and both SHALL be inside the single propagation-stopping wrapper. - AC10 — While the
/tasksrow is rendered with contributions hidden (the compact path), the row SHALL render neither the PR badge nor the MR badge. - AC11 — The single boolean that gates both badges SHALL be named
showContributions; noshowPullRequestidentifier SHALL remain inapps/web/app/tasks/rich-task-list-row.tsx. - AC23 — Where the
/tasksrow is rendered with contributions shown, the single propagation-stopping wrapper holding the badges SHALL carry the classesinline-flex items-center gap-1, and SHALL carry them unconditionally: the classes are present whether or not an MR badge renders, and SHALL NOT be applied only when two badges are present. This pins the wrapper class stated in Responsive and coarse-pointer behaviour, which AC9 alone does not observe: AC9 constrains only which elements sit inside the wrapper and in what order, so an implementation leaving the wrapper unclassed would satisfy every other criterion while shipping two touching badges on/tasksand separated badges on the other two surfaces. The unconditionality is what keeps this compatible with AC6 rather than in tension with it; see Nil, empty, and error behaviour.
/tasks hydration:
- AC12 — When
TasksPageClientrenders with an active workspace, it SHALL calluseWorkspaceMRswith that workspace id, unconditionally, and SHALL NOT gate the argument ontasksListShowDetailsor on any other setting. - AC13 — If
useWorkspaceMRsis invoked withnullwhile another workspace's MRs are cached, then every workspace's entry undertaskMRs.byWorkspaceIdSHALL be cleared. This is the existing behaviour AC12 exists to avoid triggering, and SHALL be pinned by a test that seeds two workspaces. - AC21 — Where
TasksPageClientrenders with no active workspace, passingnulltouseWorkspaceMRsis permitted, and no badge SHALL render on any surface for the duration.
Non-regression:
- AC14 — No file under
apps/web/components/github/SHALL be modified. - AC15 —
apps/web/components/gitlab/mr-task-icon.tsxSHALL NOT be modified. The badge is consumed exactly as it ships. - AC16 — The Kanban card's rendered badge output SHALL be unchanged, and
e2e/tests/gitlab/mr-task-card-badge.spec.tsSHALL pass unmodified. - AC17 — No
apps/web/src/locales/**file and noeslint.i18n.options.mjsentry SHALL change;pnpm run i18n:checkandpnpm run i18n:ratchetSHALL pass. - AC18 — No selector or hook added by this feature SHALL return a freshly
allocated array or object; MR reads SHALL go through
useTaskMRs.
Cross-cutting:
- AC19 — Every Playwright accessor introduced for
mr-task-icon-*SHALL be scoped toapp-sidebar,tasks-list-row, ortask-card-<id>, and SHALL NOT use.first()to disambiguate duplicates. - AC20 — On a mobile viewport, a
/tasksrow carrying both badges SHALL NOT cause horizontal document overflow. - AC22 — Where a sidebar task row's task has at least one MR and the row also
renders the issue badge (
issueInfopresent), the MR badge SHALL precede the issue badge in DOM order. This pins the "PR, MR, issue" grouping stated in Selection and ordering, which AC2 alone does not observe: AC2 constrains only PR-versus-MR, so an implementation appending the MR badge afterIssueTaskIconwould satisfy every other criterion while violating the stated row order.
Scenarios
Scenarios are numbered U<n> for unit and E<n> for E2E, in two independent
sequences. The two kinds are deliberately not numbered in one run: an earlier
draft numbered them continuously, and inserting a unit scenario silently shifted
every E2E number and left a stale cross-reference behind. Cite scenarios by these
prefixed ids, never by bare number.
Unit (vitest) — U1 to U11
- U1 — Sidebar, PR and MR together — seed
taskPRs.byTaskId["t1"]with one PR andtaskMRs.byWorkspaceId["ws1"]["t1"]with one MR,workspaces.activeId = "ws1"; renderTaskItemwithtaskId="t1". Bothpr-task-icon-t1andmr-task-icon-t1are present, andcompareDocumentPositionputs the PR first. (AC2) - U2 — Sidebar, MR only — no PR, no
prInfo, one MR.mr-task-icon-t1present,pr-task-icon-t1absent. This is the case the early return currently makes impossible. (AC3) - U3 — Sidebar,
prInfofallback plus MR — no store PR,prInfo={{number: 7, state: "Open"}}, one MR. Both render, PR first. (AC4) - U4 — Sidebar, no
taskId— renderTaskItemwithouttaskId, with MRs seeded for some other id. Assert both of AC5's clauses, because they are not equivalent and the weaker one alone is satisfied by code that violates the AC: (a) nomr-task-icon-*element is present, and (b)MRTaskIconis not invoked at all, asserted by mocking the module and checking zero calls. Clause (b) is the load-bearing one. An unguarded<MRTaskIcon taskId={taskId!} />— the same non-null-assertion shape the PR branch beside it already uses — would invoke the component withundefined, hituseTaskMRs' internal guard, rendernull, and pass clause (a) while breaking AC5 and adding the store subscription AC18 exists to prevent. Clause (b) needs a spy, and no in-repo precedent for one exists — write it fresh.components/kanban-card-content.test.tsxis 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. No test underapps/web/components/mocks a component module withvi.fn()and then asserts a call count, so there is nothing to copy for the assertion itself. The required shape is avi.fn()-backed component mock plus a zero-call assertion — mock the module with something of the formvi.mock("@/components/gitlab/mr-task-icon", () => ({ MRTaskIcon: vi.fn(() => null) })), import the mocked symbol, and assertexpect(MRTaskIcon).not.toHaveBeenCalled(). Copykanban-card-content.test.tsxfor thevi.mockplacement and factory shape; do NOT copy its stub body, which is what makes clause (b) unassertable. (AC5) - U5 — Sidebar, no MRs — PR only. No
mr-task-icon-*element. (AC6) - U6 —
/tasksrow, contributions shown — seeded PR and MR,showContributionstrue. Both badges render inside the single wrapper, PR first, and the wrapper carriesinline-flex items-center gap-1. (AC8, AC9, AC23) - U7 —
/tasksrow, contributions hidden — same store, compact path. Neither badge renders. (AC10) - U8 —
useWorkspaceMRs(null)wipes every workspace — seedbyWorkspaceId["ws-a"]andbyWorkspaceId["ws-b"], render the hook withnull, assert both are gone. Add toapps/web/hooks/domains/gitlab/use-task-mr.test.tsif not already covered there. (AC13) - U9 —
TasksPageClienthydration argument — withtasksListShowDetailsfalse and an active workspace,useWorkspaceMRsis called with the workspace id, notnull. (AC12) - U10 — Sidebar, MR before the issue badge — one MR seeded and
issueInfo={{url: "…", number: 42}}passed. Bothmr-task-icon-t1and the issue badge render, andcompareDocumentPositionputs the MR badge first. Run it with a store PR present as well, so the assertion covers the full "PR, MR, issue" order rather than just the adjacent pair. (AC22) - U11 — No active workspace, no badge — seed
taskMRs.byWorkspaceId["ws1"]["t1"]with one MR but setworkspaces.activeId = null; renderTaskItemwithtaskId="t1". Nomr-task-icon-*element is present. This observes AC21's second clause, which no other scenario reaches: AC21 is otherwise contract text resting onuseTaskMRsreturningEMPTY_MRSfor a null workspace, with nothing pinning it. (AC21)
E2E (Playwright, required)
apps/web/** is touched and none of the files are on the Testing step's
exemption allowlist, so E2E is required. 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: an auto-started
session moves the card out from under the assertion.
Where the seed helpers come from, stated so it is not invented at build time.
nextMRIID, seedMR, linkMR, ensureGitLabConfigured and seedBoardTask are
file-local and unexported in mr-task-card-badge.spec.ts. Each new spec file
SHALL therefore define its own local copies, seeded from that file's versions,
and SHALL NOT import them. mr-task-card-badge.spec.ts is not edited to export
them, which is what keeps AC16 ("passes unmodified") literally true. Three
reasons this is duplication rather than extraction:
- It is the established in-repo precedent:
mobile-mr-task-card-badge.spec.tsalready writes its ownseedBoardTaskWithMRand imports onlyGITLAB_HOSTandGITLAB_PROJECTfrome2e/helpers/gitlab.ts. - It does not trip lint, because ESLint rules are per-file. An earlier draft
justified this by claiming
e2e/**sits in theignoresof the rule block carryingsonarjs/no-identical-functions; that is wrong, and the correct mechanism matters because it is narrower. Inapps/web/eslint.config.mjs: the block carrying"sonarjs/no-identical-functions": "warn"has nofilesand noignores(it is the global block, so it applies toe2e/**too); theignores: ["**/*.test.ts", "**/*.test.tsx", "e2e/**"]belongs to the i18n guard block (files: i18nGuardFiles, rulei18next/no-literal-string); and thefiles: ["e2e/**/*.ts"]override disablesreact-hooks/*,max-lines,max-lines-per-functionandsonarjs/no-duplicate-string— but notsonarjs/no-identical-functions. The rule is therefore live on e2e files. It still cannot fire on this duplication, because ESLint only ever sees one file at a time and so cannot compare a helper in one spec file with its copy in another. Consequence the builder must respect: two identical functions within a single spec file DO warn, andpnpm --filter @kandev/web lintrunseslint --max-warnings 0, so that fails the build. Copy each helper across files freely; do not write two copies of the same helper inside one file. - Extracting them into
e2e/helpers/gitlab.tswould editmr-task-card-badge.spec.ts's imports, which AC16 forbids, and consolidating the GitLab e2e seed helpers is a separate cleanup; see Out of scope.
Shared constants are a different matter: GITLAB_HOST and GITLAB_PROJECT
SHALL be imported from e2e/helpers/gitlab.ts as the existing specs do, not
re-declared.
Where the mock GitHub PR comes from. Three scenarios (E3, E6, E7) need a task
carrying a PR and an MR, and the GitHub half has its own seeding path that is not
covered by the GitLab helpers above. It SHALL come from the existing mock-GitHub
API-client methods, not from a new fixture: apiClient.mockGitHubReset() and
apiClient.mockGitHubSetUser() to put the provider in a known state, then
apiClient.mockGitHubAssociateTaskPR() to attach a PR to the task.
mockGitHubAddPRs() is the wrong call and SHALL NOT be used for these three
scenarios. It is named here only so the mistake is not re-derived: it posts to
POST /api/v1/github/mock/prs, which seeds the mock provider's PR catalogue (what
pickers and listings read) and creates no task-to-PR association. A scenario that
uses it leaves taskPRs.byTaskId[taskId] empty, so PRTaskIcon renders nothing and
the PR-before-MR assertion that is the entire point of E3, E6 and E7 can never run.
mockGitHubAssociateTaskPR() posts to POST /api/v1/github/mock/task-prs and is the
call that populates the association the badge reads. No catalogue call is needed
alongside it; the association alone is sufficient.
Its required arguments are task_id, owner, repo, pr_number, pr_url,
pr_title, head_branch, base_branch and author_login; state is optional and
SHALL be passed explicitly so the seeded PR's state is not left implicit.
The reference is e2e/tests/gitlab/mr-task-card-badge.spec.ts's own test
"AC30/AC37: a linked PR and MR both render, PR before MR, and the badge tooltip names a non-open state". It is the exact precedent for all three scenarios: it calls
mockGitHubReset(), then mockGitHubSetUser("test-user"), then
mockGitHubAssociateTaskPR({ task_id, owner: "testorg", repo: "testrepo", pr_number: 900, pr_url, pr_title: "Companion PR", head_branch: "feat/companion", base_branch: "main", author_login: "test-user", state: "open" }), and asserts
PR-before-MR order by compareDocumentPosition on the Kanban card. Reading that file
does not modify it, so AC16 stays literally true.
Neither mr-task-card-badge.spec.ts nor e2e/tests/pr/pr-status-badge.spec.ts is
edited or imported from — the same local-copy rule as the GitLab helpers applies, and
for the same AC16 reason. This is stated because the spec is otherwise precise about
GitLab seeding and would otherwise leave three of seven E2E scenarios half-specified.
New file apps/web/e2e/tests/gitlab/mr-sidebar-badge.spec.ts:
- E1 — Sidebar shows the badge — on
/(board mounts the hydration owner), a task with one linked open MR. Scoped toapp-sidebar, the row located by[data-task-row-id="<taskId>"],mr-task-icon-<taskId>is visible withdata-mr-count="1"anddata-mr-state="open". (AC1, AC19) - E2 — Sidebar shows no badge without an MR — same route, a task with no
linked MR. Inside
app-sidebar,mr-task-icon-<taskId>has count 0. (AC6) - E3 — Sidebar shows PR before MR — a task with both a mock GitHub PR (seeded
per "Where the mock GitHub PR comes from" above) and a linked MR; inside the
sidebar row, both badges visible and PR precedes MR by
compareDocumentPosition. (AC2)
New file apps/web/e2e/tests/gitlab/mr-tasks-list-badge.spec.ts:
- E4 —
/tasksrich row shows the badge — navigate to/tasks, enable "Show task details" through thedisplay-buttonmenu astask-listing-view-preferences.spec.tsdoes, locate thetasks-list-rowby title, assertmr-task-icon-<taskId>inside that row. (AC8, AC19) - E5 —
/taskscompact row shows neither badge — same task, details off: inside the row, bothpr-task-icon-<taskId>andmr-task-icon-<taskId>have count 0. (AC10) - E6 —
/tasksrow shows PR before MR — task with both (mock GitHub PR seeded as above); inside the row, PR precedes MR. (AC9)
New file apps/web/e2e/tests/gitlab/mobile-mr-tasks-list-badge.spec.ts:
- E7 — Mobile
/tasksrow — mobile viewport, details on, task with both badges (mock GitHub PR seeded as above): the MR badge is visible andassertNoDocumentHorizontalOverflowpasses. Mirrorsmobile-mr-task-card-badge.spec.ts. (AC20)
All seven E2E scenarios (E1 to E7) run on / or /tasks. None mounts
TaskPageContent, so none is exposed to either hydration hole named above.
Out of scope
Each is a deliberate exclusion, not an oversight.
- A
merge_requestfield onTaskStatusSummary, and an MR analogue of the sidebar'sprInfofallback. The summary projection is produced by the backend and carriespull_requestonly. Adding a GitLab counterpart is a backend contract change (projection, WS delivery, bounded-summary spec) that cannot be tested fromapps/web/alone. Consequence, stated plainly: on a route with no MR hydration owner the sidebar can show a PR badge from the summary while showing no MR badge for the equivalent GitLab task. That gap is this card's, and it is left open on purpose. - An MR analogue of
TaskItemStatsRow's#<number>text. Same backend dependency, same reason. - Giving GitLab MR hydration a single provider-level owner. Already an out-of-scope card on gitlab-mr-status-chip; this feature adds one page-level owner where its own badge needs it and consolidates nothing.
- Making
useWorkspaceMRs(null)scope its reset to the workspace it last fetched. That is the correct long-term fix for the trap AC12 routes around, but it changes a hook four other call sites depend on, including two that rely on the full clear at sign-out. It needs a card that can test all of them. That card also owns Hole 2 above: the already-shipped gated call inhooks/domains/workspace/use-external-vcs-file-link.ts(useWorkspaceMRs(providers.has("gitlab") ? workspaceId : null)), which makes the sidebar MR badge nondeterministic on/t/:taskIdand/tasks/:idfor a task with no GitLab repository. Two candidate fixes exist — scope the reset, or drop the gate at that call site and let the hook no-op for a workspace with no MRs — and choosing between them requires testing all four call sites, so it is not decided here. - Fixing Hole 1 (desktop
MRTopbarButtonsitting inside{!isArchived && …}incomponents/task/task-top-bar.tsx, so an archived task's detail route mounts no MR hydration owner). Changing what the archived top bar renders is a task-detail-surface decision with its own parity questions on the GitHub side. - Consolidating the GitLab e2e seed helpers (
seedMR,linkMR,ensureGitLabConfigured,seedBoardTask) intoe2e/helpers/gitlab.ts. It is worth doing, and it would editmr-task-card-badge.spec.ts, which AC16 forbids here. It is a test-only cleanup card covering every GitLab spec at once, not a rider on this one. - Upgrading
MRTaskIcon's coarse-pointer disclosure to a drawer or a tap-to-open popover. The Radix tooltip is inherited as-is, and changing it would change the shipped Kanban badge on every viewport. - Re-tuning GitLab MR status colours, the multi-MR aggregate, or
aggregateMRStatusColor's array-order tie. Frozen verbatim. - Merging the PR and MR badges into one combined contribution badge, or collapsing them behind a count. Two providers, two badges.
- The Azure DevOps row badge.
components/azure-devops/is not touched, and the DOM-order ACs name only PR and MR. - Office task surfaces and any other task row that renders no
PRTaskIcontoday. Only the two surfaces named in Surfaces and mount points gain a badge. (The sidebar's archived rows are not excluded — they render through the sameTaskItemand are covered by the boundary rule above.) - Any change to which tasks appear in the sidebar or on
/tasks, to their ordering, grouping, filtering, or pagination.
Constraints
- Files SHALL stay at most 600 lines and functions at most 100 lines, both
warnwithskipBlankLinesandskipComments(apps/web/eslint.config.mjs), undereslint --max-warnings 0.task-item.tsxis ~660 raw lines today and already carries onemax-lines-per-functiondisable onTaskItem; the restructure SHALL NOT grow the file's warning count, and extracting the combined badge pair into its own small component is the expected shape. tasks-page-client.tsxhas the least headroom of the three, and it is the one file every builder must edit (AC12 adds an import plus a hook call). Measured by stripping blank and comment-only lines, it sits at roughly 597 of the 600 allowed — slightly tighter thantask-item.tsxat ~591, despite being the smaller file by raw count (642 vs 660). Treat that figure as approximate: it was derived by hand rather than from aneslintrun, so the true count may fall either side of the limit. Two lines of margin is not a margin. The builder SHALL therefore checkmax-lineson this file explicitly, before assuming a two-line edit is free. If the edit does push it over, the remedy is an extraction out oftasks-page-client.tsx— see the fence below, which permits a sibling underapp/tasks/for exactly this reason. Do not resolve it by adding aneslint-disable: the file carries none today, and silencing a size warning to land a two-line hook call trades a real signal for convenience.- Production React changes are confined to
components/task/task-item.tsx,app/tasks/rich-task-list-row.tsxandapp/tasks/tasks-page-client.tsx, plus at most one new sibling component file — undercomponents/task/for the badge-pair extraction, or underapp/tasks/iftasks-page-client.tsxneeds to shed lines per the bullet above. At most one new file in total, and the builder picks which of the two purposes it serves; if both pressures turn out to be real, that is a genuine second finding and routes back here rather than being absorbed by a second new file. All in-line shapes are permitted too and the choice is the builder's; a new file inherits every constraint the fence carries, in particular AC7 (it SHALL NOT importuseWorkspaceMRs) and AC18. The fence exists to stop the change spreading into other surfaces, not to force an extraction into an already-oversized file. Test files, E2E specs and page objects are required edits outside that fence and are not restricted by it. - The Playwright page object work SHALL extend
e2e/pages/app-sidebar-page.tswith a row-and-badge accessor rather than adding a parallel sidebar page object;sidebar-tasks-page.tsalready scopessidebar-task-itemand is the shape to follow. - Commands that SHALL pass:
cd apps/web && pnpm run typecheck,pnpm --filter @kandev/web lint,cd apps/web && pnpm run i18n:check, the vitest suites for the touched files, and the three new E2E specs.
Related
- gitlab-integration — the umbrella feature; its badge scope (Kanban card only) is what this card extends.
- gitlab-mr-status-chip — the sibling parity card; source of the hydration-ownership and colour-freeze exclusions this spec inherits.
apps/web/CLAUDE.md, "GitHub PR status UI" — the single-derivation invariant the MR side mirrors.