diff --git a/ui/storybook/prototypes/jev-task-creation/JevTaskComposer.tsx b/ui/storybook/prototypes/jev-task-creation/JevTaskComposer.tsx new file mode 100644 index 00000000000..dd7c6d3dd55 --- /dev/null +++ b/ui/storybook/prototypes/jev-task-creation/JevTaskComposer.tsx @@ -0,0 +1,623 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import type { IssueWorkMode } from "@paperclipai/shared"; +import { AlertTriangle, Check, ChevronDown, FolderKanban, Pencil, RotateCcw, Sparkles, Tag, Undo2 } from "lucide-react"; +import { AgentAvatar } from "@/components/AgentAvatar"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { workModeMetaFor, workModeMetaList } from "@/lib/work-mode-meta"; +import { + JEV_AGENTS, + JEV_MIN_PROMPT_LENGTH, + JEV_NAME, + JEV_PROJECTS, + JEV_TASK_TYPES, + classifyTaskPrompt, + type JevSuggestion, + type JevTaskType, +} from "./jev-classifier"; + +export type JevTaskComposerProps = { + /** + * `suggest-first`: Jev fills title and properties while you type; you review and create. + * `instant`: like starting a Claude Code session. Create right away; the task opens as + * "Untitled" and Jev names, classifies, and assigns it a moment later. + */ + flow?: "suggest-first" | "instant"; + initialPrompt?: string; + /** Simulated Jev latency in milliseconds. */ + latencyMs?: number; + /** Simulate Jev being unavailable; the composer falls back to manual fields. */ + jevUnavailable?: boolean; + /** Pre-set fields as if the user already chose them. Jev never overwrites these. */ + presetAssigneeId?: string; + /** Types `initialPrompt` in character by character to show suggestions arriving. */ + typeOnMount?: boolean; + mobile?: boolean; +}; + +type Field = "title" | "type" | "assigneeId" | "projectId" | "workMode"; +type Overrides = Partial<{ title: string; type: JevTaskType; assigneeId: string; projectId: string | null; workMode: IssueWorkMode }>; +type JevStatus = "idle" | "thinking" | "ready" | "error"; +type CreatedTask = { identifier: string; prompt: string }; + +const TYPING_DEBOUNCE_MS = 650; + +/** Types a newly suggested title in, like a session title arriving. Unchanged titles don't retype. */ +function useTypewriter(target: string | null) { + const [shown, setShown] = useState(""); + const previousRef = useRef(null); + useEffect(() => { + const previous = previousRef.current; + previousRef.current = target; + if (!target) return setShown(""); + if (target === previous) return setShown(target); + setShown(""); + let index = 0; + const timer = window.setInterval(() => { + index += 1; + setShown(target.slice(0, index)); + if (index >= target.length) window.clearInterval(timer); + }, 22); + return () => window.clearInterval(timer); + }, [target]); + return shown; +} + +export function JevTaskComposer({ + flow = "suggest-first", + initialPrompt = "", + latencyMs = 900, + jevUnavailable = false, + presetAssigneeId, + typeOnMount = false, + mobile = false, +}: JevTaskComposerProps) { + const [prompt, setPrompt] = useState(typeOnMount ? "" : initialPrompt); + const [status, setStatus] = useState("idle"); + const [suggestion, setSuggestion] = useState(null); + const [overrides, setOverrides] = useState(presetAssigneeId ? { assigneeId: presetAssigneeId } : {}); + const [editingTitle, setEditingTitle] = useState(false); + const [created, setCreated] = useState(null); + const [whyOpen, setWhyOpen] = useState(false); + const abortRef = useRef(null); + const lastClassifiedRef = useRef(""); + + // Autoplay: type the scenario prompt so reviewers see Jev react mid-sentence. + useEffect(() => { + if (!typeOnMount || !initialPrompt) return; + let index = 0; + const timer = window.setInterval(() => { + index += 2; + setPrompt(initialPrompt.slice(0, index)); + if (index >= initialPrompt.length) window.clearInterval(timer); + }, 40); + return () => window.clearInterval(timer); + }, [typeOnMount, initialPrompt]); + + const runJev = useCallback((text: string) => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + lastClassifiedRef.current = text; + setStatus("thinking"); + classifyTaskPrompt(text, { latencyMs, fail: jevUnavailable, signal: controller.signal }) + .then((result) => { + setSuggestion(result); + setStatus("ready"); + }) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") return; + setStatus("error"); + }); + }, [latencyMs, jevUnavailable]); + + // Suggest-first: re-classify after the user pauses typing. + useEffect(() => { + if (flow !== "suggest-first" || created) return; + const text = prompt.trim(); + if (text.length < JEV_MIN_PROMPT_LENGTH) { + abortRef.current?.abort(); + setStatus("idle"); + setSuggestion(null); + return; + } + if (text === lastClassifiedRef.current) return; + const timer = window.setTimeout(() => runJev(text), TYPING_DEBOUNCE_MS); + return () => window.clearTimeout(timer); + }, [prompt, flow, created, runJev]); + + useEffect(() => () => abortRef.current?.abort(), []); + + const effective = useMemo(() => ({ + title: overrides.title ?? suggestion?.title ?? "", + type: overrides.type ?? suggestion?.type ?? null, + assigneeId: overrides.assigneeId ?? suggestion?.assigneeId ?? null, + projectId: overrides.projectId !== undefined ? overrides.projectId : suggestion?.projectId ?? null, + workMode: overrides.workMode ?? suggestion?.workMode ?? "standard", + }), [overrides, suggestion]); + + const isSuggested = (field: Field) => suggestion !== null && overrides[field] === undefined; + const setField = (field: K, value: Overrides[K]) => + setOverrides((current) => ({ ...current, [field]: value })); + const resetField = (field: Field) => + setOverrides((current) => { + const next = { ...current }; + delete next[field]; + return next; + }); + + const canCreate = prompt.trim().length > 0; + const create = () => { + if (!canCreate) return; + setCreated({ identifier: "PAP-412", prompt: prompt.trim() }); + // Instant flow: Jev runs after creation, like a session title arriving. + if (flow === "instant" || status === "idle" || status === "thinking") runJev(prompt.trim()); + }; + const startOver = () => { + abortRef.current?.abort(); + lastClassifiedRef.current = ""; + setPrompt(""); + setSuggestion(null); + setOverrides({}); + setStatus("idle"); + setCreated(null); + setWhyOpen(false); + }; + + const animatedTitle = useTypewriter(isSuggested("title") ? effective.title : null); + const headerTitle = isSuggested("title") ? animatedTitle : effective.title; + + return ( +
+
+ setEditingTitle(true)} + onCommit={(value) => { + setEditingTitle(false); + if (value.trim() && value.trim() !== effective.title) setField("title", value.trim()); + }} + onReset={overrides.title !== undefined && suggestion ? () => resetField("title") : undefined} + /> + + {created ? ( + + ) : ( +
+