feat(terminal): a text drag (a Files/Changes row, a skill) is pasted into the pane

xterm.js has no drop handling of its own, so dropped text was lost; the pane
now pastes it as a local terminal does, with a "Drop to paste" hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 14:43:03 +07:00
parent 763759189f
commit b08482c6aa
4 changed files with 40 additions and 8 deletions
+21 -8
View File
@@ -17,7 +17,7 @@
* 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, and how `claude` attaches an image or reads
* a file. The pane also registers with
* a file. 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,7 +63,7 @@ import { notePaneFocus, registerPane } from "../../lib/panePaste";
import { attachImeInput } from "../../lib/imeInput";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
type DropState = "hint" | "uploading" | "failed";
type DropState = "hint" | "paste" | "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";
@@ -134,6 +134,10 @@ export function loadXterm(): Promise<Xterm> {
const RETRY_MIN_MS = 1500;
const RETRY_MAX_MS = 30_000;
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
/** A text drag (a Files/Changes row, a skill, selected text): xterm.js has no
* drop handling of its own, so the pane pastes it, as a local terminal does. */
const hasText = (e: DragEvent) =>
!hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain");
interface TerminalViewProps {
runId: string;
@@ -200,7 +204,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const ctrlVRef = useRef(false);
const onDrop = (e: DragEvent) => {
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
if (hasText(e)) {
e.preventDefault();
dragDepth.current = 0;
setDrop(null);
const text = e.dataTransfer.getData("text/plain");
if (text) termRef.current?.paste(text + " ");
termRef.current?.focus();
return;
}
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth.current = 0;
void attachFiles(Array.from(e.dataTransfer.files));
@@ -673,20 +686,20 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
className="relative h-full w-full pb-1 pl-3 pr-1 pt-1.5"
style={{ backgroundColor: palette.background }}
onDragEnter={(e) => {
if (!hasFiles(e)) return;
if (!hasFiles(e) && !hasText(e)) return;
e.preventDefault();
dragDepth.current += 1;
setDrop("hint");
setDrop(hasFiles(e) ? "hint" : "paste");
}}
onDragOver={(e) => {
if (!hasFiles(e)) return;
if (!hasFiles(e) && !hasText(e)) return;
e.preventDefault(); // without it the browser refuses the drop
e.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(e) => {
if (!hasFiles(e)) return;
if (!hasFiles(e) && !hasText(e)) return;
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
if (dragDepth.current === 0) setDrop((d) => (d === "hint" || d === "paste" ? null : d));
}}
onDrop={onDrop}
onPasteCapture={onPaste}
@@ -512,6 +512,23 @@ describe("TerminalView", () => {
);
});
it("pastes the text of a dragged Files-panel row, uploading nothing", () => {
dropFileMock.mockReset();
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const data: Record<string, string> = {
"application/x-agenthub-attach": '{"path":"@src/a.ts","name":"a.ts"}',
"text/plain": "src/a.ts",
};
const dataTransfer = { types: Object.keys(data), files: [], getData: (k: string) => data[k] ?? "" };
fireEvent.dragEnter(screen.getByTestId("terminal-view"), { dataTransfer });
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop to paste into the terminal");
fireEvent.drop(screen.getByTestId("terminal-view"), { dataTransfer });
expect(pasteMock).toHaveBeenCalledWith("src/a.ts ");
expect(screen.queryByTestId("terminal-drop")).toBeNull();
expect(dropFileMock).not.toHaveBeenCalled();
});
it("uploads a pasted image and pastes its path, like a drop", async () => {
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
pasteMock.mockClear();
+1
View File
@@ -65,6 +65,7 @@
},
"drop": {
"hint": "Drop files or images to attach",
"paste": "Drop to paste into the terminal",
"uploading": "Uploading…",
"failed": "Could not attach the file",
"remove": "Remove attachment",
+1
View File
@@ -65,6 +65,7 @@
},
"drop": {
"hint": "Thả file hoặc ảnh để đính kèm",
"paste": "Thả để dán vào terminal",
"uploading": "Đang tải lên…",
"failed": "Không đính kèm được file",
"remove": "Bỏ đính kèm",