feat: add home screen with agent list and orchestrator navigation

This commit is contained in:
Mohamed Boudra
2025-10-23 11:11:20 +02:00
parent a930c4156a
commit 8177aa0c2a
27 changed files with 1912 additions and 2038 deletions
@@ -0,0 +1,246 @@
import { View, TextInput, Pressable, Text } from "react-native";
import { useState } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, ArrowUp, AudioLines, Square, ChevronDown } from "lucide-react-native";
import { useSession } from "@/contexts/session-context";
import { useRealtime } from "@/contexts/realtime-context";
import { useAudioRecorder } from "@/hooks/use-audio-recorder";
import { ModeSelectorModal } from "./mode-selector-modal";
interface AgentInputAreaProps {
agentId: string;
}
export function AgentInputArea({ agentId }: AgentInputAreaProps) {
const { theme } = useUnistyles();
const { agents, ws, sendAgentMessage, setAgentMode } = useSession();
const { startRealtime } = useRealtime();
const audioRecorder = useAudioRecorder();
const [userInput, setUserInput] = useState("");
const [isRecording, setIsRecording] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [showModeSelector, setShowModeSelector] = useState(false);
const agent = agents.get(agentId);
async function handleSendMessage() {
if (!userInput.trim() || !ws.isConnected) return;
const message = userInput.trim();
setUserInput("");
setIsProcessing(true);
try {
sendAgentMessage(agentId, message);
} catch (error) {
console.error("[AgentInput] Failed to send message:", error);
} finally {
setIsProcessing(false);
}
}
async function handleVoicePress() {
if (isRecording) {
// Stop recording
try {
setIsRecording(false);
const audioData = await audioRecorder.stop();
if (audioData) {
setIsProcessing(true);
// TODO: Send audio to agent
// For now, just log it
console.log("[AgentInput] Audio recorded:", audioData.size, "bytes");
setIsProcessing(false);
}
} catch (error) {
console.error("[AgentInput] Failed to stop recording:", error);
setIsRecording(false);
setIsProcessing(false);
}
} else {
// Start recording
try {
await audioRecorder.start();
setIsRecording(true);
} catch (error) {
console.error("[AgentInput] Failed to start recording:", error);
}
}
}
function handleModeChange(modeId: string) {
setAgentMode(agentId, modeId);
}
const hasText = userInput.trim().length > 0;
return (
<View style={styles.container}>
{/* Text input */}
<TextInput
value={userInput}
onChangeText={setUserInput}
placeholder="Message agent..."
placeholderTextColor={theme.colors.mutedForeground}
style={styles.textInput}
multiline
editable={!isRecording && ws.isConnected}
/>
{/* Controls row */}
<View style={styles.controlsRow}>
{/* Mode badge - only show if agent has modes */}
{agent && agent.availableModes && agent.availableModes.length > 0 && (
<Pressable
onPress={() => setShowModeSelector(true)}
style={({ pressed }) => [
styles.modeBadge,
pressed && styles.modeBadgePressed,
]}
>
<Text style={styles.modeBadgeText}>
{agent.availableModes?.find(m => m.id === agent.currentModeId)?.name || agent.currentModeId || 'default'}
</Text>
<ChevronDown size={14} color={theme.colors.mutedForeground} />
</Pressable>
)}
{/* Buttons */}
<View style={styles.buttonRow}>
{hasText ? (
// Send button when text is entered
<Pressable
onPress={handleSendMessage}
disabled={!ws.isConnected || isProcessing}
style={[
styles.sendButton,
(!ws.isConnected || isProcessing) && styles.buttonDisabled,
]}
>
<ArrowUp size={20} color="white" />
</Pressable>
) : (
// Voice and Realtime buttons when no text
<>
{/* Voice recording button */}
<Pressable
onPress={handleVoicePress}
disabled={!ws.isConnected}
style={[
styles.voiceButton,
!ws.isConnected && styles.buttonDisabled,
isRecording && styles.voiceButtonRecording,
]}
>
{isRecording ? (
<Square size={14} color="white" fill="white" />
) : (
<Mic size={20} color={theme.colors.foreground} />
)}
</Pressable>
{/* Realtime button */}
<Pressable
onPress={startRealtime}
disabled={!ws.isConnected}
style={[
styles.realtimeButton,
!ws.isConnected && styles.buttonDisabled,
]}
>
<AudioLines size={20} color={theme.colors.background} />
</Pressable>
</>
)}
</View>
</View>
{/* Mode selector modal */}
<ModeSelectorModal
visible={showModeSelector}
agent={agent || null}
onModeChange={handleModeChange}
onClose={() => setShowModeSelector(false)}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
padding: theme.spacing[4],
gap: theme.spacing[3],
},
textInput: {
minHeight: 44,
maxHeight: 120,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
backgroundColor: theme.colors.muted,
borderRadius: theme.borderRadius.lg,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
controlsRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
modeBadge: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.muted,
borderRadius: theme.borderRadius.full,
},
modeBadgePressed: {
backgroundColor: theme.colors.accent,
},
modeBadgeText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
textTransform: "capitalize",
},
buttonRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginLeft: "auto",
},
sendButton: {
width: 40,
height: 40,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.blue[600],
alignItems: "center",
justifyContent: "center",
},
voiceButton: {
width: 40,
height: 40,
borderRadius: theme.borderRadius.full,
backgroundColor: "transparent",
alignItems: "center",
justifyContent: "center",
},
voiceButtonRecording: {
backgroundColor: theme.colors.destructive,
},
realtimeButton: {
width: 40,
height: 40,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accentForeground,
alignItems: "center",
justifyContent: "center",
},
buttonDisabled: {
opacity: 0.5,
},
}));
+148
View File
@@ -0,0 +1,148 @@
import { View, Text, Pressable, ScrollView } from "react-native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { Agent } from "@/contexts/session-context";
import type { AgentStatus } from "@server/server/acp/types";
interface AgentListProps {
agents: Map<string, Agent>;
}
function getStatusColor(status: AgentStatus): string {
switch (status) {
case "initializing":
return "#FFA500";
case "ready":
return "#2563EB";
case "processing":
return "#FACC15";
case "completed":
return "#10B981";
case "failed":
return "#EF4444";
case "killed":
return "#6B7280";
default:
return "#6B7280";
}
}
function getStatusLabel(status: AgentStatus): string {
switch (status) {
case "initializing":
return "Initializing";
case "ready":
return "Ready";
case "processing":
return "Processing";
case "completed":
return "Completed";
case "failed":
return "Failed";
case "killed":
return "Killed";
default:
return "Unknown";
}
}
export function AgentList({ agents }: AgentListProps) {
const { theme } = useUnistyles();
const agentArray = Array.from(agents.values());
function handleAgentPress(agentId: string) {
router.push(`/agent/${agentId}`);
}
return (
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
{agentArray.map((agent) => {
const statusColor = getStatusColor(agent.status);
const statusLabel = getStatusLabel(agent.status);
return (
<Pressable
key={agent.id}
style={styles.agentItem}
onPress={() => handleAgentPress(agent.id)}
>
<View style={styles.agentContent}>
<Text
style={styles.agentTitle}
numberOfLines={1}
>
{agent.title || "New Agent"}
</Text>
<View style={styles.directoryRow}>
<Text
style={styles.agentDirectory}
numberOfLines={1}
>
{agent.cwd}
</Text>
<View style={styles.statusBadge}>
<View
style={[styles.statusDot, { backgroundColor: statusColor }]}
/>
<Text style={[styles.statusText, { color: statusColor }]}>
{statusLabel}
</Text>
</View>
</View>
</View>
</Pressable>
);
})}
</ScrollView>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[4],
},
agentItem: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
backgroundColor: theme.colors.muted,
},
agentContent: {
flex: 1,
},
agentTitle: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
marginBottom: theme.spacing[1],
},
directoryRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
agentDirectory: {
flex: 1,
fontSize: theme.fontSize.sm,
color: theme.colors.mutedForeground,
},
statusBadge: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
statusDot: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
},
statusText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
}));
+62 -29
View File
@@ -11,6 +11,7 @@ import Animated, {
cancelAnimation,
runOnJS,
Easing,
type SharedValue,
} from "react-native-reanimated";
import type { AgentStatus } from "@server/server/acp/types";
@@ -28,7 +29,7 @@ interface AgentSidebarProps {
onClose: () => void;
onSelectAgent: (agentId: string) => void;
onNewAgent: () => void;
edgeSwipeTranslateX?: Animated.SharedValue<number> | null;
edgeSwipeTranslateX?: SharedValue<number> | null;
}
function getStatusColor(status: AgentStatus): string {
@@ -50,6 +51,25 @@ function getStatusColor(status: AgentStatus): string {
}
}
function getStatusLabel(status: AgentStatus): string {
switch (status) {
case "initializing":
return "Initializing";
case "ready":
return "Ready";
case "processing":
return "Processing";
case "completed":
return "Completed";
case "failed":
return "Failed";
case "killed":
return "Killed";
default:
return "Unknown";
}
}
export function AgentSidebar({
isOpen,
agents,
@@ -202,6 +222,7 @@ export function AgentSidebar({
{agents.map((agent) => {
const isActive = agent.id === activeAgentId;
const statusColor = getStatusColor(agent.status);
const statusLabel = getStatusLabel(agent.status);
return (
<Pressable
@@ -212,31 +233,33 @@ export function AgentSidebar({
]}
onPress={() => handleAgentSelect(agent.id)}
>
<View style={styles.agentInfo}>
{/* Status Indicator */}
<View
style={[styles.statusDot, { backgroundColor: statusColor }]}
/>
<View style={styles.agentContent}>
<Text
style={[
styles.agentTitle,
{ color: theme.colors.foreground },
]}
numberOfLines={1}
>
{agent.title || "New Agent"}
</Text>
{/* Agent Title */}
<View style={styles.agentContent}>
<Text
style={[
styles.agentTitle,
{ color: theme.colors.foreground },
]}
numberOfLines={1}
>
{agent.title || "New Agent"}
</Text>
{/* Agent Directory */}
<View style={styles.directoryRow}>
<Text
style={[styles.agentDirectory, { color: theme.colors.mutedForeground }]}
numberOfLines={1}
>
{agent.cwd}
</Text>
<View style={styles.statusBadge}>
<View
style={[styles.statusDot, { backgroundColor: statusColor }]}
/>
<Text style={[styles.statusText, { color: statusColor }]}>
{statusLabel}
</Text>
</View>
</View>
</View>
</Pressable>
@@ -308,16 +331,6 @@ const styles = StyleSheet.create({
borderRadius: 8,
marginBottom: 4,
},
agentInfo: {
flexDirection: "row",
alignItems: "center",
gap: 12,
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
agentContent: {
flex: 1,
},
@@ -326,9 +339,29 @@ const styles = StyleSheet.create({
fontWeight: "500",
marginBottom: 2,
},
directoryRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
agentDirectory: {
flex: 1,
fontSize: 12,
},
statusBadge: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
statusDot: {
width: 6,
height: 6,
borderRadius: 3,
},
statusText: {
fontSize: 11,
fontWeight: "500",
},
emptyState: {
paddingVertical: 32,
alignItems: "center",
@@ -0,0 +1,59 @@
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Plus } from "lucide-react-native";
interface EmptyStateProps {
onCreateAgent: () => void;
}
export function EmptyState({ onCreateAgent }: EmptyStateProps) {
const { theme } = useUnistyles();
return (
<View style={styles.container}>
<Text style={styles.title}>Hammock</Text>
<Text style={styles.subtitle}>
What would you like to work on?
</Text>
<Pressable onPress={onCreateAgent} style={styles.button}>
<Plus size={20} color={theme.colors.foreground} />
<Text style={styles.buttonText}>New agent</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[6],
},
title: {
fontSize: theme.fontSize["4xl"],
fontWeight: "700",
color: theme.colors.foreground,
marginBottom: theme.spacing[2],
},
subtitle: {
fontSize: theme.fontSize.lg,
color: theme.colors.mutedForeground,
textAlign: "center",
marginBottom: theme.spacing[8],
},
button: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
backgroundColor: theme.colors.primary,
borderRadius: theme.borderRadius.lg,
},
buttonText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.primaryForeground,
},
}));
@@ -0,0 +1,86 @@
import { View, Pressable } from "react-native";
import { usePathname } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AudioLines } from "lucide-react-native";
import { useRealtime } from "@/contexts/realtime-context";
import { useSession } from "@/contexts/session-context";
import { RealtimeControls } from "./realtime-controls";
import { AgentInputArea } from "./agent-input-area";
interface GlobalFooterProps {
agentId?: string;
}
export function GlobalFooter({ agentId }: GlobalFooterProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const pathname = usePathname();
const { isRealtimeMode, startRealtime } = useRealtime();
const { ws } = useSession();
// Determine current screen type
const isAgentScreen = pathname?.startsWith("/agent/");
const isOrchestratorScreen = pathname === "/orchestrator";
const isHomeScreen = pathname === "/";
// If realtime is active, always show realtime controls
if (isRealtimeMode) {
return (
<View style={[styles.container, { paddingBottom: insets.bottom }]}>
<RealtimeControls />
</View>
);
}
// If on agent screen, show full input area
if (isAgentScreen && agentId) {
return (
<View style={[styles.container, { paddingBottom: insets.bottom }]}>
<AgentInputArea agentId={agentId} />
</View>
);
}
// For home and orchestrator screens, show centered realtime button
return (
<View style={[styles.container, { paddingBottom: insets.bottom }]}>
<View style={styles.centeredButtonContainer}>
<Pressable
onPress={startRealtime}
disabled={!ws.isConnected}
style={[
styles.centeredRealtimeButton,
!ws.isConnected && styles.buttonDisabled,
]}
>
<AudioLines size={24} color="white" />
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
backgroundColor: theme.colors.background,
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
},
centeredButtonContainer: {
padding: theme.spacing[6],
alignItems: "center",
justifyContent: "center",
},
centeredRealtimeButton: {
width: 56,
height: 56,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.blue[600],
alignItems: "center",
justifyContent: "center",
},
buttonDisabled: {
opacity: 0.5,
},
}));
@@ -0,0 +1,76 @@
import { View, Pressable, Text } from "react-native";
import { router } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowLeft } from "lucide-react-native";
interface BackHeaderProps {
title?: string;
}
export function BackHeader({ title }: BackHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
return (
<View style={styles.header}>
<View style={{ paddingTop: insets.top + 12 }}>
<View style={styles.headerRow}>
{/* Left side - Back button */}
<View style={styles.headerLeft}>
<Pressable
onPress={() => router.back()}
style={styles.backButton}
>
<ArrowLeft size={20} color={theme.colors.foreground} />
</Pressable>
{title && (
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
)}
</View>
{/* Right side - Empty for now */}
<View style={styles.headerRight} />
</View>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
header: {
backgroundColor: theme.colors.background,
},
headerRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headerLeft: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
headerRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
backButton: {
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
title: {
flex: 1,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
},
}));
@@ -0,0 +1,79 @@
import { View, Pressable } from "react-native";
import { router } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Settings, MessageSquare, Plus } from "lucide-react-native";
interface HomeHeaderProps {
onCreateAgent: () => void;
}
export function HomeHeader({ onCreateAgent }: HomeHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
return (
<View style={styles.header}>
<View style={{ paddingTop: insets.top + 12 }}>
<View style={styles.headerRow}>
{/* Left side - Settings */}
<View style={styles.headerLeft}>
<Pressable
onPress={() => router.push("/settings")}
style={styles.iconButton}
>
<Settings size={20} color={theme.colors.foreground} />
</Pressable>
</View>
{/* Right side - Activity and New Agent */}
<View style={styles.headerRight}>
<Pressable
onPress={() => router.push("/orchestrator")}
style={styles.iconButton}
>
<MessageSquare size={20} color={theme.colors.foreground} />
</Pressable>
<Pressable
onPress={onCreateAgent}
style={styles.iconButton}
>
<Plus size={20} color={theme.colors.foreground} />
</Pressable>
</View>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
header: {
backgroundColor: theme.colors.background,
},
headerRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headerLeft: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
headerRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
iconButton: {
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.muted,
},
}));
@@ -0,0 +1,110 @@
import { View, Text, Modal, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { Agent } from "@/contexts/session-context";
interface ModeSelectorModalProps {
visible: boolean;
agent: Agent | null;
onModeChange: (modeId: string) => void;
onClose: () => void;
}
export function ModeSelectorModal({
visible,
agent,
onModeChange,
onClose,
}: ModeSelectorModalProps) {
const { theme } = useUnistyles();
return (
<Modal
visible={visible}
animationType="fade"
transparent={true}
onRequestClose={onClose}
>
<Pressable style={styles.modalOverlay} onPress={onClose}>
<View style={styles.modeSelectorContent}>
{agent?.availableModes?.map((mode) => {
const isActive = mode.id === agent.currentModeId;
return (
<Pressable
key={mode.id}
onPress={() => {
onModeChange(mode.id);
onClose();
}}
style={[
styles.modeItem,
isActive && styles.modeItemActive,
]}
>
<Text
style={[
styles.modeName,
isActive && styles.modeNameActive,
]}
>
{mode.name}
</Text>
{mode.description && (
<Text
style={[
styles.modeDescription,
isActive && styles.modeDescriptionActive,
]}
>
{mode.description}
</Text>
)}
</Pressable>
);
})}
</View>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.5)",
justifyContent: "center",
alignItems: "center",
},
modeSelectorContent: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4],
minWidth: 280,
maxWidth: 320,
},
modeItem: {
padding: theme.spacing[4],
borderRadius: theme.borderRadius.md,
marginBottom: theme.spacing[2],
backgroundColor: theme.colors.muted,
},
modeItemActive: {
backgroundColor: theme.colors.primary,
},
modeName: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[1],
},
modeNameActive: {
color: theme.colors.primaryForeground,
},
modeDescription: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
modeDescriptionActive: {
color: theme.colors.primaryForeground,
opacity: 0.8,
},
}));
@@ -0,0 +1,117 @@
import { View, ScrollView } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { forwardRef } from "react";
import {
UserMessage,
AssistantMessage,
ActivityLog,
ToolCall,
} from "@/components/message";
import type { MessageEntry } from "@/contexts/session-context";
interface OrchestratorMessagesViewProps {
messages: MessageEntry[];
currentAssistantMessage: string;
onArtifactClick: (artifactId: string) => void;
}
export const OrchestratorMessagesView = forwardRef<ScrollView, OrchestratorMessagesViewProps>(
function OrchestratorMessagesView({ messages, currentAssistantMessage, onArtifactClick }, ref) {
const { theme } = useUnistyles();
return (
<ScrollView
ref={ref}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{messages.map((msg) => {
if (msg.type === "user") {
return (
<UserMessage
key={msg.id}
message={msg.message}
timestamp={msg.timestamp}
/>
);
}
if (msg.type === "assistant") {
return (
<AssistantMessage
key={msg.id}
message={msg.message}
timestamp={msg.timestamp}
/>
);
}
if (msg.type === "activity") {
return (
<ActivityLog
key={msg.id}
type={msg.activityType}
message={msg.message}
timestamp={msg.timestamp}
metadata={msg.metadata}
onArtifactClick={onArtifactClick}
/>
);
}
if (msg.type === "artifact") {
return (
<ActivityLog
key={msg.id}
type="artifact"
message=""
timestamp={msg.timestamp}
artifactId={msg.artifactId}
artifactType={msg.artifactType}
title={msg.title}
onArtifactClick={onArtifactClick}
/>
);
}
if (msg.type === "tool_call") {
return (
<ToolCall
key={msg.id}
toolName={msg.toolName}
args={msg.args}
result={msg.result}
error={msg.error}
status={msg.status}
/>
);
}
return null;
})}
{/* Streaming assistant message */}
{currentAssistantMessage && (
<AssistantMessage
message={currentAssistantMessage}
timestamp={Date.now()}
isStreaming={true}
/>
)}
</ScrollView>
);
}
);
const styles = StyleSheet.create((theme) => ({
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[6],
},
}));
@@ -0,0 +1,110 @@
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { MicOff, Square } from "lucide-react-native";
import { VolumeMeter } from "./volume-meter";
import { useRealtime } from "@/contexts/realtime-context";
export function RealtimeControls() {
const { theme } = useUnistyles();
const {
volume,
isMuted,
isDetecting,
isSpeaking,
segmentDuration,
stopRealtime,
toggleMute,
} = useRealtime();
return (
<View style={styles.container}>
<View style={styles.volumeContainer}>
<VolumeMeter
volume={volume}
isMuted={isMuted}
isDetecting={isDetecting}
isSpeaking={isSpeaking}
/>
{/* Debug timer */}
{(isDetecting || isSpeaking) && (
<Text style={styles.debugTimer}>
{(segmentDuration / 1000).toFixed(1)}s
</Text>
)}
</View>
<View style={styles.buttons}>
{/* Mute button */}
<Pressable
onPress={toggleMute}
style={[
styles.muteButton,
isMuted && styles.muteButtonActive,
]}
>
<MicOff
size={20}
color={
isMuted
? theme.colors.background
: theme.colors.foreground
}
/>
</Pressable>
{/* Stop button */}
<Pressable
onPress={stopRealtime}
style={styles.stopButton}
>
<Square size={18} color="white" fill="white" />
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
minHeight: 200,
padding: theme.spacing[4],
},
volumeContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
buttons: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingTop: theme.spacing[4],
},
muteButton: {
width: 48,
height: 48,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.muted,
borderWidth: theme.borderWidth[2],
borderColor: theme.colors.border,
},
muteButtonActive: {
backgroundColor: theme.colors.palette.red[500],
borderColor: theme.colors.palette.red[600],
},
stopButton: {
width: 48,
height: 48,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.palette.red[600],
},
debugTimer: {
marginTop: theme.spacing[2],
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
fontFamily: "monospace",
},
}));