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:
parent
37d3919c98
commit
31d64bbb1d
2 changed files with 209 additions and 0 deletions
125
ui/src/components/studio/StudioPromptBar.test.tsx
Normal file
125
ui/src/components/studio/StudioPromptBar.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
84
ui/src/components/studio/StudioPromptBar.tsx
Normal file
84
ui/src/components/studio/StudioPromptBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue