From d87f644cded589e9fba42148685fc9476731c74a Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 11:21:09 +0000 Subject: [PATCH] refactor(nexus): mount new frame in Layout.tsx; kill old chrome (phase 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites Layout.tsx to compose the new Phase 8 frame (IconRail + TopStrip) and remove the old chrome elements specified as killed in docs/specs/2026-04-11-nexus-layout-overhaul.md §2: Removed from chrome: - 280px collapsible Sidebar / InstanceSidebar - ChatPanel global slide-in right rail - PropertiesPanel global slide-in right rail - BreadcrumbBar (replaced by ModeBreadcrumb inside TopStrip) - Footer row with Docs link, version tooltip, instance settings button, chat toggle button, theme toggle button - Effect that closed PropertiesPanel when chat opened - Mobile sidebar drawer block - Mobile sidebar swipe gesture listener Preserved: - Company-prefix URL sync and fallback redirect machinery - First-run onboarding trigger - WorktreeBanner, DevRestartBanner - Scroll-based mobile nav visibility tracking - Body overflow management - Instance settings path memory - Dialog overlays (NewIssue, NewProject, NewGoal, NewAgent) - ToastViewport, CommandPalette - MobileBottomNav (mobile only; Phase 15 replaces) Added: - IconRail mount with derived companyPrefix from matchedCompany or selectedCompany - TopStrip mount above the main content area - hasUnknownCompanyPrefix fallback defaults to /assistant instead of /dashboard (Dashboard is killed in the new IA) - useKeyboardShortcuts.onSearch dispatches the same synthetic Meta+K keydown as the CmdKButton shim The Sidebar, InstanceSidebar, BreadcrumbBar, ChatPanel, PropertiesPanel, ThemeContext, and useChatPanel files remain in the repo; Phase 16 deletes dead files after the other Phase 8 tasks are proven stable. Pages render unchanged in the new frame and will look visually wrong until Phases 9-13 rebuild their internals. That is the expected intermediate state per the spec. Part of Phase 8 of the Nexus layout overhaul (task 6 of 7). Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/components/Layout.tsx | 342 ++++++----------------------------- 1 file changed, 57 insertions(+), 285 deletions(-) diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index eac9e792..97be4990 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,14 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { BookOpen, MessageSquare, Moon, Settings, Sun } from "lucide-react"; -import { Link, Navigate, Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; +import { Navigate, Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; // [nexus] CompanyRail intentionally not rendered — single-workspace mode. // The file is preserved for upstream rebase compatibility. -import { Sidebar } from "./Sidebar"; -import { InstanceSidebar } from "./InstanceSidebar"; -import { BreadcrumbBar } from "./BreadcrumbBar"; -import { ChatPanel } from "./ChatPanel"; -import { PropertiesPanel } from "./PropertiesPanel"; import { CommandPalette } from "./CommandPalette"; import { NewIssueDialog } from "./NewIssueDialog"; import { NewProjectDialog } from "./NewProjectDialog"; @@ -18,13 +12,12 @@ import { ToastViewport } from "./ToastViewport"; import { MobileBottomNav } from "./MobileBottomNav"; import { WorktreeBanner } from "./WorktreeBanner"; import { DevRestartBanner } from "./DevRestartBanner"; +import { IconRail } from "./frame/IconRail"; +import { TopStrip } from "./frame/TopStrip"; import { useDialog } from "../context/DialogContext"; import { GeneralSettingsProvider } from "../context/GeneralSettingsContext"; -import { usePanel } from "../context/PanelContext"; -import { useChatPanel } from "../context/ChatPanelContext"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; -import { useTheme } from "../context/ThemeContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; @@ -37,8 +30,6 @@ import { import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; import { NotFoundPage } from "../pages/NotFound"; -import { Button } from "@/components/ui/button"; -import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath"; @@ -52,10 +43,8 @@ function readRememberedInstanceSettingsPath(): string { } export function Layout() { - const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar(); + const { isMobile } = useSidebar(); const { openNewIssue, openOnboarding } = useDialog(); - const { togglePanelVisible, setPanelVisible } = usePanel(); - const { chatOpen, setChatOpen, toggleChat } = useChatPanel(); const { companies, loading: companiesLoading, @@ -64,17 +53,14 @@ export function Layout() { selectionSource, setSelectedCompanyId, } = useCompany(); - const { theme, toggleTheme } = useTheme(); - const isDarkTheme = theme === "dark"; - const nextThemeLabel = isDarkTheme ? "Light" : "Dark"; const { companyPrefix } = useParams<{ companyPrefix: string }>(); const navigate = useNavigate(); const location = useLocation(); - const isInstanceSettingsRoute = location.pathname.startsWith("/instance/"); const onboardingTriggered = useRef(false); const lastMainScrollTop = useRef(0); const [mobileNavVisible, setMobileNavVisible] = useState(true); - const [instanceSettingsTarget, setInstanceSettingsTarget] = useState(() => readRememberedInstanceSettingsPath()); + const [, setInstanceSettingsTarget] = useState(() => readRememberedInstanceSettingsPath()); + const matchedCompany = useMemo(() => { if (!companyPrefix) return null; const requestedPrefix = companyPrefix.toUpperCase(); @@ -82,6 +68,9 @@ export function Layout() { }, [companies, companyPrefix]); const hasUnknownCompanyPrefix = Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany; + + const railCompanyPrefix = matchedCompany?.issuePrefix ?? selectedCompany?.issuePrefix ?? null; + const { data: health } = useQuery({ queryKey: queryKeys.health, queryFn: () => healthApi.get(), @@ -97,16 +86,9 @@ export function Layout() { queryFn: () => instanceSettingsApi.getGeneral(), }).data?.keyboardShortcuts === true; - // [nexus] Removed the `health?.deploymentMode === "authenticated"` gate. - // Paperclip's upstream assumed authenticated mode = hosted multi-tenant where - // users self-onboard via invites, so the first-run wizard was suppressed. - // Nexus's authenticated mode is single-user LAN (BETTER_AUTH), which still - // needs the first-run wizard. Note: this effect is mostly dead code for the - // zero-company case because CompanyRootRedirect navigates to /onboarding - // before Layout ever mounts — the real auto-open trigger lives in - // OnboardingRoutePage (App.tsx). This effect is retained as a belt-and- - // suspenders fallback for edge cases where a user reaches a Layout-mounted - // route with zero companies (e.g. /instance/settings/*). + // [nexus] First-run onboarding trigger. Retained as a belt-and-suspenders + // fallback for edge cases where a user reaches a Layout-mounted route with + // zero companies (e.g. /instance/settings/*). useEffect(() => { if (companiesLoading || onboardingTriggered.current) return; if (companies.length === 0) { @@ -115,6 +97,7 @@ export function Layout() { } }, [companies, companiesLoading, openOnboarding]); + // Company-prefix URL sync. useEffect(() => { if (!companyPrefix || companiesLoading || companies.length === 0) return; @@ -156,25 +139,22 @@ export function Layout() { setSelectedCompanyId, ]); - const togglePanel = togglePanelVisible; - - // Close PropertiesPanel when chat panel opens - useEffect(() => { - if (chatOpen) { - setPanelVisible(false); - } - }, [chatOpen, setPanelVisible]); - useCompanyPageMemory(); useKeyboardShortcuts({ enabled: keyboardShortcutsEnabled, onNewIssue: () => openNewIssue(), - onToggleSidebar: toggleSidebar, - onTogglePanel: togglePanel, + onToggleSidebar: () => { + // Phase 8: sidebar toggle is a no-op from keyboard — the rail is fixed. + // Kept as a stub so useKeyboardShortcuts' type contract is satisfied. + }, + onTogglePanel: () => { + // Phase 8: PropertiesPanel is no longer mounted globally. + }, onSearch: () => { - if (!chatOpen) setChatOpen(true); - requestAnimationFrame(() => window.dispatchEvent(new Event("nexus:focus-chat-search"))); + // Phase 8: open the command palette via synthetic keydown, mirroring + // the CmdKButton shim. Phase 14 replaces with a real palette context. + document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true })); }, }); @@ -187,62 +167,11 @@ export function Layout() { setMobileNavVisible(true); }, [isMobile]); - // Swipe gesture to open/close sidebar on mobile - useEffect(() => { - if (!isMobile) return; - - const EDGE_ZONE = 30; // px from left edge to start open-swipe - const MIN_DISTANCE = 50; // minimum horizontal swipe distance - const MAX_VERTICAL = 75; // max vertical drift before we ignore - - let startX = 0; - let startY = 0; - - const onTouchStart = (e: TouchEvent) => { - const t = e.touches[0]!; - startX = t.clientX; - startY = t.clientY; - }; - - const onTouchEnd = (e: TouchEvent) => { - const t = e.changedTouches[0]!; - const dx = t.clientX - startX; - const dy = Math.abs(t.clientY - startY); - - if (dy > MAX_VERTICAL) return; // vertical scroll, ignore - - // Swipe right from left edge → open - if (!sidebarOpen && startX < EDGE_ZONE && dx > MIN_DISTANCE) { - setSidebarOpen(true); - return; - } - - // Swipe left when open → close - if (sidebarOpen && dx < -MIN_DISTANCE) { - setSidebarOpen(false); - } - }; - - document.addEventListener("touchstart", onTouchStart, { passive: true }); - document.addEventListener("touchend", onTouchEnd, { passive: true }); - - return () => { - document.removeEventListener("touchstart", onTouchStart); - document.removeEventListener("touchend", onTouchEnd); - }; - }, [isMobile, sidebarOpen, setSidebarOpen]); - const updateMobileNavVisibility = useCallback((currentTop: number) => { const delta = currentTop - lastMainScrollTop.current; - - if (currentTop <= 24) { - setMobileNavVisible(true); - } else if (delta > 8) { - setMobileNavVisible(false); - } else if (delta < -8) { - setMobileNavVisible(true); - } - + if (currentTop <= 24) setMobileNavVisible(true); + else if (delta > 8) setMobileNavVisible(false); + else if (delta < -8) setMobileNavVisible(true); lastMainScrollTop.current = currentTop; }, []); @@ -252,24 +181,17 @@ export function Layout() { lastMainScrollTop.current = 0; return; } - const onScroll = () => { updateMobileNavVisibility(window.scrollY || document.documentElement.scrollTop || 0); }; - onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); - - return () => { - window.removeEventListener("scroll", onScroll); - }; + return () => window.removeEventListener("scroll", onScroll); }, [isMobile, updateMobileNavVisibility]); useEffect(() => { const previousOverflow = document.body.style.overflow; - document.body.style.overflow = isMobile ? "visible" : "hidden"; - return () => { document.body.style.overflow = previousOverflow; }; @@ -277,12 +199,10 @@ export function Layout() { useEffect(() => { if (!location.pathname.startsWith("/instance/settings/")) return; - const nextPath = normalizeRememberedInstanceSettingsPath( `${location.pathname}${location.search}${location.hash}`, ); setInstanceSettingsTarget(nextPath); - try { window.localStorage.setItem(INSTANCE_SETTINGS_MEMORY_KEY, nextPath); } catch { @@ -293,183 +213,36 @@ export function Layout() { return (
- - Skip to Main Content - - - -
- {isMobile && sidebarOpen && ( - - -
-
- - ) : ( -
-
-
- {isInstanceSettingsRoute ? : } -
-
-
-
- - - Documentation - - {health?.version && ( - - - v - - v{health.version} - - )} - - - - - - {chatOpen ? "Close chat" : "Open chat"} - - -
-
-
- )} + + + +
+ + +
+ -
-
- -
-
{hasUnknownCompanyPrefix ? ( - // [nexus] Auto-recover from bogus URL prefixes by redirecting - // to the same path under the first available company, instead - // of leaving the user stranded on an "Invite not available" - // style dead end with no way back. hasUnknownCompanyPrefix is - // already gated on companies.length > 0, so the fallback is - // guaranteed to resolve. If no fallback exists for some reason, - // fall through to the old NotFoundPage. (() => { const fallbackCompany = selectedCompany ?? companies[0] ?? null; if (!fallbackCompany) { @@ -480,7 +253,7 @@ export function Layout() { /> ); } - const restOfPath = location.pathname.replace(/^\/[^/]+/, "") || "/dashboard"; + const restOfPath = location.pathname.replace(/^\/[^/]+/, "") || "/assistant"; return ( )}
- -
-
- {isMobile && } - - - - - - + + {isMobile && } + + + + + + +
);