Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions changelogs/v2.2.4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## What's new

### Features

- **Image attachment support in chat**: Users can now paste clipboard images or use the new attachment button in the chat input to include images with their messages. Images are kept inline as base64 data URLs (no persistent file storage on disk) and rendered as inline thumbnails in chat bubbles. The LLM receives images as base64 multi-part content (OpenAI vision API format), enabling vision-capable models to analyse attached images. Supported models: GPT-4o, Claude Sonnet 4.6, Gemini 3.5 Flash, and any OpenAI-compatible vision endpoint.
Comment thread
ddutchie marked this conversation as resolved.

### Fixes

- **Pop-out window state not transferred**: The `popOut()` IPC call in the preload bridge uses an `invoke()` helper that unwraps `{ data: T }` from every handler return. The chat-popout handlers (`chat:popOut`, `chat:popoutReady`, `chat:popIn`, `chat:requestPopIn`) returned plain objects (`{ ok: true }` / state) directly, so `invoke()` always resolved to `undefined`. The inline chat's pop-out button now awaits a successful `{ ok: true }` response before showing the placeholder — the unwrap mismatch prevented it from ever firing, leaving the main window's chat panel visible alongside the pop-out. All four handlers now return `{ data: ... }` wrapped values matching the `invoke()` contract.


17 changes: 10 additions & 7 deletions electron/chat-popout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,26 +140,29 @@ export function registerChatPopoutHandlers(): void {
chatParticipants.add(event.sender.id);
pendingChatState = payload;
createChatPopoutWindow();
return { ok: true };
return { data: { ok: true } };
});

// Pop-out page signals it is ready — register as participant, return stored state
ipcMain.handle("chat:popoutReady", (event) => {
if (event.sender.id !== popoutWindow?.webContents.id) {
return { threadId: null, chatThreads: [], chatMessages: [], activeProjectId: null };
return { data: { threadId: null, chatThreads: [], chatMessages: [], activeProjectId: null } };
}
chatParticipants.add(event.sender.id);
const state = pendingChatState;
pendingChatState = null;
return state ?? { threadId: null, chatThreads: [], chatMessages: [], activeProjectId: null };
return { data: state ?? { threadId: null, chatThreads: [], chatMessages: [], activeProjectId: null } };
});

// Main window requests the pop-out to come back (clicked placeholder button)
ipcMain.handle("chat:requestPopIn", () => {
ipcMain.handle("chat:requestPopIn", (event) => {
if (event.sender.id !== mainWindowWebContentsId) {
return { data: { ok: false } };
}
if (popoutWindow && !popoutWindow.isDestroyed()) {
popoutWindow.webContents.send("chat:requestPopIn");
}
return { ok: true };
return { data: { ok: true } };
});

// Pop-out window requests pop-in: send final state to main window, close
Expand All @@ -170,7 +173,7 @@ export function registerChatPopoutHandlers(): void {
activeProjectId: string | null;
}) => {
if (event.sender.id !== popoutWindow?.webContents.id) {
return { ok: false };
return { data: { ok: false } };
}
const senderId = event.sender.id;
// Find the main window by its tracked webContents ID (not BrowserWindow.id)
Expand All @@ -180,6 +183,6 @@ export function registerChatPopoutHandlers(): void {
}
closeChatPopoutWindow();
chatParticipants.delete(senderId);
return { ok: true };
return { data: { ok: true } };
});
}
27 changes: 26 additions & 1 deletion electron/ipc/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,10 +386,35 @@ export function registerChatHandler(db: Database.Database, workspacePath: string
return;
}

// Check if the model supports vision before including images
const modelLc = (model ?? "").toLowerCase();
const supportsVision = provider !== "localllm" && (
modelLc.includes("vision") ||
modelLc.includes("gpt-4o") ||
modelLc.startsWith("claude-3") ||
modelLc.startsWith("claude-sonnet-4") ||
modelLc.includes("gemini")
);

if (req.images?.length && !supportsVision) {
req.message = "[Images omitted — model does not support vision]\n\n" + req.message;
req.images = undefined;
}

const userMessage: OpenAIMessage = req.images?.length
? ({
role: "user",
content: [
{ type: "text", text: req.message },
...req.images.map((img) => ({ type: "image_url", image_url: { url: img.dataUrl } })),
],
} as unknown as OpenAIMessage)
: { role: "user", content: req.message };
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const messages: OpenAIMessage[] = [
{ role: "system", content: buildSystemPrompt(req) },
...(req.history ?? []).map((m) => ({ role: m.role, content: m.content })),
{ role: "user", content: req.message },
userMessage,
];

