chore: v2.8.13 — Artifacts reinstall/edit/installs, agent steps in chat, pick a local file to attach, Windows paths as links
- Chat: an Agent step shows its task, status and tool calls and opens its conversation in place; Windows paths (E:\…, C:/…) in replies open as files - Prompt: attach a file on this computer by path (native picker), clearer too-large messages - Artifacts: edit title/description and a skill's files (new snapshot), Reinstall, install history, Hub-only rows, skills installed into ~/.claude/skills and ~/.agents/skills - Files: batch upload of small files in one extract - AI Tools: npm install falls back to --prefix ~/.local when the global prefix is not writable
This commit is contained in:
@@ -2,13 +2,14 @@
|
||||
* @file ChatAddMenu.test.tsx
|
||||
* @description The `+` menu's existing-resource pickers: Project files (a
|
||||
* search with folders, attached as `@path`), the workspace's Context files
|
||||
* (files only, none missing) and this browser's Recent uploads.
|
||||
* (files only, none missing), this browser's Recent uploads and, for a
|
||||
* browser on the dashboard's machine, a file chosen in the OS's own picker.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ChatAddMenu } from "../ChatAddMenu";
|
||||
import { api } from "../../../lib/api";
|
||||
@@ -21,6 +22,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
run: { files: vi.fn().mockResolvedValue({ items: ["src/", "src/a.ts"] }) },
|
||||
lanes: {
|
||||
pickPath: vi.fn().mockResolvedValue({ path: "/home/u/Videos/demo clip.mp4" }),
|
||||
sources: vi.fn().mockResolvedValue({
|
||||
sources: [
|
||||
{ id: 1, kind: "file", path: "/data/ctx/1/brief.md", name: "brief.md", missing: false },
|
||||
@@ -61,6 +63,28 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("ChatAddMenu — a file on this computer", () => {
|
||||
it("attaches the file the OS picker chose by its path, uploading nothing", async () => {
|
||||
const onAttach = renderMenu();
|
||||
fireEvent.click(screen.getByTestId("chat-add-pick-local"));
|
||||
expect(api.lanes.pickPath).toHaveBeenCalledWith("file", "/repo");
|
||||
await vi.waitFor(() =>
|
||||
expect(onAttach).toHaveBeenCalledWith([
|
||||
{ path: '@"/home/u/Videos/demo clip.mp4"', name: "demo clip.mp4" },
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("is not offered to a browser on another machine", () => {
|
||||
// Only a loopback address: a name that merely starts with "localhost" is not one.
|
||||
vi.stubGlobal("location", { ...window.location, hostname: "localhost.example.com" });
|
||||
renderMenu();
|
||||
expect(screen.queryByTestId("chat-add-pick-local")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatAddMenu — existing resources", () => {
|
||||
it("searches the project, folders included, and attaches a pick as @path", async () => {
|
||||
const onAttach = renderMenu();
|
||||
|
||||
@@ -56,7 +56,11 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
|
||||
}),
|
||||
},
|
||||
run: { files: r({ items: [] }), dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
|
||||
run: {
|
||||
files: r({ items: [] }),
|
||||
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
|
||||
localPaths: r({ sizes: [1] }),
|
||||
},
|
||||
ccConfig: {
|
||||
commands: r({
|
||||
items: [
|
||||
@@ -342,6 +346,120 @@ describe("PromptEditor — newlines and images", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches a local file manager's files by their own paths, uploading nothing", async () => {
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
value="read"
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/home/u"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("read", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
|
||||
const data: Record<string, string> = { "text/uri-list": "file:///home/u/demo.mp4" };
|
||||
fireEvent.drop(box(), {
|
||||
dataTransfer: { files: [big], types: ["Files"], getData: (k: string) => data[k] ?? "" },
|
||||
});
|
||||
expect(await screen.findByText("demo.mp4")).toBeTruthy();
|
||||
expect(api.run.localPaths).toHaveBeenCalledWith(["/home/u/demo.mp4"]);
|
||||
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4");
|
||||
});
|
||||
|
||||
it("uploads a dropped file whose path the server does not have (an SSH tunnel's laptop)", async () => {
|
||||
vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] });
|
||||
const onImagesChange = vi.fn();
|
||||
render(
|
||||
<PromptEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/home/u"
|
||||
images={[]}
|
||||
onImagesChange={onImagesChange}
|
||||
/>
|
||||
);
|
||||
const doc = new File(["x"], "spec.pdf", { type: "application/pdf" });
|
||||
const data: Record<string, string> = { "text/uri-list": "file:///Users/me/spec.pdf" };
|
||||
fireEvent.drop(box(), {
|
||||
dataTransfer: { files: [doc], types: ["Files"], getData: (k: string) => data[k] ?? "" },
|
||||
});
|
||||
await vi.waitFor(() => expect(api.run.dropFile).toHaveBeenCalledWith(doc, undefined));
|
||||
});
|
||||
|
||||
it("keeps the files that uploaded when another one is refused", async () => {
|
||||
vi.mocked(api.run.dropFile)
|
||||
.mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/a-ok.txt" })
|
||||
.mockRejectedValueOnce(Object.assign(new Error("too big"), { code: "ETOOBIG" }));
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/home/u"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
fireEvent.drop(box(), {
|
||||
dataTransfer: { files: [new File(["x"], "ok.txt"), new File(["y"], "huge.iso")] },
|
||||
});
|
||||
expect(await screen.findByText("ok.txt")).toBeTruthy();
|
||||
expect(screen.getByTestId("sent").textContent).toBe("/tmp/agenthub-drops-1/a-ok.txt");
|
||||
expect(screen.getByRole("alert").textContent).toMatch(/too large/);
|
||||
// Anything attached afterwards — here a Files panel row — ends the error.
|
||||
const data: Record<string, string> = {
|
||||
[ATTACH_MIME]: JSON.stringify({ path: "@src/a.ts", name: "a.ts" }),
|
||||
};
|
||||
fireEvent.drop(box(), { dataTransfer: { getData: (k: string) => data[k] ?? "" } });
|
||||
expect(await screen.findByText("a.ts")).toBeTruthy();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("says a file over the server's limit is too big, and how to attach it", async () => {
|
||||
vi.mocked(api.run.dropFile).mockRejectedValueOnce(
|
||||
Object.assign(new Error("too big"), { code: "ETOOBIG", status: 413, maxBytes: 20971520 })
|
||||
);
|
||||
render(
|
||||
<PromptEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/home/u"
|
||||
images={[]}
|
||||
onImagesChange={() => {}}
|
||||
/>
|
||||
);
|
||||
fireEvent.drop(box(), { dataTransfer: { files: [new File(["x"], "huge.iso")] } });
|
||||
expect((await screen.findByRole("alert")).textContent).toMatch(
|
||||
/too large to upload \(max 20 MB\).*Choose a file on this computer/
|
||||
);
|
||||
});
|
||||
|
||||
it("attaches a Files panel row dropped on it as an @path chip, once", () => {
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
@@ -593,7 +711,10 @@ describe("promptForPane — images apart, every other file in the text", () => {
|
||||
// A Windows path inside a paste of text stayed a plain path in claude; a
|
||||
// paste that is only the path becomes [Image #n].
|
||||
const images: PromptImage[] = [
|
||||
{ path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" },
|
||||
{
|
||||
path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
|
||||
name: "image.png",
|
||||
},
|
||||
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
|
||||
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
|
||||
{ path: "/data/drops/0123456789abcdef.JPG", name: "b.jpg" },
|
||||
@@ -611,14 +732,17 @@ describe("promptForPane — images apart, every other file in the text", () => {
|
||||
// A bare pasted path stayed plain text on Windows, and a path in the
|
||||
// command-line prompt is never an image; claude reads an @ mention at submit.
|
||||
const images: PromptImage[] = [
|
||||
{ path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" },
|
||||
{
|
||||
path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
|
||||
name: "image.png",
|
||||
},
|
||||
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
|
||||
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
|
||||
{ path: "C:\\Users\\Jo Do\\drops\\0123456789abcdef.png", name: "b.png" },
|
||||
];
|
||||
expect(promptForPane(" look ", images, true)).toEqual({
|
||||
text:
|
||||
'look @C:/Users/u/.claude/agenthub/drops/f326fc666d3bdd92-image.png ' +
|
||||
"look @C:/Users/u/.claude/agenthub/drops/f326fc666d3bdd92-image.png " +
|
||||
'@/data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG" ' +
|
||||
'@"C:/Users/Jo Do/drops/0123456789abcdef.png" ',
|
||||
files: [],
|
||||
|
||||
@@ -26,8 +26,14 @@ const inputMock = vi.fn();
|
||||
// The newest terminal's hidden input, where the browser's IME types.
|
||||
let textarea: HTMLTextAreaElement;
|
||||
const dropFileMock = vi.fn();
|
||||
const localPathsMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
|
||||
api: {
|
||||
run: {
|
||||
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
|
||||
localPaths: (paths: string[]) => localPathsMock(paths),
|
||||
},
|
||||
},
|
||||
}));
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
@@ -512,6 +518,23 @@ describe("TerminalView", () => {
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||
});
|
||||
|
||||
it("pastes a local file manager's own paths, uploading nothing", async () => {
|
||||
dropFileMock.mockReset();
|
||||
localPathsMock.mockReset().mockResolvedValue({ sizes: [1] });
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
|
||||
const data: Record<string, string> = {
|
||||
"text/uri-list": "file:///home/u/My%20Videos/demo.mp4",
|
||||
};
|
||||
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||
dataTransfer: { types: ["Files"], files: [big], getData: (k: string) => data[k] ?? "" },
|
||||
});
|
||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith('@"/home/u/My Videos/demo.mp4" '));
|
||||
expect(localPathsMock).toHaveBeenCalledWith(["/home/u/My Videos/demo.mp4"]);
|
||||
expect(dropFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pastes every path of several dropped files, images or not", async () => {
|
||||
dropFileMock
|
||||
.mockReset()
|
||||
|
||||
Reference in New Issue
Block a user