docs: update tmux commands formatting and add Android screenshot instructions
@@ -18,6 +18,11 @@ This is an npm workspace monorepo:
|
||||
|
||||
We run the mobile app and server in the tmux session `voice-dev`:
|
||||
|
||||
tmux capture-pane -t voice-dev:mobile -p
|
||||
`tmux capture-pane -t voice-dev:mobile -p`
|
||||
`tmux capture-pane -t voice-dev:server -p`
|
||||
|
||||
tmux capture-pane -t voice-dev:server -p
|
||||
Don't run them anywhere else. Check logs there.
|
||||
|
||||
## Andriod
|
||||
|
||||
Take screensots like this: `adb exec-out screencap -p > screenshot.png`
|
||||
|
||||
2524
package-lock.json
generated
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"softwareKeyboardLayoutMode": "pan",
|
||||
"softwareKeyboardLayoutMode": "resize",
|
||||
"permissions": [
|
||||
"RECORD_AUDIO",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
|
||||
3
packages/app/nativewind-env.d.ts
vendored
@@ -1,3 +0,0 @@
|
||||
/// <reference types="react-native-css/types" />
|
||||
|
||||
// NOTE: This file should not be edited and should be committed with your source code. It is generated by react-native-css. If you need to move or disable this file, please see the documentation.
|
||||
@@ -43,6 +43,7 @@
|
||||
"react-native-css": "^3.0.1",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "^1.19.2",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-nitro-modules": "^0.30.0",
|
||||
"react-native-permissions": "^5.4.2",
|
||||
|
||||
BIN
packages/app/screenshot-test.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
@@ -1,14 +1,17 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { KeyboardProvider } from 'react-native-keyboard-controller';
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="audio-test" />
|
||||
</Stack>
|
||||
<KeyboardProvider>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="audio-test" />
|
||||
</Stack>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
KeyboardAvoidingView,
|
||||
ScrollView,
|
||||
Animated,
|
||||
Keyboard,
|
||||
} from "react-native";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { router } from "expo-router";
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -131,9 +134,21 @@ export default function VoiceAssistantScreen() {
|
||||
const [isRealtimeMode, setIsRealtimeMode] = useState(false);
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
const audioRecorder = useAudioRecorder();
|
||||
const audioPlayer = useAudioPlayer();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// 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);
|
||||
return {
|
||||
paddingBottom: padding,
|
||||
};
|
||||
});
|
||||
|
||||
// Realtime audio with Speechmatics (echo cancellation)
|
||||
const realtimeAudio = useSpeechmaticsAudio({
|
||||
@@ -960,197 +975,198 @@ export default function VoiceAssistantScreen() {
|
||||
|
||||
// Render orchestrator view (main chat)
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
style={styles.container}
|
||||
keyboardVerticalOffset={Platform.OS === "ios" ? insets.top : 0}
|
||||
>
|
||||
{/* Connection status header with buttons */}
|
||||
<View style={{ paddingTop: insets.top + 16 }}>
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.headerLeft}>
|
||||
<ConnectionStatus isConnected={ws.isConnected} />
|
||||
<View style={styles.container}>
|
||||
{/* Fixed Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={{ paddingTop: insets.top + 16 }}>
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.headerLeft}>
|
||||
<ConnectionStatus isConnected={ws.isConnected} />
|
||||
</View>
|
||||
<View style={styles.headerRight}>
|
||||
<ConversationSelector
|
||||
currentConversationId={conversationId}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
websocket={ws}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => router.push("/settings")}
|
||||
style={styles.settingsButton}
|
||||
>
|
||||
<Settings size={20} color="white" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.headerRight}>
|
||||
<ConversationSelector
|
||||
currentConversationId={conversationId}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
websocket={ws}
|
||||
</View>
|
||||
|
||||
{/* Active processes bar */}
|
||||
<ActiveProcesses
|
||||
agents={Array.from(agents.values())}
|
||||
commands={Array.from(commands.values())}
|
||||
activeProcessId={activeAgentId}
|
||||
activeProcessType={activeAgentId ? "agent" : null}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onBackToOrchestrator={handleBackToOrchestrator}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Content Area with Keyboard Handling */}
|
||||
<ReanimatedAnimated.View
|
||||
style={[styles.contentArea, animatedKeyboardStyle]}
|
||||
>
|
||||
{/* Scrollable Messages Area */}
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
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={handleArtifactClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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={handleArtifactClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* Fixed Footer */}
|
||||
<View
|
||||
style={[
|
||||
styles.inputArea,
|
||||
{ paddingBottom: Math.max(insets.bottom, 8) },
|
||||
]}
|
||||
>
|
||||
{/* Text input */}
|
||||
<TextInput
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
placeholder="Say something..."
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
style={styles.textInput}
|
||||
multiline
|
||||
editable={!isRecording && ws.isConnected}
|
||||
/>
|
||||
|
||||
{/* Buttons */}
|
||||
<View style={styles.buttonRow}>
|
||||
{/* Realtime mode button */}
|
||||
<Pressable
|
||||
onPress={() => router.push("/settings")}
|
||||
style={styles.settingsButton}
|
||||
onPress={handleRealtimeToggle}
|
||||
disabled={!ws.isConnected}
|
||||
style={[
|
||||
styles.realtimeButton,
|
||||
!ws.isConnected && styles.buttonDisabled,
|
||||
isRealtimeMode && styles.realtimeButtonActive,
|
||||
]}
|
||||
>
|
||||
<Settings size={20} color="white" />
|
||||
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
|
||||
<AudioLines size={20} color="white" />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
|
||||
{/* Main action button */}
|
||||
<Pressable
|
||||
onPress={handleButtonClick}
|
||||
disabled={!ws.isConnected}
|
||||
style={[
|
||||
styles.mainButton,
|
||||
!ws.isConnected && styles.buttonDisabled,
|
||||
isRecording && styles.mainButtonRecording,
|
||||
isInProgress && styles.mainButtonInProgress,
|
||||
userInput.trim() &&
|
||||
!isRecording &&
|
||||
!isInProgress &&
|
||||
styles.mainButtonWithText,
|
||||
]}
|
||||
>
|
||||
{isInProgress ? (
|
||||
<Square size={18} color="white" fill="white" />
|
||||
) : isRecording ? (
|
||||
<Square size={14} color="white" fill="white" />
|
||||
) : userInput.trim() ? (
|
||||
<ArrowUp size={20} color="white" />
|
||||
) : (
|
||||
<Mic size={20} color="white" />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Active processes bar */}
|
||||
<ActiveProcesses
|
||||
agents={Array.from(agents.values())}
|
||||
commands={Array.from(commands.values())}
|
||||
activeProcessId={activeAgentId}
|
||||
activeProcessType={activeAgentId ? "agent" : null}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onBackToOrchestrator={handleBackToOrchestrator}
|
||||
/>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
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={handleArtifactClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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={handleArtifactClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* Input area */}
|
||||
<View
|
||||
style={[
|
||||
styles.inputArea,
|
||||
{
|
||||
paddingBottom: Math.max(insets.bottom, 32),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Text input */}
|
||||
<TextInput
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
placeholder="Say something..."
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
style={styles.textInput}
|
||||
multiline
|
||||
editable={!isRecording && ws.isConnected}
|
||||
/>
|
||||
|
||||
{/* Buttons */}
|
||||
<View style={styles.buttonRow}>
|
||||
{/* Realtime mode button */}
|
||||
<Pressable
|
||||
onPress={handleRealtimeToggle}
|
||||
disabled={!ws.isConnected}
|
||||
style={[
|
||||
styles.realtimeButton,
|
||||
!ws.isConnected && styles.buttonDisabled,
|
||||
isRealtimeMode && styles.realtimeButtonActive,
|
||||
]}
|
||||
>
|
||||
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
|
||||
<AudioLines size={20} color="white" />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
|
||||
{/* Main action button */}
|
||||
<Pressable
|
||||
onPress={handleButtonClick}
|
||||
disabled={!ws.isConnected}
|
||||
style={[
|
||||
styles.mainButton,
|
||||
!ws.isConnected && styles.buttonDisabled,
|
||||
isRecording && styles.mainButtonRecording,
|
||||
isInProgress && styles.mainButtonInProgress,
|
||||
userInput.trim() &&
|
||||
!isRecording &&
|
||||
!isInProgress &&
|
||||
styles.mainButtonWithText,
|
||||
]}
|
||||
>
|
||||
{isInProgress ? (
|
||||
<Square size={18} color="white" fill="white" />
|
||||
) : isRecording ? (
|
||||
<Square size={14} color="white" fill="white" />
|
||||
) : userInput.trim() ? (
|
||||
<ArrowUp size={20} color="white" />
|
||||
) : (
|
||||
<Mic size={20} color="white" />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</ReanimatedAnimated.View>
|
||||
|
||||
{/* Artifact drawer */}
|
||||
<ArtifactDrawer
|
||||
artifact={currentArtifact}
|
||||
onClose={handleCloseArtifact}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1168,12 +1184,21 @@ const styles = StyleSheet.create((theme) => ({
|
||||
agentNotFoundText: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
header: {
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
contentArea: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingBottom: theme.spacing[4],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
headerLeft: {
|
||||
flex: 1,
|
||||
@@ -1190,9 +1215,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: theme.spacing[4],
|
||||
flexGrow: 1,
|
||||
},
|
||||
inputArea: {
|
||||
paddingTop: theme.spacing[4],
|
||||
@@ -1215,6 +1242,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[3],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
realtimeButton: {
|
||||
width: 48,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { View, Text } from 'react-native';
|
||||
import { StyleSheet } from 'react-native-unistyles';
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
interface ConnectionStatusProps {
|
||||
isConnected: boolean;
|
||||
@@ -7,14 +7,11 @@ interface ConnectionStatusProps {
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
// No padding or border - parent handles layout
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
@@ -43,9 +40,19 @@ export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.row}>
|
||||
<View style={[styles.dot, isConnected ? styles.dotConnected : styles.dotDisconnected]} />
|
||||
<Text style={[styles.text, isConnected ? styles.textConnected : styles.textDisconnected]}>
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
<View
|
||||
style={[
|
||||
styles.dot,
|
||||
isConnected ? styles.dotConnected : styles.dotDisconnected,
|
||||
]}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.text,
|
||||
isConnected ? styles.textConnected : styles.textDisconnected,
|
||||
]}
|
||||
>
|
||||
{isConnected ? "Connected" : "Disconnected"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, View, Text, Pressable, ScrollView, Alert, KeyboardAvoidingView, Platform } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { MessageSquare, X, Plus, Trash2 } from 'lucide-react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { StyleSheet } from 'react-native-unistyles';
|
||||
import type { UseWebSocketReturn } from '../hooks/use-websocket';
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { MessageSquare, X, Plus, Trash2 } from "lucide-react-native";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { UseWebSocketReturn } from "../hooks/use-websocket";
|
||||
|
||||
const STORAGE_KEY = '@voice-dev:conversation-id';
|
||||
const STORAGE_KEY = "@voice-dev:conversation-id";
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
@@ -31,32 +40,41 @@ export function ConversationSelector({
|
||||
|
||||
// Listen for conversation list responses
|
||||
useEffect(() => {
|
||||
const unsubscribe = websocket.on('list_conversations_response', (message) => {
|
||||
if (message.type !== 'list_conversations_response') return;
|
||||
setConversations(message.payload.conversations);
|
||||
setIsLoading(false);
|
||||
});
|
||||
const unsubscribe = websocket.on(
|
||||
"list_conversations_response",
|
||||
(message) => {
|
||||
if (message.type !== "list_conversations_response") return;
|
||||
setConversations(message.payload.conversations);
|
||||
setIsLoading(false);
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [websocket]);
|
||||
|
||||
// Listen for delete conversation responses
|
||||
useEffect(() => {
|
||||
const unsubscribe = websocket.on('delete_conversation_response', (message) => {
|
||||
if (message.type !== 'delete_conversation_response') return;
|
||||
console.log('[ConversationSelector] Delete response:', message.payload);
|
||||
if (message.payload.success) {
|
||||
// Refresh conversations list
|
||||
fetchConversations();
|
||||
const unsubscribe = websocket.on(
|
||||
"delete_conversation_response",
|
||||
(message) => {
|
||||
if (message.type !== "delete_conversation_response") return;
|
||||
console.log("[ConversationSelector] Delete response:", message.payload);
|
||||
if (message.payload.success) {
|
||||
// Refresh conversations list
|
||||
fetchConversations();
|
||||
|
||||
// If we deleted the current conversation, start a new one
|
||||
if (message.payload.conversationId === currentConversationId) {
|
||||
handleNewConversation();
|
||||
// If we deleted the current conversation, start a new one
|
||||
if (message.payload.conversationId === currentConversationId) {
|
||||
handleNewConversation();
|
||||
}
|
||||
} else {
|
||||
Alert.alert(
|
||||
"Error",
|
||||
`Failed to delete conversation: ${message.payload.error}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
Alert.alert('Error', `Failed to delete conversation: ${message.payload.error}`);
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [websocket, currentConversationId]);
|
||||
@@ -70,30 +88,35 @@ export function ConversationSelector({
|
||||
|
||||
function fetchConversations() {
|
||||
setIsLoading(true);
|
||||
console.log('[ConversationSelector] Requesting conversations via WebSocket');
|
||||
console.log(
|
||||
"[ConversationSelector] Requesting conversations via WebSocket"
|
||||
);
|
||||
websocket.send({
|
||||
type: 'session',
|
||||
type: "session",
|
||||
message: {
|
||||
type: 'list_conversations_request',
|
||||
type: "list_conversations_request",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeleteConversation(id: string) {
|
||||
Alert.alert(
|
||||
'Delete Conversation',
|
||||
'Are you sure you want to delete this conversation?',
|
||||
"Delete Conversation",
|
||||
"Are you sure you want to delete this conversation?",
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
console.log('[ConversationSelector] Deleting conversation via WebSocket:', id);
|
||||
console.log(
|
||||
"[ConversationSelector] Deleting conversation via WebSocket:",
|
||||
id
|
||||
);
|
||||
websocket.send({
|
||||
type: 'session',
|
||||
type: "session",
|
||||
message: {
|
||||
type: 'delete_conversation_request',
|
||||
type: "delete_conversation_request",
|
||||
conversationId: id,
|
||||
},
|
||||
});
|
||||
@@ -105,21 +128,23 @@ export function ConversationSelector({
|
||||
|
||||
function handleClearAll() {
|
||||
Alert.alert(
|
||||
'Clear All Conversations',
|
||||
'Are you sure you want to delete all conversations?',
|
||||
"Clear All Conversations",
|
||||
"Are you sure you want to delete all conversations?",
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: 'Clear All',
|
||||
style: 'destructive',
|
||||
text: "Clear All",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
console.log('[ConversationSelector] Clearing all conversations via WebSocket');
|
||||
console.log(
|
||||
"[ConversationSelector] Clearing all conversations via WebSocket"
|
||||
);
|
||||
// Delete all conversations
|
||||
conversations.forEach((conv) => {
|
||||
websocket.send({
|
||||
type: 'session',
|
||||
type: "session",
|
||||
message: {
|
||||
type: 'delete_conversation_request',
|
||||
type: "delete_conversation_request",
|
||||
conversationId: conv.id,
|
||||
},
|
||||
});
|
||||
@@ -144,7 +169,10 @@ export function ConversationSelector({
|
||||
onSelectConversation(id);
|
||||
setIsOpen(false);
|
||||
} catch (error) {
|
||||
console.error('[ConversationSelector] Failed to save conversation ID:', error);
|
||||
console.error(
|
||||
"[ConversationSelector] Failed to save conversation ID:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +183,10 @@ export function ConversationSelector({
|
||||
onSelectConversation(null);
|
||||
setIsOpen(false);
|
||||
} catch (error) {
|
||||
console.error('[ConversationSelector] Failed to clear conversation ID:', error);
|
||||
console.error(
|
||||
"[ConversationSelector] Failed to clear conversation ID:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +198,7 @@ export function ConversationSelector({
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
@@ -176,10 +207,7 @@ export function ConversationSelector({
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
onPress={() => setIsOpen(true)}
|
||||
style={styles.triggerButton}
|
||||
>
|
||||
<Pressable onPress={() => setIsOpen(true)} style={styles.triggerButton}>
|
||||
<MessageSquare size={20} color="white" />
|
||||
</Pressable>
|
||||
|
||||
@@ -199,7 +227,8 @@ export function ConversationSelector({
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>
|
||||
Conversations {conversations.length > 0 && `(${conversations.length})`}
|
||||
Conversations{" "}
|
||||
{conversations.length > 0 && `(${conversations.length})`}
|
||||
</Text>
|
||||
<Pressable onPress={() => setIsOpen(false)}>
|
||||
<X size={24} color="white" />
|
||||
@@ -236,34 +265,40 @@ export function ConversationSelector({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isLoading && conversations.map((conversation) => (
|
||||
<View
|
||||
key={conversation.id}
|
||||
style={[
|
||||
styles.conversationItem,
|
||||
conversation.id === currentConversationId && styles.conversationItemActive
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.conversationContent}
|
||||
onPress={() => handleSelectConversation(conversation.id)}
|
||||
{!isLoading &&
|
||||
conversations.map((conversation) => (
|
||||
<View
|
||||
key={conversation.id}
|
||||
style={[
|
||||
styles.conversationItem,
|
||||
conversation.id === currentConversationId &&
|
||||
styles.conversationItemActive,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.conversationTitle}>
|
||||
{conversation.messageCount} messages
|
||||
</Text>
|
||||
<Text style={styles.conversationDate}>
|
||||
{formatDate(conversation.lastUpdated)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => handleDeleteConversation(conversation.id)}
|
||||
style={styles.deleteButton}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Trash2 size={20} color="#ef4444" />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
<Pressable
|
||||
style={styles.conversationContent}
|
||||
onPress={() =>
|
||||
handleSelectConversation(conversation.id)
|
||||
}
|
||||
>
|
||||
<Text style={styles.conversationTitle}>
|
||||
{conversation.messageCount} messages
|
||||
</Text>
|
||||
<Text style={styles.conversationDate}>
|
||||
{formatDate(conversation.lastUpdated)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
handleDeleteConversation(conversation.id)
|
||||
}
|
||||
style={styles.deleteButton}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Trash2 size={20} color="#ef4444" />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{!isLoading && conversations.length > 0 && (
|
||||
<View style={styles.clearAllContainer}>
|
||||
@@ -288,13 +323,12 @@ export function ConversationSelector({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
triggerButton: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[2],
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
},
|
||||
modalBackdrop: {
|
||||
flex: 1,
|
||||
@@ -303,15 +337,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.card,
|
||||
borderTopLeftRadius: theme.spacing[6],
|
||||
borderTopRightRadius: theme.spacing[6],
|
||||
height: '80%',
|
||||
height: "80%",
|
||||
},
|
||||
modalInner: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: theme.spacing[6],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
@@ -327,9 +361,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
newButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
backgroundColor: theme.colors.muted,
|
||||
paddingVertical: theme.spacing[3],
|
||||
@@ -347,26 +381,26 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
loadingContainer: {
|
||||
padding: theme.spacing[8],
|
||||
alignItems: 'center',
|
||||
alignItems: "center",
|
||||
},
|
||||
loadingText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
emptyContainer: {
|
||||
padding: theme.spacing[8],
|
||||
alignItems: 'center',
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
conversationItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: theme.spacing[4],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
conversationItemActive: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
@@ -391,11 +425,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
clearAllButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
backgroundColor: 'rgba(127, 29, 29, 0.2)',
|
||||
backgroundColor: "rgba(127, 29, 29, 0.2)",
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
|
||||
BIN
screenshot-keyboard.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
screenshot-now.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
screenshot-test.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
screenshot-test2.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
screenshot-with-keyboard.png
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
screenshot.png
Normal file
|
After Width: | Height: | Size: 105 KiB |