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

65 KiB
Raw Blame History

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:

// @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 MemoryRouter is 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 15 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 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)

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:

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 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.