@flamingo-stack/openframe-frontend-core 0.0.217 → 0.0.218

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 (93) hide show
  1. package/dist/{chunk-L6IBKPVM.js → chunk-EKBM4FHK.js} +2 -2
  2. package/dist/{chunk-SWZUZYWR.js → chunk-EWA2NFUR.js} +2 -2
  3. package/dist/{chunk-TYIBMDUZ.cjs → chunk-FZZBCRID.cjs} +7 -7
  4. package/dist/{chunk-TYIBMDUZ.cjs.map → chunk-FZZBCRID.cjs.map} +1 -1
  5. package/dist/{chunk-G2HHSZ3S.cjs → chunk-GE64T3JT.cjs} +9 -9
  6. package/dist/{chunk-G2HHSZ3S.cjs.map → chunk-GE64T3JT.cjs.map} +1 -1
  7. package/dist/{chunk-YWDC5BXM.cjs → chunk-L5RSJE2I.cjs} +1940 -915
  8. package/dist/chunk-L5RSJE2I.cjs.map +1 -0
  9. package/dist/{chunk-BVFRD34B.js → chunk-OHOUSDAY.js} +2 -2
  10. package/dist/{chunk-MVQ3OODK.cjs → chunk-S4SVD5JI.cjs} +9 -9
  11. package/dist/{chunk-MVQ3OODK.cjs.map → chunk-S4SVD5JI.cjs.map} +1 -1
  12. package/dist/{chunk-N5IKPYRL.js → chunk-SWIR5EB2.js} +2 -2
  13. package/dist/{chunk-6DCKL73F.cjs → chunk-TCJ5B2ZD.cjs} +24 -24
  14. package/dist/{chunk-6DCKL73F.cjs.map → chunk-TCJ5B2ZD.cjs.map} +1 -1
  15. package/dist/{chunk-ENBGG2K2.js → chunk-V5JY5RSY.js} +2954 -1929
  16. package/dist/chunk-V5JY5RSY.js.map +1 -0
  17. package/dist/components/chat/embeddable-chat.d.ts +13 -0
  18. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  19. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +104 -10
  20. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  21. package/dist/components/chat/hooks/use-slash-commands.d.ts +6 -0
  22. package/dist/components/chat/hooks/use-slash-commands.d.ts.map +1 -1
  23. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  24. package/dist/components/chat/hooks/use-unified-chat.d.ts.map +1 -1
  25. package/dist/components/chat/index.cjs +2 -2
  26. package/dist/components/chat/index.js +1 -1
  27. package/dist/components/chat/types/unified-chat-state.types.d.ts +81 -0
  28. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  29. package/dist/components/contact/index.cjs +3 -3
  30. package/dist/components/contact/index.js +2 -2
  31. package/dist/components/features/index.cjs +2 -2
  32. package/dist/components/features/index.js +1 -1
  33. package/dist/components/index.cjs +73 -51
  34. package/dist/components/index.cjs.map +1 -1
  35. package/dist/components/index.js +26 -4
  36. package/dist/components/index.js.map +1 -1
  37. package/dist/components/navigation/app-header.d.ts +7 -0
  38. package/dist/components/navigation/app-header.d.ts.map +1 -1
  39. package/dist/components/navigation/app-layout-drawer.d.ts +65 -0
  40. package/dist/components/navigation/app-layout-drawer.d.ts.map +1 -0
  41. package/dist/components/navigation/app-layout.d.ts +9 -1
  42. package/dist/components/navigation/app-layout.d.ts.map +1 -1
  43. package/dist/components/navigation/header-mingo-button.d.ts +21 -0
  44. package/dist/components/navigation/header-mingo-button.d.ts.map +1 -0
  45. package/dist/components/navigation/index.cjs +24 -2
  46. package/dist/components/navigation/index.cjs.map +1 -1
  47. package/dist/components/navigation/index.d.ts +5 -1
  48. package/dist/components/navigation/index.d.ts.map +1 -1
  49. package/dist/components/navigation/index.js +23 -1
  50. package/dist/components/onboarding-guides/index.cjs +18 -18
  51. package/dist/components/onboarding-guides/index.js +3 -3
  52. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
  53. package/dist/components/tickets/index.cjs +80 -66
  54. package/dist/components/tickets/index.cjs.map +1 -1
  55. package/dist/components/tickets/index.js +20 -6
  56. package/dist/components/tickets/index.js.map +1 -1
  57. package/dist/components/ui/index.cjs +2 -2
  58. package/dist/components/ui/index.js +1 -1
  59. package/dist/index.cjs +26 -2
  60. package/dist/index.cjs.map +1 -1
  61. package/dist/index.js +25 -1
  62. package/dist/utils/embed-authed-fetch.d.ts +80 -0
  63. package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
  64. package/dist/utils/index.cjs +70 -5
  65. package/dist/utils/index.cjs.map +1 -1
  66. package/dist/utils/index.d.ts +1 -1
  67. package/dist/utils/index.d.ts.map +1 -1
  68. package/dist/utils/index.js +70 -6
  69. package/dist/utils/index.js.map +1 -1
  70. package/package.json +2 -2
  71. package/src/components/chat/embeddable-chat.tsx +154 -37
  72. package/src/components/chat/hooks/use-nats-chat-adapter.ts +601 -23
  73. package/src/components/chat/hooks/use-slash-commands.ts +10 -1
  74. package/src/components/chat/hooks/use-sse-chat-adapter.ts +45 -0
  75. package/src/components/chat/hooks/use-unified-chat.ts +59 -0
  76. package/src/components/chat/types/unified-chat-state.types.ts +116 -0
  77. package/src/components/navigation/app-header.tsx +23 -0
  78. package/src/components/navigation/app-layout-drawer.tsx +620 -0
  79. package/src/components/navigation/app-layout.tsx +65 -26
  80. package/src/components/navigation/header-mingo-button.tsx +58 -0
  81. package/src/components/navigation/index.ts +17 -1
  82. package/src/components/tickets/hooks/use-ticket-engagements.ts +24 -4
  83. package/src/stories/AppLayoutDrawer.stories.tsx +228 -0
  84. package/src/utils/.embed-authed-fetch.md +7 -0
  85. package/src/utils/__tests__/embed-authed-fetch.test.ts +103 -1
  86. package/src/utils/embed-authed-fetch.ts +247 -7
  87. package/src/utils/index.ts +5 -1
  88. package/dist/chunk-ENBGG2K2.js.map +0 -1
  89. package/dist/chunk-YWDC5BXM.cjs.map +0 -1
  90. /package/dist/{chunk-L6IBKPVM.js.map → chunk-EKBM4FHK.js.map} +0 -0
  91. /package/dist/{chunk-SWZUZYWR.js.map → chunk-EWA2NFUR.js.map} +0 -0
  92. /package/dist/{chunk-BVFRD34B.js.map → chunk-OHOUSDAY.js.map} +0 -0
  93. /package/dist/{chunk-N5IKPYRL.js.map → chunk-SWIR5EB2.js.map} +0 -0
