From 898a90b86499cd8f8e0a82b2f4068875579b3b2c Mon Sep 17 00:00:00 2001 From: hugo Date: Wed, 11 Feb 2026 19:35:33 +0100 Subject: [PATCH] fix: better session handling --- src/main/engine/OpenCodeManager.ts | 117 ++++++++++-------- .../components/ChatPanel/ChatPanel.css | 2 + .../components/ChatPanel/ChatPanel.tsx | 46 ++++++- src/renderer/types/electron.d.ts | 2 +- 4 files changed, 110 insertions(+), 57 deletions(-) diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index 494028b..2e0ec6a 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -247,27 +247,27 @@ export class OpenCodeManager { const abortController = new AbortController(); this.abortControllers.set(conversationId, abortController); + const modelId = conversation.model || 'claude-sonnet-4'; + const provider = this.detectProvider(modelId); + + // Get system prompt + const systemMessage = conversation.messages.find(m => m.role === 'system'); + const systemPrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt(); + + // Build message history from DB (excluding system messages) + const dbMessages = conversation.messages.filter(m => m.role !== 'system'); + // Add the new user message + dbMessages.push({ + conversationId, + role: 'user', + content: userMessage, + createdAt: new Date(), + }); + + let fullResponse = ''; + const toolCallsCollected: Array<{ name: string; args: unknown }> = []; + try { - const modelId = conversation.model || 'claude-sonnet-4'; - const provider = this.detectProvider(modelId); - - // Get system prompt - const systemMessage = conversation.messages.find(m => m.role === 'system'); - const systemPrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt(); - - // Build message history from DB (excluding system messages) - const dbMessages = conversation.messages.filter(m => m.role !== 'system'); - // Add the new user message - dbMessages.push({ - conversationId, - role: 'user', - content: userMessage, - createdAt: new Date(), - }); - - let fullResponse = ''; - const toolCallsCollected: Array<{ name: string; args: unknown }> = []; - if (provider === 'anthropic') { const result = await this.sendAnthropicMessage( modelId, @@ -288,34 +288,40 @@ export class OpenCodeManager { ); fullResponse = result.content; } - - // Save assistant response - if (fullResponse) { - await this.chatEngine.addMessage({ - conversationId, - role: 'assistant', - content: fullResponse, - toolCalls: toolCallsCollected.length > 0 ? JSON.stringify(toolCallsCollected) : undefined, - createdAt: new Date(), - }); + } catch (error) { + const isAborted = abortController.signal.aborted || (error as Error).message === 'Request cancelled'; + if (!isAborted) { + throw error; } - - // Generate title after first exchange - const userMsgCount = conversation.messages.filter(m => m.role === 'user').length; - if (userMsgCount === 0 && fullResponse) { - this.generateConversationTitle(conversationId, userMessage, fullResponse).catch(err => - console.error('[OpenCodeManager] Error generating title:', err) - ); - } - - return { - success: true, - message: fullResponse, - toolCalls: toolCallsCollected.length > 0 ? toolCallsCollected : undefined, - }; + // On abort, keep whatever was streamed so far (already in fullResponse or empty) } finally { this.abortControllers.delete(conversationId); } + + // Save assistant response (including partial content from aborted requests) + if (fullResponse) { + await this.chatEngine.addMessage({ + conversationId, + role: 'assistant', + content: fullResponse, + toolCalls: toolCallsCollected.length > 0 ? JSON.stringify(toolCallsCollected) : undefined, + createdAt: new Date(), + }); + } + + // Generate title after first exchange + const userMsgCount = conversation.messages.filter(m => m.role === 'user').length; + if (userMsgCount === 0 && fullResponse) { + this.generateConversationTitle(conversationId, userMessage, fullResponse).catch(err => + console.error('[OpenCodeManager] Error generating title:', err) + ); + } + + return { + success: true, + message: fullResponse, + toolCalls: toolCallsCollected.length > 0 ? toolCallsCollected : undefined, + }; } catch (error) { console.error('[OpenCodeManager] Error sending message:', error); return { success: false, error: (error as Error).message }; @@ -338,6 +344,7 @@ export class OpenCodeManager { ): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> { const tools = this.getToolDefinitions(); const allToolCalls: Array<{ name: string; args: unknown }> = []; + let accumulatedText = ''; // Convert DB messages to Anthropic format let messages = this.buildAnthropicMessages(dbMessages); @@ -376,6 +383,8 @@ export class OpenCodeManager { const data = JSON.parse(response.body); + console.log('[OpenCodeManager] Round', round, 'stop_reason:', data.stop_reason, 'content blocks:', JSON.stringify(data.content?.map((b: AnthropicContentBlock) => ({ type: b.type, textLen: b.text?.length, name: b.name })))); + if (!data.content) { throw new Error('API response missing content field'); } @@ -388,17 +397,22 @@ export class OpenCodeManager { (b: AnthropicContentBlock) => b.type === 'text' ); - // Stream text content to frontend + // Accumulate and stream text content to frontend for (const block of textBlocks) { - if (block.text && callbacks.onDelta) { - callbacks.onDelta(block.text); + if (block.text) { + accumulatedText += block.text; + if (callbacks.onDelta) { + callbacks.onDelta(block.text); + } } } + console.log('[OpenCodeManager] Round', round, 'accumulatedText length:', accumulatedText.length, 'toolUseBlocks:', toolUseBlocks.length); + if (toolUseBlocks.length === 0 || data.stop_reason !== 'tool_use') { - // No more tool calls - extract final text and return - const finalText = textBlocks.map((b: AnthropicContentBlock) => b.text || '').join(''); - return { content: finalText, toolCalls: allToolCalls }; + // No more tool calls - return all accumulated text + console.log('[OpenCodeManager] Returning accumulated text length:', accumulatedText.length); + return { content: accumulatedText, toolCalls: allToolCalls }; } // Execute tool calls @@ -438,7 +452,8 @@ export class OpenCodeManager { } // If we hit max rounds, return whatever we have - return { content: 'I reached the maximum number of tool calls. Please try again.', toolCalls: allToolCalls }; + const fallbackText = accumulatedText || 'I reached the maximum number of tool calls. Please try again.'; + return { content: fallbackText, toolCalls: allToolCalls }; } /** diff --git a/src/renderer/components/ChatPanel/ChatPanel.css b/src/renderer/components/ChatPanel/ChatPanel.css index 0e9a8fc..50e68ee 100644 --- a/src/renderer/components/ChatPanel/ChatPanel.css +++ b/src/renderer/components/ChatPanel/ChatPanel.css @@ -207,6 +207,8 @@ padding: 10px 14px; border-radius: 12px; background-color: var(--vscode-input-background); + user-select: text; + cursor: text; } .chat-message.user .chat-message-text { diff --git a/src/renderer/components/ChatPanel/ChatPanel.tsx b/src/renderer/components/ChatPanel/ChatPanel.tsx index 0f90b4d..12b8fde 100644 --- a/src/renderer/components/ChatPanel/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel/ChatPanel.tsx @@ -132,14 +132,33 @@ export const ChatPanel: React.FC = ({ conversationId }) => { try { // Send message and wait for complete response - await window.electronAPI?.chat.sendMessage(conversationId, message); + const result = await window.electronAPI?.chat.sendMessage(conversationId, message); - // Reload messages to get the saved assistant response - const msgs = await window.electronAPI?.chat.getHistory(conversationId); - if (msgs) setMessages(msgs); + // Use the streamed content we accumulated via onStreamDelta + const assistantContent = streamingRef.current; + + if (assistantContent) { + const assistantMessage: ChatMessage = { + id: `assistant-${Date.now()}`, + conversationId, + role: 'assistant', + content: assistantContent, + createdAt: new Date().toISOString() + }; + setMessages(prev => [...prev, assistantMessage]); + } else if (result && !result.success) { + // Backend returned an error (API failure, model unavailable, etc.) + const errorMessage: ChatMessage = { + id: `error-${Date.now()}`, + conversationId, + role: 'assistant', + content: `Error: ${result.error || 'Failed to get a response. Please try again.'}`, + createdAt: new Date().toISOString() + }; + setMessages(prev => [...prev, errorMessage]); + } } catch (error) { console.error('Failed to send message:', error); - // Add error message const errorMessage: ChatMessage = { id: `error-${Date.now()}`, conversationId, @@ -167,6 +186,23 @@ export const ChatPanel: React.FC = ({ conversationId }) => { await window.electronAPI?.chat.abortMessage(conversationId); } catch (error) { console.error('Failed to abort:', error); + } finally { + // Keep any streamed content as a visible message + const partialContent = streamingRef.current; + setIsStreaming(false); + setStreamingContent(''); + streamingRef.current = ''; + + if (partialContent) { + const partialMessage: ChatMessage = { + id: `partial-${Date.now()}`, + conversationId, + role: 'assistant', + content: partialContent + '\n\n*(cancelled)*', + createdAt: new Date().toISOString() + }; + setMessages(prev => [...prev, partialMessage]); + } } }; diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 1f276a8..edb8714 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -354,7 +354,7 @@ export interface ElectronAPI { deleteConversation: (id: string) => Promise; // Messaging - sendMessage: (conversationId: string, message: string) => Promise; + sendMessage: (conversationId: string, message: string) => Promise<{ success: boolean; message?: string; error?: string }>; abortMessage: (conversationId: string) => Promise; getHistory: (conversationId: string) => Promise; clearMessages: (conversationId: string) => Promise;