From cb10440dcac7f037ceb3340eb47426554770ecdb Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 9 Sep 2026 01:45:44 -0400 Subject: [PATCH] fix(mobile): remove a stream error callback that could never fire The native chat subscribe declared an onError the adapter never invoked, since the transport has none: a stream error arrives as an error frame through the listener, which is where the caller already handles it. The hook's second callback was unreachable, so it goes with the parameter. The terminal adapter's acknowledge was a no-op with no caller here or on main. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../host-session-native-chat-operations.ts | 5 +- .../host-session-terminal-operations.ts | 1 - ...native-host-session-terminal-operations.ts | 1 - .../session/use-mobile-native-chat-session.ts | 118 ++++++++---------- 4 files changed, 57 insertions(+), 68 deletions(-) diff --git a/mobile/src/session/host-session-native-chat-operations.ts b/mobile/src/session/host-session-native-chat-operations.ts index 635925405a0..fa37cb77a38 100644 --- a/mobile/src/session/host-session-native-chat-operations.ts +++ b/mobile/src/session/host-session-native-chat-operations.ts @@ -36,11 +36,12 @@ export type HostSessionNativeChatImageAttachResult = export type HostSessionNativeChatOperations = { /** Whether the serving host can read this workspace's agent transcripts. */ readability(workspaceId: string): Promise + /** No error callback: the transport has none, and a stream error arrives as an `error` frame + * through `onEvent`, which is where the caller already handles it. */ subscribe( target: HostSessionNativeChatTarget, limit: number, - onEvent: (event: MobileNativeChatStreamFrame) => void, - onError: () => void + onEvent: (event: MobileNativeChatStreamFrame) => void ): () => void read( target: HostSessionNativeChatTarget, diff --git a/mobile/src/session/host-session-terminal-operations.ts b/mobile/src/session/host-session-terminal-operations.ts index 144aded7d30..5bffe947238 100644 --- a/mobile/src/session/host-session-terminal-operations.ts +++ b/mobile/src/session/host-session-terminal-operations.ts @@ -57,7 +57,6 @@ export type HostSessionTerminalOperations = { onEvent: (event: HostSessionTerminalStreamEvent) => void, onError: () => void ): () => void - acknowledge(terminalId: string, throughSequence: number): void sendInput( terminalId: string, text: string, diff --git a/mobile/src/session/native-host-session-terminal-operations.ts b/mobile/src/session/native-host-session-terminal-operations.ts index a518aedc047..835f8156b8c 100644 --- a/mobile/src/session/native-host-session-terminal-operations.ts +++ b/mobile/src/session/native-host-session-terminal-operations.ts @@ -22,7 +22,6 @@ export function nativeHostSessionTerminalOperations( ) return unsubscribe }, - acknowledge() {}, async sendInput(terminalId, text, enter, clientId) { return client .sendRequest( diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 86873816db7..e5de241cea8 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -156,71 +156,61 @@ export function useMobileNativeChatSession(args: { sessionId, transcriptPath }) - const unsubscribe = operations.subscribe( - target, - limitRef.current, - (raw) => { - if (cancelled) { - return - } - const frame = raw as MobileNativeChatStreamFrame - const applied = applyMobileNativeChatStreamFrame({ - merger: mergerRef.current, - frame, - limit: limitRef.current, - replaceSnapshot: !snapshotSeenRef.current - }) - if (applied.kind === 'ignored') { - return - } - if (applied.kind === 'error') { - setRead({ operations, identity, status: 'error' }) - setError(applied.error) - return - } - if (frame.type === 'snapshot' && !applied.pending) { - // A pending window has no transcript behind it, so the snapshot that - // follows is still this subscription's base, not a reconnect replay. - snapshotSeenRef.current = true - } - if (applied.windowReplaced || frame.type === 'snapshot') { - // Why: any authoritative window (and any replay merge) invalidates an - // in-flight older-page request; stale results must not land on it. - streamGenerationRef.current += 1 - loadingEarlierRef.current = false - setLoadingEarlier(false) - } - if (applied.windowReplaced) { - // Only a genuinely fresh window resets the grown read window — an - // overlapping reconnect replay keeps the paged-in history and limit. - limitRef.current = INITIAL_LIMIT - beforeOffsetRef.current = applied.beforeOffset ?? null - setHasMore(applied.hasMore ?? applied.messages.length >= INITIAL_LIMIT) - } - setMessages(applied.messages) - if (!applied.windowReplaced && applied.hasMore != null) { - setHasMore(applied.hasMore) - } - if (!applied.windowReplaced && applied.beforeOffset != null) { - beforeOffsetRef.current = applied.beforeOffset - } - if (applied.cursorInvalidated) { - // Fall back to a growing-tail read so history trimmed by live appends - // cannot leave a gap between the retained window and the old cursor. - streamGenerationRef.current += 1 - loadingEarlierRef.current = false - setLoadingEarlier(false) - beforeOffsetRef.current = null - } - setRead({ operations, identity, status: applied.pending ? 'awaiting-transcript' : 'ready' }) - }, - () => { - if (!cancelled) { - setRead({ operations, identity, status: 'error' }) - setError('Transcript stream failed') - } + const unsubscribe = operations.subscribe(target, limitRef.current, (raw) => { + if (cancelled) { + return } - ) + const frame = raw as MobileNativeChatStreamFrame + const applied = applyMobileNativeChatStreamFrame({ + merger: mergerRef.current, + frame, + limit: limitRef.current, + replaceSnapshot: !snapshotSeenRef.current + }) + if (applied.kind === 'ignored') { + return + } + if (applied.kind === 'error') { + setRead({ operations, identity, status: 'error' }) + setError(applied.error) + return + } + if (frame.type === 'snapshot' && !applied.pending) { + // A pending window has no transcript behind it, so the snapshot that + // follows is still this subscription's base, not a reconnect replay. + snapshotSeenRef.current = true + } + if (applied.windowReplaced || frame.type === 'snapshot') { + // Why: any authoritative window (and any replay merge) invalidates an + // in-flight older-page request; stale results must not land on it. + streamGenerationRef.current += 1 + loadingEarlierRef.current = false + setLoadingEarlier(false) + } + if (applied.windowReplaced) { + // Only a genuinely fresh window resets the grown read window — an + // overlapping reconnect replay keeps the paged-in history and limit. + limitRef.current = INITIAL_LIMIT + beforeOffsetRef.current = applied.beforeOffset ?? null + setHasMore(applied.hasMore ?? applied.messages.length >= INITIAL_LIMIT) + } + setMessages(applied.messages) + if (!applied.windowReplaced && applied.hasMore != null) { + setHasMore(applied.hasMore) + } + if (!applied.windowReplaced && applied.beforeOffset != null) { + beforeOffsetRef.current = applied.beforeOffset + } + if (applied.cursorInvalidated) { + // Fall back to a growing-tail read so history trimmed by live appends + // cannot leave a gap between the retained window and the old cursor. + streamGenerationRef.current += 1 + loadingEarlierRef.current = false + setLoadingEarlier(false) + beforeOffsetRef.current = null + } + setRead({ operations, identity, status: applied.pending ? 'awaiting-transcript' : 'ready' }) + }) return () => { cancelled = true