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 7c91bc4cb78..6018f353854 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 @@ -31,7 +31,10 @@ function sendResult(accepted: boolean): RpcSuccess { return { id: 'send', ok: true, result: { send: { accepted } }, _meta: { runtimeId: 'r' } } } -function makeClient(responses: RpcResponse[]): Pick & { +function makeClient(responses: (RpcResponse | Promise)[]): Pick< + RpcClient, + 'sendRequest' +> & { calls: { method: string; params: Record }[] } { const calls: { method: string; params: Record }[] = [] @@ -52,6 +55,7 @@ type HookArgs = Parameters[0] type Hook = ReturnType const SCOPE_A = 'h\0w\0tab-a' +const SCOPE_B = 'h\0w\0tab-b' function baseArgs(overrides: Partial & Pick): HookArgs { return { @@ -403,6 +407,14 @@ describe('useMobileNativeChatImageAttachments', () => { await act(async () => { await hook!.attachImage('library') }) + let overlappingAccepted = true + await act(async () => { + overlappingAccepted = await hook!.sendNativeChat('too soon') + }) + expect(overlappingAccepted).toBe(false) + expect(baseSend).not.toHaveBeenCalled() + expect(client.calls.filter((call) => call.method === 'terminal.send')).toHaveLength(2) + await act(async () => { releaseSettle!() await sendPromise @@ -498,6 +510,159 @@ describe('useMobileNativeChatImageAttachments', () => { expect(baseSend).toHaveBeenCalledWith('hi again') }) + it('retains the stale marker when a rejected healing clear blocks text-only send', 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 + sendResult(false), // first healing Ctrl+U rejected + sendResult(true) // retry healing Ctrl+U accepted + ]) + const baseSend = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) + mount(baseArgs({ client: client as unknown as RpcClient, baseSend })) + await act(async () => { + await hook!.attachImage('library') + }) + await act(async () => { + await hook!.sendNativeChat('hi') + }) + expect(hook!.attachments).toHaveLength(1) + + await act(async () => { + hook!.removeAttachment('img-1') + }) + let accepted = true + await act(async () => { + accepted = await hook!.sendNativeChat('hi again') + }) + expect(accepted).toBe(false) + expect(baseSend).toHaveBeenCalledTimes(1) + + await act(async () => { + accepted = await hook!.sendNativeChat('hi again') + }) + expect(accepted).toBe(true) + const sendCalls = client.calls.filter((c) => c.method === 'terminal.send') + expect(sendCalls).toHaveLength(4) + expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false }) + expect(sendCalls[3]?.params).toMatchObject({ text: '\x15', enter: false }) + expect(baseSend).toHaveBeenNthCalledWith(1, 'hi', ['file:///a.jpg']) + expect(baseSend).toHaveBeenNthCalledWith(2, 'hi again') + }) + + it('does not reroute text when the active terminal changes during a healing clear', async () => { + pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + let releaseClear: ((response: RpcResponse) => void) | null = null + const deferredClear = new Promise((resolve) => { + releaseClear = resolve + }) + const client = makeClient([ + methodNotFound('start'), + ok('save', '/tmp/a.png'), + sendResult(true), + sendResult(true), + deferredClear + ]) + const baseSend = vi.fn().mockResolvedValueOnce(false) + const activeHandleRef = { current: 'term-1' } + mount(baseArgs({ client: client as unknown as RpcClient, activeHandleRef, baseSend })) + await act(async () => { + await hook!.attachImage('library') + }) + await act(async () => { + await hook!.sendNativeChat('hi') + }) + await act(async () => { + hook!.removeAttachment('img-1') + }) + + let retry: Promise | null = null + await act(async () => { + retry = hook!.sendNativeChat('hi again') + await Promise.resolve() + }) + activeHandleRef.current = 'term-2' + let accepted = true + await act(async () => { + releaseClear!(sendResult(true)) + accepted = await retry! + }) + + expect(accepted).toBe(false) + expect(baseSend).toHaveBeenCalledTimes(1) + const sendCalls = client.calls.filter((c) => c.method === 'terminal.send') + expect(sendCalls[2]?.params).toMatchObject({ terminal: 'term-1', text: '\x15', enter: false }) + }) + + it('heals rejected image submits independently across terminals', async () => { + pick + .mockResolvedValueOnce({ base64: 'AAAA', uri: 'file:///a.jpg' }) + .mockResolvedValueOnce({ base64: 'BBBB', uri: 'file:///b.jpg' }) + const client = makeClient([ + methodNotFound('start-a'), + ok('save-a', '/tmp/a.png'), + sendResult(true), + sendResult(true), + methodNotFound('start-b'), + ok('save-b', '/tmp/b.png'), + sendResult(true), + sendResult(true), + sendResult(true), + sendResult(true) + ]) + const baseSend = vi + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + const activeHandleRef = { current: 'term-1' } + const args = baseArgs({ client: client as unknown as RpcClient, activeHandleRef, baseSend }) + mount(args) + await act(async () => { + await hook!.attachImage('library') + }) + await act(async () => { + await hook!.sendNativeChat('first') + }) + + activeHandleRef.current = 'term-2' + update({ ...args, scopeKey: SCOPE_B }) + await act(async () => { + await hook!.attachImage('library') + }) + await act(async () => { + await hook!.sendNativeChat('second') + }) + await act(async () => { + hook!.removeAttachment('img-2') + }) + + activeHandleRef.current = 'term-1' + update(args) + await act(async () => { + hook!.removeAttachment('img-1') + }) + await act(async () => { + expect(await hook!.sendNativeChat('retry first')).toBe(true) + }) + + activeHandleRef.current = 'term-2' + update({ ...args, scopeKey: SCOPE_B }) + await act(async () => { + expect(await hook!.sendNativeChat('retry second')).toBe(true) + }) + + const sendCalls = client.calls.filter((c) => c.method === 'terminal.send') + expect(sendCalls.slice(4).map((call) => call.params)).toMatchObject([ + { terminal: 'term-1', text: '\x15', enter: false }, + { terminal: 'term-2', text: '\x15', enter: false } + ]) + expect(baseSend).toHaveBeenCalledTimes(4) + }) + it('reports a disconnected attach failure via the live connection state', async () => { const client = makeClient([]) const showToast = vi.fn() 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 fded5e2f547..4d8ea9d8eb6 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -71,6 +71,10 @@ 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)) @@ -99,9 +103,10 @@ export function useMobileNativeChatImageAttachments({ // checked 'connected' at entry, so only a ref can see a mid-upload disconnect. const connStateRef = useRef(connState) connStateRef.current = connState - // Terminal whose input line may hold a partial paste from a failed send; the - // next send TO THAT terminal must lead with Ctrl+U even if it has no images. - const staleInputTerminalRef = useRef(null) + // 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()) const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS @@ -192,92 +197,120 @@ export function useMobileNativeChatImageAttachments({ const sendNativeChat = useCallback( async (text: string): Promise => { - const scope = scopeKey - const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_ATTACHMENTS - if (pendingImages.length === 0 || !scope) { - // Heal a previously failed paste: a text-only send to that terminal would - // otherwise glue the stale image paste onto this message. Best-effort — - // on failure the marker stays set and the send proceeds as before. - const staleTerminal = staleInputTerminalRef.current - if (staleTerminal && staleTerminal === activeHandleRef.current && client) { - try { - await pasteMobileNativeChatImagePaths({ - client, - terminal: staleTerminal, - deviceToken: deviceTokenRef.current, - imagePaths: [] - }) - staleInputTerminalRef.current = null - } catch { - // Leave marked for the next attempt. - } - } - return baseSend(text) - } - const handle = activeHandleRef.current - if (!client || !handle || !enabled || connState !== 'connected') { - onError?.() - // Mirror the text path's failure surface (the base send is never reached). - showToast('Message not sent (disconnected)', 1500) - return false - } - try { - const pasted = await pasteMobileNativeChatImagePaths({ - client, - terminal: handle, - deviceToken: deviceTokenRef.current, - imagePaths: pendingImages.map((attachment) => attachment.path) - }) - if (!pasted) { - // Keep the chips so the user can retry; the failed paste never submitted. - staleInputTerminalRef.current = handle - onError?.() - showToast('Message not sent', 1500) - return false - } - // The paste's leading Ctrl+U cleared any earlier stale input in `handle`. - if (staleInputTerminalRef.current === handle) { - staleInputTerminalRef.current = null - } - // 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). - await sleep(MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS) - // The paste above targeted `handle`; a tab switch during the settle would - // 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) { - staleInputTerminalRef.current = handle - onError?.() - showToast('Message not sent', 1500) - return false - } - const accepted = await baseSend( - text, - pendingImages.map((attachment) => attachment.previewUri) - ) - if (accepted) { - // Drop only what rode along — a chip attached while this send was in - // flight keeps waiting for its own send. - const sentIds = new Set(pendingImages.map((attachment) => attachment.id)) - setAttachmentsByScope((prev) => - withScopeAttachments( - prev, - scope, - (prev[scope] ?? []).filter((attachment) => !sentIds.has(attachment.id)) - ) - ) - } - return accepted - } catch { - // 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. - staleInputTerminalRef.current = handle + const operationTerminal = activeHandleRef.current + if (operationTerminal && sendInFlightTerminalsRef.current.has(operationTerminal)) { onError?.() showToast('Message not sent', 1500) return false } + if (operationTerminal) { + sendInFlightTerminalsRef.current.add(operationTerminal) + } + try { + const scope = scopeKey + const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_ATTACHMENTS + if (pendingImages.length === 0 || !scope) { + // Heal a previously failed paste: a text-only send to that terminal would + // 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) { + onError?.() + showToast('Message not sent', 1500) + return false + } + } + return baseSend(text) + } + const handle = activeHandleRef.current + if (!client || !handle || !enabled || connState !== 'connected') { + onError?.() + // Mirror the text path's failure surface (the base send is never reached). + showToast('Message not sent (disconnected)', 1500) + return false + } + try { + const pasted = await pasteMobileNativeChatImagePaths({ + client, + terminal: handle, + deviceToken: deviceTokenRef.current, + imagePaths: pendingImages.map((attachment) => attachment.path) + }) + if (!pasted) { + // Keep the chips so the user can retry; the failed paste never submitted. + markTerminalInputStale(staleInputTerminalsRef.current, 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) + // 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). + await sleep(MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS) + // The paste above targeted `handle`; a tab switch during the settle would + // 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) + onError?.() + showToast('Message not sent', 1500) + return false + } + const accepted = await baseSend( + text, + pendingImages.map((attachment) => attachment.previewUri) + ) + if (!accepted) { + // A rejected submit leaves the successfully pasted image path on this input line. + markTerminalInputStale(staleInputTerminalsRef.current, handle) + } + if (accepted) { + // Drop only what rode along — a chip attached while this send was in + // flight keeps waiting for its own send. + const sentIds = new Set(pendingImages.map((attachment) => attachment.id)) + setAttachmentsByScope((prev) => + withScopeAttachments( + prev, + scope, + (prev[scope] ?? []).filter((attachment) => !sentIds.has(attachment.id)) + ) + ) + } + return accepted + } catch { + // 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) + onError?.() + showToast('Message not sent', 1500) + return false + } + } finally { + if (operationTerminal) { + sendInFlightTerminalsRef.current.delete(operationTerminal) + } + } }, [ activeHandleRef,