@flamingo-stack/openframe-frontend-core 0.0.444 → 0.0.445

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.
Files changed (123) hide show
  1. package/dist/{chunk-DTMJWBDQ.js → chunk-22NSWMI6.js} +3 -3
  2. package/dist/{chunk-NDWIFCLS.js → chunk-3BHV2QAH.js} +555 -135
  3. package/dist/{chunk-NDWIFCLS.js.map → chunk-3BHV2QAH.js.map} +1 -1
  4. package/dist/{chunk-PMTR5W5G.js → chunk-3DQ5BFPM.js} +5 -5
  5. package/dist/{chunk-RALJEQWM.cjs → chunk-3HYSD7NR.cjs} +15 -15
  6. package/dist/{chunk-RALJEQWM.cjs.map → chunk-3HYSD7NR.cjs.map} +1 -1
  7. package/dist/{chunk-YP7VJPCJ.cjs → chunk-5DWPXK37.cjs} +40 -40
  8. package/dist/{chunk-YP7VJPCJ.cjs.map → chunk-5DWPXK37.cjs.map} +1 -1
  9. package/dist/{chunk-OXZLBIR7.cjs → chunk-5FUQTSTT.cjs} +18 -18
  10. package/dist/{chunk-OXZLBIR7.cjs.map → chunk-5FUQTSTT.cjs.map} +1 -1
  11. package/dist/{chunk-MG4AMIFX.js → chunk-5NYXGWBN.js} +2 -2
  12. package/dist/{chunk-LCJGOV6P.js → chunk-7R256DKK.js} +2 -2
  13. package/dist/{chunk-2PLNDTRG.cjs → chunk-DTLNCOSG.cjs} +32 -32
  14. package/dist/{chunk-2PLNDTRG.cjs.map → chunk-DTLNCOSG.cjs.map} +1 -1
  15. package/dist/{chunk-3SCNEGL2.js → chunk-FIAXFNAA.js} +5 -5
  16. package/dist/{chunk-FQASXKBK.js → chunk-G2KXNMFQ.js} +2 -2
  17. package/dist/{chunk-H7H3AFLF.js → chunk-H2DYCT6M.js} +2 -2
  18. package/dist/{chunk-CE3AITV2.js → chunk-H352AZJN.js} +6 -6
  19. package/dist/{chunk-DCDB3SM4.js → chunk-K4RASNQM.js} +3 -3
  20. package/dist/{chunk-WXU6A6CA.cjs → chunk-KISQUH33.cjs} +73 -73
  21. package/dist/{chunk-WXU6A6CA.cjs.map → chunk-KISQUH33.cjs.map} +1 -1
  22. package/dist/{chunk-BYTJNRJW.cjs → chunk-MPSA6YAM.cjs} +40 -40
  23. package/dist/{chunk-BYTJNRJW.cjs.map → chunk-MPSA6YAM.cjs.map} +1 -1
  24. package/dist/{chunk-ABIRMYG5.js → chunk-MQUS6BFR.js} +6 -6
  25. package/dist/{chunk-DSW6FIEF.cjs → chunk-N64VTHZ5.cjs} +7 -7
  26. package/dist/{chunk-DSW6FIEF.cjs.map → chunk-N64VTHZ5.cjs.map} +1 -1
  27. package/dist/{chunk-F6NMMPDL.cjs → chunk-P3FN4NAE.cjs} +29 -29
  28. package/dist/{chunk-F6NMMPDL.cjs.map → chunk-P3FN4NAE.cjs.map} +1 -1
  29. package/dist/{chunk-PHWQLKVE.js → chunk-P47NN7RY.js} +29 -31
  30. package/dist/chunk-P47NN7RY.js.map +1 -0
  31. package/dist/{chunk-2CJ734N2.cjs → chunk-PTT4J5GF.cjs} +3 -3
  32. package/dist/{chunk-2CJ734N2.cjs.map → chunk-PTT4J5GF.cjs.map} +1 -1
  33. package/dist/{chunk-4R3SVOKH.cjs → chunk-QL2EI56X.cjs} +797 -377
  34. package/dist/chunk-QL2EI56X.cjs.map +1 -0
  35. package/dist/{chunk-VI2QQIGR.cjs → chunk-R6WGCJBB.cjs} +73 -73
  36. package/dist/{chunk-VI2QQIGR.cjs.map → chunk-R6WGCJBB.cjs.map} +1 -1
  37. package/dist/{chunk-Q6XOEVPH.cjs → chunk-RXLMN6B5.cjs} +12 -12
  38. package/dist/{chunk-Q6XOEVPH.cjs.map → chunk-RXLMN6B5.cjs.map} +1 -1
  39. package/dist/{chunk-F2XHBAHT.js → chunk-TCEBFMLQ.js} +2 -2
  40. package/dist/{chunk-7CZ4MJSI.cjs → chunk-VIJARDBM.cjs} +3 -3
  41. package/dist/{chunk-7CZ4MJSI.cjs.map → chunk-VIJARDBM.cjs.map} +1 -1
  42. package/dist/{chunk-PXXS27EE.cjs → chunk-W24U7ZUV.cjs} +40 -42
  43. package/dist/chunk-W24U7ZUV.cjs.map +1 -0
  44. package/dist/{chunk-37ZZQXHK.js → chunk-XPZLIONZ.js} +4 -4
  45. package/dist/{chunk-FO4CI4R5.cjs → chunk-YCLSV2H6.cjs} +5 -5
  46. package/dist/{chunk-FO4CI4R5.cjs.map → chunk-YCLSV2H6.cjs.map} +1 -1
  47. package/dist/{chunk-U7U2CZBZ.js → chunk-Z4N7XLFG.js} +3 -3
  48. package/dist/components/case-studies/index.cjs +10 -10
  49. package/dist/components/case-studies/index.js +4 -4
  50. package/dist/components/chat/hooks/use-chunk-catchup.d.ts.map +1 -1
  51. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -1
  52. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +44 -1
  53. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  54. package/dist/components/chat/hooks/use-nats-dialog-subscription.d.ts.map +1 -1
  55. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -1
  56. package/dist/components/chat/index.cjs +10 -4
  57. package/dist/components/chat/index.cjs.map +1 -1
  58. package/dist/components/chat/index.js +9 -3
  59. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  60. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  61. package/dist/components/contact/index.cjs +5 -5
  62. package/dist/components/contact/index.js +4 -4
  63. package/dist/components/docs/index.cjs +7 -7
  64. package/dist/components/docs/index.js +6 -6
  65. package/dist/components/embeds/index.cjs +5 -5
  66. package/dist/components/embeds/index.js +4 -4
  67. package/dist/components/faq/index.cjs +6 -6
  68. package/dist/components/faq/index.js +5 -5
  69. package/dist/components/features/index.cjs +4 -4
  70. package/dist/components/features/index.js +3 -3
  71. package/dist/components/help-center-pages/index.cjs +23 -23
  72. package/dist/components/help-center-pages/index.js +14 -14
  73. package/dist/components/index.cjs +142 -136
  74. package/dist/components/index.cjs.map +1 -1
  75. package/dist/components/index.js +18 -12
  76. package/dist/components/index.js.map +1 -1
  77. package/dist/components/navigation/index.cjs +4 -4
  78. package/dist/components/navigation/index.js +3 -3
  79. package/dist/components/onboarding-guides/index.cjs +8 -8
  80. package/dist/components/onboarding-guides/index.js +7 -7
  81. package/dist/components/related-content/index.cjs +6 -6
  82. package/dist/components/related-content/index.js +5 -5
  83. package/dist/components/tickets/index.cjs +7 -7
  84. package/dist/components/tickets/index.js +6 -6
  85. package/dist/components/ui/index.cjs +10 -4
  86. package/dist/components/ui/index.cjs.map +1 -1
  87. package/dist/components/ui/index.js +9 -3
  88. package/dist/hooks/index.cjs +3 -3
  89. package/dist/hooks/index.js +2 -2
  90. package/dist/index.cjs +10 -4
  91. package/dist/index.cjs.map +1 -1
  92. package/dist/index.js +9 -3
  93. package/dist/nats/index.cjs +2 -2
  94. package/dist/nats/index.js +1 -1
  95. package/dist/nats/shared-connection.d.ts +6 -0
  96. package/dist/nats/shared-connection.d.ts.map +1 -1
  97. package/package.json +1 -1
  98. package/src/components/chat/hooks/__tests__/nats-adapter-message-updaters.test.ts +270 -0
  99. package/src/components/chat/hooks/use-chunk-catchup.ts +212 -57
  100. package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +12 -8
  101. package/src/components/chat/hooks/use-nats-chat-adapter.ts +574 -9
  102. package/src/components/chat/hooks/use-nats-dialog-subscription.ts +12 -6
  103. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +40 -3
  104. package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +62 -0
  105. package/src/components/chat/utils/message-segment-accumulator.ts +6 -0
  106. package/src/components/chat/utils/process-historical-messages.ts +25 -3
  107. package/src/nats/shared-connection.ts +42 -29
  108. package/dist/chunk-4R3SVOKH.cjs.map +0 -1
  109. package/dist/chunk-PHWQLKVE.js.map +0 -1
  110. package/dist/chunk-PXXS27EE.cjs.map +0 -1
  111. /package/dist/{chunk-DTMJWBDQ.js.map → chunk-22NSWMI6.js.map} +0 -0
  112. /package/dist/{chunk-PMTR5W5G.js.map → chunk-3DQ5BFPM.js.map} +0 -0
  113. /package/dist/{chunk-MG4AMIFX.js.map → chunk-5NYXGWBN.js.map} +0 -0
  114. /package/dist/{chunk-LCJGOV6P.js.map → chunk-7R256DKK.js.map} +0 -0
  115. /package/dist/{chunk-3SCNEGL2.js.map → chunk-FIAXFNAA.js.map} +0 -0
  116. /package/dist/{chunk-FQASXKBK.js.map → chunk-G2KXNMFQ.js.map} +0 -0
  117. /package/dist/{chunk-H7H3AFLF.js.map → chunk-H2DYCT6M.js.map} +0 -0
  118. /package/dist/{chunk-CE3AITV2.js.map → chunk-H352AZJN.js.map} +0 -0
  119. /package/dist/{chunk-DCDB3SM4.js.map → chunk-K4RASNQM.js.map} +0 -0
  120. /package/dist/{chunk-ABIRMYG5.js.map → chunk-MQUS6BFR.js.map} +0 -0
  121. /package/dist/{chunk-F2XHBAHT.js.map → chunk-TCEBFMLQ.js.map} +0 -0
  122. /package/dist/{chunk-37ZZQXHK.js.map → chunk-XPZLIONZ.js.map} +0 -0
  123. /package/dist/{chunk-U7U2CZBZ.js.map → chunk-Z4N7XLFG.js.map} +0 -0
