Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
76 KiB
status, created, owner
| status | created | owner |
|---|---|---|
| draft | 2026-08-11 | tbd |
GitLab MR Status Chip
Why
A task whose work lives on GitHub shows its pull request's CI, review and automation state as a chip in the chat status bar, so the user sees it without leaving the conversation. A task whose work lives on GitLab shows nothing there. GitLab users have to look up at the topbar button, or open the MR detail panel, to answer "did my pipeline pass". The GitLab merge-request equivalent of that chip does not exist.
What
- A task with at least one open linked GitLab merge request SHALL render an
MRStatusChipin the chat status bar and in the passthrough status row, beside the existing GitHub and Azure DevOps chips. - The chip SHALL show, at a glance: a GitLab merge glyph, a status glyph whose colour matches the MR's existing status colour everywhere else in the product, an MR count when more than one open MR is linked, and auto-fix / auto-merge badges when task MR automation is enabled.
- Hovering the chip on a fine-pointer device SHALL open the existing
MRCIPopoverbody (pass rate, pipeline stage groups, approval row, unresolved discussions, automation controls, merge action, footer). Tapping it on a coarse-pointer device SHALL open that same body inside a bottom-sheet drawer. - The chip SHALL derive its status from the one shared GitLab MR status
derivation, not a new private copy.
getMRStatusColorinapps/web/components/gitlab/mr-task-icon.tsxremains the single source of MR status colour, and its output for every input SHALL be byte-identical before and after this feature. - The chip SHALL NOT own any recurring fetch, polling, cache-warming or
background-sync responsibility. It issues exactly four kinds of request,
all of them existing behaviour it inherits rather than invents:
- the lazy read of the task's MR automation options, on mount;
- the GitLab connection-status read that
useGitLabAvailable()performs on mount, which the chip needs to sourcecanLink. This one has a cross-surface side effect and is stated in full under Sync and freshness; - the MR feedback read that
MRCIPopoveralready owns, only while a disclosure is open (this is what the popover'senabledprop gates; see API surface); - the link and unlink the user explicitly triggers.
Nothing else: no polling, no interval, no warmer, and specifically no
useWorkspaceMRs. See Sync and freshness.
- No file under
apps/web/components/github/changes.PRStatusChip's rendered output SHALL be identical before and after. - All chip copy SHALL go through
t()intoapps/web/src/locales/en/gitlab.json, including accessible labels.
Data model
No new persistent state. The chip reads the existing TaskMR rows
(gitlab_task_mrs, typed at apps/web/lib/types/gitlab.ts) already cached in
the store at taskMRs.byWorkspaceId[workspaceId][taskId], and the existing
TaskMRAutomationOptions cached at taskMRAutomation.byTaskId[taskId].
Fields the chip's own status derivation reads, and only these:
| Field | Type | Used for |
|---|---|---|
id |
string | React key, unlink target, final selection tiebreak |
state |
open | closed | merged | locked | string |
terminal filtering, merged / closed status |
pipeline_state |
"" | success | failure | pending | string |
failed / ready / running status |
approval_state |
"" | approved | pending | string |
ready / awaiting_approval status |
draft |
boolean | draft status |
mr_iid |
number | display, data-mr-iid, primary selection tiebreak |
project_path |
string | secondary selection tiebreak, automation-state lookup |
repository_id |
string? | automation-state lookup |
The chip SHALL NOT read unresolved_discussions for status. That field is
documented as populated only for automation-subscribed MRs, so using it would
make the chip report a different status for two otherwise identical MRs
depending on whether automation happens to be on. The popover body's own live
discussion fetch continues to render the unresolved count, unchanged.
The chip SHALL NOT read merge_status / detailed_merge_status for status.
See Out of scope.
Separately from status, the trigger's data-mr-ready-to-merge attribute and the
popover's merge button both come from the existing isMRReadyToMerge, which
does read unresolved_discussions and detailed_merge_status by design and
carries its own documented rationale. That helper is reused unchanged; the
restriction above applies to chip status only, and the two must not be
reconciled by editing isMRReadyToMerge.
API surface
No new endpoints, no new store actions, no new WS events.
Consumed, all existing:
useTaskMRs(taskId)(apps/web/hooks/domains/gitlab/use-task-mr.ts) for the linked MR list.useGitLabAvailable()for whether "link another merge request" is offered.useTaskMRAutomationOptions(taskId)(apps/web/hooks/domains/gitlab/use-task-mr-automation.ts) for automation flags andmr_states.findMRAutomationStateForMR/autoFixRoundForState(apps/web/lib/gitlab/mr-automation.ts) for round badges.MRCIPopover(apps/web/components/gitlab/mr-ci-popover.tsx) as the popover and drawer body, reused as-is with no new variant and no signature change. Its full prop contract is specified below; every prop it declares gets a named source, because one of them (enabled) decides whether the chip fetches.TaskMRLinkDialog(apps/web/components/gitlab/task-mr-link-dialog.tsx) for the link flow, with its existing{open, onOpenChange, taskId, workspaceId, taskRepositories, repositories}signature unchanged.DELETEtask-MR association viadeleteTaskMR+ theremoveTaskMRstore action, for the unlink control the popover header already renders.
MRCIPopover prop contract
MRCIPopover's declared signature is
{mr, taskId, enabled, canLink, onOpenDetailPanel?, onLink, onUnlink}. The chip
supplies every one of them from a named source:
| Prop | Type | Source |
|---|---|---|
mr |
TaskMR |
the acted-on MR: the live selected MR while the disclosure is closed, the frozen MR while it is open. Always the live store row for that id, never a snapshot. See Selection and ordering. |
taskId |
string |
the chip's own taskId |
enabled |
boolean |
the chip's own disclosure-open state (popoverOpen for the hover variant, drawerOpen for the drawer variant). false whenever the disclosure is closed. |
canLink |
boolean |
useGitLabAvailable() |
onOpenDetailPanel |
(() => void)? |
omitted. See Out of scope; the popover title then renders as static text. |
onLink |
() => void |
opens this chip's own TaskMRLinkDialog. See Link and unlink from the chip. |
onUnlink |
() => void |
a zero-argument closure that calls useUnlinkTaskMR(workspaceId) with the acted-on MR's association id captured from the enclosing render. See Link and unlink from the chip. |
enabled is the load-bearing one. MRCIPopover gates its live MR feedback
read on it (useMRFeedbackGated(mr, enabled)), which is the only reason the
"only network effect" clause in What can be stated at all. MRTopbarButton
passes enabled={popoverOpen} and the chip SHALL do the same for its own
disclosure. Passing a constant true is specifically forbidden: it would make
every mounted chip fetch MR feedback on mount, for every session view with a
linked open MR, and the What clause would be false.
Note the arity: the component declares onUnlink: () => void, not
onUnlink(associationId). The association id is closed over, not passed as an
argument, and which id gets closed over is fixed by the freeze rule rather than
left to the call site.
New exported helpers in apps/web/components/gitlab/mr-task-icon.tsx alongside
getMRStatusColor (the file the repo already designates as the single source of
GitLab MR status):
type MRChipStatus =
| "merged" | "closed" | "failed" | "draft"
| "ready" | "awaiting_approval" | "running" | "neutral"
mrChipStatus(mr: TaskMR): MRChipStatus
selectChipMR(mrs: TaskMR[]): TaskMR | null
aggregateMRChipStatus(mrs: TaskMR[]): MRChipStatus
getMRStatusColor(mr) SHALL be re-expressed as a lookup from
mrChipStatus(mr), so exactly one priority table exists.
aggregateMRChipStatus(mrs) SHALL be defined as the composition
mrChipStatus(selectChipMR(mrs)), returning neutral when selectChipMR
returns null. It is not an independent derivation and SHALL NOT have a tiebreak
rule of its own. This is what makes the trigger internally consistent: the
status the chip reports is, by construction, the status of the MR the chip
identifies and acts on. See Selection and ordering.
One extraction, in apps/web/hooks/domains/gitlab/use-task-mr.ts alongside the
other task-MR hooks:
useUnlinkTaskMR(workspaceId: string): (associationId: string) => Promise<void>
This is the unlink closure that today lives privately inside MRTopbarButton
(deleteTaskMR -> removeTaskMR, error toast using the existing
gitlab:failedToUnlinkMergeRequest and gitlab:theMergeRequestIsStillLinked
keys), moved verbatim. Both MRTopbarButton and the chip SHALL call it, and
MRTopbarButton's observable unlink behaviour SHALL be unchanged. The chip
SHALL NOT re-implement the closure: apps/web/eslint.config.mjs forbids
identical functions and 4+ duplicated strings, so a second copy is a lint
failure, not a style preference.
New testids, forming the chip's observable contract:
| testid | Element |
|---|---|
mr-status-chip |
the trigger button (both single and multi) |
mr-status-chip-drawer |
coarse-pointer drawer content |
mr-status-chip-drawer-close |
drawer close button |
mr-status-auto-fix-chip |
auto-fix round badge |
mr-status-auto-merge-chip |
auto-merge badge |
mr-status-glyph |
the status glyph, carrying data-status |
Trigger attributes, and which MR each one describes. This distinction is load-bearing while the disclosure is open; see Selection and ordering.
| Attribute | Value | Which MR |
|---|---|---|
data-status |
the MRChipStatus |
the live selected MR, always |
data-mr-count |
number of open linked MRs | n/a |
data-mr-iid |
mr_iid |
the acted-on MR |
data-mr-state |
state |
the acted-on MR |
data-mr-ready-to-merge |
"true" / "false" from the existing isMRReadyToMerge |
the acted-on MR |
data-selection-frozen |
"true" while the disclosure is open, "false" otherwise |
n/a |
The acted-on MR is the live selected MR while the disclosure is closed, and
the frozen MR while it is open. data-selection-frozen exists so that the two
regimes are distinguishable from the DOM rather than inferred; without it a test
cannot tell a frozen attribute from a stale one. All three per-MR attributes
switch regime together, so they always describe the same single MR as each
other and as the popover body.
data-mr-ready-to-merge is present on the single and the multi trigger alike,
always describing the acted-on MR. This is a deliberate divergence from
PRStatusChip, whose multi trigger omits the attribute: GitLab's chip always
has exactly one acted-on MR, because it has no multi-MR surface, so there is
always a well-defined MR for the attribute to describe.
Badge attributes. These sit on mr-status-auto-fix-chip, not on the trigger,
and they complete the observable contract — the tables above are the full
attribute inventory and nothing outside them is asserted by an AC:
| Attribute | Element | Value | Which MR |
|---|---|---|---|
data-auto-fix-exhausted |
mr-status-auto-fix-chip |
"true" / "false", from autoFixRoundForState(...).exhausted |
the badge-selected MR |
The badge-selected MR is a third selection, distinct from both the live and
the frozen selection: it is the most attention-worthy auto-fix round across the
open MRs (exhausted beats non-exhausted, then higher current, then the same
mr_iid / project_path / id order — see Selection and ordering). On a
single-MR chip all three coincide. On a multi-MR chip they need not.
State machine
mrChipStatus(mr) is a total function evaluated in this exact priority order,
first match wins. The colour column is what getMRStatusColor returns for that
MR today and MUST continue to return.
| # | Status | Condition | Colour | Glyph |
|---|---|---|---|---|
| 1 | merged |
state === "merged" |
text-purple-500 |
filled merge glyph |
| 2 | closed |
state === "closed" or state === "locked" |
text-muted-foreground |
filled dot |
| 3 | failed |
pipeline_state === "failure" |
text-red-500 |
filled circle-X |
| 4 | draft |
draft === true |
text-muted-foreground |
filled dot |
| 5 | ready |
approval_state === "approved" and pipeline_state === "success" |
text-emerald-400 |
filled circle-check |
| 6 | awaiting_approval |
approval_state === "pending" |
text-sky-400 |
clock |
| 7 | running |
pipeline_state === "pending" |
text-yellow-500 |
spinner, 3s per rotation |
| 8 | neutral |
otherwise | text-muted-foreground |
filled dot |
The ordering is deliberate and load-bearing. Stated exactly, because the neighbouring rows are easy to get backwards:
- A failed pipeline outranks
draft(row 3 before row 4), so a draft MR whose pipeline broke readsfailed, notdraft. readyrequires both an approved review and a succeeded pipeline (row 5).- A pending approval outranks a running pipeline (row 6 before row 7). Row 6
carries no pipeline condition at all: an MR with
approval_state: "pending"andpipeline_state: "pending"isawaiting_approval, notrunning.
This is exactly the order getMRStatusColor already evaluates, row for row.
The refactor SHALL NOT reorder it, and where this prose and the table above
could be read as disagreeing, the table governs.
Rank, and why aggregation has no tiebreak of its own
MR_CHIP_STATUS_RANK orders the statuses by how much attention they deserve:
| Status | Rank |
|---|---|
failed |
5 |
running |
4 |
awaiting_approval |
3 |
ready |
2 |
merged |
0 |
closed |
0 |
draft |
0 |
neutral |
0 |
Four statuses share rank 0, so rank alone does not identify a winner. The
chip therefore never aggregates by rank directly. aggregateMRChipStatus(mrs)
is defined as mrChipStatus(selectChipMR(mrs)) (neutral when selectChipMR
returns null), and selectChipMR breaks every tie by named field. Two
consequences, both intended:
- There is no rank-tie ambiguity to resolve, because ties are resolved on MRs
by
mr_iid/project_path/id, not on statuses. - The trigger's
data-statuscannot disagree with itsdata-mr-iidwhiledata-selection-frozenis"false": the reported status is read off the identified MR. This qualifier is load-bearing and is not a hedge. While a disclosure is open the freeze rule deliberately holdsdata-mr-iidon the frozen MR whiledata-statuskeeps tracking the live selection, so the two may name different MRs for exactly that window. See "Freezing while the disclosure is open", which governs. A test asserting the invariant SHALL scope itself todata-selection-frozen="false".
aggregateMRStatusColor is NOT re-expressed and its body SHALL NOT be
touched. This is a deliberate decision, not an omission:
- Its existing loop (
bestRank = -1, strict>over colour ranks) is first-in-input-order wins on a tie, so[merged, closed]returns purple while[closed, merged]returns muted. That array-order dependence is observable today on the multi-MR kanban badge. - The chip requires the opposite property: a selection that does not depend on
array order. The two cannot share one implementation without either changing
aggregateMRStatusColor's output for all-terminal lists, or importing array ordering into the chip. - Changing that behaviour is re-tuning the GitLab MR colour priority, which this spec already excludes (see Out of scope). Leaving the function byte-identical makes the mandated parity scenario a regression guard rather than a coin flip.
MR_CHIP_STATUS_RANK and the existing colour-keyed STATUS_RANK therefore
remain two separate tables, keyed on different things and serving different
callers. They are not permitted to drift: a test SHALL assert they are
order-equivalent under the status-to-colour map from the table above, i.e.
for any two statuses a and b,
MR_CHIP_STATUS_RANK[a] < MR_CHIP_STATUS_RANK[b] if and only if
STATUS_RANK[colour(a)] < STATUS_RANK[colour(b)]. The single-derivation
invariant this spec protects is the priority table (mrChipStatus, which
getMRStatusColor now reads from); it was never a claim that one rank table
serves both callers.
Selection and ordering
The chip renders one MR inside its popover even when several are linked. That
MR is the selected MR, selectChipMR(mrs), chosen deterministically:
-
Restrict to open MRs (the chip only renders when at least one exists). If none are open, return null.
"Open" means
state === "open"exactly. It is a positive equality test, never "not one of the terminal states".TaskMR.stateis typedopen | closed | merged | locked | string, so the two readings diverge forlockedand for any unrecognised string the backend may emit: under the equality test a locked MR is not selectable and does not count towarddata-mr-count, and an unknown state behaves the same way. This matches the testaggregateMRStatusColorandisMRReadyToMergealready apply, so all three agree on what "open" means. A consequence worth stating: rows 1 and 2 of the status table (merged,closed) are therefore unreachable throughselectChipMR, and remain live only forgetMRStatusColor's other callers and for the rank-parity test. -
Keep those whose
mrChipStatushas the maximumMR_CHIP_STATUS_RANK. -
Tiebreak, in order, by
mr_iidascending, thenproject_pathascending (byte-wise,<), thenidascending.idis the association primary key, so the ordering is total and the selection is unique for any input.
Array position is explicitly NOT the tiebreak. The store's array order comes
from the backend response order and is not a stable, named property. (This rule
governs the chip only. It does not apply to aggregateMRStatusColor, which
keeps its existing array-order behaviour untouched; see State machine.)
Freezing while the disclosure is open
Selection is frozen while the popover or drawer is open. The popover carries destructive and irreversible actions (unlink, merge), and an action target that swaps under the pointer mid-interaction is a hazard the aggregate status is not worth.
What is captured is the association id only, never a copy of the TaskMR
object. The popover body renders the live store row for that id on every
render. So while frozen:
- Which MR the popover acts on does not change.
- That MR's own fields DO stay live: a pipeline that goes from pending to failed
while the popover is open updates the body, and
isMRReadyToMergeand the merge button are evaluated against current data, never a snapshot taken at open. Merging or unlinking on stale fields is the failure this rule exists to prevent, so a value snapshot is specifically forbidden. - The trigger's
data-mr-iid,data-mr-stateanddata-mr-ready-to-mergedescribe the frozen MR, anddata-selection-frozenis"true". - The trigger's
data-statusand glyph continue to track the live selected MR, so a change on a different MR is still visible at a glance. This is the one window in whichdata-statusanddata-mr-iidmay describe different MRs; it is bounded bydata-selection-frozen="true"and is the deliberate cost of not swapping the action target. - The automation badges do NOT freeze.
mr-status-auto-fix-chip(its text and itsdata-auto-fix-exhausted) andmr-status-auto-merge-chipcontinue to track the live badge-selected MR, on the same side of the line asdata-statusand the glyph. The rule that decides this is what each surface is for, not which MR it happens to name. The freeze exists to stop an action target moving under the pointer: unlink and merge are destructive, so the MR they operate on is pinned. The badges are informational — nothing in the popover acts on the badge-selected MR, and no control's meaning changes when the badge changes. A frozen badge would instead hide exactly the event it exists to report: an MR exhausting its auto-fix rounds while the user has the popover open. This means a multi-MR chip may briefly show three different MRs at once —data-mr-iid(frozen, acted-on),data-status(live selection), and the badge (live badge-selection). That is accepted and is bounded bydata-selection-frozen="true". It is not a new hazard: none of the three is an action target except the frozen one, which is the whole point.
If the held id leaves the store, or its row stops being open, the popover or
drawer closes. On close the freeze is released and all attributes return to the
live selection in the same render.
DOM ordering inside both status rows SHALL be: PRStatusChip, then
MRStatusChip, then AzureDevOpsTaskPullRequestChip, then the existing
banners and right-hand controls. This matches the topbar's existing
PRTopbarButton then MRTopbarButton provider order, so a task linked to both
a PR and an MR presents the two providers in the same order in both places.
Both chips render; neither suppresses the other.
How each leg of that ordering is verified differs, and the difference is
deliberate. The PRStatusChip -> MRStatusChip leg has an AC, because both
elements carry testids. The MRStatusChip -> AzureDevOpsTaskPullRequestChip
leg has no AC and is a source-order requirement checked by reading the two
mount points.
The reason is a choice, not an impossibility, and an earlier draft overstated
it. It is true that the Azure chip exposes no data-testid and that adding one
is outside this card's permitted files. It does not follow that the leg is
unobservable: the chip renders a link whose aria-label is a hardcoded,
untranslated `Azure PR ${pullRequestId}: ...`, inside rows that do carry
testids, so a role-and-name locator could reach it without touching any Azure
file. This spec still declines to assert on it, for a different and narrower
reason: that label is an implementation detail of a component this card is
forbidden to modify, so an AC built on it could be broken by an unrelated Azure
change that this card's owner would be unable to fix. Coupling our ACs to
another team's untranslated internal string buys less than it costs. See
Scenarios.
The auto-fix round shown on a multi-MR chip is the most attention-worthy round
across the open MRs: an exhausted round beats a non-exhausted one, then a higher
current wins, and remaining ties resolve by the same
mr_iid / project_path / id order above.
Link and unlink from the chip
MRCIPopover requires both onLink and onUnlink. What activating them does
is part of this contract, not an implementation detail.
onUnlink — a zero-argument closure over the acted-on MR's association id,
matching MRCIPopover's declared onUnlink: () => void. It calls the extracted
useUnlinkTaskMR(workspaceId) described under API surface, passing the id it
closed over. (An earlier draft wrote this heading as onUnlink(associationId),
which is the exact form the prop contract above says is wrong; the prop contract
governs.) Behaviour is the topbar's, unchanged: on success the row leaves the
store; on failure an error toast is shown and the row stays.
onLink() opens the existing TaskMRLinkDialog, which the chip mounts
itself as a sibling of its trigger. Its props come from the same places
MRTopbarButton sources them:
| Prop | Source |
|---|---|
open |
the chip's own dialog open state, which it owns per mounted instance |
onOpenChange |
sets that same state |
taskId |
the chip's own taskId |
workspaceId |
the active workspace |
taskRepositories |
useTaskById(taskId)?.repositories, defaulting to a module-level shared empty array |
repositories |
state.repositories.itemsByWorkspaceId[workspaceId], defaulting to a module-level shared empty array |
Both defaults MUST be module-level constants, not a fresh [] per render, or
the dialog re-renders on every parent render. Both props are required and
non-nullable on TaskMRLinkDialog, and the workspace bucket can legitimately be
absent before repositories hydrate, so the fallback is reachable rather than
defensive. A dialog opened with an empty repositories list is the existing
component's own behaviour and this spec does not change it.
Ordering when the user activates "link another merge request":
- The chip closes its own disclosure (popover or drawer) first.
- Then
TaskMRLinkDialogopens.
Closing first is required, not cosmetic: the drawer variant is itself a focus-trapping dialog, and opening a second one inside it strands focus. The dialog's own lifecycle is unchanged, so a successful link adds the association to the store and the chip re-renders with the new MR in its input; if that MR outranks the current selection the trigger updates, subject to the freeze rule only while a disclosure is open, which by step 1 it is not.
Each mounted chip owns its own dialog instance and its own open state, like its
disclosure. Two chips mounted at once therefore have two dialog instances, both
closed until the user activates link on one of them; activating it opens only
that chip's dialog. The dialog is idempotent with respect to which chip opened
it, because both pass the same taskId and workspaceId.
The dialog does not outlive its chip. Because each chip mounts its own
dialog as a sibling of its trigger, a chip that unmounts takes its dialog with
it. So if the task's last open MR is unlinked from another surface, or goes
terminal, while this chip's link dialog is open, the dialog unmounts mid-edit
and any URL the user had typed is lost. That is the accepted behaviour, not an
oversight: the alternative is hoisting the dialog above the chip's own render
gate, which would leave a dialog on screen belonging to a chip that no longer
exists. The window is small (it needs a concurrent change on another surface)
and the user can reopen the dialog from MRTopbarButton, which is not gated on
an open MR existing.
Failure modes
- Task MR list never hydrated.
useTaskMRsreturns the shared empty array and the chip renders nothing. There is no skeleton and no loading state: an absent chip and a not-yet-fetched chip are indistinguishable by design, which is the same contractPRStatusChipandMRTaskIconalready have. - Store holds a non-array value for the task (possible during partial
hydration). The chip guards with
Array.isArrayand renders nothing, matchingMRTaskIconandPRStatusChip. - No active workspace, or
taskIdis null. The chip renders nothing. - Popover feedback fetch fails.
useMRFeedbackalready resolves with partial data and an error string; the popover degrades exactly as it does from the topbar button. The chip's own trigger status is unaffected, because it is derived fromTaskMRfields rather than from the feedback response. - Unlink fails. The existing behaviour is preserved: an error toast is shown and the association stays in the store. A successful unlink removes the row; if that was the task's last open MR the chip unmounts on the next render.
- Two surfaces unlink the same association. The chip and the topbar read the same store row. Whichever request succeeds first removes the row; the other surface's request fails and toasts. The end state is identical either way, so the outcome does not depend on ordering. The chip does not retry.
- An open MR transitions to merged/closed while its popover is open. On the next store update the chip re-evaluates. If the transitioned MR is the frozen one, the popover or drawer closes; if another open MR remains the trigger re-selects it, and if none does the chip unmounts. No stale MR is left rendered either way.
- The GitLab connection status has not resolved yet.
useGitLabAvailable()returnsfalseuntil the mount-timefetchGitLabStatusresolves (and it actively clears any cached value first — see Sync and freshness). SocanLinkisfalseon the chip's first render and may becometruea moment later without a remount. The chip SHALL treat this as ordinary prop change, not as a terminal "GitLab unavailable" state: it renders normally, and the popover's link control simply appears when the status resolves. The chip SHALL NOT cache the first value, gate its own render on it, or show an error. If the status request fails,useGitLabStatusstoresnull,canLinkstaysfalse, and the popover renders without the link control — the same as a workspace with GitLab genuinely not configured, which is the existing behaviour of every other consumer. - The automation-options fetch fails.
useTaskMRAutomationOptionsstores the error and its lazy effect is gated onerror, so it does not retry for the life of the mount. The chip treats this exactly as options-not-loaded: status glyph renders, no badges, no error text and no retry affordance on the chip itself. The topbar'sMRAutomationControlsremains the surface that reports and retries automation errors.
Sync and freshness (explicit non-responsibility)
GitHub's PRStatusChip is load-bearing for refresh. It calls
usePRFeedbackBackgroundSync, and pr-topbar-button.tsx:162 documents the
topbar button relying on the chip being mounted for that warming. GitLab's
refresh does not work that way and this chip SHALL NOT copy the responsibility:
-
MR list hydration is a one-shot
useWorkspaceMRs(workspaceId)fetch whose "already fetched" guard is a per-hook-instance ref, so each additional call site issues its ownGET /task-mrsper workspace. There are already four production call sites, and the chip SHALL NOT become a fifth:Call site Mounted by components/gitlab/mr-topbar-button.tsxthe task topbar components/kanban-board.tsxthe kanban board hooks/domains/gitlab/use-mr-key-to-tasks.tsapp/gitlab/gitlab-page-client.tsxhooks/domains/workspace/use-external-vcs-file-link.tsuseExternalVcsFileLinkHydrationincomponents/task/task-page-content.tsx, gated on the task having a GitLab-provider repositoryThe chip SHALL NOT call
useWorkspaceMRs. -
There is no shared MR feedback cache.
useMRFeedbackis a per-instance reducer with no store slice, so there is nothing for the chip to warm and nothing the topbar would inherit from it. The chip SHALL NOT add a warmer.
Two fetch triggers are genuinely new, and both are accepted rather than hidden.
New trigger 1: the automation options. The chip calls
useTaskMRAutomationOptions(taskId) to render its badges, and that hook lazily
issues a GET when the store holds no options for the task. Before this feature
those options were fetched only when the topbar dropdown or popover mounted
MRAutomationControls; now they are fetched for any session view that has a
linked open MR.
New trigger 2: the GitLab connection status, which also has a cross-surface
side effect. The chip calls useGitLabAvailable() to source canLink. That
helper (hooks/domains/gitlab/use-task-mr.ts) wraps useGitLabStatus(), whose
effect (hooks/domains/gitlab/use-gitlab-status.ts) runs on every mount and
unconditionally does setStatus(workspaceId, null) and then
void loadStatus(workspaceId) -> fetchGitLabStatus. Two consequences, both
stated rather than discovered at Build time:
- It is a request, not a cache read. Despite the hook's "store-cached" doc
comment, the guard is per-hook-instance: a populated store does not
short-circuit the effect. Every chip mount issues one
GET, and two mounted chips issue two. - It transiently blanks a value other surfaces read. Because the effect
clears the status to
nullbefore refetching, every otheruseGitLabAvailable()consumer —hooks/use-nav-availability.ts,components/kanban-external-link-availability.ts,components/task/task-session-sidebar-task-linking.ts, andcomponents/app-sidebar/sections/settings/workspaces-group.tsx— readsfalsefor the duration of the in-flight request. Mounting the chip therefore has a brief, observable effect on unrelated navigation UI.
This is accepted, not fixed here. The chip is not the first consumer to do
it (MRTopbarButton already calls useGitLabAvailable() on the same task
route), the window is one request long, and the recovery is automatic when the
status resolves. Removing the blanking means changing useGitLabStatus for
every caller, which is its own card (see Out of scope). The chip SHALL NOT work
around it with a local cache, a mount-order guard, or a debounce, and SHALL NOT
read the status slice directly to dodge the hook. What the chip owes here is
only that canLink is allowed to be false on the first render and become
true without a remount — see Failure modes.
The popover's MR feedback read is not a new trigger in this sense. It is
MRCIPopover's own existing fetch, it stays gated on that component's enabled
prop, and the chip passes its disclosure-open state there (see the prop table
under API surface). So it fires when a user opens the chip's popover or drawer,
exactly as it already fires when a user opens the topbar's, and never on mount.
What that does and does not buy across two mounted chips, stated precisely because the obvious argument is invalid:
- Guaranteed. A chip whose disclosure is closed passes
enabled: falseand issues no feedback read. A chip that is never opened never fetches feedback. - NOT guaranteed. Two chips mounted for one task may each have an open
disclosure at the same time, and then each issues its own feedback read. An
earlier draft argued they "do not double it, because at most one disclosure is
open per chip" — that is a non-sequitur and is retracted: per-chip
exclusivity gives no cross-chip exclusivity, and one open disclosure on each
of two chips is exactly two concurrent
enabled: true.
Simultaneous open across two chips IS permitted. This is a decision, not an omission. Each mounted chip owns its own disclosure state (see Accessibility) and this spec adds no cross-chip coordinator, no shared "currently open chip" context, and no global registry. The state is reachable in practice because the fine-pointer chip opens on focus as well as hover: a keyboard user can focus chip A open and then hover chip B, leaving both open.
Two doubled feedback reads is the accepted cost. The alternative — a global
mutual-exclusion coordinator — would be new shared state that neither mount
point nor MRTopbarButton has today, would have to decide which chip loses,
and would make each chip's behaviour depend on what else is mounted. That is a
worse contract than an occasional duplicate idempotent GET. useMRFeedback is
per-instance with no store slice, so two concurrent reads cannot corrupt each
other; they resolve independently into their own popovers.
The dedupe this buys, stated precisely rather than optimistically:
- Guaranteed. Once options for a task are in the store, any number of
further chip mounts for that task issue zero requests. The hook's effect
is gated on
options || loading || error, and a populated store short-circuits it on the first render. - Guaranteed. A single chip mount issues at most one request.
- NOT guaranteed, and not fixed by this card. Two chips mounted for the same
task in the same React commit, with no cached options, may each issue one
request. The hook's guard reads render-captured values, so both effects run
before either re-render observes
loading === true. This is reachable:PassthroughToolbarhas five mount sites andChatInputAreathree, several of them dockview-driven, so nothing structurally prevents two chips for one task existing at once.
The duplicate is a GET with no side effect, and the hook's own request-id
check (isCurrentAndUnchangedExternally) means the store converges to one
options object regardless of which response lands last, so the outcome does not
depend on ordering. Adding a shared in-flight guard to
useTaskMRAutomationOptions would fix it, but that hook is also used by
MRAutomationControls from the topbar, so the change belongs to a card that can
test both callers; it is named in Out of scope. The chip SHALL NOT work around
the gap with a module-level cache of its own.
The same render-captured pattern is present in useTaskCIAutomationOptions, so
PRStatusChip does not deliver cross-mount dedupe either. Parity with GitHub is
recorded here as an observation, not as evidence that the property holds.
Hydration ownership, and why this spec pins no outcome for it. An earlier
draft claimed MRTopbarButton was the only mount point for useWorkspaceMRs
and derived from that a specific consequence: that an archived task never
hydrates the MR store, so the chip renders nothing there. Both halves were
wrong and the claim is retracted. There are four call sites (table above), and
one of them — useExternalVcsFileLinkHydration at
components/task/task-page-content.tsx — runs on the /tasks/:id task page for
any task with a GitLab-provider repository, independently of the topbar and
independently of archived state.
So whether the MR store is hydrated for a given task depends on which surfaces
the session has visited and which route rendered the chip, not on a single
owner. This spec therefore states no archived-task rendering outcome and no AC
depends on one. What the chip guarantees is only the conditional already in
Failure modes: if the task's MR list is not hydrated, useTaskMRs returns the
shared empty array and the chip renders nothing, exactly as it does for a task
with no MRs. Tests SHALL seed the store rather than rely on any ambient
hydration path, which the E2E decision already requires.
Giving GitLab MR hydration a single provider-level owner is its own card (see Out of scope). That card is what would make a hydration outcome specifiable; until it lands, asserting one here would pin a route-history-dependent flake.
Responsive behaviour
- Disclosure is chosen by pointer precision only, via
useTouchDrawer()(!isFinePointer): coarse pointer renders theDrawervariant, fine pointer renders the hoverPopovervariant. - The chip SHALL NOT gate any part of itself on a Tailwind width class
(
sm:,md:, ...). Pairing a width-gated class with the pointer-gated hook is what leaves 640-767px rendered by neither branch, and the chip has no width-dependent behaviour to justify the risk. - The drawer variant carries a translated title, a screen-reader description, and a close button, and its body scrolls independently.
- Hover-popover placement is pinned by value, so two builders cannot diverge
visually while passing every scenario:
align="end",sideOffset={4}, and aw-80content width — the valuesMRTopbarButtonalready uses.sideis left to Radix's default collision handling, which is what keeps the content on screen for a chip sitting at the bottom of the viewport; the "bounding boxyis greater than or equal to 0" scenario below is the observable consequence of that and SHALL NOT be satisfied by hard-coding aside.
Accessibility, focus, and duplicate mounts
-
The trigger is a
<button type="button">withcursor-pointerand a translatedaria-labelnaming the MR (or the MR count) and its status. The drawer variant additionally carriesaria-haspopup="dialog"andaria-expanded. -
The
aria-labeldescribes the acted-on MR, so it follows the same regime asdata-mr-iid: the live selected MR while the disclosure is closed, the frozen MR while it is open. It therefore always agrees withdata-mr-iidand always names the MR whose controls the disclosure actually operates on. It does NOT followdata-status's live tracking, because a label that renamed the MR under a screen-reader user mid-interaction would describe controls other than the ones in front of them. -
The fine-pointer hover lifecycle SHALL be the existing
useHoverPopover({openDelayMs, closeDelayMs, disabled})(apps/web/hooks/domains/github/use-hover-popover.ts— note it takes a single options object, not three positional arguments), wired exactly asMRTopbarButtonwires it. That wiring is, in full and with nothing omitted — thirteen handlers, not the four-plus-two a reader might assume:Element Event Handler trigger onMouseOveronTriggerEntertrigger onMouseEnteronTriggerEntertrigger onMouseMoveonTriggerEntertrigger onPointerOveronTriggerEntertrigger onPointerEnteronTriggerEntertrigger onPointerMoveonTriggerEntertrigger onFocusonTriggerEntertrigger onMouseLeaveonTriggerLeavetrigger onPointerLeaveonTriggerLeavetrigger onBluronTriggerLeavecontent onMouseEnteronContentEntercontent onMouseMoveonContentEntercontent onMouseLeaveonContentLeaveThe redundancy is deliberate and SHALL be reproduced, not tidied. An earlier draft of this spec listed only six of these rows while also declaring the list complete; that was wrong, and a builder who implements six rows ships a chip that is measurably flakier than the topbar rather than identical to it. Two of the omitted rows are individually load-bearing:
onMouseMoveon the content.use-hover-popover.ts's own doc comment states the reason: "The content also treats mouse-move as 'enter' so a flaky or missed portal mouseenter can't strand a pending close." This is precisely the mechanism the "pointer crosses the gap" scenario below depends on. Without it that scenario does not fail cleanly — it flakes.onMouseMove/onPointerMoveon the trigger. These re-assert hover for a pointer that is resting on the trigger without generating a freshmouseenter, which is the state the closed-trigger click scenario describes.
The remaining aliases (
onMouseOver/onPointerOver/onPointerEnter, andonPointerLeave) are defensive duplicates covering browsers and input devices that dispatch the pointer family but not the mouse family, or vice versa.onTriggerEnterandonTriggerLeaveare idempotent — they set a boolean ref and schedule or cancel a timer — so firing them several times for one physical gesture is harmless by design, and that is what makes the duplication safe.Because reproducing thirteen rows by hand is exactly the kind of thing that drifts, the chip SHOULD prefer exporting and reusing the existing private
useMRPopoverInteractionswrapper (and, if it is convenient, the trigger's handler-spreading shape) frommr-topbar-button.tsxover re-typing the set. Reuse is the preferred closure of this requirement; hand-copying is permitted only if every row above is present.The content handlers are what let the pointer cross the
sideOffsetgap without closing.disabledisuseTouchDrawer().openDelayMsandcloseDelayMsSHALL both be 150, the valuesMRTopbarButtonuses. Those constants live in a privateuseMRPopoverInteractionswrapper insidemr-topbar-button.tsx, so "wired as the topbar wires it" does not by itself pin them; they are pinned here by value. The chip MAY either export that wrapper for reuse or wireuseHoverPopoverdirectly with the same values. -
The popover DOES close on blur, because
onBluris wired toonTriggerLeaveexactly as the topbar wires it. It also closes on Escape, on outside interaction (RadixonOpenChange), and when the pointer leaves both the trigger and the content. It opens on keyboard focus of the trigger and SHALL NOT steal focus when it opens.This corrects a claim an earlier draft of this spec made. For the record, because the wrong version is the kind of thing that gets re-derived: it is true that
useHoverPopoveritself declares no blur handler, butMRTopbarButtonwiresonBlurto the hook'sonTriggerLeaveat the component level. So wiring blur is what matches the topbar and omitting it is the divergence. Nor does wiring it re-introduce the portal race:onTriggerLeavedoes not close anything directly, it callsscheduleClose, which re-checksoverTriggerandoverContentat the moment the timer fires and commits the close only when the pointer is over neither region. That re-check is precisely the mechanism the hook exists to provide.Closing on blur does not resurrect the keyboard-operability problem, because this spec does not claim the popover's controls are Tab-reachable — see the next bullet, which states the opposite plainly. Focus-to-open makes the content readable by a keyboard user; tabbing away closes it again; the controls inside it were never Tab-reachable from the trigger in the first place, so nothing is lost by the close.
-
Scope of the keyboard claim. Focus-to-open makes the popover's content readable by a keyboard user. It does NOT make the popover's controls (link, unlink, merge) Tab-reachable: Radix portals the content to the end of the document and provides no tab bridge from the trigger, so Tab moves past the chip rather than into it. This spec does not claim otherwise, and does not fix it. No capability is lost: link and unlink are keyboard-operable today through
MRTopbarButton's click-drivenDropdownMenu, and on a coarse pointer the chip's own drawer is a focus-trapping dialog in which every control is operable. MakingMRCIPopover's controls keyboard-reachable from a hover popover is a pre-existing gap shared withPRStatusChipand the topbar, and is named in Out of scope. -
Clicking the fine-pointer trigger is a no-op in both states. While the popover is open, it stays open and nothing navigates; Radix treats the trigger as outside the content, so this requires an explicit outside-pointer-down guard. While the popover is closed, the click also does nothing on its own: the fine-pointer chip opens on hover and focus only, and there is no click-to-open path. A user who clicks a closed chip faster than the 150ms open delay therefore sees the popover open on the hover timer as normal, because the pointer is still over the trigger; the click neither accelerates nor suppresses it. This is a deliberate divergence from
MRTopbarButton, whose trigger navigates to the MR detail panel on click — the chip has no detail-panel action at all (see Out of scope), so it has nothing to do with a click. -
Closing the drawer returns focus to the trigger when the trigger is still mounted. It often will not be: unlinking the task's last open MR from inside the drawer, or that MR going terminal, unmounts the chip and its trigger in the same update that closes the drawer. In that case the chip makes no focus claim and focus falls to
document.bodyper browser default. This is a named, accepted consequence rather than a specified behaviour, it is shared withPRStatusChip, and improving it is listed in Out of scope. No AC asserts a focus target for the unmounted case. -
The two mount points are alternative surfaces, but nothing guarantees only one is mounted at a time. Two
mr-status-chipelements may therefore coexist in the DOM. Both are correct and identical; E2E selectors SHALL scope tochat-status-barorpassthrough-status-rowrather than matching the testid globally, and each mounted chip owns its own disclosure state. -
On a workspace switch the chip does not show the previous workspace's MRs. The mechanism is workspace-scoped selection, not a reset of the outgoing bucket — an earlier draft of this spec said the opposite and it was wrong. What actually happens:
useWorkspaceMRscallsresetTaskMRs(workspaceId)with the incoming workspace id (the all-bucketsresetTaskMRs()runs only when the workspace goes null), so the outgoing workspace's bucket is left in the store untouched. The chip reads nothing from it becauseuseTaskMRsselectsbyWorkspaceId[activeWorkspaceId], andactiveWorkspaceIdis already the incoming one. The chip therefore unmounts until the incoming workspace's MRs land. The correction matters for what it forbids: the chip SHALL NOT add any outgoing-workspace cleanup of its own, and no AC asserts that the previous workspace's bucket was cleared.
Scenarios
Rendering and absence:
-
GIVEN a task with no linked MRs, WHEN its session view renders, THEN no element with testid
mr-status-chipexists. -
GIVEN a task whose only linked MR has
state: "merged", WHEN its session view renders, THEN no element with testidmr-status-chipexists. -
GIVEN a task with two linked MRs, both terminal (one
merged, oneclosed), WHEN its session view renders, THEN no element with testidmr-status-chipexists. -
GIVEN a task with one open MR and one merged MR, WHEN its session view renders, THEN
mr-status-chipis present withdata-mr-count="1"anddata-mr-iidequal to the open MR's iid. -
GIVEN a task with one open MR and no linked PR, WHEN the chat status bar renders, THEN
mr-status-chipis present insidechat-status-bar. -
GIVEN a task with one open MR, WHEN the passthrough toolbar renders, THEN
mr-status-chipis present insidepassthrough-status-row. -
GIVEN a task linked to both an open GitHub PR and an open GitLab MR, WHEN the chat status bar renders, THEN both
pr-status-chipandmr-status-chipare present insidechat-status-bar, andpr-status-chipprecedesmr-status-chipin DOM order.This is the only ordering assertion. The Azure DevOps chip is not part of any assertion, and the requirement that
MRStatusChipbe rendered beforeAzureDevOpsTaskPullRequestChip(see Selection and ordering) stands as a source-order requirement on the two mount points, verified by reading the diff rather than by a test. That is a deliberate trade, stated honestly rather than dressed up as a technical limit: the Azure chip has nodata-testid, and although its hardcodedaria-labelwould make a role-based locator possible, this spec declines to build an AC on a string owned by a component this card may not modify. See Selection and ordering for the full reasoning. A stated code-review check that everyone can see is better than an AC whose failure mode is an unrelated team renaming their label.
Status derivation:
- GIVEN an open MR with
pipeline_state: "failure"anddraft: true, WHEN the chip renders, THENdata-statusisfailed. - GIVEN an open MR with
draft: trueandpipeline_state: "pending", WHEN the chip renders, THENdata-statusisdraft. - GIVEN an open MR with
approval_state: "approved"andpipeline_state: "success", WHEN the chip renders, THENdata-statusisreadyand the glyph carries the emerald colour class. - GIVEN an open MR with
approval_state: "pending"andpipeline_state: "success", WHEN the chip renders, THENdata-statusisawaiting_approval. - GIVEN an open MR with
approval_state: ""andpipeline_state: "pending", WHEN the chip renders, THENdata-statusisrunning. - GIVEN an open MR with
approval_state: "pending"andpipeline_state: "pending", WHEN the chip renders, THENdata-statusisawaiting_approval, notrunning, because row 6 carries no pipeline condition. - GIVEN an open MR with every status field empty, WHEN the chip renders,
THEN
data-statusisneutral. - GIVEN an open MR with
unresolved_discussions: 7and otherwisereadyfields, WHEN the chip renders, THENdata-statusis stillready, because unresolved discussions do not feed chip status. - GIVEN any
TaskMR, WHENgetMRStatusColoris called before and after the refactor, THEN it returns the identical class string. A table-driven test SHALL cover every branch of the priority table.
Aggregation and selection:
- GIVEN a task with two open MRs, one
runningand onefailed, WHEN the chip renders, THENdata-statusisfailed,data-mr-countis2, anddata-mr-iidis the failed MR's iid. - GIVEN a task with two open MRs that are both
failed, with iids 12 and 7, WHEN the chip renders, THENdata-mr-iidis7. - GIVEN a task with two open MRs that are both
failed, sharing iid 7 across projectsgroup/aandgroup/b, WHEN the chip renders, THEN the selected MR is the one ingroup/a. - GIVEN a task with three open MRs whose backend response order is reversed
between two renders, WHEN the chip renders each time, THEN
data-mr-iidis identical both times. - GIVEN a task with two open MRs at rank 0 with different statuses, one
draftwith iid 9 and oneneutralwith iid 3, WHEN the chip renders, THENdata-mr-iidis3anddata-statusisneutral(the status of the MR the tiebreak selected), for either input array order. - GIVEN any list of MRs, WHEN
aggregateMRChipStatus(mrs)is called, THEN its result equalsmrChipStatus(selectChipMR(mrs)), orneutralwhenselectChipMR(mrs)is null. A property test SHALL assert this over shuffled input orders. - GIVEN any two
MRChipStatusvaluesaandb, THENMR_CHIP_STATUS_RANK[a] < MR_CHIP_STATUS_RANK[b]if and only ifSTATUS_RANK[colour(a)] < STATUS_RANK[colour(b)], asserted by a table-driven test over all 8 statuses. - GIVEN any list of MRs, WHEN
aggregateMRStatusColoris called before and after this change, THEN it returns the identical class string. This includes all-terminal lists, where its existing first-in-input-order tie behaviour is preserved:[merged, closed]returnstext-purple-500and[closed, merged]returnstext-muted-foreground, both before and after.
Disclosure:
- GIVEN a fine-pointer viewport and a task with one open MR, WHEN the
user hovers
mr-status-chip, THENmr-topbar-popover-innerbecomes visible and its bounding boxyis greater than or equal to 0. - GIVEN that popover is open, WHEN the pointer crosses the gap between trigger and content, THEN the popover stays open.
- GIVEN that popover is open, WHEN the user clicks the chip itself, THEN the popover stays open and no navigation occurs.
- GIVEN a coarse-pointer viewport and a task with one open MR, WHEN the
user taps
mr-status-chip, THENmr-status-chip-drawerbecomes visible and containsmr-topbar-popover-inner. - GIVEN the drawer is open, WHEN the user activates
mr-status-chip-drawer-close, THEN the drawer closes and focus returns to the chip trigger. - GIVEN a coarse-pointer viewport, WHEN the chip renders, THEN no hover popover is mounted.
- GIVEN a fine-pointer viewport, WHEN the user moves keyboard focus onto the chip trigger, THEN the popover opens and focus stays on the trigger.
- GIVEN the popover was opened by keyboard focus, WHEN focus moves off
the trigger without any pointer entering the trigger or the content, THEN
the popover closes, because
onBluris wired toonTriggerLeaveand the scheduled close finds the pointer over neither region. - GIVEN the popover was opened by keyboard focus and the pointer is then moved onto the popover content, WHEN focus moves off the trigger, THEN the popover stays open, because the scheduled close re-checks both hover regions at fire time and the pointer is over the content.
- GIVEN a fine-pointer viewport and a task with one open MR, WHEN the user clicks the chip trigger while its popover is closed, THEN no navigation occurs and no panel opens; the popover opens only when the 150ms hover-open delay elapses with the pointer still over the trigger.
- GIVEN a fine-pointer viewport and a task with one open MR, WHEN the viewport width is any value from 320px to 1920px, THEN exactly one disclosure branch renders and the chip is never blank. No width between breakpoints leaves it rendered by neither branch.
Popover content and actions:
-
GIVEN an open MR with 6 of 10 pipeline jobs passing, WHEN its chip popover opens, THEN the pass-rate row reads
6/10 (60%). -
GIVEN an open MR whose pipeline has not started (
pipeline_jobs_total: 0and no fetched jobs), WHEN its chip popover opens, THENmr-pipeline-emptyis rendered. -
GIVEN an open MR and a workspace with GitLab authenticated whose status request has already resolved, WHEN its chip popover opens, THEN
mr-popover-link-anotheris rendered. (The pre-resolution window is a separate AC below; the qualifier is what keeps the two from disagreeing.) -
GIVEN an open MR and a workspace with GitLab not configured, WHEN its chip popover opens, THEN
mr-popover-link-anotheris not rendered. -
GIVEN an open MR and a workspace with GitLab authenticated, WHEN the chip mounts and its popover is opened before the mount-time GitLab status request resolves, THEN
mr-popover-link-anotheris absent at first and appears once the status resolves, without the chip unmounting or remounting and without any error being shown. -
GIVEN a task with exactly one open MR, WHEN the user unlinks it from the chip popover and the request succeeds, THEN the association is removed from the store and
mr-status-chipunmounts. -
GIVEN the unlink request fails, WHEN the user unlinks from the chip popover, THEN an error toast is shown and
mr-status-chipremains. -
GIVEN an MR that satisfies
isMRReadyToMerge, WHEN its chip popover opens, THENmr-merge-buttonis rendered anddata-mr-ready-to-mergeon the trigger is"true". -
GIVEN a task with two open MRs, WHEN the multi chip renders, THEN
data-mr-ready-to-mergeis present on the trigger and equalsisMRReadyToMerge(acted-on MR). -
GIVEN a fine-pointer viewport and an open chip popover, WHEN the user activates
mr-popover-link-another, THEN the popover closes and the task-MR link dialog opens. -
GIVEN a coarse-pointer viewport and an open chip drawer, WHEN the user activates
mr-popover-link-another, THENmr-status-chip-draweris no longer visible and the task-MR link dialog is open, so the two dialogs are never nested. -
GIVEN the link dialog opened from the chip, WHEN the user links a second open MR that outranks the current selection, THEN the dialog closes, the new association is in the store, and the trigger's
data-mr-countanddata-mr-iidupdate to include and name it. -
GIVEN two independent fixtures of the same task and association — one where the user unlinks from the chip popover, one where the user unlinks from
MRTopbarButton— WHEN the request succeeds in each, THEN both remove the same association from the store and neither toasts. -
GIVEN those same two independent fixtures, WHEN the request fails in each, THEN both show the error toast built from
gitlab:failedToUnlinkMergeRequestandgitlab:theMergeRequestIsStillLinked, and both leave the association in the store.These are two separate fixtures, not a sequence: the second unlink of one association would fail for a different reason (the row is already gone) and would test nothing about parity. Parity holds by construction because both surfaces call the one extracted
useUnlinkTaskMRand no second copy of that closure exists; these ACs pin that the extraction actually happened.
Automation badges:
-
GIVEN task MR automation with
auto_fix_enabled: falseandauto_merge_enabled: false, WHEN the chip renders, THEN neithermr-status-auto-fix-chipnormr-status-auto-merge-chipis rendered. -
GIVEN
auto_fix_enabled: true,auto_fix_max_rounds: 5, and a lifecycle state for the selected MR withauto_fix_round_count: 2, WHEN the chip renders, THENmr-status-auto-fix-chipreads2/5. -
GIVEN
auto_fix_enabled: true,auto_fix_max_rounds: 5, and no lifecycle state at all for the selected MR (findMRAutomationStateForMRreturnsundefined), WHEN the chip renders, THENmr-status-auto-fix-chipIS rendered and reads0/5, and carriesdata-auto-fix-exhausted="false".This is the ordinary steady state of an enabled MR that has not needed a fix yet, so it is the badge's most common appearance and it is pinned rather than left to inference. The value follows from
autoFixRoundForState, which is total: givenundefinedit returns{current: 0, max, exhausted: false}. Hiding the badge until round >= 1 is specifically NOT the behaviour — the badge communicates that auto-fix is on, which is true at round 0. -
GIVEN
auto_fix_enabled: trueand a lifecycle state withauto_fix_exhausted_atset, WHEN the chip renders, THENmr-status-auto-fix-chipcarriesdata-auto-fix-exhausted="true". -
GIVEN
auto_fix_enabled: trueandauto_fix_max_roundsabsent or not a finite number, WHEN the chip renders, THEN the badge's denominator is10. -
GIVEN two open MRs where one is exhausted at round 3 of 5 and the other is at round 4 of 5, WHEN the multi chip renders, THEN the badge shows the exhausted MR's
3/5. -
GIVEN two open MRs whose auto-fix rounds are fully tied — neither exhausted, both at round 2 of 5 — with iids 12 and 7, WHEN the multi chip renders, THEN the badge is the one belonging to iid
7, resolved by the samemr_iid/project_path/idorder thatselectChipMRuses. This pins the tiebreak leg of the round-aggregation rule, which the exhausted-beats- non-exhausted and higher-current-wins legs above do not reach. -
GIVEN
auto_merge_enabled: true, WHEN the chip renders, THENmr-status-auto-merge-chipis rendered. -
GIVEN automation options have not loaded, WHEN the chip renders, THEN the chip still renders its status glyph and no badges.
Idempotency and re-render:
-
GIVEN unchanged store state, WHEN the chip re-renders any number of times, THEN its rendered output and
data-*attributes are identical. -
GIVEN the task's MR automation options are already in the store, WHEN the chip mounts, THEN no request is issued.
-
GIVEN the task's MR automation options are absent from the store, WHEN a single chip mounts, THEN exactly one options request is issued for that task.
-
GIVEN the task's MR automation options are absent from the store, WHEN two chips for the same task mount in the same React commit, THEN each may issue one request, and once both settle the store holds exactly one options object for that task and both chips render identical badges. Cross-mount request dedupe is explicitly NOT asserted; see Sync and freshness.
-
GIVEN the chip is mounted, WHEN no user action occurs, THEN it issues no further request: it neither polls nor re-fetches on an interval.
-
GIVEN the chip's popover is open on a task with two open MRs, WHEN a store update makes the other MR the higher-ranked one, THEN the popover keeps showing the MR it opened with, the trigger's
data-mr-iidstill names that MR,data-selection-frozenis"true", and the trigger'sdata-statusupdates to the new live selection's status. -
GIVEN that same popover, WHEN the user closes it, THEN in the render after close
data-selection-frozenis"false"anddata-mr-iidnames the new live selection. -
GIVEN the chip's popover is open on an MR whose
pipeline_stateis"success", withdetailed_merge_status: "mergeable",unresolved_discussions: 0,draft: false— soisMRReadyToMergeis true and the trigger'sdata-mr-ready-to-mergeis"true"— WHEN a store update sets that same MR'spipeline_stateto"failure", THENdata-mr-ready-to-mergebecomes"false"andmr-merge-buttonis no longer rendered in the open popover.The transition is chosen so the assertion cannot pass vacuously. A
pending->failuretransition would leaveisMRReadyToMergefalse on both sides (it requirespipeline_state === "success"), so it proves nothing about liveness;success->failureis the smallest change that actually flips the observed value. -
GIVEN that same popover is open on that MR, WHEN the same store update lands, THEN the trigger's
data-statuschanges fromreadytofailed, confirming the body and the trigger read the same live row. -
GIVEN the chip's popover is open, WHEN the MR it is showing is unlinked from another surface, THEN the popover closes.
-
GIVEN the chip's popover is open, WHEN the MR it is showing transitions to
mergedwhile another open MR remains, THEN the popover closes and the trigger re-selects the remaining open MR. -
GIVEN two chips are mounted at once, WHEN the user opens one, THEN the other stays closed.
-
GIVEN two chips are mounted at once for the same task, WHEN the user opens the chat-status-bar chip by keyboard focus and then opens the passthrough-status-row chip by hover, THEN both disclosures are open at the same time, each rendering
mr-topbar-popover-inner, and neither closes the other. Simultaneous open is permitted; no cross-chip coordinator exists. -
GIVEN those two disclosures are both open, THEN each chip issues its own MR feedback read — two requests for the one task. Cross-chip feedback dedupe is explicitly NOT asserted; see Sync and freshness.
-
GIVEN the chip's popover is open on a task with two open MRs, so
data-selection-frozenis"true", WHEN a store update makes the other MR the badge-selected one (it becomes exhausted while the frozen MR is not), THENmr-status-auto-fix-chipupdates to the newly badge-selected MR's round and itsdata-auto-fix-exhaustedbecomes"true", whiledata-mr-iidstill names the frozen MR. The badges do not freeze.
Internationalization:
- GIVEN the pseudo-locale is active, WHEN the chip and its drawer
render, THEN every string this feature adds is pseudo-localized,
including the trigger's
aria-label, the drawer title, and the drawer's screen-reader description. - GIVEN the pseudo-locale is active, WHEN the chip's popover renders
MRCIPopover, THEN GitLab-sourced domain data inside it is NOT pseudo-localized: MR titles, author and reviewer names,project_path, branch names, and pipeline job and stage names render verbatim. Perapps/web/CLAUDE.mdthese are user/domain data, never UI copy, and this feature does not change howMRCIPopoverrenders them. The i18n assertion is scoped to chip-owned copy for exactly this reason.
Out of scope
Each of these is a deliberate exclusion, not an oversight. Each is its own card.
- MR conflict / merge-blocked as a distinct chip status. GitHub's chip has
conflict(mergeable_state: "dirty") andbehindstates. GitLab exposes the equivalent throughdetailed_merge_status/merge_status, but introducing it as a chip status would add a branch togetMRStatusColorand therefore change the kanban card icon and the topbar trigger colour for every conflicted MR in the product. That is a change to a different surface than this card's, somrChipStatusdeliberately produces no status that alters an existing colour. - Re-tuning the GitLab MR status colour priority. For example, making a
green pipeline with no approval requirement read green rather than falling to
neutral. The chip inherits today's priority table verbatim. - A GitLab multi-MR tabbed popover. GitHub has
MultiPRCIPopoverwith segmented per-PR tabs, backingPRStatusChipMultiHoverCardandPRStatusChipMultiDrawer. GitLab has no such component, andmr-topbar-button.tsxalready carries this exact exclusion. The multi-MR chip therefore shows the aggregate status plus a count on the trigger, and exactly one MR (the selected MR) inside its popover. - Reaching a terminal MR from the chip. Because there is no multi-MR
surface, the chip renders only for open MRs, and a merged or closed
association is not reachable from it. Unlinking a terminal association stays
with the topbar dropdown and the MR detail panel. This is a deliberate
divergence from
PRStatusChip, which keeps terminal PRs in its multi-PR surface for exactly that reason. - GitLab equivalents of
PRMergedBanner/PRClosedBanner. GitHub's chip row shows an archive-prompt banner when a task's PR merges or closes. No GitLab counterpart is added here. - Opening the MR detail panel from the chip popover.
MRCIPopover'sonOpenDetailPanelprop is optional and the chip omits it, so the popover title renders as static text. The dockview-settle logic that makes that open reliable is owned by the topbar button, which remains the entry point. - Giving GitLab MR hydration a single provider-level owner. It currently has four independent call sites, each with its own per-instance "already fetched" ref, so which surfaces a session has visited decides whether a given task's MRs are in the store. Consolidating them is its own card. Until it lands, this spec pins no hydration outcome and no AC depends on one. See Sync and freshness.
- A shared GitLab MR feedback cache or background sync. No store slice, no warmer, no polling is added.
- Making
useGitLabStatuscache across mounts, and stopping it blanking the shared status. Its effect refetches on every mount and clears the store tonullfirst, so each new consumer costs a request and transiently makesuseGitLabAvailable()readfalseeverywhere. The chip inherits this rather than introducing it —MRTopbarButtonalready calls the same helper on the same route — and cannot fix it locally, because the hook serves navigation, the kanban board, the settings sidebar and the integrations menu. Fixing it means a card that can test all of those callers. See Sync and freshness. - A cross-chip disclosure coordinator. Two chips mounted for one task may
both have an open disclosure, and each then issues its own MR feedback read.
Serialising that would require new shared "currently open chip" state that
neither mount point nor
MRTopbarButtonhas today, and a rule for which chip loses. The duplicate is an idempotentGETinto a per-instance reducer. See Sync and freshness. - A cross-mount in-flight guard for
useTaskMRAutomationOptions. Its lazy fetch gates on render-captured state, so two chips mounted in one React commit can each issue aGET. Fixing it means changing a hookMRAutomationControlsalso uses from the topbar, so it needs a card that can test both callers. The duplicate is an idempotentGETthat converges to one stored object. See Sync and freshness. - Restoring focus when the chip unmounts underneath its own drawer.
Unlinking the task's last open MR from inside the drawer closes the drawer and
unmounts the trigger in the same update, so there is nothing to return focus
to and it falls to
document.body. Choosing and moving focus to a surviving landmark is a shared problem withPRStatusChiprather than one this chip introduces, and fixing it well means deciding a focus target for the whole status row. See Accessibility. - Making
MRCIPopover's controls Tab-reachable from a hover popover. Radix portals the content with no tab bridge from the trigger, so link, unlink and merge are pointer- or drawer-only on a fine-pointer device. This gap is pre-existing and shared withPRStatusChipandMRTopbarButton; the chip inherits it rather than creating it, and fixing it would change a component the topbar also renders. See Accessibility. - Changing
aggregateMRStatusColor's array-order tie behaviour. Its first-in-input-order tie is observable on the multi-MR kanban badge today. This card freezes it rather than fixing it, because changing it is re-tuning the colour priority, already excluded above. See State machine. - Enriched My-GitLab list badges, per-job checks UI in the detail panel, mergeability explanation or conflict-fix CTA, merge-method picker, GitLab webhooks, poller rate limiting, and poller-side watch reconciliation.
- Depending on the parent card's
resolvePushRepofix. This feature is independent in code. Its tests seed MR associations directly through the existing link endpoint and the GitLab mock provider, never through autolink.
Constraints
-
New files SHALL be at most 600 lines and new components at most 200 lines. The GitHub original is 634 lines in one file and is not a shape to copy; the trigger, the glyph, the disclosure variants and the status derivation are separate units.
-
New copy SHALL use a
mrChip*key prefix inapps/web/src/locales/en/gitlab.json, following the same surface-scoped prefix convention as the existingmrPopover*andmrAutomation*keys. Copy that already exists undermrPopover*SHALL be reused rather than duplicated.data-testidvalues anddata-statusvalues are identifiers and SHALL NOT be translated. -
Every new file SHALL be appended to
i18nGuardFilesinapps/web/eslint.i18n.options.mjsin the same change. -
E2E coverage is required. The page object SHALL mirror the shape of
apps/web/e2e/pages/session-page.tstapPRStatusChip, exposingmrStatusChip(),mrStatusChipDrawer(),mrStatusChipDrawerClose(),mrStatusChipPopoverInner()andtapMRStatusChip().Mirroring the shape means mirroring its scoping, not just its arity.
prStatusChip()is not a baregetByTestId; it resolvesactiveChat()->chat-status-bar->pr-status-chip, which is what keeps it from tripping Playwright's strict-locator check when more than one status row is mounted.mrStatusChip()SHALL do the same forchat-status-bar. Because this feature also has a scenario against the passthrough row, the page object SHALL additionally exposemrStatusChipInPassthrough(), scoped topassthrough-status-row. Without it the mandated zero-argument set can reach only one of the two surfaces this spec requires coverage of. No accessor may resolvemr-status-chipglobally or paper over duplicates with.first(). -
Unit coverage SHALL include the
getMRStatusColorandaggregateMRStatusColorparity tables described above, updatingmr-task-icon.test.tsandmr-status-colour-parity.test.tsx. TheaggregateMRStatusColortable SHALL include at least one all-terminal pair in both input orders, so the preserved array-order tie is pinned rather than assumed. -
File boundary. The rule below governs production React source only. It does not and cannot restrict the supporting files this same section mandates: the locale catalog
apps/web/src/locales/en/gitlab.json, the guard listapps/web/eslint.i18n.options.mjs, the page objectapps/web/e2e/pages/session-page.ts, new specs underapps/web/e2e/, and the unit tests named above are all required edits and all sit outsidecomponents/gitlab/. An earlier draft stated the rule without that carve-out and so contradicted its own i18n and E2E requirements.Within production React source: the one permitted change outside
components/gitlab/and the two mount points is extractinguseUnlinkTaskMRintoapps/web/hooks/domains/gitlab/use-task-mr.tsand pointingMRTopbarButtonat it.MRTopbarButton's rendered output and observable behaviour SHALL be unchanged, andcomponents/github/SHALL NOT be touched at all.components/azure-devops/SHALL NOT be touched either — which is why the Azure leg of the DOM ordering has no AC.
Related
- gitlab-integration: the umbrella GitLab feature this chip sits inside.
apps/web/CLAUDE.md, "GitHub PR status UI": the GitHub-side invariant this feature's single-derivation requirement mirrors for GitLab.