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 {