feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)
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.
This commit is contained in:
@@ -3,7 +3,10 @@
|
||||
* @description The run-form pieces that outlived the pane's run form:
|
||||
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
|
||||
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
||||
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
|
||||
* pasted image shown as a thumbnail and sent as its saved path (and
|
||||
* remembered as a recent upload), a Files panel row dropped as an `@path`
|
||||
* chip, a composer
|
||||
* (`bare`) box growing with its text up to a cap, and `ModelPicker` — a tool's own
|
||||
* list from the server, a fixed list (an AI Proxy's models) when given one,
|
||||
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
||||
* same list as one chip: aliases up front, pinned versions under More models,
|
||||
@@ -13,17 +16,19 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import {
|
||||
ModelMenu,
|
||||
ModelPicker,
|
||||
PASTE_AS_FILE_CHARS,
|
||||
PromptEditor,
|
||||
promptWithImages,
|
||||
type PromptImage,
|
||||
} from "../RunSetup";
|
||||
import { api } from "../../../lib/api";
|
||||
import { dragProjectPath, recentUploads } from "../../../lib/promptAttach";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -158,6 +163,34 @@ describe("PromptEditor — / and @ suggestions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PromptEditor — a composer grows with its text", () => {
|
||||
it("takes the height its text needs, capped by max-h, and only when bare", () => {
|
||||
const tall = vi
|
||||
.spyOn(HTMLTextAreaElement.prototype, "scrollHeight", "get")
|
||||
.mockReturnValue(180);
|
||||
const props = {
|
||||
placeholder: "Ask anything…",
|
||||
slash: false,
|
||||
fileCwd: "/Users/tester",
|
||||
images: [],
|
||||
onImagesChange: () => {},
|
||||
onChange: () => {},
|
||||
onSubmit: () => {},
|
||||
};
|
||||
const view = render(<PromptEditor {...props} value={"a\nb\nc\nd\ne"} bare />);
|
||||
expect(box().style.height).toBe("180px");
|
||||
expect(box().className).toContain("max-h-[40vh]");
|
||||
tall.mockReturnValue(60);
|
||||
view.rerender(<PromptEditor {...props} value="a" bare />);
|
||||
expect(box().style.height).toBe("60px");
|
||||
view.unmount();
|
||||
// The framed editor keeps its fixed rows.
|
||||
render(<PromptEditor {...props} value={"a\nb\nc"} />);
|
||||
expect(box().style.height).toBe("");
|
||||
tall.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PromptEditor — newlines and images", () => {
|
||||
it("Alt+Enter inserts a newline and does not send", () => {
|
||||
const spies = renderEditor("ab");
|
||||
@@ -202,7 +235,8 @@ describe("PromptEditor — newlines and images", () => {
|
||||
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
|
||||
|
||||
fireEvent.click(thumb);
|
||||
expect(screen.getByRole("dialog", { name: "shot.png" })).toBeTruthy();
|
||||
// On the page, not in the composer's layer: nothing stacked over a pane covers it.
|
||||
expect(screen.getByRole("dialog", { name: "shot.png" }).parentElement).toBe(document.body);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
@@ -212,6 +246,38 @@ describe("PromptEditor — newlines and images", () => {
|
||||
expect(screen.getByTestId("sent").textContent).toBe("see");
|
||||
});
|
||||
|
||||
it("a composer's Upload opens a picker whose files attach like a paste", async () => {
|
||||
const pick: { current: (() => void) | null } = { current: null };
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
pickFiles={pick}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const input = screen.getByTestId("prompt-file-input") as HTMLInputElement;
|
||||
const clicked = vi.spyOn(input, "click");
|
||||
pick.current?.();
|
||||
expect(clicked).toHaveBeenCalled();
|
||||
const doc = new File(["x"], "spec.pdf", { type: "application/pdf" });
|
||||
fireEvent.change(input, { target: { files: [doc] } });
|
||||
expect(api.run.dropFile).toHaveBeenCalledWith(doc, undefined); // no workspace named
|
||||
expect(await screen.findByText("spec.pdf")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("attaches a dropped non-image file as a name chip sent by its path", async () => {
|
||||
vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" });
|
||||
function Harness() {
|
||||
@@ -237,13 +303,70 @@ describe("PromptEditor — newlines and images", () => {
|
||||
fireEvent.drop(box(), { dataTransfer: { files: [pdf] } });
|
||||
expect(await screen.findByText("spec.pdf")).toBeTruthy();
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read /tmp/agenthub-drops-1/b-spec.pdf");
|
||||
// Remembered for the + menu's Recent uploads.
|
||||
expect(recentUploads()).toContainEqual({
|
||||
path: "/tmp/agenthub-drops-1/b-spec.pdf",
|
||||
name: "spec.pdf",
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches a Files panel row dropped on it as an @path chip, once", () => {
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
value="read"
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("read", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const data: Record<string, string> = {};
|
||||
const dataTransfer = {
|
||||
setData: (k: string, v: string) => (data[k] = v),
|
||||
getData: (k: string) => data[k] ?? "",
|
||||
get types() {
|
||||
return Object.keys(data);
|
||||
},
|
||||
files: [],
|
||||
};
|
||||
// What a Files panel row's onDragStart puts on the drag.
|
||||
dragProjectPath({ dataTransfer } as unknown as React.DragEvent, "src/lib/");
|
||||
fireEvent.drop(box(), { dataTransfer });
|
||||
fireEvent.drop(box(), { dataTransfer });
|
||||
expect(screen.getByText("lib/")).toBeTruthy();
|
||||
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read @src/lib/");
|
||||
});
|
||||
|
||||
it("leaves a text paste to the textarea", () => {
|
||||
renderEditor("");
|
||||
fireEvent.paste(box(), { clipboardData: { files: [] } });
|
||||
fireEvent.paste(box(), {
|
||||
clipboardData: { files: [], getData: () => "a short note" },
|
||||
});
|
||||
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches a very long text paste as a text file, like a pasted file (#68)", async () => {
|
||||
renderEditor("");
|
||||
const long = "x".repeat(PASTE_AS_FILE_CHARS + 1);
|
||||
fireEvent.paste(box(), { clipboardData: { files: [], getData: () => long } });
|
||||
await waitFor(() => expect(api.run.dropFile).toHaveBeenCalledTimes(1));
|
||||
const file = vi.mocked(api.run.dropFile).mock.calls[0]![0] as File;
|
||||
expect(file.name).toBe("pasted-text.txt");
|
||||
expect(file.type).toBe("text/plain");
|
||||
expect(file.size).toBe(long.length); // the whole paste, nothing cut
|
||||
expect(box()).toHaveValue(""); // not typed into the box
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModelPicker", () => {
|
||||
|
||||
Reference in New Issue
Block a user