feat: add image paste support to chat input (#68)

* feat: add image paste support to chat input

Users can now paste screenshots and images directly into the chat input with Cmd+V. Pasted images show as thumbnails with a click-to-expand lightbox and a hover-reveal remove button. Images are forwarded to the ACP agent as base64 content blocks alongside the text prompt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: update ChatInput tests for new images argument

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: scope object URL cleanup to unmount only

Revoke only runs on component unmount (via a ref to latest images),
not on every add/remove. Previously the effect's cleanup re-ran on
every images state change, revoking URLs that were still in use.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: render image content blocks in message timeline

Add 'image' case to renderContentBlock in MessageBubble so pasted
images appear in the chat history after sending. Base64 sources are
rendered as data: URLs; URL sources rendered directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: handle image-only sends and clean up debug logs

- Pass a single space as prompt when images are present but text is empty,
  preventing goose from rejecting empty-text ACP content blocks
- Make images in the message timeline clickable via lightbox (ClickableImage)
- Suppress Radix Dialog aria-describedby warning in ImageLightbox
- Remove temporary debug console.logs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add drag-and-drop image support to chat input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: extract ClickableImage to fix file-size lint limit

MessageBubble.tsx was 516 lines (limit 500). Moves ClickableImage
into its own file to bring it under the limit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
morgmart
2026-04-01 17:10:09 -07:00
committed by GitHub
parent 8d8a2fd3bd
commit 905c85775f
14 changed files with 407 additions and 24 deletions
+1
View File
@@ -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",
+38 -4
View File
@@ -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
@@ -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"]}}
+2
View File
@@ -51,6 +51,7 @@ pub async fn acp_send_message(
working_dir: Option<String>,
persona_id: Option<String>,
persona_name: Option<String>,
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
}
+2 -1
View File
@@ -57,6 +57,7 @@ impl AcpService {
system_prompt: Option<String>,
persona_id: Option<String>,
persona_name: Option<String>,
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,
+30 -3
View File
@@ -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) {
+161 -5
View File
@@ -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 (
<>
<div className="group relative inline-block">
<button
type="button"
onClick={() => setLightboxOpen(true)}
className="block cursor-pointer rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`View attachment ${index + 1}`}
>
<img
src={objectUrl}
alt={`Attachment ${index + 1}`}
className="h-16 w-16 rounded-lg object-cover border border-border"
/>
</button>
<button
type="button"
onClick={() => onRemove(index)}
className="absolute -right-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-foreground text-background opacity-0 group-hover:opacity-100 transition-opacity duration-150"
aria-label="Remove image"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
<ImageLightbox
src={objectUrl}
alt={`Attachment ${index + 1}`}
open={lightboxOpen}
onOpenChange={setLightboxOpen}
/>
</>
);
}
// ---------------------------------------------------------------------------
// ChatInput
// ---------------------------------------------------------------------------
@@ -95,6 +151,7 @@ export function ChatInput({
contextLimit = 0,
}: ChatInputProps) {
const [text, setText] = useState("");
const [images, setImages] = useState<PastedImage[]>([]);
const [isCompact, setIsCompact] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(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:<mimeType>;base64,<data>"
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<HTMLTextAreaElement>) => {
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<HTMLDivElement>) => {
const hasImages = Array.from(e.dataTransfer.items).some((item) =>
item.type.startsWith("image/"),
);
if (hasImages) e.preventDefault();
}, []);
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
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({
<TooltipProvider delayDuration={300}>
<div className={cn("px-4 pb-6 pt-2", className)} ref={containerRef}>
<div className="mx-auto max-w-3xl">
<div className="relative rounded-2xl border border-border bg-background-secondary px-4 pb-3 pt-4 shadow-lg">
{/* biome-ignore lint/a11y/noStaticElementInteractions: drop zone for image files */}
<div
className="relative rounded-2xl border border-border bg-background-secondary px-4 pb-3 pt-4 shadow-lg"
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<MentionAutocomplete
personas={personas}
query={mentionQuery}
@@ -223,6 +365,19 @@ export function ChatInput({
selectedIndex={mentionSelectedIndex}
/>
{images.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{images.map((img, i) => (
<PastedImageThumb
key={img.objectUrl}
objectUrl={img.objectUrl}
index={i}
onRemove={removeImage}
/>
))}
</div>
)}
{stickyPersona && (
<div className="mb-2 flex items-center gap-1.5">
<span className="inline-flex items-center gap-1.5 rounded-full bg-brand/10 px-2.5 py-1 text-[11px] font-medium text-brand">
@@ -245,6 +400,7 @@ export function ChatInput({
value={text}
onChange={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={effectivePlaceholder}
disabled={disabled || isStreaming}
rows={1}
+9 -7
View File
@@ -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<string | null>(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]);
@@ -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 (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="cursor-pointer rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`View ${alt}`}
>
<img
src={src}
alt={alt}
className="max-h-48 max-w-xs rounded-lg object-contain"
/>
</button>
<ImageLightbox src={src} alt={alt} open={open} onOpenChange={setOpen} />
</>
);
}
@@ -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 <ClickableImage key={`image-${index}`} src={src} alt="Attached" />;
}
case "toolRequest":
case "toolResponse":
// Handled by groupContentSections toolChain rendering
@@ -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();
});
});
+4 -1
View File
@@ -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<void> {
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 ?? [],
});
}
+33
View File
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="w-auto flex items-center justify-center border-none bg-transparent p-0 shadow-none sm:max-w-[90vw]"
showCloseButton={false}
aria-describedby={undefined}
>
{/* Visually hidden title for accessibility */}
<DialogTitle className="sr-only">{alt}</DialogTitle>
<img
src={src}
alt={alt}
className="max-h-[85vh] max-w-[90vw] rounded-lg object-contain shadow-lg"
/>
</DialogContent>
</Dialog>
);
}
+91
View File
@@ -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<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}
>(({ className, children, showCloseButton = true, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
"w-full max-w-[calc(100%-2rem)]",
"rounded-lg border border-border bg-background shadow-lg outline-none",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-foreground-secondary", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};