@thrillee/aegischat 0.1.5 → 0.1.7

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,37 +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
- }
100
-
101
- useEffect(() => {
102
- configRef.current = config;
103
- }, [config]);
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);
104
132
 
105
133
  useEffect(() => {
106
134
  activeChannelIdRef.current = activeChannelId;
107
135
  }, [activeChannelId]);
108
136
 
109
137
  useEffect(() => {
110
- sessionRef.current = initialSession ?? null;
111
- }, [initialSession]);
138
+ activeChannelIdRef.current = activeChannelId;
139
+ }, [activeChannelId]);
112
140
 
113
141
  const getActiveChannelId = useCallback((): string | null => {
114
- if (typeof window === 'undefined') return null;
142
+ if (typeof window === "undefined") return null;
115
143
  return sessionStorage.getItem(SESSION_STORAGE_KEY);
116
144
  }, []);
117
145
 
118
146
  const setActiveChannelId = useCallback((id: string | null) => {
119
147
  setActiveChannelIdState(id);
120
- if (typeof window !== 'undefined') {
148
+ if (typeof window !== "undefined") {
121
149
  if (id) {
122
150
  sessionStorage.setItem(SESSION_STORAGE_KEY, id);
123
151
  } else {
@@ -126,29 +154,32 @@ export function useChat(options: UseChatOptions): UseChatReturn {
126
154
  }
127
155
  }, []);
128
156
 
129
- const fetchFromComms = useCallback(async <T>(path: string, fetchOptions: RequestInit = {}): Promise<T> => {
130
- const currentSession = sessionRef.current;
131
- if (!currentSession) {
132
- throw new Error('Chat session not initialized');
133
- }
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
+ }
134
163
 
135
- const response = await fetch(`${currentSession.api_url}${path}`, {
136
- ...fetchOptions,
137
- headers: {
138
- 'Content-Type': 'application/json',
139
- Authorization: `Bearer ${currentSession.access_token}`,
140
- ...fetchOptions.headers,
141
- },
142
- });
143
-
144
- if (!response.ok) {
145
- const error = await response.json().catch(() => ({}));
146
- throw new Error(error.message || `HTTP ${response.status}`);
147
- }
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
+ });
148
172
 
149
- const data = await response.json();
150
- return data.data || data;
151
- }, []);
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
+ );
152
183
 
