diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 5bc7d0e6e..d0788401f 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -117,6 +117,11 @@ runtime cost control, and shared AI transport surfaces. structured mentions, finding id, approval override, handoff resources, handoff actions, and handoff metadata, but must not reconstruct scoped context from prompt history or saved transcript prose. + Failed-turn recovery must also expose model-route switching through the + existing drawer model selector so operators can move from a blocked direct + provider route to a configured gateway or alternate model without losing the + draft or creating a parallel picker. Retry remains available, but it must not + be the only visible action when a failed Assistant turn is shown. Restored Assistant sessions must hydrate saved assistant content and persisted tool calls into the same transcript event shape used by live streams so switching sessions does not hide prior tool evidence or collapse @@ -143,8 +148,11 @@ runtime cost control, and shared AI transport surfaces. approval, or question blocks; if a provider emits `pulse_*` / `patrol_*` calls, DSML, XML/function-call envelopes, or JSON tool-call shapes as text content, the chat runtime must strip them before streaming, persistence, and - frontend rendering. Token accounting and other provider metadata remain - runtime/accounting data, not normal transcript prose. + frontend rendering. Completed tool rows in the drawer may show compact tool + name, action summary, status, and an explicit details affordance, but raw + tool input/output JSON must not render in the default transcript. Token + accounting and other provider metadata remain runtime/accounting data, not + normal transcript prose. 4. Add or change Patrol, alert-analysis, or remediation transport through `internal/api/ai_handlers.go`, `internal/api/ai_intelligence_handlers.go`, and `frontend-modern/src/api/patrol.ts` Provider preflight diagnostics returned from `internal/api/ai_handlers.go` must reuse the Patrol runtime failure classifier in `internal/ai/` and diff --git a/frontend-modern/src/components/AI/Chat/ChatMessages.tsx b/frontend-modern/src/components/AI/Chat/ChatMessages.tsx index cf264c5cd..ea22cc0e6 100644 --- a/frontend-modern/src/components/AI/Chat/ChatMessages.tsx +++ b/frontend-modern/src/components/AI/Chat/ChatMessages.tsx @@ -15,6 +15,7 @@ interface ChatMessagesProps { ) => void; onSkipQuestion: (messageId: string, questionId: string) => void; onRetry?: (messageId: string) => void; + onChangeModel?: () => void; // Dashboard props recentSessions?: ChatSession[]; onLoadSession?: (sessionId: string) => void; @@ -146,6 +147,7 @@ export const ChatMessages: Component = (props) => { } onSkipQuestion={(questionId) => props.onSkipQuestion(message.id, questionId)} onRetry={props.onRetry} + onChangeModel={props.onChangeModel} /> )} diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 1b186d270..6c8c967af 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -3,6 +3,7 @@ import CheckIcon from 'lucide-solid/icons/check'; import CircleAlertIcon from 'lucide-solid/icons/circle-alert'; import ClockIcon from 'lucide-solid/icons/clock'; import CopyIcon from 'lucide-solid/icons/copy'; +import CpuIcon from 'lucide-solid/icons/cpu'; import RotateCcwIcon from 'lucide-solid/icons/rotate-ccw'; import SparklesIcon from 'lucide-solid/icons/sparkles'; import { renderMarkdown } from '../aiChatUtils'; @@ -28,6 +29,7 @@ interface MessageItemProps { ) => void; onSkipQuestion: (questionId: string) => void; onRetry?: (messageId: string) => void; + onChangeModel?: () => void; } const markdownClass = @@ -277,16 +279,28 @@ export const MessageItem: Component = (props) => {

{props.message.error}

