fix: v2.7.4 — Changes matches JetBrains (#72), the chat says when Claude waits on a trust prompt (#73), Vietnamese Telex in Safari
- Changes (#72): a file git reports `MM` whose working copy equals HEAD again (a staged change since undone on disk) is no longer listed — JetBrains' GitChangesCollector drops it the same way. Mode-only changes stay listed. statusCounts (the dirty-tree safety checks) is unchanged. - Chat (#73): Claude Code runs no hook before a folder is trusted, so a new pane's session has no row; the chat now says the session has not started, offers Open terminal, and asks before typing a message or /model //effort into the pane (an Enter would pick the trust prompt's "No, exit"). - Terminal: Safari + macOS's own Vietnamese Telex replaces the whole word on each key (insertReplacementText), which xterm.js ignores — only each word's first letter reached the pane. The edit is now sent as DELs + the new tail. - Also: Ctrl+V pastes a screenshot into the terminal as a file path; the folder browser fallback lists files for a file field (`files=1`); the macOS native picker opens in front of the browser; a hidden tab is marked while a chat waits or once a turn ended; chat live line shows running calls, turn time and tokens, and "Done in …".
This commit is contained in:
@@ -42,6 +42,10 @@ describe("ChatOutline", () => {
|
||||
);
|
||||
expect(screen.getByText("Parse plan").className).toContain("line-through");
|
||||
expect(screen.getByText("Build report").className).not.toContain("line-through");
|
||||
// How far the plan is, and which step is being worked on.
|
||||
expect(screen.getByText("1/2")).toBeTruthy();
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "1");
|
||||
expect(screen.getByTitle("Build report")).toHaveAttribute("aria-current", "step");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "XLSX: Progress report" }));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx");
|
||||
|
||||
@@ -14,20 +14,30 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PaneChat } from "../PaneChat";
|
||||
import { PaneChat, contextWindow } from "../PaneChat";
|
||||
import { api } from "../../../lib/api";
|
||||
import { interruptRun, submitToRun } from "../../../lib/panePaste";
|
||||
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
||||
|
||||
vi.mock("../../conversation/ConversationView", () => ({
|
||||
// Stands in for a turn's "Edited N files" card: a click names a file.
|
||||
ConversationView: ({ onOpenFile }: { onOpenFile?: (path: string) => void }) => (
|
||||
ConversationView: ({
|
||||
onOpenFile,
|
||||
onNotStarted,
|
||||
}: {
|
||||
onOpenFile?: (path: string) => void;
|
||||
onNotStarted?: (notStarted: boolean) => void;
|
||||
}) => (
|
||||
<div data-testid="conversation">
|
||||
<button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.ts")} />
|
||||
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
|
||||
{/* The session has no row yet: the CLI has not started it. */}
|
||||
<button data-testid="not-started" onClick={() => onNotStarted?.(true)} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
const confirmDialog = vi.hoisted(() => vi.fn(async () => false));
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
|
||||
vi.mock("../../../lib/panePaste", () => ({
|
||||
submitToRun: vi.fn(() => true),
|
||||
interruptRun: vi.fn(() => true),
|
||||
@@ -200,6 +210,44 @@ describe("PaneChat — Stop", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — waiting on the user", () => {
|
||||
it("marks a hidden tab while a permission prompt waits", async () => {
|
||||
const vis = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
|
||||
document.title = "Workspace · AgentHub";
|
||||
try {
|
||||
vi.mocked(api.agents.list).mockResolvedValueOnce({
|
||||
agents: [
|
||||
{
|
||||
type: "main",
|
||||
status: "waiting",
|
||||
awaiting_input_since: "2026-10-12T08:00:00.000Z",
|
||||
awaiting_reason: "notification",
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
const { unmount } = renderChat();
|
||||
await waitFor(() => expect(document.title).toBe("● Waiting · Workspace · AgentHub"));
|
||||
unmount();
|
||||
expect(document.title).toBe("Workspace · AgentHub");
|
||||
} finally {
|
||||
vis.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("contextWindow", () => {
|
||||
it("knows Claude's 200K line from the 1M one", () => {
|
||||
expect(contextWindow("claude-opus-4-5-20251101")).toBe(200_000);
|
||||
expect(contextWindow("claude-sonnet-4-20250514")).toBe(200_000);
|
||||
expect(contextWindow("claude-3-7-sonnet-20250219")).toBe(200_000);
|
||||
expect(contextWindow("claude-opus-4-6")).toBe(1_000_000);
|
||||
expect(contextWindow("claude-opus-5-5")).toBe(1_000_000);
|
||||
expect(contextWindow("claude-haiku-5-5")).toBe(1_000_000);
|
||||
expect(contextWindow("gpt-5-codex")).toBeNull();
|
||||
expect(contextWindow(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — model, effort and permission mode", () => {
|
||||
it("types /model into the pane", async () => {
|
||||
renderChat();
|
||||
@@ -333,4 +381,34 @@ describe("PaneChat — the unsent prompt", () => {
|
||||
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
|
||||
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
|
||||
});
|
||||
|
||||
// Issue #73: before the session starts the CLI may be asking in the terminal
|
||||
// (trust this folder? — "No, exit" preselected); an Enter sent from here
|
||||
// would answer it, so a send asks first.
|
||||
it("asks before sending while the session has not started", async () => {
|
||||
vi.mocked(submitToRun).mockClear();
|
||||
confirmDialog.mockClear();
|
||||
renderChat();
|
||||
fireEvent.click(screen.getByTestId("not-started"));
|
||||
const box = screen.getByPlaceholderText("Write a message…");
|
||||
fireEvent.change(box, { target: { value: "hello" } });
|
||||
confirmDialog.mockResolvedValueOnce(false);
|
||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(1));
|
||||
expect(submitToRun).not.toHaveBeenCalled();
|
||||
expect(box).toHaveValue("hello");
|
||||
confirmDialog.mockResolvedValueOnce(true);
|
||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello"));
|
||||
});
|
||||
|
||||
it("sends without asking once the session has started", async () => {
|
||||
vi.mocked(submitToRun).mockClear();
|
||||
confirmDialog.mockClear();
|
||||
renderChat();
|
||||
fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } });
|
||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi"));
|
||||
expect(confirmDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file TerminalView.test.tsx
|
||||
* @description Tests for the xterm.js-backed terminal view: verifies it opens
|
||||
* a WS connection to the right URL, writes incoming binary frames to the
|
||||
* mocked terminal, forwards typed input as outgoing binary frames, and
|
||||
* mocked terminal, forwards typed input (Safari Telex word replacements
|
||||
* included) as outgoing binary frames, and
|
||||
* re-attaches after the socket drops (server restart) with backoff, but not
|
||||
* after `exit` or a policy close,
|
||||
* and recolours live on a dark-theme change without re-attaching.
|
||||
@@ -21,6 +22,9 @@ const wheelHandlerMock = vi.fn();
|
||||
const keyHandlerMock = vi.fn();
|
||||
const focusMock = vi.fn();
|
||||
const pasteMock = vi.fn();
|
||||
const inputMock = vi.fn();
|
||||
// The newest terminal's hidden input, where the browser's IME types.
|
||||
let textarea: HTMLTextAreaElement;
|
||||
const dropFileMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
|
||||
@@ -69,6 +73,8 @@ vi.mock("@xterm/xterm", () => ({
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
focus: focusMock,
|
||||
paste: pasteMock,
|
||||
input: inputMock,
|
||||
textarea: (textarea = document.createElement("textarea")),
|
||||
resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }),
|
||||
})),
|
||||
}));
|
||||
@@ -503,4 +509,71 @@ describe("TerminalView", () => {
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("uploads a pasted image and pastes its path, like a drop", async () => {
|
||||
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||
clipboardData: { files: [img], getData: () => "" },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-image.png ")
|
||||
);
|
||||
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
|
||||
});
|
||||
|
||||
// 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", () => {
|
||||
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);
|
||||
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||
clipboardData: { files: [], getData: () => "some text" },
|
||||
});
|
||||
expect(inputMock).toHaveBeenCalledWith("\x16", true);
|
||||
expect(pasteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends ^V on Ctrl+V when the browser fires no paste at all", () => {
|
||||
vi.useFakeTimers();
|
||||
inputMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||
handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }));
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(inputMock).toHaveBeenCalledTimes(1);
|
||||
expect(inputMock).toHaveBeenCalledWith("\x16", true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// Safari + macOS's own Vietnamese Telex: the first letter is an insertText,
|
||||
// every later key REPLACES the word — the event order captured on a Mac.
|
||||
it("forwards Safari Telex's word replacements as backspaces plus the new tail", () => {
|
||||
inputMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const type = (value: string) => {
|
||||
textarea.dispatchEvent(new InputEvent("beforeinput", { inputType: "insertReplacementText" }));
|
||||
textarea.value = value;
|
||||
textarea.dispatchEvent(new InputEvent("input", { inputType: "insertReplacementText" }));
|
||||
};
|
||||
textarea.value = "x"; // xterm itself sent this one (insertText)
|
||||
type("xi");
|
||||
type("xin");
|
||||
textarea.value = "xin vie";
|
||||
type("xin viet");
|
||||
type("xin viêt");
|
||||
type("xin việt");
|
||||
expect(inputMock.mock.calls).toEqual([
|
||||
["i", true],
|
||||
["n", true],
|
||||
["t", true],
|
||||
["\x7f\x7fêt", true],
|
||||
["\x7f\x7fệt", true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user