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 {