From 35109298e5e63f2813c0a60c3fa3738aa4b3a9d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:06:19 +0000 Subject: [PATCH] storybook: route Jev tasks live while typing, title on start Mode, assignee, and project now re-route live as the prompt changes, with a short debounce and a max wait so continuous typing still updates. A stabilizer keeps a chip's value unless Jev's new pick leads by 15 points, changed chips highlight briefly, and the fallback note waits for a pause in typing. The title is drafted once, when the task is started with Start task or Enter; Shift+Enter adds a line. The details panel counts live routing calls and their total cost. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SWn6etZjfGz6p8NWYkCQve --- .../jev-task-creation/JevTaskComposer.tsx | 255 +++++++++++++----- .../prototypes/jev-task-creation/README.md | 39 ++- .../jev-task-creation/jev-classifier.ts | 30 ++- .../stories/jev-task-creation.stories.tsx | 16 +- 4 files changed, 259 insertions(+), 81 deletions(-) diff --git a/ui/storybook/prototypes/jev-task-creation/JevTaskComposer.tsx b/ui/storybook/prototypes/jev-task-creation/JevTaskComposer.tsx index bb369366be1..29a5632f33e 100644 --- a/ui/storybook/prototypes/jev-task-creation/JevTaskComposer.tsx +++ b/ui/storybook/prototypes/jev-task-creation/JevTaskComposer.tsx @@ -16,6 +16,7 @@ import { JEV_NO_PROJECT, JEV_PROJECTS, agentContextCoverage, + stabilizeRouting, buildJevDecisionRequest, draftTaskTitle, routeTaskWithJev, @@ -25,9 +26,12 @@ import { export type JevTaskComposerProps = { /** - * `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 is named and routed a moment later. + * Both flows update mode, owner, and project live while you type. The title is + * drafted once, when the task is started (Start task or Enter), like Claude + * Code naming a session after the first message. + * `suggest-first`: review the live suggestions, then create. + * `instant`: start any time; whatever Jev has suggested so far is used, and + * anything still pending finishes after. */ flow?: "suggest-first" | "instant"; initialPrompt?: string; @@ -35,6 +39,10 @@ export type JevTaskComposerProps = { latencyMs?: number; /** Simulated latency of the separate text model that drafts the title. */ titleLatencyMs?: number; + /** Pause after a keystroke before Jev re-routes. Jev is cheap, so this can be short. */ + liveDebounceMs?: number; + /** During continuous typing, re-route at least this often. */ + liveMaxWaitMs?: number; /** Simulate Jev being unavailable; routing falls back to manual fields. */ jevUnavailable?: boolean; /** Pre-set the assignee as if the user already chose it. Jev never overwrites it. */ @@ -49,25 +57,49 @@ export type JevTaskComposerProps = { type Overrides = Partial<{ title: string; workMode: IssueWorkMode; assignee: string; project: string | null }>; type OverrideField = keyof Overrides; type Status = "idle" | "thinking" | "ready" | "error"; +type LiveStats = { calls: number; cost: number }; -const TYPING_DEBOUNCE_MS = 650; +const FLASH_MS = 900; +/** Explanatory notes (like the fallback owner) wait for a pause in typing, so they don't flicker mid-sentence. */ +const SETTLE_MS = 1000; + +/** Debounce with a ceiling: wait for a pause, but never longer than `maxWaitMs` since the first pending change. */ +function throttledDelay(pendingSince: { current: number | null }, debounceMs: number, maxWaitMs: number) { + if (pendingSince.current === null) pendingSince.current = Date.now(); + return Math.max(0, Math.min(debounceMs, maxWaitMs - (Date.now() - pendingSince.current))); +} const percent = (value: number | undefined) => (value === undefined ? "" : `${Math.round(value * 100)}%`); +const isAbort = (error: unknown) => error instanceof DOMException && error.name === "AbortError"; -/** Types a newly suggested title in, like a session title arriving. Unchanged titles don't retype. */ +function commonPrefixLength(a: string, b: string) { + let index = 0; + while (index < a.length && index < b.length && a[index] === b[index]) index += 1; + return index; +} + +/** + * Types a suggested title in, like a session title arriving. When the title is + * redrafted mid-typing, only the part that changed is retyped. + */ function useTypewriter(target: string | null) { const [shown, setShown] = useState(""); - const previousRef = useRef(null); + const shownRef = useRef(""); useEffect(() => { - const previous = previousRef.current; - previousRef.current = target; - if (!target) return setShown(""); - if (target === previous) return setShown(target); - setShown(""); - let index = 0; + if (!target) { + shownRef.current = ""; + return setShown(""); + } + let index = commonPrefixLength(shownRef.current, target); + const step = () => { + shownRef.current = target.slice(0, index); + setShown(shownRef.current); + }; + step(); + if (index >= target.length) return; const timer = window.setInterval(() => { index += 1; - setShown(target.slice(0, index)); + step(); if (index >= target.length) window.clearInterval(timer); }, 22); return () => window.clearInterval(timer); @@ -80,6 +112,8 @@ export function JevTaskComposer({ initialPrompt = "", latencyMs = 350, titleLatencyMs = 1200, + liveDebounceMs = 250, + liveMaxWaitMs = 800, jevUnavailable = false, presetAssigneeId, pausedAgentIds, @@ -101,8 +135,16 @@ export function JevTaskComposer({ const [createdIdentifier, setCreatedIdentifier] = useState(null); const [createdPrompt, setCreatedPrompt] = useState(""); const [detailsOpen, setDetailsOpen] = useState(false); - const abortRef = useRef(null); - const lastRequestedRef = useRef(""); + const [flash, setFlash] = useState>>({}); + const [liveStats, setLiveStats] = useState({ calls: 0, cost: 0 }); + const routingRef = useRef(null); + const routingAbortRef = useRef(null); + const titleAbortRef = useRef(null); + const lastRoutedRef = useRef(""); + const lastTitledRef = useRef(""); + const flashTimerRef = useRef(undefined); + const [settled, setSettled] = useState(true); + const routingPendingSinceRef = useRef(null); // Autoplay: type the scenario prompt so reviewers see suggestions react mid-sentence. useEffect(() => { @@ -116,43 +158,85 @@ export function JevTaskComposer({ return () => window.clearInterval(timer); }, [typeOnMount, initialPrompt]); - const suggest = useCallback((text: string) => { - abortRef.current?.abort(); + const runRouting = useCallback((text: string) => { + routingAbortRef.current?.abort(); const controller = new AbortController(); - abortRef.current = controller; - lastRequestedRef.current = text; - const ignoreAbort = (error: unknown) => error instanceof DOMException && error.name === "AbortError"; - + routingAbortRef.current = controller; + routingPendingSinceRef.current = null; + lastRoutedRef.current = text; setRoutingStatus("thinking"); routeTaskWithJev(text, { latencyMs, fail: jevUnavailable, signal: controller.signal, agents }) - .then((result) => { setRouting(result); setRoutingStatus("ready"); }) - .catch((error: unknown) => { if (!ignoreAbort(error)) setRoutingStatus("error"); }); + .then((result) => { + const previous = routingRef.current; + const next = stabilizeRouting(previous, result); + // Highlight only fields whose value actually changed, so the eye goes to what moved. + const changed: Partial> = previous ? { + workMode: previous.workMode !== next.workMode, + assignee: previous.assigneeId !== next.assigneeId, + project: previous.projectId !== next.projectId, + } : {}; + routingRef.current = next; + setRouting(next); + setRoutingStatus("ready"); + setLiveStats((stats) => ({ calls: stats.calls + 1, cost: stats.cost + result.usage.cost })); + if (Object.values(changed).some(Boolean)) { + setFlash(changed); + window.clearTimeout(flashTimerRef.current); + flashTimerRef.current = window.setTimeout(() => setFlash({}), FLASH_MS); + } + }) + .catch((error: unknown) => { if (!isAbort(error)) setRoutingStatus("error"); }); + }, [latencyMs, jevUnavailable, agents]); + const runTitle = useCallback((text: string) => { + titleAbortRef.current?.abort(); + const controller = new AbortController(); + titleAbortRef.current = controller; + lastTitledRef.current = text; 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, agents]); + .catch((error: unknown) => { if (!isAbort(error)) setTitleStatus("error"); }); + }, [titleLatencyMs]); - // Suggest-first: re-run after the user pauses typing. + const resetSuggestions = useCallback(() => { + routingAbortRef.current?.abort(); + titleAbortRef.current?.abort(); + lastRoutedRef.current = ""; + lastTitledRef.current = ""; + routingPendingSinceRef.current = null; + routingRef.current = null; + setRouting(null); + setSuggestedTitle(null); + setRoutingStatus("idle"); + setTitleStatus("idle"); + setFlash({}); + }, []); + + // Live routing: re-run shortly after each pause in typing, until the task is created. useEffect(() => { - if (flow !== "suggest-first" || createdIdentifier) return; + if (createdIdentifier) return; const text = prompt.trim(); if (text.length < JEV_MIN_PROMPT_LENGTH) { - abortRef.current?.abort(); - lastRequestedRef.current = ""; - setRoutingStatus("idle"); - setTitleStatus("idle"); - setRouting(null); - setSuggestedTitle(null); + if (lastRoutedRef.current || lastTitledRef.current) resetSuggestions(); return; } - if (text === lastRequestedRef.current) return; - const timer = window.setTimeout(() => suggest(text), TYPING_DEBOUNCE_MS); + if (text === lastRoutedRef.current) return; + const timer = window.setTimeout(() => runRouting(text), throttledDelay(routingPendingSinceRef, liveDebounceMs, liveMaxWaitMs)); return () => window.clearTimeout(timer); - }, [prompt, flow, createdIdentifier, suggest]); + }, [prompt, createdIdentifier, runRouting, resetSuggestions, liveDebounceMs, liveMaxWaitMs]); - useEffect(() => () => abortRef.current?.abort(), []); + useEffect(() => { + setSettled(false); + const timer = window.setTimeout(() => setSettled(true), SETTLE_MS); + return () => window.clearTimeout(timer); + }, [prompt]); + + useEffect(() => () => { + routingAbortRef.current?.abort(); + titleAbortRef.current?.abort(); + window.clearTimeout(flashTimerRef.current); + }, []); const effective = useMemo(() => ({ title: overrides.title ?? suggestedTitle ?? "", @@ -177,20 +261,18 @@ export function JevTaskComposer({ 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); + // Creating never waits: keep the routing that's shown, bring it up to date if + // typing got ahead of it, and draft the title now that the prompt is final. + if (lastRoutedRef.current !== text) runRouting(text); + runTitle(text); }; const startOver = () => { - abortRef.current?.abort(); - lastRequestedRef.current = ""; + resetSuggestions(); setPrompt(""); - setRouting(null); - setSuggestedTitle(null); setOverrides({}); - setRoutingStatus("idle"); - setTitleStatus("idle"); setCreatedIdentifier(null); setDetailsOpen(false); + setLiveStats({ calls: 0, cost: 0 }); }; const titleSuggested = suggestedTitle !== null && overrides.title === undefined; @@ -232,14 +314,13 @@ export function JevTaskComposer({ value={prompt} onChange={(event) => setPrompt(event.target.value)} onKeyDown={(event) => { - if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + // Enter starts the task, like sending a first message. Shift+Enter adds a line. + if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); create(); } }} - placeholder={flow === "instant" - ? "What should get done? It gets a title and an owner once you start." - : "What should get done? A title, mode, and owner are suggested as you write."} + placeholder="What should get done? Mode and owner update as you write. Enter to start, Shift+Enter for a new line." className="min-h-36 resize-none border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0 dark:bg-transparent" /> @@ -257,9 +338,12 @@ export function JevTaskComposer({ fromJev={fromJev} setField={setField} resetField={resetField} + flash={flash} + liveStats={liveStats} + settled={settled || created} detailsOpen={detailsOpen} onToggleDetails={() => setDetailsOpen((open) => !open)} - onRetry={() => suggest(prompt.trim() || createdPrompt)} + onRetry={() => runRouting(prompt.trim() || createdPrompt)} />
@@ -278,12 +362,14 @@ export function JevTaskComposer({ Cancel
- {flow === "suggest-first" && busy ? ( - You can create now; suggestions finish after. + {busy ? ( + + You can {flow === "instant" ? "start" : "create"} now; routing finishes after. + ) : null}
@@ -394,6 +480,9 @@ function RoutingPanel({ fromJev, setField, resetField, + flash, + liveStats, + settled, detailsOpen, onToggleDetails, onRetry, @@ -409,6 +498,9 @@ function RoutingPanel({ fromJev: (field: Exclude) => boolean; setField: (field: K, value: Overrides[K]) => void; resetField: (field: OverrideField) => void; + flash: Partial>; + liveStats: LiveStats; + settled: boolean; detailsOpen: boolean; onToggleDetails: () => void; onRetry: () => void; @@ -419,17 +511,21 @@ function RoutingPanel({ : null; const statusLine = (() => { - if (status === "thinking" && !routing) return created ? `${JEV_NAME} is routing this task…` : `${JEV_NAME} is reading your request…`; - if (fallbackOwner) { + if (status === "thinking" && !routing) return created ? `${JEV_NAME} is routing this task…` : `${JEV_NAME} is reading as you type…`; + if (fallbackOwner && settled) { 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 mode and owner after you start. You can change anything later.`; + if (status === "idle" && promptLength === 0 && !created) { + return `As you type, ${JEV_NAME} picks the mode, owner, and project. The title is written when you ${flow === "instant" ? "start" : "create"} the task.`; + } 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"; + // Chips stay on screen from the first keystroke, so suggestions fill in place instead of popping in. + const showChips = true; + const updating = routing !== null && status === "thinking"; const loading = status === "thinking" && !routing; const probabilities = routing?.probabilities; const assignee = JEV_AGENTS.find((agent) => agent.id === effective.assignee) ?? null; @@ -455,7 +551,14 @@ function RoutingPanel({ {showChips ? (
+ {routing && !statusLine ? ( + + + + ) : null} + {updating ? "Updating suggestions" : ""} ) : null} - {fallbackOwner && alternates.length > 0 ? ( + {fallbackOwner && settled && alternates.length > 0 ? (
{JEV_NAME}'s best guesses: {alternates.map((agent) => ( @@ -538,7 +645,7 @@ function RoutingPanel({ How sure {JEV_NAME} is - {detailsOpen ? : null} + {detailsOpen ? : null}
) : null}
@@ -558,7 +665,7 @@ function choiceLabel(field: JevField, routing: JevRouting): string { return workModeMetaFor(routing.workMode).label; } -function ConfidenceDetails({ routing, prompt, agents }: { routing: JevRouting; prompt: string; agents: typeof JEV_AGENTS }) { +function ConfidenceDetails({ routing, prompt, agents, liveStats }: { routing: JevRouting; prompt: string; agents: typeof JEV_AGENTS; liveStats: LiveStats }) { const fields: JevField[] = ["workMode", "assignee", "project"]; return (
@@ -590,6 +697,9 @@ function ConfidenceDetails({ routing, prompt, agents }: { routing: JevRouting; p

{routing.usage.input_tokens} input tokens · ${routing.usage.cost.toFixed(6)} · output tokens are free

+

+ Routed live {liveStats.calls} {liveStats.calls === 1 ? "time" : "times"} while typing · ${liveStats.cost.toFixed(6)} total +

Request sent to {JEV_NAME}
@@ -618,16 +728,25 @@ function AgentContextLine({ agents }: { agents: typeof JEV_AGENTS }) {
   );
 }
 
+/** A changed value flashes briefly and fades in, so live updates are noticeable but calm. */
+const FLASH_CLASSES = "ring-2 ring-primary/50 bg-primary/10";
+const CHANGE_TRANSITION = "transition-[background-color,box-shadow,border-color] duration-700 motion-reduce:transition-none";
+const FADE_IN = "animate-[tc-fade-in_240ms_ease-out] motion-reduce:animate-none";
+
 function PropertyChip({
   label,
+  valueKey,
   display,
   loading,
   suggested,
+  flash = false,
   onReset,
   children,
 }: {
   label: string;
+  valueKey: string;
   display: ReactNode;
+  flash?: boolean;
   loading: boolean;
   suggested: boolean;
   onReset?: () => void;
@@ -645,12 +764,14 @@ 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 text-foreground transition-colors hover:bg-accent",
+              "inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-sm text-foreground hover:bg-accent",
+              CHANGE_TRANSITION,
               suggested ? "border-primary/40 bg-primary/5" : "border-border",
+              flash && FLASH_CLASSES,
             )}
           >
             {suggested ?  : null}
-            {display}
+            {display}
             
           
         
@@ -704,11 +825,13 @@ function WorkModeChip({
   mode,
   loading,
   suggested,
+  flash = false,
   probabilities,
   onChange,
   onReset,
 }: {
   mode: IssueWorkMode;
+  flash?: boolean;
   loading: boolean;
   suggested: boolean;
   probabilities?: Record;
@@ -726,11 +849,13 @@ function WorkModeChip({
           
         
diff --git a/ui/storybook/prototypes/jev-task-creation/README.md b/ui/storybook/prototypes/jev-task-creation/README.md
index 497e6c6c2af..c7b2fa9cb90 100644
--- a/ui/storybook/prototypes/jev-task-creation/README.md
+++ b/ui/storybook/prototypes/jev-task-creation/README.md
@@ -27,11 +27,22 @@ Two models do the work, because they are good at different things:
 
 ## What the prototype shows
 
-- The **title** types into the header breadcrumb (`PAP › Fix login redirect
-  loop…`) when it arrives. Click it to edit.
-- **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:
+- **Live routing while you type.** Mode, assignee, and project re-route about
+  250 ms after each pause in typing, and at least every 800 ms during continuous
+  typing. Jev is fast and cheap enough for this: a call costs about $0.00004.
+- **Steady suggestions.** A chip only switches when Jev's new pick leads the
+  current one by 15 points of probability (`JEV_SWITCH_MARGIN`,
+  `stabilizeRouting`), so a word or two doesn't make the owner flicker.
+- **Calm change cues.** Chips stay on screen from the first keystroke. A changed
+  value fades in and its chip highlights briefly. A small pulsing mark shows Jev
+  re-reading. Explanatory notes, like the fallback owner, wait for a one-second
+  pause in typing.
+- **Title on start.** The title is drafted once, when you press **Start task** or
+  **Enter**, like Claude Code naming a session after the first message. It types
+  into the header breadcrumb and you can click it to edit. Shift+Enter adds a new
+  line.
+- **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.
@@ -45,20 +56,20 @@ Two models do the work, because they are good at different things:
   Paused agents are skipped, and Jev never sees them as options. The composer says
   so in one line and offers Jev's best guesses as one-click buttons
   (`fallbackAssigneeId` in `jev-classifier.ts`; stories 09 and 09b).
-- **How sure Jev is** lists the confidence for each decision, the token cost, and
-  the exact request body sent to Jev.
+- **How sure Jev is** lists the confidence for each decision, the agent context
+  sent, the token cost, how many times routing ran while typing, 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.
+- Starting never waits. Whatever Jev has suggested is used, and anything still
+  pending finishes after.
 
 ## Two flows to compare
 
-- **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. Routing and then the title arrive a
-  moment later.
+- **Suggest-first** (stories 01–11c): review the live suggestions, then create.
+- **Instant** (stories 12–13): closest to Claude Code. **Start task** or **Enter**
+  creates `PAP-412` right away. Routing is usually already there; the title
+  arrives a moment later.
 
 ## What's real and what's simulated
 
diff --git a/ui/storybook/prototypes/jev-task-creation/jev-classifier.ts b/ui/storybook/prototypes/jev-task-creation/jev-classifier.ts
index 7e1a40d68f7..b22c042eb8a 100644
--- a/ui/storybook/prototypes/jev-task-creation/jev-classifier.ts
+++ b/ui/storybook/prototypes/jev-task-creation/jev-classifier.ts
@@ -23,7 +23,9 @@ export const JEV_MODEL = "typesafe/jev-1.13";
 /** Below this assignee confidence, the task goes to the fallback owner (see `fallbackAssigneeId`). */
 export const JEV_CONFIDENCE_THRESHOLD = 0.6;
 /** Prompts need a little substance before routing starts. */
-export const JEV_MIN_PROMPT_LENGTH = 18;
+export const JEV_MIN_PROMPT_LENGTH = 12;
+/** While typing, a suggestion only switches when the new pick leads the current one by this much probability. */
+export const JEV_SWITCH_MARGIN = 0.15;
 
 // ---------------------------------------------------------------------------
 // Decisions API shapes (from the OpenRouter Jev docs)
@@ -383,6 +385,32 @@ export function routingFromJev(response: JevDecisionResponse, agents: JevAgent[]
   };
 }
 
+/**
+ * Keeps live suggestions steady while the prompt changes. Each field keeps its
+ * current value unless Jev's new top pick beats it by `JEV_SWITCH_MARGIN`, so a
+ * word or two of typing doesn't make the owner flicker between two agents.
+ * Moving in or out of the low-confidence fallback always applies.
+ */
+export function stabilizeRouting(previous: JevRouting | null, next: JevRouting): JevRouting {
+  if (!previous) return next;
+  const keepPrevious = (field: JevField, previousChoice: string, nextChoice: string) =>
+    previousChoice !== nextChoice &&
+    (next.probabilities[field][nextChoice] ?? 0) - (next.probabilities[field][previousChoice] ?? 0) < JEV_SWITCH_MARGIN;
+  const result: JevRouting = { ...next };
+  if (keepPrevious("workMode", previous.workMode, next.workMode)) result.workMode = previous.workMode;
+  const projectKey = (id: string | null) => id ?? JEV_NO_PROJECT;
+  if (keepPrevious("project", projectKey(previous.projectId), projectKey(next.projectId))) result.projectId = previous.projectId;
+  if (previous.assigneeSource === "jev" && next.assigneeSource === "jev" && keepPrevious("assignee", previous.assigneeId, next.assigneeId)) {
+    result.assigneeId = previous.assigneeId;
+    result.jevAssigneeId = previous.assigneeId;
+  }
+  result.alternateAssigneeIds = Object.entries(next.probabilities.assignee)
+    .filter(([id, probability]) => id !== result.assigneeId && probability >= 0.1)
+    .slice(0, 2)
+    .map(([id]) => id);
+  return result;
+}
+
 export type LatencyOptions = { latencyMs?: number; fail?: boolean; signal?: AbortSignal };
 export type RouteOptions = LatencyOptions & { agents?: JevAgent[] };
 
diff --git a/ui/storybook/stories/jev-task-creation.stories.tsx b/ui/storybook/stories/jev-task-creation.stories.tsx
index eda23a2fea9..525efbe2f98 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 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. " +
+          "`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. Mode, owner, and project update live as you type. Jev doesn't generate text, so a separate small text model drafts the title once, when the task is started with Start task or Enter, 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.",
@@ -33,6 +33,7 @@ const meta = {
     flow: { control: "inline-radio", options: ["suggest-first", "instant"] },
     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 } },
+    liveDebounceMs: { name: "Re-route after typing pause (ms)", control: { type: "range", min: 0, max: 1500, step: 50 } },
   },
   render: (args) => ,
 } satisfies Meta;
@@ -86,6 +87,19 @@ export const ModeProbabilities: Story = {
     await userEvent.click(chip);
   },
 };
+export const InstantLiveTyping: Story = {
+  name: "12a · Instant · Suggestions update live as you type",
+  args: { flow: "instant", initialPrompt: PROMPTS.bug, typeOnMount: true },
+};
+export const InstantEnterToStart: Story = {
+  name: "12b · Instant · Enter starts the task, then the title is written",
+  args: { flow: "instant", initialPrompt: PROMPTS.feature, titleLatencyMs: 1600 },
+  play: async ({ canvasElement }) => {
+    const box = await within(canvasElement).findByRole("textbox", { name: "Describe the task" });
+    await new Promise((resolve) => setTimeout(resolve, 900));
+    await userEvent.type(box, "{Enter}");
+  },
+};
 export const InstantEmpty: Story = {
   name: "12 · Instant · Start first, name and route after",
   args: { flow: "instant" },