FN-7582: clarify oversight nudge-disabled guideline copy

Reword the disabled-Nudge tooltip/helper text so it no longer reads as an overseer fault, and split it into two distinct reasons.
- Add taskDetail.oversight.nudgeSuppressedTitle for the human-control-suppressed case (user-paused, done/archived, autoMerge:false human-review terminal), naming manual control as the cause
- Reword taskDetail.oversight.nudgeDisabledTitle to a reassuring periodic-poll framing for the no-observation-yet case instead of implying the overseer is idle
- Compute the shared nudgeDisabledReason once and reuse it at all four render sites (mobile menu + desktop inline title/helper) so the two copies can't drift
- Add a changeset (patch) documenting the operator-facing copy fix
- Extend TaskDetailModal.oversight-controls tests and test-helpers to cover the new suppressed-vs-disabled copy branching

Files changed:
 .changeset/fn-7582-oversight-guideline-copy.md     |  7 ++
 .../dashboard/app/components/TaskDetailModal.tsx   | 37 +++++++++--
 .../TaskDetailModal.oversight-controls.test.tsx    | 76 +++++++++++++++++++++-
 .../__tests__/TaskDetailModal.test-helpers.ts      |  7 ++
 4 files changed, 121 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-7582

Fusion-Task-Lineage: bf2ceab4-2fb0-4686-a9e1-9e015502a521

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 11:24:43 -07:00
parent 53fe0d71b9
commit a1a6b09a4f
4 changed files with 121 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Clarify the oversight "Nudge unavailable" guideline so it no longer reads as an overseer fault.
category: fix
dev: TaskDetailModal oversight controls — reworded taskDetail.oversight.nudgeDisabledTitle and added taskDetail.oversight.nudgeSuppressedTitle to differentiate periodic-observation vs. manual-control states. No enablement/engine logic changed.

View File

