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,