Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
6521c74
fix(native-chat): preserve literal image markers
brennanb2025 Aug 14, 2026
f04cfaf
fix(native-chat): key optimistic echoes on literal image markers
brennanb2025 Aug 14, 2026
e2450ab
fix(native-chat): require complete image echo evidence
brennanb2025 Aug 15, 2026
40f4240
fix(mobile): isolate image echo occurrences by count
brennanb2025 Aug 15, 2026
ce409f5
fix(mobile): retire launch drafts on literal marker turns
brennanb2025 Aug 15, 2026
803ea59
fix(mobile): use array shorthand in draft reconciliation
brennanb2025 Aug 15, 2026
8e1a978
fix(native-chat): isolate literal marker match keys
brennanb2025 Aug 15, 2026
a776a86
fix(native-chat): separate literal and image echo identity
brennanb2025 Aug 15, 2026
0f74ea0
fix(native-chat): anchor marker stripping to a contiguous image-sourc…
brennanb2025 Aug 16, 2026
1689e2f
fix(native-chat): bound marker stripping by the run's image count
brennanb2025 Aug 16, 2026
ca9a22f
fix(native-chat): keep definition-shaped user lines out of Markdown's…
brennanb2025 Aug 16, 2026
4fa5a62
fix(native-chat): stop parsing user prose as link reference definitions
brennanb2025 Aug 17, 2026
2b0a4aa
refactor(native-chat): hoist the prose remark plugin list to module s…
brennanb2025 Aug 17, 2026
4cd9579
fix(native-chat): cover footnote definitions and correct the doc comment
brennanb2025 Aug 17, 2026
445ba46
fix(native-chat): stop reading an invisible image run as user-typed m…
brennanb2025 Aug 17, 2026
2719292
test(native-chat): pin the paging signal end-to-end through the live …
brennanb2025 Aug 17, 2026
c245f1c
fix(native-chat): budget the glue row by image blocks, not marker text
brennanb2025 Aug 17, 2026
ea4edc7
fix(native-chat): stop the preview strip double-spending the fold's b…
brennanb2025 Aug 17, 2026
0f93e95
fix(native-chat): keep image sends from blocking the glue prefix
brennanb2025 Aug 17, 2026
b489ca2
fix(native-chat): restore glue for marker-only echoes, drop gap matching
brennanb2025 Aug 17, 2026
82b6146
Merge origin/main into brennanb2025/sta-4363-literal-image-markers-fo…
brennanb2025 Aug 17, 2026
416a00b
fix(native-chat): make a textless send a glue barrier, not a skip
brennanb2025 Aug 17, 2026
dac5273
fix(native-chat): name the window head by id, not by list position
brennanb2025 Aug 17, 2026
d9d9d44
fix(native-chat): only let a textless send break a glue run it is inside
brennanb2025 Aug 17, 2026
f4fb51b
fix(native-chat): budget the mobile preview strip by what it appended
brennanb2025 Aug 17, 2026
bf2d56d
fix(native-chat): restore the earlier-history guard on the window-hea…
brennanb2025 Aug 17, 2026
a9c7b95
fix(native-chat): take the paging signal from the host, not from the …
brennanb2025 Aug 17, 2026
3508e54
test(native-chat): pin the runtime paging flag across the RPC hop
brennanb2025 Aug 17, 2026
b108359
fix(native-chat): gate the marker fold on the host's paging answer
brennanb2025 Aug 17, 2026
219829c
fix(mobile-native-chat): type-guard the host's paging answer
brennanb2025 Aug 17, 2026
2da1f54
fix(native-chat): keep the host's paging answer distinct from the inf…
brennanb2025 Aug 17, 2026
8d9d881
docs(mobile-native-chat): describe what the paging flag actually guar…
brennanb2025 Aug 17, 2026
d6ade84
fix(native-chat): let the IPC read result carry the paging flag in it…
brennanb2025 Aug 17, 2026
f150842
test(mobile-native-chat): drive the overlay fold from the confirmed p…
brennanb2025 Aug 17, 2026
41a81e0
test(native-chat): follow the snapshot frame's reported paging field
brennanb2025 Aug 17, 2026
c667553
fix(mobile-native-chat): let an echo the host left empty reconcile an…
brennanb2025 Aug 17, 2026
b3e65c5
fix(native-chat): stop one transcript row retiring two sends
brennanb2025 Aug 17, 2026
96bfcb4
fix(native-chat): require the window to keep growing before offering …
brennanb2025 Aug 17, 2026
a97878b
test(native-chat): follow the reported paging field into the web prel…
brennanb2025 Aug 17, 2026
e7dcb24
fix(mobile-native-chat): name the window head from the read, not the …
brennanb2025 Aug 17, 2026
13b9f82
test(mobile-native-chat): give the send-origin mock its real parameters
brennanb2025 Aug 17, 2026
5011bf0
fix(native-chat): count the photos a row shows, not the markers it kept
brennanb2025 Aug 17, 2026
6612c01
test(mobile-native-chat): cover the images argument the send hook for…
brennanb2025 Aug 17, 2026
575ac84
docs(mobile-native-chat): name the host clamp the message cap is coup…
brennanb2025 Aug 17, 2026
4d5711c
Revert "docs(mobile-native-chat): name the host clamp the message cap…
brennanb2025 Aug 17, 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
97 changes: 96 additions & 1 deletion mobile/src/session/MobileNativeChatOverlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,27 @@ type Tick = {
streamingText?: string
streamLive?: boolean
identity?: string
/** The load-earlier affordance, which may over-report on a window filled to
* exactly the limit. Deliberately NOT what the fold reads. */
hasMore?: boolean
/** The host's own paging answer. Only this may decide whether the oldest
* visible row is a window head that lost its image-source run — an inferred
* true here would erase an `[Image #n]` the user typed. */
earlierHistoryConfirmed?: boolean
/** The row the host answered about, while it is still on screen. */
windowHeadMessageId?: string
}

