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>
65 KiB
Nexus Phase 8 — Frame Skeleton Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-developmentto 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 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 <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 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 — seeui/src/components/ChatInput.test.tsxfor the reference shape) - Custom router wrapper
@/lib/router(re-exportsLink,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
#faff69as sole accent, forest#166534as secondary CTA, charcoalrgba(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 —
ChatPanelandPropertiesPanelare 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:
// @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
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:
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
MemoryRouteris not exported from@/lib/router, add it
Check ui/src/lib/router.ts for a MemoryRouter export. If absent, append:
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
cd /opt/nexus/ui && npx vitest run src/components/frame/IconRail.test.tsx
Expected: PASS — all 7 test cases green.
- Step 6: Commit
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:
// @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
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:
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
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
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:
// @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
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:
/**
* 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
cd /opt/nexus/ui && npx vitest run src/components/frame/CmdKButton.test.tsx
Expected: PASS — both test cases green.
- Step 5: Commit
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:
// @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
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:
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
cd /opt/nexus/ui && npx vitest run src/components/frame/GlobalMicButton.test.tsx
Expected: PASS — all 3 test cases green.
- Step 5: Commit
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:
// @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
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:
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
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
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
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 43–52:
readRememberedInstanceSettingsPath()helper - Lines 54–98: hook setup (
useSidebar,useDialog,usePanel,useChatPanel,useCompany,useTheme,useParams,useNavigate,useLocation,useQueryfor 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 theonSearchpath that referenceschatOpen/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:
hasUnknownCompanyPrefixfallback 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 neededconst { theme, toggleTheme } = useTheme();— no longer needed in Layout (ThemeContext still exists, settings page uses it)const { togglePanelVisible, setPanelVisible } = usePanel();— no longer needed- The
togglePaneldeclaration - 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:
IconRailfrom./frame/IconRail - Import:
TopStripfrom./frame/TopStrip - Pass
matchedCompany?.issuePrefix ?? selectedCompany?.issuePrefix ?? nullascompanyPrefixtoIconRail - Place
IconRailandTopStripin the new layout flex structure - Keep
MobileBottomNavmounted for mobile only (Phase 15 replaces it)
New Layout.tsx structure (after rewrite)
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 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:
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
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
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
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
cd /opt/nexus && pnpm dev
Or if pnpm is unavailable on the dev machine:
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
companyPrefixprop 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.