@flamingo-stack/openframe-frontend-core 0.0.207 → 0.0.208

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 (132) hide show
  1. package/dist/{chunk-Z3GQGR5E.js → chunk-2HMZSCJY.js} +3158 -2074
  2. package/dist/chunk-2HMZSCJY.js.map +1 -0
  3. package/dist/chunk-4XLJWX2N.js +12 -0
  4. package/dist/chunk-4XLJWX2N.js.map +1 -0
  5. package/dist/{chunk-APM6KBPU.cjs → chunk-C5EC5AZM.cjs} +1644 -560
  6. package/dist/chunk-C5EC5AZM.cjs.map +1 -0
  7. package/dist/chunk-VFKQMAUF.cjs +12 -0
  8. package/dist/chunk-VFKQMAUF.cjs.map +1 -0
  9. package/dist/components/chat/embeddable-chat.d.ts +35 -2
  10. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  11. package/dist/components/chat/hooks/index.d.ts +3 -0
  12. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  13. package/dist/components/chat/hooks/use-embedded-chat.d.ts +10 -169
  14. package/dist/components/chat/hooks/use-embedded-chat.d.ts.map +1 -1
  15. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +85 -0
  16. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -0
  17. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +124 -0
  18. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -0
  19. package/dist/components/chat/hooks/use-unified-chat.d.ts +33 -0
  20. package/dist/components/chat/hooks/use-unified-chat.d.ts.map +1 -0
  21. package/dist/components/chat/index.cjs +8 -2
  22. package/dist/components/chat/index.cjs.map +1 -1
  23. package/dist/components/chat/index.js +11 -5
  24. package/dist/components/chat/types/index.d.ts +1 -0
  25. package/dist/components/chat/types/index.d.ts.map +1 -1
  26. package/dist/components/chat/types/unified-chat-state.types.d.ts +185 -0
  27. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -0
  28. package/dist/components/features/index.cjs +3 -2
  29. package/dist/components/features/index.cjs.map +1 -1
  30. package/dist/components/features/index.js +2 -1
  31. package/dist/components/index.cjs +26 -2
  32. package/dist/components/index.cjs.map +1 -1
  33. package/dist/components/index.d.ts +4 -0
  34. package/dist/components/index.d.ts.map +1 -1
  35. package/dist/components/index.js +27 -3
  36. package/dist/components/navigation/index.cjs +3 -2
  37. package/dist/components/navigation/index.cjs.map +1 -1
  38. package/dist/components/navigation/index.js +2 -1
  39. package/dist/components/shared/delivery/delivery-lists.d.ts +16 -0
  40. package/dist/components/shared/delivery/delivery-lists.d.ts.map +1 -0
  41. package/dist/components/shared/delivery/delivery-table.d.ts +12 -0
  42. package/dist/components/shared/delivery/delivery-table.d.ts.map +1 -0
  43. package/dist/components/shared/delivery/index.d.ts +3 -0
  44. package/dist/components/shared/delivery/index.d.ts.map +1 -0
  45. package/dist/components/shared/dev-section/dev-section-page.d.ts +31 -0
  46. package/dist/components/shared/dev-section/dev-section-page.d.ts.map +1 -0
  47. package/dist/components/shared/dev-section/dev-section-view.d.ts +34 -0
  48. package/dist/components/shared/dev-section/dev-section-view.d.ts.map +1 -0
  49. package/dist/components/shared/dev-section/index.d.ts +3 -0
  50. package/dist/components/shared/dev-section/index.d.ts.map +1 -0
  51. package/dist/components/shared/legal-document/index.d.ts +10 -0
  52. package/dist/components/shared/legal-document/index.d.ts.map +1 -0
  53. package/dist/components/shared/legal-document/legal-document-page.d.ts +66 -0
  54. package/dist/components/shared/legal-document/legal-document-page.d.ts.map +1 -0
  55. package/dist/components/shared/legal-document/use-legal-docs.d.ts +40 -0
  56. package/dist/components/shared/legal-document/use-legal-docs.d.ts.map +1 -0
  57. package/dist/components/shared/product-release/index.d.ts +2 -1
  58. package/dist/components/shared/product-release/index.d.ts.map +1 -1
  59. package/dist/components/shared/product-release/release-detail-page.d.ts +11 -7
  60. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  61. package/dist/components/shared/roadmap/index.d.ts +18 -0
  62. package/dist/components/shared/roadmap/index.d.ts.map +1 -0
  63. package/dist/components/shared/roadmap/roadmap-grid-skeleton.d.ts +24 -0
  64. package/dist/components/shared/roadmap/roadmap-grid-skeleton.d.ts.map +1 -0
  65. package/dist/components/shared/roadmap/roadmap-grid.d.ts +18 -0
  66. package/dist/components/shared/roadmap/roadmap-grid.d.ts.map +1 -0
  67. package/dist/components/shared/roadmap/use-roadmap-voting.d.ts +25 -0
  68. package/dist/components/shared/roadmap/use-roadmap-voting.d.ts.map +1 -0
  69. package/dist/components/ui/index.cjs +8 -2
  70. package/dist/components/ui/index.cjs.map +1 -1
  71. package/dist/components/ui/index.js +11 -5
  72. package/dist/components/ui/release-changelog-section.d.ts +13 -2
  73. package/dist/components/ui/release-changelog-section.d.ts.map +1 -1
  74. package/dist/embed-shims/index.cjs +1 -6
  75. package/dist/embed-shims/index.cjs.map +1 -1
  76. package/dist/embed-shims/index.js +1 -6
  77. package/dist/embed-shims/index.js.map +1 -1
  78. package/dist/index.cjs +18 -2
  79. package/dist/index.cjs.map +1 -1
  80. package/dist/index.js +19 -3
  81. package/dist/types/delivery.d.ts +49 -0
  82. package/dist/types/delivery.d.ts.map +1 -0
  83. package/dist/types/index.cjs +13 -0
  84. package/dist/types/index.cjs.map +1 -1
  85. package/dist/types/index.d.ts +1 -0
  86. package/dist/types/index.d.ts.map +1 -1
  87. package/dist/types/index.js +12 -1
  88. package/dist/types/index.js.map +1 -1
  89. package/dist/utils/dev-sections/index.d.ts +11 -0
  90. package/dist/utils/dev-sections/index.d.ts.map +1 -0
  91. package/dist/utils/dev-sections/openframe-dev-sections.d.ts +209 -0
  92. package/dist/utils/dev-sections/openframe-dev-sections.d.ts.map +1 -0
  93. package/dist/utils/index.cjs +82 -0
  94. package/dist/utils/index.cjs.map +1 -1
  95. package/dist/utils/index.d.ts +1 -0
  96. package/dist/utils/index.d.ts.map +1 -1
  97. package/dist/utils/index.js +81 -2
  98. package/dist/utils/index.js.map +1 -1
  99. package/package.json +1 -1
  100. package/src/components/chat/embeddable-chat.tsx +123 -8
  101. package/src/components/chat/hooks/index.ts +9 -2
  102. package/src/components/chat/hooks/use-embedded-chat.ts +18 -1016
  103. package/src/components/chat/hooks/use-nats-chat-adapter.ts +372 -0
  104. package/src/components/chat/hooks/use-sse-chat-adapter.ts +1058 -0
  105. package/src/components/chat/hooks/use-unified-chat.ts +171 -0
  106. package/src/components/chat/types/index.ts +1 -0
  107. package/src/components/chat/types/unified-chat-state.types.ts +215 -0
  108. package/src/components/index.ts +8 -0
  109. package/src/components/shared/delivery/delivery-lists.tsx +199 -0
  110. package/src/components/shared/delivery/delivery-table.tsx +174 -0
  111. package/src/components/shared/delivery/index.ts +9 -0
  112. package/src/components/shared/dev-section/dev-section-page.tsx +72 -0
  113. package/src/components/shared/dev-section/dev-section-view.tsx +129 -0
  114. package/src/components/shared/dev-section/index.ts +2 -0
  115. package/src/components/shared/legal-document/index.ts +19 -0
  116. package/src/components/shared/legal-document/legal-document-page.tsx +178 -0
  117. package/src/components/shared/legal-document/use-legal-docs.ts +123 -0
  118. package/src/components/shared/product-release/index.ts +14 -3
  119. package/src/components/shared/product-release/release-detail-page.tsx +45 -7
  120. package/src/components/shared/roadmap/index.ts +23 -0
  121. package/src/components/shared/roadmap/roadmap-grid-skeleton.tsx +74 -0
  122. package/src/components/shared/roadmap/roadmap-grid.tsx +106 -0
  123. package/src/components/shared/roadmap/use-roadmap-voting.ts +163 -0
  124. package/src/components/ui/release-changelog-section.tsx +113 -32
  125. package/src/stories/EmbeddableChat.stories.tsx +186 -0
  126. package/src/types/delivery.ts +54 -0
  127. package/src/types/index.ts +1 -0
  128. package/src/utils/dev-sections/index.ts +17 -0
  129. package/src/utils/dev-sections/openframe-dev-sections.ts +148 -0
  130. package/src/utils/index.ts +6 -1
  131. package/dist/chunk-APM6KBPU.cjs.map +0 -1
  132. package/dist/chunk-Z3GQGR5E.js.map +0 -1
