feat(nexus): add AssistantInputBar composite (phase 9)

Wraps the existing ChatInput and VoiceWaveform into the spec-canonical
input surface: voice waveform above, hairline divider, and text input
below, inside a rounded 8px near-black shell. Stateless composite —
callers pass micStream/micActive and onSend through to the underlying
primitives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 12:16:51 +00:00
parent 31d64bbb1d
commit 3fe4b543cf
2 changed files with 258 additions and 0 deletions

View file

@ -0,0 +1,134 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ─── Stubs ──────────────────────────────────────────────────────────────────
// ChatInput and VoiceWaveform both pull in heavy dependencies (contexts,
// canvas APIs) that aren't relevant to what this composite adds. We stub
// them with minimal stand-ins so the test focuses on layout composition.
vi.mock("../ChatInput", () => ({
ChatInput: (props: Record<string, unknown>) => (
<button
type="button"
data-testid="chat-input-stub"
data-placeholder={String(props.placeholder ?? "")}
data-submitting={String(props.isSubmitting ?? "")}
data-disabled={String(props.disabled ?? "")}
data-enable-voice={String(props.enableVoiceInput ?? "")}
onClick={() => (props.onSend as (text: string) => void)("hi")}
>
chat-input
</button>
),
}));
vi.mock("../VoiceWaveform", () => ({
VoiceWaveform: (props: { stream: MediaStream | null; active: boolean }) => (
<div
data-testid="voice-waveform-stub"
data-has-stream={String(props.stream !== null)}
data-active={String(props.active)}
/>
),
}));
import { AssistantInputBar } from "./AssistantInputBar";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("<AssistantInputBar />", () => {
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(node: React.ReactNode) {
root = createRoot(container);
act(() => {
root!.render(node);
});
}
it("renders the waveform row, hairline divider, and wrapped ChatInput", () => {
render(<AssistantInputBar onSend={() => {}} />);
expect(container.querySelector('[data-testid="assistant-input-bar"]')).not.toBeNull();
expect(container.querySelector('[data-testid="assistant-input-bar-waveform"]')).not.toBeNull();
expect(container.querySelector('[data-testid="assistant-input-bar-divider"]')).not.toBeNull();
expect(container.querySelector('[data-testid="voice-waveform-stub"]')).not.toBeNull();
expect(container.querySelector('[data-testid="chat-input-stub"]')).not.toBeNull();
});
it("orders the waveform above the divider and the divider above the input", () => {
render(<AssistantInputBar onSend={() => {}} />);
const root = container.querySelector('[data-testid="assistant-input-bar"]');
expect(root).not.toBeNull();
const children = Array.from(root!.children);
// row 0: waveform wrapper, row 1: divider, row 2: input wrapper
expect(children[0]!.getAttribute("data-testid")).toBe("assistant-input-bar-waveform");
expect(children[1]!.getAttribute("data-testid")).toBe("assistant-input-bar-divider");
// The third child is the input wrapper div with ChatInput inside
expect(children[2]!.querySelector('[data-testid="chat-input-stub"]')).not.toBeNull();
});
it("forwards onSend to the underlying ChatInput", () => {
const onSend = vi.fn();
render(<AssistantInputBar onSend={onSend} />);
const stub = container.querySelector('[data-testid="chat-input-stub"]') as HTMLButtonElement;
act(() => {
stub.click();
});
expect(onSend).toHaveBeenCalledWith("hi");
});
it("forwards isSubmitting and disabled to ChatInput", () => {
render(<AssistantInputBar onSend={() => {}} isSubmitting disabled />);
const stub = container.querySelector('[data-testid="chat-input-stub"]') as HTMLElement;
expect(stub.getAttribute("data-submitting")).toBe("true");
expect(stub.getAttribute("data-disabled")).toBe("true");
});
it("passes micStream and micActive through to the waveform", () => {
// A fake MediaStream stand-in is fine — the stub only checks identity.
const stream = {} as unknown as MediaStream;
render(<AssistantInputBar onSend={() => {}} micStream={stream} micActive />);
const wave = container.querySelector('[data-testid="voice-waveform-stub"]') as HTMLElement;
expect(wave.getAttribute("data-has-stream")).toBe("true");
expect(wave.getAttribute("data-active")).toBe("true");
});
it("uses the spec-copy placeholder by default and accepts an override", () => {
render(<AssistantInputBar onSend={() => {}} />);
const stub = container.querySelector('[data-testid="chat-input-stub"]') as HTMLElement;
expect(stub.getAttribute("data-placeholder")).toContain("hold space to talk");
act(() => {
root!.render(<AssistantInputBar onSend={() => {}} placeholder="Custom" />);
});
const stub2 = container.querySelector('[data-testid="chat-input-stub"]') as HTMLElement;
expect(stub2.getAttribute("data-placeholder")).toBe("Custom");
});
it("enables voice input by default", () => {
render(<AssistantInputBar onSend={() => {}} />);
const stub = container.querySelector('[data-testid="chat-input-stub"]') as HTMLElement;
expect(stub.getAttribute("data-enable-voice")).toBe("true");
});
});

