Matthias Nott
2026-03-15 e25bdba29f49b1b55a8a8cccdc4583aea3c101ed
feat: multi-image upload and catch_up message delivery

- Multi-image picker with thumbnail strip and caption modal
- Sequence-based catch_up protocol for reliable message delivery
- AppState foreground resume triggers catch_up for missed messages
- Dedup by seq number, notifications suppressed during replay
4 files modified
changed files
app/chat.tsx patch | view | blame | history
components/chat/ImageCaptionModal.tsx patch | view | blame | history
contexts/ChatContext.tsx patch | view | blame | history
types/index.ts patch | view | blame | history
app/chat.tsx
....@@ -30,7 +30,7 @@
3030 const [isTextMode, setIsTextMode] = useState(false);
3131 const [showSessions, setShowSessions] = useState(false);
3232 const [audioPlaying, setAudioPlaying] = useState(false);
33
- const [stagedImage, setStagedImage] = useState<StagedImage | null>(null);
33
+ const [stagedImages, setStagedImages] = useState<StagedImage[]>([]);
3434
3535 useEffect(() => {
3636 return onPlayingChange((uri) => setAudioPlaying(uri !== null));
....@@ -59,17 +59,19 @@
5959 clearMessages();
6060 }, [clearMessages]);
6161
62
- // Resolve a picked asset into a StagedImage
63
- const stageAsset = useCallback(async (asset: { base64?: string | null; uri: string; mimeType?: string | null }) => {
64
- const mimeType = asset.mimeType ?? (asset.uri.endsWith(".png") ? "image/png" : "image/jpeg");
65
- let base64 = asset.base64 ?? "";
66
- if (!base64 && asset.uri) {
67
- const { readAsStringAsync } = await import("expo-file-system/legacy");
68
- base64 = await readAsStringAsync(asset.uri, { encoding: "base64" });
62
+ // Resolve picked assets into StagedImage array
63
+ const stageAssets = useCallback(async (assets: Array<{ base64?: string | null; uri: string; mimeType?: string | null }>) => {
64
+ const staged: StagedImage[] = [];
65
+ for (const asset of assets) {
66
+ const mimeType = asset.mimeType ?? (asset.uri.endsWith(".png") ? "image/png" : "image/jpeg");
67
+ let base64 = asset.base64 ?? "";
68
+ if (!base64 && asset.uri) {
69
+ const { readAsStringAsync } = await import("expo-file-system/legacy");
70
+ base64 = await readAsStringAsync(asset.uri, { encoding: "base64" });
71
+ }
72
+ if (base64) staged.push({ base64, uri: asset.uri, mimeType });
6973 }
70
- if (base64) {
71
- setStagedImage({ base64, uri: asset.uri, mimeType });
72
- }
74
+ if (staged.length > 0) setStagedImages(staged);
7375 }, []);
7476
7577 const pickFromLibrary = useCallback(async () => {
....@@ -82,15 +84,17 @@
8284 }
8385 const result = await ImagePicker.launchImageLibraryAsync({
8486 mediaTypes: ["images"],
87
+ allowsMultipleSelection: true,
88
+ selectionLimit: 10,
8589 quality: 0.7,
8690 base64: true,
8791 });
88
- if (result.canceled || !result.assets?.[0]) return;
89
- await stageAsset(result.assets[0]);
92
+ if (result.canceled || !result.assets?.length) return;
93
+ await stageAssets(result.assets);
9094 } catch (err: any) {
9195 Alert.alert("Image Error", err?.message ?? String(err));
9296 }
93
- }, [stageAsset]);
97
+ }, [stageAssets]);
9498
9599 const pickFromCamera = useCallback(async () => {
96100 try {
....@@ -105,11 +109,11 @@
105109 base64: true,
106110 });
107111 if (result.canceled || !result.assets?.[0]) return;
108
- await stageAsset(result.assets[0]);
112
+ await stageAssets([result.assets[0]]);
109113 } catch (err: any) {
110114 Alert.alert("Camera Error", err?.message ?? String(err));
111115 }
112
- }, [stageAsset]);
116
+ }, [stageAssets]);
113117
114118 const handlePickImage = useCallback(() => {
115119 if (Platform.OS === "ios") {
....@@ -131,11 +135,14 @@
131135
132136 const handleImageSend = useCallback(
133137 (caption: string) => {
134
- if (!stagedImage) return;
135
- sendImageMessage(stagedImage.base64, caption, stagedImage.mimeType);
136
- setStagedImage(null);
138
+ if (stagedImages.length === 0) return;
139
+ // Send each image as a separate message; caption on the first only
140
+ stagedImages.forEach((img, i) => {
141
+ sendImageMessage(img.base64, i === 0 ? caption : "", img.mimeType);
142
+ });
143
+ setStagedImages([]);
137144 },
138
- [stagedImage, sendImageMessage],
145
+ [stagedImages, sendImageMessage],
139146 );
140147
141148 const handleReplay = useCallback(async () => {
....@@ -340,10 +347,10 @@
340347
341348 {/* Image caption modal — WhatsApp-style full-screen preview */}
342349 <ImageCaptionModal
343
- visible={!!stagedImage}
344
- imageUri={stagedImage ? `data:${stagedImage.mimeType};base64,${stagedImage.base64}` : ""}
350
+ visible={stagedImages.length > 0}
351
+ images={stagedImages.map((img) => ({ uri: `data:${img.mimeType};base64,${img.base64}` }))}
345352 onSend={handleImageSend}
346
- onCancel={() => setStagedImage(null)}
353
+ onCancel={() => setStagedImages([])}
347354 />
348355
349356 {/* Session drawer — absolute overlay outside KAV */}
components/chat/ImageCaptionModal.tsx
....@@ -1,6 +1,7 @@
11 import React, { useEffect, useRef, useState } from "react";
22 import {
33 Dimensions,
4
+ FlatList,
45 Image,
56 KeyboardAvoidingView,
67 Modal,
....@@ -12,22 +13,28 @@
1213 } from "react-native";
1314 import { useTheme } from "../../contexts/ThemeContext";
1415
16
+interface ImageItem {
17
+ uri: string;
18
+}
19
+
1520 interface ImageCaptionModalProps {
1621 visible: boolean;
17
- imageUri: string;
22
+ images: ImageItem[];
1823 onSend: (caption: string) => void;
1924 onCancel: () => void;
2025 }
2126
22
-export function ImageCaptionModal({ visible, imageUri, onSend, onCancel }: ImageCaptionModalProps) {
27
+export function ImageCaptionModal({ visible, images, onSend, onCancel }: ImageCaptionModalProps) {
2328 const { colors } = useTheme();
2429 const [caption, setCaption] = useState("");
30
+ const [selectedIndex, setSelectedIndex] = useState(0);
2531 const inputRef = useRef<TextInput>(null);
2632 const { width, height } = Dimensions.get("window");
2733
2834 useEffect(() => {
2935 if (visible) {
3036 setCaption("");
37
+ setSelectedIndex(0);
3138 setTimeout(() => inputRef.current?.focus(), 300);
3239 }
3340 }, [visible]);
....@@ -37,6 +44,9 @@
3744 setCaption("");
3845 };
3946
47
+ const currentImage = images[selectedIndex]?.uri ?? "";
48
+ const isMultiple = images.length > 1;
49
+
4050 return (
4151 <Modal visible={visible} animationType="slide" transparent={false} onRequestClose={onCancel}>
4252 <View style={{ flex: 1, backgroundColor: "#000" }}>
....@@ -45,7 +55,7 @@
4555 behavior={Platform.OS === "ios" ? "padding" : undefined}
4656 keyboardVerticalOffset={0}
4757 >
48
- {/* Top bar with cancel */}
58
+ {/* Top bar with cancel + count */}
4959 <View
5060 style={{
5161 paddingTop: 54,
....@@ -68,17 +78,48 @@
6878 >
6979 <Text style={{ color: "#fff", fontSize: 16, fontWeight: "600" }}>Cancel</Text>
7080 </Pressable>
81
+ {isMultiple && (
82
+ <Text style={{ color: "rgba(255,255,255,0.6)", fontSize: 14 }}>
83
+ {selectedIndex + 1} / {images.length}
84
+ </Text>
85
+ )}
7186 </View>
7287
7388 {/* Image preview */}
7489 <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
7590 <Image
76
- source={{ uri: imageUri }}
77
- style={{ width, height: height * 0.55 }}
91
+ source={{ uri: currentImage }}
92
+ style={{ width, height: isMultiple ? height * 0.45 : height * 0.55 }}
7893 resizeMode="contain"
7994 />
8095 </View>
8196
97
+ {/* Thumbnail strip — only for multiple images */}
98
+ {isMultiple && (
99
+ <FlatList
100
+ data={images}
101
+ horizontal
102
+ showsHorizontalScrollIndicator={false}
103
+ keyExtractor={(_, i) => String(i)}
104
+ contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 8, gap: 8 }}
105
+ renderItem={({ item, index }) => (
106
+ <Pressable onPress={() => setSelectedIndex(index)}>
107
+ <Image
108
+ source={{ uri: item.uri }}
109
+ style={{
110
+ width: 56,
111
+ height: 56,
112
+ borderRadius: 8,
113
+ borderWidth: index === selectedIndex ? 2 : 0,
114
+ borderColor: colors.accent,
115
+ }}
116
+ resizeMode="cover"
117
+ />
118
+ </Pressable>
119
+ )}
120
+ />
121
+ )}
122
+
82123 {/* Caption input + send */}
83124 <View
84125 style={{
contexts/ChatContext.tsx
....@@ -6,6 +6,7 @@
66 useRef,
77 useState,
88 } from "react";
9
+import { AppState, AppStateStatus } from "react-native";
910 import { Message, WsIncoming, WsSession, PaiProject } from "../types";
1011 import { useConnection } from "./ConnectionContext";
1112 import { playAudio, encodeAudioToBase64, saveBase64Audio, canAutoplay } from "../services/audio";
....@@ -142,6 +143,7 @@
142143 loadMoreMessages: () => void;
143144 hasMoreMessages: boolean;
144145 unreadCounts: Record<string, number>;
146
+ unreadSessions: Set<string>;
145147 incomingToast: IncomingToast | null;
146148 dismissToast: () => void;
147149 latestScreenshot: string | null;
....@@ -158,12 +160,18 @@
158160 const [latestScreenshot, setLatestScreenshot] = useState<string | null>(null);
159161 const needsSync = useRef(true);
160162
163
+ // Sequence tracking for catch_up protocol
164
+ const lastSeqRef = useRef(0);
165
+ const seenSeqsRef = useRef(new Set<number>());
166
+
161167 // Per-session message storage
162168 const messagesMapRef = useRef<Record<string, Message[]>>({});
163169 // Messages for the active session (drives re-renders)
164170 const [messages, setMessages] = useState<Message[]>([]);
165171 // Unread counts for non-active sessions
166172 const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
173
+ // Server-pushed unread indicators (sessions with new activity since last viewed)
174
+ const [unreadSessions, setUnreadSessions] = useState<Set<string>>(new Set());
167175 // Per-session typing indicator (sessionId → boolean)
168176 const typingMapRef = useRef<Record<string, boolean>>({});
169177 const [isTyping, setIsTyping] = useState(false);
....@@ -211,6 +219,12 @@
211219 delete next[active.id];
212220 return next;
213221 });
222
+ setUnreadSessions((prev) => {
223
+ if (!prev.has(active.id)) return prev;
224
+ const next = new Set(prev);
225
+ next.delete(active.id);
226
+ return next;
227
+ });
214228 // Sync typing indicator for the new active session
215229 const activeTyping = typingMapRef.current[active.id] ?? false;
216230 setIsTyping(activeTyping);
....@@ -221,18 +235,34 @@
221235 }
222236 }, []);
223237
224
- // On connect: ask gateway to sync sessions. If we already had a session
225
- // selected, tell the gateway so it preserves our selection instead of
226
- // jumping to whatever iTerm has focused on the Mac.
238
+ // On connect: ask gateway to sync sessions, then request catch_up for missed messages.
227239 useEffect(() => {
228240 if (status === "connected") {
229241 needsSync.current = true;
230242 const id = activeSessionIdRef.current;
231243 sendCommand("sync", id ? { activeSessionId: id } : undefined);
244
+ // Request any messages we missed while disconnected/backgrounded
245
+ sendCommand("catch_up", { lastSeq: lastSeqRef.current });
232246 } else if (status === "disconnected") {
233247 setIsTyping(false);
234248 }
235249 // eslint-disable-next-line react-hooks/exhaustive-deps — only fire on status change
250
+ }, [status, sendCommand]);
251
+
252
+ // On foreground resume: request catch_up for any messages missed while backgrounded.
253
+ // iOS keeps the WebSocket "open" at TCP level but suspends the app — messages sent
254
+ // during that time are lost. catch_up replays them from the server's message log.
255
+ useEffect(() => {
256
+ let lastState: AppStateStatus = AppState.currentState;
257
+ const sub = AppState.addEventListener("change", (nextState) => {
258
+ if (lastState.match(/inactive|background/) && nextState === "active") {
259
+ if (status === "connected") {
260
+ sendCommand("catch_up", { lastSeq: lastSeqRef.current });
261
+ }
262
+ }
263
+ lastState = nextState;
264
+ });
265
+ return () => sub.remove();
236266 }, [status, sendCommand]);
237267
238268 // Helper: add a message to the active session
....@@ -309,135 +339,173 @@
309339 });
310340 }, []);
311341
342
+ // Process a single incoming message (used by both live delivery and catch_up replay)
343
+ const processIncoming = useCallback(async (data: WsIncoming, isCatchUp = false) => {
344
+ // Dedup by seq: if we've seen this seq before, skip it
345
+ const seq = (data as any).seq as number | undefined;
346
+ if (seq) {
347
+ if (seenSeqsRef.current.has(seq)) return;
348
+ seenSeqsRef.current.add(seq);
349
+ lastSeqRef.current = Math.max(lastSeqRef.current, seq);
350
+ // Keep seen set bounded (last 500 seqs)
351
+ if (seenSeqsRef.current.size > 500) {
352
+ const arr = Array.from(seenSeqsRef.current).sort((a, b) => a - b);
353
+ seenSeqsRef.current = new Set(arr.slice(-300));
354
+ }
355
+ }
356
+
357
+ switch (data.type) {
358
+ case "text": {
359
+ if (!isCatchUp) setIsTyping(false);
360
+ const msg: Message = {
361
+ id: generateId(),
362
+ role: "assistant",
363
+ type: "text",
364
+ content: data.content,
365
+ timestamp: Date.now(),
366
+ status: "sent",
367
+ };
368
+ if (data.sessionId) {
369
+ addMessageToSession(data.sessionId, msg);
370
+ } else {
371
+ addMessageToActive(msg);
372
+ }
373
+ if (!isCatchUp) notifyIncomingMessage("PAILot", data.content ?? "New message");
374
+ break;
375
+ }
376
+ case "voice": {
377
+ if (!isCatchUp) setIsTyping(false);
378
+ let audioUri: string | undefined;
379
+ if (data.audioBase64) {
380
+ try {
381
+ audioUri = await saveBase64Audio(data.audioBase64);
382
+ } catch {
383
+ // fallback: no playable audio
384
+ }
385
+ }
386
+ const msg: Message = {
387
+ id: generateId(),
388
+ role: "assistant",
389
+ type: "voice",
390
+ content: data.content ?? "",
391
+ audioUri,
392
+ timestamp: Date.now(),
393
+ status: "sent",
394
+ };
395
+ const isForActive = !data.sessionId || data.sessionId === activeSessionIdRef.current;
396
+ if (data.sessionId) {
397
+ addMessageToSession(data.sessionId, msg);
398
+ } else {
399
+ addMessageToActive(msg);
400
+ }
401
+ if (!isCatchUp) notifyIncomingMessage("PAILot", data.content ?? "Voice message");
402
+ // Only autoplay if live (not catch_up) and for the currently viewed session
403
+ if (!isCatchUp && msg.audioUri && canAutoplay() && isForActive) {
404
+ playAudio(msg.audioUri).catch(() => {});
405
+ }
406
+ break;
407
+ }
408
+ case "image": {
409
+ setLatestScreenshot(data.imageBase64);
410
+ const msg: Message = {
411
+ id: generateId(),
412
+ role: "assistant",
413
+ type: "image",
414
+ content: data.caption ?? "Screenshot",
415
+ imageBase64: data.imageBase64,
416
+ timestamp: Date.now(),
417
+ status: "sent",
418
+ };
419
+ if (data.sessionId) {
420
+ addMessageToSession(data.sessionId, msg);
421
+ } else {
422
+ addMessageToActive(msg);
423
+ }
424
+ if (!isCatchUp) notifyIncomingMessage("PAILot", data.caption ?? "New image");
425
+ break;
426
+ }
427
+ case "sessions": {
428
+ const incoming = data.sessions as WsSession[];
429
+ setSessions(incoming);
430
+ syncActiveFromSessions(incoming);
431
+ needsSync.current = false;
432
+ break;
433
+ }
434
+ case "session_switched": {
435
+ sendCommand("sessions");
436
+ break;
437
+ }
438
+ case "session_renamed": {
439
+ sendCommand("sessions");
440
+ break;
441
+ }
442
+ case "transcript": {
443
+ updateMessageContent(data.messageId, data.content);
444
+ break;
445
+ }
446
+ case "typing": {
447
+ const typingSession = (data.sessionId as string) || activeSessionIdRef.current || "_global";
448
+ typingMapRef.current[typingSession] = !!data.typing;
449
+ const activeTyping = typingMapRef.current[activeSessionIdRef.current ?? ""] ?? false;
450
+ setIsTyping(activeTyping);
451
+ break;
452
+ }
453
+ case "status": {
454
+ break;
455
+ }
456
+ case "projects": {
457
+ setProjects(data.projects ?? []);
458
+ break;
459
+ }
460
+ case "unread": {
461
+ const targetId = data.sessionId as string;
462
+ if (targetId && targetId !== activeSessionIdRef.current) {
463
+ setUnreadSessions((prev) => {
464
+ if (prev.has(targetId)) return prev;
465
+ const next = new Set(prev);
466
+ next.add(targetId);
467
+ return next;
468
+ });
469
+ }
470
+ break;
471
+ }
472
+ case "error": {
473
+ const errMsg: Message = {
474
+ id: generateId(),
475
+ role: "system",
476
+ type: "text",
477
+ content: data.message,
478
+ timestamp: Date.now(),
479
+ };
480
+ addMessageToActive(errMsg);
481
+ break;
482
+ }
483
+ }
484
+ }, [addMessageToActive, addMessageToSession, sendCommand, syncActiveFromSessions, updateMessageContent]);
485
+
312486 // Handle incoming WebSocket messages
313487 useEffect(() => {
314488 onMessageReceived.current = async (data: WsIncoming) => {
315
- switch (data.type) {
316
- case "text": {
317
- setIsTyping(false);
318
- const msg: Message = {
319
- id: generateId(),
320
- role: "assistant",
321
- type: "text",
322
- content: data.content,
323
- timestamp: Date.now(),
324
- status: "sent",
325
- };
326
- if (data.sessionId) {
327
- addMessageToSession(data.sessionId, msg);
328
- } else {
329
- addMessageToActive(msg);
489
+ // Handle catch_up response: replay all missed messages
490
+ if (data.type === "catch_up") {
491
+ const messages = (data as any).messages as WsIncoming[];
492
+ const serverSeq = (data as any).serverSeq as number | undefined;
493
+ if (serverSeq) lastSeqRef.current = Math.max(lastSeqRef.current, serverSeq);
494
+ if (messages && messages.length > 0) {
495
+ for (const msg of messages) {
496
+ await processIncoming(msg, true);
330497 }
331
- notifyIncomingMessage("PAILot", data.content ?? "New message");
332
- break;
333498 }
334
- case "voice": {
335
- setIsTyping(false);
336
- let audioUri: string | undefined;
337
- if (data.audioBase64) {
338
- try {
339
- audioUri = await saveBase64Audio(data.audioBase64);
340
- } catch {
341
- // fallback: no playable audio
342
- }
343
- }
344
- const msg: Message = {
345
- id: generateId(),
346
- role: "assistant",
347
- type: "voice",
348
- content: data.content ?? "",
349
- audioUri,
350
- timestamp: Date.now(),
351
- status: "sent",
352
- };
353
- const isForActive = !data.sessionId || data.sessionId === activeSessionIdRef.current;
354
- if (data.sessionId) {
355
- addMessageToSession(data.sessionId, msg);
356
- } else {
357
- addMessageToActive(msg);
358
- }
359
- notifyIncomingMessage("PAILot", data.content ?? "Voice message");
360
- // Only autoplay if this voice note is for the currently viewed session
361
- if (msg.audioUri && canAutoplay() && isForActive) {
362
- playAudio(msg.audioUri).catch(() => {});
363
- }
364
- break;
365
- }
366
- case "image": {
367
- setLatestScreenshot(data.imageBase64);
368
- const msg: Message = {
369
- id: generateId(),
370
- role: "assistant",
371
- type: "image",
372
- content: data.caption ?? "Screenshot",
373
- imageBase64: data.imageBase64,
374
- timestamp: Date.now(),
375
- status: "sent",
376
- };
377
- if (data.sessionId) {
378
- addMessageToSession(data.sessionId, msg);
379
- } else {
380
- addMessageToActive(msg);
381
- }
382
- notifyIncomingMessage("PAILot", data.caption ?? "New image");
383
- break;
384
- }
385
- case "sessions": {
386
- const incoming = data.sessions as WsSession[];
387
- setSessions(incoming);
388
- syncActiveFromSessions(incoming);
389
- needsSync.current = false;
390
- break;
391
- }
392
- case "session_switched": {
393
- // Just refresh session list — no system message needed
394
- sendCommand("sessions");
395
- break;
396
- }
397
- case "session_renamed": {
398
- // Just refresh session list — no system message needed
399
- sendCommand("sessions");
400
- break;
401
- }
402
- case "transcript": {
403
- // Voice → text reflection: replace voice bubble with transcribed text
404
- updateMessageContent(data.messageId, data.content);
405
- break;
406
- }
407
- case "typing": {
408
- const typingSession = (data.sessionId as string) || activeSessionIdRef.current || "_global";
409
- typingMapRef.current[typingSession] = !!data.typing;
410
- // Only show typing indicator if it's for the active session
411
- const activeTyping = typingMapRef.current[activeSessionIdRef.current ?? ""] ?? false;
412
- setIsTyping(activeTyping);
413
- break;
414
- }
415
- case "status": {
416
- // Connection status update — ignore for now
417
- break;
418
- }
419
- case "projects": {
420
- setProjects(data.projects ?? []);
421
- break;
422
- }
423
- case "error": {
424
- const msg: Message = {
425
- id: generateId(),
426
- role: "system",
427
- type: "text",
428
- content: data.message,
429
- timestamp: Date.now(),
430
- };
431
- addMessageToActive(msg);
432
- break;
433
- }
499
+ return;
434500 }
501
+ // Live message — process normally
502
+ await processIncoming(data);
435503 };
436504
437505 return () => {
438506 onMessageReceived.current = null;
439507 };
440
- }, [onMessageReceived, sendCommand, addMessageToActive, updateMessageContent, syncActiveFromSessions]);
508
+ }, [onMessageReceived, processIncoming]);
441509
442510 const sendTextMessage = useCallback(
443511 (text: string) => {
....@@ -532,6 +600,13 @@
532600 (sessionId: string) => {
533601 // messagesMapRef is already kept in sync by all mutators — no need to save here
534602 sendCommand("switch", { sessionId });
603
+ // Clear the server-pushed unread indicator immediately on user intent
604
+ setUnreadSessions((prev) => {
605
+ if (!prev.has(sessionId)) return prev;
606
+ const next = new Set(prev);
607
+ next.delete(sessionId);
608
+ return next;
609
+ });
535610 },
536611 [sendCommand]
537612 );
....@@ -552,6 +627,12 @@
552627 if (!u[sessionId]) return u;
553628 const next = { ...u };
554629 delete next[sessionId];
630
+ return next;
631
+ });
632
+ setUnreadSessions((prev) => {
633
+ if (!prev.has(sessionId)) return prev;
634
+ const next = new Set(prev);
635
+ next.delete(sessionId);
555636 return next;
556637 });
557638 },
....@@ -622,6 +703,7 @@
622703 loadMoreMessages,
623704 hasMoreMessages,
624705 unreadCounts,
706
+ unreadSessions,
625707 incomingToast,
626708 dismissToast,
627709 latestScreenshot,
types/index.ts
....@@ -137,6 +137,17 @@
137137 projects: PaiProject[];
138138 }
139139
140
+export interface WsIncomingUnread {
141
+ type: "unread";
142
+ sessionId: string;
143
+}
144
+
145
+export interface WsIncomingCatchUp {
146
+ type: "catch_up";
147
+ messages: Record<string, unknown>[];
148
+ serverSeq: number;
149
+}
150
+
140151 export type WsIncoming =
141152 | WsIncomingText
142153 | WsIncomingVoice
....@@ -148,4 +159,6 @@
148159 | WsIncomingTyping
149160 | WsIncomingError
150161 | WsIncomingStatus
151
- | WsIncomingProjects;
162
+ | WsIncomingProjects
163
+ | WsIncomingUnread
164
+ | WsIncomingCatchUp;