From cc54b3a10c41a3bece5e994ebfddbfd7021b5479 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Fri, 13 Feb 2026 15:03:48 -0800 Subject: [PATCH] feat(webchat): support image attachments --- .../2026-02-13-webchat-attachments-plan.md | 423 ++++++++++++++++++ docs/plans/state.json | 13 +- src/gateway/handlers/agent.ts | 23 +- src/gateway/handlers/handlers.test.ts | 23 +- src/gateway/ui/chat.html | 63 ++- src/gateway/ui/pages/chat.js | 151 ++++++- src/gateway/ui/style.css | 42 ++ 7 files changed, 707 insertions(+), 31 deletions(-) create mode 100644 docs/plans/2026-02-13-webchat-attachments-plan.md diff --git a/docs/plans/2026-02-13-webchat-attachments-plan.md b/docs/plans/2026-02-13-webchat-attachments-plan.md new file mode 100644 index 0000000..db02e38 --- /dev/null +++ b/docs/plans/2026-02-13-webchat-attachments-plan.md @@ -0,0 +1,423 @@ +# Webchat Attachment Support Implementation Plan + +**Date:** 2026-02-13 +**Status:** Planning +**Scope:** Minimal — Image attachments first (audio as follow-up) + +## Overview + +Enable webchat users to send image attachments through the gateway to the agent. This completes the deferred "webchat" phase from the P4 media pipeline (phase `6c_gateway_protocol_attachments` marked the protocol-level support as complete, but UI integration was deferred). + +## Current State + +- **Protocol:** `GatewayAttachment` type exists in `src/gateway/protocol.ts` (added in phase `6c_gateway_protocol_attachments`) +- **Backend:** `agent.send` handler in `src/gateway/handlers/agent.ts` already accepts optional `attachments` parameter and converts them to channel `Attachment[]` format +- **Media pipeline:** All model clients support multimodal messages (images via base64 or URL) +- **Other channels:** Telegram, Discord, Slack, WhatsApp all extract and pass image attachments +- **Webchat:** Currently text-only + +## Scope + +### In Scope +- Image attachments (jpeg, png, gif, webp) +- File input UI in webchat +- Client-side base64 encoding +- Gateway protocol attachment parameter +- Basic error handling (file size, type validation) +- Tests for gateway attachment handling +- Update `docs/plans/state.json` (mark phase `6d_webchat_attachments` complete) + +### Out of Scope (Follow-up) +- Audio attachments (requires different UX — separate mic button, audio preview) +- Document attachments (PDF, etc.) +- Image preview/thumbnail UI in message list +- Drag-and-drop file upload +- Multiple file selection +- Image compression/resizing client-side + +## Implementation Plan + +### Phase 1: UI Changes (Frontend) + +**File:** `src/gateway/ui/pages/chat.js` + +#### 1.1 Add File Input Element +Add a hidden file input and an "attach" button next to the send button. + +```html + + + + +``` + +**Tasks:** +- Add file input and attach button to `render()` function +- Store references in `_elements` object +- Wire click handler: attach button triggers file input +- Accept only image MIME types + +#### 1.2 File Selection Handler +Read selected file, validate, encode to base64, store in component state. + +```js +let _selectedFile = null; // { mimeType, data, filename } + +async function handleFileSelect(event) { + const file = event.target.files?.[0]; + if (!file) return; + + // Validate type + const supportedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + if (!supportedTypes.includes(file.type)) { + showSystemMessage('❌ Unsupported file type. Only JPEG, PNG, GIF, and WebP images are supported.'); + return; + } + + // Validate size (5MB limit) + const MAX_SIZE = 5 * 1024 * 1024; + if (file.size > MAX_SIZE) { + showSystemMessage('❌ File too large. Maximum size is 5MB.'); + return; + } + + // Read and encode + const reader = new FileReader(); + reader.onload = () => { + const base64 = reader.result.split(',')[1]; // Strip data URI prefix + _selectedFile = { + mimeType: file.type, + data: base64, + filename: file.name, + }; + updateAttachmentUI(); + }; + reader.onerror = () => { + showSystemMessage('❌ Failed to read file.'); + }; + reader.readAsDataURL(file); +} +``` + +**Tasks:** +- Implement `handleFileSelect()` +- Validate MIME type against `['image/jpeg', 'image/png', 'image/gif', 'image/webp']` +- Validate file size (5MB max) +- Read file as base64 using `FileReader` +- Store in `_selectedFile` state variable +- Clear file input after reading + +#### 1.3 Attachment UI Indicator +Show selected file name and allow removal before sending. + +```js +function updateAttachmentUI() { + const indicator = _elements.attachmentIndicator; + if (!indicator) return; + + if (_selectedFile) { + indicator.textContent = `📎 ${_selectedFile.filename}`; + indicator.classList.remove('hidden'); + } else { + indicator.textContent = ''; + indicator.classList.add('hidden'); + } +} + +function clearAttachment() { + _selectedFile = null; + const fileInput = _elements.fileInput; + if (fileInput) fileInput.value = ''; + updateAttachmentUI(); +} +``` + +**UI element (add to input wrapper):** +```html + +``` + +**Tasks:** +- Add attachment indicator element to UI +- Implement `updateAttachmentUI()` to show/hide indicator +- Implement `clearAttachment()` to reset state +- Add clear button handler + +#### 1.4 Update Send Handler +Pass `attachments` array to `agent.send` RPC call. + +```js +async function sendMessage() { + const text = _elements.input.value.trim(); + const attachment = _selectedFile; + + if (!text && !attachment) return; // Nothing to send + + // ... existing command parsing ... + + // Prepare RPC params + const params = { message: text }; + if (attachment) { + params.attachments = [attachment]; + } + + // Add user message to UI (with attachment indicator if present) + let displayText = text; + if (attachment) { + displayText = `📎 ${attachment.filename}\n${text}`; + } + _elements.messages.appendChild(createMessageEl('user', displayText)); + + // Clear input and attachment + _elements.input.value = ''; + clearAttachment(); + + // Send RPC + try { + await client.call('agent.send', params, handleStreamingEvent); + } catch (err) { + // ... error handling ... + } +} +``` + +**Tasks:** +- Modify `sendMessage()` to include `attachments` in RPC params if `_selectedFile` is set +- Update user message display to show attachment indicator +- Clear `_selectedFile` after sending +- Allow sending attachment-only messages (text optional) + +#### 1.5 CSS Styling + +**File:** `src/gateway/ui/style.css` + +```css +/* File input and attach button */ +#attach-btn { + background: transparent; + border: none; + font-size: 1.2rem; + cursor: pointer; + padding: 0.5rem; + opacity: 0.7; +} + +#attach-btn:hover { + opacity: 1; +} + +/* Attachment indicator */ +.attachment-indicator { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: var(--primary-bg); + border: 1px solid var(--border); + border-radius: 4px; + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +.attachment-indicator.hidden { + display: none; +} + +.attachment-indicator .btn-clear { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 1.2rem; + line-height: 1; + padding: 0 0.25rem; +} + +.attachment-indicator .btn-clear:hover { + color: var(--text-primary); +} +``` + +**Tasks:** +- Add styles for attach button +- Add styles for attachment indicator +- Add styles for clear button +- Ensure responsive layout + +### Phase 2: Testing + +#### 2.1 Backend Integration Tests + +**File:** `src/gateway/handlers/handlers.test.ts` (extend existing tests) + +Add tests for `agent.send` with attachments: + +```ts +describe('agent.send with attachments', () => { + it('should accept image attachments and convert to channel format', async () => { + const params = { + message: 'What is this?', + attachments: [ + { + mimeType: 'image/jpeg', + data: 'base64data', + filename: 'photo.jpg', + }, + ], + }; + // ... test that agent.process receives correct Attachment[] + }); + + it('should allow attachment-only messages', async () => { + const params = { + message: '', + attachments: [{ mimeType: 'image/png', data: 'base64' }], + }; + // ... test that message is processed + }); + + it('should handle missing data/url gracefully', async () => { + const params = { + message: 'test', + attachments: [{ mimeType: 'image/jpeg' }], // no data or url + }; + // ... test graceful handling (attachment ignored or error) + }); +}); +``` + +**Tasks:** +- Add tests for `agent.send` with `attachments` parameter +- Verify conversion from `GatewayAttachment[]` to `Attachment[]` +- Test attachment-only messages (empty text) +- Test malformed attachments (missing data/url) + +#### 2.2 Manual Testing Checklist + +**Test cases:** +1. Select and send a JPEG image → agent receives image, responds with description +2. Select and send a PNG image → agent receives image +3. Select an invalid file type (e.g., .txt) → error message shown, file rejected +4. Select a large file (>5MB) → error message shown, file rejected +5. Select an image, then clear it before sending → no attachment sent +6. Send attachment-only message (no text) → agent receives image +7. Send text + image → agent receives both +8. Send message, then send another with attachment → both messages processed correctly +9. Test on mobile (touch events for attach button) + +**Tasks:** +- Run full test suite: `pnpm test:run` +- Run typecheck: `pnpm typecheck` +- Manual testing in browser (Desktop Chrome, Firefox) +- Manual testing on mobile (Android/iOS) + +### Phase 3: Documentation Updates + +#### 3.1 Update `docs/plans/state.json` + +Add new phase entry under `p6-enhanced-media-pipeline`: + +```json +"6d_webchat_attachments": { + "priority": "P6", + "status": "completed", + "description": "Webchat UI for image attachments: file input, base64 encoding, attachment indicator, send handler, gateway protocol integration", + "files_modified": [ + "src/gateway/ui/pages/chat.js", + "src/gateway/ui/style.css", + "src/gateway/handlers/handlers.test.ts" + ], + "test_status": "manual + integration tests passing" +} +``` + +**Tasks:** +- Add `6d_webchat_attachments` phase +- Update `p6-enhanced-media-pipeline` summary to include webchat +- Update overall test count if applicable + +#### 3.2 Update README or User Docs (if needed) + +**File:** `README.md` (webchat section) + +Add note about attachment support: + +```md +### Webchat Features + +- Real-time chat with streaming responses +- Markdown rendering with syntax highlighting +- Slash commands (/help, /reset, /compact, /usage, /status, /model) +- Web search toggle +- **Image attachments** (JPEG, PNG, GIF, WebP up to 5MB) +- Message actions (copy, edit) +``` + +**Tasks:** +- Update README webchat section (if exists) +- Add attachment feature to feature list + +## File Changes Summary + +### Modified Files +1. `src/gateway/ui/pages/chat.js` + - Add file input element and attach button + - Implement `handleFileSelect()` for base64 encoding + - Add `_selectedFile` state variable + - Add `updateAttachmentUI()` and `clearAttachment()` helpers + - Modify `sendMessage()` to include attachments in RPC params + - Update user message display to show attachment indicator + +2. `src/gateway/ui/style.css` + - Styles for attach button + - Styles for attachment indicator + - Styles for clear button + +3. `src/gateway/handlers/handlers.test.ts` + - Add tests for `agent.send` with attachments + - Test attachment-only messages + - Test malformed attachments + +4. `docs/plans/state.json` + - Add `6d_webchat_attachments` phase entry + +5. `README.md` (optional) + - Update webchat feature list + +### No Changes Needed +- `src/gateway/protocol.ts` (already has `GatewayAttachment` type) +- `src/gateway/handlers/agent.ts` (already handles attachments parameter) +- `src/models/media.ts` (already has conversion logic) +- `src/channels/types.ts` (already has `Attachment` type) + +## Follow-up Work (Not in Scope) + +### Audio Attachments +- Requires different UX: separate mic button, recording UI, audio waveform preview +- Audio transcription via Whisper (already supported in backend) +- Estimate: 1-2 days + +### Enhanced UX +- Image preview/thumbnail in message list +- Drag-and-drop file upload +- Multiple file selection +- Client-side image compression/resizing (reduce bandwidth) +- Copy/paste image from clipboard +- Estimate: 2-3 days + +## Notes + +- **Security:** Client-side validation is not sufficient. Backend should also validate MIME types and file sizes if needed (currently delegated to model providers). +- **Performance:** 5MB limit keeps base64 payloads reasonable for WebSocket messages. Larger files should use URL-based attachments (out of scope). +- **Compatibility:** `FileReader` API is widely supported (IE10+, all modern browsers). +- **Mobile:** File input works on mobile browsers (triggers camera/gallery picker). + +## Estimated Effort + +- **Phase 1 (UI):** 2-3 hours +- **Phase 2 (Testing):** 1 hour +- **Phase 3 (Docs):** 30 minutes +- **Total:** ~4 hours diff --git a/docs/plans/state.json b/docs/plans/state.json index 5076266..ae62060 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -444,8 +444,15 @@ "files_modified": ["src/channels/whatsapp/adapter.ts"] }, "webchat": { - "status": "deferred", - "description": "Requires gateway protocol update for WebSocket attachment messages" + "status": "completed", + "description": "Webchat UI can send image attachments via gateway agent.send attachments parameter; agent handler accepts attachment-only messages", + "files_modified": [ + "src/gateway/ui/pages/chat.js", + "src/gateway/ui/chat.html", + "src/gateway/ui/style.css", + "src/gateway/handlers/agent.ts", + "src/gateway/handlers/handlers.test.ts" + ] } } } @@ -1827,7 +1834,7 @@ }, "overall_progress": { - "total_test_count": 1617, + "total_test_count": 1625, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", diff --git a/src/gateway/handlers/agent.ts b/src/gateway/handlers/agent.ts index bcf87c9..4dbe15c 100644 --- a/src/gateway/handlers/agent.ts +++ b/src/gateway/handlers/agent.ts @@ -21,11 +21,18 @@ export function createAgentHandlers(deps: AgentHandlerDeps) { return { 'agent.send': async (request: GatewayRequest, send: SendFn): Promise => { const params = request.params as { message?: string; connectionId?: string; attachments?: GatewayAttachment[]; metadata?: { isCommand?: boolean; command?: string; commandArgs?: string } } | undefined; - if (!params?.message && !params?.metadata?.isCommand) { - return makeError(request.id, ErrorCode.InvalidRequest, 'message is required'); + if (!params) { + return makeError(request.id, ErrorCode.InvalidRequest, 'params are required'); } - const connectionId = params.connectionId as string; + const safeParams = params; + const hasMessage = Boolean(safeParams.message && safeParams.message.trim()); + const hasAttachments = Boolean(safeParams.attachments && safeParams.attachments.length > 0); + if (!hasMessage && !hasAttachments && !safeParams.metadata?.isCommand) { + return makeError(request.id, ErrorCode.InvalidRequest, 'message or attachments are required'); + } + + const connectionId = safeParams.connectionId as string; if (!connectionId) { return makeError(request.id, ErrorCode.InvalidRequest, 'connectionId is required (set by server)'); } @@ -48,9 +55,9 @@ export function createAgentHandlers(deps: AgentHandlerDeps) { return deps.laneQueue.enqueue(laneId, async () => { deps.sessionBridge.setBusy(connectionId, true); - const commandInput = params.metadata?.isCommand && typeof params.metadata.command === 'string' - ? `/${params.metadata.command}${params.metadata.commandArgs ? ` ${params.metadata.commandArgs}` : ''}` - : params.message; + const commandInput = safeParams.metadata?.isCommand && typeof safeParams.metadata.command === 'string' + ? `/${safeParams.metadata.command}${safeParams.metadata.commandArgs ? ` ${safeParams.metadata.commandArgs}` : ''}` + : (safeParams.message ?? ''); if (commandInput && deps.commandRegistry?.isCommand(commandInput)) { const sessionId = deps.sessionBridge.getSessionId(connectionId); @@ -160,14 +167,14 @@ export function createAgentHandlers(deps: AgentHandlerDeps) { try { // Convert gateway attachments to channel attachments - const attachments: Attachment[] | undefined = params.attachments?.map(a => ({ + const attachments: Attachment[] | undefined = safeParams.attachments?.map(a => ({ mimeType: a.mimeType, data: a.data, url: a.url, filename: a.filename, })); - const response = await agent.process(params.message!, attachments); + const response = await agent.process(safeParams.message ?? '', attachments); deps.metrics?.incrementMessages(); send(makeEvent(request.id, 'done', { content: response })); } catch (err) { diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index 2c661bf..1cbf0c2 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -323,7 +323,28 @@ describe('agent handlers', () => { expect(sent).toHaveLength(1); }); - it('agent.send requires message', async () => { + it('agent.send accepts attachment-only requests', async () => { + const req: GatewayRequest = { + id: 12, + method: 'agent.send', + params: { + connectionId: 'conn-1', + attachments: [{ mimeType: 'image/png', data: 'iVBOR...' }], + }, + }; + const sent: OutboundMessage[] = []; + const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); + + await handlers['agent.send'](req, send); + + expect(mockAgent.process).toHaveBeenCalledWith('', [ + { mimeType: 'image/png', data: 'iVBOR...', url: undefined, filename: undefined }, + ]); + expect(sent).toHaveLength(1); + expect((sent[0] as GatewayEvent).event).toBe('done'); + }); + + it('agent.send requires message or attachments', async () => { const req: GatewayRequest = { id: 2, method: 'agent.send', params: { connectionId: 'conn-1' } }; const send = vi.fn(); const result = await handlers['agent.send'](req, send) as GatewayError; diff --git a/src/gateway/ui/chat.html b/src/gateway/ui/chat.html index 06eea8d..647c7ea 100644 --- a/src/gateway/ui/chat.html +++ b/src/gateway/ui/chat.html @@ -15,6 +15,8 @@
+ +
@@ -34,8 +36,36 @@ const messagesEl = document.getElementById('messages'); const inputEl = document.getElementById('input'); const sendBtn = document.getElementById('send'); + const attachBtn = document.getElementById('attach'); + const fileEl = document.getElementById('file'); const statusEl = document.getElementById('status'); + let pendingAttachments = []; + + function isSupportedImageMime(mimeType) { + return mimeType === 'image/jpeg' + || mimeType === 'image/png' + || mimeType === 'image/gif' + || mimeType === 'image/webp'; + } + + function readFileAsBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error('Failed to read file')); + reader.onload = () => { + const result = String(reader.result || ''); + const comma = result.indexOf(','); + if (comma === -1) { + reject(new Error('Unexpected file encoding')); + return; + } + resolve(result.slice(comma + 1)); + }; + reader.readAsDataURL(file); + }); + } + // ── WebSocket connection ─────────────────────────────────────── function connect() { @@ -82,13 +112,13 @@ function sendMessage() { const text = inputEl.value.trim(); - if (!text || !ws || ws.readyState !== WebSocket.OPEN || inputDisabled) return; + if ((!text && pendingAttachments.length === 0) || !ws || ws.readyState !== WebSocket.OPEN || inputDisabled) return; requestId++; const id = requestId; // Display user message - appendUserMessage(text); + appendUserMessage(text || `Sent ${pendingAttachments.length} attachment(s)`); // Create an assistant message area for tool events + final response createAssistantArea(id); @@ -97,14 +127,41 @@ ws.send(JSON.stringify({ id: id, method: 'agent.send', - params: { message: text }, + params: { message: text, attachments: pendingAttachments }, })); // Clear input and disable while waiting inputEl.value = ''; + pendingAttachments = []; + fileEl.value = ''; setInputDisabled(true); } + attachBtn.addEventListener('click', () => { + fileEl.click(); + }); + + fileEl.addEventListener('change', async () => { + const files = Array.from(fileEl.files ?? []); + const MAX_BYTES = 5 * 1024 * 1024; + for (const file of files) { + if (!isSupportedImageMime(file.type)) { + appendErrorMessage(`Unsupported file type: ${file.type || file.name}`); + continue; + } + if (file.size > MAX_BYTES) { + appendErrorMessage(`File too large (max 5MB): ${file.name}`); + continue; + } + try { + const data = await readFileAsBase64(file); + pendingAttachments.push({ mimeType: file.type, data, filename: file.name }); + } catch (err) { + appendErrorMessage(`Failed to attach ${file.name}: ${err.message}`); + } + } + }); + function setInputDisabled(disabled) { inputDisabled = disabled; inputEl.disabled = disabled; diff --git a/src/gateway/ui/pages/chat.js b/src/gateway/ui/pages/chat.js index bd83b0a..651c983 100644 --- a/src/gateway/ui/pages/chat.js +++ b/src/gateway/ui/pages/chat.js @@ -12,6 +12,7 @@ let _sending = false; let _searchMode = false; let _slashPopupIndex = -1; let _elements = {}; +let _pendingAttachments = []; // ── Slash Command Definitions ─────────────────────────────── @@ -114,6 +115,75 @@ function scrollToBottom() { } } +function isSupportedImageMime(mimeType) { + return mimeType === 'image/jpeg' + || mimeType === 'image/png' + || mimeType === 'image/gif' + || mimeType === 'image/webp'; +} + +function readFileAsBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error('Failed to read file')); + reader.onload = () => { + const result = String(reader.result || ''); + // result is like: data:;base64, + const comma = result.indexOf(','); + if (comma === -1) { + reject(new Error('Unexpected file encoding')); + return; + } + resolve(result.slice(comma + 1)); + }; + reader.readAsDataURL(file); + }); +} + +function renderPendingAttachments() { + const el = _elements.attachments; + if (!el) {return;} + + el.innerHTML = ''; + if (!_pendingAttachments.length) { + el.classList.add('hidden'); + return; + } + + el.classList.remove('hidden'); + for (let i = 0; i < _pendingAttachments.length; i++) { + const att = _pendingAttachments[i]; + const chip = document.createElement('div'); + chip.className = 'attachment-chip'; + + const name = document.createElement('span'); + name.className = 'attachment-name'; + name.textContent = att.filename || 'attachment'; + + const rm = document.createElement('button'); + rm.className = 'attachment-remove'; + rm.type = 'button'; + rm.title = 'Remove attachment'; + rm.textContent = '×'; + rm.addEventListener('click', () => { + _pendingAttachments.splice(i, 1); + renderPendingAttachments(); + }); + + chip.appendChild(name); + chip.appendChild(rm); + el.appendChild(chip); + } +} + +function clearPendingAttachments() { + _pendingAttachments = []; + if (_elements.fileInput) { + _elements.fileInput.value = ''; + } + renderPendingAttachments(); +} + // ── Message Action Buttons ────────────────────────────────── function getMessageText(el) { @@ -463,33 +533,38 @@ async function loadHistory(client) { async function sendMessage(client, overrideText) { const input = _elements.input; - const rawText = overrideText ?? input?.value?.trim(); - if (!rawText || _sending) {return;} + const rawText = (overrideText ?? input?.value ?? '').trim(); + const hasText = Boolean(rawText); + const hasAttachments = Boolean(_pendingAttachments.length > 0); + if ((!hasText && !hasAttachments) || _sending) {return;} - // Check for slash commands first - const cmd = parseSlashCommand(rawText); - if (cmd) { - if (!overrideText) {input.value = '';} - hideSlashPopup(); - const handled = await handleSlashCommand(cmd, client); - if (handled) {return;} - // If not fully handled (e.g. /compact), fall through to send as message + // Check for slash commands first (text only) + if (hasText) { + const cmd = parseSlashCommand(rawText); + if (cmd) { + if (!overrideText) {input.value = '';} + hideSlashPopup(); + const handled = await handleSlashCommand(cmd, client); + if (handled) {return;} + // If not fully handled (e.g. /compact), fall through to send as message + } } _sending = true; _elements.sendBtn.disabled = true; - if (!overrideText) {input.value = '';} + if (!overrideText && input) {input.value = '';} // Apply search mode prefix let messageText = rawText; - if (_searchMode && !rawText.startsWith('/')) { + if (_searchMode && hasText && !rawText.startsWith('/')) { messageText = `Search the web for: ${rawText}`; setSearchMode(false); } - // Show user message (show original text, not the prefixed version) - const displayText = _searchMode ? rawText : messageText; - _elements.messages.appendChild(createMessageEl('user', rawText)); + const userDisplay = hasText + ? rawText + : `Sent ${_pendingAttachments.length} attachment(s)`; + _elements.messages.appendChild(createMessageEl('user', userDisplay)); scrollToBottom(); // Create placeholder for assistant response @@ -500,7 +575,7 @@ async function sendMessage(client, overrideText) { scrollToBottom(); try { - const stream = client.stream('agent.send', { message: messageText }); + const stream = client.stream('agent.send', { message: messageText, attachments: _pendingAttachments }); stream.on('tool_start', (data) => { const el = createToolEventEl('tool_start', data); @@ -542,6 +617,7 @@ async function sendMessage(client, overrideText) { } finally { _sending = false; if (_elements.sendBtn) {_elements.sendBtn.disabled = false;} + clearPendingAttachments(); scrollToBottom(); } } @@ -549,6 +625,7 @@ async function sendMessage(client, overrideText) { // ── Search SVG Icon ───────────────────────────────────────── const SEARCH_ICON = ''; +const ATTACH_ICON = ''; const COPY_ICON = ''; const CHECK_ICON = ''; const EDIT_ICON = ''; @@ -570,6 +647,12 @@ export const ChatPage = { ${SEARCH_ICON} Search + + +
@@ -587,6 +670,9 @@ export const ChatPage = { input: el.querySelector('#chat-input'), sendBtn: el.querySelector('#chat-send'), searchBtn: el.querySelector('#chat-search'), + attachBtn: el.querySelector('#chat-attach'), + fileInput: el.querySelector('#chat-file'), + attachments: el.querySelector('#chat-attachments'), slashPopup: el.querySelector('#slash-popup'), }; @@ -620,6 +706,38 @@ export const ChatPage = { setSearchMode(!_searchMode); }); + // Event: attach button + _elements.attachBtn.addEventListener('click', () => { + if (_elements.fileInput) {_elements.fileInput.click();} + }); + + // Event: file input change + _elements.fileInput.addEventListener('change', async () => { + const files = Array.from(_elements.fileInput.files ?? []); + if (files.length === 0) {return;} + + const MAX_BYTES = 5 * 1024 * 1024; + for (const file of files) { + if (!isSupportedImageMime(file.type)) { + showSystemMessage(`Unsupported file type: ${file.type || file.name}`); + continue; + } + if (file.size > MAX_BYTES) { + showSystemMessage(`File too large (max 5MB): ${file.name}`); + continue; + } + + try { + const data = await readFileAsBase64(file); + _pendingAttachments.push({ mimeType: file.type, data, filename: file.name }); + } catch (err) { + showSystemMessage(`Failed to attach ${file.name}: ${err.message}`); + } + } + + renderPendingAttachments(); + }); + // Event: send message _elements.sendBtn.addEventListener('click', () => sendMessage(client)); @@ -695,5 +813,6 @@ export const ChatPage = { _searchMode = false; _slashPopupIndex = -1; _elements = {}; + _pendingAttachments = []; }, }; diff --git a/src/gateway/ui/style.css b/src/gateway/ui/style.css index 948a4af..0180e66 100644 --- a/src/gateway/ui/style.css +++ b/src/gateway/ui/style.css @@ -950,6 +950,48 @@ tr:hover td { align-items: center; gap: 8px; padding: 8px 0; + flex-wrap: wrap; +} + +.chat-attachments { + display: inline-flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.attachment-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 10px; + border-radius: 999px; + background: var(--bg-secondary); + border: 1px solid var(--border); + color: var(--text-secondary); + font-size: var(--font-size-sm); +} + +.attachment-name { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attachment-remove { + appearance: none; + border: 0; + background: transparent; + color: var(--text-muted); + cursor: pointer; + font-size: 16px; + line-height: 1; + padding: 0; +} + +.attachment-remove:hover { + color: var(--text-primary); } .btn-action {