function overlayElement(tick: Tick): ReturnType<typeof createElement> {
const controller = {
showNativeChat: tick.show ?? true,
nativeChatSession: { messages: tick.messages ?? [], status: 'ready' },
nativeChatSession: {
messages: tick.messages ?? [],
status: 'ready',
hasMore: tick.hasMore ?? false,
earlierHistoryConfirmed: tick.earlierHistoryConfirmed ?? false,
windowHeadMessageId: tick.windowHeadMessageId
},
nativeChatAgent: 'claude',
nativeChatAgentWorking: tick.streamLive ?? false,
nativeChatStreamingText: tick.streamingText,
Expand Down Expand Up @@ -172,3 +187,83 @@ describe('MobileNativeChatOverlay streaming gate', () => {
expect(streaming()).toBeNull()
})
})

// STA-4363, mobile half. The window-head rule strips a head turn's `[Image #n]`
// run because the read window may have trimmed the source turns that vouch for
// it — true only while older history exists. Desktop derives the head from its
// read list, mobile from the merged list, so pin mobile's own derivation: a
// pure-function test of the fold cannot see this call site stop guarding it.
describe('MobileNativeChatOverlay window-head marker rule', () => {
function markerTurn(id: string, text: string, timestamp: number): NativeChatMessage {
return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
}

/** Mounts the overlay, reads the rows it handed the chat view, unmounts. Each
* case owns its renderer, so nothing leaks between them. Inferred rather than
* annotated: `react-test-renderer` ships no usable types here, so naming the
* handle in a union with null yields a redundant `any` constituent. */
async function foldedFor(tick: Tick): Promise<NativeChatMessage[]> {
const mounted = create(overlayElement(tick))
await act(async () => {})
const folded = mounted.root.findAll((node) => node.type === 'ChatView')[0].props
.folded as NativeChatMessage[]
mounted.unmount()
return folded
}

async function foldedById(earlierHistoryConfirmed: boolean): Promise<Map<string, unknown>> {
const folded = await foldedFor({
earlierHistoryConfirmed,
windowHeadMessageId: earlierHistoryConfirmed ? 'u-head' : undefined,
messages: [
markerTurn('u-head', '[Image #1] hello', 1),
markerTurn('u-tail', '[Image #2] bye', 2)
]
})
return new Map(folded.map((message) => [message.id, message.blocks]))
}

it('keeps the head turn’s literal markers when the window holds the whole conversation', async () => {
expect((await foldedById(false)).get('u-head')).toEqual([
{ type: 'text', text: '[Image #1] hello' }
])
})

it('strips the head turn’s markers only while older history is still pageable', async () => {
expect((await foldedById(true)).get('u-head')).toEqual([{ type: 'text', text: 'hello' }])
})

// `hasMore` also reports true for a window filled to exactly the limit, where
// nothing older exists. Riding it here would erase a marker the user typed at
// the true start of the conversation — the ticket's own defect. Mobile's window
// is 40, so that exact fill is reachable.
it('does not strip on an inferred paging answer the host never confirmed', async () => {
const folded = await foldedFor({
hasMore: true,
earlierHistoryConfirmed: false,
messages: [markerTurn('u-head', '[Image #1] hello', 1)]
})
expect(folded[0]?.blocks).toEqual([{ type: 'text', text: '[Image #1] hello' }])
})

// A live append past the window trims the front, so the row that slides up was
// already on screen rendered literally. Re-reading it as a window head would
// rewrite a message the user had seen — the ticket's own defect, on mobile's
// 40-turn window, during any active agent run.
it('does not strip a row that only became the head after a live trim', async () => {
const folded = await foldedFor({
earlierHistoryConfirmed: true,
// The recorded head has been trimmed away; nothing on screen is known to
// sit mid-run any more.
windowHeadMessageId: 'u-trimmed-away',
messages: [markerTurn('u-head', '[Image #1] hello', 1)]
})
expect(folded[0]?.blocks).toEqual([{ type: 'text', text: '[Image #1] hello' }])
})

it('leaves a turn below the head literal even while older history exists', async () => {
expect((await foldedById(true)).get('u-tail')).toEqual([
{ type: 'text', text: '[Image #2] bye' }
])
})
})
5 changes: 4 additions & 1 deletion mobile/src/session/MobileNativeChatOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ export function MobileNativeChatOverlay({
keyboardInset
}: Props): React.JSX.Element | null {
const session = controller.nativeChatSession
const folded = useMemo(() => foldMobileNativeChatMessages(session.messages), [session.messages])
const folded = useMemo(
() => foldMobileNativeChatMessages(session.messages, session.windowHeadMessageId),
[session.messages, session.windowHeadMessageId]
)
const streaming = useMobileNativeChatStreamingBubble(
folded,
controller.nativeChatStreamingText,
Expand Down
156 changes: 156 additions & 0 deletions mobile/src/session/mobile-native-chat-draft-reconcile.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
countUserTextOccurrences,
findLandedImagePreviewEchoes,
findLandedUnconfirmedSends,
migrateImagePreviewMessageIds,
userTextOccurrenceCounts,
type PendingImagePreviewEcho,
type UnconfirmedSend
} from './mobile-native-chat-draft-reconcile'
Expand All @@ -23,6 +25,73 @@ function pending(id: string, images: string[], expectedOccurrence = 1): PendingI
}

