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:
7
.changeset/fn-7582-oversight-guideline-copy.md
Normal file
7
.changeset/fn-7582-oversight-guideline-copy.md
Normal 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.
|
||||||
@@ -3163,6 +3163,23 @@ export function TaskDetailContent({
|
|||||||
already uses — the server-side `evaluateOverseerHumanControl` guard is the
|
already uses — the server-side `evaluateOverseerHumanControl` guard is the
|
||||||
real enforcement; this is a client-side disable heuristic only). Stop is
|
real enforcement; this is a client-side disable heuristic only). Stop is
|
||||||
hidden once oversight is already off — there is nothing left to stop.
|
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 overseerSnapshot = task.plannerOverseerState ?? null;
|
||||||
const overseerActive = Boolean(overseerSnapshot);
|
const overseerActive = Boolean(overseerSnapshot);
|
||||||
@@ -3173,6 +3190,18 @@ export function TaskDetailContent({
|
|||||||
const canNudgeOverseer = overseerActive && !oversightIsOff && !overseerHumanControlSuppressed;
|
const canNudgeOverseer = overseerActive && !oversightIsOff && !overseerHumanControlSuppressed;
|
||||||
const canExplainOverseer = overseerActive && !oversightIsOff;
|
const canExplainOverseer = overseerActive && !oversightIsOff;
|
||||||
const showStopOverseer = !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 isActivityExpanded = activityExpanded && activeTab === "chat" && !isEditing;
|
||||||
const isPlannerChatExpanded = plannerChatExpanded && activeTab === "planner-chat" && !isEditing;
|
const isPlannerChatExpanded = plannerChatExpanded && activeTab === "planner-chat" && !isEditing;
|
||||||
/*
|
/*
|
||||||
@@ -4006,7 +4035,7 @@ export function TaskDetailContent({
|
|||||||
}}
|
}}
|
||||||
onKeyDown={handleOversightMenuKeyDown}
|
onKeyDown={handleOversightMenuKeyDown}
|
||||||
disabled={!canNudgeOverseer || isNudgingOverseer}
|
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")}
|
aria-label={t("taskDetail.oversight.nudgeAriaLabel", "Manual nudge")}
|
||||||
>
|
>
|
||||||
{isNudgingOverseer ? <Loader2 className="spin" aria-hidden="true" /> : <Send aria-hidden="true" />}
|
{isNudgingOverseer ? <Loader2 className="spin" aria-hidden="true" /> : <Send aria-hidden="true" />}
|
||||||
@@ -4015,7 +4044,7 @@ export function TaskDetailContent({
|
|||||||
)}
|
)}
|
||||||
{!oversightIsOff && !canNudgeOverseer && (
|
{!oversightIsOff && !canNudgeOverseer && (
|
||||||
<span className="detail-oversight-controls-helper" data-testid="detail-overseer-nudge-disabled-reason">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{showStopOverseer && (
|
{showStopOverseer && (
|
||||||
@@ -4126,7 +4155,7 @@ export function TaskDetailContent({
|
|||||||
void handleNudgeOverseer();
|
void handleNudgeOverseer();
|
||||||
}}
|
}}
|
||||||
disabled={!canNudgeOverseer || isNudgingOverseer}
|
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")}
|
aria-label={t("taskDetail.oversight.nudgeAriaLabel", "Manual nudge")}
|
||||||
>
|
>
|
||||||
{isNudgingOverseer ? <Loader2 className="spin" aria-hidden="true" /> : <Send aria-hidden="true" />}
|
{isNudgingOverseer ? <Loader2 className="spin" aria-hidden="true" /> : <Send aria-hidden="true" />}
|
||||||
@@ -4135,7 +4164,7 @@ export function TaskDetailContent({
|
|||||||
)}
|
)}
|
||||||
{(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff && !canNudgeOverseer && (
|
{(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff && !canNudgeOverseer && (
|
||||||
<span className="detail-oversight-controls-helper" data-testid="detail-overseer-nudge-disabled-reason">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(hasTaskOversightOverride || workflowOversightResolved) && showStopOverseer && (
|
{(hasTaskOversightOverride || workflowOversightResolved) && showStopOverseer && (
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ 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 () => {
|
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(
|
render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
task={makeTask({ id: "FN-111", column: "todo", plannerOversightLevel: "autonomous" })}
|
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");
|
const nudgeBtn = await screen.findByTestId("detail-overseer-nudge");
|
||||||
expect(nudgeBtn).toBeDisabled();
|
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");
|
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 () => {
|
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();
|
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 () => {
|
it("still renders no oversight-control leftover shell behind the mobile overflow menu for the off+inactive default case", async () => {
|
||||||
render(
|
render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
|
|||||||
@@ -130,6 +130,13 @@ vi.mock("lucide-react", () => ({
|
|||||||
Bell: () => null,
|
Bell: () => null,
|
||||||
// FNXC:PlannerOversight 2026-07-04-19:00: FN-7545 mobile oversight overflow-menu trigger icon.
|
// 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 }),
|
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", () => ({
|
vi.mock("../../hooks/useAgentLogs", () => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user