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
This commit is contained in:
rcourtman
2026-01-19 19:19:13 +00:00
parent 0f5807d0f9
commit 3cdc5ec6c5
10 changed files with 433 additions and 305 deletions
+2 -2
View File
@@ -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
@@ -23,6 +23,23 @@ interface ChatHeaderProps {
export const ChatHeader: Component<ChatHeaderProps> = (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 (
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-purple-50 to-violet-50 dark:from-purple-900/20 dark:to-violet-900/20">
@@ -82,7 +99,7 @@ export const ChatHeader: Component<ChatHeaderProps> = (props) => {
<div class="font-medium text-gray-900 dark:text-gray-100">Default</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Use configured default model</div>
</button>
<For each={Array.from(groupModelsByProvider(props.models).entries())}>
<For each={Array.from(groupModelsByProvider(filteredModels()).entries())}>
{([provider, models]) => (
<>
<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 sticky top-0">
@@ -94,8 +111,15 @@ export const ChatHeader: Component<ChatHeaderProps> = (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' : ''}`}
>
<div class="font-medium text-gray-900 dark:text-gray-100">
{model.name || model.id.split(':').pop()}
<div class="flex items-center gap-1.5">
<span class="font-medium text-gray-900 dark:text-gray-100">
{model.name || model.id.split(':').pop()}
</span>
<Show when={model.notable}>
<span class="px-1 py-0.5 text-[10px] font-medium bg-gradient-to-r from-purple-500 to-violet-500 text-white rounded">
NEW
</span>
</Show>
</div>
</button>
)}
@@ -103,6 +127,20 @@ export const ChatHeader: Component<ChatHeaderProps> = (props) => {
</>
)}
</For>
{/* Show/hide older models toggle */}
<Show when={hiddenModelCount() > 0}>
<div class="border-t border-gray-200 dark:border-gray-700 mt-1 pt-1">
<button
onClick={() => setShowAllModels(!showAllModels())}
class="w-full px-3 py-2 text-left text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-1.5"
>
<svg class={`w-3 h-3 transition-transform ${showAllModels() ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
{showAllModels() ? 'Hide older models' : `Show ${hiddenModelCount()} older models`}
</button>
</div>
</Show>
</div>
</Show>
</div>
@@ -112,11 +150,10 @@ export const ChatHeader: Component<ChatHeaderProps> = (props) => {
<button
onClick={props.onToggleAutonomous}
disabled={props.isTogglingAutonomous}
class={`p-2 rounded-lg transition-all ${
props.autonomousMode
class={`p-2 rounded-lg transition-all ${props.autonomousMode
? 'text-amber-600 dark:text-amber-400 bg-amber-100 dark:bg-amber-900/40 hover:bg-amber-200 dark:hover:bg-amber-900/60 shadow-sm'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
} ${props.isTogglingAutonomous ? 'opacity-50 cursor-wait' : ''}`}
} ${props.isTogglingAutonomous ? 'opacity-50 cursor-wait' : ''}`}
title={props.autonomousMode ? 'Autonomous Mode: ON (commands run without approval)' : 'Autonomous Mode: OFF (commands need approval)'}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -122,7 +122,7 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
{/* Thinking block - collapsed by default */}
<Match when={evt.type === 'thinking' && evt.thinking}>
<ThinkingBlock
content={evt.thinking!}
content={evt.thinking || ''}
isStreaming={props.message.isStreaming}
/>
</Match>
@@ -134,7 +134,12 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
{/* Completed tool execution block */}
<Match when={evt.type === 'tool' && evt.tool}>
<ToolExecutionBlock tool={evt.tool!} />
<ToolExecutionBlock tool={{
name: evt.tool?.name || 'unknown',
input: evt.tool?.input || '{}',
output: evt.tool?.output || '',
success: evt.tool?.success ?? true,
}} />
</Match>
{/* Content/text block */}
@@ -150,7 +155,7 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
prose-headings:text-slate-900 dark:prose-headings:text-slate-100
prose-strong:text-slate-900 dark:prose-strong:text-slate-100
prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5"
innerHTML={renderMarkdown(evt.content!)}
innerHTML={renderMarkdown(evt.content || '')}
/>
</Match>
@@ -0,0 +1,250 @@
import { Component, For, Show, createSignal, createMemo } from 'solid-js';
import { PROVIDER_DISPLAY_NAMES, getProviderFromModelId, groupModelsByProvider } from '../aiChatUtils';
import type { ModelInfo } from './types';
export interface ModelSelectorProps {
models: ModelInfo[];
selectedModel: string;
defaultModelLabel?: string;
chatOverrideModel?: string;
chatOverrideLabel?: string;
isLoading?: boolean;
error?: string;
onModelSelect: (modelId: string) => void;
onRefresh?: () => void;
}
/**
* Reusable model selector dropdown with notable model filtering.
* Shows only recent/notable models by default with a toggle to reveal older models.
*/
export const ModelSelector: Component<ModelSelectorProps> = (props) => {
const [isOpen, setIsOpen] = createSignal(false);
const [showAllModels, setShowAllModels] = createSignal(false);
const [searchQuery, setSearchQuery] = createSignal('');
// Filter models by notable status (show only recent/notable models by default)
const notableFilteredModels = createMemo(() => {
if (showAllModels()) {
return props.models;
}
const notable = props.models.filter(m => m.notable);
return notable.length > 0 ? notable : props.models;
});
// Count hidden (older) models
const hiddenModelCount = createMemo(() => {
const notable = props.models.filter(m => m.notable);
return props.models.length - notable.length;
});
// Apply search filter on top of notable filter
const filteredModels = createMemo(() => {
const query = searchQuery().trim().toLowerCase();
const baseModels = notableFilteredModels();
if (!query) return baseModels;
return baseModels.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)
);
});
});
// Check if typed query matches any model
const customModelCandidate = createMemo(() => searchQuery().trim());
const showCustomModelOption = createMemo(() => {
const candidate = customModelCandidate();
if (!candidate) return false;
return !props.models.some((model) => model.id === candidate);
});
const handleSelect = (modelId: string) => {
props.onModelSelect(modelId);
setIsOpen(false);
setSearchQuery('');
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Enter') return;
e.preventDefault();
const candidate = customModelCandidate();
if (candidate) {
handleSelect(candidate);
}
};
const selectedLabel = createMemo(() => {
const selected = props.selectedModel?.trim();
if (!selected) {
const fallback = props.defaultModelLabel;
return fallback ? `Default (${fallback})` : 'Default';
}
const match = props.models.find((model) => model.id === selected);
if (match) return match.name || match.id.split(':').pop() || match.id;
return selected;
});
return (
<div class="relative" data-dropdown>
<button
onClick={() => setIsOpen(!isOpen())}
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"
title="Select model for this chat"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span class="max-w-[120px] truncate font-medium">{selectedLabel()}</span>
<Show when={props.isLoading}>
<svg class="w-3 h-3 text-slate-400 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>
<svg class="w-3 h-3 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<Show when={isOpen()}>
<div class="absolute right-0 top-full mt-1 w-80 max-h-96 overflow-hidden bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 z-50">
{/* Search bar */}
<div class="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-700">
<input
type="text"
value={searchQuery()}
onInput={(e) => 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"
/>
<Show when={props.onRefresh}>
<button
type="button"
onClick={() => props.onRefresh?.()}
disabled={props.isLoading}
class="p-1.5 rounded-md text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700 disabled:opacity-50"
title="Refresh models"
>
<svg class={`w-3.5 h-3.5 ${props.isLoading ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v6h6M20 20v-6h-6M5.32 9A7.5 7.5 0 0119 12.5M18.68 15A7.5 7.5 0 015 11.5" />
</svg>
</button>
</Show>
</div>
{/* Error message */}
<Show when={props.error}>
<div class="px-3 py-2 text-[11px] text-red-500 border-b border-slate-200 dark:border-slate-700">
{props.error}
</div>
</Show>
{/* Model list */}
<div class="max-h-72 overflow-y-auto py-1">
{/* Default option */}
<button
onClick={() => handleSelect('')}
class={`w-full px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-700 ${!props.selectedModel ? 'bg-purple-50 dark:bg-purple-900/30' : ''}`}
>
<div class="font-medium text-slate-900 dark:text-slate-100">Default</div>
<div class="text-[11px] text-slate-500 dark:text-slate-400">
{props.defaultModelLabel ? `Use configured default model (${props.defaultModelLabel})` : 'Use configured default model'}
</div>
</button>
{/* Chat override option */}
<Show when={props.chatOverrideModel}>
<button
onClick={() => handleSelect(props.chatOverrideModel!)}
class={`w-full px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-700 ${props.selectedModel === props.chatOverrideModel ? 'bg-purple-50 dark:bg-purple-900/30' : ''}`}
>
<div class="font-medium text-slate-900 dark:text-slate-100">Chat override</div>
<div class="text-[11px] text-slate-500 dark:text-slate-400">
{props.chatOverrideLabel || props.chatOverrideModel}
</div>
</button>
</Show>
{/* Custom model option */}
<Show when={showCustomModelOption()}>
<button
onClick={() => handleSelect(customModelCandidate())}
class="w-full px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-700"
>
<div class="font-medium text-slate-900 dark:text-slate-100">
Use "{customModelCandidate()}"
</div>
<div class="text-[11px] text-slate-500 dark:text-slate-400">Custom model ID</div>
</button>
</Show>
{/* No results */}
<Show when={!props.isLoading && filteredModels().length === 0}>
<div class="px-3 py-4 text-center text-[11px] text-slate-500 dark:text-slate-400">
No matching models.
</div>
</Show>
{/* Grouped models */}
<For each={Array.from(groupModelsByProvider(filteredModels()).entries())}>
{([provider, providerModels]) => (
<>
<div class="px-3 py-1.5 text-[11px] font-semibold text-slate-500 dark:text-slate-400 bg-slate-50 dark:bg-slate-700/50 sticky top-0">
{PROVIDER_DISPLAY_NAMES[provider] || provider}
</div>
<For each={providerModels}>
{(model) => (
<button
onClick={() => handleSelect(model.id)}
class={`w-full px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-700 ${props.selectedModel === model.id ? 'bg-purple-50 dark:bg-purple-900/30' : ''}`}
>
<div class="flex items-center gap-1.5">
<span class="font-medium text-slate-900 dark:text-slate-100">
{model.name || model.id.split(':').pop() || model.id}
</span>
</div>
<Show when={model.description}>
<div class="text-[11px] text-slate-500 dark:text-slate-400 line-clamp-2">
{model.description}
</div>
</Show>
<Show when={model.name && model.name !== model.id}>
<div class="text-[10px] text-slate-400 dark:text-slate-500">
{model.id}
</div>
</Show>
</button>
)}
</For>
</>
)}
</For>
{/* Toggle to show older models */}
<Show when={hiddenModelCount() > 0 && !searchQuery().trim()}>
<div class="border-t border-slate-200 dark:border-slate-700 mt-1 pt-1">
<button
onClick={() => setShowAllModels(!showAllModels())}
class="w-full px-3 py-2 text-left text-xs text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 flex items-center gap-1.5"
>
<svg class={`w-3 h-3 transition-transform ${showAllModels() ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
{showAllModels() ? 'Hide older models' : `Show ${hiddenModelCount()} older models`}
</button>
</div>
</Show>
</div>
</div>
</Show>
</div>
);
};
@@ -79,7 +79,7 @@ export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) =>
{/* Command/input - truncated */}
<code class="text-slate-700 dark:text-slate-300 truncate flex-1">
{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 || '{}')}
</code>
{/* Expand indicator if has more output */}
@@ -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<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
+32 -243
View File
@@ -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<AIChatProps> = (props) => {
const [input, setInput] = createSignal('');
const [sessions, setSessions] = createSignal<ChatSession[]>([]);
const [showSessions, setShowSessions] = createSignal(false);
const [showModelSelector, setShowModelSelector] = createSignal(false);
const [models, setModels] = createSignal<ModelInfo[]>([]);
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<AIChatProps> = (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<AIChatProps> = (props) => {
const selectModel = (modelId: string) => {
chat.setModel(modelId);
updateStoredModel(chat.sessionId(), modelId);
setShowModelSelector(false);
setModelQuery('');
};
createEffect(() => {
@@ -197,8 +159,11 @@ export const AIChat: Component<AIChatProps> = (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<AIChatProps> = (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<AIChatProps> = (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<AIChatProps> = (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<AIChatProps> = (props) => {
<div class="flex items-center gap-1.5">
{/* Model selector */}
<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"
title="Select model for this chat"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span class="max-w-[120px] truncate font-medium">{selectedModelLabel()}</span>
<Show when={modelsLoading()}>
<svg class="w-3 h-3 text-slate-400 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>
<svg class="w-3 h-3 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<Show when={showModelSelector()}>
<div class="absolute right-0 top-full mt-1 w-80 max-h-96 overflow-hidden bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 z-50">
<div class="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-700">
<input
type="text"
value={modelQuery()}
onInput={(e) => 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"
/>
<button
type="button"
onClick={() => loadModels(true)}
disabled={modelsLoading()}
class="p-1.5 rounded-md text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700 disabled:opacity-50"
title="Refresh models"
>
<svg class={`w-3.5 h-3.5 ${modelsLoading() ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v6h6M20 20v-6h-6M5.32 9A7.5 7.5 0 0119 12.5M18.68 15A7.5 7.5 0 015 11.5" />
</svg>
</button>
</div>
<Show when={modelsError()}>
<div class="px-3 py-2 text-[11px] text-red-500 border-b border-slate-200 dark:border-slate-700">
{modelsError()}
</div>
</Show>
<div class="max-h-72 overflow-y-auto py-1">
<button
onClick={() => selectModel('')}
class={`w-full px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-700 ${!chat.model() ? 'bg-purple-50 dark:bg-purple-900/30' : ''}`}
>
<div class="font-medium text-slate-900 dark:text-slate-100">Default</div>
<div class="text-[11px] text-slate-500 dark:text-slate-400">
{defaultModelLabel() ? `Use configured default model (${defaultModelLabel()})` : 'Use configured default model'}
</div>
</button>
<Show when={chatOverrideModel()}>
<button
onClick={() => selectModel(chatOverrideModel())}
class={`w-full px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-700 ${chat.model() === chatOverrideModel() ? 'bg-purple-50 dark:bg-purple-900/30' : ''}`}
>
<div class="font-medium text-slate-900 dark:text-slate-100">Chat override</div>
<div class="text-[11px] text-slate-500 dark:text-slate-400">
{chatOverrideLabel() || chatOverrideModel()}
</div>
</button>
</Show>
<Show when={showCustomModelOption()}>
<button
onClick={() => selectModel(customModelCandidate())}
class="w-full px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-700"
>
<div class="font-medium text-slate-900 dark:text-slate-100">
Use "{customModelCandidate()}"
</div>
<div class="text-[11px] text-slate-500 dark:text-slate-400">Custom model ID</div>
</button>
</Show>
<Show when={!modelsLoading() && filteredModels().length === 0}>
<div class="px-3 py-4 text-center text-[11px] text-slate-500 dark:text-slate-400">
No matching models.
</div>
</Show>
<For each={Array.from(groupModelsByProvider(filteredModels()).entries())}>
{([provider, providerModels]) => (
<>
<div class="px-3 py-1.5 text-[11px] font-semibold text-slate-500 dark:text-slate-400 bg-slate-50 dark:bg-slate-700/50 sticky top-0">
{PROVIDER_DISPLAY_NAMES[provider] || provider}
</div>
<For each={providerModels}>
{(model) => (
<button
onClick={() => selectModel(model.id)}
class={`w-full px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-700 ${chat.model() === model.id ? 'bg-purple-50 dark:bg-purple-900/30' : ''}`}
>
<div class="font-medium text-slate-900 dark:text-slate-100">
{model.name || model.id.split(':').pop() || model.id}
</div>
<Show when={model.description}>
<div class="text-[11px] text-slate-500 dark:text-slate-400 line-clamp-2">
{model.description}
</div>
</Show>
<Show when={model.name && model.name !== model.id}>
<div class="text-[10px] text-slate-400 dark:text-slate-500">
{model.id}
</div>
</Show>
</button>
)}
</For>
</>
)}
</For>
</div>
</div>
</Show>
</div>
<ModelSelector
models={models()}
selectedModel={chat.model()}
defaultModelLabel={defaultModelLabel()}
chatOverrideModel={chatOverrideModel()}
chatOverrideLabel={chatOverrideLabel()}
isLoading={modelsLoading()}
error={modelsError()}
onModelSelect={selectModel}
onRefresh={() => loadModels(true)}
/>
{/* Session picker */}
<div class="relative" data-dropdown>
<button
onClick={() => {
setShowModelSelector(false);
setShowSessions(!showSessions());
}}
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"
@@ -709,7 +499,6 @@ export const AIChat: Component<AIChatProps> = (props) => {
notificationStore.info('Send a message first to start a session');
return;
}
setShowModelSelector(false);
setShowSessions(false);
setShowSessionActions(!showSessionActions());
}}
@@ -77,6 +77,7 @@ export interface ModelInfo {
id: string;
name: string;
description?: string;
notable?: boolean;
}
export interface ChatContextItem {
@@ -7,6 +7,7 @@ export const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
deepseek: 'DeepSeek',
gemini: 'Google Gemini',
ollama: 'Ollama',
};
@@ -20,12 +21,15 @@ export function getProviderFromModelId(modelId: string): string {
if (modelId.includes('claude') || modelId.includes('opus') || modelId.includes('sonnet') || modelId.includes('haiku')) {
return 'anthropic';
}
if (modelId.includes('gpt') || modelId.includes('o1') || modelId.includes('o3')) {
if (modelId.includes('gpt') || modelId.includes('o1') || modelId.includes('o3') || modelId.includes('o4')) {
return 'openai';
}
if (modelId.includes('deepseek')) {
return 'deepseek';
}
if (modelId.includes('gemini')) {
return 'gemini';
}
return 'ollama';
}
+1
View File
@@ -8,6 +8,7 @@ export interface ModelInfo {
name: string;
description?: string;
is_default?: boolean;
notable?: boolean;
}
export interface AISettings {