FN-7562: fix mobile oversight menu auto-focus opening a second native select popup
Fixes the mobile oversight overflow menu rendering two overlapping menus by narrowing auto-focus to actionable button menuitems. - Change the menu-open auto-focus effect to query `button.detail-oversight-menu-item` instead of matching any `.detail-oversight-menu-item` element, since the native level `<select>` also carried that class and was matched first. - Programmatically focusing the native `<select>` triggered its OS option picker, which rendered as a second overlapping menu on top of the custom `role="menu"` popover. - Add regression tests covering: auto-focus landing on the first button menuitem when nudge/stop/explain are available, no fallback focus on the select when oversight is off (level-only state), and confirming the desktop inline select is unaffected. - Update dashboard-guide.md docs to describe the fixed auto-focus behavior. Files changed: docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/TaskDetailModal.tsx | 3 +- .../app/components/__tests__/TaskDetailModal.oversight-mobile.test.tsx | 96 ++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-7562 Fusion-Task-Lineage: f39068f1-811f-4663-ade1-ff020a389ae5 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -201,7 +201,7 @@ Features:
|
||||
<!-- FNXC:PlannerOversight 2026-07-04-20:30: FN-7546 clarifies the cluster above — operators reported the buttons were unlabeled and looked inert, with only a hover title explaining why. Adds a visible group label and an always-visible disabled-reason line, and makes Explain always openable since it never mutates anything. -->
|
||||
- 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.
|
||||
<!-- FNXC:PlannerOversight 2026-07-04-19:00: FN-7545 collapses the above controls into a mobile overflow menu below the 768px breakpoint. -->
|
||||
- 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.
|
||||
- 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 `<select>`, so the OS option picker never auto-opens on top of the custom popover (FN-7562).
|
||||
<!-- FNXC:PlannerOversight 2026-07-04-18:00: FN-7519 adds a read-only Intervention Timeline immediately below the FN-7517 controls above. -->
|
||||
- Below the quick oversight controls, the task detail modal renders an **Intervention Timeline** (`data-testid="planner-intervention-timeline"`) 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, and is hidden entirely (no leftover container) when oversight is off or unresolved for the task. 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.
|
||||
|
||||
@@ -3353,12 +3353,13 @@ export function TaskDetailContent({
|
||||
firstMenuItem?.focus();
|
||||
}, [showMoveMenu]);
|
||||
|
||||
// FNXC:PlannerOversight 2026-07-04-00:00: FN-7562 — auto-focus the first actionable button menuitem, never the native oversight-level <select>; focusing the <select> surfaced its OS picker as a second menu overlapping the custom oversight popover on mobile.
|
||||
useEffect(() => {
|
||||
if (!showOversightMenu) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstMenuItem = oversightMenuRef.current?.querySelector<HTMLButtonElement | HTMLSelectElement>(".detail-oversight-menu-item");
|
||||
const firstMenuItem = oversightMenuRef.current?.querySelector<HTMLButtonElement>("button.detail-oversight-menu-item");
|
||||
firstMenuItem?.focus();
|
||||
}, [showOversightMenu]);
|
||||
|
||||
|
||||
@@ -384,6 +384,102 @@ describe("TaskDetailModal oversight controls — mobile overflow menu", () => {
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PlannerOversight 2026-07-04-00:00:
|
||||
FN-7562 regression coverage — the menu-open auto-focus effect used to
|
||||
select `.detail-oversight-menu-item` generically, which matched the native
|
||||
`<select>` FIRST (it carries that class too) and focused it. Focusing a
|
||||
native `<select>` programmatically surfaces its OS option picker, which
|
||||
rendered as a second menu overlapping the custom `role="menu"` popover.
|
||||
These tests assert the fixed invariant: auto-focus lands on an actionable
|
||||
button menuitem (never the select), and only one `role="menu"` surface is
|
||||
ever present, across both the active-overseer state and the oversight-off
|
||||
(level-only) state.
|
||||
*/
|
||||
it("auto-focuses the first button menuitem (never the native select) when nudge/stop/explain are available", async () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-213", column: "in-progress", plannerOversightLevel: "autonomous", plannerOverseerState: activeSnapshot })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = await screen.findByTestId("detail-oversight-menu-trigger");
|
||||
fireEvent.click(trigger);
|
||||
|
||||
const select = await screen.findByTestId("detail-oversight-level-select");
|
||||
const nudgeBtn = await screen.findByTestId("detail-overseer-nudge");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(nudgeBtn);
|
||||
});
|
||||
expect(document.activeElement).not.toBe(select);
|
||||
|
||||
// Exactly one menu surface renders — the custom popover — and the native
|
||||
// select stays a closed control (jsdom/browsers do not spawn a second
|
||||
// top-level popup unless the element is actually focused).
|
||||
expect(screen.getAllByRole("menu")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not fall back to focusing the native select when oversight is off and only the level control renders", async () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-214", column: "todo", plannerOversightLevel: "off" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = await screen.findByTestId("detail-oversight-menu-trigger");
|
||||
fireEvent.click(trigger);
|
||||
|
||||
const select = await screen.findByTestId("detail-oversight-level-select");
|
||||
// No button menuitem exists in this state (nudge/stop/explain are all
|
||||
// absent), so the auto-focus effect must not fall back to the select.
|
||||
expect(screen.queryByTestId("detail-overseer-nudge")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("detail-overseer-stop")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("detail-overseer-explain")).not.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).not.toBe(select);
|
||||
});
|
||||
expect(screen.getAllByRole("menu")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("desktop inline oversight select is unaffected by the mobile auto-focus fix", async () => {
|
||||
setViewportWidth(DESKTOP_WIDTH);
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-215", column: "in-progress", plannerOversightLevel: "autonomous", plannerOverseerState: activeSnapshot })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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();
|
||||
const select = await screen.findByTestId("detail-oversight-level-select");
|
||||
expect(select).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
|
||||
setViewportWidth(MOBILE_WIDTH);
|
||||
});
|
||||
|
||||
it("click-outside closes the menu", async () => {
|
||||
render(
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user