@@ -3163,6 +3163,23 @@ export function TaskDetailContent({
already uses — the server-side `evaluateOverseerHumanControl` guard is the
real enforcement; this is a client-side disable heuristic only). Stop is
hidden once oversight is already off — there is nothing left to stop.
FNXC:PlannerOversight 2026-07-05-00:00:
FN-7582: the original disabled-Nudge copy ("overseer is not actively
watching this task") read as a fault report — operators seeing it on a
healthy IN PROGRESS task assumed the overseer had broken, when the real
cause is benign: `pollPlannerOverseer` observes in-progress/in-review tasks
on a bounded ~45s poll, and `plannerOverseerState` is only populated once
that poll records a live observation for the current stage (FN-7531). The
reworded copy below differentiates two distinct disabled reasons instead of
one alarming message: (1) no observation yet — reassuring, periodic-poll
framing (`nudgeDisabledTitle`); (2) human-control suppressed — user-paused,
done/archived, or the `autoMerge:false` in-review human-review terminal —
which gets its own distinct copy (`nudgeSuppressedTitle`) naming manual
control as the reason instead of implying the overseer is idle. Neither
`canNudgeOverseer` nor any other enablement/gating boolean changed; this is
copy-only, selected via the already-computed `overseerHumanControlSuppressed`
/ `overseerActive` booleans below.
*/
const overseerSnapshot = task.plannerOverseerState ?? null;
const overseerActive = Boolean(overseerSnapshot);
@@ -3173,6 +3190,18 @@ export function TaskDetailContent({
const canNudgeOverseer = overseerActive && !oversightIsOff && !overseerHumanControlSuppressed;
const canExplainOverseer = overseerActive && !oversightIsOff;
const showStopOverseer = !oversightIsOff;
/*
FNXC:PlannerOversight 2026-07-05-00:00:
FN-7582 shared disabled-reason string, computed once and reused at all four
render sites (mobile menu title + helper, desktop inline title + helper) so
the two copies can never drift out of sync. Picks the human-control-suppressed
copy when suppression is the active cause even though `!overseerActive` may
also be true in that state (e.g. a paused task that never got observed) —
suppression is the more actionable/accurate explanation for the operator.
*/
const nudgeDisabledReason = overseerHumanControlSuppressed
? t("taskDetail.oversight.nudgeSuppressedTitle", "Nudge is paused while this task is under manual control.")
: t("taskDetail.oversight.nudgeDisabledTitle", "Nudge becomes available once the overseer is observing this task's current stage — it checks periodically.");
const isActivityExpanded = activityExpanded && activeTab === "chat" && !isEditing;
const isPlannerChatExpanded = plannerChatExpanded && activeTab === "planner-chat" && !isEditing;
/*
@@ -4006,7 +4035,7 @@ export function TaskDetailContent({
}}
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")}
title={canNudgeOverseer ? t("taskDetail.oversight.nudgeTitle", "Inject steering guidance into the current stage now") : nudgeDisabledReason}
aria-label={t("taskDetail.oversight.nudgeAriaLabel", "Manual nudge")}
>
{isNudgingOverseer ? <Loader2 className="spin" aria-hidden="true" /> : <Send aria-hidden="true" />}
@@ -4015,7 +4044,7 @@ export function TaskDetailContent({
)}
{!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")}
{nudgeDisabledReason}
</span>
)}
{showStopOverseer && (
@@ -4126,7 +4155,7 @@ export function TaskDetailContent({
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")}
title={canNudgeOverseer ? t("taskDetail.oversight.nudgeTitle", "Inject steering guidance into the current stage now") : nudgeDisabledReason}
aria-label={t("taskDetail.oversight.nudgeAriaLabel", "Manual nudge")}
>
{isNudgingOverseer ? <Loader2 className="spin" aria-hidden="true" /> : <Send aria-hidden="true" />}
@@ -4135,7 +4164,7 @@ export function TaskDetailContent({
)}
{(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")}
{nudgeDisabledReason}
</span>
)}
{(hasTaskOversightOverride || workflowOversightResolved) && showStopOverseer && (

View File

@@ -140,7 +140,7 @@ describe("TaskDetailModal oversight controls", () => {
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 () => {
it("shows a visible group label and an in-DOM disabled-reason helper (not just a hover title) when Nudge is unavailable (FN-7546, reworded copy FN-7582)", async () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-111", column: "todo", plannerOversightLevel: "autonomous" })}
@@ -159,8 +159,57 @@ describe("TaskDetailModal oversight controls", () => {
const nudgeBtn = await screen.findByTestId("detail-overseer-nudge");
expect(nudgeBtn).toBeDisabled();
// FN-7582: the old copy ("Nudge unavailable: overseer is not actively
// watching this task") read as a fault report. The reworded copy must
// frame the no-observation state as periodic/benign instead.
const reason = await screen.findByTestId("detail-overseer-nudge-disabled-reason");
expect(reason).toHaveTextContent("Nudge unavailable: overseer is not actively watching this task");
expect(reason).not.toHaveTextContent("not actively watching this task");
expect(reason).toHaveTextContent("Nudge becomes available once the overseer is observing this task's current stage");
expect(nudgeBtn).toHaveAttribute("title", expect.stringContaining("Nudge becomes available once the overseer is observing this task's current stage"));
});
it("desktop: shows the periodic-observation copy (not the old alarming phrase) for an in-progress task with no plannerOverseerState (FN-7582)", async () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-116", column: "in-progress", plannerOversightLevel: "autonomous" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const nudgeBtn = await screen.findByTestId("detail-overseer-nudge");
expect(nudgeBtn).toBeDisabled();
const reason = await screen.findByTestId("detail-overseer-nudge-disabled-reason");
expect(reason).not.toHaveTextContent("not actively watching this task");
expect(reason).toHaveTextContent("Nudge becomes available once the overseer is observing this task's current stage");
expect(nudgeBtn).toHaveAttribute("title", expect.stringContaining("Nudge becomes available once the overseer is observing this task's current stage"));
});
it("desktop: shows the human-control-suppressed copy (not the periodic-observation copy) when the task is user-paused (FN-7582)", async () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-117", column: "in-progress", plannerOversightLevel: "autonomous", plannerOverseerState: activeSnapshot, userPaused: true })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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 is paused while this task is under manual control.");
expect(reason).not.toHaveTextContent("Nudge becomes available once the overseer is observing this task's current stage");
expect(nudgeBtn).toHaveAttribute("title", expect.stringContaining("Nudge is paused while this task is under manual control."));
});
it("does not show the disabled-reason helper when Nudge is enabled", async () => {
@@ -453,6 +502,29 @@ describe("TaskDetailModal oversight controls — mobile breakpoint (FN-7521, FN-
expect(await screen.findByTestId("detail-overseer-explain")).toBeTruthy();
});
it("still shows the reworded periodic-observation copy (not the old alarming phrase) behind the mobile overflow menu (FN-7582)", async () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-216", column: "in-progress", plannerOversightLevel: "autonomous" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await openOversightMenu();
const nudgeBtn = await screen.findByTestId("detail-overseer-nudge");
expect(nudgeBtn).toBeDisabled();
const reason = await screen.findByTestId("detail-overseer-nudge-disabled-reason");
expect(reason).not.toHaveTextContent("not actively watching this task");
expect(reason).toHaveTextContent("Nudge becomes available once the overseer is observing this task's current stage");
});
it("still renders no oversight-control leftover shell behind the mobile overflow menu for the off+inactive default case", async () => {
render(
<TaskDetailModal

View File

@@ -130,6 +130,13 @@ vi.mock("lucide-react", () => ({
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 }),
// FNXC:Test 2026-07-05-11:20: FN-7579 added "ask-user"/"exit-gate" workflow node types to
// WorkflowNodeTypes.tsx (HelpCircle, DoorOpen), which WorkflowNodeEditor/WorkflowResultsTab
// import transitively behind TaskDetailModal's lazy workflow surfaces. The explicit mock list
// omitted them, breaking every TaskDetailModal suite at import (pre-existing gap, unrelated to
// FN-7582's copy change) — keep this list in sync with the node-editor icon set.
HelpCircle: () => null,
DoorOpen: () => null,
}));
vi.mock("../../hooks/useAgentLogs", () => ({