storybook: prototype prompt-first task creation with Jev suggestions

Adds a UX Labs prototype where the user writes the task prompt first and
Jev suggests the title, task type, assignee, project, and work mode.
Includes a suggest-first flow and an instant flow modeled on Claude Code
session titles. Jev is a local keyword heuristic with simulated latency;
no model or API is called.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWn6etZjfGz6p8NWYkCQve
This commit is contained in:
Claude committed 2026-09-24 21:11:48 +00:00
1 parent 18dac1e1ef
commit fb0ce05f7a
4 files changed
+965

No files matched your search

@@ -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<string | null>(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<JevStatus>("idle");
const [suggestion, setSuggestion] = useState<JevSuggestion | null>(null);
const [overrides, setOverrides] = useState<Overrides>(presetAssigneeId ? { assigneeId: presetAssigneeId } : {});
const [editingTitle, setEditingTitle] = useState(false);
const [created, setCreated] = useState<CreatedTask | null>(null);
const [whyOpen, setWhyOpen] = useState(false);
const abortRef = useRef<AbortController | null>(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 = <K extends keyof Overrides>(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 (
<div className={cn("flex min-h-screen w-full items-start justify-center bg-background p-4 sm:p-10", mobile && "p-0 sm:p-0")}>
<div
className={cn(
"flex w-full flex-col overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-lg",
mobile ? "min-h-screen max-w-sm rounded-none border-0" : "max-w-2xl",
)}
>
<ComposerHeader
identifier={created?.identifier}
title={headerTitle}
thinking={status === "thinking" && !effective.title}
suggested={isSuggested("title")}
editing={editingTitle}
onEdit={() => 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 ? (
<CreatedBody prompt={created.prompt} />
) : (
<div className="flex flex-col gap-3 px-4 pt-4 pb-2">
<Textarea
autoFocus
aria-label="Describe the task"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
create();
}
}}
placeholder={flow === "instant"
? `What should get done? ${JEV_NAME} will name it and route it once you start.`
: `What should get done? ${JEV_NAME} will suggest a title, type, and owner as you write.`}
className="min-h-36 resize-none border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0 dark:bg-transparent"
/>
</div>
)}
<JevPanel
flow={flow}
created={created !== null}
status={status}
promptLength={prompt.trim().length}
suggestion={suggestion}
effective={effective}
isSuggested={isSuggested}
setField={setField}
resetField={resetField}
whyOpen={whyOpen}
onToggleWhy={() => setWhyOpen((open) => !open)}
onRetry={() => runJev(prompt.trim())}
/>
<div className="mt-auto flex items-center justify-between gap-3 border-t border-border px-4 py-3">
{created ? (
<>
<Button variant="ghost" size="sm" onClick={startOver}>
<RotateCcw /> New task
</Button>
<span className="text-xs text-muted-foreground">
{status === "thinking" ? `${JEV_NAME} is routing this task…` : status === "ready" ? "Task created and assigned." : "Task created."}
</span>
</>
) : (
<>
<Button variant="ghost" size="sm" onClick={startOver} disabled={!prompt}>
Cancel
</Button>
<div className="flex items-center gap-3">
{flow === "suggest-first" && status === "thinking" ? (
<span className="hidden text-xs text-muted-foreground sm:inline">You can create now; {JEV_NAME} will finish after.</span>
) : null}
<Button size="sm" onClick={create} disabled={!canCreate}>
{flow === "instant" ? "Start task" : "Create task"}
<kbd className="ml-1 hidden text-xs opacity-60 sm:inline">⌘↵</kbd>
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
function JevMark({ thinking = false, className }: { thinking?: boolean; className?: string }) {
return (
<span
aria-hidden
className={cn(
"inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary",
thinking && "animate-pulse",
className,
)}
>
<Sparkles className="size-3" />
</span>
);
}
function ComposerHeader({
identifier,
title,
thinking,
suggested,
editing,
onEdit,
onCommit,
onReset,
}: {
identifier?: string;
title: string;
thinking: boolean;
suggested: boolean;
editing: boolean;
onEdit: () => void;
onCommit: (value: string) => void;
onReset?: () => void;
}) {
const [draft, setDraft] = useState(title);
useEffect(() => { if (editing) setDraft(title); }, [editing, title]);
return (
<div className="flex min-h-12 items-center gap-2 border-b border-border px-4 py-2 text-sm">
<span className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold">{identifier ?? "PAP"}</span>
<span className="text-muted-foreground/60">&rsaquo;</span>
{editing ? (
<input
autoFocus
aria-label="Task title"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onBlur={() => onCommit(draft)}
onKeyDown={(event) => {
if (event.key === "Enter") onCommit(draft);
if (event.key === "Escape") onCommit(title);
}}
className="min-w-0 flex-1 rounded border border-border bg-transparent px-1.5 py-0.5 font-medium outline-none focus:border-ring"
/>
) : title ? (
<button
type="button"
onClick={onEdit}
className="group flex min-w-0 items-center gap-1.5 rounded px-1 py-0.5 text-left font-medium hover:bg-accent"
title={suggested ? `Title suggested by ${JEV_NAME}. Click to edit.` : "Click to edit"}
>
{suggested ? <Sparkles aria-label={`Suggested by ${JEV_NAME}`} className="size-3.5 shrink-0 text-primary" /> : null}
<span className="truncate">{title}</span>
<Pencil aria-hidden className="size-3 shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100" />
</button>
) : thinking ? (
<span className="flex items-center gap-2 text-muted-foreground">
<span className="h-3 w-40 animate-pulse rounded bg-muted" />
<span className="sr-only">{JEV_NAME} is naming this task</span>
</span>
) : (
<span className="text-muted-foreground">{identifier ? "Untitled task" : "New task"}</span>
)}
{onReset && !editing ? (
<Button variant="ghost" size="icon-xs" className="text-muted-foreground" onClick={onReset} title={`Use ${JEV_NAME}'s title`}>
<Undo2 />
</Button>
) : null}
</div>
);
}
function CreatedBody({ prompt }: { prompt: string }) {
return (
<div className="flex flex-col gap-3 px-4 pt-4 pb-2">
<p className="self-end whitespace-pre-wrap rounded-xl bg-muted px-4 py-3 text-sm">{prompt}</p>
</div>
);
}
type Effective = {
title: string;
type: JevTaskType | null;
assigneeId: string | null;
projectId: string | null;
workMode: IssueWorkMode;
};
function JevPanel({
flow,
created,
status,
promptLength,
suggestion,
effective,
isSuggested,
setField,
resetField,
whyOpen,
onToggleWhy,
onRetry,
}: {
flow: "suggest-first" | "instant";
created: boolean;
status: JevStatus;
promptLength: number;
suggestion: JevSuggestion | null;
effective: Effective;
isSuggested: (field: Field) => boolean;
setField: <K extends keyof Overrides>(field: K, value: Overrides[K]) => void;
resetField: (field: Field) => void;
whyOpen: boolean;
onToggleWhy: () => void;
onRetry: () => void;
}) {
const statusLine = (() => {
if (status === "error") return null;
if (status === "thinking") return created ? `${JEV_NAME} is naming and routing this task…` : `${JEV_NAME} is reading your request…`;
if (status === "ready" && suggestion) {
return suggestion.confidence === "low" ? `${JEV_NAME} isn't sure who should own this. Pick one:` : null;
}
if (flow === "instant" && !created) return `${JEV_NAME} picks a title, type, and owner after you start. You can change anything later.`;
if (promptLength > 0 && promptLength < JEV_MIN_PROMPT_LENGTH) return `Keep going. ${JEV_NAME} suggests details once there's a bit more to go on.`;
return null;
})();
const showChips = status !== "idle" || flow === "suggest-first";
const loading = status === "thinking" && !suggestion;
const assignee = JEV_AGENTS.find((agent) => agent.id === effective.assigneeId) ?? null;
const alternates = (suggestion?.alternateAssigneeIds ?? [])
.map((id) => JEV_AGENTS.find((agent) => agent.id === id))
.filter((agent): agent is (typeof JEV_AGENTS)[number] => Boolean(agent) && agent!.id !== effective.assigneeId);
return (
<div className="flex flex-col gap-2 px-4 pb-3">
{status === "error" ? (
<div role="status" className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<AlertTriangle aria-hidden className="size-3.5 shrink-0" />
<span className="flex-1">{JEV_NAME} couldn't classify this. Set the owner yourself, or try again.</span>
<Button variant="ghost" size="xs" onClick={onRetry}>Try again</Button>
</div>
) : statusLine ? (
<div role="status" className="flex items-center gap-2 text-xs text-muted-foreground">
<JevMark thinking={status === "thinking"} />
<span>{statusLine}</span>
</div>
) : null}
{showChips ? (
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<PropertyChip
label="Type"
loading={loading}
suggested={isSuggested("type")}
onReset={suggestion && !isSuggested("type") ? () => resetField("type") : undefined}
display={effective.type ? (
<><Tag aria-hidden className="size-3.5" />{JEV_TASK_TYPES.find((option) => option.value === effective.type)?.label}</>
) : <><Tag aria-hidden className="size-3.5" />Type</>}
>
{(close) => JEV_TASK_TYPES.map((option) => (
<MenuItem key={option.value} selected={option.value === effective.type} onClick={() => { setField("type", option.value); close(); }}>
<span className="flex flex-col">
<span>{option.label}</span>
<span className="text-xs text-muted-foreground">{option.hint}</span>
</span>
</MenuItem>
))}
</PropertyChip>
<PropertyChip
label="Assignee"
loading={loading}
suggested={isSuggested("assigneeId")}
onReset={suggestion && !isSuggested("assigneeId") ? () => resetField("assigneeId") : undefined}
display={assignee ? (
<><AgentAvatar agent={assignee} size={16} />{assignee.name}</>
) : <span>Assignee</span>}
>
{(close) => JEV_AGENTS.map((agent) => (
<MenuItem key={agent.id} selected={agent.id === effective.assigneeId} onClick={() => { setField("assigneeId", agent.id); close(); }}>
<AgentAvatar agent={agent} size={20} />
<span className="flex flex-col">
<span>{agent.name}</span>
<span className="text-xs text-muted-foreground">{agent.title}</span>
</span>
</MenuItem>
))}
</PropertyChip>
<PropertyChip
label="Project"
loading={loading}
suggested={isSuggested("projectId") && effective.projectId !== null}
onReset={suggestion && !isSuggested("projectId") ? () => resetField("projectId") : undefined}
display={<><FolderKanban aria-hidden className="size-3.5" />{JEV_PROJECTS.find((project) => project.id === effective.projectId)?.name ?? "No project"}</>}
>
{(close) => [
<MenuItem key="none" selected={effective.projectId === null} onClick={() => { setField("projectId", null); close(); }}>No project</MenuItem>,
...JEV_PROJECTS.map((project) => (
<MenuItem key={project.id} selected={project.id === effective.projectId} onClick={() => { setField("projectId", project.id); close(); }}>
{project.name}
</MenuItem>
)),
]}
</PropertyChip>
<WorkModeChip
loading={loading}
mode={effective.workMode}
suggested={isSuggested("workMode") && effective.workMode !== "standard"}
onChange={(mode) => setField("workMode", mode)}
/>
</div>
) : null}
{status === "ready" && suggestion?.confidence === "low" && alternates.length > 0 && isSuggested("assigneeId") ? (
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="text-muted-foreground">Or:</span>
{alternates.map((agent) => (
<Button key={agent.id} variant="outline" size="xs" onClick={() => setField("assigneeId", agent.id)}>
<AgentAvatar agent={agent} size={16} />{agent.name}
</Button>
))}
</div>
) : null}
{status === "ready" && suggestion ? (
<div className="text-xs text-muted-foreground">
<button type="button" onClick={onToggleWhy} aria-expanded={whyOpen} className="inline-flex items-center gap-1 hover:text-foreground">
<ChevronDown aria-hidden className={cn("size-3 transition-transform", !whyOpen && "-rotate-90")} />
Why {JEV_NAME} chose these
</button>
{whyOpen ? (
<ul className="mt-1.5 flex flex-col gap-1 pl-4">
{suggestion.reasons.map((reason) => <li key={reason.field}>{reason.text}</li>)}
<li>Anything you change stays as you set it.</li>
</ul>
) : null}
</div>
) : null}
</div>
);
}
function PropertyChip({
label,
display,
loading,
suggested,
onReset,
children,
}: {
label: string;
display: ReactNode;
loading: boolean;
suggested: boolean;
onReset?: () => void;
children: (close: () => void) => ReactNode;
}) {
const [open, setOpen] = useState(false);
if (loading) {
return <span aria-label={`${label} loading`} className="h-7 w-24 animate-pulse rounded-md border border-border bg-muted/60" />;
}
return (
<span className="inline-flex items-center">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={`${label}${suggested ? ` (suggested by ${JEV_NAME})` : ""}`}
className={cn(
"inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-sm transition-colors hover:bg-accent hover:text-foreground",
suggested ? "border-primary/40 bg-primary/5 text-foreground" : "border-border text-foreground",
)}
>
{suggested ? <Sparkles aria-hidden className="size-3 text-primary" /> : null}
{display}
<ChevronDown aria-hidden className="size-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
{children(() => setOpen(false))}
</PopoverContent>
</Popover>
{onReset ? (
<Button variant="ghost" size="icon-xs" className="text-muted-foreground" onClick={onReset} title={`Use ${JEV_NAME}'s suggestion`}>
<Undo2 />
</Button>
) : null}
</span>
);
}
function MenuItem({ selected, onClick, children }: { selected: boolean; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className={cn("flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent", selected && "bg-accent")}
>
{children}
{selected ? <Check aria-hidden className="ml-auto size-3.5 shrink-0" /> : null}
</button>
);
}
function WorkModeChip({
mode,
loading,
suggested,
onChange,
}: {
mode: IssueWorkMode;
loading: boolean;
suggested: boolean;
onChange: (mode: IssueWorkMode) => void;
}) {
const [open, setOpen] = useState(false);
const meta = workModeMetaFor(mode);
const Icon = meta.icon;
if (loading) return <span aria-label="Mode loading" className="h-7 w-20 animate-pulse rounded-md border border-border bg-muted/60" />;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={`Mode${suggested ? ` (suggested by ${JEV_NAME})` : ""}`}
className={cn("inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-sm transition-colors", meta.classes.chip)}
>
{suggested ? <Sparkles aria-hidden className="size-3" /> : null}
<Icon aria-hidden className="size-3.5" />
{meta.label}
<ChevronDown aria-hidden className="size-3 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-48 p-1">
{workModeMetaList().map((option) => {
const OptionIcon = option.icon;
return (
<MenuItem key={option.value} selected={option.value === mode} onClick={() => { onChange(option.value); setOpen(false); }}>
<OptionIcon aria-hidden className={cn("size-3.5", option.classes.menuItem)} />
{option.label}
</MenuItem>
);
})}
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,55 @@
# Task creation with Jev
Open **UX Labs → Task creation with Jev** in Storybook:
```sh
pnpm --filter @paperclipai/ui exec storybook dev --port 6010 --host 127.0.0.1 --no-open -c storybook/.storybook
```
## The idea
Today's `NewIssueDialog` asks for a title first, then a description, then
assignee, project, and mode. This prototype starts from the prompt instead. You
describe the work and Jev fills in the rest, the way Claude Code names a session
after you start it.
- **Title** appears in the header breadcrumb (`PAP › Fix login redirect loop…`)
and types in when Jev suggests it. Click it to edit.
- **Type** (Bug, Feature, Research, Design, QA, Ops), **assignee**, **project**,
and **work mode** appear as chips under the prompt.
- Jev's values carry a sparkle. When you change a value, the sparkle goes away
and Jev stops changing it. The undo icon next to a chip restores Jev's value.
- **Why Jev chose these** explains each decision in one line.
- When Jev isn't sure who should own the task, it names alternates as one-click
buttons.
- Large scope (plan, migrate, redesign) suggests Plan mode. Questions route to
Ask mode.
- If Jev fails, the chips work as plain manual pickers and **Try again** retries.
- Creating never waits for Jev. If you create while Jev is thinking, the task is
created and Jev finishes after.
## Two flows to compare
- **Suggest-first** (stories 01–11): Jev re-reads the prompt after each typing
pause and updates the suggestions before you create the task.
- **Instant** (stories 12–13): closest to Claude Code. **Start task** creates
`PAP-412` as "Untitled task" right away. The title and routing arrive a moment
later.
## What's fake
`jev-classifier.ts` is a keyword heuristic with simulated latency. It uses
fixture agents that match the Storybook company (CodexCoder, DesignSystemCoder,
QAChecker, CTO, Darnold) and three projects. No model or API is called and no task
is persisted. To make it real, replace `classifyTaskPrompt` with a server call
that returns the same `JevSuggestion` shape. That call would need company-scoped
agent and project context, and would have to respect paused agents and budgets.
## Open questions
- Should Jev assign to an agent immediately (the instant flow), or only suggest
while the task stays in draft?
- Does task **type** need to be a real field? Today `IssueWorkMode` and labels
cover part of it.
- Should Jev's reasoning appear on the created task, as an activity entry, so the
board can audit routing decisions?
@@ -0,0 +1,196 @@
import type { IssueWorkMode } from "@paperclipai/shared";
/**
* Storybook-only stand-in for Jev, the assistant that reads a task prompt and
* suggests a title, task type, owner, project, and work mode. It is a keyword
* heuristic with simulated latency so the UX can be reviewed without a model.
* A real implementation would replace `classifyTaskPrompt` with an API call
* that returns the same `JevSuggestion` shape.
*/
export const JEV_NAME = "Jev";
export type JevTaskType = "bug" | "feature" | "research" | "design" | "qa" | "ops";
export const JEV_TASK_TYPES: { value: JevTaskType; label: string; hint: string }[] = [
{ value: "bug", label: "Bug", hint: "Something is broken or regressed" },
{ value: "feature", label: "Feature", hint: "Build or change product behavior" },
{ value: "research", label: "Research", hint: "Investigate, compare, or answer a question" },
{ value: "design", label: "Design", hint: "UI, UX, or visual work" },
{ value: "qa", label: "QA", hint: "Verify, test, or reproduce" },
{ value: "ops", label: "Ops", hint: "Admin, release, or coordination work" },
];
export type JevAgent = {
id: string;
name: string;
role: string;
title: string;
paused?: boolean;
};
export const JEV_AGENTS: JevAgent[] = [
{ id: "agent-codex", name: "CodexCoder", role: "engineer", title: "Senior Product Engineer" },
{ id: "agent-design-system", name: "DesignSystemCoder", role: "designer", title: "Design System Engineer" },
{ id: "agent-qa", name: "QAChecker", role: "qa", title: "QA Engineer" },
{ id: "agent-cto", name: "CTO", role: "cto", title: "CTO" },
{ id: "agent-darnold", name: "Darnold", role: "general", title: "Chief of Staff" },
];
export type JevProject = { id: string; name: string };
export const JEV_PROJECTS: JevProject[] = [
{ id: "project-board-ui", name: "Board UI" },
{ id: "project-agent-runtime", name: "Agent Runtime" },
{ id: "project-budget-guardrails", name: "Budget Guardrails" },
];
export type JevConfidence = "high" | "medium" | "low";
export type JevSuggestion = {
title: string;
type: JevTaskType;
assigneeId: string;
/** Other plausible owners, shown when confidence is not high. */
alternateAssigneeIds: string[];
projectId: string | null;
workMode: IssueWorkMode;
confidence: JevConfidence;
/** One short sentence per decision, shown under "Why". */
reasons: { field: "type" | "assignee" | "project" | "mode"; text: string }[];
};
/** Prompts need a little substance before Jev starts guessing. */
export const JEV_MIN_PROMPT_LENGTH = 18;
const TYPE_KEYWORDS: Record<JevTaskType, string[]> = {
bug: ["bug", "broken", "fix", "crash", "error", "regress", "fails", "failing", "500", "stuck", "doesn't", "does not", "not working", "wrong"],
feature: ["add", "build", "implement", "support", "create", "new", "allow", "enable", "endpoint", "integrate", "make", "improve", "faster", "performance"],
research: ["research", "investigate", "compare", "evaluate", "why", "how does", "figure out", "explore", "options", "should we"],
design: ["design", "mockup", "figma", "layout", "visual", "ux", "ui polish", "spacing", "typography", "color", "icon", "prototype"],
qa: ["test", "qa", "verify", "reproduce", "regression suite", "smoke", "e2e", "check that", "coverage"],
ops: ["release", "deploy", "rotate", "invoice", "schedule", "onboard", "hire", "budget report", "coordinate", "follow up", "email"],
};
const TYPE_OWNER: Record<JevTaskType, string> = {
bug: "agent-codex",
feature: "agent-codex",
research: "agent-cto",
design: "agent-design-system",
qa: "agent-qa",
ops: "agent-darnold",
};
const TYPE_OWNER_REASON: Record<JevTaskType, string> = {
bug: "CodexCoder owns product engineering and closed 3 similar bugs this week.",
feature: "CodexCoder owns product engineering and has capacity today.",
research: "The CTO handles open-ended technical questions.",
design: "DesignSystemCoder owns UI and design-system work.",
qa: "QAChecker runs verification and reproduction tasks.",
ops: "Darnold, the Chief of Staff, handles coordination and admin work.",
};
const PROJECT_KEYWORDS: Record<string, string[]> = {
"project-board-ui": ["ui", "board", "dashboard", "page", "button", "dialog", "sidebar", "inbox", "screen", "mobile", "login", "storybook"],
"project-agent-runtime": ["agent", "runtime", "heartbeat", "adapter", "wake", "codex", "claude", "runner", "session"],
"project-budget-guardrails": ["budget", "spend", "cost", "billing", "limit", "invoice", "quota"],
};
/** Hand-written titles for the story scenarios, so reviews read realistically. */
const CURATED_TITLES: Record<string, string> = {
"the login page redirects": "Fix login redirect loop after session expiry",
"add a csv export": "Add CSV export to the issues list",
"should we move": "Evaluate moving heartbeats to a queue",
"the new task dialog feels": "Tighten spacing in the new task dialog",
"before friday's release": "Run release smoke tests before Friday",
"something is off": "Investigate agent budget discrepancy",
"rotate the": "Rotate the GitHub App private key",
"plan the migration": "Plan phased migration of runtime sessions to the new adapter",
};
function score(prompt: string, keywords: string[]): number {
return keywords.reduce((total, keyword) => total + (prompt.includes(keyword) ? 1 : 0), 0);
}
const FILLER = /^(hey|hi|please|pls|so|ok|okay|can you|could you|would you|i need you to|i need to|i want to|we need to|we should|let's|lets|i think|it seems like|it looks like)\b[\s,]*/i;
/** Turn free-form text into a short, imperative-ish title. */
export function draftTitle(prompt: string): string {
const normalized = prompt.trim().toLowerCase();
for (const [prefix, title] of Object.entries(CURATED_TITLES)) {
if (normalized.startsWith(prefix)) return title;
}
let text = prompt.trim().split(/(?<=[.!?])\s|\n/)[0] ?? prompt.trim();
let previous = "";
while (previous !== text) {
previous = text;
text = text.replace(FILLER, "");
}
text = text.replace(/[.!?]+$/, "").replace(/\s+/g, " ");
if (text.length > 60) {
const cut = text.slice(0, 60);
text = cut.slice(0, cut.lastIndexOf(" ") > 30 ? cut.lastIndexOf(" ") : 60);
}
return text.charAt(0).toUpperCase() + text.slice(1);
}
export function classifyTaskPromptSync(prompt: string): JevSuggestion {
const text = prompt.toLowerCase();
const typeScores = (Object.keys(TYPE_KEYWORDS) as JevTaskType[])
.map((type) => ({ type, score: score(text, TYPE_KEYWORDS[type]) }))
.sort((a, b) => b.score - a.score);
const best = typeScores[0]!;
const runnerUp = typeScores[1]!;
const type: JevTaskType = best.score === 0 ? "research" : best.type;
const lead = best.score - runnerUp.score;
const confidence: JevConfidence =
best.score === 0 || lead === 0 ? "low" : lead >= 2 || runnerUp.score === 0 ? "high" : "medium";
const projectScores = Object.entries(PROJECT_KEYWORDS)
.map(([id, keywords]) => ({ id, score: score(text, keywords) }))
.sort((a, b) => b.score - a.score);
const projectId = projectScores[0]!.score > 0 ? projectScores[0]!.id : null;
const isQuestion = /\?\s*$/.test(prompt.trim()) || (/^(how|why|what|which|should|is|are|can|does)\b/.test(text) && !/^(can|could|would) you\b/.test(text));
const isLarge = /\b(plan|roadmap|migrate|migration|redesign|rewrite|overhaul|multi-step|phases?)\b/.test(text);
const workMode: IssueWorkMode = isQuestion && type === "research" ? "ask" : isLarge ? "planning" : "standard";
const assigneeId = TYPE_OWNER[type];
const alternateAssigneeIds = confidence === "high"
? []
: [TYPE_OWNER[runnerUp.type], "agent-cto"].filter((id, index, ids) => id !== assigneeId && ids.indexOf(id) === index).slice(0, 2);
const typeLabel = JEV_TASK_TYPES.find((option) => option.value === type)!.label;
const typeNoun = type === "qa" ? "QA" : typeLabel.toLowerCase();
const projectName = JEV_PROJECTS.find((project) => project.id === projectId)?.name;
const reasons: JevSuggestion["reasons"] = [
{
field: "type",
text: best.score === 0
? `No strong signal, so ${JEV_NAME} treated it as ${typeNoun} until you say otherwise.`
: `Reads as ${typeNoun} work${runnerUp.score > 0 && confidence !== "high" ? `, though it could be ${runnerUp.type}` : ""}.`,
},
{ field: "assignee", text: TYPE_OWNER_REASON[type] },
];
if (projectName) reasons.push({ field: "project", text: `Mentions areas covered by ${projectName}.` });
if (workMode === "planning") reasons.push({ field: "mode", text: "Large scope, so it starts in Plan mode for your review." });
if (workMode === "ask") reasons.push({ field: "mode", text: "It's a question, so Ask mode returns an answer without changing code." });
return { title: draftTitle(prompt), type, assigneeId, alternateAssigneeIds, projectId, workMode, confidence, reasons };
}
export type ClassifyOptions = { latencyMs?: number; fail?: boolean; signal?: AbortSignal };
export function classifyTaskPrompt(prompt: string, { latencyMs = 900, fail = false, signal }: ClassifyOptions = {}): Promise<JevSuggestion> {
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
if (fail) reject(new Error(`${JEV_NAME} is unavailable right now.`));
else resolve(classifyTaskPromptSync(prompt));
}, latencyMs);
signal?.addEventListener("abort", () => {
window.clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
});
});
}
@@ -0,0 +1,91 @@
import { userEvent, within } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { JevTaskComposer } from "../prototypes/jev-task-creation/JevTaskComposer";
const PROMPTS = {
bug: "The login page redirects back to itself after the session expires. Users get stuck in a loop and can't sign in until they clear cookies.",
feature: "Add a CSV export to the issues list so the board can pull tasks into a spreadsheet for the weekly review.",
research: "Should we move heartbeat scheduling onto a queue? How would that change wake latency for agents?",
design: "The new task dialog feels cramped on mobile. The spacing and typography around the property chips need a design pass.",
qa: "Before Friday's release, run the smoke tests and verify that invite links still work on authenticated private mode.",
ambiguous: "Something is off with the numbers for last month, can you take a look",
planning: "Plan the migration of agent runtime sessions to the new adapter, in phases, so nothing breaks mid-run.",
};
const meta = {
title: "UX Labs/Task creation with Jev",
component: JevTaskComposer,
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"Prototype: prompt-first task creation. You write what you need; Jev suggests a title, task type, assignee, project, and work mode. " +
"Suggested values carry a sparkle. Anything you change is yours and Jev never overwrites it; the undo icon restores Jev's value. " +
"Low-confidence routing offers alternate owners. **Instant** stories mirror Claude Code sessions: create first, and the title and routing arrive a moment later. " +
"Jev is a local keyword heuristic with simulated latency (`prototypes/jev-task-creation/jev-classifier.ts`). No model or API is called and no task is persisted.",
},
},
},
args: { flow: "suggest-first", latencyMs: 900 },
argTypes: {
flow: { control: "inline-radio", options: ["suggest-first", "instant"] },
latencyMs: { control: { type: "range", min: 100, max: 4000, step: 100 } },
},
render: (args) => <JevTaskComposer key={JSON.stringify(args)} {...args} />,
} satisfies Meta<typeof JevTaskComposer>;
export default meta;
type Story = StoryObj<typeof meta>;
export const StartHere: Story = { name: "01 · Start here · Empty composer" };
export const WatchItSuggest: Story = {
name: "02 · Watch Jev suggest while you type",
args: { initialPrompt: PROMPTS.bug, typeOnMount: true },
};
export const Bug: Story = { name: "03 · Bug → CodexCoder", args: { initialPrompt: PROMPTS.bug } };
export const Feature: Story = { name: "04 · Feature → CodexCoder", args: { initialPrompt: PROMPTS.feature } };
export const Research: Story = { name: "05 · Question → CTO in Ask mode", args: { initialPrompt: PROMPTS.research } };
export const Design: Story = { name: "06 · Design → DesignSystemCoder", args: { initialPrompt: PROMPTS.design } };
export const QA: Story = { name: "07 · QA → QAChecker", args: { initialPrompt: PROMPTS.qa } };
export const LargeScope: Story = { name: "08 · Large scope → Plan mode", args: { initialPrompt: PROMPTS.planning } };
export const LowConfidence: Story = {
name: "09 · Unsure · Jev offers alternate owners",
args: { initialPrompt: PROMPTS.ambiguous },
};
export const UserOverride: Story = {
name: "10 · You chose the assignee · Jev keeps it",
args: { initialPrompt: PROMPTS.bug, presetAssigneeId: "agent-qa" },
};
export const WhyExpanded: Story = {
name: "11 · Why Jev chose these",
args: { initialPrompt: PROMPTS.feature, latencyMs: 200 },
play: async ({ canvasElement }) => {
const toggle = await within(canvasElement).findByRole("button", { name: /Why Jev chose these/ }, { timeout: 3000 });
await userEvent.click(toggle);
},
};
export const InstantEmpty: Story = {
name: "12 · Instant · Start first, Jev names it after",
args: { flow: "instant" },
};
export const InstantCreated: Story = {
name: "13 · Instant · Title and owner arrive",
args: { flow: "instant", initialPrompt: PROMPTS.feature, latencyMs: 1600 },
play: async ({ canvasElement }) => {
await userEvent.click(await within(canvasElement).findByRole("button", { name: /Start task/ }));
},
};
export const JevUnavailable: Story = {
name: "14 · Jev unavailable · Manual fallback",
args: { initialPrompt: PROMPTS.bug, jevUnavailable: true },
};
export const SlowJev: Story = {
name: "15 · Slow Jev · Loading state",
args: { initialPrompt: PROMPTS.design, latencyMs: 60000 },
};
export const Light: Story = { name: "16 · Light theme", args: { initialPrompt: PROMPTS.bug }, globals: { theme: "light" } };
export const Mobile: Story = {
name: "17 · Mobile",
args: { initialPrompt: PROMPTS.design, mobile: true },
globals: { viewport: { value: "mobile1", isRotated: false } },
};