@@ -7,7 +7,7 @@
7
7
  * `useChat({ mode })` can dispatch between them with zero shell-side
8
8
  * branching.
9
9
  *
10
- * Composition (no new logic all the pieces already exist in the lib):
10
+ * Composition layers (in order from low to high):
11
11
  *
12
12
  * ┌──────────────────────────────────────────────────────────────┐
13
13
  * │ useNatsDialogSubscription live tail of agent events │
@@ -24,6 +24,29 @@
24
24
  * │ config.publishUserMessage consumer-owned send (HTTP/NATS) │
25
25
  * └──────────────────────────────────────────────────────────────┘
26
26
  *
27
+ * Two operating modes, distinguished by the presence of `fetchDialogs`:
28
+ *
29
+ * 1. **Bare-transport mode** (current Tauri Fae Chat usage):
30
+ * consumer supplies `config.dialogId` explicitly and owns dialog
31
+ * management above the adapter. The adapter does no list/select
32
+ * bookkeeping — every dialog field on the return value falls back
33
+ * to an empty default. This is the original v0 API; preserved
34
+ * byte-compatible so existing pinned consumers keep working.
35
+ *
36
+ * 2. **Managed-dialog mode** (openframe-frontend, EmbeddableChat
37
+ * sidebar): consumer supplies `fetchDialogs`,
38
+ * `fetchDialogMessages`, `createDialog`, etc. and the adapter
39
+ * owns the dialog state machine. The active dialog id, the list,
40
+ * pagination cursors, token usage hydration, and history merge
41
+ * all live inside this hook. The host renders the sidebar via
42
+ * `EmbeddableChat`'s built-in `<ChatSidebar>` slot and calls
43
+ * `selectDialog` / `startNewDialog` from there.
44
+ *
45
+ * The two modes are NOT mutually exclusive — passing `config.dialogId`
46
+ * alongside `fetchDialogs` forces the adapter into controlled mode (the
47
+ * external id wins) while still using the host's list fetchers for the
48
+ * sidebar.
49
+ *
27
50
  * Why publish is consumer-owned: the NATS module exposes a low-level
28
51
  * `publishBytes/String/Json` but the actual user-message endpoint varies
29
52
  * by deployment (REST POST in some, NATS subject in others). The adapter
