FN-8263: reflect session advisor state in task-detail eye
Keep the task-detail oversight indicator aligned with the effective session advisor state. - Show and light the oversight eye for enabled advisor inheritance while lifecycle oversight is off or unresolved. - Limit unresolved advisor-only menus to the session advisor toggle and repaint the icon after toggles. - Cover desktop and mobile behavior and document the interaction. Files changed: .changeset/fn-8263-session-advisor-detail-eye.md | 7 ++ docs/dashboard-guide.md | 6 +- .../dashboard/app/components/TaskDetailModal.tsx | 47 ++++++---- .../TaskDetailModal.oversight-controls.test.tsx | 101 +++++++++++++++++++++ .../TaskDetailModal.oversight-mobile.test.tsx | 40 ++++++++ 5 files changed, 183 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-8263 Fusion-Task-Lineage: 61cd134c-7721-4e19-89a9-a406358f54a9 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8263-session-advisor-detail-eye.md
Normal file
7
.changeset/fn-8263-session-advisor-detail-eye.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: The task-detail oversight eye icon now reflects the session advisor's on/off state even when planner oversight is off.
|
||||
category: fix
|
||||
dev: TaskDetailModal surfaces and lights the detail-oversight-menu-trigger Eye whenever effectiveSessionAdvisorEnabled (resolveTaskSessionAdvisorEnabled: task override / project sessionAdvisorEnabledByDefault / workflow plannerOverseerAdvisorEnabled) is true, independent of the lifecycle oversight level, and repaints on toggle at both breakpoints.
|
||||
@@ -248,8 +248,10 @@ FNXC:QuickAddActionRow 2026-07-16-16:00: FN-8194 aligns task-detail metadata act
|
||||
|
||||
FNXC:PlannerOversight 2026-07-17-13:18: FN-8233 makes the task-detail Oversight trigger communicate the combined effective overseer state: it uses Eye when the oversight level is active or the Session advisor is enabled, and EyeOff only when both are off.
|
||||
|
||||
FNXC:PlannerOversight 2026-07-18-12:00: FN-8247 requires Stop to disable both lifecycle oversight and the independently-enabled session advisor, including its live runtime, so project/workflow defaults cannot leave advisor comments running after an operator stops it. -->
|
||||
- The task detail modal's inline meta-controls cluster follows Quick Add order: compact **Attach file** (`data-testid="detail-inline-attach"`), eligible-task **GitHub tracking** toggle (`data-testid="detail-inline-github-toggle"`), **Oversight**, Priority, and Execution mode. Attach opens the existing task attachment picker; GitHub toggles the existing tracking setting and is omitted for GitLab-tracked or non-editable tasks. The Oversight control exposes planner-overseer actions behind a compact **"Oversight" overflow-menu button** (`data-testid="detail-oversight-menu-trigger"`, `Eye` when the oversight level is active or the Session advisor is enabled, otherwise `EyeOff`, `aria-haspopup="menu"`) on every viewport, desktop and mobile alike. Clicking the trigger opens a `role="menu"` popover (mirroring the existing move-action dropdown pattern) containing: 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** button (`data-testid="detail-overseer-stop"`) that disables both active lifecycle oversight and the session advisor for the task (confirmation-gated), including its live advisor runtime; it is 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 trigger itself is withheld entirely when none of the menu's controls would render (no empty-shell overflow button for the unresolved-default case).
|
||||
FNXC:PlannerOversight 2026-07-18-12:00: FN-8247 requires Stop to disable both lifecycle oversight and the independently-enabled session advisor, including its live runtime, so project/workflow defaults cannot leave advisor comments running after an operator stops it.
|
||||
|
||||
FNXC:PlannerOversight 2026-07-18-14:00: FN-8263 requires the task-detail Eye to remain visible and lit for an effective session advisor even when lifecycle oversight is off or awaiting workflow resolution. Advisor-only menus expose only the advisor toggle; lifecycle controls wait for resolved active oversight. -->
|
||||
- The task detail modal's inline meta-controls cluster follows Quick Add order: compact **Attach file** (`data-testid="detail-inline-attach"`), eligible-task **GitHub tracking** toggle (`data-testid="detail-inline-github-toggle"`), **Oversight**, Priority, and Execution mode. Attach opens the existing task attachment picker; GitHub toggles the existing tracking setting and is omitted for GitLab-tracked or non-editable tasks. The Oversight control exposes planner-overseer actions behind a compact **"Oversight" overflow-menu button** (`data-testid="detail-oversight-menu-trigger"`, `Eye` when the oversight level is active or the Session advisor is enabled, otherwise `EyeOff`, `aria-haspopup="menu"`) on every viewport, desktop and mobile alike. Clicking the trigger opens a `role="menu"` popover (mirroring the existing move-action dropdown pattern) containing: 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** button (`data-testid="detail-overseer-stop"`) that disables both active lifecycle oversight and the session advisor for the task (confirmation-gated), including its live advisor runtime; it is 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 trigger is withheld only for an unresolved task with neither lifecycle oversight controls nor a session-advisor applicability signal. When the effective session advisor is on (task override, project default, or workflow legacy setting), it remains visible and lit even while lifecycle oversight is off or unresolved; toggling it repaints the icon immediately. In the advisor-only unresolved state, the menu contains only the Session advisor toggle: the level select, Nudge, Stop, Explain, and Interventions controls remain hidden until lifecycle oversight resolves and is active.
|
||||
<!-- 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 controls inside the Oversight menu 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 entries; the label is gated by the same `(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff` condition as the buttons, so it never renders when oversight is Off/unresolved (opening the menu in that state shows only the level select). 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 originally introduced this overflow menu for mobile only, collapsing the level-select/nudge/stop/explain controls behind the trigger below the 768px breakpoint while desktop kept an inline cluster.
|
||||
|
||||
@@ -3434,13 +3434,26 @@ export function TaskDetailContent({
|
||||
const overseerHumanControlSuppressed = Boolean(isTaskPaused) || isDoneOrArchivedColumn || isOverseerHumanReviewTerminal;
|
||||
const oversightIsOff = effectiveOversightLevel === "off";
|
||||
/*
|
||||
FNXC:PlannerOversight 2026-07-17-13:18:
|
||||
FN-8233: the Oversight trigger must visibly track effective overseer state.
|
||||
`overseerTriggerOn = !oversightIsOff || effectiveSessionAdvisorEnabled` lets
|
||||
either the level select or Session advisor toggle in its dropdown update the
|
||||
shared trigger icon without introducing a stale local state snapshot.
|
||||
FNXC:PlannerOversight 2026-07-18-14:00:
|
||||
FN-8263 keeps the task-detail eye available for a session advisor independently
|
||||
of lifecycle-oversight resolution. Its applicability uses stable inheritance
|
||||
inputs (or an explicit override), so toggling an enabled advisor off repaints
|
||||
EyeOff instead of unmounting the trigger while a workflow request is pending.
|
||||
*/
|
||||
const overseerTriggerOn = !oversightIsOff || effectiveSessionAdvisorEnabled;
|
||||
const lifecycleOversightControlsResolved = hasTaskOversightOverride || workflowOversightResolved;
|
||||
const sessionAdvisorMenuApplicable =
|
||||
hasSessionAdvisorOverride ||
|
||||
projectSessionAdvisorDefault ||
|
||||
workflowOversightState.sessionAdvisorEnabled;
|
||||
const showOversightMenuTrigger = lifecycleOversightControlsResolved || sessionAdvisorMenuApplicable;
|
||||
/*
|
||||
FNXC:PlannerOversight 2026-07-18-14:10:
|
||||
FN-8263 suppresses the resolver's autonomous fallback while workflow
|
||||
lifecycle oversight is unresolved. The eye still tracks the shared advisor
|
||||
resolver immediately, rather than falsely staying lit after the advisor turns off.
|
||||
*/
|
||||
const overseerTriggerOn =
|
||||
(lifecycleOversightControlsResolved && !oversightIsOff) || effectiveSessionAdvisorEnabled;
|
||||
const canNudgeOverseer = overseerActive && !oversightIsOff && !overseerHumanControlSuppressed;
|
||||
const canExplainOverseer = overseerActive && !oversightIsOff;
|
||||
const showStopOverseer = !oversightIsOff;
|
||||
@@ -4322,7 +4335,7 @@ export function TaskDetailContent({
|
||||
so its Eye resolves through the shared `--icon-size-sm` sizing on
|
||||
mobile and stays visually aligned with Quick Add.
|
||||
*/}
|
||||
{(hasTaskOversightOverride || workflowOversightResolved) && (
|
||||
{showOversightMenuTrigger && (
|
||||
<div className="detail-oversight-menu-dropdown" ref={oversightMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -4340,8 +4353,9 @@ export function TaskDetailContent({
|
||||
</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>
|
||||
{lifecycleOversightControlsResolved && (
|
||||
<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"
|
||||
@@ -4360,8 +4374,9 @@ export function TaskDetailContent({
|
||||
{OVERSIGHT_LEVEL_LABEL[levelOption]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{/*
|
||||
FNXC:PlannerOversight 2026-07-14-18:11:
|
||||
Per-task session advisor toggle inside the Oversight menu.
|
||||
@@ -4415,12 +4430,12 @@ export function TaskDetailContent({
|
||||
: t("taskDetail.sessionAdvisor.inheritSuffix", " (inherited)")}
|
||||
</span>
|
||||
</button>
|
||||
{!oversightIsOff && (
|
||||
{lifecycleOversightControlsResolved && !oversightIsOff && (
|
||||
<span className="detail-oversight-controls-label" data-testid="detail-oversight-controls-label">
|
||||
{t("taskDetail.oversight.controlsLabel", "Overseer controls")}
|
||||
</span>
|
||||
)}
|
||||
{!oversightIsOff && (
|
||||
{lifecycleOversightControlsResolved && !oversightIsOff && (
|
||||
<button
|
||||
type="button"
|
||||
className={`detail-oversight-menu-item detail-overseer-nudge ${isNudgingOverseer ? "detail-overseer-nudge--saving" : ""}`}
|
||||
@@ -4439,12 +4454,12 @@ export function TaskDetailContent({
|
||||
<span>{t("taskDetail.oversight.nudge", "Nudge")}</span>
|
||||
</button>
|
||||
)}
|
||||
{!oversightIsOff && !canNudgeOverseer && (
|
||||
{lifecycleOversightControlsResolved && !oversightIsOff && !canNudgeOverseer && (
|
||||
<span className="detail-oversight-controls-helper" data-testid="detail-overseer-nudge-disabled-reason">
|
||||
{nudgeDisabledReason}
|
||||
</span>
|
||||
)}
|
||||
{showStopOverseer && (
|
||||
{lifecycleOversightControlsResolved && showStopOverseer && (
|
||||
<button
|
||||
type="button"
|
||||
className={`detail-oversight-menu-item detail-overseer-stop ${isStoppingOverseer ? "detail-overseer-stop--saving" : ""}`}
|
||||
@@ -4462,7 +4477,7 @@ export function TaskDetailContent({
|
||||
<span>{t("taskDetail.oversight.stop", "Stop")}</span>
|
||||
</button>
|
||||
)}
|
||||
{!oversightIsOff && (
|
||||
{lifecycleOversightControlsResolved && !oversightIsOff && (
|
||||
<button
|
||||
type="button"
|
||||
className="detail-oversight-menu-item detail-overseer-explain"
|
||||
|
||||
@@ -123,6 +123,107 @@ describe("TaskDetailModal oversight controls", () => {
|
||||
expect(toggle.querySelector('[data-testid="eye-icon"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lights the trigger from the project advisor default while oversight is off", async () => {
|
||||
const api = await import("../../api");
|
||||
vi.mocked(api.fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
sessionAdvisorEnabledByDefault: true,
|
||||
} as any);
|
||||
vi.mocked(api.fetchBoardWorkflows).mockResolvedValueOnce({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "WF-8263-project-default",
|
||||
workflows: [{ id: "WF-8263-project-default", name: "Project default workflow" } as any],
|
||||
taskWorkflowIds: { "FN-8263-project-default": "WF-8263-project-default" },
|
||||
});
|
||||
vi.mocked(api.fetchWorkflowSettingValues).mockResolvedValueOnce({
|
||||
stored: {},
|
||||
effective: { plannerOversightLevel: "off", plannerOverseerAdvisorEnabled: false },
|
||||
defaults: {},
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-8263-project-default", column: "todo", plannerOversightLevel: undefined, sessionAdvisorEnabled: undefined })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = await screen.findByTestId("detail-oversight-menu-trigger");
|
||||
await waitFor(() => {
|
||||
expect(trigger.querySelector('[data-testid="eye-icon"]')).toBeInTheDocument();
|
||||
});
|
||||
await openOversightMenu();
|
||||
expect(screen.getByTestId("detail-session-advisor-toggle")).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("keeps the advisor eye visible while workflow oversight is pending and repaints it off", async () => {
|
||||
const api = await import("../../api");
|
||||
let currentTask = makeTask({
|
||||
id: "FN-8263-pending-advisor",
|
||||
column: "todo",
|
||||
plannerOversightLevel: undefined,
|
||||
sessionAdvisorEnabled: undefined,
|
||||
});
|
||||
vi.mocked(api.fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
sessionAdvisorEnabledByDefault: true,
|
||||
} as any);
|
||||
vi.mocked(api.fetchBoardWorkflows).mockResolvedValueOnce({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "WF-8263-pending-advisor",
|
||||
workflows: [{ id: "WF-8263-pending-advisor", name: "Pending advisor workflow" } as any],
|
||||
taskWorkflowIds: { [currentTask.id]: "WF-8263-pending-advisor" },
|
||||
});
|
||||
vi.mocked(api.fetchWorkflowSettingValues).mockImplementationOnce(() => new Promise(() => {}));
|
||||
vi.mocked(api.updateTask).mockImplementation(async (_id, patch) => {
|
||||
currentTask = makeTask({ ...currentTask, ...patch });
|
||||
return currentTask as any;
|
||||
});
|
||||
|
||||
let rerenderModal: (nextTask: typeof currentTask) => void;
|
||||
const renderModal = (nextTask: typeof currentTask) => (
|
||||
<TaskDetailModal
|
||||
task={nextTask}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onTaskUpdated={(updatedTask) => rerenderModal(updatedTask as typeof currentTask)}
|
||||
addToast={noop}
|
||||
/>
|
||||
);
|
||||
const rendered = render(renderModal(currentTask));
|
||||
rerenderModal = (nextTask) => rendered.rerender(renderModal(nextTask));
|
||||
|
||||
const trigger = await screen.findByTestId("detail-oversight-menu-trigger");
|
||||
expect(trigger.querySelector('[data-testid="eye-icon"]')).toBeInTheDocument();
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.queryByTestId("detail-oversight-level-select")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("detail-oversight-controls-label")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("detail-overseer-nudge")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("detail-overseer-stop")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("detail-overseer-explain")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Interventions")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(await screen.findByTestId("detail-session-advisor-toggle"));
|
||||
await waitFor(() => {
|
||||
expect(api.updateTask).toHaveBeenCalledWith(currentTask.id, { sessionAdvisorEnabled: false }, undefined);
|
||||
expect(screen.getByTestId("detail-oversight-menu-trigger")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("detail-oversight-menu-trigger").querySelector('[data-testid="eye-off-icon"]')).toBeInTheDocument();
|
||||
expect(screen.getByTestId("detail-session-advisor-toggle")).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
});
|
||||
|
||||
it("gives an explicit false override precedence and repaints when it is toggled on", async () => {
|
||||
const api = await import("../../api");
|
||||
let currentTask = makeTask({
|
||||
|
||||
@@ -72,6 +72,46 @@ describe("TaskDetailModal oversight controls — mobile overflow menu", () => {
|
||||
setViewportWidth(DESKTOP_WIDTH);
|
||||
});
|
||||
|
||||
it("lights the shared trigger from the project advisor default while oversight is off", async () => {
|
||||
const api = await import("../../api");
|
||||
vi.mocked(api.fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
sessionAdvisorEnabledByDefault: true,
|
||||
} as any);
|
||||
vi.mocked(api.fetchBoardWorkflows).mockResolvedValueOnce({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "WF-8263-mobile-project-default",
|
||||
workflows: [{ id: "WF-8263-mobile-project-default", name: "Mobile project default workflow" } as any],
|
||||
taskWorkflowIds: { "FN-8263-mobile-project-default": "WF-8263-mobile-project-default" },
|
||||
});
|
||||
vi.mocked(api.fetchWorkflowSettingValues).mockResolvedValueOnce({
|
||||
stored: {},
|
||||
effective: { plannerOversightLevel: "off", plannerOverseerAdvisorEnabled: false },
|
||||
defaults: {},
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-8263-mobile-project-default", column: "todo", plannerOversightLevel: undefined, sessionAdvisorEnabled: undefined })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = await screen.findByTestId("detail-oversight-menu-trigger");
|
||||
await waitFor(() => {
|
||||
expect(trigger.querySelector('[data-testid="eye-icon"]')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(trigger);
|
||||
expect((await screen.findByTestId("detail-session-advisor-toggle"))).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("repaints the shared trigger and menu toggle after disabling a workflow-enabled advisor", async () => {
|
||||
const api = await import("../../api");
|
||||
let currentTask = makeTask({
|
||||
|
||||
Reference in New Issue
Block a user