From a9327563d571d987b2d60b0f00975e1906fcac4d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 21:43:19 +0000 Subject: [PATCH] storybook: treat work mode as the task type in Jev routing Drop the separate bug/feature/research type classification. Jev now answers three choice questions: work mode (Auto, Plan, or Ask), assignee, and project. The mode chip leads the row, is always marked as suggested, shows a one-line description and Jev's probability per mode, and supports undo. Owner signals remain internal to the simulation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SWn6etZjfGz6p8NWYkCQve --- .../jev-task-creation/JevTaskComposer.tsx | 131 +++++++++--------- .../prototypes/jev-task-creation/README.md | 15 +- .../jev-task-creation/jev-classifier.ts | 79 +++++------ .../stories/jev-task-creation.stories.tsx | 22 ++- 4 files changed, 119 insertions(+), 128 deletions(-) diff --git a/ui/storybook/prototypes/jev-task-creation/JevTaskComposer.tsx b/ui/storybook/prototypes/jev-task-creation/JevTaskComposer.tsx index 961cb8b511b..e16d76b6347 100644 --- a/ui/storybook/prototypes/jev-task-creation/JevTaskComposer.tsx +++ b/ui/storybook/prototypes/jev-task-creation/JevTaskComposer.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { IssueWorkMode } from "@paperclipai/shared"; -import { AlertTriangle, Check, ChevronDown, FolderKanban, Pencil, RotateCcw, Sparkles, Tag, Undo2 } from "lucide-react"; +import { AlertTriangle, Check, ChevronDown, FolderKanban, Pencil, RotateCcw, Sparkles, Undo2 } from "lucide-react"; import { AgentAvatar } from "@/components/AgentAvatar"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -15,13 +15,11 @@ import { JEV_NAME, JEV_NO_PROJECT, JEV_PROJECTS, - JEV_TASK_TYPES, buildJevDecisionRequest, draftTaskTitle, routeTaskWithJev, type JevField, type JevRouting, - type JevTaskType, } from "./jev-classifier"; export type JevTaskComposerProps = { @@ -47,7 +45,7 @@ export type JevTaskComposerProps = { mobile?: boolean; }; -type Overrides = Partial<{ title: string; type: JevTaskType; assignee: string; project: string | null; workMode: IssueWorkMode }>; +type Overrides = Partial<{ title: string; workMode: IssueWorkMode; assignee: string; project: string | null }>; type OverrideField = keyof Overrides; type Status = "idle" | "thinking" | "ready" | "error"; @@ -157,7 +155,6 @@ export function JevTaskComposer({ const effective = useMemo(() => ({ 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", @@ -241,7 +238,7 @@ export function JevTaskComposer({ }} placeholder={flow === "instant" ? "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."} + : "What should get done? A title, mode, 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" /> @@ -379,7 +376,6 @@ function ComposerHeader({ type Effective = { title: string; - type: JevTaskType | null; assignee: string | null; project: string | null; workMode: IssueWorkMode; @@ -427,7 +423,7 @@ function RoutingPanel({ const why = fallbackOwner.reportsTo === null ? "who reports to the board" : "the first agent in your org"; return `${JEV_NAME} wasn't sure who should own this (${percent(routing!.confidence.assignee)}), so it goes to ${fallbackOwner.name}, ${why}.`; } - 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" && flow === "instant" && !created) return `${JEV_NAME} picks the mode 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; })(); @@ -458,28 +454,14 @@ function RoutingPanel({ {showChips ? (
- {JEV_TASK_TYPES.find((option) => option.value === effective.type)?.label ?? "Type"}} - > - {(close) => JEV_TASK_TYPES.map((option) => ( - { setField("type", option.value); close(); }} - > - - {option.label} - {option.hint} - - - ))} - - + mode={effective.workMode} + suggested={fromJev("workMode")} + probabilities={probabilities?.workMode} + onChange={(mode) => setField("workMode", mode)} + onReset={resetFor("workMode")} + /> - setField("workMode", mode)} - />
) : null} @@ -569,10 +544,9 @@ function RoutingPanel({ ); } -const FIELD_LABELS: Record = { type: "Type", assignee: "Assignee", project: "Project", workMode: "Mode" }; +const FIELD_LABELS: Record = { workMode: "Mode", assignee: "Assignee", project: "Project" }; 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") { const name = (id: string) => JEV_AGENTS.find((agent) => agent.id === id)?.name ?? id; return routing.assigneeSource === "fallback" @@ -584,7 +558,7 @@ function choiceLabel(field: JevField, routing: JevRouting): string { } function ConfidenceDetails({ routing, prompt, agents }: { routing: JevRouting; prompt: string; agents: typeof JEV_AGENTS }) { - const fields: JevField[] = ["type", "assignee", "project", "workMode"]; + const fields: JevField[] = ["workMode", "assignee", "project"]; return (
@@ -699,53 +673,72 @@ function MenuItem({ ); } +const WORK_MODE_HINTS: Partial> = { + standard: "The agent does the work", + planning: "The agent writes a plan for your review first", + ask: "The agent answers without changing anything", +}; + +/** The task's type. Uses the shipped work-mode labels, icons, and colors. */ function WorkModeChip({ mode, loading, suggested, probabilities, onChange, + onReset, }: { mode: IssueWorkMode; loading: boolean; suggested: boolean; probabilities?: Record; onChange: (mode: IssueWorkMode) => void; + onReset?: () => void; }) { const [open, setOpen] = useState(false); const meta = workModeMetaFor(mode); const Icon = meta.icon; if (loading) return ; return ( - - - - - - {workModeMetaList().map((option) => { - const OptionIcon = option.icon; - return ( - { onChange(option.value); setOpen(false); }} - > - - {option.label} - - ); - })} - - + + + + + + + {workModeMetaList().map((option) => { + const OptionIcon = option.icon; + return ( + { onChange(option.value); setOpen(false); }} + > + + + {option.label} + {WORK_MODE_HINTS[option.value]} + + + ); + })} + + + {onReset ? ( + + ) : null} + ); } diff --git a/ui/storybook/prototypes/jev-task-creation/README.md b/ui/storybook/prototypes/jev-task-creation/README.md index 057674ec02e..2f2e7865c28 100644 --- a/ui/storybook/prototypes/jev-task-creation/README.md +++ b/ui/storybook/prototypes/jev-task-creation/README.md @@ -16,8 +16,9 @@ after you start it. 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 + TypeSafe's decision model on OpenRouter) answers typed `choice` questions for + the task's **mode**, which is its type (Auto, Plan, or Ask), plus its + **assignee** and **project**. 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. @@ -28,8 +29,12 @@ Two models do the work, because they are good at different things: - 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. +- **Mode**, **assignee**, and **project** appear as chips under the prompt, + usually before the title, because Jev answers faster. Mode is the task's type + and uses the shipped `IssueWorkMode` values and labels: + - **Auto** (`standard`): the agent does the work. + - **Plan** (`planning`): the agent writes a plan for review first. + - **Ask** (`ask`): the agent answers without changing anything. - 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. @@ -83,7 +88,5 @@ too (`draftTitle`). No API is called and no task is persisted. ## Open questions -- Does task **type** need to be a real field? Today `IssueWorkMode` and labels - cover part of it. - Should Jev's criteria for each agent come from agent instructions or role metadata, rather than a hand-written "owns" line? diff --git a/ui/storybook/prototypes/jev-task-creation/jev-classifier.ts b/ui/storybook/prototypes/jev-task-creation/jev-classifier.ts index a38e253cfcd..6cb6745e916 100644 --- a/ui/storybook/prototypes/jev-task-creation/jev-classifier.ts +++ b/ui/storybook/prototypes/jev-task-creation/jev-classifier.ts @@ -8,7 +8,8 @@ import type { IssueWorkMode } from "@paperclipai/shared"; * 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. + * questions for the task's mode (its type: Auto, Plan, or Ask), assignee, and + * project. * - A small text model drafts the title, the way Claude Code names a session. * * `buildJevDecisionRequest` produces the real request body. The response is @@ -48,17 +49,6 @@ export type JevDecisionResponse = { // --------------------------------------------------------------------------- // Company fixtures (match the Storybook company) -export type JevTaskType = "bug" | "feature" | "research" | "design" | "qa" | "ops"; - -export const JEV_TASK_TYPES: { value: JevTaskType; label: string; hint: string }[] = [ - { value: "bug", label: "Bug", hint: "Something is broken or regressed" }, - { value: "feature", label: "Feature", hint: "Build or change product behavior" }, - { value: "research", label: "Research", hint: "Investigate, compare, or answer a question" }, - { value: "design", label: "Design", hint: "UI, UX, or visual work" }, - { value: "qa", label: "QA", hint: "Verify, test, or reproduce" }, - { value: "ops", label: "Ops", hint: "Admin, release, or coordination work" }, -]; - export type JevAgent = { id: string; name: string; @@ -103,10 +93,14 @@ export function buildJevDecisionRequest(prompt: string, agents: JevAgent[] = JEV projects: JEV_PROJECTS.map(({ id, name, description }) => ({ id, name, description })), }, questions: { - task_type: { + work_mode: { type: "choice", - instructions: "What kind of work does the task prompt describe?", - criteria: Object.fromEntries(JEV_TASK_TYPES.map((option) => [option.value, option.hint])), + instructions: "What type of task is this: should the agent do the work, plan it first, or answer a question?", + criteria: { + standard: "Auto: a clear, bounded request the agent can carry out directly.", + planning: "Plan: large, risky, or multi-step work that needs a reviewed plan before anything changes.", + ask: "Ask: a question or investigation to answer without changing anything.", + }, }, assignee: { type: "choice", @@ -121,23 +115,17 @@ export function buildJevDecisionRequest(prompt: string, agents: JevAgent[] = JEV [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 +// Local simulation of Jev's answers. Work areas are internal signals for +// picking an owner; they are not shown or stored. -const TYPE_KEYWORDS: Record = { +type WorkArea = "bug" | "feature" | "research" | "design" | "qa" | "ops"; + +const AREA_KEYWORDS: Record = { bug: ["bug", "broken", "fix", "crash", "error", "regress", "fails", "failing", "500", "stuck", "doesn't", "does not", "not working", "wrong"], feature: ["add", "build", "implement", "support", "create", "new", "allow", "enable", "endpoint", "integrate", "make", "improve", "faster", "performance"], research: ["research", "investigate", "compare", "evaluate", "why", "how does", "figure out", "explore", "options", "should we"], @@ -146,8 +134,8 @@ const TYPE_KEYWORDS: Record = { 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 = { +/** Which agent usually owns each work area. */ +const AREA_OWNER: Record = { bug: "agent-codex", feature: "agent-codex", research: "agent-cto", @@ -191,17 +179,17 @@ export function simulateJevDecisions(request: JevDecisionRequest): JevDecisionRe 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; + const areaScores = Object.fromEntries( + (Object.keys(AREA_KEYWORDS) as WorkArea[]).map((area) => [area, score(text, AREA_KEYWORDS[area])]), + ) as Record; // 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); + if (Object.values(areaScores).every((value) => value === 0)) areaScores.research = 0.6; + const areaProbabilities = softmax(areaScores); const assigneeQuestion = request.questions.assignee as JevChoiceQuestion; const assigneeProbabilities: Record = Object.fromEntries(Object.keys(assigneeQuestion.criteria).map((id) => [id, 0.02])); - for (const [type, probability] of Object.entries(typeProbabilities)) { - const owner = TYPE_OWNER[type as JevTaskType]; + for (const [area, probability] of Object.entries(areaProbabilities)) { + const owner = AREA_OWNER[area as WorkArea]; if (owner in assigneeProbabilities) assigneeProbabilities[owner]! += probability; } const assigneeTotal = Object.values(assigneeProbabilities).reduce((sum, value) => sum + value, 0); @@ -214,16 +202,16 @@ export function simulateJevDecisions(request: JevDecisionRequest): JevDecisionRe 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 isInvestigation = /^(investigate|research|find out|figure out|look into|compare|evaluate)\b/.test(text); + const modeScores = { standard: 1, planning: isLarge ? 3 : 0, ask: isQuestion ? 3 : isInvestigation ? 1.6 : 0 }; const inputTokens = 380 + Math.round(prompt.length / 4); return { model: request.model, answers: { - task_type: choiceAnswer(typeProbabilities), + work_mode: choiceAnswer(softmax(modeScores)), 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 }, @@ -233,7 +221,7 @@ export function simulateJevDecisions(request: JevDecisionRequest): JevDecisionRe // --------------------------------------------------------------------------- // What the composer consumes -export type JevField = "type" | "assignee" | "project" | "workMode"; +export type JevField = "workMode" | "assignee" | "project"; /** * Who gets a task when Jev isn't confident about the owner: the org's first @@ -245,14 +233,14 @@ export function fallbackAssigneeId(agents: JevAgent[]): string | null { } export type JevRouting = { - type: JevTaskType; + /** The task's type. */ + workMode: IssueWorkMode; /** The owner to use: Jev's pick, or the fallback when Jev is unsure. */ assigneeId: string; assigneeSource: "jev" | "fallback"; /** Jev's own top pick, kept even when the fallback is used. */ jevAssigneeId: string; projectId: string | null; - workMode: IssueWorkMode; confidence: Record; probabilities: Record>; /** Jev's likely owners other than the one assigned, offered when Jev is unsure. */ @@ -261,7 +249,7 @@ export type JevRouting = { }; export function routingFromJev(response: JevDecisionResponse, agents: JevAgent[] = JEV_AGENTS): JevRouting { - const { task_type: type, assignee, project, work_mode: mode } = response.answers as Record; + const { work_mode: mode, assignee, project } = response.answers as Record; const fallbackId = assignee!.confidence < JEV_CONFIDENCE_THRESHOLD ? fallbackAssigneeId(agents) : null; const assigneeId = fallbackId ?? assignee!.choice; const alternateAssigneeIds = Object.entries(assignee!.probabilities) @@ -269,14 +257,13 @@ export function routingFromJev(response: JevDecisionResponse, agents: JevAgent[] .slice(0, 2) .map(([id]) => id); return { - type: type!.choice as JevTaskType, + workMode: mode!.choice as IssueWorkMode, assigneeId, assigneeSource: fallbackId ? "fallback" : "jev", jevAssigneeId: 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 }, + confidence: { workMode: mode!.confidence, assignee: assignee!.confidence, project: project!.confidence }, + probabilities: { workMode: mode!.probabilities, assignee: assignee!.probabilities, project: project!.probabilities }, alternateAssigneeIds, usage: response.usage, }; diff --git a/ui/storybook/stories/jev-task-creation.stories.tsx b/ui/storybook/stories/jev-task-creation.stories.tsx index d23cf5dc7e3..eda23a2fea9 100644 --- a/ui/storybook/stories/jev-task-creation.stories.tsx +++ b/ui/storybook/stories/jev-task-creation.stories.tsx @@ -21,7 +21,7 @@ const meta = { description: { component: "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. " + + "`choice` questions for the task's mode, which is its type (Auto, Plan, or Ask), plus its assignee and project, each 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 the task goes to the org's first active agent that reports to the board (else its first active agent), and Jev's best guesses 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.", @@ -44,12 +44,12 @@ export const WatchItSuggest: Story = { name: "02 · Watch Jev suggest while you type", args: { initialPrompt: PROMPTS.bug, typeOnMount: true }, }; -export const Bug: Story = { name: "03 · Bug → CodexCoder", args: { initialPrompt: PROMPTS.bug } }; -export const Feature: Story = { name: "04 · Feature → CodexCoder", args: { initialPrompt: PROMPTS.feature } }; -export const Research: Story = { name: "05 · Question → CTO in Ask mode", args: { initialPrompt: PROMPTS.research } }; -export const Design: Story = { name: "06 · Design → DesignSystemCoder", args: { initialPrompt: PROMPTS.design } }; -export const QA: Story = { name: "07 · QA → QAChecker", args: { initialPrompt: PROMPTS.qa } }; -export const LargeScope: Story = { name: "08 · Large scope → Plan mode", args: { initialPrompt: PROMPTS.planning } }; +export const Bug: Story = { name: "03 · Login bug → Auto mode, CodexCoder", args: { initialPrompt: PROMPTS.bug } }; +export const Feature: Story = { name: "04 · CSV export → Auto mode, CodexCoder", args: { initialPrompt: PROMPTS.feature } }; +export const Research: Story = { name: "05 · Question → Ask mode, CTO", args: { initialPrompt: PROMPTS.research } }; +export const Design: Story = { name: "06 · Spacing pass → Auto mode, DesignSystemCoder", args: { initialPrompt: PROMPTS.design } }; +export const QA: Story = { name: "07 · Release checks → Auto mode, QAChecker", args: { initialPrompt: PROMPTS.qa } }; +export const LargeScope: Story = { name: "08 · Phased migration → Plan mode, CodexCoder", args: { initialPrompt: PROMPTS.planning } }; export const LowConfidence: Story = { name: "09 · Unsure · Goes to the agent reporting to the board", args: { initialPrompt: PROMPTS.ambiguous }, @@ -78,6 +78,14 @@ export const AssigneeProbabilities: Story = { await userEvent.click(chip); }, }; +export const ModeProbabilities: Story = { + name: "11c · Mode menu shows Jev's probabilities", + args: { initialPrompt: PROMPTS.planning }, + play: async ({ canvasElement }) => { + const chip = await within(canvasElement).findByRole("button", { name: /^Mode/ }, { timeout: 4000 }); + await userEvent.click(chip); + }, +}; export const InstantEmpty: Story = { name: "12 · Instant · Start first, name and route after", args: { flow: "instant" },