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:
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user