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
This commit is contained in:
rcourtman
2026-01-17 14:44:34 +00:00
parent 4cea85ec97
commit 70102db985
9 changed files with 468 additions and 135 deletions
+2 -3
View File
@@ -880,9 +880,8 @@ function App() {
</div>
<ToastContainer />
<TokenRevealDialog />
{/* Fixed AI Assistant Button - always visible on the side when AI is enabled */}
<Show when={aiChatStore.enabled === true && !aiChatStore.isOpen}>
{/* This component only shows when chat is closed */}
{/* Fixed AI Assistant Button - only shows when chat is CLOSED */}
<Show when={aiChatStore.enabled === true && !aiChatStore.isOpenSignal()}>
<button
type="button"
onClick={() => aiChatStore.toggle()}
+71 -1
View File
@@ -37,6 +37,34 @@ export interface AIStatus {
engine: string;
}
// OpenCode Agent (build, code, etc.) with specific permissions and model
export interface Agent {
name: string;
description?: string;
mode: 'subagent' | 'primary' | 'all';
native?: boolean;
hidden?: boolean;
color?: string;
model?: {
providerID: string;
modelID: string;
};
}
// File change from a session
export interface FileChange {
path: string;
status: 'added' | 'modified' | 'deleted';
added: number;
removed: number;
}
// Session diff showing all file changes
export interface SessionDiff {
files: FileChange[];
summary?: string;
}
export class OpenCodeAPI {
private static baseUrl = '/api/ai';
@@ -99,6 +127,48 @@ export class OpenCodeAPI {
});
}
// ============================================
// OpenCode Extended Features
// ============================================
// List available agents (build, code, etc.)
static async listAgents(): Promise<Agent[]> {
return apiFetchJSON(`${this.baseUrl}/agents`) as Promise<Agent[]>;
}
// Summarize a session (compress context when nearing limits)
static async summarizeSession(sessionId: string): Promise<{ success: boolean; message?: string }> {
return apiFetchJSON(`${this.baseUrl}/sessions/${sessionId}/summarize`, {
method: 'POST',
}) as Promise<{ success: boolean; message?: string }>;
}
// Get file changes/diff for a session
static async getSessionDiff(sessionId: string): Promise<SessionDiff> {
return apiFetchJSON(`${this.baseUrl}/sessions/${sessionId}/diff`) as Promise<SessionDiff>;
}
// Fork a session (create a branch point)
static async forkSession(sessionId: string): Promise<ChatSession> {
return apiFetchJSON(`${this.baseUrl}/sessions/${sessionId}/fork`, {
method: 'POST',
}) as Promise<ChatSession>;
}
// Revert session changes
static async revertSession(sessionId: string): Promise<{ success: boolean }> {
return apiFetchJSON(`${this.baseUrl}/sessions/${sessionId}/revert`, {
method: 'POST',
}) as Promise<{ success: boolean }>;
}
// Unrevert session changes (redo)
static async unrevertSession(sessionId: string): Promise<{ success: boolean }> {
return apiFetchJSON(`${this.baseUrl}/sessions/${sessionId}/unrevert`, {
method: 'POST',
}) as Promise<{ success: boolean }>;
}
// Stream chat - the main chat interface
static async chat(
prompt: string,
@@ -139,7 +209,7 @@ export class OpenCodeAPI {
const STREAM_TIMEOUT_MS = 300000; // 5 minutes
try {
for (;;) {
for (; ;) {
if (Date.now() - lastEventTime > STREAM_TIMEOUT_MS) {
logger.warn('[OpenCode] Stream timeout');
break;
@@ -1,4 +1,4 @@
import { Component, Show, For, createEffect } from 'solid-js';
import { Component, Show, For, createEffect, createMemo } from 'solid-js';
import { MessageItem } from './MessageItem';
import type { ChatMessage, PendingApproval, PendingQuestion } from './types';
@@ -18,7 +18,7 @@ interface ChatMessagesProps {
/**
* ChatMessages - Renders the scrollable message list.
*
*
* Features:
* - Auto-scroll to bottom on new messages
* - Empty state with suggestions
@@ -28,15 +28,34 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
let messagesEndRef: HTMLDivElement | undefined;
let containerRef: HTMLDivElement | undefined;
// Auto-scroll to bottom on new messages
// Track content changes for auto-scroll (not just array length)
// This tracks: message count, last message content length, streaming state, and stream events
const scrollTrigger = createMemo(() => {
const msgs = props.messages;
if (msgs.length === 0) return 0;
const lastMsg = msgs[msgs.length - 1];
// Combine multiple signals to ensure we detect all content updates
return msgs.length +
(lastMsg.content?.length || 0) +
(lastMsg.isStreaming ? 1000000 : 0) +
(lastMsg.streamEvents?.length || 0);
});
// Auto-scroll to bottom on new messages or streaming content
createEffect(() => {
// Access the trigger to establish dependency (void suppresses unused var warning)
void scrollTrigger();
if (props.messages.length > 0 && messagesEndRef && containerRef) {
// Only auto-scroll if user is near the bottom
// Only auto-scroll if user is near the bottom (within 200px)
const { scrollTop, scrollHeight, clientHeight } = containerRef;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 150;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 200;
if (isNearBottom) {
messagesEndRef.scrollIntoView({ behavior: 'smooth' });
// Use instant scroll during active streaming for smoother experience
const lastMsg = props.messages[props.messages.length - 1];
const behavior = lastMsg.isStreaming ? 'instant' : 'smooth';
messagesEndRef.scrollIntoView({ behavior: behavior as ScrollBehavior });
}
}
});
@@ -1,7 +1,7 @@
import { Component, Show, For, Switch, Match, createMemo } from 'solid-js';
import { renderMarkdown } from '../aiChatUtils';
import { ThinkingBlock } from './ThinkingBlock';
import { ToolExecutionBlock, PendingToolBlock } from './ToolExecutionBlock';
import { ToolExecutionBlock } from './ToolExecutionBlock';
import { ApprovalCard } from './ApprovalCard';
import { QuestionCard } from './QuestionCard';
import type { ChatMessage, PendingApproval, PendingQuestion, StreamDisplayEvent } from './types';
@@ -127,9 +127,9 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
/>
</Match>
{/* Pending tool (currently running) - shown in chronological position */}
{/* Pending tool - hidden, we only show completed tools */}
<Match when={evt.type === 'pending_tool' && evt.pendingTool}>
<PendingToolBlock tool={evt.pendingTool!} />
<></>
</Match>
{/* Completed tool execution block */}
@@ -140,12 +140,12 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
{/* Content/text block */}
<Match when={evt.type === 'content' && evt.content}>
<div
class="text-sm prose prose-slate prose-sm dark:prose-invert max-w-none
class="text-sm prose prose-slate prose-sm dark:prose-invert max-w-none overflow-x-auto
prose-p:leading-relaxed prose-p:my-2
prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-lg prose-pre:text-xs
prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-lg prose-pre:text-xs prose-pre:overflow-x-auto prose-pre:max-w-full
prose-code:text-purple-600 dark:prose-code:text-purple-400
prose-code:bg-purple-50 dark:prose-code:bg-purple-900/30
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:break-all
prose-code:before:content-none prose-code:after:content-none
prose-headings:text-slate-900 dark:prose-headings:text-slate-100
prose-strong:text-slate-900 dark:prose-strong:text-slate-100
@@ -183,12 +183,12 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
{/* Fallback: show content if no stream events */}
<Show when={props.message.content && !hasStreamEvents()}>
<div
class="text-sm prose prose-slate prose-sm dark:prose-invert max-w-none
class="text-sm prose prose-slate prose-sm dark:prose-invert max-w-none overflow-x-auto
prose-p:leading-relaxed prose-p:my-2
prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-lg prose-pre:text-xs
prose-code:text-purple-600 dark:prose-code:text-purple-400
prose-code:bg-purple-50 dark:prose-code:bg-purple-900/30
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-lg prose-pre:text-xs prose-pre:overflow-x-auto prose-pre:max-w-full
prose-code:text-purple-600 dark:prose-code:text-purple-400
prose-code:bg-purple-50 dark:prose-code:bg-purple-900/30
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:break-all
prose-code:before:content-none prose-code:after:content-none
prose-headings:text-slate-900 dark:prose-headings:text-slate-100
prose-strong:text-slate-900 dark:prose-strong:text-slate-100
@@ -9,7 +9,7 @@ interface ToolExecutionBlockProps {
* ToolExecutionBlock - Displays completed tool executions in a compact terminal-like style.
*/
export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) => {
const [showOutput, setShowOutput] = createSignal(true);
const [showOutput, setShowOutput] = createSignal(false); // Collapsed by default like Claude Code
// Get display name for tool
const toolLabel = createMemo(() => {
@@ -34,14 +34,26 @@ export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) =>
return output.trim().length > 0 && !output.includes('not available');
});
// Truncate output
// Show only last few lines by default, full output when expanded
const displayOutput = createMemo(() => {
const output = props.tool.output || '';
const maxLen = 300;
if (!showOutput() && output.length > maxLen) {
return output.substring(0, maxLen) + '...';
if (showOutput()) {
// Show full output when expanded
return output;
}
return output;
// Show last 3 lines by default
const lines = output.split('\n').filter(line => line.trim());
if (lines.length <= 3) {
return output.trim();
}
const lastLines = lines.slice(-3).join('\n');
return '...\n' + lastLines;
});
const hasMoreOutput = createMemo(() => {
const output = props.tool.output || '';
const lines = output.split('\n').filter(line => line.trim());
return lines.length > 3;
});
const statusIcon = () => props.tool.success ? '✓' : '✗';
@@ -53,9 +65,9 @@ export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) =>
<div class="my-1 font-mono text-[11px]">
{/* Compact single-line header */}
<div
class={`flex items-center gap-1.5 px-2 py-1 rounded ${hasOutput() ? 'cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800' : ''
class={`flex items-center gap-1.5 px-2 py-1 rounded ${hasMoreOutput() ? 'cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800' : ''
} ${showOutput() ? 'bg-slate-50 dark:bg-slate-800/50' : ''}`}
onClick={() => hasOutput() && setShowOutput(!showOutput())}
onClick={() => hasMoreOutput() && setShowOutput(!showOutput())}
>
{/* Status icon */}
<span class={`${statusColor()} font-bold`}>{statusIcon()}</span>
@@ -70,8 +82,8 @@ export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) =>
{props.tool.input.length > 60 ? props.tool.input.substring(0, 60) + '...' : props.tool.input}
</code>
{/* Expand indicator if has output */}
<Show when={hasOutput()}>
{/* Expand indicator if has more output */}
<Show when={hasMoreOutput()}>
<svg
class={`w-3 h-3 text-slate-400 transition-transform ${showOutput() ? 'rotate-180' : ''}`}
fill="none"
@@ -83,18 +95,18 @@ export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) =>
</Show>
</div>
{/* Expanded output */}
<Show when={showOutput() && hasOutput()}>
<div class="ml-4 mt-1 mb-2 pl-2 border-l-2 border-slate-200 dark:border-slate-700">
<pre class="text-[10px] text-slate-600 dark:text-slate-400 whitespace-pre-wrap break-words leading-relaxed max-h-40 overflow-y-auto">
{/* Output - always show last few lines, expandable for full output */}
<Show when={hasOutput()}>
<div class="ml-4 mt-1 mb-2 pl-2 border-l-2 border-slate-200 dark:border-slate-700 overflow-hidden">
<pre class={`text-[10px] text-slate-600 dark:text-slate-400 whitespace-pre-wrap break-all leading-relaxed overflow-y-auto overflow-x-hidden bg-slate-50 dark:bg-slate-900/50 rounded p-2 ${showOutput() ? 'max-h-64' : 'max-h-20'}`}>
{displayOutput()}
</pre>
<Show when={(props.tool.output || '').length > 300}>
<Show when={hasMoreOutput()}>
<button
onClick={(e) => { 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'}
</button>
</Show>
</div>
@@ -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<boolean> => {
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<boolean> => {
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,
};
}
+202 -28
View File
@@ -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<AIChatProps> = (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<ChatSession[]>([]);
const [showSessions, setShowSessions] = createSignal(false);
@@ -35,6 +36,8 @@ export const AIChat: Component<AIChatProps> = (props) => {
const [modelQuery, setModelQuery] = createSignal('');
const [defaultModel, setDefaultModel] = createSignal('');
const [chatOverrideModel, setChatOverrideModel] = createSignal('');
const [showSessionActions, setShowSessionActions] = createSignal(false);
const [sessionActionLoading, setSessionActionLoading] = createSignal<string | null>(null);
const loadModelSelections = (): Record<string, string> => {
try {
@@ -240,6 +243,21 @@ export const AIChat: Component<AIChatProps> = (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<AIChatProps> = (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<AIChatProps> = (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<AIChatProps> = (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 (
<div
class={`flex-shrink-0 h-full bg-white dark:bg-slate-900 border-l border-slate-200 dark:border-slate-700 flex flex-col transition-all duration-300 overflow-hidden ${isOpen() ? 'w-[480px]' : 'w-0 border-l-0'
@@ -397,7 +508,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
<div class="flex items-center gap-1.5">
{/* Model selector */}
<div class="relative">
<div class="relative" data-dropdown>
<button
onClick={toggleModelSelector}
class="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] text-slate-600 dark:text-slate-300 hover:text-slate-800 dark:hover:text-slate-100 rounded-lg border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 bg-white dark:bg-slate-800 transition-colors"
@@ -526,7 +637,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
</div>
{/* Session picker */}
<div class="relative">
<div class="relative" data-dropdown>
<button
onClick={() => {
setShowModelSelector(false);
@@ -590,10 +701,75 @@ export const AIChat: Component<AIChatProps> = (props) => {
</Show>
</div>
{/* Session Actions Menu */}
<div class="relative" data-dropdown>
<button
onClick={() => {
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)"
>
<Show when={sessionActionLoading()} fallback={
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
}>
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</Show>
</button>
<Show when={showSessionActions()}>
<div class="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 z-50 overflow-hidden">
<button
onClick={handleSummarize}
class="w-full px-3 py-2 text-left text-sm flex items-center gap-2 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<svg class="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7" />
</svg>
Summarize context
</button>
<button
onClick={handleGetDiff}
class="w-full px-3 py-2 text-left text-sm flex items-center gap-2 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
View file changes
</button>
<button
onClick={handleRevert}
class="w-full px-3 py-2 text-left text-sm flex items-center gap-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 border-t border-slate-200 dark:border-slate-700"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
Revert changes
</button>
</div>
</Show>
</div>
{/* Close button */}
<button
onClick={props.onClose}
onClick={(e) => {
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"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M6 5l7 7-7 7" />
@@ -670,24 +846,22 @@ export const AIChat: Component<AIChatProps> = (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"
/>
<div class="flex flex-col gap-1.5 self-end">
<Show
when={chat.isLoading()}
fallback={
<button
type="submit"
disabled={!input().trim()}
class="px-4 py-3 bg-gradient-to-r from-purple-600 to-violet-600 hover:from-purple-700 hover:to-violet-700 text-white rounded-xl disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-purple-500/20 hover:shadow-xl hover:shadow-purple-500/30"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
}
<div class="flex gap-1.5 self-end">
{/* Send button - always visible, sends new message (aborts current if streaming) */}
<button
type="submit"
disabled={!input().trim()}
class="px-4 py-3 bg-gradient-to-r from-purple-600 to-violet-600 hover:from-purple-700 hover:to-violet-700 text-white rounded-xl disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-purple-500/20 hover:shadow-xl hover:shadow-purple-500/30"
title={chat.isLoading() ? "Send (will interrupt current response)" : "Send"}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
{/* Stop button - only visible while streaming */}
<Show when={chat.isLoading()}>
<button
type="button"
onClick={chat.stop}
@@ -122,7 +122,6 @@ export const AISettings: Component = () => {
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 = () => {
</div>
</div>
{/* Autonomous Mode */}
<div class={`${formField} p-4 rounded-lg border ${form.autonomousMode ? 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800' : 'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'}`}>
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<label class={`${labelClass()} flex items-center gap-2`}>
Autonomous Mode
<Show when={form.autonomousMode}>
<span class="px-1.5 py-0.5 text-[10px] font-semibold bg-amber-200 dark:bg-amber-800 text-amber-800 dark:text-amber-200 rounded">
ENABLED
</span>
</Show>
</label>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{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.'}
</p>
<div class="mt-2 p-2 bg-amber-100/50 dark:bg-amber-900/30 rounded border border-amber-200 dark:border-amber-800 text-[10px] text-amber-800 dark:text-amber-200">
<strong> Legal Disclaimer:</strong> AI models can hallucinate. You are responsible for any damage caused by autonomous actions. See <a href="https://github.com/rcourtman/Pulse/blob/main/TERMS.md" target="_blank" class="underline">Terms of Service</a>.
</div>
<Show when={autoFixLocked()}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
<a
class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline"
href="https://pulserelay.pro/"
target="_blank"
rel="noreferrer"
>
Upgrade to Pro
</a>{' '}
to enable autonomous mode.
</p>
</Show>
</div>
<Toggle
checked={form.autonomousMode}
onChange={(event) => setForm('autonomousMode', event.currentTarget.checked)}
disabled={saving() || autoFixLocked()}
/>
</div>
</div>
{/* AI Patrol & Efficiency Settings - Collapsible */}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
@@ -1466,14 +1416,13 @@ export const AISettings: Component = () => {
💡 Increase for slow Ollama hardware (default: 300s / 5 min)
</p>
{/* Infrastructure Control Settings */}
<div class="space-y-3 p-4 rounded-lg border border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/20">
{/* AI Permission Level */}
<div class={`space-y-3 p-4 rounded-lg border ${form.controlLevel === 'autonomous' ? 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20' : 'border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/20'}`}>
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Infrastructure Control</span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">AI Permission Level</span>
<Show when={form.controlLevel !== 'read_only'}>
<span class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${
form.controlLevel === 'autonomous'
@@ -1487,9 +1436,9 @@ export const AISettings: Component = () => {
</Show>
</div>
{/* Control Level */}
{/* Permission Level */}
<div class="flex items-center gap-3">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 w-28 flex-shrink-0">Control Level</label>
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 w-28 flex-shrink-0">Permission</label>
<select
value={form.controlLevel}
onChange={(e) => setForm('controlLevel', e.currentTarget.value as 'read_only' | 'suggest' | 'controlled' | 'autonomous')}
@@ -1497,17 +1446,35 @@ export const AISettings: Component = () => {
disabled={saving()}
>
<option value="read_only">Read Only - AI can only observe</option>
<option value="suggest">Suggest - AI suggests commands to copy/paste</option>
<option value="controlled">Controlled - AI executes with approval</option>
<option value="suggest">Suggest - AI suggests commands for you to run</option>
<option value="controlled">Controlled - AI executes with your approval</option>
<option value="autonomous">Autonomous - AI executes without approval (Pro)</option>
</select>
</div>
<p class="text-[10px] text-gray-500 dark:text-gray-400 ml-[7.5rem]">
{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'}
</p>
<Show when={form.controlLevel === 'autonomous'}>
<div class="p-2 bg-amber-100/50 dark:bg-amber-900/30 rounded border border-amber-200 dark:border-amber-800 text-[10px] text-amber-800 dark:text-amber-200">
<strong>Legal Disclaimer:</strong> AI models can hallucinate. You are responsible for any damage caused by autonomous actions. See <a href="https://github.com/rcourtman/Pulse/blob/main/TERMS.md" target="_blank" class="underline">Terms of Service</a>.
</div>
</Show>
<Show when={form.controlLevel === 'autonomous' && autoFixLocked()}>
<p class="text-xs text-gray-500 dark:text-gray-400">
<a
class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline"
href="https://pulserelay.pro/"
target="_blank"
rel="noreferrer"
>
Upgrade to Pro
</a>{' '}
to enable autonomous mode.
</p>
</Show>
{/* Protected Guests - Only show if control is enabled */}
<Show when={form.controlLevel !== 'read_only'}>
+4 -1
View File
@@ -133,11 +133,14 @@ const loadSessionFromServer = async (_sessionId: string): Promise<boolean> => {
};
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();