fix(mobile): show the same Working line in terminal-backed chats and drop the floating 'Agent is working' pill (#29972)

This commit is contained in:
Brennan Benson
2026-10-17 00:09:10 -07:00
committed by GitHub
parent ac34ffaa81
commit f4aa82874b
16 changed files with 9 additions and 134 deletions
@@ -1,64 +0,0 @@
import { useEffect, useRef } from 'react'
import { Animated, StyleSheet, Text, View } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
/** Animated three-dot "agent is working" row, shown while the active agent is
* still producing a reply. Pure presentation — visibility is the caller's call. */
export function MobileAgentWorkingIndicator(): React.JSX.Element {
const dots = [
useRef(new Animated.Value(0.3)).current,
useRef(new Animated.Value(0.3)).current,
useRef(new Animated.Value(0.3)).current
]
useEffect(() => {
const animations = dots.map((dot, i) =>
Animated.loop(
Animated.sequence([
Animated.delay(i * 160),
Animated.timing(dot, { toValue: 1, duration: 320, useNativeDriver: true }),
Animated.timing(dot, { toValue: 0.3, duration: 320, useNativeDriver: true })
])
)
)
animations.forEach((a) => a.start())
return () => animations.forEach((a) => a.stop())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<View style={styles.row}>
<Text style={styles.label}>Agent is working</Text>
<View style={styles.dots}>
{dots.map((dot, i) => (
<Animated.View key={i} style={[styles.dot, { opacity: dot }]} />
))}
</View>
</View>
)
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm
},
label: {
color: colors.textMuted,
fontSize: typography.metaSize,
fontStyle: 'italic'
},
dots: {
flexDirection: 'row',
gap: 4
},
dot: {
width: 5,
height: 5,
borderRadius: 2.5,
backgroundColor: colors.textSecondary
}
})
@@ -2,31 +2,23 @@ import { Pressable, Text, View } from 'react-native'
import { ArrowDown, ChevronsDownUp, ChevronsUpDown } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-view-styles'
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
/** Floats over the transcript's bottom-right so the Tools toggle and jump-to-latest take no row. */
export function MobileNativeChatFloatingControls({
toolCallCount,
toolsExpanded,
bridgeWorking,
onToggleTools,
showJumpToTail,
onJumpToTail
}: {
toolCallCount: number
toolsExpanded: boolean
bridgeWorking: boolean
onToggleTools: () => void
showJumpToTail: boolean
onJumpToTail: () => void
}): React.JSX.Element {
return (
<View pointerEvents="box-none" style={styles.floatingControls}>
{bridgeWorking ? (
<View pointerEvents="none" style={[styles.toolsPill, styles.workingPill]}>
<MobileAgentWorkingIndicator />
</View>
) : null}
{toolCallCount > 0 ? (
<Pressable
accessibilityRole="button"
@@ -30,9 +30,6 @@ vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' }))
vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' }))
vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
vi.mock('./MobileAgentWorkingIndicator', () => ({
MobileAgentWorkingIndicator: 'WorkingIndicator'
}))
vi.mock('./MobileNativeChatComposer', async () => {
const { composerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture')
return composerStub()
@@ -35,9 +35,6 @@ vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' }))
vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' }))
vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
vi.mock('./MobileAgentWorkingIndicator', () => ({
MobileAgentWorkingIndicator: 'WorkingIndicator'
}))
vi.mock('./MobileNativeChatComposer', async () => {
const { composerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture')
return composerStub()
@@ -36,9 +36,6 @@ vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' }))
vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' }))
vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
vi.mock('./MobileAgentWorkingIndicator', () => ({
MobileAgentWorkingIndicator: 'WorkingIndicator'
}))
vi.mock('./MobileNativeChatComposer', async () => {
const { composerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture')
return composerStub()
@@ -61,9 +61,6 @@ vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMess
vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' }))
vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' }))
vi.mock('./MobileAgentWorkingIndicator', () => ({
MobileAgentWorkingIndicator: 'WorkingIndicator'
}))
vi.mock('./MobileNativeChatSessionOptionPickers', () => ({
MobileNativeChatSessionOptionPickers: 'SessionOptions'
}))
@@ -31,9 +31,6 @@ vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' }))
vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' }))
vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
vi.mock('./MobileAgentWorkingIndicator', () => ({
MobileAgentWorkingIndicator: 'WorkingIndicator'
}))
vi.mock('./MobileNativeChatComposer', async () => {
const { composerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture')
return composerStub()
@@ -30,9 +30,6 @@ vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' }))
vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' }))
vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
vi.mock('./MobileAgentWorkingIndicator', () => ({
MobileAgentWorkingIndicator: 'WorkingIndicator'
}))
vi.mock('./MobileNativeChatComposer', async () => {
const { composerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture')
return composerStub()
@@ -36,9 +36,6 @@ vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' }))
vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' }))
vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
vi.mock('./MobileAgentWorkingIndicator', () => ({
MobileAgentWorkingIndicator: 'WorkingIndicator'
}))
vi.mock('./MobileNativeChatComposer', async () => {
const { composerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture')
return composerStub()
+3 -8
View File
@@ -284,8 +284,7 @@ export function MobileNativeChatView({
)
// 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.
// "Worked for N" row. The structured lane owns them; the bridge lane gets the live line only.
const turns = useMobileNativeChatTurnDisclosure({
messages: data,
enabled: structuredActivityUi,
@@ -297,7 +296,7 @@ export function MobileNativeChatView({
activityText: turnIndicator?.activityText ?? null,
// The display status, decided once in the session hook.
stopping: turnIndicator?.stopping === true,
lineYields: structuredActivityUi && (ask != null || permission != null || question != null),
lineYields: ask != null || permission != null || question != null,
scopeKey: sendSurfaceId
})
@@ -323,7 +322,6 @@ export function MobileNativeChatView({
onOpenFile={onOpenFile}
/>
) : null
const bridgeWorking = agentWorking === true && !structuredActivityUi
const toolCallCount = useMemo(() => mobileNativeChatToolCallCount(folded), [folded])
const toggleTools = useCallback(() => setToolsExpanded((v) => !v), [])
@@ -363,9 +361,7 @@ export function MobileNativeChatView({
keyExtractor={(item) => item.id}
renderItem={history.renderListRow}
contentContainerStyle={
toolCallCount > 0 || bridgeWorking
? styles.listContentUnderFloatingControls
: styles.listContent
toolCallCount > 0 ? styles.listContentUnderFloatingControls : styles.listContent
}
// Let link/file taps land while the composer keyboard is up
// instead of being swallowed by the dismiss gesture.
@@ -392,7 +388,6 @@ export function MobileNativeChatView({
</GestureDetector>
<MobileNativeChatFloatingControls
toolCallCount={toolCallCount}
bridgeWorking={bridgeWorking}
toolsExpanded={toolsExpanded}
onToggleTools={toggleTools}
showJumpToTail={showJumpToTail}
@@ -54,9 +54,6 @@ vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' }))
vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' }))
vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
vi.mock('./MobileAgentWorkingIndicator', () => ({
MobileAgentWorkingIndicator: 'WorkingIndicator'
}))
// Stand-in composer: exposes the view's `handleSend` through a pressable, which is
// the only composer behaviour these banner tests exercise.
@@ -192,11 +189,6 @@ describe('MobileNativeChatView', () => {
: null
}
/** The bridge cue stays outside the scrolling transcript. */
function workingIndicators(): unknown[] {
return renderer!.root.findAll((node) => String(node.type) === 'WorkingIndicator')
}
it('puts the clock bar under the prompt, activity at the tail, and drops the three dots', async () => {
const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'still working')]
await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true })
@@ -207,7 +199,6 @@ describe('MobileNativeChatView', () => {
expect(footerProps()).toEqual({ thinking: false, activityText: null, stopping: false })
expect(listIds().at(-1)).toBe('a1')
expect(props.activeTurnIsWorking).toBe(true)
expect(workingIndicators()).toHaveLength(0)
})
it.each([
@@ -318,22 +309,16 @@ describe('MobileNativeChatView', () => {
expect(footerProps()).toMatchObject({ activityText: 'Running pnpm test' })
})
it('keeps the bridge lane on the three-dot indicator with no turn status', async () => {
it('gives the bridge lane the same live Working line, with no turn status', async () => {
const folded = [userTurn('u1', 'go')]
await render({ messages: folded, folded, agentWorking: true })
const props = rowProps('u1')
expect(props.structuredActivityUi).toBe(false)
expect(props.turnStatus).toBeNull()
expect(props.activeTurnIsWorking).toBe(false)
expect(footerProps()).toBeNull()
expect(workingIndicators()).toHaveLength(1)
expect(
renderer!.root.find((node) => String(node.type) === 'WorkingIndicator').parent?.props
.pointerEvents
).toBe('none')
expect(footerProps()).toEqual({ thinking: false, activityText: null, stopping: false })
const list = renderer!.root.find((node) => String(node.type) === 'FlatList')
expect(list.props.ListFooterComponent).toBeNull()
expect(list.props.contentContainerStyle).toBe(styles.listContentUnderFloatingControls)
expect(list.props.contentContainerStyle).toBe(styles.listContent)
})
it('settles the finished turn to a tappable duration', async () => {
@@ -45,9 +45,6 @@ vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionShee
vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' }))
vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' }))
vi.mock('./MobileAgentWorkingIndicator', () => ({
MobileAgentWorkingIndicator: 'WorkingIndicator'
}))
let renderer: ReactTestRenderer | null = null
afterEach(() => {
@@ -71,9 +71,6 @@ vi.mock('../MobileNativeChatVisual', () => ({ useMobileNativeChatVisualRenderer:
vi.mock('../../platform/use-reduced-motion', () => ({ useReducedMotionEnabled: () => null }))
vi.mock('../MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' }))
vi.mock('../MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('../MobileAgentWorkingIndicator', () => ({
MobileAgentWorkingIndicator: 'WorkingIndicator'
}))
vi.mock('../MobileNativeChatSessionOptionPickers', () => ({
MobileNativeChatSessionOptionPickers: 'SessionOptions'
}))
@@ -74,9 +74,6 @@ vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionShee
vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' }))
vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' }))
vi.mock('./MobileAgentWorkingIndicator', () => ({
MobileAgentWorkingIndicator: 'WorkingIndicator'
}))
const STOP_ROW = `stopped-before-start:orca:${NEVER_OPENED}`
@@ -43,9 +43,6 @@ export const styles = StyleSheet.create({
fontSize: typography.metaSize,
fontWeight: '600'
},
workingPill: {
paddingHorizontal: 0
},
pressed: {
opacity: 0.6
},
@@ -81,8 +81,8 @@ function useScopedOpenKeys(scopeKey: string): [ReadonlySet<string>, (key: string
}
/** Owns the transcript's per-turn status rows and their disclosure state, and
* resolves what one list row needs. Bridge-lane chats pass `enabled: false` and
* keep their single three-dot working indicator instead. */
* resolves what one list row needs. Bridge-lane chats pass `enabled: false`: no turn
* rows, but the same live "Working…" line while the agent works. */
export function useMobileNativeChatTurnDisclosure({
messages,
enabled,
@@ -188,7 +188,7 @@ export function useMobileNativeChatTurnDisclosure({
const line = useMemo(
() =>
nativeChatLiveLine({
draws: enabled && isWorking && !lineYields && active !== null,
draws: isWorking && !lineYields && (!enabled || active !== null),
thinking: active?.thinking === true,
stopping,
activityText: activeActivityText,