ask_user upgrades: rich options, grouped questions, option previews (OPE-51)

Options accept {label, description, recommended, preview} objects (plain
strings unchanged — old sessions render as today's pills), and `questions`
groups up to 4 questions into one call, rendered as a stepper via the
header chips. Any option preview switches the card to a two-pane layout:
options left, monospace pane right, following hover/focus.

Grouped calls resolve with a JSON map keyed by header-or-question and
return {answers: {...}} to the agent (single stays {answer: ...});
a grouped item's first question doubles as its title/options so channel
mirrors and legacy surfaces degrade sensibly. Channel buttons use option
labels; grouped items mirror as text with the open-the-app hint.
This commit is contained in:
Devika Verma
2026-07-29 15:52:05 +05:30
parent f96ad4c8e6
commit 70cd1fa3d4
12 changed files with 930 additions and 100 deletions
+153
View File
@@ -0,0 +1,153 @@
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
// OPE-51 — ask_user upgrades: rich options (descriptions, the Recommended tag, monospace
// previews with the two-pane layout) and grouped questions (the stepper). Seeded via a per-test
// inbox route override (later routes match first) so the base fixtures' counts — which
// inbox.spec.ts pins — stay untouched.
const BASE = {
body: "",
state: "pending",
resolution: null as string | null,
inbox: "default",
created_at: "2026-07-29 08:00:00",
resolved_at: null as string | null,
session_title: "Investigate alerts",
session_agent: "ops",
session_workspace: "",
session_exists: true,
};
const RICH_ITEM = {
...BASE,
id: "inb-question-rich",
session_id: "ops-1",
kind: "question",
title: "How should I format the report?",
header: "Format",
options: [
{
label: "Markdown table",
description: "Compact and renders in the app",
recommended: true,
preview: "| env | status |\n| --- | --- |\n| staging | ok |",
},
{
label: "Plain text",
description: "Safest for email forwarding",
preview: "env: staging\nstatus: ok",
},
],
allow_text: true,
multi: false,
questions: [],
};
const GROUPED_ITEM = {
...BASE,
id: "inb-question-grouped",
session_id: "ops-1",
kind: "question",
// The first question doubles as title/options (legacy-surface degradation, server parity).
title: "Chart style?",
header: "Chart style",
options: ["Bar", "Line"],
allow_text: false,
multi: false,
questions: [
{ question: "Chart style?", header: "Chart style", options: ["Bar", "Line"], allow_text: false, multi: false },
{ question: "Which distribution?", header: "Distribution", options: ["Stacked", "Grouped"], allow_text: true, multi: false },
],
};
/** Replace the Inbox's seeded items for this test (resolve mutates the local copy). */
async function seedInbox(page: Page, items: Record<string, unknown>[]) {
const inbox = items.map((i) => ({ ...i }));
const json = (body: unknown) => ({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
await page.route(/\/v1\/inbox\/[^/]+\/resolve$/, (route) => {
const path = new URL(route.request().url()).pathname;
const id = decodeURIComponent(path.split("/").slice(-2)[0]);
const it = inbox.find((x) => x.id === id);
if (it) {
it.state = "resolved";
it.resolution = route.request().postDataJSON().resolution;
}
return route.fulfill(json({ ok: true }));
});
await page.route(/\/v1\/inbox(\?.*)?$/, (route) =>
route.fulfill(json({ items: inbox.filter((i) => i.state === "pending") })),
);
return inbox;
}
async function openInbox(page: Page, expectTitle: string) {
await page.goto("/");
await page.getByTestId("inbox-chip").click();
await expect(page.getByText(expectTitle)).toBeVisible();
}
test("rich options render descriptions + Recommended; the preview pane follows hover", async ({
page,
}) => {
await seedInbox(page, [RICH_ITEM]);
await openInbox(page, "How should I format the report?");
await expect(page.getByText("Compact and renders in the app")).toBeVisible();
await expect(page.getByText("Recommended")).toBeVisible();
// The pane opens on the first option holding a preview…
const pane = page.getByTestId("question-preview");
await expect(pane).toContainText("| env | status |");
// …and follows hover to the other option.
await page.getByRole("button", { name: /Plain text/ }).hover();
await expect(pane).toContainText("env: staging");
// Single-select still resolves on click, with the option's LABEL as the resolution.
const resolved = page.waitForRequest(
(r) => r.url().includes("/resolve") && r.method() === "POST",
);
await page.getByRole("button", { name: /Markdown table/ }).click();
expect((await resolved).postDataJSON().resolution).toBe("Markdown table");
await expect(page.getByText("How should I format the report?")).not.toBeVisible();
});
test("grouped questions step through the header chips and resolve as one answer map", async ({
page,
}) => {
await seedInbox(page, [GROUPED_ITEM]);
await openInbox(page, "Chart style?");
// Step 1: "Chart style · 1 of 2 · Distribution " — and no free-text row (allow_text: false).
const stepper = page.getByTestId("question-stepper");
await expect(stepper).toContainText("Chart style");
await expect(stepper).toContainText("1 of 2");
await expect(stepper).toContainText("Distribution ");
await expect(page.getByPlaceholder("Or type your own answer…")).not.toBeVisible();
// Answering advances to step 2 (its free-text escape is back — allow_text: true).
await page.getByRole("button", { name: "Bar", exact: true }).click();
await expect(stepper).toContainText("2 of 2");
await expect(page.getByText("Which distribution?")).toBeVisible();
await expect(page.getByPlaceholder("Or type your own answer…")).toBeVisible();
// steps back with the first answer re-askable; answer forward again.
await page.getByRole("button", { name: "Previous question" }).click();
await expect(stepper).toContainText("1 of 2");
await page.getByRole("button", { name: "Bar", exact: true }).click();
await expect(stepper).toContainText("2 of 2");
// The final answer resolves the whole card with a JSON map keyed by header.
const resolved = page.waitForRequest(
(r) => r.url().includes("/resolve") && r.method() === "POST",
);
await page.getByRole("button", { name: "Stacked", exact: true }).click();
expect((await resolved).postDataJSON().resolution).toBe(
JSON.stringify({ "Chart style": "Bar", Distribution: "Stacked" }),
);
await expect(page.getByText("Nothing pending.")).toBeVisible();
});
+4
View File
@@ -679,6 +679,8 @@ export function App() {
options: d.options || [],
allow_text: d.allow_text !== false,
multi: !!d.multi,
header: d.header || "",
questions: d.questions || [],
},
]);
break;
@@ -1601,6 +1603,8 @@ export function App() {
options: pendingQuestion.options,
allow_text: pendingQuestion.allow_text,
multi: pendingQuestion.multi,
header: pendingQuestion.header,
questions: pendingQuestion.questions,
}}
onResolve={(_id, answer) => answerQuestion(answer)}
compact
+7 -3
View File
@@ -1,4 +1,4 @@
import type { SessionInfo, WsEvent } from "./types";
import type { GroupedQuestion, QuestionOption, SessionInfo, WsEvent } from "./types";
declare const __COWORKER_DEV_TOKEN__: string;
@@ -1063,10 +1063,14 @@ export interface InboxItem {
created_at: string;
resolved_at: string | null;
visibility?: "inline" | "inbox";
// Question metadata (ask_user): quick-reply choices + a free-text escape.
options?: string[];
// Question metadata (ask_user): quick-reply choices + a free-text escape. Options may be rich
// {label, description, recommended, preview} objects (OPE-51); `questions` is the grouped form
// (stepper), whose resolution is a JSON object string keyed by header-or-question.
options?: QuestionOption[];
allow_text?: boolean;
multi?: boolean;
header?: string;
questions?: GroupedQuestion[];
// Kind-specific payload (directory: {path, writable}; …).
data?: Record<string, any>;
// Originating-session context (server-joined) so the Inbox is self-contained.
+264 -64
View File
@@ -1,12 +1,15 @@
import { useState, type ReactNode } from "react";
import type { InboxItem } from "../api";
import type { QuestionOption } from "../types";
import { humanizeApprovalTitle } from "../humanize";
import { PreviewBlock, scopeNote, TitleText } from "./ApprovalCard";
// One Inbox item, rendered identically in the Inbox list and inline in its own session view
// (answer-in-context). Resolving either place hits the same item id — first responder wins.
// Questions (ask_user) mirror Claude Code's AskUserQuestion: optional quick-reply options + an
// always-available free-text escape, with optional multi-select.
// always-available free-text escape, with optional multi-select. OPE-51 adds rich options
// ({label, description, recommended, preview}) and grouped questions (a stepper) — plain-string
// options and single questions render exactly as before.
// Shared styles (mock parity — same language as SourcesDrawer/PersonaView).
const SEC = "text-[11px] uppercase tracking-[0.05em] text-faint font-semibold";
@@ -24,6 +27,260 @@ const OPT_OFF = "border-line bg-paper text-ink hover:border-accent hover:bg-acce
const OPT_ON = "border-accent bg-accentSoft text-accent font-medium";
const INPUT =
"flex-1 min-w-0 rounded-lg bg-paper border border-line px-3 py-2 text-[13px] text-ink placeholder:text-faint outline-none focus:border-lineStrong";
// Rich options stack as full-width rows (pills can't hold a description line).
const ROW_BASE = "w-full text-left rounded-lg border px-3 py-2 transition-colors";
const ROW_OFF = "border-line bg-paper hover:border-accent hover:bg-accentSoft/50";
const ROW_ON = "border-accent bg-accentSoft";
// -- question normalization ---------------------------------------------------
interface NormOption {
label: string;
description: string;
recommended: boolean;
preview: string;
}
const normOption = (o: QuestionOption): NormOption =>
typeof o === "string"
? { label: o, description: "", recommended: false, preview: "" }
: {
label: o.label || "",
description: o.description || "",
recommended: !!o.recommended,
preview: o.preview || "",
};
interface QSpec {
question: string;
header: string;
options: NormOption[];
allowText: boolean;
multi: boolean;
}
// The item's question steps: the grouped `questions` list, or the singular fields as one step.
function specsFor(item: InboxItem): QSpec[] {
const grouped = item.questions || [];
if (grouped.length)
return grouped.map((q) => ({
question: q.question,
header: q.header || "",
options: (q.options || []).map(normOption),
allowText: q.allow_text !== false,
multi: !!q.multi,
}));
return [
{
question: item.title,
header: item.header || "",
options: (item.options || []).map(normOption),
allowText: item.allow_text !== false,
multi: !!item.multi,
},
];
}
// -- one question (options + free-text escape) --------------------------------
function QuestionBlock({ spec, onAnswer }: { spec: QSpec; onAnswer: (a: string) => void }) {
const [selected, setSelected] = useState<string[]>([]);
const [text, setText] = useState("");
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
const { options, multi } = spec;
// Any description/preview upgrades pills to stacked rows; any preview adds the side pane.
const rich = options.some((o) => o.description || o.preview);
const hasPreview = options.some((o) => o.preview);
const pick = (o: NormOption) => {
if (multi)
setSelected((s) => (s.includes(o.label) ? s.filter((x) => x !== o.label) : [...s, o.label]));
else onAnswer(o.label); // single-select answers immediately (pill behavior, unchanged)
};
// The pane follows hover/focus, falls back to the selected option, then the first preview.
const selIdx = options.findIndex((o) => selected.includes(o.label));
const previewIdx =
hoverIdx ?? (selIdx >= 0 && options[selIdx].preview ? selIdx : options.findIndex((o) => o.preview));
const preview = previewIdx >= 0 ? options[previewIdx].preview : "";
const recommendedTag = (
<span className="text-[10px] uppercase tracking-[0.04em] font-semibold text-ok bg-okSoft border border-okLine rounded-full px-1.5 py-px shrink-0">
Recommended
</span>
);
const optionRows = (
<div className={hasPreview ? "flex flex-col gap-2 min-w-0 sm:w-[46%] shrink-0" : "flex flex-col gap-2 mt-2.5"}>
{options.map((o, i) => {
const on = selected.includes(o.label);
return (
<button
key={o.label + i}
className={ROW_BASE + " " + (on ? ROW_ON : ROW_OFF)}
onMouseEnter={() => setHoverIdx(i)}
onMouseLeave={() => setHoverIdx(null)}
onFocus={() => setHoverIdx(i)}
onBlur={() => setHoverIdx(null)}
onClick={() => pick(o)}
>
<span
className={
"flex items-center gap-2 text-[13px] " + (on ? "text-accent font-medium" : "text-ink font-medium")
}
>
{multi && on && <span className="text-accent text-[11px] leading-none"></span>}
<span className="min-w-0 truncate">{o.label}</span>
{o.recommended && recommendedTag}
</span>
{o.description && (
<span className="block text-[12px] text-muted mt-0.5 leading-snug">{o.description}</span>
)}
</button>
);
})}
</div>
);
return (
<>
{options.length > 0 &&
(hasPreview ? (
// Two-pane: options left, preview right; stacks vertically on narrow widths.
<div className="flex flex-col sm:flex-row gap-3 mt-2.5">
{optionRows}
<pre
data-testid="question-preview"
className="flex-1 min-w-0 rounded-lg border border-line bg-paper p-3 text-[12px] leading-relaxed font-mono whitespace-pre overflow-auto max-h-72 text-ink"
>
{preview}
</pre>
</div>
) : rich ? (
optionRows
) : (
// Plain-string options: today's pills, untouched.
<div className="flex flex-wrap gap-2 mt-2.5">
{options.map((o) => {
const on = selected.includes(o.label);
return (
<button
key={o.label}
className={OPT_BASE + " " + (on ? OPT_ON : OPT_OFF)}
onClick={() => pick(o)}
>
{multi && on && <span className="text-accent text-[11px] leading-none"></span>}
{o.label}
</button>
);
})}
</div>
))}
{multi && options.length > 0 && (
<div className="mt-2.5">
<button
className={BTN_PRIMARY}
disabled={!selected.length}
onClick={() => onAnswer(selected.join(", "))}
>
Send{selected.length ? ` (${selected.length})` : ""}
</button>
</div>
)}
{(spec.allowText || options.length === 0) && (
<div className="flex items-center gap-2 mt-2.5">
<input
className={INPUT}
placeholder={options.length ? "Or type your own answer…" : "Your answer…"}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && text.trim()) onAnswer(text);
}}
/>
<button className={BTN_PRIMARY} disabled={!text.trim()} onClick={() => onAnswer(text)}>
Send
</button>
</div>
)}
</>
);
}
// -- the question card (single, or grouped as a stepper) ----------------------
function QuestionCard({
item,
onResolve,
chip,
}: {
item: InboxItem;
onResolve: (id: string, resolution: string) => void;
chip?: ReactNode;
}) {
const specs = specsFor(item);
const grouped = (item.questions?.length ?? 0) > 0;
const [step, setStep] = useState(0);
const [answers, setAnswers] = useState<Record<string, string>>({});
const spec = specs[Math.min(step, specs.length - 1)];
const next = step + 1 < specs.length ? specs[step + 1] : null;
// The answer map is keyed by header (falling back to the question text) — the same key the
// server's answer_result() hands the agent.
const keyFor = (s: QSpec) => s.header || s.question;
const submit = (a: string) => {
if (!grouped) {
onResolve(item.id, a);
return;
}
const all = { ...answers, [keyFor(spec)]: a };
setAnswers(all);
if (step + 1 < specs.length) setStep(step + 1);
else onResolve(item.id, JSON.stringify(all));
};
return (
<>
{/* Stepper chips (grouped): "Chart style · 1 of 2 · Distribution " — steps back. */}
<div className={SEC + " flex items-center gap-1.5"} data-testid={grouped ? "question-stepper" : undefined}>
{grouped && step > 0 && (
<button
className="text-faint hover:text-ink leading-none text-[13px]"
title="Previous question"
aria-label="Previous question"
onClick={() => setStep(step - 1)}
>
</button>
)}
<span className={grouped ? "text-accent" : undefined}>
{spec.header || (grouped ? `Question ${step + 1}` : "question")}
</span>
{grouped && (
<>
<span>·</span>
<span>
{step + 1} of {specs.length}
</span>
{next && (
<>
<span>·</span>
<span>{(next.header || `Question ${step + 2}`) + " "}</span>
</>
)}
</>
)}
</div>
<div className="text-[15px] font-semibold mt-0.5 leading-snug">{spec.question}</div>
{item.body ? (
<div className="text-[13px] text-muted mt-1 whitespace-pre-wrap">{item.body}</div>
) : null}
{chip}
{/* key={step} resets selection/text/hover state when the stepper advances */}
<QuestionBlock key={step} spec={spec} onAnswer={submit} />
</>
);
}
export function InboxItemCard({
item,
@@ -36,29 +293,7 @@ export function InboxItemCard({
chip?: ReactNode; // optional "go to session" affordance (shown in the Inbox list, not inline)
compact?: boolean;
}) {
const [answer, setAnswer] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const options = item.options || [];
const multi = !!item.multi;
const allowText = item.allow_text !== false;
const textRow = (placeholder: string) => (
<div className="flex items-center gap-2 mt-2.5">
<input
className={INPUT}
placeholder={placeholder}
value={answer}
onChange={(e) => setAnswer(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && answer.trim()) onResolve(item.id, answer);
}}
/>
<button className={BTN_PRIMARY} disabled={!answer.trim()} onClick={() => onResolve(item.id, answer)}>
Send
</button>
</div>
);
const isQuestion = item.kind === "question";
return (
<div
className={
@@ -82,7 +317,7 @@ export function InboxItemCard({
);
})()}
</div>
) : (
) : isQuestion ? null : ( // QuestionCard owns its header + title (stepper needs them)
<>
<div className={SEC}>{item.kind}</div>
<div className="text-[15px] font-semibold mt-0.5 leading-snug">{item.title}</div>
@@ -92,10 +327,10 @@ export function InboxItemCard({
<PreviewBlock text={item.data.arguments.content} />
) : item.kind === "approval" && item.data?.tool && typeof item.data.arguments?.command === "string" ? (
<PreviewBlock text={item.data.arguments.command} />
) : item.body ? (
) : !isQuestion && item.body ? (
<div className="text-[13px] text-muted mt-1 whitespace-pre-wrap">{item.body}</div>
) : null}
{chip}
{!isQuestion && chip}
{item.kind === "approval" ? (
<div className="flex items-center gap-2 mt-2.5 flex-wrap">
<button
@@ -123,43 +358,8 @@ export function InboxItemCard({
Deny
</button>
</div>
) : item.kind === "question" ? (
<>
{options.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2.5">
{options.map((opt) => {
const on = selected.includes(opt);
return (
<button
key={opt}
className={OPT_BASE + " " + (on ? OPT_ON : OPT_OFF)}
onClick={() => {
if (multi)
setSelected((s) => (on ? s.filter((x) => x !== opt) : [...s, opt]));
else onResolve(item.id, opt); // single-select resolves immediately
}}
>
{multi && on && <span className="text-accent text-[11px] leading-none"></span>}
{opt}
</button>
);
})}
</div>
)}
{multi && options.length > 0 && (
<div className="mt-2.5">
<button
className={BTN_PRIMARY}
disabled={!selected.length}
onClick={() => onResolve(item.id, selected.join(", "))}
>
Send{selected.length ? ` (${selected.length})` : ""}
</button>
</div>
)}
{(allowText || options.length === 0) &&
textRow(options.length ? "Or type your own answer…" : "Your answer…")}
</>
) : isQuestion ? (
<QuestionCard item={item} onResolve={onResolve} chip={chip} />
) : item.kind === "directory" ? (
<div className="flex items-center gap-2 mt-2.5">
<button
+21 -1
View File
@@ -131,9 +131,29 @@ export type Item =
// A live ask_user prompt (attended sessions answer inline; unattended ones route to the Inbox).
kind: "question";
question: string;
options?: string[];
options?: QuestionOption[];
allow_text?: boolean;
multi?: boolean;
header?: string;
questions?: GroupedQuestion[];
resolved?: string;
}
| { kind: "notice"; tone: "info" | "warn"; text: string; retriable?: boolean };
// -- ask_user question metadata (OPE-51) --------------------------------------
// An option is a plain string (renders as today's pill) or a rich object: `label` is the answer
// value, `description` renders under it, `recommended` adds the green tag, `preview` is monospace
// text shown in the side pane (≥1 preview switches the card to the two-pane layout).
export type QuestionOption =
| string
| { label: string; description?: string; recommended?: boolean; preview?: string };
// One step of a grouped ask_user call (up to 4, rendered as a stepper). The answer map is keyed
// by `header` (falling back to `question`).
export interface GroupedQuestion {
question: string;
header?: string;
options?: QuestionOption[];
allow_text?: boolean;
multi?: boolean;
}