From d12acb05ff0187099cc913745de1d74ce29ebcce Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:27:38 -0700 Subject: [PATCH] polish chat input and sidebar interactions (#99) * Polish chat input and sidebar interactions. Standardize keyboard focus behavior and tighten sidebar and composer controls so the compact UI feels more consistent while preserving drag-and-drop image workflows. * Fix lint follow-ups for UI polish. Extract the chat image drop-target logic and tighten the shared focus selector so CI passes without changing the polished behavior. * Fix keyboard focus detection for non-Tab navigation. Preserve hidden pointer focus rings while restoring visible focus for keyboard shortcuts and other keyboard-driven flows that move focus without Tab. --- ui/goose2/src/app/App.tsx | 3 + .../features/chat/hooks/useImageDropTarget.ts | 98 +++++++++++++++++++ ui/goose2/src/features/chat/ui/ChatInput.tsx | 52 +++++----- .../features/chat/ui/ChatInputSelector.tsx | 40 ++++---- .../src/features/chat/ui/ChatInputToolbar.tsx | 19 +--- .../src/features/chat/ui/ClickableImage.tsx | 2 +- ui/goose2/src/features/sidebar/ui/Sidebar.tsx | 75 +++++++++----- .../features/sidebar/ui/SidebarChatRow.tsx | 20 +++- .../shared/hooks/useKeyboardNavDetection.ts | 53 ++++++++++ ui/goose2/src/shared/styles/globals.css | 11 ++- ui/goose2/src/shared/ui/accordion.tsx | 2 +- ui/goose2/src/shared/ui/badge.tsx | 4 +- ui/goose2/src/shared/ui/button.tsx | 4 +- ui/goose2/src/shared/ui/checkbox.tsx | 2 +- ui/goose2/src/shared/ui/dropdown-menu.tsx | 8 +- ui/goose2/src/shared/ui/input.tsx | 2 +- ui/goose2/src/shared/ui/navigation-menu.tsx | 4 +- ui/goose2/src/shared/ui/radio-group.tsx | 2 +- ui/goose2/src/shared/ui/resizable.tsx | 2 +- ui/goose2/src/shared/ui/scroll-area.tsx | 2 +- ui/goose2/src/shared/ui/select.tsx | 2 +- ui/goose2/src/shared/ui/sidebar.tsx | 10 +- ui/goose2/src/shared/ui/slider.tsx | 2 +- ui/goose2/src/shared/ui/switch.tsx | 2 +- ui/goose2/src/shared/ui/tabs.tsx | 2 +- ui/goose2/src/shared/ui/textarea.tsx | 2 +- ui/goose2/src/shared/ui/toggle.tsx | 2 +- 27 files changed, 312 insertions(+), 115 deletions(-) create mode 100644 ui/goose2/src/features/chat/hooks/useImageDropTarget.ts create mode 100644 ui/goose2/src/shared/hooks/useKeyboardNavDetection.ts 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 */} {/*