diff --git a/apps/expo-github-mobile/src/components/MessageInputView.tsx b/apps/expo-github-mobile/src/components/MessageInputView.tsx index c1a694a26..871f4dd08 100644 --- a/apps/expo-github-mobile/src/components/MessageInputView.tsx +++ b/apps/expo-github-mobile/src/components/MessageInputView.tsx @@ -8,9 +8,12 @@ interface Props { placeholder: string onChangeText: (text: string) => void onSend: () => void + disabled?: boolean } -const MessageInputView: React.FC = ({ text, placeholder, onChangeText, onSend }) => { +const MessageInputView: React.FC = ({ text, placeholder, onChangeText, onSend, disabled = false }) => { + const isSendDisabled = disabled || !text.trim() + return ( @@ -22,11 +25,12 @@ const MessageInputView: React.FC = ({ text, placeholder, onChangeText, on placeholderTextColor={Colors.secondaryText} multiline maxLength={2000} + editable={!disabled} /> diff --git a/apps/expo-github-mobile/src/components/MessageView.tsx b/apps/expo-github-mobile/src/components/MessageView.tsx index d25c2edaf..b2b4050e3 100644 --- a/apps/expo-github-mobile/src/components/MessageView.tsx +++ b/apps/expo-github-mobile/src/components/MessageView.tsx @@ -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' diff --git a/apps/expo-github-mobile/src/components/NewSessionSheet.tsx b/apps/expo-github-mobile/src/components/NewSessionSheet.tsx index d30d1b6af..d660f5fb9 100644 --- a/apps/expo-github-mobile/src/components/NewSessionSheet.tsx +++ b/apps/expo-github-mobile/src/components/NewSessionSheet.tsx @@ -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' diff --git a/apps/expo-github-mobile/src/hooks/useSettings.ts b/apps/expo-github-mobile/src/hooks/useSettings.ts index a9d87bbb8..3cbcccca8 100644 --- a/apps/expo-github-mobile/src/hooks/useSettings.ts +++ b/apps/expo-github-mobile/src/hooks/useSettings.ts @@ -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' diff --git a/apps/expo-github-mobile/src/views/ChatView.tsx b/apps/expo-github-mobile/src/views/ChatView.tsx index cd0c9ce6d..f1fbe8ad2 100644 --- a/apps/expo-github-mobile/src/views/ChatView.tsx +++ b/apps/expo-github-mobile/src/views/ChatView.tsx @@ -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 = ({ session, onBack }) => { - const { sessions, sendMessage } = useAppState() + const { sessions } = useAppState() const [messageText, setMessageText] = useState('') const [showCreatePR, setShowCreatePR] = useState(false) + const [messages, setMessages] = useState([]) + const [pendingToolCalls, setPendingToolCalls] = useState>(new Map()) + const [isRunning, setIsRunning] = useState(false) + const scrollViewRef = useRef(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 ( @@ -63,9 +217,14 @@ const ChatView: React.FC = ({ session, onBack }) => { ? currentSession.title.slice(0, 30) + '...' : currentSession.title} - - {getFullName(currentSession.repository)} · Default - + + + {getFullName(currentSession.repository)} · {currentSession.repository.defaultBranch} + + {isRunning && ( + + )} + @@ -73,6 +232,14 @@ const ChatView: React.FC = ({ session, onBack }) => { + {/* Connection status bar */} + + + + Gateway: {connectionStatus} + + + {/* Messages + Input with keyboard avoidance */} = ({ session, onBack }) => { contentContainerStyle={styles.messagesContainer} showsVerticalScrollIndicator={false} > - {currentSession.messages.map((message) => ( - + {allMessages.map((message, index) => ( + ))} {/* Create PR button */} {hasToolCalls && ( - claude/add-popular-tweets-dy04J + {currentSession.repository.owner}/{currentSession.repository.name} setShowCreatePR(true)}> Create PR @@ -111,16 +281,23 @@ const ChatView: React.FC = ({ session, onBack }) => { placeholder="Add feedback..." onChangeText={setMessageText} onSend={handleSendMessage} + disabled={isRunning} /> {/* Bottom chips */} - - setShowCreatePR(true)}> - Create PR - - - + + {hasToolCalls && ( + setShowCreatePR(true)}> + Create PR + + + + )} @@ -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, }, diff --git a/apps/expo-github-mobile/src/views/SessionListView.tsx b/apps/expo-github-mobile/src/views/SessionListView.tsx index b111c411d..86b350f6b 100644 --- a/apps/expo-github-mobile/src/views/SessionListView.tsx +++ b/apps/expo-github-mobile/src/views/SessionListView.tsx @@ -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' diff --git a/src/commands/agent/types.ts b/src/commands/agent/types.ts index b0afadd91..1d0df2113 100644 --- a/src/commands/agent/types.ts +++ b/src/commands/agent/types.ts @@ -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; + }; }; diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index 54da9d23c..eea265bee 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -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 }, ); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index e015e1d17..9b09cf91d 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -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,