mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(mobile): heal an orphaned native-chat image paste across screen unmounts (#10480)
* fix(mobile): heal an orphaned native-chat image paste across screen unmounts The stale-input marker lived in a per-screen `useRef`, but the condition it tracks — a bracketed image paste sitting unsubmitted on the agent's composer line — lives on the host and outlives the screen. Backing out of a session and returning remounted the hook with an empty Set, so the next message submitted on top of the orphaned paste and the agent received `<image path><text>`. Move the marker to a module-level store keyed by terminal handle, and consult and consume it from every write path that can submit the composer: the image hook's text-only send, the controller send (which the chat overlay's question card reaches directly, bypassing the image hook), and the ask-answer send. Permission choices and the Escape cancel deliberately do NOT heal: they are `enter: false` keys for an active overlay that swallows the clear, so healing there would consume the marker without clearing the line and leave the next real message corrupted. Desktop scopes its Ctrl+U the same way. * fix(mobile): stop the ask heal from burning the marker on selector answers The heal ran on every ask answer, but Claude's and Codex's selector shapes cannot submit the composer: a single-select answer is a bare option digit and every stepping group is written `enter: false` (the host coerces it), so the clear is swallowed by the live overlay while the host still acks the write. That consumed the one-shot marker and left the orphaned paste to corrupt the next real message — the same failure this PR exists to fix, through a new door that main did not have. Scope the heal to the pasted-label shape, which does commit the composer. Desktop splits it the same way: use-native-chat-interactive-send.ts routes only the non-stepping answer through the clearing sender and never pre-clears sendNativeChatAskAnswer. Also pin the three deliberate skips (selector answer, permission choice, Escape cancel) with tests, so the PR's central design argument is an invariant rather than a comment, and guard the failed-heal toast with the generation check every other error surface in answerAsk already uses.
This commit is contained in:
@@ -1,7 +1,17 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { sendMobileNativeChatPermissionResponse } from './mobile-native-chat-permission-send'
|
||||
import {
|
||||
sendMobileNativeChatPermissionResponse,
|
||||
useMobileNativeChatPermissionSend
|
||||
} from './mobile-native-chat-permission-send'
|
||||
import {
|
||||
isMobileNativeChatInputStale,
|
||||
markMobileNativeChatInputStale,
|
||||
resetMobileNativeChatStaleInputForTests
|
||||
} from './mobile-native-chat-stale-input'
|
||||
|
||||
describe('sendMobileNativeChatPermissionResponse', () => {
|
||||
it('writes an approval as raw bytes without appending Return', async () => {
|
||||
@@ -41,3 +51,50 @@ describe('sendMobileNativeChatPermissionResponse', () => {
|
||||
).resolves.toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMobileNativeChatPermissionSend', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let respond: ((text: string) => Promise<boolean>) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
resetMobileNativeChatStaleInputForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
respond = null
|
||||
})
|
||||
|
||||
it('keeps the marker for a permission choice, which never submits the composer', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { send: { handle: 'terminal', accepted: true, bytesWritten: 1 } }
|
||||
})
|
||||
function Harness(): null {
|
||||
respond = useMobileNativeChatPermissionSend({
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
enabled: true,
|
||||
handleRef: { current: 'terminal' },
|
||||
deviceTokenRef: { current: null },
|
||||
onSendError: vi.fn()
|
||||
})
|
||||
return null
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
markMobileNativeChatInputStale('terminal')
|
||||
|
||||
await act(async () => {
|
||||
await expect(respond?.('1')).resolves.toBe(true)
|
||||
})
|
||||
// A choice is a bare key for a live overlay that swallows a clear while the
|
||||
// host still acks it, so healing here would burn the marker and leave the
|
||||
// paste to corrupt the next real message. Only the choice may go.
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false })
|
||||
expect(isMobileNativeChatInputStale('terminal')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,9 @@ export function useMobileNativeChatPermissionSend(args: {
|
||||
args.onSendError('Response not sent (disconnected)')
|
||||
return false
|
||||
}
|
||||
// No stale-input heal here (unlike the text/ask sends): a choice is an
|
||||
// `enter: false` key for an active overlay that swallows the clear, so it
|
||||
// would consume the marker still protecting the next real message.
|
||||
const outcome = await sendMobileNativeChatPermissionResponse({
|
||||
client: args.client,
|
||||
terminal,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
clearMobileNativeChatInputStale,
|
||||
healMobileNativeChatStaleInput,
|
||||
isMobileNativeChatInputStale,
|
||||
markMobileNativeChatInputStale,
|
||||
resetMobileNativeChatStaleInputForTests
|
||||
} from './mobile-native-chat-stale-input'
|
||||
|
||||
function sendResult(accepted: boolean) {
|
||||
return {
|
||||
id: 'send',
|
||||
ok: true as const,
|
||||
result: { send: { accepted } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
}
|
||||
}
|
||||
|
||||
function makeClient(accepted = true): Pick<RpcClient, 'sendRequest'> {
|
||||
return { sendRequest: vi.fn().mockResolvedValue(sendResult(accepted)) }
|
||||
}
|
||||
|
||||
describe('mobile native chat stale input markers', () => {
|
||||
beforeEach(() => {
|
||||
resetMobileNativeChatStaleInputForTests()
|
||||
})
|
||||
|
||||
it('tracks each terminal independently', () => {
|
||||
markMobileNativeChatInputStale('term-1')
|
||||
expect(isMobileNativeChatInputStale('term-1')).toBe(true)
|
||||
expect(isMobileNativeChatInputStale('term-2')).toBe(false)
|
||||
clearMobileNativeChatInputStale('term-1')
|
||||
expect(isMobileNativeChatInputStale('term-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('writes nothing when the terminal is not marked', async () => {
|
||||
const client = makeClient()
|
||||
await expect(
|
||||
healMobileNativeChatStaleInput({ client, terminal: 'term-1', deviceToken: null })
|
||||
).resolves.toBe(true)
|
||||
expect(client.sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the line and consumes the marker', async () => {
|
||||
const client = makeClient()
|
||||
markMobileNativeChatInputStale('term-1')
|
||||
await expect(
|
||||
healMobileNativeChatStaleInput({ client, terminal: 'term-1', deviceToken: 'device' })
|
||||
).resolves.toBe(true)
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(client.sendRequest).mock.calls[0]?.[1]).toMatchObject({
|
||||
terminal: 'term-1',
|
||||
text: '\x15',
|
||||
enter: false,
|
||||
client: { id: 'device', type: 'mobile' }
|
||||
})
|
||||
expect(isMobileNativeChatInputStale('term-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the marker when the host rejects the clear', async () => {
|
||||
const client = makeClient(false)
|
||||
markMobileNativeChatInputStale('term-1')
|
||||
await expect(
|
||||
healMobileNativeChatStaleInput({ client, terminal: 'term-1', deviceToken: null })
|
||||
).resolves.toBe(false)
|
||||
expect(isMobileNativeChatInputStale('term-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the marker when the clear throws', async () => {
|
||||
const client = { sendRequest: vi.fn().mockRejectedValue(new Error('offline')) }
|
||||
markMobileNativeChatInputStale('term-1')
|
||||
await expect(
|
||||
healMobileNativeChatStaleInput({ client, terminal: 'term-1', deviceToken: null })
|
||||
).resolves.toBe(false)
|
||||
expect(isMobileNativeChatInputStale('term-1')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send'
|
||||
|
||||
// The condition tracked here — a bracketed image paste left sitting on the agent's
|
||||
// unsubmitted input line — lives on the HOST terminal, so it outlives any one
|
||||
// session screen. Keyed by terminal handle at module scope: React state died with
|
||||
// the screen and let the orphaned paste glue onto the next message (#10228).
|
||||
const staleInputTerminals = new Set<string>()
|
||||
|
||||
export function markMobileNativeChatInputStale(terminal: string): void {
|
||||
staleInputTerminals.add(terminal)
|
||||
}
|
||||
|
||||
export function isMobileNativeChatInputStale(terminal: string): boolean {
|
||||
return staleInputTerminals.has(terminal)
|
||||
}
|
||||
|
||||
export function clearMobileNativeChatInputStale(terminal: string): void {
|
||||
staleInputTerminals.delete(terminal)
|
||||
}
|
||||
|
||||
/** Test-only: module scope outlives a single test's hooks. */
|
||||
export function resetMobileNativeChatStaleInputForTests(): void {
|
||||
staleInputTerminals.clear()
|
||||
}
|
||||
|
||||
/** Clears a marked terminal's unsubmitted input line before a write that could
|
||||
* submit it, consuming the marker only once the host accepts the clear.
|
||||
*
|
||||
* Returns true when the line is safe to submit (nothing marked, or cleared);
|
||||
* false when a needed clear failed — the marker stays set for the next attempt
|
||||
* and the caller must not submit, or the stale paste rides along with it.
|
||||
*
|
||||
* Only for writes that can commit the composer. Dialog control (permission
|
||||
* choices, Escape) and selector answers carry no commit — the host coerces their
|
||||
* `enter` to false — and go to an active overlay that swallows the keys, so a
|
||||
* clear there would not reach the input line yet would still consume the marker,
|
||||
* leaving the next real message to be corrupted by the paste. The host acks a
|
||||
* write, never a cleared line, so consumption can't be made conditional on it. */
|
||||
export async function healMobileNativeChatStaleInput(args: {
|
||||
readonly client: Pick<RpcClient, 'sendRequest'>
|
||||
readonly terminal: string
|
||||
readonly deviceToken: string | null
|
||||
}): Promise<boolean> {
|
||||
if (!isMobileNativeChatInputStale(args.terminal)) {
|
||||
return true
|
||||
}
|
||||
let cleared = false
|
||||
try {
|
||||
cleared = await pasteMobileNativeChatImagePaths({
|
||||
client: args.client,
|
||||
terminal: args.terminal,
|
||||
deviceToken: args.deviceToken,
|
||||
imagePaths: []
|
||||
})
|
||||
} catch {
|
||||
// Leave marked for the next attempt.
|
||||
return false
|
||||
}
|
||||
if (!cleared) {
|
||||
return false
|
||||
}
|
||||
clearMobileNativeChatInputStale(args.terminal)
|
||||
return true
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
|
||||
import type { AskPrompt } from './mobile-native-chat-ask'
|
||||
import {
|
||||
isMobileNativeChatInputStale,
|
||||
markMobileNativeChatInputStale,
|
||||
resetMobileNativeChatStaleInputForTests
|
||||
} from './mobile-native-chat-stale-input'
|
||||
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
|
||||
|
||||
type AnswerSend = ReturnType<typeof useMobileNativeChatAnswerSend>
|
||||
@@ -39,6 +44,7 @@ describe('useMobileNativeChatAnswerSend', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
resetMobileNativeChatStaleInputForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -229,6 +235,54 @@ describe('useMobileNativeChatAnswerSend', () => {
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Spaces', enter: true })
|
||||
})
|
||||
|
||||
it('clears an orphaned image paste before an answer that commits with Enter (#10228)', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
|
||||
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'grok')
|
||||
// An earlier image send left its path on this terminal's composer line.
|
||||
markMobileNativeChatInputStale('terminal')
|
||||
|
||||
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
|
||||
// Without the leading clear, the pasted label + Enter would submit
|
||||
// "<image path>Spaces" as one prompt.
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '\x15', enter: false })
|
||||
expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({ text: 'Spaces', enter: true })
|
||||
expect(isMobileNativeChatInputStale('terminal')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the marker for a selector answer, which cannot submit the composer', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
|
||||
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'claude')
|
||||
markMobileNativeChatInputStale('terminal')
|
||||
|
||||
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
|
||||
// A single-select answer is a bare option digit against a live overlay: the
|
||||
// clear would be swallowed but still acked, burning the marker and leaving the
|
||||
// paste to corrupt the next real message. Only the digit may go.
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
|
||||
expect(isMobileNativeChatInputStale('terminal')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not answer when the healing clear is rejected, keeping the marker', async () => {
|
||||
const onSendError = vi.fn()
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
id: 'send',
|
||||
ok: true as const,
|
||||
result: { send: { accepted: false } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok')
|
||||
markMobileNativeChatInputStale('terminal')
|
||||
|
||||
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(false)
|
||||
// Only the clear was attempted; the answer must not ride on a dirty line.
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '\x15', enter: false })
|
||||
expect(onSendError).toHaveBeenCalledWith('Answer not sent')
|
||||
expect(isMobileNativeChatInputStale('terminal')).toBe(true)
|
||||
})
|
||||
|
||||
it('stops at the first rejected write and reports failure', async () => {
|
||||
const onSendError = vi.fn()
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type AskPrompt
|
||||
} from './mobile-native-chat-ask'
|
||||
import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send'
|
||||
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
|
||||
import {
|
||||
resolveNativeChatTranscriptAgent,
|
||||
shouldStepNativeChatAskAnswer
|
||||
@@ -155,6 +156,29 @@ export function useMobileNativeChatAnswerSend(args: {
|
||||
// Grok commits pasted labels; Claude and Codex need their selector-specific
|
||||
// keystrokes paced so each step renders before the next lands.
|
||||
if (!shouldStepNativeChatAskAnswer(agentRef.current)) {
|
||||
// This shape pastes the label into the composer and commits it, so an
|
||||
// orphaned image paste would be submitted along with the answer (#10228).
|
||||
// The selector shapes below deliberately skip the heal: their keys are
|
||||
// `enter: false` for an active overlay, and a single-select answer is a
|
||||
// bare option digit that cannot submit the line at all, so clearing there
|
||||
// would consume the marker still protecting the next real message.
|
||||
// Desktop splits it identically — use-native-chat-interactive-send.ts
|
||||
// routes only the pasted-label shape through the clearing sender.
|
||||
if (
|
||||
!(await healMobileNativeChatStaleInput({
|
||||
client,
|
||||
terminal: handle,
|
||||
deviceToken: deviceTokenRef.current
|
||||
}))
|
||||
) {
|
||||
if (generationRef.current === generation) {
|
||||
onSendError('Answer not sent')
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (generationRef.current !== generation) {
|
||||
return false
|
||||
}
|
||||
return (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail()
|
||||
}
|
||||
const groups =
|
||||
|
||||
@@ -45,6 +45,11 @@ vi.mock('./mobile-native-chat-send', () => ({
|
||||
}))
|
||||
|
||||
import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send'
|
||||
import {
|
||||
isMobileNativeChatInputStale,
|
||||
markMobileNativeChatInputStale,
|
||||
resetMobileNativeChatStaleInputForTests
|
||||
} from './mobile-native-chat-stale-input'
|
||||
import {
|
||||
useMobileNativeChatController,
|
||||
type MobileNativeChatController
|
||||
@@ -64,10 +69,13 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let controller: MobileNativeChatController | null = null
|
||||
const onSendError = vi.fn()
|
||||
// Only the stale-input heal reaches the transport directly (the message send
|
||||
// itself is mocked above).
|
||||
const clientStub = { sendRequest: vi.fn() }
|
||||
|
||||
function Harness(): null {
|
||||
controller = useMobileNativeChatController({
|
||||
client: {} as RpcClient,
|
||||
client: clientStub as unknown as RpcClient,
|
||||
hostId: 'h',
|
||||
worktreeId: 'w',
|
||||
activeSessionTab: null,
|
||||
@@ -84,6 +92,7 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.clearAllMocks()
|
||||
resetMobileNativeChatStaleInputForTests()
|
||||
captureSendOrigin.mockReturnValue(ORIGIN)
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
|
||||
@@ -106,6 +115,63 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
|
||||
controller = null
|
||||
})
|
||||
|
||||
it('clears an orphaned image paste before a question-card answer (#10228)', async () => {
|
||||
// The chat overlay wires the question card straight to this send, bypassing
|
||||
// the image hook that used to own the only heal.
|
||||
markMobileNativeChatInputStale('term-1')
|
||||
clientStub.sendRequest.mockResolvedValue({
|
||||
id: 'send',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'r' }
|
||||
})
|
||||
sendWithOutcome.mockResolvedValue('accepted')
|
||||
let accepted = false
|
||||
await act(async () => {
|
||||
accepted = await controller!.handleNativeChatSend('answer')
|
||||
})
|
||||
expect(accepted).toBe(true)
|
||||
expect(clientStub.sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(clientStub.sendRequest.mock.calls[0]?.[1]).toMatchObject({
|
||||
terminal: 'term-1',
|
||||
text: '\x15',
|
||||
enter: false
|
||||
})
|
||||
expect(isMobileNativeChatInputStale('term-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not send when the healing clear is rejected, keeping the marker', async () => {
|
||||
markMobileNativeChatInputStale('term-1')
|
||||
clientStub.sendRequest.mockResolvedValue({
|
||||
id: 'send',
|
||||
ok: true,
|
||||
result: { send: { accepted: false } },
|
||||
_meta: { runtimeId: 'r' }
|
||||
})
|
||||
let accepted = true
|
||||
await act(async () => {
|
||||
accepted = await controller!.handleNativeChatSend('answer')
|
||||
})
|
||||
expect(accepted).toBe(false)
|
||||
expect(sendWithOutcome).not.toHaveBeenCalled()
|
||||
expect(onSendError).toHaveBeenCalledWith('Message not sent')
|
||||
expect(isMobileNativeChatInputStale('term-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the marker when Escape cancels an ask, which never submits the composer', async () => {
|
||||
markMobileNativeChatInputStale('term-1')
|
||||
sendWithOutcome.mockResolvedValue('accepted')
|
||||
let accepted = false
|
||||
await act(async () => {
|
||||
accepted = await controller!.handleNativeChatCancelAsk()
|
||||
})
|
||||
expect(accepted).toBe(true)
|
||||
// The clear would be swallowed by the live overlay but still acked, burning
|
||||
// the marker and leaving the paste to corrupt the next real message.
|
||||
expect(clientStub.sendRequest).not.toHaveBeenCalled()
|
||||
expect(isMobileNativeChatInputStale('term-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('threads the optimistic-echo image URIs into acceptSend on an accepted send', async () => {
|
||||
sendWithOutcome.mockResolvedValue('accepted')
|
||||
let accepted = false
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
sendMobileNativeChatMessageWithOutcome,
|
||||
type MobileNativeChatSendOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
|
||||
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
|
||||
import {
|
||||
useMobileNativeChatDrafts,
|
||||
@@ -184,6 +185,8 @@ export function useMobileNativeChatController(args: {
|
||||
return false
|
||||
}
|
||||
cancelNativeChatAnswer()
|
||||
// Escape never submits the composer, so no stale-input heal: it would consume
|
||||
// the marker still protecting the next real message.
|
||||
const outcome = await sendMobileNativeChatMessageWithOutcome({
|
||||
client,
|
||||
terminal: handle,
|
||||
@@ -241,6 +244,14 @@ export function useMobileNativeChatController(args: {
|
||||
onSendError('Message not sent (disconnected)')
|
||||
return 'rejected'
|
||||
}
|
||||
// The composer may still hold an orphaned image paste from an earlier send
|
||||
// (#10228); submitting on top of it would glue the image onto this message.
|
||||
// Also covers question-card answers, which reach this send directly.
|
||||
const healArgs = { client, terminal: handle, deviceToken: deviceTokenRef.current }
|
||||
if (!(await healMobileNativeChatStaleInput(healArgs))) {
|
||||
onSendError('Message not sent')
|
||||
return 'rejected'
|
||||
}
|
||||
const outcome = await sendMobileNativeChatMessageWithOutcome({
|
||||
client,
|
||||
terminal: handle,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input'
|
||||
import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments'
|
||||
|
||||
// Fully stub the picker so the real expo/react-native chain never loads under
|
||||
@@ -84,6 +85,9 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
pick.mockReset()
|
||||
// Stale markers live at module scope now (they outlive the screen), so they
|
||||
// also outlive a test.
|
||||
resetMobileNativeChatStaleInputForTests()
|
||||
})
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
@@ -548,6 +552,42 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
expect(baseSend).toHaveBeenNthCalledWith(2, 'later message')
|
||||
})
|
||||
|
||||
it('still heals after the session screen unmounts and remounts (#10228)', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
sendResult(true), // Ctrl+U clear
|
||||
sendResult(true), // image paste accepted — path now sits on term-1's input
|
||||
sendResult(true) // healing Ctrl+U on the remounted screen
|
||||
])
|
||||
const baseSend = vi.fn().mockResolvedValueOnce('unknown').mockResolvedValueOnce('accepted')
|
||||
mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
|
||||
await act(async () => {
|
||||
await hook!.attachImage('library')
|
||||
})
|
||||
await act(async () => {
|
||||
await hook!.sendNativeChat('pic')
|
||||
})
|
||||
|
||||
// Back out of the session screen and return. The orphaned paste sits on the
|
||||
// HOST's input line, so a fresh hook must still know to clear it.
|
||||
act(() => renderer!.unmount())
|
||||
renderer = null
|
||||
mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
|
||||
expect(hook!.attachments).toEqual([])
|
||||
|
||||
let accepted = false
|
||||
await act(async () => {
|
||||
accepted = await hook!.sendNativeChat('later message')
|
||||
})
|
||||
expect(accepted).toBe(true)
|
||||
const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
|
||||
expect(sendCalls).toHaveLength(3)
|
||||
expect(sendCalls[2]?.params).toMatchObject({ terminal: 'term-1', text: '\x15', enter: false })
|
||||
expect(baseSend).toHaveBeenNthCalledWith(2, 'later message')
|
||||
})
|
||||
|
||||
it('does not heal after an unknown text-only send (nothing was pasted first)', async () => {
|
||||
const client = makeClient([])
|
||||
const baseSend = vi.fn().mockResolvedValueOnce('unknown').mockResolvedValueOnce('accepted')
|
||||
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
pasteMobileNativeChatImagePaths
|
||||
} from './mobile-native-chat-image-send'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import {
|
||||
clearMobileNativeChatInputStale,
|
||||
healMobileNativeChatStaleInput,
|
||||
isMobileNativeChatInputStale,
|
||||
markMobileNativeChatInputStale
|
||||
} from './mobile-native-chat-stale-input'
|
||||
|
||||
type CurrentRef<T> = { readonly current: T }
|
||||
type ShowToast = (message: string, durationMs?: number) => void
|
||||
@@ -77,10 +83,6 @@ function withScopeAttachments(
|
||||
return remaining
|
||||
}
|
||||
|
||||
function markTerminalInputStale(staleInputs: Set<string>, terminal: string): void {
|
||||
staleInputs.add(terminal)
|
||||
}
|
||||
|
||||
const defaultSleep = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
@@ -109,8 +111,6 @@ export function useMobileNativeChatImageAttachments({
|
||||
// checked 'connected' at entry, so only a ref can see a mid-upload disconnect.
|
||||
const connStateRef = useRef(connState)
|
||||
connStateRef.current = connState
|
||||
// Terminals whose input may hold a failed paste; each must heal independently.
|
||||
const staleInputTerminalsRef = useRef(new Set<string>())
|
||||
// Serialize clear/paste/submit ownership per terminal while allowing other tabs to send.
|
||||
const sendInFlightTerminalsRef = useRef(new Set<string>())
|
||||
|
||||
@@ -220,25 +220,15 @@ export function useMobileNativeChatImageAttachments({
|
||||
// otherwise glue the stale image paste onto this message. Best-effort —
|
||||
// on failure the marker stays set and the text must not be submitted.
|
||||
const staleTerminal = activeHandleRef.current
|
||||
if (staleTerminal && staleInputTerminalsRef.current.has(staleTerminal) && client) {
|
||||
let cleared = false
|
||||
try {
|
||||
cleared = await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: staleTerminal,
|
||||
deviceToken: deviceTokenRef.current,
|
||||
imagePaths: []
|
||||
})
|
||||
} catch {
|
||||
// Leave marked for the next attempt.
|
||||
}
|
||||
if (!cleared) {
|
||||
onError?.()
|
||||
showToast('Message not sent', 1500)
|
||||
return false
|
||||
}
|
||||
staleInputTerminalsRef.current.delete(staleTerminal)
|
||||
if (activeHandleRef.current !== staleTerminal) {
|
||||
if (staleTerminal && isMobileNativeChatInputStale(staleTerminal) && client) {
|
||||
const healed = await healMobileNativeChatStaleInput({
|
||||
client,
|
||||
terminal: staleTerminal,
|
||||
deviceToken: deviceTokenRef.current
|
||||
})
|
||||
// A tab switch during the clear would send this text to a terminal the
|
||||
// clear never touched, so abort rather than reroute it.
|
||||
if (!healed || activeHandleRef.current !== staleTerminal) {
|
||||
onError?.()
|
||||
showToast('Message not sent', 1500)
|
||||
return false
|
||||
@@ -263,13 +253,13 @@ export function useMobileNativeChatImageAttachments({
|
||||
})
|
||||
if (!pasted) {
|
||||
// Keep the chips so the user can retry; the failed paste never submitted.
|
||||
markTerminalInputStale(staleInputTerminalsRef.current, handle)
|
||||
markMobileNativeChatInputStale(handle)
|
||||
onError?.()
|
||||
showToast('Message not sent', 1500)
|
||||
return false
|
||||
}
|
||||
// The paste's leading Ctrl+U cleared any earlier stale input in `handle`.
|
||||
staleInputTerminalsRef.current.delete(handle)
|
||||
clearMobileNativeChatInputStale(handle)
|
||||
// Let the TUI absorb the image paste before the text + Enter follow. The
|
||||
// preview URIs ride along to baseSend so the sent bubble shows the photo
|
||||
// immediately (empty text still submits a bare Enter through baseSend).
|
||||
@@ -278,7 +268,7 @@ export function useMobileNativeChatImageAttachments({
|
||||
// route the text + Enter to a different terminal than the images. Abort —
|
||||
// the chips keep their scope and a retry's Ctrl+U clears the stale paste.
|
||||
if (activeHandleRef.current !== handle) {
|
||||
markTerminalInputStale(staleInputTerminalsRef.current, handle)
|
||||
markMobileNativeChatInputStale(handle)
|
||||
onError?.()
|
||||
showToast('Message not sent', 1500)
|
||||
return false
|
||||
@@ -291,7 +281,7 @@ export function useMobileNativeChatImageAttachments({
|
||||
// 'rejected' leaves the pasted image path on this input line; 'unknown'
|
||||
// may have lost the text+Enter AFTER the paste landed, orphaning the
|
||||
// image onto whatever is sent next (#10228) — both must heal first.
|
||||
markTerminalInputStale(staleInputTerminalsRef.current, handle)
|
||||
markMobileNativeChatInputStale(handle)
|
||||
}
|
||||
if (outcome !== 'rejected') {
|
||||
// Drop only what rode along — a chip attached while this send was in
|
||||
@@ -311,7 +301,7 @@ export function useMobileNativeChatImageAttachments({
|
||||
// A thrown paste/send (network/RPC) keeps the chips and honors the
|
||||
// Promise<boolean> contract instead of rejecting. Retry-safe: the next
|
||||
// attempt's leading Ctrl+U clears whatever fraction of the paste landed.
|
||||
markTerminalInputStale(staleInputTerminalsRef.current, handle)
|
||||
markMobileNativeChatInputStale(handle)
|
||||
onError?.()
|
||||
showToast('Message not sent', 1500)
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user