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:
2026-10-20 16:44:55 +07:00
parent 49f87c4da3
commit 0547488f13
59 changed files with 4285 additions and 690 deletions
@@ -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()