diff --git a/chat/public/builder.html b/chat/public/builder.html index 53c2bcd..2313648 100644 --- a/chat/public/builder.html +++ b/chat/public/builder.html @@ -300,6 +300,26 @@ box-shadow: 0 10px 25px rgba(0, 128, 96, 0.25); } + /* Cancel mode for send button */ + .primary.cancel-mode { + background: linear-gradient(135deg, #ef4444, #dc2626); + color: #fff; + } + + .primary.cancel-mode:hover { + box-shadow: 0 10px 25px rgba(239, 68, 68, 0.25); + } + + /* Cancelled message styling */ + .message.assistant.cancelled { + opacity: 0.7; + } + + .message.assistant.cancelled .body { + background: #fef2f2; + border: 1px solid #fecaca; + } + .back-home { color: var(--muted); text-decoration: none; @@ -1932,11 +1952,16 @@ } const miniBtn = el.miniSendBtn; - const originalLabel = miniBtn ? miniBtn.textContent : ''; - if (miniBtn) { - miniBtn.disabled = true; - miniBtn.innerHTML = ''; // Clock icon for planning + + // Set sending state for cancellation support + if (typeof state !== 'undefined') { + state.isSending = true; + state.currentSendingMessageId = tempMessageId; } + if (typeof updateSendButtonState === 'function') { + updateSendButtonState(); + } + try { const response = await api('/api/plan', { method: 'POST', body: JSON.stringify(payload) }); @@ -1986,14 +2011,13 @@ renderMessages(session); } } finally { - if (miniBtn) { - miniBtn.disabled = false; - // Restore original SVG icon - miniBtn.innerHTML = ` - - - `; + // Reset sending state + if (typeof state !== 'undefined') { + state.isSending = false; + state.currentSendingMessageId = null; + } + if (typeof updateSendButtonState === 'function') { + updateSendButtonState(); } } } @@ -2278,7 +2302,15 @@ }; // document.getElementById('send-btn') removed - document.getElementById('mini-send-btn').addEventListener('click', handleSend, true); + // Use handleSendButtonClick which handles both send and cancel modes + document.getElementById('mini-send-btn').addEventListener('click', (e) => { + if (typeof handleSendButtonClick === 'function') { + handleSendButtonClick(e); + } else { + // Fallback to old behavior if function not loaded yet + handleSend(e); + } + }, true); // Initialize UI updateBuildModeUI(); diff --git a/chat/public/builder.js b/chat/public/builder.js index beae917..a991072 100644 --- a/chat/public/builder.js +++ b/chat/public/builder.js @@ -384,6 +384,8 @@ const state = { usageSummary: null, todos: [], // Current todos from OpenCode currentMessageId: null, // Track which message we're displaying todos for + isSending: false, // Track if a message is currently being sent + currentSendingMessageId: null, // Track the ID of the message being sent for cancellation }; // Expose state for builder.html @@ -1824,7 +1826,7 @@ function renderMessages(session) { // Don't hide loading indicator during streaming - only hide when completely done // This allows the spinner to continue until the OpenCode session is fully complete const assistantCard = document.createElement('div'); - assistantCard.className = 'message assistant'; + assistantCard.className = 'message assistant' + (status === 'cancelled' ? ' cancelled' : ''); const assistantMeta = document.createElement('div'); assistantMeta.className = 'meta'; const msgCliLabel = (msg.cli || session.cli || 'opencode'); @@ -1841,10 +1843,10 @@ function renderMessages(session) { rawBtn.textContent = 'Plugin Compass'; assistantMeta.appendChild(rawBtn); - // Add Undo/Redo buttons - only show for latest message and for opencode messages when done + // Add Undo/Redo buttons - show for latest message when done, errored, or cancelled const isOpencodeMsg = msg.cli === 'opencode' || msg.phase === 'build'; const isLatestMessage = latestMessage && latestMessage.id === msg.id; - const shouldShowUndoRedo = isOpencodeMsg && isLatestMessage && (status === 'done' || status === 'error'); + const shouldShowUndoRedo = isOpencodeMsg && isLatestMessage && (status === 'done' || status === 'error' || status === 'cancelled'); if (shouldShowUndoRedo) { const undoBtn = document.createElement('button'); @@ -3721,7 +3723,9 @@ async function sendMessage() { } } - if (el.miniSendBtn) el.miniSendBtn.disabled = true; + // Set sending state and update button + state.isSending = true; + updateSendButtonState(); // Ensure we have a valid current session before proceeding if (!state.currentSessionId) { @@ -3801,6 +3805,12 @@ async function sendMessage() { body: JSON.stringify(payload), }); + // Track the message ID being sent for potential cancellation + if (response?.message?.id) { + state.currentSendingMessageId = response.message.id; + console.log('[BUILDER] Tracking message for cancellation:', response.message.id); + } + // Clear attachments after successful send pendingAttachments.length = 0; renderAttachmentPreview(); @@ -3862,12 +3872,143 @@ async function sendMessage() { renderMessages(currentSession); } } finally { - if (el.miniSendBtn) el.miniSendBtn.disabled = false; + // Reset sending state when message completes, is cancelled, or errors out + // Only reset if we're not currently in a cancellation (cancelMessage handles that) + if (state.isSending && state.currentSendingMessageId) { + const session = state.sessions.find(s => s.id === state.currentSessionId); + const messageId = state.currentSendingMessageId; + const message = session?.messages.find(m => m.id === messageId); + + // Reset if message is done, errored, or cancelled + if (!message || message.status === 'done' || message.status === 'error' || message.status === 'cancelled') { + state.isSending = false; + state.currentSendingMessageId = null; + updateSendButtonState(); + } + } + } +} + +// Cancel the current message being sent +async function cancelMessage() { + if (!state.isSending || !state.currentSendingMessageId) { + console.log('[CANCEL] No message to cancel'); + return; + } + + const messageId = state.currentSendingMessageId; + const sessionId = state.currentSessionId; + + console.log('[CANCEL] Cancelling message:', messageId); + setStatus('Cancelling...'); + + // Close any active SSE streams for this message + if (state.activeStreams.has(messageId)) { + const stream = state.activeStreams.get(messageId); + stream.close(); + state.activeStreams.delete(messageId); + console.log('[CANCEL] Closed SSE stream for message:', messageId); + } + + // Find and update the message status + const session = state.sessions.find(s => s.id === sessionId); + if (session) { + const message = session.messages.find(m => m.id === messageId); + if (message) { + message.status = 'cancelled'; + message.reply = message.reply || '(Cancelled by user)'; + message.cancelled = true; + + // Show undo/redo buttons for the cancelled message + message.showUndoRedo = true; + + console.log('[CANCEL] Message marked as cancelled:', messageId); + } + + // Remove temp messages + const tempMessages = session.messages.filter(m => m.id.startsWith('temp-') && m.status === 'queued'); + tempMessages.forEach(tempMsg => { + const idx = session.messages.indexOf(tempMsg); + if (idx > -1) { + session.messages.splice(idx, 1); + } + }); + + renderMessages(session); + } + + // Reset sending state + state.isSending = false; + state.currentSendingMessageId = null; + + // Reset send button + updateSendButtonState(); + + hideLoadingIndicator(); + setStatus('Message cancelled'); + + // Refresh session to sync with server + try { + await refreshCurrentSession(); + } catch (err) { + console.warn('[CANCEL] Failed to refresh session:', err.message); + } +} + +// Update send button appearance based on state +function updateSendButtonState() { + const btn = el.miniSendBtn; + if (!btn) return; + + if (state.isSending) { + // Show cancel button (square icon) + btn.innerHTML = ` + + + + `; + btn.title = 'Cancel message'; + btn.classList.add('cancel-mode'); + btn.disabled = false; + } else { + // Show send button (paper plane icon) + btn.innerHTML = ` + + + + + `; + btn.title = 'Send message'; + btn.classList.remove('cancel-mode'); + btn.disabled = false; + } +} + +// Handle send button click - either send or cancel +function handleSendButtonClick(e) { + if (state.isSending) { + // Cancel the current message + cancelMessage(); + } else { + // Check if we're in plan mode and call the appropriate handler + if (builderState.mode === 'plan') { + // Use handleSend from builder.html which handles plan mode + if (typeof handleSend === 'function') { + handleSend(e); + } else { + console.warn('[BUILDER] handleSend not available'); + } + } else { + // Send a new message in build mode + sendMessage(); + } } } // Expose for builder.html window.sendMessage = sendMessage; +window.cancelMessage = cancelMessage; +window.handleSendButtonClick = handleSendButtonClick; function hookEvents() { if (el.newChat) { @@ -4205,7 +4346,11 @@ function hookEvents() { el.messageInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { - sendMessage(); + if (state.isSending) { + cancelMessage(); + } else { + sendMessage(); + } } }); diff --git a/opencode/packages/opencode/src/provider/provider.ts b/opencode/packages/opencode/src/provider/provider.ts index 6fae0c0..a4ce132 100644 --- a/opencode/packages/opencode/src/provider/provider.ts +++ b/opencode/packages/opencode/src/provider/provider.ts @@ -1200,7 +1200,11 @@ export namespace Provider { throw new ModelNotFoundError({ providerID, modelID, suggestions }) } - const info = provider.models[modelID] + let info = provider.models[modelID] + // Try with provider prefix if not found (e.g., "openrouter/pony-alpha") + if (!info && !modelID.includes("/")) { + info = provider.models[`${providerID}/${modelID}`] + } if (!info) { const availableModels = Object.keys(provider.models) const matches = fuzzysort.go(modelID, availableModels, { limit: 3, threshold: -10000 }) diff --git a/opencode/packages/opencode/test/tool/fixtures/models-api.json b/opencode/packages/opencode/test/tool/fixtures/models-api.json index 32722e8..1983692 100644 --- a/opencode/packages/opencode/test/tool/fixtures/models-api.json +++ b/opencode/packages/opencode/test/tool/fixtures/models-api.json @@ -27310,6 +27310,23 @@ "cost": { "input": 0, "output": 0 }, "limit": { "context": 1840000, "output": 0 } }, + "openrouter/pony-alpha": { + "id": "openrouter/pony-alpha", + "name": "Stealth", + "family": "pony", + "attachment": false, + "reasoning": true, + "tool_call": true, + "structured_output": true, + "temperature": true, + "release_date": "2026-02-06", + "last_updated": "2026-02-06", + "modalities": { "input": ["text"], "output": ["text"] }, + "open_weights": false, + "cost": { "input": 0, "output": 0 }, + "limit": { "context": 200000, "output": 131000 }, + "status": "alpha" + }, "z-ai/glm-4.7": { "id": "z-ai/glm-4.7", "name": "GLM-4.7",