improve(ui): highlight chat pane during file drag (#105330)

* feat(ui): highlight chat file drop target

* style(ui): dim chat content during file drag

* test(ui): deduplicate chat drag events
This commit is contained in:
Vyctor H. Brzezowski
2026-07-12 08:39:14 -03:00
committed by GitHub
parent 1389d7ee88
commit cfe88cfd40
3 changed files with 106 additions and 1 deletions

View File

@@ -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<T>(items: ArrayLike<T>, 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({

View File

@@ -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();

View File

@@ -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. */