@arcote.tech/arc-chat 0.7.20 → 0.7.22

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.
@@ -1,40 +1,51 @@
1
- import { useState, useCallback, useMemo, useRef, useEffect, type ComponentType, createElement, type ReactNode } from "react";
2
- import type { AssistantContentBlock, ChatStreamEvent, ArcToolAny } from "@arcote.tech/arc-ai";
1
+ import {
2
+ useState,
3
+ useCallback,
4
+ useMemo,
5
+ useEffect,
6
+ type ComponentType,
7
+ createElement,
8
+ type ReactNode,
9
+ } from "react";
10
+ import type { ArcToolAny } from "@arcote.tech/arc-ai";
3
11
  import type {
4
12
  ChatInputTextareaSlotProps,
5
13
  ChatLabels,
6
- ChatMessageData,
7
14
  SendMessageOptions,
8
15
  } from "@arcote.tech/arc-ds";
9
- import { Chat, ChatMessage, ChatInputProvider, ChatLabelsProvider, ChatToolLog, useChatLabels } from "@arcote.tech/arc-ds";
16
+ import {
17
+ Chat,
18
+ ChatMessage,
19
+ ChatInputProvider,
20
+ ChatLabelsProvider,
21
+ ChatToolLog,
22
+ useChatLabels,
23
+ } from "@arcote.tech/arc-ds";
10
24
  import { VoiceTextarea } from "@arcote.tech/arc-ai-voice";
11
-
12
- // ─── Debug / instrumentation ─────────────────────────────────────────────
13
- // Production-only chat hang: SSE `isStreaming` and DB `isGenerating` can
14
- // desync (separate channels, prod latency). We log when the reconcile
15
- // watchdog has to force-close a turn the DB already finished. Kept dependency-
16
- // free (no OTel import — this fragment is src-only and bundles into any app):
17
- // the host app can pipe these to its telemetry via `globalThis.__arcChatOnStuck`.
18
- function reportReconcileStuck(attrs: Record<string, unknown>): void {
19
- // eslint-disable-next-line no-console
20
- console.warn("[arc-chat][reconcile_stuck]", attrs);
21
- try {
22
- (globalThis as any).__arcChatOnStuck?.(attrs);
23
- } catch {
24
- /* reporter must never break the chat */
25
- }
26
- }
27
-
28
- /** Verbose per-message timing, gated behind `globalThis.__ARC_CHAT_DEBUG`.
29
- * Used to see the SSE-vs-DB channel skew (who arrives first, delta-t). */
30
- function chatDebug(...args: unknown[]): void {
31
- if ((globalThis as any).__ARC_CHAT_DEBUG) {
32
- const ts =
33
- typeof performance !== "undefined" ? performance.now().toFixed(0) : "";
34
- // eslint-disable-next-line no-console
35
- console.debug(`[arc-chat +${ts}ms]`, ...args);
36
- }
37
- }
25
+ import {
26
+ deriveTimeline,
27
+ isPendingSendSettled,
28
+ type PendingSend,
29
+ type TimelineItem,
30
+ } from "./derive-timeline";
31
+ import { useAssistantOverlays } from "./use-assistant-overlays";
32
+
33
+ // ─── Architektura ────────────────────────────────────────────────
34
+ //
35
+ // Timeline jest CZYSTĄ FUNKCJĄ (deriveTimeline) dwóch źródeł:
36
+ // - DB przez liveQuery `getByScope` — źródło prawdy o strukturze
37
+ // (wiadomości, finalne blocks, isGenerating/interrupted/error,
38
+ // tool_results),
39
+ // - overlayów SSE (useAssistantOverlays) — ulotny in-progress stan
40
+ // generujących rzędów, budowany shared reducerem.
41
+ // Plus lokalny optimistic state (pendingSends / pendingToolResults /
42
+ // localErrors).
43
+ //
44
+ // NIE pisz do timeline'u imperatywnie. Każdy "merge" dwóch kanałów przez
45
+ // mutowalny stan + flagę trybu kończył się tu produkcyjnymi race'ami
46
+ // (watchdogi, backstopy, nonce — patrz git log). Derywacja liczy się
47
+ // zawsze od najnowszego stanu obu źródeł, więc kolejność dostarczenia
48
+ // (SSE `done` vs flip `isGenerating` w DB) jest bez znaczenia.
38
49
 
