From 49e53bd345bb97a0e647c1079c22f9d5b3ee00d0 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:41:03 +0000 Subject: [PATCH] fix(mobile): greet a just-started terminal Claude with "Start a chat" (#35219) Claude reports its session at startup but writes no transcript until the first prompt, so the host answers pending and the phone said "Can't find this conversation yet". A session the running agent reported live is new; only remembered sessions keep the not-found copy. --- .../MobileNativeChatEmptyStateView.tsx | 12 ++++++ .../src/session/MobileNativeChatOverlay.tsx | 1 + mobile/src/session/MobileNativeChatView.tsx | 12 +++--- .../mobile-native-chat-controller-contract.ts | 1 + .../mobile-native-chat-render-data.test.ts | 7 ++++ .../session/mobile-native-chat-render-data.ts | 7 +++- .../mobile-native-chat-eligibility.test.ts | 37 ++++++++++++++----- .../mobile-native-chat-eligibility.ts | 13 ++++++- .../use-mobile-native-chat-controller.ts | 1 + 9 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatEmptyStateView.tsx diff --git a/mobile/src/session/MobileNativeChatEmptyStateView.tsx b/mobile/src/session/MobileNativeChatEmptyStateView.tsx new file mode 100644 index 00000000000..ed6a5fc3d70 --- /dev/null +++ b/mobile/src/session/MobileNativeChatEmptyStateView.tsx @@ -0,0 +1,12 @@ +import { Text, View } from 'react-native' +import type { NativeChatEmptyStateCopy } from '../../../src/shared/native-chat/native-chat-empty-state' +import { styles } from './mobile-native-chat-view-styles' + +export function MobileNativeChatEmptyStateView({ copy }: { copy: NativeChatEmptyStateCopy }) { + return ( + + {copy.title} + {copy.subtitle} + + ) +} diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index b005393a341..c2bc70ecbd3 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -177,6 +177,7 @@ export function MobileNativeChatOverlay({ error={session.error} readFailedFinally={session.readFailedFinally === true} agent={controller.nativeChatAgent} + sessionLive={controller.nativeChatSessionLive} agentWorking={controller.nativeChatTurnActive} awaitingInput={controller.nativeChatAwaitingInput} canStop={controller.nativeChatCanStop} diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 7fe3da017d2..9889b97beaa 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -26,6 +26,7 @@ import type { NativeChatResolvedPrompt } from '../../../src/shared/native-chat-r import { colors } from '../theme/mobile-theme' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' import { styles } from './mobile-native-chat-view-styles' +import { MobileNativeChatEmptyStateView } from './MobileNativeChatEmptyStateView' import { mobileNativeChatListFooter } from './mobile-native-chat-list-footer' import { mobileNativeChatListHeader } from './mobile-native-chat-list-header' import { @@ -75,6 +76,7 @@ type Props = ComposerTrayProps & { // Permanent read failure replaces the pane because further chat actions cannot succeed. readFailedFinally?: boolean agent?: string | null + sessionLive?: boolean /** The turn is live: working, or waiting on the reader. */ agentWorking?: boolean canStop?: boolean @@ -150,6 +152,7 @@ export function MobileNativeChatView({ error, readFailedFinally = false, agent, + sessionLive, agentWorking, canStop = agentWorking, awaitingInput = null, @@ -310,7 +313,7 @@ export function MobileNativeChatView({ // Changing text scale relays out every row, so preserve the page’s reading anchor. const layoutKey = `${fontScale}` const footer = mobileNativeChatListFooter(liveStatus, turns.waitingRows, renderItem) - const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) + const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error, sessionLive) const lockReason = useSettledMobileNativeChatInputLock(inputLockReason) const composerPlaceholder = mobileNativeChatComposerPlaceholder( @@ -320,12 +323,7 @@ export function MobileNativeChatView({ // Lock only terminal-backed Send: it can answer an agent prompt, while drafting is harmless and structured sends queue. const expandedPromptOwnsSend = !structuredActivityUi && !collapsedPrompt && (ask ?? permission ?? question) != null - const emptyStateView = emptyState ? ( - - {emptyState.title} - {emptyState.subtitle} - - ) : null + const emptyStateView = emptyState ? : null if (readFailedFinally && emptyStateView) { return {emptyStateView} diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 4635edaad07..55d0bf227c1 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -31,6 +31,7 @@ export type MobileNativeChatController = { showNativeChat: boolean showNativeChatRef: MutableRefObject nativeChatAgent: string | null + nativeChatSessionLive: boolean chatComposerScopeKey?: string | null reportQueuedEditError?: (message: string) => void chatDraftKey: string | null diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index ba81e821a7f..6ebd1f38d19 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -41,6 +41,13 @@ describe('mobileNativeChatEmptyState', () => { } ) + it('invites a first message when the running agent reported a session with no transcript yet', () => { + expect(mobileNativeChatEmptyState('awaiting-transcript', 'claude', undefined, true)).toEqual({ + title: 'Start a chat with Claude', + subtitle: 'Ask Claude to inspect code, explain output, or make a change.' + }) + }) + it('falls back to "the agent" when the agent is unknown', () => { expect(mobileNativeChatEmptyState('ready', null)?.title).toBe('Start a chat with the agent') }) diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index e2b4d30991a..1bf8a97f36d 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -24,14 +24,17 @@ import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' export function mobileNativeChatEmptyState( status: MobileNativeChatStatus, agent: string | null, - error?: string + error?: string, + sessionLive = false ): NativeChatEmptyStateCopy | null { const agentLabel = agent ? formatAgentTypeLabel(agent) : 'the agent' switch (status) { // Missing history may mean new or unreadable; promise neither in the waiting copy. case 'waiting-session': - case 'awaiting-transcript': return formatNativeChatEmptyStateCopy('notFound', agentLabel) + // A session the running agent just reported has no file until its first prompt. + case 'awaiting-transcript': + return formatNativeChatEmptyStateCopy(sessionLive ? 'empty' : 'notFound', agentLabel) case 'ready': return formatNativeChatEmptyStateCopy('empty', agentLabel) case 'error': { diff --git a/mobile/src/session/native-chat/mobile-native-chat-eligibility.test.ts b/mobile/src/session/native-chat/mobile-native-chat-eligibility.test.ts index ec44b9a54f8..31715b772e6 100644 --- a/mobile/src/session/native-chat/mobile-native-chat-eligibility.test.ts +++ b/mobile/src/session/native-chat/mobile-native-chat-eligibility.test.ts @@ -64,7 +64,8 @@ describe('resolveMobileNativeChat', () => { agent: 'claude', sessionId: 'sess-1', transcriptPath: '/tmp/claude-real-transcript.jsonl', - agentConfirmed: true + agentConfirmed: true, + sessionLive: true }) }) @@ -74,7 +75,13 @@ describe('resolveMobileNativeChat', () => { type: 'terminal', agentStatus: status({ agentType: 'codex' }) }) - ).toEqual({ agent: 'codex', sessionId: null, transcriptPath: null, agentConfirmed: true }) + ).toEqual({ + agent: 'codex', + sessionId: null, + transcriptPath: null, + agentConfirmed: true, + sessionLive: false + }) }) it('admits OpenClaude with its distinct agent identity', () => { @@ -82,7 +89,8 @@ describe('resolveMobileNativeChat', () => { agent: 'openclaude', sessionId: null, transcriptPath: null, - agentConfirmed: true + agentConfirmed: true, + sessionLive: false }) }) @@ -98,7 +106,8 @@ describe('resolveMobileNativeChat', () => { agent, sessionId: 'real-session', transcriptPath: null, - agentConfirmed: true + agentConfirmed: true, + sessionLive: true }) expect(resolveMobileNativeChat(tab, false)).toBeNull() }) @@ -155,7 +164,8 @@ describe('resolveMobileNativeChat', () => { agent: 'codex', sessionId: 'structured-1', transcriptPath: null, - agentConfirmed: true + agentConfirmed: true, + sessionLive: true }) }) @@ -170,7 +180,8 @@ describe('resolveMobileNativeChat', () => { agent: 'claude', sessionId: 'structured-1', transcriptPath: null, - agentConfirmed: true + agentConfirmed: true, + sessionLive: true }) }) @@ -179,7 +190,13 @@ describe('resolveMobileNativeChat', () => { (agent) => { expect( resolveMobileNativeChat({ type: 'agent-session', sessionId: 'structured-1', agent }) - ).toEqual({ agent, sessionId: 'structured-1', transcriptPath: null, agentConfirmed: true }) + ).toEqual({ + agent, + sessionId: 'structured-1', + transcriptPath: null, + agentConfirmed: true, + sessionLive: true + }) } ) @@ -236,7 +253,8 @@ describe('withRememberedNativeChatIdentity', () => { agent: 'claude', sessionId: 'sess-1', transcriptPath: '/t/sess-1.jsonl', - agentConfirmed: false + agentConfirmed: false, + sessionLive: false }) expect(resolveMobileNativeChatSessionId(next)).toBe('sess-1') }) @@ -258,7 +276,8 @@ describe('withRememberedNativeChatIdentity', () => { agent: 'codex', sessionId: null, transcriptPath: null, - agentConfirmed: true + agentConfirmed: true, + sessionLive: false }) }) diff --git a/mobile/src/session/native-chat/mobile-native-chat-eligibility.ts b/mobile/src/session/native-chat/mobile-native-chat-eligibility.ts index 3d9db47c9af..6312ab5d13e 100644 --- a/mobile/src/session/native-chat/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/native-chat/mobile-native-chat-eligibility.ts @@ -13,6 +13,8 @@ export type MobileNativeChatResolution = { transcriptPath: string | null // Remembered identity alone permits reads only; typing could otherwise reach a bare shell. agentConfirmed: boolean + // The running agent reported this session, so a missing transcript means no prompt yet. + sessionLive: boolean } export type MobileNativeChatIdentity = { @@ -43,7 +45,13 @@ export function resolveMobileNativeChat( if (tab.type === 'agent-session') { // Structured tabs use journals, so no per-agent transcript layout is needed. return tab.sessionId && isStructuredAgentId(tab.agent) - ? { agent: tab.agent, sessionId: tab.sessionId, transcriptPath: null, agentConfirmed: true } + ? { + agent: tab.agent, + sessionId: tab.sessionId, + transcriptPath: null, + agentConfirmed: true, + sessionLive: true + } : null } if (tab.type !== 'terminal') { @@ -70,7 +78,8 @@ export function resolveMobileNativeChat( transcriptPath: liveSession ? (liveSession.transcriptPath ?? null) : (rememberedSession?.transcriptPath ?? null), - agentConfirmed: evidenceAgent !== null + agentConfirmed: evidenceAgent !== null, + sessionLive: liveSession?.id != null } } diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index ff4fa08a225..0f130dbcada 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -225,6 +225,7 @@ export function useMobileNativeChatController(args: { showNativeChat, showNativeChatRef, nativeChatAgent: activeChatResolution?.agent ?? null, + nativeChatSessionLive: activeChatResolution?.sessionLive === true, chatDraftKey, setChatComposerText, getChatComposerEditGeneration,