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
8 changes: 5 additions & 3 deletions mobile/src/session/MobileNativeChatComposer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ describe('MobileNativeChatComposer', () => {

await act(async () => sendButton().props.onPress())

expect(onSend).toHaveBeenCalledWith(' hello')
// Verbatim: the send seam trims for the wire, so a rejected send can hand
// back the draft byte-for-byte (#14819).
expect(onSend).toHaveBeenCalledWith(' hello ')
expect(onChangeText).not.toHaveBeenCalled()
})

Expand Down Expand Up @@ -120,7 +122,7 @@ describe('MobileNativeChatComposer', () => {
)
})
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith(' /clear is prose')
expect(onSend).toHaveBeenCalledWith(' /clear is prose ')
})

it('locks the option pickers while a composer send is in flight', async () => {
Expand Down Expand Up @@ -219,7 +221,7 @@ describe('MobileNativeChatComposer', () => {

await act(async () => sendButton().props.onPress())

expect(onSend).toHaveBeenCalledWith(' hello')
expect(onSend).toHaveBeenCalledWith(' hello ')
expect(onChangeText).not.toHaveBeenCalled()
})

Expand Down
4 changes: 3 additions & 1 deletion mobile/src/session/MobileNativeChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ export function MobileNativeChatComposer({
sendingRef.current = true
setSending(true)
try {
const accepted = await onSend(value.trimEnd())
// Raw, not trimmed: the send seam owns the wire trim, and a rejection has
// to hand the user back exactly what they typed (#14819).
const accepted = await onSend(value)
if (accepted) {
setCursor(0)
}
Expand Down
164 changes: 164 additions & 0 deletions mobile/src/session/mobile-native-chat-pending-baseline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline'
import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo'

function userTurn(id: string, text: string, timestamp: number): NativeChatMessage {
return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
}

function assistantTurn(id: string, text: string, timestamp: number): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text }],
timestamp,
source: 'transcript'
}
}

function unresolved(
id: string,
text: string,
expectedOccurrence = 1
): MobileNativeChatPendingMessage {
return { id, text, expectedOccurrence, baselineTailMessageId: null, baselineResolved: false }
}

describe('rebaseMobileNativeChatPendingBaselines', () => {
const history = [userTurn('m1', 'run the tests', 1000), assistantTurn('m2', 'passed', 1100)]

it('returns the same array when every baseline is already resolved', () => {
const pending: MobileNativeChatPendingMessage[] = [
{
id: 'p1',
text: 'hi',
expectedOccurrence: 1,
baselineTailMessageId: 'm2',
baselineResolved: true
}
]
expect(rebaseMobileNativeChatPendingBaselines(history, pending)).toBe(pending)
})

it('gives an unresolved send the loaded tail as its glue boundary', () => {
expect(
rebaseMobileNativeChatPendingBaselines(history, [unresolved('p1', 'run the tests')])
).toEqual([
{
id: 'p1',
text: 'run the tests',
// NOT recounted against this read: the read may already carry this send's
// own echo, and counting it as history would strand the bubble forever.
expectedOccurrence: 1,
baselineTailMessageId: 'm2',
baselineResolved: true
}
])
})

it('pins to null when the authoritative transcript is empty', () => {
expect(rebaseMobileNativeChatPendingBaselines([], [unresolved('p1', 'hi')])).toEqual([
{
id: 'p1',
text: 'hi',
expectedOccurrence: 1,
baselineTailMessageId: null,
baselineResolved: true
}
])
})

it('keeps the ordinals the queue already assigned', () => {
expect(
rebaseMobileNativeChatPendingBaselines(history, [
unresolved('p1', 'run the tests', 1),
unresolved('p2', 'run the tests', 2)
]).map((item) => item.expectedOccurrence)
).toEqual([1, 2])
})

it('leaves already-resolved neighbours untouched', () => {
const resolved: MobileNativeChatPendingMessage = {
id: 'p1',
text: 'run the tests',
expectedOccurrence: 9,
baselineTailMessageId: 'm1',
baselineResolved: true
}
const rebased = rebaseMobileNativeChatPendingBaselines(history, [
resolved,
unresolved('p2', 'run the tests', 2)
])
expect(rebased[0]).toBe(resolved)
expect(rebased[1]).toEqual({
id: 'p2',
text: 'run the tests',
expectedOccurrence: 2,
baselineTailMessageId: 'm2',
baselineResolved: true
})
})

// An unsettled read still shows this session's own retained history, so a send
// made across a reconnect already owns a real boundary. Moving it onto the read
// that follows pushes it past the send's own echo and strands the bubble.
it('never moves a tail the send actually captured', () => {
const captured: MobileNativeChatPendingMessage = {
id: 'p1',
text: 'run the tests',
expectedOccurrence: 1,
baselineTailMessageId: 'm1',
baselineResolved: false
}
const rebased = rebaseMobileNativeChatPendingBaselines(history, [captured])
expect(rebased[0]?.baselineTailMessageId).toBe('m1')
expect(rebased[0]?.baselineResolved).toBe(true)
})

// An image echo counts image turns AFTER its tail, so moving a real tail onto
// this read would discard an echo the read already carries.
it('keeps a caption-less image echo on the tail it actually captured', () => {
const images = {
...unresolved('p1', ''),
baselineTailMessageId: 'm1',
images: ['file:///a.png']
}
const rebased = rebaseMobileNativeChatPendingBaselines(history, [images])
expect(rebased[0]?.baselineTailMessageId).toBe('m1')
expect(rebased[0]?.expectedOccurrence).toBe(1)
expect(rebased[0]?.baselineResolved).toBe(true)
})

// A captioned image echo binds by an ordinal counted over the whole transcript.
// Pinning a tail without recounting leaves it matching nothing, forever.
it('leaves a captioned image echo entirely alone', () => {
const rebased = rebaseMobileNativeChatPendingBaselines(history, [
{ ...unresolved('p1', 'here'), images: ['file:///a.png'] }
])
expect(rebased[0]?.baselineTailMessageId).toBe(null)
expect(rebased[0]?.expectedOccurrence).toBe(1)
expect(rebased[0]?.baselineResolved).toBe(true)
})

// A supplied tail would sit at or after this send's own echo, and an image
// echo counts turns AFTER its tail — so it would exclude the very row it is
// waiting for, and image entries have no other retirement path.
it('never supplies a tail to a caption-less image echo either', () => {
const rebased = rebaseMobileNativeChatPendingBaselines(history, [
{ ...unresolved('p1', ''), images: ['file:///a.png'] },
{ ...unresolved('p2', '', 2), images: ['file:///b.png'] }
])
expect(rebased.map((item) => item.baselineTailMessageId)).toEqual([null, null])
expect(rebased.map((item) => item.expectedOccurrence)).toEqual([1, 2])
expect(rebased.every((item) => item.baselineResolved)).toBe(true)
})

// Same rule for a text-empty entry with no images: it reconciles by counting
// image-source turns after its tail, so a supplied tail strands it too.
it('never supplies a tail to a text-empty entry', () => {
const rebased = rebaseMobileNativeChatPendingBaselines(history, [unresolved('p1', ' ')])
expect(rebased[0]?.baselineTailMessageId).toBe(null)
expect(rebased[0]?.baselineResolved).toBe(true)
})
})
53 changes: 53 additions & 0 deletions mobile/src/session/mobile-native-chat-pending-baseline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { normalizeReconcileText } from './mobile-native-chat-draft-reconcile'
import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo'

/**
* Give the sends that never saw a transcript the boundary they lack, on the
* first settled read.
*
* Such a send has no row to judge candidates against — which held it out of
* matching entirely, stranding it as a queued bubble and, worse, as a permanent
* glue barrier for its neighbours.
*
* Only a send that captured NO tail is pinned. An unsettled read still shows
* this session's own retained history (see `createNativeChatTranscriptRetention`
* — a reconnect or a failed read keeps the conversation on screen rather than
* blanking it), so a send made then already owns a correct boundary. Moving it
* onto this read would push it past the send's own echo and strand the bubble
* for good.
*
* The ordinal is deliberately NOT recounted. The read can already carry the
* send's own echo — a re-subscribe returns whatever exists now — and nothing
* local separates that echo from an identical older prompt: the transport writes
* keystrokes into a TUI and carries no message id, and row timestamps come from
* the host while the send time comes from the phone. Counting it as history puts
* the ordinal one past anything the transcript can supply.
*
* And only a TEXT-bearing send is given one. The glue matcher is the sole
* consumer that a supplied tail helps; every other one is harmed by it. An image
* echo reconciles by counting turns AFTER its tail, so a tail taken from a read
* that already carries its echo excludes that echo and the bubble can never
* retire — image entries have no other retirement path. A captioned one is worse
* still: it binds by an ordinal counted over the whole transcript, so a tail
* without a matching recount leaves it claiming nothing at all.
*/
export function rebaseMobileNativeChatPendingBaselines(
messages: readonly NativeChatMessage[],
current: MobileNativeChatPendingMessage[]
): MobileNativeChatPendingMessage[] {
if (current.every((item) => item.baselineResolved)) {
return current
}
const baselineTailMessageId = messages.at(-1)?.id ?? null
return current.map((item) => {
if (item.baselineResolved) {
return item
}
const reconcilesAgainstItsOwnTail =
Boolean(item.images?.length) || normalizeReconcileText(item.text) === ''
return item.baselineTailMessageId !== null || reconcilesAgainstItsOwnTail
? { ...item, baselineResolved: true }
: { ...item, baselineResolved: true, baselineTailMessageId }
})
}
7 changes: 7 additions & 0 deletions mobile/src/session/mobile-native-chat-pending-echo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ export type MobileNativeChatPendingMessage = {
/** Local preview URIs carried by the send for its optimistic echo. */
images?: string[]
baselineTailMessageId: string | null
/** Whether the transcript this baseline was captured from was already this
* session's own history. A send issued mid-hydration is captured unresolved
* and rebased onto the first authoritative read instead of reconciling
* against rows that may belong to another tab. */
baselineResolved: boolean
}

export type MobileNativeChatSendOrigin = {
Expand All @@ -13,6 +18,7 @@ export type MobileNativeChatSendOrigin = {
normalizedText: string
baselineOccurrences: number
baselineTailMessageId: string | null
baselineResolved: boolean
}

type PendingByKey = Record<string, MobileNativeChatPendingMessage[]>
Expand Down Expand Up @@ -56,6 +62,7 @@ export function appendMobileNativeChatPending(
? expectedImageEchoOrdinal
: origin.baselineOccurrences + earlierOutstanding + 1,
baselineTailMessageId: origin.baselineTailMessageId,
baselineResolved: origin.baselineResolved,
...(images?.length ? { images } : {})
}
]
Expand Down
Loading