FN-7172: add right dock pin toggle
Add a persisted right-dock pin control that switches between overlay and push layouts.\n\n- Add a toolbar pin button with accessible labels and persisted pinned state.\n- Switch pinned docks into the flex row so the main project content narrows instead of being overlaid.\n- Cover pin persistence, layout classes, header toggling, mobile behavior, and expanded modal independence in tests.\n- Document the overlay/push workflow and add a minor changeset for the published CLI package.\n\nFiles changed:\n .changeset/fn-7172-right-dock-pin-push-mode.md | 7 +\n docs/dashboard-guide.md | 8 +-\n packages/dashboard/app/components/RightDock.css | 9 ++\n packages/dashboard/app/components/RightDock.tsx | 45 +++++-\n .../app/components/__tests__/RightDock.test.tsx | 174 +++++++++++++++++++--\n .../app/components/useRightDockController.tsx | 21 ++-\n 6 files changed, 241 insertions(+), 23 deletions(-) Fusion-Task-Id: FN-7172 Fusion-Task-Lineage: 1608fead-74cd-4a1e-87be-1ce27f388f34 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7172-right-dock-pin-push-mode.md
Normal file
7
.changeset/fn-7172-right-dock-pin-push-mode.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add a pin toggle to the right sidebar to push content aside instead of overlaying it.
|
||||
category: feature
|
||||
dev: New persisted localStorage flag `fusion:right-dock-pinned` (default false). When pinned, `.right-dock` switches from absolute overlay to in-flow (`right-dock--pinned`, position: relative) so the shell flex layout reflows `.project-content`; unpinned restores overlay. Toggle lives in the right-dock toolbar.
|
||||
@@ -50,7 +50,7 @@ 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 right dock is a persistent far-right tools sidebar in the project content row. Use the in-dock collapse control to switch between the full tool panel and the compact far-right rail; the selected tool, expanded/collapsed state, width, and expanded modal size persist across reloads.
|
||||
When enabled on desktop or tablet project screens, the right dock is a persistent far-right tools sidebar in the project content row. By default it opens as an overlay so the main content does not reflow. Use the dock toolbar pin action to switch into push mode, where the dock becomes an in-flow pane that shrinks the main content beside it; unpinning returns to overlay mode. The selected tool, open/closed state, pinned push-mode state, width, and expanded modal size persist across reloads.
|
||||
|
||||
The dock toolbar has built-in inline tool panels for **Activity**, **Activity Log**, **Git Manager**, **Files**, and project tool launchers such as **Import from GitHub** / **Import Tasks** workflow entry points and **Automation** actions when available. **Activity**, **Activity Log**, **Git Manager**, and **Files** render in embedded mode inside the dock instead of opening fixed popup overlays; **Files** 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 for more room. The right-dock **Files** viewer and its expanded pop-out match the Files modal for browser-previewable file types: image, video/movie, audio, and PDF selections render as native browser previews, while editable text files keep the editor and save flow. Plugin overflow views may add additional right-dock tool tabs, except plugin destinations that explicitly belong in the left sidebar.
|
||||
|
||||
@@ -64,8 +64,10 @@ Use the desktop/tablet right dock this way:
|
||||
Expected outcome: the dock width changes within its min/max bounds and is saved for future reloads.
|
||||
4. Select the dock expand action.
|
||||
Expected outcome: the same inline tool opens in a resizable modal while the dock remains the source navigation surface.
|
||||
5. Use the dock's own collapse control.
|
||||
Expected outcome: the far-right surface switches between full panel and compact rail without creating duplicate left-sidebar destinations; mobile viewports never render the right dock.
|
||||
5. Select the dock pin action.
|
||||
Expected outcome: pinned mode pushes the main content narrower, unpinned mode overlays the page without reserving space, and the preference is saved for future reloads.
|
||||
6. Use the Header right-sidebar toggle.
|
||||
Expected outcome: the far-right surface opens or closes without creating duplicate left-sidebar destinations; mobile viewports never render or reserve space for the right dock.
|
||||
|
||||
Content views such as Artifacts, Research, Insights, Skills, Memory, Evals, Goals, Dev Server, **Workflows**, **Import Tasks**, and **Automations** live in the left sidebar (or compact mobile navigation) rather than the right dock. On desktop/tablet, GitHub import lives under **Import Tasks**; mobile keeps compact GitHub import entries in the More surfaces.
|
||||
|
||||
|
||||
@@ -34,6 +34,15 @@ The right dock OVERLAYS the page content (floats over the right edge) instead of
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:RightDockPin 2026-06-27-00:00:
|
||||
Pinned mode is CSS-only: the project shell is already a flex row, so switching the dock from absolute overlay to relative in-flow reserves the dock width and shrinks `.project-content` without an App shell class. Keep position:relative (not static) so the absolute resize handle remains anchored to the dock, and remove the floating shadow so the pinned dock reads as a sibling pane with the existing tokenized divider. The mobile `.right-dock { display:none }` media guard still wins because this modifier does not override display.
|
||||
*/
|
||||
.right-dock--pinned {
|
||||
position: relative;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.right-dock--with-footer {
|
||||
padding-bottom: var(--executor-footer-height);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { Maximize2 } from "lucide-react";
|
||||
import { Maximize2, Pin, PinOff } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
findOverflowViewEntry,
|
||||
@@ -21,6 +21,7 @@ export const RIGHT_DOCK_MAX_WIDTH = 1280;
|
||||
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";
|
||||
export const RIGHT_DOCK_PINNED_STORAGE_KEY = "fusion:right-dock-pinned";
|
||||
|
||||
function clampRightDockWidth(width: number): number {
|
||||
return Math.max(RIGHT_DOCK_MIN_WIDTH, Math.min(RIGHT_DOCK_MAX_WIDTH, width));
|
||||
@@ -46,6 +47,23 @@ export function persistRightDockOpen(open: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:RightDockPin 2026-06-27-00:00:
|
||||
Right-dock push mode is a local, reversible UI preference. Default missing/invalid storage to unpinned so existing overlay behavior remains unchanged, and keep the same SSR-safe localStorage pattern used by the dock open flag.
|
||||
*/
|
||||
export function readStoredRightDockPinned(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.localStorage.getItem(RIGHT_DOCK_PINNED_STORAGE_KEY) === "true";
|
||||
}
|
||||
|
||||
export function persistRightDockPinned(pinned: boolean): void {
|
||||
try {
|
||||
window.localStorage.setItem(RIGHT_DOCK_PINNED_STORAGE_KEY, String(pinned));
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
|
||||
function isInlineOverflowViewKey(key: string, options: OverflowViewVisibilityOptions): key is OverflowViewKey {
|
||||
const entry = findOverflowViewEntry(key as OverflowViewKey, options);
|
||||
return Boolean(entry?.render);
|
||||
@@ -79,6 +97,8 @@ export interface RightDockProps {
|
||||
visibilityOptions?: OverflowViewVisibilityOptions;
|
||||
onExpand?: (key: OverflowViewKey) => void;
|
||||
footerVisible?: boolean;
|
||||
pinned: boolean;
|
||||
onTogglePin: () => void;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -100,6 +120,8 @@ export function RightDock({
|
||||
visibilityOptions = {},
|
||||
onExpand,
|
||||
footerVisible = false,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
}: RightDockProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const entries = useMemo(() => getVisibleOverflowViewEntries(visibilityOptions), [visibilityOptions]);
|
||||
@@ -204,10 +226,14 @@ export function RightDock({
|
||||
const SelectedIcon = selectedEntry.icon;
|
||||
const dockWidth = `${width}px`;
|
||||
const expandSelectedViewLabel = t("rightDock.expandView", "Expand {{label}}", { label: selectedEntry.label });
|
||||
const pinLabel = pinned
|
||||
? t("rightDock.unpin", "Unpin sidebar (overlay content)")
|
||||
: t("rightDock.pin", "Pin sidebar (push content)");
|
||||
const PinIcon = pinned ? PinOff : Pin;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`right-dock${open ? "" : " right-dock--collapsed"}${footerVisible ? " right-dock--with-footer" : ""}`}
|
||||
className={`right-dock${open ? "" : " right-dock--collapsed"}${footerVisible ? " right-dock--with-footer" : ""}${pinned ? " right-dock--pinned" : ""}`}
|
||||
style={dockWidth ? { width: dockWidth } : undefined}
|
||||
aria-label={t("rightDock.label", "Right dock")}
|
||||
data-testid="right-dock"
|
||||
@@ -250,6 +276,21 @@ export function RightDock({
|
||||
})}
|
||||
</div>
|
||||
<div className="right-dock__actions">
|
||||
{/*
|
||||
FNXC:RightDockPin 2026-06-27-00:00:
|
||||
The pin affordance belongs only to the in-dock toolbar, not the floating pop-out. It toggles overlay vs push layout while preserving open/close and expanded-modal independence, and aria-pressed mirrors the persisted push-mode state.
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon right-dock__pin"
|
||||
aria-label={pinLabel}
|
||||
title={pinLabel}
|
||||
aria-pressed={pinned}
|
||||
data-testid="right-dock-pin"
|
||||
onClick={onTogglePin}
|
||||
>
|
||||
<PinIcon size={16} />
|
||||
</button>
|
||||
{open && selectedEntry.render ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -2,7 +2,13 @@ import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { RightDock, RIGHT_DOCK_VIEW_STORAGE_KEY, RIGHT_DOCK_WIDTH_STORAGE_KEY } from "../RightDock";
|
||||
import {
|
||||
RightDock,
|
||||
RIGHT_DOCK_PINNED_STORAGE_KEY,
|
||||
RIGHT_DOCK_VIEW_STORAGE_KEY,
|
||||
RIGHT_DOCK_WIDTH_STORAGE_KEY,
|
||||
type RightDockProps,
|
||||
} from "../RightDock";
|
||||
import { RightDockExpandModal } from "../RightDockExpandModal";
|
||||
import { useRightDockController, type RightDockControllerInput } from "../useRightDockController";
|
||||
import { DOCK_FILES_CURRENT_KEY } from "../DockFilesView";
|
||||
@@ -23,6 +29,10 @@ const renderProps = {
|
||||
|
||||
const rightDockCss = readFileSync(resolve(__dirname, "../RightDock.css"), "utf8");
|
||||
|
||||
function TestRightDock(props: Omit<RightDockProps, "pinned" | "onTogglePin"> & Partial<Pick<RightDockProps, "pinned" | "onTogglePin">>) {
|
||||
return <RightDock pinned={false} onTogglePin={vi.fn()} {...props} />;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Navigation 2026-06-22-16:00:
|
||||
The right dock is now an all-inline tools rail sourced from STATIC_OVERFLOW_VIEW_ENTRIES in overflowViewRegistry. The roster, in registry order, is files, activity-log, git-manager, devserver (gated on devServerView), secrets, todos (gated on todosEnabled), pull-requests. The earlier usage/github-import/automation launcher actions were removed, so every visible tab is an inline view that switches the dock body and can expand into the modal.
|
||||
@@ -85,7 +95,7 @@ describe("RightDock", () => {
|
||||
});
|
||||
|
||||
it("renders Files by default and restores the persisted inline view on remount", () => {
|
||||
const { unmount } = render(<RightDock open={true} renderProps={renderProps} />);
|
||||
const { unmount } = render(<TestRightDock open={true} renderProps={renderProps} />);
|
||||
|
||||
expect(screen.getByTestId("right-dock-tab-files")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByTestId("right-dock-files-view")).toBeInTheDocument();
|
||||
@@ -99,7 +109,7 @@ describe("RightDock", () => {
|
||||
expect(window.localStorage.getItem(RIGHT_DOCK_VIEW_STORAGE_KEY)).toBe("git-manager");
|
||||
unmount();
|
||||
|
||||
render(<RightDock open={true} renderProps={renderProps} />);
|
||||
render(<TestRightDock open={true} renderProps={renderProps} />);
|
||||
expect(screen.getByTestId("right-dock-tab-git-manager")).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
@@ -109,19 +119,19 @@ describe("RightDock", () => {
|
||||
*/
|
||||
it("forces the Files two-pane layout when the dock is dragged wide, and stays stacked when narrow", () => {
|
||||
// Narrow default width (360px) -> stacked single-panel.
|
||||
const { unmount } = render(<RightDock open={true} renderProps={renderProps} />);
|
||||
const { unmount } = render(<TestRightDock open={true} renderProps={renderProps} />);
|
||||
expect(screen.getByTestId("right-dock-files-view")).toHaveAttribute("data-layout", "auto");
|
||||
unmount();
|
||||
|
||||
// Wide persisted width (>= 640px) -> deterministic LEFT|RIGHT two-pane.
|
||||
window.localStorage.setItem(RIGHT_DOCK_WIDTH_STORAGE_KEY, "900");
|
||||
render(<RightDock open={true} renderProps={renderProps} />);
|
||||
render(<TestRightDock open={true} renderProps={renderProps} />);
|
||||
expect(screen.getByTestId("right-dock-files-view")).toHaveAttribute("data-layout", "two-pane");
|
||||
});
|
||||
|
||||
it("falls back to Files when storage points at a removed right-dock view", () => {
|
||||
window.localStorage.setItem(RIGHT_DOCK_VIEW_STORAGE_KEY, "documents");
|
||||
render(<RightDock open={true} renderProps={renderProps} />);
|
||||
render(<TestRightDock open={true} renderProps={renderProps} />);
|
||||
|
||||
expect(screen.getByTestId("right-dock-tab-files")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByTestId("right-dock-files-view")).toBeInTheDocument();
|
||||
@@ -129,19 +139,149 @@ describe("RightDock", () => {
|
||||
});
|
||||
|
||||
it("exposes localized right-dock affordance labels without an in-dock collapse shell", () => {
|
||||
render(<RightDock open={true} renderProps={renderProps} />);
|
||||
render(<TestRightDock open={true} renderProps={renderProps} />);
|
||||
|
||||
expect(screen.getByTestId("right-dock")).toHaveAttribute("aria-label", "Right dock");
|
||||
expect(screen.getByTestId("right-dock-resize-handle")).toHaveAttribute("aria-label", "Resize right dock");
|
||||
expect(screen.getByRole("tablist", { name: "Right dock views" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("right-dock-pin")).toHaveAttribute("aria-label", "Pin sidebar (push content)");
|
||||
expect(screen.getByTestId("right-dock-pin")).toHaveAttribute("title", "Pin sidebar (push content)");
|
||||
expect(screen.getByTestId("right-dock-pin")).toHaveAttribute("aria-pressed", "false");
|
||||
expect(screen.getByTestId("right-dock-expand")).toHaveAttribute("aria-label", "Expand Files");
|
||||
expect(screen.getByTestId("right-dock-expand")).toHaveAttribute("title", "Expand Files");
|
||||
expect(screen.queryByTestId("right-dock-collapse-toggle")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the pin affordance for both states and delegates the toggle", () => {
|
||||
const onTogglePin = vi.fn();
|
||||
const { rerender } = render(<TestRightDock open={true} renderProps={renderProps} pinned={false} onTogglePin={onTogglePin} />);
|
||||
|
||||
expect(screen.getByTestId("right-dock-pin")).toHaveAttribute("aria-label", "Pin sidebar (push content)");
|
||||
expect(screen.getByTestId("right-dock-pin")).toHaveAttribute("aria-pressed", "false");
|
||||
expect(screen.getByTestId("right-dock")).not.toHaveClass("right-dock--pinned");
|
||||
|
||||
fireEvent.click(screen.getByTestId("right-dock-pin"));
|
||||
expect(onTogglePin).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<TestRightDock open={true} renderProps={renderProps} pinned={true} onTogglePin={onTogglePin} />);
|
||||
expect(screen.getByTestId("right-dock-pin")).toHaveAttribute("aria-label", "Unpin sidebar (overlay content)");
|
||||
expect(screen.getByTestId("right-dock-pin")).toHaveAttribute("title", "Unpin sidebar (overlay content)");
|
||||
expect(screen.getByTestId("right-dock-pin")).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByTestId("right-dock")).toHaveClass("right-dock--pinned");
|
||||
});
|
||||
|
||||
it("persists and restores pinned state through the right-dock controller", () => {
|
||||
const controllerInput = {
|
||||
active: true,
|
||||
projectId: "project-1",
|
||||
addToast: vi.fn(),
|
||||
settingsLoaded: true,
|
||||
researchReadinessVersion: 0,
|
||||
tasks: [],
|
||||
workflowSteps: [],
|
||||
subscribePluginEvents: () => () => {},
|
||||
openDetailTask: vi.fn(),
|
||||
openFileInBrowser: vi.fn(),
|
||||
openSettings: vi.fn(),
|
||||
onSendSelectionToTask: vi.fn(),
|
||||
onCreateTaskFromInsight: vi.fn(),
|
||||
onNavigateToMission: vi.fn(),
|
||||
onTaskCreated: vi.fn(),
|
||||
prAuthAvailable: false,
|
||||
autoMerge: false,
|
||||
visibilityOptions: {},
|
||||
footerVisible: false,
|
||||
} as unknown as RightDockControllerInput;
|
||||
|
||||
function Harness() {
|
||||
const controller = useRightDockController(controllerInput);
|
||||
return (
|
||||
<>
|
||||
<output data-testid="controller-pinned">{String(controller.pinned)}</output>
|
||||
{controller.dock}
|
||||
{controller.modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const { unmount } = render(<Harness />);
|
||||
expect(screen.getByTestId("controller-pinned")).toHaveTextContent("false");
|
||||
expect(screen.getByTestId("right-dock")).not.toHaveClass("right-dock--pinned");
|
||||
expect(screen.getByTestId("right-dock-pin")).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
fireEvent.click(screen.getByTestId("right-dock-pin"));
|
||||
expect(window.localStorage.getItem(RIGHT_DOCK_PINNED_STORAGE_KEY)).toBe("true");
|
||||
expect(screen.getByTestId("controller-pinned")).toHaveTextContent("true");
|
||||
expect(screen.getByTestId("right-dock")).toHaveClass("right-dock--pinned");
|
||||
expect(screen.getByTestId("right-dock-pin")).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
fireEvent.click(screen.getByTestId("right-dock-pin"));
|
||||
expect(window.localStorage.getItem(RIGHT_DOCK_PINNED_STORAGE_KEY)).toBe("false");
|
||||
expect(screen.getByTestId("right-dock")).not.toHaveClass("right-dock--pinned");
|
||||
unmount();
|
||||
|
||||
window.localStorage.setItem(RIGHT_DOCK_PINNED_STORAGE_KEY, "true");
|
||||
render(<Harness />);
|
||||
expect(screen.getByTestId("controller-pinned")).toHaveTextContent("true");
|
||||
expect(screen.getByTestId("right-dock")).toHaveClass("right-dock--pinned");
|
||||
});
|
||||
|
||||
it("defaults missing, false, and invalid pinned storage to unpinned", () => {
|
||||
const controllerInput = {
|
||||
active: true,
|
||||
projectId: "project-1",
|
||||
addToast: vi.fn(),
|
||||
settingsLoaded: true,
|
||||
researchReadinessVersion: 0,
|
||||
tasks: [],
|
||||
workflowSteps: [],
|
||||
subscribePluginEvents: () => () => {},
|
||||
openDetailTask: vi.fn(),
|
||||
openFileInBrowser: vi.fn(),
|
||||
openSettings: vi.fn(),
|
||||
onSendSelectionToTask: vi.fn(),
|
||||
onCreateTaskFromInsight: vi.fn(),
|
||||
onNavigateToMission: vi.fn(),
|
||||
onTaskCreated: vi.fn(),
|
||||
prAuthAvailable: false,
|
||||
autoMerge: false,
|
||||
visibilityOptions: {},
|
||||
footerVisible: false,
|
||||
} as unknown as RightDockControllerInput;
|
||||
|
||||
function Harness() {
|
||||
const controller = useRightDockController(controllerInput);
|
||||
return <>{controller.dock}</>;
|
||||
}
|
||||
|
||||
const { unmount } = render(<Harness />);
|
||||
expect(screen.getByTestId("right-dock")).not.toHaveClass("right-dock--pinned");
|
||||
unmount();
|
||||
|
||||
window.localStorage.setItem(RIGHT_DOCK_PINNED_STORAGE_KEY, "false");
|
||||
const falseMount = render(<Harness />);
|
||||
expect(screen.getByTestId("right-dock")).not.toHaveClass("right-dock--pinned");
|
||||
falseMount.unmount();
|
||||
|
||||
window.localStorage.setItem(RIGHT_DOCK_PINNED_STORAGE_KEY, "not-json");
|
||||
render(<Harness />);
|
||||
expect(screen.getByTestId("right-dock")).not.toHaveClass("right-dock--pinned");
|
||||
});
|
||||
|
||||
it("keeps pinned layout as an explicit CSS switch from overlay to in-flow push", () => {
|
||||
const baseRule = rightDockCss.match(/\.right-dock\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||
const pinnedRule = rightDockCss.match(/\.right-dock--pinned\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||
|
||||
expect(baseRule).toContain("position: absolute;");
|
||||
expect(pinnedRule).toContain("position: relative;");
|
||||
expect(pinnedRule).toContain("box-shadow: none;");
|
||||
expect(rightDockCss).toContain("@media (max-width: 768px)");
|
||||
expect(rightDockCss).toContain(".right-dock {\n display: none;");
|
||||
});
|
||||
|
||||
it("renders exactly the current right-dock tool entries and no removed content-view tabs", () => {
|
||||
render(
|
||||
<RightDock
|
||||
<TestRightDock
|
||||
open={true}
|
||||
|
||||
renderProps={renderProps}
|
||||
@@ -182,7 +322,7 @@ describe("RightDock", () => {
|
||||
FNXC:Navigation 2026-06-22-16:00:
|
||||
devserver is gated on experimentalFeatures.devServerView and todos on todosEnabled. With both unset (default renderProps), the dock renders only the five always-on inline tools.
|
||||
*/
|
||||
render(<RightDock open={true} renderProps={renderProps} />);
|
||||
render(<TestRightDock open={true} renderProps={renderProps} />);
|
||||
expect(screen.getAllByRole("tab").map((tab) => tab.getAttribute("data-testid"))).toEqual([
|
||||
"right-dock-tab-files",
|
||||
"right-dock-tab-activity-log",
|
||||
@@ -199,7 +339,7 @@ describe("RightDock", () => {
|
||||
FNXC:Navigation 2026-06-22-16:00:
|
||||
The right dock no longer hosts launcher-action tabs that fire Header handlers; every tab is an inline view. Clicking a non-Files tab selects it (aria-selected flips, Files deselects) and replaces the body, and the Files tab restores the inline Files view.
|
||||
*/
|
||||
render(<RightDock open={true} renderProps={renderProps} />);
|
||||
render(<TestRightDock open={true} renderProps={renderProps} />);
|
||||
|
||||
expect(screen.getByTestId("right-dock-tab-files")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByTestId("right-dock-files-view")).toBeInTheDocument();
|
||||
@@ -221,7 +361,7 @@ describe("RightDock", () => {
|
||||
The resize clamp + persisted-width read both funnel through RIGHT_DOCK_MAX_WIDTH, raised to 1280 so the dock drags MUCH wider. Drag far past the cap (startWidth 360 + 2000 px of leftward travel) and assert it clamps to the new 1280 max, then a keyboard step down lands one shift-step (48px) below the cap. This proves the new cap governs both the pointer drag and the keyboard path.
|
||||
*/
|
||||
it("clamps then persists resize width while open", () => {
|
||||
render(<RightDock open={true} renderProps={renderProps} />);
|
||||
render(<TestRightDock open={true} renderProps={renderProps} />);
|
||||
|
||||
const handle = screen.getByTestId("right-dock-resize-handle");
|
||||
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 2000 });
|
||||
@@ -235,7 +375,7 @@ describe("RightDock", () => {
|
||||
|
||||
it("restores persisted width on mount", () => {
|
||||
window.localStorage.setItem(RIGHT_DOCK_WIDTH_STORAGE_KEY, "400");
|
||||
render(<RightDock open={true} renderProps={renderProps} />);
|
||||
render(<TestRightDock open={true} renderProps={renderProps} />);
|
||||
|
||||
expect(screen.getByTestId("right-dock")).toHaveStyle({ width: "400px" });
|
||||
expect(screen.getByTestId("right-dock-resize-handle")).toHaveAttribute("aria-valuenow", "400");
|
||||
@@ -243,7 +383,7 @@ describe("RightDock", () => {
|
||||
|
||||
// FNXC:Navigation 2026-06-22-09:00: Show/hide is owned by the canonical Header right-sidebar toggle. The dock no longer renders an in-dock collapse toggle or a collapsed rail; when open=false it renders nothing so the main content reclaims the space.
|
||||
it("renders nothing when closed and renders the dock content when open", () => {
|
||||
const { rerender } = render(<RightDock open={true} renderProps={renderProps} />);
|
||||
const { rerender } = render(<TestRightDock open={true} renderProps={renderProps} />);
|
||||
|
||||
// Show/hide invariant only — the exact tab set is owned by overflowViewRegistry, not asserted here.
|
||||
expect(screen.getByTestId("right-dock")).toBeInTheDocument();
|
||||
@@ -252,13 +392,14 @@ describe("RightDock", () => {
|
||||
expect(screen.getAllByRole("tab").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByTestId("right-dock-collapse-toggle")).toBeNull();
|
||||
|
||||
rerender(<RightDock open={false} renderProps={renderProps} />);
|
||||
rerender(<TestRightDock open={false} renderProps={renderProps} />);
|
||||
expect(screen.queryByTestId("right-dock")).toBeNull();
|
||||
expect(screen.queryByTestId("right-dock-body")).toBeNull();
|
||||
expect(screen.queryByTestId("right-dock-resize-handle")).toBeNull();
|
||||
expect(screen.queryByTestId("right-dock-pin")).toBeNull();
|
||||
expect(screen.queryAllByRole("tab")).toHaveLength(0);
|
||||
|
||||
rerender(<RightDock open={true} renderProps={renderProps} />);
|
||||
rerender(<TestRightDock open={true} renderProps={renderProps} />);
|
||||
expect(screen.getByTestId("right-dock")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("right-dock-body")).toBeInTheDocument();
|
||||
});
|
||||
@@ -281,6 +422,7 @@ describe("RightDock", () => {
|
||||
expect(screen.getByTestId("right-dock-expand-modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("right-dock-expand-modal")).toHaveAttribute("aria-label", "Files expanded");
|
||||
expect(screen.getByTestId("right-dock-expand-body")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("right-dock-pin")).toBeNull();
|
||||
/*
|
||||
FNXC:RightDock 2026-06-22-17:40:
|
||||
The pop-out is a floating, non-blocking window: the overlay carries the non-blocking class (transparent + pointer-events:none in CSS so behind-clicks pass through), a drag handle (header) exists, and the panel is the floating variant. There is no overlay click-to-dismiss; the explicit close button is the only dismissal.
|
||||
@@ -360,7 +502,7 @@ describe("RightDock", () => {
|
||||
Every tab is inline, so the expand button fires onExpand with whichever inline entry is selected (here git-manager after switching away from the default Files).
|
||||
*/
|
||||
const onExpand = vi.fn();
|
||||
render(<RightDock open={true} renderProps={renderProps} onExpand={onExpand} />);
|
||||
render(<TestRightDock open={true} renderProps={renderProps} onExpand={onExpand} />);
|
||||
fireEvent.click(screen.getByTestId("right-dock-tab-git-manager"));
|
||||
fireEvent.click(screen.getByTestId("right-dock-expand"));
|
||||
expect(onExpand).toHaveBeenCalledWith("git-manager");
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fetchTaskDetail } from "../api";
|
||||
import { getScopedItem } from "../utils/projectStorage";
|
||||
import { DOCK_FILES_CURRENT_KEY } from "./DockFilesView";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import { RightDock, persistRightDockOpen, readStoredRightDockOpen } from "./RightDock";
|
||||
import { RightDock, persistRightDockOpen, persistRightDockPinned, readStoredRightDockOpen, readStoredRightDockPinned } from "./RightDock";
|
||||
import { RightDockExpandModal } from "./RightDockExpandModal";
|
||||
import type { OverflowViewKey, OverflowViewRenderProps, OverflowViewVisibilityOptions } from "./overflowViewRegistry";
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface RightDockControllerInput {
|
||||
export interface RightDockController {
|
||||
open: boolean;
|
||||
toggle: () => void;
|
||||
pinned: boolean;
|
||||
togglePin: () => void;
|
||||
dock: ReactNode;
|
||||
modal: ReactNode;
|
||||
}
|
||||
@@ -55,6 +57,11 @@ The popped-out expand modal is INDEPENDENT of the dock's open state. `expandedVi
|
||||
*/
|
||||
export function useRightDockController(input: RightDockControllerInput): RightDockController {
|
||||
const [open, setOpen] = useState(readStoredRightDockOpen);
|
||||
/*
|
||||
FNXC:RightDockPin 2026-06-27-00:00:
|
||||
Pin state is owned next to open state so the Header toggle, dock render, and pop-out modal share one controller contract. The flag persists independently of open/expanded state: closing or popping out the dock must not erase the user's overlay-vs-push preference.
|
||||
*/
|
||||
const [pinned, setPinned] = useState(readStoredRightDockPinned);
|
||||
const [expandedView, setExpandedView] = useState<OverflowViewKey | null>(null);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
@@ -66,6 +73,14 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
|
||||
});
|
||||
}, []);
|
||||
|
||||
const togglePin = useCallback(() => {
|
||||
setPinned((current) => {
|
||||
const next = !current;
|
||||
persistRightDockPinned(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/*
|
||||
FNXC:RightDock 2026-06-22-19:25:
|
||||
Popping a view out CLOSES the right dock but KEEPS the floating modal open. The modal is independent of dock open state (see expandedView note above), so collapsing the dock on pop-out gives the user the full-width app behind the movable, non-blocking modal. Clearing the pop-out (viewKey null) leaves the dock as-is.
|
||||
@@ -155,7 +170,9 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
|
||||
return {
|
||||
open,
|
||||
toggle,
|
||||
dock: input.active ? <RightDock open={open} renderProps={renderProps} visibilityOptions={input.visibilityOptions} footerVisible={input.footerVisible} onExpand={handleExpand} /> : null,
|
||||
pinned,
|
||||
togglePin,
|
||||
dock: input.active ? <RightDock open={open} renderProps={renderProps} visibilityOptions={input.visibilityOptions} footerVisible={input.footerVisible} pinned={pinned} onTogglePin={togglePin} onExpand={handleExpand} /> : null,
|
||||
modal: input.active ? <RightDockExpandModal viewKey={expandedView} renderProps={renderProps} visibilityOptions={input.visibilityOptions} onClose={() => setExpandedView(null)} /> : null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user