39
50
  interface ChatComponentConfig {
40
51
  chatName: string;
@@ -67,28 +78,6 @@ interface ChatComponentConfig {
67
78
  footer?: ReactNode;
68
79
  }
69
80
 
70
- type ToolStatus = "pending" | "executing" | "complete" | "error";
71
-
72
- type TimelineItem =
73
- | { type: "message"; id: string; role: "user" | "assistant"; content: string; isStreaming?: boolean }
74
- | {
75
- type: "tool";
76
- id: string; // = toolCallId, stabilne pomiędzy SSE i DB rebuild
77
- toolCallId: string;
78
- toolName: string;
79
- params: Record<string, unknown>;
80
- result?: unknown;
81
- status: ToolStatus;
82
- /** Legacy compat — true gdy status !== complete. Renderowane przez stary ChatToolLog. */
83
- calling: boolean;
84
- error?: string;
85
- }
86
- | {
87
- type: "interrupted";
88
- id: string;
89
- messageId: string;
90
- };
91
-
92
81
  export function createChatComponent(
93
82
  config: ChatComponentConfig,
94
83
  ): ComponentType<{ scope: any; identifyBy: string }> {
@@ -104,36 +93,29 @@ export function createChatComponent(
104
93
  footer,
105
94
  } = config;
106
95
  const toolsMap = new Map(tools.map((t) => [t.name, t]));
107
-
108
- function ChatComponentInner({ scope, identifyBy }: { scope: any; identifyBy: string }) {
96
+ const isServerTool = (toolName: string) =>
97
+ toolsMap.get(toolName)?.isServerTool === true;
98
+
99
+ function ChatComponentInner({
100
+ scope,
101
+ identifyBy,
102
+ }: {
103
+ scope: any;
104
+ identifyBy: string;
105
+ }) {
109
106
  const chatLabels = useChatLabels();
110
- const [timeline, setTimeline] = useState<TimelineItem[]>([]);
111
- const [isStreaming, setIsStreaming] = useState(false);
112
- /** Set messageIds dla których SSE zwrócił 410 (server restart mid-stream).
113
- * Renderowane jako TimelineItem "interrupted" z retry button. */
114
- const [interruptedIds, setInterruptedIds] = useState<Set<string>>(() => new Set());
115
- /** Bumped by the reconcile watchdog to FORCE a timeline rebuild even when
116
- * `historySig` already changed (during streaming) and won't change again.
117
- * See the watchdog effect below. */
118
- const [reconcileNonce, setReconcileNonce] = useState(0);
119
- /** Bumped by the visibility / heartbeat handlers to force the SSE useEffect
120
- * to re-fire (abort the dead reader and re-fetch with the same
121
- * `messageId`). Backend's `FINALIZE_GRACE_MS` (5 s) + `init` snapshot
122
- * recovers the in-progress state; 410 → existing retry/interrupted path. */
123
- const [streamReopenNonce, setStreamReopenNonce] = useState(0);
124
- /** AbortController of the currently-open SSE stream, exposed via ref so the
125
- * watchdog can tear it down when the DB says the turn is already done. */
126
- const sseCtrlRef = useRef<AbortController | null>(null);
127
- /** Last SSE event type seen (for stuck telemetry — was `done` ever sent?). */
128
- const lastSseEventRef = useRef<string | null>(null);
129
- /** Timestamp (performance.now()) of the last SSE byte received — set on
130
- * every `reader.read()` resolve (data + keepalive ping). Drives the
131
- * client-side heartbeat watchdog: if nothing arrived for >2× the server
132
- * keepalive interval, treat the socket as dead and reconnect. */
133
- const lastSseByteAtRef = useRef<number | null>(null);
134
- /** When the (isStreaming=true ∧ DB-idle) divergence first appeared — for
135
- * the debounce window and the `waitedMs` stuck attribute. */
136
- const stuckSinceRef = useRef<number | null>(null);
107
+
108
+ // ─── Lokalny optimistic state ─────────────────────────────────
109
+ const [pendingSends, setPendingSends] = useState<PendingSend[]>([]);
110
+ const [pendingToolResults, setPendingToolResults] = useState<
111
+ Map<string, unknown>
112
+ >(() => new Map());
113
+ const [localErrors, setLocalErrors] = useState<
114
+ Array<{ id: string; content: string }>
115
+ >([]);
116
+ /** Wymusza re-derywację co interwał gdy busy klauzule busy mają
117
+ * staleness cutoff, który musi móc wygasnąć bez zmiany danych. */
118
+ const [busyTick, setBusyTick] = useState(0);
137
119
 
138
120
  const queries = scope.useQuery();
139
121
  const mutations = scope.useMutation();
@@ -141,740 +123,95 @@ export function createChatComponent(
141
123
  const messageMutations = mutations[messageElementName];
142
124
 
143
125
  const scopeId = identifyBy;
144
- const historyResult = scopeId && messageQueries?.getByScope
145
- ? messageQueries.getByScope({ scopeId })
146
- : [undefined, false];
126
+ const historyResult =
127
+ scopeId && messageQueries?.getByScope
128
+ ? messageQueries.getByScope({ scopeId })
129
+ : [undefined, false];
147
130
  const historyData = historyResult?.[0];
148
- const historyLen = historyData?.length ?? 0;
149
131
 
150
- // Stable signature of all messages `[id]:[isGenerating]:[hasBlocks]:[contentLen]`.
151
- // Changes on insert AND on partial update (e.g. ctx.modify flipping
152
- // isGenerating to false), so the timeline-rebuild effect refires for
153
- // both cases. `historyLen` alone misses updates that don't change count.
154
- const historySig = useMemo(
132
+ // ─── Overlaye SSE per generujący row ──────────────────────────
133
+ const generatingIds = useMemo<string[]>(
155
134
  () =>
156
- historyData
157
- ?.map(
135
+ (historyData ?? [])
136
+ .filter(
158
137
  (m: any) =>
159
- `${m._id}:${m.isGenerating ? 1 : 0}:${m.blocks ? "f" : "e"}:${m.content?.length ?? 0}`,
138
+ m.role === "assistant" && m.isGenerating && !m.interrupted,
160
139
  )
161
- .join("|") ?? "",
140
+ .map((m: any) => m._id as string),
162
141
  [historyData],
163
142
  );
143
+ const overlays = useAssistantOverlays(chatName, generatingIds);
164
144
 
165
- /** ID assistant rowa, którego currently otwarty stream subskrybujemy.
166
- * Wyciąga się z DB historii — jest max jeden taki w danej chwili
167
- * (listener sekwencyjny). Null gdy nie ma czego streamować. */
168
- const activeGeneratingMessageId = useMemo<string | null>(() => {
169
- if (!historyData) return null;
170
- for (const msg of historyData) {
171
- if (
172
- msg.role === "assistant" &&
173
- msg.isGenerating &&
174
- !interruptedIds.has(msg._id)
175
- ) {
176
- return msg._id;
177
- }
178
- }
179
- return null;
180
- }, [historyData, interruptedIds]);
145
+ // ─── Derywacja ────────────────────────────────────────────────
146
+ const derived = useMemo(
147
+ () =>
148
+ deriveTimeline({
149
+ history: historyData,
150
+ overlays,
151
+ pendingSends,
152
+ pendingToolResults,
153
+ isServerTool,
154
+ now: Date.now(),
155
+ }),
156
+ [historyData, overlays, pendingSends, pendingToolResults, busyTick],
157
+ );
181
158
 
182
- // ─── Restore timeline from DB history ───────────────────────
183
- // Podczas streamingu SSE jest źródłem prawdy dla aktualnie generowanej
184
- // wiadomości — rebuild byłby kolizją. Po `done` klient ustawia
185
- // `isStreaming=false` i ten useEffect refireuje — zaaplikuje final
186
- // blocks z `assistantTurnCompleted` projection (jedyny moment gdy treść
187
- // assistant'a ląduje w DB).
188
159
  useEffect(() => {
189
- if (isStreaming) return;
190
- if (!historyData || historyLen === 0) return;
191
-
192
- const resultIds = new Set<string>();
193
- const resultMap = new Map<string, { content: string; isError?: boolean }>();
194
- for (const msg of historyData) {
195
- if (msg.role === "tool_result" && msg.toolCallId) {
196
- resultIds.add(msg.toolCallId);
197
- resultMap.set(msg.toolCallId, { content: msg.content, isError: msg.isError });
198
- }
199
- }
200
-
201
- const items: TimelineItem[] = [];
202
-
203
- for (const msg of historyData) {
204
- // System messages are developer-injected priming prompts. They go
205
- // to the LLM via buildHistory() but must not appear in the user's
206
- // chat timeline.
207
- if (msg.role === "system") continue;
208
-
209
- if (msg.role === "user") {
210
- items.push({
211
- type: "message",
212
- id: msg._id,
213
- role: "user",
214
- content: msg.content ?? "",
215
- });
216
- continue;
217
- }
218
-
219
- if (msg.role === "assistant") {
220
- const renderBlocks = (
221
- blocks: AssistantContentBlock[],
222
- options: { isStreaming?: boolean } = {},
223
- ) => {
224
- let textCount = 0;
225
- for (const block of blocks) {
226
- if (block.type === "text") {
227
- if (block.text) {
228
- items.push({
229
- type: "message",
230
- id: `${msg._id}_t${textCount}`,
231
- role: "assistant",
232
- content: block.text,
233
- isStreaming: options.isStreaming,
234
- });
235
- }
236
- textCount++;
237
- } else {
238
- const result = resultMap.get(block.id);
239
- const hasResult = resultIds.has(block.id);
240
- // Brak result w DB = tool wciąż w toku (server: executing,
241
- // interactive: pending). `calling: true` w obu utrzymuje loader/input override.
242
- const status: ToolStatus = result?.isError
243
- ? "error"
244
- : hasResult
245
- ? "complete"
246
- : options.isStreaming
247
- ? "executing"
248
- : "pending";
249
- items.push({
250
- type: "tool",
251
- id: block.id,
252
- toolCallId: block.id,
253
- toolName: block.name,
254
- params: block.arguments,
255
- result: result ? tryParseJson(result.content) : undefined,
256
- status,
257
- calling: !hasResult,
258
- error: result?.isError ? result.content : undefined,
259
- });
260
- }
261
- }
262
- };
263
-
264
- // Open turn (in progress). The row exists w DB z `isGenerating: true`
265
- // ale BEZ `blocks` — final treść ląduje w DB dopiero po
266
- // `assistantTurnCompleted`. Live wartość jest in-memory w stream-registry
267
- // i zostanie pobrana przez SSE `init` event.
268
- if (msg.isGenerating) {
269
- if (interruptedIds.has(msg._id)) {
270
- items.push({
271
- type: "interrupted",
272
- id: `${msg._id}_interrupted`,
273
- messageId: msg._id,
274
- });
275
- } else {
276
- items.push({
277
- type: "message",
278
- id: `${msg._id}_t0`,
279
- role: "assistant",
280
- content: "",
281
- isStreaming: true,
282
- });
283
- }
284
- continue;
285
- }
286
-
287
- // Closed turn — render z final blocks.
288
- const blocks =
289
- (tryParseJson(msg.blocks ?? "") as AssistantContentBlock[]) ?? [];
290
- renderBlocks(blocks);
291
- }
292
- }
293
-
294
- setTimeline(items);
295
- // Deps: `historySig` + `interruptedIds` + `reconcileNonce`. Wciąż BEZ
296
- // `isStreaming` — gdyby było w deps: po `done` SSE flips isStreaming=false →
297
- // refire → może zobaczyć STARĄ `isGenerating:1` (DB jeszcze nie
298
- // zaprojektowała assistantTurnCompleted) → reset do streaming → caret nie
299
- // znika. `reconcileNonce` (bumpowany przez watchdog) wymusza rebuild w
300
- // odwrotnym race (query-update wyprzedził SSE `done` → historySig zmienił
301
- // się PODCZAS isStreaming=true → rebuild pominięty guardem i bez nonce
302
- // nigdy nie nadrobiony → produkcyjny hang).
303
- }, [historySig, interruptedIds, reconcileNonce]);
160
+ if (!derived.busy) return;
161
+ const id = setInterval(() => setBusyTick((t) => t + 1), 10_000);
162
+ return () => clearInterval(id);
163
+ }, [derived.busy]);
304
164
 
305
- // Verbose DB-side timing (gated) paired with the SSE-event logs above,
306
- // this shows the channel skew: who arrives first, and how far apart.
165
+ // ─── GC optimistic state potwierdzonego przez liveQuery ───────
307
166
  useEffect(() => {
308
- chatDebug("db update", {
309
- count: historyLen,
310
- activeGenerating: activeGeneratingMessageId,
167
+ setPendingSends((prev) => {
168
+ const next = prev.filter((p) => !isPendingSendSettled(historyData, p));
169
+ return next.length === prev.length ? prev : next;
311
170
  });
312
- }, [historySig, historyLen, activeGeneratingMessageId]);
313
-
314
- // ─── Reconcile watchdog — DB is the arbiter of "is a turn running" ──
315
- // Production hang: SSE `done` and the DB `assistantTurnCompleted` projection
316
- // travel on separate channels (SSE vs WS query sync). When the query-update
317
- // (`isGenerating→false`) wins the race while `isStreaming` is still true — or
318
- // when `done` is lost entirely (410 exhaustion / server restart) — the
319
- // rebuild guard skips and never retries, so the chat sticks in a streaming
320
- // state until a page refresh.
321
- //
322
- // DB truth: `activeGeneratingMessageId === null` ⇒ no turn is generating. If
323
- // we still believe we're streaming after a short debounce, force-close the
324
- // turn: abort the (dead) SSE, drop `isStreaming`, and bump `reconcileNonce`
325
- // so the rebuild runs with the final DB blocks. The debounce avoids
326
- // flicker in the brief between-turns window (old turn done, next not started
327
- // yet). No false fires during a real stream — the DB then holds
328
- // `isGenerating:1`, so `activeGeneratingMessageId` is non-null.
329
- useEffect(() => {
330
- if (!(isStreaming && activeGeneratingMessageId === null)) {
331
- stuckSinceRef.current = null;
332
- return;
333
- }
334
- if (stuckSinceRef.current === null) stuckSinceRef.current = Date.now();
335
- const since = stuckSinceRef.current;
336
- const RECONCILE_DEBOUNCE_MS = 200;
337
- const timer = setTimeout(() => {
338
- const lastByteAt = lastSseByteAtRef.current;
339
- reportReconcileStuck({
340
- scopeId,
341
- chatName,
342
- lastSseEvent: lastSseEventRef.current,
343
- waitedMs: Date.now() - since,
344
- visibilityState:
345
- typeof document !== "undefined" ? document.visibilityState : null,
346
- hidden: typeof document !== "undefined" ? document.hidden : null,
347
- msSinceLastSseByte:
348
- lastByteAt !== null && typeof performance !== "undefined"
349
- ? Math.round(performance.now() - lastByteAt)
350
- : null,
351
- });
352
- sseCtrlRef.current?.abort();
353
- sseCtrlRef.current = null;
354
- stuckSinceRef.current = null;
355
- setIsStreaming(false);
356
- setReconcileNonce((n) => n + 1);
357
- }, RECONCILE_DEBOUNCE_MS);
358
- return () => clearTimeout(timer);
359
- }, [isStreaming, activeGeneratingMessageId, scopeId]);
360
-
361
- // ─── Backstop: merge interactive-tool answers regardless of isStreaming ──
362
- // Answering an interactive tool (askQuestions) creates a `tool_result` row
363
- // AND starts a new assistant turn — so `isStreaming` flips back to true and
364
- // the global rebuild guard freezes the whole timeline. Without this, the
365
- // answered question stays in input-view ("Odpowiedz na pytania" + the
366
- // questions) instead of the answer-view, until that next turn ends — and on
367
- // prod its SSE `done` is often lost (the turn finalizes after the DB
368
- // projection that nulls `activeGeneratingMessageId` aborts the stream), so
369
- // it never catches up. This flips any `calling` tool block to answer-view
370
- // the moment its `tool_result` lands in the DB, bypassing the guard.
371
- useEffect(() => {
372
- if (!historyData) return;
373
- const resultMap = new Map<string, { content: string; isError?: boolean }>();
374
- for (const msg of historyData) {
375
- if (msg.role === "tool_result" && msg.toolCallId) {
376
- resultMap.set(msg.toolCallId, {
377
- content: msg.content,
378
- isError: msg.isError,
379
- });
380
- }
381
- }
382
- if (resultMap.size === 0) return;
383
- setTimeline((prev) => {
171
+ setPendingToolResults((prev) => {
172
+ if (prev.size === 0) return prev;
384
173
  let changed = false;
385
- const next = prev.map((t) => {
386
- if (t.type !== "tool" || !t.calling) return t;
387
- const r = resultMap.get(t.toolCallId);
388
- if (!r) return t;
389
- changed = true;
390
- return {
391
- ...t,
392
- calling: false,
393
- status: (r.isError ? "error" : "complete") as ToolStatus,
394
- result: tryParseJson(r.content),
395
- error: r.isError ? r.content : undefined,
396
- };
397
- });
174
+ const next = new Map(prev);
175
+ for (const msg of historyData ?? []) {
176
+ if (
177
+ msg.role === "tool_result" &&
178
+ msg.toolCallId &&
179
+ next.has(msg.toolCallId)
180
+ ) {
181
+ next.delete(msg.toolCallId);
182
+ changed = true;
183
+ }
184
+ }
398
185
  return changed ? next : prev;
399
186
  });
400
- }, [historySig]);
401
-
402
- // ─── SSE event processing ───────────────────────────────────
403
- const processEventRef = useRef<((event: ChatStreamEvent) => void) | null>(null);
404
- const processEvent = useCallback(
405
- (event: ChatStreamEvent) => {
406
- lastSseEventRef.current = event.type;
407
- chatDebug("sse event", event.type);
408
- switch (event.type) {
409
- case "init": {
410
- // Pierwszy event po `subscribe(messageId)`. Niesie snapshot
411
- // in-memory `currentBlocks` — może być pusty (świeży stream) albo
412
- // wypełniony (graceful reconnect mid-stream). Hydratuje bubble
413
- // assistant'a.
414
- if (!event.messageId) break;
415
- const messageId = event.messageId;
416
- const blocks = event.currentBlocks ?? [];
417
- setTimeline((prev) => {
418
- // Wytnij placeholdery i istniejące bubble assistantów dla tego
419
- // messageId (z timeline rebuild) — zastąpimy świeżą wersją.
420
- const cleaned = prev.filter(
421
- (it) =>
422
- !(
423
- it.type === "message" &&
424
- typeof it.id === "string" &&
425
- it.id.startsWith(`${messageId}_t`)
426
- ) &&
427
- !(it.type === "tool" && blocks.some((b) => b.type === "tool_call" && b.id === it.id)),
428
- );
429
- let textCount = 0;
430
- const newItems: TimelineItem[] = [];
431
- for (const block of blocks) {
432
- if (block.type === "text") {
433
- newItems.push({
434
- type: "message",
435
- id: `${messageId}_t${textCount}`,
436
- role: "assistant",
437
- content: block.text,
438
- isStreaming: true,
439
- });
440
- textCount++;
441
- } else {
442
- newItems.push({
443
- type: "tool",
444
- id: block.id,
445
- toolCallId: block.id,
446
- toolName: block.name,
447
- params: block.arguments,
448
- status: "executing",
449
- calling: true,
450
- });
451
- }
452
- }
453
- // Jeśli init przyniósł 0 text blocks → wciąż dodaj pusty
454
- // streaming placeholder, żeby text_delta miał gdzie dolepić.
455
- if (!newItems.some((it) => it.type === "message" && it.role === "assistant")) {
456
- newItems.push({
457
- type: "message",
458
- id: `${messageId}_t0`,
459
- role: "assistant",
460
- content: "",
461
- isStreaming: true,
462
- });
463
- }
464
- return [...cleaned, ...newItems];
465
- });
466
- break;
467
- }
468
-
469
- case "text_delta":
470
- if (!event.textDelta) break;
471
- setTimeline((prev) => {
472
- const last = prev[prev.length - 1];
473
- const willAppend = !!(
474
- last &&
475
- last.type === "message" &&
476
- last.role === "assistant" &&
477
- last.isStreaming
478
- );
479
- if (willAppend) {
480
- return prev.map((item, i) =>
481
- i === prev.length - 1 && item.type === "message"
482
- ? { ...item, content: item.content + event.textDelta }
483
- : item,
484
- );
485
- }
486
- const newId = event.messageId
487
- ? `${event.messageId}_streaming_${prev.length}`
488
- : `assistant_${Date.now()}`;
489
- return [
490
- ...prev,
491
- {
492
- type: "message",
493
- id: newId,
494
- role: "assistant",
495
- content: event.textDelta!,
496
- isStreaming: true,
497
- },
498
- ];
499
- });
500
- break;
501
-
502
- case "tool_call_pending":
503
- // AI właśnie zaczyna tool call. Dla SERVER tools pokazujemy loader
504
- // "Przygotowuje: {name}…" od razu. Dla INTERACTIVE tools czekamy
505
- // na `interactive_tool_request` z pełnymi args.
506
- if (event.toolCallId) {
507
- const toolDef = event.toolCallName
508
- ? toolsMap.get(event.toolCallName)
509
- : undefined;
510
- if (toolDef && !toolDef.isServerTool) break;
511
- setTimeline((prev) => {
512
- if (prev.some((it) => it.type === "tool" && it.id === event.toolCallId)) {
513
- return prev;
514
- }
515
- const next = prev.map((item) =>
516
- item.type === "message" && item.isStreaming
517
- ? { ...item, isStreaming: false }
518
- : item,
519
- );
520
- next.push({
521
- type: "tool",
522
- id: event.toolCallId!,
523
- toolCallId: event.toolCallId!,
524
- toolName: event.toolCallName ?? "",
525
- params: {},
526
- status: "pending",
527
- calling: true,
528
- });
529
- return next;
530
- });
531
- }
532
- break;
533
-
534
- case "tool_call_arguments_delta":
535
- // Args lecą znak po znaku — UI nie potrzebuje tej granularności.
536
- break;
537
-
538
- case "tool_call_arguments_complete":
539
- // Pełne args dostępne. Trzy przypadki:
540
- // 1. Server tool już jest w timeline (od tool_call_pending) → update.
541
- // 2. Interactive tool jeszcze nie jest w timeline → ADD tutaj.
542
- // 3. Tool już dodany przez interactive_tool_request → no-op.
543
- if (event.toolCallId) {
544
- setTimeline((prev) => {
545
- const existing = prev.find(
546
- (it): it is Extract<TimelineItem, { type: "tool" }> =>
547
- it.type === "tool" && it.toolCallId === event.toolCallId,
548
- );
549
- if (existing) {
550
- return prev.map((item) =>
551
- item.type === "tool" && item.toolCallId === event.toolCallId
552
- ? {
553
- ...item,
554
- params: event.arguments ?? item.params,
555
- status: "executing",
556
- calling: true,
557
- toolName: event.toolCallName ?? item.toolName,
558
- }
559
- : item,
560
- );
561
- }
562
- const next = prev.map((item) =>
563
- item.type === "message" && item.isStreaming
564
- ? { ...item, isStreaming: false }
565
- : item,
566
- );
567
- next.push({
568
- type: "tool",
569
- id: event.toolCallId!,
570
- toolCallId: event.toolCallId!,
571
- toolName: event.toolCallName ?? "",
572
- params: event.arguments ?? {},
573
- status: "pending",
574
- calling: true,
575
- });
576
- return next;
577
- });
578
- }
579
- break;
580
-
581
- case "tool_call_executed":
582
- if (event.toolResult) {
583
- setTimeline((prev) =>
584
- prev.map((item) =>
585
- item.type === "tool" && item.toolCallId === event.toolResult!.toolCallId
586
- ? {
587
- ...item,
588
- result: tryParseJson(event.toolResult!.content),
589
- status: event.toolResult!.isError ? "error" : "complete",
590
- calling: false,
591
- error: event.toolResult!.isError ? event.toolResult!.content : undefined,
592
- }
593
- : item,
594
- ),
595
- );
596
- }
597
- break;
598
-
599
- case "interactive_tool_request":
600
- if (event.toolCalls) {
601
- setTimeline((prev) => {
602
- const byId = new Map(
603
- prev
604
- .filter((it) => it.type === "tool")
605
- .map((it) => [(it as any).id, it]),
606
- );
607
- const next = prev.map((item) =>
608
- item.type === "message" && item.isStreaming
609
- ? { ...item, isStreaming: false }
610
- : item,
611
- );
612
- for (const tc of event.toolCalls!) {
613
- const existing = byId.get(tc.id);
614
- if (existing) {
615
- const idx = next.findIndex(
616
- (it) => it.type === "tool" && (it as any).id === tc.id,
617
- );
618
- if (idx >= 0) {
619
- next[idx] = {
620
- ...(next[idx] as any),
621
- toolName: tc.name || (next[idx] as any).toolName,
622
- params: tc.arguments ?? (next[idx] as any).params,
623
- };
624
- }
625
- continue;
626
- }
627
- next.push({
628
- type: "tool",
629
- id: tc.id,
630
- toolCallId: tc.id,
631
- toolName: tc.name,
632
- params: tc.arguments ?? {},
633
- status: "pending",
634
- calling: true,
635
- });
636
- }
637
- return next;
638
- });
639
- }
640
- break;
187
+ }, [historyData]);
641
188
 
642
- case "done":
643
- // Stream zakończył turn. setIsStreaming(false) odpali timeline
644
- // rebuild z DB final blocks z `assistantTurnCompleted` projection
645
- // zastąpią streaming bubble.
646
- setTimeline((prev) =>
647
- prev.map((item) =>
648
- item.type === "message" && item.isStreaming
649
- ? { ...item, isStreaming: false }
650
- : item,
651
- ),
652
- );
653
- setIsStreaming(false);
654
- break;
655
-
656
- case "error":
657
- setTimeline((prev) => {
658
- const last = prev[prev.length - 1];
659
- if (
660
- last &&
661
- last.type === "message" &&
662
- last.role === "assistant" &&
663
- last.isStreaming
664
- ) {
665
- return prev.map((item, i) =>
666
- i === prev.length - 1 && item.type === "message"
667
- ? {
668
- ...item,
669
- content: item.content || event.error || chatLabels.errorLabel,
670
- isStreaming: false,
671
- }
672
- : item,
673
- );
674
- }
675
- return [
676
- ...prev,
677
- {
678
- type: "message",
679
- id: `error_${Date.now()}`,
680
- role: "assistant",
681
- content: event.error || chatLabels.errorLabel,
682
- },
683
- ];
684
- });
685
- setIsStreaming(false);
686
- break;
687
- }
189
+ const pushLocalError = useCallback(
190
+ (err: unknown) => {
191
+ const msg = err instanceof Error ? err.message : chatLabels.errorLabel;
192
+ setLocalErrors((prev) => [
193
+ ...prev,
194
+ {
195
+ id: `error_${Date.now()}_${prev.length}`,
196
+ content: `${chatLabels.errorLabel}: ${msg}`,
197
+ },
198
+ ]);
688
199
  },
689
200
  [chatLabels],
690
201
  );
691
- processEventRef.current = processEvent;
692
-
693
- // ─── Auto-subscribe SSE per active assistant messageId ──────
694
- // Driven przez DB history: za każdym razem gdy w DB pojawia się
695
- // assistant row z `isGenerating=true`, otwieramy do niego SSE.
696
- // Wszystkie scenariusze (send, respond, retry, page reload mid-stream)
697
- // przechodzą przez ten sam mechanizm — handleSend/respond/retry tylko
698
- // wywołują mutację, ten effect załapie nowy generating row z DB query
699
- // update.
700
- useEffect(() => {
701
- if (!activeGeneratingMessageId) return;
702
- const messageId = activeGeneratingMessageId;
703
- const ctrl = new AbortController();
704
- sseCtrlRef.current = ctrl;
705
- lastSseEventRef.current = null;
706
- let cancelled = false;
707
- setIsStreaming(true);
708
- chatDebug("sse open", { messageId });
709
-
710
- (async () => {
711
- try {
712
- // 410 = brak in-memory streamu dla messageId. Serwer tworzy stream
713
- // synchronicznie ze startem turnu (listener przed 1. awaitem), więc
714
- // race "GET przed startStream" jest zamknięty — ale zostaje krótki
715
- // residualny race i okno restartu serwera. Ponów kilka razy z
716
- // backoffem zanim uznasz turn za przerwany: startStream / grace
717
- // window zwykle dogania w tym czasie.
718
- let res: Response | null = null;
719
- const MAX_410_RETRIES = 4;
720
- const RETRY_DELAY_MS = 300;
721
- for (let attempt = 0; ; attempt++) {
722
- res = await fetch(`/route/chat/${chatName}/stream/${messageId}`, {
723
- credentials: "include",
724
- signal: ctrl.signal,
725
- headers: { Accept: "text/event-stream" },
726
- });
727
- if (res.status !== 410) break;
728
- if (attempt >= MAX_410_RETRIES) {
729
- // Naprawdę nieosiągalny (restart mid-stream / poza grace window).
730
- setInterruptedIds((prev) => new Set(prev).add(messageId));
731
- setIsStreaming(false);
732
- return;
733
- }
734
- await new Promise<void>((r) => setTimeout(r, RETRY_DELAY_MS));
735
- if (cancelled) return;
736
- }
737
- if (!res.ok) throw new Error(`Stream failed: ${res.status}`);
738
-
739
- const reader = res.body!.getReader();
740
- const decoder = new TextDecoder();
741
- let buf = "";
742
- lastSseByteAtRef.current =
743
- typeof performance !== "undefined" ? performance.now() : 0;
744
- while (!cancelled) {
745
- const { value, done } = await reader.read();
746
- if (done) break;
747
- // Heartbeat tick — kept on every read (even keepalive ` : ping`
748
- // bytes that don't produce events). Drives the watchdog effect
749
- // below: if this stops ticking for >2× the server keepalive, we
750
- // assume the socket is dead and reopen.
751
- lastSseByteAtRef.current =
752
- typeof performance !== "undefined" ? performance.now() : 0;
753
- buf += decoder.decode(value, { stream: true });
754
- const lines = buf.split("\n");
755
- buf = lines.pop() ?? "";
756
- for (const line of lines) {
757
- if (!line.startsWith("data: ")) continue;
758
- try {
759
- const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
760
- processEventRef.current?.(event);
761
- // Yield między eventami żeby React nie batchował setTimeline
762
- // w jeden render (streaming niewidoczny). Używamy
763
- // queueMicrotask, NIE setTimeout(0): w karcie w tle Chrome
764
- // throttluje timeouts do ≥1 s, więc setTimeout(0) zamienia
765
- // pętlę w 1-event/s freeze i po powrocie buf jest zalany.
766
- // Microtaski nie są throttlowane → pętla domyka turn nawet
767
- // w tle, a w foreground React i tak renderuje co microtask.
768
- await new Promise<void>((r) => queueMicrotask(r));
769
- } catch {}
770
- }
771
- }
772
- } catch (err) {
773
- if ((err as any)?.name === "AbortError") return;
774
- // Network glitch / SSE hang — treat as interrupted.
775
- setInterruptedIds((prev) => new Set(prev).add(messageId));
776
- setIsStreaming(false);
777
- }
778
- })();
779
-
780
- return () => {
781
- cancelled = true;
782
- ctrl.abort();
783
- if (sseCtrlRef.current === ctrl) sseCtrlRef.current = null;
784
- lastSseByteAtRef.current = null;
785
- };
786
- // `streamReopenNonce`: tab-return / heartbeat-timeout forces re-fetch
787
- // even though `activeGeneratingMessageId` didn't change. Backend's
788
- // FINALIZE_GRACE_MS (5 s) + `init` snapshot recover state seamlessly.
789
- }, [activeGeneratingMessageId, streamReopenNonce]);
790
-
791
- // ─── Visibility recovery — tab return forces SSE reconnect ──────────
792
- // Chrome/Safari freeze background tabs aggressively: setTimeout is clamped
793
- // to ≥1 s, `reader.read()` can hang silently when the OS suspends the
794
- // socket, and reactive-query WS updates may be missed. The net effect is
795
- // a "zombie" turn after returning to the tab — caret stuck, `done` event
796
- // dropped, missing tokens. We do not try to detect each failure mode;
797
- // instead, every return-to-visible while we believe we're streaming
798
- // tears down the (possibly dead) SSE and bumps `streamReopenNonce` so
799
- // the SSE effect re-fires with the same messageId. The backend's
800
- // FINALIZE_GRACE_MS window + `init` snapshot make this idempotent —
801
- // worst case we get an immediate `done` and a clean teardown.
802
- useEffect(() => {
803
- if (typeof document === "undefined") return;
804
- const onVisibility = () => {
805
- if (document.visibilityState !== "visible") return;
806
- if (!isStreaming) return;
807
- chatDebug("visibility reconnect", {
808
- activeGeneratingMessageId,
809
- });
810
- sseCtrlRef.current?.abort();
811
- sseCtrlRef.current = null;
812
- setStreamReopenNonce((n) => n + 1);
813
- };
814
- document.addEventListener("visibilitychange", onVisibility);
815
- return () =>
816
- document.removeEventListener("visibilitychange", onVisibility);
817
- }, [isStreaming, activeGeneratingMessageId]);
818
-
819
- // ─── BFCache restore (back/forward navigation) — force full reset ───
820
- // `pageshow` with `persisted=true` fires when the doc is restored from
821
- // BFCache. JS was paused, every socket is dead, and refs/state lie about
822
- // current reality. Treat it like a fresh mount: abort, bump nonces,
823
- // let the rebuild + SSE reopen run from scratch.
824
- useEffect(() => {
825
- if (typeof window === "undefined") return;
826
- const onPageShow = (e: PageTransitionEvent) => {
827
- if (!e.persisted) return;
828
- chatDebug("bfcache restore");
829
- sseCtrlRef.current?.abort();
830
- sseCtrlRef.current = null;
831
- setReconcileNonce((n) => n + 1);
832
- if (isStreaming) setStreamReopenNonce((n) => n + 1);
833
- };
834
- window.addEventListener("pageshow", onPageShow);
835
- return () => window.removeEventListener("pageshow", onPageShow);
836
- }, [isStreaming]);
837
-
838
- // ─── Heartbeat watchdog — silent dead socket detector ───────────────
839
- // Server sends a `: ping\n\n` keepalive every 5 s. If `reader.read()`
840
- // doesn't resolve for >12 s, the socket is almost certainly dead
841
- // (network glitch, server restart without a 410, or just the OS
842
- // gracefully tearing down a long-idle connection). Reconnect — same
843
- // path as the visibility handler. `setInterval` is clamped in
844
- // background tabs, but the visibility handler already covers that
845
- // case; this guards "tab active, socket silently dead".
846
- useEffect(() => {
847
- if (!isStreaming) return;
848
- if (typeof performance === "undefined") return;
849
- const HEARTBEAT_TIMEOUT_MS = 12_000; // 2× server KEEPALIVE_INTERVAL_MS + slack
850
- const id = setInterval(() => {
851
- const last = lastSseByteAtRef.current;
852
- if (last === null) return;
853
- const elapsed = performance.now() - last;
854
- if (elapsed < HEARTBEAT_TIMEOUT_MS) return;
855
- if (typeof document !== "undefined" && document.hidden) return;
856
- chatDebug("heartbeat timeout — reconnect", {
857
- msSinceLastByte: Math.round(elapsed),
858
- });
859
- sseCtrlRef.current?.abort();
860
- sseCtrlRef.current = null;
861
- setStreamReopenNonce((n) => n + 1);
862
- }, 3_000);
863
- return () => clearInterval(id);
864
- }, [isStreaming]);
865
202
 
866
- // ─── Send message ───────────────────────────────────────────
203
+ // ─── Send message ─────────────────────────────────────────────
867
204
  const handleSend = useCallback(
868
205
  async (content: string, options: SendMessageOptions) => {
869
- if (isStreaming || !scopeId) return;
870
- setIsStreaming(true);
871
- const userMsgId = `user_${Date.now()}`;
872
- setTimeline((prev) => [
206
+ if (!scopeId) return;
207
+ if (derived.busy || derived.hasWaitingInteractive) return;
208
+ const localId = `pending_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
209
+ setPendingSends((prev) => [
873
210
  ...prev,
874
- { type: "message", id: userMsgId, role: "user", content },
211
+ { localId, content, sentAt: Date.now() },
875
212
  ]);
876
213
  try {
877
- await messageMutations.sendMessage({
214
+ const res = await messageMutations.sendMessage({
878
215
  scopeId,
879
216
  content,
880
217
  model: options.model,
@@ -882,66 +219,54 @@ export function createChatComponent(
882
219
  ? { attachmentsJson: JSON.stringify(options.attachments) }
883
220
  : {}),
884
221
  });
885
- // Reszta dzieje się przez auto-subscribe effect powyżej: mutacja
886
- // emit'uje `assistantTurnStarted` → DB query pushuje fresh assistant
887
- // row do klienta → `activeGeneratingMessageId` ustawia się → effect
888
- // otwiera SSE.
222
+ const dbId = (res as any)?.messageId;
223
+ if (dbId) {
224
+ setPendingSends((prev) =>
225
+ prev.map((p) => (p.localId === localId ? { ...p, dbId } : p)),
226
+ );
227
+ }
228
+ // Reszta dzieje się sama: mutacja emituje `assistantTurnStarted` →
229
+ // liveQuery pushuje generujący row → useAssistantOverlays otwiera
230
+ // SSE → deriveTimeline renderuje overlay.
889
231
  } catch (err) {
890
- const errorMsg = err instanceof Error ? err.message : chatLabels.errorLabel;
891
- setTimeline((prev) => [
892
- ...prev,
893
- { type: "message", id: `error_${Date.now()}`, role: "assistant", content: `${chatLabels.errorLabel}: ${errorMsg}` },
894
- ]);
895
- setIsStreaming(false);
232
+ setPendingSends((prev) => prev.filter((p) => p.localId !== localId));
233
+ pushLocalError(err);
896
234
  }
897
235
  },
898
- [isStreaming, scopeId, messageMutations, chatLabels],
236
+ [scopeId, messageMutations, pushLocalError, derived.busy, derived.hasWaitingInteractive],
899
237
  );
900
238
 
901
- // ─── Retry interrupted generation ───────────────────────────
239
+ // ─── Retry interrupted generation ─────────────────────────────
902
240
  const handleRetry = useCallback(
903
241
  async (messageId: string) => {
904
242
  if (!scopeId) return;
905
243
  try {
906
244
  await messageMutations.retryGeneration({ messageId });
907
- // Mutacja usuwa interrupted row + tworzy fresh assistant row
908
- // auto-subscribe effect załapie nowy generating row.
909
- setInterruptedIds((prev) => {
910
- const next = new Set(prev);
911
- next.delete(messageId);
912
- return next;
913
- });
245
+ // Mutacja usuwa interrupted row + tworzy fresh generujący row
246
+ // liveQuery i overlay hook przejmują dalej.
914
247
  } catch (err) {
915
- const errorMsg = err instanceof Error ? err.message : chatLabels.errorLabel;
916
- setTimeline((prev) => [
917
- ...prev,
918
- { type: "message", id: `error_${Date.now()}`, role: "assistant", content: `${chatLabels.errorLabel}: ${errorMsg}` },
919
- ]);
248
+ pushLocalError(err);
920
249
  }
921
250
  },
922
- [scopeId, messageMutations, chatLabels],
251
+ [scopeId, messageMutations, pushLocalError],
923
252
  );
924
253
 
925
- // ─── Build messages for Chat DS (only user/assistant) ───────
926
- const chatMessages: ChatMessageData[] = timeline
927
- .filter((item): item is TimelineItem & { type: "message" } => item.type === "message")
928
- .map((item) => ({
929
- id: item.id,
930
- role: item.role,
931
- content: item.content,
932
- isStreaming: item.isStreaming,
933
- }));
934
-
935
- // ─── Render tool view ───────────────────────────────────────
254
+ // ─── Render tool view ─────────────────────────────────────────
936
255
  const renderToolItem = (item: TimelineItem & { type: "tool" }) => {
937
256
  const tool = toolsMap.get(item.toolName);
938
257
  const ViewComponent = tool?.viewComponent;
939
258
 
940
259
  if (!ViewComponent) {
941
- return createElement(ChatToolLog, {
942
- calling: item.calling,
943
- label: item.toolName,
944
- }, item.calling ? chatLabels.toolCallingLabel : item.error ?? chatLabels.toolDoneLabel);
260
+ return createElement(
261
+ ChatToolLog,
262
+ {
263
+ calling: item.calling,
264
+ label: item.toolName,
265
+ },
266
+ item.calling
267
+ ? chatLabels.toolCallingLabel
268
+ : (item.error ?? chatLabels.toolDoneLabel),
269
+ );
945
270
  }
946
271
 
947
272
  if (tool.isServerTool) {
@@ -953,15 +278,13 @@ export function createChatComponent(
953
278
  });
954
279
  }
955
280
 
956
- // Interactive tool
281
+ // Interactive tool — odpowiedź idzie przez optimistic
282
+ // pendingToolResults (answer-view od razu), mutacja respondToTool
283
+ // tworzy tool_result row + nowy turn.
957
284
  const respond = async (result: unknown) => {
958
285
  if (!scopeId) return;
959
- setTimeline((prev) =>
960
- prev.map((t) =>
961
- t.type === "tool" && t.toolCallId === item.toolCallId
962
- ? { ...t, calling: false, result }
963
- : t,
964
- ),
286
+ setPendingToolResults((prev) =>
287
+ new Map(prev).set(item.toolCallId, result),
965
288
  );
966
289
  try {
967
290
  await messageMutations.respondToTool({
@@ -970,18 +293,13 @@ export function createChatComponent(
970
293
  toolName: item.toolName,
971
294
  result: JSON.stringify(result),
972
295
  });
973
- // Auto-subscribe effect załapie nowy assistant row (utworzony
974
- // atomowo przez mutację respondToTool razem z userResponded event).
975
296
  } catch (err) {
976
- setTimeline((prev) => [
977
- ...prev,
978
- {
979
- type: "message",
980
- id: `error_${Date.now()}`,
981
- role: "assistant",
982
- content: `${chatLabels.errorLabel}: ${err instanceof Error ? err.message : chatLabels.errorLabel}`,
983
- },
984
- ]);
297
+ setPendingToolResults((prev) => {
298
+ const next = new Map(prev);
299
+ next.delete(item.toolCallId);
300
+ return next;
301
+ });
302
+ pushLocalError(err);
985
303
  }
986
304
  };
987
305
 
@@ -993,21 +311,19 @@ export function createChatComponent(
993
311
  });
994
312
  };
995
313
 
996
- // ─── Render full timeline ───────────────────────────────────
997
- // Streaming message renders last (after tools) for correct visual order
998
- const sortedTimeline = [...timeline].sort((a, b) => {
999
- const aStreaming = a.type === "message" && a.isStreaming ? 1 : 0;
1000
- const bStreaming = b.type === "message" && b.isStreaming ? 1 : 0;
1001
- return aStreaming - bStreaming;
1002
- });
1003
-
314
+ // ─── Render timeline ──────────────────────────────────────────
1004
315
  const timelineElements: ReactNode[] = [];
1005
- for (const item of sortedTimeline) {
316
+ for (const item of derived.items) {
1006
317
  if (item.type === "message") {
1007
318
  timelineElements.push(
1008
319
  createElement(ChatMessage, {
1009
320
  key: item.id,
1010
- message: { id: item.id, role: item.role, content: item.content, isStreaming: item.isStreaming },
321
+ message: {
322
+ id: item.id,
323
+ role: item.role,
324
+ content: item.content,
325
+ isStreaming: item.isStreaming,
326
+ },
1011
327
  }),
1012
328
  );
1013
329
  } else if (item.type === "tool") {
@@ -1020,7 +336,8 @@ export function createChatComponent(
1020
336
  "div",
1021
337
  {
1022
338
  key: item.id,
1023
- className: "flex items-center gap-2 text-sm text-muted-foreground",
339
+ className:
340
+ "flex items-center gap-2 text-sm text-muted-foreground",
1024
341
  },
1025
342
  createElement("span", null, chatLabels.interruptedLabel),
1026
343
  createElement(
@@ -1036,13 +353,14 @@ export function createChatComponent(
1036
353
  );
1037
354
  }
1038
355
  }
1039
-
1040
- // Check if any interactive tool is waiting for response
1041
- const hasWaitingInteractive = timeline.some(
1042
- (item) => item.type === "tool" && item.calling && !toolsMap.get(item.toolName)?.isServerTool,
1043
- );
1044
-
1045
- void chatMessages;
356
+ for (const err of localErrors) {
357
+ timelineElements.push(
358
+ createElement(ChatMessage, {
359
+ key: err.id,
360
+ message: { id: err.id, role: "assistant", content: err.content },
361
+ }),
362
+ );
363
+ }
1046
364
 
1047
365
  return createElement(
1048
366
  ChatInputProvider,
@@ -1076,24 +394,16 @@ export function createChatComponent(
1076
394
  placeholder,
1077
395
  rows,
1078
396
  })),
1079
- disabled: isStreaming || hasWaitingInteractive,
397
+ disabled: derived.busy || derived.hasWaitingInteractive,
1080
398
  }),
1081
399
  ),
1082
400
  );
1083
401
  }
1084
402
 
1085
403
  return function ChatComponent(props: { scope: any; identifyBy: string }) {
1086
- return createElement(
1087
- ChatLabelsProvider,
1088
- { labels, children: createElement(ChatComponentInner, props) },
1089
- );
404
+ return createElement(ChatLabelsProvider, {
405
+ labels,
406
+ children: createElement(ChatComponentInner, props),
407
+ });
1090
408
  };
1091
409
  }
1092
-
1093
- function tryParseJson(str: string): unknown {
1094
- try {
1095
- return JSON.parse(str);
1096
- } catch {
1097
- return str;
1098
- }
1099
- }