mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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:
@@ -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",
|
||||
|
||||
Generated
+38
-4
@@ -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"]}}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user