From 70102db9859de52cb4b523311f524d49e2571d85 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 17 Jan 2026 14:44:34 +0000 Subject: [PATCH] fix(ui): AI chat panel collapse and session actions Chat panel fixes: - Fix collapse button not working (use store's isOpenSignal) - Add isOpenSignal accessor for proper SolidJS reactivity - Click-outside handler to close dropdowns Session management UI: - Session actions menu (summarize, diff, revert) - Better tool result handling with exit codes - Continuation messages after command execution - Session title generation from first message OpenCode API additions: - Session summarize/diff/revert endpoints - Better model format handling --- frontend-modern/src/App.tsx | 5 +- frontend-modern/src/api/opencode.ts | 72 +++++- .../src/components/AI/Chat/ChatMessages.tsx | 31 ++- .../src/components/AI/Chat/MessageItem.tsx | 22 +- .../components/AI/Chat/ToolExecutionBlock.tsx | 44 ++-- .../src/components/AI/Chat/hooks/useChat.ts | 101 +++++++- .../src/components/AI/Chat/index.tsx | 230 +++++++++++++++--- .../src/components/Settings/AISettings.tsx | 93 +++---- frontend-modern/src/stores/aiChat.ts | 5 +- 9 files changed, 468 insertions(+), 135 deletions(-) diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 5dcdadd55..1fda510e0 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -880,9 +880,8 @@ function App() { - {/* Fixed AI Assistant Button - always visible on the side when AI is enabled */} - - {/* This component only shows when chat is closed */} + {/* Fixed AI Assistant Button - only shows when chat is CLOSED */} + diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 4f15fa01f..b907a0add 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -120,6 +120,12 @@ export function useChat(options: UseChatOptions = {}) { case 'tool_start': { const data = event.data as { name: string; input: string }; + + // Skip tool_start for "question" - these are handled by the question event type + if (data.name === 'question' || data.name === 'Question') { + return msg; + } + const toolId = generateId(); // Unique ID to track this tool const pendingTool = { name: data.name, input: data.input }; @@ -141,8 +147,14 @@ export function useChat(options: UseChatOptions = {}) { const pendingTools = msg.pendingTools || []; const events = msg.streamEvents || []; - // Find the matching pending tool (by name, since we may not have ID in the event) - const matchingPendingIndex = pendingTools.findIndex((t) => t.name === data.name); + // Normalize tool name for matching - strip MCP server prefix (pulse_) which may be doubled + const normalizeToolName = (name: string) => name.replace(/^(pulse_)+/, ''); + const normalizedEndName = normalizeToolName(data.name); + + // Find the matching pending tool (by normalized name) + const matchingPendingIndex = pendingTools.findIndex( + (t) => normalizeToolName(t.name) === normalizedEndName + ); const updatedPending = matchingPendingIndex >= 0 ? [...pendingTools.slice(0, matchingPendingIndex), ...pendingTools.slice(matchingPendingIndex + 1)] : pendingTools; @@ -159,7 +171,7 @@ export function useChat(options: UseChatOptions = {}) { let updatedEvents = [...events]; for (let i = events.length - 1; i >= 0; i--) { const evt = events[i]; - if (evt.type === 'pending_tool' && evt.pendingTool?.name === data.name) { + if (evt.type === 'pending_tool' && normalizeToolName(evt.pendingTool?.name || '') === normalizedEndName) { // Replace pending with completed updatedEvents[i] = { type: 'tool', tool: newToolCall }; break; @@ -252,9 +264,24 @@ export function useChat(options: UseChatOptions = {}) { ); }; - // Send a message + // Send a message - allows sending mid-stream (aborts current response like OpenCode TUI) const sendMessage = async (prompt: string) => { - if (!prompt.trim() || isLoading()) return; + if (!prompt.trim()) return; + + // If already streaming, abort the current request first + if (isLoading() && abortControllerRef) { + logger.debug('[useChat] Aborting current stream to send new message'); + abortControllerRef.abort(); + abortControllerRef = null; + // Mark any streaming messages as stopped + setMessages((prev) => + prev.map((msg) => + msg.isStreaming + ? { ...msg, isStreaming: false, pendingTools: [] } + : msg + ) + ); + } // Ensure we have a session for conversation continuity // Without this, every message creates a new session and loses context @@ -436,9 +463,27 @@ export function useChat(options: UseChatOptions = {}) { updateQuestion(messageId, questionId, { isAnswering: true }); try { + // Send answer to OpenCode via API await OpenCodeAPI.answerQuestion(questionId, answers); - // Remove the question after successful answer + // Remove the question card after successful answer updateQuestion(messageId, questionId, { removed: true }); + + // After answering, OpenCode continues processing but the SSE stream has closed. + // We need to send a follow-up message to get the continuation. + const answerSummary = answers.map(a => a.value).join(', '); + logger.debug('[useChat] Question answered, sending continuation', { questionId, answerSummary }); + + // Wait for any previous stream to finish + if (isLoading()) { + logger.debug('[useChat] Waiting for stream to finish before sending answer'); + const idle = await waitForIdleInternal(10000); + if (!idle) { + logger.warn('[useChat] Timeout waiting for stream, sending anyway'); + } + } + + // Send the answer as a message to continue the conversation + await sendMessage(answerSummary || 'Continue'); } catch (error) { logger.error('[useChat] Failed to answer question:', error); notificationStore.error('Failed to answer question'); @@ -446,6 +491,49 @@ export function useChat(options: UseChatOptions = {}) { } }; + // Internal helper to wait for idle state + const waitForIdleInternal = (timeoutMs: number): Promise => { + return new Promise((resolve) => { + if (!isLoading()) { + resolve(true); + return; + } + const startTime = Date.now(); + const checkInterval = setInterval(() => { + if (!isLoading()) { + clearInterval(checkInterval); + resolve(true); + } else if (Date.now() - startTime > timeoutMs) { + clearInterval(checkInterval); + resolve(false); + } + }, 100); + }); + }; + + // Wait for the chat to become idle (not loading) + // Useful for sending follow-up messages after approvals + const waitForIdle = (timeoutMs = 30000): Promise => { + return new Promise((resolve) => { + if (!isLoading()) { + resolve(true); + return; + } + + const startTime = Date.now(); + const checkInterval = setInterval(() => { + if (!isLoading()) { + clearInterval(checkInterval); + resolve(true); + } else if (Date.now() - startTime > timeoutMs) { + clearInterval(checkInterval); + logger.warn('[useChat] waitForIdle timed out'); + resolve(false); + } + }, 100); + }); + }; + return { messages, isLoading, @@ -461,5 +549,6 @@ export function useChat(options: UseChatOptions = {}) { addToolResult, updateQuestion, answerQuestion, + waitForIdle, }; } diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 9d78697ff..3f1c62e84 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -1,7 +1,8 @@ -import { Component, Show, createSignal, onMount, For, createMemo, createEffect } from 'solid-js'; +import { Component, Show, createSignal, onMount, onCleanup, For, createMemo, createEffect } from 'solid-js'; import { AIAPI } from '@/api/ai'; import { OpenCodeAPI, type ChatSession } from '@/api/opencode'; import { notificationStore } from '@/stores/notifications'; +import { aiChatStore } from '@/stores/aiChat'; import { logger } from '@/utils/logger'; import { useChat } from './hooks/useChat'; import { ChatMessages } from './ChatMessages'; @@ -23,8 +24,8 @@ interface AIChatProps { * session management, and streaming response display. */ export const AIChat: Component = (props) => { - // UI state - const [isOpen] = createSignal(true); + // UI state - use store's isOpenSignal for reactivity + const isOpen = aiChatStore.isOpenSignal; const [input, setInput] = createSignal(''); const [sessions, setSessions] = createSignal([]); const [showSessions, setShowSessions] = createSignal(false); @@ -35,6 +36,8 @@ export const AIChat: Component = (props) => { const [modelQuery, setModelQuery] = createSignal(''); const [defaultModel, setDefaultModel] = createSignal(''); const [chatOverrideModel, setChatOverrideModel] = createSignal(''); + const [showSessionActions, setShowSessionActions] = createSignal(false); + const [sessionActionLoading, setSessionActionLoading] = createSignal(null); const loadModelSelections = (): Record => { try { @@ -240,6 +243,21 @@ export const AIChat: Component = (props) => { } }); + // Click outside handler to close all dropdowns + onMount(() => { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement; + // Only close if click is outside dropdown containers + if (!target.closest('[data-dropdown]')) { + setShowModelSelector(false); + setShowSessions(false); + setShowSessionActions(false); + } + }; + document.addEventListener('click', handleClickOutside); + onCleanup(() => document.removeEventListener('click', handleClickOutside)); + }); + // Handle submit const handleSubmit = () => { const prompt = input().trim(); @@ -248,7 +266,7 @@ export const AIChat: Component = (props) => { setInput(''); }; - // Handle key down + // Handle key down - allow sending even while loading (will abort and send) const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -302,17 +320,55 @@ export const AIChat: Component = (props) => { // Add tool result if command was executed if (result.approved) { - const execResult = (result as { result?: { stdout?: string; stderr?: string; exit_code?: number } }).result; - const output = execResult - ? `Exit code: ${execResult.exit_code}\n${execResult.stdout || ''}${execResult.stderr ? '\nStderr: ' + execResult.stderr : ''}` - : 'Command approved but no execution result available.'; + const typedResult = result as { + result?: { stdout?: string; stderr?: string; exit_code?: number }; + error?: string; + message?: string; + executed?: boolean; + }; + const execResult = typedResult.result; + + let output: string; + let success: boolean; + + if (execResult) { + output = `Exit code: ${execResult.exit_code}\n${execResult.stdout || ''}${execResult.stderr ? '\nStderr: ' + execResult.stderr : ''}`; + success = execResult.exit_code === 0; + } else if (typedResult.error) { + output = `Execution failed: ${typedResult.error}`; + success = false; + } else if (typedResult.message) { + output = typedResult.message; + success = false; + } else { + output = 'Command approved but no execution result available.'; + success = false; + } chat.addToolResult(messageId, { name: approval.toolName, input: approval.command, output: output.trim(), - success: execResult ? execResult.exit_code === 0 : false, + success, }); + + // Continue the conversation - short message, output is already in the tool result + const continuationMessage = success + ? 'Command executed. Please analyze the result and continue.' + : 'Command completed with issues. Please analyze and advise.'; + + // Wait for any ongoing stream to complete before sending continuation + if (chat.isLoading()) { + logger.debug('[AIChat] Waiting for chat to become idle before continuation'); + const isIdle = await chat.waitForIdle(30000); + if (!isIdle) { + logger.warn('[AIChat] Timeout waiting for chat to become idle'); + notificationStore.warning('Chat is busy. Sending continuation anyway...'); + } + } + + logger.debug('[AIChat] Sending continuation message', { sessionId: chat.sessionId() }); + await chat.sendMessage(continuationMessage); } } catch (error) { logger.error('[AIChat] Approval failed:', error); @@ -373,6 +429,61 @@ export const AIChat: Component = (props) => { } }; + // Session action handlers + const handleSummarize = async () => { + const sessionId = chat.sessionId(); + if (!sessionId) return; + + setSessionActionLoading('summarize'); + setShowSessionActions(false); + try { + await OpenCodeAPI.summarizeSession(sessionId); + notificationStore.success('Session summarized to save context'); + } catch (error) { + notificationStore.error('Failed to summarize session'); + } finally { + setSessionActionLoading(null); + } + }; + + const handleGetDiff = async () => { + const sessionId = chat.sessionId(); + if (!sessionId) return; + + setSessionActionLoading('diff'); + setShowSessionActions(false); + try { + const diff = await OpenCodeAPI.getSessionDiff(sessionId); + const files = diff.files || []; + if (files.length === 0) { + notificationStore.info('No file changes in this session'); + } else { + notificationStore.success(`${files.length} file(s) changed in this session`); + // Could open a modal here to show detailed diff + } + } catch (error) { + notificationStore.error('Failed to get session diff'); + } finally { + setSessionActionLoading(null); + } + }; + + const handleRevert = async () => { + const sessionId = chat.sessionId(); + if (!sessionId) return; + + setSessionActionLoading('revert'); + setShowSessionActions(false); + try { + await OpenCodeAPI.revertSession(sessionId); + notificationStore.success('Session changes reverted'); + } catch (error) { + notificationStore.error('Failed to revert session'); + } finally { + setSessionActionLoading(null); + } + }; + return (
= (props) => {
{/* Model selector */} -
+
{/* Session picker */} -
+
+ {/* Session Actions Menu */} +
+ + + +
+ + + +
+
+
+ {/* Close button */} - } +
+ {/* Send button - always visible, sends new message (aborts current if streaming) */} + + {/* Stop button - only visible while streaming */} +
- {/* Autonomous Mode */} -
-
-
- -

- {form.autonomousMode - ? 'AI will execute all commands without asking for approval. Only enable if you trust your configured model.' - : 'AI will ask for approval before running commands that modify your system. Read-only commands (like df, ps, docker stats) run automatically.'} -

-
- ⚠️ Legal Disclaimer: AI models can hallucinate. You are responsible for any damage caused by autonomous actions. See Terms of Service. -
- -

- - Upgrade to Pro - {' '} - to enable autonomous mode. -

-
-
- setForm('autonomousMode', event.currentTarget.checked)} - disabled={saving() || autoFixLocked()} - /> -
-
- {/* AI Patrol & Efficiency Settings - Collapsible */}