@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
@@ -182,7 +182,13 @@ export function useNatsDialogSubscription({
182
182
  }
183
183
  }, [enabled, wsUrl])
184
184
 
185
+ // Key the subscription effects on the topics CONTENT, not the array
186
+ // identity — hosts often pass an inline array, and an identity dep made
187
+ // every render tear down + recreate the subscriptions (plain NATS delivers
188
+ // nothing during that gap, so chunks were silently lost).
185
189
  const topicsKey = topics.join(',')
190
+ const topicsRef = useRef(topics)
191
+ topicsRef.current = topics
186
192
  const lastSubscribedDialogIdRef = useRef<string | null>(null)
187
193
  const isConnectedRef = useRef(isConnected)
188
194
 
@@ -260,7 +266,7 @@ export function useNatsDialogSubscription({
260
266
  }
261
267
  }
262
268
 
263
- topics.forEach((topic) => {
269
+ topicsRef.current.forEach((topic) => {
264
270
  const subscription = client.subscribeBytes(
265
271
  `chat.${dialogId}.${topic}`,
266
272
  handleMessage(topic),
@@ -268,7 +274,7 @@ export function useNatsDialogSubscription({
268
274
  )
269
275
  subscriptionRefs.current.set(topic, subscription)
270
276
  })
271
-
277
+
272
278
  lastSubscribedDialogIdRef.current = dialogId
273
279
  setIsSubscribed(true)
274
280
  onSubscribedRef.current?.()
@@ -292,7 +298,7 @@ export function useNatsDialogSubscription({
292
298
  lastSubscribedDialogIdRef.current = null
293
299
  abortControllerRef.current = null
294
300
  }
295
- }, [enabled, dialogId, topicsKey, topics])
301
+ }, [enabled, dialogId, topicsKey])
296
302
 
297
303
  useEffect(() => {
298
304
  if (!enabled || !currentDialogIdRef.current || !isConnected) {
@@ -322,7 +328,7 @@ export function useNatsDialogSubscription({
322
328
  }
323
329
  }
324
330
 
325
- topics.forEach((topic) => {
331
+ topicsRef.current.forEach((topic) => {
326
332
  const subscription = client.subscribeBytes(
327
333
  `chat.${dialogId}.${topic}`,
328
334
  handleMessage(topic),
@@ -330,14 +336,14 @@ export function useNatsDialogSubscription({
330
336
  )
331
337
  subscriptionRefs.current.set(topic, subscription)
332
338
  })
333
-
339
+
334
340
  lastSubscribedDialogIdRef.current = dialogId
335
341
  setIsSubscribed(true)
336
342
  onSubscribedRef.current?.()
337
343
  } else if (subscriptionRefs.current.size > 0) {
338
344
  setIsSubscribed(true)
339
345
  }
340
- }, [isConnected, enabled, topics, topicsKey])
346
+ }, [isConnected, enabled, topicsKey])
341
347
 
342
348
  return { isConnected, isSubscribed, reconnectionCount }
343
349
  }
@@ -193,6 +193,16 @@ export function useRealtimeChunkProcessor(
193
193
  // is the source of truth. Don't fire onToolExecuted here — its
194
194
  // cross-message scan is first-match-wins and could touch a
195
195
  // same-execId segment in a prior bubble (agent retry case).
196
+ //
197
+ // KNOWN EDGE (accepted): a slow async batch tool whose EXECUTED
198
+ // lands only AFTER the continuation stream's MESSAGE_START takes
199
+ // this path too — the freshly reset accumulator has no batch
200
+ // segment to merge into, so the chunk renders as a standalone card
201
+ // in the new bubble while the prior batch card's executions slot
202
+ // stays 'executing' until the next history refetch. Routing such
203
+ // chunks cross-message would reintroduce the retry hazard above;
204
+ // revisit only if the backend confirms batch executions can
205
+ // overlap the continuation stream.
196
206
  const segments = accumulator.addToolExecution(action.segment)
197
207
  emitSegments(segments)
198
208
  break
@@ -286,6 +296,26 @@ export function useRealtimeChunkProcessor(
286
296
  approvalType: escalatedData.approvalType,
287
297
  })
288
298
 
299
+ // The escalated card was never displayed inline, so this emit is
300
+ // what surfaces it after resolution. In-stream the cumulative
301
+ // array is correct; post-MESSAGE_END the accumulator was RESET,
302
+ // so a cumulative (replace-mode) emit would wipe the trailing
303
+ // bubble down to the lone card — emit only the resolved card(s)
304
+ // as an append instead. Store hosts upsert batches by request id,
305
+ // so a replayed append stays idempotent.
306
+ const emitResolved = (segments: MessageSegment[]) => {
307
+ if (isInStreamRef.current) {
308
+ emitSegments(segments)
309
+ return
310
+ }
311
+ const delta = segments.filter(
312
+ (s) =>
313
+ (s.type === 'approval_request' && s.data.requestId === requestId) ||
314
+ (s.type === 'approval_batch' && s.data.approvalRequestId === requestId),
315
+ )
316
+ emitSegments(delta, { append: true })
317
+ }
318
+
289
319
  if (escalatedData.toolCalls && escalatedData.toolCalls.length > 0) {
290
320
  if (batchApprovalsEnabled) {
291
321
  const segments = accumulator.addApprovalBatch(
@@ -296,7 +326,7 @@ export function useRealtimeChunkProcessor(
296
326
  undefined,
297
327
  resolvedByName,
298
328
  )
299
- emitSegments(segments)
329
+ emitResolved(segments)
300
330
  } else {
301
331
  let segments = accumulator.getSegments()
302
332
  for (const call of escalatedData.toolCalls) {
@@ -309,7 +339,7 @@ export function useRealtimeChunkProcessor(
309
339
  status,
310
340
  )
311
341
  }
312
- emitSegments(segments)
342
+ emitResolved(segments)
313
343
  }
314
344
  } else {
315
345
  const segments = accumulator.addApprovalRequest(
@@ -319,7 +349,7 @@ export function useRealtimeChunkProcessor(
319
349
  escalatedData.approvalType,
320
350
  status,
321
351
  )
322
- emitSegments(segments)
352
+ emitResolved(segments)
323
353
  }
324
354
  } else {
325
355
  // Always keep the in-memory accumulator in sync so a following
@@ -419,6 +449,13 @@ export function useRealtimeChunkProcessor(
419
449
  hasInitializedWithData.current = false
420
450
  sawDirectMessageRef.current = false
421
451
  directTeardownFiredRef.current = false
452
+ // Stream-state flags are per-dialog too. Leaking them across a reset
453
+ // made the next dialog's cold-start chunks take the post-stream append
454
+ // path (hasEverStreamed stuck true) — which consumers may silently drop
455
+ // when no assistant bubble exists yet — and left tool/compaction routing
456
+ // thinking a stream was still open (isInStream stuck true).
457
+ isInStreamRef.current = false
458
+ hasEverStreamedRef.current = false
422
459
  }, [])
423
460
 
424
461
  const updateApprovalStatus = useCallback(
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Regression guard for `displayApprovalTypes` semantics in the history
3
+ * processors. An OMITTED option must render EVERY approval type inline —
4
+ * consumers (tickets/mingo admin surfaces) that pass a wider list to their
5
+ * realtime processor but omit it on the history path relied on this, and a
6
+ * silent `['CLIENT']` default made their pending ADMIN approval cards vanish
7
+ * on every reload/reconnect refetch. Parity with realtime is opt-in via an
8
+ * explicit list.
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest'
12
+ import { processHistoricalMessagesWithErrors } from '../process-historical-messages'
13
+ import type { HistoricalMessage, MessageSegment } from '../../types'
14
+
15
+ const adminApprovalMessage = (id: string): HistoricalMessage => ({
16
+ id,
17
+ createdAt: '2026-07-17T12:00:00Z',
18
+ owner: { type: 'ASSISTANT' },
19
+ messageData: [
20
+ {
21
+ type: 'APPROVAL_REQUEST',
22
+ approvalRequestId: `req-${id}`,
23
+ approvalType: 'ADMIN',
24
+ command: 'systemctl restart nats',
25
+ },
26
+ ],
27
+ })
28
+
29
+ const approvalSegments = (segments: MessageSegment[] | string | undefined) =>
30
+ Array.isArray(segments)
31
+ ? segments.filter((s) => s.type === 'approval_request' || s.type === 'approval_batch')
32
+ : []
33
+
34
+ describe('processHistoricalMessagesWithErrors — displayApprovalTypes', () => {
35
+ it('renders non-CLIENT approvals inline when the option is OMITTED', () => {
36
+ const { messages, escalatedApprovals } = processHistoricalMessagesWithErrors([
37
+ adminApprovalMessage('m1'),
38
+ ])
39
+ const rendered = messages.flatMap((m) => approvalSegments(m.content))
40
+ expect(rendered).toHaveLength(1)
41
+ expect(escalatedApprovals.size).toBe(0)
42
+ })
43
+
44
+ it('escalates (does not render) approval types excluded by an explicit list', () => {
45
+ const { messages, escalatedApprovals } = processHistoricalMessagesWithErrors(
46
+ [adminApprovalMessage('m1')],
47
+ { displayApprovalTypes: ['CLIENT'] },
48
+ )
49
+ const rendered = messages.flatMap((m) => approvalSegments(m.content))
50
+ expect(rendered).toHaveLength(0)
51
+ expect(escalatedApprovals.has('req-m1')).toBe(true)
52
+ })
53
+
54
+ it('renders approval types included by an explicit list', () => {
55
+ const { messages } = processHistoricalMessagesWithErrors(
56
+ [adminApprovalMessage('m1')],
57
+ { displayApprovalTypes: ['CLIENT', 'ADMIN'] },
58
+ )
59
+ const rendered = messages.flatMap((m) => approvalSegments(m.content))
60
+ expect(rendered).toHaveLength(1)
61
+ })
62
+ })
@@ -225,6 +225,12 @@ export class MessageSegmentAccumulator {
225
225
  if (!hasCall) return seg
226
226
 
227
227
  const prev: ApprovalBatchExecutionState | undefined = seg.data.executions?.[execId]
228
+ // Never downgrade a done slot back to executing (redelivered EXECUTING
229
+ // after its EXECUTED already landed) — matched, but unchanged.
230
+ if (toolData.type === 'EXECUTING_TOOL' && prev?.status === 'done') {
231
+ matched = true
232
+ return seg
233
+ }
228
234
  const next: ApprovalBatchExecutionState =
229
235
  toolData.type === 'EXECUTED_TOOL'
230
236
  ? { status: 'done', result: toolData.result, success: toolData.success }
@@ -78,7 +78,15 @@ export function processHistoricalMessages(
78
78
  onReject,
79
79
  chatTypeFilter,
80
80
  approvalStatuses = {},
81
+ // An omitted option means "display every approval type" — the original
82
+ // history semantics. Deliberately NOT defaulted to the realtime
83
+ // processor's ['CLIENT']: consumers that pass a wider list to their
84
+ // realtime processor but omit it on the history path would silently lose
85
+ // pending non-CLIENT approval cards on every reload/reconnect refetch
86
+ // (they also ignore `escalatedApprovals`). Realtime/history parity is
87
+ // opt-in: pass the same explicit list to both.
81
88
  displayApprovalTypes,
89
+ batchApprovalsEnabled,
82
90
  } = options
83
91
 
84
92
  const processedMessages: ProcessedMessage[] = []
@@ -174,7 +182,13 @@ export function processHistoricalMessages(
174
182
  lastAssistantId = msg.id
175
183
 
176
184
  messageDataArray.forEach((data) => {
177
- processMessageData(data, accumulator, approvalStatuses, { displayApprovalTypes }, escalatedApprovals)
185
+ processMessageData(
186
+ data,
187
+ accumulator,
188
+ approvalStatuses,
189
+ { displayApprovalTypes, batchApprovalsEnabled },
190
+ escalatedApprovals,
191
+ )
178
192
  })
179
193
 
180
194
  // Check if we should flush (next message is from user or last message)
@@ -473,7 +487,9 @@ export function processHistoricalMessagesWithErrors(
473
487
  messages: HistoricalMessage[],
474
488
  options: MessageProcessingOptions = {}
475
489
  ): ProcessHistoricalMessagesResult {
476
- const { chatTypeFilter, assistantName = 'Fae', assistantType = 'fae', assistantAvatar, onApprove, onReject, approvalStatuses = {}, displayApprovalTypes } = options
490
+ // displayApprovalTypes omitted = display every approval type (original
491
+ // history semantics — see the matching note in processHistoricalMessages).
492
+ const { chatTypeFilter, assistantName = 'Fae', assistantType = 'fae', assistantAvatar, onApprove, onReject, approvalStatuses = {}, displayApprovalTypes, batchApprovalsEnabled } = options
477
493
 
478
494
  const processedMessages: ProcessedMessage[] = []
479
495
  const accumulator = createMessageSegmentAccumulator({ onApprove, onReject })
@@ -563,7 +579,13 @@ export function processHistoricalMessagesWithErrors(
563
579
  lastAssistantId = msg.id
564
580
 
565
581
  messageDataArray.forEach((data) => {
566
- processMessageData(data, accumulator, approvalStatuses, { displayApprovalTypes }, escalatedApprovals)
582
+ processMessageData(
583
+ data,
584
+ accumulator,
585
+ approvalStatuses,
586
+ { displayApprovalTypes, batchApprovalsEnabled },
587
+ escalatedApprovals,
588
+ )
567
589
  })
568
590
 
569
591
  const nextMsg = messages[index + 1]
@@ -52,21 +52,30 @@ export interface ReleaseClientOptions {
52
52
  delayMs?: number
53
53
  }
54
54
 
55
- let shared: SharedConnection | null = null
56
-
55
+ // One shared connection PER URL. The previous single-slot implementation
56
+ // force-closed whatever was connected the moment any consumer acquired a
57
+ // DIFFERENT URL — even with live refs on it. With two chat surfaces on
58
+ // distinct endpoints (`/ws/nats` client chat vs `/ws/nats-api` dashboard)
59
+ // mounted at once, each acquire killed the other's socket, and the loser's
60
+ // retry loop self-cancelled (connection identity mismatch) leaving a dead
61
+ // subscription that silently received nothing.
62
+ const connections = new Map<string, SharedConnection>()
63
+
64
+ /** Legacy accessor from the single-slot era: returns the first live shared
65
+ * connection, or null. With MULTIPLE URLs connected (e.g. `/ws/nats` client
66
+ * chat + `/ws/nats-api` dashboard mounted together) "first" is whichever
67
+ * surface acquired first — an arbitrary, mount-order-dependent answer.
68
+ * Prefer `getSharedConnectionFor(url)`; this stays only for external
69
+ * registry-pinned consumers of the old single-connection API. */
57
70
  export function getSharedConnection(): SharedConnection | null {
58
- return shared
71
+ const first = connections.values().next()
72
+ return first.done ? null : first.value
59
73
  }
60
74
 
61
75
  export function acquireClient(url: string, opts?: AcquireClientOptions): SharedConnection {
62
- if (shared?.wsUrl !== url) {
63
- if (shared) {
64
- if (shared.closeTimer) clearTimeout(shared.closeTimer)
65
- const old = shared
66
- shared = null
67
- void old.client.close().catch(() => {})
68
- }
76
+ let conn = connections.get(url)
69
77
 
78
+ if (!conn) {
70
79
  const {
71
80
  name = 'openframe-frontend',
72
81
  user = 'machine',
@@ -87,7 +96,7 @@ export function acquireClient(url: string, opts?: AcquireClientOptions): SharedC
87
96
  maxPingOut,
88
97
  })
89
98
 
90
- shared = {
99
+ conn = {
91
100
  wsUrl: url,
92
101
  client,
93
102
  connectPromise: null,
@@ -96,39 +105,43 @@ export function acquireClient(url: string, opts?: AcquireClientOptions): SharedC
96
105
  retryTimer: null,
97
106
  retryOwner: null,
98
107
  }
108
+ connections.set(url, conn)
99
109
  }
100
110
 
101
- shared.refCount += 1
102
- if (shared.closeTimer) {
103
- clearTimeout(shared.closeTimer)
104
- shared.closeTimer = null
111
+ conn.refCount += 1
112
+ if (conn.closeTimer) {
113
+ clearTimeout(conn.closeTimer)
114
+ conn.closeTimer = null
105
115
  }
106
- return shared
116
+ return conn
107
117
  }
108
118
 
109
119
  export function releaseClient(url: string, opts?: ReleaseClientOptions): void {
110
- if (!shared || shared.wsUrl !== url) return
120
+ const conn = connections.get(url)
121
+ if (!conn) return
111
122
 
112
- shared.refCount = Math.max(0, shared.refCount - 1)
113
- if (shared.refCount > 0) return
123
+ conn.refCount = Math.max(0, conn.refCount - 1)
124
+ if (conn.refCount > 0) return
114
125
 
115
126
  const delay = opts?.delayMs ?? NATS_DEFAULTS.SHARED_CLOSE_DELAY_MS
116
- shared.closeTimer = setTimeout(() => {
117
- const s = shared
118
- shared = null
119
- if (s) {
120
- if (s.retryTimer) {
121
- clearTimeout(s.retryTimer)
122
- s.retryTimer = null
123
- }
124
- void s.client.close().catch(() => {})
127
+ conn.closeTimer = setTimeout(() => {
128
+ conn.closeTimer = null
129
+ // A new acquire may have raced in during the grace period.
130
+ if (conn.refCount > 0) return
131
+ if (connections.get(url) === conn) {
132
+ connections.delete(url)
133
+ }
134
+ if (conn.retryTimer) {
135
+ clearTimeout(conn.retryTimer)
136
+ conn.retryTimer = null
125
137
  }
138
+ void conn.client.close().catch(() => {})
126
139
  }, delay)
127
140
  }
128
141
 
129
142
  export function getSharedConnectionFor(url: string | null | undefined): SharedConnection | null {
130
143
  if (!url) return null
131
- return shared && shared.wsUrl === url ? shared : null
144
+ return connections.get(url) ?? null
132
145
  }
133
146
 
134
147
  // ---------------------------------------------------------------------------