diff --git a/ui/goose2/package.json b/ui/goose2/package.json index 0c203f2e36..446fe6bbc4 100644 --- a/ui/goose2/package.json +++ b/ui/goose2/package.json @@ -23,6 +23,7 @@ "test:e2e:skills": "pnpm build && playwright test --project=skills" }, "dependencies": { + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", diff --git a/ui/goose2/pnpm-lock.yaml b/ui/goose2/pnpm-lock.yaml index c2b7e3f172..859b59d14e 100644 --- a/ui/goose2/pnpm-lock.yaml +++ b/ui/goose2/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -559,6 +562,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.1.1': resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} peerDependencies: @@ -881,7 +897,6 @@ packages: resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} @@ -905,13 +920,11 @@ packages: resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} @@ -923,7 +936,6 @@ packages: resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} @@ -2692,6 +2704,28 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 diff --git a/ui/goose2/src-tauri/gen/schemas/capabilities.json b/ui/goose2/src-tauri/gen/schemas/capabilities.json index 89f4c436a6..688775b406 100644 --- a/ui/goose2/src-tauri/gen/schemas/capabilities.json +++ b/ui/goose2/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:window:allow-show","core:window:allow-close","opener:default",{"identifier":"opener:allow-open-path","allow":[{"path":"$HOME/**"},{"path":"$HOME/.goose/**"},{"path":"$HOME/.goose/artifacts/**"},{"path":"$TEMP/**"},{"path":"/Volumes/**"},{"path":"/mnt/**"},{"path":"/workspace/**"},{"path":"/workspaces/**"},{"path":"/opt/**"},{"path":"/srv/**"},{"path":"*:/**"}]},"window-state:allow-restore-state","window-state:allow-save-window-state","dialog:allow-open"]}} +{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:window:allow-show","core:window:allow-close","opener:default",{"identifier":"opener:allow-open-path","allow":[{"path":"$HOME/**"},{"path":"$HOME/.goose/**"},{"path":"$HOME/.goose/artifacts/**"},{"path":"$TEMP/**"},{"path":"/Volumes/**"},{"path":"/mnt/**"},{"path":"/workspace/**"},{"path":"/workspaces/**"},{"path":"/opt/**"},{"path":"/srv/**"},{"path":"*:/**"}]},"window-state:allow-restore-state","window-state:allow-save-window-state","dialog:allow-open"]}} \ No newline at end of file diff --git a/ui/goose2/src-tauri/src/commands/acp.rs b/ui/goose2/src-tauri/src/commands/acp.rs index c784c823f0..8eb75e713b 100644 --- a/ui/goose2/src-tauri/src/commands/acp.rs +++ b/ui/goose2/src-tauri/src/commands/acp.rs @@ -51,6 +51,7 @@ pub async fn acp_send_message( working_dir: Option, persona_id: Option, persona_name: Option, + images: Vec<(String, String)>, ) -> Result<(), String> { let working_dir = working_dir .map(|dir| dir.trim().to_string()) @@ -76,6 +77,7 @@ pub async fn acp_send_message( system_prompt, persona_id, persona_name, + images, ) .await } diff --git a/ui/goose2/src-tauri/src/services/acp/mod.rs b/ui/goose2/src-tauri/src/services/acp/mod.rs index 7a41dc2d69..ec33298bc6 100644 --- a/ui/goose2/src-tauri/src/services/acp/mod.rs +++ b/ui/goose2/src-tauri/src/services/acp/mod.rs @@ -57,6 +57,7 @@ impl AcpService { system_prompt: Option, persona_id: Option, persona_name: Option, + images: Vec<(String, String)>, ) -> Result<(), String> { // Ensure the session exists in the SessionStore (create if needed) session_store.ensure_session(&session_id, Some(provider_id.clone())); @@ -152,7 +153,7 @@ impl AcpService { .run( &session_id_inner, &effective_prompt, - &[], + &images, &working_dir, &store, &writer, diff --git a/ui/goose2/src/features/chat/hooks/useChat.ts b/ui/goose2/src/features/chat/hooks/useChat.ts index 8168c46f26..fb1df30b14 100644 --- a/ui/goose2/src/features/chat/hooks/useChat.ts +++ b/ui/goose2/src/features/chat/hooks/useChat.ts @@ -57,8 +57,16 @@ export function useChat( ); const sendMessage = useCallback( - async (text: string, overridePersona?: { id: string; name?: string }) => { - if (!text.trim() || chatState === "streaming" || chatState === "thinking") + async ( + text: string, + overridePersona?: { id: string; name?: string }, + images?: { base64: string; mimeType: string }[], + ) => { + if ( + (!text.trim() && (!images || images.length === 0)) || + chatState === "streaming" || + chatState === "thinking" + ) return; const effectivePersonaInfo = resolvePersonaInfo( @@ -78,6 +86,19 @@ export function useChat( targetPersonaName: effectivePersonaInfo.name, }; } + // Embed image content blocks into the user message for local display + if (images && images.length > 0) { + for (const img of images) { + userMessage.content.push({ + type: "image", + source: { + type: "base64", + mediaType: img.mimeType, + data: img.base64, + }, + }); + } + } store.addMessage(sessionId, userMessage); store.setChatState(sessionId, "thinking"); store.setError(sessionId, null); @@ -123,11 +144,17 @@ export function useChat( // Send via ACP — response streams back through Tauri events // which are handled by the global useAcpStream listener in AppShell. store.setChatState(sessionId, "streaming"); - await acpSendMessage(sessionId, providerId, text, { + // When images are present with no text, pass a single space so the ACP + // driver doesn't send an empty text content block that goose rejects. + const acpPrompt = text.trim() || (images?.length ? " " : text); + await acpSendMessage(sessionId, providerId, acpPrompt, { systemPrompt, workingDir: workingDirOverride, personaId: effectivePersonaInfo?.id, personaName: effectivePersonaInfo?.name, + images: images?.map( + (img) => [img.base64, img.mimeType] as [string, string], + ), }); // Note: setChatState("idle") is handled by useAcpStream on "acp:done" } catch (err) { diff --git a/ui/goose2/src/features/chat/ui/ChatInput.tsx b/ui/goose2/src/features/chat/ui/ChatInput.tsx index c419a51634..049c410a9b 100644 --- a/ui/goose2/src/features/chat/ui/ChatInput.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInput.tsx @@ -10,11 +10,18 @@ import { import { ChatInputToolbar } from "./ChatInputToolbar"; import { TooltipProvider } from "@/shared/ui/tooltip"; import { PersonaAvatar } from "./PersonaPicker"; +import { ImageLightbox } from "@/shared/ui/ImageLightbox"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- +export interface PastedImage { + base64: string; + mimeType: string; + objectUrl: string; +} + export interface ModelOption { id: string; name: string; @@ -29,7 +36,7 @@ export interface ProjectOption { } interface ChatInputProps { - onSend: (text: string, personaId?: string) => void; + onSend: (text: string, personaId?: string, images?: PastedImage[]) => void; onStop?: () => void; isStreaming?: boolean; disabled?: boolean; @@ -64,6 +71,55 @@ interface ChatInputProps { contextLimit?: number; } +// --------------------------------------------------------------------------- +// PastedImageThumb +// --------------------------------------------------------------------------- + +function PastedImageThumb({ + objectUrl, + index, + onRemove, +}: { + objectUrl: string; + index: number; + onRemove: (index: number) => void; +}) { + const [lightboxOpen, setLightboxOpen] = useState(false); + + return ( + <> +
+ + +
+ + + ); +} + // --------------------------------------------------------------------------- // ChatInput // --------------------------------------------------------------------------- @@ -95,6 +151,7 @@ export function ChatInput({ contextLimit = 0, }: ChatInputProps) { const [text, setText] = useState(""); + const [images, setImages] = useState([]); const [isCompact, setIsCompact] = useState(false); const textareaRef = useRef(null); const containerRef = useRef(null); @@ -105,7 +162,8 @@ export function ChatInput({ ); const stickyPersona = activePersona; - const canSend = text.trim().length > 0 && !isStreaming && !disabled; + const canSend = + (text.trim().length > 0 || images.length > 0) && !isStreaming && !disabled; const { mentionOpen, @@ -130,14 +188,36 @@ export function ChatInput({ return () => observer.disconnect(); }, []); + // Keep a ref to latest images so the unmount cleanup always sees current state + // without needing images as a dependency (which would revoke still-active URLs + // on every add/remove). + const imagesRef = useRef(images); + imagesRef.current = images; + + useEffect(() => { + return () => { + for (const img of imagesRef.current) { + URL.revokeObjectURL(img.objectUrl); + } + }; + }, []); + const handleSend = useCallback(() => { if (!canSend) return; - onSend(text.trim(), selectedPersonaId ?? undefined); + onSend( + text.trim(), + selectedPersonaId ?? undefined, + images.length > 0 ? images : undefined, + ); setText(""); + setImages((prev) => { + for (const img of prev) URL.revokeObjectURL(img.objectUrl); + return []; + }); if (textareaRef.current) { textareaRef.current.style.height = "auto"; } - }, [canSend, text, onSend, selectedPersonaId]); + }, [canSend, text, images, onSend, selectedPersonaId]); const handleMentionSelect = useCallback( (persona: Persona) => { @@ -199,6 +279,63 @@ export function ChatInput({ textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; }; + const addImageFile = useCallback((file: File) => { + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = reader.result as string; + // dataUrl is "data:;base64," + const [header, base64] = dataUrl.split(","); + const mimeType = header.replace("data:", "").replace(";base64", ""); + const objectUrl = URL.createObjectURL(file); + setImages((prev) => [...prev, { base64, mimeType, objectUrl }]); + }; + reader.readAsDataURL(file); + }, []); + + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + const items = Array.from(e.clipboardData.items); + const imageItems = items.filter((item) => item.type.startsWith("image/")); + if (imageItems.length === 0) return; + + e.preventDefault(); + for (const item of imageItems) { + const file = item.getAsFile(); + if (!file) continue; + addImageFile(file); + } + }, + [addImageFile], + ); + + const handleDragOver = useCallback((e: React.DragEvent) => { + const hasImages = Array.from(e.dataTransfer.items).some((item) => + item.type.startsWith("image/"), + ); + if (hasImages) e.preventDefault(); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + const files = Array.from(e.dataTransfer.files).filter((f) => + f.type.startsWith("image/"), + ); + if (files.length === 0) return; + e.preventDefault(); + for (const file of files) { + addImageFile(file); + } + }, + [addImageFile], + ); + + const removeImage = useCallback((index: number) => { + setImages((prev) => { + URL.revokeObjectURL(prev[index].objectUrl); + return prev.filter((_, i) => i !== index); + }); + }, []); + const personaDisplayName = activePersona?.displayName ?? "Goose"; const effectivePlaceholder = placeholder === "Message Goose..." @@ -213,7 +350,12 @@ export function ChatInput({
-
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: drop zone for image files */} +
+ {images.length > 0 && ( +
+ {images.map((img, i) => ( + + ))} +
+ )} + {stickyPersona && (
@@ -245,6 +400,7 @@ export function ChatInput({ value={text} onChange={handleInput} onKeyDown={handleKeyDown} + onPaste={handlePaste} placeholder={effectivePlaceholder} disabled={disabled || isStreaming} rows={1} diff --git a/ui/goose2/src/features/chat/ui/ChatView.tsx b/ui/goose2/src/features/chat/ui/ChatView.tsx index 9f9530dd9c..1ddf1c83aa 100644 --- a/ui/goose2/src/features/chat/ui/ChatView.tsx +++ b/ui/goose2/src/features/chat/ui/ChatView.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { MessageTimeline } from "./MessageTimeline"; -import { ChatInput } from "./ChatInput"; +import { ChatInput, type PastedImage } from "./ChatInput"; import { LoadingGoose } from "./LoadingGoose"; import { useChat } from "../hooks/useChat"; import { useChatStore } from "../stores/chatStore"; @@ -252,12 +252,14 @@ export function ChatView({ ); // Ref for deferred sends after persona switch (Bug 1 fix: avoid stale system prompt) - const deferredSend = useRef(null); + const deferredSend = useRef<{ text: string; images?: PastedImage[] } | null>( + null, + ); // Wrap sendMessage to handle @ mentioned persona overrides const chatStore = useChatStore(); const handleSend = useCallback( - (text: string, personaId?: string) => { + (text: string, personaId?: string, images?: PastedImage[]) => { if (personaId && personaId !== selectedPersonaId) { const newPersona = personas.find((p) => p.id === personaId); if (newPersona) { @@ -278,10 +280,10 @@ export function ChatView({ } handlePersonaChange(personaId); // Defer the send until after persona state updates - deferredSend.current = text; + deferredSend.current = { text, images }; return; } - sendMessage(text); + sendMessage(text, undefined, images); }, [ sendMessage, @@ -296,9 +298,9 @@ export function ChatView({ // Effect to send deferred message after persona switch completes useEffect(() => { if (deferredSend.current && selectedPersona) { - const text = deferredSend.current; + const { text, images } = deferredSend.current; deferredSend.current = null; - sendMessage(text); + sendMessage(text, undefined, images); } }, [sendMessage, selectedPersona]); diff --git a/ui/goose2/src/features/chat/ui/ClickableImage.tsx b/ui/goose2/src/features/chat/ui/ClickableImage.tsx new file mode 100644 index 0000000000..01b6f54a63 --- /dev/null +++ b/ui/goose2/src/features/chat/ui/ClickableImage.tsx @@ -0,0 +1,23 @@ +import { useState } from "react"; +import { ImageLightbox } from "@/shared/ui/ImageLightbox"; + +export function ClickableImage({ src, alt }: { src: string; alt: string }) { + const [open, setOpen] = useState(false); + return ( + <> + + + + ); +} diff --git a/ui/goose2/src/features/chat/ui/MessageBubble.tsx b/ui/goose2/src/features/chat/ui/MessageBubble.tsx index d52aab9b8c..9def39b58d 100644 --- a/ui/goose2/src/features/chat/ui/MessageBubble.tsx +++ b/ui/goose2/src/features/chat/ui/MessageBubble.tsx @@ -12,10 +12,12 @@ import { cn } from "@/shared/lib/cn"; import { ToolCallCard } from "./ToolCallCard"; import { ThinkingBlock } from "./ThinkingBlock"; import { MarkdownContent } from "./MarkdownContent"; +import { ClickableImage } from "./ClickableImage"; import type { Message, MessageContent, TextContent, + ImageContent, ToolRequestContent, ToolResponseContent, ThinkingContent, @@ -310,6 +312,14 @@ function renderContentBlock(content: MessageContent, index: number) { /> ); } + case "image": { + const ic = content as ImageContent; + const src = + ic.source.type === "base64" + ? `data:${ic.source.mediaType};base64,${ic.source.data}` + : ic.source.url; + return ; + } case "toolRequest": case "toolResponse": // Handled by groupContentSections toolChain rendering diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx index 2ba6826880..89ed74f087 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx @@ -65,7 +65,7 @@ describe("ChatInput", () => { await user.type(input, "hello"); await user.keyboard("{Enter}"); - expect(onSend).toHaveBeenCalledWith("hello", undefined); + expect(onSend).toHaveBeenCalledWith("hello", undefined, undefined); }); it("does not call onSend on Shift+Enter (newline)", async () => { @@ -237,7 +237,7 @@ describe("ChatInput", () => { await user.type(input, "hello"); await user.keyboard("{Enter}"); - expect(onSend).toHaveBeenCalledWith("hello", "reviewer"); + expect(onSend).toHaveBeenCalledWith("hello", "reviewer", undefined); expect(screen.getByText("@Reviewer")).toBeInTheDocument(); }); }); diff --git a/ui/goose2/src/shared/api/acp.ts b/ui/goose2/src/shared/api/acp.ts index fbf9a62fba..6eb22adbfc 100644 --- a/ui/goose2/src/shared/api/acp.ts +++ b/ui/goose2/src/shared/api/acp.ts @@ -10,6 +10,8 @@ export interface AcpSendMessageOptions { workingDir?: string; personaId?: string; personaName?: string; + /** Image attachments as [base64Data, mimeType] pairs. */ + images?: [string, string][]; } /** Discover ACP providers installed on the system. */ @@ -24,7 +26,7 @@ export async function acpSendMessage( prompt: string, options: AcpSendMessageOptions = {}, ): Promise { - const { systemPrompt, workingDir, personaId, personaName } = options; + const { systemPrompt, workingDir, personaId, personaName, images } = options; return invoke("acp_send_message", { sessionId, providerId, @@ -33,6 +35,7 @@ export async function acpSendMessage( workingDir: workingDir ?? null, personaId: personaId ?? null, personaName: personaName ?? null, + images: images ?? [], }); } diff --git a/ui/goose2/src/shared/ui/ImageLightbox.tsx b/ui/goose2/src/shared/ui/ImageLightbox.tsx new file mode 100644 index 0000000000..4f46d3f654 --- /dev/null +++ b/ui/goose2/src/shared/ui/ImageLightbox.tsx @@ -0,0 +1,33 @@ +import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog"; + +interface ImageLightboxProps { + src: string; + alt?: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function ImageLightbox({ + src, + alt = "Image preview", + open, + onOpenChange, +}: ImageLightboxProps) { + return ( + + + {/* Visually hidden title for accessibility */} + {alt} + {alt} + + + ); +} diff --git a/ui/goose2/src/shared/ui/dialog.tsx b/ui/goose2/src/shared/ui/dialog.tsx new file mode 100644 index 0000000000..3a5c1bb916 --- /dev/null +++ b/ui/goose2/src/shared/ui/dialog.tsx @@ -0,0 +1,91 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + showCloseButton?: boolean; + } +>(({ className, children, showCloseButton = true, ...props }, ref) => ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +};