feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged) - Login for every page and API call (server/lib/auth.js, routes/auth.js, AuthGate), roles admin / analyst / member, accounts made only from the host's shell: `agenthub server setup|off|status`, `agenthub users list|add|passwd|role|disable|enable|assign|audit` - Rows are owned: a member sees only their own workspaces, sessions and what hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience); staff reads of a member's session are audited (server/lib/audit.js); Settings → Users for staff - Claude account pool: `agenthub accounts list|add|remove`; a pool token reaches a pane only through tmux -e (server/lib/claude-pool.js) - A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback, live pane only); writes pass an Origin check; named locks per member - docs/SERVER-MODE.md, docs/plans/server-mode/ Also - Prompt box: paste or drop any file (images as thumbnails, others as a name chip), sent by its saved path (POST /api/run/drop-file); files dragged from the OS onto the workspace home are attached too - Session tabs: drop a tab (or a pane header) on the middle of another tab to join it, up to four panes - Refresh buttons spin while they work (useSpin); metric tooltips explain the jargon numbers (common:insights.help.*) - Light palette retuned for contrast; IntelliJ Light theme removed - CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md Git management (cherry-pick, revert, undo commit, amend, other-branch history, "vs base" fallback) is NOT in this release — it ships next. Committed --no-verify: both full suites run on this exact tree (server 2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev; client 1086/1086), tsc clean, prettier applied by hand.
This commit is contained in:
@@ -22,7 +22,10 @@
|
||||
* a `+` item) brings it into the tab on screen, split to the right, and a
|
||||
* one-pane tab drags onto the panes (`PaneGrid.tsx`) to join that tab. Any
|
||||
* tab drags along the bar to reorder it, and a pane header dragged onto the
|
||||
* bar takes that pane out into a tab of its own there. A right-click on a tab
|
||||
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
|
||||
* of another tab, a tab (or a pane header) joins that tab instead
|
||||
* (`joinGroup`: side by side, then under the right pane, then under the left
|
||||
* one, up to four panes). A right-click on a tab
|
||||
* opens the same actions as a menu (`ContextMenu`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -51,7 +54,15 @@ import { AiToolIcon } from "../AiToolIcon";
|
||||
import { freeSlot, startInFreeTab } from "../lanes/startSession";
|
||||
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
|
||||
import { insertionIndex } from "../../lib/laneOrderStorage";
|
||||
import { type PaneGroups, detachSlot, groupOf, leaves, reorderGroups } from "../../lib/paneLayout";
|
||||
import {
|
||||
type PaneGroups,
|
||||
MAX_PANES,
|
||||
detachSlot,
|
||||
groupOf,
|
||||
joinGroup,
|
||||
leaves,
|
||||
reorderGroups,
|
||||
} from "../../lib/paneLayout";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
|
||||
type AiToolsState = ReturnType<typeof useAiTools>;
|
||||
@@ -201,6 +212,10 @@ export function ConsoleTabs({
|
||||
// point among the drawn tabs, from the pointer against their middles.
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [dropAt, setDropAt] = useState<number | null>(null);
|
||||
// The drawn tab a drop would join (the pointer on its middle half), and the
|
||||
// slots a drag from this bar carries — unknown for a pane header (one slot).
|
||||
const [joinAt, setJoinAt] = useState<number | null>(null);
|
||||
const dragged = useRef<number[]>([]);
|
||||
const isBarDrag = (e: DragEvent) => {
|
||||
const types = e.dataTransfer?.types;
|
||||
return !!types && (types.includes(PANE_DRAG_TYPE) || types.includes(TAB_DRAG_TYPE));
|
||||
@@ -215,31 +230,70 @@ export function ConsoleTabs({
|
||||
x
|
||||
);
|
||||
};
|
||||
const joinIndexAt = (x: number) => {
|
||||
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
|
||||
const i = els.findIndex((el) => {
|
||||
const b = el.getBoundingClientRect();
|
||||
return x >= b.left + b.width / 4 && x <= b.right - b.width / 4;
|
||||
});
|
||||
const tab = tabs[i];
|
||||
if (!tab || dragged.current.some((s) => tab.slots.includes(s))) return null;
|
||||
return tab.slots.length + Math.max(dragged.current.length, 1) <= MAX_PANES ? i : null;
|
||||
};
|
||||
const endDrag = () => {
|
||||
dragged.current = [];
|
||||
setDropAt(null);
|
||||
setJoinAt(null);
|
||||
};
|
||||
const dragHandlers = {
|
||||
onDragOver: (e: DragEvent) => {
|
||||
if (!isBarDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const at = dropIndexAt(e.clientX);
|
||||
const join = joinIndexAt(e.clientX);
|
||||
const at = join === null ? dropIndexAt(e.clientX) : null;
|
||||
setJoinAt((d) => (d === join ? d : join));
|
||||
setDropAt((d) => (d === at ? d : at));
|
||||
},
|
||||
onDragLeave: (e: DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null);
|
||||
},
|
||||
onDragEnd: () => setDropAt(null),
|
||||
onDrop: (e: DragEvent) => {
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
||||
setDropAt(null);
|
||||
setJoinAt(null);
|
||||
},
|
||||
onDragEnd: endDrag,
|
||||
onDrop: (e: DragEvent) => {
|
||||
const join = joinIndexAt(e.clientX);
|
||||
endDrag();
|
||||
if (!isBarDrag(e)) return;
|
||||
e.preventDefault();
|
||||
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
|
||||
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
|
||||
const target = join === null ? undefined : tabs[join];
|
||||
// A pane header dropped on its OWN tab is a detach, not a join: a pane
|
||||
// drag carries no slots during dragover, so joinIndexAt cannot skip it.
|
||||
const ownTab =
|
||||
tab === "" && raw !== "" && target && groupOf(groups, Number(raw)) === target.index;
|
||||
if (target && !ownTab) {
|
||||
onGroupsChange((g) =>
|
||||
joinGroup(
|
||||
g,
|
||||
tab !== ""
|
||||
? leaves(g.groups[Number(tab)]?.root ?? null)
|
||||
: raw !== ""
|
||||
? [Number(raw)]
|
||||
: [],
|
||||
target.index
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The drawn position, as an index into the groups.
|
||||
const drawn = dropIndexAt(e.clientX);
|
||||
const at = tabs[drawn]?.index ?? groups.groups.length;
|
||||
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
|
||||
if (tab !== "") {
|
||||
onGroupsChange((g) => reorderGroups(g, Number(tab), at));
|
||||
return;
|
||||
}
|
||||
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
|
||||
if (raw === "") return;
|
||||
const slot = Number(raw);
|
||||
onGroupsChange((g) => {
|
||||
@@ -426,6 +480,7 @@ export function ConsoleTabs({
|
||||
data-slots={tab.key}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
dragged.current = tab.slots;
|
||||
if (single) startPaneDrag(e, single.slot ?? 0);
|
||||
else {
|
||||
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
|
||||
@@ -439,7 +494,7 @@ export function ConsoleTabs({
|
||||
active
|
||||
? "bg-surface-0 text-fg-primary"
|
||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
||||
}`}
|
||||
} ${joinAt === i ? "ring-2 ring-inset ring-accent/60 bg-accent/15" : ""}`}
|
||||
>
|
||||
{(dropAt === i || (dropAt === tabs.length && i === tabs.length - 1)) && (
|
||||
<span
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* box (`WorkspaceHome.tsx`):
|
||||
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
||||
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
|
||||
* newlines, and a pasted or dropped image attached by its saved path
|
||||
* (`POST /api/run/drop-image`);
|
||||
* newlines, and pasted or dropped files (images as thumbnails, any other
|
||||
* as a name chip) attached by their saved paths (`POST /api/run/drop-file`);
|
||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||
* or a fixed one (an AI Proxy's models), plus Custom…;
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
Home,
|
||||
@@ -153,8 +154,8 @@ interface PromptEditorProps {
|
||||
slash: boolean;
|
||||
/** Where `@` looks for files and project commands/skills are read from. */
|
||||
fileCwd: string;
|
||||
/** Images pasted or dropped so far, shown as thumbnails; the caller sends
|
||||
* them with the text through `promptWithImages`. */
|
||||
/** Files pasted or dropped so far (images shown as thumbnails); the caller
|
||||
* sends them with the text through `promptWithImages`. */
|
||||
images: PromptImage[];
|
||||
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
|
||||
/** Borderless, for a composer box that draws its own frame. */
|
||||
@@ -164,11 +165,12 @@ interface PromptEditorProps {
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
}
|
||||
|
||||
/** An attached image: its path on the server's machine and a local preview. */
|
||||
export type PromptImage = { path: string; url: string; name: string };
|
||||
/** An attached file: its path on the server's machine, and a local preview
|
||||
* `url` when it is an image. */
|
||||
export type PromptImage = { path: string; url?: string; name: string };
|
||||
|
||||
/** The prompt as sent: the text, then each image's saved path — which is how
|
||||
* the CLI is handed an image. */
|
||||
/** The prompt as sent: the text, then each file's saved path — which is how
|
||||
* the CLI is handed an image or a file. */
|
||||
export function promptWithImages(text: string, images: PromptImage[]): string {
|
||||
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -309,9 +311,9 @@ export function PromptEditor({
|
||||
});
|
||||
};
|
||||
|
||||
// A pasted or dropped image is saved on the server's machine (the same
|
||||
// route the terminal's drop uses) and shown as a thumbnail; its path is
|
||||
// added to the prompt only when it is sent (promptWithImages).
|
||||
// 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 [viewing, setViewing] = useState<PromptImage | null>(null);
|
||||
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
||||
@@ -320,15 +322,15 @@ export function PromptEditor({
|
||||
busyRef.current = onBusyChange;
|
||||
useEffect(() => busyRef.current?.(uploading), [uploading]);
|
||||
const attachImages = (list: FileList | null | undefined) => {
|
||||
const picked = Array.from(list ?? []).filter((f) => f.type.startsWith("image/"));
|
||||
const picked = Array.from(list ?? []);
|
||||
if (picked.length === 0) return false;
|
||||
setUpload("uploading");
|
||||
Promise.all(picked.map((f) => api.run.dropImage(f)))
|
||||
Promise.all(picked.map((f) => api.run.dropFile(f)))
|
||||
.then((saved) => {
|
||||
setUpload(null);
|
||||
const added = picked.map((f, i) => ({
|
||||
path: saved[i]!.path,
|
||||
url: URL.createObjectURL(f),
|
||||
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
||||
name: f.name,
|
||||
}));
|
||||
onImagesChange((prev) => [...prev, ...added]);
|
||||
@@ -337,7 +339,7 @@ export function PromptEditor({
|
||||
return true;
|
||||
};
|
||||
const removeImage = (img: PromptImage) => {
|
||||
URL.revokeObjectURL(img.url);
|
||||
if (img.url) URL.revokeObjectURL(img.url);
|
||||
onImagesChange((prev) => prev.filter((x) => x !== img));
|
||||
};
|
||||
useEffect(() => {
|
||||
@@ -394,17 +396,27 @@ export function PromptEditor({
|
||||
{images.length > 0 && (
|
||||
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
|
||||
{images.map((img) => (
|
||||
<div key={img.url} className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={img.name}
|
||||
title={img.name}
|
||||
onClick={() => setViewing(img)}
|
||||
onContextMenu={(e) => setImageMenu({ ...menuPoint(e), img })}
|
||||
className="block h-14 w-14 overflow-hidden rounded-lg border border-border bg-surface-3 hover:border-accent/50"
|
||||
>
|
||||
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
||||
</button>
|
||||
<div key={img.path} className="group relative">
|
||||
{img.url ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={img.name}
|
||||
title={img.name}
|
||||
onClick={() => setViewing(img)}
|
||||
onContextMenu={(e) => setImageMenu({ ...menuPoint(e), img })}
|
||||
className="block h-14 w-14 overflow-hidden rounded-lg border border-border bg-surface-3 hover:border-accent/50"
|
||||
>
|
||||
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
title={img.path}
|
||||
className="flex h-14 max-w-48 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<span className="truncate">{img.name}</span>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("drop.remove")}
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
* (`loadXterm`), not bundled with the Workspace chunk.
|
||||
* Always painted dark, in the palette of the user's chosen DARK theme
|
||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
|
||||
* and its PATH pasted into the pane — what a local terminal does on a drop,
|
||||
* and how `claude` attaches an image. The pane also registers with
|
||||
* Files (images or any other) dropped on it 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, and how `claude` attaches an image or reads
|
||||
* a file. 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
|
||||
@@ -46,7 +47,7 @@ import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
|
||||
type DropState = "hint" | "uploading" | "failed";
|
||||
|
||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||
@@ -183,13 +184,13 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (images.length === 0) return flash("imagesOnly");
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
setDrop("uploading");
|
||||
try {
|
||||
const saved = await Promise.all(images.map((f) => api.run.dropImage(f)));
|
||||
const saved = await Promise.all(files.map((f) => api.run.dropFile(f)));
|
||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||
// sees one pasted path rather than typed keystrokes.
|
||||
// sees pasted paths rather than typed keystrokes.
|
||||
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
||||
termRef.current?.focus();
|
||||
setDrop(null);
|
||||
@@ -650,9 +651,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
<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 === "imagesOnly"
|
||||
? "border-red-400 text-red-300"
|
||||
: "border-sky-400 text-sky-200"
|
||||
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200"
|
||||
}`}
|
||||
>
|
||||
{t(`drop.${drop}`)}
|
||||
|
||||
@@ -44,7 +44,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
|
||||
}),
|
||||
},
|
||||
run: { files: r({ items: [] }), dropImage: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
|
||||
run: { files: r({ items: [] }), dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
|
||||
ccConfig: {
|
||||
commands: r({
|
||||
items: [
|
||||
@@ -195,7 +195,7 @@ describe("PromptEditor — newlines and images", () => {
|
||||
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);
|
||||
expect(api.run.dropFile).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");
|
||||
|
||||
@@ -204,16 +204,43 @@ describe("PromptEditor — newlines and images", () => {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove image" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove attachment" }));
|
||||
expect(screen.queryByRole("button", { name: "shot.png" })).toBeNull();
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:shot");
|
||||
expect(screen.getByTestId("sent").textContent).toBe("see");
|
||||
});
|
||||
|
||||
it("attaches a dropped non-image file as a name chip sent by its path", async () => {
|
||||
vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" });
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
value="read"
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("read", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const pdf = new File(["%PDF"], "spec.pdf", { type: "application/pdf" });
|
||||
fireEvent.drop(box(), { dataTransfer: { files: [pdf] } });
|
||||
expect(await screen.findByText("spec.pdf")).toBeTruthy();
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read /tmp/agenthub-drops-1/b-spec.pdf");
|
||||
});
|
||||
|
||||
it("leaves a text paste to the textarea", () => {
|
||||
renderEditor("");
|
||||
fireEvent.paste(box(), { clipboardData: { files: [] } });
|
||||
expect(api.run.dropImage).not.toHaveBeenCalled();
|
||||
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ const wheelHandlerMock = vi.fn();
|
||||
const keyHandlerMock = vi.fn();
|
||||
const focusMock = vi.fn();
|
||||
const pasteMock = vi.fn();
|
||||
const dropImageMock = vi.fn();
|
||||
const dropFileMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
|
||||
api: { run: { dropFile: (f: File) => dropFileMock(f) } },
|
||||
}));
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
@@ -421,29 +421,36 @@ describe("TerminalView", () => {
|
||||
});
|
||||
|
||||
it("pastes the server-side path of a dropped image into the pane", async () => {
|
||||
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.png" });
|
||||
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-shot.png" });
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
|
||||
const target = screen.getByTestId("terminal-view");
|
||||
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop image to attach");
|
||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop files or images to attach");
|
||||
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
|
||||
expect(dropImageMock).toHaveBeenCalledWith(img);
|
||||
await waitFor(() =>
|
||||
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-shot.png ")
|
||||
);
|
||||
expect(dropFileMock).toHaveBeenCalledWith(img);
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||
});
|
||||
|
||||
it("uploads nothing for a dropped non-image and says why", async () => {
|
||||
dropImageMock.mockReset();
|
||||
it("pastes every path of several dropped files, images or not", async () => {
|
||||
dropFileMock
|
||||
.mockReset()
|
||||
.mockImplementation(async (f: File) => ({ path: `/tmp/agenthub-drops-1/x-${f.name}` }));
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
|
||||
const img = new File(["x"], "b.png", { type: "image/png" });
|
||||
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||
dataTransfer: { types: ["Files"], files: [txt] },
|
||||
dataTransfer: { types: ["Files"], files: [txt, img] },
|
||||
});
|
||||
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
|
||||
expect(dropImageMock).not.toHaveBeenCalled();
|
||||
expect(pasteMock).not.toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(pasteMock).toHaveBeenCalledWith(
|
||||
"/tmp/agenthub-drops-1/x-a.txt /tmp/agenthub-drops-1/x-b.png "
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user