From 0e888d62af1434fef231e11a5c307a5b48a8deb1 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 07 Mar 2026 10:49:07 +0100
Subject: [PATCH] feat: singleton audio, transcript reflection, voice persistence
---
contexts/ChatContext.tsx | 48 ++++++++++++++++++++++++++++++++++++++++++------
1 files changed, 42 insertions(+), 6 deletions(-)
diff --git a/contexts/ChatContext.tsx b/contexts/ChatContext.tsx
index 0865375..151f6f7 100644
--- a/contexts/ChatContext.tsx
+++ b/contexts/ChatContext.tsx
@@ -26,7 +26,9 @@
const MESSAGES_DIR = "pailot-messages";
-/** Strip heavy fields (base64 images, audio URIs) before persisting. */
+/** Strip heavy fields (base64 images, audio URIs) before persisting.
+ * Voice messages keep their content (transcript) but lose audioUri
+ * since cache files won't survive app restarts. */
function lightMessage(m: Message): Message {
const light = { ...m };
if (light.imageBase64) light.imageBase64 = undefined;
@@ -63,7 +65,16 @@
if (!file.endsWith(".json")) continue;
const sessionId = file.replace(".json", "");
const content = await fs.readAsStringAsync(`${dir}${file}`);
- result[sessionId] = JSON.parse(content) as Message[];
+ result[sessionId] = (JSON.parse(content) as Message[])
+ // Drop voice messages with no audio and no content (empty chunks)
+ .filter((m) => !(m.type === "voice" && !m.audioUri && !m.content))
+ .map((m) => {
+ // Voice messages without audio but with transcript → show as text
+ if (m.type === "voice" && !m.audioUri && m.content) {
+ return { ...m, type: "text" };
+ }
+ return m;
+ });
}
return result;
} catch {
@@ -179,12 +190,15 @@
}
}, [messages]);
- // On connect: ask gateway to detect the focused iTerm2 session and sync
+ // On connect: ask gateway to sync sessions. If we already had a session
+ // selected, tell the gateway so it preserves our selection instead of
+ // jumping to whatever iTerm has focused on the Mac.
useEffect(() => {
if (status === "connected") {
needsSync.current = true;
- sendCommand("sync");
+ sendCommand("sync", activeSessionId ? { activeSessionId } : undefined);
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps — only fire on status change
}, [status, sendCommand]);
// Helper: add a message to the active session
@@ -233,6 +247,23 @@
},
[]
);
+
+ // Update a message's content (e.g., voice transcript reflection)
+ const updateMessageContent = useCallback((id: string, content: string) => {
+ setMessages((prev) => {
+ const next = prev.map((m) =>
+ m.id === id ? { ...m, content } : m
+ );
+ setActiveSessionId((sessId) => {
+ if (sessId) {
+ messagesMapRef.current[sessId] = next;
+ debouncedSave(messagesMapRef.current);
+ }
+ return sessId;
+ });
+ return next;
+ });
+ }, []);
// Handle incoming WebSocket messages
useEffect(() => {
@@ -322,6 +353,11 @@
sendCommand("sessions");
break;
}
+ case "transcript": {
+ // Voice → text reflection: replace voice bubble with transcribed text
+ updateMessageContent(data.messageId, data.content);
+ break;
+ }
case "error": {
const msg: Message = {
id: generateId(),
@@ -339,7 +375,7 @@
return () => {
onMessageReceived.current = null;
};
- }, [onMessageReceived, sendCommand, addMessageToActive, syncActiveFromSessions]);
+ }, [onMessageReceived, sendCommand, addMessageToActive, updateMessageContent, syncActiveFromSessions]);
const sendTextMessage = useCallback(
(text: string) => {
@@ -375,7 +411,7 @@
addMessageToActive(msg);
try {
const base64 = await encodeAudioToBase64(audioUri);
- const sent = wsVoice(base64);
+ const sent = wsVoice(base64, "", id);
updateMessageStatus(id, sent ? "sent" : "error");
} catch (err) {
console.error("Failed to encode audio:", err);
--
Gitblit v1.3.1