diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 192e2214f..4bf3eb3cb 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -177,11 +177,14 @@ runtime cost control, and shared AI transport surfaces. frontend rendering. Compacted no-whitespace internal prelude text attached to a leaked tool invocation is part of that same artifact and must be suppressed or retracted from the current stream segment instead of rendered - as assistant prose. 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. + as assistant prose. Drawer-level loading/progress status must not duplicate + transcript output once assistant content, governed tool progress, approvals, + questions, errors, or restored tool evidence are already visible; progress + stays in the transcript row that owns the active turn. 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/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index 73c321177..a75fb419f 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -518,7 +518,8 @@ describe('AIChat', () => { id: 'assistant-error-openrouter', role: 'assistant', content: '', - error: 'The AI provider rejected the credentials. Check your AI provider API key in Settings.', + error: + 'The AI provider rejected the credentials. Check your AI provider API key in Settings.', timestamp: new Date('2026-06-05T10:00:00Z'), model: 'openrouter:deepseek/deepseek-v4-pro', }; @@ -2925,6 +2926,7 @@ describe('AIChat', () => { ]); renderChat(); expect(screen.queryByText('Running get nodes...')).not.toBeInTheDocument(); + expect(screen.queryByText('Thinking...')).not.toBeInTheDocument(); }); it('does not duplicate status in the footer when assistant content is streaming', () => { @@ -2943,6 +2945,23 @@ describe('AIChat', () => { expect(screen.queryByText('Thinking...')).not.toBeInTheDocument(); }); + it('does not fall back to thinking when loading outlives visible assistant content', () => { + mockChat.isLoading.mockReturnValue(true); + mockChat.messages.mockReturnValue([ + { + id: 'msg-1', + role: 'assistant' as const, + content: 'UI_PARITY_OK', + timestamp: new Date(), + isStreaming: false, + streamEvents: [{ type: 'content', content: 'UI_PARITY_OK' }], + }, + ]); + renderChat(); + expect(screen.queryByText('Thinking...')).not.toBeInTheDocument(); + expect(screen.queryByText('Generating response...')).not.toBeInTheDocument(); + }); + it('keeps queued follow-up status without duplicating active assistant streaming status', () => { mockChat.isLoading.mockReturnValue(true); mockChat.queuedFollowUpCount.mockReturnValue(1); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 006f05e67..172705ae5 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -227,9 +227,8 @@ const findProviderReadinessAlternative = (args: { }); const candidate = - sortedCandidates.find( - ({ model }) => normalizeComparableModelKey(model.id) === selectedKey, - ) || sortedCandidates[0]; + sortedCandidates.find(({ model }) => normalizeComparableModelKey(model.id) === selectedKey) || + sortedCandidates[0]; if (!candidate) return null; return { @@ -1059,6 +1058,36 @@ export const AIChat: Component = (props) => { }), ); + const assistantHasVisibleTranscriptOutput = (message: ChatMessage) => { + if (message.role !== 'assistant') return false; + if ((message.content || '').trim() || message.error) return true; + if ( + message.pendingTools?.length || + message.pendingApprovals?.length || + message.pendingQuestions?.length || + message.toolCalls?.length + ) { + return true; + } + + return (message.streamEvents || []).some((event) => { + switch (event.type) { + case 'content': + return !!event.content?.trim(); + case 'tool': + return !!event.tool; + case 'pending_tool': + return !!event.pendingTool; + case 'approval': + return !!event.approval; + case 'question': + return !!event.question; + default: + return false; + } + }); + }; + // Compute current status for display const currentStatus = createMemo(() => { if (!chat.isLoading()) return null; @@ -1071,6 +1100,11 @@ export const AIChat: Component = (props) => { return null; } + const lastAssistant = [...messages].reverse().find((message) => message.role === 'assistant'); + if (lastAssistant && assistantHasVisibleTranscriptOutput(lastAssistant)) { + return null; + } + const lastMessage = messages[messages.length - 1]; if (!lastMessage || lastMessage.role !== 'assistant') {