@@ -70,6 +70,7 @@ import { useNatsDialogSubscription } from './use-nats-dialog-subscription'
70
70
  import { useRealtimeChunkProcessor } from './use-realtime-chunk-processor'
71
71
  import { useChunkCatchup } from './use-chunk-catchup'
72
72
  import { processHistoricalMessagesWithErrors } from '../utils/process-historical-messages'
73
+ import { extractIncompleteMessageState } from '../utils/extract-incomplete-message-state'
73
74
  import type {
74
75
  ChunkData,
75
76
  FetchChunksFunction,
@@ -79,6 +80,9 @@ import type {
79
80
  NatsMessageType,
80
81
  StreamingPhase,
81
82
  ChatApprovalStatus,
83
+ SegmentsUpdateMetadata,
84
+ ToolExecutionSegment,
85
+ ApprovalBatchExecutionState,
82
86
  } from '../types'
83
87
  import type {
84
88
  ChatConnectionState,
@@ -212,6 +216,16 @@ export interface UseNatsChatAdapterConfig {
212
216
  */
213
217
  batchApprovalsEnabled?: boolean
214
218
 
219
+ /**
220
+ * Approval types rendered as actionable cards inline. Mirrors
221
+ * `UseRealtimeChunkProcessorOptions.displayApprovalTypes` (default
222
+ * `['CLIENT']`) and is forwarded to the history processor so both paths
223
+ * agree. Hosts whose backend emits other types (e.g. `USER`) MUST set this
224
+ * — otherwise those approvals are escalated to a callback this adapter
225
+ * doesn't surface and the card never renders.
226
+ */
227
+ displayApprovalTypes?: string[]
228
+
215
229
  // ─── Managed-dialog mode (sidebar + history) ─────────────────────────────
216
230
 
217
231
  /**
@@ -368,6 +382,223 @@ function updateTrailingAssistant(
368
382
  ]
369
383
  }
370
384
 
385
+ /**
386
+ * Realtime user/direct/system chunks can be replays of rows already on
387
+ * screen (our own optimistic send echoed back, catchup replaying the tail
388
+ * over just-loaded history, reconnect back-fill).
389
+ *
390
+ * Dedup layers, in priority order:
391
+ * 1. `streamSeq` identity (JetStream) — exact, never confuses a genuinely
392
+ * repeated text with a replay; tracked in a per-dialog seen-set.
393
+ * 2. One-shot optimistic-echo consumption — `sendMessage` records the text
394
+ * it optimistically rendered; the backend's MESSAGE_REQUEST echo removes
395
+ * exactly one entry.
396
+ * 3. Content match over a SHORT same-author tail window — only for
397
+ * seq-less transports (plain NATS replay over just-loaded history, where
398
+ * the persisted twin sits at most a couple of rows up). Same-author so
399
+ * an admin message can never suppress a user's identical text.
400
+ */
401
+ const CONTENT_DEDUP_WINDOW = 4
402
+ const SYSTEM_DEDUP_WINDOW = 10
403
+
404
+ function hasRecentMessage(
405
+ prev: UnifiedChatMessage[],
406
+ predicate: (message: UnifiedChatMessage) => boolean,
407
+ window: number,
408
+ ): boolean {
409
+ const start = Math.max(0, prev.length - window)
410
+ for (let i = prev.length - 1; i >= start; i--) {
411
+ if (predicate(prev[i])) return true
412
+ }
413
+ return false
414
+ }
415
+
416
+ /**
417
+ * Append-mode counterpart of `updateTrailingAssistant` for post-MESSAGE_END
418
+ * continuation chunks (`SegmentsUpdateMetadata.append`). The processor emits
419
+ * single text/thinking fragments there — replacing the bubble with them (the
420
+ * old behaviour, which dropped the metadata) wiped everything the bubble
421
+ * already showed. Coalesces trailing fragments of the same type, mirroring
422
+ * the accumulator.
423
+ *
424
+ * Exported for host reuse (custom stores wiring the same processor) + tests.
425
+ */
426
+ export function appendToTrailingAssistant(
427
+ prev: UnifiedChatMessage[],
428
+ segments: MessageSegment[],
429
+ ): UnifiedChatMessage[] {
430
+ if (segments.length === 0) return prev
431
+ const last = prev[prev.length - 1]
432
+ if (!last || last.role !== 'assistant') {
433
+ return [
434
+ ...prev,
435
+ { id: nextId('assistant'), role: 'assistant', content: '', segments },
436
+ ]
437
+ }
438
+ const merged = [...(last.segments ?? [])]
439
+ for (const seg of segments) {
440
+ const tail = merged[merged.length - 1]
441
+ if (seg.type === 'text' && tail?.type === 'text') {
442
+ merged[merged.length - 1] = { type: 'text', text: tail.text + seg.text }
443
+ } else if (seg.type === 'thinking' && tail?.type === 'thinking') {
444
+ merged[merged.length - 1] = { type: 'thinking', text: tail.text + seg.text }
445
+ } else if (seg.type === 'approval_batch') {
446
+ // Approval deltas must be IDEMPOTENT: the escalated-result emit can be
447
+ // seen twice (live + catch-up replay over hydrated history), so upsert
448
+ // by request id instead of raw-appending a duplicate card.
449
+ const idx = merged.findIndex(
450
+ (m) => m.type === 'approval_batch' && m.data.approvalRequestId === seg.data.approvalRequestId,
451
+ )
452
+ if (idx !== -1) merged[idx] = seg
453
+ else merged.push(seg)
454
+ } else if (seg.type === 'approval_request') {
455
+ // Legacy cards share one requestId across an unfolded batch — key on
456
+ // (requestId, command) so each tool's card upserts its own twin.
457
+ const idx = merged.findIndex(
458
+ (m) =>
459
+ m.type === 'approval_request' &&
460
+ m.data.requestId === seg.data.requestId &&
461
+ m.data.command === seg.data.command,
462
+ )
463
+ if (idx !== -1) merged[idx] = seg
464
+ else merged.push(seg)
465
+ } else {
466
+ // Other non-text segments are pushed RAW on purpose: EXECUTING↔EXECUTED
467
+ // pairing and batch merging are `applyToolExecutionToMessages`'s job
468
+ // (post-END tool chunks never reach this helper — the processor routes
469
+ // them to onToolExecuted). Running the accumulator here would
470
+ // double-apply those rules.
471
+ merged.push(seg)
472
+ }
473
+ }
474
+ return [...prev.slice(0, -1), { ...last, segments: merged }]
475
+ }
476
+
477
+ /**
478
+ * Upsert a standalone context-compaction segment into the trailing assistant
479
+ * bubble. Compaction emissions arrive as the accumulator's CUMULATIVE array —
480
+ * only the compaction segment itself may be applied, or interleaved
481
+ * continuation text would duplicate. A `completed` segment replaces the last
482
+ * `started` one in place.
483
+ *
484
+ * Exported for host reuse + tests.
485
+ */
486
+ export function upsertTrailingCompaction(
487
+ prev: UnifiedChatMessage[],
488
+ segments: MessageSegment[],
489
+ ): UnifiedChatMessage[] {
490
+ const compaction = [...segments].reverse().find((s) => s.type === 'context_compaction')
491
+ if (!compaction) return prev
492
+ const last = prev[prev.length - 1]
493
+ if (!last || last.role !== 'assistant') {
494
+ return [
495
+ ...prev,
496
+ { id: nextId('assistant'), role: 'assistant', content: '', segments: [compaction] },
497
+ ]
498
+ }
499
+ const existing = last.segments ?? []
500
+ // LAST 'started' segment (not first): with repeated compactions in one
501
+ // bubble the earlier ones are already completed-in-place, so the newest
502
+ // 'started' is the only one this completion can belong to.
503
+ const startedIdx = existing.map((s) => s.type === 'context_compaction' && s.status === 'started').lastIndexOf(true)
504
+ const merged =
505
+ startedIdx !== -1
506
+ ? existing.map((s, i) => (i === startedIdx ? compaction : s))
507
+ : [...existing, compaction]
508
+ return [...prev.slice(0, -1), { ...last, segments: merged }]
509
+ }
510
+
511
+ /**
512
+ * Cross-message tool-execution updater for post-MESSAGE_END tool chunks
513
+ * (approved commands executing after the approval bubble, async batch
514
+ * results). Scans messages from the end:
515
+ * 1) an `approval_batch` whose `toolCalls` contains the execution id →
516
+ * merge into its `executions` map;
517
+ * 2) a matching `tool_execution` segment (same id, or EXECUTING with the
518
+ * same tool for legacy id-less backends) → update in place;
519
+ * 3) no match → append the segment to the trailing assistant bubble.
520
+ *
521
+ * Exported for host reuse + tests.
522
+ */
523
+ export function applyToolExecutionToMessages(
524
+ prev: UnifiedChatMessage[],
525
+ segment: ToolExecutionSegment,
526
+ ): UnifiedChatMessage[] {
527
+ const toolData = segment.data
528
+ const execId = toolData.toolExecutionRequestId
529
+
530
+ for (let i = prev.length - 1; i >= 0; i--) {
531
+ const message = prev[i]
532
+ if (message.role !== 'assistant' || !message.segments) continue
533
+
534
+ for (let j = message.segments.length - 1; j >= 0; j--) {
535
+ const seg = message.segments[j]
536
+
537
+ if (
538
+ execId &&
539
+ seg.type === 'approval_batch' &&
540
+ seg.data.toolCalls.some((c) => c.toolExecutionRequestId === execId)
541
+ ) {
542
+ const prevExec: ApprovalBatchExecutionState | undefined = seg.data.executions?.[execId]
543
+ // Never downgrade a done batch slot back to executing (JetStream
544
+ // redelivery of the EXECUTING chunk after EXECUTED landed). Returning
545
+ // prev = matched-with-no-change, so the caller doesn't append either.
546
+ if (toolData.type === 'EXECUTING_TOOL' && prevExec?.status === 'done') {
547
+ return prev
548
+ }
549
+ const nextExec: ApprovalBatchExecutionState =
550
+ toolData.type === 'EXECUTED_TOOL'
551
+ ? { status: 'done', result: toolData.result, success: toolData.success }
552
+ : { status: 'executing', result: prevExec?.result, success: prevExec?.success }
553
+ const nextSegments = [...message.segments]
554
+ nextSegments[j] = {
555
+ ...seg,
556
+ data: { ...seg.data, executions: { ...(seg.data.executions ?? {}), [execId]: nextExec } },
557
+ }
558
+ const next = [...prev]
559
+ next[i] = { ...message, segments: nextSegments }
560
+ return next
561
+ }
562
+
563
+ if (seg.type === 'tool_execution') {
564
+ // KNOWN LIMIT (id-less chunks only — current backends always send
565
+ // toolExecutionRequestId): the fuzzy fallback pairs only with a
566
+ // segment still EXECUTING, so a replayed id-less EXECUTING/EXECUTED
567
+ // arriving after the pair completed matches nothing and falls to the
568
+ // append below (duplicate card). Widening the predicate to EXECUTED
569
+ // twins would instead swallow a LEGITIMATE second run of the same
570
+ // tool — without ids the two are indistinguishable, and losing a
571
+ // real run is worse than a duplicate card on a legacy transport.
572
+ const matches = execId
573
+ ? seg.data.toolExecutionRequestId === execId
574
+ : seg.data.type === 'EXECUTING_TOOL' &&
575
+ seg.data.integratedToolType === toolData.integratedToolType &&
576
+ seg.data.toolFunction === toolData.toolFunction
577
+ if (!matches) continue
578
+ // Never downgrade a completed segment back to EXECUTING (replayed
579
+ // EXECUTING chunk after its EXECUTED already landed).
580
+ if (toolData.type === 'EXECUTING_TOOL' && seg.data.type === 'EXECUTED_TOOL') {
581
+ return prev
582
+ }
583
+ const nextSegments = [...message.segments]
584
+ nextSegments[j] = {
585
+ type: 'tool_execution',
586
+ data: {
587
+ ...toolData,
588
+ toolTitle: toolData.toolTitle ?? seg.data.toolTitle,
589
+ parameters: toolData.parameters || seg.data.parameters,
590
+ },
591
+ }
592
+ const next = [...prev]
593
+ next[i] = { ...message, segments: nextSegments }
594
+ return next
595
+ }
596
+ }
597
+ }
598
+
599
+ return appendToTrailingAssistant(prev, [segment])
600
+ }
601
+
371
602
  /**
372
603
  * Map `ProcessedMessage` (lib's historical-message format) into
373
604
  * `UnifiedChatMessage` (the unified-chat-state contract). Only `user`
@@ -450,6 +681,7 @@ export function useNatsChatAdapter(
450
681
  fetchChunks,
451
682
  topics,
452
683
  batchApprovalsEnabled,
684
+ displayApprovalTypes,
453
685
  fetchDialogs,
454
686
  fetchDialogMessages,
455
687
  createDialog: createDialogCallback,
@@ -484,6 +716,13 @@ export function useNatsChatAdapter(
484
716
  ? controlledDialogId
485
717
  : null
486
718
 
719
+ // Render-synchronous mirror of the active dialog. Async continuations
720
+ // (reconnect back-fill, catchup finallys) capture `dialogId` at start and
721
+ // compare against this ref when they resume — a continuation whose dialog
722
+ // is no longer active must not touch the new dialog's flags.
723
+ const currentDialogIdRef = useRef<string | null>(dialogId)
724
+ currentDialogIdRef.current = dialogId
725
+
487
726
  // ─── Message thread + streaming phase ─────────────────────────────────────
488
727
 
489
728
  const [messages, setMessages] = useState<UnifiedChatMessage[]>([])
@@ -499,7 +738,34 @@ export function useNatsChatAdapter(
499
738
  // MESSAGE_END never replays — locking the composer forever. Live chunks
500
739
  // after catchup lock normally; a reopened mid-execution dialog re-locks on
501
740
  // the next live chunk instead.
502
- const suppressAgentBusyRef = useRef(false)
741
+ // A COUNTER, not a boolean: the initial-catchup window and a reconnect
742
+ // back-fill window can overlap (reconnect during the initial fetch), and
743
+ // with a boolean whichever finished FIRST dropped the other's suppression
744
+ // mid-replay. Each window increments on entry / decrements in its finally.
745
+ const suppressAgentBusyRef = useRef(0)
746
+
747
+ // True when the trailing assistant loaded from history is an INCOMPLETE
748
+ // turn (mid-stream / mid-approval tail). The catchup replay re-streams that
749
+ // turn from its MESSAGE_START, and the replayed stream must ADOPT (replace)
750
+ // the partial history bubble. Any other MESSAGE_START over a non-empty
751
+ // trailing assistant is a NEW turn (observer / second device / post-approval
752
+ // continuation) and must open a fresh bubble instead of overwriting the
753
+ // completed one. Consumed (reset) by the first MESSAGE_START.
754
+ const adoptTrailingAssistantRef = useRef(false)
755
+
756
+ // Set after the first successful NATS connect. Later 'connected' events are
757
+ // RECONNECTS: plain NATS replays nothing, so the adapter must back-fill the
758
+ // disconnect gap via `resetAndCatchUp` or those chunks are lost forever.
759
+ const hasConnectedOnceRef = useRef(false)
760
+
761
+ // Exact-identity dedup for participant chunks (user/direct/system): the
762
+ // JetStream streamSeq of every rendered participant row. Cleared per
763
+ // dialog. See the dedup-layers note above `hasRecentMessage`.
764
+ const seenParticipantSeqsRef = useRef<Set<number>>(new Set())
765
+ // Texts `sendMessage` rendered optimistically and whose MESSAGE_REQUEST
766
+ // echo hasn't come back yet — the echo consumes exactly ONE entry, so a
767
+ // genuinely repeated send ("yes", "yes") still renders twice.
768
+ const pendingEchoTextsRef = useRef<string[]>([])
503
769
 
504
770
  // Approval status map. Used both to dedupe pending segments at render
505
771
  // time and to feed `processHistoricalMessagesWithErrors` so previously
@@ -622,7 +888,7 @@ export function useNatsChatAdapter(
622
888
  // Stable callback ref so `useRealtimeChunkProcessor`'s options object
623
889
  // doesn't churn every render and tear down the accumulator state.
624
890
  const callbacksRef: MutableRefObject<{
625
- onSegmentsUpdate: (segments: MessageSegment[]) => void
891
+ onSegmentsUpdate: (segments: MessageSegment[], meta?: SegmentsUpdateMetadata) => void
626
892
  onStreamStart: () => void
627
893
  onStreamEnd: () => void
628
894
  onAgentBusy: () => void
@@ -634,18 +900,67 @@ export function useNatsChatAdapter(
634
900
  providerName: string
635
901
  contextWindow: number
636
902
  }) => void
903
+ onToolExecuted: (segment: ToolExecutionSegment) => void
904
+ onApprovalResolved: (
905
+ requestId: string,
906
+ status: ChatApprovalStatus,
907
+ approvalType: string,
908
+ resolvedByName?: string | null,
909
+ ) => void
910
+ onUserMessage: (
911
+ text: string,
912
+ meta?: { ownerType?: string; displayName?: string; userId?: string; streamSeq?: number; contextItems?: Array<{ type: string; id: string }> },
913
+ ) => void
914
+ onDirectMessage: (
915
+ text: string,
916
+ meta?: { ownerType?: string; displayName?: string; userId?: string; streamSeq?: number },
917
+ ) => void
918
+ onSystemMessage: (text: string, meta?: { streamSeq?: number }) => void
637
919
  }> = useRef({
638
- onSegmentsUpdate: (segments: MessageSegment[]) => {
920
+ onSegmentsUpdate: (segments: MessageSegment[], meta?: SegmentsUpdateMetadata) => {
921
+ // Standalone compaction updates carry the accumulator's cumulative
922
+ // array — apply only the compaction segment (upsert) or interleaved
923
+ // continuation text would duplicate.
924
+ if (meta?.append && meta.isCompacting) {
925
+ setMessages((prev) => upsertTrailingCompaction(prev, segments))
926
+ return
927
+ }
928
+ // Post-MESSAGE_END continuation fragments append into the existing
929
+ // bubble; replacing (the pre-fix behaviour, which dropped `meta`)
930
+ // wiped the completed reply and left only the newest fragment.
931
+ if (meta?.append) {
932
+ setMessages((prev) => appendToTrailingAssistant(prev, segments))
933
+ return
934
+ }
639
935
  setMessages((prev) => updateTrailingAssistant(prev, segments))
640
936
  },
641
- onStreamStart: () => setStreamingPhase('streaming'),
937
+ onStreamStart: () => {
938
+ setStreamingPhase('streaming')
939
+ // A new stream must never overwrite a COMPLETED trailing assistant
940
+ // bubble (observer tab / second device / post-approval continuation
941
+ // turn). Open a fresh bubble unless the trailing assistant is empty
942
+ // (our own optimistic placeholder) or is the incomplete history tail
943
+ // the catchup replay is legitimately re-streaming (adopt-once flag).
944
+ const adoptTail = adoptTrailingAssistantRef.current
945
+ adoptTrailingAssistantRef.current = false
946
+ setMessages((prev) => {
947
+ const last = prev[prev.length - 1]
948
+ if (!last || last.role !== 'assistant' || adoptTail) return prev
949
+ const hasContent = (last.segments?.length ?? 0) > 0 || last.content !== ''
950
+ if (!hasContent) return prev
951
+ return [
952
+ ...prev,
953
+ { id: nextId('assistant'), role: 'assistant', content: '', segments: [] },
954
+ ]
955
+ })
956
+ },
642
957
  onStreamEnd: () => setStreamingPhase('idle'),
643
958
  // Agent is executing (approved) commands outside an open stream — keep
644
959
  // the composer locked exactly like the in-stream phases. An open stream
645
960
  // keeps ownership: only 'idle' upgrades to 'thinking'. Suppressed during
646
961
  // initial catchup so a replayed dead tail can't lock a finished dialog.
647
962
  onAgentBusy: () => {
648
- if (suppressAgentBusyRef.current) return
963
+ if (suppressAgentBusyRef.current > 0) return
649
964
  setStreamingPhase((p) => (p === 'idle' ? 'thinking' : p))
650
965
  },
651
966
  // Terminal turn failures can arrive without a MESSAGE_END; unlock the
@@ -662,6 +977,166 @@ export function useNatsChatAdapter(
662
977
  modelLabel: meta.modelDisplayName || meta.modelName || null,
663
978
  contextWindowMaxTokens: meta.contextWindow || null,
664
979
  }),
980
+ // Post-MESSAGE_END tool chunks (approved commands executing after the
981
+ // approval bubble, async batch results). Routed through the cross-message
982
+ // updater — the accumulator was reset at MESSAGE_END, so the old
983
+ // fallthrough replaced the whole trailing bubble with one tool segment.
984
+ onToolExecuted: (segment: ToolExecutionSegment) => {
985
+ setMessages((prev) => applyToolExecutionToMessages(prev, segment))
986
+ },
987
+ // Cross-message approval status flip. Wiring this also makes the
988
+ // processor skip its cumulative re-emit for resolved approvals — which,
989
+ // post-MESSAGE_END, was an empty array that blanked the trailing bubble.
990
+ onApprovalResolved: (requestId, status, _approvalType, resolvedByName) => {
991
+ setMessages((prev) => {
992
+ let changed = false
993
+ const next = prev.map((m) => {
994
+ if (m.role !== 'assistant' || !m.segments) return m
995
+ let msgChanged = false
996
+ const segs = m.segments.map((s) => {
997
+ if (s.type === 'approval_request' && s.data.requestId === requestId && s.status !== status) {
998
+ msgChanged = true
999
+ return { ...s, status }
1000
+ }
1001
+ if (s.type === 'approval_batch' && s.data.approvalRequestId === requestId) {
1002
+ const nextResolvedBy = resolvedByName ?? s.resolvedByName
1003
+ if (s.status === status && nextResolvedBy === s.resolvedByName) return s
1004
+ msgChanged = true
1005
+ return { ...s, status, resolvedByName: nextResolvedBy }
1006
+ }
1007
+ return s
1008
+ })
1009
+ if (!msgChanged) return m
1010
+ changed = true
1011
+ return { ...m, segments: segs }
1012
+ })
1013
+ return changed ? next : prev
1014
+ })
1015
+ // Mirror into the status map so history re-processing (dialog reopen,
1016
+ // pagination) renders the card resolved instead of actionable.
1017
+ setApprovalStatuses((prev) =>
1018
+ prev[requestId] === status ? prev : { ...prev, [requestId]: status },
1019
+ )
1020
+ },
1021
+ // MESSAGE_REQUEST echo — a user message from THIS or another session.
1022
+ onUserMessage: (text, meta) => {
1023
+ if (!text) return
1024
+ // Layer 1 FIRST: exact event identity. A seq we've handled is a
1025
+ // replay; a fresh seq is appended with NO content matching, so
1026
+ // genuinely repeated texts from other participants always render.
1027
+ // Must run (and RECORD) before the echo consume below — the echo
1028
+ // branch early-returns, and skipping the record there let an
1029
+ // at-least-once redelivery of the same event pass as fresh (and eat
1030
+ // a second pending echo entry).
1031
+ const seq = meta?.streamSeq
1032
+ if (typeof seq === 'number') {
1033
+ if (seenParticipantSeqsRef.current.has(seq)) return
1034
+ seenParticipantSeqsRef.current.add(seq)
1035
+ }
1036
+ // Layer 2: our own optimistic echo — consume exactly one recorded send.
1037
+ if (meta?.ownerType !== 'ADMIN') {
1038
+ const echoIdx = pendingEchoTextsRef.current.indexOf(text)
1039
+ if (echoIdx !== -1) {
1040
+ pendingEchoTextsRef.current.splice(echoIdx, 1)
1041
+ return
1042
+ }
1043
+ }
1044
+ const authorType = meta?.ownerType === 'ADMIN' ? 'admin' : 'user'
1045
+ setMessages((prev) => {
1046
+ // Layer 3: seq-less transports only — same-author content twin in the
1047
+ // immediate tail (replay over just-loaded history).
1048
+ if (
1049
+ typeof seq !== 'number' &&
1050
+ hasRecentMessage(
1051
+ prev,
1052
+ (m) => m.role === 'user' && (m.authorType ?? 'user') === authorType && m.content === text,
1053
+ CONTENT_DEDUP_WINDOW,
1054
+ )
1055
+ ) {
1056
+ return prev
1057
+ }
1058
+ return [
1059
+ ...prev,
1060
+ {
1061
+ id: nextId('user'),
1062
+ role: 'user',
1063
+ content: text,
1064
+ ...(meta?.displayName ? { name: meta.displayName } : {}),
1065
+ authorType,
1066
+ ...(meta?.contextItems && meta.contextItems.length > 0
1067
+ ? {
1068
+ contextItems: meta.contextItems.map((ci) => ({
1069
+ type: ci.type,
1070
+ id: ci.id,
1071
+ label: (ci as { label?: string }).label ?? ci.id,
1072
+ })),
1073
+ }
1074
+ : {}),
1075
+ },
1076
+ ]
1077
+ })
1078
+ },
1079
+ // Technician / admin direct message into the dialog.
1080
+ onDirectMessage: (text, meta) => {
1081
+ if (!text) return
1082
+ const seq = meta?.streamSeq
1083
+ if (typeof seq === 'number') {
1084
+ if (seenParticipantSeqsRef.current.has(seq)) return
1085
+ seenParticipantSeqsRef.current.add(seq)
1086
+ }
1087
+ setMessages((prev) => {
1088
+ // Seq-less fallback matches only prior ADMIN-authored twins — a
1089
+ // client's identical text must never suppress the technician's.
1090
+ if (
1091
+ typeof seq !== 'number' &&
1092
+ hasRecentMessage(
1093
+ prev,
1094
+ (m) => m.role === 'user' && m.authorType === 'admin' && m.content === text,
1095
+ CONTENT_DEDUP_WINDOW,
1096
+ )
1097
+ ) {
1098
+ return prev
1099
+ }
1100
+ return [
1101
+ ...prev,
1102
+ {
1103
+ id: `direct-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
1104
+ role: 'user',
1105
+ content: text,
1106
+ name: meta?.displayName ?? 'Admin',
1107
+ authorType: 'admin',
1108
+ },
1109
+ ]
1110
+ })
1111
+ },
1112
+ // System notice — rendered as a name-only row (same shape the history
1113
+ // processor produces via `pushStandaloneMessages`).
1114
+ onSystemMessage: (text, meta) => {
1115
+ if (!text) return
1116
+ const seq = meta?.streamSeq
1117
+ if (typeof seq === 'number') {
1118
+ if (seenParticipantSeqsRef.current.has(seq)) return
1119
+ seenParticipantSeqsRef.current.add(seq)
1120
+ }
1121
+ setMessages((prev) => {
1122
+ if (
1123
+ typeof seq !== 'number' &&
1124
+ hasRecentMessage(prev, (m) => m.authorType === 'system' && m.name === text, SYSTEM_DEDUP_WINDOW)
1125
+ ) {
1126
+ return prev
1127
+ }
1128
+ return [
1129
+ ...prev,
1130
+ {
1131
+ id: `system-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
1132
+ role: 'user',
1133
+ content: '',
1134
+ name: text,
1135
+ authorType: 'system',
1136
+ },
1137
+ ]
1138
+ })
1139
+ },
665
1140
  })
666
1141
 
667
1142
  // Real-time chunk → segment processor. Approval handlers route through
@@ -669,17 +1144,25 @@ export function useNatsChatAdapter(
669
1144
  // host) doesn't tear down the accumulator.
670
1145
  const { processChunk, reset: resetAccumulator } = useRealtimeChunkProcessor({
671
1146
  callbacks: {
672
- onSegmentsUpdate: (segments) => callbacksRef.current.onSegmentsUpdate(segments),
1147
+ onSegmentsUpdate: (segments, meta) => callbacksRef.current.onSegmentsUpdate(segments, meta),
673
1148
  onStreamStart: () => callbacksRef.current.onStreamStart(),
674
1149
  onStreamEnd: () => callbacksRef.current.onStreamEnd(),
675
1150
  onAgentBusy: () => callbacksRef.current.onAgentBusy(),
676
1151
  onError: () => callbacksRef.current.onError(),
677
1152
  onTokenUsage: (data) => callbacksRef.current.onTokenUsage(data),
678
1153
  onMetadata: (meta) => callbacksRef.current.onMetadata(meta),
1154
+ onToolExecuted: (segment) => callbacksRef.current.onToolExecuted(segment),
1155
+ onApprovalResolved: (requestId, status, approvalType, resolvedByName) =>
1156
+ callbacksRef.current.onApprovalResolved(requestId, status, approvalType, resolvedByName),
1157
+ onUserMessage: (text, meta) => callbacksRef.current.onUserMessage(text, meta),
1158
+ onDirectMessage: (text, meta) => callbacksRef.current.onDirectMessage(text, meta),
1159
+ onSystemMessage: (text, meta) => callbacksRef.current.onSystemMessage(text, meta),
679
1160
  onApprove: accumApprove,
680
1161
  onReject: accumReject,
681
1162
  },
682
1163
  batchApprovalsEnabled,
1164
+ approvalStatuses,
1165
+ ...(displayApprovalTypes ? { displayApprovalTypes } : {}),
683
1166
  })
684
1167
 
685
1168
  // History catchup — back-fills chunks emitted while the adapter was
@@ -689,6 +1172,8 @@ export function useNatsChatAdapter(
689
1172
  catchUpChunks,
690
1173
  startInitialBuffering,
691
1174
  resetChunkTracking,
1175
+ resetAndCatchUp,
1176
+ isBufferingActive,
692
1177
  } = useChunkCatchup({
693
1178
  dialogId: active ? dialogId : null,
694
1179
  onChunkReceived: (chunk: ChunkData) => processChunk(chunk),
@@ -731,11 +1216,23 @@ export function useNatsChatAdapter(
731
1216
  onReject: accumReject,
732
1217
  approvalStatuses,
733
1218
  batchApprovalsEnabled,
1219
+ ...(displayApprovalTypes ? { displayApprovalTypes } : {}),
734
1220
  },
735
1221
  )
736
1222
  const unified = mapProcessedToUnified(rawProcessed)
737
1223
  if (cursor === undefined) {
738
- // First page — replace.
1224
+ // First page — replace. When the trailing assistant is an
1225
+ // INCOMPLETE turn, the catchup replay will re-stream it from its
1226
+ // MESSAGE_START — let that stream adopt (replace) the partial
1227
+ // bubble instead of opening a duplicate one.
1228
+ // Gated on an ACTIVE catchup buffer: when the catchup already
1229
+ // finalized before this history page resolved, no replay is coming
1230
+ // to consume the flag — arming it anyway would hand the adoption
1231
+ // to the NEXT genuine turn, which would overwrite the partial
1232
+ // bubble instead of opening a fresh one.
1233
+ adoptTrailingAssistantRef.current =
1234
+ isBufferingActive() &&
1235
+ extractIncompleteMessageState(rawProcessed[rawProcessed.length - 1]) !== undefined
739
1236
  setMessages(unified)
740
1237
  } else {
741
1238
  // Older page — prepend.
@@ -762,8 +1259,10 @@ export function useNatsChatAdapter(
762
1259
  chatTypeFilter,
763
1260
  approvalStatuses,
764
1261
  batchApprovalsEnabled,
1262
+ displayApprovalTypes,
765
1263
  accumApprove,
766
1264
  accumReject,
1265
+ isBufferingActive,
767
1266
  ],
768
1267
  )
769
1268
 
@@ -781,6 +1280,9 @@ export function useNatsChatAdapter(
781
1280
 
782
1281
  // Drop accumulator + message state for the previous dialog.
783
1282
  resetAccumulator()
1283
+ adoptTrailingAssistantRef.current = false
1284
+ seenParticipantSeqsRef.current.clear()
1285
+ pendingEchoTextsRef.current = []
784
1286
  setMessages([])
785
1287
  setMessagesNextCursor(null)
786
1288
  setDialogTokenUsage(null)
@@ -806,7 +1308,7 @@ export function useNatsChatAdapter(
806
1308
  useEffect(() => {
807
1309
  if (!active || !dialogId) return
808
1310
  // Gate onAgentBusy for the replay window (see suppressAgentBusyRef).
809
- suppressAgentBusyRef.current = true
1311
+ suppressAgentBusyRef.current += 1
810
1312
  resetChunkTracking()
811
1313
  startInitialBuffering()
812
1314
  catchUpChunks()
@@ -814,7 +1316,16 @@ export function useNatsChatAdapter(
814
1316
  console.error('[useNatsChatAdapter] initial catchup failed:', err)
815
1317
  })
816
1318
  .finally(() => {
817
- suppressAgentBusyRef.current = false
1319
+ suppressAgentBusyRef.current = Math.max(0, suppressAgentBusyRef.current - 1)
1320
+ // The adopt-once flag targets the replayed MESSAGE_START of the
1321
+ // incomplete history tail. When the replay produced none (chunk
1322
+ // store expired past its ~10-min retention / empty), the armed flag
1323
+ // would survive and make the NEXT genuine turn (post-approval
1324
+ // continuation, second device) adopt-and-overwrite the partial
1325
+ // bubble. Scoped: only the still-active dialog clears its own flag.
1326
+ if (dialogId === currentDialogIdRef.current) {
1327
+ adoptTrailingAssistantRef.current = false
1328
+ }
818
1329
  })
819
1330
  }, [active, dialogId, resetChunkTracking, startInitialBuffering, catchUpChunks])
820
1331
 
@@ -839,6 +1350,54 @@ export function useNatsChatAdapter(
839
1350
  // First successful event marks the connection as up.
840
1351
  setConnectionState('connected')
841
1352
  },
1353
+ onConnect: () => {
1354
+ // Plain NATS pub/sub has no replay: chunks published while the socket
1355
+ // was down are gone unless we back-fill. The FIRST connect is covered
1356
+ // by the initial catchup effect; every later one is a reconnect.
1357
+ if (!hasConnectedOnceRef.current) {
1358
+ hasConnectedOnceRef.current = true
1359
+ return
1360
+ }
1361
+ suppressAgentBusyRef.current += 1
1362
+ // Buffer live deliveries for the WHOLE back-fill, including the history
1363
+ // await — otherwise chunks landing mid-fetch are processed unbuffered
1364
+ // (advancing lastSequenceId + dedup keys) and the history replace then
1365
+ // wipes their content with no way to replay them. `resetAndCatchUp`
1366
+ // keeps an already-active buffer, so nothing collected here is lost.
1367
+ startInitialBuffering()
1368
+ void (async () => {
1369
+ try {
1370
+ // History FIRST: the backend never stores DIRECT_MESSAGE/SYSTEM
1371
+ // chunks in its catchup store (instant types go straight to Mongo),
1372
+ // so the chunk back-fill can't recover them — the persisted history
1373
+ // page is the only source. Then replay the unsaved chunk tail on
1374
+ // top of the fresh snapshot.
1375
+ if (dialogId && fetchDialogMessages) {
1376
+ await loadDialogHistory(dialogId)
1377
+ }
1378
+ // The user may have switched dialogs during the await — the new
1379
+ // dialog's own effects run their catchup cycle; re-running it here
1380
+ // (via refs it would target the NEW dialog) would reset that cycle
1381
+ // mid-flight.
1382
+ if (dialogId !== currentDialogIdRef.current) return
1383
+ await resetAndCatchUp()
1384
+ } catch (err) {
1385
+ console.error('[useNatsChatAdapter] reconnect catchup failed:', err)
1386
+ } finally {
1387
+ suppressAgentBusyRef.current = Math.max(0, suppressAgentBusyRef.current - 1)
1388
+ // The adopt-once flag targets the replayed MESSAGE_START of the
1389
+ // incomplete tail — but on reconnect that START was already
1390
+ // consumed live and is dedup-skipped by the replay, so it never
1391
+ // fires onStreamStart. Clear the flag or the NEXT genuine turn
1392
+ // adopts (and overwrites) the completed trailing bubble. Scoped:
1393
+ // a stale back-fill must not clear the NEW dialog's freshly armed
1394
+ // flag.
1395
+ if (dialogId === currentDialogIdRef.current) {
1396
+ adoptTrailingAssistantRef.current = false
1397
+ }
1398
+ }
1399
+ })()
1400
+ },
842
1401
  })
843
1402
 
844
1403
  // Without a finer hook signal we treat "active + dialog selected" as
@@ -914,6 +1473,12 @@ export function useNatsChatAdapter(
914
1473
  ): Promise<void> => {
915
1474
  const hidden = sendOptions?.hidden ?? false
916
1475
 
1476
+ // Record the text so the backend's MESSAGE_REQUEST echo consumes this
1477
+ // send instead of rendering a duplicate row (cap keeps the list from
1478
+ // growing if a backend never echoes).
1479
+ pendingEchoTextsRef.current.push(text)
1480
+ if (pendingEchoTextsRef.current.length > 5) pendingEchoTextsRef.current.shift()
1481
+
917
1482
  // Optimistically append the user bubble + an empty assistant
918
1483
  // placeholder. The assistant body fills in as NATS chunks land.
919
1484
  setMessages((prev) => [