diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 40cc1440e8..7cd3333754 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -196,12 +196,16 @@ Features: - Task cards show a read-only **oversight-level badge** (`Observe`, `Steer`, or `Auto-recovery`) in the meta-badges cluster reflecting the effective planner-oversight level, but only when oversight is *meaningfully configured* — an explicit per-task override (including an explicit `autonomous` override), or a resolved workflow/effective tier of `observe`/`steer` (`data-testid="card-oversight-badge"`). A card that merely **inherits** the schema default `autonomous` tier (no per-task override, no non-default workflow tier) renders no badge and no empty `.card-meta-badges` shell. The badge is also absent when the effective level is explicitly "off", **and** while an inherited (no per-task-override) workflow tier is still being resolved (in flight or not yet fetched) — it never shows a guessed default during that window. - -- The task detail modal's inline meta-controls cluster (next to Priority/Execution mode) adds four planner-overseer controls: a **quick oversight-level select** (`data-testid="detail-oversight-level-select"`) that writes the per-task `plannerOversightLevel` override (Off/Observe/Steer/Autonomous recovery) or clears it back to the inherited workflow/project default via an "Inherit" option; a **manual nudge** button (`data-testid="detail-overseer-nudge"`) that asks the overseer to inject one guidance-only steering comment into the currently watched stage right now (never a merge/PR/destructive action), disabled when the overseer is off/inactive or the task is user-paused/done/archived/`autoMerge:false` in-review; a **stop oversight** button (`data-testid="detail-overseer-stop"`) that disables active oversight for the task (confirmation-gated), hidden once oversight is already off; and an **explain current action** button (`data-testid="detail-overseer-explain"`) that toggles a small read-only panel (`data-testid="detail-overseer-explain-panel"`) showing the overseer's watched stage, reason, last action, and attempt count/limit, with a non-empty-shell inactive state when the overseer is not currently watching. All three action controls call the `POST /tasks/:id/overseer/nudge`, `POST /tasks/:id/overseer/stop`, and `GET /tasks/:id/overseer/explain` routes. + +- The task detail modal's inline meta-controls cluster (next to Priority/Execution mode) exposes planner-overseer controls behind a single compact **"Oversight" overflow-menu button** (`data-testid="detail-oversight-menu-trigger"`, `MoreVertical` icon, `aria-haspopup="menu"`) on every viewport, desktop and mobile alike. Clicking the trigger opens a `role="menu"` popover (mirroring the existing move-action dropdown pattern) containing: a **quick oversight-level select** (`data-testid="detail-oversight-level-select"`) that writes the per-task `plannerOversightLevel` override (Off/Observe/Steer/Autonomous recovery) or clears it back to the inherited workflow/project default via an "Inherit" option; a **manual nudge** button (`data-testid="detail-overseer-nudge"`) that asks the overseer to inject one guidance-only steering comment into the currently watched stage right now (never a merge/PR/destructive action), disabled when the overseer is off/inactive or the task is user-paused/done/archived/`autoMerge:false` in-review; a **stop oversight** button (`data-testid="detail-overseer-stop"`) that disables active oversight for the task (confirmation-gated), hidden once oversight is already off; and an **explain current action** button (`data-testid="detail-overseer-explain"`) that toggles a small read-only panel (`data-testid="detail-overseer-explain-panel"`) showing the overseer's watched stage, reason, last action, and attempt count/limit, with a non-empty-shell inactive state when the overseer is not currently watching. All three action controls call the `POST /tasks/:id/overseer/nudge`, `POST /tasks/:id/overseer/stop`, and `GET /tasks/:id/overseer/explain` routes. The trigger itself is withheld entirely when none of the menu's controls would render (no empty-shell overflow button for the unresolved-default case). -- The FN-7517 controls above carry a visible, non-interactive **`"Overseer controls"` group label** (`data-testid="detail-oversight-controls-label"`) so Nudge/Stop/Explain read as an identifiable cluster rather than unlabeled chips; the label is gated by the same `(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff` condition as the buttons, so it never renders an empty shell when oversight is Off/unresolved. When **Nudge** is disabled, an always-visible helper line (`data-testid="detail-overseer-nudge-disabled-reason"`) states the reason in-DOM (mirroring the existing hover `title`) instead of relying on a mouse-hover tooltip alone. **Explain** is read-only and non-mutating, so it is never disabled purely because the overseer is inactive — clicking it always opens/closes the panel, which shows the overseer's live state when watching or an informative "not currently watching this task" message otherwise. Nudge's mutating enablement rule (`canNudgeOverseer`, including the human-control suppression cases) and Stop's confirmation dialog are unchanged. - -- On mobile (viewport ≤ 768px), the level-select/nudge/stop/explain controls above collapse behind a single compact **"Oversight" overflow-menu button** (`data-testid="detail-oversight-menu-trigger"`, `MoreVertical` icon, `aria-haspopup="menu"`) that opens a `role="menu"` popover (mirroring the existing move-action dropdown pattern) listing the same controls as full-width, comfortably tappable `role="menuitem"` entries with the SAME testids and enablement/visibility rules as the desktop inline controls. The trigger itself is withheld entirely when none of those controls would render (no empty-shell overflow button for the oversight-off + overseer-inactive default). Desktop (>768px) keeps the inline cluster unchanged. Opening the menu presents exactly one surface: menu-open auto-focus lands on the first actionable button menuitem, never the native level ``, so the OS option picker never auto-opens on top of the custom popover (FN-7562). - The task detail modal's **Activity** tab view dropdown (Live/Feed/Raw) gains a fourth **Interventions** option, shown only when planner oversight is active for the task (same gate as the former inline mount: `(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff`). Selecting it renders the **Intervention Timeline** (`data-testid="planner-intervention-timeline"`) inside the Activity panel, listing every recorded planner-overseer intervention for the task, newest-first: watched stage, reason, action taken, outcome (with a `.status-dot` indicator using semantic outcome tokens), an attempt count/limit badge (only when both are present), and source links (agent log / review comment / failed check / merge error / PR state / generic URL). It renders a calm "No planner interventions yet" empty state rather than an empty shell when there are none. When oversight is off or unresolved, the Interventions option is absent from the dropdown entirely (no leftover empty segment), and if it was previously selected the view falls back to Live rather than leaving a blank panel. Entries are read via `GET /tasks/:id/overseer/interventions`, which assembles them from the existing run-audit store under the `overseer:intervention` mutation type (`recordPlannerIntervention`/`getPlannerInterventionTimeline` in `@fusion/core`). This is a pure read surface — FN-7520 wires the actual intervention-producing call-sites. - Task detail surfaces show the selected/effective workflow identity near the task's workflow controls so individual cards remain understandable when Board is in **All workflows** or another aggregate/mixed context. diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index 2819c055a5..47cdecbfe8 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -322,26 +322,23 @@ The task-detail modal metadata must keep priority, execution mode, provenance, P /* FNXC:PlannerOversight 2026-07-04-17:00: FN-7517: the oversight-controls cluster (priority + execution-mode + the - new oversight-level/nudge/stop/explain controls) must wrap without - overflowing the mobile modal width rather than forcing horizontal scroll. + Oversight trigger) must wrap without overflowing the mobile modal width + rather than forcing horizontal scroll. - FNXC:PlannerOversight 2026-07-04-19:00: - FN-7545: the level-select/nudge/stop/explain controls now collapse behind a - single `.detail-oversight-menu-trigger` button below this breakpoint (JS - `isOversightMenuMobile` swap in TaskDetailModal.tsx, not a CSS hide), so the - cluster only ever needs to fit priority + execution-mode + the trigger on - one row; `flex-wrap: wrap` remains as a safety fallback so the cluster never - forces horizontal scroll if it still overflows at very narrow widths. + FNXC:PlannerOversight 2026-07-05-00:00: + FN-7604: the level-select/nudge/stop/explain controls collapse behind the + single `.detail-oversight-menu-trigger` button at EVERY viewport now (no + longer a mobile-only branch), so the cluster only ever needs to fit + priority + execution-mode + the trigger on one row; `flex-wrap: wrap` + remains as a safety fallback so the cluster never forces horizontal scroll + if it still overflows at very narrow widths. The `.detail-oversight-menu` + positioning (`right: 0`) is already declared once on the base rule above + and applies at every width, so no mobile-only override is needed here. */ .detail-meta-inline-controls { flex-wrap: wrap; } - .detail-oversight-menu { - right: 0; - left: auto; - } - .detail-provenance { align-items: center; } @@ -542,36 +539,20 @@ uppercases its content; this only removes a duplicated, drift-prone override. /* FNXC:PlannerOversight 2026-07-04-17:00: -FN-7517 task-detail planner-overseer controls (quick level-change chip, +FN-7517 task-detail planner-overseer controls (quick level-change select, manual nudge / stop / explain buttons, explain panel). Reuses the same `.card-oversight-badge--*` status-color modifiers FN-7516's TaskCard badge already declares (see TaskCard.css) so oversight-level color stays a single -semantic source, and the same `--detail-priority-control-min-height` chip -height token as the priority/execution-mode controls above so the cluster -renders at a uniform height. +semantic source. -FNXC:TaskDetail 2026-07-05-00:00: -FN-7585 — like `.detail-priority-chip`, override `.card-oversight-badge`'s -transparent border with the shared `--btn-border-width`/`--border`/ -`--detail-control-border-radius` trio so every oversight level (including -`--off`, which has a neutral tint) renders the same bordered box as Priority -and Execution-mode. The mobile `.detail-oversight-menu-trigger` swap below -gets the identical trio so both oversight variants match. +FNXC:PlannerOversight 2026-07-05-00:00: +FN-7604 — the desktop-only `.detail-oversight-chip` wrapper (the inline +quick-level-change chip rendered outside the overflow menu) was removed along +with the desktop inline branch it styled; the level select now renders only +inside `.detail-oversight-menu-item--select` (see below). `.detail-oversight-select` +itself stays — the dropdown menu item reuses this class for the native +` { - void handleOversightLevelChange(event.target.value); - }} - disabled={isSavingOversightLevel} - aria-label={t("taskDetail.oversight.ariaLabel", "Planner oversight level")} - > - - {PLANNER_OVERSIGHT_LEVELS.map((levelOption) => ( - - ))} - - - )} - {/* - FNXC:PlannerOversight 2026-07-04-17:00: - FN-7517 manual nudge / stop oversight / explain current action - controls. Disabled (with an accessible aria-label reason) rather - than hidden for nudge/explain when the overseer is off/inactive - so operators understand WHY the control is inert instead of it - silently vanishing; stop is hidden once oversight is already off - (nothing left to stop) per the PROMPT's enablement rule, avoiding - an always-on empty shell for the common oversight-off default. - - FNXC:PlannerOversight 2026-07-04-20:30: - FN-7546 — operators reported these controls were confusing: - unlabeled inline chips, greyed out most of the time, with the - only "why" behind a mouse-hover `title`. Add a visible, - non-interactive `detail-oversight-controls-label` group label - (gated by the SAME condition as the buttons) so the cluster is - identifiable, and a `detail-overseer-nudge-disabled-reason` - helper line that surfaces the disabled reason in-DOM (not just - on hover) whenever Nudge is unavailable. Explain is read-only - and non-mutating, so its disabled gate is removed entirely - (see handleExplainOverseer below) — it always opens its panel, - which already renders an informative "not currently watching" - empty state. Nudge's mutating gate (`canNudgeOverseer`) and - Stop's confirm dialog are unchanged. - */} - {(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff && ( - - {t("taskDetail.oversight.controlsLabel", "Overseer controls")} - - )} - {(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff && ( - - )} - {(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff && !canNudgeOverseer && ( - - {nudgeDisabledReason} - - )} - {(hasTaskOversightOverride || workflowOversightResolved) && showStopOverseer && ( - - )} - {(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff && ( - - )} - - )} + )} {overseerExplainOpen && (
@@ -6013,8 +5884,8 @@ export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) { const overlayDismissProps = useOverlayDismiss(onClose); /* FNXC:TaskDetailSwipeBack 2026-07-05-12:30: - FN-7587 — track the mobile breakpoint locally (mirrors the OVERSIGHT_MENU_MOBILE_BREAKPOINT - resize-listener pattern above) so the list/modal/nested task-detail surface gets the same + FN-7587 — track the mobile breakpoint locally (mirrors the same resize-listener pattern + used elsewhere in this file) so the list/modal/nested task-detail surface gets the same presentation-only predictive-back slide/fade enter transition as the board main-panel (MainContent.tsx), without threading a new isMobile prop through App.tsx/AppModals.tsx. This is presentation-only: it never touches onClose/onRequestClose timing or the underlying diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx index acb0fa4882..b14129ed92 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx @@ -1,3 +1,11 @@ +/* +FNXC:PlannerOversight 2026-07-05-00:00: +FN-7604 — the footer "Actions" dropdown button name is matched EXACTLY +(`{ name: "Actions" }`) throughout this file, not via a loose `/actions/i` +regex. The now-universal Oversight overflow trigger's aria-label is +"Oversight actions", which also matches `/actions/i` and made every such +query ambiguous once the trigger stopped being a mobile-only affordance. +*/ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -743,7 +751,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown to see Duplicate - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); expect(screen.getByRole("menuitem", { name: "Duplicate" })).toBeTruthy(); @@ -764,7 +772,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown - Duplicate should not be there - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); expect(screen.queryByRole("menuitem", { name: "Duplicate" })).toBeNull(); }); @@ -787,7 +795,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" })); @@ -820,7 +828,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" })); @@ -852,7 +860,7 @@ describe("TaskDetailModal", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: /actions/i })); + fireEvent.click(screen.getByRole("button", { name: "Actions" })); const pauseItem = screen.getByRole("menuitem", { name: "Pause" }); fireEvent.pointerUp(pauseItem, { pointerType: "touch", pointerId: 1 }); @@ -884,7 +892,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" })); @@ -915,7 +923,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" })); @@ -945,7 +953,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" })); @@ -976,7 +984,7 @@ describe("TaskDetailModal", () => { addToast={noop} />, ); - fireEvent.click(screen.getByRole("button", { name: /actions/i })); + fireEvent.click(screen.getByRole("button", { name: "Actions" })); const item = screen.queryByRole("menuitem", { name: "Refine" }); if (shouldShow) expect(item).toBeTruthy(); else expect(item).toBeNull(); @@ -1012,7 +1020,7 @@ describe("TaskDetailModal", () => { />, ); - expect(screen.getByRole("button", { name: /actions/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Actions" })).toBeTruthy(); }); it("renders Unpause button for a paused triage task", () => { @@ -1029,7 +1037,7 @@ describe("TaskDetailModal", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: /actions/i })); + fireEvent.click(screen.getByRole("button", { name: "Actions" })); expect(screen.getByRole("menuitem", { name: "Unpause" })).toBeTruthy(); }); @@ -1051,7 +1059,7 @@ describe("TaskDetailModal", () => { />, ); - await userEvent.click(screen.getByRole("button", { name: /actions/i })); + await userEvent.click(screen.getByRole("button", { name: "Actions" })); await userEvent.click(screen.getByRole("menuitem", { name: "Unpause" })); await waitFor(() => { @@ -1084,7 +1092,7 @@ describe("TaskDetailModal", () => { expect(mockFetchAgent).toHaveBeenCalledWith("agent-1", undefined); }); - await userEvent.click(screen.getByRole("button", { name: /actions/i })); + await userEvent.click(screen.getByRole("button", { name: "Actions" })); await userEvent.click(screen.getByRole("menuitem", { name: "Unpause" })); await waitFor(() => { @@ -1115,7 +1123,7 @@ describe("TaskDetailModal", () => { expect(mockFetchAgent).toHaveBeenCalledWith("agent-1", undefined); }); - await userEvent.click(screen.getByRole("button", { name: /actions/i })); + await userEvent.click(screen.getByRole("button", { name: "Actions" })); expect(screen.getByRole("menuitem", { name: "Unpause" })).toBeTruthy(); expect(await screen.findByText("Paused by agent")).toBeTruthy(); @@ -1145,7 +1153,7 @@ describe("TaskDetailModal", () => { expect(mockFetchAgent).toHaveBeenCalledWith("agent-1", undefined); }); - await userEvent.click(screen.getByRole("button", { name: /actions/i })); + await userEvent.click(screen.getByRole("button", { name: "Actions" })); await userEvent.click(screen.getByRole("menuitem", { name: "Pause" })); await waitFor(() => { @@ -1177,7 +1185,7 @@ describe("TaskDetailModal", () => { />, ); - await userEvent.click(screen.getByRole("button", { name: /actions/i })); + await userEvent.click(screen.getByRole("button", { name: "Actions" })); expect(screen.getByRole("menuitem", { name: expectedLabel })).toBeTruthy(); }); @@ -1196,7 +1204,7 @@ describe("TaskDetailModal", () => { />, ); - await userEvent.click(screen.getByRole("button", { name: /actions/i })); + await userEvent.click(screen.getByRole("button", { name: "Actions" })); expect(screen.queryByRole("menuitem", { name: "Pause" })).toBeNull(); expect(screen.queryByRole("menuitem", { name: "Unpause" })).toBeNull(); @@ -1216,7 +1224,7 @@ describe("TaskDetailModal", () => { />, ); - expect(screen.queryByRole("button", { name: /actions/i })).toBeNull(); + expect(screen.queryByRole("button", { name: "Actions" })).toBeNull(); }); it("clicking Refine opens the refinement modal", () => { @@ -1234,7 +1242,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); @@ -1258,7 +1266,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); @@ -1281,7 +1289,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); @@ -1309,7 +1317,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); @@ -1333,7 +1341,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); @@ -1364,7 +1372,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); @@ -1392,7 +1400,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); @@ -1443,7 +1451,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); @@ -1495,7 +1503,7 @@ describe("TaskDetailModal", () => { await screen.findByTestId("task-detail-workflow-badge"); expect(screen.getByTestId("task-detail-workflow-badge")).toHaveTextContent("Custom refinement lane"); - fireEvent.click(screen.getByRole("button", { name: /actions/i })); + fireEvent.click(screen.getByRole("button", { name: "Actions" })); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); fireEvent.change(screen.getByPlaceholderText("Enter your feedback here..."), { target: { value: "Keep the same workflow lane" } }); fireEvent.click(screen.getByText("Create Refinement Task")); @@ -1531,7 +1539,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); // Click Refine from the dropdown @@ -1562,7 +1570,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); // Click Refine from the dropdown @@ -1596,7 +1604,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); // Click Refine from the dropdown @@ -1630,7 +1638,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); // Click Refine from the dropdown diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-controls.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-controls.test.tsx index 18bf22f1ad..dccdea07d4 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-controls.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-controls.test.tsx @@ -3,8 +3,18 @@ FNXC:PlannerOversight 2026-07-04-17:00: FN-7517 coverage for the task-detail planner-overseer controls: the quick oversight-level-change select, the manual nudge/stop/explain buttons, and their enablement/leftover-shell rules (Surface Enumeration). + +FNXC:PlannerOversight 2026-07-05-00:00: +FN-7604 — the desktop inline cluster was removed; ALL oversight controls +(including at the historically "desktop" 1024px-ish jsdom default width) now +render only behind the `detail-oversight-menu-trigger` overflow menu, the +same surface the mobile suite below already exercised. This describe block's +tests are retargeted to open the trigger via the shared `openOversightMenu()` +helper before querying level-select/nudge/stop/explain, mirroring the +pre-existing FN-7545/FN-7558 mobile pattern exactly — there is no longer a +separate desktop-only assertion path. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import type { PlannerOverseerRuntimeSnapshot } from "@fusion/core"; import { @@ -34,6 +44,20 @@ const activeSnapshot: PlannerOverseerRuntimeSnapshot = { lastAction: "inject_guidance", }; +/* +FNXC:PlannerOversight 2026-07-05-00:00: +FN-7604 — shared open-the-overflow-menu helper reused across every describe +block in this file (the pattern the FN-7521/FN-7545 mobile-only describe +block already used). Since the dropdown is now the universal surface at +every viewport, every test that needs to observe the level select or the +nudge/stop/explain buttons must click the trigger first. +*/ +async function openOversightMenu() { + const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); + fireEvent.click(trigger); + return trigger; +} + describe("TaskDetailModal oversight controls", () => { beforeEach(async () => { vi.clearAllMocks(); @@ -63,6 +87,7 @@ describe("TaskDetailModal oversight controls", () => { />, ); + await openOversightMenu(); const select = await screen.findByTestId("detail-oversight-level-select"); expect((select as HTMLSelectElement).value).toBe("observe"); @@ -90,6 +115,7 @@ describe("TaskDetailModal oversight controls", () => { />, ); + await openOversightMenu(); const select = await screen.findByTestId("detail-oversight-level-select"); fireEvent.change(select, { target: { value: "__inherit__" } }); @@ -114,6 +140,7 @@ describe("TaskDetailModal oversight controls", () => { />, ); + await openOversightMenu(); const nudgeBtn = await screen.findByTestId("detail-overseer-nudge"); expect(nudgeBtn).not.toBeDisabled(); fireEvent.click(nudgeBtn); @@ -136,6 +163,7 @@ describe("TaskDetailModal oversight controls", () => { />, ); + await openOversightMenu(); const nudgeBtn = await screen.findByTestId("detail-overseer-nudge"); expect(nudgeBtn).toBeDisabled(); }); @@ -153,6 +181,7 @@ describe("TaskDetailModal oversight controls", () => { />, ); + await openOversightMenu(); const label = await screen.findByTestId("detail-oversight-controls-label"); expect(label).toHaveTextContent("Overseer controls"); @@ -181,6 +210,7 @@ describe("TaskDetailModal oversight controls", () => { />, ); + await openOversightMenu(); const nudgeBtn = await screen.findByTestId("detail-overseer-nudge"); expect(nudgeBtn).toBeDisabled(); @@ -203,6 +233,7 @@ describe("TaskDetailModal oversight controls", () => { />, ); + await openOversightMenu(); const nudgeBtn = await screen.findByTestId("detail-overseer-nudge"); expect(nudgeBtn).toBeDisabled(); @@ -225,6 +256,7 @@ describe("TaskDetailModal oversight controls", () => { />, ); + await openOversightMenu(); const nudgeBtn = await screen.findByTestId("detail-overseer-nudge"); expect(nudgeBtn).not.toBeDisabled(); expect(screen.queryByTestId("detail-overseer-nudge-disabled-reason")).not.toBeInTheDocument(); @@ -243,6 +275,7 @@ describe("TaskDetailModal oversight controls", () => { />, ); + await openOversightMenu(); const nudgeBtn = await screen.findByTestId("detail-overseer-nudge"); expect(nudgeBtn).toBeDisabled(); }); @@ -260,6 +293,7 @@ describe("TaskDetailModal oversight controls", () => { />, ); + await openOversightMenu(); const nudgeBtn = await screen.findByTestId("detail-overseer-nudge"); expect(nudgeBtn).toBeDisabled(); }); @@ -280,6 +314,7 @@ describe("TaskDetailModal oversight controls", () => { />, ); + await openOversightMenu(); const stopBtn = await screen.findByTestId("detail-overseer-stop"); fireEvent.click(stopBtn); @@ -302,6 +337,7 @@ describe("TaskDetailModal oversight controls", () => { />, ); + await openOversightMenu(); await screen.findByTestId("detail-oversight-level-select"); expect(screen.queryByTestId("detail-overseer-stop")).not.toBeInTheDocument(); }); @@ -322,6 +358,7 @@ describe("TaskDetailModal oversight controls", () => { />, ); + await openOversightMenu(); const explainBtn = await screen.findByTestId("detail-overseer-explain"); fireEvent.click(explainBtn); @@ -349,6 +386,7 @@ describe("TaskDetailModal oversight controls", () => { />, ); + await openOversightMenu(); const explainBtn = await screen.findByTestId("detail-overseer-explain"); fireEvent.click(explainBtn); @@ -372,6 +410,7 @@ describe("TaskDetailModal oversight controls", () => { />, ); + await openOversightMenu(); const explainBtn = await screen.findByTestId("detail-overseer-explain"); // Read-only Explain must never be disabled purely because the overseer // isn't actively watching — that inactive state is exactly what the @@ -397,9 +436,11 @@ describe("TaskDetailModal oversight controls", () => { />, ); - // The quick level-change select still renders (it's always editable so an - // operator can opt IN to oversight), but nudge/stop/explain must not - // render an always-on empty shell for the common off+inactive default. + // The quick level-change select still renders inside the opened menu + // (it's always editable so an operator can opt IN to oversight), but + // nudge/stop/explain must not render an always-on empty shell for the + // common off+inactive default. + await openOversightMenu(); await screen.findByTestId("detail-oversight-level-select"); expect(screen.queryByTestId("detail-overseer-nudge")).not.toBeInTheDocument(); expect(screen.queryByTestId("detail-overseer-stop")).not.toBeInTheDocument(); @@ -416,8 +457,10 @@ sites pass a slim `Task` (no `prompt` key) that never carries `plannerOverseerState` — only the full-detail fetch response does. These tests reproduce that exact path: a slim task prop with NO snapshot, plus a mocked `fetchTaskDetail` resolving a full TaskDetail WITH an active snapshot, -and assert Nudge enables (helper absent) once the fetched detail lands — at -both the desktop inline site and the mobile overflow-menu site. +and assert Nudge enables (helper absent) once the fetched detail lands — +behind the (now universal, FN-7604) overflow-menu trigger at both a +"desktop" and a narrow-viewport width, exercised via the shared +`openOversightMenu()` helper. */ describe("TaskDetailModal oversight controls — snapshot delivered via fetched full detail (FN-7600)", () => { const originalInnerWidth = window.innerWidth; @@ -471,6 +514,7 @@ describe("TaskDetailModal oversight controls — snapshot delivered via fetched />, ); + await openOversightMenu(); const nudgeBtn = await screen.findByTestId("detail-overseer-nudge"); await waitFor(() => { expect(nudgeBtn).not.toBeDisabled(); @@ -498,6 +542,7 @@ describe("TaskDetailModal oversight controls — snapshot delivered via fetched />, ); + await openOversightMenu(); const nudgeBtn = await screen.findByTestId("detail-overseer-nudge"); expect(nudgeBtn).toBeDisabled(); const reason = await screen.findByTestId("detail-overseer-nudge-disabled-reason"); @@ -545,18 +590,22 @@ describe("TaskDetailModal oversight controls — snapshot delivered via fetched * matching the pre-FN-7545 DOM (CSS-only `@media (max-width: 768px)` wrap, * no conditional mount). FN-7545 then collapsed that cluster's action * controls (level select / nudge / stop / explain) into a mobile overflow - * menu: at `window.innerWidth <= OVERSIGHT_MENU_MOBILE_BREAKPOINT` (768) the - * mount-time `updateOversightMenuMobile()` effect flips `isOversightMenuMobile` - * to true, so those controls now render INSIDE a closed `detail-oversight-menu` - * behind a `detail-oversight-menu-trigger` button instead of inline — the old - * flat queries no longer find them. This suite is corrected to drive the real - * FN-7545 mobile affordance: open the trigger, then query the menu items. It - * still asserts the same invariants FN-7521 required — select-writes-on-change, - * enabled nudge/stop/explain when the overseer is active, and no leftover - * empty-menu shell for the off+inactive default — just through the shipped - * mobile surface. The desktop branch (first `describe` above) is unchanged. + * menu behind a `detail-oversight-menu-trigger` button instead of inline — + * the old flat queries no longer find them. This suite drives the real + * FN-7545 overflow-menu affordance: open the trigger, then query the menu + * items. + * + * FNXC:PlannerOversight 2026-07-05-00:00 (FN-7604): + * The overflow menu this suite exercises is now the UNIVERSAL surface at + * every viewport, not a mobile-only branch — the desktop describe block + * above drives the exact same menu via the shared `openOversightMenu()` + * helper. Forcing `window.innerWidth = 375` here no longer selects a + * different code path; it is kept purely as a documented regression guard + * that the popover still renders/behaves correctly at a narrow width (e.g. + * `.detail-oversight-menu { right: 0 }` positioning), not because a second + * branch exists to select between. */ -describe("TaskDetailModal oversight controls — mobile breakpoint (FN-7521, FN-7545 overflow menu)", () => { +describe("TaskDetailModal oversight controls — narrow-viewport regression guard (FN-7521, FN-7545/FN-7604 universal overflow menu)", () => { const originalInnerWidth = window.innerWidth; beforeEach(async () => { @@ -568,10 +617,10 @@ describe("TaskDetailModal oversight controls — mobile breakpoint (FN-7521, FN- vi.mocked(api.nudgeOverseer).mockResolvedValue({ applied: false, reason: "oversight-off" }); vi.mocked(api.stopOverseer).mockResolvedValue({ applied: true, reason: "stopped" }); vi.mocked(api.explainOverseer).mockResolvedValue({ snapshot: null }); - // Setting innerWidth before render is sufficient: TaskDetailModal's mount - // effect calls `updateOversightMenuMobile()` once on mount, reading - // `window.innerWidth` synchronously, which flips `isOversightMenuMobile` - // before the first paint the tests observe. + // Force a narrow viewport as a regression guard for the popover's mobile + // positioning/rendering; the overflow menu itself is the universal + // surface at every width post-FN-7604, so this no longer selects a + // separate branch. Object.defineProperty(window, "innerWidth", { value: 375, configurable: true }); }); @@ -579,13 +628,9 @@ describe("TaskDetailModal oversight controls — mobile breakpoint (FN-7521, FN- Object.defineProperty(window, "innerWidth", { value: originalInnerWidth, configurable: true }); }); - async function openOversightMenu() { - const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); - fireEvent.click(trigger); - return trigger; - } + // Reuses the shared `openOversightMenu()` helper defined at file scope. - it("still renders the quick level-change select behind the mobile overflow menu and writes on change", async () => { + it("still renders the quick level-change select behind the overflow menu and writes on change", async () => { const api = await import("../../api"); const mockUpdate = vi.fn().mockResolvedValue(makeTask({ id: "FN-201", plannerOversightLevel: "steer" })); vi.mocked(api.updateTask).mockImplementation(mockUpdate as any); @@ -613,7 +658,7 @@ describe("TaskDetailModal oversight controls — mobile breakpoint (FN-7521, FN- }); }); - it("still renders enabled nudge/stop/explain controls behind the mobile overflow menu when the overseer is actively watching", async () => { + it("still renders enabled nudge/stop/explain controls behind the overflow menu at a narrow viewport when the overseer is actively watching", async () => { render( { + it("still shows the reworded periodic-observation copy (not the old alarming phrase) behind the overflow menu at a narrow viewport (FN-7582)", async () => { render( { + it("still renders no oversight-control leftover shell behind the overflow menu at a narrow viewport for the off+inactive default case", async () => { render( { beforeEach(async () => { @@ -726,7 +780,7 @@ describe("Intervention Timeline relocation into the Activity dropdown (FN-7571)" />, ); - await screen.findByTestId("detail-overseer-nudge"); + await screen.findByTestId("detail-oversight-menu-trigger"); expect(screen.queryByTestId("planner-intervention-timeline")).not.toBeInTheDocument(); }); @@ -743,7 +797,7 @@ describe("Intervention Timeline relocation into the Activity dropdown (FN-7571)" />, ); - await screen.findByTestId("detail-overseer-nudge"); + await screen.findByTestId("detail-oversight-menu-trigger"); openActivityViewMenu(); const option = screen.getByRole("menuitem", { name: "Interventions" }); fireEvent.click(option); @@ -764,7 +818,7 @@ describe("Intervention Timeline relocation into the Activity dropdown (FN-7571)" />, ); - await screen.findByTestId("detail-oversight-level-select"); + await screen.findByTestId("detail-oversight-menu-trigger"); openActivityViewMenu(); expect(screen.queryByRole("menuitem", { name: "Interventions" })).not.toBeInTheDocument(); expect(screen.queryByTestId("planner-intervention-timeline")).not.toBeInTheDocument(); @@ -783,7 +837,7 @@ describe("Intervention Timeline relocation into the Activity dropdown (FN-7571)" />, ); - await screen.findByTestId("detail-overseer-nudge"); + await screen.findByTestId("detail-oversight-menu-trigger"); openActivityViewMenu(); fireEvent.click(screen.getByRole("menuitem", { name: "Interventions" })); expect(await screen.findByTestId("planner-intervention-timeline")).toBeInTheDocument(); @@ -828,7 +882,7 @@ describe("Intervention Timeline relocation into the Activity dropdown (FN-7571)" />, ); - await screen.findByTestId("detail-overseer-nudge"); + await screen.findByTestId("detail-oversight-menu-trigger"); openActivityViewMenu(); fireEvent.click(screen.getByRole("menuitem", { name: "Feed" })); @@ -858,7 +912,7 @@ describe("Intervention Timeline relocation into the Activity dropdown (FN-7571)" />, ); - await screen.findByTestId("detail-overseer-nudge"); + await screen.findByTestId("detail-oversight-menu-trigger"); openActivityViewMenu(); fireEvent.click(screen.getByRole("menuitem", { name: "Interventions" })); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-mobile.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-mobile.test.tsx index ad14f00d29..bf1598e14d 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-mobile.test.tsx @@ -1,15 +1,21 @@ /* FNXC:PlannerOversight 2026-07-04-19:00: -FN-7545 coverage for the mobile collapse of the FN-7517 oversight action -controls into a single overflow menu (`detail-oversight-menu-trigger`). The -suite forces the narrow-viewport branch by setting `window.innerWidth` below -the `TaskDetailModal.tsx` `OVERSIGHT_MENU_MOBILE_BREAKPOINT` (768) BEFORE -render, since the component reads `window.innerWidth` on mount via a resize -listener (mirroring `DocumentsView`'s local `isMobile` pattern) rather than a -CSS media query. Every action inside the menu reuses the SAME handlers and -enablement gates as the desktop suite -(`TaskDetailModal.oversight-controls.test.tsx`) — this file only asserts the -collapsed-menu affordance, not new guard logic. +FN-7545 coverage for the collapse of the FN-7517 oversight action controls +into a single overflow menu (`detail-oversight-menu-trigger`). Every action +inside the menu reuses the SAME handlers and enablement gates as the desktop +suite (`TaskDetailModal.oversight-controls.test.tsx`) — this file only +asserts the collapsed-menu affordance, not new guard logic. + +FNXC:PlannerOversight 2026-07-05-00:00: +FN-7604 — the overflow menu is now the SINGLE UNIVERSAL surface at every +viewport (desktop and mobile); it is no longer a narrow-viewport-only branch +selected by a JS `isOversightMenuMobile` resize listener (that state, the +`OVERSIGHT_MENU_MOBILE_BREAKPOINT` constant, and its effects were removed +from `TaskDetailModal.tsx`). `setViewportWidth`/`MOBILE_WIDTH`/`DESKTOP_WIDTH` +no longer select which branch mounts — both widths mount the exact same +dropdown — they are kept as a documented regression guard that the popover +still renders/positions/behaves correctly across a narrow AND a desktop +viewport, per the Surface Enumeration breakpoint requirement. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; @@ -455,7 +461,7 @@ describe("TaskDetailModal oversight controls — mobile overflow menu", () => { expect(screen.getAllByRole("menu")).toHaveLength(1); }); - it("desktop inline oversight select is unaffected by the mobile auto-focus fix", async () => { + it("the overflow-menu popover renders identically at a desktop viewport (FN-7604 universal dropdown)", async () => { setViewportWidth(DESKTOP_WIDTH); render( @@ -470,12 +476,18 @@ describe("TaskDetailModal oversight controls — mobile overflow menu", () => { />, ); - // Desktop renders the inline native select directly (no overflow trigger, - // no custom popover) — confirm that surface is untouched by this fix. - expect(screen.queryByTestId("detail-oversight-menu-trigger")).not.toBeInTheDocument(); + // FNXC:PlannerOversight 2026-07-05-00:00: FN-7604 — there is no longer a + // desktop-only inline select surface; the overflow-menu trigger is the + // single universal mount point at every viewport, including desktop. The + // popover stays closed until clicked, exactly like the mobile width. + const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); + expect(screen.queryByTestId("detail-oversight-level-select")).not.toBeInTheDocument(); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + + fireEvent.click(trigger); const select = await screen.findByTestId("detail-oversight-level-select"); expect(select).toBeInTheDocument(); - expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + expect(screen.getByRole("menu")).toBeInTheDocument(); setViewportWidth(MOBILE_WIDTH); }); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx index e63acd3a39..0b5cdd3998 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx @@ -1,6 +1,13 @@ /* FNXC:TaskDetailTabs 2026-06-17-08:20: FN-7306 labels the stable internal `chat` tab as Activity and keeps it as the default TaskDetailModal tab. Tests that assert Definition-only sections must opt into `initialTab="definition"` so they verify the intended surface instead of the Activity landing state. + +FNXC:PlannerOversight 2026-07-05-00:00: +FN-7604 — the footer "Actions" dropdown button name is matched EXACTLY +(`{ name: "Actions" }`) throughout this file, not via a loose `/actions/i` +regex. The now-universal Oversight overflow trigger's aria-label is +"Oversight actions", which also matches `/actions/i` and made every such +query ambiguous once the trigger stopped being a mobile-only affordance. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; @@ -1538,7 +1545,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown to see Retry - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); expect(screen.getByRole("menuitem", { name: "Retry" })).toBeTruthy(); @@ -1560,7 +1567,7 @@ describe("TaskDetailModal", () => { ); // No Retry should be visible in the Actions dropdown - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); expect(screen.queryByRole("menuitem", { name: "Retry" })).toBeNull(); }); @@ -1599,7 +1606,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown and check for exactly one Retry - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); const retryButtons = screen.getAllByRole("menuitem", { name: "Retry" }); @@ -1622,7 +1629,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown and check for exactly one Retry - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); const retryButtons = screen.getAllByRole("menuitem", { name: "Retry" }); @@ -1644,7 +1651,7 @@ describe("TaskDetailModal", () => { />, ); - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); const retryButtons = screen.getAllByRole("menuitem", { name: "Retry" }); @@ -1670,7 +1677,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown and click Retry - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); await act(async () => { fireEvent.click(actionsBtn); }); @@ -1706,7 +1713,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown and click Retry - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); await act(async () => { fireEvent.click(actionsBtn); }); @@ -1746,7 +1753,7 @@ describe("TaskDetailModal", () => { ); // Open Actions dropdown and click Retry - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); await act(async () => { fireEvent.click(actionsBtn); }); @@ -1787,7 +1794,7 @@ describe("TaskDetailModal", () => { expect(screen.getByRole("menuitem", { name: "Back to In Progress" })).toBeTruthy(); expect(screen.queryByRole("menuitem", { name: "Move to Todo" })).toBeNull(); - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); expect(screen.queryByRole("menuitem", { name: "Retry" })).toBeNull(); }); @@ -1807,7 +1814,7 @@ describe("TaskDetailModal", () => { />, ); - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + const actionsBtn = screen.getByRole("button", { name: "Actions" }); await act(async () => { fireEvent.click(actionsBtn); }); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx index c2ba62771d..c4342edd93 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx @@ -232,13 +232,16 @@ describe("TaskDetailModal", () => { const inlineControlsBlock = getStandaloneCssRuleBlock(css, ".detail-meta-inline-controls"); const priorityChipBlock = getExactCssRuleBlock(css, ".detail-priority-chip"); const executionToggleBlock = getExactCssRuleBlock(css, ".detail-execution-mode-toggle"); - const oversightChipBlock = getExactCssRuleBlock(css, ".detail-oversight-chip"); const oversightTriggerBlock = getExactCssRuleBlock(css, ".detail-oversight-menu-trigger"); - // The cluster declares one shared border-radius token; all four controls - // must reference it rather than four independent literal radii. + // The cluster declares one shared border-radius token; all three + // controls must reference it rather than independent literal radii. + // FNXC:PlannerOversight 2026-07-05-00:00: FN-7604 removed the desktop-only + // `.detail-oversight-chip` wrapper (the inline branch it styled was + // deleted); the Oversight surface is now represented solely by + // `.detail-oversight-menu-trigger`, which already carried this trio. expect(inlineControlsBlock).toContain("--detail-control-border-radius: var(--radius-md);"); - for (const block of [priorityChipBlock, executionToggleBlock, oversightChipBlock, oversightTriggerBlock]) { + for (const block of [priorityChipBlock, executionToggleBlock, oversightTriggerBlock]) { expect(block).toContain("border-radius: var(--detail-control-border-radius);"); expect(block).toContain("border-width: var(--btn-border-width);"); expect(block).toContain("border-color: var(--border);"); @@ -247,26 +250,25 @@ describe("TaskDetailModal", () => { expect(block).toContain("box-sizing: border-box;"); } - // Guard against regressing back to four independent literal radius values - // (e.g. reintroducing a bare `var(--radius-pill)` on only the chips). + // Guard against regressing back to independent literal radius values + // (e.g. reintroducing a bare `var(--radius-pill)` on the priority chip). expect(priorityChipBlock).not.toMatch(/border-radius:\s*var\(--radius-pill\)/); - expect(oversightChipBlock).not.toMatch(/border-radius:\s*var\(--radius-pill\)/); + expect(oversightTriggerBlock).not.toMatch(/border-radius:\s*var\(--radius-pill\)/); }); it("renders the Priority dropdown chip like the Oversight dropdown chip, on every surface (FN-7597)", () => { const css = readDashboardStylesSource(); const priorityChipBlock = getExactCssRuleBlock(css, ".detail-priority-chip"); - const oversightChipBlock = getExactCssRuleBlock(css, ".detail-oversight-chip"); const oversightTriggerBlock = getExactCssRuleBlock(css, ".detail-oversight-menu-trigger"); const prioritySelectBlock = getExactCssRuleBlock(css, ".detail-priority-select"); const oversightSelectBlock = getExactCssRuleBlock(css, ".detail-oversight-select"); const prioritySelectOptionBlock = getExactCssRuleBlock(css, ".detail-priority-select option"); const oversightSelectOptionBlock = getExactCssRuleBlock(css, ".detail-oversight-select option"); - // Same box size AND same border source for the desktop Priority chip vs. - // BOTH oversight surfaces (desktop chip and the mobile overflow trigger). - for (const block of [priorityChipBlock, oversightChipBlock, oversightTriggerBlock]) { + // Same box size AND same border source for the Priority chip vs. the + // (now-universal, FN-7604) Oversight overflow trigger. + for (const block of [priorityChipBlock, oversightTriggerBlock]) { expect(block).toContain("min-height: var(--detail-priority-control-min-height);"); expect(block).toContain("border-width: var(--btn-border-width);"); expect(block).toContain("border-color: var(--border);"); @@ -613,8 +615,13 @@ describe("TaskDetailModal", () => { />, ); - // Actions are now in a dropdown - open it first - const actionsBtn = screen.getByRole("button", { name: /actions/i }); + // Actions are now in a dropdown - open it first. + // FNXC:PlannerOversight 2026-07-05-00:00: FN-7604 — the footer "Actions" + // dropdown button name must be matched EXACTLY (not `/actions/i`) because + // the now-universal Oversight overflow trigger's aria-label is "Oversight + // actions", which also matches a loose /actions/i regex and made this + // query ambiguous once the trigger stopped being mobile-only. + const actionsBtn = screen.getByRole("button", { name: "Actions" }); fireEvent.click(actionsBtn); // Now the dropdown items should be visible @@ -653,7 +660,7 @@ describe("TaskDetailModal", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: /actions/i })); + fireEvent.click(screen.getByRole("button", { name: "Actions" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); await waitFor(() => { @@ -680,7 +687,7 @@ describe("TaskDetailModal", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: /actions/i })); + fireEvent.click(screen.getByRole("button", { name: "Actions" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); await waitFor(() => { @@ -707,7 +714,7 @@ describe("TaskDetailModal", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: /actions/i })); + fireEvent.click(screen.getByRole("button", { name: "Actions" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); await waitFor(() => { @@ -732,7 +739,7 @@ describe("TaskDetailModal", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: /actions/i })); + fireEvent.click(screen.getByRole("button", { name: "Actions" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); await waitFor(() => { @@ -771,7 +778,7 @@ describe("TaskDetailModal", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: /actions/i })); + fireEvent.click(screen.getByRole("button", { name: "Actions" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); await waitFor(() => { @@ -833,7 +840,7 @@ describe("TaskDetailModal", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: /actions/i })); + fireEvent.click(screen.getByRole("button", { name: "Actions" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); await waitFor(() => { @@ -870,7 +877,7 @@ describe("TaskDetailModal", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: /actions/i })); + fireEvent.click(screen.getByRole("button", { name: "Actions" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); await waitFor(() => { @@ -907,7 +914,7 @@ describe("TaskDetailModal", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: /actions/i })); + fireEvent.click(screen.getByRole("button", { name: "Actions" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); await waitFor(() => { @@ -950,7 +957,7 @@ describe("TaskDetailModal", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: /actions/i })); + fireEvent.click(screen.getByRole("button", { name: "Actions" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); await waitFor(() => { @@ -981,7 +988,7 @@ describe("TaskDetailModal", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: /actions/i })); + fireEvent.click(screen.getByRole("button", { name: "Actions" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); await waitFor(() => { @@ -1018,7 +1025,7 @@ describe("TaskDetailModal", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: /actions/i })); + fireEvent.click(screen.getByRole("button", { name: "Actions" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); await waitFor(() => { @@ -1052,7 +1059,7 @@ describe("TaskDetailModal", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: /actions/i })); + fireEvent.click(screen.getByRole("button", { name: "Actions" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); await waitFor(() => {