@castlekit/castle 0.3.0 → 0.3.1

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 (35) hide show
  1. package/LICENSE +21 -0
  2. package/install.sh +20 -1
  3. package/package.json +17 -2
  4. package/src/app/api/openclaw/agents/route.ts +7 -1
  5. package/src/app/api/openclaw/chat/channels/route.ts +6 -3
  6. package/src/app/api/openclaw/chat/route.ts +17 -6
  7. package/src/app/api/openclaw/chat/search/route.ts +2 -1
  8. package/src/app/api/openclaw/config/route.ts +2 -0
  9. package/src/app/api/openclaw/events/route.ts +23 -8
  10. package/src/app/api/openclaw/ping/route.ts +5 -0
  11. package/src/app/api/openclaw/session/context/route.ts +163 -0
  12. package/src/app/api/openclaw/session/status/route.ts +179 -11
  13. package/src/app/api/openclaw/sessions/route.ts +2 -0
  14. package/src/app/chat/[channelId]/page.tsx +115 -35
  15. package/src/app/globals.css +10 -0
  16. package/src/app/page.tsx +10 -8
  17. package/src/components/chat/chat-input.tsx +23 -5
  18. package/src/components/chat/message-bubble.tsx +29 -13
  19. package/src/components/chat/message-list.tsx +238 -80
  20. package/src/components/chat/session-stats-panel.tsx +391 -86
  21. package/src/components/providers/search-provider.tsx +33 -4
  22. package/src/lib/db/index.ts +12 -2
  23. package/src/lib/db/queries.ts +199 -72
  24. package/src/lib/db/schema.ts +4 -0
  25. package/src/lib/gateway-connection.ts +24 -3
  26. package/src/lib/hooks/use-chat.ts +219 -241
  27. package/src/lib/hooks/use-compaction-events.ts +132 -0
  28. package/src/lib/hooks/use-context-boundary.ts +82 -0
  29. package/src/lib/hooks/use-openclaw.ts +44 -57
  30. package/src/lib/hooks/use-search.ts +1 -0
  31. package/src/lib/hooks/use-session-stats.ts +4 -1
  32. package/src/lib/sse-singleton.ts +184 -0
  33. package/src/lib/types/chat.ts +22 -6
  34. package/src/lib/db/__tests__/queries.test.ts +0 -318
  35. package/vitest.config.ts +0 -13
@@ -10,6 +10,7 @@ import type {
10
10
  ChatCompleteRequest,
11
11
  } from "@/lib/types/chat";
12
12
  import { setAgentThinking, setAgentActive } from "@/lib/hooks/use-agent-status";
13
+ import { subscribe, getLastEventTimestamp, type SSEEvent } from "@/lib/sse-singleton";
13
14
 
14
15
  // ============================================================================
15
16
  // Fetcher
@@ -17,7 +18,10 @@ import { setAgentThinking, setAgentActive } from "@/lib/hooks/use-agent-status";
17
18
 
18
19
  const historyFetcher = async (url: string) => {
19
20
  const res = await fetch(url);
20
- if (!res.ok) throw new Error("Failed to load messages");
21
+ if (!res.ok) {
22
+ console.error(`[useChat] History fetch failed: ${res.status} ${url}`);
23
+ throw new Error("Failed to load messages");
24
+ }
21
25
  return res.json();
22
26
  };
23
27
 
@@ -44,7 +48,7 @@ interface OrphanedRun {
44
48
 
45
49
  // Singleton state — shared across all unmounted chat instances
46
50
  const orphanedRuns = new Map<string, OrphanedRun>();
47
- let orphanedES: EventSource | null = null;
51
+ let orphanedUnsubscribe: (() => void) | null = null;
48
52
  let orphanedSafetyTimer: ReturnType<typeof setTimeout> | null = null;
49
53
 
50
54
  function cleanupOrphaned() {
@@ -52,8 +56,8 @@ function cleanupOrphaned() {
52
56
  clearTimeout(orphanedSafetyTimer);
53
57
  orphanedSafetyTimer = null;
54
58
  }
55
- orphanedES?.close();
56
- orphanedES = null;
59
+ orphanedUnsubscribe?.();
60
+ orphanedUnsubscribe = null;
57
61
  orphanedRuns.clear();
58
62
  }
59
63
 
@@ -85,12 +89,14 @@ function persistOrphanedRun(
85
89
  }),
