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:
2026-10-13 10:46:56 +07:00
parent 7cb4f64781
commit b0dd5af22f
20 changed files with 320 additions and 31 deletions
+20 -1
View File
@@ -18,7 +18,7 @@ const fs = require("node:fs");
const TEST_DB = path.join(os.tmpdir(), `transcript-sender-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { classifyTranscriptSender, patchCounts } = require("../routes/sessions");
const { classifyTranscriptSender, patchCounts, askAnswers } = require("../routes/sessions");
const { db } = require("../db");
after(() => {
@@ -168,3 +168,22 @@ describe("patchCounts — the +/- lines of a Write/Edit result", () => {
assert.equal(patchCounts(undefined), null);
});
});
describe("askAnswers — what the user picked or typed for an AskUserQuestion", () => {
it("returns each question's answer", () => {
const r = {
questions: [{ question: "Commit?", options: [{ label: "Yes" }, { label: "No" }] }],
answers: { "Commit?": "Yes", "Notes?": "typed text" },
annotations: {},
};
assert.deepEqual(askAnswers(r), { "Commit?": "Yes", "Notes?": "typed text" });
});
it("drops a non-string answer and answers null for a result that is no question", () => {
assert.deepEqual(askAnswers({ answers: { a: "x", b: 3 } }), { a: "x" });
assert.equal(askAnswers({ answers: {} }), null);
assert.equal(askAnswers({ answers: ["x"] }), null);
assert.equal(askAnswers({ stdout: "ok" }), null);
assert.equal(askAnswers("text"), null);
});
});
+13
View File
@@ -1029,6 +1029,7 @@ router.get("/:id/transcript", async (req, res) => {
} else if (block.type === "tool_result") {
// A line carries one tool result; an edit's +/- counts ride on it.
const diff = patchCounts(entry.toolUseResult);
const answers = askAnswers(entry.toolUseResult);
content.push({
type: "tool_result",
id: block.tool_use_id || null,
@@ -1040,6 +1041,7 @@ router.get("/:id/transcript", async (req, res) => {
),
is_error: !!block.is_error,
...(diff ? { diff } : {}),
...(answers ? { answers } : {}),
});
}
}
@@ -1330,6 +1332,16 @@ function patchCounts(r) {
return null;
}
/** An AskUserQuestion result's answers (`toolUseResult.answers`): question
* text → the option picked, or what the user typed. Null for any other result. */
function askAnswers(r) {
if (!r || typeof r !== "object" || !r.answers || typeof r.answers !== "object") return null;
if (Array.isArray(r.answers)) return null;
const out = {};
for (const [q, a] of Object.entries(r.answers)) if (typeof a === "string") out[q] = a;
return Object.keys(out).length ? out : null;
}
// A conversation turn — a prompt, an answer — is shown whole: it is bounded by
// the model's output and what the user typed. Only tool payloads and thinking
// (the parts that can run to megabytes) keep the 10 KB cap. This one guards
@@ -1353,3 +1365,4 @@ module.exports.sessionStats = sessionStats;
// Exported for unit tests — sender attribution is correctness-critical.
module.exports.classifyTranscriptSender = classifyTranscriptSender;
module.exports.patchCounts = patchCounts;
module.exports.askAnswers = askAnswers;