@arcote.tech/arc-chat 0.7.9 → 0.7.11

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,5 +1,5 @@
1
1
  import { useState, useCallback, useMemo, useRef, useEffect, type ComponentType, createElement, type ReactNode } from "react";
2
- import type { ChatStreamEvent, ArcToolAny } from "@arcote.tech/arc-ai";
2
+ import type { AssistantContentBlock, ChatStreamEvent, ArcToolAny } from "@arcote.tech/arc-ai";
3
3
  import type {
4
4
  ChatInputTextareaSlotProps,
5
5
  ChatLabels,
@@ -55,6 +55,11 @@ type TimelineItem =
55
55
  /** Legacy compat — true gdy status !== complete. Renderowane przez stary ChatToolLog. */
56
56
  calling: boolean;
57
57
  error?: string;
58
+ }
59
+ | {
60
+ type: "interrupted";
61
+ id: string;
62
+ messageId: string;
58
63
  };
59
64
 
60
65
  export function createChatComponent(
@@ -77,13 +82,9 @@ export function createChatComponent(
77
82
  const chatLabels = useChatLabels();
78
83
  const [timeline, setTimeline] = useState<TimelineItem[]>([]);
79
84
  const [isStreaming, setIsStreaming] = useState(false);
80
- const sessionIdRef = useRef<string | null>(null);
81
- const currentAssistantIdRef = useRef<string | null>(null);
82
- const resumedSessionRef = useRef<string | null>(null);
83
- /** Last seq applied per session — dedup dla replay buffer + double subscribe. */
84
- const lastSeqBySessionRef = useRef<Map<string, number>>(new Map());
85
- /** afterSeq dla SSE resume — read once z partialLastSeq w DB rebuild. */
86
- const resumeAfterSeqRef = useRef<number>(0);
85
+ /** Set messageIds dla których SSE zwrócił 410 (server restart mid-stream).
86
+ * Renderowane jako TimelineItem "interrupted" z retry button. */
87
+ const [interruptedIds, setInterruptedIds] = useState<Set<string>>(() => new Set());
87
88
 
88
89
  const queries = scope.useQuery();
89
90
  const mutations = scope.useMutation();
@@ -106,19 +107,35 @@ export function createChatComponent(
106
107
  historyData
107
108
  ?.map(
108
109
  (m: any) =>
109
- `${m._id}:${m.isGenerating ? 1 : 0}:${m.blocks ? "f" : "e"}:${m.content?.length ?? 0}:${m.partialLastSeq ?? 0}`,
110
+ `${m._id}:${m.isGenerating ? 1 : 0}:${m.blocks ? "f" : "e"}:${m.content?.length ?? 0}`,
110
111
  )
111
112
  .join("|") ?? "",
112
113
  [historyData],
113
114
  );
114
115
 
116
+ /** ID assistant rowa, którego currently otwarty stream subskrybujemy.
117
+ * Wyciąga się z DB historii — jest max jeden taki w danej chwili
118
+ * (listener sekwencyjny). Null gdy nie ma czego streamować. */
119
+ const activeGeneratingMessageId = useMemo<string | null>(() => {
120
+ if (!historyData) return null;
121
+ for (const msg of historyData) {
122
+ if (
123
+ msg.role === "assistant" &&
124
+ msg.isGenerating &&
125
+ !interruptedIds.has(msg._id)
126
+ ) {
127
+ return msg._id;
128
+ }
129
+ }
130
+ return null;
131
+ }, [historyData, interruptedIds]);
132
+
115
133
  // ─── Restore timeline from DB history ───────────────────────
116
134
  // Podczas streamingu SSE jest źródłem prawdy dla aktualnie generowanej
117
- // wiadomości — rebuild byłby kolizją. Snapshoty do `partialBlocks` służą
118
- // wyłącznie do hydratacji po reload (gdy `isStreaming` jest jeszcze
119
- // `false` na mount). Po `done` SSE klient ustawia `isStreaming=false` i
120
- // ten useEffect refireuje (dep `isStreaming`) — zaaplikuje final blocks
121
- // z `assistantTurnCompleted`.
135
+ // wiadomości — rebuild byłby kolizją. Po `done` klient ustawia
136
+ // `isStreaming=false` i ten useEffect refireuje zaaplikuje final
137
+ // blocks z `assistantTurnCompleted` projection (jedyny moment gdy treść
138
+ // assistant'a ląduje w DB).
122
139
  useEffect(() => {
123
140
  if (isStreaming) return;
124
141
  if (!historyData || historyLen === 0) return;
@@ -133,7 +150,6 @@ export function createChatComponent(
133
150
  }
134
151
 
135
152
  const items: TimelineItem[] = [];
136
- let hasActiveGeneration = false;
137
153
 
138
154
  for (const msg of historyData) {
139
155
  // System messages are developer-injected priming prompts. They go
@@ -152,19 +168,8 @@ export function createChatComponent(
152
168
  }
153
169
 
154
170
  if (msg.role === "assistant") {
155
- // Helper — renderuje block array jako TimelineItem-y do `items`.
156
- // Tool block id = block.id (toolCallId) — JEDEN klucz wszędzie,
157
- // bez `${msg._id}_b${idx}` mismatchu z SSE path'a (tc_${id}).
158
171
  const renderBlocks = (
159
- blocks: Array<
160
- | { type: "text"; text: string }
161
- | {
162
- type: "tool_call";
163
- id: string;
164
- name: string;
165
- arguments: Record<string, unknown>;
166
- }
167
- >,
172
+ blocks: AssistantContentBlock[],
168
173
  options: { isStreaming?: boolean } = {},
169
174
  ) => {
170
175
  let textCount = 0;
@@ -183,11 +188,8 @@ export function createChatComponent(
183
188
  } else {
184
189
  const result = resultMap.get(block.id);
185
190
  const hasResult = resultIds.has(block.id);
186
- // Brak result w DB = tool wciąż w toku:
187
- // - server tool podczas exec "executing"
188
- // - interactive tool czekający na user response → "pending"
189
- // (z perspektywy klienta różnica jest tylko semantyczna,
190
- // `calling: true` w obu przypadkach utrzymuje loader/input override)
191
+ // Brak result w DB = tool wciąż w toku (server: executing,
192
+ // interactive: pending). `calling: true` w obu utrzymuje loader/input override.
191
193
  const status: ToolStatus = result?.isError
192
194
  ? "error"
193
195
  : hasResult
@@ -197,7 +199,7 @@ export function createChatComponent(
197
199
  : "pending";
198
200
  items.push({
199
201
  type: "tool",
200
- id: block.id, // = toolCallId
202
+ id: block.id,
201
203
  toolCallId: block.id,
202
204
  toolName: block.name,
203
205
  params: block.arguments,
@@ -210,23 +212,21 @@ export function createChatComponent(
210
212
  }
211
213
  };
212
214
 
213
- // Open turn (in progress). The row exists with `isGenerating: true`.
214
- // SQLite zwraca booleany jako 0/1 używamy truthy check zamiast
215
- // `=== true` żeby zaakceptować zarówno true jak i 1.
215
+ // Open turn (in progress). The row exists w DB z `isGenerating: true`
216
+ // ale BEZ `blocks` final treść ląduje w DB dopiero po
217
+ // `assistantTurnCompleted`. Live wartość jest in-memory w stream-registry
218
+ // i zostanie pobrana przez SSE `init` event.
216
219
  if (msg.isGenerating) {
217
- if (msg.sessionId) sessionIdRef.current = msg.sessionId;
218
- hasActiveGeneration = true;
219
- currentAssistantIdRef.current = msg._id;
220
- resumeAfterSeqRef.current = msg.partialLastSeq ?? 0;
221
- const partial =
222
- (tryParseJson(msg.partialBlocks ?? "") as Array<any>) ?? [];
223
- if (partial.length > 0) {
224
- renderBlocks(partial, { isStreaming: true });
220
+ if (interruptedIds.has(msg._id)) {
221
+ items.push({
222
+ type: "interrupted",
223
+ id: `${msg._id}_interrupted`,
224
+ messageId: msg._id,
225
+ });
225
226
  } else {
226
- // brak partial — pusty streaming bubble jako placeholder
227
227
  items.push({
228
228
  type: "message",
229
- id: msg._id,
229
+ id: `${msg._id}_t0`,
230
230
  role: "assistant",
231
231
  content: "",
232
232
  isStreaming: true,
@@ -237,82 +237,83 @@ export function createChatComponent(
237
237
 
238
238
  // Closed turn — render z final blocks.
239
239
  const blocks =
240
- (tryParseJson(msg.blocks ?? "") as Array<any>) ?? [];
240
+ (tryParseJson(msg.blocks ?? "") as AssistantContentBlock[]) ?? [];
241
241
  renderBlocks(blocks);
242
242
  }
243
243
  }
244
244
 
245
245
  setTimeline(items);
246
- if (!isStreaming && hasActiveGeneration) {
247
- setIsStreaming(true);
248
- }
249
- // Dep TYLKO `historySig` — bez `isStreaming`. Gdyby `isStreaming` było
250
- // w deps: po `done` SSE flips isStreaming=false → useEffect refires →
246
+ // Dep TYLKO `historySig` + `interruptedIds`. Bez `isStreaming` gdyby
247
+ // było w deps: po `done` SSE flips isStreaming=false → useEffect refires →
251
248
  // może zobaczyć STAREJ `isGenerating:1` row (DB jeszcze nie zaprojektowała
252
249
  // assistantTurnCompleted) → reset do streaming mode → caret nigdy nie znika.
253
- // Rebuild fires tylko gdy faktycznie zmienia się stan w DB.
254
- }, [historySig]);
255
-
256
- // ─── SSE stream consumer ────────────────────────────────────
257
- // Reusable: handles fetch + read loop + processEvent dispatch.
258
- // Caller is responsible for setting isStreaming/sessionIdRef before
259
- // and clearing them after (different lifecycle in send vs respond vs resume).
260
- const consumeStream = useCallback(
261
- async (sessionId: string, afterSeq = 0): Promise<void> => {
262
- const query = afterSeq > 0 ? `?afterSeq=${afterSeq}` : "";
263
- const streamUrl = `/route/chat/${chatName}/stream/${sessionId}${query}`;
264
- const response = await fetch(streamUrl, {
265
- credentials: "include",
266
- headers: { Accept: "text/event-stream" },
267
- });
268
- if (!response.ok) throw new Error(`Stream failed: ${response.status}`);
269
-
270
- const reader = response.body!.getReader();
271
- const decoder = new TextDecoder();
272
- let partialLine = "";
273
-
274
- while (true) {
275
- const { value, done } = await reader.read();
276
- if (done) break;
277
- const text = partialLine + decoder.decode(value, { stream: true });
278
- const lines = text.split("\n");
279
- partialLine = lines.pop() ?? "";
280
- for (const line of lines) {
281
- if (line.startsWith("data: ")) {
282
- try {
283
- const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
284
- await processEventRef.current?.(event);
285
- // Yield między eventami w tym samym TCP chunku — gdy server
286
- // wypycha kilka eventów naraz (np. replay buffer), bez tego
287
- // wszystkie setTimeline batchują się w jeden render.
288
- await new Promise<void>((r) => setTimeout(r, 0));
289
- } catch {}
290
- }
291
- }
292
- }
293
- if (partialLine.startsWith("data: ")) {
294
- try {
295
- const event = JSON.parse(partialLine.slice(6)) as ChatStreamEvent;
296
- await processEventRef.current?.(event);
297
- } catch {}
298
- }
299
- },
300
- [],
301
- );
250
+ }, [historySig, interruptedIds]);
302
251
 
303
252
  // ─── SSE event processing ───────────────────────────────────
304
- const processEventRef = useRef<((event: ChatStreamEvent) => Promise<void>) | null>(null);
253
+ const processEventRef = useRef<((event: ChatStreamEvent) => void) | null>(null);
305
254
  const processEvent = useCallback(
306
- async (event: ChatStreamEvent) => {
307
- // Seq dedup — replay buffer może wysłać te same eventy ponownie po
308
- // reconnect. Klient odrzuca seq <= lastSeq.
309
- if (event.seq != null && event.sessionId) {
310
- const lastSeq = lastSeqBySessionRef.current.get(event.sessionId) ?? 0;
311
- if (event.seq <= lastSeq) return;
312
- lastSeqBySessionRef.current.set(event.sessionId, event.seq);
313
- }
314
-
255
+ (event: ChatStreamEvent) => {
315
256
  switch (event.type) {
257
+ case "init": {
258
+ // Pierwszy event po `subscribe(messageId)`. Niesie snapshot
259
+ // in-memory `currentBlocks` — może być pusty (świeży stream) albo
260
+ // wypełniony (graceful reconnect mid-stream). Hydratuje bubble
261
+ // assistant'a.
262
+ if (!event.messageId) break;
263
+ const messageId = event.messageId;
264
+ const blocks = event.currentBlocks ?? [];
265
+ setTimeline((prev) => {
266
+ // Wytnij placeholdery i istniejące bubble assistantów dla tego
267
+ // messageId (z timeline rebuild) — zastąpimy świeżą wersją.
268
+ const cleaned = prev.filter(
269
+ (it) =>
270
+ !(
271
+ it.type === "message" &&
272
+ typeof it.id === "string" &&
273
+ it.id.startsWith(`${messageId}_t`)
274
+ ) &&
275
+ !(it.type === "tool" && blocks.some((b) => b.type === "tool_call" && b.id === it.id)),
276
+ );
277
+ let textCount = 0;
278
+ const newItems: TimelineItem[] = [];
279
+ for (const block of blocks) {
280
+ if (block.type === "text") {
281
+ newItems.push({
282
+ type: "message",
283
+ id: `${messageId}_t${textCount}`,
284
+ role: "assistant",
285
+ content: block.text,
286
+ isStreaming: true,
287
+ });
288
+ textCount++;
289
+ } else {
290
+ newItems.push({
291
+ type: "tool",
292
+ id: block.id,
293
+ toolCallId: block.id,
294
+ toolName: block.name,
295
+ params: block.arguments,
296
+ status: "executing",
297
+ calling: true,
298
+ });
299
+ }
300
+ }
301
+ // Jeśli init przyniósł 0 text blocks → wciąż dodaj pusty
302
+ // streaming placeholder, żeby text_delta miał gdzie dolepić.
303
+ if (!newItems.some((it) => it.type === "message" && it.role === "assistant")) {
304
+ newItems.push({
305
+ type: "message",
306
+ id: `${messageId}_t0`,
307
+ role: "assistant",
308
+ content: "",
309
+ isStreaming: true,
310
+ });
311
+ }
312
+ return [...cleaned, ...newItems];
313
+ });
314
+ break;
315
+ }
316
+
316
317
  case "text_delta":
317
318
  if (!event.textDelta) break;
318
319
  setTimeline((prev) => {
@@ -333,7 +334,6 @@ export function createChatComponent(
333
334
  const newId = event.messageId
334
335
  ? `${event.messageId}_streaming_${prev.length}`
335
336
  : `assistant_${Date.now()}`;
336
- currentAssistantIdRef.current = newId;
337
337
  return [
338
338
  ...prev,
339
339
  {
@@ -349,13 +349,8 @@ export function createChatComponent(
349
349
 
350
350
  case "tool_call_pending":
351
351
  // AI właśnie zaczyna tool call. Dla SERVER tools pokazujemy loader
352
- // "Przygotowuje: {name}…" od razu ViewComponent dostaje `params: {}`
353
- // ale to OK bo server tool nie używa params do init'a UI.
354
- //
355
- // Dla INTERACTIVE tools NIE pushujemy do timeline tutaj — ich
356
- // ViewComponent (np. AskQuestionsView) używa `params.questions`
357
- // od mount-time do registerInputOverride, więc musi dostać pełne
358
- // args. Czekamy na `interactive_tool_request` które niesie pełne args.
352
+ // "Przygotowuje: {name}…" od razu. Dla INTERACTIVE tools czekamy
353
+ // na `interactive_tool_request` z pełnymi args.
359
354
  if (event.toolCallId) {
360
355
  const toolDef = event.toolCallName
361
356
  ? toolsMap.get(event.toolCallName)
@@ -381,20 +376,17 @@ export function createChatComponent(
381
376
  });
382
377
  return next;
383
378
  });
384
- currentAssistantIdRef.current = null;
385
379
  }
386
380
  break;
387
381
 
388
382
  case "tool_call_arguments_delta":
389
- // Args lecą znak po znaku — UI zwykle nie potrzebuje tej granularności.
390
- // Pomijamy update'y żeby nie re-renderować bez powodu.
383
+ // Args lecą znak po znaku — UI nie potrzebuje tej granularności.
391
384
  break;
392
385
 
393
386
  case "tool_call_arguments_complete":
394
387
  // Pełne args dostępne. Trzy przypadki:
395
- // 1. Server tool już jest w timeline (od tool_call_pending) → update params/status.
396
- // 2. Interactive tool jeszcze nie jest w timeline (skipnięty w pending) → ADD tutaj
397
- // żeby zachować kolejność emisji przez AI (np. [tool, text]).
388
+ // 1. Server tool już jest w timeline (od tool_call_pending) → update.
389
+ // 2. Interactive tool jeszcze nie jest w timeline → ADD tutaj.
398
390
  // 3. Tool już dodany przez interactive_tool_request → no-op.
399
391
  if (event.toolCallId) {
400
392
  setTimeline((prev) => {
@@ -415,8 +407,6 @@ export function createChatComponent(
415
407
  : item,
416
408
  );
417
409
  }
418
- // Brak w timeline → interactive tool, dodaj teraz w odpowiednim
419
- // miejscu (po finalizacji ewentualnej trwającej streaming bubble).
420
410
  const next = prev.map((item) =>
421
411
  item.type === "message" && item.isStreaming
422
412
  ? { ...item, isStreaming: false }
@@ -433,7 +423,6 @@ export function createChatComponent(
433
423
  });
434
424
  return next;
435
425
  });
436
- currentAssistantIdRef.current = null;
437
426
  }
438
427
  break;
439
428
 
@@ -471,9 +460,6 @@ export function createChatComponent(
471
460
  for (const tc of event.toolCalls!) {
472
461
  const existing = byId.get(tc.id);
473
462
  if (existing) {
474
- // Tool już istnieje (dodany przez tool_call_arguments_complete).
475
- // Upewnij się że toolName + params są aktualne — fallback
476
- // gdyby serwer wcześniej wysłał je puste.
477
463
  const idx = next.findIndex(
478
464
  (it) => it.type === "tool" && (it as any).id === tc.id,
479
465
  );
@@ -498,11 +484,13 @@ export function createChatComponent(
498
484
  }
499
485
  return next;
500
486
  });
501
- currentAssistantIdRef.current = null;
502
487
  }
503
488
  break;
504
489
 
505
490
  case "done":
491
+ // Stream zakończył turn. setIsStreaming(false) odpali timeline
492
+ // rebuild z DB — final blocks z `assistantTurnCompleted` projection
493
+ // zastąpią streaming bubble.
506
494
  setTimeline((prev) =>
507
495
  prev.map((item) =>
508
496
  item.type === "message" && item.isStreaming
@@ -511,7 +499,6 @@ export function createChatComponent(
511
499
  ),
512
500
  );
513
501
  setIsStreaming(false);
514
- currentAssistantIdRef.current = null;
515
502
  break;
516
503
 
517
504
  case "error":
@@ -544,80 +531,102 @@ export function createChatComponent(
544
531
  ];
545
532
  });
546
533
  setIsStreaming(false);
547
- currentAssistantIdRef.current = null;
548
534
  break;
549
535
  }
550
536
  },
551
537
  [chatLabels],
552
538
  );
553
-
554
- // Keep ref in sync so consumeStream (stable callback) can call latest version
555
539
  processEventRef.current = processEvent;
556
540
 
557
- // ─── Resume/auto-open SSE dla isGenerating rowsów ────────────
558
- // Każdy `isGenerating: true` row z `sessionId` w DB triggers SSE subscribe.
559
- // Obejmuje: (a) reload mid-stream, (b) welcome-message-style auto-trigger
560
- // gdzie onEnter listener tworzy assistant turn bez user message.
561
- // `consumeStream` z `afterSeq` (zhydratowane z `partialLastSeq`) sprawia
562
- // że replay buffer skipuje już zaaplikowane chunki.
541
+ // ─── Auto-subscribe SSE per active assistant messageId ──────
542
+ // Driven przez DB history: za każdym razem gdy w DB pojawia się
543
+ // assistant row z `isGenerating=true`, otwieramy do niego SSE.
544
+ // Wszystkie scenariusze (send, respond, retry, page reload mid-stream)
545
+ // przechodzą przez ten sam mechanizm handleSend/respond/retry tylko
546
+ // wywołują mutację, ten effect załapie nowy generating row z DB query
547
+ // update.
563
548
  useEffect(() => {
564
- if (!scopeId) return;
565
- const sid = sessionIdRef.current;
566
- if (!sid) return;
567
- if (resumedSessionRef.current === sid) return;
568
- resumedSessionRef.current = sid;
569
- const afterSeq = resumeAfterSeqRef.current;
570
- // Inicjalizujemy lastSeq dla tej sesji od `afterSeq` żeby uniknąć
571
- // ponownej aplikacji już-zhydratowanych chunków (gdyby replay pominął).
572
- lastSeqBySessionRef.current.set(sid, afterSeq);
549
+ if (!activeGeneratingMessageId) return;
550
+ const messageId = activeGeneratingMessageId;
551
+ const ctrl = new AbortController();
552
+ let cancelled = false;
573
553
  setIsStreaming(true);
554
+
574
555
  (async () => {
575
556
  try {
576
- await consumeStream(sid, afterSeq);
577
- } catch {
578
- // Stream może już skończony / GC'd
579
- } finally {
557
+ const res = await fetch(
558
+ `/route/chat/${chatName}/stream/${messageId}`,
559
+ {
560
+ credentials: "include",
561
+ signal: ctrl.signal,
562
+ headers: { Accept: "text/event-stream" },
563
+ },
564
+ );
565
+ if (res.status === 410) {
566
+ // Stream nie istnieje — proces zrestartował się mid-stream
567
+ // (in-memory state utracony). Mark messageId jako interrupted,
568
+ // klient pokaże retry UI.
569
+ setInterruptedIds((prev) => new Set(prev).add(messageId));
570
+ setIsStreaming(false);
571
+ return;
572
+ }
573
+ if (!res.ok) throw new Error(`Stream failed: ${res.status}`);
574
+
575
+ const reader = res.body!.getReader();
576
+ const decoder = new TextDecoder();
577
+ let buf = "";
578
+ while (!cancelled) {
579
+ const { value, done } = await reader.read();
580
+ if (done) break;
581
+ buf += decoder.decode(value, { stream: true });
582
+ const lines = buf.split("\n");
583
+ buf = lines.pop() ?? "";
584
+ for (const line of lines) {
585
+ if (!line.startsWith("data: ")) continue;
586
+ try {
587
+ const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
588
+ processEventRef.current?.(event);
589
+ // Yield between events in the same TCP chunk — bez tego
590
+ // React batchuje setTimeline w jeden render, streaming
591
+ // niewidoczny.
592
+ await new Promise<void>((r) => setTimeout(r, 0));
593
+ } catch {}
594
+ }
595
+ }
596
+ } catch (err) {
597
+ if ((err as any)?.name === "AbortError") return;
598
+ // Network glitch / SSE hang — treat as interrupted.
599
+ setInterruptedIds((prev) => new Set(prev).add(messageId));
580
600
  setIsStreaming(false);
581
- sessionIdRef.current = null;
582
- currentAssistantIdRef.current = null;
583
601
  }
584
602
  })();
585
- }, [historySig, scopeId, consumeStream]);
603
+
604
+ return () => {
605
+ cancelled = true;
606
+ ctrl.abort();
607
+ };
608
+ }, [activeGeneratingMessageId]);
586
609
 
587
610
  // ─── Send message ───────────────────────────────────────────
588
611
  const handleSend = useCallback(
589
612
  async (content: string, options: SendMessageOptions) => {
590
613
  if (isStreaming || !scopeId) return;
591
-
592
614
  setIsStreaming(true);
593
-
594
615
  const userMsgId = `user_${Date.now()}`;
595
616
  setTimeline((prev) => [
596
617
  ...prev,
597
618
  { type: "message", id: userMsgId, role: "user", content },
598
619
  ]);
599
-
600
620
  try {
601
- const sendResult = await messageMutations.sendMessage({
621
+ await messageMutations.sendMessage({
602
622
  scopeId,
603
623
  content,
604
624
  model: options.model,
605
625
  });
606
-
607
- const { sessionId } = sendResult as { sessionId: string; messageId: string };
608
- sessionIdRef.current = sessionId;
609
- // Zapobiega dwukrotnemu konsumpowaniu SSE — resume effect zobaczy
610
- // że ten sessionId już jest "resumed" i pominie.
611
- resumedSessionRef.current = sessionId;
612
- currentAssistantIdRef.current = null;
613
-
614
- try {
615
- await consumeStream(sessionId, 0);
616
- } finally {
617
- setIsStreaming(false);
618
- sessionIdRef.current = null;
619
- currentAssistantIdRef.current = null;
620
- }
626
+ // Reszta dzieje się przez auto-subscribe effect powyżej: mutacja
627
+ // emit'uje `assistantTurnStarted` DB query pushuje fresh assistant
628
+ // row do klienta → `activeGeneratingMessageId` ustawia się → effect
629
+ // otwiera SSE.
621
630
  } catch (err) {
622
631
  const errorMsg = err instanceof Error ? err.message : chatLabels.errorLabel;
623
632
  setTimeline((prev) => [
@@ -625,11 +634,33 @@ export function createChatComponent(
625
634
  { type: "message", id: `error_${Date.now()}`, role: "assistant", content: `${chatLabels.errorLabel}: ${errorMsg}` },
626
635
  ]);
627
636
  setIsStreaming(false);
628
- sessionIdRef.current = null;
629
- currentAssistantIdRef.current = null;
630
637
  }
631
638
  },
632
- [isStreaming, scopeId, messageMutations, processEvent, chatLabels],
639
+ [isStreaming, scopeId, messageMutations, chatLabels],
640
+ );
641
+
642
+ // ─── Retry interrupted generation ───────────────────────────
643
+ const handleRetry = useCallback(
644
+ async (messageId: string) => {
645
+ if (!scopeId) return;
646
+ try {
647
+ await messageMutations.retryGeneration({ messageId });
648
+ // Mutacja usuwa interrupted row + tworzy fresh assistant row →
649
+ // auto-subscribe effect załapie nowy generating row.
650
+ setInterruptedIds((prev) => {
651
+ const next = new Set(prev);
652
+ next.delete(messageId);
653
+ return next;
654
+ });
655
+ } catch (err) {
656
+ const errorMsg = err instanceof Error ? err.message : chatLabels.errorLabel;
657
+ setTimeline((prev) => [
658
+ ...prev,
659
+ { type: "message", id: `error_${Date.now()}`, role: "assistant", content: `${chatLabels.errorLabel}: ${errorMsg}` },
660
+ ]);
661
+ }
662
+ },
663
+ [scopeId, messageMutations, chatLabels],
633
664
  );
634
665
 
635
666
  // ─── Build messages for Chat DS (only user/assistant) ───────
@@ -666,8 +697,6 @@ export function createChatComponent(
666
697
  // Interactive tool
667
698
  const respond = async (result: unknown) => {
668
699
  if (!scopeId) return;
669
-
670
- // Update timeline immediately
671
700
  setTimeline((prev) =>
672
701
  prev.map((t) =>
673
702
  t.type === "tool" && t.toolCallId === item.toolCallId
@@ -675,25 +704,15 @@ export function createChatComponent(
675
704
  : t,
676
705
  ),
677
706
  );
678
-
679
- // Send response — returns new sessionId for SSE
680
- const respondResult = await messageMutations.respondToTool({
681
- scopeId,
682
- toolCallId: item.toolCallId,
683
- toolName: item.toolName,
684
- result: JSON.stringify(result),
685
- });
686
-
687
- const { sessionId: newSessionId } = respondResult as { sessionId: string };
688
- if (!newSessionId) return;
689
-
690
- sessionIdRef.current = newSessionId;
691
- resumedSessionRef.current = newSessionId; // skip auto-resume
692
- setIsStreaming(true);
693
- currentAssistantIdRef.current = null;
694
-
695
707
  try {
696
- await consumeStream(newSessionId, 0);
708
+ await messageMutations.respondToTool({
709
+ scopeId,
710
+ toolCallId: item.toolCallId,
711
+ toolName: item.toolName,
712
+ result: JSON.stringify(result),
713
+ });
714
+ // Auto-subscribe effect załapie nowy assistant row (utworzony
715
+ // atomowo przez mutację respondToTool razem z userResponded event).
697
716
  } catch (err) {
698
717
  setTimeline((prev) => [
699
718
  ...prev,
@@ -704,10 +723,6 @@ export function createChatComponent(
704
723
  content: `${chatLabels.errorLabel}: ${err instanceof Error ? err.message : chatLabels.errorLabel}`,
705
724
  },
706
725
  ]);
707
- } finally {
708
- setIsStreaming(false);
709
- sessionIdRef.current = null;
710
- currentAssistantIdRef.current = null;
711
726
  }
712
727
  };
713
728
 
@@ -740,6 +755,26 @@ export function createChatComponent(
740
755
  timelineElements.push(
741
756
  createElement("div", { key: item.id }, renderToolItem(item)),
742
757
  );
758
+ } else if (item.type === "interrupted") {
759
+ timelineElements.push(
760
+ createElement(
761
+ "div",
762
+ {
763
+ key: item.id,
764
+ className: "flex items-center gap-2 text-sm text-muted-foreground",
765
+ },
766
+ createElement("span", null, chatLabels.interruptedLabel),
767
+ createElement(
768
+ "button",
769
+ {
770
+ type: "button",
771
+ className: "underline cursor-pointer",
772
+ onClick: () => handleRetry(item.messageId),
773
+ },
774
+ chatLabels.retryLabel,
775
+ ),
776
+ ),
777
+ );
743
778
  }
744
779
  }
745
780
 
@@ -748,6 +783,8 @@ export function createChatComponent(
748
783
  (item) => item.type === "tool" && item.calling && !toolsMap.get(item.toolName)?.isServerTool,
749
784
  );
750
785
 
786
+ void chatMessages;
787
+
751
788
  return createElement(
752
789
  ChatInputProvider,
753
790
  null,