describe('mobile native chat image preview reconciliation', () => {
// Some hosts echo an image send as a user turn they kept no content for. The
// preview-binding path already accepts that shape and shows the photo, so the
// send must reconcile against it too — otherwise the send stays held and the
// 20s deadline warns "delivery unknown" about a message that plainly landed.
// Keyed under '' either way, so only a captionless send can reach it.
it('reconciles an image-only send against an echo the host left empty', () => {
const messages = [
{
id: 'base',
role: 'assistant' as const,
blocks: [{ type: 'text' as const, text: 'ready' }],
timestamp: null,
source: 'transcript' as const
},
{
id: 'echo',
role: 'user' as const,
blocks: [],
timestamp: null,
source: 'transcript' as const
}
]
const unconfirmed: UnconfirmedSend = {
draftKey: 'draft',
pendingKey: 'pending-key',
text: '',
normalizedText: '',
imageCount: 1,
baselineTailMessageId: 'base',
deadline: null
}

expect(findLandedUnconfirmedSends(messages, [unconfirmed])).toEqual([unconfirmed])
})

// The empty turn says nothing about a caption, so a send that carried one must
// not claim it — that would retire a bubble whose text never landed.
it('does not let an empty echo claim a captioned image send', () => {
const messages = [
{
id: 'base',
role: 'assistant' as const,
blocks: [{ type: 'text' as const, text: 'ready' }],
timestamp: null,
source: 'transcript' as const
},
{
id: 'echo',
role: 'user' as const,
blocks: [],
timestamp: null,
source: 'transcript' as const
}
]
const unconfirmed: UnconfirmedSend = {
draftKey: 'draft',
pendingKey: 'pending-key',
text: 'look at this',
normalizedText: 'look at this',
imageCount: 1,
baselineTailMessageId: 'base',
deadline: null
}

expect(findLandedUnconfirmedSends(messages, [unconfirmed])).toEqual([])
})

it('reconciles a trailing-marker echo and hands its preview to that echo', () => {
const messages = [
userText('source', '[Image: source: /tmp/a.png]'),
Expand All @@ -37,6 +106,7 @@ describe('mobile native chat image preview reconciliation', () => {
pendingKey: 'pending-key',
text: 'look at this',
normalizedText: 'look at this',
imageCount: 1,
baselineTailMessageId: null,
deadline: null
}
Expand All @@ -58,6 +128,7 @@ describe('mobile native chat image preview reconciliation', () => {
pendingKey: 'pending-key',
text: 'look here',
normalizedText: 'look here',
imageCount: 1,
baselineTailMessageId: null,
deadline: null
}
Expand All @@ -68,6 +139,54 @@ describe('mobile native chat image preview reconciliation', () => {
])
})

it('does not treat markerless text as an unknown-ack literal marker echo', () => {
const unconfirmed: UnconfirmedSend = {
draftKey: 'draft',
pendingKey: 'pending-key',
text: 'keep [Image #1] literal',
normalizedText: 'keep [Image #1] literal',
imageCount: 0,
baselineTailMessageId: null,
deadline: null
}

expect(findLandedUnconfirmedSends([userText('wrong', 'keep literal')], [unconfirmed])).toEqual(
[]
)
expect(
findLandedUnconfirmedSends([userText('right', 'keep [Image #1] literal')], [unconfirmed])
).toEqual([unconfirmed])
})

it('does not treat literal marker text as a markerless unknown-ack echo', () => {
const unconfirmed: UnconfirmedSend = {
draftKey: 'draft',
pendingKey: 'pending-key',
text: 'keep literal',
normalizedText: 'keep literal',
imageCount: 0,
baselineTailMessageId: null,
deadline: null
}

expect(
findLandedUnconfirmedSends([userText('wrong', 'keep [Image #1] literal')], [unconfirmed])
).toEqual([])
expect(findLandedUnconfirmedSends([userText('right', 'keep literal')], [unconfirmed])).toEqual([
unconfirmed
])
})

it('keeps no-image occurrence baselines separate from attached-image turns', () => {
const messages = [
userText('source', '[Image: source: /tmp/a.png]'),
userText('prompt', 'keep [Image #1] literal')
]

expect(countUserTextOccurrences(messages, 'keep literal')).toBe(0)
expect(countUserTextOccurrences(messages, 'keep literal', 1)).toBe(1)
})

it('reconciles multiple transcript text blocks with desktop separators', () => {
const prompt: NativeChatMessage = {
...userText('prompt', 'unused'),
Expand All @@ -84,6 +203,24 @@ describe('mobile native chat image preview reconciliation', () => {
])
})

it('hands a local preview to a marker-only transcript echo', () => {
expect(
findLandedImagePreviewEchoes(
[userText('prompt', '[Image #1]')],
[pending('pending', ['file:///a.jpg'])]
)
).toEqual([{ pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] }])
})

it('hands a captioned preview to a marker-only compatibility echo', () => {
expect(
findLandedImagePreviewEchoes(
[userText('prompt', 'look [Image #1] here')],
[{ ...pending('pending', ['file:///a.jpg']), text: 'look here' }]
)
).toEqual([{ pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] }])
})

it('keeps separate adjacent image-only sends independently reconcilable', () => {
const landed = findLandedImagePreviewEchoes(
[
Expand Down Expand Up @@ -165,3 +302,22 @@ describe('mobile native chat image preview reconciliation', () => {
).toEqual({ [sessionKey]: { prompt: ['file:///a.jpg'] } })
})
})

describe('userTextOccurrenceCounts', () => {
it('counts a literal marker turn under its verbatim text', () => {
expect(userTextOccurrenceCounts([userText('u1', 'keep [Image #1] literal')])).toEqual(
new Map([['keep [Image #1] literal', 1]])
)
})

// An attached-image turn must not retire a send that carried no images.
it('excludes a folded image turn from the literal landed counts', () => {
const counts = userTextOccurrenceCounts([
userText('s1', '[Image: source: /tmp/a.png]'),
userText('p1', '[Image #1] look')
])

expect(counts.get('look')).toBeUndefined()
expect(counts.size).toBe(0)
})
})
Loading
Loading