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:
parent
31d64bbb1d
commit
3fe4b543cf
2 changed files with 258 additions and 0 deletions
134
ui/src/components/assistant/AssistantInputBar.test.tsx
Normal file
134
ui/src/components/assistant/AssistantInputBar.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
124
ui/src/components/assistant/AssistantInputBar.tsx
Normal file
124
ui/src/components/assistant/AssistantInputBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue