@castlekit/castle 0.1.5 → 0.3.0

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.
Files changed (68) hide show
  1. package/drizzle.config.ts +7 -0
  2. package/next.config.ts +1 -0
  3. package/package.json +25 -4
  4. package/src/app/api/avatars/[id]/route.ts +122 -25
  5. package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
  6. package/src/app/api/openclaw/agents/route.ts +77 -41
  7. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  8. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  9. package/src/app/api/openclaw/chat/channels/route.ts +214 -0
  10. package/src/app/api/openclaw/chat/route.ts +272 -0
  11. package/src/app/api/openclaw/chat/search/route.ts +149 -0
  12. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  13. package/src/app/api/openclaw/config/route.ts +45 -4
  14. package/src/app/api/openclaw/events/route.ts +31 -2
  15. package/src/app/api/openclaw/logs/route.ts +20 -5
  16. package/src/app/api/openclaw/restart/route.ts +12 -4
  17. package/src/app/api/openclaw/session/status/route.ts +42 -0
  18. package/src/app/api/settings/avatar/route.ts +190 -0
  19. package/src/app/api/settings/route.ts +88 -0
  20. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  21. package/src/app/chat/[channelId]/page.tsx +305 -0
  22. package/src/app/chat/layout.tsx +96 -0
  23. package/src/app/chat/page.tsx +52 -0
  24. package/src/app/globals.css +89 -2
  25. package/src/app/layout.tsx +7 -1
  26. package/src/app/page.tsx +147 -28
  27. package/src/app/settings/page.tsx +300 -0
  28. package/src/cli/onboarding.ts +202 -37
  29. package/src/components/chat/agent-mention-popup.tsx +89 -0
  30. package/src/components/chat/archived-channels.tsx +190 -0
  31. package/src/components/chat/channel-list.tsx +140 -0
  32. package/src/components/chat/chat-input.tsx +310 -0
  33. package/src/components/chat/create-channel-dialog.tsx +171 -0
  34. package/src/components/chat/markdown-content.tsx +205 -0
  35. package/src/components/chat/message-bubble.tsx +152 -0
  36. package/src/components/chat/message-list.tsx +508 -0
  37. package/src/components/chat/message-queue.tsx +68 -0
  38. package/src/components/chat/session-divider.tsx +61 -0
  39. package/src/components/chat/session-stats-panel.tsx +139 -0
  40. package/src/components/chat/storage-indicator.tsx +76 -0
  41. package/src/components/layout/sidebar.tsx +126 -45
  42. package/src/components/layout/user-menu.tsx +29 -4
  43. package/src/components/providers/presence-provider.tsx +8 -0
  44. package/src/components/providers/search-provider.tsx +81 -0
  45. package/src/components/search/search-dialog.tsx +269 -0
  46. package/src/components/ui/avatar.tsx +11 -9
  47. package/src/components/ui/dialog.tsx +10 -4
  48. package/src/components/ui/tooltip.tsx +25 -8
  49. package/src/components/ui/twemoji-text.tsx +37 -0
  50. package/src/lib/api-security.ts +188 -0
  51. package/src/lib/config.ts +36 -4
  52. package/src/lib/date-utils.ts +79 -0
  53. package/src/lib/db/__tests__/queries.test.ts +318 -0
  54. package/src/lib/db/index.ts +642 -0
  55. package/src/lib/db/queries.ts +1017 -0
  56. package/src/lib/db/schema.ts +160 -0
  57. package/src/lib/device-identity.ts +303 -0
  58. package/src/lib/gateway-connection.ts +273 -36
  59. package/src/lib/hooks/use-agent-status.ts +251 -0
  60. package/src/lib/hooks/use-chat.ts +775 -0
  61. package/src/lib/hooks/use-openclaw.ts +105 -70
  62. package/src/lib/hooks/use-search.ts +113 -0
  63. package/src/lib/hooks/use-session-stats.ts +57 -0
  64. package/src/lib/hooks/use-user-settings.ts +46 -0
  65. package/src/lib/types/chat.ts +186 -0
  66. package/src/lib/types/search.ts +60 -0
  67. package/src/middleware.ts +52 -0
  68. package/vitest.config.ts +13 -0