@@ -46,39 +69,83 @@ import {
46
69
  import { useNatsDialogSubscription } from './use-nats-dialog-subscription'
47
70
  import { useRealtimeChunkProcessor } from './use-realtime-chunk-processor'
48
71
  import { useChunkCatchup } from './use-chunk-catchup'
72
+ import { processHistoricalMessagesWithErrors } from '../utils/process-historical-messages'
49
73
  import type {
50
74
  ChunkData,
51
75
  FetchChunksFunction,
76
+ HistoricalMessage,
52
77
  MessageSegment,
53
78
  NatsMessageType,
54
79
  StreamingPhase,
80
+ ChatApprovalStatus,
55
81
  } from '../types'
56
82
  import type {
83
+ ChatConnectionState,
84
+ DialogTokenUsage,
57
85
  UnifiedChatState,
58
86
  UnifiedChatMessage,
59
87
  UnifiedSendMessageOptions,
60
88
  } from '../types/unified-chat-state.types'
89
+ import type { DialogItem } from '../types/component.types'
61
90
  import type { ChatRef } from '../chat-ref.types'
62
91
 
63
92
  // =============================================================================
64
93
  // Config + options
65
94
  // =============================================================================
66
95
 
96
+ /** Page-fetch parameters passed to `fetchDialogs`. The adapter owns the
97
+ * cursor — the host only resolves it against the backend. */
98
+ export interface FetchDialogsParams {
99
+ cursor?: string
100
+ limit?: number
101
+ search?: string
102
+ }
103
+
104
+ /** Successful `fetchDialogs` response. `nextCursor: null` means "no more
105
+ * pages" — used to terminate the infinite-scroll observer in the
106
+ * sidebar. */
107
+ export interface FetchDialogsResult {
108
+ dialogs: DialogItem[]
109
+ nextCursor: string | null
110
+ }
111
+
112
+ /** Page-fetch parameters passed to `fetchDialogMessages`. */
113
+ export interface FetchDialogMessagesParams {
114
+ dialogId: string
115
+ cursor?: string
116
+ limit?: number
117
+ }
118
+
119
+ /** Successful `fetchDialogMessages` response. `tokenUsage` is optional —
120
+ * some backends only attach it to the dialog header query, in which case
121
+ * hosts can either include it on the first page only (will populate the
122
+ * ModelDisplay readout) or fold it in via a separate update path. */
123
+ export interface FetchDialogMessagesResult {
124
+ messages: HistoricalMessage[]
125
+ nextCursor: string | null
126
+ tokenUsage?: DialogTokenUsage | null
127
+ }
128
+
67
129
  /**
68
130
  * Consumer-supplied configuration for the NATS chat adapter.
69
131
  *
70
- * Every field is consumer-owned the lib does not assume a particular
71
- * backend protocol or auth scheme. Hosts wire these up against their
72
- * own OpenFrame deployment.
132
+ * Every field except `getNatsWsUrl` + `publishUserMessage` is optional
133
+ * the lib does not assume a particular backend protocol or auth scheme.
134
+ * Hosts wire these up against their own OpenFrame deployment.
73
135
  */
74
136
  export interface UseNatsChatAdapterConfig {
75
137
  /**
76
- * Active conversation/dialog id. When `null` the adapter stays
77
- * subscription-idle (no NATS connection, no catchup fetch). Set this
78
- * once the consumer's "open new conversation" flow has allocated an
79
- * id from the backend.
138
+ * Active conversation/dialog id. When omitted (`undefined`) the
139
+ * adapter manages its own active dialog id internally — the host
140
+ * drives selection through `selectDialog` / `startNewDialog`. When
141
+ * explicitly set (including `null`) the adapter treats this as
142
+ * controlled mode and uses the value verbatim. v0 consumers (the
143
+ * Tauri Fae Chat client) pass an explicit id here and own the
144
+ * lifecycle externally; v1+ consumers (the openframe-frontend
145
+ * EmbeddableChat) leave it undefined and rely on `fetchDialogs`
146
+ * for sidebar-driven selection.
80
147
  */
81
- dialogId: string | null
148
+ dialogId?: string | null
82
149
 
83
150
  /**
84
151
  * Build the NATS WebSocket URL. Returning `null` short-circuits the
@@ -136,6 +203,78 @@ export interface UseNatsChatAdapterConfig {
136
203
  * `toolCalls[]`. Set `false` to fall back to legacy per-tool cards.
137
204
  */
138
205
  batchApprovalsEnabled?: boolean
206
+
207
+ // ─── Managed-dialog mode (sidebar + history) ─────────────────────────────
208
+
209
+ /**
210
+ * Fetch a paginated page of dialogs for the sidebar. When provided,
211
+ * the adapter switches to managed-dialog mode: it owns the active
212
+ * dialog id, the dialog list, and pagination state. When omitted,
213
+ * the adapter operates in bare-transport mode (current Tauri Fae
214
+ * Chat usage) and the sidebar fields on the return value stay empty.
215
+ */
216
+ fetchDialogs?: (params: FetchDialogsParams) => Promise<FetchDialogsResult>
217
+
218
+ /**
219
+ * Fetch a page of historical messages for a dialog. Required for
220
+ * sidebar-driven dialog switching — when omitted, selecting a
221
+ * dialog brings up an empty thread until streaming starts.
222
+ *
223
+ * Messages must arrive in the same wire shape the openframe backend
224
+ * emits (HistoricalMessage with messageData[]); the adapter feeds
225
+ * them through `processHistoricalMessagesWithErrors` to produce
226
+ * accumulator-compatible segments.
227
+ */
228
+ fetchDialogMessages?: (
229
+ params: FetchDialogMessagesParams,
230
+ ) => Promise<FetchDialogMessagesResult>
231
+
232
+ /**
233
+ * Allocate a fresh dialog on the backend. Returns the new dialog id
234
+ * which the adapter sets as the active dialog. When omitted, the
235
+ * "Start new chat" affordance on the sidebar is hidden (or the host
236
+ * can implement its own).
237
+ */
238
+ createDialog?: () => Promise<{ dialogId: string }>
239
+
240
+ /** Delete a dialog from history. When omitted, the sidebar item's
241
+ * delete affordance is hidden. */
242
+ deleteDialog?: (dialogId: string) => Promise<void>
243
+
244
+ /**
245
+ * Approve a pending tool-call request. Wired into the segment
246
+ * accumulator's `onApprove` so approval-card buttons fire this
247
+ * directly. When omitted, approval cards render disabled buttons.
248
+ */
249
+ approveRequest?: (requestId: string) => Promise<void>
250
+
251
+ /** Reject counterpart of `approveRequest`. */
252
+ rejectRequest?: (requestId: string, reason?: string) => Promise<void>
253
+
254
+ /**
255
+ * Cancel in-flight assistant generation. Without this, `stopMessage`
256
+ * only flips the UI status — the backend continues until the agent
257
+ * finishes naturally. With this, the backend stops emitting chunks.
258
+ */
259
+ stopGeneration?: (dialogId: string) => Promise<void>
260
+
261
+ /** Display name for the assistant in historical messages — defaults
262
+ * to `'Mingo'`. */
263
+ assistantName?: string
264
+
265
+ /**
266
+ * GraphQL `chatType` discriminator to filter historical messages by.
267
+ * When set, `processHistoricalMessagesWithErrors` skips messages
268
+ * whose `chatType` doesn't match. Openframe-frontend uses
269
+ * `'ADMIN_AI_CHAT'` here.
270
+ */
271
+ chatTypeFilter?: string
272
+
273
+ /** Default page size for dialog list pagination. Defaults to 20. */
274
+ dialogsPageSize?: number
275
+
276
+ /** Default page size for message history pagination. Defaults to 50. */
277
+ messagesPageSize?: number
139
278
  }
140
279
 
141
280
  /**
@@ -192,6 +331,43 @@ function updateTrailingAssistant(
192
331
  ]
193
332
  }
194
333
 
334
+ /**
335
+ * Map `ProcessedMessage` (lib's historical-message format) into
336
+ * `UnifiedChatMessage` (the unified-chat-state contract). Only `user`
337
+ * and `assistant` roles round-trip; `error` is dropped on the floor
338
+ * here because the unified contract surfaces errors as banners, not
339
+ * inline messages. (Hosts that need inline error bubbles can extend
340
+ * the contract later.)
341
+ */
342
+ function mapProcessedToUnified(
343
+ processed: Array<{
344
+ id: string
345
+ role: 'user' | 'assistant' | 'error'
346
+ content: string | MessageSegment[]
347
+ name?: string
348
+ }>,
349
+ ): UnifiedChatMessage[] {
350
+ const out: UnifiedChatMessage[] = []
351
+ for (const m of processed) {
352
+ if (m.role === 'error') continue
353
+ if (Array.isArray(m.content)) {
354
+ out.push({
355
+ id: m.id,
356
+ role: m.role,
357
+ content: '',
358
+ segments: m.content,
359
+ })
360
+ } else {
361
+ out.push({
362
+ id: m.id,
363
+ role: m.role,
364
+ content: m.content,
365
+ })
366
+ }
367
+ }
368
+ return out
369
+ }
370
+
195
371
  // =============================================================================
196
372
  // Hook
197
373
  // =============================================================================
@@ -202,18 +378,141 @@ export function useNatsChatAdapter(
202
378
  ): UnifiedChatState {
203
379
  const { active = true } = options
204
380
  const {
205
- dialogId,
381
+ dialogId: controlledDialogId,
206
382
  getNatsWsUrl,
207
383
  clientConfig,
208
384
  publishUserMessage,
209
385
  fetchChunks,
210
386
  enableThinking,
211
387
  batchApprovalsEnabled,
388
+ fetchDialogs,
389
+ fetchDialogMessages,
390
+ createDialog: createDialogCallback,
391
+ deleteDialog: deleteDialogCallback,
392
+ approveRequest: approveRequestCallback,
393
+ rejectRequest: rejectRequestCallback,
394
+ stopGeneration: stopGenerationCallback,
395
+ assistantName = 'Mingo',
396
+ chatTypeFilter,
397
+ dialogsPageSize = 20,
398
+ messagesPageSize = 50,
212
399
  } = config
213
400
 
401
+ // ─── Active dialog id resolution ──────────────────────────────────────────
402
+ // The discriminator is *capability*, not *value*: when the host wires
403
+ // `fetchDialogs` we know they want the adapter to own the dialog list,
404
+ // active id, and sidebar wiring. Any `controlledDialogId` they pass in
405
+ // that mode is ignored (host shouldn't be fighting the adapter for
406
+ // selection). When `fetchDialogs` is absent (Tauri Fae Chat) the adapter
407
+ // is in bare-transport mode and the host's `dialogId` is the source of
408
+ // truth — including `null`, which means "no dialog selected, idle WS".
409
+
410
+ const isManagedMode = fetchDialogs !== undefined
411
+ const [internalDialogId, setInternalDialogId] = useState<string | null>(null)
412
+ const dialogId = isManagedMode
413
+ ? internalDialogId
414
+ : controlledDialogId !== undefined
415
+ ? controlledDialogId
416
+ : null
417
+
418
+ // ─── Message thread + streaming phase ─────────────────────────────────────
419
+
214
420
  const [messages, setMessages] = useState<UnifiedChatMessage[]>([])
215
421
  const [streamingPhase, setStreamingPhase] = useState<StreamingPhase>('idle')
216
422
 
423
+ // Approval status map. Used both to dedupe pending segments at render
424
+ // time and to feed `processHistoricalMessagesWithErrors` so previously
425
+ // resolved approvals don't re-render as actionable on dialog switch.
426
+ const [approvalStatuses, setApprovalStatuses] = useState<
427
+ Record<string, ChatApprovalStatus>
428
+ >({})
429
+
430
+ // ─── Dialog list state (managed-dialog mode only) ─────────────────────────
431
+
432
+ const [dialogs, setDialogs] = useState<DialogItem[]>([])
433
+ const [dialogsNextCursor, setDialogsNextCursor] = useState<string | null>(null)
434
+ const [isDialogsLoading, setIsDialogsLoading] = useState<boolean>(false)
435
+ const [isCreatingDialog, setIsCreatingDialog] = useState<boolean>(false)
436
+
437
+ // ─── Message-history pagination ───────────────────────────────────────────
438
+
439
+ const [messagesNextCursor, setMessagesNextCursor] = useState<string | null>(null)
440
+ const [isMessagesLoading, setIsMessagesLoading] = useState<boolean>(false)
441
+
442
+ // ─── Token usage (Mingo per-dialog cumulative) ────────────────────────────
443
+
444
+ const [dialogTokenUsage, setDialogTokenUsage] = useState<DialogTokenUsage | null>(
445
+ null,
446
+ )
447
+
448
+ // ─── Connection state ─────────────────────────────────────────────────────
449
+
450
+ const [connectionState, setConnectionState] =
451
+ useState<ChatConnectionState>('connecting')
452
+
453
+ // ─── Approval handlers ────────────────────────────────────────────────────
454
+ // Optimistically flip status before the network round-trip so the
455
+ // accumulator's render reflects the user's choice immediately.
456
+
457
+ const handleApprove = useCallback(
458
+ async (requestId: string) => {
459
+ if (!approveRequestCallback) return
460
+ setApprovalStatuses((prev) => ({ ...prev, [requestId]: 'approved' }))
461
+ try {
462
+ await approveRequestCallback(requestId)
463
+ } catch (err) {
464
+ // Revert the optimistic flip on failure so the user can retry.
465
+ setApprovalStatuses((prev) => {
466
+ const next = { ...prev }
467
+ delete next[requestId]
468
+ return next
469
+ })
470
+ console.error('[useNatsChatAdapter] approveRequest failed:', err)
471
+ }
472
+ },
473
+ [approveRequestCallback],
474
+ )
475
+
476
+ const handleReject = useCallback(
477
+ async (requestId: string, reason?: string) => {
478
+ if (!rejectRequestCallback) return
479
+ setApprovalStatuses((prev) => ({ ...prev, [requestId]: 'rejected' }))
480
+ try {
481
+ await rejectRequestCallback(requestId, reason)
482
+ } catch (err) {
483
+ setApprovalStatuses((prev) => {
484
+ const next = { ...prev }
485
+ delete next[requestId]
486
+ return next
487
+ })
488
+ console.error('[useNatsChatAdapter] rejectRequest failed:', err)
489
+ }
490
+ },
491
+ [rejectRequestCallback],
492
+ )
493
+
494
+ // Stable refs so the accumulator's callbacks don't churn on re-render.
495
+ const handleApproveRef = useRef(handleApprove)
496
+ handleApproveRef.current = handleApprove
497
+ const handleRejectRef = useRef(handleReject)
498
+ handleRejectRef.current = handleReject
499
+
500
+ // Accumulator-compatible adapters. The lib's accumulator contract is
501
+ // `(requestId?: string) => void | Promise<void>` — single optional arg,
502
+ // no rejection reason. Our public API surface widens that to require a
503
+ // string + accept a reason, so we narrow here at the boundary. Reason
504
+ // is unavailable from button-click callers (the accumulator doesn't
505
+ // pass it) so we only forward the requestId; consumers that need
506
+ // structured reject reasons can call `rejectRequest` directly.
507
+ const accumApprove = useCallback((requestId?: string): void | Promise<void> => {
508
+ if (!requestId) return
509
+ return handleApproveRef.current(requestId)
510
+ }, [])
511
+ const accumReject = useCallback((requestId?: string): void | Promise<void> => {
512
+ if (!requestId) return
513
+ return handleRejectRef.current(requestId)
514
+ }, [])
515
+
217
516
  // Stable callback ref so `useRealtimeChunkProcessor`'s options object
218
517
  // doesn't churn every render and tear down the accumulator state.
219
518
  const callbacksRef: MutableRefObject<{
@@ -228,12 +527,16 @@ export function useNatsChatAdapter(
228
527
  onStreamEnd: () => setStreamingPhase('idle'),
229
528
  })
230
529
 
231
- // Real-time chunk → segment processor.
530
+ // Real-time chunk → segment processor. Approval handlers route through
531
+ // the refs so a config change (e.g. callback identity churn from the
532
+ // host) doesn't tear down the accumulator.
232
533
  const { processChunk, reset: resetAccumulator } = useRealtimeChunkProcessor({
233
534
  callbacks: {
234
535
  onSegmentsUpdate: (segments) => callbacksRef.current.onSegmentsUpdate(segments),
235
536
  onStreamStart: () => callbacksRef.current.onStreamStart(),
236
537
  onStreamEnd: () => callbacksRef.current.onStreamEnd(),
538
+ onApprove: accumApprove,
539
+ onReject: accumReject,
237
540
  },
238
541
  enableThinking,
239
542
  batchApprovalsEnabled,
@@ -252,9 +555,111 @@ export function useNatsChatAdapter(
252
555
  fetchChunks,
253
556
  })
254
557
 
255
- // Trigger initial backfill whenever a fresh dialog activates. Mirrors the
256
- // pattern in `.use-chunk-catchup.md`: enable buffering first so realtime
257
- // chunks queue behind the historical fetch, then flush in order.
558
+ // ─── Historical message hydration ─────────────────────────────────────────
559
+ // When the active dialog changes AND a `fetchDialogMessages` callback
560
+ // is wired, load the first page of history, process it through the
561
+ // accumulator, and seed `messages`. Streaming chunks for the same
562
+ // dialog then append on top.
563
+
564
+ // Monotonic guard: each history load captures a sequence number; when a
565
+ // response resolves, it only applies if it's still the latest request. This
566
+ // prevents a slow response for a previously-selected dialog from clobbering
567
+ // the state of the dialog the user just switched to.
568
+ const historyLoadSeqRef = useRef(0)
569
+
570
+ const loadDialogHistory = useCallback(
571
+ async (id: string, cursor?: string): Promise<void> => {
572
+ if (!fetchDialogMessages) return
573
+ historyLoadSeqRef.current += 1
574
+ const seq = historyLoadSeqRef.current
575
+ setIsMessagesLoading(true)
576
+ try {
577
+ const result = await fetchDialogMessages({
578
+ dialogId: id,
579
+ cursor,
580
+ limit: messagesPageSize,
581
+ })
582
+ // Ignore stale responses superseded by a newer dialog selection.
583
+ if (seq !== historyLoadSeqRef.current) return
584
+ const { messages: rawProcessed } = processHistoricalMessagesWithErrors(
585
+ result.messages,
586
+ {
587
+ assistantName,
588
+ assistantType: 'mingo',
589
+ chatTypeFilter,
590
+ onApprove: accumApprove,
591
+ onReject: accumReject,
592
+ approvalStatuses,
593
+ batchApprovalsEnabled,
594
+ },
595
+ )
596
+ const unified = mapProcessedToUnified(rawProcessed)
597
+ if (cursor === undefined) {
598
+ // First page — replace.
599
+ setMessages(unified)
600
+ } else {
601
+ // Older page — prepend.
602
+ setMessages((prev) => [...unified, ...prev])
603
+ }
604
+ setMessagesNextCursor(result.nextCursor)
605
+ if (result.tokenUsage !== undefined) {
606
+ setDialogTokenUsage(result.tokenUsage)
607
+ }
608
+ } catch (err) {
609
+ console.error('[useNatsChatAdapter] fetchDialogMessages failed:', err)
610
+ } finally {
611
+ // Only the latest request owns the loading flag — a superseded one
612
+ // must not clear it while the newer load is still in flight.
613
+ if (seq === historyLoadSeqRef.current) {
614
+ setIsMessagesLoading(false)
615
+ }
616
+ }
617
+ },
618
+ [
619
+ fetchDialogMessages,
620
+ messagesPageSize,
621
+ assistantName,
622
+ chatTypeFilter,
623
+ approvalStatuses,
624
+ batchApprovalsEnabled,
625
+ accumApprove,
626
+ accumReject,
627
+ ],
628
+ )
629
+
630
+ // Reset + reload when the active dialog changes.
631
+ const prevDialogIdRef = useRef<string | null | undefined>(undefined)
632
+ useEffect(() => {
633
+ if (prevDialogIdRef.current === dialogId) return
634
+ prevDialogIdRef.current = dialogId
635
+
636
+ // Invalidate any in-flight history load for the previous dialog. Bumping
637
+ // here (not only inside loadDialogHistory) covers the clear/delete path
638
+ // — switching to a null/empty dialog never starts a new load, so without
639
+ // this an in-flight response could repopulate the just-cleared state.
640
+ historyLoadSeqRef.current += 1
641
+
642
+ // Drop accumulator + message state for the previous dialog.
643
+ resetAccumulator()
644
+ setMessages([])
645
+ setMessagesNextCursor(null)
646
+ setDialogTokenUsage(null)
647
+ setStreamingPhase('idle')
648
+ // No load runs when there's no active dialog, so clear the flag here;
649
+ // otherwise the superseded load's guarded `finally` leaves it stuck on.
650
+ setIsMessagesLoading(false)
651
+
652
+ if (!active || !dialogId) return
653
+
654
+ // Hydrate history first; then catchup will fold in any chunks that
655
+ // landed between the history snapshot and the live tail.
656
+ void loadDialogHistory(dialogId)
657
+ }, [active, dialogId, loadDialogHistory, resetAccumulator])
658
+
659
+ // Trigger initial chunk backfill whenever a fresh dialog activates.
660
+ // Runs alongside history hydration — history seeds processed messages,
661
+ // catchup buffers raw chunks in case the WS came online after history
662
+ // was already snapshotted.
258
663
  useEffect(() => {
259
664
  if (!active || !dialogId) return
260
665
  resetChunkTracking()
@@ -264,9 +669,10 @@ export function useNatsChatAdapter(
264
669
  })
265
670
  }, [active, dialogId, resetChunkTracking, startInitialBuffering, catchUpChunks])
266
671
 
267
- // Live tail subscription. `enabled` is gated on both `active` and a
268
- // non-null dialogId so the consumer doesn't pay socket cost before
269
- // a conversation exists.
672
+ // ─── Live NATS subscription ───────────────────────────────────────────────
673
+ // Gated on `active` and a non-null dialogId so the consumer doesn't pay
674
+ // socket cost before a conversation exists.
675
+
270
676
  useNatsDialogSubscription({
271
677
  enabled: active && dialogId != null,
272
678
  dialogId,
@@ -277,10 +683,59 @@ export function useNatsChatAdapter(
277
683
  // with historical playback. `useChunkCatchup` itself forwards to
278
684
  // `processChunk` once dedupe checks pass.
279
685
  catchupProcessChunk(payload as ChunkData, messageType)
686
+ // First successful event marks the connection as up.
687
+ setConnectionState('connected')
280
688
  },
281
689
  })
282
690
 
283
- // ─── Public API ───────────────────────────────────────────────────────────
691
+ // Without a finer hook signal we treat "active + dialog selected" as
692
+ // connecting until the first event lands, and "no dialog" as idle-
693
+ // connected (nothing to subscribe to anyway).
694
+ useEffect(() => {
695
+ if (!active || !dialogId) {
696
+ setConnectionState('connected')
697
+ return
698
+ }
699
+ setConnectionState('connecting')
700
+ }, [active, dialogId])
701
+
702
+ // ─── Dialog list management (managed-dialog mode) ─────────────────────────
703
+
704
+ const loadDialogsPage = useCallback(
705
+ async (cursor?: string): Promise<void> => {
706
+ if (!fetchDialogs) return
707
+ setIsDialogsLoading(true)
708
+ try {
709
+ const result = await fetchDialogs({
710
+ cursor,
711
+ limit: dialogsPageSize,
712
+ })
713
+ setDialogsNextCursor(result.nextCursor)
714
+ if (cursor === undefined) {
715
+ setDialogs(result.dialogs)
716
+ } else {
717
+ setDialogs((prev) => [...prev, ...result.dialogs])
718
+ }
719
+ } catch (err) {
720
+ console.error('[useNatsChatAdapter] fetchDialogs failed:', err)
721
+ } finally {
722
+ setIsDialogsLoading(false)
723
+ }
724
+ },
725
+ [fetchDialogs, dialogsPageSize],
726
+ )
727
+
728
+ // Initial dialog list load.
729
+ const initialDialogsLoadedRef = useRef(false)
730
+ useEffect(() => {
731
+ if (!fetchDialogs) return
732
+ if (!active) return
733
+ if (initialDialogsLoadedRef.current) return
734
+ initialDialogsLoadedRef.current = true
735
+ void loadDialogsPage()
736
+ }, [active, fetchDialogs, loadDialogsPage])
737
+
738
+ // ─── Public action handlers ───────────────────────────────────────────────
284
739
 
285
740
  const sendMessage = useCallback(
286
741
  async (
@@ -314,12 +769,15 @@ export function useNatsChatAdapter(
314
769
  )
315
770
 
316
771
  const stopMessage = useCallback(() => {
317
- // NATS streams are driven server-side; the client can't really
318
- // "cancel" an in-flight agent task without backend cooperation.
319
- // For now we just drop the UI status — incoming chunks will still
320
- // be accepted and rendered if the agent completes anyway.
772
+ // Best-effort UI flip. The actual backend cancellation is gated on
773
+ // the host-supplied callback.
321
774
  setStreamingPhase('idle')
322
- }, [])
775
+ if (stopGenerationCallback && dialogId) {
776
+ void stopGenerationCallback(dialogId).catch((err) => {
777
+ console.error('[useNatsChatAdapter] stopGeneration failed:', err)
778
+ })
779
+ }
780
+ }, [stopGenerationCallback, dialogId])
323
781
 
324
782
  const clearMessages = useCallback(() => {
325
783
  setMessages([])
@@ -327,6 +785,89 @@ export function useNatsChatAdapter(
327
785
  setStreamingPhase('idle')
328
786
  }, [resetAccumulator])
329
787
 
788
+ const selectDialog = useCallback(
789
+ (id: string | null) => {
790
+ if (!isManagedMode) {
791
+ // Bare-transport mode: the host owns dialog id externally. No-op
792
+ // here so we don't compete with the host's own state machine.
793
+ // Hosts wanting EmbeddableChat-driven selection should wire
794
+ // `fetchDialogs` to flip the adapter into managed-dialog mode.
795
+ return
796
+ }
797
+ setInternalDialogId(id)
798
+ },
799
+ [isManagedMode],
800
+ )
801
+
802
+ const startNewDialog = useCallback(async (): Promise<string | null> => {
803
+ if (!createDialogCallback) return null
804
+ if (isCreatingDialog) return null
805
+ try {
806
+ setIsCreatingDialog(true)
807
+ const result = await createDialogCallback()
808
+ // Optimistically prepend a placeholder dialog so the sidebar shows
809
+ // the new conversation immediately. The next list refresh will
810
+ // replace it with the canonical entry.
811
+ setDialogs((prev) => [
812
+ {
813
+ id: result.dialogId,
814
+ title: 'New Chat',
815
+ timestamp: new Date(),
816
+ },
817
+ ...prev.filter((d) => d.id !== result.dialogId),
818
+ ])
819
+ if (isManagedMode) {
820
+ setInternalDialogId(result.dialogId)
821
+ }
822
+ return result.dialogId
823
+ } catch (err) {
824
+ console.error('[useNatsChatAdapter] createDialog failed:', err)
825
+ return null
826
+ } finally {
827
+ setIsCreatingDialog(false)
828
+ }
829
+ }, [createDialogCallback, isCreatingDialog, isManagedMode])
830
+
831
+ const deleteDialog = useCallback(
832
+ async (id: string): Promise<void> => {
833
+ if (!deleteDialogCallback) return
834
+ try {
835
+ await deleteDialogCallback(id)
836
+ setDialogs((prev) => prev.filter((d) => d.id !== id))
837
+ if (isManagedMode && internalDialogId === id) {
838
+ setInternalDialogId(null)
839
+ }
840
+ } catch (err) {
841
+ console.error('[useNatsChatAdapter] deleteDialog failed:', err)
842
+ }
843
+ },
844
+ [deleteDialogCallback, internalDialogId, isManagedMode],
845
+ )
846
+
847
+ const loadMoreDialogs = useCallback(async (): Promise<void> => {
848
+ if (!dialogsNextCursor) return
849
+ await loadDialogsPage(dialogsNextCursor)
850
+ }, [dialogsNextCursor, loadDialogsPage])
851
+
852
+ const loadMoreMessages = useCallback(async (): Promise<void> => {
853
+ if (!dialogId || !messagesNextCursor) return
854
+ await loadDialogHistory(dialogId, messagesNextCursor)
855
+ }, [dialogId, messagesNextCursor, loadDialogHistory])
856
+
857
+ const approveRequest = useCallback(
858
+ async (requestId: string) => {
859
+ await handleApproveRef.current(requestId)
860
+ },
861
+ [],
862
+ )
863
+
864
+ const rejectRequest = useCallback(
865
+ async (requestId: string, reason?: string) => {
866
+ await handleRejectRef.current(requestId, reason)
867
+ },
868
+ [],
869
+ )
870
+
330
871
  // No-op refs — Mingo agent has no RAG entity-card affordances.
331
872
  const discussRef = useCallback((_ref: ChatRef) => {
332
873
  /* no-op in Mingo mode */
@@ -338,6 +879,8 @@ export function useNatsChatAdapter(
338
879
  // ─── Return shape ─────────────────────────────────────────────────────────
339
880
 
340
881
  const isLoading = streamingPhase !== 'idle'
882
+ const hasMoreDialogs = dialogsNextCursor != null
883
+ const hasMoreMessages = messagesNextCursor != null
341
884
 
342
885
  return useMemo<UnifiedChatState>(
343
886
  () => ({
@@ -357,6 +900,25 @@ export function useNatsChatAdapter(
357
900
  currentOutputTokens: null,
358
901
  currentCacheHitRatePct: null,
359
902
  currentUsageBreakdown: null,
903
+ // Dialog management
904
+ dialogs,
905
+ activeDialogId: dialogId,
906
+ selectDialog,
907
+ startNewDialog,
908
+ deleteDialog,
909
+ isDialogsLoading: isDialogsLoading || isCreatingDialog,
910
+ isMessagesLoading,
911
+ hasMoreDialogs,
912
+ loadMoreDialogs,
913
+ hasMoreMessages,
914
+ loadMoreMessages,
915
+ // Approval mutations
916
+ approveRequest,
917
+ rejectRequest,
918
+ // Token usage
919
+ dialogTokenUsage,
920
+ // Connection state
921
+ connectionState,
360
922
  }),
361
923
  [
362
924
  messages,
@@ -367,6 +929,22 @@ export function useNatsChatAdapter(
367
929
  clearMessages,
368
930
  discussRef,
369
931
  displayRef,
932
+ dialogs,
933
+ dialogId,
934
+ selectDialog,
935
+ startNewDialog,
936
+ deleteDialog,
937
+ isDialogsLoading,
938
+ isCreatingDialog,
939
+ isMessagesLoading,
940
+ hasMoreDialogs,
941
+ loadMoreDialogs,
942
+ hasMoreMessages,
943
+ loadMoreMessages,
944
+ approveRequest,
945
+ rejectRequest,
946
+ dialogTokenUsage,
947
+ connectionState,
370
948
  ],
371
949
  )
372
950
  }