diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index e137cb8c8..951266a98 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -103,7 +103,7 @@ bottom: 0; flex-shrink: 0; display: flex; - align-items: stretch; + flex-direction: column; gap: 12px; margin-top: auto; /* Push to bottom of flex container */ padding: 12px 4px 4px; @@ -111,6 +111,92 @@ 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 { background: linear-gradient(to bottom, transparent, var(--bg-content) 20%); } diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 81aae3c88..3ff74935d 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -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 { generateUUID } from "./uuid"; import { resetToolStream } from "./app-tool-stream"; @@ -12,6 +12,7 @@ import type { ClawdbotApp } from "./app"; type ChatHost = { connected: boolean; chatMessage: string; + chatAttachments: ChatAttachment[]; chatQueue: Array<{ id: string; text: string; createdAt: number }>; chatRunId: string | null; chatSending: boolean; @@ -61,10 +62,10 @@ function enqueueChatMessage(host: ChatHost, text: string) { async function sendChatMessageNow( host: ChatHost, message: string, - opts?: { previousDraft?: string; restoreDraft?: boolean }, + opts?: { previousDraft?: string; restoreDraft?: boolean; attachments?: ChatAttachment[] }, ) { resetToolStream(host as unknown as Parameters[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) { host.chatMessage = opts.previousDraft; } @@ -104,7 +105,11 @@ export async function handleSendChat( if (!host.connected) return; const previousDraft = host.chatMessage; 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)) { await handleAbortChat(host); @@ -113,6 +118,8 @@ export async function handleSendChat( if (messageOverride == null) { host.chatMessage = ""; + // Clear attachments when sending + host.chatAttachments = []; } if (isChatBusy(host)) { @@ -123,6 +130,7 @@ export async function handleSendChat( await sendChatMessageNow(host, message, { previousDraft: messageOverride == null ? previousDraft : undefined, restoreDraft: Boolean(messageOverride && opts?.restoreDraft), + attachments: hasAttachments ? attachments : undefined, }); } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index db29bd7ec..38b16b084 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -477,6 +477,8 @@ export function renderApp(state: AppViewState) { }, onChatScroll: (event) => state.handleChatScroll(event), onDraftChange: (next) => (state.chatMessage = next), + attachments: state.chatAttachments, + onAttachmentsChange: (next) => (state.chatAttachments = next), onSend: () => state.handleSendChat(), canAbort: Boolean(state.chatRunId), onAbort: () => void state.handleAbortChat(), diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 0e21d283a..310305ff9 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -129,6 +129,7 @@ export class ClawdbotApp extends LitElement { @state() chatAvatarUrl: string | null = null; @state() chatThinkingLevel: string | null = null; @state() chatQueue: ChatQueueItem[] = []; + @state() chatAttachments: Array<{ id: string; dataUrl: string; mimeType: string }> = []; // Sidebar state for tool output viewing @state() sidebarOpen = false; @state() sidebarContent: string | null = null; diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 3d967f672..644d49358 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -2,6 +2,12 @@ import { extractText } from "../chat/message-extract"; import type { GatewayBrowserClient } from "../gateway"; import { generateUUID } from "../uuid"; +export type ChatAttachment = { + id: string; + dataUrl: string; + mimeType: string; +}; + export type ChatState = { client: GatewayBrowserClient | null; connected: boolean; @@ -11,6 +17,7 @@ export type ChatState = { chatThinkingLevel: string | null; chatSending: boolean; chatMessage: string; + chatAttachments: ChatAttachment[]; chatRunId: string | null; chatStream: string | null; chatStreamStartedAt: number | null; @@ -43,17 +50,44 @@ export async function loadChatHistory(state: ChatState) { } } -export async function sendChatMessage(state: ChatState, message: string): Promise { +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 { if (!state.client || !state.connected) return false; const msg = message.trim(); - if (!msg) return false; + const hasAttachments = attachments && attachments.length > 0; + if (!msg && !hasAttachments) return false; 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, { role: "user", - content: [{ type: "text", text: msg }], + content: contentBlocks, timestamp: now, }, ]; @@ -64,12 +98,29 @@ export async function sendChatMessage(state: ChatState, message: string): Promis state.chatRunId = runId; state.chatStream = ""; 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 => a !== null) + : undefined; + try { await state.client.request("chat.send", { sessionKey: state.sessionKey, message: msg, deliver: false, idempotencyKey: runId, + attachments: apiAttachments, }); return true; } catch (err) { diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index dd61ca0ec..17fc8401f 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -22,6 +22,12 @@ export type CompactionIndicatorStatus = { completedAt: number | null; }; +export type ChatAttachment = { + id: string; + dataUrl: string; + mimeType: string; +}; + export type ChatProps = { sessionKey: string; onSessionKeyChange: (next: string) => void; @@ -52,6 +58,9 @@ export type ChatProps = { splitRatio?: number; assistantName: string; assistantAvatar: string | null; + // Image attachments + attachments?: ChatAttachment[]; + onAttachmentsChange?: (attachments: ChatAttachment[]) => void; // Event handlers onRefresh: () => void; onToggleFocusMode: () => void; @@ -95,6 +104,82 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un 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` +
+ ${attachments.map( + (att) => html` +
+ Attachment preview + +
+ `, + )} +
+ `; +} + export function renderChat(props: ChatProps) { const canCompose = props.connected; const isBusy = props.sending || props.stream !== null; @@ -109,8 +194,11 @@ export function renderChat(props: ChatProps) { avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null, }; + const hasAttachments = (props.attachments?.length ?? 0) > 0; 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…"; const splitRatio = props.splitRatio ?? 0.6; @@ -235,39 +323,43 @@ export function renderChat(props: ChatProps) { : nothing}
- -
- - + ${renderAttachmentPreview(props)} +
+ +
+ + +