@thrillee/aegischat 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,14 @@
2
2
  // AegisChat React SDK - useChat Hook
3
3
  // ============================================================================
4
4
 
5
- import { useCallback, useEffect, useRef, useState } from 'react';
6
- import { chatApi, channelsApi, messagesApi, filesApi, configureApiClient } from '../services/api';
5
+ import { useCallback, useEffect, useRef, useState } from "react";
6
+ import {
7
+ chatApi,
8
+ channelsApi,
9
+ messagesApi,
10
+ filesApi,
11
+ configureApiClient,
12
+ } from "../services/api";
7
13
  import type {
8
14
  AegisConfig,
9
15
  ChatSession,
@@ -15,20 +21,20 @@ import type {
15
21
  FileAttachment,
16
22
  UploadProgress,
17
23
  MessageSummary,
18
- } from '../types';
24
+ } from "../types";
19
25
 
20
26
  const TYPING_TIMEOUT = 3000;
21
27
  const RECONNECT_INTERVAL = 3000;
22
28
  const MAX_RECONNECT_ATTEMPTS = 5;
23
29
  const MAX_RECONNECT_DELAY = 30000;
24
30
  const PING_INTERVAL = 30000;
25
- const SESSION_STORAGE_KEY = '@aegischat/activeChannel';
31
+ const SESSION_STORAGE_KEY = "@aegischat/activeChannel";
26
32
 
