From 4c266155785aad5050ebff7211e3d5f9e15c3238 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sun, 08 Mar 2026 07:37:45 +0100
Subject: [PATCH] feat: explicit session addressing + toast queue + solid toast styling
---
types/index.ts | 4 ++
contexts/ConnectionContext.tsx | 23 ++++++-----
contexts/ChatContext.tsx | 23 ++++++++---
components/ui/IncomingToast.tsx | 51 +++++++++++++------------
4 files changed, 58 insertions(+), 43 deletions(-)
diff --git a/components/ui/IncomingToast.tsx b/components/ui/IncomingToast.tsx
index 779c82e..2c9fb23 100644
--- a/components/ui/IncomingToast.tsx
+++ b/components/ui/IncomingToast.tsx
@@ -13,22 +13,24 @@
export function IncomingToast({ sessionName, preview, onTap, onDismiss }: IncomingToastProps) {
const { colors } = useTheme();
- const opacity = useRef(new Animated.Value(0)).current;
- const translateY = useRef(new Animated.Value(-40)).current;
+ const translateY = useRef(new Animated.Value(-60)).current;
useEffect(() => {
- // Slide in
- Animated.parallel([
- Animated.timing(opacity, { toValue: 1, duration: 200, useNativeDriver: true }),
- Animated.timing(translateY, { toValue: 0, duration: 200, useNativeDriver: true }),
- ]).start();
+ // Slide in from above (no opacity — keeps background solid)
+ Animated.spring(translateY, {
+ toValue: 0,
+ useNativeDriver: true,
+ tension: 80,
+ friction: 10,
+ }).start();
// Auto-dismiss
const timer = setTimeout(() => {
- Animated.parallel([
- Animated.timing(opacity, { toValue: 0, duration: 200, useNativeDriver: true }),
- Animated.timing(translateY, { toValue: -40, duration: 200, useNativeDriver: true }),
- ]).start(() => onDismiss());
+ Animated.timing(translateY, {
+ toValue: -60,
+ duration: 200,
+ useNativeDriver: true,
+ }).start(() => onDismiss());
}, DISPLAY_MS);
return () => clearTimeout(timer);
@@ -38,11 +40,10 @@
<Animated.View
style={{
position: "absolute",
- top: 0,
+ top: 4,
left: 12,
right: 12,
zIndex: 100,
- opacity,
transform: [{ translateY }],
}}
>
@@ -57,31 +58,31 @@
borderRadius: 12,
backgroundColor: pressed ? colors.bgTertiary : colors.bgSecondary,
borderWidth: 1,
- borderColor: colors.border,
+ borderColor: colors.accent,
shadowColor: "#000",
- shadowOffset: { width: 0, height: 2 },
- shadowOpacity: 0.25,
- shadowRadius: 4,
- elevation: 5,
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.4,
+ shadowRadius: 8,
+ elevation: 8,
})}
>
<View
style={{
- width: 8,
- height: 8,
- borderRadius: 4,
- backgroundColor: "#3b82f6",
+ width: 10,
+ height: 10,
+ borderRadius: 5,
+ backgroundColor: colors.accent,
}}
/>
<View style={{ flex: 1 }}>
- <Text style={{ color: colors.text, fontSize: 13, fontWeight: "700" }} numberOfLines={1}>
+ <Text style={{ color: colors.text, fontSize: 14, fontWeight: "700" }} numberOfLines={1}>
{sessionName}
</Text>
- <Text style={{ color: colors.textMuted, fontSize: 12, marginTop: 1 }} numberOfLines={1}>
+ <Text style={{ color: colors.textMuted, fontSize: 12, marginTop: 2 }} numberOfLines={1}>
{preview}
</Text>
</View>
- <Text style={{ color: colors.textMuted, fontSize: 11 }}>tap to switch</Text>
+ <Text style={{ color: colors.accent, fontSize: 11, fontWeight: "600" }}>switch</Text>
</Pressable>
</Animated.View>
);
diff --git a/contexts/ChatContext.tsx b/contexts/ChatContext.tsx
index b109bb7..144f375 100644
--- a/contexts/ChatContext.tsx
+++ b/contexts/ChatContext.tsx
@@ -167,7 +167,8 @@
// Per-session typing indicator (sessionId → boolean)
const typingMapRef = useRef<Record<string, boolean>>({});
const [isTyping, setIsTyping] = useState(false);
- // Toast for other-session incoming messages
+ // Toast queue for other-session incoming messages (show one at a time)
+ const toastQueueRef = useRef<{ sessionId: string; sessionName: string; preview: string }[]>([]);
const [incomingToast, setIncomingToast] = useState<{ sessionId: string; sessionName: string; preview: string } | null>(null);
// PAI projects list
const [projects, setProjects] = useState<PaiProject[]>([]);
@@ -265,13 +266,19 @@
...u,
[sessionId]: (u[sessionId] ?? 0) + 1,
}));
- // Show toast for other-session messages (assistant only, skip system noise)
+ // Queue toast for other-session messages (assistant only, skip system noise)
if (msg.role === "assistant") {
setSessions((prev) => {
const session = prev.find((s) => s.id === sessionId);
const name = session?.name ?? sessionId.slice(0, 8);
const preview = msg.type === "voice" ? "🎤 Voice note" : msg.type === "image" ? "📷 Image" : (msg.content ?? "").slice(0, 60);
- setIncomingToast({ sessionId, sessionName: name, preview });
+ const toast = { sessionId, sessionName: name, preview };
+ // If no toast is showing, show immediately; otherwise queue
+ setIncomingToast((current) => {
+ if (current === null) return toast;
+ toastQueueRef.current.push(toast);
+ return current;
+ });
return prev;
});
}
@@ -444,7 +451,7 @@
status: "sending",
};
addMessageToActive(msg);
- const sent = wsSend(text);
+ const sent = wsSend(text, activeSessionIdRef.current ?? undefined);
updateMessageStatus(id, sent ? "sent" : "error");
},
[wsSend, addMessageToActive, updateMessageStatus]
@@ -466,7 +473,7 @@
addMessageToActive(msg);
try {
const base64 = await encodeAudioToBase64(audioUri);
- const sent = wsVoice(base64, "", id);
+ const sent = wsVoice(base64, "", id, activeSessionIdRef.current ?? undefined);
updateMessageStatus(id, sent ? "sent" : "error");
} catch (err) {
console.error("Failed to encode audio:", err);
@@ -489,7 +496,7 @@
status: "sending",
};
addMessageToActive(msg);
- const sent = wsImageSend(imageBase64, caption, mimeType);
+ const sent = wsImageSend(imageBase64, caption, mimeType, activeSessionIdRef.current ?? undefined);
updateMessageStatus(id, sent ? "sent" : "error");
},
[wsImageSend, addMessageToActive, updateMessageStatus]
@@ -560,7 +567,9 @@
}, [sendCommand]);
const dismissToast = useCallback(() => {
- setIncomingToast(null);
+ // Show next queued toast, or clear
+ const next = toastQueueRef.current.shift();
+ setIncomingToast(next ?? null);
}, []);
const loadMoreMessages = useCallback(() => {
diff --git a/contexts/ConnectionContext.tsx b/contexts/ConnectionContext.tsx
index f1fab69..ed245ba 100644
--- a/contexts/ConnectionContext.tsx
+++ b/contexts/ConnectionContext.tsx
@@ -23,10 +23,10 @@
status: ConnectionStatus;
connect: (config?: ServerConfig) => void;
disconnect: () => void;
- sendTextMessage: (text: string) => boolean;
- sendVoiceMessage: (audioBase64: string, transcript?: string, messageId?: string) => boolean;
- sendImageMessage: (imageBase64: string, caption: string, mimeType: string) => boolean;
- sendCommand: (command: string, args?: Record<string, unknown>) => boolean;
+ sendTextMessage: (text: string, sessionId?: string) => boolean;
+ sendVoiceMessage: (audioBase64: string, transcript?: string, messageId?: string, sessionId?: string) => boolean;
+ sendImageMessage: (imageBase64: string, caption: string, mimeType: string, sessionId?: string) => boolean;
+ sendCommand: (command: string, args?: Record<string, unknown>, sessionId?: string) => boolean;
saveServerConfig: (config: ServerConfig) => Promise<void>;
onMessageReceived: React.MutableRefObject<
((data: WsIncoming) => void) | null
@@ -116,32 +116,33 @@
setServerConfig(config);
}, []);
- const sendTextMessage = useCallback((text: string): boolean => {
- return wsClient.send({ type: "text", content: text });
+ const sendTextMessage = useCallback((text: string, sessionId?: string): boolean => {
+ return wsClient.send({ type: "text", content: text, sessionId });
}, []);
const sendVoiceMessage = useCallback(
- (audioBase64: string, transcript: string = "", messageId?: string): boolean => {
+ (audioBase64: string, transcript: string = "", messageId?: string, sessionId?: string): boolean => {
return wsClient.send({
type: "voice",
content: transcript,
audioBase64,
messageId,
+ sessionId,
});
},
[]
);
const sendImageMessage = useCallback(
- (imageBase64: string, caption: string = "", mimeType: string = "image/jpeg"): boolean => {
- return wsClient.send({ type: "image", imageBase64, caption, mimeType });
+ (imageBase64: string, caption: string = "", mimeType: string = "image/jpeg", sessionId?: string): boolean => {
+ return wsClient.send({ type: "image", imageBase64, caption, mimeType, sessionId });
},
[]
);
const sendCommand = useCallback(
- (command: string, args?: Record<string, unknown>): boolean => {
- const msg: WsOutgoing = { type: "command", command, args };
+ (command: string, args?: Record<string, unknown>, sessionId?: string): boolean => {
+ const msg: WsOutgoing = { type: "command", command, args, sessionId };
return wsClient.send(msg as any);
},
[]
diff --git a/types/index.ts b/types/index.ts
index 613d2d6..259915f 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -28,6 +28,7 @@
export interface WsTextMessage {
type: "text";
content: string;
+ sessionId?: string;
}
export interface WsVoiceMessage {
@@ -35,6 +36,7 @@
audioBase64: string;
content: string;
messageId?: string;
+ sessionId?: string;
}
export interface WsImageMessage {
@@ -42,12 +44,14 @@
imageBase64: string;
caption: string;
mimeType: string;
+ sessionId?: string;
}
export interface WsCommandMessage {
type: "command";
command: string;
args?: Record<string, unknown>;
+ sessionId?: string;
}
export type WsOutgoing = WsTextMessage | WsVoiceMessage | WsImageMessage | WsCommandMessage;
--
Gitblit v1.3.1