feat(webchat): add image paste support

- Add paste event handler to chat textarea to capture clipboard images
- Add image preview UI with thumbnails and remove buttons
- Update sendChatMessage to pass attachments to chat.send RPC
- Add CSS styles for attachment preview (light/dark theme support)

Closes #1681 (image paste support portion)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
joeynyc
2026-01-25 13:45:09 -05:00
committed by Peter Steinberger
parent 08183fe009
commit fabdf2f6f7
6 changed files with 282 additions and 42 deletions

View File

@@ -103,7 +103,7 @@
bottom: 0; bottom: 0;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: stretch; flex-direction: column;
gap: 12px; gap: 12px;
margin-top: auto; /* Push to bottom of flex container */ margin-top: auto; /* Push to bottom of flex container */
padding: 12px 4px 4px; padding: 12px 4px 4px;
@@ -111,6 +111,92 @@
z-index: 10; z-index: 10;
} }
/* Image attachments preview */
.chat-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
background: var(--panel);
border-radius: 8px;
border: 1px solid var(--border);
}
.chat-attachment {
position: relative;
width: 80px;
height: 80px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border);
background: var(--bg);
}
.chat-attachment__img {
width: 100%;
height: 100%;
object-fit: cover;
}
.chat-attachment__remove {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 12px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 150ms ease-out;
}
.chat-attachment:hover .chat-attachment__remove {
opacity: 1;
}
.chat-attachment__remove:hover {
background: rgba(220, 38, 38, 0.9);
}
.chat-attachment__remove svg {
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 2px;
}
/* Light theme attachment overrides */
:root[data-theme="light"] .chat-attachments {
background: #f8fafc;
border-color: rgba(16, 24, 40, 0.1);
}
:root[data-theme="light"] .chat-attachment {
border-color: rgba(16, 24, 40, 0.15);
background: #fff;
}
:root[data-theme="light"] .chat-attachment__remove {
background: rgba(0, 0, 0, 0.6);
}
/* Compose input row - horizontal layout */
.chat-compose__row {
display: flex;
align-items: stretch;
gap: 12px;
flex: 1;
}
:root[data-theme="light"] .chat-compose { :root[data-theme="light"] .chat-compose {
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%); background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
} }

View File