27
33
  export interface UseChatOptions {
28
34
  config?: AegisConfig;
29
- role: 'lawyer' | 'client';
35
+ role?: "lawyer" | "client";
30
36
  clientId?: string;
31
-
37
+
32
38
  initialSession?: ChatSession | null;
33
39
  autoConnect?: boolean;
34
40
  onMessage?: (message: Message) => void;
@@ -51,8 +57,23 @@ export interface UseChatReturn {
51
57
  connect: () => Promise<void>;
52
58
  disconnect: () => void;
53
59
  selectChannel: (channelId: string) => void;
54
- sendMessage: (content: string, options?: { type?: string; parent_id?: string; metadata?: Record<string, unknown> }) => Promise<void>;
55
- sendMessageWithFiles: (content: string, files: File[], options?: { type?: string; parent_id?: string; metadata?: Record<string, unknown> }) => Promise<void>;
60
+ sendMessage: (
61
+ content: string,
62
+ options?: {
63
+ type?: string;
64
+ parent_id?: string;
65
+ metadata?: Record<string, unknown>;
66
+ },
67
+ ) => Promise<void>;
68
+ sendMessageWithFiles: (
69
+ content: string,
70
+ files: File[],
71
+ options?: {
72
+ type?: string;
73
+ parent_id?: string;
74
+ metadata?: Record<string, unknown>;
75
+ },
76
+ ) => Promise<void>;
56
77
  uploadFile: (file: File) => Promise<FileAttachment | null>;
57
78
  loadMoreMessages: () => Promise<void>;
58
79
  startTyping: () => void;
@@ -62,18 +83,32 @@ export interface UseChatReturn {
62
83
  retryMessage: (tempId: string) => Promise<void>;
63
84
  deleteFailedMessage: (tempId: string) => void;
64
85
  markAsRead: (channelId: string) => Promise<void>;
86
+ setup: (options: UseChatOptions) => void;
65
87
  }
66
88
 
67
- export function useChat(options: UseChatOptions): UseChatReturn {
68
- const { config, role, clientId, initialSession, autoConnect = true, onMessage, onTyping, onConnectionChange } = options;
69
-
70
- const [session, setSession] = useState<ChatSession | null>(initialSession ?? null);
89
+ export function useChat(options: Partial<UseChatOptions> = {}): UseChatReturn {
90
+ const {
91
+ config,
92
+ role,
93
+ clientId,
94
+ initialSession,
95
+ autoConnect = true,
96
+ onMessage,
97
+ onTyping,
98
+ onConnectionChange,
99
+ } = options;
100
+
101
+ const [session, setSession] = useState<ChatSession | null>(null);
71
102
  const [isConnected, setIsConnected] = useState(false);
72
103
  const [isConnecting, setIsConnecting] = useState(false);
73
- const [activeChannelId, setActiveChannelIdState] = useState<string | null>(null);
104
+ const [activeChannelId, setActiveChannelIdState] = useState<string | null>(
105
+ null,
106
+ );
74
107
  const [channels, setChannels] = useState<ChannelListItem[]>([]);
75
108
  const [messages, setMessages] = useState<Message[]>([]);
76
- const [typingUsers, setTypingUsers] = useState<Record<string, TypingUser[]>>({});
109
+ const [typingUsers, setTypingUsers] = useState<Record<string, TypingUser[]>>(
110
+ {},
111
+ );
77
112
  const [isLoadingChannels, setIsLoadingChannels] = useState(false);
78
113
  const [isLoadingMessages, setIsLoadingMessages] = useState(false);
79
114
  const [hasMoreMessages, setHasMoreMessages] = useState(true);
@@ -87,34 +122,30 @@ export function useChat(options: UseChatOptions): UseChatReturn {
87
122
  const isManualDisconnect = useRef(false);
88
123
  const oldestMessageId = useRef<string | null>(null);
89
124
  const activeChannelIdRef = useRef<string | null>(null);
90
- const configRef = useRef(config);
91
- const sessionRef = useRef<ChatSession | null>(initialSession ?? null);
92
-
93
- // Configure API client when initialSession is provided (so channelsApi/messagesApi/filesApi use correct baseUrl)
94
- if (initialSession && !config) {
95
- configureApiClient({
96
- baseUrl: initialSession.api_url,
97
- getAccessToken: async () => sessionRef.current?.access_token || "",
98
- });
99
- }
125
+ const sessionRef = useRef<ChatSession | null>(null);
126
+ const roleRef = useRef<string | undefined>(undefined);
127
+ const clientIdRef = useRef<string | undefined>(undefined);
128
+ const autoConnectRef = useRef(true);
129
+ const onMessageRef = useRef<(message: Message) => void | undefined>(undefined);
130
+ const onTypingRef = useRef<((channelId: string, user: TypingUser) => void) | undefined>(undefined);
131
+ const onConnectionChangeRef = useRef<((connected: boolean) => void) | undefined>(undefined);
100
132
 
101
133
  useEffect(() => {
102
- configRef.current = config;
103
- }, [config]);
134
+ activeChannelIdRef.current = activeChannelId;
135
+ }, [activeChannelId]);
104
136
 
105
137
  useEffect(() => {
106
138
  activeChannelIdRef.current = activeChannelId;
107
139
  }, [activeChannelId]);
108
140
 
109
-
110
141
  const getActiveChannelId = useCallback((): string | null => {
111
- if (typeof window === 'undefined') return null;
142
+ if (typeof window === "undefined") return null;
112
143
  return sessionStorage.getItem(SESSION_STORAGE_KEY);
113
144
  }, []);
114
145
 
115
146
  const setActiveChannelId = useCallback((id: string | null) => {
116
147
  setActiveChannelIdState(id);
117
- if (typeof window !== 'undefined') {
148
+ if (typeof window !== "undefined") {
118
149
  if (id) {
119
150
  sessionStorage.setItem(SESSION_STORAGE_KEY, id);
120
151
  } else {
@@ -123,29 +154,32 @@ export function useChat(options: UseChatOptions): UseChatReturn {
123
154
  }
124
155
  }, []);
125
156
 
126
- const fetchFromComms = useCallback(async <T>(path: string, fetchOptions: RequestInit = {}): Promise<T> => {
127
- const currentSession = sessionRef.current;
128
- if (!currentSession) {
129
- throw new Error('Chat session not initialized');
130
- }
157
+ const fetchFromComms = useCallback(
158
+ async <T>(path: string, fetchOptions: RequestInit = {}): Promise<T> => {
159
+ const currentSession = sessionRef.current;
160
+ if (!currentSession) {
161
+ throw new Error("Chat session not initialized");
162
+ }
131
163
 
132
- const response = await fetch(`${currentSession.api_url}${path}`, {
133
- ...fetchOptions,
134
- headers: {
135
- 'Content-Type': 'application/json',
136
- Authorization: `Bearer ${currentSession.access_token}`,
137
- ...fetchOptions.headers,
138
- },
139
- });
140
-
141
- if (!response.ok) {
142
- const error = await response.json().catch(() => ({}));
143
- throw new Error(error.message || `HTTP ${response.status}`);
144
- }
164
+ const response = await fetch(`${currentSession.api_url}${path}`, {
165
+ ...fetchOptions,
166
+ headers: {
167
+ "Content-Type": "application/json",
168
+ Authorization: `Bearer ${currentSession.access_token}`,
169
+ ...fetchOptions.headers,
170
+ },
171
+ });
145
172
 
146
- const data = await response.json();
147
- return data.data || data;
148
- }, []);
173
+ if (!response.ok) {
174
+ const error = await response.json().catch(() => ({}));
175
+ throw new Error(error.message || `HTTP ${response.status}`);
176
+ }
177
+
178
+ const data = await response.json();
179
+ return data.data || data;
180
+ },
181
+ [],
182
+ );
149
183
 
150
184
  const clearTimers = useCallback(() => {
151
185
  if (reconnectTimeout.current) {
@@ -158,119 +192,172 @@ export function useChat(options: UseChatOptions): UseChatReturn {
158
192
  }
159
193
  }, []);
160
194
 
161
- const handleWebSocketMessage = useCallback((data: { type: string; payload: unknown }) => {
162
- const currentActiveChannelId = activeChannelIdRef.current;
163
- console.log('[AegisChat] WebSocket message received:', data.type, data);
164
-
165
- switch (data.type) {
166
- case 'message.new': {
167
- const newMessage = data.payload as Message;
168
- if (newMessage.channel_id === currentActiveChannelId) {
169
- setMessages((prev) => {
170
- const existingIndex = prev.findIndex(
171
- (m) => m.tempId && m.content === newMessage.content && m.status === 'sending'
195
+ const handleWebSocketMessage = useCallback(
196
+ (data: { type: string; payload: unknown }) => {
197
+ const currentActiveChannelId = activeChannelIdRef.current;
198
+ console.log("[AegisChat] WebSocket message received:", data.type, data);
199
+
200
+ switch (data.type) {
201
+ case "message.new": {
202
+ const newMessage = data.payload as Message;
203
+ if (newMessage.channel_id === currentActiveChannelId) {
204
+ setMessages((prev) => {
205
+ const existingIndex = prev.findIndex(
206
+ (m) =>
207
+ m.tempId &&
208
+ m.content === newMessage.content &&
209
+ m.status === "sending",
210
+ );
211
+ if (existingIndex !== -1) {
212
+ const updated = [...prev];
213
+ updated[existingIndex] = { ...newMessage, status: "sent" };
214
+ return updated;
215
+ }
216
+ if (prev.some((m) => m.id === newMessage.id)) return prev;
217
+ return [...prev, { ...newMessage, status: "delivered" }];
218
+ });
219
+ onMessageRef.current?.(newMessage);
220
+ }
221
+ setChannels((prev) => {
222
+ const updated = prev.map((ch) =>
223
+ ch.id === newMessage.channel_id
224
+ ? {
225
+ ...ch,
226
+ last_message: {
227
+ id: newMessage.id,
228
+ content: newMessage.content,
229
+ created_at: newMessage.created_at,
230
+ sender: {
231
+ id: newMessage.sender_id,
232
+ display_name: "Unknown",
233
+ status: "online" as const,
234
+ },
235
+ } as MessageSummary,
236
+ unread_count:
237
+ ch.id === currentActiveChannelId
238
+ ? 0
239
+ : ch.unread_count + 1,
240
+ }
241
+ : ch,
172
242
  );
173
- if (existingIndex !== -1) {
174
- const updated = [...prev];
175
- updated[existingIndex] = { ...newMessage, status: 'sent' };
176
- return updated;
177
- }
178
- if (prev.some((m) => m.id === newMessage.id)) return prev;
179
- return [...prev, { ...newMessage, status: 'delivered' }];
243
+ return updated.sort((a, b) => {
244
+ const timeA = a.last_message?.created_at || "";
245
+ const timeB = b.last_message?.created_at || "";
246
+ return timeB.localeCompare(timeA);
247
+ });
180
248
  });
181
- onMessage?.(newMessage);
249
+ break;
182
250
  }
183
- setChannels((prev) => {
184
- const updated = prev.map((ch) =>
185
- ch.id === newMessage.channel_id
186
- ? {
187
- ...ch,
188
- last_message: {
189
- id: newMessage.id,
190
- content: newMessage.content,
191
- created_at: newMessage.created_at,
192
- sender: { id: newMessage.sender_id, display_name: 'Unknown', status: 'online' as const },
193
- } as MessageSummary,
194
- unread_count: ch.id === currentActiveChannelId ? 0 : ch.unread_count + 1,
195
- }
196
- : ch
197
- );
198
- return updated.sort((a, b) => {
199
- const timeA = a.last_message?.created_at || '';
200
- const timeB = b.last_message?.created_at || '';
201
- return timeB.localeCompare(timeA);
202
- });
203
- });
204
- break;
205
- }
206
- case 'message.updated': {
207
- const updatedMessage = data.payload as Message;
208
- setMessages((prev) => prev.map((m) => (m.id === updatedMessage.id ? updatedMessage : m)));
209
- break;
210
- }
211
- case 'message.deleted': {
212
- const { message_id } = data.payload as { message_id: string };
213
- setMessages((prev) => prev.map((m) => (m.id === message_id ? { ...m, deleted: true } : m)));
214
- break;
215
- }
216
- case 'message.delivered':
217
- case 'message.read': {
218
- const { message_id, channel_id, status } = data.payload as { message_id: string; channel_id: string; status: string };
219
- if (channel_id === currentActiveChannelId) {
251
+ case "message.updated": {
252
+ const updatedMessage = data.payload as Message;
220
253
  setMessages((prev) =>
221
- prev.map((m) => (m.id === message_id ? { ...m, status: (status as Message['status']) || 'delivered' } : m))
254
+ prev.map((m) => (m.id === updatedMessage.id ? updatedMessage : m)),
222
255
  );
256
+ break;
223
257
  }
224
- break;
225
- }
226
- case 'message.delivered.batch':
227
- case 'message.read.batch': {
228
- const { channel_id } = data.payload as { channel_id: string };
229
- if (channel_id === currentActiveChannelId) {
258
+ case "message.deleted": {
259
+ const { message_id } = data.payload as { message_id: string };
230
260
  setMessages((prev) =>
231
- prev.map((m) => (m.status === 'sent' || m.status === 'delivered' ? { ...m, status: data.type === 'message.delivered.batch' ? 'delivered' : 'read' } : m))
261
+ prev.map((m) =>
262
+ m.id === message_id ? { ...m, deleted: true } : m,
263
+ ),
232
264
  );
265
+ break;
233
266
  }
234
- break;
235
- }
236
- case 'typing.start': {
237
- const { channel_id, user } = data.payload as { channel_id: string; user: UserSummary };
238
- const typingUser: TypingUser = {
239
- id: user.id,
240
- displayName: user.display_name,
241
- avatarUrl: user.avatar_url,
242
- startedAt: Date.now(),
243
- };
244
- setTypingUsers((prev) => ({
245
- ...prev,
246
- [channel_id]: [...(prev[channel_id] || []).filter((u) => u.id !== user.id), typingUser],
247
- }));
248
- onTyping?.(channel_id, typingUser);
249
- break;
250
- }
251
- case 'typing.stop': {
252
- const { channel_id, user_id } = data.payload as { channel_id: string; user_id: string };
253
- setTypingUsers((prev) => ({
254
- ...prev,
255
- [channel_id]: (prev[channel_id] || []).filter((u) => u.id !== user_id),
256
- }));
257
- break;
267
+ case "message.delivered":
268
+ case "message.read": {
269
+ const { message_id, channel_id, status } = data.payload as {
270
+ message_id: string;
271
+ channel_id: string;
272
+ status: string;
273
+ };
274
+ if (channel_id === currentActiveChannelId) {
275
+ setMessages((prev) =>
276
+ prev.map((m) =>
277
+ m.id === message_id
278
+ ? {
279
+ ...m,
280
+ status: (status as Message["status"]) || "delivered",
281
+ }
282
+ : m,
283
+ ),
284
+ );
285
+ }
286
+ break;
287
+ }
288
+ case "message.delivered.batch":
289
+ case "message.read.batch": {
290
+ const { channel_id } = data.payload as { channel_id: string };
291
+ if (channel_id === currentActiveChannelId) {
292
+ setMessages((prev) =>
293
+ prev.map((m) =>
294
+ m.status === "sent" || m.status === "delivered"
295
+ ? {
296
+ ...m,
297
+ status:
298
+ data.type === "message.delivered.batch"
299
+ ? "delivered"
300
+ : "read",
301
+ }
302
+ : m,
303
+ ),
304
+ );
305
+ }
306
+ break;
307
+ }
308
+ case "typing.start": {
309
+ const { channel_id, user } = data.payload as {
310
+ channel_id: string;
311
+ user: UserSummary;
312
+ };
313
+ const typingUser: TypingUser = {
314
+ id: user.id,
315
+ displayName: user.display_name,
316
+ avatarUrl: user.avatar_url,
317
+ startedAt: Date.now(),
318
+ };
319
+ setTypingUsers((prev) => ({
320
+ ...prev,
321
+ [channel_id]: [
322
+ ...(prev[channel_id] || []).filter((u) => u.id !== user.id),
323
+ typingUser,
324
+ ],
325
+ }));
326
+ onTypingRef.current?.(channel_id, typingUser);
327
+ break;
328
+ }
329
+ case "typing.stop": {
330
+ const { channel_id, user_id } = data.payload as {
331
+ channel_id: string;
332
+ user_id: string;
333
+ };
334
+ setTypingUsers((prev) => ({
335
+ ...prev,
336
+ [channel_id]: (prev[channel_id] || []).filter(
337
+ (u) => u.id !== user_id,
338
+ ),
339
+ }));
340
+ break;
341
+ }
342
+ case "pong":
343
+ break;
344
+ default:
345
+ console.log("[AegisChat] Unhandled message type:", data.type);
258
346
  }
259
- case 'pong':
260
- break;
261
- default:
262
- console.log('[AegisChat] Unhandled message type:', data.type);
263
- }
264
- }, [onMessage, onTyping]);
347
+ },
348
+ [],
349
+ );
265
350
 
266
351
  const connectWebSocket = useCallback(() => {
267
352
  const currentSession = sessionRef.current;
268
353
  if (!currentSession?.websocket_url || !currentSession?.access_token) {
269
- console.warn('[AegisChat] Cannot connect WebSocket - missing session or token');
354
+ console.warn(
355
+ "[AegisChat] Cannot connect WebSocket - missing session or token",
356
+ );
270
357
  return;
271
358
  }
272
359
  if (wsRef.current?.readyState === WebSocket.OPEN) {
273
- console.log('[AegisChat] WebSocket already open, skipping connection');
360
+ console.log("[AegisChat] WebSocket already open, skipping connection");
274
361
  return;
275
362
  }
276
363
 
@@ -278,24 +365,29 @@ export function useChat(options: UseChatOptions): UseChatReturn {
278
365
  isManualDisconnect.current = false;
279
366
 
280
367
  const wsUrl = `${currentSession.websocket_url}?token=${currentSession.access_token}`;
281
- console.log('[AegisChat] Creating WebSocket connection to:', wsUrl);
368
+ console.log("[AegisChat] Creating WebSocket connection to:", wsUrl);
282
369
  const ws = new WebSocket(wsUrl);
283
370
 
284
371
  ws.onopen = () => {
285
- console.log('[AegisChat] WebSocket connected');
372
+ console.log("[AegisChat] WebSocket connected");
286
373
  setIsConnected(true);
287
374
  setIsConnecting(false);
288
375
  reconnectAttempts.current = 0;
289
- onConnectionChange?.(true);
376
+ onConnectionChangeRef.current?.(true);
290
377
 
291
378
  pingInterval.current = setInterval(() => {
292
379
  if (ws.readyState === WebSocket.OPEN) {
293
- ws.send(JSON.stringify({ type: 'ping' }));
380
+ ws.send(JSON.stringify({ type: "ping" }));
294
381
  }
295
382
  }, PING_INTERVAL);
296
383
 
297
384
  if (activeChannelIdRef.current) {
298
- ws.send(JSON.stringify({ type: 'channel.join', payload: { channel_id: activeChannelIdRef.current } }));
385
+ ws.send(
386
+ JSON.stringify({
387
+ type: "channel.join",
388
+ payload: { channel_id: activeChannelIdRef.current },
389
+ }),
390
+ );
299
391
  }
300
392
  };
301
393
 
@@ -304,19 +396,25 @@ export function useChat(options: UseChatOptions): UseChatReturn {
304
396
  const data = JSON.parse(event.data);
305
397
  handleWebSocketMessage(data);
306
398
  } catch (error) {
307
- console.error('[AegisChat] Failed to parse WebSocket message:', error);
399
+ console.error("[AegisChat] Failed to parse WebSocket message:", error);
308
400
  }
309
401
  };
310
402
 
311
403
  ws.onclose = () => {
312
- console.log('[AegisChat] WebSocket disconnected');
404
+ console.log("[AegisChat] WebSocket disconnected");
313
405
  setIsConnected(false);
314
406
  setIsConnecting(false);
315
407
  clearTimers();
316
- onConnectionChange?.(false);
317
-
318
- if (!isManualDisconnect.current && reconnectAttempts.current < MAX_RECONNECT_ATTEMPTS) {
319
- const delay = Math.min(RECONNECT_INTERVAL * Math.pow(2, reconnectAttempts.current), MAX_RECONNECT_DELAY);
408
+ onConnectionChangeRef.current?.(false);
409
+
410
+ if (
411
+ !isManualDisconnect.current &&
412
+ reconnectAttempts.current < MAX_RECONNECT_ATTEMPTS
413
+ ) {
414
+ const delay = Math.min(
415
+ RECONNECT_INTERVAL * Math.pow(2, reconnectAttempts.current),
416
+ MAX_RECONNECT_DELAY,
417
+ );
320
418
  console.log(`[AegisChat] Reconnecting in ${delay}ms...`);
321
419
  reconnectTimeout.current = setTimeout(() => {
322
420
  reconnectAttempts.current++;
@@ -326,17 +424,21 @@ export function useChat(options: UseChatOptions): UseChatReturn {
326
424
  };
327
425
 
328
426
  ws.onerror = (error) => {
329
- console.error('[AegisChat] WebSocket error:', error);
427
+ console.error("[AegisChat] WebSocket error:", error);
330
428
  };
331
429
 
332
430
  wsRef.current = ws;
333
- }, [clearTimers, handleWebSocketMessage, onConnectionChange]);
431
+ }, [clearTimers, handleWebSocketMessage]);
334
432
 
335
433
  const connect = useCallback(async () => {
336
- console.log('[AegisChat] connect() called');
337
- const targetSession = sessionRef.current ?? initialSession;
434
+ console.log("[AegisChat] connect() called");
435
+ const targetSession = sessionRef.current;
338
436
  if (!targetSession) {
339
- console.log('[AegisChat] No session available, skipping connect');
437
+ console.log("[AegisChat] No session available, skipping connect");
438
+ return;
439
+ }
440
+ if (!autoConnectRef.current) {
441
+ console.log("[AegisChat] autoConnect is false, skipping connect");
340
442
  return;
341
443
  }
342
444
  connectWebSocket();
@@ -364,239 +466,401 @@ export function useChat(options: UseChatOptions): UseChatReturn {
364
466
  const response = await channelsApi.list({});
365
467
  setChannels(response.data.channels || []);
366
468
  } catch (error) {
367
- console.error('[AegisChat] Failed to fetch channels:', error);
469
+ console.error("[AegisChat] Failed to fetch channels:", error);
368
470
  } finally {
369
471
  setIsLoadingChannels(false);
370
472
  }
371
473
  }, []);
372
474
 
373
- const selectChannel = useCallback(async (channelId: string) => {
374
- const currentActiveChannelId = activeChannelIdRef.current;
375
- setActiveChannelId(channelId);
376
- setMessages([]);
377
- setHasMoreMessages(true);
378
- oldestMessageId.current = null;
379
-
380
- if (wsRef.current?.readyState === WebSocket.OPEN) {
381
- if (currentActiveChannelId) {
382
- wsRef.current.send(JSON.stringify({ type: 'channel.leave', payload: { channel_id: currentActiveChannelId } }));
475
+ const selectChannel = useCallback(
476
+ async (channelId: string) => {
477
+ const currentActiveChannelId = activeChannelIdRef.current;
478
+ setActiveChannelId(channelId);
479
+ setMessages([]);
480
+ setHasMoreMessages(true);
481
+ oldestMessageId.current = null;
482
+
483
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
484
+ if (currentActiveChannelId) {
485
+ wsRef.current.send(
486
+ JSON.stringify({
487
+ type: "channel.leave",
488
+ payload: { channel_id: currentActiveChannelId },
489
+ }),
490
+ );
491
+ }
492
+ wsRef.current.send(
493
+ JSON.stringify({
494
+ type: "channel.join",
495
+ payload: { channel_id: channelId },
496
+ }),
497
+ );
383
498
  }
384
- wsRef.current.send(JSON.stringify({ type: 'channel.join', payload: { channel_id: channelId } }));
385
- }
386
499
 
387
- setIsLoadingMessages(true);
388
- try {
389
- const response = await fetchFromComms<MessagesResponse>(`/channels/${channelId}/messages?limit=50`);
390
- setMessages(response.messages || []);
391
- setHasMoreMessages(response.has_more);
392
- if (response.oldest_id) {
393
- oldestMessageId.current = response.oldest_id;
394
- }
500
+ setIsLoadingMessages(true);
501
+ try {
502
+ const response = await fetchFromComms<MessagesResponse>(
503
+ `/channels/${channelId}/messages?limit=50`,
504
+ );
505
+ setMessages(response.messages || []);
506
+ setHasMoreMessages(response.has_more);
507
+ if (response.oldest_id) {
508
+ oldestMessageId.current = response.oldest_id;
509
+ }
395
510
 
396
- await markAsRead(channelId);
511
+ await markAsRead(channelId);
397
512
 
398
- setChannels((prev) => prev.map((ch) => (ch.id === channelId ? { ...ch, unread_count: 0 } : ch)));
399
- } catch (error) {
400
- console.error('[AegisChat] Failed to load messages:', error);
401
- setMessages([]);
402
- } finally {
403
- setIsLoadingMessages(false);
404
- }
405
- }, [setActiveChannelId, fetchFromComms]);
513
+ setChannels((prev) =>
514
+ prev.map((ch) =>
515
+ ch.id === channelId ? { ...ch, unread_count: 0 } : ch,
516
+ ),
517
+ );
518
+ } catch (error) {
519
+ console.error("[AegisChat] Failed to load messages:", error);
520
+ setMessages([]);
521
+ } finally {
522
+ setIsLoadingMessages(false);
523
+ }
524
+ },
525
+ [setActiveChannelId, fetchFromComms],
526
+ );
406
527
 
407
- const markAsRead = useCallback(async (channelId: string) => {
408
- try {
409
- await fetchFromComms(`/channels/${channelId}/read`, { method: 'POST' });
410
- } catch (error) {
411
- console.error('[AegisChat] Failed to mark as read:', error);
412
- }
413
- }, [fetchFromComms]);
528
+ const markAsRead = useCallback(
529
+ async (channelId: string) => {
530
+ try {
531
+ await fetchFromComms(`/channels/${channelId}/read`, { method: "POST" });
532
+ } catch (error) {
533
+ console.error("[AegisChat] Failed to mark as read:", error);
534
+ }
535
+ },
536
+ [fetchFromComms],
537
+ );
414
538
 
415
539
  const loadMoreMessages = useCallback(async () => {
416
540
  if (!activeChannelId || !hasMoreMessages || isLoadingMessages) return;
417
541
 
418
542
  setIsLoadingMessages(true);
419
543
  try {
420
- const params = oldestMessageId.current ? `?before=${oldestMessageId.current}&limit=50` : '?limit=50';
421
- const response = await fetchFromComms<MessagesResponse>(`/channels/${activeChannelId}/messages${params}`);
544
+ const params = oldestMessageId.current
545
+ ? `?before=${oldestMessageId.current}&limit=50`
546
+ : "?limit=50";
547
+ const response = await fetchFromComms<MessagesResponse>(
548
+ `/channels/${activeChannelId}/messages${params}`,
549
+ );
422
550
  setMessages((prev) => [...(response.messages || []), ...prev]);
423
551
  setHasMoreMessages(response.has_more);
424
552
  if (response.oldest_id) {
425
553
  oldestMessageId.current = response.oldest_id;
426
554
  }
427
555
  } catch (error) {
428
- console.error('[AegisChat] Failed to load more messages:', error);
556
+ console.error("[AegisChat] Failed to load more messages:", error);
429
557
  } finally {
430
558
  setIsLoadingMessages(false);
431
559
  }
432
560
  }, [activeChannelId, hasMoreMessages, isLoadingMessages, fetchFromComms]);
433
561
 
434
- const sendMessage = useCallback(async (
435
- content: string,
436
- msgOptions: { type?: string; parent_id?: string; metadata?: Record<string, unknown> } = {}
437
- ) => {
438
- const currentActiveChannelId = activeChannelIdRef.current;
439
- const currentSession = sessionRef.current;
440
- if (!currentActiveChannelId || !content.trim() || !currentSession) return;
441
-
442
- const tempId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
443
- const trimmedContent = content.trim();
444
-
445
- const optimisticMessage: Message = {
446
- id: tempId,
447
- tempId,
448
- channel_id: currentActiveChannelId,
449
- sender_id: currentSession.comms_user_id,
450
- content: trimmedContent,
451
- type: (msgOptions.type as Message['type']) || 'text',
452
- created_at: new Date().toISOString(),
453
- updated_at: new Date().toISOString(),
454
- status: 'sending',
455
- metadata: msgOptions.metadata || {},
456
- };
457
-
458
- setMessages((prev) => [...prev, optimisticMessage]);
459
-
460
- const now = new Date().toISOString();
461
- setChannels((prev) => {
462
- const updated = prev.map((ch) =>
463
- ch.id === currentActiveChannelId
464
- ? {
465
- ...ch,
466
- last_message: {
467
- id: tempId,
468
- content: trimmedContent,
469
- created_at: now,
470
- sender: { id: currentSession.comms_user_id, display_name: 'You', status: 'online' as const },
471
- },
472
- }
473
- : ch
474
- );
475
- return updated.sort((a, b) => {
476
- const timeA = a.last_message?.created_at || '';
477
- const timeB = b.last_message?.created_at || '';
478
- return timeB.localeCompare(timeA);
479
- });
480
- });
481
-
482
- try {
483
- await fetchFromComms<Message>(`/channels/${currentActiveChannelId}/messages`, {
484
- method: 'POST',
485
- body: JSON.stringify({ content: trimmedContent, type: msgOptions.type || 'text', parent_id: msgOptions.parent_id, metadata: msgOptions.metadata }),
486
- });
487
- } catch (error) {
488
- console.error('[AegisChat] Failed to send message:', error);
489
- setMessages((prev) =>
490
- prev.map((m) =>
491
- m.tempId === tempId ? { ...m, status: 'failed', errorMessage: error instanceof Error ? error.message : 'Failed to send' } : m
492
- )
493
- );
494
- throw error;
495
- }
496
- }, [fetchFromComms]);
497
-
498
- const uploadFile = useCallback(async (file: File): Promise<FileAttachment | null> => {
499
- const currentSession = sessionRef.current;
500
- if (!currentSession) return null;
501
-
502
- const fileId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
503
-
504
- setUploadProgress((prev) => [...prev, { fileId, fileName: file.name, progress: 0, status: 'pending' }]);
505
-
506
- try {
507
- setUploadProgress((prev) => prev.map((p) => p.fileId === fileId ? { ...p, status: 'uploading', progress: 10 } : p));
562
+ const sendMessage = useCallback(
563
+ async (
564
+ content: string,
565
+ msgOptions: {
566
+ type?: string;
567
+ parent_id?: string;
568
+ metadata?: Record<string, unknown>;
569
+ } = {},
570
+ ) => {
571
+ const currentActiveChannelId = activeChannelIdRef.current;
572
+ const currentSession = sessionRef.current;
573
+ if (!currentActiveChannelId || !content.trim() || !currentSession) return;
574
+
575
+ const tempId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
576
+ const trimmedContent = content.trim();
577
+
578
+ const optimisticMessage: Message = {
579
+ id: tempId,
580
+ tempId,
581
+ channel_id: currentActiveChannelId,
582
+ sender_id: currentSession.comms_user_id,
583
+ content: trimmedContent,
584
+ type: (msgOptions.type as Message["type"]) || "text",
585
+ created_at: new Date().toISOString(),
586
+ updated_at: new Date().toISOString(),
587
+ status: "sending",
588
+ metadata: msgOptions.metadata || {},
589
+ };
508
590
 
509
- const uploadUrlResponse = await fetchFromComms<{ upload_url: string; file_id: string; expires_at: string }>('/files/upload-url', {
510
- method: 'POST',
511
- body: JSON.stringify({ file_name: file.name, file_type: file.type || 'application/octet-stream', file_size: file.size }),
591
+ setMessages((prev) => [...prev, optimisticMessage]);
592
+
593
+ const now = new Date().toISOString();
594
+ setChannels((prev) => {
595
+ const updated = prev.map((ch) =>
596
+ ch.id === currentActiveChannelId
597
+ ? {
598
+ ...ch,
599
+ last_message: {
600
+ id: tempId,
601
+ content: trimmedContent,
602
+ created_at: now,
603
+ sender: {
604
+ id: currentSession.comms_user_id,
605
+ display_name: "You",
606
+ status: "online" as const,
607
+ },
608
+ },
609
+ }
610
+ : ch,
611
+ );
612
+ return updated.sort((a, b) => {
613
+ const timeA = a.last_message?.created_at || "";
614
+ const timeB = b.last_message?.created_at || "";
615
+ return timeB.localeCompare(timeA);
616
+ });
512
617
  });
513
618
 
514
- setUploadProgress((prev) => prev.map((p) => p.fileId === fileId ? { ...p, fileId: uploadUrlResponse.file_id, progress: 30 } : p));
619
+ try {
620
+ await fetchFromComms<Message>(
621
+ `/channels/${currentActiveChannelId}/messages`,
622
+ {
623
+ method: "POST",
624
+ body: JSON.stringify({
625
+ content: trimmedContent,
626
+ type: msgOptions.type || "text",
627
+ parent_id: msgOptions.parent_id,
628
+ metadata: msgOptions.metadata,
629
+ }),
630
+ },
631
+ );
632
+ } catch (error) {
633
+ console.error("[AegisChat] Failed to send message:", error);
634
+ setMessages((prev) =>
635
+ prev.map((m) =>
636
+ m.tempId === tempId
637
+ ? {
638
+ ...m,
639
+ status: "failed",
640
+ errorMessage:
641
+ error instanceof Error ? error.message : "Failed to send",
642
+ }
643
+ : m,
644
+ ),
645
+ );
646
+ throw error;
647
+ }
648
+ },
649
+ [fetchFromComms],
650
+ );
515
651
 
516
- const uploadResponse = await fetch(uploadUrlResponse.upload_url, {
517
- method: 'PUT',
518
- body: file,
519
- headers: { 'Content-Type': file.type || 'application/octet-stream' },
520
- });
652
+ const uploadFile = useCallback(
653
+ async (file: File): Promise<FileAttachment | null> => {
654
+ const currentSession = sessionRef.current;
655
+ if (!currentSession) return null;
521
656
 
522
- if (!uploadResponse.ok) throw new Error(`Upload failed: ${uploadResponse.statusText}`);
657
+ const fileId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
523
658
 
524
- setUploadProgress((prev) => prev.map((p) => p.fileId === uploadUrlResponse.file_id ? { ...p, status: 'confirming', progress: 70 } : p));
659
+ setUploadProgress((prev) => [
660
+ ...prev,
661
+ { fileId, fileName: file.name, progress: 0, status: "pending" },
662
+ ]);
525
663
 
526
- const confirmResponse = await fetchFromComms<{ file: FileAttachment }>('/files', {
527
- method: 'POST',
528
- body: JSON.stringify({ file_id: uploadUrlResponse.file_id }),
529
- });
664
+ try {
665
+ setUploadProgress((prev) =>
666
+ prev.map((p) =>
667
+ p.fileId === fileId
668
+ ? { ...p, status: "uploading", progress: 10 }
669
+ : p,
670
+ ),
671
+ );
672
+
673
+ const uploadUrlResponse = await fetchFromComms<{
674
+ upload_url: string;
675
+ file_id: string;
676
+ expires_at: string;
677
+ }>("/files/upload-url", {
678
+ method: "POST",
679
+ body: JSON.stringify({
680
+ file_name: file.name,
681
+ file_type: file.type || "application/octet-stream",
682
+ file_size: file.size,
683
+ }),
684
+ });
530
685
 
531
- setUploadProgress((prev) => prev.map((p) => p.fileId === uploadUrlResponse.file_id ? { ...p, status: 'complete', progress: 100 } : p));
532
- setTimeout(() => setUploadProgress((prev) => prev.filter((p) => p.fileId !== uploadUrlResponse.file_id)), 2000);
686
+ setUploadProgress((prev) =>
687
+ prev.map((p) =>
688
+ p.fileId === fileId
689
+ ? { ...p, fileId: uploadUrlResponse.file_id, progress: 30 }
690
+ : p,
691
+ ),
692
+ );
693
+
694
+ const uploadResponse = await fetch(uploadUrlResponse.upload_url, {
695
+ method: "PUT",
696
+ body: file,
697
+ headers: { "Content-Type": file.type || "application/octet-stream" },
698
+ });
533
699
 
534
- return confirmResponse.file;
535
- } catch (error) {
536
- console.error('[AegisChat] Failed to upload file:', error);
537
- setUploadProgress((prev) => prev.map((p) => p.fileId === fileId ? { ...p, status: 'error', error: error instanceof Error ? error.message : 'Upload failed' } : p));
538
- setTimeout(() => setUploadProgress((prev) => prev.filter((p) => p.fileId !== fileId)), 5000);
539
- return null;
540
- }
541
- }, [fetchFromComms]);
700
+ if (!uploadResponse.ok)
701
+ throw new Error(`Upload failed: ${uploadResponse.statusText}`);
702
+
703
+ setUploadProgress((prev) =>
704
+ prev.map((p) =>
705
+ p.fileId === uploadUrlResponse.file_id
706
+ ? { ...p, status: "confirming", progress: 70 }
707
+ : p,
708
+ ),
709
+ );
710
+
711
+ const confirmResponse = await fetchFromComms<{ file: FileAttachment }>(
712
+ "/files",
713
+ {
714
+ method: "POST",
715
+ body: JSON.stringify({ file_id: uploadUrlResponse.file_id }),
716
+ },
717
+ );
718
+
719
+ setUploadProgress((prev) =>
720
+ prev.map((p) =>
721
+ p.fileId === uploadUrlResponse.file_id
722
+ ? { ...p, status: "complete", progress: 100 }
723
+ : p,
724
+ ),
725
+ );
726
+ setTimeout(
727
+ () =>
728
+ setUploadProgress((prev) =>
729
+ prev.filter((p) => p.fileId !== uploadUrlResponse.file_id),
730
+ ),
731
+ 2000,
732
+ );
733
+
734
+ return confirmResponse.file;
735
+ } catch (error) {
736
+ console.error("[AegisChat] Failed to upload file:", error);
737
+ setUploadProgress((prev) =>
738
+ prev.map((p) =>
739
+ p.fileId === fileId
740
+ ? {
741
+ ...p,
742
+ status: "error",
743
+ error:
744
+ error instanceof Error ? error.message : "Upload failed",
745
+ }
746
+ : p,
747
+ ),
748
+ );
749
+ setTimeout(
750
+ () =>
751
+ setUploadProgress((prev) =>
752
+ prev.filter((p) => p.fileId !== fileId),
753
+ ),
754
+ 5000,
755
+ );
756
+ return null;
757
+ }
758
+ },
759
+ [fetchFromComms],
760
+ );
761
+
762
+ const sendMessageWithFiles = useCallback(
763
+ async (
764
+ content: string,
765
+ files: File[],
766
+ msgOptions: {
767
+ type?: string;
768
+ parent_id?: string;
769
+ metadata?: Record<string, unknown>;
770
+ } = {},
771
+ ) => {
772
+ const currentActiveChannelId = activeChannelIdRef.current;
773
+ const currentSession = sessionRef.current;
774
+ if (
775
+ !currentActiveChannelId ||
776
+ (!content.trim() && files.length === 0) ||
777
+ !currentSession
778
+ )
779
+ return;
780
+
781
+ const tempId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
782
+ const trimmedContent = content.trim();
783
+
784
+ const optimisticMessage: Message = {
785
+ id: tempId,
786
+ tempId,
787
+ channel_id: currentActiveChannelId,
788
+ sender_id: currentSession.comms_user_id,
789
+ content: trimmedContent || `Uploading ${files.length} file(s)...`,
790
+ type: "file",
791
+ created_at: new Date().toISOString(),
792
+ updated_at: new Date().toISOString(),
793
+ status: "sending",
794
+ metadata: {
795
+ ...msgOptions.metadata,
796
+ files: files.map((f) => ({
797
+ id: `temp-${f.name}`,
798
+ filename: f.name,
799
+ mime_type: f.type,
800
+ size: f.size,
801
+ url: "",
802
+ })),
803
+ },
804
+ };
542
805
 
543
- const sendMessageWithFiles = useCallback(async (
544
- content: string,
545
- files: File[],
546
- msgOptions: { type?: string; parent_id?: string; metadata?: Record<string, unknown> } = {}
547
- ) => {
548
- const currentActiveChannelId = activeChannelIdRef.current;
549
- const currentSession = sessionRef.current;
550
- if (!currentActiveChannelId || (!content.trim() && files.length === 0) || !currentSession) return;
551
-
552
- const tempId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
553
- const trimmedContent = content.trim();
554
-
555
- const optimisticMessage: Message = {
556
- id: tempId,
557
- tempId,
558
- channel_id: currentActiveChannelId,
559
- sender_id: currentSession.comms_user_id,
560
- content: trimmedContent || `Uploading ${files.length} file(s)...`,
561
- type: 'file',
562
- created_at: new Date().toISOString(),
563
- updated_at: new Date().toISOString(),
564
- status: 'sending',
565
- metadata: { ...msgOptions.metadata, files: files.map((f) => ({ id: `temp-${f.name}`, filename: f.name, mime_type: f.type, size: f.size, url: '' })) },
566
- };
806
+ setMessages((prev) => [...prev, optimisticMessage]);
567
807
 
568
- setMessages((prev) => [...prev, optimisticMessage]);
808
+ try {
809
+ const uploadedFiles: FileAttachment[] = [];
810
+ for (const file of files) {
811
+ const attachment = await uploadFile(file);
812
+ if (attachment) uploadedFiles.push(attachment);
813
+ }
569
814
 
570
- try {
571
- const uploadedFiles: FileAttachment[] = [];
572
- for (const file of files) {
573
- const attachment = await uploadFile(file);
574
- if (attachment) uploadedFiles.push(attachment);
815
+ const messageType =
816
+ uploadedFiles.length > 0 && !trimmedContent ? "file" : "text";
817
+
818
+ await fetchFromComms<Message>(
819
+ `/channels/${currentActiveChannelId}/messages`,
820
+ {
821
+ method: "POST",
822
+ body: JSON.stringify({
823
+ content:
824
+ trimmedContent ||
825
+ (uploadedFiles.length > 0
826
+ ? `Shared ${uploadedFiles.length} file(s)`
827
+ : ""),
828
+ type: msgOptions.type || messageType,
829
+ parent_id: msgOptions.parent_id,
830
+ metadata: { ...msgOptions.metadata, files: uploadedFiles },
831
+ file_ids: uploadedFiles.map((f) => f.id),
832
+ }),
833
+ },
834
+ );
835
+ } catch (error) {
836
+ console.error("[AegisChat] Failed to send message with files:", error);
837
+ setMessages((prev) =>
838
+ prev.map((m) =>
839
+ m.tempId === tempId
840
+ ? {
841
+ ...m,
842
+ status: "failed",
843
+ errorMessage:
844
+ error instanceof Error ? error.message : "Failed to send",
845
+ }
846
+ : m,
847
+ ),
848
+ );
849
+ throw error;
575
850
  }
576
-
577
- const messageType = uploadedFiles.length > 0 && !trimmedContent ? 'file' : 'text';
578
-
579
- await fetchFromComms<Message>(`/channels/${currentActiveChannelId}/messages`, {
580
- method: 'POST',
581
- body: JSON.stringify({
582
- content: trimmedContent || (uploadedFiles.length > 0 ? `Shared ${uploadedFiles.length} file(s)` : ''),
583
- type: msgOptions.type || messageType,
584
- parent_id: msgOptions.parent_id,
585
- metadata: { ...msgOptions.metadata, files: uploadedFiles },
586
- file_ids: uploadedFiles.map((f) => f.id),
587
- }),
588
- });
589
- } catch (error) {
590
- console.error('[AegisChat] Failed to send message with files:', error);
591
- setMessages((prev) => prev.map((m) => m.tempId === tempId ? { ...m, status: 'failed', errorMessage: error instanceof Error ? error.message : 'Failed to send' } : m));
592
- throw error;
593
- }
594
- }, [fetchFromComms, uploadFile]);
851
+ },
852
+ [fetchFromComms, uploadFile],
853
+ );
595
854
 
596
855
  const stopTyping = useCallback(() => {
597
856
  const currentActiveChannelId = activeChannelIdRef.current;
598
857
  if (!currentActiveChannelId || !wsRef.current) return;
599
- wsRef.current.send(JSON.stringify({ type: 'typing.stop', payload: { channel_id: currentActiveChannelId } }));
858
+ wsRef.current.send(
859
+ JSON.stringify({
860
+ type: "typing.stop",
861
+ payload: { channel_id: currentActiveChannelId },
862
+ }),
863
+ );
600
864
  if (typingTimeout.current) {
601
865
  clearTimeout(typingTimeout.current);
602
866
  typingTimeout.current = null;
@@ -606,55 +870,122 @@ export function useChat(options: UseChatOptions): UseChatReturn {
606
870
  const startTyping = useCallback(() => {
607
871
  const currentActiveChannelId = activeChannelIdRef.current;
608
872
  if (!currentActiveChannelId || !wsRef.current) return;
609
- wsRef.current.send(JSON.stringify({ type: 'typing.start', payload: { channel_id: currentActiveChannelId } }));
873
+ wsRef.current.send(
874
+ JSON.stringify({
875
+ type: "typing.start",
876
+ payload: { channel_id: currentActiveChannelId },
877
+ }),
878
+ );
610
879
  if (typingTimeout.current) clearTimeout(typingTimeout.current);
611
880
  typingTimeout.current = setTimeout(stopTyping, TYPING_TIMEOUT);
612
881
  }, [stopTyping]);
613
882
 
614
- const createDMWithUser = useCallback(async (userId: string): Promise<string | null> => {
615
- try {
616
- const channel = await fetchFromComms<{ id: string }>('/channels/dm', {
617
- method: 'POST',
618
- body: JSON.stringify({ user_id: userId }),
619
- });
620
- await refreshChannels();
621
- return channel.id;
622
- } catch (error) {
623
- console.error('[AegisChat] Failed to create DM:', error);
624
- return null;
625
- }
626
- }, [fetchFromComms, refreshChannels]);
627
-
628
- const retryMessage = useCallback(async (tempId: string) => {
629
- const failedMessage = messages.find((m) => m.tempId === tempId && m.status === 'failed');
630
- const currentActiveChannelId = activeChannelIdRef.current;
631
- if (!failedMessage || !currentActiveChannelId) return;
883
+ const createDMWithUser = useCallback(
884
+ async (userId: string): Promise<string | null> => {
885
+ try {
886
+ const channel = await fetchFromComms<{ id: string }>("/channels/dm", {
887
+ method: "POST",
888
+ body: JSON.stringify({ user_id: userId }),
889
+ });
890
+ await refreshChannels();
891
+ return channel.id;
892
+ } catch (error) {
893
+ console.error("[AegisChat] Failed to create DM:", error);
894
+ return null;
895
+ }
896
+ },
897
+ [fetchFromComms, refreshChannels],
898
+ );
899
+
900
+ const retryMessage = useCallback(
901
+ async (tempId: string) => {
902
+ const failedMessage = messages.find(
903
+ (m) => m.tempId === tempId && m.status === "failed",
904
+ );
905
+ const currentActiveChannelId = activeChannelIdRef.current;
906
+ if (!failedMessage || !currentActiveChannelId) return;
632
907
 
633
- setMessages((prev) => prev.map((m) => m.tempId === tempId ? { ...m, status: 'sending', errorMessage: undefined } : m));
908
+ setMessages((prev) =>
909
+ prev.map((m) =>
910
+ m.tempId === tempId
911
+ ? { ...m, status: "sending", errorMessage: undefined }
912
+ : m,
913
+ ),
914
+ );
634
915
 
635
- try {
636
- await fetchFromComms<Message>(`/channels/${currentActiveChannelId}/messages`, {
637
- method: 'POST',
638
- body: JSON.stringify({ content: failedMessage.content, type: failedMessage.type, metadata: failedMessage.metadata }),
639
- });
640
- } catch (error) {
641
- console.error('[AegisChat] Failed to retry message:', error);
642
- setMessages((prev) => prev.map((m) => m.tempId === tempId ? { ...m, status: 'failed', errorMessage: error instanceof Error ? error.message : 'Failed to send' } : m));
643
- }
644
- }, [messages, fetchFromComms]);
916
+ try {
917
+ await fetchFromComms<Message>(
918
+ `/channels/${currentActiveChannelId}/messages`,
919
+ {
920
+ method: "POST",
921
+ body: JSON.stringify({
922
+ content: failedMessage.content,
923
+ type: failedMessage.type,
924
+ metadata: failedMessage.metadata,
925
+ }),
926
+ },
927
+ );
928
+ } catch (error) {
929
+ console.error("[AegisChat] Failed to retry message:", error);
930
+ setMessages((prev) =>
931
+ prev.map((m) =>
932
+ m.tempId === tempId
933
+ ? {
934
+ ...m,
935
+ status: "failed",
936
+ errorMessage:
937
+ error instanceof Error ? error.message : "Failed to send",
938
+ }
939
+ : m,
940
+ ),
941
+ );
942
+ }
943
+ },
944
+ [messages, fetchFromComms],
945
+ );
645
946
 
646
947
  const deleteFailedMessage = useCallback((tempId: string) => {
647
948
  setMessages((prev) => prev.filter((m) => m.tempId !== tempId));
648
949
  }, []);
649
950
 
650
- // Note: connect() is called by the wrapper hook, not automatically.
651
- // This allows the wrapper to fetch the session externally first.
951
+ const setup = useCallback((options: UseChatOptions) => {
952
+ const {
953
+ config,
954
+ role,
955
+ clientId,
956
+ initialSession,
957
+ autoConnect = true,
958
+ onMessage,
959
+ onTyping,
960
+ onConnectionChange,
961
+ } = options;
962
+
963
+ roleRef.current = role;
964
+ clientIdRef.current = clientId;
965
+ autoConnectRef.current = autoConnect;
966
+ onMessageRef.current = onMessage;
967
+ onTypingRef.current = onTyping;
968
+ onConnectionChangeRef.current = onConnectionChange;
969
+
970
+ if (initialSession) {
971
+ sessionRef.current = initialSession;
972
+
973
+ if (!config) {
974
+ configureApiClient({
975
+ baseUrl: initialSession.api_url,
976
+ getAccessToken: async () => sessionRef.current?.access_token || "",
977
+ });
978
+ }
979
+
980
+ setSession(initialSession);
981
+ }
982
+ }, []);
652
983
 
653
984
  useEffect(() => {
654
- if (session && !isConnected && !isConnecting && autoConnect) {
985
+ if (session && !isConnected && !isConnecting && autoConnectRef.current) {
655
986
  connectWebSocket();
656
987
  }
657
- }, [session, isConnected, isConnecting, autoConnect, connectWebSocket]);
988
+ }, [session, isConnected, isConnecting, connectWebSocket]);
658
989
 
659
990
  useEffect(() => {
660
991
  if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
@@ -663,7 +994,10 @@ export function useChat(options: UseChatOptions): UseChatReturn {
663
994
  const data = JSON.parse(event.data);
664
995
  handleWebSocketMessage(data);
665
996
  } catch (error) {
666
- console.error('[AegisChat] Failed to parse WebSocket message:', error);
997
+ console.error(
998
+ "[AegisChat] Failed to parse WebSocket message:",
999
+ error,
1000
+ );
667
1001
  }
668
1002
  };
669
1003
  }
@@ -720,6 +1054,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
720
1054
  retryMessage,
721
1055
  deleteFailedMessage,
722
1056
  markAsRead,
1057
+ setup,
723
1058
  };
724
1059
  }
725
1060