From 31d64bbb1d91202d305ec84deb07763dd8bfb856 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 12:16:12 +0000 Subject: [PATCH] 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) --- .../studio/StudioPromptBar.test.tsx | 125 ++++++++++++++++++ ui/src/components/studio/StudioPromptBar.tsx | 84 ++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 ui/src/components/studio/StudioPromptBar.test.tsx create mode 100644 ui/src/components/studio/StudioPromptBar.tsx diff --git a/ui/src/components/studio/StudioPromptBar.test.tsx b/ui/src/components/studio/StudioPromptBar.test.tsx new file mode 100644 index 00000000..18f4d15e --- /dev/null +++ b/ui/src/components/studio/StudioPromptBar.test.tsx @@ -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 | 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( + , + ); + }); + 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"); + }); +}); diff --git a/ui/src/components/studio/StudioPromptBar.tsx b/ui/src/components/studio/StudioPromptBar.tsx new file mode 100644 index 00000000..4f77ef0b --- /dev/null +++ b/ui/src/components/studio/StudioPromptBar.tsx @@ -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 ( +
+ + 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", + )} + /> + +
+ ); +}