153
184
  const clearTimers = useCallback(() => {
154
185
  if (reconnectTimeout.current) {
@@ -161,119 +192,172 @@ export function useChat(options: UseChatOptions): UseChatReturn {
161
192
  }
162
193
  }, []);
163
194
 
164
- const handleWebSocketMessage = useCallback((data: { type: string; payload: unknown }) => {
165
- const currentActiveChannelId = activeChannelIdRef.current;
166
- console.log('[AegisChat] WebSocket message received:', data.type, data);
167
-
168
- switch (data.type) {
169
- case 'message.new': {
170
- const newMessage = data.payload as Message;
171
- if (newMessage.channel_id === currentActiveChannelId) {
172
- setMessages((prev) => {
173
- const existingIndex = prev.findIndex(
174
- (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,
175
242
  );
176
- if (existingIndex !== -1) {
177
- const updated = [...prev];
178
- updated[existingIndex] = { ...newMessage, status: 'sent' };
179
- return updated;
180
- }
181
- if (prev.some((m) => m.id === newMessage.id)) return prev;
182
- 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
+ });
183
248
  });
184
- onMessage?.(newMessage);
249
+ break;
185
250
  }
186
- setChannels((prev) => {
187
- const updated = prev.map((ch) =>
188
- ch.id === newMessage.channel_id
189
- ? {
190
- ...ch,
191
- last_message: {
192
- id: newMessage.id,
193
- content: newMessage.content,
194
- created_at: newMessage.created_at,
195
- sender: { id: newMessage.sender_id, display_name: 'Unknown', status: 'online' as const },
196
- } as MessageSummary,
197
- unread_count: ch.id === currentActiveChannelId ? 0 : ch.unread_count + 1,
198
- }
199
- : ch
200
- );
201
- return updated.sort((a, b) => {
202
- const timeA = a.last_message?.created_at || '';
203
- const timeB = b.last_message?.created_at || '';
204
- return timeB.localeCompare(timeA);
205
- });
206
- });
207
- break;
208
- }
209
- case 'message.updated': {
210
- const updatedMessage = data.payload as Message;
211
- setMessages((prev) => prev.map((m) => (m.id === updatedMessage.id ? updatedMessage : m)));
212
- break;
213
- }
214
- case 'message.deleted': {
215
- const { message_id } = data.payload as { message_id: string };
216
- setMessages((prev) => prev.map((m) => (m.id === message_id ? { ...m, deleted: true } : m)));
217
- break;
218
- }
219
- case 'message.delivered':
220
- case 'message.read': {
221
- const { message_id, channel_id, status } = data.payload as { message_id: string; channel_id: string; status: string };
222
- if (channel_id === currentActiveChannelId) {
251
+ case "message.updated": {
252
+ const updatedMessage = data.payload as Message;
223
253
  setMessages((prev) =>
224
- 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)),
225
255
  );
256
+ break;
226
257
  }
227
- break;
228
- }
229
- case 'message.delivered.batch':
230
- case 'message.read.batch': {
231
- const { channel_id } = data.payload as { channel_id: string };
232
- if (channel_id === currentActiveChannelId) {
258
+ case "message.deleted": {
259
+ const { message_id } = data.payload as { message_id: string };
233
260
  setMessages((prev) =>
234
- 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
+ ),
235
264
  );
265
+ break;
236
266
  }
237
- break;
238
- }
239
- case 'typing.start': {
240
- const { channel_id, user } = data.payload as { channel_id: string; user: UserSummary };
241
- const typingUser: TypingUser = {
242
- id: user.id,
243
- displayName: user.display_name,
244
- avatarUrl: user.avatar_url,
245
- startedAt: Date.now(),
246
- };
247
- setTypingUsers((prev) => ({
248
- ...prev,
249
- [channel_id]: [...(prev[channel_id] || []).filter((u) => u.id !== user.id), typingUser],
250
- }));
251
- onTyping?.(channel_id, typingUser);
252
- break;
253
- }
254
- case 'typing.stop': {
255
- const { channel_id, user_id } = data.payload as { channel_id: string; user_id: string };
256
- setTypingUsers((prev) => ({
257
- ...prev,
258
- [channel_id]: (prev[channel_id] || []).filter((u) => u.id !== user_id),
259
- }));
260
- 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);
261
346
  }
262
- case 'pong':
263
- break;
264
- default:
265
- console.log('[AegisChat] Unhandled message type:', data.type);
266
- }
267
- }, [onMessage, onTyping]);
347
+ },
348
+ [],
349
+ );
268
350
 
