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:
parent
a591c51010
commit
175c7a0cbc
@ -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>
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user