diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 9d77e1326..351d1aeb7 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -36,3 +36,5 @@ Set a high timeout, like 2 minutes.
Try not to bias it, state the problem clearly and let it work it out.
`codex exec "prompt"`
+
+**CRITICAL: ALWAYS RUN TYPECHECK AFTER EVERY CHANGE.**
diff --git a/packages/app/assets/images/android-icon-background.png b/packages/app/assets/images/android-icon-background.png
index 5ffefc5bb..2cc012d47 100644
Binary files a/packages/app/assets/images/android-icon-background.png and b/packages/app/assets/images/android-icon-background.png differ
diff --git a/packages/app/assets/images/android-icon-foreground.png b/packages/app/assets/images/android-icon-foreground.png
index 3a9e5016d..27c94262e 100644
Binary files a/packages/app/assets/images/android-icon-foreground.png and b/packages/app/assets/images/android-icon-foreground.png differ
diff --git a/packages/app/assets/images/android-icon-monochrome.png b/packages/app/assets/images/android-icon-monochrome.png
index 77484ebdb..767aee490 100644
Binary files a/packages/app/assets/images/android-icon-monochrome.png and b/packages/app/assets/images/android-icon-monochrome.png differ
diff --git a/packages/app/assets/images/favicon.png b/packages/app/assets/images/favicon.png
index 408bd7466..663079133 100644
Binary files a/packages/app/assets/images/favicon.png and b/packages/app/assets/images/favicon.png differ
diff --git a/packages/app/assets/images/hammock-icon.png b/packages/app/assets/images/hammock-icon.png
new file mode 100644
index 000000000..ae5077dee
Binary files /dev/null and b/packages/app/assets/images/hammock-icon.png differ
diff --git a/packages/app/assets/images/icon.png b/packages/app/assets/images/icon.png
index 7165a53c7..ae5077dee 100644
Binary files a/packages/app/assets/images/icon.png and b/packages/app/assets/images/icon.png differ
diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index d20194640..7595ebdd1 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -1,18 +1,61 @@
-import { Stack } from 'expo-router';
-import { SafeAreaProvider } from 'react-native-safe-area-context';
-import { KeyboardProvider } from 'react-native-keyboard-controller';
-import { GestureHandlerRootView } from 'react-native-gesture-handler';
+import { Stack } from "expo-router";
+import { SafeAreaProvider } from "react-native-safe-area-context";
+import { KeyboardProvider } from "react-native-keyboard-controller";
+import { GestureHandlerRootView } from "react-native-gesture-handler";
+import { SessionProvider } from "@/contexts/session-context";
+import { RealtimeProvider } from "@/contexts/realtime-context";
+import { useSettings } from "@/hooks/use-settings";
+import { View, ActivityIndicator } from "react-native";
+
+function ProvidersWrapper({ children }: { children: React.ReactNode }) {
+ const { settings, isLoading } = useSettings();
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
export default function RootLayout() {
return (
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/app/agent/[id].tsx b/packages/app/src/app/agent/[id].tsx
new file mode 100644
index 000000000..4776a0634
--- /dev/null
+++ b/packages/app/src/app/agent/[id].tsx
@@ -0,0 +1,87 @@
+import { View, Text } from "react-native";
+import { useLocalSearchParams } from "expo-router";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
+import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated";
+import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import { BackHeader } from "@/components/headers/back-header";
+import { AgentStreamView } from "@/components/agent-stream-view";
+import { GlobalFooter } from "@/components/global-footer";
+import { useSession } from "@/contexts/session-context";
+
+export default function AgentScreen() {
+ const { theme } = useUnistyles();
+ const insets = useSafeAreaInsets();
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const { agents, agentStreamState, pendingPermissions, respondToPermission } = useSession();
+
+ // Keyboard animation
+ const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
+ const animatedKeyboardStyle = useAnimatedStyle(() => {
+ "worklet";
+ const absoluteHeight = Math.abs(keyboardHeight.value);
+ const padding = Math.max(0, absoluteHeight - insets.bottom);
+ return {
+ paddingBottom: padding,
+ };
+ });
+
+ const agent = id ? agents.get(id) : undefined;
+ const streamItems = id ? agentStreamState.get(id) || [] : [];
+ const agentPermissions = new Map(
+ Array.from(pendingPermissions.entries()).filter(([_, perm]) => perm.agentId === id)
+ );
+
+ if (!agent) {
+ return (
+
+
+
+ Agent not found
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Content Area with Keyboard Animation */}
+
+
+ respondToPermission(requestId, id!, agent.sessionId || "", [optionId])
+ }
+ />
+
+ {/* Footer */}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ container: {
+ flex: 1,
+ backgroundColor: theme.colors.background,
+ },
+ content: {
+ flex: 1,
+ },
+ errorContainer: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ errorText: {
+ fontSize: theme.fontSize.lg,
+ color: theme.colors.mutedForeground,
+ },
+}));
diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx
index c0c02f60f..c5dc68638 100644
--- a/packages/app/src/app/index.tsx
+++ b/packages/app/src/app/index.tsx
@@ -1,1710 +1,66 @@
-import { useEffect, useState, useRef } from "react";
-import {
- View,
- Pressable,
- Text,
- TextInput,
- Platform,
- KeyboardAvoidingView,
- ScrollView,
- Animated,
- Keyboard,
- Modal,
-} from "react-native";
-import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
-import { GestureDetector, Gesture } from "react-native-gesture-handler";
-import ReanimatedAnimated, {
- useAnimatedStyle,
- useSharedValue,
- withSpring,
- withRepeat,
- withSequence,
- withTiming,
- cancelAnimation,
- runOnJS,
- Easing,
-} from "react-native-reanimated";
-import { router } from "expo-router";
-import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
+import { View } from "react-native";
+import { useState } from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
+import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
-import { theme as defaultTheme, theme } from "../styles/theme";
-
-import { useWebSocket } from "@/hooks/use-websocket";
-import { useAudioRecorder } from "@/hooks/use-audio-recorder";
-import { useAudioPlayer } from "@/hooks/use-audio-player";
-import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio";
-import { useSettings } from "@/hooks/use-settings";
-import { ConnectionStatus } from "@/components/connection-status";
-import {
- UserMessage,
- AssistantMessage,
- ActivityLog,
- ToolCall,
-} from "@/components/message";
-import { ArtifactDrawer, type Artifact } from "@/components/artifact-drawer";
-import { AgentSidebar } from "@/components/agent-sidebar";
-import { AgentStreamView } from "@/components/agent-stream-view";
-import { ConversationSelector } from "@/components/conversation-selector";
-import { VolumeMeter } from "@/components/volume-meter";
-import { reduceStreamUpdate, generateMessageId, type StreamItem } from "@/types/stream";
+import { HomeHeader } from "@/components/headers/home-header";
+import { EmptyState } from "@/components/empty-state";
+import { AgentList } from "@/components/agent-list";
+import { GlobalFooter } from "@/components/global-footer";
import { CreateAgentModal } from "@/components/create-agent-modal";
-import {
- Settings,
- Mic,
- ArrowUp,
- Square,
- AudioLines,
- MicOff,
- Plus,
- ChevronDown,
- Menu,
- Home,
-} from "lucide-react-native";
-import type {
- ActivityLogPayload,
- SessionInboundMessage,
- WSInboundMessage,
-} from "@server/server/messages";
-import type { AgentStatus } from "@server/server/acp/types";
-import type { SessionNotification } from "@agentclientprotocol/sdk";
-import { parseSessionUpdate } from "@/types/agent-activity";
+import { useSession } from "@/contexts/session-context";
-type MessageEntry =
- | {
- type: "user";
- id: string;
- timestamp: number;
- message: string;
- }
- | {
- type: "assistant";
- id: string;
- timestamp: number;
- message: string;
- }
- | {
- type: "activity";
- id: string;
- timestamp: number;
- activityType: "system" | "info" | "success" | "error";
- message: string;
- metadata?: Record;
- }
- | {
- type: "artifact";
- id: string;
- timestamp: number;
- artifactId: string;
- artifactType: string;
- title: string;
- }
- | {
- type: "tool_call";
- id: string;
- timestamp: number;
- toolName: string;
- args: any;
- result?: any;
- error?: any;
- status: "executing" | "completed" | "failed";
- };
-
-type ViewMode = "orchestrator" | "agent";
-
-interface Agent {
- id: string;
- status: AgentStatus;
- createdAt: Date;
- type: "claude";
- sessionId?: string;
- error?: string;
- currentModeId?: string;
- availableModes?: Array<{
- id: string;
- name: string;
- description?: string | null;
- }>;
- title?: string;
- cwd: string;
-}
-
-interface Command {
- id: string;
- name: string;
- workingDirectory: string;
- currentCommand: string;
- isDead: boolean;
- exitCode: number | null;
-}
-
-interface AgentUpdate {
- timestamp: Date;
- notification: SessionNotification;
-}
-
-interface RealtimeCircleProps {
- volume: number;
- onClose: () => void;
-}
-
-function RealtimeCircle({ volume, onClose }: RealtimeCircleProps) {
+export default function HomeScreen() {
const { theme } = useUnistyles();
-
- // Base size for the circles
- const BASE_SIZE = 80;
- const MIN_SCALE = 1;
- const MAX_SCALE = 1.8;
-
- // Create animated value for volume-based scaling
- const volumeScale = useSharedValue(MIN_SCALE);
- const pulseScale = useSharedValue(1);
-
- // Update volume scale when volume changes
- useEffect(() => {
- // Map volume (0-1) to scale range (MIN_SCALE - MAX_SCALE)
- const targetScale = MIN_SCALE + volume * (MAX_SCALE - MIN_SCALE);
- volumeScale.value = withSpring(targetScale, {
- damping: 15,
- stiffness: 150,
- });
- }, [volume]);
-
- // Continuous pulsating animation for outer circle
- useEffect(() => {
- pulseScale.value = withRepeat(
- withSequence(
- withTiming(1.3, { duration: 1000 }),
- withTiming(1, { duration: 1000 })
- ),
- -1,
- false
- );
- }, []);
-
- // Animated style for inner circle (volume reactive)
- const innerCircleStyle = useAnimatedStyle(() => ({
- width: BASE_SIZE,
- height: BASE_SIZE,
- borderRadius: BASE_SIZE / 2,
- transform: [{ scale: volumeScale.value }],
- }));
-
- // Animated style for outer circle (continuous pulse)
- const outerCircleStyle = useAnimatedStyle(() => ({
- width: BASE_SIZE * 1.5,
- height: BASE_SIZE * 1.5,
- borderRadius: (BASE_SIZE * 1.5) / 2,
- transform: [{ scale: pulseScale.value }],
- opacity: 0.3,
- }));
-
- return (
-
-
- {/* Outer pulsating circle */}
-
-
- {/* Inner volume-reactive circle */}
-
-
-
- );
-}
-
-export default function VoiceAssistantScreen() {
- const { settings, isLoading: settingsLoading } = useSettings();
- const [conversationId, setConversationId] = useState(null);
- const ws = useWebSocket(settings.serverUrl, conversationId);
-
- // Realtime mode state (defined early so we can use it in audioRecorder)
- const [isRealtimeMode, setIsRealtimeMode] = useState(false);
- const isRealtimeModeRef = useRef(isRealtimeMode);
- const pulseAnim = useRef(new Animated.Value(1)).current;
-
- // Keep ref in sync with state
- useEffect(() => {
- isRealtimeModeRef.current = isRealtimeMode;
- }, [isRealtimeMode]);
-
const insets = useSafeAreaInsets();
- const audioRecorder = useAudioRecorder();
- const audioPlayer = useAudioPlayer();
+ const { agents, createAgent } = useSession();
+ const [showCreateModal, setShowCreateModal] = useState(false);
// Keyboard animation
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
- const bottomInset = insets.bottom;
const animatedKeyboardStyle = useAnimatedStyle(() => {
"worklet";
const absoluteHeight = Math.abs(keyboardHeight.value);
- const padding = Math.max(0, absoluteHeight - bottomInset);
+ const padding = Math.max(0, absoluteHeight - insets.bottom);
return {
paddingBottom: padding,
};
});
- // Realtime audio with Speechmatics (echo cancellation)
- const realtimeAudio = useSpeechmaticsAudio({
- onSpeechStart: () => {
- console.log("[App] Speech detected");
- // Stop audio playback if playing
- if (isPlayingAudio) {
- audioPlayer.stop();
- }
- },
- onSpeechEnd: () => {
- console.log("[App] Speech ended");
- },
- onAudioSegment: (base64Audio: string) => {
- console.log("[App] Sending audio segment, length:", base64Audio.length);
+ const hasAgents = agents.size > 0;
- // Send audio segment to server (realtime always goes to orchestrator)
- try {
- ws.send({
- type: "session",
- message: {
- type: "realtime_audio_chunk",
- audio: base64Audio,
- format: "audio/wav",
- isLast: true, // Complete segment
- },
- });
- } catch (error) {
- console.error("[App] Failed to send audio segment:", error);
- }
- },
- onError: (error) => {
- console.error("[App] Realtime audio error:", error);
- setMessages((prev) => [
- ...prev,
- {
- type: "activity",
- id: generateMessageId(),
- timestamp: Date.now(),
- activityType: "error",
- message: `Realtime audio error: ${error.message}`,
- },
- ]);
- },
- volumeThreshold: 0.3,
- silenceDuration: 2000,
- speechConfirmationDuration: 300,
- detectionGracePeriod: 200,
- });
-
- const [messages, setMessages] = useState([]);
- const [currentAssistantMessage, setCurrentAssistantMessage] = useState("");
- const [isProcessingAudio, setIsProcessingAudio] = useState(false);
-
- // Agent stream state - unified chronological stream using reducer pattern
- const [agentStreamState, setAgentStreamState] = useState