From 3cdc5ec6c5445e5a330fc6437095dcee5dbbda1b Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 19 Jan 2026 19:19:13 +0000 Subject: [PATCH] feat(frontend): Update AI chat for native streaming architecture Adapts frontend to work with the new native chat service: Model selector improvements: - Extract ModelSelector to reusable component - Add notable model filtering (shows recent models by default) - Add "Show older models" toggle for legacy model access - Add notable badge indicator for recommended models Chat flow changes: - Simplify approval handling - backend agentic loop executes commands - Tool results now come via stream events, not approval response - Session model selection preserved when switching sessions Type updates: - Add 'notable' field to ModelInfo interface - Add 'notable' to API response types --- frontend-modern/src/api/ai.ts | 4 +- .../src/components/AI/Chat/ChatHeader.tsx | 49 +++- .../src/components/AI/Chat/MessageItem.tsx | 11 +- .../src/components/AI/Chat/ModelSelector.tsx | 250 ++++++++++++++++ .../components/AI/Chat/ToolExecutionBlock.tsx | 2 +- .../src/components/AI/Chat/hooks/useChat.ts | 139 +++++---- .../src/components/AI/Chat/index.tsx | 275 ++---------------- .../src/components/AI/Chat/types.ts | 1 + .../src/components/AI/aiChatUtils.ts | 6 +- frontend-modern/src/types/ai.ts | 1 + 10 files changed, 433 insertions(+), 305 deletions(-) create mode 100644 frontend-modern/src/components/AI/Chat/ModelSelector.tsx diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index f0d2fb6ee..e34fb3b0b 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -56,8 +56,8 @@ export class AIAPI { } // Get available models from the AI provider - static async getModels(): Promise<{ models: { id: string; name: string; description?: string }[]; error?: string }> { - return apiFetchJSON(`${this.baseUrl}/ai/models`) as Promise<{ models: { id: string; name: string; description?: string }[]; error?: string }>; + static async getModels(): Promise<{ models: { id: string; name: string; description?: string; notable?: boolean }[]; error?: string }> { + return apiFetchJSON(`${this.baseUrl}/ai/models`) as Promise<{ models: { id: string; name: string; description?: string; notable?: boolean }[]; error?: string }>; } // Get AI cost/usage summary diff --git a/frontend-modern/src/components/AI/Chat/ChatHeader.tsx b/frontend-modern/src/components/AI/Chat/ChatHeader.tsx index d10a3838c..d94847a43 100644 --- a/frontend-modern/src/components/AI/Chat/ChatHeader.tsx +++ b/frontend-modern/src/components/AI/Chat/ChatHeader.tsx @@ -23,6 +23,23 @@ interface ChatHeaderProps { export const ChatHeader: Component = (props) => { const [showModelSelector, setShowModelSelector] = createSignal(false); const [showSessionPicker, setShowSessionPicker] = createSignal(false); + const [showAllModels, setShowAllModels] = createSignal(false); + + // Filter models based on notable status + const filteredModels = () => { + if (showAllModels()) { + return props.models; + } + // Show notable models, or all if none are notable + const notable = props.models.filter(m => m.notable); + return notable.length > 0 ? notable : props.models; + }; + + // Count hidden models + const hiddenModelCount = () => { + const notable = props.models.filter(m => m.notable); + return props.models.length - notable.length; + }; return (
@@ -82,7 +99,7 @@ export const ChatHeader: Component = (props) => {
Default
Use configured default model
- + {([provider, models]) => ( <>
@@ -94,8 +111,15 @@ export const ChatHeader: Component = (props) => { onClick={() => { props.onModelChange(model.id); setShowModelSelector(false); }} class={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-700 ${props.selectedModel === model.id ? 'bg-purple-50 dark:bg-purple-900/30' : ''}`} > -
- {model.name || model.id.split(':').pop()} +
+ + {model.name || model.id.split(':').pop()} + + + + NEW + +
)} @@ -103,6 +127,20 @@ export const ChatHeader: Component = (props) => { )} + {/* Show/hide older models toggle */} + 0}> +
+ +
+
@@ -112,11 +150,10 @@ export const ChatHeader: Component = (props) => { + + +
+ {/* Search bar */} +
+ setSearchQuery(e.currentTarget.value)} + onKeyDown={handleKeyDown} + placeholder="Search or enter model ID" + class="flex-1 text-xs px-2 py-1.5 rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-200 focus:outline-none focus:ring-2 focus:ring-purple-400/50" + /> + + + +
+ + {/* Error message */} + +
+ {props.error} +
+
+ + {/* Model list */} +
+ {/* Default option */} + + + {/* Chat override option */} + + + + + {/* Custom model option */} + + + + + {/* No results */} + +
+ No matching models. +
+
+ + {/* Grouped models */} + + {([provider, providerModels]) => ( + <> +
+ {PROVIDER_DISPLAY_NAMES[provider] || provider} +
+ + {(model) => ( + + )} + + + )} +
+ + {/* Toggle to show older models */} + 0 && !searchQuery().trim()}> +
+ +
+
+
+
+
+
+ ); +}; diff --git a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx index d82463ee8..9f9c08e09 100644 --- a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx +++ b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx @@ -79,7 +79,7 @@ export const ToolExecutionBlock: Component = (props) => {/* Command/input - truncated */} - {props.tool.input.length > 60 ? props.tool.input.substring(0, 60) + '...' : props.tool.input} + {(props.tool.input || '').length > 60 ? (props.tool.input || '').substring(0, 60) + '...' : (props.tool.input || '{}')} {/* Expand indicator if has more output */} diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index b907a0add..8865e7e70 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -94,6 +94,7 @@ export function useChat(options: UseChatOptions = {}) { prev.map((msg) => { if (msg.id !== assistantId) return msg; + try { switch (event.type) { case 'content': { const content = event.data as string; @@ -119,7 +120,7 @@ export function useChat(options: UseChatOptions = {}) { } case 'tool_start': { - const data = event.data as { name: string; input: string }; + 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') { @@ -127,7 +128,7 @@ export function useChat(options: UseChatOptions = {}) { } const toolId = generateId(); // Unique ID to track this tool - const pendingTool = { name: data.name, input: data.input }; + const pendingTool = { name: data.name || 'unknown', input: data.input || '{}' }; // Add to streamEvents in chronological position const updated = addStreamEvent(msg, { @@ -148,8 +149,8 @@ export function useChat(options: UseChatOptions = {}) { const events = msg.streamEvents || []; // 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); + const normalizeToolName = (name: string) => (name || '').replace(/^(pulse_)+/, ''); + const normalizedEndName = normalizeToolName(data.name || ''); // Find the matching pending tool (by normalized name) const matchingPendingIndex = pendingTools.findIndex( @@ -160,28 +161,54 @@ export function useChat(options: UseChatOptions = {}) { : pendingTools; const newToolCall: ToolExecution = { - name: data.name, - input: data.input, - output: data.output, - success: data.success, + name: data.name || 'unknown', + input: data.input || '{}', + output: data.output || '', + success: data.success ?? true, }; - // Find the pending_tool event in streamEvents and replace it with completed tool - // Search from the end to find the most recent matching pending tool - let updatedEvents = [...events]; - for (let i = events.length - 1; i >= 0; i--) { - const evt = events[i]; - if (evt.type === 'pending_tool' && normalizeToolName(evt.pendingTool?.name || '') === normalizedEndName) { - // Replace pending with completed - updatedEvents[i] = { type: 'tool', tool: newToolCall }; - break; + // Check if there's an approval card for this tool + // If so, we need to remove both the pending_tool AND the approval, + // then add the completed tool at the end (since execution happened AFTER approval) + const hasApproval = events.some( + (evt) => evt.type === 'approval' && normalizeToolName(evt.approval?.toolName || '') === normalizedEndName + ); + + let updatedEvents: typeof events; + if (hasApproval) { + // Remove pending_tool and approval, add completed tool at end + updatedEvents = events.filter((evt) => { + if (evt.type === 'pending_tool' && normalizeToolName(evt.pendingTool?.name || '') === normalizedEndName) { + return false; + } + if (evt.type === 'approval' && normalizeToolName(evt.approval?.toolName || '') === normalizedEndName) { + return false; + } + return true; + }); + updatedEvents.push({ type: 'tool', tool: newToolCall }); + } else { + // No approval - just replace pending_tool in place + updatedEvents = [...events]; + for (let i = events.length - 1; i >= 0; i--) { + const evt = events[i]; + if (evt.type === 'pending_tool' && normalizeToolName(evt.pendingTool?.name || '') === normalizedEndName) { + updatedEvents[i] = { type: 'tool', tool: newToolCall }; + break; + } } } + // Also remove from pendingApprovals if present + const updatedApprovals = (msg.pendingApprovals || []).filter( + (a) => normalizeToolName(a.toolName || '') !== normalizedEndName + ); + return { ...msg, streamEvents: updatedEvents, pendingTools: updatedPending, + pendingApprovals: updatedApprovals, toolCalls: [...(msg.toolCalls || []), newToolCall], }; } @@ -260,6 +287,10 @@ export function useChat(options: UseChatOptions = {}) { default: return msg; } + } catch (err) { + logger.error('[useChat] Error processing event', { event, error: err }); + return msg; // Return unchanged message on error + } }) ); }; @@ -465,25 +496,54 @@ export function useChat(options: UseChatOptions = {}) { try { // Send answer to OpenCode via API await OpenCodeAPI.answerQuestion(questionId, answers); - // Remove the question card after successful answer + + // Remove the question card - it's been handled 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 }); + // After answering, check if the stream is still active. + // If it closed (e.g. on question), we force a re-connection to receive continuation events. + if (!isLoading()) { + logger.debug('[useChat] Stream closed, re-initiating to catch continuation', { + questionId, + messageId, + }); - // 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'); + const currentSessionId = sessionId(); + if (currentSessionId) { + setIsLoading(true); + abortControllerRef = new AbortController(); + + // Set the message back to streaming state to show the AI is working + setMessages((prev) => + prev.map((m) => (m.id === messageId ? { ...m, isStreaming: true } : m)) + ); + + OpenCodeAPI.chat( + '', // Empty prompt - just resume listening for completion + currentSessionId, + model() || undefined, + (event) => { + processEvent(messageId, event); + }, + abortControllerRef.signal + ) + .catch((err) => { + if (err instanceof Error && err.name === 'AbortError') return; + logger.error('[useChat] Re-connection failed:', err); + }) + .finally(() => { + setIsLoading(false); + abortControllerRef = null; + }); } } - // Send the answer as a message to continue the conversation - await sendMessage(answerSummary || 'Continue'); + logger.debug('[useChat] Question answered, waiting for AI to continue', { + questionId, + }); + + // Brief delay to allow backend processing to settle + await new Promise((resolve) => setTimeout(resolve, 500)); } catch (error) { logger.error('[useChat] Failed to answer question:', error); notificationStore.error('Failed to answer question'); @@ -491,25 +551,6 @@ 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 diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 3f1c62e84..a0ec3833c 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -6,7 +6,7 @@ import { aiChatStore } from '@/stores/aiChat'; import { logger } from '@/utils/logger'; import { useChat } from './hooks/useChat'; import { ChatMessages } from './ChatMessages'; -import { PROVIDER_DISPLAY_NAMES, getProviderFromModelId, groupModelsByProvider } from '../aiChatUtils'; +import { ModelSelector } from './ModelSelector'; import type { PendingApproval, PendingQuestion, ModelInfo } from './types'; const MODEL_LEGACY_STORAGE_KEY = 'pulse:ai_chat_model'; @@ -29,11 +29,9 @@ export const AIChat: Component = (props) => { const [input, setInput] = createSignal(''); const [sessions, setSessions] = createSignal([]); const [showSessions, setShowSessions] = createSignal(false); - const [showModelSelector, setShowModelSelector] = createSignal(false); const [models, setModels] = createSignal([]); const [modelsLoading, setModelsLoading] = createSignal(false); const [modelsError, setModelsError] = createSignal(''); - const [modelQuery, setModelQuery] = createSignal(''); const [defaultModel, setDefaultModel] = createSignal(''); const [chatOverrideModel, setChatOverrideModel] = createSignal(''); const [showSessionActions, setShowSessionActions] = createSignal(false); @@ -105,40 +103,6 @@ export const AIChat: Component = (props) => { return match ? (match.name || match.id.split(':').pop() || match.id) : override; }); - const selectedModelLabel = createMemo(() => { - const selected = chat.model().trim(); - if (!selected) { - const fallback = defaultModelLabel(); - return fallback ? `Default (${fallback})` : 'Default'; - } - const match = models().find((model) => model.id === selected); - if (match) return match.name || match.id.split(':').pop() || match.id; - return selected; - }); - - const filteredModels = createMemo(() => { - const query = modelQuery().trim().toLowerCase(); - if (!query) return models(); - return models().filter((model) => { - const provider = getProviderFromModelId(model.id); - const providerName = PROVIDER_DISPLAY_NAMES[provider] || provider; - const modelName = model.name || ''; - return ( - model.id.toLowerCase().includes(query) || - modelName.toLowerCase().includes(query) || - (model.description || '').toLowerCase().includes(query) || - provider.toLowerCase().includes(query) || - providerName.toLowerCase().includes(query) - ); - }); - }); - - const customModelCandidate = createMemo(() => modelQuery().trim()); - const showCustomModelOption = createMemo(() => { - const candidate = customModelCandidate(); - if (!candidate) return false; - return !models().some((model) => model.id === candidate); - }); const loadModels = async (notify = false) => { if (notify) { @@ -184,8 +148,6 @@ export const AIChat: Component = (props) => { const selectModel = (modelId: string) => { chat.setModel(modelId); updateStoredModel(chat.sessionId(), modelId); - setShowModelSelector(false); - setModelQuery(''); }; createEffect(() => { @@ -197,8 +159,11 @@ export const AIChat: Component = (props) => { } return; } - if (chat.model()) { - chat.setModel(''); + // If there's no stored model for this session but we have a current selection, + // preserve it (and migrate it to this session) + const currentModel = chat.model(); + if (currentModel && sessionId) { + updateStoredModel(sessionId, currentModel); } }); @@ -249,7 +214,6 @@ export const AIChat: Component = (props) => { 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); } @@ -313,63 +277,26 @@ export const AIChat: Component = (props) => { chat.updateApproval(messageId, approval.toolId, { isExecuting: true }); try { - const result = await OpenCodeAPI.approveCommand(approval.approvalId); + // Call the approve endpoint - this marks it as approved in the backend + // The agentic loop will detect this and execute the command + // Execution results will come via tool_end event in the stream + await OpenCodeAPI.approveCommand(approval.approvalId); - // Remove from pending approvals + // Remove from pending approvals - the tool_end event will show the result chat.updateApproval(messageId, approval.toolId, { removed: true }); - // Add tool result if command was executed - if (result.approved) { - const typedResult = result as { - result?: { stdout?: string; stderr?: string; exit_code?: number }; - error?: string; - message?: string; - executed?: boolean; - }; - const execResult = typedResult.result; + logger.debug('[AIChat] Command approved, waiting for agentic loop to execute', { + approvalId: approval.approvalId, + toolName: approval.toolName, + }); - let output: string; - let success: boolean; + // Note: We don't manually add tool results or send continuation messages here. + // The agentic loop will: + // 1. Detect the approval + // 2. Re-execute the tool with the approval_id + // 3. Send a tool_end event with the result + // 4. Continue the conversation automatically - 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, - }); - - // 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); notificationStore.error('Failed to approve command'); @@ -408,26 +335,6 @@ export const AIChat: Component = (props) => { chat.updateQuestion(messageId, questionId, { removed: true }); }; - const toggleModelSelector = () => { - const next = !showModelSelector(); - setShowModelSelector(next); - if (next) { - setShowSessions(false); - setModelQuery(''); - if (models().length === 0 && !modelsLoading()) { - loadModels(); - } - } - }; - - const handleModelInputKeyDown = (e: KeyboardEvent) => { - if (e.key !== 'Enter') return; - e.preventDefault(); - const candidate = customModelCandidate(); - if (candidate) { - selectModel(candidate); - } - }; // Session action handlers const handleSummarize = async () => { @@ -508,139 +415,22 @@ export const AIChat: Component = (props) => {
{/* Model selector */} -
- - - -
-
- setModelQuery(e.currentTarget.value)} - onKeyDown={handleModelInputKeyDown} - placeholder="Search or enter model ID" - class="flex-1 text-xs px-2 py-1.5 rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-200 focus:outline-none focus:ring-2 focus:ring-purple-400/50" - /> - -
- - -
- {modelsError()} -
-
- -
- - - - - - - - - - - -
- No matching models. -
-
- - - {([provider, providerModels]) => ( - <> -
- {PROVIDER_DISPLAY_NAMES[provider] || provider} -
- - {(model) => ( - - )} - - - )} -
-
-
-
-
+ loadModels(true)} + /> {/* Session picker */}