diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index ad6f208d08d..15aa3c6e099 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -2,11 +2,8 @@ import { useEffect, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import ThreadSidebar from "./Sidebar"; -import { RightPanelSheet } from "./RightPanelSheet"; import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; -import { useMediaQuery } from "../hooks/useMediaQuery"; -import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; -import { PixsoPanel, PixsoPanelInlineSidebar, usePixsoStore } from "../ru-fork/pixso-move"; +import { OverlayPanelHost } from "../ru-fork/rightPanel"; import { clearShortcutModifierState, syncShortcutModifierStateFromKeyboardEvent, @@ -17,9 +14,6 @@ const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); - const pixsoOpen = usePixsoStore((state) => state.panelOpen); - const closePixso = usePixsoStore((state) => state.closePanel); - const useRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { @@ -77,13 +71,11 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {children} - {useRightPanelSheet ? ( - - - - ) : ( - - )} + {/* ru-fork: one shared host for all global overlay panels, mounted once outside + the routes so it never remounts on navigation. The coordinator decides which + entry is shown; the host keeps the slot open across swaps and keeps content + mounted through the close slide. */} + ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c3d1b0c9ff..be8f874e4dd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -196,7 +196,7 @@ import { import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { retainThreadDetailSubscription } from "../environments/runtime/service"; import { RightPanelSheet } from "./RightPanelSheet"; -import { useMcpManagerStore } from "../ru-fork/mcp-manage"; +import { useRightPanelStore } from "../ru-fork/rightPanel"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; @@ -1596,8 +1596,7 @@ export default function ChatView(props: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), [keybindings, nonTerminalShortcutLabelOptions], ); - const mcpPanelOpen = useMcpManagerStore((state) => state.panelOpen); - const setMcpPanelOpen = useMcpManagerStore((state) => state.setPanelOpen); + const closeOverlayPanel = useRightPanelStore((state) => state.close); const onToggleDiff = useCallback(() => { if (!isServerThread) { @@ -1605,8 +1604,9 @@ export default function ChatView(props: ChatViewProps) { } if (!diffOpen) { onDiffPanelOpen?.(); - // Diff and MCP share the right-panel slot: opening diff closes MCP. - setMcpPanelOpen(false); + // Diff and the overlay panels share the right-panel slot: opening diff + // closes whichever overlay (MCP/Pixso) is open. + closeOverlayPanel(); } void navigate({ to: "/$environmentId/$threadId", @@ -1626,27 +1626,10 @@ export default function ChatView(props: ChatViewProps) { isServerThread, navigate, onDiffPanelOpen, - setMcpPanelOpen, + closeOverlayPanel, threadId, ]); - const onToggleMcp = useCallback(() => { - const next = !mcpPanelOpen; - setMcpPanelOpen(next); - // Mutual exclusion with the diff panel: opening MCP closes diff. - if (next && diffOpen) { - void navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId }, - replace: true, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: undefined }; - }, - }); - } - }, [diffOpen, environmentId, mcpPanelOpen, navigate, setMcpPanelOpen, threadId]); - const envLocked = Boolean( activeThread && (activeThread.messages.length > 0 || @@ -3634,7 +3617,6 @@ export default function ChatView(props: ChatViewProps) { diffToggleShortcutLabel={diffPanelShortcutLabel} gitCwd={gitCwd} diffOpen={diffOpen} - mcpPanelOpen={mcpPanelOpen} advancedChatOpen={advancedChatOpen} onToggleAdvancedChat={() => { if (activeThread) toggleAdvancedChatMode(activeThread.id); @@ -3645,7 +3627,6 @@ export default function ChatView(props: ChatViewProps) { onDeleteProjectScript={deleteProjectScript} onToggleTerminal={toggleTerminalVisibility} onToggleDiff={onToggleDiff} - onToggleMcp={onToggleMcp} /> diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 829ed4159d4..c1f54fcd38e 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -21,6 +21,8 @@ export function DiffPanelShell(props: { mode: DiffPanelMode; header: ReactNode; children: ReactNode; + /** Optional override for the shell's surface (e.g. `bg-card`); defaults to `bg-background`. */ + className?: string; }) { const shouldUseDragRegion = isElectron && props.mode !== "sheet"; @@ -31,6 +33,7 @@ export function DiffPanelShell(props: { props.mode === "inline" ? "w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border" : "w-full", + props.className, )} > {shouldUseDragRegion ? ( diff --git a/apps/web/src/components/RightInlineSidebar.tsx b/apps/web/src/components/RightInlineSidebar.tsx new file mode 100644 index 00000000000..44fe17fa73b --- /dev/null +++ b/apps/web/src/components/RightInlineSidebar.tsx @@ -0,0 +1,52 @@ +import type { CSSProperties, ReactNode } from "react"; + +import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; + +// ru-fork: single generic shell for the right-side inline sidebars (diff + overlays). +// Replaces three byte-identical wrappers — callers differ only in width constants, +// the storage key, an optional shouldAcceptWidth guard, and the (already open-gated) +// children they render. +export function RightInlineSidebar({ + open, + onOpenChange, + storageKey, + defaultWidth, + minWidth, + maxWidth, + shouldAcceptWidth, + children, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + storageKey: string; + defaultWidth: string; + minWidth: number; + maxWidth: number; + shouldAcceptWidth?: (args: { nextWidth: number; wrapper: HTMLElement }) => boolean; + children: ReactNode; +}) { + return ( + + + {children} + + + + ); +} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 2d27d4b362d..8b571d35d2f 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -9,7 +9,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; import { memo } from "react"; import GitActionsControl from "../GitActionsControl"; import { type DraftId } from "~/composerDraftStore"; -import { BoxesIcon, DiffIcon, SparklesIcon, TerminalSquareIcon } from "lucide-react"; +import { DiffIcon, SparklesIcon, TerminalSquareIcon } from "lucide-react"; import { Badge } from "../ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; @@ -36,7 +36,6 @@ interface ChatHeaderProps { diffToggleShortcutLabel: string | null; gitCwd: string | null; diffOpen: boolean; - mcpPanelOpen: boolean; // ru-fork: advanced chat mode (qwen transcript view) toggle. advancedChatOpen: boolean; onToggleAdvancedChat: () => void; @@ -46,7 +45,6 @@ interface ChatHeaderProps { onDeleteProjectScript: (scriptId: string) => Promise; onToggleTerminal: () => void; onToggleDiff: () => void; - onToggleMcp: () => void; } export const ChatHeader = memo(function ChatHeader({ @@ -67,7 +65,6 @@ export const ChatHeader = memo(function ChatHeader({ diffToggleShortcutLabel, gitCwd, diffOpen, - mcpPanelOpen, advancedChatOpen, onToggleAdvancedChat, onRunProjectScript, @@ -76,7 +73,6 @@ export const ChatHeader = memo(function ChatHeader({ onDeleteProjectScript, onToggleTerminal, onToggleDiff, - onToggleMcp, }: ChatHeaderProps) { const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteEnvironment = @@ -177,23 +173,6 @@ export const ChatHeader = memo(function ChatHeader({ : "Показать/скрыть панель diff"} - - - - - } - /> - Показать/скрыть панель MCP-серверов - {/* ru-fork: advanced chat mode — render the CLI transcript view. */} import("../components/DiffPanel")); const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; @@ -114,28 +115,17 @@ const DiffPanelInlineSidebar = (props: { ); return ( - - - {renderDiffContent ? : null} - - - + {renderDiffContent ? : null} + ); }; @@ -169,9 +159,12 @@ function ChatThreadRouteView() { const serverThreadStarted = threadHasStarted(serverThread); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; const diffOpen = search.diff === "1"; - // MCP panel open/close is global (useMcpManagerStore) and rendered once in the - // _chat layout; here we only need the setter to keep diff + MCP mutually exclusive. - const setMcpPanelOpen = useMcpManagerStore((store) => store.setPanelOpen); + // The overlay panels (MCP/Pixso) are coordinator-driven and mounted in + // AppSidebarLayout. Diff stays URL-driven; the two seams below keep all three + // mutually exclusive: opening diff closes the overlay, and the effect closes + // diff whenever an overlay opens (e.g. from the left nav). + const overlayPanelOpen = useRightPanelStore((store) => store.open !== null); + const closeOverlayPanel = useRightPanelStore((store) => store.close); const shouldUseDiffSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null; const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({ @@ -208,8 +201,8 @@ function ChatThreadRouteView() { return; } markDiffOpened(); - // Diff and MCP share the right-panel slot — only one open at a time. - setMcpPanelOpen(false); + // Diff and the overlay panels share the right-panel slot — only one open. + closeOverlayPanel(); void navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), @@ -218,7 +211,14 @@ function ChatThreadRouteView() { return { ...rest, diff: "1" }; }, }); - }, [markDiffOpened, navigate, setMcpPanelOpen, threadRef]); + }, [markDiffOpened, navigate, closeOverlayPanel, threadRef]); + + // Seam: opening an overlay panel (from the left nav) closes the diff panel. + useEffect(() => { + if (overlayPanelOpen && diffOpen) { + closeDiff(); + } + }, [overlayPanelOpen, diffOpen, closeDiff]); useEffect(() => { if (!threadRef || !bootstrapComplete) { @@ -261,7 +261,7 @@ function ChatThreadRouteView() { onOpenDiff={openDiff} renderDiffContent={shouldRenderDiffContent} /> - {/* ru-fork: the MCP panel is mounted once in the _chat layout (McpPanelMount). */} + {/* ru-fork: the MCP/Pixso overlay panels are mounted in AppSidebarLayout, not per route. */} ); } @@ -279,7 +279,7 @@ function ChatThreadRouteView() { {shouldRenderDiffContent ? : null} - {/* ru-fork: the MCP panel sheet is mounted once in the _chat layout. */} + {/* ru-fork: the MCP/Pixso overlay panels are mounted in AppSidebarLayout, not per route. */} ); } diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index e8c78abf950..8ea532c8cba 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -69,8 +69,8 @@ function DraftChatThreadRouteView() { return null; } - // The MCP panel is mounted once in the _chat layout (McpPanelMount), so it is - // available here on drafts too without a per-route mount. + // The MCP + Pixso overlay panels are mounted once in AppSidebarLayout + // (coordinator-driven), so they are available here on drafts too. return ( - {/* ru-fork: single MCP panel mount for all chat routes (draft + thread) — - hoisted here so it never remounts on navigation. */} - + {/* ru-fork: the MCP + Pixso overlay panels are mounted once in + AppSidebarLayout (coordinator-driven), not here. */} ); } diff --git a/apps/web/src/ru-fork/mcp-manage/components/McpPanel.tsx b/apps/web/src/ru-fork/mcp-manage/components/McpPanel.tsx index f69f95919d7..d7c0856c850 100644 --- a/apps/web/src/ru-fork/mcp-manage/components/McpPanel.tsx +++ b/apps/web/src/ru-fork/mcp-manage/components/McpPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from "react"; import { BoxesIcon, XIcon } from "lucide-react"; -import { Button } from "~/components/ui/button"; +import { DiffPanelShell, type DiffPanelMode } from "~/components/DiffPanelShell"; +import { Toggle } from "~/components/ui/toggle-group"; import { Tabs, TabsIndicator, TabsList, TabsPanel, TabsTab } from "~/components/ui/tabs"; import { useActiveProject } from "~/hooks/useActiveProject"; import { useMcpManagerStore } from "../store"; @@ -9,10 +10,20 @@ import { ProjectsTab } from "./ProjectsTab"; import { RegistryTab } from "./RegistryTab"; /** - * The MCP manager panel content: a header with title + close, two tabs (Каталог / Проекты), - * and their panels. Shared by the inline sidebar and the mobile sheet (both pass `onClose`). + * The MCP manager panel. Built on `DiffPanelShell` (same as PixsoPanel) so its background + * and header match the diff/Pixso panels exactly — `bg-background`, the same header row + * (height + Electron drag-region + titlebar handling + `px-4` + bottom border). The close + * button is the same outline `Toggle` (`variant="outline" size="xs"`, `pressed={false}`) + * those headers use. Body: two tabs (Каталог / Проекты). Shared by the inline sidebar + * (`mode="sidebar"`) and the mobile sheet (`mode="sheet"`). */ -export function McpPanel({ onClose }: { onClose: () => void }) { +export function McpPanel({ + onClose, + mode = "sidebar", +}: { + onClose: () => void; + mode?: DiffPanelMode; +}) { const activeTab = useMcpManagerStore((state) => state.activeTab); const setActiveTab = useMcpManagerStore((state) => state.setActiveTab); const selectProject = useMcpManagerStore((state) => state.selectProject); @@ -30,20 +41,28 @@ export function McpPanel({ onClose }: { onClose: () => void }) { } }, [routeProjectId, selectProject]); - return ( -
-
-
- -

MCP-серверы

-
-
- -
+ const header = ( + <> +
+ +

MCP-серверы

+
+
+ onClose()} + aria-label="Закрыть панель MCP" + > + +
+ + ); + return ( + setActiveTab(value as McpPanelTab)} @@ -61,6 +80,6 @@ export function McpPanel({ onClose }: { onClose: () => void }) { -
+ ); } diff --git a/apps/web/src/ru-fork/mcp-manage/components/McpPanelInlineSidebar.tsx b/apps/web/src/ru-fork/mcp-manage/components/McpPanelInlineSidebar.tsx deleted file mode 100644 index c96e20ab7bb..00000000000 --- a/apps/web/src/ru-fork/mcp-manage/components/McpPanelInlineSidebar.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { CSSProperties } from "react"; -import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; -import { McpPanel } from "./McpPanel"; - -const MCP_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_mcp_sidebar_width"; -const MCP_INLINE_DEFAULT_WIDTH = "clamp(24rem,32vw,34rem)"; -const MCP_INLINE_SIDEBAR_MIN_WIDTH = 22 * 16; -const MCP_INLINE_SIDEBAR_MAX_WIDTH = 40 * 16; - -/** - * Desktop inline right-sidebar host for the MCP panel. Mirrors the diff panel's inline - * sidebar so the two share the same slot and resize/offcanvas behaviour, only one open at a - * time (mutual exclusion is enforced by the caller). - */ -export function McpPanelInlineSidebar({ - open, - onClose, -}: { - open: boolean; - onClose: () => void; -}) { - return ( - { - if (!next) onClose(); - }} - className="min-h-0 w-auto flex-none bg-transparent" - style={{ "--sidebar-width": MCP_INLINE_DEFAULT_WIDTH } as CSSProperties} - > - - {open ? : null} - - - - ); -} diff --git a/apps/web/src/ru-fork/mcp-manage/components/McpPanelMount.tsx b/apps/web/src/ru-fork/mcp-manage/components/McpPanelMount.tsx deleted file mode 100644 index 10cd1bf8860..00000000000 --- a/apps/web/src/ru-fork/mcp-manage/components/McpPanelMount.tsx +++ /dev/null @@ -1,31 +0,0 @@ -// ru-fork: the single mount point for the MCP panel, hoisted into the shared -// `_chat` layout so it lives OUTSIDE any per-route component. This is what stops -// the panel remounting/jumping when navigating draft → thread (and makes it -// available on draft routes too). State is global (useMcpManagerStore), so it -// survives navigation; the panel chooses its responsive form here — a desktop -// inline right-sidebar or a mobile sheet — mirroring the diff panel. - -import { useCallback } from "react"; - -import { RightPanelSheet } from "~/components/RightPanelSheet"; -import { useMediaQuery } from "~/hooks/useMediaQuery"; -import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "~/rightPanelLayout"; -import { useMcpManagerStore } from "../store"; -import { McpPanel } from "./McpPanel"; -import { McpPanelInlineSidebar } from "./McpPanelInlineSidebar"; - -export function McpPanelMount() { - const open = useMcpManagerStore((state) => state.panelOpen); - const setPanelOpen = useMcpManagerStore((state) => state.setPanelOpen); - const close = useCallback(() => setPanelOpen(false), [setPanelOpen]); - const useSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); - - if (useSheet) { - return ( - - - - ); - } - return ; -} diff --git a/apps/web/src/ru-fork/mcp-manage/index.ts b/apps/web/src/ru-fork/mcp-manage/index.ts index 6b27ea8a887..4be00c34cbf 100644 --- a/apps/web/src/ru-fork/mcp-manage/index.ts +++ b/apps/web/src/ru-fork/mcp-manage/index.ts @@ -8,8 +8,6 @@ */ export { McpPanel } from "./components/McpPanel"; -export { McpPanelInlineSidebar } from "./components/McpPanelInlineSidebar"; -export { McpPanelMount } from "./components/McpPanelMount"; export { isToolEnabled, selectProjectBindings, diff --git a/apps/web/src/ru-fork/mcp-manage/store.ts b/apps/web/src/ru-fork/mcp-manage/store.ts index 3255c9097c0..3577d9fbb44 100644 --- a/apps/web/src/ru-fork/mcp-manage/store.ts +++ b/apps/web/src/ru-fork/mcp-manage/store.ts @@ -4,34 +4,29 @@ * ru-fork: the catalog/bindings/projects data + mutations used to live here on * fake in-memory state. They now come from the real backend via {@link useMcp} * (atoms in rpc/mcpState + the 5 mcp.* orchestration commands). This store holds - * ONLY ephemeral UI state — which panel/tab/selection is open. The pure - * selectors below operate on the UI view-types the hooks produce. + * ONLY ephemeral tab/selection state. Panel open/close (and MCP⊕Pixso mutual + * exclusion) lives in the shared right-panel coordinator (ru-fork/rightPanel). + * The pure selectors below operate on the UI view-types the hooks produce. */ import { create } from "zustand"; import type { McpPanelTab, McpProjectBinding, McpRegistryServer } from "./types"; interface McpManagerState { - readonly panelOpen: boolean; readonly activeTab: McpPanelTab; readonly selectedServerId: string | null; readonly selectedProjectId: string; - readonly setPanelOpen: (open: boolean) => void; - readonly togglePanel: () => void; readonly setActiveTab: (tab: McpPanelTab) => void; readonly selectServer: (serverId: string | null) => void; readonly selectProject: (projectId: string) => void; } export const useMcpManagerStore = create()((set) => ({ - panelOpen: false, activeTab: "registry", selectedServerId: null, selectedProjectId: "", - setPanelOpen: (open) => set({ panelOpen: open }), - togglePanel: () => set((state) => ({ panelOpen: !state.panelOpen })), setActiveTab: (tab) => set({ activeTab: tab }), selectServer: (serverId) => set({ selectedServerId: serverId }), selectProject: (projectId) => set({ selectedProjectId: projectId }), diff --git a/apps/web/src/ru-fork/pixso-move/components/PixsoNavGroup.tsx b/apps/web/src/ru-fork/pixso-move/components/PixsoNavGroup.tsx index fced781b3ab..49e7cad89ac 100644 --- a/apps/web/src/ru-fork/pixso-move/components/PixsoNavGroup.tsx +++ b/apps/web/src/ru-fork/pixso-move/components/PixsoNavGroup.tsx @@ -1,39 +1,40 @@ -import { LayersIcon, ServerIcon } from "lucide-react"; import { SidebarGroup, SidebarMenu, SidebarMenuButton, SidebarMenuItem, } from "~/components/ui/sidebar"; -import { usePixsoStore } from "../store"; +import { OVERLAY_PANELS, useRightPanelStore } from "~/ru-fork/rightPanel"; /** - * Left-nav block (under search, above the projects list): "Макеты Pixso" opens the right - * panel; "MCP Серверы" is an inert placeholder for now (does nothing). + * Left-nav block (under search, above the projects list): one button per global + * overlay panel (from OVERLAY_PANELS). Each toggles its right panel (click again to + * close) and is highlighted while open; the coordinator keeps only one open at a time. */ export function PixsoNavGroup() { - const openPanel = usePixsoStore((state) => state.openPanel); + const openPanel = useRightPanelStore((state) => state.open); + const toggle = useRightPanelStore((state) => state.toggle); return ( - - - - Макеты Pixso - - - - - - MCP Серверы - - + {OVERLAY_PANELS.map((panel) => { + const Icon = panel.icon; + return ( + + toggle(panel.id)} + data-testid={`overlay-nav-${panel.id}`} + > + + {panel.label} + + + ); + })} ); diff --git a/apps/web/src/ru-fork/pixso-move/components/PixsoPanel.tsx b/apps/web/src/ru-fork/pixso-move/components/PixsoPanel.tsx index e7843e8558e..ebcccbfe671 100644 --- a/apps/web/src/ru-fork/pixso-move/components/PixsoPanel.tsx +++ b/apps/web/src/ru-fork/pixso-move/components/PixsoPanel.tsx @@ -65,7 +65,7 @@ export function PixsoPanel({ ); return ( - + {view === "settings" ? ( ) : view === "detail" ? ( diff --git a/apps/web/src/ru-fork/pixso-move/components/PixsoPanelInlineSidebar.tsx b/apps/web/src/ru-fork/pixso-move/components/PixsoPanelInlineSidebar.tsx deleted file mode 100644 index b7e3e504f65..00000000000 --- a/apps/web/src/ru-fork/pixso-move/components/PixsoPanelInlineSidebar.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { CSSProperties } from "react"; -import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; -import { PixsoPanel } from "./PixsoPanel"; - -const PIXSO_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_pixso_sidebar_width"; -const PIXSO_INLINE_DEFAULT_WIDTH = "clamp(24rem,32vw,34rem)"; -const PIXSO_INLINE_SIDEBAR_MIN_WIDTH = 22 * 16; -const PIXSO_INLINE_SIDEBAR_MAX_WIDTH = 40 * 16; - -/** - * Desktop inline right-sidebar host for the Pixso panel. Mirrors the MCP / diff inline - * sidebars so they share the same slot and resize/offcanvas behaviour. - */ -export function PixsoPanelInlineSidebar({ - open, - onClose, -}: { - open: boolean; - onClose: () => void; -}) { - return ( - { - if (!next) onClose(); - }} - className="min-h-0 w-auto flex-none bg-transparent" - style={{ "--sidebar-width": PIXSO_INLINE_DEFAULT_WIDTH } as CSSProperties} - > - - {open ? : null} - - - - ); -} diff --git a/apps/web/src/ru-fork/pixso-move/index.ts b/apps/web/src/ru-fork/pixso-move/index.ts index f4554ae233e..9d15b3d2a33 100644 --- a/apps/web/src/ru-fork/pixso-move/index.ts +++ b/apps/web/src/ru-fork/pixso-move/index.ts @@ -1,12 +1,11 @@ /** * Pixso Move (ru-fork) — public surface. * - * A left-nav entry ("Макеты Pixso") opens a right panel that reads a designer's macets from + * A left-nav entry ("Макеты Pixso") toggles a right panel that reads a designer's macets from * the pixso-move server: a preview gallery → node detail (preview + JSON + LLM result blocks) * → settings. Isolated here to keep the impact on the wider web app minimal. */ export { PixsoNavGroup } from "./components/PixsoNavGroup"; export { PixsoPanel } from "./components/PixsoPanel"; -export { PixsoPanelInlineSidebar } from "./components/PixsoPanelInlineSidebar"; export { usePixsoStore } from "./store"; diff --git a/apps/web/src/ru-fork/pixso-move/store.ts b/apps/web/src/ru-fork/pixso-move/store.ts index 679843cc92a..6c50d891bb6 100644 --- a/apps/web/src/ru-fork/pixso-move/store.ts +++ b/apps/web/src/ru-fork/pixso-move/store.ts @@ -1,7 +1,9 @@ /** - * Pixso Move — UI + settings store (zustand). Mirrors the MCP manager store: panel open - * state + a small master→detail view machine, plus the designer's settings persisted to - * localStorage (the web app is not sandboxed, so localStorage is safe here). + * Pixso Move — UI + settings store (zustand). Mirrors the MCP manager store: a small + * master→detail view machine, plus the designer's settings persisted to localStorage + * (the web app is not sandboxed, so localStorage is safe here). Panel open/close (and + * MCP⊕Pixso mutual exclusion) lives in the shared right-panel coordinator + * (ru-fork/rightPanel), not here. */ import { create } from "zustand"; @@ -51,15 +53,12 @@ function persist(settings: PixsoSettings): void { } interface PixsoStore { - readonly panelOpen: boolean; readonly view: PixsoView; readonly selectedNodeId: string | null; /** Bumped by the refresh button; gates the gallery query (manual refresh only). */ readonly refreshNonce: number; readonly settings: PixsoSettings; - readonly openPanel: () => void; - readonly closePanel: () => void; readonly openSettings: () => void; readonly openNode: (nodeId: string) => void; readonly backToGallery: () => void; @@ -68,14 +67,11 @@ interface PixsoStore { } export const usePixsoStore = create()((set) => ({ - panelOpen: false, view: "gallery", selectedNodeId: null, refreshNonce: 0, settings: loadSettings(), - openPanel: () => set({ panelOpen: true }), - closePanel: () => set({ panelOpen: false }), openSettings: () => set({ view: "settings" }), openNode: (nodeId) => set({ selectedNodeId: nodeId, view: "detail" }), backToGallery: () => set({ view: "gallery" }), diff --git a/apps/web/src/ru-fork/rightPanel/OverlayPanelHost.tsx b/apps/web/src/ru-fork/rightPanel/OverlayPanelHost.tsx new file mode 100644 index 00000000000..7ad5cd07a61 --- /dev/null +++ b/apps/web/src/ru-fork/rightPanel/OverlayPanelHost.tsx @@ -0,0 +1,71 @@ +import { useRef } from "react"; + +import { RightInlineSidebar } from "~/components/RightInlineSidebar"; +import { RightPanelSheet } from "~/components/RightPanelSheet"; +import { useMediaQuery } from "~/hooks/useMediaQuery"; +import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "~/rightPanelLayout"; +import { OVERLAY_PANELS, type OverlayPanel } from "./registry"; +import { useRightPanelStore } from "./store"; + +/** + * ru-fork: the single shared host for every global overlay panel. ONE slider (or + * sheet) whose content is whichever registry entry is open — which is what makes the + * transitions read well: + * - open → close keeps the active panel's content mounted through the slide-out, so + * the real panel (with its own background) slides away instead of an empty shell; + * - switching one overlay for another keeps the slot open and swaps content in place + * (no slide-out-then-slide-in); + * - width + remembered resize follow the active panel (each entry has its own + * storageKey); the sidebar re-reads that on swap and animates `--sidebar-width`. + * + * Visibility + N-way mutual exclusion come from useRightPanelStore. + */ +export function OverlayPanelHost() { + const open = useRightPanelStore((state) => state.open); + const close = useRightPanelStore((state) => state.close); + const shouldUseSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); + + // Remember the last opened panel so its content stays rendered while the slot + // slides closed (`open` is null during the close transition). + const lastShownRef = useRef(open); + if (open !== null) { + lastShownRef.current = open; + } + + const activeId = open ?? lastShownRef.current; + const active = activeId === null ? null : OVERLAY_PANELS.find((panel) => panel.id === activeId); + + const isOpen = open !== null; + const form = shouldUseSheet ? "sheet" : "sidebar"; + const content = active ? active.render(form, close) : null; + // The host must stay mounted (collapsed) even before the first open, otherwise the + // first open would mount the slider already-expanded and skip the slide-in (CSS + // transitions only animate state changes, not initial mount). Width comes from the + // active panel; before anything is opened it falls back to the first registry entry + // (widths are uniform, and the slot is off-screen then anyway). + const width = (active ?? OVERLAY_PANELS[0]).width; + + if (shouldUseSheet) { + return ( + + {content} + + ); + } + + return ( + { + if (!nextOpen) close(); + }} + storageKey={width.storageKey} + defaultWidth={width.defaultWidth} + minWidth={width.minWidth} + maxWidth={width.maxWidth} + > + {content} + + ); +} + diff --git a/apps/web/src/ru-fork/rightPanel/index.ts b/apps/web/src/ru-fork/rightPanel/index.ts new file mode 100644 index 00000000000..3d22d5927e7 --- /dev/null +++ b/apps/web/src/ru-fork/rightPanel/index.ts @@ -0,0 +1,8 @@ +/** + * Right-panel coordinator (ru-fork) — single source of truth for which global + * overlay panel is open, plus the panel registry. See ./store for the diff contract. + */ + +export { useRightPanelStore } from "./store"; +export { OverlayPanelHost } from "./OverlayPanelHost"; +export { OVERLAY_PANELS, type OverlayPanel } from "./registry"; diff --git a/apps/web/src/ru-fork/rightPanel/registry.tsx b/apps/web/src/ru-fork/rightPanel/registry.tsx new file mode 100644 index 00000000000..e978d78af02 --- /dev/null +++ b/apps/web/src/ru-fork/rightPanel/registry.tsx @@ -0,0 +1,68 @@ +import type { LucideIcon } from "lucide-react"; +import { LayersIcon, ServerIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { McpPanel } from "~/ru-fork/mcp-manage/components/McpPanel"; +import { PixsoPanel } from "~/ru-fork/pixso-move/components/PixsoPanel"; + +/** + * The form a panel is rendered in: a wide-screen offcanvas sidebar or a narrow-screen + * sheet. Values are a subset of `DiffPanelMode` so they pass straight through as a + * panel's `mode` (no mapping). ("inline" — the legacy full-width diff — is unused here.) + */ +export type OverlayPanelForm = "sidebar" | "sheet"; + +export interface OverlayPanelWidth { + readonly storageKey: string; + readonly defaultWidth: string; + readonly minWidth: number; + readonly maxWidth: number; +} + +export interface OverlayPanelEntry { + readonly id: string; + readonly label: string; + readonly icon: LucideIcon; + readonly width: OverlayPanelWidth; + readonly render: (form: OverlayPanelForm, onClose: () => void) => ReactNode; +} + +/** + * ru-fork: the registry of global right-side overlay panels. Adding a panel = one + * entry here; the id-union, the left-nav button, the mount, and N-way mutual + * exclusion all follow automatically. `as const satisfies` keeps each entry + * type-checked while preserving the literal `id`s for the derived union below. + */ +export const OVERLAY_PANELS = [ + { + id: "mcp", + label: "MCP Серверы", + icon: ServerIcon, + width: { + storageKey: "chat_mcp_sidebar_width", + defaultWidth: "clamp(24rem,32vw,34rem)", + minWidth: 22 * 16, + maxWidth: 40 * 16, + }, + render: (form: OverlayPanelForm, onClose: () => void) => ( + + ), + }, + { + id: "pixso", + label: "Макеты Pixso", + icon: LayersIcon, + width: { + storageKey: "chat_pixso_sidebar_width", + defaultWidth: "clamp(24rem,32vw,34rem)", + minWidth: 22 * 16, + maxWidth: 40 * 16, + }, + render: (form: OverlayPanelForm, onClose: () => void) => ( + + ), + }, +] as const satisfies readonly OverlayPanelEntry[]; + +/** Union of overlay-panel ids, derived from the registry — never hand-maintained. */ +export type OverlayPanel = (typeof OVERLAY_PANELS)[number]["id"]; diff --git a/apps/web/src/ru-fork/rightPanel/store.ts b/apps/web/src/ru-fork/rightPanel/store.ts new file mode 100644 index 00000000000..480a64f26c9 --- /dev/null +++ b/apps/web/src/ru-fork/rightPanel/store.ts @@ -0,0 +1,25 @@ +/** + * ru-fork: coordinator for the global right-side overlay panels (the registry in + * ./registry). A single `open` enum makes them mutually exclusive for free, and + * `toggle()` gives click-again-to-close. + * + * The diff panel stays URL-driven and thread-scoped (NOT modelled here); it + * coordinates with this store at two thin seams in the thread route: + * - opening diff calls `close()` (closes whichever overlay is open); + * - an effect closes diff whenever an overlay opens. + */ + +import { create } from "zustand"; +import type { OverlayPanel } from "./registry"; + +interface RightPanelState { + readonly open: OverlayPanel | null; + readonly toggle: (panel: OverlayPanel) => void; + readonly close: () => void; +} + +export const useRightPanelStore = create()((set) => ({ + open: null, + toggle: (panel) => set((state) => ({ open: state.open === panel ? null : panel })), + close: () => set({ open: null }), +})); diff --git a/panel.md b/panel.md new file mode 100644 index 00000000000..11f4754347f --- /dev/null +++ b/panel.md @@ -0,0 +1,1050 @@ +# Panel system refactor — implementation plan + +Branch/worktree: `features-ui` (from `ru-code` @ `db5b9299`). +Status: **PLAN ONLY — no source edited.** This document is the contract to review before any edit. + +--- + +## 1. Requirements + +1. **Diff panel** stays exactly where it is — thread-scoped, URL-driven (`?diff=1`), only on an active thread. No behavioural change. +2. **MCP panel** moves to "where Pixso is now": a global overlay mounted at the app-layout level, opened from the **left sidebar**. +3. **Only one** of {diff, mcp, pixso} can be open at any time (full 3-way mutual exclusion). +4. **Pixso** and **MCP** both open from the **left sidebar** nav group. +5. Clicking the **same** left-nav item again **closes** the open panel (toggle). +6. DRY, clean, senior-level — no copy-pasted hosts, consistent imports. + +--- + +## 2. Current architecture & problems + +| Panel | Open-state | Trigger | Mount | Exclusion | +|---|---|---|---|---| +| Diff | URL `?diff=1` | header toggle + `diff.toggle` shortcut | `DiffPanelInlineSidebar` in thread route | closes MCP only | +| MCP | `useMcpManagerStore.panelOpen` | **header toggle** (not nav) | `McpPanelMount` in `_chat.tsx` | closes diff only | +| Pixso | `usePixsoStore.panelOpen` | left nav "Макеты Pixso" (open-only) | `AppSidebarLayout` | **none** | + +Problems: + +- **P1 — three open-state mechanisms, ad-hoc pairwise exclusion.** Pixso is ignored by both diff and MCP, so opening Pixso leaves diff/MCP open. No single source of truth. +- **P2 — three near-identical inline-sidebar hosts.** `PixsoPanelInlineSidebar` and `McpPanelInlineSidebar` are byte-identical except 4 constants + the panel; `DiffPanelInlineSidebar` only adds a `shouldAcceptWidth` guard. +- **P3 — duplicated responsive (sheet-vs-inline) selection** in `McpPanelMount` and (pre-refactor) `AppSidebarLayout`. +- **P4 — MCP nav item is inert** (placeholder), MCP is driven only from the chat header. + +--- + +## 3. Target architecture (registry-driven, N-way out of the box) + +``` +┌─ ru-fork/rightPanel/registry.tsx ─────────────────────────────────────┐ +│ OVERLAY_PANELS: readonly [{ id, label, icon, width, render }] (module const) +│ type OverlayPanel = (typeof OVERLAY_PANELS)[number]["id"] (derived) +│ → THE single place to add a panel: append one entry. id-union derives itself. +│ → imports panel COMPONENTS directly (not barrels) to avoid an import cycle. +└────────────────────────────────────────────────────────────────────────┘ +┌─ ru-fork/rightPanel/store.ts ─────────────────────────────────────────┐ +│ useRightPanelStore: { open: OverlayPanel | null, toggle, close } +│ → single source of truth; one enum ⇒ N-way mutual exclusion is automatic; +│ toggle ⇒ click-to-close. (imports OverlayPanel type from registry) +└────────────────────────────────────────────────────────────────────────┘ +┌─ components/RightInlineSidebar.tsx ───────────────────────────────────┐ +│ Pure presentational shell for ANY right inline sidebar. +│ Props: open, onOpenChange, storageKey, defaultWidth, min/maxWidth, +│ shouldAcceptWidth?, children. Shared by diff + overlays. +└────────────────────────────────────────────────────────────────────────┘ +┌─ ru-fork/rightPanel/OverlayPanelHost.tsx ─────────────────────────────┐ +│ ONE shared host for ALL overlays: picks sheet vs RightInlineSidebar, renders +│ whichever entry is open. Keeps content mounted through the close slide; swaps +│ content in place when switching overlays; width/resize follow the active entry. +│ The single home of the sheet/inline logic; one gap ⇒ the main column animates once. +└────────────────────────────────────────────────────────────────────────┘ +┌─ consumers (data-driven, no per-panel code) ──────────────────────────┐ +│ PixsoNavGroup → OVERLAY_PANELS.map(button) (toggle + isActive) +│ AppSidebarLayout → (one shared slot) +└────────────────────────────────────────────────────────────────────────┘ + +Adding panel #N = ONE entry in OVERLAY_PANELS. The id-union, the nav button, the +mount, and N-way exclusion all follow automatically. No per-feature mount files. + +Diff stays URL-driven and is NOT in the coordinator. It coordinates at TWO seams: + • opening diff → useRightPanelStore.close() (close whichever overlay is open) + • opening overlay → effect in thread route closes diff (overlay open ⇒ ?diff=undefined) +``` + +Why diff stays separate: it is thread-scoped and URL-addressable (deep-linkable, survives reload). Folding it into the store would lose that. The two seams give complete exclusion while keeping URL ownership local to the route that owns it. + +**Why deep imports in the registry.** `registry.tsx` imports `McpPanel`/`PixsoPanel` from their **component files** (`~/ru-fork//components/`), NOT the module barrels. Importing via a barrel would cycle: registry → `pixso-move/index` → `PixsoNavGroup` → registry. The panel components depend only on their own stores, so the deep import yields an acyclic graph: `store → registry → panel components`; `OverlayPanelMount → {store, registry}`; `nav/layout → registry`. + +**Re-render safety.** `OVERLAY_PANELS` is a module constant, so every `render` fn has a stable identity (no per-render allocation, no `useCallback` needed). Each `OverlayPanelMount` subscribes via `useRightPanelStore((state) => state.open === panel.id)` → a boolean; zustand re-renders a mount only when *its* boolean flips, so toggling one panel re-renders just the two affected mounts, never all of them. + +--- + +## 4. Import-style rules (measured from the codebase, must follow) + +- **`apps/web/src/components/*.tsx`** → **relative**: `./ui/sidebar` (57 vs 10), sibling `./Name` (35 vs 0). +- **`apps/web/src/routes/*.tsx`** → **relative**: `../components/...` (25 vs 2), `../ru-fork/...`. +- **`apps/web/src/ru-fork/*/**`** → **`~/` alias** for cross-app (`~/components/ui/...`, `~/hooks/...`, `~/components/RightPanelSheet`, `~/rightPanelLayout`, `~/ru-fork/`); **relative** for same-module (`./McpPanel`, `../store`). + +--- + +## 5. NEW FILES (full content) + +### 5.1 `apps/web/src/ru-fork/rightPanel/registry.tsx` + +> The single place to add/remove/reorder overlay panels. Imports panel **components +> directly** (not barrels) to keep the dependency graph acyclic — see §3. + +```tsx +import type { LucideIcon } from "lucide-react"; +import { LayersIcon, ServerIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { McpPanel } from "~/ru-fork/mcp-manage/components/McpPanel"; +import { PixsoPanel } from "~/ru-fork/pixso-move/components/PixsoPanel"; + +/** + * The form a panel is rendered in: a wide-screen offcanvas sidebar or a narrow-screen + * sheet. Values are a subset of `DiffPanelMode` so they pass straight through as a + * panel's `mode` (no mapping). ("inline" — the legacy full-width diff — is unused here.) + */ +export type OverlayPanelForm = "sidebar" | "sheet"; + +export interface OverlayPanelWidth { + readonly storageKey: string; + readonly defaultWidth: string; + readonly minWidth: number; + readonly maxWidth: number; +} + +export interface OverlayPanelEntry { + readonly id: string; + readonly label: string; + readonly icon: LucideIcon; + readonly width: OverlayPanelWidth; + readonly render: (form: OverlayPanelForm, onClose: () => void) => ReactNode; +} + +/** + * ru-fork: the registry of global right-side overlay panels. Adding a panel = one + * entry here; the id-union, the left-nav button, the mount, and N-way mutual + * exclusion all follow automatically. `as const satisfies` keeps each entry + * type-checked while preserving the literal `id`s for the derived union below. + */ +export const OVERLAY_PANELS = [ + { + id: "mcp", + label: "MCP Серверы", + icon: ServerIcon, + width: { + storageKey: "chat_mcp_sidebar_width", + defaultWidth: "clamp(24rem,32vw,34rem)", + minWidth: 22 * 16, + maxWidth: 40 * 16, + }, + render: (form: OverlayPanelForm, onClose: () => void) => ( + + ), + }, + { + id: "pixso", + label: "Макеты Pixso", + icon: LayersIcon, + width: { + storageKey: "chat_pixso_sidebar_width", + defaultWidth: "clamp(24rem,32vw,34rem)", + minWidth: 22 * 16, + maxWidth: 40 * 16, + }, + render: (form: OverlayPanelForm, onClose: () => void) => ( + + ), + }, +] as const satisfies readonly OverlayPanelEntry[]; + +/** Union of overlay-panel ids, derived from the registry — never hand-maintained. */ +export type OverlayPanel = (typeof OVERLAY_PANELS)[number]["id"]; +``` + +> Both entries pass `mode={form}` to their panel — each is built on `DiffPanelShell`, so +> the form (`"sidebar"` | `"sheet"`) drives the shared header/background geometry (px-4 row, +> Electron drag-region + WCO insets, bottom border) and the outline `Toggle` close button. +> +> **Widths:** MCP and Pixso intentionally share **identical** values (default +> `clamp(24rem,32vw,34rem)`, min 352px, max 640px) — exactly today's values; divergence is +> a one-line edit per entry. Each keeps its **own** `storageKey`, so a user's resize is +> remembered per panel. The **diff** panel is **not** in this registry — its width config +> stays in the thread route, **unchanged**. + +### 5.2 `apps/web/src/ru-fork/rightPanel/store.ts` + +```ts +/** + * ru-fork: coordinator for the global right-side overlay panels (the registry in + * ./registry). A single `open` enum makes them mutually exclusive for free, and + * `toggle()` gives click-again-to-close. + * + * The diff panel stays URL-driven and thread-scoped (NOT modelled here); it + * coordinates with this store at two thin seams in the thread route: + * - opening diff calls `close()` (closes whichever overlay is open); + * - an effect closes diff whenever an overlay opens. + */ + +import { create } from "zustand"; +import type { OverlayPanel } from "./registry"; + +interface RightPanelState { + readonly open: OverlayPanel | null; + readonly toggle: (panel: OverlayPanel) => void; + readonly close: () => void; +} + +export const useRightPanelStore = create()((set) => ({ + open: null, + toggle: (panel) => set((state) => ({ open: state.open === panel ? null : panel })), + close: () => set({ open: null }), +})); +``` + +> API is exactly `open` / `toggle` / `close` — the only verbs the call-sites use (nav → +> `toggle`, diff-open seam → `close`). No force-open `openPanel`: it would have zero callers. + +### 5.3 `apps/web/src/ru-fork/rightPanel/OverlayPanelHost.tsx` + +> **One shared host** for all overlays (not one mount per entry). This is what makes the +> transitions read well: open→close keeps the active panel's content mounted through the +> slide-out (the real panel, not an empty shell), switching overlays keeps the slot open and +> swaps content in place, and there's a **single** gap element so the main column animates +> once. Width + remembered resize follow the active panel (option B): the sidebar's resize +> effect re-reads the active `storageKey` on swap and animates `--sidebar-width`. + +```tsx +import { useRef } from "react"; + +import { RightInlineSidebar } from "~/components/RightInlineSidebar"; +import { RightPanelSheet } from "~/components/RightPanelSheet"; +import { useMediaQuery } from "~/hooks/useMediaQuery"; +import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "~/rightPanelLayout"; +import { OVERLAY_PANELS, type OverlayPanel } from "./registry"; +import { useRightPanelStore } from "./store"; + +export function OverlayPanelHost() { + const open = useRightPanelStore((state) => state.open); + const close = useRightPanelStore((state) => state.close); + const shouldUseSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); + + // Remember the last opened panel so its content stays rendered while the slot + // slides closed (`open` is null during the close transition). + const lastShownRef = useRef(open); + if (open !== null) { + lastShownRef.current = open; + } + + const activeId = open ?? lastShownRef.current; + const active = activeId === null ? null : OVERLAY_PANELS.find((panel) => panel.id === activeId); + if (!active) { + return null; + } + + const isOpen = open !== null; + const form = shouldUseSheet ? "sheet" : "sidebar"; + const content = active.render(form, close); + + if (shouldUseSheet) { + return ( + + {content} + + ); + } + + return ( + { + if (!nextOpen) close(); + }} + storageKey={active.width.storageKey} + defaultWidth={active.width.defaultWidth} + minWidth={active.width.minWidth} + maxWidth={active.width.maxWidth} + > + {content} + + ); +} +``` + +### 5.4 `apps/web/src/ru-fork/rightPanel/index.ts` + +```ts +/** + * Right-panel coordinator (ru-fork) — single source of truth for which global + * overlay panel is open, plus the panel registry. See ./store for the diff contract. + */ + +export { useRightPanelStore } from "./store"; +export { OverlayPanelHost } from "./OverlayPanelHost"; +export { OVERLAY_PANELS, type OverlayPanel } from "./registry"; +``` + +> Barrel exports only what's consumed across module boundaries: the store hook, the mount, +> the registry array, and the `OverlayPanel` type. The other registry types +> (`OverlayPanelEntry`/`OverlayPanelForm`/`OverlayPanelWidth`) are imported **directly** from +> `./registry` by same-module files (`store.ts`, `OverlayPanelMount.tsx`), so re-exporting +> them here would be unused surface. + +### 5.5 `apps/web/src/components/RightInlineSidebar.tsx` + +> Note: `./ui/sidebar` (relative) — `components/` convention. + +```tsx +import type { CSSProperties, ReactNode } from "react"; + +import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; + +// ru-fork: single generic shell for the right-side inline sidebars (diff + overlays). +// Replaces three byte-identical wrappers — callers differ only in width constants, +// the storage key, an optional shouldAcceptWidth guard, and the (already open-gated) +// children they render. +export function RightInlineSidebar({ + open, + onOpenChange, + storageKey, + defaultWidth, + minWidth, + maxWidth, + shouldAcceptWidth, + children, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + storageKey: string; + defaultWidth: string; + minWidth: number; + maxWidth: number; + shouldAcceptWidth?: (args: { nextWidth: number; wrapper: HTMLElement }) => boolean; + children: ReactNode; +}) { + return ( + + + {children} + + + + ); +} +``` + +--- + +## 6. EDITED FILES (before → after) + +### 6.1 `apps/web/src/ru-fork/mcp-manage/store.ts` — drop `panelOpen` + +**Before** (doc + interface + impl head): +```ts + * (atoms in rpc/mcpState + the 5 mcp.* orchestration commands). This store holds + * ONLY ephemeral UI state — which panel/tab/selection is open. The pure + * selectors below operate on the UI view-types the hooks produce. + */ + +import { create } from "zustand"; +import type { McpPanelTab, McpProjectBinding, McpRegistryServer } from "./types"; + +interface McpManagerState { + readonly panelOpen: boolean; + readonly activeTab: McpPanelTab; + readonly selectedServerId: string | null; + readonly selectedProjectId: string; + + readonly setPanelOpen: (open: boolean) => void; + readonly togglePanel: () => void; + readonly setActiveTab: (tab: McpPanelTab) => void; + readonly selectServer: (serverId: string | null) => void; + readonly selectProject: (projectId: string) => void; +} + +export const useMcpManagerStore = create()((set) => ({ + panelOpen: false, + activeTab: "registry", + selectedServerId: null, + selectedProjectId: "", + + setPanelOpen: (open) => set({ panelOpen: open }), + togglePanel: () => set((state) => ({ panelOpen: !state.panelOpen })), + setActiveTab: (tab) => set({ activeTab: tab }), + selectServer: (serverId) => set({ selectedServerId: serverId }), + selectProject: (projectId) => set({ selectedProjectId: projectId }), +})); +``` + +**After:** +```ts + * (atoms in rpc/mcpState + the 5 mcp.* orchestration commands). This store holds + * ONLY ephemeral tab/selection state. Panel open/close (and MCP⊕Pixso mutual + * exclusion) lives in the shared right-panel coordinator (ru-fork/rightPanel). + * The pure selectors below operate on the UI view-types the hooks produce. + */ + +import { create } from "zustand"; +import type { McpPanelTab, McpProjectBinding, McpRegistryServer } from "./types"; + +interface McpManagerState { + readonly activeTab: McpPanelTab; + readonly selectedServerId: string | null; + readonly selectedProjectId: string; + + readonly setActiveTab: (tab: McpPanelTab) => void; + readonly selectServer: (serverId: string | null) => void; + readonly selectProject: (projectId: string) => void; +} + +export const useMcpManagerStore = create()((set) => ({ + activeTab: "registry", + selectedServerId: null, + selectedProjectId: "", + + setActiveTab: (tab) => set({ activeTab: tab }), + selectServer: (serverId) => set({ selectedServerId: serverId }), + selectProject: (projectId) => set({ selectedProjectId: projectId }), +})); +``` +*(selectors below are unchanged.)* + +### 6.2 `apps/web/src/ru-fork/pixso-move/store.ts` — drop `panelOpen`/`openPanel`/`closePanel` + +**Before (header doc):** +```ts + * Pixso Move — UI + settings store (zustand). Mirrors the MCP manager store: panel open + * state + a small master→detail view machine, plus the designer's settings persisted to + * localStorage (the web app is not sandboxed, so localStorage is safe here). + */ +``` +**After:** +```ts + * Pixso Move — UI + settings store (zustand). Mirrors the MCP manager store: a small + * master→detail view machine, plus the designer's settings persisted to localStorage + * (the web app is not sandboxed, so localStorage is safe here). Panel open/close (and + * MCP⊕Pixso mutual exclusion) lives in the shared right-panel coordinator + * (ru-fork/rightPanel), not here. + */ +``` + +**Before (interface + impl):** +```ts +interface PixsoStore { + readonly panelOpen: boolean; + readonly view: PixsoView; + readonly selectedNodeId: string | null; + /** Bumped by the refresh button; gates the gallery query (manual refresh only). */ + readonly refreshNonce: number; + readonly settings: PixsoSettings; + + readonly openPanel: () => void; + readonly closePanel: () => void; + readonly openSettings: () => void; + readonly openNode: (nodeId: string) => void; + readonly backToGallery: () => void; + readonly refresh: () => void; + readonly updateSettings: (patch: Partial) => void; +} + +export const usePixsoStore = create()((set) => ({ + panelOpen: false, + view: "gallery", + selectedNodeId: null, + refreshNonce: 0, + settings: loadSettings(), + + openPanel: () => set({ panelOpen: true }), + closePanel: () => set({ panelOpen: false }), + openSettings: () => set({ view: "settings" }), +``` +**After:** +```ts +interface PixsoStore { + readonly view: PixsoView; + readonly selectedNodeId: string | null; + /** Bumped by the refresh button; gates the gallery query (manual refresh only). */ + readonly refreshNonce: number; + readonly settings: PixsoSettings; + + readonly openSettings: () => void; + readonly openNode: (nodeId: string) => void; + readonly backToGallery: () => void; + readonly refresh: () => void; + readonly updateSettings: (patch: Partial) => void; +} + +export const usePixsoStore = create()((set) => ({ + view: "gallery", + selectedNodeId: null, + refreshNonce: 0, + settings: loadSettings(), + + openSettings: () => set({ view: "settings" }), +``` +*(remaining actions unchanged.)* + +### 6.3 `apps/web/src/ru-fork/pixso-move/components/PixsoNavGroup.tsx` — render nav from the registry (toggle + active highlight) + +**Before:** +```tsx +import { LayersIcon, ServerIcon } from "lucide-react"; +import { + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "~/components/ui/sidebar"; +import { usePixsoStore } from "../store"; + +/** + * Left-nav block (under search, above the projects list): "Макеты Pixso" opens the right + * panel; "MCP Серверы" is an inert placeholder for now (does nothing). + */ +export function PixsoNavGroup() { + const openPanel = usePixsoStore((state) => state.openPanel); + + return ( + + + + + + Макеты Pixso + + + + + + MCP Серверы + + + + + ); +} +``` +**After** (data-driven — one button per registry entry; adding a panel needs no change here): +```tsx +import { + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "~/components/ui/sidebar"; +import { OVERLAY_PANELS, useRightPanelStore } from "~/ru-fork/rightPanel"; + +/** + * Left-nav block (under search, above the projects list): one button per global + * overlay panel (from OVERLAY_PANELS). Each toggles its right panel (click again to + * close) and is highlighted while open; the coordinator keeps only one open at a time. + */ +export function PixsoNavGroup() { + const openPanel = useRightPanelStore((state) => state.open); + const toggle = useRightPanelStore((state) => state.toggle); + + return ( + + + {OVERLAY_PANELS.map((panel) => { + const Icon = panel.icon; + return ( + + toggle(panel.id)} + data-testid={`overlay-nav-${panel.id}`} + > + + {panel.label} + + + ); + })} + + + ); +} +``` + +> `() => toggle(panel.id)` is a per-render closure, but it's a handful of nav buttons +> (not a hot path) and `SidebarMenuButton` isn't memoized on it — negligible. The nav +> re-renders only when `open` changes (needed to refresh `isActive`). + +### 6.4 `apps/web/src/ru-fork/mcp-manage/components/McpPanelMount.tsx` — **DELETED** + +No per-feature mount files in the registry design — the registry entry + `OverlayPanelMount` +replace them. See §7. (`PixsoPanelMount` is likewise **not** created.) + +### 6.5 `apps/web/src/components/AppSidebarLayout.tsx` + +**Before (imports 4–13):** +```tsx +import ThreadSidebar from "./Sidebar"; +import { RightPanelSheet } from "./RightPanelSheet"; +import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; +import { useMediaQuery } from "../hooks/useMediaQuery"; +import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; +import { PixsoPanel, PixsoPanelInlineSidebar, usePixsoStore } from "../ru-fork/pixso-move"; +import { + clearShortcutModifierState, + syncShortcutModifierStateFromKeyboardEvent, +} from "../shortcutModifierState"; +``` +**After:** +```tsx +import ThreadSidebar from "./Sidebar"; +import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; +import { OverlayPanelHost } from "../ru-fork/rightPanel"; +import { + clearShortcutModifierState, + syncShortcutModifierStateFromKeyboardEvent, +} from "../shortcutModifierState"; +``` + +**Before (body 19–22):** +```tsx + const navigate = useNavigate(); + const pixsoOpen = usePixsoStore((state) => state.panelOpen); + const closePixso = usePixsoStore((state) => state.closePanel); + const useRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); +``` +**After:** +```tsx + const navigate = useNavigate(); +``` + +**Before (JSX 74–82):** +```tsx + {children} + {useRightPanelSheet ? ( + + + + ) : ( + + )} + +``` +**After** (one shared host — adding a panel needs no change here): +```tsx + {children} + {/* ru-fork: one shared host for all global overlay panels, mounted once outside + the routes so it never remounts on navigation. The coordinator decides which + entry is shown; the host keeps the slot open across swaps and keeps content + mounted through the close slide. */} + + +``` + +### 6.6 `apps/web/src/routes/_chat.tsx` — MCP mount moves to the layout + +**Before (import 17–19):** +```tsx +import { useActiveProjectRef } from "~/hooks/useActiveProject"; +import { McpPanelMount } from "../ru-fork/mcp-manage"; +import { getPrimaryEnvironmentConnection } from "../environments/runtime"; +``` +**After:** +```tsx +import { useActiveProjectRef } from "~/hooks/useActiveProject"; +import { getPrimaryEnvironmentConnection } from "../environments/runtime"; +``` + +**Before (JSX 121–131):** +```tsx +function ChatRouteLayout() { + return ( + <> + + + + {/* ru-fork: single MCP panel mount for all chat routes (draft + thread) — + hoisted here so it never remounts on navigation. */} + + + ); +} +``` +**After:** +```tsx +function ChatRouteLayout() { + return ( + <> + + + + {/* ru-fork: the MCP + Pixso overlay panels are mounted once in + AppSidebarLayout (coordinator-driven), not here. */} + + ); +} +``` + +### 6.7 `apps/web/src/routes/_chat.$environmentId.$threadId.tsx` + +**(a) Imports 24–26.** Before: +```tsx +import { RightPanelSheet } from "../components/RightPanelSheet"; +import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; +import { useMcpManagerStore } from "../ru-fork/mcp-manage"; +``` +After (route convention = relative `../components/...`; only `SidebarInset` still needed): +```tsx +import { RightPanelSheet } from "../components/RightPanelSheet"; +import { RightInlineSidebar } from "../components/RightInlineSidebar"; +import { SidebarInset } from "../components/ui/sidebar"; +import { useRightPanelStore } from "../ru-fork/rightPanel"; +``` + +**(b) `DiffPanelInlineSidebar` return — lines 116–139.** Before: +```tsx + return ( + + + {renderDiffContent ? : null} + + + + ); +``` +After (the `onOpenChange` + `shouldAcceptInlineSidebarWidth` callbacks above are unchanged): +```tsx + return ( + + {renderDiffContent ? : null} + + ); +``` + +**(c) Coordinator wiring — lines 172–174.** Before: +```tsx + // MCP panel open/close is global (useMcpManagerStore) and rendered once in the + // _chat layout; here we only need the setter to keep diff + MCP mutually exclusive. + const setMcpPanelOpen = useMcpManagerStore((store) => store.setPanelOpen); +``` +After: +```tsx + // The overlay panels (MCP/Pixso) are coordinator-driven and mounted in + // AppSidebarLayout. Diff stays URL-driven; the two seams below keep all three + // mutually exclusive: opening diff closes the overlay, and the effect closes + // diff whenever an overlay opens (e.g. from the left nav). + const overlayPanelOpen = useRightPanelStore((store) => store.open !== null); + const closeOverlayPanel = useRightPanelStore((store) => store.close); +``` + +**(d) `openDiff` — line 212.** Before: +```tsx + markDiffOpened(); + // Diff and MCP share the right-panel slot — only one open at a time. + setMcpPanelOpen(false); + void navigate({ +``` +After: +```tsx + markDiffOpened(); + // Diff and the overlay panels share the right-panel slot — only one open. + closeOverlayPanel(); + void navigate({ +``` +…and its dependency array `[markDiffOpened, navigate, setMcpPanelOpen, threadRef]` → `[markDiffOpened, navigate, closeOverlayPanel, threadRef]`. + +**(e) New effect (add next to the existing effects, ~line 223, after `openDiff`):** +```tsx + // Seam: opening an overlay panel (from the left nav) closes the diff panel. + useEffect(() => { + if (overlayPanelOpen && diffOpen) { + closeDiff(); + } + }, [overlayPanelOpen, diffOpen, closeDiff]); +``` + +**(f) Stale comment — line 264 (inline branch).** Before: +```tsx + {/* ru-fork: the MCP panel is mounted once in the _chat layout (McpPanelMount). */} +``` +After: +```tsx + {/* ru-fork: the MCP/Pixso overlay panels are mounted in AppSidebarLayout, not per route. */} +``` + +**(g) Stale comment — line 282 (sheet branch).** Before: +```tsx + {/* ru-fork: the MCP panel sheet is mounted once in the _chat layout. */} +``` +After: +```tsx + {/* ru-fork: the MCP/Pixso overlay panels are mounted in AppSidebarLayout, not per route. */} +``` + +### 6.8 `apps/web/src/components/ChatView.tsx` — drop the MCP header button, keep diff↔overlay seam + +**(a) Import — line 199.** Before: +```tsx +import { useMcpManagerStore } from "../ru-fork/mcp-manage"; +``` +After: +```tsx +import { useRightPanelStore } from "../ru-fork/rightPanel"; +``` + +**(b) State + toggles — lines 1599–1648.** Before: +```tsx + const mcpPanelOpen = useMcpManagerStore((state) => state.panelOpen); + const setMcpPanelOpen = useMcpManagerStore((state) => state.setPanelOpen); + + const onToggleDiff = useCallback(() => { + if (!isServerThread) { + return; + } + if (!diffOpen) { + onDiffPanelOpen?.(); + // Diff and MCP share the right-panel slot: opening diff closes MCP. + setMcpPanelOpen(false); + } + void navigate({ + to: "/$environmentId/$threadId", + params: { + environmentId, + threadId, + }, + replace: true, + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; + }, + }); + }, [ + diffOpen, + environmentId, + isServerThread, + navigate, + onDiffPanelOpen, + setMcpPanelOpen, + threadId, + ]); + + const onToggleMcp = useCallback(() => { + const next = !mcpPanelOpen; + setMcpPanelOpen(next); + // Mutual exclusion with the diff panel: opening MCP closes diff. + if (next && diffOpen) { + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return { ...rest, diff: undefined }; + }, + }); + } + }, [diffOpen, environmentId, mcpPanelOpen, navigate, setMcpPanelOpen, threadId]); +``` +After (drop `mcpPanelOpen`, drop `onToggleMcp` entirely; `onToggleDiff` closes the overlay via the coordinator): +```tsx + const closeOverlayPanel = useRightPanelStore((state) => state.close); + + const onToggleDiff = useCallback(() => { + if (!isServerThread) { + return; + } + if (!diffOpen) { + onDiffPanelOpen?.(); + // Diff and the overlay panels share the right-panel slot: opening diff + // closes whichever overlay (MCP/Pixso) is open. + closeOverlayPanel(); + } + void navigate({ + to: "/$environmentId/$threadId", + params: { + environmentId, + threadId, + }, + replace: true, + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; + }, + }); + }, [ + diffOpen, + environmentId, + isServerThread, + navigate, + onDiffPanelOpen, + closeOverlayPanel, + threadId, + ]); +``` + +**(c) ChatHeader props — lines 3636–3648.** Remove `mcpPanelOpen={mcpPanelOpen}` (3637) and `onToggleMcp={onToggleMcp}` (3648). All other props unchanged. + +### 6.9 `apps/web/src/components/chat/ChatHeader.tsx` — remove the MCP toggle + +**(a) Icon import — line 12.** Before: +```tsx +import { BoxesIcon, DiffIcon, SparklesIcon, TerminalSquareIcon } from "lucide-react"; +``` +After (`BoxesIcon` no longer used): +```tsx +import { DiffIcon, SparklesIcon, TerminalSquareIcon } from "lucide-react"; +``` + +**(b) Props interface — remove line 39 `mcpPanelOpen: boolean;` and line 49 `onToggleMcp: () => void;`.** + +**(c) Destructure — remove line 70 `mcpPanelOpen,` and line 79 `onToggleMcp,`.** + +**(d) MCP `Toggle` block — remove lines 180–196 entirely:** +```tsx + + + + + } + /> + Показать/скрыть панель MCP-серверов + +``` +*(The diff `Toggle` directly above and the advanced-chat `Toggle` directly below are unchanged.)* + +### 6.10 Barrels + +`apps/web/src/ru-fork/mcp-manage/index.ts` — **remove** two lines (the inline-sidebar export and the now-deleted mount): +```ts +export { McpPanelInlineSidebar } from "./components/McpPanelInlineSidebar"; +export { McpPanelMount } from "./components/McpPanelMount"; +``` +*(`McpPanel` and the store/selectors/type exports stay.)* + +`apps/web/src/ru-fork/pixso-move/index.ts` — **remove** line (no `PixsoPanelMount` replacement — there is no per-feature mount in the registry design): +```ts +export { PixsoPanelInlineSidebar } from "./components/PixsoPanelInlineSidebar"; +``` +*(`PixsoNavGroup`, `PixsoPanel`, `usePixsoStore` stay.)* + +> Conscious decision: the barrels still export `McpPanel` / `PixsoPanel` / `usePixsoStore`. +> `registry.tsx` reaches the panels via **component-file** imports (to break the barrel cycle, +> §3), and the stores are read internally via `../store`, so these barrel exports have no +> external consumer after this change. They are kept as the modules' intentional public +> surface (re-exporting a feature's store/panel is normal) and are not lint-flagged (unused +> *exports* are not errors). No consumer change is required. + +### 6.11 `apps/web/src/routes/_chat.draft.$draftId.tsx` — stale comment (lines 72–73) + +**Before:** +```tsx + // The MCP panel is mounted once in the _chat layout (McpPanelMount), so it is + // available here on drafts too without a per-route mount. +``` +**After:** +```tsx + // The MCP + Pixso overlay panels are mounted once in AppSidebarLayout + // (coordinator-driven), so they are available here on drafts too. +``` + +--- + +## 7. DELETED FILES + +- `apps/web/src/ru-fork/mcp-manage/components/McpPanelInlineSidebar.tsx` +- `apps/web/src/ru-fork/pixso-move/components/PixsoPanelInlineSidebar.tsx` +- `apps/web/src/ru-fork/mcp-manage/components/McpPanelMount.tsx` + +(The two inline sidebars are replaced by `RightInlineSidebar`; `McpPanelMount` is replaced by the registry entry + `OverlayPanelMount` — and no `PixsoPanelMount` is created.) + +--- + +## 8. Mutual-exclusion behavior matrix + +| Action | diff (`?diff`) | overlay (`store.open`) | Mechanism | +|---|---|---|---| +| Click nav "MCP" while nothing open | — | `mcp` | `toggle("mcp")` | +| Click nav "MCP" while MCP open | — | `null` | `toggle("mcp")` (req #5) | +| Click nav "Pixso" while MCP open | — | `pixso` | single enum ⇒ MCP auto-closes | +| Toggle diff while Pixso open | `1` | `null` | `onToggleDiff` → `closeOverlayPanel()` | +| Click nav "MCP" while diff open | `undefined` | `mcp` | route effect: overlay open ⇒ `closeDiff()` | +| Toggle diff off | `undefined` | unchanged | normal diff toggle | + +No loops: opening diff sets overlay `null` (effect no-op); opening overlay clears diff (overlay stays open). + +--- + +## 9. Verification + +- `tsc` typecheck (web) — expect 0 errors; confirms all removed store fields / props / imports are fully unreferenced. (tsconfig sets neither `noUnusedLocals` nor `noUnusedParameters`.) +- `oxlint` (repo `.oxlintrc.json`, categories warn) — no unused imports (`BoxesIcon` in ChatHeader; `Sidebar`/`SidebarProvider`/`SidebarRail`/`RightPanelSheet`/`useMediaQuery`/`RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY` in AppSidebarLayout). The registry's `render: (form, onClose) => …` where the MCP entry ignores `form` is safe: `no-unused-vars` uses `args: after-used` and `form` precedes the used `onClose`. +- No import cycle: `store → registry → {McpPanel, PixsoPanel}` (component-file imports), `OverlayPanelMount → {store, registry}`, `nav/layout → registry`. Confirm `madge`/manual: nothing in `rightPanel` imports a feature **barrel**. +- Re-render check (React DevTools): toggling MCP re-renders only the MCP + previously-open mounts, not all of `OVERLAY_PANELS`. +- Manual: nav toggles open/close + highlight; opening any one of {diff, …overlays} closes the others; nav state (MCP tab/selection, Pixso view/node) is retained across close→reopen and across switching panels; diff still deep-links via `?diff=1` and is thread-only; narrow layout still gets sheets. +- Completeness greps (run from `apps/web/src`, must return nothing): + - `grep -rn "panelOpen\|McpPanelInlineSidebar\|PixsoPanelInlineSidebar\|McpPanelMount\|PixsoPanelMount\|onToggleMcp\|setMcpPanelOpen\|mcpPanelOpen" .` + - `grep -rn "_chat layout" .` (no stale mount-location comments) + - `grep -rn "BoxesIcon" .` (icon fully removed) + +--- + +## 10. File summary + +**New (5):** `ru-fork/rightPanel/{registry.tsx,store.ts,OverlayPanelHost.tsx,index.ts}`, `components/RightInlineSidebar.tsx`. +**Edited (11):** mcp store, pixso store, `PixsoNavGroup`, `AppSidebarLayout`, `_chat.tsx`, `_chat.$environmentId.$threadId.tsx` (incl. 2 stale comments), `_chat.draft.$draftId.tsx` (stale comment), `ChatView`, `ChatHeader`, + 2 barrels (`mcp-manage/index.ts`, `pixso-move/index.ts`). +**Deleted (3):** `McpPanelInlineSidebar.tsx`, `PixsoPanelInlineSidebar.tsx`, `McpPanelMount.tsx`.