From a0f39302919fbacf7a0d407f01b1a50413ea6f70 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Mon, 02 Mar 2026 23:15:13 +0100
Subject: [PATCH] feat: on-device speech recognition, navigation screen, session picker
---
app/chat.tsx | 182 +++++++++++++++++++++++++++++++++------------
1 files changed, 133 insertions(+), 49 deletions(-)
diff --git a/app/chat.tsx b/app/chat.tsx
index cd0e2af..d773e98 100644
--- a/app/chat.tsx
+++ b/app/chat.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback } from "react";
+import React, { useCallback, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { router } from "expo-router";
@@ -6,69 +6,129 @@
import { useConnection } from "../contexts/ConnectionContext";
import { MessageList } from "../components/chat/MessageList";
import { InputBar } from "../components/chat/InputBar";
-import { CommandBar } from "../components/chat/CommandBar";
+import { CommandBar, TextModeCommandBar } from "../components/chat/CommandBar";
import { StatusDot } from "../components/ui/StatusDot";
+import { SessionPicker } from "../components/SessionPicker";
+import { playAudio } from "../services/audio";
export default function ChatScreen() {
- const { messages, sendTextMessage, sendVoiceMessage, clearMessages } =
+ const { messages, sendTextMessage, clearMessages, requestScreenshot } =
useChat();
const { status } = useConnection();
+ const [isTextMode, setIsTextMode] = useState(false);
+ const [showSessions, setShowSessions] = useState(false);
- const handleCommand = useCallback(
- (command: string) => {
- if (command === "/clear") {
- clearMessages();
+ const handleSessions = useCallback(() => {
+ setShowSessions(true);
+ }, []);
+
+ const handleScreenshot = useCallback(() => {
+ requestScreenshot();
+ router.push("/navigate");
+ }, [requestScreenshot]);
+
+ const handleHelp = useCallback(() => {
+ sendTextMessage("/h");
+ }, [sendTextMessage]);
+
+ const handleNavigate = useCallback(() => {
+ router.push("/navigate");
+ }, []);
+
+ const handleClear = useCallback(() => {
+ clearMessages();
+ }, [clearMessages]);
+
+ const handleReplay = useCallback(() => {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i];
+ if (msg.role === "assistant") {
+ if (msg.audioUri) {
+ playAudio(msg.audioUri).catch(() => {});
+ }
return;
}
- sendTextMessage(command);
- },
- [sendTextMessage, clearMessages]
- );
-
- const handleSendVoice = useCallback(
- (audioUri: string, durationMs: number) => {
- sendVoiceMessage(audioUri, durationMs);
- },
- [sendVoiceMessage]
- );
+ }
+ }, [messages]);
return (
- <SafeAreaView className="flex-1 bg-pai-bg" edges={["top", "bottom"]}>
+ <SafeAreaView style={{ flex: 1, backgroundColor: "#0A0A0F" }} edges={["top", "bottom"]}>
{/* Header */}
- <View className="flex-row items-center justify-between px-4 py-3 border-b border-pai-border">
- <Text className="text-pai-text text-xl font-bold tracking-tight">
- PAILot
- </Text>
- <View className="flex-row items-center gap-3">
- <StatusDot status={status} size={10} />
- <Text className="text-pai-text-secondary text-xs">
- {status === "connected"
- ? "Connected"
- : status === "connecting"
- ? "Connecting..."
- : "Offline"}
- </Text>
- <Pressable
- onPress={() => router.push("/settings")}
- className="w-9 h-9 items-center justify-center rounded-full bg-pai-bg-tertiary"
- hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
+ <View
+ style={{
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: "#2E2E45",
+ }}
+ >
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
+ <Text
+ style={{
+ color: "#E8E8F0",
+ fontSize: 22,
+ fontWeight: "800",
+ letterSpacing: -0.5,
+ }}
>
- <Text className="text-base">⚙️</Text>
- </Pressable>
+ PAILot
+ </Text>
+ <StatusDot status={status} size={8} />
</View>
+
+ <Pressable
+ onPress={() => router.push("/settings")}
+ hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
+ style={{
+ width: 36,
+ height: 36,
+ alignItems: "center",
+ justifyContent: "center",
+ borderRadius: 18,
+ backgroundColor: "#1E1E2E",
+ }}
+ >
+ <Text style={{ fontSize: 15 }}>⚙️</Text>
+ </Pressable>
</View>
{/* Message list */}
- <View className="flex-1">
+ <View style={{ flex: 1 }}>
{messages.length === 0 ? (
- <View className="flex-1 items-center justify-center gap-3">
- <Text className="text-5xl">🛩</Text>
- <Text className="text-pai-text text-xl font-semibold">
- PAILot
- </Text>
- <Text className="text-pai-text-muted text-sm text-center px-8">
- Voice-first AI communicator.{"\n"}Hold the mic button to talk.
- </Text>
+ <View style={{ flex: 1, alignItems: "center", justifyContent: "center", gap: 16 }}>
+ <View
+ style={{
+ width: 80,
+ height: 80,
+ borderRadius: 40,
+ backgroundColor: "#1E1E2E",
+ alignItems: "center",
+ justifyContent: "center",
+ borderWidth: 1,
+ borderColor: "#2E2E45",
+ }}
+ >
+ <Text style={{ fontSize: 36 }}>🛩</Text>
+ </View>
+ <View style={{ alignItems: "center", gap: 6 }}>
+ <Text style={{ color: "#E8E8F0", fontSize: 20, fontWeight: "700" }}>
+ PAILot
+ </Text>
+ <Text
+ style={{
+ color: "#5A5A78",
+ fontSize: 14,
+ textAlign: "center",
+ paddingHorizontal: 40,
+ lineHeight: 20,
+ }}
+ >
+ Voice-first AI communicator.{"\n"}Tap the mic to start talking.
+ </Text>
+ </View>
</View>
) : (
<MessageList messages={messages} />
@@ -76,10 +136,34 @@
</View>
{/* Command bar */}
- <CommandBar onCommand={handleCommand} />
+ {isTextMode ? (
+ <TextModeCommandBar
+ onSessions={handleSessions}
+ onScreenshot={handleScreenshot}
+ onNavigate={handleNavigate}
+ onClear={handleClear}
+ />
+ ) : (
+ <CommandBar
+ onSessions={handleSessions}
+ onScreenshot={handleScreenshot}
+ onHelp={handleHelp}
+ />
+ )}
{/* Input bar */}
- <InputBar onSendText={sendTextMessage} onSendVoice={handleSendVoice} />
+ <InputBar
+ onSendText={sendTextMessage}
+ onReplay={handleReplay}
+ isTextMode={isTextMode}
+ onToggleMode={() => setIsTextMode((v) => !v)}
+ />
+
+ {/* Session picker modal */}
+ <SessionPicker
+ visible={showSessions}
+ onClose={() => setShowSessions(false)}
+ />
</SafeAreaView>
);
}
--
Gitblit v1.3.1