From 0ae44992dfbef67700902419e998976b7c842725 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 20:03:53 -0700 Subject: [PATCH] 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 --- .changeset/fn-7030-graph-task-popout.md | 7 + docs/dashboard-guide.md | 4 +- .../app/components/dashboard/MainContent.tsx | 15 +- .../MainContent.graph-popout.test.tsx | 274 ++++++++++++++++++ 4 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 .changeset/fn-7030-graph-task-popout.md create mode 100644 packages/dashboard/app/components/dashboard/__tests__/MainContent.graph-popout.test.tsx diff --git a/.changeset/fn-7030-graph-task-popout.md b/.changeset/fn-7030-graph-task-popout.md new file mode 100644 index 0000000000..305858d32a --- /dev/null +++ b/.changeset/fn-7030-graph-task-popout.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 1cbeee9cc1..a95a85b62b 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index 8ea6715dc1..43233d1e2e 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -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 ( {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) => ( openDetailTask(value)} + onOpenDetail={openPluginTaskDetail} addToast={addToast} workflowStepNameLookup={workflowStepNameLookup} disableDrag={true} diff --git a/packages/dashboard/app/components/dashboard/__tests__/MainContent.graph-popout.test.tsx b/packages/dashboard/app/components/dashboard/__tests__/MainContent.graph-popout.test.tsx new file mode 100644 index 0000000000..437ba42bf3 --- /dev/null +++ b/packages/dashboard/app/components/dashboard/__tests__/MainContent.graph-popout.test.tsx @@ -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 ( +
+ +
{task && context?.renderTaskCard?.(task)}
+
+ ); + }, +})); + +vi.mock("../../TaskCard", () => ({ + TaskCard: ({ task, onOpenDetail }: { task: Task | TaskDetail; onOpenDetail: (task: Task | TaskDetail) => void }) => ( + + ), +})); + +vi.mock("../../GraphWorkflowSwitcherSlot", () => ({ + GraphWorkflowSwitcherSlot: () =>
, + 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 { + 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(); + + 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( + , + ); + + 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(); + + 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"]); + }); +});