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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 19:43:51 -07:00
parent 052a277725
commit 19a436ab5e
5 changed files with 830 additions and 88 deletions

View File

@@ -188,6 +188,8 @@ Features:
<!-- FNXC:PlannerOversight 2026-07-04-HH:MM: FN-7542 removed the FN-7516 active-overseer-state ("Executor") indicator described above as unwanted per-card noise — it fired on nearly every in-progress card. The oversight-level badge documented above is unaffected. -->
<!-- FNXC:PlannerOversight 2026-07-04-17:00: FN-7517 adds interactive task-detail planner-overseer controls (quick level change, manual nudge, stop oversight, explain current action) alongside the FN-7516 read-only card badges above. These controls live ONLY in TaskDetailModal, not TaskCard. -->
- 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.
<!-- 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.
<!-- 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.

View File

@@ -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

View File

@@ -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<PlannerOverseerRuntimeSnapshot | null>(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<HTMLDivElement>(null);
const oversightMenuButtonRef = useRef<HTMLButtonElement>(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<HTMLButtonElement>) => {
const shouldOpenMenu = event.key === "ArrowDown" || (event.altKey && event.key === "ArrowDown");
if (!shouldOpenMenu) {
return;
}
event.preventDefault();
setShowOversightMenu(true);
}, []);
const handleOversightMenuKeyDown = useCallback((event: React.KeyboardEvent<HTMLElement>) => {
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<HTMLButtonElement | HTMLSelectElement>(".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) && (
<label
className={`card-oversight-badge card-oversight-badge--${effectiveOversightLevel} detail-oversight-chip ${isSavingOversightLevel ? "detail-oversight-chip--saving" : ""}`}
>
<span>{t("taskDetail.oversight.label", "Oversight:")}</span>
<select
className="detail-oversight-select"
data-testid="detail-oversight-level-select"
value={hasTaskOversightOverride ? (task.plannerOversightLevel as string) : "__inherit__"}
onChange={(event) => {
void handleOversightLevelChange(event.target.value);
}}
disabled={isSavingOversightLevel}
aria-label={t("taskDetail.oversight.ariaLabel", "Planner oversight level")}
>
<option value="__inherit__">
{t("taskDetail.oversight.inherit", "Inherit ({{level}})", { level: OVERSIGHT_LEVEL_LABEL[effectiveOversightLevel] })}
</option>
{PLANNER_OVERSIGHT_LEVELS.map((levelOption) => (
<option key={levelOption} value={levelOption}>
{OVERSIGHT_LEVEL_LABEL[levelOption]}
</option>
))}
</select>
</label>
)}
{/*
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 && (
<button
type="button"
className={`btn btn-sm detail-overseer-nudge ${isNudgingOverseer ? "detail-overseer-nudge--saving" : ""}`}
data-testid="detail-overseer-nudge"
onClick={() => {
void handleNudgeOverseer();
}}
disabled={!canNudgeOverseer || isNudgingOverseer}
title={canNudgeOverseer ? t("taskDetail.oversight.nudgeTitle", "Inject steering guidance into the current stage now") : t("taskDetail.oversight.nudgeDisabledTitle", "Nudge unavailable: overseer is not actively watching this task")}
aria-label={t("taskDetail.oversight.nudgeAriaLabel", "Manual nudge")}
>
{isNudgingOverseer ? <Loader2 className="spin" aria-hidden="true" /> : <Send aria-hidden="true" />}
<span>{t("taskDetail.oversight.nudge", "Nudge")}</span>
</button>
)}
{(hasTaskOversightOverride || workflowOversightResolved) && showStopOverseer && (
<button
type="button"
className={`btn btn-sm detail-overseer-stop ${isStoppingOverseer ? "detail-overseer-stop--saving" : ""}`}
data-testid="detail-overseer-stop"
onClick={() => {
void handleStopOverseer();
}}
disabled={isStoppingOverseer}
aria-label={t("taskDetail.oversight.stopAriaLabel", "Stop oversight")}
>
{isStoppingOverseer ? <Loader2 className="spin" aria-hidden="true" /> : <Square aria-hidden="true" />}
<span>{t("taskDetail.oversight.stop", "Stop")}</span>
</button>
)}
{(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff && (
<button
type="button"
className="btn btn-sm detail-overseer-explain"
data-testid="detail-overseer-explain"
onClick={() => {
void handleExplainOverseer();
}}
disabled={!canExplainOverseer && !overseerExplainOpen}
title={canExplainOverseer ? t("taskDetail.oversight.explainTitle", "Explain the overseer's current action") : t("taskDetail.oversight.explainDisabledTitle", "Explain unavailable: overseer is not actively watching this task")}
aria-label={t("taskDetail.oversight.explainAriaLabel", "Explain current action")}
aria-expanded={overseerExplainOpen}
>
<Info aria-hidden="true" />
<span>{t("taskDetail.oversight.explain", "Explain")}</span>
</button>
{isOversightMenuMobile ? (
(hasTaskOversightOverride || workflowOversightResolved) && (
<div className="detail-oversight-menu-dropdown" ref={oversightMenuRef}>
<button
type="button"
ref={oversightMenuButtonRef}
className="btn btn-sm detail-oversight-menu-trigger"
data-testid="detail-oversight-menu-trigger"
onClick={handleOversightMenuButtonClick}
onKeyDown={handleOversightMenuButtonKeyDown}
aria-haspopup="menu"
aria-expanded={showOversightMenu}
aria-label={t("taskDetail.oversight.menuAriaLabel", "Oversight actions")}
>
<MoreVertical aria-hidden="true" />
<span>{t("taskDetail.oversight.menuLabel", "Oversight")}</span>
</button>
{showOversightMenu && (
<div className="detail-oversight-menu" role="menu" onKeyDown={handleOversightMenuKeyDown}>
<label className="detail-oversight-menu-item detail-oversight-menu-item--select">
<span>{t("taskDetail.oversight.label", "Oversight:")}</span>
<select
className="detail-oversight-select detail-oversight-menu-item"
data-testid="detail-oversight-level-select"
value={hasTaskOversightOverride ? (task.plannerOversightLevel as string) : "__inherit__"}
onChange={(event) => {
void handleOversightLevelChange(event.target.value);
}}
disabled={isSavingOversightLevel}
aria-label={t("taskDetail.oversight.ariaLabel", "Planner oversight level")}
>
<option value="__inherit__">
{t("taskDetail.oversight.inherit", "Inherit ({{level}})", { level: OVERSIGHT_LEVEL_LABEL[effectiveOversightLevel] })}
</option>
{PLANNER_OVERSIGHT_LEVELS.map((levelOption) => (
<option key={levelOption} value={levelOption}>
{OVERSIGHT_LEVEL_LABEL[levelOption]}
</option>
))}
</select>
</label>
{!oversightIsOff && (
<button
type="button"
className={`detail-oversight-menu-item detail-overseer-nudge ${isNudgingOverseer ? "detail-overseer-nudge--saving" : ""}`}
role="menuitem"
data-testid="detail-overseer-nudge"
onClick={() => {
void handleNudgeOverseer();
setShowOversightMenu(false);
}}
onKeyDown={handleOversightMenuKeyDown}
disabled={!canNudgeOverseer || isNudgingOverseer}
title={canNudgeOverseer ? t("taskDetail.oversight.nudgeTitle", "Inject steering guidance into the current stage now") : t("taskDetail.oversight.nudgeDisabledTitle", "Nudge unavailable: overseer is not actively watching this task")}
aria-label={t("taskDetail.oversight.nudgeAriaLabel", "Manual nudge")}
>
{isNudgingOverseer ? <Loader2 className="spin" aria-hidden="true" /> : <Send aria-hidden="true" />}
<span>{t("taskDetail.oversight.nudge", "Nudge")}</span>
</button>
)}
{showStopOverseer && (
<button
type="button"
className={`detail-oversight-menu-item detail-overseer-stop ${isStoppingOverseer ? "detail-overseer-stop--saving" : ""}`}
role="menuitem"
data-testid="detail-overseer-stop"
onClick={() => {
void handleStopOverseer();
setShowOversightMenu(false);
}}
onKeyDown={handleOversightMenuKeyDown}
disabled={isStoppingOverseer}
aria-label={t("taskDetail.oversight.stopAriaLabel", "Stop oversight")}
>
{isStoppingOverseer ? <Loader2 className="spin" aria-hidden="true" /> : <Square aria-hidden="true" />}
<span>{t("taskDetail.oversight.stop", "Stop")}</span>
</button>
)}
{!oversightIsOff && (
<button
type="button"
className="detail-oversight-menu-item detail-overseer-explain"
role="menuitem"
data-testid="detail-overseer-explain"
onClick={() => {
void handleExplainOverseer();
setShowOversightMenu(false);
}}
onKeyDown={handleOversightMenuKeyDown}
disabled={!canExplainOverseer && !overseerExplainOpen}
title={canExplainOverseer ? t("taskDetail.oversight.explainTitle", "Explain the overseer's current action") : t("taskDetail.oversight.explainDisabledTitle", "Explain unavailable: overseer is not actively watching this task")}
aria-label={t("taskDetail.oversight.explainAriaLabel", "Explain current action")}
aria-expanded={overseerExplainOpen}
>
<Info aria-hidden="true" />
<span>{t("taskDetail.oversight.explain", "Explain")}</span>
</button>
)}
</div>
)}
</div>
)
) : (
<>
{(hasTaskOversightOverride || workflowOversightResolved) && (
<label
className={`card-oversight-badge card-oversight-badge--${effectiveOversightLevel} detail-oversight-chip ${isSavingOversightLevel ? "detail-oversight-chip--saving" : ""}`}
>
<span>{t("taskDetail.oversight.label", "Oversight:")}</span>
<select
className="detail-oversight-select"
data-testid="detail-oversight-level-select"
value={hasTaskOversightOverride ? (task.plannerOversightLevel as string) : "__inherit__"}
onChange={(event) => {
void handleOversightLevelChange(event.target.value);
}}
disabled={isSavingOversightLevel}
aria-label={t("taskDetail.oversight.ariaLabel", "Planner oversight level")}
>
<option value="__inherit__">
{t("taskDetail.oversight.inherit", "Inherit ({{level}})", { level: OVERSIGHT_LEVEL_LABEL[effectiveOversightLevel] })}
</option>
{PLANNER_OVERSIGHT_LEVELS.map((levelOption) => (
<option key={levelOption} value={levelOption}>
{OVERSIGHT_LEVEL_LABEL[levelOption]}
</option>
))}
</select>
</label>
)}
{/*
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 && (
<button
type="button"
className={`btn btn-sm detail-overseer-nudge ${isNudgingOverseer ? "detail-overseer-nudge--saving" : ""}`}
data-testid="detail-overseer-nudge"
onClick={() => {
void handleNudgeOverseer();
}}
disabled={!canNudgeOverseer || isNudgingOverseer}
title={canNudgeOverseer ? t("taskDetail.oversight.nudgeTitle", "Inject steering guidance into the current stage now") : t("taskDetail.oversight.nudgeDisabledTitle", "Nudge unavailable: overseer is not actively watching this task")}
aria-label={t("taskDetail.oversight.nudgeAriaLabel", "Manual nudge")}
>
{isNudgingOverseer ? <Loader2 className="spin" aria-hidden="true" /> : <Send aria-hidden="true" />}
<span>{t("taskDetail.oversight.nudge", "Nudge")}</span>
</button>
)}
{(hasTaskOversightOverride || workflowOversightResolved) && showStopOverseer && (
<button
type="button"
className={`btn btn-sm detail-overseer-stop ${isStoppingOverseer ? "detail-overseer-stop--saving" : ""}`}
data-testid="detail-overseer-stop"
onClick={() => {
void handleStopOverseer();
}}
disabled={isStoppingOverseer}
aria-label={t("taskDetail.oversight.stopAriaLabel", "Stop oversight")}
>
{isStoppingOverseer ? <Loader2 className="spin" aria-hidden="true" /> : <Square aria-hidden="true" />}
<span>{t("taskDetail.oversight.stop", "Stop")}</span>
</button>
)}
{(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff && (
<button
type="button"
className="btn btn-sm detail-overseer-explain"
data-testid="detail-overseer-explain"
onClick={() => {
void handleExplainOverseer();
}}
disabled={!canExplainOverseer && !overseerExplainOpen}
title={canExplainOverseer ? t("taskDetail.oversight.explainTitle", "Explain the overseer's current action") : t("taskDetail.oversight.explainDisabledTitle", "Explain unavailable: overseer is not actively watching this task")}
aria-label={t("taskDetail.oversight.explainAriaLabel", "Explain current action")}
aria-expanded={overseerExplainOpen}
>
<Info aria-hidden="true" />
<span>{t("taskDetail.oversight.explain", "Explain")}</span>
</button>
)}
</>
)}
</div>
{overseerExplainOpen && (

View File

@@ -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(
<TaskDetailModal
task={makeTask({ id: "FN-200", 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");
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(
<TaskDetailModal
task={makeTask({ id: "FN-201", column: "todo", plannerOversightLevel: "off" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// 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(
<TaskDetailModal
task={makeTask({ id: "FN-212", column: "todo", plannerOversightLevel: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// 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(
<TaskDetailModal
task={makeTask({ id: "FN-202", 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);
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(
<TaskDetailModal
task={makeTask({ id: "FN-203", column: "todo", plannerOversightLevel: "autonomous" })}
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 nudgeBtn = await screen.findByTestId("detail-overseer-nudge");
expect(nudgeBtn).toBeDisabled();
});
it("stop is absent from the menu when oversight is already off", async () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-204", column: "in-progress", 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);
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(
<TaskDetailModal
task={makeTask({ id: "FN-205", column: "in-progress", plannerOversightLevel: "observe" })}
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");
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(
<TaskDetailModal
task={makeTask({ id: "FN-206", 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 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(
<TaskDetailModal
task={makeTask({ id: "FN-207", 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 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(
<TaskDetailModal
task={makeTask({ id: "FN-208", column: "in-progress", plannerOversightLevel: "observe", 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 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(
<TaskDetailModal
task={makeTask({ id: "FN-209", column: "in-progress", plannerOversightLevel: "steer" })}
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 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(
<TaskDetailModal
task={makeTask({ id: "FN-210", 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);
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(
<>
<TaskDetailModal
task={makeTask({ id: "FN-211", column: "in-progress", plannerOversightLevel: "autonomous", plannerOverseerState: activeSnapshot })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>
<div data-testid="outside-target" />
</>,
);
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();
});
});
});

View File

@@ -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", () => ({