Based on the changes, I can see this is a significant feature addition that adds the ability to create agents and communicate with them through both text and audio, with a modal UI for agent creation and mode switching.

```
feat: add agent creation modal and agent-specific messaging

- Add CreateAgentModal component for creating new agents with mode selection
- Implement agent-specific text and audio message routing
- Add mode selector modal with visual mode switching UI
- Introduce unified stream reducer pattern for agent activity
- Refactor AgentStreamView to use StreamItem[] instead of raw notifications
- Update ActiveProcesses to show orchestrator pill and support mode switching
- Add message types: send_agent_message, send_agent_audio, create_agent_request, set_agent_mode
- Implement agent audio transcription and message forwarding in session handler
- Add mode badge UI showing current agent mode
- Update tmux capture-pane to allowed commands

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
```
This commit is contained in:
Mohamed Boudra
2025-10-22 22:06:00 +02:00
parent 2c72d02cc7
commit b86e5fbb3f
9 changed files with 1568 additions and 299 deletions
@@ -18,10 +18,10 @@ export interface ActiveProcessesProps {
isDead: boolean;
exitCode: number | null;
}>;
activeProcessId: string | null;
activeProcessType: 'agent' | null;
viewMode: 'orchestrator' | 'agent';
activeAgentId: string | null;
onSelectAgent: (id: string) => void;
onBackToOrchestrator: () => void;
onSelectOrchestrator: () => void;
}
function getAgentStatusColor(status: AgentStatus): string {
@@ -122,6 +122,9 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
fontWeight: '500',
},
processTextActive: {
color: theme.colors.primaryForeground,
},
statusDot: {
width: 8,
height: 8,
@@ -138,39 +141,43 @@ const styles = StyleSheet.create((theme) => ({
export function ActiveProcesses({
agents,
commands,
activeProcessId,
activeProcessType,
viewMode,
activeAgentId,
onSelectAgent,
onBackToOrchestrator,
onSelectOrchestrator,
}: ActiveProcessesProps) {
const hasActiveProcess = activeProcessId !== null && activeProcessType !== null;
if (agents.length === 0 && commands.length === 0) {
// Only show if there's at least one agent
if (agents.length === 0) {
return null;
}
return (
<View style={styles.container}>
{hasActiveProcess && (
<View style={styles.header}>
<Pressable
onPress={onBackToOrchestrator}
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonActive]}
>
<Text style={styles.backButtonText}>Back to Chat</Text>
</Pressable>
</View>
)}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={{ gap: 8 }}
>
{/* Orchestrator pill */}
<Pressable
onPress={onSelectOrchestrator}
style={({ pressed }) => [
styles.processItem,
viewMode === 'orchestrator' ? styles.processItemActive : styles.processItemInactive,
pressed && { opacity: 0.7 },
]}
>
<View style={styles.agentIcon} />
<Text style={[
styles.processText,
viewMode === 'orchestrator' && styles.processTextActive,
]}>Orchestrator</Text>
</Pressable>
{/* Agent pills */}
{agents.map((agent) => {
const modeName = getModeName(agent.currentModeId, agent.availableModes);
const isActive = activeProcessType === 'agent' && activeProcessId === agent.id;
const isActive = viewMode === 'agent' && activeAgentId === agent.id;
return (
<Pressable
@@ -184,7 +191,10 @@ export function ActiveProcesses({
>
<View style={styles.agentIcon} />
<Text style={styles.processText}>{agent.id.substring(0, 8)}</Text>
<Text style={[
styles.processText,
isActive && styles.processTextActive,
]}>{agent.id.substring(0, 8)}</Text>
<View style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]} />
@@ -194,26 +204,6 @@ export function ActiveProcesses({
</Pressable>
);
})}
{commands.map((command) => {
const statusColor = command.isDead
? command.exitCode === 0
? '#22c55e'
: '#ef4444'
: '#3b82f6';
return (
<View key={`command-${command.id}`} style={[styles.processItem, styles.processItemInactive]}>
<View style={styles.commandIcon} />
<Text style={styles.processText} numberOfLines={1}>
{command.name || command.currentCommand.substring(0, 20)}
</Text>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
</View>
);
})}
</ScrollView>
</View>
);
@@ -267,7 +267,7 @@ const stylesheet = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1],
},
thoughtLabel: {
color: theme.colors.palette.purple[400],
color: theme.colors.palette.purple[500],
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[1],
@@ -319,7 +319,7 @@ const stylesheet = StyleSheet.create((theme) => ({
statusText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
fontWeight: theme.fontWeight.semibold,
},
expandIcon: {
color: theme.colors.mutedForeground,
+191 -107
View File
@@ -1,11 +1,10 @@
import { useEffect, useRef, useMemo } from 'react';
import { useEffect, useRef } from 'react';
import { View, Text, ScrollView, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { StyleSheet } from 'react-native-unistyles';
import type { AgentStatus } from '@server/server/acp/types';
import type { SessionNotification } from '@agentclientprotocol/sdk';
import { AgentActivityItem } from './agent-activity';
import { parseSessionUpdate, groupActivities, type AgentActivity } from '@/types/agent-activity';
import { AssistantMessage } from './message';
import type { StreamItem } from '@/types/stream';
export interface AgentStreamViewProps {
agentId: string;
@@ -15,13 +14,7 @@ export interface AgentStreamViewProps {
createdAt: Date;
type: 'claude';
};
updates: Array<{
timestamp: Date;
notification: SessionNotification;
}>;
onBack: () => void;
onKillAgent: (agentId: string) => void;
onCancelAgent: (agentId: string) => void;
streamItems: StreamItem[];
}
function getStatusColor(status: AgentStatus): string {
@@ -60,6 +53,11 @@ function getStatusIcon(status: AgentStatus): string {
}
function formatTimestamp(date: Date): string {
// Handle invalid dates gracefully
if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
return '--:--:--';
}
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
@@ -68,124 +66,138 @@ function formatTimestamp(date: Date): string {
}).format(date);
}
function UserMessageCard({ text, timestamp }: { text: string; timestamp: Date }) {
return (
<View style={stylesheet.userMessageCard}>
<Text style={stylesheet.timestamp}>
{formatTimestamp(timestamp)}
</Text>
<Text style={stylesheet.userMessageLabel}>You</Text>
<Text style={stylesheet.userMessageText}>{text}</Text>
</View>
);
}
function ThoughtCard({ text, timestamp }: { text: string; timestamp: Date }) {
return (
<View style={stylesheet.thoughtCard}>
<Text style={stylesheet.timestamp}>
{formatTimestamp(timestamp)}
</Text>
<Text style={stylesheet.thoughtLabel}>Thinking</Text>
<Text style={stylesheet.thoughtText}>{text}</Text>
</View>
);
}
function ToolCallCard({
title,
status,
toolKind,
timestamp
}: {
title: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
toolKind?: string;
timestamp: Date;
}) {
return (
<View style={stylesheet.toolCallCard}>
<Text style={stylesheet.timestamp}>
{formatTimestamp(timestamp)}
</Text>
<View style={stylesheet.toolCallHeader}>
<Text style={stylesheet.toolCallLabel}>{toolKind || 'Tool'}</Text>
<Text style={[stylesheet.toolCallStatus, { color: getStatusColor(status as any) }]}>
{status}
</Text>
</View>
<Text style={stylesheet.toolCallTitle}>{title}</Text>
</View>
);
}
export function AgentStreamView({
agentId,
agent,
updates,
onBack,
onKillAgent,
onCancelAgent,
streamItems,
}: AgentStreamViewProps) {
const scrollViewRef = useRef<ScrollView>(null);
const insets = useSafeAreaInsets();
// Parse and group activities
const groupedActivities = useMemo(() => {
// Parse all notifications into typed activities
const activities: AgentActivity[] = updates
.map((update) => {
const parsedUpdate = parseSessionUpdate(update.notification);
if (!parsedUpdate) return null;
return {
timestamp: update.timestamp,
update: parsedUpdate,
};
})
.filter((activity): activity is AgentActivity => activity !== null);
// Group consecutive text chunks and merge tool calls
return groupActivities(activities);
}, [updates]);
// Auto-scroll to bottom when new updates arrive
// Auto-scroll to bottom when new items arrive
useEffect(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, [groupedActivities]);
const canCancel = agent.status === 'processing';
const canKill = agent.status !== 'killed' && agent.status !== 'completed';
}, [streamItems]);
return (
<View style={stylesheet.container}>
{/* Header */}
<View style={[stylesheet.header, { paddingTop: insets.top + 16 }]}>
{/* Back button */}
<Pressable
onPress={onBack}
style={stylesheet.backButton}
>
<Text style={stylesheet.backButtonText}>
← Back to Chat
</Text>
</Pressable>
{/* Agent info */}
<View style={stylesheet.agentInfoRow}>
<View style={stylesheet.agentIdRow}>
<Text style={stylesheet.agentLabel}>Agent:</Text>
<Text style={stylesheet.agentId}>
{agentId.substring(0, 8)}
</Text>
</View>
<View
style={[
stylesheet.statusBadge,
{ backgroundColor: getStatusColor(agent.status) }
]}
>
<Text style={stylesheet.statusIcon}>{getStatusIcon(agent.status)}</Text>
<Text style={stylesheet.statusText}>{agent.status}</Text>
</View>
</View>
<Text style={stylesheet.createdText}>
Created: {formatTimestamp(agent.createdAt)}
</Text>
{/* Control buttons */}
{(canCancel || canKill) && (
<View style={stylesheet.controlButtons}>
{canCancel && (
<Pressable
onPress={() => onCancelAgent(agentId)}
style={stylesheet.cancelButton}
>
<Text style={stylesheet.controlButtonText}>
Cancel
</Text>
</Pressable>
)}
{canKill && (
<Pressable
onPress={() => onKillAgent(agentId)}
style={stylesheet.killButton}
>
<Text style={stylesheet.controlButtonText}>
Kill
</Text>
</Pressable>
)}
</View>
)}
</View>
{/* Updates list */}
{/* Content list */}
<ScrollView
ref={scrollViewRef}
style={stylesheet.scrollView}
contentContainerStyle={{ paddingTop: 16, paddingBottom: Math.max(insets.bottom, 32) }}
>
{groupedActivities.length === 0 ? (
{streamItems.length === 0 ? (
<View style={stylesheet.emptyState}>
<Text style={stylesheet.emptyStateText}>
No updates yet.{'\n'}Waiting for agent activity...
Start chatting with this agent...
</Text>
</View>
) : (
groupedActivities.map((item, idx) => (
<AgentActivityItem key={idx} item={item} />
))
streamItems.map((item) => {
switch (item.kind) {
case 'user_message':
return (
<UserMessageCard
key={item.id}
text={item.text}
timestamp={item.timestamp}
/>
);
case 'assistant_message':
return (
<AssistantMessage
key={item.id}
message={item.text}
timestamp={item.timestamp.getTime()}
/>
);
case 'thought':
return (
<ThoughtCard
key={item.id}
text={item.text}
timestamp={item.timestamp}
/>
);
case 'tool_call':
return (
<ToolCallCard
key={item.id}
title={item.title}
status={item.status}
toolKind={item.toolKind}
timestamp={item.timestamp}
/>
);
case 'plan':
// TODO: Render plan component
return null;
case 'activity_log':
case 'artifact':
// These are orchestrator-only, skip for now
return null;
default:
return null;
}
})
)}
</ScrollView>
</View>
@@ -197,6 +209,78 @@ const stylesheet = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.background,
},
userMessageCard: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
marginBottom: theme.spacing[2],
borderLeftWidth: 3,
borderLeftColor: theme.colors.primary,
},
userMessageLabel: {
color: theme.colors.primary,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[1],
},
userMessageText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
timestamp: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginBottom: theme.spacing[1],
},
thoughtCard: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
marginBottom: theme.spacing[2],
borderLeftWidth: 3,
borderLeftColor: theme.colors.palette.purple[500],
},
thoughtLabel: {
color: theme.colors.palette.purple[500],
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[1],
},
thoughtText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: 20,
fontStyle: 'italic',
},
toolCallCard: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
marginBottom: theme.spacing[2],
borderLeftWidth: 3,
borderLeftColor: theme.colors.palette.blue[500],
},
toolCallHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: theme.spacing[1],
},
toolCallLabel: {
color: theme.colors.palette.blue[500],
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
toolCallStatus: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
toolCallTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
header: {
backgroundColor: theme.colors.card,
borderBottomWidth: theme.borderWidth[1],
@@ -0,0 +1,324 @@
import { useState } from "react";
import {
Modal,
View,
Text,
Pressable,
TextInput,
ScrollView,
KeyboardAvoidingView,
Platform,
} from "react-native";
import { X } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { theme as defaultTheme } from "@/styles/theme";
interface CreateAgentModalProps {
isVisible: boolean;
onClose: () => void;
onCreateAgent: (workingDir: string, mode: string) => void;
}
const MODES = [
{
value: "plan",
label: "Plan",
description: "Plan and design before implementing",
},
{
value: "bypassPermissions",
label: "Bypass Permissions",
description: "Skip permission prompts for faster execution",
},
] as const;
export function CreateAgentModal({
isVisible,
onClose,
onCreateAgent,
}: CreateAgentModalProps) {
const [workingDir, setWorkingDir] = useState("");
const [selectedMode, setSelectedMode] = useState("plan");
const [errorMessage, setErrorMessage] = useState("");
function handleCreate() {
if (!workingDir.trim()) {
setErrorMessage("Working directory is required");
return;
}
onCreateAgent(workingDir.trim(), selectedMode);
handleClose();
}
function handleClose() {
setWorkingDir("");
setSelectedMode("plan");
setErrorMessage("");
onClose();
}
return (
<Modal
visible={isVisible}
animationType="slide"
transparent={true}
onRequestClose={handleClose}
>
<KeyboardAvoidingView
style={styles.modalOverlay}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<Pressable style={styles.modalBackdrop} onPress={handleClose} />
<View style={styles.modalContent}>
<View style={styles.modalInner}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Create New Agent</Text>
<Pressable onPress={handleClose}>
<X size={24} color="white" />
</Pressable>
</View>
{/* Form */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{/* Working Directory Input */}
<View style={styles.formSection}>
<Text style={styles.label}>Working Directory</Text>
<TextInput
style={styles.input}
placeholder="/path/to/project"
placeholderTextColor={defaultTheme.colors.mutedForeground}
value={workingDir}
onChangeText={(text) => {
setWorkingDir(text);
setErrorMessage("");
}}
autoCapitalize="none"
autoCorrect={false}
/>
{errorMessage ? (
<Text style={styles.errorText}>{errorMessage}</Text>
) : (
<Text style={styles.helperText}>
Absolute path to the project directory
</Text>
)}
</View>
{/* Mode Selector */}
<View style={styles.formSection}>
<Text style={styles.label}>Mode</Text>
<View style={styles.modeContainer}>
{MODES.map((mode) => (
<Pressable
key={mode.value}
onPress={() => setSelectedMode(mode.value)}
style={[
styles.modeOption,
selectedMode === mode.value &&
styles.modeOptionSelected,
]}
>
<View style={styles.modeOptionContent}>
<View
style={[
styles.radioOuter,
selectedMode === mode.value
? styles.radioOuterSelected
: styles.radioOuterUnselected,
]}
>
{selectedMode === mode.value && (
<View style={styles.radioInner} />
)}
</View>
<View style={styles.modeTextContainer}>
<Text style={styles.modeLabel}>{mode.label}</Text>
<Text style={styles.modeDescription}>
{mode.description}
</Text>
</View>
</View>
</Pressable>
))}
</View>
</View>
{/* Action Buttons */}
<View style={styles.buttonContainer}>
<Pressable
style={[styles.createButton, !workingDir.trim() && styles.createButtonDisabled]}
onPress={handleCreate}
disabled={!workingDir.trim()}
>
<Text style={styles.createButtonText}>Create Agent</Text>
</Pressable>
<Pressable style={styles.cancelButton} onPress={handleClose}>
<Text style={styles.cancelButtonText}>Cancel</Text>
</Pressable>
</View>
</ScrollView>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.5)",
},
modalBackdrop: {
flex: 1,
},
modalContent: {
backgroundColor: theme.colors.card,
borderTopLeftRadius: theme.spacing[6],
borderTopRightRadius: theme.spacing[6],
height: "75%",
},
modalInner: {
flex: 1,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: theme.spacing[6],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headerTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
},
scrollView: {
flex: 1,
},
scrollContent: {
padding: theme.spacing[6],
paddingBottom: theme.spacing[4] * 5,
},
formSection: {
marginBottom: theme.spacing[6],
},
label: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[2],
},
input: {
backgroundColor: theme.colors.background,
color: theme.colors.foreground,
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
helperText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[2],
},
errorText: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[2],
},
modeContainer: {
gap: theme.spacing[3],
},
modeOption: {
backgroundColor: theme.colors.background,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
padding: theme.spacing[4],
},
modeOptionSelected: {
borderColor: theme.colors.palette.blue[500],
backgroundColor: theme.colors.muted,
},
modeOptionContent: {
flexDirection: "row",
alignItems: "center",
},
radioOuter: {
width: 20,
height: 20,
borderRadius: theme.borderRadius.full,
borderWidth: theme.borderWidth[2],
marginRight: theme.spacing[3],
alignItems: "center",
justifyContent: "center",
},
radioOuterSelected: {
borderColor: theme.colors.palette.blue[500],
},
radioOuterUnselected: {
borderColor: theme.colors.border,
},
radioInner: {
width: 12,
height: 12,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.blue[500],
},
modeTextContainer: {
flex: 1,
},
modeLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[1],
},
modeDescription: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
buttonContainer: {
gap: theme.spacing[3],
marginTop: theme.spacing[4],
},
createButton: {
backgroundColor: theme.colors.palette.blue[500],
paddingVertical: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
alignItems: "center",
},
createButtonDisabled: {
backgroundColor: theme.colors.palette.blue[900],
opacity: theme.opacity[50],
},
createButtonText: {
color: theme.colors.palette.white,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
cancelButton: {
backgroundColor: "transparent",
paddingVertical: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
alignItems: "center",
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
cancelButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
}));