FN-6845: add resizable right dock for More views
Add a tablet and desktop right dock for overflow dashboard views while preserving mobile navigation behavior. - Route the non-mobile More views affordance into a resizable right dock with expand-to-modal support. - Add an overflow view registry and controller to render auxiliary dashboard views in the dock. - Make the right dock default-on in settings alongside the default left sidebar, with explicit opt-outs for legacy tests. - Cover right dock behavior, navigation fallbacks, and overflow registry entries with focused tests and docs. Files changed: docs/dashboard-guide.md | 10 + packages/dashboard/app/App.tsx | 15 +- .../mobile-feature-access-regression.test.tsx | 78 +++++ packages/dashboard/app/components/Header.tsx | 66 ++++- packages/dashboard/app/components/RightDock.css | 165 +++++++++++ packages/dashboard/app/components/RightDock.tsx | 234 +++++++++++++++ .../app/components/RightDockExpandModal.tsx | 70 +++++ .../dashboard/app/components/SettingsModal.tsx | 17 +- .../app/components/__tests__/Header.test.tsx | 70 +++++ .../app/components/__tests__/RightDock.test.tsx | 202 +++++++++++++ .../__tests__/navigation-history.test.tsx | 2 +- .../__tests__/overflowViewRegistry.test.tsx | 67 +++++ .../app/components/overflowViewRegistry.tsx | 314 +++++++++++++++++++++ .../app/components/useRightDockController.tsx | 126 +++++++++ 14 files changed, 1408 insertions(+), 28 deletions(-) Fusion-Task-Id: FN-6845 Fusion-Task-Lineage: 3cb04264-cf7a-43e1-b774-678ff3cb325b
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
<NavigationHistoryProvider value={{ pushNav, replaceCurrent, removeNav }}>
|
||||
@@ -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.
|
||||
*/}
|
||||
<div className={`dashboard-project-shell${sidebarActive ? " dashboard-project-shell--with-sidebar" : ""}`} data-testid="dashboard-project-shell">
|
||||
<div className={`dashboard-project-shell${sidebarActive ? " dashboard-project-shell--with-sidebar" : ""}${rightDockActive ? " dashboard-project-shell--with-right-dock" : ""}`} data-testid="dashboard-project-shell">
|
||||
{sidebarActive && (
|
||||
<LeftSidebarNav
|
||||
view={taskView}
|
||||
@@ -2150,7 +2153,9 @@ function AppInner() {
|
||||
>
|
||||
{renderMainContent()}
|
||||
</div>
|
||||
{rightDock.dock}
|
||||
</div>
|
||||
{rightDock.modal}
|
||||
{executorFooterVisible && currentProject && (
|
||||
<ExecutorStatusBar
|
||||
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
|
||||
|
||||
@@ -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(
|
||||
<Header
|
||||
view="board"
|
||||
onChangeView={vi.fn()}
|
||||
mobileNavEnabled={false}
|
||||
showAgentsTab={true}
|
||||
rightDockActive={true}
|
||||
rightDockOpen={false}
|
||||
onToggleRightDock={onToggleRightDock}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Header
|
||||
view="board"
|
||||
onChangeView={vi.fn()}
|
||||
mobileNavEnabled={false}
|
||||
showAgentsTab={true}
|
||||
leftSidebarNavActive={true}
|
||||
rightDockActive={true}
|
||||
rightDockOpen={true}
|
||||
onToggleRightDock={onToggleRightDock}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Header
|
||||
view="board"
|
||||
onChangeView={vi.fn()}
|
||||
mobileNavEnabled={false}
|
||||
showAgentsTab={true}
|
||||
rightDockActive={false}
|
||||
rightDockOpen={false}
|
||||
onToggleRightDock={onToggleRightDock}
|
||||
/>,
|
||||
);
|
||||
|
||||
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:
|
||||
|
||||
@@ -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 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon header-right-dock-toggle"
|
||||
onClick={onToggleRightDock}
|
||||
title={t("header.toggleRightDock", "Toggle right dock")}
|
||||
aria-label={t("header.toggleRightDock", "Toggle right dock")}
|
||||
aria-pressed={rightDockOpen}
|
||||
aria-expanded={rightDockOpen}
|
||||
data-testid="view-toggle-overflow-trigger"
|
||||
>
|
||||
<PanelRight size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/**
|
||||
* 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({
|
||||
<>
|
||||
<button
|
||||
ref={viewOverflowTriggerRef}
|
||||
className={`view-toggle-btn${["research", "skills", "insights", "memory", "secrets", "dev-server", "devserver", "graph", "stash-recovery", "todos"].includes(view) || (isTablet && view === "documents") || (experimentalFeatures?.evalsView && view === "evals") || (experimentalFeatures?.goalsView && view === "goalsView") || isPluginViewId(view) ? " active" : ""}`}
|
||||
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
||||
title={t("header.moreViews", "More views")}
|
||||
aria-label={t("header.moreViews", "More views")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isViewOverflowOpen}
|
||||
className={`view-toggle-btn${!shouldRouteMoreViewsToRightDock && (["research", "skills", "insights", "memory", "secrets", "dev-server", "devserver", "graph", "stash-recovery", "todos"].includes(view) || (isTablet && view === "documents") || (experimentalFeatures?.evalsView && view === "evals") || (experimentalFeatures?.goalsView && view === "goalsView") || isPluginViewId(view)) ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
if (shouldRouteMoreViewsToRightDock) {
|
||||
setIsViewOverflowOpen(false);
|
||||
onToggleRightDock?.();
|
||||
return;
|
||||
}
|
||||
setIsViewOverflowOpen((prev) => !prev);
|
||||
}}
|
||||
title={shouldRouteMoreViewsToRightDock ? t("header.toggleRightDock", "Toggle right dock") : t("header.moreViews", "More views")}
|
||||
aria-label={shouldRouteMoreViewsToRightDock ? t("header.toggleRightDock", "Toggle right dock") : t("header.moreViews", "More views")}
|
||||
{...(shouldRouteMoreViewsToRightDock ? {
|
||||
"aria-pressed": rightDockOpen,
|
||||
"aria-expanded": rightDockOpen,
|
||||
} : {
|
||||
"aria-haspopup": "menu" as const,
|
||||
"aria-expanded": isViewOverflowOpen,
|
||||
})}
|
||||
data-testid="view-toggle-overflow-trigger"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
{shouldRouteMoreViewsToRightDock ? <PanelRight size={16} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
{isViewOverflowOpen && (
|
||||
{!shouldRouteMoreViewsToRightDock && isViewOverflowOpen && (
|
||||
<div
|
||||
ref={viewOverflowRef}
|
||||
className="view-toggle-overflow-menu"
|
||||
@@ -1518,8 +1557,11 @@ export function Header({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<button className="btn-icon" onClick={onOpenSettings} title={t("header.settings", "Settings")}>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
|
||||
165
packages/dashboard/app/components/RightDock.css
Normal file
165
packages/dashboard/app/components/RightDock.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
234
packages/dashboard/app/components/RightDock.tsx
Normal file
234
packages/dashboard/app/components/RightDock.tsx
Normal file
@@ -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<OverflowViewKey>(() => 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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<aside
|
||||
className={`right-dock${footerVisible ? " right-dock--with-footer" : ""}`}
|
||||
style={{ width: `${width}px` }}
|
||||
aria-label="Right dock"
|
||||
data-testid="right-dock"
|
||||
>
|
||||
<div
|
||||
className="right-dock__resize-handle"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={RIGHT_DOCK_MIN_WIDTH}
|
||||
aria-valuemax={RIGHT_DOCK_MAX_WIDTH}
|
||||
aria-valuenow={width}
|
||||
aria-label="Resize right dock"
|
||||
tabIndex={0}
|
||||
data-testid="right-dock-resize-handle"
|
||||
onPointerDown={handleResizeStart}
|
||||
onKeyDown={handleResizeKeyDown}
|
||||
/>
|
||||
<div className="right-dock__toolbar">
|
||||
<div className="right-dock__tabs" role="tablist" aria-label="Right dock views">
|
||||
{entries.map((entry) => {
|
||||
const Icon = entry.icon;
|
||||
const selected = entry.key === selectedEntry.key;
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
className={`btn-icon right-dock__tab${selected ? " right-dock__tab--active" : ""}`}
|
||||
aria-label={entry.label}
|
||||
title={entry.label}
|
||||
aria-selected={selected}
|
||||
role="tab"
|
||||
data-testid={entry.testId}
|
||||
onClick={() => selectEntry(entry.key)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="right-dock__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon right-dock__expand"
|
||||
aria-label={`Expand ${selectedEntry.label}`}
|
||||
title={`Expand ${selectedEntry.label}`}
|
||||
data-testid="right-dock-expand"
|
||||
onClick={() => onExpand?.(selectedEntry.key)}
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon right-dock__close"
|
||||
aria-label="Close right dock"
|
||||
title="Close right dock"
|
||||
data-testid="right-dock-close"
|
||||
onClick={closeDock}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="right-dock__header">
|
||||
<SelectedIcon size={16} />
|
||||
<div className="right-dock__title" role="heading" aria-level={3}>{selectedEntry.label}</div>
|
||||
</div>
|
||||
<div className="right-dock__body" role="tabpanel" aria-label={selectedEntry.label} data-testid="right-dock-body">
|
||||
{selectedEntry.render(renderProps)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
70
packages/dashboard/app/components/RightDockExpandModal.tsx
Normal file
70
packages/dashboard/app/components/RightDockExpandModal.tsx
Normal file
@@ -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<HTMLElement | null>;
|
||||
}
|
||||
|
||||
/*
|
||||
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<HTMLDivElement>(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 (
|
||||
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true" aria-label={`${entry.label} expanded`} data-testid="right-dock-expand-modal">
|
||||
<div className="modal right-dock-expand-modal" ref={modalRef}>
|
||||
<div className="modal-header right-dock-expand-modal__header">
|
||||
<div className="right-dock-expand-modal__title">
|
||||
<Maximize2 size={16} />
|
||||
<Icon size={16} />
|
||||
<span>{entry.label}</span>
|
||||
</div>
|
||||
<button className="modal-close" onClick={closeAndRestoreFocus} aria-label="Close expanded right dock view" data-testid="right-dock-expand-close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="right-dock-expand-modal__body" data-testid="right-dock-expand-body">
|
||||
{entry.render(renderProps)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -277,6 +277,7 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
|
||||
/*
|
||||
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<string>(["leftSidebarNav"]);
|
||||
const DEFAULT_ON_EXPERIMENTAL_FEATURES = new Set<string>(["leftSidebarNav", "rightDock"]);
|
||||
|
||||
const EXPERIMENTAL_FEATURE_LEGACY_ALIASES: Record<string, string> = {
|
||||
devServer: "devServerView",
|
||||
@@ -297,15 +298,11 @@ const EXPERIMENTAL_FEATURE_LEGACY_ALIASES: Record<string, string> = {
|
||||
function getCanonicalExperimentalFeatureKey(key: string): string {
|
||||
return EXPERIMENTAL_FEATURE_LEGACY_ALIASES[key] ?? key;
|
||||
}
|
||||
|
||||
function isExperimentalFeatureEnabled(features: Record<string, boolean>, 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<string, boolean>, key: string): boolean {
|
||||
|
||||
@@ -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(
|
||||
<Header
|
||||
onOpenSettings={noop}
|
||||
onOpenGitHubImport={noop}
|
||||
onChangeView={noop}
|
||||
rightDockActive={true}
|
||||
rightDockOpen={true}
|
||||
onToggleRightDock={onToggleRightDock}
|
||||
/>,
|
||||
);
|
||||
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" });
|
||||
|
||||
202
packages/dashboard/app/components/__tests__/RightDock.test.tsx
Normal file
202
packages/dashboard/app/components/__tests__/RightDock.test.tsx
Normal file
@@ -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<typeof import("../../api")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchWorkspaceFileList: vi.fn().mockResolvedValue({ entries: [], currentPath: "." }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../DocumentsView", () => ({ DocumentsView: () => <div data-testid="mock-documents-view" /> }));
|
||||
vi.mock("../ResearchView", () => ({ ResearchView: () => <div data-testid="mock-research-view" /> }));
|
||||
vi.mock("../InsightsView", () => ({ InsightsView: () => <div data-testid="mock-insights-view" /> }));
|
||||
vi.mock("../EvalsView", () => ({ EvalsView: () => <div data-testid="mock-evals-view" /> }));
|
||||
vi.mock("../SkillsView", () => ({ SkillsView: () => <div data-testid="mock-skills-view" /> }));
|
||||
vi.mock("../MemoryView", () => ({ MemoryView: () => <div data-testid="mock-memory-view" /> }));
|
||||
vi.mock("../SecretsView", () => ({ SecretsView: () => <div data-testid="mock-secrets-view" /> }));
|
||||
vi.mock("../DevServerView", () => ({ DevServerView: () => <div data-testid="mock-devserver-view" /> }));
|
||||
vi.mock("../TodoView", () => ({ TodoView: () => <div data-testid="mock-todos-view" /> }));
|
||||
vi.mock("../GoalsView", () => ({ GoalsView: () => <div data-testid="mock-goals-view" /> }));
|
||||
vi.mock("../StashRecoveryView", () => ({ StashRecoveryView: () => <div data-testid="mock-stash-recovery-view" /> }));
|
||||
|
||||
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(
|
||||
<RightDock open={true} onOpenChange={onOpenChange} renderProps={renderProps} />,
|
||||
);
|
||||
|
||||
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(<RightDock open={true} onOpenChange={onOpenChange} renderProps={renderProps} />);
|
||||
expect(screen.getByTestId("right-dock-tab-secrets")).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("hides entries gated off by their matching Header flags", () => {
|
||||
render(
|
||||
<RightDock
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
renderProps={renderProps}
|
||||
visibilityOptions={{
|
||||
experimentalFeatures: {
|
||||
insights: false,
|
||||
memoryView: false,
|
||||
devServerView: false,
|
||||
researchView: false,
|
||||
evalsView: false,
|
||||
goalsView: false,
|
||||
},
|
||||
showSkillsTab: false,
|
||||
todosEnabled: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<RightDock open={true} onOpenChange={onOpenChange} renderProps={renderProps} />);
|
||||
|
||||
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(<RightDock open={true} onOpenChange={vi.fn()} renderProps={renderProps} />);
|
||||
|
||||
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(
|
||||
<RightDock
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
renderProps={renderProps}
|
||||
visibilityOptions={{
|
||||
experimentalFeatures: {
|
||||
insights: true,
|
||||
memoryView: true,
|
||||
devServerView: true,
|
||||
researchView: true,
|
||||
evalsView: true,
|
||||
goalsView: true,
|
||||
},
|
||||
showSkillsTab: true,
|
||||
todosEnabled: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<RightDockExpandModal
|
||||
viewKey="secrets"
|
||||
renderProps={renderProps}
|
||||
onClose={onClose}
|
||||
returnFocusRef={{ current: focusButton }}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<RightDockExpandModal
|
||||
viewKey="secrets"
|
||||
renderProps={renderProps}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<RightDock open={true} onOpenChange={vi.fn()} renderProps={renderProps} onExpand={onExpand} />);
|
||||
fireEvent.click(screen.getByTestId("right-dock-tab-secrets"));
|
||||
fireEvent.click(screen.getByTestId("right-dock-expand"));
|
||||
expect(onExpand).toHaveBeenCalledWith("secrets");
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
314
packages/dashboard/app/components/overflowViewRegistry.tsx
Normal file
314
packages/dashboard/app/components/overflowViewRegistry.tsx
Normal file
@@ -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<Task | TaskDetail>;
|
||||
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> | 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<LucideProps>;
|
||||
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 (
|
||||
<PageErrorBoundary>
|
||||
<Suspense fallback={null}>{node}</Suspense>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineFilesView({ projectId, openFile }: Pick<OverflowViewRenderProps, "projectId" | "openFile">) {
|
||||
const { entries, currentPath, setPath, loading, error, refresh } = useWorkspaceFileBrowser("project", true, projectId);
|
||||
return (
|
||||
<div data-testid="right-dock-files-view">
|
||||
<FileBrowser
|
||||
entries={entries}
|
||||
currentPath={currentPath}
|
||||
onSelectFile={(path) => openFile?.(path, { workspace: "project" })}
|
||||
onNavigate={setPath}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRetry={refresh}
|
||||
workspace="project"
|
||||
onRefresh={refresh}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
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(<InlineFilesView projectId={props.projectId} openFile={props.openFile} />),
|
||||
},
|
||||
{
|
||||
key: "documents",
|
||||
label: "Documents",
|
||||
icon: FileText,
|
||||
testId: "right-dock-tab-documents",
|
||||
render: (props) => wrapOverflowView(
|
||||
<DocumentsView
|
||||
projectId={props.projectId}
|
||||
addToast={props.addToast}
|
||||
onOpenDetail={(task) => 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(
|
||||
<ResearchView
|
||||
projectId={props.projectId}
|
||||
addToast={props.addToast}
|
||||
onOpenSettings={(section) => 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(
|
||||
<InsightsView
|
||||
projectId={props.projectId}
|
||||
addToast={props.addToast}
|
||||
onClose={() => 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(<SkillsView addToast={props.addToast} projectId={props.projectId} onClose={() => undefined} />),
|
||||
},
|
||||
{
|
||||
key: "memory",
|
||||
label: "Memory",
|
||||
icon: Brain,
|
||||
testId: "right-dock-tab-memory",
|
||||
isVisible: ({ experimentalFeatures }) => experimentalFeatures?.memoryView === true,
|
||||
render: (props) => props.settingsLoaded === false ? null : wrapOverflowView(
|
||||
<MemoryView
|
||||
addToast={props.addToast}
|
||||
projectId={props.projectId}
|
||||
onSendSelectionToTask={props.onSendSelectionToTask}
|
||||
/>,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "secrets",
|
||||
label: "Secrets",
|
||||
icon: Lock,
|
||||
testId: "right-dock-tab-secrets",
|
||||
render: (props) => wrapOverflowView(<SecretsView addToast={props.addToast} />),
|
||||
},
|
||||
{
|
||||
key: "stash-recovery",
|
||||
label: "Stash Recovery",
|
||||
icon: History,
|
||||
testId: "right-dock-tab-stash-recovery",
|
||||
render: () => wrapOverflowView(<StashRecoveryView />),
|
||||
},
|
||||
{
|
||||
key: "evals",
|
||||
label: "Evals",
|
||||
icon: Target,
|
||||
testId: "right-dock-tab-evals",
|
||||
isVisible: ({ experimentalFeatures }) => experimentalFeatures?.evalsView === true,
|
||||
render: (props) => props.settingsLoaded === false ? null : wrapOverflowView(
|
||||
<EvalsView
|
||||
projectId={props.projectId}
|
||||
onOpenSettings={(section) => 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(
|
||||
<GoalsView anchorGoalId={props.anchorGoalId} onNavigateToMission={(missionId) => props.onNavigateToMission?.(missionId)} />,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "todos",
|
||||
label: "Todos",
|
||||
icon: CheckSquare,
|
||||
testId: "right-dock-tab-todos",
|
||||
isVisible: ({ todosEnabled }) => todosEnabled === true,
|
||||
render: (props) => wrapOverflowView(
|
||||
<TodoView
|
||||
projectId={props.projectId}
|
||||
addToast={props.addToast}
|
||||
onPlanningMode={props.onPlanningMode}
|
||||
onTaskCreated={props.onTaskCreated}
|
||||
/>,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "devserver",
|
||||
label: "Dev Server",
|
||||
icon: Monitor,
|
||||
testId: "right-dock-tab-devserver",
|
||||
isVisible: ({ experimentalFeatures }) => experimentalFeatures?.devServerView === true,
|
||||
render: (props) => props.settingsLoaded === false ? null : wrapOverflowView(<DevServerView addToast={props.addToast} projectId={props.projectId} />),
|
||||
},
|
||||
];
|
||||
|
||||
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(
|
||||
<PluginDashboardViewHost
|
||||
taskView={pluginTaskView}
|
||||
context={props.pluginContext ?? {
|
||||
projectId: props.projectId,
|
||||
tasks: (props.tasks ?? []) as Task[],
|
||||
workflowSteps: props.workflowSteps ?? [],
|
||||
subscribePluginEvents: props.subscribePluginEvents,
|
||||
openTaskDetail: props.onOpenDetail ?? (() => 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);
|
||||
}
|
||||
126
packages/dashboard/app/components/useRightDockController.tsx
Normal file
126
packages/dashboard/app/components/useRightDockController.tsx
Normal file
@@ -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<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;
|
||||
onSendSelectionToTask: (description: string) => void;
|
||||
onCreateTaskFromInsight: (payload: { insightId: string; title: string; description: string }) => Promise<void> | void;
|
||||
onNavigateToMission: (missionId: string) => void;
|
||||
onTaskCreated: (task: Task) => void;
|
||||
workflowStepNameLookup: Map<string, string>;
|
||||
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<OverflowViewKey | null>(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) => (
|
||||
<TaskCard
|
||||
task={task}
|
||||
projectId={input.projectId}
|
||||
onOpenDetail={(value: 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<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,
|
||||
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} onOpenChange={setPersistedOpen} renderProps={renderProps} visibilityOptions={input.visibilityOptions} footerVisible={input.footerVisible} onExpand={setExpandedView} /> : null,
|
||||
modal: input.active ? <RightDockExpandModal viewKey={expandedView} renderProps={renderProps} visibilityOptions={input.visibilityOptions} onClose={() => setExpandedView(null)} /> : null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user