const emitToolCall = (e: { tool: string; label: string; args: Record<string, unknown> }) => {
Expand Down
1 change: 1 addition & 0 deletions electron/lib/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export interface ChatRequest {
history?: Array<{ role: "user" | "assistant" | "system"; content: string }>;
config?: { baseUrl?: string; model?: string; apiKey?: string; maxSteps?: number; temperature?: number };
systemPrompt?: string;
images?: Array<{ name: string; dataUrl: string }>;
}

export function buildSystemPrompt(req: ChatRequest): string {
Expand Down
90 changes: 87 additions & 3 deletions src/components/chat/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import React, { useRef, useEffect, useState, useMemo } from "react";
import { Send, Square, Sparkles, FileText, CheckSquare, FileCode } from "lucide-react";
import { Send, Square, Sparkles, FileText, CheckSquare, FileCode, Image, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Tooltip } from "@/components/ui/tooltip";

Expand Down Expand Up @@ -32,6 +32,10 @@ interface ChatInputProps {
commands?: SlashCommand[];
suggestions?: SuggestionItem[];
onSearchSuggestions?: (query: string) => Promise<SuggestionItem[]>;
/** Images attached but not yet sent — shown as thumbnail strip */
pendingImages?: Array<{ name: string; dataUrl: string }>;
onRemoveImage?: (index: number) => void;
onAttachImages?: (files: File[]) => void;
}

export const ChatInput = React.forwardRef<HTMLTextAreaElement, ChatInputProps>(
Expand All @@ -50,11 +54,15 @@ export const ChatInput = React.forwardRef<HTMLTextAreaElement, ChatInputProps>(
commands = [],
suggestions = [],
onSearchSuggestions,
pendingImages,
onRemoveImage,
onAttachImages,
},
ref
) => {
const internalRef = useRef<HTMLTextAreaElement>(null);
const resolvedRef = (ref as React.RefObject<HTMLTextAreaElement>) || internalRef;
const fileInputRef = useRef<HTMLInputElement>(null);

const [showSuggestions, setShowSuggestions] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
Expand Down Expand Up @@ -186,6 +194,34 @@ export const ChatInput = React.forwardRef<HTMLTextAreaElement, ChatInputProps>(
}
}, [autoFocus, resolvedRef]);

const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData.items;
const imageFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) imageFiles.push(file);
}
}
if (imageFiles.length > 0) {
e.preventDefault();
onAttachImages?.(imageFiles);
}
};