86
90
  };
87
91
 
92
+ console.log(`[useChat] Persisting orphaned run ${runId} (channel=${run.channelId}, status=${status})`);
88
93
  fetch("/api/openclaw/chat", {
89
94
  method: "PUT",
90
95
  headers: { "Content-Type": "application/json" },
91
96
  body: JSON.stringify(payload),
92
97
  })
93
98
  .then(() => {
99
+ console.log(`[useChat] Orphaned run ${runId} persisted successfully`);
94
100
  // Trigger SWR revalidation so the message shows when user navigates back
95
101
  globalMutate(
96
102
  (key) =>
@@ -98,7 +104,7 @@ function persistOrphanedRun(
98
104
  key.startsWith(`/api/openclaw/chat?channelId=${run.channelId}`),
99
105
  );
100
106
  })
101
- .catch((err) => console.error("[useChat] Orphan persist failed:", err));
107
+ .catch((err) => console.error(`[useChat] Orphan persist failed for run ${runId}:`, err));
102
108
  }
103
109
 
104
110
  // All runs done — tear down
@@ -107,55 +113,40 @@ function persistOrphanedRun(
107
113
  }
108
114
  }
109
115
 
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;
116
+ function ensureOrphanedSubscription() {
117
+ if (orphanedUnsubscribe) return; // already subscribed
117
118
 
118
- const delta = evt.payload as ChatDelta;
119
- if (!delta?.runId || !orphanedRuns.has(delta.runId)) return;
119
+ orphanedUnsubscribe = subscribe("chat", (evt: SSEEvent) => {
120
+ const delta = evt.payload as ChatDelta;
121
+ if (!delta?.runId || !orphanedRuns.has(delta.runId)) return;
120
122
 
121
- const run = orphanedRuns.get(delta.runId)!;
123
+ const run = orphanedRuns.get(delta.runId)!;
122
124
 
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
125
+ if (delta.state === "delta") {
126
+ // Skip delta accumulation — text is cumulative (full text so far),
127
+ // so we just wait for the "final" event which has the complete response.
128
+ return;
129
+ } else if (delta.state === "final") {
130
+ persistOrphanedRun(delta.runId, run, delta, "complete");
131
+ } else if (delta.state === "error") {
132
+ persistOrphanedRun(delta.runId, run, delta, "interrupted");
133
133
  }
134
- };
134
+ });
135
135
  }
136
136
 
137
137
  /**
138
- * Hand off an EventSource and its active runs to the module-level handler.
139
- * Multiple unmounts merge into a single shared connection.
138
+ * Hand off active runs to the module-level orphan handler.
139
+ * Uses the shared SSE singleton no separate EventSource needed.
140
140
  */
