Files
fusion/packages/dashboard/app/components/useRightDockController.tsx
gsxdsm 7b358b74b5 feat(FN-7039): drive progress bar + Workflow tab from graph-written results
getUnifiedTaskProgress reads task.workflowStepResults (graph-written in U2) and
no longer needs workflowStepNameLookup — names come from result.workflowStepName.
Removed the lookup prop + fetchWorkflowSteps-name plumbing end-to-end (App,
MainContent, RightDock, Board, Column, Lane, WorktreeGroup, TaskCard). Added the
running state (pending + startedAt) and a distinct advisory_failure (amber, non-
blocking) vs failed (red) treatment; advisory_failure counts as complete.

Plan U3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:48:50 -07:00

162 lines
7.6 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
import type { ToastType } from "../hooks/useToast";
import type { DetailTaskTab } from "../hooks/useModalManager";
import { fetchTaskDetail } from "../api";
import { getScopedItem } from "../utils/projectStorage";
import { DOCK_FILES_CURRENT_KEY } from "./DockFilesView";
import { TaskCard } from "./TaskCard";
import { RightDock, persistRightDockOpen, readStoredRightDockOpen } from "./RightDock";
import { RightDockExpandModal } from "./RightDockExpandModal";
import type { OverflowViewKey, OverflowViewRenderProps, OverflowViewVisibilityOptions } from "./overflowViewRegistry";
export interface RightDockControllerInput {
active: boolean;
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
settingsLoaded: boolean;
researchReadinessVersion: number;
goalAnchorId?: string;
tasks: Array<Task | TaskDetail>;
workflowSteps: WorkflowStep[];
subscribePluginEvents: (pluginId: string, onEvent: (event: { event: string; payload: unknown }) => void) => () => void;
openDetailTask: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
openFileInBrowser: (path: string, opts?: { workspace?: string; line?: number; col?: number }) => void;
openSettings: (section?: string) => void;
onOpenUsage?: (anchorRect?: DOMRect | null) => void;
onOpenActivityLog?: () => void;
onOpenGitHubImport?: () => void;
onOpenGitManager?: () => void;
onOpenSchedules?: () => void;
onSendSelectionToTask: (description: string) => void;
onCreateTaskFromInsight: (payload: { insightId: string; title: string; description: string }) => Promise<void> | void;
onNavigateToMission: (missionId: string) => void;
onTaskCreated: (task: Task) => void;
prAuthAvailable: boolean;
autoMerge: boolean;
visibilityOptions: OverflowViewVisibilityOptions;
footerVisible: boolean;
}
export interface RightDockController {
open: boolean;
toggle: () => void;
dock: ReactNode;
modal: ReactNode;
}
/*
FNXC:Navigation 2026-06-21-23:40:
The right dock is visible by default and collapses from inside the dock. Keep the persisted open/collapsed state in this controller so App and Header do not need duplicate right-dock toggle wiring.
FNXC:RightDock 2026-06-22-18:50:
The popped-out expand modal is INDEPENDENT of the dock's open state. `expandedView` and the modal it drives live at the controller level (a sibling of `dock`, NOT a child of RightDock — which early-returns null when closed). Toggling the dock closed must therefore NOT clear `expandedView`: once a view is popped out it stays open and interactive even with the dock hidden, and only its own close button (`onClose -> setExpandedView(null)`) dismisses it. We still clear `expandedView` when the surface becomes inactive (project change/teardown) because that unmounts the whole controller surface, not a user dock-hide.
*/
export function useRightDockController(input: RightDockControllerInput): RightDockController {
const [open, setOpen] = useState(readStoredRightDockOpen);
const [expandedView, setExpandedView] = useState<OverflowViewKey | null>(null);
const toggle = useCallback(() => {
setOpen((current) => {
const next = !current;
persistRightDockOpen(next);
// FNXC:RightDock 2026-06-22-18:50: Do NOT clear expandedView on dock-hide; the floating pop-out is independent and survives the dock closing.
return next;
});
}, []);
/*
FNXC:RightDock 2026-06-22-19:25:
Popping a view out CLOSES the right dock but KEEPS the floating modal open. The modal is independent of dock open state (see expandedView note above), so collapsing the dock on pop-out gives the user the full-width app behind the movable, non-blocking modal. Clearing the pop-out (viewKey null) leaves the dock as-is.
*/
const handleExpand = useCallback((viewKey: OverflowViewKey | null) => {
/*
FNXC:RightDockFiles 2026-06-23-23:38:
If Files is showing an individual file, Expand should open the existing FileBrowserModal at that file instead of the generic right-dock expanded panel. The file modal is the shared movable/resizable file surface and keeps its transparent, non-blurring FloatingWindow backdrop; an empty Files view still expands to the two-pane browser.
*/
if (viewKey === "files") {
const currentFile = getScopedItem(DOCK_FILES_CURRENT_KEY, input.projectId);
if (currentFile) {
input.openFileInBrowser(currentFile, { workspace: "project" });
setOpen(false);
persistRightDockOpen(false);
setExpandedView(null);
return;
}
}
setExpandedView(viewKey);
if (viewKey) {
setOpen(false);
persistRightDockOpen(false);
}
}, [input]);
useEffect(() => {
if (!input.active) setExpandedView(null);
}, [input.active]);
const renderTaskCard = useCallback((task: Task | TaskDetail) => (
<TaskCard
task={task}
projectId={input.projectId}
onOpenDetail={(value: Task | TaskDetail) => input.openDetailTask(value)}
addToast={input.addToast}
disableDrag={true}
prAuthAvailable={input.prAuthAvailable}
autoMergeEnabled={input.autoMerge}
nearDuplicateCanonicalInactive={typeof task.sourceMetadata?.nearDuplicateOf === "string"
? isNearDuplicateCanonicalInactive(input.tasks.find((candidate) => candidate.id === task.sourceMetadata?.nearDuplicateOf))
: undefined}
/>
), [input]);
const renderProps = useMemo<OverflowViewRenderProps>(() => ({
projectId: input.projectId,
addToast: input.addToast,
settingsLoaded: input.settingsLoaded,
readinessVersion: input.researchReadinessVersion,
anchorGoalId: input.goalAnchorId,
tasks: input.tasks,
workflowSteps: input.workflowSteps,
pluginContext: {
projectId: input.projectId,
tasks: input.tasks as Task[],
workflowSteps: input.workflowSteps,
subscribePluginEvents: input.subscribePluginEvents,
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => input.openDetailTask(task, initialTab),
openFile: input.openFileInBrowser,
renderTaskCard,
addToast: input.addToast,
},
onOpenSettings: input.openSettings,
onOpenUsage: input.onOpenUsage,
onOpenActivityLog: input.onOpenActivityLog,
onOpenGitHubImport: input.onOpenGitHubImport,
onOpenGitManager: input.onOpenGitManager,
onOpenSchedules: input.onOpenSchedules,
onOpenTaskDetail: (taskId: string) => {
void fetchTaskDetail(taskId, input.projectId)
.then((task) => input.openDetailTask(task as TaskDetail))
.catch((error) => input.addToast(error instanceof Error ? error.message : "Failed to open task detail", "error"));
},
onOpenDetail: input.openDetailTask,
onSendSelectionToTask: input.onSendSelectionToTask,
onCreateTaskFromInsight: input.onCreateTaskFromInsight,
onNavigateToMission: input.onNavigateToMission,
onPlanningMode: input.onSendSelectionToTask,
onTaskCreated: input.onTaskCreated,
renderTaskCard,
subscribePluginEvents: input.subscribePluginEvents,
openFile: input.openFileInBrowser,
}), [input, renderTaskCard]);
return {
open,
toggle,
dock: input.active ? <RightDock open={open} renderProps={renderProps} visibilityOptions={input.visibilityOptions} footerVisible={input.footerVisible} onExpand={handleExpand} /> : null,
modal: input.active ? <RightDockExpandModal viewKey={expandedView} renderProps={renderProps} visibilityOptions={input.visibilityOptions} onClose={() => setExpandedView(null)} /> : null,
};
}