mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
fix(mobile-native-chat): supply a boundary only to a text-bearing send
An image echo reconciles by counting turns AFTER its tail and has no other retirement path, so the tail supplied from a read that already carried its own echo excluded the very row it was waiting for: the "Queued" photo bubble stuck for the life of the session and the transcript row rendered as bare marker text with no photo. A regression against main, and against the earlier revision of this fix that pinned only captioned echoes. The glue matcher is the only consumer a supplied tail helps. Everything that reconciles relative to its own tail keeps whatever it captured.
This commit is contained in:
@@ -141,15 +141,24 @@ describe('rebaseMobileNativeChatPendingBaselines', () => {
|
||||
expect(rebased[0]?.baselineResolved).toBe(true)
|
||||
})
|
||||
|
||||
// A null tail counts from the top of the transcript, so an image turn already
|
||||
// in this read would claim the send and take the user's fresh photo with it.
|
||||
it('pins a caption-less image echo that captured no tail at all', () => {
|
||||
// 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(['m2', 'm2'])
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,10 +24,13 @@ import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pendin
|
||||
* the host while the send time comes from the phone. Counting it as history puts
|
||||
* the ordinal one past anything the transcript can supply.
|
||||
*
|
||||
* Which is why a CAPTIONED image echo is left alone entirely: it binds its
|
||||
* preview by an ordinal counted over the whole transcript, so its tail and its
|
||||
* ordinal have to describe the same read. Supplying one without the other leaves
|
||||
* it matching nothing, forever. Everything else matches relative to its tail.
|
||||
* 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[],
|
||||
@@ -41,9 +44,9 @@ export function rebaseMobileNativeChatPendingBaselines(
|
||||
if (item.baselineResolved) {
|
||||
return item
|
||||
}
|
||||
const bindsByAbsoluteOrdinal =
|
||||
Boolean(item.images?.length) && normalizeReconcileText(item.text) !== ''
|
||||
return item.baselineTailMessageId !== null || bindsByAbsoluteOrdinal
|
||||
const reconcilesAgainstItsOwnTail =
|
||||
Boolean(item.images?.length) || normalizeReconcileText(item.text) === ''
|
||||
return item.baselineTailMessageId !== null || reconcilesAgainstItsOwnTail
|
||||
? { ...item, baselineResolved: true }
|
||||
: { ...item, baselineResolved: true, baselineTailMessageId }
|
||||
})
|
||||
|
||||
@@ -292,6 +292,24 @@ describe('useMobileNativeChatDrafts glued pending sends', () => {
|
||||
// A send held here would sit as a live segment at the head of its run, so
|
||||
// the cursor could never reach a later pair — the stuck bubble would take
|
||||
// the whole feature down with it for the rest of the session.
|
||||
// An image entry retires ONLY by binding its preview, so a supplied tail
|
||||
// that excluded its own echo would leave the bubble — and the photo — stuck
|
||||
// for the life of the session.
|
||||
it('retires a photo whose image echo arrives with the read', async () => {
|
||||
await mount([], true)
|
||||
act(() => {
|
||||
const origin = state?.captureSendOrigin('')
|
||||
if (origin) {
|
||||
state?.acceptSend(origin, '', ['file:///new.png'])
|
||||
}
|
||||
})
|
||||
|
||||
const withEcho = [assistantTurn('m1', 'ready', 1000), imageTurn('m2', '/tmp/new.png', 5000)]
|
||||
await update(withEcho)
|
||||
expect(state?.pending).toEqual([])
|
||||
expect(state?.imagePreviewsByMessageId).toEqual({ m2: ['file:///new.png'] })
|
||||
})
|
||||
|
||||
it('leaves a later pair able to glue after the late read resolved the first send', async () => {
|
||||
await mount([], true)
|
||||
act(() => send('run the tests'))
|
||||
@@ -325,23 +343,6 @@ describe('useMobileNativeChatDrafts glued pending sends', () => {
|
||||
expect(renderedPendingTexts(olderIdentical, state)).toEqual(['fix the', 'bug'])
|
||||
})
|
||||
|
||||
// The preview pass runs before the rebase, so an entry with no boundary
|
||||
// would bind the user's fresh photo to whatever image turn the read carries.
|
||||
it('does not bind a photo sent before the read to an image turn already in it', async () => {
|
||||
await mount([], false, false)
|
||||
act(() => {
|
||||
const origin = state?.captureSendOrigin('')
|
||||
if (origin) {
|
||||
state?.acceptSend(origin, '', ['file:///new.png'])
|
||||
}
|
||||
})
|
||||
|
||||
const olderImage = [imageTurn('m1', '/old/photo.png', 1000)]
|
||||
await update(olderImage)
|
||||
expect(state?.imagePreviewsByMessageId).toEqual({})
|
||||
expect(state?.pending.map((item) => item.images)).toEqual([['file:///new.png']])
|
||||
})
|
||||
|
||||
it('still retires them once their own glued row lands', async () => {
|
||||
await mount([], false, false)
|
||||
rapidSend('fix the', 'bug')
|
||||
|
||||
Reference in New Issue
Block a user