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:
5
.changeset/fn-3087-graph-card-detail.md
Normal file
5
.changeset/fn-3087-graph-card-detail.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user