nexus/docs/plans/2026-04-11-nexus-phase-8-frame-skeleton.md
Nexus Dev 2a42f4ab86 docs(nexus): phase 8 frame skeleton implementation plan
Task-by-task implementation plan for Phase 8 of the Nexus layout
overhaul (docs/specs/2026-04-11-nexus-layout-overhaul.md §13).

Seven tasks:
  1. IconRail component + test
  2. ModeBreadcrumb component + test
  3. CmdKButton shim component + test
  4. GlobalMicButton scaffold + test
  5. TopStrip composite + test
  6. Layout.tsx rewrite (mount new frame, kill old chrome)
  7. Manual smoke test

TDD throughout (write failing test, run, implement, run, commit).
One commit per task. Execution via subagent-driven-development with
fresh subagent per task and two-stage review.

The plan includes complete code for every task (no placeholders),
exact file paths, exact test patterns matching the existing vitest
manual-createRoot pattern from ChatInput.test.tsx, and wire-up
details for preserving Layout.tsx's non-chrome responsibilities
(company-prefix URL sync, first-run onboarding, body overflow,
instance settings memory, dialog overlays).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:45:30 +00:00

1761 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `<Outlet />` unchanged; they will look wrong in the new frame, and that's expected — phases 913 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 `<Outlet />` 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 13 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<typeof createRoot> | 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(
<MemoryRouter initialEntries={[initialPath]}>
<IconRail companyPrefix="NEX" />
</MemoryRouter>
);
});
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 (
<nav
aria-label="Primary"
className="hidden md:flex w-[56px] shrink-0 flex-col items-center bg-background py-2"
>
{/* Nexus mark — the only volt-filled element in the rail */}
<Link
to={companyPrefix ? `/${companyPrefix}/assistant` : "/"}
aria-label="Nexus home"
className="mb-4 flex h-8 w-8 items-center justify-center rounded-[4px] text-[#faff69]"
>
<NexusMark className="h-5 w-5" />
</Link>
<ul className="flex flex-1 flex-col items-center gap-2">
{DESTINATIONS.slice(0, 3).map((dest) => (
<DestinationLink key={dest.key} destination={dest} companyPrefix={companyPrefix} pathname={pathname} />
))}
</ul>
<div className="mt-auto pb-1">
<DestinationLink
destination={DESTINATIONS[3]!}
companyPrefix={companyPrefix}
pathname={pathname}
/>
</div>
</nav>
);
}
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 (
<li className="relative">
<Link
to={href}
aria-label={destination.label}
aria-current={active ? "page" : undefined}
title={destination.label}
className={cn(
"flex h-10 w-10 items-center justify-center rounded-[4px]",
"transition-colors duration-100 ease-out",
active
? "text-[#faff69]"
: "text-muted-foreground hover:text-[#faff69]",
)}
>
<Icon className="h-5 w-5" strokeWidth={1.5} />
</Link>
{/* Active bar on the right edge — DESIGN.md §4.1 */}
{active && (
<span
aria-hidden="true"
className="absolute right-0 top-1/2 h-5 w-[2px] -translate-y-1/2 bg-[#faff69]"
/>
)}
</li>
);
}
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 (
<svg
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="square"
className={className}
>
<path d="M10 1 L18 5 L18 15 L10 19 L2 15 L2 5 Z" />
<path d="M6 8 L14 12" />
<path d="M14 8 L6 12" />
</svg>
);
}
```
- [ ] **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) <noreply@anthropic.com>
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<typeof createRoot> | 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(
<MemoryRouter initialEntries={[pathname]}>
<ModeBreadcrumb />
</MemoryRouter>
);
});
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/<sub>` → `["STUDIO", "<SUB>"]`
* - `/:prefix/convert*` → `["STUDIO", "CONVERT"]` (Phase 10 folds Convert into Studio)
* - `/:prefix/projects` → `["PROJECTS"]`
* - `/:prefix/projects/<slug>*` → `["PROJECTS", "<SLUG>"]`
* - `/:prefix/(issues|agents|routines|goals|approvals|costs|activity|inbox)` → `["PROJECTS"]`
* - `/:prefix/issues/<slug>` → `["PROJECTS", "<SLUG>"]`
* - `/instance/settings` → `["SETTINGS"]`
* - `/instance/settings/<sub>` → `["SETTINGS", "<SUB>"]`
* - 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/<rest>
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 (
<div
data-testid="mode-breadcrumb"
className="flex items-center gap-2 text-[14px] font-semibold uppercase tracking-[0.1em]"
>
{segments.map((segment, index) => {
const isLeaf = index === segments.length - 1;
return (
<span key={index} className="flex items-center gap-2">
{index > 0 && (
<span
data-testid="mode-breadcrumb-sep"
aria-hidden="true"
className="text-muted-foreground"
>
/
</span>
)}
<span
data-testid="mode-breadcrumb-segment"
className={cn(isLeaf ? "text-[#faff69]" : "text-muted-foreground")}
>
{segment}
</span>
</span>
);
})}
</div>
);
}
```
- [ ] **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/<tab> 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) <noreply@anthropic.com>
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<typeof createRoot> | 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(<CmdKButton />);
});
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 (
<button
type="button"
onClick={handleClick}
aria-label="Open command palette"
title="Open command palette (⌘K)"
className="inline-flex h-8 items-center gap-2 rounded-[4px] border border-border bg-card px-2 text-[12px] font-medium text-muted-foreground transition-colors hover:text-[#faff69] hover:border-[#faff69]"
>
<kbd className="font-mono text-[12px]">K</kbd>
</button>
);
}
```
- [ ] **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) <noreply@anthropic.com>
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<typeof createRoot> | 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(<GlobalMicButton state={state} />);
});
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 (
<button
type="button"
onClick={onClick}
aria-label="Voice"
title="Voice (Phase 14 — not yet wired)"
data-state={state}
className={cn(
"relative inline-flex h-8 w-8 items-center justify-center rounded-[8px]",
"border border-border bg-card transition-colors",
"hover:border-[#faff69]",
)}
>
{state === "idle" && (
<span
aria-hidden="true"
className="h-2 w-2 rounded-full bg-[#166534]"
/>
)}
{state === "listening" && (
<>
<span
aria-hidden="true"
className="h-2 w-2 rounded-full bg-[#faff69] animate-pulse"
/>
<span
aria-hidden="true"
className="absolute inset-0 rounded-[8px] border-2 border-[#faff69] opacity-60 animate-ping"
/>
</>
)}
{state === "speaking" && (
<span
aria-hidden="true"
className="h-2 w-2 rounded-full bg-[#a0a0a0]"
/>
)}
</button>
);
}
```
- [ ] **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) <noreply@anthropic.com>
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<typeof createRoot> | 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(
<MemoryRouter initialEntries={[pathname]}>
<TopStrip />
</MemoryRouter>
);
});
}
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 (
<header
aria-label="Top bar"
className="sticky top-0 z-30 flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-6"
>
<ModeBreadcrumb />
<div className="flex items-center gap-4">
<CmdKButton />
<GlobalMicButton state="idle" />
</div>
</header>
);
}
```
- [ ] **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 15 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 <header aria-label="Top bar"> 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) <noreply@anthropic.com>
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 4352: `readRememberedInstanceSettingsPath()` helper
- Lines 5498: hook setup (`useSidebar`, `useDialog`, `usePanel`, `useChatPanel`, `useCompany`, `useTheme`, `useParams`, `useNavigate`, `useLocation`, `useQuery` for health + keyboardShortcutsEnabled)
- Lines 100157: company-prefix URL sync effect
- Lines 110116: first-run onboarding trigger
- Lines 161166: the `setPanelVisible(false)` effect can be deleted since PropertiesPanel is killed
- Lines 168: `useCompanyPageMemory()`
- Lines 170179: `useKeyboardShortcuts` — remove the `onSearch` path that references `chatOpen` / `setChatOpen` (Phase 14 reintroduces a different onSearch)
- Lines 181233: mobile nav visibility + swipe gesture — keep (mobile still uses the old MobileBottomNav for Phase 8)
- Lines 235266: scroll tracking for mobile nav visibility — keep
- Lines 268276: body overflow management — keep
- Lines 278291: instance settings memory — keep
- Lines 465490: `hasUnknownCompanyPrefix` fallback redirect — keep
- Overlays at lines 501506: 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 319372)
- The entire desktop sidebar block (lines 374446)
- The BreadcrumbBar mount (lines 449455)
- 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 (
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
<div
className={cn(
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
isMobile ? "min-h-dvh" : "flex h-dvh flex-col overflow-hidden",
)}
>
<a href="#main-content" className="/* skip link */">Skip to Main Content</a>
<WorktreeBanner />
<DevRestartBanner devServer={health?.devServer} />
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
{/* Desktop-only icon rail */}
<IconRail companyPrefix={railCompanyPrefix} />
{/* Main column */}
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<TopStrip />
<main
id="main-content"
tabIndex={-1}
className={cn(
"flex-1",
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
)}
>
{hasUnknownCompanyPrefix ? /* existing fallback logic */ : <Outlet />}
</main>
</div>
</div>
{/* Mobile bottom nav stays until Phase 15 */}
{isMobile && <MobileBottomNav visible={mobileNavVisible} />}
{/* Overlays unchanged */}
<CommandPalette />
<NewIssueDialog />
<NewProjectDialog />
<NewGoalDialog />
<NewAgentDialog />
<ToastViewport />
</div>
</GeneralSettingsProvider>
);
```
There is no test added for this task — the component tests from tasks 15 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<string>(() => 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 (
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
<div
className={cn(
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
isMobile ? "min-h-dvh" : "flex h-dvh flex-col overflow-hidden",
)}
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Skip to Main Content
</a>
<WorktreeBanner />
<DevRestartBanner devServer={health?.devServer} />
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
<IconRail companyPrefix={railCompanyPrefix} />
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<TopStrip />
<main
id="main-content"
tabIndex={-1}
className={cn(
"flex-1",
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto p-4 md:p-6",
)}
>
{hasUnknownCompanyPrefix ? (
(() => {
const fallbackCompany = selectedCompany ?? companies[0] ?? null;
if (!fallbackCompany) {
return (
<NotFoundPage
scope="invalid_company_prefix"
requestedPrefix={companyPrefix ?? undefined}
/>
);
}
const restOfPath = location.pathname.replace(/^\/[^/]+/, "") || "/assistant";
return (
<Navigate
to={`/${fallbackCompany.issuePrefix}${restOfPath}${location.search}${location.hash}`}
replace
/>
);
})()
) : (
<Outlet />
)}
</main>
</div>
</div>
{isMobile && <MobileBottomNav visible={mobileNavVisible} />}
<CommandPalette />
<NewIssueDialog />
<NewProjectDialog />
<NewGoalDialog />
<NewAgentDialog />
<ToastViewport />
</div>
</GeneralSettingsProvider>
);
}
```
**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) <noreply@anthropic.com>
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 `/<prefix>/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**.