fix: per-user Today, native paste and drop on a local machine
- Today's spend in the status bar and the Claude Code statusline is the
caller's own (`GET /api/pricing/cost?mine=1`, scope.ownSessionWhere), not
every user's sum for an admin or analyst.
- A screenshot pasted into a local claude pane is sent as Claude Code's own
image-paste key: Alt+V on Windows (Ctrl+V did nothing there), ^V elsewhere,
and only when the machine can read its clipboard (wl-paste/xclip + a
display on Linux), else it is uploaded (GET /api/run/native-paste {key}).
- A file manager's files pasted or dropped on a local browser go in by their
own paths, read from the OS clipboard or the file manager's selection
(server/lib/file-manager.js, POST /api/run/clipboard-files and
/selected-files): no upload, no size limit. A copied image file is pasted
by path, text that comes with a picture of it (spreadsheet cells) is
pasted as text, Windows paths go in as `C:/…` mentions.
- Tab titles for Snapshot, Artifacts, Files and Administration.
- An npm tool AgentHub installed under ~/.local is updated there, not by its
own updater into npm's global prefix.
This commit is contained in:
@@ -24,10 +24,18 @@ export function DocumentTitle() {
|
||||
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
|
||||
} else if (path.startsWith("/sessions")) {
|
||||
title = t("sessions");
|
||||
} else if (path.startsWith("/snapshot/")) {
|
||||
title = t("snapshot");
|
||||
} else if (path.startsWith("/workspace")) {
|
||||
title = t("workspace");
|
||||
} else if (path.startsWith("/settings")) {
|
||||
title = t("settings");
|
||||
} else if (path.startsWith("/artifacts")) {
|
||||
title = t("artifacts");
|
||||
} else if (path.startsWith("/files")) {
|
||||
title = t("files");
|
||||
} else if (path.startsWith("/admin")) {
|
||||
title = t("admin");
|
||||
} else if (path !== "/") {
|
||||
title = t("notFound");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* — with a green dot while a newer release is out —, how many SESSIONS are
|
||||
* running and how many need the user (`GET /api/sessions?status=active` and
|
||||
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
|
||||
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>` — and, on the
|
||||
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>&mine=1`, the
|
||||
* user's OWN spend even for an admin — and, on the
|
||||
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
||||
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
|
||||
* the URL's `session`, or the focused pane's session from the page's run poll,
|
||||
@@ -403,7 +404,7 @@ export function StatusBar({
|
||||
const gen = costGen.current;
|
||||
const live = () => gen === costGen.current;
|
||||
api.pricing
|
||||
.totalCost({ date: localDate() })
|
||||
.totalCost({ date: localDate(), mine: true })
|
||||
.then((r) => live() && setToday(r))
|
||||
.catch(() => undefined);
|
||||
api.budgets
|
||||
|
||||
@@ -34,5 +34,13 @@ describe("DocumentTitle", () => {
|
||||
expect(titleFor("/")).toMatch(/^Dashboard · /);
|
||||
expect(titleFor("/sessions")).toMatch(/^Sessions · /);
|
||||
expect(titleFor("/settings")).toMatch(/^Settings · /);
|
||||
expect(titleFor("/artifacts")).toMatch(/^Artifacts · /);
|
||||
expect(titleFor("/files")).toMatch(/^Files · /);
|
||||
expect(titleFor("/admin")).toMatch(/^Administration · /);
|
||||
expect(titleFor("/snapshot/abc")).toMatch(/^Snapshot · /);
|
||||
});
|
||||
|
||||
it("titles an unknown route as not found", () => {
|
||||
expect(titleFor("/nope")).toMatch(/^Not found · /);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,7 +162,7 @@ describe("StatusBar", () => {
|
||||
const conn = screen.getByRole("button", { name: /Live/ });
|
||||
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
|
||||
const cost = await screen.findByText("Today $1.23 (12.3M Tokens)");
|
||||
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today() });
|
||||
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today(), mine: true });
|
||||
expect(cost).toHaveAttribute(
|
||||
"title",
|
||||
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* 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 — so is a local file a browser on the dashboard's
|
||||
* own machine drops or pastes with its `file://` path, never uploaded —
|
||||
* own machine drops or pastes with its `file://` path, or pastes from a
|
||||
* file manager's Copy its OS clipboard holds, 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`);
|
||||
@@ -78,6 +79,8 @@ import {
|
||||
localAttachment,
|
||||
localCandidates,
|
||||
sameFilesHere,
|
||||
fileManagerPaths,
|
||||
isClipboardBitmap,
|
||||
SKILL_MIME,
|
||||
rememberUploads,
|
||||
} from "../../lib/promptAttach";
|
||||
@@ -463,21 +466,26 @@ export function PromptEditor({
|
||||
};
|
||||
/** 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. */
|
||||
* manager hands over `file://` paths beside the bytes) or its file manager
|
||||
* holds them (a paste: its clipboard; a drop: its selection — no upload,
|
||||
* no size limit), else uploaded. */
|
||||
const attachDropped = (
|
||||
dt: DataTransfer | null | undefined,
|
||||
list: FileList | null | undefined
|
||||
list: FileList | File[] | null | undefined,
|
||||
from: "paste" | "drop"
|
||||
) => {
|
||||
const files = Array.from(list ?? []);
|
||||
if (files.length === 0) return false;
|
||||
const paths = localCandidates(dt, files);
|
||||
if (!paths) return attachImages(files);
|
||||
if (!isLocalBrowser()) return attachImages(files);
|
||||
const candidates = localCandidates(dt, files);
|
||||
setUpload("uploading");
|
||||
void sameFilesHere(paths, files).then((same) => {
|
||||
if (!same) return void attachImages(files);
|
||||
void (async () => {
|
||||
let paths = candidates && (await sameFilesHere(candidates, files)) ? candidates : null;
|
||||
paths ??= await fileManagerPaths(files, from);
|
||||
if (!paths) 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
|
||||
@@ -518,7 +526,7 @@ export function PromptEditor({
|
||||
} else if (skill) {
|
||||
e.preventDefault();
|
||||
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
||||
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files)) e.preventDefault();
|
||||
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files, "drop")) 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.
|
||||
@@ -683,17 +691,21 @@ export function PromptEditor({
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault();
|
||||
else if (pastedLocalFiles(e.clipboardData)) e.preventDefault();
|
||||
else {
|
||||
const pasted = Array.from(e.clipboardData?.files ?? []);
|
||||
const text = e.clipboardData?.getData?.("text/plain") ?? "";
|
||||
// Cells from a spreadsheet come with a picture of them: the text
|
||||
// is what was copied (isClipboardBitmap).
|
||||
const textWithPicture =
|
||||
pasted.length > 0 && pasted.every(isClipboardBitmap) && !!text.trim();
|
||||
if (!textWithPicture && attachDropped(e.clipboardData, pasted, "paste"))
|
||||
e.preventDefault();
|
||||
else if (!textWithPicture && pastedLocalFiles(e.clipboardData)) e.preventDefault();
|
||||
else if (text.length > PASTE_AS_FILE_CHARS) {
|
||||
// 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
|
||||
// argument and a tmux message both have a size limit).
|
||||
const text = e.clipboardData?.getData("text/plain") ?? "";
|
||||
if (text.length > PASTE_AS_FILE_CHARS) {
|
||||
e.preventDefault();
|
||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||
}
|
||||
e.preventDefault();
|
||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -20,7 +20,11 @@
|
||||
* reads them when the prompt is sent — an image as its bare path, which claude
|
||||
* shows as `[Image #n]` like a native drop — except a PASTED image in a
|
||||
* `claude` pane of a local-mode dashboard opened on its own machine, which is
|
||||
* sent as ^V so `claude` reads the desktop clipboard itself, uploading nothing
|
||||
* sent as its image-paste key (^V; Alt+V on Windows) so `claude` reads the
|
||||
* desktop clipboard itself, uploading nothing — and a file manager's files
|
||||
* pasted or dropped on that machine go in by the paths it holds them under
|
||||
* (its clipboard / its selection: `POST /api/run/clipboard-files`,
|
||||
* `selected-files`), with no upload and no size limit
|
||||
* (`lib/nativePaste.ts`, a Settings switch); a browser on the dashboard's own
|
||||
* machine pastes a file manager's files (dropped, or copied: `file://` text)
|
||||
* 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
|
||||
@@ -72,18 +76,20 @@ import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "..
|
||||
import {
|
||||
claudePastes,
|
||||
copiedFilePaths,
|
||||
isClipboardBitmap,
|
||||
droppedFilePaths,
|
||||
fileManagerPaths,
|
||||
isLocalBrowser,
|
||||
localCandidates,
|
||||
sameFilesHere,
|
||||
} from "../../lib/promptAttach";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { readNativePaste } from "../../lib/nativePaste";
|
||||
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||
|
||||
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocal";
|
||||
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocalPaste";
|
||||
|
||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||
@@ -245,6 +251,11 @@ export const TerminalView = memo(function TerminalView({
|
||||
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
|
||||
const ctrlVRef = useRef(false);
|
||||
const { local: localMode } = usePermissions();
|
||||
// Ask early whether `^V` can reach this machine's clipboard, so the first
|
||||
// pasted screenshot already takes the right way.
|
||||
useEffect(() => {
|
||||
if (tool === "claude" && localMode && isLocalBrowser()) nativePasteWorks();
|
||||
}, [tool, localMode]);
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
if (hasText(e)) {
|
||||
@@ -262,7 +273,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
void attachFiles(files, localCandidates(e.dataTransfer, files));
|
||||
void attachFiles(files, localCandidates(e.dataTransfer, files), "drop");
|
||||
};
|
||||
|
||||
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
|
||||
@@ -283,6 +294,9 @@ export const TerminalView = memo(function TerminalView({
|
||||
return;
|
||||
}
|
||||
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
||||
// Cells from a spreadsheet, a page's selection: text WITH a picture of
|
||||
// it. A terminal pastes the text there, so does this.
|
||||
if (files.every(isClipboardBitmap) && e.clipboardData?.getData("text/plain")?.trim()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// On the dashboard's own machine the browser's clipboard IS the pane's:
|
||||
@@ -293,13 +307,14 @@ export const TerminalView = memo(function TerminalView({
|
||||
localMode &&
|
||||
isLocalBrowser() &&
|
||||
readNativePaste() &&
|
||||
nativePasteWorks() &&
|
||||
files.length > 0 &&
|
||||
files.every((f) => f.type.startsWith("image/"));
|
||||
files.every(isClipboardBitmap);
|
||||
if (nativeImage) {
|
||||
termRef.current?.input("\x16", true);
|
||||
termRef.current?.input(nativePasteKey(), true);
|
||||
return;
|
||||
}
|
||||
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
|
||||
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files), "paste");
|
||||
else termRef.current?.input("\x16", true);
|
||||
};
|
||||
|
||||
@@ -319,11 +334,15 @@ export const TerminalView = memo(function TerminalView({
|
||||
/** 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) => {
|
||||
* that machine's file manager holds them under (a paste: its clipboard; a
|
||||
* drop: its selection, what the drag carries — no upload, no size limit,
|
||||
* as a native terminal), else the paths the uploads were saved at. A
|
||||
* refused file does not cost the others. */
|
||||
const attachFiles = async (files: File[], local: string[] | null, from: "paste" | "drop") => {
|
||||
if (files.length === 0) return;
|
||||
setDrop("uploading");
|
||||
let paths = local && (await sameFilesHere(local, files)) ? local : null;
|
||||
paths ??= await fileManagerPaths(files, from);
|
||||
const errors: unknown[] = [];
|
||||
if (!paths) {
|
||||
const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
|
||||
@@ -341,7 +360,8 @@ export const TerminalView = memo(function TerminalView({
|
||||
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
|
||||
// A terminal has no + menu: on this machine, a paste goes by path.
|
||||
else flash(isLocalBrowser() ? "tooBigLocalPaste" : "tooBig", 6000); // a sentence to read
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -959,7 +979,7 @@ 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" || drop === "tooBig" || drop === "tooBigLocal"
|
||||
drop === "failed" || drop === "tooBig" || drop === "tooBigLocalPaste"
|
||||
? "border-red-400 text-red-300"
|
||||
: "border-sky-400 text-sky-200"
|
||||
}`}
|
||||
|
||||
@@ -60,6 +60,8 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
files: r({ items: [] }),
|
||||
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
|
||||
localPaths: r({ sizes: [1] }),
|
||||
clipboardFiles: r({ files: [] }),
|
||||
selectedFiles: r({ files: [] }),
|
||||
},
|
||||
ccConfig: {
|
||||
commands: r({
|
||||
@@ -377,6 +379,35 @@ describe("PromptEditor — newlines and images", () => {
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4");
|
||||
});
|
||||
|
||||
// Chrome on Windows pastes a file manager's copy as bytes only: the path
|
||||
// comes off the OS clipboard, so a file over the upload limit attaches too.
|
||||
it("attaches a pasted file manager copy by the path the OS clipboard holds", async () => {
|
||||
vi.mocked(api.run.clipboardFiles).mockResolvedValueOnce({
|
||||
files: [{ path: "C:\\Users\\u\\big.iso", size: 3_000_000_000, dir: false }],
|
||||
});
|
||||
vi.mocked(api.run.dropFile).mockClear();
|
||||
const onImagesChange = vi.fn();
|
||||
render(
|
||||
<PromptEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/home/u"
|
||||
images={[]}
|
||||
onImagesChange={onImagesChange}
|
||||
/>
|
||||
);
|
||||
const iso = new File(["x"], "big.iso");
|
||||
Object.defineProperty(iso, "size", { value: 3_000_000_000 });
|
||||
fireEvent.paste(box(), { clipboardData: { files: [iso], getData: () => "" } });
|
||||
await vi.waitFor(() => expect(onImagesChange).toHaveBeenCalled());
|
||||
const update = onImagesChange.mock.calls[0]![0] as (p: unknown[]) => { path: string }[];
|
||||
expect(update([]).map((a) => a.path)).toEqual(["@C:/Users/u/big.iso"]);
|
||||
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -10,9 +10,18 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
render,
|
||||
cleanup,
|
||||
act,
|
||||
createEvent,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { TerminalView, loadXterm } from "../TerminalView";
|
||||
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
||||
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
|
||||
|
||||
const writeMock = vi.fn();
|
||||
const onDataHandlers: Array<(d: string) => void> = [];
|
||||
@@ -27,11 +36,18 @@ const inputMock = vi.fn();
|
||||
let textarea: HTMLTextAreaElement;
|
||||
const dropFileMock = vi.fn();
|
||||
const localPathsMock = vi.fn();
|
||||
const nativePasteMock = vi.fn(() => Promise.resolve({ native: true }));
|
||||
type Held = { files: { path: string; size: number | null; dir: boolean }[] };
|
||||
const clipboardFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
|
||||
const selectedFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
run: {
|
||||
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
|
||||
localPaths: (paths: string[]) => localPathsMock(paths),
|
||||
nativePaste: () => nativePasteMock(),
|
||||
clipboardFiles: () => clipboardFilesMock(),
|
||||
selectedFiles: () => selectedFilesMock(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -590,11 +606,13 @@ describe("TerminalView", () => {
|
||||
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
|
||||
});
|
||||
|
||||
it("hands a local claude pane's pasted image to claude as ^V, uploading nothing", () => {
|
||||
it("hands a local claude pane's pasted image to claude as ^V, uploading nothing", async () => {
|
||||
__resetNativePaste();
|
||||
dropFileMock.mockReset();
|
||||
inputMock.mockClear();
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||
await waitFor(() => expect(nativePasteWorks()).toBe(true));
|
||||
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||
clipboardData: { files: [img], getData: () => "" },
|
||||
@@ -604,6 +622,41 @@ describe("TerminalView", () => {
|
||||
expect(pasteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Claude Code on Windows binds its image paste to Alt+V; a ^V did nothing.
|
||||
it("sends Alt+V instead of ^V when the dashboard runs on Windows", async () => {
|
||||
__resetNativePaste();
|
||||
nativePasteMock.mockResolvedValueOnce({ native: true, key: "alt+v" } as never);
|
||||
dropFileMock.mockReset();
|
||||
inputMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||
await waitFor(() => expect(nativePasteWorks()).toBe(true));
|
||||
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||
clipboardData: { files: [img], getData: () => "" },
|
||||
});
|
||||
expect(inputMock).toHaveBeenCalledWith("\x1bv", true);
|
||||
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
|
||||
expect(dropFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// No wl-paste/xclip (or no display) on the dashboard's machine: ^V would
|
||||
// paste nothing, so the screenshot is uploaded like a remote browser's.
|
||||
it("uploads a claude pane's pasted image when the machine cannot read its clipboard", async () => {
|
||||
__resetNativePaste();
|
||||
nativePasteMock.mockResolvedValueOnce({ native: false });
|
||||
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
|
||||
inputMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||
await waitFor(() => expect(nativePasteMock).toHaveBeenCalled());
|
||||
await Promise.resolve();
|
||||
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||
clipboardData: { files: [img], getData: () => "" },
|
||||
});
|
||||
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(img, "r1"));
|
||||
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
|
||||
});
|
||||
|
||||
it("uploads a claude pane's pasted image once native paste is turned off", async () => {
|
||||
localStorage.setItem("agenthub.terminal.nativePaste", "off");
|
||||
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
|
||||
@@ -621,6 +674,100 @@ describe("TerminalView", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// A file manager's Copy reaches Chrome on Windows as bytes and a name only:
|
||||
// the pane gets the file's own path off the OS clipboard — no upload, so
|
||||
// no size limit (a 3 GB video too), as a native terminal pastes it.
|
||||
it("pastes a copied file by the path the OS clipboard holds, uploading nothing", async () => {
|
||||
__resetNativePaste();
|
||||
dropFileMock.mockReset();
|
||||
pasteMock.mockClear();
|
||||
inputMock.mockClear();
|
||||
clipboardFilesMock.mockResolvedValueOnce({
|
||||
files: [{ path: "C:\\Users\\u\\Videos\\demo.mp4", size: 3_000_000_000, dir: false }],
|
||||
});
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||
await waitFor(() => expect(nativePasteWorks()).toBe(true));
|
||||
const video = new File([new Uint8Array([1])], "demo.mp4", { type: "video/mp4" });
|
||||
Object.defineProperty(video, "size", { value: 3_000_000_000 });
|
||||
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||
clipboardData: { files: [video], getData: () => "" },
|
||||
});
|
||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/demo.mp4 "));
|
||||
expect(dropFileMock).not.toHaveBeenCalled();
|
||||
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
|
||||
});
|
||||
|
||||
// Chrome hands a file dragged from Explorer no path; the drag carries the
|
||||
// items selected there, so the pane gets their own paths — as a native
|
||||
// terminal types them — and nothing is uploaded, whatever the size.
|
||||
it("drops a file manager's file by the path selected there, uploading nothing", async () => {
|
||||
dropFileMock.mockReset();
|
||||
pasteMock.mockClear();
|
||||
selectedFilesMock.mockResolvedValueOnce({
|
||||
files: [{ path: "C:\\Users\\u\\Videos\\big.mkv", size: 5_000_000_000, dir: false }],
|
||||
});
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||
const video = new File([new Uint8Array([1])], "big.mkv");
|
||||
Object.defineProperty(video, "size", { value: 5_000_000_000 });
|
||||
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||
dataTransfer: { files: [video], types: ["Files"], getData: () => "" },
|
||||
});
|
||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/big.mkv "));
|
||||
expect(dropFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uploads a dropped file the file manager does not hold selected", async () => {
|
||||
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-notes.txt" });
|
||||
selectedFilesMock.mockResolvedValueOnce({
|
||||
files: [{ path: "/home/u/other.txt", size: 1, dir: false }],
|
||||
});
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="codex" />);
|
||||
const notes = new File(["x"], "notes.txt");
|
||||
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||
dataTransfer: { files: [notes], types: ["Files"], getData: () => "" },
|
||||
});
|
||||
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(notes, "r1"));
|
||||
});
|
||||
|
||||
// A copied image FILE is not a screenshot: claude's own paste finds no
|
||||
// picture on a clipboard holding a file, so it goes by path too.
|
||||
it("pastes a copied image file by path instead of claude's image paste", async () => {
|
||||
__resetNativePaste();
|
||||
dropFileMock.mockReset();
|
||||
pasteMock.mockClear();
|
||||
inputMock.mockClear();
|
||||
clipboardFilesMock.mockResolvedValueOnce({
|
||||
files: [{ path: "/home/u/Pictures/logo.png", size: 2, dir: false }],
|
||||
});
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||
await waitFor(() => expect(nativePasteWorks()).toBe(true));
|
||||
const logo = new File([new Uint8Array([137, 80])], "logo.png", { type: "image/png" });
|
||||
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||
clipboardData: { files: [logo], getData: () => "" },
|
||||
});
|
||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/home/u/Pictures/logo.png "));
|
||||
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
|
||||
expect(dropFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Cells from a spreadsheet come with a picture of them; a terminal pastes
|
||||
// the text, so the browser's paste is left to xterm.
|
||||
it("leaves text that comes with a picture of it to xterm's paste", async () => {
|
||||
__resetNativePaste();
|
||||
dropFileMock.mockReset();
|
||||
inputMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||
await waitFor(() => expect(nativePasteWorks()).toBe(true));
|
||||
const pic = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||
const ev = createEvent.paste(screen.getByTestId("terminal-view"), {
|
||||
clipboardData: { files: [pic], getData: (t: string) => (t === "text/plain" ? "a\tb" : "") },
|
||||
});
|
||||
fireEvent(screen.getByTestId("terminal-view"), ev);
|
||||
expect(ev.defaultPrevented).toBe(false);
|
||||
expect(inputMock).not.toHaveBeenCalled();
|
||||
expect(dropFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
|
||||
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
|
||||
// pasted like any browser paste: sending ^V for it pasted nothing.
|
||||
|
||||
@@ -88,5 +88,6 @@
|
||||
"now": "now",
|
||||
"users": "Users",
|
||||
"admin": "Administration",
|
||||
"files": "Files"
|
||||
"files": "Files",
|
||||
"snapshot": "Snapshot"
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"failed": "Could not attach the file",
|
||||
"tooBig": "File too large to upload (max {{mb}} MB) — put it in the workspace folder and attach it from Files",
|
||||
"tooBigLocal": "File too large to upload (max {{mb}} MB) — attach it by path instead: + → Choose a file on this computer",
|
||||
"tooBigLocalPaste": "File too large to upload (max {{mb}} MB) — copy it in your file manager and paste it here instead (Ctrl+V): a pasted file goes in by its own path",
|
||||
"remove": "Remove attachment",
|
||||
"view": "View image"
|
||||
},
|
||||
|
||||
@@ -1142,7 +1142,7 @@
|
||||
},
|
||||
"nativePaste": {
|
||||
"label": "Paste images natively",
|
||||
"description": "On this machine, an image pasted into a Claude pane is read by Claude from your clipboard, like in a normal terminal — nothing is uploaded. Turn it off if you open the dashboard through an SSH tunnel: Claude would read the server's clipboard. This browser only."
|
||||
"description": "On this machine, an image pasted into a Claude pane is read by Claude from your clipboard, like in a normal terminal — nothing is uploaded. Needs wl-paste or xclip on Linux; without them the image is uploaded instead. Turn it off if you open the dashboard through an SSH tunnel: Claude would read the server's clipboard. This browser only."
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Terminal font",
|
||||
|
||||
@@ -88,5 +88,6 @@
|
||||
"now": "bây giờ",
|
||||
"users": "Người dùng",
|
||||
"admin": "Quản trị",
|
||||
"files": "Quản lý file"
|
||||
"files": "Quản lý file",
|
||||
"snapshot": "Snapshot"
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"failed": "Không đính kèm được file",
|
||||
"tooBig": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đặt file vào thư mục workspace rồi đính kèm từ Files",
|
||||
"tooBigLocal": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đính kèm bằng đường dẫn: + → Chọn file trên máy này",
|
||||
"tooBigLocalPaste": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy copy file trong trình quản lý file rồi dán vào đây (Ctrl+V): file được dán sẽ dùng đường dẫn gốc",
|
||||
"remove": "Bỏ đính kèm",
|
||||
"view": "Xem ảnh"
|
||||
},
|
||||
|
||||
@@ -1130,7 +1130,7 @@
|
||||
},
|
||||
"nativePaste": {
|
||||
"label": "Dán ảnh kiểu native",
|
||||
"description": "Trên máy này, ảnh dán vào pane Claude được Claude tự đọc từ clipboard, như trong terminal thường — không upload gì. Tắt nếu bạn mở dashboard qua SSH tunnel: khi đó Claude sẽ đọc clipboard của server. Chỉ áp dụng cho trình duyệt này."
|
||||
"description": "Trên máy này, ảnh dán vào pane Claude được Claude tự đọc từ clipboard, như trong terminal thường — không upload gì. Trên Linux cần wl-paste hoặc xclip; thiếu thì ảnh được upload thay thế. Tắt nếu bạn mở dashboard qua SSH tunnel: khi đó Claude sẽ đọc clipboard của server. Chỉ áp dụng cho trình duyệt này."
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Font terminal",
|
||||
|
||||
+33
-1
@@ -1023,6 +1023,13 @@ export function gitTarget(
|
||||
return session ? { lane: laneId, session } : laneId;
|
||||
}
|
||||
|
||||
/** A file the dashboard machine's file manager holds, by its own path. */
|
||||
export interface FileManagerFile {
|
||||
path: string;
|
||||
size: number | null;
|
||||
dir: boolean;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// ───────────────────────── Server mode login (routes/auth.js) ─────────────────────────
|
||||
auth: {
|
||||
@@ -2152,7 +2159,9 @@ export const api = {
|
||||
*
|
||||
* @returns {@link CostResult} — the aggregate cost breakdown across sessions.
|
||||
*/
|
||||
totalCost: (opts: { lane?: number; date?: string; tool?: string; range?: DateRange } = {}) => {
|
||||
totalCost: (
|
||||
opts: { lane?: number; date?: string; tool?: string; range?: DateRange; mine?: boolean } = {}
|
||||
) => {
|
||||
// Scope the aggregate to the active data-scope, exactly like the sessions /
|
||||
// stats / analytics endpoints — otherwise switching the Data scope selector
|
||||
// left the Dashboard "total cost" showing the un-narrowed global total.
|
||||
@@ -2162,6 +2171,8 @@ export const api = {
|
||||
// `date` (local YYYY-MM-DD): only that day's usage, breakdown included.
|
||||
if (opts.date) qs.set("date", opts.date);
|
||||
if (opts.tool) qs.set("tool", opts.tool);
|
||||
// `mine`: only the caller's own sessions, even for an admin/analyst (server mode).
|
||||
if (opts.mine) qs.set("mine", "1");
|
||||
// `range`: total, breakdown and by_tool for those local days only.
|
||||
applyRange(qs, opts.range);
|
||||
applyScope(qs);
|
||||
@@ -3350,6 +3361,27 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ paths }),
|
||||
}),
|
||||
/** POST /api/run/clipboard-files — the files a file manager's Copy put on
|
||||
* the dashboard machine's clipboard, by their own paths (`size` null for a
|
||||
* folder); 403 EREMOTE unless local mode and a loopback browser. */
|
||||
clipboardFiles: () =>
|
||||
request<{ files: FileManagerFile[] }>("/run/clipboard-files", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
}),
|
||||
/** POST /api/run/selected-files — the files selected in that machine's
|
||||
* file manager (Explorer, Finder): what a drag from it carries. Same
|
||||
* shape and refusal as `clipboardFiles`. */
|
||||
selectedFiles: () =>
|
||||
request<{ files: FileManagerFile[] }>("/run/selected-files", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
}),
|
||||
/** GET /api/run/native-paste — whether a `claude` pane can read this
|
||||
* machine's clipboard itself (a reader installed, a display reachable);
|
||||
* false for a remote browser and in server mode. `key`: the chord
|
||||
* `claude` reads an image with on that machine (Alt+V on Windows). */
|
||||
nativePaste: () => request<{ native: boolean; key?: "ctrl+v" | "alt+v" }>("/run/native-paste"),
|
||||
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
|
||||
start: (args: RunStartArgs) =>
|
||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||
|
||||
@@ -8,9 +8,15 @@
|
||||
* uploaded or copied. Off, the image is uploaded like a remote browser's. An
|
||||
* SSH tunnel's browser also reaches the dashboard by loopback, and its `^V`
|
||||
* would read the SERVER's clipboard — that is what turning it off is for.
|
||||
* The server must also be able to read its clipboard (`nativePasteWorks`: on
|
||||
* Linux `wl-paste`/`xclip` and a display) — else the `^V` pasted nothing.
|
||||
* On Windows Claude Code binds its image paste to Alt+V, not Ctrl+V
|
||||
* (`nativePasteKey`): a `^V` there was silently ignored.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { api } from "./api";
|
||||
|
||||
export const NATIVE_PASTE_KEY = "agenthub.terminal.nativePaste";
|
||||
|
||||
/** True unless this browser turned native image paste off. */
|
||||
@@ -30,3 +36,36 @@ export function writeNativePaste(on: boolean) {
|
||||
/* storage blocked — the default */
|
||||
}
|
||||
}
|
||||
|
||||
let works: boolean | null = null;
|
||||
let asked: Promise<void> | null = null;
|
||||
// Claude Code's image-paste chord on the dashboard's machine: Ctrl+V, but
|
||||
// Alt+V on Windows, where a ^V reaches no binding and pastes nothing.
|
||||
let key = "\x16";
|
||||
|
||||
/** Whether the dashboard's machine can hand a pane its clipboard, as last
|
||||
* answered — false until it has (an upload then, which always works). The
|
||||
* first call asks the server once per page. */
|
||||
export function nativePasteWorks(): boolean {
|
||||
asked ??= api.run
|
||||
.nativePaste()
|
||||
.then((r) => {
|
||||
works = r.native === true;
|
||||
key = r.key === "alt+v" ? "\x1bv" : "\x16";
|
||||
})
|
||||
.catch(() => void (works = false));
|
||||
return works === true;
|
||||
}
|
||||
|
||||
/** The bytes that make the pane's `claude` read its clipboard: ^V, or ESC v
|
||||
* (Alt+V) on a Windows dashboard. */
|
||||
export function nativePasteKey(): string {
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Test seam: forget the server's answer. */
|
||||
export function __resetNativePaste() {
|
||||
works = null;
|
||||
asked = null;
|
||||
key = "\x16";
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
* dashboard's own machine attaches by path instead of uploading
|
||||
* (`localCandidates` — a drag's uri-list, or the plain-text paths a pasted
|
||||
* file-manager copy carries —, checked by the server in `sameFilesHere` —
|
||||
* `POST /api/run/local-paths` —, `localAttachment`). An attachment is a path
|
||||
* sent with the prompt; that check is the one call here to the server.
|
||||
* `POST /api/run/local-paths` —, `localAttachment`), and the pasted or
|
||||
* dropped files that machine's file manager holds (`fileManagerPaths`). An
|
||||
* attachment is a path sent with the prompt; those checks are the calls here
|
||||
* to the server.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -144,6 +146,53 @@ export async function sameFilesHere(paths: string[], files: File[]): Promise<boo
|
||||
}
|
||||
}
|
||||
|
||||
/** A pasted BITMAP (a screenshot), not a copied image FILE: browsers name the
|
||||
* clipboard's picture `image.png`, while a file manager's copy keeps its own
|
||||
* name. Only a bitmap goes to `claude`'s own image paste — a copied file is
|
||||
* pasted by its path, as a native terminal does (`claude` finds no picture
|
||||
* on a clipboard that holds a file) — and a bitmap that comes WITH text (a
|
||||
* spreadsheet's cells, a page's selection) is the text's picture: a
|
||||
* terminal pastes the text. */
|
||||
export function isClipboardBitmap(f: File): boolean {
|
||||
return f.type.startsWith("image/") && f.name === "image.png";
|
||||
}
|
||||
|
||||
/** The pasted or dropped `files` by their own paths on the dashboard's
|
||||
* machine, as its file manager holds them — a paste on its OS clipboard, a
|
||||
* drop as its selection, which is what a drag from it carries
|
||||
* (`POST /api/run/clipboard-files` / `selected-files`): a native terminal's
|
||||
* paths, and the one way past the upload limit. Null when this is not a
|
||||
* loopback browser, on a refusal (server mode), or when any file is not
|
||||
* held under the same name and size (an SSH tunnel's browser pastes
|
||||
* ANOTHER machine's files). Paths come back in `files`' order. */
|
||||
export async function fileManagerPaths(
|
||||
files: File[],
|
||||
from: "paste" | "drop"
|
||||
): Promise<string[] | null> {
|
||||
if (!isLocalBrowser() || files.length === 0) return null;
|
||||
try {
|
||||
const { files: held } = await (from === "paste"
|
||||
? api.run.clipboardFiles()
|
||||
: api.run.selectedFiles());
|
||||
const base = (p: string) =>
|
||||
p
|
||||
.replace(/[\\/]+$/, "")
|
||||
.split(/[\\/]/)
|
||||
.pop() ?? "";
|
||||
const left = [...held];
|
||||
const paths: string[] = [];
|
||||
for (const f of files) {
|
||||
const i = left.findIndex((h) => base(h.path) === f.name && (h.dir || h.size === f.size));
|
||||
if (i < 0) return null;
|
||||
paths.push(left[i]!.path.replace(/(.)[\\/]+$/, "$1"));
|
||||
left.splice(i, 1);
|
||||
}
|
||||
return paths;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The local files a drop or a paste names as `file://` URIs (a file
|
||||
* manager's, an editor's), as absolute paths — or null when it names none,
|
||||
* or anything that is not one. `file:///C:/x` is `C:/x`. */
|
||||
@@ -169,9 +218,11 @@ export function droppedFilePaths(dt: DataTransfer | null | undefined): string[]
|
||||
|
||||
/** A file on this machine, by its absolute path, as an attachment. */
|
||||
export function localAttachment(path: string): Attachment {
|
||||
// A Windows path in `/` form, like `claudeMention` (Windows takes either).
|
||||
const p = /^[A-Za-z]:\\/.test(path) ? path.replace(/\\/g, "/") : path;
|
||||
return {
|
||||
// A space would end the mention: Claude Code reads `@"…"` whole.
|
||||
path: /\s/.test(path) ? `@"${path}"` : `@${path}`,
|
||||
path: /\s/.test(p) ? `@"${p}"` : `@${p}`,
|
||||
name:
|
||||
path
|
||||
.replace(/[\\/]$/, "")
|
||||
|
||||
Reference in New Issue
Block a user