@arcote.tech/arc-chat 0.7.19 → 0.7.20

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-chat",
3
3
  "type": "module",
4
- "version": "0.7.19",
4
+ "version": "0.7.20",
5
5
  "private": false,
6
6
  "description": "Chat module with AI integration for Arc framework",
7
7
  "main": "./src/index.ts",
@@ -10,12 +10,12 @@
10
10
  "type-check": "tsc --noEmit"
11
11
  },
12
12
  "peerDependencies": {
13
- "@arcote.tech/arc": "^0.7.19",
14
- "@arcote.tech/arc-ai": "^0.7.19",
15
- "@arcote.tech/arc-ai-voice": "^0.7.19",
16
- "@arcote.tech/arc-auth": "^0.7.19",
17
- "@arcote.tech/arc-ds": "^0.7.19",
18
- "@arcote.tech/platform": "^0.7.19",
13
+ "@arcote.tech/arc": "^0.7.20",
14
+ "@arcote.tech/arc-ai": "^0.7.20",
15
+ "@arcote.tech/arc-ai-voice": "^0.7.20",
16
+ "@arcote.tech/arc-auth": "^0.7.20",
17
+ "@arcote.tech/arc-ds": "^0.7.20",
18
+ "@arcote.tech/platform": "^0.7.20",
19
19
  "lucide-react": ">=0.400.0",
20
20
  "react": ">=18.0.0",
21
21
  "typescript": "^5.0.0"
@@ -116,11 +116,21 @@ export function createChatComponent(
116
116
  * `historySig` already changed (during streaming) and won't change again.
117
117
  * See the watchdog effect below. */
118
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);
119
124
  /** AbortController of the currently-open SSE stream, exposed via ref so the
120
125
  * watchdog can tear it down when the DB says the turn is already done. */
121
126
  const sseCtrlRef = useRef<AbortController | null>(null);
122
127
  /** Last SSE event type seen (for stuck telemetry — was `done` ever sent?). */
123
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);
124
134
  /** When the (isStreaming=true ∧ DB-idle) divergence first appeared — for
125
135
  * the debounce window and the `waitedMs` stuck attribute. */
126
136
  const stuckSinceRef = useRef<number | null>(null);
@@ -325,11 +335,19 @@ export function createChatComponent(
325
335
  const since = stuckSinceRef.current;
326
336
  const RECONCILE_DEBOUNCE_MS = 200;
327
337
  const timer = setTimeout(() => {
338
+ const lastByteAt = lastSseByteAtRef.current;
328
339
  reportReconcileStuck({
329
340
  scopeId,
330
341
  chatName,
331
342
  lastSseEvent: lastSseEventRef.current,
332
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,
333
351
  });
334
352
  sseCtrlRef.current?.abort();
335
353
  sseCtrlRef.current = null;
@@ -721,9 +739,17 @@ export function createChatComponent(
721
739
  const reader = res.body!.getReader();
722
740
  const decoder = new TextDecoder();
723
741
  let buf = "";
742
+ lastSseByteAtRef.current =
743
+ typeof performance !== "undefined" ? performance.now() : 0;
724
744
  while (!cancelled) {
725
745
  const { value, done } = await reader.read();
726
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;
727
753
  buf += decoder.decode(value, { stream: true });
728
754
  const lines = buf.split("\n");
729
755
  buf = lines.pop() ?? "";
@@ -732,10 +758,14 @@ export function createChatComponent(
732
758
  try {
733
759
  const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
734
760
  processEventRef.current?.(event);
735
- // Yield between events in the same TCP chunk — bez tego
736
- // React batchuje setTimeline w jeden render, streaming
737
- // niewidoczny.
738
- await new Promise<void>((r) => setTimeout(r, 0));
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));
739
769
  } catch {}
740
770
  }
741
771
  }
@@ -751,8 +781,87 @@ export function createChatComponent(
751
781
  cancelled = true;
752
782
  ctrl.abort();
753
783
  if (sseCtrlRef.current === ctrl) sseCtrlRef.current = null;
784
+ lastSseByteAtRef.current = null;
754
785
  };
755
- }, [activeGeneratingMessageId]);
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]);
756
865
 
757
866
  // ─── Send message ───────────────────────────────────────────
758
867
  const handleSend = useCallback(