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:
parent
673962fad8
commit
45d2a9ff24
4 changed files with 144 additions and 71 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" && (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue