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
|
- 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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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