- - - +
+ + + + + + +
diff --git a/frontend-modern/src/components/AI/Chat/ModelSelector.tsx b/frontend-modern/src/components/AI/Chat/ModelSelector.tsx index ecc0f8aa2..9722a011e 100644 --- a/frontend-modern/src/components/AI/Chat/ModelSelector.tsx +++ b/frontend-modern/src/components/AI/Chat/ModelSelector.tsx @@ -1,7 +1,19 @@ -import { Component, For, Show, createSignal, createMemo, onMount, onCleanup } from 'solid-js'; +import { + Component, + For, + Show, + createEffect, + createSignal, + createMemo, + onMount, + onCleanup, +} from 'solid-js'; import { getProviderFromModelId, groupModelsByProvider } from '../aiChatUtils'; import { SearchField } from '@/components/shared/SearchField'; -import { getAIProviderDisplayName } from '@/utils/aiProviderPresentation'; +import { + formatAIModelRouteLabel, + getAIProviderDisplayName, +} from '@/utils/aiProviderPresentation'; import { AI_CHAT_MODEL_SELECTOR_EMPTY_STATE } from '@/utils/aiChatPresentation'; import type { ModelInfo } from './types'; @@ -13,6 +25,7 @@ export interface ModelSelectorProps { chatOverrideLabel?: string; isLoading?: boolean; error?: string; + openRequest?: number; onModelSelect: (modelId: string) => void; onRefresh?: () => void; } @@ -27,6 +40,8 @@ export const ModelSelector: Component = (props) => { const [searchQuery, setSearchQuery] = createSignal(''); const [dropdownPosition, setDropdownPosition] = createSignal({ top: 0, right: 0 }); let buttonRef: HTMLButtonElement | undefined; + let searchInputRef: HTMLInputElement | undefined; + let lastOpenRequest = props.openRequest || 0; // Filter models by notable status (show only recent/notable models by default) const notableFilteredModels = createMemo(() => { @@ -81,10 +96,13 @@ export const ModelSelector: Component = (props) => { }; const handleToggle = () => { - if (!isOpen()) { + if (isOpen()) { + setIsOpen(false); + } else { updateDropdownPosition(); + setIsOpen(true); + queueMicrotask(() => searchInputRef?.focus()); } - setIsOpen(!isOpen()); }; const handleSelect = (modelId: string) => { @@ -93,6 +111,18 @@ export const ModelSelector: Component = (props) => { setSearchQuery(''); }; + createEffect(() => { + const request = props.openRequest || 0; + if (request <= 0 || request === lastOpenRequest) return; + lastOpenRequest = request; + queueMicrotask(() => { + updateDropdownPosition(); + setSearchQuery(''); + setIsOpen(true); + queueMicrotask(() => searchInputRef?.focus()); + }); + }); + const handleKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Enter') return; e.preventDefault(); @@ -110,7 +140,7 @@ export const ModelSelector: Component = (props) => { return props.defaultModelLabel || 'Default'; } const match = props.models.find((model) => model.id === selected); - if (match) return match.name || match.id.split(':').pop() || match.id; + if (match) return formatAIModelRouteLabel(match); return selected; }); @@ -203,6 +233,9 @@ export const ModelSelector: Component = (props) => { placeholder="Search or enter model ID" class="flex-1" inputClass="py-1.5 text-xs focus:ring-blue-400" + inputRef={(el) => { + searchInputRef = el; + }} /> - {/* Output - always show last few lines, expandable for full output */} - +
-
-            {displayOutput()}
-          
- - + +
+ Input +
+
+              {(props.tool.input || '').trim()}
+            
+
+ +
+ Output +
+
+              {(props.tool.output || '').trim()}
+            
@@ -139,22 +127,8 @@ interface PendingToolBlockProps { } export const PendingToolBlock: Component = (props) => { - const toolLabel = createMemo(() => { - const name = props.tool.name; - if (name === 'run_command' || name === 'pulse_run_command') return 'cmd'; - if (name === 'fetch_url' || name === 'pulse_fetch_url') return 'fetch'; - if (name === 'get_infrastructure_state' || name === 'pulse_get_infrastructure_state') - return 'infra'; - if (name === 'get_active_alerts' || name === 'pulse_get_active_alerts') return 'alerts'; - if (name === 'get_metrics_history' || name === 'pulse_get_metrics_history') return 'metrics'; - if (name === 'get_baselines' || name === 'pulse_get_baselines') return 'baselines'; - if (name === 'get_patterns' || name === 'pulse_get_patterns') return 'patterns'; - if (name === 'get_disk_health' || name === 'pulse_get_disk_health') return 'disks'; - if (name === 'get_storage' || name === 'pulse_get_storage') return 'storage'; - if (name === 'get_resource_details' || name === 'pulse_get_resource_details') return 'resource'; - if (name.includes('finding')) return 'finding'; - return formatIdentifierLabel(name, { stripPrefix: 'pulse_', maxLength: 12 }); - }); + const toolLabel = createMemo(() => getToolLabel(props.tool.name)); + const inputSummary = createMemo(() => parseToolInputSummary(props.tool.input || '')); return (
@@ -177,12 +151,7 @@ export const PendingToolBlock: Component = (props) => { {toolLabel()} - {/* Command - truncated */} - - {props.tool.input.length > 50 - ? props.tool.input.substring(0, 50) + '...' - : props.tool.input} - + {inputSummary()}
); }; diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index 17bf579cd..1a0521209 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -14,7 +14,11 @@ const { mockByType, mockResources, mockWebSocketState, + mockChatMessagesProps, } = vi.hoisted(() => { + const mockChatMessagesProps: Array<{ + onChangeModel?: () => void; + }> = []; const mockChat = { messages: vi.fn((): ChatMessage[] => []), isLoading: vi.fn(() => false), @@ -169,6 +173,7 @@ const { mockByType, mockResources, mockWebSocketState, + mockChatMessagesProps, }; }); @@ -182,24 +187,36 @@ vi.mock('../ChatMessages', () => ({ ChatMessages: (props: { messages: ChatMessage[]; emptyState?: { title: string; subtitle?: string }; - }) => ( -
- {props.emptyState?.title && ( - {props.emptyState.title} - )} - {props.emptyState?.subtitle && ( - {props.emptyState.subtitle} - )} -
- ), + onChangeModel?: () => void; + }) => { + mockChatMessagesProps.push(props); + return ( +
+ {props.emptyState?.title && ( + {props.emptyState.title} + )} + {props.emptyState?.subtitle && ( + {props.emptyState.subtitle} + )} + +
+ ); + }, })); vi.mock('../ModelSelector', () => ({ - ModelSelector: (props: { selectedModel: string; models: ModelInfo[] }) => ( + ModelSelector: (props: { selectedModel: string; models: ModelInfo[]; openRequest?: number }) => (
), })); @@ -305,6 +322,7 @@ async function waitForProviderCheckSettled() { beforeEach(() => { vi.clearAllMocks(); + mockChatMessagesProps.length = 0; setViewportWidth(1440); resetAIRuntimeState(); mockAiChatStore.isOpenSignal.mockReturnValue(true); @@ -382,6 +400,18 @@ describe('AIChat', () => { expect(screen.getByTestId('model-selector')).toBeInTheDocument(); }); + it('opens the model selector from failed-turn recovery', async () => { + renderChat(); + + expect(screen.getByTestId('model-selector')).toHaveAttribute('data-open-request', '0'); + + fireEvent.click(screen.getByTestId('mock-change-model')); + + await waitFor(() => { + expect(screen.getByTestId('model-selector')).toHaveAttribute('data-open-request', '1'); + }); + }); + it('checks the selected provider and shows a readiness issue before the first send', async () => { mockAIAPI.getSettings.mockResolvedValue({ model: 'deepseek:deepseek-v4-pro', diff --git a/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx index 23e1545b3..e6227935e 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx @@ -13,6 +13,7 @@ let capturedMessageItemProps: Array<{ answers: Array<{ id: string; value: string }>, ) => void; onSkipQuestion: (questionId: string) => void; + onChangeModel?: () => void; }> = []; vi.mock('../MessageItem', () => ({ @@ -25,6 +26,7 @@ vi.mock('../MessageItem', () => ({ answers: Array<{ id: string; value: string }>, ) => void; onSkipQuestion: (questionId: string) => void; + onChangeModel?: () => void; }) => { capturedMessageItemProps.push(props); return ( @@ -316,6 +318,20 @@ describe('ChatMessages', () => { expect(handlers.onSkipQuestion).toHaveBeenCalledWith('msg-5', 'q-77'); }); + it('forwards onChangeModel unchanged for failed-turn recovery', () => { + const onChangeModel = vi.fn(); + render(() => ( + + )); + + const propsForMsg = capturedMessageItemProps.find((p) => p.message.id === 'msg-model'); + expect(propsForMsg?.onChangeModel).toBe(onChangeModel); + }); + it('forwards correct message.id when multiple messages exist', () => { const handlers = makeHandlers(); render(() => ( diff --git a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx index 18d8702cd..3ae059ec6 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx @@ -146,6 +146,7 @@ describe('MessageItem', () => { describe('error block', () => { it('renders a distinct error block with the message and a retry button', () => { const onRetry = vi.fn(); + const onChangeModel = vi.fn(); render(() => ( { })} {...makeHandlers()} onRetry={onRetry} + onChangeModel={onChangeModel} /> )); @@ -162,6 +164,10 @@ describe('MessageItem', () => { expect(alert).toBeInTheDocument(); expect(alert.textContent).toContain('billing or quota reasons'); + const changeModel = screen.getByRole('button', { name: /change model/i }); + fireEvent.click(changeModel); + expect(onChangeModel).toHaveBeenCalledTimes(1); + const retry = screen.getByRole('button', { name: /try again/i }); fireEvent.click(retry); expect(onRetry).toHaveBeenCalledWith('msg-1'); diff --git a/frontend-modern/src/components/AI/Chat/__tests__/ModelSelector.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/ModelSelector.test.tsx index a3e38ac3f..47f4d25b2 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/ModelSelector.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/ModelSelector.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; -import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; import { ModelSelector } from '../ModelSelector'; import type { ModelInfo } from '../types'; @@ -53,6 +54,26 @@ describe('ModelSelector', () => { expect(screen.getByText('Claude Sonnet 4')).toBeInTheDocument(); }); + it('keeps gateway-routed selected model labels distinct', () => { + render(() => ( + + )); + + expect(screen.getByText('DeepSeek: DeepSeek V4 Pro via OpenRouter')).toBeInTheDocument(); + }); + it('shows "Default" when no model is selected', () => { render(() => ); @@ -114,6 +135,32 @@ describe('ModelSelector', () => { expect(screen.getByPlaceholderText('Search or enter model ID')).toBeInTheDocument(); }); + it('opens dropdown and focuses search when an external recovery action requests it', async () => { + const [openRequest, setOpenRequest] = createSignal(0); + render(() => ( + <> + + + + )); + + expect(screen.queryByPlaceholderText('Search or enter model ID')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Recovery action' })); + + const searchInput = await screen.findByPlaceholderText('Search or enter model ID'); + await waitFor(() => { + expect(document.activeElement).toBe(searchInput); + }); + }); + it('closes dropdown when toggled again', () => { render(() => ); diff --git a/frontend-modern/src/components/AI/Chat/__tests__/ToolExecutionBlock.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/ToolExecutionBlock.test.tsx index 4d76bcd21..712dc108e 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/ToolExecutionBlock.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/ToolExecutionBlock.test.tsx @@ -109,22 +109,34 @@ describe('ToolExecutionBlock', () => { expect(screen.getByText('ls -la')).toBeInTheDocument(); }); - it('truncates input longer than 60 chars', () => { + it('truncates input summaries longer than 28 chars', () => { const longInput = 'A'.repeat(70); render(() => ); - expect(screen.getByText('A'.repeat(60) + '...')).toBeInTheDocument(); + expect(screen.getByText('A'.repeat(28))).toBeInTheDocument(); }); - it('shows "{}" when input is empty', () => { - render(() => ); - expect(screen.getByText('{}')).toBeInTheDocument(); + it('summarizes JSON action input without showing raw JSON by default', () => { + render(() => ( + + )); + + expect(screen.getByText('topology')).toBeInTheDocument(); + expect(screen.queryByText(/summary_only/)).not.toBeInTheDocument(); + expect(screen.queryByText(/total_nodes/)).not.toBeInTheDocument(); }); // --- Output display --- - it('shows output when non-empty', () => { + it('does not show output by default when non-empty', () => { render(() => ); - expect(screen.getByText('hello world')).toBeInTheDocument(); + expect(screen.queryByText('hello world')).not.toBeInTheDocument(); + expect(screen.getByText('Details')).toBeInTheDocument(); }); it('hides output that is only whitespace', () => { @@ -142,90 +154,42 @@ describe('ToolExecutionBlock', () => { expect(container.querySelector('pre')).toBeNull(); }); - it('shows last 3 lines by default when output has more lines', () => { - const output = 'line1\nline2\nline3\nline4\nline5'; - render(() => ); - const pre = screen.getByText(/line5/); - expect(pre.textContent).toContain('...'); - expect(pre.textContent).toContain('line3'); - expect(pre.textContent).toContain('line4'); - expect(pre.textContent).toContain('line5'); - }); - - it('shows all lines when 3 or fewer non-empty lines', () => { - const output = 'line1\nline2\nline3'; - render(() => ); - const pre = screen.getByText(/line1/); - expect(pre.textContent).not.toContain('...'); - }); - // --- Expand/collapse behavior --- - it('shows "Show full output" button when output has more than 3 lines', () => { + it('shows a details button when raw input or output is available', () => { const output = 'l1\nl2\nl3\nl4\nl5'; render(() => ); - expect(screen.getByText('Show full output')).toBeInTheDocument(); + expect(screen.getByText('Details')).toBeInTheDocument(); }); - it('does not show expand button when output has 3 or fewer lines', () => { - const output = 'l1\nl2\nl3'; - render(() => ); - expect(screen.queryByText('Show full output')).toBeNull(); + it('does not show a details button when there are no details', () => { + render(() => ); + expect(screen.queryByText('Details')).toBeNull(); }); - it('shows full output when "Show full output" button is clicked', async () => { + it('shows raw input and output when details are opened', async () => { const output = 'line1\nline2\nline3\nline4\nline5'; - render(() => ); - const btn = screen.getByText('Show full output'); + render(() => ( + + )); + const btn = screen.getByText('Details'); fireEvent.click(btn); - // After expanding, the button should say "Show less" - expect(screen.getByText('Show less')).toBeInTheDocument(); - // And full output should be visible - const pre = screen.getByText(/line1/); - expect(pre.textContent).toContain('line1'); - expect(pre.textContent).toContain('line5'); + + expect(screen.getByText('Hide details')).toBeInTheDocument(); + expect(screen.getByText('Input')).toBeInTheDocument(); + expect(screen.getByText('Output')).toBeInTheDocument(); + expect(screen.getByText('{"action":"list"}')).toBeInTheDocument(); + expect(screen.getByText(/line1/).textContent).toContain('line5'); }); - it('collapses back when "Show less" is clicked', async () => { + it('hides raw details when toggled closed', async () => { const output = 'line1\nline2\nline3\nline4\nline5'; render(() => ); - fireEvent.click(screen.getByText('Show full output')); - expect(screen.getByText('Show less')).toBeInTheDocument(); - fireEvent.click(screen.getByText('Show less')); - expect(screen.getByText('Show full output')).toBeInTheDocument(); - }); - - it('clicking header row toggles output when has more lines', async () => { - const output = 'l1\nl2\nl3\nl4\nl5'; - const { container } = render(() => ); - // The header row has cursor-pointer class when collapsible - const header = container.querySelector('.cursor-pointer'); - expect(header).not.toBeNull(); - fireEvent.click(header!); - expect(screen.getByText('Show less')).toBeInTheDocument(); - }); - - it('header row is not clickable when output has 3 or fewer lines', () => { - const output = 'l1\nl2'; - const { container } = render(() => ); - expect(container.querySelector('.cursor-pointer')).toBeNull(); - }); - - // --- Expand chevron icon --- - - it('renders expand chevron SVG when output is collapsible', () => { - const output = 'l1\nl2\nl3\nl4'; - const { container } = render(() => ); - const svg = container.querySelector('svg'); - expect(svg).not.toBeNull(); - }); - - it('rotates chevron when expanded', async () => { - const output = 'l1\nl2\nl3\nl4'; - const { container } = render(() => ); - fireEvent.click(screen.getByText('Show full output')); - const svg = container.querySelector('svg'); - expect(svg?.classList.contains('rotate-180')).toBe(true); + fireEvent.click(screen.getByText('Details')); + expect(screen.getByText('Hide details')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Hide details')); + expect(screen.getByText('Details')).toBeInTheDocument(); + expect(screen.queryByText(/line1/)).not.toBeInTheDocument(); }); // --- Edge cases --- @@ -248,20 +212,19 @@ describe('ToolExecutionBlock', () => { // hasOutput returns false when output contains "not available" expect(container.querySelector('pre')).toBeNull(); // The expand button should also not appear since output section is hidden - expect(screen.queryByText('Show full output')).toBeNull(); + expect(screen.getByText('Details')).toBeInTheDocument(); }); - it('does not truncate input at exactly 60 chars', () => { - const input60 = 'X'.repeat(60); - render(() => ); - // Exactly 60 chars should NOT be truncated (condition is > 60) - expect(screen.getByText(input60)).toBeInTheDocument(); + it('does not truncate input summaries at exactly 28 chars', () => { + const input28 = 'X'.repeat(28); + render(() => ); + expect(screen.getByText(input28)).toBeInTheDocument(); }); - it('truncates input at 61 chars', () => { - const input61 = 'Y'.repeat(61); - render(() => ); - expect(screen.getByText('Y'.repeat(60) + '...')).toBeInTheDocument(); + it('truncates input summaries at 29 chars', () => { + const input29 = 'Y'.repeat(29); + render(() => ); + expect(screen.getByText('Y'.repeat(28))).toBeInTheDocument(); }); }); @@ -295,16 +258,9 @@ describe('PendingToolBlock', () => { expect(screen.getByText('custom op')).toBeInTheDocument(); }); - it('falls through to fallback for pulse_get_storage_config (not mapped in pending)', () => { - // PendingToolBlock does NOT have pulse_get_storage_config mapped unlike ToolExecutionBlock - // Fallback: strip pulse_, replace underscores, truncate to 12 → "get storage " - const { container } = render(() => ( - - )); - // Use container query since getByText trims trailing whitespace - const label = container.querySelector('span.uppercase'); - expect(label).not.toBeNull(); - expect(label!.textContent).toBe('get storage '); + it('maps pulse_get_storage_config consistently while pending', () => { + render(() => ); + expect(screen.getByText('storage cfg')).toBeInTheDocument(); }); // --- Input display --- @@ -314,22 +270,33 @@ describe('PendingToolBlock', () => { expect(screen.getByText('df -h')).toBeInTheDocument(); }); - it('truncates input longer than 50 chars', () => { + it('truncates input summaries longer than 28 chars', () => { const longInput = 'B'.repeat(55); render(() => ); - expect(screen.getByText('B'.repeat(50) + '...')).toBeInTheDocument(); + expect(screen.getByText('B'.repeat(28))).toBeInTheDocument(); }); - it('does not truncate input at exactly 50 chars', () => { - const input50 = 'C'.repeat(50); - render(() => ); - expect(screen.getByText(input50)).toBeInTheDocument(); + it('does not truncate input summaries at exactly 28 chars', () => { + const input28 = 'C'.repeat(28); + render(() => ); + expect(screen.getByText(input28)).toBeInTheDocument(); }); - it('truncates input at 51 chars', () => { - const input51 = 'D'.repeat(51); - render(() => ); - expect(screen.getByText('D'.repeat(50) + '...')).toBeInTheDocument(); + it('truncates input summaries at 29 chars', () => { + const input29 = 'D'.repeat(29); + render(() => ); + expect(screen.getByText('D'.repeat(28))).toBeInTheDocument(); + }); + + it('summarizes JSON action input without showing raw JSON', () => { + render(() => ( + + )); + + expect(screen.getByText('topology')).toBeInTheDocument(); + expect(screen.queryByText(/include/)).not.toBeInTheDocument(); }); // --- Spinner --- @@ -355,7 +322,7 @@ describe('PendingToolsList', () => { render(() => ); expect(screen.getByText('cmd1')).toBeInTheDocument(); expect(screen.getByText('url1')).toBeInTheDocument(); - expect(screen.getByText('{}')).toBeInTheDocument(); + expect(screen.getByText('request')).toBeInTheDocument(); }); it('collapses when more than 3 tools, showing first 2', () => { diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 9be72bdc6..bc3327b7c 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -451,6 +451,7 @@ export const AIChat: Component = (props) => { const [showSessions, setShowSessions] = createSignal(false); const [sessionDropdownPosition, setSessionDropdownPosition] = createSignal({ top: 0, right: 0 }); let sessionButtonRef: HTMLButtonElement | undefined; + const [modelSelectorOpenRequest, setModelSelectorOpenRequest] = createSignal(0); const [defaultModel, setDefaultModel] = createSignal(''); const [chatOverrideModel, setChatOverrideModel] = createSignal(''); const [providerReadiness, setProviderReadiness] = createSignal({ @@ -707,14 +708,14 @@ export const AIChat: Component = (props) => { const fallback = defaultModel().trim(); if (!fallback) return ''; const match = aiRuntimeModels().find((model) => model.id === fallback); - return match ? match.name || match.id.split(':').pop() || match.id : fallback; + return match ? formatAIModelRouteLabel(match) : fallback; }); const chatOverrideLabel = createMemo(() => { const override = chatOverrideModel().trim(); if (!override) return ''; const match = aiRuntimeModels().find((model) => model.id === override); - return match ? match.name || match.id.split(':').pop() || match.id : override; + return match ? formatAIModelRouteLabel(match) : override; }); const selectedChatModel = createMemo(() => { @@ -879,6 +880,10 @@ export const AIChat: Component = (props) => { updateStoredModel(chat.sessionId(), modelId); }; + const openModelSelectorFromError = () => { + setModelSelectorOpenRequest((value) => value + 1); + }; + const switchToProviderReadinessAlternative = () => { const alternative = providerReadinessAlternative(); if (!alternative) return; @@ -1764,6 +1769,7 @@ export const AIChat: Component = (props) => { chatOverrideLabel={chatOverrideLabel()} isLoading={aiRuntimeModelsLoading()} error={aiRuntimeModelsError()} + openRequest={modelSelectorOpenRequest()} onModelSelect={selectModel} onRefresh={() => loadModels(true)} /> @@ -2139,6 +2145,7 @@ export const AIChat: Component = (props) => { onAnswerQuestion={handleAnswerQuestion} onSkipQuestion={handleSkipQuestion} onRetry={(messageId) => chat.retryMessage(messageId)} + onChangeModel={openModelSelectorFromError} recentSessions={sessions() .filter((s) => s.id !== chat.sessionId() && s.message_count > 0) .slice(0, 3)}