mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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:
parent
50beb9f33e
commit
b659c75e4d
8 changed files with 205 additions and 32 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue