mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
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:
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">›</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 } },
|
||||
};
|
||||
Reference in new issue
Block a user