From c23dfe16e95713e7058137308bdbc28419609a39 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 07 Mar 2026 11:54:15 +0100
Subject: [PATCH] feat: typing indicator, message deletion, chain playback, autoplay guard
---
contexts/ChatContext.tsx | 36 ++++++++++++++++++++++++++++++++++--
1 files changed, 34 insertions(+), 2 deletions(-)
diff --git a/contexts/ChatContext.tsx b/contexts/ChatContext.tsx
index 151f6f7..788bb31 100644
--- a/contexts/ChatContext.tsx
+++ b/contexts/ChatContext.tsx
@@ -8,7 +8,7 @@
} from "react";
import { Message, WsIncoming, WsSession } from "../types";
import { useConnection } from "./ConnectionContext";
-import { playAudio, encodeAudioToBase64, saveBase64Audio } from "../services/audio";
+import { playAudio, encodeAudioToBase64, saveBase64Audio, canAutoplay } from "../services/audio";
import { requestNotificationPermissions, notifyIncomingMessage } from "../services/notifications";
function generateId(): string {
@@ -119,7 +119,9 @@
sendTextMessage: (text: string) => void;
sendVoiceMessage: (audioUri: string, durationMs?: number) => void;
sendImageMessage: (imageBase64: string, caption: string, mimeType: string) => void;
+ deleteMessage: (id: string) => void;
clearMessages: () => void;
+ isTyping: boolean;
sessions: WsSession[];
activeSessionId: string | null;
requestSessions: () => void;
@@ -147,6 +149,8 @@
const [messages, setMessages] = useState<Message[]>([]);
// Unread counts for non-active sessions
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
+ // Typing indicator from server
+ const [isTyping, setIsTyping] = useState(false);
const {
status,
@@ -197,6 +201,8 @@
if (status === "connected") {
needsSync.current = true;
sendCommand("sync", activeSessionId ? { activeSessionId } : undefined);
+ } else if (status === "disconnected") {
+ setIsTyping(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps — only fire on status change
}, [status, sendCommand]);
@@ -270,6 +276,7 @@
onMessageReceived.current = async (data: WsIncoming) => {
switch (data.type) {
case "text": {
+ setIsTyping(false);
const msg: Message = {
id: generateId(),
role: "assistant",
@@ -283,6 +290,7 @@
break;
}
case "voice": {
+ setIsTyping(false);
let audioUri: string | undefined;
if (data.audioBase64) {
try {
@@ -302,7 +310,7 @@
};
addMessageToActive(msg);
notifyIncomingMessage("PAILot", data.content ?? "Voice message");
- if (msg.audioUri) {
+ if (msg.audioUri && canAutoplay()) {
playAudio(msg.audioUri).catch(() => {});
}
break;
@@ -356,6 +364,14 @@
case "transcript": {
// Voice → text reflection: replace voice bubble with transcribed text
updateMessageContent(data.messageId, data.content);
+ break;
+ }
+ case "typing": {
+ setIsTyping(data.typing);
+ break;
+ }
+ case "status": {
+ // Connection status update — ignore for now
break;
}
case "error": {
@@ -440,6 +456,20 @@
[wsImageSend, addMessageToActive, updateMessageStatus]
);
+ const deleteMessage = useCallback((id: string) => {
+ setMessages((prev) => {
+ const next = prev.filter((m) => m.id !== id);
+ setActiveSessionId((sessId) => {
+ if (sessId) {
+ messagesMapRef.current[sessId] = next;
+ debouncedSave(messagesMapRef.current);
+ }
+ return sessId;
+ });
+ return next;
+ });
+ }, []);
+
const clearMessages = useCallback(() => {
setMessages([]);
setActiveSessionId((id) => {
@@ -515,7 +545,9 @@
sendTextMessage,
sendVoiceMessage,
sendImageMessage,
+ deleteMessage,
clearMessages,
+ isTyping,
sessions,
activeSessionId,
requestSessions,
--
Gitblit v1.3.1