View file

@ -0,0 +1,124 @@
// [nexus] Assistant input bar composite (Phase 9).
//
// Stacks the voice waveform above the text input, separated by a hairline
// divider, inside a single near-black surface with a sharp 8px radius.
// Wraps the existing ChatInput as-is; waveform state is passed in by the
// caller so we can keep this component stateless and reusable.
import { ChatInput } from "../ChatInput";
import { VoiceWaveform } from "../VoiceWaveform";
import type { Agent } from "@paperclipai/shared";
import type { PendingFile } from "../../hooks/useChatFileUpload";
import { cn } from "@/lib/utils";
export interface AssistantInputBarProps {
/**
* Called when the user submits a message (Enter or Send button).
*/
onSend: (content: string) => void;
/**
* Optional microphone stream for the waveform. If null the waveform shows
* a flat baseline.
*/
micStream?: MediaStream | null;
/**
* Whether the microphone is actively capturing. Drives the waveform
* animation loop.
*/
micActive?: boolean;
/**
* Forwarded to ChatInput: disables submit while a stream is in flight.
*/
isSubmitting?: boolean;
/**
* Forwarded to ChatInput.
*/
disabled?: boolean;
/**
* Placeholder text. Defaults to the spec copy.
*/
placeholder?: string;
/**
* Agents for the @mention popover inside ChatInput.
*/
agents?: Agent[];
agentsLoading?: boolean;
/**
* File attach integration.
*/
onFilesPicked?: (files: File[]) => void;
pendingFiles?: PendingFile[];
onRemoveFile?: (id: string) => void;
/**
* Whether voice input (mic) button inside ChatInput should be shown.
* Defaults to true for the Assistant page.
*/
enableVoiceInput?: boolean;
className?: string;
}
export function AssistantInputBar({
onSend,
micStream = null,
micActive = false,
isSubmitting,
disabled,
placeholder = "Type a message or hold space to talk…",
agents,
agentsLoading,
onFilesPicked,
pendingFiles,
onRemoveFile,
enableVoiceInput = true,
className,
}: AssistantInputBarProps) {
return (
<div
data-testid="assistant-input-bar"
aria-label="Assistant input"
className={cn(
"flex flex-col rounded-[8px] border border-border bg-card",
"focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2 focus-within:ring-offset-background",
className,
)}
>
{/* Waveform row */}
<div
data-testid="assistant-input-bar-waveform"
className="flex items-center justify-center px-4 pt-3 pb-2"
>
<div className="relative flex h-8 w-full items-center justify-center overflow-hidden">
{/* Flat-baseline underlay: silver 1px line visible whenever the
canvas isn't drawing (idle state). */}
<div
aria-hidden="true"
className="absolute left-1/2 top-1/2 h-px w-20 -translate-x-1/2 -translate-y-1/2 bg-muted-foreground/40"
/>
<VoiceWaveform stream={micStream} active={micActive} />
</div>
</div>
{/* Hairline divider */}
<div
aria-hidden="true"
data-testid="assistant-input-bar-divider"
className="h-px w-full bg-border"
/>
{/* Text input row with generous 24px vertical padding (px-4 py-6) */}
<div className="px-4 py-6">
<ChatInput
onSend={onSend}
isSubmitting={isSubmitting}
disabled={disabled}
placeholder={placeholder}
agents={agents}
agentsLoading={agentsLoading}
onFilesPicked={onFilesPicked}
pendingFiles={pendingFiles}
onRemoveFile={onRemoveFile}
enableVoiceInput={enableVoiceInput}
/>
</div>
</div>
);
}