fix: v2.7.5 — the chat shows each AskUserQuestion with the user's answer
- Chat: an AskUserQuestion is its own card (AskCard in MessageList.tsx), not part of the collapsed work line — each question with the option picked or the text typed, "declined" when refused. The transcript route passes the answers from Claude Code's toolUseResult (askAnswers, beside diff). - Chat: the activity panel keeps the user's open/close per session for the tab, and opens by default only once the session has an output. - Workspace home: the sent prompt's draft is dropped even though opening the session unmounts the home. - Terminal: Ctrl+V with text on the clipboard pastes the text; only an empty clipboard still sends ^V. - AGENTS.md / CLAUDE.md: run only the tests a change touches while iterating, the full suite once before committing.
This commit is contained in:
@@ -15,7 +15,9 @@
|
||||
* files ends with Claude Desktop's "Edited N files" card: every file with the
|
||||
* lines added and removed (the tool results' `diff`); a click opens the file
|
||||
* when the caller can (`onOpenFile`), and a row (or the card's header, for
|
||||
* every file in it) drags into a prompt box as an `@path` attachment. A thinking block that carries text is
|
||||
* every file in it) drags into a prompt box as an `@path` attachment. An
|
||||
* AskUserQuestion is its own card (`AskCard`), not part of the collapsed work
|
||||
* line: each question with the answer the user picked or typed. A thinking block that carries text is
|
||||
* the model's narration to the user (Claude Code ≥ 2.1.280 stores real
|
||||
* thinking empty) and reads as prose, like Claude Desktop shows it; a live
|
||||
* chat (`live`) ends, until its turn is over, with what the model is doing
|
||||
@@ -95,6 +97,8 @@ import {
|
||||
Check,
|
||||
Hand,
|
||||
SquareTerminal,
|
||||
MessageCircleQuestion,
|
||||
CornerDownRight,
|
||||
} from "lucide-react";
|
||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
||||
|
||||
@@ -461,6 +465,7 @@ type Item =
|
||||
| { kind: "user"; key: string; msg: TranscriptMessage }
|
||||
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
|
||||
| { kind: "work"; key: string; blocks: TranscriptContent[] }
|
||||
| { kind: "ask"; key: string; block: TranscriptContent }
|
||||
| { kind: "edits"; key: string; blocks: TranscriptContent[] }
|
||||
| { kind: "done"; key: string; ms: number; output: number };
|
||||
|
||||
@@ -522,7 +527,10 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
msg.content.forEach((block, b) => {
|
||||
const last = items[items.length - 1];
|
||||
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
|
||||
if (block.type === "tool_use") {
|
||||
// A question to the user is a turn of the conversation, not hidden work.
|
||||
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
|
||||
items.push({ kind: "ask", key: `${key}:${b}`, block });
|
||||
} else if (block.type === "tool_use") {
|
||||
if (last?.kind === "work") last.blocks.push(block);
|
||||
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
|
||||
} else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) {
|
||||
@@ -669,6 +677,14 @@ export function MessageList({
|
||||
return (
|
||||
<WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />
|
||||
);
|
||||
case "ask":
|
||||
return (
|
||||
<AskCard
|
||||
key={item.key}
|
||||
block={item.block}
|
||||
result={item.block.id ? (toolResultMap.get(item.block.id) ?? null) : null}
|
||||
/>
|
||||
);
|
||||
case "edits":
|
||||
// The card closes a finished turn; one still working has none yet.
|
||||
if (pending && item === items[items.length - 1]) return null;
|
||||
@@ -935,6 +951,50 @@ function RunningStep({
|
||||
}
|
||||
|
||||
/** Under a turn that ended: how long it took and the output it made. */
|
||||
/** An AskUserQuestion: each question with the answer the user picked or
|
||||
* typed (the result's `answers`), "declined" when they refused it, no answer
|
||||
* yet while the CLI still asks. */
|
||||
function AskCard({
|
||||
block,
|
||||
result,
|
||||
}: {
|
||||
block: TranscriptContent;
|
||||
result: TranscriptContent | null;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const input = block.input as { questions?: unknown } | undefined;
|
||||
const questions = (Array.isArray(input?.questions) ? input.questions : [])
|
||||
.map((q) => (q && typeof q === "object" ? (q as { question?: unknown }).question : null))
|
||||
.filter((q): q is string => typeof q === "string");
|
||||
return (
|
||||
<div
|
||||
data-testid="chat-ask"
|
||||
className="space-y-2 rounded-xl border border-border px-3 py-2 text-sm"
|
||||
>
|
||||
{questions.map((q, i) => {
|
||||
const answer = result?.answers?.[q];
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="flex items-start gap-1.5 text-fg-secondary">
|
||||
<MessageCircleQuestion className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>{q}</span>
|
||||
</div>
|
||||
{answer && (
|
||||
<div className="ml-5 mt-0.5 flex items-start gap-1.5 font-medium text-fg-primary">
|
||||
<CornerDownRight className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
|
||||
<span className="whitespace-pre-wrap">{answer}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{result?.is_error && (
|
||||
<div className="text-xs text-fg-muted">{t("conversation.askDeclined")}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnDone({ ms, output }: { ms: number; output: number }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file MessageList.ask.test.tsx
|
||||
* @description An AskUserQuestion call is not folded into the collapsed work
|
||||
* line: the chat shows it as its own card — each question with the answer the
|
||||
* user picked or typed, "declined" when they refused it, nothing yet while the
|
||||
* CLI still waits.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { MessageList } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const at = "2026-10-13T08:00:00.000Z";
|
||||
const user = (text: string): TranscriptMessage =>
|
||||
({
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: at,
|
||||
content: [{ type: "text", text }],
|
||||
}) as TranscriptMessage;
|
||||
const ask = (id: string, questions: string[]): TranscriptMessage =>
|
||||
({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
content: [
|
||||
{ type: "tool_use", id: `${id}r`, name: "Read", input: { file_path: "/w/a.ts" } },
|
||||
{
|
||||
type: "tool_use",
|
||||
id,
|
||||
name: "AskUserQuestion",
|
||||
input: {
|
||||
questions: questions.map((question) => ({
|
||||
question,
|
||||
header: "Pick",
|
||||
options: [{ label: "Yes" }, { label: "No" }],
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
}) as TranscriptMessage;
|
||||
const result = (id: string, extra: Record<string, unknown>) =>
|
||||
({
|
||||
type: "user",
|
||||
sender: "tool",
|
||||
timestamp: at,
|
||||
content: [{ type: "tool_result", id, output: "x", ...extra }],
|
||||
}) as TranscriptMessage;
|
||||
|
||||
describe("an AskUserQuestion card", () => {
|
||||
it("shows each question with the user's answer, outside the work line", () => {
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[
|
||||
user("go"),
|
||||
ask("q1", ["Commit?", "Push?"]),
|
||||
result("q1", { answers: { "Commit?": "Yes", "Push?": "only after review" } }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const card = screen.getByTestId("chat-ask");
|
||||
expect(within(card).getByText("Commit?")).toBeTruthy();
|
||||
expect(within(card).getByText("Yes")).toBeTruthy();
|
||||
expect(within(card).getByText("only after review")).toBeTruthy();
|
||||
expect(within(screen.getByTestId("chat-tools")).queryByText(/Commit\?/)).toBeNull();
|
||||
});
|
||||
|
||||
it("says declined when refused, and shows no answer while still asked", () => {
|
||||
const { rerender } = render(
|
||||
<MessageList loading={false} messages={[user("go"), ask("q1", ["Commit?"])]} />
|
||||
);
|
||||
const card = screen.getByTestId("chat-ask");
|
||||
expect(within(card).getByText("Commit?")).toBeTruthy();
|
||||
expect(within(card).queryByText(/declined/i)).toBeNull();
|
||||
rerender(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[user("go"), ask("q1", ["Commit?"]), result("q1", { is_error: true })]}
|
||||
/>
|
||||
);
|
||||
expect(within(screen.getByTestId("chat-ask")).getByText(/declined/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user