diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 7427aa9a82..1d86040914 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -31,6 +31,16 @@ The footer collapse toggle uses the same row styling as other sidebar items: exp On mobile viewports (`<=768px`), the sidebar is not rendered even when the default-on setting is enabled. The existing bottom `MobileNavBar` remains the navigation surface. +## Right Dock (experimental, default on) + +The **Right Dock Panel** experiment is enabled by default. To disable it, open **Settings → Experimental Features** and turn off **Right Dock Panel**. + +When enabled on desktop or tablet project screens, the Header **More views** three-dots control becomes a right-panel toggle. It opens a persistent dock on the right side of the project content instead of the overflow dropdown, and the icon changes to communicate the panel toggle behavior. If Left Sidebar Navigation is also enabled and the Header view-toggle row is hidden, the same right-dock toggle remains available as a standalone Header icon. + +The dock toolbar includes Files plus the same overflow destinations that would appear in the Header overflow menu, including gated experimental/plugin views only when their own flags or plugins are active. The dock opens to **Files** by default, then restores the last selected dock view from browser storage on later visits. Each docked view has an expand button that opens the same view in a resizable modal, and both the dock width and expanded modal size persist across reloads. + +On mobile viewports, the Right Dock never renders. The compact Header overflow and bottom `MobileNavBar` keep their existing behavior even when the experiment is enabled. + ## Deep Links Use deep links to open a specific task directly from notifications, chat, or external tools. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index a2c39ea2a9..076ca46dc5 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -45,6 +45,7 @@ import { import type { SectionId } from "./components/SettingsModal"; import { MobileNavBar } from "./components/MobileNavBar"; import { LeftSidebarNav } from "./components/LeftSidebarNav"; +import { useRightDockController } from "./components/useRightDockController"; import { QuickChatFAB } from "./components/QuickChatFAB"; import { ToastContainer } from "./components/ToastContainer"; import { useBackgroundSessions } from "./hooks/useBackgroundSessions"; @@ -1037,7 +1038,10 @@ function AppInner() { Left sidebar navigation is now the default primary navigation on non-mobile project screens. Keep `leftSidebarNav: false` as the explicit opt-out and keep mobile on the bottom navigation bar. */ const leftSidebarNavEnabled = experimentalFeatures.leftSidebarNav !== false; + /* FNXC:Navigation 2026-06-21-00:00: The default-on right dock makes tablet/desktop More views toggle a persistent right panel unless settings store `rightDock: false`; mobile remains legacy. */ + const rightDockEnabled = experimentalFeatures.rightDock !== false; const executorFooterVisible = viewMode === "project" && !!currentProject; + const rightDockActive = rightDockEnabled && !isMobile && executorFooterVisible; const sidebarActive = leftSidebarNavEnabled && !isMobile && executorFooterVisible; const agentOnboardingEnabled = experimentalFeatures.agentOnboarding === true; const agentsEnabled = true; @@ -1919,6 +1923,7 @@ function AppInner() { // Top progress bar reflects any in-flight revalidation: projects, current-project, or tasks. // Add new sources here, not inside TopProgressBar. const isRevalidating = projectsLoading || currentProjectLoading || isStale; + const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openFileInBrowser, openSettings: (section?: string) => modalManager.openSettings(section as SectionId), onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), workflowStepNameLookup, prAuthAvailable, autoMerge, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, todosEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible }); return ( @@ -1974,6 +1979,7 @@ function AppInner() { projectId={currentProject?.id} mobileNavEnabled={isMobile} leftSidebarNavActive={sidebarActive} + rightDockActive={rightDockActive} rightDockOpen={rightDock.open} onToggleRightDock={rightDock.toggle} // Node switching props availableNodes={nodes} currentNode={currentNode} @@ -1994,6 +2000,7 @@ function AppInner() { evalsView: evalsEnabled, goalsView: goalsEnabled, leftSidebarNav: leftSidebarNavEnabled, + rightDock: rightDockEnabled, }} pluginDashboardViews={pluginDashboardViews} shellConnectionControl={ @@ -2112,11 +2119,7 @@ function AppInner() { }} /> )} - {/* - FNXC:Navigation 2026-06-19-00:00: - The left sidebar experiment wraps only the project content region on non-mobile project screens; mobile keeps MobileNavBar as the navigation owner and the flag leaves project-content unwrapped when inactive. - */} -
+
{sidebarActive && ( {renderMainContent()}
+ {rightDock.dock}
+ {rightDock.modal} {executorFooterVisible && currentProject && ( 0 ? remoteData.tasks : tasks} diff --git a/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx b/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx index 916c98268c..7638043881 100644 --- a/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx +++ b/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx @@ -276,6 +276,59 @@ describe("Mobile Feature Access Regression Guard", () => { } }); + it("right dock reroutes desktop and tablet More views without leaving the dropdown or chevron behind", () => { + for (const tier of ["desktop", "tablet"] as const) { + mockViewport(tier); + const onToggleRightDock = vi.fn(); + const { unmount } = render( +
, + ); + + const trigger = screen.getByTestId("view-toggle-overflow-trigger"); + expect(trigger.querySelector(".lucide-panel-right")).toBeTruthy(); + expect(trigger.querySelector(".lucide-chevron-down")).toBeNull(); + fireEvent.click(trigger); + expect(onToggleRightDock).toHaveBeenCalledOnce(); + expect(screen.queryByRole("menu", { name: "More views" })).toBeNull(); + unmount(); + } + }); + + it("right dock stays reachable as one standalone toggle when left sidebar nav is active", () => { + for (const tier of ["desktop", "tablet"] as const) { + mockViewport(tier); + const onToggleRightDock = vi.fn(); + const { unmount } = render( +
, + ); + + const triggers = screen.getAllByTestId("view-toggle-overflow-trigger"); + expect(triggers).toHaveLength(1); + expect(triggers[0].querySelector(".lucide-panel-right")).toBeTruthy(); + expect(triggers[0]).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(triggers[0]); + expect(onToggleRightDock).toHaveBeenCalledOnce(); + unmount(); + } + }); + it("desktop and tablet header view navigation remains intact when left sidebar is inactive", () => { for (const tier of ["desktop", "tablet"] as const) { mockViewport(tier); @@ -295,6 +348,31 @@ describe("Mobile Feature Access Regression Guard", () => { } }); + it("right dock flag off keeps the desktop and tablet More views chevron dropdown", () => { + for (const tier of ["desktop", "tablet"] as const) { + mockViewport(tier); + const onToggleRightDock = vi.fn(); + const { unmount } = render( +
, + ); + + const trigger = screen.getByTestId("view-toggle-overflow-trigger"); + expect(trigger.querySelector(".lucide-chevron-down")).toBeTruthy(); + fireEvent.click(trigger); + expect(onToggleRightDock).not.toHaveBeenCalled(); + expect(screen.getByRole("menu", { name: "More views" })).toBeInTheDocument(); + unmount(); + } + }); + it("left sidebar app gate renders by default on desktop and tablet, honors explicit opt-out, and never renders on mobile", () => { /* * Surface Enumeration checklist asserted here: diff --git a/packages/dashboard/app/components/Header.tsx b/packages/dashboard/app/components/Header.tsx index fe3f76ec4c..dcd2a2a1c3 100644 --- a/packages/dashboard/app/components/Header.tsx +++ b/packages/dashboard/app/components/Header.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { Settings, Play, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge } from "lucide-react"; +import { Settings, Play, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge, PanelRight } from "lucide-react"; import "./Header.css"; // ProjectSelector styles used by the imported standalone component. import "./ProjectSelector.css"; @@ -110,6 +110,10 @@ export interface HeaderProps { mobileNavEnabled?: boolean; /** When true on non-mobile screens, persistent left sidebar owns primary view navigation. */ leftSidebarNavActive?: boolean; + /** When true on tablet/desktop, the More views overflow trigger toggles the auxiliary right dock instead of a menu. */ + rightDockActive?: boolean; + rightDockOpen?: boolean; + onToggleRightDock?: () => void; /** Available nodes for the node selector */ availableNodes?: NodeConfig[]; /** Currently selected node (null for local) */ @@ -119,7 +123,7 @@ export interface HeaderProps { /** Whether the current view is a remote node */ isRemote?: boolean; /** Experimental feature flags controlling visibility of nav items. */ - experimentalFeatures?: { insights?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean; evalsView?: boolean; goalsView?: boolean; leftSidebarNav?: boolean }; + experimentalFeatures?: { insights?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean; evalsView?: boolean; goalsView?: boolean; leftSidebarNav?: boolean; rightDock?: boolean }; pluginDashboardViews?: PluginDashboardViewEntry[]; shellConnectionControl?: ReactNode; } @@ -166,6 +170,9 @@ export function Header({ shellHost = { kind: "browser" }, mobileNavEnabled, leftSidebarNavActive = false, + rightDockActive = false, + rightDockOpen = false, + onToggleRightDock, availableNodes = [], currentNode, onSelectNode, @@ -188,6 +195,7 @@ export function Header({ The hidden Header view-toggle location becomes the workflow-control portal slot only when left sidebar navigation is active on tablet/desktop. Mobile and flag-off paths keep workflow controls inline so the board/list chrome remains byte-identical. */ const hideHeaderViewNav = leftSidebarNavActive && !isMobile; + const shouldRouteMoreViewsToRightDock = rightDockActive && !isMobile && typeof onToggleRightDock === "function"; const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); const [isNonMobileSearchOpen, setIsNonMobileSearchOpen] = useState(false); // Track when user has explicitly closed the search (used for toggle visibility) @@ -948,6 +956,25 @@ export function Header({ /> )} + {/* + FNXC:Navigation 2026-06-21-00:00: + The default-on right dock changes the tablet/desktop More views affordance into a true panel toggle, so the icon must communicate a right panel and expose pressed/expanded state. When left-sidebar navigation hides Header view tabs, this standalone control keeps the dock reachable without duplicating the hidden overflow trigger; mobile and flag-off paths keep the legacy chevron menu. + */} + {hideHeaderViewNav && shouldRouteMoreViewsToRightDock && ( + + )} + {/** * FNXC:Header 2026-06-21-00:00: * Desktop and tablet header search must render after the workflow portal slot so a populated WorkflowSwitcher appears left of the search icon while preserving the mobile search trigger's existing position and behavior. @@ -1085,17 +1112,29 @@ export function Header({ <> - {isViewOverflowOpen && ( + {!shouldRouteMoreViewsToRightDock && isViewOverflowOpen && (
)} - {/* Settings - always inline on desktop; engine controls now live in the footer status bar. */} - {!isCompact && ( + {/* + FNXC:Navigation 2026-06-21-13:48: + Left sidebar navigation owns desktop Settings when active, so Header hides its duplicate icon to preserve a single titled Settings control for users and navigation-history tests. + */} + {!isCompact && !leftSidebarNavActive && ( diff --git a/packages/dashboard/app/components/RightDock.css b/packages/dashboard/app/components/RightDock.css new file mode 100644 index 0000000000..38f55e8594 --- /dev/null +++ b/packages/dashboard/app/components/RightDock.css @@ -0,0 +1,165 @@ +/* +FNXC:Navigation 2026-06-21-00:00: +The right dock CSS uses a mobile media query as a belt-and-suspenders guard only. The authoritative mobile gate is the JS `rightDockActive` value from `useViewportMode`, which also covers phone classes that a width-only query cannot classify reliably. +*/ +.right-dock { + position: relative; + display: flex; + flex: 0 0 auto; + flex-direction: column; + min-width: min(100%, var(--right-dock-min-width, calc(var(--space-2xl) * 8))); + max-width: min(100%, var(--right-dock-max-width, calc(var(--space-2xl) * 22))); + min-height: 0; + background: var(--surface); + border-left: thin solid var(--border); + color: var(--text); + box-shadow: var(--shadow-lg); +} + +.right-dock--with-footer { + padding-bottom: var(--executor-footer-height); +} + +.right-dock__resize-handle { + position: absolute; + inset-block: 0; + inset-inline-start: calc(var(--space-xs) * -1); + z-index: 2; + width: var(--space-sm); + cursor: col-resize; + touch-action: none; +} + +.right-dock__resize-handle::before { + position: absolute; + inset-block: 0; + inset-inline-start: calc(var(--space-xs) - var(--btn-border-width)); + width: var(--btn-border-width); + background: transparent; + content: ""; + transition: background var(--transition-fast); +} + +.right-dock__resize-handle:hover::before, +.right-dock__resize-handle:focus-visible::before { + background: var(--todo); +} + +.right-dock__resize-handle:focus-visible { + outline: none; +} + +.right-dock__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + padding: var(--space-sm); + border-bottom: thin solid var(--border); +} + +.right-dock__tabs, +.right-dock__actions { + display: flex; + align-items: center; + gap: var(--space-xs); + min-width: 0; +} + +.right-dock__tabs { + flex: 1; + overflow-x: auto; + scrollbar-width: thin; +} + +.right-dock__tab { + flex-shrink: 0; + color: var(--text-muted); +} + +.right-dock__tab--active, +.right-dock__tab[aria-selected="true"] { + background: var(--status-todo-bg); + color: var(--todo); +} + +.right-dock__header { + display: flex; + flex-shrink: 0; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + border-bottom: thin solid var(--border); + color: var(--text); +} + +.right-dock__title { + min-width: 0; + margin: 0; + overflow: hidden; + color: inherit; + font: inherit; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.right-dock__body { + display: flex; + flex: 1; + min-height: 0; + min-width: 0; + overflow: auto; +} + +.right-dock__body > * { + flex: 1; + min-width: 0; +} + +.right-dock-expand-modal { + display: flex; + flex-direction: column; + width: min(90vw, calc(var(--space-2xl) * 36)); + height: min(85vh, calc(var(--space-2xl) * 24)); + min-width: min(90vw, calc(var(--space-2xl) * 12)); + min-height: min(85vh, calc(var(--space-2xl) * 10)); + max-width: 95vw; + max-height: 90vh; + resize: both; + overflow: hidden; +} + +.right-dock-expand-modal__header, +.right-dock-expand-modal__title { + display: flex; + align-items: center; + gap: var(--space-sm); + min-width: 0; +} + +.right-dock-expand-modal__title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.right-dock-expand-modal__body { + display: flex; + flex: 1; + min-height: 0; + min-width: 0; + overflow: auto; +} + +.right-dock-expand-modal__body > * { + flex: 1; + min-width: 0; +} + +@media (max-width: 768px) { + .right-dock { + display: none; + } +} diff --git a/packages/dashboard/app/components/RightDock.tsx b/packages/dashboard/app/components/RightDock.tsx new file mode 100644 index 0000000000..85d30bca36 --- /dev/null +++ b/packages/dashboard/app/components/RightDock.tsx @@ -0,0 +1,234 @@ +import { useCallback, useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { Maximize2, X } from "lucide-react"; +import { + findOverflowViewEntry, + getVisibleOverflowViewEntries, + isOverflowViewKeyVisible, + type OverflowViewKey, + type OverflowViewRenderProps, + type OverflowViewVisibilityOptions, +} from "./overflowViewRegistry"; +import "./RightDock.css"; + +export const RIGHT_DOCK_DEFAULT_WIDTH = 360; +export const RIGHT_DOCK_MIN_WIDTH = 280; +export const RIGHT_DOCK_MAX_WIDTH = 720; +export const RIGHT_DOCK_WIDTH_STORAGE_KEY = "fusion:right-dock-width"; +export const RIGHT_DOCK_VIEW_STORAGE_KEY = "fusion:right-dock-view"; +export const RIGHT_DOCK_OPEN_STORAGE_KEY = "fusion:right-dock-open"; + +function clampRightDockWidth(width: number): number { + return Math.max(RIGHT_DOCK_MIN_WIDTH, Math.min(RIGHT_DOCK_MAX_WIDTH, width)); +} + +export function readStoredRightDockWidth(): number { + if (typeof window === "undefined") return RIGHT_DOCK_DEFAULT_WIDTH; + const stored = window.localStorage.getItem(RIGHT_DOCK_WIDTH_STORAGE_KEY); + const parsed = stored ? Number(stored) : NaN; + return Number.isFinite(parsed) ? clampRightDockWidth(parsed) : RIGHT_DOCK_DEFAULT_WIDTH; +} + +export function readStoredRightDockOpen(): boolean { + if (typeof window === "undefined") return false; + return window.localStorage.getItem(RIGHT_DOCK_OPEN_STORAGE_KEY) === "true"; +} + +export function persistRightDockOpen(open: boolean): void { + try { + window.localStorage.setItem(RIGHT_DOCK_OPEN_STORAGE_KEY, String(open)); + } catch { + // Ignore storage errors. + } +} + +function readStoredRightDockView(options: OverflowViewVisibilityOptions): OverflowViewKey { + if (typeof window === "undefined") return "files"; + const stored = window.localStorage.getItem(RIGHT_DOCK_VIEW_STORAGE_KEY); + return stored && isOverflowViewKeyVisible(stored, options) ? stored : "files"; +} + +function persistRightDockWidth(width: number): void { + try { + window.localStorage.setItem(RIGHT_DOCK_WIDTH_STORAGE_KEY, String(width)); + } catch { + // Ignore storage errors. + } +} + +function persistRightDockView(key: OverflowViewKey): void { + try { + window.localStorage.setItem(RIGHT_DOCK_VIEW_STORAGE_KEY, key); + } catch { + // Ignore storage errors. + } +} + +export interface RightDockProps { + open: boolean; + onOpenChange: (open: boolean) => void; + renderProps: OverflowViewRenderProps; + visibilityOptions?: OverflowViewVisibilityOptions; + onExpand?: (key: OverflowViewKey) => void; + footerVisible?: boolean; +} + +/* +FNXC:Navigation 2026-06-21-00:00: +The right dock is an auxiliary tablet/desktop surface: it remembers the last overflow destination, starts on Files when none is valid, and resizes from its left edge without changing the canonical Header/MobileNavBar active navigation state. +*/ +export function RightDock({ + open, + onOpenChange, + renderProps, + visibilityOptions = {}, + onExpand, + footerVisible = false, +}: RightDockProps) { + const entries = useMemo(() => getVisibleOverflowViewEntries(visibilityOptions), [visibilityOptions]); + const [selectedKey, setSelectedKey] = useState(() => readStoredRightDockView(visibilityOptions)); + const [width, setWidth] = useState(readStoredRightDockWidth); + + useEffect(() => { + if (!isOverflowViewKeyVisible(selectedKey, visibilityOptions)) { + setSelectedKey("files"); + persistRightDockView("files"); + } + }, [selectedKey, visibilityOptions]); + + const selectedEntry = findOverflowViewEntry(selectedKey, visibilityOptions) ?? entries[0]; + + const selectEntry = useCallback((key: OverflowViewKey) => { + setSelectedKey(key); + persistRightDockView(key); + }, []); + + const closeDock = useCallback(() => { + persistRightDockOpen(false); + onOpenChange(false); + }, [onOpenChange]); + + const handleResizeStart = useCallback((event: React.PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const resizeHandle = event.currentTarget; + if (typeof resizeHandle.setPointerCapture === "function") { + resizeHandle.setPointerCapture(event.pointerId); + } + + const startX = event.clientX; + const startWidth = width; + let latestWidth = startWidth; + document.body.style.userSelect = "none"; + + const onPointerMove = (moveEvent: PointerEvent) => { + const nextWidth = clampRightDockWidth(startWidth + startX - moveEvent.clientX); + latestWidth = nextWidth; + setWidth(nextWidth); + }; + + const onPointerUp = (upEvent: PointerEvent) => { + if (typeof resizeHandle.releasePointerCapture === "function") { + resizeHandle.releasePointerCapture(upEvent.pointerId); + } + document.body.style.userSelect = ""; + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + persistRightDockWidth(latestWidth); + }; + + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", onPointerUp); + }, [width]); + + const handleResizeKeyDown = useCallback((event: ReactKeyboardEvent) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + const step = event.shiftKey ? 48 : 16; + const delta = event.key === "ArrowLeft" ? step : -step; + const nextWidth = clampRightDockWidth(width + delta); + setWidth(nextWidth); + persistRightDockWidth(nextWidth); + }, [width]); + + if (!open || !selectedEntry) { + return null; + } + + const SelectedIcon = selectedEntry.icon; + + return ( + + ); +} diff --git a/packages/dashboard/app/components/RightDockExpandModal.tsx b/packages/dashboard/app/components/RightDockExpandModal.tsx new file mode 100644 index 0000000000..0f93ca5e55 --- /dev/null +++ b/packages/dashboard/app/components/RightDockExpandModal.tsx @@ -0,0 +1,70 @@ +import { useEffect, useRef, type RefObject } from "react"; +import { Maximize2, X } from "lucide-react"; +import { findOverflowViewEntry, type OverflowViewKey, type OverflowViewRenderProps, type OverflowViewVisibilityOptions } from "./overflowViewRegistry"; +import { useModalResizePersist } from "../hooks/useModalResizePersist"; +import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; +import "./RightDock.css"; + +const RIGHT_DOCK_EXPAND_MODAL_SIZE_STORAGE_KEY = "fusion:right-dock-expand-modal-size"; + +export interface RightDockExpandModalProps { + viewKey: OverflowViewKey | null; + renderProps: OverflowViewRenderProps; + visibilityOptions?: OverflowViewVisibilityOptions; + onClose: () => void; + returnFocusRef?: RefObject; +} + +/* +FNXC:Navigation 2026-06-21-00:00: +Expanded right-dock views reuse the same overflow registry render function as the dock body, so expanding changes only available space and never swaps to a divergent component or prop contract. +*/ +export function RightDockExpandModal({ + viewKey, + renderProps, + visibilityOptions = {}, + onClose, + returnFocusRef, +}: RightDockExpandModalProps) { + const modalRef = useRef(null); + const entry = viewKey ? findOverflowViewEntry(viewKey, visibilityOptions) : undefined; + const closeAndRestoreFocus = () => { + onClose(); + window.setTimeout(() => returnFocusRef?.current?.focus(), 0); + }; + const overlayDismissProps = useOverlayDismiss(closeAndRestoreFocus); + useModalResizePersist(modalRef, Boolean(entry), RIGHT_DOCK_EXPAND_MODAL_SIZE_STORAGE_KEY); + + useEffect(() => { + if (entry) return undefined; + return () => { + returnFocusRef?.current?.focus(); + }; + }, [entry, returnFocusRef]); + + if (!entry) { + return null; + } + + const Icon = entry.icon; + + return ( +
+
+
+
+ + + {entry.label} +
+ +
+
+ {entry.render(renderProps)} +
+
+
+ ); +} diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 75f310a9d2..e9b459bcca 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -277,6 +277,7 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record = { evalsView: "Evals View", goalsView: "Goals View", leftSidebarNav: "Left Sidebar Navigation", + rightDock: "Right Dock Panel", sandbox: "Sandbox (command isolation)", chatRooms: "Chat Rooms", agentOnboarding: "Planning-style Agent Onboarding", @@ -286,9 +287,9 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record = { /* FNXC:Navigation 2026-06-21-00:00: -The dashboard owns the left sidebar default-on rollout because the shared experimental-feature helper must keep default-off semantics for unrelated experiments. Keep this set local to Settings so the toggle checked-state matches App's `leftSidebarNav !== false` derivation without changing core behavior. +The dashboard owns the left sidebar and right dock default-on rollout because the shared experimental-feature helper must keep default-off semantics for unrelated experiments. Keep this set local to Settings so toggle checked-state matches App's `leftSidebarNav !== false` and `rightDock !== false` derivations without changing core behavior. */ -const DEFAULT_ON_EXPERIMENTAL_FEATURES = new Set(["leftSidebarNav"]); +const DEFAULT_ON_EXPERIMENTAL_FEATURES = new Set(["leftSidebarNav", "rightDock"]); const EXPERIMENTAL_FEATURE_LEGACY_ALIASES: Record = { devServer: "devServerView", @@ -297,15 +298,11 @@ const EXPERIMENTAL_FEATURE_LEGACY_ALIASES: Record = { function getCanonicalExperimentalFeatureKey(key: string): string { return EXPERIMENTAL_FEATURE_LEGACY_ALIASES[key] ?? key; } - function isExperimentalFeatureEnabled(features: Record, key: string): boolean { - if (features[key] === true) { - return true; - } - - return Object.entries(EXPERIMENTAL_FEATURE_LEGACY_ALIASES).some( - ([legacyKey, canonicalKey]) => canonicalKey === key && features[legacyKey] === true, - ); + if (features[key] === true) return true; + if (features[key] === false) return false; + if (Object.entries(EXPERIMENTAL_FEATURE_LEGACY_ALIASES).some(([legacyKey, canonicalKey]) => canonicalKey === key && features[legacyKey] === true)) return true; + return DEFAULT_ON_EXPERIMENTAL_FEATURES.has(key); } function isDashboardExperimentalFeatureEnabled(features: Record, key: string): boolean { diff --git a/packages/dashboard/app/components/__tests__/Header.test.tsx b/packages/dashboard/app/components/__tests__/Header.test.tsx index 844a3a1ba3..36baa0dbdf 100644 --- a/packages/dashboard/app/components/__tests__/Header.test.tsx +++ b/packages/dashboard/app/components/__tests__/Header.test.tsx @@ -256,6 +256,76 @@ describe("Header", () => { expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument(); }); + it.each(["desktop", "tablet"] as const)("routes More views to the right dock panel toggle on %s", (tier) => { + const onToggleRightDock = vi.fn(); + const { rerender } = renderHeader({ + onChangeView: noop, + rightDockActive: true, + rightDockOpen: false, + onToggleRightDock, + }, tier); + + const trigger = screen.getByTestId("view-toggle-overflow-trigger"); + expect(trigger.querySelector(".lucide-panel-right")).toBeTruthy(); + expect(trigger.querySelector(".lucide-chevron-down")).toBeNull(); + expect(trigger).toHaveAttribute("aria-pressed", "false"); + expect(trigger).not.toHaveAttribute("aria-haspopup"); + + fireEvent.click(trigger); + expect(onToggleRightDock).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("menu", { name: "More views" })).toBeNull(); + + rerender( +
, + ); + expect(screen.getByTestId("view-toggle-overflow-trigger")).toHaveAttribute("aria-pressed", "true"); + }); + + it.each(["desktop", "tablet"] as const)("keeps one standalone right dock toggle when left sidebar hides view nav on %s", (tier) => { + const onToggleRightDock = vi.fn(); + renderHeader({ + onChangeView: noop, + leftSidebarNavActive: true, + rightDockActive: true, + rightDockOpen: true, + onToggleRightDock, + }, tier); + + const triggers = screen.getAllByTestId("view-toggle-overflow-trigger"); + expect(triggers).toHaveLength(1); + expect(triggers[0].querySelector(".lucide-panel-right")).toBeTruthy(); + expect(triggers[0]).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(triggers[0]); + expect(onToggleRightDock).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("menu", { name: "More views" })).toBeNull(); + }); + + it("keeps the legacy chevron dropdown on mobile even when right dock props are present", () => { + const onToggleRightDock = vi.fn(); + renderHeader({ + onChangeView: noop, + mobileNavEnabled: false, + rightDockActive: true, + rightDockOpen: false, + onToggleRightDock, + }, "mobile"); + + const trigger = screen.getByTestId("view-toggle-overflow-trigger"); + expect(trigger.querySelector(".lucide-chevron-down")).toBeTruthy(); + expect(trigger.querySelector(".lucide-panel-right")).toBeNull(); + expect(trigger).toHaveAttribute("aria-haspopup", "menu"); + fireEvent.click(trigger); + expect(onToggleRightDock).not.toHaveBeenCalled(); + expect(screen.getByRole("menu", { name: "More views" })).toBeInTheDocument(); + }); + it("shows secrets in overflow and routes to secrets view", () => { const onChangeView = vi.fn(); renderHeader({ onChangeView, view: "board" }); diff --git a/packages/dashboard/app/components/__tests__/RightDock.test.tsx b/packages/dashboard/app/components/__tests__/RightDock.test.tsx new file mode 100644 index 0000000000..265a0f9fd8 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/RightDock.test.tsx @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { RightDock, RIGHT_DOCK_OPEN_STORAGE_KEY, RIGHT_DOCK_VIEW_STORAGE_KEY, RIGHT_DOCK_WIDTH_STORAGE_KEY } from "../RightDock"; +import { RightDockExpandModal } from "../RightDockExpandModal"; + +vi.mock("../../api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWorkspaceFileList: vi.fn().mockResolvedValue({ entries: [], currentPath: "." }), + }; +}); + +vi.mock("../DocumentsView", () => ({ DocumentsView: () =>
})); +vi.mock("../ResearchView", () => ({ ResearchView: () =>
})); +vi.mock("../InsightsView", () => ({ InsightsView: () =>
})); +vi.mock("../EvalsView", () => ({ EvalsView: () =>
})); +vi.mock("../SkillsView", () => ({ SkillsView: () =>
})); +vi.mock("../MemoryView", () => ({ MemoryView: () =>
})); +vi.mock("../SecretsView", () => ({ SecretsView: () =>
})); +vi.mock("../DevServerView", () => ({ DevServerView: () =>
})); +vi.mock("../TodoView", () => ({ TodoView: () =>
})); +vi.mock("../GoalsView", () => ({ GoalsView: () =>
})); +vi.mock("../StashRecoveryView", () => ({ StashRecoveryView: () =>
})); + +const renderProps = { + addToast: vi.fn(), + projectId: "project-1", +}; + +describe("RightDock", () => { + beforeEach(() => { + window.localStorage.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it("renders Files by default and restores a persisted selected view", () => { + const onOpenChange = vi.fn(); + const { unmount } = render( + , + ); + + expect(screen.getByTestId("right-dock-tab-files")).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("right-dock-files-view")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("right-dock-tab-secrets")); + expect(window.localStorage.getItem(RIGHT_DOCK_VIEW_STORAGE_KEY)).toBe("secrets"); + unmount(); + + render(); + expect(screen.getByTestId("right-dock-tab-secrets")).toHaveAttribute("aria-selected", "true"); + }); + + it("hides entries gated off by their matching Header flags", () => { + render( + , + ); + + expect(screen.getByTestId("right-dock-tab-files")).toBeInTheDocument(); + expect(screen.getByTestId("right-dock-tab-documents")).toBeInTheDocument(); + expect(screen.getByTestId("right-dock-tab-secrets")).toBeInTheDocument(); + expect(screen.getByTestId("right-dock-tab-stash-recovery")).toBeInTheDocument(); + expect(screen.queryByTestId("right-dock-tab-research")).toBeNull(); + expect(screen.queryByTestId("right-dock-tab-insights")).toBeNull(); + }); + + it("closes internally and clamps then persists resize width", () => { + const onOpenChange = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId("right-dock-close")); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(window.localStorage.getItem(RIGHT_DOCK_OPEN_STORAGE_KEY)).toBe("false"); + + const handle = screen.getByTestId("right-dock-resize-handle"); + fireEvent.pointerDown(handle, { pointerId: 1, clientX: 900 }); + fireEvent.pointerMove(document, { pointerId: 1, clientX: 0 }); + fireEvent.pointerUp(document, { pointerId: 1, clientX: 0 }); + expect(window.localStorage.getItem(RIGHT_DOCK_WIDTH_STORAGE_KEY)).toBe("720"); + + fireEvent.keyDown(handle, { key: "ArrowRight", shiftKey: true }); + expect(window.localStorage.getItem(RIGHT_DOCK_WIDTH_STORAGE_KEY)).toBe("672"); + }); + + it("restores persisted width on mount", () => { + window.localStorage.setItem(RIGHT_DOCK_WIDTH_STORAGE_KEY, "400"); + render(); + + expect(screen.getByTestId("right-dock")).toHaveStyle({ width: "400px" }); + expect(screen.getByTestId("right-dock-resize-handle")).toHaveAttribute("aria-valuenow", "400"); + }); + + it("mounts every visible static registry view in the dock body without crashing", async () => { + render( + , + ); + + const expectedViews: Array<[string, string]> = [ + ["right-dock-tab-files", "right-dock-files-view"], + ["right-dock-tab-documents", "mock-documents-view"], + ["right-dock-tab-research", "mock-research-view"], + ["right-dock-tab-insights", "mock-insights-view"], + ["right-dock-tab-skills", "mock-skills-view"], + ["right-dock-tab-memory", "mock-memory-view"], + ["right-dock-tab-secrets", "mock-secrets-view"], + ["right-dock-tab-stash-recovery", "mock-stash-recovery-view"], + ["right-dock-tab-evals", "mock-evals-view"], + ["right-dock-tab-goals", "mock-goals-view"], + ["right-dock-tab-todos", "mock-todos-view"], + ["right-dock-tab-devserver", "mock-devserver-view"], + ]; + + for (const [tabId, bodyId] of expectedViews) { + fireEvent.click(screen.getByTestId(tabId)); + expect(await screen.findByTestId(bodyId)).toBeInTheDocument(); + } + }); + + it("renders the expanded modal through the same registry and restores focus on close", async () => { + const onClose = vi.fn(); + const focusButton = document.createElement("button"); + document.body.appendChild(focusButton); + const focusSpy = vi.spyOn(focusButton, "focus"); + + render( + , + ); + + expect(screen.getByTestId("right-dock-expand-modal")).toBeInTheDocument(); + expect(screen.getByTestId("right-dock-expand-body")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("right-dock-expand-close")); + expect(onClose).toHaveBeenCalledTimes(1); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + expect(focusSpy).toHaveBeenCalled(); + focusButton.remove(); + }); + + it("restores the expanded modal's persisted size", () => { + window.localStorage.setItem("fusion:right-dock-expand-modal-size", JSON.stringify({ width: 640, height: 480 })); + render( + , + ); + + expect(screen.getByTestId("right-dock-expand-modal").querySelector(".right-dock-expand-modal")).toHaveStyle({ + width: "640px", + height: "480px", + }); + }); + + it("fires expand for the selected entry", () => { + const onExpand = vi.fn(); + render(); + fireEvent.click(screen.getByTestId("right-dock-tab-secrets")); + fireEvent.click(screen.getByTestId("right-dock-expand")); + expect(onExpand).toHaveBeenCalledWith("secrets"); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/navigation-history.test.tsx b/packages/dashboard/app/components/__tests__/navigation-history.test.tsx index d13b18d083..33ee24ab6d 100644 --- a/packages/dashboard/app/components/__tests__/navigation-history.test.tsx +++ b/packages/dashboard/app/components/__tests__/navigation-history.test.tsx @@ -25,7 +25,7 @@ const defaultSettings: Settings = { worktreeInitCommand: "", testCommand: "", buildCommand: "", - experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true, evalsView: true, todoView: true }, + experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true, evalsView: true, todoView: true, leftSidebarNav: false, rightDock: false }, }; const mockSubscribeSse = vi.fn((..._args: any[]) => vi.fn()); diff --git a/packages/dashboard/app/components/__tests__/overflowViewRegistry.test.tsx b/packages/dashboard/app/components/__tests__/overflowViewRegistry.test.tsx new file mode 100644 index 0000000000..1ec8c58755 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/overflowViewRegistry.test.tsx @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { getVisibleOverflowViewEntries, STATIC_OVERFLOW_VIEW_ENTRIES } from "../overflowViewRegistry"; +import type { PluginDashboardViewEntry } from "../../api"; + +describe("overflowViewRegistry", () => { + it("keeps Files as the default first entry and Documents visible without a viewport gate", () => { + const entries = getVisibleOverflowViewEntries(); + expect(entries[0]?.key).toBe("files"); + expect(entries.some((entry) => entry.key === "documents")).toBe(true); + }); + + it("matches Header feature gates for flag-controlled overflow destinations", () => { + const disabled = getVisibleOverflowViewEntries({ + experimentalFeatures: { + insights: false, + memoryView: false, + devServerView: false, + researchView: false, + evalsView: false, + goalsView: false, + }, + showSkillsTab: false, + todosEnabled: false, + }).map((entry) => entry.key); + + expect(disabled).toEqual(["files", "documents", "secrets", "stash-recovery"]); + + const enabled = getVisibleOverflowViewEntries({ + experimentalFeatures: { + insights: true, + memoryView: true, + devServerView: true, + researchView: true, + evalsView: true, + goalsView: true, + }, + showSkillsTab: true, + todosEnabled: true, + }).map((entry) => entry.key); + + expect(enabled).toEqual(STATIC_OVERFLOW_VIEW_ENTRIES.map((entry) => entry.key)); + }); + + it("adds only non-primary plugin views after static entries", () => { + const pluginDashboardViews: PluginDashboardViewEntry[] = [ + { + pluginId: "plugin-a", + view: { viewId: "primary", label: "Primary", placement: "primary" }, + }, + { + pluginId: "plugin-a", + view: { viewId: "tools", label: "Tools", placement: "overflow", order: 2 }, + }, + { + pluginId: "plugin-b", + view: { viewId: "audit", label: "Audit", placement: "secondary", order: 1 }, + }, + ]; + + const entries = getVisibleOverflowViewEntries({ pluginDashboardViews }); + expect(entries.slice(-2).map((entry) => entry.key)).toEqual([ + "plugin:plugin-b:audit", + "plugin:plugin-a:tools", + ]); + expect(entries.some((entry) => entry.key === "plugin:plugin-a:primary")).toBe(false); + }); +}); diff --git a/packages/dashboard/app/components/overflowViewRegistry.tsx b/packages/dashboard/app/components/overflowViewRegistry.tsx new file mode 100644 index 0000000000..9d627eae79 --- /dev/null +++ b/packages/dashboard/app/components/overflowViewRegistry.tsx @@ -0,0 +1,314 @@ +import { lazy, Suspense, type ComponentType, type ReactNode } from "react"; +import { + Brain, + CheckSquare, + FileText, + Folder, + History, + Lock, + Monitor, + Search, + Sparkles, + Target, + Zap, + type LucideProps, +} from "lucide-react"; +import type { Task, TaskDetail, WorkflowStep } from "@fusion/core"; +import type { PluginDashboardViewEntry } from "../api"; +import type { ToastType } from "../hooks/useToast"; +import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser"; +import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry"; +import { PluginDashboardViewHost } from "../plugins/PluginDashboardViewHost"; +import type { DetailTaskTab, PluginDashboardViewContext } from "../plugins/types"; +import { FileBrowser } from "./FileBrowser"; +import { PageErrorBoundary } from "./ErrorBoundary"; +import { getPluginNavIcon } from "./pluginNavIcon"; + +const DocumentsView = lazy(() => import("./DocumentsView").then((m) => ({ default: m.DocumentsView }))); +const InsightsView = lazy(() => import("./InsightsView").then((m) => ({ default: m.InsightsView }))); +const ResearchView = lazy(() => import("./ResearchView").then((m) => ({ default: m.ResearchView }))); +const EvalsView = lazy(() => import("./EvalsView").then((m) => ({ default: m.EvalsView }))); +const SkillsView = lazy(() => import("./SkillsView").then((m) => ({ default: m.SkillsView }))); +const MemoryView = lazy(() => import("./MemoryView").then((m) => ({ default: m.MemoryView }))); +const SecretsView = lazy(() => import("./SecretsView").then((m) => ({ default: m.SecretsView }))); +const DevServerView = lazy(() => import("./DevServerView").then((m) => ({ default: m.DevServerView }))); +const TodoView = lazy(() => import("./TodoView").then((m) => ({ default: m.TodoView }))); +const GoalsView = lazy(() => import("./GoalsView").then((m) => ({ default: m.GoalsView }))); +const StashRecoveryView = lazy(() => import("./StashRecoveryView").then((m) => ({ default: m.StashRecoveryView }))); + +export type OverflowViewKey = + | "files" + | "documents" + | "research" + | "insights" + | "skills" + | "memory" + | "secrets" + | "stash-recovery" + | "evals" + | "goalsView" + | "todos" + | "devserver" + | `plugin:${string}:${string}`; + +export interface OverflowViewFeatureState { + insights?: boolean; + memoryView?: boolean; + devServerView?: boolean; + researchView?: boolean; + evalsView?: boolean; + goalsView?: boolean; +} + +export interface OverflowViewRenderProps { + projectId?: string; + addToast: (message: string, type?: ToastType) => void; + settingsLoaded?: boolean; + readinessVersion?: number; + anchorGoalId?: string; + tasks?: Array; + workflowSteps?: WorkflowStep[]; + pluginContext?: PluginDashboardViewContext; + onOpenSettings?: (section?: string) => void; + onOpenTaskDetail?: (taskId: string) => void; + onOpenDetail?: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void; + onSendSelectionToTask?: (description: string) => void; + onCreateTaskFromInsight?: (payload: { insightId: string; title: string; description: string }) => Promise | void; + onNavigateToMission?: (missionId: string) => void; + onPlanningMode?: (initialPlan: string) => void; + onTaskCreated?: (task: Task) => void; + renderTaskCard?: (task: Task | TaskDetail) => ReactNode; + subscribePluginEvents?: PluginDashboardViewContext["subscribePluginEvents"]; + openFile?: PluginDashboardViewContext["openFile"]; +} + +export interface OverflowViewEntry { + key: OverflowViewKey; + label: string; + icon: ComponentType; + testId: string; + render: (props: OverflowViewRenderProps) => ReactNode; + isVisible?: (options: OverflowViewVisibilityOptions) => boolean; +} + +export interface OverflowViewVisibilityOptions { + experimentalFeatures?: OverflowViewFeatureState; + showSkillsTab?: boolean; + todosEnabled?: boolean; + pluginDashboardViews?: PluginDashboardViewEntry[]; +} + +function wrapOverflowView(node: ReactNode): ReactNode { + return ( + + {node} + + ); +} + +function InlineFilesView({ projectId, openFile }: Pick) { + const { entries, currentPath, setPath, loading, error, refresh } = useWorkspaceFileBrowser("project", true, projectId); + return ( +
+ openFile?.(path, { workspace: "project" })} + onNavigate={setPath} + loading={loading} + error={error} + onRetry={refresh} + workspace="project" + onRefresh={refresh} + projectId={projectId} + /> +
+ ); +} + +/* +FNXC:Navigation 2026-06-21-00:00: +The right dock and its expand modal must resolve every hosted overflow destination through this registry so toolbar gating, component choice, and props cannot drift between the compact panel and full-size modal surfaces. +*/ +export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [ + { + key: "files", + label: "Files", + icon: Folder, + testId: "right-dock-tab-files", + render: (props) => wrapOverflowView(), + }, + { + key: "documents", + label: "Documents", + icon: FileText, + testId: "right-dock-tab-documents", + render: (props) => wrapOverflowView( + props.onOpenDetail?.(task)} + onSendSelectionToTask={props.onSendSelectionToTask} + />, + ), + }, + { + key: "research", + label: "Research", + icon: Search, + testId: "right-dock-tab-research", + isVisible: ({ experimentalFeatures }) => experimentalFeatures?.researchView === true, + render: (props) => props.settingsLoaded === false ? null : wrapOverflowView( + props.onOpenSettings?.(section)} + readinessVersion={props.readinessVersion ?? 0} + />, + ), + }, + { + key: "insights", + label: "Insights", + icon: Sparkles, + testId: "right-dock-tab-insights", + isVisible: ({ experimentalFeatures }) => experimentalFeatures?.insights === true, + render: (props) => props.settingsLoaded === false ? null : wrapOverflowView( + undefined} + onCreateTask={async (payload) => { + await props.onCreateTaskFromInsight?.(payload); + }} + />, + ), + }, + { + key: "skills", + label: "Skills", + icon: Zap, + testId: "right-dock-tab-skills", + isVisible: ({ showSkillsTab }) => showSkillsTab === true, + render: (props) => wrapOverflowView( undefined} />), + }, + { + key: "memory", + label: "Memory", + icon: Brain, + testId: "right-dock-tab-memory", + isVisible: ({ experimentalFeatures }) => experimentalFeatures?.memoryView === true, + render: (props) => props.settingsLoaded === false ? null : wrapOverflowView( + , + ), + }, + { + key: "secrets", + label: "Secrets", + icon: Lock, + testId: "right-dock-tab-secrets", + render: (props) => wrapOverflowView(), + }, + { + key: "stash-recovery", + label: "Stash Recovery", + icon: History, + testId: "right-dock-tab-stash-recovery", + render: () => wrapOverflowView(), + }, + { + key: "evals", + label: "Evals", + icon: Target, + testId: "right-dock-tab-evals", + isVisible: ({ experimentalFeatures }) => experimentalFeatures?.evalsView === true, + render: (props) => props.settingsLoaded === false ? null : wrapOverflowView( + props.onOpenSettings?.(section)} + onOpenTaskDetail={(taskId) => props.onOpenTaskDetail?.(taskId)} + />, + ), + }, + { + key: "goalsView", + label: "Goals", + icon: Target, + testId: "right-dock-tab-goals", + isVisible: ({ experimentalFeatures }) => experimentalFeatures?.goalsView === true, + render: (props) => props.settingsLoaded === false ? null : wrapOverflowView( + props.onNavigateToMission?.(missionId)} />, + ), + }, + { + key: "todos", + label: "Todos", + icon: CheckSquare, + testId: "right-dock-tab-todos", + isVisible: ({ todosEnabled }) => todosEnabled === true, + render: (props) => wrapOverflowView( + , + ), + }, + { + key: "devserver", + label: "Dev Server", + icon: Monitor, + testId: "right-dock-tab-devserver", + isVisible: ({ experimentalFeatures }) => experimentalFeatures?.devServerView === true, + render: (props) => props.settingsLoaded === false ? null : wrapOverflowView(), + }, +]; + +function buildPluginOverflowViewEntries(pluginDashboardViews: PluginDashboardViewEntry[] = []): OverflowViewEntry[] { + return pluginDashboardViews + .filter((entry) => entry.view.placement !== "primary") + .sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER)) + .map((entry) => { + const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId); + const PluginIcon = getPluginNavIcon(entry.view.icon); + return { + key: pluginTaskView, + label: entry.view.label, + icon: PluginIcon, + testId: `right-dock-tab-plugin-${entry.pluginId}-${entry.view.viewId}`, + render: (props: OverflowViewRenderProps) => wrapOverflowView( + undefined), + openFile: props.openFile ?? (() => undefined), + renderTaskCard: props.renderTaskCard, + addToast: props.addToast, + }} + />, + ), + } satisfies OverflowViewEntry; + }); +} + +export function getVisibleOverflowViewEntries(options: OverflowViewVisibilityOptions = {}): OverflowViewEntry[] { + const staticEntries = STATIC_OVERFLOW_VIEW_ENTRIES.filter((entry) => entry.isVisible?.(options) ?? true); + return [...staticEntries, ...buildPluginOverflowViewEntries(options.pluginDashboardViews)]; +} + +export function findOverflowViewEntry(key: OverflowViewKey, options: OverflowViewVisibilityOptions = {}): OverflowViewEntry | undefined { + return getVisibleOverflowViewEntries(options).find((entry) => entry.key === key); +} + +export function isOverflowViewKeyVisible(key: string, options: OverflowViewVisibilityOptions = {}): key is OverflowViewKey { + return getVisibleOverflowViewEntries(options).some((entry) => entry.key === key); +} diff --git a/packages/dashboard/app/components/useRightDockController.tsx b/packages/dashboard/app/components/useRightDockController.tsx new file mode 100644 index 0000000000..0061a69893 --- /dev/null +++ b/packages/dashboard/app/components/useRightDockController.tsx @@ -0,0 +1,126 @@ +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 { 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; + 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; + onSendSelectionToTask: (description: string) => void; + onCreateTaskFromInsight: (payload: { insightId: string; title: string; description: string }) => Promise | void; + onNavigateToMission: (missionId: string) => void; + onTaskCreated: (task: Task) => void; + workflowStepNameLookup: Map; + prAuthAvailable: boolean; + autoMerge: boolean; + visibilityOptions: OverflowViewVisibilityOptions; + footerVisible: boolean; +} + +export interface RightDockController { + open: boolean; + toggle: () => void; + dock: ReactNode; + modal: ReactNode; +} + +/* +FNXC:Navigation 2026-06-21-12:20: +App owns whether the default-on right dock is available, but this controller owns the persisted open state and shared view props so App.tsx does not duplicate each overflow destination's dock/modal rendering contract. +*/ +export function useRightDockController(input: RightDockControllerInput): RightDockController { + const [open, setOpen] = useState(readStoredRightDockOpen); + const [expandedView, setExpandedView] = useState(null); + + const setPersistedOpen = useCallback((nextOpen: boolean) => { + setOpen(nextOpen); + persistRightDockOpen(nextOpen); + if (!nextOpen) setExpandedView(null); + }, []); + const toggle = useCallback(() => { + setOpen((current) => { + const next = !current; + persistRightDockOpen(next); + if (!next) setExpandedView(null); + return next; + }); + }, []); + + useEffect(() => { + if (!input.active) setExpandedView(null); + }, [input.active]); + + const renderTaskCard = useCallback((task: Task | TaskDetail) => ( + input.openDetailTask(value)} + addToast={input.addToast} + workflowStepNameLookup={input.workflowStepNameLookup} + 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(() => ({ + 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, + 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 ? : null, + modal: input.active ? setExpandedView(null)} /> : null, + }; +}