feat(nexus): add StudioPromptBar with intent routing (phase 10)

Freeform input rendered at the bottom of the Studio home. Uses the
classifyIntent helper to route to a workshop, or falls through to the
Assistant for unclassified prompts. The parent page wires the two
callbacks to navigate().

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

View file

@ -0,0 +1,125 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StudioPromptBar } from "./StudioPromptBar";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("StudioPromptBar", () => {
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(onClassified = vi.fn(), onFallbackToAssistant = vi.fn()) {
root = createRoot(container);
act(() => {
root!.render(
<StudioPromptBar
onClassified={onClassified}
onFallbackToAssistant={onFallbackToAssistant}
/>,
);
});
return {
input: container.querySelector("input#studio-prompt-input") as HTMLInputElement,
form: container.querySelector("form") as HTMLFormElement,
submit: container.querySelector("button[type='submit']") as HTMLButtonElement,
onClassified,
onFallbackToAssistant,
};
}
function type(input: HTMLInputElement, text: string) {
// React tracks a hidden "value" setter on the native input prototype;
// we must call it so React sees the change when we dispatch.
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
act(() => {
setter?.call(input, text);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
function submit(form: HTMLFormElement) {
act(() => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
}
it("renders the input with the spec placeholder", () => {
const { input } = render();
expect(input).not.toBeNull();
expect(input.getAttribute("placeholder")).toContain("Or just describe it");
expect(input.getAttribute("placeholder")).toContain("wallpaper");
});
it("disables the submit button while the input is empty", () => {
const { submit: button } = render();
expect(button.disabled).toBe(true);
});
it("enables the submit button once the input has content", () => {
const { input, submit: button } = render();
type(input, "diagram of the auth flow");
expect(button.disabled).toBe(false);
});
it("routes a classifiable prompt via onClassified and clears the input", () => {
const { input, form, onClassified, onFallbackToAssistant } = render();
type(input, "diagram of the auth flow");
submit(form);
expect(onClassified).toHaveBeenCalledTimes(1);
expect(onClassified).toHaveBeenCalledWith("diagrams", "diagram of the auth flow");
expect(onFallbackToAssistant).not.toHaveBeenCalled();
expect(input.value).toBe("");
});
it("routes a convert prompt via onClassified", () => {
const { input, form, onClassified } = render();
type(input, "convert this pdf to markdown");
submit(form);
expect(onClassified).toHaveBeenCalledWith("convert", "convert this pdf to markdown");
});
it("falls through to the Assistant when the classifier returns null", () => {
const { input, form, onClassified, onFallbackToAssistant } = render();
type(input, "what's the weather today");
submit(form);
expect(onClassified).not.toHaveBeenCalled();
expect(onFallbackToAssistant).toHaveBeenCalledWith("what's the weather today");
expect(input.value).toBe("");
});
it("does nothing when submitting whitespace-only input", () => {
const { input, form, onClassified, onFallbackToAssistant } = render();
type(input, " ");
submit(form);
expect(onClassified).not.toHaveBeenCalled();
expect(onFallbackToAssistant).not.toHaveBeenCalled();
});
it("has an accessible label for the submit button", () => {
const { submit: button } = render();
expect(button.getAttribute("aria-label")).toBe("Submit prompt");
});
});

View file

@ -0,0 +1,84 @@
import { useState, type FormEvent } from "react";
import { ArrowUpRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { classifyIntent } from "./classifyIntent";
import type { WorkshopSlug } from "./workshops";
interface StudioPromptBarProps {
/**
* Called when the prompt was successfully classified into a workshop.
* Receives the target slug and the prefilled prompt text.
*/
onClassified: (slug: WorkshopSlug, prefilledPrompt: string) => void;
/**
* Called when the prompt could not be classified (classifier returned
* null). Receives the raw prompt; the parent page should navigate the
* user to the Assistant with the prompt pre-loaded.
*/
onFallbackToAssistant: (prompt: string) => void;
}
/**
* Phase 10 freeform input at the bottom of the Studio home page.
*
* Spec (§6.5): the user types "I need a 1920×1080 wallpaper of …", the
* classifier maps that to the `wallpapers` workshop, and this component
* calls `onClassified("wallpapers", prompt)`. If no workshop matches, the
* prompt falls through to the Assistant via `onFallbackToAssistant`.
*
* This component owns the input state; routing is the parent's job.
*/
export function StudioPromptBar({ onClassified, onFallbackToAssistant }: StudioPromptBarProps) {
const [value, setValue] = useState("");
const handleSubmit = (event?: FormEvent) => {
event?.preventDefault();
const prompt = value.trim();
if (!prompt) return;
const result = classifyIntent(prompt);
if (result) {
onClassified(result.slug, result.prefilledPrompt);
} else {
onFallbackToAssistant(prompt);
}
setValue("");
};
return (
<form
data-testid="studio-prompt-bar"
onSubmit={handleSubmit}
className="flex w-full items-center gap-3 border-t border-border pt-6"
>
<label htmlFor="studio-prompt-input" className="sr-only">
Describe what you need
</label>
<input
id="studio-prompt-input"
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={'Or just describe it: "I need a 1920×1080 wallpaper of …"'}
autoComplete="off"
className={cn(
"flex-1 border-none bg-transparent px-0 py-2",
"text-[16px] text-foreground placeholder:text-muted-foreground",
"focus:outline-none focus-visible:outline-none",
)}
/>
<button
type="submit"
aria-label="Submit prompt"
disabled={value.trim().length === 0}
className={cn(
"inline-flex h-10 w-10 items-center justify-center rounded-md",
"text-primary transition-colors duration-100",
"hover:bg-card disabled:cursor-not-allowed disabled:opacity-40",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<ArrowUpRight className="h-5 w-5" strokeWidth={1.5} aria-hidden="true" />
</button>
</form>
);
}