269
351
  const connectWebSocket = useCallback(() => {
270
352
  const currentSession = sessionRef.current;
271
353
  if (!currentSession?.websocket_url || !currentSession?.access_token) {
272
- console.warn('[AegisChat] Cannot connect WebSocket - missing session or token');
354
+ console.warn(
355
+ "[AegisChat] Cannot connect WebSocket - missing session or token",
356
+ );
273
357
  return;
274
358
  }
275
359
  if (wsRef.current?.readyState === WebSocket.OPEN) {
276
- console.log('[AegisChat] WebSocket already open, skipping connection');
360
+ console.log("[AegisChat] WebSocket already open, skipping connection");
277
361
  return;
278
362
  }
279
363
 
@@ -281,24 +365,29 @@ export function useChat(options: UseChatOptions): UseChatReturn {
281
365
  isManualDisconnect.current = false;
282
366
 
283
367
  const wsUrl = `${currentSession.websocket_url}?token=${currentSession.access_token}`;
284
- console.log('[AegisChat] Creating WebSocket connection to:', wsUrl);
368
+ console.log("[AegisChat] Creating WebSocket connection to:", wsUrl);
285
369
  const ws = new WebSocket(wsUrl);
286
370
 
287
371
  ws.onopen = () => {
288
- console.log('[AegisChat] WebSocket connected');
372
+ console.log("[AegisChat] WebSocket connected");
289
373
  setIsConnected(true);
290
374
  setIsConnecting(false);
291
375
  reconnectAttempts.current = 0;
292
- onConnectionChange?.(true);
376
+ onConnectionChangeRef.current?.(true);
293
377
 
294
378
  pingInterval.current = setInterval(() => {
295
379
  if (ws.readyState === WebSocket.OPEN) {
296
- ws.send(JSON.stringify({ type: 'ping' }));
380
+ ws.send(JSON.stringify({ type: "ping" }));
297
381
  }
298
382
  }, PING_INTERVAL);
299
383
 
300
384
  if (activeChannelIdRef.current) {
301
- 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
+ );
302
391
  }
303
392
  };
304
393
 
@@ -307,19 +396,25 @@ export function useChat(options: UseChatOptions): UseChatReturn {
307
396
  const data = JSON.parse(event.data);
308
397
  handleWebSocketMessage(data);
309
398
  } catch (error) {
310
- console.error('[AegisChat] Failed to parse WebSocket message:', error);
399
+ console.error("[AegisChat] Failed to parse WebSocket message:", error);
311
400
  }
312
401
  };
313
402
 
314
403
  ws.onclose = () => {
315
- console.log('[AegisChat] WebSocket disconnected');
404
+ console.log("[AegisChat] WebSocket disconnected");
316
405
  setIsConnected(false);
317
406
  setIsConnecting(false);
318
407
  clearTimers();
319
- onConnectionChange?.(false);
320
-
321
- if (!isManualDisconnect.current && reconnectAttempts.current < MAX_RECONNECT_ATTEMPTS) {
322
- 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
+ );
323
418
  console.log(`[AegisChat] Reconnecting in ${delay}ms...`);
324
419
  reconnectTimeout.current = setTimeout(() => {
325
420
  reconnectAttempts.current++;
@@ -329,17 +424,21 @@ export function useChat(options: UseChatOptions): UseChatReturn {
329
424
  };
330
425
 
331
426
  ws.onerror = (error) => {
332
- console.error('[AegisChat] WebSocket error:', error);
427
+ console.error("[AegisChat] WebSocket error:", error);
333
428
  };
334
429
 
335
430
  wsRef.current = ws;
336
- }, [clearTimers, handleWebSocketMessage, onConnectionChange]);
431
+ }, [clearTimers, handleWebSocketMessage]);
337
432
 
338
433
  const connect = useCallback(async () => {
339
- console.log('[AegisChat] connect() called');
340
- const targetSession = sessionRef.current ?? initialSession;
434
+ console.log("[AegisChat] connect() called");
435
+ const targetSession = sessionRef.current;
341
436
  if (!targetSession) {
342
- 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");
343
442
  return;
344
443
  }
345
444
  connectWebSocket();
@@ -367,239 +466,401 @@ export function useChat(options: UseChatOptions): UseChatReturn {
367
466
  const response = await channelsApi.list({});
368
467
  setChannels(response.data.channels || []);
369
468
  } catch (error) {
370
- console.error('[AegisChat] Failed to fetch channels:', error);
469
+ console.error("[AegisChat] Failed to fetch channels:", error);
371
470
  } finally {
372
471
  setIsLoadingChannels(false);
373
472
  }
374
473
  }, []);
375
474
 
376
- const selectChannel = useCallback(async (channelId: string) => {
377
- const currentActiveChannelId = activeChannelIdRef.current;
378
- setActiveChannelId(channelId);
379
- setMessages([]);
380
- setHasMoreMessages(true);
381
- oldestMessageId.current = null;
382
-
383
- if (wsRef.current?.readyState === WebSocket.OPEN) {
384
- if (currentActiveChannelId) {
385
- 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
+ );
386
498
  }
387
- wsRef.current.send(JSON.stringify({ type: 'channel.join', payload: { channel_id: channelId } }));
388
- }
389
499
 
390
- setIsLoadingMessages(true);
391
- try {
392
- const response = await fetchFromComms<MessagesResponse>(`/channels/${channelId}/messages?limit=50`);
393
- setMessages(response.messages || []);
394
- setHasMoreMessages(response.has_more);
395
- if (response.oldest_id) {
396
- oldestMessageId.current = response.oldest_id;
397
- }
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
+ }
398
510
 
399
- await markAsRead(channelId);
511
+ await markAsRead(channelId);
400
512
 
401
- setChannels((prev) => prev.map((ch) => (ch.id === channelId ? { ...ch, unread_count: 0 } : ch)));
402
- } catch (error) {
403
- console.error('[AegisChat] Failed to load messages:', error);
404
- setMessages([]);
405
- } finally {
406
- setIsLoadingMessages(false);
407
- }
408
- }, [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
+ );
409
527
 
