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 * 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 * (`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 * 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 * `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. * pasted into whichever of the workspace's panes was focused last.
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission * 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 { attachImeInput } from "../../lib/imeInput";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont"; 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). */ /** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader"; export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
@@ -134,6 +134,10 @@ export function loadXterm(): Promise<Xterm> {
const RETRY_MIN_MS = 1500; const RETRY_MIN_MS = 1500;
const RETRY_MAX_MS = 30_000; const RETRY_MAX_MS = 30_000;
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files"); 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 { interface TerminalViewProps {
runId: string; runId: string;
@@ -200,7 +204,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const ctrlVRef = useRef(false); const ctrlVRef = useRef(false);
const onDrop = (e: DragEvent) => { 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(); e.preventDefault();
dragDepth.current = 0; dragDepth.current = 0;
void attachFiles(Array.from(e.dataTransfer.files)); 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" className="relative h-full w-full pb-1 pl-3 pr-1 pt-1.5"
style={{ backgroundColor: palette.background }} style={{ backgroundColor: palette.background }}
onDragEnter={(e) => { onDragEnter={(e) => {
if (!hasFiles(e)) return; if (!hasFiles(e) && !hasText(e)) return;
e.preventDefault(); e.preventDefault();
dragDepth.current += 1; dragDepth.current += 1;
setDrop("hint"); setDrop(hasFiles(e) ? "hint" : "paste");
}} }}
onDragOver={(e) => { onDragOver={(e) => {
if (!hasFiles(e)) return; if (!hasFiles(e) && !hasText(e)) return;
e.preventDefault(); // without it the browser refuses the drop e.preventDefault(); // without it the browser refuses the drop
e.dataTransfer.dropEffect = "copy"; e.dataTransfer.dropEffect = "copy";
}} }}
onDragLeave={(e) => { onDragLeave={(e) => {
if (!hasFiles(e)) return; if (!hasFiles(e) && !hasText(e)) return;
dragDepth.current = Math.max(0, dragDepth.current - 1); 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} onDrop={onDrop}
onPasteCapture={onPaste} 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 () => { it("uploads a pasted image and pastes its path, like a drop", async () => {
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" }); dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
pasteMock.mockClear(); pasteMock.mockClear();
+1
View File
@@ -65,6 +65,7 @@
}, },
"drop": { "drop": {
"hint": "Drop files or images to attach", "hint": "Drop files or images to attach",
"paste": "Drop to paste into the terminal",
"uploading": "Uploading…", "uploading": "Uploading…",
"failed": "Could not attach the file", "failed": "Could not attach the file",
"remove": "Remove attachment", "remove": "Remove attachment",
+1
View File
@@ -65,6 +65,7 @@
}, },
"drop": { "drop": {
"hint": "Thả file hoặc ảnh để đính kèm", "hint": "Thả file hoặc ảnh để đính kèm",
"paste": "Thả để dán vào terminal",
"uploading": "Đang tải lên…", "uploading": "Đang tải lên…",
"failed": "Không đính kèm được file", "failed": "Không đính kèm được file",
"remove": "Bỏ đính kèm", "remove": "Bỏ đính kèm",