mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWn6etZjfGz6p8NWYkCQve
This commit is contained in:
4 files changed
+259
-81
No files matched your search
@@ -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<string | null>(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<string | null>(null);
|
||||
const [createdPrompt, setCreatedPrompt] = useState("");
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const lastRequestedRef = useRef("");
|
||||
const [flash, setFlash] = useState<Partial<Record<JevField, boolean>>>({});
|
||||
const [liveStats, setLiveStats] = useState<LiveStats>({ calls: 0, cost: 0 });
|
||||
const routingRef = useRef<JevRouting | null>(null);
|
||||
const routingAbortRef = useRef<AbortController | null>(null);
|
||||
const titleAbortRef = useRef<AbortController | null>(null);
|
||||
const lastRoutedRef = useRef("");
|
||||
const lastTitledRef = useRef("");
|
||||
const flashTimerRef = useRef<number | undefined>(undefined);
|
||||
const [settled, setSettled] = useState(true);
|
||||
const routingPendingSinceRef = useRef<number | null>(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<Record<JevField, boolean>> = 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"
|
||||
/>
|
||||
</div>
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-3 border-t border-border px-4 py-3">
|
||||
@@ -278,12 +362,14 @@ export function JevTaskComposer({
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
{flow === "suggest-first" && busy ? (
|
||||
<span className="hidden text-xs text-muted-foreground sm:inline">You can create now; suggestions finish after.</span>
|
||||
{busy ? (
|
||||
<span className="hidden text-xs text-muted-foreground sm:inline">
|
||||
You can {flow === "instant" ? "start" : "create"} now; routing finishes after.
|
||||
</span>
|
||||
) : null}
|
||||
<Button size="sm" onClick={create} disabled={!canCreate}>
|
||||
{flow === "instant" ? "Start task" : "Create task"}
|
||||
<kbd className="ml-1 hidden text-xs opacity-60 sm:inline">⌘↵</kbd>
|
||||
<kbd className="ml-1 hidden text-xs opacity-60 sm:inline">↵</kbd>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -394,6 +480,9 @@ function RoutingPanel({
|
||||
fromJev,
|
||||
setField,
|
||||
resetField,
|
||||
flash,
|
||||
liveStats,
|
||||
settled,
|
||||
detailsOpen,
|
||||
onToggleDetails,
|
||||
onRetry,
|
||||
@@ -409,6 +498,9 @@ function RoutingPanel({
|
||||
fromJev: (field: Exclude<OverrideField, "title">) => boolean;
|
||||
setField: <K extends OverrideField>(field: K, value: Overrides[K]) => void;
|
||||
resetField: (field: OverrideField) => void;
|
||||
flash: Partial<Record<JevField, boolean>>;
|
||||
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 ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
{routing && !statusLine ? (
|
||||
<span title={updating ? `${JEV_NAME} is updating` : `Suggested by ${JEV_NAME}`}>
|
||||
<JevMark thinking={updating} />
|
||||
</span>
|
||||
) : null}
|
||||
<span role="status" className="sr-only">{updating ? "Updating suggestions" : ""}</span>
|
||||
<WorkModeChip
|
||||
flash={flash.workMode}
|
||||
loading={loading}
|
||||
mode={effective.workMode}
|
||||
suggested={fromJev("workMode")}
|
||||
@@ -465,6 +568,8 @@ function RoutingPanel({
|
||||
/>
|
||||
<PropertyChip
|
||||
label="Assignee"
|
||||
valueKey={effective.assignee ?? "none"}
|
||||
flash={flash.assignee}
|
||||
loading={loading}
|
||||
suggested={fromJev("assignee") && !fallbackOwner}
|
||||
onReset={resetFor("assignee")}
|
||||
@@ -490,6 +595,8 @@ function RoutingPanel({
|
||||
|
||||
<PropertyChip
|
||||
label="Project"
|
||||
valueKey={effective.project ?? "none"}
|
||||
flash={flash.project}
|
||||
loading={loading}
|
||||
suggested={fromJev("project") && effective.project !== null}
|
||||
onReset={resetFor("project")}
|
||||
@@ -520,7 +627,7 @@ function RoutingPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{fallbackOwner && alternates.length > 0 ? (
|
||||
{fallbackOwner && settled && alternates.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{JEV_NAME}'s best guesses:</span>
|
||||
{alternates.map((agent) => (
|
||||
@@ -538,7 +645,7 @@ function RoutingPanel({
|
||||
<ChevronDown aria-hidden className={cn("size-3 transition-transform", !detailsOpen && "-rotate-90")} />
|
||||
How sure {JEV_NAME} is
|
||||
</button>
|
||||
{detailsOpen ? <ConfidenceDetails routing={routing} prompt={prompt} agents={agents} /> : null}
|
||||
{detailsOpen ? <ConfidenceDetails routing={routing} prompt={prompt} agents={agents} liveStats={liveStats} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className="mt-2 flex flex-col gap-2 pl-4">
|
||||
@@ -590,6 +697,9 @@ function ConfidenceDetails({ routing, prompt, agents }: { routing: JevRouting; p
|
||||
<p className="tabular-nums">
|
||||
{routing.usage.input_tokens} input tokens · ${routing.usage.cost.toFixed(6)} · output tokens are free
|
||||
</p>
|
||||
<p className="tabular-nums">
|
||||
Routed live {liveStats.calls} {liveStats.calls === 1 ? "time" : "times"} while typing · ${liveStats.cost.toFixed(6)} total
|
||||
</p>
|
||||
<details>
|
||||
<summary className="cursor-pointer hover:text-foreground">Request sent to {JEV_NAME}</summary>
|
||||
<pre className="mt-1 max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-2 font-mono text-xs text-foreground">
|
||||
@@ -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 ? <Sparkles aria-hidden className="size-3 text-primary" /> : null}
|
||||
{display}
|
||||
<span key={valueKey} className={cn("inline-flex items-center gap-1.5", FADE_IN)}>{display}</span>
|
||||
<ChevronDown aria-hidden className="size-3 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
@@ -704,11 +825,13 @@ function WorkModeChip({
|
||||
mode,
|
||||
loading,
|
||||
suggested,
|
||||
flash = false,
|
||||
probabilities,
|
||||
onChange,
|
||||
onReset,
|
||||
}: {
|
||||
mode: IssueWorkMode;
|
||||
flash?: boolean;
|
||||
loading: boolean;
|
||||
suggested: boolean;
|
||||
probabilities?: Record<string, number>;
|
||||
@@ -726,11 +849,13 @@ function WorkModeChip({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Mode${suggested ? ` (suggested by ${JEV_NAME})` : ""}`}
|
||||
className={cn("inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-sm transition-colors", meta.classes.chip)}
|
||||
className={cn("inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-sm", CHANGE_TRANSITION, meta.classes.chip, flash && FLASH_CLASSES)}
|
||||
>
|
||||
{suggested ? <Sparkles aria-hidden className="size-3" /> : null}
|
||||
<Icon aria-hidden className="size-3.5" />
|
||||
{meta.label}
|
||||
<span key={mode} className={cn("inline-flex items-center gap-1.5", FADE_IN)}>
|
||||
<Icon aria-hidden className="size-3.5" />
|
||||
{meta.label}
|
||||
</span>
|
||||
<ChevronDown aria-hidden className="size-3 opacity-60" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
|
||||
@@ -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) => <JevTaskComposer key={JSON.stringify(args)} {...args} />,
|
||||
} satisfies Meta<typeof JevTaskComposer>;
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user