-
+ {/* Output - always show last few lines, expandable for full output */}
+
+
+
{displayOutput()}
-
300}>
+
{ e.stopPropagation(); setShowOutput(!showOutput()); }}
class="mt-1 text-[9px] text-purple-600 dark:text-purple-400 hover:underline"
>
- {showOutput() && (props.tool.output || '').length > 300 ? 'Show less' : 'Show all'}
+ {showOutput() ? 'Show less' : 'Show full output'}
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 */}
-
+
= (props) => {
{/* Session picker */}
-
+
{
setShowModelSelector(false);
@@ -590,10 +701,75 @@ export const AIChat: Component = (props) => {
+ {/* Session Actions Menu */}
+
+
{
+ if (!chat.sessionId()) {
+ notificationStore.info('Send a message first to start a session');
+ return;
+ }
+ setShowModelSelector(false);
+ setShowSessions(false);
+ setShowSessionActions(!showSessionActions());
+ }}
+ disabled={sessionActionLoading() !== null}
+ class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ title="Session actions (summarize, diff, revert)"
+ >
+
+
+
+ }>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Summarize context
+
+
+
+
+
+ View file changes
+
+
+
+
+
+ Revert changes
+
+
+
+
+
{/* Close button */}
{
+ e.stopPropagation();
+ props.onClose();
+ }}
class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
+ title="Close panel"
>
@@ -670,24 +846,22 @@ export const AIChat: Component = (props) => {
onKeyDown={handleKeyDown}
placeholder="Ask about your infrastructure..."
rows={2}
- disabled={chat.isLoading()}
- class="flex-1 px-4 py-3 text-sm rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none disabled:opacity-50 disabled:cursor-not-allowed"
+ class="flex-1 px-4 py-3 text-sm rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none"
/>
-
-
-
-
-
-
- }
+
+ {/* Send button - always visible, sends new message (aborts current if streaming) */}
+
+
+
+
+
+ {/* Stop button - only visible while streaming */}
+
{
autoFixModel: '', // Empty means use patrol model
baseUrl: '', // Legacy - kept for compatibility
clearApiKey: false,
- autonomousMode: false,
authMethod: 'api_key' as AuthMethod,
patrolIntervalMinutes: 360, // 6 hours default
alertTriggeredAnalysis: true,
@@ -155,7 +154,6 @@ export const AISettings: Component = () => {
autoFixModel: '',
baseUrl: '',
clearApiKey: false,
- autonomousMode: false,
authMethod: 'api_key',
patrolIntervalMinutes: 360, // 6 hours default
alertTriggeredAnalysis: true,
@@ -185,7 +183,6 @@ export const AISettings: Component = () => {
autoFixModel: data.auto_fix_model || '',
baseUrl: data.base_url || '',
clearApiKey: false,
- autonomousMode: data.autonomous_mode || false,
authMethod: data.auth_method || 'api_key',
patrolIntervalMinutes: data.patrol_interval_minutes ?? 360, // Use minutes, default to 6hr
alertTriggeredAnalysis: data.alert_triggered_analysis !== false, // default to true
@@ -341,11 +338,6 @@ export const AISettings: Component = () => {
payload.enabled = form.enabled;
}
- // Include autonomous mode if changed
- if (form.autonomousMode !== settings()?.autonomous_mode) {
- payload.autonomous_mode = form.autonomousMode;
- }
-
// Include patrol settings if changed
if (form.patrolIntervalMinutes !== (settings()?.patrol_interval_minutes ?? 360)) {
payload.patrol_interval_minutes = form.patrolIntervalMinutes;
@@ -1187,48 +1179,6 @@ export const AISettings: Component = () => {
- {/* Autonomous Mode */}
-
-
{/* AI Patrol & Efficiency Settings - Collapsible */}
{
💡 Increase for slow Ollama hardware (default: 300s / 5 min)
- {/* Infrastructure Control Settings */}
-
+ {/* AI Permission Level */}
+
-
-
+
-
Infrastructure Control
+
AI Permission Level
{
- {/* Control Level */}
+ {/* Permission Level */}
- Control Level
+ Permission
setForm('controlLevel', e.currentTarget.value as 'read_only' | 'suggest' | 'controlled' | 'autonomous')}
@@ -1497,17 +1446,35 @@ export const AISettings: Component = () => {
disabled={saving()}
>
Read Only - AI can only observe
- Suggest - AI suggests commands to copy/paste
- Controlled - AI executes with approval
+ Suggest - AI suggests commands for you to run
+ Controlled - AI executes with your approval
Autonomous - AI executes without approval (Pro)
- {form.controlLevel === 'read_only' && '🔒 AI can only query infrastructure, no control actions'}
- {form.controlLevel === 'suggest' && '💬 AI suggests commands like "pct stop 101" for you to run'}
- {form.controlLevel === 'controlled' && '✅ AI can start/stop VMs and containers with your approval'}
- {form.controlLevel === 'autonomous' && '⚠️ AI executes control actions without asking'}
+ {form.controlLevel === 'read_only' && '🔒 AI can only query and observe - no commands or control actions'}
+ {form.controlLevel === 'suggest' && '💬 AI suggests commands for you to copy/paste and run manually'}
+ {form.controlLevel === 'controlled' && '✅ AI can execute commands and control VMs/containers with your approval'}
+ {form.controlLevel === 'autonomous' && '⚠️ AI executes all commands and control actions without asking'}
+
+
+
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.
+
+
{/* Protected Guests - Only show if control is enabled */}
diff --git a/frontend-modern/src/stores/aiChat.ts b/frontend-modern/src/stores/aiChat.ts
index 6f588d1dc..cbdd53898 100644
--- a/frontend-modern/src/stores/aiChat.ts
+++ b/frontend-modern/src/stores/aiChat.ts
@@ -133,11 +133,14 @@ const loadSessionFromServer = async (_sessionId: string): Promise => {
};
export const aiChatStore = {
- // Check if chat is open
+ // Check if chat is open (non-reactive getter for simple checks)
get isOpen() {
return isAIChatOpen();
},
+ // Reactive accessor - use this in Show/createEffect for proper reactivity
+ isOpenSignal: isAIChatOpen,
+
// Get current context (legacy single-item)
get context() {
return aiChatContext();