diff --git a/ui/goose2/src/app/App.tsx b/ui/goose2/src/app/App.tsx index a496490bbd..80ae2f96bd 100644 --- a/ui/goose2/src/app/App.tsx +++ b/ui/goose2/src/app/App.tsx @@ -1,8 +1,11 @@ import { useEffect } from "react"; import { AppShell } from "@/app/AppShell"; +import { useKeyboardNavDetection } from "@/shared/hooks/useKeyboardNavDetection"; export function App() { + useKeyboardNavDetection(); + useEffect(() => { const preventWindowFileNavigation = (event: DragEvent) => { event.preventDefault(); diff --git a/ui/goose2/src/features/chat/hooks/useImageDropTarget.ts b/ui/goose2/src/features/chat/hooks/useImageDropTarget.ts new file mode 100644 index 0000000000..141d866c2f --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/useImageDropTarget.ts @@ -0,0 +1,98 @@ +import { useCallback, useRef, useState, type DragEvent } from "react"; + +interface UseImageDropTargetOptions { + disabled: boolean; + isStreaming: boolean; + onDropFile: (file: File) => void; +} + +function hasDraggedFiles(dataTransfer: DataTransfer) { + return ( + Array.from(dataTransfer.items).some( + (item) => item.kind === "file" || item.type.startsWith("image/"), + ) || Array.from(dataTransfer.types).includes("Files") + ); +} + +export function useImageDropTarget({ + disabled, + isStreaming, + onDropFile, +}: UseImageDropTargetOptions) { + const [isImageDragOver, setIsImageDragOver] = useState(false); + const dragDepthRef = useRef(0); + + const handleDragEnter = useCallback( + (e: DragEvent) => { + if (disabled || isStreaming || !hasDraggedFiles(e.dataTransfer)) { + return; + } + + e.preventDefault(); + dragDepthRef.current += 1; + setIsImageDragOver(true); + }, + [disabled, isStreaming], + ); + + const handleDragOver = useCallback( + (e: DragEvent) => { + if (disabled || isStreaming || !hasDraggedFiles(e.dataTransfer)) { + return; + } + + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setIsImageDragOver(true); + }, + [disabled, isStreaming], + ); + + const handleDragLeave = useCallback( + (e: DragEvent) => { + e.preventDefault(); + + if (!isImageDragOver) { + return; + } + + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) { + setIsImageDragOver(false); + } + }, + [isImageDragOver], + ); + + const handleDrop = useCallback( + (e: DragEvent) => { + dragDepthRef.current = 0; + setIsImageDragOver(false); + + if (disabled || isStreaming) { + return; + } + + const files = Array.from(e.dataTransfer.files).filter((file) => + file.type.startsWith("image/"), + ); + if (files.length === 0) { + return; + } + + e.preventDefault(); + for (const file of files) { + onDropFile(file); + } + }, + [disabled, isStreaming, onDropFile], + ); + + return { + isImageDragOver, + handleDragEnter, + handleDragOver, + handleDragLeave, + handleDrop, + }; +} diff --git a/ui/goose2/src/features/chat/ui/ChatInput.tsx b/ui/goose2/src/features/chat/ui/ChatInput.tsx index 880c3a3f07..0176c34681 100644 --- a/ui/goose2/src/features/chat/ui/ChatInput.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInput.tsx @@ -3,6 +3,7 @@ import { X } from "lucide-react"; import type { AcpProvider } from "@/shared/api/acp"; import type { Persona } from "@/shared/types/agents"; import { cn } from "@/shared/lib/cn"; +import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; import { MentionAutocomplete, @@ -15,6 +16,7 @@ import { PersonaAvatar } from "./PersonaPicker"; import { ImageLightbox } from "@/shared/ui/ImageLightbox"; import type { PastedImage } from "@/shared/types/messages"; import { resizeImage } from "../lib/resizeImage"; +import { useImageDropTarget } from "../hooks/useImageDropTarget"; export interface ModelOption { id: string; @@ -83,7 +85,7 @@ function PastedImageThumb({ )} - - diff --git a/ui/goose2/src/features/chat/ui/ClickableImage.tsx b/ui/goose2/src/features/chat/ui/ClickableImage.tsx index 01b6f54a63..8d6addc12a 100644 --- a/ui/goose2/src/features/chat/ui/ClickableImage.tsx +++ b/ui/goose2/src/features/chat/ui/ClickableImage.tsx @@ -8,7 +8,7 @@ export function ClickableImage({ src, alt }: { src: string; alt: string }) { -
{/* Goose logo — pinned top */} -
- +
+
+ + {!collapsed && ( + + )} +
{/* Navigation (scrollable) */} @@ -271,6 +285,19 @@ export function Sidebar({ )}
+ {collapsed && ( + + )} + {/* TODO: Search bar — uncomment when onSearchClick is wired up */} {/*