Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/components/chat/MessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
31 changes: 20 additions & 11 deletions src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div className="py-0.5 text-xs text-muted-foreground">{label}</div>
);
// one-line pointer while it's still open.
if (item.resolution.state === "pending") {
return (
<div className="py-0.5 text-xs text-muted-foreground">
Input requested — answer above the composer.
</div>
);
}
if (item.resolution.state === "responding") {
return (
<div className="py-0.5 text-xs text-muted-foreground">
Submitting answers…
</div>
);
}
// Resolved: echo the user's selection as a reply bubble so the
// transcript actually shows that they answered.
return <UserInputAnswer item={item} />;
}
default:
return (
Expand Down
124 changes: 124 additions & 0 deletions src/components/chat/UserInputAnswer.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="py-0.5 text-xs text-muted-foreground">Answered</div>;
}

// 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 (
<div className="flex justify-end">
<div className="flex max-w-[82%] flex-col items-end gap-1">
<div className="flex flex-col gap-2 rounded-[14px_14px_5px_14px] border border-border/60 bg-card px-[15px] py-[11px] text-[13.5px] leading-[1.55] text-foreground">
{lines.map((line, i) => (
<div key={i} className="flex flex-col gap-0.5">
{showHeaders && line.header ? (
<div className="text-[10.5px] font-medium uppercase tracking-wide text-muted-foreground">
{line.header}
</div>
) : null}
<div className="whitespace-pre-wrap break-words">
{line.value}
</div>
</div>
))}
</div>
</div>
</div>
);
});

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<string, unknown> | 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<string, string> {
const map = new Map<string, string>();
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<string, unknown> {
return typeof value === "object" && value !== null;
}
39 changes: 39 additions & 0 deletions src/dev/tauri-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down