mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-18 19:31:43 +00:00
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:
committed by
GitHub
parent
1389d7ee88
commit
cfe88cfd40
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user