From 19a436ab5eff8ae61295f771f96434848ddc98a7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 19:43:51 -0700 Subject: [PATCH] FN-7545: collapse task-detail oversight controls into mobile overflow menu Adds a mobile-friendly overflow menu for the task-detail planner-overseer controls, keeping the desktop inline cluster unchanged. - Below 768px, replace the inline oversight-level select/nudge/stop/explain controls with a single "Oversight" overflow-menu trigger (MoreVertical icon, aria-haspopup="menu") mirroring the existing move-action dropdown pattern - Popover lists the same controls as full-width, tappable role="menuitem" entries with identical testids and enablement/visibility rules as desktop - Trigger is withheld entirely when no controls would render (no empty-shell button) - Desktop (>768px) inline cluster is unchanged - Adds TaskDetailModal.oversight-mobile.test.tsx covering trigger visibility, menu contents, and parity with desktop controls - Updates docs/dashboard-guide.md to document the mobile overflow-menu behavior Files changed: docs/dashboard-guide.md | 2 + packages/dashboard/app/components/TaskDetailModal.css | 118 ++++++ packages/dashboard/app/components/TaskDetailModal.tsx | 383 ++++++++++++++----- packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-mobile.test.tsx | 413 +++++++++++++++++++++ packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts | 2 + 5 files changed, 830 insertions(+), 88 deletions(-) Fusion-Task-Id: FN-7545 Fusion-Task-Lineage: 63cb778e-f17d-43d3-a3de-864683d4e8f1 Co-authored-by: Fusion (runfusion.ai) --- docs/dashboard-guide.md | 2 + .../app/components/TaskDetailModal.css | 118 +++++ .../app/components/TaskDetailModal.tsx | 383 ++++++++++++---- .../TaskDetailModal.oversight-mobile.test.tsx | 413 ++++++++++++++++++ .../__tests__/TaskDetailModal.test-helpers.ts | 2 + 5 files changed, 830 insertions(+), 88 deletions(-) create mode 100644 packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-mobile.test.tsx diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 9ed83dd6fe..63bcdadbbe 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -188,6 +188,8 @@ Features: - 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. + +- 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. - 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. diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index f8dd477700..4adf9aef49 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -324,11 +324,24 @@ The task-detail modal metadata must keep priority, execution mode, provenance, P 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. + + 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. */ .detail-meta-inline-controls { flex-wrap: wrap; } + .detail-oversight-menu { + right: 0; + left: auto; + } + .detail-provenance { align-items: center; } @@ -551,6 +564,111 @@ renders at a uniform height. color: var(--text-muted); } +/* +FNXC:PlannerOversight 2026-07-04-19:00: +FN-7545 — mobile overflow menu for the oversight action controls. Reuses the +same `.detail-move-dropdown` / `.detail-move-menu` popover tokens/pattern +(position/border/radius/shadow) so the two overflow menus in this modal look +and behave consistently. The popover opens DOWNWARD (`top: calc(100% + ...)`) +since the trigger lives in the header cluster rather than the footer, unlike +`.detail-move-menu` which opens upward from the footer. +*/ +.detail-oversight-menu-dropdown { + position: relative; +} + +.detail-oversight-menu-trigger { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + min-height: var(--detail-priority-control-min-height); + padding-block: var(--space-xs); + box-sizing: border-box; +} + +.detail-oversight-menu-trigger svg { + width: 1em; + height: 1em; +} + +.detail-oversight-menu { + position: absolute; + top: calc(100% + var(--space-xs)); + right: 0; + z-index: 50; + display: flex; + flex-direction: column; + min-width: calc(var(--space-2xl) + var(--space-2xl) + var(--space-md)); + max-width: min(calc(100vw - var(--space-lg) * 2), 280px); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + overflow: hidden; +} + +.detail-oversight-menu-item { + /* + FNXC:PlannerOversight 2026-07-04-19:00: + FN-7545 — 36px touch-target floor for the collapsed mobile menu items, + matching the project's existing `--settings-inline-touch-target` / + `--wf-editor-touch-target` calc(space-lg + space-lg + space-xs) formula + (SettingsModal.css / WorkflowNodeEditor.css) so tap targets stay comfortable + without hardcoding a raw pixel value. + */ + --detail-oversight-menu-item-min-height: calc(var(--space-lg) + var(--space-lg) + var(--space-xs)); + + display: flex; + align-items: center; + gap: var(--space-xs); + width: 100%; + min-height: var(--detail-oversight-menu-item-min-height); + padding: var(--space-sm) var(--space-md); + font-size: 13px; + font-weight: 400; + color: var(--text); + background: transparent; + border: none; + cursor: pointer; + text-align: left; + box-sizing: border-box; + transition: background var(--transition-fast); +} + +label.detail-oversight-menu-item--select { + cursor: default; + justify-content: space-between; +} + +.detail-oversight-menu-item--select .detail-oversight-select { + flex: 1; + min-width: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} + +.detail-oversight-menu-item:hover { + background: var(--surface-hover, color-mix(in srgb, var(--text) 6%, transparent)); +} + +button.detail-oversight-menu-item:focus { + outline: none; + background: var(--surface-hover, color-mix(in srgb, var(--text) 6%, transparent)); +} + +button.detail-oversight-menu-item:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +button.detail-oversight-menu-item svg { + width: 1em; + height: 1em; +} + /* FNXC:PlannerOversight 2026-07-04-18:00: FN-7519 Intervention Timeline styles. Attaches to the closest existing seam diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index c9ef750673..b5d2c2388d 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -2,7 +2,7 @@ import "./TaskDetailModal.css"; import React, { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles, Maximize2, Minimize2, Send, Square, Info } from "lucide-react"; +import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles, Maximize2, Minimize2, Send, Square, Info, MoreVertical } from "lucide-react"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; @@ -84,6 +84,8 @@ const ACTIVITY_VIEW_MENU_MIN_WIDTH = 160; const ACTIVITY_VIEW_MENU_MIN_HEIGHT = 120; const ACTIVITY_VIEW_MENU_MAX_HEIGHT = 320; const ACTIVITY_VIEW_MENU_OPEN_VIEWPORT_GUARD_MS = 350; +// FNXC:PlannerOversight 2026-07-04-19:00: FN-7545 — mobile breakpoint for collapsing the oversight action cluster into an overflow menu; matches the `@media (max-width: 768px)` breakpoint used across TaskDetailModal.css. +const OVERSIGHT_MENU_MOBILE_BREAKPOINT = 768; type ActivityViewMenuPosition = { top: number; @@ -1044,6 +1046,20 @@ export function TaskDetailContent({ const [overseerExplainOpen, setOverseerExplainOpen] = useState(false); const [isLoadingOverseerExplain, setIsLoadingOverseerExplain] = useState(false); const [overseerExplainSnapshot, setOverseerExplainSnapshot] = useState(null); + /* + FNXC:PlannerOversight 2026-07-04-19:00: + FN-7545 — collapse the oversight action controls into a mobile overflow + menu so the detail control bar fits narrow viewports; desktop keeps the + inline cluster; menu never renders an empty shell when oversight is + off/inactive. `isOversightMenuMobile` mirrors the `DocumentsView` local + `isMobile` resize-listener pattern (defaults false so JSDOM/unit tests keep + exercising the desktop inline branch unless a test explicitly narrows the + viewport). + */ + const [isOversightMenuMobile, setIsOversightMenuMobile] = useState(false); + const [showOversightMenu, setShowOversightMenu] = useState(false); + const oversightMenuRef = useRef(null); + const oversightMenuButtonRef = useRef(null); const { confirm, confirmWithChoice, confirmWithCheckbox } = useConfirm(); const requestClose = useCallback(() => { onRequestClose?.(); @@ -1410,7 +1426,7 @@ export function TaskDetailContent({ // Close task-detail dropdown menus on outside click useEffect(() => { - const hasOpenMenu = showMoveMenu || showActionsMenu || showActivityViewMenu; + const hasOpenMenu = showMoveMenu || showActionsMenu || showActivityViewMenu || showOversightMenu; if (!hasOpenMenu) return; const handleClick = (e: MouseEvent) => { @@ -1418,6 +1434,7 @@ export function TaskDetailContent({ const inMoveMenu = moveMenuRef.current?.contains(target); const inActionsMenu = actionsMenuRef.current?.contains(target); const inActivityViewMenu = activityViewMenuRef.current?.contains(target) || activityViewButtonRef.current?.contains(target); + const inOversightMenu = oversightMenuRef.current?.contains(target) || oversightMenuButtonRef.current?.contains(target); if (!inMoveMenu && showMoveMenu) { setShowMoveMenu(false); @@ -1430,15 +1447,18 @@ export function TaskDetailContent({ setShowActivityViewMenu(false); setActivityViewMenuPosition(null); } + if (!inOversightMenu && showOversightMenu) { + setShowOversightMenu(false); + } }; document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); - }, [showMoveMenu, showActionsMenu, showActivityViewMenu]); + }, [showMoveMenu, showActionsMenu, showActivityViewMenu, showOversightMenu]); // Close task-detail dropdown menus on Escape key (before modal Escape handler) useEffect(() => { - const hasOpenMenu = showMoveMenu || showActionsMenu || showActivityViewMenu; + const hasOpenMenu = showMoveMenu || showActionsMenu || showActivityViewMenu || showOversightMenu; if (!hasOpenMenu) return; const handleKeyDown = (e: KeyboardEvent) => { @@ -1451,12 +1471,35 @@ export function TaskDetailContent({ setShowActivityViewMenu(false); setActivityViewMenuPosition(null); } + if (showOversightMenu) { + setShowOversightMenu(false); + } } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [showMoveMenu, showActionsMenu, showActivityViewMenu]); + }, [showMoveMenu, showActionsMenu, showActivityViewMenu, showOversightMenu]); + + // FNXC:PlannerOversight 2026-07-04-19:00: FN-7545 — track the mobile breakpoint locally (mirrors DocumentsView's isMobile resize-listener pattern) so the oversight action cluster can collapse into an overflow menu on narrow viewports while desktop keeps the inline layout. + useEffect(() => { + const updateOversightMenuMobile = () => { + setIsOversightMenuMobile(window.innerWidth <= OVERSIGHT_MENU_MOBILE_BREAKPOINT); + }; + + updateOversightMenuMobile(); + window.addEventListener("resize", updateOversightMenuMobile); + + return () => { + window.removeEventListener("resize", updateOversightMenuMobile); + }; + }, []); + + useEffect(() => { + if (!isOversightMenuMobile) { + setShowOversightMenu(false); + } + }, [isOversightMenuMobile]); // Reset spec edit state when task changes useEffect(() => { @@ -3155,6 +3198,44 @@ export function TaskDetailContent({ closeMoveMenuAndFocusTrigger(); }, [closeMoveMenuAndFocusTrigger]); + /* + FNXC:PlannerOversight 2026-07-04-19:00: + FN-7545 — mobile oversight overflow-menu open/close/keyboard handling, + mirroring `handleMoveButtonClick`/`handleMoveButtonKeyDown`/`handleMoveMenuKeyDown` + above so the two popovers behave consistently (toggle on click, ArrowDown + opens, Escape closes and returns focus to the trigger). + */ + const closeOversightMenuAndFocusTrigger = useCallback(() => { + setShowOversightMenu(false); + oversightMenuButtonRef.current?.focus(); + }, []); + + const handleOversightMenuButtonClick = useCallback(() => { + setShowOversightMenu((prev) => !prev); + setShowMoveMenu(false); + setShowActionsMenu(false); + }, []); + + const handleOversightMenuButtonKeyDown = useCallback((event: React.KeyboardEvent) => { + const shouldOpenMenu = event.key === "ArrowDown" || (event.altKey && event.key === "ArrowDown"); + if (!shouldOpenMenu) { + return; + } + + event.preventDefault(); + setShowOversightMenu(true); + }, []); + + const handleOversightMenuKeyDown = useCallback((event: React.KeyboardEvent) => { + if (event.key !== "Escape") { + return; + } + + event.preventDefault(); + event.stopPropagation(); + closeOversightMenuAndFocusTrigger(); + }, [closeOversightMenuAndFocusTrigger]); + const closeActivityViewMenuAndFocusTrigger = useCallback(() => { activityViewMenuViewportGuardUntilRef.current = 0; setShowActivityViewMenu(false); @@ -3261,6 +3342,15 @@ export function TaskDetailContent({ firstMenuItem?.focus(); }, [showMoveMenu]); + useEffect(() => { + if (!showOversightMenu) { + return; + } + + const firstMenuItem = oversightMenuRef.current?.querySelector(".detail-oversight-menu-item"); + firstMenuItem?.focus(); + }, [showOversightMenu]); + useLayoutEffect(() => { if (!showActivityViewMenu) { setActivityViewMenuPosition(null); @@ -3730,90 +3820,207 @@ export function TaskDetailContent({ workflow tier resolves (or a per-task override renders it synchronously), mirroring FN-7516's TaskCard badge gating so this never shows a guessed schema-default value for a beat. + + FNXC:PlannerOversight 2026-07-04-19:00: + FN-7545 — collapse the oversight action controls into a mobile + overflow menu so the detail control bar fits narrow viewports; + desktop keeps the inline cluster; menu never renders an empty + shell when oversight is off/inactive. Both branches below share + the SAME enablement gates (`hasTaskOversightOverride`, + `workflowOversightResolved`, `oversightIsOff`, `showStopOverseer`, + `canNudgeOverseer`, `canExplainOverseer`) and the SAME handlers + — the mobile branch only changes where the controls render, never + their guard logic. */} - {(hasTaskOversightOverride || workflowOversightResolved) && ( - - )} - {/* - 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. - */} - {(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff && ( - - )} - {(hasTaskOversightOverride || workflowOversightResolved) && showStopOverseer && ( - - )} - {(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff && ( - + {isOversightMenuMobile ? ( + (hasTaskOversightOverride || workflowOversightResolved) && ( +
+ + {showOversightMenu && ( +
+ + {!oversightIsOff && ( + + )} + {showStopOverseer && ( + + )} + {!oversightIsOff && ( + + )} +
+ )} +
+ ) + ) : ( + <> + {(hasTaskOversightOverride || workflowOversightResolved) && ( + + )} + {/* + 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. + */} + {(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff && ( + + )} + {(hasTaskOversightOverride || workflowOversightResolved) && showStopOverseer && ( + + )} + {(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff && ( + + )} + )} {overseerExplainOpen && ( diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-mobile.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-mobile.test.tsx new file mode 100644 index 0000000000..8b2c8d36b9 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-mobile.test.tsx @@ -0,0 +1,413 @@ +/* +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. +*/ +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 { + makeTask, + noop, + noopDelete, + noopMerge, + noopMove, + noopOpenDetail, + mockConfirm, + setupTaskDetailModalHooks, +} from "./TaskDetailModal.test-helpers"; +import { TaskDetailModal } from "../TaskDetailModal"; + +setupTaskDetailModalHooks(); + +const MOBILE_WIDTH = 375; +const DESKTOP_WIDTH = 1024; + +function setViewportWidth(width: number): void { + Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: width }); + window.dispatchEvent(new Event("resize")); +} + +const activeSnapshot: PlannerOverseerRuntimeSnapshot = { + state: "watching", + oversightLevel: "autonomous", + watchedStage: "executor", + signal: "progressing", + attemptCount: 1, + attemptLimit: 3, + pendingConfirmation: false, + observedAt: 1_700_000_000_000, + reason: "Task is actively executing in-progress work", + lastAction: "inject_guidance", +}; + +describe("TaskDetailModal oversight controls — mobile overflow menu", () => { + beforeEach(async () => { + vi.clearAllMocks(); + mockConfirm.mockResolvedValue(true); + setViewportWidth(MOBILE_WIDTH); + const api = await import("../../api"); + vi.mocked(api.fetchBoardWorkflows).mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }); + vi.mocked(api.fetchWorkflowSettingValues).mockResolvedValue({ stored: {}, effective: {}, defaults: {} }); + 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 }); + }); + + afterEach(() => { + setViewportWidth(DESKTOP_WIDTH); + }); + + it("renders a single overflow trigger (no inline action buttons) when oversight actions are available", async () => { + render( + , + ); + + const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); + expect(trigger).toHaveAttribute("aria-haspopup", "menu"); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + // Actions are not directly in the DOM until the menu opens. + expect(screen.queryByTestId("detail-overseer-nudge")).not.toBeInTheDocument(); + expect(screen.queryByTestId("detail-overseer-stop")).not.toBeInTheDocument(); + expect(screen.queryByTestId("detail-overseer-explain")).not.toBeInTheDocument(); + }); + + it("the overflow menu shows only the level select (no leftover action shells) when oversight is explicitly off and the overseer is inactive", async () => { + render( + , + ); + + // The trigger still appears (an explicit per-task override exists so the + // level control must stay reachable to opt back IN to oversight), but + // opening it must show ONLY the level select — no nudge/stop/explain + // leftover shells, mirroring the desktop suite's equivalent assertion. + const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); + fireEvent.click(trigger); + + await screen.findByTestId("detail-oversight-level-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(); + }); + + it("renders NO overflow-menu trigger when there is no per-task override and the workflow tier has not resolved a level yet", async () => { + const api = await import("../../api"); + // A workflow badge id forces the async workflow-oversight-effective-level + // lookup path (see `workflowIdForOversight` in TaskDetailModal.tsx) instead + // of the synchronous `!workflowIdForOversight` fast-resolve, so + // `workflowOversightResolved` stays false until the fetch below settles. + vi.mocked(api.fetchBoardWorkflows).mockResolvedValue({ + flagEnabled: true, + defaultWorkflowId: "WF-mobile-test", + workflows: [{ id: "WF-mobile-test", name: "Mobile Test Workflow" } as any], + taskWorkflowIds: { "FN-212": "WF-mobile-test" }, + }); + vi.mocked(api.fetchWorkflowSettingValues).mockImplementation(() => new Promise(() => {})); + + render( + , + ); + + // Give pending microtasks/effects a chance to flush; the trigger must not + // appear while the workflow tier is still unresolved and no override exists. + await waitFor(() => { + expect(screen.queryByTestId("detail-oversight-menu-trigger")).not.toBeInTheDocument(); + }); + }); + + it("opening the menu exposes the level select and honors nudge/stop/explain enablement rules", async () => { + render( + , + ); + + const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + + const menu = screen.getByRole("menu"); + expect(menu).toBeInTheDocument(); + + const select = await screen.findByTestId("detail-oversight-level-select"); + expect((select as HTMLSelectElement).value).toBe("autonomous"); + + const nudgeBtn = screen.getByTestId("detail-overseer-nudge"); + expect(nudgeBtn).not.toBeDisabled(); + const stopBtn = screen.getByTestId("detail-overseer-stop"); + expect(stopBtn).toBeInTheDocument(); + const explainBtn = screen.getByTestId("detail-overseer-explain"); + expect(explainBtn).not.toBeDisabled(); + }); + + it("nudge is disabled inside the menu when the overseer has no active observation", async () => { + render( + , + ); + + const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); + fireEvent.click(trigger); + + const nudgeBtn = await screen.findByTestId("detail-overseer-nudge"); + expect(nudgeBtn).toBeDisabled(); + }); + + it("stop is absent from the menu when oversight is already off", async () => { + render( + , + ); + + const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); + fireEvent.click(trigger); + + await screen.findByTestId("detail-oversight-level-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(); + }); + + it("selecting a level from the collapsed select writes the override via handleOversightLevelChange", async () => { + const api = await import("../../api"); + const mockUpdate = vi.mocked(api.updateTask); + mockUpdate.mockResolvedValueOnce(makeTask({ id: "FN-205", plannerOversightLevel: "steer" }) as any); + + render( + , + ); + + const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); + fireEvent.click(trigger); + + const select = await screen.findByTestId("detail-oversight-level-select"); + fireEvent.change(select, { target: { value: "steer" } }); + + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalledWith("FN-205", { plannerOversightLevel: "steer" }, undefined); + }); + }); + + it("nudge from the menu calls nudgeOverseer and closes the menu", async () => { + const api = await import("../../api"); + vi.mocked(api.nudgeOverseer).mockResolvedValueOnce({ applied: true, reason: "nudged", task: makeTask({ id: "FN-206" }) as any }); + + render( + , + ); + + const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); + fireEvent.click(trigger); + + const nudgeBtn = await screen.findByTestId("detail-overseer-nudge"); + fireEvent.click(nudgeBtn); + + await waitFor(() => { + expect(api.nudgeOverseer).toHaveBeenCalledWith("FN-206", undefined); + }); + await waitFor(() => { + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + }); + + it("explain from the menu opens the explain panel and renders the active snapshot", async () => { + const api = await import("../../api"); + vi.mocked(api.explainOverseer).mockResolvedValueOnce({ snapshot: activeSnapshot }); + + render( + , + ); + + const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); + fireEvent.click(trigger); + + const explainBtn = await screen.findByTestId("detail-overseer-explain"); + fireEvent.click(explainBtn); + + const panel = await screen.findByTestId("detail-overseer-explain-panel"); + expect(panel).toHaveTextContent("executor"); + expect(panel).toHaveTextContent("Task is actively executing in-progress work"); + }); + + it("explain from the menu shows the inactive empty-state when the overseer is inactive", async () => { + const api = await import("../../api"); + vi.mocked(api.explainOverseer).mockResolvedValueOnce({ snapshot: null }); + + render( + , + ); + + const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); + fireEvent.click(trigger); + + const explainBtn = await screen.findByTestId("detail-overseer-explain"); + fireEvent.click(explainBtn); + + const panel = await screen.findByTestId("detail-overseer-explain-panel"); + expect(panel).toHaveTextContent("not currently watching"); + }); + + it("stop from the menu calls stopOverseer after confirmation", async () => { + const api = await import("../../api"); + vi.mocked(api.stopOverseer).mockResolvedValueOnce({ applied: true, reason: "stopped", task: makeTask({ id: "FN-209", plannerOversightLevel: "off" }) as any }); + + render( + , + ); + + const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); + fireEvent.click(trigger); + + const stopBtn = await screen.findByTestId("detail-overseer-stop"); + fireEvent.click(stopBtn); + + await waitFor(() => { + expect(mockConfirm).toHaveBeenCalled(); + expect(api.stopOverseer).toHaveBeenCalledWith("FN-209", undefined); + }); + }); + + it("Escape closes the menu and returns focus to the trigger", async () => { + render( + , + ); + + const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); + fireEvent.click(trigger); + expect(screen.getByRole("menu")).toBeInTheDocument(); + + fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" }); + + await waitFor(() => { + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + + it("click-outside closes the menu", async () => { + render( + <> + +
+ , + ); + + const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); + fireEvent.click(trigger); + expect(screen.getByRole("menu")).toBeInTheDocument(); + + fireEvent.mouseDown(screen.getByTestId("outside-target")); + + await waitFor(() => { + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts index 60be2dc0a5..689383d5b4 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts @@ -128,6 +128,8 @@ vi.mock("lucide-react", () => ({ Code2: () => null, Cpu: () => null, Bell: () => null, + // FNXC:PlannerOversight 2026-07-04-19:00: FN-7545 mobile oversight overflow-menu trigger icon. + MoreVertical: (props: any) => React.createElement("svg", { "data-testid": "more-vertical-icon", ...props }), })); vi.mock("../../hooks/useAgentLogs", () => ({