mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-10 22:20:27 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user