feat(ui): accept drag-and-drop attachments in the new-session composer (#111530)

* feat(ui): accept drag-and-drop attachments in new-session composer

The new-thread composer only accepted attachments via paste and the +
menu; dragging a file onto it was silently ignored. Wire the shared
chat attachment drop handling onto the composer shell with the same
balanced drag affordance as the chat pane, and gate both composers'
drop/dragover cancellation on file drags so text/URL drops keep the
textarea's native behavior.

* fix(ui): cancel non-file drops outside editable composer targets

A URL dropped on the transcript, header, or composer chrome would hit
the browser default and navigate the app away, discarding drafts. Keep
native text/URL drops only when the drop target is an editable control;
cancel them everywhere else in both composers.

* fix(ui): treat disabled and readonly inputs as non-editable drop targets

A URL dropped precisely on a disabled composer textarea would still hit
the browser default and could navigate away. Check actual editability
(disabled/readOnly/isContentEditable) instead of selector shape.

* fix(ui): limit native drops to text-entry inputs

Enabled non-text inputs (checkbox, range) counted as editable drop
targets, so a URL dropped on one skipped cancellation and could
navigate the browser away. Restrict the native-drop exception to
text-capable input types, textareas, and contenteditable content.
This commit is contained in:
Peter Steinberger
2026-07-19 19:55:29 -07:00
committed by GitHub
parent 64c82812a5
commit da10827439
6 changed files with 333 additions and 9 deletions

View File

@@ -3326,6 +3326,31 @@ describe("chat attachment picker", () => {
expect(chat.hasAttribute("data-attachment-drop-active")).toBe(false);
});
it("cancels non-file drops outside the composer textarea but keeps them native inside it", () => {
const container = renderChatView();
const chat = requireElement(container, "section.card.chat", "chat drop target");
const textarea = requireElement(
container,
".agent-chat__composer-combobox > textarea",
"composer textarea",
);
const outsideDrop = createDragEvent("drop", ["text/uri-list"]);
chat.dispatchEvent(outsideDrop);
expect(outsideDrop.defaultPrevented).toBe(true);
const textareaDrop = createDragEvent("drop", ["text/uri-list"]);
textarea.dispatchEvent(textareaDrop);
expect(textareaDrop.defaultPrevented).toBe(false);
const range = document.createElement("input");
range.type = "range";
chat.append(range);
const rangeDrop = createDragEvent("drop", ["text/uri-list"]);
range.dispatchEvent(rangeDrop);
expect(rangeDrop.defaultPrevented).toBe(true);
});
it("turns large pasted plain text into a compact attachment", async () => {
const onAttachmentsChange = vi.fn();
const container = renderChatView({
@@ -3490,7 +3515,9 @@ describe("chat attachment picker", () => {
});
const droppedFile = new File(["%PDF-1.4\n"], "brief.pdf", { type: "application/pdf" });
const dropEvent = new Event("drop", { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, "dataTransfer", { value: { files: [droppedFile] } });
Object.defineProperty(dropEvent, "dataTransfer", {
value: { files: [droppedFile], types: ["Files"] },
});
try {
textarea.dispatchEvent(pasteEvent);

View File

@@ -26,7 +26,11 @@ import type { ChatSideResult, ChatSideResultPending } from "../../lib/chat/side-
import type { EmbedSandboxMode } from "../../lib/chat/tool-display.ts";
import type { ProviderUsageDisplayProps } from "../../lib/provider-quota-summary.ts";
import type { UiSessionDefaultsHost } from "../../lib/sessions/session-key.ts";
import { handleChatAttachmentDrop } from "./components/chat-attachments.ts";
import {
handleChatAttachmentDrop,
isEditableDropTarget,
isFileDrag,
} from "./components/chat-attachments.ts";
import {
renderBackgroundTasksRail,
type BackgroundTasksProps,
@@ -67,10 +71,6 @@ import type { ChatRunUiStatus } from "./run-lifecycle.ts";
import type { CompactionStatus, FallbackStatus, PlanStatus } from "./tool-stream.ts";
import "../../components/resizable-divider.ts";
function isFileDrag(dataTransfer: DataTransfer | null): boolean {
return Array.from(dataTransfer?.types ?? []).includes("Files");
}
export type ChatProps = {
transcript: ChatTranscriptController;
paneId: string;
@@ -483,6 +483,15 @@ export function renderChat(props: ChatProps) {
: {},
)}
@drop=${(event: DragEvent) => {
// Text/URL drops stay native only inside editable controls; anywhere
// else they are cancelled so a dropped link cannot navigate the app
// away. Session drags are handled by the parent chat page either way.
if (!isFileDrag(event.dataTransfer)) {
if (!isEditableDropTarget(event)) {
event.preventDefault();
}
return;
}
event.preventDefault();
clearAttachmentDropActive(event);
if (canCompose) {
@@ -492,9 +501,18 @@ export function renderChat(props: ChatProps) {
@dragenter=${(event: DragEvent) => setAttachmentDropActive(event, true)}
@dragleave=${(event: DragEvent) => setAttachmentDropActive(event, false)}
@dragover=${(event: DragEvent) => {
if (!isFileDrag(event.dataTransfer)) {
if (!isEditableDropTarget(event)) {
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "none";
}
}
return;
}
event.preventDefault();
if (canCompose && event.dataTransfer && isFileDrag(event.dataTransfer)) {
event.dataTransfer.dropEffect = "copy";
if (event.dataTransfer) {
event.dataTransfer.dropEffect = canCompose ? "copy" : "none";
}
}}
@keydown=${(event: KeyboardEvent) => {

View File

@@ -36,6 +36,39 @@ type ChatAttachmentControlsProps = {
readSignal?: AbortSignal;
};
export function isFileDrag(dataTransfer: DataTransfer | null): boolean {
return Array.from(dataTransfer?.types ?? []).includes("Files");
}
const TEXT_ENTRY_INPUT_TYPES = new Set([
"email",
"number",
"password",
"search",
"tel",
"text",
"url",
]);
// Native text/URL drop insertion is only meaningful on controls that can
// actually accept it; anywhere else (disabled/readonly inputs, non-text
// controls like checkbox/range) an uncancelled URL drop navigates the app
// away and discards unsent drafts.
export function isEditableDropTarget(event: DragEvent): boolean {
const target = event.target;
if (!(target instanceof Element)) {
return false;
}
const editable = target.closest("textarea, input, [contenteditable]");
if (editable instanceof HTMLInputElement) {
return TEXT_ENTRY_INPUT_TYPES.has(editable.type) && !editable.disabled && !editable.readOnly;
}
if (editable instanceof HTMLTextAreaElement) {
return !editable.disabled && !editable.readOnly;
}
return editable instanceof HTMLElement && editable.isContentEditable;
}
function currentAttachments(props: ChatAttachmentControlsProps): ChatAttachment[] {
return props.getAttachments?.() ?? props.attachments ?? [];
}

View File

@@ -0,0 +1,149 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { waitForFast } from "../../test-helpers/wait-for.ts";
import { NewSessionAttachmentDraft } from "./attachment-draft.ts";
import { renderNewSessionDraftComposer } from "./composer.ts";
import { NewSessionModelControl } from "./model-control.ts";
const attachmentDrafts: NewSessionAttachmentDraft[] = [];
function renderComposer(overrides: { submitting?: boolean; messageLocked?: boolean } = {}) {
const container = document.createElement("div");
const attachmentDraft = new NewSessionAttachmentDraft(() => undefined);
attachmentDrafts.push(attachmentDraft);
render(
renderNewSessionDraftComposer({
agentId: "main",
attachmentDraft,
canSubmit: true,
context: undefined,
isCatalogTarget: true,
message: "",
modelControl: new NewSessionModelControl(() => undefined),
requiresModifier: false,
submitting: overrides.submitting ?? false,
messageLocked: overrides.messageLocked,
onInput: () => undefined,
onSubmit: () => undefined,
}),
container,
);
const composer = container.querySelector<HTMLElement>(".new-session-page__composer");
if (!composer) {
throw new Error("Expected new-session composer");
}
return { attachmentDraft, composer };
}
function createDragEvent(type: string, files: File[] = [], types = ["Files"]): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, "dataTransfer", {
value: { files, types },
});
return event;
}
afterEach(() => {
for (const attachmentDraft of attachmentDrafts) {
attachmentDraft.reset({ release: true });
}
attachmentDrafts.length = 0;
vi.restoreAllMocks();
});
describe("new-session composer attachment drops", () => {
it("adds a dropped file through the shared attachment handling", async () => {
const { attachmentDraft, composer } = renderComposer();
const replace = vi.spyOn(attachmentDraft, "replace");
const file = new File(["image"], "pic.png", { type: "image/png" });
composer.dispatchEvent(createDragEvent("drop", [file]));
await waitForFast(() => expect(replace).toHaveBeenCalledOnce());
expect(replace).toHaveBeenCalledWith([
expect.objectContaining({
fileName: "pic.png",
mimeType: "image/png",
sizeBytes: file.size,
}),
]);
expect(attachmentDraft.attachments).toHaveLength(1);
expect(attachmentDraft.attachments[0]).toMatchObject({
fileName: "pic.png",
mimeType: "image/png",
sizeBytes: file.size,
});
});
it("keeps the drop affordance balanced across nested drag targets", () => {
const { composer } = renderComposer();
composer.dispatchEvent(createDragEvent("dragenter"));
expect(composer.hasAttribute("data-attachment-drop-active")).toBe(true);
composer.dispatchEvent(createDragEvent("dragenter"));
composer.dispatchEvent(createDragEvent("dragleave"));
expect(composer.hasAttribute("data-attachment-drop-active")).toBe(true);
composer.dispatchEvent(createDragEvent("dragleave"));
expect(composer.hasAttribute("data-attachment-drop-active")).toBe(false);
});
it("keeps non-file drops native inside the textarea and cancels them elsewhere", () => {
const { attachmentDraft, composer } = renderComposer();
const replace = vi.spyOn(attachmentDraft, "replace");
const textarea = composer.querySelector<HTMLTextAreaElement>("textarea");
if (!textarea) {
throw new Error("Expected composer textarea");
}
const dragenter = createDragEvent("dragenter", [], ["text/plain"]);
composer.dispatchEvent(dragenter);
expect(composer.hasAttribute("data-attachment-drop-active")).toBe(false);
const textareaDrop = createDragEvent("drop", [], ["text/plain"]);
textarea.dispatchEvent(textareaDrop);
expect(textareaDrop.defaultPrevented).toBe(false);
const shellDrop = createDragEvent("drop", [], ["text/uri-list"]);
composer.dispatchEvent(shellDrop);
expect(shellDrop.defaultPrevented).toBe(true);
expect(replace).not.toHaveBeenCalled();
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
composer.append(checkbox);
const checkboxDrop = createDragEvent("drop", [], ["text/uri-list"]);
checkbox.dispatchEvent(checkboxDrop);
expect(checkboxDrop.defaultPrevented).toBe(true);
});
it.each([
{ submitting: true, messageLocked: false },
{ submitting: false, messageLocked: true },
])("ignores drops while the composer is disabled", (disabled) => {
const { attachmentDraft, composer } = renderComposer(disabled);
const replace = vi.spyOn(attachmentDraft, "replace");
const readAsDataUrl = vi.spyOn(FileReader.prototype, "readAsDataURL");
const file = new File(["image"], "pic.png", { type: "image/png" });
composer.dispatchEvent(createDragEvent("dragenter"));
composer.dispatchEvent(createDragEvent("drop", [file]));
expect(composer.hasAttribute("data-attachment-drop-active")).toBe(false);
expect(readAsDataUrl).not.toHaveBeenCalled();
expect(replace).not.toHaveBeenCalled();
expect(attachmentDraft.attachments).toEqual([]);
const textarea = composer.querySelector<HTMLTextAreaElement>("textarea");
if (!textarea) {
throw new Error("Expected composer textarea");
}
expect(textarea.disabled).toBe(true);
const disabledTextareaDrop = createDragEvent("drop", [], ["text/uri-list"]);
textarea.dispatchEvent(disabledTextareaDrop);
expect(disabledTextareaDrop.defaultPrevented).toBe(true);
});
});

View File

@@ -4,7 +4,10 @@ import "../../components/tooltip.ts";
import { t } from "../../i18n/index.ts";
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
import {
handleChatAttachmentDrop,
handleChatAttachmentPaste,
isEditableDropTarget,
isFileDrag,
renderAttachmentPreview,
renderChatAttachmentInputs,
renderChatAttachmentMenu,
@@ -59,8 +62,69 @@ function renderNewSessionComposer(options: NewSessionComposerOptions) {
onPendingReadsChange: options.onPendingReadsChange,
readSignal: options.readSignal,
};
const enabled = !options.submitting && !options.messageLocked;
// Nested dragenter/dragleave events must stay balanced so crossing composer
// children does not flicker the file drop affordance.
let attachmentDragDepth = 0;
const setAttachmentDropActive = (event: DragEvent, active: boolean) => {
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) {
return;
}
if (active) {
if (!enabled || !isFileDrag(event.dataTransfer)) {
return;
}
attachmentDragDepth += 1;
} else {
attachmentDragDepth = Math.max(0, attachmentDragDepth - 1);
}
target.toggleAttribute("data-attachment-drop-active", attachmentDragDepth > 0);
};
const clearAttachmentDropActive = (event: DragEvent) => {
attachmentDragDepth = 0;
const target = event.currentTarget;
if (target instanceof HTMLElement) {
target.removeAttribute("data-attachment-drop-active");
}
};
return html`
<div class="agent-chat__composer-shell new-session-page__composer">
<div
class="agent-chat__composer-shell new-session-page__composer"
@drop=${(event: DragEvent) => {
// Text/URL drops stay native only inside the textarea; elsewhere they
// are cancelled so a dropped link cannot navigate the app away. File
// drops are cancelled even while disabled for the same reason.
if (!isFileDrag(event.dataTransfer)) {
if (!isEditableDropTarget(event)) {
event.preventDefault();
}
return;
}
event.preventDefault();
clearAttachmentDropActive(event);
if (enabled) {
handleChatAttachmentDrop(event, attachmentProps);
}
}}
@dragenter=${(event: DragEvent) => setAttachmentDropActive(event, true)}
@dragleave=${(event: DragEvent) => setAttachmentDropActive(event, false)}
@dragover=${(event: DragEvent) => {
if (!isFileDrag(event.dataTransfer)) {
if (!isEditableDropTarget(event)) {
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "none";
}
}
return;
}
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = enabled ? "copy" : "none";
}
}}
>
<div class="agent-chat__input">
${renderChatAttachmentInputs(attachmentProps)} ${renderAttachmentPreview(attachmentProps)}
<div class="agent-chat__composer-input-row">

View File

@@ -245,7 +245,40 @@ wa-popover.new-session-page__select--folder::part(body) {
/* The chip row already spaces itself; keep the composer snug beneath it. */
.new-session-page__composer.agent-chat__composer-shell {
position: relative;
margin-top: 0;
border-radius: var(--radius-md);
}
.new-session-page__composer::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
border-radius: inherit;
background: var(--accent);
opacity: 0;
transition: opacity 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.new-session-page__composer[data-attachment-drop-active]::after {
opacity: 0.1;
}
.new-session-page__composer > * {
transition: opacity 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.new-session-page__composer[data-attachment-drop-active] > * {
opacity: 0.5;
}
@media (prefers-reduced-motion: reduce) {
.new-session-page__composer::after,
.new-session-page__composer > * {
transition-duration: 0ms;
}
}
.new-session-page__target-icon {