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.
This commit is contained in:
Kelvin Amoaba
2026-10-28 17:41:03 +00:00
committed by GitHub
parent 117bbc48e6
commit 49e53bd345
9 changed files with 71 additions and 20 deletions
@@ -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 (
<View style={styles.center}>
<Text style={styles.emptyTitle}>{copy.title}</Text>
<Text style={styles.emptySubtitle}>{copy.subtitle}</Text>
</View>
)
}
@@ -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}
+5 -7
View File
@@ -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 ? (
<View style={styles.center}>
<Text style={styles.emptyTitle}>{emptyState.title}</Text>
<Text style={styles.emptySubtitle}>{emptyState.subtitle}</Text>
</View>
) : null
const emptyStateView = emptyState ? <MobileNativeChatEmptyStateView copy={emptyState} /> : null
if (readFailedFinally && emptyStateView) {
return <View style={[styles.root, { paddingBottom: bottomPad }]}>{emptyStateView}</View>
@@ -31,6 +31,7 @@ export type MobileNativeChatController = {
showNativeChat: boolean
showNativeChatRef: MutableRefObject<boolean>
nativeChatAgent: string | null
nativeChatSessionLive: boolean
chatComposerScopeKey?: string | null
reportQueuedEditError?: (message: string) => void
chatDraftKey: string | null
@@ -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')
})
@@ -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': {
@@ -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
})
})
@@ -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
}
}
@@ -225,6 +225,7 @@ export function useMobileNativeChatController(args: {
showNativeChat,
showNativeChatRef,
nativeChatAgent: activeChatResolution?.agent ?? null,
nativeChatSessionLive: activeChatResolution?.sessionLive === true,
chatDraftKey,
setChatComposerText,
getChatComposerEditGeneration,