# Nexus Phase 8 — Frame Skeleton Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the current `Layout.tsx` chrome with the new global frame — a 56px left icon rail with 4 primary destinations (Assistant, Studio, Projects, Settings) and a 48px top strip with a mode breadcrumb, ⌘K button, and global mic button. Delete the 280px sidebar, the global `ChatPanel` slide-in, the global `PropertiesPanel`, the footer row with theme/chat/settings buttons, and the `BreadcrumbBar` component from the chrome. Pages still render into `` unchanged; they will look wrong in the new frame, and that's expected — phases 9–13 rebuild them. **Architecture:** A new `ui/src/components/frame/` subdir holds five new components (`IconRail`, `ModeBreadcrumb`, `CmdKButton`, `GlobalMicButton`, `TopStrip`). `Layout.tsx` is rewritten to compose the new frame, preserving all non-chrome responsibilities (company-prefix URL sync, first-run onboarding, dialogs, overlays, body overflow management, instance settings memory). Existing pages render into `` unchanged. Mobile in Phase 8 keeps the existing `MobileBottomNav` intact (Phase 15 replaces it); only desktop gets the new frame. **Tech Stack:** - React 18, TypeScript strict, Vite - Tailwind v4 with DESIGN.md token remap (phases 1–3 of MIGRATION-PLAN.md already shipped `--primary` → volt, `--muted-foreground` → silver, `--border` → charcoal) - `lucide-react` (icons: `MessageCircle`, `Sparkles`, `FolderKanban`, `Settings`) - vitest + jsdom + React 18 manual `createRoot` + `act()` (existing test pattern — see `ui/src/components/ChatInput.test.tsx` for the reference shape) - Custom router wrapper `@/lib/router` (re-exports `Link`, `Navigate`, `Outlet`, `useLocation`, `useNavigate`, `useParams`) - Existing `useCompany()` context from `../context/CompanyContext` **Binding constraints (from `docs/specs/2026-04-11-nexus-layout-overhaul.md`):** - §3 DESIGN.md inheritance: pure black canvas, volt `#faff69` as sole accent, forest `#166534` as secondary CTA, charcoal `rgba(65,65,65,0.8)` borders, Inter typography, sharp 4/8 radii, border-based depth - §4.1 icon rail: 56px wide, locked (no collapse/expand), silver default + volt active with 2px volt bar on right edge, uppercase 1.4px-tracking tooltip labels, Lucide icons - §4.2 top strip: 48px tall, sticky, charcoal bottom border, mode label slash-separated breadcrumb in uppercase 1.4px tracking (last segment volt) - §4.3 no global right rail — `ChatPanel` and `PropertiesPanel` are removed from chrome - §10.1 ⌘K is the universal palette trigger (CmdK shim in Phase 8; full globalization is Phase 14) - §5.5 global mic is visible everywhere (idle state only in Phase 8; full voice routing is Phase 14) **What Phase 8 explicitly does NOT build (deferred to later phases):** - The Assistant full-bleed chat UI (Phase 9) - The Studio workshop grid (Phase 10) - The Projects hero-stat list or Builder-mode tabs (Phase 11) - The promote-to-project transition animation (Phase 12) - Settings consolidation (Phase 13) - Voice routing from non-Assistant modes (Phase 14) - The globalized ⌘K palette searching across projects/conversations (Phase 14) - Mobile icon rail as a bottom tab bar (Phase 15) - Removal of dead code (old Sidebar component, old ChatPanel file, old MobileBottomNav) — the files stay in the repo, just unmounted from the chrome. Phase 16 deletes them. **File inventory** | Action | Path | Responsibility | |---|---|---| | Create | `ui/src/components/frame/IconRail.tsx` | 56px left rail with 4 primary destinations + Nexus mark | | Create | `ui/src/components/frame/IconRail.test.tsx` | Renders 4 links, applies active state, has correct aria | | Create | `ui/src/components/frame/ModeBreadcrumb.tsx` | Derives slash-separated uppercase breadcrumb from current route | | Create | `ui/src/components/frame/ModeBreadcrumb.test.tsx` | Pure function tests for route → label mapping | | Create | `ui/src/components/frame/CmdKButton.tsx` | Renders the kbd badge; onClick dispatches synthetic `Cmd+K` keydown | | Create | `ui/src/components/frame/CmdKButton.test.tsx` | Renders, dispatches event on click | | Create | `ui/src/components/frame/GlobalMicButton.tsx` | Renders mic button in idle state; onClick is a no-op in Phase 8 | | Create | `ui/src/components/frame/GlobalMicButton.test.tsx` | Renders with aria-label | | Create | `ui/src/components/frame/TopStrip.tsx` | Composes `ModeBreadcrumb` + `CmdKButton` + `GlobalMicButton` | | Create | `ui/src/components/frame/TopStrip.test.tsx` | Renders all three children | | Modify | `ui/src/components/Layout.tsx` | Rewrite JSX to use new frame; remove sidebar/ChatPanel/PropertiesPanel/BreadcrumbBar/footer | 11 files touched. 5 new components + 5 test files + 1 rewrite. **Commit scheme:** one commit per task, message prefix `feat(nexus):` or `refactor(nexus):` per convention. Each commit co-authored. --- ## Task 1: Create `IconRail` component **Files:** - Create: `ui/src/components/frame/IconRail.tsx` - Create: `ui/src/components/frame/IconRail.test.tsx` The rail is 56px wide, fixed height (100% of viewport), has four primary destination links plus a Nexus mark at the top. Active state is derived from the current pathname. Routes are company-prefixed: the rail reads `companyPrefix` from `useParams` and builds URLs like `/${companyPrefix}/assistant`. The Settings destination points at `/instance/settings/general` (not company-prefixed — Paperclip instance settings live at a global route). ### Active state derivation | Pathname contains | Active icon | |---|---| | `/assistant` | Assistant | | `/content-studio` or `/studio` or `/convert` | Studio | | `/projects` or `/issues` or `/agents` or `/routines` or `/goals` or `/approvals` or `/costs` or `/activity` or `/inbox` | Projects | | `/instance/settings` | Settings | (The Projects group is broad because Phase 8 still has these as top-level routes. Phase 11 demotes them to per-project tabs and the grouping becomes natural.) - [ ] **Step 1: Write the failing test** Create `ui/src/components/frame/IconRail.test.tsx`: ```tsx // @vitest-environment jsdom import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { MemoryRouter } from "@/lib/router"; import { IconRail } from "./IconRail"; // Tell React this environment uses act() for event flushing. // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; describe("IconRail", () => { let container: HTMLDivElement; let root: ReturnType | null = null; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); root = null; }); afterEach(() => { if (root) { act(() => { root!.unmount(); }); root = null; } if (container.parentNode) { container.remove(); } }); function renderRail(initialPath: string) { root = createRoot(container); act(() => { root!.render( ); }); return { getNav: () => container.querySelector("nav[aria-label='Primary']") as HTMLElement, getLinks: () => Array.from(container.querySelectorAll("nav[aria-label='Primary'] a")) as HTMLAnchorElement[], getLinkByLabel: (label: string) => container.querySelector(`nav[aria-label='Primary'] a[aria-label='${label}']`) as HTMLAnchorElement | null, }; } it("renders a Primary nav with the four destination links", () => { const { getNav, getLinks } = renderRail("/NEX/assistant"); expect(getNav()).not.toBeNull(); const links = getLinks(); const labels = links.map((a) => a.getAttribute("aria-label")); expect(labels).toEqual(["Assistant", "Studio", "Projects", "Settings"]); }); it("builds company-prefixed URLs for the first three destinations", () => { const { getLinkByLabel } = renderRail("/NEX/assistant"); expect(getLinkByLabel("Assistant")?.getAttribute("href")).toBe("/NEX/assistant"); expect(getLinkByLabel("Studio")?.getAttribute("href")).toBe("/NEX/content-studio"); expect(getLinkByLabel("Projects")?.getAttribute("href")).toBe("/NEX/projects"); }); it("points Settings at the global /instance/settings/general route", () => { const { getLinkByLabel } = renderRail("/NEX/assistant"); expect(getLinkByLabel("Settings")?.getAttribute("href")).toBe("/instance/settings/general"); }); it("marks the Assistant link as current on /NEX/assistant", () => { const { getLinkByLabel } = renderRail("/NEX/assistant"); expect(getLinkByLabel("Assistant")?.getAttribute("aria-current")).toBe("page"); expect(getLinkByLabel("Projects")?.getAttribute("aria-current")).toBeNull(); }); it("marks the Projects link as current on /NEX/issues", () => { const { getLinkByLabel } = renderRail("/NEX/issues"); expect(getLinkByLabel("Projects")?.getAttribute("aria-current")).toBe("page"); expect(getLinkByLabel("Assistant")?.getAttribute("aria-current")).toBeNull(); }); it("marks the Studio link as current on /NEX/convert (Convert is folded into Studio)", () => { const { getLinkByLabel } = renderRail("/NEX/convert"); expect(getLinkByLabel("Studio")?.getAttribute("aria-current")).toBe("page"); }); it("marks the Settings link as current on /instance/settings/general", () => { const { getLinkByLabel } = renderRail("/instance/settings/general"); expect(getLinkByLabel("Settings")?.getAttribute("aria-current")).toBe("page"); }); }); ``` **Note:** this test imports `MemoryRouter` from `@/lib/router`. If that export doesn't exist, check the wrapper at `ui/src/lib/router.ts` — there is likely a `MemoryRouter` or `HashRouter` re-exported from `react-router-dom`. If not, add it: `export { MemoryRouter } from "react-router-dom";` in `ui/src/lib/router.ts`. That's a 1-line edit that belongs in this task's commit. - [ ] **Step 2: Run test to verify it fails** ```bash cd /opt/nexus/ui && npx vitest run src/components/frame/IconRail.test.tsx ``` Expected: FAIL — `Cannot find module './IconRail'`. - [ ] **Step 3: Implement `IconRail`** Create `ui/src/components/frame/IconRail.tsx`: ```tsx import { MessageCircle, Sparkles, FolderKanban, Settings } from "lucide-react"; import { Link, useLocation } from "@/lib/router"; import { cn } from "@/lib/utils"; interface IconRailProps { /** * The currently active company prefix (e.g. "NEX"). Used to build * company-prefixed destination URLs. When null, the rail still renders * but the first three destinations navigate to "/". */ companyPrefix: string | null; } type DestinationKey = "assistant" | "studio" | "projects" | "settings"; interface Destination { key: DestinationKey; label: string; /** * Given a company prefix (possibly null), return the destination URL. */ href: (prefix: string | null) => string; /** * Return true if the destination should be marked active for the given pathname. */ isActive: (pathname: string) => boolean; icon: typeof MessageCircle; } const DESTINATIONS: Destination[] = [ { key: "assistant", label: "Assistant", href: (prefix) => (prefix ? `/${prefix}/assistant` : "/"), isActive: (pathname) => /\/assistant(\/|$)/.test(pathname), icon: MessageCircle, }, { key: "studio", label: "Studio", // Studio is a future unified route (Phase 10). For Phase 8 we route it // to the existing content-studio page. The Studio icon also highlights // when the user is on /convert because Convert folds into Studio in Phase 10. href: (prefix) => (prefix ? `/${prefix}/content-studio` : "/"), isActive: (pathname) => /\/content-studio(\/|$)/.test(pathname) || /\/studio(\/|$)/.test(pathname) || /\/convert(\/|$)/.test(pathname), icon: Sparkles, }, { key: "projects", label: "Projects", href: (prefix) => (prefix ? `/${prefix}/projects` : "/"), // The Projects icon acts as the umbrella for every route that Phase 11 // will eventually demote to a per-project tab. isActive: (pathname) => /\/projects(\/|$)/.test(pathname) || /\/issues(\/|$)/.test(pathname) || /\/agents(\/|$)/.test(pathname) || /\/routines(\/|$)/.test(pathname) || /\/goals(\/|$)/.test(pathname) || /\/approvals(\/|$)/.test(pathname) || /\/costs(\/|$)/.test(pathname) || /\/activity(\/|$)/.test(pathname) || /\/inbox(\/|$)/.test(pathname) || /\/execution-workspaces(\/|$)/.test(pathname), icon: FolderKanban, }, { key: "settings", label: "Settings", // Instance settings is a global (non-company-prefixed) route in Paperclip. // Phase 13 may change this; for Phase 8 we preserve the current URL. href: () => "/instance/settings/general", isActive: (pathname) => pathname.startsWith("/instance/settings"), icon: Settings, }, ]; export function IconRail({ companyPrefix }: IconRailProps) { const { pathname } = useLocation(); return ( ); } function DestinationLink({ destination, companyPrefix, pathname, }: { destination: Destination; companyPrefix: string | null; pathname: string; }) { const Icon = destination.icon; const active = destination.isActive(pathname); const href = destination.href(companyPrefix); return (
  • {/* Active bar on the right edge — DESIGN.md §4.1 */} {active && (
  • ); } function NexusMark({ className }: { className?: string }) { // Geometric Nexus mark — a hexagonal nucleus with a single volt stroke. // Phase 16 may replace with a finalized brand mark; this is a placeholder // that matches the ClickHouse-cockpit aesthetic (sharp, minimal, volt accent). return ( ); } ``` - [ ] **Step 4: If `MemoryRouter` is not exported from `@/lib/router`, add it** Check `ui/src/lib/router.ts` for a `MemoryRouter` export. If absent, append: ```ts export { MemoryRouter } from "react-router-dom"; ``` This export is used only by tests; production code should not import it. - [ ] **Step 5: Run test to verify it passes** ```bash cd /opt/nexus/ui && npx vitest run src/components/frame/IconRail.test.tsx ``` Expected: PASS — all 7 test cases green. - [ ] **Step 6: Commit** ```bash cd /opt/nexus && git add ui/src/components/frame/IconRail.tsx ui/src/components/frame/IconRail.test.tsx ui/src/lib/router.ts git commit -m "$(cat <<'EOF' feat(nexus): add IconRail component for layout overhaul (phase 8) Introduces the 56px left icon rail specified in docs/specs/2026-04-11-nexus-layout-overhaul.md §4.1. Four primary destinations (Assistant, Studio, Projects, Settings) rendered as Lucide icons with silver default + volt active state and a 2px volt bar on the right edge of the active item. Destinations are company-prefixed except Settings, which points at the global /instance/settings/general route. The Studio icon also highlights on /convert because Phase 10 folds ConvertPage into Studio as a workshop. The Projects icon is the umbrella for all Phase 11 per-project-tab routes (issues, agents, routines, goals, approvals, costs, activity, inbox, execution-workspaces). The rail is not yet mounted in Layout.tsx — that happens in task 6. Part of the Nexus v1.7 structural overhaul (Phase 8 of MIGRATION-PLAN.md §8b). Companion tests cover all 4 destinations, active-state derivation, and aria-current semantics. Co-Authored-By: Claude Opus 4.6 (1M context) EOF )" ``` --- ## Task 2: Create `ModeBreadcrumb` component **Files:** - Create: `ui/src/components/frame/ModeBreadcrumb.tsx` - Create: `ui/src/components/frame/ModeBreadcrumb.test.tsx` The breadcrumb derives its segments from the current pathname. It's a pure function of route state, so its tests can be mostly unit-style (call the derivation function, assert segments). ### Derivation rules | Pathname | Segments | |---|---| | `/NEX/assistant` | `["ASSISTANT"]` | | `/NEX/assistant/conv-123` | `["ASSISTANT"]` | | `/NEX/content-studio` | `["STUDIO"]` | | `/NEX/content-studio/diagrams` | `["STUDIO", "DIAGRAMS"]` | | `/NEX/convert/pdf/docx` | `["STUDIO", "CONVERT"]` | | `/NEX/projects` | `["PROJECTS"]` | | `/NEX/projects/nexus-design` | `["PROJECTS", "NEXUS-DESIGN"]` | | `/NEX/issues/NEX-42` | `["PROJECTS", "NEX-42"]` (Phase 11 will scope issues under projects; Phase 8 flattens) | | `/instance/settings/general` | `["SETTINGS"]` | | `/instance/settings/integrations` | `["SETTINGS", "INTEGRATIONS"]` | | `/` or unknown | `["HOME"]` | The leaf segment is rendered in volt, non-leaf segments are silver with ` / ` separators. - [ ] **Step 1: Write the failing test** Create `ui/src/components/frame/ModeBreadcrumb.test.tsx`: ```tsx // @vitest-environment jsdom import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { MemoryRouter } from "@/lib/router"; import { ModeBreadcrumb, deriveBreadcrumbSegments } from "./ModeBreadcrumb"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; describe("deriveBreadcrumbSegments", () => { it.each([ ["/NEX/assistant", ["ASSISTANT"]], ["/NEX/assistant/conv-abc", ["ASSISTANT"]], ["/NEX/content-studio", ["STUDIO"]], ["/NEX/content-studio/diagrams", ["STUDIO", "DIAGRAMS"]], ["/NEX/convert", ["STUDIO", "CONVERT"]], ["/NEX/convert/pdf/docx", ["STUDIO", "CONVERT"]], ["/NEX/projects", ["PROJECTS"]], ["/NEX/projects/nexus-design", ["PROJECTS", "NEXUS-DESIGN"]], ["/NEX/issues", ["PROJECTS"]], ["/NEX/issues/NEX-42", ["PROJECTS", "NEX-42"]], ["/NEX/agents", ["PROJECTS"]], ["/NEX/routines", ["PROJECTS"]], ["/instance/settings/general", ["SETTINGS"]], ["/instance/settings/integrations", ["SETTINGS", "INTEGRATIONS"]], ["/", ["HOME"]], ["/unknown/path", ["HOME"]], ])("maps %s to %o", (input, expected) => { expect(deriveBreadcrumbSegments(input)).toEqual(expected); }); }); describe("ModeBreadcrumb (rendered)", () => { let container: HTMLDivElement; let root: ReturnType | null = null; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); root = null; }); afterEach(() => { if (root) { act(() => { root!.unmount(); }); root = null; } if (container.parentNode) { container.remove(); } }); function renderCrumb(pathname: string) { root = createRoot(container); act(() => { root!.render( ); }); return { getRoot: () => container.querySelector("[data-testid='mode-breadcrumb']") as HTMLElement | null, getSegments: () => Array.from(container.querySelectorAll("[data-testid='mode-breadcrumb-segment']")).map( (el) => el.textContent?.trim() ?? "" ), getSeparators: () => container.querySelectorAll("[data-testid='mode-breadcrumb-sep']").length, }; } it("renders a single ASSISTANT segment and no separator", () => { const { getSegments, getSeparators } = renderCrumb("/NEX/assistant"); expect(getSegments()).toEqual(["ASSISTANT"]); expect(getSeparators()).toBe(0); }); it("renders two segments with one separator for /NEX/content-studio/diagrams", () => { const { getSegments, getSeparators } = renderCrumb("/NEX/content-studio/diagrams"); expect(getSegments()).toEqual(["STUDIO", "DIAGRAMS"]); expect(getSeparators()).toBe(1); }); it("renders PROJECTS / NEX-42 for /NEX/issues/NEX-42", () => { const { getSegments } = renderCrumb("/NEX/issues/NEX-42"); expect(getSegments()).toEqual(["PROJECTS", "NEX-42"]); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /opt/nexus/ui && npx vitest run src/components/frame/ModeBreadcrumb.test.tsx ``` Expected: FAIL — `Cannot find module './ModeBreadcrumb'`. - [ ] **Step 3: Implement `ModeBreadcrumb`** Create `ui/src/components/frame/ModeBreadcrumb.tsx`: ```tsx import { useLocation } from "@/lib/router"; import { cn } from "@/lib/utils"; /** * Derive breadcrumb segments from a pathname. * * Exposed for unit testing — the component itself just consumes this. * * Mapping rules: * - `/:prefix/assistant*` → `["ASSISTANT"]` (any suffix collapses) * - `/:prefix/content-studio` → `["STUDIO"]` * - `/:prefix/content-studio/` → `["STUDIO", ""]` * - `/:prefix/convert*` → `["STUDIO", "CONVERT"]` (Phase 10 folds Convert into Studio) * - `/:prefix/projects` → `["PROJECTS"]` * - `/:prefix/projects/*` → `["PROJECTS", ""]` * - `/:prefix/(issues|agents|routines|goals|approvals|costs|activity|inbox)` → `["PROJECTS"]` * - `/:prefix/issues/` → `["PROJECTS", ""]` * - `/instance/settings` → `["SETTINGS"]` * - `/instance/settings/` → `["SETTINGS", ""]` * - everything else → `["HOME"]` */ export function deriveBreadcrumbSegments(pathname: string): string[] { // /instance/settings/... if (pathname.startsWith("/instance/settings")) { const rest = pathname.replace(/^\/instance\/settings\/?/, ""); if (!rest) return ["SETTINGS"]; const leaf = rest.split("/")[0] ?? ""; if (!leaf || leaf === "general") return ["SETTINGS"]; return ["SETTINGS", leaf.toUpperCase()]; } // Company-prefixed routes: /NEX/ const match = pathname.match(/^\/[^/]+\/(.*)$/); if (!match) return ["HOME"]; const rest = match[1] ?? ""; if (!rest) return ["HOME"]; const segments = rest.split("/").filter(Boolean); const head = segments[0]; const next = segments[1]; switch (head) { case "assistant": return ["ASSISTANT"]; case "content-studio": case "studio": return next ? ["STUDIO", next.toUpperCase()] : ["STUDIO"]; case "convert": return ["STUDIO", "CONVERT"]; case "projects": return next ? ["PROJECTS", next.toUpperCase()] : ["PROJECTS"]; case "issues": case "agents": case "routines": case "goals": case "approvals": case "costs": case "activity": case "inbox": case "execution-workspaces": return next ? ["PROJECTS", next.toUpperCase()] : ["PROJECTS"]; default: return ["HOME"]; } } export function ModeBreadcrumb() { const { pathname } = useLocation(); const segments = deriveBreadcrumbSegments(pathname); return (
    {segments.map((segment, index) => { const isLeaf = index === segments.length - 1; return ( {index > 0 && ( )} {segment} ); })}
    ); } ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /opt/nexus/ui && npx vitest run src/components/frame/ModeBreadcrumb.test.tsx ``` Expected: PASS — all test cases green (the `it.each` parameterized table + the 3 render tests). - [ ] **Step 5: Commit** ```bash cd /opt/nexus && git add ui/src/components/frame/ModeBreadcrumb.tsx ui/src/components/frame/ModeBreadcrumb.test.tsx git commit -m "$(cat <<'EOF' feat(nexus): add ModeBreadcrumb for layout overhaul (phase 8) Uppercase slash-separated breadcrumb that derives from the current pathname per docs/specs/2026-04-11-nexus-layout-overhaul.md §4.2. Leaf segment in volt, non-leaf segments in silver. Pure function deriveBreadcrumbSegments() is exported for unit testing. The derivation intentionally collapses Phase 11's soon-to-be-demoted routes (issues/agents/routines/goals/approvals/costs/activity/inbox) under the PROJECTS umbrella segment. When Phase 11 lands, the actual URLs will be /projects/:slug/ and the derivation will naturally produce PROJECTS / PROJECT-SLUG without code changes. Part of Phase 8 of the Nexus layout overhaul. Co-Authored-By: Claude Opus 4.6 (1M context) EOF )" ``` --- ## Task 3: Create `CmdKButton` component **Files:** - Create: `ui/src/components/frame/CmdKButton.tsx` - Create: `ui/src/components/frame/CmdKButton.test.tsx` This is a **shim** for Phase 8. The button renders the ⌘K kbd glyph and opens the existing `CommandPalette` by dispatching a synthetic `keydown` event that `CommandPalette.tsx` already listens for on `document`. Phase 14 will replace this with a proper command-palette context. The shim is documented inline so a reviewer doesn't mistake it for production quality. **Why not refactor CommandPalette's state into a context now?** Because `CommandPalette.tsx` is ~400 lines and touches routing, company selection, dialogs, sidebar state. Refactoring it is out of scope for Phase 8 (which is about chrome, not search). The synthetic keydown is a 1-line hack that works today and gets replaced by Phase 14's real globalization. - [ ] **Step 1: Write the failing test** Create `ui/src/components/frame/CmdKButton.test.tsx`: ```tsx // @vitest-environment jsdom import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CmdKButton } from "./CmdKButton"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; describe("CmdKButton", () => { let container: HTMLDivElement; let root: ReturnType | null = null; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); root = null; }); afterEach(() => { if (root) { act(() => { root!.unmount(); }); root = null; } if (container.parentNode) { container.remove(); } }); function renderButton() { root = createRoot(container); act(() => { root!.render(); }); return { getButton: () => container.querySelector("button[aria-label='Open command palette']") as HTMLButtonElement, getKbd: () => container.querySelector("kbd")?.textContent?.trim(), }; } it("renders a button with the ⌘K kbd glyph", () => { const { getButton, getKbd } = renderButton(); expect(getButton()).not.toBeNull(); expect(getKbd()).toBe("⌘K"); }); it("dispatches a Meta+K keydown on document when clicked", () => { const listener = vi.fn(); document.addEventListener("keydown", listener); const { getButton } = renderButton(); act(() => { getButton().click(); }); expect(listener).toHaveBeenCalledTimes(1); const event = listener.mock.calls[0]![0] as KeyboardEvent; expect(event.key).toBe("k"); expect(event.metaKey).toBe(true); document.removeEventListener("keydown", listener); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /opt/nexus/ui && npx vitest run src/components/frame/CmdKButton.test.tsx ``` Expected: FAIL — `Cannot find module './CmdKButton'`. - [ ] **Step 3: Implement `CmdKButton`** Create `ui/src/components/frame/CmdKButton.tsx`: ```tsx /** * CmdKButton — Phase 8 shim for the top-strip command palette trigger. * * This button renders the ⌘K keyboard glyph and, when clicked, dispatches a * synthetic Meta+K keydown event on `document`. The existing CommandPalette * component in ui/src/components/CommandPalette.tsx installs a document-level * keydown listener for Meta+K and opens itself when that key is pressed, so * the synthetic event reaches it without needing a refactor to share state. * * Phase 14 of docs/specs/2026-04-11-nexus-layout-overhaul.md replaces this * shim with a proper command-palette context and globalizes the palette's * search index. This file will either be deleted or gutted at that point. */ export function CmdKButton() { const handleClick = () => { const event = new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true, cancelable: true, }); document.dispatchEvent(event); }; return ( ); } ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /opt/nexus/ui && npx vitest run src/components/frame/CmdKButton.test.tsx ``` Expected: PASS — both test cases green. - [ ] **Step 5: Commit** ```bash cd /opt/nexus && git add ui/src/components/frame/CmdKButton.tsx ui/src/components/frame/CmdKButton.test.tsx git commit -m "$(cat <<'EOF' feat(nexus): add CmdKButton shim for layout overhaul (phase 8) Top-strip button that renders the ⌘K glyph and opens the existing CommandPalette by dispatching a synthetic Meta+K keydown on document, which CommandPalette.tsx already listens for. This is explicitly a Phase 8 shim; Phase 14 of docs/specs/2026-04-11-nexus-layout-overhaul.md replaces it with a proper command-palette context when globalizing the palette's search index. Part of Phase 8 of the Nexus layout overhaul. Co-Authored-By: Claude Opus 4.6 (1M context) EOF )" ``` --- ## Task 4: Create `GlobalMicButton` component **Files:** - Create: `ui/src/components/frame/GlobalMicButton.tsx` - Create: `ui/src/components/frame/GlobalMicButton.test.tsx` This is a **visual-only** component for Phase 8. It renders the mic button in its three states (idle / listening / speaking) via a prop. The button's onClick is a no-op in Phase 8; Phase 14 wires it to the actual voice pipeline and adds the queueing-to-Assistant behavior. For Phase 8 we accept only `idle` as the state — the other states are defined as TypeScript types so Phase 14 just has to pass the right state value without changing the component's signature. The states are specified in `docs/specs/2026-04-11-nexus-layout-overhaul.md` §4.2: - **idle**: forest green (`#166534`) 8×8 dot centered in a 32×32 rounded-[8px] button - **listening**: volt (`#faff69`) fill with a 1.5s pulse loop and expanding volt ring (not required in Phase 8) - **speaking**: silver (`#a0a0a0`) fill, no pulse (not required in Phase 8) - [ ] **Step 1: Write the failing test** Create `ui/src/components/frame/GlobalMicButton.test.tsx`: ```tsx // @vitest-environment jsdom import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { GlobalMicButton } from "./GlobalMicButton"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; describe("GlobalMicButton", () => { let container: HTMLDivElement; let root: ReturnType | null = null; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); root = null; }); afterEach(() => { if (root) { act(() => { root!.unmount(); }); root = null; } if (container.parentNode) { container.remove(); } }); function render(state: "idle" | "listening" | "speaking" = "idle") { root = createRoot(container); act(() => { root!.render(); }); return { getButton: () => container.querySelector("button[aria-label='Voice']") as HTMLButtonElement, getDataState: () => container.querySelector("button[aria-label='Voice']")?.getAttribute("data-state"), }; } it("renders a button with aria-label 'Voice' by default", () => { const { getButton } = render(); expect(getButton()).not.toBeNull(); }); it("reflects the state prop via data-state", () => { expect(render("idle").getDataState()).toBe("idle"); expect(render("listening").getDataState()).toBe("listening"); expect(render("speaking").getDataState()).toBe("speaking"); }); it("is a no-op on click in Phase 8 (does not throw)", () => { const { getButton } = render(); act(() => { getButton().click(); }); // If this didn't throw, the test passes. expect(getButton()).not.toBeNull(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /opt/nexus/ui && npx vitest run src/components/frame/GlobalMicButton.test.tsx ``` Expected: FAIL — `Cannot find module './GlobalMicButton'`. - [ ] **Step 3: Implement `GlobalMicButton`** Create `ui/src/components/frame/GlobalMicButton.tsx`: ```tsx import { cn } from "@/lib/utils"; export type GlobalMicState = "idle" | "listening" | "speaking"; interface GlobalMicButtonProps { state?: GlobalMicState; /** * Phase 14 will wire this to the voice pipeline. In Phase 8 it's a no-op * by default; callers can override for manual testing. */ onClick?: () => void; } /** * GlobalMicButton — Phase 8 visual-only mic button for the top strip. * * Per docs/specs/2026-04-11-nexus-layout-overhaul.md §4.2, three states are * specified: * - idle: forest-green dot, no animation * - listening: volt fill, 1.5s pulse loop + expanding volt ring * - speaking: silver fill, no pulse * * Phase 8 renders all three but only `idle` is wired up functionally. The * listening / speaking visuals are scaffolded so Phase 14 can toggle the * state prop without changing this component's signature. */ export function GlobalMicButton({ state = "idle", onClick }: GlobalMicButtonProps) { return ( ); } ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /opt/nexus/ui && npx vitest run src/components/frame/GlobalMicButton.test.tsx ``` Expected: PASS — all 3 test cases green. - [ ] **Step 5: Commit** ```bash cd /opt/nexus && git add ui/src/components/frame/GlobalMicButton.tsx ui/src/components/frame/GlobalMicButton.test.tsx git commit -m "$(cat <<'EOF' feat(nexus): add GlobalMicButton scaffold for layout overhaul (phase 8) Visual-only mic button for the top strip per docs/specs/2026-04-11-nexus-layout-overhaul.md §4.2. Renders three specified states (idle / listening / speaking) but Phase 8 only wires the idle state functionally. Phase 14 will toggle the state prop from the voice pipeline without changing this component's signature. Part of Phase 8 of the Nexus layout overhaul. Co-Authored-By: Claude Opus 4.6 (1M context) EOF )" ``` --- ## Task 5: Create `TopStrip` composite **Files:** - Create: `ui/src/components/frame/TopStrip.tsx` - Create: `ui/src/components/frame/TopStrip.test.tsx` Composes `ModeBreadcrumb` (left) + `CmdKButton` and `GlobalMicButton` (right). 48px tall, charcoal bottom border, pure black background, sticky at the top of the main column. - [ ] **Step 1: Write the failing test** Create `ui/src/components/frame/TopStrip.test.tsx`: ```tsx // @vitest-environment jsdom import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { MemoryRouter } from "@/lib/router"; import { TopStrip } from "./TopStrip"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; describe("TopStrip", () => { let container: HTMLDivElement; let root: ReturnType | null = null; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); root = null; }); afterEach(() => { if (root) { act(() => { root!.unmount(); }); root = null; } if (container.parentNode) { container.remove(); } }); function render(pathname: string) { root = createRoot(container); act(() => { root!.render( ); }); } it("renders the ModeBreadcrumb with derived segments", () => { render("/NEX/assistant"); const segment = container.querySelector("[data-testid='mode-breadcrumb-segment']"); expect(segment?.textContent?.trim()).toBe("ASSISTANT"); }); it("renders the CmdK button", () => { render("/NEX/assistant"); expect(container.querySelector("button[aria-label='Open command palette']")).not.toBeNull(); }); it("renders the global mic button", () => { render("/NEX/assistant"); expect(container.querySelector("button[aria-label='Voice']")).not.toBeNull(); }); it("is wrapped in a header element for landmark semantics", () => { render("/NEX/assistant"); const header = container.querySelector("header"); expect(header).not.toBeNull(); expect(header?.getAttribute("aria-label")).toBe("Top bar"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /opt/nexus/ui && npx vitest run src/components/frame/TopStrip.test.tsx ``` Expected: FAIL — `Cannot find module './TopStrip'`. - [ ] **Step 3: Implement `TopStrip`** Create `ui/src/components/frame/TopStrip.tsx`: ```tsx import { CmdKButton } from "./CmdKButton"; import { GlobalMicButton } from "./GlobalMicButton"; import { ModeBreadcrumb } from "./ModeBreadcrumb"; export function TopStrip() { return (
    ); } ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /opt/nexus/ui && npx vitest run src/components/frame/TopStrip.test.tsx ``` Expected: PASS — all 4 test cases green. - [ ] **Step 5: Run the whole frame test suite to verify no regressions** ```bash cd /opt/nexus/ui && npx vitest run src/components/frame/ ``` Expected: PASS — all 5 test files green, all test cases from tasks 1–5 pass together. - [ ] **Step 6: Commit** ```bash cd /opt/nexus && git add ui/src/components/frame/TopStrip.tsx ui/src/components/frame/TopStrip.test.tsx git commit -m "$(cat <<'EOF' feat(nexus): add TopStrip composite for layout overhaul (phase 8) 48px sticky top strip per docs/specs/2026-04-11-nexus-layout-overhaul.md §4.2. Composes ModeBreadcrumb (left) + CmdKButton and GlobalMicButton (right) inside a
    landmark. Part of Phase 8 of the Nexus layout overhaul. Completes the frame components; next task mounts them in Layout.tsx. Co-Authored-By: Claude Opus 4.6 (1M context) EOF )" ``` --- ## Task 6: Rewrite `Layout.tsx` to use the new frame **Files:** - Modify: `ui/src/components/Layout.tsx` This task is the largest single change in Phase 8. The existing `Layout.tsx` is 510 lines and interleaves chrome (sidebar, ChatPanel, PropertiesPanel, BreadcrumbBar, footer with theme toggle) with non-chrome responsibilities (company-prefix URL sync, first-run onboarding trigger, scroll handling, body overflow, instance settings memory, hasUnknownCompanyPrefix fallback). **What to keep:** - Lines 43–52: `readRememberedInstanceSettingsPath()` helper - Lines 54–98: hook setup (`useSidebar`, `useDialog`, `usePanel`, `useChatPanel`, `useCompany`, `useTheme`, `useParams`, `useNavigate`, `useLocation`, `useQuery` for health + keyboardShortcutsEnabled) - Lines 100–157: company-prefix URL sync effect - Lines 110–116: first-run onboarding trigger - Lines 161–166: the `setPanelVisible(false)` effect can be deleted since PropertiesPanel is killed - Lines 168: `useCompanyPageMemory()` - Lines 170–179: `useKeyboardShortcuts` — remove the `onSearch` path that references `chatOpen` / `setChatOpen` (Phase 14 reintroduces a different onSearch) - Lines 181–233: mobile nav visibility + swipe gesture — keep (mobile still uses the old MobileBottomNav for Phase 8) - Lines 235–266: scroll tracking for mobile nav visibility — keep - Lines 268–276: body overflow management — keep - Lines 278–291: instance settings memory — keep - Lines 465–490: `hasUnknownCompanyPrefix` fallback redirect — keep - Overlays at lines 501–506: CommandPalette, NewIssueDialog, NewProjectDialog, NewGoalDialog, NewAgentDialog, ToastViewport — keep - WorktreeBanner, DevRestartBanner — keep - Skip-to-main-content link — keep (accessibility) **What to delete from Layout.tsx:** - Imports: `Sidebar`, `InstanceSidebar`, `BreadcrumbBar`, `ChatPanel`, `PropertiesPanel`, `MessageSquare`, `Moon`, `Sun`, `BookOpen`, `Tooltip`/`TooltipTrigger`/`TooltipContent`, `Button`, `useChatPanel`, `useTheme`, `usePanel`, `instanceSettingsTarget`-related imports of Settings icon (the Settings icon in the rail is inside IconRail, not Layout) - `const { chatOpen, setChatOpen, toggleChat } = useChatPanel();` — no longer needed - `const { theme, toggleTheme } = useTheme();` — no longer needed in Layout (ThemeContext still exists, settings page uses it) - `const { togglePanelVisible, setPanelVisible } = usePanel();` — no longer needed - The `togglePanel` declaration - The effect that closes panel on chatOpen - The entire mobile sidebar drawer block (lines 319–372) - The entire desktop sidebar block (lines 374–446) - The BreadcrumbBar mount (lines 449–455) - The ChatPanel mount (line 495) - The PropertiesPanel mount (line 496) **What to add to Layout.tsx:** - Import: `IconRail` from `./frame/IconRail` - Import: `TopStrip` from `./frame/TopStrip` - Pass `matchedCompany?.issuePrefix ?? selectedCompany?.issuePrefix ?? null` as `companyPrefix` to `IconRail` - Place `IconRail` and `TopStrip` in the new layout flex structure - Keep `MobileBottomNav` mounted for mobile only (Phase 15 replaces it) ### New `Layout.tsx` structure (after rewrite) ```tsx return (
    Skip to Main Content
    {/* Desktop-only icon rail */} {/* Main column */}
    {hasUnknownCompanyPrefix ? /* existing fallback logic */ : }
    {/* Mobile bottom nav stays until Phase 15 */} {isMobile && } {/* Overlays unchanged */}
    ); ``` There is no test added for this task — the component tests from tasks 1–5 verify the new frame, and Layout.tsx primarily composes them. A manual smoke test (Step 7) verifies the integration. - [ ] **Step 1: Read the current `Layout.tsx`** Open `ui/src/components/Layout.tsx` in the editor. The file is 510 lines; the above "what to keep / delete / add" lists map every section. - [ ] **Step 2: Rewrite `Layout.tsx`** Replace the entire file contents with: ```tsx import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; 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 { CommandPalette } from "./CommandPalette"; import { NewIssueDialog } from "./NewIssueDialog"; import { NewProjectDialog } from "./NewProjectDialog"; import { NewGoalDialog } from "./NewGoalDialog"; import { NewAgentDialog } from "./NewAgentDialog"; 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 { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; import { instanceSettingsApi } from "../api/instanceSettings"; import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection"; import { DEFAULT_INSTANCE_SETTINGS_PATH, normalizeRememberedInstanceSettingsPath, } from "../lib/instance-settings"; import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; import { NotFoundPage } from "../pages/NotFound"; const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath"; function readRememberedInstanceSettingsPath(): string { if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH; try { return normalizeRememberedInstanceSettingsPath(window.localStorage.getItem(INSTANCE_SETTINGS_MEMORY_KEY)); } catch { return DEFAULT_INSTANCE_SETTINGS_PATH; } } export function Layout() { const { sidebarOpen, setSidebarOpen, isMobile } = useSidebar(); const { openNewIssue, openOnboarding } = useDialog(); const { companies, loading: companiesLoading, selectedCompany, selectedCompanyId, selectionSource, setSelectedCompanyId, } = useCompany(); const { companyPrefix } = useParams<{ companyPrefix: string }>(); const navigate = useNavigate(); const location = useLocation(); const onboardingTriggered = useRef(false); const lastMainScrollTop = useRef(0); const [mobileNavVisible, setMobileNavVisible] = useState(true); const [, setInstanceSettingsTarget] = useState(() => readRememberedInstanceSettingsPath()); const matchedCompany = useMemo(() => { if (!companyPrefix) return null; const requestedPrefix = companyPrefix.toUpperCase(); return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix) ?? null; }, [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(), retry: false, refetchInterval: (query) => { const data = query.state.data as { devServer?: { enabled?: boolean } } | undefined; return data?.devServer?.enabled ? 2000 : false; }, refetchIntervalInBackground: true, }); const keyboardShortcutsEnabled = useQuery({ queryKey: queryKeys.instance.generalSettings, queryFn: () => instanceSettingsApi.getGeneral(), }).data?.keyboardShortcuts === true; // [nexus] First-run onboarding trigger. See original Layout.tsx for the full // rationale comment; this is retained as a belt-and-suspenders fallback. useEffect(() => { if (companiesLoading || onboardingTriggered.current) return; if (companies.length === 0) { onboardingTriggered.current = true; openOnboarding(); } }, [companies, companiesLoading, openOnboarding]); // Company-prefix URL sync. useEffect(() => { if (!companyPrefix || companiesLoading || companies.length === 0) return; if (!matchedCompany) { const fallback = (selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null) ?? companies[0] ?? null; if (fallback && selectedCompanyId !== fallback.id) { setSelectedCompanyId(fallback.id, { source: "route_sync" }); } return; } if (companyPrefix !== matchedCompany.issuePrefix) { const suffix = location.pathname.replace(/^\/[^/]+/, ""); navigate(`/${matchedCompany.issuePrefix}${suffix}${location.search}`, { replace: true }); return; } if ( shouldSyncCompanySelectionFromRoute({ selectionSource, selectedCompanyId, routeCompanyId: matchedCompany.id, }) ) { setSelectedCompanyId(matchedCompany.id, { source: "route_sync" }); } }, [ companyPrefix, companies, companiesLoading, matchedCompany, location.pathname, location.search, navigate, selectionSource, selectedCompanyId, setSelectedCompanyId, ]); useCompanyPageMemory(); useKeyboardShortcuts({ enabled: keyboardShortcutsEnabled, onNewIssue: () => openNewIssue(), 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: () => { // 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 })); }, }); useEffect(() => { if (!isMobile) { setMobileNavVisible(true); return; } lastMainScrollTop.current = 0; setMobileNavVisible(true); }, [isMobile]); // Swipe gesture is a no-op in Phase 8 (no drawer to open) but the listener // is cheap to retain; Phase 15 may reintroduce a gesture for the mobile // bottom tab bar state. useEffect(() => { if (!isMobile) return; const onTouchStart = () => {}; const onTouchEnd = () => {}; 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); lastMainScrollTop.current = currentTop; }, []); useEffect(() => { if (!isMobile) { setMobileNavVisible(true); 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); }, [isMobile, updateMobileNavVisibility]); useEffect(() => { const previousOverflow = document.body.style.overflow; document.body.style.overflow = isMobile ? "visible" : "hidden"; return () => { document.body.style.overflow = previousOverflow; }; }, [isMobile]); 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 { // Ignore storage failures in restricted environments. } }, [location.hash, location.pathname, location.search]); return (
    Skip to Main Content
    {hasUnknownCompanyPrefix ? ( (() => { const fallbackCompany = selectedCompany ?? companies[0] ?? null; if (!fallbackCompany) { return ( ); } const restOfPath = location.pathname.replace(/^\/[^/]+/, "") || "/assistant"; return ( ); })() ) : ( )}
    {isMobile && }
    ); } ``` **Note on the `hasUnknownCompanyPrefix` fallback redirect:** the old code defaulted the `restOfPath` fallback to `"/dashboard"`. In the new IA, Dashboard is killed, so the fallback changes to `"/assistant"` (which is the new landing route). This is a Phase 8 vocabulary change that aligns with the spec. - [ ] **Step 3: Run the full UI test suite to verify nothing else broke** ```bash cd /opt/nexus/ui && npx vitest run 2>&1 | tail -30 ``` Expected: all tests pass. If test files fail due to unrelated pre-existing issues (we saw several at the start of the overhaul — `AgentConfigForm`, onboarding tests, `useKeyboardShortcuts.ts`, etc.), check that none of them were *caused* by the Layout rewrite. Failures in files we didn't touch are pre-existing; failures in Layout itself are new and must be fixed before committing. - [ ] **Step 4: Run typecheck** ```bash cd /opt/nexus/ui && npx tsc --noEmit 2>&1 | grep -E "(frame/|Layout\.tsx)" || echo "NO NEW ERRORS in frame/ or Layout.tsx" ``` Expected: `NO NEW ERRORS in frame/ or Layout.tsx`. If new errors appear in those files, fix them before committing. - [ ] **Step 5: Commit** ```bash cd /opt/nexus && git add ui/src/components/Layout.tsx git commit -m "$(cat <<'EOF' refactor(nexus): mount new frame in Layout.tsx; kill old chrome (phase 8) 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 - The effect that closed PropertiesPanel when chat opened (no longer needed — both panels are unmounted) 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 now dispatches the same synthetic Meta+K keydown as the CmdKButton shim The Sidebar, InstanceSidebar, BreadcrumbBar, ChatPanel, PropertiesPanel, and ThemeContext files remain in the repo; Phase 16 deletes dead files. 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. Co-Authored-By: Claude Opus 4.6 (1M context) EOF )" ``` --- ## Task 7: Manual smoke test (non-coding verification) **Files:** none This is a verification step, not a code change. No commit is produced. - [ ] **Step 1: Start the dev server** ```bash cd /opt/nexus && pnpm dev ``` Or if `pnpm` is unavailable on the dev machine: ```bash cd /opt/nexus/ui && npx vite dev ``` - [ ] **Step 2: Load the app in a browser** Open `http://10.5.0.128:6100/NEX/assistant` (or whatever `//assistant` resolves to locally). Expected visual checks: | Check | Pass criteria | |---|---| | Left icon rail visible | 56px wide strip on the left, pure black, showing Nexus mark + 4 Lucide icons (MessageCircle, Sparkles, FolderKanban, Settings) | | Top strip visible | 48px horizontal strip at the top of the main area, showing `ASSISTANT` in volt on the left and the ⌘K button + mic button on the right | | Old sidebar gone | No 280px left sidebar with project/agent lists | | Old ChatPanel gone | No 380px slide-in chat panel on the right when clicking around | | Old PropertiesPanel gone | No right-side properties panel even on Issue Detail or Agent Detail | | Theme toggle button gone from chrome | No Sun/Moon button in any footer or header | | BreadcrumbBar gone | No separate breadcrumb bar below the top strip — the mode label IS the breadcrumb | | Icon rail active state | The Assistant icon shows volt color and a 2px volt bar on its right edge when on `/NEX/assistant` | | Icon rail navigation | Clicking Studio navigates to `/NEX/content-studio`; Projects → `/NEX/projects`; Settings → `/instance/settings/general` | | ⌘K button opens palette | Clicking the ⌘K button opens the existing command palette | | Mode breadcrumb updates | Navigating to `/NEX/issues/NEX-42` shows `PROJECTS / NEX-42` breadcrumb | | Pages still render | The old `PersonalAssistant`, `ContentStudio`, `Projects`, `Issues` pages still render inside the main area (they look wrong in the new frame, that's fine) | | `/NEX/assistant` does not blank | The piper-tts fix from commit `137bd3d0` is in place; the Assistant page loads and renders its current (old) layout inside the new frame | Any check that fails blocks Phase 8 completion. Log the failure, return to the offending task, fix, and re-run this smoke. - [ ] **Step 3: Stop the dev server when done** --- ## Self-review checklist Run this checklist against the plan before dispatching tasks to subagents: - [ ] Spec coverage: §4.1 IconRail → Task 1 ✓ · §4.2 TopStrip / ModeBreadcrumb → Tasks 2, 3, 4, 5 ✓ · §4.3 kill right rail → Task 6 ✓ · §13 Phase 8 scope ("frame skeleton") → all tasks combined ✓ - [ ] No `TBD` / `TODO` / "add appropriate error handling" style placeholders - [ ] Every code step contains the actual code - [ ] Every commit step contains the actual commit command with message - [ ] File paths are absolute or project-relative throughout - [ ] Task granularity is bite-sized (write test → run fail → implement → run pass → commit) - [ ] Task 6 preserves every non-chrome responsibility of the original Layout.tsx - [ ] The `companyPrefix` prop flows from Layout → IconRail consistently - [ ] The test pattern matches `ui/src/components/ChatInput.test.tsx` (manual createRoot + act, not react-testing-library) - [ ] Tailwind classes reference semantic tokens (`bg-background`, `text-muted-foreground`, `border-border`) where possible, with literal hex only for brand-specific one-offs (`#faff69`, `#166534`, `#a0a0a0`) --- ## Execution handoff After this plan is approved, Phase 8 is executed via **superpowers:subagent-driven-development** — a fresh subagent per task, with two-stage review (design review after the test is written; code review after implementation lands). The dispatcher (main session) reviews between tasks and does not write code itself. Tasks 1 → 5 touch disjoint files and could in principle run in parallel, but task 6 depends on all of them, and the task-by-task review cadence of subagent-driven-development gives better quality than parallelism saves. Run sequentially. After all 7 tasks complete and Task 7's smoke test passes, Wave 1 is done. Write the next plan: `docs/plans/2026-04-11-nexus-phase-9-10-11-wave-2.md` or three separate per-phase plans for Wave 2 (Assistant, Studio, Projects), and dispatch those in parallel via **superpowers:dispatching-parallel-agents**.