From da10827439a41e54c2ffb18cb361ae7dfd204758 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 19 Jul 2026 19:55:29 -0700 Subject: [PATCH] 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. --- ui/src/pages/chat/chat-view.test.ts | 29 +++- ui/src/pages/chat/chat-view.ts | 32 +++- .../pages/chat/components/chat-attachments.ts | 33 ++++ ui/src/pages/new-session/composer.test.ts | 149 ++++++++++++++++++ ui/src/pages/new-session/composer.ts | 66 +++++++- ui/src/styles/new-session.css | 33 ++++ 6 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 ui/src/pages/new-session/composer.test.ts diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index e019027ca3d8..a5bb04d2146c 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -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); diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 7d8dcff41119..4af418f2391c 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -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) => { diff --git a/ui/src/pages/chat/components/chat-attachments.ts b/ui/src/pages/chat/components/chat-attachments.ts index 4bd954e25fc7..566b1bd2fdeb 100644 --- a/ui/src/pages/chat/components/chat-attachments.ts +++ b/ui/src/pages/chat/components/chat-attachments.ts @@ -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 ?? []; } diff --git a/ui/src/pages/new-session/composer.test.ts b/ui/src/pages/new-session/composer.test.ts new file mode 100644 index 000000000000..7bf324888f97 --- /dev/null +++ b/ui/src/pages/new-session/composer.test.ts @@ -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(".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("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("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); + }); +}); diff --git a/ui/src/pages/new-session/composer.ts b/ui/src/pages/new-session/composer.ts index 60ac45a64bcc..b3865ed9f92c 100644 --- a/ui/src/pages/new-session/composer.ts +++ b/ui/src/pages/new-session/composer.ts @@ -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` -
+
{ + // 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"; + } + }} + >
${renderChatAttachmentInputs(attachmentProps)} ${renderAttachmentPreview(attachmentProps)}
diff --git a/ui/src/styles/new-session.css b/ui/src/styles/new-session.css index 2dcca2b797aa..3938eb226e63 100644 --- a/ui/src/styles/new-session.css +++ b/ui/src/styles/new-session.css @@ -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 {