Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
6289065
fix(native-chat): a message the host recorded stays in the chat when …
brennanb2025 Oct 3, 2026
1b0f2a7
fix(native-chat): a not-delivered message is said once, on its row, o…
brennanb2025 Oct 3, 2026
64ab6f4
test(native-chat): a Retry of a recorded rejection leaves the not-sen…
brennanb2025 Oct 3, 2026
0b9a8a0
revert(native-chat): keep the older-host recovered-send predicate out…
brennanb2025 Oct 3, 2026
412ab12
fix(native-chat): the desktop words a not-sent message it holds no ou…
brennanb2025 Oct 3, 2026
c3ce6bc
fix(native-chat): a failed /compact or queued draft is said once, whe…
brennanb2025 Oct 3, 2026
71e4355
fix(native-chat): the rail stays lit while the reader is on a not-sen…
brennanb2025 Oct 3, 2026
d9e0605
docs(native-chat): say a not-sent row lights no tick of its own
brennanb2025 Oct 3, 2026
87f2c0f
fix(native-chat): read an older host's restart answer as recovered wh…
brennanb2025 Oct 4, 2026
4f50e40
fix(mobile): never hand back a message the host recorded and then rej…
brennanb2025 Oct 4, 2026
e6c5c84
fix(native-chat): a not-sent line reads muted, not as an error
brennanb2025 Oct 4, 2026
94c446a
fix(native-chat): a rejected /compact stays in the chat, said once
brennanb2025 Oct 4, 2026
7ebec5b
perf(native-chat): read only status rows when finding command result …
brennanb2025 Oct 4, 2026
f6784ad
fix(mobile): resend the same text as a new message when its kept id w…
brennanb2025 Oct 4, 2026
f6c1995
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-rec…
brennanb2025 Oct 4, 2026
30f5505
Merge origin/main into brennanb2025/chat-recorded-outcomes-from-journal
brennanb2025 Oct 4, 2026
6cb9adb
refactor(native-chat): one shared wording for a recorded rejection's row
brennanb2025 Oct 4, 2026
24c642c
fix(native-chat): a rejected /compact stays in the chat as not sent, …
brennanb2025 Oct 4, 2026
6629b3e
fix(mobile): show a message the host recorded and then rejected in pl…
brennanb2025 Oct 4, 2026
4f9c961
fix(native-chat): a not-sent line reads muted on main's notices too
brennanb2025 Oct 4, 2026
b0febd9
fix(native-chat): a not-sent message takes no rail tick, matching the…
brennanb2025 Oct 4, 2026
c9ab048
fix(native-chat): return a not-sent row at its journal place, so the …
brennanb2025 Oct 4, 2026
bb88899
fix(mobile): a resend of a not-sent message's text ends as one bubble
brennanb2025 Oct 4, 2026
d5d3b9c
fix(native-chat): keep a command's reply unless its row already shows…
brennanb2025 Oct 4, 2026
5474933
fix(native-chat): a send the host couldn't confirm keeps the doubt color
brennanb2025 Oct 4, 2026
429c37a
docs(native-chat): say the outline leaves a rejected message out, as …
brennanb2025 Oct 4, 2026
a7300f8
fix(mobile): a phone send's own not-sent row settles it
brennanb2025 Oct 4, 2026
b62240a
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-rec…
brennanb2025 Oct 4, 2026
3bff2f5
test(native-chat): give the delivery line's tone its own test file
brennanb2025 Oct 4, 2026
e6a1ac0
test(native-chat): keep the doubt case in the tone test a real doubt
brennanb2025 Oct 4, 2026
5521ec9
refactor(mobile): read a send's not-sent snapshot as a Set
brennanb2025 Oct 4, 2026
5ec6369
fix(native-chat): keep a not-sent message when its text is sent again
brennanb2025 Oct 5, 2026
a6a5cb7
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-rec…
brennanb2025 Oct 6, 2026
9adf800
refactor(mobile): draw the chat list's load-earlier header from its o…
brennanb2025 Oct 6, 2026
5231b07
fix(mobile-chat): a captioned image send's placeholder retires on its…
brennanb2025 Oct 6, 2026
dbc1fec
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-rec…
brennanb2025 Oct 6, 2026
0fd5735
fix(native-chat): keep the rail's tick lit below a not-sent message
brennanb2025 Oct 6, 2026
a94ab96
test(native-chat): pin the lit rail tick for QA's failed-start layout
brennanb2025 Oct 6, 2026
9bf8c6f
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-rec…
brennanb2025 Oct 6, 2026
1ec6958
test(native-chat): drop the list's removed fontScale prop from the re…
brennanb2025 Oct 6, 2026
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
31 changes: 31 additions & 0 deletions mobile/src/session/MobileNativeChatMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ describe('MobileNativeChatMessage', () => {
workedSeconds: number | null
} | null
onToggleTurn?: () => void
unsentNotice?: string
} = {}
): ReactTestRenderer {
act(() => {
Expand Down Expand Up @@ -146,6 +147,36 @@ describe('MobileNativeChatMessage', () => {
expect(texts.some((text) => text.includes('/tmp/host.png'))).toBe(true)
})

it('says under a message the host recorded but never delivered why it was not sent', () => {
const tree = render(
{ ...userMessage([{ type: 'text', text: 'hello' }]), unsent: true },
{ unsentNotice: "Orca couldn't reach the agent. Your message was not sent." }
)
expect(textIn(tree.root)).toEqual([
'hello',
"Orca couldn't reach the agent. Your message was not sent."
])
})

it('still says it was not sent when no words for it are loaded', () => {
const tree = render({ ...userMessage([{ type: 'text', text: 'hello' }]), unsent: true })
expect(textIn(tree.root)).toEqual(['hello', 'Your message was not sent.'])
})

it('says it was not sent as a muted label, not an error', () => {
const tree = render({ ...userMessage([{ type: 'text', text: 'hello' }]), unsent: true })
const [label] = tree.root.findAll(
(node) =>
node.props.style !== undefined && node.children.join('') === 'Your message was not sent.'
)
expect(label?.props.style).toMatchObject({ color: colors.textMuted })
})

it('says nothing more under a delivered message', () => {
const tree = render(userMessage([{ type: 'text', text: 'hello' }]))
expect(textIn(tree.root)).toEqual(['hello'])
})

it('makes user message text selectable', () => {
const tree = render(userMessage([{ type: 'text', text: 'Prompt I typed' }]))
const text = tree.root
Expand Down
11 changes: 10 additions & 1 deletion mobile/src/session/MobileNativeChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ToolRun } from './MobileNativeChatToolRun'
import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status'
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../src/shared/agent-session-write-notice-copy'

function Prose({
block,
Expand Down Expand Up @@ -118,7 +119,8 @@ function MobileNativeChatMessageImpl({
turnKey,
onToggleTurn,
activeTurnIsWorking,
structuredActivityUi = false
structuredActivityUi = false,
unsentNotice
}: {
message: NativeChatMessage
toolsExpanded?: boolean
Expand All @@ -139,6 +141,8 @@ function MobileNativeChatMessageImpl({
activeTurnIsWorking?: boolean
/** Structured lane only: live tool progress plus the turn-status disclosure. */
structuredActivityUi?: boolean
/** Why the host did not deliver this message, when it is shown as not sent. */
unsentNotice?: string
}): React.JSX.Element {
const isUser = message.role === 'user'
const isReasoning = message.role === 'reasoning'
Expand Down Expand Up @@ -207,6 +211,11 @@ function MobileNativeChatMessageImpl({
/>
) : null}
</Content>
{isUser && message.unsent === true ? (
<NativeText style={styles.unsentLabel}>
{unsentNotice ?? AGENT_SESSION_WRITE_NOTICE_COPY.notDoneSend}
</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 @@ -40,6 +41,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 @@ -283,6 +285,10 @@ export function MobileNativeChatView({
const hasPendingStructuredInteraction =
structuredActivityUi && (ask != null || permission != null || question != null)

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

const liveStatus =
Expand Down Expand Up @@ -349,21 +356,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
46 changes: 37 additions & 9 deletions mobile/src/session/mobile-native-chat-draft-reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,37 @@ export type UnconfirmedSend = {
baselineTailMessageId: string | null
/** Queued-draft cards on screen at send time; see `findQueuedUnconfirmedSends`. */
baselineQueuedMessageIds?: readonly string[]
/** Rows shown as not sent at send time; see `sendBaselineUnsentMessageIds`. */
baselineUnsentMessageIds?: readonly string[]
deadline: ReturnType<typeof setTimeout> | null
}

export function normalizedUserText(message: NativeChatMessage): string | null {
return normalizedNativeChatUserMessageText(message)
/** Rows shown as not sent when a send went out. Only these can't be its echo: one that appears
* later is the send's own row, settled as not sent. The count, image and unconfirmed matchers
* all apply this. */
export function sendBaselineUnsentMessageIds(messages: readonly NativeChatMessage[]): string[] {
return messages.filter((message) => message.unsent === true).map((message) => message.id)
}

/** The row a send's echo must land after: the newest one, past any shown as not sent, which are in
* the send's baseline and so never its echo. */
export function sendBaselineTailMessageId(messages: readonly NativeChatMessage[]): string | null {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index]?.unsent !== true) {
return messages[index]?.id ?? null
}
}
return null
}

/** At send time: every row shown as not sent is then in the send's baseline, so none is counted. */
export function countUserTextOccurrences(
messages: readonly NativeChatMessage[],
text: string
): number {
let count = 0
for (const message of messages) {
if (normalizedUserText(message) === text) {
if (message.unsent !== true && normalizedNativeChatUserMessageText(message) === text) {
count++
}
}
Expand Down Expand Up @@ -63,6 +80,8 @@ export type PendingImagePreviewEcho = {
images?: string[]
expectedOccurrence: number
baselineTailMessageId: string | null
/** See `sendBaselineUnsentMessageIds`. */
baselineUnsentMessageIds?: readonly string[]
}

export type LandedImagePreviewEcho = {
Expand Down Expand Up @@ -177,12 +196,13 @@ export function findLandedImagePreviewEchoes(
continue
}
const targetText = normalizeNativeChatUserText(entry.text)
const baselineUnsent = new Set(entry.baselineUnsentMessageIds)
const candidates = normalized.filter((message) => {
if (message.role !== 'user') {
if (message.role !== 'user' || (message.unsent === true && baselineUnsent.has(message.id))) {
return false
}
if (targetText) {
const text = normalizedUserText(message)
const text = normalizedNativeChatUserMessageText(message)
if (text === null) {
return false
}
Expand Down Expand Up @@ -257,15 +277,17 @@ export function findLandedUnconfirmedSends(
// (`[Image: source: …]` or no text) keys under '' so an empty-text send can
// claim it.
const messageIndexById = new Map<string, number>()
const userMessagesByText = new Map<string, Array<{ id: string; index: number }>>()
const userMessagesByText = new Map<string, { id: string; index: number; unsent: boolean }[]>()
for (const [index, message] of messages.entries()) {
messageIndexById.set(message.id, index)
if (message.role !== 'user') {
continue
}
const key = isImageSourceUserTurn(message) ? '' : (normalizedUserText(message) ?? '')
const key = isImageSourceUserTurn(message)
? ''
: (normalizedNativeChatUserMessageText(message) ?? '')
const current = userMessagesByText.get(key) ?? []
current.push({ id: message.id, index })
current.push({ id: message.id, index, unsent: message.unsent === true })
userMessagesByText.set(key, current)
}

Expand All @@ -278,9 +300,15 @@ export function findLandedUnconfirmedSends(
if (tailIndex === undefined) {
continue
}
const baselineUnsent = new Set(entry.baselineUnsentMessageIds)
const echo = userMessagesByText
.get(entry.normalizedText)
?.find((message) => message.index > tailIndex && !claimedMessageIds.has(message.id))
?.find(
(message) =>
message.index > tailIndex &&
!claimedMessageIds.has(message.id) &&
!(message.unsent && baselineUnsent.has(message.id))
)
if (echo) {
claimedMessageIds.add(echo.id)
landed.push(entry)
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>
)
}
5 changes: 5 additions & 0 deletions mobile/src/session/mobile-native-chat-message-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ export const styles = StyleSheet.create({
reasoning: {
opacity: 0.7
},
unsentLabel: {
marginTop: spacing.xs,
color: colors.textMuted,
fontSize: typography.metaSize
},
toolRun: {
marginTop: spacing.xs
},
Expand Down
Loading
Loading