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
+34 -2
View File
@@ -1,7 +1,10 @@
/**
* @file ChatAddMenu.tsx
* @description The Chat view's `+` menu, Claude Desktop's: Upload or add
* files (the prompt box's own picker), what is already there to attach —
* files (the prompt box's own picker), Choose a file on this computer (a
* browser on the dashboard's own machine only: the OS's own picker,
* `POST /api/lanes/pick-path`, attaches the file by its path — any size,
* nothing uploaded), what is already there to attach —
* Project files (a search of the box's folder, folders too:
* `GET /api/run/files?dirs=1`, sent as `@path`), the workspace's Context
* files (`GET /api/lanes/:id/sources`) and Recent uploads (this browser's,
@@ -21,6 +24,7 @@ import {
ChevronRight,
FileText,
FolderOpen,
HardDrive,
History,
Paperclip,
Plug,
@@ -29,7 +33,14 @@ import {
Settings2,
} from "lucide-react";
import { api } from "../../lib/api";
import { projectAttachment, recentUploads, type Attachment } from "../../lib/promptAttach";
import {
isLocalBrowser,
localAttachment,
projectAttachment,
recentUploads,
type Attachment,
} from "../../lib/promptAttach";
import { toast } from "../../lib/toast";
interface Entry {
name: string;
@@ -244,6 +255,17 @@ export function ChatAddMenu({
close();
onAttach([item]);
};
const pickLocal = async () => {
close();
// The dialog opens on the desktop, sometimes behind this window.
toast.info(t("chat.pickLocalOpen"));
try {
const r = await api.lanes.pickPath("file", cwd || undefined);
if (r.path) onAttach([localAttachment(r.path)]);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const manage = (tab: "skills" | "plugins") => {
close();
navigate(`/cc-config?tab=${tab}`);
@@ -291,6 +313,16 @@ export function ChatAddMenu({
onUpload();
}}
/>
{isLocalBrowser() && (
<Row
icon={<HardDrive className="h-4 w-4" />}
label={t("chat.pickLocal")}
title={t("chat.pickLocalHint")}
testId="chat-add-pick-local"
onHover={() => setSub(null)}
onClick={() => void pickLocal()}
/>
)}
<Row
icon={<FolderOpen className="h-4 w-4" />}
label={t("chat.projectFiles")}
+13 -4
View File
@@ -403,7 +403,11 @@ export const PaneChat = memo(function PaneChat({
// status filter ignored: check the status here.
.then((r) => {
const main = r.agents.filter((a) => a.type === "main");
setWorking(main.some((a) => a.status === "working"));
// A fresh start, resume or `/clear` leaves main "working" at an
// empty prompt (SessionStart's flag) until the first prompt.
setWorking(
main.some((a) => a.status === "working" && agentAwaitingReason(a) !== "session_start")
);
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
})
.catch(() => {}),
@@ -561,7 +565,7 @@ export const PaneChat = memo(function PaneChat({
)}
</div>
<div className="mx-4">
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
<div className="mx-auto w-full max-w-3xl">
{menu && (
<PaneMenuCard
// A new question starts with its field closed.
@@ -578,7 +582,7 @@ export const PaneChat = memo(function PaneChat({
{/* In the card's place while the CLI asks: hidden, never unmounted,
so a draft or an upload in flight survives the question. */}
<div
className={`rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50 ${menu ? "hidden" : ""}`}
className={`rounded-[1.25rem] border border-border bg-surface-1 p-2.5 shadow-[0_2px_12px_-4px_rgb(0_0_0/0.12)] transition-[border-color,box-shadow] focus-within:border-border-light focus-within:shadow-[0_4px_20px_-6px_rgb(0_0_0/0.2)] ${menu ? "hidden" : ""}`}
>
<PromptEditor
bare
@@ -633,8 +637,13 @@ export const PaneChat = memo(function PaneChat({
}}
aria-label={t("chat.stop")}
title={t("chat.stop")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
className="relative flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
>
{/* A turning ring: the turn is still going. */}
<span
aria-hidden
className="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-t-accent"
/>
<Square className="h-3 w-3 fill-current" />
</button>
) : (
+73 -16
View File
@@ -11,7 +11,9 @@
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
* remembered for the + menu's Recent uploads), and a panel row (Files,
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
* as an `@path` chip — so is a local file a browser on the dashboard's
* own machine drops or pastes with its `file://` path, never uploaded —
* a `bare` one takes a drop anywhere on its box;
* its `/command` and `@path` tokens are coloured by a mirror laid over
* the textarea (`lib/promptTokens.ts`);
* a borderless (`bare`) one grows with its text up to 40vh;
@@ -71,6 +73,10 @@ import {
droppedAttachments,
droppedSkill,
droppedText,
isLocalBrowser,
localAttachment,
localCandidates,
sameFilesHere,
SKILL_MIME,
rememberUploads,
} from "../../lib/promptAttach";
@@ -229,7 +235,10 @@ export function promptForPane(
}
const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path);
return {
text: promptWithImages(text, images.filter((i) => !isImage(i))),
text: promptWithImages(
text,
images.filter((i) => !isImage(i))
),
files: images.filter(isImage).map((i) => i.path),
};
}
@@ -399,7 +408,11 @@ export function PromptEditor({
// A pasted or dropped file is saved on the server's machine (the same
// route the terminal's drop uses) and shown as a thumbnail or a name chip;
// its path is added to the prompt only when it is sent (promptWithImages).
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
const [upload, setUpload] = useState<"uploading" | "failed" | "tooBig" | "tooBigLocal" | null>(
null
);
// The server's own limit, from its ETOOBIG refusal (never a copy of it here).
const [limitMb, setLimitMb] = useState(0);
const [viewing, setViewing] = useState<PromptImage | null>(null);
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
const uploading = upload === "uploading";
@@ -410,20 +423,64 @@ export function PromptEditor({
const picked = Array.from(list ?? []);
if (picked.length === 0) return false;
setUpload("uploading");
Promise.all(picked.map((f) => api.run.dropFile(f, lane)))
.then((saved) => {
setUpload(null);
const added = picked.map((f, i) => ({
path: saved[i]!.path,
// Each file on its own: one refused file does not cost the others.
void Promise.allSettled(picked.map((f) => api.run.dropFile(f, lane))).then((results) => {
const added: PromptImage[] = [];
const errors: unknown[] = [];
results.forEach((r, i) => {
if (r.status === "rejected") return void errors.push(r.reason);
const f = picked[i]!;
added.push({
path: r.value.path,
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
name: f.name,
}));
});
});
if (added.length) {
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
keepError.current = errors.length > 0; // these came with the error
onImagesChange((prev) => [...prev, ...added]);
})
.catch(() => setUpload("failed"));
}
// Over the server's limit (ETOOBIG): refused before the body is sent.
const tooBig = errors
.map((e) => e as { code?: string; maxBytes?: number } | null)
.find((e) => e?.code === "ETOOBIG");
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
setUpload(
!errors.length ? null : !tooBig ? "failed" : isLocalBrowser() ? "tooBigLocal" : "tooBig"
);
});
return true;
};
/** Dropped or pasted files: by their own paths when the browser is on the
* dashboard's machine and the server finds the same files there (a file
* manager hands over `file://` paths beside the bytes), else uploaded. */
const attachDropped = (
dt: DataTransfer | null | undefined,
list: FileList | null | undefined
) => {
const files = Array.from(list ?? []);
if (files.length === 0) return false;
const paths = localCandidates(dt, files);
if (!paths) return attachImages(files);
setUpload("uploading");
void sameFilesHere(paths, files).then((same) => {
if (!same) return void attachImages(files);
setUpload(null);
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
});
return true;
};
// Anything attached since — a path, a panel row, a + menu pick — ends a past
// upload's error; not the files that uploaded beside the one refused.
const keepError = useRef(false);
const imageCount = useRef(images.length);
useEffect(() => {
const grew = images.length > imageCount.current;
imageCount.current = images.length;
if (grew && !keepError.current) setUpload((u) => (u === "uploading" ? u : null));
keepError.current = false;
}, [images.length]);
const removeImage = (img: PromptImage) => {
if (img.url) URL.revokeObjectURL(img.url);
onImagesChange((prev) => prev.filter((x) => x !== img));
@@ -452,7 +509,7 @@ export function PromptEditor({
} else if (skill) {
e.preventDefault();
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files)) e.preventDefault();
else {
// Only text (a file manager's or an editor's `file://` list, a
// selection, a link): what the terminal beneath would paste.
@@ -617,7 +674,7 @@ export function PromptEditor({
onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
if (attachImages(e.clipboardData?.files)) e.preventDefault();
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault();
else {
// A very long paste goes in as a text file, like Claude Desktop:
// the prompt then carries its path, never the text (#68: a CLI's
@@ -661,10 +718,10 @@ export function PromptEditor({
</div>
{upload && (
<p
role={upload === "failed" ? "alert" : "status"}
className={`mt-1 text-[11px] ${upload === "failed" ? "text-status-danger" : "text-fg-muted"}`}
role={upload === "uploading" ? "status" : "alert"}
className={`mt-1 text-[11px] ${upload === "uploading" ? "text-fg-muted" : "text-status-danger"}`}
>
{t(`drop.${upload}`)}
{t(`drop.${upload}`, { mb: limitMb })}
</p>
)}
{kind && (
+46 -16
View File
@@ -17,7 +17,9 @@
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
* reads them (an image as an image) when the prompt is sent. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
* reads them (an image as an image) when the prompt is sent; a browser on the
* dashboard's own machine pastes a file manager's files by their own paths
* instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
* pasted into whichever of the workspace's panes was focused last.
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
@@ -63,12 +65,17 @@ import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { writeClipboard } from "../../lib/clipboard";
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
import { claudeMention } from "../../lib/promptAttach";
import {
claudeMention,
isLocalBrowser,
localCandidates,
sameFilesHere,
} from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
type DropState = "hint" | "paste" | "uploading" | "failed";
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocal";
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
@@ -198,6 +205,8 @@ export const TerminalView = memo(function TerminalView({
const tRef = useRef(t);
tRef.current = t;
const [drop, setDrop] = useState<DropState | null>(null);
// The server's own limit, from its ETOOBIG refusal (never a copy of it here).
const [limitMb, setLimitMb] = useState(0);
// The socket dropped after it had been up: keystrokes are not reaching tmux.
const [reconnecting, setReconnecting] = useState(false);
// Ctrl+M: Tab and Shift+Tab move focus out instead of reaching the pane.
@@ -212,10 +221,10 @@ export const TerminalView = memo(function TerminalView({
// only goes away when the pointer leaves the terminal itself.
const dragDepth = useRef(0);
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const flash = (state: DropState) => {
const flash = (state: DropState, ms = 2500) => {
setDrop(state);
if (flashTimer.current) clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => setDrop(null), 2500);
flashTimer.current = setTimeout(() => setDrop(null), ms);
};
useEffect(
() => () => {
@@ -240,7 +249,8 @@ export const TerminalView = memo(function TerminalView({
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth.current = 0;
void attachFiles(Array.from(e.dataTransfer.files));
const files = Array.from(e.dataTransfer.files);
void attachFiles(files, localCandidates(e.dataTransfer, files));
};
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
@@ -255,24 +265,42 @@ export const TerminalView = memo(function TerminalView({
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
e.preventDefault();
e.stopPropagation();
if (files.length > 0) void attachFiles(files);
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
else termRef.current?.input("\x16", true);
};
const attachFiles = async (files: File[]) => {
/** Pastes the files' paths: their own (`local`, the `file://` paths a
* browser on the dashboard's machine is handed beside the bytes) once the
* server finds the same files there — nothing uploaded —, else the paths
* the uploads were saved at. A refused file does not cost the others. */
const attachFiles = async (files: File[], local: string[] | null = null) => {
if (files.length === 0) return;
setDrop("uploading");
try {
const saved = await Promise.all(files.map((f) => api.run.dropFile(f, runId)));
let paths = local && (await sameFilesHere(local, files)) ? local : null;
const errors: unknown[] = [];
if (!paths) {
const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
paths = results.flatMap((r) => {
if (r.status === "fulfilled") return [r.value.path];
errors.push(r.reason);
return [];
});
}
if (paths.length) {
// paste(), not a raw send: it honours bracketed-paste mode, so claude
// sees pasted paths rather than typed keystrokes.
const asText = tool === "claude" ? claudeMention : (p: string) => p;
termRef.current?.paste(saved.map((r) => asText(r.path)).join(" ") + " ");
termRef.current?.paste(paths.map(asText).join(" ") + " ");
termRef.current?.focus();
setDrop(null);
} catch {
flash("failed");
}
// Over the server's limit (ETOOBIG): refused before the body is sent.
const tooBig = errors
.map((e) => e as { code?: string; maxBytes?: number } | null)
.find((e) => e?.code === "ETOOBIG");
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
if (!errors.length) setDrop(null);
else if (!tooBig) flash("failed");
else flash(isLocalBrowser() ? "tooBigLocal" : "tooBig", 6000); // a sentence to read
};
useEffect(() => {
@@ -878,10 +906,12 @@ export const TerminalView = memo(function TerminalView({
<div
data-testid="terminal-drop"
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200"
drop === "failed" || drop === "tooBig" || drop === "tooBigLocal"
? "border-red-400 text-red-300"
: "border-sky-400 text-sky-200"
}`}
>
{t(`drop.${drop}`)}
{t(`drop.${drop}`, { mb: limitMb })}
</div>
)}
</div>
@@ -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()