diff --git a/docs/plans/2026-04-11-nexus-phase-8-frame-skeleton.md b/docs/plans/2026-04-11-nexus-phase-8-frame-skeleton.md new file mode 100644 index 00000000..ce8b03b8 --- /dev/null +++ b/docs/plans/2026-04-11-nexus-phase-8-frame-skeleton.md @@ -0,0 +1,1761 @@ +# 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**.