@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.
- package/dist/{chunk-DTMJWBDQ.js → chunk-22NSWMI6.js} +3 -3
- package/dist/{chunk-NDWIFCLS.js → chunk-3BHV2QAH.js} +555 -135
- package/dist/{chunk-NDWIFCLS.js.map → chunk-3BHV2QAH.js.map} +1 -1
- package/dist/{chunk-PMTR5W5G.js → chunk-3DQ5BFPM.js} +5 -5
- package/dist/{chunk-RALJEQWM.cjs → chunk-3HYSD7NR.cjs} +15 -15
- package/dist/{chunk-RALJEQWM.cjs.map → chunk-3HYSD7NR.cjs.map} +1 -1
- package/dist/{chunk-YP7VJPCJ.cjs → chunk-5DWPXK37.cjs} +40 -40
- package/dist/{chunk-YP7VJPCJ.cjs.map → chunk-5DWPXK37.cjs.map} +1 -1
- package/dist/{chunk-OXZLBIR7.cjs → chunk-5FUQTSTT.cjs} +18 -18
- package/dist/{chunk-OXZLBIR7.cjs.map → chunk-5FUQTSTT.cjs.map} +1 -1
- package/dist/{chunk-MG4AMIFX.js → chunk-5NYXGWBN.js} +2 -2
- package/dist/{chunk-LCJGOV6P.js → chunk-7R256DKK.js} +2 -2
- package/dist/{chunk-2PLNDTRG.cjs → chunk-DTLNCOSG.cjs} +32 -32
- package/dist/{chunk-2PLNDTRG.cjs.map → chunk-DTLNCOSG.cjs.map} +1 -1
- package/dist/{chunk-3SCNEGL2.js → chunk-FIAXFNAA.js} +5 -5
- package/dist/{chunk-FQASXKBK.js → chunk-G2KXNMFQ.js} +2 -2
- package/dist/{chunk-H7H3AFLF.js → chunk-H2DYCT6M.js} +2 -2
- package/dist/{chunk-CE3AITV2.js → chunk-H352AZJN.js} +6 -6
- package/dist/{chunk-DCDB3SM4.js → chunk-K4RASNQM.js} +3 -3
- package/dist/{chunk-WXU6A6CA.cjs → chunk-KISQUH33.cjs} +73 -73
- package/dist/{chunk-WXU6A6CA.cjs.map → chunk-KISQUH33.cjs.map} +1 -1
- package/dist/{chunk-BYTJNRJW.cjs → chunk-MPSA6YAM.cjs} +40 -40
- package/dist/{chunk-BYTJNRJW.cjs.map → chunk-MPSA6YAM.cjs.map} +1 -1
- package/dist/{chunk-ABIRMYG5.js → chunk-MQUS6BFR.js} +6 -6
- package/dist/{chunk-DSW6FIEF.cjs → chunk-N64VTHZ5.cjs} +7 -7
- package/dist/{chunk-DSW6FIEF.cjs.map → chunk-N64VTHZ5.cjs.map} +1 -1
- package/dist/{chunk-F6NMMPDL.cjs → chunk-P3FN4NAE.cjs} +29 -29
- package/dist/{chunk-F6NMMPDL.cjs.map → chunk-P3FN4NAE.cjs.map} +1 -1
- package/dist/{chunk-PHWQLKVE.js → chunk-P47NN7RY.js} +29 -31
- package/dist/chunk-P47NN7RY.js.map +1 -0
- package/dist/{chunk-2CJ734N2.cjs → chunk-PTT4J5GF.cjs} +3 -3
- package/dist/{chunk-2CJ734N2.cjs.map → chunk-PTT4J5GF.cjs.map} +1 -1
- package/dist/{chunk-4R3SVOKH.cjs → chunk-QL2EI56X.cjs} +797 -377
- package/dist/chunk-QL2EI56X.cjs.map +1 -0
- package/dist/{chunk-VI2QQIGR.cjs → chunk-R6WGCJBB.cjs} +73 -73
- package/dist/{chunk-VI2QQIGR.cjs.map → chunk-R6WGCJBB.cjs.map} +1 -1
- package/dist/{chunk-Q6XOEVPH.cjs → chunk-RXLMN6B5.cjs} +12 -12
- package/dist/{chunk-Q6XOEVPH.cjs.map → chunk-RXLMN6B5.cjs.map} +1 -1
- package/dist/{chunk-F2XHBAHT.js → chunk-TCEBFMLQ.js} +2 -2
- package/dist/{chunk-7CZ4MJSI.cjs → chunk-VIJARDBM.cjs} +3 -3
- package/dist/{chunk-7CZ4MJSI.cjs.map → chunk-VIJARDBM.cjs.map} +1 -1
- package/dist/{chunk-PXXS27EE.cjs → chunk-W24U7ZUV.cjs} +40 -42
- package/dist/chunk-W24U7ZUV.cjs.map +1 -0
- package/dist/{chunk-37ZZQXHK.js → chunk-XPZLIONZ.js} +4 -4
- package/dist/{chunk-FO4CI4R5.cjs → chunk-YCLSV2H6.cjs} +5 -5
- package/dist/{chunk-FO4CI4R5.cjs.map → chunk-YCLSV2H6.cjs.map} +1 -1
- package/dist/{chunk-U7U2CZBZ.js → chunk-Z4N7XLFG.js} +3 -3
- package/dist/components/case-studies/index.cjs +10 -10
- package/dist/components/case-studies/index.js +4 -4
- package/dist/components/chat/hooks/use-chunk-catchup.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +44 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-dialog-subscription.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +10 -4
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.js +9 -3
- package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +5 -5
- package/dist/components/contact/index.js +4 -4
- package/dist/components/docs/index.cjs +7 -7
- package/dist/components/docs/index.js +6 -6
- package/dist/components/embeds/index.cjs +5 -5
- package/dist/components/embeds/index.js +4 -4
- package/dist/components/faq/index.cjs +6 -6
- package/dist/components/faq/index.js +5 -5
- package/dist/components/features/index.cjs +4 -4
- package/dist/components/features/index.js +3 -3
- package/dist/components/help-center-pages/index.cjs +23 -23
- package/dist/components/help-center-pages/index.js +14 -14
- package/dist/components/index.cjs +142 -136
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +18 -12
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +4 -4
- package/dist/components/navigation/index.js +3 -3
- package/dist/components/onboarding-guides/index.cjs +8 -8
- package/dist/components/onboarding-guides/index.js +7 -7
- package/dist/components/related-content/index.cjs +6 -6
- package/dist/components/related-content/index.js +5 -5
- package/dist/components/tickets/index.cjs +7 -7
- package/dist/components/tickets/index.js +6 -6
- package/dist/components/ui/index.cjs +10 -4
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +9 -3
- package/dist/hooks/index.cjs +3 -3
- package/dist/hooks/index.js +2 -2
- package/dist/index.cjs +10 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +9 -3
- package/dist/nats/index.cjs +2 -2
- package/dist/nats/index.js +1 -1
- package/dist/nats/shared-connection.d.ts +6 -0
- package/dist/nats/shared-connection.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/hooks/__tests__/nats-adapter-message-updaters.test.ts +270 -0
- package/src/components/chat/hooks/use-chunk-catchup.ts +212 -57
- package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +12 -8
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +574 -9
- package/src/components/chat/hooks/use-nats-dialog-subscription.ts +12 -6
- package/src/components/chat/hooks/use-realtime-chunk-processor.ts +40 -3
- package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +62 -0
- package/src/components/chat/utils/message-segment-accumulator.ts +6 -0
- package/src/components/chat/utils/process-historical-messages.ts +25 -3
- package/src/nats/shared-connection.ts +42 -29
- package/dist/chunk-4R3SVOKH.cjs.map +0 -1
- package/dist/chunk-PHWQLKVE.js.map +0 -1
- package/dist/chunk-PXXS27EE.cjs.map +0 -1
- /package/dist/{chunk-DTMJWBDQ.js.map → chunk-22NSWMI6.js.map} +0 -0
- /package/dist/{chunk-PMTR5W5G.js.map → chunk-3DQ5BFPM.js.map} +0 -0
- /package/dist/{chunk-MG4AMIFX.js.map → chunk-5NYXGWBN.js.map} +0 -0
- /package/dist/{chunk-LCJGOV6P.js.map → chunk-7R256DKK.js.map} +0 -0
- /package/dist/{chunk-3SCNEGL2.js.map → chunk-FIAXFNAA.js.map} +0 -0
- /package/dist/{chunk-FQASXKBK.js.map → chunk-G2KXNMFQ.js.map} +0 -0
- /package/dist/{chunk-H7H3AFLF.js.map → chunk-H2DYCT6M.js.map} +0 -0
- /package/dist/{chunk-CE3AITV2.js.map → chunk-H352AZJN.js.map} +0 -0
- /package/dist/{chunk-DCDB3SM4.js.map → chunk-K4RASNQM.js.map} +0 -0
- /package/dist/{chunk-ABIRMYG5.js.map → chunk-MQUS6BFR.js.map} +0 -0
- /package/dist/{chunk-F2XHBAHT.js.map → chunk-TCEBFMLQ.js.map} +0 -0
- /package/dist/{chunk-37ZZQXHK.js.map → chunk-XPZLIONZ.js.map} +0 -0
- /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
|
-
|
|
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: () =>
|
|
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
|
|
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 =
|
|
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) => [
|