Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
266 lines
8.2 KiB
TypeScript
266 lines
8.2 KiB
TypeScript
import { useEffect, useState, type RefObject } from "react";
|
|
import type {
|
|
AgentRichTextEditorHandle,
|
|
AgentRichTextPastedImage
|
|
} from "../agentRichText/AgentRichTextEditor";
|
|
import {
|
|
imageFilesFromDataTransfer,
|
|
nonImageFilesFromDataTransfer,
|
|
readAgentRichTextPromptImages,
|
|
systemFileDragInfoFromDataTransfer
|
|
} from "../agentRichText/agentRichTextPromptImages";
|
|
import { hasWorkspaceFileDropData } from "../../terminalNode/workspaceFileDrop";
|
|
|
|
interface UseComposerFileDropInput {
|
|
composerRef: RefObject<HTMLFormElement | null>;
|
|
editorHandleRef: RefObject<AgentRichTextEditorHandle | null>;
|
|
inputDisabled: boolean;
|
|
promptFilesSupported: boolean;
|
|
promptImagesSupported: boolean;
|
|
addDraftImages: (images: AgentRichTextPastedImage[]) => void;
|
|
addDraftFiles: (files: readonly File[]) => void;
|
|
scheduleComposerFocus: () => void;
|
|
onPromptImagesUnsupported?: () => void;
|
|
}
|
|
|
|
function isPointInsideElement(
|
|
element: HTMLElement,
|
|
clientX: number,
|
|
clientY: number
|
|
): boolean {
|
|
const bounds = element.getBoundingClientRect();
|
|
return (
|
|
clientX >= bounds.left &&
|
|
clientX <= bounds.right &&
|
|
clientY >= bounds.top &&
|
|
clientY <= bounds.bottom
|
|
);
|
|
}
|
|
|
|
export function useComposerFileDrop({
|
|
composerRef,
|
|
editorHandleRef,
|
|
inputDisabled,
|
|
promptFilesSupported,
|
|
promptImagesSupported,
|
|
addDraftImages,
|
|
addDraftFiles,
|
|
scheduleComposerFocus,
|
|
onPromptImagesUnsupported
|
|
}: UseComposerFileDropInput) {
|
|
const [fileDropOverlayHost, setFileDropOverlayHost] =
|
|
useState<HTMLElement | null>(null);
|
|
const [fileDropOverlayActive, setFileDropOverlayActive] = useState(false);
|
|
useEffect(() => {
|
|
const composer = composerRef.current;
|
|
const dropTarget = composer?.closest("#agent-gui-detail") ?? composer;
|
|
if (!dropTarget) {
|
|
return undefined;
|
|
}
|
|
let isDisposed = false;
|
|
setFileDropOverlayHost(dropTarget as HTMLElement);
|
|
|
|
const isDragEvent = (event: Event): event is DragEvent =>
|
|
"dataTransfer" in event;
|
|
|
|
const clearDropOverlay = (): void => {
|
|
if (!isDisposed) {
|
|
setFileDropOverlayActive(false);
|
|
}
|
|
};
|
|
|
|
const containsEventTarget = (event: DragEvent): boolean => {
|
|
const target = event.target;
|
|
return target instanceof Node && dropTarget.contains(target);
|
|
};
|
|
|
|
const systemFileDrag = (
|
|
event: DragEvent
|
|
): { hasImageFiles: boolean; hasRegularFiles: boolean } | null => {
|
|
if (
|
|
event.defaultPrevented ||
|
|
inputDisabled ||
|
|
!containsEventTarget(event) ||
|
|
hasWorkspaceFileDropData(event.dataTransfer)
|
|
) {
|
|
return null;
|
|
}
|
|
const dragInfo = systemFileDragInfoFromDataTransfer(event.dataTransfer);
|
|
const hasRegularFiles = dragInfo.hasRegularFiles && promptFilesSupported;
|
|
const hasPromptImages = dragInfo.hasImageFiles && promptImagesSupported;
|
|
const hasImageFilesAsFiles =
|
|
dragInfo.hasImageFiles &&
|
|
!promptImagesSupported &&
|
|
promptFilesSupported;
|
|
if (!hasPromptImages && !hasImageFilesAsFiles && !hasRegularFiles) {
|
|
return null;
|
|
}
|
|
return { hasImageFiles: dragInfo.hasImageFiles, hasRegularFiles };
|
|
};
|
|
|
|
const systemFileDrop = (
|
|
event: DragEvent
|
|
): { imageFiles: File[]; regularFiles: File[] } | null => {
|
|
if (
|
|
event.defaultPrevented ||
|
|
inputDisabled ||
|
|
!containsEventTarget(event) ||
|
|
hasWorkspaceFileDropData(event.dataTransfer)
|
|
) {
|
|
return null;
|
|
}
|
|
const imageFiles = imageFilesFromDataTransfer(event.dataTransfer);
|
|
const imageFileSet = new Set(imageFiles);
|
|
const regularFiles = promptFilesSupported
|
|
? [
|
|
...nonImageFilesFromDataTransfer(event.dataTransfer).filter(
|
|
(file) => !imageFileSet.has(file)
|
|
),
|
|
...(!promptImagesSupported ? imageFiles : [])
|
|
]
|
|
: [];
|
|
const promptImageFiles = promptImagesSupported ? imageFiles : [];
|
|
const unsupportedImageFiles =
|
|
!promptImagesSupported && !promptFilesSupported ? imageFiles : [];
|
|
if (
|
|
promptImageFiles.length === 0 &&
|
|
regularFiles.length === 0 &&
|
|
unsupportedImageFiles.length === 0
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
imageFiles: [...promptImageFiles, ...unsupportedImageFiles],
|
|
regularFiles
|
|
};
|
|
};
|
|
|
|
const handleDragOver: EventListener = (event): void => {
|
|
if (!isDragEvent(event)) {
|
|
return;
|
|
}
|
|
const drag = systemFileDrag(event);
|
|
if (!drag) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
if (
|
|
!drag.hasRegularFiles &&
|
|
drag.hasImageFiles &&
|
|
!promptImagesSupported &&
|
|
!promptFilesSupported
|
|
) {
|
|
clearDropOverlay();
|
|
return;
|
|
}
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.dropEffect = "copy";
|
|
}
|
|
if (!isDisposed) {
|
|
setFileDropOverlayActive(true);
|
|
}
|
|
};
|
|
|
|
const handleDrop: EventListener = (event): void => {
|
|
if (!isDragEvent(event)) {
|
|
return;
|
|
}
|
|
const drop = systemFileDrop(event);
|
|
if (!drop) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
clearDropOverlay();
|
|
if (drop.regularFiles.length > 0) {
|
|
editorHandleRef.current?.focusAtEnd();
|
|
addDraftFiles(drop.regularFiles);
|
|
scheduleComposerFocus();
|
|
}
|
|
if (drop.imageFiles.length === 0) {
|
|
return;
|
|
}
|
|
if (!promptImagesSupported) {
|
|
onPromptImagesUnsupported?.();
|
|
return;
|
|
}
|
|
void readAgentRichTextPromptImages(drop.imageFiles).then((images) => {
|
|
if (isDisposed || images.length === 0) {
|
|
return;
|
|
}
|
|
addDraftImages(images);
|
|
scheduleComposerFocus();
|
|
});
|
|
};
|
|
|
|
// `dragleave` is unreliable across nested children, so mirror the file
|
|
// manager and clear the overlay from a capture-phase document listener
|
|
// whenever the pointer leaves the drop target's bounds.
|
|
const handleDocumentDragOver: EventListener = (event): void => {
|
|
if (!isDragEvent(event)) {
|
|
return;
|
|
}
|
|
if (
|
|
isPointInsideElement(
|
|
dropTarget as HTMLElement,
|
|
event.clientX,
|
|
event.clientY
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
clearDropOverlay();
|
|
};
|
|
|
|
// Once the drag leaves the window entirely, `dragover` stops firing (so the
|
|
// handler above can never fire) and external file drags never dispatch a
|
|
// renderer-side `dragend` — leaving the overlay stuck. A `dragleave` whose
|
|
// pointer sits at/outside the viewport edge (or has no relatedTarget) means
|
|
// the cursor left the window, so clear the overlay then.
|
|
const handleDocumentDragLeave: EventListener = (event): void => {
|
|
if (!isDragEvent(event)) {
|
|
return;
|
|
}
|
|
const leftWindow =
|
|
event.relatedTarget === null ||
|
|
event.clientX <= 0 ||
|
|
event.clientY <= 0 ||
|
|
event.clientX >= window.innerWidth ||
|
|
event.clientY >= window.innerHeight;
|
|
if (leftWindow) {
|
|
clearDropOverlay();
|
|
}
|
|
};
|
|
|
|
dropTarget.addEventListener("dragover", handleDragOver);
|
|
dropTarget.addEventListener("drop", handleDrop);
|
|
document.addEventListener("dragover", handleDocumentDragOver, true);
|
|
document.addEventListener("dragleave", handleDocumentDragLeave, true);
|
|
window.addEventListener("dragend", clearDropOverlay);
|
|
window.addEventListener("drop", clearDropOverlay);
|
|
window.addEventListener("blur", clearDropOverlay);
|
|
return () => {
|
|
isDisposed = true;
|
|
setFileDropOverlayHost(null);
|
|
setFileDropOverlayActive(false);
|
|
dropTarget.removeEventListener("dragover", handleDragOver);
|
|
dropTarget.removeEventListener("drop", handleDrop);
|
|
document.removeEventListener("dragover", handleDocumentDragOver, true);
|
|
document.removeEventListener("dragleave", handleDocumentDragLeave, true);
|
|
window.removeEventListener("dragend", clearDropOverlay);
|
|
window.removeEventListener("drop", clearDropOverlay);
|
|
window.removeEventListener("blur", clearDropOverlay);
|
|
};
|
|
}, [
|
|
addDraftImages,
|
|
addDraftFiles,
|
|
inputDisabled,
|
|
onPromptImagesUnsupported,
|
|
promptFilesSupported,
|
|
promptImagesSupported,
|
|
scheduleComposerFocus
|
|
]);
|
|
|
|
return { fileDropOverlayActive, fileDropOverlayHost };
|
|
}
|