diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 4a45f419..c7090956 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -57,7 +57,7 @@ import { type FileMention, } from '@/components/ai-elements/prompt-input'; -import { Shimmer } from '@/components/ai-elements/shimmer'; +import { TurnActivityIndicator } from '@/components/turn-activity-indicator'; import { useSmoothedText } from './hooks/useSmoothedText'; import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'; import { WebSearchResult } from '@/components/ai-elements/web-search-result'; @@ -968,12 +968,13 @@ function App() { const streamingBuffersRef = useRef>(new Map()) const [isStopping, setIsStopping] = useState(false) const [, setStopClickedAt] = useState(null) - // Sessions runtime: hook-derived activity for the ACTIVE chat. - // activeIsProcessing blocks the composer until the turn settles; - // activeIsThinking ("actively working") drives shimmers and disables - // permission/ask-human buttons — false while waiting on the user. + // Sessions runtime: whole-turn liveness drives the composer Stop control + // and running indicator. Model reasoning is a narrower state used only to + // label that indicator "Thinking..." while reasoning is actually streaming. const activeIsProcessing = sessionChat.chatState?.isProcessing ?? isProcessing - const activeIsThinking = sessionChat.chatState ? sessionChat.chatState.isThinking : isProcessing + const activeIsReasoning = sessionChat.chatState?.isReasoning ?? false + const activeIsWaitingOnHuman = sessionChat.chatState?.isWaitingOnHuman ?? false + const activeIsWorking = activeIsProcessing && !activeIsWaitingOnHuman // A failed session load must be visible, not a blank chat. const sessionLoadErrorItems = React.useMemo(() => ( sessionChat.error @@ -6766,7 +6767,7 @@ function App() { onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} - isProcessing={isActive && activeIsThinking} + isProcessing={isActive && activeIsWorking} response={response} /> )} @@ -6784,7 +6785,7 @@ function App() { query={request.query} options={request.options} onResponse={(response) => handleAskHumanResponse(request.toolCallId, request.subflow, response)} - isProcessing={isActive && activeIsThinking} + isProcessing={isActive && activeIsWorking} /> ))} @@ -6796,10 +6797,10 @@ function App() { )} - {isActive && activeIsThinking && !tabState.currentAssistantMessage && ( + {isActive && activeIsProcessing && ( - Thinking... + )} @@ -6952,7 +6953,8 @@ function App() { allPermissionRequests={activeChatTabState.allPermissionRequests} permissionResponses={activeChatTabState.permissionResponses} autoPermissionDecisions={activeChatTabState.autoPermissionDecisions} - isThinking={activeIsThinking} + isReasoning={activeIsReasoning} + isWaitingOnHuman={activeIsWaitingOnHuman} onPermissionResponse={handlePermissionResponse} onAskHumanResponse={handleAskHumanResponse} isToolOpenForTab={isToolOpenForTab} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index ce1bc9cf..63c3a90a 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -23,7 +23,7 @@ import { MessageContent, MessageResponse, } from '@/components/ai-elements/message' -import { Shimmer } from '@/components/ai-elements/shimmer' +import { TurnActivityIndicator } from '@/components/turn-activity-indicator' import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool' import { WebSearchResult } from '@/components/ai-elements/web-search-result' import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card' @@ -143,10 +143,8 @@ interface ChatSidebarProps { chatTabStates?: Record viewportAnchors?: Record isProcessing: boolean - // Actively working (sessions runtime). When provided, drives the shimmer - // instead of isProcessing so waiting on a permission/ask-human doesn't - // render a "Thinking…" under the card. - isThinking?: boolean + isReasoning?: boolean + isWaitingOnHuman?: boolean isStopping?: boolean onStop?: () => void onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void @@ -215,7 +213,8 @@ export function ChatSidebar({ chatTabStates = {}, viewportAnchors = {}, isProcessing, - isThinking, + isReasoning = false, + isWaitingOnHuman = false, isStopping, onStop, onSubmit, @@ -736,7 +735,7 @@ export function ChatSidebar({ onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} - isProcessing={isActive && (isThinking ?? isProcessing)} + isProcessing={isActive && isProcessing && !isWaitingOnHuman} response={response} /> )} @@ -753,7 +752,7 @@ export function ChatSidebar({ key={request.toolCallId} query={request.query} onResponse={(response) => onAskHumanResponse(request.toolCallId, request.subflow, response)} - isProcessing={isActive && (isThinking ?? isProcessing)} + isProcessing={isActive && isProcessing && !isWaitingOnHuman} /> ))} @@ -765,10 +764,10 @@ export function ChatSidebar({ )} - {isActive && (isThinking ?? isProcessing) && !tabState.currentAssistantMessage && ( + {isActive && isProcessing && ( - Thinking... + )} diff --git a/apps/x/apps/renderer/src/components/turn-activity-indicator.test.tsx b/apps/x/apps/renderer/src/components/turn-activity-indicator.test.tsx new file mode 100644 index 00000000..0a029f12 --- /dev/null +++ b/apps/x/apps/renderer/src/components/turn-activity-indicator.test.tsx @@ -0,0 +1,19 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { TurnActivityIndicator } from './turn-activity-indicator' + +afterEach(cleanup) + +describe('TurnActivityIndicator', () => { + it('shows generic activity while a turn is running outside model reasoning', () => { + render() + expect(screen.getByRole('status')).toHaveTextContent('Working...') + expect(screen.queryByText('Thinking...')).toBeNull() + }) + + it('shows thinking only while model reasoning is active', () => { + render() + expect(screen.getByRole('status')).toHaveTextContent('Thinking...') + expect(screen.queryByText('Working...')).toBeNull() + }) +}) diff --git a/apps/x/apps/renderer/src/components/turn-activity-indicator.tsx b/apps/x/apps/renderer/src/components/turn-activity-indicator.tsx new file mode 100644 index 00000000..c1cb26c0 --- /dev/null +++ b/apps/x/apps/renderer/src/components/turn-activity-indicator.tsx @@ -0,0 +1,17 @@ +import { Shimmer } from '@/components/ai-elements/shimmer' + +export function TurnActivityIndicator({ isReasoning }: { isReasoning: boolean }) { + if (isReasoning) { + return
Thinking...
+ } + + return ( +
+
+ ) +} diff --git a/apps/x/apps/renderer/src/lib/session-chat/store.test.ts b/apps/x/apps/renderer/src/lib/session-chat/store.test.ts index 9c5e0e9c..10955cea 100644 --- a/apps/x/apps/renderer/src/lib/session-chat/store.test.ts +++ b/apps/x/apps/renderer/src/lib/session-chat/store.test.ts @@ -5,6 +5,7 @@ import type { SessionsClient } from './client' import type { SessionFeedListener } from './feed' import { SessionChatStore, SessionListStore } from './store' import { + TS, assistantText, completed, completedTurnLog, @@ -144,7 +145,25 @@ describe('SessionChatStore', () => { emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go')))) emit(turnEvent(S1, 'turn-1', requested('turn-1', 0))) - expect(store.getSnapshot().chatState?.isThinking).toBe(true) + expect(store.getSnapshot().chatState?.isProcessing).toBe(true) + expect(store.getSnapshot().chatState?.isReasoning).toBe(false) + + emit(turnEvent(S1, 'turn-1', { + type: 'model_step_event', + turnId: 'turn-1', + ts: TS, + modelCallIndex: 0, + event: { type: 'reasoning_start' }, + })) + expect(store.getSnapshot().chatState?.isReasoning).toBe(true) + emit(turnEvent(S1, 'turn-1', { + type: 'model_step_event', + turnId: 'turn-1', + ts: TS, + modelCallIndex: 0, + event: { type: 'reasoning_end', text: 'done reasoning' }, + })) + expect(store.getSnapshot().chatState?.isReasoning).toBe(false) emit(turnEvent(S1, 'turn-1', completed('turn-1', 0, assistantText('done')))) emit(turnEvent(S1, 'turn-1', turnCompleted('turn-1'))) diff --git a/apps/x/apps/renderer/src/lib/session-chat/test-fixtures.ts b/apps/x/apps/renderer/src/lib/session-chat/test-fixtures.ts index 3df22529..fa5bd107 100644 --- a/apps/x/apps/renderer/src/lib/session-chat/test-fixtures.ts +++ b/apps/x/apps/renderer/src/lib/session-chat/test-fixtures.ts @@ -32,7 +32,7 @@ export function created( turnId: string, sessionId: string, input: ReturnType = user('hello'), -): TEvent { +): Extract { return { type: 'turn_created', schemaVersion: 1, diff --git a/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts b/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts index 10671d9e..8bcf8c21 100644 --- a/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts +++ b/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts @@ -29,6 +29,14 @@ function assistantCalls(...parts: Array>) { return { role: 'assistant' as const, content: parts } } +function modelStep( + turnId: string, + modelCallIndex: number, + event: Extract['event'], +): Extract { + return { type: 'model_step_event', turnId, ts: TS, modelCallIndex, event } +} + describe('applyOverlay', () => { it('accumulates text and reasoning deltas', () => { let overlay = emptyOverlay() @@ -333,17 +341,19 @@ describe('buildSessionChatState', () => { state.conversation.filter(isChatMessage).map((m) => m.content), ).toEqual(['first?', 'first answer', 'second?']) expect(state.isProcessing).toBe(true) // latest turn idle = actively working - expect(state.isThinking).toBe(true) + expect(state.isReasoning).toBe(false) + expect(state.isWaitingOnHuman).toBe(false) }) it('is settled (not processing) when the latest turn is terminal', () => { const turn = reduceTurn(completedTurnLog(T1, S1, 'q', 'a')) const state = buildSessionChatState([turn], emptyOverlay()) expect(state.isProcessing).toBe(false) - expect(state.isThinking).toBe(false) + expect(state.isReasoning).toBe(false) + expect(state.isWaitingOnHuman).toBe(false) }) - it('exposes pending permissions as request events; waiting is not thinking', () => { + it('exposes pending permissions as request events and marks the turn as waiting', () => { const turn = reduceTurn([ created(T1, S1), requested(T1, 0), @@ -375,7 +385,78 @@ describe('buildSessionChatState', () => { permission: { kind: 'command', commandNames: ['rm'] }, }) expect(state.isProcessing).toBe(true) - expect(state.isThinking).toBe(false) + expect(state.isReasoning).toBe(false) + expect(state.isWaitingOnHuman).toBe(true) + }) + + it('does not mark automatic permission classification as waiting on a human', () => { + const turnCreated = created(T1, S1) + const turn = reduceTurn([ + { + ...turnCreated, + config: { ...turnCreated.config, autoPermission: true }, + }, + requested(T1, 0), + completed(T1, 0, assistantCalls(toolCallPart('p1', 'executeCommand'))), + { + type: 'tool_permission_required', + turnId: T1, + ts: TS, + toolCallId: 'p1', + toolName: 'executeCommand', + request: { kind: 'command', commandNames: ['echo'] }, + }, + ]) + const state = buildSessionChatState([turn], emptyOverlay()) + expect(state.allPermissionRequests.has('p1')).toBe(false) + expect(state.isProcessing).toBe(true) + expect(state.isReasoning).toBe(false) + expect(state.isWaitingOnHuman).toBe(false) + }) + + it('marks the turn as waiting when automatic permission classification defers', () => { + const turnCreated = created(T1, S1) + const turn = reduceTurn([ + { + ...turnCreated, + config: { ...turnCreated.config, autoPermission: true }, + }, + requested(T1, 0), + completed(T1, 0, assistantCalls(toolCallPart('p1', 'executeCommand'))), + { + type: 'tool_permission_required', + turnId: T1, + ts: TS, + toolCallId: 'p1', + toolName: 'executeCommand', + request: { kind: 'command', commandNames: ['rm'] }, + }, + { + type: 'tool_permission_classified', + turnId: T1, + ts: TS, + toolCallId: 'p1', + decision: 'defer', + reason: 'needs human review', + }, + ]) + const state = buildSessionChatState([turn], emptyOverlay()) + expect(state.allPermissionRequests.has('p1')).toBe(true) + expect(state.isProcessing).toBe(true) + expect(state.isReasoning).toBe(false) + expect(state.isWaitingOnHuman).toBe(true) + }) + + it('tracks reasoning only between reasoning_start and reasoning_end', () => { + const events: TEvent[] = [ + created(T1, S1), + requested(T1, 0), + modelStep(T1, 0, { type: 'reasoning_start' }), + ] + expect(buildSessionChatState([reduceTurn(events)], emptyOverlay()).isReasoning).toBe(true) + + events.push(modelStep(T1, 0, { type: 'reasoning_end', text: 'considering' })) + expect(buildSessionChatState([reduceTurn(events)], emptyOverlay()).isReasoning).toBe(false) }) it('maps human decisions to responses and classifier decisions to auto-decisions', () => { @@ -420,7 +501,8 @@ describe('buildSessionChatState', () => { query: 'Deploy?', options: ['Yes', 'No'], }) - expect(state.isThinking).toBe(false) // waiting on the human, no shimmer + expect(state.isReasoning).toBe(false) + expect(state.isWaitingOnHuman).toBe(true) }) it('stitches live tool output onto the matching tool item', () => { @@ -440,5 +522,6 @@ describe('buildSessionChatState', () => { const turn = reduceTurn([created(T1, S1), requested(T1, 0)]) const state = buildSessionChatState([turn], { ...emptyOverlay(), text: 'typing…' }) expect(state.currentAssistantMessage).toBe('typing…') + expect(state.isReasoning).toBe(false) }) }) diff --git a/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts b/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts index cc1fc6da..8db36e6a 100644 --- a/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts +++ b/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts @@ -330,9 +330,12 @@ export type SessionChatState = { // Composer blocked / Stop shown until the latest turn settles (waiting on a // permission or ask-human still counts as processing). isProcessing: boolean - // Actively working (non-terminal, nothing waiting on the user) — drives the - // "Thinking…" shimmer; deliberately false under permission/ask-human cards. - isThinking: boolean + // True only between reasoning_start and reasoning_end for the latest open + // model call. This describes model activity, not whole-turn liveness. + isReasoning: boolean + // Kept separate from processing so permission/ask-human controls remain + // interactive while the turn is suspended for user input. + isWaitingOnHuman: boolean } function toolCallPartOf(tc: ToolCallState) { @@ -344,6 +347,34 @@ function toolCallPartOf(tc: ToolCallState) { } } +// An unresolved permission is not necessarily waiting on the user. In auto +// mode the classifier advances it without human input unless it defers (or +// cannot classify the request). Keeping this distinction here prevents both +// a transient permission card and a false human-wait state while the classifier +// is working. +function permissionNeedsHuman(state: TurnState, tc: ToolCallState): boolean { + if (!state.definition.config.humanAvailable) return false + if (!state.definition.config.autoPermission) return true + const permission = tc.permission + return ( + permission?.required.checkerError !== undefined || + permission?.classificationFailed === true || + permission?.classification?.decision === 'defer' + ) +} + +function isModelReasoning(state: TurnState): boolean { + const call = state.modelCalls[state.modelCalls.length - 1] + if (!call || call.response !== undefined || call.error !== undefined) return false + + let reasoning = false + for (const event of call.stepEvents) { + if (event.type === 'reasoning_start') reasoning = true + if (event.type === 'reasoning_end') reasoning = false + } + return reasoning +} + // Compose the whole session's conversation: prior (settled) turns plus the // latest turn, with the live overlay stitched onto the latest turn's items. export function buildSessionChatState( @@ -375,6 +406,7 @@ export function buildSessionChatState( if (latest) { for (const tc of outstandingPermissions(latest)) { + if (!permissionNeedsHuman(latest, tc)) continue allPermissionRequests.set(tc.toolCallId, { runId: latestTurnId, type: 'tool-permission-request', @@ -418,6 +450,7 @@ export function buildSessionChatState( } const settled = status === 'completed' || status === 'failed' || status === 'cancelled' + const waitingOnHuman = allPermissionRequests.size > 0 || pendingAskHumanRequests.size > 0 return { conversation, currentAssistantMessage: stripVoiceTags(overlay.text), @@ -427,6 +460,7 @@ export function buildSessionChatState( permissionResponses, autoPermissionDecisions, isProcessing: latest !== undefined && !settled, - isThinking: status === 'idle', + isReasoning: latest !== undefined && !settled && isModelReasoning(latest), + isWaitingOnHuman: waitingOnHuman, } }