mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
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:
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 = {
|
||||
|
||||
Reference in new issue
Block a user