141
- function orphanEventSource(
142
- es: EventSource,
143
- newRuns: Map<string, OrphanedRun>,
144
- ) {
141
+ function orphanRuns(newRuns: Map<string, OrphanedRun>) {
145
142
  // Merge new runs into the shared state
146
143
  for (const [runId, run] of newRuns) {
147
144
  orphanedRuns.set(runId, run);
148
145
  }
149
146
 
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();
147
+ ensureOrphanedSubscription();
157
148
 
158
- // Reset safety timer (5 min) so stale connections don't leak
149
+ // Reset safety timer (5 min) so stale subscriptions don't leak
159
150
  if (orphanedSafetyTimer) clearTimeout(orphanedSafetyTimer);
160
151
  orphanedSafetyTimer = setTimeout(cleanupOrphaned, 5 * 60 * 1000);
161
152
  }
@@ -229,7 +220,6 @@ export function useChat({ channelId, defaultAgentId, anchorMessageId }: UseChatO
229
220
 
230
221
  // Track active runIds for reconnection timeout
231
222
  const activeRunIds = useRef<Set<string>>(new Set());
232
- const eventSourceRef = useRef<EventSource | null>(null);
233
223
 
234
224
  // Helper: update both streaming state and ref in sync
235
225
  const updateStreaming = useCallback((updater: (prev: Map<string, StreamingMessage>) => Map<string, StreamingMessage>) => {
@@ -281,6 +271,24 @@ export function useChat({ channelId, defaultAgentId, anchorMessageId }: UseChatO
281
271
  const hasMoreBefore: boolean = historyData?.hasMoreBefore ?? historyData?.hasMore ?? false;
282
272
  const hasMoreAfter: boolean = historyData?.hasMoreAfter ?? false;
283
273
 
274
+ // ---------------------------------------------------------------------------
275
+ // Initialize session key from loaded messages
276
+ // ---------------------------------------------------------------------------
277
+ // On page load, currentSessionKey is null until a message is sent.
278
+ // Derive it from the most recent message that has a sessionKey so the
279
+ // stats panel shows immediately without waiting for a new send.
280
+ useEffect(() => {
281
+ if (currentSessionKey) return; // already set by sendMessage or SSE
282
+ if (messages.length === 0) return;
283
+
284
+ for (let i = messages.length - 1; i >= 0; i--) {
285
+ if (messages[i].sessionKey) {
286
+ setCurrentSessionKey(messages[i].sessionKey);
287
+ return;
288
+ }
289
+ }
290
+ }, [currentSessionKey, messages]);
291
+
284
292
  // ---------------------------------------------------------------------------
285
293
  // Load older (backward pagination)
286
294
  // ---------------------------------------------------------------------------
@@ -374,192 +382,158 @@ export function useChat({ channelId, defaultAgentId, anchorMessageId }: UseChatO
374
382
  }, [loadingNewer, hasMoreAfter, messages, channelId, mutateHistory]);
375
383
 
376
384
  // ---------------------------------------------------------------------------
377
- // SSE: Listen for chat events
385
+ // SSE: Listen for chat events via shared singleton
378
386
  // ---------------------------------------------------------------------------
379
387
  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);
388
+ const unsubscribe = subscribe("chat", (evt: SSEEvent) => {
389
+ const delta = evt.payload as ChatDelta;
390
+ if (!delta?.runId) return;
387
391
 
388
- // Only handle chat events
389
- if (evt.event !== "chat") return;
392
+ // Only process events for our active runs
393
+ if (!activeRunIds.current.has(delta.runId)) return;
390
394
 
391
- const delta = evt.payload as ChatDelta;
392
- if (!delta?.runId) return;
395
+ if (delta.state === "delta") {
396
+ // Skip delta text accumulation — Gateway sends cumulative text
397
+ // (full response so far) in each delta, NOT incremental chunks.
398
+ // Just ensure a placeholder exists so UI shows the loading indicator.
399
+ updateStreaming((prev) => {
400
+ if (prev.has(delta.runId)) return prev; // already tracked
401
+ const next = new Map(prev);
402
+ next.set(delta.runId, {
403
+ runId: delta.runId,
404
+ agentId: defaultAgentId || "",
405
+ agentName: "",
406
+ sessionKey: delta.sessionKey,
407
+ content: "",
408
+ startedAt: Date.now(),
409
+ });
410
+ return next;
411
+ });
393
412
 
394
- // Only process events for our active runs
413
+ // Update sessionKey if provided
414
+ if (delta.sessionKey) {
415
+ setCurrentSessionKey(delta.sessionKey);
416
+ }
417
+ } else if (delta.state === "final") {
418
+ // Guard: if we already processed this runId's final, skip
395
419
  if (!activeRunIds.current.has(delta.runId)) return;
420
+ activeRunIds.current.delete(delta.runId);
421
+
422
+ // Read streaming state from ref (always current, no stale closure)
423
+ const sm = streamingRef.current.get(delta.runId);
424
+ const finalContent =
425
+ delta.text ||
426
+ delta.message?.content?.[0]?.text ||
427
+ sm?.content ||
428
+ "";
429
+ const streamAgentId = sm?.agentId || defaultAgentId || "";
430
+ const streamAgentName = sm?.agentName;
431
+ const streamSessionKey =
432
+ delta.sessionKey || sm?.sessionKey || "";
433
+
434
+ // Ensure typing indicator shows for at least 800ms
435
+ const MIN_INDICATOR_MS = 800;
436
+ const elapsed = sm?.startedAt ? Date.now() - sm.startedAt : MIN_INDICATOR_MS;
437
+ const remaining = Math.max(0, MIN_INDICATOR_MS - elapsed);
438
+
439
+ const persistAndCleanup = () => {
440
+ setAgentActive(streamAgentId);
441
+
442
+ if (finalContent) {
443
+ const completePayload: ChatCompleteRequest = {
444
+ runId: delta.runId,
445
+ channelId,
446
+ content: finalContent,
447
+ sessionKey: streamSessionKey,
448
+ agentId: streamAgentId,
449
+ agentName: streamAgentName,
450
+ status: "complete",
451
+ inputTokens: delta.message?.inputTokens,
452
+ outputTokens: delta.message?.outputTokens,
453
+ };
396
454
 
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,
455
+ fetch("/api/openclaw/chat", {
456
+ method: "PUT",
457
+ headers: { "Content-Type": "application/json" },
458
+ body: JSON.stringify(completePayload),
459
+ })
460
+ .then((res) => {
461
+ if (!res.ok) console.error("[useChat] PUT failed:", res.status);
462
+ return mutateHistory();
463
+ })
464
+ .then(() => {
465
+ updateStreaming((prev) => {
466
+ const next = new Map(prev);
467
+ next.delete(delta.runId);
468
+ return next;
408
469
  });
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(),
470
+ })
471
+ .catch((err) => {
472
+ console.error("[useChat] Complete persist failed:", err);
473
+ updateStreaming((prev) => {
474
+ const next = new Map(prev);
475
+ next.delete(delta.runId);
476
+ return next;
418
477
  });
419
- }
478
+ });
479
+ } else {
480
+ updateStreaming((prev) => {
481
+ const next = new Map(prev);
482
+ next.delete(delta.runId);
420
483
  return next;
421
484
  });
422
485
  }
486
+ };
423
487
 
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
- };
488
+ if (remaining > 0) {
489
+ setTimeout(persistAndCleanup, remaining);
490
+ } else {
491
+ persistAndCleanup();
492
+ }
493
+ } else if (delta.state === "error") {
494
+ console.error("[useChat] Stream error:", delta.errorMessage);
467
495
 
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
- };
496
+ const sm = streamingRef.current.get(delta.runId);
497
+ const errorContent = sm?.content || "";
498
+ const errorAgentId = sm?.agentId || defaultAgentId || "";
499
+ const errorAgentName = sm?.agentName;
504
500
 
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
- });
501
+ setAgentActive(errorAgentId);
530
502
 
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
- };
503
+ activeRunIds.current.delete(delta.runId);
504
+ updateStreaming((prev) => {
505
+ const next = new Map(prev);
506
+ next.delete(delta.runId);
507
+ return next;
508
+ });
542
509
 
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
- }
510
+ if (errorContent) {
511
+ const errorPayload: ChatCompleteRequest = {
512
+ runId: delta.runId,
513
+ channelId,
514
+ content: errorContent,
515
+ sessionKey: delta.sessionKey || sm?.sessionKey || "",
516
+ agentId: errorAgentId,
517
+ agentName: errorAgentName,
518
+ status: "interrupted",
519
+ };
550
520
 
551
- setSendError(delta.errorMessage || "Stream error");
521
+ fetch("/api/openclaw/chat", {
522
+ method: "PUT",
523
+ headers: { "Content-Type": "application/json" },
524
+ body: JSON.stringify(errorPayload),
525
+ }).then(() => mutateHistory())
526
+ .catch(() => {});
552
527
  }
