@chrysb/alphaclaw 0.8.2 → 0.8.3-beta.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.
@@ -0,0 +1,1094 @@
1
+ import { h } from "preact";
2
+ import {
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from "preact/hooks";
9
+ import htm from "htm";
10
+ import { marked } from "marked";
11
+ import { authFetch } from "../../lib/api.js";
12
+ import { kChatSessionDraftsStorageKey } from "../../lib/storage-keys.js";
13
+ import { showToast } from "../toast.js";
14
+
15
+ const html = htm.bind(h);
16
+ const kNewChatEventName = "alphaclaw:chat-new";
17
+ const kWsReconnectMaxAttempts = 8;
18
+ const kAutoscrollBottomThresholdPx = 40;
19
+ const kChatDebugQueryFlag = "chatDebug";
20
+
21
+ const buildMessage = ({
22
+ role = "assistant",
23
+ content = "",
24
+ createdAt = Date.now(),
25
+ debugPayload = null,
26
+ } = {}) => ({
27
+ id: crypto.randomUUID(),
28
+ role,
29
+ content: String(content || ""),
30
+ createdAt: Number(createdAt) || Date.now(),
31
+ debugPayload,
32
+ });
33
+
34
+ const formatChatTime = (createdAt) => {
35
+ const value = Number(createdAt || 0);
36
+ if (!value) return "";
37
+ try {
38
+ return new Date(value).toLocaleTimeString([], {
39
+ hour: "2-digit",
40
+ minute: "2-digit",
41
+ });
42
+ } catch {
43
+ return "";
44
+ }
45
+ };
46
+
47
+ const escapeHtmlForMarkdown = (value = "") =>
48
+ String(value || "")
49
+ .replaceAll("&", "&")
50
+ .replaceAll("<", "&lt;")
51
+ .replaceAll(">", "&gt;");
52
+
53
+ const normalizeMarkdownInput = (value = "") => {
54
+ const source = String(value || "").replace(/\r\n/g, "\n");
55
+ if (source.includes("\n")) return source;
56
+ // Some runtimes persist escaped sequences in history payloads.
57
+ return source.includes("\\n") ? source.replace(/\\n/g, "\n") : source;
58
+ };
59
+
60
+ const normalizeListMarkers = (value = "") =>
61
+ String(value || "").replace(/^(\s*)\d+\.\s+/gm, "$1- ");
62
+
63
+ const parseJsonMessage = (value = "") => {
64
+ const source = String(value || "").trim();
65
+ if (!source) return null;
66
+ if (!(source.startsWith("{") || source.startsWith("["))) return null;
67
+ try {
68
+ return JSON.parse(source);
69
+ } catch {
70
+ return null;
71
+ }
72
+ };
73
+
74
+ const extractToolCallsFromPayload = (payload = null) => {
75
+ const normalizedPayload =
76
+ payload && typeof payload === "object" ? payload : {};
77
+ if (
78
+ Array.isArray(normalizedPayload?.toolCalls) &&
79
+ normalizedPayload.toolCalls.length > 0
80
+ ) {
81
+ return normalizedPayload.toolCalls;
82
+ }
83
+ const rawParts = Array.isArray(normalizedPayload?.rawMessage?.content)
84
+ ? normalizedPayload.rawMessage.content
85
+ : [];
86
+ return rawParts
87
+ .filter((part) => String(part?.type || "").toLowerCase() === "toolcall")
88
+ .map((part) => ({
89
+ id: String(part?.id || ""),
90
+ name: String(part?.name || ""),
91
+ arguments: part?.arguments || null,
92
+ partialJson: String(part?.partialJson || ""),
93
+ }))
94
+ .filter((toolCall) => toolCall.name || toolCall.id);
95
+ };
96
+
97
+ const normalizeToolResult = (toolResult = null) => {
98
+ if (!toolResult || typeof toolResult !== "object") return null;
99
+ const rawMessage = toolResult?.rawMessage || toolResult;
100
+ if (!rawMessage || typeof rawMessage !== "object") return null;
101
+ const contentParts = Array.isArray(rawMessage?.content) ? rawMessage.content : [];
102
+ const text = contentParts
103
+ .map((part) => String(part?.text || ""))
104
+ .filter((value) => value.length > 0)
105
+ .join("\n")
106
+ .trim();
107
+ return {
108
+ toolCallId: String(rawMessage?.toolCallId || toolResult?.toolCallId || ""),
109
+ toolName: String(rawMessage?.toolName || toolResult?.toolName || ""),
110
+ text,
111
+ isError: Boolean(
112
+ rawMessage?.isError === true ||
113
+ toolResult?.isError === true ||
114
+ String(rawMessage?.status || "").toLowerCase() === "error",
115
+ ),
116
+ rawMessage,
117
+ };
118
+ };
119
+
120
+ const buildToolMessage = ({
121
+ toolCall = null,
122
+ toolResult = null,
123
+ createdAt = Date.now(),
124
+ debugPayload = null,
125
+ } = {}) => {
126
+ const normalizedToolCall =
127
+ toolCall && typeof toolCall === "object" ? toolCall : {};
128
+ const name = String(
129
+ normalizedToolCall?.name || toolResult?.toolName || "unknown",
130
+ );
131
+ return buildMessage({
132
+ role: "tool",
133
+ content: `Tool call: ${name}`,
134
+ createdAt,
135
+ debugPayload:
136
+ debugPayload ||
137
+ ({
138
+ timestamp: createdAt,
139
+ metadata: null,
140
+ rawMessage: null,
141
+ toolCalls: normalizedToolCall?.name || normalizedToolCall?.id ? [normalizedToolCall] : [],
142
+ toolResult: toolResult || null,
143
+ }),
144
+ });
145
+ };
146
+
147
+ const renderMarkdownHtml = (value = "") =>
148
+ marked.parse(
149
+ escapeHtmlForMarkdown(normalizeListMarkers(normalizeMarkdownInput(value))),
150
+ {
151
+ gfm: true,
152
+ breaks: true,
153
+ },
154
+ );
155
+
156
+ export const ChatRoute = ({ sessions = [], selectedSessionKey = "" }) => {
157
+ const [messagesBySession, setMessagesBySession] = useState({});
158
+ const [draft, setDraft] = useState("");
159
+ const [draftBySession, setDraftBySession] = useState(() => {
160
+ try {
161
+ const rawValue = localStorage.getItem(kChatSessionDraftsStorageKey);
162
+ if (!rawValue) return {};
163
+ const parsed = JSON.parse(rawValue);
164
+ return parsed && typeof parsed === "object" ? parsed : {};
165
+ } catch {
166
+ return {};
167
+ }
168
+ });
169
+ const [sending, setSending] = useState(false);
170
+ const [streaming, setStreaming] = useState(false);
171
+ const [isConnected, setIsConnected] = useState(false);
172
+ const [rawHistoryBySession, setRawHistoryBySession] = useState({});
173
+ const [debugEventsBySession, setDebugEventsBySession] = useState({});
174
+ const [activeRunBySession, setActiveRunBySession] = useState({});
175
+ const [connectionError, setConnectionError] = useState("");
176
+ const [historyLoading, setHistoryLoading] = useState(false);
177
+ const wsRef = useRef(null);
178
+ const threadRef = useRef(null);
179
+ const reconnectTimerRef = useRef(null);
180
+ const reconnectAttemptsRef = useRef(0);
181
+ const selectedSessionKeyRef = useRef(selectedSessionKey);
182
+ const realtimeDisabledRef = useRef(false);
183
+ const shouldAutoScrollRef = useRef(true);
184
+ const appendDebugEvent = useCallback((sessionKey, label, payload) => {
185
+ const normalizedSessionKey = String(
186
+ sessionKey || selectedSessionKeyRef.current || "",
187
+ );
188
+ if (!normalizedSessionKey) return;
189
+ const nextEvent = {
190
+ id: crypto.randomUUID(),
191
+ at: Date.now(),
192
+ label: String(label || ""),
193
+ payload: payload ?? null,
194
+ };
195
+ setDebugEventsBySession((currentMap) => {
196
+ const existing = currentMap[normalizedSessionKey] || [];
197
+ const nextList = [...existing, nextEvent].slice(-30);
198
+ return {
199
+ ...currentMap,
200
+ [normalizedSessionKey]: nextList,
201
+ };
202
+ });
203
+ }, []);
204
+
205
+ useEffect(() => {
206
+ selectedSessionKeyRef.current = selectedSessionKey;
207
+ }, [selectedSessionKey]);
208
+
209
+ useEffect(() => {
210
+ if (!selectedSessionKey) return;
211
+ setDraft(String(draftBySession[selectedSessionKey] || ""));
212
+ }, [draftBySession, selectedSessionKey]);
213
+
214
+ useEffect(() => {
215
+ try {
216
+ localStorage.setItem(
217
+ kChatSessionDraftsStorageKey,
218
+ JSON.stringify(draftBySession),
219
+ );
220
+ } catch {}
221
+ }, [draftBySession]);
222
+
223
+ const selectedSession = useMemo(
224
+ () =>
225
+ sessions.find(
226
+ (sessionRow) =>
227
+ String(sessionRow?.key || "") === String(selectedSessionKey || ""),
228
+ ) || null,
229
+ [selectedSessionKey, sessions],
230
+ );
231
+ const chatDebugEnabled = useMemo(() => {
232
+ try {
233
+ const params = new URLSearchParams(window.location.search || "");
234
+ return params.get(kChatDebugQueryFlag) === "1";
235
+ } catch {
236
+ return false;
237
+ }
238
+ }, []);
239
+
240
+ const messages = useMemo(
241
+ () => messagesBySession[selectedSessionKey] || [],
242
+ [messagesBySession, selectedSessionKey],
243
+ );
244
+
245
+ useEffect(() => {
246
+ const handleNewChat = () => {
247
+ if (!selectedSessionKey) return;
248
+ setMessagesBySession((currentMap) => ({
249
+ ...currentMap,
250
+ [selectedSessionKey]: [],
251
+ }));
252
+ setDraft("");
253
+ setDraftBySession((currentMap) => ({
254
+ ...currentMap,
255
+ [selectedSessionKey]: "",
256
+ }));
257
+ };
258
+ window.addEventListener(kNewChatEventName, handleNewChat);
259
+ return () => {
260
+ window.removeEventListener(kNewChatEventName, handleNewChat);
261
+ };
262
+ }, [selectedSessionKey]);
263
+
264
+ useEffect(() => {
265
+ let mounted = true;
266
+
267
+ const connect = () => {
268
+ if (realtimeDisabledRef.current) return;
269
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
270
+ const ws = new WebSocket(
271
+ `${protocol}//${window.location.host}/api/ws/chat`,
272
+ );
273
+ wsRef.current = ws;
274
+
275
+ ws.onopen = () => {
276
+ if (!mounted) return;
277
+ setIsConnected(true);
278
+ setConnectionError("");
279
+ reconnectAttemptsRef.current = 0;
280
+ const currentSessionKey = String(selectedSessionKeyRef.current || "");
281
+ if (currentSessionKey) {
282
+ setHistoryLoading(true);
283
+ ws.send(
284
+ JSON.stringify({
285
+ type: "history",
286
+ sessionKey: currentSessionKey,
287
+ }),
288
+ );
289
+ }
290
+ };
291
+
292
+ ws.onclose = () => {
293
+ if (!mounted) return;
294
+ setIsConnected(false);
295
+ setStreaming(false);
296
+ setSending(false);
297
+ setHistoryLoading(false);
298
+ if (realtimeDisabledRef.current) return;
299
+ if (reconnectAttemptsRef.current >= kWsReconnectMaxAttempts) return;
300
+ const delayMs = Math.min(
301
+ 1000 * 2 ** reconnectAttemptsRef.current,
302
+ 5000,
303
+ );
304
+ reconnectAttemptsRef.current += 1;
305
+ setConnectionError("Realtime chat socket disconnected.");
306
+ reconnectTimerRef.current = setTimeout(connect, delayMs);
307
+ };
308
+
309
+ ws.onerror = () => {
310
+ if (!mounted) return;
311
+ setIsConnected(false);
312
+ setHistoryLoading(false);
313
+ setConnectionError("Realtime chat socket failed to connect.");
314
+ };
315
+
316
+ ws.onmessage = (event) => {
317
+ let payload = null;
318
+ try {
319
+ payload = JSON.parse(String(event?.data || ""));
320
+ } catch {
321
+ return;
322
+ }
323
+ if (!payload || typeof payload !== "object") return;
324
+ appendDebugEvent(
325
+ String(payload.sessionKey || selectedSessionKeyRef.current || ""),
326
+ `ws:${String(payload.type || "unknown")}`,
327
+ payload,
328
+ );
329
+
330
+ if (payload.type === "history") {
331
+ const historySessionKey = String(payload.sessionKey || "");
332
+ if (!historySessionKey) return;
333
+ const historyMessages = (
334
+ Array.isArray(payload.messages) ? payload.messages : []
335
+ )
336
+ .map((messageRow) =>
337
+ buildMessage({
338
+ role: String(messageRow?.role || "assistant"),
339
+ content: String(messageRow?.content || ""),
340
+ createdAt: Number(messageRow?.timestamp) || Date.now(),
341
+ debugPayload: messageRow || null,
342
+ }),
343
+ )
344
+ .filter(
345
+ (messageRow) =>
346
+ String(messageRow.content || "").trim() ||
347
+ extractToolCallsFromPayload(messageRow?.debugPayload).length >
348
+ 0,
349
+ );
350
+ setMessagesBySession((currentMap) => ({
351
+ ...currentMap,
352
+ [historySessionKey]: historyMessages,
353
+ }));
354
+ setRawHistoryBySession((currentMap) => ({
355
+ ...currentMap,
356
+ [historySessionKey]: payload.rawHistory || null,
357
+ }));
358
+ setHistoryLoading(false);
359
+ return;
360
+ }
361
+
362
+ if (payload.type === "chunk") {
363
+ const chunkSessionKey = String(
364
+ payload.sessionKey || selectedSessionKeyRef.current || "",
365
+ );
366
+ const messageId = String(payload.messageId || "");
367
+ const chunkText = String(payload.content || "");
368
+ if (!chunkSessionKey || !messageId) return;
369
+ setSending(false);
370
+ setStreaming(true);
371
+ setMessagesBySession((currentMap) => {
372
+ const currentMessages = currentMap[chunkSessionKey] || [];
373
+ const lastMessage = currentMessages[currentMessages.length - 1];
374
+ if (
375
+ lastMessage &&
376
+ lastMessage.role === "assistant" &&
377
+ String(lastMessage.id || "") === messageId
378
+ ) {
379
+ return {
380
+ ...currentMap,
381
+ [chunkSessionKey]: [
382
+ ...currentMessages.slice(0, -1),
383
+ {
384
+ ...lastMessage,
385
+ content: `${String(lastMessage.content || "")}${chunkText}`,
386
+ debugPayload: {
387
+ ...(lastMessage?.debugPayload || {}),
388
+ source: "stream",
389
+ messageId,
390
+ sessionKey: chunkSessionKey,
391
+ chunkCount:
392
+ Number(lastMessage?.debugPayload?.chunkCount || 1) + 1,
393
+ lastChunk: chunkText,
394
+ },
395
+ },
396
+ ],
397
+ };
398
+ }
399
+ return {
400
+ ...currentMap,
401
+ [chunkSessionKey]: [
402
+ ...currentMessages,
403
+ {
404
+ id: messageId,
405
+ role: "assistant",
406
+ content: chunkText,
407
+ createdAt: Date.now(),
408
+ debugPayload: {
409
+ source: "stream",
410
+ messageId,
411
+ sessionKey: chunkSessionKey,
412
+ chunkCount: 1,
413
+ lastChunk: chunkText,
414
+ },
415
+ },
416
+ ],
417
+ };
418
+ });
419
+ return;
420
+ }
421
+
422
+ if (payload.type === "tool") {
423
+ const toolSessionKey = String(
424
+ payload.sessionKey || selectedSessionKeyRef.current || "",
425
+ );
426
+ if (!toolSessionKey) return;
427
+ const toolPhase = String(payload.phase || "").toLowerCase();
428
+ const toolCall =
429
+ payload?.toolCall && typeof payload.toolCall === "object"
430
+ ? payload.toolCall
431
+ : null;
432
+ const toolResult =
433
+ payload?.toolResult && typeof payload.toolResult === "object"
434
+ ? payload.toolResult
435
+ : null;
436
+ const toolCallId = String(
437
+ toolCall?.id || toolResult?.toolCallId || payload?.toolCallId || "",
438
+ );
439
+ const toolTimestamp = Number(payload.timestamp) || Date.now();
440
+ setMessagesBySession((currentMap) => {
441
+ const currentMessages = currentMap[toolSessionKey] || [];
442
+ if (toolPhase === "result") {
443
+ let matched = false;
444
+ const nextMessages = currentMessages.map((messageRow) => {
445
+ if (matched || messageRow.role !== "tool") return messageRow;
446
+ const messageToolCalls = extractToolCallsFromPayload(
447
+ messageRow.debugPayload,
448
+ );
449
+ const messageToolCallId = String(messageToolCalls?.[0]?.id || "");
450
+ const messageToolName = String(messageToolCalls?.[0]?.name || "");
451
+ const resultToolName = String(toolResult?.toolName || "");
452
+ const hasResultAlready = Boolean(
453
+ normalizeToolResult(messageRow?.debugPayload?.toolResult),
454
+ );
455
+ const shouldMatchById =
456
+ toolCallId && messageToolCallId && messageToolCallId === toolCallId;
457
+ const shouldMatchByNameFallback =
458
+ !toolCallId &&
459
+ !messageToolCallId &&
460
+ resultToolName &&
461
+ messageToolName === resultToolName &&
462
+ !hasResultAlready;
463
+ if (!shouldMatchById && !shouldMatchByNameFallback) {
464
+ return messageRow;
465
+ }
466
+ matched = true;
467
+ return {
468
+ ...messageRow,
469
+ debugPayload: {
470
+ ...(messageRow.debugPayload || {}),
471
+ toolResult: toolResult || null,
472
+ rawEvent: payload?.rawEvent || null,
473
+ },
474
+ };
475
+ });
476
+ if (matched) {
477
+ return {
478
+ ...currentMap,
479
+ [toolSessionKey]: nextMessages,
480
+ };
481
+ }
482
+ }
483
+ if (toolPhase === "call" && toolCall) {
484
+ const duplicateCall = currentMessages.some((messageRow) => {
485
+ if (messageRow.role !== "tool") return false;
486
+ const existingCall = extractToolCallsFromPayload(
487
+ messageRow.debugPayload,
488
+ )[0];
489
+ if (!existingCall) return false;
490
+ const existingId = String(existingCall?.id || "");
491
+ if (toolCallId && existingId && existingId === toolCallId) {
492
+ return true;
493
+ }
494
+ const existingName = String(existingCall?.name || "");
495
+ const incomingName = String(toolCall?.name || "");
496
+ return !toolCallId && existingName && incomingName && existingName === incomingName;
497
+ });
498
+ if (duplicateCall) return currentMap;
499
+ return {
500
+ ...currentMap,
501
+ [toolSessionKey]: [
502
+ ...currentMessages,
503
+ buildToolMessage({
504
+ toolCall,
505
+ createdAt: toolTimestamp,
506
+ debugPayload: {
507
+ timestamp: toolTimestamp,
508
+ metadata: null,
509
+ rawMessage: null,
510
+ toolCalls: [toolCall],
511
+ toolResult: null,
512
+ rawEvent: payload?.rawEvent || null,
513
+ },
514
+ }),
515
+ ],
516
+ };
517
+ }
518
+ if (toolPhase === "result" && toolResult) {
519
+ return {
520
+ ...currentMap,
521
+ [toolSessionKey]: [
522
+ ...currentMessages,
523
+ buildToolMessage({
524
+ toolCall: toolCall || {
525
+ id: String(toolResult?.toolCallId || ""),
526
+ name: String(toolResult?.toolName || "unknown"),
527
+ arguments: null,
528
+ partialJson: "",
529
+ },
530
+ toolResult,
531
+ createdAt: toolTimestamp,
532
+ debugPayload: {
533
+ timestamp: toolTimestamp,
534
+ metadata: null,
535
+ rawMessage: null,
536
+ toolCalls: toolCall ? [toolCall] : [],
537
+ toolResult,
538
+ rawEvent: payload?.rawEvent || null,
539
+ },
540
+ }),
541
+ ],
542
+ };
543
+ }
544
+ return currentMap;
545
+ });
546
+ return;
547
+ }
548
+
549
+ if (payload.type === "started") {
550
+ const nextSessionKey = String(
551
+ payload.sessionKey || selectedSessionKeyRef.current || "",
552
+ );
553
+ const runId = String(payload.runId || "");
554
+ if (!nextSessionKey || !runId) return;
555
+ setActiveRunBySession((currentMap) => ({
556
+ ...currentMap,
557
+ [nextSessionKey]: runId,
558
+ }));
559
+ return;
560
+ }
561
+
562
+ if (payload.type === "done") {
563
+ const doneSessionKey = String(
564
+ payload.sessionKey || selectedSessionKeyRef.current || "",
565
+ );
566
+ if (doneSessionKey) {
567
+ setActiveRunBySession((currentMap) => {
568
+ const nextMap = { ...currentMap };
569
+ delete nextMap[doneSessionKey];
570
+ return nextMap;
571
+ });
572
+ }
573
+ setSending(false);
574
+ setStreaming(false);
575
+ setHistoryLoading(false);
576
+ if (doneSessionKey && ws && ws.readyState === 1) {
577
+ setHistoryLoading(true);
578
+ appendDebugEvent(doneSessionKey, "ws:history-request-after-done", {
579
+ type: "history",
580
+ sessionKey: doneSessionKey,
581
+ });
582
+ ws.send(
583
+ JSON.stringify({
584
+ type: "history",
585
+ sessionKey: doneSessionKey,
586
+ }),
587
+ );
588
+ }
589
+ return;
590
+ }
591
+
592
+ if (payload.type === "error") {
593
+ setSending(false);
594
+ setStreaming(false);
595
+ setHistoryLoading(false);
596
+ const errorSessionKey = String(
597
+ payload.sessionKey || selectedSessionKeyRef.current || "",
598
+ );
599
+ if (errorSessionKey) {
600
+ setActiveRunBySession((currentMap) => {
601
+ const nextMap = { ...currentMap };
602
+ delete nextMap[errorSessionKey];
603
+ return nextMap;
604
+ });
605
+ setMessagesBySession((currentMap) => ({
606
+ ...currentMap,
607
+ [errorSessionKey]: [
608
+ ...(currentMap[errorSessionKey] || []),
609
+ buildMessage({
610
+ role: "assistant",
611
+ content:
612
+ String(payload.message || "").trim() ||
613
+ "Something went wrong.",
614
+ }),
615
+ ],
616
+ }));
617
+ }
618
+ if (payload.message) showToast(String(payload.message), "error");
619
+ }
620
+ };
621
+ };
622
+
623
+ connect();
624
+
625
+ return () => {
626
+ mounted = false;
627
+ if (reconnectTimerRef.current) {
628
+ clearTimeout(reconnectTimerRef.current);
629
+ }
630
+ const ws = wsRef.current;
631
+ wsRef.current = null;
632
+ if (ws) ws.close();
633
+ };
634
+ }, []);
635
+
636
+ useEffect(() => {
637
+ if (!selectedSessionKey) return;
638
+ const ws = wsRef.current;
639
+ if (ws && ws.readyState === 1) {
640
+ setHistoryLoading(true);
641
+ appendDebugEvent(selectedSessionKey, "ws:history-request", {
642
+ type: "history",
643
+ sessionKey: selectedSessionKey,
644
+ });
645
+ ws.send(
646
+ JSON.stringify({
647
+ type: "history",
648
+ sessionKey: selectedSessionKey,
649
+ }),
650
+ );
651
+ return;
652
+ }
653
+ // Fallback for environments where websocket upgrade is unavailable:
654
+ // load history over HTTP so the UI can still show prior messages.
655
+ let cancelled = false;
656
+ const loadHistory = async () => {
657
+ try {
658
+ setHistoryLoading(true);
659
+ const response = await authFetch(
660
+ `/api/chat/history?sessionKey=${encodeURIComponent(selectedSessionKey)}`,
661
+ );
662
+ const payload = await response.json();
663
+ if (cancelled) return;
664
+ if (!response.ok || payload?.ok === false) {
665
+ throw new Error(payload?.error || "Could not load chat history");
666
+ }
667
+ appendDebugEvent(selectedSessionKey, "http:history-response", payload);
668
+ const historyMessages = (
669
+ Array.isArray(payload.messages) ? payload.messages : []
670
+ )
671
+ .map((messageRow) =>
672
+ buildMessage({
673
+ role: String(messageRow?.role || "assistant"),
674
+ content: String(messageRow?.content || ""),
675
+ createdAt: Number(messageRow?.timestamp) || Date.now(),
676
+ debugPayload: messageRow || null,
677
+ }),
678
+ )
679
+ .filter(
680
+ (messageRow) =>
681
+ String(messageRow.content || "").trim() ||
682
+ extractToolCallsFromPayload(messageRow?.debugPayload).length > 0,
683
+ );
684
+ setMessagesBySession((currentMap) => ({
685
+ ...currentMap,
686
+ [selectedSessionKey]: historyMessages,
687
+ }));
688
+ setRawHistoryBySession((currentMap) => ({
689
+ ...currentMap,
690
+ [selectedSessionKey]: payload.rawHistory || null,
691
+ }));
692
+ if (!isConnected) {
693
+ // If HTTP history works while WS is down, stop noisy reconnect loops.
694
+ realtimeDisabledRef.current = true;
695
+ if (reconnectTimerRef.current) {
696
+ clearTimeout(reconnectTimerRef.current);
697
+ reconnectTimerRef.current = null;
698
+ }
699
+ const ws = wsRef.current;
700
+ if (ws) ws.close();
701
+ setConnectionError("Realtime unavailable; using HTTP fallback.");
702
+ }
703
+ } catch (err) {
704
+ if (cancelled) return;
705
+ const errorMessage = err.message || "Could not load chat history.";
706
+ appendDebugEvent(selectedSessionKey, "http:history-error", {
707
+ error: errorMessage,
708
+ });
709
+ if (
710
+ errorMessage.toLowerCase().includes("runtime unavailable") ||
711
+ errorMessage.toLowerCase().includes("websocket unavailable")
712
+ ) {
713
+ realtimeDisabledRef.current = true;
714
+ if (reconnectTimerRef.current) {
715
+ clearTimeout(reconnectTimerRef.current);
716
+ reconnectTimerRef.current = null;
717
+ }
718
+ const ws = wsRef.current;
719
+ if (ws) ws.close();
720
+ setConnectionError(
721
+ "Chat runtime unavailable (missing server dependency).",
722
+ );
723
+ } else {
724
+ setConnectionError(errorMessage);
725
+ }
726
+ } finally {
727
+ if (!cancelled) setHistoryLoading(false);
728
+ }
729
+ };
730
+ loadHistory();
731
+ return () => {
732
+ cancelled = true;
733
+ };
734
+ }, [isConnected, selectedSessionKey]);
735
+
736
+ const handleThreadScroll = useCallback(() => {
737
+ const threadElement = threadRef.current;
738
+ if (!threadElement) return;
739
+ const distanceFromBottom =
740
+ threadElement.scrollHeight -
741
+ threadElement.scrollTop -
742
+ threadElement.clientHeight;
743
+ shouldAutoScrollRef.current =
744
+ distanceFromBottom <= kAutoscrollBottomThresholdPx;
745
+ }, []);
746
+
747
+ useEffect(() => {
748
+ const threadElement = threadRef.current;
749
+ if (!threadElement) return;
750
+ if (!shouldAutoScrollRef.current) return;
751
+ threadElement.scrollTop = threadElement.scrollHeight;
752
+ }, [messages, historyLoading, streaming]);
753
+
754
+ const handleDraftInput = useCallback(
755
+ (event) => {
756
+ const nextValue = String(event?.target?.value || "");
757
+ setDraft(nextValue);
758
+ if (!selectedSessionKey) return;
759
+ setDraftBySession((currentMap) => ({
760
+ ...currentMap,
761
+ [selectedSessionKey]: nextValue,
762
+ }));
763
+ },
764
+ [selectedSessionKey],
765
+ );
766
+
767
+ const handleSend = useCallback(() => {
768
+ const messageText = String(draft || "").trim();
769
+ const ws = wsRef.current;
770
+ if (!messageText || !selectedSessionKey || sending || streaming) return;
771
+ if (!ws || ws.readyState !== 1) {
772
+ showToast(
773
+ "Chat websocket is unavailable in this environment.",
774
+ "warning",
775
+ );
776
+ return;
777
+ }
778
+
779
+ const userMessage = buildMessage({
780
+ role: "user",
781
+ content: messageText,
782
+ debugPayload: {
783
+ source: "composer",
784
+ type: "message",
785
+ content: messageText,
786
+ sessionKey: selectedSessionKey,
787
+ },
788
+ });
789
+ setDraft("");
790
+ setDraftBySession((currentMap) => ({
791
+ ...currentMap,
792
+ [selectedSessionKey]: "",
793
+ }));
794
+ setSending(true);
795
+ setMessagesBySession((currentMap) => ({
796
+ ...currentMap,
797
+ [selectedSessionKey]: [
798
+ ...(currentMap[selectedSessionKey] || []),
799
+ userMessage,
800
+ ],
801
+ }));
802
+ setStreaming(true);
803
+ ws.send(
804
+ JSON.stringify({
805
+ type: "message",
806
+ content: messageText,
807
+ sessionKey: selectedSessionKey,
808
+ }),
809
+ );
810
+ appendDebugEvent(selectedSessionKey, "ws:message-request", {
811
+ type: "message",
812
+ content: messageText,
813
+ sessionKey: selectedSessionKey,
814
+ });
815
+ }, [appendDebugEvent, draft, selectedSessionKey, sending, streaming]);
816
+
817
+ const handleStop = useCallback(() => {
818
+ const ws = wsRef.current;
819
+ if (!ws || ws.readyState !== 1 || !selectedSessionKey) return;
820
+ ws.send(
821
+ JSON.stringify({
822
+ type: "stop",
823
+ sessionKey: selectedSessionKey,
824
+ }),
825
+ );
826
+ appendDebugEvent(selectedSessionKey, "ws:stop-request", {
827
+ type: "stop",
828
+ sessionKey: selectedSessionKey,
829
+ });
830
+ setStreaming(false);
831
+ setSending(false);
832
+ }, [appendDebugEvent, selectedSessionKey]);
833
+
834
+ const rawHistory = selectedSessionKey
835
+ ? rawHistoryBySession[selectedSessionKey]
836
+ : null;
837
+ const debugEvents = selectedSessionKey
838
+ ? debugEventsBySession[selectedSessionKey] || []
839
+ : [];
840
+
841
+ return html`
842
+ <div class="chat-route-shell">
843
+ <div class="chat-route-header">
844
+ <div>
845
+ <div class="chat-route-title">Chat</div>
846
+ <div class="chat-route-subtitle">
847
+ ${selectedSession?.label || "Pick a session in the sidebar"}
848
+ </div>
849
+ ${connectionError
850
+ ? html`<div class="chat-route-warning">${connectionError}</div>`
851
+ : null}
852
+ </div>
853
+ </div>
854
+ <div class="chat-thread" ref=${threadRef} onscroll=${handleThreadScroll}>
855
+ ${!selectedSessionKey
856
+ ? html`<div class="chat-empty-state">
857
+ Select a session to begin chatting.
858
+ </div>`
859
+ : historyLoading
860
+ ? html`<div class="chat-empty-state">Loading history...</div>`
861
+ : messages.length === 0
862
+ ? html`<div class="chat-empty-state">
863
+ Start a message in this session.
864
+ </div>`
865
+ : messages.map(
866
+ (message) => html`
867
+ ${(() => {
868
+ const toolCalls = extractToolCallsFromPayload(
869
+ message.debugPayload,
870
+ );
871
+ const hasVisibleContent =
872
+ String(message.content || "").trim().length > 0;
873
+ const isToolMessage = message.role === "tool";
874
+ const shouldRenderContent =
875
+ hasVisibleContent &&
876
+ !isToolMessage &&
877
+ !(
878
+ toolCalls.length > 0 &&
879
+ String(message.content || "").startsWith(
880
+ "Tool calls:",
881
+ )
882
+ );
883
+ const primaryToolCall = toolCalls[0] || null;
884
+ const matchedResult = normalizeToolResult(
885
+ message?.debugPayload?.toolResult || null,
886
+ );
887
+ return html`
888
+ <div
889
+ key=${message.id}
890
+ class=${`chat-bubble ${message.role === "user" ? "is-user" : "is-assistant"}`}
891
+ >
892
+ ${!isToolMessage
893
+ ? html`
894
+ <div class="chat-bubble-meta">
895
+ <span
896
+ >${message.role === "user"
897
+ ? "You"
898
+ : "Agent"}</span
899
+ >
900
+ <span
901
+ >${formatChatTime(message.createdAt)}</span
902
+ >
903
+ </div>
904
+ `
905
+ : null}
906
+ ${isToolMessage && primaryToolCall
907
+ ? html`
908
+ <details class="chat-tool-inline-message">
909
+ <summary>
910
+ <span class="chat-tool-inline-icon"
911
+ >🛠️</span
912
+ >
913
+ <span class="chat-tool-inline-title"
914
+ >${String(
915
+ primaryToolCall?.name || "unknown",
916
+ )}</span
917
+ >
918
+ <span class="chat-tool-inline-time"
919
+ >${formatChatTime(
920
+ message.createdAt,
921
+ )}</span
922
+ >
923
+ </summary>
924
+ <div class="chat-tool-inline-body">
925
+ <div class="chat-tool-inline-label">
926
+ Payload
927
+ </div>
928
+ <pre>
929
+ ${JSON.stringify(
930
+ {
931
+ id:
932
+ String(primaryToolCall?.id || "") ||
933
+ null,
934
+ name:
935
+ String(
936
+ primaryToolCall?.name || "",
937
+ ) || null,
938
+ arguments:
939
+ primaryToolCall?.arguments || null,
940
+ partialJson:
941
+ String(
942
+ primaryToolCall?.partialJson ||
943
+ "",
944
+ ) || null,
945
+ },
946
+ null,
947
+ 2,
948
+ )}</pre
949
+ >
950
+ ${matchedResult
951
+ ? html`
952
+ <div class="chat-tool-inline-label">
953
+ Result${matchedResult.isError
954
+ ? " (error)"
955
+ : ""}
956
+ </div>
957
+ <pre>
958
+ ${JSON.stringify(
959
+ {
960
+ toolCallId:
961
+ matchedResult.toolCallId,
962
+ toolName:
963
+ matchedResult.toolName,
964
+ text: matchedResult.text || "",
965
+ isError: matchedResult.isError,
966
+ rawMessage:
967
+ matchedResult.rawMessage ||
968
+ null,
969
+ },
970
+ null,
971
+ 2,
972
+ )}</pre
973
+ >
974
+ `
975
+ : null}
976
+ </div>
977
+ </details>
978
+ `
979
+ : null}
980
+ ${shouldRenderContent
981
+ ? (() => {
982
+ const parsedJson = parseJsonMessage(
983
+ message.content,
984
+ );
985
+ if (parsedJson) {
986
+ return html`<pre
987
+ class="chat-bubble-content chat-bubble-json"
988
+ >
989
+ ${JSON.stringify(parsedJson, null, 2)}</pre
990
+ >`;
991
+ }
992
+ return html`
993
+ <div
994
+ class="chat-bubble-content chat-bubble-markdown"
995
+ dangerouslySetInnerHTML=${{
996
+ __html: renderMarkdownHtml(
997
+ message.content,
998
+ ),
999
+ }}
1000
+ ></div>
1001
+ `;
1002
+ })()
1003
+ : null}
1004
+ ${!isToolMessage
1005
+ ? html`
1006
+ <details class="chat-message-json">
1007
+ <summary>JSON</summary>
1008
+ <pre>
1009
+ ${JSON.stringify(
1010
+ message.debugPayload || {
1011
+ role: message.role,
1012
+ content: message.content,
1013
+ createdAt: message.createdAt,
1014
+ },
1015
+ null,
1016
+ 2,
1017
+ )}</pre
1018
+ >
1019
+ </details>
1020
+ `
1021
+ : null}
1022
+ </div>
1023
+ `;
1024
+ })()}
1025
+ `,
1026
+ )}
1027
+ ${selectedSessionKey && (sending || streaming)
1028
+ ? html`
1029
+ <div class="chat-bubble is-assistant chat-typing-indicator">
1030
+ <div class="chat-bubble-meta">
1031
+ <span>Agent</span>
1032
+ <span>${isConnected ? "typing..." : "reconnecting..."}</span>
1033
+ </div>
1034
+ <div class="chat-typing-dots">
1035
+ <span></span><span></span><span></span>
1036
+ </div>
1037
+ </div>
1038
+ `
1039
+ : null}
1040
+ ${selectedSessionKey
1041
+ ? chatDebugEnabled
1042
+ ? html`
1043
+ <details class="chat-raw-debug">
1044
+ <summary>Raw history JSON</summary>
1045
+ <pre>${JSON.stringify(rawHistory || null, null, 2)}</pre>
1046
+ </details>
1047
+ <details class="chat-raw-debug">
1048
+ <summary>Inbound event log</summary>
1049
+ <pre>${JSON.stringify(debugEvents, null, 2)}</pre>
1050
+ </details>
1051
+ `
1052
+ : null
1053
+ : null}
1054
+ </div>
1055
+ <div class="chat-composer">
1056
+ <textarea
1057
+ class="chat-composer-input"
1058
+ placeholder=${selectedSessionKey
1059
+ ? "Type a message..."
1060
+ : "Select a session to start"}
1061
+ value=${draft}
1062
+ disabled=${!selectedSessionKey || sending || !isConnected}
1063
+ oninput=${handleDraftInput}
1064
+ ></textarea>
1065
+ <div class="chat-composer-actions">
1066
+ ${streaming
1067
+ ? html`
1068
+ <button
1069
+ type="button"
1070
+ class="ac-btn-secondary chat-composer-stop"
1071
+ disabled=${!isConnected}
1072
+ onclick=${handleStop}
1073
+ >
1074
+ Stop
1075
+ </button>
1076
+ `
1077
+ : null}
1078
+ <button
1079
+ type="button"
1080
+ class="ac-btn-cyan chat-composer-send"
1081
+ disabled=${!selectedSessionKey ||
1082
+ sending ||
1083
+ streaming ||
1084
+ !isConnected ||
1085
+ !String(draft || "").trim()}
1086
+ onclick=${handleSend}
1087
+ >
1088
+ ${sending || streaming ? "Sending..." : "Send"}
1089
+ </button>
1090
+ </div>
1091
+ </div>
1092
+ </div>
1093
+ `;
1094
+ };