410
- const markAsRead = useCallback(async (channelId: string) => {
411
- try {
412
- await fetchFromComms(`/channels/${channelId}/read`, { method: 'POST' });
413
- } catch (error) {
414
- console.error('[AegisChat] Failed to mark as read:', error);
415
- }
416
- }, [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
+ );
417
538
 
418
539
  const loadMoreMessages = useCallback(async () => {
419
540
  if (!activeChannelId || !hasMoreMessages || isLoadingMessages) return;
420
541
 
421
542
  setIsLoadingMessages(true);
422
543
  try {
423
- const params = oldestMessageId.current ? `?before=${oldestMessageId.current}&limit=50` : '?limit=50';
424
- 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
+ );
425
550
  setMessages((prev) => [...(response.messages || []), ...prev]);
426
551
  setHasMoreMessages(response.has_more);
427
552
  if (response.oldest_id) {
428
553
  oldestMessageId.current = response.oldest_id;
429
554
  }
430
555
  } catch (error) {
431
- console.error('[AegisChat] Failed to load more messages:', error);
556
+ console.error("[AegisChat] Failed to load more messages:", error);
432
557
  } finally {
433
558
  setIsLoadingMessages(false);
434
559
  }
435
560
  }, [activeChannelId, hasMoreMessages, isLoadingMessages, fetchFromComms]);
436
561
 
437
- const sendMessage = useCallback(async (
438
- content: string,
439
- msgOptions: { type?: string; parent_id?: string; metadata?: Record<string, unknown> } = {}
440
- ) => {
441
- const currentActiveChannelId = activeChannelIdRef.current;
442
- const currentSession = sessionRef.current;
443
- if (!currentActiveChannelId || !content.trim() || !currentSession) return;
444
-
445
- const tempId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
446
- const trimmedContent = content.trim();
447
-
448
- const optimisticMessage: Message = {
449
- id: tempId,
450
- tempId,
451
- channel_id: currentActiveChannelId,
452
- sender_id: currentSession.comms_user_id,
453
- content: trimmedContent,
454
- type: (msgOptions.type as Message['type']) || 'text',
455
- created_at: new Date().toISOString(),
456
- updated_at: new Date().toISOString(),
457
- status: 'sending',
458
- metadata: msgOptions.metadata || {},
459
- };
460
-
461
- setMessages((prev) => [...prev, optimisticMessage]);
462
-
463
- const now = new Date().toISOString();
464
- setChannels((prev) => {
465
- const updated = prev.map((ch) =>
466
- ch.id === currentActiveChannelId
467
- ? {
468
- ...ch,
469
- last_message: {
470
- id: tempId,
471
- content: trimmedContent,
472
- created_at: now,
473
- sender: { id: currentSession.comms_user_id, display_name: 'You', status: 'online' as const },
474
- },
475
- }
476
- : ch
477
- );
478
- return updated.sort((a, b) => {
479
- const timeA = a.last_message?.created_at || '';
480
- const timeB = b.last_message?.created_at || '';
481
- return timeB.localeCompare(timeA);
482
- });
483
- });
484
-
485
- try {
486
- await fetchFromComms<Message>(`/channels/${currentActiveChannelId}/messages`, {
487
- method: 'POST',
488
- body: JSON.stringify({ content: trimmedContent, type: msgOptions.type || 'text', parent_id: msgOptions.parent_id, metadata: msgOptions.metadata }),
489
- });
490
- } catch (error) {
491
- console.error('[AegisChat] Failed to send message:', error);
492
- setMessages((prev) =>
493
- prev.map((m) =>
494
- m.tempId === tempId ? { ...m, status: 'failed', errorMessage: error instanceof Error ? error.message : 'Failed to send' } : m
495
- )
496
- );
497
- throw error;
498
- }
499
- }, [fetchFromComms]);
500
-
501
- const uploadFile = useCallback(async (file: File): Promise<FileAttachment | null> => {
502
- const currentSession = sessionRef.current;
503
- if (!currentSession) return null;
504
-
505
- const fileId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
506
-
507
- setUploadProgress((prev) => [...prev, { fileId, fileName: file.name, progress: 0, status: 'pending' }]);
508
-
509
- try {
510
- 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
+ };
511
590
 
512
- const uploadUrlResponse = await fetchFromComms<{ upload_url: string; file_id: string; expires_at: string }>('/files/upload-url', {
513
- method: 'POST',
514
- 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
+ });
515
617
  });
