diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 635f3ef854bc..964c551cd98b 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -554,6 +554,12 @@ function requireElement(container: Element, selector: string, label: string): El return element; } +function createDragEvent(type: string, types = ["Files"]): Event { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { value: { types } }); + return event; +} + function itemAt(items: ArrayLike, index: number, label: string): T { return expectDefined(items[index], `${label} ${index}`); } @@ -3527,6 +3533,38 @@ describe("chat slash menu accessibility", () => { }); describe("chat attachment picker", () => { + it("highlights only the chat pane receiving a file drag", () => { + const first = renderChatView(); + const second = renderChatView(); + const firstChat = requireElement(first, "section.card.chat", "first chat drop target"); + const secondChat = requireElement(second, "section.card.chat", "second chat drop target"); + + secondChat.dispatchEvent(createDragEvent("dragenter")); + + expect(firstChat.hasAttribute("data-attachment-drop-active")).toBe(false); + expect(secondChat.hasAttribute("data-attachment-drop-active")).toBe(true); + + secondChat.dispatchEvent(createDragEvent("dragleave")); + + expect(secondChat.hasAttribute("data-attachment-drop-active")).toBe(false); + }); + + it("keeps the file drop overlay stable across nested drag targets", () => { + const container = renderChatView(); + const chat = requireElement(container, "section.card.chat", "chat drop target"); + + chat.dispatchEvent(createDragEvent("dragenter")); + chat.dispatchEvent(createDragEvent("dragenter")); + chat.dispatchEvent(createDragEvent("dragleave")); + expect(chat.hasAttribute("data-attachment-drop-active")).toBe(true); + + chat.dispatchEvent(createDragEvent("dragleave")); + expect(chat.hasAttribute("data-attachment-drop-active")).toBe(false); + + chat.dispatchEvent(createDragEvent("dragenter", ["application/x-openclaw-session"])); + expect(chat.hasAttribute("data-attachment-drop-active")).toBe(false); + }); + it("turns large pasted plain text into a compact attachment", async () => { const onAttachmentsChange = vi.fn(); const container = renderChatView({ diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 40ed18200d95..83b0a7b5de8b 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -60,6 +60,10 @@ import type { ChatRunUiStatus } from "./run-lifecycle.ts"; import type { CompactionStatus, FallbackStatus } 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 = { paneId: string; sessionKey: string; @@ -224,6 +228,31 @@ export function renderChat(props: ChatProps) { }; const sideChatVisible = isSideChatPanelVisible(sideChatProps); let chatSection: HTMLElement | null = null; + // Nested dragenter/dragleave events must stay balanced so crossing transcript + // children does not flicker the pane-level file drop affordance. + let attachmentDragDepth = 0; + const setAttachmentDropActive = (event: DragEvent, active: boolean) => { + const target = event.currentTarget; + if (!(target instanceof HTMLElement)) { + return; + } + if (active) { + if (!canCompose || !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"); + } + }; const thread = renderChatThread({ paneId: props.paneId, @@ -356,11 +385,19 @@ export function renderChat(props: ChatProps) { )} @drop=${(event: DragEvent) => { event.preventDefault(); + clearAttachmentDropActive(event); if (canCompose) { handleChatAttachmentDrop(event, props); } }} - @dragover=${(event: DragEvent) => event.preventDefault()} + @dragenter=${(event: DragEvent) => setAttachmentDropActive(event, true)} + @dragleave=${(event: DragEvent) => setAttachmentDropActive(event, false)} + @dragover=${(event: DragEvent) => { + event.preventDefault(); + if (canCompose && event.dataTransfer && isFileDrag(event.dataTransfer)) { + event.dataTransfer.dropEffect = "copy"; + } + }} @keydown=${(event: KeyboardEvent) => { if (event.key === "Escape" && props.replyTarget && !event.defaultPrevented) { event.preventDefault(); diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 3e339a4bfe9c..125e75e5a19c 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -28,6 +28,36 @@ openclaw-chat-page { box-shadow: none !important; } +.chat::after { + content: ""; + position: absolute; + inset: 0; + z-index: 90; + pointer-events: none; + background: var(--accent); + opacity: 0; + transition: opacity 140ms cubic-bezier(0.23, 1, 0.32, 1); +} + +.chat[data-attachment-drop-active]::after { + opacity: 0.1; +} + +.chat > * { + transition: opacity 140ms cubic-bezier(0.23, 1, 0.32, 1); +} + +.chat[data-attachment-drop-active] > * { + opacity: 0.5; +} + +@media (prefers-reduced-motion: reduce) { + .chat::after, + .chat > * { + transition-duration: 0ms; + } +} + .chat-workbench__main { /* Positioning context for the floating rail openers so they pin to the thread column instead of overlapping an open right-docked rail. */