553
- } catch {
554
- // Ignore parse errors
528
+
529
+ setSendError(delta.errorMessage || "Stream error");
555
530
  }
556
- };
531
+ });
557
532
 
558
533
  return () => {
559
534
  if (activeRunIds.current.size > 0) {
560
- // Agent is still processinghand off the EventSource to the
561
- // module-level handler so "final" events still get persisted
562
- // even though the component is unmounting.
535
+ console.log(`[useChat] Unmounting with ${activeRunIds.current.size} active run(s) handing off to orphan handler`);
536
+ // Agent is still processing hand off to orphan handler
563
537
  const runs = new Map<string, OrphanedRun>();
564
538
  for (const runId of activeRunIds.current) {
565
539
  const sm = streamingRef.current.get(runId);
@@ -574,58 +548,59 @@ export function useChat({ channelId, defaultAgentId, anchorMessageId }: UseChatO
574
548
  });
575
549
  }
576
550
  }
577
- orphanEventSource(es, runs);
578
- } else {
579
- es.close();
551
+ orphanRuns(runs);
580
552
  }
581
- eventSourceRef.current = null;
553
+ unsubscribe();
582
554
  };
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
555
  // eslint-disable-next-line react-hooks/exhaustive-deps
587
556
  }, [channelId, defaultAgentId, mutateHistory, updateStreaming]);
