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:
@@ -20,8 +20,10 @@
|
||||
* (`answerRunMenu`).
|
||||
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
|
||||
* outputs, what the session used), open at first in a pane spanning the
|
||||
* grid's full width or height (`panelOpen`) and collapsed into a file button
|
||||
* with the output count in a quarter-sized one; it sits beside the
|
||||
* grid's full width or height (`panelOpen`) once the session has an output,
|
||||
* else collapsed into a file button with the output count; the user's own
|
||||
* open/close is kept per session for the tab (sessionStorage), so the chat
|
||||
* leaving for the terminal and back keeps it; it sits beside the
|
||||
* conversation when the pane is wide and over it when narrow (a CSS container
|
||||
* query on the pane, not the window), dragged wider or narrower by its
|
||||
* splitter there (a fraction of the pane, remembered per browser). A file
|
||||
@@ -92,6 +94,11 @@ const MODES: [PermissionMode, string][] = [
|
||||
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
||||
const SIDE_KEY = "agenthub.chat.panelWidth";
|
||||
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
||||
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
|
||||
const readOutlineChoice = (sessionId: string) => {
|
||||
const v = sessionStorage.getItem(outlineKey(sessionId));
|
||||
return v === null ? null : v === "1";
|
||||
};
|
||||
const storedSide = () => {
|
||||
const f = Number(localStorage.getItem(SIDE_KEY));
|
||||
return f ? clampSide(f) : null;
|
||||
@@ -328,11 +335,20 @@ export function PaneChat({
|
||||
const [model, setModel] = useState(run.model ?? "");
|
||||
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
|
||||
const pickFiles = useRef<(() => void) | null>(null);
|
||||
const [outlineOpen, setOutlineOpen] = useState(panelOpen);
|
||||
// The user's own open/close, per session for this tab: it outlives the chat
|
||||
// leaving for the terminal (an unmount). Unset, the default rules.
|
||||
const [outlineChoice, setOutlineChoice] = useState(() => readOutlineChoice(sessionId));
|
||||
// A split or a close changes the pane's shape: its default comes back.
|
||||
useEffect(() => setOutlineOpen(panelOpen), [panelOpen]);
|
||||
const shape = useRef(panelOpen);
|
||||
useEffect(() => {
|
||||
if (shape.current === panelOpen) return;
|
||||
shape.current = panelOpen;
|
||||
sessionStorage.removeItem(outlineKey(sessionId));
|
||||
setOutlineChoice(null);
|
||||
}, [panelOpen, sessionId]);
|
||||
const openOutline = (open: boolean) => {
|
||||
setOutlineOpen(open);
|
||||
sessionStorage.setItem(outlineKey(sessionId), open ? "1" : "0");
|
||||
setOutlineChoice(open);
|
||||
setPreview(null);
|
||||
};
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
@@ -355,6 +371,8 @@ export function PaneChat({
|
||||
}, []);
|
||||
const [list, setList] = useState<OutlineList | null>(null);
|
||||
const activity = useSessionOutline(sessionId);
|
||||
// Open by default only in a spanning pane, and only once there is an output.
|
||||
const outlineOpen = outlineChoice ?? (panelOpen && activity.outputs.length > 0);
|
||||
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
|
||||
const text = promptWithImages(prompt, images);
|
||||
|
||||
|
||||
@@ -40,7 +40,15 @@
|
||||
* here takes the lead back (`resize` with `drive: true`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useId, useMemo, useRef, useState, type ClipboardEvent, type DragEvent } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ClipboardEvent,
|
||||
type DragEvent,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import type { CanvasAddon } from "@xterm/addon-canvas";
|
||||
@@ -200,13 +208,14 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
|
||||
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
|
||||
// text/plain and would paste nothing. Capture phase, because xterm's own
|
||||
// paste listener stops the event. A Ctrl+V with no file in it is the pane's
|
||||
// ^V again (vim's block select, readline's quoted insert), not a text paste.
|
||||
// paste listener stops the event. Text is xterm's to paste, Ctrl+V's too;
|
||||
// only a Ctrl+V with nothing on the clipboard is the pane's ^V again
|
||||
// (vim's block select, readline's quoted insert).
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
const ctrlV = ctrlVRef.current;
|
||||
ctrlVRef.current = false;
|
||||
const files = Array.from(e.clipboardData?.files ?? []);
|
||||
if (files.length === 0 && !ctrlV) return;
|
||||
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (files.length > 0) void attachFiles(files);
|
||||
@@ -354,7 +363,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
}
|
||||
// Ctrl+V: xterm would send ^V, and claude would then read the
|
||||
// SERVER's clipboard, never this browser's. Let the browser paste
|
||||
// instead (onPaste); a browser that fires none still sends ^V.
|
||||
// instead (onPaste: a file's path, or the text); a browser that fires
|
||||
// no paste, or one with nothing on the clipboard, still sends ^V.
|
||||
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "v") {
|
||||
ctrlVRef.current = true;
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user