fa156d6a59
Chat UI: - Each Claude/Codex session keeps its own chat/terminal view; Settings only sets how new sessions open. - Composer + menu: upload, project files and folders (#54, also dragged from the Files/Changes tree), context files, recent uploads, skills, plugins. - Activity side panel (progress, outputs, files read, uploads, memory, skills, connectors) with a file preview; edited-file counts per turn. - Permission-mode chip (Shift+Tab steps, read back from the pane: server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode). - Stop (Esc) while the main agent works; the status is checked per agent (with session_id /api/agents ignores its status filter) and re-read every 5 s while Stop shows. - Dropped images shown in the conversation (GET /api/run/drop/:name, the requester's own drop dir), full-size viewer. - A plain-text paste over 5,000 characters is attached as pasted-text.txt, so no prompt reaches tmux/argv limits (#68). Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the whole panel to a workspace's extra folder (#70). Server mode S3 (one Linux user per account) — experimental, verified with fake sudo plus a P6 managed-settings spike on a test account: - member tmux listing runs in the background (cached, 15 s => unknown); - transcript snapshot copy is async, chunked, temp file + rename; - Express Layer patch only on Express 4; - tmux capturePane/sendKey go to the member's own server. Docs: the multi-user design moved in as docs/plans/server-mode/README.md (the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
112 lines
4.5 KiB
TypeScript
112 lines
4.5 KiB
TypeScript
/**
|
||
* @file Select.test.tsx
|
||
* @description The shared Select as a WAI-ARIA listbox button: expanded state,
|
||
* listbox/option roles with aria-selected, aria-activedescendant tracking,
|
||
* Arrow/Home/End/Enter/Escape/type-ahead, focus back on the trigger, and a
|
||
* list that flips up by the room in its split pane, not the window's.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { describe, it, expect, vi } from "vitest";
|
||
import { fireEvent, render, screen } from "@testing-library/react";
|
||
import { Select } from "../Select";
|
||
|
||
const OPTIONS = [
|
||
{ value: "a", label: "Apple" },
|
||
{ value: "b", label: "Banana" },
|
||
{ value: "c", label: "Cherry" },
|
||
{ value: "d", label: "Blueberry" },
|
||
];
|
||
|
||
function setup(value = "b") {
|
||
const onChange = vi.fn();
|
||
render(<Select value={value} onChange={onChange} options={OPTIONS} label="Fruit" />);
|
||
const button = screen.getByRole("button", { name: "Fruit" });
|
||
return { onChange, button };
|
||
}
|
||
|
||
const activeLabel = () => {
|
||
const list = screen.getByRole("listbox");
|
||
return document.getElementById(list.getAttribute("aria-activedescendant")!)?.textContent;
|
||
};
|
||
|
||
describe("Select (listbox button)", () => {
|
||
it("opens on ArrowDown into a focused listbox on the chosen option", () => {
|
||
const { button } = setup();
|
||
expect(button).toHaveAttribute("aria-haspopup", "listbox");
|
||
expect(button).toHaveAttribute("aria-expanded", "false");
|
||
fireEvent.keyDown(button, { key: "ArrowDown" });
|
||
expect(button).toHaveAttribute("aria-expanded", "true");
|
||
const list = screen.getByRole("listbox", { name: "Fruit" });
|
||
expect(list).toHaveFocus();
|
||
expect(button).toHaveAttribute("aria-controls", list.id);
|
||
expect(screen.getByRole("option", { name: "Banana" })).toHaveAttribute("aria-selected", "true");
|
||
expect(screen.getByRole("option", { name: "Apple" })).toHaveAttribute("aria-selected", "false");
|
||
expect(activeLabel()).toBe("Banana");
|
||
});
|
||
|
||
it("moves with arrows/Home/End and picks with Enter, focus back on the button", () => {
|
||
const { button, onChange } = setup();
|
||
fireEvent.click(button);
|
||
const list = screen.getByRole("listbox");
|
||
fireEvent.keyDown(list, { key: "ArrowDown" });
|
||
expect(activeLabel()).toBe("Cherry");
|
||
fireEvent.keyDown(list, { key: "Home" });
|
||
expect(activeLabel()).toBe("Apple");
|
||
fireEvent.keyDown(list, { key: "End" });
|
||
expect(activeLabel()).toBe("Blueberry");
|
||
fireEvent.keyDown(list, { key: "ArrowUp" });
|
||
fireEvent.keyDown(list, { key: "Enter" });
|
||
expect(onChange).toHaveBeenCalledWith("c");
|
||
expect(screen.queryByRole("listbox")).toBeNull();
|
||
expect(button).toHaveFocus();
|
||
});
|
||
|
||
it("Escape closes without picking and does not bubble", () => {
|
||
const { button, onChange } = setup();
|
||
const behind = vi.fn();
|
||
document.addEventListener("keydown", behind);
|
||
fireEvent.click(button);
|
||
fireEvent.keyDown(screen.getByRole("listbox"), { key: "Escape" });
|
||
document.removeEventListener("keydown", behind);
|
||
expect(onChange).not.toHaveBeenCalled();
|
||
expect(behind).not.toHaveBeenCalled();
|
||
expect(screen.queryByRole("listbox")).toBeNull();
|
||
expect(button).toHaveFocus();
|
||
});
|
||
|
||
it("type-ahead jumps to the next option starting with the typed letter", () => {
|
||
const { button } = setup("a");
|
||
fireEvent.click(button);
|
||
const list = screen.getByRole("listbox");
|
||
fireEvent.keyDown(list, { key: "b" });
|
||
expect(activeLabel()).toBe("Banana");
|
||
fireEvent.keyDown(list, { key: "b" });
|
||
expect(activeLabel()).toBe("Blueberry"); // a repeated letter cycles
|
||
});
|
||
|
||
it("picks with a click on an option", () => {
|
||
const { button, onChange } = setup();
|
||
fireEvent.click(button);
|
||
fireEvent.click(screen.getByText("Apple"));
|
||
expect(onChange).toHaveBeenCalledWith("a");
|
||
});
|
||
|
||
it("opens upward when its pane (data-menu-bounds) has no room below, whatever the window has", () => {
|
||
const rect = (top: number, bottom: number) => ({ top, bottom }) as DOMRect;
|
||
render(
|
||
<div data-menu-bounds data-testid="pane">
|
||
<Select value="b" onChange={() => {}} options={OPTIONS} label="Fruit" />
|
||
</div>
|
||
);
|
||
// A top pane 0–400 in a 2000px window; the trigger near its bottom.
|
||
vi.spyOn(screen.getByTestId("pane"), "getBoundingClientRect").mockReturnValue(rect(0, 400));
|
||
const button = screen.getByRole("button", { name: "Fruit" });
|
||
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380));
|
||
vi.stubGlobal("innerHeight", 2000);
|
||
fireEvent.click(button);
|
||
expect(screen.getByRole("listbox").className).toContain("bottom-full");
|
||
vi.unstubAllGlobals();
|
||
});
|
||
});
|