FN-7546: clarify planner-oversight Nudge/Explain controls with visible label and in-DOM disabled reason
Add a visible group label and an in-DOM disabled-reason helper to the task-detail oversight controls so the Nudge/Explain cluster is no longer confusing when greyed out, and make Explain always-openable since it is read-only. - Add a `detail-oversight-controls-label` group label above the Nudge/Stop/Explain buttons, gated by the same visibility condition as the buttons (mobile and desktop clusters) - Add a `detail-overseer-nudge-disabled-reason` in-DOM helper line explaining why Nudge is unavailable, instead of relying only on a hover title - Remove the disabled gate from Explain (it's read-only/non-mutating) and update its title copy to reflect that it always opens and shows last-known state when inactive - Add regression tests covering the new label/helper text and Explain's always-enabled behavior - Add a patch changeset and a docs/dashboard-guide.md note Files changed: .changeset/FN-7546-oversight-controls-clarity.md | 7 +++ docs/dashboard-guide.md | 2 + .../dashboard/app/components/TaskDetailModal.css | 42 +++++++++++++ .../dashboard/app/components/TaskDetailModal.tsx | 41 +++++++++++-- .../TaskDetailModal.oversight-controls.test.tsx | 69 ++++++++++++++++++++++ 5 files changed, 157 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-7546 Fusion-Task-Lineage: d1f342ea-2de0-4b54-9930-9b3d540c7af6 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/FN-7546-oversight-controls-clarity.md
Normal file
7
.changeset/FN-7546-oversight-controls-clarity.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Clarify the task-detail planner-oversight Nudge/Explain controls with a visible label, an in-DOM disabled reason, and an always-openable Explain panel.
|
||||||
|
category: fix
|
||||||
|
dev: TaskDetailModal now renders a `detail-oversight-controls-label` group label and `detail-overseer-nudge-disabled-reason` helper text (both gated by the existing oversight-cluster visibility condition); Explain no longer disables on `!canExplainOverseer` since it is read-only. Nudge's `canNudgeOverseer` gate and Stop's confirm dialog are unchanged.
|
||||||
@@ -198,6 +198,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-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. -->
|
<!-- 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.
|
- 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-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. -->
|
<!-- 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.
|
||||||
<!-- FNXC:PlannerOversight 2026-07-04-18:00: FN-7519 adds a read-only Intervention Timeline immediately below the FN-7517 controls above. -->
|
<!-- FNXC:PlannerOversight 2026-07-04-18:00: FN-7519 adds a read-only Intervention Timeline immediately below the FN-7517 controls above. -->
|
||||||
|
|||||||
@@ -528,6 +528,35 @@ renders at a uniform height.
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlannerOversight 2026-07-04-20:30:
|
||||||
|
FN-7546 — group label + always-visible disabled-reason helper for the
|
||||||
|
Nudge/Stop/Explain cluster, so operators no longer rely on a mouse-hover
|
||||||
|
`title` alone to understand what the controls are or why Nudge is inert.
|
||||||
|
Both are gated by the same condition as the buttons themselves (never an
|
||||||
|
empty shell when oversight is Off/unresolved). Uses the same muted-text /
|
||||||
|
small-caption convention as `.detail-overseer-explain-panel__empty` rather
|
||||||
|
than a one-off style.
|
||||||
|
*/
|
||||||
|
.detail-oversight-controls-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: var(--detail-priority-control-min-height);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-oversight-controls-helper {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-basis: 100%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
}
|
||||||
|
|
||||||
.detail-overseer-explain-panel {
|
.detail-overseer-explain-panel {
|
||||||
margin-top: var(--space-xs);
|
margin-top: var(--space-xs);
|
||||||
padding: var(--space-sm);
|
padding: var(--space-sm);
|
||||||
@@ -669,6 +698,19 @@ button.detail-oversight-menu-item svg {
|
|||||||
height: 1em;
|
height: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlannerOversight 2026-07-04-20:30:
|
||||||
|
FN-7546 — the same group label / disabled-reason helper as the desktop
|
||||||
|
cluster above, adapted to the mobile overflow-menu's padded list-item layout
|
||||||
|
so they read as ordinary (non-interactive) menu rows rather than floating
|
||||||
|
text.
|
||||||
|
*/
|
||||||
|
.detail-oversight-menu .detail-oversight-controls-label,
|
||||||
|
.detail-oversight-menu .detail-oversight-controls-helper {
|
||||||
|
padding: var(--space-xs) var(--space-md);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:PlannerOversight 2026-07-04-18:00:
|
FNXC:PlannerOversight 2026-07-04-18:00:
|
||||||
FN-7519 Intervention Timeline styles. Attaches to the closest existing seam
|
FN-7519 Intervention Timeline styles. Attaches to the closest existing seam
|
||||||
|
|||||||
@@ -3873,6 +3873,11 @@ export function TaskDetailContent({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
{!oversightIsOff && (
|
||||||
|
<span className="detail-oversight-controls-label" data-testid="detail-oversight-controls-label">
|
||||||
|
{t("taskDetail.oversight.controlsLabel", "Overseer controls")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{!oversightIsOff && (
|
{!oversightIsOff && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3892,6 +3897,11 @@ export function TaskDetailContent({
|
|||||||
<span>{t("taskDetail.oversight.nudge", "Nudge")}</span>
|
<span>{t("taskDetail.oversight.nudge", "Nudge")}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!oversightIsOff && !canNudgeOverseer && (
|
||||||
|
<span className="detail-oversight-controls-helper" data-testid="detail-overseer-nudge-disabled-reason">
|
||||||
|
{t("taskDetail.oversight.nudgeDisabledTitle", "Nudge unavailable: overseer is not actively watching this task")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{showStopOverseer && (
|
{showStopOverseer && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3921,8 +3931,7 @@ export function TaskDetailContent({
|
|||||||
setShowOversightMenu(false);
|
setShowOversightMenu(false);
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleOversightMenuKeyDown}
|
onKeyDown={handleOversightMenuKeyDown}
|
||||||
disabled={!canExplainOverseer && !overseerExplainOpen}
|
title={canExplainOverseer ? t("taskDetail.oversight.explainTitle", "Explain the overseer's current action") : t("taskDetail.oversight.explainInactiveTitle", "Overseer is not currently watching this task — Explain shows its last known state")}
|
||||||
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-label={t("taskDetail.oversight.explainAriaLabel", "Explain current action")}
|
||||||
aria-expanded={overseerExplainOpen}
|
aria-expanded={overseerExplainOpen}
|
||||||
>
|
>
|
||||||
@@ -3971,7 +3980,27 @@ export function TaskDetailContent({
|
|||||||
silently vanishing; stop is hidden once oversight is already off
|
silently vanishing; stop is hidden once oversight is already off
|
||||||
(nothing left to stop) per the PROMPT's enablement rule, avoiding
|
(nothing left to stop) per the PROMPT's enablement rule, avoiding
|
||||||
an always-on empty shell for the common oversight-off default.
|
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 && (
|
||||||
|
<span className="detail-oversight-controls-label" data-testid="detail-oversight-controls-label">
|
||||||
|
{t("taskDetail.oversight.controlsLabel", "Overseer controls")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff && (
|
{(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3988,6 +4017,11 @@ export function TaskDetailContent({
|
|||||||
<span>{t("taskDetail.oversight.nudge", "Nudge")}</span>
|
<span>{t("taskDetail.oversight.nudge", "Nudge")}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff && !canNudgeOverseer && (
|
||||||
|
<span className="detail-oversight-controls-helper" data-testid="detail-overseer-nudge-disabled-reason">
|
||||||
|
{t("taskDetail.oversight.nudgeDisabledTitle", "Nudge unavailable: overseer is not actively watching this task")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{(hasTaskOversightOverride || workflowOversightResolved) && showStopOverseer && (
|
{(hasTaskOversightOverride || workflowOversightResolved) && showStopOverseer && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -4011,8 +4045,7 @@ export function TaskDetailContent({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleExplainOverseer();
|
void handleExplainOverseer();
|
||||||
}}
|
}}
|
||||||
disabled={!canExplainOverseer && !overseerExplainOpen}
|
title={canExplainOverseer ? t("taskDetail.oversight.explainTitle", "Explain the overseer's current action") : t("taskDetail.oversight.explainInactiveTitle", "Overseer is not currently watching this task — Explain shows its last known state")}
|
||||||
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-label={t("taskDetail.oversight.explainAriaLabel", "Explain current action")}
|
||||||
aria-expanded={overseerExplainOpen}
|
aria-expanded={overseerExplainOpen}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -140,6 +140,47 @@ describe("TaskDetailModal oversight controls", () => {
|
|||||||
expect(nudgeBtn).toBeDisabled();
|
expect(nudgeBtn).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows a visible group label and an in-DOM disabled-reason helper (not just a hover title) when Nudge is unavailable (FN-7546)", async () => {
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ id: "FN-111", column: "todo", plannerOversightLevel: "autonomous" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = await screen.findByTestId("detail-oversight-controls-label");
|
||||||
|
expect(label).toHaveTextContent("Overseer controls");
|
||||||
|
|
||||||
|
const nudgeBtn = await screen.findByTestId("detail-overseer-nudge");
|
||||||
|
expect(nudgeBtn).toBeDisabled();
|
||||||
|
|
||||||
|
const reason = await screen.findByTestId("detail-overseer-nudge-disabled-reason");
|
||||||
|
expect(reason).toHaveTextContent("Nudge unavailable: overseer is not actively watching this task");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show the disabled-reason helper when Nudge is enabled", async () => {
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ id: "FN-112", column: "in-progress", plannerOversightLevel: "autonomous", plannerOverseerState: activeSnapshot })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const nudgeBtn = await screen.findByTestId("detail-overseer-nudge");
|
||||||
|
expect(nudgeBtn).not.toBeDisabled();
|
||||||
|
expect(screen.queryByTestId("detail-overseer-nudge-disabled-reason")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("nudge is disabled while the task is user-paused", async () => {
|
it("nudge is disabled while the task is user-paused", async () => {
|
||||||
render(
|
render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
@@ -266,6 +307,34 @@ describe("TaskDetailModal oversight controls", () => {
|
|||||||
expect(panel).toHaveTextContent("not currently watching");
|
expect(panel).toHaveTextContent("not currently watching");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Explain is never disabled while the overseer is inactive and always opens the read-only panel (FN-7546)", async () => {
|
||||||
|
const api = await import("../../api");
|
||||||
|
vi.mocked(api.explainOverseer).mockResolvedValueOnce({ snapshot: null });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ id: "FN-113", column: "todo", plannerOversightLevel: "autonomous" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
// panel's empty-state message communicates.
|
||||||
|
expect(explainBtn).not.toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.click(explainBtn);
|
||||||
|
|
||||||
|
const panel = await screen.findByTestId("detail-overseer-explain-panel");
|
||||||
|
expect(panel).toHaveTextContent("not currently watching");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders no oversight-control leftover shell when oversight is off and the overseer is inactive (default case)", async () => {
|
it("renders no oversight-control leftover shell when oversight is off and the overseer is inactive (default case)", async () => {
|
||||||
render(
|
render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
|
|||||||
Reference in New Issue
Block a user