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