feat(FN-3087): add immediate wake controls for agent inbox and message API,

This merge ships five distinct features: experimental-flag gating for research tools in the CLI extension and core settings, immediate wake controls for the agent inbox and message API enabling on-demand heartbeat triggers, separation of plugin lifecycle from setup probe state in the dashboard, grap

Fusion-Task-Id: FN-3087
This commit is contained in:
Fusion
2026-05-07 10:52:12 -07:00
committed by gsxdsm
parent 30767ba0c4
commit 514e5f3b94
8 changed files with 64 additions and 9 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix dependency-graph task card activation so primary non-drag clicks open task details exactly once through the dashboard host callback, while preserving drag suppression and graph highlighting behavior.

View File

@@ -15,7 +15,7 @@ Plugin-provided top-level **Graph** dashboard view for Fusion.
- **Position persistence**: dragged node positions are stored per project in browser localStorage and restored on reload
- **Animated transitions**: fit/reset operations animate `transform` (`var(--transition-normal)`), while continuous drag/wheel/pinch stays transition-free for responsiveness
- **Node rendering**: each graph node renders the real dashboard `TaskCard` via `GraphTaskNode` (no duplicated card markup)
- **Task detail integration**: clicking a graph card opens the native dashboard task detail modal through host context (`openTaskDetail`), matching board/list behavior
- **Task detail integration**: a primary non-drag click on a graph node surface opens the native dashboard task detail modal exactly once through host context (`openTaskDetail`), matching board/list behavior
- **In-progress behavior**: steps are visible by default and active-task glow (`agent-active`) is preserved because node cards reuse TaskCard directly
- **Active-state indicator bar**: active nodes render a compact top bar (`.graph-task-active-indicator`) with the current execution status label (for example `Executing`, `Planning`) and pulsing `--in-progress` emphasis
- **Current-step highlighting**: active nodes set `data-current-step` for valid native step indices so CSS selectors highlight the currently executing `.card-step-item` and pulse its step dot
@@ -36,10 +36,11 @@ Plugin-provided top-level **Graph** dashboard view for Fusion.
## Dependency chain highlighting
- **Hover** a node to highlight the full transitive upstream + downstream chain for that task.
- **Click** a node to persist selection highlighting until the same node is clicked again or the canvas pane is clicked.
- **Click** a node to persist selection highlighting until the same node is clicked again or the canvas pane is clicked; this same click also opens task detail once through the host detail callback.
- **Priority**: hover state overrides selected state; when hover leaves, selected highlighting reappears.
- **Dimming**: when a chain is active, unrelated nodes and edges are dimmed.
- **Neutral state**: when nothing is hovered/selected, no highlight/dim classes are applied.
- **Drag suppression**: drag movements above the node drag threshold suppress the post-drag click, preventing accidental detail opens on pointer release.
- **Edge rule**: an edge is highlighted only when both its source and target nodes are in the active chain.
## Controls

View File

@@ -64,7 +64,7 @@ export function GraphTaskNode({
onNodeDragEnd,
...taskCardProps
}: GraphTaskNodeProps) {
const { task, globalPaused, taskStuckTimeoutMs, lastFetchTimeMs } = taskCardProps;
const { task, globalPaused, taskStuckTimeoutMs, lastFetchTimeMs, onOpenDetail } = taskCardProps;
const isFailed = task.status === "failed";
const isPaused = task.paused === true;
const isStuck = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);
@@ -102,7 +102,13 @@ export function GraphTaskNode({
data-current-step={isActive && hasValidCurrentStep ? String(task.currentStep) : undefined}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={onClick}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) {
return;
}
onOpenDetail(task);
}}
onClickCapture={drag.onClickCapture}
onPointerDown={drag.onPointerDown}
onPointerMove={drag.onPointerMove}
@@ -114,7 +120,7 @@ export function GraphTaskNode({
<span className="graph-task-active-indicator-text">{getStatusLabel(task.status)}</span>
</div>
) : null}
<TaskCard {...taskCardProps} disableDrag={true} />
<TaskCard {...taskCardProps} onOpenDetail={() => {}} disableDrag={true} />
</div>
);
}

