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:
7
.changeset/fn-7030-graph-task-popout.md
Normal file
7
.changeset/fn-7030-graph-task-popout.md
Normal 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.
|
||||
@@ -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
|
||||
- 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
|
||||
- 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
|
||||
- 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.
|
||||
- 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 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
|
||||
- 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
|
||||
|
||||
@@ -254,6 +254,17 @@ export function MainContent({
|
||||
const pluginContextTasks = isDependencyGraphView
|
||||
? filterTasksByGraphWorkflowSelection(pluginTasks, currentProject?.id, graphWorkflowSelection)
|
||||
: 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 (
|
||||
<PageErrorBoundary>
|
||||
{isDependencyGraphView ? (
|
||||
@@ -271,13 +282,13 @@ export function MainContent({
|
||||
tasks: pluginContextTasks,
|
||||
workflowSteps,
|
||||
subscribePluginEvents,
|
||||
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => openDetailTask(task, initialTab),
|
||||
openTaskDetail: openPluginTaskDetail,
|
||||
openFile: openFileInBrowser,
|
||||
renderTaskCard: (task: Task | TaskDetail) => (
|
||||
<TaskCard
|
||||
task={task}
|
||||
projectId={currentProject?.id}
|
||||
onOpenDetail={(value: Task | TaskDetail) => openDetailTask(value)}
|
||||
onOpenDetail={openPluginTaskDetail}
|
||||
addToast={addToast}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
disableDrag={true}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user