refactor(nexus): wire frame buttons to phase 14 contexts

GlobalMicButton drops the state and onClick scaffolding props from
phase 8 and now consumes VoiceContext directly — click calls
toggleListening(). It also reflects hasQueuedVoice via the idle dot
color and an updated aria-label so the assistant-bound queue is
observable from any route.

CmdKButton replaces the synthetic Meta+K keydown shim with a direct
setOpen(true) call on the CommandPaletteContext. The shim comment
block is gone; no more document dispatch side effects on click.

Tests updated to render inside their respective providers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 13:23:04 +00:00
parent 673962fad8
commit 45d2a9ff24
4 changed files with 144 additions and 71 deletions

View file

@ -2,8 +2,12 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { CmdKButton } from "./CmdKButton";
import {
CommandPaletteProvider,
useCommandPalette,
} from "../../context/CommandPaletteContext";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@ -20,20 +24,42 @@ describe("CmdKButton", () => {
afterEach(() => {
if (root) {
act(() => { root!.unmount(); });
act(() => {
root!.unmount();
});
root = null;
}
if (container.parentNode) container.remove();
});
function renderButton() {
let captured: ReturnType<typeof useCommandPalette> | null = null;
const CaptureState: React.FC = () => {
captured = useCommandPalette();
return (
<span data-testid="state">{captured.open ? "open" : "closed"}</span>
);
};
root = createRoot(container);
act(() => {
root!.render(<CmdKButton />);
root!.render(
<CommandPaletteProvider registerKeyboardShortcut={false}>
<CmdKButton />
<CaptureState />
</CommandPaletteProvider>,
);
});
return {
getButton: () => container.querySelector("button[aria-label='Open command palette']") as HTMLButtonElement,
getButton: () =>
container.querySelector(
"button[aria-label='Open command palette']",
) as HTMLButtonElement,
getKbd: () => container.querySelector("kbd")?.textContent?.trim(),
getState: () =>
container.querySelector("[data-testid='state']")?.textContent ?? null,
getCtx: () => captured,
};
}
@ -43,8 +69,22 @@ describe("CmdKButton", () => {
expect(getKbd()).toBe("⌘K");
});
it("dispatches a Meta+K keydown on document when clicked", () => {
const listener = vi.fn();
it("calls setOpen(true) on click — no synthetic keydown", () => {
const { getButton, getState } = renderButton();
expect(getState()).toBe("closed");
act(() => {
getButton().click();
});
expect(getState()).toBe("open");
});
it("does not dispatch a document-level keydown as a side-effect", () => {
let sawKeydown = false;
const listener = () => {
sawKeydown = true;
};
document.addEventListener("keydown", listener);
const { getButton } = renderButton();
@ -52,11 +92,7 @@ describe("CmdKButton", () => {
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);
expect(sawKeydown).toBe(false);
});
});

View file

@ -1,27 +1,17 @@
import { useCommandPalette } from "../../context/CommandPaletteContext";
/**
* CmdKButton Phase 8 shim for the top-strip command palette trigger.
* CmdKButton top-strip trigger for the command palette.
*
* 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 (see its useEffect at lines 42-51) 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.
* Phase 14 landed the real wiring: clicking the button calls
* `useCommandPalette().setOpen(true)` directly. The Phase 8 synthetic
* Meta+K keydown shim has been retired along with the accompanying
* document-level listener inside CommandPalette (the provider now owns
* the global keyboard shortcut).
*/
export function CmdKButton() {
const handleClick = () => {
const event = new KeyboardEvent("keydown", {
key: "k",
metaKey: true,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(event);
};
const { setOpen } = useCommandPalette();
const handleClick = () => setOpen(true);
return (
<button

View file

@ -2,12 +2,19 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GlobalMicButton } from "./GlobalMicButton";
import { VoiceProvider } from "../../context/VoiceContext";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function makeFakeStream(): MediaStream {
return {
getTracks: () => [{ stop: vi.fn() }],
} as unknown as MediaStream;
}
describe("GlobalMicButton", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null = null;
@ -20,39 +27,71 @@ describe("GlobalMicButton", () => {
afterEach(() => {
if (root) {
act(() => { root!.unmount(); });
act(() => {
root!.unmount();
});
root = null;
}
if (container.parentNode) container.remove();
});
function render(state: "idle" | "listening" | "speaking" = "idle") {
function renderButton(
getUserMedia: (c: MediaStreamConstraints) => Promise<MediaStream> = async () =>
makeFakeStream(),
) {
root = createRoot(container);
act(() => {
root!.render(<GlobalMicButton state={state} />);
root!.render(
<VoiceProvider getUserMedia={getUserMedia} silenceErrors>
<GlobalMicButton />
</VoiceProvider>,
);
});
return {
getButton: () => container.querySelector("button[aria-label='Voice']") as HTMLButtonElement,
getDataState: () => container.querySelector("button[aria-label='Voice']")?.getAttribute("data-state"),
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();
it("renders a button with a Voice aria-label by default (idle)", () => {
const { getButton, getDataState } = renderButton();
expect(getButton()).not.toBeNull();
expect(getDataState()).toBe("idle");
});
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("transitions to data-state='listening' after click", async () => {
const getUserMedia = vi.fn(async () => makeFakeStream());
const { getButton, getDataState } = renderButton(getUserMedia);
it("is a no-op on click in Phase 8 (does not throw)", () => {
const { getButton } = render();
act(() => {
await act(async () => {
getButton().click();
// flush microtasks so the async startListening completes
await Promise.resolve();
await Promise.resolve();
});
expect(getButton()).not.toBeNull();
expect(getUserMedia).toHaveBeenCalled();
expect(getDataState()).toBe("listening");
});
it("falls back to idle when getUserMedia rejects", async () => {
const getUserMedia = vi.fn(async () => {
throw new Error("NotAllowedError");
});
const { getButton, getDataState } = renderButton(getUserMedia);
await act(async () => {
getButton().click();
await Promise.resolve();
await Promise.resolve();
});
expect(getDataState()).toBe("idle");
});
});

View file

@ -1,41 +1,46 @@
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;
}
import { useVoice } from "../../context/VoiceContext";
/**
* GlobalMicButton Phase 8 visual-only mic button for the top strip.
* GlobalMicButton Phase 14 wiring of the top-strip voice affordance.
*
* Per docs/specs/2026-04-11-nexus-layout-overhaul.md §4.2, three states are
* specified:
* Consumes `VoiceContext` directly; the `state` and `onClick` scaffolding
* props from Phase 8 are gone. Click cycles through
* idle listening (on stop) speaking idle
* via `VoiceContext.toggleListening()`.
*
* Per docs/specs/2026-04-11-nexus-layout-overhaul.md §4.2 the three visual
* states are:
* - 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.
*
* Literal hex `#166534` (forest) and `#a0a0a0` (silver) are used because
* MIGRATION-PLAN.md §3 proposes these as new CSS variables but has not yet
* shipped them. Volt uses the `text-primary`/`bg-primary` semantic token.
*/
export function GlobalMicButton({ state = "idle", onClick }: GlobalMicButtonProps) {
export function GlobalMicButton() {
const { state, toggleListening, hasQueuedVoice } = useVoice();
const label =
state === "listening"
? "Voice — listening"
: state === "speaking"
? "Voice — processing"
: hasQueuedVoice
? "Voice (queued)"
: "Voice";
return (
<button
type="button"
onClick={onClick}
aria-label="Voice"
title="Voice (Phase 14 — not yet wired)"
onClick={() => {
void toggleListening();
}}
aria-label={label}
title={label}
data-state={state}
data-queued={hasQueuedVoice ? "true" : undefined}
className={cn(
"relative inline-flex h-8 w-8 items-center justify-center rounded-[8px]",
"border border-border bg-card transition-colors",
@ -46,7 +51,10 @@ export function GlobalMicButton({ state = "idle", onClick }: GlobalMicButtonProp
{state === "idle" && (
<span
aria-hidden="true"
className="h-2 w-2 rounded-full bg-[#166534]"
className={cn(
"h-2 w-2 rounded-full",
hasQueuedVoice ? "bg-primary" : "bg-[#166534]",
)}
/>
)}
{state === "listening" && (