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

View File

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

View File

@ -22,7 +22,6 @@ import {
allModels,
} from '../models/types'
import MessageInputView from './MessageInputView'
import Chip from './Chip'
import { useGitHubRepos, type GitHubRepo, type GitHubBranch } from '../hooks/useGitHub'
import { useSettings } from '../hooks/useSettings'
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'
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 {
View,
Text,
@ -8,46 +8,200 @@ import {
KeyboardAvoidingView,
Platform,
StyleSheet,
ActivityIndicator,
} from 'react-native'
import { Ionicons } from '@expo/vector-icons'
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 MessageView from '../components/MessageView'
import MessageInputView from '../components/MessageInputView'
import Chip from '../components/Chip'
import { useGateway, type ToolCallEvent } from '../hooks/useGateway'
interface Props {
session: Session
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 { sessions, sendMessage } = useAppState()
const { sessions } = useAppState()
const [messageText, setMessageText] = useState('')
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)
// Get current session from state to see updates
// Get current session from state
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 = () => {
if (messageText.trim()) {
sendMessage(messageText, currentSession)
setMessageText('')
// Handle tool call events
useEffect(() => {
const unsubscribe = onToolCall((event: ToolCallEvent) => {
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
useEffect(() => {
if (scrollViewRef.current) {
setTimeout(() => {
scrollViewRef.current?.scrollToEnd({ animated: true })
}, 100)
}
}, [currentSession.messages.length])
// Combine session messages with live messages
const allMessages = [...currentSession.messages, ...messages]
const hasToolCalls = allMessages.some((m) => m.toolCalls.length > 0)
// Connection status indicator
const connectionStatus = gatewayConnected ? 'Connected' : 'Disconnected'
const connectionColor = gatewayConnected ? Colors.success : Colors.error
return (
<SafeAreaView style={styles.container}>
@ -63,9 +217,14 @@ const ChatView: React.FC<Props> = ({ session, onBack }) => {
? currentSession.title.slice(0, 30) + '...'
: currentSession.title}
</Text>
<Text style={styles.headerSubtitle}>
{getFullName(currentSession.repository)} · Default
</Text>
<View style={styles.headerSubtitleRow}>
<Text style={styles.headerSubtitle}>
{getFullName(currentSession.repository)} · {currentSession.repository.defaultBranch}
</Text>
{isRunning && (
<ActivityIndicator size="small" color={Colors.accent} style={styles.spinner} />
)}
</View>
</View>
<TouchableOpacity style={styles.menuButton}>
@ -73,6 +232,14 @@ const ChatView: React.FC<Props> = ({ session, onBack }) => {
</TouchableOpacity>
</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 */}
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
@ -85,15 +252,18 @@ const ChatView: React.FC<Props> = ({ session, onBack }) => {
contentContainerStyle={styles.messagesContainer}
showsVerticalScrollIndicator={false}
>
{currentSession.messages.map((message) => (
<MessageView key={message.id} message={message} />
{allMessages.map((message, index) => (
<MessageView
key={`${message.id}-${index}`}
message={message}
/>
))}
{/* Create PR button */}
{hasToolCalls && (
<View style={styles.prButtonContainer}>
<Text style={styles.branchName} numberOfLines={1}>
claude/add-popular-tweets-dy04J
{currentSession.repository.owner}/{currentSession.repository.name}
</Text>
<TouchableOpacity style={styles.createPRButton} onPress={() => setShowCreatePR(true)}>
<Text style={styles.createPRText}>Create PR</Text>
@ -111,16 +281,23 @@ const ChatView: React.FC<Props> = ({ session, onBack }) => {
placeholder="Add feedback..."
onChangeText={setMessageText}
onSend={handleSendMessage}
disabled={isRunning}
/>
{/* Bottom chips */}
<View style={styles.chipsContainer}>
<Chip icon="code-working" text="Branch" secondaryText="claude/add-popular-tweets..." />
<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>
<Chip
icon="code-working"
text="Branch"
secondaryText={currentSession.repository.defaultBranch}
/>
{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>
</KeyboardAvoidingView>
@ -154,14 +331,37 @@ const styles = StyleSheet.create({
fontWeight: '500',
color: Colors.primaryText,
},
headerSubtitleRow: {
flexDirection: 'row',
alignItems: 'center',
},
headerSubtitle: {
fontSize: 11,
color: Colors.secondaryText,
},
spinner: {
marginLeft: Spacing.XS,
},
menuButton: {
width: 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: {
flex: 1,
},

View File

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

View File

@ -63,4 +63,10 @@ export type AgentCommandOpts = {
extraSystemPrompt?: string;
/** Per-call stream param overrides (best-effort). */
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,
label: Type.Optional(SessionLabelString),
spawnedBy: Type.Optional(Type.String()),
repoContext: Type.Optional(
Type.Object({
owner: NonEmptyString,
name: NonEmptyString,
branch: NonEmptyString,
}),
),
},
{ additionalProperties: false },
);

View File

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