feat(mobile): add repo context support and streaming

- Add repoContext parameter to Gateway agent method
- Update AgentParamsSchema to include repoContext (owner/name/branch)
- Add repoContext to AgentCommandOpts type
- Pass repoContext through gateway to agent command
- Fix streaming implementation with proper enum usage (MessageRole, ToolCallStatus)
- Add disabled prop to MessageInputView
- Clean up lint issues (remove unused imports)

The mobile app can now send repository context (owner/repo/branch) when starting
a coding session. The gateway will receive this context and pass it to the agent
command, preparing for workspace clone/checkout implementation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
semihpolat 2026-01-23 14:24:57 -08:00
parent a591c51010
commit 175c7a0cbc
9 changed files with 258 additions and 38 deletions

View File

@ -8,9 +8,12 @@ interface Props {
placeholder: string placeholder: string
onChangeText: (text: string) => void onChangeText: (text: string) => void
onSend: () => void onSend: () => void
disabled?: boolean
} }
const MessageInputView: React.FC<Props> = ({ text, placeholder, onChangeText, onSend }) => { const MessageInputView: React.FC<Props> = ({ text, placeholder, onChangeText, onSend, disabled = false }) => {
const isSendDisabled = disabled || !text.trim()
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.inputWrapper}> <View style={styles.inputWrapper}>
@ -22,11 +25,12 @@ const MessageInputView: React.FC<Props> = ({ text, placeholder, onChangeText, on
placeholderTextColor={Colors.secondaryText} placeholderTextColor={Colors.secondaryText}
multiline multiline
maxLength={2000} maxLength={2000}
editable={!disabled}
/> />
<TouchableOpacity <TouchableOpacity
style={[styles.sendButton, { opacity: text.trim() ? 1 : 0.5 }]} style={[styles.sendButton, { opacity: isSendDisabled ? 0.5 : 1 }]}
onPress={onSend} onPress={onSend}
disabled={!text.trim()} disabled={isSendDisabled}
> >
<Ionicons name="arrow-up" size={20} color={Colors.buttonPrimaryText} /> <Ionicons name="arrow-up" size={20} color={Colors.buttonPrimaryText} />
</TouchableOpacity> </TouchableOpacity>

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { View, Text, ScrollView, StyleSheet } from 'react-native' import { View, Text, StyleSheet } from 'react-native'
import { Colors, Spacing, Radius } from '../theme/colors' import { Colors, Spacing } from '../theme/colors'
import { Message } from '../models/types' import { Message } from '../models/types'
import ToolCallView from './ToolCallView' import ToolCallView from './ToolCallView'

View File

@ -22,7 +22,6 @@ import {
allModels, allModels,
} from '../models/types' } from '../models/types'
import MessageInputView from './MessageInputView' import MessageInputView from './MessageInputView'
import Chip from './Chip'
import { useGitHubRepos, type GitHubRepo, type GitHubBranch } from '../hooks/useGitHub' import { useGitHubRepos, type GitHubRepo, type GitHubBranch } from '../hooks/useGitHub'
import { useSettings } from '../hooks/useSettings' import { useSettings } from '../hooks/useSettings'
import { GitHubAPI } from '../services/github' import { GitHubAPI } from '../services/github'

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import AsyncStorage from '@react-native-async-storage/async-storage' import AsyncStorage from '@react-native-async-storage/async-storage'
const SETTINGS_KEY = '@clawdbot_settings' const SETTINGS_KEY = '@clawdbot_settings'

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef, useCallback } from 'react'
import { import {
View, View,
Text, Text,
@ -8,46 +8,200 @@ import {
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
StyleSheet, StyleSheet,
ActivityIndicator,
} from 'react-native' } from 'react-native'
import { Ionicons } from '@expo/vector-icons' import { Ionicons } from '@expo/vector-icons'
import { useAppState } from '../contexts/AppContext' import { useAppState } from '../contexts/AppContext'
import { Session, getFullName } from '../models/types' import { Session, getFullName, Message, ToolCall, MessageRole, ToolCallStatus } from '../models/types'
import { Colors, Spacing, Radius } from '../theme/colors' import { Colors, Spacing, Radius } from '../theme/colors'
import MessageView from '../components/MessageView' import MessageView from '../components/MessageView'
import MessageInputView from '../components/MessageInputView' import MessageInputView from '../components/MessageInputView'
import Chip from '../components/Chip' import Chip from '../components/Chip'
import { useGateway, type ToolCallEvent } from '../hooks/useGateway'
interface Props { interface Props {
session: Session session: Session
onBack: () => void onBack: () => void
} }
// In-memory messages for this session (not persisted to AppContext)
const createMessage = (): Message => ({
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2)}`,
role: MessageRole.Assistant,
content: '',
timestamp: new Date(),
toolCalls: [],
})
const ChatView: React.FC<Props> = ({ session, onBack }) => { const ChatView: React.FC<Props> = ({ session, onBack }) => {
const { sessions, sendMessage } = useAppState() const { sessions } = useAppState()
const [messageText, setMessageText] = useState('') const [messageText, setMessageText] = useState('')
const [showCreatePR, setShowCreatePR] = useState(false) const [showCreatePR, setShowCreatePR] = useState(false)
const [messages, setMessages] = useState<Message[]>([])
const [pendingToolCalls, setPendingToolCalls] = useState<Map<string, ToolCall>>(new Map())
const [isRunning, setIsRunning] = useState(false)
const scrollViewRef = useRef<ScrollView>(null) const scrollViewRef = useRef<ScrollView>(null)
// Get current session from state to see updates // Get current session from state
const currentSession = sessions.find((s) => s.id === session.id) || session const currentSession = sessions.find((s) => s.id === session.id) || session
const hasToolCalls = currentSession.messages.some((m) => m.toolCalls.length > 0) // Gateway hooks
const { connected: gatewayConnected, sendMessage: sendGatewayMessage, onToolCall, onAssistant, onLifecycle } = useGateway()
const handleSendMessage = () => { // Handle tool call events
if (messageText.trim()) { useEffect(() => {
sendMessage(messageText, currentSession) const unsubscribe = onToolCall((event: ToolCallEvent) => {
setMessageText('') const { toolCallId, name, phase, args, partialResult, result, isError } = event
setPendingToolCalls((prev) => {
const next = new Map(prev)
if (phase === 'start') {
const toolCall: ToolCall = {
id: toolCallId,
type: name as any,
name,
input: JSON.stringify(args || {}),
status: ToolCallStatus.Running,
}
next.set(toolCallId, toolCall)
// Add to current assistant message
setMessages((prevMsgs) => {
const updated = [...prevMsgs]
const lastMsg = updated[updated.length - 1]
if (lastMsg?.role === MessageRole.Assistant) {
lastMsg.toolCalls = [...lastMsg.toolCalls, toolCall]
}
return updated
})
} else if (phase === 'update') {
const existing = next.get(toolCallId)
if (existing) {
existing.output = partialResult || ''
}
} else if (phase === 'end') {
const existing = next.get(toolCallId)
if (existing) {
existing.status = isError ? ToolCallStatus.Failed : ToolCallStatus.Completed
existing.output = result ? JSON.stringify(result, null, 2) : existing.output
}
}
return next
})
// Trigger scroll
setTimeout(() => {
scrollViewRef.current?.scrollToEnd({ animated: true })
}, 50)
})
return unsubscribe
}, [onToolCall])
// Handle assistant events (text streaming)
useEffect(() => {
const unsubscribe = onAssistant((event) => {
const { delta } = event
setMessages((prev) => {
const updated = [...prev]
let lastMsg = updated[updated.length - 1]
// Create new message if none exists or last is from user
if (!lastMsg || lastMsg.role !== MessageRole.Assistant) {
lastMsg = createMessage()
updated.push(lastMsg)
}
if (delta) {
lastMsg.content += delta
}
return updated
})
// Trigger scroll
setTimeout(() => {
scrollViewRef.current?.scrollToEnd({ animated: true })
}, 50)
})
return unsubscribe
}, [onAssistant])
// Handle lifecycle events
useEffect(() => {
const unsubscribe = onLifecycle((event) => {
const { msg, phase } = event as { msg?: string; phase?: string }
if (phase === 'start') {
setIsRunning(true)
// Create new assistant message for this run
setMessages((prev) => [...prev, createMessage()])
} else if (phase === 'end' || phase === 'error') {
setIsRunning(false)
}
if (msg) {
console.log('[Agent]', msg)
}
})
return unsubscribe
}, [onLifecycle])
const handleSendMessage = async () => {
if (!messageText.trim()) return
const userMsg: Message = {
id: `msg-${Date.now()}-user`,
role: MessageRole.User,
content: messageText,
timestamp: new Date(),
toolCalls: [],
}
setMessages((prev) => [...prev, userMsg])
const textToSend = messageText
setMessageText('')
// Scroll to show user message
setTimeout(() => {
scrollViewRef.current?.scrollToEnd({ animated: true })
}, 50)
// Send to gateway
const result = await sendGatewayMessage(textToSend, {
owner: currentSession.repository.owner,
name: currentSession.repository.name,
branch: currentSession.repository.defaultBranch,
})
if (!result) {
// Failed to send - show error
setMessages((prev) => {
const errorMsg: Message = {
id: `msg-${Date.now()}-error`,
role: MessageRole.Assistant,
content: 'Failed to connect to clawdbot gateway. Please check your settings.',
timestamp: new Date(),
toolCalls: [],
}
return [...prev, errorMsg]
})
} }
} }
// Auto scroll to bottom when messages change // Combine session messages with live messages
useEffect(() => { const allMessages = [...currentSession.messages, ...messages]
if (scrollViewRef.current) { const hasToolCalls = allMessages.some((m) => m.toolCalls.length > 0)
setTimeout(() => {
scrollViewRef.current?.scrollToEnd({ animated: true }) // Connection status indicator
}, 100) const connectionStatus = gatewayConnected ? 'Connected' : 'Disconnected'
} const connectionColor = gatewayConnected ? Colors.success : Colors.error
}, [currentSession.messages.length])
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
@ -63,9 +217,14 @@ const ChatView: React.FC<Props> = ({ session, onBack }) => {
? currentSession.title.slice(0, 30) + '...' ? currentSession.title.slice(0, 30) + '...'
: currentSession.title} : currentSession.title}
</Text> </Text>
<Text style={styles.headerSubtitle}> <View style={styles.headerSubtitleRow}>
{getFullName(currentSession.repository)} · Default <Text style={styles.headerSubtitle}>
</Text> {getFullName(currentSession.repository)} · {currentSession.repository.defaultBranch}
</Text>
{isRunning && (
<ActivityIndicator size="small" color={Colors.accent} style={styles.spinner} />
)}
</View>
</View> </View>
<TouchableOpacity style={styles.menuButton}> <TouchableOpacity style={styles.menuButton}>
@ -73,6 +232,14 @@ const ChatView: React.FC<Props> = ({ session, onBack }) => {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Connection status bar */}
<View style={[styles.connectionBar, { backgroundColor: connectionColor + '20' }]}>
<View style={[styles.connectionDot, { backgroundColor: connectionColor }]} />
<Text style={[styles.connectionText, { color: connectionColor }]}>
Gateway: {connectionStatus}
</Text>
</View>
{/* Messages + Input with keyboard avoidance */} {/* Messages + Input with keyboard avoidance */}
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.keyboardAvoidingView} style={styles.keyboardAvoidingView}
@ -85,15 +252,18 @@ const ChatView: React.FC<Props> = ({ session, onBack }) => {
contentContainerStyle={styles.messagesContainer} contentContainerStyle={styles.messagesContainer}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{currentSession.messages.map((message) => ( {allMessages.map((message, index) => (
<MessageView key={message.id} message={message} /> <MessageView
key={`${message.id}-${index}`}
message={message}
/>
))} ))}
{/* Create PR button */} {/* Create PR button */}
{hasToolCalls && ( {hasToolCalls && (
<View style={styles.prButtonContainer}> <View style={styles.prButtonContainer}>
<Text style={styles.branchName} numberOfLines={1}> <Text style={styles.branchName} numberOfLines={1}>
claude/add-popular-tweets-dy04J {currentSession.repository.owner}/{currentSession.repository.name}
</Text> </Text>
<TouchableOpacity style={styles.createPRButton} onPress={() => setShowCreatePR(true)}> <TouchableOpacity style={styles.createPRButton} onPress={() => setShowCreatePR(true)}>
<Text style={styles.createPRText}>Create PR</Text> <Text style={styles.createPRText}>Create PR</Text>
@ -111,16 +281,23 @@ const ChatView: React.FC<Props> = ({ session, onBack }) => {
placeholder="Add feedback..." placeholder="Add feedback..."
onChangeText={setMessageText} onChangeText={setMessageText}
onSend={handleSendMessage} onSend={handleSendMessage}
disabled={isRunning}
/> />
{/* Bottom chips */} {/* Bottom chips */}
<View style={styles.chipsContainer}> <View style={styles.chipsContainer}>
<Chip icon="code-working" text="Branch" secondaryText="claude/add-popular-tweets..." /> <Chip
<TouchableOpacity style={styles.createPRChip} onPress={() => setShowCreatePR(true)}> icon="code-working"
<Text style={styles.chipText}>Create PR</Text> text="Branch"
<Ionicons name="open-outline" size={12} color={Colors.primaryText} /> secondaryText={currentSession.repository.defaultBranch}
<Ionicons name="ellipsis-horizontal" size={12} color={Colors.primaryText} /> />
</TouchableOpacity> {hasToolCalls && (
<TouchableOpacity style={styles.createPRChip} onPress={() => setShowCreatePR(true)}>
<Text style={styles.chipText}>Create PR</Text>
<Ionicons name="open-outline" size={12} color={Colors.primaryText} />
<Ionicons name="ellipsis-horizontal" size={12} color={Colors.primaryText} />
</TouchableOpacity>
)}
</View> </View>
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
@ -154,14 +331,37 @@ const styles = StyleSheet.create({
fontWeight: '500', fontWeight: '500',
color: Colors.primaryText, color: Colors.primaryText,
}, },
headerSubtitleRow: {
flexDirection: 'row',
alignItems: 'center',
},
headerSubtitle: { headerSubtitle: {
fontSize: 11, fontSize: 11,
color: Colors.secondaryText, color: Colors.secondaryText,
}, },
spinner: {
marginLeft: Spacing.XS,
},
menuButton: { menuButton: {
width: 24, width: 24,
height: 24, height: 24,
}, },
connectionBar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.MD,
paddingVertical: Spacing.XS,
gap: Spacing.XS,
},
connectionDot: {
width: 6,
height: 6,
borderRadius: 3,
},
connectionText: {
fontSize: 11,
fontWeight: '500',
},
keyboardAvoidingView: { keyboardAvoidingView: {
flex: 1, flex: 1,
}, },

View File

@ -4,14 +4,12 @@ import {
Text, Text,
ScrollView, ScrollView,
TouchableOpacity, TouchableOpacity,
Pressable,
StyleSheet, StyleSheet,
SafeAreaView, SafeAreaView,
} from 'react-native' } from 'react-native'
import { Ionicons } from '@expo/vector-icons' import { Ionicons } from '@expo/vector-icons'
import { useAppState, Session } from '../contexts/AppContext' import { useAppState, Session } from '../contexts/AppContext'
import { Colors, Spacing, Radius } from '../theme/colors' import { Colors, Spacing, Radius } from '../theme/colors'
import { getFullName } from '../models/types'
import SessionRow from '../components/SessionRow' import SessionRow from '../components/SessionRow'
import NewSessionSheet from '../components/NewSessionSheet' import NewSessionSheet from '../components/NewSessionSheet'

View File

@ -63,4 +63,10 @@ export type AgentCommandOpts = {
extraSystemPrompt?: string; extraSystemPrompt?: string;
/** Per-call stream param overrides (best-effort). */ /** Per-call stream param overrides (best-effort). */
streamParams?: AgentStreamParams; streamParams?: AgentStreamParams;
/** Repository context for GitHub workspace management. */
repoContext?: {
owner: string;
name: string;
branch: string;
};
}; };

View File

@ -64,6 +64,13 @@ export const AgentParamsSchema = Type.Object(
idempotencyKey: NonEmptyString, idempotencyKey: NonEmptyString,
label: Type.Optional(SessionLabelString), label: Type.Optional(SessionLabelString),
spawnedBy: Type.Optional(Type.String()), spawnedBy: Type.Optional(Type.String()),
repoContext: Type.Optional(
Type.Object({
owner: NonEmptyString,
name: NonEmptyString,
branch: NonEmptyString,
}),
),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );

View File

@ -82,6 +82,11 @@ export const agentHandlers: GatewayRequestHandlers = {
timeout?: number; timeout?: number;
label?: string; label?: string;
spawnedBy?: string; spawnedBy?: string;
repoContext?: {
owner: string;
name: string;
branch: string;
};
}; };
const cfg = loadConfig(); const cfg = loadConfig();
const idem = request.idempotencyKey; const idem = request.idempotencyKey;
@ -334,6 +339,7 @@ export const agentHandlers: GatewayRequestHandlers = {
runId, runId,
lane: request.lane, lane: request.lane,
extraSystemPrompt: request.extraSystemPrompt, extraSystemPrompt: request.extraSystemPrompt,
repoContext: request.repoContext,
}, },
defaultRuntime, defaultRuntime,
context.deps, context.deps,