diff --git a/apps/web/components/gitlab/mr-status-chip-drawer.tsx b/apps/web/components/gitlab/mr-status-chip-drawer.tsx
new file mode 100644
index 0000000000..152b37f14f
--- /dev/null
+++ b/apps/web/components/gitlab/mr-status-chip-drawer.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { IconX } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@kandev/ui/drawer";
+import { Button } from "@kandev/ui/button";
+import { MRCIPopover } from "./mr-ci-popover";
+import { MRStatusChipTriggerContent, useMRChipTriggerAttrs } from "./mr-status-chip-trigger";
+import { useFrozenChipSelection, type ChipAutomation } from "./mr-status-chip-selection";
+import type { MRChipStatus } from "./mr-task-icon";
+import type { TaskMR } from "@/lib/types/gitlab";
+
+/** Coarse-pointer disclosure variant: the same MRCIPopover body inside a bottom-sheet drawer. */
+export function MRStatusChipDrawer({
+ mrs,
+ openCount,
+ liveSelected,
+ liveStatus,
+ automation,
+ taskId,
+ canLink,
+ onUnlink,
+ onLink,
+}: {
+ mrs: TaskMR[];
+ openCount: number;
+ liveSelected: TaskMR;
+ liveStatus: MRChipStatus;
+ automation: ChipAutomation;
+ taskId: string;
+ canLink: boolean;
+ onUnlink: (associationId: string) => void;
+ onLink: () => void;
+}) {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const { actedOnMR, frozen, shouldForceClose } = useFrozenChipSelection(mrs, liveSelected, open);
+
+ useEffect(() => {
+ if (shouldForceClose) setOpen(false);
+ }, [shouldForceClose]);
+
+ const attrs = useMRChipTriggerAttrs({ openCount, liveStatus, actedOnMR, frozen });
+ // The chip closes its own disclosure before the link dialog opens: the
+ // drawer is itself a focus-trapping dialog, so opening a second one
+ // inside it would strand focus (spec: Link and unlink from the chip).
+ const handleLink = () => {
+ setOpen(false);
+ onLink();
+ };
+
+ return (
+
+ {/* asChild registers this button as Radix's Dialog trigger ref, which its
+ default onCloseAutoFocus reads to restore focus on close — an
+ untracked plain button leaves nothing to focus and it falls back to
+ document.body (spec: Accessibility, "closing the drawer returns
+ focus to the chip trigger"). */}
+
+
+
+
+
+
+ {t("gitlab:mrChipDrawerTitle", { mriid: actedOnMR.mr_iid })}
+
+
+ {t("gitlab:mrChipDrawerDescription")}
+
+
+
+
+
+
+ {/* See mr-status-chip-popover.tsx for why shouldForceClose gates
+ this render: actedOnMR falls back to the new live selection in
+ the same commit the frozen MR vanishes, before the closing
+ effect below fires. */}
+ {!shouldForceClose && (
+ onUnlink(actedOnMR.id)}
+ />
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/components/gitlab/mr-status-chip-popover.tsx b/apps/web/components/gitlab/mr-status-chip-popover.tsx
new file mode 100644
index 0000000000..2405255d20
--- /dev/null
+++ b/apps/web/components/gitlab/mr-status-chip-popover.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import { useEffect } from "react";
+import { Popover, PopoverAnchor, PopoverContent } from "@kandev/ui/popover";
+import { useMRPopoverInteractions } from "./mr-topbar-button";
+import { MRCIPopover } from "./mr-ci-popover";
+import { MRStatusChipTriggerContent, useMRChipTriggerAttrs } from "./mr-status-chip-trigger";
+import {
+ useFrozenChipSelection,
+ useTriggerOutsideGuard,
+ type ChipAutomation,
+} from "./mr-status-chip-selection";
+import type { MRChipStatus } from "./mr-task-icon";
+import type { TaskMR } from "@/lib/types/gitlab";
+
+/**
+ * Fine-pointer disclosure variant: reuses the topbar button's own hover
+ * lifecycle (useMRPopoverInteractions) so the 13-handler wiring can't drift
+ * from `MRTopbarButton`'s (spec: Accessibility, "prefer exporting and
+ * reusing" over hand-copying).
+ */
+export function MRStatusChipPopover({
+ mrs,
+ openCount,
+ liveSelected,
+ liveStatus,
+ automation,
+ taskId,
+ canLink,
+ onUnlink,
+ onLink,
+}: {
+ mrs: TaskMR[];
+ openCount: number;
+ liveSelected: TaskMR;
+ liveStatus: MRChipStatus;
+ automation: ChipAutomation;
+ taskId: string;
+ canLink: boolean;
+ onUnlink: (associationId: string) => void;
+ onLink: () => void;
+}) {
+ const { open, onOpenChange, onTriggerEnter, onTriggerLeave, onContentEnter, onContentLeave } =
+ useMRPopoverInteractions();
+ const { actedOnMR, frozen, shouldForceClose } = useFrozenChipSelection(mrs, liveSelected, open);
+ const { ref, onPointerDownOutside } = useTriggerOutsideGuard();
+
+ useEffect(() => {
+ if (shouldForceClose) onOpenChange(false);
+ }, [shouldForceClose, onOpenChange]);
+
+ const attrs = useMRChipTriggerAttrs({ openCount, liveStatus, actedOnMR, frozen });
+ // The chip closes its own disclosure before the link dialog opens
+ // (spec: Link and unlink from the chip, "closing first is required").
+ const handleLink = () => {
+ onOpenChange(false);
+ onLink();
+ };
+
+ return (
+
+
+
+
+ e.preventDefault()}
+ >
+ {/* shouldForceClose fires the same render `actedOnMR` falls back
+ from the vanished frozen MR to the new live selection — before
+ the effect below has a chance to close the disclosure. Skipping
+ the render here for that one commit means the popover is never
+ shown with merge/unlink controls wired to a swapped-in MR the
+ user did not open it on (spec: "an action target that swaps
+ under the pointer mid-interaction is a hazard"). */}
+ {!shouldForceClose && (
+ onUnlink(actedOnMR.id)}
+ />
+ )}
+
+
+ );
+}
diff --git a/apps/web/components/gitlab/mr-status-chip-selection.test.ts b/apps/web/components/gitlab/mr-status-chip-selection.test.ts
new file mode 100644
index 0000000000..deb654bfd4
--- /dev/null
+++ b/apps/web/components/gitlab/mr-status-chip-selection.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, it } from "vitest";
+import { chipAutomation, selectBadgeMR } from "./mr-status-chip-selection";
+import type { TaskMR, TaskMRAutomationOptions, TaskMRLifecycleState } from "@/lib/types/gitlab";
+
+function makeMR(overrides: Partial = {}): TaskMR {
+ return {
+ id: "association-1",
+ task_id: "task-1",
+ host: "https://gitlab.example",
+ project_path: "group/project",
+ mr_iid: 81,
+ mr_url: "https://gitlab.example/group/project/-/merge_requests/81",
+ mr_title: "Test MR",
+ head_branch: "feature",
+ base_branch: "main",
+ author_username: "alice",
+ state: "open",
+ approval_state: "",
+ pipeline_state: "",
+ merge_status: "",
+ draft: false,
+ approval_count: 0,
+ required_approvals: 0,
+ pipeline_jobs_total: 0,
+ pipeline_jobs_pass: 0,
+ reviewer_count: 0,
+ unapproved_reviewers: 0,
+ unresolved_discussions: 0,
+ created_at: "",
+ updated_at: "",
+ ...overrides,
+ };
+}
+
+function makeState(overrides: Partial = {}): TaskMRLifecycleState {
+ return {
+ task_id: "task-1",
+ repository_id: "",
+ project_path: "group/project",
+ mr_iid: 81,
+ review_request_initialized: false,
+ last_review_requested: false,
+ last_observed_state: "",
+ last_lifecycle_event: "",
+ last_fix_signature: "",
+ last_fix_checkpoint_json: "",
+ last_merge_signature: "",
+ created_at: "",
+ updated_at: "",
+ auto_fix_round_count: 0,
+ ...overrides,
+ };
+}
+
+function makeAutomation(overrides: Partial = {}): TaskMRAutomationOptions {
+ return {
+ task_id: "task-1",
+ auto_fix_enabled: true,
+ auto_merge_enabled: false,
+ auto_fix_max_rounds: 5,
+ effective_auto_fix_prompt: "",
+ using_default_prompt: true,
+ prompt_on_review_requested: false,
+ prompt_on_merged: false,
+ prompt_on_closed: false,
+ review_reviewer_username: "",
+ updated_at: "",
+ mr_states: [],
+ ...overrides,
+ };
+}
+
+// spec: Selection and ordering, "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, then the
+// same mr_iid/project_path/id order selectChipMR uses. Previously had zero
+// coverage of any kind (Review round 1 finding #1, mutation-proven: with
+// isBadgeMRBetter neutered to `return false`, the full suite stayed green).
+describe("selectBadgeMR tiebreak", () => {
+ it("prefers an exhausted round over a non-exhausted higher round (spec.md:1063-1065)", () => {
+ const exhausted = makeMR({ id: "a", mr_iid: 3 });
+ const notExhausted = makeMR({ id: "b", mr_iid: 9 });
+ const options = makeAutomation({
+ mr_states: [
+ makeState({
+ mr_iid: 3,
+ auto_fix_round_count: 3,
+ auto_fix_exhausted_at: "2026-08-11T00:00:00Z",
+ }),
+ makeState({ mr_iid: 9, auto_fix_round_count: 4 }),
+ ],
+ });
+
+ // Both input orders, so a first-in-array bug can't hide behind a
+ // coincidentally-correct fixture order.
+ expect(selectBadgeMR([exhausted, notExhausted], options)?.id).toBe("a");
+ expect(selectBadgeMR([notExhausted, exhausted], options)?.id).toBe("a");
+ });
+
+ it("prefers a higher current round when neither is exhausted", () => {
+ const lowerRound = makeMR({ id: "a", mr_iid: 1 });
+ const higherRound = makeMR({ id: "b", mr_iid: 2 });
+ const options = makeAutomation({
+ mr_states: [
+ makeState({ mr_iid: 1, auto_fix_round_count: 1 }),
+ makeState({ mr_iid: 2, auto_fix_round_count: 4 }),
+ ],
+ });
+
+ expect(selectBadgeMR([lowerRound, higherRound], options)?.id).toBe("b");
+ expect(selectBadgeMR([higherRound, lowerRound], options)?.id).toBe("b");
+ });
+
+ it("resolves a fully-tied round by mr_iid ascending, iid 7 beating iid 12 (spec.md:1066-1071)", () => {
+ const higherIID = makeMR({ id: "a", mr_iid: 12 });
+ const lowerIID = makeMR({ id: "b", mr_iid: 7 });
+ const options = makeAutomation({
+ mr_states: [
+ makeState({ mr_iid: 12, auto_fix_round_count: 2 }),
+ makeState({ mr_iid: 7, auto_fix_round_count: 2 }),
+ ],
+ });
+
+ // For either input order, the winner is the lower mr_iid (7), not
+ // whichever MR happens to come first in the array.
+ expect(selectBadgeMR([higherIID, lowerIID], options)?.mr_iid).toBe(7);
+ expect(selectBadgeMR([lowerIID, higherIID], options)?.mr_iid).toBe(7);
+ });
+
+ it("returns null when auto-fix is disabled", () => {
+ const mr = makeMR();
+ expect(selectBadgeMR([mr], makeAutomation({ auto_fix_enabled: false }))).toBeNull();
+ });
+});
+
+// Integration-level confirmation that chipAutomation's returned round info
+// actually comes from the badge-selected MR identified above.
+describe("chipAutomation", () => {
+ it("returns the badge-selected MR's own round info", () => {
+ const exhausted = makeMR({ id: "a", mr_iid: 3 });
+ const notExhausted = makeMR({ id: "b", mr_iid: 9 });
+ const options = makeAutomation({
+ mr_states: [
+ makeState({
+ mr_iid: 3,
+ auto_fix_round_count: 3,
+ auto_fix_exhausted_at: "2026-08-11T00:00:00Z",
+ }),
+ makeState({ mr_iid: 9, auto_fix_round_count: 4 }),
+ ],
+ });
+
+ const result = chipAutomation(options, [exhausted, notExhausted]);
+
+ expect(result.autoFixRound).toEqual({ current: 3, max: 5, exhausted: true });
+ });
+});
diff --git a/apps/web/components/gitlab/mr-status-chip-selection.ts b/apps/web/components/gitlab/mr-status-chip-selection.ts
new file mode 100644
index 0000000000..8c85cd639f
--- /dev/null
+++ b/apps/web/components/gitlab/mr-status-chip-selection.ts
@@ -0,0 +1,122 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { compareChipMR } from "./mr-task-icon";
+import {
+ autoFixRoundForState,
+ findMRAutomationStateForMR,
+ type AutoFixRoundInfo,
+} from "@/lib/gitlab/mr-automation";
+import type { TaskMR, TaskMRAutomationOptions } from "@/lib/types/gitlab";
+
+export type ChipAutomation = {
+ autoFixEnabled: boolean;
+ autoMergeEnabled: boolean;
+ autoFixRound: AutoFixRoundInfo | null;
+};
+
+function autoFixRoundFor(options: TaskMRAutomationOptions | null, mr: TaskMR): AutoFixRoundInfo {
+ return autoFixRoundForState(
+ findMRAutomationStateForMR(options?.mr_states, mr),
+ options?.auto_fix_max_rounds,
+ );
+}
+
+function isBadgeMRBetter(
+ candidateRound: AutoFixRoundInfo,
+ candidate: TaskMR,
+ bestRound: AutoFixRoundInfo,
+ best: TaskMR,
+): boolean {
+ if (candidateRound.exhausted !== bestRound.exhausted) return candidateRound.exhausted;
+ if (candidateRound.current !== bestRound.current)
+ return candidateRound.current > bestRound.current;
+ return compareChipMR(candidate, best) < 0;
+}
+
+/**
+ * The badge-selected MR: the most attention-worthy auto-fix round across the
+ * open MRs — an exhausted round beats a non-exhausted one, then a higher
+ * `current` wins, then the same `mr_iid` / `project_path` / `id` order
+ * `selectChipMR` uses (spec: Selection and ordering). Distinct from the live
+ * and frozen selections, and deliberately never frozen (spec: "The
+ * automation badges do NOT freeze").
+ */
+export function selectBadgeMR(
+ openMRs: TaskMR[],
+ options: TaskMRAutomationOptions | null,
+): TaskMR | null {
+ if (!options?.auto_fix_enabled) return null;
+ let best: TaskMR | null = null;
+ let bestRound: AutoFixRoundInfo | null = null;
+ for (const mr of openMRs) {
+ const round = autoFixRoundFor(options, mr);
+ if (!best || !bestRound || isBadgeMRBetter(round, mr, bestRound, best)) {
+ best = mr;
+ bestRound = round;
+ }
+ }
+ return best;
+}
+
+/** Automation flags + round info for the chip's badges, live (never frozen). */
+export function chipAutomation(
+ options: TaskMRAutomationOptions | null,
+ openMRs: TaskMR[],
+): ChipAutomation {
+ const badgeMR = selectBadgeMR(openMRs, options);
+ return {
+ autoFixEnabled: Boolean(options?.auto_fix_enabled),
+ autoMergeEnabled: Boolean(options?.auto_merge_enabled),
+ autoFixRound: badgeMR ? autoFixRoundFor(options, badgeMR) : null,
+ };
+}
+
+/**
+ * Freezes which MR the disclosure acts on by association `id` while it is
+ * open (spec: "Freezing while the disclosure is open"). Captures `id` only,
+ * never a copy of the TaskMR object, so the returned `actedOnMR` always
+ * re-reads the live store row for that id. Deliberately local to whichever
+ * disclosure variant (popover vs drawer) calls it: a pointer-precision
+ * change unmounts one variant and mounts the other, so this state resets to
+ * unfrozen on that transition with no extra code (spec round-4 finding
+ * F31) — the freeze can never survive a variant swap because there is
+ * nothing above the swap point holding it.
+ */
+export function useFrozenChipSelection(mrs: TaskMR[], liveSelected: TaskMR, open: boolean) {
+ const [frozenId, setFrozenId] = useState(null);
+ const wasOpen = useRef(false);
+
+ useEffect(() => {
+ if (open && !wasOpen.current) setFrozenId(liveSelected.id);
+ if (!open) setFrozenId(null);
+ wasOpen.current = open;
+ }, [open, liveSelected.id]);
+
+ const frozenMR = frozenId
+ ? (mrs.find((mr) => mr.id === frozenId && mr.state === "open") ?? null)
+ : null;
+ // If the held id leaves the store, or its row stops being open, the
+ // disclosure closes (spec). The caller effects on `shouldForceClose`.
+ const shouldForceClose = frozenId !== null && frozenMR === null;
+ const actedOnMR = frozenMR ?? liveSelected;
+ return { actedOnMR, frozen: frozenId !== null && !shouldForceClose, shouldForceClose };
+}
+
+/**
+ * Radix treats the trigger as outside the popover content, so a click on
+ * the chip itself would otherwise close it via onPointerDownOutside. This
+ * guard filters out trigger clicks so clicking the chip is a no-op while
+ * the popover stays open (spec: "Clicking the fine-pointer trigger is a
+ * no-op in both states").
+ */
+export function useTriggerOutsideGuard() {
+ const ref = useRef(null);
+ const onPointerDownOutside = useCallback(
+ (e: { target: EventTarget | null; preventDefault: () => void }) => {
+ if (ref.current && ref.current.contains(e.target as Node)) e.preventDefault();
+ },
+ [],
+ );
+ return { ref, onPointerDownOutside };
+}
diff --git a/apps/web/components/gitlab/mr-status-chip-trigger.tsx b/apps/web/components/gitlab/mr-status-chip-trigger.tsx
new file mode 100644
index 0000000000..e53eef56d4
--- /dev/null
+++ b/apps/web/components/gitlab/mr-status-chip-trigger.tsx
@@ -0,0 +1,175 @@
+"use client";
+
+import {
+ IconCircleCheckFilled,
+ IconCircleXFilled,
+ IconClock,
+ IconGitMerge,
+ IconLoader2,
+ IconPointFilled,
+} from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { cn } from "@/lib/utils";
+import { isMRReadyToMerge, mrChipStatus } from "./mr-task-icon";
+import type { MRChipStatus } from "./mr-task-icon";
+import type { ChipAutomation } from "./mr-status-chip-selection";
+import type { TaskMR } from "@/lib/types/gitlab";
+
+export const MR_CHIP_TRIGGER_CLASS =
+ "cursor-pointer inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-xs";
+
+const STATUS_LABEL_KEY: Record = {
+ merged: "gitlab:mrChipStatusMerged",
+ closed: "gitlab:mrChipStatusClosed",
+ failed: "gitlab:mrChipStatusFailed",
+ draft: "gitlab:mrChipStatusDraft",
+ ready: "gitlab:mrChipStatusReady",
+ awaiting_approval: "gitlab:mrChipStatusAwaitingApproval",
+ running: "gitlab:mrChipStatusRunning",
+ neutral: "gitlab:mrChipStatusNeutral",
+};
+
+export function MRStatusChipGlyph({ status }: { status: MRChipStatus }) {
+ const cls = "h-3.5 w-3.5";
+ const testProps = { "data-testid": "mr-status-glyph", "data-status": status } as const;
+ switch (status) {
+ case "merged":
+ return (
+
+ );
+ case "failed":
+ return (
+
+ );
+ case "ready":
+ return (
+
+ );
+ case "awaiting_approval":
+ return ;
+ case "running":
+ return (
+
+ );
+ default:
+ // closed, draft, neutral all render the same muted dot.
+ return (
+
+ );
+ }
+}
+
+// The aria-label describes the acted-on MR, so its status word is the
+// acted-on MR's OWN status (mrChipStatus(actedOnMR)) — it deliberately does
+// NOT read the live-tracking `liveStatus` that drives `data-status` and the
+// glyph. Diverging on that point would rename the label mid-interaction to a
+// status that belongs to a different MR than the one the disclosure is
+// actually showing/acting on (spec: Accessibility, "The aria-label describes
+// the acted-on MR").
+function useMRChipAriaLabel(openCount: number, actedOnMR: TaskMR) {
+ const { t } = useTranslation();
+ const status = t(STATUS_LABEL_KEY[mrChipStatus(actedOnMR)]);
+ if (openCount > 1) return t("gitlab:mrChipAriaLabelMulti", { count: openCount, status });
+ return t("gitlab:mrChipAriaLabelSingle", { mriid: actedOnMR.mr_iid, status });
+}
+
+export type MRChipTriggerAttrs = {
+ "data-testid": "mr-status-chip";
+ "data-status": MRChipStatus;
+ "data-mr-count": number;
+ "data-mr-iid": number;
+ "data-mr-state": string;
+ "data-mr-ready-to-merge": "true" | "false";
+ "data-selection-frozen": "true" | "false";
+ "aria-label": string;
+ className: string;
+};
+
+export function useMRChipTriggerAttrs({
+ openCount,
+ liveStatus,
+ actedOnMR,
+ frozen,
+}: {
+ openCount: number;
+ liveStatus: MRChipStatus;
+ actedOnMR: TaskMR;
+ frozen: boolean;
+}): MRChipTriggerAttrs {
+ return {
+ "data-testid": "mr-status-chip",
+ "data-status": liveStatus,
+ "data-mr-count": openCount,
+ "data-mr-iid": actedOnMR.mr_iid,
+ "data-mr-state": actedOnMR.state,
+ "data-mr-ready-to-merge": isMRReadyToMerge(actedOnMR) ? "true" : "false",
+ "data-selection-frozen": frozen ? "true" : "false",
+ "aria-label": useMRChipAriaLabel(openCount, actedOnMR),
+ className: MR_CHIP_TRIGGER_CLASS,
+ };
+}
+
+function AutomationFlagBadges({ automation }: { automation: ChipAutomation }) {
+ const { t } = useTranslation();
+ if (!automation.autoFixEnabled && !automation.autoMergeEnabled) return null;
+ const round = automation.autoFixRound;
+ return (
+ <>
+ {automation.autoFixEnabled && round && (
+
+ {t("gitlab:mrChipAutoFix")} {round.current}/{round.max}
+
+ )}
+ {automation.autoMergeEnabled && (
+
+ {t("gitlab:mrChipAutoMerge")}
+
+ )}
+ >
+ );
+}
+
+export function MRStatusChipTriggerContent({
+ openCount,
+ liveStatus,
+ automation,
+}: {
+ openCount: number;
+ liveStatus: MRChipStatus;
+ automation: ChipAutomation;
+}) {
+ return (
+ <>
+
+
+ {openCount > 1 && (
+ {openCount}
+ )}
+
+ >
+ );
+}
diff --git a/apps/web/components/gitlab/mr-status-chip.test.tsx b/apps/web/components/gitlab/mr-status-chip.test.tsx
new file mode 100644
index 0000000000..9975e3f193
--- /dev/null
+++ b/apps/web/components/gitlab/mr-status-chip.test.tsx
@@ -0,0 +1,426 @@
+import { createElement } from "react";
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useHoverPopover } from "@/hooks/domains/github/use-hover-popover";
+import { MRStatusChip } from "./mr-status-chip";
+import type { TaskMR, TaskMRAutomationOptions } from "@/lib/types/gitlab";
+
+const OPEN_DELAY_MS = 150;
+const CHIP_TESTID = "mr-status-chip";
+const DATA_MR_IID = "data-mr-iid";
+const DATA_SELECTION_FROZEN = "data-selection-frozen";
+const DATA_MR_COUNT = "data-mr-count";
+const DATA_STATUS = "data-status";
+const POPOVER_STUB_TESTID = "mr-ci-popover-stub";
+
+const gitlabMocks = vi.hoisted(() => ({ mrs: [] as TaskMR[] }));
+const touchMocks = vi.hoisted(() => ({ usesTouchDrawer: false }));
+const automationMocks = vi.hoisted(() => ({
+ options: null as TaskMRAutomationOptions | null,
+}));
+const dialogMocks = vi.hoisted(() => ({ lastProps: null as { open: boolean } | null }));
+const unlinkMock = vi.hoisted(() => vi.fn(async () => {}));
+const popoverRenderMRIIDs = vi.hoisted(() => [] as number[]);
+
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (state: unknown) => unknown) =>
+ selector({
+ workspaces: { activeId: "workspace-1" },
+ repositories: { itemsByWorkspaceId: { "workspace-1": [] } },
+ }),
+}));
+
+vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({
+ useGitLabAvailable: () => true,
+ useTaskMRs: () => gitlabMocks.mrs,
+ useUnlinkTaskMR: () => unlinkMock,
+}));
+
+vi.mock("@/hooks/domains/gitlab/use-task-mr-automation", () => ({
+ useTaskMRAutomationOptions: () => ({ options: automationMocks.options }),
+}));
+
+vi.mock("@/hooks/domains/kanban/use-task-by-id", () => ({
+ useTaskById: () => ({ id: "task-1", repositories: [] }),
+}));
+
+vi.mock("@/hooks/use-compact-task-chrome", () => ({
+ useTouchDrawer: () => touchMocks.usesTouchDrawer,
+}));
+
+vi.mock("./task-mr-link-dialog", () => ({
+ TaskMRLinkDialog: (props: { open: boolean }) => {
+ dialogMocks.lastProps = props;
+ return ;
+ },
+}));
+
+vi.mock("./mr-ci-popover", () => ({
+ MRCIPopover: ({
+ mr,
+ onLink,
+ onUnlink,
+ }: {
+ mr: TaskMR;
+ onLink: () => void;
+ onUnlink: () => void;
+ }) => {
+ popoverRenderMRIIDs.push(mr.mr_iid);
+ return (
+
+ popover for !{mr.mr_iid}
+
+
+
+ );
+ },
+}));
+
+vi.mock("./mr-topbar-button", () => ({
+ useMRPopoverInteractions: () => {
+ const hover = useHoverPopover({ openDelayMs: OPEN_DELAY_MS, closeDelayMs: OPEN_DELAY_MS });
+ return { usesTouchDrawer: false, ...hover };
+ },
+}));
+
+function makeMR(overrides: Partial = {}): TaskMR {
+ return {
+ id: "association-1",
+ task_id: "task-1",
+ host: "https://gitlab.example",
+ project_path: "group/project",
+ mr_iid: 81,
+ mr_url: "https://gitlab.example/group/project/-/merge_requests/81",
+ mr_title: "Test MR",
+ head_branch: "feature",
+ base_branch: "main",
+ author_username: "alice",
+ state: "open",
+ approval_state: "",
+ pipeline_state: "",
+ merge_status: "",
+ draft: false,
+ approval_count: 0,
+ required_approvals: 0,
+ pipeline_jobs_total: 0,
+ pipeline_jobs_pass: 0,
+ reviewer_count: 0,
+ unapproved_reviewers: 0,
+ unresolved_discussions: 0,
+ created_at: "",
+ updated_at: "",
+ ...overrides,
+ };
+}
+
+function makeAutomation(overrides: Partial = {}): TaskMRAutomationOptions {
+ return {
+ task_id: "task-1",
+ auto_fix_enabled: false,
+ auto_merge_enabled: false,
+ auto_fix_max_rounds: 10,
+ effective_auto_fix_prompt: "",
+ using_default_prompt: true,
+ prompt_on_review_requested: false,
+ prompt_on_merged: false,
+ prompt_on_closed: false,
+ review_reviewer_username: "",
+ updated_at: "",
+ mr_states: [],
+ ...overrides,
+ };
+}
+
+function resetChipMocks() {
+ vi.useFakeTimers();
+ touchMocks.usesTouchDrawer = false;
+ automationMocks.options = null;
+ dialogMocks.lastProps = null;
+ unlinkMock.mockClear();
+ popoverRenderMRIIDs.length = 0;
+}
+
+function teardownChipMocks() {
+ cleanup();
+ vi.useRealTimers();
+}
+
+describe("MRStatusChip rendering and selection", () => {
+ beforeEach(resetChipMocks);
+ afterEach(teardownChipMocks);
+
+ it("renders nothing when the task has no linked MRs", () => {
+ gitlabMocks.mrs = [];
+ render(createElement(MRStatusChip, { taskId: "task-1" }));
+ expect(screen.queryByTestId(CHIP_TESTID)).toBeNull();
+ });
+
+ it("renders nothing when the only linked MR is terminal", () => {
+ gitlabMocks.mrs = [makeMR({ state: "merged" })];
+ render(createElement(MRStatusChip, { taskId: "task-1" }));
+ expect(screen.queryByTestId(CHIP_TESTID)).toBeNull();
+ });
+
+ it("renders the trigger for a single open MR with the expected attributes", () => {
+ gitlabMocks.mrs = [makeMR({ pipeline_state: "failure" })];
+ render(createElement(MRStatusChip, { taskId: "task-1" }));
+ const trigger = screen.getByTestId(CHIP_TESTID);
+ expect(trigger.getAttribute(DATA_STATUS)).toBe("failed");
+ expect(trigger.getAttribute(DATA_MR_COUNT)).toBe("1");
+ expect(trigger.getAttribute(DATA_MR_IID)).toBe("81");
+ expect(trigger.getAttribute("data-mr-state")).toBe("open");
+ expect(trigger.getAttribute("data-mr-ready-to-merge")).toBe("false");
+ expect(trigger.getAttribute(DATA_SELECTION_FROZEN)).toBe("false");
+ });
+
+ it("selects the highest-ranked open MR and reports the total open count", () => {
+ gitlabMocks.mrs = [
+ makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
+ makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }),
+ makeMR({ id: "c", mr_iid: 3, state: "merged" }),
+ ];
+ render(createElement(MRStatusChip, { taskId: "task-1" }));
+ const trigger = screen.getByTestId(CHIP_TESTID);
+ expect(trigger.getAttribute(DATA_STATUS)).toBe("failed");
+ expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
+ // Terminal MR is not counted toward data-mr-count.
+ expect(trigger.getAttribute(DATA_MR_COUNT)).toBe("2");
+ });
+
+ it("renders the auto-fix badge at round 0 with no lifecycle state, and the auto-merge badge", () => {
+ gitlabMocks.mrs = [makeMR()];
+ automationMocks.options = makeAutomation({
+ auto_fix_enabled: true,
+ auto_fix_max_rounds: 5,
+ auto_merge_enabled: true,
+ });
+ render(createElement(MRStatusChip, { taskId: "task-1" }));
+ const badge = screen.getByTestId("mr-status-auto-fix-chip");
+ expect(badge.textContent).toContain("0/5");
+ expect(badge.getAttribute("data-auto-fix-exhausted")).toBe("false");
+ expect(screen.getByTestId("mr-status-auto-merge-chip")).toBeTruthy();
+ });
+
+ it("renders neither badge when automation is disabled", () => {
+ gitlabMocks.mrs = [makeMR()];
+ automationMocks.options = makeAutomation();
+ render(createElement(MRStatusChip, { taskId: "task-1" }));
+ expect(screen.queryByTestId("mr-status-auto-fix-chip")).toBeNull();
+ expect(screen.queryByTestId("mr-status-auto-merge-chip")).toBeNull();
+ });
+});
+
+describe("MRStatusChip disclosure interactions", () => {
+ beforeEach(resetChipMocks);
+ afterEach(teardownChipMocks);
+
+ it("opens the hover popover on the fine-pointer variant and freezes the acted-on MR while open", () => {
+ gitlabMocks.mrs = [
+ makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
+ makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }),
+ ];
+ const { rerender } = render(createElement(MRStatusChip, { taskId: "task-1" }));
+ const trigger = screen.getByTestId(CHIP_TESTID);
+
+ act(() => {
+ fireEvent.mouseEnter(trigger);
+ vi.advanceTimersByTime(OPEN_DELAY_MS);
+ });
+
+ expect(screen.getByTestId(POPOVER_STUB_TESTID).textContent).toContain("!2");
+ expect(trigger.getAttribute(DATA_SELECTION_FROZEN)).toBe("true");
+ expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
+
+ // Store update makes MR "a" the new live selection (now also failed,
+ // and it outranks "b" by the mr_iid tiebreak) while the popover stays
+ // open on "b": data-status tracks the live selection, data-mr-iid and
+ // data-selection-frozen stay frozen on "b" (spec: Selection - freezing
+ // while the disclosure is open). A `rerender()` is required — mutating
+ // the hoisted mock array alone does not re-render the component.
+ gitlabMocks.mrs = [
+ makeMR({ id: "a", mr_iid: 1, pipeline_state: "failure" }),
+ makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }),
+ ];
+ act(() => {
+ rerender(createElement(MRStatusChip, { taskId: "task-1" }));
+ });
+
+ expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
+ expect(trigger.getAttribute(DATA_SELECTION_FROZEN)).toBe("true");
+ expect(trigger.getAttribute(DATA_STATUS)).toBe("failed");
+ });
+
+ it("keeps the aria-label naming the acted-on MR's own status while frozen, not the live selection's", () => {
+ gitlabMocks.mrs = [
+ makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }), // running, rank 4
+ makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }), // failed, rank 5
+ ];
+ const { rerender } = render(createElement(MRStatusChip, { taskId: "task-1" }));
+ const trigger = screen.getByTestId(CHIP_TESTID);
+
+ act(() => {
+ fireEvent.mouseEnter(trigger);
+ vi.advanceTimersByTime(OPEN_DELAY_MS);
+ });
+
+ // Frozen on MR "b" (failed) — live and acted-on agree here, a sanity check.
+ expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
+ expect(trigger.getAttribute("aria-label")).toContain("pipeline failed");
+
+ // MR "b" (the frozen, acted-on MR) becomes ready; MR "a" is unchanged and
+ // now outranks it, so the LIVE selection flips to "a" (still running).
+ // data-mr-iid stays on "b" (still open, so the freeze holds), and
+ // data-status tracks the live selection ("running") by design — but the
+ // aria-label describes the acted-on MR, so it must say "ready to merge"
+ // (b's own status), never "pipeline running" (spec: Accessibility, "does
+ // NOT follow data-status's live tracking").
+ gitlabMocks.mrs = [
+ makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
+ makeMR({ id: "b", mr_iid: 2, approval_state: "approved", pipeline_state: "success" }),
+ ];
+ act(() => {
+ rerender(createElement(MRStatusChip, { taskId: "task-1" }));
+ });
+
+ expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
+ expect(trigger.getAttribute(DATA_STATUS)).toBe("running");
+ expect(trigger.getAttribute("aria-label")).toContain("ready to merge");
+ expect(trigger.getAttribute("aria-label")).not.toContain("pipeline running");
+ });
+
+ it("calls the unlink hook with the acted-on MR's association id", () => {
+ gitlabMocks.mrs = [makeMR({ id: "assoc-xyz" })];
+ render(createElement(MRStatusChip, { taskId: "task-1" }));
+ const trigger = screen.getByTestId(CHIP_TESTID);
+ act(() => {
+ fireEvent.mouseEnter(trigger);
+ vi.advanceTimersByTime(OPEN_DELAY_MS);
+ });
+ fireEvent.click(screen.getByRole("button", { name: "unlink" }));
+ expect(unlinkMock).toHaveBeenCalledWith("assoc-xyz");
+ });
+
+ it("closes the popover before opening the link dialog", () => {
+ gitlabMocks.mrs = [makeMR()];
+ render(createElement(MRStatusChip, { taskId: "task-1" }));
+ const trigger = screen.getByTestId(CHIP_TESTID);
+ act(() => {
+ fireEvent.mouseEnter(trigger);
+ vi.advanceTimersByTime(OPEN_DELAY_MS);
+ });
+ expect(screen.getByTestId(POPOVER_STUB_TESTID)).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: "link another" }));
+
+ expect(screen.queryByTestId(POPOVER_STUB_TESTID)).toBeNull();
+ expect(dialogMocks.lastProps?.open).toBe(true);
+ });
+});
+
+describe("MRStatusChip freeze and forced close", () => {
+ beforeEach(resetChipMocks);
+ afterEach(teardownChipMocks);
+
+ // spec: Failure modes / Idempotency and re-render — "if the transitioned MR
+ // is the frozen one, the popover or drawer closes; if another open MR
+ // remains the trigger re-selects it." Exercises useFrozenChipSelection's
+ // shouldForceClose path (mr-status-chip-selection.ts:98), which previously
+ // had zero coverage (Review round 1 finding #4).
+ it("force-closes the popover and re-selects the remaining open MR when the frozen MR goes terminal", () => {
+ gitlabMocks.mrs = [
+ makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }), // running, rank 4
+ makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }), // failed, rank 5 — gets frozen
+ ];
+ const { rerender } = render(createElement(MRStatusChip, { taskId: "task-1" }));
+ const trigger = screen.getByTestId(CHIP_TESTID);
+
+ act(() => {
+ fireEvent.mouseEnter(trigger);
+ vi.advanceTimersByTime(OPEN_DELAY_MS);
+ });
+
+ expect(screen.getByTestId(POPOVER_STUB_TESTID)).toBeTruthy();
+ expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
+ expect(trigger.getAttribute(DATA_SELECTION_FROZEN)).toBe("true");
+
+ // The frozen MR ("b") merges elsewhere while the popover is open; "a"
+ // remains the task's only open MR.
+ gitlabMocks.mrs = [
+ makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
+ makeMR({ id: "b", mr_iid: 2, state: "merged" }),
+ ];
+ act(() => {
+ rerender(createElement(MRStatusChip, { taskId: "task-1" }));
+ });
+
+ expect(screen.queryByTestId(POPOVER_STUB_TESTID)).toBeNull();
+ expect(trigger.getAttribute(DATA_MR_COUNT)).toBe("1");
+ expect(trigger.getAttribute(DATA_MR_IID)).toBe("1");
+ expect(trigger.getAttribute(DATA_SELECTION_FROZEN)).toBe("false");
+ });
+
+ // Review round 1 finding #6 (codex): shouldForceClose and actedOnMR's
+ // fallback to the live selection are computed synchronously in the same
+ // render as the store update, but closing the disclosure is a `useEffect`
+ // that only fires after that render commits. Without the `!shouldForceClose`
+ // guard in MRStatusChipPopover/Drawer, MRCIPopover renders — with active
+ // merge/unlink controls — for the swapped-in MR ("a") for one commit before
+ // the effect closes the popover. Tracking every mr_iid MRCIPopover was ever
+ // rendered with (not just the final settled DOM, which act() would show as
+ // already-closed either way) is what actually catches this.
+ it("never renders MRCIPopover for the swapped-in MR during the forced-close transition", () => {
+ gitlabMocks.mrs = [
+ makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
+ makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }), // frozen
+ ];
+ const { rerender } = render(createElement(MRStatusChip, { taskId: "task-1" }));
+ const trigger = screen.getByTestId(CHIP_TESTID);
+
+ act(() => {
+ fireEvent.mouseEnter(trigger);
+ vi.advanceTimersByTime(OPEN_DELAY_MS);
+ });
+ expect(popoverRenderMRIIDs).toEqual([2]);
+
+ gitlabMocks.mrs = [
+ makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
+ makeMR({ id: "b", mr_iid: 2, state: "merged" }),
+ ];
+ act(() => {
+ rerender(createElement(MRStatusChip, { taskId: "task-1" }));
+ });
+
+ // Across the whole transition (freeze open -> b vanishes -> popover
+ // closes), MRCIPopover is only ever rendered for "b" (the MR the user
+ // opened the popover on) — never for "a" (the swapped-in live MR).
+ expect(popoverRenderMRIIDs).not.toContain(1);
+ });
+});
+
+describe("MRStatusChip responsive variant", () => {
+ beforeEach(resetChipMocks);
+ afterEach(teardownChipMocks);
+
+ it("renders the coarse-pointer drawer variant instead of the popover", () => {
+ touchMocks.usesTouchDrawer = true;
+ gitlabMocks.mrs = [makeMR()];
+ render(createElement(MRStatusChip, { taskId: "task-1" }));
+ const trigger = screen.getByTestId(CHIP_TESTID);
+ expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
+
+ fireEvent.click(trigger);
+ expect(screen.getByTestId("mr-status-chip-drawer")).toBeTruthy();
+ expect(screen.getByTestId(POPOVER_STUB_TESTID).textContent).toContain("!81");
+
+ act(() => {
+ fireEvent.click(screen.getByTestId("mr-status-chip-drawer-close"));
+ vi.advanceTimersByTime(1000);
+ });
+ expect(screen.getByTestId("mr-status-chip-drawer").getAttribute("data-state")).toBe("closed");
+ });
+});
diff --git a/apps/web/components/gitlab/mr-status-chip.tsx b/apps/web/components/gitlab/mr-status-chip.tsx
new file mode 100644
index 0000000000..b1abbc8411
--- /dev/null
+++ b/apps/web/components/gitlab/mr-status-chip.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { useState } from "react";
+import { useAppStore } from "@/components/state-provider";
+import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id";
+import {
+ useGitLabAvailable,
+ useTaskMRs,
+ useUnlinkTaskMR,
+} from "@/hooks/domains/gitlab/use-task-mr";
+import { useTaskMRAutomationOptions } from "@/hooks/domains/gitlab/use-task-mr-automation";
+import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
+import { TaskMRLinkDialog } from "./task-mr-link-dialog";
+import { mrChipStatus, selectChipMR } from "./mr-task-icon";
+import { chipAutomation } from "./mr-status-chip-selection";
+import { MRStatusChipPopover } from "./mr-status-chip-popover";
+import { MRStatusChipDrawer } from "./mr-status-chip-drawer";
+import type { Repository } from "@/lib/types/http";
+import type { TaskMR } from "@/lib/types/gitlab";
+
+const EMPTY_REPOSITORIES: Repository[] = [];
+const EMPTY_TASK_REPOSITORIES: Array<{ repository_id: string }> = [];
+
+/**
+ * Compact GitLab MR status chip for the chat status bar and passthrough
+ * toolbar — the GitLab counterpart to PRStatusChip. Renders nothing when
+ * the task has no MR whose `state === "open"` (spec: What, Failure modes).
+ * `taskId`/`workspaceId` are resolved and narrowed here, before any hook
+ * that should only run for a session view with a linked open MR
+ * (useTaskMRAutomationOptions, useGitLabAvailable — spec: Sync and
+ * freshness, "New trigger 1"/"New trigger 2") is called in the mounted
+ * child below.
+ */
+export function MRStatusChip({ taskId }: { taskId: string | null }) {
+ const workspaceId = useAppStore((state) => state.workspaces.activeId);
+ const mrs = useTaskMRs(taskId);
+ const openMRs = Array.isArray(mrs) ? mrs.filter((mr) => mr.state === "open") : [];
+ if (!taskId || !workspaceId || openMRs.length === 0) return null;
+ return (
+
+ );
+}
+
+function MRStatusChipMounted({
+ taskId,
+ workspaceId,
+ mrs,
+ openMRs,
+}: {
+ taskId: string;
+ workspaceId: string;
+ mrs: TaskMR[];
+ openMRs: TaskMR[];
+}) {
+ const repositories = useAppStore(
+ (state) => state.repositories.itemsByWorkspaceId[workspaceId] ?? EMPTY_REPOSITORIES,
+ );
+ const task = useTaskById(taskId);
+ const { options: automationOptions } = useTaskMRAutomationOptions(taskId);
+ const canLink = useGitLabAvailable();
+ const unlink = useUnlinkTaskMR(workspaceId);
+ const usesTouchDrawer = useTouchDrawer();
+ const [linkOpen, setLinkOpen] = useState(false);
+
+ const liveSelected = selectChipMR(mrs);
+ if (!liveSelected) return null;
+
+ const disclosureProps = {
+ mrs,
+ openCount: openMRs.length,
+ liveSelected,
+ liveStatus: mrChipStatus(liveSelected),
+ automation: chipAutomation(automationOptions, openMRs),
+ taskId,
+ canLink,
+ onUnlink: (associationId: string) => void unlink(associationId),
+ onLink: () => setLinkOpen(true),
+ };
+
+ return (
+ <>
+ {usesTouchDrawer ? (
+
+ ) : (
+
+ )}
+
+ >
+ );
+}
diff --git a/apps/web/components/gitlab/mr-status-colour-parity.test.tsx b/apps/web/components/gitlab/mr-status-colour-parity.test.tsx
index 5ec169db05..7086c92d18 100644
--- a/apps/web/components/gitlab/mr-status-colour-parity.test.tsx
+++ b/apps/web/components/gitlab/mr-status-colour-parity.test.tsx
@@ -1,6 +1,6 @@
import { describe, expect, it, afterEach } from "vitest";
import { cleanup, render } from "@testing-library/react";
-import { getMRStatusColor, MRStatusIcon } from "./mr-task-icon";
+import { aggregateMRStatusColor, getMRStatusColor, MRStatusIcon } from "./mr-task-icon";
import type { TaskMR } from "@/lib/types/gitlab";
afterEach(() => cleanup());
@@ -71,4 +71,15 @@ describe("MR status colour parity (AC36)", () => {
expect(getMRStatusColor(mr)).toBe(want);
},
);
+
+ // spec Constraints: aggregateMRStatusColor's first-in-input-order tie is
+ // frozen, not fixed, by the MR status chip feature — this pins it in both
+ // input orders so the chip's own array-order-independent selectChipMR
+ // can never accidentally end up sharing this function's behaviour.
+ it("aggregateMRStatusColor's all-terminal tie is decided by array order, in both orders", () => {
+ const merged = makeMR({ id: "a", state: "merged" });
+ const closed = makeMR({ id: "b", state: "closed" });
+ expect(aggregateMRStatusColor([merged, closed])).toBe("text-purple-500");
+ expect(aggregateMRStatusColor([closed, merged])).toBe(MUTED);
+ });
});
diff --git a/apps/web/components/gitlab/mr-task-icon.test.ts b/apps/web/components/gitlab/mr-task-icon.test.ts
index c24bb2591f..2df4c39e89 100644
--- a/apps/web/components/gitlab/mr-task-icon.test.ts
+++ b/apps/web/components/gitlab/mr-task-icon.test.ts
@@ -1,13 +1,23 @@
import { describe, expect, it } from "vitest";
import {
+ aggregateMRChipStatus,
aggregateMRStatusColor,
+ compareChipMR,
getMRStatusColor,
getMRTooltip,
isMRReadyToMerge,
+ mrChipStatus,
+ MR_CHIP_STATUS_RANK,
+ STATUS_RANK,
+ selectChipMR,
+ type MRChipStatus,
} from "./mr-task-icon";
import type { TaskMR } from "@/lib/types/gitlab";
const MUTED = "text-muted-foreground";
+const PURPLE = "text-purple-500";
+const RED = "text-red-500";
+const YELLOW = "text-yellow-500";
function makeMR(overrides: Partial = {}): TaskMR {
return {
@@ -42,10 +52,10 @@ function makeMR(overrides: Partial = {}): TaskMR {
// AC35: one case per row of the priority table, plus order-proving cases.
describe("getMRStatusColor", () => {
it.each([
- ["merged", makeMR({ state: "merged" }), "text-purple-500"],
+ ["merged", makeMR({ state: "merged" }), PURPLE],
["closed", makeMR({ state: "closed" }), MUTED],
["locked", makeMR({ state: "locked" }), MUTED],
- ["pipeline failure", makeMR({ pipeline_state: "failure" }), "text-red-500"],
+ ["pipeline failure", makeMR({ pipeline_state: "failure" }), RED],
["draft", makeMR({ draft: true }), MUTED],
[
"approved and pipeline success",
@@ -53,7 +63,7 @@ describe("getMRStatusColor", () => {
"text-emerald-400",
],
["approval pending", makeMR({ approval_state: "pending" }), "text-sky-400"],
- ["pipeline pending", makeMR({ pipeline_state: "pending" }), "text-yellow-500"],
+ ["pipeline pending", makeMR({ pipeline_state: "pending" }), YELLOW],
["otherwise", makeMR(), MUTED],
])("%s -> %s", (_name, mr, expected) => {
expect(getMRStatusColor(mr)).toBe(expected);
@@ -61,7 +71,7 @@ describe("getMRStatusColor", () => {
it("order: merged beats a failing pipeline (row 1 before row 4)", () => {
const mr = makeMR({ state: "merged", pipeline_state: "failure" });
- expect(getMRStatusColor(mr)).toBe("text-purple-500");
+ expect(getMRStatusColor(mr)).toBe(PURPLE);
});
it("order: closed beats approved (row 2 before row 6)", () => {
@@ -76,12 +86,12 @@ describe("getMRStatusColor", () => {
it("order: a failing pipeline beats approved (row 4 before row 6) — approved with red CI is not emerald", () => {
const mr = makeMR({ approval_state: "approved", pipeline_state: "failure" });
- expect(getMRStatusColor(mr)).toBe("text-red-500");
+ expect(getMRStatusColor(mr)).toBe(RED);
});
it("order: a running pipeline beats approved (row 8 is only reached if row 6 didn't match) — approved with pending CI is not emerald", () => {
const mr = makeMR({ approval_state: "approved", pipeline_state: "pending" });
- expect(getMRStatusColor(mr)).toBe("text-yellow-500");
+ expect(getMRStatusColor(mr)).toBe(YELLOW);
});
});
@@ -207,7 +217,7 @@ describe("aggregateMRStatusColor", () => {
makeMR({ id: "a", approval_state: "approved", pipeline_state: "success" }),
makeMR({ id: "b", pipeline_state: "failure" }),
];
- expect(aggregateMRStatusColor(mrs)).toBe("text-red-500");
+ expect(aggregateMRStatusColor(mrs)).toBe(RED);
});
it("drops terminal MRs when at least one MR is open", () => {
@@ -215,11 +225,249 @@ describe("aggregateMRStatusColor", () => {
makeMR({ id: "a", state: "merged" }),
makeMR({ id: "b", state: "open", pipeline_state: "pending" }),
];
- expect(aggregateMRStatusColor(mrs)).toBe("text-yellow-500");
+ expect(aggregateMRStatusColor(mrs)).toBe(YELLOW);
});
it("falls back to the first MR's colour when every MR is terminal", () => {
const mrs = [makeMR({ id: "a", state: "merged" }), makeMR({ id: "b", state: "closed" })];
- expect(aggregateMRStatusColor(mrs)).toBe("text-purple-500");
+ expect(aggregateMRStatusColor(mrs)).toBe(PURPLE);
+ });
+
+ // Spec Constraints: "SHALL include at least one all-terminal pair in both
+ // input orders" — pins the preserved first-in-input-order tie behaviour
+ // that selectChipMR (below) deliberately does NOT share (spec round-4
+ // finding F30).
+ it("all-terminal pair: array order decides the tie, in both input orders", () => {
+ const mergedFirst = [
+ makeMR({ id: "a", state: "merged" }),
+ makeMR({ id: "b", state: "closed" }),
+ ];
+ const closedFirst = [
+ makeMR({ id: "b", state: "closed" }),
+ makeMR({ id: "a", state: "merged" }),
+ ];
+ expect(aggregateMRStatusColor(mergedFirst)).toBe(PURPLE);
+ expect(aggregateMRStatusColor(closedFirst)).toBe(MUTED);
+ });
+});
+
+// spec: State machine — mrChipStatus is the one priority table
+// getMRStatusColor now reads from.
+describe("mrChipStatus", () => {
+ it.each([
+ ["merged", makeMR({ state: "merged" }), "merged"],
+ ["closed", makeMR({ state: "closed" }), "closed"],
+ ["locked", makeMR({ state: "locked" }), "closed"],
+ ["pipeline failure", makeMR({ pipeline_state: "failure" }), "failed"],
+ ["draft", makeMR({ draft: true }), "draft"],
+ [
+ "approved and pipeline success",
+ makeMR({ approval_state: "approved", pipeline_state: "success" }),
+ "ready",
+ ],
+ ["approval pending", makeMR({ approval_state: "pending" }), "awaiting_approval"],
+ ["pipeline pending", makeMR({ pipeline_state: "pending" }), "running"],
+ ["otherwise", makeMR(), "neutral"],
+ ] satisfies Array<[string, TaskMR, MRChipStatus]>)("%s -> %s", (_name, mr, expected) => {
+ expect(mrChipStatus(mr)).toBe(expected);
+ });
+
+ it("row 6 carries no pipeline condition: pending approval + pending pipeline is awaiting_approval, not running", () => {
+ expect(mrChipStatus(makeMR({ approval_state: "pending", pipeline_state: "pending" }))).toBe(
+ "awaiting_approval",
+ );
+ });
+
+ it("getMRStatusColor is a byte-identical lookup from mrChipStatus for every status", () => {
+ const statuses: MRChipStatus[] = [
+ "merged",
+ "closed",
+ "failed",
+ "draft",
+ "ready",
+ "awaiting_approval",
+ "running",
+ "neutral",
+ ];
+ const fixtures: Record = {
+ merged: makeMR({ state: "merged" }),
+ closed: makeMR({ state: "closed" }),
+ failed: makeMR({ pipeline_state: "failure" }),
+ draft: makeMR({ draft: true }),
+ ready: makeMR({ approval_state: "approved", pipeline_state: "success" }),
+ awaiting_approval: makeMR({ approval_state: "pending" }),
+ running: makeMR({ pipeline_state: "pending" }),
+ neutral: makeMR(),
+ };
+ const expectedColor: Record = {
+ merged: PURPLE,
+ closed: MUTED,
+ failed: RED,
+ draft: MUTED,
+ ready: "text-emerald-400",
+ awaiting_approval: "text-sky-400",
+ running: YELLOW,
+ neutral: MUTED,
+ };
+ for (const status of statuses) {
+ expect(mrChipStatus(fixtures[status])).toBe(status);
+ expect(getMRStatusColor(fixtures[status])).toBe(expectedColor[status]);
+ }
+ });
+});
+
+// spec: Selection and ordering.
+describe("selectChipMR", () => {
+ it("returns null when no MR is open", () => {
+ expect(selectChipMR([makeMR({ state: "merged" }), makeMR({ state: "closed" })])).toBeNull();
+ });
+
+ it("'open' is a positive equality test: locked and unknown states are not selectable", () => {
+ const mrs = [makeMR({ id: "a", state: "locked" }), makeMR({ id: "b", state: "weird-state" })];
+ expect(selectChipMR(mrs)).toBeNull();
+ });
+
+ it("keeps the maximum MR_CHIP_STATUS_RANK among open MRs", () => {
+ const running = makeMR({ id: "a", pipeline_state: "pending" });
+ const failed = makeMR({ id: "b", pipeline_state: "failure" });
+ expect(selectChipMR([running, failed])?.id).toBe("b");
+ });
+
+ it("tiebreaks by mr_iid ascending", () => {
+ const mrs = [makeMR({ id: "a", mr_iid: 12 }), makeMR({ id: "b", mr_iid: 7 })];
+ expect(selectChipMR(mrs)?.mr_iid).toBe(7);
+ });
+
+ it("tiebreaks by project_path ascending when mr_iid ties", () => {
+ const mrs = [
+ makeMR({ id: "a", mr_iid: 7, project_path: "group/b" }),
+ makeMR({ id: "b", mr_iid: 7, project_path: "group/a" }),
+ ];
+ expect(selectChipMR(mrs)?.project_path).toBe("group/a");
+ });
+
+ it("tiebreaks by id ascending when mr_iid and project_path both tie", () => {
+ const mrs = [
+ makeMR({ id: "z", mr_iid: 7, project_path: "group/a" }),
+ makeMR({ id: "a", mr_iid: 7, project_path: "group/a" }),
+ ];
+ expect(selectChipMR(mrs)?.id).toBe("a");
+ });
+
+ it("is order-independent: the same winner is picked regardless of input array order", () => {
+ const draft = makeMR({ id: "draft", mr_iid: 9, draft: true });
+ const neutral = makeMR({ id: "neutral", mr_iid: 3 });
+ expect(selectChipMR([draft, neutral])?.id).toBe("neutral");
+ expect(selectChipMR([neutral, draft])?.id).toBe("neutral");
+ });
+
+ // spec round-4 finding F30: selectChipMR must not mutate its input, since
+ // useTaskMRs returns the store array by reference and that same array
+ // backs aggregateMRStatusColor's array-order-dependent tie.
+ it("does not mutate the input array's order", () => {
+ const mrs = [makeMR({ id: "b", mr_iid: 2 }), makeMR({ id: "a", mr_iid: 1 })];
+ const before = mrs.map((mr) => mr.id);
+ selectChipMR(mrs);
+ expect(mrs.map((mr) => mr.id)).toEqual(before);
+ });
+});
+
+describe("aggregateMRChipStatus", () => {
+ it("equals mrChipStatus(selectChipMR(mrs)) for a mixed list", () => {
+ const mrs = [
+ makeMR({ id: "a", pipeline_state: "pending" }),
+ makeMR({ id: "b", pipeline_state: "failure" }),
+ ];
+ expect(aggregateMRChipStatus(mrs)).toBe(mrChipStatus(selectChipMR(mrs) as TaskMR));
+ });
+
+ it("is neutral when selectChipMR returns null", () => {
+ expect(aggregateMRChipStatus([makeMR({ state: "merged" })])).toBe("neutral");
+ });
+
+ // spec.md:931-934: "A property test SHALL assert this over shuffled input
+ // orders." Enumerates every permutation of a small fixed list (no external
+ // property-testing dependency exists in this repo) and asserts the actual
+ // spec equality — aggregateMRChipStatus(mrs) === mrChipStatus(selectChipMR(mrs))
+ // or "neutral" when selectChipMR is null — for EVERY order, not one
+ // hand-picked shuffle compared only to itself.
+ it("equals mrChipStatus(selectChipMR(mrs)), or neutral when null, over every permutation of a mixed list", () => {
+ const mrs = [
+ makeMR({ id: "a", mr_iid: 3 }), // rank 0 (neutral)
+ makeMR({ id: "b", mr_iid: 9, draft: true }), // rank 0 (draft)
+ makeMR({ id: "c", mr_iid: 1, pipeline_state: "failure" }), // rank 5 (failed) — the winner
+ makeMR({ id: "d", mr_iid: 5, state: "merged" }), // terminal, excluded from selection
+ makeMR({ id: "e", mr_iid: 12, pipeline_state: "pending" }), // rank 4 (running)
+ ];
+
+ for (const permutation of permutationsOf(mrs)) {
+ const selected = selectChipMR(permutation);
+ const expected = selected ? mrChipStatus(selected) : "neutral";
+ expect(aggregateMRChipStatus(permutation)).toBe(expected);
+ }
+ });
+
+ it("equals neutral over every permutation of an all-terminal list", () => {
+ const mrs = [
+ makeMR({ id: "a", state: "merged" }),
+ makeMR({ id: "b", state: "closed" }),
+ makeMR({ id: "c", state: "locked" }),
+ ];
+
+ for (const permutation of permutationsOf(mrs)) {
+ expect(selectChipMR(permutation)).toBeNull();
+ expect(aggregateMRChipStatus(permutation)).toBe("neutral");
+ }
+ });
+});
+
+/** All permutations of a small array (n! — keep n <= ~6 to stay fast). */
+function permutationsOf(items: T[]): T[][] {
+ if (items.length <= 1) return [items];
+ const result: T[][] = [];
+ for (let i = 0; i < items.length; i++) {
+ const rest = [...items.slice(0, i), ...items.slice(i + 1)];
+ for (const permutation of permutationsOf(rest)) {
+ result.push([items[i], ...permutation]);
+ }
+ }
+ return result;
+}
+
+// spec: State machine, "Rank, and why aggregation has no tiebreak of its
+// own" — MR_CHIP_STATUS_RANK and STATUS_RANK are two separate tables keyed
+// on different things, required only to be order-equivalent under the
+// status-to-colour map (spec round-4 finding F34).
+describe("MR_CHIP_STATUS_RANK / STATUS_RANK order-equivalence", () => {
+ const colorFor: Record = {
+ merged: PURPLE,
+ closed: MUTED,
+ failed: RED,
+ draft: MUTED,
+ ready: "text-emerald-400",
+ awaiting_approval: "text-sky-400",
+ running: YELLOW,
+ neutral: MUTED,
+ };
+ const statuses = Object.keys(MR_CHIP_STATUS_RANK) as MRChipStatus[];
+
+ it("orders any two statuses the same way as STATUS_RANK does their colours", () => {
+ for (const a of statuses) {
+ for (const b of statuses) {
+ const chipOrder = MR_CHIP_STATUS_RANK[a] < MR_CHIP_STATUS_RANK[b];
+ const colorOrder = STATUS_RANK[colorFor[a]] < STATUS_RANK[colorFor[b]];
+ expect(chipOrder).toBe(colorOrder);
+ }
+ }
+ });
+});
+
+describe("compareChipMR", () => {
+ it("is a total order: exactly one of ab holds for any pair", () => {
+ const a = makeMR({ id: "a", mr_iid: 1, project_path: "group/a" });
+ const b = makeMR({ id: "b", mr_iid: 1, project_path: "group/a" });
+ expect(compareChipMR(a, b)).toBeLessThan(0);
+ expect(compareChipMR(b, a)).toBeGreaterThan(0);
+ expect(compareChipMR(a, a)).toBe(0);
});
});
diff --git a/apps/web/components/gitlab/mr-task-icon.tsx b/apps/web/components/gitlab/mr-task-icon.tsx
index 7eca849410..362a358174 100644
--- a/apps/web/components/gitlab/mr-task-icon.tsx
+++ b/apps/web/components/gitlab/mr-task-icon.tsx
@@ -14,29 +14,61 @@ const YELLOW_500 = "text-yellow-500";
const SKY_400 = "text-sky-400";
const EMERALD_400 = "text-emerald-400";
+/**
+ * The eight-state MR chip status derivation (spec: gitlab-mr-status-chip,
+ * State machine). Total function, evaluated in this exact priority order —
+ * first match wins. `getMRStatusColor` below is re-expressed as a lookup
+ * from this so exactly one priority table exists (was previously its own
+ * copy of the same if-chain).
+ */
+export type MRChipStatus =
+ | "merged"
+ | "closed"
+ | "failed"
+ | "draft"
+ | "ready"
+ | "awaiting_approval"
+ | "running"
+ | "neutral";
+
+export function mrChipStatus(mr: TaskMR): MRChipStatus {
+ if (mr.state === "merged") return "merged";
+ if (mr.state === "closed" || mr.state === "locked") return "closed";
+ if (mr.pipeline_state === "failure") return "failed";
+ if (mr.draft) return "draft";
+ if (mr.approval_state === "approved" && mr.pipeline_state === "success") return "ready";
+ if (mr.approval_state === "pending") return "awaiting_approval";
+ if (mr.pipeline_state === "pending") return "running";
+ return "neutral";
+}
+
+const MR_CHIP_STATUS_COLOR: Record = {
+ merged: PURPLE_500,
+ closed: MUTED_FOREGROUND,
+ failed: RED_500,
+ draft: MUTED_FOREGROUND,
+ ready: EMERALD_400,
+ awaiting_approval: SKY_400,
+ running: YELLOW_500,
+ neutral: MUTED_FOREGROUND,
+};
+
/**
* The single source of MR status colour for the GitLab directory (C10, Q5).
* Consumed by both the Kanban card badge (MRTaskIcon, below) and the MR
* topbar button (mr-topbar-button.tsx) — previously two private,
- * disagreeing copies (statusTextColor / MRStatusIcon). Evaluated in this
- * exact priority order (first match wins); see plan AC35 for the table this
- * mirrors row-for-row.
+ * disagreeing copies (statusTextColor / MRStatusIcon). See plan AC35 for
+ * the table this mirrors row-for-row.
*/
export function getMRStatusColor(mr: TaskMR): string {
- if (mr.state === "merged") return PURPLE_500;
- if (mr.state === "closed") return MUTED_FOREGROUND;
- if (mr.state === "locked") return MUTED_FOREGROUND;
- if (mr.pipeline_state === "failure") return RED_500;
- if (mr.draft) return MUTED_FOREGROUND;
- if (mr.approval_state === "approved" && mr.pipeline_state === "success") return EMERALD_400;
- if (mr.approval_state === "pending") return SKY_400;
- if (mr.pipeline_state === "pending") return YELLOW_500;
- return MUTED_FOREGROUND;
+ return MR_CHIP_STATUS_COLOR[mrChipStatus(mr)];
}
-const STATUS_RANK: Record = {
- // Higher = more attention-worthy. Drives the aggregated badge colour when
- // a task has multiple MRs (mirrors github's aggregatePRStatusColor).
+// Higher = more attention-worthy. Drives the aggregated badge colour when a
+// task has multiple MRs (mirrors github's aggregatePRStatusColor). Exported
+// so a test can assert order-equivalence against MR_CHIP_STATUS_RANK below
+// without hand-restating either table (spec round-4 finding F34).
+export const STATUS_RANK: Record = {
[RED_500]: 4,
[YELLOW_500]: 3,
[SKY_400]: 2,
@@ -45,6 +77,72 @@ const STATUS_RANK: Record = {
[MUTED_FOREGROUND]: 0,
};
+// Ranks the eight chip statuses by attention-worthiness for selectChipMR's
+// rank filter (step 2). A separate table from STATUS_RANK above: this one is
+// keyed by status, that one by colour, and they serve different callers —
+// they are required only to be order-equivalent under the colour map, not
+// identical (spec: State machine, "Rank, and why aggregation has no tiebreak
+// of its own").
+export const MR_CHIP_STATUS_RANK: Record = {
+ failed: 5,
+ running: 4,
+ awaiting_approval: 3,
+ ready: 2,
+ merged: 0,
+ closed: 0,
+ draft: 0,
+ neutral: 0,
+};
+
+/**
+ * Total order over MRs used to break rank ties deterministically:
+ * `mr_iid` ascending, then `project_path` ascending (byte-wise `<`), then
+ * `id` ascending. `id` is the association primary key, so this is total.
+ * Shared by selectChipMR (below) and the chip's badge-selection rule, which
+ * uses the identical tiebreak (spec: Selection and ordering).
+ */
+export function compareChipMR(a: TaskMR, b: TaskMR): number {
+ if (a.mr_iid !== b.mr_iid) return a.mr_iid - b.mr_iid;
+ if (a.project_path !== b.project_path) return a.project_path < b.project_path ? -1 : 1;
+ if (a.id === b.id) return 0;
+ return a.id < b.id ? -1 : 1;
+}
+
+function isChipMRBetter(candidate: TaskMR, current: TaskMR): boolean {
+ const candidateRank = MR_CHIP_STATUS_RANK[mrChipStatus(candidate)];
+ const currentRank = MR_CHIP_STATUS_RANK[mrChipStatus(current)];
+ if (candidateRank !== currentRank) return candidateRank > currentRank;
+ return compareChipMR(candidate, current) < 0;
+}
+
+/**
+ * Deterministically picks the one MR the chip renders and acts on: restrict
+ * to `state === "open"` exactly, keep the maximum MR_CHIP_STATUS_RANK, then
+ * tiebreak via compareChipMR. Pure and non-mutating — it never sorts or
+ * otherwise reorders `mrs` in place, so a caller holding the same array
+ * reference (useTaskMRs returns the store array by reference) never has it
+ * silently reordered under it (spec round-4 finding F30).
+ */
+export function selectChipMR(mrs: TaskMR[]): TaskMR | null {
+ let best: TaskMR | null = null;
+ for (const mr of mrs) {
+ if (mr.state !== "open") continue;
+ if (!best || isChipMRBetter(mr, best)) best = mr;
+ }
+ return best;
+}
+
+/**
+ * `aggregateMRChipStatus(mrs)` is defined as the composition
+ * `mrChipStatus(selectChipMR(mrs))`, returning `neutral` when selectChipMR
+ * returns null. It has no tiebreak rule of its own — see State machine,
+ * "Rank, and why aggregation has no tiebreak of its own".
+ */
+export function aggregateMRChipStatus(mrs: TaskMR[]): MRChipStatus {
+ const selected = selectChipMR(mrs);
+ return selected ? mrChipStatus(selected) : "neutral";
+}
+
/**
* Best-effort client-side approximation of the backend's
* mrAutomationReadyToMerge, evaluated against whatever TaskMR fields are
diff --git a/apps/web/components/gitlab/mr-topbar-button.hover.test.tsx b/apps/web/components/gitlab/mr-topbar-button.hover.test.tsx
index d414587fe3..66e482c9d3 100644
--- a/apps/web/components/gitlab/mr-topbar-button.hover.test.tsx
+++ b/apps/web/components/gitlab/mr-topbar-button.hover.test.tsx
@@ -40,6 +40,7 @@ vi.mock("@/components/state-provider", () => ({
vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({
useGitLabAvailable: () => true,
useTaskMRs: () => gitlabMocks.mrs,
+ useUnlinkTaskMR: () => vi.fn(async () => {}),
useWorkspaceMRs: vi.fn(),
}));
diff --git a/apps/web/components/gitlab/mr-topbar-button.layout-settle.test.tsx b/apps/web/components/gitlab/mr-topbar-button.layout-settle.test.tsx
index cbe8a62f18..a2f654d3a5 100644
--- a/apps/web/components/gitlab/mr-topbar-button.layout-settle.test.tsx
+++ b/apps/web/components/gitlab/mr-topbar-button.layout-settle.test.tsx
@@ -68,6 +68,7 @@ vi.mock("@/components/state-provider", () => ({
vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({
useGitLabAvailable: () => true,
useTaskMRs: () => gitlabMocks.mrs,
+ useUnlinkTaskMR: () => vi.fn(async () => {}),
useWorkspaceMRs: vi.fn(),
}));
diff --git a/apps/web/components/gitlab/mr-topbar-button.test.ts b/apps/web/components/gitlab/mr-topbar-button.test.ts
index 9a973166bf..d7e4e8676e 100644
--- a/apps/web/components/gitlab/mr-topbar-button.test.ts
+++ b/apps/web/components/gitlab/mr-topbar-button.test.ts
@@ -24,6 +24,7 @@ vi.mock("@/components/state-provider", () => ({
vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({
useGitLabAvailable: () => true,
useTaskMRs: () => gitlabMocks.mrs,
+ useUnlinkTaskMR: () => vi.fn(async () => {}),
useWorkspaceMRs: vi.fn(),
}));
diff --git a/apps/web/components/gitlab/mr-topbar-button.tsx b/apps/web/components/gitlab/mr-topbar-button.tsx
index ef393f4156..5649ac8036 100644
--- a/apps/web/components/gitlab/mr-topbar-button.tsx
+++ b/apps/web/components/gitlab/mr-topbar-button.tsx
@@ -28,12 +28,11 @@ import { Popover, PopoverAnchor, PopoverContent } from "@kandev/ui/popover";
import {
useGitLabAvailable,
useTaskMRs,
+ useUnlinkTaskMR,
useWorkspaceMRs,
} from "@/hooks/domains/gitlab/use-task-mr";
import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id";
import { useAppStore } from "@/components/state-provider";
-import { useToast } from "@/components/toast-provider";
-import { deleteTaskMR } from "@/lib/api/domains/gitlab-api";
import type { TaskMR } from "@/lib/types/gitlab";
import type { Repository } from "@/lib/types/http";
import { TaskMRLinkDialog } from "./task-mr-link-dialog";
@@ -54,7 +53,7 @@ const MR_POPOVER_CLOSE_DELAY_MS = 150;
* PRMultiButton (C7). A multi-MR aggregate popover is out of scope (§9) —
* hovering a 2+ MR trigger only ever opens the click-driven dropdown.
*/
-function useMRPopoverInteractions() {
+export function useMRPopoverInteractions() {
const usesTouchDrawer = useTouchDrawer();
const hover = useHoverPopover({
openDelayMs: MR_POPOVER_OPEN_DELAY_MS,
@@ -449,7 +448,6 @@ export const MRTopbarButton = memo(function MRTopbarButton({
compact?: boolean;
mobile?: boolean;
}) {
- const { t } = useTranslation();
const [linkOpen, setLinkOpen] = useState(false);
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
const workspaceId = useAppStore((s) => s.workspaces.activeId);
@@ -462,25 +460,10 @@ export const MRTopbarButton = memo(function MRTopbarButton({
useWorkspaceMRs(workspaceId);
const mrs = useTaskMRs(activeTaskId);
const gitlabAvailable = useGitLabAvailable();
- const removeTaskMR = useAppStore((state) => state.removeTaskMR);
- const { toast } = useToast();
+ const unlink = useUnlinkTaskMR(workspaceId);
if (!activeTaskId || !workspaceId) return null;
- const unlink = async (associationId: string) => {
- try {
- await deleteTaskMR(associationId, workspaceId);
- removeTaskMR(workspaceId, associationId);
- } catch (error) {
- toast({
- title: t("gitlab:failedToUnlinkMergeRequest"),
- description:
- error instanceof Error ? error.message : t("gitlab:theMergeRequestIsStillLinked"),
- variant: "error",
- });
- }
- };
-
return (
<>
({
PRStatusChip: () => null,
}));
+vi.mock("@/components/gitlab/mr-status-chip", () => ({
+ MRStatusChip: () => null,
+}));
+
vi.mock("@/components/azure-devops/azure-devops-task-pull-request-chip", () => ({
AzureDevOpsTaskPullRequestChip: () => null,
}));
diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx
index 644580842a..9e9f32341f 100644
--- a/apps/web/components/task/chat/chat-input-area.tsx
+++ b/apps/web/components/task/chat/chat-input-area.tsx
@@ -8,6 +8,7 @@ import { TodoIndicator } from "./todo-indicator";
import { AutoScrollToggleButton } from "./auto-scroll-toggle-button";
import { PRMergedBanner, PRClosedBanner } from "./pr-archive-banners";
import { PRStatusChip } from "@/components/github/pr-status-chip";
+import { MRStatusChip } from "@/components/gitlab/mr-status-chip";
import { AzureDevOpsTaskPullRequestChip } from "@/components/azure-devops/azure-devops-task-pull-request-chip";
import { shareableSessionStateClient } from "@/components/task/share/share-button";
import { TranscriptNavGroup } from "@/components/task/chat/transcript-nav-group";
@@ -429,6 +430,7 @@ function ChatStatusBar({
{showTodos && }
{autopilot && }
+
{queueChip}
{/* Distinct per-banner keys: the key remounts the banner on task switch
diff --git a/apps/web/components/task/passthrough-toolbar.test.tsx b/apps/web/components/task/passthrough-toolbar.test.tsx
index 4145e3030c..71465a22ae 100644
--- a/apps/web/components/task/passthrough-toolbar.test.tsx
+++ b/apps/web/components/task/passthrough-toolbar.test.tsx
@@ -120,6 +120,10 @@ vi.mock("@/components/github/pr-status-chip", () => ({
PRStatusChip: () => null,
}));
+vi.mock("@/components/gitlab/mr-status-chip", () => ({
+ MRStatusChip: () => null,
+}));
+
vi.mock("@/components/azure-devops/azure-devops-task-pull-request-chip", () => ({
AzureDevOpsTaskPullRequestChip: () => null,
}));
diff --git a/apps/web/components/task/passthrough-toolbar.tsx b/apps/web/components/task/passthrough-toolbar.tsx
index e48184b323..6aee212c9a 100644
--- a/apps/web/components/task/passthrough-toolbar.tsx
+++ b/apps/web/components/task/passthrough-toolbar.tsx
@@ -21,6 +21,7 @@ import { Button } from "@kandev/ui/button";
import { Textarea } from "@kandev/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { PRStatusChip } from "@/components/github/pr-status-chip";
+import { MRStatusChip } from "@/components/gitlab/mr-status-chip";
import { AzureDevOpsTaskPullRequestChip } from "@/components/azure-devops/azure-devops-task-pull-request-chip";
import { PRMergedBanner } from "./chat/pr-archive-banners";
import { type ChatInputContainerHandle } from "./chat/chat-input-container";
@@ -535,6 +536,7 @@ function PassthroughStatusRow({
+
{taskId && }
{showProceed && nextStepName && (
diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts
index b9705ab297..9558c2a15b 100644
--- a/apps/web/e2e/pages/session-page.ts
+++ b/apps/web/e2e/pages/session-page.ts
@@ -747,6 +747,50 @@ export class SessionPage {
await expect(this.prStatusChipDrawer()).toBeVisible({ timeout: 5_000 });
}
+ // --- GitLab MR status chip accessors: mirrors the PR status chip shape
+ // above, including its scoping (spec: gitlab-mr-status-chip, Constraints).
+
+ /** Compact GitLab MR status chip rendered in the chat status bar. */
+ mrStatusChip(): Locator {
+ return this.activeChat().getByTestId("chat-status-bar").getByTestId("mr-status-chip");
+ }
+
+ /** Compact GitLab MR status chip rendered in the passthrough toolbar's status row. */
+ mrStatusChipInPassthrough(): Locator {
+ return this.page.getByTestId("passthrough-status-row").getByTestId("mr-status-chip");
+ }
+
+ /** Mobile bottom-sheet drawer that hosts the chip's MRCIPopover body. */
+ mrStatusChipDrawer(): Locator {
+ return this.page.getByTestId("mr-status-chip-drawer");
+ }
+
+ /** Close button inside the chip's mobile drawer. */
+ mrStatusChipDrawerClose(): Locator {
+ return this.page.getByTestId("mr-status-chip-drawer-close");
+ }
+
+ /**
+ * MRCIPopover body when rendered inside the chip's own disclosure — the
+ * hover popover on a fine pointer, or the drawer on a coarse pointer.
+ * `mr-topbar-popover-inner` is also emitted by MRTopbarButton's own
+ * popover on the same route, so this scopes through the chip's own
+ * wrapper testid (`mr-status-chip-popover` / `mr-status-chip-drawer`)
+ * rather than resolving the inner testid globally.
+ */
+ mrStatusChipPopoverInner(): Locator {
+ return this.page
+ .getByTestId("mr-status-chip-popover")
+ .getByTestId("mr-topbar-popover-inner")
+ .or(this.mrStatusChipDrawer().getByTestId("mr-topbar-popover-inner"));
+ }
+
+ /** Tap the chip and wait for the mobile drawer to be visible. */
+ async tapMRStatusChip(): Promise {
+ await this.mrStatusChip().tap();
+ await expect(this.mrStatusChipDrawer()).toBeVisible({ timeout: 5_000 });
+ }
+
/** Multi-PR aggregate popover content (segmented tabs + selected PR's CI). */
prTopbarPopoverAggregate(): Locator {
return this.page.getByTestId("pr-multi-popover");
diff --git a/apps/web/e2e/tests/gitlab/mobile-mr-status-chip.spec.ts b/apps/web/e2e/tests/gitlab/mobile-mr-status-chip.spec.ts
new file mode 100644
index 0000000000..1a64381733
--- /dev/null
+++ b/apps/web/e2e/tests/gitlab/mobile-mr-status-chip.spec.ts
@@ -0,0 +1,95 @@
+import { test, expect } from "../../fixtures/test-base";
+import { SessionPage } from "../../pages/session-page";
+import { GITLAB_HOST, GITLAB_PROJECT } from "../../helpers/gitlab";
+import type { ApiClient } from "../../helpers/api-client";
+import type { SeedData } from "../../fixtures/test-base";
+
+const MR_IID = 411;
+
+/**
+ * Seeds one open MR directly with state: "open" (already normalized) rather
+ * than the shared seedGitLabReview helper's GitLab-native "opened" — the
+ * mock provider never normalizes state, and the chip's spec-mandated
+ * `state === "open"` exact-equality filter needs the normalized form. See
+ * mr-status-chip.spec.ts's seedChipMR for the full rationale.
+ */
+async function seedTaskWithLinkedMR(apiClient: ApiClient, seedData: SeedData, title: string) {
+ await apiClient.configureGitLab(seedData.workspaceId, GITLAB_HOST);
+ const now = new Date().toISOString();
+ await apiClient.mockGitLabAddMRs(seedData.workspaceId, GITLAB_PROJECT, [
+ {
+ iid: MR_IID,
+ id: MR_IID + 10_000,
+ project_id: 101,
+ title: "Mobile chip MR",
+ url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${MR_IID}`,
+ web_url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${MR_IID}`,
+ state: "open",
+ head_branch: "feature/mobile-chip",
+ head_sha: `sha-${MR_IID}`,
+ base_branch: "main",
+ author_username: "contributor",
+ project_namespace: "platform",
+ project_path: GITLAB_PROJECT,
+ body: "",
+ draft: false,
+ merge_status: "can_be_merged",
+ has_conflicts: false,
+ additions: 1,
+ deletions: 1,
+ reviewers: [],
+ assignees: [],
+ created_at: now,
+ updated_at: now,
+ },
+ ]);
+ await apiClient.updateRepository(seedData.repositoryId, {
+ provider: "gitlab",
+ provider_host: GITLAB_HOST,
+ provider_owner: "platform",
+ provider_name: "kandev",
+ });
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ title,
+ seedData.agentProfileId,
+ {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+ await apiClient.linkTaskGitLabMR(seedData.workspaceId, {
+ task_id: task.id,
+ repository_id: seedData.repositoryId,
+ mr_url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${MR_IID}`,
+ });
+ return task.id;
+}
+
+test.describe("mobile GitLab MR status chip", () => {
+ test("tapping the chip opens the drawer with the popover body, and closing returns focus to the trigger", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+ const taskId = await seedTaskWithLinkedMR(apiClient, seedData, "Mobile MR chip");
+
+ await testPage.goto(`/t/${taskId}`);
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+
+ await expect(session.mrStatusChip()).toBeVisible({ timeout: 15_000 });
+ await session.tapMRStatusChip();
+
+ const drawer = session.mrStatusChipDrawer();
+ await expect(drawer).toBeVisible();
+ await expect(session.mrStatusChipPopoverInner()).toContainText(`!${MR_IID}`);
+
+ await session.mrStatusChipDrawerClose().click();
+ await expect(drawer).toBeHidden({ timeout: 5_000 });
+ await expect(session.mrStatusChip()).toBeFocused();
+ });
+});
diff --git a/apps/web/e2e/tests/gitlab/mr-status-chip.spec.ts b/apps/web/e2e/tests/gitlab/mr-status-chip.spec.ts
new file mode 100644
index 0000000000..4a2c5506d4
--- /dev/null
+++ b/apps/web/e2e/tests/gitlab/mr-status-chip.spec.ts
@@ -0,0 +1,354 @@
+import { test, expect } from "../../fixtures/test-base";
+import { KanbanPage } from "../../pages/kanban-page";
+import { SessionPage } from "../../pages/session-page";
+import { GITLAB_HOST, GITLAB_PROJECT } from "../../helpers/gitlab";
+import type { ApiClient } from "../../helpers/api-client";
+import type { SeedData } from "../../fixtures/test-base";
+
+/**
+ * Seeds one open MR with a successful pipeline and 0/1 approvals
+ * (-> awaiting_approval per the chip's state machine). Deliberately does NOT
+ * reuse the shared seedGitLabReview helper (e2e/helpers/gitlab.ts): that
+ * helper seeds GitLab's raw wording state: "opened", which MRTopbarButton
+ * doesn't care about (it renders on mrs.length > 0 regardless of state) but
+ * the chip's spec-mandated `state === "open"` exact-equality filter does not
+ * match. The mock provider never normalizes "opened" -> "open" (only the
+ * real GitLab HTTP client's convertRawMR does that), so a chip-focused test
+ * must seed the already-normalized value directly — the same convention
+ * mr-task-card-badge.spec.ts's local seedMR helper already uses for the same
+ * reason.
+ */
+async function seedChipMR(apiClient: ApiClient, workspaceId: string, iid: number, title: string) {
+ await apiClient.configureGitLab(workspaceId, GITLAB_HOST);
+ await apiClient.mockGitLabAddMRs(workspaceId, GITLAB_PROJECT, [mrSeed(iid, title)]);
+ await apiClient.mockGitLabAddPipelines(workspaceId, GITLAB_PROJECT, [
+ pipelineSeed(iid, "success"),
+ ]);
+ await apiClient.mockGitLabAddApprovals(workspaceId, GITLAB_PROJECT, iid, [], 1);
+}
+
+/** Create a passthrough agent profile, mirroring cli-mode/passthrough-toolbar.spec.ts. */
+async function createPassthroughProfile(apiClient: ApiClient, name: string): Promise {
+ const { agents } = await apiClient.listAgents();
+ if (agents.length === 0) throw new Error("no agents registered in this e2e profile");
+ const profile = await apiClient.createAgentProfile(agents[0].id, name, {
+ model: "mock-fast",
+ auto_approve: true,
+ cli_passthrough: true,
+ });
+ return profile.id;
+}
+
+async function createTask(apiClient: ApiClient, seedData: SeedData, title: string) {
+ await apiClient.updateRepository(seedData.repositoryId, {
+ provider: "gitlab",
+ provider_host: GITLAB_HOST,
+ provider_owner: "platform",
+ provider_name: "kandev",
+ });
+ return apiClient.createTaskWithAgent(seedData.workspaceId, title, seedData.agentProfileId, {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ });
+}
+
+async function linkMR(apiClient: ApiClient, seedData: SeedData, taskId: string, iid: number) {
+ await apiClient.linkTaskGitLabMR(seedData.workspaceId, {
+ task_id: taskId,
+ repository_id: seedData.repositoryId,
+ mr_url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${iid}`,
+ });
+}
+
+async function openTask(
+ testPage: import("@playwright/test").Page,
+ session: SessionPage,
+ taskId: string,
+) {
+ await testPage.goto(`/t/${taskId}`);
+ await session.waitForLoad();
+}
+
+test.describe("GitLab MR status chip", () => {
+ test("renders in the chat status bar for a single open MR, and hovering reveals the popover body", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+ const MR_IID = 401;
+ await seedChipMR(apiClient, seedData.workspaceId, MR_IID, "Chip render MR");
+ const task = await createTask(apiClient, seedData, "MR chip render");
+ await linkMR(apiClient, seedData, task.id, MR_IID);
+
+ const session = new SessionPage(testPage);
+ await openTask(testPage, session, task.id);
+
+ const chip = session.mrStatusChip();
+ await expect(chip).toBeVisible({ timeout: 15_000 });
+ // seedChipMR: pipeline succeeds, approvals 0/1 -> awaiting_approval.
+ await expect(chip).toHaveAttribute("data-status", "awaiting_approval");
+ await expect(chip).toHaveAttribute("data-mr-count", "1");
+ await expect(chip).toHaveAttribute("data-mr-iid", String(MR_IID));
+ await expect(chip).toHaveAttribute("data-selection-frozen", "false");
+
+ await chip.hover();
+ const inner = session.mrStatusChipPopoverInner();
+ await expect(inner).toBeVisible({ timeout: 5_000 });
+ await expect(inner).toContainText(`!${MR_IID}`);
+
+ const box = await inner.boundingBox();
+ expect(box?.y).toBeGreaterThanOrEqual(0);
+ });
+
+ test("selects the awaiting-approval MR over a draft one (higher rank), and freezes the acted-on MR while the popover is open", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+ const DRAFT_IID = 402;
+ const AWAITING_IID = 403;
+ await apiClient.configureGitLab(seedData.workspaceId, GITLAB_HOST);
+ // The mock provider's pipeline list is keyed by project only (shared by
+ // every MR in it, not per-ref), so both MRs get the same successful
+ // pipeline here and are differentiated by draft/approval state instead.
+ await apiClient.mockGitLabAddMRs(seedData.workspaceId, GITLAB_PROJECT, [
+ { ...mrSeed(DRAFT_IID, "Draft MR"), draft: true },
+ mrSeed(AWAITING_IID, "Awaiting approval MR"),
+ ]);
+ await apiClient.mockGitLabAddPipelines(seedData.workspaceId, GITLAB_PROJECT, [
+ pipelineSeed(DRAFT_IID, "success"),
+ ]);
+ await apiClient.mockGitLabAddApprovals(
+ seedData.workspaceId,
+ GITLAB_PROJECT,
+ AWAITING_IID,
+ [],
+ 1,
+ );
+
+ const task = await createTask(apiClient, seedData, "MR chip multi-select");
+ await linkMR(apiClient, seedData, task.id, DRAFT_IID);
+ await linkMR(apiClient, seedData, task.id, AWAITING_IID);
+
+ const session = new SessionPage(testPage);
+ await openTask(testPage, session, task.id);
+
+ const chip = session.mrStatusChip();
+ await expect(chip).toBeVisible({ timeout: 15_000 });
+ await expect(chip).toHaveAttribute("data-status", "awaiting_approval");
+ await expect(chip).toHaveAttribute("data-mr-count", "2");
+ await expect(chip).toHaveAttribute("data-mr-iid", String(AWAITING_IID));
+
+ await chip.hover();
+ await expect(session.mrStatusChipPopoverInner()).toBeVisible({ timeout: 5_000 });
+ await expect(chip).toHaveAttribute("data-selection-frozen", "true");
+ // The frozen acted-on MR keeps naming the higher-ranked MR while the popover stays open.
+ await expect(chip).toHaveAttribute("data-mr-iid", String(AWAITING_IID));
+ });
+
+ test("unlinking from the chip popover removes the association and the chip unmounts", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+ const MR_IID = 404;
+ await seedChipMR(apiClient, seedData.workspaceId, MR_IID, "Unlink MR");
+ const task = await createTask(apiClient, seedData, "MR chip unlink");
+ await linkMR(apiClient, seedData, task.id, MR_IID);
+
+ const session = new SessionPage(testPage);
+ await openTask(testPage, session, task.id);
+
+ const chip = session.mrStatusChip();
+ await expect(chip).toBeVisible({ timeout: 15_000 });
+ await chip.hover();
+ const inner = session.mrStatusChipPopoverInner();
+ await expect(inner).toBeVisible({ timeout: 5_000 });
+
+ await inner.getByRole("button", { name: `Unlink !${MR_IID}` }).click();
+ await expect(chip).toHaveCount(0, { timeout: 10_000 });
+ });
+
+ test("activating link-another closes the popover before the link dialog opens", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+ const MR_IID = 405;
+ await seedChipMR(apiClient, seedData.workspaceId, MR_IID, "Link another MR");
+ const task = await createTask(apiClient, seedData, "MR chip link another");
+ await linkMR(apiClient, seedData, task.id, MR_IID);
+
+ const session = new SessionPage(testPage);
+ await openTask(testPage, session, task.id);
+
+ const chip = session.mrStatusChip();
+ await expect(chip).toBeVisible({ timeout: 15_000 });
+ await chip.hover();
+ const inner = session.mrStatusChipPopoverInner();
+ await expect(inner).toBeVisible({ timeout: 5_000 });
+
+ await inner.getByTestId("mr-popover-link-another").click();
+ await expect(session.mrStatusChipPopoverInner()).toBeHidden({ timeout: 5_000 });
+ await expect(testPage.getByRole("dialog")).toBeVisible({ timeout: 5_000 });
+ });
+
+ test("renders auto-fix (round 0) and auto-merge badges once automation is enabled", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+ const MR_IID = 406;
+ await seedChipMR(apiClient, seedData.workspaceId, MR_IID, "Automation badges MR");
+ const task = await createTask(apiClient, seedData, "MR chip automation badges");
+ await linkMR(apiClient, seedData, task.id, MR_IID);
+ await apiClient.updateTaskMRAutomationOptions(task.id, {
+ auto_fix_enabled: true,
+ auto_merge_enabled: true,
+ });
+
+ const session = new SessionPage(testPage);
+ await openTask(testPage, session, task.id);
+
+ const chip = session.mrStatusChip();
+ await expect(chip).toBeVisible({ timeout: 15_000 });
+ const autoFixBadge = chip.getByTestId("mr-status-auto-fix-chip");
+ await expect(autoFixBadge).toBeVisible();
+ await expect(autoFixBadge).toContainText("0/10");
+ await expect(autoFixBadge).toHaveAttribute("data-auto-fix-exhausted", "false");
+ await expect(chip.getByTestId("mr-status-auto-merge-chip")).toBeVisible();
+ });
+
+ test("DOM order: pr-status-chip precedes mr-status-chip when a task has both an open PR and MR", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+ const MR_IID = 407;
+ await seedChipMR(apiClient, seedData.workspaceId, MR_IID, "Dual chip MR");
+ const task = await createTask(apiClient, seedData, "MR chip DOM order");
+ await linkMR(apiClient, seedData, task.id, MR_IID);
+ await apiClient.mockGitHubAssociateTaskPR({
+ task_id: task.id,
+ owner: "test-org",
+ repo: "test-repo",
+ pr_number: 501,
+ pr_url: "https://github.com/test-org/test-repo/pull/501",
+ pr_title: "Dual chip PR",
+ head_branch: "feature/dual",
+ base_branch: "main",
+ author_login: "test-user",
+ state: "open",
+ });
+
+ const session = new SessionPage(testPage);
+ await openTask(testPage, session, task.id);
+
+ const statusBar = session.chatStatusBar();
+ await expect(statusBar.getByTestId("pr-status-chip")).toBeVisible({ timeout: 15_000 });
+ await expect(session.mrStatusChip()).toBeVisible({ timeout: 15_000 });
+
+ const order = await statusBar.evaluate((el) => {
+ const children = Array.from(
+ el.querySelectorAll("[data-testid='pr-status-chip'], [data-testid='mr-status-chip']"),
+ );
+ return children.map((child) => child.getAttribute("data-testid"));
+ });
+ expect(order.indexOf("pr-status-chip")).toBeLessThan(order.indexOf("mr-status-chip"));
+ });
+
+ test("renders in the passthrough toolbar's status row for a passthrough session", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+ const MR_IID = 408;
+ await seedChipMR(apiClient, seedData.workspaceId, MR_IID, "Passthrough chip MR");
+ await apiClient.updateRepository(seedData.repositoryId, {
+ provider: "gitlab",
+ provider_host: GITLAB_HOST,
+ provider_owner: "platform",
+ provider_name: "kandev",
+ });
+ const profileId = await createPassthroughProfile(apiClient, "MR Chip Passthrough");
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "MR chip passthrough row",
+ profileId,
+ {
+ description: "initial prompt",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+ await linkMR(apiClient, seedData, task.id, MR_IID);
+
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+ const card = kanban.taskCardByTitle("MR chip passthrough row");
+ await expect(card).toBeVisible({ timeout: 30_000 });
+ await card.click();
+ await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
+
+ const session = new SessionPage(testPage);
+ await session.waitForPassthroughLoad(20_000);
+ await session.waitForPassthroughLoaded(20_000);
+
+ const chip = session.mrStatusChipInPassthrough();
+ await expect(chip).toBeVisible({ timeout: 15_000 });
+ await expect(chip).toHaveAttribute("data-mr-iid", String(MR_IID));
+ });
+});
+
+function mrSeed(iid: number, title: string) {
+ const now = new Date().toISOString();
+ return {
+ id: iid + 10_000,
+ iid,
+ project_id: 101,
+ title,
+ url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${iid}`,
+ web_url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${iid}`,
+ state: "open",
+ head_branch: `feature/${iid}`,
+ head_sha: `sha-${iid}`,
+ base_branch: "main",
+ author_username: "contributor",
+ project_namespace: "platform",
+ project_path: GITLAB_PROJECT,
+ body: "",
+ draft: false,
+ merge_status: "can_be_merged",
+ has_conflicts: false,
+ additions: 1,
+ deletions: 1,
+ reviewers: [],
+ assignees: [],
+ created_at: now,
+ updated_at: now,
+ };
+}
+
+function pipelineSeed(iid: number, status: string) {
+ return {
+ id: iid + 20_000,
+ iid: 1,
+ status,
+ source: "push",
+ ref: `feature/${iid}`,
+ sha: `sha-${iid}`,
+ web_url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/pipelines/${iid + 20_000}`,
+ jobs_total: 2,
+ jobs_passing: status === "success" ? 2 : 0,
+ };
+}
diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs
index 02dac98f0c..d5a44173fa 100644
--- a/apps/web/eslint.i18n.options.mjs
+++ b/apps/web/eslint.i18n.options.mjs
@@ -1421,6 +1421,11 @@ export const i18nGuardFiles = [
"components/gitlab/mr-files-section.tsx",
"components/gitlab/mr-overview-section.tsx",
"components/gitlab/mr-reviewer-control.tsx",
+ "components/gitlab/mr-status-chip.tsx",
+ "components/gitlab/mr-status-chip-drawer.tsx",
+ "components/gitlab/mr-status-chip-popover.tsx",
+ "components/gitlab/mr-status-chip-selection.ts",
+ "components/gitlab/mr-status-chip-trigger.tsx",
"components/gitlab/mr-topbar-button.tsx",
"components/gitlab/my-gitlab/issue-list.tsx",
"components/gitlab/my-gitlab/list-toolbar.tsx",
diff --git a/apps/web/hooks/domains/gitlab/use-task-mr.test.ts b/apps/web/hooks/domains/gitlab/use-task-mr.test.ts
index a93e6c4570..943e810b4f 100644
--- a/apps/web/hooks/domains/gitlab/use-task-mr.test.ts
+++ b/apps/web/hooks/domains/gitlab/use-task-mr.test.ts
@@ -7,15 +7,23 @@ import type { GitLabStatus, TaskMR } from "@/lib/types/gitlab";
const fetchGitLabStatusMock = vi.fn<() => Promise>();
const listWorkspaceTaskMRsMock =
vi.fn<(workspaceId: string) => Promise<{ task_mrs: Record } | null>>();
+const deleteTaskMRMock = vi.fn<(associationId: string, workspaceId: string) => Promise>();
const EMPTY_TASK_MRS: Record = {};
const WORKSPACE_MRS_TEST_ID = "workspace-mrs";
+const toastMock = vi.fn();
vi.mock("@/lib/api/domains/gitlab-api", () => ({
fetchGitLabStatus: () => fetchGitLabStatusMock(),
listWorkspaceTaskMRs: (workspaceId: string) => listWorkspaceTaskMRsMock(workspaceId),
+ deleteTaskMR: (associationId: string, workspaceId: string) =>
+ deleteTaskMRMock(associationId, workspaceId),
}));
-import { useGitLabAvailable, useTaskMRs, useWorkspaceMRs } from "./use-task-mr";
+vi.mock("@/components/toast-provider", () => ({
+ useToast: () => ({ toast: toastMock }),
+}));
+
+import { useGitLabAvailable, useTaskMRs, useUnlinkTaskMR, useWorkspaceMRs } from "./use-task-mr";
function wrapper({ children }: { children: ReactNode }) {
return createElement(StateProvider, {
@@ -352,3 +360,81 @@ describe("useGitLabAvailable", () => {
expect(fetchGitLabStatusMock).toHaveBeenCalledTimes(1);
});
});
+
+// spec: API surface, "one extraction ... moved verbatim". Both
+// MRTopbarButton and MRStatusChip call this closure, so parity between the
+// two surfaces holds because there is exactly one implementation.
+describe("useUnlinkTaskMR", () => {
+ beforeEach(() => {
+ deleteTaskMRMock.mockReset();
+ toastMock.mockReset();
+ });
+
+ it("is a no-op when workspaceId is null", async () => {
+ const { result } = renderHook(() => useUnlinkTaskMR(null), { wrapper });
+ await act(async () => {
+ await result.current("assoc-1");
+ });
+ expect(deleteTaskMRMock).not.toHaveBeenCalled();
+ });
+
+ it("removes the association from the store on success", async () => {
+ deleteTaskMRMock.mockResolvedValueOnce(undefined);
+ const mr = makeMR({ id: "assoc-1", task_id: "task-1" });
+ const { result } = renderHook(
+ () => {
+ const setTaskMRs = useAppStore((s) => s.setTaskMRs);
+ const mrs = useTaskMRs("task-1", "ws-1");
+ const unlink = useUnlinkTaskMR("ws-1");
+ return { setTaskMRs, mrs, unlink };
+ },
+ { wrapper },
+ );
+ act(() => {
+ result.current.setTaskMRs("ws-1", { "task-1": [mr] });
+ });
+ expect(result.current.mrs).toEqual([mr]);
+
+ await act(async () => {
+ await result.current.unlink("assoc-1");
+ });
+
+ expect(deleteTaskMRMock).toHaveBeenCalledWith("assoc-1", "ws-1");
+ expect(toastMock).not.toHaveBeenCalled();
+ // The whole point of this test: the association actually left the
+ // store. Asserting only pre-unlink state (as this test previously did)
+ // is phantom-green — deleting the removeTaskMR call from the hook still
+ // leaves that assertion passing.
+ expect(result.current.mrs).toEqual([]);
+ });
+
+ it("toasts an error and leaves the association in the store on failure", async () => {
+ deleteTaskMRMock.mockRejectedValueOnce(new Error("network down"));
+ const mr = makeMR({ id: "assoc-1", task_id: "task-1" });
+ const { result } = renderHook(
+ () => {
+ const setTaskMRs = useAppStore((s) => s.setTaskMRs);
+ const mrs = useTaskMRs("task-1", "ws-1");
+ const unlink = useUnlinkTaskMR("ws-1");
+ return { setTaskMRs, mrs, unlink };
+ },
+ { wrapper },
+ );
+ act(() => {
+ result.current.setTaskMRs("ws-1", { "task-1": [mr] });
+ });
+
+ await act(async () => {
+ await result.current.unlink("assoc-1");
+ });
+
+ // spec.md:1026-1029 names the specific title key (gitlab:failedToUnlinkMergeRequest)
+ // both surfaces must use; assert its resolved English text since i18next
+ // is the real instance in this test environment, not a raw-key stub.
+ expect(toastMock).toHaveBeenCalledTimes(1);
+ expect(toastMock).toHaveBeenCalledWith(
+ expect.objectContaining({ title: "Failed to unlink merge request" }),
+ );
+ expect(result.current.mrs).toEqual([mr]);
+ });
+});
diff --git a/apps/web/hooks/domains/gitlab/use-task-mr.ts b/apps/web/hooks/domains/gitlab/use-task-mr.ts
index 0fc66cd78c..eb58703ade 100644
--- a/apps/web/hooks/domains/gitlab/use-task-mr.ts
+++ b/apps/web/hooks/domains/gitlab/use-task-mr.ts
@@ -1,8 +1,10 @@
"use client";
-import { useEffect, useRef } from "react";
-import { listWorkspaceTaskMRs } from "@/lib/api/domains/gitlab-api";
+import { useCallback, useEffect, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { deleteTaskMR, listWorkspaceTaskMRs } from "@/lib/api/domains/gitlab-api";
import { useAppStore } from "@/components/state-provider";
+import { useToast } from "@/components/toast-provider";
import type { TaskMR } from "@/lib/types/gitlab";
import { useGitLabStatus } from "./use-gitlab-status";
@@ -75,3 +77,39 @@ export function useGitLabAvailable(): boolean {
const { status } = useGitLabStatus();
return Boolean(status?.authenticated || status?.token_configured);
}
+
+/**
+ * Unlink closure shared by MRTopbarButton and MRStatusChip so the two
+ * surfaces can never disagree on unlink behaviour (extracted rather than
+ * duplicated — apps/web/eslint.config.mjs forbids identical functions and
+ * 4+ duplicated strings, so a second copy would be a lint failure).
+ * `workspaceId` may be null across the hook's unconditional call in a
+ * component that only invokes the returned closure once a workspace is
+ * known, mirroring how MRTopbarButton already calls its hooks before its
+ * own `!workspaceId` early return.
+ */
+export function useUnlinkTaskMR(
+ workspaceId: string | null,
+): (associationId: string) => Promise {
+ const { t } = useTranslation();
+ const removeTaskMR = useAppStore((state) => state.removeTaskMR);
+ const { toast } = useToast();
+
+ return useCallback(
+ async (associationId: string) => {
+ if (!workspaceId) return;
+ try {
+ await deleteTaskMR(associationId, workspaceId);
+ removeTaskMR(workspaceId, associationId);
+ } catch (error) {
+ toast({
+ title: t("gitlab:failedToUnlinkMergeRequest"),
+ description:
+ error instanceof Error ? error.message : t("gitlab:theMergeRequestIsStillLinked"),
+ variant: "error",
+ });
+ }
+ },
+ [workspaceId, removeTaskMR, toast, t],
+ );
+}
diff --git a/apps/web/src/locales/en/gitlab.json b/apps/web/src/locales/en/gitlab.json
index e32c8e8ffd..6f85850007 100644
--- a/apps/web/src/locales/en/gitlab.json
+++ b/apps/web/src/locales/en/gitlab.json
@@ -331,5 +331,21 @@
"mrTooltipState": "State: {{state}}",
"mrTooltipReview": "Review: {{state}}",
"mrTooltipPipeline": "Pipeline: {{state}}",
- "mrTooltipReadyToMerge": "Ready to merge"
+ "mrTooltipReadyToMerge": "Ready to merge",
+ "mrChipAriaLabelSingle": "Merge request !{{mriid}}, {{status}}",
+ "mrChipAriaLabelMulti_one": "{{count}} merge request, {{status}}",
+ "mrChipAriaLabelMulti_other": "{{count}} merge requests, {{status}}",
+ "mrChipStatusMerged": "merged",
+ "mrChipStatusClosed": "closed",
+ "mrChipStatusFailed": "pipeline failed",
+ "mrChipStatusDraft": "draft",
+ "mrChipStatusReady": "ready to merge",
+ "mrChipStatusAwaitingApproval": "awaiting approval",
+ "mrChipStatusRunning": "pipeline running",
+ "mrChipStatusNeutral": "no pipeline",
+ "mrChipAutoFix": "Auto-fix",
+ "mrChipAutoMerge": "Auto-merge",
+ "mrChipDrawerTitle": "Merge request !{{mriid}}",
+ "mrChipDrawerDescription": "Pipeline status, review status, and merge request actions.",
+ "mrChipDrawerClose": "Close merge request status"
}
diff --git a/apps/web/src/locales/pseudo/gitlab.json b/apps/web/src/locales/pseudo/gitlab.json
index 17b4a3735c..00dd1d9bb2 100644
--- a/apps/web/src/locales/pseudo/gitlab.json
+++ b/apps/web/src/locales/pseudo/gitlab.json
@@ -331,5 +331,21 @@
"mrTooltipState": "Śţàţē: {{state}}",
"mrTooltipReview": "Ŕēvĩēŵ: {{state}}",
"mrTooltipPipeline": "Ƥĩƥēĺĩńē: {{state}}",
- "mrTooltipReadyToMerge": "Ŕēàďŷ ţō ḿēŕĝē"
+ "mrTooltipReadyToMerge": "Ŕēàďŷ ţō ḿēŕĝē",
+ "mrChipAriaLabelSingle": "Ḿēŕĝē ŕēqũēśţ !{{mriid}}, {{status}}",
+ "mrChipAriaLabelMulti_one": "{{count}} ḿēŕĝē ŕēqũēśţ, {{status}}",
+ "mrChipAriaLabelMulti_other": "{{count}} ḿēŕĝē ŕēqũēśţś, {{status}}",
+ "mrChipStatusMerged": "ḿēŕĝēď",
+ "mrChipStatusClosed": "ćĺōśēď",
+ "mrChipStatusFailed": "ƥĩƥēĺĩńē ƒàĩĺēď",
+ "mrChipStatusDraft": "ďŕàƒţ",
+ "mrChipStatusReady": "ŕēàďŷ ţō ḿēŕĝē",
+ "mrChipStatusAwaitingApproval": "àŵàĩţĩńĝ àƥƥŕōvàĺ",
+ "mrChipStatusRunning": "ƥĩƥēĺĩńē ŕũńńĩńĝ",
+ "mrChipStatusNeutral": "ńō ƥĩƥēĺĩńē",
+ "mrChipAutoFix": "Àũţō-ƒĩx",
+ "mrChipAutoMerge": "Àũţō-ḿēŕĝē",
+ "mrChipDrawerTitle": "Ḿēŕĝē ŕēqũēśţ !{{mriid}}",
+ "mrChipDrawerDescription": "Ƥĩƥēĺĩńē śţàţũś, ŕēvĩēŵ śţàţũś, àńď ḿēŕĝē ŕēqũēśţ àćţĩōńś.",
+ "mrChipDrawerClose": "Ćĺōśē ḿēŕĝē ŕēqũēśţ śţàţũś"
}
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index 0f75014441..5aea2a86e7 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -127,6 +127,7 @@ Per-workspace credentials and triage triggers for external services.
| [provider-aware-review-automation](integrations/provider-aware-review-automation.md) | approved |
| [github-authentication](integrations/github-authentication.md) | draft |
| [gitlab-integration](gitlab-integration/spec.md) | shipped |
+| [gitlab-mr-status-chip](gitlab-mr-status-chip/spec.md) | draft |
| [gitlab-workflow-sync](gitlab-workflow-sync/spec.md) | shipped |
| [jira-status-filter](jira-status-filter/spec.md) | shipped |
| [enable-disable-toggle](integrations/enable-disable-toggle.md) | shipped |
diff --git a/docs/specs/gitlab-mr-status-chip/spec.md b/docs/specs/gitlab-mr-status-chip/spec.md
new file mode 100644
index 0000000000..3f69cb47f7
--- /dev/null
+++ b/docs/specs/gitlab-mr-status-chip/spec.md
@@ -0,0 +1,1296 @@
+---
+status: draft
+created: 2026-08-11
+owner: 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
+ `MRStatusChip` in 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
+ `MRCIPopover` body (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. `getMRStatusColor` in
+ `apps/web/components/gitlab/mr-task-icon.tsx` remains 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:
+ 1. the lazy read of the task's MR automation options, on mount;
+ 2. the GitLab connection-status read that `useGitLabAvailable()` performs on
+ mount, which the chip needs to source `canLink`. This one has a
+ cross-surface side effect and is stated in full under Sync and freshness;
+ 3. the MR feedback read that `MRCIPopover` already owns, **only while a
+ disclosure is open** (this is what the popover's `enabled` prop gates; see
+ API surface);
+ 4. 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()` into
+ `apps/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 and `mr_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.
+- `DELETE` task-MR association via `deleteTaskMR` + the `removeTaskMR` store
+ 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
+```
+
+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 reads `failed`, not `draft`.
+- `ready` requires **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"` and `pipeline_state: "pending"` is
+ `awaiting_approval`, not `running`.
+
+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:
+
+1. There is no rank-tie ambiguity to resolve, because ties are resolved on MRs
+ by `mr_iid` / `project_path` / `id`, not on statuses.
+2. The trigger's `data-status` cannot disagree with its `data-mr-iid` **while
+ `data-selection-frozen` is `"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 holds `data-mr-iid` on the
+ frozen MR while `data-status` keeps 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 to `data-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:
+
+1. 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.state` is typed
+ `open | closed | merged | locked | string`, so the two readings diverge for
+ `locked` and for any unrecognised string the backend may emit: under the
+ equality test a locked MR is not selectable and does not count toward
+ `data-mr-count`, and an unknown state behaves the same way. This matches the
+ test `aggregateMRStatusColor` and `isMRReadyToMerge` already 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 through
+ `selectChipMR`, and remain live only for `getMRStatusColor`'s other callers
+ and for the rank-parity test.
+2. Keep those whose `mrChipStatus` has the maximum `MR_CHIP_STATUS_RANK`.
+3. Tiebreak, in order, by **`mr_iid` ascending**, then **`project_path`
+ ascending** (byte-wise, `<`), then **`id` ascending**. `id` is 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 `isMRReadyToMerge` and 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-state` and `data-mr-ready-to-merge`
+ describe the frozen MR, and `data-selection-frozen` is `"true"`.
+- The trigger's `data-status` and 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 which `data-status` and `data-mr-iid` may describe different
+ MRs; it is bounded by `data-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 its `data-auto-fix-exhausted`) and `mr-status-auto-merge-chip` continue to
+ track the live badge-selected MR, on the same side of the line as `data-status`
+ and 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 by
+ `data-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":
+
+1. The chip closes its own disclosure (popover or drawer) first.
+2. Then `TaskMRLinkDialog` opens.
+
+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.** `useTaskMRs` returns 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 contract `PRStatusChip` and `MRTaskIcon` already have.
+- **Store holds a non-array value for the task** (possible during partial
+ hydration). The chip guards with `Array.isArray` and renders nothing, matching
+ `MRTaskIcon` and `PRStatusChip`.
+- **No active workspace, or `taskId` is null.** The chip renders nothing.
+- **Popover feedback fetch fails.** `useMRFeedback` already 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 from `TaskMR` fields 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()`
+ returns `false` until the mount-time `fetchGitLabStatus` resolves (and it
+ actively clears any cached value first — see Sync and freshness). So `canLink`
+ is `false` on the chip's first render and may become `true` a 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, `useGitLabStatus` stores `null`, `canLink` stays `false`,
+ 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.** `useTaskMRAutomationOptions` stores the
+ error and its lazy effect is gated on `error`, 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's `MRAutomationControls` remains 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 own `GET /task-mrs` per 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.tsx` | the task topbar |
+ | `components/kanban-board.tsx` | the kanban board |
+ | `hooks/domains/gitlab/use-mr-key-to-tasks.ts` | `app/gitlab/gitlab-page-client.tsx` |
+ | `hooks/domains/workspace/use-external-vcs-file-link.ts` | `useExternalVcsFileLinkHydration` in `components/task/task-page-content.tsx`, gated on the task having a GitLab-provider repository |
+
+ The chip SHALL NOT call `useWorkspaceMRs`.
+- There is no shared MR feedback cache. `useMRFeedback` is 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:
+
+1. **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.
+2. **It transiently blanks a value other surfaces read.** Because the effect
+ clears the status to `null` before refetching, every other
+ `useGitLabAvailable()` consumer — `hooks/use-nav-availability.ts`,
+ `components/kanban-external-link-availability.ts`,
+ `components/task/task-session-sidebar-task-linking.ts`, and
+ `components/app-sidebar/sections/settings/workspaces-group.tsx` — reads
+ `false` for 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: false` and
+ 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:
+ `PassthroughToolbar` has five mount sites and `ChatInputArea` three, 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 the `Drawer` variant, fine pointer
+ renders the hover `Popover` variant.
+- 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 a
+ `w-80` content width — the values `MRTopbarButton` already uses. `side` is
+ 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 box `y`
+ is greater than or equal to 0" scenario below is the observable consequence of
+ that and SHALL NOT be satisfied by hard-coding a `side`.
+
+## Accessibility, focus, and duplicate mounts
+
+- The trigger is a `