diff --git a/mobile/src/session/mobile-native-chat-permission-send.test.ts b/mobile/src/session/mobile-native-chat-permission-send.test.ts index 05bb7ee8c8b..b2db8580937 100644 --- a/mobile/src/session/mobile-native-chat-permission-send.test.ts +++ b/mobile/src/session/mobile-native-chat-permission-send.test.ts @@ -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) | 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) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-permission-send.ts b/mobile/src/session/mobile-native-chat-permission-send.ts index 6c4fee4ff91..b06ff88fe48 100644 --- a/mobile/src/session/mobile-native-chat-permission-send.ts +++ b/mobile/src/session/mobile-native-chat-permission-send.ts @@ -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, diff --git a/mobile/src/session/mobile-native-chat-stale-input.test.ts b/mobile/src/session/mobile-native-chat-stale-input.test.ts new file mode 100644 index 00000000000..b70c7f70e4f --- /dev/null +++ b/mobile/src/session/mobile-native-chat-stale-input.test.ts @@ -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 { + 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) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-stale-input.ts b/mobile/src/session/mobile-native-chat-stale-input.ts new file mode 100644 index 00000000000..8fcc0b9a514 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-stale-input.ts @@ -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() + +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 + readonly terminal: string + readonly deviceToken: string | null +}): Promise { + 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 +} diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts index 680cccbea95..a9871802b5e 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -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 @@ -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 + // "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({ diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts index 4cc58afdb87..021a948a2a2 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -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 = diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 160baf080ef..b2c0fff78a9 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -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 diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index d3d0f2c2772..0331e38511d 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -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, diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index a041048898c..8079a48d2b1 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -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') diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index b81cb8ec1d0..f0ce66dd92b 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -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 = { readonly current: T } type ShowToast = (message: string, durationMs?: number) => void @@ -77,10 +83,6 @@ function withScopeAttachments( return remaining } -function markTerminalInputStale(staleInputs: Set, terminal: string): void { - staleInputs.add(terminal) -} - const defaultSleep = (ms: number): Promise => 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()) // Serialize clear/paste/submit ownership per terminal while allowing other tabs to send. const sendInFlightTerminalsRef = useRef(new Set()) @@ -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 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