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}