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
@@ -13,7 +13,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PaneChat, contextWindow } from "../PaneChat";
@@ -148,6 +148,7 @@ beforeEach(() => {
vi.mocked(submitToRun).mockClear();
// A prompt typed in one test is a saved draft in the next (`lib/chatDraft`).
localStorage.removeItem("agenthub.chat.draft.s1");
sessionStorage.clear();
});
describe("PaneChat — the + menu", () => {
@@ -313,27 +314,63 @@ describe("PaneChat — the activity panel", () => {
expect(screen.getByTestId("chat-outline")).toBeTruthy();
});
it("starts open in a pane spanning the grid, and follows the pane when it is split", () => {
it("starts open in a pane spanning the grid, and follows the pane when it is split", async () => {
const pane = (panelOpen: boolean) => (
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen={panelOpen} />
</MemoryRouter>
);
const { rerender } = render(pane(true));
expect(screen.getByTestId("chat-outline")).toBeTruthy();
await screen.findByTestId("chat-outline");
rerender(pane(false));
expect(screen.queryByTestId("chat-outline")).toBeNull();
expect(screen.getByTestId("chat-outline-show")).toBeTruthy();
});
it("resizes from its splitter and remembers the width", () => {
it("starts closed while the session has no output, even in a spanning pane", async () => {
vi.mocked(api.sessions.outline).mockResolvedValueOnce({
progress: [],
outputs: [],
files: [],
uploads: [],
memory: [],
skills: [],
mcp: [],
} as never);
render(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
</MemoryRouter>
);
await waitFor(() => expect(api.sessions.outline).toHaveBeenCalled());
expect(screen.queryByTestId("chat-outline")).toBeNull();
expect(screen.getByTestId("chat-outline-show").textContent).toBe("0");
});
it("keeps the user's open or close across the chat leaving for the terminal", async () => {
const pane = () => (
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
</MemoryRouter>
);
const first = render(pane());
fireEvent.click(
within(await screen.findByTestId("chat-outline")).getByRole("button", { name: "Hide side panel" })
);
first.unmount();
render(pane());
await waitFor(() => expect(screen.getByTestId("chat-outline-show").textContent).toBe("2"));
expect(screen.queryByTestId("chat-outline")).toBeNull();
});
it("resizes from its splitter and remembers the width", async () => {
localStorage.removeItem("agenthub.chat.panelWidth");
render(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
</MemoryRouter>
);
const splitter = screen.getByRole("separator", { name: "Resize side panel" });
const splitter = await screen.findByRole("separator", { name: "Resize side panel" });
expect(splitter.getAttribute("aria-valuenow")).toBe("70");
fireEvent.keyDown(splitter, { key: "ArrowLeft" });
expect(localStorage.getItem("agenthub.chat.panelWidth")).toBe("0.35");
@@ -527,18 +527,30 @@ describe("TerminalView", () => {
});
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
// reads the SERVER's clipboard, not the browser's.
it("lets Ctrl+V reach the browser's paste, and still sends ^V when it holds no file", () => {
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
// pasted like any browser paste: sending ^V for it pasted nothing.
it("lets Ctrl+V reach the browser's paste: text is pasted, ^V only for an empty clipboard", () => {
vi.useFakeTimers();
inputMock.mockClear();
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
expect(handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }))).toBe(false);
// Not cancelled: xterm's own paste listener takes the text.
expect(
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [], getData: () => "some text" },
})
).toBe(true);
vi.advanceTimersByTime(1);
expect(inputMock).not.toHaveBeenCalled();
handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }));
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [], getData: () => "some text" },
clipboardData: { files: [], getData: () => "" },
});
vi.advanceTimersByTime(1);
expect(inputMock).toHaveBeenCalledTimes(1);
expect(inputMock).toHaveBeenCalledWith("\x16", true);
expect(pasteMock).not.toHaveBeenCalled();
vi.useRealTimers();
});
it("sends ^V on Ctrl+V when the browser fires no paste at all", () => {