Matthias Nott
2026-03-07 c23dfe16e95713e7058137308bdbc28419609a39
contexts/ChatContext.tsx
....@@ -8,7 +8,7 @@
88 } from "react";
99 import { Message, WsIncoming, WsSession } from "../types";
1010 import { useConnection } from "./ConnectionContext";
11
-import { playAudio, encodeAudioToBase64, saveBase64Audio } from "../services/audio";
11
+import { playAudio, encodeAudioToBase64, saveBase64Audio, canAutoplay } from "../services/audio";
1212 import { requestNotificationPermissions, notifyIncomingMessage } from "../services/notifications";
1313
1414 function generateId(): string {
....@@ -119,7 +119,9 @@
119119 sendTextMessage: (text: string) => void;
120120 sendVoiceMessage: (audioUri: string, durationMs?: number) => void;
121121 sendImageMessage: (imageBase64: string, caption: string, mimeType: string) => void;
122
+ deleteMessage: (id: string) => void;
122123 clearMessages: () => void;
124
+ isTyping: boolean;
123125 sessions: WsSession[];
124126 activeSessionId: string | null;
125127 requestSessions: () => void;
....@@ -147,6 +149,8 @@
147149 const [messages, setMessages] = useState<Message[]>([]);
148150 // Unread counts for non-active sessions
149151 const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
152
+ // Typing indicator from server
153
+ const [isTyping, setIsTyping] = useState(false);
150154
151155 const {
152156 status,
....@@ -197,6 +201,8 @@
197201 if (status === "connected") {
198202 needsSync.current = true;
199203 sendCommand("sync", activeSessionId ? { activeSessionId } : undefined);
204
+ } else if (status === "disconnected") {
205
+ setIsTyping(false);
200206 }
201207 // eslint-disable-next-line react-hooks/exhaustive-deps — only fire on status change
202208 }, [status, sendCommand]);
....@@ -270,6 +276,7 @@
270276 onMessageReceived.current = async (data: WsIncoming) => {
271277 switch (data.type) {
272278 case "text": {
279
+ setIsTyping(false);
273280 const msg: Message = {
274281 id: generateId(),
275282 role: "assistant",
....@@ -283,6 +290,7 @@
283290 break;
284291 }
285292 case "voice": {
293
+ setIsTyping(false);
286294 let audioUri: string | undefined;
287295 if (data.audioBase64) {
288296 try {
....@@ -302,7 +310,7 @@
302310 };
303311 addMessageToActive(msg);
304312 notifyIncomingMessage("PAILot", data.content ?? "Voice message");
305
- if (msg.audioUri) {
313
+ if (msg.audioUri && canAutoplay()) {
306314 playAudio(msg.audioUri).catch(() => {});
307315 }
308316 break;
....@@ -356,6 +364,14 @@
356364 case "transcript": {
357365 // Voice → text reflection: replace voice bubble with transcribed text
358366 updateMessageContent(data.messageId, data.content);
367
+ break;
368
+ }
369
+ case "typing": {
370
+ setIsTyping(data.typing);
371
+ break;
372
+ }
373
+ case "status": {
374
+ // Connection status update — ignore for now
359375 break;
360376 }
361377 case "error": {
....@@ -440,6 +456,20 @@
440456 [wsImageSend, addMessageToActive, updateMessageStatus]
441457 );
442458
459
+ const deleteMessage = useCallback((id: string) => {
460
+ setMessages((prev) => {
461
+ const next = prev.filter((m) => m.id !== id);
462
+ setActiveSessionId((sessId) => {
463
+ if (sessId) {
464
+ messagesMapRef.current[sessId] = next;
465
+ debouncedSave(messagesMapRef.current);
466
+ }
467
+ return sessId;
468
+ });
469
+ return next;
470
+ });
471
+ }, []);
472
+
443473 const clearMessages = useCallback(() => {
444474 setMessages([]);
445475 setActiveSessionId((id) => {
....@@ -515,7 +545,9 @@
515545 sendTextMessage,
516546 sendVoiceMessage,
517547 sendImageMessage,
548
+ deleteMessage,
518549 clearMessages,
550
+ isTyping,
519551 sessions,
520552 activeSessionId,
521553 requestSessions,