FN-7571: move planner intervention timeline into Activity dropdown
Relocates the FN-7519 Intervention Timeline out of the inline oversight cluster and into the task Activity view as a fourth selectable segment. - Add `interventions` to the `ActivitySegment` union and gate its dropdown option on the same oversight-active expression previously used by the inline mount - Render `PlannerInterventionTimeline` in a new Activity segment panel instead of inline next to the oversight controls - Fall back to the Live segment automatically if oversight turns off (or was never resolved) while Interventions is selected, avoiding an orphaned selection - Update oversight-controls and task-activity-chat tests to cover the new dropdown segment and removed inline mount - Add changeset and update dashboard guide docs Files changed: ...7571-intervention-timeline-activity-dropdown.md | 7 + docs/dashboard-guide.md | 4 +- .../dashboard/app/components/TaskDetailModal.tsx | 49 ++++--- .../TaskDetailModal.oversight-controls.test.tsx | 148 +++++++++++++++++++++ .../TaskDetailModal.task-activity-chat.test.tsx | 7 +- 5 files changed, 195 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-7571 Fusion-Task-Lineage: 331a1b32-5712-4583-9777-e36ff5cc3ffc Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Move the planner intervention timeline into the task Activity view dropdown.
|
||||
category: feature
|
||||
dev: Removes the inline `PlannerInterventionTimeline` mount from the FN-7517 oversight cluster in `TaskDetailModal.tsx` and adds a fourth `interventions` `ActivitySegment`, shown in the Activity dropdown only when planner oversight is active for the task; falls back to Live if oversight turns off while Interventions is selected.
|
||||
@@ -202,8 +202,8 @@ Features:
|
||||
- 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. -->
|
||||
- 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. Opening the menu presents exactly one surface: menu-open auto-focus lands on the first actionable button menuitem, never the native level `<select>`, so the OS option picker never auto-opens on top of the custom popover (FN-7562).
|
||||
<!-- 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.
|
||||
<!-- FNXC:PlannerOversight 2026-07-04-18:00: FN-7519 adds a read-only Intervention Timeline. FN-7571 (2026-07-04-19:00) relocates it from an inline mount below the FN-7517 controls into the task-detail Activity view dropdown as a fourth "Interventions" segment, alongside Live/Feed/Raw. -->
|
||||
- The task detail modal's **Activity** tab view dropdown (Live/Feed/Raw) gains a fourth **Interventions** option, shown only when planner oversight is active for the task (same gate as the former inline mount: `(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff`). Selecting it renders the **Intervention Timeline** (`data-testid="planner-intervention-timeline"`) inside the Activity panel, 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. When oversight is off or unresolved, the Interventions option is absent from the dropdown entirely (no leftover empty segment), and if it was previously selected the view falls back to Live rather than leaving a blank panel. 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.
|
||||
- Board task cards support a context menu from right-click, keyboard context menu / Shift+F10, or touch long-press for detail-aligned lifecycle actions without changing normal card clicks. The menu opens as an independent overlay so it stays visible beyond the card or column edge while remaining clamped to the viewport. On mobile, long-press opens that menu without selecting card text or showing native copy/paste callouts. Selecting an action applies that exact action once and dismisses the menu. Completed card context menus include **Refine**, which opens the existing task-detail refinement feedback modal for the same task.
|
||||
<!-- FNXC:BoardCardActions 2026-06-29-00:00: Board card context menus are documented as alternate entry points only; normal click still opens task detail, and mobile long-press must not trigger detail behind the menu.
|
||||
|
||||
@@ -224,7 +224,7 @@ function formatDurationCompact(ageMs: number): string {
|
||||
}
|
||||
|
||||
type TabId = "summary" | "definition" | "chat" | "planner-chat" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | "terminal" | `plugin-${string}`;
|
||||
type ActivitySegment = "current" | "feed" | "raw-logs";
|
||||
type ActivitySegment = "current" | "feed" | "raw-logs" | "interventions";
|
||||
|
||||
/*
|
||||
FNXC:TaskDetailActivityTab 2026-06-30-00:00:
|
||||
@@ -3306,13 +3306,35 @@ export function TaskDetailContent({
|
||||
});
|
||||
}, []);
|
||||
|
||||
/*
|
||||
FNXC:PlannerOversight 2026-07-04-19:00:
|
||||
FN-7571 moves the FN-7519 Intervention Timeline out of the inline oversight
|
||||
cluster and into the Activity view dropdown as a fourth `interventions`
|
||||
segment, alongside Live/Feed/Raw. It is gated on the SAME oversight-active
|
||||
expression the inline mount used to use (`hasTaskOversightOverride ||
|
||||
workflowOversightResolved`, minus `oversightIsOff`) so the option never
|
||||
appears — and never leaves an always-empty segment — when oversight is off
|
||||
or unresolved for the task.
|
||||
*/
|
||||
const oversightActive = (hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff;
|
||||
|
||||
const activityViewOptions = useMemo<Array<{ value: ActivitySegment; label: string }>>(() => [
|
||||
{ value: "current", label: t("taskDetail.activity.current", "Live") },
|
||||
{ value: "feed", label: t("taskDetail.activity.feed", "Feed") },
|
||||
{ value: "raw-logs", label: t("taskDetail.activity.raw", "Raw") },
|
||||
], [t]);
|
||||
...(oversightActive ? [{ value: "interventions" as const, label: t("taskDetail.activity.interventions", "Interventions") }] : []),
|
||||
], [t, oversightActive]);
|
||||
const selectedActivityViewLabel = activityViewOptions.find((option) => option.value === activitySegment)?.label ?? activityViewOptions[0]?.label ?? "Live";
|
||||
|
||||
// FNXC:PlannerOversight 2026-07-04-19:00: if oversight turns off (or was never active) while
|
||||
// the Interventions segment is selected, fall back to Live so a hidden dropdown option never
|
||||
// leaves a blank/selected segment behind.
|
||||
useEffect(() => {
|
||||
if (!oversightActive && activitySegment === "interventions") {
|
||||
setActivitySegment("current");
|
||||
}
|
||||
}, [oversightActive, activitySegment]);
|
||||
|
||||
const selectActivityView = useCallback((value: ActivitySegment) => {
|
||||
activityViewMenuViewportGuardUntilRef.current = 0;
|
||||
setActiveTab("chat");
|
||||
@@ -4097,21 +4119,6 @@ export function TaskDetailContent({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
FNXC:PlannerOversight 2026-07-04-18:00:
|
||||
FN-7519 Intervention Timeline: rendered adjacent to the FN-7517
|
||||
oversight cluster (no separate `.task-oversight-controls` class
|
||||
exists in the merged FN-7517 code, so this attaches to the
|
||||
closest existing seam — the same gating condition used by the
|
||||
nudge/stop/explain controls above). Hidden entirely (no
|
||||
leftover empty shell) when oversight is Off or unresolved, per
|
||||
the Surface Enumeration gate.
|
||||
*/}
|
||||
<PlannerInterventionTimeline
|
||||
taskId={task.id}
|
||||
projectId={projectId}
|
||||
hidden={!(hasTaskOversightOverride || workflowOversightResolved) || oversightIsOff}
|
||||
/>
|
||||
{provenanceDisplay && (
|
||||
<div className="detail-provenance">
|
||||
<GitBranch aria-hidden="true" />
|
||||
@@ -4474,6 +4481,14 @@ export function TaskDetailContent({
|
||||
loadingMore={agentLogLoadingMore}
|
||||
totalCount={agentLogTotal}
|
||||
/>
|
||||
) : activitySegment === "interventions" ? (
|
||||
// FNXC:PlannerOversight 2026-07-04-19:00: FN-7571 relocates the FN-7519
|
||||
// Intervention Timeline from the inline oversight cluster into this
|
||||
// Activity segment. Reachable only via the dropdown, which already gates
|
||||
// on oversightActive, so no `hidden` prop is needed here.
|
||||
<div className="detail-activity" role="tabpanel">
|
||||
<PlannerInterventionTimeline taskId={task.id} projectId={projectId} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="detail-activity" role="tabpanel">
|
||||
<button
|
||||
|
||||
@@ -477,3 +477,151 @@ describe("TaskDetailModal oversight controls — mobile breakpoint (FN-7521, FN-
|
||||
expect(screen.queryByTestId("detail-overseer-explain")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PlannerOversight 2026-07-04-19:00:
|
||||
FN-7571 coverage: the FN-7519 Intervention Timeline moved from an inline
|
||||
mount in the oversight cluster into the Activity view dropdown as a fourth
|
||||
"Interventions" segment, gated on the same oversight-active expression the
|
||||
inline mount used. These assertions cover: (a) no inline mount remains,
|
||||
(b) the dropdown option appears/renders the timeline when oversight is
|
||||
active, (c) the option is absent and nothing mounts when oversight is off,
|
||||
and (d) selecting Interventions then losing oversight falls back to Live
|
||||
with no blank panel. Runs at the default (desktop) breakpoint, matching the
|
||||
first describe block's setup rather than the FN-7521/FN-7545 mobile one.
|
||||
*/
|
||||
describe("Intervention Timeline relocation into the Activity dropdown (FN-7571)", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
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 });
|
||||
});
|
||||
|
||||
function openActivityViewMenu() {
|
||||
const existingMenu = screen.queryByRole("menu", { name: "Activity views" });
|
||||
if (!existingMenu) {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
|
||||
}
|
||||
return screen.getByRole("menu", { name: "Activity views" });
|
||||
}
|
||||
|
||||
it("never renders the timeline inline in the oversight cluster", 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}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("detail-overseer-nudge");
|
||||
expect(screen.queryByTestId("planner-intervention-timeline")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exposes an Interventions option in the Activity dropdown and renders the timeline when oversight is active", 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}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("detail-overseer-nudge");
|
||||
openActivityViewMenu();
|
||||
const option = screen.getByRole("menuitem", { name: "Interventions" });
|
||||
fireEvent.click(option);
|
||||
|
||||
expect(await screen.findByTestId("planner-intervention-timeline")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the Interventions option and mounts nothing when oversight is off", async () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-212", column: "in-progress", plannerOversightLevel: "off" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("detail-oversight-level-select");
|
||||
openActivityViewMenu();
|
||||
expect(screen.queryByRole("menuitem", { name: "Interventions" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("planner-intervention-timeline")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to Live with no blank panel if oversight turns off after Interventions was selected", async () => {
|
||||
const { rerender } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-213", column: "in-progress", plannerOversightLevel: "autonomous", plannerOverseerState: activeSnapshot })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("detail-overseer-nudge");
|
||||
openActivityViewMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Interventions" }));
|
||||
expect(await screen.findByTestId("planner-intervention-timeline")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-213", column: "in-progress", plannerOversightLevel: "off" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("planner-intervention-timeline")).not.toBeInTheDocument();
|
||||
});
|
||||
openActivityViewMenu();
|
||||
expect(screen.getByRole("menuitem", { name: "Live" })).toHaveAttribute("aria-current", "true");
|
||||
});
|
||||
|
||||
it("still renders the empty state inside the Activity segment when there are no interventions", async () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-214", column: "in-progress", plannerOversightLevel: "autonomous", plannerOverseerState: activeSnapshot })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("detail-overseer-nudge");
|
||||
openActivityViewMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Interventions" }));
|
||||
|
||||
expect(await screen.findByTestId("planner-intervention-timeline-empty")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ function renderModal(props: Partial<ComponentProps<typeof TaskDetailModal>> = {}
|
||||
task={makeTask({
|
||||
id: "FN-7315",
|
||||
column: "in-progress" as any,
|
||||
// FNXC:PlannerOversight 2026-07-04-19:00: explicitly off so these Activity/Chat-tab
|
||||
// integration tests (unrelated to FN-7571 oversight/intervention coverage) don't pick
|
||||
// up the always-active default oversight level (autonomous) and gain an unrelated
|
||||
// "Interventions" dropdown option.
|
||||
plannerOversightLevel: "off",
|
||||
log: [
|
||||
{ timestamp: "2026-06-30T20:00:00.000Z", action: "Started work", outcome: "Executor checked out" },
|
||||
{ timestamp: "2026-06-30T20:01:00.000Z", action: "Posted update" },
|
||||
@@ -686,7 +691,7 @@ describe("TaskDetailModal Activity and planner Chat tab integration", () => {
|
||||
mockRawLogs([]);
|
||||
render(
|
||||
<TaskDetailContent
|
||||
task={makeTask({ id: "FN-7315-DONE", column: "done" as any, log: [], steeringComments: [] })}
|
||||
task={makeTask({ id: "FN-7315-DONE", column: "done" as any, log: [], steeringComments: [], plannerOversightLevel: "off" })}
|
||||
embedded
|
||||
onRequestClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
|
||||
Reference in New Issue
Block a user