516
618
 
517
- 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
+ );
518
651
 
519
- const uploadResponse = await fetch(uploadUrlResponse.upload_url, {
520
- method: 'PUT',
521
- body: file,
522
- headers: { 'Content-Type': file.type || 'application/octet-stream' },
523
- });
652
+ const uploadFile = useCallback(
653
+ async (file: File): Promise<FileAttachment | null> => {
654
+ const currentSession = sessionRef.current;
655
+ if (!currentSession) return null;
524
656
 
525
- if (!uploadResponse.ok) throw new Error(`Upload failed: ${uploadResponse.statusText}`);
657
+ const fileId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
526
658
 
527
- 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
+ ]);
528
663
 
529
- const confirmResponse = await fetchFromComms<{ file: FileAttachment }>('/files', {
530
- method: 'POST',
531
- body: JSON.stringify({ file_id: uploadUrlResponse.file_id }),
532
- });
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
+ });
533
685
 
534
- setUploadProgress((prev) => prev.map((p) => p.fileId === uploadUrlResponse.file_id ? { ...p, status: 'complete', progress: 100 } : p));
535
- 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
+ });
536
699
 
537
- return confirmResponse.file;
538
- } catch (error) {
539
- console.error('[AegisChat] Failed to upload file:', error);
540
- setUploadProgress((prev) => prev.map((p) => p.fileId === fileId ? { ...p, status: 'error', error: error instanceof Error ? error.message : 'Upload failed' } : p));
541
- setTimeout(() => setUploadProgress((prev) => prev.filter((p) => p.fileId !== fileId)), 5000);
542
- return null;
543
- }
544
- }, [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
+ };
545
805
 
546
- const sendMessageWithFiles = useCallback(async (
547
- content: string,
548
- files: File[],
549
- msgOptions: { type?: string; parent_id?: string; metadata?: Record<string, unknown> } = {}
550
- ) => {
551
- const currentActiveChannelId = activeChannelIdRef.current;
552
- const currentSession = sessionRef.current;
553
- if (!currentActiveChannelId || (!content.trim() && files.length === 0) || !currentSession) return;
554
-
555
- const tempId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
556
- const trimmedContent = content.trim();
557
-
558
- const optimisticMessage: Message = {
559
- id: tempId,
560
- tempId,
561
- channel_id: currentActiveChannelId,
562
- sender_id: currentSession.comms_user_id,
563
- content: trimmedContent || `Uploading ${files.length} file(s)...`,
564
- type: 'file',
565
- created_at: new Date().toISOString(),
566
- updated_at: new Date().toISOString(),
567
- status: 'sending',
568
- metadata: { ...msgOptions.metadata, files: files.map((f) => ({ id: `temp-${f.name}`, filename: f.name, mime_type: f.type, size: f.size, url: '' })) },
569
- };
806
+ setMessages((prev) => [...prev, optimisticMessage]);
570
807
 
571
- 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
+ }
572
814
 
573
- try {
574
- const uploadedFiles: FileAttachment[] = [];
575
- for (const file of files) {
576
- const attachment = await uploadFile(file);
577
- 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;
578
850
  }
579
-
580
- const messageType = uploadedFiles.length > 0 && !trimmedContent ? 'file' : 'text';
581
-
582
- await fetchFromComms<Message>(`/channels/${currentActiveChannelId}/messages`, {
583
- method: 'POST',
584
- body: JSON.stringify({
585
- content: trimmedContent || (uploadedFiles.length > 0 ? `Shared ${uploadedFiles.length} file(s)` : ''),
586
- type: msgOptions.type || messageType,
587
- parent_id: msgOptions.parent_id,
588
- metadata: { ...msgOptions.metadata, files: uploadedFiles },
589
- file_ids: uploadedFiles.map((f) => f.id),
590
- }),
591
- });
592
- } catch (error) {
593
- console.error('[AegisChat] Failed to send message with files:', error);
594
- setMessages((prev) => prev.map((m) => m.tempId === tempId ? { ...m, status: 'failed', errorMessage: error instanceof Error ? error.message : 'Failed to send' } : m));
595
- throw error;
596
- }
597
- }, [fetchFromComms, uploadFile]);
851
+ },
852
+ [fetchFromComms, uploadFile],
853
+ );
598
854
 
