mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 16:02:45 +00:00
mobile: give the live turn row a spinner and the shared indicator label
Mobile's per-turn row is already the only live indicator on the structured lane, but it pulsed a bare word and never showed what the provider said it was doing. It now renders a spinner beside the same resolved label desktop uses, and reads reasoning from the journal instead of inferring it from missing output. The bridge lane's four prompt/interrupt write seams move to one module so the controller stays under its line cap.
This commit is contained in:
@@ -9,6 +9,7 @@ vi.mock('react-native', async () => {
|
||||
const Text = ({ children, ...props }: { children?: unknown }): unknown =>
|
||||
React.createElement('Text', props, children)
|
||||
return {
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Animated: {
|
||||
Text,
|
||||
Value: class {
|
||||
|
||||
@@ -73,6 +73,7 @@ function MobileNativeChatMessageImpl({
|
||||
turnStatus,
|
||||
turnExpanded,
|
||||
turnKey,
|
||||
turnActivityText,
|
||||
onToggleTurn,
|
||||
activeTurnIsWorking,
|
||||
structuredActivityUi = false
|
||||
@@ -88,6 +89,8 @@ function MobileNativeChatMessageImpl({
|
||||
turnExpanded?: boolean
|
||||
/** Set only when this row's turn has settled and can disclose its activity. */
|
||||
turnKey?: string
|
||||
/** Provider activity copy, set only on the live turn's row. */
|
||||
turnActivityText?: string | null
|
||||
/** Stable across renders; the row supplies its own key when tapped. */
|
||||
onToggleTurn?: (turnKey: string) => void
|
||||
/** Session-level working state for this message's turn; gates the live tool row. */
|
||||
@@ -150,6 +153,7 @@ function MobileNativeChatMessageImpl({
|
||||
startedAt={turnStatus.startedAt}
|
||||
thinking={turnStatus.thinking}
|
||||
workedSeconds={turnStatus.workedSeconds}
|
||||
activityText={turnActivityText}
|
||||
expanded={turnExpanded ?? false}
|
||||
onToggleExpanded={turnKey && onToggleTurn ? () => onToggleTurn(turnKey) : undefined}
|
||||
/>
|
||||
|
||||
@@ -73,6 +73,7 @@ export function MobileNativeChatOverlay({
|
||||
agentWorking={controller.nativeChatAgentWorking}
|
||||
canStop={controller.nativeChatCanStop}
|
||||
structuredActivityUi={controller.nativeChatStructured}
|
||||
turnIndicator={controller.nativeChatTurnIndicator}
|
||||
workingStartedAt={controller.nativeChatWorkingStartedAt}
|
||||
settledTurns={controller.nativeChatSettledTurns}
|
||||
streaming={streaming}
|
||||
|
||||
@@ -7,18 +7,8 @@ vi.mock('react-native', async () => {
|
||||
const Text = ({ children, ...props }: { children?: unknown }): unknown =>
|
||||
React.createElement('Text', props, children)
|
||||
return {
|
||||
Animated: {
|
||||
Text,
|
||||
Value: class {
|
||||
constructor(private value: number) {}
|
||||
setValue(next: number): void {
|
||||
this.value = next
|
||||
}
|
||||
},
|
||||
loop: (animation: unknown) => animation,
|
||||
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
|
||||
timing: () => ({ start: vi.fn(), stop: vi.fn() })
|
||||
},
|
||||
ActivityIndicator: (props: Record<string, unknown>) =>
|
||||
React.createElement('ActivityIndicator', props),
|
||||
Pressable: ({ children, ...props }: { children?: unknown }) =>
|
||||
React.createElement('Pressable', props, children),
|
||||
Text,
|
||||
@@ -49,6 +39,7 @@ describe('MobileNativeChatTurnStatus', () => {
|
||||
startedAt: number | null
|
||||
thinking: boolean
|
||||
workedSeconds?: number | null
|
||||
activityText?: string | null
|
||||
expanded?: boolean
|
||||
onToggleExpanded?: () => void
|
||||
}): ReactTestRenderer {
|
||||
@@ -61,12 +52,16 @@ describe('MobileNativeChatTurnStatus', () => {
|
||||
const labels = (node: ReactTestInstance): string[] =>
|
||||
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
|
||||
|
||||
it('reads "Thinking" before the turn produces output', () => {
|
||||
const spinners = (node: ReactTestInstance): ReactTestInstance[] =>
|
||||
node.findAllByType('ActivityIndicator' as never)
|
||||
|
||||
it('reads "Thinking" beside one spinner while the turn reasons', () => {
|
||||
const tree = render({ startedAt: Date.now(), thinking: true })
|
||||
expect(labels(tree.root)).toEqual(['Thinking'])
|
||||
expect(spinners(tree.root)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('counts up once the turn is producing output', () => {
|
||||
it('counts up on that same single row when the turn is not reasoning', () => {
|
||||
const startedAt = Date.now()
|
||||
const tree = render({ startedAt, thinking: false })
|
||||
expect(labels(tree.root)).toEqual(['Working for 0s'])
|
||||
@@ -74,6 +69,19 @@ describe('MobileNativeChatTurnStatus', () => {
|
||||
vi.advanceTimersByTime(12_000)
|
||||
})
|
||||
expect(labels(tree.root)).toEqual(['Working for 12s'])
|
||||
expect(spinners(tree.root)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lets provider activity text beat both fallbacks and hold the clock', () => {
|
||||
const tree = render({
|
||||
startedAt: Date.now(),
|
||||
thinking: true,
|
||||
activityText: 'Running pnpm test'
|
||||
})
|
||||
expect(labels(tree.root)).toEqual(['Running pnpm test'])
|
||||
expect(spinners(tree.root)).toHaveLength(1)
|
||||
// No label consumes the duration, so nothing schedules a tick for it.
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('settles to a tappable "Worked for" row that toggles the turn', () => {
|
||||
@@ -98,9 +106,10 @@ describe('MobileNativeChatTurnStatus', () => {
|
||||
expect(labels(tree.root)).toEqual(['Worked for 5s'])
|
||||
})
|
||||
|
||||
it('holds no interval once the turn has settled', () => {
|
||||
render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 })
|
||||
it('holds no interval, and no spinner, once the turn has settled', () => {
|
||||
const tree = render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(spinners(tree.root)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('announces the live row to assistive tech', () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { ChevronRight } from 'lucide-react-native'
|
||||
import {
|
||||
formatNativeChatActiveTurnLabel,
|
||||
formatNativeChatTurnStatusLabel,
|
||||
NATIVE_CHAT_TURN_STATUS_COPY,
|
||||
nativeChatElapsedSeconds
|
||||
@@ -25,48 +26,38 @@ function useElapsedSeconds(startedAt: number | null, counting: boolean): number
|
||||
return counting ? nativeChatElapsedSeconds(startedAt, mountedAt, now) : 0
|
||||
}
|
||||
|
||||
/** The per-turn status row — "Thinking", then "Working for 12s" while the turn
|
||||
* runs, settling to a tappable "Worked for 3m 4s" that discloses the turn's
|
||||
* tool activity. Desktop parity: `NativeChatWorkingStatus`. */
|
||||
/** The per-turn status row. While the turn runs it is the one live indicator — a
|
||||
* spinner beside what the provider says it is doing, else "Thinking", else
|
||||
* "Working for 12s". It settles to a tappable "Worked for 3m 4s" that discloses
|
||||
* the turn's tool activity. Desktop parity: `NativeChatTurnActivityLine` for the
|
||||
* live row, `NativeChatWorkingStatus` for the settled one. */
|
||||
export function MobileNativeChatTurnStatus({
|
||||
startedAt,
|
||||
thinking,
|
||||
workedSeconds,
|
||||
activityText,
|
||||
expanded = false,
|
||||
onToggleExpanded
|
||||
}: {
|
||||
startedAt: number | null
|
||||
thinking: boolean
|
||||
workedSeconds?: number | null
|
||||
/** Provider activity copy for a live turn; outranks the other two labels. */
|
||||
activityText?: string | null
|
||||
expanded?: boolean
|
||||
onToggleExpanded?: () => void
|
||||
}): React.JSX.Element {
|
||||
const counting = !thinking && workedSeconds == null
|
||||
const settled = workedSeconds != null
|
||||
const counting = !settled && !thinking && !activityText?.trim()
|
||||
const elapsedSeconds = useElapsedSeconds(startedAt, counting)
|
||||
const label = formatNativeChatTurnStatusLabel({ thinking, workedSeconds, elapsedSeconds })
|
||||
const label = settled
|
||||
? formatNativeChatTurnStatusLabel({ thinking, workedSeconds, elapsedSeconds })
|
||||
: formatNativeChatActiveTurnLabel({ activityText, thinking, elapsedSeconds })
|
||||
|
||||
const pulse = useRef(new Animated.Value(1)).current
|
||||
useEffect(() => {
|
||||
if (!thinking) {
|
||||
pulse.setValue(1)
|
||||
return
|
||||
}
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulse, { toValue: 0.45, duration: 700, useNativeDriver: true }),
|
||||
Animated.timing(pulse, { toValue: 1, duration: 700, useNativeDriver: true })
|
||||
])
|
||||
)
|
||||
animation.start()
|
||||
return () => animation.stop()
|
||||
}, [pulse, thinking])
|
||||
|
||||
const rowStyle = [styles.row, thinking ? null : styles.rowSettled]
|
||||
|
||||
if (workedSeconds != null && onToggleExpanded) {
|
||||
if (settled && onToggleExpanded) {
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [...rowStyle, pressed && styles.pressed]}
|
||||
style={({ pressed }) => [styles.row, styles.rowSettled, pressed && styles.pressed]}
|
||||
onPress={onToggleExpanded}
|
||||
hitSlop={6}
|
||||
accessibilityRole="button"
|
||||
@@ -83,11 +74,14 @@ export function MobileNativeChatTurnStatus({
|
||||
|
||||
return (
|
||||
<View
|
||||
style={rowStyle}
|
||||
style={[styles.row, settled ? styles.rowSettled : null]}
|
||||
accessibilityLiveRegion="polite"
|
||||
accessibilityLabel={NATIVE_CHAT_TURN_STATUS_COPY.responding}
|
||||
>
|
||||
<Animated.Text style={[styles.label, thinking && { opacity: pulse }]}>{label}</Animated.Text>
|
||||
{settled ? null : <ActivityIndicator size="small" color={colors.textMuted} />}
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -109,7 +103,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
label: {
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.bodySize
|
||||
fontSize: typography.bodySize,
|
||||
flexShrink: 1
|
||||
},
|
||||
caretOpen: {
|
||||
transform: [{ rotate: '90deg' }]
|
||||
|
||||
@@ -73,6 +73,7 @@ type Overrides = {
|
||||
onSend?: (text: string) => Promise<boolean>
|
||||
pending?: Parameters<typeof MobileNativeChatView>[0]['pending']
|
||||
structuredActivityUi?: boolean
|
||||
turnIndicator?: Parameters<typeof MobileNativeChatView>[0]['turnIndicator']
|
||||
agentWorking?: boolean
|
||||
canStop?: boolean
|
||||
sendSurfaceId?: string
|
||||
@@ -282,11 +283,50 @@ describe('MobileNativeChatView', () => {
|
||||
await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true })
|
||||
const props = rowProps('u1')
|
||||
expect(props.structuredActivityUi).toBe(true)
|
||||
expect(props.turnStatus).toMatchObject({ thinking: true, workedSeconds: null })
|
||||
// Nothing reports reasoning, so the one live row counts instead of guessing.
|
||||
expect(props.turnStatus).toMatchObject({ thinking: false, workedSeconds: null })
|
||||
expect(props.turnActivityText).toBeNull()
|
||||
expect(props.activeTurnIsWorking).toBe(true)
|
||||
expect(workingIndicators()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports the live turn as thinking only when its journal says it is reasoning', async () => {
|
||||
const folded = [userTurn('u1', 'go')]
|
||||
await render({
|
||||
messages: folded,
|
||||
folded,
|
||||
structuredActivityUi: true,
|
||||
agentWorking: true,
|
||||
turnIndicator: { thinking: true, activityText: null }
|
||||
})
|
||||
expect(rowProps('u1').turnStatus).toMatchObject({ thinking: true, workedSeconds: null })
|
||||
})
|
||||
|
||||
it('hands the live row the provider activity copy that outranks its fallbacks', async () => {
|
||||
const folded = [userTurn('u1', 'go')]
|
||||
await render({
|
||||
messages: folded,
|
||||
folded,
|
||||
structuredActivityUi: true,
|
||||
agentWorking: true,
|
||||
turnIndicator: { thinking: true, activityText: 'Running pnpm test' }
|
||||
})
|
||||
expect(rowProps('u1').turnActivityText).toBe('Running pnpm test')
|
||||
})
|
||||
|
||||
it('withholds the activity copy from a settled turn', async () => {
|
||||
const folded = [userTurn('u1', 'go'), userTurn('u2', 'again')]
|
||||
await render({
|
||||
messages: folded,
|
||||
folded,
|
||||
structuredActivityUi: true,
|
||||
agentWorking: true,
|
||||
turnIndicator: { thinking: false, activityText: 'Running pnpm test' }
|
||||
})
|
||||
expect(rowProps('u1').turnActivityText).toBeNull()
|
||||
expect(rowProps('u2').turnActivityText).toBe('Running pnpm test')
|
||||
})
|
||||
|
||||
it('keeps the bridge lane on the three-dot indicator with no turn status', async () => {
|
||||
const folded = [userTurn('u1', 'go')]
|
||||
await render({ messages: folded, folded, agentWorking: true })
|
||||
|
||||
@@ -13,7 +13,10 @@ import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-ha
|
||||
import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native'
|
||||
import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status'
|
||||
import type {
|
||||
NativeChatLiveTurnIndicator,
|
||||
NativeChatSettledTurns
|
||||
} from '../../../src/shared/native-chat-turn-status'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from './mobile-native-chat-view-styles'
|
||||
import {
|
||||
@@ -53,6 +56,8 @@ type Props = {
|
||||
/** Structured lane: per-turn "Working for N" status plus live tool progress,
|
||||
* replacing the bridge lane's static three-dot working row (desktop parity). */
|
||||
structuredActivityUi?: boolean
|
||||
/** What labels the live turn's one indicator row (structured lane only). */
|
||||
turnIndicator?: NativeChatLiveTurnIndicator | null
|
||||
/** Structured lane: host-recorded turn timing feeding the per-turn status rows. */
|
||||
workingStartedAt?: number | null
|
||||
settledTurns?: NativeChatSettledTurns | null
|
||||
@@ -136,6 +141,7 @@ export function MobileNativeChatView({
|
||||
agentWorking,
|
||||
canStop = agentWorking,
|
||||
structuredActivityUi = false,
|
||||
turnIndicator = null,
|
||||
workingStartedAt,
|
||||
settledTurns,
|
||||
onStop,
|
||||
@@ -259,14 +265,17 @@ export function MobileNativeChatView({
|
||||
[hasMore, loadingEarlier, onLoadEarlier]
|
||||
)
|
||||
|
||||
// Per-turn "Thinking / Working for N / Worked for N" rows. The structured lane
|
||||
// owns them; the bridge lane keeps its three-dot indicator.
|
||||
// Per-turn status rows: one live indicator while the turn runs, then a settled
|
||||
// "Worked for N" row. The structured lane owns them; the bridge lane keeps its
|
||||
// three-dot indicator.
|
||||
const turns = useMobileNativeChatTurnDisclosure({
|
||||
messages: data,
|
||||
enabled: structuredActivityUi,
|
||||
isWorking: agentWorking === true,
|
||||
workingStartedAt,
|
||||
settledTurns,
|
||||
thinking: turnIndicator?.thinking === true,
|
||||
activityText: turnIndicator?.activityText ?? null,
|
||||
scopeKey: sendSurfaceId
|
||||
})
|
||||
|
||||
@@ -336,6 +345,7 @@ export function MobileNativeChatView({
|
||||
startedAt={turns.active.startedAt}
|
||||
thinking={turns.active.thinking}
|
||||
workedSeconds={turns.active.workedSeconds}
|
||||
activityText={turns.activeActivityText}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import type {
|
||||
} from '../../../src/shared/native-chat-ask'
|
||||
import type { detectAgentPermission } from './mobile-native-chat-permission'
|
||||
import type { parseAgentQuestion } from './mobile-native-chat-question'
|
||||
import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status'
|
||||
import type {
|
||||
NativeChatLiveTurnIndicator,
|
||||
NativeChatSettledTurns
|
||||
} from '../../../src/shared/native-chat-turn-status'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts'
|
||||
import type { useMobileNativeChatSession } from './use-mobile-native-chat-session'
|
||||
@@ -29,6 +32,8 @@ export type MobileNativeChatController = {
|
||||
/** Structured lane: drives the per-turn status row and live tool progress. */
|
||||
nativeChatStructured: boolean
|
||||
nativeChatAgentWorking: boolean
|
||||
/** What labels the live turn's one indicator row; null off the structured lane. */
|
||||
nativeChatTurnIndicator: NativeChatLiveTurnIndicator | null
|
||||
/** Structured lane: host-recorded turn timing for the per-turn status rows. */
|
||||
nativeChatWorkingStartedAt: number | null
|
||||
nativeChatSettledTurns: NativeChatSettledTurns | null
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { MutableRefObject } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
|
||||
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
|
||||
import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask'
|
||||
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
|
||||
import type { MobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
|
||||
|
||||
/** The bridge lane's four prompt/interrupt write seams. They share one enable
|
||||
* gate and chain through the answer seam's `cancelPending`, so a caller cannot
|
||||
* wire one of them to a different lane or forget to drop in-flight answer
|
||||
* writes before an Escape. The structured lane answers over RPC instead. */
|
||||
export function useMobileBridgeChatPromptWrites(args: {
|
||||
client: RpcClient | null
|
||||
enabled: boolean
|
||||
handleRef: MutableRefObject<string | null>
|
||||
deviceTokenRef: MutableRefObject<string | null>
|
||||
agentRef: MutableRefObject<string | null>
|
||||
/** Changes on chat session swap; cancels pending writes when it does. */
|
||||
sessionId: string | null
|
||||
streamIdentity: string
|
||||
onSendError: (message: string) => void
|
||||
}): {
|
||||
answerAsk: MobileNativeChatAnswerSend['answerAsk']
|
||||
cancelAsk: () => Promise<boolean>
|
||||
respondPermission: (send: string) => Promise<boolean>
|
||||
stop: () => void
|
||||
} {
|
||||
const { client, enabled, handleRef, deviceTokenRef, streamIdentity, onSendError } = args
|
||||
const { answerAsk, cancelPending } = useMobileNativeChatAnswerSend({
|
||||
client,
|
||||
enabled,
|
||||
handleRef,
|
||||
deviceTokenRef,
|
||||
agentRef: args.agentRef,
|
||||
sessionId: args.sessionId,
|
||||
streamIdentity,
|
||||
onSendError
|
||||
})
|
||||
const cancelAsk = useMobileNativeChatCancelAsk({
|
||||
client,
|
||||
enabled,
|
||||
handleRef,
|
||||
deviceTokenRef,
|
||||
cancelPending,
|
||||
onSendError
|
||||
})
|
||||
const respondPermission = useMobileNativeChatPermissionSend({
|
||||
client,
|
||||
enabled,
|
||||
handleRef,
|
||||
deviceTokenRef,
|
||||
onSendError
|
||||
})
|
||||
const stop = useMobileNativeChatStop({
|
||||
client,
|
||||
enabled,
|
||||
handleRef,
|
||||
deviceTokenRef,
|
||||
streamIdentity,
|
||||
cancelPending,
|
||||
onSendError
|
||||
})
|
||||
return { answerAsk, cancelAsk, respondPermission, stop }
|
||||
}
|
||||
@@ -2,10 +2,7 @@ import { useLayoutEffect, useRef, type MutableRefObject } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import type { MobileNativeChatTab } from './mobile-native-chat-eligibility'
|
||||
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
|
||||
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
|
||||
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
|
||||
import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask'
|
||||
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
|
||||
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
|
||||
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
|
||||
@@ -14,10 +11,10 @@ import { useMobileNativeChatSessionOptionController } from './use-mobile-native-
|
||||
import { useMobileNativeChatSessionLane } from './use-mobile-native-chat-session-lane'
|
||||
import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge'
|
||||
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
|
||||
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
|
||||
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
|
||||
import { useThrottledLatestValue } from './use-throttled-latest-value'
|
||||
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
|
||||
import { useMobileBridgeChatPromptWrites } from './use-mobile-bridge-chat-prompt-writes'
|
||||
import { useMobileNativeChatActiveResolution } from './use-mobile-native-chat-active-resolution'
|
||||
|
||||
export type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
|
||||
@@ -171,42 +168,19 @@ export function useMobileNativeChatController(args: {
|
||||
? client != null && activeChatSessionId != null && connState === 'connected'
|
||||
: nativeChatInputLeaseReady && connState === 'connected'
|
||||
|
||||
const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } =
|
||||
useMobileNativeChatAnswerSend({
|
||||
client,
|
||||
enabled: inputSendable && !activeChatStructured,
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
agentRef: activeChatAgentRef,
|
||||
sessionId: activeChatSessionId,
|
||||
streamIdentity,
|
||||
onSendError
|
||||
})
|
||||
|
||||
const handleNativeChatCancelAsk = useMobileNativeChatCancelAsk({
|
||||
client,
|
||||
enabled: inputSendable && !activeChatStructured,
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
cancelPending: cancelNativeChatAnswer,
|
||||
onSendError
|
||||
})
|
||||
|
||||
const legacyHandleNativeChatRespondPermission = useMobileNativeChatPermissionSend({
|
||||
client,
|
||||
enabled: inputSendable && !activeChatStructured,
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
onSendError
|
||||
})
|
||||
|
||||
const handleNativeChatStop = useMobileNativeChatStop({
|
||||
const {
|
||||
answerAsk: handleNativeChatAnswerAsk,
|
||||
cancelAsk: handleNativeChatCancelAsk,
|
||||
respondPermission: legacyHandleNativeChatRespondPermission,
|
||||
stop: handleNativeChatStop
|
||||
} = useMobileBridgeChatPromptWrites({
|
||||
client,
|
||||
enabled: inputSendable && !activeChatStructured,
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
agentRef: activeChatAgentRef,
|
||||
sessionId: activeChatSessionId,
|
||||
streamIdentity,
|
||||
cancelPending: cancelNativeChatAnswer,
|
||||
onSendError
|
||||
})
|
||||
|
||||
@@ -298,6 +272,7 @@ export function useMobileNativeChatController(args: {
|
||||
/** Structured lane: drives the per-turn status row and live tool progress. */
|
||||
nativeChatStructured: activeChatStructured,
|
||||
nativeChatAgentWorking,
|
||||
nativeChatTurnIndicator: activeChatStructured ? structuredNativeChat.turnIndicator : null,
|
||||
nativeChatWorkingStartedAt: activeChatStructured ? structuredNativeChat.workingStartedAt : null,
|
||||
nativeChatSettledTurns: activeChatStructured ? structuredNativeChat.settledTurns : null,
|
||||
nativeChatCanStop: activeChatStructured
|
||||
|
||||
@@ -16,6 +16,8 @@ export type MobileNativeChatTurnRow = {
|
||||
turnExpanded: boolean
|
||||
/** Set only on a settled turn — the one row that has activity to disclose. */
|
||||
turnKey?: string
|
||||
/** Provider activity copy, on the live turn's row only; it labels that one row. */
|
||||
turnActivityText?: string | null
|
||||
activeTurnIsWorking: boolean
|
||||
}
|
||||
|
||||
@@ -28,6 +30,8 @@ export function useMobileNativeChatTurnDisclosure({
|
||||
isWorking,
|
||||
workingStartedAt,
|
||||
settledTurns,
|
||||
thinking = false,
|
||||
activityText = null,
|
||||
scopeKey
|
||||
}: {
|
||||
messages: readonly NativeChatMessage[]
|
||||
@@ -36,10 +40,16 @@ export function useMobileNativeChatTurnDisclosure({
|
||||
workingStartedAt?: number | null
|
||||
/** Host-recorded durations; they outrank whatever this client observed. */
|
||||
settledTurns?: NativeChatSettledTurns | null
|
||||
/** Whether the turn is reasoning right now, derived from its journal content. */
|
||||
thinking?: boolean
|
||||
/** What the provider says the live turn is doing; outranks the other labels. */
|
||||
activityText?: string | null
|
||||
/** Host/worktree/tab identity for timing and disclosure isolation. */
|
||||
scopeKey: string
|
||||
}): {
|
||||
active: NativeChatTurnStatus | null
|
||||
/** The live turn's provider activity copy, for the unanchored footer row. */
|
||||
activeActivityText: string | null
|
||||
/** True when the live turn has no user message to hang its status row under. */
|
||||
activeTurnIsUnanchored: boolean
|
||||
onToggleTurn: (turnKey: string) => void
|
||||
@@ -51,6 +61,7 @@ export function useMobileNativeChatTurnDisclosure({
|
||||
isWorking,
|
||||
workingStartedAt,
|
||||
settledTurns,
|
||||
thinking,
|
||||
scopeKey
|
||||
})
|
||||
const [expandedTurns, setExpandedTurns] = useState<{
|
||||
@@ -93,13 +104,15 @@ export function useMobileNativeChatTurnDisclosure({
|
||||
}, [enabled, messages])
|
||||
|
||||
const { active, activeTurnKey, completedByTurn } = turnStatuses
|
||||
const activeActivityText = enabled && isWorking ? (activityText ?? null) : null
|
||||
const resolveRow = useCallback(
|
||||
(index: number, message: NativeChatMessage): MobileNativeChatTurnRow => {
|
||||
const turnKey = turnKeys[index]
|
||||
const isActiveTurn = turnKey === activeTurnKey
|
||||
const turnStatus =
|
||||
!enabled || message.role !== 'user'
|
||||
? null
|
||||
: turnKey === activeTurnKey
|
||||
: isActiveTurn
|
||||
? active
|
||||
: turnKey
|
||||
? (completedByTurn[turnKey] ?? null)
|
||||
@@ -107,6 +120,7 @@ export function useMobileNativeChatTurnDisclosure({
|
||||
return {
|
||||
turnStatus,
|
||||
turnExpanded: turnKey ? expandedTurnIds.has(turnKey) : false,
|
||||
turnActivityText: isActiveTurn ? activeActivityText : null,
|
||||
// Why: the key travels and the row calls one stable handler with it. A
|
||||
// closure per row would be a new identity every render of a streaming
|
||||
// transcript, defeating the row's memo; caching one per turn would mean
|
||||
@@ -120,11 +134,21 @@ export function useMobileNativeChatTurnDisclosure({
|
||||
(turnKey === undefined && activeTurnKey === MOBILE_UNANCHORED_TURN_KEY))
|
||||
}
|
||||
},
|
||||
[turnKeys, enabled, activeTurnKey, active, completedByTurn, expandedTurnIds, isWorking]
|
||||
[
|
||||
turnKeys,
|
||||
enabled,
|
||||
activeTurnKey,
|
||||
active,
|
||||
activeActivityText,
|
||||
completedByTurn,
|
||||
expandedTurnIds,
|
||||
isWorking
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
active,
|
||||
activeActivityText,
|
||||
/** Stable for a given chat scope, so it never disturbs a row's memo. */
|
||||
onToggleTurn: toggleExpandedTurn,
|
||||
activeTurnIsUnanchored:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
nativeChatTurnHasResponse,
|
||||
reduceNativeChatTurnTiming,
|
||||
selectNativeChatTurnStatuses,
|
||||
type NativeChatSettledTurns,
|
||||
@@ -27,6 +26,7 @@ export function useMobileNativeChatTurnStatus({
|
||||
isWorking,
|
||||
workingStartedAt,
|
||||
settledTurns,
|
||||
thinking = false,
|
||||
scopeKey
|
||||
}: {
|
||||
messages: readonly NativeChatMessage[]
|
||||
@@ -35,6 +35,8 @@ export function useMobileNativeChatTurnStatus({
|
||||
workingStartedAt?: number | null
|
||||
/** Host-recorded durations; they outrank whatever this client observed. */
|
||||
settledTurns?: NativeChatSettledTurns | null
|
||||
/** Whether the turn is reasoning right now, derived from its journal content. */
|
||||
thinking?: boolean
|
||||
/** Host/worktree/tab identity. Timings never carry across chat surfaces. */
|
||||
scopeKey: string
|
||||
}): {
|
||||
@@ -45,7 +47,6 @@ export function useMobileNativeChatTurnStatus({
|
||||
const latestUserIndex = enabled
|
||||
? messages.findLastIndex((message) => message.role === 'user')
|
||||
: -1
|
||||
const hasCurrentTurnResponse = enabled && nativeChatTurnHasResponse(messages, latestUserIndex)
|
||||
const latestUserId = latestUserIndex !== -1 ? (messages[latestUserIndex]?.id ?? null) : null
|
||||
const activeTurnKey = latestUserId ?? MOBILE_UNANCHORED_TURN_KEY
|
||||
const [scopedTiming, setScopedTiming] = useState<ScopedTurnTiming>(() => ({
|
||||
@@ -95,6 +96,7 @@ export function useMobileNativeChatTurnStatus({
|
||||
// turn re-renders ~20x/s. Without this, every settled turn's row gets fresh
|
||||
// props each tick and the memoized message rows all re-render.
|
||||
const turnIsWorking = enabled && isWorking
|
||||
const turnIsThinking = enabled && thinking
|
||||
const settledByTurn = enabled ? (settledTurns ?? undefined) : undefined
|
||||
const statuses = useMemo(
|
||||
() =>
|
||||
@@ -102,17 +104,10 @@ export function useMobileNativeChatTurnStatus({
|
||||
activeTurnKey,
|
||||
isWorking: turnIsWorking,
|
||||
workingStartedAt,
|
||||
hasCurrentTurnResponse,
|
||||
thinking: turnIsThinking,
|
||||
settledByTurn
|
||||
}),
|
||||
[
|
||||
timingByTurn,
|
||||
activeTurnKey,
|
||||
turnIsWorking,
|
||||
workingStartedAt,
|
||||
hasCurrentTurnResponse,
|
||||
settledByTurn
|
||||
]
|
||||
[timingByTurn, activeTurnKey, turnIsWorking, workingStartedAt, turnIsThinking, settledByTurn]
|
||||
)
|
||||
return { ...statuses, activeTurnKey }
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ import {
|
||||
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection'
|
||||
import { hasUnansweredStructuredAgentSessionDispatch } from '../../../src/shared/structured-agent-session-projection'
|
||||
import {
|
||||
activeStructuredAgentSessionTurnId,
|
||||
hasUnansweredStructuredAgentSessionDispatch
|
||||
} from '../../../src/shared/structured-agent-session-projection'
|
||||
isStructuredAgentSessionThinking
|
||||
} from '../../../src/shared/structured-agent-session-live-turn'
|
||||
import { selectStructuredAgentTurnActivity } from '../../../src/shared/native-chat-turn-activity'
|
||||
import {
|
||||
pendingStructuredApproval,
|
||||
pendingStructuredQuestion,
|
||||
@@ -31,6 +33,7 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { MobileChatPermission } from './mobile-native-chat-permission'
|
||||
import type { MobileChatQuestion } from './mobile-native-chat-question'
|
||||
import type { MobileNativeChatSession } from './use-mobile-native-chat-session'
|
||||
import type { NativeChatLiveTurnIndicator } from '../../../src/shared/native-chat-turn-status'
|
||||
import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state'
|
||||
import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses'
|
||||
import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options'
|
||||
@@ -43,6 +46,8 @@ type StructuredMobileSession = ReturnType<typeof useMobileStructuredAgentOptions
|
||||
session: MobileNativeChatSession
|
||||
isWorking: boolean
|
||||
turnId: string | null
|
||||
/** What labels the live turn's one indicator row. */
|
||||
turnIndicator: NativeChatLiveTurnIndicator
|
||||
sendWithOutcome: (
|
||||
text: string,
|
||||
images?: string[],
|
||||
@@ -270,6 +275,12 @@ export function useMobileStructuredAgentSession(args: {
|
||||
)
|
||||
const turnId = activeStructuredAgentSessionTurnId(state.items)
|
||||
const turnTiming = useMobileStructuredAgentTurnTiming(state, turnId)
|
||||
const activityText =
|
||||
selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null
|
||||
const thinking = isStructuredAgentSessionThinking(state.items)
|
||||
// Stable while the readings hold, so a streaming turn does not re-render the
|
||||
// whole chat surface on every journal batch.
|
||||
const turnIndicator = useMemo(() => ({ thinking, activityText }), [thinking, activityText])
|
||||
const status = state.status === 'idle' ? 'idle' : state.status
|
||||
const approvalPrompt = useMemo(
|
||||
() => state.items.find(pendingStructuredApproval) ?? null,
|
||||
@@ -296,6 +307,7 @@ export function useMobileStructuredAgentSession(args: {
|
||||
turnId !== null ||
|
||||
hasUnansweredStructuredAgentSessionDispatch(state.submissions, state.fence),
|
||||
turnId,
|
||||
turnIndicator,
|
||||
...turnTiming,
|
||||
sendWithOutcome,
|
||||
cancel,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AGENT_JOURNAL_THINKING_PRESENTATION,
|
||||
type AgentJournalRenderItem
|
||||
} from '../../../src/shared/agent-session-journal-types'
|
||||
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
|
||||
|
||||
function journalItem(
|
||||
sequence: number,
|
||||
body: AgentJournalRenderItem['body']
|
||||
): AgentJournalRenderItem {
|
||||
return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body }
|
||||
}
|
||||
|
||||
function snapshot(items: AgentJournalRenderItem[], fence: number): AgentSessionSubscribeEvent {
|
||||
const newest = items.length
|
||||
return {
|
||||
type: 'snapshot',
|
||||
sessionId: 'session-1',
|
||||
fence,
|
||||
page: {
|
||||
sessionId: 'session-1',
|
||||
epoch: 'epoch-1',
|
||||
fence,
|
||||
direction: 'tail',
|
||||
items,
|
||||
removedItemIds: [],
|
||||
submissions: [],
|
||||
window: {
|
||||
oldest: { epoch: 'epoch-1', sequence: 1 },
|
||||
newest: { epoch: 'epoch-1', sequence: newest },
|
||||
nextCursor: { epoch: 'epoch-1', sequence: newest + 1 }
|
||||
},
|
||||
liveCursor: { epoch: 'epoch-1', sequence: newest },
|
||||
hasOlder: false,
|
||||
hasNewer: false
|
||||
}
|
||||
} as AgentSessionSubscribeEvent
|
||||
}
|
||||
|
||||
/** What the one live indicator row reads, resolved off the session journal. */
|
||||
describe('useMobileStructuredAgentSession turn indicator', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let hook: ReturnType<typeof useMobileStructuredAgentSession> | null = null
|
||||
let listener: ((value: unknown) => void) | null = null
|
||||
const sendRequest = vi.fn(async (method: string) => ({
|
||||
ok: true,
|
||||
result:
|
||||
method === 'agentSession.options'
|
||||
? {
|
||||
models: [{ id: 'gpt-fast', label: 'GPT Fast', isDefault: true, efforts: [] }],
|
||||
current: { model: 'gpt-fast' }
|
||||
}
|
||||
: {},
|
||||
_meta: { runtimeId: 'r1' }
|
||||
}))
|
||||
const subscribe = vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => {
|
||||
listener = onData
|
||||
return vi.fn()
|
||||
})
|
||||
const client = { sendRequest, subscribe } as unknown as RpcClient
|
||||
// Stable across renders: a fresh callback would re-run the hold/subscribe effect
|
||||
// and release the session out from under the test.
|
||||
const onSendError = vi.fn()
|
||||
|
||||
function Harness(): null {
|
||||
hook = useMobileStructuredAgentSession({
|
||||
client,
|
||||
sessionId: 'session-1',
|
||||
sourceIdentity: 'host-a\0workspace-a',
|
||||
enabled: true,
|
||||
connected: true,
|
||||
agent: 'codex',
|
||||
onSendError
|
||||
} as never)
|
||||
return null
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
listener = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
hook = null
|
||||
})
|
||||
|
||||
const runningTurn = journalItem(1, { kind: 'turn', turnId: 'turn-1', state: 'running' })
|
||||
const reasoning = journalItem(2, {
|
||||
kind: 'status',
|
||||
text: 'Weighing two approaches',
|
||||
presentation: AGENT_JOURNAL_THINKING_PRESENTATION
|
||||
})
|
||||
|
||||
it('reads the live turn as reasoning while reasoning is its newest content', async () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
await vi.waitFor(() => expect(listener).not.toBeNull())
|
||||
|
||||
act(() => {
|
||||
listener?.(snapshot([runningTurn, reasoning], 3))
|
||||
})
|
||||
|
||||
expect(hook?.turnIndicator).toEqual({ thinking: true, activityText: null })
|
||||
})
|
||||
|
||||
it('hands the row the provider copy once real content ends the reasoning', async () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
await vi.waitFor(() => expect(listener).not.toBeNull())
|
||||
|
||||
act(() => {
|
||||
listener?.(
|
||||
snapshot(
|
||||
[
|
||||
runningTurn,
|
||||
reasoning,
|
||||
journalItem(3, {
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
input: { command: 'pnpm lint' },
|
||||
state: 'running'
|
||||
}),
|
||||
journalItem(4, { kind: 'status', text: 'Updating the plan' })
|
||||
],
|
||||
3
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(hook?.turnIndicator).toEqual({ thinking: false, activityText: 'Updating the plan' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user