feat(workspace): composer like Claude Desktop, image thumbnails, tool icons in the picker

This commit is contained in:
2026-10-02 16:13:27 +07:00
parent 4802684d2f
commit f35bf2043f
15 changed files with 414 additions and 185 deletions
@@ -2,17 +2,19 @@
* @file RunSetup.test.tsx
* @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) and `ModelPicker` — a tool's own
* 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
* list from the server, a fixed list (an AI Router's models) when given one,
* and Custom…, which is a sentinel, never a model id.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { ModelPicker, PromptEditor } from "../RunSetup";
import { ModelPicker, PromptEditor, promptWithImages, type PromptImage } from "../RunSetup";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => {
@@ -34,7 +36,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
}),
},
run: { files: r({ items: [] }) },
run: { files: r({ items: [] }), dropImage: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
ccConfig: {
commands: r({
items: [
@@ -67,6 +69,8 @@ function renderEditor(value: string, slash = true) {
placeholder="Ask anything…"
slash={slash}
fileCwd="/Users/tester"
images={[]}
onImagesChange={() => {}}
{...spies}
/>
);
@@ -117,6 +121,65 @@ describe("PromptEditor — / and @ suggestions", () => {
});
});
describe("PromptEditor — newlines and images", () => {
it("Alt+Enter inserts a newline and does not send", () => {
const spies = renderEditor("ab");
(box() as HTMLTextAreaElement).setSelectionRange(1, 1);
fireEvent.keyDown(box(), { key: "Enter", altKey: true });
expect(spies.onChange).toHaveBeenLastCalledWith("a\nb");
fireEvent.keyDown(box(), { key: "Enter", shiftKey: true });
expect(spies.onSubmit).not.toHaveBeenCalled();
fireEvent.keyDown(box(), { key: "Enter" });
expect(spies.onSubmit).toHaveBeenCalledTimes(1);
});
it("shows a pasted image as a thumbnail that opens full size and can be removed", async () => {
URL.createObjectURL = vi.fn(() => "blob:shot");
URL.revokeObjectURL = vi.fn();
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
value="see"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("see", images)}</output>
</>
);
}
render(<Harness />);
const img = new File(["x"], "shot.png", { type: "image/png" });
fireEvent.paste(box(), { clipboardData: { files: [img] } });
const thumb = await screen.findByRole("button", { name: "shot.png" });
expect(api.run.dropImage).toHaveBeenCalledWith(img);
// The path is not typed into the prompt; it goes with it on send.
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
fireEvent.click(thumb);
expect(screen.getByRole("dialog", { name: "shot.png" })).toBeTruthy();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Remove image" }));
expect(screen.queryByRole("button", { name: "shot.png" })).toBeNull();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:shot");
expect(screen.getByTestId("sent").textContent).toBe("see");
});
it("leaves a text paste to the textarea", () => {
renderEditor("");
fireEvent.paste(box(), { clipboardData: { files: [] } });
expect(api.run.dropImage).not.toHaveBeenCalled();
});
});
describe("ModelPicker", () => {
it("lists the tool's own models, and Custom… reports only the typed id", async () => {
const onChange = vi.fn();