fix(mobile): keep structured chat provider-aware

This commit is contained in:
Brennan Benson
2026-08-10 04:26:07 -07:00
parent 41616eb32e
commit 5ea6f86dab
5 changed files with 72 additions and 22 deletions
@@ -4651,6 +4651,7 @@ export default function SessionScreen() {
) : activeStructuredTab ? (
<MobileStructuredAgentSessionView
key={activeStructuredTab.sessionId}
agent={activeStructuredTab.agent}
items={structuredSessionEntry.session.items}
status={structuredSessionEntry.session.status}
error={structuredSessionEntry.session.error}
@@ -59,6 +59,7 @@ describe('MobileStructuredAgentSessionView command seam', () => {
act(() => {
renderer = create(
createElement(MobileStructuredAgentSessionView, {
agent: 'codex',
items: [],
status: 'ready',
hasOlder: false,
@@ -108,4 +109,29 @@ describe('MobileStructuredAgentSessionView command seam', () => {
renderer!.root.findByProps({ accessibilityRole: 'alert' }).findByType('Text').children
).toEqual(['/review is not available in chat sessions.'])
})
it('uses Claude branding and command suggestions for Claude sessions', () => {
act(() => {
renderer!.update(
createElement(MobileStructuredAgentSessionView, {
...renderer!.root.findByType(MobileStructuredAgentSessionView).props,
agent: 'claude'
})
)
})
const composer = renderer!.root.findByType('MobileNativeChatComposer')
expect(composer.props).toMatchObject({ agent: 'claude', placeholder: 'Message Claude' })
expect(composer.props.slashCommands.map((command: { name: string }) => command.name)).toEqual([
'model',
'effort',
'clear',
'compact',
'init',
'review',
'help'
])
const empty = renderer!.root.findByType('FlatList').props.ListEmptyComponent
expect(empty.props.children[0].props.children).toBe('New Claude chat')
})
})
@@ -40,10 +40,12 @@ import { useMobileStructuredComposerState } from './use-mobile-structured-compos
import {
dispatchMobileStructuredComposerCommand,
isMobileStructuredComposerCommand,
MOBILE_STRUCTURED_SLASH_COMMANDS
mobileStructuredSlashCommands
} from './mobile-structured-composer-command'
import type { MobileStructuredAgent } from './mobile-structured-session-create'
type Props = {
agent: MobileStructuredAgent
items: AgentJournalRenderItem[]
status: 'idle' | 'loading' | 'ready' | 'error'
error?: string
@@ -75,6 +77,8 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
const [commandError, setCommandError] = useState<string | null>(null)
const turnId = activeMobileStructuredTurnId(props.items)
const allAttachments = [...restored, ...props.attachments]
const agentName = props.agent === 'claude' ? 'Claude' : 'Codex'
const slashCommands = useMemo(() => mobileStructuredSlashCommands(props.agent), [props.agent])
const rows = useMemo(
() => buildMobileStructuredTimeline(props.items, props.outbox),
[props.items, props.outbox]
@@ -179,7 +183,7 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
ListEmptyComponent={
<View style={styles.center}>
<Text style={styles.title}>
{props.status === 'error' ? 'Conversation unavailable' : 'New Codex chat'}
{props.status === 'error' ? 'Conversation unavailable' : `New ${agentName} chat`}
</Text>
<Text style={styles.subtitle}>{props.error ?? 'Send a message to get started.'}</Text>
</View>
@@ -206,11 +210,15 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
value={composerText}
onChangeText={setComposerText}
onSend={async (text) => {
if (allAttachments.length > 0 && isMobileStructuredComposerCommand(text)) {
if (allAttachments.length > 0 && isMobileStructuredComposerCommand(text, props.agent)) {
setCommandError('Remove attachments before using a chat-session command.')
return false
}
const command = await dispatchMobileStructuredComposerCommand(text, props.sessionOptions)
const command = await dispatchMobileStructuredComposerCommand(
text,
props.sessionOptions,
props.agent
)
if (command.handled) {
setCommandError(command.error)
if (command.accepted) {
@@ -226,8 +234,8 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
}
return accepted
}}
agent="codex"
slashCommands={MOBILE_STRUCTURED_SLASH_COMMANDS}
agent={props.agent}
slashCommands={slashCommands}
sessionOptions={{ controller: props.sessionOptions, isWorking: false }}
onAttachImage={props.onAttachImage}
attachments={allAttachments}
@@ -240,7 +248,7 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
}}
isAttaching={props.isAttaching}
disabled={props.status !== 'ready'}
placeholder={props.status === 'ready' ? 'Message Codex' : 'Reconnecting…'}
placeholder={props.status === 'ready' ? `Message ${agentName}` : 'Reconnecting…'}
/>
</View>
)
@@ -46,12 +46,12 @@ describe('structured composer slash dispatch', () => {
it('opens live model and effort pickers instead of sending command text', async () => {
const options = controller()
expect(await dispatchMobileStructuredComposerCommand('/model', options)).toEqual({
expect(await dispatchMobileStructuredComposerCommand('/model', options, 'codex')).toEqual({
handled: true,
accepted: true,
error: null
})
expect(await dispatchMobileStructuredComposerCommand('/effort', options)).toEqual({
expect(await dispatchMobileStructuredComposerCommand('/effort', options, 'codex')).toEqual({
handled: true,
accepted: true,
error: null
@@ -63,15 +63,15 @@ describe('structured composer slash dispatch', () => {
it('applies command arguments through structured setOption', async () => {
const options = controller()
await dispatchMobileStructuredComposerCommand('/model GPT Next', options)
await dispatchMobileStructuredComposerCommand('/effort high', options)
await dispatchMobileStructuredComposerCommand('/model GPT Next', options, 'codex')
await dispatchMobileStructuredComposerCommand('/effort high', options, 'claude')
expect(options.setOption).toHaveBeenNthCalledWith(1, 'model', 'gpt-next')
expect(options.setOption).toHaveBeenNthCalledWith(2, 'effort', 'high')
})
it('visibly refuses every other advertised Codex command', async () => {
const result = await dispatchMobileStructuredComposerCommand('/review', controller())
const result = await dispatchMobileStructuredComposerCommand('/review', controller(), 'codex')
expect(result).toEqual({
handled: true,
accepted: true,
@@ -81,7 +81,7 @@ describe('structured composer slash dispatch', () => {
it('leaves unadvertised slash text available as an ordinary prompt', async () => {
await expect(
dispatchMobileStructuredComposerCommand('/not-a-catalog-command', controller())
dispatchMobileStructuredComposerCommand('/not-a-catalog-command', controller(), 'claude')
).resolves.toEqual({ handled: false, accepted: false, error: null })
})
})
@@ -1,17 +1,28 @@
import { getVerifiedNativeChatCommands } from '../../../src/shared/native-chat-agent-profiles'
import type { SlashCommandSuggestion } from '../../../src/shared/native-chat-slash-commands'
import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options'
import type { MobileStructuredAgent } from './mobile-structured-session-create'
const MODEL_COMMAND: SlashCommandSuggestion = {
name: 'model',
description: 'Choose the model'
}
const EFFORT_COMMAND: SlashCommandSuggestion = {
name: 'effort',
description: 'Choose reasoning effort'
}
export const MOBILE_STRUCTURED_SLASH_COMMANDS: readonly SlashCommandSuggestion[] = [
...getVerifiedNativeChatCommands('codex').slice(0, 1),
EFFORT_COMMAND,
...getVerifiedNativeChatCommands('codex').slice(1)
]
export function mobileStructuredSlashCommands(
agent: MobileStructuredAgent
): readonly SlashCommandSuggestion[] {
return [
MODEL_COMMAND,
EFFORT_COMMAND,
...getVerifiedNativeChatCommands(agent).filter(
(command) => command.name !== 'model' && command.name !== 'effort'
)
]
}
export type MobileStructuredCommandOutcome = {
handled: boolean
@@ -27,10 +38,13 @@ function commandParts(text: string): { name: string; argument: string } | null {
return match ? { name: match[1]!.toLowerCase(), argument: match[2]?.trim() ?? '' } : null
}
export function isMobileStructuredComposerCommand(text: string): boolean {
export function isMobileStructuredComposerCommand(
text: string,
agent: MobileStructuredAgent
): boolean {
const command = commandParts(text)
return Boolean(
command && MOBILE_STRUCTURED_SLASH_COMMANDS.some((entry) => entry.name === command.name)
command && mobileStructuredSlashCommands(agent).some((entry) => entry.name === command.name)
)
}
@@ -44,10 +58,11 @@ function unavailable(name: string): MobileStructuredCommandOutcome {
export async function dispatchMobileStructuredComposerCommand(
text: string,
controller: MobileNativeChatSessionOptionsController
controller: MobileNativeChatSessionOptionsController,
agent: MobileStructuredAgent
): Promise<MobileStructuredCommandOutcome> {
const command = commandParts(text)
if (!command || !isMobileStructuredComposerCommand(text)) {
if (!command || !isMobileStructuredComposerCommand(text, agent)) {
return { handled: false, accepted: false, error: null }
}
if (command.name !== 'model' && command.name !== 'effort') {