diff --git a/coworker/engine.py b/coworker/engine.py index 341eba05..9e07d712 100644 --- a/coworker/engine.py +++ b/coworker/engine.py @@ -971,6 +971,13 @@ async def _handle_ask_user(self, tool_call: ToolCall) -> AsyncIterator[Event]: from the Inbox when unattended), and return it as the tool result.""" args = tool_call.arguments or {} question = str(args.get("question", "")).strip() + # Grouped form (OPE-51): `questions` alone is a valid call — the singular field may be + # empty. The asker normalizes/validates the entries; here only "is anything asked?". + if not question: + for entry in args.get("questions") or []: + if isinstance(entry, dict) and str(entry.get("question", "")).strip(): + question = str(entry["question"]).strip() + break if self.question_asker is None or not question: result: dict[str, Any] = { "answer": "", @@ -992,7 +999,7 @@ async def _handle_ask_user(self, tool_call: ToolCall) -> AsyncIterator[Event]: "error": "no response", } - status = "ok" if result.get("answer") else "denied" + status = "ok" if (result.get("answer") or result.get("answers")) else "denied" self.messages.append(_tool_result_message(tool_call, result)) self._audit( tool_call, diff --git a/coworker/inbox.py b/coworker/inbox.py index 924e707a..2354db47 100644 --- a/coworker/inbox.py +++ b/coworker/inbox.py @@ -78,11 +78,19 @@ class InboxItem: tool_call_id: Optional[str] = None # Question metadata (ask_user): optional quick-reply choices + a free-text escape, mirroring # the structured-but-always-answerable shape of Claude Code's AskUserQuestion. - options: list[str] = field(default_factory=list) + # An option is a plain string OR a rich {label, description, recommended, preview} object + # (OPE-51); old persisted items hold strings and stay valid. + options: list = field(default_factory=list) allow_text: bool = ( True # accept a typed answer even when options exist (the "Other" escape) ) multi: bool = False # allow choosing more than one option + header: str = "" # short chip label for the card ("Region") + # Grouped form (OPE-51): up to 4 {question, header, options, allow_text, multi} entries + # rendered as a stepper. When non-empty the singular title/options fields above still hold + # the FIRST question (so old surfaces and channel mirrors degrade to something sensible), + # and the resolution is a JSON object string keyed by header-or-question. + questions: list[dict] = field(default_factory=list) # Kind-specific payload (directory: suggested path/writable; plan: the plan text; …). data: dict[str, Any] = field(default_factory=dict) @@ -126,6 +134,8 @@ def add( options=None, allow_text: bool = True, multi: bool = False, + header: str = "", + questions=None, tool_call_id: Optional[str] = None, ) -> InboxItem: # Idempotent by (session_id, tool_call_id): a durable resume re-raises the same prompt, and @@ -146,6 +156,8 @@ def add( options=list(options or []), allow_text=bool(allow_text), multi=bool(multi), + header=str(header or ""), + questions=list(questions or []), tool_call_id=tool_call_id, ) with self._lock: @@ -194,6 +206,8 @@ def add_question( options=None, allow_text=True, multi=False, + header="", + questions=None, tool_call_id=None, ) -> InboxItem: return self.add( @@ -206,6 +220,8 @@ def add_question( options=options, allow_text=allow_text, multi=multi, + header=header, + questions=questions, tool_call_id=tool_call_id, ) diff --git a/coworker/interactions.py b/coworker/interactions.py index b8a93960..f40943b8 100644 --- a/coworker/interactions.py +++ b/coworker/interactions.py @@ -17,6 +17,7 @@ from typing import Optional from .inbox import KIND_APPROVAL, KIND_QUESTION +from .tools.ask import option_label @dataclass @@ -48,7 +49,15 @@ def buttons_for(item) -> list[Button]: Button("Approve", encode(item.id, "allow")), Button("Deny", encode(item.id, "deny")), ] + if item.kind == KIND_QUESTION and getattr(item, "questions", None): + # Grouped questions (OPE-51): one button row can't answer 2+ questions — send plain text + # with the open-the-app hint instead. + return [] if item.kind == KIND_QUESTION and getattr(item, "options", None): - # One button per option; the resolution IS the chosen option text (what the agent gets). - return [Button(opt, encode(item.id, opt)) for opt in item.options] + # One button per option; the resolution IS the chosen option's label (what the agent + # gets). Rich {label, description, …} options button as their label. + return [ + Button(option_label(opt), encode(item.id, option_label(opt))) + for opt in item.options + ] return [] diff --git a/coworker/server/app.py b/coworker/server/app.py index 65eea877..e262ad82 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -1603,15 +1603,17 @@ async def approver(_request) -> ApprovalOutcome: async def question_asker(args: dict, tool_call_id=None) -> dict: # ask_user (engine does NOT emit the event — we do, only when attended). + from ..tools.ask import answer_result, question_item_fields + + fields = question_item_fields(args) + if fields is None: # engine guards too; belt-and-braces + return {"answer": "", "error": "no question"} item = manager.inbox.add_question( session_id, - str(args.get("question", "")), inbox=_route(), visibility=_visibility(), - options=list(args.get("options") or []), - allow_text=bool(args.get("allow_text", True)), - multi=bool(args.get("multi", False)), tool_call_id=tool_call_id, + **fields, ) if item.state == "pending": manager.persist_session(session_id) @@ -1626,11 +1628,12 @@ async def question_asker(args: dict, tool_call_id=None) -> dict: "options": item.options, "allow_text": item.allow_text, "multi": item.multi, - "header": str(args.get("header", "")), + "header": item.header, + "questions": item.questions, }, } ) - return {"answer": await manager.inbox.wait(item.id)} + return answer_result(item.questions, await manager.inbox.wait(item.id)) async def directory_requester(args: dict, tool_call_id=None) -> dict: # The engine has already emitted DIRECTORY_REQUESTED. Park, await, then apply the grant. diff --git a/coworker/server/manager.py b/coworker/server/manager.py index ad76e996..6ab60cd3 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -751,27 +751,26 @@ def inbox_question_asker(self, session_id: str, agent: str): async def ask( args: dict[str, Any], tool_call_id: Optional[str] = None ) -> dict[str, Any]: - question = str(args.get("question", "")).strip() - if not question: + from ..tools.ask import answer_result, question_item_fields + + fields = question_item_fields(args) + if fields is None: return {"answer": "", "error": "no question"} inbox_name = self.inbox_routing.route_for(session_id, agent) item = self.inbox.add_question( session_id, - title=question, inbox=inbox_name, - options=list(args.get("options") or []), - allow_text=bool(args.get("allow_text", True)), - multi=bool(args.get("multi", False)), tool_call_id=tool_call_id, + **fields, ) if ( item.state != "pending" ): # durable resume re-raised an already-answered prompt - return {"answer": item.resolution or ""} + return answer_result(item.questions, item.resolution) self.persist_session(session_id) # the pending tool call is now on disk await self.mirror_inbox_item(item) answer = await self.inbox.wait(item.id) - return {"answer": answer} + return answer_result(item.questions, answer) return ask diff --git a/coworker/tools/ask.py b/coworker/tools/ask.py index 14deebfa..3268109d 100644 --- a/coworker/tools/ask.py +++ b/coworker/tools/ask.py @@ -6,36 +6,136 @@ question becomes an Inbox item (answerable inline in the live session, or from the Inbox when the session runs unattended), the agent suspends until it's resolved, and the answer comes back as the tool result. The callable here is only a schema carrier + a safe fallback. + +OPE-51 upgrades: options may be rich objects ({label, description, recommended, preview}) instead +of plain strings, and `questions` groups up to 4 questions into ONE call (rendered as a stepper — +one agent round-trip instead of several). Plain-string options and the singular `question` form +stay valid: old sessions and simple asks render exactly as before. """ from __future__ import annotations +import json + from aisuite.agents import ToolMetadata, tool +# How many questions one grouped call may carry (stepper chips get unreadable past this). +MAX_GROUPED_QUESTIONS = 4 + +# An option is a plain string OR a rich object. `label` is what the user picks (and what comes +# back as the answer); `description` renders under it; `recommended` adds the green tag (put the +# recommended option first); `preview` is monospace text shown in the side pane (code, config, +# ASCII mockups, SQL — any text; when ≥1 option has one the card switches to two-pane layout). +_OPTION_SCHEMA = { + "anyOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "label": {"type": "string"}, + "description": {"type": "string"}, + "recommended": {"type": "boolean"}, + "preview": {"type": "string"}, + }, + "required": ["label"], + }, + ] +} + +# Explicit schema (same pattern as todo.py): the string-or-object option union and the nested +# `questions` array can't be auto-generated from the signature reliably. +_ASK_SCHEMA = { + "type": "function", + "function": { + "name": "ask_user", + "description": ( + "Ask the user one or more questions and wait for their answer. Use for decisions or " + "information only the user can provide. Group related questions (up to " + f"{MAX_GROUPED_QUESTIONS}) into one call via `questions` instead of asking serially." + ), + "parameters": { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "The full question, in plain language (single-question form).", + }, + "options": { + "type": "array", + "items": _OPTION_SCHEMA, + "description": ( + "Optional quick-reply choices: plain strings, or objects with `label` " + "(required — this is the answer value), `description` (why/when to pick " + "it), `recommended` (green tag; list that option first), and `preview` " + "(monospace text — code, config, a mockup — shown in a side pane)." + ), + }, + "allow_text": { + "type": "boolean", + "description": ( + "Keep a free-text answer available even when options exist (default true; " + "the \"Other / type your own\" escape). Set false only when the options " + "are exhaustive." + ), + }, + "multi": { + "type": "boolean", + "description": "Allow the user to pick more than one option.", + }, + "header": { + "type": "string", + "description": "Short (≤ ~12 char) chip label for the card, e.g. \"Region\".", + }, + "questions": { + "type": "array", + "maxItems": MAX_GROUPED_QUESTIONS, + "items": { + "type": "object", + "properties": { + "question": {"type": "string"}, + "header": { + "type": "string", + "description": ( + "Short (≤ ~12 char) label — names this step in the stepper " + "chips and keys its answer in the result." + ), + }, + "options": {"type": "array", "items": _OPTION_SCHEMA}, + "allow_text": {"type": "boolean"}, + "multi": {"type": "boolean"}, + }, + "required": ["question"], + }, + "description": ( + f"Grouped form: up to {MAX_GROUPED_QUESTIONS} questions asked in ONE " + "round-trip, rendered as a stepper. When set, the singular " + "question/options fields are ignored." + ), + }, + }, + "required": [], + }, + }, +} + def ask_user_tool() -> object: def ask_user( - question: str, - options: list[str] | None = None, + question: str = "", + options: list | None = None, allow_text: bool = True, multi: bool = False, header: str = "", + questions: list | None = None, ) -> dict: """Ask the user a question and wait for their answer — use when you genuinely need a human decision or information you can't infer (a preference, a missing fact, a choice between real alternatives). Prefer this over guessing or stalling. - - `question`: the full question, in plain language. - - `options`: optional quick-reply choices. Offer them when the answer is one of a few - discrete alternatives; leave empty for an open-ended question. - - `allow_text`: keep a free-text answer available even when you give options (the default; - this is the "Other / type your own" escape). Set False only when the options are - exhaustive and a typed answer would be meaningless. - - `multi`: allow the user to pick more than one option. - - `header`: a short (≤ ~12 char) label for the Inbox card chip, e.g. "Region". - - Returns `{"answer": "..."}` — the chosen option(s) or the typed text. Don't ask what you can - reasonably decide yourself; reserve this for choices that are actually the user's to make. + Single form returns `{"answer": "..."}` — the chosen option label(s) or the typed text. + Grouped form (`questions`) returns `{"answers": {"
": "..."}}` — one + entry per question. Don't ask what you can reasonably decide yourself; reserve this for + choices that are actually the user's to make. """ # Real handling lives in the engine (it needs the out-of-band Inbox round-trip). This body # only runs if no question_asker is wired (e.g. a headless surface). @@ -44,7 +144,7 @@ def ask_user( "error": "asking the user isn't available in this surface", } - return tool( + wrapped = tool( ask_user, metadata=ToolMetadata( category="interaction", @@ -56,3 +156,100 @@ def ask_user( ), ), ) + wrapped.__coworker_schema__ = _ASK_SCHEMA + return wrapped + + +def normalize_option(opt) -> dict: + """One option in canonical dict form: {label, description, recommended, preview}. Plain + strings become {label: str, ...empty}. The label doubles as the answer value everywhere + (buttons, pills, resolutions), so it is always a non-empty-able str.""" + if isinstance(opt, dict): + return { + "label": str(opt.get("label", "")), + "description": str(opt.get("description", "")), + "recommended": bool(opt.get("recommended", False)), + "preview": str(opt.get("preview", "")), + } + return {"label": str(opt), "description": "", "recommended": False, "preview": ""} + + +def option_label(opt) -> str: + """The answer value / button text for a str-or-dict option.""" + return str(opt.get("label", "")) if isinstance(opt, dict) else str(opt) + + +def normalize_questions(raw) -> list[dict]: + """The grouped `questions` arg in canonical form (capped, blanks dropped). Each entry: + {question, header, options: [canonical option], allow_text, multi}.""" + out: list[dict] = [] + for entry in list(raw or [])[:MAX_GROUPED_QUESTIONS]: + if not isinstance(entry, dict): + continue + q = str(entry.get("question", "")).strip() + if not q: + continue + out.append( + { + "question": q, + "header": str(entry.get("header", "")), + "options": [normalize_option(o) for o in entry.get("options") or []], + "allow_text": bool(entry.get("allow_text", True)), + "multi": bool(entry.get("multi", False)), + } + ) + return out + + +def question_item_fields(args: dict) -> dict | None: + """`InboxStore.add_question` kwargs from raw ask_user args, or None when nothing was asked. + A grouped call surfaces its FIRST question as title/options too, so legacy surfaces (channel + mirrors, old persisted-item readers) degrade to a sensible single question.""" + grouped = normalize_questions(args.get("questions")) + if grouped: + first = grouped[0] + return { + "title": first["question"], + "options": first["options"], + "allow_text": first["allow_text"], + "multi": first["multi"], + "header": first["header"], + "questions": grouped, + } + question = str(args.get("question", "")).strip() + if not question: + return None + return { + "title": question, + # Strings pass through untouched (simple asks keep rendering as today's pills); + # rich objects are canonicalized so downstream never meets a half-filled dict. + "options": [ + o if isinstance(o, str) else normalize_option(o) + for o in args.get("options") or [] + ], + "allow_text": bool(args.get("allow_text", True)), + "multi": bool(args.get("multi", False)), + "header": str(args.get("header", "")), + "questions": [], + } + + +def answer_result(item_questions: list, resolution: str | None) -> dict: + """Shape the ask_user tool result from an Inbox item's resolution string. Grouped items + resolve with a JSON object string keyed by header-or-question → `{"answers": {...}}`; + everything else returns the plain `{"answer": str}` shape.""" + if item_questions: + try: + parsed = json.loads(resolution or "") + except (ValueError, TypeError): + parsed = None + if isinstance(parsed, dict): + return {"answers": {str(k): str(v) for k, v in parsed.items()}} + if resolution: + # Answered from a text-only surface (e.g. a mirrored channel): attribute the lone + # answer to the first question rather than losing it. + first = item_questions[0] if isinstance(item_questions[0], dict) else {} + key = str(first.get("header") or first.get("question") or "answer") + return {"answers": {key: str(resolution)}} + return {"answer": ""} + return {"answer": resolution or ""} diff --git a/surfaces/gui/e2e/ask-upgrades.spec.ts b/surfaces/gui/e2e/ask-upgrades.spec.ts new file mode 100644 index 00000000..018450a5 --- /dev/null +++ b/surfaces/gui/e2e/ask-upgrades.spec.ts @@ -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[]) { + 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(); +}); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index d18825cf..c2df912b 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -695,6 +695,8 @@ export function App() { options: d.options || [], allow_text: d.allow_text !== false, multi: !!d.multi, + header: d.header || "", + questions: d.questions || [], }, ]); break; @@ -1645,6 +1647,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 diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index ad9debd5..fc9c0672 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -1,4 +1,4 @@ -import type { SessionInfo, WsEvent } from "./types"; +import type { GroupedQuestion, QuestionOption, SessionInfo, WsEvent } from "./types"; declare const __COWORKER_DEV_TOKEN__: string; @@ -1239,10 +1239,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; // Originating-session context (server-joined) so the Inbox is self-contained. diff --git a/surfaces/gui/src/components/InboxItemCard.tsx b/surfaces/gui/src/components/InboxItemCard.tsx index a4075636..9512b8e0 100644 --- a/surfaces/gui/src/components/InboxItemCard.tsx +++ b/surfaces/gui/src/components/InboxItemCard.tsx @@ -1,5 +1,6 @@ import { useState, type ReactNode } from "react"; import type { InboxItem } from "../api"; +import type { QuestionOption } from "../types"; import { humanizeApprovalTitle } from "../humanize"; import { approvalActionLabels, @@ -12,7 +13,9 @@ import { // 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"; @@ -30,6 +33,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([]); + const [text, setText] = useState(""); + const [hoverIdx, setHoverIdx] = useState(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 = ( + + Recommended + + ); + + const optionRows = ( +
+ {options.map((o, i) => { + const on = selected.includes(o.label); + return ( + + ); + })} +
+ ); + + return ( + <> + {options.length > 0 && + (hasPreview ? ( + // Two-pane: options left, preview right; stacks vertically on narrow widths. +
+ {optionRows} +
+              {preview}
+            
+
+ ) : rich ? ( + optionRows + ) : ( + // Plain-string options: today's pills, untouched. +
+ {options.map((o) => { + const on = selected.includes(o.label); + return ( + + ); + })} +
+ ))} + {multi && options.length > 0 && ( +
+ +
+ )} + {(spec.allowText || options.length === 0) && ( +
+ setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && text.trim()) onAnswer(text); + }} + /> + +
+ )} + + ); +} + +// -- 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>({}); + 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. */} +
+ {grouped && step > 0 && ( + + )} + + {spec.header || (grouped ? `Question ${step + 1}` : "question")} + + {grouped && ( + <> + · + + {step + 1} of {specs.length} + + {next && ( + <> + · + {(next.header || `Question ${step + 2}`) + " ›"} + + )} + + )} +
+
{spec.question}
+ {item.body ? ( +
{item.body}
+ ) : null} + {chip} + {/* key={step} resets selection/text/hover state when the stepper advances */} + + + ); +} export function InboxItemCard({ item, @@ -42,29 +299,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([]); - const options = item.options || []; - const multi = !!item.multi; - const allowText = item.allow_text !== false; - - const textRow = (placeholder: string) => ( -
- setAnswer(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && answer.trim()) onResolve(item.id, answer); - }} - /> - -
- ); - + const isQuestion = item.kind === "question"; return (
- ) : ( + ) : isQuestion ? null : ( // QuestionCard owns its header + title (stepper needs them) <>
{item.kind}
{item.title}
@@ -101,10 +336,10 @@ export function InboxItemCard({ ) : item.kind === "approval" && item.data?.tool && typeof item.data.arguments?.command === "string" ? ( - ) : item.body ? ( + ) : !isQuestion && item.body ? (
{item.body}
) : null} - {chip} + {!isQuestion && chip} {item.kind === "approval" ? (
- ) : item.kind === "question" ? ( - <> - {options.length > 0 && ( -
- {options.map((opt) => { - const on = selected.includes(opt); - return ( - - ); - })} -
- )} - {multi && options.length > 0 && ( -
- -
- )} - {(allowText || options.length === 0) && - textRow(options.length ? "Or type your own answer…" : "Your answer…")} - + ) : isQuestion ? ( + ) : item.kind === "directory" ? (