599
855
  const stopTyping = useCallback(() => {
600
856
  const currentActiveChannelId = activeChannelIdRef.current;
601
857
  if (!currentActiveChannelId || !wsRef.current) return;
602
- 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
+ );
603
864
  if (typingTimeout.current) {
604
865
  clearTimeout(typingTimeout.current);
605
866
  typingTimeout.current = null;
@@ -609,55 +870,122 @@ export function useChat(options: UseChatOptions): UseChatReturn {
609
870
  const startTyping = useCallback(() => {
610
871
  const currentActiveChannelId = activeChannelIdRef.current;
611
872
  if (!currentActiveChannelId || !wsRef.current) return;
612
- 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
+ );
613
879
  if (typingTimeout.current) clearTimeout(typingTimeout.current);
614
880
  typingTimeout.current = setTimeout(stopTyping, TYPING_TIMEOUT);
615
881
  }, [stopTyping]);
616
882
 
617
- const createDMWithUser = useCallback(async (userId: string): Promise<string | null> => {
618
- try {
619
- const channel = await fetchFromComms<{ id: string }>('/channels/dm', {
620
- method: 'POST',
621
- body: JSON.stringify({ user_id: userId }),
622
- });
623
- await refreshChannels();
624
- return channel.id;
625
- } catch (error) {
626
- console.error('[AegisChat] Failed to create DM:', error);
627
- return null;
628
- }
629
- }, [fetchFromComms, refreshChannels]);
630
-
631
- const retryMessage = useCallback(async (tempId: string) => {
632
- const failedMessage = messages.find((m) => m.tempId === tempId && m.status === 'failed');
633
- const currentActiveChannelId = activeChannelIdRef.current;
634
- 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;
635
907
 
636
- 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
+ );
637
915
 
638
- try {
639
- await fetchFromComms<Message>(`/channels/${currentActiveChannelId}/messages`, {
640
- method: 'POST',
641
- body: JSON.stringify({ content: failedMessage.content, type: failedMessage.type, metadata: failedMessage.metadata }),
642
- });
643
- } catch (error) {
644
- console.error('[AegisChat] Failed to retry message:', error);
645
- setMessages((prev) => prev.map((m) => m.tempId === tempId ? { ...m, status: 'failed', errorMessage: error instanceof Error ? error.message : 'Failed to send' } : m));
646
- }
647
- }, [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
+ );
648
946
 
649
947
  const deleteFailedMessage = useCallback((tempId: string) => {
650
948
  setMessages((prev) => prev.filter((m) => m.tempId !== tempId));
651
949
  }, []);
652
950
 
653
- // Note: connect() is called by the wrapper hook, not automatically.
654
- // 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.replace(/\/api\/v1\/?$/, ''),
976
+ getAccessToken: async () => sessionRef.current?.access_token || "",
977
+ });
978
+ }
979
+
980
+ setSession(initialSession);
981
+ }
982
+ }, []);
655
983
 
656
984
  useEffect(() => {
657
- if (initialSession && !isConnected && !isConnecting && autoConnect) {
985
+ if (session && !isConnected && !isConnecting && autoConnectRef.current) {
658
986
  connectWebSocket();
659
987
  }
660
- }, [initialSession, session, isConnected, isConnecting, autoConnect, connectWebSocket]);
988
+ }, [session, isConnected, isConnecting, connectWebSocket]);
661
989
 
662
990
  useEffect(() => {
663
991
  if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
@@ -666,7 +994,10 @@ export function useChat(options: UseChatOptions): UseChatReturn {
666
994
  const data = JSON.parse(event.data);
667
995
  handleWebSocketMessage(data);
668
996
  } catch (error) {
669
- console.error('[AegisChat] Failed to parse WebSocket message:', error);
997
+ console.error(
998
+ "[AegisChat] Failed to parse WebSocket message:",
999
+ error,
1000
+ );
670
1001
  }
671
1002
  };
672
1003
  }
@@ -723,6 +1054,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
723
1054
  retryMessage,
724
1055
  deleteFailedMessage,
725
1056
  markAsRead,
1057
+ setup,
726
1058
  };
727
1059
  }
728
1060