Skip to content
37 changes: 37 additions & 0 deletions mobile/src/session/MobileNativeChatDeliveryRecoveryNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Pressable, Text, View } from 'react-native'
import { AlertTriangle } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-view-styles'

export function MobileNativeChatDeliveryRecoveryNotice({
onShowTerminal
}: {
onShowTerminal?: () => void
}): React.JSX.Element {
return (
<View
style={styles.deliveryRecovery}
accessibilityRole="alert"
accessibilityLiveRegion="assertive"
>
<View style={styles.deliveryRecoveryHeader}>
<View style={styles.deliveryRecoveryIcon}>
<AlertTriangle size={16} color={colors.textMuted} strokeWidth={2} />
</View>
<View style={styles.deliveryRecoveryCopy}>
<Text style={styles.deliveryRecoveryTitle}>Message wasn’t sent</Text>
<Text style={styles.deliveryRecoveryDescription}>
The terminal may be waiting for input. Your message is still in the composer.
</Text>
</View>
</View>
<Pressable
style={({ pressed }) => [styles.deliveryRecoveryButton, pressed && styles.pressed]}
onPress={onShowTerminal}
accessibilityRole="button"
>
<Text style={styles.deliveryRecoveryButtonText}>Show terminal</Text>
</Pressable>
</View>
)
}
2 changes: 2 additions & 0 deletions mobile/src/session/MobileNativeChatOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ export function MobileNativeChatOverlay({
inputLockReason={inputLockReason}
sendErrorMessage={sendErrorMessage}
onClearSendError={onClearSendError}
deliveryFailed={controller.nativeChatDeliveryFailed}
onShowTerminal={controller.showNativeChatTerminal}
filePaths={controller.nativeChatFilePaths}
onNeedFiles={controller.loadNativeChatFiles}
sessionOptions={controller.nativeChatSessionOptions}
Expand Down
19 changes: 19 additions & 0 deletions mobile/src/session/MobileNativeChatView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ vi.mock('react-native-gesture-handler', () => {
})

vi.mock('lucide-react-native', () => ({
AlertTriangle: 'AlertTriangle',
ArrowDown: 'ArrowDown',
ChevronsDownUp: 'ChevronsDownUp',
ChevronsUpDown: 'ChevronsUpDown',
Expand Down Expand Up @@ -71,6 +72,8 @@ type Overrides = {
onClearSendError?: () => void
inputLockReason?: 'disconnected' | 'waiting' | null
onSend?: (text: string) => Promise<boolean>
deliveryFailed?: boolean
onShowTerminal?: () => void
}

function suppressRendererWarning(): () => void {
Expand Down Expand Up @@ -170,6 +173,22 @@ describe('MobileNativeChatView', () => {
expect(bannerText()).toContain('Permission reply failed')
})

it('shows persistent delivery recovery with a terminal action', async () => {
const onShowTerminal = vi.fn()
await render({ deliveryFailed: true, onShowTerminal })

expect(bannerText()).toContain('Message wasn’t sent')
expect(bannerText()).toContain('Your message is still in the composer.')
const action = renderer!.root.find(
(node) =>
node.type === 'Pressable' &&
node.findAll((child) => child.type === 'Text' && child.props.children === 'Show terminal')
.length > 0
)
act(() => action.props.onPress())
expect(onShowTerminal).toHaveBeenCalledOnce()
})

it('does not duplicate the route banner when the composer rejects', async () => {
const onClearSendError = vi.fn()
await render({
Expand Down
6 changes: 6 additions & 0 deletions mobile/src/session/MobileNativeChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
import { MobileNativeChatDeliveryRecoveryNotice } from './MobileNativeChatDeliveryRecoveryNotice'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
Expand Down Expand Up @@ -85,6 +86,8 @@ type Props = {
sendErrorMessage?: string | null
/** Clears `sendErrorMessage` once a later send is accepted. */
onClearSendError?: () => void
deliveryFailed?: boolean
onShowTerminal?: () => void
filePaths?: string[]
onNeedFiles?: (query: string) => void
/** Model/session-option pickers for the composer action row (desktop parity). */
Expand Down Expand Up @@ -143,6 +146,8 @@ export function MobileNativeChatView({
inputLockReason,
sendErrorMessage,
onClearSendError,
deliveryFailed,
onShowTerminal,
filePaths,
onNeedFiles,
sessionOptions,
Expand Down Expand Up @@ -429,6 +434,7 @@ export function MobileNativeChatView({
<Text style={styles.sendErrorText}>{sendErrorMessage}</Text>
</View>
) : null}
{deliveryFailed && <MobileNativeChatDeliveryRecoveryNotice onShowTerminal={onShowTerminal} />}
<MobileNativeChatComposer
value={composerText}
onChangeText={onComposerTextChange}
Expand Down
2 changes: 2 additions & 0 deletions mobile/src/session/mobile-native-chat-controller-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export type MobileNativeChatController = {
setChatComposerText: Dispatch<SetStateAction<string>>
chatPending: MobileNativeChatPendingMessage[]
chatImagePreviewsByMessageId: Record<string, string[]>
nativeChatDeliveryFailed: boolean
showNativeChatTerminal: () => void
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
nativeChatAgentWorking: boolean
nativeChatStreamingText?: string
Expand Down
4 changes: 4 additions & 0 deletions mobile/src/session/mobile-native-chat-pending-echo.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { AgentPromptSubmissionOccurrence } from '../../../src/shared/agent-status-types'

export type MobileNativeChatPendingMessage = {
id: string
text: string
Expand All @@ -13,6 +15,8 @@ export type MobileNativeChatSendOrigin = {
normalizedText: string
baselineOccurrences: number
baselineTailMessageId: string | null
/** Latest hook occurrence observed before dispatch starts. */
promptSubmissionBaseline?: AgentPromptSubmissionOccurrence
}

type PendingByKey = Record<string, MobileNativeChatPendingMessage[]>
Expand Down
117 changes: 117 additions & 0 deletions mobile/src/session/mobile-native-chat-prompt-delivery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { createHash } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import {
assignMobileNativeChatPromptSubmissions,
mobileNativeChatPromptDigest,
mobileNativeChatPromptWasAcknowledged,
type MobileNativeChatDeliveryCheck
} from './mobile-native-chat-prompt-delivery'

describe('mobile native chat prompt delivery', () => {
it('matches the desktop full-prompt digest contract', () => {
const prompt = ` ${'long '.repeat(60)}\nemoji 🚀 `
expect(mobileNativeChatPromptDigest(prompt)).toBe(
`sha256:${createHash('sha256').update(prompt).digest('hex')}`
)
})

it('requires an exact newer occurrence', () => {
const check: MobileNativeChatDeliveryCheck = {
pendingId: 'pending-1',
draftKey: 'draft',
pendingKey: 'pending',
text: 'hello',
normalizedText: 'hello',
baselineTailMessageId: null,
submittedAt: 20,
expectedDigest: mobileNativeChatPromptDigest('hello'),
baseline: { streamId: 'stream', sequence: 2, digest: 'old', receivedAt: 10 },
deadline: null
}
Comment on lines +19 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

rg -n -C 3 "submittedAt|export type MobileNativeChatDeliveryCheck" mobile/src/session/mobile-native-chat-prompt-delivery.ts mobile/src/session/mobile-native-chat-prompt-delivery.test.ts

Repository: stablyai/orca

Length of output: 2849


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## Type definition"
sed -n '1,45p' mobile/src/session/mobile-native-chat-prompt-delivery.ts

echo
echo "## Test imports and type usages"
sed -n '1,70p' mobile/src/session/mobile-native-chat-prompt-delivery.test.ts
echo
sed -n '70,105p' mobile/src/session/mobile-native-chat-prompt-delivery.test.ts

echo
echo "## Check whether submittedAt is read/required anywhere"
rg -n "submittedAt|MobileNativeChatDeliveryCheck" mobile/src/session/mobile-native-chat-prompt-delivery.ts mobile/src/session/mobile-native-chat-prompt-delivery.test.ts

Repository: stablyai/orca

Length of output: 5794


Remove the stale submittedAt properties.

MobileNativeChatDeliveryCheck does not define submittedAt, and the implementation does not read it. Keep the named type literals clean at lines 26, 51, and 87.

expect(
mobileNativeChatPromptWasAcknowledged(check, [
{ streamId: 'stream', sequence: 2, digest: check.expectedDigest, receivedAt: 21 }
])
).toBe(false)
expect(
mobileNativeChatPromptWasAcknowledged(check, [
{ streamId: 'stream', sequence: 3, digest: check.expectedDigest, receivedAt: 21 }
])
).toBe(true)
})

it('accepts only a newer host occurrence from a newly observed stream', () => {
const check: MobileNativeChatDeliveryCheck = {
pendingId: 'pending-1',
draftKey: 'draft',
pendingKey: 'pending',
text: 'hello',
normalizedText: 'hello',
baselineTailMessageId: null,
submittedAt: 9_000,
expectedDigest: mobileNativeChatPromptDigest('hello'),
baseline: { streamId: 'old-stream', sequence: 8, digest: 'old', receivedAt: 8_000 },
deadline: null
}

expect(
mobileNativeChatPromptWasAcknowledged(check, [
{
streamId: 'new-stream',
sequence: 1,
digest: check.expectedDigest,
receivedAt: 100
}
])
).toBe(false)
expect(
mobileNativeChatPromptWasAcknowledged(check, [
{
streamId: 'new-stream',
sequence: 2,
digest: check.expectedDigest,
receivedAt: 8_001
}
])
).toBe(true)
})

it('assigns one occurrence to only one repeated pending send', () => {
const digest = mobileNativeChatPromptDigest('repeat')
const base = {
draftKey: 'draft',
pendingKey: 'pending',
text: 'repeat',
normalizedText: 'repeat',
baselineTailMessageId: null,
submittedAt: 20,
expectedDigest: digest,
deadline: null
}
const occurrence = {
streamId: 'stream',
sequence: 1,
digest,
receivedAt: 21
}

expect([
...assignMobileNativeChatPromptSubmissions(
[
{ ...base, pendingId: 'first' },
{ ...base, pendingId: 'second' }
],
[occurrence]
).keys()
]).toEqual(['first'])
expect([
...assignMobileNativeChatPromptSubmissions(
[
{ ...base, pendingId: 'first', acknowledgedBy: occurrence },
{ ...base, pendingId: 'second' }
],
[occurrence]
).keys()
]).toEqual(['first'])
})
})
76 changes: 76 additions & 0 deletions mobile/src/session/mobile-native-chat-prompt-delivery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { sha256 } from '@noble/hashes/sha256'
import type { AgentPromptSubmissionOccurrence } from '../../../src/shared/agent-status-types'

export const MOBILE_NATIVE_CHAT_DELIVERY_CONFIRMATION_MS = 8_000

export type MobileNativeChatDeliveryCheck = {
pendingId: string
draftKey: string
pendingKey: string | null
text: string
normalizedText: string
baselineTailMessageId: string | null
expectedDigest: string
baseline?: AgentPromptSubmissionOccurrence
deadline: ReturnType<typeof setTimeout> | null
acknowledgedBy?: AgentPromptSubmissionOccurrence
}

function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}

export function mobileNativeChatPromptDigest(text: string): string {
return `sha256:${bytesToHex(sha256(new TextEncoder().encode(text)))}`
}

export function mobileNativeChatPromptWasAcknowledged(
check: MobileNativeChatDeliveryCheck,
occurrences: readonly AgentPromptSubmissionOccurrence[]
): boolean {
return occurrences.some((occurrence) => {
if (occurrence.digest !== check.expectedDigest) {
return false
}
if (!check.baseline) {
return true
}
if (occurrence.streamId === check.baseline.streamId) {
return occurrence.sequence > check.baseline.sequence
}
return occurrence.receivedAt > check.baseline.receivedAt
})
}

function occurrenceKey(occurrence: AgentPromptSubmissionOccurrence): string {
return `${occurrence.streamId}\0${occurrence.sequence}`
}

/** Assign each hook occurrence to at most one pending send. */
export function assignMobileNativeChatPromptSubmissions(
checks: readonly MobileNativeChatDeliveryCheck[],
occurrences: readonly AgentPromptSubmissionOccurrence[]
): Map<string, AgentPromptSubmissionOccurrence> {
const assignments = new Map<string, AgentPromptSubmissionOccurrence>()
const claimed = new Set<string>()
for (const check of checks) {
if (check.acknowledgedBy) {
assignments.set(check.pendingId, check.acknowledgedBy)
claimed.add(occurrenceKey(check.acknowledgedBy))
}
}
for (const check of checks) {
if (check.acknowledgedBy) {
continue
}
const occurrence = occurrences.find(
(item) =>
!claimed.has(occurrenceKey(item)) && mobileNativeChatPromptWasAcknowledged(check, [item])
)
if (occurrence) {
assignments.set(check.pendingId, occurrence)
claimed.add(occurrenceKey(occurrence))
}
}
return assignments
}
48 changes: 48 additions & 0 deletions mobile/src/session/mobile-native-chat-view-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,54 @@ export const styles = StyleSheet.create({
fontSize: typography.metaSize,
fontWeight: '600'
},
deliveryRecovery: {
gap: spacing.sm,
marginHorizontal: spacing.md,
marginBottom: spacing.sm,
padding: spacing.md,
borderRadius: 12,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgRaised
},
deliveryRecoveryHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm
},
deliveryRecoveryIcon: {
width: 34,
height: 34,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
backgroundColor: colors.bgPanel
},
deliveryRecoveryCopy: {
flex: 1,
gap: 2
},
deliveryRecoveryTitle: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '700'
},
deliveryRecoveryDescription: {
color: colors.textMuted,
fontSize: typography.metaSize,
lineHeight: 16
},
deliveryRecoveryButton: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderRadius: 8,
backgroundColor: colors.surfaceBright
},
deliveryRecoveryButtonText: {
color: colors.bgBase,
fontSize: typography.metaSize,
fontWeight: '700'
},
chromeToggle: {
flexDirection: 'row',
alignItems: 'center',
Expand Down
Loading
Loading