diff --git a/ui/src/components/assistant/AssistantInputBar.test.tsx b/ui/src/components/assistant/AssistantInputBar.test.tsx new file mode 100644 index 00000000..600e9299 --- /dev/null +++ b/ui/src/components/assistant/AssistantInputBar.test.tsx @@ -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) => ( + + ), +})); + +vi.mock("../VoiceWaveform", () => ({ + VoiceWaveform: (props: { stream: MediaStream | null; active: boolean }) => ( +
+ ), +})); + +import { AssistantInputBar } from "./AssistantInputBar"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("", () => { + let container: HTMLDivElement; + let root: ReturnType | 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( {}} />); + + 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( {}} />); + 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(); + 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( {}} 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( {}} 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( {}} />); + const stub = container.querySelector('[data-testid="chat-input-stub"]') as HTMLElement; + expect(stub.getAttribute("data-placeholder")).toContain("hold space to talk"); + + act(() => { + root!.render( {}} 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( {}} />); + const stub = container.querySelector('[data-testid="chat-input-stub"]') as HTMLElement; + expect(stub.getAttribute("data-enable-voice")).toBe("true"); + }); +}); diff --git a/ui/src/components/assistant/AssistantInputBar.tsx b/ui/src/components/assistant/AssistantInputBar.tsx new file mode 100644 index 00000000..4f505340 --- /dev/null +++ b/ui/src/components/assistant/AssistantInputBar.tsx @@ -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 ( +
+ {/* Waveform row */} +
+
+ {/* Flat-baseline underlay: silver 1px line visible whenever the + canvas isn't drawing (idle state). */} + +
+ + {/* Hairline divider */} + + ); +}