@@ -0,0 +1,775 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useRef, useEffect } from "react";
4
+ import useSWR, { mutate as globalMutate } from "swr";
5
+ import type {
6
+ ChatMessage,
7
+ StreamingMessage,
8
+ ChatDelta,
9
+ ChatSendResponse,
10
+ ChatCompleteRequest,
11
+ } from "@/lib/types/chat";
12
+ import { setAgentThinking, setAgentActive } from "@/lib/hooks/use-agent-status";
13
+
14
+ // ============================================================================
15
+ // Fetcher
16
+ // ============================================================================
17
+
18
+ const historyFetcher = async (url: string) => {
19
+ const res = await fetch(url);
20
+ if (!res.ok) throw new Error("Failed to load messages");
21
+ return res.json();
22
+ };
23
+
24
+ // ============================================================================
25
+ // Orphaned run handler (module-level singleton)
26
+ // ============================================================================
27
+ //
28
+ // When a chat component unmounts while an agent is still processing,
29
+ // we hand off its EventSource and active runs to this module-level handler.
30
+ // It continues processing SSE events so "final"/"error" events persist
31
+ // the completed message to the DB. Without this, messages are lost on nav.
32
+ //
33
+ // Uses a single shared EventSource to avoid exhausting the browser's
34
+ // per-origin connection limit when the user navigates between many channels.
35
+
36
+ interface OrphanedRun {
37
+ channelId: string;
38
+ agentId: string;
39
+ agentName: string;
40
+ sessionKey: string;
41
+ content: string;
42
+ startedAt: number;
43
+ }
44
+
45
+ // Singleton state — shared across all unmounted chat instances
46
+ const orphanedRuns = new Map<string, OrphanedRun>();
47
+ let orphanedES: EventSource | null = null;
48
+ let orphanedSafetyTimer: ReturnType<typeof setTimeout> | null = null;
49
+
50
+ function cleanupOrphaned() {
51
+ if (orphanedSafetyTimer) {
52
+ clearTimeout(orphanedSafetyTimer);
53
+ orphanedSafetyTimer = null;
54
+ }
55
+ orphanedES?.close();
56
+ orphanedES = null;
57
+ orphanedRuns.clear();
58
+ }
59
+
60
+ function persistOrphanedRun(
61
+ runId: string,
62
+ run: OrphanedRun,
63
+ delta: ChatDelta,
64
+ status: "complete" | "interrupted",
65
+ ) {
66
+ orphanedRuns.delete(runId);
67
+ setAgentActive(run.agentId);
68
+
69
+ const content = status === "complete"
70
+ ? (delta.text || delta.message?.content?.[0]?.text || run.content)
71
+ : run.content;
72
+
73
+ if (content) {
74
+ const payload: ChatCompleteRequest = {
75
+ runId,
76
+ channelId: run.channelId,
77
+ content,
78
+ sessionKey: delta.sessionKey || run.sessionKey,
79
+ agentId: run.agentId,
80
+ agentName: run.agentName,
81
+ status,
82
+ ...(status === "complete" && {
83
+ inputTokens: delta.message?.inputTokens,
84
+ outputTokens: delta.message?.outputTokens,
85
+ }),
86
+ };
87
+
88
+ fetch("/api/openclaw/chat", {
89
+ method: "PUT",
90
+ headers: { "Content-Type": "application/json" },
91
+ body: JSON.stringify(payload),
92
+ })
93
+ .then(() => {
94
+ // Trigger SWR revalidation so the message shows when user navigates back
95
+ globalMutate(
96
+ (key) =>
97
+ typeof key === "string" &&
98
+ key.startsWith(`/api/openclaw/chat?channelId=${run.channelId}`),
99
+ );
100
+ })
101
+ .catch((err) => console.error("[useChat] Orphan persist failed:", err));
102
+ }
103
+
104
+ // All runs done — tear down
105
+ if (orphanedRuns.size === 0) {
106
+ cleanupOrphaned();
107
+ }
108
+ }
109
+
110
+ function attachOrphanedHandler() {
111
+ if (!orphanedES) return;
112
+
113
+ orphanedES.onmessage = (e) => {
114
+ try {
115
+ const evt = JSON.parse(e.data);
116
+ if (evt.event !== "chat") return;
117
+
118
+ const delta = evt.payload as ChatDelta;
119
+ if (!delta?.runId || !orphanedRuns.has(delta.runId)) return;
120
+
121
+ const run = orphanedRuns.get(delta.runId)!;
122
+
123
+ if (delta.state === "delta") {
124
+ const text = delta.text ?? delta.message?.content?.[0]?.text ?? "";
125
+ if (text) run.content += text;
126
+ } else if (delta.state === "final") {
127
+ persistOrphanedRun(delta.runId, run, delta, "complete");
128
+ } else if (delta.state === "error") {
129
+ persistOrphanedRun(delta.runId, run, delta, "interrupted");
130
+ }
131
+ } catch {
132
+ // Ignore parse errors
133
+ }
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Hand off an EventSource and its active runs to the module-level handler.
139
+ * Multiple unmounts merge into a single shared connection.
140
+ */
141
+ function orphanEventSource(
142
+ es: EventSource,
143
+ newRuns: Map<string, OrphanedRun>,
144
+ ) {
145
+ // Merge new runs into the shared state
146
+ for (const [runId, run] of newRuns) {
147
+ orphanedRuns.set(runId, run);
148
+ }
149
+
150
+ if (orphanedES && orphanedES !== es) {
151
+ // Already have an orphaned ES — close the old one and use the newer
152
+ // (fresher) connection
153
+ orphanedES.close();
154
+ }
155
+ orphanedES = es;
156
+ attachOrphanedHandler();
157
+
158
+ // Reset safety timer (5 min) so stale connections don't leak
159
+ if (orphanedSafetyTimer) clearTimeout(orphanedSafetyTimer);
160
+ orphanedSafetyTimer = setTimeout(cleanupOrphaned, 5 * 60 * 1000);
161
+ }
162
+
163
+ // ============================================================================
164
+ // Hook
165
+ // ============================================================================
166
+
167
+ /** Max messages to keep in the SWR cache. When exceeded, evict from the
168
+ * opposite end to prevent unbounded memory growth during long scroll sessions. */
169
+ const MAX_CACHED_MESSAGES = 500;
170
+
171
+ interface UseChatOptions {
172
+ channelId: string;
173
+ defaultAgentId?: string;
174
+ /** When set, loads a window of messages around this ID instead of the latest. */
175
+ anchorMessageId?: string;
176
+ }
177
+
178
+ /** Shape of the SWR cache for message history. */
179
+ interface HistoryData {
180
+ messages: ChatMessage[];
181
+ hasMoreBefore: boolean;
182
+ hasMoreAfter: boolean;
183
+ }
184
+
185
+ interface UseChatReturn {
186
+ // Messages
187
+ messages: ChatMessage[];
188
+ isLoading: boolean;
189
+ hasMoreBefore: boolean;
190
+ hasMoreAfter: boolean;
191
+ loadOlder: () => void;
192
+ loadNewer: () => void;
193
+ loadingOlder: boolean;
194
+ loadingNewer: boolean;
195
+
196
+ // Backward-compat aliases
197
+ hasMore: boolean;
198
+ loadMore: () => void;
199
+ loadingMore: boolean;
200
+
201
+ // Streaming
202
+ streamingMessages: Map<string, StreamingMessage>;
203
+ isStreaming: boolean;
204
+
205
+ // Session
206
+ currentSessionKey: string | null;
207
+
208
+ // Actions
209
+ sendMessage: (content: string, agentId?: string) => Promise<void>;
210
+ abortResponse: () => Promise<void>;
211
+ sending: boolean;
212
+
213
+ // Errors
214
+ sendError: string | null;
215
+ clearSendError: () => void;
216
+ }
217
+
218
+ export function useChat({ channelId, defaultAgentId, anchorMessageId }: UseChatOptions): UseChatReturn {
219
+ // ---------------------------------------------------------------------------
220
+ // State
221
+ // ---------------------------------------------------------------------------
222
+ const [streamingMessages, setStreamingMessages] = useState<Map<string, StreamingMessage>>(new Map());
223
+ const streamingRef = useRef<Map<string, StreamingMessage>>(new Map());
224
+ const [currentSessionKey, setCurrentSessionKey] = useState<string | null>(null);
225
+ const [sending, setSending] = useState(false);
226
+ const [sendError, setSendError] = useState<string | null>(null);
227
+ const [loadingOlder, setLoadingOlder] = useState(false);
228
+ const [loadingNewer, setLoadingNewer] = useState(false);
229
+
230
+ // Track active runIds for reconnection timeout
231
+ const activeRunIds = useRef<Set<string>>(new Set());
232
+ const eventSourceRef = useRef<EventSource | null>(null);
233
+
234
+ // Helper: update both streaming state and ref in sync
235
+ const updateStreaming = useCallback((updater: (prev: Map<string, StreamingMessage>) => Map<string, StreamingMessage>) => {
236
+ setStreamingMessages((prev) => {
237
+ const next = updater(prev);
238
+ streamingRef.current = next;
239
+ return next;
240
+ });
241
+ }, []);
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // SWR: Message history
245
+ // ---------------------------------------------------------------------------
246
+ // When anchorMessageId is set, fetch a window around it.
247
+ // Otherwise fetch the latest messages (default behavior).
248
+ const swrKey = channelId
249
+ ? anchorMessageId
250
+ ? `/api/openclaw/chat?channelId=${channelId}&limit=50&around=${anchorMessageId}`
251
+ : `/api/openclaw/chat?channelId=${channelId}&limit=50`
252
+ : null;
253
+
254
+ const {
255
+ data: historyData,
256
+ isLoading,
257
+ mutate: mutateHistory,
258
+ } = useSWR(
259
+ swrKey,
260
+ historyFetcher,
261
+ {
262
+ revalidateOnFocus: false,
263
+ dedupingInterval: 5000,
264
+ }
265
+ );
266
+
267
+ // Deduplicate by message ID — race conditions between SWR revalidation,
268
+ // loadMore pagination, and orphan EventSource can produce duplicate entries.
269
+ const messages: ChatMessage[] = (() => {
270
+ const raw = historyData?.messages ?? [];
271
+ const seen = new Set<string>();
272
+ return raw.filter((m: ChatMessage) => {
273
+ if (seen.has(m.id)) return false;
274
+ seen.add(m.id);
275
+ return true;
276
+ });
277
+ })();
278
+
279
+ // Derive bidirectional pagination flags.
280
+ // Default mode returns { hasMore }, anchor mode returns { hasMoreBefore, hasMoreAfter }.
281
+ const hasMoreBefore: boolean = historyData?.hasMoreBefore ?? historyData?.hasMore ?? false;
282
+ const hasMoreAfter: boolean = historyData?.hasMoreAfter ?? false;
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Load older (backward pagination)
286
+ // ---------------------------------------------------------------------------
287
+ const loadOlder = useCallback(async () => {
288
+ if (loadingOlder || !hasMoreBefore || messages.length === 0) return;
289
+ setLoadingOlder(true);
290
+
291
+ const oldestId = messages[0]?.id;
292
+ try {
293
+ const res = await fetch(
294
+ `/api/openclaw/chat?channelId=${channelId}&limit=50&before=${oldestId}`
295
+ );
296
+ if (res.ok) {
297
+ const data = await res.json();
298
+ const olderMessages: ChatMessage[] = data.messages ?? [];
299
+ if (olderMessages.length > 0) {
300
+ // Prepend older messages to the SWR cache (dedup on merge)
301
+ // Evict from the newer end if cache exceeds MAX_CACHED_MESSAGES
302
+ mutateHistory(
303
+ (current: HistoryData | undefined) => {
304
+ const existing = current?.messages ?? [];
305
+ const existingIds = new Set(existing.map((m) => m.id));
306
+ const unique = olderMessages.filter((m: ChatMessage) => !existingIds.has(m.id));
307
+ let merged = [...unique, ...existing];
308
+ let hasMoreAfter = current?.hasMoreAfter ?? false;
309
+ if (merged.length > MAX_CACHED_MESSAGES) {
310
+ merged = merged.slice(0, MAX_CACHED_MESSAGES);
311
+ hasMoreAfter = true;
312
+ }
313
+ return {
314
+ messages: merged,
315
+ hasMoreBefore: data.hasMore,
316
+ hasMoreAfter,
317
+ };
318
+ },
319
+ { revalidate: false }
320
+ );
321
+ }
322
+ }
323
+ } catch (err) {
324
+ console.error("[useChat] Load older failed:", err);
325
+ } finally {
326
+ setLoadingOlder(false);
327
+ }
328
+ }, [loadingOlder, hasMoreBefore, messages, channelId, mutateHistory]);
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // Load newer (forward pagination) — only used in anchor mode
332
+ // ---------------------------------------------------------------------------
333
+ const loadNewer = useCallback(async () => {
334
+ if (loadingNewer || !hasMoreAfter || messages.length === 0) return;
335
+ setLoadingNewer(true);
336
+
337
+ const newestId = messages[messages.length - 1]?.id;
338
+ try {
339
+ const res = await fetch(
340
+ `/api/openclaw/chat?channelId=${channelId}&limit=50&after=${newestId}`
341
+ );
342
+ if (res.ok) {
343
+ const data = await res.json();
344
+ const newerMessages: ChatMessage[] = data.messages ?? [];
345
+ if (newerMessages.length > 0) {
346
+ // Append newer messages to the SWR cache (dedup on merge)
347
+ // Evict from the older end if cache exceeds MAX_CACHED_MESSAGES
348
+ mutateHistory(
349
+ (current: HistoryData | undefined) => {
350
+ const existing = current?.messages ?? [];
351
+ const existingIds = new Set(existing.map((m) => m.id));
352
+ const unique = newerMessages.filter((m: ChatMessage) => !existingIds.has(m.id));
353
+ let merged = [...existing, ...unique];
354
+ let hasMoreBefore = current?.hasMoreBefore ?? false;
355
+ if (merged.length > MAX_CACHED_MESSAGES) {
356
+ merged = merged.slice(merged.length - MAX_CACHED_MESSAGES);
357
+ hasMoreBefore = true;
358
+ }
359
+ return {
360
+ messages: merged,
361
+ hasMoreBefore,
362
+ hasMoreAfter: data.hasMore,
363
+ };
364
+ },
365
+ { revalidate: false }
366
+ );
367
+ }
368
+ }
369
+ } catch (err) {
370
+ console.error("[useChat] Load newer failed:", err);
371
+ } finally {
372
+ setLoadingNewer(false);
373
+ }
374
+ }, [loadingNewer, hasMoreAfter, messages, channelId, mutateHistory]);
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // SSE: Listen for chat events
378
+ // ---------------------------------------------------------------------------
379
+ useEffect(() => {
380
+ // Use the existing SSE endpoint that forwards all gateway events
381
+ const es = new EventSource("/api/openclaw/events");
382
+ eventSourceRef.current = es;
383
+
384
+ es.onmessage = (e) => {
385
+ try {
386
+ const evt = JSON.parse(e.data);
387
+
388
+ // Only handle chat events
389
+ if (evt.event !== "chat") return;
390
+
391
+ const delta = evt.payload as ChatDelta;
392
+ if (!delta?.runId) return;
393
+
394
+ // Only process events for our active runs
395
+ if (!activeRunIds.current.has(delta.runId)) return;
396
+
397
+ if (delta.state === "delta") {
398
+ // Accumulate streaming text
399
+ const text = delta.text ?? delta.message?.content?.[0]?.text ?? "";
400
+ if (text) {
401
+ updateStreaming((prev) => {
402
+ const next = new Map(prev);
403
+ const existing = next.get(delta.runId);
404
+ if (existing) {
405
+ next.set(delta.runId, {
406
+ ...existing,
407
+ content: existing.content + text,
408
+ });
409
+ } else {
410
+ // Placeholder hasn't been created yet (race with POST response)
411
+ next.set(delta.runId, {
412
+ runId: delta.runId,
413
+ agentId: defaultAgentId || "",
414
+ agentName: "",
415
+ sessionKey: delta.sessionKey,
416
+ content: text,
417
+ startedAt: Date.now(),
418
+ });
419
+ }
420
+ return next;
421
+ });
422
+ }
423
+
424
+ // Update sessionKey if provided
425
+ if (delta.sessionKey) {
426
+ setCurrentSessionKey(delta.sessionKey);
427
+ }
428
+ } else if (delta.state === "final") {
429
+ // Guard: if we already processed this runId's final, skip
430
+ if (!activeRunIds.current.has(delta.runId)) return;
431
+ activeRunIds.current.delete(delta.runId);
432
+
433
+ // Read accumulated content from the ref (always current, no stale closure)
434
+ const sm = streamingRef.current.get(delta.runId);
435
+ const accumulatedContent = sm?.content || "";
436
+ const finalContent =
437
+ delta.text ||
438
+ delta.message?.content?.[0]?.text ||
439
+ accumulatedContent;
440
+ const streamAgentId = sm?.agentId || defaultAgentId || "";
441
+ const streamAgentName = sm?.agentName;
442
+ const streamSessionKey =
443
+ delta.sessionKey || sm?.sessionKey || "";
444
+
445
+ // Ensure typing indicator shows for at least 800ms
446
+ const MIN_INDICATOR_MS = 800;
447
+ const elapsed = sm?.startedAt ? Date.now() - sm.startedAt : MIN_INDICATOR_MS;
448
+ const remaining = Math.max(0, MIN_INDICATOR_MS - elapsed);
449
+
450
+ const persistAndCleanup = () => {
451
+ // Mark agent as active (2 min timer back to idle)
452
+ setAgentActive(streamAgentId);
453
+
454
+ // Persist to DB, then reload history, then remove streaming placeholder
455
+ if (finalContent) {
456
+ const completePayload: ChatCompleteRequest = {
457
+ runId: delta.runId,
458
+ channelId,
459
+ content: finalContent,
460
+ sessionKey: streamSessionKey,
461
+ agentId: streamAgentId,
462
+ agentName: streamAgentName,
463
+ status: "complete",
464
+ inputTokens: delta.message?.inputTokens,
465
+ outputTokens: delta.message?.outputTokens,
466
+ };
467
+
468
+ fetch("/api/openclaw/chat", {
469
+ method: "PUT",
470
+ headers: { "Content-Type": "application/json" },
471
+ body: JSON.stringify(completePayload),
472
+ })
473
+ .then((res) => {
474
+ if (!res.ok) {
475
+ console.error("[useChat] PUT failed:", res.status);
476
+ }
477
+ return mutateHistory();
478
+ })
479
+ .then(() => {
480
+ // Only remove streaming message AFTER history has reloaded
481
+ updateStreaming((prev) => {
482
+ const next = new Map(prev);
483
+ next.delete(delta.runId);
484
+ return next;
485
+ });
486
+ })
487
+ .catch((err) => {
488
+ console.error("[useChat] Complete persist failed:", err);
489
+ updateStreaming((prev) => {
490
+ const next = new Map(prev);
491
+ next.delete(delta.runId);
492
+ return next;
493
+ });
494
+ });
495
+ } else {
496
+ // No content at all — just clean up
497
+ updateStreaming((prev) => {
498
+ const next = new Map(prev);
499
+ next.delete(delta.runId);
500
+ return next;
501
+ });
502
+ }
503
+ };
504
+
505
+ // Wait for minimum indicator time, then persist
506
+ if (remaining > 0) {
507
+ setTimeout(persistAndCleanup, remaining);
508
+ } else {
509
+ persistAndCleanup();
510
+ }
511
+ } else if (delta.state === "error") {
512
+ console.error("[useChat] Stream error:", delta.errorMessage);
513
+
514
+ // Read accumulated content from ref
515
+ const sm = streamingRef.current.get(delta.runId);
516
+ const errorContent = sm?.content || "";
517
+ const errorAgentId = sm?.agentId || defaultAgentId || "";
518
+ const errorAgentName = sm?.agentName;
519
+
520
+ // Mark agent as active even on error (it did work)
521
+ setAgentActive(errorAgentId);
522
+
523
+ // Remove streaming message immediately for errors
524
+ activeRunIds.current.delete(delta.runId);
525
+ updateStreaming((prev) => {
526
+ const next = new Map(prev);
527
+ next.delete(delta.runId);
528
+ return next;
529
+ });
530
+
531
+ // Save partial if we have content
532
+ if (errorContent) {
533
+ const errorPayload: ChatCompleteRequest = {
534
+ runId: delta.runId,
535
+ channelId,
536
+ content: errorContent,
537
+ sessionKey: delta.sessionKey || sm?.sessionKey || "",
538
+ agentId: errorAgentId,
539
+ agentName: errorAgentName,
540
+ status: "interrupted",
541
+ };
542
+
543
+ fetch("/api/openclaw/chat", {
544
+ method: "PUT",
545
+ headers: { "Content-Type": "application/json" },
546
+ body: JSON.stringify(errorPayload),
547
+ }).then(() => mutateHistory())
548
+ .catch(() => {});
549
+ }
550
+
551
+ setSendError(delta.errorMessage || "Stream error");
552
+ }
553
+ } catch {
554
+ // Ignore parse errors
555
+ }
556
+ };
557
+
558
+ return () => {
559
+ if (activeRunIds.current.size > 0) {
560
+ // Agent is still processing — hand off the EventSource to the
561
+ // module-level handler so "final" events still get persisted
562
+ // even though the component is unmounting.
563
+ const runs = new Map<string, OrphanedRun>();
564
+ for (const runId of activeRunIds.current) {
565
+ const sm = streamingRef.current.get(runId);
566
+ if (sm) {
567
+ runs.set(runId, {
568
+ channelId,
569
+ agentId: sm.agentId,
570
+ agentName: sm.agentName,
571
+ sessionKey: sm.sessionKey,
572
+ content: sm.content,
573
+ startedAt: sm.startedAt,
574
+ });
575
+ }
576
+ }
577
+ orphanEventSource(es, runs);
578
+ } else {
579
+ es.close();
580
+ }
581
+ eventSourceRef.current = null;
582
+ };
583
+ // We intentionally omit streamingMessages and currentSessionKey from deps
584
+ // to prevent re-creating the EventSource on every state change.
585
+ // The event handler accesses them via closure that stays fresh enough.
586
+ // eslint-disable-next-line react-hooks/exhaustive-deps
587
+ }, [channelId, defaultAgentId, mutateHistory, updateStreaming]);
588
+
589
+ // ---------------------------------------------------------------------------
590
+ // Reconnection timeout: mark in-flight runIds as interrupted after 30s
591
+ // ---------------------------------------------------------------------------
592
+ useEffect(() => {
593
+ // When streamingMessages changes, check for stale runs
594
+ const timer = setTimeout(() => {
595
+ const now = Date.now();
596
+ updateStreaming((prev) => {
597
+ const next = new Map(prev);
598
+ let changed = false;
599
+ for (const [runId, sm] of next) {
600
+ if (now - sm.startedAt > 30000) {
601
+ // Save partial
602
+ if (sm.content) {
603
+ fetch("/api/openclaw/chat", {
604
+ method: "PUT",
605
+ headers: { "Content-Type": "application/json" },
606
+ body: JSON.stringify({
607
+ runId,
608
+ channelId,
609
+ content: sm.content,
610
+ sessionKey: sm.sessionKey,
611
+ agentId: sm.agentId,
612
+ agentName: sm.agentName,
613
+ status: "interrupted",
614
+ } satisfies ChatCompleteRequest),
615
+ }).then(() => mutateHistory())
616
+ .catch(() => {});
617
+ }
618
+ activeRunIds.current.delete(runId);
619
+ next.delete(runId);
620
+ changed = true;
621
+ }
622
+ }
623
+ return changed ? next : prev;
624
+ });
625
+ }, 30000);
626
+
627
+ return () => clearTimeout(timer);
628
+ }, [streamingMessages, channelId, mutateHistory, updateStreaming]);
629
+
630
+ // ---------------------------------------------------------------------------
631
+ // Send message
632
+ // ---------------------------------------------------------------------------
633
+ const sendMessage = useCallback(
634
+ async (content: string, agentId?: string) => {
635
+ if (!content.trim() || sending) return;
636
+
637
+ setSending(true);
638
+ setSendError(null);
639
+
640
+ try {
641
+ const res = await fetch("/api/openclaw/chat", {
642
+ method: "POST",
643
+ headers: { "Content-Type": "application/json" },
644
+ body: JSON.stringify({
645
+ channelId,
646
+ content,
647
+ agentId: agentId || defaultAgentId,
648
+ }),
649
+ });
650
+
651
+ // Safely parse the JSON response
652
+ let result: ChatSendResponse;
653
+ try {
654
+ const data = await res.json();
655
+ if (!res.ok) {
656
+ throw new Error(data.error || `Send failed (${res.status})`);
657
+ }
658
+ result = data;
659
+ } catch (parseErr) {
660
+ if (!res.ok) {
661
+ throw new Error(`Send failed (${res.status})`);
662
+ }
663
+ throw new Error(`Invalid response from server: ${(parseErr as Error).message}`);
664
+ }
665
+
666
+ // Update session key
667
+ if (result.sessionKey) {
668
+ setCurrentSessionKey(result.sessionKey);
669
+ }
670
+
671
+ // Track this run for streaming
672
+ activeRunIds.current.add(result.runId);
673
+
674
+ // Refresh history to show the persisted user message
675
+ mutateHistory();
676
+
677
+ // Random delay before showing typing indicator — feels natural
678
+ const delay = 800 + Math.random() * 400;
679
+ await new Promise((resolve) => setTimeout(resolve, delay));
680
+
681
+ // Add streaming placeholder — shows typing indicator
682
+ const resolvedAgentId = agentId || defaultAgentId || "";
683
+ setAgentThinking(resolvedAgentId, channelId);
684
+
685
+ updateStreaming((prev) => {
686
+ const next = new Map(prev);
687
+ next.set(result.runId, {
688
+ runId: result.runId,
689
+ agentId: resolvedAgentId,
690
+ agentName: "",
691
+ sessionKey: result.sessionKey,
692
+ content: "",
693
+ startedAt: Date.now(),
694
+ });
695
+ return next;
696
+ });
697
+ } catch (err) {
698
+ setSendError((err as Error).message);
699
+ } finally {
700
+ setSending(false);
701
+ }
702
+ },
703
+ [channelId, defaultAgentId, sending, mutateHistory, updateStreaming]
704
+ );
705
+
706
+ // ---------------------------------------------------------------------------
707
+ // Abort response
708
+ // ---------------------------------------------------------------------------
709
+ const abortResponse = useCallback(async () => {
710
+ try {
711
+ await fetch("/api/openclaw/chat", { method: "DELETE" });
712
+
713
+ // Save partial content for all active streams
714
+ updateStreaming((prev) => {
715
+ for (const [runId, sm] of prev) {
716
+ if (sm.content) {
717
+ fetch("/api/openclaw/chat", {
718
+ method: "PUT",
719
+ headers: { "Content-Type": "application/json" },
720
+ body: JSON.stringify({
721
+ runId,
722
+ channelId,
723
+ content: sm.content,
724
+ sessionKey: sm.sessionKey,
725
+ agentId: sm.agentId,
726
+ agentName: sm.agentName,
727
+ status: "aborted",
728
+ } satisfies ChatCompleteRequest),
729
+ }).then(() => mutateHistory())
730
+ .catch(() => {});
731
+ }
732
+ activeRunIds.current.delete(runId);
733
+ }
734
+ return new Map();
735
+ });
736
+ } catch (err) {
737
+ console.error("[useChat] Abort failed:", err);
738
+ }
739
+ }, [channelId, mutateHistory, updateStreaming]);
740
+
741
+ // ---------------------------------------------------------------------------
742
+ // Clear error
743
+ // ---------------------------------------------------------------------------
744
+ const clearSendError = useCallback(() => setSendError(null), []);
745
+
746
+ // Auto-dismiss error after 5s
747
+ useEffect(() => {
748
+ if (!sendError) return;
749
+ const t = setTimeout(() => setSendError(null), 5000);
750
+ return () => clearTimeout(t);
751
+ }, [sendError]);
752
+
753
+ return {
754
+ messages,
755
+ isLoading,
756
+ hasMoreBefore,
757
+ hasMoreAfter,
758
+ loadOlder,
759
+ loadNewer,
760
+ loadingOlder,
761
+ loadingNewer,
762
+ // Backward-compat aliases (used by MessageList's existing scroll-up logic)
763
+ hasMore: hasMoreBefore,
764
+ loadMore: loadOlder,
765
+ loadingMore: loadingOlder,
766
+ streamingMessages,
767
+ isStreaming: streamingMessages.size > 0,
768
+ currentSessionKey,
769
+ sendMessage,
770
+ abortResponse,
771
+ sending,
772
+ sendError,
773
+ clearSendError,
774
+ };
775
+ }