FN-7030: route graph tasks to shared pop-out

Route dependency Graph task opens through the shared movable task pop-out.\n\n- Send dependency-graph plugin task opens and rendered graph task cards to popOutTaskDetail.\n- Preserve fixed modal behavior for non-graph plugin dashboard views.\n- Document the Graph behavior and add regression coverage for desktop, mobile, and pop-out deduping.\n- Add a patch changeset for the published CLI package.\n\nFiles changed:\n .changeset/fn-7030-graph-task-popout.md            |   7 +\n docs/dashboard-guide.md                            |   4 +-\n .../app/components/dashboard/MainContent.tsx       |  15 +-\n .../__tests__/MainContent.graph-popout.test.tsx    | 274 +++++++++++++++++++++\n 4 files changed, 296 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-7030

Fusion-Task-Lineage: 83008d81-218e-49ce-9aff-cb6b51d84dba
This commit is contained in:
gsxdsm
2026-06-25 20:03:53 -07:00
parent 45727f1459
commit 0ae44992df
4 changed files with 296 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Open dependency Graph tasks in the shared movable task pop-out.
category: fix
dev: Routes graph plugin task-open callbacks through MainContent popOutTaskDetail while preserving non-graph plugin modal behavior.

View File

@@ -181,8 +181,8 @@ Behavior:
- Pan limits are zoom-aware and based on full graph extents (including negative auto-layout origins), so zoomed-in views can still pan to every rendered node instead of getting trapped by fixed viewport-only bounds - Pan limits are zoom-aware and based on full graph extents (including negative auto-layout origins), so zoomed-in views can still pan to every rendered node instead of getting trapped by fixed viewport-only bounds
- Dependency graph nodes reuse the same `TaskCard` UI as board/list views, so status badges, progress/steps, mission badges, retry/archive controls, and active-task glow stay visually consistent - Dependency graph nodes reuse the same `TaskCard` UI as board/list views, so status badges, progress/steps, mission badges, retry/archive controls, and active-task glow stay visually consistent
- Active graph nodes also add a dedicated top status indicator bar and current-step row highlighting so in-progress execution state stays visible even when zoomed out - Active graph nodes also add a dedicated top status indicator bar and current-step row highlighting so in-progress execution state stays visible even when zoomed out
- Clicking a graph card opens task details via the host detail handler (`onOpenDetail`, with `onOpenTaskDetail` fallback), while clicking the same card again or empty canvas clears selection - Clicking a graph card opens task details in the shared movable/resizable task pop-out via the host detail handler (`onOpenDetail`, with `onOpenTaskDetail` fallback), while clicking the same card again or empty canvas clears selection.
- On touch devices, single-tap is reserved for pan/drag gestures, so double-tapping a node opens its task detail modal; this does not change selection state. - On touch devices, single-tap is reserved for pan/drag gestures, so double-tapping a node opens the same shared task pop-out; this does not change selection state.
- Hovering or selecting a node highlights its full upstream and downstream dependency chain; highlighted nodes and connecting edges are emphasized while non-chain nodes are dimmed, and highlight clears when hover/selection is removed - Hovering or selecting a node highlights its full upstream and downstream dependency chain; highlighted nodes and connecting edges are emphasized while non-chain nodes are dimmed, and highlight clears when hover/selection is removed
- Nodes support manual drag repositioning with a 4px movement threshold to separate click from drag, using pointer capture and zoom-aware delta scaling for reliable tracking - Nodes support manual drag repositioning with a 4px movement threshold to separate click from drag, using pointer capture and zoom-aware delta scaling for reliable tracking
- Custom node positions persist per project in browser localStorage (`kb:${projectId}:fusion-plugin-dependency-graph:positions`) across refresh/project switches, and **Fit to graph** clears saved positions and restores auto-layout - Custom node positions persist per project in browser localStorage (`kb:${projectId}:fusion-plugin-dependency-graph:positions`) across refresh/project switches, and **Fit to graph** clears saved positions and restores auto-layout

View File

