mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user