storybook: model Jev task routing on the OpenRouter decisions API

Jev (typesafe/jev-1.13) answers typed choice questions with probabilities
and does not generate text. Split suggestions accordingly: Jev routes type,
assignee, project, and work mode; a separate text model drafts the title.
The prototype now builds the real decisions request, simulates responses in
the documented shape, shows per-option probabilities in chip menus, and
replaces the invented reasons with Jev's confidence and token cost.

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:33:26 +00:00
1 parent fb0ce05f7a
commit fad0f7007a
4 files changed
+564 -310

No files matched your search

@@ -9,41 +9,50 @@ import { cn } from "@/lib/utils";
import { workModeMetaFor, workModeMetaList } from "@/lib/work-mode-meta";
import {
JEV_AGENTS,
JEV_CONFIDENCE_THRESHOLD,
JEV_MIN_PROMPT_LENGTH,
JEV_MODEL,
JEV_NAME,
JEV_NO_PROJECT,
JEV_PROJECTS,
JEV_TASK_TYPES,
classifyTaskPrompt,
type JevSuggestion,
buildJevDecisionRequest,
draftTaskTitle,
routeTaskWithJev,
type JevField,
type JevRouting,
type JevTaskType,
} from "./jev-classifier";
export type JevTaskComposerProps = {
/**
* `suggest-first`: Jev fills title and properties while you type; you review and create.
* `suggest-first`: Jev routes and a title is drafted 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.
* "Untitled" and is named and routed a moment later.
*/
flow?: "suggest-first" | "instant";
initialPrompt?: string;
/** Simulated Jev latency in milliseconds. */
/** Simulated Jev decision latency. Jev is a fast decision model. */
latencyMs?: number;
/** Simulate Jev being unavailable; the composer falls back to manual fields. */
/** Simulated latency of the separate text model that drafts the title. */
titleLatencyMs?: number;
/** Simulate Jev being unavailable; routing falls back to manual fields. */
jevUnavailable?: boolean;
/** Pre-set fields as if the user already chose them. Jev never overwrites these. */
/** Pre-set the assignee as if the user already chose it. Jev never overwrites it. */
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 };
type Overrides = Partial<{ title: string; type: JevTaskType; assignee: string; project: string | null; workMode: IssueWorkMode }>;
type OverrideField = keyof Overrides;
type Status = "idle" | "thinking" | "ready" | "error";
const TYPING_DEBOUNCE_MS = 650;
const percent = (value: number | undefined) => (value === undefined ? "" : `${Math.round(value * 100)}%`);
/** 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("");
@@ -68,23 +77,27 @@ function useTypewriter(target: string | null) {
export function JevTaskComposer({
flow = "suggest-first",
initialPrompt = "",
latencyMs = 900,
latencyMs = 350,
titleLatencyMs = 1200,
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 [routingStatus, setRoutingStatus] = useState<Status>("idle");
const [routing, setRouting] = useState<JevRouting | null>(null);
const [titleStatus, setTitleStatus] = useState<Status>("idle");
const [suggestedTitle, setSuggestedTitle] = useState<string | null>(null);
const [overrides, setOverrides] = useState<Overrides>(presetAssigneeId ? { assignee: presetAssigneeId } : {});
const [editingTitle, setEditingTitle] = useState(false);
const [created, setCreated] = useState<CreatedTask | null>(null);
const [whyOpen, setWhyOpen] = useState(false);
const [createdIdentifier, setCreatedIdentifier] = useState<string | null>(null);
const [createdPrompt, setCreatedPrompt] = useState("");
const [detailsOpen, setDetailsOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const lastClassifiedRef = useRef("");
const lastRequestedRef = useRef("");
// Autoplay: type the scenario prompt so reviewers see Jev react mid-sentence.
// Autoplay: type the scenario prompt so reviewers see suggestions react mid-sentence.
useEffect(() => {
if (!typeOnMount || !initialPrompt) return;
let index = 0;
@@ -96,52 +109,56 @@ export function JevTaskComposer({
return () => window.clearInterval(timer);
}, [typeOnMount, initialPrompt]);
const runJev = useCallback((text: string) => {
const suggest = 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]);
lastRequestedRef.current = text;
const ignoreAbort = (error: unknown) => error instanceof DOMException && error.name === "AbortError";
// Suggest-first: re-classify after the user pauses typing.
setRoutingStatus("thinking");
routeTaskWithJev(text, { latencyMs, fail: jevUnavailable, signal: controller.signal })
.then((result) => { setRouting(result); setRoutingStatus("ready"); })
.catch((error: unknown) => { if (!ignoreAbort(error)) setRoutingStatus("error"); });
setTitleStatus("thinking");
draftTaskTitle(text, { latencyMs: titleLatencyMs, signal: controller.signal })
.then((title) => { setSuggestedTitle(title); setTitleStatus("ready"); })
.catch((error: unknown) => { if (!ignoreAbort(error)) setTitleStatus("error"); });
}, [latencyMs, titleLatencyMs, jevUnavailable]);
// Suggest-first: re-run after the user pauses typing.
useEffect(() => {
if (flow !== "suggest-first" || created) return;
if (flow !== "suggest-first" || createdIdentifier) return;
const text = prompt.trim();
if (text.length < JEV_MIN_PROMPT_LENGTH) {
abortRef.current?.abort();
setStatus("idle");
setSuggestion(null);
lastRequestedRef.current = "";
setRoutingStatus("idle");
setTitleStatus("idle");
setRouting(null);
setSuggestedTitle(null);
return;
}
if (text === lastClassifiedRef.current) return;
const timer = window.setTimeout(() => runJev(text), TYPING_DEBOUNCE_MS);
if (text === lastRequestedRef.current) return;
const timer = window.setTimeout(() => suggest(text), TYPING_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [prompt, flow, created, runJev]);
}, [prompt, flow, createdIdentifier, suggest]);
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]);
title: overrides.title ?? suggestedTitle ?? "",
type: overrides.type ?? routing?.type ?? null,
assignee: overrides.assignee ?? routing?.assigneeId ?? null,
project: overrides.project !== undefined ? overrides.project : routing?.projectId ?? null,
workMode: overrides.workMode ?? routing?.workMode ?? "standard",
}), [overrides, routing, suggestedTitle]);
const isSuggested = (field: Field) => suggestion !== null && overrides[field] === undefined;
const setField = <K extends keyof Overrides>(field: K, value: Overrides[K]) =>
const fromJev = (field: Exclude<OverrideField, "title">) => routing !== null && overrides[field] === undefined;
const setField = <K extends OverrideField>(field: K, value: Overrides[K]) =>
setOverrides((current) => ({ ...current, [field]: value }));
const resetField = (field: Field) =>
const resetField = (field: OverrideField) =>
setOverrides((current) => {
const next = { ...current };
delete next[field];
@@ -151,23 +168,29 @@ export function JevTaskComposer({
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 text = prompt.trim();
setCreatedIdentifier("PAP-412");
setCreatedPrompt(text);
// Creating never waits. Anything not yet suggested for this exact prompt runs now.
if (lastRequestedRef.current !== text || routingStatus === "idle") suggest(text);
};
const startOver = () => {
abortRef.current?.abort();
lastClassifiedRef.current = "";
lastRequestedRef.current = "";
setPrompt("");
setSuggestion(null);
setRouting(null);
setSuggestedTitle(null);
setOverrides({});
setStatus("idle");
setCreated(null);
setWhyOpen(false);
setRoutingStatus("idle");
setTitleStatus("idle");
setCreatedIdentifier(null);
setDetailsOpen(false);
};
const animatedTitle = useTypewriter(isSuggested("title") ? effective.title : null);
const headerTitle = isSuggested("title") ? animatedTitle : effective.title;
const titleSuggested = suggestedTitle !== null && overrides.title === undefined;
const animatedTitle = useTypewriter(titleSuggested ? suggestedTitle : null);
const created = createdIdentifier !== null;
const busy = routingStatus === "thinking" || titleStatus === "thinking";
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")}>
@@ -178,21 +201,23 @@ export function JevTaskComposer({
)}
>
<ComposerHeader
identifier={created?.identifier}
title={headerTitle}
thinking={status === "thinking" && !effective.title}
suggested={isSuggested("title")}
identifier={createdIdentifier ?? undefined}
title={titleSuggested ? animatedTitle : effective.title}
thinking={titleStatus === "thinking" && !effective.title}
suggested={titleSuggested}
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}
onReset={overrides.title !== undefined && suggestedTitle ? () => resetField("title") : undefined}
/>
{created ? (
<CreatedBody prompt={created.prompt} />
<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">{createdPrompt}</p>
</div>
) : (
<div className="flex flex-col gap-3 px-4 pt-4 pb-2">
<Textarea
@@ -207,26 +232,27 @@ export function JevTaskComposer({
}
}}
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.`}
? "What should get done? It gets a title and an owner once you start."
: "What should get done? A title, type, and owner are suggested 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
<RoutingPanel
flow={flow}
created={created !== null}
status={status}
created={created}
status={routingStatus}
promptLength={prompt.trim().length}
suggestion={suggestion}
prompt={createdPrompt || prompt.trim()}
routing={routing}
effective={effective}
isSuggested={isSuggested}
fromJev={fromJev}
setField={setField}
resetField={resetField}
whyOpen={whyOpen}
onToggleWhy={() => setWhyOpen((open) => !open)}
onRetry={() => runJev(prompt.trim())}
detailsOpen={detailsOpen}
onToggleDetails={() => setDetailsOpen((open) => !open)}
onRetry={() => suggest(prompt.trim() || createdPrompt)}
/>
<div className="mt-auto flex items-center justify-between gap-3 border-t border-border px-4 py-3">
@@ -236,7 +262,7 @@ export function JevTaskComposer({
<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."}
{busy ? "Naming and routing this task…" : routingStatus === "ready" ? "Task created and assigned." : "Task created."}
</span>
</>
) : (
@@ -245,8 +271,8 @@ export function JevTaskComposer({
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>
{flow === "suggest-first" && busy ? (
<span className="hidden text-xs text-muted-foreground sm:inline">You can create now; suggestions finish after.</span>
) : null}
<Button size="sm" onClick={create} disabled={!canCreate}>
{flow === "instant" ? "Start task" : "Create task"}
@@ -261,14 +287,13 @@ export function JevTaskComposer({
);
}
function JevMark({ thinking = false, className }: { thinking?: boolean; className?: string }) {
function JevMark({ thinking = false }: { thinking?: boolean }) {
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" />
@@ -320,22 +345,22 @@ function ComposerHeader({
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"}
title={suggested ? "Suggested title. Click to edit." : "Click to edit"}
>
{suggested ? <Sparkles aria-label={`Suggested by ${JEV_NAME}`} className="size-3.5 shrink-0 text-primary" /> : null}
{suggested ? <Sparkles aria-label="Suggested" 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 className="sr-only">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`}>
<Button variant="ghost" size="icon-xs" className="text-muted-foreground" onClick={onReset} title="Use the suggested title">
<Undo2 />
</Button>
) : null}
@@ -343,73 +368,68 @@ function ComposerHeader({
);
}
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;
assignee: string | null;
project: string | null;
workMode: IssueWorkMode;
};
function JevPanel({
function RoutingPanel({
flow,
created,
status,
promptLength,
suggestion,
prompt,
routing,
effective,
isSuggested,
fromJev,
setField,
resetField,
whyOpen,
onToggleWhy,
detailsOpen,
onToggleDetails,
onRetry,
}: {
flow: "suggest-first" | "instant";
created: boolean;
status: JevStatus;
status: Status;
promptLength: number;
suggestion: JevSuggestion | null;
prompt: string;
routing: JevRouting | 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;
fromJev: (field: Exclude<OverrideField, "title">) => boolean;
setField: <K extends OverrideField>(field: K, value: Overrides[K]) => void;
resetField: (field: OverrideField) => void;
detailsOpen: boolean;
onToggleDetails: () => void;
onRetry: () => void;
}) {
const unsureOwner = routing !== null && fromJev("assignee") && routing.confidence.assignee < JEV_CONFIDENCE_THRESHOLD;
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.`;
if (status === "thinking" && !routing) return created ? `${JEV_NAME} is routing this task…` : `${JEV_NAME} is reading your request…`;
if (unsureOwner) return `${JEV_NAME} is ${percent(routing!.confidence.assignee)} sure about the owner. Pick one:`;
if (status === "idle" && flow === "instant" && !created) return `${JEV_NAME} picks the type and owner after you start. You can change anything later.`;
if (status === "idle" && promptLength > 0 && promptLength < JEV_MIN_PROMPT_LENGTH) return "Keep going. Suggestions start 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 ?? [])
const loading = status === "thinking" && !routing;
const probabilities = routing?.probabilities;
const assignee = JEV_AGENTS.find((agent) => agent.id === effective.assignee) ?? null;
const alternates = (routing?.alternateAssigneeIds ?? [])
.map((id) => JEV_AGENTS.find((agent) => agent.id === id))
.filter((agent): agent is (typeof JEV_AGENTS)[number] => Boolean(agent) && agent!.id !== effective.assigneeId);
.filter((agent): agent is (typeof JEV_AGENTS)[number] => agent !== undefined && agent.id !== effective.assignee);
const resetFor = (field: Exclude<OverrideField, "title">) => (routing && !fromJev(field) ? () => resetField(field) : undefined);
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>
<span className="flex-1">{JEV_NAME} couldn't route this. Set the owner yourself, or try again.</span>
<Button variant="ghost" size="xs" onClick={onRetry}>Try again</Button>
</div>
) : statusLine ? (
@@ -424,14 +444,17 @@ function JevPanel({
<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</>}
suggested={fromJev("type")}
onReset={resetFor("type")}
display={<><Tag aria-hidden className="size-3.5" />{JEV_TASK_TYPES.find((option) => option.value === effective.type)?.label ?? "Type"}</>}
>
{(close) => JEV_TASK_TYPES.map((option) => (
<MenuItem key={option.value} selected={option.value === effective.type} onClick={() => { setField("type", option.value); close(); }}>
<MenuItem
key={option.value}
selected={option.value === effective.type}
probability={probabilities?.type[option.value]}
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>
@@ -443,14 +466,17 @@ function JevPanel({
<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>}
suggested={fromJev("assignee")}
onReset={resetFor("assignee")}
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(); }}>
<MenuItem
key={agent.id}
selected={agent.id === effective.assignee}
probability={probabilities?.assignee[agent.id]}
onClick={() => { setField("assignee", agent.id); close(); }}
>
<AgentAvatar agent={agent} size={20} />
<span className="flex flex-col">
<span>{agent.name}</span>
@@ -463,58 +489,117 @@ function JevPanel({
<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"}</>}
suggested={fromJev("project") && effective.project !== null}
onReset={resetFor("project")}
display={<><FolderKanban aria-hidden className="size-3.5" />{JEV_PROJECTS.find((project) => project.id === effective.project)?.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(); }}>
<MenuItem
key={project.id}
selected={project.id === effective.project}
probability={probabilities?.project[project.id]}
onClick={() => { setField("project", project.id); close(); }}
>
{project.name}
</MenuItem>
)),
<MenuItem
key="none"
selected={effective.project === null}
probability={probabilities?.project[JEV_NO_PROJECT]}
onClick={() => { setField("project", null); close(); }}
>
No project
</MenuItem>,
]}
</PropertyChip>
<WorkModeChip
loading={loading}
mode={effective.workMode}
suggested={isSuggested("workMode") && effective.workMode !== "standard"}
suggested={fromJev("workMode") && effective.workMode !== "standard"}
probabilities={probabilities?.workMode}
onChange={(mode) => setField("workMode", mode)}
/>
</div>
) : null}
{status === "ready" && suggestion?.confidence === "low" && alternates.length > 0 && isSuggested("assigneeId") ? (
{unsureOwner && alternates.length > 0 ? (
<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)}>
<Button key={agent.id} variant="outline" size="xs" onClick={() => setField("assignee", agent.id)}>
<AgentAvatar agent={agent} size={16} />{agent.name}
<span className="text-muted-foreground">{percent(probabilities?.assignee[agent.id])}</span>
</Button>
))}
</div>
) : null}
{status === "ready" && suggestion ? (
{routing ? (
<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 type="button" onClick={onToggleDetails} aria-expanded={detailsOpen} className="inline-flex items-center gap-1 hover:text-foreground">
<ChevronDown aria-hidden className={cn("size-3 transition-transform", !detailsOpen && "-rotate-90")} />
How sure {JEV_NAME} is
</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}
{detailsOpen ? <ConfidenceDetails routing={routing} prompt={prompt} /> : null}
</div>
) : null}
</div>
);
}
const FIELD_LABELS: Record<JevField, string> = { type: "Type", assignee: "Assignee", project: "Project", workMode: "Mode" };
function choiceLabel(field: JevField, routing: JevRouting): string {
if (field === "type") return JEV_TASK_TYPES.find((option) => option.value === routing.type)?.label ?? routing.type;
if (field === "assignee") return JEV_AGENTS.find((agent) => agent.id === routing.assigneeId)?.name ?? routing.assigneeId;
if (field === "project") return JEV_PROJECTS.find((project) => project.id === routing.projectId)?.name ?? "No project";
return workModeMetaFor(routing.workMode).label;
}
function ConfidenceDetails({ routing, prompt }: { routing: JevRouting; prompt: string }) {
const fields: JevField[] = ["type", "assignee", "project", "workMode"];
return (
<div className="mt-2 flex flex-col gap-2 pl-4">
<dl className="grid grid-cols-[auto_1fr_auto] items-center gap-x-3 gap-y-1.5">
{fields.map((field) => {
const confidence = routing.confidence[field];
return (
<div key={field} className="contents">
<dt>{FIELD_LABELS[field]}</dt>
<dd className="flex items-center gap-2 text-foreground">
<span className="truncate">{choiceLabel(field, routing)}</span>
<span className="h-1 w-16 shrink-0 overflow-hidden rounded-full bg-muted" aria-hidden>
<span
className={cn("block h-full rounded-full", confidence < JEV_CONFIDENCE_THRESHOLD ? "bg-muted-foreground" : "bg-primary")}
style={{ width: percent(confidence) }}
/>
</span>
</dd>
<dd className="tabular-nums">{percent(confidence)}</dd>
</div>
);
})}
</dl>
<p>
{JEV_MODEL} returns a choice and probabilities, not reasons. Open a chip to see every option's probability.
Anything you change stays as you set it.
</p>
<p className="tabular-nums">
{routing.usage.input_tokens} input tokens · ${routing.usage.cost.toFixed(6)} · output tokens are free
</p>
<details>
<summary className="cursor-pointer hover:text-foreground">Request sent to {JEV_NAME}</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded-md bg-muted p-2 font-mono text-xs text-foreground">
{`POST https://openrouter.ai/api/alpha/decisions\n${JSON.stringify(buildJevDecisionRequest(prompt), null, 2)}`}
</pre>
</details>
</div>
);
}
function PropertyChip({
label,
display,
@@ -542,8 +627,8 @@ function PropertyChip({
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",
"inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-sm text-foreground transition-colors hover:bg-accent",
suggested ? "border-primary/40 bg-primary/5" : "border-border",
)}
>
{suggested ? <Sparkles aria-hidden className="size-3 text-primary" /> : null}
@@ -551,7 +636,7 @@ function PropertyChip({
<ChevronDown aria-hidden className="size-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<PopoverContent align="start" className="w-72 p-1">
{children(() => setOpen(false))}
</PopoverContent>
</Popover>
@@ -564,7 +649,17 @@ function PropertyChip({
);
}
function MenuItem({ selected, onClick, children }: { selected: boolean; onClick: () => void; children: ReactNode }) {
function MenuItem({
selected,
probability,
onClick,
children,
}: {
selected: boolean;
probability?: number;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
@@ -572,7 +667,10 @@ function MenuItem({ selected, onClick, children }: { selected: boolean; 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}
<span className="ml-auto flex shrink-0 items-center gap-2">
{probability !== undefined ? <span className="text-xs tabular-nums text-muted-foreground">{percent(probability)}</span> : null}
{selected ? <Check aria-hidden className="size-3.5" /> : <span className="size-3.5" />}
</span>
</button>
);
}
@@ -581,11 +679,13 @@ function WorkModeChip({
mode,
loading,
suggested,
probabilities,
onChange,
}: {
mode: IssueWorkMode;
loading: boolean;
suggested: boolean;
probabilities?: Record<string, number>;
onChange: (mode: IssueWorkMode) => void;
}) {
const [open, setOpen] = useState(false);
@@ -606,11 +706,16 @@ function WorkModeChip({
<ChevronDown aria-hidden className="size-3 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-48 p-1">
<PopoverContent align="start" className="w-56 p-1">
{workModeMetaList().map((option) => {
const OptionIcon = option.icon;
return (
<MenuItem key={option.value} selected={option.value === mode} onClick={() => { onChange(option.value); setOpen(false); }}>
<MenuItem
key={option.value}
selected={option.value === mode}
probability={probabilities?.[option.value]}
onClick={() => { onChange(option.value); setOpen(false); }}
>
<OptionIcon aria-hidden className={cn("size-3.5", option.classes.menuItem)} />
{option.label}
</MenuItem>
@@ -620,4 +725,3 @@ function WorkModeChip({
</Popover>
);
}
@@ -10,46 +10,76 @@ pnpm --filter @paperclipai/ui exec storybook dev --port 6010 --host 127.0.0.1 --
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
describe the work and the rest is suggested, 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 models do the work, because they are good at different things:
- **[Jev](https://openrouter.ai/docs/guides/community/jev)** (`typesafe/jev-1.13`,
TypeSafe's decision model on OpenRouter) answers typed `choice` questions:
task type, assignee, project, and work mode. It returns a choice, a
confidence, and a probability for every option. It is fast and cheap: you pay
for input tokens only, and output tokens are free. It does not generate text or
explain its answers.
- **A small text model** drafts the title. Jev can't, because it doesn't write
text.
## What the prototype shows
- The **title** types into the header breadcrumb (`PAP › Fix login redirect
loop…`) when it arrives. Click it to edit.
- **Type**, **assignee**, **project**, and **mode** appear as chips under the
prompt, usually before the title, because Jev answers faster.
- Suggested values carry a sparkle. When you change a value, the sparkle goes
away and the value is never overwritten. The undo icon restores the suggestion.
- Every chip menu shows Jev's probability for each option.
- Below the confidence threshold (`JEV_CONFIDENCE_THRESHOLD`, 0.6), the composer
says how sure Jev is and offers the next likely owners as one-click buttons.
- **How sure Jev is** lists the confidence for each decision, the token cost, and
the exact request body sent to Jev.
- If Jev fails, the title still arrives, the chips work as manual pickers, and
**Try again** retries.
- Creating never waits. If you create while suggestions are pending, the task is
created and they finish 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.
- **Suggest-first** (stories 01–11b): suggestions update after each typing pause,
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.
`PAP-412` as "Untitled task" right away. Routing and then the title arrive a
moment later.
## What's fake
## What's real and what's simulated
`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.
`jev-classifier.ts` builds the real Decisions API request body
(`buildJevDecisionRequest`): the prompt, the company's agents, and its projects go
in `state`, with four `choice` questions. The request and response types follow
the OpenRouter docs.
The response is simulated (`simulateJevDecisions`): keyword signals go through a
softmax to produce probabilities in Jev's response shape. The title is simulated
too (`draftTitle`). No API is called and no task is persisted.
## Going live
1. Add a company-scoped server route, for example
`POST /api/companies/:companyId/issues/suggest`. It builds the request from
the company's active agents and projects, leaving out paused agents and agents
over budget. It calls `POST https://openrouter.ai/api/alpha/decisions` with the
instance's OpenRouter key, and a small chat model for the title, in parallel.
Keep the key on the server.
2. Return a `JevRouting`-shaped payload, so this UI doesn't change.
3. Record the chosen values and Jev's confidences on the created task's activity
entry, so the board can audit routing decisions.
4. Label 100–200 real tasks and tune the confidence threshold against them, as the
OpenRouter classification cookbook recommends.
## Open questions
- Should Jev assign to an agent immediately (the instant flow), or only suggest
while the task stays in draft?
- Should a task auto-assign in the instant flow even when Jev's confidence is
low, or wait for the user to pick?
- 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?
- Should Jev's criteria for each agent come from agent instructions or role
metadata, rather than a hand-written "owns" line?
@@ -1,14 +1,52 @@
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.
* Storybook-only model of task routing with Jev, TypeSafe's decision model on
* OpenRouter (https://openrouter.ai/docs/guides/community/jev).
*
* Jev answers typed questions about application state with probabilities; it
* does not generate text or explain itself. So routing is split in two:
*
* - Jev (`POST /api/alpha/decisions`, model `typesafe/jev-1.13`) answers `choice`
* questions for task type, assignee, project, and work mode.
* - A small text model drafts the title, the way Claude Code names a session.
*
* `buildJevDecisionRequest` produces the real request body. The response is
* simulated locally from keyword signals, in the documented response shape, so
* the UX can be reviewed without an API key. Swap `simulateJevDecisions` for a
* server call to go live; never call OpenRouter from the browser with a key.
*/
export const JEV_NAME = "Jev";
export const JEV_MODEL = "typesafe/jev-1.13";
/** Below this confidence, the UI asks the user to pick between likely owners. */
export const JEV_CONFIDENCE_THRESHOLD = 0.6;
/** Prompts need a little substance before routing starts. */
export const JEV_MIN_PROMPT_LENGTH = 18;
// ---------------------------------------------------------------------------
// Decisions API shapes (from the OpenRouter Jev docs)
export type JevChoiceQuestion = { type: "choice"; instructions: string; criteria: Record<string, string> };
export type JevNoulQuestion = { type: "noul"; instructions: string; criteria?: { true: string; false: string } };
export type JevScoreQuestion = { type: "score"; instructions: string; criteria: string[] };
export type JevQuestion = JevChoiceQuestion | JevNoulQuestion | JevScoreQuestion;
export type JevDecisionRequest = {
model: string;
state: Record<string, unknown>;
questions: Record<string, JevQuestion>;
};
export type JevChoiceAnswer = { type: "choice"; choice: string; confidence: number; probabilities: Record<string, number> };
export type JevDecisionResponse = {
model: string;
answers: Record<string, JevChoiceAnswer>;
usage: { input_tokens: number; output_tokens: number; cost: number };
};
// ---------------------------------------------------------------------------
// Company fixtures (match the Storybook company)
export type JevTaskType = "bug" | "feature" | "research" | "design" | "qa" | "ops";
@@ -21,47 +59,71 @@ export const JEV_TASK_TYPES: { value: JevTaskType; label: string; hint: string }
{ value: "ops", label: "Ops", hint: "Admin, release, or coordination work" },
];
export type JevAgent = {
id: string;
name: string;
role: string;
title: string;
paused?: boolean;
};
export type JevAgent = { id: string; name: string; role: string; title: string; owns: string };
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" },
{ id: "agent-codex", name: "CodexCoder", role: "engineer", title: "Senior Product Engineer", owns: "Product engineering: bugs and features in the app and API." },
{ id: "agent-design-system", name: "DesignSystemCoder", role: "designer", title: "Design System Engineer", owns: "UI, UX, visual polish, and the design system." },
{ id: "agent-qa", name: "QAChecker", role: "qa", title: "QA Engineer", owns: "Verification, reproduction, and release testing." },
{ id: "agent-cto", name: "CTO", role: "cto", title: "CTO", owns: "Architecture decisions and open-ended technical questions." },
{ id: "agent-darnold", name: "Darnold", role: "general", title: "Chief of Staff", owns: "Coordination, admin, and operational follow-ups." },
];
export type JevProject = { id: string; name: string };
export type JevProject = { id: string; name: string; description: 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" },
{ id: "project-board-ui", name: "Board UI", description: "The operator-facing web app: pages, dialogs, inbox, mobile." },
{ id: "project-agent-runtime", name: "Agent Runtime", description: "Adapters, heartbeats, runners, and agent sessions." },
{ id: "project-budget-guardrails", name: "Budget Guardrails", description: "Spend limits, cost tracking, and billing." },
];
export type JevConfidence = "high" | "medium" | "low";
export const JEV_NO_PROJECT = "none";
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 }[];
};
// ---------------------------------------------------------------------------
// Request builder: this is what a server would send to OpenRouter.
/** Prompts need a little substance before Jev starts guessing. */
export const JEV_MIN_PROMPT_LENGTH = 18;
export function buildJevDecisionRequest(prompt: string): JevDecisionRequest {
return {
model: JEV_MODEL,
state: {
task_prompt: prompt,
agents: JEV_AGENTS.map(({ id, name, title, owns }) => ({ id, name, title, owns })),
projects: JEV_PROJECTS.map(({ id, name, description }) => ({ id, name, description })),
},
questions: {
task_type: {
type: "choice",
instructions: "What kind of work does the task prompt describe?",
criteria: Object.fromEntries(JEV_TASK_TYPES.map((option) => [option.value, option.hint])),
},
assignee: {
type: "choice",
instructions: "Which agent should own this task?",
criteria: Object.fromEntries(JEV_AGENTS.map((agent) => [agent.id, `${agent.name}, ${agent.title}. ${agent.owns}`])),
},
project: {
type: "choice",
instructions: "Which project does this task belong to?",
criteria: {
...Object.fromEntries(JEV_PROJECTS.map((project) => [project.id, project.description])),
[JEV_NO_PROJECT]: "None of the projects fit.",
},
},
work_mode: {
type: "choice",
instructions: "How should the assignee approach this task?",
criteria: {
standard: "Do the work directly.",
planning: "Large or risky scope: write a plan for review before doing the work.",
ask: "A question: answer it without changing anything.",
},
},
},
};
}
// ---------------------------------------------------------------------------
// Local simulation of Jev's answers
const TYPE_KEYWORDS: Record<JevTaskType, string[]> = {
bug: ["bug", "broken", "fix", "crash", "error", "regress", "fails", "failing", "500", "stuck", "doesn't", "does not", "not working", "wrong"],
@@ -72,6 +134,7 @@ const TYPE_KEYWORDS: Record<JevTaskType, string[]> = {
ops: ["release", "deploy", "rotate", "invoice", "schedule", "onboard", "hire", "budget report", "coordinate", "follow up", "email"],
};
/** Which agent usually owns each type. Assignee probabilities follow type probabilities. */
const TYPE_OWNER: Record<JevTaskType, string> = {
bug: "agent-codex",
feature: "agent-codex",
@@ -81,40 +144,144 @@ const TYPE_OWNER: Record<JevTaskType, string> = {
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. */
function score(text: string, keywords: string[]): number {
return keywords.reduce((total, keyword) => total + (text.includes(keyword) ? 1 : 0), 0);
}
function softmax(scores: Record<string, number>, sharpness = 2.2): Record<string, number> {
const entries = Object.entries(scores);
const weights = entries.map(([, value]) => Math.exp(value * sharpness));
const total = weights.reduce((sum, weight) => sum + weight, 0);
return Object.fromEntries(entries.map(([key], index) => [key, weights[index]! / total]));
}
function choiceAnswer(probabilities: Record<string, number>): JevChoiceAnswer {
const ranked = Object.entries(probabilities).sort((a, b) => b[1] - a[1]);
const top = ranked[0]!;
const second = ranked[1]?.[1] ?? 0;
// Jev reports confidence separately from the top probability; approximate it by the margin.
const confidence = Math.min(1, Math.max(0, (top[1] - second) * 1.6));
return {
type: "choice",
choice: top[0],
confidence: Math.round(confidence * 100) / 100,
probabilities: Object.fromEntries(ranked.map(([key, value]) => [key, Math.round(value * 100) / 100])),
};
}
export function simulateJevDecisions(request: JevDecisionRequest): JevDecisionResponse {
const prompt = String(request.state.task_prompt ?? "");
const text = prompt.toLowerCase();
const typeScores = Object.fromEntries(
(Object.keys(TYPE_KEYWORDS) as JevTaskType[]).map((type) => [type, score(text, TYPE_KEYWORDS[type])]),
) as Record<JevTaskType, number>;
// No signal at all reads as an open question.
if (Object.values(typeScores).every((value) => value === 0)) typeScores.research = 0.6;
const typeProbabilities = softmax(typeScores);
const assigneeProbabilities: Record<string, number> = Object.fromEntries(JEV_AGENTS.map((agent) => [agent.id, 0]));
for (const [type, probability] of Object.entries(typeProbabilities)) {
assigneeProbabilities[TYPE_OWNER[type as JevTaskType]]! += probability;
}
const projectScores: Record<string, number> = {
...Object.fromEntries(Object.entries(PROJECT_KEYWORDS).map(([id, keywords]) => [id, score(text, keywords)])),
[JEV_NO_PROJECT]: 0.5,
};
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 modeScores = { standard: 1, planning: isLarge ? 3 : 0, ask: isQuestion ? 3 : 0 };
const inputTokens = 380 + Math.round(prompt.length / 4);
return {
model: request.model,
answers: {
task_type: choiceAnswer(typeProbabilities),
assignee: choiceAnswer(assigneeProbabilities),
project: choiceAnswer(softmax(projectScores, 2.4)),
work_mode: choiceAnswer(softmax(modeScores)),
},
// Output tokens are free on Jev; $0.042 per million input tokens.
usage: { input_tokens: inputTokens, output_tokens: 0, cost: (inputTokens * 0.042) / 1_000_000 },
};
}
// ---------------------------------------------------------------------------
// What the composer consumes
export type JevField = "type" | "assignee" | "project" | "workMode";
export type JevRouting = {
type: JevTaskType;
assigneeId: string;
projectId: string | null;
workMode: IssueWorkMode;
confidence: Record<JevField, number>;
probabilities: Record<JevField, Record<string, number>>;
/** Likely owners other than the top pick, shown when assignee confidence is low. */
alternateAssigneeIds: string[];
usage: JevDecisionResponse["usage"];
};
export function routingFromJev(response: JevDecisionResponse): JevRouting {
const { task_type: type, assignee, project, work_mode: mode } = response.answers as Record<string, JevChoiceAnswer>;
const alternateAssigneeIds = Object.entries(assignee!.probabilities)
.filter(([id, probability]) => id !== assignee!.choice && probability >= 0.1)
.slice(0, 2)
.map(([id]) => id);
return {
type: type!.choice as JevTaskType,
assigneeId: assignee!.choice,
projectId: project!.choice === JEV_NO_PROJECT ? null : project!.choice,
workMode: mode!.choice as IssueWorkMode,
confidence: { type: type!.confidence, assignee: assignee!.confidence, project: project!.confidence, workMode: mode!.confidence },
probabilities: { type: type!.probabilities, assignee: assignee!.probabilities, project: project!.probabilities, workMode: mode!.probabilities },
alternateAssigneeIds,
usage: response.usage,
};
}
export type LatencyOptions = { latencyMs?: number; fail?: boolean; signal?: AbortSignal };
function delay<T>(produce: () => T, { latencyMs = 400, fail = false, signal }: LatencyOptions, failure: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => (fail ? reject(new Error(failure)) : resolve(produce())), latencyMs);
signal?.addEventListener("abort", () => {
window.clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
});
});
}
/** Jev is a fast decision model, so routing usually lands before the title. */
export function routeTaskWithJev(prompt: string, options: LatencyOptions = {}): Promise<JevRouting> {
return delay(() => routingFromJev(simulateJevDecisions(buildJevDecisionRequest(prompt))), options, `${JEV_NAME} is unavailable right now.`);
}
// ---------------------------------------------------------------------------
// Title: a small text model, not Jev. Simulated with curated titles and a trim.
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",
"something is off": "Investigate last month's numbers",
"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)) {
@@ -134,63 +301,6 @@ export function draftTitle(prompt: string): string {
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"));
});
});
export function draftTaskTitle(prompt: string, options: LatencyOptions = {}): Promise<string> {
return delay(() => draftTitle(prompt), options, "Couldn't draft a title.");
}
@@ -20,17 +20,19 @@ const meta = {
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.",
"Prototype: prompt-first task creation. You write what you need. Jev (`typesafe/jev-1.13`, TypeSafe's decision model on OpenRouter) answers " +
"`choice` questions for task type, assignee, project, and work mode, with a confidence and per-option probabilities. Jev doesn't generate text, so a separate small text model drafts the title, like Claude Code naming a session. " +
"Suggested values carry a sparkle. Anything you change is yours and is never overwritten; the undo icon restores the suggestion. " +
"Below the confidence threshold, likely alternate owners appear as one-click buttons. **Instant** stories create first; the title and routing arrive a moment later. " +
"Jev's answers are simulated locally in the documented response shape (`prototypes/jev-task-creation/jev-classifier.ts`); **How sure Jev is → Request sent to Jev** shows the real request body. No API is called and no task is persisted.",
},
},
},
args: { flow: "suggest-first", latencyMs: 900 },
args: { flow: "suggest-first", latencyMs: 350, titleLatencyMs: 1200 },
argTypes: {
flow: { control: "inline-radio", options: ["suggest-first", "instant"] },
latencyMs: { control: { type: "range", min: 100, max: 4000, step: 100 } },
latencyMs: { name: "Jev latency (ms)", control: { type: "range", min: 100, max: 4000, step: 50 } },
titleLatencyMs: { name: "Title model latency (ms)", control: { type: "range", min: 100, max: 6000, step: 100 } },
},
render: (args) => <JevTaskComposer key={JSON.stringify(args)} {...args} />,
} satisfies Meta<typeof JevTaskComposer>;
@@ -56,32 +58,40 @@ 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 },
export const Confidence: Story = {
name: "11 · How sure Jev is",
args: { initialPrompt: PROMPTS.research },
play: async ({ canvasElement }) => {
const toggle = await within(canvasElement).findByRole("button", { name: /Why Jev chose these/ }, { timeout: 3000 });
const toggle = await within(canvasElement).findByRole("button", { name: /How sure Jev is/ }, { timeout: 4000 });
await userEvent.click(toggle);
},
};
export const AssigneeProbabilities: Story = {
name: "11b · Assignee menu shows Jev's probabilities",
args: { initialPrompt: PROMPTS.ambiguous },
play: async ({ canvasElement }) => {
const chip = await within(canvasElement).findByRole("button", { name: /^Assignee/ }, { timeout: 4000 });
await userEvent.click(chip);
},
};
export const InstantEmpty: Story = {
name: "12 · Instant · Start first, Jev names it after",
name: "12 · Instant · Start first, name and route after",
args: { flow: "instant" },
};
export const InstantCreated: Story = {
name: "13 · Instant · Title and owner arrive",
args: { flow: "instant", initialPrompt: PROMPTS.feature, latencyMs: 1600 },
args: { flow: "instant", initialPrompt: PROMPTS.feature, latencyMs: 700, titleLatencyMs: 2200 },
play: async ({ canvasElement }) => {
await userEvent.click(await within(canvasElement).findByRole("button", { name: /Start task/ }));
},
};
export const JevUnavailable: Story = {
name: "14 · Jev unavailable · Manual fallback",
name: "14 · Jev unavailable · Title still arrives, routing is manual",
args: { initialPrompt: PROMPTS.bug, jevUnavailable: true },
};
export const SlowJev: Story = {
name: "15 · Slow Jev · Loading state",
args: { initialPrompt: PROMPTS.design, latencyMs: 60000 },
args: { initialPrompt: PROMPTS.design, latencyMs: 60000, titleLatencyMs: 60000 },
};
export const Light: Story = { name: "16 · Light theme", args: { initialPrompt: PROMPTS.bug }, globals: { theme: "light" } };
export const Mobile: Story = {