@@ -1,4 +1,4 @@
import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat"; import { abortChatRun, loadChatHistory, sendChatMessage, type ChatAttachment } from "./controllers/chat";
import { loadSessions } from "./controllers/sessions"; import { loadSessions } from "./controllers/sessions";
import { generateUUID } from "./uuid"; import { generateUUID } from "./uuid";
import { resetToolStream } from "./app-tool-stream"; import { resetToolStream } from "./app-tool-stream";
@@ -12,6 +12,7 @@ import type { ClawdbotApp } from "./app";
type ChatHost = { type ChatHost = {
connected: boolean; connected: boolean;
chatMessage: string; chatMessage: string;
chatAttachments: ChatAttachment[];
chatQueue: Array<{ id: string; text: string; createdAt: number }>; chatQueue: Array<{ id: string; text: string; createdAt: number }>;
chatRunId: string | null; chatRunId: string | null;
chatSending: boolean; chatSending: boolean;
@@ -61,10 +62,10 @@ function enqueueChatMessage(host: ChatHost, text: string) {
async function sendChatMessageNow( async function sendChatMessageNow(
host: ChatHost, host: ChatHost,
message: string, message: string,
opts?: { previousDraft?: string; restoreDraft?: boolean }, opts?: { previousDraft?: string; restoreDraft?: boolean; attachments?: ChatAttachment[] },
) { ) {
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]); resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
const ok = await sendChatMessage(host as unknown as ClawdbotApp, message); const ok = await sendChatMessage(host as unknown as ClawdbotApp, message, opts?.attachments);
if (!ok && opts?.previousDraft != null) { if (!ok && opts?.previousDraft != null) {
host.chatMessage = opts.previousDraft; host.chatMessage = opts.previousDraft;
} }
@@ -104,7 +105,11 @@ export async function handleSendChat(
if (!host.connected) return; if (!host.connected) return;
const previousDraft = host.chatMessage; const previousDraft = host.chatMessage;
const message = (messageOverride ?? host.chatMessage).trim(); const message = (messageOverride ?? host.chatMessage).trim();
if (!message) return; const attachments = host.chatAttachments ?? [];
const hasAttachments = attachments.length > 0;
// Allow sending with just attachments (no message text required)
if (!message && !hasAttachments) return;
if (isChatStopCommand(message)) { if (isChatStopCommand(message)) {
await handleAbortChat(host); await handleAbortChat(host);
@@ -113,6 +118,8 @@ export async function handleSendChat(
if (messageOverride == null) { if (messageOverride == null) {
host.chatMessage = ""; host.chatMessage = "";
// Clear attachments when sending
host.chatAttachments = [];
} }
if (isChatBusy(host)) { if (isChatBusy(host)) {
@@ -123,6 +130,7 @@ export async function handleSendChat(
await sendChatMessageNow(host, message, { await sendChatMessageNow(host, message, {
previousDraft: messageOverride == null ? previousDraft : undefined, previousDraft: messageOverride == null ? previousDraft : undefined,
restoreDraft: Boolean(messageOverride && opts?.restoreDraft), restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
attachments: hasAttachments ? attachments : undefined,
}); });
} }

View File

@@ -477,6 +477,8 @@ export function renderApp(state: AppViewState) {
}, },
onChatScroll: (event) => state.handleChatScroll(event), onChatScroll: (event) => state.handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next), onDraftChange: (next) => (state.chatMessage = next),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(), onSend: () => state.handleSendChat(),
canAbort: Boolean(state.chatRunId), canAbort: Boolean(state.chatRunId),
onAbort: () => void state.handleAbortChat(), onAbort: () => void state.handleAbortChat(),

View File

@@ -129,6 +129,7 @@ export class ClawdbotApp extends LitElement {
@state() chatAvatarUrl: string | null = null; @state() chatAvatarUrl: string | null = null;
@state() chatThinkingLevel: string | null = null; @state() chatThinkingLevel: string | null = null;
@state() chatQueue: ChatQueueItem[] = []; @state() chatQueue: ChatQueueItem[] = [];
@state() chatAttachments: Array<{ id: string; dataUrl: string; mimeType: string }> = [];
// Sidebar state for tool output viewing // Sidebar state for tool output viewing
@state() sidebarOpen = false; @state() sidebarOpen = false;
@state() sidebarContent: string | null = null; @state() sidebarContent: string | null = null;

View File

@@ -2,6 +2,12 @@ import { extractText } from "../chat/message-extract";
import type { GatewayBrowserClient } from "../gateway"; import type { GatewayBrowserClient } from "../gateway";
import { generateUUID } from "../uuid"; import { generateUUID } from "../uuid";
export type ChatAttachment = {
id: string;
dataUrl: string;
mimeType: string;
};
export type ChatState = { export type ChatState = {
client: GatewayBrowserClient | null; client: GatewayBrowserClient | null;
connected: boolean; connected: boolean;
@@ -11,6 +17,7 @@ export type ChatState = {
chatThinkingLevel: string | null; chatThinkingLevel: string | null;
chatSending: boolean; chatSending: boolean;
chatMessage: string; chatMessage: string;
chatAttachments: ChatAttachment[];
chatRunId: string | null; chatRunId: string | null;
chatStream: string | null; chatStream: string | null;
chatStreamStartedAt: number | null; chatStreamStartedAt: number | null;
@@ -43,17 +50,44 @@ export async function loadChatHistory(state: ChatState) {
} }
} }
export async function sendChatMessage(state: ChatState, message: string): Promise<boolean> { function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null {
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
if (!match) return null;
return { mimeType: match[1], content: match[2] };
}
export async function sendChatMessage(
state: ChatState,
message: string,
attachments?: ChatAttachment[],
): Promise<boolean> {
if (!state.client || !state.connected) return false; if (!state.client || !state.connected) return false;
const msg = message.trim(); const msg = message.trim();
if (!msg) return false; const hasAttachments = attachments && attachments.length > 0;
if (!msg && !hasAttachments) return false;
const now = Date.now(); const now = Date.now();
// Build user message content blocks
const contentBlocks: Array<{ type: string; text?: string; source?: unknown }> = [];
if (msg) {
contentBlocks.push({ type: "text", text: msg });
}
// Add image previews to the message for display
if (hasAttachments) {
for (const att of attachments) {
contentBlocks.push({
type: "image",
source: { type: "base64", media_type: att.mimeType, data: att.dataUrl },
});
}
}
state.chatMessages = [ state.chatMessages = [
...state.chatMessages, ...state.chatMessages,
{ {
role: "user", role: "user",
content: [{ type: "text", text: msg }], content: contentBlocks,
timestamp: now, timestamp: now,
}, },
]; ];
@@ -64,12 +98,29 @@ export async function sendChatMessage(state: ChatState, message: string): Promis
state.chatRunId = runId; state.chatRunId = runId;
state.chatStream = ""; state.chatStream = "";
state.chatStreamStartedAt = now; state.chatStreamStartedAt = now;
// Convert attachments to API format
const apiAttachments = hasAttachments
? attachments
.map((att) => {
const parsed = dataUrlToBase64(att.dataUrl);
if (!parsed) return null;
return {
type: "image",
mimeType: parsed.mimeType,
content: parsed.content,
};
})
.filter((a): a is NonNullable<typeof a> => a !== null)
: undefined;
try { try {
await state.client.request("chat.send", { await state.client.request("chat.send", {
sessionKey: state.sessionKey, sessionKey: state.sessionKey,
message: msg, message: msg,
deliver: false, deliver: false,
idempotencyKey: runId, idempotencyKey: runId,
attachments: apiAttachments,
}); });
return true; return true;
} catch (err) { } catch (err) {

View File

@@ -22,6 +22,12 @@ export type CompactionIndicatorStatus = {
completedAt: number | null; completedAt: number | null;
}; };
export type ChatAttachment = {
id: string;
dataUrl: string;
mimeType: string;
};
export type ChatProps = { export type ChatProps = {
sessionKey: string; sessionKey: string;
onSessionKeyChange: (next: string) => void; onSessionKeyChange: (next: string) => void;
@@ -52,6 +58,9 @@ export type ChatProps = {
splitRatio?: number; splitRatio?: number;
assistantName: string; assistantName: string;
assistantAvatar: string | null; assistantAvatar: string | null;
// Image attachments
attachments?: ChatAttachment[];
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
// Event handlers // Event handlers
onRefresh: () => void; onRefresh: () => void;
onToggleFocusMode: () => void; onToggleFocusMode: () => void;
@@ -95,6 +104,82 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
return nothing; return nothing;
} }
function generateAttachmentId(): string {
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function handlePaste(
e: ClipboardEvent,
props: ChatProps,
) {
const items = e.clipboardData?.items;
if (!items || !props.onAttachmentsChange) return;
const imageItems: DataTransferItem[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith("image/")) {
imageItems.push(item);
}
}
if (imageItems.length === 0) return;
e.preventDefault();
for (const item of imageItems) {
const file = item.getAsFile();
if (!file) continue;
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
const newAttachment: ChatAttachment = {
id: generateAttachmentId(),
dataUrl,
mimeType: file.type,
};
const current = props.attachments ?? [];
props.onAttachmentsChange?.([...current, newAttachment]);
};
reader.readAsDataURL(file);
}
}
function renderAttachmentPreview(props: ChatProps) {
const attachments = props.attachments ?? [];
if (attachments.length === 0) return nothing;
return html`
<div class="chat-attachments">
${attachments.map(
(att) => html`
<div class="chat-attachment">
<img
src=${att.dataUrl}
alt="Attachment preview"
class="chat-attachment__img"
/>
<button
class="chat-attachment__remove"
type="button"
aria-label="Remove attachment"
@click=${() => {
const next = (props.attachments ?? []).filter(
(a) => a.id !== att.id,
);
props.onAttachmentsChange?.(next);
}}
>
${icons.x}
</button>
</div>
`,
)}
</div>
`;
}
export function renderChat(props: ChatProps) { export function renderChat(props: ChatProps) {
const canCompose = props.connected; const canCompose = props.connected;
const isBusy = props.sending || props.stream !== null; const isBusy = props.sending || props.stream !== null;
@@ -109,8 +194,11 @@ export function renderChat(props: ChatProps) {
avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null, avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null,
}; };
const hasAttachments = (props.attachments?.length ?? 0) > 0;
const composePlaceholder = props.connected const composePlaceholder = props.connected
? "Message (↩ to send, Shift+↩ for line breaks)" ? hasAttachments
? "Add a message or paste more images..."
: "Message (↩ to send, Shift+↩ for line breaks, paste images)"
: "Connect to the gateway to start chatting…"; : "Connect to the gateway to start chatting…";
const splitRatio = props.splitRatio ?? 0.6; const splitRatio = props.splitRatio ?? 0.6;
@@ -235,39 +323,43 @@ export function renderChat(props: ChatProps) {
: nothing} : nothing}
<div class="chat-compose"> <div class="chat-compose">
<label class="field chat-compose__field"> ${renderAttachmentPreview(props)}
<span>Message</span> <div class="chat-compose__row">
<textarea <label class="field chat-compose__field">
.value=${props.draft} <span>Message</span>
?disabled=${!props.connected} <textarea
@keydown=${(e: KeyboardEvent) => { .value=${props.draft}
if (e.key !== "Enter") return; ?disabled=${!props.connected}
if (e.isComposing || e.keyCode === 229) return; @keydown=${(e: KeyboardEvent) => {
if (e.shiftKey) return; // Allow Shift+Enter for line breaks if (e.key !== "Enter") return;
if (!props.connected) return; if (e.isComposing || e.keyCode === 229) return;
e.preventDefault(); if (e.shiftKey) return; // Allow Shift+Enter for line breaks
if (canCompose) props.onSend(); if (!props.connected) return;
}} e.preventDefault();
@input=${(e: Event) => if (canCompose) props.onSend();
props.onDraftChange((e.target as HTMLTextAreaElement).value)} }}
placeholder=${composePlaceholder} @input=${(e: Event) =>
></textarea> props.onDraftChange((e.target as HTMLTextAreaElement).value)}
</label> @paste=${(e: ClipboardEvent) => handlePaste(e, props)}
<div class="chat-compose__actions"> placeholder=${composePlaceholder}
<button ></textarea>
class="btn" </label>
?disabled=${!props.connected || (!canAbort && props.sending)} <div class="chat-compose__actions">
@click=${canAbort ? props.onAbort : props.onNewSession} <button
> class="btn"
${canAbort ? "Stop" : "New session"} ?disabled=${!props.connected || (!canAbort && props.sending)}
</button> @click=${canAbort ? props.onAbort : props.onNewSession}
<button >
class="btn primary" ${canAbort ? "Stop" : "New session"}
?disabled=${!props.connected} </button>
@click=${props.onSend} <button
> class="btn primary"
${isBusy ? "Queue" : "Send"}<kbd class="btn-kbd">↵</kbd> ?disabled=${!props.connected}
</button> @click=${props.onSend}
>
${isBusy ? "Queue" : "Send"}<kbd class="btn-kbd">↵</kbd>
</button>
</div>
</div> </div>
</div> </div>
</section> </section>