diff --git a/ui/storybook/prototypes/jev-task-creation/JevTaskComposer.tsx b/ui/storybook/prototypes/jev-task-creation/JevTaskComposer.tsx index d40c54ea05f..20e331f7ee7 100644 --- a/ui/storybook/prototypes/jev-task-creation/JevTaskComposer.tsx +++ b/ui/storybook/prototypes/jev-task-creation/JevTaskComposer.tsx @@ -1,10 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { IssueWorkMode } from "@paperclipai/shared"; -import { AlertTriangle, Check, ChevronDown, FolderKanban, Pencil, RotateCcw, Sparkles, Undo2 } from "lucide-react"; +import { Check, ChevronDown, FolderKanban, Maximize2, Minimize2, Sparkles, Undo2, X } from "lucide-react"; import { AgentAvatar } from "@/components/AgentAvatar"; +import { InlineBanner } from "@/components/InlineBanner"; import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Textarea } from "@/components/ui/textarea"; +import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { workModeMetaFor, workModeMetaList } from "@/lib/work-mode-meta"; import { @@ -14,25 +16,30 @@ import { JEV_MODEL, JEV_NAME, JEV_PROJECTS, + TITLE_MODEL, agentContextCoverage, - stabilizeRouting, buildJevDecisionRequest, draftTaskTitle, routeTaskWithJev, + stabilizeRouting, + type JevAgent, type JevField, type JevRouting, + type ModelUsage, } from "./jev-classifier"; export type JevTaskComposerProps = { /** - * Both flows update mode and owner live while you type. The title is - * drafted once, when the task is started (Start task or Enter), like Claude - * Code naming a session after the first message. + * Both flows update mode and owner live while you type. The title is drafted + * once, when the task is started (Start task or Enter), like Claude Code + * naming a session after the first message. * `suggest-first`: review the live suggestions, then create. * `instant`: start any time; whatever Jev has suggested so far is used, and * anything still pending finishes after. */ flow?: "suggest-first" | "instant"; + /** `dialog` renders the real modal. `inline` renders the same surface in the page flow, for review pages. */ + presentation?: "dialog" | "inline"; initialPrompt?: string; /** Simulated Jev decision latency. Jev is a fast decision model. */ latencyMs?: number; @@ -50,17 +57,29 @@ export type JevTaskComposerProps = { pausedAgentIds?: string[]; /** Types `initialPrompt` in character by character to show suggestions arriving. */ typeOnMount?: boolean; - mobile?: boolean; }; type Overrides = Partial<{ title: string; workMode: IssueWorkMode; assignee: string; project: string | null }>; type OverrideField = keyof Overrides; +type RoutedField = Exclude; type Status = "idle" | "thinking" | "ready" | "error"; -type LiveStats = { calls: number; cost: number }; +type LiveStats = { calls: number; inputTokens: number; cost: number }; + +const EMPTY_STATS: LiveStats = { calls: 0, inputTokens: 0, cost: 0 }; const FLASH_MS = 900; /** Explanatory notes (like the fallback owner) wait for a pause in typing, so they don't flicker mid-sentence. */ const SETTLE_MS = 1000; +const CREATED_IDENTIFIER = "PAP-412"; + +/** Shipped `NewIssueDialog` compact property control, so chips match the product exactly. */ +const COMPACT_CONTROL = + "inline-flex h-8 items-center gap-1.5 rounded-md border px-2.5 py-0 text-xs transition-colors sm:h-auto sm:px-2 sm:py-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; +/** A changed value briefly takes the accent fill; timing comes from the motion tokens. */ +const CHANGE_TRANSITION = "duration-(--motion-duration-slow) ease-(--motion-ease-standard)"; + +const percent = (value: number | undefined) => (value === undefined ? "" : `${Math.round(value * 100)}%`); +const isAbort = (error: unknown) => error instanceof DOMException && error.name === "AbortError"; /** Debounce with a ceiling: wait for a pause, but never longer than `maxWaitMs` since the first pending change. */ function throttledDelay(pendingSince: { current: number | null }, debounceMs: number, maxWaitMs: number) { @@ -68,19 +87,13 @@ function throttledDelay(pendingSince: { current: number | null }, debounceMs: nu return Math.max(0, Math.min(debounceMs, maxWaitMs - (Date.now() - pendingSince.current))); } -const percent = (value: number | undefined) => (value === undefined ? "" : `${Math.round(value * 100)}%`); -const isAbort = (error: unknown) => error instanceof DOMException && error.name === "AbortError"; - function commonPrefixLength(a: string, b: string) { let index = 0; while (index < a.length && index < b.length && a[index] === b[index]) index += 1; return index; } -/** - * Types a suggested title in, like a session title arriving. When the title is - * redrafted mid-typing, only the part that changed is retyped. - */ +/** Types a suggested title in, like a session title arriving. A redraft only retypes the part that changed. */ function useTypewriter(target: string | null) { const [shown, setShown] = useState(""); const shownRef = useRef(""); @@ -108,6 +121,7 @@ function useTypewriter(target: string | null) { export function JevTaskComposer({ flow = "suggest-first", + presentation = "dialog", initialPrompt = "", latencyMs = 350, titleLatencyMs = 1200, @@ -117,7 +131,6 @@ export function JevTaskComposer({ presetAssigneeId, pausedAgentIds, typeOnMount = false, - mobile = false, }: JevTaskComposerProps) { const pausedKey = (pausedAgentIds ?? []).join(","); const agents = useMemo( @@ -130,19 +143,17 @@ export function JevTaskComposer({ const [titleStatus, setTitleStatus] = useState("idle"); const [suggestedTitle, setSuggestedTitle] = useState(null); const [overrides, setOverrides] = useState(presetAssigneeId ? { assignee: presetAssigneeId } : {}); - const [editingTitle, setEditingTitle] = useState(false); - const [createdIdentifier, setCreatedIdentifier] = useState(null); - const [createdPrompt, setCreatedPrompt] = useState(""); - const [detailsOpen, setDetailsOpen] = useState(false); + const [createdPrompt, setCreatedPrompt] = useState(null); + const [expanded, setExpanded] = useState(false); const [flash, setFlash] = useState>>({}); - const [liveStats, setLiveStats] = useState({ calls: 0, cost: 0 }); + const [liveStats, setLiveStats] = useState(EMPTY_STATS); + const [titleUsage, setTitleUsage] = useState(null); + const [settled, setSettled] = useState(true); const routingRef = useRef(null); const routingAbortRef = useRef(null); const titleAbortRef = useRef(null); const lastRoutedRef = useRef(""); - const lastTitledRef = useRef(""); const flashTimerRef = useRef(undefined); - const [settled, setSettled] = useState(true); const routingPendingSinceRef = useRef(null); // Autoplay: type the scenario prompt so reviewers see suggestions react mid-sentence. @@ -169,14 +180,17 @@ export function JevTaskComposer({ const previous = routingRef.current; const next = stabilizeRouting(previous, result); // Highlight only fields whose value actually changed, so the eye goes to what moved. - const changed: Partial> = previous ? { - workMode: previous.workMode !== next.workMode, - assignee: previous.assigneeId !== next.assigneeId, - } : {}; + const changed: Partial> = previous + ? { workMode: previous.workMode !== next.workMode, assignee: previous.assigneeId !== next.assigneeId } + : {}; routingRef.current = next; setRouting(next); setRoutingStatus("ready"); - setLiveStats((stats) => ({ calls: stats.calls + 1, cost: stats.cost + result.usage.cost })); + setLiveStats((stats) => ({ + calls: stats.calls + 1, + inputTokens: stats.inputTokens + result.usage.input_tokens, + cost: stats.cost + result.usage.cost, + })); if (Object.values(changed).some(Boolean)) { setFlash(changed); window.clearTimeout(flashTimerRef.current); @@ -190,10 +204,9 @@ export function JevTaskComposer({ titleAbortRef.current?.abort(); const controller = new AbortController(); titleAbortRef.current = controller; - lastTitledRef.current = text; setTitleStatus("thinking"); draftTaskTitle(text, { latencyMs: titleLatencyMs, signal: controller.signal }) - .then((title) => { setSuggestedTitle(title); setTitleStatus("ready"); }) + .then((draft) => { setSuggestedTitle(draft.title); setTitleUsage(draft.usage); setTitleStatus("ready"); }) .catch((error: unknown) => { if (!isAbort(error)) setTitleStatus("error"); }); }, [titleLatencyMs]); @@ -201,28 +214,30 @@ export function JevTaskComposer({ routingAbortRef.current?.abort(); titleAbortRef.current?.abort(); lastRoutedRef.current = ""; - lastTitledRef.current = ""; routingPendingSinceRef.current = null; routingRef.current = null; setRouting(null); setSuggestedTitle(null); + setTitleUsage(null); setRoutingStatus("idle"); setTitleStatus("idle"); setFlash({}); }, []); + const created = createdPrompt !== null; + // Live routing: re-run shortly after each pause in typing, until the task is created. useEffect(() => { - if (createdIdentifier) return; + if (created) return; const text = prompt.trim(); if (text.length < JEV_MIN_PROMPT_LENGTH) { - if (lastRoutedRef.current || lastTitledRef.current) resetSuggestions(); + if (lastRoutedRef.current) resetSuggestions(); return; } if (text === lastRoutedRef.current) return; const timer = window.setTimeout(() => runRouting(text), throttledDelay(routingPendingSinceRef, liveDebounceMs, liveMaxWaitMs)); return () => window.clearTimeout(timer); - }, [prompt, createdIdentifier, runRouting, resetSuggestions, liveDebounceMs, liveMaxWaitMs]); + }, [prompt, created, runRouting, resetSuggestions, liveDebounceMs, liveMaxWaitMs]); useEffect(() => { setSettled(false); @@ -244,7 +259,7 @@ export function JevTaskComposer({ workMode: overrides.workMode ?? routing?.workMode ?? "standard", }), [overrides, routing, suggestedTitle]); - const fromJev = (field: Exclude) => routing !== null && overrides[field] === undefined; + const fromJev = (field: RoutedField) => routing !== null && overrides[field] === undefined; const setField = (field: K, value: Overrides[K]) => setOverrides((current) => ({ ...current, [field]: value })); const resetField = (field: OverrideField) => @@ -256,9 +271,8 @@ export function JevTaskComposer({ const canCreate = prompt.trim().length > 0; const create = () => { - if (!canCreate) return; + if (!canCreate || created) return; const text = prompt.trim(); - setCreatedIdentifier("PAP-412"); setCreatedPrompt(text); // Creating never waits: keep the routing that's shown, bring it up to date if // typing got ahead of it, and draft the title now that the prompt is final. @@ -269,504 +283,435 @@ export function JevTaskComposer({ resetSuggestions(); setPrompt(""); setOverrides({}); - setCreatedIdentifier(null); - setDetailsOpen(false); - setLiveStats({ calls: 0, cost: 0 }); + setCreatedPrompt(null); + setLiveStats(EMPTY_STATS); }; const titleSuggested = suggestedTitle !== null && overrides.title === undefined; const animatedTitle = useTypewriter(titleSuggested ? suggestedTitle : null); - const created = createdIdentifier !== null; - const busy = routingStatus === "thinking" || titleStatus === "thinking"; + const routingPending = routingStatus === "thinking"; - return ( -
-
- setEditingTitle(true)} - onCommit={(value) => { - setEditingTitle(false); - if (value.trim() && value.trim() !== effective.title) setField("title", value.trim()); - }} - onReset={overrides.title !== undefined && suggestedTitle ? () => resetField("title") : undefined} - /> + const surface = ( + <> + setExpanded((value) => !value)} + onRenameTitle={(value) => setField("title", value)} + onResetTitle={overrides.title !== undefined && suggestedTitle ? () => resetField("title") : undefined} + onClose={startOver} + /> +
{created ? ( -
-

{createdPrompt}

-
+

{createdPrompt}

) : ( -
-