View File

@@ -100,6 +100,7 @@ describe("DependencyGraph highlighting", () => {
expect(edgeCB?.className.baseVal || edgeCB?.className).toContain("graph-edge--highlighted");
fireEvent.click(screen.getByTestId("task-C"));
expect(onOpenDetail).toHaveBeenCalledTimes(1);
expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "C" }));
});

View File

@@ -111,10 +111,11 @@ describe("DependencyGraph", () => {
expect(fitToGraph).toHaveBeenCalled();
});
it("clicking a card triggers onOpenDetail", () => {
it("clicking a card triggers onOpenDetail exactly once", () => {
const onOpenDetail = vi.fn();
render(<DependencyGraph tasks={[createTask("A", "in-progress")]} onOpenDetail={onOpenDetail} />);
fireEvent.click(screen.getByTestId("task-A"));
expect(onOpenDetail).toHaveBeenCalledTimes(1);
expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "A" }));
});

View File

@@ -220,13 +220,26 @@ describe("GraphTaskNode", () => {
expect(screen.getByTestId("graph-task-node-FN-TEST").hasAttribute("data-current-step")).toBe(false);
});
it("clicking card opens task detail", () => {
it("clicking card opens task detail exactly once", () => {
const props = createProps(createTask());
const { container } = render(<GraphTaskNode {...props} />);
const card = container.querySelector(".card");
expect(card).toBeTruthy();
fireEvent.click(card!);
expect(props.onOpenDetail).toHaveBeenCalledTimes(1);
expect(props.onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-TEST" }));
});
it("clicking active indicator surface opens task detail", () => {
const props = createProps(createTask({ column: "in-progress", status: "executing" }));
const { container } = render(<GraphTaskNode {...props} />);
const indicator = container.querySelector(".graph-task-active-indicator");
expect(indicator).toBeTruthy();
fireEvent.click(indicator!);
expect(props.onOpenDetail).toHaveBeenCalledTimes(1);
expect(props.onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-TEST" }));
});

View File

@@ -1,9 +1,20 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi, afterEach } from "vitest";
import { definePlugin } from "@fusion/plugin-sdk";
import { validatePluginManifest } from "@fusion/core";
import plugin from "../index";
import plugin, { DependencyGraphDashboardView } from "../index";
import { getPluginViewId } from "../../../../packages/dashboard/app/plugins/pluginViewRegistry";
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
TaskCard: ({ task, onOpenDetail }: { task: { id: string }; onOpenDetail: (task: { id: string }) => void }) =>
createElement("button", { "data-testid": `task-${task.id}`, onClick: () => onOpenDetail(task) }, task.id),
}));
afterEach(() => {
cleanup();
});
describe("dependency graph plugin host integration contract", () => {
it("declares dashboard view manifest shape", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-dependency-graph");
@@ -43,4 +54,20 @@ describe("dependency graph plugin host integration contract", () => {
expect(getPluginViewId(plugin.manifest.id, view.viewId)).toBe("plugin:fusion-plugin-dependency-graph:graph");
});
it("uses host openTaskDetail context when rendered through dashboard view entrypoint", () => {
const openTaskDetail = vi.fn();
render(
createElement(DependencyGraphDashboardView, {
context: {
tasks: [{ id: "FN-HOST", description: "FN-HOST", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [] }],
openTaskDetail,
} as never,
}),
);
fireEvent.click(screen.getByTestId("task-FN-HOST"));
expect(openTaskDetail).toHaveBeenCalledTimes(1);
expect(openTaskDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-HOST" }));
});
});

View File

@@ -65,6 +65,7 @@ describe("dependency graph interactions", () => {
render(<DependencyGraph tasks={[createTask("A", "in-progress")]} onOpenDetail={onOpenDetail} />);
fireEvent.click(screen.getByTestId("task-A"));
expect(onOpenDetail).toHaveBeenCalledTimes(1);
expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "A" }));
expect(screen.getAllByText(/Executing/).length).toBeGreaterThan(0);
});