FN-6899: make right dock persistent

Make the right dock a persistent far-right surface with its own collapse control.

- Default the right dock to visible and preserve a collapsed rail instead of hiding it entirely.\n- Remove Header right-dock toggle props and routing so More views remains a menu and left-sidebar mode has no duplicate Header toggle.\n- Update docs, changeset, and regression tests for persistent dock behavior.\n\nFiles changed:\n .changeset/fn-6899-click-revealed-right-dock.md    |  5 ++\n docs/dashboard-guide.md                            |  6 +-\n packages/dashboard/app/App.tsx                     |  1 -\n .../mobile-feature-access-regression.test.tsx      | 32 ++------\n packages/dashboard/app/components/Header.tsx       | 57 +++-----------\n packages/dashboard/app/components/RightDock.css    | 18 +++++\n packages/dashboard/app/components/RightDock.tsx    | 86 ++++++++++++----------\n .../app/components/__tests__/Header.test.tsx       | 57 +++-----------\n .../app/components/__tests__/RightDock.test.tsx    | 31 ++++++--\n .../app/components/useRightDockController.tsx      |  4 +-\n 10 files changed, 132 insertions(+), 165 deletions(-)

Fusion-Task-Id: FN-6899

Fusion-Task-Lineage: 96c4ba70-eb73-4034-8254-6a192402d905
This commit is contained in:
gsxdsm
2026-06-21 23:37:25 -07:00
parent bb663a4705
commit a91388198b
10 changed files with 132 additions and 165 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Make the dashboard right dock persistent by default with an in-dock collapse toggle, and remove duplicate Header right-dock toggle behavior.

View File

@@ -23,7 +23,7 @@ Task Detail modal opens from onboarding, activity log, and task-to-task navigati
**Left Sidebar Navigation** is enabled by default for desktop/tablet project screens, moving project navigation out of the Header and into a persistent left sidebar. To opt out, open **Settings → Experimental Features** and turn **Left Sidebar Navigation** off (`leftSidebarNav: false`).
When enabled on desktop or tablet project screens, the sidebar starts with a centered **New Task** button that opens the existing New Task dialog from any project screen. Expanded mode shows the plus icon and **New Task** label; collapsed rail mode keeps the centered icon-only button accessible through its label/title. Below that action, the sidebar contains the primary destinations (Board, List, Agents, Command Center, Missions, Chat, Artifacts, Mailbox, and plugin primary views), selected auxiliary destinations as regular entries (Research, Insights, Skills, Memory, Stash Recovery, Evals, Goals, Dev Server, and plugin overflow views when their flags/plugins are enabled), and a footer with the collapse toggle directly above the Settings button. Secrets and Todos are intentionally not listed in the left sidebar; use the Right Dock for those auxiliary views on desktop/tablet. The Header retains the Fusion brand and project selector, keeps its non-navigation controls, and hides the view-toggle row and **More views** trigger so there is only one canonical primary navigation surface.
When enabled on desktop or tablet project screens, the sidebar starts with a centered **New Task** button that opens the existing New Task dialog from any project screen. Expanded mode shows the plus icon and **New Task** label; collapsed rail mode keeps the centered icon-only button accessible through its label/title. Below that action, the sidebar contains the primary destinations (Board, List, Agents, Command Center, Missions, Chat, Artifacts, Mailbox, and plugin primary views), selected auxiliary destinations as regular entries (Research, Insights, Skills, Memory, Secrets, Stash Recovery, Evals, Goals, Todos, Dev Server, and plugin overflow views when their flags/plugins are enabled), and a footer with the collapse toggle directly above the Settings button. The Header retains the Fusion brand and project selector, keeps its non-navigation controls, and hides the view-toggle row and **More views** trigger so there is only one canonical primary navigation surface.
While the sidebar is active on desktop/tablet project screens, Board and List workflow controls move into the Header slot that replaces the hidden view toggle. Board and List share one workflow dropdown: each workflow row includes an inline edit action, and a persistent **New workflow** action remains at the bottom of the dropdown while the workflow list scrolls. The standalone workflow row above the board/list content is removed in this mode. When the flag is off, outside project screens, or on mobile, workflow controls remain inline with the same consolidated dropdown.
@@ -35,9 +35,9 @@ On mobile viewports (`<=768px`), the sidebar is not rendered even when the defau
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 right-panel toggle opens a persistent dock on the right side of the project content. 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.
When enabled on desktop or tablet project screens, the dock is visible by default as the persistent far-right sidebar in the project content row. Its in-dock collapse toggle (`right-dock-collapse-toggle`) replaces the former Header right-panel toggle, so there is no duplicate Header control when Left Sidebar Navigation is active or when the Header view-toggle row is visible.
The dock toolbar is a tools rail with exactly six destinations: **Activity**, **Activity Log**, **Import from GitHub**, **Git Manager**, **Files**, and **Automation**. The launcher tools reuse the same handlers as the former desktop Header toolbar buttons; **Files** remains the inline dock view, opens by default, and is the fallback when browser storage points at a removed dock key. Inline dock views have an expand button that opens the same view in a resizable modal, while action-only tools launch directly and are not expandable. Dock width and expanded modal size persist across reloads.
The dock toolbar has exactly six destinations: **Activity**, **Activity Log**, **Import from GitHub**, **Git Manager**, **Files**, and **Automation**. The launcher tools reuse the same handlers as the former desktop Header toolbar buttons; **Files** remains the inline dock view, opens by default, and is the fallback when browser storage points at a removed dock key. Inline dock views have an expand button that opens the same view in a resizable modal, while action-only tools launch directly and are not expandable. Dock collapsed state, width, and expanded modal size persist across reloads.
Content views such as Artifacts, Research, Insights, Skills, Memory, Secrets, Evals, Goals, Todos, and Dev Server live in the left sidebar (or compact mobile navigation) rather than the right dock. The six tool buttons are no longer duplicated in the desktop top Header toolbar, and the former desktop overflow trigger is removed when it would otherwise be empty.