@@ -254,6 +254,17 @@ export function MainContent({
const pluginContextTasks = isDependencyGraphView const pluginContextTasks = isDependencyGraphView
? filterTasksByGraphWorkflowSelection(pluginTasks, currentProject?.id, graphWorkflowSelection) ? filterTasksByGraphWorkflowSelection(pluginTasks, currentProject?.id, graphWorkflowSelection)
: pluginTasks; : pluginTasks;
/*
FNXC:GraphTaskPopout 2026-06-25-12:00:
Dependency-graph task opens must share the movable, resizable FloatingWindow pop-out used by Board/List pop-out and artifact cards. Keep non-graph plugin views on the fixed task-detail modal so plugin contracts outside the Graph view do not change.
*/
const openPluginTaskDetail = (task: Task | TaskDetail, initialTab?: DetailTaskTab) => {
if (isDependencyGraphView) {
popOutTaskDetail(task);
return;
}
openDetailTask(task, initialTab);
};
return ( return (
<PageErrorBoundary> <PageErrorBoundary>
{isDependencyGraphView ? ( {isDependencyGraphView ? (
@@ -271,13 +282,13 @@ export function MainContent({
tasks: pluginContextTasks, tasks: pluginContextTasks,
workflowSteps, workflowSteps,
subscribePluginEvents, subscribePluginEvents,
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => openDetailTask(task, initialTab), openTaskDetail: openPluginTaskDetail,
openFile: openFileInBrowser, openFile: openFileInBrowser,
renderTaskCard: (task: Task | TaskDetail) => ( renderTaskCard: (task: Task | TaskDetail) => (
<TaskCard <TaskCard
task={task} task={task}
projectId={currentProject?.id} projectId={currentProject?.id}
onOpenDetail={(value: Task | TaskDetail) => openDetailTask(value)} onOpenDetail={openPluginTaskDetail}
addToast={addToast} addToast={addToast}
workflowStepNameLookup={workflowStepNameLookup} workflowStepNameLookup={workflowStepNameLookup}
disableDrag={true} disableDrag={true}

View File

@@ -0,0 +1,274 @@
import { lazy } from "react";
import { act, render, renderHook, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { Task, TaskDetail } from "@fusion/core";
import { MainContent } from "../MainContent";
import type { MainContentProps } from "../types";
import { usePoppedOutTasks } from "../../../hooks/usePoppedOutTasks";
import type { PluginDashboardViewContext } from "../../../plugins/types";
const hostContexts: PluginDashboardViewContext[] = [];
vi.mock("../../../plugins/PluginDashboardViewHost", () => ({
PluginDashboardViewHost: ({ taskView, context }: { taskView: string; context?: PluginDashboardViewContext }) => {
if (context) hostContexts.push(context);
const task = context?.tasks[0];
return (
<div data-testid="plugin-host" data-task-view={taskView}>
<button type="button" onClick={() => task && context?.openTaskDetail(task, "logs")}>Open from plugin bridge</button>
<div data-testid="rendered-task-card">{task && context?.renderTaskCard?.(task)}</div>
</div>
);
},
}));
vi.mock("../../TaskCard", () => ({
TaskCard: ({ task, onOpenDetail }: { task: Task | TaskDetail; onOpenDetail: (task: Task | TaskDetail) => void }) => (
<button type="button" onClick={() => onOpenDetail(task)}>Open rendered task card</button>
),
}));
vi.mock("../../GraphWorkflowSwitcherSlot", () => ({
GraphWorkflowSwitcherSlot: () => <div data-testid="graph-workflow-switcher" />,
filterTasksByGraphWorkflowSelection: (tasks: Task[]) => tasks,
}));
const graphTask = {
id: "FN-GRAPH",
title: "Graph task",
description: "Graph task description",
column: "todo",
status: "todo",
dependencies: [],
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
} as unknown as Task;
const otherTask = {
...graphTask,
id: "FN-OTHER",
title: "Other graph task",
} as unknown as Task;
const LazyStub = lazy(async () => ({ default: () => null }));
function mainContentProps(overrides: Partial<MainContentProps> = {}): MainContentProps {
return {
showBackendConnectionErrorPage: false,
projectsError: null,
t: ((key: string, fallback?: string) => fallback ?? key) as MainContentProps["t"],
retryingProjects: false,
handleRetryProjects: vi.fn(),
shellApi: null,
taskView: "graph",
modalManager: {
closeSettings: vi.fn(),
settingsInitialSection: undefined,
openWorkflowEditor: vi.fn(),
} as unknown as MainContentProps["modalManager"],
handleChangeTaskView: vi.fn(),
addToast: vi.fn(),
currentProject: { id: "project-1", name: "Project 1" } as MainContentProps["currentProject"],
themeMode: "system",
setThemeMode: vi.fn(),
colorTheme: "default",
setColorTheme: vi.fn(),
dashboardFontScalePct: 100,
setDashboardFontScalePct: vi.fn(),
shadcnCustomColors: {},
setShadcnCustomColors: vi.fn(),
resolvedThemeMode: "light",
setQuickChatButtonModeImmediate: vi.fn(),
reopenOnboardingWithNav: vi.fn(),
viewMode: "project",
projects: [],
projectsLoading: false,
handleSelectProject: vi.fn(),
handleAddProject: vi.fn(),
handlePauseProject: vi.fn(),
handleResumeProject: vi.fn(),
handleRemoveProject: vi.fn(),
nodes: [],
graphPluginTaskView: "plugin:fusion-plugin-dependency-graph:graph",
graphWorkflowSelection: null,
setGraphWorkflowSelection: vi.fn(),
isRemote: false,
remoteData: { tasks: [] } as unknown as MainContentProps["remoteData"],
tasks: [graphTask],
workflowSteps: [],
subscribePluginEvents: vi.fn(() => vi.fn()),
openDetailTask: vi.fn(),
openFileInBrowser: vi.fn(),
workflowStepNameLookup: new Map(),
prAuthAvailable: false,
autoMerge: true,
settingsLoaded: true,
skillsEnabled: true,
experimentalFeatures: {},
setQuickChatOpen: vi.fn(),
setMailboxUnreadCount: vi.fn(),
setMissionTargetId: vi.fn(),
setMissionResumeSessionId: vi.fn(),
setMilestoneSliceResumeSessionId: vi.fn(),
missionResumeSessionId: undefined,
missionTargetId: undefined,
milestoneSliceResumeSessionId: undefined,
setGoalAnchorId: vi.fn(),
goalAnchorId: undefined,
agentsEnabled: true,
agentOnboardingEnabled: false,
handleOpenTaskLogs: vi.fn(),
popOutTaskDetail: vi.fn(),
selectedPrId: undefined,
insightsEnabled: true,
handleInsightTaskCreate: vi.fn(),
researchEnabled: true,
openSettingsWithNav: vi.fn(),
researchReadinessVersion: 0,
evalsEnabled: true,
memoryEnabled: true,
goalsEnabled: true,
handleOpenMission: vi.fn(),
todosEnabled: true,
openPlanningWithInitialPlanWithNav: vi.fn(),
ingestCreatedTasks: vi.fn(),
nodesEnabled: true,
openWorkflowEditorWithNav: vi.fn(),
handlePlanningTaskCreated: vi.fn(),
handlePlanningTasksCreated: vi.fn(),
handleGitHubImport: vi.fn(),
devServerEnabled: true,
mainPanelDetailTask: null,
filteredBoardTasks: [],
maxConcurrent: 2,
moveTask: vi.fn(),
pauseTask: vi.fn(),
openTaskDetailInMainPanel: vi.fn(),
openGroupModalWithNav: vi.fn(),
handleBoardQuickCreate: vi.fn(),
openNewTaskWithNav: vi.fn(),
subtaskBreakdownEnabled: true,
openSubtaskBreakdownWithNav: vi.fn(),
toggleAutoMerge: vi.fn(),
globalPaused: false,
updateTask: vi.fn(),
retryTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
deleteTask: vi.fn(),
archiveAllDone: vi.fn(),
loadArchivedTasks: vi.fn(),
searchQuery: "",
availableModels: [],
favoriteProviders: [],
favoriteModels: [],
handleOpenDetailWithTab: vi.fn(),
handleToggleFavorite: vi.fn(),
handleToggleModelFavorite: vi.fn(),
taskStuckTimeoutMs: undefined,
staleHighFanoutBlockerAgeThresholdMs: 0,
lastFetchTimeMs: undefined,
openCreateWorkflowWithNav: vi.fn(),
sidebarActive: false,
isMobile: false,
mainPanelDetailInitialTab: "chat",
closeTaskDetailMainPanel: vi.fn(),
setMainPanelDetailTask: vi.fn(),
mergeTask: vi.fn(),
resetTask: vi.fn(),
duplicateTask: vi.fn(),
unpauseTask: vi.fn(),
capacityRiskBannerEnabled: false,
capacityRiskDismissed: false,
capacityRiskSignal: { level: "low", reasons: [] } as unknown as MainContentProps["capacityRiskSignal"],
handleDismissCapacityRisk: vi.fn(),
AgentsView: LazyStub as MainContentProps["AgentsView"],
ChatView: LazyStub as MainContentProps["ChatView"],
CommandCenter: LazyStub as MainContentProps["CommandCenter"],
DevServerView: LazyStub as MainContentProps["DevServerView"],
DocumentsView: LazyStub as MainContentProps["DocumentsView"],
EvalsView: LazyStub as MainContentProps["EvalsView"],
GoalsView: LazyStub as MainContentProps["GoalsView"],
InsightsView: LazyStub as MainContentProps["InsightsView"],
MemoryView: LazyStub as MainContentProps["MemoryView"],
PullRequestView: LazyStub as MainContentProps["PullRequestView"],
ResearchView: LazyStub as MainContentProps["ResearchView"],
SecretsView: LazyStub as MainContentProps["SecretsView"],
SkillsView: LazyStub as MainContentProps["SkillsView"],
TodoView: LazyStub as MainContentProps["TodoView"],
_AutomationsView: LazyStub as MainContentProps["_AutomationsView"],
_ImportTasksView: LazyStub as MainContentProps["_ImportTasksView"],
_SettingsView: LazyStub as MainContentProps["_SettingsView"],
_WorkflowEditorView: LazyStub as MainContentProps["_WorkflowEditorView"],
...overrides,
};
}
describe("MainContent graph task pop-out wiring", () => {
it("routes dependency-graph bridge and rendered task-card opens to the shared pop-out", () => {
hostContexts.length = 0;
const openDetailTask = vi.fn();
const popOutTaskDetail = vi.fn();
render(<MainContent {...mainContentProps({ openDetailTask, popOutTaskDetail })} />);
expect(screen.getByTestId("graph-workflow-switcher")).toBeInTheDocument();
screen.getByText("Open from plugin bridge").click();
expect(popOutTaskDetail).toHaveBeenCalledWith(graphTask);
expect(openDetailTask).not.toHaveBeenCalled();
screen.getByText("Open rendered task card").click();
expect(popOutTaskDetail).toHaveBeenCalledTimes(2);
expect(popOutTaskDetail).toHaveBeenLastCalledWith(graphTask);
expect(openDetailTask).not.toHaveBeenCalled();
});
it("keeps non-graph plugin views on the fixed task-detail modal path", () => {
hostContexts.length = 0;
const openDetailTask = vi.fn();
const popOutTaskDetail = vi.fn();
render(
<MainContent
{...mainContentProps({
taskView: "plugin:example:dashboard",
graphPluginTaskView: null,
openDetailTask,
popOutTaskDetail,
})}
/>,
);
screen.getByText("Open from plugin bridge").click();
expect(openDetailTask).toHaveBeenCalledWith(graphTask, "logs");
expect(popOutTaskDetail).not.toHaveBeenCalled();
screen.getByText("Open rendered task card").click();
expect(openDetailTask).toHaveBeenCalledTimes(2);
expect(openDetailTask).toHaveBeenLastCalledWith(graphTask, undefined);
expect(popOutTaskDetail).not.toHaveBeenCalled();
});
it("uses the same graph pop-out path when rendered for mobile", () => {
const openDetailTask = vi.fn();
const popOutTaskDetail = vi.fn();
render(<MainContent {...mainContentProps({ isMobile: true, openDetailTask, popOutTaskDetail })} />);
screen.getByText("Open from plugin bridge").click();
expect(popOutTaskDetail).toHaveBeenCalledWith(graphTask);
expect(openDetailTask).not.toHaveBeenCalled();
});
it("dedupes repeat pop-outs by task id while allowing distinct task windows", () => {
const { result } = renderHook(() => usePoppedOutTasks());
act(() => result.current.popOut(graphTask));
act(() => result.current.popOut(graphTask));
expect(result.current.tasks).toHaveLength(1);
expect(result.current.tasks[0]?.id).toBe("FN-GRAPH");
act(() => result.current.popOut(otherTask));
expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-GRAPH", "FN-OTHER"]);
});
});