Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions mobile/src/session/MobileNativeChatMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ describe('MobileNativeChatMessage', () => {
} | null
onToggleTurn?: () => void
reasoningIsLive?: boolean
unsentNotice?: string
} = {}
): ReactTestRenderer {
act(() => {
Expand Down Expand Up @@ -168,6 +169,41 @@ describe('MobileNativeChatMessage', () => {
expect(rows.some((row) => row.props.style.includes(styles.rowUser))).toBe(false)
})

describe('a message the host recorded and did not deliver', () => {
const notSent = { ...userMessage([{ type: 'text', text: 'fix it' }]), unsent: true as const }
const lineIn = (tree: ReactTestRenderer) =>
tree.root.findAll(
(node) => String(node.type) === 'Text' && node.props.style === styles.unsentNotice
)

it('says why under the bubble, and draws no line without a reason, as the desktop', () => {
expect(textIn(render(notSent, { unsentNotice: 'Claude is not signed in.' }).root)).toEqual([
'fix it',
'Claude is not signed in.'
])
expect(lineIn(render(notSent))).toHaveLength(0)
})

it("draws the line under another agent's message too", () => {
const fromAgent: NativeChatMessage = {
...notSent,
from: {
kind: 'agent',
senders: [
{
party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null },
name: 'Coder'
}
],
orchestration: null
}
}
expect(
lineIn(render(fromAgent, { unsentNotice: 'Your message was not sent.' }))
).toHaveLength(1)
})
})

it('preserves an ordinary assistant answer without interpreting its text as a host notice', () => {
const tree = render(toolMessage([{ type: 'text', text: 'provider fallback' }]))
expect(tree.root.find((node) => String(node.type) === 'MobileMarkdown').props.content).toBe(
Expand Down
4 changes: 4 additions & 0 deletions mobile/src/session/MobileNativeChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ function MobileNativeChatMessageImpl({
reasoningIsLive = false,
reasoningExpanded,
onToggleReasoning,
unsentNotice,
subagentGroupsOpen,
onToggleSubagentGroup
}: {
Expand Down Expand Up @@ -175,6 +176,8 @@ function MobileNativeChatMessageImpl({
/** The transcript-held disclosure of a reasoning row; one stable handler takes its key. */
reasoningExpanded?: boolean
onToggleReasoning?: (key: string) => void
/** Why the host did not deliver this message, when it is drawn as not sent. */
unsentNotice?: string
/** The roster groups the reader opened, held by the transcript; only a roster row gets it. */
subagentGroupsOpen?: ReadonlySet<string>
onToggleSubagentGroup?: (groupId: string) => void
Expand Down Expand Up @@ -308,6 +311,7 @@ function MobileNativeChatMessageImpl({
/>
) : null}
</Content>
{unsentNotice ? <NativeText style={styles.unsentNotice}>{unsentNotice}</NativeText> : null}
</View>
{actionsOpen ? (
<MobileNativeChatMessageActionsSheet
Expand Down
25 changes: 9 additions & 16 deletions mobile/src/session/MobileNativeChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-view-styles'
import { mobileNativeChatListFooter } from './mobile-native-chat-list-footer'
import { mobileNativeChatListHeader } from './mobile-native-chat-list-header'
import {
buildMobileNativeChatTransientData,
mobileNativeChatEmptyState,
Expand All @@ -44,6 +45,7 @@ import type { MobileChatPermission } from './mobile-native-chat-permission'
import type { MobileChatQuestion } from './mobile-native-chat-question'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
import { mobileNativeChatUnsentNotices } from './mobile-native-chat-unsent-notices'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'

/** Why the composer input is locked: the transport is disconnected, or the
Expand Down Expand Up @@ -297,6 +299,10 @@ export function MobileNativeChatView({
scopeKey: sendSurfaceId
})

const unsentNotices = useMemo(
() => mobileNativeChatUnsentNotices(turnJournal, agent),
[turnJournal, agent]
)
const renderItem = useCallback(
({ item, index }: { item: NativeChatMessage; index: number }) => (
<MobileNativeChatMessage
Expand All @@ -306,10 +312,11 @@ export function MobileNativeChatView({
onOpenFile={onOpenFile}
structuredActivityUi={structuredActivityUi}
onToggleTurn={turns.onToggleTurn}
unsentNotice={item.unsent ? unsentNotices.get(item.id) : undefined}
{...turns.resolveRow(index, item)}
/>
),
[toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns]
[toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns, unsentNotices]
)

const liveStatus = turns.liveLine ? (
Expand Down Expand Up @@ -368,21 +375,7 @@ export function MobileNativeChatView({
scrollEventThrottle={32}
onContentSizeChange={pinToTailAfterContentResize}
onLayout={pinToTail}
ListHeaderComponent={
hasMore ? (
<Pressable
style={styles.loadEarlier}
onPress={loadEarlier}
disabled={loadingEarlier}
>
{loadingEarlier ? (
<ActivityIndicator size="small" color={colors.textMuted} />
) : (
<Text style={styles.loadEarlierText}>Load earlier messages</Text>
)}
</Pressable>
) : null
}
ListHeaderComponent={mobileNativeChatListHeader(hasMore, loadingEarlier, loadEarlier)}
ListFooterComponent={mobileNativeChatListFooter(
liveStatus,
turns.waitingRows,
Expand Down
41 changes: 7 additions & 34 deletions mobile/src/session/mobile-native-chat-draft-reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ export type UnconfirmedSend = {
text: string
normalizedText: string
baselineTailMessageId: string | null
/** Queued-draft cards on screen at send time; see `findQueuedUnconfirmedSends`. */
baselineQueuedMessageIds?: readonly string[]
deadline: ReturnType<typeof setTimeout> | null
}

Expand Down Expand Up @@ -65,22 +63,24 @@ export type PendingImagePreviewEcho = {
baselineTailMessageId: string | null
}

export type LandedImagePreviewEcho = {
pendingId: string
/** This phone's photos for the row they were sent in. */
export type SentImagePreview = {
messageId: string
images: string[]
}

export type LandedImagePreviewEcho = SentImagePreview & { pendingId: string }

const SENT_IMAGE_PREVIEW_LIMIT = 32
const SENT_IMAGE_PREVIEW_SESSION_LIMIT = 8

export function mergeLandedImagePreviewEchoes(
export function mergeSentImagePreviews(
previous: Record<string, Record<string, string[]>>,
sessionKey: string,
landed: readonly LandedImagePreviewEcho[]
previews: readonly SentImagePreview[]
): Record<string, Record<string, string[]>> {
const entries = Object.entries(previous[sessionKey] ?? {})
for (const preview of landed) {
for (const preview of previews) {
const existingIndex = entries.findIndex(([messageId]) => messageId === preview.messageId)
if (existingIndex !== -1) {
entries.splice(existingIndex, 1)
Expand Down Expand Up @@ -221,33 +221,6 @@ export function findLandedImagePreviewEchoes(
return landed
}

/** Holds the host already took as queued-draft cards: a card not on screen at
* send time with the send's text. It will reach the transcript only when it
* drains, which can be long past the unconfirmed deadline. */
export function findQueuedUnconfirmedSends(
cards: readonly { messageId: string; text: string }[],
entries: readonly UnconfirmedSend[]
): UnconfirmedSend[] {
if (cards.length === 0) {
return []
}
const claimed = new Set<string>()
const held: UnconfirmedSend[] = []
for (const entry of entries) {
const card = cards.find(
(candidate) =>
!claimed.has(candidate.messageId) &&
!(entry.baselineQueuedMessageIds ?? []).includes(candidate.messageId) &&
normalizeNativeChatUserText(candidate.text) === entry.normalizedText
)
if (card) {
claimed.add(card.messageId)
held.push(entry)
}
}
return held
}

export function findLandedUnconfirmedSends(
messages: readonly NativeChatMessage[],
entries: readonly UnconfirmedSend[]
Expand Down
24 changes: 24 additions & 0 deletions mobile/src/session/mobile-native-chat-list-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { ReactElement } from 'react'
import { ActivityIndicator, Pressable, Text } from 'react-native'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-view-styles'

/** The "Load earlier messages" control, while older history remains. */
export function mobileNativeChatListHeader(
hasMore: boolean | undefined,
loadingEarlier: boolean | undefined,
loadEarlier: () => void
): ReactElement | null {
if (!hasMore) {
return null
}
return (
<Pressable style={styles.loadEarlier} onPress={loadEarlier} disabled={loadingEarlier}>
{loadingEarlier ? (
<ActivityIndicator size="small" color={colors.textMuted} />
) : (
<Text style={styles.loadEarlierText}>Load earlier messages</Text>
)}
</Pressable>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,7 @@ function phoneTranscript(state: StructuredAgentSessionState) {
})
return {
items,
messages: projectStructuredAgentSessionMessages(items, [], state.submissions, {
rejectedInPlace: false
})
messages: projectStructuredAgentSessionMessages(items, [], state.submissions)
}
}

Expand Down
6 changes: 6 additions & 0 deletions mobile/src/session/mobile-native-chat-message-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ export const styles = StyleSheet.create({
color: colors.textMuted,
fontSize: typography.metaSize
},
unsentNotice: {
maxWidth: '88%',
marginTop: spacing.xs,
color: colors.statusRed,
fontSize: typography.metaSize
},
toolRun: {
marginTop: spacing.xs
},
Expand Down
3 changes: 0 additions & 3 deletions mobile/src/session/mobile-native-chat-pending-echo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,6 @@ export type MobileNativeChatSendOrigin = {
baselineOccurrences: number
baselineTailMessageId: string | null
baselineResolved: boolean
/** Queued-draft cards already on screen at send time, so an earlier identical
* card cannot confirm this send. Structured lane, on any host that publishes cards. */
baselineQueuedMessageIds?: readonly string[]
}

type PendingByKey = Record<string, MobileNativeChatPendingMessage[]>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,7 @@ describe('a send a Stop took back before the agent started it, on the phone', ()
})

it('stays where it was sent, with the stop row after it and no turn status of its own', () => {
const messages = projectStructuredAgentSessionMessages(items, [], submissions, {
rejectedInPlace: false
})
const messages = projectStructuredAgentSessionMessages(items, [], submissions)
expect(messages.map((message) => message.id)).toEqual([
agentJournalSubmissionKey('warm-up'),
't1-answer',
Expand Down
88 changes: 88 additions & 0 deletions mobile/src/session/mobile-native-chat-unsent-notices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import {
agentJournalItemKey,
agentJournalSubmissionKey
} from '../../../src/shared/agent-session-journal-item-key'
import type { AgentSessionFailureFact } from '../../../src/shared/agent-session-failure'
import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../src/shared/agent-session-journal-types'
import { structuredAgentSessionStartFailureRowIdentity } from '../../../src/shared/structured-agent-session-start-failure-row-key'
import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection'
import { mobileNativeChatUnsentNotices } from './mobile-native-chat-unsent-notices'

function submission(
clientMessageId: string,
patch: Partial<AgentJournalSubmission> = {}
): AgentJournalSubmission {
return {
clientMessageId,
fence: 1,
payloadFingerprint: clientMessageId,
dispatchState: 'rejected',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: 2,
...patch
}
}

const startFailed: AgentSessionFailureFact = {
kind: 'startFailed',
refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } }
}

const startRow: AgentJournalRenderItem = {
itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('gen')),
revision: 1,
sequence: 1,
observedAt: 1,
body: {
kind: 'status',
tone: 'error',
...agentSessionFailureWords(startFailed, { agentName: 'Claude', surface: 'row' })
}
}

describe("the phone's line under a not-sent message", () => {
it("words each rejected message as the desktop does, naming the chat's agent", () => {
const notices = mobileNativeChatUnsentNotices(
{
items: [],
submissions: [
submission('lost', { rejection: startFailed }),
submission('sent', { dispatchState: 'accepted' })
]
},
'claude'
)
expect(Object.fromEntries(notices)).toEqual({
[agentJournalSubmissionKey('lost')]: "Claude couldn't start. Start a new chat to continue."
})
})

it('says only that it was not sent when a loaded row already says why', () => {
const notices = mobileNativeChatUnsentNotices(
{ items: [startRow], submissions: [submission('lost', { rejection: startFailed })] },
'claude'
)
expect(notices.get(agentJournalSubmissionKey('lost'))).toBe('Your message was not sent.')
})

it('gives one shared empty map when no send failed, a Stop taking one back included', () => {
const accepted = {
items: [],
submissions: [
submission('sent', { dispatchState: 'accepted' }),
submission('stopped', { reason: DISPATCH_REJECTED_CANCELLED })
]
}
expect(mobileNativeChatUnsentNotices(accepted, 'claude').size).toBe(0)
expect(mobileNativeChatUnsentNotices(accepted, 'claude')).toBe(
mobileNativeChatUnsentNotices(null, 'claude')
)
})
})
Loading
Loading