View File

@@ -1998,7 +1998,6 @@ function AppInner() {
projectId={currentProject?.id}
mobileNavEnabled={isMobile}
leftSidebarNavActive={sidebarActive}
rightDockActive={rightDockActive} rightDockOpen={rightDock.open} onToggleRightDock={rightDock.toggle}
// Node switching props
availableNodes={nodes}
currentNode={currentNode}

View File

@@ -276,36 +276,30 @@ describe("Mobile Feature Access Regression Guard", () => {
}
});
it("right dock reroutes desktop and tablet More views without leaving the dropdown or chevron behind", () => {
it("desktop and tablet More views remain a dropdown rather than a Header right-dock toggle", () => {
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();
expect(trigger.querySelector(".lucide-chevron-down")).toBeTruthy();
expect(trigger.querySelector(".lucide-panel-right")).toBeNull();
fireEvent.click(trigger);
expect(onToggleRightDock).toHaveBeenCalledOnce();
expect(screen.queryByRole("menu", { name: "More views" })).toBeNull();
expect(screen.getByRole("menu", { name: "More views" })).toBeInTheDocument();
unmount();
}
});
it("right dock stays reachable as one standalone toggle when left sidebar nav is active", () => {
it("left sidebar nav leaves no duplicate Header right-dock toggle", () => {
for (const tier of ["desktop", "tablet"] as const) {
mockViewport(tier);
const onToggleRightDock = vi.fn();
const { unmount } = render(
<Header
view="board"
@@ -313,18 +307,11 @@ describe("Mobile Feature Access Regression Guard", () => {
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();
expect(screen.queryByTestId("view-toggle-overflow-trigger")).toBeNull();
expect(document.querySelector(".header-right-dock-toggle")).toBeNull();
unmount();
}
});
@@ -351,23 +338,18 @@ 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();
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Settings, LayoutGrid, List, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, Grid3X3, Mail, MessageSquare, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge, PanelRight, ChevronDown, ChevronRight } from "lucide-react";
import { Settings, LayoutGrid, List, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, Grid3X3, Mail, MessageSquare, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge, ChevronDown, ChevronRight } from "lucide-react";
import "./Header.css";
// ProjectSelector styles used by the imported standalone component.
import "./ProjectSelector.css";
@@ -96,10 +96,6 @@ 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) */
@@ -149,9 +145,6 @@ export function Header({
shellHost = { kind: "browser" },
mobileNavEnabled,
leftSidebarNavActive = false,
rightDockActive = false,
rightDockOpen = false,
onToggleRightDock,
availableNodes = [],
currentNode,
onSelectNode,
@@ -174,7 +167,10 @@ 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";
/*
FNXC:Navigation 2026-06-21-23:40:
The right dock is persistent and owns its own collapse control, so Header must not render a duplicate right-dock toggle or repurpose the More views overflow trigger on tablet/desktop.
*/
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
const [isNonMobileSearchOpen, setIsNonMobileSearchOpen] = useState(false);
// Track when user has explicitly closed the search (used for toggle visibility)
@@ -599,25 +595,6 @@ 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.
@@ -759,29 +736,19 @@ export function Header({
<>
<button
ref={viewOverflowTriggerRef}
className={`view-toggle-btn${!shouldRouteMoreViewsToRightDock && (["research", "skills", "insights", "memory", "secrets", "dev-server", "devserver", "graph", "todos"].includes(view) || (isTablet && view === "documents") || (experimentalFeatures?.evalsView && view === "evals") || (experimentalFeatures?.goalsView && view === "goalsView") || isPluginViewId(view)) ? " active" : ""}`}
className={`view-toggle-btn${(["research", "skills", "insights", "memory", "secrets", "dev-server", "devserver", "graph", "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,
})}
title={t("header.moreViews", "More views")}
aria-label={t("header.moreViews", "More views")}
aria-haspopup="menu"
aria-expanded={isViewOverflowOpen}
data-testid="view-toggle-overflow-trigger"
>
{shouldRouteMoreViewsToRightDock ? <PanelRight size={16} /> : <ChevronDown size={12} />}
<ChevronDown size={12} />
</button>
{!shouldRouteMoreViewsToRightDock && isViewOverflowOpen && (
{isViewOverflowOpen && (
<div
ref={viewOverflowRef}
className="view-toggle-overflow-menu"

View File

@@ -20,6 +20,12 @@ The right dock CSS uses a mobile media query as a belt-and-suspenders guard only
padding-bottom: var(--executor-footer-height);
}
.right-dock--collapsed {
width: calc(var(--space-2xl) + var(--space-sm));
min-width: calc(var(--space-2xl) + var(--space-sm));
max-width: calc(var(--space-2xl) + var(--space-sm));
}
.right-dock__resize-handle {
position: absolute;
inset-block: 0;
@@ -58,6 +64,12 @@ The right dock CSS uses a mobile media query as a belt-and-suspenders guard only
border-bottom: thin solid var(--border);
}
.right-dock--collapsed .right-dock__toolbar {
flex-direction: column;
justify-content: flex-start;
height: 100%;
}
.right-dock__tabs,
.right-dock__actions {
display: flex;
@@ -72,6 +84,12 @@ The right dock CSS uses a mobile media query as a belt-and-suspenders guard only
scrollbar-width: thin;
}
.right-dock--collapsed .right-dock__tabs,
.right-dock--collapsed .right-dock__actions {
flex-direction: column;
overflow: visible;
}
.right-dock__tab {
flex-shrink: 0;
color: var(--text-muted);

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { Maximize2, X } from "lucide-react";
import { Maximize2, PanelRight } from "lucide-react";
import {
findOverflowViewEntry,
getVisibleOverflowViewEntries,
@@ -29,8 +29,8 @@ export function readStoredRightDockWidth(): number {
}
export function readStoredRightDockOpen(): boolean {
if (typeof window === "undefined") return false;
return window.localStorage.getItem(RIGHT_DOCK_OPEN_STORAGE_KEY) === "true";
if (typeof window === "undefined") return true;
return window.localStorage.getItem(RIGHT_DOCK_OPEN_STORAGE_KEY) !== "false";
}
export function persistRightDockOpen(open: boolean): void {
@@ -83,6 +83,9 @@ The right dock is an auxiliary tablet/desktop surface: it remembers the last ove
FNXC:Navigation 2026-06-21-20:14:
FN-6882 splits right-dock entries into launcher actions and inline views. Action tabs invoke their existing Header handlers without replacing the Files body; only inline entries persist selection or expand into the modal.
FNXC:Navigation 2026-06-21-23:40:
The right dock is persistent and visible by default on tablet/desktop project screens. Its in-dock collapse toggle replaces the removed Header right-dock toggle, keeping one far-right control surface while preserving a narrow rail for restoring the panel.
*/
export function RightDock({
open,
@@ -118,10 +121,11 @@ export function RightDock({
persistRightDockView(key);
}, [renderProps, visibilityOptions]);
const closeDock = useCallback(() => {
persistRightDockOpen(false);
onOpenChange(false);
}, [onOpenChange]);
const toggleCollapsed = useCallback(() => {
const nextOpen = !open;
persistRightDockOpen(nextOpen);
onOpenChange(nextOpen);
}, [onOpenChange, open]);
const handleResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
@@ -167,32 +171,35 @@ export function RightDock({
persistRightDockWidth(nextWidth);
}, [width]);
if (!open || !selectedEntry) {
if (!selectedEntry) {
return null;
}
const SelectedIcon = selectedEntry.icon;
const dockWidth = open ? `${width}px` : undefined;
return (
<aside
className={`right-dock${footerVisible ? " right-dock--with-footer" : ""}`}
style={{ width: `${width}px` }}
className={`right-dock${open ? "" : " right-dock--collapsed"}${footerVisible ? " right-dock--with-footer" : ""}`}
style={dockWidth ? { width: dockWidth } : undefined}
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}
/>
{open ? (
<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}
/>
) : null}
<div className="right-dock__toolbar">
<div className="right-dock__tabs" role="tablist" aria-label="Right dock views">
{entries.map((entry) => {
@@ -216,7 +223,7 @@ export function RightDock({
})}
</div>
<div className="right-dock__actions">
{selectedEntry.render ? (
{open && selectedEntry.render ? (
<button
type="button"
className="btn-icon right-dock__expand"
@@ -230,23 +237,28 @@ export function RightDock({
) : null}
<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}
className="btn-icon right-dock__collapse-toggle"
aria-label={open ? "Collapse right dock" : "Expand right dock"}
title={open ? "Collapse right dock" : "Expand right dock"}
aria-expanded={open}
data-testid="right-dock-collapse-toggle"
onClick={toggleCollapsed}
>
<X size={16} />
<PanelRight 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>
{open ? (
<>
<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>
</>
) : null}
</aside>
);
}

View File

@@ -269,65 +269,33 @@ describe("Header", () => {
expect(screen.queryByTestId("view-overflow-stash-recovery")).toBeNull();
});
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);
it.each(["desktop", "tablet"] as const)("keeps More views as a chevron dropdown instead of a right-dock toggle on %s", (tier) => {
renderHeader({ onChangeView: noop, todosEnabled: true }, 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");
expect(trigger.querySelector(".lucide-chevron-down")).toBeTruthy();
expect(trigger.querySelector(".lucide-panel-right")).toBeNull();
expect(trigger).toHaveAttribute("aria-haspopup", "menu");
expect(trigger).not.toHaveAttribute("aria-pressed");
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");
expect(screen.getByRole("menu", { name: "More views" })).toBeInTheDocument();
});
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();
it.each(["desktop", "tablet"] as const)("renders no duplicate Header right-dock toggle when left sidebar hides view nav on %s", (tier) => {
renderHeader({
onChangeView: noop,
leftSidebarNavActive: true,
rightDockActive: true,
rightDockOpen: true,
onToggleRightDock,
todosEnabled: true,
}, 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();
expect(screen.queryByTestId("view-toggle-overflow-trigger")).toBeNull();
expect(document.querySelector(".header-right-dock-toggle")).toBeNull();
});
it("keeps the legacy chevron dropdown on mobile even when right dock props are present", () => {
const onToggleRightDock = vi.fn();
it("keeps the legacy chevron dropdown on mobile", () => {
renderHeader({
onChangeView: noop,
mobileNavEnabled: false,
rightDockActive: true,
rightDockOpen: false,
onToggleRightDock,
}, "mobile");
const trigger = screen.getByTestId("view-toggle-overflow-trigger");
@@ -335,7 +303,6 @@ describe("Header", () => {
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();
});

View File

@@ -50,10 +50,7 @@ describe("RightDock", () => {
});
it("renders Files by default and restores only persisted inline views", () => {
const onOpenChange = vi.fn();
const { unmount } = render(
<RightDock open={true} onOpenChange={onOpenChange} renderProps={renderProps} />,
);
const { unmount } = render(<RightDock open={true} onOpenChange={vi.fn()} renderProps={renderProps} />);
expect(screen.getByTestId("right-dock-tab-files")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("right-dock-files-view")).toBeInTheDocument();
@@ -62,7 +59,7 @@ describe("RightDock", () => {
expect(window.localStorage.getItem(RIGHT_DOCK_VIEW_STORAGE_KEY)).toBeNull();
unmount();
render(<RightDock open={true} onOpenChange={onOpenChange} renderProps={renderProps} />);
render(<RightDock open={true} onOpenChange={vi.fn()} renderProps={renderProps} />);
expect(screen.getByTestId("right-dock-tab-files")).toHaveAttribute("aria-selected", "true");
});
@@ -148,11 +145,11 @@ describe("RightDock", () => {
expect(screen.getByTestId("right-dock-files-view")).toBeInTheDocument();
});
it("closes internally and clamps then persists resize width", () => {
it("collapses 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"));
fireEvent.click(screen.getByTestId("right-dock-collapse-toggle"));
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(window.localStorage.getItem(RIGHT_DOCK_OPEN_STORAGE_KEY)).toBe("false");
@@ -174,6 +171,26 @@ describe("RightDock", () => {
expect(screen.getByTestId("right-dock-resize-handle")).toHaveAttribute("aria-valuenow", "400");
});
it("shows an in-dock collapse toggle and keeps the collapsed rail persistent", () => {
const onOpenChange = vi.fn();
const { rerender } = render(<RightDock open={true} onOpenChange={onOpenChange} renderProps={renderProps} />);
expect(screen.getByTestId("right-dock-collapse-toggle")).toHaveAttribute("aria-expanded", "true");
expect(screen.getByTestId("right-dock-body")).toBeInTheDocument();
expect(screen.getByTestId("right-dock-resize-handle")).toBeInTheDocument();
expect(screen.getAllByRole("tab").map((tab) => tab.getAttribute("data-testid"))).toEqual(toolTabIds);
rerender(<RightDock open={false} onOpenChange={onOpenChange} renderProps={renderProps} />);
expect(screen.getByTestId("right-dock")).toHaveClass("right-dock--collapsed");
expect(screen.getByTestId("right-dock-collapse-toggle")).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByTestId("right-dock-body")).toBeNull();
expect(screen.queryByTestId("right-dock-resize-handle")).toBeNull();
expect(screen.getAllByRole("tab").map((tab) => tab.getAttribute("data-testid"))).toEqual(toolTabIds);
fireEvent.click(screen.getByTestId("right-dock-collapse-toggle"));
expect(onOpenChange).toHaveBeenLastCalledWith(true);
expect(window.localStorage.getItem(RIGHT_DOCK_OPEN_STORAGE_KEY)).toBe("true");
});
it("renders the expanded modal through the same registry and restores focus on close", async () => {
const onClose = vi.fn();
const focusButton = document.createElement("button");

View File

@@ -46,8 +46,8 @@ export interface RightDockController {
}
/*
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.
FNXC:Navigation 2026-06-21-23:40:
The right dock is visible by default and collapses from inside the dock. Keep the persisted open/collapsed state in this controller so App and Header do not need duplicate right-dock toggle wiring.
*/
export function useRightDockController(input: RightDockControllerInput): RightDockController {
const [open, setOpen] = useState(readStoredRightDockOpen);