fix(x): separate reasoning from turn activity

Show a generic animated Working status whenever an active turn can be stopped, and reserve the Thinking shimmer for model reasoning_start/reasoning_end windows.

Keep human-wait state separate so permission and ask-human controls remain interactive, and add renderer state and component coverage for the new behavior.
This commit is contained in:
Ramnique Singh 2026-07-07 19:25:38 +05:30
parent 50beb9f33e
commit b659c75e4d
8 changed files with 205 additions and 32 deletions

View file

@ -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<Map<string, { assistant: string }>>(new Map())
const [isStopping, setIsStopping] = useState(false)
const [, setStopClickedAt] = useState<number | null>(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<ConversationItem[]>(() => (
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() {
</Message>
)}
{isActive && activeIsThinking && !tabState.currentAssistantMessage && (
{isActive && activeIsProcessing && (
<Message from="assistant">
<MessageContent>
<Shimmer duration={1}>Thinking...</Shimmer>
<TurnActivityIndicator isReasoning={activeIsReasoning} />
</MessageContent>
</Message>
)}
@ -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}

View file

@ -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<string, ChatTabViewState>
viewportAnchors?: Record<string, ChatViewportAnchorState>
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({
</Message>
)}
{isActive && (isThinking ?? isProcessing) && !tabState.currentAssistantMessage && (
{isActive && isProcessing && (
<Message from="assistant">
<MessageContent>
<Shimmer duration={1}>Thinking...</Shimmer>
<TurnActivityIndicator isReasoning={isReasoning} />
</MessageContent>
</Message>
)}

View file

@ -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(<TurnActivityIndicator isReasoning={false} />)
expect(screen.getByRole('status')).toHaveTextContent('Working...')
expect(screen.queryByText('Thinking...')).toBeNull()
})
it('shows thinking only while model reasoning is active', () => {
render(<TurnActivityIndicator isReasoning />)
expect(screen.getByRole('status')).toHaveTextContent('Thinking...')
expect(screen.queryByText('Working...')).toBeNull()
})
})

View file

@ -0,0 +1,17 @@
import { Shimmer } from '@/components/ai-elements/shimmer'
export function TurnActivityIndicator({ isReasoning }: { isReasoning: boolean }) {
if (isReasoning) {
return <div role="status"><Shimmer duration={1}>Thinking...</Shimmer></div>
}
return (
<div role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="relative flex size-2" aria-hidden="true">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-current opacity-40" />
<span className="relative inline-flex size-2 rounded-full bg-current" />
</span>
<span>Working...</span>
</div>
)
}

View file

@ -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')))

View file

@ -32,7 +32,7 @@ export function created(
turnId: string,
sessionId: string,
input: ReturnType<typeof user> = user('hello'),
): TEvent {
): Extract<TEvent, { type: 'turn_created' }> {
return {
type: 'turn_created',
schemaVersion: 1,

View file

@ -29,6 +29,14 @@ function assistantCalls(...parts: Array<ReturnType<typeof toolCallPart>>) {
return { role: 'assistant' as const, content: parts }
}
function modelStep(
turnId: string,
modelCallIndex: number,
event: Extract<TEvent, { type: 'model_step_event' }>['event'],
): Extract<TEvent, { type: 'model_step_event' }> {
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)
})
})

View file

@ -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,
}
}