@arcote.tech/arc-chat 0.7.20 → 0.7.21

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,416 @@
1
+ import type { AssistantContentBlock } from "@arcote.tech/arc-ai";
2
+ import { orderMessages } from "../ordering";
3
+
4
+ // ─── Czysta derywacja timeline'u ─────────────────────────────────
5
+ //
6
+ // Timeline NIE jest mutowalnym stanem — jest czystą funkcją dwóch źródeł:
7
+ // 1. DB (liveQuery `getByScope`) — jedyne źródło prawdy o STRUKTURZE:
8
+ // lista wiadomości, finalne blocks, isGenerating/interrupted/error,
9
+ // tool_results.
10
+ // 2. Overlays — ulotny, in-progress stan per generujący assistant row
11
+ // (bloki budowane z SSE przez shared reducer).
12
+ // Plus lokalny optimistic state (pending sends / pending tool results).
13
+ //
14
+ // Dzięki temu klasyczne race'y dwóch kanałów (SSE `done` vs flip
15
+ // `isGenerating` w DB, tool_result w trakcie nowego turnu, zgubione
16
+ // `done`) przestają istnieć: derywacja zawsze liczy się od NAJNOWSZEGO
17
+ // stanu obu źródeł, niezależnie od kolejności ich dostarczenia.
18
+
19
+ export type ToolStatus = "pending" | "executing" | "complete" | "error";
20
+
21
+ export type TimelineItem =
22
+ | {
23
+ type: "message";
24
+ id: string;
25
+ role: "user" | "assistant";
26
+ content: string;
27
+ isStreaming?: boolean;
28
+ }
29
+ | {
30
+ type: "tool";
31
+ id: string; // = toolCallId, stabilne pomiędzy overlayem a final blocks
32
+ toolCallId: string;
33
+ toolName: string;
34
+ params: Record<string, unknown>;
35
+ result?: unknown;
36
+ status: ToolStatus;
37
+ /** Legacy compat — true gdy brak wyniku. Renderowane przez ChatToolLog. */
38
+ calling: boolean;
39
+ error?: string;
40
+ }
41
+ | {
42
+ type: "interrupted";
43
+ id: string;
44
+ messageId: string;
45
+ };
46
+
47
+ export type OverlayStatus = "connecting" | "live" | "done" | "gone";
48
+
49
+ export interface AssistantOverlay {
50
+ blocks: AssistantContentBlock[];
51
+ status: OverlayStatus;
52
+ }
53
+
54
+ export interface PendingSend {
55
+ localId: string;
56
+ content: string;
57
+ sentAt: number;
58
+ /** `messageId` zwrócony przez mutację `sendMessage` (gdy już resolved). */
59
+ dbId?: string;
60
+ }
61
+
62
+ export interface DeriveTimelineInput {
63
+ history: any[] | undefined;
64
+ overlays: ReadonlyMap<string, AssistantOverlay>;
65
+ pendingSends: readonly PendingSend[];
66
+ pendingToolResults: ReadonlyMap<string, unknown>;
67
+ isServerTool: (toolName: string) => boolean;
68
+ /** Wstrzykiwany zegar — testowalność + staleness cutoff reguły busy. */
69
+ now: number;
70
+ }
71
+
72
+ export interface DerivedTimeline {
73
+ items: TimelineItem[];
74
+ /**
75
+ * Czy konwersacja jest "zajęta" (input disabled). Wyprowadzane z DB,
76
+ * nie z lokalnego flagu — pokrywa też okna między iteracjami tool-loopa,
77
+ * gdy żaden row nie jest `isGenerating`.
78
+ */
79
+ busy: boolean;
80
+ /** Czy jakiś interactive tool czeka na odpowiedź użytkownika. */
81
+ hasWaitingInteractive: boolean;
82
+ }
83
+
84
+ /**
85
+ * Cutoff świeżości dla klauzul busy opartych o "ostatnia aktywność w DB".
86
+ * Chroni przed wiecznym disabled gdy listener padł między zapisami (np.
87
+ * wyjątek w buildInstructions po saveToolResult). Po przekroczeniu busy
88
+ * degraduje się do zachowania sprzed redesignu (input enabled mimo
89
+ * trwającej pętli) — bezpieczny gorszy przypadek.
90
+ */
91
+ const BUSY_STALENESS_MS = 120_000;
92
+
93
+ function ts(value: string | Date): number {
94
+ return value instanceof Date ? value.getTime() : new Date(value).getTime();
95
+ }
96
+
97
+ function tryParseJson(str: string): unknown {
98
+ try {
99
+ return JSON.parse(str);
100
+ } catch {
101
+ return str;
102
+ }
103
+ }
104
+
105
+ function parseBlocks(raw: unknown): AssistantContentBlock[] {
106
+ // Adapter Postgresa auto-parsuje kolumny tekstowe wyglądające jak JSON
107
+ // (deserializeValue w postgres-adapter) — po hydracji store'a z bazy
108
+ // `blocks` przychodzi jako GOTOWA tablica, nie string. Oba kształty są
109
+ // poprawne na tej granicy.
110
+ if (Array.isArray(raw)) return raw as AssistantContentBlock[];
111
+ if (typeof raw !== "string" || raw.length === 0) return [];
112
+ try {
113
+ const parsed = JSON.parse(raw);
114
+ return Array.isArray(parsed) ? parsed : [];
115
+ } catch {
116
+ return [];
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Czy optimistic pending send ma już swój odpowiednik w DB. Match po
122
+ * `dbId` (deterministyczny, gdy mutacja zdążyła resolve'ować) z fallbackiem
123
+ * po treści (liveQuery push potrafi wyprzedzić resolve mutacji).
124
+ */
125
+ export function isPendingSendSettled(
126
+ history: any[] | undefined,
127
+ pending: PendingSend,
128
+ ): boolean {
129
+ if (!history) return false;
130
+ for (const row of history) {
131
+ if (pending.dbId && row._id === pending.dbId) return true;
132
+ if (
133
+ row.role === "user" &&
134
+ row.content === pending.content &&
135
+ ts(row.createdAt) >= pending.sentAt - 60_000
136
+ ) {
137
+ return true;
138
+ }
139
+ }
140
+ return false;
141
+ }
142
+
143
+ export function deriveTimeline(input: DeriveTimelineInput): DerivedTimeline {
144
+ const {
145
+ history,
146
+ overlays,
147
+ pendingSends,
148
+ pendingToolResults,
149
+ isServerTool,
150
+ now,
151
+ } = input;
152
+
153
+ const rows = orderMessages(history ?? []);
154
+
155
+ // Wyniki tooli: DB jest autorytatywne; optimistic pending tylko dopóki
156
+ // tool_result row nie dotarł przez liveQuery.
157
+ const resultMap = new Map<
158
+ string,
159
+ { value: unknown; isError?: boolean; raw?: string }
160
+ >();
161
+ for (const msg of rows) {
162
+ if (msg.role === "tool_result" && msg.toolCallId) {
163
+ // `content` może przyjść jako string ALBO już sparsowany obiekt —
164
+ // patrz komentarz w parseBlocks (deserializacja adaptera Postgresa).
165
+ const content: unknown = msg.content;
166
+ resultMap.set(msg.toolCallId, {
167
+ value: typeof content === "string" ? tryParseJson(content) : content,
168
+ isError: msg.isError,
169
+ raw:
170
+ typeof content === "string"
171
+ ? content
172
+ : JSON.stringify(content ?? ""),
173
+ });
174
+ }
175
+ }
176
+ for (const [toolCallId, value] of pendingToolResults) {
177
+ if (!resultMap.has(toolCallId)) {
178
+ resultMap.set(toolCallId, { value });
179
+ }
180
+ }
181
+
182
+ const items: TimelineItem[] = [];
183
+
184
+ const pushBlocks = (
185
+ messageId: string,
186
+ blocks: AssistantContentBlock[],
187
+ options: { streaming?: boolean } = {},
188
+ ) => {
189
+ let textCount = 0;
190
+ blocks.forEach((block, idx) => {
191
+ if (block.type === "text") {
192
+ if (block.text) {
193
+ items.push({
194
+ type: "message",
195
+ id: `${messageId}_t${textCount}`,
196
+ role: "assistant",
197
+ content: block.text,
198
+ // Caret tylko na ostatnim bloku aktywnie streamowanego turnu.
199
+ isStreaming: options.streaming && idx === blocks.length - 1,
200
+ });
201
+ }
202
+ textCount++;
203
+ return;
204
+ }
205
+ const result = resultMap.get(block.id);
206
+ const status: ToolStatus = result
207
+ ? result.isError
208
+ ? "error"
209
+ : "complete"
210
+ : isServerTool(block.name)
211
+ ? "executing"
212
+ : "pending";
213
+ items.push({
214
+ type: "tool",
215
+ id: block.id,
216
+ toolCallId: block.id,
217
+ toolName: block.name,
218
+ params: block.arguments,
219
+ result: result?.value,
220
+ status,
221
+ calling: !result,
222
+ error: result?.isError ? (result.raw ?? String(result.value)) : undefined,
223
+ });
224
+ });
225
+ };
226
+
227
+ for (const msg of rows) {
228
+ // System rows to developer-injected priming prompts — LLM je widzi
229
+ // (buildHistory), timeline nie.
230
+ if (msg.role === "system") continue;
231
+ if (msg.role === "tool_result") continue; // renderowane przy tool blocku
232
+
233
+ if (msg.role === "user") {
234
+ items.push({
235
+ type: "message",
236
+ id: msg._id,
237
+ role: "user",
238
+ // Wiadomość użytkownika zaczynająca się od "{" / "[" wraca
239
+ // z Postgresa sparsowana — renderujemy zawsze string.
240
+ content:
241
+ typeof msg.content === "string"
242
+ ? msg.content
243
+ : JSON.stringify(msg.content ?? ""),
244
+ });
245
+ continue;
246
+ }
247
+
248
+ if (msg.role !== "assistant") continue;
249
+
250
+ if (msg.isGenerating) {
251
+ const overlay = overlays.get(msg._id);
252
+
253
+ // Stream nieosiągalny (410 po retry / błąd sieci) — natychmiastowy
254
+ // lokalny stan; lazy repair w route wkrótce utrwali `interrupted`
255
+ // w DB dla wszystkich klientów.
256
+ if (overlay?.status === "gone") {
257
+ items.push({
258
+ type: "interrupted",
259
+ id: `${msg._id}_interrupted`,
260
+ messageId: msg._id,
261
+ });
262
+ continue;
263
+ }
264
+
265
+ if (overlay && overlay.blocks.length > 0) {
266
+ const streaming =
267
+ overlay.status === "live" || overlay.status === "connecting";
268
+ // Interactive tool w trakcie streamowania pokazujemy dopiero z
269
+ // kompletem argumentów: overlay dodaje blok już przy
270
+ // `tool_call_pending` (arguments={}), a view komponenty interactive
271
+ // tooli (np. AskQuestionsView → registerInputOverride) montują się
272
+ // z params i oczekują pełnych danych. Filtr PRZED pushBlocks —
273
+ // dzięki temu caret ląduje na ostatnim WIDOCZNYM bloku (tekst
274
+ // trzyma caret, gdy args się streamują), a turn złożony z samego
275
+ // ukrytego toola spada do spinner-placeholdera niżej.
276
+ const visibleBlocks = streaming
277
+ ? overlay.blocks.filter(
278
+ (b) =>
279
+ b.type !== "tool_call" ||
280
+ isServerTool(b.name) ||
281
+ Object.keys(b.arguments ?? {}).length > 0,
282
+ )
283
+ : overlay.blocks;
284
+ if (visibleBlocks.length > 0) {
285
+ pushBlocks(msg._id, visibleBlocks, { streaming });
286
+ continue;
287
+ }
288
+ }
289
+
290
+ // Brak overlaya (SSE jeszcze się łączy), pusty stream albo same
291
+ // ukryte bloki — pusty streaming bubble (DS renderuje spinner).
292
+ items.push({
293
+ type: "message",
294
+ id: `${msg._id}_t0`,
295
+ role: "assistant",
296
+ content: "",
297
+ isStreaming: true,
298
+ });
299
+ continue;
300
+ }
301
+
302
+ if (msg.interrupted) {
303
+ items.push({
304
+ type: "interrupted",
305
+ id: `${msg._id}_interrupted`,
306
+ messageId: msg._id,
307
+ });
308
+ continue;
309
+ }
310
+
311
+ // Zamknięty turn — finalne blocks z DB. Klucze itemów identyczne jak
312
+ // przy overlayu (`${_id}_t${n}` / toolCallId) → przejście overlay→DB
313
+ // nie remountuje elementów.
314
+ pushBlocks(msg._id, parseBlocks(msg.blocks));
315
+
316
+ if (msg.error) {
317
+ items.push({
318
+ type: "message",
319
+ id: `${msg._id}_error`,
320
+ role: "assistant",
321
+ content: msg.error,
322
+ });
323
+ }
324
+ }
325
+
326
+ // Optimistic user messages — jeszcze nie potwierdzone przez liveQuery.
327
+ for (const pending of pendingSends) {
328
+ if (isPendingSendSettled(history, pending)) continue;
329
+ items.push({
330
+ type: "message",
331
+ id: pending.localId,
332
+ role: "user",
333
+ content: pending.content,
334
+ });
335
+ }
336
+
337
+ const hasWaitingInteractive = items.some(
338
+ (it) => it.type === "tool" && it.calling && !isServerTool(it.toolName),
339
+ );
340
+
341
+ return {
342
+ items,
343
+ busy: deriveBusy(rows, overlays, pendingSends, history, isServerTool, now),
344
+ hasWaitingInteractive,
345
+ };
346
+ }
347
+
348
+ function deriveBusy(
349
+ rows: any[],
350
+ overlays: ReadonlyMap<string, AssistantOverlay>,
351
+ pendingSends: readonly PendingSend[],
352
+ history: any[] | undefined,
353
+ isServerTool: (toolName: string) => boolean,
354
+ now: number,
355
+ ): boolean {
356
+ // 1. Trwa generacja (row otwarty, stream nie jest martwy).
357
+ for (const msg of rows) {
358
+ if (
359
+ msg.role === "assistant" &&
360
+ msg.isGenerating &&
361
+ !msg.interrupted &&
362
+ overlays.get(msg._id)?.status !== "gone"
363
+ ) {
364
+ return true;
365
+ }
366
+ }
367
+
368
+ // 2. Wiadomość wysłana, row jeszcze nie dotarł przez liveQuery.
369
+ if (pendingSends.some((p) => !isPendingSendSettled(history, p))) return true;
370
+
371
+ if (rows.length === 0) return false;
372
+ const lastActivityTs = Math.max(...rows.map((r) => ts(r.createdAt)));
373
+ const fresh = now - lastActivityTs < BUSY_STALENESS_MS;
374
+ if (!fresh) return false;
375
+
376
+ // 3. Gap A: ostatni assistant turn zamknął się server-tool callami,
377
+ // których wyniki jeszcze nie dotarły (listener egzekwuje tool'e).
378
+ const lastAssistant = [...rows]
379
+ .reverse()
380
+ .find((r) => r.role === "assistant");
381
+ if (
382
+ lastAssistant &&
383
+ !lastAssistant.isGenerating &&
384
+ !lastAssistant.interrupted &&
385
+ !lastAssistant.error
386
+ ) {
387
+ const resultIds = new Set(
388
+ rows
389
+ .filter((r) => r.role === "tool_result" && r.toolCallId)
390
+ .map((r) => r.toolCallId),
391
+ );
392
+ const blocks = parseBlocks(lastAssistant.blocks);
393
+ const awaitingServerTool = blocks.some(
394
+ (b) =>
395
+ b.type === "tool_call" &&
396
+ isServerTool(b.name) &&
397
+ !resultIds.has(b.id),
398
+ );
399
+ if (awaitingServerTool) return true;
400
+ }
401
+
402
+ // 4. Gap B: ostatni row to świeży tool_result server-toola — listener
403
+ // jest pomiędzy saveToolResult a startAssistantTurn następnej
404
+ // iteracji (np. wolny buildInstructions). Po server-tool result
405
+ // ZAWSZE następuje kolejny turn.
406
+ const lastRow = rows[rows.length - 1];
407
+ if (
408
+ lastRow.role === "tool_result" &&
409
+ lastRow.toolName &&
410
+ isServerTool(lastRow.toolName)
411
+ ) {
412
+ return true;
413
+ }
414
+
415
+ return false;
416
+ }
@@ -0,0 +1,269 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { ChatStreamEvent } from "@arcote.tech/arc-ai";
3
+ import { applyStreamEvent } from "../streaming/blocks-reducer";
4
+ import type { AssistantOverlay } from "./derive-timeline";
5
+
6
+ // ─── Overlay hook — SSE jako warstwa nakładkowa, nie źródło prawdy ──
7
+ //
8
+ // Per generujący assistant row (`isGenerating=true` w DB) utrzymuje
9
+ // połączenie SSE i buduje `AssistantOverlay { blocks, status }` przez
10
+ // SHARED reducer (`applyStreamEvent` — ten sam kod co serwerowy
11
+ // `publish()`). Event `init` niesie snapshot — resetuje bazę bloków,
12
+ // więc reconnect w dowolnym momencie jest bezstratny i idempotentny.
13
+ //
14
+ // Hook NIGDY nie dotyka timeline'u — wyłącznie aktualizuje mapę
15
+ // overlayów. Autorytatywny koniec turnu to flip `isGenerating=false`
16
+ // w DB (liveQuery); `done`/`error` z SSE są czysto advisory.
17
+ //
18
+ // Failure mode'y kanału SSE (martwy socket, throttling karty w tle,
19
+ // BFCache, restart serwera) degradują się do "overlay przestał się
20
+ // aktualizować" — derywacja renderuje placeholder/ostatni znany stan,
21
+ // a DB ostatecznie domyka turn. Stąd recovery jest proste: zabij
22
+ // połączenie i otwórz nowe (init snapshot naprawia wszystko).
23
+
24
+ /** Telemetria — host app może podpiąć `globalThis.__arcChatOnStuck`. */
25
+ function reportStreamGone(attrs: Record<string, unknown>): void {
26
+ // eslint-disable-next-line no-console
27
+ console.warn("[arc-chat][stream_gone]", attrs);
28
+ try {
29
+ (globalThis as any).__arcChatOnStuck?.(attrs);
30
+ } catch {
31
+ /* reporter must never break the chat */
32
+ }
33
+ }
34
+
35
+ /** Verbose timing gated za `globalThis.__ARC_CHAT_DEBUG`. */
36
+ function chatDebug(...args: unknown[]): void {
37
+ if ((globalThis as any).__ARC_CHAT_DEBUG) {
38
+ const ts =
39
+ typeof performance !== "undefined" ? performance.now().toFixed(0) : "";
40
+ // eslint-disable-next-line no-console
41
+ console.debug(`[arc-chat +${ts}ms]`, ...args);
42
+ }
43
+ }
44
+
45
+ interface Connection {
46
+ ctrl: AbortController;
47
+ /** performance.now() ostatniego bajtu (dane + keepalive ping). */
48
+ lastByteAt: number | null;
49
+ }
50
+
51
+ const MAX_410_RETRIES = 4;
52
+ const RETRY_DELAY_MS = 300;
53
+ /** 2× serwerowy KEEPALIVE_INTERVAL_MS (5 s) + zapas. */
54
+ const HEARTBEAT_TIMEOUT_MS = 12_000;
55
+
56
+ export function useAssistantOverlays(
57
+ chatName: string,
58
+ generatingIds: readonly string[],
59
+ ): ReadonlyMap<string, AssistantOverlay> {
60
+ const [overlays, setOverlays] = useState<Map<string, AssistantOverlay>>(
61
+ () => new Map(),
62
+ );
63
+ /** Bump → effect przeładowuje połączenia (visibility / heartbeat / BFCache). */
64
+ const [reopenNonce, setReopenNonce] = useState(0);
65
+ const connsRef = useRef<Map<string, Connection>>(new Map());
66
+
67
+ const idsKey = generatingIds.join("|");
68
+
69
+ useEffect(() => {
70
+ const ids = new Set(generatingIds);
71
+
72
+ // GC overlayów rzędów, które przestały generować (derywacja i tak by
73
+ // je zignorowała — flip isGenerating przyniósł finalne blocks w tym
74
+ // samym rzędzie liveQuery).
75
+ setOverlays((prev) => {
76
+ let changed = false;
77
+ const next = new Map(prev);
78
+ for (const key of next.keys()) {
79
+ if (!ids.has(key)) {
80
+ next.delete(key);
81
+ changed = true;
82
+ }
83
+ }
84
+ return changed ? next : prev;
85
+ });
86
+
87
+ for (const messageId of generatingIds) {
88
+ const ctrl = new AbortController();
89
+ const conn: Connection = { ctrl, lastByteAt: null };
90
+ connsRef.current.set(messageId, conn);
91
+ /** Połączenie jest aktualne dopóki connsRef trzyma TĘ instancję —
92
+ * zamiennik sekwencerów: eventy ze starych readerów są ignorowane. */
93
+ const isCurrent = () => connsRef.current.get(messageId) === conn;
94
+
95
+ const setOverlay = (
96
+ update: (prev: AssistantOverlay | undefined) => AssistantOverlay,
97
+ ) => {
98
+ if (!isCurrent()) return;
99
+ setOverlays((prev) => {
100
+ const next = new Map(prev);
101
+ next.set(messageId, update(prev.get(messageId)));
102
+ return next;
103
+ });
104
+ };
105
+
106
+ // Reconnect zachowuje ostatnie znane bloki (status connecting) —
107
+ // init snapshot zaraz je nadpisze; zero flasha do placeholdera.
108
+ setOverlay((prev) => ({
109
+ blocks: prev?.blocks ?? [],
110
+ status: "connecting",
111
+ }));
112
+ chatDebug("sse open", { messageId });
113
+
114
+ (async () => {
115
+ try {
116
+ // 410 = brak in-memory streamu. Serwer otwiera stream
117
+ // synchronicznie ze startem turnu (listener przed 1. awaitem),
118
+ // ale zostaje krótki residualny race + okno restartu serwera.
119
+ // Retry z backoffem; po wyczerpaniu → "gone" (route w tym
120
+ // czasie mógł już zrobić lazy repair `interrupted` w DB).
121
+ let res: Response | null = null;
122
+ for (let attempt = 0; ; attempt++) {
123
+ res = await fetch(`/route/chat/${chatName}/stream/${messageId}`, {
124
+ credentials: "include",
125
+ signal: ctrl.signal,
126
+ headers: { Accept: "text/event-stream" },
127
+ });
128
+ if (res.status !== 410) break;
129
+ if (attempt >= MAX_410_RETRIES) {
130
+ reportStreamGone({ chatName, messageId, reason: "410_exhausted" });
131
+ setOverlay((prev) => ({
132
+ blocks: prev?.blocks ?? [],
133
+ status: "gone",
134
+ }));
135
+ return;
136
+ }
137
+ await new Promise<void>((r) => setTimeout(r, RETRY_DELAY_MS));
138
+ if (!isCurrent()) return;
139
+ }
140
+ if (!res.ok) throw new Error(`Stream failed: ${res.status}`);
141
+
142
+ const reader = res.body!.getReader();
143
+ const decoder = new TextDecoder();
144
+ let buf = "";
145
+ conn.lastByteAt =
146
+ typeof performance !== "undefined" ? performance.now() : 0;
147
+
148
+ while (isCurrent()) {
149
+ const { value, done } = await reader.read();
150
+ if (done) break;
151
+ conn.lastByteAt =
152
+ typeof performance !== "undefined" ? performance.now() : 0;
153
+ buf += decoder.decode(value, { stream: true });
154
+ const lines = buf.split("\n");
155
+ buf = lines.pop() ?? "";
156
+ for (const line of lines) {
157
+ if (!line.startsWith("data: ")) continue;
158
+ try {
159
+ const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
160
+ chatDebug("sse event", event.type);
161
+ if (event.type === "init") {
162
+ setOverlay(() => ({
163
+ blocks: event.currentBlocks ?? [],
164
+ status: "live",
165
+ }));
166
+ } else if (event.type === "done" || event.type === "error") {
167
+ // Advisory — zdejmuje caret od razu; autorytatywne
168
+ // domknięcie (finalne blocks / error) przyjdzie flipem
169
+ // isGenerating w DB.
170
+ setOverlay((prev) => ({
171
+ blocks: prev?.blocks ?? [],
172
+ status: "done",
173
+ }));
174
+ } else {
175
+ setOverlay((prev) => ({
176
+ blocks: applyStreamEvent(prev?.blocks ?? [], event),
177
+ status: prev?.status ?? "live",
178
+ }));
179
+ }
180
+ // Yield między eventami, żeby React nie batchował updateów
181
+ // w jeden render (streaming niewidoczny). queueMicrotask,
182
+ // NIE setTimeout(0): Chrome throttluje timeouty w kartach
183
+ // w tle do ≥1 s — microtaski nie są throttlowane, pętla
184
+ // domyka turn nawet w tle.
185
+ await new Promise<void>((r) => queueMicrotask(r));
186
+ } catch {}
187
+ }
188
+ }
189
+ } catch (err) {
190
+ if ((err as any)?.name === "AbortError") return;
191
+ if (!isCurrent()) return;
192
+ reportStreamGone({
193
+ chatName,
194
+ messageId,
195
+ reason: "network_error",
196
+ error: err instanceof Error ? err.message : String(err),
197
+ });
198
+ setOverlay((prev) => ({
199
+ blocks: prev?.blocks ?? [],
200
+ status: "gone",
201
+ }));
202
+ }
203
+ })();
204
+ }
205
+
206
+ // Cleanup zamyka WSZYSTKIE połączenia tego cyklu — następny run
207
+ // effectu (zmiana generatingIds / bump nonce / StrictMode remount)
208
+ // otwiera je od zera. Reconnect jest bezstratny: `init` snapshot
209
+ // resetuje bazę overlaya, a overlay state przeżywa cykl effectu.
210
+ return () => {
211
+ for (const conn of connsRef.current.values()) conn.ctrl.abort();
212
+ connsRef.current.clear();
213
+ };
214
+ }, [idsKey, reopenNonce, chatName]);
215
+
216
+ /** Wymuś przeładowanie połączeń (cleanup + ponowny run effectu). */
217
+ const reconnectAll = (reason: string) => {
218
+ if (connsRef.current.size === 0) return;
219
+ chatDebug("reconnect", { reason });
220
+ setReopenNonce((n) => n + 1);
221
+ };
222
+ const reconnectAllRef = useRef(reconnectAll);
223
+ reconnectAllRef.current = reconnectAll;
224
+
225
+ // ─── Visibility — powrót na kartę wymusza reconnect ────────────
226
+ // Chrome/Safari agresywnie zamrażają karty w tle: reader.read() potrafi
227
+ // wisieć na martwym sockecie bez błędu. Nie wykrywamy poszczególnych
228
+ // failure mode'ów — każdy powrót do widoczności przeładowuje połączenia.
229
+ useEffect(() => {
230
+ if (typeof document === "undefined") return;
231
+ const onVisibility = () => {
232
+ if (document.visibilityState !== "visible") return;
233
+ reconnectAllRef.current("visibility");
234
+ };
235
+ document.addEventListener("visibilitychange", onVisibility);
236
+ return () => document.removeEventListener("visibilitychange", onVisibility);
237
+ }, []);
238
+
239
+ // ─── BFCache restore — wszystkie sockety są martwe ──────────────
240
+ useEffect(() => {
241
+ if (typeof window === "undefined") return;
242
+ const onPageShow = (e: PageTransitionEvent) => {
243
+ if (!e.persisted) return;
244
+ reconnectAllRef.current("bfcache");
245
+ };
246
+ window.addEventListener("pageshow", onPageShow);
247
+ return () => window.removeEventListener("pageshow", onPageShow);
248
+ }, []);
249
+
250
+ // ─── Heartbeat — cichy martwy socket przy aktywnej karcie ───────
251
+ // Serwer wysyła `: ping` co 5 s; brak bajtów > HEARTBEAT_TIMEOUT_MS
252
+ // oznacza martwe połączenie → reconnect.
253
+ useEffect(() => {
254
+ if (generatingIds.length === 0) return;
255
+ if (typeof performance === "undefined") return;
256
+ const id = setInterval(() => {
257
+ if (typeof document !== "undefined" && document.hidden) return;
258
+ for (const conn of connsRef.current.values()) {
259
+ if (conn.lastByteAt === null) continue;
260
+ if (performance.now() - conn.lastByteAt < HEARTBEAT_TIMEOUT_MS) continue;
261
+ reconnectAllRef.current("heartbeat");
262
+ return;
263
+ }
264
+ }, 3_000);
265
+ return () => clearInterval(id);
266
+ }, [idsKey]);
267
+
268
+ return overlays;
269
+ }