588
557
 
589
558
  // ---------------------------------------------------------------------------
590
- // Reconnection timeout: mark in-flight runIds as interrupted after 30s
559
+ // Heartbeat-based timeout: if no SSE events arrive for 60s while we have
560
+ // active streaming runs, the connection is likely dead — mark as interrupted.
591
561
  // ---------------------------------------------------------------------------
592
562
  useEffect(() => {
593
- // When streamingMessages changes, check for stale runs
594
- const timer = setTimeout(() => {
595
- const now = Date.now();
563
+ if (streamingMessages.size === 0) return;
564
+
565
+ const HEARTBEAT_TIMEOUT_MS = 60_000;
566
+ const CHECK_INTERVAL_MS = 10_000;
567
+
568
+ const interval = setInterval(() => {
569
+ const timeSinceLastEvent = Date.now() - getLastEventTimestamp();
570
+ if (timeSinceLastEvent < HEARTBEAT_TIMEOUT_MS) return;
571
+
572
+ console.warn(`[useChat] SSE heartbeat timeout — no events for ${Math.round(timeSinceLastEvent / 1000)}s, interrupting ${streamingMessages.size} active run(s)`);
573
+
574
+ // SSE connection appears dead — mark all active runs as interrupted
596
575
  updateStreaming((prev) => {
576
+ if (prev.size === 0) return prev;
597
577
  const next = new Map(prev);
598
- let changed = false;
599
578
  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;
579
+ if (sm.content) {
580
+ fetch("/api/openclaw/chat", {
581
+ method: "PUT",
582
+ headers: { "Content-Type": "application/json" },
583
+ body: JSON.stringify({
584
+ runId,
585
+ channelId,
586
+ content: sm.content,
587
+ sessionKey: sm.sessionKey,
588
+ agentId: sm.agentId,
589
+ agentName: sm.agentName,
590
+ status: "interrupted",
591
+ } satisfies ChatCompleteRequest),
592
+ }).then(() => mutateHistory())
593
+ .catch(() => {});
621
594
  }
595
+ activeRunIds.current.delete(runId);
596
+ next.delete(runId);
622
597
  }
623
- return changed ? next : prev;
598
+ return next;
624
599
  });
625
- }, 30000);
600
+ }, CHECK_INTERVAL_MS);
626
601
 
627
- return () => clearTimeout(timer);
628
- }, [streamingMessages, channelId, mutateHistory, updateStreaming]);
602
+ return () => clearInterval(interval);
603
+ }, [streamingMessages.size, channelId, mutateHistory, updateStreaming]);
629
604
 
630
605
  // ---------------------------------------------------------------------------
631
606
  // Send message
@@ -663,6 +638,8 @@ export function useChat({ channelId, defaultAgentId, anchorMessageId }: UseChatO
663
638
  throw new Error(`Invalid response from server: ${(parseErr as Error).message}`);
664
639
  }
665
640
 
641
+ console.log(`[useChat] Send OK — runId=${result.runId} channel=${channelId}`);
642
+
666
643
  // Update session key
667
644
  if (result.sessionKey) {
668
645
  setCurrentSessionKey(result.sessionKey);
@@ -695,6 +672,7 @@ export function useChat({ channelId, defaultAgentId, anchorMessageId }: UseChatO
695
672
  return next;
696
673
  });
697
674
  } catch (err) {
675
+ console.error(`[useChat] Send FAILED — channel=${channelId}:`, (err as Error).message);
698
676
  setSendError((err as Error).message);
699
677
  } finally {
700
678
  setSending(false);