From eb3ea661112e64a2d32d0e29185e589dd808dbb1 Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Sun, 12 Jul 2026 10:01:05 +0200 Subject: [PATCH] Show the user's answer as a reply after an AskUserQuestion resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an agent asks an AskUserQuestion-style multiple-choice prompt (request_kind "user-input"), the interactive picker renders above the composer. Once answered, that panel disappears and the transcript row collapsed to a static muted "Answered" string — so the thread never showed what was picked and it didn't read as a reply. The selection was already stored on the resolved request as the decision's updated_input.answers. Render it back as a right-aligned reply bubble that mirrors UserMessage styling, so a submitted answer reads as a genuine user reply. - New UserInputAnswer component reads updated_input.answers off the resolved request; multi-question prompts get each answer labeled by its question header; falls back to the plain "Answered" marker when a resolved request carries no readable answer (e.g. cancelled). - MessageList user-input branch: pointer while pending, "Submitting answers…" while responding, UserInputAnswer once resolved. - Tests cover responding marker, resolved answer echo, multi-question headers, and the no-answer fallback. - Dev mock seeds a resolved AskUserQuestion for design QA. --- src/components/chat/MessageList.test.tsx | 68 +++++++++++++ src/components/chat/MessageList.tsx | 31 ++++-- src/components/chat/UserInputAnswer.tsx | 124 +++++++++++++++++++++++ src/dev/tauri-mock.ts | 39 +++++++ 4 files changed, 251 insertions(+), 11 deletions(-) create mode 100644 src/components/chat/UserInputAnswer.tsx diff --git a/src/components/chat/MessageList.test.tsx b/src/components/chat/MessageList.test.tsx index f85ef35d..78daeeb3 100644 --- a/src/components/chat/MessageList.test.tsx +++ b/src/components/chat/MessageList.test.tsx @@ -168,6 +168,74 @@ describe("MessageList dispatch", () => { expect(screen.queryByText(/Approval requested/)).toBeNull(); }); + it("shows a submitting marker while a user-input request is responding", () => { + renderList([ + askReq({ + resolution: { + state: "responding", + decision: { decision: "allow", updated_input: { answers: {} } }, + }, + }), + ]); + expect(screen.getByText(/Submitting answers/)).toBeInTheDocument(); + }); + + it("echoes the user's answer as a reply once a user-input request resolves", () => { + renderList([ + askReq({ + resolution: { + state: "resolved", + decision: { + decision: "allow", + updated_input: { + questions: [], + answers: { "Framework?": "React" }, + }, + }, + }, + }), + ]); + // The chosen option is now visible in the transcript... + expect(screen.getByText("React")).toBeInTheDocument(); + // ...instead of the old opaque "Answered" marker. + expect(screen.queryByText("Answered")).toBeNull(); + }); + + it("labels each answer by its question header when several were asked", () => { + renderList([ + askReq({ + payload: { + questions: [ + { header: "Framework", question: "Framework?", options: [] }, + { header: "Styling", question: "Styling?", options: [] }, + ], + }, + resolution: { + state: "resolved", + decision: { + decision: "allow", + updated_input: { + answers: { "Framework?": "React", "Styling?": "Tailwind" }, + }, + }, + }, + }), + ]); + expect(screen.getByText("React")).toBeInTheDocument(); + expect(screen.getByText("Tailwind")).toBeInTheDocument(); + expect(screen.getByText("Framework")).toBeInTheDocument(); + expect(screen.getByText("Styling")).toBeInTheDocument(); + }); + + it("falls back to the plain marker when a resolved user-input carries no answer", () => { + renderList([ + askReq({ + resolution: { state: "resolved", decision: { decision: "cancel" } }, + }), + ]); + expect(screen.getByText("Answered")).toBeInTheDocument(); + }); + it("falls back to PermissionRequestBlock for unknown request_kind", () => { renderList([genericReq()]); expect(screen.getByText(/Approval requested/)).toBeInTheDocument(); diff --git a/src/components/chat/MessageList.tsx b/src/components/chat/MessageList.tsx index 98aeba45..828d47a6 100644 --- a/src/components/chat/MessageList.tsx +++ b/src/components/chat/MessageList.tsx @@ -37,6 +37,7 @@ import { StreamingMarker } from "./StreamingMarker"; import { SubagentsCard } from "./SubagentsCard"; import { isTaskSummaryTool, TaskSummaryCard } from "./TaskSummaryCard"; import { ToolCallCard } from "./ToolCallCard"; +import { UserInputAnswer } from "./UserInputAnswer"; import { UserMessage } from "./UserMessage"; import { WorkflowRunCard } from "./WorkflowRunCard"; import { transcriptFadeEnabled } from "./transcript-fade"; @@ -620,18 +621,26 @@ function renderAssistantBody( /> ); case "user-input": { - // The interactive panel for user-input prompts lives above the + // The interactive picker for user-input prompts lives above the // composer (ComposerPendingInputPanel); the transcript keeps a - // one-line pointer so the thread of events stays readable. - const label = - item.resolution.state === "pending" - ? "Input requested — answer above the composer." - : item.resolution.state === "responding" - ? "Submitting answers…" - : "Answered"; - return ( -
{label}
- ); + // one-line pointer while it's still open. + if (item.resolution.state === "pending") { + return ( +
+ Input requested — answer above the composer. +
+ ); + } + if (item.resolution.state === "responding") { + return ( +
+ Submitting answers… +
+ ); + } + // Resolved: echo the user's selection as a reply bubble so the + // transcript actually shows that they answered. + return ; } default: return ( diff --git a/src/components/chat/UserInputAnswer.tsx b/src/components/chat/UserInputAnswer.tsx new file mode 100644 index 00000000..100f866e --- /dev/null +++ b/src/components/chat/UserInputAnswer.tsx @@ -0,0 +1,124 @@ +import { memo } from "react"; + +import type { PermissionRequestItem } from "@/lib/agent-chat/types"; + +/** + * Right-aligned reply bubble that echoes the user's answer to an + * AskUserQuestion prompt (`request_kind: "user-input"`) once it has + * resolved. The interactive picker lives above the composer + * (ComposerPendingInputPanel); after the user submits, that panel is + * gone, so without this the transcript only showed a muted "Answered" + * marker and it never looked like the user replied. + * + * The answer is already stored on the resolved request — the sidecar + * relays the picker's `AskUserQuestionOutput` back as the decision's + * `updated_input`, whose `answers` map is keyed by question text with + * the chosen option label(s) as the value (comma-joined for + * multiSelect). We read it back here instead of re-deriving anything. + * + * Styling deliberately mirrors `UserMessage` (same card fill, border, + * and the asymmetric `14px 14px 5px 14px` radius that tucks toward the + * composer) so a submitted answer reads as a genuine user reply. All + * colors are theme tokens. + */ +export const UserInputAnswer = memo(function UserInputAnswer({ + item, +}: { + item: PermissionRequestItem; +}) { + const lines = extractAnswerLines(item); + + // Resolved without a readable answer (e.g. the prompt was cancelled, + // or a future decision shape we don't parse) — fall back to the + // original muted marker so the row is never blank. + if (lines.length === 0) { + return
Answered
; + } + + // With a single question the value speaks for itself; with several, + // label each answer by its question header so the reply stays legible. + const showHeaders = lines.length > 1; + + return ( +
+
+
+ {lines.map((line, i) => ( +
+ {showHeaders && line.header ? ( +
+ {line.header} +
+ ) : null} +
+ {line.value} +
+
+ ))} +
+
+
+ ); +}); + +interface AnswerLine { + /** Short question header (the chip label), when we can recover it from + * the original prompt payload. `null` falls back to no label. */ + header: string | null; + /** The chosen option label(s) — the user's actual answer. */ + value: string; +} + +/** Pull the human-readable answer(s) off a resolved user-input request. + * Returns `[]` for any non-resolved / non-`allow` / unparseable shape so + * the caller can degrade to the plain marker. */ +function extractAnswerLines(item: PermissionRequestItem): AnswerLine[] { + if (item.resolution.state !== "resolved") return []; + const { decision } = item.resolution; + if (decision.decision !== "allow") return []; + + const answers = readAnswers(decision.updated_input); + if (!answers) return []; + + const headerByQuestion = buildHeaderMap(item.payload); + + return Object.entries(answers) + .filter( + ([, value]) => typeof value === "string" && value.trim().length > 0, + ) + .map(([question, value]) => ({ + header: headerByQuestion.get(question) ?? null, + value: value as string, + })); +} + +/** The `answers` map off an `AskUserQuestionOutput`, or `null` if the + * payload isn't the shape we expect. */ +function readAnswers(input: unknown): Record | null { + if (!isRecord(input)) return null; + const answers = input["answers"]; + if (!isRecord(answers)) return null; + return answers; +} + +/** Map question text → its short `header` chip, read from the original + * prompt payload so multi-question replies can be labeled. */ +function buildHeaderMap(payload: unknown): Map { + const map = new Map(); + if (!isRecord(payload)) return map; + const questions = payload["questions"]; + if (!Array.isArray(questions)) return map; + for (const q of questions) { + if (!isRecord(q)) continue; + const question = q["question"]; + const header = q["header"]; + if (typeof question === "string" && typeof header === "string") { + map.set(question, header); + } + } + return map; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/dev/tauri-mock.ts b/src/dev/tauri-mock.ts index 305196f4..2e06b904 100644 --- a/src/dev/tauri-mock.ts +++ b/src/dev/tauri-mock.ts @@ -626,6 +626,45 @@ function mockChatTranscript(): string[] { images: [{ path: MOCK_USER_IMAGE_DATA_URL, media_type: "image/png" }], }); for (const envelope of subagentTurnEnvelopes(T, subTurnId)) push(envelope); + // A resolved AskUserQuestion (`user-input`) so the transcript shows the + // user's answer echoed back as a reply bubble (design QA for the + // UserInputAnswer row). + push({ + type: "request_opened", + thread_id: T, + turn_id: subTurnId, + request_id: "seed-user-input-1", + request_kind: "user-input", + payload: { + questions: [ + { + header: "Approach", + question: "How should we handle the clipboard fallback?", + multiSelect: false, + options: [ + { label: "Feature-detect and degrade", description: "" }, + { label: "Always use the legacy path", description: "" }, + ], + }, + ], + }, + tool_use_id: null, + }); + push({ + type: "request_resolved", + thread_id: T, + request_id: "seed-user-input-1", + decision: { + decision: "allow", + updated_input: { + questions: [], + answers: { + "How should we handle the clipboard fallback?": + "Feature-detect and degrade", + }, + }, + }, + }); push({ type: "turn_completed", thread_id: T,