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:
2026-10-12 15:40:47 +07:00
parent 5d8fc9bb98
commit 46e3899945
44 changed files with 1329 additions and 297 deletions
@@ -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();
});
});