@@ -0,0 +1,1058 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * useSseChatAdapter — the SSE/Guide-mode transport adapter for the unified
5
+ * chat surface. One of two `UnifiedChatState` implementations; its NATS
6
+ * counterpart is `useNatsChatAdapter` (Mingo mode). The public
7
+ * `useChat({ mode })` dispatches between them based on `mode.transport`.
8
+ *
9
+ * Renamed from `useEmbeddedChat` during the unified-chat refactor. The
10
+ * legacy name is re-exported as an alias from `./use-embedded-chat` for
11
+ * backward compatibility — internal lib code should import this canonical
12
+ * name directly.
13
+ *
14
+ * Two key contracts vs hub-side chat hooks:
15
+ *
16
+ * 1. `source` is READ FROM THE RUNTIME, not a parameter. Throws loudly
17
+ * if `runtime.source` is empty so embedders that forget to wire
18
+ * identity get an immediate signal rather than a silently-empty
19
+ * localStorage namespace.
20
+ *
21
+ * 2. Navigation is runtime-provided. The hub-side `useDocSearch` keeps
22
+ * its own `decideNewTab` + `useDocNavigation.navigate` plumbing for
23
+ * the search autocomplete (it calls `router.push`). The chat body
24
+ * doesn't navigate via this hook — click handlers on rendered
25
+ * cards/chips go through `handleChatNavClick` instead.
26
+ *
27
+ * 3. `tableIdForDocumentType` is INJECTED via the optional
28
+ * `tableIdForDocumentType` parameter. Hub callers pass the
29
+ * registry-backed lookup from `lib/config/rag-table-config`;
30
+ * embedders that don't supply one fall back to
31
+ * `defaultTableIdForDocumentType` (the lib-baked map covering
32
+ * every documentType currently registered in the hub) so the
33
+ * `displayRef` / `discussRef` Ask + Display buttons WORK out of
34
+ * the box. Override only when you have polymorphic / per-tenant
35
+ * types the default doesn't cover.
36
+ *
37
+ * Wire format and SSE-parser logic mirror the hub original byte-for-byte
38
+ * so server-side and client-side stay in lockstep across the migration.
39
+ */
40
+
41
+ import { useState, useEffect, useCallback, useMemo, useRef, type MutableRefObject } from 'react'
42
+ import { useChat, type Message, type StreamFnExtraOptions } from './use-chat'
43
+ import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'
44
+ import type { ChatRef } from '../chat-ref.types'
45
+ import { buildChatRefKey } from '../types/chat.types'
46
+ import type { MessageSegment } from '../types/message.types'
47
+ import { fetchSlashCommands, type SlashCommandSummary } from './use-slash-commands'
48
+ import { getChatProxyAuth } from '../utils/chat-proxy-auth-storage'
49
+ import { chatAuthedFetch } from '../utils/chat-authed-fetch'
50
+ import { parseScrollAnchor, type ScrollAnchor } from '../utils/scroll-anchor'
51
+ import { AUTO_CONTINUATION_DIRECTIVE_PREFIX } from '../utils/auto-continuation-directive'
52
+ import { flattenAssistantContent } from '../utils/flatten-assistant-content'
53
+ import { sanitizeTitleForChat } from '../utils/slash-dispatch-utils'
54
+ import { defaultTableIdForDocumentType } from '../utils/source-icons'
55
+ import type {
56
+ UnifiedChatState,
57
+ UnifiedSendMessageOptions,
58
+ StreamingPhase,
59
+ } from '../types/unified-chat-state.types'
60
+
61
+ // =============================================================================
62
+ // Public types
63
+ // =============================================================================
64
+
65
+ /** Source identifier — opaque string ID (registry lookup happens in the
66
+ * hub-side platform-utils, not in lib). */
67
+ export type DocSource = string
68
+
69
+ export interface ChatSource {
70
+ index: number
71
+ name: string
72
+ path: string
73
+ documentType: string
74
+ externalUrl?: string
75
+ /** Platform that owns the destination at `externalUrl`. */
76
+ targetPlatform?: string | null
77
+ /** Primary-key value for single-row chips. */
78
+ id?: string
79
+ /** Per-row items for grouped chips. */
80
+ items?: Array<{
81
+ id: string
82
+ documentType: string
83
+ name: string
84
+ externalUrl?: string
85
+ targetPlatform?: string | null
86
+ /** In-app doc-tree path for markdown / data-room-doc rows so the
87
+ * grouped chip's anchor can trigger an in-page doc-tree swap via
88
+ * `handleChatNavClick` (parity with single-row chips + cards). */
89
+ path?: string | null
90
+ }>
91
+ /** RagTableConfig.id for this source. */
92
+ sourceRepo?: string
93
+ /** Optional display label override returned by the chat API. */
94
+ label?: string
95
+ }
96
+
97
+ export interface DocChatMessage {
98
+ id: string
99
+ role: 'user' | 'assistant'
100
+ /** String form for legacy callers that just want the answer text; structured
101
+ * segments include thinking blocks too, which the lib's
102
+ * ChatMessageEnhanced renders as <ThinkingDisplay> cards. */
103
+ content: string
104
+ /** Structured segments. When set, callers should prefer this over `content`. */
105
+ segments?: MessageSegment[]
106
+ sources?: ChatSource[]
107
+ /** Per-row refs for inline object-card rendering. Keyed by
108
+ * `<documentType>:<primaryKey>`. Populated for assistant messages only. */
109
+ chatRefs?: Record<string, ChatRef>
110
+ /** Per-message viewport-positioning hint emitted by the server. */
111
+ scrollAnchor?: ScrollAnchor
112
+ /** When true the message is part of the conversation history but is
113
+ * NOT rendered in the chat UI. */
114
+ hidden?: boolean
115
+ }
116
+
117
+ // `StreamingPhase` is unified across transports — re-exported here to
118
+ // preserve the legacy import path. Canonical home is now
119
+ // `types/unified-chat-state.types.ts`.
120
+ export type { StreamingPhase } from '../types/unified-chat-state.types'
121
+
122
+ /** Per-turn metadata extracted from the streamed metadata frame. */
123
+ export interface ChatTurnMeta {
124
+ /** Provider key recognized by `<ModelDisplay>` for icon
125
+ * selection: 'anthropic' | 'openai' | 'google' (case-insensitive). */
126
+ provider: string | null
127
+ modelLabel: string | null
128
+ contextWindowMaxTokens: number | null
129
+ /** Input tokens (from message_start). Includes cached tokens. */
130
+ inputTokens: number | null
131
+ /** Output tokens (from message_delta). Only known after stream end. */
132
+ outputTokens: number | null
133
+ /** Cache hit % (read / total-input × 100). Only known after stream end. */
134
+ cacheHitRatePct: number | null
135
+ /** Cross-call usage breakdown extracted from the trailing usage frame. */
136
+ breakdown: {
137
+ haikuRewriter?: { input: number; output: number }
138
+ haikuClassifier?: { input: number; output: number }
139
+ haikuSummarizer?: { input: number; output: number }
140
+ routedAnswer?: { model: string; complexity: string; thinkingBudget: number }
141
+ } | null
142
+ /** Per-message viewport-positioning hint. */
143
+ scrollAnchor: ScrollAnchor | null
144
+ /** Routing decision from the server's `decideRoute`. */
145
+ routedComplexity: string | null
146
+ routedThinkingBudget: number | null
147
+ }
148
+
149
+ /**
150
+ * Optional dependency-injection options for `useSseChatAdapter`.
151
+ *
152
+ * - `tableIdForDocumentType` — looks up the RagTableConfig.id for an
153
+ * LLM document type. Used by `displayRef` + `discussRef` to translate
154
+ * an inline-card click into a server-side entity-id filter.
155
+ *
156
+ * **Defaults to `defaultTableIdForDocumentType` from
157
+ * `src/utils/source-icons.ts`** — a lib-baked map covering every
158
+ * documentType currently registered in the hub's RAG_TABLE_CONFIGS.
159
+ * This makes Ask / Display work out of the box in embedders without
160
+ * any callback wiring.
161
+ *
162
+ * Override only when you have polymorphic / per-tenant document
163
+ * types that map to a different tableId than the default
164
+ * (hub-canonical) registry — pass your own `(docType) => tableId`
165
+ * callback and it wins over the default.
166
+ */
167
+ export interface UseSseChatAdapterOptions {
168
+ tableIdForDocumentType?: (documentType: string) => string | null
169
+ }
170
+
171
+ // =============================================================================
172
+ // Internal helpers
173
+ // =============================================================================
174
+
175
+ /** Single source of truth for a fresh `ChatTurnMeta` row. */
176
+ function createEmptyTurnMeta(): ChatTurnMeta {
177
+ return {
178
+ provider: null,
179
+ modelLabel: null,
180
+ contextWindowMaxTokens: null,
181
+ inputTokens: null,
182
+ outputTokens: null,
183
+ cacheHitRatePct: null,
184
+ breakdown: null,
185
+ scrollAnchor: null,
186
+ routedComplexity: null,
187
+ routedThinkingBudget: null,
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Escape `<` so the lib's `SimpleMarkdownRenderer` (which uses `rehypeRaw`
193
+ * to pass HTML through) doesn't treat XML-like tokens in Claude's
194
+ * thinking output as React components. Escaping `<` → `&lt;` preserves
195
+ * the visible character without breaking blockquote `>` markers.
196
+ */
197
+ function escapeThinkingTags(text: string): string {
198
+ return text.replace(/</g, '&lt;')
199
+ }
200
+
201
+ function createDocStreamFn(
202
+ source: DocSource,
203
+ endpoints: { chatStreamUrl: string; approvalToolUrl: string },
204
+ messagesRef: MutableRefObject<Message[]>,
205
+ sourcesMapRef: MutableRefObject<Map<number, ChatSource[]>>,
206
+ refsMapRef: MutableRefObject<Map<number, Record<string, ChatRef>>>,
207
+ metaMapRef: MutableRefObject<Map<number, ChatTurnMeta>>,
208
+ setStreamingPhase: (phase: StreamingPhase) => void,
209
+ bumpMetaTick: () => void,
210
+ sendCountRef: MutableRefObject<number>,
211
+ ) {
212
+ // CRITICAL: the decoder + buffer MUST live INSIDE the returned async-
213
+ // generator function (per-call closure), NOT at the factory level. A
214
+ // rapid send-stop-send sequence with hook-level state would feed the
215
+ // second stream's first chunk into the first stream's tail buffer,
216
+ // corrupting metadata-frame parsing.
217
+ return async function* (
218
+ message: string,
219
+ signal?: AbortSignal,
220
+ extra?: StreamFnExtraOptions,
221
+ ): AsyncGenerator<MessageSegment> {
222
+ const currentMessages = messagesRef.current || []
223
+ // Filter `hidden:true` messages out of the API history. The approval-
224
+ // action turn injects a hidden user message with `content=''`.
225
+ // `flattenAssistantContent` joins text-segment arrays into a single
226
+ // string so the server sees the receipt + auto-continuation Qs.
227
+ const apiMessages = [
228
+ ...currentMessages
229
+ .filter((m) => (m.role === 'user' || m.role === 'assistant') && !m.hidden)
230
+ .map((m) => ({
231
+ role: m.role,
232
+ content:
233
+ typeof m.content === 'string' ? m.content : flattenAssistantContent(m.content),
234
+ })),
235
+ { role: 'user', content: message },
236
+ ]
237
+
238
+ // URL + body branch — approvalAction routes to the approval-tool
239
+ // endpoint, the standard chat path routes to the chat-stream endpoint.
240
+ const targetPath = extra?.approvalAction
241
+ ? endpoints.approvalToolUrl
242
+ : endpoints.chatStreamUrl
243
+ // `source` is INTENTIONALLY NOT in the body. The chat route resolves
244
+ // it server-side via its own platform-detection — tamper-proof binding
245
+ // so a client on one platform can't POST a different platform's
246
+ // conversation.
247
+ const requestBody = extra?.approvalAction
248
+ ? {
249
+ proposal_id: extra.approvalAction.proposalId,
250
+ action: extra.approvalAction.action,
251
+ messages: currentMessages
252
+ .filter((m) => (m.role === 'user' || m.role === 'assistant') && !m.hidden)
253
+ .map((m) => ({
254
+ role: m.role,
255
+ content:
256
+ typeof m.content === 'string'
257
+ ? m.content
258
+ : flattenAssistantContent(m.content),
259
+ })),
260
+ }
261
+ : {
262
+ messages: apiMessages,
263
+ ...(extra?.commandOverride ? { commandOverride: extra.commandOverride } : {}),
264
+ ...(extra?.pendingAttachments && extra.pendingAttachments.length > 0
265
+ ? { pendingAttachments: extra.pendingAttachments }
266
+ : {}),
267
+ }
268
+ // `chatAuthedFetch` carries the bearer-act-as headers (+ Supabase
269
+ // session cookies) — same wrapper `use-chat-attachments` and
270
+ // `use-chat-identity` use, so all three chat-side fetch sites share
271
+ // one identity-propagation primitive.
272
+ const response = await chatAuthedFetch(targetPath, {
273
+ method: 'POST',
274
+ body: JSON.stringify(requestBody),
275
+ signal,
276
+ })
277
+
278
+ if (!response.ok) {
279
+ throw new Error(`Chat request failed: ${response.status}`)
280
+ }
281
+
282
+ const reader = response.body?.getReader()
283
+ if (!reader) throw new Error('No response body')
284
+
285
+ const decoder = new TextDecoder()
286
+ let buffer = ''
287
+ let inText = false
288
+ // Live thinking accumulator — coalesced to ~20fps to avoid a re-render
289
+ // storm (Anthropic emits 30-60 deltas/sec).
290
+ let thinkingAccum = ''
291
+ let lastThinkingYieldTime = 0
292
+ let pendingThinkingYield = false
293
+ const THINKING_YIELD_INTERVAL_MS = 50
294
+ let trailerBuffer = ''
295
+ let inTrailer = false
296
+
297
+ const sendIdx = sendCountRef.current - 1
298
+ while (true) {
299
+ const { done, value } = await reader.read()
300
+ if (done) break
301
+
302
+ const chunk = decoder.decode(value, { stream: true })
303
+
304
+ if (!inText) {
305
+ buffer += chunk
306
+ while (!inText) {
307
+ const recIdx = buffer.indexOf('\x1E')
308
+ const nullIdx = buffer.indexOf('\0')
309
+ if (recIdx !== -1 && (nullIdx === -1 || recIdx < nullIdx)) {
310
+ if (pendingThinkingYield) {
311
+ pendingThinkingYield = false
312
+ yield { type: 'thinking', text: escapeThinkingTags(thinkingAccum) }
313
+ }
314
+ inText = true
315
+ setStreamingPhase('streaming')
316
+ const after = buffer.slice(recIdx + 1)
317
+ buffer = ''
318
+ if (after) {
319
+ // The `after` slice may ALSO contain the `\x1F` trailing-
320
+ // usage sentinel — common for fixed-answer responses where
321
+ // the whole frame sequence arrives in ONE TCP chunk.
322
+ const unitIdx = after.indexOf('\x1F')
323
+ if (unitIdx === -1) {
324
+ yield { type: 'text', text: after }
325
+ } else {
326
+ const textBefore = after.slice(0, unitIdx)
327
+ const trailerAfter = after.slice(unitIdx + 1)
328
+ if (textBefore) {
329
+ yield { type: 'text', text: textBefore }
330
+ }
331
+ inTrailer = true
332
+ trailerBuffer = trailerAfter
333
+ }
334
+ }
335
+ break
336
+ }
337
+ if (nullIdx === -1) break // need more bytes
338
+ const metaStr = buffer.slice(0, nullIdx)
339
+ const remaining = buffer.slice(nullIdx + 1)
340
+ let meta: any
341
+ try {
342
+ meta = JSON.parse(metaStr)
343
+ } catch {
344
+ // Not JSON — start of answer body.
345
+ inText = true
346
+ setStreamingPhase('streaming')
347
+ if (buffer.length > 0) {
348
+ yield { type: 'text', text: buffer }
349
+ buffer = ''
350
+ }
351
+ break
352
+ }
353
+ if (meta.status === 'thinking') {
354
+ setStreamingPhase('thinking')
355
+ } else if (meta.kind === 'thinking-delta' && typeof meta.text === 'string') {
356
+ thinkingAccum += meta.text
357
+ const now = Date.now()
358
+ if (now - lastThinkingYieldTime >= THINKING_YIELD_INTERVAL_MS) {
359
+ lastThinkingYieldTime = now
360
+ pendingThinkingYield = false
361
+ yield { type: 'thinking', text: escapeThinkingTags(thinkingAccum) }
362
+ } else {
363
+ pendingThinkingYield = true
364
+ }
365
+ } else if (meta.kind === 'usage' && meta.stage === 'start') {
366
+ mergeTurnMeta(metaMapRef, sendIdx, {
367
+ inputTokens: meta.input_tokens ?? null,
368
+ })
369
+ bumpMetaTick()
370
+ } else if (
371
+ meta.kind === 'decision_resolved' &&
372
+ typeof meta.action === 'string'
373
+ ) {
374
+ // Server-driven post-approve / post-reject frame.
375
+ const action = meta.action === 'rejected' ? 'rejected' : 'approved'
376
+ const toolName =
377
+ typeof meta.tool_name === 'string' ? meta.tool_name : undefined
378
+ const result = (meta.result ?? null) as
379
+ | { ticket_id?: string; status?: string | null; mirror_synced?: boolean }
380
+ | null
381
+ const card = (meta.card ?? null) as
382
+ | { type?: string; marker?: string; ref?: ChatRef }
383
+ | null
384
+ if (card?.ref?.id && card?.type) {
385
+ const existing = refsMapRef.current.get(sendIdx) ?? {}
386
+ const key = buildChatRefKey(card.type, card.ref.id)
387
+ refsMapRef.current.set(sendIdx, { ...existing, [key]: card.ref })
388
+ bumpMetaTick()
389
+ }
390
+ yield {
391
+ type: 'decision_resolved',
392
+ action,
393
+ ok: meta.ok === true,
394
+ willAutoContinue: meta.willAutoContinue === true,
395
+ ...(toolName ? { toolName } : {}),
396
+ ...(result ? { result } : {}),
397
+ ...(card?.marker ? { marker: card.marker } : {}),
398
+ ...(card?.ref ? { cardRef: card.ref } : {}),
399
+ ...(typeof meta.receiptText === 'string'
400
+ ? { receiptText: meta.receiptText }
401
+ : {}),
402
+ proposalId:
403
+ typeof meta.proposalId === 'string' ? meta.proposalId : undefined,
404
+ } as any
405
+ } else if (meta.kind === 'approval_request' && meta.proposalId) {
406
+ // The model called a write tool. Server persisted the
407
+ // proposal and sent us a CARD-READY payload.
408
+ const proposalId = String(meta.proposalId)
409
+ const toolName = String(meta.toolName ?? 'tool')
410
+ const headline =
411
+ typeof meta.title === 'string' && meta.title.length > 0
412
+ ? meta.title
413
+ : toolName
414
+ const rawFields = Array.isArray(meta.fields)
415
+ ? (meta.fields as Array<{ label?: string; value?: string }>)
416
+ : []
417
+ const fields: Array<{ label: string; value: string }> = []
418
+ for (const f of rawFields) {
419
+ if (!f || !f.label || !f.value) continue
420
+ fields.push({ label: f.label, value: f.value })
421
+ }
422
+ yield {
423
+ type: 'approval_request',
424
+ data: {
425
+ command: headline,
426
+ fields,
427
+ requestId: proposalId,
428
+ approvalType: toolName,
429
+ },
430
+ status: 'pending',
431
+ } as any
432
+ } else if (meta.kind === 'text-leading' && typeof meta.text === 'string') {
433
+ // Model's preamble text (the prose written BEFORE the tool_use
434
+ // block) — emitted as a standalone text segment.
435
+ yield { type: 'text', text: meta.text }
436
+ } else if (meta.kind === 'tool_error') {
437
+ const msg =
438
+ typeof meta.message === 'string' && meta.message.length > 0
439
+ ? meta.message
440
+ : 'Could not complete the requested action right now.'
441
+ yield {
442
+ type: 'text',
443
+ text: `⚠️ ${msg}`,
444
+ } as any
445
+ } else if (meta.kind === 'routing') {
446
+ if (typeof meta.routedComplexity === 'string') {
447
+ mergeTurnMeta(metaMapRef, sendIdx, {
448
+ routedComplexity: meta.routedComplexity,
449
+ routedThinkingBudget:
450
+ typeof meta.routedThinkingBudget === 'number'
451
+ ? meta.routedThinkingBudget
452
+ : null,
453
+ })
454
+ bumpMetaTick()
455
+ }
456
+ } else {
457
+ if (meta.sources) {
458
+ sourcesMapRef.current.set(sendIdx, meta.sources)
459
+ }
460
+ // Per-row refs for inline object cards.
461
+ if (meta.refs && typeof meta.refs === 'object') {
462
+ refsMapRef.current.set(sendIdx, meta.refs as Record<string, ChatRef>)
463
+ bumpMetaTick()
464
+ }
465
+ if (
466
+ meta.modelLabel ||
467
+ meta.contextWindowMaxTokens ||
468
+ meta.provider ||
469
+ meta.model
470
+ ) {
471
+ mergeTurnMeta(metaMapRef, sendIdx, {
472
+ provider: meta.provider ?? null,
473
+ modelLabel: meta.modelLabel ?? null,
474
+ contextWindowMaxTokens: meta.contextWindowMaxTokens ?? null,
475
+ })
476
+ bumpMetaTick()
477
+ }
478
+ const parsedAnchor = parseScrollAnchor(meta.scrollAnchor)
479
+ if (parsedAnchor !== null) {
480
+ mergeTurnMeta(metaMapRef, sendIdx, { scrollAnchor: parsedAnchor })
481
+ bumpMetaTick()
482
+ }
483
+ }
484
+ buffer = remaining
485
+ }
486
+ } else if (inTrailer) {
487
+ trailerBuffer += chunk
488
+ } else {
489
+ setStreamingPhase('streaming')
490
+ // Trailing usage frame uses \x1F (Unit Separator) as a sentinel.
491
+ const sepIdx = chunk.indexOf('\x1F')
492
+ if (sepIdx === -1) {
493
+ yield { type: 'text', text: chunk }
494
+ } else {
495
+ const before = chunk.slice(0, sepIdx)
496
+ const after = chunk.slice(sepIdx + 1)
497
+ if (before) yield { type: 'text', text: before }
498
+ inTrailer = true
499
+ trailerBuffer = after
500
+ }
501
+ }
502
+ }
503
+ // Stream ended. Flush any pending thinking text that didn't make it
504
+ // out before the throttle window expired.
505
+ if (pendingThinkingYield) {
506
+ pendingThinkingYield = false
507
+ yield { type: 'thinking', text: escapeThinkingTags(thinkingAccum) }
508
+ }
509
+ // Parse trailer if present.
510
+ if (trailerBuffer.length > 0) {
511
+ try {
512
+ const meta = JSON.parse(trailerBuffer)
513
+ if (
514
+ meta.kind === 'usage' &&
515
+ (meta.stage === 'end' || meta.stage === 'display')
516
+ ) {
517
+ const breakdown =
518
+ meta.breakdown && typeof meta.breakdown === 'object'
519
+ ? {
520
+ haikuRewriter:
521
+ meta.breakdown.haikuRewriter &&
522
+ typeof meta.breakdown.haikuRewriter.input === 'number'
523
+ ? meta.breakdown.haikuRewriter
524
+ : undefined,
525
+ haikuClassifier:
526
+ meta.breakdown.haikuClassifier &&
527
+ typeof meta.breakdown.haikuClassifier.input === 'number'
528
+ ? meta.breakdown.haikuClassifier
529
+ : undefined,
530
+ haikuSummarizer:
531
+ meta.breakdown.haikuSummarizer &&
532
+ typeof meta.breakdown.haikuSummarizer.input === 'number'
533
+ ? meta.breakdown.haikuSummarizer
534
+ : undefined,
535
+ routedAnswer:
536
+ meta.breakdown.routedAnswer &&
537
+ typeof meta.breakdown.routedAnswer.model === 'string'
538
+ ? meta.breakdown.routedAnswer
539
+ : undefined,
540
+ }
541
+ : null
542
+ mergeTurnMeta(metaMapRef, sendIdx, {
543
+ inputTokens: meta.input_tokens ?? null,
544
+ outputTokens: meta.output_tokens ?? null,
545
+ cacheHitRatePct:
546
+ typeof meta.hit_rate_pct === 'number' ? meta.hit_rate_pct : null,
547
+ ...(breakdown ? { breakdown } : {}),
548
+ })
549
+ bumpMetaTick()
550
+ }
551
+ } catch {
552
+ // Malformed trailer — silently ignore.
553
+ }
554
+ }
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Merge partial fields into the per-turn meta map. Preserves existing
560
+ * non-null values so a leading frame's data isn't wiped by a later frame
561
+ * that didn't include it.
562
+ */
563
+ function mergeTurnMeta(
564
+ ref: MutableRefObject<Map<number, ChatTurnMeta>>,
565
+ sendIdx: number,
566
+ partial: Partial<ChatTurnMeta>,
567
+ ): void {
568
+ const prev = ref.current.get(sendIdx) ?? createEmptyTurnMeta()
569
+ const filtered = Object.fromEntries(
570
+ Object.entries(partial).filter(([, v]) => v !== undefined && v !== null),
571
+ ) as Partial<ChatTurnMeta>
572
+ ref.current.set(sendIdx, { ...prev, ...filtered })
573
+ }
574
+
575
+ // =============================================================================
576
+ // localStorage persistence
577
+ // =============================================================================
578
+
579
+ const CHAT_STORAGE_VERSION = 1
580
+
581
+ /** Storage key — includes the proxy-auth impersonation email when
582
+ * present so each impersonated customer keeps a SEPARATE chat history. */
583
+ const chatStorageKey = (source: DocSource): string => {
584
+ const base = `mingo-chat-${source}-v${CHAT_STORAGE_VERSION}`
585
+ const auth = getChatProxyAuth()
586
+ if (auth?.email) {
587
+ return `${base}-u-${encodeURIComponent(auth.email.toLowerCase())}`
588
+ }
589
+ return base
590
+ }
591
+
592
+ /** Sweep stale per-user chat-history keys. Drops any key whose email
593
+ * differs from the CURRENT proxy-auth identity. */
594
+ function pruneStaleChatStorage(source: DocSource): void {
595
+ if (typeof window === 'undefined') return
596
+ try {
597
+ const currentKey = chatStorageKey(source)
598
+ const prefix = `mingo-chat-${source}-v${CHAT_STORAGE_VERSION}-u-`
599
+ const legacy = `mingo-chat-${source}-v${CHAT_STORAGE_VERSION}`
600
+ const toRemove: string[] = []
601
+ for (let i = 0; i < window.localStorage.length; i++) {
602
+ const k = window.localStorage.key(i)
603
+ if (!k) continue
604
+ if (!k.startsWith(prefix)) continue
605
+ if (k === currentKey) continue
606
+ if (k === legacy) continue
607
+ toRemove.push(k)
608
+ }
609
+ for (const k of toRemove) {
610
+ window.localStorage.removeItem(k)
611
+ }
612
+ } catch {
613
+ // localStorage access blocked (Safari private mode etc.) — non-fatal.
614
+ }
615
+ }
616
+
617
+ interface PersistedChatState {
618
+ messages: Message[]
619
+ sources: Array<[number, ChatSource[]]>
620
+ /** Per-turn refs for inline object cards. */
621
+ refs?: Array<[number, Record<string, ChatRef>]>
622
+ sendCount: number
623
+ }
624
+
625
+ function loadPersistedChat(source: DocSource): PersistedChatState | null {
626
+ if (typeof window === 'undefined') return null
627
+ try {
628
+ const raw = window.localStorage.getItem(chatStorageKey(source))
629
+ if (!raw) return null
630
+ const parsed = JSON.parse(raw) as PersistedChatState
631
+ if (!parsed || !Array.isArray(parsed.messages)) return null
632
+ // Rehydrate Date objects + run forward-compat migrations.
633
+ for (const m of parsed.messages) {
634
+ if (typeof m.timestamp === 'string') m.timestamp = new Date(m.timestamp)
635
+ // Forward-migration for auto-continuation directive bubbles.
636
+ if (
637
+ m.role === 'user' &&
638
+ !m.hidden &&
639
+ typeof m.content === 'string' &&
640
+ m.content.startsWith(AUTO_CONTINUATION_DIRECTIVE_PREFIX)
641
+ ) {
642
+ m.hidden = true
643
+ }
644
+ }
645
+ return parsed
646
+ } catch {
647
+ return null
648
+ }
649
+ }
650
+
651
+ function savePersistedChat(source: DocSource, state: PersistedChatState) {
652
+ if (typeof window === 'undefined') return
653
+ try {
654
+ window.localStorage.setItem(chatStorageKey(source), JSON.stringify(state))
655
+ } catch {
656
+ // Quota exceeded or private mode — silently drop.
657
+ }
658
+ }
659
+
660
+ // =============================================================================
661
+ // useSseChatAdapter — public hook
662
+ // =============================================================================
663
+
664
+ /**
665
+ * Stream-driven AI chat hook. Returns the message list, send/stop/clear
666
+ * controls, and per-turn metadata (model name, token counts, routing
667
+ * decision) the lib's `<ChatContainer>` consumes.
668
+ *
669
+ * Source identity comes from `useRequiredChatRuntime().source` — no
670
+ * parameter. Throws if `runtime.source` is empty.
671
+ */
672
+ export function useSseChatAdapter(
673
+ options?: UseSseChatAdapterOptions,
674
+ ): UnifiedChatState {
675
+ // Chat-specific code REQUIRES a runtime — the lib's `<HubRuntimeProvider>`
676
+ // (hub) / embedder's provider must wrap the tree.
677
+ const runtime = useRequiredChatRuntime()
678
+ const source = runtime.source
679
+ if (!source) {
680
+ throw new Error(
681
+ '[useSseChatAdapter] runtime.source is required — got empty string. ' +
682
+ 'Wire `source` on your <ChatRuntimeContext.Provider value={...}>. ' +
683
+ 'Hub default: <HubRuntimeProvider source={currentPlatform()}>; ' +
684
+ 'embedded apps: pass your own platform/tenant identifier.',
685
+ )
686
+ }
687
+ // Fall back to the lib-baked hub-canonical map when the embedder
688
+ // didn't supply an override. Keeps Ask + Display working in any
689
+ // mount that doesn't have a custom documentType registry. Embedders
690
+ // with polymorphic / per-tenant types pass their own callback and it
691
+ // wins.
692
+ const tableIdForDocumentType =
693
+ options?.tableIdForDocumentType ?? defaultTableIdForDocumentType
694
+
695
+ // Restore persisted state once on mount.
696
+ const persistedRef = useRef<PersistedChatState | null>(null)
697
+ if (persistedRef.current === null) {
698
+ pruneStaleChatStorage(source)
699
+ persistedRef.current =
700
+ loadPersistedChat(source) || { messages: [], sources: [], sendCount: 0 }
701
+ }
702
+
703
+ const sourcesMapRef = useRef<Map<number, ChatSource[]>>(
704
+ new Map(persistedRef.current.sources),
705
+ )
706
+ const refsMapRef = useRef<Map<number, Record<string, ChatRef>>>(
707
+ new Map(persistedRef.current.refs ?? []),
708
+ )
709
+ const metaMapRef = useRef<Map<number, ChatTurnMeta>>(new Map())
710
+ const messagesRef = useRef<Message[]>(persistedRef.current.messages)
711
+ const sendCountRef = useRef(persistedRef.current.sendCount)
712
+ const [streamingPhase, setStreamingPhase] = useState<StreamingPhase>('idle')
713
+ const [metaTick, setMetaTick] = useState(0)
714
+ const bumpMetaTick = useCallback(() => setMetaTick((t) => t + 1), [])
715
+
716
+ const streamFn = useMemo(
717
+ () =>
718
+ createDocStreamFn(
719
+ source,
720
+ {
721
+ chatStreamUrl: runtime.endpoints.chatStreamUrl,
722
+ approvalToolUrl: runtime.endpoints.approvalToolUrl,
723
+ },
724
+ messagesRef,
725
+ sourcesMapRef,
726
+ refsMapRef,
727
+ metaMapRef,
728
+ setStreamingPhase,
729
+ bumpMetaTick,
730
+ sendCountRef,
731
+ ),
732
+ [
733
+ source,
734
+ runtime.endpoints.chatStreamUrl,
735
+ runtime.endpoints.approvalToolUrl,
736
+ bumpMetaTick,
737
+ ],
738
+ )
739
+
740
+ // Per-source tableId → slash-command-id lookup. Hydrated from the
741
+ // commands endpoint on mount. Used by `displayRef` to translate an
742
+ // inline-card's Display click into a `/<cmd> display "<value>"`
743
+ // invocation, picking the canonical command for the row's RAG table.
744
+ const [cmdIdByTableId, setCmdIdByTableId] = useState<Map<string, string>>(
745
+ () => new Map(),
746
+ )
747
+ const commandsUrl = runtime.endpoints.commandsUrl
748
+ useEffect(() => {
749
+ let cancelled = false
750
+ const ctrl = new AbortController()
751
+ fetchSlashCommands('', ctrl.signal, commandsUrl)
752
+ .then((commands) => {
753
+ if (cancelled) return
754
+ const buckets = new Map<string, SlashCommandSummary[]>()
755
+ for (const cmd of commands) {
756
+ if (!cmd.primarySourceId) continue
757
+ const arr = buckets.get(cmd.primarySourceId) ?? []
758
+ arr.push(cmd)
759
+ buckets.set(cmd.primarySourceId, arr)
760
+ }
761
+ const map = new Map<string, string>()
762
+ for (const [tableId, cmds] of buckets) {
763
+ const display = cmds.find((c) => c.actions.some((a) => a.id === 'display'))
764
+ const picked =
765
+ display ??
766
+ [...cmds].sort((a, b) => {
767
+ const ao = a.displayOrder ?? Number.POSITIVE_INFINITY
768
+ const bo = b.displayOrder ?? Number.POSITIVE_INFINITY
769
+ return ao - bo
770
+ })[0]
771
+ if (picked) map.set(tableId, picked.id)
772
+ }
773
+ setCmdIdByTableId(map)
774
+ })
775
+ .catch((err) => {
776
+ if (!cancelled && (err as Error)?.name !== 'AbortError') {
777
+ console.warn(
778
+ '[useSseChatAdapter] failed to fetch slash commands for displayRef:',
779
+ err,
780
+ )
781
+ }
782
+ })
783
+ return () => {
784
+ cancelled = true
785
+ ctrl.abort()
786
+ }
787
+ }, [source, commandsUrl])
788
+
789
+ // Persist on every messages change. Sources + sendCount live in refs,
790
+ // so we read their current values at write time.
791
+ const persist = useCallback(
792
+ (nextMessages: Message[]) => {
793
+ savePersistedChat(source, {
794
+ messages: nextMessages,
795
+ sources: Array.from(sourcesMapRef.current.entries()),
796
+ refs: Array.from(refsMapRef.current.entries()),
797
+ sendCount: sendCountRef.current,
798
+ })
799
+ },
800
+ [source],
801
+ )
802
+
803
+ const {
804
+ messages,
805
+ isTyping,
806
+ isStreaming,
807
+ sendMessage: chatSendMessage,
808
+ stopMessage: chatStopMessage,
809
+ clearMessages: chatClearMessages,
810
+ hasMessages,
811
+ } = useChat({
812
+ useMock: false,
813
+ assistantName: 'Mingo AI',
814
+ streamFn,
815
+ initialMessages: persistedRef.current.messages,
816
+ onMessagesChange: persist,
817
+ })
818
+
819
+ messagesRef.current = messages
820
+
821
+ // Index sources/refs/scrollAnchor by USER-SEND count (`sendIdx`), not
822
+ // by assistant-message count. Each user send produces exactly ONE
823
+ // refsMapRef entry server-side, but it can produce MULTIPLE assistant
824
+ // messages on the client (main RAG reply + post-approve card +
825
+ // auto-continuation prose). Counting USER sends and mapping every
826
+ // following assistant message to that index keeps the lookup stable.
827
+ let sendIdx = -1
828
+ const docMessages: DocChatMessage[] = messages.map((m) => {
829
+ const segments = Array.isArray(m.content) ? (m.content as MessageSegment[]) : undefined
830
+ const content =
831
+ typeof m.content === 'string'
832
+ ? m.content
833
+ : Array.isArray(m.content)
834
+ ? m.content
835
+ .filter((s) => s.type === 'text')
836
+ .map((s) => (s as { type: 'text'; text: string }).text)
837
+ .join('')
838
+ : ''
839
+
840
+ let sources: ChatSource[] | undefined
841
+ let chatRefs: Record<string, ChatRef> | undefined
842
+ let scrollAnchor: ScrollAnchor | undefined
843
+ if (m.role === 'user' && !m.hidden) {
844
+ sendIdx++
845
+ }
846
+ if (m.role === 'assistant') {
847
+ const lookupIdx = sendIdx >= 0 ? sendIdx : 0
848
+ sources = sourcesMapRef.current.get(lookupIdx)
849
+ // The receipt path stamps `chatRefs` directly onto the assistant
850
+ // message in useChat. Prefer that message-bound copy when present;
851
+ // falls back to per-turn refs map.
852
+ chatRefs = m.chatRefs ?? refsMapRef.current.get(lookupIdx)
853
+ scrollAnchor =
854
+ (metaMapRef.current.get(lookupIdx)?.scrollAnchor as ScrollAnchor | null) ??
855
+ undefined
856
+ }
857
+
858
+ return {
859
+ id: m.id,
860
+ role: m.role as 'user' | 'assistant',
861
+ content,
862
+ ...(segments ? { segments } : {}),
863
+ ...(sources ? { sources } : {}),
864
+ ...(chatRefs ? { chatRefs } : {}),
865
+ ...(scrollAnchor ? { scrollAnchor } : {}),
866
+ ...(m.hidden ? { hidden: true } : {}),
867
+ }
868
+ })
869
+
870
+ /**
871
+ * Internal sendMessage options — union of the public
872
+ * `UnifiedSendMessageOptions` (semantic fields: `hidden`, `attachments`)
873
+ * and SSE-only internal extras (`commandOverride`, `approvalAction`)
874
+ * set by `discussRef` / `displayRef` / post-approval continuation.
875
+ *
876
+ * The public `UnifiedChatState.sendMessage` accepts only the narrow
877
+ * unified shape; this hook accepts the wider one because internal
878
+ * callers need it. Function-param contravariance makes this assignable.
879
+ */
880
+ type InternalSendMessageOptions = UnifiedSendMessageOptions &
881
+ Pick<StreamFnExtraOptions, 'commandOverride' | 'approvalAction'>
882
+
883
+ const sendMessage = useCallback(
884
+ async (
885
+ text: string,
886
+ options?: InternalSendMessageOptions,
887
+ ): Promise<void> => {
888
+ const {
889
+ hidden,
890
+ attachments,
891
+ commandOverride,
892
+ approvalAction,
893
+ } = options ?? {}
894
+ const sseExtras: StreamFnExtraOptions = {
895
+ ...(commandOverride ? { commandOverride } : {}),
896
+ ...(approvalAction ? { approvalAction } : {}),
897
+ ...(attachments && attachments.length > 0
898
+ ? { pendingAttachments: attachments }
899
+ : {}),
900
+ }
901
+ sendCountRef.current++
902
+ setStreamingPhase('thinking')
903
+ await chatSendMessage(text, sseExtras, hidden ? { hidden } : undefined)
904
+ },
905
+ [chatSendMessage],
906
+ )
907
+
908
+ /**
909
+ * "Display" callback for inline cards whose registry entry sets
910
+ * `displayAction: true`. Parallel to `discussRef` but emits a
911
+ * `/<cmd> display "<title>"` slash command instead of the Discuss
912
+ * prose. Resolution of the slash command id for the ref's documentType
913
+ * happens via the `cmdIdByTableId` map hydrated from the commands
914
+ * endpoint.
915
+ *
916
+ * `tableIdForDocumentType` is always defined here — either the
917
+ * embedder-supplied override OR the lib-baked
918
+ * `defaultTableIdForDocumentType` fallback. Unknown documentTypes
919
+ * still short-circuit gracefully via the `tableId === null` check
920
+ * below.
921
+ */
922
+ const displayRef = useCallback(
923
+ (reference: ChatRef) => {
924
+ const tableId = tableIdForDocumentType(reference.type)
925
+ if (!tableId) {
926
+ console.warn(
927
+ `[useSseChatAdapter] displayRef: no tableId for documentType="${reference.type}"; ignoring click`,
928
+ )
929
+ return
930
+ }
931
+ const cmdId = cmdIdByTableId.get(tableId)
932
+ if (!cmdId) {
933
+ console.warn(
934
+ `[useSseChatAdapter] displayRef: no slash command for tableId="${tableId}" source="${source}"; ignoring click`,
935
+ )
936
+ return
937
+ }
938
+ const refSlug =
939
+ typeof reference.metadata?.slug === 'string' &&
940
+ reference.metadata.slug.length > 0
941
+ ? reference.metadata.slug
942
+ : ''
943
+ const queryValue =
944
+ refSlug || sanitizeTitleForChat(reference.title) || reference.id
945
+ // Escape `\` BEFORE `"` so a trailing backslash can't smuggle a
946
+ // close-quote past parsers that honor JS-style escapes. Matches
947
+ // `formatSingularLookupInvocation`'s pattern in slash-dispatch-utils.
948
+ const escaped = queryValue
949
+ .replace(/\\/g, '\\\\')
950
+ .replace(/"/g, '\\"')
951
+ const text = `/${cmdId} display "${escaped}"`
952
+ sendMessage(text)
953
+ },
954
+ [sendMessage, source, cmdIdByTableId, tableIdForDocumentType],
955
+ )
956
+
957
+ /**
958
+ * "Discuss" affordance for ObjectCard. Synthesizes a natural-language
959
+ * prompt ("Tell me more about <title>"); the structured
960
+ * `commandOverride.entityIdFilter` is sent server-side via the request
961
+ * body so retrieval narrows to the named row.
962
+ *
963
+ * `tableIdForDocumentType` is always defined here — either the
964
+ * embedder-supplied override OR the lib-baked default. Unknown
965
+ * documentTypes still short-circuit via the `tableId === null` check.
966
+ */
967
+ const discussRef = useCallback(
968
+ (reference: ChatRef) => {
969
+ const tableId = tableIdForDocumentType(reference.type)
970
+ if (!tableId) {
971
+ console.warn(
972
+ `[useSseChatAdapter] discussRef: no tableId for documentType="${reference.type}"; ignoring click`,
973
+ )
974
+ return
975
+ }
976
+ const refId = (reference.id ?? '').trim()
977
+ if (!refId) {
978
+ console.warn(
979
+ `[useSseChatAdapter] discussRef: empty reference.id for type="${reference.type}"; ignoring click`,
980
+ )
981
+ return
982
+ }
983
+ // RETRIEVAL IS STRICTLY PRIMARY-KEY-DRIVEN. The visible prose
984
+ // ("Tell me more about <title>") is UX-only; retrieval narrows
985
+ // via `entityIdFilter` before the LLM is invoked.
986
+ const sanitizedTitle = sanitizeTitleForChat(reference.title)
987
+ const prompt = `Tell me more about ${sanitizedTitle || 'this item'}`
988
+ sendMessage(prompt, {
989
+ commandOverride: { entityIdFilter: { tableId, id: refId } },
990
+ })
991
+ },
992
+ [sendMessage, tableIdForDocumentType],
993
+ )
994
+
995
+ const stopMessage = useCallback(() => {
996
+ chatStopMessage()
997
+ setStreamingPhase('idle')
998
+ }, [chatStopMessage])
999
+
1000
+ const clearMessages = useCallback(() => {
1001
+ sourcesMapRef.current.clear()
1002
+ refsMapRef.current.clear()
1003
+ metaMapRef.current.clear()
1004
+ sendCountRef.current = 0
1005
+ setStreamingPhase('idle')
1006
+ // Force the latestMeta useMemo to recompute with the cleared map.
1007
+ bumpMetaTick()
1008
+ chatClearMessages()
1009
+ // Clear persisted state too so the next mount starts fresh.
1010
+ if (typeof window !== 'undefined') {
1011
+ try {
1012
+ window.localStorage.removeItem(chatStorageKey(source))
1013
+ } catch {}
1014
+ }
1015
+ }, [chatClearMessages, source, bumpMetaTick])
1016
+
1017
+ // Reset to idle whenever both flags drop off.
1018
+ useEffect(() => {
1019
+ if (!isTyping && !isStreaming && streamingPhase !== 'idle') {
1020
+ setStreamingPhase('idle')
1021
+ }
1022
+ }, [isTyping, isStreaming, streamingPhase])
1023
+
1024
+ // Resolve the active turn's metadata.
1025
+ const latestMeta = useMemo(
1026
+ () =>
1027
+ metaMapRef.current.get(sendCountRef.current - 1) ??
1028
+ metaMapRef.current.get(sendCountRef.current - 2) ??
1029
+ null,
1030
+ [metaTick, streamingPhase],
1031
+ )
1032
+
1033
+ return {
1034
+ messages: docMessages,
1035
+ isLoading: isTyping || isStreaming,
1036
+ sendMessage,
1037
+ /** "Discuss" affordance for ObjectCard. */
1038
+ discussRef,
1039
+ /** "Display" counterpart of `discussRef`. */
1040
+ displayRef,
1041
+ stopMessage,
1042
+ clearMessages,
1043
+ streamingPhase,
1044
+ /** Provider key for the lib's `<ModelDisplay>` icon. */
1045
+ currentProvider: latestMeta?.provider ?? null,
1046
+ currentModelLabel: latestMeta?.modelLabel ?? null,
1047
+ currentContextWindowMaxTokens: latestMeta?.contextWindowMaxTokens ?? null,
1048
+ /** Input tokens (known after server's message_start frame; null until). */
1049
+ currentInputTokens: latestMeta?.inputTokens ?? null,
1050
+ /** Output tokens (known only after server's trailing usage frame). */
1051
+ currentOutputTokens: latestMeta?.outputTokens ?? null,
1052
+ /** Cache hit % (read / total-input × 100). null during streaming. */
1053
+ currentCacheHitRatePct: latestMeta?.cacheHitRatePct ?? null,
1054
+ /** Cross-call usage breakdown (Haiku rewriter/classifier/summarizer
1055
+ * token counts). null until the trailing usage frame lands. */
1056
+ currentUsageBreakdown: latestMeta?.breakdown ?? null,
1057
+ }
1058
+ }