const handleAttachClick = () => {
fileInputRef.current?.click();
};

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
onAttachImages?.(Array.from(files));
}
e.target.value = "";
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const listLength = trigger?.type === "command" ? filteredCommands.length : filteredSuggestions.length;
if (showSuggestions && listLength > 0 && trigger) {
Expand Down Expand Up @@ -228,6 +264,37 @@ export const ChatInput = React.forwardRef<HTMLTextAreaElement, ChatInputProps>(

return (
<div className="relative w-full">
{/* Hidden file input for image attachment */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleFileChange}
className="hidden"
/>

{/* Image preview strip */}
{pendingImages && pendingImages.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{pendingImages.map((img, i) => (
<div key={i} className="relative group">
<img
src={img.dataUrl}
alt={img.name}
className="w-16 h-16 object-cover rounded-lg border border-[var(--border)]"
/>
<button
onClick={() => onRemoveImage?.(i)}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-[var(--danger)] text-[var(--surface)] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={10} />
</button>
</div>
))}
</div>
)}

{/* Autocomplete suggestions */}
{showSuggestions && trigger && (
<div className="absolute bottom-full left-0 right-0 mb-1.5 z-50 bg-[var(--surface)] border border-[var(--border)] rounded-xl shadow-2xl overflow-hidden max-h-56 overflow-y-auto animate-in fade-in slide-in-from-bottom-2 duration-150">
Expand Down Expand Up @@ -319,6 +386,7 @@ export const ChatInput = React.forwardRef<HTMLTextAreaElement, ChatInputProps>(
onChange(e.target.value);
checkTrigger();
}}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={checkTrigger}
onSelect={checkTrigger}
Expand All @@ -331,7 +399,23 @@ export const ChatInput = React.forwardRef<HTMLTextAreaElement, ChatInputProps>(
)}
/>

<div className="flex items-center gap-2 flex-shrink-0 self-center">
<div className="flex items-center gap-1.5 flex-shrink-0 self-center">
{!isLoading && (
<Tooltip content="Attach image" side="left">
<button
onClick={handleAttachClick}
type="button"
disabled={disabled}
className={cn(
"flex-shrink-0 rounded-lg text-[var(--text-tertiary)] hover:text-[var(--accent)] hover:bg-[var(--surface-3)] disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center transition-all duration-200",
isOverview ? "w-8 h-8 rounded-xl" : "w-7 h-7"
)}
>
{/* eslint-disable-next-line jsx-a11y/alt-text -- lucide icon component, not <img> */}
<Image size={isOverview ? 13 : 12} />
</button>
</Tooltip>
)}
{isLoading && onStop ? (
<Tooltip content="Stop generation" side="left">
<button
Expand All @@ -349,7 +433,7 @@ export const ChatInput = React.forwardRef<HTMLTextAreaElement, ChatInputProps>(
<Tooltip content="Send (Enter)" side="left">
<button
onClick={onSubmit}
disabled={disabled || !value.trim()}
disabled={disabled || (!value.trim() && (!pendingImages || pendingImages.length === 0))}
type="button"
className={cn(
"flex-shrink-0 rounded-lg bg-[var(--accent)] text-white hover:bg-[color-mix(in srgb,var(--accent)_90%,black)] disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center transition-all duration-200 hover:scale-105 active:scale-95 shadow-md shadow-[var(--accent)]/10",
Expand Down
12 changes: 12 additions & 0 deletions src/components/chat/chat-panel/ChatMessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ export const ChatMessageBubble = React.memo(function ChatMessageBubble({ message
{!isUser && message.reasoning && (
<ThinkingPanel text={message.reasoning} />
)}
{message.images && message.images.length > 0 && (
<div className={cn("flex flex-wrap gap-2", isUser && "justify-end")}>
{message.images.map((img, i) => (
<img
key={i}
src={img.url}
alt={img.name}
className="max-w-[200px] max-h-[200px] rounded-lg border border-[var(--border)] object-cover"
/>
))}
</div>
)}
<div className={cn("px-3 py-2.5 rounded-xl text-xs leading-relaxed max-w-full",
isUser ? "bg-[var(--accent)] text-white rounded-tr-sm" : "bg-[var(--surface-2)] border border-[var(--border)] text-[var(--text-secondary)] rounded-tl-sm")}>
<MarkdownContent content={message.content} isUser={isUser} />
Expand Down
60 changes: 48 additions & 12 deletions src/components/chat/chat-panel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP
const threadId = activeChatThreadId;

const [input, setInput] = useState("");
const [pendingImages, setPendingImages] = useState<Array<{ name: string; dataUrl: string }>>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const projectRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -335,25 +336,56 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP
useEffect(() => { if (isChatActive) messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, isLoading, isChatActive]);
useEffect(() => { if (chatOpen) inputRef.current?.focus(); }, [chatOpen]);

const handleAttachImages = useCallback(async (files: File[]) => {
const imageItems: Array<{ name: string; dataUrl: string }> = [];
for (const file of files) {
try {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error(`Failed to read ${file.name}`));
reader.onabort = () => reject(new Error(`Read aborted for ${file.name}`));
reader.readAsDataURL(file);
});
imageItems.push({ name: file.name, dataUrl });
} catch (err) {
console.error("[chat] Skipping unreadable image:", err);
}
}
setPendingImages((prev) => [...prev, ...imageItems]);
}, []);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleRemoveImage = useCallback((index: number) => {
setPendingImages((prev) => prev.filter((_, i) => i !== index));
}, []);

const handleSend = useCallback(async (text?: string) => {
const content = text ?? input.trim();
if (!content || !threadId) return;
if ((!content || !content.trim()) && pendingImages.length === 0) return;
if (!threadId) return;

const trimmed = content.trim();
if (trimmed === "/compact" || trimmed === "/ compact") {
setInput("");
useCairnStore.getState().compactChatThread(threadId);
return;
}
if (!pendingImages.length) {
if (trimmed === "/compact" || trimmed === "/ compact") {
setInput("");
useCairnStore.getState().compactChatThread(threadId);
return;
}

if (trimmed === "/archive-chat" || trimmed === "/archive" || trimmed === "/ archive-chat" || trimmed === "/ archive") {
setInput("");
handleArchiveChat();
return;
if (trimmed === "/archive-chat" || trimmed === "/archive" || trimmed === "/ archive-chat" || trimmed === "/ archive") {
setInput("");
handleArchiveChat();
return;
}
}

setInput("");
addMessage(threadId, "user", content);

const imagesToSend = pendingImages.length > 0 ? pendingImages : undefined;
const imageUrls = pendingImages.map((img) => ({ url: img.dataUrl, name: img.name }));
setPendingImages([]);

addMessage(threadId, "user", content, undefined, undefined, undefined, undefined, imageUrls);

// Resolve context references and append to prompt payload
const store = useCairnStore.getState();
Expand Down Expand Up @@ -388,8 +420,9 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP
temperature: aiConfig.temperature ?? 0.3,
},
systemPrompt,
images: imagesToSend?.map((img) => ({ name: img.name, dataUrl: img.dataUrl })),
});
}, [input, threadId, addMessage, sendStream, activeProjectId, activeWorkspaceId, messages, aiConfig, activeView, graphData, selectedNode, handleArchiveChat, project]);
}, [input, threadId, addMessage, sendStream, activeProjectId, activeWorkspaceId, messages, aiConfig, activeView, graphData, selectedNode, handleArchiveChat, project, pendingImages]);

const handleRetry = useCallback((content: string) => {
handleSend(content);
Expand Down Expand Up @@ -553,6 +586,9 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP
placeholder={activeView === "graph" ? "Ask about your knowledge graph…" : "Ask about your project…"}
commands={CHAT_SLASH_COMMANDS}
suggestions={mentionSuggestions}
pendingImages={pendingImages}
onRemoveImage={handleRemoveImage}
onAttachImages={handleAttachImages}
/>
<div className="flex items-center justify-between mt-1.5 px-0.5">
<p className="text-[0.714rem] text-[var(--text-tertiary)]">
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/useChatStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export interface ChatStreamRequest {
temperature?: number;
};
systemPrompt?: string;
/** Images attached to the current user message (base64 data URLs) */
images?: Array<{ name: string; dataUrl: string }>;
}

export interface UseChatStreamResult {
Expand Down
Loading